├── src ├── fields │ ├── DateField.js │ ├── AbstractEnumerableField.js │ ├── index.js │ ├── ObjectField.js │ ├── ArrayField.js │ ├── StringField.js │ └── AbstractField.js ├── widgets │ ├── HiddenWidget.js │ ├── PasswordWidget.js │ ├── EmailWidget.js │ ├── ArrayWidget │ │ ├── getItemPosition.js │ │ ├── AddHandle.js │ │ ├── OrderHandle.js │ │ ├── RemoveHandle.js │ │ ├── Item.js │ │ └── index.js │ ├── ZipWidget.js │ ├── IntegerWidget.js │ ├── FileWidget │ │ ├── plus.svg │ │ ├── plus-android.svg │ │ ├── gallery-android.svg │ │ ├── photo-android.svg │ │ └── index.js │ ├── TextareaWidget.js │ ├── PhoneWidget.js │ ├── ObjectWidget │ │ ├── index.js │ │ └── createGrid.js │ ├── common │ │ ├── Row.js │ │ ├── Column.js │ │ └── Screen.js │ ├── index.js │ ├── ErrorWidget.js │ ├── LabelWidget.js │ ├── TextInput.js │ ├── SelectWidget.js │ ├── NumberWidget.js │ ├── DateWidget.js │ └── TextInputWidget.js ├── FormEvent.js ├── Div.js ├── UIProvider.js ├── CancelButton.js ├── SubmitButton.js ├── Theme.js ├── utils.js └── index.js ├── .idea ├── .gitignore ├── misc.xml ├── vcs.xml ├── modules.xml └── react-native-jsonschema-form-velocity.iml ├── .travis.yml ├── .github └── dependabot.yml ├── renovate.json ├── .babelrc ├── .npmignore ├── .gitignore ├── PULL_REQUEST_TEMPLATE.md ├── ISSUE_TEMPLATE.md ├── .eslintrc ├── package.json └── README.md /src/fields/DateField.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /workspace.xml 3 | -------------------------------------------------------------------------------- /src/widgets/HiddenWidget.js: -------------------------------------------------------------------------------- 1 | const HiddenWidget = () => null; 2 | 3 | export default HiddenWidget; 4 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | time: "00:00" 8 | timezone: "Etc/UTC" -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /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/fields/AbstractEnumerableField.js: -------------------------------------------------------------------------------- 1 | import AbstractField from './AbstractField'; 2 | 3 | class AbstractEnumerableField extends AbstractField { 4 | getWidget = () => null 5 | } 6 | 7 | export default AbstractEnumerableField; 8 | -------------------------------------------------------------------------------- /src/fields/index.js: -------------------------------------------------------------------------------- 1 | import ArrayField from './ArrayField'; 2 | import ObjectField from './ObjectField'; 3 | import StringField from './StringField'; 4 | 5 | export default { 6 | ArrayField, 7 | ObjectField, 8 | StringField, 9 | }; 10 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | ["lodash", { 4 | "id": [ 5 | "lodash", 6 | "underscore.string" 7 | ] 8 | }], 9 | "@babel/plugin-transform-react-jsx", 10 | "@babel/plugin-proposal-class-properties" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /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 | .idea 24 | .DS_Store 25 | /.idea 26 | -------------------------------------------------------------------------------- /.idea/react-native-jsonschema-form-velocity.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /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/widgets/FileWidget/plus.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/widgets/FileWidget/plus-android.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/UIProvider.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Provider as ThemeProvider } from './Theme'; 4 | 5 | // eslint-disable-next-line import/prefer-default-export 6 | export const UIProvider = ({ theme, children }) => ( 7 | 8 | {children} 9 | 10 | ); 11 | 12 | UIProvider.propTypes = { 13 | theme: PropTypes.shape(), 14 | children: PropTypes.node, 15 | }; 16 | 17 | UIProvider.defaultProps = { 18 | theme: {}, 19 | children: null, 20 | }; 21 | 22 | -------------------------------------------------------------------------------- /src/CancelButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Button } from 'react-native'; 3 | import PropTypes from 'prop-types'; 4 | import { withTheme } from './Theme'; 5 | 6 | const CancelButton = ({ text, onPress }) => ( 7 | 16 | ); 17 | 18 | CancelButton.propTypes = { 19 | onPress: PropTypes.func.isRequired, 20 | text: PropTypes.string.isRequired, 21 | }; 22 | 23 | export default withTheme('JsonSchemaFormCancelButton')(CancelButton); 24 | -------------------------------------------------------------------------------- /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/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/ArrayWidget/AddHandle.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Button } from 'react-native'; 4 | import { get } from 'lodash'; 5 | 6 | const AddHandle = ({ theme, onPress, addLabel }) => ( 7 | 18 | ); 19 | 20 | AddHandle.propTypes = { 21 | theme: PropTypes.shape().isRequired, 22 | onPress: PropTypes.func.isRequired, 23 | addLabel: PropTypes.string.isRequired, 24 | }; 25 | 26 | export default AddHandle; 27 | -------------------------------------------------------------------------------- /src/widgets/FileWidget/gallery-android.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/SubmitButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { StyleSheet, Button } from 'react-native'; 3 | import PropTypes from 'prop-types'; 4 | import { withTheme } from './Theme'; 5 | 6 | const styles = StyleSheet.create({ 7 | button: { 8 | marginBottom: 5, 9 | }, 10 | }); 11 | 12 | const SubmitButton = ({ theme, text, onPress }) => ( 13 | 25 | ); 26 | 27 | SubmitButton.propTypes = { 28 | theme: PropTypes.shape().isRequired, 29 | onPress: PropTypes.func.isRequired, 30 | text: PropTypes.string.isRequired, 31 | }; 32 | 33 | export default withTheme('JsonSchemaFormSubmitButton')(SubmitButton); 34 | -------------------------------------------------------------------------------- /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/widgets/common/Row.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { StyleSheet } from 'react-native'; 4 | import { ViewPropTypes } from 'deprecated-react-native-prop-types'; 5 | 6 | import { withTheme } from '../../Theme'; 7 | import Column from './Column'; 8 | 9 | const styles = StyleSheet.create({ 10 | empty: {}, 11 | defaults: { 12 | display: 'flex', 13 | flexWrap: 'wrap', 14 | flexDirection: 'row', 15 | alignItems: 'flex-start', 16 | justifyContent: 'flex-start', 17 | }, 18 | }); 19 | 20 | const Row = ({ xs, style, ...props }) => ( 21 | 26 | ); 27 | 28 | Row.propTypes = { 29 | xs: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), 30 | style: ViewPropTypes.style, 31 | }; 32 | 33 | Row.defaultProps = { 34 | xs: 12, 35 | style: styles.empty, 36 | }; 37 | 38 | export default withTheme('Row')(Row); 39 | -------------------------------------------------------------------------------- /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/widgets/index.js: -------------------------------------------------------------------------------- 1 | import ArrayWidget from './ArrayWidget'; 2 | import DateWidget from './DateWidget'; 3 | import EmailWidget from './EmailWidget'; 4 | import ErrorWidget from './ErrorWidget'; 5 | import FileWidget from './FileWidget'; 6 | import HiddenWidget from './HiddenWidget'; 7 | import IntegerWidget from './IntegerWidget'; 8 | import LabelWidget from './LabelWidget'; 9 | import NumberWidget from './NumberWidget'; 10 | import ObjectWidget from './ObjectWidget'; 11 | import PasswordWidget from './PasswordWidget'; 12 | import PhoneWidget from './PhoneWidget'; 13 | import SelectWidget from './SelectWidget'; 14 | import TextareaWidget from './TextareaWidget'; 15 | import TextInputWidget from './TextInputWidget'; 16 | import ZipWidget from './ZipWidget'; 17 | 18 | export default { 19 | ArrayWidget, 20 | DateWidget, 21 | EmailWidget, 22 | ErrorWidget, 23 | FileWidget, 24 | HiddenWidget, 25 | IntegerWidget, 26 | LabelWidget, 27 | NumberWidget, 28 | ObjectWidget, 29 | PasswordWidget, 30 | PhoneWidget, 31 | SelectWidget, 32 | TextareaWidget, 33 | TextInputWidget, 34 | ZipWidget, 35 | }; 36 | -------------------------------------------------------------------------------- /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 (schema.format === 'file') { 16 | Widget = widgets.FileWidget; 17 | } else if (password.test(name)) { 18 | Widget = widgets.PasswordWidget; 19 | } else if (email.test(name)) { 20 | Widget = widgets.EmailWidget; 21 | } else if (phone.test(name)) { 22 | Widget = widgets.PhoneWidget; 23 | } else if (message.test(name)) { 24 | Widget = widgets.TextareaWidget; 25 | } else if (zip.test(name)) { 26 | Widget = widgets.ZipWidget; 27 | } else { 28 | Widget = widgets.TextInputWidget; 29 | } 30 | return Widget; 31 | } 32 | } 33 | 34 | export default StringField; 35 | -------------------------------------------------------------------------------- /src/widgets/FileWidget/photo-android.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /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, Text, View } from 'react-native'; 4 | import { withTheme } from '../Theme'; 5 | 6 | const styles = StyleSheet.create({ 7 | regular: { 8 | marginTop: -5, 9 | fontSize: 12, 10 | }, 11 | auto: { 12 | marginTop: 0, 13 | marginBottom: 0, 14 | }, 15 | container: { 16 | marginTop: 10, 17 | }, 18 | first: { 19 | marginTop: -10, 20 | }, 21 | last: { 22 | marginBottom: 10, 23 | }, 24 | }); 25 | 26 | const ErrorWidget = ({ 27 | theme, 28 | children, 29 | last, 30 | first, 31 | auto, 32 | ...props 33 | }) => { 34 | const style = [ 35 | styles.regular, 36 | { color: StyleSheet.flatten(theme.input.error.border).borderColor }, 37 | props.style, // eslint-disable-line 38 | ]; 39 | if (first) { 40 | style.push(styles.first); 41 | } 42 | if (last) { 43 | style.push(styles.last); 44 | } 45 | return ( 46 | 47 | 48 | {children} 49 | 50 | 51 | 52 | ); 53 | }; 54 | 55 | ErrorWidget.propTypes = { 56 | theme: PropTypes.shape().isRequired, 57 | last: PropTypes.bool, 58 | first: PropTypes.bool, 59 | children: PropTypes.node, 60 | auto: PropTypes.bool, 61 | }; 62 | 63 | ErrorWidget.defaultProps = { 64 | last: true, 65 | first: true, 66 | children: null, 67 | auto: false, 68 | }; 69 | 70 | export default withTheme('ErrorWidget')(ErrorWidget); 71 | -------------------------------------------------------------------------------- /src/widgets/ArrayWidget/OrderHandle.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { StyleSheet, View, Text } from 'react-native'; 4 | import { ViewPropTypes } from 'deprecated-react-native-prop-types'; 5 | import Div from '../../Div'; 6 | 7 | const styles = StyleSheet.create({ 8 | order: { 9 | fontSize: 15, 10 | textAlign: 'center', 11 | paddingRight: 10, 12 | paddingTop: 11, 13 | lineHeight: 23, 14 | }, 15 | hidden: { 16 | opacity: 0, 17 | paddingTop: 0, 18 | }, 19 | xs: { 20 | paddingTop: 0, 21 | }, 22 | }); 23 | 24 | const OrderHandle = ({ 25 | theme, 26 | handle, 27 | panHandlers, 28 | titleOnly, 29 | orderLabel, 30 | orderStyle, 31 | }) => ( 32 | 33 |
34 | 45 | {orderLabel} 46 | 47 |
48 |
49 | ); 50 | 51 | OrderHandle.propTypes = { 52 | theme: PropTypes.shape().isRequired, 53 | handle: PropTypes.string.isRequired, 54 | titleOnly: PropTypes.bool.isRequired, 55 | panHandlers: PropTypes.shape(), 56 | orderLabel: PropTypes.node.isRequired, 57 | orderStyle: ViewPropTypes.style, 58 | }; 59 | 60 | OrderHandle.defaultProps = { 61 | panHandlers: null, 62 | orderStyle: null, 63 | }; 64 | 65 | export default OrderHandle; 66 | -------------------------------------------------------------------------------- /src/widgets/ArrayWidget/RemoveHandle.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { StyleSheet, Text, Platform } from 'react-native'; 4 | import { ViewPropTypes } from 'deprecated-react-native-prop-types'; 5 | 6 | const styles = StyleSheet.create({ 7 | remove: { 8 | paddingLeft: 10, 9 | paddingTop: 11, 10 | fontSize: 11, 11 | fontWeight: '600', 12 | ...Platform.select( 13 | { 14 | ios: { 15 | color: '#007AFF', 16 | }, 17 | android: { 18 | color: '#7489A8', 19 | fontFamily: 'Roboto-Medium', 20 | }, 21 | }, 22 | ), 23 | }, 24 | hidden: { 25 | opacity: 0, 26 | paddingTop: 0, 27 | }, 28 | alignRight: { 29 | paddingTop: 0, 30 | width: '100%', 31 | textAlign: 'right', 32 | }, 33 | }); 34 | 35 | const RemoveHandle = ({ 36 | theme, 37 | onRemovePress, 38 | titleOnly, 39 | removeLabel, 40 | removeStyle, 41 | }) => { 42 | if (!titleOnly) { 43 | return ( 44 | 55 | {removeLabel} 56 | 57 | ); 58 | } 59 | return ( 60 | 68 | {removeLabel} 69 | 70 | ); 71 | }; 72 | 73 | RemoveHandle.propTypes = { 74 | theme: PropTypes.shape().isRequired, 75 | onRemovePress: PropTypes.func.isRequired, 76 | titleOnly: PropTypes.bool.isRequired, 77 | removeLabel: PropTypes.node.isRequired, 78 | removeStyle: ViewPropTypes.style, 79 | }; 80 | 81 | RemoveHandle.defaultProps = { 82 | removeStyle: null, 83 | }; 84 | 85 | export default RemoveHandle; 86 | -------------------------------------------------------------------------------- /src/widgets/ArrayWidget/Item.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import RemoveHandle from './RemoveHandle'; 4 | 5 | const useOnRemovePress = ({ index, onRemove }) => () => onRemove(index); 6 | 7 | const Item = ({ 8 | propertyName, 9 | propertyValue, 10 | propertySchema, 11 | propertyMeta, 12 | propertyErrors, 13 | PropertyField, 14 | RemoveComponent, 15 | itemTitle, 16 | ...props 17 | }) => { 18 | const { 19 | removable, 20 | propertyUiSchema, 21 | index, 22 | } = props; 23 | const onRemovePress = useOnRemovePress(props); 24 | 25 | return ( 26 | 27 | 36 | {removable && (RemoveComponent !== RemoveHandle) ? ( 37 | 38 | ) : null} 39 | {removable && RemoveComponent === RemoveHandle ? ( 40 | 41 | ) : null} 42 | 43 | ); 44 | }; 45 | 46 | Item.propTypes = { 47 | value: PropTypes.arrayOf(PropTypes.any).isRequired, 48 | index: PropTypes.number.isRequired, 49 | propertyName: PropTypes.string.isRequired, 50 | propertySchema: PropTypes.shape().isRequired, 51 | propertyUiSchema: PropTypes.shape().isRequired, 52 | propertyMeta: PropTypes.any.isRequired, // eslint-disable-line 53 | orderable: PropTypes.bool.isRequired, 54 | removable: PropTypes.bool.isRequired, 55 | RemoveComponent: PropTypes.elementType.isRequired, 56 | PropertyField: PropTypes.elementType.isRequired, 57 | auto: PropTypes.bool, 58 | itemTitle: PropTypes.string, 59 | propertyValue: PropTypes.any, // eslint-disable-line 60 | propertyErrors: PropTypes.any, // eslint-disable-line 61 | }; 62 | 63 | Item.defaultProps = { 64 | auto: false, 65 | propertyErrors: undefined, 66 | itemTitle: '', 67 | }; 68 | 69 | export default Item; 70 | -------------------------------------------------------------------------------- /.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-hooks/rules-of-hooks": "error", 52 | "react-hooks/exhaustive-deps": "error", 53 | "import-extensions": 0, 54 | "jsx-a11y/anchor-is-valid": 0, 55 | "react/jsx-filename-extension": 0, 56 | "no-multiple-empty-lines": ["error", { "max": 1 }], 57 | "object-curly-newline": ["error"], 58 | "arrow-parens": [2, "as-needed", { "requireForBlockBody": true }], 59 | "react/jsx-props-no-spreading": 0, 60 | "react/jsx-fragments": 0, 61 | "jsx-a11y/control-has-associated-label": 0, 62 | "react/static-property-placement": 0, 63 | "react/state-in-constructor": 0, 64 | "prefer-object-spread": 0, 65 | "no-mixed-operators": ["error", { "allowSamePrecedence": true }], 66 | "react/jsx-curly-brace-presence": 0, 67 | "no-unused-expressions": ["error", {"allowShortCircuit": true}] 68 | } 69 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@velocitycareerlabs/react-native-jsonschema-web-form", 3 | "version": "0.3.96", 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", 8 | "build:windows": "rmdir /s /q dist && babel src -d dist && xcopy package.json dist\\package.json && xcopy README.md dist/README.md", 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": "Michael Avoyan (https://github.com/velocitycareerlabs)", 15 | "license": "MIT", 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/velocitycareerlabs/react-native-jsonschema-form" 19 | }, 20 | "bugs": { 21 | "url": "https://github.com/velocitycareerlabs/react-native-jsonschema-form/issues" 22 | }, 23 | "homepage": "https://github.com/velocitycareerlabs/react-native-jsonschema-form#readme", 24 | "publishConfig": { 25 | "registry": "https://registry.npmjs.org/" 26 | }, 27 | "devDependencies": { 28 | "@babel/cli": "7.28.3", 29 | "@babel/core": "^7.0.0", 30 | "@babel/eslint-parser": "7.28.5", 31 | "@babel/plugin-proposal-class-properties": "7.18.6", 32 | "@babel/plugin-transform-react-jsx": "7.27.1", 33 | "@babel/register": "7.28.3", 34 | "@svgr/cli": "^8.1.0", 35 | "babel-plugin-lodash": "3.3.4", 36 | "cloc": "2.11.0", 37 | "eslint": "7.32.0", 38 | "eslint-config-airbnb": "18.2.1", 39 | "eslint-plugin-import": "2.32.0", 40 | "eslint-plugin-json": "2.1.2", 41 | "eslint-plugin-jsx-a11y": "6.10.2", 42 | "eslint-plugin-react": "7.37.5", 43 | "eslint-plugin-react-hooks": "4.6.2" 44 | }, 45 | "dependencies": { 46 | "@react-native-picker/picker": "^2.11.4", 47 | "deprecated-react-native-prop-types": "^5.0.0", 48 | "lodash": "^4.17.11", 49 | "moment": "^2.29.4", 50 | "prop-types": "^15.6.2", 51 | "react-native-image-picker": "3.8.1", 52 | "react-native-modal": "^11.5.6", 53 | "react-native-modal-dropdown": "git+https://github.com/siemiatj/react-native-modal-dropdown.git#9ebaf21a51405dd240534703df8df3739f51237a", 54 | "react-native-raw-bottom-sheet": "^2.2.0", 55 | "underscore.string": "^3.3.6" 56 | }, 57 | "browserslist": [ 58 | ">0.2%", 59 | "not dead", 60 | "not ie < 11", 61 | "not op_mini all" 62 | ], 63 | "keywords": [ 64 | "react", 65 | "react native", 66 | "react native web", 67 | "react-component", 68 | "ui components", 69 | "json schema", 70 | "react-form" 71 | ] 72 | } 73 | -------------------------------------------------------------------------------- /src/widgets/common/Column.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { StyleSheet, View } from 'react-native'; 4 | import { ViewPropTypes } from 'deprecated-react-native-prop-types'; 5 | import { useScreen } from './Screen'; 6 | import { withTheme } from '../../Theme'; 7 | 8 | const pick = (...args) => { 9 | for (let i = 0; i < args.length; i += 1) { 10 | if (args[i] !== undefined && args[i] !== null) { 11 | return args[i]; 12 | } 13 | } 14 | return args[args.length - 1]; 15 | }; 16 | 17 | const styles = StyleSheet.create({ 18 | empty: {}, 19 | defaults: { 20 | flexDirection: 'column', 21 | alignItems: 'flex-start', 22 | justifyContent: 'flex-start', 23 | }, 24 | width1: { width: '8.33333333%' }, 25 | width2: { width: '16.66666667%' }, 26 | width3: { width: '25%' }, 27 | width4: { width: '33.33333333%' }, 28 | width5: { width: '41.66666667%' }, 29 | width6: { width: '50%' }, 30 | width7: { width: '58.33333333%' }, 31 | width8: { width: '66.66666667%' }, 32 | width9: { width: '75%' }, 33 | width10: { width: '83.33333333%' }, 34 | width11: { width: '91.66666667%' }, 35 | width12: { width: '100%' }, 36 | }); 37 | 38 | const Column = ({ 39 | lg, 40 | md, 41 | sm, 42 | xs, 43 | style, 44 | absolute, 45 | children, 46 | }) => { 47 | const screen = useScreen(); 48 | 49 | let width; 50 | switch (screen.type) { 51 | case 'lg': width = pick(lg, md, sm, xs); break; 52 | case 'md': width = pick(md, sm, xs); break; 53 | case 'sm': width = pick(sm, xs); break; 54 | default: width = xs; 55 | } 56 | if (width === 0) { 57 | return null; 58 | } 59 | let columnStyle; 60 | if (width === null) { 61 | if (style === styles.empty) { 62 | columnStyle = styles.defaults; 63 | } else { 64 | columnStyle = [styles.defaults, style]; 65 | } 66 | } else if (!absolute) { 67 | columnStyle = [styles.defaults, style, styles[`width${width}`]]; 68 | } else { 69 | columnStyle = [styles.defaults, style, { width }]; 70 | } 71 | return ( 72 | 73 | {children} 74 | 75 | ); 76 | }; 77 | 78 | Column.propTypes = { 79 | children: PropTypes.node, 80 | style: ViewPropTypes.style, 81 | xs: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), 82 | sm: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), 83 | md: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), 84 | lg: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), 85 | absolute: PropTypes.bool, 86 | }; 87 | 88 | Column.defaultProps = { 89 | children: null, 90 | style: styles.empty, 91 | xs: null, 92 | sm: null, 93 | md: null, 94 | lg: null, 95 | absolute: false, 96 | }; 97 | 98 | export default withTheme('Column')(Column); 99 | -------------------------------------------------------------------------------- /src/widgets/LabelWidget.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { StyleSheet, View, Text } from 'react-native'; 4 | import { ViewPropTypes } from 'deprecated-react-native-prop-types'; 5 | import { pick, omit } from 'lodash'; 6 | import { useTheme } from '../Theme'; 7 | import { viewStyleKeys } from '../utils'; 8 | 9 | const styles = StyleSheet.create({ 10 | error: { 11 | color: '#EE2D68', 12 | }, 13 | container: { 14 | flexDirection: 'row', 15 | alignItems: 'center', 16 | maxWidth: '100%', 17 | }, 18 | labelContainer: { 19 | paddingTop: 10, 20 | paddingBottom: 5, 21 | }, 22 | labelText: { 23 | fontWeight: 'bold', 24 | }, 25 | checkbox: { 26 | height: 20, 27 | marginRight: 5, 28 | }, 29 | checkboxIcon: { 30 | fontSize: 20, 31 | height: 20, 32 | lineHeight: 20, 33 | }, 34 | fullWidth: { 35 | width: '100%', 36 | }, 37 | }); 38 | 39 | const LabelWidget = (preProps) => { 40 | const props = useTheme('LabelWidget', preProps); 41 | 42 | const { 43 | onPress, 44 | children, 45 | theme, 46 | themeTextStyle, 47 | style, 48 | hasError, 49 | label, 50 | auto, 51 | hasTitle, 52 | } = props; 53 | const currentContainerStyle = [ 54 | styles.container, 55 | auto ? null : styles.fullWidth, 56 | ]; 57 | const currentTextStyle = []; 58 | if (label) { 59 | currentContainerStyle.push(styles.labelContainer); 60 | currentTextStyle.push(styles.labelText); 61 | } 62 | if (hasError) { 63 | currentTextStyle.push({ color: StyleSheet.flatten(theme.input.error.border).borderColor }); 64 | } else { 65 | currentTextStyle.push(themeTextStyle.text); 66 | } 67 | const css = StyleSheet.flatten(style || {}); 68 | 69 | return ( 70 | 71 | {hasTitle ? ( 72 | 73 | {children} 74 | 75 | ) : null} 76 | 77 | ); 78 | }; 79 | 80 | LabelWidget.propTypes = { 81 | theme: PropTypes.shape().isRequired, 82 | themeTextStyle: PropTypes.shape().isRequired, 83 | hasError: PropTypes.bool.isRequired, 84 | hasTitle: PropTypes.bool.isRequired, 85 | children: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), 86 | style: ViewPropTypes.style, 87 | label: PropTypes.bool, 88 | auto: PropTypes.bool, 89 | meta: PropTypes.any, // eslint-disable-line 90 | onPress: PropTypes.func, 91 | }; 92 | 93 | LabelWidget.defaultProps = { 94 | style: null, 95 | label: false, 96 | auto: false, 97 | children: null, 98 | onPress: undefined, 99 | }; 100 | 101 | export default LabelWidget; 102 | -------------------------------------------------------------------------------- /src/widgets/TextInput.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { noop, pick } from 'lodash'; 4 | import { 5 | TextInput as RNTextInput, StyleSheet, Platform, LayoutAnimation, 6 | } from 'react-native'; 7 | import { ViewPropTypes } from 'deprecated-react-native-prop-types'; 8 | import { useTheme } from '../Theme'; 9 | 10 | const styles = StyleSheet.create({ 11 | empty: {}, 12 | defaults: { 13 | paddingTop: 5, 14 | paddingBottom: 8, 15 | paddingLeft: 12, 16 | paddingRight: 12, 17 | minHeight: 40, 18 | textAlignVertical: 'center', 19 | }, 20 | }); 21 | 22 | const allowedAttributes = [ 23 | 'allowFontScaling', 24 | 'autoCapitalize', 25 | 'autoCompleteType', 26 | 'autoCorrect', 27 | 'autoFocus', 28 | 'blurOnSubmit', 29 | 'caretHidden', 30 | 'clearButtonMode', 31 | 'clearTextOnFocus', 32 | 'contextMenuHidden', 33 | 'dataDetectorTypes', 34 | 'defaultValue', 35 | 'disableFullscreenUI', 36 | 'editable', 37 | 'enablesReturnKeyAutomatically', 38 | 'importantForAutofill', 39 | 'inlineImageLeft', 40 | 'inlineImagePadding', 41 | 'inputAccessoryViewID', 42 | 'keyboardAppearance', 43 | 'keyboardType', 44 | 'maxFontSizeMultiplier', 45 | 'maxLength', 46 | 'multiline', 47 | 'numberOfLines', 48 | 'onBlur', 49 | 'onChange', 50 | 'onChangeText', 51 | 'onContentSizeChange', 52 | 'onEndEditing', 53 | 'onFocus', 54 | 'onKeyPress', 55 | 'onLayout', 56 | 'onScroll', 57 | 'onSelectionChange', 58 | 'onSubmitEditing', 59 | 'placeholder', 60 | 'placeholderTextColor', 61 | 'returnKeyLabel', 62 | 'returnKeyType', 63 | 'rejectResponderTermination', 64 | 'scrollEnabled', 65 | 'secureTextEntry', 66 | 'selection', 67 | 'selectionColor', 68 | 'selectionState', 69 | 'selectTextOnFocus', 70 | 'showSoftInputOnFocus', 71 | 'spellCheck', 72 | 'textContentType', 73 | 'style', 74 | 'textBreakStrategy', 75 | 'underlineColorAndroid', 76 | 'value', 77 | 'pointerEvents', 78 | ]; 79 | 80 | const androidProps = {}; 81 | if (Platform.OS === 'android') { 82 | androidProps.textAlignVertical = 'top'; 83 | } 84 | 85 | const TextInput = (props) => { 86 | const { 87 | // Make sure we don't send hasError to RNTextInput 88 | // since it's not a valid prop for . 89 | hasError, 90 | style, 91 | multiline, 92 | numberOfLines, 93 | disabled, 94 | readonly, 95 | editable, 96 | className, 97 | theme, 98 | themeInputStyle, 99 | onRef, 100 | scroller, 101 | ...params 102 | } = useTheme('TextInput', props); 103 | 104 | const wrappedOnFocus = (...args) => { 105 | LayoutAnimation.configureNext({ 106 | duration: 250, 107 | create: { 108 | type: LayoutAnimation.Types.linear, 109 | property: LayoutAnimation.Properties.opacity, 110 | }, 111 | }); 112 | 113 | if (multiline && scroller) { 114 | scroller.setNativeProps({ scrollEnabled: false }); 115 | } 116 | if (params.onFocus) { 117 | return params.onFocus(...args); 118 | } 119 | return null; 120 | }; 121 | 122 | const wrappedOnBlur = (...args) => { 123 | if (multiline && scroller) { 124 | scroller.setNativeProps({ scrollEnabled: true }); 125 | } 126 | if (params.onBlur) { 127 | return params.onBlur(...args); 128 | } 129 | return null; 130 | }; 131 | 132 | return ( 133 | { 153 | event.preventDefault(); 154 | }} 155 | /> 156 | ); 157 | }; 158 | 159 | TextInput.propTypes = { 160 | style: ViewPropTypes.style, 161 | multiline: PropTypes.bool, 162 | numberOfLines: PropTypes.number, 163 | readonly: PropTypes.bool, 164 | disabled: PropTypes.bool, 165 | hasError: PropTypes.bool, 166 | className: PropTypes.string, 167 | onRef: PropTypes.func, 168 | editable: PropTypes.bool, 169 | }; 170 | 171 | TextInput.defaultProps = { 172 | style: styles.empty, 173 | multiline: false, 174 | numberOfLines: 1, 175 | readonly: false, 176 | disabled: false, 177 | hasError: false, 178 | className: '', 179 | onRef: noop, 180 | editable: true, 181 | }; 182 | 183 | export default TextInput; 184 | -------------------------------------------------------------------------------- /src/widgets/common/Screen.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { Dimensions, Keyboard } from 'react-native'; 3 | 4 | /* eslint react/no-multi-comp: 0 */ 5 | /* eslint max-classes-per-file: 0 */ 6 | 7 | function calculateType(width) { 8 | if (width >= 1200) { 9 | return 'lg'; 10 | } 11 | if (width >= 992) { 12 | return 'md'; 13 | } 14 | if (width >= 768) { 15 | return 'sm'; 16 | } 17 | return 'xs'; 18 | } 19 | 20 | let keyboardHeight = 0; 21 | 22 | let screen = Dimensions.get('window'); 23 | if (screen.width > screen.height) { 24 | const aux = screen.height; 25 | screen.height = screen.width; 26 | screen.width = aux; 27 | } 28 | screen.type = calculateType(screen.width); 29 | screen.reduced = screen.type === 'xs' || screen.type === 'sm'; 30 | 31 | function addEventListener(eventName, handler) { 32 | switch (eventName) { 33 | case 'keyboardShow': 34 | Keyboard.addListener('keyboardDidShow', handler); 35 | break; 36 | case 'keyboardHide': 37 | Keyboard.addListener('keyboardDidHide', handler); 38 | break; 39 | case 'resize': 40 | Dimensions.addEventListener('change', handler); 41 | break; 42 | default: throw new Error(`Event '${eventName}' has not been implemented`); 43 | } 44 | } 45 | 46 | addEventListener('keyboardHide', () => { 47 | keyboardHeight = 0; 48 | }); 49 | 50 | addEventListener('keyboardShow', (e) => { 51 | keyboardHeight = e.endCoordinates.height; 52 | }); 53 | 54 | addEventListener('resize', () => { 55 | screen = { ...Dimensions.get('window') }; 56 | screen.type = calculateType(screen.width); 57 | screen.reduced = screen.type === 'xs' || screen.type === 'sm'; 58 | }); 59 | 60 | function getHeight() { 61 | return screen.height; 62 | } 63 | 64 | function getWidth() { 65 | return screen.width; 66 | } 67 | 68 | function getType() { 69 | return screen.type; 70 | } 71 | 72 | function getScrollElement() { 73 | return { 74 | scrollTop: () => 0, 75 | scrollTo: () => {}, 76 | addEventListener: () => {}, 77 | removeEventListener: () => {}, 78 | }; 79 | } 80 | 81 | export default { 82 | getHeight, 83 | getWidth, 84 | getType, 85 | getScrollElement, 86 | addEventListener, 87 | }; 88 | 89 | export const ScreenContext = React.createContext(screen); 90 | 91 | export const KeyboardContext = React.createContext(0); 92 | 93 | export const useScreen = () => useContext(ScreenContext); 94 | 95 | export const withScreen = () => Component => props => ( 96 | 97 | {value => } 98 | 99 | ); 100 | 101 | export const useKeyboard = () => useContext(KeyboardContext); 102 | 103 | export const withKeyboard = () => Component => props => ( 104 | 105 | {value => } 106 | 107 | ); 108 | 109 | export const calculateScreen = () => Component => class extends React.PureComponent { 110 | constructor(props) { 111 | super(props); 112 | this.mounted = false; 113 | this.onMountHandlers = []; 114 | this.state = { screen }; 115 | this.tryToUpdate = this.update.bind(this); 116 | addEventListener('resize', this.tryToUpdate); 117 | } 118 | 119 | componentDidMount() { 120 | this.mounted = true; 121 | this.onMount(); 122 | } 123 | 124 | componentWillUnmount() { 125 | this.mounted = false; 126 | } 127 | 128 | onMount(handler) { 129 | if (handler) { 130 | this.onMountHandlers.push(handler); 131 | } 132 | if (this.mounted) { 133 | const fn = this.onMountHandlers.shift(); 134 | if (fn) { 135 | fn(); 136 | } 137 | } 138 | } 139 | 140 | update() { 141 | const self = this; 142 | self.onMount(() => self.setState({ screen })); 143 | } 144 | 145 | render() { 146 | const { screen } = this.state; // eslint-disable-line 147 | return ; 148 | } 149 | }; 150 | 151 | export const calculateKeyboard = () => Component => class extends React.PureComponent { 152 | constructor(props) { 153 | super(props); 154 | this.mounted = false; 155 | this.onMountHandlers = []; 156 | this.state = { keyboard: keyboardHeight }; 157 | this.tryToUpdate = this.update.bind(this); 158 | addEventListener('keyboardHide', this.tryToUpdate); 159 | addEventListener('keyboardShow', this.tryToUpdate); 160 | } 161 | 162 | componentDidMount() { 163 | this.mounted = true; 164 | this.onMount(); 165 | } 166 | 167 | componentWillUnmount() { 168 | this.mounted = false; 169 | } 170 | 171 | onMount(handler) { 172 | if (handler) { 173 | this.onMountHandlers.push(handler); 174 | } 175 | if (this.mounted) { 176 | const fn = this.onMountHandlers.shift(); 177 | if (fn) { 178 | fn(); 179 | } 180 | } 181 | } 182 | 183 | update() { 184 | const self = this; 185 | self.onMount(() => self.setState({ keyboard: keyboardHeight })); 186 | } 187 | 188 | render() { 189 | const { keyboard } = this.state; // eslint-disable-line 190 | return ; 191 | } 192 | }; 193 | -------------------------------------------------------------------------------- /src/widgets/SelectWidget.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import ModalDropdown from 'react-native-modal-dropdown'; 4 | import { 5 | StyleSheet, Platform, Text, TouchableOpacity, View, 6 | } from 'react-native'; 7 | import { ViewPropTypes } from 'deprecated-react-native-prop-types'; 8 | import Picker from '@react-native-picker/picker'; 9 | import { 10 | isArray, isNaN, noop, without, 11 | } from 'lodash'; 12 | import { Icon } from 'react-native-elements'; 13 | import { useOnChange } from '../utils'; 14 | 15 | const styles = StyleSheet.create({ 16 | container: { 17 | width: '100%', 18 | height: 40, 19 | flexDirection: 'row', 20 | justifyContent: 'space-between', 21 | alignItems: 'center', 22 | borderBottomWidth: 0.5, 23 | marginBottom: 10, 24 | }, 25 | pickerContainer: { 26 | width: '100%', 27 | marginBottom: 10, 28 | }, 29 | picker: { 30 | width: '100%', 31 | height: 40, 32 | flexDirection: 'row', 33 | justifyContent: 'space-between', 34 | alignItems: 'center', 35 | borderBottomWidth: 1, 36 | paddingLeft: 0, 37 | marginLeft: 0, 38 | }, 39 | item: { 40 | fontSize: 15, 41 | lineHeight: 20, 42 | paddingLeft: 10, 43 | paddingVertical: 15, 44 | }, 45 | dropdown: { 46 | width: '80%', 47 | elevation: 5, 48 | backgroundColor: '#FFFFFF', 49 | }, 50 | icon: { 51 | fontSize: 12, 52 | fontWeight: '400', 53 | color: '#697079', 54 | }, 55 | }); 56 | 57 | const parser = ({ schema }) => (value) => { 58 | let parsedValue = value; 59 | if (schema.type === 'number' || schema.type === 'integer') { 60 | parsedValue = parseFloat(value); 61 | if (isNaN(parsedValue)) { 62 | parsedValue = null; 63 | } 64 | } else if (schema.type === 'boolean') { 65 | parsedValue = value; 66 | } 67 | return parsedValue; 68 | }; 69 | 70 | const SelectWidget = (props) => { 71 | const { 72 | schema, 73 | uiSchema, 74 | value, 75 | theme, 76 | hasError, 77 | style, 78 | placeholder, 79 | onFocus, 80 | onBlur, 81 | } = props; 82 | 83 | useEffect(() => () => Picker.hide(), []); 84 | 85 | const onChange = useOnChange({ ...props, parser }); 86 | 87 | let values = uiSchema['ui:enum'] || schema.enum || []; 88 | if (isArray(uiSchema['ui:enumExcludes'])) { 89 | values = without(values, uiSchema['ui:enumExcludes']); 90 | } 91 | const labels = uiSchema['ui:enumNames'] || schema.enumNames || values; 92 | 93 | const onPickerConfirm = (val) => { 94 | onChange(val[0]); 95 | onBlur && onBlur(); 96 | }; 97 | 98 | const onPickerSelect = (val) => { 99 | onChange(val[0]); 100 | }; 101 | 102 | const onTogglePicker = () => { 103 | Picker.isPickerShow((status) => { 104 | if (status) { 105 | Picker.hide(); 106 | } else { 107 | Picker.init({ 108 | pickerData: labels, 109 | selectedValue: [value || labels[0]], 110 | pickerCancelBtnText: '', 111 | pickerConfirmBtnText: 'Done', 112 | pickerConfirmBtnColor: [0, 122, 255, 1], 113 | pickerTitleText: '', 114 | pickerRowHeight: 40, 115 | onPickerConfirm, 116 | onPickerSelect, 117 | }); 118 | 119 | Picker.show(); 120 | onFocus && onFocus(); 121 | onChange(value || labels[0]); 122 | } 123 | }); 124 | }; 125 | 126 | const onSelect = (index) => { 127 | onChange(labels[index]); 128 | }; 129 | 130 | const placeholderStyle = theme.input[hasError ? 'error' : 'regular'].placeholder; 131 | 132 | const dropdownHeight = labels.length * 50; 133 | 134 | return Platform.OS === 'ios' 135 | ? ( 136 | 146 | {placeholder 147 | ? ( 148 | 149 | {placeholder} 150 | 151 | ) 152 | : ( 153 | 154 | {value} 155 | 156 | )} 157 | 163 | 164 | ) 165 | : ( 166 | 200 ? 200 : dropdownHeight }]} 171 | options={labels} 172 | dropdownListProps={{}} 173 | onSelect={onSelect} 174 | renderSeparator={() => } 175 | > 176 | 179 | {placeholder 180 | ? ( 181 | 182 | {placeholder} 183 | 184 | ) 185 | : ( 186 | 187 | {value} 188 | 189 | )} 190 | 196 | 197 | 198 | ); 199 | }; 200 | 201 | SelectWidget.propTypes = { 202 | theme: PropTypes.shape().isRequired, 203 | schema: PropTypes.shape().isRequired, 204 | uiSchema: PropTypes.shape().isRequired, 205 | hasError: PropTypes.bool.isRequired, 206 | value: PropTypes.any, // eslint-disable-line 207 | style: ViewPropTypes.style, 208 | placeholder: PropTypes.string, 209 | onBlur: PropTypes.func, 210 | onFocus: PropTypes.func, 211 | }; 212 | 213 | SelectWidget.defaultProps = { 214 | value: '', 215 | placeholder: '', 216 | style: {}, 217 | onBlur: noop, 218 | onFocus: noop, 219 | }; 220 | 221 | export default SelectWidget; 222 | -------------------------------------------------------------------------------- /src/widgets/NumberWidget.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { repeat, isNaN } from 'lodash'; 4 | import TextInputWidget from './TextInputWidget'; 5 | 6 | /* eslint no-param-reassign: 0 */ 7 | const INITIAL_ZEROS = /^0*/g; 8 | const NUMBER_ONLY = /[^0-9]/g; 9 | const THOUSANDS = /\B(?=(\d{3})+(?!\d))/g; 10 | 11 | function setSymbol(value, settings) { 12 | let operator = ''; 13 | if (value.indexOf('-') > -1) { 14 | value = value.replace('-', ''); 15 | operator = '-'; 16 | } 17 | if (value.indexOf(settings.prefix) > -1) { 18 | value = value.replace(settings.prefix, ''); 19 | } 20 | if (value.indexOf(settings.suffix) > -1) { 21 | value = value.replace(settings.suffix, ''); 22 | } 23 | return operator + settings.prefix + value + settings.suffix; 24 | } 25 | 26 | function buildIntegerPart(integerPart, negative, settings) { 27 | // remove initial zeros 28 | integerPart = integerPart.replace(INITIAL_ZEROS, ''); 29 | 30 | // put settings.thousands every 3 chars 31 | integerPart = integerPart.replace(THOUSANDS, settings.thousands); 32 | if (integerPart === '') { 33 | integerPart = '0'; 34 | } 35 | return negative + integerPart; 36 | } 37 | 38 | function maskValueStandard(value, settings) { 39 | const negative = (value.indexOf('-') > -1 && settings.allowNegative) ? '-' : ''; 40 | let onlyNumbers = value.replace(NUMBER_ONLY, ''); 41 | let integerPart = onlyNumbers.slice(0, onlyNumbers.length - settings.precision); 42 | let newValue; 43 | let decimalPart; 44 | let leadingZeros; 45 | 46 | newValue = buildIntegerPart(integerPart, negative, settings); 47 | 48 | if (settings.precision > 0) { 49 | if (!Number.isNaN(value) && value.indexOf('.')) { 50 | const precision = value.substr(value.indexOf('.') + 1); 51 | onlyNumbers += new Array(Math.max(0, (settings.precision + 1) - precision.length)).join(0); 52 | integerPart = onlyNumbers.slice(0, onlyNumbers.length - settings.precision); 53 | newValue = buildIntegerPart(integerPart, negative, settings); 54 | } 55 | decimalPart = onlyNumbers.slice(onlyNumbers.length - settings.precision); 56 | leadingZeros = new Array((settings.precision + 1) - decimalPart.length).join(0); 57 | newValue += settings.decimal + leadingZeros + decimalPart; 58 | } 59 | return setSymbol(newValue, settings); 60 | } 61 | 62 | function maskValueReverse(value, settings) { 63 | const negative = (value.indexOf('-') > -1 && settings.allowNegative) ? '-' : ''; 64 | const valueWithoutSymbol = value.replace(settings.prefix, '').replace(settings.suffix, ''); 65 | let integerPart = valueWithoutSymbol.split(settings.decimal)[0]; 66 | let newValue; 67 | let decimalPart = ''; 68 | 69 | if (integerPart === '') { 70 | integerPart = '0'; 71 | } 72 | newValue = buildIntegerPart(integerPart, negative, settings); 73 | 74 | if (settings.precision > 0) { 75 | const arr = valueWithoutSymbol.split(settings.decimal); 76 | if (arr.length > 1) { 77 | [, decimalPart] = arr; 78 | } 79 | newValue += settings.decimal + decimalPart; 80 | const rounded = Number.parseFloat((`${integerPart}.${decimalPart}`)).toFixed(settings.precision); 81 | const roundedDecimalPart = rounded.toString().split(settings.decimal)[1]; 82 | newValue = `${newValue.split(settings.decimal)[0]}.${roundedDecimalPart}`; 83 | } 84 | return setSymbol(newValue, settings); 85 | } 86 | 87 | function maskValue(text, settings) { 88 | if (settings.allowEmpty && text === '') { 89 | return ''; 90 | } 91 | let value = text.replace(new RegExp(settings.thousands, 'g'), ''); 92 | if (settings.precision > 0 && value.indexOf(settings.decimal) >= 0) { 93 | value = value.split(settings.decimal); 94 | if (value[1].length < settings.precision) { 95 | if (parseFloat(value[0]) + parseFloat(value[1]) === 0) { 96 | return ''; 97 | } 98 | value = `${value[0].substring(0, value[0].length - 1)}${settings.decimal}${value[0][value[0].length - 1]}${value[1]}`; 99 | } else { 100 | value = `${value[0]}${settings.decimal}${value[1]}`; 101 | } 102 | } else if (settings.precision > 0) { 103 | if (settings.reverse) { 104 | value = `${text}${settings.decimal}0`; 105 | } else { 106 | value = `0${settings.decimal}${Array(settings.precision).join(0)}${text}`; 107 | } 108 | } 109 | if (settings.reverse) { 110 | return maskValueReverse(value, settings); 111 | } 112 | return maskValueStandard(value, settings); 113 | } 114 | 115 | const useMask = ({ currency, ...settings }) => (value, direction) => { 116 | if (!currency) { 117 | return value; 118 | } 119 | let textValue = value; 120 | if (direction === 'in') { 121 | const { decimal, precision } = settings; 122 | if (precision > 0) { 123 | const parts = textValue.split(decimal); 124 | if (parts.length < 2) { 125 | parts.push('0'); 126 | } 127 | if (parts[1].length < precision) { 128 | parts[1] += repeat('0', precision - parts[1].length); 129 | textValue = parts.join(decimal); 130 | } 131 | } 132 | } 133 | return maskValue(textValue, settings); 134 | }; 135 | 136 | const useTextParser = ({ currency, thousands, decimal }) => (value) => { 137 | if (!currency) { 138 | if (value[value.length - 1] === decimal && value.split(decimal).length === 2) { 139 | return value; 140 | } 141 | const result = parseFloat(value); 142 | return !isNaN(result) ? result : null; 143 | } 144 | const thousandsRegex = new RegExp(`\\${thousands}`, 'g'); 145 | const decimalRegex = new RegExp(`\\${decimal}`); 146 | const result = parseFloat(value.replace(thousandsRegex, '').replace(decimalRegex, '.')); 147 | return !isNaN(result) ? result : null; 148 | }; 149 | 150 | const NumberWidget = (props) => { 151 | const mask = useMask(props); 152 | const textParser = useTextParser(props); 153 | 154 | return ( 155 | 161 | ); 162 | }; 163 | 164 | NumberWidget.propTypes = { 165 | currency: PropTypes.bool, 166 | prefix: PropTypes.string, 167 | suffix: PropTypes.string, 168 | affixesStay: PropTypes.bool, 169 | thousands: PropTypes.string, 170 | decimal: PropTypes.string, 171 | precision: PropTypes.number, 172 | allowNegative: PropTypes.bool, 173 | allowEmpty: PropTypes.bool, 174 | reverse: PropTypes.bool, 175 | }; 176 | 177 | NumberWidget.defaultProps = { 178 | currency: false, 179 | prefix: '', 180 | suffix: '', 181 | affixesStay: true, 182 | thousands: ',', 183 | decimal: '.', 184 | precision: 2, 185 | allowNegative: false, 186 | allowEmpty: false, 187 | reverse: false, 188 | }; 189 | 190 | export default NumberWidget; 191 | -------------------------------------------------------------------------------- /src/widgets/ObjectWidget/createGrid.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { StyleSheet, View } from 'react-native'; 3 | import { 4 | omit, isString, isArray, keys, 5 | } from 'lodash'; 6 | import Row from '../common/Row'; 7 | import Column from '../common/Column'; 8 | import { getComponent, withPrefix } from '../../utils'; 9 | 10 | /* eslint react/prop-types: 0 */ 11 | /* eslint no-use-before-define: 0 */ 12 | 13 | const styles = StyleSheet.create({ 14 | labelTop: { 15 | fontWeight: 'bold', 16 | paddingBottom: 5, 17 | }, 18 | label: { 19 | fontWeight: 'bold', 20 | paddingTop: 10, 21 | paddingBottom: 5, 22 | }, 23 | grid: { 24 | marginLeft: -10, 25 | alignItems: 'flex-start', 26 | }, 27 | item: { 28 | paddingLeft: 10, 29 | }, 30 | containerRow: { 31 | flexDirection: 'row', 32 | flexWrap: 'wrap', 33 | }, 34 | row: { 35 | flexBasis: '100%', 36 | paddingHorizontal: 22, 37 | }, 38 | withoutPadding: { 39 | paddingHorizontal: 0, 40 | }, 41 | halfRow: { 42 | flexBasis: '50%', 43 | paddingHorizontal: 0, 44 | }, 45 | }); 46 | 47 | const attributes = ['type', 'children', 'style', 'columns']; 48 | 49 | const getMeta = (schema) => { 50 | if (schema.type === 'array') { 51 | return []; 52 | } 53 | return {}; 54 | }; 55 | 56 | const createProperty = (property, gridItem, index, params) => { 57 | const { 58 | name, 59 | schema, 60 | fields, 61 | uiSchema, 62 | withoutHorizontalPadding, 63 | } = params; 64 | const uiProperty = uiSchema[property]; 65 | const propertySchema = schema.properties[property]; 66 | const propertyName = withPrefix(property, name); 67 | 68 | if (!propertySchema) { 69 | const UnexistentProperty = () => null; 70 | UnexistentProperty.key = propertyName; 71 | return UnexistentProperty; 72 | } 73 | 74 | const PropertyComponent = getComponent(propertySchema.type || 'string', 'Field', fields); 75 | 76 | if (!PropertyComponent) { 77 | const UnexistentPropertyComponent = () => null; 78 | UnexistentPropertyComponent.key = propertyName; 79 | return UnexistentPropertyComponent; 80 | } 81 | 82 | let PropertyContainer; 83 | let propertyContainerProps; 84 | if (gridItem.type === 'grid') { 85 | const columns = gridItem.columns || []; 86 | const column = (isArray(columns) ? columns[index] : columns) || {}; 87 | PropertyContainer = Row; 88 | propertyContainerProps = { 89 | ...column, 90 | style: [ 91 | styles.item, 92 | { zIndex: gridItem.children.length - index }, 93 | column.style || null, 94 | ], 95 | }; 96 | } else { 97 | PropertyContainer = React.Fragment; 98 | propertyContainerProps = {}; 99 | } 100 | const onFocus = () => { 101 | if (params.onFocus) { 102 | params.onFocus(propertyName); 103 | } 104 | }; 105 | 106 | const withoutPadding = withoutHorizontalPadding || propertySchema.format === 'date-time' 107 | || (propertySchema.type === 'object' && keys(propertySchema.properties).length); 108 | 109 | const Property = ({ 110 | value, 111 | meta, 112 | errors, 113 | uiSchema: propertyUiSchema, 114 | ...props 115 | }) => ( 116 | 122 | 123 | 137 | 138 | 139 | ); 140 | Property.key = propertyName; 141 | 142 | return Property; 143 | }; 144 | 145 | const getLabelComponent = ({ 146 | key, 147 | first, 148 | params, 149 | gridItem, 150 | }) => { 151 | const { widgets } = params; 152 | const Widget = widgets.LabelWidget; 153 | const Label = props => ( 154 | 164 | {gridItem.children} 165 | 166 | ); 167 | Label.key = key; 168 | return Label; 169 | }; 170 | 171 | const getGeneralComponent = ({ 172 | gridItem, 173 | key, 174 | zIndex, 175 | params, 176 | }) => { 177 | let Wrapper; 178 | if (gridItem.type === 'column') { 179 | Wrapper = Column; 180 | } else if (gridItem.type === 'view') { 181 | Wrapper = View; 182 | } else { 183 | Wrapper = Row; 184 | } 185 | 186 | const gridStyle = gridItem.type === 'grid' ? styles.grid : null; 187 | const items = gridItem.children.map((child, i) => { 188 | if (isString(child)) { 189 | return createProperty(child, gridItem, i, params); 190 | } 191 | return createGridItem({ 192 | params, 193 | gridItem: child, 194 | key: `${key}-${i}`, 195 | zIndex: gridItem.children.length - i, 196 | first: i === 0, 197 | last: i === gridItem.children.length - 1, 198 | }); 199 | }); 200 | const GridItem = props => ( 201 | 206 | {items.map(Child => )} 207 | 208 | ); 209 | GridItem.key = key; 210 | return GridItem; 211 | }; 212 | 213 | const createGridItem = (props) => { 214 | const { gridItem } = props; 215 | if (gridItem.type === 'label') { 216 | return getLabelComponent(props); 217 | } 218 | return getGeneralComponent(props); 219 | }; 220 | 221 | const createGrid = (grid, params) => { 222 | const onFocus = (name) => { 223 | params.setField(name); 224 | }; 225 | 226 | const items = grid.map((gridItem, i) => createGridItem({ 227 | params: { ...params, onFocus }, 228 | gridItem, 229 | first: i === 0, 230 | zIndex: grid.length - i, 231 | key: `${params.name}-${i}`, 232 | })); 233 | return props => ( 234 | 235 | {items.map(GridItem => )} 236 | 237 | ); 238 | }; 239 | 240 | export default createGrid; 241 | -------------------------------------------------------------------------------- /src/widgets/DateWidget.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useCallback } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import moment from 'moment'; 4 | import { 5 | StyleSheet, View, 6 | TouchableOpacity, 7 | Text, 8 | Keyboard, 9 | Platform, 10 | LayoutAnimation, 11 | PixelRatio, 12 | } from 'react-native'; 13 | import { ViewPropTypes } from 'deprecated-react-native-prop-types'; 14 | import DateTimePicker from '@react-native-community/datetimepicker'; 15 | import { noop, get } from 'lodash'; 16 | import { 17 | useOnChange, 18 | usePrevious, 19 | } from '../utils'; 20 | 21 | const MIN_DATE = new Date(1900, 0); 22 | const MAX_DATE = new Date(2100, 0); 23 | 24 | const styles = StyleSheet.create({ 25 | container: { 26 | width: '100%', 27 | }, 28 | pickerContainer: { 29 | backgroundColor: '#fff', 30 | marginBottom: 10, 31 | }, 32 | buttonBlock: { 33 | flexDirection: 'row', 34 | justifyContent: 'space-between', 35 | }, 36 | buttonTitle: { 37 | fontSize: 16, 38 | paddingVertical: 8, 39 | }, 40 | rightPicker: { 41 | marginLeft: '-100%', 42 | }, 43 | inputContainer: { 44 | justifyContent: 'center', 45 | width: '100%', 46 | height: 40 * PixelRatio.getFontScale(), 47 | paddingVertical: 8, 48 | }, 49 | leftRow: { 50 | paddingRight: 8, 51 | paddingLeft: 32, 52 | }, 53 | rightRow: { 54 | paddingLeft: 8, 55 | paddingRight: 32, 56 | }, 57 | }); 58 | 59 | const DATE_FORMAT = 'YYYY-MM-DD'; 60 | 61 | const parseDateOnlyStringToLocalDate = (dateOnlyStrings) => { 62 | if (!dateOnlyStrings) { 63 | return null; 64 | } 65 | 66 | const [year, month, day] = dateOnlyStrings.split('-'); 67 | return new Date(+year, +month - 1, +day, 0, 0, 0); 68 | }; 69 | 70 | const DateWidget = (props) => { 71 | const { 72 | uiSchema, 73 | value, 74 | hasError, 75 | theme, 76 | style, 77 | placeholder, 78 | onFocus, 79 | onBlur, 80 | activeField, 81 | name, 82 | inFocus, 83 | rightRow, 84 | leftRow, 85 | } = props; 86 | const [date, setDate] = useState(new Date()); 87 | const [show, setShow] = useState(false); 88 | const onWrappedChange = useOnChange(props); 89 | const prevActiveField = usePrevious(activeField); 90 | const localDateValue = parseDateOnlyStringToLocalDate(value); 91 | 92 | const hidePicker = useCallback(() => { 93 | const currentDate = localDateValue || (date && new Date(date)); 94 | const dateToSave = currentDate && moment(currentDate).format(DATE_FORMAT); 95 | onWrappedChange(dateToSave); 96 | if (onBlur) { 97 | onBlur(); 98 | } 99 | }, [date, localDateValue, onBlur, onWrappedChange]); 100 | 101 | useEffect(() => { 102 | if (activeField !== name && prevActiveField === name) { 103 | setShow(false); 104 | hidePicker(); 105 | } 106 | }, [activeField, prevActiveField, name, hidePicker]); 107 | 108 | const onCancel = () => { 109 | setShow(false); 110 | setDate(''); 111 | onWrappedChange(''); 112 | if (onBlur) { 113 | onBlur(); 114 | } 115 | }; 116 | 117 | const onChange = (event, selectedDate) => { 118 | if (Platform.OS !== 'ios') { 119 | if (selectedDate === undefined) { 120 | onCancel(); 121 | } else { 122 | setShow(false); 123 | const dateToSave = moment(new Date(selectedDate)).format(DATE_FORMAT); 124 | setDate(selectedDate); 125 | onWrappedChange(dateToSave); 126 | if (onBlur) { 127 | onBlur(); 128 | } 129 | } 130 | } else { 131 | const currentDate = selectedDate || date; 132 | const dateToSave = moment(new Date(currentDate)).format(DATE_FORMAT); 133 | setDate(currentDate); 134 | onWrappedChange(dateToSave); 135 | } 136 | }; 137 | 138 | const togglePicker = () => { 139 | setShow(!show); 140 | if (show) { 141 | hidePicker(); 142 | } else { 143 | LayoutAnimation.configureNext({ 144 | duration: 250, 145 | create: { 146 | type: LayoutAnimation.Types.linear, 147 | property: LayoutAnimation.Properties.opacity, 148 | }, 149 | }); 150 | 151 | Keyboard.dismiss(); 152 | setDate(new Date()); 153 | if (onFocus) { 154 | onFocus(); 155 | } 156 | } 157 | }; 158 | 159 | const pickerValue = localDateValue || (date && new Date(date)); 160 | 161 | const formattedValue = pickerValue 162 | ? moment(pickerValue).format(uiSchema['ui:dateFormat'] || 'DD MMM YYYY') 163 | : ''; 164 | 165 | const placeholderStyle = theme.input[hasError ? 'error' : 'regular'].placeholder; 166 | const textStyle = inFocus ? get(theme, 'Datepicker.focused', {}) : {}; 167 | const rightPicker = rightRow ? styles.rightPicker : {}; 168 | 169 | return ( 170 | 171 | 179 | 187 | {placeholder 188 | ? {placeholder} 189 | : {formattedValue}} 190 | 191 | 192 | {show && ( 193 | 200 | {Platform.OS === 'ios' 201 | ? ( 202 | 203 | 204 | 205 | Cancel 206 | 207 | 208 | 209 | 210 | Ok 211 | 212 | 213 | 214 | ) 215 | : null} 216 | 225 | 226 | )} 227 | 228 | ); 229 | }; 230 | 231 | DateWidget.propTypes = { 232 | theme: PropTypes.shape().isRequired, 233 | uiSchema: PropTypes.shape().isRequired, 234 | name: PropTypes.string.isRequired, 235 | value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), 236 | hasError: PropTypes.bool, 237 | style: ViewPropTypes.style, 238 | placeholder: PropTypes.string, 239 | onBlur: PropTypes.func, 240 | onFocus: PropTypes.func, 241 | activeField: PropTypes.string.isRequired, 242 | inFocus: PropTypes.bool.isRequired, 243 | rightRow: PropTypes.bool, 244 | leftRow: PropTypes.bool, 245 | }; 246 | 247 | DateWidget.defaultProps = { 248 | value: '', 249 | placeholder: '', 250 | hasError: false, 251 | rightRow: false, 252 | leftRow: false, 253 | style: {}, 254 | onBlur: noop, 255 | onFocus: noop, 256 | }; 257 | 258 | export default DateWidget; 259 | -------------------------------------------------------------------------------- /src/Theme.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { StyleSheet } from 'react-native'; 3 | import { omit, merge } from 'lodash'; 4 | import PropTypes from 'prop-types'; 5 | 6 | const Theme = React.createContext('theme'); 7 | 8 | const themeProps = [ 9 | 'fontFamily', 10 | 'theme', 11 | 'themeTextStyle', 12 | 'themeInputStyle', 13 | 'themePrimaryStyle', 14 | ]; 15 | 16 | const defaults = { 17 | canonical: uri => uri.replace('/amp'), 18 | resource: uri => uri, 19 | omit: props => omit(props, themeProps), 20 | '*': { 21 | fontFamily: { 22 | regular: 'system font', 23 | bold: 'system font', 24 | }, 25 | }, 26 | Link: { 27 | basepath: 'localhost', 28 | }, 29 | Datepicker: { 30 | selectedDateColor: '#337AB7', 31 | }, 32 | input: { 33 | regular: StyleSheet.create({ 34 | background: { backgroundColor: '#FFFFFF' }, 35 | border: { 36 | borderWidth: 1, 37 | borderStyle: 'solid', 38 | borderColor: '#D3D6D6', 39 | borderRadius: 2, 40 | }, 41 | text: { color: '#545454' }, 42 | placeholder: { color: '#D3D6D6' }, 43 | opacity: { opacity: 1 }, 44 | selected: { color: '#0E73CA' }, 45 | unselected: { color: '#BDC3C7' }, 46 | }), 47 | readonly: StyleSheet.create({ 48 | background: { backgroundColor: '#F4F6F6' }, 49 | border: { 50 | borderWidth: 1, 51 | borderStyle: 'solid', 52 | borderColor: '#D5DBDB', 53 | borderRadius: 2, 54 | }, 55 | text: { color: '#545454' }, 56 | placeholder: { color: '#D3D6D6' }, 57 | opacity: { opacity: 0.7 }, 58 | selected: { color: '#0E73CA' }, 59 | unselected: { color: '#BDC3C7' }, 60 | }), 61 | disabled: StyleSheet.create({ 62 | background: { backgroundColor: '#F4F6F6' }, 63 | border: { 64 | borderWidth: 1, 65 | borderStyle: 'solid', 66 | borderColor: '#D5DBDB', 67 | borderRadius: 2, 68 | }, 69 | text: { color: '#545454' }, 70 | placeholder: { color: '#D3D6D6' }, 71 | opacity: { opacity: 0.7 }, 72 | selected: { color: '#0E73CA' }, 73 | unselected: { color: '#BDC3C7' }, 74 | }), 75 | focused: StyleSheet.create({ 76 | background: { backgroundColor: '#FFFFFF' }, 77 | border: { 78 | borderWidth: 1, 79 | borderStyle: 'solid', 80 | borderColor: '#6CB3FF', 81 | borderRadius: 2, 82 | }, 83 | text: { color: '#545454' }, 84 | placeholder: { color: '#D3D6D6' }, 85 | opacity: { opacity: 1 }, 86 | selected: { color: '#0E73CA' }, 87 | unselected: { color: '#BDC3C7' }, 88 | }), 89 | error: StyleSheet.create({ 90 | background: { backgroundColor: '#FFFFFF' }, 91 | border: { 92 | borderWidth: 1, 93 | borderStyle: 'solid', 94 | borderColor: '#EE2D68', 95 | borderRadius: 2, 96 | }, 97 | text: { color: '#545454' }, 98 | placeholder: { color: '#EE2D68' }, 99 | opacity: { opacity: 1 }, 100 | selected: { color: '#0E73CA' }, 101 | unselected: { color: '#BDC3C7' }, 102 | }), 103 | }, 104 | colors: { 105 | text: 'gray', 106 | primary: 'navy', 107 | pink: StyleSheet.create({ 108 | background: { backgroundColor: '#F15786' }, 109 | border: { borderColor: '#BF4E71' }, 110 | text: { color: '#F15786' }, 111 | }), 112 | yellow: StyleSheet.create({ 113 | background: { backgroundColor: '#FEB715' }, 114 | border: { borderColor: '#D99D14' }, 115 | text: { color: '#FEB715' }, 116 | }), 117 | gray: StyleSheet.create({ 118 | background: { backgroundColor: '#D4D4D4' }, 119 | border: { borderColor: '#BFBFBF' }, 120 | text: { color: '#545454' }, 121 | }), 122 | lightGray: StyleSheet.create({ 123 | background: { backgroundColor: '#BFBFBF' }, 124 | border: { borderColor: '#BFBFBF' }, 125 | text: { color: '#BFBFBF' }, 126 | }), 127 | white: StyleSheet.create({ 128 | background: { backgroundColor: '#FFFFFF' }, 129 | border: { borderColor: '#BFBFBF' }, 130 | text: { color: '#FFFFFF' }, 131 | }), 132 | navy: StyleSheet.create({ 133 | background: { backgroundColor: '#0E73CA' }, 134 | border: { borderColor: '#055396' }, 135 | text: { color: '#0E73CA' }, 136 | }), 137 | teal: StyleSheet.create({ 138 | background: { backgroundColor: '#23AAAA' }, 139 | border: { borderColor: '#23AAAA' }, 140 | text: { color: '#23AAAA' }, 141 | }), 142 | black: StyleSheet.create({ 143 | background: { backgroundColor: '#000000' }, 144 | border: { borderColor: '#000000' }, 145 | text: { color: '#000000' }, 146 | }), 147 | }, 148 | platform: { 149 | web: { 150 | '*': { 151 | fontFamily: { 152 | regular: '"Lucida Sans Unicode","Lucida Grande",Arial,Helvetica,clean,sans-serif', 153 | bold: '"Lucida Grande", "Lucida Sans Unicode","Lucida Grande",Arial,Helvetica,clean,sans-serif', 154 | }, 155 | }, 156 | }, 157 | }, 158 | }; 159 | 160 | const getInputStyle = (theme, { 161 | disabled, 162 | readonly, 163 | hasError, 164 | autoFocus, 165 | }) => { 166 | let label = 'regular'; 167 | if (hasError) { 168 | label = 'error'; 169 | } else if (disabled) { 170 | label = 'disabled'; 171 | } else if (readonly) { 172 | label = 'readonly'; 173 | } else if (autoFocus) { 174 | label = 'focused'; 175 | } 176 | return theme.input[label]; 177 | }; 178 | 179 | export class Provider extends React.Component { 180 | static propTypes = { 181 | value: PropTypes.shape(), 182 | }; 183 | 184 | static defaultProps = { 185 | value: {}, 186 | }; 187 | 188 | static getDerivedStateFromProps(nextProps, prevState) { 189 | if (nextProps.value !== prevState.value) { 190 | return { value: merge({}, defaults, nextProps.value) }; 191 | } 192 | return null; 193 | } 194 | 195 | constructor(props) { 196 | super(props); 197 | const { value } = props; 198 | this.state = { value: merge({}, defaults, value) }; 199 | } 200 | 201 | render() { 202 | const { value } = this.state; 203 | return ; 204 | } 205 | } 206 | 207 | export const { Consumer } = Theme; 208 | 209 | export const useTheme = (type, { style, ...props }) => { 210 | const theme = useContext(Theme); 211 | 212 | return { 213 | ...(theme['*'] || {}), 214 | ...(theme[type] || {}), 215 | ...props, 216 | style: [theme[type] && theme[type].style, style], 217 | theme, 218 | themeTextStyle: theme.colors[theme.colors.text], 219 | themePrimaryStyle: theme.colors[theme.colors.primary], 220 | themeInputStyle: getInputStyle(theme, props), 221 | }; 222 | }; 223 | 224 | export const withTheme = type => Component => ({ style, ...props }) => { // eslint-disable-line 225 | Component.displayName = type; // eslint-disable-line 226 | 227 | return ( 228 | 229 | {(theme) => { 230 | const currentProps = Object.assign( 231 | {}, 232 | theme['*'] || {}, 233 | theme[type] || {}, 234 | ); 235 | 236 | const { Component: Replacement } = currentProps; 237 | const Renderer = Replacement || Component; 238 | 239 | return ( 240 | 249 | ); 250 | }} 251 | 252 | ); 253 | }; 254 | 255 | export default { 256 | withTheme, 257 | Provider, 258 | Consumer, 259 | }; 260 | -------------------------------------------------------------------------------- /src/widgets/TextInputWidget.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { StyleSheet, Platform } from 'react-native'; 4 | import { ViewPropTypes } from 'deprecated-react-native-prop-types'; 5 | import { noop, isString, isFunction } from 'lodash'; 6 | import { isEmpty, formatMask } from '../utils'; 7 | import TextInput from './TextInput'; 8 | 9 | const styles = StyleSheet.create({ 10 | fullWidth: { 11 | width: '100%', 12 | }, 13 | auto: { 14 | marginBottom: 0, 15 | }, 16 | }); 17 | 18 | const getTextValue = ({ mask, value, maskParser }) => { 19 | let textValue = ''; 20 | if (value !== null && value !== undefined) { 21 | if (isString(mask)) { 22 | textValue = formatMask(`${value}`, mask, maskParser); 23 | } else if (isFunction(mask)) { 24 | textValue = mask(`${value}`, 'in'); 25 | } else { 26 | textValue = value; 27 | } 28 | } 29 | return textValue; 30 | }; 31 | 32 | class TextInputWidget extends React.Component { 33 | static propTypes = { 34 | update: PropTypes.oneOfType([PropTypes.shape(), PropTypes.string]).isRequired, 35 | renderId: PropTypes.number.isRequired, 36 | name: PropTypes.string.isRequired, 37 | uiSchema: PropTypes.shape().isRequired, 38 | hasError: PropTypes.bool.isRequired, 39 | style: ViewPropTypes.style, 40 | mask: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), 41 | onBlur: PropTypes.func, 42 | onFocus: PropTypes.func, 43 | onChange: PropTypes.func, 44 | onSubmit: PropTypes.func, 45 | focus: PropTypes.string, 46 | value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), 47 | placeholder: PropTypes.string, 48 | readonly: PropTypes.bool, 49 | disabled: PropTypes.bool, 50 | secureTextEntry: PropTypes.bool, 51 | keyboardType: PropTypes.string, 52 | multiline: PropTypes.bool, 53 | numberOfLines: PropTypes.number, 54 | auto: PropTypes.bool, 55 | textParser: PropTypes.func, 56 | Input: PropTypes.elementType, 57 | inputProps: PropTypes.shape(), 58 | onChangeText: PropTypes.func, 59 | register: PropTypes.func, 60 | changeOnBlur: PropTypes.bool, 61 | maskParser: PropTypes.func, 62 | scroller: PropTypes.shape(), 63 | }; 64 | 65 | static defaultProps = { 66 | style: styles.empty, 67 | mask: null, 68 | onBlur: noop, 69 | onFocus: noop, 70 | onChange: noop, 71 | onSubmit: noop, 72 | focus: null, 73 | value: null, 74 | placeholder: '', 75 | readonly: false, 76 | disabled: false, 77 | secureTextEntry: false, 78 | keyboardType: 'default', 79 | multiline: false, 80 | numberOfLines: 1, 81 | auto: false, 82 | textParser: value => value, 83 | Input: TextInput, 84 | inputProps: {}, 85 | onChangeText: null, 86 | register: noop, 87 | changeOnBlur: true, 88 | maskParser: null, 89 | scroller: null, 90 | }; 91 | 92 | static getDerivedStateFromProps(nextProps, prevState) { 93 | const state = {}; 94 | 95 | const { update, value, renderId } = nextProps; 96 | const { valueProp, renderIdProp } = prevState; 97 | 98 | if (value !== valueProp) { 99 | state.valueProp = value; 100 | } 101 | if (renderId !== renderIdProp) { 102 | state.renderIdProp = renderId; 103 | } 104 | if (update === 'all' && value !== valueProp && renderId !== renderIdProp) { 105 | state.text = getTextValue(nextProps); 106 | } 107 | if (!Object.keys(state).length) { 108 | return null; 109 | } 110 | return state; 111 | } 112 | 113 | constructor(props) { 114 | super(props); 115 | const { value, renderId, uiSchema } = props; 116 | 117 | const text = getTextValue(props); 118 | this.selection = { 119 | start: text.length, 120 | end: text.length, 121 | }; 122 | 123 | this.state = { 124 | text, 125 | valueProp: value, 126 | renderIdProp: renderId, 127 | autoFocus: !!uiSchema['ui:autofocus'], 128 | }; 129 | } 130 | 131 | parse = (text) => { 132 | const { mask, textParser, maskParser } = this.props; 133 | if (!mask) { 134 | return textParser(text); 135 | } 136 | if (isFunction(mask)) { 137 | return textParser(mask(text, 'out')); 138 | } 139 | return textParser(formatMask(text, mask, maskParser)); 140 | }; 141 | 142 | setText = text => this.setState({ text }); 143 | 144 | onRef = (input) => { 145 | this.input = input; 146 | }; 147 | 148 | onChangeText = (nextText) => { 149 | const { onChangeText } = this.props; 150 | const nextValue = this.parse(nextText); 151 | const nextTextValue = getTextValue({ ...this.props, value: nextValue }); 152 | if (onChangeText) { 153 | onChangeText(nextTextValue); 154 | } 155 | this.setText(nextTextValue); 156 | }; 157 | 158 | onBlur = (...args) => { 159 | const { 160 | name, 161 | value, 162 | onBlur, 163 | onChange, 164 | changeOnBlur, 165 | } = this.props; 166 | 167 | this.setState({ autoFocus: false }); 168 | const { text } = this.state; 169 | const nextValue = this.parse(text); 170 | if (onBlur) { 171 | onBlur(...args); 172 | } 173 | if (changeOnBlur && nextValue !== value) { 174 | onChange(nextValue, name); 175 | } 176 | }; 177 | 178 | onFocus = (...args) => { 179 | this.setState({ autoFocus: true }); 180 | const { onFocus } = this.props; 181 | if (onFocus) { 182 | onFocus(...args); 183 | } 184 | }; 185 | 186 | onSubmitEditing = () => { 187 | const { text } = this.state; 188 | const { 189 | name, 190 | value, 191 | onChange, 192 | onSubmit, 193 | changeOnBlur, 194 | } = this.props; 195 | const nextValue = this.parse(text); 196 | if (changeOnBlur && nextValue !== value) { 197 | onChange(nextValue, name); 198 | } 199 | onSubmit(); 200 | }; 201 | 202 | onSelectionChange = (event) => { 203 | this.selection = { 204 | start: event.nativeEvent.selection.start, 205 | end: event.nativeEvent.selection.end, 206 | }; 207 | }; 208 | 209 | render() { 210 | const { 211 | name, 212 | auto, 213 | style, 214 | multiline, 215 | hasError, 216 | disabled, 217 | readonly, 218 | placeholder, 219 | keyboardType, 220 | numberOfLines, 221 | secureTextEntry, 222 | Input, 223 | inputProps, 224 | register, 225 | uiSchema, 226 | mask, 227 | scroller, 228 | } = this.props; 229 | 230 | const { text, autoFocus } = this.state; 231 | 232 | register(this.setText); 233 | 234 | const currentStyle = [ 235 | auto ? styles.auto : styles.fullWidth, 236 | style, 237 | ]; 238 | 239 | const selectionProps = {}; 240 | if (!mask && Platform.OS === 'web') { 241 | selectionProps.selection = this.selection; 242 | selectionProps.onSelectionChange = this.onSelectionChange; 243 | } 244 | 245 | return ( 246 | 272 | ); 273 | } 274 | } 275 | 276 | export default TextInputWidget; 277 | -------------------------------------------------------------------------------- /src/widgets/FileWidget/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import React, { useRef } from 'react'; 3 | import PropTypes from 'prop-types'; 4 | import { 5 | StyleSheet, 6 | Image, 7 | TouchableOpacity, 8 | Text, 9 | View, 10 | Platform, 11 | ActionSheetIOS, 12 | PermissionsAndroid, 13 | } from 'react-native'; 14 | import { ViewPropTypes } from 'deprecated-react-native-prop-types'; 15 | import { launchCamera, launchImageLibrary } from 'react-native-image-picker'; 16 | import RBSheet from 'react-native-raw-bottom-sheet'; 17 | import Plus from './plus.svg'; 18 | import PlusAndroid from './plus-android.svg'; 19 | import PhotoAndroid from './photo-android.svg'; 20 | import GalleryAndroid from './gallery-android.svg'; 21 | 22 | const styles = StyleSheet.create({ 23 | inputTextContainer: { 24 | flexDirection: 'row', 25 | justifyContent: 'flex-start', 26 | alignItems: 'center', 27 | width: '100%', 28 | minHeight: 40, 29 | paddingVertical: 8, 30 | paddingRight: 12, 31 | marginBottom: 10, 32 | }, 33 | withPlaceholder: { 34 | justifyContent: 'space-between', 35 | }, 36 | image: { 37 | width: 88, 38 | height: 88, 39 | }, 40 | container: { 41 | height: 59, 42 | justifyContent: 'center', 43 | }, 44 | containerWithIcon: { 45 | flexDirection: 'row', 46 | justifyContent: 'flex-start', 47 | alignItems: 'center', 48 | }, 49 | text: { 50 | textAlignVertical: 'center', 51 | letterSpacing: 0.2, 52 | fontSize: 14, 53 | marginHorizontal: 16, 54 | }, 55 | icon: { 56 | marginHorizontal: 16, 57 | }, 58 | }); 59 | 60 | const FileWidget = (props) => { 61 | const { 62 | hasError, 63 | theme, 64 | value, 65 | onChange, 66 | name, 67 | style, 68 | placeholder, 69 | } = props; 70 | 71 | const refRBSheet = useRef(); 72 | 73 | const isIOS = Boolean(process.env.STORYBOOK_IS_IOS) || Platform.OS === 'ios'; 74 | 75 | const requestCameraPermission = async () => { 76 | if (!isIOS) { 77 | try { 78 | const granted = await PermissionsAndroid.request( 79 | PermissionsAndroid.PERMISSIONS.CAMERA, 80 | { 81 | title: 'Camera Permission', 82 | message: 'App needs camera permission', 83 | }, 84 | ); 85 | // If CAMERA Permission is granted 86 | return granted === PermissionsAndroid.RESULTS.GRANTED; 87 | } catch (err) { 88 | console.warn(err); 89 | return false; 90 | } 91 | } else return true; 92 | }; 93 | 94 | const requestExternalWritePermission = async () => { 95 | if (!isIOS) { 96 | try { 97 | const granted = await PermissionsAndroid.request( 98 | PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE, 99 | { 100 | title: 'External Storage Write Permission', 101 | message: 'App needs write permission', 102 | }, 103 | ); 104 | // If WRITE_EXTERNAL_STORAGE Permission is granted 105 | return granted === PermissionsAndroid.RESULTS.GRANTED; 106 | } catch (err) { 107 | console.warn(err); 108 | // eslint-disable-next-line no-alert 109 | alert('Write permission err', err); 110 | } 111 | return false; 112 | } return true; 113 | }; 114 | 115 | const onImageReceive = (response) => { 116 | const isAssetsWrapped = response 117 | && Object.prototype.hasOwnProperty.call(response, 'assets') 118 | && Array.isArray(response.assets) 119 | && response.assets[0]; 120 | 121 | const uri = isAssetsWrapped ? response.assets[0].uri : response.uri; 122 | 123 | console.log('Response = ', uri); 124 | 125 | if (response.didCancel) { 126 | console.log('User cancelled image picker'); 127 | return; 128 | } if (response.errorCode === 'camera_unavailable') { 129 | console.log('Camera not available on device'); 130 | return; 131 | } if (response.errorCode === 'permission') { 132 | console.log('Permission not satisfied'); 133 | return; 134 | } if (response.errorCode === 'others') { 135 | console.log(response.errorMessage); 136 | return; 137 | } 138 | onChange(uri, name); 139 | }; 140 | 141 | const onCameraTap = async () => { 142 | const isCameraPermitted = await requestCameraPermission(); 143 | const isStoragePermitted = await requestExternalWritePermission(); 144 | if (isCameraPermitted && isStoragePermitted) { 145 | if (!isIOS) { 146 | refRBSheet.current.close(); 147 | } 148 | 149 | const options = { 150 | mediaType: 'photo', 151 | quality: 1, 152 | saveToPhotos: true, 153 | }; 154 | launchCamera(options, onImageReceive); 155 | } 156 | }; 157 | 158 | const onImageTap = () => { 159 | if (!isIOS) { 160 | refRBSheet.current.close(); 161 | } 162 | 163 | const options = { 164 | mediaType: 'photo', 165 | quality: 1, 166 | selectionLimit: 1, 167 | }; 168 | launchImageLibrary(options, onImageReceive); 169 | }; 170 | 171 | const showPicker = () => { 172 | if (isIOS) { 173 | ActionSheetIOS.showActionSheetWithOptions( 174 | { 175 | options: ['Cancel', 'Take photo', 'Add from gallery'], 176 | cancelButtonIndex: 0, 177 | }, 178 | (buttonIndex) => { 179 | if (buttonIndex === 1) { 180 | onCameraTap(); 181 | } else if (buttonIndex === 2) { 182 | onImageTap(); 183 | } 184 | }, 185 | ); 186 | } else { 187 | refRBSheet.current.open(); 188 | } 189 | }; 190 | const placeholderStyle = theme.input[hasError ? 'error' : 'regular'].placeholder; 191 | 192 | return ( 193 | <> 194 | 205 | {placeholder 206 | ? ( 207 | 208 | {placeholder} 209 | 210 | ) 211 | : null} 212 | {value ? 213 | : ( 214 | 215 | {isIOS ? () 216 | : ()} 217 | 218 | )} 219 | 220 | 226 | 237 | 238 | 239 | 240 | 241 | Take photo 242 | 243 | 244 | 255 | 256 | 257 | 258 | 259 | Add from gallery 260 | 261 | 262 | 263 | 264 | 265 | ); 266 | }; 267 | 268 | FileWidget.propTypes = { 269 | name: PropTypes.string.isRequired, 270 | theme: PropTypes.shape().isRequired, 271 | hasError: PropTypes.bool.isRequired, 272 | style: ViewPropTypes.style, 273 | placeholder: PropTypes.string, 274 | value: PropTypes.string, 275 | onChange: PropTypes.func.isRequired, 276 | }; 277 | 278 | FileWidget.defaultProps = { 279 | placeholder: '', 280 | style: {}, 281 | value: '', 282 | }; 283 | 284 | export default FileWidget; 285 | -------------------------------------------------------------------------------- /src/widgets/ArrayWidget/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { 3 | map, 4 | each, 5 | last, 6 | times, 7 | isArray, 8 | isFunction, 9 | isEmpty, 10 | } from 'lodash'; 11 | import { 12 | Text, View, TouchableOpacity, StyleSheet, Platform, 13 | } from 'react-native'; 14 | import { titleize } from 'underscore.string'; 15 | import { Icon } from 'react-native-elements'; 16 | import { 17 | getTitle, 18 | getComponent, 19 | FIELD_TITLE, 20 | } from '../../utils'; 21 | import AddHandle from './AddHandle'; 22 | import OrderHandle from './OrderHandle'; 23 | import RemoveHandle from './RemoveHandle'; 24 | import Item from './Item'; 25 | 26 | /* eslint react/no-array-index-key: 0 */ 27 | 28 | const getItem = (schema) => { 29 | let newItem = ''; 30 | if (schema.items.type === 'object') { 31 | newItem = {}; 32 | } else if (schema.items.type === 'array') { 33 | newItem = []; 34 | } 35 | return newItem; 36 | }; 37 | 38 | const formatTitle = title => titleize(title).replace(/ies$/, 'y').replace(/s$/, ''); 39 | 40 | const iterateUiSchema = (uiSchema, i) => { 41 | const widgetProps = uiSchema['ui:widgetProps'] || {}; 42 | const titleProps = uiSchema['ui:titleProps'] || {}; 43 | const errorProps = uiSchema['ui:errorProps'] || {}; 44 | return { 45 | ...uiSchema, 46 | 'ui:widgetProps': isArray(widgetProps) ? (widgetProps[i] || {}) : widgetProps, 47 | 'ui:titleProps': isArray(titleProps) ? (titleProps[i] || {}) : titleProps, 48 | 'ui:errorProps': isArray(errorProps) ? (errorProps[i] || {}) : errorProps, 49 | }; 50 | }; 51 | 52 | const adjustUiSchema = (possibleUiSchema, i, props) => { 53 | let uiSchema = possibleUiSchema; 54 | if (isFunction(possibleUiSchema['ui:iterate'])) { 55 | uiSchema = possibleUiSchema['ui:iterate'](i, props); 56 | } 57 | const adjustedUiSchema = iterateUiSchema(uiSchema, i); 58 | each(uiSchema, (uis, key) => { 59 | if (!/^ui:/.test(key)) { 60 | adjustedUiSchema[key] = iterateUiSchema(uis, i); 61 | } 62 | }); 63 | return adjustedUiSchema; 64 | }; 65 | 66 | const getProps = (props) => { 67 | const { 68 | name, 69 | schema, 70 | fields, 71 | uiSchema, 72 | value: originalValue, 73 | } = props; 74 | 75 | const value = isArray(originalValue) ? originalValue : []; 76 | 77 | const title = getTitle(uiSchema['ui:title'] || FIELD_TITLE, { 78 | name, 79 | value, 80 | key: last(name.split('.')), 81 | }); 82 | const itemTitle = getTitle(uiSchema['ui:itemTitle'] || FIELD_TITLE, { 83 | name, 84 | value, 85 | key: last(name.split('.')), 86 | }); 87 | 88 | const propertySchema = schema.items; 89 | const propertyUiSchema = uiSchema.items; 90 | const PropertyField = getComponent(propertySchema.type, 'Field', fields); 91 | const options = uiSchema['ui:options'] || {}; 92 | 93 | const extraProps = { 94 | value, 95 | title, 96 | propertySchema, 97 | propertyUiSchema, 98 | PropertyField, 99 | axis: options.axis || 'y', 100 | minimumNumberOfItems: ( 101 | options.minimumNumberOfItems === undefined 102 | || options.minimumNumberOfItems === null 103 | ) ? 0 : options.minimumNumberOfItems, 104 | addLabel: options.addLabel || `Add ${formatTitle(title)}`, 105 | removeLabel: options.removeLabel || 'Delete', 106 | orderLabel: options.orderLabel, 107 | removeStyle: options.removeStyle, 108 | orderStyle: options.orderStyle, 109 | addable: options.addable !== false, 110 | removable: options.removable !== false, 111 | orderable: options.orderable !== false, 112 | AddComponent: options.AddComponent || AddHandle, 113 | OrderComponent: options.OrderComponent || OrderHandle, 114 | RemoveComponent: options.RemoveComponent || RemoveHandle, 115 | ItemComponent: options.ItemComponent || Item, 116 | itemTitle: formatTitle(itemTitle), 117 | }; 118 | return { ...props, ...extraProps }; 119 | }; 120 | 121 | const useOnAdd = ({ 122 | name, 123 | meta, 124 | value, 125 | schema, 126 | onChange, 127 | minimumNumberOfItems, 128 | }) => () => { 129 | let nextValue; 130 | let nextMeta = meta; 131 | if (value.length < minimumNumberOfItems) { 132 | nextValue = value.concat(times(minimumNumberOfItems - value.length + 1, () => getItem(schema))); 133 | if (meta) { 134 | nextMeta = nextMeta.concat(times(minimumNumberOfItems - value.length + 1, () => ({}))); 135 | } 136 | } else { 137 | nextValue = value.concat([getItem(schema)]); 138 | if (meta) { 139 | nextMeta = nextMeta.concat([{}]); 140 | } 141 | } 142 | onChange(nextValue, name, { 143 | nextMeta: nextMeta || false, 144 | }); 145 | }; 146 | 147 | const useOnRemove = ({ 148 | name, 149 | value, 150 | onChange, 151 | reorder, 152 | errors, 153 | meta, 154 | }) => (index) => { 155 | const nextValue = (isArray(value) ? value : []).filter((v, i) => (i !== index)); 156 | let nextMeta = (isArray(meta) ? meta : []); 157 | if (nextMeta) { 158 | nextMeta = nextMeta.filter((v, i) => (i !== index)); 159 | } 160 | let nextErrors = (isArray(errors) ? errors : []); 161 | if (nextErrors) { 162 | nextErrors = nextErrors.filter((v, i) => (i !== index)); 163 | } 164 | onChange(nextValue, name, { 165 | nextMeta: nextMeta || false, 166 | nextErrors: nextErrors || false, 167 | }); 168 | setTimeout(reorder); 169 | }; 170 | 171 | const useReorder = ({ review, setState }) => () => setState({ 172 | refs: [], 173 | positions: [], 174 | review: review + 1, 175 | dragging: null, 176 | }); 177 | 178 | const onChangeText = (props, val, index) => { 179 | const nextValue = isEmpty(props.value) 180 | ? [val] : map(props.value, (item, key) => (index === key ? val : item)); 181 | props.onChange(nextValue, props.activeField); 182 | }; 183 | 184 | const ArrayWidget = (props) => { 185 | const [state, setState] = useState({ 186 | refs: [], 187 | positions: [], 188 | review: 0, 189 | dragging: null, 190 | }); 191 | 192 | const params = getProps({ ...props, ...state }); 193 | const reorder = useReorder({ ...params, setState }); 194 | 195 | const onAdd = useOnAdd(params); 196 | const onRemove = useOnRemove({ ...params, reorder }); 197 | 198 | const { 199 | meta, 200 | review, 201 | name, 202 | value, 203 | title, 204 | schema, 205 | uiSchema, 206 | errors, 207 | propertyUiSchema, 208 | ItemComponent, 209 | theme, 210 | } = params; 211 | 212 | const hasError = isArray(errors) && errors.length > 0 && !errors.hidden; 213 | const hasTitle = uiSchema['ui:title'] !== false; 214 | const toggleable = !!uiSchema['ui:toggleable']; 215 | 216 | const styles = StyleSheet.create({ 217 | labelContainer: { 218 | width: '100%', 219 | flexDirection: 'row', 220 | justifyContent: 'space-between', 221 | alignItems: 'center', 222 | }, 223 | inputTextContainer: { 224 | flexDirection: 'row', 225 | justifyContent: 'space-between', 226 | alignItems: 'center', 227 | width: '100%', 228 | height: 60, 229 | paddingVertical: 8, 230 | }, 231 | icon: { 232 | height: 20, 233 | width: 20, 234 | borderRadius: 10, 235 | fontWeight: '400', 236 | justifyContent: 'center', 237 | alignItems: 'center', 238 | ...Platform.select({ ios: { backgroundColor: '#000000' }, android: { backgroundColor: '#7489A8' } }), 239 | }, 240 | }); 241 | 242 | const addComponent = ( 243 | 244 | 250 | 251 | ); 252 | 253 | return ( 254 | 255 | {isEmpty(value) || hasTitle || toggleable 256 | ? ( 257 | 263 | {hasTitle 264 | ? ( 265 | 271 | {title} 272 | 273 | ) 274 | : null} 275 | {addComponent} 276 | 277 | ) : null} 278 | {times(value.length, (index) => { 279 | const itemUiSchema = adjustUiSchema(propertyUiSchema, index, params); 280 | return ( 281 | onChangeText(params, val, index)} 292 | withoutHorizontalPadding 293 | /> 294 | ); 295 | })} 296 | 297 | ); 298 | }; 299 | 300 | export default ArrayWidget; 301 | -------------------------------------------------------------------------------- /src/fields/AbstractField.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { StyleSheet, View } from 'react-native'; 3 | import PropTypes from 'prop-types'; 4 | import { 5 | last, isArray, isString, noop, 6 | } from 'lodash'; 7 | import { 8 | getComponent, 9 | getTitle, 10 | FIELD_TITLE, 11 | toPath, 12 | } from '../utils'; 13 | import ArrayWidget from '../widgets/ArrayWidget'; 14 | 15 | const styles = StyleSheet.create({ 16 | field: {}, 17 | fieldInline: { 18 | alignItems: 'center', 19 | paddingBottom: 10, 20 | }, 21 | container: { 22 | width: '100%', 23 | display: 'flex', 24 | flexWrap: 'wrap', 25 | flexDirection: 'row', 26 | alignItems: 'flex-start', 27 | justifyContent: 'flex-start', 28 | }, 29 | containerWithLabel: { 30 | height: 60, 31 | }, 32 | leftRow: { 33 | paddingRight: 8, 34 | paddingLeft: 32, 35 | }, 36 | rightRow: { 37 | paddingLeft: 8, 38 | }, 39 | fullRow: { 40 | paddingHorizontal: 24, 41 | }, 42 | label: { 43 | paddingTop: 3, 44 | }, 45 | }); 46 | 47 | class AbstractField extends React.Component { 48 | static propTypes = { 49 | id: PropTypes.string.isRequired, 50 | name: PropTypes.string.isRequired, 51 | update: PropTypes.oneOfType([ 52 | PropTypes.string, 53 | PropTypes.shape(), 54 | ]).isRequired, 55 | schema: PropTypes.shape().isRequired, 56 | uiSchema: PropTypes.shape().isRequired, 57 | clearCache: PropTypes.bool.isRequired, 58 | widgets: PropTypes.shape().isRequired, 59 | required: PropTypes.shape().isRequired, 60 | noTitle: PropTypes.bool, 61 | titleOnly: PropTypes.bool, 62 | zIndex: PropTypes.number, 63 | meta: PropTypes.any, // eslint-disable-line 64 | errors: PropTypes.any, // eslint-disable-line 65 | value: PropTypes.any, // eslint-disable-line 66 | onFocus: PropTypes.func, 67 | activeField: PropTypes.string.isRequired, 68 | }; 69 | 70 | static defaultProps = { 71 | noTitle: false, 72 | titleOnly: false, 73 | errors: {}, 74 | value: undefined, 75 | zIndex: 0, 76 | onFocus: noop, 77 | }; 78 | 79 | constructor(props) { 80 | super(props); 81 | 82 | this.state = { 83 | inFocus: false, 84 | }; 85 | } 86 | 87 | shouldComponentUpdate(nextProps, nextState) { 88 | const { 89 | clearCache, update, name, activeField, 90 | } = nextProps; 91 | const { inFocus } = nextState; 92 | const { inFocus: currentInFocus } = this.state; 93 | const { activeField: currentActiveField } = this.props; 94 | return ( 95 | name === '' 96 | || clearCache 97 | || update === 'all' 98 | || update[name] 99 | || inFocus !== currentInFocus 100 | || currentActiveField !== activeField 101 | || false 102 | ); 103 | } 104 | 105 | getDefaultWidget() { // eslint-disable-line 106 | throw new Error('Abstract field cannot be used.'); 107 | } 108 | 109 | getPlaceholder(params) { 110 | const { 111 | name, 112 | schema, 113 | uiSchema, 114 | noTitle, 115 | required, 116 | value, 117 | } = this.props; 118 | const { inFocus } = this.state; 119 | if (inFocus || value) { 120 | return ''; 121 | } 122 | const hasTitle = !( 123 | noTitle 124 | || uiSchema['ui:title'] === false 125 | || schema.type === 'object' 126 | || this.cache === ArrayWidget 127 | ); 128 | if (!uiSchema['ui:toggleable'] && !hasTitle) { 129 | return ''; 130 | } 131 | let title = getTitle(uiSchema['ui:title'] || FIELD_TITLE, params); 132 | if (required[toPath(name, '[]')]) { 133 | title += '*'; 134 | } 135 | return title; 136 | } 137 | 138 | onFocus = () => { 139 | const { onFocus } = this.props; 140 | if (onFocus) { 141 | onFocus(); 142 | } 143 | this.setState(() => ({ inFocus: true })); 144 | }; 145 | 146 | onBlur = () => { 147 | this.setState(() => ({ inFocus: false })); 148 | }; 149 | 150 | renderTitle(hasError, params) { 151 | const { 152 | id, 153 | name, 154 | widgets, 155 | schema, 156 | uiSchema, 157 | noTitle, 158 | required, 159 | } = this.props; 160 | const { inFocus } = this.state; 161 | if (!inFocus && !params.value) { 162 | return null; 163 | } 164 | const hasTitle = !( 165 | noTitle 166 | || uiSchema['ui:noLabel'] 167 | || uiSchema['ui:title'] === false 168 | || schema.type === 'object' 169 | || this.cache === ArrayWidget 170 | ); 171 | if (!uiSchema['ui:toggleable'] && !hasTitle) { 172 | return null; 173 | } 174 | const { LabelWidget } = widgets; 175 | let title = getTitle(uiSchema['ui:title'] || FIELD_TITLE, params); 176 | if (required[toPath(name, '[]')]) { 177 | title += '*'; 178 | } 179 | const leftRow = uiSchema['ui:leftRow'] ? styles.leftRow : {}; 180 | const rightRow = uiSchema['ui:rightRow'] ? styles.rightRow : {}; 181 | const fullRow = schema.format === 'date-time' ? styles.fullRow : {}; 182 | 183 | return ( 184 | 196 | {title} 197 | 198 | ); 199 | } 200 | 201 | renderErrors() { 202 | const { 203 | widgets, uiSchema, errors, schema, 204 | } = this.props; 205 | 206 | const { ErrorWidget } = widgets; 207 | const leftRow = uiSchema['ui:leftRow'] ? styles.leftRow : {}; 208 | const rightRow = uiSchema['ui:rightRow'] ? styles.rightRow : {}; 209 | const fullRow = schema.format === 'date-time' ? styles.fullRow : {}; 210 | 211 | return errors.filter(isString).map((error, i) => ( 212 | 221 | {error} 222 | 223 | )); 224 | } 225 | 226 | render() { 227 | const { 228 | name, 229 | meta, 230 | schema, 231 | uiSchema, 232 | widgets, 233 | errors, 234 | value, 235 | titleOnly, 236 | zIndex, 237 | clearCache, 238 | activeField, 239 | } = this.props; 240 | const { inFocus } = this.state; 241 | 242 | if (clearCache) { 243 | this.cache = null; 244 | } 245 | if (!this.cache) { 246 | if (this.getWidget) { 247 | this.cache = this.getWidget(this.props); 248 | } 249 | if (!this.cache) { 250 | this.cache = getComponent(uiSchema['ui:widget'], 'Widget', widgets); 251 | } 252 | if (!this.cache) { 253 | this.cache = this.getDefaultWidget(this.props); 254 | } 255 | } 256 | const Widget = this.cache; 257 | const hasError = ( 258 | schema.type !== 'object' 259 | && (schema.type !== 'array' || Widget.hideable === false) 260 | && isArray(errors) 261 | && errors.length > 0 262 | && (!errors.hidden || Widget.hideable === false) 263 | ); 264 | if (hasError && errors.lastValue === undefined) { 265 | errors.lastValue = value; 266 | } 267 | if (Widget.custom) { 268 | return ; 269 | } 270 | 271 | const containerProps = uiSchema['ui:containerProps'] || {}; 272 | if (uiSchema['ui:widget'] === 'hidden') { 273 | if (!hasError) { 274 | return null; 275 | } 276 | // Show errors for hidden fields 277 | return ( 278 | 279 | {this.renderErrors()} 280 | 281 | ); 282 | } 283 | const key = last(name.split('.')); 284 | const params = { 285 | key, 286 | name, 287 | value, 288 | }; 289 | const placeholder = this.getPlaceholder(params); 290 | return ( 291 | 300 | {this.renderTitle(hasError, params)} 301 | {!titleOnly || schema.type === 'object' || schema.type === 'array' ? ( 302 | 303 | 319 | {hasError ? this.renderErrors() : null} 320 | 321 | ) : null} 322 | 323 | ); 324 | } 325 | } 326 | 327 | export default AbstractField; 328 | -------------------------------------------------------------------------------- /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 | * [Widgets](#widgets) 26 | * [License](#license) 27 | 28 | ## Documentation 29 | 30 | Coming soon! 31 | 32 | ## Setup 33 | 34 | 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. 35 | 36 | ### Requirements 37 | 38 | First you need to install react ^16.8.3 (this library uses react-hooks). 39 | 40 | ```sh 41 | yarn add react 42 | ``` 43 | 44 | 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: 45 | 46 | ```sh 47 | yarn add https://github.com/expo/react-native/archive/sdk-33.0.0.tar.gz 48 | ``` 49 | 50 | 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. 51 | 52 | ```sh 53 | yarn add react-dom react-native-web 54 | ``` 55 | 56 | This library is backed by [react-native-elements](https://reactnativeelements.com/). Please make sure you have installed `react-native-elements` and its dependencies. 57 | 58 | ```sh 59 | yarn add react-native-elements 60 | ``` 61 | 62 | Please make sure you have installed `react-native-community/datetimepicker` and its dependencies. 63 | 64 | ```sh 65 | yarn add @react-native-community/datetimepicker 66 | ``` 67 | 68 | Please make sure you have installed `react-native-image-picker` and its dependencies. 69 | 70 | ```sh 71 | yarn add react-native-image-picker 72 | ``` 73 | 74 | ### Installation 75 | 76 | Install the library using `yarn` or `npm`. 77 | 78 | ```sh 79 | yarn add react-native-web-jsonschema-form 80 | ``` 81 | 82 | ## Examples 83 | 84 | ### Basic Form 85 | 86 | ```javascript 87 | import React from 'react'; 88 | import { useHistory } from 'react-router'; 89 | import { Alert } from 'react-native'; 90 | import { UIProvider, Form } from 'react-native-web-jsonschema-form'; 91 | import { Router, Switch } from 'react-router-dom'; 92 | // import { Router, Switch } from 'react-router-native'; 93 | 94 | const theme = { 95 | input: { 96 | focused: StyleSheet.create({ 97 | border: { 98 | borderColor: 'yellow', 99 | }, 100 | }), 101 | }, 102 | }; 103 | 104 | const schema = { 105 | type: 'object', 106 | properties: { 107 | username: { type: 'string' }, 108 | password: { type: 'string' }, 109 | }, 110 | }; 111 | 112 | const BasicForm = ({ formData, onChange }) => ( 113 |
118 | ); 119 | 120 | const ThemeWrapper = ({ children }) => { 121 | const history = useHistory(); 122 | 123 | return ( 124 | 125 | {children} 126 | 127 | ); 128 | }; 129 | 130 | const App = () => { 131 | const [formData, setFormData] = useState({}); 132 | 133 | const onChange = (event) => setFormData({ 134 | ...formData, 135 | [event.params.name]: event.params.value, 136 | }); 137 | 138 | return ( 139 | 140 | 141 | 142 | 146 | 147 | 148 | 149 | ); 150 | }; 151 | ``` 152 | 153 | ### Event Handlers 154 | 155 | ```javascript 156 | import React from 'React'; 157 | import PropTypes from 'prop-types'; 158 | import { Loading, Alert } from 'react-native-web-ui-components'; 159 | import { Form } from 'react-native-web-jsonschema-form'; 160 | 161 | class MyForm extends React.Component { 162 | static propTypes = { 163 | controller: PropTypes.string.isRequired, 164 | action: PropTypes.string.isRequired, 165 | }; 166 | 167 | constructor(props) { 168 | super(props); 169 | this.state = { 170 | schema: null, 171 | message: null, 172 | posting: null, 173 | }; 174 | } 175 | 176 | onSubmit = async (event) => { 177 | const { action, controller } = this.props; 178 | const { values } = event.params; 179 | this.setState({ posting: true }); 180 | return fetch(`/${controller}/${action}`, { 181 | method: 'POST', 182 | body: JSON.stringify(values), 183 | }); 184 | }; 185 | 186 | onSuccess = async (event) => { 187 | const { response } = event.params; 188 | this.setState({ 189 | posting: false, 190 | message: response.message, 191 | }); 192 | }; 193 | 194 | onError = async (event) => { 195 | // These are errors for fields that are not included in the schema 196 | const { exceptions } = event.params; 197 | const warning = Object.keys(exceptions).map(k => exceptions[k].join('\n')); 198 | this.setState({ 199 | posting: false, 200 | message: warning.length ? warning.join('\n') : null, 201 | }); 202 | }; 203 | 204 | render() { 205 | const { schema, posting, message } = this.state; 206 | if (!schema) { 207 | const self = this; 208 | fetch(`/get-schema/${controller}/${action}`) 209 | .then((schema) => self.setState({ schema })); 210 | 211 | return ; 212 | } 213 | 214 | return ( 215 | 216 | {posting ? : null} 217 | {message ? ( 218 | 219 | Message 220 | 221 | ) : null} 222 | 228 | 229 | ); 230 | } 231 | } 232 | ``` 233 | 234 | ### Custom Theme 235 | 236 | 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. 237 | 238 | ```javascript 239 | const theme = { 240 | input: { 241 | focused: StyleSheet.create({ 242 | border: { 243 | borderColor: 'yellow', 244 | borderWidth: 2, 245 | borderStyle: 'solid', 246 | }, 247 | background: { 248 | backgroundColor: 'white', 249 | }, 250 | text: { 251 | fontSize: 14, 252 | color: '#545454', 253 | }, 254 | placeholder: { 255 | color: '#FAFAFA', 256 | }, 257 | opacity: { 258 | opacity: 1, 259 | }, 260 | selected: { 261 | color: 'blue', 262 | }, 263 | unselected: { 264 | color: '#FAFAFA', 265 | }, 266 | }), 267 | regular: {...}, 268 | disabled: {...}, 269 | readonly: {...}, 270 | error: {...}, 271 | }, 272 | }; 273 | 274 | const ThemeWrapper = ({ children }) => { 275 | const history = useHistory(); 276 | 277 | return ( 278 | 279 | {children} 280 | 281 | ); 282 | }; 283 | ``` 284 | 285 | ### Form Validation 286 | 287 | See https://github.com/CareLuLu/react-native-web-jsonschema-form/issues/139#issuecomment-654377982. 288 | 289 | ### Array Fields 290 | 291 | See https://github.com/CareLuLu/react-native-web-jsonschema-form/issues/113#issuecomment-621375353. 292 | 293 | ### Props 294 | 295 | The `Form` has the following props: 296 | 297 | ```javascript 298 | import React from 'react'; 299 | import Form from 'react-native-web-jsonschema-form'; 300 | 301 | const Example = ({ 302 | // Misc 303 | name, // String to be used as id, if empty a hash will be used instead. 304 | onRef, // Function to be called with the form instance. This is NOT a DOM/Native element. 305 | scroller, // If provided, this will be passed to the widgets to allow disabling ScrollView during a gesture. 306 | wigdets, // Object with a list of custom widgets. 307 | 308 | 309 | // Data 310 | formData, // Initial data to populate the form. If this attribute changes, the form will update the data. 311 | filterEmptyValues, // If true, all empty and non-required fields will be omitted from the submitted values. 312 | 313 | // Schema 314 | schema, // JSON schema 315 | uiSchema, // JSON schema modifying UI defaults for schema 316 | errorSchema, // JSON schema with errors 317 | 318 | // Events 319 | // * All events can be synchronous or asynchronous functions. 320 | // * All events receive one parameter `event` with `name`, `preventDefault()` and `params`. 321 | onFocus, 322 | onChange, 323 | onSubmit, 324 | onCancel, 325 | onSuccess, 326 | onError, 327 | 328 | // Layout 329 | buttonPosition, // left, right, center 330 | submitButton, // If false, it will not be rendered. If it is a string, it will be used as the default button text. 331 | cancelButton, // If false, it will not be rendered. If it is a string, it will be used as the default button text. 332 | SubmitButton, // Component to render the submit button 333 | CancelButton, // Component to render the cancel button 334 | }) => ( 335 | 336 | ) 337 | ``` 338 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import { useState, useRef, useEffect } 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 = uiSchema['ui:oneOf'] || 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 ucfirst = (text) => { 358 | if (!text) { 359 | return ''; 360 | } 361 | return `${text[0].toUpperCase()}${text.substring(1)}`; 362 | }; 363 | 364 | export const getComponent = (name, suffix, library) => library[`${ucfirst(name)}${suffix}`]; 365 | 366 | export const expand = (update) => { 367 | if (update === 'all') { 368 | return update; 369 | } 370 | const parts = {}; 371 | each(update, (name) => { 372 | const keys = name.split('.'); 373 | let prefix = ''; 374 | each(keys, (key) => { 375 | prefix = withPrefix(key, prefix); 376 | parts[prefix] = true; 377 | }); 378 | }); 379 | return parts; 380 | }; 381 | 382 | export const getRequired = (schema, prefix = '') => { 383 | let required = {}; 384 | if (schema.type === 'object') { 385 | each(schema.required || [], (propertyKey) => { 386 | required[withPrefix(propertyKey, prefix)] = true; 387 | }); 388 | each(schema.properties, (propertySchema, propertyKey) => Object.assign( 389 | required, 390 | getRequired(propertySchema, withPrefix(propertyKey, prefix)), 391 | )); 392 | } 393 | if (schema.type === 'array') { 394 | Object.assign(required, getRequired(schema.items, withPrefix('0', prefix))); 395 | } 396 | if (prefix === '') { 397 | const normalizedRequired = {}; 398 | each(required, (v, k) => { 399 | normalizedRequired[toPath(k, '[]')] = v; 400 | }); 401 | required = normalizedRequired; 402 | } 403 | return required; 404 | }; 405 | 406 | export const getRequiredAndNotHiddenFields = (requiredFileds, uiSchema) => ( 407 | Object.keys(requiredFileds || {}).reduce((acc, key) => { 408 | const uiSchemaForKey = key.split('.').reduce((subAcc, subKey) => { 409 | if ((subAcc.uiSchema[subKey] || {})['ui:widget'] === 'hidden') { 410 | return { ...subAcc, ...{ uiSchema: subAcc.uiSchema[subKey] || {}, subKeyRequired: false } }; 411 | } 412 | return { ...subAcc, ...{ uiSchema: subAcc.uiSchema[subKey] || {} } }; 413 | }, { uiSchema, subKeyRequired: true }); 414 | 415 | if (uiSchemaForKey.subKeyRequired) { 416 | return { ...acc, [key]: true }; 417 | } 418 | 419 | return acc; 420 | }, {}) 421 | ); 422 | 423 | const maskOptions = { 424 | undefined: /^$/, 425 | a: /^[A-Za-zÀ-ÖØ-öø-ÿ]$/, 426 | 9: /^[0-9]$/, 427 | '*': /^.$/, 428 | }; 429 | 430 | const defaultParser = value => ((value === null || value === undefined) ? '' : `${value}`); 431 | 432 | export const formatMask = (value, mask, maskParser) => { 433 | const parse = maskParser || defaultParser; 434 | const text = parse(value); 435 | let result = ''; 436 | let cursorText = 0; 437 | let cursorMask = 0; 438 | for (; cursorText < text.length; cursorText += 1) { 439 | let charText = text[cursorText]; 440 | let charMask; 441 | let extras = ''; 442 | do { 443 | charMask = mask[cursorMask]; 444 | cursorMask += 1; 445 | if (!(charMask in maskOptions)) { 446 | extras += charMask; 447 | if (charMask === charText) { 448 | cursorText += 1; 449 | charText = text[cursorText] || ''; 450 | result += extras; 451 | extras = ''; 452 | } 453 | } 454 | } while (!(charMask in maskOptions)); 455 | if (maskOptions[charMask].test(charText)) { 456 | result += extras + charText; 457 | } 458 | } 459 | return result; 460 | }; 461 | 462 | export const normalized = (value) => { 463 | if (value === '' || value === null || value === undefined) { 464 | return ''; 465 | } 466 | return value; 467 | }; 468 | 469 | export const isEmpty = value => (value === '' || value === null || value === undefined); 470 | 471 | export const usePrevious = (value) => { 472 | const ref = useRef(); 473 | 474 | useEffect(() => { 475 | ref.current = value; 476 | }, [value]); 477 | 478 | return ref.current; 479 | }; 480 | 481 | export const viewStyleKeys = [ 482 | 'animationDelay', 483 | 'animationDirection', 484 | 'animationDuration', 485 | 'animationFillMode', 486 | 'animationIterationCount', 487 | 'animationName', 488 | 'animationPlayState', 489 | 'animationTimingFunction', 490 | 'transitionDelay', 491 | 'transitionDuration', 492 | 'transitionProperty', 493 | 'transitionTimingFunction', 494 | 'borderColor', 495 | 'borderBottomColor', 496 | 'borderEndColor', 497 | 'borderLeftColor', 498 | 'borderRightColor', 499 | 'borderStartColor', 500 | 'borderTopColor', 501 | 'borderRadius', 502 | 'borderBottomEndRadius', 503 | 'borderBottomLeftRadius', 504 | 'borderBottomRightRadius', 505 | 'borderBottomStartRadius', 506 | 'borderTopEndRadius', 507 | 'borderTopLeftRadius', 508 | 'borderTopRightRadius', 509 | 'borderTopStartRadius', 510 | 'borderStyle', 511 | 'borderBottomStyle', 512 | 'borderEndStyle', 513 | 'borderLeftStyle', 514 | 'borderRightStyle', 515 | 'borderStartStyle', 516 | 'borderTopStyle', 517 | 'cursor', 518 | 'touchAction', 519 | 'userSelect', 520 | 'willChange', 521 | 'alignContent', 522 | 'alignItems', 523 | 'alignSelf', 524 | 'backfaceVisibility', 525 | 'borderWidth', 526 | 'borderBottomWidth', 527 | 'borderEndWidth', 528 | 'borderLeftWidth', 529 | 'borderRightWidth', 530 | 'borderStartWidth', 531 | 'borderTopWidth', 532 | 'bottom', 533 | 'boxSizing', 534 | 'direction', 535 | 'display', 536 | 'end', 537 | 'flex', 538 | 'flexBasis', 539 | 'flexDirection', 540 | 'flexGrow', 541 | 'flexShrink', 542 | 'flexWrap', 543 | 'height', 544 | 'justifyContent', 545 | 'left', 546 | 'margin', 547 | 'marginBottom', 548 | 'marginHorizontal', 549 | 'marginEnd', 550 | 'marginLeft', 551 | 'marginRight', 552 | 'marginStart', 553 | 'marginTop', 554 | 'marginVertical', 555 | 'maxHeight', 556 | 'maxWidth', 557 | 'minHeight', 558 | 'minWidth', 559 | 'order', 560 | 'overflow', 561 | 'overflowX', 562 | 'overflowY', 563 | 'padding', 564 | 'paddingBottom', 565 | 'paddingHorizontal', 566 | 'paddingEnd', 567 | 'paddingLeft', 568 | 'paddingRight', 569 | 'paddingStart', 570 | 'paddingTop', 571 | 'paddingVertical', 572 | 'position', 573 | 'right', 574 | 'start', 575 | 'top', 576 | 'visibility', 577 | 'width', 578 | 'zIndex', 579 | 'aspectRatio', 580 | 'gridAutoColumns', 581 | 'gridAutoFlow', 582 | 'gridAutoRows', 583 | 'gridColumnEnd', 584 | 'gridColumnGap', 585 | 'gridColumnStart', 586 | 'gridRowEnd', 587 | 'gridRowGap', 588 | 'gridRowStart', 589 | 'gridTemplateColumns', 590 | 'gridTemplateRows', 591 | 'gridTemplateAreas', 592 | 'shadowColor', 593 | 'shadowOffset', 594 | 'shadowOpacity', 595 | 'shadowRadius', 596 | 'shadowSpread', 597 | 'perspective', 598 | 'perspectiveOrigin', 599 | 'transform', 600 | 'transformOrigin', 601 | 'transformStyle', 602 | 'backgroundColor', 603 | 'opacity', 604 | 'elevation', 605 | 'backgroundAttachment', 606 | 'backgroundBlendMode', 607 | 'backgroundClip', 608 | 'backgroundImage', 609 | 'backgroundOrigin', 610 | 'backgroundPosition', 611 | 'backgroundRepeat', 612 | 'backgroundSize', 613 | 'boxShadow', 614 | 'clip', 615 | 'filter', 616 | 'outline', 617 | 'outlineColor', 618 | 'overscrollBehavior', 619 | 'overscrollBehaviorX', 620 | 'overscrollBehaviorY', 621 | 'WebkitMaskImage', 622 | 'WebkitOverflowScrolling', 623 | ]; 624 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React, { isValidElement, cloneElement } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { 4 | StyleSheet, Platform, Keyboard, View, Text, 5 | } from 'react-native'; 6 | import { ViewPropTypes } from 'deprecated-react-native-prop-types'; 7 | import { 8 | set, 9 | get, 10 | each, 11 | noop, 12 | cloneDeep, 13 | isString, 14 | isArray, 15 | isError, 16 | isPlainObject, 17 | } from 'lodash'; 18 | import { withTheme } from './Theme'; 19 | import { 20 | toPath, 21 | expand, 22 | getMetas, 23 | getValues, 24 | getErrors, 25 | getRequired, 26 | getRequiredAndNotHiddenFields, 27 | getStructure, 28 | getExceptions, 29 | normalized, 30 | } from './utils'; 31 | import fields from './fields'; 32 | import defaultWidgets from './widgets'; 33 | import FormEvent from './FormEvent'; 34 | import DefaultCancelButton from './CancelButton'; 35 | import DefaultSubmitButton from './SubmitButton'; 36 | 37 | export { UIProvider } from './UIProvider'; 38 | 39 | export { 40 | FIELD_KEY, 41 | FIELD_NAME, 42 | FIELD_VALUE, 43 | FIELD_TITLE, 44 | } from './utils'; 45 | 46 | const emptyObject = {}; 47 | 48 | const emptySchema = { 49 | type: 'object', 50 | properties: [], 51 | }; 52 | 53 | const formStyle = StyleSheet.create({ 54 | form: { 55 | paddingTop: 20, 56 | paddingBottom: 10, 57 | backgroundColor: 'white', 58 | borderRadius: 14, 59 | shadowColor: '#FF2D55', 60 | shadowOpacity: 0.14, 61 | shadowRadius: 8, 62 | shadowOffset: { 63 | height: 0, 64 | width: 0, 65 | }, 66 | marginBottom: 25, 67 | ...Platform.select({ 68 | android: { 69 | borderRadius: 4, 70 | elevation: 3, 71 | }, 72 | }), 73 | }, 74 | error: { 75 | color: '#FF2D55', 76 | }, 77 | buttonsBlock: { 78 | flexDirection: 'row', 79 | justifyContent: 'center', 80 | marginBottom: 25, 81 | marginTop: 25, 82 | }, 83 | buttonLeft: { 84 | marginRight: 5, 85 | }, 86 | buttonRight: { 87 | marginLeft: 5, 88 | }, 89 | }); 90 | 91 | const defaultReject = (err) => { throw err; }; 92 | 93 | const addToObject = obj => (v, k) => Object.assign(obj, { [k]: v }); 94 | 95 | const addToArray = arr => v => arr.push(v); 96 | 97 | class JsonSchemaForm extends React.Component { 98 | static propTypes = { 99 | name: PropTypes.string, 100 | schema: PropTypes.shape(), 101 | uiSchema: PropTypes.shape(), 102 | metaSchema: PropTypes.shape(), 103 | errorSchema: PropTypes.shape(), 104 | formData: PropTypes.shape(), 105 | children: PropTypes.node, 106 | onRef: PropTypes.func, 107 | onChange: PropTypes.func, 108 | onSubmit: PropTypes.func, 109 | onCancel: PropTypes.func, 110 | onSuccess: PropTypes.func, 111 | onError: PropTypes.func, 112 | onInit: PropTypes.func, 113 | buttonPosition: PropTypes.oneOf(['left', 'right', 'center']), 114 | cancelButton: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]), 115 | CancelButton: PropTypes.elementType, 116 | submitButton: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]), 117 | SubmitButton: PropTypes.elementType, 118 | scroller: PropTypes.shape(), 119 | widgets: PropTypes.shape(), 120 | filterEmptyValues: PropTypes.bool, 121 | insideClickRegex: PropTypes.instanceOf(RegExp), 122 | customSubmitButton: PropTypes.node, 123 | customFormStyles: ViewPropTypes.style, 124 | }; 125 | 126 | static defaultProps = { 127 | name: null, 128 | formData: emptyObject, 129 | schema: emptySchema, 130 | uiSchema: emptyObject, 131 | metaSchema: undefined, 132 | errorSchema: emptyObject, 133 | children: null, 134 | onRef: noop, 135 | onChange: noop, 136 | onSubmit: noop, 137 | onCancel: noop, 138 | onSuccess: noop, 139 | onError: noop, 140 | onInit: noop, 141 | buttonPosition: 'right', 142 | cancelButton: true, 143 | CancelButton: DefaultCancelButton, 144 | submitButton: true, 145 | SubmitButton: DefaultSubmitButton, 146 | scroller: null, 147 | widgets: emptyObject, 148 | filterEmptyValues: false, 149 | insideClickRegex: undefined, 150 | customSubmitButton: null, 151 | customFormStyles: {}, 152 | }; 153 | 154 | static getDerivedStateFromProps(nextProps, prevState) { 155 | const state = { 156 | clearCache: false, 157 | }; 158 | let clear = false; 159 | let { 160 | metas, 161 | values, 162 | errors, 163 | schema, 164 | uiSchema, 165 | } = prevState; 166 | 167 | // If the schema or uiSchema is different, we recalculate everything 168 | const { schemaProp, uiSchemaProp } = prevState; 169 | if (nextProps.schema !== schemaProp || nextProps.uiSchema !== uiSchemaProp) { 170 | clear = true; 171 | const structure = getStructure(nextProps.schema, nextProps.uiSchema); 172 | schema = structure.schema; 173 | uiSchema = structure.uiSchema; 174 | state.schema = schema; 175 | state.uiSchema = uiSchema; 176 | state.update = 'all'; 177 | state.clearCache = true; 178 | state.schemaProp = nextProps.schema; 179 | state.uiSchemaProp = nextProps.uiSchema; 180 | state.required = getRequired(schema); 181 | } 182 | 183 | // Check for formData updates 184 | if (clear || nextProps.formData !== prevState.formDataProp) { 185 | values = getValues(cloneDeep(nextProps.formData), schema); 186 | state.values = values; 187 | state.update = 'all'; 188 | state.formDataProp = nextProps.formData; 189 | } 190 | 191 | // Check for errorSchema updates 192 | if (clear || nextProps.errorSchema !== prevState.errorSchemaProp) { 193 | errors = getErrors(cloneDeep(nextProps.errorSchema), schema); 194 | state.errors = errors; 195 | state.update = 'all'; 196 | state.errorSchemaProp = nextProps.errorSchema; 197 | } 198 | 199 | // Check for metaSchema updates 200 | if (clear || nextProps.metaSchema !== prevState.metaSchemaProp) { 201 | metas = getMetas(cloneDeep(nextProps.metaSchema || values), schema, uiSchema); 202 | state.metas = metas; 203 | state.update = 'all'; 204 | state.metaSchemaProp = nextProps.metaSchema; 205 | } 206 | 207 | return state; 208 | } 209 | 210 | constructor(props) { 211 | super(props); 212 | 213 | const { 214 | name, 215 | onRef, 216 | widgets, 217 | formData, 218 | schema, 219 | uiSchema, 220 | metaSchema, 221 | errorSchema, 222 | insideClickRegex, 223 | onInit, 224 | } = props; 225 | 226 | this.id = `Form__${name || Math.random().toString(36).substr(2, 9)}`; 227 | this.fieldRegex = insideClickRegex || new RegExp(`(${this.id}-field|react-datepicker)`); 228 | this.mountSteps = []; 229 | this.widgets = Object.assign({}, defaultWidgets, widgets); 230 | 231 | const structure = getStructure(schema, uiSchema); 232 | const values = getValues(cloneDeep(formData), structure.schema); 233 | const errors = getErrors(cloneDeep(errorSchema), structure.schema); 234 | const metas = getMetas(cloneDeep(metaSchema || values), structure.schema, structure.uiSchema); 235 | const required = getRequired(structure.schema); 236 | const requiredAndNotHiddenFields = getRequiredAndNotHiddenFields(required, structure.uiSchema); 237 | 238 | if (onInit) { 239 | onInit({ values }); 240 | } 241 | 242 | this.state = { 243 | values, 244 | errors, 245 | metas, 246 | required, 247 | requiredAndNotHiddenFields, 248 | schema: structure.schema, 249 | uiSchema: structure.uiSchema, 250 | formDataProp: formData, 251 | schemaProp: schema, 252 | uiSchemaProp: uiSchema, 253 | errorSchemaProp: errorSchema, 254 | metaSchemaProp: metaSchema, 255 | update: {}, 256 | clearCache: false, 257 | activeField: '', 258 | }; 259 | 260 | onRef(this); 261 | } 262 | 263 | componentDidMount() { 264 | if (Platform.OS === 'web') { 265 | window.addEventListener('click', this.clickListener); 266 | } 267 | this.mounted = true; 268 | this.onMount(); 269 | } 270 | 271 | componentWillUnmount() { 272 | this.mounted = false; 273 | if (Platform.OS === 'web') { 274 | window.removeEventListener('click', this.clickListener); 275 | } 276 | } 277 | 278 | onMount(handler) { 279 | if (handler) { 280 | this.mountSteps.push(handler); 281 | } 282 | if (this.mounted) { 283 | const fn = this.mountSteps.shift(); 284 | if (fn) { 285 | fn.call(this); 286 | } 287 | } 288 | } 289 | 290 | onChange = (value, name, params = {}) => { 291 | const { 292 | update = [], 293 | nextErrors = false, 294 | nextMeta = false, 295 | silent = false, 296 | } = params; 297 | 298 | const { metas, values, errors } = this.state; 299 | const { onChange } = this.props; 300 | 301 | const event = new FormEvent('change', { 302 | name, 303 | value, 304 | values, 305 | metas, 306 | nextMeta, 307 | nextErrors, 308 | silent, 309 | path: toPath(name), 310 | update: [name].concat(update), 311 | }); 312 | 313 | this.setState({ 314 | isSubmitError: false, 315 | }); 316 | 317 | this.run(onChange(event), () => { 318 | if (!event.isDefaultPrevented()) { 319 | const { path } = event.params; 320 | set(event.params.values, path, event.params.value); 321 | if (event.params.nextMeta !== false) { 322 | set(metas, path, event.params.nextMeta); 323 | } 324 | if (event.params.nextErrors !== false) { 325 | set(errors, path, event.params.nextErrors); 326 | } 327 | const error = get(errors, path); 328 | if (error) { 329 | if (normalized(error.lastValue) !== normalized(event.params.value)) { 330 | error.hidden = true; 331 | } else { 332 | error.hidden = false; 333 | } 334 | } 335 | this.onMount(() => this.setState({ 336 | metas: { ...metas }, 337 | errors: { ...errors }, 338 | values: { ...event.params.values }, 339 | update: expand(event.params.update), 340 | })); 341 | } 342 | }); 343 | }; 344 | 345 | reset = () => { 346 | const { schema, uiSchema, formData } = this.props; 347 | 348 | this.setState({ 349 | values: getValues(cloneDeep(formData), getStructure(schema, uiSchema).schema), 350 | update: 'all', 351 | }); 352 | } 353 | 354 | clearAll = () => { 355 | const { schema, uiSchema } = this.props; 356 | 357 | this.setState({ 358 | values: getValues({}, getStructure(schema, uiSchema).schema), 359 | update: 'all', 360 | }); 361 | } 362 | 363 | onCancel = () => { 364 | const { values } = this.state; 365 | const { onCancel } = this.props; 366 | const event = new FormEvent('cancel', { values }); 367 | this.run(onCancel(event)); 368 | }; 369 | 370 | onSubmit = () => { 371 | this.setState(() => ({ activeField: '' })); 372 | if (Platform.OS !== 'web') { 373 | Keyboard.dismiss(); 374 | } 375 | setTimeout(() => { 376 | const { 377 | uiSchema, metas, values, requiredAndNotHiddenFields, 378 | } = this.state; 379 | const { onSubmit, filterEmptyValues } = this.props; 380 | let nextValues = this.filterDisabled(values, metas); 381 | if (filterEmptyValues) { 382 | nextValues = this.filterEmpty(nextValues); 383 | } 384 | 385 | // eslint-disable-next-line max-len 386 | const isAllRequiredFieldsFilled = Object.keys(requiredAndNotHiddenFields || {}).reduce((acc, key) => { 387 | const value = key.split('.').reduce((subAcc, subKey) => ( 388 | { 389 | values: (subAcc.values || {})[subKey], 390 | visibilityValues: (subAcc.visibilityValues || {})[subKey], 391 | }), 392 | { values: nextValues, visibilityValues: uiSchema }); 393 | const isNotAllVisibleFieldFilled = !(value.values ?? true) && (value.visibilityValues || {})['ui:widget'] !== 'hidden'; 394 | 395 | if (isNotAllVisibleFieldFilled) { 396 | return false; 397 | } 398 | return acc; 399 | }, true); 400 | 401 | if (!isAllRequiredFieldsFilled) { 402 | this.setState({ 403 | isSubmitError: true, 404 | }); 405 | return; 406 | } 407 | 408 | const event = new FormEvent('submit', { values: nextValues }); 409 | this.run(onSubmit(event), (response) => { 410 | if (!event.isDefaultPrevented()) { 411 | this.onSuccess(response); 412 | } 413 | }, (errorSchema) => { 414 | if (!event.isDefaultPrevented()) { 415 | this.onError(errorSchema); 416 | } 417 | }); 418 | }, Platform.OS !== 'web' ? 50 : 0); 419 | }; 420 | 421 | onSuccess = (response) => { 422 | const { schema, values } = this.state; 423 | const { onSuccess } = this.props; 424 | const event = new FormEvent('success', { 425 | values, 426 | response, 427 | update: 'all', 428 | }); 429 | this.run(onSuccess(event), () => { 430 | if (!event.isDefaultPrevented()) { 431 | this.onMount(() => this.setState({ 432 | errors: getErrors({}, schema), 433 | values: event.params.values, 434 | update: expand(event.params.update), 435 | })); 436 | } 437 | }); 438 | }; 439 | 440 | onError = (err) => { 441 | const { schema } = this.state; 442 | const { onError } = this.props; 443 | let errorSchema = err; 444 | if (isError(errorSchema)) { 445 | errorSchema = { Error: [err.message] }; 446 | } 447 | const errors = getErrors(errorSchema || {}, schema); 448 | const exceptions = getExceptions(errorSchema, errors); 449 | const event = new FormEvent('error', { 450 | errors, 451 | exceptions, 452 | update: 'all', 453 | }); 454 | this.run(onError(event), () => { 455 | if (!event.isDefaultPrevented()) { 456 | this.onMount(() => this.setState({ 457 | errors: event.params.errors, 458 | update: expand(event.params.update), 459 | })); 460 | } 461 | }); 462 | }; 463 | 464 | cancel = () => this.onCancel(); 465 | 466 | submit = () => this.onSubmit(); 467 | 468 | run = (maybePromise, resolveHandler, rejectHandler) => { 469 | const self = this; 470 | const resolve = resolveHandler || noop; 471 | const reject = rejectHandler || defaultReject; 472 | if (maybePromise && maybePromise.then) { 473 | return maybePromise 474 | .then((...args) => resolve.call(self, ...args)) 475 | .catch((...args) => reject.call(self, ...args)); 476 | } 477 | return resolve.call(self, maybePromise); 478 | }; 479 | 480 | setField = (name) => { 481 | this.setState(() => ({ activeField: name })); 482 | }; 483 | 484 | filterEmpty(values, path = '', type = 'object') { 485 | const self = this; 486 | const { required } = self.state; 487 | const filteredValues = type === 'object' ? {} : []; 488 | const add = type === 'object' ? addToObject(filteredValues) : addToArray(filteredValues); 489 | each(values, (v, k) => { 490 | let empty = false; 491 | const name = path ? `${path}.${k}` : k; 492 | let value = v; 493 | if (isArray(v)) { 494 | value = self.filterEmpty(v, name, 'array'); 495 | empty = value.length === 0; 496 | } else if (isPlainObject(v)) { 497 | value = self.filterEmpty(v, name, 'object'); 498 | empty = Object.keys(value).length === 0; 499 | } else { 500 | empty = value === '' || value === undefined || value === null; 501 | } 502 | if (required[toPath(name, '[]')] || !empty) { 503 | add(value, k); 504 | } 505 | }); 506 | return filteredValues; 507 | } 508 | 509 | filterDisabled(values, metas, path = '', type = 'object') { 510 | const self = this; 511 | const filteredValues = type === 'object' ? {} : []; 512 | const add = type === 'object' ? addToObject(filteredValues) : addToArray(filteredValues); 513 | each(values, (v, k) => { 514 | const disabled = !!(metas && metas[k] && metas[k]['ui:disabled']); 515 | if (!disabled) { 516 | const name = path ? `${path}.${k}` : k; 517 | let value = v; 518 | if (isArray(v)) { 519 | value = self.filterDisabled(v, (metas && metas[k]) || [], name, 'array'); 520 | } else if (isPlainObject(v)) { 521 | value = self.filterDisabled(v, (metas && metas[k]) || {}, name, 'object'); 522 | } 523 | add(value, k); 524 | } 525 | }); 526 | return filteredValues; 527 | } 528 | 529 | render() { 530 | const { 531 | event, 532 | schema, 533 | uiSchema, 534 | metas, 535 | values, 536 | errors, 537 | update, 538 | required, 539 | clearCache, 540 | activeField, 541 | isSubmitError, 542 | } = this.state; 543 | 544 | const { 545 | children, 546 | cancelButton, 547 | CancelButton, 548 | submitButton, 549 | SubmitButton, 550 | customSubmitButton, 551 | customFormStyles, 552 | } = this.props; 553 | 554 | const { ObjectField } = fields; 555 | return ( 556 | 557 | 558 | 581 | 582 | {!!isSubmitError && Please fill all required fields.} 583 | {children || (submitButton === false && cancelButton === false) ? children : ( 584 | 585 | {cancelButton ? ( 586 | 591 | ) : null} 592 | {submitButton && !customSubmitButton ? ( 593 | 598 | ) : null} 599 | {isValidElement(customSubmitButton) 600 | ? cloneElement(customSubmitButton, { onPress: this.onSubmit }) 601 | : null} 602 | 603 | )} 604 | 605 | ); 606 | } 607 | } 608 | 609 | export const Form = withTheme('JsonSchemaForm')(JsonSchemaForm); 610 | --------------------------------------------------------------------------------