├── src ├── fields │ ├── DateField.js │ ├── NullField.js │ ├── ObjectField.js │ ├── NumberField.js │ ├── IntegerField.js │ ├── index.js │ ├── ArrayField.js │ ├── StringField.js │ ├── AbstractEnumerableField.js │ ├── BooleanField.js │ └── AbstractField.js ├── widgets │ ├── HiddenWidget.js │ ├── TextWidget │ │ ├── EditHandle.js │ │ ├── SaveHandle.js │ │ ├── CancelHandle.js │ │ ├── Handle.js │ │ └── index.js │ ├── PasswordWidget.js │ ├── EmailWidget.js │ ├── RadioWidget.js │ ├── ArrayWidget │ │ ├── getItemPosition.js │ │ ├── AddHandle.js │ │ ├── OrderHandle.js │ │ ├── RemoveHandle.js │ │ ├── Item.js │ │ ├── DraggableItem.js │ │ └── index.js │ ├── ZipWidget.js │ ├── IntegerWidget.js │ ├── TextareaWidget.js │ ├── ScheduleWidget │ │ ├── fillSchedule.js │ │ ├── CheckAll.js │ │ └── index.js │ ├── PhoneWidget.js │ ├── FileWidget │ │ ├── FileArea.js │ │ ├── RemoveHandle.js │ │ ├── UploadHandle.js │ │ ├── OrderHandle.js │ │ ├── ProgressHandle.js │ │ └── index.js │ ├── ObjectWidget │ │ ├── index.js │ │ └── createGrid.js │ ├── ErrorWidget.js │ ├── index.js │ ├── AutocompleteWidget.js │ ├── SelectWidget.js │ ├── TimeRangeWidget.js │ ├── DateWidget.js │ ├── LabelWidget.js │ ├── TagInputWidget.js │ ├── CheckboxWidget.js │ ├── RatingWidget.js │ ├── NumberWidget.js │ └── TextInputWidget.js ├── FormEvent.js ├── Div.js ├── CancelButton.js ├── SubmitButton.js ├── index.d.ts ├── index.js └── utils.js ├── .travis.yml ├── renovate.json ├── .babelrc ├── .npmignore ├── .gitignore ├── PULL_REQUEST_TEMPLATE.md ├── ISSUE_TEMPLATE.md ├── .eslintrc ├── package.json └── README.md /src/fields/DateField.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/widgets/HiddenWidget.js: -------------------------------------------------------------------------------- 1 | const HiddenWidget = () => null; 2 | 3 | export default HiddenWidget; 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: 3 | - node_js 4 | node_js: 5 | - "10" 6 | env: 7 | - ACTION="run lint" 8 | - ACTION="run build" 9 | script: 10 | - yarn $ACTION -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ], 5 | "semanticCommits": true, 6 | "automerge": true, 7 | "major": { 8 | "automerge": false 9 | }, 10 | "masterIssue": true 11 | } 12 | -------------------------------------------------------------------------------- /src/widgets/TextWidget/EditHandle.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Handle from './Handle'; 3 | 4 | const EditHandle = props => ( 5 | 6 | Edit 7 | 8 | ); 9 | 10 | export default EditHandle; 11 | -------------------------------------------------------------------------------- /src/widgets/TextWidget/SaveHandle.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Handle from './Handle'; 3 | 4 | const SaveHandle = props => ( 5 | 6 | Save 7 | 8 | ); 9 | 10 | export default SaveHandle; 11 | -------------------------------------------------------------------------------- /src/widgets/PasswordWidget.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TextInputWidget from './TextInputWidget'; 3 | 4 | const PasswordWidget = props => ( 5 | 6 | ); 7 | 8 | export default PasswordWidget; 9 | -------------------------------------------------------------------------------- /src/widgets/TextWidget/CancelHandle.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Handle from './Handle'; 3 | 4 | const CancelHandle = props => ( 5 | 6 | Cancel 7 | 8 | ); 9 | 10 | export default CancelHandle; 11 | -------------------------------------------------------------------------------- /src/widgets/EmailWidget.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TextInputWidget from './TextInputWidget'; 3 | 4 | const EmailWidget = props => ( 5 | 6 | ); 7 | 8 | export default EmailWidget; 9 | -------------------------------------------------------------------------------- /src/fields/NullField.js: -------------------------------------------------------------------------------- 1 | import AbstractField from './AbstractField'; 2 | 3 | class NullField extends AbstractField { 4 | getDefaultWidget() { 5 | const { widgets } = this.props; 6 | return widgets.HiddenWidget; 7 | } 8 | } 9 | 10 | export default NullField; 11 | -------------------------------------------------------------------------------- /src/fields/ObjectField.js: -------------------------------------------------------------------------------- 1 | import AbstractField from './AbstractField'; 2 | 3 | class ObjectField extends AbstractField { 4 | getDefaultWidget() { 5 | const { widgets } = this.props; 6 | return widgets.ObjectWidget; 7 | } 8 | } 9 | 10 | export default ObjectField; 11 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | ["lodash", { 4 | "id": [ 5 | "react-native-web-ui-components", 6 | "lodash", 7 | "underscore.string", 8 | ] 9 | }], 10 | "@babel/plugin-transform-react-jsx", 11 | "@babel/plugin-proposal-class-properties" 12 | ] 13 | } -------------------------------------------------------------------------------- /src/fields/NumberField.js: -------------------------------------------------------------------------------- 1 | import AbstractEnumerableField from './AbstractEnumerableField'; 2 | 3 | class NumberField extends AbstractEnumerableField { 4 | getDefaultWidget() { 5 | const { widgets } = this.props; 6 | return widgets.NumberWidget; 7 | } 8 | } 9 | 10 | export default NumberField; 11 | -------------------------------------------------------------------------------- /src/widgets/RadioWidget.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Radiobox from 'react-native-web-ui-components/Radiobox'; 3 | import CheckboxWidget from './CheckboxWidget'; 4 | 5 | const RadioWidget = props => ; 6 | 7 | export default RadioWidget; 8 | -------------------------------------------------------------------------------- /src/fields/IntegerField.js: -------------------------------------------------------------------------------- 1 | import AbstractEnumerableField from './AbstractEnumerableField'; 2 | 3 | class IntegerField extends AbstractEnumerableField { 4 | getDefaultWidget() { 5 | const { widgets } = this.props; 6 | return widgets.IntegerWidget; 7 | } 8 | } 9 | 10 | export default IntegerField; 11 | -------------------------------------------------------------------------------- /src/widgets/ArrayWidget/getItemPosition.js: -------------------------------------------------------------------------------- 1 | const getItemPosition = element => new Promise((resolve) => { 2 | element.measure((fx, fy, width, height, px, py) => { 3 | resolve({ 4 | width, 5 | height, 6 | x: px, 7 | y: py, 8 | }); 9 | }); 10 | }); 11 | 12 | export default getItemPosition; 13 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # expo 4 | .expo/ 5 | 6 | # dependencies 7 | /node_modules 8 | 9 | # misc 10 | .env.local 11 | .env.development.local 12 | .env.test.local 13 | .env.production.local 14 | 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | -------------------------------------------------------------------------------- /src/FormEvent.js: -------------------------------------------------------------------------------- 1 | class FormEvent { 2 | constructor(name, params = {}) { 3 | this.name = name; 4 | this.prevented = false; 5 | this.params = params; 6 | } 7 | 8 | preventDefault() { 9 | this.prevented = true; 10 | } 11 | 12 | isDefaultPrevented() { 13 | return this.prevented === true; 14 | } 15 | } 16 | 17 | export default FormEvent; 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # expo 4 | .expo/ 5 | 6 | # build 7 | build 8 | dist 9 | 10 | # dependencies 11 | /node_modules 12 | 13 | # misc 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | 23 | .DS_Store 24 | -------------------------------------------------------------------------------- /src/widgets/ZipWidget.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import TextInputWidget from './TextInputWidget'; 4 | 5 | const ZipWidget = props => ; 6 | 7 | ZipWidget.propTypes = { 8 | mask: PropTypes.string, 9 | keyboardType: PropTypes.string, 10 | }; 11 | 12 | ZipWidget.defaultProps = { 13 | mask: '99999', 14 | keyboardType: 'number-pad', 15 | }; 16 | 17 | export default ZipWidget; 18 | -------------------------------------------------------------------------------- /src/widgets/IntegerWidget.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { isNaN } from 'lodash'; 3 | import TextInputWidget from './TextInputWidget'; 4 | 5 | const textParser = (value) => { 6 | const result = parseInt(value, 10); 7 | return !isNaN(result) ? result : null; 8 | }; 9 | 10 | const IntegerWidget = props => ( 11 | 12 | ); 13 | 14 | export default IntegerWidget; 15 | -------------------------------------------------------------------------------- /src/Div.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Platform } from 'react-native'; 4 | 5 | const Div = ({ children, ...props }) => { 6 | if (Platform.OS === 'web') { 7 | return React.createElement('div', { 8 | ...props, 9 | style: { display: 'flex' }, 10 | }, children); 11 | } 12 | return children; 13 | }; 14 | 15 | Div.propTypes = { 16 | children: PropTypes.node.isRequired, 17 | }; 18 | 19 | export default Div; 20 | -------------------------------------------------------------------------------- /src/fields/index.js: -------------------------------------------------------------------------------- 1 | import ArrayField from './ArrayField'; 2 | import BooleanField from './BooleanField'; 3 | import IntegerField from './IntegerField'; 4 | import NullField from './NullField'; 5 | import NumberField from './NumberField'; 6 | import ObjectField from './ObjectField'; 7 | import StringField from './StringField'; 8 | 9 | export default { 10 | ArrayField, 11 | BooleanField, 12 | IntegerField, 13 | NullField, 14 | NumberField, 15 | ObjectField, 16 | StringField, 17 | }; 18 | -------------------------------------------------------------------------------- /src/widgets/ArrayWidget/AddHandle.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Button from 'react-native-web-ui-components/Button'; 4 | 5 | const AddHandle = ({ theme, onPress, addLabel }) => ( 6 | 9 | ); 10 | 11 | AddHandle.propTypes = { 12 | theme: PropTypes.shape().isRequired, 13 | onPress: PropTypes.func.isRequired, 14 | addLabel: PropTypes.string.isRequired, 15 | }; 16 | 17 | export default AddHandle; 18 | -------------------------------------------------------------------------------- /src/widgets/TextWidget/Handle.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { StyleSheet } from 'react-native'; 4 | import Link from 'react-native-web-ui-components/Link'; 5 | 6 | const styles = StyleSheet.create({ 7 | handle: { 8 | paddingLeft: 10, 9 | paddingTop: 11, 10 | }, 11 | }); 12 | 13 | const Handle = ({ theme, ...props }) => ( 14 | 20 | ); 21 | 22 | Handle.propTypes = { 23 | theme: PropTypes.shape().isRequired, 24 | }; 25 | 26 | export default Handle; 27 | -------------------------------------------------------------------------------- /src/widgets/TextareaWidget.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import TextInputWidget from './TextInputWidget'; 4 | 5 | const TextareaWidget = (props) => { 6 | const { uiSchema } = props; 7 | const numberOfLines = (uiSchema['ui:options'] && uiSchema['ui:options'].rows) || 2; 8 | return ; 9 | }; 10 | 11 | TextareaWidget.propTypes = { 12 | uiSchema: PropTypes.shape({ 13 | 'ui:options': PropTypes.shape({ 14 | rows: PropTypes.number, 15 | }), 16 | }).isRequired, 17 | }; 18 | 19 | export default TextareaWidget; 20 | -------------------------------------------------------------------------------- /src/widgets/ScheduleWidget/fillSchedule.js: -------------------------------------------------------------------------------- 1 | import { find } from 'lodash'; 2 | import { days } from '../../utils'; 3 | 4 | const fillSchedule = (value, timesAttribute, dateAttribute) => { 5 | const schedule = []; 6 | days.forEach((day) => { 7 | let entry = find(value, { [dateAttribute]: day }); 8 | if (!entry) { 9 | entry = { [dateAttribute]: false, [timesAttribute]: '' }; 10 | } else { 11 | entry = { 12 | [dateAttribute]: !!entry[dateAttribute], 13 | [timesAttribute]: entry[timesAttribute], 14 | }; 15 | } 16 | schedule.push(entry); 17 | }); 18 | return schedule; 19 | }; 20 | 21 | export default fillSchedule; 22 | -------------------------------------------------------------------------------- /src/CancelButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Button from 'react-native-web-ui-components/Button'; 4 | import { withTheme } from 'react-native-web-ui-components/Theme'; 5 | 6 | const CancelButton = ({ text, onPress }) => ( 7 | 17 | ); 18 | 19 | CancelButton.propTypes = { 20 | onPress: PropTypes.func.isRequired, 21 | text: PropTypes.string.isRequired, 22 | }; 23 | 24 | export default withTheme('JsonSchemaFormCancelButton')(CancelButton); 25 | -------------------------------------------------------------------------------- /src/widgets/PhoneWidget.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import TextInputWidget from './TextInputWidget'; 4 | 5 | const defaultMaskParser = (value) => { 6 | const text = (value === null || value === undefined) ? '' : `${value}`; 7 | return text.replace(/[^0-9]/g, ''); 8 | }; 9 | 10 | const PhoneWidget = props => ; 11 | 12 | PhoneWidget.propTypes = { 13 | mask: PropTypes.string, 14 | keyboardType: PropTypes.string, 15 | maskParser: PropTypes.func, 16 | }; 17 | 18 | PhoneWidget.defaultProps = { 19 | mask: '(999) 999-9999', 20 | keyboardType: 'number-pad', 21 | maskParser: defaultMaskParser, 22 | }; 23 | 24 | export default PhoneWidget; 25 | -------------------------------------------------------------------------------- /src/widgets/FileWidget/FileArea.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { StyleSheet, Platform } from 'react-native'; 4 | import View from 'react-native-web-ui-components/View'; 5 | import StylePropType from 'react-native-web-ui-components/StylePropType'; 6 | 7 | const FileArea = ({ onAreaClick, style, children }) => { 8 | if (Platform.OS === 'web') { 9 | return React.createElement('div', { 10 | style: StyleSheet.flatten(style), 11 | onClick: onAreaClick, 12 | }, children); 13 | } 14 | return ( 15 | 16 | {children} 17 | 18 | ); 19 | }; 20 | 21 | FileArea.propTypes = { 22 | onAreaClick: PropTypes.func.isRequired, 23 | children: PropTypes.node, 24 | style: StylePropType, 25 | }; 26 | 27 | FileArea.defaultProps = { 28 | children: null, 29 | style: {}, 30 | }; 31 | 32 | export default FileArea; 33 | -------------------------------------------------------------------------------- /src/SubmitButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { StyleSheet } from 'react-native'; 3 | import PropTypes from 'prop-types'; 4 | import Button from 'react-native-web-ui-components/Button'; 5 | import { withTheme } from 'react-native-web-ui-components/Theme'; 6 | 7 | const styles = StyleSheet.create({ 8 | button: { 9 | marginBottom: 5, 10 | }, 11 | }); 12 | 13 | const SubmitButton = ({ theme, text, onPress }) => ( 14 | 26 | ); 27 | 28 | SubmitButton.propTypes = { 29 | theme: PropTypes.shape().isRequired, 30 | onPress: PropTypes.func.isRequired, 31 | text: PropTypes.string.isRequired, 32 | }; 33 | 34 | export default withTheme('JsonSchemaFormSubmitButton')(SubmitButton); 35 | -------------------------------------------------------------------------------- /src/widgets/ObjectWidget/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import createGrid from './createGrid'; 4 | 5 | class ObjectWidget extends React.Component { 6 | static propTypes = { 7 | schema: PropTypes.shape().isRequired, 8 | uiSchema: PropTypes.shape().isRequired, 9 | clearCache: PropTypes.bool.isRequired, 10 | }; 11 | 12 | render() { 13 | const { 14 | schema, 15 | uiSchema, 16 | clearCache, 17 | } = this.props; 18 | 19 | if (clearCache) { 20 | this.cache = null; 21 | } 22 | 23 | if (!this.cache) { 24 | const grid = uiSchema['ui:grid'] || [{ 25 | type: 'column', 26 | xs: 12, 27 | children: Object.keys(schema.properties), 28 | }]; 29 | this.cache = createGrid(grid, this.props); 30 | } 31 | const Grid = this.cache; 32 | return ; 33 | } 34 | } 35 | 36 | export default ObjectWidget; 37 | -------------------------------------------------------------------------------- /src/fields/ArrayField.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { isString } from 'lodash'; 3 | import AbstractField from './AbstractField'; 4 | 5 | class ArrayField extends AbstractField { 6 | getDefaultWidget() { 7 | const { widgets } = this.props; 8 | return widgets.ArrayWidget; 9 | } 10 | 11 | renderErrors() { 12 | let { errors } = this.props; 13 | const { widgets, uiSchema } = this.props; 14 | 15 | const { ErrorWidget } = widgets; 16 | 17 | if (uiSchema['ui:widget'] === 'tagInput') { 18 | errors = errors.__originalValues || []; // eslint-disable-line 19 | } 20 | errors = errors.filter(error => isString(error)); 21 | 22 | return errors.map((error, i) => ( 23 | 31 | {error} 32 | 33 | )); 34 | } 35 | } 36 | 37 | export default ArrayField; 38 | -------------------------------------------------------------------------------- /src/fields/StringField.js: -------------------------------------------------------------------------------- 1 | import AbstractEnumerableField from './AbstractEnumerableField'; 2 | 3 | const password = /password$/i; 4 | const email = /(email|username)$/i; 5 | const phone = /(phone|mobile|cellphone)$/i; 6 | const message = /(message|text|notes)$/i; 7 | const zip = /zip$/i; 8 | 9 | class StringField extends AbstractEnumerableField { 10 | getDefaultWidget() { 11 | const { name, widgets, schema } = this.props; 12 | let Widget; 13 | if (schema.format === 'date-time') { 14 | Widget = widgets.DateWidget; 15 | } else if (password.test(name)) { 16 | Widget = widgets.PasswordWidget; 17 | } else if (email.test(name)) { 18 | Widget = widgets.EmailWidget; 19 | } else if (phone.test(name)) { 20 | Widget = widgets.PhoneWidget; 21 | } else if (message.test(name)) { 22 | Widget = widgets.TextareaWidget; 23 | } else if (zip.test(name)) { 24 | Widget = widgets.ZipWidget; 25 | } else { 26 | Widget = widgets.TextInputWidget; 27 | } 28 | return Widget; 29 | } 30 | } 31 | 32 | export default StringField; 33 | -------------------------------------------------------------------------------- /src/widgets/FileWidget/RemoveHandle.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { StyleSheet } from 'react-native'; 4 | import Link from 'react-native-web-ui-components/Link'; 5 | import StylePropType from 'react-native-web-ui-components/StylePropType'; 6 | 7 | const styles = StyleSheet.create({ 8 | handle: { 9 | paddingLeft: 10, 10 | paddingTop: 11, 11 | }, 12 | }); 13 | 14 | const RemoveHandle = ({ 15 | theme, 16 | onRemovePress, 17 | removeLabel, 18 | removeStyle, 19 | ...props 20 | }) => ( 21 | 28 | {removeLabel} 29 | 30 | ); 31 | 32 | RemoveHandle.propTypes = { 33 | theme: PropTypes.shape().isRequired, 34 | onRemovePress: PropTypes.func.isRequired, 35 | removeLabel: PropTypes.node.isRequired, 36 | removeStyle: StylePropType, 37 | }; 38 | 39 | RemoveHandle.defaultProps = { 40 | removeStyle: null, 41 | }; 42 | 43 | export default RemoveHandle; 44 | -------------------------------------------------------------------------------- /src/widgets/FileWidget/UploadHandle.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { StyleSheet } from 'react-native'; 4 | import StylePropType from 'react-native-web-ui-components/StylePropType'; 5 | import Link from 'react-native-web-ui-components/Link'; 6 | 7 | const styles = StyleSheet.create({ 8 | handle: { 9 | paddingRight: 10, 10 | paddingTop: 6, 11 | }, 12 | fullWidth: { 13 | width: '100%', 14 | }, 15 | }); 16 | 17 | const UploadHandle = ({ 18 | theme, 19 | auto, 20 | onUploadPress, 21 | uploadLabel, 22 | uploadStyle, 23 | ...props 24 | }) => ( 25 | 36 | {uploadLabel} 37 | 38 | ); 39 | 40 | UploadHandle.propTypes = { 41 | auto: PropTypes.bool.isRequired, 42 | theme: PropTypes.shape().isRequired, 43 | onUploadPress: PropTypes.func.isRequired, 44 | uploadLabel: PropTypes.node.isRequired, 45 | uploadStyle: StylePropType, 46 | }; 47 | 48 | UploadHandle.defaultProps = { 49 | uploadStyle: null, 50 | }; 51 | 52 | export default UploadHandle; 53 | -------------------------------------------------------------------------------- /src/widgets/FileWidget/OrderHandle.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { StyleSheet } from 'react-native'; 4 | import View from 'react-native-web-ui-components/View'; 5 | import Text from 'react-native-web-ui-components/Text'; 6 | import StylePropType from 'react-native-web-ui-components/StylePropType'; 7 | import Div from '../../Div'; 8 | 9 | const styles = StyleSheet.create({ 10 | order: { 11 | fontSize: 15, 12 | textAlign: 'center', 13 | paddingRight: 10, 14 | paddingTop: 11, 15 | lineHeight: 23, 16 | }, 17 | }); 18 | 19 | const OrderHandle = ({ 20 | handle, 21 | panHandlers, 22 | orderLabel, 23 | orderStyle, 24 | }) => ( 25 | 26 |
27 | 33 | {orderLabel} 34 | 35 |
36 |
37 | ); 38 | 39 | OrderHandle.propTypes = { 40 | handle: PropTypes.string.isRequired, 41 | orderLabel: PropTypes.node.isRequired, 42 | panHandlers: PropTypes.shape(), 43 | orderStyle: StylePropType, 44 | }; 45 | 46 | OrderHandle.defaultProps = { 47 | panHandlers: null, 48 | orderStyle: null, 49 | }; 50 | 51 | export default OrderHandle; 52 | -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | A similar PR may already be submitted! 2 | Please search among the [Pull request](../../pulls) before creating one. 3 | 4 | Thanks for submitting a pull request! Please provide enough information so that others can review your pull request: 5 | 6 | For more information, see the `CONTRIBUTING` guide. 7 | 8 | 9 | **Summary** 10 | 11 | 12 | 13 | This PR fixes/implements the following **bugs/features** 14 | 15 | * [ ] Bug 1 16 | * [ ] Bug 2 17 | * [ ] Feature 1 18 | * [ ] Feature 2 19 | * [ ] Breaking changes 20 | 21 | 22 | 23 | Explain the **motivation** for making this change. What existing problem does the pull request solve? 24 | 25 | 26 | 27 | **Test plan (required)** 28 | 29 | Demonstrate the code is solid. Example: The exact commands you ran and their output, screenshots / videos if the pull request changes UI. 30 | 31 | 32 | 33 | **Code formatting** 34 | 35 | 36 | 37 | **Closing issues** 38 | 39 | 40 | Fixes # 41 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Your issue may already be reported! 2 | Please search on the [issue tracker](../../issues) before creating one. 3 | 4 | ## Expected Behavior 5 | 6 | 7 | 8 | ## Current Behavior 9 | 10 | 11 | 12 | ## Possible Solution 13 | 14 | 15 | 16 | ## Steps to Reproduce (for bugs) 17 | 18 | 19 | 1. 20 | 2. 21 | 3. 22 | 4. 23 | 24 | ## Context 25 | 26 | 27 | 28 | ## Your Environment 29 | 30 | * Version used: 31 | * Browser Name and version: 32 | * Operating System and version (desktop or mobile): 33 | * Link to your project: 34 | -------------------------------------------------------------------------------- /src/widgets/ErrorWidget.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { StyleSheet } from 'react-native'; 4 | import Text from 'react-native-web-ui-components/Text'; 5 | import { withTheme } from 'react-native-web-ui-components/Theme'; 6 | 7 | const styles = StyleSheet.create({ 8 | regular: { 9 | marginTop: -5, 10 | fontSize: 12, 11 | }, 12 | auto: { 13 | marginTop: 0, 14 | marginBottom: 0, 15 | }, 16 | first: { 17 | marginTop: -10, 18 | }, 19 | last: { 20 | marginBottom: 10, 21 | }, 22 | }); 23 | 24 | const ErrorWidget = ({ 25 | theme, 26 | children, 27 | last, 28 | first, 29 | auto, 30 | ...props 31 | }) => { 32 | const style = [ 33 | styles.regular, 34 | { color: StyleSheet.flatten(theme.input.error.border).borderColor }, 35 | props.style, // eslint-disable-line 36 | ]; 37 | if (first) { 38 | style.push(styles.first); 39 | } 40 | if (last) { 41 | style.push(styles.last); 42 | } 43 | return ( 44 | 45 | {children} 46 | 47 | ); 48 | }; 49 | 50 | ErrorWidget.propTypes = { 51 | theme: PropTypes.shape().isRequired, 52 | last: PropTypes.bool, 53 | first: PropTypes.bool, 54 | children: PropTypes.node, 55 | auto: PropTypes.bool, 56 | }; 57 | 58 | ErrorWidget.defaultProps = { 59 | last: true, 60 | first: true, 61 | children: null, 62 | auto: false, 63 | }; 64 | 65 | export default withTheme('ErrorWidget')(ErrorWidget); 66 | -------------------------------------------------------------------------------- /src/widgets/index.js: -------------------------------------------------------------------------------- 1 | import ArrayWidget from './ArrayWidget'; 2 | import AutocompleteWidget from './AutocompleteWidget'; 3 | import CheckboxWidget from './CheckboxWidget'; 4 | import DateWidget from './DateWidget'; 5 | import EmailWidget from './EmailWidget'; 6 | import ErrorWidget from './ErrorWidget'; 7 | import FileWidget from './FileWidget'; 8 | import HiddenWidget from './HiddenWidget'; 9 | import IntegerWidget from './IntegerWidget'; 10 | import LabelWidget from './LabelWidget'; 11 | import NumberWidget from './NumberWidget'; 12 | import ObjectWidget from './ObjectWidget'; 13 | import PasswordWidget from './PasswordWidget'; 14 | import PhoneWidget from './PhoneWidget'; 15 | import RadioWidget from './RadioWidget'; 16 | import RatingWidget from './RatingWidget'; 17 | import ScheduleWidget from './ScheduleWidget'; 18 | import SelectWidget from './SelectWidget'; 19 | import TagInputWidget from './TagInputWidget'; 20 | import TextareaWidget from './TextareaWidget'; 21 | import TextInputWidget from './TextInputWidget'; 22 | import TextWidget from './TextWidget'; 23 | import TimeRangeWidget from './TimeRangeWidget'; 24 | import ZipWidget from './ZipWidget'; 25 | 26 | export default { 27 | ArrayWidget, 28 | AutocompleteWidget, 29 | CheckboxWidget, 30 | DateWidget, 31 | EmailWidget, 32 | ErrorWidget, 33 | FileWidget, 34 | HiddenWidget, 35 | IntegerWidget, 36 | LabelWidget, 37 | NumberWidget, 38 | ObjectWidget, 39 | PasswordWidget, 40 | PhoneWidget, 41 | RadioWidget, 42 | RatingWidget, 43 | ScheduleWidget, 44 | SelectWidget, 45 | TagInputWidget, 46 | TextareaWidget, 47 | TextInputWidget, 48 | TextWidget, 49 | TimeRangeWidget, 50 | ZipWidget, 51 | 52 | // Alias 53 | RadioboxWidget: RadioWidget, 54 | }; 55 | -------------------------------------------------------------------------------- /src/widgets/ArrayWidget/OrderHandle.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { StyleSheet } from 'react-native'; 4 | import View from 'react-native-web-ui-components/View'; 5 | import Text from 'react-native-web-ui-components/Text'; 6 | import StylePropType from 'react-native-web-ui-components/StylePropType'; 7 | import Div from '../../Div'; 8 | 9 | const styles = StyleSheet.create({ 10 | order: { 11 | fontSize: 15, 12 | textAlign: 'center', 13 | paddingRight: 10, 14 | paddingTop: 11, 15 | lineHeight: 23, 16 | }, 17 | hidden: { 18 | opacity: 0, 19 | paddingTop: 0, 20 | }, 21 | xs: { 22 | paddingTop: 0, 23 | }, 24 | }); 25 | 26 | const OrderHandle = ({ 27 | theme, 28 | handle, 29 | panHandlers, 30 | titleOnly, 31 | screenType, 32 | orderLabel, 33 | orderStyle, 34 | }) => ( 35 | 36 |
37 | 48 | {orderLabel} 49 | 50 |
51 |
52 | ); 53 | 54 | OrderHandle.propTypes = { 55 | theme: PropTypes.shape().isRequired, 56 | screenType: PropTypes.string.isRequired, 57 | handle: PropTypes.string.isRequired, 58 | titleOnly: PropTypes.bool.isRequired, 59 | panHandlers: PropTypes.shape(), 60 | orderLabel: PropTypes.node.isRequired, 61 | orderStyle: StylePropType, 62 | }; 63 | 64 | OrderHandle.defaultProps = { 65 | panHandlers: null, 66 | orderStyle: null, 67 | }; 68 | 69 | export default OrderHandle; 70 | -------------------------------------------------------------------------------- /src/widgets/AutocompleteWidget.js: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { pick, isFunction } from 'lodash'; 4 | import Autocomplete from 'react-native-web-ui-components/Autocomplete'; 5 | import TextInputWidget from './TextInputWidget'; 6 | 7 | const allowedAttributes = [ 8 | 'allowEmpty', 9 | 'items', 10 | 'getItemValue', 11 | 'onSelect', 12 | 'isMatch', 13 | 'Item', 14 | 'Input', 15 | 'inputStyle', 16 | 'Menu', 17 | 'menuStyle', 18 | 'menuVisibleWhenFocused', 19 | 'itemHeight', 20 | 'itemProps', 21 | 'itemStyle', 22 | 'Spinner', 23 | 'spinnerHeight', 24 | 'EmptyResult', 25 | 'emptyResultHeight', 26 | 'throttleDelay', 27 | 'debounceDelay', 28 | 'throttleDebounceThreshold', 29 | ]; 30 | 31 | const useOnSelect = ({ 32 | name, 33 | onChange, 34 | onSelect, 35 | setText, 36 | }) => (value, item) => { 37 | setText.current(value); 38 | if (isFunction(onSelect)) { 39 | onSelect(value, item); 40 | } 41 | return onChange(value, name); 42 | }; 43 | 44 | const AutocompleteWidget = (props) => { 45 | const setText = useRef(); 46 | const onSelect = useOnSelect({ ...props, setText }); 47 | 48 | const register = (fn) => { setText.current = fn; }; 49 | 50 | const inputProps = { 51 | ...pick(props, allowedAttributes), 52 | onSelect, 53 | autoCapitalize: 'none', 54 | }; 55 | 56 | return ( 57 | 65 | ); 66 | }; 67 | 68 | AutocompleteWidget.hideable = false; 69 | 70 | AutocompleteWidget.propTypes = { 71 | name: PropTypes.string.isRequired, 72 | onChange: PropTypes.func.isRequired, 73 | }; 74 | 75 | export default AutocompleteWidget; 76 | -------------------------------------------------------------------------------- /src/widgets/ArrayWidget/RemoveHandle.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { StyleSheet } from 'react-native'; 4 | import Text from 'react-native-web-ui-components/Text'; 5 | import Link from 'react-native-web-ui-components/Link'; 6 | import StylePropType from 'react-native-web-ui-components/StylePropType'; 7 | 8 | const styles = StyleSheet.create({ 9 | remove: { 10 | paddingLeft: 10, 11 | paddingTop: 11, 12 | }, 13 | hidden: { 14 | opacity: 0, 15 | paddingTop: 0, 16 | }, 17 | alignRight: { 18 | paddingTop: 0, 19 | width: '100%', 20 | textAlign: 'right', 21 | }, 22 | }); 23 | 24 | const RemoveHandle = ({ 25 | theme, 26 | onRemovePress, 27 | titleOnly, 28 | screenType, 29 | removeLabel, 30 | removeStyle, 31 | }) => { 32 | if (!titleOnly) { 33 | return ( 34 | 45 | {removeLabel} 46 | 47 | ); 48 | } 49 | return ( 50 | 58 | {removeLabel} 59 | 60 | ); 61 | }; 62 | 63 | RemoveHandle.propTypes = { 64 | theme: PropTypes.shape().isRequired, 65 | onRemovePress: PropTypes.func.isRequired, 66 | titleOnly: PropTypes.bool.isRequired, 67 | screenType: PropTypes.string.isRequired, 68 | removeLabel: PropTypes.node.isRequired, 69 | removeStyle: StylePropType, 70 | }; 71 | 72 | RemoveHandle.defaultProps = { 73 | removeStyle: null, 74 | }; 75 | 76 | export default RemoveHandle; 77 | -------------------------------------------------------------------------------- /src/widgets/FileWidget/ProgressHandle.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { StyleSheet } from 'react-native'; 4 | import View from 'react-native-web-ui-components/View'; 5 | import Row from 'react-native-web-ui-components/Row'; 6 | import { Helmet, style } from 'react-native-web-ui-components/Helmet'; 7 | 8 | const styles = StyleSheet.create({ 9 | progress: { 10 | position: 'absolute', 11 | top: 29, 12 | left: 0, 13 | width: '100%', 14 | height: 2, 15 | backgroundColor: 'rgba(200, 200, 200, 0.5)', 16 | }, 17 | full: { 18 | height: 2, 19 | }, 20 | error: { 21 | position: 'absolute', 22 | top: 0, 23 | left: 0, 24 | width: '100%', 25 | height: '100%', 26 | backgroundColor: '#FF0000', 27 | opacity: 0.2, 28 | borderRadius: 2, 29 | }, 30 | }); 31 | 32 | const ProgressHandle = ({ meta, theme }) => ( 33 | 34 | 35 | 42 | 43 | {meta['ui:progress'] !== undefined && meta['ui:progress'] < 100 ? ( 44 | 45 | 55 | 56 | ) : null} 57 | {meta['ui:error'] !== undefined ? ( 58 | 59 | ) : null} 60 | 61 | ); 62 | 63 | ProgressHandle.propTypes = { 64 | theme: PropTypes.shape().isRequired, 65 | meta: PropTypes.shape().isRequired, 66 | }; 67 | 68 | export default ProgressHandle; 69 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "parser": "babel-eslint", 4 | "plugins": [ 5 | "react", 6 | "react-hooks", 7 | "json" 8 | ], 9 | "parserOptions": { 10 | "ecmaVersion": 6, 11 | "sourceType": "module", 12 | "ecmaFeatures": { 13 | "arrowFunctions": true, 14 | "binaryLiterals": true, 15 | "blockBindings": true, 16 | "classes": true, 17 | "defaultParams": true, 18 | "destructuring": true, 19 | "forOf": true, 20 | "generators": true, 21 | "modules": true, 22 | "objectLiteralComputedProperties": true, 23 | "objectLiteralDuplicateProperties": true, 24 | "objectLiteralShorthandMethods": true, 25 | "objectLiteralShorthandProperties": true, 26 | "octalLiterals": true, 27 | "regexUFlag": true, 28 | "regexYFlag": true, 29 | "spread": true, 30 | "superInFunctions": true, 31 | "templateStrings": true, 32 | "unicodeCodePointEscapes": true, 33 | "globalReturn": true, 34 | "jsx": true 35 | } 36 | }, 37 | "env": { 38 | "node": true, 39 | "es6": true, 40 | "browser": true 41 | }, 42 | "rules": { 43 | "import/no-unresolved": 0, 44 | "import/no-extraneous-dependencies": [ 45 | "error", 46 | { 47 | "peerDependencies": true 48 | } 49 | ], 50 | "import/extensions": ["error", "never"], 51 | "react/prop-types": 0, 52 | "react-hooks/rules-of-hooks": "error", 53 | "react-hooks/exhaustive-deps": "error", 54 | "import-extensions": 0, 55 | "jsx-a11y/anchor-is-valid": 0, 56 | "react/jsx-filename-extension": 0, 57 | "no-multiple-empty-lines": ["error", { "max": 1 }], 58 | "object-curly-newline": ["error"], 59 | "arrow-parens": [2, "as-needed", { "requireForBlockBody": true }], 60 | "react/jsx-props-no-spreading": 0, 61 | "react/jsx-fragments": 0, 62 | "jsx-a11y/control-has-associated-label": 0, 63 | "react/static-property-placement": 0, 64 | "react/state-in-constructor": 0, 65 | "prefer-object-spread": 0, 66 | "no-mixed-operators": ["error", { "allowSamePrecedence": true }], 67 | "react/jsx-curly-brace-presence": 0 68 | } 69 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-web-jsonschema-form", 3 | "version": "4.0.0", 4 | "private": false, 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "rm -rf dist && babel src -d dist && cp package.json dist/package.json && cp README.md dist/README.md && cp src/index.d.ts dist/index.d.ts", 8 | "build:windows": "rmdir /s /q dist && babel src -d dist && xcopy package.json dist\\package.json && xcopy README.md dist/README.md && xcopy src/index.d.ts dist/index.d.ts", 9 | "lint": "eslint ./src -c ./.eslintrc --ext .js", 10 | "cloc:src": "yarn cloc ./src", 11 | "cloc:test": "yarn cloc ./__tests__", 12 | "cloc:all": "yarn cloc ./" 13 | }, 14 | "author": "Gabriel Marques ", 15 | "license": "MIT", 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/CareLuLu/react-native-web-jsonschema-form" 19 | }, 20 | "devDependencies": { 21 | "@babel/cli": "7.28.3", 22 | "@babel/core": "7.28.5", 23 | "@babel/plugin-proposal-class-properties": "7.18.6", 24 | "@babel/plugin-transform-react-jsx": "7.27.1", 25 | "@babel/register": "7.28.3", 26 | "@babel/eslint-parser": "7.28.5", 27 | "babel-plugin-lodash": "3.3.4", 28 | "cloc": "2.11.0", 29 | "eslint": "7.32.0", 30 | "eslint-config-airbnb": "18.2.1", 31 | "eslint-plugin-import": "2.32.0", 32 | "eslint-plugin-json": "3.1.0", 33 | "eslint-plugin-jsx-a11y": "6.10.2", 34 | "eslint-plugin-react": "7.37.5", 35 | "eslint-plugin-react-hooks": "4.6.2" 36 | }, 37 | "dependencies": { 38 | "lodash": "^4.17.14", 39 | "prop-types": "^15.6.2", 40 | "underscore.string": "^3.3.5" 41 | }, 42 | "peerDependencies": { 43 | "react": "^16.8.3 || ^17.0.0", 44 | "react-native": "*", 45 | "react-native-web-ui-components": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" 46 | }, 47 | "browserslist": [ 48 | ">0.2%", 49 | "not dead", 50 | "not ie < 11", 51 | "not op_mini all" 52 | ], 53 | "keywords": [ 54 | "expo", 55 | "react", 56 | "react native", 57 | "react native web", 58 | "react-component", 59 | "ui components", 60 | "json schema", 61 | "react-form" 62 | ] 63 | } 64 | -------------------------------------------------------------------------------- /src/fields/AbstractEnumerableField.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { StyleSheet } from 'react-native'; 3 | import { isArray, without } from 'lodash'; 4 | import AbstractField from './AbstractField'; 5 | 6 | const styles = StyleSheet.create({ 7 | padding: { 8 | paddingLeft: 10, 9 | }, 10 | margin: { 11 | marginBottom: 10, 12 | }, 13 | }); 14 | 15 | class AbstractEnumerableField extends AbstractField { 16 | getWidget() { 17 | const { schema, widgets, uiSchema } = this.props; 18 | let Widget; 19 | const widgetName = uiSchema['ui:widget']; 20 | if (widgetName === 'radioboxes' || widgetName === 'checkboxes') { 21 | let values = uiSchema['ui:enum'] || schema.enum || []; 22 | if (isArray(uiSchema['ui:enumExcludes'])) { 23 | values = without(values, uiSchema['ui:enumExcludes']); 24 | } 25 | const labels = uiSchema['ui:enumNames'] || schema.enumNames || values; 26 | const { RadioWidget, CheckboxWidget } = widgets; 27 | const BaseWidget = widgetName === 'radioboxes' ? RadioWidget : CheckboxWidget; 28 | const inlineOptions = uiSchema['ui:options'] && uiSchema['ui:options'].inline; 29 | Widget = ({ value, style, ...props }) => ( // eslint-disable-line 30 | 31 | {values.map((trueValue, i) => ( 32 | 0 43 | && (uiSchema['ui:inline'] || inlineOptions) 44 | ) 45 | ) ? styles.padding : null, 46 | !uiSchema['ui:inline'] ? styles.margin : null, 47 | style, 48 | ]} 49 | value={trueValue} 50 | /> 51 | ))} 52 | 53 | ); 54 | } 55 | return Widget; 56 | } 57 | } 58 | 59 | export default AbstractEnumerableField; 60 | -------------------------------------------------------------------------------- /src/widgets/SelectWidget.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { StyleSheet } from 'react-native'; 4 | import { isArray, isNaN, without } from 'lodash'; 5 | import Select from 'react-native-web-ui-components/Select'; 6 | import StylePropType from 'react-native-web-ui-components/StylePropType'; 7 | import { useOnChange, useAutoFocus } from '../utils'; 8 | 9 | const styles = StyleSheet.create({ 10 | defaults: { 11 | marginBottom: 10, 12 | }, 13 | fullWidth: { 14 | width: '100%', 15 | }, 16 | auto: { 17 | marginBottom: 0, 18 | }, 19 | }); 20 | 21 | const parser = ({ schema }) => (value) => { 22 | let parsedValue = value; 23 | if (schema.type === 'number' || schema.type === 'integer') { 24 | parsedValue = parseFloat(value); 25 | if (isNaN(parsedValue)) { 26 | parsedValue = null; 27 | } 28 | } else if (schema.type === 'boolean') { 29 | parsedValue = value; 30 | } 31 | return parsedValue; 32 | }; 33 | 34 | const SelectWidget = (props) => { 35 | const { 36 | schema, 37 | uiSchema, 38 | hasError, 39 | name, 40 | value, 41 | readonly, 42 | disabled, 43 | placeholder, 44 | auto, 45 | style, 46 | } = props; 47 | 48 | const autoFocusParams = useAutoFocus(props); 49 | const onChange = useOnChange({ ...props, parser }); 50 | 51 | let values = uiSchema['ui:enum'] || schema.enum || []; 52 | if (isArray(uiSchema['ui:enumExcludes'])) { 53 | values = without(values, uiSchema['ui:enumExcludes']); 54 | } 55 | const labels = uiSchema['ui:enumNames'] || schema.enumNames || values; 56 | 57 | const currentStyle = [ 58 | styles.defaults, 59 | auto ? styles.auto : styles.fullWidth, 60 | style, 61 | ]; 62 | 63 | return ( 64 | 286 | ); 287 | } 288 | } 289 | 290 | export default TextInputWidget; 291 | -------------------------------------------------------------------------------- /src/widgets/ArrayWidget/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { 3 | noop, 4 | each, 5 | last, 6 | times, 7 | isArray, 8 | isFunction, 9 | } from 'lodash'; 10 | import { titleize } from 'underscore.string'; 11 | import Screen from 'react-native-web-ui-components/Screen'; 12 | import Icon from 'react-native-web-ui-components/Icon'; 13 | import { 14 | getTitle, 15 | getTitleFormat, 16 | getComponent, 17 | } from '../../utils'; 18 | import getItemPosition from './getItemPosition'; 19 | import AddHandle from './AddHandle'; 20 | import OrderHandle from './OrderHandle'; 21 | import RemoveHandle from './RemoveHandle'; 22 | import Item from './Item'; 23 | import DraggableItem from './DraggableItem'; 24 | 25 | /* eslint react/no-array-index-key: 0 */ 26 | 27 | const uiTitleRegex = /\.ui_/; 28 | 29 | const getItem = (schema) => { 30 | let newItem = ''; 31 | if (schema.items.type === 'object') { 32 | newItem = {}; 33 | } else if (schema.items.type === 'array') { 34 | newItem = []; 35 | } 36 | return newItem; 37 | }; 38 | 39 | const formatTitle = title => titleize(title).replace(/ies$/, 'y').replace(/s$/, ''); 40 | 41 | const iterateUiSchema = (uiSchema, i) => { 42 | const widgetProps = uiSchema['ui:widgetProps'] || {}; 43 | const titleProps = uiSchema['ui:titleProps'] || {}; 44 | const errorProps = uiSchema['ui:errorProps'] || {}; 45 | return { 46 | ...uiSchema, 47 | 'ui:widgetProps': isArray(widgetProps) ? (widgetProps[i] || {}) : widgetProps, 48 | 'ui:titleProps': isArray(titleProps) ? (titleProps[i] || {}) : titleProps, 49 | 'ui:errorProps': isArray(errorProps) ? (errorProps[i] || {}) : errorProps, 50 | }; 51 | }; 52 | 53 | const adjustUiSchema = (possibleUiSchema, i, props) => { 54 | let uiSchema = possibleUiSchema; 55 | if (isFunction(possibleUiSchema['ui:iterate'])) { 56 | uiSchema = possibleUiSchema['ui:iterate'](i, props); 57 | } 58 | const adjustedUiSchema = iterateUiSchema(uiSchema, i); 59 | each(uiSchema, (uis, key) => { 60 | if (!/^ui:/.test(key)) { 61 | adjustedUiSchema[key] = iterateUiSchema(uis, i); 62 | } 63 | }); 64 | return adjustedUiSchema; 65 | }; 66 | 67 | const getProps = (props) => { 68 | const { 69 | name, 70 | schema, 71 | fields, 72 | uiSchema, 73 | value: originalValue, 74 | } = props; 75 | 76 | const value = isArray(originalValue) ? originalValue : []; 77 | 78 | const screenType = Screen.getType(); 79 | const title = getTitle(getTitleFormat(schema, uiSchema), { 80 | name, 81 | value, 82 | key: last(name.split('.')), 83 | }); 84 | 85 | const propertySchema = schema.items; 86 | const propertyUiSchema = uiSchema.items; 87 | const PropertyField = getComponent(propertySchema.type, 'Field', fields); 88 | const options = uiSchema['ui:options'] || {}; 89 | 90 | const extraProps = { 91 | value, 92 | title, 93 | screenType, 94 | propertySchema, 95 | propertyUiSchema, 96 | PropertyField, 97 | axis: options.axis || 'y', 98 | minimumNumberOfItems: ( 99 | options.minimumNumberOfItems === undefined 100 | || options.minimumNumberOfItems === null 101 | ) ? 1 : options.minimumNumberOfItems, 102 | addLabel: options.addLabel || `Add ${formatTitle(title)}`, 103 | removeLabel: options.removeLabel || 'Delete', 104 | orderLabel: options.orderLabel || , 105 | removeStyle: options.removeStyle, 106 | orderStyle: options.orderStyle, 107 | addable: options.addable !== false, 108 | removable: options.removable !== false, 109 | orderable: options.orderable !== false, 110 | AddComponent: options.AddComponent || AddHandle, 111 | OrderComponent: options.OrderComponent || OrderHandle, 112 | RemoveComponent: options.RemoveComponent || RemoveHandle, 113 | ItemComponent: options.ItemComponent || Item, 114 | }; 115 | return { ...props, ...extraProps }; 116 | }; 117 | 118 | const useOnAdd = ({ 119 | name, 120 | meta, 121 | value, 122 | schema, 123 | onChange, 124 | minimumNumberOfItems, 125 | }) => () => { 126 | let nextValue; 127 | let nextMeta = meta; 128 | if (value.length < minimumNumberOfItems) { 129 | nextValue = value.concat(times(minimumNumberOfItems - value.length + 1, () => getItem(schema))); 130 | if (meta) { 131 | nextMeta = nextMeta.concat(times(minimumNumberOfItems - value.length + 1, () => ({}))); 132 | } 133 | } else { 134 | nextValue = value.concat([getItem(schema)]); 135 | if (meta) { 136 | nextMeta = nextMeta.concat([{}]); 137 | } 138 | } 139 | onChange(nextValue, name, { 140 | nextMeta: nextMeta || false, 141 | }); 142 | }; 143 | 144 | const useOnRemove = ({ 145 | name, 146 | value, 147 | onChange, 148 | reorder, 149 | errors, 150 | meta, 151 | }) => (index) => { 152 | const nextValue = (isArray(value) ? value : []).filter((v, i) => (i !== index)); 153 | let nextMeta = (isArray(meta) ? meta : []); 154 | if (nextMeta) { 155 | nextMeta = nextMeta.filter((v, i) => (i !== index)); 156 | } 157 | let nextErrors = (isArray(errors) ? errors : []); 158 | if (nextErrors) { 159 | nextErrors = nextErrors.filter((v, i) => (i !== index)); 160 | } 161 | onChange(nextValue, name, { 162 | nextMeta: nextMeta || false, 163 | nextErrors: nextErrors || false, 164 | }); 165 | setTimeout(reorder); 166 | }; 167 | 168 | const useOnItemRef = ({ refs, positions }) => (ref, index) => { 169 | refs[index] = ref; // eslint-disable-line 170 | setTimeout(() => { 171 | getItemPosition(ref).then((position) => { 172 | positions[index] = position; // eslint-disable-line 173 | }).catch(noop); 174 | }, 100); 175 | }; 176 | 177 | const useSetDragging = ({ setDragging, setState }) => (dragging) => { 178 | if (setDragging) { 179 | setDragging(dragging); 180 | } 181 | setState(current => ({ ...current, dragging })); 182 | }; 183 | 184 | const useReorder = ({ review, setState }) => () => setState({ 185 | refs: [], 186 | positions: [], 187 | review: review + 1, 188 | dragging: null, 189 | }); 190 | 191 | const ArrayWidget = (props) => { 192 | const [state, setState] = useState({ 193 | refs: [], 194 | positions: [], 195 | review: 0, 196 | dragging: null, 197 | }); 198 | 199 | const setDragging = useSetDragging({ ...props, setState }); 200 | const params = getProps({ ...props, ...state, setDragging }); 201 | const reorder = useReorder({ ...params, setState }); 202 | 203 | const onAdd = useOnAdd(params); 204 | const onRemove = useOnRemove({ ...params, reorder }); 205 | const onItemRef = useOnItemRef(params); 206 | 207 | const { 208 | meta, 209 | review, 210 | name, 211 | value, 212 | title, 213 | addLabel, 214 | addable, 215 | widgets, 216 | schema, 217 | uiSchema, 218 | errors, 219 | dragging, 220 | screenType, 221 | propertyUiSchema, 222 | minimumNumberOfItems, 223 | AddComponent, 224 | ItemComponent, 225 | } = params; 226 | 227 | const { LabelWidget } = widgets; 228 | const hasError = isArray(errors) && errors.length > 0 && !errors.hidden; 229 | const hasTitle = uiSchema['ui:title'] !== false; 230 | const toggleable = !!uiSchema['ui:toggleable']; 231 | const missingItems = Math.max(0, minimumNumberOfItems - value.length); 232 | 233 | return ( 234 | 235 | {hasTitle || toggleable ? ( 236 | 244 | {title} 245 | 246 | ) : null} 247 | {schema.items.type === 'object' && uiSchema.items['ui:title'] !== false && screenType !== 'xs' ? ( 248 | 264 | ) : null} 265 | {times(value.length, (index) => { 266 | const itemUiSchema = adjustUiSchema(propertyUiSchema, index, params); 267 | return ( 268 | 284 | ); 285 | })} 286 | {times(missingItems, (index) => { 287 | const itemUiSchema = adjustUiSchema(propertyUiSchema, value.length + index, params); 288 | return ( 289 | 305 | ); 306 | })} 307 | {addable && !uiTitleRegex.test(name) ? ( 308 | 309 | ) : null} 310 | 311 | ); 312 | }; 313 | 314 | export default ArrayWidget; 315 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Native Web JSONSchema Form 2 | 3 | [![Dependencies](https://img.shields.io/badge/dependencies-renovate-brightgreen.svg)](https://github.com/CareLuLu/react-native-web-jsonschema-form/issues/12) 4 | [![Codacy Badge](https://img.shields.io/codacy/grade/0a2f23b96c3a47038c89c00bb72ea197/master)](https://www.codacy.com/gh/CareLuLu/react-native-web-jsonschema-form?utm_source=github.com&utm_medium=referral&utm_content=CareLuLu/react-native-web-jsonschema-form&utm_campaign=Badge_Grade) 5 | [![NPM](https://img.shields.io/npm/v/react-native-web-jsonschema-form.svg)](https://www.npmjs.com/package/react-native-web-jsonschema-form) 6 | 7 | Render customizable forms using [JSON schema](http://json-schema.org/) for responsive websites and [Expo](https://expo.io/) apps (both iOS and Android). This library was inpired on [react-jsonschema-form](https://github.com/mozilla-services/react-jsonschema-form) but was built with [React Native](https://facebook.github.io/react-native/) and [React Native Web](https://github.com/necolas/react-native-web) in mind. 8 | 9 | - See this library in production at https://www.carelulu.com 10 | - Skeleton project using React Native Web Jsonschema Form at https://www.carelulu.com/react-native-web-example/login and https://github.com/CareLuLu/react-native-web-skeleton 11 | 12 | ## Table of Contents 13 | 14 | * [Documentation](#documentation) 15 | * [Setup](#setup) 16 | * [Requirements](#requirements) 17 | * [Installation](#installation) 18 | * [Examples](#examples) 19 | * [Basic Form](#basic-form) 20 | * [Event Handlers](#event-handlers) 21 | * [Custom Theme](#custom-theme) 22 | * [Form Validation](#form-validation) 23 | * [Array Fields](#array-fields) 24 | * [Props](#props) 25 | * [License](#license) 26 | 27 | ## Documentation 28 | 29 | Coming soon! 30 | 31 | ## Setup 32 | 33 | React Native Web JSONSchema Form was created to facilitate the development of `write once, run anywhere` web and mobile apps. In order to accomplish that, this library is heavily based on React Native and React Native Web. 34 | 35 | ### Requirements 36 | 37 | First you need to install react ^16.8.3 (this library uses react-hooks). 38 | 39 | ```sh 40 | yarn add react 41 | ``` 42 | 43 | If you're using [Expo](https://expo.io/), they use a custom version of react-native and therefore you need to check what is the React Native repository for the Expo version you're using. For Expo v33.x.x you'd run: 44 | 45 | ```sh 46 | yarn add https://github.com/expo/react-native/archive/sdk-33.0.0.tar.gz 47 | ``` 48 | 49 | If your project is also being used for web, please install React Native Web. Make sure your babel/webpack build replace all `react-native` imports with `react-native-web` ([details here](https://github.com/necolas/react-native-web/blob/master/docs/guides/getting-started.md)). If you used [React Create App](https://github.com/facebook/create-react-app), aliasing is already taken care off for you. 50 | 51 | ```sh 52 | yarn add react-dom react-native-web 53 | ``` 54 | 55 | This library is backed by [react-native-web-ui-components](https://www.npmjs.com/package/react-native-web-ui-components). Please make sure you have installed `react-native-web-ui-components` and its dependencies. All inputs and buttons will follow the theme used on your project. The form must be within `` but doesn't need to be a direct child. 56 | 57 | ```sh 58 | yarn add react-native-web-ui-components 59 | ``` 60 | 61 | ### Installation 62 | 63 | Install the library using `yarn` or `npm`. 64 | 65 | ```sh 66 | yarn add react-native-web-jsonschema-form 67 | ``` 68 | 69 | ## Examples 70 | 71 | ### Basic Form 72 | 73 | ```javascript 74 | import React from 'react'; 75 | import { useHistory } from 'react-router'; 76 | import { Alert } from 'react-native'; 77 | import { UIProvider } from 'react-native-web-ui-components'; 78 | import Form from 'react-native-web-jsonschema-form'; 79 | import { Router, Switch } from 'react-router-dom'; 80 | // import { Router, Switch } from 'react-router-native'; 81 | 82 | const theme = { 83 | input: { 84 | focused: StyleSheet.create({ 85 | border: { 86 | borderColor: 'yellow', 87 | }, 88 | }), 89 | }, 90 | }; 91 | 92 | const schema = { 93 | type: 'object', 94 | properties: { 95 | username: { type: 'string' }, 96 | password: { type: 'string' }, 97 | }, 98 | }; 99 | 100 | const BasicForm = ({ formData, onChange }) => ( 101 |
106 | ); 107 | 108 | const ThemeWrapper = ({ children }) => { 109 | const history = useHistory(); 110 | 111 | return ( 112 | 113 | {children} 114 | 115 | ); 116 | }; 117 | 118 | const App = () => { 119 | const [formData, setFormData] = useState({}); 120 | 121 | const onChange = (event) => setFormData({ 122 | ...formData, 123 | [event.params.name]: event.params.value, 124 | }); 125 | 126 | return ( 127 | 128 | 129 | 130 | 134 | 135 | 136 | 137 | ); 138 | }; 139 | ``` 140 | 141 | ### Event Handlers 142 | 143 | ```javascript 144 | import React from 'React'; 145 | import PropTypes from 'prop-types'; 146 | import { Loading, Alert } from 'react-native-web-ui-components'; 147 | import Form from 'react-native-web-jsonschema-form'; 148 | 149 | class MyForm extends React.Component { 150 | static propTypes = { 151 | controller: PropTypes.string.isRequired, 152 | action: PropTypes.string.isRequired, 153 | }; 154 | 155 | constructor(props) { 156 | super(props); 157 | this.state = { 158 | schema: null, 159 | message: null, 160 | posting: null, 161 | }; 162 | } 163 | 164 | onSubmit = async (event) => { 165 | const { action, controller } = this.props; 166 | const { values } = event.params; 167 | this.setState({ posting: true }); 168 | return fetch(`/${controller}/${action}`, { 169 | method: 'POST', 170 | body: JSON.stringify(values), 171 | }); 172 | }; 173 | 174 | onSuccess = async (event) => { 175 | const { response } = event.params; 176 | this.setState({ 177 | posting: false, 178 | message: response.message, 179 | }); 180 | }; 181 | 182 | onError = async (event) => { 183 | // These are errors for fields that are not included in the schema 184 | const { exceptions } = event.params; 185 | const warning = Object.keys(exceptions).map(k => exceptions[k].join('\n')); 186 | this.setState({ 187 | posting: false, 188 | message: warning.length ? warning.join('\n') : null, 189 | }); 190 | }; 191 | 192 | render() { 193 | const { schema, posting, message } = this.state; 194 | if (!schema) { 195 | const self = this; 196 | fetch(`/get-schema/${controller}/${action}`) 197 | .then((schema) => self.setState({ schema }); 198 | 199 | return ; 200 | } 201 | 202 | return ( 203 | 204 | {posting ? : null} 205 | {message ? ( 206 | 207 | Message 208 | 209 | ) : null} 210 | 216 | 217 | ); 218 | } 219 | } 220 | ``` 221 | 222 | ### Custom Theme 223 | 224 | There are 5 input states: `regular`, `focused`, `disabled`, `readonly` and `error`. On which one of them you can define styles for `background`, `border`, `text`, `placeholder`, `opacity`, `selected` and `unselected`. These properties will be used accordingly by the widgets provided in this library. For example, `selected` and `unselected` will be used checkboxes and radioboxes to represent checked and unchecked. 225 | 226 | ```javascript 227 | const theme = { 228 | input: { 229 | focused: StyleSheet.create({ 230 | border: { 231 | borderColor: 'yellow', 232 | borderWidth: 2, 233 | borderStyle: 'solid', 234 | }, 235 | background: { 236 | backgroundColor: 'white', 237 | }, 238 | text: { 239 | fontSize: 14, 240 | color: '#545454', 241 | }, 242 | placeholder: { 243 | color: '#FAFAFA', 244 | }, 245 | opacity: { 246 | opacity: 1, 247 | }, 248 | selected: { 249 | color: 'blue', 250 | }, 251 | unselected: { 252 | color: '#FAFAFA', 253 | }, 254 | }), 255 | regular: {...}, 256 | disabled: {...}, 257 | readonly: {...}, 258 | error: {...}, 259 | }, 260 | }; 261 | 262 | const ThemeWrapper = ({ children }) => { 263 | const history = useHistory(); 264 | 265 | return ( 266 | 267 | {children} 268 | 269 | ); 270 | }; 271 | ``` 272 | 273 | ### Form Validation 274 | 275 | See https://github.com/CareLuLu/react-native-web-jsonschema-form/issues/139#issuecomment-654377982. 276 | 277 | ### Array Fields 278 | 279 | See https://github.com/CareLuLu/react-native-web-jsonschema-form/issues/113#issuecomment-621375353. 280 | 281 | ## Props 282 | 283 | The `Form` has the following props: 284 | 285 | ```javascript 286 | import React from 'react'; 287 | import Form from 'react-native-web-jsonschema-form'; 288 | 289 | const Example = ({ 290 | // Misc 291 | name, // String to be used as id, if empty a hash will be used instead. 292 | onRef, // Function to be called with the form instance. This is NOT a DOM/Native element. 293 | scroller, // If provided, this will be passed to the widgets to allow disabling ScrollView during a gesture. 294 | wigdets, // Object with a list of custom widgets. 295 | 296 | 297 | // Data 298 | formData, // Initial data to populate the form. If this attribute changes, the form will update the data. 299 | filterEmptyValues, // If true, all empty and non-required fields will be omitted from the submitted values. 300 | 301 | // Schema 302 | schema, // JSON schema 303 | uiSchema, // JSON schema modifying UI defaults for schema 304 | errorSchema, // JSON schema with errors 305 | 306 | // Events 307 | // * All events can be synchronous or asynchronous functions. 308 | // * All events receive one parameter `event` with `name`, `preventDefault()` and `params`. 309 | onFocus, 310 | onChange, 311 | onSubmit, 312 | onCancel, 313 | onSuccess, 314 | onError, 315 | 316 | // Layout 317 | buttonPosition, // left, right, center 318 | submitButton, // If false, it will not be rendered. If it is a string, it will be used as the default button text. 319 | cancelButton, // If false, it will not be rendered. If it is a string, it will be used as the default button text. 320 | SubmitButton, // Component to render the submit button 321 | CancelButton, // Component to render the cancel button 322 | }) => ( 323 | 324 | ) 325 | ``` 326 | 327 | ## License 328 | 329 | [MIT](https://moroshko.mit-license.org/) 330 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { StyleSheet, Platform, Keyboard } from 'react-native'; 4 | import { 5 | set, 6 | get, 7 | each, 8 | noop, 9 | cloneDeep, 10 | isString, 11 | isArray, 12 | isError, 13 | isPlainObject, 14 | } from 'lodash'; 15 | import Row from 'react-native-web-ui-components/Row'; 16 | import { withTheme } from 'react-native-web-ui-components/Theme'; 17 | import { 18 | toPath, 19 | expand, 20 | getMetas, 21 | getValues, 22 | getErrors, 23 | getRequired, 24 | getStructure, 25 | getExceptions, 26 | normalized, 27 | } from './utils'; 28 | import fields from './fields'; 29 | import defaultWidgets from './widgets'; 30 | import FormEvent from './FormEvent'; 31 | import DefaultCancelButton from './CancelButton'; 32 | import DefaultSubmitButton from './SubmitButton'; 33 | 34 | export { 35 | FIELD_KEY, 36 | FIELD_NAME, 37 | FIELD_VALUE, 38 | FIELD_TITLE, 39 | } from './utils'; 40 | 41 | const emptyObject = {}; 42 | 43 | const emptySchema = { 44 | type: 'object', 45 | properties: [], 46 | }; 47 | 48 | const styles = StyleSheet.create({ 49 | form: { zIndex: 1 }, 50 | buttonContainer: { 51 | paddingTop: 5, 52 | }, 53 | buttonContainerCenter: { 54 | justifyContent: 'center', 55 | }, 56 | buttonContainerLeft: { 57 | justifyContent: 'flex-start', 58 | }, 59 | buttonContainerRight: { 60 | justifyContent: 'flex-end', 61 | }, 62 | }); 63 | 64 | const defaultReject = (err) => { throw err; }; 65 | 66 | const getButtonPosition = (position) => { 67 | const style = [styles.buttonContainer]; 68 | if (position === 'center') { 69 | style.push(styles.buttonContainerCenter); 70 | } else if (position === 'left') { 71 | style.push(styles.buttonContainerLeft); 72 | } else { 73 | style.push(styles.buttonContainerRight); 74 | } 75 | return style; 76 | }; 77 | 78 | const addToObject = obj => (v, k) => Object.assign(obj, { [k]: v }); 79 | 80 | const addToArray = arr => v => arr.push(v); 81 | 82 | class JsonSchemaForm extends React.Component { 83 | static propTypes = { 84 | name: PropTypes.string, 85 | schema: PropTypes.shape(), 86 | uiSchema: PropTypes.shape(), 87 | metaSchema: PropTypes.shape(), 88 | errorSchema: PropTypes.shape(), 89 | formData: PropTypes.shape(), 90 | children: PropTypes.node, 91 | onRef: PropTypes.func, 92 | onChange: PropTypes.func, 93 | onSubmit: PropTypes.func, 94 | onCancel: PropTypes.func, 95 | onSuccess: PropTypes.func, 96 | onError: PropTypes.func, 97 | buttonPosition: PropTypes.oneOf(['left', 'right', 'center']), 98 | cancelButton: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]), 99 | CancelButton: PropTypes.elementType, 100 | submitButton: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]), 101 | SubmitButton: PropTypes.elementType, 102 | scroller: PropTypes.shape(), 103 | widgets: PropTypes.shape(), 104 | filterEmptyValues: PropTypes.bool, 105 | insideClickRegex: PropTypes.instanceOf(RegExp), 106 | }; 107 | 108 | static defaultProps = { 109 | name: null, 110 | formData: emptyObject, 111 | schema: emptySchema, 112 | uiSchema: emptyObject, 113 | metaSchema: undefined, 114 | errorSchema: emptyObject, 115 | children: null, 116 | onRef: noop, 117 | onChange: noop, 118 | onSubmit: noop, 119 | onCancel: noop, 120 | onSuccess: noop, 121 | onError: noop, 122 | buttonPosition: 'right', 123 | cancelButton: true, 124 | CancelButton: DefaultCancelButton, 125 | submitButton: true, 126 | SubmitButton: DefaultSubmitButton, 127 | scroller: null, 128 | widgets: emptyObject, 129 | filterEmptyValues: false, 130 | insideClickRegex: undefined, 131 | }; 132 | 133 | static getDerivedStateFromProps(nextProps, prevState) { 134 | const state = { 135 | clearCache: false, 136 | }; 137 | let clear = false; 138 | let { 139 | metas, 140 | values, 141 | errors, 142 | schema, 143 | uiSchema, 144 | } = prevState; 145 | 146 | // If the schema or uiSchema is different, we recalculate everything 147 | const { schemaProp, uiSchemaProp } = prevState; 148 | if (nextProps.schema !== schemaProp || nextProps.uiSchema !== uiSchemaProp) { 149 | clear = true; 150 | const structure = getStructure(nextProps.schema, nextProps.uiSchema); 151 | schema = structure.schema; 152 | uiSchema = structure.uiSchema; 153 | state.schema = schema; 154 | state.uiSchema = uiSchema; 155 | state.update = 'all'; 156 | state.clearCache = true; 157 | state.schemaProp = nextProps.schema; 158 | state.uiSchemaProp = nextProps.uiSchema; 159 | state.required = getRequired(schema); 160 | } 161 | 162 | // Check for formData updates 163 | if (clear || nextProps.formData !== prevState.formDataProp) { 164 | values = getValues(cloneDeep(nextProps.formData), schema); 165 | state.values = values; 166 | state.update = 'all'; 167 | state.formDataProp = nextProps.formData; 168 | } 169 | 170 | // Check for errorSchema updates 171 | if (clear || nextProps.errorSchema !== prevState.errorSchemaProp) { 172 | errors = getErrors(cloneDeep(nextProps.errorSchema), schema); 173 | state.errors = errors; 174 | state.update = 'all'; 175 | state.errorSchemaProp = nextProps.errorSchema; 176 | } 177 | 178 | // Check for metaSchema updates 179 | if (clear || nextProps.metaSchema !== prevState.metaSchemaProp) { 180 | metas = getMetas(cloneDeep(nextProps.metaSchema || values), schema, uiSchema); 181 | state.metas = metas; 182 | state.update = 'all'; 183 | state.metaSchemaProp = nextProps.metaSchema; 184 | } 185 | 186 | return state; 187 | } 188 | 189 | constructor(props) { 190 | super(props); 191 | 192 | const { 193 | name, 194 | onRef, 195 | widgets, 196 | formData, 197 | schema, 198 | uiSchema, 199 | metaSchema, 200 | errorSchema, 201 | insideClickRegex, 202 | } = props; 203 | 204 | this.id = `Form__${name || Math.random().toString(36).substr(2, 9)}`; 205 | this.fieldRegex = insideClickRegex || new RegExp(`(${this.id}-field|react-datepicker)`); 206 | this.mountSteps = []; 207 | this.widgets = Object.assign({}, defaultWidgets, widgets); 208 | 209 | const structure = getStructure(schema, uiSchema); 210 | const values = getValues(cloneDeep(formData), structure.schema); 211 | const errors = getErrors(cloneDeep(errorSchema), structure.schema); 212 | const metas = getMetas(cloneDeep(metaSchema || values), structure.schema, structure.uiSchema); 213 | const required = getRequired(structure.schema); 214 | 215 | this.state = { 216 | values, 217 | errors, 218 | metas, 219 | required, 220 | schema: structure.schema, 221 | uiSchema: structure.uiSchema, 222 | formDataProp: formData, 223 | schemaProp: schema, 224 | uiSchemaProp: uiSchema, 225 | errorSchemaProp: errorSchema, 226 | metaSchemaProp: metaSchema, 227 | update: {}, 228 | clearCache: false, 229 | }; 230 | 231 | onRef(this); 232 | } 233 | 234 | componentDidMount() { 235 | if (Platform.OS === 'web') { 236 | window.addEventListener('click', this.clickListener); 237 | } 238 | this.mounted = true; 239 | this.onMount(); 240 | } 241 | 242 | componentWillUnmount() { 243 | this.mounted = false; 244 | if (Platform.OS === 'web') { 245 | window.removeEventListener('click', this.clickListener); 246 | } 247 | } 248 | 249 | onMount(handler) { 250 | if (handler) { 251 | this.mountSteps.push(handler); 252 | } 253 | if (this.mounted) { 254 | const fn = this.mountSteps.shift(); 255 | if (fn) { 256 | fn.call(this); 257 | } 258 | } 259 | } 260 | 261 | onChange = (value, name, params = {}) => { 262 | const { 263 | update = [], 264 | nextErrors = false, 265 | nextMeta = false, 266 | silent = false, 267 | } = params; 268 | 269 | const { metas, values, errors } = this.state; 270 | const { onChange } = this.props; 271 | 272 | const event = new FormEvent('change', { 273 | name, 274 | value, 275 | values, 276 | metas, 277 | nextMeta, 278 | nextErrors, 279 | silent, 280 | path: toPath(name), 281 | update: [name].concat(update), 282 | }); 283 | 284 | this.run(onChange(event), () => { 285 | if (!event.isDefaultPrevented()) { 286 | const { path } = event.params; 287 | set(event.params.values, path, event.params.value); 288 | if (event.params.nextMeta !== false) { 289 | set(metas, path, event.params.nextMeta); 290 | } 291 | if (event.params.nextErrors !== false) { 292 | set(errors, path, event.params.nextErrors); 293 | } 294 | const error = get(errors, path); 295 | if (error) { 296 | if (normalized(error.lastValue) !== normalized(event.params.value)) { 297 | error.hidden = true; 298 | } else { 299 | error.hidden = false; 300 | } 301 | } 302 | this.onMount(() => this.setState({ 303 | metas: { ...metas }, 304 | errors: { ...errors }, 305 | values: { ...event.params.values }, 306 | update: expand(event.params.update), 307 | })); 308 | } 309 | }); 310 | }; 311 | 312 | onCancel = () => { 313 | const { values } = this.state; 314 | const { onCancel } = this.props; 315 | const event = new FormEvent('cancel', { values }); 316 | this.run(onCancel(event)); 317 | }; 318 | 319 | onSubmit = () => { 320 | if (Platform.OS !== 'web') { 321 | Keyboard.dismiss(); 322 | } 323 | setTimeout(() => { 324 | const { metas, values } = this.state; 325 | const { onSubmit, filterEmptyValues } = this.props; 326 | let nextValues = this.filterDisabled(values, metas); 327 | if (filterEmptyValues) { 328 | nextValues = this.filterEmpty(nextValues); 329 | } 330 | const event = new FormEvent('submit', { values: nextValues }); 331 | this.run(onSubmit(event), (response) => { 332 | if (!event.isDefaultPrevented()) { 333 | this.onSuccess(response); 334 | } 335 | }, (errorSchema) => { 336 | if (!event.isDefaultPrevented()) { 337 | this.onError(errorSchema); 338 | } 339 | }); 340 | }, Platform.OS !== 'web' ? 50 : 0); 341 | }; 342 | 343 | onSuccess = (response) => { 344 | const { schema, values } = this.state; 345 | const { onSuccess } = this.props; 346 | const event = new FormEvent('success', { 347 | values, 348 | response, 349 | update: 'all', 350 | }); 351 | this.run(onSuccess(event), () => { 352 | if (!event.isDefaultPrevented()) { 353 | this.onMount(() => this.setState({ 354 | errors: getErrors({}, schema), 355 | values: event.params.values, 356 | update: expand(event.params.update), 357 | })); 358 | } 359 | }); 360 | }; 361 | 362 | onError = (err) => { 363 | const { schema } = this.state; 364 | const { onError } = this.props; 365 | let errorSchema = err; 366 | if (isError(errorSchema)) { 367 | errorSchema = { Error: [err.message] }; 368 | } 369 | const errors = getErrors(errorSchema || {}, schema); 370 | const exceptions = getExceptions(errorSchema, errors); 371 | const event = new FormEvent('error', { 372 | errors, 373 | exceptions, 374 | update: 'all', 375 | }); 376 | this.run(onError(event), () => { 377 | if (!event.isDefaultPrevented()) { 378 | this.onMount(() => this.setState({ 379 | errors: event.params.errors, 380 | update: expand(event.params.update), 381 | })); 382 | } 383 | }); 384 | }; 385 | 386 | cancel = () => this.onCancel(); 387 | 388 | submit = () => this.onSubmit(); 389 | 390 | run = (maybePromise, resolveHandler, rejectHandler) => { 391 | const self = this; 392 | const resolve = resolveHandler || noop; 393 | const reject = rejectHandler || defaultReject; 394 | if (maybePromise && maybePromise.then) { 395 | return maybePromise 396 | .then((...args) => resolve.call(self, ...args)) 397 | .catch((...args) => reject.call(self, ...args)); 398 | } 399 | return resolve.call(self, maybePromise); 400 | }; 401 | 402 | filterEmpty(values, path = '', type = 'object') { 403 | const self = this; 404 | const { required } = self.state; 405 | const filteredValues = type === 'object' ? {} : []; 406 | const add = type === 'object' ? addToObject(filteredValues) : addToArray(filteredValues); 407 | each(values, (v, k) => { 408 | let empty = false; 409 | const name = path ? `${path}.${k}` : k; 410 | let value = v; 411 | if (isArray(v)) { 412 | value = self.filterEmpty(v, name, 'array'); 413 | empty = value.length === 0; 414 | } else if (isPlainObject(v)) { 415 | value = self.filterEmpty(v, name, 'object'); 416 | empty = Object.keys(value).length === 0; 417 | } else { 418 | empty = value === '' || value === undefined || value === null; 419 | } 420 | if (required[toPath(name, '[]')] || !empty) { 421 | add(value, k); 422 | } 423 | }); 424 | return filteredValues; 425 | } 426 | 427 | filterDisabled(values, metas, path = '', type = 'object') { 428 | const self = this; 429 | const filteredValues = type === 'object' ? {} : []; 430 | const add = type === 'object' ? addToObject(filteredValues) : addToArray(filteredValues); 431 | each(values, (v, k) => { 432 | const disabled = !!(metas && metas[k] && metas[k]['ui:disabled']); 433 | if (!disabled) { 434 | const name = path ? `${path}.${k}` : k; 435 | let value = v; 436 | if (isArray(v)) { 437 | value = self.filterDisabled(v, (metas && metas[k]) || [], name, 'array'); 438 | } else if (isPlainObject(v)) { 439 | value = self.filterDisabled(v, (metas && metas[k]) || {}, name, 'object'); 440 | } 441 | add(value, k); 442 | } 443 | }); 444 | return filteredValues; 445 | } 446 | 447 | render() { 448 | const { 449 | event, 450 | schema, 451 | uiSchema, 452 | metas, 453 | values, 454 | errors, 455 | update, 456 | required, 457 | clearCache, 458 | } = this.state; 459 | 460 | const { 461 | children, 462 | cancelButton, 463 | CancelButton, 464 | submitButton, 465 | SubmitButton, 466 | buttonPosition, 467 | } = this.props; 468 | 469 | const { ObjectField } = fields; 470 | 471 | return ( 472 | 473 | 474 | 495 | 496 | {children || (submitButton === false && cancelButton === false) ? children : ( 497 | 498 | {cancelButton ? ( 499 | 503 | ) : null} 504 | {submitButton ? ( 505 | 509 | ) : null} 510 | 511 | )} 512 | 513 | ); 514 | } 515 | } 516 | 517 | export default withTheme('JsonSchemaForm')(JsonSchemaForm); 518 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { 3 | has, 4 | get, 5 | each, 6 | uniq, 7 | first, 8 | indexOf, 9 | flatten, 10 | isNaN, 11 | isArray, 12 | isString, 13 | isPlainObject, 14 | } from 'lodash'; 15 | import { humanize } from 'underscore.string'; 16 | 17 | export const EMPTY = '%empty%'; 18 | export const FIELD_KEY = '%key%'; 19 | export const FIELD_NAME = '%name%'; 20 | export const FIELD_VALUE = '%value%'; 21 | export const FIELD_TITLE = '%title%'; 22 | 23 | const FIELD_KEY_REGEX = /%key%/g; 24 | const FIELD_NAME_REGEX = /%name%/g; 25 | const FIELD_VALUE_REGEX = /%value%/g; 26 | const FIELD_TITLE_REGEX = /%title%/g; 27 | const ORIGINAL_VALUES_REGEX = /__originalValues/; 28 | 29 | /* eslint no-param-reassign: 0 */ 30 | 31 | const passThrough = () => value => value; 32 | 33 | const withDefaults = (uiSchema, base = {}) => ({ 34 | '*': base['*'] || uiSchema['*'] || {}, // Pass default through 35 | ...(base['*'] || uiSchema['*'] || {}), // Inherit default properties 36 | ...(uiSchema || {}), 37 | }); 38 | 39 | const getGridStructure = (grid, schema, uiSchema, getStructure) => { 40 | if (grid.type === 'label') { 41 | return {}; 42 | } 43 | const compiledSchema = {}; 44 | const compiledUiSchema = {}; 45 | each(grid.children, (item) => { 46 | if (isString(item)) { 47 | getStructure( 48 | schema.properties[item], 49 | withDefaults(uiSchema[item], uiSchema), 50 | item, 51 | compiledSchema, 52 | compiledUiSchema, 53 | ); 54 | } else { 55 | const gridStructure = getGridStructure(item, schema, uiSchema, getStructure); 56 | Object.assign(compiledSchema, gridStructure.schema); 57 | Object.assign(compiledUiSchema, gridStructure.uiSchema); 58 | } 59 | }); 60 | return { 61 | schema: compiledSchema, 62 | uiSchema: compiledUiSchema, 63 | }; 64 | }; 65 | 66 | const isValid = (key, pick, omitted, include) => ( 67 | (include.length && include.indexOf(key) >= 0) || ( 68 | (!pick.length || pick.indexOf(key) >= 0) 69 | && (!omitted.length || omitted.indexOf(key) < 0) 70 | ) 71 | ); 72 | 73 | const getUiSchemaPick = (schema, uiSchema) => { 74 | let pick = uiSchema['ui:pick'] || []; 75 | if (pick === 'required') { 76 | pick = schema.required; 77 | } else if (pick && pick.length) { 78 | const requiredIndex = indexOf(pick, '*required'); 79 | if (requiredIndex >= 0) { 80 | pick = pick.concat([]); 81 | pick[requiredIndex] = schema.required; 82 | pick = uniq(flatten(pick)); 83 | } 84 | } else { 85 | pick = Object.keys(schema.properties); 86 | } 87 | return pick; 88 | }; 89 | 90 | const orderedKeys = (schema, uiSchema) => uniq(( 91 | uiSchema['ui:order'] 92 | || getUiSchemaPick(schema, uiSchema) 93 | ).concat(Object.keys(schema.properties))); 94 | 95 | const orderedEach = (schema, uiSchema, iterator) => { 96 | const keys = orderedKeys(schema, uiSchema); 97 | each(keys, key => iterator(schema.properties[key], key)); 98 | }; 99 | 100 | export const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; 101 | 102 | export const getAutoFocus = ({ uiSchema }) => !!uiSchema['ui:autofocus']; 103 | 104 | export const useAutoFocus = ({ uiSchema, onFocus, onBlur }) => { 105 | const [autoFocus, setAutoFocus] = useState(getAutoFocus({ uiSchema })); 106 | 107 | return { 108 | autoFocus, 109 | onFocus: (...args) => { 110 | setAutoFocus(true); 111 | if (onFocus) { 112 | onFocus(...args); 113 | } 114 | }, 115 | onBlur: (...args) => { 116 | setAutoFocus(false); 117 | if (onBlur) { 118 | onBlur(...args); 119 | } 120 | }, 121 | }; 122 | }; 123 | 124 | export const useOnChange = (props) => { 125 | const { 126 | name, 127 | onChange, 128 | parser = passThrough, 129 | } = props; 130 | 131 | return value => onChange(parser(props)(value), name); 132 | }; 133 | 134 | export const getStructure = ( 135 | possibleSchema, 136 | uiSchema, 137 | key, 138 | compiledSchema = {}, 139 | compiledUiSchema = {}, 140 | ) => { 141 | if (!possibleSchema) { 142 | return { 143 | schema: compiledSchema, 144 | uiSchema: compiledUiSchema, 145 | }; 146 | } 147 | let schema = possibleSchema; 148 | if (schema.anyOf) { 149 | schema = first(schema.anyOf); 150 | } else if (schema.oneOf) { 151 | schema = first(schema.oneOf); 152 | } 153 | if (schema.type === 'object') { 154 | const schemaNode = { 155 | ...schema, 156 | properties: {}, 157 | required: schema.required || [], 158 | }; 159 | const uiSchemaNode = withDefaults(uiSchema); 160 | if (uiSchemaNode['ui:grid']) { 161 | // Pull properties defined on/ordered by the grid 162 | each(uiSchema['ui:grid'], (grid) => { 163 | const gridStructure = getGridStructure(grid, schema, uiSchemaNode, getStructure); 164 | Object.assign(schemaNode.properties, gridStructure.schema); 165 | Object.assign(uiSchemaNode, gridStructure.uiSchema); 166 | }); 167 | } else { 168 | // Pull valid properties in order of ui:order 169 | const pick = getUiSchemaPick(schema, uiSchema); 170 | const omitted = uiSchema['ui:omit'] || []; 171 | const include = uiSchema['ui:include'] || []; 172 | orderedEach(schema, uiSchema, (propertySchema, propertyKey) => { 173 | if (isValid(propertyKey, pick, omitted, include)) { 174 | getStructure( 175 | propertySchema, 176 | withDefaults(uiSchema[propertyKey], uiSchemaNode), 177 | propertyKey, 178 | schemaNode.properties, 179 | uiSchemaNode, 180 | ); 181 | } 182 | }); 183 | } 184 | if (key) { 185 | compiledSchema[key] = schemaNode; 186 | compiledUiSchema[key] = uiSchemaNode; 187 | } else { 188 | Object.assign(compiledSchema, schemaNode); 189 | Object.assign(compiledUiSchema, uiSchemaNode); 190 | } 191 | } else if (schema.type === 'array') { 192 | const schemaNode = { 193 | ...schema, 194 | }; 195 | const uiSchemaNode = withDefaults(uiSchema); 196 | getStructure( 197 | schemaNode.items, 198 | withDefaults(uiSchemaNode.items, uiSchemaNode), 199 | 'items', 200 | schemaNode, 201 | uiSchemaNode, 202 | ); 203 | compiledSchema[key] = schemaNode; 204 | compiledUiSchema[key] = uiSchemaNode; 205 | } else { 206 | compiledSchema[key] = { ...schema }; 207 | compiledUiSchema[key] = { ...uiSchema }; 208 | } 209 | return { 210 | schema: compiledSchema, 211 | uiSchema: compiledUiSchema, 212 | }; 213 | }; 214 | 215 | export const isField = (element, classNameRegex) => { 216 | for (let node = element; node && node !== document; node = node.parentNode) { 217 | if ( 218 | classNameRegex.test(node.className || '') 219 | || classNameRegex.test(node.getAttribute('data-class') || '') 220 | ) { 221 | return true; 222 | } 223 | } 224 | return false; 225 | }; 226 | 227 | // Similar to lodash's merge but it doesn't merge arrays. 228 | // Instead, arrays are replaced by source's array. 229 | export const merge = (destination, source = {}) => { 230 | each(source, (v, k) => { 231 | if (!has(destination, k)) { 232 | destination[k] = v; 233 | } else if (isPlainObject(v)) { 234 | if (!isPlainObject(destination[k])) { 235 | destination[k] = v; 236 | } else { 237 | merge(destination[k], v); 238 | } 239 | } else { 240 | destination[k] = v; 241 | } 242 | }); 243 | return destination; 244 | }; 245 | 246 | export const getValues = (data, schema, key, casting = true, uiSchema = false, errors = false) => { 247 | let value = key ? get(data, key) : data; 248 | if (schema.type === 'object') { 249 | value = isPlainObject(value) ? value : {}; 250 | const node = {}; 251 | each(schema.properties, (propertySchema, propertyKey) => { 252 | node[propertyKey] = getValues( 253 | value, 254 | propertySchema, 255 | propertyKey, 256 | casting, 257 | uiSchema && uiSchema[propertyKey], 258 | errors, 259 | ); 260 | }); 261 | if (uiSchema) { 262 | value['ui:disabled'] = (uiSchema && uiSchema['ui:disabled']) || false; 263 | } 264 | if (errors) { 265 | node.__originalValues = value; // eslint-disable-line 266 | } 267 | return node; 268 | } 269 | if (schema.type === 'array') { 270 | value = isArray(value) ? value : []; 271 | if (uiSchema) { 272 | value['ui:disabled'] = (uiSchema && uiSchema['ui:disabled']) || false; 273 | } 274 | const values = value.map(item => getValues( 275 | item, 276 | schema.items, 277 | null, 278 | casting, 279 | uiSchema && uiSchema.items, 280 | errors, 281 | )); 282 | 283 | if (errors) { 284 | values.__originalValues = value; // eslint-disable-line 285 | } 286 | return values; 287 | } 288 | if (casting) { 289 | if (value === null || value === undefined) { 290 | switch (schema.type) { 291 | case 'string': value = schema.enum ? null : ''; break; 292 | case 'number': 293 | case 'float': 294 | case 'integer': 295 | case 'date': 296 | default: value = null; 297 | } 298 | } else { 299 | switch (schema.type) { 300 | case 'string': value = `${value}`; break; 301 | case 'number': 302 | case 'float': value = parseFloat(value); break; 303 | case 'integer': 304 | case 'date': value = parseInt(value, 10); break; 305 | default: break; 306 | } 307 | if (isNaN(value)) { 308 | value = null; 309 | } 310 | } 311 | } else if (uiSchema) { 312 | value = { 313 | 'ui:disabled': (uiSchema && uiSchema['ui:disabled']) || false, 314 | }; 315 | } 316 | return value; 317 | }; 318 | 319 | export const getMetas = (data, schema, uiSchema) => getValues(data, schema, null, false, uiSchema); 320 | 321 | export const getErrors = (data, schema, key) => getValues(data, schema, key, false, false, true); 322 | 323 | export const withPrefix = (key, prefix) => (prefix ? `${prefix}.${key}` : key); 324 | 325 | export const getExceptions = (errorSchema, errors, path = '') => { 326 | const exceptions = {}; 327 | if ( 328 | isArray(errorSchema) 329 | && errorSchema.length 330 | && isString(errorSchema[0]) 331 | && !errors 332 | && !ORIGINAL_VALUES_REGEX.test(path) 333 | ) { 334 | exceptions[path] = errorSchema; 335 | } else if (isPlainObject(errorSchema) || isArray(errorSchema)) { 336 | each(errorSchema, (v, k) => Object.assign( 337 | exceptions, 338 | getExceptions(errorSchema[k], get(errors, k), withPrefix(k, path)), 339 | )); 340 | } 341 | return exceptions; 342 | }; 343 | 344 | const nameToPath = /\.([0-9]+)\.?/g; 345 | 346 | export const toPath = (name, replacement = '[$1]') => name.replace(nameToPath, replacement); 347 | 348 | export const getTitle = (format, params = {}) => { 349 | let title = format || ''; 350 | title = title.replace(FIELD_TITLE_REGEX, humanize(params.key || '')); 351 | title = title.replace(FIELD_KEY_REGEX, params.key); 352 | title = title.replace(FIELD_NAME_REGEX, params.name); 353 | title = title.replace(FIELD_VALUE_REGEX, params.value); 354 | return title; 355 | }; 356 | 357 | export const getTitleFormat = (schema, uiSchema) => { 358 | let format = uiSchema['ui:title']; 359 | if (format === undefined) { 360 | format = schema.title; 361 | } 362 | if (format === undefined && schema.type !== 'object') { 363 | format = FIELD_TITLE; 364 | } 365 | if (format === undefined) { 366 | format = false; 367 | } 368 | if (format && !isString(format)) { 369 | if (schema.type !== 'object') { 370 | format = FIELD_TITLE; 371 | } else { 372 | format = false; 373 | } 374 | } 375 | return format; 376 | }; 377 | 378 | export const ucfirst = (text) => { 379 | if (!text) { 380 | return ''; 381 | } 382 | return `${text[0].toUpperCase()}${text.substring(1)}`; 383 | }; 384 | 385 | export const getComponent = (name, suffix, library) => library[`${ucfirst(name)}${suffix}`]; 386 | 387 | export const expand = (update) => { 388 | if (update === 'all') { 389 | return update; 390 | } 391 | const parts = {}; 392 | each(update, (name) => { 393 | const keys = name.split('.'); 394 | let prefix = ''; 395 | each(keys, (key) => { 396 | prefix = withPrefix(key, prefix); 397 | parts[prefix] = true; 398 | }); 399 | }); 400 | return parts; 401 | }; 402 | 403 | export const getRequired = (schema, prefix = '') => { 404 | let required = {}; 405 | if (schema.type === 'object') { 406 | each(schema.required || [], (propertyKey) => { 407 | required[withPrefix(propertyKey, prefix)] = true; 408 | }); 409 | each(schema.properties, (propertySchema, propertyKey) => Object.assign( 410 | required, 411 | getRequired(propertySchema, withPrefix(propertyKey, prefix)), 412 | )); 413 | } 414 | if (schema.type === 'array') { 415 | Object.assign(required, getRequired(schema.items, withPrefix('0', prefix))); 416 | } 417 | if (prefix === '') { 418 | const normalizedRequired = {}; 419 | each(required, (v, k) => { 420 | normalizedRequired[toPath(k, '[]')] = v; 421 | }); 422 | required = normalizedRequired; 423 | } 424 | return required; 425 | }; 426 | 427 | const maskOptions = { 428 | undefined: /^$/, 429 | a: /^[A-Za-zÀ-ÖØ-öø-ÿ]$/, 430 | 9: /^[0-9]$/, 431 | '*': /^.$/, 432 | }; 433 | 434 | const defaultParser = value => ((value === null || value === undefined) ? '' : `${value}`); 435 | 436 | export const formatMask = (value, mask, maskParser) => { 437 | const parse = maskParser || defaultParser; 438 | const text = parse(value); 439 | let result = ''; 440 | let cursorText = 0; 441 | let cursorMask = 0; 442 | for (; cursorText < text.length; cursorText += 1) { 443 | let charText = text[cursorText]; 444 | let charMask; 445 | let extras = ''; 446 | do { 447 | charMask = mask[cursorMask]; 448 | cursorMask += 1; 449 | if (!(charMask in maskOptions)) { 450 | extras += charMask; 451 | if (charMask === charText) { 452 | cursorText += 1; 453 | charText = text[cursorText] || ''; 454 | result += extras; 455 | extras = ''; 456 | } 457 | } 458 | } while (!(charMask in maskOptions)); 459 | if (maskOptions[charMask].test(charText)) { 460 | result += extras + charText; 461 | } 462 | } 463 | return result; 464 | }; 465 | 466 | export const normalized = (value) => { 467 | if (value === '' || value === null || value === undefined) { 468 | return ''; 469 | } 470 | return value; 471 | }; 472 | 473 | export const isEmpty = value => (value === '' || value === null || value === undefined); 474 | 475 | export const viewStyleKeys = [ 476 | 'animationDelay', 477 | 'animationDirection', 478 | 'animationDuration', 479 | 'animationFillMode', 480 | 'animationIterationCount', 481 | 'animationName', 482 | 'animationPlayState', 483 | 'animationTimingFunction', 484 | 'transitionDelay', 485 | 'transitionDuration', 486 | 'transitionProperty', 487 | 'transitionTimingFunction', 488 | 'borderColor', 489 | 'borderBottomColor', 490 | 'borderEndColor', 491 | 'borderLeftColor', 492 | 'borderRightColor', 493 | 'borderStartColor', 494 | 'borderTopColor', 495 | 'borderRadius', 496 | 'borderBottomEndRadius', 497 | 'borderBottomLeftRadius', 498 | 'borderBottomRightRadius', 499 | 'borderBottomStartRadius', 500 | 'borderTopEndRadius', 501 | 'borderTopLeftRadius', 502 | 'borderTopRightRadius', 503 | 'borderTopStartRadius', 504 | 'borderStyle', 505 | 'borderBottomStyle', 506 | 'borderEndStyle', 507 | 'borderLeftStyle', 508 | 'borderRightStyle', 509 | 'borderStartStyle', 510 | 'borderTopStyle', 511 | 'cursor', 512 | 'touchAction', 513 | 'userSelect', 514 | 'willChange', 515 | 'alignContent', 516 | 'alignItems', 517 | 'alignSelf', 518 | 'backfaceVisibility', 519 | 'borderWidth', 520 | 'borderBottomWidth', 521 | 'borderEndWidth', 522 | 'borderLeftWidth', 523 | 'borderRightWidth', 524 | 'borderStartWidth', 525 | 'borderTopWidth', 526 | 'bottom', 527 | 'boxSizing', 528 | 'direction', 529 | 'display', 530 | 'end', 531 | 'flex', 532 | 'flexBasis', 533 | 'flexDirection', 534 | 'flexGrow', 535 | 'flexShrink', 536 | 'flexWrap', 537 | 'height', 538 | 'justifyContent', 539 | 'left', 540 | 'margin', 541 | 'marginBottom', 542 | 'marginHorizontal', 543 | 'marginEnd', 544 | 'marginLeft', 545 | 'marginRight', 546 | 'marginStart', 547 | 'marginTop', 548 | 'marginVertical', 549 | 'maxHeight', 550 | 'maxWidth', 551 | 'minHeight', 552 | 'minWidth', 553 | 'order', 554 | 'overflow', 555 | 'overflowX', 556 | 'overflowY', 557 | 'padding', 558 | 'paddingBottom', 559 | 'paddingHorizontal', 560 | 'paddingEnd', 561 | 'paddingLeft', 562 | 'paddingRight', 563 | 'paddingStart', 564 | 'paddingTop', 565 | 'paddingVertical', 566 | 'position', 567 | 'right', 568 | 'start', 569 | 'top', 570 | 'visibility', 571 | 'width', 572 | 'zIndex', 573 | 'aspectRatio', 574 | 'gridAutoColumns', 575 | 'gridAutoFlow', 576 | 'gridAutoRows', 577 | 'gridColumnEnd', 578 | 'gridColumnGap', 579 | 'gridColumnStart', 580 | 'gridRowEnd', 581 | 'gridRowGap', 582 | 'gridRowStart', 583 | 'gridTemplateColumns', 584 | 'gridTemplateRows', 585 | 'gridTemplateAreas', 586 | 'shadowColor', 587 | 'shadowOffset', 588 | 'shadowOpacity', 589 | 'shadowRadius', 590 | 'shadowSpread', 591 | 'perspective', 592 | 'perspectiveOrigin', 593 | 'transform', 594 | 'transformOrigin', 595 | 'transformStyle', 596 | 'backgroundColor', 597 | 'opacity', 598 | 'elevation', 599 | 'backgroundAttachment', 600 | 'backgroundBlendMode', 601 | 'backgroundClip', 602 | 'backgroundImage', 603 | 'backgroundOrigin', 604 | 'backgroundPosition', 605 | 'backgroundRepeat', 606 | 'backgroundSize', 607 | 'boxShadow', 608 | 'clip', 609 | 'filter', 610 | 'outline', 611 | 'outlineColor', 612 | 'overscrollBehavior', 613 | 'overscrollBehaviorX', 614 | 'overscrollBehaviorY', 615 | 'WebkitMaskImage', 616 | 'WebkitOverflowScrolling', 617 | ]; 618 | -------------------------------------------------------------------------------- /src/widgets/FileWidget/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useCallback } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { StyleSheet } from 'react-native'; 4 | import { 5 | get, 6 | each, 7 | last, 8 | cloneDeep, 9 | } from 'lodash'; 10 | import StylePropType from 'react-native-web-ui-components/StylePropType'; 11 | import Dropzone from 'react-native-web-ui-components/Dropzone'; 12 | import Row from 'react-native-web-ui-components/Row'; 13 | import View from 'react-native-web-ui-components/View'; 14 | import { 15 | withPrefix, 16 | getTitle, 17 | getTitleFormat, 18 | isField, 19 | toPath, 20 | } from '../../utils'; 21 | import ArrayWidget from '../ArrayWidget'; 22 | import ObjectWidget from '../ObjectWidget'; 23 | import TextWidget from '../TextWidget'; 24 | import FileArea from './FileArea'; 25 | import OrderHandle from './OrderHandle'; 26 | import RemoveHandle from './RemoveHandle'; 27 | import ProgressHandle from './ProgressHandle'; 28 | import UploadHandle from './UploadHandle'; 29 | 30 | let id = 0; 31 | 32 | const handleRegex = /__handle( |$)/; 33 | 34 | const styles = StyleSheet.create({ 35 | defaults: { 36 | marginBottom: 10, 37 | }, 38 | fullWidth: { 39 | width: '100%', 40 | }, 41 | auto: { 42 | marginBottom: 0, 43 | }, 44 | containerMultiple: { 45 | width: '100%', 46 | padding: 10, 47 | }, 48 | containerSingle: { 49 | width: '100%', 50 | paddingTop: 5, 51 | paddingLeft: 5, 52 | paddingRight: 5, 53 | paddingBottom: 0, 54 | }, 55 | main: { 56 | flex: 1, 57 | }, 58 | item: { 59 | marginTop: -10, 60 | marginBottom: 0, 61 | }, 62 | itemSingle: { 63 | height: 30, 64 | }, 65 | label: { 66 | paddingTop: 10, 67 | paddingBottom: 5, 68 | }, 69 | wrapper: { 70 | flex: 1, 71 | }, 72 | singleLine: { 73 | minHeight: 40, 74 | }, 75 | removeSingle: { 76 | paddingLeft: 5, 77 | paddingRight: 3, 78 | paddingTop: 6, 79 | }, 80 | uploadSingle: { 81 | paddingLeft: 5, 82 | }, 83 | }); 84 | 85 | const uiProperty = /^ui:/; 86 | const attribute = /\.[^.]+$/; 87 | 88 | const getHref = ({ 89 | name, 90 | meta, 91 | values, 92 | basepath, 93 | pathAttribute, 94 | }) => { 95 | const progress = meta && meta['ui:progress']; 96 | if (progress === undefined || progress === 100) { 97 | let path = name; 98 | if (pathAttribute) { 99 | path = name.replace(attribute, `.${pathAttribute}`); 100 | } 101 | return `${basepath}${get(values, path)}`; 102 | } 103 | return null; 104 | }; 105 | 106 | const getProps = (props) => { 107 | const { 108 | name, 109 | value, 110 | schema, 111 | uiSchema, 112 | multiple, 113 | widgets, 114 | fileStyle, 115 | nameAttribute, 116 | pathAttribute, 117 | downloadable, 118 | downloadBasepath, 119 | EditComponent, 120 | ProgressComponent, 121 | RemoveComponent, 122 | UploadComponent, 123 | removeLabel, 124 | removeStyle, 125 | uploadLabel, 126 | uploadStyle, 127 | } = props; 128 | 129 | const dropzoneStyle = []; 130 | const options = uiSchema['ui:options'] || {}; 131 | const removable = options.removable !== false; 132 | let isMultiple = multiple; 133 | let adjustedNameAttribute; 134 | let adjustedPathAttribute; 135 | let fileSchema = schema; 136 | let defaultWidget = 'text'; 137 | let baseUiSchemaPath = ''; 138 | let pathUiSchemaPath = ''; 139 | let nameUiSchemaPath = ''; 140 | let adjustedFileStyle = fileStyle; 141 | let adjustedDownloadable = downloadable; 142 | let adjustedDownloadBasepath = downloadBasepath; 143 | let adjustedEditComponent = EditComponent; 144 | let adjustedProgressComponent = ProgressComponent; 145 | let adjustedRemoveComponent = RemoveComponent; 146 | let adjustedRemoveStyle = removeStyle; 147 | let adjustedRemoveLabel = removeLabel; 148 | let adjustedUploadComponent = UploadComponent; 149 | let adjustedUploadStyle = uploadStyle; 150 | let adjustedUploadLabel = uploadLabel; 151 | 152 | if (schema.type === 'array') { 153 | isMultiple = isMultiple === null ? true : isMultiple; 154 | fileSchema = schema.items; 155 | baseUiSchemaPath = 'items'; 156 | pathUiSchemaPath = 'items'; 157 | nameUiSchemaPath = 'items'; 158 | adjustedRemoveComponent = ( 159 | adjustedRemoveComponent 160 | || (uiSchema['ui:options'] && uiSchema['ui:options'].RemoveComponent) 161 | ); 162 | adjustedRemoveStyle = ( 163 | adjustedRemoveStyle 164 | || (uiSchema['ui:options'] && uiSchema['ui:options'].removeStyle) 165 | ); 166 | adjustedRemoveLabel = ( 167 | adjustedRemoveLabel 168 | || (uiSchema['ui:options'] && uiSchema['ui:options'].removeLabel) 169 | ); 170 | adjustedUploadComponent = ( 171 | adjustedUploadComponent 172 | || (uiSchema['ui:options'] && uiSchema['ui:options'].UploadComponent) 173 | ); 174 | adjustedUploadStyle = ( 175 | adjustedUploadStyle 176 | || (uiSchema['ui:options'] && uiSchema['ui:options'].uploadStyle) 177 | ); 178 | adjustedUploadLabel = ( 179 | adjustedUploadLabel 180 | || (uiSchema['ui:options'] && uiSchema['ui:options'].uploadLabel) 181 | ); 182 | adjustedEditComponent = ( 183 | adjustedEditComponent 184 | || (uiSchema['ui:options'] && uiSchema['ui:options'].EditComponent) 185 | ); 186 | adjustedProgressComponent = ( 187 | adjustedProgressComponent 188 | || (uiSchema['ui:options'] && uiSchema['ui:options'].ProgressComponent) 189 | ); 190 | if (adjustedDownloadable === undefined) { 191 | adjustedDownloadable = uiSchema['ui:options'] && uiSchema['ui:options'].downloadable; 192 | } 193 | if (adjustedDownloadBasepath === undefined) { 194 | adjustedDownloadBasepath = uiSchema['ui:options'] && uiSchema['ui:options'].downloadBasepath; 195 | } 196 | } else if (isMultiple) { 197 | throw new Error('FileWidget: widget cannot return multiple files for a non-array schema.'); 198 | } else { 199 | isMultiple = false; 200 | } 201 | 202 | if (!adjustedProgressComponent) { 203 | adjustedProgressComponent = ProgressHandle; 204 | } 205 | if (adjustedDownloadable === undefined) { 206 | adjustedDownloadable = true; 207 | } 208 | if (adjustedDownloadBasepath === undefined) { 209 | adjustedDownloadBasepath = ''; 210 | } 211 | if (!adjustedRemoveComponent) { 212 | adjustedRemoveComponent = RemoveHandle; 213 | } 214 | if (adjustedRemoveStyle === undefined) { 215 | adjustedRemoveStyle = null; 216 | } 217 | if (adjustedRemoveLabel === undefined) { 218 | adjustedRemoveLabel = 'Delete'; 219 | } 220 | if (!adjustedUploadComponent) { 221 | adjustedUploadComponent = UploadHandle; 222 | } 223 | if (adjustedUploadStyle === undefined) { 224 | adjustedUploadStyle = null; 225 | } 226 | if (adjustedUploadLabel === undefined) { 227 | adjustedUploadLabel = 'Upload'; 228 | } 229 | 230 | if (fileSchema.type === 'string') { 231 | adjustedNameAttribute = ''; 232 | adjustedPathAttribute = ''; 233 | } else if (fileSchema.type === 'object') { 234 | const strings = Object.keys(fileSchema.properties).filter(k => (fileSchema.properties[k].type === 'string')); 235 | 236 | if (!strings.length) { 237 | throw new Error('FileWidget: the file schema must have at least one string property to be used as pathAttribute.'); 238 | } 239 | adjustedPathAttribute = pathAttribute || strings[0]; 240 | adjustedNameAttribute = nameAttribute || adjustedPathAttribute; 241 | 242 | if (strings.indexOf(adjustedPathAttribute) < 0) { 243 | throw new Error(`FileWidget: the file schema doesn't contain "${adjustedPathAttribute}"`); 244 | } 245 | if (strings.indexOf(adjustedNameAttribute) < 0) { 246 | throw new Error(`FileWidget: the file schema doesn't contain "${adjustedNameAttribute}"`); 247 | } 248 | nameUiSchemaPath = withPrefix(adjustedNameAttribute, nameUiSchemaPath); 249 | pathUiSchemaPath = withPrefix(adjustedPathAttribute, pathUiSchemaPath); 250 | } else { 251 | throw new Error(`FileWidget cannot be used with ${fileSchema.type}.`); 252 | } 253 | 254 | const propertyUiSchema = cloneDeep(uiSchema); 255 | const baseUiSchema = baseUiSchemaPath 256 | ? get(propertyUiSchema, baseUiSchemaPath) : propertyUiSchema; 257 | const pathUiSchema = pathUiSchemaPath 258 | ? get(propertyUiSchema, pathUiSchemaPath) : propertyUiSchema; 259 | const nameUiSchema = nameUiSchemaPath 260 | ? get(propertyUiSchema, nameUiSchemaPath) : propertyUiSchema; 261 | 262 | if (adjustedNameAttribute && adjustedNameAttribute !== adjustedPathAttribute) { 263 | baseUiSchema['ui:title'] = false; 264 | pathUiSchema['ui:widget'] = 'hidden'; 265 | if (schema.type !== 'array' && (!value || !value[pathAttribute])) { 266 | defaultWidget = 'hidden'; 267 | } 268 | nameUiSchema['ui:widget'] = nameUiSchema['ui:widget'] || defaultWidget; 269 | nameUiSchema['ui:title'] = false; 270 | nameUiSchema['ui:widgetProps'] = nameUiSchema['ui:widgetProps'] || {}; 271 | nameUiSchema['ui:widgetProps'].auto = false; 272 | nameUiSchema['ui:widgetProps'].editable = true; 273 | nameUiSchema['ui:widgetProps'].style = [ 274 | styles.auto, 275 | styles.fullWidth, 276 | nameUiSchema['ui:widgetProps'].style, 277 | ]; 278 | if (adjustedDownloadable) { 279 | nameUiSchema['ui:widgetProps'].basepath = adjustedDownloadBasepath; 280 | nameUiSchema['ui:widgetProps'].to = getHref; 281 | nameUiSchema['ui:widgetProps'].pathAttribute = pathAttribute; 282 | } 283 | nameUiSchema['ui:widgetProps'].inputContainerStyle = styles.main; 284 | nameUiSchema['ui:widgetProps'].EditComponent = adjustedEditComponent; 285 | nameUiSchema['ui:widgetProps'].children = adjustedProgressComponent; 286 | if (schema.type !== 'array' && (nameUiSchema['ui:widget'] === 'text' || nameUiSchema['ui:widget'] === 'hidden')) { 287 | dropzoneStyle.push(styles.singleLine); 288 | } 289 | } else { 290 | if ( 291 | (schema.type === 'string' && !value) 292 | || (schema.type === 'object' && (!value || !value[pathAttribute])) 293 | ) { 294 | defaultWidget = 'hidden'; 295 | } 296 | pathUiSchema['ui:widget'] = pathUiSchema['ui:widget'] || defaultWidget; 297 | pathUiSchema['ui:title'] = false; 298 | pathUiSchema['ui:widgetProps'] = pathUiSchema['ui:widgetProps'] || {}; 299 | pathUiSchema['ui:widgetProps'].auto = false; 300 | pathUiSchema['ui:widgetProps'].editable = false; 301 | pathUiSchema['ui:widgetProps'].style = [ 302 | styles.auto, 303 | styles.fullWidth, 304 | pathUiSchema['ui:widgetProps'].style, 305 | ]; 306 | if (adjustedDownloadable) { 307 | pathUiSchema['ui:widgetProps'].basepath = adjustedDownloadBasepath; 308 | pathUiSchema['ui:widgetProps'].to = getHref; 309 | } 310 | pathUiSchema['ui:widgetProps'].EditComponent = adjustedEditComponent; 311 | pathUiSchema['ui:widgetProps'].children = adjustedProgressComponent; 312 | if ( 313 | (schema.type === 'string' && pathUiSchema['ui:widget'] === 'file') 314 | || (schema.type === 'object' && pathUiSchema['ui:widget'] === 'text') 315 | ) { 316 | dropzoneStyle.push(styles.singleLine); 317 | } 318 | } 319 | 320 | if (fileSchema.type === 'object') { 321 | baseUiSchema['ui:grid'] = [{ 322 | type: 'view', 323 | style: styles.main, 324 | children: [], 325 | }]; 326 | each(baseUiSchema, (innerPropertyUiSchema, k) => { 327 | if (!uiProperty.test(k)) { 328 | baseUiSchema['ui:grid'][0].children.push(k); 329 | if (k !== adjustedNameAttribute && k !== adjustedPathAttribute) { 330 | innerPropertyUiSchema['ui:widget'] = 'hidden'; // eslint-disable-line 331 | } 332 | } 333 | }); 334 | } 335 | 336 | let title = false; 337 | if (schema.type === 'array') { 338 | if (uiSchema['ui:title'] !== false) { 339 | title = getTitle(getTitleFormat(schema, uiSchema), { 340 | name, 341 | value, 342 | key: last(name.split('.')), 343 | }); 344 | } 345 | propertyUiSchema['ui:title'] = false; 346 | propertyUiSchema['ui:options'] = propertyUiSchema['ui:options'] || {}; 347 | propertyUiSchema['ui:options'].addable = false; 348 | propertyUiSchema['ui:options'].minimumNumberOfItems = 0; 349 | propertyUiSchema['ui:options'].OrderComponent = propertyUiSchema['ui:options'].OrderComponent || OrderHandle; 350 | propertyUiSchema['ui:options'].RemoveComponent = propertyUiSchema['ui:options'].RemoveComponent || RemoveHandle; 351 | propertyUiSchema['ui:titleProps'] = propertyUiSchema['ui:titleProps'] || {}; 352 | propertyUiSchema['ui:titleProps'].style = [ 353 | styles.label, 354 | propertyUiSchema['ui:titleProps'].style, 355 | ]; 356 | adjustedFileStyle = propertyUiSchema['ui:options'].fileStyle || fileStyle; 357 | } 358 | 359 | return { 360 | ...props, 361 | title, 362 | removable, 363 | fileSchema, 364 | dropzoneStyle, 365 | propertyUiSchema, 366 | propertySchema: schema, 367 | multiple: isMultiple, 368 | LabelWidget: widgets.LabelWidget, 369 | fileStyle: adjustedFileStyle, 370 | RemoveComponent: adjustedRemoveComponent, 371 | UploadComponent: adjustedUploadComponent, 372 | removeStyle: adjustedRemoveStyle, 373 | removeLabel: adjustedRemoveLabel, 374 | uploadStyle: adjustedUploadStyle, 375 | uploadLabel: adjustedUploadLabel, 376 | }; 377 | }; 378 | 379 | const useOnChange = ({ 380 | name, 381 | value, 382 | onChange, 383 | schema, 384 | nameAttribute, 385 | pathAttribute, 386 | }) => (propertyValue, propertyName, params = {}) => { 387 | if (schema.type === 'array' && propertyName === name) { 388 | const update = []; 389 | const length = Math.max(propertyValue.length, value.length); 390 | for (let i = 0; i < length; i += 1) { 391 | update.push(`${name}.${i}${nameAttribute ? `.${nameAttribute}` : ''}`); 392 | update.push(`${name}.${i}${pathAttribute ? `.${pathAttribute}` : ''}`); 393 | } 394 | onChange(propertyValue, propertyName, { 395 | ...params, 396 | update, 397 | }); 398 | } else if (schema.type === 'array' && schema.items.type === 'object') { 399 | const path = propertyName.split('.'); 400 | const [key] = path.splice(-1, 1); 401 | const index = parseInt(path.splice(-1, 1)[0], 10); 402 | value[index][key] = propertyValue; // eslint-disable-line 403 | onChange(value, name, { 404 | ...params, 405 | update: [ 406 | `${name}.${index}.${nameAttribute}`, 407 | `${name}.${index}.${pathAttribute}`, 408 | ], 409 | }); 410 | } else if (schema.type === 'array' && schema.items.type === 'string') { 411 | const path = propertyName.split('.'); 412 | const index = parseInt(path.splice(-1, 1)[0], 10); 413 | value[index] = propertyValue; // eslint-disable-line 414 | onChange(value, name, { 415 | ...params, 416 | update: [`${name}.${index}`], 417 | }); 418 | } else if (schema.type === 'object' && propertyName === name) { 419 | onChange(propertyValue, propertyName, { 420 | ...params, 421 | update: [ 422 | `${name}.${nameAttribute}`, 423 | `${name}.${pathAttribute}`, 424 | ], 425 | }); 426 | } else if (schema.type === 'object') { 427 | const path = propertyName.split('.'); 428 | const [key] = path.splice(-1, 1); 429 | value[key] = propertyValue; // eslint-disable-line 430 | onChange(value, name, { 431 | ...params, 432 | update: [ 433 | `${name}.${nameAttribute}`, 434 | `${name}.${pathAttribute}`, 435 | ], 436 | }); 437 | } else { 438 | onChange(propertyValue, propertyName, params); 439 | } 440 | }; 441 | 442 | const useOnAccepted = ({ 443 | name, 444 | meta, 445 | value, 446 | schema, 447 | multiple, 448 | onChange, 449 | fileSchema, 450 | nameAttribute, 451 | }) => (files) => { 452 | let nextValue = files; 453 | let nextMeta = meta; 454 | if (multiple) { 455 | nextValue = value.concat(files.map(file => file.value)); 456 | if (fileSchema.type === 'string') { 457 | nextMeta = nextMeta.concat(files.map(file => ({ 458 | 'ui:fileId': file.id, 459 | 'ui:progress': 0, 460 | }))); 461 | } else { 462 | nextMeta = nextMeta.concat(files.map(file => ({ 463 | [nameAttribute]: { 464 | 'ui:fileId': file.id, 465 | 'ui:progress': 0, 466 | }, 467 | }))); 468 | } 469 | return onChange(nextValue, name, { 470 | nextMeta, 471 | }); 472 | } 473 | if (schema.type === 'array' && fileSchema === 'string') { 474 | nextValue = files.map(file => file.value); 475 | nextMeta = files.map(file => ({ 476 | 'ui:fileId': file.id, 477 | 'ui:progress': 0, 478 | })); 479 | } else if (schema.type === 'array' && fileSchema === 'object') { 480 | nextValue = files.map(file => file.value); 481 | nextMeta = files.map(file => ({ 482 | [nameAttribute]: { 483 | 'ui:fileId': file.id, 484 | 'ui:progress': 0, 485 | }, 486 | })); 487 | } else if (schema.type === 'object') { 488 | nextValue = {}; 489 | nextMeta = {}; 490 | if (files.length) { 491 | Object.assign(nextValue, files[0].value); 492 | Object.assign(nextMeta, { 493 | [nameAttribute]: { 494 | 'ui:fileId': files[0].id, 495 | 'ui:progress': 0, 496 | }, 497 | }); 498 | } 499 | } else { 500 | nextValue = null; 501 | nextMeta = {}; 502 | if (files.length) { 503 | nextValue = files[0].value; 504 | Object.assign(nextMeta, { 505 | 'ui:fileId': files[0].id, 506 | 'ui:progress': 0, 507 | }); 508 | } 509 | } 510 | return onChange(nextValue, name, { 511 | nextMeta, 512 | }); 513 | }; 514 | 515 | const useOnRemove = ({ 516 | name, 517 | value, 518 | onChange, 519 | removable, 520 | }) => { 521 | const canRemove = value && removable; 522 | const onRemove = () => { 523 | const nextValue = null; 524 | const nextMeta = {}; 525 | onChange(nextValue, name, { 526 | nextMeta, 527 | }); 528 | }; 529 | return [canRemove, onRemove]; 530 | }; 531 | 532 | const setMeta = (meta, params) => { 533 | each(params, (v, k) => { meta[`ui:${k}`] = v; }); // eslint-disable-line 534 | }; 535 | 536 | const useOnMeta = ({ 537 | name, 538 | meta, 539 | metas, 540 | value, 541 | values, 542 | schema, 543 | fileSchema, 544 | onChange, 545 | nameAttribute, 546 | }) => { 547 | const anchor = useRef(); 548 | 549 | anchor.current = (fileId, params) => { 550 | let update; 551 | let metaItem; 552 | let metaItemIndex; 553 | const nextMeta = get(metas, toPath(name), meta); 554 | const nextValue = get(values, toPath(name), value); 555 | 556 | if (schema.type === 'array' && fileSchema.type === 'string') { 557 | for (let i = 0; i < nextMeta.length; i += 1) { 558 | if (nextMeta[i]['ui:fileId'] === fileId) { 559 | metaItem = nextMeta[i]; 560 | metaItemIndex = i; 561 | } 562 | } 563 | if (metaItem) { 564 | setMeta(metaItem, params); 565 | update = [`${name}.${metaItemIndex}`]; 566 | } 567 | } else if (schema.type === 'array' && fileSchema.type === 'object') { 568 | for (let i = 0; i < nextMeta.length; i += 1) { 569 | if (nextMeta[i][nameAttribute] && nextMeta[i][nameAttribute]['ui:fileId'] === fileId) { 570 | metaItem = nextMeta[i]; 571 | metaItemIndex = i; 572 | } 573 | } 574 | if (metaItem) { 575 | setMeta(metaItem[nameAttribute], params); 576 | update = [`${name}.${metaItemIndex}.${nameAttribute}`]; 577 | } 578 | } else if (fileSchema.type === 'string' && nextMeta['ui:fileId'] === fileId) { 579 | setMeta(nextMeta, params); 580 | update = [name]; 581 | } else if (fileSchema.type === 'object' && nextMeta[nameAttribute] && nextMeta[nameAttribute]['ui:fileId'] === fileId) { 582 | setMeta(nextMeta[nameAttribute], params); 583 | update = [`${name}.${nameAttribute}`]; 584 | } 585 | if (update) { 586 | onChange(nextValue, name, { 587 | nextMeta, 588 | update, 589 | }); 590 | } 591 | }; 592 | 593 | return anchor; 594 | }; 595 | 596 | const useOnDrop = (props) => { 597 | const { 598 | onDrop, 599 | onMeta, 600 | onAccepted, 601 | fileSchema, 602 | nameAttribute, 603 | pathAttribute, 604 | } = props; 605 | 606 | const anchor = useRef(); 607 | anchor.current = (files) => { 608 | if (files.length) { 609 | const nextFiles = files.map((file) => { 610 | id += 1; 611 | const fileId = id; 612 | return { 613 | id, 614 | file, 615 | value: fileSchema.type === 'string' ? (file.uri || file.name) : ({ 616 | [nameAttribute]: file.name, 617 | [pathAttribute]: file.uri || file.name, 618 | }), 619 | setData: data => onMeta.current(fileId, { data }), 620 | setProgress: progress => onMeta.current(fileId, { progress }), 621 | setError: error => onMeta.current(fileId, { error }), 622 | }; 623 | }); 624 | onDrop(nextFiles, onAccepted, props); 625 | } 626 | }; 627 | return anchor; 628 | }; 629 | 630 | const useOnAreaClick = ({ dragging }) => (event) => { 631 | if (dragging || event.isDefaultPrevented()) { 632 | event.stopPropagation(); 633 | } 634 | }; 635 | 636 | const useOnClick = ({ propertySchema }) => (event) => { 637 | const fn = propertySchema.type === 'array' ? 'preventDefault' : 'stopPropagation'; 638 | if ( 639 | event.nativeEvent.target.tagName === 'INPUT' 640 | || event.nativeEvent.target.querySelectorAll('input').length > 0 641 | ) { 642 | event[fn](); 643 | } 644 | if (isField(event.nativeEvent.target, handleRegex)) { 645 | event[fn](); 646 | } 647 | if (event.nativeEvent.target.tagName === 'A') { 648 | event.stopPropagation(); 649 | } 650 | }; 651 | 652 | const FileWidget = (props) => { 653 | const [dragging, setDragging] = useState(null); 654 | const params = getProps({ ...props, dragging, setDragging }); 655 | 656 | const { 657 | title, 658 | accept, 659 | cameraText, 660 | albumText, 661 | fileText, 662 | cancelText, 663 | propertySchema, 664 | propertyUiSchema, 665 | uiSchema, 666 | LabelWidget, 667 | hasError, 668 | style: _style, 669 | fileStyle, 670 | auto, 671 | dropzoneStyle, 672 | RemoveComponent, 673 | UploadComponent, 674 | removeStyle, 675 | removeLabel, 676 | uploadStyle, 677 | uploadLabel, 678 | ...nextProps 679 | } = params; 680 | 681 | const onAreaClick = useOnAreaClick(params); 682 | const onClick = useOnClick(params); 683 | const onChange = useOnChange(params); 684 | const onMeta = useOnMeta(params); 685 | const onAccepted = useOnAccepted({ ...params, onChange }); 686 | const [canRemove, onRemove] = useOnRemove({ ...params, onChange }); 687 | 688 | const onDropAnchor = useOnDrop({ ...params, onMeta, onAccepted }); 689 | const onDrop = useCallback( 690 | (...args) => onDropAnchor.current(...args), 691 | [onDropAnchor], 692 | ); 693 | 694 | const dropzone = useRef(); 695 | const onRef = (ref) => { 696 | dropzone.current = ref; 697 | }; 698 | const onUploadPress = () => { 699 | if (dropzone.current) { 700 | dropzone.current.open(); 701 | } 702 | }; 703 | 704 | return ( 705 | 706 | {(title !== false || uiSchema['ui:toggleable']) && propertySchema.type === 'string' ? ( 707 | 716 | {title} 717 | 718 | ) : null} 719 | 735 | 736 | {propertySchema.type === 'array' ? ( 737 | 738 | 745 | 755 | 756 | ) : ( 757 | <> 758 | 759 | {propertySchema.type === 'object' ? ( 760 | 761 | 770 | 771 | ) : null} 772 | {propertySchema.type === 'string' && nextProps.value ? ( 773 | 774 | 784 | 785 | ) : null} 786 | 787 | {canRemove ? ( 788 | 795 | ) : null} 796 | 803 | 804 | )} 805 | 806 | 807 | 808 | ); 809 | }; 810 | 811 | FileWidget.propTypes = { 812 | name: PropTypes.string.isRequired, 813 | schema: PropTypes.shape().isRequired, 814 | uiSchema: PropTypes.shape().isRequired, 815 | hasError: PropTypes.bool.isRequired, 816 | widgets: PropTypes.shape().isRequired, 817 | style: StylePropType, 818 | fileStyle: StylePropType, 819 | auto: PropTypes.bool, 820 | multiple: PropTypes.bool, 821 | nameAttribute: PropTypes.string, 822 | pathAttribute: PropTypes.string, 823 | onDrop: PropTypes.func, 824 | accept: PropTypes.arrayOf(PropTypes.string), 825 | cameraText: PropTypes.string, 826 | albumText: PropTypes.string, 827 | fileText: PropTypes.string, 828 | cancelText: PropTypes.string, 829 | EditComponent: PropTypes.elementType, 830 | ProgressComponent: PropTypes.elementType, 831 | downloadable: PropTypes.bool, 832 | downloadBasepath: PropTypes.string, 833 | }; 834 | 835 | FileWidget.defaultProps = { 836 | style: null, 837 | fileStyle: null, 838 | auto: false, 839 | multiple: null, 840 | nameAttribute: null, 841 | pathAttribute: null, 842 | onDrop: (items, accept) => accept(items), 843 | accept: undefined, 844 | cameraText: undefined, 845 | albumText: undefined, 846 | fileText: undefined, 847 | cancelText: undefined, 848 | EditComponent: undefined, 849 | ProgressComponent: undefined, 850 | downloadable: undefined, 851 | downloadBasepath: undefined, 852 | }; 853 | 854 | export default FileWidget; 855 | --------------------------------------------------------------------------------