diff --git a/.eslintrc b/.eslintrc
index e175ad797..845d06457 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -55,7 +55,7 @@
"avoid-escape"
],
"react/prop-types": 0,
- "semi": [1, "always"]
+ "semi": [2, "always"]
},
"env": {
"browser": true,
diff --git a/package-lock.json b/package-lock.json
index adbaeba4d..279f2043d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,6 +1,6 @@
{
"name": "Vulcan",
- "version": "1.12.16",
+ "version": "1.12.17",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -702,7 +702,6 @@
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz",
"integrity": "sha1-DNkKVhCT810KmSVsIrcGlDP60Rc=",
- "optional": true,
"requires": {
"kind-of": "^3.0.2",
"longest": "^1.0.1",
@@ -898,11 +897,12 @@
}
},
"apollo-errors": {
- "version": "1.5.1",
- "resolved": "https://registry.npmjs.org/apollo-errors/-/apollo-errors-1.5.1.tgz",
- "integrity": "sha512-gYAceMzNJfF+mUHH2/4UcZTkZtDY54arCTKGbKa7WU5IXnTJ4V+P94wHodcDkLLHWpHL8SW1hEgjN5ZINcPb1w==",
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/apollo-errors/-/apollo-errors-1.9.0.tgz",
+ "integrity": "sha512-XVukHd0KLvgY6tNjsPS3/Re3U6RQlTKrTbIpqqeTMo2N34uQMr+H1UheV21o8hOZBAFosvBORVricJiP5vfmrw==",
"requires": {
- "es6-error": "^4.0.0"
+ "assert": "^1.4.1",
+ "extendable-error": "^0.1.5"
}
},
"apollo-link": {
@@ -1289,6 +1289,14 @@
"safer-buffer": "~2.1.0"
}
},
+ "assert": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/assert/-/assert-1.4.1.tgz",
+ "integrity": "sha1-mZEtWRg2tab1s0XA8H7vwI/GXZE=",
+ "requires": {
+ "util": "0.10.3"
+ }
+ },
"assert-err": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/assert-err/-/assert-err-1.1.0.tgz",
@@ -2384,7 +2392,7 @@
},
"cheerio": {
"version": "0.22.0",
- "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-0.22.0.tgz",
+ "resolved": "http://registry.npmjs.org/cheerio/-/cheerio-0.22.0.tgz",
"integrity": "sha1-qbqoYKP5tZWmuBsahocxIe06Jp4=",
"requires": {
"css-select": "~1.2.0",
@@ -2831,7 +2839,7 @@
},
"css-select": {
"version": "1.2.0",
- "resolved": "https://registry.npmjs.org/css-select/-/css-select-1.2.0.tgz",
+ "resolved": "http://registry.npmjs.org/css-select/-/css-select-1.2.0.tgz",
"integrity": "sha1-KzoRBTnFNV8c2NMUYj6HCxIeyFg=",
"requires": {
"boolbase": "~1.0.0",
@@ -3370,11 +3378,6 @@
"es6-symbol": "~3.1.1"
}
},
- "es6-error": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
- "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="
- },
"es6-iterator": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz",
@@ -3977,6 +3980,11 @@
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.1.tgz",
"integrity": "sha1-p1Xqe8Gt/MWjHOfnYtuq3F5jZEQ="
},
+ "extendable-error": {
+ "version": "0.1.5",
+ "resolved": "https://registry.npmjs.org/extendable-error/-/extendable-error-0.1.5.tgz",
+ "integrity": "sha1-EiMIpwl7yJomOyxPvwiceBQOO20="
+ },
"extglob": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/extglob/-/extglob-0.3.2.tgz",
@@ -6168,8 +6176,7 @@
"longest": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz",
- "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=",
- "optional": true
+ "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc="
},
"loose-envify": {
"version": "1.3.1",
@@ -6261,7 +6268,7 @@
"process": "^0.11.9",
"punycode": "^1.4.1",
"querystring-es3": "^0.2.1",
- "readable-stream": "git+https://github.com/meteor/readable-stream.git",
+ "readable-stream": "git+https://github.com/meteor/readable-stream.git#c688cdd193549919b840e8d72a86682d91961e12",
"stream-browserify": "^2.0.1",
"string_decoder": "^1.0.1",
"timers-browserify": "^1.4.2",
@@ -6711,11 +6718,11 @@
"version": "git+https://github.com/meteor/readable-stream.git#c688cdd193549919b840e8d72a86682d91961e12",
"from": "git+https://github.com/meteor/readable-stream.git",
"requires": {
- "inherits": "~2.0.3",
+ "inherits": "~2.0.1",
"isarray": "~1.0.0",
- "process-nextick-args": "~2.0.0",
- "safe-buffer": "~5.1.1",
- "string_decoder": "~1.1.0",
+ "process-nextick-args": "~1.0.6",
+ "safe-buffer": "^5.0.1",
+ "string_decoder": "~1.0.0",
"util-deprecate": "~1.0.1"
}
},
@@ -9717,6 +9724,21 @@
"os-homedir": "^1.0.0"
}
},
+ "util": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz",
+ "integrity": "sha1-evsa/lCAUkZInj23/g7TeTNqwPk=",
+ "requires": {
+ "inherits": "2.0.1"
+ },
+ "dependencies": {
+ "inherits": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz",
+ "integrity": "sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE="
+ }
+ }
+ },
"util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -9852,7 +9874,7 @@
},
"chalk": {
"version": "1.1.3",
- "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "resolved": "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
"integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
"requires": {
"ansi-styles": "^2.2.1",
diff --git a/packages/vulcan-ui-material/.editorconfig b/packages/vulcan-ui-material/.editorconfig
new file mode 100644
index 000000000..68b3a5241
--- /dev/null
+++ b/packages/vulcan-ui-material/.editorconfig
@@ -0,0 +1,15 @@
+root = true
+
+[*]
+indent_style = space
+indent_size = 2
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+[*.js]
+trim_trailing_whitespace = false
+
+[*.md]
+trim_trailing_whitespace = false
diff --git a/packages/vulcan-ui-material/.eslintrc b/packages/vulcan-ui-material/.eslintrc
new file mode 100755
index 000000000..39277e6db
--- /dev/null
+++ b/packages/vulcan-ui-material/.eslintrc
@@ -0,0 +1,86 @@
+{
+ "extends": [
+ "eslint:recommended",
+ //"airbnb",
+ "plugin:meteor/recommended",
+ "plugin:react/recommended"
+ ],
+ "parser": "babel-eslint",
+ "parserOptions": {
+ "allowImportExportEverywhere": true,
+ "ecmaVersion": 6,
+ "sourceType": "module"
+ },
+ "rules": {
+ "babel/generator-star-spacing": 0,
+ "babel/new-cap": [1, {
+ "capIsNewExceptions": [
+ "Optional",
+ "OneOf",
+ "Maybe",
+ "MailChimpAPI",
+ "Juice",
+ "Run",
+ "AppComposer",
+ "Query",
+ "InArray"
+ ]
+ }],
+ "babel/array-bracket-spacing": 0,
+ "babel/object-curly-spacing": 0,
+ "babel/object-shorthand": 0,
+ "babel/arrow-parens": 0,
+ "babel/no-await-in-loop": 1,
+ "comma-dangle": 0,
+ "key-spacing": 0,
+ "no-extra-boolean-cast": 0,
+ "no-undef": 1,
+ "no-unused-vars": [1, {
+ "vars": "all",
+ "args": "none",
+ "varsIgnorePattern": "React|PropTypes|Component"
+ }],
+ "react/prop-types": 0,
+ "react/display-name": 0,
+ "meteor/audit-argument-checks": 0,
+ "meteor/no-session": 0,
+ "no-case-declarations": 0,
+ "no-console": 0,
+ "semi": "error",
+ "quotes": [
+ 1,
+ "single"
+ ]
+ },
+ "env": {
+ "browser": true,
+ "commonjs": true,
+ "es6": true,
+ "meteor": true,
+ "node": true
+ },
+ "plugins": [
+ "babel",
+ "meteor",
+ "react",
+ "jsx-a11y"
+ ],
+ "settings": {
+ "import/resolver": {
+ "meteor": {
+ "paths": [
+ "/usr/local/share/global_modules"
+ ],
+ "moduleDirectory": [
+ "node_modules",
+ "packages"
+ ]
+ }
+ }
+ },
+ "root": true,
+ "globals": {
+ "param": true,
+ "returns": true
+ }
+}
diff --git a/packages/vulcan-ui-material/.gitignore b/packages/vulcan-ui-material/.gitignore
new file mode 100644
index 000000000..e0ac94aec
--- /dev/null
+++ b/packages/vulcan-ui-material/.gitignore
@@ -0,0 +1,3 @@
+npm-debug.log
+node_modules
+.idea/workspace.xml
diff --git a/packages/vulcan-ui-material/.versions b/packages/vulcan-ui-material/.versions
new file mode 100644
index 000000000..5e93359dd
--- /dev/null
+++ b/packages/vulcan-ui-material/.versions
@@ -0,0 +1,89 @@
+accounts-base@1.4.3
+allow-deny@1.1.0
+autoupdate@1.5.0
+babel-compiler@7.2.4
+babel-runtime@1.3.0
+base64@1.0.11
+binary-heap@1.0.11
+blaze-tools@1.0.10
+boilerplate-generator@1.6.0
+buffer@0.0.0
+caching-compiler@1.2.1
+caching-html-compiler@1.1.3
+callback-hook@1.1.0
+check@1.3.1
+ddp@1.4.0
+ddp-client@2.3.3
+ddp-common@1.4.0
+ddp-rate-limiter@1.0.7
+ddp-server@2.2.0
+deps@1.0.12
+diff-sequence@1.1.1
+dynamic-import@0.5.0
+ecmascript@0.12.4
+ecmascript-runtime@0.7.0
+ecmascript-runtime-client@0.8.0
+ecmascript-runtime-server@0.7.1
+ejson@1.1.0
+email@1.2.3
+erikdakoda:vulcan-material-ui@1.12.8_17
+es5-shim@4.8.0
+fetch@0.1.0
+fourseven:scss@4.10.0
+geojson-utils@1.0.10
+hot-code-push@1.0.4
+html-tools@1.0.11
+htmljs@1.0.11
+http@1.4.1
+id-map@1.1.0
+inter-process-messaging@0.1.0
+localstorage@1.2.0
+logging@1.1.20
+meteor@1.9.2
+meteorhacks:inject-initial@1.0.4
+meteorhacks:picker@1.0.3
+minifier-css@1.4.1
+minifier-js@2.4.0
+minimongo@1.4.5
+modern-browsers@0.1.3
+modules@0.13.0
+modules-runtime@0.10.3
+mongo@1.6.0
+mongo-decimal@0.1.0
+mongo-dev-server@1.1.0
+mongo-id@1.0.7
+npm-mongo@3.1.1
+ordered-dict@1.1.0
+percolatestudio:synced-cron@1.1.0
+promise@0.11.2
+random@1.1.0
+rate-limit@1.0.9
+reactive-dict@1.2.1
+reactive-var@1.0.11
+reload@1.2.0
+retry@1.1.0
+routepolicy@1.1.0
+server-render@0.3.1
+service-configuration@1.0.11
+session@1.2.0
+shell-server@0.4.0
+socket-stream-client@0.2.2
+spacebars-compiler@1.1.3
+standard-minifier-css@1.5.2
+standard-minifier-js@2.4.0
+static-html@1.2.2
+templating-tools@1.1.2
+tracker@1.2.0
+underscore@1.0.10
+url@1.2.0
+vulcan:accounts@1.12.8
+vulcan:core@1.12.8
+vulcan:debug@1.12.8
+vulcan:email@1.12.8
+vulcan:forms@1.12.8
+vulcan:i18n@1.12.8
+vulcan:lib@1.12.8
+vulcan:routing@1.12.8
+vulcan:users@1.12.8
+webapp@1.7.1
+webapp-hashing@1.0.9
diff --git a/packages/vulcan-ui-material/accounts.css b/packages/vulcan-ui-material/accounts.css
new file mode 100644
index 000000000..73485abcf
--- /dev/null
+++ b/packages/vulcan-ui-material/accounts.css
@@ -0,0 +1,18 @@
+.accounts-ui .form-control {
+ color: rgba(0, 0, 0, 0.87);
+ padding: 8px 0;
+ font-size: 1rem;
+ font-family: "Roboto", "Helvetica", "Arial", sans-serif;
+ border: none;
+ box-shadow: none;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.87);
+ margin-bottom: 1px;
+ border-radius: 0;
+ width: 100%;
+}
+
+.accounts-ui .form-control:focus {
+ border-bottom-width: 2px;
+ margin-bottom: 0;
+}
+
diff --git a/packages/vulcan-ui-material/client/main.js b/packages/vulcan-ui-material/client/main.js
new file mode 100644
index 000000000..1ed6f2fb0
--- /dev/null
+++ b/packages/vulcan-ui-material/client/main.js
@@ -0,0 +1,3 @@
+export * from '../components/index';
+export * from '../modules/index';
+import './wrapWithMuiTheme';
diff --git a/packages/vulcan-ui-material/client/wrapWithMuiTheme.jsx b/packages/vulcan-ui-material/client/wrapWithMuiTheme.jsx
new file mode 100644
index 000000000..8b0c3baa7
--- /dev/null
+++ b/packages/vulcan-ui-material/client/wrapWithMuiTheme.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { addCallback, registerComponent, Components } from 'meteor/vulcan:core';
+import MuiThemeProvider from '@material-ui/core/styles/MuiThemeProvider';
+import { getCurrentTheme } from '../modules/themes';
+import JssCleanup from '../components/theme/JssCleanup';
+
+class ThemeProvider extends React.Component {
+ render() {
+ const theme = getCurrentTheme();
+ return (
+
+ {this.props.children}
+
+ );
+ }
+}
+
+registerComponent('ThemeProvider', ThemeProvider);
+
+function wrapWithMuiTheme(app) {
+ return (
+
+ {app}
+
+ );
+}
+
+
+addCallback('router.client.wrapper', wrapWithMuiTheme);
diff --git a/packages/vulcan-ui-material/components/accounts/AccountsButton.jsx b/packages/vulcan-ui-material/components/accounts/AccountsButton.jsx
new file mode 100755
index 000000000..d80dfe629
--- /dev/null
+++ b/packages/vulcan-ui-material/components/accounts/AccountsButton.jsx
@@ -0,0 +1,46 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import Button from '@material-ui/core/Button';
+import { replaceComponent, Utils } from 'meteor/vulcan:core';
+import classNames from 'classnames';
+
+
+export class AccountsButton extends Component {
+ render () {
+
+ const {
+ label,
+ type,
+ disabled = false,
+ className,
+ onClick
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+
+AccountsButton.propTypes = {
+ label: PropTypes.string.isRequired,
+ type: PropTypes.oneOf(['link', 'submit', 'button']),
+ disabled: PropTypes.bool,
+ className: PropTypes.string,
+ onClick: PropTypes.func.isRequired,
+};
+
+
+replaceComponent('AccountsButton', AccountsButton);
diff --git a/packages/vulcan-ui-material/components/accounts/AccountsButtons.jsx b/packages/vulcan-ui-material/components/accounts/AccountsButtons.jsx
new file mode 100755
index 000000000..dc55093d9
--- /dev/null
+++ b/packages/vulcan-ui-material/components/accounts/AccountsButtons.jsx
@@ -0,0 +1,48 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { Components, replaceComponent } from 'meteor/vulcan:core';
+import CardActions from '@material-ui/core/CardActions';
+import withStyles from '@material-ui/core/styles/withStyles';
+import classNames from 'classnames';
+
+
+const styles = theme => ({
+ root: {
+ flexDirection: 'row-reverse',
+ padding: theme.spacing.unit * 2,
+ height: 'auto',
+ },
+});
+
+
+export class AccountsButtons extends Component {
+ render () {
+
+ const {
+ classes,
+ buttons = {},
+ className = 'buttons',
+ } = this.props;
+
+ return (
+
+ {Object.keys(buttons).map((id, i) =>
+
+ )}
+
+ );
+ }
+}
+
+
+AccountsButtons.propTypes = {
+ classes: PropTypes.object.isRequired,
+ buttons: PropTypes.object,
+ className: PropTypes.string,
+};
+
+
+AccountsButtons.displayName = 'AccountsButtons';
+
+
+replaceComponent('AccountsButtons', AccountsButtons, [withStyles, styles]);
diff --git a/packages/vulcan-ui-material/components/accounts/AccountsField.jsx b/packages/vulcan-ui-material/components/accounts/AccountsField.jsx
new file mode 100755
index 000000000..4ac940a51
--- /dev/null
+++ b/packages/vulcan-ui-material/components/accounts/AccountsField.jsx
@@ -0,0 +1,114 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { replaceComponent } from 'meteor/vulcan:core';
+import TextField from '@material-ui/core/TextField';
+
+
+const autocompleteValues = {
+ 'username': 'username',
+ 'usernameOrEmail': 'email',
+ 'email': 'email',
+ 'password': 'current-password'
+};
+
+
+export class AccountsField extends PureComponent {
+
+
+ constructor (props) {
+ super(props);
+ this.state = {
+ mount: true
+ };
+ }
+
+
+ triggerUpdate () {
+ // Trigger an onChange on initial load, to support browser pre-filled values.
+ const { onChange } = this.props;
+ if (this.input && onChange) {
+ onChange({ target: { value: this.input.value } });
+ }
+ }
+
+
+ componentDidMount () {
+ this.triggerUpdate();
+ }
+
+
+ componentDidUpdate (prevProps) {
+ // Re-mount component so that we don't expose browser pre-filled passwords if the component was
+ // a password before and now something else.
+ if (prevProps.id !== this.props.id) {
+ this.setState({ mount: false });
+ } else if (!this.state.mount) {
+ this.setState({ mount: true });
+ this.triggerUpdate();
+ }
+ }
+
+
+ render () {
+ const {
+ id,
+ hint,
+ label,
+ type = 'text',
+ onChange,
+ required = false,
+ className = 'field',
+ defaultValue = '',
+ autoFocus,
+ messages,
+ } = this.props;
+ let { message } = this.props;
+ const { mount = true } = this.state;
+
+ if (type === 'notice') {
+ return
{label}
;
+ }
+
+ const autoComplete = autocompleteValues[id];
+
+ if (messages && messages.find && typeof id === 'string') {
+ const foundMessage = messages.find(element => {
+ if (typeof element.field !== 'string') return false;
+ return id.toLowerCase().indexOf(element.field.toLowerCase()) > -1;
+ });
+ if (foundMessage) {
+ message = foundMessage;
+ }
+ }
+
+ return (
+ mount &&
+
+
+ { this.input = ref; }}
+ onChange={onChange}
+ placeholder={hint}
+ defaultValue={defaultValue}
+ autoComplete={autoComplete }
+ label={label}
+ autoFocus={autoFocus}
+ required={required}
+ error={!!message}
+ helperText={message && message.message}
+ fullWidth
+ />
+
+ );
+ }
+}
+
+
+AccountsField.propTypes = {
+ onChange: PropTypes.func,
+};
+
+
+replaceComponent('AccountsField', AccountsField);
diff --git a/packages/vulcan-ui-material/components/accounts/AccountsFields.jsx b/packages/vulcan-ui-material/components/accounts/AccountsFields.jsx
new file mode 100755
index 000000000..59309c9a3
--- /dev/null
+++ b/packages/vulcan-ui-material/components/accounts/AccountsFields.jsx
@@ -0,0 +1,27 @@
+import React, { Component } from 'react';
+import { Components, replaceComponent } from 'meteor/vulcan:core';
+import CardContent from '@material-ui/core/CardContent';
+
+
+export class AccountsFields extends Component {
+ render () {
+ const {
+ fields = {},
+ className = 'fields',
+ messages,
+ } = this.props;
+
+ return (
+
+ {
+ Object.keys(fields).map((id, i) =>
+
+ )
+ }
+
+ );
+ }
+}
+
+
+replaceComponent('AccountsFields', AccountsFields);
diff --git a/packages/vulcan-ui-material/components/accounts/AccountsForm.jsx b/packages/vulcan-ui-material/components/accounts/AccountsForm.jsx
new file mode 100755
index 000000000..af28fb37a
--- /dev/null
+++ b/packages/vulcan-ui-material/components/accounts/AccountsForm.jsx
@@ -0,0 +1,68 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import { Components, registerComponent } from 'meteor/vulcan:core';
+import withStyles from '@material-ui/core/styles/withStyles';
+
+
+const styles = theme => ({
+ messages: theme.utils.errorMessage,
+});
+
+
+export class AccountsForm extends Component {
+
+
+ componentDidMount () {
+ let form = this.form;
+ if (form) {
+ form.addEventListener('submit', (e) => {
+ e.preventDefault();
+ });
+ }
+ }
+
+
+ render () {
+ const {
+ oauthServices,
+ fields,
+ buttons,
+ messages,
+ ready = true,
+ className,
+ classes,
+ } = this.props;
+
+ return (
+
+ );
+ }
+
+
+}
+
+
+AccountsForm.propTypes = {
+ oauthServices: PropTypes.object,
+ fields: PropTypes.object.isRequired,
+ buttons: PropTypes.object.isRequired,
+ error: PropTypes.string,
+ ready: PropTypes.bool,
+ classes: PropTypes.object.isRequired,
+};
+
+
+AccountsForm.displayName = 'AccountsForm';
+
+
+registerComponent('AccountsForm', AccountsForm, [withStyles, styles]);
diff --git a/packages/vulcan-ui-material/components/accounts/AccountsPasswordOrService.jsx b/packages/vulcan-ui-material/components/accounts/AccountsPasswordOrService.jsx
new file mode 100644
index 000000000..8f3a40667
--- /dev/null
+++ b/packages/vulcan-ui-material/components/accounts/AccountsPasswordOrService.jsx
@@ -0,0 +1,57 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { replaceComponent } from 'meteor/vulcan:core';
+import { intlShape } from 'meteor/vulcan:i18n';
+import Typography from '@material-ui/core/Typography';
+import CardActions from '@material-ui/core/CardActions';
+import withStyles from '@material-ui/core/styles/withStyles';
+import classNames from 'classnames';
+
+const styles = theme => ({
+ root: {
+ flexDirection: 'row-reverse',
+ paddingRight: theme.spacing.unit * 2,
+ paddingLeft: theme.spacing.unit * 2,
+ height: 'auto',
+ },
+ typography: {
+ marginRight: theme.spacing.unit,
+ }
+});
+
+export function hasPasswordService() {
+ // First look for OAuth services.
+ return !!Package['accounts-password'];
+}
+
+export class AccountsPasswordOrService extends PureComponent {
+ render () {
+ let { className = 'password-or-service', style = {}, classes } = this.props;
+ const services = Object.keys(this.props.oauthServices).map(service => {
+ return this.props.oauthServices[service].label;
+ });
+ let labels = services;
+ if (services.length > 2) {
+ labels = [];
+ }
+
+ if (hasPasswordService() && services.length > 0) {
+ return (
+
+ { `${this.context.intl.formatMessage({id: 'accounts.or_use'})} ${ labels.join(' / ') }` }
+
+ );
+ }
+ return null;
+ }
+}
+
+AccountsPasswordOrService.propTypes = {
+ oauthServices: PropTypes.object
+};
+
+AccountsPasswordOrService.contextTypes = {
+ intl: intlShape
+};
+
+replaceComponent('AccountsPasswordOrService', AccountsPasswordOrService, [withStyles, styles]);
diff --git a/packages/vulcan-ui-material/components/accounts/AccountsSocialButtons.jsx b/packages/vulcan-ui-material/components/accounts/AccountsSocialButtons.jsx
new file mode 100644
index 000000000..2ce19f254
--- /dev/null
+++ b/packages/vulcan-ui-material/components/accounts/AccountsSocialButtons.jsx
@@ -0,0 +1,28 @@
+import React from 'react';
+import { Components, replaceComponent } from 'meteor/vulcan:core';
+import CardActions from '@material-ui/core/CardActions'
+import withStyles from '@material-ui/core/styles/withStyles'
+import classNames from 'classnames'
+
+const styles = theme => ({
+ root: {
+ justifyContent: 'flex-end',
+ padding: theme.spacing.unit * 2,
+ height: 'auto',
+ },
+});
+
+export class AccountsSocialButtons extends React.Component {
+ render() {
+ let { oauthServices = {}, className = 'social-buttons', classes } = this.props;
+ return(
+
+ {Object.keys(oauthServices).map((id, i) => {
+ return ;
+ })}
+
+ );
+ }
+}
+
+replaceComponent('AccountsSocialButtons', AccountsSocialButtons, [withStyles, styles]);
diff --git a/packages/vulcan-ui-material/components/bonus/LoadMore.jsx b/packages/vulcan-ui-material/components/bonus/LoadMore.jsx
new file mode 100644
index 000000000..934da0c7d
--- /dev/null
+++ b/packages/vulcan-ui-material/components/bonus/LoadMore.jsx
@@ -0,0 +1,133 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage, intlShape } from 'react-intl';
+import { Components, registerComponent } from 'meteor/vulcan:core';
+import withStyles from '@material-ui/core/styles/withStyles';
+import Typography from '@material-ui/core/Typography';
+import Button from '@material-ui/core/Button';
+import IconButton from '@material-ui/core/IconButton';
+import ArrowDownIcon from 'mdi-material-ui/ArrowDown';
+import ScrollTrigger from './ScrollTrigger';
+import classNames from 'classnames';
+
+
+const styles = theme => ({
+
+ root: {
+ textAlign: 'center',
+ flexBasis: '100%',
+ },
+
+ textButton: {
+ marginTop: theme.spacing.unit * 2,
+ },
+
+ iconButton: {},
+
+ caption: {
+ marginTop: theme.spacing.unit * 3,
+ paddingTop: theme.spacing.unit,
+ paddingBottom: theme.spacing.unit,
+ },
+
+});
+
+
+const LoadMore = ({
+ classes,
+ count,
+ totalCount,
+ loadMore,
+ networkStatus,
+ showCount,
+ useTextButton,
+ className,
+ infiniteScroll,
+ }, { intl }) => {
+
+ const isLoadingMore = networkStatus === 2;
+ const loadMoreText = intl.formatMessage({ id: 'load_more.load_more' });
+ const title = `${loadMoreText} (${count}/${totalCount})`;
+ const hasMore = totalCount > count;
+ const countValues = { count, totalCount };
+
+ const loadMoreButton = useTextButton
+ ?
+
+ :
+ loadMore()}>
+
+ ;
+
+ return (
+
+ {
+ showCount &&
+
+
+
+
+ }
+ {
+ isLoadingMore
+
+ ?
+
+
+
+ :
+
+ hasMore
+
+ ?
+
+ infiniteScroll
+
+ ?
+
+ loadMore()}>
+ {loadMoreButton}
+
+
+ :
+
+ loadMoreButton
+ :
+
+ null
+ }
+
+ );
+};
+
+
+LoadMore.propTypes = {
+ classes: PropTypes.object.isRequired,
+ count: PropTypes.number,
+ totalCount: PropTypes.number,
+ loadMore: PropTypes.func,
+ networkStatus: PropTypes.number,
+ showCount: PropTypes.bool,
+ useTextButton: PropTypes.bool,
+ className: PropTypes.string,
+ infiniteScroll: PropTypes.bool,
+};
+
+
+LoadMore.defaultProps = {
+ showCount: true,
+};
+
+
+LoadMore.contextTypes = {
+ intl: intlShape.isRequired,
+};
+
+
+LoadMore.displayName = 'LoadMore';
+
+
+registerComponent('LoadMore', LoadMore, [withStyles, styles]);
+
diff --git a/packages/vulcan-ui-material/components/bonus/ScrollTrigger.jsx b/packages/vulcan-ui-material/components/bonus/ScrollTrigger.jsx
new file mode 100644
index 000000000..0340f2536
--- /dev/null
+++ b/packages/vulcan-ui-material/components/bonus/ScrollTrigger.jsx
@@ -0,0 +1,125 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import ReactDOM from 'react-dom';
+import _throttle from 'lodash/throttle';
+
+
+class ScrollTrigger extends Component {
+
+ constructor (props) {
+ super(props);
+
+ this.onScroll = _throttle(this.onScroll.bind(this), 100, {
+ leading: true,
+ trailing: true,
+ });
+
+ this.onResize = _throttle(this.onResize.bind(this), 100, {
+ leading: true,
+ trailing: true,
+ });
+
+ this.inViewport = false;
+ this.passive = this.supportsPassive ? { passive: true } : false;
+ }
+
+ supportsPassive () {
+ let supportsPassive = false;
+ try {
+ const opts = Object.defineProperty({}, 'passive', {
+ get: function() {
+ supportsPassive = true;
+ }
+ });
+ window.addEventListener("testPassive", null, opts);
+ window.removeEventListener("testPassive", null, opts);
+ //eslint-disable-next-line no-empty
+ } catch (e) {}
+ return supportsPassive;
+ }
+
+ componentDidMount () {
+ this.scroller = document.getElementById('main');
+ this.scroller.addEventListener('resize', this.onResize, this.passive);
+ this.scroller.addEventListener('scroll', this.onScroll, this.passive);
+
+ this.inViewport = false;
+
+ if (this.props.triggerOnLoad) {
+ this.checkStatus();
+ }
+ }
+
+ componentWillUnmount () {
+ if (!this.scroller) return;
+ this.scroller.removeEventListener('resize', this.onResize);
+ this.scroller.removeEventListener('scroll', this.onScroll);
+ this.scroller = null;
+ }
+
+ onResize () {
+ this.checkStatus();
+ }
+
+ onScroll () {
+ this.checkStatus();
+ }
+
+ checkStatus () {
+ if (!this.scroller) return;
+
+ const {
+ onEnter,
+ } = this.props;
+
+ //eslint-disable-next-line
+ const element = ReactDOM.findDOMNode(this.element);
+ const elementRect = element.getBoundingClientRect();
+ const viewportEnd = this.scroller.clientHeight + this.props.preload;
+ const inViewport = elementRect.top < viewportEnd;
+
+ if (inViewport) {
+ if (!this.inViewport) {
+ this.inViewport = true;
+
+ onEnter(this);
+ }
+
+ } else {
+ if (this.inViewport) {
+ this.inViewport = false;
+ }
+ }
+ }
+
+ render () {
+ const {
+ children,
+ } = this.props;
+
+ return (
+ {this.element = element;}}>
+ {children}
+
+ );
+ }
+}
+
+
+ScrollTrigger.propTypes = {
+ scrollerId: PropTypes.string,
+ triggerOnLoad: PropTypes.bool,
+ preload: PropTypes.number,
+ onEnter: PropTypes.func,
+};
+
+
+ScrollTrigger.defaultProps = {
+ scrollerId: 'main',
+ preload: 1000,
+ triggerOnLoad: true,
+ onEnter: () => {},
+};
+
+
+export default ScrollTrigger;
diff --git a/packages/vulcan-ui-material/components/bonus/SearchInput.jsx b/packages/vulcan-ui-material/components/bonus/SearchInput.jsx
new file mode 100644
index 000000000..fa611fdd0
--- /dev/null
+++ b/packages/vulcan-ui-material/components/bonus/SearchInput.jsx
@@ -0,0 +1,240 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { Components, registerComponent } from 'meteor/vulcan:core';
+import withStyles from '@material-ui/core/styles/withStyles';
+import SearchIcon from 'mdi-material-ui/Magnify';
+import ClearIcon from 'mdi-material-ui/CloseCircle';
+import Input from '@material-ui/core/Input';
+import NoSsr from '@material-ui/core/NoSsr';
+import classNames from 'classnames';
+import _debounce from 'lodash/debounce';
+import KeyboardEventHandler from 'react-keyboard-event-handler';
+import autosizeInput from 'autosize-input';
+
+const styles = theme => ({
+
+ '@global': {
+ 'input[type=text]::-ms-clear, input[type=text]::-ms-reveal':
+ {
+ display: 'none',
+ width: 0,
+ height: 0,
+ },
+ 'input[type="search"]::-webkit-search-decoration, input[type="search"]::-webkit-search-cancel-button':
+ { display: 'none' },
+ 'input[type="search"]::-webkit-search-results-button, input[type="search"]::-webkit-search-results-decoration':
+ { display: 'none' },
+ },
+
+ root: {
+ display: 'inline-flex',
+ backgroundColor: theme.palette.common.faintBlack,
+ borderRadius: 20,
+ padding: 6,
+ },
+
+ clear: {
+ transition: theme.transitions.create('opacity,transform', {
+ duration: theme.transitions.duration.short,
+ }),
+ opacity: 0.65,
+ width: 36,
+ height: 36,
+ margin: -6,
+ marginLeft: 0,
+ '& svg': {
+ width: 16,
+ height: 16,
+ },
+ flexDirection: 'column',
+ },
+
+ clearDense: {
+ width: 32,
+ height: 32,
+ margin: -4,
+ marginLeft: 0,
+ },
+
+ clearDisabled: {
+ opacity: 0,
+ pointerEvents: 'none',
+ },
+
+ dense: {
+ padding: 4,
+ },
+
+ icon: {
+ color: theme.palette.common.lightBlack,
+ marginLeft: theme.spacing.unit,
+ marginRight: theme.spacing.unit,
+ },
+
+ input: {
+ lineHeight: 1,
+ paddingTop: 2,
+ paddingBottom: 2,
+ marginBottom: 1,
+ /*transition: theme.transitions.create('width', {
+ duration: theme.transitions.duration.shortest,
+ }),*/
+ minWidth: 130,
+ },
+
+});
+
+
+class SearchInput extends PureComponent {
+
+ constructor (props) {
+ super(props);
+
+ this.state = {
+ value: props.defaultValue || '',
+ };
+
+ this.input = null;
+ this.removeAutosize = null;
+ this.triggerResize = null;
+
+ this.updateQuery = _debounce(this.updateQuery, 500);
+ }
+
+ componentDidMount () {
+ if (!document) return;
+ const element = document.querySelector(`.search-input-${this.props.name} input[type=search]`);
+
+ // We have to patch into addEventListener because autosizeInput provides no way to trigger resize
+ element._addEventListener = element.addEventListener;
+ element.addEventListener = function(type, listener, useCapture) {
+ if(useCapture === undefined)
+ useCapture = false;
+ this._addEventListener(type, listener, useCapture);
+ this.triggerResize = listener;
+ };
+
+ this.removeAutosize = autosizeInput(element);
+
+ this.triggerResize = element.triggerResize;
+ element.addEventListener = element._addEventListener;
+ }
+
+ componentWillUnmount () {
+ if (this.removeAutosize) {
+ this.removeAutosize();
+ }
+ }
+
+ handleShortcutKeys = (key, event) => {
+ switch (key) {
+ case 's':
+ this.focusInput();
+ event.preventDefault();
+ break;
+ case 'c':
+ case 'esc':
+ this.clearSearch(event, true);
+ event.preventDefault();
+ break;
+ }
+ };
+
+ handleFocus = () => {
+ this.input.select();
+ };
+
+ focusInput = (event) => {
+ this.input.focus();
+ };
+
+ clearSearch = (event, dontFocus) => {
+ this.setState({ value: '' }, this.triggerResize);
+ this.updateQuery('');
+
+ if (!dontFocus) {
+ this.focusInput();
+ }
+ };
+
+ updateSearch = (event) => {
+ const value = event.target.value;
+ this.setState({ value: value });
+ this.updateQuery(value);
+ };
+
+ updateQuery = (value) => {
+ this.props.updateQuery(value);
+ };
+
+ render () {
+ const {
+ classes,
+ className,
+ dense,
+ noShortcuts,
+ name,
+ } = this.props;
+
+ const searchIcon = ;
+
+ const clearButton = }
+ onClick={this.clearSearch}
+ classes={{
+ root: classNames(!this.state.value && classes.clearDisabled),
+ button: classNames('clear-button', classes.clear, dense && classes.clearDense),
+ }}
+ disabled={!this.state.value}
+ />;
+
+ return (
+
+ this.input = input}
+ value={this.state.value}
+ type="search"
+ onChange={this.updateSearch}
+ onFocus={this.handleFocus}
+ disableUnderline={true}
+ startAdornment={searchIcon}
+ endAdornment={clearButton}
+ />
+
+ {
+ // KeyboardEventHandler is not valid on the server, where its name is undefined
+ typeof window !== 'undefined' && KeyboardEventHandler.name && !noShortcuts &&
+
+
+ }
+
+
+ );
+ }
+
+}
+
+
+SearchInput.propTypes = {
+ classes: PropTypes.object.isRequired,
+ updateQuery: PropTypes.func.isRequired,
+ className: PropTypes.string,
+ dense: PropTypes.bool,
+ noShortcuts: PropTypes.bool,
+ name: PropTypes.string.isRequired,
+};
+
+
+SearchInput.defaultProps = {
+ name: 'search',
+};
+
+
+SearchInput.displayName = 'SearchInput';
+
+
+registerComponent('SearchInput', SearchInput, [withStyles, styles]);
diff --git a/packages/vulcan-ui-material/components/bonus/TooltipIconButton.jsx b/packages/vulcan-ui-material/components/bonus/TooltipIconButton.jsx
new file mode 100644
index 000000000..45e3ba288
--- /dev/null
+++ b/packages/vulcan-ui-material/components/bonus/TooltipIconButton.jsx
@@ -0,0 +1,108 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Components, registerComponent, Utils } from 'meteor/vulcan:core';
+import { intlShape } from 'meteor/vulcan:i18n';
+import withStyles from '@material-ui/core/styles/withStyles';
+import withTheme from '@material-ui/core/styles/withTheme';
+import Tooltip from '@material-ui/core/Tooltip';
+import IconButton from '@material-ui/core/IconButton';
+import Button from '@material-ui/core/Button';
+import classNames from 'classnames';
+
+
+const styles = theme => ({
+ root: {},
+ tooltip: {
+ margin: '4px !important',
+ },
+ buttonWrap: {
+ display: 'inline-block',
+ },
+ button: {},
+});
+
+
+const TooltipIconButton = (props, { intl }) => {
+
+ const {
+ title,
+ titleId,
+ placement,
+ icon,
+ className,
+ classes,
+ theme,
+ buttonRef,
+ variant,
+ ...properties
+ } = props;
+
+ const titleText = props.title || intl.formatMessage({ id: titleId });
+ const slug = Utils.slugify(titleId);
+
+ return (
+
+
+ {
+ variant === 'fab'
+
+ ?
+
+
+
+ :
+
+
+ {icon}
+
+ }
+
+
+ );
+
+};
+
+
+TooltipIconButton.propTypes = {
+ title: PropTypes.node,
+ titleId: PropTypes.string,
+ placement: PropTypes.string,
+ icon: PropTypes.node.isRequired,
+ className: PropTypes.string,
+ classes: PropTypes.object,
+ buttonRef: PropTypes.func,
+ variant: PropTypes.string,
+ theme: PropTypes.object,
+};
+
+
+TooltipIconButton.defaultProps = {
+ placement: 'bottom',
+};
+
+
+TooltipIconButton.contextTypes = {
+ intl: intlShape.isRequired,
+};
+
+
+TooltipIconButton.displayName = 'TooltipIconButton';
+
+
+registerComponent('TooltipIconButton', TooltipIconButton, [withStyles, styles], [withTheme]);
diff --git a/packages/vulcan-ui-material/components/bonus/TooltipIntl.jsx b/packages/vulcan-ui-material/components/bonus/TooltipIntl.jsx
new file mode 100644
index 000000000..5e21e277d
--- /dev/null
+++ b/packages/vulcan-ui-material/components/bonus/TooltipIntl.jsx
@@ -0,0 +1,168 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Components, registerComponent, Utils } from 'meteor/vulcan:core';
+import { intlShape } from 'meteor/vulcan:i18n';
+import withStyles from '@material-ui/core/styles/withStyles';
+import withTheme from '@material-ui/core/styles/withTheme';
+import Tooltip from '@material-ui/core/Tooltip';
+import IconButton from '@material-ui/core/IconButton';
+import Button from '@material-ui/core/Button';
+import classNames from 'classnames';
+
+
+const styles = theme => ({
+
+ root: {
+ display: 'inherit',
+ },
+
+ tooltip: {
+ margin: '4px !important',
+ },
+
+ buttonWrap: {
+ display: 'inherit',
+ },
+
+ button: {},
+
+ icon: {},
+
+ popoverPopper: {
+ zIndex: 1700,
+ },
+
+ popoverTooltip: {
+ zIndex: 1701,
+ },
+
+});
+
+
+const TooltipIntl = (props, { intl }) => {
+
+ const {
+ title,
+ titleId,
+ titleValues,
+ placement,
+ icon,
+ className,
+ classes,
+ theme,
+ enterDelay,
+ leaveDelay,
+ buttonRef,
+ variant,
+ parent,
+ children,
+ ...properties
+ } = props;
+
+ const iconWithClass = icon && React.cloneElement(icon, { className: classes.icon });
+ const popperClass = parent === 'popover' && classes.popoverPopper;
+ const tooltipClass = parent === 'popover' && classes.popoverTooltip;
+ const tooltipEnterDelay = typeof enterDelay === 'number' ? enterDelay : theme.utils.tooltipEnterDelay;
+ const tooltipLeaveDelay = typeof leaveDelay === 'number' ? leaveDelay : theme.utils.tooltipLeaveDelay;
+ const titleText = props.title || intl.formatMessage({ id: titleId }, titleValues);
+ const slug = Utils.slugify(titleId);
+
+ return (
+
+
+
+ {
+ variant === 'fab' && !!icon
+
+ ?
+
+
+
+ :
+
+ !!icon
+
+ ?
+
+
+ {iconWithClass}
+
+
+ :
+
+ variant === 'button'
+
+ ?
+
+
+ :
+
+ children
+ }
+
+
+
+ );
+
+};
+
+
+TooltipIntl.propTypes = {
+ title: PropTypes.node,
+ titleId: PropTypes.string,
+ titleValues: PropTypes.object,
+ placement: PropTypes.string,
+ icon: PropTypes.node,
+ className: PropTypes.string,
+ classes: PropTypes.object,
+ buttonRef: PropTypes.func,
+ variant: PropTypes.string,
+ theme: PropTypes.object,
+ enterDelay: PropTypes.number,
+ leaveDelay: PropTypes.number,
+ parent: PropTypes.oneOf(['default', 'popover']),
+ children: PropTypes.node,
+};
+
+
+TooltipIntl.defaultProps = {
+ placement: 'bottom',
+ parent: 'default',
+};
+
+
+TooltipIntl.contextTypes = {
+ intl: intlShape.isRequired,
+};
+
+
+TooltipIntl.displayName = 'TooltipIntl';
+
+
+registerComponent('TooltipIntl', TooltipIntl, [withStyles, styles], [withTheme]);
diff --git a/packages/vulcan-ui-material/components/core/Card.jsx b/packages/vulcan-ui-material/components/core/Card.jsx
new file mode 100644
index 000000000..a311c4841
--- /dev/null
+++ b/packages/vulcan-ui-material/components/core/Card.jsx
@@ -0,0 +1,212 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { intlShape } from 'meteor/vulcan:i18n';
+import { replaceComponent, Components } from 'meteor/vulcan:core';
+import moment from 'moment';
+import withStyles from '@material-ui/core/styles/withStyles';
+import IconButton from '@material-ui/core/IconButton';
+import Checkbox from '@material-ui/core/Checkbox';
+import EditIcon from 'mdi-material-ui/Pencil';
+import Table from '@material-ui/core/Table';
+import TableBody from '@material-ui/core/TableBody';
+import TableRow from '@material-ui/core/TableRow';
+import TableCell from '@material-ui/core/TableCell';
+import classNames from 'classnames';
+
+
+const getLabel = (field, fieldName, collection, intl) => {
+ const schema = collection.simpleSchema()._schema;
+ const fieldSchema = schema[fieldName];
+ if (fieldSchema) {
+ return intl.formatMessage(
+ { id: `${collection._name}.${fieldName}`, defaultMessage: fieldSchema.label });
+ } else {
+ return fieldName;
+ }
+};
+
+
+const getTypeName = (field, fieldName, collection) => {
+ const schema = collection.simpleSchema()._schema;
+ const fieldSchema = schema[fieldName];
+ if (fieldSchema) {
+ const type = fieldSchema.type.singleType;
+ const typeName = typeof type === 'function' ? type.name : type;
+ return typeName;
+ } else {
+ return typeof field;
+ }
+};
+
+
+const parseImageUrl = value => {
+ const isImage = ['.png', '.jpg', '.gif'].indexOf(value.substr(-4)) !== -1 ||
+ ['.webp', '.jpeg'].indexOf(value.substr(-5)) !== -1;
+ return isImage ?
+
:
+ ;
+};
+
+
+const LimitedString = ({ string }) =>
+
+ {string.indexOf(' ') === -1 && string.length > 30 ?
+ {string.substr(0, 30)}… :
+ {string}
+ }
+
;
+
+
+export const getFieldValue = (value, typeName, classes={}) => {
+
+ if (typeof value === 'undefined' || value === null) {
+ return '';
+ }
+
+ if (Array.isArray(value)) {
+ typeName = 'Array';
+ }
+
+ if (typeof typeName === 'undefined') {
+ typeName = typeof value;
+ }
+
+ switch (typeName) {
+
+ case 'Boolean':
+ case 'boolean':
+ return ;
+
+ case 'Number':
+ case 'number':
+ case 'SimpleSchema.Integer':
+ return {value.toString()}
;
+
+ case 'Array':
+ return {value.map(
+ (item, index) => - {getFieldValue(item, typeof item, classes)}
)}
;
+
+ case 'Object':
+ case 'object':
+ return (
+
+
+ {_.map(value, (value, key) =>
+
+ {key}
+ {getFieldValue(value, typeof value, classes)}
+
+ )}
+
+
+ );
+
+ case 'Date':
+ return moment(new Date(value)).format('dddd, MMMM Do YYYY, h:mm:ss');
+
+ default:
+ return parseImageUrl(value);
+ }
+};
+
+
+const CardItem = ({ label, value, typeName, classes }) =>
+
+
+ {label}
+
+
+ {getFieldValue(value, typeName, classes)}
+
+ ;
+
+
+const CardEdit = (props, context) => {
+ const classes = props.classes;
+ const editTitle = context.intl.formatMessage({ id: 'cards.edit' });
+ return (
+
+
+
+
+ }
+ >
+
+
+
+
+ );
+};
+
+
+CardEdit.contextTypes = { intl: intlShape };
+
+
+const CardEditForm = ({ collection, document, closeModal }) =>
+ {
+ closeModal();
+ }}
+ />;
+
+
+const styles = theme => ({
+ root: {},
+ table: {
+ maxWidth: '100%'
+ },
+ tableBody: {},
+ tableRow: {},
+ tableCell: {},
+ tableHeadCell: {},
+});
+
+
+const Card = ({ className, collection, document, currentUser, fields, classes }, { intl }) => {
+
+ const fieldNames = fields ? fields : _.without(_.keys(document), '__typename');
+ const canUpdate = currentUser && collection.options.mutations.update.check(currentUser, document);
+
+ return (
+
+
+
+ {canUpdate ? : null}
+ {fieldNames.map((fieldName, index) =>
+
+ )}
+
+
+
+ );
+};
+
+
+Card.displayName = 'Card';
+
+
+Card.propTypes = {
+ className: PropTypes.string,
+ collection: PropTypes.object,
+ document: PropTypes.object,
+ currentUser: PropTypes.object,
+ fields: PropTypes.array,
+ classes: PropTypes.object.isRequired,
+};
+
+
+Card.contextTypes = {
+ intl: intlShape
+};
+
+
+replaceComponent('Card', Card, [withStyles, styles]);
diff --git a/packages/vulcan-ui-material/components/core/Datatable.jsx b/packages/vulcan-ui-material/components/core/Datatable.jsx
new file mode 100644
index 000000000..982a404d6
--- /dev/null
+++ b/packages/vulcan-ui-material/components/core/Datatable.jsx
@@ -0,0 +1,644 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import {
+ Components,
+ registerComponent,
+ replaceComponent,
+ withCurrentUser,
+ withMulti,
+ Utils
+} from 'meteor/vulcan:core';
+import { intlShape } from 'meteor/vulcan:i18n';
+import withStyles from '@material-ui/core/styles/withStyles';
+import Table from '@material-ui/core/Table';
+import TableBody from '@material-ui/core/TableBody';
+import TableHead from '@material-ui/core/TableHead';
+import TableRow from '@material-ui/core/TableRow';
+import TableCell from '@material-ui/core/TableCell';
+import TableFooter from '@material-ui/core/TableFooter';
+import Tooltip from '@material-ui/core/Tooltip';
+import TableSortLabel from '@material-ui/core/TableSortLabel';
+import TablePagination from '@material-ui/core/TablePagination';
+import Toolbar from '@material-ui/core/Toolbar';
+import Typography from '@material-ui/core/Typography';
+import { getFieldValue } from './Card';
+import _assign from 'lodash/assign';
+import classNames from 'classnames';
+
+
+/*
+
+Datatable Component
+
+*/
+const baseStyles = theme => ({
+ root: {
+ position: 'relative',
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ },
+ addButton: {
+ position: 'absolute',
+ top: '-8px',
+ right: 0,
+ },
+ search: {
+ marginBottom: theme.spacing.unit * 8,
+ },
+ table: {},
+ denseTable: {},
+ denserTable: {},
+ flatTable: {},
+ tableHead: {},
+ tableBody: {},
+ tableFooter: {},
+ tableRow: {},
+ tableHeadCell: {},
+ tableCell: {},
+ clickRow: {},
+ editCell: {},
+ editButton: {}
+});
+
+const delay = (function () {
+ var timer = 0;
+ return function (callback, ms) {
+ clearTimeout(timer);
+ timer = setTimeout(callback, ms);
+ };
+})();
+
+class Datatable extends PureComponent {
+
+ constructor (props) {
+ super(props);
+
+ this.updateQuery = this.updateQuery.bind(this);
+
+ this.state = {
+ value: '',
+ query: '',
+ currentSort: {},
+ };
+ }
+
+ toggleSort = column => {
+ let currentSort;
+ if (!this.state.currentSort[column]) {
+ currentSort = { [column]: 1 };
+ } else if (this.state.currentSort[column] === 1) {
+ currentSort = { [column]: -1 };
+ } else {
+ currentSort = {};
+ }
+ this.setState({ currentSort });
+ };
+
+ updateQuery (value) {
+ this.setState({
+ value: value
+ });
+ delay(() => {
+ this.setState({
+ query: value
+ });
+ }, 700);
+ }
+
+ render () {
+ if (this.props.data) {
+
+ return ;
+
+ } else {
+
+ const {
+ className,
+ collection,
+ options,
+ showSearch,
+ showNew,
+ currentUser,
+ classes,
+ } = this.props;
+
+ const listOptions = {
+ collection: collection,
+ ...options,
+ };
+
+ const DatatableWithMulti = withMulti(listOptions)(Components.DatatableContents);
+
+ // add _id to orderBy when we want to sort a column, to avoid breaking the graphql() hoc;
+ // see https://github.com/VulcanJS/Vulcan/issues/2090#issuecomment-433860782
+ // this.state.currentSort !== {} is always false, even when console.log(this.state.currentSort) displays
+ // {}. So we test on the length of keys for this object.
+ const orderBy = Object.keys(this.state.currentSort).length == 0 ? {} :
+ { ...this.state.currentSort, _id: -1 };
+
+ return (
+
+ {/* DatatableAbove Component part*/}
+ {
+ showSearch &&
+
+
+ }
+ {
+ showNew &&
+
+
+ }
+
+
+
+ );
+ }
+ }
+}
+
+
+Datatable.propTypes = {
+ title: PropTypes.string,
+ className: PropTypes.string,
+ collection: PropTypes.object,
+ options: PropTypes.object,
+ columns: PropTypes.array,
+ showEdit: PropTypes.bool,
+ editComponent: PropTypes.func,
+ showNew: PropTypes.bool,
+ showSearch: PropTypes.bool,
+ emptyState: PropTypes.node,
+ currentUser: PropTypes.object,
+ classes: PropTypes.object,
+ data: PropTypes.array,
+ footerData: PropTypes.array,
+ dense: PropTypes.string,
+ queryDataRef: PropTypes.func,
+ rowClass: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
+ handleRowClick: PropTypes.func,
+ intlNamespace: PropTypes.string,
+ toggleSort: PropTypes.func,
+ currentSort: PropTypes.object,
+ paginate: PropTypes.bool,
+};
+
+
+Datatable.defaultProps = {
+ showNew: true,
+ showEdit: true,
+ showSearch: true,
+ paginate: false,
+};
+
+
+replaceComponent('Datatable', Datatable, withCurrentUser, [withStyles, baseStyles]);
+
+
+/*
+
+DatatableContents Component
+
+*/
+const datatableContentsStyles = theme => (_assign({}, baseStyles(theme), {
+ table: {
+ marginTop: theme.spacing.unit * 3,
+ marginBottom: theme.spacing.unit * 3,
+ },
+ denseTable: theme.utils.denseTable,
+ flatTable: theme.utils.flatTable,
+ denserTable: theme.utils.denserTable,
+}));
+
+
+const DatatableContents = ({
+ collection,
+ columns,
+ results,
+ loading,
+ loadMore,
+ count,
+ totalCount,
+ networkStatus,
+ refetch,
+ showEdit,
+ editComponent,
+ emptyState,
+ currentUser,
+ classes,
+ footerData,
+ dense,
+ queryDataRef,
+ rowClass,
+ handleRowClick,
+ intlNamespace,
+ title,
+ toggleSort,
+ currentSort,
+ paginate,
+ paginationTerms,
+ setPaginationTerms
+ }) => {
+
+ if (loading) {
+ return ;
+ } else if (!results || !results.length) {
+ return emptyState || null;
+ }
+
+ if (queryDataRef) queryDataRef(this.props);
+
+ const denseClass = dense && classes[dense + 'Table'];
+
+ // Pagination functions
+ const getPage = (paginationTerms) => (parseInt((paginationTerms.limit - 1) / paginationTerms.itemsPerPage));
+
+ const onChangePage = (event, page) => {
+ setPaginationTerms({
+ itemsPerPage: paginationTerms.itemsPerPage,
+ limit: (page + 1) * paginationTerms.itemsPerPage,
+ offset: page * paginationTerms.itemsPerPage
+ });
+ };
+
+ const onChangeRowsPerPage = (event) => {
+ let value = event.target.value;
+ let offset = Math.max(0, parseInt((paginationTerms.limit - paginationTerms.itemsPerPage) / value) * value);
+ let limit = Math.min(offset + value, totalCount);
+ setPaginationTerms({
+ itemsPerPage: value,
+ limit: limit,
+ offset: offset
+ });
+ };
+
+ return (
+
+ {
+ title &&
+
+
+ title
+
+
+ }
+
+
+
+ {
+ _.sortBy(columns, column => column.order).map(
+ (column, index) =>
+
+ )
+ }
+ {
+ (showEdit || editComponent) &&
+
+
+ }
+
+
+
+ {
+ results &&
+
+
+ {
+ results.map(
+ (document, index) =>
+ )
+ }
+
+ }
+
+ {
+ footerData &&
+
+
+
+ {
+ _.sortBy(columns, column => column.order).map(
+ (column, index) =>
+
+ {footerData[index]}
+
+ )
+ }
+ {
+ (showEdit || editComponent) &&
+
+
+ }
+
+
+
+ }
+
+
+ {
+ paginate &&
+
+
+ }
+ {
+ !paginate && loadMore &&
+
+
+ }
+
+ );
+};
+
+
+replaceComponent('DatatableContents', DatatableContents, [withStyles, datatableContentsStyles]);
+
+
+/*
+
+DatatableHeader Component
+
+*/
+const DatatableHeader = ({ collection, intlNamespace, column, classes, toggleSort, currentSort }, { intl }) => {
+ const columnName = typeof column === 'string' ? column : column.name || column.label;
+ let formattedLabel = '';
+
+ if (collection) {
+ const schema = collection.simpleSchema()._schema;
+
+ /*
+ use either:
+
+ 1. the column name translation
+ 2. the column name label in the schema (if the column name matches a schema field)
+ 3. the raw column name.
+ */
+ const defaultMessage = schema[columnName] ? schema[columnName].label : Utils.camelToSpaces(columnName);
+ formattedLabel = typeof columnName === 'string' ?
+ intl.formatMessage({
+ id: `${collection._name}.${columnName}`,
+ defaultMessage: defaultMessage
+ }) :
+ '';
+
+ // if sortable is a string, use it as the name of the property to sort by. If it's just `true`, use
+ // column.name
+ const sortPropertyName = typeof column.sortable === 'string' ? column.sortable : column.name;
+
+ if (column.sortable) {
+ return ;
+ }
+ } else if (intlNamespace) {
+ formattedLabel = typeof columnName === 'string' ?
+ intl.formatMessage({
+ id: `${intlNamespace}.${columnName}`,
+ defaultMessage: columnName
+ }) :
+ '';
+ } else {
+ formattedLabel = intl.formatMessage({ id: columnName, defaultMessage: columnName });
+ }
+
+ return {formattedLabel};
+};
+
+
+DatatableHeader.contextTypes = {
+ intl: intlShape,
+};
+
+
+replaceComponent('DatatableHeader', DatatableHeader);
+
+
+/*
+
+DatatableSorter Component
+
+*/
+
+const DatatableSorter = ({ name, label, toggleSort, currentSort, sortable }) =>
+
+
+ toggleSort(name)}
+ >
+ {label}
+
+
+ ;
+
+replaceComponent('DatatableSorter', DatatableSorter);
+
+
+/*
+
+DatatableRow Component
+
+*/
+const datatableRowStyles = theme => (_assign({}, baseStyles(theme), {
+ clickRow: {
+ cursor: 'pointer',
+ },
+ editCell: {
+ paddingTop: '0 !important',
+ paddingBottom: '0 !important',
+ textAlign: 'right',
+ },
+}));
+
+
+const DatatableRow = ({
+ collection,
+ columns,
+ document,
+ refetch,
+ showEdit,
+ editComponent,
+ currentUser,
+ rowClass,
+ handleRowClick,
+ classes,
+ }, { intl }) => {
+
+ const EditComponent = editComponent;
+
+ if (typeof rowClass === 'function') {
+ rowClass = rowClass(document);
+ }
+
+ return (
+ handleRowClick(event, document))}
+ hover
+ >
+
+ {
+ _.sortBy(columns, column => column.order).map(
+ (column, index) =>
+ )
+ }
+
+ {
+ (showEdit || editComponent) &&
+
+
+ {
+ EditComponent &&
+
+
+ }
+ {
+ showEdit &&
+
+
+ }
+
+ }
+
+
+ );
+};
+
+
+replaceComponent('DatatableRow', DatatableRow, [withStyles, datatableRowStyles]);
+
+
+DatatableRow.contextTypes = {
+ intl: intlShape
+};
+
+
+/*
+
+DatatableCell Component
+
+*/
+const DatatableCell = ({ column, document, currentUser, classes }) => {
+ const Component = column.component ||
+ Components[column.componentName] ||
+ Components.DatatableDefaultCell;
+
+ const columnName = typeof column === 'string' ? column : column.name;
+ const className = typeof columnName === 'string' ?
+ `datatable-item-${columnName.toLowerCase()}` :
+ '';
+ const cellClass = typeof column.cellClass === 'function' ?
+ column.cellClass({ column, document, currentUser }) :
+ typeof column.cellClass === 'string' ?
+ column.cellClass :
+ null;
+
+ return (
+
+
+
+ );
+};
+
+
+replaceComponent('DatatableCell', DatatableCell);
+
+
+/*
+
+DatatableDefaultCell Component
+
+*/
+const DatatableDefaultCell = ({ column, document }) =>
+
+ {
+ typeof column === 'string'
+ ?
+ getFieldValue(document[column])
+ :
+ getFieldValue(document[column.name])
+ }
+
;
+
+
+replaceComponent('DatatableDefaultCell', DatatableDefaultCell);
diff --git a/packages/vulcan-ui-material/components/core/EditButton.jsx b/packages/vulcan-ui-material/components/core/EditButton.jsx
new file mode 100644
index 000000000..7e391a648
--- /dev/null
+++ b/packages/vulcan-ui-material/components/core/EditButton.jsx
@@ -0,0 +1,86 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Components, registerComponent } from 'meteor/vulcan:core';
+import { intlShape } from 'meteor/vulcan:i18n';
+import EditIcon from 'mdi-material-ui/Pencil';
+
+
+const EditButton = ({
+ collection,
+ document,
+ color = 'default',
+ variant,
+ triggerClasses,
+ buttonClasses,
+ ...props
+ }, { intl }) => (
+
+ }
+ color={color}
+ variant={variant}
+ classes={buttonClasses}
+ />}
+ >
+
+
+);
+
+
+EditButton.propTypes = {
+ collection: PropTypes.object.isRequired,
+ document: PropTypes.object.isRequired,
+ color: PropTypes.oneOf(['default', 'inherit', 'primary', 'secondary']),
+ variant: PropTypes.string,
+ triggerClasses: PropTypes.object,
+ buttonClasses: PropTypes.object,
+};
+
+
+EditButton.contextTypes = {
+ intl: intlShape
+};
+
+
+EditButton.displayName = 'EditButton';
+
+
+registerComponent('EditButton', EditButton);
+
+
+/*
+
+EditForm Component
+
+*/
+const EditForm = ({ collection, document, closeModal, options, successCallback, removeSuccessCallback,...props }) => {
+
+ const success = successCallback
+ ? () => {
+ successCallback();
+ closeModal();
+ }
+ : closeModal;
+
+ const remove = removeSuccessCallback
+ ? () => {
+ removeSuccessCallback();
+ closeModal();
+ }
+ : closeModal;
+
+ return (
+
+);
+}
+
+registerComponent('EditForm', EditForm);
diff --git a/packages/vulcan-ui-material/components/core/Loading.jsx b/packages/vulcan-ui-material/components/core/Loading.jsx
new file mode 100644
index 000000000..dc821a032
--- /dev/null
+++ b/packages/vulcan-ui-material/components/core/Loading.jsx
@@ -0,0 +1,9 @@
+import React from 'react';
+import { replaceComponent } from 'meteor/vulcan:core';
+import CircularProgress from '@material-ui/core/CircularProgress';
+
+function Loading(props) {
+ return ;
+}
+
+replaceComponent('Loading', Loading);
diff --git a/packages/vulcan-ui-material/components/core/ModalTrigger.jsx b/packages/vulcan-ui-material/components/core/ModalTrigger.jsx
new file mode 100644
index 000000000..8a3dbd159
--- /dev/null
+++ b/packages/vulcan-ui-material/components/core/ModalTrigger.jsx
@@ -0,0 +1,150 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { intlShape } from 'meteor/vulcan:i18n';
+import { registerComponent } from 'meteor/vulcan:core';
+import withStyles from '@material-ui/core/styles/withStyles';
+import Dialog from '@material-ui/core/Dialog';
+import DialogContent from '@material-ui/core/DialogContent';
+import DialogTitle from '@material-ui/core/DialogTitle';
+import Button from '@material-ui/core/Button';
+import classNames from 'classnames';
+
+
+const styles = theme => ({
+ root: {
+ display: 'inline-block',
+ },
+ button: {},
+ anchor: {},
+ dialog: {},
+ dialogPaper: {},
+ dialogTitle: {},
+ dialogContent: {
+ paddingTop: '4px',
+ },
+ dialogOverflow: {
+ overflowY: 'visible',
+ },
+});
+
+
+class ModalTrigger extends PureComponent {
+
+ constructor (props) {
+ super(props);
+
+ this.state = { modalIsOpen: false };
+
+
+ }
+
+ componentDidMount() {
+ if (this.props.action) {
+ this.props.action({
+ openModal: this.openModal,
+ closeModal: this.closeModal,
+ });
+ }
+ }
+
+ openModal = () => {
+ this.setState({ modalIsOpen: true });
+ };
+
+ closeModal = () => {
+ this.setState({ modalIsOpen: false });
+ };
+
+ render () {
+ const {
+ className,
+ dialogClassName,
+ dialogOverflow,
+ labelId,
+ component,
+ titleId,
+ type,
+ children,
+ classes,
+ } = this.props;
+
+ const intl = this.context.intl;
+
+ const label = labelId ? intl.formatMessage({ id: labelId }) : this.props.label;
+ const title = titleId ? intl.formatMessage({ id: titleId }) : this.props.title;
+ const overflowClass = dialogOverflow && classes.dialogOverflow;
+
+ const triggerComponent = component
+ ?
+ React.cloneElement(component, { onClick: this.openModal })
+ :
+ type === 'button'
+ ?
+
+ :
+ {label};
+
+ const childrenComponent = typeof children.type === 'function' ?
+ React.cloneElement(children, { closeModal: this.closeModal }) :
+ children;
+
+ return (
+
+
+ {triggerComponent}
+
+
+
+
+ );
+ }
+}
+
+
+ModalTrigger.propTypes = {
+ /**
+ * Callback fired when the component mounts.
+ * This is useful when you want to trigger an action programmatically.
+ * It supports `openModal()` and `closeModal()`.
+ *
+ * @param {object} actions This object contains all possible actions
+ * that can be triggered programmatically.
+ */
+ action: PropTypes.func,
+ className: PropTypes.string,
+ dialogClassName: PropTypes.string,
+ dialogOverflow: PropTypes.bool,
+ label: PropTypes.string,
+ labelId: PropTypes.string,
+ component: PropTypes.object,
+ title: PropTypes.node,
+ titleId: PropTypes.string,
+ type: PropTypes.oneOf(['link', 'button']),
+ children: PropTypes.node,
+ classes: PropTypes.object,
+};
+
+
+ModalTrigger.contextTypes = {
+ intl: intlShape,
+};
+
+
+registerComponent('ModalTrigger', ModalTrigger, [withStyles, styles]);
diff --git a/packages/vulcan-ui-material/components/core/NewButton.jsx b/packages/vulcan-ui-material/components/core/NewButton.jsx
new file mode 100644
index 000000000..1ce2cbf93
--- /dev/null
+++ b/packages/vulcan-ui-material/components/core/NewButton.jsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Components, replaceComponent } from 'meteor/vulcan:core';
+import { intlShape } from 'meteor/vulcan:i18n';
+import AddIcon from 'mdi-material-ui/Plus';
+
+
+const NewButton = ({
+ className,
+ collection,
+ color = 'default',
+ variant,
+ }, { intl }) => (
+
+ }
+ color={color}
+ variant={variant}
+ />}
+ >
+
+
+);
+
+
+NewButton.propTypes = {
+ className: PropTypes.string,
+ collection: PropTypes.object.isRequired,
+ color: PropTypes.oneOf(['default', 'inherit', 'primary', 'secondary']),
+ variant: PropTypes.string,
+};
+
+
+NewButton.contextTypes = {
+ intl: intlShape
+};
+
+
+NewButton.displayName = 'NewButton';
+
+
+replaceComponent('NewButton', NewButton);
diff --git a/packages/vulcan-ui-material/components/forms/FormComponentInner.jsx b/packages/vulcan-ui-material/components/forms/FormComponentInner.jsx
new file mode 100644
index 000000000..473f39e6d
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/FormComponentInner.jsx
@@ -0,0 +1,129 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { intlShape } from 'meteor/vulcan:i18n';
+import { Components, registerComponent, instantiateComponent } from 'meteor/vulcan:core';
+import withStyles from '@material-ui/core/styles/withStyles';
+import _omit from 'lodash/omit';
+import classNames from 'classnames';
+
+
+const styles = theme => ({
+
+ formInput: {
+ position: 'relative',
+ marginBottom: theme.spacing.unit * 3,
+ },
+
+ halfWidthLeft: {
+ display: 'inline-block',
+ width: '48%',
+ verticalAlign: 'top',
+ marginRight: '4%',
+ },
+
+ halfWidthRight: {
+ display: 'inline-block',
+ width: '48%',
+ verticalAlign: 'top',
+ },
+
+ thirdWidthLeft: {
+ display: 'inline-block',
+ width: '31%',
+ verticalAlign: 'top',
+ marginRight: '3.5%',
+ },
+
+ thirdWidthRight: {
+ display: 'inline-block',
+ width: '31%',
+ verticalAlign: 'top',
+ },
+
+ hidden: {
+ display: 'none',
+ },
+
+});
+
+
+class FormComponentInner extends PureComponent {
+
+ getProperties = () => {
+ return _omit(this.props, 'classes');
+ };
+
+ render () {
+ const {
+ classes,
+ inputClassName,
+ name,
+ input,
+ hidden,
+ beforeComponent,
+ afterComponent,
+ formInput,
+ intlInput,
+ nestedInput,
+ formComponents,
+ } = this.props;
+
+ const FormComponents = formComponents;
+
+ const inputClass = classNames(
+ classes.formInput,
+ hidden && classes.hidden,
+ inputClassName && classes[inputClassName],
+ `input-${name}`,
+ `form-component-${input || 'default'}`
+ );
+
+ const properties = this.getProperties();
+
+ const FormInput = formInput;
+
+ if (intlInput) {
+ return ;
+ } else if (nestedInput){
+ return ;
+ } else {
+ return (
+
+ {instantiateComponent(beforeComponent, properties)}
+
+ {instantiateComponent(afterComponent, properties)}
+
+ );
+ }
+
+ }
+}
+
+
+FormComponentInner.contextTypes = {
+ intl: intlShape,
+};
+
+
+FormComponentInner.propTypes = {
+ classes: PropTypes.object.isRequired,
+ inputClassName: PropTypes.string,
+ name: PropTypes.string.isRequired,
+ input: PropTypes.any,
+ beforeComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
+ afterComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
+ errors: PropTypes.array.isRequired,
+ help: PropTypes.node,
+ onChange: PropTypes.func.isRequired,
+ showCharsRemaining: PropTypes.bool.isRequired,
+ charsRemaining: PropTypes.number,
+ charsCount: PropTypes.number,
+ max: PropTypes.number,
+ formInput: PropTypes.func.isRequired,
+};
+
+
+FormComponentInner.displayName = 'FormComponentInner';
+
+
+registerComponent('FormComponentInner', FormComponentInner, [withStyles, styles]);
diff --git a/packages/vulcan-ui-material/components/forms/FormErrors.jsx b/packages/vulcan-ui-material/components/forms/FormErrors.jsx
new file mode 100644
index 000000000..049d13ce1
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/FormErrors.jsx
@@ -0,0 +1,51 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { replaceComponent, Components } from 'meteor/vulcan:core';
+import { FormattedMessage } from 'meteor/vulcan:i18n';
+
+import Snackbar from '@material-ui/core/Snackbar';
+import withStyles from '@material-ui/core/styles/withStyles';
+import classNames from 'classnames';
+
+
+const styles = theme => ({
+ root: {
+ position: 'relative',
+ boxShadow: 'none',
+ marginBottom: theme.spacing.unit * 2
+ },
+ list: {
+ marginBottom: 0
+ },
+ error: { '& > div': { backgroundColor: theme.palette.error[500] } },
+ danger: { '& > div': { backgroundColor: theme.palette.error[500] } },
+ warning: { '& > div': { backgroundColor: theme.palette.error[500] } }
+});
+
+
+const FormErrors = ({ errors, classes }) => {
+ const messageNode = (
+
+ {errors.map((error, index) => (
+ -
+
+
+ ))}
+
+ );
+
+ return (
+
+ {!!errors.length && (
+
+ )}
+
+ );
+};
+
+
+replaceComponent('FormErrors', FormErrors, [withStyles, styles]);
diff --git a/packages/vulcan-ui-material/components/forms/FormGroup.jsx b/packages/vulcan-ui-material/components/forms/FormGroup.jsx
new file mode 100644
index 000000000..3ad7781db
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/FormGroup.jsx
@@ -0,0 +1,205 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { Components, replaceComponent, instantiateComponent, } from 'meteor/vulcan:core';
+import Users from 'meteor/vulcan:users';
+import withStyles from '@material-ui/core/styles/withStyles';
+import Collapse from '@material-ui/core/Collapse';
+import Typography from '@material-ui/core/Typography';
+import Paper from '@material-ui/core/Paper';
+import ExpandLessIcon from 'mdi-material-ui/ChevronUp';
+import ExpandMoreIcon from 'mdi-material-ui/ChevronDown';
+import classNames from 'classnames';
+
+
+const styles = theme => ({
+ root: {
+ minWidth: '320px'
+ },
+ paper: {
+ padding: theme.spacing.unit * 3
+ },
+ subtitle1: {
+ display: 'flex',
+ alignItems: 'center',
+ paddingLeft: theme.spacing.unit / 2,
+ marginTop: theme.spacing.unit * 5,
+ marginBottom: theme.spacing.unit,
+ color: theme.palette.primary[500],
+ },
+ collapsible: {
+ cursor: 'pointer',
+ },
+ label: {},
+ toggle: {
+ '& svg': {
+ width: 21,
+ height: 21,
+ display: 'block',
+ }
+ },
+ container: {
+ paddingLeft: 4,
+ paddingRight: 4,
+ marginLeft: -4,
+ marginRight: -4,
+ },
+ entered: {
+ overflow: 'visible',
+ },
+});
+
+
+class FormGroup extends PureComponent {
+
+
+ constructor (props) {
+ super(props);
+
+ this.isAdmin = props.name === 'admin';
+
+ this.state = {
+ collapsed: props.startCollapsed || this.isAdmin,
+ };
+ }
+
+
+ toggle = () => {
+ const collapsible = this.props.collapsible || this.isAdmin;
+ if (!collapsible) return;
+
+ this.setState({
+ collapsed: !this.state.collapsed
+ });
+ };
+
+
+ renderHeading = () => {
+ const { classes, label } = this.props;
+ const collapsible = this.props.collapsible || this.isAdmin;
+
+ return (
+
+
+
+ {label}
+
+
+ {
+ collapsible &&
+
+
+ {
+ this.state.collapsed
+ ?
+
+ :
+
+ }
+
+ }
+
+
+ );
+ };
+
+
+ // if at least one of the fields in the group has an error, the group as a whole has an error
+ hasErrors = () => _.some(this.props.fields, field => {
+ return !!this.props.errors.filter(error => error.path === field.path).length;
+ });
+
+
+ render () {
+ const {
+ name,
+ hidden,
+ classes,
+ currentUser,
+ } = this.props;
+
+ if (this.isAdmin && !Users.isAdmin(currentUser)) {
+ return null;
+ }
+
+ if (typeof hidden === 'function' ? hidden({ ...this.props }) : hidden) {
+ return null;
+ }
+
+ //do not display if no fields, no startComponent and no endComponent
+ if (!this.props.startComponent && !this.props.endComponent && !this.props.fields.length) {
+ return null;
+ }
+
+ const anchorName = name.split('.').length > 1 ? name.split('.')[1] : name;
+ const collapseIn = !this.state.collapsed || this.hasErrors();
+
+ const FormComponents = this.props.formComponents;
+
+ return (
+
+ );
+ }
+
+
+}
+
+
+FormGroup.propTypes = {
+ name: PropTypes.string,
+ label: PropTypes.string,
+ order: PropTypes.number,
+ hidden: PropTypes.bool,
+ fields: PropTypes.array,
+ collapsible: PropTypes.bool,
+ startCollapsed: PropTypes.bool,
+ updateCurrentValues: PropTypes.func,
+ startComponent: PropTypes.node,
+ endComponent: PropTypes.node,
+ currentUser: PropTypes.object,
+};
+
+
+replaceComponent('FormGroup', FormGroup, [withStyles, styles]);
diff --git a/packages/vulcan-ui-material/components/forms/FormGroupNone.jsx b/packages/vulcan-ui-material/components/forms/FormGroupNone.jsx
new file mode 100644
index 000000000..773b11eba
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/FormGroupNone.jsx
@@ -0,0 +1,95 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import {
+ Components,
+ registerComponent,
+ instantiateComponent,
+} from 'meteor/vulcan:core';
+import withStyles from '@material-ui/core/styles/withStyles';
+import Users from 'meteor/vulcan:users';
+import classNames from 'classnames';
+
+
+const styles = theme => ({
+ root: {
+ minWidth: '320px'
+ },
+});
+
+
+class FormGroupNone extends PureComponent {
+
+
+ render () {
+ const {
+ name,
+ hidden,
+ classes,
+ currentUser,
+ } = this.props;
+
+ if (this.isAdmin && !Users.isAdmin(currentUser)) {
+ return null;
+ }
+
+ if (typeof hidden === 'function' ? hidden({ ...this.props }) : hidden) {
+ return null;
+ }
+
+ //do not display if no fields, no startComponent and no endComponent
+ if (!this.props.startComponent && !this.props.endComponent && !this.props.fields.length) {
+ return null;
+ }
+
+ const anchorName = name.split('.').length > 1 ? name.split('.')[1] : name;
+
+ const FormComponents = this.props.formComponents;
+
+ return (
+
+ );
+ }
+
+
+}
+
+
+FormGroupNone.propTypes = {
+ name: PropTypes.string,
+ order: PropTypes.number,
+ hidden: PropTypes.bool,
+ fields: PropTypes.array,
+ updateCurrentValues: PropTypes.func,
+ startComponent: PropTypes.node,
+ endComponent: PropTypes.node,
+ currentUser: PropTypes.object,
+};
+
+
+registerComponent('FormGroupNone', FormGroupNone, [withStyles, styles]);
diff --git a/packages/vulcan-ui-material/components/forms/FormGroupWithLine.jsx b/packages/vulcan-ui-material/components/forms/FormGroupWithLine.jsx
new file mode 100644
index 000000000..7ea0c0965
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/FormGroupWithLine.jsx
@@ -0,0 +1,198 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { Components, registerComponent, instantiateComponent } from 'meteor/vulcan:core';
+import Users from 'meteor/vulcan:users';
+import withStyles from '@material-ui/core/styles/withStyles';
+import Collapse from '@material-ui/core/Collapse';
+import Divider from '@material-ui/core/Divider';
+import Typography from '@material-ui/core/Typography';
+import ExpandLessIcon from 'mdi-material-ui/ChevronUp';
+import ExpandMoreIcon from 'mdi-material-ui/ChevronDown';
+import classNames from 'classnames';
+
+
+const styles = theme => ({
+ root: {
+ minWidth: '320px',
+ },
+ divider: {
+ marginLeft: theme.spacing.unit * -3,
+ marginRight: theme.spacing.unit * -3,
+ },
+ subtitle1: {
+ marginTop: theme.spacing.unit * 5,
+ position: 'relative',
+ },
+ collapsible: {
+ cursor: 'pointer',
+ },
+ typography: {
+ display: 'flex',
+ alignItems: 'center',
+ '& > div': {
+ display: 'flex',
+ alignItems: 'center',
+ },
+ '& > div:first-child': {
+ ...theme.typography.subtitle1,
+ },
+ paddingTop: theme.spacing.unit,
+ paddingBottom: theme.spacing.unit,
+ },
+ toggle: {
+ color: theme.palette.action.active,
+ },
+ entered: {
+ overflow: 'visible',
+ },
+});
+
+
+class FormGroupWithLine extends PureComponent {
+
+ constructor (props) {
+ super(props);
+
+ this.isAdmin = props.name === 'admin';
+
+ this.state = {
+ collapsed: props.startCollapsed || this.isAdmin,
+ };
+ }
+
+
+ toggle = () => {
+ const collapsible = this.props.collapsible || this.isAdmin;
+ if (!collapsible) return;
+
+ this.setState({
+ collapsed: !this.state.collapsed
+ });
+ };
+
+
+ renderHeading = () => {
+ const { classes } = this.props;
+ const collapsible = this.props.collapsible || this.isAdmin;
+
+ return (
+
+
+
+
+
+
+ {this.props.label}
+
+ {
+ collapsible &&
+
+
+ {
+ this.state.collapsed
+ ?
+
+ :
+
+ }
+
+ }
+
+
+
+ );
+ };
+
+
+ // if at least one of the fields in the group has an error, the group as a whole has an error
+ hasErrors = () => _.some(this.props.fields, field => {
+ return !!this.props.errors.filter(error => error.path === field.path).length;
+ });
+
+
+ render () {
+ const {
+ name,
+ hidden,
+ classes,
+ currentUser,
+ } = this.props;
+
+ if (this.isAdmin && !Users.isAdmin(currentUser)) {
+ return null;
+ }
+
+ if (typeof hidden === 'function' ? hidden({ ...this.props }) : hidden) {
+ return null;
+ }
+
+ //do not display if no fields, no startComponent and no endComponent
+ if (!this.props.startComponent && !this.props.endComponent && !this.props.fields.length) {
+ return null;
+ }
+
+ const anchorName = name.split('.').length > 1 ? name.split('.')[1] : name;
+ const collapseIn = !this.state.collapsed || this.hasErrors();
+
+ const FormComponents = this.props.formComponents;
+
+ return (
+
+ );
+ }
+}
+
+
+FormGroupWithLine.propTypes = {
+ name: PropTypes.string,
+ label: PropTypes.string,
+ order: PropTypes.number,
+ fields: PropTypes.array,
+ collapsible: PropTypes.bool,
+ startCollapsed: PropTypes.bool,
+ updateCurrentValues: PropTypes.func,
+ startComponent: PropTypes.node,
+ endComponent: PropTypes.node,
+ currentUser: PropTypes.object,
+};
+
+
+registerComponent('FormGroupWithLine', FormGroupWithLine, [withStyles, styles]);
diff --git a/packages/vulcan-ui-material/components/forms/FormNested.jsx b/packages/vulcan-ui-material/components/forms/FormNested.jsx
new file mode 100644
index 000000000..2b3c7e5db
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/FormNested.jsx
@@ -0,0 +1,13 @@
+import React from 'react';
+import { replaceComponent } from 'meteor/vulcan:core';
+import Delete from 'mdi-material-ui/Delete';
+import Plus from 'mdi-material-ui/Plus';
+
+
+const IconRemove = () => ;
+
+replaceComponent('IconRemove', IconRemove);
+
+const IconAdd = () => ;
+
+replaceComponent('IconAdd', IconAdd);
diff --git a/packages/vulcan-ui-material/components/forms/FormNestedDivider.jsx b/packages/vulcan-ui-material/components/forms/FormNestedDivider.jsx
new file mode 100644
index 000000000..344ba4fdb
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/FormNestedDivider.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { registerComponent } from 'meteor/vulcan:core';
+import { FormattedMessage } from 'meteor/vulcan:i18n';
+import withStyles from '@material-ui/core/styles/withStyles';
+import Divider from '@material-ui/core/Divider';
+
+
+const styles = theme => ({
+
+ divider: {
+ marginLeft: -24,
+ marginRight: -24,
+ marginTop: 16,
+ marginBottom: 23,
+ },
+
+});
+
+
+const FormNestedDivider = ({ classes, label, addItem }) => ;
+
+FormNestedDivider.propTypes = {
+ classes: PropTypes.object.isRequired,
+ label: PropTypes.string,
+ addItem: PropTypes.func,
+};
+
+registerComponent('FormNestedDivider', FormNestedDivider, [withStyles, styles]);
diff --git a/packages/vulcan-ui-material/components/forms/FormNestedFoot.jsx b/packages/vulcan-ui-material/components/forms/FormNestedFoot.jsx
new file mode 100644
index 000000000..7cb6b98eb
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/FormNestedFoot.jsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Components, registerComponent } from 'meteor/vulcan:core';
+import { FormattedMessage } from 'meteor/vulcan:i18n';
+import Grid from '@material-ui/core/Grid';
+
+const FormNestedFoot = ({ label, addItem }) => (
+
+
+
+
+
+);
+
+FormNestedFoot.propTypes = {
+ label: PropTypes.string,
+ addItem: PropTypes.func,
+};
+
+registerComponent('FormNestedFoot', FormNestedFoot);
diff --git a/packages/vulcan-ui-material/components/forms/FormNestedHead.jsx b/packages/vulcan-ui-material/components/forms/FormNestedHead.jsx
new file mode 100644
index 000000000..bd24a88d5
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/FormNestedHead.jsx
@@ -0,0 +1,13 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { replaceComponent } from 'meteor/vulcan:core';
+import { FormattedMessage } from 'meteor/vulcan:i18n';
+
+const FormNestedHead = ({ label, addItem }) => ;
+
+FormNestedHead.propTypes = {
+ label: PropTypes.string,
+ addItem: PropTypes.func,
+};
+
+replaceComponent('FormNestedHead', FormNestedHead);
diff --git a/packages/vulcan-ui-material/components/forms/FormSubmit.jsx b/packages/vulcan-ui-material/components/forms/FormSubmit.jsx
new file mode 100644
index 000000000..b8b92a301
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/FormSubmit.jsx
@@ -0,0 +1,136 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Components, replaceComponent } from 'meteor/vulcan:core';
+import { intlShape } from 'meteor/vulcan:i18n';
+import withStyles from '@material-ui/core/styles/withStyles';
+import Button from '@material-ui/core/Button';
+import IconButton from '@material-ui/core/IconButton';
+import DeleteIcon from 'mdi-material-ui/Delete';
+import Tooltip from '@material-ui/core/Tooltip';
+import { FormattedMessage } from 'meteor/vulcan:i18n';
+import classNames from 'classnames';
+
+
+const styles = theme => ({
+ root: {
+ textAlign: 'center',
+ marginTop: theme.spacing.unit * 4,
+ },
+ button: {
+ margin: theme.spacing.unit,
+ },
+ delete: {
+ float: 'left',
+ },
+ tooltip: {
+ margin: 3,
+ }
+});
+
+
+const FormSubmit = ({
+ submitLabel,
+ cancelLabel,
+ cancelCallback,
+ revertLabel,
+ revertCallback,
+ document,
+ deleteDocument,
+ collectionName,
+ classes
+ }, {
+ intl,
+ isChanged,
+ clearForm,
+ }) => {
+
+ if (typeof isChanged !== 'function') {
+ isChanged = () => true;
+ }
+
+ return (
+
+
+ {
+ deleteDocument
+ ?
+
+
+
+
+
+ :
+ null
+ }
+
+ {
+ cancelCallback
+ ?
+
+ :
+ null
+ }
+
+ {
+ revertCallback
+ ?
+
+ :
+ null
+ }
+
+
+
+
+ );
+};
+
+
+FormSubmit.propTypes = {
+ submitLabel: PropTypes.node,
+ cancelLabel: PropTypes.node,
+ revertLabel: PropTypes.node,
+ cancelCallback: PropTypes.func,
+ revertCallback: PropTypes.func,
+ document: PropTypes.object,
+ deleteDocument: PropTypes.func,
+ collectionName: PropTypes.string,
+ classes: PropTypes.object,
+};
+
+
+FormSubmit.contextTypes = {
+ intl: intlShape,
+ isChanged: PropTypes.func,
+ clearForm: PropTypes.func,
+};
+
+
+replaceComponent('FormSubmit', FormSubmit, [withStyles, styles]);
diff --git a/packages/vulcan-ui-material/components/forms/base-controls/EndAdornment.jsx b/packages/vulcan-ui-material/components/forms/base-controls/EndAdornment.jsx
new file mode 100644
index 000000000..4f136be08
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/base-controls/EndAdornment.jsx
@@ -0,0 +1,84 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { instantiateComponent } from 'meteor/vulcan:core';
+import withStyles from '@material-ui/core/styles/withStyles';
+import InputAdornment from '@material-ui/core/InputAdornment';
+import IconButton from '@material-ui/core/IconButton';
+import CloseIcon from 'mdi-material-ui/CloseCircle';
+import classNames from 'classnames';
+
+
+export const styles = theme => ({
+ inputAdornment: {
+ whiteSpace: 'nowrap',
+ marginTop: '0 !important',
+ '& > *': {
+ verticalAlign: 'bottom',
+ },
+ '& > svg': {
+ color: theme.palette.common.darkBlack,
+ },
+ '& > * + *': {
+ marginLeft: 8,
+ }
+ },
+ clearButton: {
+ opacity: 0,
+ '& svg': {
+ width: 20,
+ height: 20,
+ },
+ marginRight: -12,
+ marginLeft: -4,
+ '&:first-child': {
+ marginLeft: -12,
+ },
+ transition: theme.transitions.create('opacity', {
+ duration: theme.transitions.duration.short,
+ }),
+ },
+ urlButton: {
+ verticalAlign: 'bottom',
+ width: 24,
+ height: 24,
+ fontSize: 20,
+ }
+});
+
+
+const EndAdornment = (props) => {
+ const { classes, value, addonAfter, changeValue, hideClear, disabled } = props;
+
+ if (!addonAfter && (!changeValue || hideClear || disabled)) return null;
+ const hasValue = !!value || value === 0;
+
+ const clearButton = changeValue && !hideClear && !disabled &&
+ {
+ event.preventDefault();
+ changeValue(null);
+ }}
+ tabIndex="-1"
+ >
+
+ ;
+
+ return (
+
+ {instantiateComponent(addonAfter)}
+ {clearButton}
+
+ );
+};
+
+
+EndAdornment.propTypes = {
+ classes: PropTypes.object.isRequired,
+ value: PropTypes.any,
+ changeValue: PropTypes.func,
+ hideClear: PropTypes.bool,
+ addonAfter: PropTypes.oneOfType([PropTypes.string, PropTypes.node, PropTypes.func]),
+};
+
+
+export default withStyles(styles)(EndAdornment);
diff --git a/packages/vulcan-ui-material/components/forms/base-controls/MuiCheckboxGroup.jsx b/packages/vulcan-ui-material/components/forms/base-controls/MuiCheckboxGroup.jsx
new file mode 100644
index 000000000..4d80d8492
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/base-controls/MuiCheckboxGroup.jsx
@@ -0,0 +1,150 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import createReactClass from 'create-react-class';
+import ComponentMixin from './mixins/component';
+import withStyles from '@material-ui/core/styles/withStyles';
+import FormGroup from '@material-ui/core/FormGroup';
+import FormControlLabel from '@material-ui/core/FormControlLabel';
+import MuiFormControl from './MuiFormControl';
+import MuiFormHelper from './MuiFormHelper';
+import Checkbox from '@material-ui/core/Checkbox';
+import Switch from '@material-ui/core/Switch';
+import classNames from 'classnames';
+
+
+const styles = theme => ({
+ group: {
+ marginTop: '8px',
+ },
+ twoColumn: {
+ display: 'block',
+ [theme.breakpoints.down('md')]: {
+ '& > label': {
+ marginRight: theme.spacing.unit * 5,
+ },
+ },
+ [theme.breakpoints.up('md')]: {
+ '& > label': {
+ width: '49%',
+ },
+ },
+ },
+ threeColumn: {
+ display: 'block',
+ [theme.breakpoints.down('xs')]: {
+ '& > label': {
+ marginRight: theme.spacing.unit * 5,
+ },
+ },
+ [theme.breakpoints.up('xs')]: {
+ '& > label': {
+ width: '49%',
+ },
+ },
+ [theme.breakpoints.up('md')]: {
+ '& > label': {
+ width: '32%',
+ },
+ },
+ },
+});
+
+
+const MuiCheckboxGroup = createReactClass({
+
+ mixins: [ComponentMixin],
+
+ propTypes: {
+ name: PropTypes.string.isRequired,
+ options: PropTypes.array.isRequired,
+ classes: PropTypes.object.isRequired,
+ variant: PropTypes.oneOf(['checkbox', 'switch']),
+ },
+
+ componentDidMount: function () {
+ if (this.props.refFunction) {
+ this.props.refFunction(this);
+ }
+ },
+
+ getDefaultProps: function () {
+ return {
+ label: '',
+ help: null,
+ variant: 'checkbox',
+ };
+ },
+
+ changeCheckbox: function () {
+ const value = [];
+ this.props.options.forEach(function (option, key) {
+ if (this[this.props.name + '-' + option.value].checked) {
+ value.push(option.value);
+ }
+ }.bind(this));
+ //this.setValue(value);
+ this.props.onChange(this.props.name, value);
+ },
+
+ validate: function () {
+ if (this.props.onBlur) {
+ this.props.onBlur();
+ }
+ return true;
+ },
+
+ renderElement: function () {
+ const controls = this.props.options.map((checkbox, key) => {
+ let value = checkbox.value;
+ let checked = (this.props.value.indexOf(value) !== -1);
+ let disabled = checkbox.disabled || this.props.disabled;
+ const Component = this.props.variant === 'switch' ? Switch : Checkbox;
+
+ return (
+ this[this.props.name + '-' + value] = c}
+ checked={checked}
+ onChange={this.changeCheckbox}
+ value={value}
+ disabled={disabled}
+ />
+ }
+ label={checkbox.label}
+ />
+ );
+ });
+
+ const maxLength = this.props.options.reduce((max, option) =>
+ option.label.length > max ? option.label.length : max, 0);
+
+ const columnClass = maxLength < 20 ? 'threeColumn' : maxLength < 30 ? 'twoColumn' : '';
+
+ return (
+
+ {controls}
+
+ );
+ },
+
+ render: function () {
+
+ if (this.props.layout === 'elementOnly') {
+ return (
+ {this.renderElement()}
+ );
+ }
+
+ return (
+
+ {this.renderElement()}
+
+
+ );
+ }
+});
+
+
+export default withStyles(styles)(MuiCheckboxGroup);
diff --git a/packages/vulcan-ui-material/components/forms/base-controls/MuiFormControl.jsx b/packages/vulcan-ui-material/components/forms/base-controls/MuiFormControl.jsx
new file mode 100644
index 000000000..7b426355d
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/base-controls/MuiFormControl.jsx
@@ -0,0 +1,91 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import createReactClass from 'create-react-class';
+import InputLabel from '@material-ui/core/InputLabel';
+import FormControl from '@material-ui/core/FormControl';
+import FormLabel from '@material-ui/core/FormLabel';
+
+
+//noinspection JSUnusedGlobalSymbols
+const MuiFormControl = createReactClass({
+
+ propTypes: {
+ label: PropTypes.node,
+ children: PropTypes.node,
+ required: PropTypes.bool,
+ hasErrors: PropTypes.bool,
+ fakeLabel: PropTypes.bool,
+ hideLabel: PropTypes.bool,
+ layout: PropTypes.oneOf(['horizontal', 'vertical', 'elementOnly']),
+ htmlFor: PropTypes.string
+ },
+
+ getDefaultProps: function () {
+ return {
+ label: '',
+ required: false,
+ hasErrors: false,
+ fakeLabel: false,
+ hideLabel: false,
+ };
+ },
+
+ renderRequiredSymbol: function () {
+ if (this.props.required === false) {
+ return null;
+ }
+ return (
+ *
+ );
+ },
+
+ renderLabel: function () {
+ if (this.props.layout === 'elementOnly' || this.props.hideLabel) {
+ return null;
+ }
+
+ if (this.props.fakeLabel) {
+ return (
+
+ {this.props.label}
+ {this.renderRequiredSymbol()}
+
+ );
+ }
+
+ const shrink = ['date', 'time', 'datetime'].includes(this.props.inputType) ? true : undefined;
+
+ return (
+
+ {this.props.label}
+ {this.renderRequiredSymbol()}
+
+ );
+ },
+
+ render: function () {
+ const { layout, className, children, hasErrors } = this.props;
+
+ if (layout === 'elementOnly') {
+ return {children};
+ }
+
+ return (
+
+ {this.renderLabel()}
+ {children}
+
+ );
+ }
+
+});
+
+
+export default MuiFormControl;
diff --git a/packages/vulcan-ui-material/components/forms/base-controls/MuiFormHelper.jsx b/packages/vulcan-ui-material/components/forms/base-controls/MuiFormHelper.jsx
new file mode 100644
index 000000000..58a5f4e5d
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/base-controls/MuiFormHelper.jsx
@@ -0,0 +1,75 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { instantiateComponent, Components } from 'meteor/vulcan:core';
+import withStyles from '@material-ui/core/styles/withStyles';
+import FormHelperText from '@material-ui/core/FormHelperText';
+import { FormattedMessage } from 'meteor/vulcan:i18n';
+import classNames from 'classnames';
+
+
+export const styles = theme => ({
+
+ error: {
+ color: theme.palette.error.main,
+ },
+
+ formHelperText: {
+ display: 'flex',
+ '& :first-child': {
+ flexGrow: 1,
+ }
+ },
+
+});
+
+
+const MuiFormHelper = (props) => {
+ const {
+ classes,
+ help,
+ errors,
+ hasErrors,
+ showCharsRemaining,
+ charsRemaining,
+ charsCount,
+ max,
+ } = props;
+
+ if (!help && !hasErrors && !showCharsRemaining) {
+ return null;
+ }
+
+ const errorMessage = hasErrors &&
+ ;
+
+ return (
+
+
+
+ {
+ hasErrors ? errorMessage : help
+ }
+
+
+ {
+ showCharsRemaining &&
+
+
+ {charsCount} / {max}
+
+ }
+
+
+ );
+};
+
+
+MuiFormHelper.propTypes = {
+ classes: PropTypes.object.isRequired,
+ value: PropTypes.any,
+ changeValue: PropTypes.func,
+ addonAfter: PropTypes.oneOfType([PropTypes.string, PropTypes.node, PropTypes.func]),
+};
+
+
+export default withStyles(styles)(MuiFormHelper);
diff --git a/packages/vulcan-ui-material/components/forms/base-controls/MuiInput.jsx b/packages/vulcan-ui-material/components/forms/base-controls/MuiInput.jsx
new file mode 100644
index 000000000..0f5775c4f
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/base-controls/MuiInput.jsx
@@ -0,0 +1,138 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import createReactClass from 'create-react-class';
+import withStyles from '@material-ui/core/styles/withStyles';
+import ComponentMixin from './mixins/component';
+import MuiFormControl from './MuiFormControl';
+import MuiFormHelper from './MuiFormHelper';
+import Input from '@material-ui/core/Input';
+import StartAdornment, { hideStartAdornment, fixUrl } from './StartAdornment';
+import EndAdornment from './EndAdornment';
+
+
+export const styles = theme => ({
+ inputRoot: {
+ '& .clear-enabled': { opacity: 0 },
+ '&:hover .clear-enabled': { opacity: 0.54 },
+ },
+ inputFocused: {
+ '& .clear-enabled': { opacity: 0.54 }
+ },
+});
+
+
+//noinspection JSUnusedGlobalSymbols
+const MuiInput = createReactClass({
+ element: null,
+
+ mixins: [ComponentMixin],
+
+ displayName: 'MuiInput',
+
+ propTypes: {
+ type: PropTypes.oneOf([
+ 'color',
+ 'date',
+ 'datetime',
+ 'datetime-local',
+ 'email',
+ 'hidden',
+ 'month',
+ 'number',
+ 'password',
+ 'range',
+ 'search',
+ 'tel',
+ 'text',
+ 'time',
+ 'url',
+ 'week'
+ ]),
+ errors: PropTypes.array,
+ placeholder: PropTypes.string,
+ formatValue: PropTypes.func,
+ scrubValue: PropTypes.func,
+ hideClear: PropTypes.bool,
+ },
+
+ getDefaultProps: function () {
+ return {
+ type: 'text',
+ };
+ },
+
+ handleChange: function (event) {
+ let value = event.target.value;
+ if (this.props.scrubValue) {
+ value = this.props.scrubValue(value);
+ }
+ this.changeValue(value);
+ },
+
+ changeValue: function (value) {
+ this.props.onChange(this.props.name, value);
+ },
+
+ handleBlur: function (event) {
+ const { type, value } = this.props;
+
+ if (type === 'url' && !!value && value !== fixUrl(value)) {
+ this.changeValue(fixUrl(value))
+ }
+ },
+
+ render: function () {
+ const startAdornment = hideStartAdornment(this.props) ? null :
+ ;
+ const endAdornment =
+ ;
+
+ let element = this.renderElement(startAdornment, endAdornment);
+
+ if (this.props.layout === 'elementOnly' || this.props.type === 'hidden') {
+ return element;
+ }
+
+ return (
+
+ {element}
+
+
+ );
+ },
+
+ renderElement: function (startAdornment, endAdornment) {
+ const { classes, disabled, autoFocus, formatValue } = this.props;
+ const value = formatValue ? formatValue(this.props.value) : this.props.value;
+ const options = this.props.options || {};
+
+ return (
+ (this.element = c)}
+ {...this.cleanProps(this.props)}
+ id={this.getId()}
+ value={value}
+ onChange={this.handleChange}
+ onBlur={this.handleBlur}
+ disabled={disabled}
+ rows={options.rows || this.props.rows}
+ autoFocus={options.autoFocus || autoFocus}
+ startAdornment={startAdornment}
+ endAdornment={endAdornment}
+ placeholder={this.props.placeholder}
+ classes={{ root: classes.inputRoot, focused: classes.inputFocused }}
+ />
+ );
+ },
+
+
+});
+
+
+export default withStyles(styles)(MuiInput);
diff --git a/packages/vulcan-ui-material/components/forms/base-controls/MuiRadioGroup.jsx b/packages/vulcan-ui-material/components/forms/base-controls/MuiRadioGroup.jsx
new file mode 100644
index 000000000..d111dd111
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/base-controls/MuiRadioGroup.jsx
@@ -0,0 +1,162 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import createReactClass from 'create-react-class';
+import ComponentMixin from './mixins/component';
+import withStyles from '@material-ui/core/styles/withStyles';
+import MuiFormControl from './MuiFormControl';
+import MuiFormHelper from './MuiFormHelper';
+import FormControlLabel from '@material-ui/core/FormControlLabel';
+import Radio from '@material-ui/core/Radio';
+import RadioGroup from '@material-ui/core/RadioGroup';
+import classNames from 'classnames';
+
+
+const styles = theme => ({
+ group: {
+ marginTop: '8px',
+ },
+ inline: {
+ flexDirection: 'row',
+ '& > label': {
+ marginRight: theme.spacing.unit * 5,
+ },
+ },
+ twoColumn: {
+ display: 'block',
+ [theme.breakpoints.down('md')]: {
+ '& > label': {
+ marginRight: theme.spacing.unit * 5,
+ },
+ },
+ [theme.breakpoints.up('md')]: {
+ '& > label': {
+ width: '49%',
+ },
+ },
+ },
+ threeColumn: {
+ display: 'block',
+ [theme.breakpoints.down('xs')]: {
+ '& > label': {
+ marginRight: theme.spacing.unit * 5,
+ },
+ },
+ [theme.breakpoints.up('xs')]: {
+ '& > label': {
+ width: '49%',
+ },
+ },
+ [theme.breakpoints.up('md')]: {
+ '& > label': {
+ width: '32%',
+ },
+ },
+ },
+ radio: {
+ width: '32px',
+ height: '32px',
+ marginLeft: '8px',
+ },
+ line: {
+ marginBottom: '12px',
+ },
+});
+
+
+const MuiRadioGroup = createReactClass({
+
+ mixins: [ComponentMixin],
+
+ propTypes: {
+ name: PropTypes.string.isRequired,
+ type: PropTypes.oneOf(['inline', 'stacked']),
+ options: PropTypes.array.isRequired
+ },
+
+ getInitialState: function () {
+ if (this.props.refFunction) {
+ this.props.refFunction(this);
+ }
+ },
+
+ getDefaultProps: function () {
+ return {
+ type: 'stacked',
+ label: '',
+ help: null,
+ classes: PropTypes.object.isRequired,
+ };
+ },
+
+ changeRadio: function (event) {
+ const value = event.target.value;
+ //this.setValue(value);
+ this.props.onChange(this.props.name, value);
+ },
+
+ validate: function () {
+ if (this.props.onBlur) {
+ this.props.onBlur();
+ }
+ return true;
+ },
+
+ renderElement: function () {
+ const controls = this.props.options.map((radio, key) => {
+ let checked = (this.props.value === radio.value);
+ let disabled = radio.disabled || this.props.disabled;
+
+ return (
+ this['element-' + key] = c}
+ checked={checked}
+ disabled={disabled}
+ />}
+ className={this.props.classes.line}
+ label={radio.label}
+ />
+ );
+ });
+
+ const maxLength = this.props.options.reduce((max, option) =>
+ option.label.length > max ? option.label.length : max, 0);
+
+ let columnClass = maxLength < 18 ? 'threeColumn' : maxLength < 30 ? 'twoColumn' : '';
+ if (this.props.type === 'inline') columnClass = 'inline';
+
+ return (
+
+ {controls}
+
+ );
+ },
+
+ render: function () {
+
+ if (this.props.layout === 'elementOnly') {
+ return (
+ {this.renderElement()}
+ );
+ }
+
+ return (
+
+ {this.renderElement()}
+
+
+ );
+ }
+});
+
+
+export default withStyles(styles)(MuiRadioGroup);
diff --git a/packages/vulcan-ui-material/components/forms/base-controls/MuiSelect.jsx b/packages/vulcan-ui-material/components/forms/base-controls/MuiSelect.jsx
new file mode 100644
index 000000000..6c50f1bbd
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/base-controls/MuiSelect.jsx
@@ -0,0 +1,210 @@
+import withStyles from '@material-ui/core/styles/withStyles';
+import React from 'react';
+import createReactClass from 'create-react-class';
+import ComponentMixin from './mixins/component';
+import MuiFormControl from './MuiFormControl';
+import MuiFormHelper from './MuiFormHelper';
+import Select from '@material-ui/core/Select';
+import Input from '@material-ui/core/Input';
+import MenuItem from '@material-ui/core/MenuItem';
+import MenuList from '@material-ui/core/MenuList';
+import ListSubheader from '@material-ui/core/ListSubheader';
+import StartAdornment, { hideStartAdornment } from './StartAdornment';
+import EndAdornment from './EndAdornment';
+import _isArray from 'lodash/isArray';
+
+
+export const styles = theme => ({
+
+ inputRoot: {
+ '& .clear-enabled': { opacity: 0 },
+ '&:hover .clear-enabled': { opacity: 0.54 },
+ },
+
+ inputFocused: {
+ '& .clear-enabled': { opacity: 0.54 }
+ },
+
+ menuItem: {
+ paddingTop: 4,
+ paddingBottom: 4,
+ paddingLeft: 9,
+ fontFamily: theme.typography.fontFamily,
+ color: theme.palette.type === 'light' ? 'rgba(0, 0, 0, 0.87)' : theme.palette.common.white,
+ fontSize: theme.typography.pxToRem(16),
+ lineHeight: '1.1875em',
+ },
+
+ input: {
+ paddingLeft: 8,
+ },
+
+});
+
+
+const MuiSelect = createReactClass({
+
+ element: null,
+
+ mixins: [ComponentMixin],
+
+ getInitialState: function () {
+ return {
+ isOpen: false,
+ };
+ },
+
+ handleOpen: function () {
+ // this doesn't work
+ this.setState({
+ isOpen: true,
+ });
+ },
+
+ handleClose: function () {
+ // this doesn't work
+ this.setState({
+ isOpen: false,
+ });
+ },
+
+ handleChange: function (event) {
+ const target = event.target;
+ let value;
+ if (this.props.multiple) {
+ value = [];
+ for (let i = 0; i < target.length; i++) {
+ const option = target.options[i];
+ if (option.selected) {
+ value.push(option.value);
+ }
+ }
+ } else {
+ value = target.value;
+ }
+ this.changeValue(value);
+ },
+
+ changeValue: function (value) {
+ this.props.onChange(this.props.name, value);
+ },
+
+ render: function () {
+ if (this.props.layout === 'elementOnly') {
+ return this.renderElement();
+ }
+
+ return (
+
+ {this.renderElement()}
+
+
+ );
+ },
+
+ renderElement: function () {
+ const renderOption = (item, key) => {
+ //eslint-disable-next-line no-unused-vars
+ const { group, label, ...rest } = item;
+ return this.props.native
+ ?
+
+ :
+ ;
+ };
+
+ const renderGroup = (label, key, nodes) => {
+ return this.props.native
+ ?
+
+ :
+ {label}} key={key}>
+ {nodes}
+ ;
+ };
+
+ const { options, classes } = this.props;
+
+ let groups = options.filter(function (item) {
+ return item.group;
+ }).map(function (item) {
+ return item.group;
+ });
+ // Get the unique items in group.
+ groups = [...new Set(groups)];
+
+ let optionNodes = [];
+
+ if (groups.length === 0) {
+ optionNodes = options.map(function (item, index) {
+ return renderOption(item, index);
+ });
+ } else {
+ // For items without groups.
+ const itemsWithoutGroup = options.filter(function (item) {
+ return !item.group;
+ });
+
+ itemsWithoutGroup.forEach(function (item, index) {
+ optionNodes.push(renderOption(item, 'no-group-' + index));
+ });
+
+ groups.forEach(function (group, groupIndex) {
+
+ const groupItems = options.filter(function (item) {
+ return item.group === group;
+ });
+
+ const groupOptionNodes = groupItems.map(function (item, index) {
+ return renderOption(item, groupIndex + '-' + index);
+ });
+
+ optionNodes.push(renderGroup(group, groupIndex, groupOptionNodes));
+ });
+ }
+
+ let value = this.props.value;
+ if (!this.props.multiple && _isArray(value)) {
+ value = value.length ? value[0] : '';
+ }
+
+ const startAdornment = hideStartAdornment(this.props) ? null :
+ ;
+ const endAdornment =
+ ;
+
+ return (
+
+ );
+ }
+});
+
+
+export default withStyles(styles)(MuiSelect);
diff --git a/packages/vulcan-ui-material/components/forms/base-controls/MuiSuggest.jsx b/packages/vulcan-ui-material/components/forms/base-controls/MuiSuggest.jsx
new file mode 100644
index 000000000..287491b60
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/base-controls/MuiSuggest.jsx
@@ -0,0 +1,437 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import createReactClass from 'create-react-class';
+import ComponentMixin from './mixins/component';
+import withStyles from '@material-ui/core/styles/withStyles';
+import Input from '@material-ui/core/Input';
+import Autosuggest from 'react-autosuggest';
+import Paper from '@material-ui/core/Paper';
+import MenuItem from '@material-ui/core/MenuItem';
+import match from 'autosuggest-highlight/match';
+import parse from 'autosuggest-highlight/parse';
+import { registerComponent } from 'meteor/vulcan:core';
+import StartAdornment, { hideStartAdornment } from './StartAdornment';
+import EndAdornment from './EndAdornment';
+import MuiFormControl from './MuiFormControl';
+import MuiFormHelper from './MuiFormHelper';
+import _isEqual from 'lodash/isEqual';
+import classNames from 'classnames';
+import IsolatedScroll from 'react-isolated-scroll';
+
+
+const maxSuggestions = 100;
+
+
+/*{
+ container: 'react-autosuggest__container',
+ containerOpen: 'react-autosuggest__container--open',
+ input: 'react-autosuggest__input',
+ inputOpen: 'react-autosuggest__input--open',
+ inputFocused: 'react-autosuggest__input--focused',
+ suggestionsContainer: 'react-autosuggest__suggestions-container',
+ suggestionsContainerOpen: 'react-autosuggest__suggestions-container--open',
+ suggestionsList: 'react-autosuggest__suggestions-list',
+ suggestion: 'react-autosuggest__suggestion',
+ suggestionFirst: 'react-autosuggest__suggestion--first',
+ suggestionHighlighted: 'react-autosuggest__suggestion--highlighted',
+ sectionContainer: 'react-autosuggest__section-container',
+ sectionContainerFirst: 'react-autosuggest__section-container--first',
+ sectionTitle: 'react-autosuggest__section-title'
+}*/
+const styles = theme => ({
+ container: {
+ flexGrow: 1,
+ position: 'relative',
+ },
+ textField: {
+ width: '100%',
+ 'label + div > &': {
+ marginTop: theme.spacing.unit * 2,
+ },
+ },
+ input: {
+ outline: 0,
+ font: 'inherit',
+ color: 'currentColor',
+ width: '100%',
+ border: '0',
+ margin: '0',
+ padding: '7px 0',
+ display: 'block',
+ boxSizing: 'content-box',
+ background: 'none',
+ verticalAlign: 'middle',
+ '&::-webkit-search-decoration, &::-webkit-search-cancel-button, &::after, &:after':
+ { display: 'none' },
+ '&::-webkit-search-results, &::-webkit-search-results-decoration':
+ { display: 'none' },
+ },
+ readOnly: {
+ cursor: 'pointer',
+ },
+ suggestionsContainer: {
+ display: 'none',
+ position: 'absolute',
+ left: 0,
+ right: 0,
+ zIndex: theme.zIndex.modal,
+ marginBottom: theme.spacing.unit * 3,
+ maxHeight: 48 * 8,
+ },
+ suggestionsContainerOpen: {
+ display: 'flex',
+ },
+ scroller: {
+ flexGrow: 1,
+ overflowY: 'auto',
+ },
+ suggestion: {
+ display: 'block',
+ },
+ suggestionIcon: {
+ marginRight: theme.spacing.unit * 2,
+ },
+ selected: {
+ backgroundColor: theme.palette.secondary.light,
+ },
+ suggestionsList: {
+ margin: 0,
+ padding: 0,
+ listStyleType: 'none',
+ },
+ inputRoot: {
+ '& .clear-enabled': { opacity: 0 },
+ '&:hover .clear-enabled': { opacity: 0.54 },
+ },
+ inputFocused: {
+ '& .clear-enabled': { opacity: 0.54 }
+ },
+});
+
+
+const MuiSuggest = createReactClass({
+
+ inputElement: null,
+
+ mixins: [ComponentMixin],
+
+ propTypes: {
+ options: PropTypes.arrayOf(PropTypes.shape({
+ label: PropTypes.string,
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ formatted: PropTypes.node,
+ iconComponent: PropTypes.node,
+ onClick: PropTypes.func,
+ })),
+ classes: PropTypes.object.isRequired,
+ limitToList: PropTypes.bool,
+ disableText: PropTypes.bool,
+ showAllOptions: PropTypes.bool,
+ className: PropTypes.string,
+ autoComplete: PropTypes.string,
+ autoFocus: PropTypes.bool,
+ },
+
+ getDefaultProps: function () {
+ return {
+ autoComplete: 'off',
+ autoFocus: false,
+ };
+ },
+
+ getOptionFormatted: function (option) {
+ return option.formatted || option.label || option.value || '';
+ },
+
+ getOptionLabel: function (option) {
+ return option.label || option.value || '';
+ },
+
+ getInitialState: function () {
+ if (this.props.refFunction) {
+ this.props.refFunction(this);
+ }
+
+ const selectedOption = this.getSelectedOption();
+ return {
+ inputValue: this.getOptionLabel(selectedOption),
+ selectedOption: selectedOption,
+ suggestions: [],
+ };
+ },
+
+ componentWillReceiveProps: function (nextProps) {
+ if (nextProps.value !== this.state.value ||
+ nextProps.options !== this.props.options) {
+ const selectedOption = this.getSelectedOption(nextProps);
+ this.setState({
+ inputValue: this.getOptionLabel(selectedOption),
+ selectedOption: selectedOption,
+ });
+ }
+ },
+
+ shouldComponentUpdate: function (nextProps, nextState) {
+ return !_isEqual(nextState, this.state) ||
+ nextProps.help !== this.props.help ||
+ nextProps.charsCount !== this.props.charsCount ||
+ !_isEqual(nextProps.errors, this.props.errors) ||
+ nextProps.options !== this.props.options;
+ },
+
+ getSelectedOption: function (props) {
+ props = props || this.props;
+ const selectedOption = props.options.find((opt) => opt.value === props.value);
+ return selectedOption || { label: '', value: null };
+ },
+
+ handleFocus: function (event) {
+ if (!this.inputElement) return;
+
+ this.inputElement.select();
+ },
+
+ handleBlur: function (event, { highlightedSuggestion: suggestion }) {
+ if (suggestion) {
+ this.changeValue(suggestion);
+ } else if (this.props.limitToList) {
+ const selectedOption = this.getSelectedOption();
+ this.setState({
+ inputValue: this.getOptionLabel(selectedOption),
+ });
+ }
+ },
+
+ suggestionSelected: function (event, { suggestion }) {
+ this.changeValue(suggestion);
+ },
+
+ changeValue: function (suggestion) {
+ if (!suggestion) {
+ suggestion = { label: '', value: null };
+ }
+ if (suggestion.onClick) {
+ return;
+ }
+ this.setState({
+ selectedOption: suggestion,
+ inputValue: this.getOptionLabel(suggestion),
+ });
+ this.props.onChange(this.props.name, suggestion.value, this.getOptionLabel(suggestion));
+ },
+
+ handleInputChange: function (event) {
+ const value = event.target.value;
+ this.setState({
+ inputValue: value,
+ });
+ },
+
+ handleSuggestionsFetchRequested: function ({ value, reason }) {
+ this.setState({
+ suggestions: this.getSuggestions(value),
+ });
+ },
+
+ handleSuggestionsClearRequested: function () {
+ this.setState({
+ suggestions: [],
+ });
+ },
+
+ shouldRenderSuggestions: function (value) {
+ return true;
+ },
+
+ render: function () {
+ const value = this.props.value;
+
+ const startAdornment = hideStartAdornment(this.props) ? null :
+ ;
+ const endAdornment =
+ ;
+
+ const element = this.renderElement(startAdornment, endAdornment);
+
+ if (this.props.layout === 'elementOnly') {
+ return element;
+ }
+
+ return (
+
+ {element}
+
+
+ );
+ },
+
+ renderElement: function (startAdornment, endAdornment) {
+ const { classes, autoFocus, disableText, showAllOptions } = this.props;
+
+ return (
+
+ );
+ },
+
+ renderInputComponent: function (inputProps) {
+ const { classes, autoFocus, autoComplete, value, ref, startAdornment, endAdornment, disabled, ...rest } = inputProps;
+
+ return (
+ { ref(c); this.inputElement = c; }}
+ type="text"
+ startAdornment={startAdornment}
+ endAdornment={endAdornment}
+ disabled={disabled}
+ inputProps={{
+ ...rest,
+ }}
+ />
+ );
+ },
+
+ renderSuggestion: function (suggestion, { query, isHighlighted }) {
+ const label = this.getOptionFormatted(suggestion);
+ const matches = match(label, query);
+ const parts = parse(label, matches);
+ const isSelected = suggestion.value === this.props.value;
+ const className = isSelected ? this.props.classes.selected : null;
+
+ return (
+
+ );
+ },
+
+ renderSuggestionsContainer: function ({ containerProps, children }) {
+ const { classes } = this.props;
+
+ return (
+
+ );
+ },
+
+ getSuggestionValue: function (suggestion) {
+ return suggestion.value;
+ },
+
+ getSuggestions: function (value) {
+ const inputValue = value.trim().toLowerCase();
+ const inputLength = inputValue.length;
+ let count = 0;
+ const inputMatchesSelection = value === this.getOptionLabel(this.state.selectedOption);
+
+ return (this.props.disableText || this.props.showAllOptions) && inputMatchesSelection ?
+
+ this.props.options.filter(suggestion => {
+ return true;
+ })
+
+ :
+
+ inputLength === 0
+
+ ?
+
+ this.props.options.filter(suggestion => {
+ count += 1;
+ return count <= maxSuggestions;
+ })
+
+ :
+
+ this.props.options.filter(suggestion => {
+ const label = this.getOptionLabel(suggestion);
+ const keep =
+ count < maxSuggestions && label.toLowerCase().slice(0, inputLength) ===
+ inputValue;
+
+ if (keep) {
+ count += 1;
+ }
+
+ return keep;
+ });
+ },
+
+});
+
+
+export default withStyles(styles)(MuiSuggest);
+registerComponent('MuiSuggest', MuiSuggest, [withStyles, styles]);
diff --git a/packages/vulcan-ui-material/components/forms/base-controls/MuiSwitch.jsx b/packages/vulcan-ui-material/components/forms/base-controls/MuiSwitch.jsx
new file mode 100644
index 000000000..52181f865
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/base-controls/MuiSwitch.jsx
@@ -0,0 +1,71 @@
+import React from 'react';
+import createReactClass from 'create-react-class';
+import ComponentMixin from './mixins/component';
+import FormControlLabel from '@material-ui/core/FormControlLabel';
+import Switch from '@material-ui/core/Switch';
+import MuiFormControl from './MuiFormControl';
+import MuiFormHelper from './MuiFormHelper';
+
+
+const MuiSwitch = createReactClass({
+
+ mixins: [ComponentMixin],
+
+ getDefaultProps: function () {
+ return {
+ label: '',
+ rowLabel: '',
+ value: false
+ };
+ },
+
+ changeValue: function (event) {
+ const target = event.target;
+ const value = target.checked;
+
+ //this.setValue(value);
+ this.props.onChange(this.props.name, value);
+
+ setTimeout(() => {document.activeElement.blur();});
+ },
+
+ render: function () {
+
+ const element = this.renderElement();
+
+ if (this.props.layout === 'elementOnly') {
+ return element;
+ }
+
+ return (
+
+ {element}
+
+
+ );
+ },
+
+ renderElement: function () {
+ return (
+ this.element = c}
+ {...this.cleanSwitchProps(this.cleanProps(this.props))}
+ id={this.getId()}
+ checked={this.props.value === true}
+ onChange={this.changeValue}
+ disabled={this.props.disabled}
+ />
+ }
+ label={this.props.label}
+ />
+ );
+ },
+
+});
+
+
+export default MuiSwitch;
diff --git a/packages/vulcan-ui-material/components/forms/base-controls/StartAdornment.jsx b/packages/vulcan-ui-material/components/forms/base-controls/StartAdornment.jsx
new file mode 100644
index 000000000..58d6a7434
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/base-controls/StartAdornment.jsx
@@ -0,0 +1,54 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { instantiateComponent } from 'meteor/vulcan:core';
+import withStyles from '@material-ui/core/styles/withStyles';
+import InputAdornment from '@material-ui/core/InputAdornment';
+import IconButton from '@material-ui/core/IconButton';
+import OpenInNewIcon from 'mdi-material-ui/OpenInNew';
+import { styles } from './EndAdornment';
+
+
+export const hideStartAdornment = (props) => {
+ return !props.addonBefore && !props.isUrl;
+};
+
+
+export const fixUrl = (url) => {
+ return url.indexOf('http://') === -1 && url.indexOf('https://') ? 'http://' + url : url;
+};
+
+
+const StartAdornment = (props) => {
+ const { classes, value, type, addonBefore } = props;
+
+ if (hideStartAdornment(props)) return null;
+
+ const urlButton = type === 'url' &&
+
+
+ ;
+
+
+ return (
+
+ {instantiateComponent(addonBefore)}
+ {urlButton}
+
+ );
+};
+
+
+StartAdornment.propTypes = {
+ classes: PropTypes.object.isRequired,
+ value: PropTypes.any,
+ type: PropTypes.string,
+ addonBefore: PropTypes.oneOfType([PropTypes.string, PropTypes.node, PropTypes.func]),
+};
+
+
+export default withStyles(styles)(StartAdornment);
diff --git a/packages/vulcan-ui-material/components/forms/base-controls/mixins/component.jsx b/packages/vulcan-ui-material/components/forms/base-controls/mixins/component.jsx
new file mode 100644
index 000000000..7913cbfc5
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/base-controls/mixins/component.jsx
@@ -0,0 +1,139 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'meteor/vulcan:i18n';
+import _omit from 'lodash/omit';
+
+
+export default {
+
+ propTypes: {
+ label: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
+ hideLabel: PropTypes.bool,
+ layout: PropTypes.string,
+ required: PropTypes.bool,
+ errors: PropTypes.arrayOf(PropTypes.object),
+ },
+
+ getFormControlProperties: function () {
+ return {
+ label: this.props.label,
+ hideLabel: this.props.hideLabel,
+ layout: this.props.layout,
+ required: this.props.required,
+ hasErrors: this.hasErrors(),
+ className: this.props.className,
+ inputType: this.props.inputType,
+ };
+ },
+
+ getFormHelperProperties: function () {
+ return {
+ help: this.props.help,
+ errors: this.props.errors,
+ hasErrors: this.hasErrors(),
+ showCharsRemaining: this.props.showCharsRemaining,
+ charsRemaining: this.props.charsRemaining,
+ charsCount: this.props.charsCount,
+ max: this.props.max,
+ };
+ },
+
+ hashString: function (string) {
+ let hash = 0;
+ for (let i = 0; i < string.length; i++) {
+ hash = (((hash << 5) - hash) + string.charCodeAt(i)) & 0xFFFFFFFF;
+ }
+ return hash;
+ },
+
+ /**
+ * The ID is used as an attribute on the form control, and is used to allow
+ * associating the label element with the form control.
+ *
+ * If we don't explicitly pass an `id` prop, we generate one based on the
+ * `name`, `label` and `itemIndex` (for nested forms) properties.
+ */
+ getId: function () {
+ if (this.props.id) {
+ return this.props.id;
+ }
+ const label = (typeof this.props.label === 'undefined' ? '' : this.props.label);
+ const itemIndex = (typeof this.props.itemIndex=== 'undefined' ? '' : this.props.itemIndex);
+ return [
+ 'frc',
+ this.props.name.split('[').join('_').replace(']', ''),
+ itemIndex,
+ this.hashString(JSON.stringify(label))
+ ].join('-');
+ },
+
+ hasErrors: function () {
+ return !!(this.props.errors && this.props.errors.length);
+ },
+
+ cleanProps: function (props) {
+ const removedFields = [
+ 'beforeComponent',
+ 'afterComponent',
+ 'addonAfter',
+ 'addonBefore',
+ 'help',
+ 'label',
+ 'hideLabel',
+ 'options',
+ 'layout',
+ 'rowLabel',
+ 'validatePristine',
+ 'validateOnSubmit',
+ 'inputClassName',
+ 'optional',
+ 'throwError',
+ 'currentValues',
+ 'addToDeletedValues',
+ 'deletedValues',
+ 'clearFieldErrors',
+ 'formType',
+ 'inputType',
+ 'showCharsRemaining',
+ 'charsCount',
+ 'charsRemaining',
+ 'handleChange',
+ 'document',
+ 'updateCurrentValues',
+ 'classes',
+ 'errors',
+ 'description',
+ 'clearField',
+ 'regEx',
+ 'mustComplete',
+ 'renderComponent',
+ 'formInput',
+ 'className',
+ 'formatValue',
+ 'scrubValue',
+ 'custom',
+ 'hideClear',
+ 'inputProperties',
+ 'currentUser',
+ 'nestedSchema',
+ 'parentFieldName',
+ 'itemIndex',
+ 'formComponents',
+ 'autoValue',
+ 'minCount',
+ 'maxCount'
+ ];
+
+ return _omit(props, removedFields);
+ },
+
+ cleanSwitchProps: function (props) {
+ const removedFields = [
+ 'value',
+ 'error',
+ ];
+
+ return _omit(props, removedFields);
+ },
+
+};
diff --git a/packages/vulcan-ui-material/components/forms/controls/Checkbox.jsx b/packages/vulcan-ui-material/components/forms/controls/Checkbox.jsx
new file mode 100644
index 000000000..4f016d1ad
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/controls/Checkbox.jsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import MuiSwitch from '../base-controls/MuiSwitch';
+import { registerComponent } from 'meteor/vulcan:core';
+
+
+const CheckboxComponent = ({ refFunction, ...properties }) =>
+ ;
+
+
+registerComponent('FormComponentCheckbox', CheckboxComponent);
diff --git a/packages/vulcan-ui-material/components/forms/controls/CheckboxGroup.jsx b/packages/vulcan-ui-material/components/forms/controls/CheckboxGroup.jsx
new file mode 100644
index 000000000..226693354
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/controls/CheckboxGroup.jsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import MuiCheckboxGroup from '../base-controls/MuiCheckboxGroup';
+import { registerComponent } from 'meteor/vulcan:core';
+
+
+const CheckboxGroupComponent = ({ refFunction, ...properties }) =>
+ ;
+
+
+registerComponent('FormComponentCheckboxGroup', CheckboxGroupComponent);
diff --git a/packages/vulcan-ui-material/components/forms/controls/CountrySelect.jsx b/packages/vulcan-ui-material/components/forms/controls/CountrySelect.jsx
new file mode 100644
index 000000000..1c4cf14be
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/controls/CountrySelect.jsx
@@ -0,0 +1,11 @@
+import React from 'react';
+import MuiSuggest from '../base-controls/MuiSuggest';
+import { registerComponent } from 'meteor/vulcan:core';
+import { countries } from './countries';
+
+
+const CountrySelect = ({ refFunction, ...properties }) =>
+ ;
+
+
+registerComponent('CountrySelect', CountrySelect);
diff --git a/packages/vulcan-ui-material/components/forms/controls/Date.jsx b/packages/vulcan-ui-material/components/forms/controls/Date.jsx
new file mode 100644
index 000000000..190e08ced
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/controls/Date.jsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import MuiInput from '../base-controls/MuiInput';
+import { registerComponent } from 'meteor/vulcan:core';
+import withStyles from '@material-ui/core/styles/withStyles';
+
+
+export const styles = theme => ({
+
+ '@global': {
+ 'input[type=date]::-ms-clear, input[type=date]::-ms-reveal': {
+ display: 'none',
+ width: 0,
+ height: 0,
+ },
+ 'input[type=date]::-webkit-search-cancel-button': {
+ display: 'none',
+ '-webkit-appearance': 'none',
+ },
+ 'input[type="date"]::-webkit-clear-button': {
+ display: 'none',
+ '-webkit-appearance': 'none',
+ },
+
+ 'input[type="date"]::-webkit-inner-spin-button,input[type="date"]::-webkit-outer-spin-button': {
+ '-webkit-appearance': 'none',
+ margin: 0,
+ },
+ },
+
+});
+
+
+const DateComponent = ({ refFunction, classes, ...properties }) =>
+ ;
+
+
+registerComponent('FormComponentDate', DateComponent, [withStyles, styles]);
diff --git a/packages/vulcan-ui-material/components/forms/controls/DateRdt.jsx b/packages/vulcan-ui-material/components/forms/controls/DateRdt.jsx
new file mode 100644
index 000000000..f012d6717
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/controls/DateRdt.jsx
@@ -0,0 +1,60 @@
+// Deprecated react-datetime version
+
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import DateTimePicker from 'react-datetime';
+import { registerComponent } from 'meteor/vulcan:core';
+
+class DateComponent extends PureComponent {
+
+ constructor(props) {
+ super(props);
+ this.updateDate = this.updateDate.bind(this);
+ }
+
+ // when the datetime picker has mounted, SmartForm will catch the date value (no formsy mixin in this component)
+ // componentDidMount() {
+ // if (this.props.value) {
+ // this.updateDate(this.props.value);
+ // }
+ // }
+
+ updateDate(date) {
+ this.context.updateCurrentValues({[this.props.path]: date});
+ }
+
+ render() {
+
+ const date = this.props.value ? (typeof this.props.value === 'string' ? new Date(this.props.value) : this.props.value) : null;
+
+ return (
+
+
+
+ this.updateDate(newDate)}
+ inputProps={{name: this.props.name}}
+ />
+
+
+ );
+ }
+}
+
+DateComponent.propTypes = {
+ control: PropTypes.any,
+ datatype: PropTypes.any,
+ group: PropTypes.any,
+ label: PropTypes.string,
+ name: PropTypes.string,
+ value: PropTypes.any,
+};
+
+DateComponent.contextTypes = {
+ updateCurrentValues: PropTypes.func,
+};
+
+export default DateComponent;
diff --git a/packages/vulcan-ui-material/components/forms/controls/DateTime.jsx b/packages/vulcan-ui-material/components/forms/controls/DateTime.jsx
new file mode 100644
index 000000000..5fc870a0a
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/controls/DateTime.jsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import MuiInput from '../base-controls/MuiInput';
+import { registerComponent } from 'meteor/vulcan:core';
+import withStyles from '@material-ui/core/styles/withStyles';
+
+
+export const styles = theme => ({
+
+ '@global': {
+ 'input[type=datetime]::-ms-clear, input[type=datetime]::-ms-reveal': {
+ display: 'none',
+ width: 0,
+ height: 0,
+ },
+ 'input[type=datetime]::-webkit-search-cancel-button': {
+ display: 'none',
+ '-webkit-appearance': 'none',
+ },
+ 'input[type="datetime"]::-webkit-clear-button': {
+ display: 'none',
+ '-webkit-appearance': 'none',
+ },
+
+ 'input[type="datetime"]::-webkit-inner-spin-button,input[type="datetime"]::-webkit-outer-spin-button': {
+ '-webkit-appearance': 'none',
+ margin: 0,
+ },
+ },
+
+});
+
+
+const DateTimeComponent = ({ refFunction, classes, ...properties }) =>
+ ;
+
+
+registerComponent('FormComponentDateTime', DateTimeComponent, [withStyles, styles]);
diff --git a/packages/vulcan-ui-material/components/forms/controls/DateTimeRdt.jsx b/packages/vulcan-ui-material/components/forms/controls/DateTimeRdt.jsx
new file mode 100644
index 000000000..21c308d9e
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/controls/DateTimeRdt.jsx
@@ -0,0 +1,61 @@
+// Deprecated react-datetime version
+
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import DateTimePicker from 'react-datetime';
+import { registerComponent } from 'meteor/vulcan:core';
+
+
+class DateTimeRdt extends PureComponent {
+
+ constructor(props) {
+ super(props);
+ this.updateDate = this.updateDate.bind(this);
+ }
+
+ // when the datetime picker has mounted, SmartForm will catch the date value (no formsy mixin in this component)
+ componentDidMount() {
+ if (this.props.value) {
+ this.updateDate(this.props.value);
+ }
+ }
+
+ updateDate(date) {
+ this.context.updateCurrentValues({[this.props.name]: date});
+ }
+
+ render() {
+
+ const date = this.props.value ? (typeof this.props.value === 'string' ? new Date(this.props.value) : this.props.value) : null;
+
+ return (
+
+
+
+ this.updateDate(newDate._d)}
+ format={"x"}
+ inputProps={{name: this.props.name}}
+ />
+
+
+ );
+ }
+}
+
+DateTimeRdt.propTypes = {
+ control: PropTypes.any,
+ datatype: PropTypes.any,
+ group: PropTypes.any,
+ label: PropTypes.string,
+ name: PropTypes.string,
+ value: PropTypes.any,
+};
+
+DateTimeRdt.contextTypes = {
+ updateCurrentValues: PropTypes.func,
+};
+
+export default DateTimeRdt;
diff --git a/packages/vulcan-ui-material/components/forms/controls/Default.jsx b/packages/vulcan-ui-material/components/forms/controls/Default.jsx
new file mode 100644
index 000000000..e35f64abd
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/controls/Default.jsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import MuiInput from '../base-controls/MuiInput';
+import { registerComponent } from 'meteor/vulcan:core';
+
+
+const Default = ({ refFunction, ...properties }) =>
+ ;
+
+
+registerComponent('FormComponentDefault', Default);
diff --git a/packages/vulcan-ui-material/components/forms/controls/Email.jsx b/packages/vulcan-ui-material/components/forms/controls/Email.jsx
new file mode 100644
index 000000000..f4688361f
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/controls/Email.jsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import MuiInput from '../base-controls/MuiInput';
+import { registerComponent } from 'meteor/vulcan:core';
+
+
+const EmailComponent = ({ refFunction, ...properties }) =>
+ ;
+
+
+registerComponent('FormComponentEmail', EmailComponent);
diff --git a/packages/vulcan-ui-material/components/forms/controls/Number.jsx b/packages/vulcan-ui-material/components/forms/controls/Number.jsx
new file mode 100644
index 000000000..03136d0c3
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/controls/Number.jsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import MuiInput from '../base-controls/MuiInput';
+import { registerComponent } from 'meteor/vulcan:core';
+
+
+const NumberComponent = ({ refFunction, ...properties }) =>
+ ;
+
+
+registerComponent('FormComponentNumber', NumberComponent);
diff --git a/packages/vulcan-ui-material/components/forms/controls/PostalCode.jsx b/packages/vulcan-ui-material/components/forms/controls/PostalCode.jsx
new file mode 100644
index 000000000..810433e6c
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/controls/PostalCode.jsx
@@ -0,0 +1,15 @@
+import React from 'react';
+import MuiInput from '../base-controls/MuiInput';
+import { registerComponent } from 'meteor/vulcan:core';
+import { getCountryInfo } from './RegionSelect';
+
+
+const PostalCode = ({ classes, refFunction, ...properties }) => {
+ const currentCountryInfo = getCountryInfo(properties);
+ const postalLabel = currentCountryInfo ? currentCountryInfo.postalLabel : 'Postal code';
+
+ return ;
+};
+
+
+registerComponent('PostalCode', PostalCode);
diff --git a/packages/vulcan-ui-material/components/forms/controls/RadioGroup.jsx b/packages/vulcan-ui-material/components/forms/controls/RadioGroup.jsx
new file mode 100644
index 000000000..63d0052e9
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/controls/RadioGroup.jsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import MuiRadioGroup from '../base-controls/MuiRadioGroup';
+import { registerComponent } from 'meteor/vulcan:core';
+
+
+const RadioGroupComponent = ({ refFunction, ...properties }) =>
+ ;
+
+
+registerComponent('FormComponentRadioGroup', RadioGroupComponent);
diff --git a/packages/vulcan-ui-material/components/forms/controls/RegionSelect.jsx b/packages/vulcan-ui-material/components/forms/controls/RegionSelect.jsx
new file mode 100644
index 000000000..a790bb351
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/controls/RegionSelect.jsx
@@ -0,0 +1,31 @@
+import React from 'react';
+import MuiSuggest from '../base-controls/MuiSuggest';
+import MuiInput from '../base-controls/MuiInput';
+import { registerComponent } from 'meteor/vulcan:core';
+import { countryInfo } from './countries';
+import _get from 'lodash/get';
+
+
+export const getCountryInfo = function (formComponentProps) {
+ const addressPath = formComponentProps.path;
+ const countryParts = addressPath.split('.');
+ countryParts[countryParts.length-1] = 'country';
+ const country = _get(formComponentProps.document, countryParts);
+ return country && countryInfo[country];
+};
+
+
+const RegionSelect = ({ classes, refFunction, ...properties }) => {
+ const currentCountryInfo = getCountryInfo(properties);
+ const options = currentCountryInfo ? currentCountryInfo.regions : null;
+ const regionLabel = currentCountryInfo ? currentCountryInfo.regionLabel : 'Region';
+
+ if (options) {
+ return ;
+ } else {
+ return ;
+ }
+};
+
+
+registerComponent('RegionSelect', RegionSelect);
diff --git a/packages/vulcan-ui-material/components/forms/controls/Select.jsx b/packages/vulcan-ui-material/components/forms/controls/Select.jsx
new file mode 100644
index 000000000..7ff92e343
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/controls/Select.jsx
@@ -0,0 +1,14 @@
+import React from 'react';
+import MuiSelect from '../base-controls/MuiSelect';
+import { registerComponent } from 'meteor/vulcan:core';
+
+
+const SelectComponent = ({ refFunction, ...properties }) => {
+ const noneOption = { label: '', value: '' };
+ properties.options = [noneOption, ...properties.options];
+
+ return ;
+};
+
+
+registerComponent('FormComponentSelect', SelectComponent);
diff --git a/packages/vulcan-ui-material/components/forms/controls/SelectMultiple.jsx b/packages/vulcan-ui-material/components/forms/controls/SelectMultiple.jsx
new file mode 100644
index 000000000..5b3f552ce
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/controls/SelectMultiple.jsx
@@ -0,0 +1,13 @@
+import React from 'react';
+import MuiSelect from '../base-controls/MuiSelect';
+import { registerComponent } from 'meteor/vulcan:core';
+
+
+const SelectMultiple = ({ refFunction, ...properties }) => {
+ properties.multiple = true;
+
+ return ;
+};
+
+
+registerComponent('FormComponentSelectMultiple', SelectMultiple);
diff --git a/packages/vulcan-ui-material/components/forms/controls/Textarea.jsx b/packages/vulcan-ui-material/components/forms/controls/Textarea.jsx
new file mode 100644
index 000000000..988960741
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/controls/Textarea.jsx
@@ -0,0 +1,15 @@
+import React from 'react';
+import MuiInput from '../base-controls/MuiInput';
+import { registerComponent } from 'meteor/vulcan:core';
+
+
+const TextareaComponent = ({ refFunction, ...properties }) =>
+ ;
+
+
+registerComponent('FormComponentTextarea', TextareaComponent);
diff --git a/packages/vulcan-ui-material/components/forms/controls/Time.jsx b/packages/vulcan-ui-material/components/forms/controls/Time.jsx
new file mode 100644
index 000000000..e52bb3cbb
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/controls/Time.jsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import MuiInput from '../base-controls/MuiInput';
+import { registerComponent } from 'meteor/vulcan:core';
+import withStyles from '@material-ui/core/styles/withStyles';
+
+
+export const styles = theme => ({
+
+ '@global': {
+ 'input[type=time]::-ms-clear, input[type=time]::-ms-reveal': {
+ display: 'none',
+ width: 0,
+ height: 0,
+ },
+ 'input[type=time]::-webkit-search-cancel-button': {
+ display: 'none',
+ '-webkit-appearance': 'none',
+ },
+ 'input[type="time"]::-webkit-clear-button': {
+ display: 'none',
+ '-webkit-appearance': 'none',
+ },
+
+ 'input[type="time"]::-webkit-inner-spin-button,input[type="time"]::-webkit-outer-spin-button': {
+ '-webkit-appearance': 'none',
+ margin: 0,
+ },
+ },
+
+});
+
+
+const TimeComponent = ({ refFunction, classes, ...properties }) =>
+ ;
+
+
+registerComponent('FormComponentTime', TimeComponent, [withStyles, styles]);
diff --git a/packages/vulcan-ui-material/components/forms/controls/TimeRdt.jsx b/packages/vulcan-ui-material/components/forms/controls/TimeRdt.jsx
new file mode 100644
index 000000000..cf8334241
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/controls/TimeRdt.jsx
@@ -0,0 +1,73 @@
+// Deprecated react-datetime version
+
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import DateTimePicker from 'react-datetime';
+import { registerComponent } from 'meteor/vulcan:core';
+
+class TimeRdt extends PureComponent {
+
+ constructor(props) {
+ super(props);
+ this.updateDate = this.updateDate.bind(this);
+ }
+
+ // when the datetime picker has mounted, SmartForm will catch the date value (no formsy mixin in this component)
+ // componentDidMount() {
+ // if (this.props.value) {
+ // this.context.updateCurrentValues({[this.props.path]: this.props.value});
+ // }
+ // }
+
+ updateDate(mDate) {
+ // if this is a properly formatted moment date, update time
+ if (typeof mDate === 'object') {
+ this.context.updateCurrentValues({[this.props.path]: mDate.format('HH:mm')});
+ }
+ }
+
+ render() {
+
+ const date = new Date();
+
+ // transform time string into date object to work inside datetimepicker
+ const time = this.props.value;
+ if (time) {
+ date.setHours(parseInt(time.substr(0,2)), parseInt(time.substr(3,5)));
+ } else {
+ date.setHours(0,0);
+ }
+
+ return (
+
+
+
+ this.updateDate(newDate)}
+ inputProps={{name: this.props.name}}
+ />
+
+
+ );
+ }
+}
+
+TimeRdt.propTypes = {
+ control: PropTypes.any,
+ datatype: PropTypes.any,
+ group: PropTypes.any,
+ label: PropTypes.string,
+ name: PropTypes.string,
+ value: PropTypes.any,
+};
+
+TimeRdt.contextTypes = {
+ updateCurrentValues: PropTypes.func,
+};
+
+export default TimeRdt;
diff --git a/packages/vulcan-ui-material/components/forms/controls/Url.jsx b/packages/vulcan-ui-material/components/forms/controls/Url.jsx
new file mode 100644
index 000000000..d5d9ebf13
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/controls/Url.jsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import MuiInput from '../base-controls/MuiInput';
+import { registerComponent } from 'meteor/vulcan:core';
+
+
+const UrlComponent = ({ refFunction, ...properties }) =>
+ ;
+
+
+registerComponent('FormComponentUrl', UrlComponent);
diff --git a/packages/vulcan-ui-material/components/forms/controls/countries.js b/packages/vulcan-ui-material/components/forms/controls/countries.js
new file mode 100644
index 000000000..a2d84b1e7
--- /dev/null
+++ b/packages/vulcan-ui-material/components/forms/controls/countries.js
@@ -0,0 +1,402 @@
+export const countries = [
+ { value: 'AF', label: 'Afghanistan' },
+ { value: 'AX', label: 'Åland Islands' },
+ { value: 'AL', label: 'Albania' },
+ { value: 'DZ', label: 'Algeria' },
+ { value: 'AS', label: 'American Samoa' },
+ { value: 'AD', label: 'Andorra' },
+ { value: 'AO', label: 'Angola' },
+ { value: 'AI', label: 'Anguilla' },
+ { value: 'AQ', label: 'Antarctica' },
+ { value: 'AG', label: 'Antigua and Barbuda' },
+ { value: 'AR', label: 'Argentina' },
+ { value: 'AM', label: 'Armenia' },
+ { value: 'AW', label: 'Aruba' },
+ { value: 'AU', label: 'Australia' },
+ { value: 'AT', label: 'Austria' },
+ { value: 'AZ', label: 'Azerbaijan' },
+ { value: 'BS', label: 'Bahamas' },
+ { value: 'BH', label: 'Bahrain' },
+ { value: 'BD', label: 'Bangladesh' },
+ { value: 'BB', label: 'Barbados' },
+ { value: 'BY', label: 'Belarus' },
+ { value: 'BE', label: 'Belgium' },
+ { value: 'BZ', label: 'Belize' },
+ { value: 'BJ', label: 'Benin' },
+ { value: 'BM', label: 'Bermuda' },
+ { value: 'BT', label: 'Bhutan' },
+ { value: 'BO', label: 'Bolivia' },
+ { value: 'BQ', label: 'Bonaire, Sint Eustatius, Saba' },
+ { value: 'BA', label: 'Bosnia and Herzegovina' },
+ { value: 'BW', label: 'Botswana' },
+ { value: 'BV', label: 'Bouvet Island' },
+ { value: 'BR', label: 'Brazil' },
+ { value: 'IO', label: 'British Indian Ocean Territory' },
+ { value: 'BN', label: 'Brunei Darussalam' },
+ { value: 'BG', label: 'Bulgaria' },
+ { value: 'BF', label: 'Burkina Faso' },
+ { value: 'BI', label: 'Burundi' },
+ { value: 'CV', label: 'Cabo Verde' },
+ { value: 'KH', label: 'Cambodia' },
+ { value: 'CM', label: 'Cameroon' },
+ { value: 'CA', label: 'Canada' },
+ { value: 'KY', label: 'Cayman Islands' },
+ { value: 'CF', label: 'Central African Republic' },
+ { value: 'TD', label: 'Chad' },
+ { value: 'CL', label: 'Chile' },
+ { value: 'CN', label: 'China' },
+ { value: 'CX', label: 'Christmas Island' },
+ { value: 'CC', label: 'Cocos (Keeling) Islands' },
+ { value: 'CO', label: 'Colombia' },
+ { value: 'KM', label: 'Comoros' },
+ { value: 'CG', label: 'Congo' },
+ { value: 'CD', label: 'Congo (Democratic Republic of the)' },
+ { value: 'CK', label: 'Cook Islands' },
+ { value: 'CR', label: 'Costa Rica' },
+ { value: 'CI', label: 'Côte d’Ivoire' },
+ { value: 'HR', label: 'Croatia' },
+ { value: 'CU', label: 'Cuba' },
+ { value: 'CW', label: 'Curaçao' },
+ { value: 'CY', label: 'Cyprus' },
+ { value: 'CZ', label: 'Czechia' },
+ { value: 'DK', label: 'Denmark' },
+ { value: 'DJ', label: 'Djibouti' },
+ { value: 'DM', label: 'Dominica' },
+ { value: 'DO', label: 'Dominican Republic' },
+ { value: 'EC', label: 'Ecuador' },
+ { value: 'EG', label: 'Egypt' },
+ { value: 'SV', label: 'El Salvador' },
+ { value: 'GQ', label: 'Equatorial Guinea' },
+ { value: 'ER', label: 'Eritrea' },
+ { value: 'EE', label: 'Estonia' },
+ { value: 'ET', label: 'Ethiopia' },
+ { value: 'FK', label: 'Falkland Islands' },
+ { value: 'FO', label: 'Faroe Islands' },
+ { value: 'FJ', label: 'Fiji' },
+ { value: 'FI', label: 'Finland' },
+ { value: 'FR', label: 'France' },
+ { value: 'GF', label: 'French Guiana' },
+ { value: 'PF', label: 'French Polynesia' },
+ { value: 'TF', label: 'French Southern Territories' },
+ { value: 'GA', label: 'Gabon' },
+ { value: 'GM', label: 'Gambia' },
+ { value: 'GE', label: 'Georgia' },
+ { value: 'DE', label: 'Germany' },
+ { value: 'GH', label: 'Ghana' },
+ { value: 'GI', label: 'Gibraltar' },
+ { value: 'GR', label: 'Greece' },
+ { value: 'GL', label: 'Greenland' },
+ { value: 'GD', label: 'Grenada' },
+ { value: 'GP', label: 'Guadeloupe' },
+ { value: 'GU', label: 'Guam' },
+ { value: 'GT', label: 'Guatemala' },
+ { value: 'GG', label: 'Guernsey' },
+ { value: 'GN', label: 'Guinea' },
+ { value: 'GW', label: 'Guinea-Bissau' },
+ { value: 'GY', label: 'Guyana' },
+ { value: 'HT', label: 'Haiti' },
+ { value: 'HM', label: 'Heard Island, Mcdonald Islands' },
+ { value: 'VA', label: 'Vatican City State' },
+ { value: 'HN', label: 'Honduras' },
+ { value: 'HK', label: 'Hong Kong' },
+ { value: 'HU', label: 'Hungary' },
+ { value: 'IS', label: 'Iceland' },
+ { value: 'IN', label: 'India' },
+ { value: 'ID', label: 'Indonesia' },
+ { value: 'IR', label: 'Iran' },
+ { value: 'IQ', label: 'Iraq' },
+ { value: 'IE', label: 'Ireland' },
+ { value: 'IM', label: 'Isle of Man' },
+ { value: 'IL', label: 'Israel' },
+ { value: 'IT', label: 'Italy' },
+ { value: 'JM', label: 'Jamaica' },
+ { value: 'JP', label: 'Japan' },
+ { value: 'JE', label: 'Jersey' },
+ { value: 'JO', label: 'Jordan' },
+ { value: 'KZ', label: 'Kazakhstan' },
+ { value: 'KE', label: 'Kenya' },
+ { value: 'KI', label: 'Kiribati' },
+ { value: 'KW', label: 'Kuwait' },
+ { value: 'KG', label: 'Kyrgyzstan' },
+ { value: 'LA', label: 'Lao' },
+ { value: 'LV', label: 'Latvia' },
+ { value: 'LB', label: 'Lebanon' },
+ { value: 'LS', label: 'Lesotho' },
+ { value: 'LR', label: 'Liberia' },
+ { value: 'LY', label: 'Libya' },
+ { value: 'LI', label: 'Liechtenstein' },
+ { value: 'LT', label: 'Lithuania' },
+ { value: 'LU', label: 'Luxembourg' },
+ { value: 'MO', label: 'Macao' },
+ { value: 'MK', label: 'Macedonia' },
+ { value: 'MG', label: 'Madagascar' },
+ { value: 'MW', label: 'Malawi' },
+ { value: 'MY', label: 'Malaysia' },
+ { value: 'MV', label: 'Maldives' },
+ { value: 'ML', label: 'Mali' },
+ { value: 'MT', label: 'Malta' },
+ { value: 'MH', label: 'Marshall Islands' },
+ { value: 'MQ', label: 'Martinique' },
+ { value: 'MR', label: 'Mauritania' },
+ { value: 'MU', label: 'Mauritius' },
+ { value: 'YT', label: 'Mayotte' },
+ { value: 'MX', label: 'Mexico' },
+ { value: 'FM', label: 'Micronesia' },
+ { value: 'MD', label: 'Moldova' },
+ { value: 'MC', label: 'Monaco' },
+ { value: 'MN', label: 'Mongolia' },
+ { value: 'ME', label: 'Montenegro' },
+ { value: 'MS', label: 'Montserrat' },
+ { value: 'MA', label: 'Morocco' },
+ { value: 'MZ', label: 'Mozambique' },
+ { value: 'MM', label: 'Myanmar' },
+ { value: 'NA', label: 'Namibia' },
+ { value: 'NR', label: 'Nauru' },
+ { value: 'NP', label: 'Nepal' },
+ { value: 'NL', label: 'Netherlands' },
+ { value: 'NC', label: 'New Caledonia' },
+ { value: 'NZ', label: 'New Zealand' },
+ { value: 'NI', label: 'Nicaragua' },
+ { value: 'NE', label: 'Niger' },
+ { value: 'NG', label: 'Nigeria' },
+ { value: 'NU', label: 'Niue' },
+ { value: 'NF', label: 'Norfolk Island' },
+ { value: 'KP', label: 'North Korea' },
+ { value: 'MP', label: 'Northern Mariana Islands' },
+ { value: 'NO', label: 'Norway' },
+ { value: 'OM', label: 'Oman' },
+ { value: 'PK', label: 'Pakistan' },
+ { value: 'PW', label: 'Palau' },
+ { value: 'PS', label: 'Palestine' },
+ { value: 'PA', label: 'Panama' },
+ { value: 'PG', label: 'Papua New Guinea' },
+ { value: 'PY', label: 'Paraguay' },
+ { value: 'PE', label: 'Peru' },
+ { value: 'PH', label: 'Philippines' },
+ { value: 'PN', label: 'Pitcairn' },
+ { value: 'PL', label: 'Poland' },
+ { value: 'PT', label: 'Portugal' },
+ { value: 'PR', label: 'Puerto Rico' },
+ { value: 'QA', label: 'Qatar' },
+ { value: 'RE', label: 'Réunion' },
+ { value: 'RO', label: 'Romania' },
+ { value: 'RU', label: 'Russian Federation' },
+ { value: 'RW', label: 'Rwanda' },
+ { value: 'BL', label: 'Saint Barthélemy' },
+ { value: 'SH', label: 'Saint Helena, Ascension, Tristan Da Cunha' },
+ { value: 'KN', label: 'Saint Kitts and Nevis' },
+ { value: 'LC', label: 'Saint Lucia' },
+ { value: 'MF', label: 'Saint Martin (French Portion)' },
+ { value: 'PM', label: 'Saint Pierre and Miquelon' },
+ { value: 'VC', label: 'Saint Vincent and the Grenadines' },
+ { value: 'WS', label: 'Samoa' },
+ { value: 'SM', label: 'San Marino' },
+ { value: 'ST', label: 'Sao Tome and Principe' },
+ { value: 'SA', label: 'Saudi Arabia' },
+ { value: 'SN', label: 'Senegal' },
+ { value: 'RS', label: 'Serbia' },
+ { value: 'SC', label: 'Seychelles' },
+ { value: 'SL', label: 'Sierra Leone' },
+ { value: 'SG', label: 'Singapore' },
+ { value: 'SX', label: 'Sint Maarten (Dutch part)' },
+ { value: 'SK', label: 'Slovakia' },
+ { value: 'SI', label: 'Slovenia' },
+ { value: 'SB', label: 'Solomon Islands' },
+ { value: 'SO', label: 'Somalia' },
+ { value: 'ZA', label: 'South Africa' },
+ { value: 'GS', label: 'South Georgia, South Sandwich Islands' },
+ { value: 'KR', label: 'South Korea' },
+ { value: 'SS', label: 'South Sudan' },
+ { value: 'ES', label: 'Spain' },
+ { value: 'LK', label: 'Sri Lanka' },
+ { value: 'SD', label: 'Sudan' },
+ { value: 'SR', label: 'Suriname' },
+ { value: 'SJ', label: 'Svalbard and Jan Mayen' },
+ { value: 'SZ', label: 'Swaziland' },
+ { value: 'SE', label: 'Sweden' },
+ { value: 'CH', label: 'Switzerland' },
+ { value: 'SY', label: 'Syria' },
+ { value: 'TW', label: 'Taiwan' },
+ { value: 'TJ', label: 'Tajikistan' },
+ { value: 'TZ', label: 'Tanzania' },
+ { value: 'TH', label: 'Thailand' },
+ { value: 'TL', label: 'Timor-Leste' },
+ { value: 'TG', label: 'Togo' },
+ { value: 'TK', label: 'Tokelau' },
+ { value: 'TO', label: 'Tonga' },
+ { value: 'TT', label: 'Trinidad and Tobago' },
+ { value: 'TN', label: 'Tunisia' },
+ { value: 'TR', label: 'Turkey' },
+ { value: 'TM', label: 'Turkmenistan' },
+ { value: 'TC', label: 'Turks and Caicos Islands' },
+ { value: 'TV', label: 'Tuvalu' },
+ { value: 'UG', label: 'Uganda' },
+ { value: 'UA', label: 'Ukraine' },
+ { value: 'AE', label: 'United Arab Emirates' },
+ { value: 'GB', label: 'United Kingdom' },
+ { value: 'US', label: 'United States' },
+ { value: 'UM', label: 'United States Minor Outlying Islands' },
+ { value: 'UY', label: 'Uruguay' },
+ { value: 'UZ', label: 'Uzbekistan' },
+ { value: 'VU', label: 'Vanuatu' },
+ { value: 'VE', label: 'Venezuela' },
+ { value: 'VN', label: 'Viet Nam' },
+ { value: 'VG', label: 'Virgin Islands, British' },
+ { value: 'VI', label: 'Virgin Islands, U.S.' },
+ { value: 'WF', label: 'Wallis and Futuna' },
+ { value: 'EH', label: 'Western Sahara' },
+ { value: 'YE', label: 'Yemen' },
+ { value: 'ZM', label: 'Zambia' },
+ { value: 'ZW', label: 'Zimbabwe' },
+];
+
+
+export const countryInfo = {
+ US: {
+ regionLabel: 'State',
+ postalLabel: 'Zip code',
+ regions: [
+ { value: 'AL', label: 'Alabama' },
+ { value: 'AK', label: 'Alaska' },
+ { value: 'AZ', label: 'Arizona' },
+ { value: 'AR', label: 'Arkansas' },
+ { value: 'CA', label: 'California' },
+ { value: 'CO', label: 'Colorado' },
+ { value: 'CT', label: 'Connecticut' },
+ { value: 'DE', label: 'Delaware' },
+ { value: 'FL', label: 'Florida' },
+ { value: 'GA', label: 'Georgia' },
+ { value: 'HI', label: 'Hawaii' },
+ { value: 'ID', label: 'Idaho' },
+ { value: 'IL', label: 'Illinois' },
+ { value: 'IN', label: 'Indiana' },
+ { value: 'IA', label: 'Iowa' },
+ { value: 'KS', label: 'Kansas' },
+ { value: 'KY', label: 'Kentucky' },
+ { value: 'LA', label: 'Louisiana' },
+ { value: 'ME', label: 'Maine' },
+ { value: 'MD', label: 'Maryland' },
+ { value: 'MA', label: 'Massachusetts' },
+ { value: 'MI', label: 'Michigan' },
+ { value: 'MN', label: 'Minnesota' },
+ { value: 'MS', label: 'Mississippi' },
+ { value: 'MO', label: 'Missouri' },
+ { value: 'MT', label: 'Montana' },
+ { value: 'NE', label: 'Nebraska' },
+ { value: 'NV', label: 'Nevada' },
+ { value: 'NH', label: 'New Hampshire' },
+ { value: 'NJ', label: 'New Jersey' },
+ { value: 'NM', label: 'New Mexico' },
+ { value: 'NY', label: 'New York' },
+ { value: 'NC', label: 'North Carolina' },
+ { value: 'ND', label: 'North Dakota' },
+ { value: 'OH', label: 'Ohio' },
+ { value: 'OK', label: 'Oklahoma' },
+ { value: 'OR', label: 'Oregon' },
+ { value: 'PA', label: 'Pennsylvania' },
+ { value: 'RI', label: 'Rhode Island' },
+ { value: 'SC', label: 'South Carolina' },
+ { value: 'SD', label: 'South Dakota' },
+ { value: 'TN', label: 'Tennessee' },
+ { value: 'TX', label: 'Texas' },
+ { value: 'UT', label: 'Utah' },
+ { value: 'VT', label: 'Vermont' },
+ { value: 'VA', label: 'Virginia' },
+ { value: 'WA', label: 'Washington' },
+ { value: 'WV', label: 'West Virginia' },
+ { value: 'WI', label: 'Wisconsin' },
+ { value: 'WY', label: 'Wyoming' },
+ ]
+ },
+ CA: {
+ regionLabel: 'Province',
+ postalLabel: 'Postal code',
+ regions: [
+ { value: 'AB', label: 'Alberta' },
+ { value: 'BC', label: 'British Columbia' },
+ { value: 'MB', label: 'Manitoba' },
+ { value: 'NB', label: 'New Brunswick' },
+ { value: 'NL', label: 'Newfoundland and Labrador' },
+ { value: 'NS', label: 'Nova Scotia' },
+ { value: 'NT', label: 'Northwest Territories' },
+ { value: 'NU', label: 'Nunavut' },
+ { value: 'ON', label: 'Ontario' },
+ { value: 'PE', label: 'Prince Edward Island' },
+ { value: 'QC', label: 'Quebec' },
+ { value: 'SK', label: 'Saskatchewan' },
+ { value: 'YT', label: 'Yukon' },
+ ],
+ },
+ AU: {
+ regionLabel: 'State',
+ postalLabel: 'Postcode',
+ regions: [
+ { value: 'ACT', label: 'Australian Capital Territory' },
+ { value: 'NSW', label: 'New South Wales' },
+ { value: 'NT', label: 'Northern Territory' },
+ { value: 'QLD', label: 'Queensland' },
+ { value: 'SA', label: 'South Australia' },
+ { value: 'TAS', label: 'Tasmania' },
+ { value: 'VIC', label: 'Victoria' },
+ { value: 'WA', label: 'Western Australia' },
+ ],
+ },
+ UK: {
+ regionLabel: 'County',
+ postalLabel: 'Postcode',
+ },
+};
+
+
+export const getCountryLabel = (countryValue) => {
+ const country = countries.find(country => country.value === countryValue);
+ return country ? country.label : '';
+};
+
+
+export const getCountryContinent = (countryValue) => {
+ const country = countries.find(country => country.value === countryValue);
+ return country ? country.continent : '';
+};
+
+
+export const getRegionLabel = (countryValue, regionValue) => {
+ if (!countryInfo[countryValue] || !countryInfo[countryValue].regions) {
+ return regionValue;
+ }
+
+ const regions = countryInfo[countryValue].regions;
+
+ let region = regions.find(nextRegion => nextRegion.value === regionValue);
+
+ if (region) {
+ return region.label;
+ } else {
+ return regionValue;
+ }
+};
+
+
+// Given a region value or label, returns the region value (QC or Quebec => QC)
+// or false if the regionValue is invalid
+export const validateRegion = (countryValue, regionValue) => {
+ if (!countryInfo[countryValue] || !countryInfo[countryValue].regions) {
+ return regionValue;
+ }
+
+ const regions = countryInfo[countryValue].regions;
+
+ let region = regions.find(nextRegion => nextRegion.value === regionValue);
+
+ if (region) {
+ return regionValue;
+ }
+
+ region = regions.find(nextRegion => nextRegion.label === regionValue);
+
+ if (region) {
+ return region.value;
+ } else {
+ return false;
+ }
+};
diff --git a/packages/vulcan-ui-material/components/index.js b/packages/vulcan-ui-material/components/index.js
new file mode 100644
index 000000000..e619680ee
--- /dev/null
+++ b/packages/vulcan-ui-material/components/index.js
@@ -0,0 +1,58 @@
+import './accounts/AccountsButton';
+import './accounts/AccountsButtons';
+import './accounts/AccountsField';
+import './accounts/AccountsFields';
+import './accounts/AccountsForm';
+import './accounts/AccountsPasswordOrService';
+import './accounts/AccountsSocialButtons';
+
+import './bonus/LoadMore';
+import './bonus/SearchInput';
+import './bonus/TooltipIntl';
+import './bonus/TooltipIconButton';
+
+import './core/Card';
+import './core/Datatable';
+import './core/EditButton';
+import './core/Loading';
+import './core/ModalTrigger';
+import './core/NewButton';
+
+import './forms/FormComponentInner';
+import './forms/FormErrors';
+import './forms/FormGroup';
+import './forms/FormGroupNone';
+import './forms/FormGroupWithLine';
+import './forms/FormNested';
+import './forms/FormNestedDivider';
+import './forms/FormNestedFoot';
+import './forms/FormNestedHead';
+import './forms/FormSubmit';
+import './forms/controls/Checkbox';
+import './forms/controls/CheckboxGroup';
+import './forms/controls/CountrySelect';
+import './forms/controls/Date';
+import './forms/controls/DateRdt';
+import './forms/controls/DateTime';
+import './forms/controls/DateTimeRdt';
+import './forms/controls/Default';
+import './forms/controls/Email';
+import './forms/controls/Number';
+import './forms/controls/PostalCode';
+import './forms/controls/RadioGroup';
+import './forms/controls/RegionSelect';
+import './forms/controls/Select';
+import './forms/controls/Textarea';
+import './forms/controls/Time';
+import './forms/controls/TimeRdt';
+import './forms/controls/Url';
+
+import './theme/ThemeStyles';
+
+import './ui/Button';
+import './ui/Alert';
+
+import './upload/UploadImage';
+import './upload/UploadInner';
+
+export * from './forms/controls/countries';
diff --git a/packages/vulcan-ui-material/components/theme/JssCleanup.jsx b/packages/vulcan-ui-material/components/theme/JssCleanup.jsx
new file mode 100644
index 000000000..4d344294b
--- /dev/null
+++ b/packages/vulcan-ui-material/components/theme/JssCleanup.jsx
@@ -0,0 +1,23 @@
+import React, { PureComponent } from 'react';
+
+
+class JssCleanup extends PureComponent {
+
+
+ // Remove the server-side injected CSS.
+ componentDidMount() {
+ if (!document || !document.getElementById) return;
+
+ const jssStyles = document.getElementById('jss-server-side');
+ if (jssStyles && jssStyles.parentNode) {
+// jssStyles.parentNode.removeChild(jssStyles);
+ }
+ }
+
+ render() {
+ return this.props.children;
+ }
+}
+
+
+export default JssCleanup;
diff --git a/packages/vulcan-ui-material/components/theme/ThemeStyles.jsx b/packages/vulcan-ui-material/components/theme/ThemeStyles.jsx
new file mode 100644
index 000000000..7441fc01b
--- /dev/null
+++ b/packages/vulcan-ui-material/components/theme/ThemeStyles.jsx
@@ -0,0 +1,232 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Components, registerComponent } from 'meteor/vulcan:core';
+import withTheme from '@material-ui/core/styles/withTheme';
+import withStyles from '@material-ui/core/styles/withStyles';
+import Typography from '@material-ui/core/Typography';
+import Grid from '@material-ui/core/Grid';
+import Paper from '@material-ui/core/Paper';
+import { getContrastRatio } from '@material-ui/core/styles/colorManipulator';
+import classNames from 'classnames';
+
+
+const describeTypography = (theme, className) => {
+ const typography = className ? theme.typography[className] : theme.typography;
+ const fontFamily = typography.fontFamily.split(',')[0];
+ return `${fontFamily} ${typography.fontWeight} ${typography.fontSize}px`;
+};
+
+
+const mainPalette = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900];
+const altPalette = ['A100', 'A200', 'A400', 'A700'];
+
+
+function getColorBlock(theme, classes, colorName, colorValue, colorTitle) {
+ const bgColor = theme.palette[colorName][colorValue];
+
+ let fgColor = theme.palette.common.black;
+ if (getContrastRatio(bgColor, fgColor) < 7) {
+ fgColor = theme.palette.common.white;
+ }
+
+ let blockTitle;
+ if (colorTitle) {
+ blockTitle = {colorName}
;
+ }
+
+ let rowStyle = {
+ backgroundColor: bgColor,
+ color: fgColor,
+ listStyle: 'none',
+ padding: 15,
+ };
+
+ if (colorValue.toString().indexOf('A1') === 0) {
+ rowStyle = {
+ ...rowStyle,
+ marginTop: 4,
+ };
+ }
+
+ return (
+
+ {blockTitle}
+
+ {colorValue}
+ {bgColor.toUpperCase()}
+
+
+ );
+}
+
+function getColorGroup(options) {
+ const { theme, classes, color, showAltPalette } = options;
+ const cssColor = color.replace(' ', '').replace(color.charAt(0), color.charAt(0).toLowerCase());
+ let colorsList = [];
+ colorsList = mainPalette.map(mainValue => getColorBlock(theme, classes, cssColor, mainValue));
+
+ if (showAltPalette) {
+ altPalette.forEach(altValue => {
+ colorsList.push(getColorBlock(theme, classes, cssColor, altValue));
+ });
+ }
+
+ return (
+
+ {getColorBlock(theme, classes, cssColor, 500, true)}
+
+ {colorsList}
+
+ );
+}
+
+
+const styles = theme => ({
+ root: {},
+ paper: {
+ padding: theme.spacing.unit * 3,
+ },
+ name: {
+ marginBottom: 60,
+ },
+ blockSpace: {
+ height: 4,
+ },
+ colorContainer: {
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ },
+ colorGroup: {
+ padding: '16px 0',
+ margin: '0 15px 0 0',
+ flexGrow: 1,
+ [theme.breakpoints.up('sm')]: {
+ flexGrow: 0,
+ },
+ },
+ colorValue: {
+ ...theme.typography.caption,
+ color: 'inherit',
+ },
+});
+
+
+const latin = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur justo quam, ' +
+ 'pellentesque ultrices ex a, aliquet porttitor ante. Donec tellus arcu, viverra ut lorem id, ' +
+ 'ultrices ultricies enim. Donec enim metus, sollicitudin id lobortis id, iaculis ut arcu. ' +
+ 'Maecenas sollicitudin congue nisi. Donec convallis, ipsum ac ultricies dignissim, orci ex ' +
+ 'efficitur lectus, ac lacinia risus nunc at diam. Nam gravida bibendum lectus. Donec ' +
+ 'scelerisque sem nec urna vestibulum vehicula.';
+
+
+const ThemeStyles = ({ theme, classes }) => {
+ return (
+
+
+
+
+ h1: {describeTypography(theme, 'h1')}
+
+
+ h2: {describeTypography(theme, 'h2')}
+
+
+ h3: {describeTypography(theme, 'h3')}
+
+
+ h4: {describeTypography(theme, 'h4')}
+
+
+
+
+
+
+ Headline: {describeTypography(theme, 'h5')}
+
+
+ Title: {describeTypography(theme, 'h6')}
+
+
+ Subtitle1: {describeTypography(theme, 'subtitle1')}
+
+
+ Body 1: {describeTypography(theme, 'body1')} - {latin}
+
+
+ {latin}
+
+
+ Body 2: {describeTypography(theme, 'body2')} - {latin}
+
+
+ {latin}
+
+
+ Caption: {describeTypography(theme, 'caption')}
+
+
+ Base: {describeTypography(theme)} - {latin}
+
+
+ Button - {describeTypography(theme)}
+
+
+
+
+
+ {
+ getColorGroup({
+ theme,
+ classes,
+ color: 'primary',
+ showAltPalette: true,
+ })
+ }
+
+
+
+ {
+ getColorGroup({
+ theme,
+ classes,
+ color: 'secondary',
+ showAltPalette: true,
+ })
+ }
+
+
+
+ {
+ getColorGroup({
+ theme,
+ classes,
+ color: 'error',
+ showAltPalette: true,
+ })
+ }
+
+
+
+ {
+ getColorGroup({
+ theme,
+ classes,
+ color: 'background',
+ showAltPalette: true,
+ })
+ }
+
+
+
+ );
+};
+
+
+ThemeStyles.propTypes = {
+ theme: PropTypes.object.isRequired,
+ classes: PropTypes.object.isRequired,
+};
+
+
+registerComponent('ThemeStyles', ThemeStyles, [withTheme, null], [withStyles, styles]);
diff --git a/packages/vulcan-ui-material/components/ui/Alert.jsx b/packages/vulcan-ui-material/components/ui/Alert.jsx
new file mode 100644
index 000000000..7a1254f5d
--- /dev/null
+++ b/packages/vulcan-ui-material/components/ui/Alert.jsx
@@ -0,0 +1,31 @@
+/**
+ * @Author: Apollinaire Lecocq
+ * @Date: 09-01-19
+ * @Last modified by: apollinaire
+ * @Last modified time: 10-01-19
+ */
+import React from 'react';
+import withStyles from '@material-ui/core/styles/withStyles';
+import { registerComponent } from 'meteor/vulcan:core';
+
+import Card from '@material-ui/core/Card';
+import CardContent from '@material-ui/core/CardContent';
+
+const AlertStyle = theme => ({
+ error: {
+ color: theme.palette.error.main,
+ backgroundColor: theme.palette.error[100],
+ fontFamily: theme.typography.fontFamily,
+ },
+ other: {
+ fontFamily: theme.typography.fontFamily,
+ },
+});
+
+const Alert = ({ children, variant, classes, ...rest }) => (
+
+ {children}
+
+);
+
+registerComponent({ name: 'Alert', component: Alert, hocs: [[withStyles, AlertStyle]] });
diff --git a/packages/vulcan-ui-material/components/ui/Button.jsx b/packages/vulcan-ui-material/components/ui/Button.jsx
new file mode 100644
index 000000000..23ea96d84
--- /dev/null
+++ b/packages/vulcan-ui-material/components/ui/Button.jsx
@@ -0,0 +1,51 @@
+import React from 'react';
+import { Components, registerComponent } from 'meteor/vulcan:core';
+import MuiButton from '@material-ui/core/Button';
+import MuiIconButton from '@material-ui/core/IconButton';
+
+
+const Button = ({ children, color, variant, size, iconButton, ...rest }) => {
+ switch(variant) {
+ case 'success':
+ color = 'primary';
+ variant = null;
+ break;
+ case 'danger':
+ color = 'default';
+ variant = null;
+ break;
+ case 'inverse':
+ color = 'inherit';
+ variant = null;
+ break;
+ }
+
+ switch(size) {
+ case 'xsmall':
+ size = 'small';
+ break;
+ case 'small':
+ size = 'medium';
+ break;
+ case 'large':
+ size = 'large';
+ break;
+ }
+
+ if (iconButton) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
+};
+
+
+registerComponent('Button', Button);
diff --git a/packages/vulcan-ui-material/components/upload/UploadImage.jsx b/packages/vulcan-ui-material/components/upload/UploadImage.jsx
new file mode 100755
index 000000000..6904e2fba
--- /dev/null
+++ b/packages/vulcan-ui-material/components/upload/UploadImage.jsx
@@ -0,0 +1,117 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { Components, registerComponent } from 'meteor/vulcan:lib';
+import { FormattedMessage } from 'meteor/vulcan:i18n';
+import withStyles from '@material-ui/core/styles/withStyles';
+import IconButton from '@material-ui/core/IconButton';
+import DeleteIcon from 'mdi-material-ui/Delete';
+import classNames from 'classnames';
+
+/**
+ * Used by UploadInner to display a single image
+ */
+const styles = theme => ({
+
+ uploadImage: {
+ textAlign: 'center',
+ marginBottom: theme.spacing.unit * -1,
+ marginLeft: theme.spacing.unit * 0.5,
+ marginRight: theme.spacing.unit * 0.5,
+ },
+
+ uploadImageContents: {
+ position: 'relative',
+ },
+
+ uploadImageImg: {
+ display: 'block',
+ maxWidth: 150,
+ maxHeight: 150,
+ },
+
+ uploadLoading: {
+ position: 'absolute',
+ top: 0,
+ bottom: 0,
+ left: 0,
+ right: 0,
+ background: 'rgba(255,255,255,0.8)',
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ span: {
+ display: 'block',
+ fontSize: '1.5rem',
+ }
+ },
+
+ deleteButton: {
+ }
+
+});
+
+
+class UploadImage extends PureComponent {
+
+ constructor (props) {
+ super(props);
+ this.handleClear = this.handleClear.bind(this);
+ }
+
+ handleClear (event) {
+ event.preventDefault();
+ this.props.clearImage(this.props.index);
+ }
+
+ // Get the URL of an image or the first in an array of images
+ getImageUrl (imageOrImageArray) {
+ // if image is actually an array of formats, use first format
+ const image = Array.isArray(imageOrImageArray) ? imageOrImageArray[0] : imageOrImageArray;
+
+ // if image is an object, return secure_url; else return image itself
+ return typeof image === 'string' ? image : image.secure_url;
+ }
+
+ render () {
+ const { loading, error, image, style, classes } = this.props;
+
+ return (
+
+
+
+
+
})
+ {
+ loading &&
+
+
+
+
+ }
+
+
+
+
+
+
+
+ );
+ }
+}
+
+
+UploadImage.propTypes = {
+ clearImage: PropTypes.func.isRequired,
+ index: PropTypes.number.isRequired,
+ image: PropTypes.oneOfType([PropTypes.string, PropTypes.array, PropTypes.object]),
+ loading: PropTypes.bool,
+ error: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
+ style: PropTypes.object,
+ classes: PropTypes.object.isRequired,
+};
+
+
+UploadImage.displayName = 'UploadImageMui';
+
+
+registerComponent('UploadImage', UploadImage, [withStyles, styles]);
diff --git a/packages/vulcan-ui-material/components/upload/UploadInner.jsx b/packages/vulcan-ui-material/components/upload/UploadInner.jsx
new file mode 100755
index 000000000..b3c4ab007
--- /dev/null
+++ b/packages/vulcan-ui-material/components/upload/UploadInner.jsx
@@ -0,0 +1,181 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Components, registerComponent, getComponent } from 'meteor/vulcan:lib';
+import Dropzone from 'react-dropzone';
+import withStyles from '@material-ui/core/styles/withStyles';
+import { FormattedMessage } from 'meteor/vulcan:i18n';
+import FormControl from '@material-ui/core/FormControl';
+import FormLabel from '@material-ui/core/FormLabel';
+import FormHelperText from '@material-ui/core/FormHelperText';
+
+
+/*
+
+Material UI GUI for Cloudinary Image Upload component
+
+*/
+
+
+const styles = theme => ({
+
+ root: {},
+
+ label: {},
+
+ uploadField: {
+ marginTop: theme.spacing.unit,
+ },
+
+ dropzoneBase: {
+ borderWidth: 3,
+ borderStyle: 'dashed',
+ borderColor: theme.palette.background[900],
+ backgroundColor: theme.palette.background[100],
+ color: theme.palette.common.lightBlack,
+ padding: '30px 60px',
+ transition: 'all 0.5s',
+ cursor: 'pointer',
+ position: 'relative',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ '&[aria-disabled="false"]:hover': {
+ color: theme.palette.common.midBlack,
+ borderColor: theme.palette.background['A200'],
+ }
+ },
+
+ dropzoneActive: {
+ borderStyle: 'solid',
+ borderColor: theme.palette.status.info,
+ },
+
+ dropzoneReject: {
+ borderStyle: 'solid',
+ borderColor: theme.palette.status.danger,
+ },
+
+ uploadState: {},
+
+ uploadImages: {
+ border: `1px solid ${theme.palette.background[500]}`,
+ backgroundColor: theme.palette.background[100],
+ display: 'flex',
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ justifyContent: 'center',
+ paddingTop: theme.spacing.unit,
+ paddingRight: theme.spacing.unit * 0.5,
+ paddingBottom: theme.spacing.unit,
+ paddingLeft: theme.spacing.unit * 0.5,
+ },
+});
+
+
+const UploadInner = (props) => {
+ const {
+ uploading,
+ images,
+ disabled,
+ maxCount,
+ label,
+ help,
+ options,
+ enableMultiple,
+ onDrop,
+ isDeleted,
+ clearImage,
+ classes
+ } = props;
+
+ const UploadImage = getComponent(options.uploadImageComponentName || 'UploadImage');
+
+ return (
+
+
+
+ {label}
+
+ {
+ help &&
+
+ {help}
+ }
+
+ {
+ (disabled && !enableMultiple)
+ ?
+ null
+ :
+
+
+
+
+ {uploading && (
+
+
+
+
+
+ )}
+
+ }
+
+ {!!images.length && (
+
+
+ {images.map(
+ (image, index) =>
+ !isDeleted(index) && (
+
+ )
+ )}
+
+
+ )}
+
+
+
+ );
+};
+
+
+UploadInner.propTypes = {
+ uploading: PropTypes.bool,
+ images: PropTypes.array.isRequired,
+ disabled: PropTypes.bool,
+ maxCount: PropTypes.number.isRequired,
+ label: PropTypes.string,
+ help: PropTypes.string,
+ options: PropTypes.object.isRequired,
+ enableMultiple: PropTypes.bool,
+ onDrop: PropTypes.func.isRequired,
+ isDeleted: PropTypes.func.isRequired,
+ clearImage: PropTypes.func.isRequired,
+ classes: PropTypes.object.isRequired,
+};
+
+
+UploadInner.displayName = 'UploadInnerMui';
+
+
+registerComponent('UploadInner', UploadInner, [withStyles, styles]);
diff --git a/packages/vulcan-ui-material/en_US.js b/packages/vulcan-ui-material/en_US.js
new file mode 100644
index 000000000..57fb41cec
--- /dev/null
+++ b/packages/vulcan-ui-material/en_US.js
@@ -0,0 +1,12 @@
+import { addStrings } from 'meteor/vulcan:core';
+
+
+addStrings('en', {
+
+ "search.search": "Search",
+ "search.clear": "Clear search",
+ "load_more.load_more": "Load more",
+ "load_more.loaded_count": "Loaded {count} of {totalCount}",
+ "load_more.loaded_all": "{totalCount, plural, =0 {No items} one {One item} other {# items}}",
+
+});
diff --git a/packages/vulcan-ui-material/example/Header.jsx b/packages/vulcan-ui-material/example/Header.jsx
new file mode 100644
index 000000000..7ae557a4f
--- /dev/null
+++ b/packages/vulcan-ui-material/example/Header.jsx
@@ -0,0 +1,98 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import AppBar from '@material-ui/core/AppBar';
+import Toolbar from '@material-ui/core/Toolbar';
+import IconButton from '@material-ui/core/IconButton';
+import Typography from '@material-ui/core/Typography';
+import MenuIcon from 'mdi-material-ui/Menu';
+import ChevronLeftIcon from 'mdi-material-ui/ChevronLeft';
+import withStyles from '@material-ui/core/styles/withStyles';
+import { getSetting, Components, registerComponent } from 'meteor/vulcan:core';
+import classNames from 'classnames';
+
+
+const drawerWidth = 240;
+const topBarHeight = 100;
+
+
+const styles = theme => ({
+ appBar: {
+ position: 'absolute',
+ transition: theme.transitions.create(['margin', 'width'], {
+ easing: theme.transitions.easing.sharp,
+ duration: theme.transitions.duration.leavingScreen,
+ }),
+ },
+ appBarShift: {
+ marginLeft: drawerWidth,
+ width: `calc(100% - ${drawerWidth}px)`,
+ transition: theme.transitions.create(['margin', 'width'], {
+ easing: theme.transitions.easing.easeOut,
+ duration: theme.transitions.duration.enteringScreen,
+ }),
+ },
+ toolbar: {
+ height: `${topBarHeight}px`,
+ minHeight: `${topBarHeight}px`,
+ },
+ headerMid: {
+ flexGrow: 1,
+ display: 'flex',
+ alignItems: 'center',
+ '& h1': {
+ margin: '0 24px 0 0',
+ fontSize: '18px',
+ lineHeight: 1,
+ }
+ },
+ menuButton: {
+ marginRight: theme.spacing.unit * 3,
+ },
+});
+
+
+const Header = (props, context) => {
+ const classes = props.classes;
+ const isSideNavOpen = props.isSideNavOpen;
+ const toggleSideNav = props.toggleSideNav;
+
+ const siteTitle = getSetting('title', 'My App');
+
+ return (
+
+
+
+ toggleSideNav()}
+ className={classNames(classes.menuButton)}
+ color="inherit"
+ >
+ {isSideNavOpen ? : }
+
+
+
+
+
+ {siteTitle}
+
+
+
+
+
+
+ );
+};
+
+
+Header.propTypes = {
+ classes: PropTypes.object.isRequired,
+ isSideNavOpen: PropTypes.bool,
+ toggleSideNav: PropTypes.func,
+};
+
+
+Header.displayName = 'Header';
+
+
+registerComponent('Header', Header, [withStyles, styles]);
diff --git a/packages/vulcan-ui-material/example/Layout.jsx b/packages/vulcan-ui-material/example/Layout.jsx
new file mode 100644
index 000000000..3d6d52885
--- /dev/null
+++ b/packages/vulcan-ui-material/example/Layout.jsx
@@ -0,0 +1,136 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Drawer from '@material-ui/core/Drawer';
+import AppBar from '@material-ui/core/AppBar';
+import Toolbar from '@material-ui/core/Toolbar';
+import { Components, replaceComponent, Utils } from 'meteor/vulcan:core';
+import withStyles from '@material-ui/core/styles/withStyles';
+import classNames from 'classnames';
+
+
+const drawerWidth = 240;
+const topBarHeight = 100;
+
+
+const styles = theme => {
+ const contentPadding = theme.spacing.unit * 8;
+
+ return {
+ '@global': {
+ html: {
+ background: theme.palette.background.default,
+ WebkitFontSmoothing: 'antialiased',
+ MozOsxFontSmoothing: 'grayscale',
+ overflow: 'hidden',
+ },
+ body: {
+ margin: 0,
+ },
+ },
+ root: {
+ width: '100%',
+ zIndex: 1,
+ overflow: 'hidden',
+ },
+ appFrame: {
+ position: 'relative',
+ display: 'flex',
+ height: '100vh',
+ alignItems: 'stretch',
+ },
+ drawerPaper: {
+ position: 'relative',
+ width: drawerWidth,
+ backgroundColor: theme.palette.background[200],
+ },
+ drawerHeader: {
+ height: `${topBarHeight}px !important`,
+ minHeight: `${topBarHeight}px !important`,
+ position: 'relative !important',
+ },
+ content: {
+ padding: contentPadding,
+ width: '100%',
+ marginLeft: -drawerWidth,
+ flexGrow: 1,
+ backgroundColor: theme.palette.background.default,
+ color: theme.palette.text.primary,
+ transition: theme.transitions.create('margin', {
+ easing: theme.transitions.easing.sharp,
+ duration: theme.transitions.duration.leavingScreen,
+ }),
+ height: `calc(100% - ${topBarHeight}px - ${contentPadding * 2}px)`,
+ marginTop: topBarHeight,
+ overflowY: 'scroll',
+ },
+ mainShift: {
+ marginLeft: 0,
+ transition: theme.transitions.create('margin', {
+ easing: theme.transitions.easing.easeOut,
+ duration: theme.transitions.duration.enteringScreen,
+ }),
+ },
+ };
+};
+
+
+class Layout extends React.Component {
+ state = {
+ isOpen: { sideNav: true }
+ };
+
+ toggle = (item, openOrClose) => {
+ const newState = { isOpen: {} };
+ newState.isOpen[item] = typeof openOrClose === 'string' ?
+ openOrClose === 'open' :
+ !this.state.isOpen[item];
+ this.setState(newState);
+ };
+
+ render = () => {
+ const routeName = Utils.slugify(this.props.currentRoute.name);
+ const classes = this.props.classes;
+ const isOpen = this.state.isOpen;
+
+ return (
+
+
+
+
+ this.toggle('sideNav', openOrClose)} />
+
+
+
+
+
+
+
+
+
+
+ {this.props.children}
+
+
+
+
+
+
+ );
+ };
+}
+
+
+Layout.propTypes = {
+ classes: PropTypes.object.isRequired,
+ children: PropTypes.node,
+};
+
+
+Layout.displayName = 'Layout';
+
+
+replaceComponent('Layout', Layout, [withStyles, styles]);
diff --git a/packages/vulcan-ui-material/example/SideNavigation.jsx b/packages/vulcan-ui-material/example/SideNavigation.jsx
new file mode 100644
index 000000000..2d30e417f
--- /dev/null
+++ b/packages/vulcan-ui-material/example/SideNavigation.jsx
@@ -0,0 +1,105 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Components, registerComponent, withCurrentUser } from 'meteor/vulcan:core';
+import { withRouter } from 'react-router-dom';
+import List from '@material-ui/core/List';
+import ListItem from '@material-ui/core/ListItem';
+import ListItemIcon from '@material-ui/core/ListItemIcon';
+import ListItemText from '@material-ui/core/ListItemText';
+import Divider from '@material-ui/core/Divider';
+import Collapse from '@material-ui/core/Collapse';
+import ExpandLessIcon from 'mdi-material-ui/ChevronUp';
+import ExpandMoreIcon from 'mdi-material-ui/ChevronDown';
+import LockIcon from 'mdi-material-ui/Lock';
+import UsersIcon from 'mdi-material-ui/AccountMultiple';
+import ThemeIcon from 'mdi-material-ui/Palette';
+import HomeIcon from 'mdi-material-ui/Home';
+import withStyles from '@material-ui/core/styles/withStyles';
+import Users from 'meteor/vulcan:users';
+
+
+const styles = theme => ({
+ root: {},
+ nested: {
+ paddingLeft: theme.spacing.unit * 4,
+ },
+});
+
+
+class SideNavigation extends React.Component {
+ state = {
+ isOpen: { admin: false }
+ };
+
+ toggle = (item) => {
+ const newState = { isOpen: {} };
+ newState.isOpen[item] = !this.state.isOpen[item];
+ this.setState(newState);
+ };
+
+ render () {
+ const currentUser = this.props.currentUser;
+ const classes = this.props.classes;
+ const isOpen = this.state.isOpen;
+
+ return (
+
+
+
+ {this.props.history.push('/');}}>
+
+
+
+
+
+
+
+ {
+ Users.isAdmin(currentUser) &&
+
+
+
+
+ this.toggle('admin')}>
+
+
+
+
+ {isOpen.admin ? : }
+
+
+ {this.props.history.push('/admin');}}>
+
+
+
+
+
+ {this.props.history.push('/theme');}}>
+
+
+
+
+
+
+
+
+ }
+
+
+ );
+ }
+}
+
+
+SideNavigation.propTypes = {
+ classes: PropTypes.object.isRequired,
+ currentUser: PropTypes.object,
+};
+
+
+SideNavigation.displayName = 'SideNavigation';
+
+
+registerComponent('SideNavigation', SideNavigation, [withStyles, styles], withCurrentUser, withRouter);
diff --git a/packages/vulcan-ui-material/forms.css b/packages/vulcan-ui-material/forms.css
new file mode 100644
index 000000000..219fee574
--- /dev/null
+++ b/packages/vulcan-ui-material/forms.css
@@ -0,0 +1,16 @@
+.form-nested-item {
+ display: flex;
+}
+
+.form-nested-item-inner {
+ flex-grow: 1;
+}
+
+.form-nested-item-remove {
+ padding-top: 8px;
+ margin-left: 8px;
+}
+
+.form-nested-item-remove > button {
+ margin-right: -4px;
+}
diff --git a/packages/vulcan-ui-material/fr_FR.js b/packages/vulcan-ui-material/fr_FR.js
new file mode 100644
index 000000000..2d5c04745
--- /dev/null
+++ b/packages/vulcan-ui-material/fr_FR.js
@@ -0,0 +1,8 @@
+import { addStrings } from 'meteor/vulcan:core';
+
+addStrings('fr', {
+
+ "search.search": "Recherche",
+ "search.clear": "Effacer la recherche",
+
+});
diff --git a/packages/vulcan-ui-material/history.md b/packages/vulcan-ui-material/history.md
new file mode 100644
index 000000000..8613491ff
--- /dev/null
+++ b/packages/vulcan-ui-material/history.md
@@ -0,0 +1,109 @@
+1.12.8_17 / 2019-02-02
+======================
+
+ * TooltipIntl: Changed display from 'inline-block' to 'inherit' for more flexibility
+ * Countries: Added getRegionLabel function
+
+1.12.8_16 / 2019-01-21
+======================
+
+ * Countries: Fixed bug in validateRegion
+
+1.12.8_15 / 2019-01-21
+======================
+
+ * Countries: Fixed bug in validateRegion
+
+1.12.8_14 / 2019-01-20
+======================
+
+ * Countries: Added validateRegion function, which given a region value or label, will return the region value ('NY' or 'New York' => 'NY)
+ * The contents of countries is now exported - this may be refactored out of the core vulcan-material-ui as some point
+
+1.12.8_13 / 2019-01-14
+======================
+
+ * ModalTrigger: Added boolean dialogOverflow prop for use cases like popups that can go beyond the size of the dialog box
+ * MuiSuggest: Fixed bug - The disabled state was not displayed correctly
+ * MuiSuggest: Fixed bug - After selecting a suggestion, clicking on the control did not re-open the suggestions menu
+
+1.12.8_12 / 2019-01-12
+======================
+
+ * Upgraded to Meteor 1.8.0.2
+
+1.12.8_11 / 2018-12-21
+======================
+
+ * SearchInput: Added install autosize-input to readme
+ * Datatable: Fixed sorting delay
+ * Datatable: Added tableHeadCell class
+ * Datatable: Added cellClass column property, which can be a string or a function: column.cellClass({ column, document, currentUser })
+
+1.12.8_10 / 2018-12-09
+======================
+
+ * TooltipIntl: Added icon class
+ * FormGroupWithLine: Moved caret from the right side to next to the title
+ * Changed load_more.loaded_all string
+
+1.12.8_9 / 2018-11-26
+=====================
+
+ * Fixed bug that displayed invalid total count at the bottom of data tables
+
+1.12.8_8 / 2018-11-23
+=====================
+
+ * Improved the functionality of the LoadMore component
+ * The showNoMore property has been deprecated
+ * A showCount property has been added (true by default) that shows a count of loaded and total items
+ * The load more icon or button is displayed even when infiniteScroll is enabled
+
+1.12.8_7 / 2018-11-10
+=====================
+
+ * Fixed bug in Datatable.jsx
+ * Updated ReadMe
+
+1.12.8_6 / 2018-11-06
+=====================
+
+ * Fixed bug in Datatable.jsx
+ * Reduced spacing of form components
+
+1.12.8_5 / 2018-10-31
+=====================
+
+ * Fixed bugs in Datatable pagination
+ * Set Datatable paginate prop to false by default
+
+1.12.8_4 / 2018-10-31
+=====================
+
+ * Removed 'fr_FR.js' from package.js because any french strings loaded activates the french language
+ * Fixed delete button and its tooltips positioning in FormSubmit
+ * Added pagination to Datatable
+
+1.12.8_2 / 2018-10-29
+=====================
+
+ * Fixed localization in "clear search" tooltip
+ * Added name and aria-haspopup properties to the input component to improve compliance and facilitate UAT
+ * Replaced Date, Time and DateTime form controls with native controls as recommended by MUI.
+ The deprecated react-datetime version of the controls are still there as DateRdt, TimeRdt and DateTimeRdt, but they are not registered.
+ * Updated readme
+
+1.12.8_1 / 2018-10-22
+=====================
+
+ * Made form components compatible with new Form.formComponents property
+
+1.12.8 / 2018-10-19
+===================
+
+ * Made improvements to the search box, including keyboard shortcuts (s: focus search; c: clear search)
+ * Added support in TooltipIntl for tooltips in popovers
+ * Added action prop to ModalTrigger that enables a parent component to call openModal and closeModal
+ * Started using MUI tables in Card component
+ * Fixed bugs in MuiSuggest component
diff --git a/packages/vulcan-ui-material/modules/index.js b/packages/vulcan-ui-material/modules/index.js
new file mode 100644
index 000000000..5a65d1ab1
--- /dev/null
+++ b/packages/vulcan-ui-material/modules/index.js
@@ -0,0 +1,4 @@
+export * from './themes';
+export JssCleanup from '../components/theme/JssCleanup';
+import './sampleTheme';
+import './routes';
diff --git a/packages/vulcan-ui-material/modules/routes.js b/packages/vulcan-ui-material/modules/routes.js
new file mode 100755
index 000000000..ee9c6cf06
--- /dev/null
+++ b/packages/vulcan-ui-material/modules/routes.js
@@ -0,0 +1,8 @@
+import { addRoute } from 'meteor/vulcan:core';
+
+
+addRoute({
+ name: 'theme',
+ path: '/theme',
+ componentName: 'ThemeStyles',
+});
diff --git a/packages/vulcan-ui-material/modules/sampleTheme.js b/packages/vulcan-ui-material/modules/sampleTheme.js
new file mode 100644
index 000000000..a382078a7
--- /dev/null
+++ b/packages/vulcan-ui-material/modules/sampleTheme.js
@@ -0,0 +1,76 @@
+import { registerTheme } from './themes';
+import indigo from '@material-ui/core/colors/indigo';
+import blue from '@material-ui/core/colors/blue';
+import red from '@material-ui/core/colors/red';
+
+
+/** @ignore */
+
+/**
+ *
+ * Sample theme to get you out of the gate quickly
+ *
+ * For a complete list of configuration variables see:
+ * https://material-ui.com/customization/themes/
+ *
+ */
+
+
+const theme = {
+
+ palette: {
+ primary: indigo,
+ secondary: blue,
+ error: red,
+ },
+
+ utils: {
+
+ tooltipEnterDelay: 700,
+
+ errorMessage: {
+ textAlign: 'center',
+ backgroundColor: red[500],
+ color: 'white',
+ borderRadius: '4px',
+ fontWeight: 'bold',
+ },
+
+ denseTable: {
+ '& > thead > tr > th, & > tbody > tr > td': {
+ padding: '4px 16px 4px 16px',
+ },
+ '& > thead > tr > th:last-child, & > tbody > tr > td:last-child': {
+ paddingRight: '16px',
+ },
+ },
+
+ flatTable: {
+ '& > thead > tr > th, & > tbody > tr > td': {
+ padding: '4px 16px 4px 16px',
+ whiteSpace: 'nowrap',
+ },
+ '& > thead > tr > th:last-child, & > tbody > tr > td:last-child': {
+ paddingRight: '16px',
+ },
+ },
+
+ denserTable: {
+ '& > thead > tr, & > tbody > tr': {
+ height: '40px',
+ },
+ '& > thead > tr > th, & > tbody > tr > td': {
+ padding: '4px 16px 4px 16px',
+ whiteSpace: 'nowrap',
+ },
+ '& > thead > tr > th:last-child, & > tbody > tr > td:last-child': {
+ paddingRight: '16px',
+ },
+ },
+
+ },
+
+};
+
+
+registerTheme('Sample', theme);
diff --git a/packages/vulcan-ui-material/modules/themes.js b/packages/vulcan-ui-material/modules/themes.js
new file mode 100644
index 000000000..eb9b57744
--- /dev/null
+++ b/packages/vulcan-ui-material/modules/themes.js
@@ -0,0 +1,67 @@
+/** @module vulcan-material-ui */
+
+import createMuiTheme from '@material-ui/core/styles/createMuiTheme';
+import { registerSetting, getSetting } from 'meteor/vulcan:core';
+
+
+registerSetting('muiTheme', 'Sample', 'Material UI theme used by erikdakota:vulcan-material-ui');
+
+
+export const ThemesTable = {}; // storage for info about themes
+
+
+/**
+ * Register a theme with a name
+ *
+ * @param {String} name The name of the theme to register
+ * @param {Object} theme The theme object - see defaultTheme.js
+ *
+ */
+export const registerTheme = (name, theme) => {
+ const themeInfo = {
+ name,
+ theme,
+ };
+
+ ThemesTable[name] = themeInfo;
+};
+
+
+/**
+ * Get a theme registered with registerTheme()
+ *
+ * @param {String} name The name of the theme to get
+ *
+ * @returns {Object} A theme object
+ */
+export const getTheme = (name) => {
+ const themeInfo = ThemesTable[name];
+ if (!themeInfo) return null;
+ themeInfo.theme.typography = { ...themeInfo.theme.typography, useNextVariants: true }
+ return createMuiTheme(themeInfo.theme);
+};
+
+/**
+ * Get the raw theme object registered with registerTheme()
+ *
+ * @param {String} name The name of the theme to get
+ *
+ * @returns {Object} The object passed to registerTheme
+ */
+
+export const getRawTheme = (name) => {
+ const themeInfo = ThemesTable[name];
+ if (!themeInfo) return null;
+ return themeInfo.theme;
+}
+
+/**
+ * Get the theme specified in the 'muiTheme' setting
+ *
+ * @returns {Object}
+ */
+export const getCurrentTheme = () => {
+ const themeName = getSetting('muiTheme', 'Sample');
+ const theme = getTheme(themeName);
+ return theme;
+};
diff --git a/packages/vulcan-ui-material/package.js b/packages/vulcan-ui-material/package.js
new file mode 100644
index 000000000..024850c42
--- /dev/null
+++ b/packages/vulcan-ui-material/package.js
@@ -0,0 +1,26 @@
+Package.describe({
+ name: 'vulcan:ui-material',
+ version: '1.12.17',
+ summary: 'Replacement for Vulcan (http://vulcanjs.org/) components using material-ui',
+ documentation: 'README.md'
+});
+
+
+Package.onUse(function (api) {
+ api.versionsFrom('METEOR@1.6');
+
+ api.use([
+ 'ecmascript',
+ 'vulcan:core@1.12.8',
+ 'vulcan:accounts@1.12.8',
+ 'vulcan:forms@1.12.8',
+ ]);
+
+ api.addFiles([
+ 'accounts.css',
+ 'forms.css',
+ ], ['client', 'server']);
+
+ api.mainModule('client/main.js', 'client');
+ api.mainModule('server/main.js', 'server');
+});
diff --git a/packages/vulcan-ui-material/readme.md b/packages/vulcan-ui-material/readme.md
new file mode 100644
index 000000000..05f71b4b4
--- /dev/null
+++ b/packages/vulcan-ui-material/readme.md
@@ -0,0 +1,208 @@
+# erikdakoda:vulcan-material-ui 1.12.8_13
+
+Replacement for [Vulcan](http://vulcanjs.org/) components using [Material-UI](https://material-ui.com/).
+This version has been tested against Vulcan 1.12.8 and Material-UI 3.1.0.
+
+To give me feedback open an issue on GitHub or you can reach me on the [Vulcan Slack](https://vulcanjs.slack.com)
+channel as erikdakoda.
+
+There are some nice bonus features like a CountrySelect with autocomplete and theming.
+
+All components in vulcan:ui-bootstrap, vulcan:forms and vulcan:accounts have been implemented except for Icon.
+
+## Installation
+
+To add vulcan-material-ui to an existing Vulcan project, enter the following:
+
+``` sh
+meteor add erikdakoda:vulcan-material-ui
+
+meteor npm install --save @material-ui/core
+meteor npm install --save react-jss
+meteor npm install --save mdi-material-ui
+meteor npm install --save react-autosuggest
+meteor npm install --save autosuggest-highlight
+meteor npm install --save react-isolated-scroll
+meteor npm install --save-exact react-keyboard-event-handler@1.3.2
+meteor npm install --save autosize-input
+```
+
+> NOTE: If you want to avoid deprecation warnings added in MUI versions after 3.1.0, you can lock MUI to the currently supported version using `meteor npm install --save @material-ui/core@3.1.0`. Don't for get to remove or update the version number when you update this package in the future.
+
+
+> IMPORTANT: Please note that I have abandoned material-ui-icons in favor of mdi-material-ui because it has a much larger [selection of icons](https://materialdesignicons.com/).
+
+This package no longer depends on `vulcan:ui-boostrap`, so you can remove it.
+
+To activate the example layout copy the three components to your project and import them:
+
+``` javascript
+import './example/Header',
+import './example/Layout',
+import './example/SideNavigation',
+```
+
+## Theming
+
+For an example theme see `modules/sampleTheme.js`. For a complete list of values you can customize,
+see the [MUI Default Theme](https://material-ui-next.com/customization/default-theme/).
+
+Register your theme in the Vulcan environment by giving it a name: `registerTheme('MyTheme', theme);`.
+You can have multiple themes registered and you can specify which one to use in your settings file using the `muiTheme` public setting.
+
+In addition to the Material UI spec, I use a `utils` section in my themes where I place global variables for reusable styles.
+For example the sample theme contains
+
+```
+const theme = {
+
+ . . .
+
+ utils: {
+
+ tooltipEnterDelay: 700,
+
+ errorMessage: {
+ textAlign: 'center',
+ backgroundColor: red[500],
+ color: 'white',
+ borderRadius: '4px',
+ },
+
+ . . .
+
+ // additional utils definitions go here
+
+ },
+
+};
+```
+
+You can use tooltipEnterDelay (or any other variable you define in utils) anywhere you include the withTheme HOC. See `/components/bonus/TooltipIconButton.jsx` for an example.
+
+You can use errorMessage (or any other style fragment you define in utils) anywhere you include the withStyles HOC. See `/components/accounts/Form.jsx` for an example.
+
+## Server Side Rendering (SSR)
+
+Material UI and Vulcan support SSR, but this is a complex beast with pitfalls. Sometimes you will see a warning like this:
+
+`Warning: Prop className did not match. Server: "MuiChip-label-131" Client: "MuiChip-label-130"`
+
+Sometimes the React rendered on the server and the client don't match exactly and this causes a problem with [JSS](https://material-ui-next.com/customization/css-in-js/#jss). This is a complicated issue that has multiple causes and I will be working on solving each of the issues causing this over time.
+
+Your pages should still render correctly, but there may be a blink and redraw when the first page after SSR loads in the browser.
+
+In your own code, make sure that your components will render the same on the server and the client. This means not referring to client-side object such as `document` or `jQuery`. If you have a misbehaving component, try wrapping it with [react-no-ssr](https://github.com/kadirahq/react-no-ssr).
+
+## Form Controls
+
+You can pass a couple of extra options to inputs from the `form` property of your schema:
+
+``` javascript
+ userKey: {
+ type: String,
+ label: 'User key',
+ description: 'The user’s key',
+ optional: true,
+ hidden: function ({ document }) {
+ return !document.platformId || !document.usePlatformApp;
+ },
+ inputProperties: {
+ autoFocus: true, // focus this input when the form loads
+ addonBefore: , // adorn the start of the input
+ addonAfter: , // adorn the end of the input
+ inputClassName: 'halfWidthLeft', // use 'halfWidthLeft' or 'halfWidthRight'
+ // to display two controls side by side
+ hideLabel: true, // hide the label
+ rows: 10, // for textareas you can specify the rows
+ variant: 'switch', // for checkboxgroups you can use either
+ // 'checkbox' (default) or 'switch'
+ inputProps: { step: 'any' } // Attributes applied to the input element, for
+ // ex pass the step attr to a number input
+ },
+ group: platformGroup,
+ canRead: ['members'],
+ canCreate: ['members'],
+ canUpdate: ['members'],
+ },
+```
+
+> Note: `form.getHidden` has been deprecated. Now you can just pass a function to `hidden`.
+
+## Form Groups
+
+You can pass a couple of extra options to form groups as well:
+
+``` javascript
+ const platformGroup: {
+ name: 'shops.platform',
+ order: 4,
+ startComponent: 'ShopsPlatformTitle', // component to put at the top of the form group
+ endComponent: 'ShopsConnectButtons', // component to put at the bottom of the form group
+ },
+```
+
+## DataTable
+
+You can pass the DataTable component an `editComponent` property in addition to or instead of `showEdit`. Here is a simplified example:
+
+``` javascript
+const AgendaJobActions = ({ collection, document }) => {
+ const scheduleAgent = () => {
+ Meteor.call('scheduleAgent', document.agentId);
+ };
+
+ return }
+ onClick={scheduleAgent}/>;
+};
+
+AgendaJobActionsInner.propTypes = {
+ collecion: PropTypes.object.isRequired,
+ document: PropTypes.object.isRequired,
+};
+
+
+```
+
+You can also control the spacing of the table cells using the `dense` property. Valid values are:
+
+| Value | Description |
+| ------- | ------------ |
+| dense | right cell padding of 16px instead of 56px |
+| flat | right cell padding of 16px and nowrap |
+| denser | right cell padding of 16px, nowrap, and row height of 40px instead of 56px |
+
+You can also use other string values, as long as you define a `utils` entry named the same + `Table`, for example `myCustomTable`. Check out the sample theme for examples.
+
+
+## CountrySelect
+
+There is an additional component, an autosuggest-based country select.
+
+``` javascript
+ country: {
+ type: String,
+ label: 'Country',
+ input: 'CountrySelect',
+ canRead: ['guests'],
+ canCreate: ['members'],
+ canUpdate: ['members'],
+ },
+```
+
+Countries are stored as their 2-letter country codes. I have included a helper function for displaying the country name:
+
+``` javascript
+import Typography from '@material-ui/core/Typography';
+import { getCountryLabel } from 'meteor/erikdakoda:vulcan-material-ui';
+
+
+ {getCountryLabel(supplier.country)}
+
+```
+
diff --git a/packages/vulcan-ui-material/server/main.js b/packages/vulcan-ui-material/server/main.js
new file mode 100644
index 000000000..1ed6f2fb0
--- /dev/null
+++ b/packages/vulcan-ui-material/server/main.js
@@ -0,0 +1,3 @@
+export * from '../components/index';
+export * from '../modules/index';
+import './wrapWithMuiTheme';
diff --git a/packages/vulcan-ui-material/server/wrapWithMuiTheme.jsx b/packages/vulcan-ui-material/server/wrapWithMuiTheme.jsx
new file mode 100644
index 000000000..32933c0e5
--- /dev/null
+++ b/packages/vulcan-ui-material/server/wrapWithMuiTheme.jsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import { addCallback } from 'meteor/vulcan:core';
+import JssProvider from 'react-jss/lib/JssProvider';
+import MuiThemeProvider from '@material-ui/core/styles/MuiThemeProvider';
+import createGenerateClassName from '@material-ui/core/styles/createGenerateClassName';
+import { getCurrentTheme } from '../modules/themes';
+import { SheetsRegistry } from 'react-jss/lib/jss';
+import JssCleanup from '../components/theme/JssCleanup';
+
+
+function wrapWithMuiTheme (app, {context }) {
+ const sheetsRegistry = new SheetsRegistry();
+ context.sheetsRegistry = sheetsRegistry;
+
+ const sheetsManager = new Map();
+
+ const theme = getCurrentTheme();
+
+ const generateClassName = createGenerateClassName({ seed: '' });
+
+ return (
+
+
+
+ {app}
+
+
+
+ );
+}
+
+
+function injectJss(sink, { context }) {
+ const sheets = context.sheetsRegistry.toString();
+ sink.appendToHead(
+ ``
+ );
+ return sink;
+}
+
+
+addCallback('router.server.wrapper', wrapWithMuiTheme);
+addCallback('router.server.postRender', injectJss);