diff --git a/.gitignore b/.gitignore index 858b4077e..c05794cbb 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ coverage/* dist/* -#TODO: Jshint is a dependency of the git hook which installs these files on install... +# TODO: Jshint is a dependency of the git hook which installs these files on install... .jshintignore .jshintrc @@ -16,3 +16,6 @@ build/* npm-debug.log stats.json + +# TODO: Remove when using proper jQuery loader +src/dev_jquery.js diff --git a/package.json b/package.json index 97d499da2..93c8f2cc9 100644 --- a/package.json +++ b/package.json @@ -48,8 +48,8 @@ "babel-loader": "^5.3.2", "classnames": "^2.1.3", "d2": "0.0.16", - "d2-flux": "^0.4.0", - "d2-ui": "0.0.1", + "d2-flux": "^0.5.0", + "d2-ui": "0.0.7", "d2-ui-basicfields": "^0.4.1", "d2-ui-button": "0.0.2", "d2-ui-datatable": "0.0.4", @@ -58,6 +58,7 @@ "d2-ui-pagination": "0.0.4", "d2-utils": "0.0.4", "jquery": "^2.1.4", + "lodash.isnumber": "^3.0.1", "loglevel": "^1.4.0", "material-ui": "^0.12.3", "react-router": "^0.13.3", diff --git a/scss/App/App.scss b/scss/App/App.scss index f2d838901..53e2352f4 100644 --- a/scss/App/App.scss +++ b/scss/App/App.scss @@ -1,3 +1,4 @@ .app { color: inherit; + padding-top: 3rem; } diff --git a/scss/SideBar/SideBar.scss b/scss/SideBar/SideBar.scss index 4640b3954..5580936c5 100644 --- a/scss/SideBar/SideBar.scss +++ b/scss/SideBar/SideBar.scss @@ -3,9 +3,18 @@ $sidebar--border-color: #E1E1E1; $sidebar--border-style: 1px solid; $sidebar--item--text-color: #303030; $sidebar--item--hover-color: #EEE; +$left-bar-width: 256px; .sidebar { - padding: 1rem; + width: $left-bar-width; + float: left; + position: fixed; + margin-top: 16px; + bottom: 0; + top: 0; + left: 0; + padding-top: 2rem; + overflow-y: auto; ul { list-style: none; @@ -23,7 +32,6 @@ $sidebar--item--hover-color: #EEE; a { color: $sidebar--item--text-color; display: block; - padding: 1rem; text-decoration: none; } } diff --git a/scss/maintenance.scss b/scss/maintenance.scss index 150c7d3dc..8c4cd2171 100644 --- a/scss/maintenance.scss +++ b/scss/maintenance.scss @@ -23,9 +23,10 @@ $sidebar-border-style: 1px solid; } html { + background: #ffffff; + font-family: 'Roboto', sans-serif; font-size: 14px; } - //Components @import './SideBar/sidebar'; diff --git a/src/App/App.component.js b/src/App/App.component.js index 6c79c1bef..896379db6 100644 --- a/src/App/App.component.js +++ b/src/App/App.component.js @@ -5,7 +5,7 @@ import HeaderBar from 'd2-ui/lib/header-bar/HeaderBar.component'; import MainContent from '../MainContent/MainContent.component'; import SideBar from '../SideBar/SideBarContainer.component'; import SnackbarContainer from '../Snackbar/SnackbarContainer.component'; -import {getInstance} from 'd2'; +import {getInstance} from 'd2/lib/d2'; import AppWithD2 from 'd2-ui/lib/app/AppWithD2.component'; import log from 'loglevel'; import appTheme from './app.theme'; diff --git a/src/App/app.theme.js b/src/App/app.theme.js index 66937cdfa..d1298433e 100644 --- a/src/App/app.theme.js +++ b/src/App/app.theme.js @@ -1,31 +1,47 @@ -import ThemeManager from 'material-ui/lib/styles/theme-manager'; import Colors from 'material-ui/lib/styles/colors'; import ColorManipulator from 'material-ui/lib/utils/color-manipulator'; import Spacing from 'material-ui/lib/styles/spacing'; +import ThemeManager from 'material-ui/lib/styles/theme-manager'; -const themeConfig = { +const theme = { spacing: Spacing, fontFamily: 'Roboto, sans-serif', palette: { primary1Color: Colors.blue500, primary2Color: Colors.blue700, - primary3Color: Colors.lightBlack, - accent1Color: Colors.blueA200, - accent2Color: Colors.grey100, + primary3Color: Colors.grey300, + accent1Color: '#276696', + accent2Color: '#E9E9E9', accent3Color: Colors.grey500, - accent4Color: Colors.blueGrey50, textColor: Colors.darkBlack, alternateTextColor: Colors.white, + canvasColor: Colors.white, borderColor: Colors.grey300, disabledColor: ColorManipulator.fade(Colors.darkBlack, 0.3), }, - }; -const appTheme = ThemeManager.getMuiTheme(themeConfig); +function createAppTheme(style) { + return { + sideBar: { + backgroundColor: '#F3F3F3', + backgroundColorItem: 'transparent', + backgroundColorItemActive: style.palette.accent2Color, + textColor: style.palette.textColor, + textColorActive: style.palette.primary1Color, + borderStyle: '1px solid #e1e1e1', + }, + forms: { + minWidth: 350, + maxWidth: 900, + }, + formFields: { + secondaryColor: style.palette.accent4Color, + }, + }; +} -appTheme.formFields = { - secondaryColor: themeConfig.palette.accent4Color, -}; +const muiTheme = ThemeManager.getMuiTheme(theme); +const appTheme = createAppTheme(theme); -export default appTheme; +export default Object.assign({}, muiTheme, appTheme); diff --git a/src/BasicFields/AttributeFields.component.js b/src/BasicFields/AttributeFields.component.js new file mode 100644 index 000000000..cb133adb1 --- /dev/null +++ b/src/BasicFields/AttributeFields.component.js @@ -0,0 +1,74 @@ +import React from 'react'; +import InputField from './InputField.component'; +import Input from './Input.component'; +import Select from './Select.component'; +import FormFields from './FormFields.component'; +import LabelWrapper from './wrappers/LabelWrapper.component'; + +const AttributeFields = React.createClass({ + propTypes: { + model: React.PropTypes.shape({ + modelDefinition: React.PropTypes.shape({ + attributeProperties: React.PropTypes.object, + }).isRequired, + attributes: React.PropTypes.object, + }).isRequired, + style: React.PropTypes.object, + }, + + render() { + if (!this.props.model || !this.props.model.modelDefinition.attributeProperties) { + return null; + } + + return ( + + {Object.keys(this.props.model.modelDefinition.attributeProperties).map(attributeName => { + const attributeDefinition = this.props.model.modelDefinition.attributeProperties[attributeName]; + const field = { + key: 'attributes.' + attributeName, + type: attributeDefinition.optionSet ? Select : Input, + templateOptions: { + label: attributeName, + required: Boolean(attributeDefinition.mandatory), + translateLabel: false, + }, + validators: { + required: () => { + // Not required, or required and has a value + return !Boolean(attributeDefinition.mandatory) || !!this.props.model.attributes[attributeName]; + }, + }, + }; + + if (attributeDefinition.optionSet) { + field.wrapper = LabelWrapper; + } + + if (attributeDefinition.optionSet) { + field.templateOptions.options = attributeDefinition.optionSet.options; + field.toModelTransformer = valueOnModel => { + if (valueOnModel && valueOnModel.code) { + return valueOnModel.code; + } + return undefined; + }; + field.fromModelTransformer = function transformAttribute(valueOnModel) { + return this.templateOptions.options.reduce((result, option) => { + if (!result && option.code === valueOnModel.attributes[attributeName]) { + return option; + } + return result; + }, undefined); + }.bind(field); + } + + return (); + })} + + ); + }, +}); + +export default AttributeFields; diff --git a/src/BasicFields/CheckBox.component.js b/src/BasicFields/CheckBox.component.js new file mode 100644 index 000000000..febba11d1 --- /dev/null +++ b/src/BasicFields/CheckBox.component.js @@ -0,0 +1,39 @@ +import React from 'react'; +import FormFieldMixin from './FormField.mixin'; +import CheckBox from 'material-ui/lib/checkbox'; +import Translate from 'd2-ui/lib/i18n/Translate.mixin'; + +export default React.createClass({ + propTypes: { + fieldConfig: React.PropTypes.shape({ + key: React.PropTypes.string, + }).isRequired, + model: React.PropTypes.object.isRequired, + }, + + mixins: [FormFieldMixin, Translate], + + render() { + const fc = this.props.fieldConfig; + const to = fc.templateOptions || {}; + const getLabelText = () => { + return this.getTranslation(to.label || fc.key); + }; + + return ( +
+ +
+ ); + }, + + toggleCheckBox() { + const fc = this.props.fieldConfig; + + this.handleChange({target: {value: !this.props.model[fc.key]}}); + }, +}); diff --git a/src/BasicFields/Form.component.js b/src/BasicFields/Form.component.js new file mode 100644 index 000000000..3a6f63945 --- /dev/null +++ b/src/BasicFields/Form.component.js @@ -0,0 +1,103 @@ +import React from 'react'; +import classes from 'classnames'; + +import log from 'loglevel'; + +const Form = React.createClass({ + propTypes: { + children: React.PropTypes.oneOfType([ + React.PropTypes.node, + React.PropTypes.arrayOf(React.PropTypes.node), + ]), + headerFields: React.PropTypes.arrayOf(React.PropTypes.string), + model: React.PropTypes.object.isRequired, + name: React.PropTypes.string.isRequired, + style: React.PropTypes.object, + }, + + childContextTypes: { + updateForm: React.PropTypes.func.isRequired, + setStatus: React.PropTypes.func.isRequired, + }, + + getChildContext() { + return { + updateForm: this.updateForm, + setStatus: this.setStatus, + }; + }, + + getInitialState() { + return { + formStatus: false, + }; + }, + + render() { + const classList = classes('d2-form'); + + const children = React.Children.map(this.props.children, child => { + if (child) { + return React.addons.cloneWithProps(child, {isFormValid: this.isValid, style: this.props.style}); + } + }); + + return ( +
+ {children} +
+ ); + }, + + formFieldStates: {}, + + updateForm(fieldName, newValue, oldValue) { + // TODO: Ideally we would like the model to be immutable. It would be better if we could emit a model change here + // on some model observer that would propagate down through the props. + this.setValue(fieldName, newValue); + + // Force an update of the component as there might be fields that depend on values of others + // skip logic etc. + this.forceUpdate(); + + log.debug(`Form updated because of change to ${fieldName} from '${oldValue}' to '${newValue}'`); + }, + + setStatus(fieldName, isValid) { + this.formFieldStates[fieldName] = isValid; + // log.debug(`${fieldName} is now ${isValid ? 'Valid' : 'Invalid'}`); + + const formStatus = Object.keys(this.formFieldStates) + .reduce((collector, formFieldName) => { + return collector && this.formFieldStates[formFieldName]; + }, true); + + // log.debug(`FormStatus: ${formStatus}`); + + if (formStatus !== this.state.formStatus) { + this.setState({ + formStatus: formStatus, + }, () => { + // log.debug(`This current form status is: ${formStatus ? 'Valid' : 'Invalid'}`); + }); + } + }, + + isValid() { + return this.state.formStatus; + }, + + setValue(fieldName, newValue) { + if (!fieldName || !this.props.model) { return undefined; } + + const keyParts = fieldName.split('.'); + let value = this.props.model; + + while (keyParts.length > 1) { + value = value[keyParts.shift()]; + } + value[keyParts[0]] = newValue; + }, +}); + +export default Form; diff --git a/src/BasicFields/FormField.mixin.js b/src/BasicFields/FormField.mixin.js new file mode 100644 index 000000000..f05aaffb2 --- /dev/null +++ b/src/BasicFields/FormField.mixin.js @@ -0,0 +1,101 @@ +import React from 'react'; +import log from 'loglevel'; + +const noop = () => { +}; + +const FormFieldMixin = { + contextTypes: { + updateForm: React.PropTypes.func.isRequired, + setStatus: React.PropTypes.func.isRequired, + }, + + getDefaultProps() { + return { + fieldConfig: { + templateOptions: {}, + validators: {}, + }, + model: {}, + type: 'text', + whenFocusReceived: noop, + whenFocusLost: noop, + contentUpdated: noop, + }; + }, + + getInitialState() { + const fc = this.props.fieldConfig; + + this.validators = fc.validators || {}; + + return { + hasFocus: false, + }; + }, + + componentWillMount() { + this.formFieldHandlers = { + onFocus: this.handleFocus, + onBlur: this.handleBlur, + onChange: this.handleChange, + }; + + // Create a shortcut for the template options + this.to = this.props.fieldConfig.templateOptions || {}; + this.fc = this.props.fieldConfig; + }, + + hasContent() { + return !!this.getValue(); + }, + + handleFocus() { + this.setState({ + hasFocus: true, + }, this.props.whenFocusReceived); + }, + + handleBlur() { + this.setState({ + hasFocus: false, + }, this.props.whenFocusLost); + }, + + handleChange(event) { + const newValue = event && event.target ? event.target.value : undefined; + + // If we have an updateForm method we will delay the forced update to be triggered by the form instead + if (this.context.updateForm) { + this.props.contentUpdated(!!newValue, newValue, this); + this.context.updateForm(this.props.fieldConfig.key, newValue, this.getValue()); + } else { + // TODO: Deprecate this when we are sure we can + log.warn('Warning: Using FormFields without a updateForm context is not recommended.'); + this.props.model[this.props.fieldConfig.key] = newValue; + + this.forceUpdate(() => { + this.props.contentUpdated(this.hasContent(), newValue, this); + }); + } + }, + + getId() { + return [this.props.formName, this.props.fieldConfig.key].filter(part => part).join('__'); + }, + + getValue() { + if (!this.props.fieldConfig.key || !this.props.model) { return undefined; } + + const keyParts = this.props.fieldConfig.key.split('.'); + let value = this.props.model; + + while (keyParts.length > 0) { + value = value[keyParts.shift()]; + } + + return value; + }, +}; + +export default FormFieldMixin; diff --git a/src/BasicFields/FormFields.component.js b/src/BasicFields/FormFields.component.js new file mode 100644 index 000000000..00dd675c6 --- /dev/null +++ b/src/BasicFields/FormFields.component.js @@ -0,0 +1,48 @@ +import React from 'react'; +import classes from 'classnames'; +import FormUpdateContext from './FormUpdateContext.mixin'; +import StylePropable from 'material-ui/lib/mixins/style-propable'; + +const FormFields = React.createClass({ + propTypes: { + children: React.PropTypes.oneOfType([React.PropTypes.node, React.PropTypes.arrayOf(React.PropTypes.node)]), + className: React.PropTypes.oneOfType( + React.PropTypes.string, + React.PropTypes.array, + React.PropTypes.object + ), + style: React.PropTypes.object, + highlight: React.PropTypes.bool, + }, + + contextTypes: { + muiTheme: React.PropTypes.object, + }, + + mixins: [FormUpdateContext], + + render() { + const classList = classes('d2-form-fields', this.props.className); + + const children = React.Children.map(this.props.children, child => { + if (child) { + return React.addons.cloneWithProps(child, {}); + } + }); + + const headerColor = this.props.highlight === true ? {backgroundColor: this.getTheme().secondaryColor} : {}; + const wrapStyle = Object.assign({}, this.props.style, headerColor); + + return ( +
+ {children} +
+ ); + }, + + getTheme() { + return this.context.muiTheme.formFields; + }, +}); + +export default FormFields; diff --git a/src/BasicFields/FormUpdateContext.mixin.js b/src/BasicFields/FormUpdateContext.mixin.js new file mode 100644 index 000000000..5e87e4695 --- /dev/null +++ b/src/BasicFields/FormUpdateContext.mixin.js @@ -0,0 +1,20 @@ +import React from 'react'; + +export default { + contextTypes: { + updateForm: React.PropTypes.func.isRequired, + setStatus: React.PropTypes.func.isRequired, + }, + + childContextTypes: { + updateForm: React.PropTypes.func.isRequired, + setStatus: React.PropTypes.func.isRequired, + }, + + getChildContext() { + return { + updateForm: this.context.updateForm, + setStatus: this.context.setStatus, + }; + }, +}; diff --git a/src/BasicFields/Input.component.js b/src/BasicFields/Input.component.js new file mode 100644 index 000000000..4ca753fab --- /dev/null +++ b/src/BasicFields/Input.component.js @@ -0,0 +1,59 @@ +import React from 'react'; +import classes from 'classnames'; + +import FormFieldMixin from './FormField.mixin.js'; +import TextField from 'material-ui/lib/text-field'; +import Translate from 'd2-ui/lib/i18n/Translate.mixin'; + +const Input = React.createClass({ + propTypes: { + fieldConfig: React.PropTypes.shape({ + key: React.PropTypes.string.isRequired, + }).isRequired, + model: React.PropTypes.object.isRequired, + type: React.PropTypes.string.isRequired, + validationClasses: React.PropTypes.array, + isValid: React.PropTypes.bool, + }, + + mixins: [FormFieldMixin, Translate], + + componentWillMount() { + this.validators.min = () => { + return true; + }; + this.validators.max = () => { + return true; + }; + }, + + render() { + const classList = classes(this.props.validationClasses); + const getLabelText = () => { + let labelText = (this.to.label || this.fc.key); + if (this.to.translateLabel !== false) { + labelText = this.getTranslation(labelText); + } + + return [ + labelText, + this.to.required ? this.getTranslation('required') : undefined, + ].filter(value => value).join(' '); + }; + + return ( + + ); + }, +}); + +export default Input; diff --git a/src/BasicFields/InputField.component.js b/src/BasicFields/InputField.component.js new file mode 100644 index 000000000..e8e9f7e88 --- /dev/null +++ b/src/BasicFields/InputField.component.js @@ -0,0 +1,107 @@ +import {isFunction} from 'd2-utils'; +import log from 'loglevel'; + +import React from 'react'; +import FormUpdateContext from './FormUpdateContext.mixin'; + +const InputField = React.createClass({ + propTypes: { + formName: React.PropTypes.string.isRequired, + fieldConfig: React.PropTypes.shape({ + key: React.PropTypes.string.isRequired, + type: React.PropTypes.constructor.isRequired, + wrapper: React.PropTypes.constructor, + templateOptions: React.PropTypes.object, + }).isRequired, + + model: React.PropTypes.object.isRequired, + }, + + mixins: [FormUpdateContext], + + getDefaultProps() { + return { + fieldConfig: {}, + model: {}, + }; + }, + + componentWillMount() { + if (!this.props.fieldConfig.type) { + throw new Error('`fieldConfig.type` is required to render the input field'); + } + + // Run initial validation to show invalid fields after loading + this.runValidation(); + }, + + componentWillReceiveProps() { + this.runValidation(); + }, + + render() { + const fc = this.props.fieldConfig; + + // Don't render the component when the hide property is or returns true + if (fc.hide === true || (isFunction(fc.hide) && fc.hide(this.props.model, fc))) { + return null; + } + + const renderedField = ; + + if (fc.wrapper) { + return ( + + {renderedField} + + ); + } + return renderedField; + }, + + isValid() { + return this.runValidation().isValid; + }, + + runValidation() { + const fc = this.props.fieldConfig; + const to = fc.templateOptions || {}; + const validators = fc.validators || {}; + + if (to.required && !validators.required) { + validators.required = () => { + return !!this.props.model[this.props.fieldConfig.key] || this.props.model[this.props.fieldConfig.key] === false; + }; + } + + // Log warning if there are no validators found + if (!Object.keys(validators).length) { + log.debug(`Warning: No validator object found for field '${this.props.fieldConfig.key}'`); + } + + const validationClasses = []; // Validate class is used by materialize to show red and green states + const validationStatus = Object.keys(validators || {}).reduce((vs, validatorName) => { + vs[validatorName] = validators[validatorName].apply(this); + if (vs[validatorName] === false) { + validationClasses.push('invalid-' + validatorName); + } + return vs; + }, {}); + + const isValid = Object.keys(validationStatus).reduce((status, validatorName) => { + return status && validationStatus[validatorName]; + }, true); + + const validationResult = { + isValid: isValid, + validationClasses: isValid ? validationClasses : validationClasses.concat(['invalid']), + }; + + this.setState({validation: validationResult}); + this.context.setStatus && this.context.setStatus(fc.key, isValid); + + return validationResult; + }, +}); + +export default InputField; diff --git a/src/BasicFields/MultiSelect.component.js b/src/BasicFields/MultiSelect.component.js new file mode 100644 index 000000000..894e35586 --- /dev/null +++ b/src/BasicFields/MultiSelect.component.js @@ -0,0 +1,322 @@ +import React from 'react'; +import classes from 'classnames'; +import {isFunction} from 'd2-utils'; +import FormFieldMixin from './FormField.mixin'; +import log from 'loglevel'; +import FontIcon from 'material-ui/lib/font-icon'; +import FlatButton from 'material-ui/lib/flat-button'; +import TextField from 'material-ui/lib/text-field'; +import Translate from 'd2-ui/lib/i18n/Translate.mixin'; + +const identity = (value) => value; + +const MultiSelect = React.createClass({ + propTypes: { + fieldConfig: React.PropTypes.shape({ + key: React.PropTypes.string.isRequired, + data: React.PropTypes.shape({ + source: React.PropTypes.func, + }), + fromModelTransformer: React.PropTypes.func, + toModelTransformer: React.PropTypes.func, + }).isRequired, + model: React.PropTypes.object.isRequired, + }, + + mixins: [FormFieldMixin, Translate], + + getInitialState() { + const fc = this.props.fieldConfig; + + return { + fromModelTransformer: isFunction(fc.fromModelTransformer) ? fc.fromModelTransformer : identity, + toModelTransformer: isFunction(fc.toModelTransformer) ? fc.toModelTransformer : identity, + availableOptions: [], + selectedOptions: [], + }; + }, + + componentWillMount() { + const source = this.props.fieldConfig.data.source; + + if (!isFunction(source) && !Array.isArray(source)) { + log.warn(`Warning: The source for the MultiSelectBox with key '${this.props.fieldConfig.key}' is not a function or an array.`); + } + + const initMultiSelectValues = values => { + const valueOnModel = this.props.model[this.props.fieldConfig.key]; + const transformedSelectedPromises = Promise.all((Array.isArray(valueOnModel) ? valueOnModel : []).map(this.state.fromModelTransformer)); + + Promise.all([Promise.resolve(values), transformedSelectedPromises]) + .then(([availableValues, selectedValues]) => { + this.collection = availableValues; + this.selected = selectedValues; + this.updateLists(false); + }); + }; + + if (isFunction(source)) { + source() + .then((collection) => { + // TODO: This is a poor way of identifying if it is a D2.ModelCollection + if (collection.toArray) { + this.modelDefinition = collection.modelDefinition; + this.pager = collection.pager; + initMultiSelectValues(collection.toArray()); + } else { + initMultiSelectValues(collection); + } + }); + } + + if (Array.isArray(source)) { + initMultiSelectValues(source); + } + }, + + render() { + const filterRegExp = new RegExp(this.state.selectedSearchString, 'i'); + const classList = classes('multi-select'); + const sortByName = (a, b) => a.name && b.name && a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + const selectedOptionsToRender = (this.state.selectedOptions || []) + .filter(selectedOption => filterRegExp.test(selectedOption.name)) + .sort(sortByName); + + const buttonStyle = { + verticalAlign: 'text-bottom', + lineHeight: '3rem', + }; + + return ( +
+
+ search + +
{this.state.availableOptions.length}
+
+
+
    +
  • + + play_arrow + +
  • +
  • + + play_arrow + +
  • +
  • + + fast_rewind + +
  • +
  • + + fast_forward + +
  • +
+
+
+ + +
{selectedOptionsToRender.length} {this.state.selectedSearchString ? ' of ' + this.selected.length : ''}
+
+
+ ); + }, + + collection: [], + selected: [], + isLoadingMore: false, + + updateLists(updateModel = true) { + this.setState({ + availableOptions: this.collection.filter(availableOption => { + return this.selected.map(modelOption => modelOption.id).indexOf(availableOption.id) === -1; + }), + + selectedOptions: this.selected, + }, () => { + const shouldLoadMore = Boolean(this.state.availableOptions.length < 20 && this.pager && this.pager.hasNextPage); + if (shouldLoadMore && !this.isLoadingMore && !this.state.availableSearchString) { + log.info('Load more items too few'); + this.loadMoreValuesThroughPager(); + } + + if (updateModel) { + Promise.all(this.selected.map(this.state.toModelTransformer)) + .then((values) => { + this.handleChange({ + target: { + value: values, + }, + }); + }); + } + }); + }, + + loadMoreIfAvailable() { + const availableSelectElement = React.findDOMNode(this.refs.available); + + // TODO: This is not covered by unit tests as phantom.js does not seem to render the select as scrollable + if (availableSelectElement.scrollHeight !== 0 && availableSelectElement.scrollTop + availableSelectElement.offsetHeight + Math.floor(availableSelectElement.scrollHeight * 0.10) <= availableSelectElement.scrollHeight) { + return; + } + + if (this.pager && this.pager.hasNextPage() && !this.isLoadingMore) { + if (!this.isLoadingMore) { + this.loadMoreValuesThroughPager(); + } + } + }, + + loadMoreValuesThroughPager() { + log.info('Loading more async...'); + this.isLoadingMore = true; + + this.pager.getNextPage() + .then((collection) => { + this.isLoadingMore = false; + this.pager = collection.pager; + this.collection = this.collection.concat(collection.toArray()); + this.updateLists(false); + }) + .catch(e => { + log.error('Failed to load more for the multiselect box', e); + }); + }, + + selectAll() { + if (this.modelDefinition && !this.state.availableSearchString) { + this.modelDefinition.list({paging: false}) + .then((collection) => { + this.pager = collection.pager; + this.selected = collection.toArray(); + this.collection = collection.toArray(); + + this.setState({ + allLoaded: true, + }); + + this.updateLists(); + }); + } else { + const alreadySelected = new Set(this.selected.map(value => value.id)); + this.selected = this.selected.concat(this.collection.filter(option => !alreadySelected.has(option.id))); + this.updateLists(); + } + }, + + selectNone() { + if (this.state.selectedSearchString) { + const filterRegExp = new RegExp(this.state.selectedSearchString, 'i'); + this.selected = this.selected.filter(selectedOption => !filterRegExp.test(selectedOption.name)); + } else { + this.selected = []; + } + this.updateLists(); + }, + + moveToSelected() { + const valuesToMove = Array.from(this.refs.available.getDOMNode().options) + .filter(option => option.selected) + .map(option => option.value); + + this.selected.push.apply(this.selected, this.collection.filter(value => valuesToMove.indexOf(value.id.toString()) >= 0)); + this.updateLists(); + }, + + moveToAvailable() { + const valuesToMove = Array.from(this.refs.selected.getDOMNode().options) + .filter(option => option.selected) + .map(option => option.value); + + this.selected = this.selected + .filter(modelValue => { + return valuesToMove.indexOf(modelValue.id.toString()) === -1; + }); + this.updateLists(); + }, + + moveItemToSelected(availableOption) { + return () => { + this.selected + .push(availableOption); + this.updateLists(); + }; + }, + + moveItemToAvailable(availableOption) { + return () => { + this.selected = this.selected + .filter(item => { + return item.id !== availableOption.id; + }); + + // Make sure we only keep track of each item once + const itemsInUse = new Set(this.collection.map(item => item.id)); + if (!itemsInUse.has(availableOption.id)) { + this.collection.push(availableOption); + } + + this.updateLists(); + }; + }, + + _searchAvailable(event) { + this.setState({ + availableSearchString: event.target.value.trim(), + }, () => { + this.resetAvailableList(); + }); + }, + + resetAvailableList() { + if (this.tempCollection && !this.state.availableSearchString) { + this.collection = this.tempCollection; + this.updateLists(false); + } + }, + + _doSearchAvailable() { + // If there is no search string update the list back to the original + if (!this.state.availableSearchString) { + this.resetAvailableList(); + return; + } + + if (!this.allLoaded && this.modelDefinition) { + this.modelDefinition + .filter().on('name').like(this.state.availableSearchString) + .list({paging: false}) + .then((collection) => { + this.tempCollection = this.collection; + this.collection = collection.toArray(); + this.updateLists(false); + }); + } + }, + + _searchSelected(event) { + this.setState({ + selectedSearchString: event.target.value.trim(), + }); + }, +}); + +export default MultiSelect; diff --git a/src/BasicFields/Select.component.js b/src/BasicFields/Select.component.js new file mode 100644 index 000000000..26d3b2c24 --- /dev/null +++ b/src/BasicFields/Select.component.js @@ -0,0 +1,154 @@ +import React from 'react'; +import classes from 'classnames'; +import {isFunction} from 'd2-utils'; +import ReactSelect from 'react-select'; +// import Icon from 'd2-ui-icon'; + +import FormFieldMixin from './FormField.mixin'; + +function isLoading(fieldConfig) { + return fieldConfig.data && fieldConfig.data.loading; +} + +const identity = (value) => value; + +// TODO: Select when wrapped does not correctly remove the has-content class +const Select = React.createClass({ + propTypes: { + fieldConfig: React.PropTypes.shape({ + key: React.PropTypes.string.isRequired, + templateOptions: React.PropTypes.shape({ + options: React.PropTypes.oneOfType([React.PropTypes.array, React.PropTypes.object]), + }), + data: React.PropTypes.object, + fromModelTransformer: React.PropTypes.func, + toModelTransformer: React.PropTypes.func, + }).isRequired, + contentUpdated: React.PropTypes.func.isRequired, + model: React.PropTypes.object.isRequired, + }, + + mixins: [FormFieldMixin], + + getDefaultProps() { + return {}; + }, + + getInitialState() { + const fc = this.props.fieldConfig; + const options = Array.from(this.props.fieldConfig.templateOptions.options || []); + + this.reactSelectProps = { + onChange: (value) => { + const to = this.props.fieldConfig.templateOptions; + // TODO: Since handle change normally receives the input event we have to simulate the synthetic event. See if there is a nicer way to do this. + // We could change the mixin to only receive the value, however that would require most of the inputs to add a method that extracts the value. + // An alternative resolution would be to have a second method in the mixin that takes only the value and handleChange as a proxy to that method. + // We would then call the former. + + let selected = value; + if (this.state.isObjectValue) { + selected = to.options.reduce((current, option) => option.id === value ? option : current, undefined); + } + + this.handleChange({target: {value: this.state.toModelTransformer(selected)}}); + }, + }; + + return { + fromModelTransformer: isFunction(fc.fromModelTransformer) ? fc.fromModelTransformer : identity, + toModelTransformer: isFunction(fc.toModelTransformer) ? fc.toModelTransformer : identity, + isObjectValue: options.every(option => !!option.id), + isLoading: isLoading(this.props.fieldConfig), + }; + }, + + componentWillMount() { + if (this.hasSourcePromise()) { + this.getSourcePromise().then(() => { + this.setState({ + isLoading: false, + }, this.updateCurrentValue); + }); + } + + this.updateCurrentValue(); + }, + + componentDidMount() { + /* eslint-disable no-underscore-dangle */ + this._hasContent = this.hasContent; + this.hasContent = () => { + return this._hasContent() || this.state.hasSearchContent; + }; + /* eslint-enable no-underscore-dangle */ + }, + + componentWillReceiveProps() { + this.updateCurrentValue(); + }, + + getSourcePromise() { + return this.props.fieldConfig.data.sourcePromise; + }, + + getTransformedValue() { + return this.state.fromModelTransformer(this.props.model); + }, + + render() { + const classList = classes('d2-select'); + + return ( +
+ +
+ ); + }, + + updateCurrentValue() { + const to = this.props.fieldConfig.templateOptions; + + this.reactSelectProps.name = this.getId(); + this.reactSelectProps.placeholder = ''; + this.reactSelectProps.options = (to.options || []).map(option => { + return {label: option.name, value: this.state.isObjectValue ? option.id : option.value}; + }); + this.reactSelectProps.clearable = !to.required; + this.reactSelectProps.inputProps = { + onKeyUp: (event) => { + this.setState({ + hasSearchContent: !!event.target.value, + }, () => { + this.forceUpdate(() => { + this.props.contentUpdated(this.hasContent(), this.props.model[this.props.fieldConfig.key], this); + }); + }); + }, + }; + + if (this.state.isObjectValue && this.getTransformedValue()) { + if (this.getTransformedValue().id && this.getTransformedValue().name && this.props.model !== this.getTransformedValue()) { + this.reactSelectProps.value = {label: this.getTransformedValue().name, value: this.getTransformedValue().id}; + } else { + if (this.getValue() && this.getValue().name && this.getValue().id) { + this.reactSelectProps.value = {label: this.getValue().name, value: this.getValue().id}; + } else { + this.reactSelectProps.value = {label: this.getValue(), value: this.getValue()}; + } + } + } else { + this.reactSelectProps.value = this.getValue(); + } + + this.setState({ + reactSelectProps: this.reactSelectProps, + }); + }, + + hasSourcePromise() { + return this.props.fieldConfig.data && this.props.fieldConfig.data.sourcePromise; + }, +}); + +export default Select; diff --git a/src/BasicFields/Textarea.component.js b/src/BasicFields/Textarea.component.js new file mode 100644 index 000000000..37edabef9 --- /dev/null +++ b/src/BasicFields/Textarea.component.js @@ -0,0 +1,45 @@ +import React from 'react'; +import classes from 'classnames'; +import Translate from 'd2-ui/lib/i18n/Translate.mixin'; +import FormFieldBase from './FormField.mixin.js'; +import TextField from 'material-ui/lib/text-field'; + +const Textarea = React.createClass({ + propTypes: { + fieldConfig: React.PropTypes.shape({ + key: React.PropTypes.string, + }).isRequired, + model: React.PropTypes.object.isRequired, + validationClasses: React.PropTypes.array, + isValid: React.PropTypes.bool, + }, + + mixins: [FormFieldBase, Translate], + + render() { + const classList = classes(this.props.validationClasses, 'textarea'); + + const getLabelText = () => { + return [ + this.getTranslation(this.to.label || this.fc.key), + this.to.required ? this.getTranslation('required') : undefined, + ].filter(value => value).join(' '); + }; + + return ( + + ); + }, +}); + +export default Textarea; diff --git a/src/BasicFields/fields/CheckBox.js b/src/BasicFields/fields/CheckBox.js new file mode 100644 index 000000000..55c8958cd --- /dev/null +++ b/src/BasicFields/fields/CheckBox.js @@ -0,0 +1,12 @@ +import Field from './Field'; +import CheckBoxComponent from '../CheckBox.component'; + +class CheckBox extends Field { + constructor(options) { + super(options); + + this.type = CheckBoxComponent; + } +} + +export default CheckBox; diff --git a/src/BasicFields/fields/Field.js b/src/BasicFields/fields/Field.js new file mode 100644 index 000000000..9f287addc --- /dev/null +++ b/src/BasicFields/fields/Field.js @@ -0,0 +1,36 @@ +import {camelCaseToUnderscores} from 'd2-utils'; +import Input from '../form-fields/text-field'; +import {isRequired} from 'd2-ui/lib/forms/Validators'; +import {getInstance} from 'd2/lib/d2'; + +class Field { + constructor() { + this.type = Input; + this.fieldOptions = {}; + this.validators = []; + } + + static create(options) { + const field = Object.assign(new this(), options || {}); + + // Set required validator + if (options.required) { + field.validators.push(isRequired); + } + + if (options.unique) { + console.log('Add unique validator to: ' + options.name); + field.validators.push(function () { + return getInstance() + .then(d2 => { + console.log(options); + + return Promise.reject('not_unique'); + }); + }); + } + + return field; + } +} +export default Field; diff --git a/src/BasicFields/fields/InputBox.js b/src/BasicFields/fields/InputBox.js new file mode 100644 index 000000000..1a962260b --- /dev/null +++ b/src/BasicFields/fields/InputBox.js @@ -0,0 +1,5 @@ +import Field from './Field'; + +class InputBox extends Field {} + +export default InputBox; diff --git a/src/BasicFields/fields/MultiSelectBox.js b/src/BasicFields/fields/MultiSelectBox.js new file mode 100644 index 000000000..ca9867041 --- /dev/null +++ b/src/BasicFields/fields/MultiSelectBox.js @@ -0,0 +1,37 @@ +import log from 'loglevel'; + +import Field from './Field'; + +import MultiSelect from '../MultiSelect.component'; +import LabelWrapper from '../wrappers/LabelWrapper.component'; + +class MultiSelectBox extends Field { + constructor(options) { + super(options); + + this.type = MultiSelect; + this.data = this.data || {}; + this.data.referenceType = options.referenceType; + this.data.source = options.source || []; + + this.wrapper = LabelWrapper; + } + + static create(modelValidation, models) { + modelValidation.templateOptions = modelValidation.templateOptions || {}; + + if (models && models[modelValidation.referenceType]) { + modelValidation.source = () => { + return models[modelValidation.referenceType.name].list(); + }; + modelValidation.referenceType = models[modelValidation.referenceType]; + return new MultiSelectBox(modelValidation); + } + // FIXME: Don't allow MultiSelectBoxes without source; + log.warn('Warning: Trying to create a MultiSelect box without a referenceType or a referenceType that does not exist', modelValidation.referenceType); + return new MultiSelectBox(modelValidation); + } +} +MultiSelectBox.FIELDS_NOT_TO_COPY = MultiSelectBox.FIELDS_NOT_TO_COPY.concat(['source', 'referenceType']); + +export default MultiSelectBox; diff --git a/src/BasicFields/fields/SelectBox.js b/src/BasicFields/fields/SelectBox.js new file mode 100644 index 000000000..276ade60c --- /dev/null +++ b/src/BasicFields/fields/SelectBox.js @@ -0,0 +1,20 @@ +import Field from './Field'; + +import Select from '../Select.component'; +import LabelWrapper from '../wrappers/LabelWrapper.component'; + +class SelectBox extends Field { + constructor(options) { + super(options); + + this.type = Select; + this.wrapper = LabelWrapper; + + this.templateOptions.options = !!options.templateOptions && options.templateOptions.options || options.constants || []; + + this.templateOptions.options = this.templateOptions.options + .map(constant => ({name: constant, value: constant})); + } +} + +export default SelectBox; diff --git a/src/BasicFields/fields/SelectBoxAsync.js b/src/BasicFields/fields/SelectBoxAsync.js new file mode 100644 index 000000000..0f0b72a28 --- /dev/null +++ b/src/BasicFields/fields/SelectBoxAsync.js @@ -0,0 +1,40 @@ +import SelectBox from './SelectBox'; + +class SelectBoxAsync extends SelectBox { + constructor(options) { + super(options); + + this.data = { + loading: true, + }; + + // TODO: Poor mans promise detection (But might be needed because of different Promise Libraries) + if (options.source && options.source.then && options.source.catch) { + this.data.sourcePromise = options.source + .then(result => { + this.templateOptions.options = result; + this.data.loading = false; + return result; + }) + .catch(error => { + this.data.loading = false; + throw new Error('Unable to load values for: ' + options.fieldName + ' Error:' + error); + }); + } + } + + static create(modelValidation, models) { + modelValidation.templateOptions = modelValidation.templateOptions || {}; + + if (models && models[modelValidation.referenceType]) { + modelValidation.source = models[modelValidation.referenceType] + .list({paging: false}) + .then(modelCollection => modelCollection.toArray()); + return new SelectBoxAsync(modelValidation); + } + throw new Error('Passed models does not have a reference to the type: ' + modelValidation.referenceType); + } +} +SelectBoxAsync.FIELDS_NOT_TO_COPY = SelectBox.FIELDS_NOT_TO_COPY.concat(['source']); + +export default SelectBoxAsync; diff --git a/src/BasicFields/fields/TextBox.js b/src/BasicFields/fields/TextBox.js new file mode 100644 index 000000000..06034a4e6 --- /dev/null +++ b/src/BasicFields/fields/TextBox.js @@ -0,0 +1,13 @@ +import Field from './Field'; + +import Textarea from '../Textarea.component'; + +class TextBox extends Field { + constructor(options) { + super(options); + + this.type = Textarea; + } +} + +export default TextBox; diff --git a/src/BasicFields/fields/index.js b/src/BasicFields/fields/index.js new file mode 100644 index 000000000..e9a7e532d --- /dev/null +++ b/src/BasicFields/fields/index.js @@ -0,0 +1,33 @@ +//import CheckBox from './CheckBox'; +import InputBox from './InputBox'; +//import SelectBox from './SelectBox'; +//import SelectBoxAsync from './SelectBoxAsync'; +//import MultiSelectBox from './MultiSelectBox'; +//import TextBox from './TextBox'; + +export const CHECKBOX = Symbol('CHECKBOX'); +export const INPUT = Symbol('INPUT'); +export const SELECT = Symbol('SELECT'); +export const SELECTASYNC = Symbol('SELECTASYNC'); +export const MULTISELECT = Symbol('MULTISELECT'); +export const TEXT = Symbol('TEXT'); + +export const fieldTypeClasses = new Map([ + //[CHECKBOX, CheckBox], + [INPUT, InputBox], + //[SELECT, SelectBox], + //[SELECTASYNC, SelectBoxAsync], + //[MULTISELECT, MultiSelectBox], + //[TEXT, TextBox], +]); + +export const typeToFieldMap = new Map([ + // ['BOOLEAN', CHECKBOX], + // ['CONSTANT', SELECT], + ['IDENTIFIER', INPUT], //TODO: Add identifiers for the type of field... + // ['REFERENCE', SELECTASYNC], + ['TEXT', INPUT], + // ['COLLECTION', MULTISELECT], + // ['INTEGER', INPUT], // TODO: Add Numberfield! + // ['URL', INPUT], // TODO: Add Url field? +]); diff --git a/src/BasicFields/form-fields/check-box.js b/src/BasicFields/form-fields/check-box.js new file mode 100644 index 000000000..d4c13f6ec --- /dev/null +++ b/src/BasicFields/form-fields/check-box.js @@ -0,0 +1,20 @@ +import React from 'react/addons'; +import Checkbox from 'material-ui/lib/checkbox'; + +import MuiThemeMixin from '../mui-theme.mixin'; + +export default React.createClass({ + propTypes: { + onChange: React.PropTypes.func.isRequired, + }, + + mixins: [MuiThemeMixin], + + render() { + return ( +
+ +
+ ); + }, +}); diff --git a/src/BasicFields/form-fields/drop-down.js b/src/BasicFields/form-fields/drop-down.js new file mode 100644 index 000000000..f6c748cd6 --- /dev/null +++ b/src/BasicFields/form-fields/drop-down.js @@ -0,0 +1,31 @@ +import React from 'react/addons'; +import SelectField from 'material-ui/lib/select-field'; + +import MuiThemeMixin from '../mui-theme.mixin'; + +export default React.createClass({ + propTypes: { + defaultValue: React.PropTypes.oneOfType([ + React.PropTypes.string, + React.PropTypes.number, + React.PropTypes.bool, + ]), + onFocus: React.PropTypes.func, + onBlur: React.PropTypes.func, + }, + + mixins: [MuiThemeMixin], + + getInitialState() { + return {value: this.props.defaultValue ? this.props.defaultValue : 'null'}; + }, + + render() { + const {onFocus, onBlur, ...other} = this.props; + return ( + + ); + }, +}); diff --git a/src/BasicFields/form-fields/file-upload.js b/src/BasicFields/form-fields/file-upload.js new file mode 100644 index 000000000..d84a20067 --- /dev/null +++ b/src/BasicFields/form-fields/file-upload.js @@ -0,0 +1,192 @@ +import React from 'react/addons'; +import log from 'loglevel'; + +import LinearProgress from 'material-ui/lib/linear-progress'; +import FlatButton from 'material-ui/lib/flat-button'; +import Dialog from 'material-ui/lib/dialog'; + +import Translate from 'd2-ui/lib/i18n/Translate.mixin'; + +import Checkbox from 'material-ui/lib/checkbox'; +import AppTheme from '../theme'; + +export default React.createClass({ + propTypes: { + name: React.PropTypes.oneOf(['logo_front', 'logo_banner']).isRequired, + label: React.PropTypes.string.isRequired, + value: React.PropTypes.bool.isRequired, + defaultValue: React.PropTypes.bool.isRequired, + isEnabled: React.PropTypes.bool.isRequired, + + onFocus: React.PropTypes.func, + onBlur: React.PropTypes.func, + onChange: React.PropTypes.func, + }, + + mixins: [Translate], + + getInitialState() { + return { + isEnabled: this.props.isEnabled, + uploading: false, + progress: undefined, + }; + }, + + renderUploading() { + const progressStyle = { + position: 'absolute', + left: 0, + right: 0, + zIndex: 1, + }; + + return ( +
+ +
+ +
+
+ ); + }, + + renderUpload() { + const bodyStyle = { + backgroundColor: AppTheme.rawTheme.palette.accent1Color, + textAlign: 'center', + overflow: 'auto', + padding: 48, + }; + + const apiBase = this.context.d2.Api.getApi().baseUrl; + const imgUrl = [apiBase, 'staticContent', this.props.name].join('/'); + + if (this.state.isEnabled) { + return ( +
+ + + + + +
+ ); + } + + return ( + + ); + }, + + render() { + const {onFocus, onBlur, onChange, ...other} = this.props; + + const containerStyle = { + position: 'relative', + display: 'block', + whiteSpace: 'nowrap', + }; + + const checkStyle = { + display: 'inline-block', + whiteSpace: 'nowrap', + paddingRight: 8, + paddingTop: 8, + paddingBottom: 8, + }; + + const btnStyle = { + display: 'inline-block', + position: 'absolute', + top: 2, + }; + + return ( +
+
+ +
+
+ { this.state.uploading ? this.renderUploading() : this.renderUpload() } + this.fileInput = ref} + onChange={this._upload} /> +
+
+ ); + }, + + _fileClick(e) { + if (this.fileInput && !this.state.uploading) { + this.fileInput.getDOMNode().click(e); + } else if (this.state.uploading) { + this.xhr.abort(); + this.setState({uploading: false, progress: undefined}); + log.info('File upload cancelled'); + } + }, + + _previewClick() { + this.refs.dialog.show(); + }, + + _check(e) { + this.props.onChange({target: {value: e.target.checked}}); + }, + + _upload(e) { + if (e.target.files.length === 0) { + return; + } + + this.setState({ + uploading: true, + progress: undefined, + }); + + const api = this.context.d2.Api.getApi(); + const xhr = new XMLHttpRequest(); + xhr.upload.onprogress = (progress) => { + if (progress.lengthComputable) { + this.setState({progress: (progress.loaded / progress.total) * 100}); + } else { + this.setState({progress: undefined}); + } + }; + this.xhr = xhr; + + const data = new FormData(); + data.append('file', e.target.files[0]); + + api.post(['staticContent', this.props.name].join('/'), data, { + contentType: false, + processData: false, + xhr: () => { return xhr; }, + }).then(() => { + log.info('File uploaded successfully'); + this.props.onChange({target: {value: true}}); + this.setState({ + uploading: false, + progress: undefined, + isEnabled: true, + }); + }).catch(() => { + log.warn('File upload failed:', arguments); + this.props.onChange({target: {value: false}}); + this.setState({ + uploading: false, + progress: undefined, + isEnabled: false, + }); + }); + }, +}); diff --git a/src/BasicFields/form-fields/multi-toggle.js b/src/BasicFields/form-fields/multi-toggle.js new file mode 100644 index 000000000..4d04d6189 --- /dev/null +++ b/src/BasicFields/form-fields/multi-toggle.js @@ -0,0 +1,71 @@ +import React from 'react'; + +// Material UI +import Checkbox from 'material-ui/lib/checkbox'; + +export default React.createClass({ + propTypes: { + label: React.PropTypes.string.isRequired, + onChange: React.PropTypes.func.isRequired, + items: React.PropTypes.arrayOf(React.PropTypes.shape({ + name: React.PropTypes.string.isRequired, + value: React.PropTypes.bool, + text: React.PropTypes.string.isRequired, + })), + style: React.PropTypes.object, + }, + + contextTypes: { + muiTheme: React.PropTypes.object, + }, + + getInitialState() { + return { + values: this.props.items.reduce((prev, curr) => { + if (curr.value) { + prev.push(curr.name); + } + return prev; + }, []), + }; + }, + + render() { + const style = Object.assign({}, this.context.muiTheme.forms, this.props.style); + return ( +
+
{this.props.label}
+ {this.props.items.map(item => { + return ( + + ); + })} +
+ ); + }, + + _handleToggle(value, event, checked) { + this.setState(oldState => { + if (checked) { + if (oldState.values.indexOf(value) === -1) { + oldState.values.push(value); + } + } else { + if (oldState.values.indexOf(value) !== -1) { + oldState.values.splice(oldState.values.indexOf(value), 1); + } + } + return oldState; + }, () => { + this.props.onChange({target: {value: this.state.values}}); + }); + }, +}); diff --git a/src/BasicFields/form-fields/text-field.js b/src/BasicFields/form-fields/text-field.js new file mode 100644 index 000000000..26c91b491 --- /dev/null +++ b/src/BasicFields/form-fields/text-field.js @@ -0,0 +1,23 @@ +import React from 'react/addons'; +import TextField from 'material-ui/lib/text-field'; + +import MuiThemeMixin from '../mui-theme.mixin'; + +export default React.createClass({ + propTypes: { + multiLine: React.PropTypes.bool, + }, + + mixins: [MuiThemeMixin], + + render() { + const errorStyle = { + lineHeight: this.props.multiLine ? '48px' : '12px', + marginTop: this.props.multiLine ? -16 : -12, + }; + + return ( + + ); + }, +}); diff --git a/src/BasicFields/index.js b/src/BasicFields/index.js new file mode 100644 index 000000000..03675b27d --- /dev/null +++ b/src/BasicFields/index.js @@ -0,0 +1,7 @@ +import Input from './Input.component'; +import Textarea from './Textarea.component'; + +export default { + Input, + Textarea, +}; diff --git a/src/BasicFields/mui-theme.mixin.js b/src/BasicFields/mui-theme.mixin.js new file mode 100644 index 000000000..d80491381 --- /dev/null +++ b/src/BasicFields/mui-theme.mixin.js @@ -0,0 +1,15 @@ +import React from 'react'; + +import AppTheme from '../App/app.theme'; + +export default { + childContextTypes: { + muiTheme: React.PropTypes.object, + }, + + getChildContext() { + return { + muiTheme: AppTheme, + }; + }, +}; diff --git a/src/BasicFields/wrappers/LabelWrapper.component.js b/src/BasicFields/wrappers/LabelWrapper.component.js new file mode 100644 index 000000000..5db9d67b9 --- /dev/null +++ b/src/BasicFields/wrappers/LabelWrapper.component.js @@ -0,0 +1,158 @@ +import React from 'react'; +import Translate from 'd2-ui/lib/i18n/Translate.mixin'; +import classes from 'classnames'; + +const noop = () => {}; + +// TODO: Wrapper mixin can be exported to make it easier to create wrapper components +const WrapperMixin = React.createMixin({ + propTypes: { + formName: React.PropTypes.string.isRequired, + fieldConfig: React.PropTypes.shape({ + key: React.PropTypes.string.isRequired, + templateOptions: React.PropTypes.shape({ + label: React.PropTypes.oneOfType([ + React.PropTypes.string, + React.PropTypes.number, + ]), + }).isRequired, + }).isRequired, + children: React.PropTypes.node, + }, + + getDefaultProps() { + return { + fieldConfig: { + templateOptions: {}, + }, + model: {}, + contentUpdated: noop, + }; + }, + + componentWillMount() { + // Create a shortcut for the template options + this.to = this.props.fieldConfig.templateOptions; + this.fc = this.props.fieldConfig; + }, + + inputGotFocus() { + this.setState({ + hasFocus: true, + }); + }, + + inputLostFocus() { + this.setState({ + hasFocus: false, + }); + }, + + contentUpdated(hasContent) { + this.setState({ + hasContent: hasContent, + }, this.props.contentUpdated); + }, +}); + +const LabelWrapper = React.createClass({ + propTypes: { + formName: React.PropTypes.string.isRequired, + fieldConfig: React.PropTypes.shape({ + key: React.PropTypes.string.isRequired, + templateOptions: React.PropTypes.shape({ + label: React.PropTypes.oneOfType([ + React.PropTypes.string, + React.PropTypes.number, + ]), + }).isRequired, + }).isRequired, + children: React.PropTypes.node, + model: React.PropTypes.object.isRequired, + validationClasses: React.PropTypes.arrayOf(React.PropTypes.string), + }, + + contextTypes: { + updateForm: React.PropTypes.func.isRequired, + }, + + childContextTypes: { + updateForm: React.PropTypes.func.isRequired, + }, + + mixins: [WrapperMixin, Translate], + + getChildContext() { + return { + updateForm: this.context.updateForm, + }; + }, + + getInitialState() { + return { + hasFocus: false, + }; + }, + + getId() { + return [this.props.formName, this.props.fieldConfig.key].filter(part => part).join('__'); + }, + + getValue() { + if (!this.props.fieldConfig.key || !this.props.model) { return undefined; } + + const keyParts = this.props.fieldConfig.key.split('.'); + let value = this.props.model; + + while (keyParts.length > 0) { + value = value[keyParts.shift()]; + } + + return value; + }, + + render() { + const classList = classes( + 'd2-input', + 'input-field', + { + 'd2-input--focused': this.state.hasFocus, + 'd2-input--content': (this.state.hasContent || this.getValue()) ? true : false, + }, + this.props.validationClasses + ); + + const children = React.Children.map(this.props.children, child => { + return React.cloneElement(child, { + whenFocusReceived: this.inputGotFocus, + whenFocusLost: this.inputLostFocus, + contentUpdated: this.contentUpdated}); + }); + + const labelClasses = classes({ + active: Boolean(this.state.hasContent || this.getValue()), + }); + + // TODO: Duplicate code with Input.component + const getLabelText = () => { + let labelText = (this.to.label || this.fc.key); + if (this.to.translateLabel !== false) { + labelText = this.getTranslation(labelText); + } + + return [ + labelText, + this.to.required ? this.getTranslation('required') : undefined, + ].filter(value => value).join(' '); + }; + + return ( +
+ + {children} +
+ ); + }, +}); + +export default LabelWrapper; diff --git a/src/EditModel/CancelButton.component.js b/src/EditModel/CancelButton.component.js index bedd27630..65ec631c5 100644 --- a/src/EditModel/CancelButton.component.js +++ b/src/EditModel/CancelButton.component.js @@ -1,7 +1,7 @@ import React from 'react'; import RaisedButton from 'material-ui/lib/raised-button'; import Translate from 'd2-ui/lib/i18n/Translate.mixin'; -import {config} from 'd2'; +import {config} from 'd2/lib/d2'; config.i18n.strings.add('cancel'); diff --git a/src/EditModel/EditModel.component.js b/src/EditModel/EditModel.component.js index 0985a68a1..94bb942b8 100644 --- a/src/EditModel/EditModel.component.js +++ b/src/EditModel/EditModel.component.js @@ -1,13 +1,14 @@ -import React from 'react/addons'; +import React from 'react'; import Router from 'react-router'; -import FormForModel from 'd2-ui-basicfields/FormForModel.component'; +import FormForModel from '../forms/FormForModel.component'; import fieldOverrides from '../config/field-overrides/index'; import fieldOrderNames from '../config/field-config/field-order'; import headerFieldsNames from '../config/field-config/header-fields'; -import FormFieldsForModel from 'd2-ui-basicfields/FormFieldsForModel'; -import FormFieldsManager from 'd2-ui-basicfields/FormFieldsManager'; -import AttributeFields from 'd2-ui-basicfields/AttributeFields.component'; -import {getInstance as getD2} from 'd2'; +import disabledOnEdit from '../config/disabled-on-edit'; +import FormFieldsForModel from '../forms/FormFieldsForModel'; +import FormFieldsManager from '../forms/FormFieldsManager'; +// import AttributeFields from '../BasicFields/AttributeFields.component'; +import {getInstance as getD2} from 'd2/lib/d2'; import modelToEditStore from './modelToEditStore'; import objectActions from './objectActions'; import snackActions from '../Snackbar/snack.actions'; @@ -17,11 +18,16 @@ import Paper from 'material-ui/lib/paper'; import {isString} from 'd2-utils'; import SharingNotification from './SharingNotification.component'; import FormButtons from './FormButtons.component'; +import Form from 'd2-ui/lib/forms/Form.component'; +import log from 'loglevel'; +import FormHeading from './FormHeading'; +import camelCaseToUnderscores from 'd2-utils/camelCaseToUnderscores'; // TODO: Gives a flash of the old content when switching models (Should probably display a loading bar) export default class EditModel extends React.Component { constructor(props) { super(props); + this.state = { modelToEdit: undefined, isLoading: true, @@ -44,16 +50,21 @@ export default class EditModel extends React.Component { this.disposable = modelToEditStore .subscribe((modelToEdit) => { this.setState({ + fieldConfigs: formFieldsManager.getFormFieldsForModel(modelToEdit) + .map(fieldConfig => { + if (this.props.modelId !== 'add' && disabledOnEdit.for(modelType).indexOf(fieldConfig.name) !== -1) { + fieldConfig.fieldOptions.disabled = true; + } + return fieldConfig; + }), modelToEdit: modelToEdit, isLoading: false, }); }, (errorMessage) => { - console.log(errorMessage); snackActions.show({message: errorMessage}); }); this.setState({ - d2: d2, formFieldsManager: formFieldsManager, }); }); @@ -72,16 +83,16 @@ export default class EditModel extends React.Component { render() { const formPaperStyle = { width: '80%', - margin: '0 auto 2rem', - }; - - const innerFormPaperStyle = { + margin: '3rem auto 2rem', padding: '2rem 5rem 4rem', }; const renderForm = () => { - if (!this.state.d2) { - return undefined; + if (this.state.isLoading) { + + return ( + + ); } const saveButtonStyle = { @@ -90,17 +101,13 @@ export default class EditModel extends React.Component { return ( - - - - - {this.extraFieldsForModelType()} - + +
- - + + - +
); }; @@ -117,6 +124,10 @@ export default class EditModel extends React.Component { ); } + _updateForm(fieldName, value) { + objectActions.update({fieldName, value}); + } + saveAction(event) { event.preventDefault(); @@ -125,11 +136,12 @@ export default class EditModel extends React.Component { (message) => snackActions.show({message, action: 'Ok!'}), (errorMessage) => { if (isString(errorMessage)) { + log.debug(errorMessage.messages); snackActions.show({message: errorMessage}); } if (errorMessage.messages && errorMessage.messages.length > 0) { - console.log(errorMessage.messages); + log.debug(errorMessage.messages); snackActions.show({message: `${errorMessage.messages[0].property}: ${errorMessage.messages[0].message} `}); } } diff --git a/src/EditModel/EditModelContainer.component.js b/src/EditModel/EditModelContainer.component.js index c1deb2c1f..60a05f4ed 100644 --- a/src/EditModel/EditModelContainer.component.js +++ b/src/EditModel/EditModelContainer.component.js @@ -3,7 +3,7 @@ import EditModel from './EditModel.component'; import DataElementEditModel from './model-specific-components/DataElementEditModel.component'; import IndicatorEditModel from './model-specific-components/IndicatorEditModel.component'; import objectActions from './objectActions'; -import {config, getInstance as getD2} from 'd2'; +import {config, getInstance as getD2} from 'd2/lib/d2'; import modelToEditStore from './modelToEditStore'; import snackActions from '../Snackbar/snack.actions'; diff --git a/src/EditModel/FormHeading.js b/src/EditModel/FormHeading.js new file mode 100644 index 000000000..b8800f241 --- /dev/null +++ b/src/EditModel/FormHeading.js @@ -0,0 +1,18 @@ +import React from 'react'; +import Translate from 'd2-ui/lib/i18n/Translate.mixin'; +import Heading from 'd2-ui/lib/headings/Heading.component'; + +export default React.createClass({ + propTypes: { + text: React.PropTypes.string.isRequired, + level: React.PropTypes.number, + }, + + mixins: [Translate], + + render() { + return ( + + ); + }, +}); diff --git a/src/EditModel/SaveButton.component.js b/src/EditModel/SaveButton.component.js index fbc129cd4..8fd9a9be0 100644 --- a/src/EditModel/SaveButton.component.js +++ b/src/EditModel/SaveButton.component.js @@ -1,7 +1,7 @@ import React from 'react'; import RaisedButton from 'material-ui/lib/raised-button'; import Translate from 'd2-ui/lib/i18n/Translate.mixin'; -import {config} from 'd2'; +import {config} from 'd2/lib/d2'; config.i18n.strings.add('save'); @@ -14,8 +14,9 @@ const SaveButton = React.createClass({ mixins: [Translate], render() { + //!this.props.isFormValid() return ( - + ); }, }); diff --git a/src/EditModel/SharingNotification.component.js b/src/EditModel/SharingNotification.component.js index 1e86090ef..af9f6afd5 100644 --- a/src/EditModel/SharingNotification.component.js +++ b/src/EditModel/SharingNotification.component.js @@ -1,7 +1,7 @@ import React from 'react'; import Auth from 'd2-ui/lib/auth/Auth.mixin'; import Translate from 'd2-ui/lib/i18n/Translate.mixin'; -import {config} from 'd2'; +import {config} from 'd2/lib/d2'; import FontIcon from 'material-ui/lib/font-icon'; import Paper from 'material-ui/lib/paper'; diff --git a/src/EditModel/SingleModelStore.js b/src/EditModel/SingleModelStore.js index d8698730f..a6ded3ebc 100644 --- a/src/EditModel/SingleModelStore.js +++ b/src/EditModel/SingleModelStore.js @@ -1,5 +1,5 @@ import Store from 'd2-flux/store/Store'; -import {getInstance as getD2} from 'd2'; +import {getInstance as getD2} from 'd2/lib/d2'; import {Observable} from 'rx'; function loadModelFromD2(objectType, objectId) { @@ -31,7 +31,7 @@ const singleModelStoreConfig = { }, save() { - const importResultPromise = this.state.save() + const importResultPromise = this.state.save(true) .then(response => { if (response.response.importCount.imported === 1 || response.response.importCount.updated === 1) { return response; diff --git a/src/EditModel/model-specific-components/DataElementEditModel.component.js b/src/EditModel/model-specific-components/DataElementEditModel.component.js index 0d151027e..d7ded19fe 100644 --- a/src/EditModel/model-specific-components/DataElementEditModel.component.js +++ b/src/EditModel/model-specific-components/DataElementEditModel.component.js @@ -1,6 +1,6 @@ import React from 'react'; import EditModel from '../EditModel.component'; -import {getInstance as getD2} from 'd2'; +import {getInstance as getD2} from 'd2/lib/d2'; import modelToEditStore from '../modelToEditStore'; import objectActions from '../objectActions'; import DataElementGroupsFields from './DataElementGroupsFields.component'; diff --git a/src/EditModel/model-specific-components/DataElementGroupsFields.component.js b/src/EditModel/model-specific-components/DataElementGroupsFields.component.js index 5bf7da747..08cd5a8b8 100644 --- a/src/EditModel/model-specific-components/DataElementGroupsFields.component.js +++ b/src/EditModel/model-specific-components/DataElementGroupsFields.component.js @@ -1,7 +1,7 @@ -import {getInstance as getD2} from 'd2'; +import {getInstance as getD2} from 'd2/lib/d2'; import React from 'react'; import modelToEditStore from '../modelToEditStore'; -import FormFields from 'd2-ui-basicfields/FormFields.component'; +import FormFields from '../../BasicFields/FormFields.component'; import ReactSelect from 'react-select'; const rejectWhenGroupSetIs = (dataElementGroupSetId) => (dataElementGroup) => dataElementGroup.dataElementGroupSet && dataElementGroup.dataElementGroupSet.id !== dataElementGroupSetId; diff --git a/src/EditModel/model-specific-components/IndicatorEditModel.component.js b/src/EditModel/model-specific-components/IndicatorEditModel.component.js index ba5a842bf..ee8869608 100644 --- a/src/EditModel/model-specific-components/IndicatorEditModel.component.js +++ b/src/EditModel/model-specific-components/IndicatorEditModel.component.js @@ -1,9 +1,9 @@ import React from 'react'; import EditModel from '../EditModel.component'; -import {getInstance as getD2} from 'd2'; -import Pager from 'd2/pager/Pager'; +import {getInstance as getD2} from 'd2/lib/d2'; +import Pager from 'd2/lib/pager/Pager'; import Dialog from 'material-ui/lib/dialog'; -import FormUpdateContext from 'd2-ui-basicfields/FormUpdateContext.mixin'; +import FormUpdateContext from '../../BasicFields/FormUpdateContext.mixin'; import RaisedButton from 'material-ui/lib/raised-button'; // Indicator expression manager diff --git a/src/EditModel/model-specific-components/IndicatorExpressionManagerContainer.component.js b/src/EditModel/model-specific-components/IndicatorExpressionManagerContainer.component.js index 889689ea9..f4daf8a52 100644 --- a/src/EditModel/model-specific-components/IndicatorExpressionManagerContainer.component.js +++ b/src/EditModel/model-specific-components/IndicatorExpressionManagerContainer.component.js @@ -1,10 +1,10 @@ import React from 'react'; import Action from 'd2-flux/action/Action'; -import FormUpdateContext from 'd2-ui-basicfields/FormUpdateContext.mixin'; +import FormUpdateContext from '../../BasicFields/FormUpdateContext.mixin'; import IndicatorExpressionManager from 'd2-ui/lib/indicator-expression-manager/IndicatorExpressionManager.component'; import indicatorExpressionStatusStore from 'd2-ui/lib/indicator-expression-manager/indicatorExpressionStatus.store'; import dataElementOperandSelectorActions from 'd2-ui/lib/indicator-expression-manager/dataElementOperandSelector.actions'; -import {getInstance as getD2} from 'd2'; +import {getInstance as getD2} from 'd2/lib/d2'; import {Observable} from 'rx'; import Translate from 'd2-ui/lib/i18n/Translate.mixin'; diff --git a/src/EditModel/objectActions.js b/src/EditModel/objectActions.js index 4b77c0f0a..9b93cc2c8 100644 --- a/src/EditModel/objectActions.js +++ b/src/EditModel/objectActions.js @@ -2,8 +2,11 @@ import Action from 'd2-flux/action/Action'; import modelToEditStore from './modelToEditStore'; import {isFunction} from 'd2-utils'; import log from 'loglevel'; +import ModelCollectionProperty from 'd2/lib/model/ModelCollectionProperty'; -const objectActions = Action.createActionsFromNames(['getObjectOfTypeById', 'getObjectOfTypeByIdAndClone', 'saveObject', 'afterSave', 'saveAndRedirectToList']); +const isUndefinedOrNullOrNaN = (value) => (value === undefined) || (value === null) || Number.isNaN(value); + +const objectActions = Action.createActionsFromNames(['getObjectOfTypeById', 'getObjectOfTypeByIdAndClone', 'saveObject', 'afterSave', 'saveAndRedirectToList', 'update']); objectActions.getObjectOfTypeById .subscribe(({data, complete, error}) => { @@ -42,4 +45,26 @@ objectActions.saveObject.subscribe(action => { .subscribe(successHandler, errorHandler); }); +objectActions.update.subscribe(action => { + const {fieldName, value} = action.data; + const modelToEdit = modelToEditStore.getState(); + + if (modelToEdit) { + if (!(modelToEdit[fieldName] && modelToEdit[fieldName].constructor && modelToEdit[fieldName].constructor.name === 'ModelCollectionProperty')) { + log.debug(`Change ${fieldName} to ${value}`); + modelToEdit[fieldName] = value; + log.debug(`Value is now: ${modelToEdit.dataValues[fieldName]}`); + } else { + log.debug('Not updating anything'); + } + + modelToEditStore.setState(modelToEdit); + + action.complete(); + } else { + log.error(`modelToEdit does not exist`); + action.error(); + } +}); + export default objectActions; diff --git a/src/List/ContextActions.js b/src/List/ContextActions.js index 80b1287ae..4afb65710 100644 --- a/src/List/ContextActions.js +++ b/src/List/ContextActions.js @@ -1,8 +1,11 @@ import Router from 'react-router'; import Action from 'd2-flux/action/Action'; import detailsStore from './details.store'; -import {config, getInstance as getD2} from 'd2'; +import {config, getInstance as getD2} from 'd2/lib/d2'; import {camelCaseToUnderscores} from 'd2-utils'; +import snackActions from '../Snackbar/snack.actions'; +import log from 'loglevel'; +import listStore from './list.store'; config.i18n.strings.add('edit'); config.i18n.strings.add('clone'); @@ -39,10 +42,25 @@ contextActions.delete .then(() => { model.delete() .then(() => { - console.info('Deleted!'); + + //Remove deleted item from the listStore + if (listStore.getState() && listStore.getState().list) { + listStore.setState({ + pager: listStore.getState().pager, + list: listStore.getState().list + .filter(modelToCheck => modelToCheck.id !== model.id) + }); + } + + snackActions.show({ + message: `${model.name} ${d2.i18n.getTranslation('was_deleted')}`, + }); }) .catch(response => { - console.warn(response.responseJSON.message); + log.warn(response); + snackActions.show({ + message: `${model.name} ${d2.i18n.getTranslation('was_not_deleted')}`, + }); }); }); }); diff --git a/src/List/List.component.js b/src/List/List.component.js index 22295ab7e..1942ea4a7 100644 --- a/src/List/List.component.js +++ b/src/List/List.component.js @@ -13,7 +13,7 @@ import listStore from './list.store'; import listActions from './list.actions'; import ObserverRegistry from '../utils/ObserverRegistry.mixin'; import Paper from 'material-ui/lib/paper'; -import {config} from 'd2'; +import {config} from 'd2/lib/d2'; import Translate from 'd2-ui/lib/i18n/Translate.mixin'; import ListActionBar from './ListActionBar.component'; import SearchBox from './SearchBox.component'; @@ -62,8 +62,6 @@ const List = React.createClass({ statics: { willTransitionTo(transition, params, query, callback) { - console.log('Loading a list'); - executeLoadListAction(params.modelType) .subscribe( (message) => { console.info(message); callback(); }, @@ -71,7 +69,6 @@ const List = React.createClass({ if (/^.+s$/.test(params.modelType)) { const nonPluralAttempt = params.modelType.substring(0, params.modelType.length - 1); log.warn(`Could not find requested model type '${params.modelType}' attempting to redirect to '${nonPluralAttempt}'`); - console.log(this); transition.redirect('list', {modelType: nonPluralAttempt}); callback(); } else { @@ -178,7 +175,6 @@ const List = React.createClass({ searchListByName(searchObserver) { const searchListByNameDisposable = searchObserver .subscribe((value) => { - console.log('Starting search'); this.setState({ isLoading: true, }); diff --git a/src/List/SearchBox.component.js b/src/List/SearchBox.component.js index 24e0e1508..63f49f7cc 100644 --- a/src/List/SearchBox.component.js +++ b/src/List/SearchBox.component.js @@ -2,7 +2,7 @@ import React from 'react'; import ObservedEvents from '../utils/ObservedEvents.mixin'; import Translate from 'd2-ui/lib/i18n/Translate.mixin'; import TextField from 'material-ui/lib/text-field'; -import {config} from 'd2'; +import {config} from 'd2/lib/d2'; config.i18n.strings.add('search_by_name'); config.i18n.strings.add('press_enter_to_search'); diff --git a/src/List/list.store.js b/src/List/list.store.js index b426e7d54..defd7d408 100644 --- a/src/List/list.store.js +++ b/src/List/list.store.js @@ -1,4 +1,4 @@ -import {getInstance as getD2} from 'd2'; +import {getInstance as getD2} from 'd2/lib/d2'; import {Subject, Observable} from 'rx'; import Store from 'd2-flux/store/Store'; @@ -46,9 +46,13 @@ export default Store.create({ error(modelType + ' is not a valid schema name'); } - const listSearchPromise = d2.models[modelType] - .filter().on('name').like(searchString) - .list({fields: 'name,id,lastUpdated'}); + let modelDefinition = d2.models[modelType]; + + if (searchString) { + modelDefinition = d2.models[modelType].filter().on('name').like(searchString); + } + + const listSearchPromise = modelDefinition.list({fields: 'name,id,code,description'}); this.listSourceSubject.onNext(Observable.fromPromise(listSearchPromise)); diff --git a/src/SideBar/SideBar.component.js b/src/SideBar/SideBar.component.js index 68d05f965..033a04f35 100644 --- a/src/SideBar/SideBar.component.js +++ b/src/SideBar/SideBar.component.js @@ -4,12 +4,12 @@ import ObservedEvents from '../utils/ObservedEvents.mixin'; import List from 'material-ui/lib/lists/list'; import ListItem from 'material-ui/lib/lists/list-item'; import TextField from 'material-ui/lib/text-field'; +import SideBarItem from './SideBarItem.component'; const SideBar = React.createClass({ propTypes: { filterChildren: React.PropTypes.func, items: React.PropTypes.array.isRequired, - title: React.PropTypes.string.isRequired, searchHint: React.PropTypes.string.isRequired, }, @@ -39,10 +39,7 @@ const SideBar = React.createClass({ .map(event => event && event.target && event.target.value ? event.target.value : '') .subscribe( searchString => { - console.log(searchString); - this.setState({ - searchString: searchString, - }); + this.setState({searchString: searchString}); }, error => { log.error('Could not set the search string', error); @@ -59,16 +56,18 @@ const SideBar = React.createClass({ return null; } } - return (); + return (); }); return (
-

{this.props.title}

- +
+ + {this.state && this.state.showCloseButton ? clear : null} +
{this.filteredChildren} @@ -78,7 +77,11 @@ const SideBar = React.createClass({ selectTheFirst() { // TODO: This ties into the DOM, which is not really best practice. Think about a way to solve this without having to do other hacky magic. - React.findDOMNode(this).querySelector('a').click(); + const firstItem = React.findDOMNode(this).querySelector('a'); + + if (firstItem) { + firstItem.click(); + } }, }); diff --git a/src/SideBar/SideBarContainer.component.js b/src/SideBar/SideBarContainer.component.js index 5654bcc9f..15606d000 100644 --- a/src/SideBar/SideBarContainer.component.js +++ b/src/SideBar/SideBarContainer.component.js @@ -2,7 +2,7 @@ import React from 'react'; import {State, Navigation} from 'react-router'; import sideBarItemsStore from './sideBarItems.store'; import SideBar from './SideBar.component'; -import {config} from 'd2'; +import {config} from 'd2/lib/d2'; import Translate from 'd2-ui/lib/i18n/Translate.mixin'; import {camelCaseToUnderscores} from 'd2-utils'; @@ -32,8 +32,6 @@ const SideBarContainer = React.createClass({ .map(listItem => { return { primaryText: this.getTranslation(camelCaseToUnderscores(listItem)), - secondaryText: this.getTranslation(`intro_${camelCaseToUnderscores(listItem)}`), - secondaryTextLines: 2, modelType: listItem, isActive: this.isActive('list', {modelType: listItem}), onClick: function onClick() { @@ -43,8 +41,7 @@ const SideBarContainer = React.createClass({ }); return ( - diff --git a/src/SideBar/SideBarItem.component.js b/src/SideBar/SideBarItem.component.js new file mode 100644 index 000000000..8e69c9d02 --- /dev/null +++ b/src/SideBar/SideBarItem.component.js @@ -0,0 +1,38 @@ +import React from 'react'; +import ListItem from 'material-ui/lib/lists/list-item'; + +export default React.createClass({ + propTypes: { + label: React.PropTypes.string.isRequired, + isActive: React.PropTypes.bool.isRequired, + }, + + contextTypes: { + muiTheme: React.PropTypes.object, + }, + + render() { + const { + label, + style, + isActive, + ...rest, + } = this.props; + + const theme = this.context.muiTheme; + const listStyle = { + backgroundColor: isActive ? theme.sideBar.backgroundColorItemActive : theme.sideBar.backgroundColorItem, + color: isActive ? theme.sideBar.textColorActive : theme.sideBar.textColor, + fontSize: 14, + fontWeight: isActive ? 'bold' : 'inherit', + }; + + return ( + + ); + }, +}); diff --git a/src/SideBar/sideBarItems.store.js b/src/SideBar/sideBarItems.store.js index 60ac0fa3e..51fd420b0 100644 --- a/src/SideBar/sideBarItems.store.js +++ b/src/SideBar/sideBarItems.store.js @@ -1,5 +1,5 @@ import Store from 'd2-flux/store/Store'; -import {getInstance as getD2} from 'd2'; +import {getInstance as getD2} from 'd2/lib/d2'; const sideBarItemsStore = Store.create(); const isInPredefinedList = (name) => { diff --git a/src/Snackbar/snack.actions.js b/src/Snackbar/snack.actions.js index 445f8868f..fccee2296 100644 --- a/src/Snackbar/snack.actions.js +++ b/src/Snackbar/snack.actions.js @@ -1,6 +1,6 @@ import Action from 'd2-flux/action/Action'; import snackStore from './snack.store'; -import {config, getInstance as getD2} from 'd2'; +import {config, getInstance as getD2} from 'd2/lib/d2'; const snackActions = Action.createActionsFromNames(['show', 'hide']); diff --git a/src/config/disabled-on-edit/categoryOptionGroup.js b/src/config/disabled-on-edit/categoryOptionGroup.js new file mode 100644 index 000000000..c62673e3e --- /dev/null +++ b/src/config/disabled-on-edit/categoryOptionGroup.js @@ -0,0 +1,3 @@ +export default [ + 'dataDimensionType', +]; diff --git a/src/config/disabled-on-edit/index.js b/src/config/disabled-on-edit/index.js new file mode 100644 index 000000000..19de20079 --- /dev/null +++ b/src/config/disabled-on-edit/index.js @@ -0,0 +1,14 @@ +import categoryOptionGroup from './categoryOptionGroup'; + +const disabledByType = { + categoryOptionGroup +}; + +export default { + for(schemaName) { + if (schemaName && disabledByType[schemaName]) { + return disabledByType[schemaName]; + } + return []; + }, +}; diff --git a/src/config/field-config/field-order.js b/src/config/field-config/field-order.js index ed6ad51bd..253c5380d 100644 --- a/src/config/field-config/field-order.js +++ b/src/config/field-config/field-order.js @@ -2,9 +2,11 @@ const fieldOrderByName = new Map([ ['dataElement', [ 'name', 'shortName', 'code', 'description', 'formName', 'domainType', 'valueType', 'aggregationType', 'zeroIsSignificant', 'url', 'categoryCombo', 'optionSet', 'commentOptionSet', 'legendSet', 'aggregationLevels']], - ['dataElementGroupSet', ['name', 'shortName', 'code', 'description', 'compulsory', 'dataDimension']], + ['dataElementGroup', ['name', 'shortName', 'code', 'dataElements']], + ['dataElementGroupSet', ['name', 'shortName', 'code', 'description', 'compulsory', 'dataDimension', 'dataElementGroups']], ['category', ['name', 'shortName', 'code', 'dataDimension', 'dataDimensionType', 'categoryOptions']], ['categoryCombo', ['name', 'code', 'dimensionType', 'skipTotal', 'categories']], + ['categoryOptionGroup', ['name', 'shortName', 'code', 'dataDimensionType', 'categoryOptions']], ['categoryOptionGroupSet', ['name', 'description', 'dataDimension', 'categoryOptionGroups']], ['indicator', ['name', 'shortName', 'code', 'description', 'annualized', 'decimals', 'indicatorType', 'legendSet', 'url']], ['indicatorGroup', ['name', 'indicators']], diff --git a/src/config/field-overrides/dataElement.js b/src/config/field-overrides/dataElement.js index 49cf1b8bd..fcdb88ada 100644 --- a/src/config/field-overrides/dataElement.js +++ b/src/config/field-overrides/dataElement.js @@ -1,5 +1,5 @@ -import {SELECT, MULTISELECT} from 'd2-ui-basicfields/fields'; -import {config, getInstance as getD2} from 'd2'; +import {SELECT, MULTISELECT} from '../../BasicFields//fields'; +import {config, getInstance as getD2} from 'd2/lib/d2'; // TODO: Perhaps these translations should be generated somehow config.i18n.strings.add('value_type'); diff --git a/src/forms/FormFieldsForModel.js b/src/forms/FormFieldsForModel.js new file mode 100644 index 000000000..c6a3b92ef --- /dev/null +++ b/src/forms/FormFieldsForModel.js @@ -0,0 +1,87 @@ +import log from 'loglevel'; +import camelCaseToUnderscores from 'd2-utils/camelCaseToUnderscores'; + +import {fieldTypeClasses, typeToFieldMap} from './fields'; + +const fieldNamesToIgnoreOnDisplay = ['id', 'publicAccess', 'created', 'lastUpdated', 'user', 'userGroupAccesses', 'attributeValues']; + +class FormFieldsForModel { + constructor(models, FIELDS_TO_IGNORE_ON_DISPLAY = fieldNamesToIgnoreOnDisplay) { + if (!models) { + log.warn('Warning: `models` passed to FormFieldsForModel is undefined, therefore async select boxes ' + + 'and references fields might not work.'); + } + + this.fieldNamesToIgnoreOnDisplay = FIELDS_TO_IGNORE_ON_DISPLAY; + this.fieldOrder = []; + this.models = models; + } + + setDefaultFieldOrder(fieldNames) { + this.fieldOrder = fieldNames || []; + } + + getFormFieldsForModel(model, fieldOverrides = {}) { + if (!(model && model.modelDefinition && model.modelDefinition.modelValidations)) { + throw new TypeError('Passed model does not seem to adhere to the d2 model structure ' + + '(model.modelDefinition.modelValidations is not available)'); + } + + const removeFieldsThatShouldNotBeDisplayed = modelValidation => this.fieldNamesToIgnoreOnDisplay.indexOf(modelValidation.fieldName) === -1; + const onlyUsableFieldTypes = modelValidation => fieldTypeClasses.get(typeToFieldMap.get(modelValidation.type)); + const onlyWritableProperties = modelValidation => modelValidation.writable; + const onlyPersistedProperties = modelValidation => modelValidation.persisted; + const onlyOwnedProperties = modelValidation => modelValidation.owner; + const toArrayOfFieldConfigurations = fieldName => { + const modelValidationForField = model.modelDefinition.modelValidations[fieldName]; + const fieldConfig = Object.create(modelValidationForField); + + fieldConfig.name = fieldName; + fieldConfig.fieldOptions = { + labelText: camelCaseToUnderscores(fieldName), + model: model, + }; + + return fieldConfig; + }; + + const fieldInstances = Object.keys(model.modelDefinition.modelValidations) + .map(toArrayOfFieldConfigurations) + .filter(onlyWritableProperties) + .filter(onlyPersistedProperties) + .filter(onlyOwnedProperties) + .filter(removeFieldsThatShouldNotBeDisplayed) + .filter(onlyUsableFieldTypes) + .map(modelValidation => getFieldClassInstance.bind(this)(modelValidation, model.modelDefinition)) // eslint-disable-line no-use-before-define + .filter(field => field); + + if (!this.fieldOrder || !this.fieldOrder.length) { + return fieldInstances; + } + + return fieldInstances + .filter(field => this.fieldOrder.indexOf(field.name) !== -1) + .sort((left, right) => this.fieldOrder.indexOf(left.name) > this.fieldOrder.indexOf(right.name) ? 1 : -1); + + function getFieldClassInstance(modelValidation, modelDefinition) { + const overrideConfig = fieldOverrides[modelValidation.fieldName]; + const isOverridden = !!overrideConfig; + let fieldType = typeToFieldMap.get(modelValidation.type); + if (isOverridden) { + if (overrideConfig.type) { + fieldType = overrideConfig.type; + } + + Object.keys(overrideConfig) + .filter(key => key !== 'type') + .forEach(key => { + modelValidation[key] = overrideConfig[key]; + }); + } + + return fieldTypeClasses.get(fieldType)(modelValidation, modelDefinition, this.models); + } + } +} + +export default FormFieldsForModel; diff --git a/src/forms/FormFieldsManager.js b/src/forms/FormFieldsManager.js new file mode 100644 index 000000000..a8b38745f --- /dev/null +++ b/src/forms/FormFieldsManager.js @@ -0,0 +1,44 @@ +import {TEXT} from './fields'; + +class FormFieldsManager { + constructor(fieldsForModelService) { + this.fieldsForModelService = fieldsForModelService; + + this.fieldOverrides = { + description: {type: TEXT}, + }; + + this.headerFields = []; + } + + getFormFieldsForModel(model) { + return this.fieldsForModelService.getFormFieldsForModel(model, this.fieldOverrides); + } + + setFieldOrder(fieldNames) { + this.fieldsForModelService.setDefaultFieldOrder(fieldNames); + return this; + } + + getHeaderFieldsForModel(model) { + return this.getFormFieldsForModel(model) + .filter((fieldConfig) => this.headerFields.indexOf(fieldConfig.key) !== -1); + } + + getNonHeaderFieldsForModel(model) { + return this.getFormFieldsForModel(model) + .filter((fieldConfig) => this.headerFields.indexOf(fieldConfig.key) === -1); + } + + setHeaderFields(fieldNames) { + this.headerFields = Array.from(new Set(fieldNames)); + return this; + } + + addFieldOverrideFor(fieldName, fieldConfig) { + this.fieldOverrides[fieldName] = fieldConfig; + return this; + } +} + +export default FormFieldsManager; diff --git a/src/forms/FormForModel.component.js b/src/forms/FormForModel.component.js new file mode 100644 index 000000000..cef45fca4 --- /dev/null +++ b/src/forms/FormForModel.component.js @@ -0,0 +1,59 @@ +import React from 'react'; +import classes from 'classnames'; + +import FormFieldsForModel from './FormFieldsForModel'; +import FormFieldsManager from './FormFieldsManager'; + +import Form from 'd2-ui/lib/forms/Form.component'; +//import InputField from './InputField.component'; +//import FormFields from './FormFields.component'; + +const FormForModel = React.createClass({ + propTypes: { + name: React.PropTypes.string.isRequired, + model: React.PropTypes.object.isRequired, + formFieldsManager: React.PropTypes.instanceOf(FormFieldsManager), + children: React.PropTypes.arrayOf(React.PropTypes.node), + formStyle: React.PropTypes.object, + }, + + getInitialState() { + return { + formFieldsManager: this.props.formFieldsManager || new FormFieldsManager(new FormFieldsForModel(this.props.d2.models)), + }; + }, + + render() { + const classList = classes('form-for-model'); + const headerFields = [].concat(this.state.formFieldsManager.getHeaderFieldsForModel(this.props.model)); + const fields = [].concat(this.state.formFieldsManager.getNonHeaderFieldsForModel(this.props.model)); + + const children = this.props.children || []; + + return ( +
+
+ + {headerFields.map(field => { + return (); + })} + + + {fields.map((field) => { + return (); + })} + + {children} +
+
+ ); + }, +}); + +export default FormForModel; diff --git a/src/forms/fields.js b/src/forms/fields.js new file mode 100644 index 000000000..4a0803046 --- /dev/null +++ b/src/forms/fields.js @@ -0,0 +1,190 @@ +import {isRequired, isNumber as isNumberValidator} from 'd2-ui/lib/forms/Validators'; +import isNumber from 'lodash.isnumber'; +import log from 'loglevel'; + +// FormField components +import TextField from './form-fields/text-field'; +import MultiSelect from './form-fields/multi-select'; +import CheckBox from './form-fields/check-box'; +import NumberField from './form-fields/number-field'; +import DropDown from './form-fields/drop-down'; + +function getValidatorsFromModelValidation(modelValidation, modelDefinition) { + let validators = []; + + if (modelValidation.required) { + validators.push(isRequired); + } + + if (modelDefinition) { + validators = validators.concat(addValidatorForType(modelValidation.type, modelValidation, modelDefinition)); + } + + return validators; +} + +function toInteger(value) { + return Number.parseInt(value, 10); +} + +function isIntegerValidator(value) { + console.log(value, Number.parseInt(value, 10) === Number.parseFloat(value)); + return Number.parseInt(value, 10) === Number.parseFloat(value); +} +isIntegerValidator.message = 'number_should_not_have_decimals'; + +function addValidatorForType(type, modelValidation, modelDefinition) { + const validators = []; + + switch (type) { + case 'INTEGER': + validators.push(isNumberValidator); + validators.push(isIntegerValidator); + + if (isNumber(modelValidation.max)) { + function max(value) { + return Number(value) <= modelValidation.max; + } + max.message = 'value_not_max'; + validators.push(max); + } + + if (isNumber(modelValidation.min)) { + function min(value) { + return Number(value) >= modelValidation.min; + } + min.message = 'value_not_min'; + validators.push(min); + } + + break; + case 'IDENTIFIER': + case 'TEXT': + if (isNumber(modelValidation.max)) { + function max(value) { + return value.length <= modelValidation.max; + } + max.message = 'value_not_max'; + validators.push(max); + } + + if (isNumber(modelValidation.min)) { + function min(value) { + return value.length >= modelValidation.min; + } + min.message = 'value_not_min'; + validators.push(min); + } + + if (modelValidation.unique) { + function checkAgainstServer(value) { + // Don't validate against the server when we have no value + if (!value.trim()) { + return Promise.resolve(true); + } + + if (modelValidation.modelDefinition) { + log.error('No modelDefintion found on validation object.'); + + return Promise.reject('could_not_run_async_validation') + } + + return modelDefinition + .filter().on(modelValidation.fieldOptions.referenceProperty).equals(value) + .list() + .then(collection => { + if (collection.size !== 0) { + return Promise.reject('value_not_unique'); + } else { + return Promise.resolve(true); + } + }); + } + + validators.push(checkAgainstServer); + } + + break; + } + + return validators; +} + +function getFieldUIComponent(type) { + switch (type) { + case 'CONSTANT': + return DropDown; + break; + case 'BOOLEAN': + return CheckBox; + break; + case 'COLLECTION': + return MultiSelect; + break; + case 'INTEGER': + return NumberField; + break; + case 'TEXT': + case 'IDENTIFIER': + default: + break; + } + return TextField; +} + +export function createFieldConfig(fieldConfig, modelDefinition, models) { + const basicFieldConfig = { + type: getFieldUIComponent(fieldConfig.type), + fieldOptions: Object.assign(fieldConfig.fieldOptions || {}, { + floatingLabelText: fieldConfig.fieldOptions.labelText, + modelDefinition: modelDefinition, + models: models, + referenceType: fieldConfig.referenceType, + referenceProperty: fieldConfig.name, + isInteger: fieldConfig.type === 'INTEGER', + multiLine: fieldConfig.name === 'description', + fullWidth: true, + options: fieldConfig.constants + .map((constant) => { + return { + text: constant, + value: constant, + }; + }), + }) + }; + + if (fieldConfig.type === 'INTEGER') { + basicFieldConfig.beforeUpdateConverter = toInteger; + } + + const validators = [].concat(getValidatorsFromModelValidation(fieldConfig, modelDefinition)); + + return Object.assign(fieldConfig, {validators}, basicFieldConfig); +} + +export const CHECKBOX = Symbol('CHECKBOX'); +export const INPUT = Symbol('INPUT'); +export const SELECT = Symbol('SELECT'); +export const SELECTASYNC = Symbol('SELECTASYNC'); +export const MULTISELECT = Symbol('MULTISELECT'); +export const TEXT = Symbol('TEXT'); + +export const fieldTypeClasses = new Map([ + [CHECKBOX, createFieldConfig], + [INPUT, createFieldConfig], + [SELECT, createFieldConfig], + //[SELECTASYNC, SelectBoxAsync], + [MULTISELECT, createFieldConfig], +]); + +export const typeToFieldMap = new Map([ + ['BOOLEAN', CHECKBOX], + ['CONSTANT', SELECT], + ['IDENTIFIER', INPUT], //TODO: Add identifiers for the type of field... + // ['REFERENCE', SELECTASYNC], + ['TEXT', INPUT], + ['COLLECTION', MULTISELECT], + ['INTEGER', INPUT], // TODO: Add Numberfield! + // ['URL', INPUT], // TODO: Add Url field? +]); diff --git a/src/forms/form-fields/check-box.js b/src/forms/form-fields/check-box.js new file mode 100644 index 000000000..f7d5b365a --- /dev/null +++ b/src/forms/form-fields/check-box.js @@ -0,0 +1,30 @@ +import React from 'react/addons'; +import Checkbox from 'material-ui/lib/checkbox'; +import Translate from 'd2-ui/lib/i18n/Translate.mixin'; + +import MuiThemeMixin from '../mui-theme.mixin'; + +export default React.createClass({ + propTypes: { + onChange: React.PropTypes.func.isRequired, + }, + + mixins: [MuiThemeMixin, Translate], + + _onClick() { + // TODO: Emit a proper event..? + this.props.onChange({ + target: { + value: this.props.defaultValue !== true, + }, + }) + }, + + render() { + return ( +
+ +
+ ); + }, +}); diff --git a/src/forms/form-fields/drop-down.js b/src/forms/form-fields/drop-down.js new file mode 100644 index 000000000..8ba3a1357 --- /dev/null +++ b/src/forms/form-fields/drop-down.js @@ -0,0 +1,42 @@ +import React from 'react/addons'; +import SelectField from 'material-ui/lib/select-field'; + +import MuiThemeMixin from '../mui-theme.mixin'; + +export default React.createClass({ + propTypes: { + defaultValue: React.PropTypes.oneOfType([ + React.PropTypes.string, + React.PropTypes.number, + React.PropTypes.bool, + ]), + onFocus: React.PropTypes.func, + onBlur: React.PropTypes.func, + }, + + mixins: [MuiThemeMixin], + + getInitialState() { + return { + value: this.props.defaultValue ? this.props.defaultValue : 'null', + options: this.props.options + .map((option) => { + return { + payload: option.value, + text: option.text, + }; + }) + }; + }, + + render() { + const {onFocus, onBlur, ...other} = this.props; + return ( + + ); + }, +}); diff --git a/src/forms/form-fields/file-upload.js b/src/forms/form-fields/file-upload.js new file mode 100644 index 000000000..d84a20067 --- /dev/null +++ b/src/forms/form-fields/file-upload.js @@ -0,0 +1,192 @@ +import React from 'react/addons'; +import log from 'loglevel'; + +import LinearProgress from 'material-ui/lib/linear-progress'; +import FlatButton from 'material-ui/lib/flat-button'; +import Dialog from 'material-ui/lib/dialog'; + +import Translate from 'd2-ui/lib/i18n/Translate.mixin'; + +import Checkbox from 'material-ui/lib/checkbox'; +import AppTheme from '../theme'; + +export default React.createClass({ + propTypes: { + name: React.PropTypes.oneOf(['logo_front', 'logo_banner']).isRequired, + label: React.PropTypes.string.isRequired, + value: React.PropTypes.bool.isRequired, + defaultValue: React.PropTypes.bool.isRequired, + isEnabled: React.PropTypes.bool.isRequired, + + onFocus: React.PropTypes.func, + onBlur: React.PropTypes.func, + onChange: React.PropTypes.func, + }, + + mixins: [Translate], + + getInitialState() { + return { + isEnabled: this.props.isEnabled, + uploading: false, + progress: undefined, + }; + }, + + renderUploading() { + const progressStyle = { + position: 'absolute', + left: 0, + right: 0, + zIndex: 1, + }; + + return ( +
+ +
+ +
+
+ ); + }, + + renderUpload() { + const bodyStyle = { + backgroundColor: AppTheme.rawTheme.palette.accent1Color, + textAlign: 'center', + overflow: 'auto', + padding: 48, + }; + + const apiBase = this.context.d2.Api.getApi().baseUrl; + const imgUrl = [apiBase, 'staticContent', this.props.name].join('/'); + + if (this.state.isEnabled) { + return ( +
+ + + + + +
+ ); + } + + return ( + + ); + }, + + render() { + const {onFocus, onBlur, onChange, ...other} = this.props; + + const containerStyle = { + position: 'relative', + display: 'block', + whiteSpace: 'nowrap', + }; + + const checkStyle = { + display: 'inline-block', + whiteSpace: 'nowrap', + paddingRight: 8, + paddingTop: 8, + paddingBottom: 8, + }; + + const btnStyle = { + display: 'inline-block', + position: 'absolute', + top: 2, + }; + + return ( +
+
+ +
+
+ { this.state.uploading ? this.renderUploading() : this.renderUpload() } + this.fileInput = ref} + onChange={this._upload} /> +
+
+ ); + }, + + _fileClick(e) { + if (this.fileInput && !this.state.uploading) { + this.fileInput.getDOMNode().click(e); + } else if (this.state.uploading) { + this.xhr.abort(); + this.setState({uploading: false, progress: undefined}); + log.info('File upload cancelled'); + } + }, + + _previewClick() { + this.refs.dialog.show(); + }, + + _check(e) { + this.props.onChange({target: {value: e.target.checked}}); + }, + + _upload(e) { + if (e.target.files.length === 0) { + return; + } + + this.setState({ + uploading: true, + progress: undefined, + }); + + const api = this.context.d2.Api.getApi(); + const xhr = new XMLHttpRequest(); + xhr.upload.onprogress = (progress) => { + if (progress.lengthComputable) { + this.setState({progress: (progress.loaded / progress.total) * 100}); + } else { + this.setState({progress: undefined}); + } + }; + this.xhr = xhr; + + const data = new FormData(); + data.append('file', e.target.files[0]); + + api.post(['staticContent', this.props.name].join('/'), data, { + contentType: false, + processData: false, + xhr: () => { return xhr; }, + }).then(() => { + log.info('File uploaded successfully'); + this.props.onChange({target: {value: true}}); + this.setState({ + uploading: false, + progress: undefined, + isEnabled: true, + }); + }).catch(() => { + log.warn('File upload failed:', arguments); + this.props.onChange({target: {value: false}}); + this.setState({ + uploading: false, + progress: undefined, + isEnabled: false, + }); + }); + }, +}); diff --git a/src/forms/form-fields/multi-select.js b/src/forms/form-fields/multi-select.js new file mode 100644 index 000000000..077e71770 --- /dev/null +++ b/src/forms/form-fields/multi-select.js @@ -0,0 +1,195 @@ +import React from 'react'; +import Store from 'd2-flux/store/Store'; +import {getInstance} from 'd2/lib/d2'; +import GroupEditor from 'd2-ui/lib/group-editor/GroupEditor.component'; +import Action from 'd2-flux/action/Action'; +import Translate from 'd2-ui/lib/i18n/Translate.mixin'; +import TextField from 'material-ui/lib/text-field'; +import Heading from 'd2-ui/lib/headings/Heading.component'; +import camelCaseToUnderscores from 'd2-utils/camelCaseToUnderscores'; +import {config} from 'd2/lib/d2'; + +config.i18n.strings.add('search_available_selected_items'); + +export const multiSelectActions = Action.createActionsFromNames([ + 'addItemsToModelCollection', + 'removeItemsFromModelCollection' +]); + +function unique(values) { + return Array.from((new Set(values)).values()); +} + +function filterModelsMapOnItemIds(map, items) { + return Array + .from(map.values()) + .filter(model => items.indexOf(model.id) !== -1) +} + +multiSelectActions.addItemsToModelCollection + .subscribe(({data, complete, error}) => { + try { + const [modelsToAdd, propertyName, model] = data; + + if (!model[propertyName]) { + error(`Model does not have property called '${propertyName}'`); + } + + modelsToAdd + .forEach(itemToAdd => { + model[propertyName].add(itemToAdd); + }); + + complete(); + } catch (e) { + console.error(e); + } + }); + +multiSelectActions.removeItemsFromModelCollection + .subscribe(({data, complete, error}) => { + const [modelsToRemove, propertyName, model] = data; + + if (!model[propertyName]) { + error(`Model does not have property called '${propertyName}'`); + } + + modelsToRemove + .forEach(itemToRemove => { + model[propertyName].remove(itemToRemove); + }); + + complete(); + }); + +export default React.createClass({ + mixins: [Translate], + + getInitialState() { + const itemStore = Store.create(); + itemStore.state = []; + const assignedItemStore = Store.create(); + assignedItemStore.state = []; + + return { + itemStore, + assignedItemStore, + filterText: '', + }; + }, + + componentWillMount() { + if (!this.props.referenceType) { return; } + + getInstance() + .then(this.loadAvailableItems) + .then(this.populateItemStore) + .then(this.populateAssignedStore); + }, + + loadAvailableItems(d2) { + if (d2.models[this.props.referenceType]) { + return d2.models[this.props.referenceType].list({paging: false, fields: 'displayName|rename(name),id'}); + } + return Promise.reject(`${this.props.referenceType} is not a model on d2.models`); + }, + + populateItemStore(availableItems) { + this.state.itemStore.setState(availableItems) + }, + + populateAssignedStore() { + this.state.assignedItemStore.setState(Array.from(this.props.defaultValue.values()).map(value => value.id)) + }, + + _assignItems(items) { + return new Promise((resolve, reject) => { + const modelsToAdd = filterModelsMapOnItemIds(this.state.itemStore.state, items); + + multiSelectActions.addItemsToModelCollection(modelsToAdd, this.props.referenceProperty, this.props.model) + .subscribe(() => { + const newAssignedItems = [] + .concat(this.state.assignedItemStore.getState()) + .concat(items) + .filter(value => value); + + this.state.assignedItemStore.setState(unique([].concat(newAssignedItems))); + + this.props.onChange({ + target: { + value: this.props.model[this.props.referenceProperty], + }, + }); + + resolve(); + }, reject); + }); + }, + + _removeItems(items) { + return new Promise((resolve, reject) => { + const modelsToRemove = filterModelsMapOnItemIds(this.state.itemStore.state, items); + + multiSelectActions.removeItemsFromModelCollection(modelsToRemove, this.props.referenceProperty, this.props.model) + .subscribe(() => { + const newAssignedItems = [] + .concat(this.state.assignedItemStore.getState()) + .filter(item => items.indexOf(item) === -1) + .filter(value => value); + + this.updateForm(newAssignedItems); + resolve(); + }, reject); + }); + }, + + _setFilterText(event) { + this.setState({ + filterText: event.target.value, + }); + }, + + updateForm(newAssignedItems) { + this.state.assignedItemStore.setState(unique([].concat(newAssignedItems))); + + this.props.onChange({ + target: { + value: this.props.model[this.props.referenceProperty], + }, + }); + + }, + + render() { + const labelStyle = { + float: 'left', + position: 'relative', + display: 'block', + width: 'calc(100% - 60px)', + lineHeight: '24px', + color: 'rgba(0,0,0,0.3)', + marginTop: '1rem', + fontSize: 16, + }; + + return ( +
+ + + +
+ ); + }, +}); diff --git a/src/forms/form-fields/multi-toggle.js b/src/forms/form-fields/multi-toggle.js new file mode 100644 index 000000000..4d04d6189 --- /dev/null +++ b/src/forms/form-fields/multi-toggle.js @@ -0,0 +1,71 @@ +import React from 'react'; + +// Material UI +import Checkbox from 'material-ui/lib/checkbox'; + +export default React.createClass({ + propTypes: { + label: React.PropTypes.string.isRequired, + onChange: React.PropTypes.func.isRequired, + items: React.PropTypes.arrayOf(React.PropTypes.shape({ + name: React.PropTypes.string.isRequired, + value: React.PropTypes.bool, + text: React.PropTypes.string.isRequired, + })), + style: React.PropTypes.object, + }, + + contextTypes: { + muiTheme: React.PropTypes.object, + }, + + getInitialState() { + return { + values: this.props.items.reduce((prev, curr) => { + if (curr.value) { + prev.push(curr.name); + } + return prev; + }, []), + }; + }, + + render() { + const style = Object.assign({}, this.context.muiTheme.forms, this.props.style); + return ( +
+
{this.props.label}
+ {this.props.items.map(item => { + return ( + + ); + })} +
+ ); + }, + + _handleToggle(value, event, checked) { + this.setState(oldState => { + if (checked) { + if (oldState.values.indexOf(value) === -1) { + oldState.values.push(value); + } + } else { + if (oldState.values.indexOf(value) !== -1) { + oldState.values.splice(oldState.values.indexOf(value), 1); + } + } + return oldState; + }, () => { + this.props.onChange({target: {value: this.state.values}}); + }); + }, +}); diff --git a/src/forms/form-fields/number-field.js b/src/forms/form-fields/number-field.js new file mode 100644 index 000000000..4b5152dda --- /dev/null +++ b/src/forms/form-fields/number-field.js @@ -0,0 +1,38 @@ +import React from 'react/addons'; +import TextField from 'material-ui/lib/text-field'; +import Translate from 'd2-ui/lib/i18n/Translate.mixin'; +import isNumber from 'lodash.isnumber'; + +import MuiThemeMixin from '../mui-theme.mixin'; + +export default React.createClass({ + propTypes: { + multiLine: React.PropTypes.bool, + }, + + mixins: [MuiThemeMixin, Translate], + + _convertToNumberAndEmitChange(event) { + // When the value is not a number emit the original event + if (Number.isNaN(Number(event.target.value))) { + return this.props.onChange(event); + } + + this.props.onChange({ + target: { + value: Number.parseFloat(event.target.value), + } + }); + }, + + render() { + const errorStyle = { + lineHeight: this.props.multiLine ? '48px' : '12px', + marginTop: this.props.multiLine ? -16 : -12, + }; + + return ( + + ); + }, +}); diff --git a/src/forms/form-fields/text-field.js b/src/forms/form-fields/text-field.js new file mode 100644 index 000000000..c7d8669fb --- /dev/null +++ b/src/forms/form-fields/text-field.js @@ -0,0 +1,24 @@ +import React from 'react/addons'; +import TextField from 'material-ui/lib/text-field'; +import Translate from 'd2-ui/lib/i18n/Translate.mixin'; + +import MuiThemeMixin from '../mui-theme.mixin'; + +export default React.createClass({ + propTypes: { + multiLine: React.PropTypes.bool, + }, + + mixins: [MuiThemeMixin, Translate], + + render() { + const errorStyle = { + lineHeight: this.props.multiLine ? '48px' : '12px', + marginTop: this.props.multiLine ? -16 : -12, + }; + + return ( + + ); + }, +}); diff --git a/src/forms/mui-theme.mixin.js b/src/forms/mui-theme.mixin.js new file mode 100644 index 000000000..d80491381 --- /dev/null +++ b/src/forms/mui-theme.mixin.js @@ -0,0 +1,15 @@ +import React from 'react'; + +import AppTheme from '../App/app.theme'; + +export default { + childContextTypes: { + muiTheme: React.PropTypes.object, + }, + + getChildContext() { + return { + muiTheme: AppTheme, + }; + }, +}; diff --git a/src/i18n/i18n_module_en.properties b/src/i18n/i18n_module_en.properties index 80b3aeb6c..10fa8f9cd 100644 --- a/src/i18n/i18n_module_en.properties +++ b/src/i18n/i18n_module_en.properties @@ -68,6 +68,7 @@ available_dataelements=Available Data Elements selected_dataelements=Selected Data Elements add_selected=Add selected remove_all=Remove all +assign_all=Assign all create_new_data_dictionary=Create new data dictionary region=Region select_domain_type=Select domain type @@ -236,3 +237,7 @@ required=(required) aggregation_type=Aggregation operator ok=Ok! dismiss=Dismiss +description=Description +search_available_selected_items=Search available/selected items +selected=selected +indicators=Indicators diff --git a/src/index.html b/src/index.html index fb4563271..d365e5df6 100644 --- a/src/index.html +++ b/src/index.html @@ -7,6 +7,7 @@ + @@ -24,7 +25,7 @@
- +