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 (
+
-
{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 (
+
+
+
+ );
+ },
+});
+
+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 @@
-
+