├── .babelrc ├── .editorconfig ├── .eslintrc.yaml ├── .gitignore ├── .npmignore ├── .storybook ├── addons.js ├── config.js └── webpack.config.js ├── .travis.yml ├── README.md ├── ROADMAP.md ├── package.json ├── src ├── Address │ └── index.jsx ├── BaseButton │ ├── index.jsx │ └── styled │ │ └── button.jsx ├── BaseInput │ ├── index.jsx │ └── styled │ │ ├── input.js │ │ └── validation-icon.js ├── CheckboxField │ ├── index.jsx │ └── styled │ │ ├── checkbox.js │ │ ├── fieldset.js │ │ ├── indicator.js │ │ ├── inner-label.js │ │ ├── label.js │ │ └── wrapper.js ├── Converse │ ├── formula.js │ └── index.jsx ├── Date │ ├── index.jsx │ ├── labelTranslations.js │ └── styled │ │ ├── day-picker.js │ │ ├── overlay-wrapper.js │ │ ├── overlay.js │ │ ├── validation-icon.js │ │ └── wrapper.js ├── EmailField │ ├── index.jsx │ └── styled │ │ └── email.js ├── FetchForm │ └── index.jsx ├── Fieldset │ ├── index.jsx │ └── styled │ │ ├── fieldset-inner.js │ │ ├── fieldset.js │ │ ├── legend.js │ │ └── wrapper.js ├── FormRow │ └── index.jsx ├── Hidden │ ├── index.jsx │ └── styled │ │ └── hidden.js ├── IBAN │ └── index.jsx ├── Input │ ├── index.jsx │ └── styled │ │ ├── inner-wrapper.js │ │ ├── input.js │ │ ├── prefix.js │ │ ├── suffix.js │ │ ├── text-content.js │ │ ├── validation-icon.js │ │ └── wrapper.js ├── LookUp │ ├── index.jsx │ └── styled │ │ └── validation-message.js ├── Number │ └── index.jsx ├── Observables │ ├── Field.jsx │ ├── Form.js │ └── Forms.js ├── ParagraphField │ └── index.jsx ├── Parser │ ├── index.js │ └── template.js ├── PhoneField │ ├── index.jsx │ └── styled │ │ └── phone.js ├── RadioField │ ├── index.jsx │ └── styled │ │ ├── indicator.js │ │ ├── radio-label.js │ │ ├── radio.js │ │ └── wrapper.js ├── RangeField │ ├── index.jsx │ └── styled │ │ ├── range-value-wrapper.js │ │ ├── range-value.js │ │ ├── range.js │ │ ├── validation-icon.js │ │ └── wrapper.js ├── Relation │ └── index.jsx ├── RuleHint │ ├── index.jsx │ └── styled │ │ └── validation-message.js ├── SelectField │ ├── index.jsx │ └── styled │ │ ├── validation-icon.js │ │ └── wrapper.js ├── SubmitButton │ └── index.jsx ├── TextArea │ ├── index.jsx │ └── styled │ │ ├── text-area.js │ │ └── validation-icon.js ├── Webform │ ├── ThankYouMessage │ │ ├── index.jsx │ │ └── styled │ │ │ └── message.js │ ├── conditionals │ │ ├── __tests__ │ │ │ └── formatCondtionals.test.js │ │ └── index.js │ ├── index.jsx │ ├── rules.js │ └── styled │ │ ├── element.js │ │ ├── form-title.js │ │ ├── list-item.js │ │ ├── list.js │ │ ├── stdcss.0.0.7.js │ │ └── webform.js ├── WebformElement │ ├── index.jsx │ └── styled │ │ ├── form-row.js │ │ ├── label.js │ │ ├── required-marker.js │ │ ├── text-content.js │ │ └── validation-message.js ├── WebformUtils.js ├── Wizard │ ├── WizardProgress │ │ ├── index.jsx │ │ └── styled │ │ │ ├── bar.js │ │ │ ├── step-number.js │ │ │ ├── step-title.js │ │ │ └── step.js │ ├── index.jsx │ └── styled │ │ ├── button-next.js │ │ ├── button-prev.js │ │ └── button-wrapper.js ├── Wrapper │ └── index.jsx ├── index.js └── styles │ └── theme.js ├── stories ├── RemoteForm.jsx └── index.jsx ├── utils ├── postinstall.js └── prepublish.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "env", 5 | { 6 | "targets": { 7 | "browsers": [ 8 | "last 2 versions" 9 | ], 10 | "node": "current", 11 | "uglify": true 12 | } 13 | } 14 | ], 15 | "react", 16 | "stage-1" 17 | ], 18 | "plugins": [ 19 | "transform-decorators-legacy", 20 | [ 21 | "transform-runtime", 22 | { 23 | "polyfill": true 24 | } 25 | ], 26 | ["styled-components", { 27 | "ssr": true, 28 | "displayName": false 29 | }] 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintrc.yaml: -------------------------------------------------------------------------------- 1 | extends: airbnb 2 | rules: 3 | jsx-a11y/no-static-element-interactions: 0 4 | keyword-spacing: 5 | - 2 6 | - 7 | after: false 8 | overrides: 9 | from: 10 | after: true 11 | case: 12 | after: true 13 | return: 14 | after: true 15 | const: 16 | after: true 17 | let: 18 | after: true 19 | import: 20 | after: true 21 | export: 22 | after: true 23 | else: 24 | after: true 25 | try: 26 | after: true 27 | no-param-reassign: 28 | - 2 29 | - 30 | props: false 31 | max-len: 32 | - 2 33 | - 34 | code: 140 35 | ignoreComments: true 36 | ignoreUrls: true 37 | ignoreStrings: true 38 | ignoreTemplateLiterals: true 39 | jsx-quotes: 40 | - 2 41 | - prefer-single 42 | no-return-assign: 43 | - 0 44 | no-console: 45 | - 2 46 | - 47 | allow: 48 | - "warn" 49 | - "error" 50 | - "info" 51 | react/no-did-mount-set-state: 0 52 | 53 | parser: babel-eslint 54 | globals: 55 | window: true 56 | document: true 57 | requestAnimationFrame: true 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | lib 3 | storybook-static 4 | package-lock.json 5 | .vscode 6 | 7 | # Created by https://www.gitignore.io/api/node,phpstorm 8 | 9 | ### Node ### 10 | # Logs 11 | logs 12 | *.log 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | 17 | # Runtime data 18 | pids 19 | *.pid 20 | *.seed 21 | *.pid.lock 22 | 23 | # Directory for instrumented libs generated by jscoverage/JSCover 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | coverage 28 | 29 | # nyc test coverage 30 | .nyc_output 31 | 32 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 33 | .grunt 34 | 35 | # Bower dependency directory (https://bower.io/) 36 | bower_components 37 | 38 | # node-waf configuration 39 | .lock-wscript 40 | 41 | # Compiled binary addons (http://nodejs.org/api/addons.html) 42 | build/Release 43 | 44 | # Dependency directories 45 | node_modules/ 46 | jspm_packages/ 47 | 48 | # Typescript v1 declaration files 49 | typings/ 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional REPL history 58 | .node_repl_history 59 | 60 | # Output of 'npm pack' 61 | *.tgz 62 | 63 | # Yarn Integrity file 64 | .yarn-integrity 65 | 66 | # dotenv environment variables file 67 | .env 68 | 69 | 70 | ### PhpStorm ### 71 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 72 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 73 | 74 | # User-specific stuff: 75 | .idea/**/workspace.xml 76 | .idea/**/tasks.xml 77 | 78 | # Sensitive or high-churn files: 79 | .idea/**/dataSources/ 80 | .idea/**/dataSources.ids 81 | .idea/**/dataSources.xml 82 | .idea/**/dataSources.local.xml 83 | .idea/**/sqlDataSources.xml 84 | .idea/**/dynamic.xml 85 | .idea/**/uiDesigner.xml 86 | 87 | # Gradle: 88 | .idea/**/gradle.xml 89 | .idea/**/libraries 90 | 91 | # Mongo Explorer plugin: 92 | .idea/**/mongoSettings.xml 93 | 94 | ## File-based project format: 95 | *.iws 96 | 97 | ## Plugin-specific files: 98 | 99 | # IntelliJ 100 | /out/ 101 | 102 | # mpeltonen/sbt-idea plugin 103 | .idea_modules/ 104 | 105 | # JIRA plugin 106 | atlassian-ide-plugin.xml 107 | 108 | # Crashlytics plugin (for Android Studio and IntelliJ) 109 | com_crashlytics_export_strings.xml 110 | crashlytics.properties 111 | crashlytics-build.properties 112 | fabric.properties 113 | 114 | ### PhpStorm Patch ### 115 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 116 | 117 | # *.iml 118 | # modules.xml 119 | # .idea/misc.xml 120 | # *.ipr 121 | 122 | # End of https://www.gitignore.io/api/node,phpstorm 123 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .idea 2 | # lib <-- we want to include this on npm!! 3 | 4 | 5 | # no storybook things on npm 6 | storybook-static 7 | .storybook 8 | stories 9 | 10 | # Created by https://www.gitignore.io/api/node,phpstorm 11 | 12 | ### Node ### 13 | # Logs 14 | logs 15 | *.log 16 | npm-debug.log* 17 | yarn-debug.log* 18 | yarn-error.log* 19 | 20 | # Runtime data 21 | pids 22 | *.pid 23 | *.seed 24 | *.pid.lock 25 | 26 | # Directory for instrumented libs generated by jscoverage/JSCover 27 | lib-cov 28 | 29 | # Coverage directory used by tools like istanbul 30 | coverage 31 | 32 | # nyc test coverage 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 36 | .grunt 37 | 38 | # Bower dependency directory (https://bower.io/) 39 | bower_components 40 | 41 | # node-waf configuration 42 | .lock-wscript 43 | 44 | # Compiled binary addons (http://nodejs.org/api/addons.html) 45 | build/Release 46 | 47 | # Dependency directories 48 | node_modules/ 49 | jspm_packages/ 50 | 51 | # Typescript v1 declaration files 52 | typings/ 53 | 54 | # Optional npm cache directory 55 | .npm 56 | 57 | # Optional eslint cache 58 | .eslintcache 59 | 60 | # Optional REPL history 61 | .node_repl_history 62 | 63 | # Output of 'npm pack' 64 | *.tgz 65 | 66 | # Yarn Integrity file 67 | .yarn-integrity 68 | 69 | # dotenv environment variables file 70 | .env 71 | 72 | 73 | ### PhpStorm ### 74 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 75 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 76 | 77 | # User-specific stuff: 78 | .idea/**/workspace.xml 79 | .idea/**/tasks.xml 80 | 81 | # Sensitive or high-churn files: 82 | .idea/**/dataSources/ 83 | .idea/**/dataSources.ids 84 | .idea/**/dataSources.xml 85 | .idea/**/dataSources.local.xml 86 | .idea/**/sqlDataSources.xml 87 | .idea/**/dynamic.xml 88 | .idea/**/uiDesigner.xml 89 | 90 | # Gradle: 91 | .idea/**/gradle.xml 92 | .idea/**/libraries 93 | 94 | # Mongo Explorer plugin: 95 | .idea/**/mongoSettings.xml 96 | 97 | ## File-based project format: 98 | *.iws 99 | 100 | ## Plugin-specific files: 101 | 102 | # IntelliJ 103 | /out/ 104 | 105 | # mpeltonen/sbt-idea plugin 106 | .idea_modules/ 107 | 108 | # JIRA plugin 109 | atlassian-ide-plugin.xml 110 | 111 | # Crashlytics plugin (for Android Studio and IntelliJ) 112 | com_crashlytics_export_strings.xml 113 | crashlytics.properties 114 | crashlytics-build.properties 115 | fabric.properties 116 | 117 | ### PhpStorm Patch ### 118 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 119 | 120 | # *.iml 121 | # modules.xml 122 | # .idea/misc.xml 123 | # *.ipr 124 | 125 | # End of https://www.gitignore.io/api/node,phpstorm 126 | -------------------------------------------------------------------------------- /.storybook/addons.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/headless-ninja/hn-react-webform/52137ff405287a1946e0d058b54f0d66579fd190/.storybook/addons.js -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { configure } from '@kadira/storybook'; 2 | 3 | function loadStories() { 4 | require('../stories/index.jsx'); 5 | } 6 | 7 | configure(loadStories, module); 8 | -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | 3 | module.exports = { 4 | module: { 5 | loaders: [{ 6 | test: /\.md$/, 7 | loader: "raw" 8 | }, { 9 | test: /\.json$/, 10 | loader: 'json' 11 | }, { 12 | test: /\.css$/, 13 | exclude: /highlight.*\.css$/, 14 | loader: 'style!css?importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]' 15 | }, { 16 | test: /highlight.*\.css$/, 17 | loader: 'style!css' 18 | }] 19 | }, 20 | 21 | plugins: [ 22 | new webpack.LoaderOptionsPlugin({ 23 | options: { 24 | context: '/', 25 | }, 26 | }) 27 | ] 28 | }; 29 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - node 4 | 5 | script: 6 | - yarn test 7 | - yarn build 8 | - yarn build-storybook 9 | 10 | branches: 11 | except: 12 | - /^v\d+\.\d+\.\d+$/ 13 | 14 | after_success: 15 | - yarn semantic-release 16 | 17 | deploy: 18 | skip_cleanup: true 19 | provider: pages 20 | local-dir: storybook-static 21 | github-token: 22 | secure: "UJwWzAq1MabmWKc4cTzQAf1AwRgcYllgnC2I9ex/iYUNAyf9jUmz81FvcLneGBjAO885Namu8O3RE57wHrj1uul2lhFFXM15ALp3I4rrMtEfW7cTMhmsqxpLKO6sqTjw6JI+uUUN/Tc8inHV7YuQxvCidnmzL5D2ENytrqrFpeIwCMta3UKZ2uMK0+KrMgd9JG9NmKvYGRGH1lpmK7Yq7gJtPJB/t/ACgsVcrAbhmMbh3ssaOidjPMc9aslqCepff8fIrMgacdficJ1b2NWOzjs1caQpHRpo4wassB+NJ8DNOkBikGNMUiD87TEdFZifj5MW8pU2j/sJv+ntLRAValDxw8DclYSE/Nt3PXD+AZwLFjkFM/VqMHLErGdEaS1YGVRoFIDm16J+AegoGyE6207hwh3ClyMI/bXy/HaPivppVqmkap2RMZlQoZ9MpOqbLtQtqz+nGRKqB+CM3Ngyj24/XxRZGBACq76wN4ncBnVxq2+RhclyZnd8HT5LGUsZ+q5M2msA7HawBOeYVshD98mDHf4DLnT1m7tarsM8aV2h6MEK4GiBq5y+/GQN84CER3XJMJLaczYDr0j5DJOIcM/MgPPJRbHeDxb4LB3iYmtTvyJo4wOIQAySPy30nMzady3BLkNga7zd5IpjxytyC1OLoXaZIgxaWFjgO6yPCls=" 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | :warning: This repository is still in development phase and is in active development. Don't expect anything to work yet! 2 | 3 | [![Build Status](https://travis-ci.org/burst-digital/hn-react-webform.svg?branch=master)](https://travis-ci.org/burst-digital/hn-react-webform) 4 | [![bitHound Overall Score](https://www.bithound.io/github/burst-digital/hn-react-webform/badges/score.svg)](https://www.bithound.io/github/burst-digital/hn-react-webform) 5 | 6 | # HN-React-Webform 7 | With this awesome React component, you can render complete Drupal Webforms in React. With validation, easy custom styling and a modern, clean interface. 8 | 9 | ## Installation 10 | 11 | First, install *hn-react-webform* in your project: 12 | ```bash 13 | $ npm install hn-react-webform 14 | # or 15 | $ yarn add hn-react-webform 16 | ``` 17 | Then, import the component: 18 | ```javascript 19 | // ES6 20 | import Webform from 'hn-react-webform'; 21 | // ES5 22 | var Webform = require('hn-react-webform').default; 23 | ``` 24 | This project uses *CSS Modules*, *CSS.next* and *ES7* to ease styling. Your web-bundler (like Webpack) needs to support *CSS Modules* to correctly parse all styling. 25 | # Contributing 26 | 27 | If you want to help contributing, follow these steps: 28 | 29 | 1. Clone this repo 30 | 2. `cd` into the folder 31 | 3. Run `npm install` 32 | 5. Run `npm run storybook` 33 | 6. Edit files in `/src` and view changes on http://localhost:6006/ 34 | 35 | 36 | If you want to contribute and to use this version of the module in a different project on your local machine without storybook, run `npm link` 37 | -------------------------------------------------------------------------------- /ROADMAP.md: -------------------------------------------------------------------------------- 1 | - Make sure the rules aren't set on construct() or in render functions, but everything in the static meta object, and make it a computed value on Field 2 | - Transfer the submit function to Form, with only the parameter (isDraft = false) which handles everything. Drafts should only send up to the current page. 3 | - Remove all obsolete parameters, and provide the formStore trough the mobx Provider. 4 | - Implement semantic-release, and run yarn build on the CI. 5 | - Implement mobx strict mode, and do everything with actions. 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hn-react-webform", 3 | "version": "0.0.0", 4 | "main": "lib/index.js", 5 | "repository": "https://github.com/burst-digital/hn-react-webform", 6 | "scripts": { 7 | "start": "npm run build -- --watch", 8 | "build": "rimraf lib && babel src --out-dir lib --copy-files", 9 | "lint": "npm run lint-js", 10 | "lint-js": "eslint --ext .jsx,.js src", 11 | "lint-js-fix": "eslint --ext .jsx,.js src --fix", 12 | "lint-js-watch": "node node_modules/eslint-watch/bin/esw -w --fix --ext .jsx,.js src", 13 | "lint-fix": "npm run lint-js-fix", 14 | "storybook": "start-storybook -p 6006", 15 | "build-storybook": "build-storybook", 16 | "test": "npm run lint && jest src", 17 | "precommit": "lint-staged && jest src", 18 | "semantic-release": "semantic-release pre && node utils/prepublish.js && npm publish && semantic-release post", 19 | "postinstall": "node utils/postinstall.js" 20 | }, 21 | "dependencies": { 22 | "fetch-everywhere": "^1.0.5", 23 | "get-nested": "^4.0.0", 24 | "google_tag": "^1.1.1", 25 | "html-entities": "^1.2.1", 26 | "ibantools": "^1.3.0", 27 | "mobx": "^4.9.4", 28 | "mobx-react": "^5.4.3", 29 | "moment": "^2.18.1", 30 | "prop-types": "^15.5.10", 31 | "react-css-modules": "^4.3.0", 32 | "react-day-picker": "^5.5.1", 33 | "react-google-recaptcha": "^2.0.1", 34 | "react-html-parser": "^1.0.3", 35 | "react-input-mask": "^0.8.0", 36 | "react-select": "^1.3.0", 37 | "styled-components": "^3.3.3", 38 | "validator": "^7.0.0" 39 | }, 40 | "lint-staged": { 41 | "*.{js,jsx}": [ 42 | "eslint --fix", 43 | "git add" 44 | ] 45 | }, 46 | "devDependencies": { 47 | "@kadira/storybook": "^2.35.3", 48 | "babel-cli": "^6.24.1", 49 | "babel-eslint": "^7.2.3", 50 | "babel-plugin-styled-components": "^1.5.1", 51 | "babel-plugin-syntax-object-rest-spread": "^6.13.0", 52 | "babel-plugin-transform-decorators-legacy": "^1.3.4", 53 | "babel-plugin-transform-runtime": "^6.23.0", 54 | "babel-preset-env": "^1.4.0", 55 | "babel-preset-react": "^6.24.1", 56 | "babel-preset-stage-1": "^6.24.1", 57 | "css-loader": "^0.28.1", 58 | "eslint": "^3.19.0", 59 | "eslint-config-airbnb": "^15.0.1", 60 | "eslint-plugin-import": "^2.2.0", 61 | "eslint-plugin-jsx-a11y": "^5.0.3", 62 | "eslint-plugin-react": "^7.0.1", 63 | "eslint-watch": "^3.1.0", 64 | "highlight.js": "^9.11.0", 65 | "husky": "^0.13.3", 66 | "jest": "^20.0.4", 67 | "jest-cli": "^20.0.3", 68 | "json-loader": "^0.5.4", 69 | "lint-staged": "^4.0.3", 70 | "raw-loader": "^0.5.1", 71 | "react": "^16.0.0", 72 | "react-addons-test-utils": "^15.5.1", 73 | "react-dom": "^16.0.0", 74 | "react-remarkable": "^1.1.1", 75 | "rimraf": "^2.6.1", 76 | "semantic-release": "^7.0.2", 77 | "storybook-readme": "^2.0.2", 78 | "style-loader": "^0.17.0", 79 | "stylelint": "^7.10.1", 80 | "stylelint-config-standard": "^16.0.0", 81 | "webpack": "^2.5.1" 82 | }, 83 | "peerDependencies": { 84 | "react": "^16.0.0", 85 | "react-dom": "^16.0.0" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Address/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { observer } from 'mobx-react'; 4 | import { get } from 'mobx'; 5 | import composeLookUp from '../LookUp'; 6 | import Fieldset from '../Fieldset'; 7 | import rules from '../Webform/rules'; 8 | import RuleHint from '../RuleHint'; 9 | import WebformUtils from '../WebformUtils'; 10 | import FormStore from '../Observables/Form'; 11 | 12 | // styled 13 | import FieldsetFormRow from '../Fieldset/styled/wrapper'; 14 | import ValidationMessage from '../LookUp/styled/validation-message'; 15 | 16 | @observer 17 | class Address extends Component { 18 | static meta = { 19 | wrapper: FieldsetFormRow, 20 | label: Fieldset.meta.label, 21 | wrapperProps: Fieldset.meta.wrapperProps, 22 | labelVisibility: Fieldset.meta.labelVisibility, 23 | validations: [el => `address_${el.key}`], 24 | hasValue: false, 25 | }; 26 | 27 | static propTypes = { 28 | field: PropTypes.shape({ 29 | '#webform_key': PropTypes.string.isRequired, 30 | '#addressError': PropTypes.string, 31 | composite_elements: PropTypes.arrayOf(PropTypes.shape()), 32 | parent: PropTypes.shape({ 33 | props: PropTypes.shape({ 34 | field: PropTypes.shape(), 35 | }), 36 | }), 37 | }).isRequired, 38 | getField: PropTypes.func.isRequired, 39 | onBlur: PropTypes.func.isRequired, 40 | onChange: PropTypes.func.isRequired, 41 | formKeySuffix: PropTypes.string.isRequired, 42 | url: PropTypes.string.isRequired, 43 | formStore: PropTypes.instanceOf(FormStore).isRequired, 44 | fields: PropTypes.arrayOf(PropTypes.shape()).isRequired, 45 | settings: PropTypes.shape().isRequired, 46 | registerLookUp: PropTypes.func.isRequired, 47 | }; 48 | 49 | constructor(props) { 50 | super(props); 51 | 52 | const field = props.formStore.getField(props.field['#webform_key']); 53 | 54 | const lookUpIsBlocking = field.element['#address_validation'] || ( 55 | field.parent && field.parent.element['#address_validation']); 56 | 57 | this.lookUpFields = { 58 | street: { 59 | elementKey: 'street', 60 | formKey: `address_street${props.formKeySuffix}`, 61 | apiValue: address => address.street, 62 | hideField: true, 63 | disableField: lookUpIsBlocking, 64 | }, 65 | postcode: { 66 | elementKey: 'postcode', 67 | formKey: `address_postcode${props.formKeySuffix}`, 68 | apiValue: address => (address.postcode || '').toUpperCase(), 69 | triggerLookUp: true, 70 | }, 71 | number: { 72 | elementKey: 'number', 73 | formKey: `address_number${props.formKeySuffix}`, 74 | apiValue: () => false, 75 | triggerLookUp: true, 76 | }, 77 | addition: { 78 | elementKey: 'addition', 79 | formKey: `address_number_add${props.formKeySuffix}`, 80 | apiValue: () => false, 81 | triggerLookUp: true, 82 | }, 83 | city: { 84 | elementKey: 'city', 85 | formKey: `address_city${props.formKeySuffix}`, 86 | apiValue: address => address.city.label, 87 | hideField: true, 88 | disableField: lookUpIsBlocking, 89 | }, 90 | locationLat: { 91 | elementKey: 'locationLat', 92 | formKey: `address_location_lat${props.formKeySuffix}`, 93 | apiValue: address => address.geo.center.wgs84.coordinates[1], 94 | }, 95 | locationLng: { 96 | elementKey: 'locationLng', 97 | formKey: `address_location_lng${props.formKeySuffix}`, 98 | apiValue: address => address.geo.center.wgs84.coordinates[0], 99 | }, 100 | manualOverride: { 101 | elementKey: 'manualOverride', 102 | formKey: `address_manual_override${props.formKeySuffix}`, 103 | apiValue: () => false, 104 | }, 105 | }; 106 | 107 | 108 | this.lookUpBase = `${props.url}/postcode-api/address?_format=json`; 109 | 110 | const lookUpKey = this.getLookUpKey(props); 111 | 112 | rules.set(`address_${props.field['#webform_key']}`, { 113 | rule: () => { 114 | const lookUp = get(field.lookUps, lookUpKey); 115 | return !lookUpIsBlocking || !lookUp || !lookUp.lookUpSent || ( 116 | lookUp.lookUpSent && lookUp.lookUpSuccessful 117 | ); 118 | }, 119 | hint: () => null, 120 | shouldValidate: () => props.fields.reduce( 121 | (shouldValidate, item) => 122 | shouldValidate && !item.isEmpty && item.isBlurred && item.valid, 123 | true, 124 | ), 125 | }); 126 | 127 | props.registerLookUp(lookUpKey, this.lookUpFields, true); 128 | } 129 | 130 | getLookUpKey(props) { 131 | return `${( 132 | props || this.props 133 | ).field['#webform_key']}-address`; 134 | } 135 | 136 | prepareLookUp(fields) { 137 | const postCodeField = this.props.getField('postcode').field; 138 | const numberField = this.props.getField('number').field; 139 | 140 | if(!fields.postcode || !postCodeField || !numberField || !postCodeField.valid || !fields.number || !numberField.valid) { 141 | return false; 142 | } 143 | 144 | const query = `&postcode=${fields.postcode.toUpperCase()}${fields.number ? `&number=${fields.number}` : ''}`; 145 | 146 | return { 147 | query, 148 | // eslint-disable-next-line no-underscore-dangle 149 | checkResponse: json => json, 150 | isSuccessful: json => (!!json.id), 151 | }; 152 | } 153 | 154 | render() { 155 | const field = this.props.formStore.getField(this.lookUpFields.postcode.formKey); 156 | const lookUpKey = this.getLookUpKey(); 157 | const lookUp = get(field.lookUps, lookUpKey); 158 | 159 | return ( 160 |
161 | {lookUp && lookUp.lookUpSent && !lookUp.lookUpSuccessful && ( 162 | } 164 | key={`address_${this.props.field['#webform_key']}`} 165 | hint={WebformUtils.getCustomValue(this.props.field, 'addressError', this.props.settings) || 166 | WebformUtils.getErrorMessage(this.props.field, '#required_error') || 167 | 'We don\'t recognise this address. Please check again, or proceed anyway.'} 168 | /> 169 | )} 170 |
171 | ); 172 | } 173 | } 174 | 175 | export default composeLookUp(Address); 176 | -------------------------------------------------------------------------------- /src/BaseButton/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Button from './styled/button'; 4 | 5 | const BaseButton = ({ disabled, label, formSubmitAttributes, onClick, primary, type }) => ( 6 | 16 | ); 17 | 18 | BaseButton.propTypes = { 19 | disabled: PropTypes.bool, 20 | formSubmitAttributes: PropTypes.arrayOf(PropTypes.string), 21 | label: PropTypes.string, 22 | primary: PropTypes.bool, 23 | onClick: PropTypes.func, 24 | type: PropTypes.string, 25 | }; 26 | 27 | BaseButton.defaultProps = { 28 | disabled: null, 29 | formSubmitAttributes: [], 30 | label: '', 31 | primary: true, 32 | onClick: () => {}, 33 | type: 'button', 34 | }; 35 | 36 | export default BaseButton; 37 | -------------------------------------------------------------------------------- /src/BaseButton/styled/button.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export default styled.button` 4 | cursor: pointer; 5 | -webkit-appearance: none; 6 | margin: calc(${p => p.theme.spacingUnit} * 1.5) 0 0; 7 | font-size: 1em; 8 | line-height: inherit; 9 | font-family: inherit; 10 | border: none; 11 | color: ${p => p.theme.buttonTextColor}; 12 | background-color: ${p => p.theme.buttonColor}; 13 | border-radius: ${p => p.theme.borderRadius}; 14 | padding: ${p => p.theme.buttonSpacingV} ${p => p.theme.buttonSpacingH}; 15 | transition: background-color 350ms ease; 16 | 17 | &:hover { 18 | background-color: ${p => p.theme.buttonColorHover}; 19 | } 20 | 21 | ${p => p.disabled && ` 22 | background-color: ${p.theme.buttonColorDisabled}; 23 | cursor: default; 24 | 25 | &:hover { 26 | background-color: ${p.theme.buttonColorDisabled}; 27 | } 28 | `} 29 | 30 | ${p => !p.primary && ` 31 | background-color: ${p.theme.buttonSecondaryColor}; 32 | color: ${p.theme.buttonSecondaryTextColor}; 33 | 34 | &:hover { 35 | background-color: ${p.theme.buttonSecondaryColorHover}; 36 | } 37 | `} 38 | `; 39 | -------------------------------------------------------------------------------- /src/BaseInput/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import InputMask from 'react-input-mask'; 4 | import { observer } from 'mobx-react'; 5 | import WebformElement from '../WebformElement'; 6 | // styled 7 | import Input from './styled/input'; 8 | 9 | const StyledInputMask = Input.withComponent(({ error, ...p }) => ); 10 | 11 | @observer 12 | class BaseInput extends Component { 13 | static propTypes = { 14 | field: PropTypes.shape({ 15 | '#type': PropTypes.string.isRequired, 16 | '#placeholder': PropTypes.string, 17 | '#field_prefix': PropTypes.string, 18 | '#field_suffix': PropTypes.string, 19 | '#webform_key': PropTypes.string.isRequired, 20 | '#required': PropTypes.bool, 21 | '#mask': PropTypes.oneOfType([ 22 | PropTypes.string, 23 | PropTypes.bool, 24 | ]), 25 | '#alwaysShowMask': PropTypes.oneOfType([ 26 | PropTypes.string, 27 | PropTypes.bool, 28 | ]), 29 | '#min': PropTypes.string, 30 | '#max': PropTypes.string, 31 | '#step': PropTypes.string, 32 | '#attributes': PropTypes.oneOfType([ 33 | PropTypes.shape({ 34 | autoComplete: PropTypes.string, 35 | }), 36 | PropTypes.array, 37 | ]), 38 | }).isRequired, 39 | className: PropTypes.string, 40 | value: PropTypes.oneOfType([ 41 | PropTypes.string, 42 | PropTypes.number, 43 | PropTypes.bool, 44 | ]).isRequired, 45 | type: PropTypes.string, 46 | id: PropTypes.number, 47 | webformElement: PropTypes.instanceOf(WebformElement).isRequired, 48 | onChange: PropTypes.func.isRequired, 49 | onBlur: PropTypes.func.isRequired, 50 | onFocus: PropTypes.func, 51 | onClick: PropTypes.func, 52 | onKeyDown: PropTypes.func, 53 | parentRef: PropTypes.func, 54 | state: PropTypes.shape({ 55 | required: PropTypes.bool.isRequired, 56 | enabled: PropTypes.bool.isRequired, 57 | }).isRequired, 58 | }; 59 | 60 | static defaultProps = { 61 | id: 0, 62 | className: undefined, 63 | type: 'text', 64 | autoComplete: '', 65 | onFocus: () => {}, 66 | onClick: () => {}, 67 | onKeyDown: () => {}, 68 | parentRef: () => {}, 69 | }; 70 | 71 | render() { 72 | const attrs = { 73 | 'aria-invalid': this.props.webformElement.isValid() ? null : true, 74 | 'aria-required': this.props.state.required ? true : null, 75 | ...(this.props.field['#attributes'] || {}), 76 | }; 77 | 78 | let InputComponent = Input; // Input HTML element is 'input' by default 79 | 80 | // When there is a mask from Drupal. 81 | if(this.props.field['#mask']) { 82 | InputComponent = StyledInputMask; // Use InputMask element instead. 83 | attrs.mask = this.props.field['#mask']; 84 | attrs.alwaysShowMask = this.props.field['#alwaysShowMask'] || true; 85 | } 86 | 87 | return ( 88 | ); 109 | } 110 | } 111 | 112 | export default BaseInput; 113 | -------------------------------------------------------------------------------- /src/BaseInput/styled/input.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import ValidationIcon from './validation-icon'; 3 | 4 | export default styled.input` 5 | box-sizing: border-box; 6 | margin: 0 0 calc(${p => p.theme.spacingUnit} / 2); 7 | font-size: 0.9em; 8 | line-height: ${p => p.theme.inputLineHeight}; 9 | font-family: inherit; 10 | width: 100%; 11 | border: 1px solid ${p => p.theme.borderColor}; 12 | background-color: ${p => p.theme.inputBgColor}; 13 | border-radius: ${p => p.theme.borderRadius}; 14 | padding: calc(${p => p.theme.spacingUnit} / 4) calc(${p => p.theme.spacingUnit} / 2); 15 | 16 | &:focus { 17 | outline: none; 18 | box-shadow: 0 0 2px 3px ${p => p.theme.focusColor}; 19 | } 20 | 21 | &:disabled { 22 | background-color: ${p => p.theme.inputDisabledBgColor}; 23 | } 24 | 25 | &::placeholder { 26 | color: ${p => p.theme.placeholderColor}; 27 | } 28 | 29 | ${p => p.error && ` 30 | border-color: ${p.theme.errorColor}; 31 | background-color: ${p.theme.errorBgColor}; 32 | 33 | &::placeholder { 34 | color: ${p.theme.errorColor}; 35 | } 36 | `} 37 | 38 | &[type="range"] + ${ValidationIcon} { 39 | position: absolute; 40 | right: -22px; 41 | top: 5px; 42 | } 43 | `; 44 | -------------------------------------------------------------------------------- /src/BaseInput/styled/validation-icon.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export default styled.span` 4 | display: inline-block; 5 | width: calc(${p => p.theme.spacingUnit} / 1.5); 6 | height: calc(${p => p.theme.spacingUnit} / 1.5); 7 | margin-left: calc(${p => p.theme.spacingUnit} / 1.5); 8 | 9 | &::after { 10 | content: ''; 11 | display: block; 12 | width: 60%; 13 | height: 100%; 14 | border-bottom: 2px solid ${p => p.theme.successColor}; 15 | border-right: 2px solid ${p => p.theme.successColor}; 16 | transform: rotate(45deg) translateY(-4px); 17 | transform: rotate(45deg) translateY(-4px); 18 | opacity: 0; 19 | } 20 | 21 | ${p => p.success && ` 22 | &::after { 23 | opacity: 1; 24 | } 25 | `} 26 | `; 27 | -------------------------------------------------------------------------------- /src/CheckboxField/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { observer } from 'mobx-react'; 4 | import Parser from '../Parser'; 5 | import Fieldset from '../Fieldset'; 6 | // styled 7 | import Wrapper from './styled/wrapper'; 8 | import Label from './styled/label'; 9 | import Checkbox from './styled/checkbox'; 10 | import Indicator from './styled/indicator'; 11 | import InnerLabel from './styled/inner-label'; 12 | import StyledFieldsetFormRow from './styled/fieldset'; 13 | 14 | @observer 15 | class CheckboxField extends Component { 16 | static meta = { 17 | wrapper: StyledFieldsetFormRow, 18 | label: Fieldset.meta.label, 19 | wrapperProps: Fieldset.meta.wrapperProps, 20 | field_display: { 21 | '#description': 'NO_DESCRIPTION', 22 | }, 23 | }; 24 | 25 | static propTypes = { 26 | field: PropTypes.shape({ 27 | '#webform_key': PropTypes.string.isRequired, 28 | '#title_display': PropTypes.string, 29 | '#description': PropTypes.string, 30 | '#required': PropTypes.bool, 31 | }).isRequired, 32 | value: PropTypes.oneOfType([ 33 | PropTypes.string, 34 | PropTypes.number, 35 | PropTypes.bool, 36 | ]).isRequired, 37 | id: PropTypes.number, 38 | onChange: PropTypes.func.isRequired, 39 | onBlur: PropTypes.func.isRequired, 40 | state: PropTypes.shape({ 41 | required: PropTypes.bool.isRequired, 42 | enabled: PropTypes.bool.isRequired, 43 | }).isRequired, 44 | }; 45 | 46 | static defaultProps = { 47 | id: 0, 48 | }; 49 | 50 | constructor(props) { 51 | super(props); 52 | 53 | this.onChange = this.onChange.bind(this); 54 | } 55 | 56 | onChange(e) { 57 | this.props.onChange(e.target.checked); 58 | this.props.onBlur(e); 59 | } 60 | 61 | getValue() { 62 | return this.props.value === '1' || this.props.value === true ? 'checked' : false; 63 | } 64 | 65 | render() { 66 | const value = this.getValue(); 67 | return ( 68 | 69 | 85 | 86 | ); 87 | } 88 | } 89 | 90 | export default CheckboxField; 91 | -------------------------------------------------------------------------------- /src/CheckboxField/styled/checkbox.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import Indicator from './indicator'; 3 | 4 | export default styled.input` 5 | /* Reset anything that could peek out or interfere with dimensions, but don't use display:none for WCAG */ 6 | position: absolute; 7 | overflow: hidden; 8 | margin: 0; 9 | padding: 0; 10 | border: 0; 11 | outline: 0; 12 | opacity: 0; 13 | 14 | &:focus { 15 | & + ${Indicator} { 16 | box-shadow: 0 0 2px 3px ${p => p.theme.focusColor}; 17 | } 18 | } 19 | 20 | &:checked { 21 | & + ${Indicator} { 22 | background: ${p => p.theme.iconCheckbox} no-repeat center center ${p => p.theme.inputBgColor}; 23 | background-size: calc(${p => p.theme.spacingUnit} * 0.8) auto; 24 | } 25 | } 26 | `; 27 | -------------------------------------------------------------------------------- /src/CheckboxField/styled/fieldset.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import FieldsetFormRow from '../../Fieldset/styled/wrapper'; 3 | 4 | export default styled(FieldsetFormRow)` 5 | margin-bottom: 0; 6 | `; 7 | -------------------------------------------------------------------------------- /src/CheckboxField/styled/indicator.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export default styled.span` 4 | content: ''; 5 | float: left; 6 | display: inline-block; 7 | width: ${p => p.theme.spacingUnitCheckbox}; 8 | height: ${p => p.theme.spacingUnitCheckbox}; 9 | margin-right: calc(${p => p.theme.spacingUnitCheckbox} * 1.5 * -1); 10 | background-color: ${p => p.theme.inputBgColor}; 11 | border: 1px solid ${p => p.theme.borderColor}; 12 | border-radius: ${p => p.theme.borderRadius}; 13 | `; 14 | -------------------------------------------------------------------------------- /src/CheckboxField/styled/inner-label.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export default styled.span` 4 | display: inline-block; 5 | padding-left: calc(${p => p.theme.spacingUnitCheckbox} * 1.5); 6 | line-height: 1.5; 7 | top: -0.2em; 8 | position: relative; 9 | `; 10 | -------------------------------------------------------------------------------- /src/CheckboxField/styled/label.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export default styled.label` 4 | width: auto; 5 | margin-bottom: 0; 6 | `; 7 | -------------------------------------------------------------------------------- /src/CheckboxField/styled/wrapper.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export default styled.div` 4 | display: inline-block; 5 | padding-bottom: calc(${p => p.theme.spacingUnit} / 4); 6 | 7 | ${p => p.labelDisplay === 'inline' && ` 8 | @media (min-width: 768px) { 9 | float: left; 10 | width: calc(50% - (${p.theme.spacingUnit} / 2) - 0.5em); 11 | } 12 | `} 13 | `; 14 | -------------------------------------------------------------------------------- /src/Converse/formula.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Pass a formula and an object with values to evaluate the strings after replacing the variables. 3 | * Examples formulas are: 4 | * * 10 + 10 => 20 5 | * * amount * 10 => will check the 'values' parameter object for an 'amount' key and use the value to replace it and evaluate the string. 6 | * * (amount|0| * 12) + 1.95 => will check the 'values' parameter object for an 'amount' key and use the value to replace it and evaluate the string. If the 'amount' key is not present it will use '0' instead. 7 | * @param formula The formula needs to be a string and can contain the basic mathematical operators, i.e. + - * / () ^ 8 | * @param values Object with the keys corresponding to the variables in the formula and with values to be used instead of the variables. 9 | * @returns number 10 | */ 11 | // eslint-disable-next-line import/prefer-default-export 12 | export const parse = (formula, values) => { 13 | const formattedFormula = formula.replace(/\|([0-9]+)\|/g, ''); // Remove all fallback values from formula (|1|) 14 | 15 | const formulaParts = formula.split('|'); // Split formula into parts, an array of fields & fallback values ('field|1| + field2|0|' => ['field', 1, 'field2', 0]) 16 | 17 | const replacedFormula = formattedFormula.match(/([a-zA-Z_])+/g).reduce((result, variable) => { 18 | let value = values[variable]; 19 | if(typeof value === 'undefined') { 20 | const fallbackIndex = formulaParts.findIndex(item => item.endsWith(variable)); // Get index of field in formula parts 21 | const fallbackValue = parseFloat(formulaParts[fallbackIndex + 1]); // Next to field is the fallback value, hence the + 1 22 | if(fallbackIndex !== false && !isNaN(fallbackValue)) { // If the fallback value exists, and is a number 23 | value = fallbackValue; 24 | } 25 | } 26 | 27 | return result.replace(variable, value); 28 | }, formattedFormula); 29 | 30 | // Replace comma's with periods. 31 | const removeCommasFormula = replacedFormula.replace(/,/g, '.'); 32 | 33 | if(removeCommasFormula.match(/[^-()\d\s/*+.]/g)) { 34 | console.error(`Invalid characters found in formula ${removeCommasFormula.toString()}`); 35 | return 0; 36 | } 37 | 38 | // eslint-disable-next-line no-eval 39 | return eval(removeCommasFormula); 40 | }; 41 | -------------------------------------------------------------------------------- /src/Converse/index.jsx: -------------------------------------------------------------------------------- 1 | import { observer, inject } from 'mobx-react'; 2 | import PropTypes from 'prop-types'; 3 | import React, { Component } from 'react'; 4 | import Fieldset from '../Fieldset'; 5 | import { parse } from './formula'; 6 | 7 | // styled 8 | import FieldsetFormRow from '../Fieldset/styled/wrapper'; 9 | 10 | @inject('formStore') 11 | @observer 12 | class Converse extends Component { 13 | static meta = { 14 | wrapper: FieldsetFormRow, 15 | label: Fieldset.meta.label, 16 | wrapperProps: Fieldset.meta.wrapperProps, 17 | labelVisibility: Fieldset.meta.labelVisibility, 18 | hasValue: false, 19 | }; 20 | 21 | static propTypes = { 22 | onBlur: PropTypes.func.isRequired, 23 | onChange: PropTypes.func.isRequired, 24 | }; 25 | 26 | static getTokens(formStore, field) { 27 | if(field.visible) { 28 | const amount = Converse.calculateFormula(formStore.values, field.element['#payment_amount']) || 0; 29 | const formattedAmount = parseFloat(amount).toFixed(2); 30 | return { 31 | payment_amount: formattedAmount, 32 | payment_amount_number: formattedAmount.slice(0, -3), 33 | payment_amount_decimals: formattedAmount.slice(-2), 34 | }; 35 | } 36 | return {}; 37 | } 38 | 39 | /** 40 | * financial_amount_monthly_suggestion|0| + financial_amount_monthly|0| + financial_amount_yearly_suggestion|0| + financial_amount_yearly|0| 41 | * @param values 42 | * @param formula 43 | * @returns {*} 44 | */ 45 | static calculateFormula(values, formula) { 46 | const amount = parse(formula, values); 47 | if(isNaN(amount)) { 48 | return null; 49 | } 50 | return amount < 0 ? 0 : amount; 51 | } 52 | 53 | render() { 54 | return ( 55 |
61 | ); 62 | } 63 | } 64 | 65 | export default Converse; 66 | -------------------------------------------------------------------------------- /src/Date/index.jsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react'; 2 | import moment from 'moment'; 3 | import PropTypes from 'prop-types'; 4 | import React, { Component } from 'react'; 5 | import MomentLocaleUtils from 'react-day-picker/moment'; 6 | import BaseInput from '../BaseInput'; 7 | import Fieldset from '../Fieldset'; 8 | import Input from '../Input'; 9 | import RuleHint from '../RuleHint'; 10 | import rules from '../Webform/rules'; 11 | import WebformElement from '../WebformElement'; 12 | import WebformUtils from '../WebformUtils'; 13 | import labelTranslations from './labelTranslations'; 14 | // styled 15 | import Wrapper from './styled/wrapper'; 16 | import OverlayWrapper from './styled/overlay-wrapper'; 17 | import Overlay from './styled/overlay'; 18 | import DayPicker from './styled/day-picker'; 19 | import ValidationIcon from './styled/validation-icon'; 20 | 21 | @observer 22 | class Date extends Component { 23 | static meta = { 24 | validations: [ 25 | el => `date_${el.key}`, 26 | el => `date_range_${el.key}`, 27 | ], 28 | }; 29 | 30 | static propTypes = { 31 | field: PropTypes.shape({ 32 | '#webform_key': PropTypes.string, 33 | '#default_value': PropTypes.string, 34 | '#date_date_min': PropTypes.string, 35 | '#date_date_max': PropTypes.string, 36 | '#min': PropTypes.string, 37 | '#max': PropTypes.string, 38 | '#mask': PropTypes.oneOfType([ 39 | PropTypes.string, 40 | PropTypes.bool, 41 | ]), 42 | '#dateError': PropTypes.string, 43 | '#dateRangeError': PropTypes.string, 44 | '#dateBeforeError': PropTypes.string, 45 | '#dateAfterError': PropTypes.string, 46 | '#title_display': PropTypes.string, 47 | '#datepicker': PropTypes.bool, 48 | composite_elements: PropTypes.oneOfType([ 49 | PropTypes.array, 50 | PropTypes.object, 51 | ]), 52 | }).isRequired, 53 | value: PropTypes.oneOfType([ 54 | PropTypes.string, 55 | PropTypes.bool, 56 | ]).isRequired, 57 | type: PropTypes.string, 58 | webformElement: PropTypes.instanceOf(WebformElement).isRequired, 59 | onChange: PropTypes.func.isRequired, 60 | onFocus: PropTypes.func, 61 | onBlur: PropTypes.func.isRequired, 62 | settings: PropTypes.shape().isRequired, 63 | locale: PropTypes.string, 64 | }; 65 | 66 | static defaultProps = { 67 | locale: 'nl', 68 | type: 'text', 69 | onFocus: () => {}, 70 | }; 71 | 72 | static rewriteValue(value) { 73 | const date = moment(value, Date.dateFormat, true); 74 | return date.format('YYYY-MM-DD'); 75 | } 76 | 77 | static dateFormat = 'DD/MM/YYYY'; 78 | 79 | constructor(props) { 80 | super(props); 81 | 82 | this.min = props.field['#min'] || props.field['#date_date_min']; 83 | this.max = props.field['#max'] || props.field['#date_date_max']; 84 | 85 | rules.set(`date_${props.field['#webform_key']}`, { 86 | rule: (value) => { 87 | const timestamp = moment(value, Date.dateFormat, true); 88 | return WebformUtils.isEmpty(props.field, value) || timestamp.isValid(); 89 | }, 90 | hint: () => 91 | (), 95 | shouldValidate: field => field.isBlurred && !WebformUtils.isEmpty(field, field.getValue()), 96 | }); 97 | 98 | rules.set(`date_range_${props.field['#webform_key']}`, { 99 | rule: value => WebformUtils.isEmpty(props.field, value) || this.calculateDateRange(value).valid, 100 | hint: (value) => { 101 | const result = this.calculateDateRange(value); 102 | let hint; 103 | 104 | switch(result.type) { 105 | case 'before': 106 | hint = WebformUtils.getCustomValue(props.field, 'dateBeforeError', props.settings) || WebformUtils.getErrorMessage(props.field, '#required_error') || 'Please enter a date before {{max}}'; 107 | break; 108 | case 'after': 109 | hint = WebformUtils.getCustomValue(props.field, 'dateAfterError', props.settings) || WebformUtils.getErrorMessage(props.field, '#required_error') || 'Please enter a date after {{min}}'; 110 | break; 111 | default: 112 | case 'range': 113 | hint = WebformUtils.getCustomValue(props.field, 'dateRangeError', props.settings) || WebformUtils.getErrorMessage(props.field, '#required_error') || 'Please enter a date between {{min}} and {{max}}'; 114 | break; 115 | } 116 | 117 | return (); 126 | }, 127 | shouldValidate: field => field.isBlurred && !WebformUtils.isEmpty(field, field.getValue()), 128 | }); 129 | 130 | this.clickedInside = false; 131 | 132 | this.state = { 133 | showOverlay: false, 134 | selectedDay: moment(props.value, Date.dateFormat, true).isValid() ? moment(props.value, Date.dateFormat).toDate() : null, 135 | }; 136 | 137 | this.calculateDisabledDates = this.calculateDisabledDates.bind(this); 138 | this.handleInputFocus = this.handleInputFocus.bind(this); 139 | this.handleInputBlur = this.handleInputBlur.bind(this); 140 | this.handleInputChange = this.handleInputChange.bind(this); 141 | this.handleContainerMouseDown = this.handleContainerMouseDown.bind(this); 142 | this.handleDayClick = this.handleDayClick.bind(this); 143 | this.setRef = this.setRef.bind(this); 144 | } 145 | 146 | componentWillUnmount() { 147 | clearTimeout(this.clickTimeout); 148 | } 149 | 150 | setRef(ref, el) { 151 | this[ref] = el; 152 | } 153 | 154 | getDateRange(result = {}, dateFormat = Date.dateFormat) { 155 | const minMoment = moment(this.min, dateFormat, true); 156 | const maxMoment = moment(this.max, dateFormat, true); 157 | 158 | result.min = minMoment; 159 | if(!minMoment.isValid()) { 160 | const [amount, unit] = this.min.split(' '); 161 | result.min = moment().add(amount, unit); 162 | } 163 | 164 | result.max = maxMoment; 165 | if(!maxMoment.isValid()) { 166 | const [amount, unit] = this.max.split(' '); 167 | result.max = moment().add(amount, unit); 168 | } 169 | 170 | return result; 171 | } 172 | 173 | getLabelDisplay() { 174 | return this.props.webformElement.getLabelDisplay(); 175 | } 176 | 177 | calculateDateRange(value) { 178 | const result = this.getDateRange({ 179 | valid: true, 180 | }); 181 | 182 | const timestamp = moment(value, Date.dateFormat, true); 183 | 184 | if(this.min && this.max) { 185 | result.type = 'range'; 186 | result.valid = timestamp.isBetween(result.min, result.max); 187 | } else if(this.min) { 188 | result.type = 'after'; 189 | result.valid = timestamp.isAfter(result.min); 190 | } else if(this.max) { 191 | result.type = 'before'; 192 | result.valid = timestamp.isBefore(result.max); 193 | } 194 | 195 | return result; 196 | } 197 | 198 | calculateDisabledDates(day) { 199 | const result = this.calculateDateRange(moment(day).format(Date.dateFormat)); 200 | return !result.valid; 201 | } 202 | 203 | handleInputBlur() { 204 | const showOverlay = this.clickedInside; 205 | 206 | this.setState({ showOverlay }); 207 | 208 | // Force input's focus if blur event was caused by clicking on the calendar. 209 | if(showOverlay) { 210 | this.props.onFocus(); 211 | } 212 | } 213 | 214 | handleInputFocus() { 215 | this.setState({ showOverlay: true }); 216 | } 217 | 218 | handleInputChange(e) { 219 | const value = e.target.value; 220 | const momentDay = moment(value, Date.dateFormat, true); 221 | const newValue = momentDay.isValid() ? momentDay.format(Date.dateFormat) : value; 222 | const selectedDay = momentDay.isValid() ? momentDay.toDate() : null; 223 | this.props.onChange(newValue); 224 | this.setState({ selectedDay }, 225 | () => selectedDay && this.daypicker.showMonth(selectedDay), 226 | ); 227 | } 228 | 229 | handleContainerMouseDown() { 230 | this.clickedInside = true; 231 | // The input's onBlur method is called from a queue right after onMouseDown event. 232 | // setTimeout adds another callback in the queue, but is called later than onBlur event. 233 | this.clickTimeout = setTimeout(() => this.clickedInside = false, 0); 234 | } 235 | 236 | handleDayClick(selectedDay) { 237 | this.props.onChange(moment(selectedDay).format(Date.dateFormat)); 238 | this.setState({ 239 | selectedDay, 240 | showOverlay: false, 241 | }); 242 | this.props.onBlur(); 243 | } 244 | 245 | render() { 246 | const { value, field } = this.props; 247 | 248 | field['#mask'] = Fieldset.getValue(field, 'mask'); 249 | if(field['#mask'] === false) { 250 | field['#mask'] = '99/99/9999'; 251 | } 252 | 253 | if(!field['#datepicker']) { 254 | return ( 255 | 260 | ); 261 | } 262 | 263 | const DateInput = ( 264 | 273 | ); 274 | 275 | return ( 276 | 282 | {DateInput} 283 | {this.state.showOverlay && ( 284 | 285 | 286 | this.setRef('daypicker', el)} 288 | initialMonth={this.state.selectedDay || undefined} 289 | onDayClick={this.handleDayClick} 290 | selectedDays={this.state.selectedDay} 291 | locale={this.props.locale} 292 | localeUtils={MomentLocaleUtils} 293 | labels={labelTranslations[this.props.locale]} 294 | enableOutsideDays 295 | disabledDays={[this.calculateDisabledDates]} 296 | /> 297 | 298 | 299 | )} 300 | 301 | 302 | ); 303 | } 304 | } 305 | 306 | export default Date; 307 | -------------------------------------------------------------------------------- /src/Date/labelTranslations.js: -------------------------------------------------------------------------------- 1 | export const nlLabels = { 2 | nextMonth: 'Volgende maand', 3 | previousMonth: 'Vorige maand', 4 | }; 5 | 6 | export default { 7 | nl: nlLabels, 8 | }; 9 | -------------------------------------------------------------------------------- /src/Date/styled/day-picker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import styled from 'styled-components'; 3 | import DayPicker from 'react-day-picker'; 4 | 5 | export default styled(DayPicker)` 6 | /* DayPicker styles */ 7 | 8 | .DayPicker { 9 | display: -webkit-box; 10 | display: -ms-flexbox; 11 | display: flex; 12 | -ms-flex-wrap: wrap; 13 | flex-wrap: wrap; 14 | -webkit-box-pack: center; 15 | -ms-flex-pack: center; 16 | justify-content: center; 17 | position: relative; 18 | padding: 1rem 0; 19 | -webkit-user-select: none; 20 | -moz-user-select: none; 21 | -ms-user-select: none; 22 | user-select: none; 23 | -webkit-box-orient: horizontal; 24 | -webkit-box-direction: normal; 25 | -ms-flex-direction: row; 26 | flex-direction: row; 27 | } 28 | 29 | .DayPicker-Month { 30 | display: table; 31 | border-collapse: collapse; 32 | border-spacing: 0; 33 | -webkit-user-select: none; 34 | -moz-user-select: none; 35 | -ms-user-select: none; 36 | user-select: none; 37 | margin: 0 1rem; 38 | } 39 | 40 | .DayPicker-NavBar { 41 | position: absolute; 42 | left: 0; 43 | right: 0; 44 | padding: 0 .5rem; 45 | top: 1rem; 46 | } 47 | 48 | .DayPicker-NavButton { 49 | position: absolute; 50 | width: 1.5rem; 51 | height: 1.5rem; 52 | background-repeat: no-repeat; 53 | background-position: center; 54 | background-size: contain; 55 | cursor: pointer; 56 | } 57 | 58 | .DayPicker-NavButton--prev { 59 | left: 1rem; 60 | background-image: url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+Cjxzdmcgd2lkdGg9IjI2cHgiIGhlaWdodD0iNTBweCIgdmlld0JveD0iMCAwIDI2IDUwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHhtbG5zOnNrZXRjaD0iaHR0cDovL3d3dy5ib2hlbWlhbmNvZGluZy5jb20vc2tldGNoL25zIj4KICAgIDwhLS0gR2VuZXJhdG9yOiBTa2V0Y2ggMy4zLjIgKDEyMDQzKSAtIGh0dHA6Ly93d3cuYm9oZW1pYW5jb2RpbmcuY29tL3NrZXRjaCAtLT4KICAgIDx0aXRsZT5wcmV2PC90aXRsZT4KICAgIDxkZXNjPkNyZWF0ZWQgd2l0aCBTa2V0Y2guPC9kZXNjPgogICAgPGRlZnM+PC9kZWZzPgogICAgPGcgaWQ9IlBhZ2UtMSIgc3Ryb2tlPSJub25lIiBzdHJva2Utd2lkdGg9IjEiIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCIgc2tldGNoOnR5cGU9Ik1TUGFnZSI+CiAgICAgICAgPGcgaWQ9InByZXYiIHNrZXRjaDp0eXBlPSJNU0xheWVyR3JvdXAiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDEzLjM5MzE5MywgMjUuMDAwMDAwKSBzY2FsZSgtMSwgMSkgdHJhbnNsYXRlKC0xMy4zOTMxOTMsIC0yNS4wMDAwMDApIHRyYW5zbGF0ZSgwLjg5MzE5MywgMC4wMDAwMDApIiBmaWxsPSIjNTY1QTVDIj4KICAgICAgICAgICAgPHBhdGggZD0iTTAsNDkuMTIzNzMzMSBMMCw0NS4zNjc0MzQ1IEwyMC4xMzE4NDU5LDI0LjcyMzA2MTIgTDAsNC4yMzEzODMxNCBMMCwwLjQ3NTA4NDQ1OSBMMjUsMjQuNzIzMDYxMiBMMCw0OS4xMjM3MzMxIEwwLDQ5LjEyMzczMzEgWiIgaWQ9InJpZ2h0IiBza2V0Y2g6dHlwZT0iTVNTaGFwZUdyb3VwIj48L3BhdGg+CiAgICAgICAgPC9nPgogICAgPC9nPgo8L3N2Zz4K"); 61 | } 62 | 63 | .DayPicker-NavButton--next { 64 | right: 1rem; 65 | background-image: url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+Cjxzdmcgd2lkdGg9IjI2cHgiIGhlaWdodD0iNTBweCIgdmlld0JveD0iMCAwIDI2IDUwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHhtbG5zOnNrZXRjaD0iaHR0cDovL3d3dy5ib2hlbWlhbmNvZGluZy5jb20vc2tldGNoL25zIj4KICAgIDwhLS0gR2VuZXJhdG9yOiBTa2V0Y2ggMy4zLjIgKDEyMDQzKSAtIGh0dHA6Ly93d3cuYm9oZW1pYW5jb2RpbmcuY29tL3NrZXRjaCAtLT4KICAgIDx0aXRsZT5uZXh0PC90aXRsZT4KICAgIDxkZXNjPkNyZWF0ZWQgd2l0aCBTa2V0Y2guPC9kZXNjPgogICAgPGRlZnM+PC9kZWZzPgogICAgPGcgaWQ9IlBhZ2UtMSIgc3Ryb2tlPSJub25lIiBzdHJva2Utd2lkdGg9IjEiIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCIgc2tldGNoOnR5cGU9Ik1TUGFnZSI+CiAgICAgICAgPGcgaWQ9Im5leHQiIHNrZXRjaDp0eXBlPSJNU0xheWVyR3JvdXAiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAuOTUxNDUxLCAwLjAwMDAwMCkiIGZpbGw9IiM1NjVBNUMiPgogICAgICAgICAgICA8cGF0aCBkPSJNMCw0OS4xMjM3MzMxIEwwLDQ1LjM2NzQzNDUgTDIwLjEzMTg0NTksMjQuNzIzMDYxMiBMMCw0LjIzMTM4MzE0IEwwLDAuNDc1MDg0NDU5IEwyNSwyNC43MjMwNjEyIEwwLDQ5LjEyMzczMzEgTDAsNDkuMTIzNzMzMSBaIiBpZD0icmlnaHQiIHNrZXRjaDp0eXBlPSJNU1NoYXBlR3JvdXAiPjwvcGF0aD4KICAgICAgICA8L2c+CiAgICA8L2c+Cjwvc3ZnPgo="); 66 | } 67 | 68 | 69 | .DayPicker-Caption { 70 | display: table-caption; 71 | height: 1.5rem; 72 | text-align: center; 73 | } 74 | 75 | .DayPicker-Weekdays { 76 | display: table-header-group; 77 | } 78 | 79 | .DayPicker-WeekdaysRow { 80 | display: table-row; 81 | } 82 | 83 | .DayPicker-Weekday { 84 | display: table-cell; 85 | padding: .5rem; 86 | font-size: .875em; 87 | text-align: center; 88 | color: #8b9898; 89 | } 90 | 91 | .DayPicker-Body { 92 | display: table-row-group; 93 | } 94 | 95 | .DayPicker-Week { 96 | display: table-row; 97 | } 98 | 99 | .DayPicker-Day { 100 | display: table-cell; 101 | padding: .5rem; 102 | border: 1px solid #eaecec; 103 | text-align: center; 104 | cursor: pointer; 105 | vertical-align: middle; 106 | } 107 | 108 | .DayPicker-WeekNumber { 109 | display: table-cell; 110 | padding: .5rem; 111 | text-align: right; 112 | vertical-align: middle; 113 | min-width: 1rem; 114 | font-size: 0.75em; 115 | cursor: pointer; 116 | color: #8b9898; 117 | } 118 | 119 | .DayPicker--interactionDisabled .DayPicker-Day { 120 | cursor: default; 121 | } 122 | 123 | .DayPicker-Footer { 124 | display: table-caption; 125 | caption-side: bottom; 126 | padding-top: .5rem; 127 | } 128 | 129 | .DayPicker-TodayButton { 130 | border:none; 131 | background-image:none; 132 | background-color:transparent; 133 | -webkit-box-shadow: none; 134 | box-shadow: none; 135 | cursor: pointer; 136 | color: #4A90E2; 137 | font-size: 0.875em; 138 | } 139 | 140 | /* Default modifiers */ 141 | 142 | .DayPicker-Day--today { 143 | color: #d0021b; 144 | font-weight: 500; 145 | } 146 | 147 | .DayPicker-Day--disabled { 148 | color: #dce0e0; 149 | cursor: default; 150 | background-color: #eff1f1; 151 | } 152 | 153 | .DayPicker-Day--outside { 154 | cursor: default; 155 | color: #dce0e0; 156 | } 157 | 158 | /* Example modifiers */ 159 | 160 | .DayPicker-Day--sunday { 161 | background-color: #f7f8f8; 162 | } 163 | 164 | .DayPicker-Day--sunday:not(.DayPicker-Day--today) { 165 | color: #dce0e0; 166 | } 167 | 168 | .DayPicker-Day--selected:not(.DayPicker-Day--disabled):not(.DayPicker-Day--outside) { 169 | color: #FFF; 170 | background-color: #4A90E2; 171 | } 172 | 173 | /* DayPickerInput */ 174 | 175 | .DayPickerInput { 176 | display: inline-block; 177 | } 178 | 179 | .DayPickerInput-OverlayWrapper { 180 | position: relative; 181 | } 182 | 183 | .DayPickerInput-Overlay { 184 | left: 0; 185 | position: absolute; 186 | background: white; 187 | -webkit-box-shadow: 0 2px 5px rgba(0, 0, 0, .15); 188 | box-shadow: 0 2px 5px rgba(0, 0, 0, .15); 189 | } 190 | /* Navigation */ 191 | .DayPicker-Month { 192 | font-weight: bold; 193 | } 194 | 195 | .DayPicker-Weekday { 196 | color: #757575; 197 | font-size: 0.8em; 198 | } 199 | 200 | /* Days */ 201 | .DayPicker-Day { 202 | font-weight: normal; 203 | transition: background-color 350ms ease; 204 | } 205 | 206 | /* Enabled days */ 207 | .DayPicker-Day:not(.DayPicker-Day--disabled):not(.DayPicker-Day--outside) { 208 | color: ${p => p.theme.baseColor}; 209 | 210 | &:hover { 211 | background-color: #e4e7e7; 212 | } 213 | } 214 | 215 | /* Disabled days */ 216 | 217 | /* .DayPicker-Day.DayPicker-Day--outside { 218 | color: purple; 219 | } */ 220 | 221 | .DayPicker-Day--disabled { 222 | &:hover { 223 | background-color: #fff; 224 | } 225 | } 226 | 227 | /* Today */ 228 | .DayPicker-Day--today { 229 | font-weight: bold; 230 | color: ${p => p.theme.baseColor}; 231 | } 232 | 233 | /* Selected day */ 234 | .DayPicker-Day--selected:not(.DayPicker-Day--disabled):not(.DayPicker-Day--outside) { 235 | color: ${p => p.theme.buttonTextColor}; 236 | background-color: ${p => p.theme.buttonColor}; 237 | } 238 | `; 239 | -------------------------------------------------------------------------------- /src/Date/styled/overlay-wrapper.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export default styled.div` 4 | position: relative; 5 | `; 6 | -------------------------------------------------------------------------------- /src/Date/styled/overlay.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export default styled.div` 4 | position: absolute; 5 | left: 0; 6 | top: 5px; 7 | background: #fff; 8 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15); 9 | z-index: 1; 10 | `; 11 | -------------------------------------------------------------------------------- /src/Date/styled/validation-icon.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import ValidationIcon from '../../BaseInput/styled/validation-icon'; 3 | 4 | export default styled(ValidationIcon)` 5 | position: absolute; 6 | top: 10px; 7 | right: 0; 8 | `; 9 | -------------------------------------------------------------------------------- /src/Date/styled/wrapper.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export default styled.div` 4 | position: relative; 5 | 6 | ${p => p.labelDisplay === 'inline' && ` 7 | width: calc(${p.theme.inputWidth} + 20px); 8 | padding-right: 20px; 9 | 10 | @media (min-width: 768px) { 11 | display: inline-block; 12 | float: left; 13 | } 14 | `} 15 | 16 | ${p => p.labelDisplay === 'before' && ` 17 | width: calc(${p.theme.inputWidth} + 20px); 18 | padding-right: 20px; 19 | 20 | @media (min-width: 768px) { 21 | width: calc(${p.theme.inlineLabelWidth} + ${p.theme.inputWidth} + 20px); 22 | } 23 | `} 24 | `; 25 | -------------------------------------------------------------------------------- /src/EmailField/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import validator from 'validator'; 4 | import { get } from 'mobx'; 5 | import { observer } from 'mobx-react'; 6 | import rules from '../Webform/rules'; 7 | import RuleHint from '../RuleHint'; 8 | import composeLookUp from '../LookUp'; 9 | import WebformUtils from '../WebformUtils'; 10 | import FormStore from '../Observables/Form'; 11 | // styled 12 | import Email from './styled/email'; 13 | 14 | @observer 15 | class EmailField extends Component { 16 | static meta = { 17 | validations: [ 18 | el => `email_${el.key}`, 19 | el => `email_neverbounce_${el.key}`, 20 | ], 21 | }; 22 | 23 | static propTypes = { 24 | field: PropTypes.shape({ 25 | '#webform_key': PropTypes.string.isRequired, 26 | '#emailError': PropTypes.string, 27 | '#neverBounceEmail': PropTypes.string, 28 | }).isRequired, 29 | getField: PropTypes.func.isRequired, 30 | onChange: PropTypes.func.isRequired, 31 | onBlur: PropTypes.func.isRequired, 32 | settings: PropTypes.shape().isRequired, 33 | url: PropTypes.string.isRequired, 34 | formStore: PropTypes.instanceOf(FormStore).isRequired, 35 | registerLookUp: PropTypes.func.isRequired, 36 | }; 37 | 38 | constructor(props) { 39 | super(props); 40 | 41 | this.lookUpFields = { 42 | email: { 43 | elementKey: 'email', 44 | formKey: props.field['#webform_key'], 45 | triggerLookUp: true, 46 | apiValue: () => false, 47 | required: true, 48 | }, 49 | }; 50 | this.lookUpBase = `${props.url}/neverbounce/validate-single?_format=json`; 51 | 52 | const lookUpKey = this.getLookUpKey(props); 53 | const field = props.formStore.getField(props.field['#webform_key']); 54 | 55 | rules.set(`email_${props.field['#webform_key']}`, { 56 | rule: () => field.isEmpty || validator.isEmail(field.value), 57 | hint: value => 58 | , 59 | shouldValidate: () => field.isBlurred && !field.isEmpty, 60 | }); 61 | rules.set(`email_neverbounce_${props.field['#webform_key']}`, { 62 | rule: () => { 63 | const lookUp = get(field.lookUps, lookUpKey); 64 | return field.isEmpty || !lookUp || lookUp.lookUpSuccessful; 65 | }, 66 | hint: () => 67 | , 68 | shouldValidate: () => field.isBlurred && !field.isEmpty && validator.isEmail(field.value), 69 | }); 70 | 71 | 72 | props.registerLookUp(lookUpKey, this.lookUpFields); 73 | } 74 | 75 | getLookUpKey(props) { 76 | return `${(props || this.props).field['#webform_key']}-email`; 77 | } 78 | 79 | prepareLookUp(fields) { 80 | const emailField = this.props.getField('email'); 81 | 82 | if( 83 | !fields.email || 84 | !emailField.field || 85 | WebformUtils.isEmpty(emailField.field.props, fields.email) || 86 | !validator.isEmail(fields.email) 87 | ) { 88 | return false; 89 | } 90 | 91 | const query = `&email=${fields.email}`; 92 | 93 | return { 94 | query, 95 | checkResponse: json => (json.success ? json : false), 96 | isSuccessful: response => response.result !== 1, 97 | }; 98 | } 99 | 100 | render() { 101 | return ( 102 | 107 | ); 108 | } 109 | } 110 | 111 | export default composeLookUp(EmailField); 112 | -------------------------------------------------------------------------------- /src/EmailField/styled/email.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import Input from '../../Input'; 3 | 4 | export default styled(Input)` 5 | color: #000; 6 | `; 7 | -------------------------------------------------------------------------------- /src/FetchForm/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import fetch from 'fetch-everywhere'; 4 | import { observer } from 'mobx-react'; 5 | import Webform from '../Webform'; 6 | 7 | const LOAD_STATES = { 8 | LOADING: 0, 9 | SUCCESS: 1, 10 | ERROR: 2, 11 | }; 12 | 13 | 14 | @observer 15 | class FetchForm extends Component { 16 | static propTypes = { 17 | baseUrl: PropTypes.string.isRequired, 18 | path: PropTypes.string.isRequired, 19 | field: PropTypes.string, 20 | } 21 | 22 | static defaultProps = { 23 | field: 'field_form', 24 | } 25 | 26 | constructor(props) { 27 | super(props); 28 | 29 | this.state = { 30 | loadState: LOAD_STATES.LOADING, 31 | }; 32 | 33 | this.fetchForm = this.fetchForm.bind(this); 34 | } 35 | 36 | componentDidMount() { 37 | this.fetchForm(); 38 | } 39 | 40 | componentDidUpdate(prevProps) { 41 | if(prevProps.baseUrl !== this.props.baseUrl || prevProps.path !== this.props.path || prevProps.field !== this.props.field) { 42 | this.fetchForm(); 43 | } 44 | } 45 | 46 | fetchForm() { 47 | if(!this.props.baseUrl) { 48 | this.setState({ loadState: LOAD_STATES.ERROR }); 49 | return; 50 | } 51 | 52 | // console.info('Loading..'); 53 | this.setState({ loadState: LOAD_STATES.LOADING }); 54 | 55 | this.fetch = fetch(`${this.props.baseUrl}/url?url=/${this.props.path}&_format=json`) 56 | .then(data => data.json()) 57 | .then((json) => { 58 | if(typeof json.content !== 'object' || (!this.props.field && !json.content.form_id) || (this.props.field && !json.content[this.props.field].form_id)) { 59 | throw Error('Combination of url && field didn\'t work'); 60 | } 61 | // console.info('JSON!', json.content[this.props.field]); 62 | this.setState({ 63 | form: this.props.field ? json.content[this.props.field] : json.content, 64 | loadState: LOAD_STATES.SUCCESS, 65 | }); 66 | }) 67 | .catch((error) => { 68 | console.error('Error loading form:', error); 69 | this.setState({ loadState: LOAD_STATES.ERROR }); 70 | }); 71 | } 72 | 73 | render() { 74 | switch(this.state.loadState) { 75 | case LOAD_STATES.LOADING: 76 | return
LOADING..
; 77 | case LOAD_STATES.ERROR: 78 | return
ERROR LOADING FORM.
; 79 | case LOAD_STATES.SUCCESS: 80 | return ( { 88 | const block = false; 89 | console.info('submithook, block:', block); 90 | return Promise.resolve({ 91 | submit: !block, 92 | errors: ['Custom error, override submit'], 93 | }); 94 | }} 95 | onAfterSubmit={() => console.info('afterhook')} 96 | />); 97 | default: 98 | return null; 99 | } 100 | } 101 | } 102 | 103 | export default FetchForm; 104 | -------------------------------------------------------------------------------- /src/Fieldset/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import getNested from 'get-nested'; 4 | import { observer } from 'mobx-react'; 5 | import WebformElement from '../WebformElement'; 6 | import FormStore from '../Observables/Form'; 7 | // styled 8 | import StyledFieldset from './styled/fieldset'; 9 | import Legend from './styled/legend'; 10 | import FieldsetInner from './styled/fieldset-inner'; 11 | 12 | @observer 13 | class Fieldset extends Component { 14 | static meta = { 15 | wrapper: StyledFieldset, 16 | wrapperProps: { className: 'hrw-fieldset hrw-form-row' }, 17 | label: Legend, 18 | labelVisibility: 'invisible', 19 | hasValue: false, 20 | }; 21 | 22 | static propTypes = { 23 | field: PropTypes.shape({ 24 | composite_elements: PropTypes.array, 25 | '#webform_key': PropTypes.string.isRequired, 26 | }).isRequired, 27 | formStore: PropTypes.instanceOf(FormStore).isRequired, 28 | webformElement: PropTypes.instanceOf(WebformElement).isRequired, 29 | onChange: PropTypes.func.isRequired, 30 | onBlur: PropTypes.func.isRequired, 31 | settings: PropTypes.shape().isRequired, 32 | children: PropTypes.node, 33 | webformSettings: PropTypes.shape().isRequired, 34 | style: PropTypes.shape(), 35 | webformPage: PropTypes.string, 36 | form: PropTypes.shape({ 37 | settings: PropTypes.object.isRequired, 38 | }).isRequired, 39 | status: PropTypes.string.isRequired, 40 | childrenAdjacent: PropTypes.bool, 41 | url: PropTypes.string.isRequired, 42 | }; 43 | 44 | static defaultProps = { 45 | children: [], 46 | style: {}, 47 | webformPage: null, 48 | childrenAdjacent: false, 49 | }; 50 | 51 | /** 52 | * Get value from field based on key. Checks overridden values, and if so, provides master value 53 | * 54 | * @param field Full field object 55 | * @param key Key without leading #, e.g. pattern 56 | * 57 | * @return any Value of field or master, depending of overrides. 58 | */ 59 | static getValue(field, key) { 60 | if(key.startsWith('#')) { 61 | throw new Error('Please use the field without leading hash.'); 62 | } 63 | 64 | if(field[`#override_${key}`]) { 65 | return field[`#${key}`]; 66 | } 67 | 68 | return getNested(() => field.composite_elements.find(element => element['#key'] === key)['#default_value'], null); 69 | } 70 | 71 | getFormElements() { 72 | const formElements = this.props.field.composite_elements || []; 73 | return formElements.map(field => ( 74 | 88 | )); 89 | } 90 | 91 | render() { 92 | const formElements = this.getFormElements(); 93 | return ( 94 | 95 | {!this.props.childrenAdjacent && this.props.children} 96 | {formElements} 97 | {this.props.childrenAdjacent && this.props.children} 98 | 99 | ); 100 | } 101 | } 102 | 103 | export default Fieldset; 104 | -------------------------------------------------------------------------------- /src/Fieldset/styled/fieldset-inner.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export default styled.div` 4 | & > :last-child { 5 | margin-bottom: 0; 6 | } 7 | `; 8 | -------------------------------------------------------------------------------- /src/Fieldset/styled/fieldset.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import FormRow from '../../WebformElement/styled/form-row'; 3 | 4 | export default styled.fieldset` 5 | box-sizing: border-box; 6 | margin-bottom: calc(${p => p.theme.spacingUnitFieldset} * 1.5); 7 | border: ${p => p.theme.fieldsetBorder}; 8 | border-radius: ${p => p.theme.borderRadius}; 9 | background-color: ${p => p.theme.fieldsetBgColor}; 10 | padding: calc(${p => p.theme.spacingUnitFieldset} * 1) calc(${p => p.theme.spacingUnitFieldset} * 1); 11 | 12 | @media screen and (min-width: 768px) { 13 | padding: calc(${p => p.theme.spacingUnitFieldset} * 2.5) calc(${p => p.theme.spacingUnitFieldset} * 2.5); 14 | } 15 | 16 | &${FormRow} { 17 | border: none; 18 | } 19 | `; 20 | -------------------------------------------------------------------------------- /src/Fieldset/styled/legend.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import Label from '../../WebformElement/styled/label'; 3 | 4 | const Legend = Label.withComponent('legend'); 5 | 6 | export default styled(Legend)` 7 | width: 100%; 8 | float: left; 9 | padding: 0; 10 | margin-bottom: calc(${p => p.theme.spacingUnitFieldset} / 2); 11 | 12 | ${p => p.labelDisplay === 'inline' && ` 13 | @media (min-width: 768px) { 14 | width: ${p.theme.inlineLabelWidth}; 15 | padding-right: calc(${p.theme.spacingUnit} / 2); 16 | } 17 | `} 18 | 19 | ${p => p.labelDisplay === 'invisible' && ` 20 | display: none; 21 | `} 22 | 23 | ${p => p.labelDisplay === 'before' && ` 24 | display: block; 25 | float: none; 26 | width: 100%; 27 | `} 28 | `; 29 | -------------------------------------------------------------------------------- /src/Fieldset/styled/wrapper.js: -------------------------------------------------------------------------------- 1 | import FormRow from '../../WebformElement/styled/form-row'; 2 | 3 | const Fieldset = FormRow.withComponent('fieldset'); 4 | 5 | export default Fieldset; 6 | -------------------------------------------------------------------------------- /src/FormRow/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Fieldset from '../Fieldset'; 3 | // styled 4 | import FieldsetFormRow from '../Fieldset/styled/wrapper'; 5 | 6 | const FormRow = props => ( 7 |
10 | ); 11 | FormRow.meta = { 12 | wrapper: FieldsetFormRow, 13 | label: Fieldset.meta.label, 14 | wrapperProps: Fieldset.meta.wrapperProps, 15 | labelVisibility: Fieldset.meta.labelVisibility, 16 | hasValue: false, 17 | }; 18 | 19 | export default FormRow; 20 | -------------------------------------------------------------------------------- /src/Hidden/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { observer } from 'mobx-react'; 4 | import Converse from '../Converse'; 5 | import Form from '../Observables/Form'; 6 | 7 | // styled 8 | import StyledHidden from './styled/hidden'; 9 | 10 | const Hidden = props => ( 11 | 16 | ); 17 | 18 | /** 19 | * ~financial_amount_monthly_suggestion|0| + financial_amount_monthly|0| + financial_amount_yearly_suggestion|0| + financial_amount_yearly|0|~ 20 | * @param value 21 | * @param values 22 | * @returns {*} 23 | */ 24 | Hidden.rewriteValue = (value, values) => { 25 | if(!value || !value.toString().startsWith('~') || !value.toString().endsWith('~')) return value; 26 | return Converse.calculateFormula(values, value.slice(1, -1)); 27 | }; 28 | 29 | Hidden.propTypes = { 30 | value: PropTypes.oneOfType([PropTypes.string.isRequired, PropTypes.number.isRequired]).isRequired, 31 | formStore: PropTypes.instanceOf(Form).isRequired, 32 | }; 33 | 34 | const DecoratedHidden = observer(Hidden); 35 | 36 | DecoratedHidden.meta = { 37 | wrapper: 'div', 38 | wrapperProps: { style: { display: 'none' } }, 39 | }; 40 | 41 | DecoratedHidden.rewriteValue = Hidden.rewriteValue; 42 | 43 | export default DecoratedHidden; 44 | -------------------------------------------------------------------------------- /src/Hidden/styled/hidden.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import Input from '../../Input/styled/input'; 3 | 4 | export default styled(Input)` 5 | display: none; 6 | `; 7 | -------------------------------------------------------------------------------- /src/IBAN/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { isValidIBAN } from 'ibantools'; 4 | import { observer } from 'mobx-react'; 5 | import Input from '../Input'; 6 | import rules from '../Webform/rules'; 7 | import RuleHint from '../RuleHint'; 8 | import WebformUtils from '../WebformUtils'; 9 | 10 | @observer 11 | class IBAN extends Component { 12 | static meta = { 13 | validations: [el => `iban_${el.key}`], 14 | }; 15 | 16 | static propTypes = { 17 | field: PropTypes.shape({ 18 | '#webform_key': PropTypes.string.isRequired, 19 | '#ibanError': PropTypes.string, 20 | }).isRequired, 21 | settings: PropTypes.shape().isRequired, 22 | }; 23 | 24 | static rewriteValue(value) { 25 | return (value || '').replace(/\s/g, '').toUpperCase(); 26 | } 27 | 28 | constructor(props) { 29 | super(props); 30 | 31 | rules.delete(`pattern_${props.field['#webform_key']}`); // IBAN has own validation, pattern is only used in back end. 32 | 33 | rules.set(`iban_${props.field['#webform_key']}`, { 34 | rule: value => isValidIBAN(value), 35 | hint: () => 36 | , 37 | shouldValidate: field => field.isBlurred, 38 | }); 39 | } 40 | 41 | render() { 42 | return ( 43 | 47 | ); 48 | } 49 | } 50 | 51 | export default IBAN; 52 | -------------------------------------------------------------------------------- /src/Input/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { observer } from 'mobx-react'; 4 | import Parser from '../Parser'; 5 | import BaseInput from '../BaseInput'; 6 | import WebformElement from '../WebformElement'; 7 | // styled 8 | import Wrapper from './styled/wrapper'; 9 | import InnerWrapper from './styled/inner-wrapper'; 10 | import Prefix from './styled/prefix'; 11 | import Suffix from './styled/suffix'; 12 | import ValidationIcon from './styled/validation-icon'; 13 | 14 | @observer 15 | class Input extends Component { 16 | static propTypes = { 17 | field: PropTypes.shape({ 18 | '#type': PropTypes.string.isRequired, 19 | '#placeholder': PropTypes.string, 20 | '#field_prefix': PropTypes.string, 21 | '#field_suffix': PropTypes.string, 22 | '#webform_key': PropTypes.string.isRequired, 23 | '#required': PropTypes.bool, 24 | '#mask': PropTypes.oneOfType([ 25 | PropTypes.string, 26 | PropTypes.bool, 27 | ]), 28 | '#alwaysShowMask': PropTypes.oneOfType([ 29 | PropTypes.string, 30 | PropTypes.bool, 31 | ]), 32 | '#min': PropTypes.string, 33 | '#max': PropTypes.string, 34 | '#step': PropTypes.string, 35 | '#attributes': PropTypes.oneOfType([ 36 | PropTypes.shape({ 37 | autoComplete: PropTypes.string, 38 | }), 39 | PropTypes.array, 40 | ]), 41 | }).isRequired, 42 | webformElement: PropTypes.instanceOf(WebformElement).isRequired, 43 | }; 44 | 45 | getLabelDisplay() { 46 | return this.props.webformElement.getLabelDisplay(); 47 | } 48 | 49 | renderTextContent(selector) { 50 | const value = this.props.field[`#field_${selector}`]; 51 | if(value) { 52 | const TextContent = selector === 'prefix' ? Prefix : Suffix; 53 | 54 | return {Parser(value)}; 55 | } 56 | return null; 57 | } 58 | 59 | render() { 60 | return ( 61 |
62 | 63 | 64 | {this.renderTextContent('prefix')} 65 | 69 | {this.renderTextContent('suffix')} 70 | 71 | 72 | 73 |
74 | ); 75 | } 76 | } 77 | 78 | export default Input; 79 | -------------------------------------------------------------------------------- /src/Input/styled/inner-wrapper.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export default styled.div` 4 | display: flex; 5 | justify-content: space-between; 6 | `; 7 | -------------------------------------------------------------------------------- /src/Input/styled/input.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import BaseInput from '../../BaseInput/styled/input'; 3 | 4 | export default styled(BaseInput)` 5 | 6 | `; 7 | -------------------------------------------------------------------------------- /src/Input/styled/prefix.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import TextContent from './text-content'; 3 | 4 | export default styled(TextContent)` 5 | margin-right: 0.5em; 6 | `; 7 | -------------------------------------------------------------------------------- /src/Input/styled/suffix.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import TextContent from './text-content'; 3 | 4 | export default styled(TextContent)` 5 | margin-left: 0.5em; 6 | `; 7 | -------------------------------------------------------------------------------- /src/Input/styled/text-content.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export default styled.span` 4 | display: inline-block; 5 | align-self: center; 6 | `; 7 | -------------------------------------------------------------------------------- /src/Input/styled/validation-icon.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import ValidationIcon from '../../BaseInput/styled/validation-icon'; 3 | 4 | export default styled(ValidationIcon)` 5 | 6 | `; 7 | -------------------------------------------------------------------------------- /src/Input/styled/wrapper.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export default styled.div` 4 | display: inline-block; 5 | 6 | ${p => p.labelDisplay === 'inline' && ` 7 | width: ${p.theme.inputWidth}; 8 | `} 9 | 10 | ${p => p.labelDisplay === 'before' && ` 11 | width: ${p.theme.inputWidth}; 12 | max-width: 100%; 13 | 14 | @media (min-width: 768px) { 15 | width: calc(${p.theme.inlineLabelWidth} + ${p.theme.inputWidth}); 16 | } 17 | `} 18 | `; 19 | -------------------------------------------------------------------------------- /src/LookUp/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import fetch from 'fetch-everywhere'; 4 | import getNested from 'get-nested'; 5 | import { get, set } from 'mobx'; 6 | import FormStore from '../Observables/Form'; 7 | 8 | function composeLookUp(LookUpComponent) { 9 | return class extends Component { 10 | static meta = LookUpComponent.meta || {}; 11 | 12 | static propTypes = { 13 | formStore: PropTypes.instanceOf(FormStore).isRequired, 14 | field: PropTypes.shape({ 15 | '#webform_key': PropTypes.string.isRequired, 16 | composite_elements: PropTypes.arrayOf(PropTypes.shape({ 17 | '#webform_key': PropTypes.string.isRequired, 18 | '#default_value': PropTypes.string, 19 | })), 20 | }).isRequired, 21 | onBlur: PropTypes.func.isRequired, 22 | onChange: PropTypes.func.isRequired, 23 | }; 24 | 25 | static rewriteValue = LookUpComponent.rewriteValue; 26 | 27 | constructor(props) { 28 | super(props); 29 | 30 | this.formKeySuffix = `-${props.field['#webform_key']}`; 31 | 32 | /** 33 | * Example: { 34 | * street: { 35 | * formKey: 'street', // #webform_key in Drupal 36 | * apiValue: response => response.street, // Method that receives the response object, and returns the value to pre-fill or false if not to pre-fill. 37 | * hideField: true, // Boolean to determine default state of field. 38 | * triggerLookUp: false, // Boolean to determine whether blurring this field should trigger the look-up. 39 | * }, 40 | * } 41 | */ 42 | this.lookUpFields = {}; 43 | 44 | this.lookUpOnBlurOnly = false; 45 | 46 | this.lookUpBase = null; 47 | this.el = null; 48 | 49 | this.state = { 50 | query: '', 51 | }; 52 | 53 | this.onChange = this.onChange.bind(this); 54 | this.onBlur = this.onBlur.bind(this); 55 | this.onMount = this.onMount.bind(this); 56 | this.getField = this.getField.bind(this); 57 | this.getState = this.getState.bind(this); 58 | this.fieldIterator = this.fieldIterator.bind(this); 59 | this.triggerLookUp = this.triggerLookUp.bind(this); 60 | this.registerLookUp = this.registerLookUp.bind(this); 61 | } 62 | 63 | componentDidMount() { 64 | this.setFieldVisibility(true); 65 | this.setManualOverride(false); 66 | } 67 | 68 | onMount(el) { 69 | if(el) { 70 | this.el = el; 71 | this.lookUpBase = el.lookUpBase; 72 | this.lookUpFields = el.lookUpFields || {}; 73 | } 74 | } 75 | 76 | onBlur(e) { 77 | const triggerElement = Object.values(this.lookUpFields).find(element => element.formKey === e.target.name); 78 | if(!triggerElement || !triggerElement.triggerLookUp) { 79 | return; 80 | } 81 | 82 | this.triggerLookUp(); 83 | 84 | this.props.onBlur(e); 85 | } 86 | 87 | onChange(e) { 88 | const triggerElement = Object.values(this.lookUpFields).find(element => element.formKey === e.target.name); 89 | if(triggerElement && !triggerElement.triggerLookUp) { 90 | this.setManualOverride(true); 91 | } 92 | this.props.onChange(e); 93 | 94 | if(!triggerElement || this.lookUpOnBlurOnly) return; 95 | 96 | const field = this.props.formStore.getField(triggerElement.formKey); 97 | 98 | if(!field) return; 99 | 100 | const lookUpKey = this.el.getLookUpKey(); 101 | const lookUp = get(field.lookUps, lookUpKey); 102 | if(lookUp && lookUp.lookUpSent) this.triggerLookUp(); 103 | } 104 | 105 | setFieldVisibility() { 106 | const lookUpKey = this.el.getLookUpKey(); 107 | this.fieldIterator((field, element) => { 108 | const lookUp = get(field.lookUps, lookUpKey); 109 | if(lookUp) { 110 | set(field.lookUps, lookUpKey, { 111 | ...lookUp, 112 | lookUpHide: !!element.hideField, 113 | lookUpDisabled: !!element.disableField, 114 | }); 115 | } 116 | }); 117 | } 118 | 119 | setManualOverride(override) { 120 | const { field } = this.getField('manualOverride'); 121 | if(field) { 122 | field.value = override.toString(); 123 | } 124 | } 125 | 126 | /** 127 | * @param elementKey 128 | * @returns {Boolean | {field: Field, element}} 129 | */ 130 | getField(elementKey) { 131 | if(!this.lookUpFields) { 132 | return false; 133 | } 134 | const element = this.lookUpFields[elementKey]; 135 | if(!element) { 136 | return false; 137 | } 138 | const field = this.props.formStore.getField(element.formKey); 139 | return { 140 | element, 141 | field, 142 | }; 143 | } 144 | 145 | getFields() { 146 | if(!this.lookUpFields) { 147 | return []; 148 | } 149 | return Object.values(this.lookUpFields).map(element => this.props.formStore.getField(element.formKey)); 150 | } 151 | 152 | getState() { 153 | return this.state; 154 | } 155 | 156 | registerLookUp(lookUpKey, lookUpFields, lookUpOnBlurOnly = false) { 157 | this.lookUpFields = lookUpFields; 158 | this.lookUpOnBlurOnly = lookUpOnBlurOnly; 159 | this.fieldIterator((field, element) => { 160 | set(field.lookUps, lookUpKey, { 161 | lookUpSent: false, 162 | lookUpSuccessful: true, 163 | lookUpHide: element.lookUps, 164 | lookUpDisabled: false, 165 | }); 166 | }); 167 | } 168 | 169 | triggerLookUp() { 170 | const fields = {}; 171 | this.fieldIterator((field, element) => { 172 | const value = field.value; 173 | if(!field.empty) { 174 | fields[element.elementKey] = value; 175 | } 176 | }); 177 | 178 | const lookUpObject = this.el.prepareLookUp ? this.el.prepareLookUp(fields) : false; 179 | if(lookUpObject && lookUpObject.query !== this.state.query) { 180 | this.lookUp(lookUpObject); 181 | } 182 | } 183 | 184 | fieldIterator(cb) { 185 | let stop = false; 186 | if(!this.lookUpFields) { 187 | return false; 188 | } 189 | Object.keys(this.lookUpFields).forEach((elementKey) => { 190 | if(stop === true) { 191 | return; 192 | } 193 | const { element, field } = this.getField(elementKey); 194 | if(field) { 195 | stop = cb(field, element) === false; 196 | } 197 | }); 198 | return true; 199 | } 200 | 201 | lookUp(request) { 202 | const { query, headers = {} } = request; 203 | this.setState({ query }); 204 | 205 | // eslint-disable-next-line no-undef 206 | const headersObject = new Headers(headers); 207 | 208 | fetch(`${this.lookUpBase}${query}`, { 209 | headers: headersObject, 210 | }) 211 | .then(res => res.json()) 212 | .then(response => this.processResponse(response, request)) 213 | .catch(response => this.processResponse(response, request)); 214 | } 215 | 216 | processResponse(jsonResponse, { 217 | query, 218 | checkResponse = () => false, 219 | isSuccessful = () => true, 220 | }) { 221 | if(this.state.query !== query) { 222 | console.warn('A lookUp query was returned, but we already fired another one. Ignoring this result.', query, jsonResponse); 223 | return; 224 | } 225 | 226 | const response = checkResponse(jsonResponse); 227 | const successful = isSuccessful(response); 228 | 229 | const lookUpKey = this.el.getLookUpKey(); 230 | const lookUpField = this.props.formStore.getField(this.props.field['#webform_key']); 231 | 232 | set(lookUpField.lookUps, lookUpKey, { 233 | ...get(lookUpField.lookUps, lookUpKey), 234 | lookUpSent: true, 235 | lookUpSuccessful: successful, 236 | }); 237 | 238 | // Let every field know the lookUp was sent, and if it was successful 239 | Object.keys(this.lookUpFields).forEach((elementKey) => { 240 | const { field } = this.getField(elementKey); 241 | if(field) { 242 | set(field.lookUps, lookUpKey, { 243 | ...get(field.lookUps, lookUpKey), 244 | lookUpSent: true, 245 | lookUpSuccessful: successful, 246 | }); 247 | } 248 | }); 249 | 250 | this.fieldIterator((field, element) => { 251 | const value = getNested(() => element.apiValue(response)); 252 | if(value) { 253 | field.value = value; 254 | field.isBlurred = true; 255 | } 256 | }); 257 | 258 | this.setManualOverride(!successful); // If successful, set to false since values are overridden 259 | 260 | if(this.el.lookUpCallback) { 261 | this.el.lookUpCallback(response); 262 | } 263 | } 264 | 265 | render() { 266 | return ( 267 | 280 | ); 281 | } 282 | }; 283 | } 284 | 285 | export default composeLookUp; 286 | -------------------------------------------------------------------------------- /src/LookUp/styled/validation-message.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export default styled.p` 4 | margin: 0; 5 | font-size: 0.8em; 6 | padding-bottom: calc(${p => p.theme.spacingUnit}); 7 | line-height: 1.2em; 8 | `; 9 | -------------------------------------------------------------------------------- /src/Number/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { observer } from 'mobx-react'; 4 | import Input from '../Input'; 5 | import WebformUtils from '../WebformUtils'; 6 | 7 | @observer 8 | class Number extends Component { 9 | 10 | /** 11 | * @param {Field} field Field 12 | */ 13 | static validate(field) { 14 | const numberError = WebformUtils.getCustomValue(field.element, 'numberError', field.formStore.form.settings); 15 | 16 | if(field.element['#step']) { 17 | const dec = Math.max(...[field.element['#step'], field.value].map(n => `${n}.`.split('.')[1].length)); 18 | if(Math.round(field.value * (10 ** dec)) % Math.round(field.element['#step'] * (10 ** dec)) !== 0) { 19 | return [numberError]; 20 | } 21 | } 22 | 23 | if(field.element['#min'] && parseFloat(field.value) < parseFloat(field.element['#min'])) { 24 | return [numberError]; 25 | } 26 | 27 | if(field.element['#max'] && parseFloat(field.value) > parseFloat(field.element['#max'])) { 28 | return [numberError]; 29 | } 30 | 31 | return []; 32 | } 33 | 34 | static propTypes = { 35 | onChange: PropTypes.func.isRequired, 36 | onBlur: PropTypes.func.isRequired, 37 | }; 38 | 39 | constructor(props) { 40 | super(props); 41 | 42 | this.onChange = this.onChange.bind(this); 43 | } 44 | 45 | onChange(e) { 46 | this.props.onChange(e); 47 | } 48 | 49 | render() { 50 | return ( 51 | 57 | ); 58 | } 59 | } 60 | 61 | export default Number; 62 | -------------------------------------------------------------------------------- /src/Observables/Field.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { observable, computed, values } from 'mobx'; 3 | import getNested from 'get-nested'; 4 | import RuleHint from '../RuleHint'; 5 | import rules from '../Webform/rules'; 6 | import { components } from '../index'; 7 | import { formatConditionals, checkConditionals, supportedActions } from '../Webform/conditionals'; 8 | import WebformUtils from '../WebformUtils'; 9 | 10 | class Field { 11 | key; 12 | element; 13 | 14 | /** 15 | * @var Form 16 | */ 17 | formStore; 18 | componentClass; 19 | conditionals; 20 | 21 | /** 22 | * @var {Field} 23 | */ 24 | parent; 25 | /** 26 | * 27 | * @type {Array.} 28 | */ 29 | parents = []; 30 | 31 | page = 'no-page'; 32 | 33 | /** 34 | * @deprecated 35 | * Better not use the component at all. All logic should be in this file 36 | */ 37 | @observable component = null; 38 | 39 | /** 40 | * By default, the value is ''. 41 | * This value gets updated in the WebformElement component. 42 | * @type {string} 43 | */ 44 | @observable value = ''; 45 | 46 | /** 47 | * As soon as the field was ever blurred or an user tries to submit an page, this value becomes true. 48 | * It can be used to determine if the field should display a checkmark or errors etc. 49 | * @type {boolean} 50 | */ 51 | @observable isBlurred = false; 52 | 53 | @observable lookUps = {}; 54 | 55 | constructor(formStore, element, parent) { 56 | if(!element['#webform_key']) { 57 | throw new Error('Element key is required'); 58 | } 59 | 60 | this.value = element['#value'] || element['#default_value'] || ''; 61 | 62 | this.formStore = formStore; 63 | this.key = element['#webform_key']; 64 | this.element = element; 65 | this.componentClass = components[element['#type']] || components.default; 66 | this.conditionals = formatConditionals(element['#states']); 67 | this.parent = parent; 68 | if(parent) { 69 | this.parents = [parent, ...this.parent.parents]; 70 | } 71 | if(element['#type'] === 'webform_wizard_page') { 72 | this.page = this.key; 73 | } else if(parent) { 74 | this.page = this.parent.page; 75 | } 76 | 77 | rules.set(`${supportedActions.required}_${this.key}`, { 78 | rule: () => !this.isEmpty && 79 | (!this.componentClass.isEmpty || !this.componentClass.isEmpty(this)), 80 | hint: value => 81 | (), 90 | shouldValidate: field => field.isBlurred, 91 | }); 92 | 93 | const pattern = this.element['#pattern']; 94 | if(pattern) { 95 | rules.set(`pattern_${this.key}`, { 96 | rule: (value = '') => new RegExp(pattern).test(value) || this.isEmpty, 97 | hint: (value) => { 98 | const patternError = WebformUtils.getCustomValue(this.element, 'patternError', this.formStore.form.settings); 99 | const populatedPatternError = getNested(() => this.formStore.form.settings.custom_elements.patternError['#options'][patternError], this.element['#patternError'] || WebformUtils.getErrorMessage(element, '#required_error') || 'Please enter a valid value.'); 100 | return ; 101 | }, 102 | shouldValidate: field => field.isBlurred && WebformUtils.validateRule(rules.get(`${supportedActions.required}_${this.key}`), field), 103 | }); 104 | } 105 | } 106 | 107 | /** 108 | * Checks is the field is currently valid. 109 | * @returns {boolean} 110 | */ 111 | @computed get valid() { 112 | return this.errors.length === 0; 113 | } 114 | 115 | @computed get validations() { 116 | const validations = [ 117 | this.required ? `${supportedActions.required}_${this.key}` : null, 118 | this.element['#pattern'] ? `pattern_${this.key}` : null, 119 | ]; 120 | 121 | const populatedValidations = validations.map(validation => rules.get(validation) || null); 122 | 123 | const customValidationKeys = getNested(() => this.componentClass.meta.validations, []) 124 | .map(validation => validation(this) || null); 125 | 126 | populatedValidations.push(...customValidationKeys.map(k => rules.get(k)).filter(k => k)); 127 | 128 | return populatedValidations; 129 | } 130 | 131 | /** 132 | * This is used to check if the 'required' validation is met. 133 | * @returns {boolean} 134 | */ 135 | @computed get isEmpty() { 136 | if(this.value === '' || this.value === false) { 137 | return true; 138 | } 139 | 140 | if(this.element['#mask']) { 141 | const mask = this.element['#mask'].replace(/9|a|A/g, '_'); 142 | return this.value === mask; 143 | } 144 | 145 | return false; 146 | } 147 | 148 | @computed get errors() { 149 | const validations = this.validations; 150 | const field = this; 151 | 152 | // Field is always valid, if there is none, OR the field is invisible, OR a parent is invisible. 153 | if(!field || !this.visible) { 154 | return []; 155 | } 156 | 157 | const errors = []; 158 | 159 | // Check if there is a static 'validate' function on the component class. 160 | if(typeof field.componentClass.validate === 'function') { 161 | errors.push(...field.componentClass.validate(this)); 162 | } 163 | 164 | const fails = validations ? validations.filter(validation => !WebformUtils.validateRule(validation, field)) : []; 165 | 166 | errors.push(...fails.map(rule => rule.hint(this.value))); 167 | 168 | return errors; 169 | } 170 | 171 | @computed get visible() { 172 | const lookUps = values(this.lookUps); 173 | if(lookUps.length > 0 && lookUps.every(l => l.lookUpHide && !l.lookUpSent)) return false; 174 | return (this.parent ? this.parent.visible : true) && (typeof this.conditionalLogicResults.visible === 'undefined') ? true : this.conditionalLogicResults.visible; 175 | } 176 | 177 | @computed get required() { 178 | return (typeof this.conditionalLogicResults.required === 'undefined') ? !!this.element['#required'] : this.conditionalLogicResults.required; 179 | } 180 | 181 | @computed get enabled() { 182 | const lookUps = values(this.lookUps); 183 | if(lookUps.length > 0 && lookUps.every(l => l.lookUpDisabled)) return false; 184 | return (typeof this.conditionalLogicResults.enabled === 'undefined') ? true : this.conditionalLogicResults.enabled; 185 | } 186 | 187 | @computed get conditionalLogicResults() { 188 | return checkConditionals(this.formStore, this.key); 189 | } 190 | 191 | /** 192 | * @deprecated 193 | * Use field.value. 194 | */ 195 | getValue() { 196 | return this.value; 197 | } 198 | } 199 | 200 | export default Field; 201 | -------------------------------------------------------------------------------- /src/Observables/Form.js: -------------------------------------------------------------------------------- 1 | import { observable, computed, action } from 'mobx'; 2 | import getNested from 'get-nested'; 3 | import Field from './Field'; 4 | 5 | class Form { 6 | 7 | /** 8 | * The unique key for this form. 9 | */ 10 | key = null; 11 | 12 | /** 13 | * The raw data provided by Drupal. 14 | */ 15 | form; 16 | 17 | @observable settings = {}; 18 | 19 | /** 20 | * All fields in this form. 21 | * @type {Array.} 22 | */ 23 | @observable fields = []; 24 | 25 | @observable page = 'no-page'; 26 | 27 | /** 28 | * All visible fields in this form. 29 | * @type {Array.} 30 | */ 31 | @computed get visibleFields() { 32 | return this.fields.filter(field => field.visible); 33 | } 34 | 35 | /** 36 | * All visible fields in this form of the current page. 37 | * @type {Array.} 38 | */ 39 | @computed get visibleFieldsOfCurrentPage() { 40 | return this.visibleFields.filter(field => field.page === this.page); 41 | } 42 | 43 | 44 | constructor(formId, { settings, form, defaultValues = {} }) { 45 | this.key = formId; 46 | this.form = form; 47 | this.settings = settings; 48 | 49 | // Create all fields. 50 | this.form.elements.forEach(element => this.createField(element)); 51 | 52 | // Set all default values. 53 | Object.keys(defaultValues).forEach((key) => { 54 | const field = this.getField(key); 55 | if(field) field.value = defaultValues[key]; 56 | }); 57 | 58 | // Start at the first page 59 | const page = this.fields.find(f => f.page !== 'no-page'); 60 | if(page) this.page = page.page; 61 | } 62 | 63 | @action.bound 64 | createField(element, parent) { 65 | const field = new Field(this, element, parent); 66 | if(field.componentClass) this.fields.push(field); 67 | 68 | // If it has children, create them too. 69 | if(element.composite_elements) { 70 | element.composite_elements.forEach(e => this.createField(e, field)); 71 | } 72 | } 73 | 74 | /** 75 | * @param key 76 | * @returns {Field} 77 | */ 78 | getField(key) { 79 | return this.fields.find(field => field.key === key); 80 | } 81 | 82 | @computed get values() { 83 | const values = {}; 84 | 85 | this.visibleFields.filter(field => !field.isEmpty).forEach((field) => { 86 | values[field.key] = typeof field.componentClass.rewriteValue === 'function' ? field.componentClass.rewriteValue(field.value, values) : field.value; 87 | }); 88 | 89 | return values; 90 | } 91 | 92 | @computed get valid() { 93 | return !this.fields.find(field => field.visible && !field.valid); 94 | } 95 | 96 | isValid(page) { 97 | const invalid = this.fields.find((field) => { 98 | // Only check the current page 99 | if(!field.component || field.component.props.webformPage !== page) return false; 100 | 101 | // If an error was found, return true 102 | return !field.valid; 103 | }); 104 | 105 | // If an error was found, return false 106 | return !invalid; 107 | } 108 | 109 | @computed get tokens() { 110 | const tokens = {}; 111 | this.fields.forEach((field) => { 112 | tokens[field.key] = field.value; 113 | if(typeof field.componentClass.getTokens === 'function') { 114 | Object.assign(tokens, field.componentClass.getTokens(this, field)); 115 | } 116 | }); 117 | return tokens; 118 | } 119 | 120 | @observable isSubmitting = false; 121 | 122 | @action.bound 123 | isMultipage() { 124 | return (getNested(() => this.form.elements) || []).find(element => element['#webform_key'] === 'wizard_pages') !== undefined; 125 | } 126 | 127 | } 128 | 129 | export default Form; 130 | -------------------------------------------------------------------------------- /src/Observables/Forms.js: -------------------------------------------------------------------------------- 1 | import { observable, action } from 'mobx'; 2 | import Form from './Form'; 3 | 4 | class Forms { 5 | 6 | @observable forms = {}; 7 | 8 | getForm(formID, options) { 9 | if(this.forms[formID]) return this.forms[formID]; 10 | else if(!options) throw Error('Tried to get a form ', formID, 'but the form was not initiated.'); 11 | 12 | this.forms[formID] = Forms.createForm(formID, options); 13 | 14 | return this.forms[formID]; 15 | } 16 | 17 | @action 18 | deleteForm(formID) { 19 | delete this.forms[formID]; 20 | } 21 | 22 | @action 23 | static createForm(formID, options) { 24 | return new Form(formID, options); 25 | } 26 | } 27 | 28 | const form = new Forms(); 29 | 30 | export default form; 31 | -------------------------------------------------------------------------------- /src/ParagraphField/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { inject, observer } from 'mobx-react'; 4 | import Parser, { template } from '../Parser'; 5 | import FormStore from '../Observables/Form'; 6 | 7 | @inject('formStore') 8 | @observer 9 | class ParagraphField extends Component { 10 | static propTypes = { 11 | field: PropTypes.shape({ 12 | '#message_message': PropTypes.string, 13 | }).isRequired, 14 | formStore: PropTypes.instanceOf(FormStore).isRequired, 15 | }; 16 | 17 | getFormattedMarkup() { 18 | return Parser(template(this.props.formStore, this.props.field['#message_message'])); 19 | } 20 | 21 | render() { 22 | return ( 23 |

{this.getFormattedMarkup()}

24 | ); 25 | } 26 | } 27 | 28 | export default ParagraphField; 29 | -------------------------------------------------------------------------------- /src/Parser/index.js: -------------------------------------------------------------------------------- 1 | import Parser from 'react-html-parser'; 2 | import { Html5Entities } from 'html-entities'; 3 | import template from './template'; 4 | 5 | const entities = new Html5Entities(); 6 | 7 | export default function (string) { 8 | const decoded = entities.decode(string); 9 | return Parser(decoded); 10 | } 11 | 12 | export { template }; 13 | -------------------------------------------------------------------------------- /src/Parser/template.js: -------------------------------------------------------------------------------- 1 | const regex = /{{(\w+)}}/g; 2 | 3 | function parseTemplate(formStore, text) { 4 | // First check if it is really a string 5 | if(typeof text !== 'string' || typeof text.replace !== 'function') { 6 | console.info('Tried to template', text, 'but it doesn\'t have a replace function'); 7 | return text; 8 | } 9 | 10 | return text.replace(regex, (found, variable) => formStore.tokens[variable]); 11 | } 12 | 13 | export default parseTemplate; 14 | -------------------------------------------------------------------------------- /src/PhoneField/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | // styled 3 | import Phone from './styled/phone'; 4 | 5 | const PhoneField = props => ( 6 | 11 | ); 12 | 13 | export default PhoneField; 14 | -------------------------------------------------------------------------------- /src/PhoneField/styled/phone.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import Input from '../../Input'; 3 | 4 | export default styled(Input)` 5 | -webkit-appearance: none; 6 | `; 7 | -------------------------------------------------------------------------------- /src/RadioField/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import getNested from 'get-nested'; 4 | import Parser from '../Parser'; 5 | import WebformElement from '../WebformElement'; 6 | import Field from '../Observables/Field'; 7 | import Fieldset from '../Fieldset'; 8 | // styled 9 | import Wrapper from './styled/wrapper'; 10 | import RadioLabel from './styled/radio-label'; 11 | import Radio from './styled/radio'; 12 | import Indicator from './styled/indicator'; 13 | import FieldsetFormRow from '../Fieldset/styled/wrapper'; 14 | 15 | class RadioField extends Component { 16 | static meta = { 17 | wrapper: FieldsetFormRow, 18 | label: Fieldset.meta.label, 19 | wrapperProps: Fieldset.meta.wrapperProps, 20 | }; 21 | 22 | static propTypes = { 23 | field: PropTypes.shape({ 24 | '#required': PropTypes.bool, 25 | '#options': PropTypes.arrayOf(PropTypes.shape({ 26 | value: PropTypes.node, 27 | text: PropTypes.node, 28 | })), 29 | '#webform_key': PropTypes.string.isRequired, 30 | '#title_display': PropTypes.string, 31 | '#options_display': PropTypes.string, 32 | }).isRequired, 33 | value: PropTypes.oneOfType([ 34 | PropTypes.string, 35 | PropTypes.number, 36 | PropTypes.bool, 37 | ]).isRequired, 38 | webformElement: PropTypes.instanceOf(WebformElement).isRequired, 39 | onChange: PropTypes.func.isRequired, 40 | onBlur: PropTypes.func.isRequired, 41 | state: PropTypes.instanceOf(Field).isRequired, 42 | }; 43 | 44 | getOptionPositionDisplay() { 45 | return this.props.field['#options_display']; 46 | } 47 | 48 | getLabelPositionDisplay() { 49 | return this.props.field['#title_display']; 50 | } 51 | 52 | render() { 53 | const wrapperAttrs = { 54 | 'aria-invalid': this.props.webformElement.isValid() ? null : true, 55 | 'aria-required': this.props.field['#required'] ? true : null, 56 | }; 57 | 58 | return ( 59 | 64 | { 65 | getNested(() => this.props.field['#options'], []).map((option, index) => { 66 | const labelKey = `${this.props.field['#webform_key']}_${index}`; 67 | return ( 68 | 73 | 83 | 84 | {Parser(option.text)} 85 | 86 | ); 87 | }) 88 | } 89 | 90 | ); 91 | } 92 | } 93 | 94 | export default RadioField; 95 | -------------------------------------------------------------------------------- /src/RadioField/styled/indicator.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export default styled.span` 4 | content: ''; 5 | display: inline-block; 6 | width: ${p => p.theme.spacingUnitRadio}; 7 | height: ${p => p.theme.spacingUnitRadio}; 8 | margin-right: calc(${p => p.theme.spacingUnitRadio} / 2); 9 | background-color: ${p => p.theme.inputBgColor}; 10 | border: 1px solid ${p => p.theme.borderColor}; 11 | border-radius: 50%; 12 | `; 13 | -------------------------------------------------------------------------------- /src/RadioField/styled/radio-label.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export default styled.label` 4 | display: block; 5 | margin-bottom: calc(${p => p.theme.spacingUnit} / 3); 6 | line-height: ${p => p.theme.inputLineHeight}; 7 | 8 | ${p => [ 9 | 'side_by_side', 10 | 'two_columns', 11 | 'three_columns', 12 | ].includes(p.optionDisplay) && ` 13 | margin-right: calc(${p.theme.spacingUnit} / 1); 14 | display: inline-block; 15 | width: auto; 16 | `} 17 | `; 18 | -------------------------------------------------------------------------------- /src/RadioField/styled/radio.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import Indicator from './indicator'; 3 | 4 | export default styled.input` 5 | /* Reset anything that could peek out or interfere with dimensions, but don't use display:none for WCAG */ 6 | position: absolute; 7 | overflow: hidden; 8 | margin: 0; 9 | padding: 0; 10 | border: 0; 11 | outline: 0; 12 | opacity: 0; 13 | 14 | &:focus { 15 | & + ${Indicator} { 16 | box-shadow: 0 0 2px 3px ${p => p.theme.focusColor}; 17 | } 18 | } 19 | 20 | &:checked { 21 | & + ${Indicator} { 22 | position: relative; 23 | 24 | &::before { 25 | content: ''; 26 | position: absolute; 27 | top: 25%; 28 | left: 25%; 29 | width: 50%; 30 | height: 50%; 31 | background-color: ${p => p.theme.checkedColor}; 32 | border-radius: 50%; 33 | } 34 | } 35 | } 36 | `; 37 | -------------------------------------------------------------------------------- /src/RadioField/styled/wrapper.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export default styled.div` 4 | @media (min-width: 768px) { 5 | display: inline-block; 6 | float: left; 7 | } 8 | 9 | ${p => p.labelDisplay === 'inline' && ` 10 | width: calc(${p.theme.inlineLabelWidth} - (${p.theme.spacingUnit} / 2) - 0.5em); 11 | `} 12 | 13 | ${p => p.labelDisplay === 'before' && ` 14 | float: none; 15 | width: 100%; 16 | `} 17 | `; 18 | -------------------------------------------------------------------------------- /src/RangeField/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import WebformElement from '../WebformElement'; 4 | // styled 5 | import Wrapper from './styled/wrapper'; 6 | import Range from './styled/range'; 7 | import RangeValueWrapper from './styled/range-value-wrapper'; 8 | import RangeValue from './styled/range-value'; 9 | import ValidationIcon from './styled/validation-icon'; 10 | 11 | class RangeField extends Component { 12 | static propTypes = { 13 | field: PropTypes.shape({ 14 | '#title_display': PropTypes.string, 15 | '#min': PropTypes.string, 16 | '#max': PropTypes.string, 17 | }).isRequired, 18 | value: PropTypes.oneOfType([ 19 | PropTypes.string, 20 | PropTypes.number, 21 | PropTypes.bool, 22 | ]).isRequired, 23 | onChange: PropTypes.func.isRequired, 24 | webformElement: PropTypes.instanceOf(WebformElement).isRequired, 25 | }; 26 | 27 | componentDidMount() { 28 | // rewrite to 0 if not numeric 29 | const value = !isNaN(parseFloat(this.props.value)) && isFinite(this.props.value) ? this.props.value : 0; 30 | this.props.onChange(value); 31 | } 32 | 33 | getLabelPosition() { 34 | return this.props.field['#title_display']; 35 | } 36 | 37 | getPercentageValue() { 38 | const minValue = parseFloat(this.props.field['#min']); 39 | const range = parseFloat(this.props.field['#max']) - minValue; 40 | let rangeValue = (this.props.value - minValue) / range; 41 | rangeValue = isFinite(rangeValue) ? rangeValue : 0; 42 | rangeValue = rangeValue > 0 ? rangeValue : 0; 43 | 44 | return rangeValue * 100; 45 | } 46 | 47 | render() { 48 | return ( 49 |
50 | 51 | 55 | 56 | {this.props.value} 57 | 58 | 59 | 60 |
61 | ); 62 | } 63 | } 64 | 65 | export default RangeField; 66 | -------------------------------------------------------------------------------- /src/RangeField/styled/range-value-wrapper.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export default styled.span` 4 | box-sizing: border-box; 5 | position: relative; 6 | display: inline-block; 7 | padding-left: 7px; 8 | padding-right: 7px; 9 | width: 100%; 10 | `; 11 | -------------------------------------------------------------------------------- /src/RangeField/styled/range-value.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export default styled.span` 4 | display: inline-block; 5 | position: relative; 6 | line-height: 20px; 7 | text-align: center; 8 | border-radius: ${p => p.theme.borderRadius}; 9 | background: ${p => p.theme.borderColor}; 10 | padding: 5px 10px; 11 | margin-top: 7px; 12 | transform: translateX(-50%); 13 | 14 | &::after { 15 | content: ''; 16 | width: 0; 17 | height: 0; 18 | position: absolute; 19 | left: 50%; 20 | top: -7px; 21 | transform: translateX(-50%); 22 | border-left: 7px solid transparent; 23 | border-bottom: 7px solid ${p => p.theme.borderColor}; 24 | border-right: 7px solid transparent; 25 | } 26 | `; 27 | -------------------------------------------------------------------------------- /src/RangeField/styled/range.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import BaseInput from '../../BaseInput/styled/input'; 3 | 4 | export default styled(BaseInput)` 5 | color: #000; 6 | padding-left: 0; 7 | padding-right: 0; 8 | margin-left: 0; 9 | margin-right: 0; 10 | 11 | &:focus { 12 | box-shadow: none; 13 | } 14 | 15 | &::-webkit-slider-thumb, 16 | &::-moz-range-thumb, 17 | &::-ms-thumb { 18 | &:focus { 19 | box-shadow: 0 0 2px 3px ${p => p.theme.focusColor}; 20 | } 21 | } 22 | `; 23 | -------------------------------------------------------------------------------- /src/RangeField/styled/validation-icon.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import ValidationIcon from '../../BaseInput/styled/validation-icon'; 3 | 4 | export default styled(ValidationIcon)` 5 | position: absolute; 6 | right: 0; 7 | top: 10px; 8 | `; 9 | -------------------------------------------------------------------------------- /src/RangeField/styled/wrapper.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export default styled.div` 4 | position: relative; 5 | margin: 0 0 calc(${p => p.theme.spacingUnit} / 2); 6 | 7 | @media (min-width: 768px) { 8 | display: inline-block; 9 | float: left; 10 | } 11 | 12 | & input { 13 | width: 100%; 14 | } 15 | 16 | ${p => p.labelDisplay === 'inline' && ` 17 | width: calc(${p.theme.inputWidth} + 20px); 18 | padding-right: 20px; 19 | `} 20 | 21 | ${p => p.labelDisplay === 'before' && ` 22 | width: calc(${p.theme.inputWidth} + 20px); 23 | padding-right: 20px; 24 | 25 | @media (min-width: 768px) { 26 | width: calc(${p.theme.inlineLabelWidth} + ${p.theme.inputWidth} + 20px); 27 | } 28 | `} 29 | `; 30 | -------------------------------------------------------------------------------- /src/Relation/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import getNested from 'get-nested'; 3 | import PropTypes from 'prop-types'; 4 | import { observer } from 'mobx-react'; 5 | import { get } from 'mobx'; 6 | import composeLookUp from '../LookUp'; 7 | import Fieldset from '../Fieldset'; 8 | import RuleHint from '../RuleHint'; 9 | import rules from '../Webform/rules'; 10 | import FormStore from '../Observables/Form'; 11 | import WebformUtils from '../WebformUtils'; 12 | // styled 13 | import ValidationMessage from '../LookUp/styled/validation-message'; 14 | 15 | @observer 16 | class Relation extends Component { 17 | static meta = { 18 | labelVisibility: Fieldset.meta.labelVisibility, 19 | validations: [el => `relation_membership_${el.key}`], 20 | hasValue: false, 21 | }; 22 | 23 | static propTypes = { 24 | field: PropTypes.shape({ 25 | '#webform_key': PropTypes.string.isRequired, 26 | '#relationError': PropTypes.string, 27 | '#membership_validation': PropTypes.oneOfType([ 28 | PropTypes.number, 29 | PropTypes.bool, 30 | ]), 31 | composite_elements: PropTypes.arrayOf(PropTypes.shape()), 32 | }).isRequired, 33 | fields: PropTypes.arrayOf(PropTypes.shape()).isRequired, 34 | onBlur: PropTypes.func.isRequired, 35 | formKeySuffix: PropTypes.string.isRequired, 36 | formStore: PropTypes.instanceOf(FormStore).isRequired, 37 | url: PropTypes.string.isRequired, 38 | settings: PropTypes.shape().isRequired, 39 | registerLookUp: PropTypes.func.isRequired, 40 | }; 41 | 42 | constructor(props) { 43 | super(props); 44 | 45 | this.lookUpFields = { 46 | relation_number: { 47 | elementKey: 'relation_number', 48 | formKey: `relation_number${props.formKeySuffix}`, 49 | triggerLookUp: true, 50 | apiValue: () => false, 51 | required: true, 52 | }, 53 | postcode: { 54 | elementKey: 'postcode', 55 | formKey: `address_postcode${this.fullAddressLookUp() ? `-${this.fullAddressLookUp()}` : props.formKeySuffix}`, 56 | triggerLookUp: true, 57 | apiValue: () => false, 58 | required: true, 59 | }, 60 | }; 61 | 62 | this.lookUpBase = `${props.url}/salesforce-lookup/contact?_format=json`; 63 | 64 | const lookUpKey = this.getLookUpKey(props); 65 | const field = props.formStore.getField(props.field['#webform_key']); 66 | 67 | rules.set(`relation_membership_${props.field['#webform_key']}`, { 68 | rule: () => { 69 | const lookUp = get(field.lookUps, lookUpKey); 70 | return !field.element['#membership_validation'] || !lookUp || !lookUp.lookUpSent || ( 71 | lookUp.lookUpSent && lookUp.lookUpSuccessful 72 | ); 73 | }, 74 | hint: () => null, 75 | shouldValidate: () => props.fields.reduce((shouldValidate, item) => 76 | shouldValidate && !item.isEmpty && item.isBlurred && item.valid, 77 | true, 78 | ), 79 | }); 80 | 81 | props.registerLookUp(lookUpKey, this.lookUpFields); 82 | } 83 | 84 | getLookUpKey(props) { 85 | return `${( 86 | props || this.props 87 | ).field['#webform_key']}-relation`; 88 | } 89 | 90 | /** 91 | * If Relation field has full address element, get its webform key to add as suffix to fields. 92 | * @returns {bool|string} Returns false if not full address field (i.e. postcode only) or string of address webform key. 93 | */ 94 | fullAddressLookUp() { 95 | return getNested(() => this.props.field.composite_elements.find(element => element['#type'] === 'webform_address_custom' || element['#type'] === 'dutch_address')['#webform_key']); 96 | } 97 | 98 | prepareLookUp(fields) { 99 | const performLookUp = this.props.fields.reduce((shouldValidate, item) => 100 | shouldValidate && !item.isEmpty && item.isBlurred && item.valid, 101 | true, 102 | ); 103 | 104 | if(!performLookUp) { 105 | return false; 106 | } 107 | 108 | const query = `&relation=${fields.relation_number}&postal_code=${fields.postcode}`; 109 | 110 | return { 111 | query, 112 | checkResponse: json => (json.Id && (!this.props.field['#membership_validation'] || parseInt(json.Aantal_lidmaatschappen__c, 10) > 0)) || false, 113 | isSuccessful: response => (!!response), 114 | }; 115 | } 116 | 117 | render() { 118 | const field = this.props.formStore.getField(this.lookUpFields.relation_number.formKey); 119 | const lookUpKey = this.getLookUpKey(); 120 | const lookUp = get(field.lookUps, lookUpKey); 121 | 122 | return ( 123 |
124 | {lookUp && lookUp.lookUpSent && !lookUp.lookUpSuccessful && ( 125 | } key={`relation_${lookUpKey}`} hint={WebformUtils.getCustomValue(this.props.field, 'relationError', this.props.settings) || WebformUtils.getErrorMessage(this.props.field, '#required_error') || 'We don\'t recognise this combination of relation number and postal code. Please check again, or proceed anyway.'} /> 126 | )} 127 |
128 | ); 129 | } 130 | } 131 | 132 | export default composeLookUp(Relation); 133 | -------------------------------------------------------------------------------- /src/RuleHint/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { inject } from 'mobx-react'; 4 | import Parser, { template } from '../Parser'; 5 | import FormStore from '../Observables/Form'; 6 | // styled 7 | import ValidationMessage from './styled/validation-message'; 8 | 9 | @inject('formStore') 10 | class RuleHint extends Component { 11 | static propTypes = { 12 | hint: PropTypes.oneOfType([ 13 | PropTypes.string, 14 | PropTypes.element, 15 | ]).isRequired, 16 | tokens: PropTypes.objectOf(PropTypes.node), 17 | component: PropTypes.oneOfType([ 18 | PropTypes.string, 19 | PropTypes.shape({ 20 | type: PropTypes.string.isRequired, 21 | props: PropTypes.shape({ 22 | className: PropTypes.string, 23 | }), 24 | }), 25 | PropTypes.node, 26 | ]), 27 | formStore: PropTypes.instanceOf(FormStore).isRequired, 28 | }; 29 | 30 | static defaultProps = { 31 | tokens: {}, 32 | tokenCharacter: ':', 33 | component: 'li', 34 | } 35 | 36 | getHint() { 37 | const { tokens } = this.props; 38 | let hint = this.props.hint; 39 | Object.keys(tokens).forEach((token) => { 40 | hint = hint.replace(new RegExp(`{{${token}}}`, 'g'), tokens[token]); 41 | }); 42 | 43 | return hint; 44 | } 45 | 46 | render() { 47 | const hint = this.getHint(); 48 | const RuleComponent = ValidationMessage.withComponent(this.props.component.type || this.props.component); 49 | return ( 50 | 51 | 52 | {Parser(template(this.props.formStore, hint))} 53 | 54 | 55 | ); 56 | } 57 | } 58 | 59 | export default RuleHint; 60 | -------------------------------------------------------------------------------- /src/RuleHint/styled/validation-message.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export default styled.li` 4 | padding: 2px 0 0; 5 | list-style-type: none; 6 | color: ${p => p.theme.errorColor}; 7 | `; 8 | -------------------------------------------------------------------------------- /src/SelectField/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Select from 'react-select'; 4 | import WebformElement from '../WebformElement'; 5 | // styled 6 | import Wrapper from './styled/wrapper'; 7 | import ValidationIcon from './styled/validation-icon'; 8 | 9 | /** 10 | * Select2 11 | * @source https://github.com/JedWatson/react-select 12 | */ 13 | 14 | class SelectField extends Component { 15 | static propTypes = { 16 | field: PropTypes.shape({ 17 | '#title_display': PropTypes.string.string, 18 | '#options': PropTypes.arrayOf(PropTypes.shape({ 19 | value: PropTypes.node, 20 | text: PropTypes.node, 21 | })), 22 | '#webform_key': PropTypes.string.isRequired, 23 | '#multiple': PropTypes.bool, 24 | '#empty_option': PropTypes.string, 25 | }).isRequired, 26 | value: PropTypes.oneOfType([ 27 | PropTypes.string, 28 | PropTypes.number, 29 | PropTypes.bool, 30 | ]).isRequired, 31 | webformElement: PropTypes.instanceOf(WebformElement).isRequired, 32 | onChange: PropTypes.func.isRequired, 33 | onBlur: PropTypes.func.isRequired, 34 | state: PropTypes.shape({ 35 | required: PropTypes.bool.isRequired, 36 | enabled: PropTypes.bool.isRequired, 37 | }).isRequired, 38 | }; 39 | 40 | constructor(props) { 41 | super(props); 42 | 43 | this.handleChange = this.handleChange.bind(this); 44 | } 45 | 46 | getLabelPosition() { 47 | return this.props.webformElement.getLabelDisplay(); 48 | } 49 | 50 | handleChange(value) { 51 | const newValue = value || ''; 52 | if(newValue && newValue.value) { 53 | this.props.onChange(newValue.value); 54 | } else { 55 | this.props.onChange(newValue); 56 | } 57 | } 58 | 59 | render() { 60 | const options = this.props.field['#options'] || {}; 61 | const mappedOptions = options.map(option => ({ 62 | label: option.text, 63 | value: option.value, 64 | })); 65 | return ( 66 | 71 | this.setState({ baseUrl: e.target.value })} value={this.state.baseUrl} /> 63 |
64 | 65 | 66 | this.setState({ path: e.target.value })} value={this.state.path} /> 67 |
68 | 69 | 70 | this.setState({ field: e.target.value })} value={this.state.field} /> 71 |
72 | 73 | 74 |
75 | 76 | 77 | this.setState({ visible: !this.state.visible })} 80 | >⇓ 81 | this.form = component} 83 | baseUrl={this.state.baseUrl} 84 | path={this.state.path} 85 | field={this.state.field} 86 | /> 87 | 88 | ); 89 | } 90 | } 91 | 92 | export default RemoteForm; 93 | -------------------------------------------------------------------------------- /utils/postinstall.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console,global-require */ 2 | const spawn = require('child_process').spawnSync; 3 | const path = require('path'); 4 | 5 | if(!require('fs').existsSync(path.join(__dirname, '../node_modules/eslint'))) { 6 | console.log('Installing devDependencies of hn-react-webform..', path.join(__dirname, 'node_modules/eslint/package.json')); 7 | spawn('npm', 'install --ignore-scripts'.split(' '), { cwd: __dirname, stdio: 'inherit' }); 8 | } else { 9 | console.log('DevDependencies of hn-react-webform already installed, not installing again.'); 10 | } 11 | 12 | if(!require('fs').existsSync(path.join(__dirname, '../lib'))) { 13 | console.log('Building hn-react-webform..'); 14 | spawn('npm', 'run build'.split(' '), { cwd: __dirname, stdio: 'inherit' }); 15 | 16 | console.log('Building done, hn-react-webform is ready to go!'); 17 | } else { 18 | console.log('Already built hn-react-webform, not building again.'); 19 | } 20 | -------------------------------------------------------------------------------- /utils/prepublish.js: -------------------------------------------------------------------------------- 1 | const pkg = require('../package.json'); 2 | 3 | delete pkg.scripts.postinstall; 4 | require('fs').writeFileSync('./package.json', JSON.stringify(pkg, null, 2)); 5 | --------------------------------------------------------------------------------