├── docs ├── .nojekyll ├── static │ ├── js │ │ └── docs.js │ └── img │ │ ├── collapsed-indicator-bg.svg │ │ └── logo.svg ├── favicon.ico ├── apple-touch-icon.png ├── .babelrc ├── src │ ├── index.js │ ├── App.js │ ├── tabs.js │ └── demos.js ├── docs │ ├── index.md │ ├── developing.md │ ├── schema.md │ ├── install.md │ └── usage │ │ ├── browser.md │ │ └── node.md ├── _data │ ├── docToc.cjs │ └── project.cjs ├── _includes │ ├── icons │ │ └── github.svg │ ├── page.html │ └── base.html ├── .eslintrc.json ├── playground.html ├── .eleventy.cjs ├── index.html ├── package.json └── devserver.py ├── .babelrc ├── src ├── components │ ├── loaders.js │ ├── buttons.js │ ├── index.js │ ├── icons.js │ ├── widgets.js │ ├── containers.js │ ├── autocomplete.js │ └── uploader.js ├── index.js ├── constants.js ├── editorState.js ├── renderer.js ├── util.js ├── form.js ├── schemaValidation.js ├── dataValidation.js └── data.js ├── .eslintrc.json ├── README.md ├── LICENSE ├── package.json ├── .gitignore └── dev └── index.html /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/static/js/docs.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bhch/react-json-form/HEAD/docs/favicon.ico -------------------------------------------------------------------------------- /docs/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bhch/react-json-form/HEAD/docs/apple-touch-icon.png -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | ["@babel/preset-react", {"absoluteRuntime": false}] 5 | ] 6 | } -------------------------------------------------------------------------------- /docs/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | ["@babel/preset-react", {"absoluteRuntime": false}] 5 | ] 6 | } -------------------------------------------------------------------------------- /src/components/loaders.js: -------------------------------------------------------------------------------- 1 | export default function Loader (props) { 2 | let className = 'rjf-loader'; 3 | if (props.className) 4 | className = className + ' ' + props.className; 5 | 6 | return
; 7 | } 8 | -------------------------------------------------------------------------------- /docs/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App.js'; 4 | 5 | 6 | ReactDOM.render( 7 | 8 | 9 | , 10 | document.getElementById("playgroundRoot") 11 | ); 12 | -------------------------------------------------------------------------------- /docs/static/img/collapsed-indicator-bg.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /docs/docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: page.html 3 | title: Documentation 4 | --- 5 | 6 | **React JSON Form** documentation and usage guide. 7 | 8 | 9 | ### Table of contents 10 | 11 | {% for item in docToc.items -%} 12 | - [{{ item.title }}]({{ item.url | url }}) 13 | {% endfor %} 14 | 15 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import ReactJSONForm from './form.js'; 2 | import EditorState from './editorState.js'; 3 | import {createForm, getFormInstance} from './renderer.js'; 4 | import DataValidator from './dataValidation.js'; 5 | 6 | 7 | export { 8 | ReactJSONForm, 9 | EditorState, 10 | createForm, 11 | getFormInstance, 12 | DataValidator, 13 | }; -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | /* Symbol for joining coordinates. 2 | * Earlier, a hyphen (-) was used. But that caused problems when 3 | * object keys had hyphen in them. So, we're switching to a less 4 | * commonly used symbol. 5 | */ 6 | export const JOIN_SYMBOL = '§'; 7 | 8 | /* HTML field name prefix */ 9 | export const FIELD_NAME_PREFIX = 'rjf'; 10 | 11 | /* Filler item for arrays to make them at least minItems long */ 12 | export const FILLER = '__RJF_FILLER__'; 13 | -------------------------------------------------------------------------------- /docs/_data/docToc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = function() { 2 | return { 3 | header: 'Documentation', 4 | items: [ 5 | {title: 'Install', url: '/docs/install/'}, 6 | {title: 'Using in Node', url: '/docs/usage/node/'}, 7 | {title: 'Using in Browser', url: '/docs/usage/browser/'}, 8 | {title: 'Schema', url: '/docs/schema/'}, 9 | {title: 'Developing', url: '/docs/developing/'}, 10 | ], 11 | }; 12 | }; 13 | -------------------------------------------------------------------------------- /docs/docs/developing.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: page.html 3 | title: Developing 4 | --- 5 | 6 | This document is about the development of the **react-json-form** library. 7 | 8 | Currently, the library has no tests and the code is also a bit of a mess lacking 9 | comments in crucial places. 10 | 11 | Please open an [issue on Github]({{ project.github }}) about your desired feature 12 | or bugfix before writing code. 13 | 14 | 15 | ## Contributing code 16 | 17 | To be updated. 18 | 19 | ## Contributing docs 20 | 21 | To be updated. 22 | -------------------------------------------------------------------------------- /src/components/buttons.js: -------------------------------------------------------------------------------- 1 | export default function Button({className, alterClassName, ...props}) { 2 | if (!className) 3 | className = ''; 4 | 5 | let classes = className.split(' '); 6 | 7 | if (alterClassName !== false) { 8 | className = ''; 9 | for (let i = 0; i < classes.length; i++) { 10 | className = className + 'rjf-' + classes[i] + '-button '; 11 | } 12 | } 13 | 14 | return ( 15 | 22 | ); 23 | } -------------------------------------------------------------------------------- /docs/_includes/icons/github.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:react/recommended" 9 | ], 10 | "parser": "@babel/eslint-parser", 11 | "parserOptions": { 12 | "ecmaFeatures": { 13 | "jsx": true 14 | }, 15 | "ecmaVersion": 12, 16 | "sourceType": "module" 17 | }, 18 | "plugins": [ 19 | "react" 20 | ], 21 | "rules": { 22 | "react/prop-types": "off", 23 | "no-prototype-builtins": "off", 24 | "no-unused-vars": "warn", 25 | "no-unreachable": "warn" 26 | }, 27 | "globals": { 28 | "React": "readonly", 29 | "ReactDOM": "readonly" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /docs/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:react/recommended" 9 | ], 10 | "parser": "@babel/eslint-parser", 11 | "parserOptions": { 12 | "ecmaFeatures": { 13 | "jsx": true 14 | }, 15 | "ecmaVersion": 12, 16 | "sourceType": "module" 17 | }, 18 | "plugins": [ 19 | "react" 20 | ], 21 | "rules": { 22 | "react/prop-types": "off", 23 | "no-prototype-builtins": "off", 24 | "no-unused-vars": "warn", 25 | "no-unreachable": "warn" 26 | }, 27 | "globals": { 28 | "React": "readonly", 29 | "ReactDOM": "readonly" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /docs/_includes/page.html: -------------------------------------------------------------------------------- 1 | --- 2 | layout: base.html 3 | --- 4 | 5 |
6 | 19 |
20 | {{ content }} 21 |
22 |
-------------------------------------------------------------------------------- /src/components/index.js: -------------------------------------------------------------------------------- 1 | import Button from './buttons'; 2 | import {FormInput, FormCheckInput, FormRadioInput, FormSelectInput, FormFileInput, 3 | FormTextareaInput, FormDateTimeInput, FormMultiSelectInput, FormURLInput} from './form'; 4 | import AutoCompleteInput from './autocomplete'; 5 | import {FormRow, FormGroup, FormRowControls, GroupTitle, GroupDescription} from './containers'; 6 | import Loader from './loaders'; 7 | import Icon from './icons'; 8 | import FileUploader from './uploader'; 9 | 10 | export { 11 | Button, 12 | FormInput, FormCheckInput, FormRadioInput, FormSelectInput, FormFileInput, 13 | FormTextareaInput, FormDateTimeInput, FormMultiSelectInput, 14 | AutoCompleteInput, FormURLInput, 15 | FormRow, FormGroup, FormRowControls, GroupTitle, GroupDescription, 16 | Loader, 17 | Icon, 18 | FileUploader, 19 | }; -------------------------------------------------------------------------------- /docs/playground.html: -------------------------------------------------------------------------------- 1 | --- 2 | layout: base.html 3 | title: Playground 4 | scripts: 5 | - https://cdnjs.cloudflare.com/ajax/libs/react/17.0.2/umd/react.production.min.js 6 | - https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.2/umd/react-dom.production.min.js 7 | - https://cdnjs.cloudflare.com/ajax/libs/react-modal/3.15.1/react-modal.min.js 8 | - /static/js/playground.js 9 | --- 10 |
11 |
12 |
13 |     _
14 | .__(.)< (LOADING)
15 |  \___)
16 |         
17 |
18 |
19 |
20 |
21 |
22 | -------------------------------------------------------------------------------- /docs/.eleventy.cjs: -------------------------------------------------------------------------------- 1 | const syntaxHighlight = require('@11ty/eleventy-plugin-syntaxhighlight'); 2 | const markdownIt = require('markdown-it'); 3 | const markdownItAnchor = require('markdown-it-anchor'); 4 | 5 | module.exports = function(eleventyConfig) { 6 | eleventyConfig.addPassthroughCopy('static'); 7 | eleventyConfig.addPassthroughCopy('apple-touch-icon.png'); 8 | eleventyConfig.addPassthroughCopy('favicon.ico'); 9 | 10 | eleventyConfig.addPlugin(syntaxHighlight); 11 | 12 | eleventyConfig.setLibrary( 13 | 'md', 14 | markdownIt({html: true}).use(markdownItAnchor, { 15 | permalink: markdownItAnchor.permalink.linkInsideHeader() 16 | }) 17 | ); 18 | 19 | eleventyConfig.addLiquidFilter('navLinkIsActive', function(item, pageUrl) { 20 | if (item.isBase && item.url !== '/') 21 | return pageUrl.startsWith(item.url); 22 | return item.url === pageUrl; 23 | }); 24 | 25 | return { 26 | pathPrefix: '/react-json-form/' 27 | }; 28 | }; 29 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | --- 2 | layout: base.html 3 | title: React JSON Form 4 | --- 5 |
6 |
7 |

8 | React JSON Form is a library for creating forms using 9 | JSON Schema. 10 |

11 |

12 | It can be used either directly in the browser or in a Node.js project. 13 |

14 |

15 | Documentation →
16 |

17 |

18 | Demos & Playground → 19 |

20 | 21 |

Project links

22 | 27 |
28 |
29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-json-form 2 | 3 | React Component for editing JSON data using form inputs. 4 | 5 | [![npm badge](https://img.shields.io/npm/v/@bhch/react-json-form?color=brightgreen&logo=npm&style=flat-square)][npm] 6 | 7 | ### Live demo 8 | 9 | https://bhch.github.io/react-json-form/playground/ 10 | 11 | ### Documentation 12 | 13 | https://bhch.github.io/react-json-form/ 14 | 15 | ### ⚠️ Important notes 16 | 17 | 1. **Consider this library as a work-in-progress** (at least until version 3 which will be a more stable release). 18 | 2. Currently, this library doesn't provide default CSS styles. So, you're required to write 19 | your own CSS styles. You can also copy the styles from the demo page (see [`docs.css` after `Line 433`](https://github.com/bhch/react-json-form/blob/master/docs/static/css/docs.css#L433)) as a starting point. 20 | 3. Be prepared for breaking changes regarding UI structure and CSS class names. Currently, CSS class names don't 21 | follow a particular naming standard. But this will change in v3. 22 | 4. Support for UI themes will be added soon. 23 | 24 | [npm]: https://www.npmjs.com/package/@bhch/react-json-form/ 25 | -------------------------------------------------------------------------------- /docs/docs/schema.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: page.html 3 | title: Schema 4 | --- 5 | 6 |
7 |

Note

8 |

This document will be completed in future.

9 |

10 | Meanwhile, all the following things have been documented in React JSON Form's sister 11 | project: django-jsonform. 12 |

13 |
14 | 15 | ## Schema guide 16 | 17 | Documented here: 18 | 19 | 20 | ## Input types 21 | 22 | Documented here: 23 | 24 | Also, checkout the [playground]({{ '/playground' | url }}). 25 | 26 | ## Uploading files 27 | 28 | Documented here: 29 | 30 | Although the code examples in the document is for Django (Python) backend, but 31 | the concept can be adapted to any language. 32 | 33 | ## Autocomplete input 34 | 35 | Documented here: 36 | 37 | Although the code examples in the document is for Django (Python) backend, but 38 | the concept can be adapted to any language. -------------------------------------------------------------------------------- /docs/_data/project.cjs: -------------------------------------------------------------------------------- 1 | var pjson = require('../../package.json'); 2 | 3 | 4 | module.exports = function() { 5 | return { 6 | node_env: process.env.NODE_ENV || 'development', 7 | version: pjson.version, 8 | title: 'React JSON Form', 9 | name: pjson.name, 10 | url: 'https://bhch.github.io/react-json-form/', 11 | github: pjson.repository.url.replace('.git', ''), 12 | topNav: [ 13 | {title: 'Home', url: '/'}, 14 | {title: 'Docs', url: '/docs/', isBase: true}, 15 | {title: 'Install', url: '/docs/install/', className: 'd-sm-none'}, 16 | {title: 'Using in Node', url: '/docs/usage/node/', className: 'd-sm-none'}, 17 | {title: 'Using in Browser', url: '/docs/usage/browser/', className: 'd-sm-none'}, 18 | {title: 'Schema', url: '/docs/schema/', className: 'd-sm-none'}, 19 | {title: 'Developing', url: '/docs/developing/', className: 'd-sm-none'}, 20 | {title: 'Playground', url: '/playground/'}, 21 | {title: 'Github', url: pjson.repository.url.replace('.git', ''), icon: 'github'}, 22 | ], 23 | footerNav: [ 24 | {title: 'View on Github', url: pjson.repository.url.replace('.git', ''), icon: 'github'}, 25 | ], 26 | }; 27 | }; 28 | -------------------------------------------------------------------------------- /docs/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Tabs, TabContent} from './tabs.js'; 3 | 4 | 5 | export default class App extends React.Component { 6 | constructor(props) { 7 | super(props); 8 | 9 | this.state = { 10 | activeTabIndex: this.getActiveTabIndexFromHash() || 0, 11 | }; 12 | } 13 | 14 | getActiveTabIndexFromHash() { 15 | if (!window.location.hash) 16 | return 0; 17 | 18 | try { 19 | let index = window.location.hash.split('-')[0].split('#')[1]; 20 | if (isNaN(index)) 21 | index = 0; 22 | else 23 | index = Number(index); 24 | return index; 25 | } catch (error) { 26 | return 0; 27 | } 28 | } 29 | 30 | onTabClick = (index, slug) => { 31 | window.location.hash = index + '-' + slug; 32 | this.setState({activeTabIndex: index}); 33 | } 34 | 35 | render() { 36 | return ( 37 |
38 | 42 | 43 | 46 |
47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /docs/docs/install.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: page.html 3 | title: Install 4 | --- 5 | 6 | **React JSON Form** can be used either in your Node project or directly in the browser. 7 | 8 | ## Node 9 | 10 | Install it using this command: 11 | 12 | ```shell 13 | $ npm install @bhch/react-json-form --save 14 | ``` 15 | 16 | ## Browser 17 | 18 | Before loading the library, you're also required to include the dependencies. 19 | 20 | ### Loading via CDN 21 | 22 | This library is available via Unpkg CDN. 23 | 24 | ```html 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | ``` 33 | 34 | ### Self hosting 35 | 36 | We also provide compiled dist files for every release. This allows you to self host and serve 37 | this library. 38 | 39 | [Download the latest release][1] from Github. 40 | 41 | Next, extract `react-json-form.js` file from the package. This is the browser suitable build. 42 | It's already minified. 43 | 44 | [1]: https://github.com/bhch/react-json-form/releases/download/v{{ project.version }}/react-json-form-{{ project.version }}-dist.zip -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2021, Bharat Chauhan 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-json-form-docs", 3 | "version": "0.0.1", 4 | "description": "", 5 | "type": "module", 6 | "source": "./src/index.js", 7 | "exports": { 8 | "require": "./static/js/playground.cjs", 9 | "default": "./static/js/playground.modern.js" 10 | }, 11 | "main": "./static/js/playground.cjs", 12 | "module": "./static/js/playground.module.js", 13 | "unpkg": "./static/js/playground.js", 14 | "scripts": { 15 | "build": "npm run build:playground", 16 | "build:playground": "microbundle --format umd --jsx React.createElement --sourcemap false --globals react=React,react-dom=ReactDOM,react-modal=ReactModal", 17 | "build:playground:standalone": "microbundle --format umd --jsx React.createElement --sourcemap false --define process.env.NODE_ENV=production --external None", 18 | "dev:playground": "microbundle watch --format umd --no-compress --jsx React.createElement --sourcemap false --globals react=React,react-dom=ReactDOM,react-modal=ReactModal", 19 | "dev:docs": "npx @11ty/eleventy --serve --port=8000 --config=.eleventy.cjs", 20 | "build:docs": "NODE_ENV=production npx @11ty/eleventy --config=.eleventy.cjs", 21 | "lint": "eslint src" 22 | }, 23 | "keywords": [], 24 | "author": "Bharat Chauhan", 25 | "license": "BSD-3-Clause", 26 | "devDependencies": { 27 | "@11ty/eleventy": "^1.0.2", 28 | "@11ty/eleventy-plugin-syntaxhighlight": "^4.1.0", 29 | "@babel/eslint-parser": "^7.18.9", 30 | "@codemirror/commands": "^6.0.1", 31 | "@codemirror/lang-json": "^6.0.0", 32 | "@codemirror/state": "^6.1.0", 33 | "@codemirror/view": "^6.1.1", 34 | "codemirror": "^6.0.1", 35 | "eslint": "^8.20.0", 36 | "eslint-plugin-react": "^7.30.1", 37 | "markdown-it-anchor": "^8.6.4", 38 | "markdown-it-deflist": "^2.1.0", 39 | "microbundle": "^0.15.0", 40 | "react": "^17.0.2", 41 | "react-dom": "^17.0.2", 42 | "react-json-form": "file:..", 43 | "react-modal": "^3.15.1" 44 | }, 45 | "dependencies": { 46 | "react": "^17.0.2", 47 | "react-dom": "^17.0.2", 48 | "react-modal": "^3.15.1" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bhch/react-json-form", 3 | "version": "2.14.4", 4 | "description": "Create forms using JSON Schema", 5 | "publishConfig": { 6 | "access": "public" 7 | }, 8 | "type": "module", 9 | "source": "./src/index.js", 10 | "exports": { 11 | "require": "./dist/react-json-form.cjs", 12 | "default": "./dist/react-json-form.modern.js" 13 | }, 14 | "main": "./dist/react-json-form.cjs", 15 | "module": "./dist/react-json-form.module.js", 16 | "unpkg": "./dist/react-json-form.js", 17 | "scripts": { 18 | "build": "npm run build:web && npm run build:node", 19 | "build:web": "microbundle --format umd --jsx React.createElement --sourcemap false --globals react=React,react-dom=ReactDOM,react-modal=ReactModal", 20 | "build:node": "microbundle --format cjs,esm,modern --jsx React.createElement --sourcemap false --target node", 21 | "dev:web": "microbundle watch --format umd --no-compress --jsx React.createElement --sourcemap false --globals react=React,react-dom=ReactDOM,react-modal=ReactModal", 22 | "dev:node": "microbundle watch --format modern --no-compress --jsx React.createElement --sourcemap false --taget node", 23 | "test": "jest", 24 | "lint": "eslint src" 25 | }, 26 | "files": [ 27 | "./src", 28 | "./dist" 29 | ], 30 | "keywords": [ 31 | "json-form", 32 | "jsonform", 33 | "json-schema-form", 34 | "jsonschemaform", 35 | "react", 36 | "react-component", 37 | "react-jsons-form", 38 | "react-jsonschema-form" 39 | ], 40 | "author": "Bharat Chauhan", 41 | "license": "BSD-3-Clause", 42 | "homepage": "https://github.com/bhch/react-json-form", 43 | "bugs": "https://github.com/bhch/react-json-form/issues", 44 | "repository": { 45 | "type": "git", 46 | "url": "https://github.com/bhch/react-json-form.git" 47 | }, 48 | "funding": "https://github.com/sponsors/bhch/", 49 | "devDependencies": { 50 | "@babel/eslint-parser": "^7.18.9", 51 | "eslint": "^8.20.0", 52 | "eslint-plugin-react": "^7.30.1", 53 | "jest": "^29.0.2", 54 | "microbundle": "^0.15.0" 55 | }, 56 | "peerDependencies": { 57 | "react": "^17.0.2 || ^18", 58 | "react-dom": "^17.0.2 || ^18", 59 | "react-modal": "^3.15.1" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/ 39 | build/Release 40 | 41 | # Compiled files during development 42 | dev/dist 43 | 44 | # Dependency directories 45 | node_modules/ 46 | # Symlink 47 | node_modules 48 | jspm_packages/ 49 | 50 | # TypeScript v1 declaration files 51 | typings/ 52 | 53 | # TypeScript cache 54 | *.tsbuildinfo 55 | 56 | # Optional npm cache directory 57 | .npm 58 | 59 | # Optional eslint cache 60 | .eslintcache 61 | 62 | # Microbundle cache 63 | .rpt2_cache/ 64 | .rts2_cache_cjs/ 65 | .rts2_cache_es/ 66 | .rts2_cache_umd/ 67 | 68 | # Optional REPL history 69 | .node_repl_history 70 | 71 | # Output of 'npm pack' 72 | *.tgz 73 | 74 | # Yarn Integrity file 75 | .yarn-integrity 76 | 77 | # dotenv environment variables file 78 | .env 79 | .env.test 80 | 81 | # parcel-bundler cache (https://parceljs.org/) 82 | .cache 83 | 84 | # Next.js build output 85 | .next 86 | 87 | # Nuxt.js build / generate output 88 | .nuxt 89 | 90 | # Gatsby files 91 | .cache/ 92 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 93 | # https://nextjs.org/blog/next-9-1#public-directory-support 94 | # public 95 | 96 | # vuepress build output 97 | .vuepress/dist 98 | 99 | # Serverless directories 100 | .serverless/ 101 | 102 | # FuseBox cache 103 | .fusebox/ 104 | 105 | # DynamoDB Local files 106 | .dynamodb/ 107 | 108 | # TernJS port file 109 | .tern-port 110 | 111 | # Docs dev build 112 | docs/_site 113 | 114 | # Zipped dist package 115 | *-dist.zip -------------------------------------------------------------------------------- /src/editorState.js: -------------------------------------------------------------------------------- 1 | import {getBlankData, getSyncedData} from './data'; 2 | import {validateSchema} from './schemaValidation'; 3 | 4 | 5 | export default class EditorState { 6 | /* Not for public consumption */ 7 | constructor(state) { 8 | this.state = state; 9 | } 10 | 11 | static create(schema, data) { 12 | /* 13 | schema and data can be either a JSON string or a JS object. 14 | data is optional. 15 | */ 16 | 17 | if (typeof schema === 'string') 18 | schema = JSON.parse(schema); 19 | 20 | let validation = validateSchema(schema); 21 | 22 | if (!validation.isValid) 23 | throw new Error('Error while creating EditorState: Invalid schema: ' + validation.msg); 24 | 25 | if (typeof data === 'string' && data !== '') 26 | data = JSON.parse(data); 27 | 28 | if (!data) { 29 | // create empty data from schema 30 | data = getBlankData(schema, (ref) => EditorState.getRef(ref, schema)); 31 | } else { 32 | // data might be stale if schema has new keys, so add them to data 33 | try { 34 | data = getSyncedData(data, schema, (ref) => EditorState.getRef(ref, schema)); 35 | } catch (error) { 36 | console.error("Error while creating EditorState: Schema and data structure don't match"); 37 | throw error; 38 | } 39 | } 40 | 41 | return new EditorState({schema: schema, data: data}); 42 | } 43 | 44 | static getRef(ref, schema) { 45 | /* Returns schema reference. Nothing to do with React's refs. 46 | 47 | This will not normalize keywords, i.e. it won't convert 'keys' 48 | to 'properties', etc. Because what if there's an actual key called 49 | 'keys'? Substituting the keywords will lead to unexpected lookup. 50 | 51 | */ 52 | 53 | let refSchema; 54 | let tokens = ref.split('/'); 55 | 56 | for (let i = 0; i < tokens.length; i++) { 57 | let token = tokens[i]; 58 | 59 | if (token === '#') 60 | refSchema = schema; 61 | else 62 | refSchema = refSchema[token]; 63 | } 64 | 65 | return {...refSchema}; 66 | } 67 | 68 | static update(editorState, data) { 69 | /* Only for updating data. 70 | For updating schema, create new state. 71 | */ 72 | return new EditorState({...editorState._getState(), data: data}); 73 | } 74 | 75 | _getState() { 76 | return this.state; 77 | } 78 | 79 | getData() { 80 | let state = this._getState(); 81 | return state.data; 82 | } 83 | 84 | getSchema() { 85 | let state = this._getState(); 86 | return state.schema; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/components/icons.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | 4 | export default function Icon(props) { 5 | let icon; 6 | 7 | switch (props.name) { 8 | case 'chevron-up': 9 | icon = ; 10 | break; 11 | case 'chevron-down': 12 | icon = ; 13 | break; 14 | case 'arrow-down': 15 | icon = ; 16 | break; 17 | case 'x-lg': 18 | icon = ; 19 | break; 20 | case 'x-circle': 21 | icon = ; 22 | break; 23 | case 'three-dots-vertical': 24 | icon = ; 25 | break; 26 | case 'box-arrow-up-right': 27 | icon = ; 28 | break; 29 | } 30 | 31 | return ( 32 | 33 | {icon} 34 | 35 | ); 36 | } 37 | 38 | function ChevronUp(props) { 39 | return ( 40 | 41 | ); 42 | } 43 | 44 | function ChevronDown(props) { 45 | return ( 46 | 47 | ); 48 | } 49 | 50 | function ArrowDown(props) { 51 | return ( 52 | 53 | ); 54 | } 55 | 56 | function XLg(props) { 57 | return ( 58 | 59 | ); 60 | } 61 | 62 | function XCircle(props) { 63 | return ( 64 | 65 | 66 | 67 | 68 | ); 69 | } 70 | 71 | function ThreeDotsVertical(props) { 72 | return ( 73 | 74 | ); 75 | } 76 | 77 | function BoxArrowUpRight(props) { 78 | return ( 79 | 80 | 81 | 82 | 83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /docs/devserver.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Development server. 4 | 5 | Features: 6 | - Disables caching for static files 7 | - Link substitution 8 | 9 | Usage: 10 | $ ./devserver.py 11 | 12 | Gotchas: 13 | - Currently only works with index.html page 14 | """ 15 | import os 16 | import time 17 | import http.server 18 | from http import HTTPStatus 19 | 20 | 21 | PORT = 8000 22 | 23 | 24 | # Substitutions 25 | SUB = [ 26 | ( 27 | '/react-json-form/', 28 | '/' 29 | ), 30 | ( 31 | 'https://cdnjs.cloudflare.com/ajax/libs/bootstrap/4.6.2/css/bootstrap.min.css', 32 | '/local/bootstrap.min.css', 33 | ), 34 | ( 35 | 'https://cdnjs.cloudflare.com/ajax/libs/react/17.0.2/umd/react.production.min.js', 36 | '/node_modules/react/umd/react.development.js', 37 | ), 38 | ( 39 | 'https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.2/umd/react-dom.production.min.js', 40 | '/node_modules/react-dom/umd/react-dom.development.js', 41 | ), 42 | ( 43 | 'https://cdnjs.cloudflare.com/ajax/libs/react-modal/3.15.1/react-modal.min.js', 44 | '/node_modules/react-modal/dist/react-modal.min.js' 45 | ), 46 | ] 47 | 48 | 49 | CACHE_TIME = None 50 | CACHE_DATA = None 51 | 52 | 53 | class DevHTTPRequestHandler(http.server.SimpleHTTPRequestHandler): 54 | def send_response_only(self, code, message=None): 55 | super().send_response_only(code, message) 56 | self.send_header('Cache-Control', 'no-store, must-revalidate') 57 | self.send_header('Expires', '0') 58 | 59 | def is_index(self): 60 | return self.path == '/' or self.path == '/index.html' 61 | 62 | def do_GET(self): 63 | if self.is_index(): 64 | try: 65 | f = open('index.html', 'rb') 66 | except OSError: 67 | self.send_error(HTTPStatus.NOT_FOUND, "File not found") 68 | return None 69 | 70 | try: 71 | fs = os.fstat(f.fileno()) 72 | global CACHE_TIME 73 | global CACHE_DATA 74 | if not CACHE_TIME or CACHE_TIME <= fs.st_mtime: 75 | CACHE_TIME = time.time() 76 | CACHE_DATA = f.read().decode() 77 | 78 | for sub in SUB: 79 | CACHE_DATA = CACHE_DATA.replace(sub[0], sub[1]) 80 | 81 | CACHE_DATA = CACHE_DATA.encode('utf-8') 82 | 83 | self.send_response(HTTPStatus.OK) 84 | self.send_header("Content-type", "text/html") 85 | self.send_header("Content-Length", str(len(CACHE_DATA))) 86 | self.send_header("Last-Modified", self.date_time_string(fs.st_mtime)) 87 | self.end_headers() 88 | 89 | f.close() 90 | 91 | self.wfile.write(CACHE_DATA) 92 | except: 93 | f.close() 94 | raise 95 | else: 96 | super().do_GET() 97 | 98 | 99 | if __name__ == '__main__': 100 | http.server.test( 101 | HandlerClass=DevHTTPRequestHandler, 102 | port=PORT 103 | ) -------------------------------------------------------------------------------- /docs/_includes/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {% if title %}{{ title }} - {% endif %}{{ project.title }} 7 | 8 | 9 | 10 | 11 | 12 | {% if project.node_env == 'production' %} 13 | 14 | 15 | 16 | {% endif %} 17 | 18 | 19 | 20 |
21 |
22 | 48 |
49 | 50 |
51 |

{{ title | upcase }}

52 |
53 | 54 |
55 | {{ content }} 56 |
57 |
58 | 59 |
60 | 79 |
80 | 81 | 95 | 96 | {% if project.node_env == 'production' %} 97 | 98 | 99 | 106 | {% endif %} 107 | 108 | {% for link in scripts -%} 109 | 110 | {% endfor -%} 111 | 112 | 113 | -------------------------------------------------------------------------------- /docs/docs/usage/browser.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: page.html 3 | title: Using in Browser 4 | --- 5 | 6 | In the browser, the library will be available under `reactJsonForm` variable. 7 | 8 | ## Creating the form 9 | 10 | Use the [`reactJsonForm.createForm()`](#reactjsonform.createform(config)) function to 11 | create the form from your schema. 12 | 13 | You'll also need to have a `textarea` where the form will save the data. 14 | 15 | ```html 16 |
17 | 18 |
19 | 20 | 23 | 24 | 25 | 40 | ``` 41 | 42 | 43 | ## Handling events 44 | 45 | ```js 46 | form.addEventListener('change', function(e) { 47 | // ... 48 | }); 49 | ``` 50 | 51 | See [`addEventListener()`](#forminstance.addeventlistener(event%2C-callback)) section for 52 | further details about handling events. 53 | 54 | 55 | ## Data validation 56 | 57 | *New in version 2.1* 58 | 59 | The form component provides basic data validation. 60 | 61 | Usage example: 62 | 63 | ```js 64 | var validation = form.validate(); 65 | 66 | var isValid = validation.isValid; // it will be false if data is not valid 67 | 68 | var errorMap = validation.errorMap; // object containing error messages 69 | 70 | if (!isValid) { 71 | // notify user 72 | alert('Please correct the errors'); 73 | 74 | // update the form component 75 | // it will display error messages below each input 76 | form.update({errorMap: errorMap}); 77 | } 78 | 79 | ``` 80 | 81 | You can adopt the above code example to validate the data before a form is submitted. 82 | 83 | You can also implement custom validation instead of calling `.validate()`. In that 84 | case, you'll have to manually create an [`errorMap` object]({{ '/docs/usage/node/#data-validation' | url }}) 85 | for displaying error messages. 86 | 87 | ## API reference 88 | 89 | ### Library functions 90 | 91 | ##### `reactJsonForm.createForm(config)` 92 | 93 | Function used for creating the form UI. The `config` parameter is an object 94 | which may contain these keys: 95 | 96 | - `containerId`: The HTML ID of the element in which the form should be rendered. 97 | - `dataInputId`: The HTML ID of the textarea element in which the form data should be kept. 98 | - `schema`: The schema of the form. 99 | - `data` *(Optional)*: The initial data of the form. 100 | - `fileHandler` *(Optional)*: URL for the common file handler endpoint for all file fields. 101 | - `fileHandlerArgs` *(Optional)*: Key-value pairs which will be sent via querystring to the `fileHandler` URL. 102 | - `errorMap` *(Optional)*: An object containing error messages for fields. 103 | - `readonly` *(Optional)*: A boolean. If `true`, the whole form will be read-only. 104 | 105 | *Changed in version 2.1*: `errorMap` option was added. 106 | *Changed in version 2.2*: `fileHandlerArgs` option was added. 107 | *Changed in version 2.10*: `readonly` option was added. 108 | 109 | 110 | ##### `reactJsonForm.getFormInstance(containerId)` 111 | 112 | Call this function to get a previously created form instance. 113 | 114 | If you've earlier created an instance in a scoped function, then to get 115 | the form instance in another scope, this function can be helpful. 116 | 117 | This helps you avoid keeping track of the form instances yourself. 118 | 119 | ```js 120 | var form = reactJsonForm.getFormInstance('formContainer'); 121 | ``` 122 | 123 | ### Form instance 124 | 125 | The following methods, attributes & events are available on a form instance. 126 | 127 | #### Methods 128 | 129 | ##### `formInstance.addEventListener(event, callback)` 130 | 131 | Register a callback for a given event ([see available events](#events)). 132 | 133 | ##### `formInstance.render()` 134 | 135 | Function to render the form. 136 | 137 | ##### `formInstance.update(config)` 138 | 139 | Re-render the form with the given `config`. 140 | 141 | 142 | ##### `formInstance.validate()` 143 | 144 | *New in version 2.1* 145 | 146 | Validates the current data against the instance's schema. 147 | 148 | Returns a *validation* object with following keys: 149 | 150 | - `isValid`: It will be `true` if data is valid, else `false`. 151 | - `errorMap`: An object containing field names and validation errors. 152 | 153 | ##### `formInstance.getData()` 154 | 155 | *New in version 2.1* 156 | 157 | Returns the current data of the form instance. 158 | 159 | ##### `formInstance.getSchema()` 160 | 161 | *New in version 2.1* 162 | 163 | Returns the current schema of the form instance. 164 | 165 | 166 | #### Events 167 | 168 | Following is the list of currently available events: 169 | 170 | ##### `change` 171 | 172 | This event is fired for every change in the form's data. 173 | 174 | The callback for this event will be passed an `Object` with the following keys: 175 | 176 | - `data`: Current data of the form. 177 | - `prevData`: Previous data of the form (before the event). 178 | - `schema`: Current schema of the form. 179 | - `prevSchema`: Previous schema of the form (before the event). 180 | 181 | Example: 182 | 183 | ```js 184 | var form = reactjsonform.createform(...); 185 | 186 | form.addEventListener('change', function(e) { 187 | var data = e.data; 188 | var prevData: e.prevData; 189 | var schema: e.schema; 190 | var prevSchema: e.prevSchema; 191 | 192 | // do something ... 193 | }); 194 | ``` 195 | 196 |
197 |

Attention!

198 |

199 | If you want to call the update() 200 | method from the change event listener, you must call it conditionally 201 | or else it might cause an infite loop. 202 |

203 |

204 | For example, only call the update() after checking that the 205 | current data and prevData are not same. 206 |

207 |
208 | -------------------------------------------------------------------------------- /src/components/widgets.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Button from './buttons'; 3 | import Icon from './icons'; 4 | 5 | 6 | export class TimePicker extends React.Component { 7 | componentWillUnmount() { 8 | let data = { 9 | hh: this.validateValue('hh', this.props.hh).toString().padStart(2, '0'), 10 | mm: this.validateValue('mm', this.props.mm).toString().padStart(2, '0'), 11 | ss: this.validateValue('ss', this.props.ss).toString().padStart(2, '0') 12 | } 13 | this.sendValue(data); 14 | } 15 | 16 | sendValue = (data) => { 17 | this.props.onChange(data); 18 | } 19 | 20 | validateValue = (name, value) => { 21 | if (name === 'hh' && value < 1) 22 | return 12; 23 | else if (name !== 'hh' && value < 0) 24 | return 59; 25 | else if (name === 'hh' && value > 12) 26 | return 1; 27 | else if (name !== 'hh' && value > 59) 28 | return 0; 29 | 30 | return value; 31 | } 32 | 33 | handleChange = (e) => { 34 | let name = e.target.dataset.name; 35 | let value = e.target.value; 36 | 37 | if (isNaN(value)) 38 | return; 39 | 40 | let validValue = this.validateValue(name, parseInt(value) || 0); 41 | 42 | if (name === 'hh' && (value === '0' || value === '' || value === '00') && validValue === 1) 43 | validValue = 0; 44 | 45 | if (value.startsWith('0') && validValue < 10 && validValue !== 0) { 46 | validValue = validValue.toString().padStart(2, '0'); 47 | } 48 | 49 | this.sendValue({[name]: value !== '' ? validValue.toString() : ''}); 50 | } 51 | 52 | handleKeyDown = (e) => { 53 | if (e.keyCode !== 38 && e.keyCode !== 40) 54 | return; 55 | 56 | let name = e.target.dataset.name; 57 | let value = parseInt(e.target.value) || 0; 58 | 59 | if (e.keyCode === 38) { 60 | value++; 61 | } else if (e.keyCode === 40) { 62 | value--; 63 | } 64 | 65 | this.sendValue({[name]: this.validateValue(name, value).toString().padStart(2, '0')}); 66 | } 67 | 68 | handleSpin = (name, type) => { 69 | let value = this.props[name]; 70 | 71 | if (name === 'ampm') { 72 | value = value === 'am' ? 'pm': 'am'; 73 | } else { 74 | value = parseInt(value) || 0; 75 | if (type === 'up') { 76 | value++; 77 | } else { 78 | value--; 79 | } 80 | value = this.validateValue(name, value).toString().padStart(2, '0'); 81 | } 82 | 83 | this.sendValue({[name]: value}); 84 | } 85 | 86 | handleBlur = (e) => { 87 | let value = this.validateValue(e.target.dataset.name, parseInt(e.target.value) || 0); 88 | 89 | if (value < 10) { 90 | this.sendValue({[e.target.dataset.name]: value.toString().padStart(2, '0')}); 91 | } 92 | } 93 | 94 | render() { 95 | return ( 96 |
97 |
98 |
Hrs
99 |
100 |
Min
101 |
102 |
Sec
103 |
104 |
am/pm
105 |
106 | 107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 | 117 |
118 |
119 |
:
120 |
121 |
:
122 |
123 |
124 |
{this.props.ampm}
125 |
126 | 127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 | ); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/renderer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import EditorState from './editorState'; 4 | import ReactJSONForm from './form'; 5 | import DataValidator from './dataValidation'; 6 | 7 | 8 | export function FormInstance(config) { 9 | this.containerId = config.containerId; 10 | this.dataInputId = config.dataInputId; 11 | 12 | this.schema = config.schema; 13 | this.data = config.data; 14 | this.errorMap = config.errorMap; 15 | this.fileHandler = config.fileHandler; 16 | this.fileHandlerArgs = config.fileHandlerArgs || {}; 17 | this.readonly = config.readonly || false; 18 | 19 | this.eventListeners = null; 20 | 21 | this._dataSynced = false; 22 | 23 | this.addEventListener = function(event, listener) { 24 | if (this.eventListeners === null) 25 | this.eventListeners = {}; 26 | 27 | if (!this.eventListeners.hasOwnProperty(event)) 28 | this.eventListeners[event] = new Set(); 29 | 30 | this.eventListeners[event].add(listener); 31 | }; 32 | 33 | this.onChange = function(e) { 34 | this.data = e.data; 35 | 36 | if (!this._dataSynced) { 37 | // this is the first change event for syncing data 38 | this._dataSynced = true; 39 | return; 40 | } 41 | 42 | if (!this.eventListeners) 43 | return; 44 | 45 | if (!this.eventListeners.hasOwnProperty('change') || !this.eventListeners.change.size) 46 | return; 47 | 48 | this.eventListeners.change.forEach((cb) => cb(e)); 49 | }; 50 | this.onChange = this.onChange.bind(this); 51 | 52 | this.render = function() { 53 | if (ReactDOM.hasOwnProperty('createRoot')) 54 | this._renderReact18(); 55 | else 56 | this._renderReact17(); 57 | }; 58 | 59 | this._renderReact17 = function() { 60 | try { 61 | ReactDOM.render( 62 | this._getFormContainerComponent(), 63 | document.getElementById(this.containerId) 64 | ); 65 | } catch (error) { 66 | ReactDOM.render( 67 | , 68 | document.getElementById(this.containerId) 69 | ); 70 | } 71 | }; 72 | 73 | this._renderReact18 = function() { 74 | const root = ReactDOM.createRoot(document.getElementById(this.containerId)); 75 | 76 | try { 77 | root.render(this._getFormContainerComponent()); 78 | } catch (error) { 79 | root.render(); 80 | } 81 | }; 82 | 83 | this._getFormContainerComponent = function() { 84 | return ( 85 | 95 | ); 96 | }; 97 | 98 | this.update = function(config) { 99 | this.schema = config.schema || this.schema; 100 | this.data = config.data || this.data; 101 | this.errorMap = config.errorMap || this.errorMap; 102 | 103 | this.render(); 104 | }; 105 | 106 | this.getSchema = function() { 107 | return this.schema; 108 | }; 109 | 110 | this.getData = function() { 111 | return this.data; 112 | }; 113 | 114 | this.validate = function() { 115 | let validator = new DataValidator(this.getSchema()); 116 | return validator.validate(this.getData()); 117 | }; 118 | } 119 | 120 | 121 | const FORM_INSTANCES = {}; 122 | 123 | export function createForm(config) { 124 | let instance = new FormInstance(config); 125 | 126 | // save a reference to the instance 127 | FORM_INSTANCES[config.containerId] = instance; 128 | 129 | return instance; 130 | } 131 | 132 | 133 | export function getFormInstance(id) { 134 | return FORM_INSTANCES[id]; 135 | } 136 | 137 | 138 | export class FormContainer extends React.Component { 139 | constructor(props) { 140 | super(props); 141 | 142 | this.state = { 143 | editorState: EditorState.create(props.schema, props.data), 144 | }; 145 | 146 | this.prevEditorState = this.state.editorState; 147 | 148 | this.dataInput = document.getElementById(props.dataInputId); 149 | } 150 | 151 | componentDidMount() { 152 | this.props.onChange({data: this.state.editorState.getData()}); 153 | this.populateDataInput(this.state.editorState.getData()); 154 | } 155 | 156 | componentDidUpdate(prevProps, prevState) { 157 | if (this.props.schema !== prevProps.schema) { 158 | let newSchema = this.props.schema; 159 | let newData = this.props.data !== prevProps.data ? this.props.data : this.state.editorState.getData(); 160 | this.setState({ 161 | editorState: EditorState.create(newSchema, newData) 162 | }); 163 | 164 | return; 165 | } 166 | 167 | if (this.props.data !== prevProps.data) { 168 | this.setState({ 169 | editorState: EditorState.update(this.state.editorState, this.props.data) 170 | }); 171 | 172 | return; 173 | } 174 | 175 | if (this.state.editorState !== prevState.editorState) 176 | this.populateDataInput(this.state.editorState.getData()); 177 | 178 | if (this.props.onChange && this.state.editorState !== prevState.editorState) 179 | this.props.onChange({ 180 | schema: this.state.editorState.getSchema(), 181 | data: this.state.editorState.getData(), 182 | prevSchema: prevState.editorState.getSchema(), 183 | prevData: prevState.editorState.getData() 184 | }); 185 | } 186 | 187 | populateDataInput = (data) => { 188 | this.dataInput.value = JSON.stringify(data); 189 | } 190 | 191 | handleChange = (editorState) => { 192 | this.setState({editorState: editorState}); 193 | } 194 | 195 | render() { 196 | return ( 197 | 205 | ); 206 | } 207 | } 208 | 209 | 210 | function ErrorReporter(props) { 211 | /* Component for displaying errors to the user related to schema */ 212 | 213 | return ( 214 |
215 |

(!) {props.error.toString()}

216 |

Check browser console for more details about the error.

217 |
218 | ); 219 | } 220 | -------------------------------------------------------------------------------- /src/components/containers.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Button from './buttons'; 3 | import {getSchemaType} from '../util.js'; 4 | 5 | 6 | export function GroupTitle(props) { 7 | if (!props.children) 8 | return null; 9 | 10 | return ( 11 |
12 | {props.children} 13 | 14 | {props.editable && 15 | 16 | {' '} 17 | 20 | 21 | } 22 | 23 | {props.collapsible && 24 | 25 | {' '} 26 | 29 | 30 | } 31 |
32 | ); 33 | } 34 | 35 | 36 | export function GroupDescription(props) { 37 | if (!props.children) 38 | return null; 39 | 40 | return
{props.children}
; 41 | } 42 | 43 | 44 | function animate(e, animation, callback) { 45 | let el = e.target.parentElement.parentElement; 46 | let prevEl = el.previousElementSibling; 47 | let nextEl = el.nextElementSibling; 48 | 49 | el.classList.add('rjf-animate', 'rjf-' + animation); 50 | 51 | if (animation === 'move-up') { 52 | let {y, height} = prevEl.getBoundingClientRect(); 53 | let y1 = y, h1 = height; 54 | 55 | ({y, height} = el.getBoundingClientRect()); 56 | let y2 = y, h2 = height; 57 | 58 | prevEl.classList.add('rjf-animate'); 59 | 60 | prevEl.style.opacity = 0; 61 | prevEl.style.transform = 'translateY(' + (y2 - y1) + 'px)'; 62 | 63 | el.style.opacity = 0; 64 | el.style.transform = 'translateY(-' + (y2 - y1) + 'px)'; 65 | 66 | } else if (animation === 'move-down') { 67 | let {y, height} = el.getBoundingClientRect(); 68 | let y1 = y, h1 = height; 69 | 70 | ({y, height} = nextEl.getBoundingClientRect()); 71 | let y2 = y, h2 = height; 72 | 73 | nextEl.classList.add('rjf-animate'); 74 | 75 | nextEl.style.opacity = 0; 76 | nextEl.style.transform = 'translateY(-' + (y2 - y1) + 'px)'; 77 | 78 | el.style.opacity = 0; 79 | el.style.transform = 'translateY(' + (y2 - y1) + 'px)'; 80 | } 81 | 82 | setTimeout(function() { 83 | callback(); 84 | 85 | el.classList.remove('rjf-animate', 'rjf-' + animation); 86 | el.style = null; 87 | 88 | if (animation === 'move-up') { 89 | prevEl.classList.remove('rjf-animate'); 90 | prevEl.style = null; 91 | } 92 | else if (animation === 'move-down') { 93 | nextEl.classList.remove('rjf-animate'); 94 | nextEl.style = null; 95 | } 96 | }, 200); 97 | } 98 | 99 | export function FormRowControls(props) { 100 | return ( 101 |
102 | {props.onMoveUp && 103 | 110 | } 111 | {props.onMoveDown && 112 | 119 | } 120 | {props.onRemove && 121 | 128 | } 129 |
130 | ); 131 | } 132 | 133 | export function FormRow(props) { 134 | let className = 'rjf-form-row'; 135 | 136 | if (props.hidden) 137 | className += ' rjf-form-row-hidden'; 138 | 139 | return ( 140 |
141 | 142 |
143 | {props.children} 144 |
145 |
146 | ); 147 | } 148 | 149 | 150 | export function FormGroup(props) { 151 | const [collapsed, setCollapsed] = React.useState(false); 152 | 153 | let type = getSchemaType(props.schema); 154 | 155 | let hasChildren = React.Children.count(props.children); 156 | 157 | let innerClassName = props.level === 0 && props.childrenType === 'groups' 158 | ? '' 159 | : 'rjf-form-group-inner'; 160 | 161 | let addButtonText; 162 | let addButtonTitle; 163 | 164 | if (type === 'object') { 165 | addButtonText = 'Add key'; 166 | addButtonTitle = 'Add new key'; 167 | } else { 168 | addButtonText = 'Add item'; 169 | addButtonTitle = 'Add new item'; 170 | } 171 | 172 | return ( 173 |
174 | {props.level === 0 && 175 | setCollapsed(!collapsed)} 180 | collapsed={collapsed} 181 | > 182 | {props.schema.title} 183 | 184 | } 185 | 186 | {props.level === 0 && {props.schema.description}} 187 | 188 |
189 | {props.level > 0 && 190 | setCollapsed(!collapsed)} 195 | collapsed={collapsed} 196 | > 197 | {props.schema.title} 198 | 199 | } 200 | 201 | {props.level > 0 && {props.schema.description}} 202 | 203 | {collapsed &&
Collapsed
} 204 |
205 | {props.children} 206 |
207 | 208 | {!collapsed && props.addable && 209 | 212 | } 213 |
214 |
215 | ); 216 | } 217 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {JOIN_SYMBOL, FIELD_NAME_PREFIX} from './constants'; 3 | 4 | 5 | export const EditorContext = React.createContext(); 6 | 7 | 8 | export function capitalize(string) { 9 | if (!string) 10 | return ''; 11 | 12 | return string.charAt(0).toUpperCase() + string.substr(1).toLowerCase(); 13 | } 14 | 15 | 16 | export function convertType(value, to) { 17 | if (typeof value === to) 18 | return value; 19 | 20 | if (to === 'number' || to === 'integer') { 21 | if (typeof value === 'string') { 22 | value = value.trim(); 23 | if (value === '') 24 | value = null; 25 | else if (!isNaN(Number(value))) 26 | value = Number(value); 27 | } else if (typeof value === 'boolean') { 28 | value = value === true ? 1 : 0; 29 | } 30 | } else if (to === 'boolean') { 31 | if (value === 'false' || value === false) 32 | value = false; 33 | else 34 | value = true; 35 | } 36 | 37 | return value; 38 | } 39 | 40 | 41 | export function actualType(value) { 42 | /* Returns the "actual" type of the given value. 43 | 44 | - array -> 'array' 45 | - null -> 'null' 46 | */ 47 | 48 | let type = typeof value; 49 | 50 | if (type === 'object') { 51 | if (Array.isArray(value)) 52 | type = 'array'; 53 | else if (value === null) 54 | type = 'null'; 55 | } 56 | 57 | return type; 58 | } 59 | 60 | 61 | export function getSchemaType(schema) { 62 | /* Returns type of the given schema. 63 | 64 | If schema.type is not present, it tries to guess the type. 65 | 66 | If data is given, it will try to use that to guess the type. 67 | */ 68 | let type; 69 | 70 | if (schema.hasOwnProperty('const')) 71 | type = actualType(schema.const); 72 | else 73 | type = normalizeKeyword(schema.type); 74 | 75 | if (!type) { 76 | if (schema.hasOwnProperty('properties') || 77 | schema.hasOwnProperty('keys') 78 | ) 79 | type = 'object'; 80 | else if (schema.hasOwnProperty('items')) 81 | type = 'array'; 82 | else if (schema.hasOwnProperty('allOf')) 83 | type = 'allOf'; 84 | else if (schema.hasOwnProperty('oneOf')) 85 | type = 'oneOf'; 86 | else if (schema.hasOwnProperty('anyOf')) 87 | type = 'anyOf'; 88 | else 89 | type = 'string'; 90 | } 91 | 92 | return type; 93 | } 94 | 95 | 96 | 97 | export function getVerboseName(name) { 98 | if (name === undefined || name === null) 99 | return ''; 100 | 101 | name = name.replace(/_/g, ' '); 102 | return capitalize(name); 103 | } 104 | 105 | 106 | export function getCsrfCookie() { 107 | let csrfCookies = document.cookie.split(';').filter((item) => item.trim().indexOf('csrftoken=') === 0); 108 | 109 | if (csrfCookies.length) { 110 | return csrfCookies[0].split('=')[1]; 111 | } else { 112 | // if no cookie found, get the value from the csrf form input 113 | let input = document.querySelector('input[name="csrfmiddlewaretoken"]'); 114 | if (input) 115 | return input.value; 116 | } 117 | 118 | return null; 119 | } 120 | 121 | 122 | export function joinCoords() { 123 | /* Generates coordinates from given arguments */ 124 | return Array.from(arguments).join(JOIN_SYMBOL); 125 | } 126 | 127 | 128 | export function splitCoords(coords) { 129 | /* Generates coordinates */ 130 | return coords.split(JOIN_SYMBOL); 131 | } 132 | 133 | 134 | export function getCoordsFromName(name) { 135 | /* Returns coordinates of a field in the data from 136 | * the given name of the input. 137 | * Field names have FIELD_NAME_PREFIX prepended but the coordinates don't. 138 | * e.g.: 139 | * name: rjf-0-field (where rjf- is the FIELD_NAME_PREFIX) 140 | * coords: 0-field 141 | */ 142 | return name.slice((FIELD_NAME_PREFIX + JOIN_SYMBOL).length); 143 | } 144 | 145 | 146 | export function debounce(func, wait) { 147 | let timeout; 148 | 149 | return function() { 150 | clearTimeout(timeout); 151 | 152 | let args = arguments; 153 | let context = this; 154 | 155 | timeout = setTimeout(function() { 156 | func.apply(context, args); 157 | }, (wait || 1)); 158 | } 159 | } 160 | 161 | 162 | export function normalizeKeyword(kw) { 163 | /* Converts custom supported keywords to standard JSON schema keywords */ 164 | 165 | if (Array.isArray(kw)) 166 | kw = kw.find((k) => k !== 'null') || 'null'; 167 | 168 | switch (kw) { 169 | case 'list': return 'array'; 170 | case 'dict': return 'object'; 171 | case 'keys': return 'properties'; 172 | case 'choices': return 'enum'; 173 | case 'datetime': return 'date-time'; 174 | default: return kw; 175 | } 176 | } 177 | 178 | export function getKeyword(obj, keyword, alias, default_value) { 179 | /* Function useful for getting value from schema if a 180 | * keyword has an alias. 181 | */ 182 | return getKey(obj, keyword, getKey(obj, alias, default_value)); 183 | } 184 | 185 | export function getKey(obj, key, default_value) { 186 | /* Approximation of Python's dict.get() function. */ 187 | 188 | let val = obj[key]; 189 | return (typeof val !== 'undefined') ? val : default_value; 190 | } 191 | 192 | 193 | export function choicesValueTitleMap(choices) { 194 | /* Returns a mapping of {value: title} for the given choices. 195 | * E.g.: 196 | * Input: [{'title': 'One', 'value': 1}, 2] 197 | * Output: {1: 'One', 2: 2} 198 | */ 199 | 200 | let map = {}; 201 | 202 | for (let i = 0; i < choices.length; i++) { 203 | let choice = choices[i]; 204 | let value, title; 205 | if (actualType(choice) === 'object') { 206 | value = choice.value; 207 | title = choice.title; 208 | } else { 209 | value = choice; 210 | title = choice; 211 | } 212 | 213 | map[value] = title; 214 | } 215 | 216 | return map; 217 | } 218 | 219 | 220 | export function valueInChoices(schema, value) { 221 | /* Checks whether the given value is in schema choices or not. 222 | If schema doesn't have choices, returns true. 223 | */ 224 | 225 | let choices = getKeyword(schema, 'choices', 'enum'); 226 | if (!choices) 227 | return true; 228 | 229 | let found = choices.find((choice) => { 230 | if (typeof choice == 'object') 231 | choice = choice.value; 232 | 233 | return value == choice; 234 | }) 235 | 236 | return found !== undefined ? true : false; 237 | } 238 | 239 | 240 | /* Set operations */ 241 | 242 | export function isEqualset(a, b) { 243 | return a.size === b.size && Array.from(a).every((i) => b.has(i)); 244 | } 245 | 246 | export function isSuperset(set, subset) { 247 | for (const elem of subset) { 248 | if (!set.has(elem)) { 249 | return false; 250 | } 251 | } 252 | return true; 253 | } 254 | 255 | export function isSubset(set, superset) { 256 | for (const elem of set) { 257 | if (!superset.has(elem)) { 258 | return false; 259 | } 260 | } 261 | return true; 262 | } 263 | -------------------------------------------------------------------------------- /docs/docs/usage/node.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: page.html 3 | title: Using in Node 4 | --- 5 | 6 | ## Basic usage 7 | 8 | **React JSON Form** delegates the state management to you. Your component is 9 | responsible for saving the state. 10 | 11 | ### Example using hooks 12 | 13 | ```jsx 14 | import {ReactJSONForm, EditorState} from '@bhch/react-json-form'; 15 | 16 | 17 | const MyForm = () => { 18 | const [editorState, setEditorState] = useState(() => 19 | EditorState.create(schema, data) 20 | ); 21 | 22 | return ( 23 | 27 | ); 28 | }; 29 | ``` 30 | 31 | ### Example using class component 32 | 33 | ```jsx 34 | import {ReactJSONForm, EditorState} from '@bhch/react-json-form'; 35 | 36 | 37 | class MyComponent extends React.Component { 38 | constructor(props) { 39 | super(props); 40 | 41 | this.state = { 42 | editorState: EditorState.create(schema, data); 43 | } 44 | } 45 | 46 | handleFormChange = (editorState) => { 47 | this.setState({editorState: editorState}); 48 | } 49 | 50 | render() { 51 | return ( 52 |
53 | 57 |
58 | ); 59 | } 60 | } 61 | ``` 62 | 63 | ## `ReactJSONForm` API reference 64 | 65 | ### Props 66 | 67 | - `editorState`: Instance of [`EditorState`](#editorstate-api-reference) containing the schema and data. 68 | - `onChange`: Callback function for handling changes. This function will receive a new instance of 69 | `EditorState` (because `EditorState` is immutable, instead of modifying the previous instance, we 70 | replace it with a new one). 71 | - `fileHandler`: A URL to a common file handler endpoint for all file input fields. 72 | - `fileHandlerArgs` (*Optional*): Key-value pairs which will be sent via querystring to the `fileHandler` URL. 73 | - `errorMap`: An object containing error messages for input fields. [See data validation section](#data-validation) 74 | for more. 75 | - `readonly`: A boolean. If `true`, the whole form will be read-only. 76 | 77 | *Changed in version 2.1*: `errorMap` prop was added. 78 | *Changed in version 2.10*: `readonly` prop was added. 79 | 80 | ## `EditorState` API reference 81 | 82 | `EditorState` must be treated as an immutable object. If you want to make any 83 | changes to the state, such as updating the schema, or changing the data, you 84 | must do it by calling the methods provided by `EditorState`. 85 | 86 | Always avoid directly mutating the `EditorState` object. 87 | 88 | ### Static methods 89 | 90 | ##### `EditorState.create(schema, data)` 91 | 92 | **Returns**: New `EditorState` instance. 93 | 94 | **Arguments**: 95 | 96 | - `schema`: Schema for the form. It can either be a JS `Object` or a JSON string. 97 | - `data` *(Optional)*: Initial data for the form. It can either be a JS `Object`, 98 | `Array`, or a JSON string. 99 | 100 | This method also tries to validate the schema and data and it will raise an exception 101 | if in case the schema is invalid or the data structure doesn't match the given schema. 102 | 103 | 104 | ##### `EditorState.update(editorState, data)` 105 | 106 | **Returns**: New `EditorState` instance. 107 | 108 | **Arguments**: 109 | 110 | - `editorState`: Instance of `editorState`. 111 | - `data`: Must be either a JS `Object` or `Array`. It can not be a JSON string. 112 | 113 | Use this method to update an existing `EditorState` with the given data. 114 | 115 | Since, the `EditorState` object is considered immutable, it doesn't actually 116 | modify the given `editorState`. Instead, it creates and return a new `EditorState` 117 | instance. 118 | 119 | This method is only for updating data. It doesn't do any validations to keep 120 | updates as fast as possible. 121 | 122 | If you want to validate the schema, you must create a new state using 123 | `EditorState.create()` method as that will also validate the schema and the data. 124 | 125 | 126 | ### Instance methods 127 | 128 | The following methods are available on an instance of `EditorState`. 129 | 130 | 131 | ##### `EditorState.getData()` 132 | 133 | Use this method to get the current data of the form. 134 | 135 | It will either return an `Array` or an `Object` depending upon the outermost `type` 136 | declared in the schema. 137 | 138 | 139 | ##### `EditorState.getSchema()` 140 | 141 | This method returns the schema. 142 | 143 | 144 | #### Data validation 145 | 146 | *New in version 2.1* 147 | 148 | **React JSON Form** comes with a basic data validator called [`DataValidator`](#datavalidator-api-reference). 149 | But you are free to validate the data however you want. 150 | 151 | After the validation, you may also want to display error messages below the 152 | input fields. For this purpose, the `ReactJSONForm` component accepts an `errorMap` 153 | prop which is basically a mapping of field names in the data and error messages. 154 | 155 | An `errorMap` looks like this: 156 | 157 | ```js 158 | let errorMap = { 159 | 'name': 'This field is required', 160 | 161 | // multiple error messages 162 | 'age': [ 163 | 'This field is required', 164 | 'This value must be greater than 18' 165 | ] 166 | 167 | // nested arrays and objects 168 | 169 | // first item in array 170 | '0': 'This is required', 171 | 172 | // first item > object > property: name 173 | // (see note below about the section sign "§") 174 | '0§name': 'This is required' 175 | } 176 | ``` 177 | 178 |
179 |

The section sign (§)

180 |

181 | The section sign (§) is used as the separator symbol for 182 | doing nested items lookup. 183 |

184 |

185 | Earlier, the hyphen (-) was used but that complicated things 186 | when the the schema object properties (i.e. field names) also had a hyphen 187 | in them. Then it became impossible to determine whether the hyphen was the 188 | separator or part of the key. 189 |

190 |
191 | 192 | 193 | ##### `DataValidator` API reference 194 | 195 | ##### Constructor 196 | 197 | ##### `new DataValidator(schema)` 198 | 199 | **Returns**: An instance of `DataValidator` 200 | 201 | **Arguments**: 202 | 203 | - `schema`: Schema object (not JSON string). 204 | 205 | ##### Instance methods 206 | 207 | Following methods must be called form the instance of `DataValidator`. 208 | 209 | ##### `validatorInstance.validate(data)` 210 | 211 | **Returns**: A *validation* object containing these keys: 212 | 213 | - `isValid`: A boolean denoting whether the data is valid or not. 214 | - `errorMap`: An object containing error messages for invalid data fields. 215 | 216 | **Arguments**: 217 | 218 | - `data`: The data to validate against the `schema` provided to the constructor. 219 | 220 | Example: 221 | 222 | ```jsx 223 | import {DataValidator} from '@bhch/react-json-form'; 224 | 225 | const validator = new DataValidator(schema); 226 | const validation = validator.validate(data); 227 | 228 | const isValid = validation.isValid; 229 | const errorMap = validation.errorMap; 230 | 231 | if (isValid) 232 | alert('Success'); 233 | else 234 | alert('Invalid'); 235 | 236 | // pass the errorMap object to ReactJSONForm 237 | // and error messages will be displayed under 238 | // input fields 239 | 243 | ``` 244 | -------------------------------------------------------------------------------- /src/form.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {getArrayFormRow, getObjectFormRow, getOneOfFormRow, getAnyOfFormRow, getAllOfFormRow} from './ui'; 3 | import {EditorContext, joinCoords, splitCoords, getSchemaType} from './util'; 4 | import {FIELD_NAME_PREFIX} from './constants'; 5 | import EditorState from './editorState'; 6 | 7 | 8 | export default class ReactJSONForm extends React.Component { 9 | handleChange = (coords, value) => { 10 | /* 11 | e.target.name is a chain of indices and keys: 12 | xxx-0-key-1-key2 and so on. 13 | These can be used as coordinates to locate 14 | a particular deeply nested item. 15 | 16 | This first coordinate is not important and should be removed. 17 | */ 18 | coords = splitCoords(coords); 19 | 20 | coords.shift(); // remove first coord 21 | 22 | // :TODO: use immutable JS instead of JSON-ising the data 23 | let data = setDataUsingCoords(coords, JSON.parse(JSON.stringify(this.props.editorState.getData())), value); 24 | 25 | this.props.onChange(EditorState.update(this.props.editorState, data)); 26 | } 27 | 28 | getRef = (ref) => { 29 | /* Returns schema reference. Nothing to do with React's refs.*/ 30 | 31 | return EditorState.getRef(ref, this.props.editorState.getSchema()); 32 | } 33 | 34 | getFields = () => { 35 | let data = this.props.editorState.getData(); 36 | let schema = this.props.editorState.getSchema(); 37 | let formGroups = []; 38 | 39 | if (schema.hasOwnProperty('$ref')) { 40 | schema = {...this.getRef(schema['$ref']), ...schema}; 41 | delete schema['$ref']; 42 | } 43 | 44 | let type = getSchemaType(schema); 45 | 46 | let args = { 47 | data: data, 48 | schema: schema, 49 | name: FIELD_NAME_PREFIX, 50 | onChange: this.handleChange, 51 | onAdd: this.addFieldset, 52 | onRemove: this.removeFieldset, 53 | onEdit: this.editFieldset, 54 | onMove: this.moveFieldset, 55 | level: 0, 56 | getRef: this.getRef, 57 | errorMap: this.props.errorMap || {} 58 | }; 59 | 60 | if (this.props.readonly) 61 | args.schema.readOnly = true; 62 | 63 | if (type === 'array') 64 | return getArrayFormRow(args); 65 | else if (type === 'object') 66 | return getObjectFormRow(args); 67 | else if (type === 'oneOf') 68 | return getOneOfFormRow(args); 69 | else if (type === 'anyOf') 70 | return getAnyOfFormRow(args); 71 | else if (type === 'allOf') 72 | return getAllOfFormRow(args); 73 | 74 | return formGroups; 75 | } 76 | 77 | addFieldset = (blankData, coords) => { 78 | coords = splitCoords(coords); 79 | coords.shift(); 80 | 81 | // :TODO: use immutable JS instead of JSON-ising the data 82 | let data = addDataUsingCoords(coords, JSON.parse(JSON.stringify(this.props.editorState.getData())), blankData); 83 | 84 | this.props.onChange(EditorState.update(this.props.editorState, data)); 85 | } 86 | 87 | removeFieldset = (coords) => { 88 | coords = splitCoords(coords); 89 | coords.shift(); 90 | 91 | // :TODO: use immutable JS instead of JSON-ising the data 92 | let data = removeDataUsingCoords(coords, JSON.parse(JSON.stringify(this.props.editorState.getData()))); 93 | 94 | this.props.onChange(EditorState.update(this.props.editorState, data)); 95 | } 96 | 97 | editFieldset = (value, newCoords, oldCoords) => { 98 | /* Add and remove in a single state update 99 | 100 | newCoords will be added 101 | oldCoords willbe removed 102 | */ 103 | 104 | newCoords = splitCoords(newCoords); 105 | newCoords.shift(); 106 | 107 | oldCoords = splitCoords(oldCoords); 108 | oldCoords.shift(); 109 | 110 | let data = addDataUsingCoords(newCoords, JSON.parse(JSON.stringify(this.props.editorState.getData())), value); 111 | 112 | data = removeDataUsingCoords(oldCoords, data); 113 | 114 | this.props.onChange(EditorState.update(this.props.editorState, data)); 115 | } 116 | 117 | moveFieldset = (oldCoords, newCoords) => { 118 | oldCoords = splitCoords(oldCoords); 119 | oldCoords.shift(); 120 | 121 | newCoords = splitCoords(newCoords); 122 | newCoords.shift(); 123 | 124 | // :TODO: use immutable JS instead of JSON-ising the data 125 | let data = moveDataUsingCoords(oldCoords, newCoords, JSON.parse(JSON.stringify(this.props.editorState.getData()))); 126 | 127 | this.props.onChange(EditorState.update(this.props.editorState, data)); 128 | } 129 | 130 | render() { 131 | return ( 132 |
133 |
134 | 140 | {this.getFields()} 141 | 142 |
143 |
144 | ); 145 | } 146 | } 147 | 148 | function setDataUsingCoords(coords, data, value) { 149 | let coord = coords.shift(); 150 | 151 | if (!isNaN(Number(coord))) 152 | coord = Number(coord); 153 | 154 | if (coords.length) { 155 | data[coord] = setDataUsingCoords(coords, data[coord], value); 156 | } else { 157 | if (coord === undefined) // top level array with multiselect widget 158 | data = value; 159 | else 160 | data[coord] = value; 161 | } 162 | 163 | return data; 164 | } 165 | 166 | function addDataUsingCoords(coords, data, value) { 167 | let coord = coords.shift(); 168 | if (!isNaN(Number(coord))) 169 | coord = Number(coord); 170 | 171 | if (coords.length) { 172 | data[coord] = addDataUsingCoords(coords, data[coord], value); 173 | } else { 174 | if (Array.isArray(data[coord])) { 175 | data[coord].push(value); 176 | } else { 177 | if (Array.isArray(data)) { 178 | data.push(value); 179 | } else { 180 | data[coord] = value; 181 | } 182 | } 183 | } 184 | 185 | return data; 186 | } 187 | 188 | function removeDataUsingCoords(coords, data) { 189 | let coord = coords.shift(); 190 | if (!isNaN(Number(coord))) 191 | coord = Number(coord); 192 | 193 | if (coords.length) { 194 | removeDataUsingCoords(coords, data[coord]); 195 | } else { 196 | if (Array.isArray(data)) 197 | data.splice(coord, 1); // in-place mutation 198 | else 199 | delete data[coord]; 200 | } 201 | 202 | return data; 203 | } 204 | 205 | 206 | function moveDataUsingCoords(oldCoords, newCoords, data) { 207 | let oldCoord = oldCoords.shift(); 208 | 209 | if (!isNaN(Number(oldCoord))) 210 | oldCoord = Number(oldCoord); 211 | 212 | if (oldCoords.length) { 213 | moveDataUsingCoords(oldCoords, newCoords, data[oldCoord]); 214 | } else { 215 | if (Array.isArray(data)) { 216 | /* Using newCoords allows us to move items from 217 | one array to another. 218 | However, for now, we're only moving items in a 219 | single array. 220 | */ 221 | let newCoord = newCoords[newCoords.length - 1]; 222 | 223 | let item = data[oldCoord]; 224 | 225 | data.splice(oldCoord, 1); 226 | data.splice(newCoord, 0, item); 227 | } 228 | } 229 | 230 | return data; 231 | } 232 | -------------------------------------------------------------------------------- /docs/static/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/schemaValidation.js: -------------------------------------------------------------------------------- 1 | import {normalizeKeyword, getSchemaType} from './util'; 2 | 3 | 4 | export function validateSchema(schema) { 5 | if (!(schema instanceof Object)) 6 | return {isValid: false, msg: "Schema must be an object"}; 7 | 8 | let type = getSchemaType(schema); 9 | 10 | let validation = {isValid: true, msg: ""}; 11 | if (type === 'object') 12 | validation = validateObject(schema); 13 | else if (type === 'array') 14 | validation = validateArray(schema); 15 | else { 16 | if (schema.hasOwnProperty('allOf')) { 17 | validation = validateAllOf(schema); 18 | } else if (schema.hasOwnProperty('oneOf')) { 19 | validation = validateOneOf(schema); 20 | } else if (schema.hasOwnProperty('anyOf')) { 21 | validation = validateAnyOf(schema); 22 | } else if (schema.hasOwnProperty('$ref')) { 23 | validation = {isValid: true}; 24 | } else { 25 | validation = { 26 | isValid: false, 27 | msg: "Outermost schema can only be of type array, list, object or dict" 28 | }; 29 | } 30 | } 31 | 32 | if (!validation.isValid || !schema.hasOwnProperty('$defs')) 33 | return validation; 34 | 35 | // validate $defs 36 | // :TODO: validate $defs nested inside objects/arrays 37 | if (!(schema['$defs']) instanceof Object) 38 | return { 39 | isValid: false, 40 | msg: "'$defs' must be a valid JavaScript Object" 41 | }; 42 | 43 | return validation; 44 | } 45 | 46 | 47 | export function validateObject(schema) { 48 | if ( 49 | !schema.hasOwnProperty('keys') && 50 | !schema.hasOwnProperty('properties') && 51 | !schema.hasOwnProperty('oneOf') && 52 | !schema.hasOwnProperty('anyOf') && 53 | !schema.hasOwnProperty('allOf') 54 | ) 55 | return { 56 | isValid: false, 57 | msg: "Schema of type '" + schema.type + "' must have at least one of these keys: " + 58 | "['properties' or 'keys' or 'oneOf' or 'anyOf' or 'allOf']" 59 | }; 60 | 61 | let validation; 62 | 63 | let keys = schema.properties || schema.keys; 64 | if (keys) { 65 | validation = validateKeys(keys); 66 | if (!validation.isValid) 67 | return validation; 68 | } 69 | 70 | if (schema.hasOwnProperty('additionalProperties')) { 71 | if (!(schema.additionalProperties instanceof Object) && typeof schema.additionalProperties !== 'boolean') 72 | return { 73 | isValid: false, 74 | msg: "'additionalProperties' must be either a JavaScript boolean or a JavaScript object" 75 | }; 76 | 77 | if (schema.additionalProperties instanceof Object) { 78 | if (schema.additionalProperties.hasOwnProperty('$ref')) { 79 | validation = validateRef(schema.additionalProperties); 80 | if (!validation.isValid) 81 | return validation; 82 | } else { 83 | let type = normalizeKeyword(schema.additionalProperties.type); 84 | 85 | if (type === 'object') 86 | return validateObject(schema.additionalProperties); 87 | else if (type === 'array') 88 | return validateSchema(schema.additionalProperties); 89 | /* :TODO: else validate allowed types */ 90 | } 91 | } 92 | 93 | } 94 | 95 | if (schema.hasOwnProperty('oneOf')) { 96 | validation = validateOneOf(schema); 97 | if (!validation.isValid) 98 | return validation; 99 | } 100 | 101 | if (schema.hasOwnProperty('anyOf')) { 102 | validation = validateAnyOf(schema); 103 | if (!validation.isValid) 104 | return validation; 105 | } 106 | 107 | if (schema.hasOwnProperty('allOf')) { 108 | validation = validateAllOf(schema); 109 | if (!validation.isValid) 110 | return validation; 111 | } 112 | 113 | return {isValid: true, msg: ""}; 114 | } 115 | 116 | 117 | function validateKeys(keys) { 118 | if (!(keys instanceof Object)) 119 | return { 120 | isValid: false, 121 | msg: "The 'keys' or 'properties' key must be a valid JavaScript Object" 122 | }; 123 | 124 | for (let key in keys) { 125 | if (!keys.hasOwnProperty(key)) 126 | continue; 127 | 128 | let value = keys[key]; 129 | 130 | if (!(value instanceof Object)) 131 | return { 132 | isValid: false, 133 | msg: "Key '" + key + "' must be a valid JavaScript Object" 134 | }; 135 | 136 | let validation = {isValid: true}; 137 | 138 | let value_type = normalizeKeyword(value.type); 139 | 140 | if (value_type) { 141 | if (value_type === 'object') 142 | validation = validateObject(value); 143 | else if (value_type === 'array') 144 | validation = validateArray(value); 145 | } else if (value.hasOwnProperty('$ref')) { 146 | validation = validateRef(value); 147 | } else if (value.hasOwnProperty('oneOf')) { 148 | validation = validateOneOf(value); 149 | } else if (value.hasOwnProperty('anyOf')) { 150 | validation = validateAnyOf(value); 151 | } else if (value.hasOwnProperty('allOf')) { 152 | validation = validateAllOf(value); 153 | } else if (value.hasOwnProperty('const')) { 154 | validation = validateConst(value); 155 | } else { 156 | validation = {isValid: false, msg: "Key '" + key + "' must have a 'type' or a '$ref"}; 157 | } 158 | 159 | if (!validation.isValid) 160 | return validation; 161 | } 162 | 163 | return {isValid: true, msg: ""}; 164 | } 165 | 166 | 167 | export function validateArray(schema) { 168 | if (!schema.hasOwnProperty('items')) 169 | return { 170 | isValid: false, 171 | msg: "Schema of type '" + schema.type + "' must have a key called 'items'" 172 | }; 173 | 174 | if (!(schema.items instanceof Object)) 175 | return { 176 | isValid: false, 177 | msg: "The 'items' key must be a valid JavaScript Object'" 178 | }; 179 | 180 | let items_type = normalizeKeyword(schema.items.type); 181 | 182 | if (items_type) { 183 | if (items_type === 'object') 184 | return validateObject(schema.items); 185 | else if (items_type === 'array') 186 | return validateArray(schema.items); 187 | /* :TODO: else validate allowed types */ 188 | } else if (schema.items.hasOwnProperty('$ref')) { 189 | return validateRef(schema.items); 190 | } else { 191 | if (!schema.items.hasOwnProperty('oneOf') && 192 | !schema.items.hasOwnProperty('anyOf') && 193 | !schema.items.hasOwnProperty('allOf') && 194 | !schema.items.hasOwnProperty('const') 195 | ) 196 | return {isValid: false, msg: "Array 'items' must have a 'type' or '$ref' or 'oneOf' or 'anyOf'"}; 197 | } 198 | 199 | if (schema.items.hasOwnProperty('oneOf')) { 200 | validation = validateOneOf(schema.items); 201 | if (!validation.isValid) 202 | return validation; 203 | } 204 | 205 | if (schema.items.hasOwnProperty('anyOf')) { 206 | validation = validateAnyOf(schema.items); 207 | if (!validation.isValid) 208 | return validation; 209 | } 210 | 211 | if (schema.items.hasOwnProperty('allOf')) { 212 | // we don't support allOf inside array yet 213 | return { 214 | isValid: false, 215 | msg: "Currently, 'allOf' inside array items is not supported" 216 | } 217 | } 218 | 219 | if (schema.items.hasOwnProperty('const')) { 220 | validation = validateConst(schema.items); 221 | if (!validation.isValid) 222 | return validation; 223 | } 224 | 225 | return {isValid: true, msg: ""}; 226 | } 227 | 228 | 229 | export function validateRef(schema) { 230 | if (typeof schema['$ref'] !== 'string') 231 | return { 232 | isValid: false, 233 | msg: "'$ref' keyword must be a string" 234 | }; 235 | 236 | if (!schema['$ref'].startsWith('#')) 237 | return { 238 | isValid: false, 239 | msg: "'$ref' value must begin with a hash (#) character" 240 | }; 241 | 242 | if (schema['$ref'].lenght > 1 && !schema['$ref'].startsWith('#/')) 243 | return { 244 | isValid: false, 245 | msg: "Invalid '$ref' path" 246 | }; 247 | 248 | return {isValid: true, msg: ""}; 249 | } 250 | 251 | 252 | export function validateOneOf(schema) { 253 | return validateSubschemas(schema, 'oneOf'); 254 | } 255 | 256 | 257 | export function validateAnyOf(schema) { 258 | return validateSubschemas(schema, 'anyOf'); 259 | } 260 | 261 | 262 | export function validateAllOf(schema) { 263 | let validation = validateSubschemas(schema, 'allOf'); 264 | if (!validation.isValid) 265 | return validation; 266 | 267 | // currently, we only support anyOf inside an object 268 | // so, we'll check if all subschemas are objects or not 269 | 270 | let subschemas = schema['allOf']; 271 | 272 | for (let i = 0; i < subschemas.length; i++) { 273 | let subschema = subschemas[i]; 274 | let subType = getSchemaType(subschema); 275 | 276 | if (subType !== 'object') { 277 | return { 278 | isValid: false, 279 | msg: "Possible conflict in 'allOf' subschemas. Currently, we only support subschemas listed in 'allOf' to be of type 'object'." 280 | } 281 | } 282 | } 283 | 284 | return validation 285 | } 286 | 287 | 288 | function validateConst(schema) { 289 | return {isValid: true, msg: ""}; 290 | } 291 | 292 | 293 | function validateSubschemas(schema, keyword) { 294 | /* 295 | Common validator for oneOf/anyOf/allOf 296 | 297 | Params: 298 | schema: the schema containing the oneOf/anyOf/allOf subschema 299 | keyword: one of 'oneOf' or 'anyOf' or 'allOf' 300 | 301 | Validation: 302 | 1. Must be an array 303 | 2. Must have at least one subschema 304 | 3. If directly inside an object, each subschema in array must have 'properties' or 'keys keyword 305 | */ 306 | let subschemas = schema[keyword]; 307 | 308 | if (!Array.isArray(subschemas)) 309 | return { 310 | isValid: false, 311 | msg: "'" + keyword + "' property must be an array" 312 | }; 313 | 314 | if (!subschemas.length) 315 | return { 316 | isValid: false, 317 | msg: "'" + keyword + "' must contain at least one subschema" 318 | }; 319 | 320 | for (let i = 0; i < subschemas.length; i++) { 321 | let subschema = subschemas[i]; 322 | let subType = getSchemaType(subschema); 323 | 324 | if (subType === 'object') { 325 | let validation = validateObject(subschema); 326 | if (!validation.isValid) 327 | return validation; 328 | } else if (subType === 'array') { 329 | let validation = validateArray(subschema); 330 | if (!validation.isValid) 331 | return validation; 332 | } 333 | } 334 | 335 | return {isValid: true, msg: ""}; 336 | } 337 | -------------------------------------------------------------------------------- /docs/src/tabs.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {EditorState} from "@codemirror/state"; 3 | import {EditorView} from "@codemirror/view"; 4 | import {lineNumbers, highlightActiveLineGutter, highlightSpecialChars, 5 | drawSelection, highlightActiveLine, keymap 6 | } from '@codemirror/view'; 7 | import {indentOnInput, syntaxHighlighting, defaultHighlightStyle,bracketMatching} from '@codemirror/language'; 8 | import {history, defaultKeymap, historyKeymap, indentWithTab} from '@codemirror/commands'; 9 | import {closeBrackets, completionKeymap} from '@codemirror/autocomplete'; 10 | import {lintKeymap, lintGutter, linter} from '@codemirror/lint'; 11 | import {json, jsonParseLinter} from "@codemirror/lang-json"; 12 | 13 | import {ReactJSONForm, EditorState as RJFEditorState, DataValidator} from 'react-json-form'; 14 | 15 | import DEMOS from './demos.js'; 16 | 17 | export function Tabs(props) { 18 | return ( 19 |
20 |
21 |
22 | 38 |
39 |
40 |
41 | ); 42 | } 43 | 44 | 45 | export class TabContent extends React.Component { 46 | constructor(props) { 47 | super(props); 48 | 49 | this.state = { 50 | rjf_state: RJFEditorState.create(this.getActiveTabSchema(), this.getActiveTabData()), 51 | schemaError: false, 52 | errorMap: {} 53 | }; 54 | 55 | this.schemaEditorParentRef = React.createRef(); 56 | this.dataEditorParentRef = React.createRef(); 57 | } 58 | 59 | componentDidMount() { 60 | this.schemaEditorView = new EditorView({ 61 | state: this.getSchemaEditorNewState(), 62 | parent: this.schemaEditorParentRef.current 63 | }); 64 | 65 | this.dataEditorView = new EditorView({ 66 | state: this.getDataEditorNewState(), 67 | parent: this.dataEditorParentRef.current 68 | }); 69 | } 70 | 71 | componentDidUpdate(prevProps, prevState) { 72 | if (this.props.activeTabIndex !== prevProps.activeTabIndex) { 73 | this.setState({ 74 | rjf_state: RJFEditorState.create(this.getActiveTabSchema(), this.getActiveTabData()), 75 | schemaError: false, 76 | errorMap: {}, 77 | }, (state) => { 78 | 79 | this.schemaEditorView.setState(this.getSchemaEditorNewState()); 80 | this.updateDataEditor(this.getEditorData()); 81 | }); 82 | } 83 | } 84 | 85 | getSchemaEditorNewState = () => { 86 | return EditorState.create({ 87 | doc: this.getEditorSchema(), 88 | extensions: [ 89 | lineNumbers(), 90 | highlightActiveLineGutter(), 91 | highlightSpecialChars(), 92 | drawSelection(), 93 | history(), 94 | lintGutter(), 95 | indentOnInput(), 96 | syntaxHighlighting(defaultHighlightStyle, { fallback: true }), 97 | bracketMatching(), 98 | closeBrackets(), 99 | highlightActiveLine(), 100 | keymap.of([ 101 | ...indentWithTab, 102 | ...defaultKeymap, 103 | ...historyKeymap 104 | ]), 105 | json(), 106 | linter(jsonParseLinter()), 107 | 108 | EditorView.updateListener.of((update) => { 109 | if (update.docChanged) { 110 | // only update form state if the schema is valid JSON 111 | try { 112 | let newSchema = JSON.parse(update.state.doc.toString()); 113 | } catch (error) { 114 | this.setState({schemaError: 'Must be strictly valid JSON (no trailing commas, use double-quotes, etc.)'}); 115 | return; 116 | } 117 | 118 | try { 119 | 120 | let newState = RJFEditorState.create(update.state.doc.toString()); 121 | 122 | this.setState({rjf_state: newState, schemaError: false}, (state) => { 123 | this.updateDataEditor(this.getEditorData()); 124 | }); 125 | } catch (error) { 126 | // schema didn't validate 127 | this.setState({schemaError: error.toString()}); 128 | return; 129 | } 130 | } 131 | }) 132 | ] 133 | }); 134 | } 135 | 136 | getDataEditorNewState = () => { 137 | return EditorState.create({ 138 | doc: this.getEditorData(), 139 | extensions: [ 140 | lineNumbers(), 141 | highlightSpecialChars(), 142 | drawSelection(), 143 | lintGutter(), 144 | syntaxHighlighting(defaultHighlightStyle, { fallback: true }), 145 | json(), 146 | EditorState.readOnly.of(true) 147 | ] 148 | }); 149 | } 150 | 151 | updateDataEditor(data) { 152 | this.dataEditorView.dispatch({ 153 | changes: { 154 | from: 0, 155 | to: this.dataEditorView.state.doc.length, 156 | insert: data 157 | } 158 | }); 159 | } 160 | 161 | getActiveTab() { 162 | return DEMOS[this.props.activeTabIndex]; 163 | } 164 | 165 | getActiveTabSchema() { 166 | return DEMOS[this.props.activeTabIndex].schema; 167 | } 168 | 169 | getActiveTabData() { 170 | return DEMOS[this.props.activeTabIndex].data; 171 | } 172 | 173 | getEditorSchema() { 174 | /* Returns schema for the editor */ 175 | return JSON.stringify(this.state.rjf_state.getSchema(), null, 2); 176 | } 177 | 178 | getEditorData() { 179 | /* Returns data for the editor */ 180 | return JSON.stringify(this.state.rjf_state.getData(), null, 2); 181 | } 182 | 183 | handleFormChange = (rjf_state) => { 184 | this.setState({rjf_state: rjf_state}, (state) => { 185 | if (!this.dataEditorView) 186 | return; 187 | 188 | this.updateDataEditor(this.getEditorData()); 189 | }); 190 | } 191 | 192 | validateData = () => { 193 | let validator = new DataValidator(this.state.rjf_state.getSchema()); 194 | let validation = validator.validate(this.state.rjf_state.getData()); 195 | this.setState({ 196 | errorMap: validation.errorMap 197 | }); 198 | } 199 | 200 | render() { 201 | return ( 202 |
203 |
204 | 205 | 206 | 207 |
208 |
209 |

Schema

210 |
211 | {this.state.schemaError && 212 |

213 | (!) Schema is not valid 214 |
215 | {this.state.schemaError} 216 |

217 | } 218 |
219 |
220 |

Output data

221 |
222 | Read-only 223 |
224 |
225 |
226 |

Form

227 |
228 | 237 | 238 | {this.getActiveTab().slug === 'validation' && 239 |
240 | 247 |
248 | } 249 |
250 |
251 |
252 |
253 | ); 254 | } 255 | } 256 | 257 | function Description(props) { 258 | let description = DEMOS[props.activeTabIndex].description; 259 | 260 | if (!description) 261 | return null; 262 | 263 | return ( 264 |
265 |
266 |
267 | 268 | 269 | 270 | 271 |
272 |
273 | {description()} 274 |
275 |
276 |
277 | ); 278 | } -------------------------------------------------------------------------------- /src/components/autocomplete.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {FormInput, FormMultiSelectInputField} from './form'; 3 | import Loader from './loaders'; 4 | import Button from './buttons'; 5 | import Icon from './icons'; 6 | import {EditorContext, debounce, getCoordsFromName} from '../util'; 7 | 8 | 9 | export default class AutoCompleteInput extends React.Component { 10 | static contextType = EditorContext; 11 | 12 | constructor(props) { 13 | super(props); 14 | 15 | this.state = { 16 | searchInputValue: '', 17 | showOptions: false, 18 | options: [], 19 | loading: false, 20 | }; 21 | 22 | this.optionsContainer = React.createRef(); 23 | this.searchInputRef = React.createRef(); 24 | this.input = React.createRef(); 25 | 26 | this.debouncedFetchOptions = debounce(this.fetchOptions, 500); 27 | 28 | this.defaultEmptyValue = props.multiselect ? [] : ''; 29 | } 30 | 31 | componentDidUpdate(prevProps, prevState) { 32 | if (this.state.showOptions && this.state.showOptions !== prevState.showOptions) { 33 | if (this.searchInputRef.current) 34 | this.searchInputRef.current.focus(); 35 | } 36 | } 37 | 38 | handleSelect = (value) => { 39 | if (this.props.multiselect) { 40 | if (Array.isArray(this.props.value)) 41 | value = this.props.value.concat([value]); 42 | else 43 | value = [value]; 44 | } 45 | 46 | let event = { 47 | target: { 48 | type: this.props.type, 49 | value: value, 50 | name: this.props.name 51 | } 52 | }; 53 | 54 | if (!this.props.multiselect) 55 | this.hideOptions(); 56 | 57 | this.props.onChange(event); 58 | } 59 | 60 | handleMultiselectRemove = (val) => { 61 | let value = this.props.value.filter((item) => { 62 | return item !== val; 63 | }); 64 | 65 | let event = { 66 | target: { 67 | type: this.props.type, 68 | value: value, 69 | name: this.props.name 70 | } 71 | }; 72 | 73 | this.props.onChange(event); 74 | } 75 | 76 | clearValue = (e) => { 77 | this.handleSelect(this.defaultEmptyValue); 78 | } 79 | 80 | hasValue = () => { 81 | if (Array.isArray(this.props.value) && !this.props.value.length) 82 | return false; 83 | 84 | if (this.props.value === '' || this.props.value === null) 85 | return false; 86 | 87 | return true; 88 | } 89 | 90 | handleSearchInputChange = (e) => { 91 | let value = e.target.value; 92 | if (value) { 93 | this.setState({searchInputValue: value, loading: true}, this.debouncedFetchOptions); 94 | } else { 95 | this.setState({searchInputValue: value, loading: false, options: []}); 96 | } 97 | } 98 | 99 | fetchOptions = () => { 100 | if (this.state.searchInputValue === '') 101 | return; 102 | 103 | // :TODO: cache results 104 | 105 | let endpoint = this.props.handler; 106 | 107 | if (!endpoint) { 108 | console.error( 109 | "Error: No 'handler' endpoint provided for autocomplete input." 110 | ); 111 | 112 | this.setState({loading: false}); 113 | return; 114 | } 115 | 116 | let url = endpoint + '?' + new URLSearchParams({ 117 | field_name: this.context.fieldName, 118 | model_name: this.context.modelName, 119 | coords: getCoordsFromName(this.props.name), 120 | query: this.state.searchInputValue 121 | }); 122 | 123 | fetch(url, {method: 'GET'}) 124 | .then((response) => response.json()) 125 | .then((result) => { 126 | if (!Array.isArray(result.results)) 127 | result.results = []; 128 | 129 | this.setState((state) => ({ 130 | loading: false, 131 | options: [...result.results], 132 | })); 133 | }) 134 | .catch((error) => { 135 | alert('Something went wrong while fetching options'); 136 | console.error('Error:', error); 137 | this.setState({loading: false}); 138 | }); 139 | } 140 | 141 | showOptions = (e) => { 142 | if (!this.state.showOptions) 143 | this.setState({showOptions: true}); 144 | } 145 | 146 | hideOptions = (e) => { 147 | this.setState({showOptions: false, searchInputValue: '', options: [], loading: false}); 148 | } 149 | 150 | toggleOptions = (e) => { 151 | this.setState((state) => { 152 | if (state.showOptions) { 153 | return {showOptions: false, searchInputValue: '', options: [], loading: false}; 154 | } else { 155 | return {showOptions: true}; 156 | } 157 | }); 158 | } 159 | 160 | render() { 161 | return ( 162 |
163 | {this.props.multiselect ? 164 | 169 | this.handleMultiselectRemove(e.target.value)} 173 | value={this.props.value} 174 | placeholder={this.props.placeholder || ' '} 175 | disabled={this.props.readOnly || false} 176 | /> 177 | 178 | : 179 | 180 | 194 | {this.hasValue() && !this.props.readOnly && 195 | 202 | } 203 | 204 | } 205 | 206 | {this.state.showOptions && !this.props.readOnly && 207 | 221 | } 222 |
223 | ) 224 | } 225 | } 226 | 227 | class AutoCompletePopup extends React.Component { 228 | componentDidMount() { 229 | document.addEventListener('mousedown', this.handleClickOutside); 230 | } 231 | 232 | componentWillUnmount() { 233 | document.removeEventListener('mousedown', this.handleClickOutside); 234 | } 235 | 236 | handleClickOutside = (e) => { 237 | if (this.props.containerRef.current && 238 | !this.props.containerRef.current.contains(e.target) && 239 | !this.props.inputRef.current.contains(e.target) 240 | ) 241 | this.props.hideOptions(); 242 | }; 243 | 244 | render() { 245 | return ( 246 |
247 |
251 | 252 | 258 | 259 | {this.props.searchInputValue && 260 | 268 | } 269 |
270 |
271 | ); 272 | } 273 | } 274 | 275 | 276 | function AutocompleteSearchBox(props) { 277 | return ( 278 |
279 | 287 | {props.loading && } 288 |
289 | ); 290 | } 291 | 292 | 293 | function AutocompleteOptions(props) { 294 | return ( 295 |
296 | {!props.options.length && !props.loading && 297 |
No options
298 | } 299 | 300 | {props.options.map((option, i) => { 301 | let title, inputValue; 302 | if (typeof option === 'object') { 303 | title = option.title || option.label; 304 | inputValue = option.value; 305 | } else { 306 | title = option; 307 | if (typeof title === 'boolean') 308 | title = capitalize(title.toString()); 309 | inputValue = option; 310 | } 311 | 312 | let selected = false; 313 | 314 | if (Array.isArray(props.value)) 315 | selected = props.value.indexOf(inputValue) > -1; 316 | else 317 | selected = props.value === inputValue; 318 | 319 | let optionClassName = 'rjf-autocomplete-field-option'; 320 | if (selected) 321 | optionClassName += ' selected'; 322 | 323 | return ( 324 |
props.multiselect && selected ? null : props.onSelect(inputValue)} 330 | > 331 | {title} 332 |
333 | ); 334 | })} 335 |
336 | ); 337 | } 338 | -------------------------------------------------------------------------------- /src/dataValidation.js: -------------------------------------------------------------------------------- 1 | import {normalizeKeyword, getKeyword, getKey, joinCoords, getSchemaType, valueInChoices} from './util'; 2 | import {JOIN_SYMBOL} from './constants'; 3 | import EditorState from './editorState'; 4 | 5 | 6 | export default function DataValidator(schema) { 7 | this.schema = schema; 8 | this.errorMap = {}; 9 | 10 | this.validate = function(data) { 11 | // reset errorMap so that this validator object 12 | // can be reused for same schema 13 | this.errorMap = {}; 14 | 15 | let validator = this.getValidator(getSchemaType(schema)); 16 | 17 | if (validator) 18 | validator(this.schema, data, ''); 19 | else 20 | this.addError('', 'Invalid schema type: "' + schema.type + '"'); 21 | 22 | let validation = {isValid: true, errorMap: this.errorMap}; 23 | 24 | if (Object.keys(this.errorMap).length) 25 | validation['isValid'] = false; 26 | 27 | return validation; 28 | }; 29 | 30 | this.getValidator = function (schema_type) { 31 | schema_type = normalizeKeyword(schema_type); 32 | 33 | let func; 34 | 35 | switch (schema_type) { 36 | case 'array': 37 | func = this.validateArray; 38 | break; 39 | case 'object': 40 | func = this.validateObject; 41 | break; 42 | case 'allOf': 43 | func = this.validateAllOf; 44 | break; 45 | case 'oneOf': 46 | func = this.validateOneOf; 47 | break; 48 | case 'anyOf': 49 | func = this.validateAnyOf; 50 | break; 51 | case 'string': 52 | func = this.validateString; 53 | break; 54 | case 'boolean': 55 | func = this.validateBoolean; 56 | break; 57 | case 'integer': 58 | func = this.validateInteger; 59 | break; 60 | case 'number': 61 | func = this.validateNumber; 62 | break; 63 | } 64 | 65 | if (func) 66 | return func.bind(this); 67 | 68 | return func; 69 | }; 70 | 71 | this.getRef = function(ref) { 72 | return EditorState.getRef(ref, this.schema); 73 | }; 74 | 75 | this.addError = function(coords, msg) { 76 | if (!this.errorMap.hasOwnProperty(coords)) 77 | this.errorMap[coords] = []; 78 | 79 | this.errorMap[coords].push(msg); 80 | }; 81 | 82 | this.joinCoords = function(coords) { 83 | let c = joinCoords.apply(null, coords); 84 | 85 | if (c.startsWith(JOIN_SYMBOL)) 86 | c = c.slice(1); 87 | 88 | return c; 89 | } 90 | 91 | this.validateArray = function(schema, data, coords) { 92 | if (!Array.isArray(data)) { 93 | this.addError(coords, "Invalid data type. Expected array."); 94 | return; 95 | } 96 | 97 | let next_schema = schema.items; 98 | if (next_schema.hasOwnProperty('$ref')) 99 | next_schema = this.getRef(next_schema.$ref); 100 | let next_type = getSchemaType(next_schema); 101 | 102 | let minItems = getKeyword(schema, 'minItems', 'min_items'); 103 | let maxItems = getKeyword(schema, 'maxItems', 'max_items'); 104 | 105 | if (minItems && data.length < parseInt(minItems)) 106 | this.addError(coords, 'Minimum ' + minItems + ' items required.'); 107 | 108 | if (maxItems && data.length > parseInt(maxItems)) 109 | this.addError(coords, 'Maximum ' + maxItems + ' items allowed.'); 110 | 111 | if (getKey(schema, 'uniqueItems')) { 112 | let items_type = next_type; 113 | if (items_type === 'array' || items_type === 'object') { 114 | if (data.length !== new Set(data.map((i) => JSON.stringify(i))).size) 115 | this.addError(coords, 'All items in this list must be unique.'); 116 | } else { 117 | if (data.length !== new Set(data).size) 118 | this.addError(coords, 'All items in this list must be unique.'); 119 | } 120 | } 121 | 122 | let next_validator = this.getValidator(next_type); 123 | 124 | // currently allOf is not supported in array items 125 | if (next_type === 'allOf') 126 | next_validator = null; 127 | 128 | if (next_validator) { 129 | for (let i = 0; i < data.length; i++) 130 | next_validator(next_schema, data[i], this.joinCoords([coords, i])); 131 | } else 132 | this.addError(coords, 'Unsupported type "' + next_type + '" for array items.'); 133 | }; 134 | 135 | this.validateObject = function(schema, data, coords) { 136 | if (typeof data !== 'object' || Array.isArray(data)) { 137 | this.addError(coords, "Invalid data type. Expected object."); 138 | return; 139 | } 140 | 141 | let fields = getKeyword(schema, 'properties', 'keys', {}); 142 | 143 | let data_keys = Object.keys(data); 144 | let missing_keys = Object.keys(fields).filter((i) => data_keys.indexOf(i) === -1); 145 | 146 | if (missing_keys.length) { 147 | this.addError(coords, 'These fields are missing from the data: ' + missing_keys.join(', ')); 148 | return; 149 | } 150 | 151 | for (let key in data) { 152 | if (!data.hasOwnProperty(key)) 153 | continue; 154 | 155 | let next_schema; 156 | 157 | if (fields.hasOwnProperty(key)) 158 | next_schema = fields[key]; 159 | else { 160 | if (!schema.hasOwnProperty('additionalProperties')) 161 | continue; 162 | 163 | next_schema = schema.additionalProperties; 164 | 165 | if (next_schema === true) 166 | next_schema = {type: 'string'}; 167 | } 168 | 169 | if (next_schema.hasOwnProperty('$ref')) 170 | next_schema = this.getRef(next_schema.$ref); 171 | 172 | if (schema.hasOwnProperty('required') && Array.isArray(schema.required)) { 173 | if (schema.required.indexOf(key) > -1 && !next_schema.hasOwnProperty('required')) 174 | next_schema['required'] = true; 175 | } 176 | 177 | let next_type = getSchemaType(next_schema); 178 | 179 | let next_validator = this.getValidator(next_type); 180 | 181 | if (next_validator) 182 | next_validator(next_schema, data[key], this.joinCoords([coords, key])); 183 | else { 184 | this.addError(coords, 'Unsupported type "' + next_type + '" for object properties (keys).'); 185 | return; 186 | } 187 | } 188 | 189 | if (schema.hasOwnProperty('allOf')) 190 | this.validateAllOf(schema, data, coords); 191 | }; 192 | 193 | this.validateAllOf = function(schema, data, coords) { 194 | /* Currently, we only support allOf inside object 195 | so we assume the given type to be an object. 196 | */ 197 | 198 | let newSchema = {type: 'object', properties: {}}; 199 | 200 | // combine subschemas 201 | for (let i = 0; i < schema.allOf.length; i++) { 202 | let subschema = schema.allOf[i]; 203 | 204 | if (subschema.hasOwnProperty('$ref')) 205 | subschema = this.getRef(subschema.$ref); 206 | 207 | let fields = getKeyword(subschema, 'properties', 'keys', {}); 208 | 209 | for (let field in fields) 210 | newSchema.properties[field] = fields[field]; 211 | } 212 | 213 | this.validateObject(newSchema, data, coords); 214 | }; 215 | 216 | this.validateOneOf = function(schema, data, coords) { 217 | // :TODO: 218 | }; 219 | 220 | this.validateAnyOf = function(schema, data, coords) { 221 | // :TODO: 222 | }; 223 | 224 | this.validateString = function(schema, data, coords) { 225 | if (schema.required && !data) { 226 | this.addError(coords, 'This field is required.'); 227 | return; 228 | } 229 | 230 | if (typeof data !== 'string') { 231 | this.addError(coords, 'This value is invalid. Must be a valid string.'); 232 | return; 233 | } 234 | 235 | if (!data) // not required, can be empty 236 | return; 237 | 238 | if (schema.minLength && data.length < parseInt(schema.minLength)) 239 | this.addError(coords, 'This value must be at least ' + schema.minLength + ' characters long.'); 240 | 241 | if ((schema.maxLength || schema.maxLength == 0) && data.length > parseInt(schema.maxLength)) 242 | this.addError(coords, 'This value may not be longer than ' + schema.maxLength + ' characters.'); 243 | 244 | if (!valueInChoices(schema, data)) { 245 | this.addError(coords, 'Invalid choice "' + data + '"'); 246 | return; 247 | } 248 | 249 | let format = normalizeKeyword(schema.format); 250 | let format_invalid = false; 251 | let format_validator; 252 | 253 | switch (format) { 254 | case 'email': 255 | format_validator = this.validateEmail; 256 | break; 257 | case 'date': 258 | format_validator = this.validateDate; 259 | break; 260 | case 'time': 261 | format_validator = this.validateTime; 262 | break; 263 | case 'date-time': 264 | format_validator = this.validateDateTime; 265 | break; 266 | } 267 | 268 | if (format_validator) 269 | format_validator.call(this, schema, data, coords); 270 | }; 271 | 272 | this.validateBoolean = function(schema, data, coords) { 273 | if (schema.required && (data === null || data === undefined)) { 274 | this.addError(coords, 'This field is required.'); 275 | return; 276 | } 277 | 278 | if (typeof data !== 'boolean' && data !== null && data !== undefined) 279 | this.addError(coords, 'Invalid value.'); 280 | }; 281 | 282 | this.validateInteger = function(schema, data, coords) { 283 | if (schema.required && (data === null || data === undefined)) { 284 | this.addError(coords, 'This field is required.'); 285 | return; 286 | } 287 | 288 | if (data === null) // not required, integer can be null 289 | return; 290 | 291 | if (typeof data !== 'number') { 292 | this.addError(coords, 'Invalid value. Only integers allowed.'); 293 | return; 294 | } 295 | 296 | // 1.0 and 1 must be treated equal 297 | if (data !== parseInt(data)) { 298 | this.addError(coords, 'Invalid value. Only integers allowed.'); 299 | return; 300 | } 301 | 302 | this.validateNumber(schema, data, coords); 303 | }; 304 | 305 | this.validateNumber = function(schema, data, coords) { 306 | if (schema.required && (data === null || data === undefined)) { 307 | this.addError(coords, 'This field is required.'); 308 | return; 309 | } 310 | 311 | if (data === null) // not required, number can be null 312 | return; 313 | 314 | if (typeof data !== 'number') { 315 | this.addError(coords, 'Invalid value. Only numbers allowed.'); 316 | return; 317 | } 318 | 319 | if ((schema.minimum || schema.minimum === 0) && data < schema.minimum) 320 | this.addError(coords, 'This value must not be less than ' + schema.minimum); 321 | 322 | if ((schema.maximum || schema.maximum === 0) && data > schema.maximum) 323 | this.addError(coords, 'This value must not be greater than ' + schema.maximum); 324 | 325 | if ((schema.exclusiveMinimum || schema.exclusiveMinimum === 0) && data <= schema.exclusiveMinimum) 326 | this.addError(coords, 'This value must be greater than ' + schema.exclusiveMinimum); 327 | 328 | if ((schema.exclusiveMaximum || schema.exclusiveMaximum === 0) && data >= schema.exclusiveMaximum) 329 | this.addError(coords, 'This value must be less than ' + schema.exclusiveMaximum); 330 | 331 | if ((schema.multipleOf || schema.multipleOf === 0) && ((data * 100) % (schema.multipleOf * 100)) / 100) 332 | this.addError(coords, 'This value must be a multiple of ' + schema.multipleOf); 333 | 334 | if (!valueInChoices(schema, data)) { 335 | this.addError(coords, 'Invalid choice "' + data + '"'); 336 | return; 337 | } 338 | }; 339 | 340 | this.validateEmail = function(schema, data, coords) { 341 | // half-arsed validation but will do for the time being 342 | if (data.indexOf(' ') > -1 ) { 343 | this.addError(coords, 'Enter a valid email address.'); 344 | return; 345 | } 346 | 347 | if (data.length > 320) { 348 | this.addError(coords, 'Email may not be longer than 320 characters'); 349 | return; 350 | } 351 | }; 352 | 353 | this.validateDate = function(schema, data, coords) { 354 | // :TODO: 355 | }; 356 | 357 | this.validateTime = function(schema, data, coords) { 358 | // :TODO: 359 | 360 | }; 361 | 362 | this.validateDateTime = function(schema, data, coords) { 363 | // :TODO: 364 | 365 | }; 366 | } 367 | -------------------------------------------------------------------------------- /docs/src/demos.js: -------------------------------------------------------------------------------- 1 | const DEMOS = [ 2 | { 3 | name: 'Array/List', 4 | slug: 'array-list', 5 | schema: { 6 | type: 'array', 7 | title: 'Shopping list', 8 | description: 'Add items to your shopping list', 9 | items: { 10 | type: 'string' 11 | }, 12 | minItems: 1, 13 | maxItems: 5 14 | }, 15 | data: ['eggs', 'juice', 'milk'] 16 | }, 17 | 18 | { 19 | name: 'Object/Dict', 20 | slug: 'object-dict', 21 | schema: { 22 | type: 'object', 23 | description: 'Fill in your personal details', 24 | keys: { 25 | first_name: {type: 'string'}, 26 | last_name: {type: 'string'}, 27 | age: {type: 'integer'}, 28 | } 29 | } 30 | }, 31 | 32 | { 33 | name: 'Additional properties', 34 | slug: 'additional-properties', 35 | schema: { 36 | type: 'object', 37 | title: 'Product attributes', 38 | keys: { 39 | brand: {type: 'string'}, 40 | colour: {type: 'string'} 41 | }, 42 | additionalProperties: {type: 'string'} 43 | }, 44 | data: { 45 | brand: 'Nokia', 46 | colour: 'black', 47 | weight: '150 gm' 48 | } 49 | }, 50 | 51 | { 52 | name: 'Enum (Choices)', 53 | slug: 'enum-choices', 54 | schema: { 55 | type: 'object', 56 | keys: { 57 | country: { 58 | type: 'string', 59 | choices: ['Australia', 'India', 'United Kingdom', 'United States'] 60 | } 61 | } 62 | }, 63 | }, 64 | 65 | { 66 | name: 'Choices with custom titles', 67 | slug: 'choices-with-custom-titles', 68 | schema: { 69 | type: 'object', 70 | keys: { 71 | country: { 72 | type: 'string', 73 | choices: [ 74 | {title: '🇦🇺 Australia', value: 'au'}, 75 | {title: '🇮🇳 India', value: 'in'}, 76 | {title: '🇬🇧 United Kingdom', value: 'gb'}, 77 | {title: '🇺🇸 United States', value: 'us'}, 78 | ] 79 | } 80 | } 81 | } 82 | }, 83 | 84 | { 85 | name: 'Multi select choices', 86 | slug: 'multi-select-choices', 87 | schema: { 88 | type: 'array', 89 | title: 'Cities', 90 | items: { 91 | type: 'string', 92 | choices: ['New york', 'London', 'Mumbai', 'Tokyo'], 93 | widget: 'multiselect' 94 | } 95 | }, 96 | description: () => ( 97 |
98 | Multiple selections only work inside an array.
99 | Each selected item is added to the array in the selection order. 100 |
101 | ) 102 | }, 103 | 104 | { 105 | name: 'Boolean', 106 | slug: 'boolean', 107 | schema: { 108 | type: 'object', 109 | keys: { 110 | isActive: {type: 'boolean', title: 'Is active'}, 111 | isActive2: { 112 | type: 'boolean', 113 | title: 'Are you sure?', 114 | widget: 'radio', 115 | choices: [ 116 | {title: 'Yes', value: true}, 117 | {title: 'No', value: false}, 118 | ] 119 | }, 120 | isActive3: { 121 | type: 'boolean', 122 | title: 'Really?', 123 | widget: 'select', 124 | choices: [ 125 | {title: 'Yes', value: true}, 126 | {title: 'No', value: false}, 127 | ] 128 | }, 129 | } 130 | }, 131 | description: () => ( 132 |
133 | Boolean fields get a radio input by default. 134 | But you can also use checkbox or a select input via 135 | the widget keyword. 136 |
137 | ) 138 | }, 139 | 140 | { 141 | name: 'Referencing ($ref & $defs)', 142 | slug: 'referencing', 143 | schema: { 144 | type: 'object', 145 | keys: { 146 | name: {type: 'string'}, 147 | shipping_address: {'$ref': '#/$defs/address'}, 148 | billing_address: {'$ref': '#/$defs/address'}, 149 | }, 150 | '$defs': { 151 | address: { 152 | type: 'object', 153 | keys: { 154 | house: {type: 'string'}, 155 | street: {type: 'string'}, 156 | city: {type: 'string'}, 157 | postal_code: {type: 'string'}, 158 | } 159 | } 160 | } 161 | } 162 | }, 163 | 164 | { 165 | name: 'Recursion', 166 | slug: 'recursion', 167 | schema: { 168 | type: 'array', 169 | items: { 170 | type: 'object', 171 | title: 'Person', 172 | keys: { 173 | name: {type: 'string'}, 174 | age: {type: 'integer'}, 175 | children: {'$ref': '#'} 176 | } 177 | } 178 | }, 179 | data: [{name: 'Alice', age: 90, children: []}], 180 | description: () => ( 181 |
You can recursively nest an item within itself. 182 | However, there are certain edge cases where it might lead to infinite recursion error. So, be careful! 183 |
184 | ) 185 | }, 186 | 187 | { 188 | name: 'File inputs', 189 | slug: 'file-inputs', 190 | schema: { 191 | 'type': 'object', 192 | 'properties': { 193 | 'base64_upload': {type: 'string', 'format': 'data-url'}, 194 | 'server_upload': { 195 | type: 'string', 196 | 'format': 'file-url', 197 | 'helpText': 'Default input for file-url opens a modal', 198 | }, 199 | 'simple_input': { 200 | type: 'string', 201 | 'format': 'file-url', 202 | 'widget': 'fileinput', 203 | 'helpText': 'Custom input using widget: \'fileinput\'' 204 | }, 205 | } 206 | }, 207 | data: {}, 208 | description: () => ( 209 |
210 | File upload to server (file-url) 211 | will not work in this demo because a server is required. 212 | However, Base64 upload (data-url) will work fine. 213 |
214 | ) 215 | }, 216 | 217 | { 218 | name: 'Date & Time', 219 | slug: 'date-time', 220 | schema: { 221 | type: 'object', 222 | keys: { 223 | date: { 224 | type: 'string', 225 | format: 'date' 226 | }, 227 | time: { 228 | type: 'string', 229 | format: 'time' 230 | }, 231 | datetime: { 232 | type: 'string', 233 | format: 'date-time', 234 | helpText: 'For datetime input, a custom input is used' 235 | } 236 | } 237 | } 238 | }, 239 | 240 | { 241 | name: 'Autocomplete', 242 | slug: 'autocomplete', 243 | schema: { 244 | type: 'object', 245 | keys: { 246 | country: {type: 'string', widget: 'autocomplete', handler: 'https://run.mocky.io/v3/b4f1afe0-01a5-4218-988c-36b320a7373c'}, 247 | } 248 | }, 249 | description: () => ( 250 |
251 | This demo uses a placeholder JSON API to load data. Hence, autocompletion may not work as expected. 252 |
253 | ) 254 | }, 255 | 256 | { 257 | name: 'Multi select + Autocomplete', 258 | slug: 'multiselect-autocomplete', 259 | schema: { 260 | type: 'array', 261 | title: 'Countries', 262 | items: { 263 | type: 'string', 264 | widget: 'multiselect-autocomplete', 265 | handler: 'https://run.mocky.io/v3/b4f1afe0-01a5-4218-988c-36b320a7373c' 266 | } 267 | }, 268 | description: () => ( 269 |
270 | Multiple selections only work inside an array.
271 | This demo uses a placeholder JSON API to load data. Hence, autocompletion may not work as expected. 272 |
273 | ) 274 | }, 275 | 276 | { 277 | name: 'Textarea', 278 | slug: 'textarea', 279 | schema: { 280 | type: 'object', 281 | keys: { 282 | title: {type: 'string'}, 283 | body: {type: 'string', widget: 'textarea'} 284 | } 285 | } 286 | }, 287 | 288 | { 289 | name: 'Range input', 290 | slug: 'range', 291 | schema: { 292 | type: 'object', 293 | title: 'Range input', 294 | properties: { 295 | volume: {type: 'number', widget: 'range', minimum: 0, maximum: 10} 296 | } 297 | } 298 | }, 299 | 300 | { 301 | name: 'Placeholder & Help text', 302 | slug: 'placehlder-help-text', 303 | schema: { 304 | type: 'object', 305 | keys: { 306 | name: { 307 | type: 'string', 308 | placeholder: 'Placeholder text', 309 | helpText: 'This is a help text' 310 | } 311 | } 312 | } 313 | }, 314 | 315 | { 316 | name: 'Readonly & Hidden inputs', 317 | slug: 'readonly-hidden-inputs', 318 | schema: { 319 | type: 'object', 320 | keys: { 321 | first_name: { 322 | type: 'string', 323 | placeholder: 'Readonly input', 324 | readonly: true 325 | }, 326 | last_name: { 327 | type: 'string', 328 | widget: 'hidden' 329 | } 330 | } 331 | }, 332 | description: () => ( 333 |
334 | The following schema has two inputs. 335 | first_name is readonly and last_name is hidden so it's not visible. 336 |
337 | ) 338 | }, 339 | 340 | { 341 | name: 'Formats', 342 | slug: 'formats', 343 | schema: { 344 | type: 'object', 345 | title: 'Available input formats', 346 | keys: { 347 | email: {type: 'string', format: 'email'}, 348 | password: {type: 'string', format: 'password'}, 349 | colour: {type: 'string', format: 'color'}, 350 | url: {type: 'string', format: 'uri', 'title': 'URL'}, 351 | } 352 | }, 353 | data: { 354 | 'email': 'john@example.com', 355 | 'password': 'correcthorsebatterystaple', 356 | 'colour': '#ffff00', 357 | 'url': 'http://example.com' 358 | } 359 | }, 360 | 361 | { 362 | name: 'Validation', 363 | slug: 'validation', 364 | schema: { 365 | type: 'object', 366 | title: 'Press "Submit" to validate data', 367 | keys: { 368 | name: {type: 'string', required: true}, 369 | age: {type: 'number', required: true, minimum: 50}, 370 | } 371 | } 372 | }, 373 | 374 | { 375 | name: 'AnyOf', 376 | slug: 'anyof', 377 | schema: { 378 | type: 'object', 379 | title: 'Person info', 380 | properties: { 381 | name: {type: 'string'}, 382 | age_or_birthdate: { 383 | title: 'Age or Birthdate', 384 | anyOf: [ 385 | {type: 'integer', title: 'Age'}, 386 | {type: 'string', 'format': 'date-time', title: 'Birthdate'}, 387 | ] 388 | }, 389 | contacts: { 390 | type: 'array', 391 | items: { 392 | anyOf: [ 393 | {type: 'string', title: 'Email', placeholder: 'you@example.com'}, 394 | {type: 'integer', title: 'Phone', placeholder: '1234567890'}, 395 | ] 396 | } 397 | } 398 | }, 399 | }, 400 | }, 401 | 402 | { 403 | name: 'OneOf', 404 | slug: 'oneof', 405 | schema: { 406 | type: 'object', 407 | properties: { 408 | location: { 409 | oneOf: [ 410 | { 411 | type: 'object', 412 | title: 'Coordinates', 413 | properties: { 414 | latitude: {type: 'number'}, 415 | longitude: {type: 'number'}, 416 | } 417 | }, 418 | { 419 | type: 'object', 420 | title: 'City & Country', 421 | properties: { 422 | city: {type: 'string'}, 423 | country: {type: 'string'}, 424 | } 425 | }, 426 | ] 427 | }, 428 | secret_code: { 429 | oneOf: [ 430 | {type: 'integer', title: 'Numeric code'}, 431 | {type: 'string', title: 'String code'}, 432 | ] 433 | }, 434 | }, 435 | }, 436 | }, 437 | 438 | { 439 | name: 'AllOf', 440 | slug: 'allof', 441 | schema: { 442 | type: 'object', 443 | title: 'Person', 444 | allOf: [ 445 | { 446 | properties: { 447 | name: {type: 'string'} 448 | } 449 | }, 450 | { 451 | properties: { 452 | age: {type: 'integer'} 453 | } 454 | }, 455 | ] 456 | }, 457 | description: () => ( 458 |
459 | Currently, allOf supports very limited features. 460 | It only works inside objects and won't work inside arrays or other types. 461 |
462 | ) 463 | 464 | }, 465 | 466 | { 467 | name: 'Constants', 468 | slug: 'const', 469 | schema: { 470 | type: 'object', 471 | title: 'Person', 472 | properties: { 473 | species: {const: 'Homo Sapiens'}, 474 | name: {type: 'string'} 475 | }, 476 | }, 477 | }, 478 | ]; 479 | 480 | 481 | export default DEMOS; 482 | -------------------------------------------------------------------------------- /dev/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | JSON Editor 6 | 397 | 398 | 399 | 400 |

JSON Editor

401 | 402 |
403 |
404 |
405 |
406 |
407 | 408 |
409 |
410 | 411 | 412 | 413 | 414 | 415 | 416 | 462 | 463 | 497 | 498 | 499 | -------------------------------------------------------------------------------- /src/components/uploader.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactModal from 'react-modal'; 3 | import Button from './buttons'; 4 | import Loader from './loaders'; 5 | import {EditorContext, capitalize, getCsrfCookie, getCoordsFromName} from '../util'; 6 | import {FormFileInput, Label} from './form.js'; 7 | import Icon from './icons'; 8 | 9 | export default class FileUploader extends React.Component { 10 | static contextType = EditorContext; 11 | 12 | constructor(props) { 13 | super(props); 14 | 15 | this.state = { 16 | value: props.value, 17 | //fileName: this.getFileName(), 18 | loading: false, 19 | open: false, 20 | pane: 'upload' 21 | }; 22 | 23 | this.hiddenInputRef = React.createRef(); 24 | 25 | this.newFiles = []; // track new uploaded files to send DELETE request 26 | // on page exit if unsaved 27 | this.exitListenersAdded = false; 28 | } 29 | 30 | openModal = (e) => { 31 | this.setState({open: true}); 32 | } 33 | 34 | closeModal = (e) => { 35 | this.setState({open: false, pane: 'upload'}); 36 | } 37 | 38 | togglePane = (name) => { 39 | this.setState({pane: name}); 40 | } 41 | 42 | handleFileSelect = (value) => { 43 | // we create a fake event 44 | let event = { 45 | target: { 46 | type: 'text', 47 | value: value, 48 | name: this.props.name 49 | } 50 | }; 51 | 52 | this.props.onChange(event); 53 | 54 | this.closeModal(); 55 | } 56 | 57 | handleFileUpload = (e) => { 58 | this.newFiles.push(e.target.value); 59 | this.addExitEventListeners(); 60 | 61 | this.props.onChange(e); 62 | this.closeModal(); 63 | } 64 | 65 | addExitEventListeners = () => { 66 | /* Sets page exit (unload) event listeners. 67 | * 68 | * The purpose of these listeners is to send a DELETE 69 | * request uf user leaves page WITHOUT SAVING FORM. 70 | * 71 | * The event listeners are only added if there a
element 72 | * parent of this react-jsonform component because if there's 73 | * no form to save, then the user will always have to leave 74 | * without saving. Hence, no point in sending unsaved DELETE requests. 75 | */ 76 | 77 | if (this.exitListenersAdded) 78 | return; 79 | 80 | if (!this.hiddenInputRef.current) 81 | return; 82 | 83 | if (!this.hiddenInputRef.current.form) 84 | return; 85 | 86 | window.addEventListener('beforeunload', this.promptOnExit); 87 | window.addEventListener('unload', this.sendDeleteRequestOnExit); 88 | 89 | this.hiddenInputRef.current.form.addEventListener('submit', (e) => { 90 | window.removeEventListener('beforeunload', this.promptOnExit); 91 | window.removeEventListener('unload', this.sendDeleteRequestOnExit); 92 | }); 93 | 94 | this.exitListenersAdded = true; 95 | } 96 | 97 | promptOnExit = (e) => { 98 | if (!this.newFiles.length) 99 | return; 100 | 101 | e.preventDefault(); 102 | e.returnValue = ''; 103 | } 104 | 105 | sendDeleteRequestOnExit = (e) => { 106 | if (!this.newFiles.length) 107 | return; 108 | 109 | this.sendDeleteRequest([this.newFiles], 'unsaved_form_page_exit', true); 110 | } 111 | 112 | clearFile = () => { 113 | if (window.confirm('Do you want to remove this file?')) { 114 | let event = { 115 | target: { 116 | type: 'text', 117 | value: '', 118 | name: this.props.name 119 | } 120 | }; 121 | 122 | this.props.onChange(event); 123 | } 124 | } 125 | 126 | sendDeleteRequest = (values, trigger, keepalive) => { 127 | /* Sends DELETE request to file handler endpoint. 128 | * 129 | * Prams: 130 | * values: (array) names of files to delete 131 | * trigger: (string) the action which triggered the deletion 132 | * keepalive: (bool) whether to use keepalive flag or not 133 | */ 134 | 135 | let endpoint = this.props.handler || this.context.fileHandler; 136 | 137 | let querystring = new URLSearchParams({ 138 | ...this.context.fileHandlerArgs, 139 | coords: getCoordsFromName(this.props.name), 140 | trigger: trigger 141 | }); 142 | 143 | for (let i = 0; i < values.length; i++) { 144 | querystring.append('value', values[i]); 145 | } 146 | 147 | let url = endpoint + '?' + querystring; 148 | 149 | let options = { 150 | method: 'DELETE', 151 | headers: { 152 | 'X-CSRFToken': getCsrfCookie(), 153 | }, 154 | }; 155 | if (keepalive) 156 | options['keepalive'] = true; 157 | 158 | return fetch(url, options); 159 | } 160 | 161 | render() { 162 | if (!this.props.handler && !this.context.fileHandler) { 163 | return ; 164 | } 165 | 166 | return ( 167 |
168 |
248 | ); 249 | } 250 | } 251 | 252 | 253 | function TabButton(props) { 254 | let className = 'rjf-upload-modal__tab-button'; 255 | if (props.active) 256 | className += ' rjf-upload-modal__tab-button--active'; 257 | 258 | return ( 259 | 265 | ); 266 | } 267 | 268 | 269 | function UploadPane(props) { 270 | return ( 271 |
272 |

Upload new

273 |
274 | 275 |
276 | ); 277 | } 278 | 279 | 280 | class LibraryPane extends React.Component { 281 | constructor(props) { 282 | super(props); 283 | 284 | this.state = { 285 | loading: true, 286 | files: [], 287 | page: 0, // current page 288 | hasMore: true, 289 | }; 290 | } 291 | 292 | componentDidMount() { 293 | //setTimeout(() => this.setState({loading: false}), 1000); 294 | this.fetchList(); 295 | } 296 | 297 | fetchList = () => { 298 | let endpoint = this.props.fileHandler; 299 | 300 | if (!endpoint) { 301 | console.error( 302 | "Error: fileHandler option need to be passed " 303 | + "while initializing editor for enabling file listing."); 304 | this.setState({loading: false, hasMore: false}); 305 | return; 306 | } 307 | 308 | let url = endpoint + '?' + new URLSearchParams({ 309 | ...this.props.fileHandlerArgs, 310 | page: this.state.page + 1 311 | }); 312 | 313 | fetch(url, {method: 'GET'}) 314 | .then((response) => response.json()) 315 | .then((result) => { 316 | if (!Array.isArray(result.results)) 317 | result.results = []; 318 | 319 | this.setState((state) => ({ 320 | loading: false, 321 | files: [...state.files, ...result.results], 322 | page: result.results.length > 0 ? state.page + 1 : state.page, 323 | hasMore: result.results.length > 0, 324 | }) 325 | ); 326 | }) 327 | .catch((error) => { 328 | alert('Something went wrong while retrieving media files'); 329 | console.error('Error:', error); 330 | this.setState({loading: false}); 331 | }); 332 | } 333 | 334 | onLoadMore = (e) => { 335 | this.setState({loading: true}, this.fetchList); 336 | } 337 | 338 | onFileDelete = () => { 339 | this.setState({page: 0, files: []}, this.onLoadMore); 340 | } 341 | 342 | render() { 343 | return ( 344 |
345 |

Media library

346 | 347 |
348 | {this.state.files.map((i) => { 349 | return ( 350 | 356 | ); 357 | })} 358 |
359 | 360 | {this.state.loading && } 361 | 362 | {!this.state.loading && this.state.hasMore && 363 |
364 | 367 |
368 | } 369 | {!this.state.hasMore && 370 |
371 | {this.state.files.length ? 'End of list' : 'No files found'} 372 |
373 | } 374 |
375 | ); 376 | } 377 | } 378 | 379 | 380 | const DEFAULT_THUBNAIL = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' fill='%23999999' viewBox='0 0 16 16'%3E%3Cpath d='M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z'/%3E%3C/svg%3E"; 381 | 382 | 383 | function MediaTile(props) { 384 | let metadata = props.metadata || {}; 385 | 386 | return ( 387 |
388 | 393 |
props.onClick(props.value)}> 394 | 395 | {props.metadata && 396 |
397 | {Object.getOwnPropertyNames(metadata).map((key) => { 398 | return {metadata[key]}; 399 | })} 400 |
401 | } 402 |
403 |
404 | ); 405 | } 406 | 407 | 408 | class MediaTileMenu extends React.Component { 409 | constructor(props) { 410 | super(props); 411 | 412 | this.state = { 413 | open: false, 414 | loading: false 415 | }; 416 | } 417 | 418 | toggleMenu = (e) => { 419 | this.setState((state) => ({open: !state.open})); 420 | } 421 | 422 | handleDeleteClick = (e) => { 423 | if (window.confirm('Do you want to delete this file?')) { 424 | this.setState({loading: true}); 425 | this.props.sendDeleteRequest([this.props.value], 'delete_button') 426 | .then((response) => { 427 | let status = response.status; 428 | let msg; 429 | 430 | if (status === 200) { 431 | // success 432 | } else if (status === 400) 433 | msg = 'Bad request'; 434 | else if (status === 401 || status === 403) 435 | msg = "You don't have permission to delete this file"; 436 | else if (status === 404) 437 | msg = 'This file does not exist on server'; 438 | else if (status === 405) 439 | msg = 'This operation is not permitted'; 440 | else if (status > 405) 441 | msg = 'Something went wrong while deleting file'; 442 | 443 | this.setState({loading: false, open: false}); 444 | 445 | if (msg) 446 | alert(msg); 447 | else 448 | this.props.onFileDelete(); 449 | }) 450 | .catch((error) => { 451 | alert('Something went wrong while deleting file'); 452 | console.error('Error:', error); 453 | this.setState({loading: false}); 454 | }); 455 | } 456 | } 457 | 458 | render() { 459 | return ( 460 |
461 | 469 | {this.state.open && 470 |
471 | 479 |
480 | } 481 |
482 | ); 483 | } 484 | } 485 | -------------------------------------------------------------------------------- /src/data.js: -------------------------------------------------------------------------------- 1 | import {normalizeKeyword, getKeyword, getSchemaType, actualType, 2 | isEqualset, isSubset, valueInChoices} from './util'; 3 | import {FILLER} from './constants'; 4 | 5 | 6 | export function getBlankObject(schema, getRef) { 7 | let keys = {}; 8 | 9 | let schema_keys = getKeyword(schema, 'keys', 'properties', {}); 10 | 11 | for (let key in schema_keys) { 12 | let value = schema_keys[key]; 13 | 14 | let isRef = value.hasOwnProperty('$ref'); 15 | let isConst = value.hasOwnProperty('const'); 16 | 17 | if (isRef) { 18 | value = {...getRef(value['$ref']), ...value}; 19 | delete value['$ref']; 20 | } 21 | 22 | let type = normalizeKeyword(value.type); 23 | 24 | if (!type) { 25 | // check for oneOf/anyOf 26 | if (value.hasOwnProperty('oneOf')) 27 | value = value.oneOf[0]; 28 | else if (value.hasOwnProperty('anyOf')) 29 | value = value.anyOf[0]; 30 | 31 | type = normalizeKeyword(value.type); 32 | } 33 | 34 | let default_ = value.default; 35 | 36 | if (isConst) { 37 | type = actualType(value.const); 38 | default_ = value.const; 39 | } 40 | 41 | if (type === 'array') 42 | keys[key] = isRef ? [] : getBlankArray(value, getRef); 43 | else if (type === 'object') 44 | keys[key] = getBlankObject(value, getRef); 45 | else if (type === 'boolean') 46 | keys[key] = default_ === false ? false : (default_ || null); 47 | else if (type === 'integer' || type === 'number') 48 | keys[key] = default_ === 0 ? 0 : (default_ || null); 49 | else 50 | keys[key] = default_ || ''; 51 | } 52 | 53 | if (schema.hasOwnProperty('oneOf')) 54 | keys = {...keys, ...getBlankObject(schema.oneOf[0])}; 55 | 56 | if (schema.hasOwnProperty('anyOf')) 57 | keys = {...keys, ...getBlankObject(schema.anyOf[0])}; 58 | 59 | if (schema.hasOwnProperty('allOf')) { 60 | for (let i = 0; i < schema.allOf.length; i++) { 61 | keys = {...keys, ...getBlankObject(schema.allOf[i])}; 62 | } 63 | } 64 | 65 | return keys; 66 | } 67 | 68 | 69 | export function getBlankArray(schema, getRef) { 70 | let minItems = getKeyword(schema, 'minItems', 'min_items') || 0; 71 | 72 | if (schema.default && schema.default.length >= minItems) 73 | return schema.default; 74 | 75 | let items = []; 76 | 77 | if (schema.default) 78 | items = [...schema.default]; 79 | 80 | if (minItems === 0) 81 | return items; 82 | 83 | if (schema.items.hasOwnProperty('$ref')) { 84 | // :TODO: this mutates the original schema 85 | // but i'll fix it later 86 | schema.items = {...getRef(schema.items['$ref']), ...schema.items}; 87 | delete schema.items['$ref']; 88 | } 89 | 90 | let type = normalizeKeyword(schema.items.type); 91 | 92 | if (!type) { 93 | if (Array.isArray(schema.items['oneOf'])) 94 | type = getSchemaType(schema.items.oneOf[0]); 95 | else if (Array.isArray(schema.items['anyOf'])) 96 | type = getSchemaType(schema.items.anyOf[0]); 97 | else if (Array.isArray(schema.items['allOf'])) 98 | type = getSchemaType(schema.items.allOf[0]); 99 | else if (schema.items.hasOwnProperty('const')) 100 | type = actualType(schema.items.const); 101 | } 102 | 103 | if (type === 'array') { 104 | while (items.length < minItems) 105 | items.push(getBlankArray(schema.items, getRef)); 106 | return items; 107 | } else if (type === 'object') { 108 | while (items.length < minItems) 109 | items.push(getBlankObject(schema.items, getRef)); 110 | return items; 111 | } else if (type === 'oneOf') { 112 | while (items.length < minItems) 113 | items.push(getBlankOneOf(schema.items, getRef)); 114 | return items; 115 | } else if (type === 'anyOf') { 116 | while (items.length < minItems) 117 | items.push(getBlankOneOf(schema.items, getRef)); 118 | return items; 119 | } 120 | 121 | if (schema.items.widget === 'multiselect') 122 | return items; 123 | 124 | let default_ = schema.items.default; 125 | 126 | if (schema.items.hasOwnProperty('const')) 127 | default_ = schema.items.const; 128 | 129 | if (type === 'boolean') { 130 | while (items.length < minItems) 131 | items.push(default_ === false ? false : (default_ || null)); 132 | } else if (type === 'integer' || type === 'number') { 133 | while (items.length < minItems) 134 | items.push(default_ === 0 ? 0 : (default_ || null)); 135 | } else { 136 | // string, etc. 137 | while (items.length < minItems) 138 | items.push(default_ || ''); 139 | } 140 | 141 | return items; 142 | } 143 | 144 | 145 | export function getBlankAllOf(schema, getRef) { 146 | // currently, we support allOf only inside an object 147 | return getBlankObject(schema, getRef); 148 | } 149 | 150 | 151 | export function getBlankOneOf(schema, getRef) { 152 | // for blank data, we always return the first option 153 | let nextSchema = schema.oneOf[0]; 154 | 155 | let type = getSchemaType(nextSchema); 156 | 157 | return getBlankData(nextSchema, getRef); 158 | } 159 | 160 | 161 | export function getBlankAnyOf(schema, getRef) { 162 | // for blank data, we always return the first option 163 | let nextSchema = schema.anyOf[0]; 164 | 165 | let type = getSchemaType(nextSchema); 166 | 167 | return getBlankData(nextSchema, getRef); 168 | } 169 | 170 | 171 | export function getBlankData(schema, getRef) { 172 | if (schema.hasOwnProperty('$ref')) { 173 | schema = {...getRef(schema['$ref']), ...schema}; 174 | delete schema['$ref']; 175 | } 176 | 177 | let type = getSchemaType(schema); 178 | 179 | let default_ = schema.default; 180 | 181 | if (schema.hasOwnProperty('const')) { 182 | type = actualType(schema.const); 183 | default_ = schema.const; 184 | } 185 | 186 | if (type === 'array') 187 | return getBlankArray(schema, getRef); 188 | else if (type === 'object') 189 | return getBlankObject(schema, getRef); 190 | else if (type === 'allOf') 191 | return getBlankAllOf(schema, getRef); 192 | else if (type === 'oneOf') 193 | return getBlankOneOf(schema, getRef); 194 | else if (type === 'anyOf') 195 | return getBlankAnyOf(schema, getRef); 196 | else if (type === 'boolean') 197 | return default_ === false ? false : (default_ || null); 198 | else if (type === 'integer' || type === 'number') 199 | return default_ === 0 ? 0 : (default_ || null); 200 | else // string, etc. 201 | return default_ || ''; 202 | } 203 | 204 | 205 | function getSyncedArray(data, schema, getRef) { 206 | if (data === null) 207 | data = []; 208 | 209 | if (actualType(data) !== 'array') 210 | throw new Error("Schema expected an 'array' but the data type was '" + actualType(data) + "'"); 211 | 212 | let newData = JSON.parse(JSON.stringify(data)); 213 | 214 | if (schema.items.hasOwnProperty('$ref')) { 215 | // :TODO: this will most probably mutate the original schema 216 | // but i'll fix it later 217 | schema.items = {...getRef(schema.items['$ref']), ...schema.items}; 218 | delete schema.items['$ref']; 219 | } 220 | 221 | let type; 222 | let default_; 223 | 224 | if (schema.items.hasOwnProperty('const')) { 225 | type = actualType(schema.items.const); 226 | default_ = schema.items.const; 227 | } else { 228 | type = normalizeKeyword(schema.items.type); 229 | default_ = schema.items.defualt; 230 | } 231 | 232 | let minItems = schema.minItems || schema.min_items || 0; 233 | 234 | if (schema.items.widget !== 'multiselect') { 235 | while (data.length < minItems) 236 | data.push(FILLER); 237 | } 238 | 239 | for (let i = 0; i < data.length; i++) { 240 | let item = data[i]; 241 | 242 | if (type === 'array') { 243 | if (item === FILLER) 244 | item = []; 245 | newData[i] = getSyncedArray(item, schema.items, getRef); 246 | } else if (type === 'object') { 247 | if (item === FILLER) 248 | item = {}; 249 | newData[i] = getSyncedObject(item, schema.items, getRef); 250 | } else { 251 | // if the current value is not in choices, we reset to blank 252 | if (!valueInChoices(schema.items, newData[i])) 253 | item = FILLER; 254 | 255 | if (item === FILLER) { 256 | if (type === 'integer' || type === 'number') 257 | newData[i] = default_ === 0 ? 0 : (default_ || null); 258 | else if (type === 'boolean') 259 | newData[i] = default_ === false ? false : (default_ || null); 260 | else 261 | newData[i] = default_ || ''; 262 | } 263 | } 264 | 265 | if (schema.items.hasOwnProperty('const')) 266 | newData[i] = schema.items.const; 267 | } 268 | 269 | return newData; 270 | } 271 | 272 | 273 | function getSyncedObject(data, schema, getRef) { 274 | if (data === null) 275 | data = {}; 276 | 277 | if (actualType(data) !== 'object') 278 | throw new Error("Schema expected an 'object' but the data type was '" + actualType(data) + "'"); 279 | 280 | let newData = JSON.parse(JSON.stringify(data)); 281 | 282 | let schema_keys = getKeyword(schema, 'keys', 'properties', {}); 283 | 284 | if (schema.hasOwnProperty('allOf')) { 285 | for (let i = 0; i < schema.allOf.length; i++) { 286 | // ignore items in allOf which are not object 287 | if (getSchemaType(schema.allOf[i]) !== 'object') 288 | continue; 289 | 290 | schema_keys = {...schema_keys, ...getKeyword(schema.allOf[i], 'properties', 'keys', {})}; 291 | } 292 | } 293 | 294 | let keys = [...Object.keys(schema_keys)]; 295 | 296 | for (let i = 0; i < keys.length; i++) { 297 | let key = keys[i]; 298 | let schemaValue = schema_keys[key]; 299 | let isRef = schemaValue.hasOwnProperty('$ref'); 300 | 301 | if (isRef) { 302 | schemaValue = {...getRef(schemaValue['$ref']), ...schemaValue}; 303 | delete schemaValue['$ref']; 304 | } 305 | 306 | let type; 307 | let default_; 308 | 309 | if (schemaValue.hasOwnProperty('const')) { 310 | type = actualType(schemaValue.const); 311 | default_ = schemaValue.const; 312 | } else { 313 | type = getSchemaType(schemaValue); 314 | default_ = schemaValue.default; 315 | } 316 | 317 | if (!data.hasOwnProperty(key)) { 318 | /* This key is declared in schema but it's not present in the data. 319 | So we can use blank data here. 320 | */ 321 | if (type === 'array') 322 | newData[key] = getSyncedArray([], schemaValue, getRef); 323 | else if (type === 'object') 324 | newData[key] = getSyncedObject({}, schemaValue, getRef); 325 | else if (type === 'oneOf') 326 | newData[key] = getBlankOneOf(schemaValue, getRef); 327 | else if (type === 'anyOf') 328 | newData[key] = getBlankAnyOf(schemaValue, getRef); 329 | else if (type === 'boolean') 330 | newData[key] = default_ === false ? false : (default_ || null); 331 | else if (type === 'integer' || type === 'number') 332 | newData[key] = default_ === 0 ? 0 : (default_ || null); 333 | else 334 | newData[key] = default_ || ''; 335 | } else { 336 | if (type === 'array') 337 | newData[key] = getSyncedArray(data[key], schemaValue, getRef); 338 | else if (type === 'object') 339 | newData[key] = getSyncedObject(data[key], schemaValue, getRef); 340 | else if (type === 'oneOf') 341 | newData[key] = getSyncedOneOf(data[key], schemaValue, getRef); 342 | else if (type === 'anyOf') 343 | newData[key] = getSyncedAnyOf(data[key], schemaValue, getRef); 344 | else { 345 | // if the current value is not in choices, we reset to blank 346 | if (!valueInChoices(schemaValue, data[key])) 347 | data[key] = ''; 348 | 349 | if (data[key] === '') { 350 | if (type === 'integer' || type === 'number') 351 | newData[key] = default_ === 0 ? 0 : (default_ || null); 352 | else if (type === 'boolean') 353 | newData[key] = default_ === false ? false : (default_ || null); 354 | else 355 | newData[key] = default_ || ''; 356 | } else { 357 | newData[key] = data[key]; 358 | } 359 | } 360 | } 361 | 362 | if (schemaValue.hasOwnProperty('const')) 363 | newData[key] = schemaValue.const; 364 | } 365 | 366 | if (schema.hasOwnProperty('oneOf')) 367 | newData = {...newData, ...getSyncedOneOf(data, schema, getRef)}; 368 | 369 | if (schema.hasOwnProperty('anyOf')) 370 | newData = {...newData, ...getSyncedAnyOf(data, schema, getRef)}; 371 | 372 | return newData; 373 | } 374 | 375 | 376 | export function getSyncedAllOf(data, schema, getRef) { 377 | // currently we only support allOf inside an object 378 | // so, we'll treat the curent schema and data to be an object 379 | 380 | return getSyncedObject(data, schema, getRef); 381 | } 382 | 383 | 384 | export function getSyncedOneOf(data, schema, getRef) { 385 | let index = findMatchingSubschemaIndex(data, schema, getRef, 'oneOf'); 386 | let subschema = schema['oneOf'][index]; 387 | 388 | let syncFunc = getSyncFunc(getSchemaType(subschema)); 389 | 390 | if (syncFunc) 391 | return syncFunc(data, subschema, getRef); 392 | 393 | return data; 394 | } 395 | 396 | 397 | export function getSyncedAnyOf(data, schema, getRef) { 398 | let index = findMatchingSubschemaIndex(data, schema, getRef, 'anyOf'); 399 | let subschema = schema['anyOf'][index]; 400 | 401 | let syncFunc = getSyncFunc(getSchemaType(subschema)); 402 | 403 | if (syncFunc) 404 | return syncFunc(data, subschema, getRef); 405 | 406 | return data; 407 | } 408 | 409 | 410 | export function getSyncedData(data, schema, getRef) { 411 | // adds those keys to data which are in schema but not in data 412 | if (schema.hasOwnProperty('$ref')) { 413 | schema = {...getRef(schema['$ref']), ...schema}; 414 | delete schema['$ref']; 415 | } 416 | 417 | let type = getSchemaType(schema); 418 | 419 | let syncFunc = getSyncFunc(type); 420 | 421 | if (syncFunc) 422 | return syncFunc(data, schema, getRef); 423 | 424 | return data; 425 | } 426 | 427 | 428 | function getSyncFunc(type) { 429 | if (type === 'array') 430 | return getSyncedArray; 431 | else if (type === 'object') 432 | return getSyncedObject; 433 | else if (type === 'allOf') 434 | return getSyncedAllOf; 435 | else if (type === 'oneOf') 436 | return getSyncedOneOf; 437 | else if (type === 'anyOf') 438 | return getSyncedAnyOf; 439 | 440 | return null; 441 | } 442 | 443 | 444 | export function findMatchingSubschemaIndex(data, schema, getRef, schemaName) { 445 | let dataType = actualType(data); 446 | let subschemas = schema[schemaName]; 447 | 448 | let index = null; 449 | 450 | for (let i = 0; i < subschemas.length; i++) { 451 | let subschema = subschemas[i]; 452 | 453 | if (subschema.hasOwnProperty('$ref')) { 454 | subschema = {...getRef(subschema['$ref']), ...subschema}; 455 | delete subschema['$ref']; 456 | } 457 | 458 | let subType = getSchemaType(subschema); 459 | 460 | if (dataType === 'object') { 461 | // check if all keys match 462 | if (dataObjectMatchesSchema(data, subschema)) { 463 | index = i; 464 | break; 465 | } 466 | } else if (dataType === 'array') { 467 | // check if item types match 468 | if (dataArrayMatchesSchema(data, subschema)) { 469 | index = i; 470 | break; 471 | } 472 | } else if (dataType === subType) { 473 | index = i; 474 | break; 475 | } 476 | } 477 | 478 | if (index === null) { 479 | // no exact match found 480 | // so we'll just return the first schema that matches the data type 481 | for (let i = 0; i < subschemas.length; i++) { 482 | let subschema = subschemas[i]; 483 | 484 | if (subschema.hasOwnProperty('$ref')) { 485 | subschema = {...getRef(subschema['$ref']), ...subschema}; 486 | delete subschema['$ref']; 487 | } 488 | 489 | let subType = getSchemaType(subschema); 490 | 491 | if (dataType === subType) { 492 | index = i; 493 | break; 494 | } 495 | } 496 | } 497 | 498 | if (index === null) { 499 | // still no match found 500 | if (data === null) // for null data, return the first subschema and hope for the best 501 | index = 0; 502 | else // for anything else, throw error 503 | throw new Error("No matching subschema found in '" + schemaName + "' for data '" + data + "' (type: " + dataType + ")"); 504 | } 505 | 506 | return index; 507 | } 508 | 509 | export function dataObjectMatchesSchema(data, subschema) { 510 | let dataType = actualType(data); 511 | let subType = getSchemaType(subschema); 512 | 513 | if (subType !== dataType) 514 | return false; 515 | 516 | let subSchemaKeys = getKeyword(subschema, 'properties', 'keys', {}); 517 | 518 | // check if all keys in the schema are present in the data 519 | keyset1 = new Set(Object.keys(data)); 520 | keyset2 = new Set(Object.keys(subSchemaKeys)); 521 | 522 | if (subschema.hasOwnProperty('additionalProperties')) { 523 | // subSchemaKeys must be a subset of data 524 | if (!isSubset(keyset2, keyset1)) 525 | return false; 526 | } else { 527 | // subSchemaKeys must be equal to data 528 | if (!isEqualset(keyset2, keyset1)) 529 | return false; 530 | } 531 | 532 | for (let key in subSchemaKeys) { 533 | if (!subSchemaKeys.hasOwnProperty(key)) 534 | continue; 535 | 536 | if (!data.hasOwnProperty(key)) 537 | return false; 538 | 539 | if (subSchemaKeys[key].hasOwnProperty('const')) { 540 | if (subSchemaKeys[key].const !== data[key]) 541 | return false; 542 | } 543 | 544 | let keyType = normalizeKeyword(subSchemaKeys[key].type); 545 | let dataValueType = actualType(data[key]); 546 | 547 | if (keyType === 'number' && ['number', 'integer', 'null'].indexOf(dataValueType) === -1) { 548 | return false; 549 | } else if (keyType === 'integer' && ['number', 'integer', 'null'].indexOf(dataValueType) === -1) { 550 | return false; 551 | } else if (keyType === 'boolean' && ['boolean', 'null'].indexOf(dataValueType) === -1) { 552 | return false; 553 | } else if (keyType === 'string' && dataValueType !== 'string') { 554 | return false; 555 | } 556 | 557 | // TODO: also check minimum, maximum, etc. keywords 558 | } 559 | 560 | // if here, all checks have passed 561 | return true; 562 | } 563 | 564 | 565 | export function dataArrayMatchesSchema(data, subschema) { 566 | let dataType = actualType(data); 567 | let subType = getSchemaType(subschema); 568 | 569 | if (subType !== dataType) 570 | return false; 571 | 572 | let itemsType = subschema.items.type; // Temporary. Nested subschemas inside array.items won't work. 573 | 574 | // check each item in data conforms to array items.type 575 | for (let i = 0; i < data.length; i++) { 576 | dataValueType = actualType(data[i]); 577 | 578 | if (subschema.items.hasOwnProperty('const')) { 579 | if (subschema.items.const !== data[i]) 580 | return false; 581 | } 582 | 583 | if (itemsType === 'number' && ['number', 'integer', 'null'].indexOf(dataValueType) === -1) { 584 | return false; 585 | } else if (itemsType === 'integer' && ['number', 'integer', 'null'].indexOf(dataValueType) === -1) { 586 | return false; 587 | } else if (itemsType === 'boolean' && ['boolean', 'null'].indexOf(dataValueType) === -1) { 588 | return false; 589 | } else if (itemsType === 'string' && dataValueType !== 'string') { 590 | return false; 591 | } 592 | } 593 | 594 | // if here, all checks have passed 595 | return true; 596 | } --------------------------------------------------------------------------------