├── setupTest.js ├── .gitignore ├── example ├── .gitignore ├── public │ ├── index.html │ └── global.css ├── package.json ├── rollup.config.js └── src │ └── index.jsx ├── src ├── JSONSchema │ ├── index.ts │ ├── logic │ │ ├── index.ts │ │ ├── pathUtils.ts │ │ ├── refHandlers.ts │ │ └── schemaHandlers.ts │ ├── __mocks__ │ │ ├── deepFreeze.ts │ │ └── mockSchemaWithRefs.ts │ ├── __tests__ │ │ ├── formPathHandler.test.ts │ │ ├── resolveReferences.test.tsx │ │ └── testFrozenObject.test.tsx │ ├── path-handler.ts │ └── types │ │ └── index.ts ├── hooks │ ├── validators │ │ ├── index.ts │ │ ├── getEnum.ts │ │ ├── getStringValidator.ts │ │ ├── types │ │ │ └── index.ts │ │ ├── getNumberValidator.ts │ │ ├── getGenericValidator.ts │ │ ├── getError.ts │ │ ├── numberUtilities.ts │ │ └── __tests__ │ │ │ └── errorMessages.test.tsx │ ├── index.ts │ ├── usePassword.ts │ ├── useHidden.ts │ ├── useInput.ts │ ├── __mocks__ │ │ ├── mockSchema.ts │ │ └── mockTextSchema.ts │ ├── useTextArea.ts │ ├── __tests__ │ │ ├── useInput.test.tsx │ │ ├── useCheckbox.test.tsx │ │ ├── useCustomValidator.test.tsx │ │ ├── useObject.test.tsx │ │ ├── useTextArea.test.tsx │ │ ├── useRawInput.test.tsx │ │ ├── useRadio.test.tsx │ │ └── useSelect.test.tsx │ ├── useGenericInput.ts │ ├── useRawInput.ts │ ├── useSelect.ts │ ├── useRadio.ts │ ├── useCheckbox.ts │ ├── types │ │ └── index.ts │ └── useObject.ts ├── components │ ├── index.ts │ ├── __mocks__ │ │ └── mockSchema.ts │ ├── types │ │ └── index.ts │ ├── __tests__ │ │ └── FormContext.test.tsx │ └── FormContext.tsx ├── index.ts └── __mocks__ │ └── mockObjectComponent.tsx ├── .prettierrc ├── .eslintrc ├── tsconfig.json ├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── PULL_REQUEST_TEMPLATE.md ├── rollup.config.js ├── package.json ├── CHANGELOG.md └── README.md /setupTest.js: -------------------------------------------------------------------------------- 1 | require('mutationobserver-shim') 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | *.swp 4 | *.DS_STORE 5 | output/* 6 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /public/build/ 3 | 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /src/JSONSchema/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types' 2 | export { getDataFromPointer } from './path-handler' 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "trailingComma": "es5", 5 | "eslintIntegration": true 6 | } 7 | -------------------------------------------------------------------------------- /src/JSONSchema/logic/index.ts: -------------------------------------------------------------------------------- 1 | export * from './pathUtils' 2 | export * from './refHandlers' 3 | export * from './schemaHandlers' 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "vtex-react", 3 | "root": true, 4 | "env": { 5 | "browser": true, 6 | "node": true, 7 | "es6": true, 8 | "jest": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/hooks/validators/index.ts: -------------------------------------------------------------------------------- 1 | export * from './numberUtilities' 2 | export * from './getStringValidator' 3 | export * from './getGenericValidator' 4 | export * from './getError' 5 | export * from './types' 6 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | FormContextProps, 3 | JSONFormContextValues, 4 | OnSubmitParameters, 5 | OnSubmitType, 6 | } from './types' 7 | export { FormContext, useFormContext } from './FormContext' 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vtex/tsconfig", 3 | "compilerOptions": { 4 | "noEmitOnError": false, 5 | "typeRoots": ["node_modules/@types"], 6 | "types": ["node", "jest"], 7 | "declaration": true, 8 | "declarationDir": "./output", 9 | "outDir": "./output" 10 | }, 11 | "exclude": ["**/__mocks__/*", "**/__tests__/*"] 12 | } 13 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { useCheckbox } from './useCheckbox' 2 | export { useHidden } from './useHidden' 3 | export { useInput } from './useInput' 4 | export { useObject } from './useObject' 5 | export { usePassword } from './usePassword' 6 | export { useRadio } from './useRadio' 7 | export { useSelect } from './useSelect' 8 | export { useTextArea } from './useTextArea' 9 | 10 | export * from './types' 11 | -------------------------------------------------------------------------------- /src/JSONSchema/logic/pathUtils.ts: -------------------------------------------------------------------------------- 1 | export const JSONSchemaRootPointer = '#' 2 | 3 | export const concatFormPointer = (path: string, newNode: string): string => { 4 | return path + '/' + newNode 5 | } 6 | 7 | export const getSplitPointer = (pointer: string): string[] => { 8 | const split = pointer.split('/') 9 | 10 | // Removes the root pointer 11 | if (split[0] === JSONSchemaRootPointer) { 12 | split.shift() 13 | } 14 | 15 | return split 16 | } 17 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { Controller } from 'react-hook-form' 2 | export * from 'react-hook-form/dist/types' 3 | 4 | export { 5 | FormContext, 6 | FormContextProps, 7 | JSONFormContextValues, 8 | OnSubmitParameters, 9 | OnSubmitType, 10 | } from './components' 11 | 12 | export * from './hooks' 13 | 14 | export * from './JSONSchema' 15 | 16 | export { 17 | ErrorMessage, 18 | ErrorMessageValues, 19 | ErrorTypes, 20 | } from './hooks/validators/types' 21 | -------------------------------------------------------------------------------- /src/hooks/validators/getEnum.ts: -------------------------------------------------------------------------------- 1 | import { JSONSchemaType, JSONSchemaBaseInstanceTypes } from '../../JSONSchema' 2 | 3 | const mapEnumItemsToString = (obj: JSONSchemaBaseInstanceTypes): string => { 4 | if (obj) { 5 | return obj.toString() 6 | } 7 | return '' 8 | } 9 | 10 | export const getEnumAsStringArray = ( 11 | currentObject: JSONSchemaType 12 | ): string[] => { 13 | return currentObject.enum ? currentObject.enum.map(mapEnumItemsToString) : [] 14 | } 15 | -------------------------------------------------------------------------------- /example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | React app 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | -------------------------------------------------------------------------------- /src/JSONSchema/__mocks__/deepFreeze.ts: -------------------------------------------------------------------------------- 1 | export function deepFreeze(obj) { 2 | // Gets all properties names to freeze 3 | const propNames = Object.getOwnPropertyNames(obj) 4 | 5 | // Freezes each property before freezing the object itself 6 | propNames.forEach(function(name) { 7 | const prop = obj[name] 8 | 9 | // Freezes prop if it is an object 10 | if (typeof prop == 'object' && prop !== null) deepFreeze(prop) 11 | }) 12 | 13 | // Freezes itself 14 | return Object.freeze(obj) 15 | } 16 | -------------------------------------------------------------------------------- /src/hooks/usePassword.ts: -------------------------------------------------------------------------------- 1 | import { 2 | UseInputParameters, 3 | BasicInputReturnType, 4 | UseRawInputReturnType, 5 | } from './types' 6 | import { getRawInputCustomFields } from './useRawInput' 7 | import { useGenericInput } from './useGenericInput' 8 | 9 | export const getPasswordCustomFields = ( 10 | baseObject: BasicInputReturnType 11 | ): UseRawInputReturnType => { 12 | return getRawInputCustomFields(baseObject, 'password') 13 | } 14 | 15 | export const usePassword: UseInputParameters = pointer => { 16 | return getPasswordCustomFields(useGenericInput(pointer)) 17 | } 18 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | # Use 4 spaces for HTML files 13 | [*.html] 14 | indent_size = 4 15 | 16 | # The JSON files contain newlines inconsistently 17 | [*.json] 18 | insert_final_newline = ignore 19 | 20 | # Minified JavaScript files shouldn't be changed 21 | [**.min.js] 22 | indent_style = ignore 23 | insert_final_newline = ignore 24 | 25 | [*.md] 26 | trim_trailing_whitespace = false 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import commonjs from '@rollup/plugin-commonjs' 2 | import resolve from '@rollup/plugin-node-resolve' 3 | import typescript from 'rollup-plugin-typescript2' 4 | import { terser } from 'rollup-plugin-terser' 5 | 6 | import pkg from './package.json' 7 | 8 | const PROD = !process.env.ROLLUP_WATCH 9 | 10 | export default { 11 | input: 'src/index.ts', 12 | output: [ 13 | { 14 | file: pkg.main, 15 | format: 'cjs', 16 | }, 17 | { 18 | file: pkg.module, 19 | format: 'es', 20 | }, 21 | ], 22 | plugins: [resolve(), commonjs(), typescript(), PROD && terser()], 23 | external: ['react', 'react-dom', 'react-hook-form'], 24 | } 25 | -------------------------------------------------------------------------------- /src/hooks/useHidden.ts: -------------------------------------------------------------------------------- 1 | import { 2 | UseInputParameters, 3 | BasicInputReturnType, 4 | UseRawInputReturnType, 5 | } from './types' 6 | import { useGenericInput } from './useGenericInput' 7 | import { getRawInputCustomFields } from './useRawInput' 8 | 9 | const noop = () => ({}) 10 | 11 | export const getHiddenCustomFields = ( 12 | baseObject: BasicInputReturnType 13 | ): UseRawInputReturnType => { 14 | return { 15 | ...getRawInputCustomFields(baseObject, 'hidden'), 16 | isRequired: false, 17 | getLabelProps: noop, 18 | } 19 | } 20 | 21 | export const useHidden: UseInputParameters = pointer => { 22 | return getHiddenCustomFields(useGenericInput(pointer)) 23 | } 24 | -------------------------------------------------------------------------------- /src/components/__mocks__/mockSchema.ts: -------------------------------------------------------------------------------- 1 | const mockSchema = { 2 | $id: 'https://example.com/mock.schema.json', 3 | $schema: 'http://json-schema.org/draft-07/schema#', 4 | title: 'Mock', 5 | type: 'object', 6 | properties: { 7 | firstField: { 8 | type: 'string', 9 | description: 'The first field.', 10 | title: 'First field', 11 | }, 12 | secondField: { 13 | type: 'string', 14 | description: 'The second field.', 15 | title: 'Second field', 16 | }, 17 | thirdField: { 18 | type: 'string', 19 | description: 'The third field.', 20 | title: 'Third field', 21 | }, 22 | }, 23 | } 24 | 25 | export default mockSchema 26 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-app", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "build": "rollup -c", 6 | "dev": "rollup -c -w", 7 | "start": "sirv public" 8 | }, 9 | "devDependencies": { 10 | "@babel/core": "^7.8.3", 11 | "@babel/preset-react": "^7.8.3", 12 | "@rollup/plugin-alias": "^3.0.0", 13 | "@rollup/plugin-commonjs": "^11.0.0", 14 | "@rollup/plugin-node-resolve": "^6.0.0", 15 | "@rollup/plugin-replace": "^2.3.0", 16 | "react-hook-form-jsonschema": "link:..", 17 | "rollup": "^1.20.0", 18 | "rollup-plugin-babel": "^4.3.3", 19 | "rollup-plugin-livereload": "^1.0.0", 20 | "rollup-plugin-terser": "^5.1.2" 21 | }, 22 | "dependencies": { 23 | "react": "^16.12.0", 24 | "react-dom": "^16.12.0", 25 | "sirv-cli": "^0.4.4" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/hooks/validators/getStringValidator.ts: -------------------------------------------------------------------------------- 1 | import { ValidationOptions } from 'react-hook-form' 2 | 3 | import { JSONSchemaType } from '../../JSONSchema' 4 | import { ErrorTypes } from './types' 5 | 6 | export const getStringValidator = ( 7 | currentObject: JSONSchemaType, 8 | baseValidator: ValidationOptions 9 | ): ValidationOptions => { 10 | if (currentObject.minLength) { 11 | baseValidator.minLength = { 12 | value: currentObject.minLength, 13 | message: ErrorTypes.minLength, 14 | } 15 | } 16 | 17 | if (currentObject.maxLength) { 18 | baseValidator.maxLength = { 19 | value: currentObject.maxLength, 20 | message: ErrorTypes.maxLength, 21 | } 22 | } 23 | 24 | if (currentObject.pattern) { 25 | baseValidator.pattern = { 26 | value: new RegExp(currentObject.pattern), 27 | message: ErrorTypes.pattern, 28 | } 29 | } 30 | return baseValidator 31 | } 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | A clear and concise description of what the bug is. 9 | 10 | **To Reproduce** 11 | Steps to reproduce the behavior: 12 | 1. Go to '...' 13 | 2. Click on '....' 14 | 3. Scroll down to '....' 15 | 4. See error 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Screenshots** 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | **Desktop (please complete the following information):** 24 | - OS: [e.g. iOS] 25 | - Browser [e.g. chrome, safari] 26 | - Version [e.g. 22] 27 | 28 | **Smartphone (please complete the following information):** 29 | - Device: [e.g. iPhone6] 30 | - OS: [e.g. iOS8.1] 31 | - Browser [e.g. stock browser, safari] 32 | - Version [e.g. 22] 33 | 34 | **Additional context** 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | #### What problem is this solving? 2 | 3 | 4 | 5 | #### Checklist/Reminders 6 | 7 | - [ ] Updated `README.md`. 8 | - [ ] Updated `CHANGELOG.md`. 9 | - [ ] Linked this PR to a Clubhouse story (if applicable). 10 | - [ ] Updated/created tests (important for bug fixes). 11 | - [ ] Deleted the workspace after merging this PR (if applicable). 12 | 13 | #### Example usage 14 | 15 | #### Type of changes 16 | 17 | 18 | ✔️ | Type of Change 19 | ---|--- 20 | _ | Bug fix 21 | _ | New feature 22 | _ | Breaking change 23 | _ | Technical improvements 24 | 25 | #### Notes 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/hooks/validators/types/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | JSONSubSchemaInfo, 3 | JSONSchemaType, 4 | JSONSchemaBaseInstanceTypes, 5 | } from '../../../JSONSchema' 6 | 7 | export enum ErrorTypes { 8 | required = '__form_error_required__', 9 | maxLength = '__form_error_maxLength__', 10 | minLength = '__form_error_minLength__', 11 | maxValue = '__form_error_maxValue__', 12 | minValue = '__form_error_minValue__', 13 | pattern = '__form_error_pattern__', 14 | notInteger = '__form_error_notInteger__', 15 | notFloat = '__form_error_notFloat__', 16 | multipleOf = '__form_error_multipleOf__', 17 | notInEnum = '__form_error_notInEnum', 18 | undefinedError = '__form_error_undefinedError__', 19 | } 20 | 21 | export type ErrorMessageValues = 22 | | JSONSchemaType['enum'] 23 | | JSONSchemaBaseInstanceTypes 24 | | undefined 25 | 26 | export type ErrorMessage = 27 | | { 28 | message: ErrorTypes | string 29 | expected: ErrorMessageValues 30 | } 31 | | undefined 32 | 33 | export type CustomValidatorReturnValue = string | true 34 | 35 | export type CustomValidator = ( 36 | value: string, 37 | context: JSONSubSchemaInfo 38 | ) => CustomValidatorReturnValue 39 | 40 | export type CustomValidators = Record 41 | -------------------------------------------------------------------------------- /example/public/global.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | position: relative; 4 | width: 100%; 5 | height: 100%; 6 | } 7 | 8 | body { 9 | color: #333; 10 | margin: 0; 11 | padding: 8px; 12 | box-sizing: border-box; 13 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 14 | Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif; 15 | } 16 | 17 | a { 18 | color: rgb(0, 100, 200); 19 | text-decoration: none; 20 | } 21 | 22 | a:hover { 23 | text-decoration: underline; 24 | } 25 | 26 | a:visited { 27 | color: rgb(0, 80, 160); 28 | } 29 | 30 | label { 31 | display: block; 32 | } 33 | 34 | input, 35 | button, 36 | select, 37 | textarea { 38 | font-family: inherit; 39 | font-size: inherit; 40 | padding: 0.4em; 41 | margin: 0 0 0.5em 0; 42 | box-sizing: border-box; 43 | border: 1px solid #ccc; 44 | border-radius: 2px; 45 | } 46 | 47 | input:disabled { 48 | color: #ccc; 49 | } 50 | 51 | input[type='range'] { 52 | height: 0; 53 | } 54 | 55 | button { 56 | color: #333; 57 | background-color: #f4f4f4; 58 | outline: none; 59 | } 60 | 61 | button:disabled { 62 | color: #999; 63 | } 64 | 65 | button:not(:disabled):active { 66 | background-color: #ddd; 67 | } 68 | 69 | button:focus { 70 | border-color: #666; 71 | } 72 | -------------------------------------------------------------------------------- /src/hooks/useInput.ts: -------------------------------------------------------------------------------- 1 | import { 2 | UseInputParameters, 3 | BasicInputReturnType, 4 | UseRawInputReturnType, 5 | } from './types' 6 | import { getRawInputCustomFields } from './useRawInput' 7 | import { useGenericInput } from './useGenericInput' 8 | 9 | export const getInputCustomFields = ( 10 | baseObject: BasicInputReturnType 11 | ): UseRawInputReturnType => { 12 | const currentObject = baseObject.getObject() 13 | 14 | let inputType = 'text' 15 | if (currentObject.type === 'string') { 16 | switch (currentObject.format) { 17 | case 'date': 18 | inputType = 'date' 19 | break 20 | case 'date-time': 21 | inputType = 'datetime-local' 22 | break 23 | case 'email': 24 | inputType = 'email' 25 | break 26 | case 'hostname': 27 | inputType = 'url' 28 | break 29 | case 'uri': 30 | inputType = 'url' 31 | break 32 | } 33 | } else if ( 34 | currentObject.type === 'integer' || 35 | currentObject.type === 'number' 36 | ) { 37 | inputType = 'number' 38 | } 39 | 40 | return getRawInputCustomFields(baseObject, inputType) 41 | } 42 | 43 | export const useInput: UseInputParameters = pointer => { 44 | return getInputCustomFields(useGenericInput(pointer)) 45 | } 46 | -------------------------------------------------------------------------------- /src/components/types/index.ts: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { 3 | DeepPartial, 4 | FieldValues, 5 | FormContextValues, 6 | Mode, 7 | } from 'react-hook-form' 8 | 9 | import { JSONSchemaType, IDSchemaPair } from '../../JSONSchema' 10 | import { CustomValidators } from '../../hooks/validators' 11 | 12 | export interface JSONFormContextValues< 13 | FormValues extends FieldValues = FieldValues 14 | > extends FormContextValues { 15 | schema: JSONSchemaType 16 | idMap: IDSchemaPair 17 | customValidators?: CustomValidators 18 | } 19 | 20 | export type OnSubmitParameters = { 21 | data: JSONSchemaType 22 | event: React.BaseSyntheticEvent | undefined 23 | methods: JSONFormContextValues 24 | } 25 | export type OnSubmitType = (props: OnSubmitParameters) => void | Promise 26 | 27 | export type FormContextProps = { 28 | formProps?: Omit, 'onSubmit'> 29 | validationMode?: Mode 30 | revalidateMode?: Mode 31 | submitFocusError?: boolean 32 | onChange?: (data: JSONSchemaType) => void 33 | onSubmit?: OnSubmitType 34 | noNativeValidate?: boolean 35 | customValidators?: CustomValidators 36 | schema: JSONSchemaType 37 | defaultValues?: DeepPartial | FormValues 38 | } 39 | -------------------------------------------------------------------------------- /src/hooks/__mocks__/mockSchema.ts: -------------------------------------------------------------------------------- 1 | export const toFixed = (value: number, precision: number): string => { 2 | const power = Math.pow(10, precision || 0) 3 | return String(Math.round(value * power) / power) 4 | } 5 | 6 | const mockSchema = { 7 | type: 'object', 8 | required: ['errorTest', 'arrayErrorTest'], 9 | properties: { 10 | stringTest: { 11 | type: 'string', 12 | title: 'test-useSelectString', 13 | enum: ['this', 'tests', 'the', 'useSelect', 'hook'], 14 | }, 15 | integerTest: { 16 | type: 'integer', 17 | title: 'test-useSelectInteger', 18 | minimum: 0, 19 | maximum: 6, 20 | multipleOf: 2, 21 | }, 22 | numberTest: { 23 | type: 'number', 24 | title: 'test-useSelectNumber', 25 | minimum: 0, 26 | maximum: 0.5, 27 | multipleOf: 0.1, 28 | }, 29 | booleanTest: { 30 | type: 'boolean', 31 | title: 'test-useSelectBoolean', 32 | }, 33 | errorTest: { 34 | type: 'string', 35 | title: 'test-showError', 36 | enum: ['should', 'show', 'error', 'when', 'submitted'], 37 | }, 38 | arrayErrorTest: { 39 | type: 'array', 40 | items: { 41 | enum: ['should', 'show', 'error', 'when', 'submitted'], 42 | }, 43 | minItems: 1, 44 | }, 45 | }, 46 | } 47 | 48 | export default mockSchema 49 | -------------------------------------------------------------------------------- /src/hooks/__mocks__/mockTextSchema.ts: -------------------------------------------------------------------------------- 1 | const mockTextSchema = { 2 | type: 'object', 3 | required: ['errorTest'], 4 | properties: { 5 | stringTest: { 6 | type: 'string', 7 | name: 'test-useTextAreaString', 8 | minLength: 2, 9 | }, 10 | integerTest: { 11 | type: 'integer', 12 | name: 'test-useTextAreaInteger', 13 | minimum: 0, 14 | maximum: 10, 15 | multipleOf: 1, 16 | }, 17 | numberTest: { 18 | type: 'number', 19 | name: 'test-useTextAreaNumber', 20 | minimum: 0, 21 | maximum: 10, 22 | multipleOf: 0.1, 23 | }, 24 | errorTest: { 25 | type: 'string', 26 | name: 'test-showError', 27 | minLength: 10, 28 | }, 29 | stringDateTest: { 30 | type: 'string', 31 | title: 'test-useInput-format-date', 32 | format: 'date', 33 | }, 34 | stringDateTimeTest: { 35 | type: 'string', 36 | title: 'test-useInput-format-date-time', 37 | format: 'date-time', 38 | }, 39 | stringEmailTest: { 40 | type: 'string', 41 | title: 'test-useInput-format-email', 42 | format: 'email', 43 | }, 44 | stringHostnameTest: { 45 | type: 'string', 46 | title: 'test-useInput-format-hostname', 47 | format: 'hostname', 48 | }, 49 | stringUriTest: { 50 | type: 'string', 51 | title: 'test-useInput-format-uri', 52 | format: 'uri', 53 | }, 54 | }, 55 | } 56 | 57 | export default mockTextSchema 58 | -------------------------------------------------------------------------------- /src/JSONSchema/__tests__/formPathHandler.test.ts: -------------------------------------------------------------------------------- 1 | import { getObjectFromForm } from '../logic' 2 | 3 | test('should return an object that matches the schema', () => { 4 | const mockJSONSchema = { 5 | type: 'object', 6 | properties: { 7 | firstName: { 8 | type: 'string', 9 | }, 10 | middleName: { 11 | type: 'string', 12 | }, 13 | lastName: { 14 | type: 'string', 15 | }, 16 | address: { 17 | type: 'object', 18 | properties: { 19 | city: { 20 | type: 'string', 21 | }, 22 | street: { 23 | type: 'string', 24 | }, 25 | streetNumber: { 26 | type: 'integer', 27 | }, 28 | }, 29 | }, 30 | }, 31 | } 32 | const mockData = { 33 | '#/properties/firstName': 'Jane', 34 | '#/properties/lastName': 'Doe', 35 | '#/properties/address/properties/city': 'RJ', 36 | '#/properties/address/properties/street': 'Praia de Botafogo', 37 | '#/properties/address/properties/streetNumber': 300, 38 | '#/properties/middleName': null, 39 | '#/properties/intruderField': 40 | 'I am an intruder, you should not return me :)', 41 | } 42 | 43 | const testResult = getObjectFromForm(mockJSONSchema, mockData) 44 | 45 | expect(testResult).toEqual({ 46 | firstName: 'Jane', 47 | lastName: 'Doe', 48 | address: { 49 | city: 'RJ', 50 | street: 'Praia de Botafogo', 51 | streetNumber: 300, 52 | }, 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /src/JSONSchema/path-handler.ts: -------------------------------------------------------------------------------- 1 | import { JSONSchemaType, JSONSubSchemaInfo } from './types' 2 | import { 3 | getObjectFromForm, 4 | concatFormPointer, 5 | getAnnotatedSchemaFromPointer, 6 | getSplitPointer, 7 | } from './logic' 8 | import { useFormContext } from '../components' 9 | 10 | const useAnnotatedSchemaFromPointer = ( 11 | path: string, 12 | data: JSONSchemaType 13 | ): JSONSubSchemaInfo => { 14 | return getAnnotatedSchemaFromPointer(path, data, useFormContext()) 15 | } 16 | 17 | const useObjectFromForm = (data: JSONSchemaType): JSONSchemaType => { 18 | return getObjectFromForm(useFormContext().schema, data) 19 | } 20 | 21 | const getDataFromPointer = ( 22 | pointer: string, 23 | data: JSONSchemaType 24 | ): undefined | string => { 25 | const splitPointer = getSplitPointer(pointer) 26 | 27 | let insideProperties = false 28 | 29 | return splitPointer 30 | .reduce( 31 | (currentContext, node: string) => { 32 | if (node === 'properties' && !insideProperties) { 33 | insideProperties = true 34 | return { ...currentContext, insideProperties: true } 35 | } 36 | insideProperties = false 37 | 38 | return { 39 | currentData: currentContext.currentData 40 | ? currentContext.currentData[node] 41 | : undefined, 42 | insideProperties: true, 43 | } 44 | }, 45 | { currentData: data, insideProperties: false } 46 | ) 47 | .currentData?.toString() 48 | } 49 | 50 | export { 51 | useObjectFromForm, 52 | concatFormPointer, 53 | useAnnotatedSchemaFromPointer, 54 | getDataFromPointer, 55 | } 56 | -------------------------------------------------------------------------------- /src/JSONSchema/__tests__/resolveReferences.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render } from '@vtex/test-tools/react' 3 | 4 | import { FormContext } from '../../components' 5 | import mockObjectSchema from '../__mocks__/mockSchemaWithRefs' 6 | import { MockObject } from '../../__mocks__/mockObjectComponent' 7 | import { deepFreeze } from '../__mocks__/deepFreeze' 8 | 9 | // Ensures there is no modification to base schema 10 | test('should render all child properties of the schema', async () => { 11 | const { getByText } = render( 12 | 13 | 14 | 15 | ) 16 | 17 | expect(getByText('firstName')).toBeDefined() 18 | expect(getByText('lastName')).toBeDefined() 19 | expect(getByText('age')).toBeDefined() 20 | expect(getByText('street')).toBeDefined() 21 | expect(getByText('streetType')).toBeDefined() 22 | expect(getByText('streetNumber')).toBeDefined() 23 | expect(getByText('zipCode')).toBeDefined() 24 | }) 25 | 26 | test('should render all child properties of the schema even if frozen', async () => { 27 | const mockSchema = deepFreeze(mockObjectSchema) 28 | const { getByText } = render( 29 | 30 | 31 | 32 | ) 33 | 34 | expect(getByText('firstName')).toBeDefined() 35 | expect(getByText('lastName')).toBeDefined() 36 | expect(getByText('age')).toBeDefined() 37 | expect(getByText('street')).toBeDefined() 38 | expect(getByText('streetType')).toBeDefined() 39 | expect(getByText('streetNumber')).toBeDefined() 40 | expect(getByText('zipCode')).toBeDefined() 41 | }) 42 | -------------------------------------------------------------------------------- /src/hooks/useTextArea.ts: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { 4 | UseTextAreaParameters, 5 | BasicInputReturnType, 6 | UseTextAreaReturnType, 7 | InputTypes, 8 | } from './types' 9 | import { useGenericInput } from './useGenericInput' 10 | 11 | const getInputId = (pointer: string): string => { 12 | return pointer + '-textarea-input' 13 | } 14 | 15 | const getLabelId = (pointer: string): string => { 16 | return pointer + '-textarea-label' 17 | } 18 | 19 | export const getTextAreaCustomFields = ( 20 | baseInput: BasicInputReturnType 21 | ): UseTextAreaReturnType => { 22 | const { register } = baseInput.formContext 23 | const { validator } = baseInput 24 | 25 | const currentObject = baseInput.getObject() 26 | 27 | const itemProps: React.ComponentProps<'textarea'> = {} 28 | if (currentObject.type === 'string') { 29 | itemProps.minLength = currentObject.minLength 30 | itemProps.maxLength = currentObject.maxLength 31 | } 32 | 33 | return { 34 | ...baseInput, 35 | type: InputTypes.textArea, 36 | getLabelProps: () => { 37 | const itemProps: React.ComponentProps<'label'> = {} 38 | itemProps.id = getLabelId(baseInput.pointer) 39 | itemProps.htmlFor = getInputId(baseInput.pointer) 40 | 41 | return itemProps 42 | }, 43 | getTextAreaProps: () => { 44 | itemProps.name = baseInput.pointer 45 | itemProps.ref = register(validator) 46 | itemProps.required = baseInput.isRequired 47 | itemProps.id = getInputId(baseInput.pointer) 48 | 49 | return itemProps 50 | }, 51 | } 52 | } 53 | 54 | export const useTextArea: UseTextAreaParameters = pointer => { 55 | return getTextAreaCustomFields(useGenericInput(pointer)) 56 | } 57 | -------------------------------------------------------------------------------- /src/hooks/validators/getNumberValidator.ts: -------------------------------------------------------------------------------- 1 | import { ValidationOptions } from 'react-hook-form' 2 | 3 | import { getNumberMaximum, getNumberMinimum } from './numberUtilities' 4 | import { JSONSchemaType } from '../../JSONSchema' 5 | import { ErrorTypes } from './types' 6 | 7 | export const getNumberValidator = ( 8 | currentObject: JSONSchemaType, 9 | baseValidator: ValidationOptions 10 | ): ValidationOptions => { 11 | const minimum = getNumberMinimum(currentObject) 12 | const maximum = getNumberMaximum(currentObject) 13 | 14 | baseValidator.validate = { 15 | ...baseValidator.validate, 16 | multipleOf: (value: string) => { 17 | if (currentObject.type === 'integer' && value) { 18 | return ( 19 | currentObject.multipleOf && 20 | (parseInt(value) % parseInt(currentObject.multipleOf) === 0 || 21 | ErrorTypes.multipleOf) 22 | ) 23 | } else { 24 | // TODO: implement float checking with epsilon 25 | return true 26 | } 27 | }, 28 | } 29 | 30 | if (currentObject.type === 'integer') { 31 | baseValidator.pattern = { 32 | value: /^([+-]?[1-9]\d*|0)$/, 33 | message: ErrorTypes.notInteger, 34 | } 35 | } else { 36 | baseValidator.pattern = { 37 | value: /^([+-]?[0-9]+([.][0-9]+))?$/, 38 | message: ErrorTypes.notFloat, 39 | } 40 | } 41 | 42 | if (minimum || minimum === 0) { 43 | baseValidator.min = { 44 | value: minimum, 45 | message: ErrorTypes.minValue, 46 | } 47 | } 48 | 49 | if (maximum || maximum === 0) { 50 | baseValidator.max = { 51 | value: maximum, 52 | message: ErrorTypes.maxValue, 53 | } 54 | } 55 | 56 | return baseValidator 57 | } 58 | -------------------------------------------------------------------------------- /src/JSONSchema/__mocks__/mockSchemaWithRefs.ts: -------------------------------------------------------------------------------- 1 | const mockSchema = { 2 | type: 'object', 3 | required: ['firstName', 'lastName'], 4 | $id: 'https://vtex.io/oneSampleschema.json', 5 | properties: { 6 | firstName: { 7 | type: 'string', 8 | title: 'First Name', 9 | }, 10 | lastName: { 11 | type: 'string', 12 | title: 'Last Name', 13 | }, 14 | age: { 15 | type: 'number', 16 | title: 'Age', 17 | minimum: 18, 18 | maximum: 100, 19 | multipleOf: 1, 20 | }, 21 | address: { 22 | $ref: '#/definitions/address', 23 | }, 24 | }, 25 | definitions: { 26 | $id: 'definitions.json', 27 | address: { 28 | type: 'object', 29 | title: 'Address', 30 | properties: { 31 | street: { 32 | $ref: 'streetAddress', 33 | }, 34 | streetType: { 35 | $ref: 'https://vtex.io/streetType', 36 | }, 37 | streetNumber: { 38 | $ref: 39 | 'https://vtex.io/oneSampleschema.json#/definitions/streetNumber', 40 | }, 41 | zipCode: { 42 | $ref: 'https://vtex.io/definitions.json#/zipCode', 43 | }, 44 | }, 45 | }, 46 | street: { 47 | type: 'string', 48 | title: 'Street Address', 49 | $id: 'streetAddress', 50 | }, 51 | streetType: { 52 | type: 'string', 53 | title: 'Street Type', 54 | $id: 'streetType', 55 | enum: ['road', 'boulevard', 'avenue'], 56 | }, 57 | streetNumber: { 58 | type: 'integer', 59 | title: 'Address Number', 60 | minimum: 0, 61 | }, 62 | zipCode: { 63 | type: 'integer', 64 | title: 'Zip Code', 65 | }, 66 | }, 67 | } 68 | 69 | export default mockSchema 70 | -------------------------------------------------------------------------------- /src/hooks/__tests__/useInput.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { renderHook } from '@testing-library/react-hooks' 3 | 4 | import { FormContext } from '../../components' 5 | import { useInput } from '../useInput' 6 | import mockTextSchema from '../__mocks__/mockTextSchema' 7 | 8 | const Wrapper: FC = ({ children }) => { 9 | return {children} 10 | } 11 | 12 | test('useInput type date', () => { 13 | const { result } = renderHook(() => useInput('#/properties/stringDateTest'), { 14 | wrapper: Wrapper, 15 | }) 16 | 17 | const { type } = result.current.getInputProps() 18 | expect(type).toBe('date') 19 | }) 20 | 21 | test('useInput type date-time', () => { 22 | const { result } = renderHook( 23 | () => useInput('#/properties/stringDateTimeTest'), 24 | { 25 | wrapper: Wrapper, 26 | } 27 | ) 28 | 29 | const { type } = result.current.getInputProps() 30 | expect(type).toBe('datetime-local') 31 | }) 32 | 33 | test('useInput type email', () => { 34 | const { result } = renderHook( 35 | () => useInput('#/properties/stringEmailTest'), 36 | { 37 | wrapper: Wrapper, 38 | } 39 | ) 40 | 41 | const { type } = result.current.getInputProps() 42 | expect(type).toBe('email') 43 | }) 44 | 45 | test('useInput type hostname', () => { 46 | const { result } = renderHook( 47 | () => useInput('#/properties/stringHostnameTest'), 48 | { 49 | wrapper: Wrapper, 50 | } 51 | ) 52 | 53 | const { type } = result.current.getInputProps() 54 | expect(type).toBe('url') 55 | }) 56 | 57 | test('useInput type uri', () => { 58 | const { result } = renderHook(() => useInput('#/properties/stringUriTest'), { 59 | wrapper: Wrapper, 60 | }) 61 | 62 | const { type } = result.current.getInputProps() 63 | expect(type).toBe('url') 64 | }) 65 | -------------------------------------------------------------------------------- /src/components/__tests__/FormContext.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render } from '@vtex/test-tools/react' 2 | import React from 'react' 3 | import { Controller } from 'react-hook-form' 4 | 5 | import { useObject } from '../../hooks/useObject' 6 | import mockSchema from '../__mocks__/mockSchema' 7 | import { FormContext } from '../FormContext' 8 | 9 | const ObjectRenderer = (props: { pointer: string }) => { 10 | const fields = useObject({ pointer: props.pointer }) 11 | 12 | return ( 13 | <> 14 | {fields.map(field => { 15 | const fieldJsonSchema = field.getObject() 16 | 17 | return ( 18 | } 20 | control={field.formContext.control} 21 | defaultValue="" 22 | key={field.pointer} 23 | name={field.pointer} 24 | /> 25 | ) 26 | })} 27 | 28 | ) 29 | } 30 | 31 | test('should call onChange when something changes', () => { 32 | const changeHandlerMock = jest.fn() 33 | 34 | const { getByLabelText } = render( 35 | 36 | 37 | 38 | ) 39 | 40 | expect(changeHandlerMock).toHaveBeenCalledTimes(0) 41 | 42 | let changeValue 43 | 44 | Object.entries(mockSchema.properties).forEach( 45 | ([fieldName, fieldProperties], index) => { 46 | const fieldNumber = index + 1 47 | 48 | const inputElement = getByLabelText(fieldProperties.title) 49 | 50 | const fieldNewValue = `new value for field ${fieldNumber}` 51 | 52 | fireEvent.change(inputElement, { 53 | target: { value: fieldNewValue }, 54 | }) 55 | 56 | expect(changeHandlerMock).toHaveBeenCalledTimes(fieldNumber) 57 | 58 | changeValue = { ...changeValue, [fieldName]: fieldNewValue } 59 | 60 | expect(changeHandlerMock).toHaveBeenLastCalledWith(changeValue) 61 | } 62 | ) 63 | }) 64 | -------------------------------------------------------------------------------- /src/hooks/__tests__/useCheckbox.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { render, wait } from '@vtex/test-tools/react' 3 | 4 | import { useCheckbox } from '../useCheckbox' 5 | import { FormContext } from '../../components' 6 | import mockCheckboxSchema from '../__mocks__/mockSchema' 7 | 8 | const MockCheckbox: FC<{ pointer: string }> = props => { 9 | const methods = useCheckbox(props.pointer) 10 | 11 | return ( 12 | <> 13 | {methods.getItems().map((value, index) => { 14 | return ( 15 | 22 | ) 23 | })} 24 | {methods.getError() &&

This is an error!

} 25 | 26 | ) 27 | } 28 | 29 | test('should have boolean true and false', done => { 30 | const { getByText } = render( 31 | { 34 | expect(data.booleanTest).toBe(true) 35 | done() 36 | }} 37 | > 38 | 39 | 40 | 41 | ) 42 | expect(getByText('test-useSelectBoolean')).toBeDefined() 43 | 44 | getByText('test-useSelectBoolean').click() 45 | getByText('Submit').click() 46 | }) 47 | 48 | test('should raise error', async () => { 49 | const { getByText } = render( 50 | { 53 | return 54 | }} 55 | > 56 | 57 | 58 | 59 | ) 60 | 61 | getByText('Submit').click() 62 | 63 | await wait(() => expect(getByText('This is an error!')).toBeDefined()) 64 | }) 65 | -------------------------------------------------------------------------------- /src/hooks/__tests__/useCustomValidator.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { render, wait, fireEvent } from '@vtex/test-tools/react' 3 | 4 | import { useRawInput } from '../useRawInput' 5 | import { FormContext } from '../../components' 6 | import { useGenericInput } from '../useGenericInput' 7 | import mockRawFormSchema from '../__mocks__/mockTextSchema' 8 | 9 | const MockRawForm: FC<{ pointer: string }> = props => { 10 | const methods = useRawInput(useGenericInput(props.pointer), 'text') 11 | 12 | const error = methods.getError() 13 | return ( 14 | 19 | ) 20 | } 21 | 22 | test('should use custom validator', async () => { 23 | const { getByText, container, getByLabelText, queryByText } = render( 24 | { 27 | return 28 | }} 29 | customValidators={{ 30 | validateNameHelena: value => { 31 | return value === 'Helena' ? true : '__is_not_helena_error__' 32 | }, 33 | }} 34 | > 35 | 36 | 37 | 38 | ) 39 | 40 | expect(container.querySelector('input')).toBeDefined() 41 | expect(container.querySelector('label')).toBeDefined() 42 | expect(getByText('stringTest')).toBeDefined() 43 | 44 | const stringField = getByLabelText('stringTest') 45 | getByText('Submit').click() 46 | fireEvent.change(stringField, { 47 | target: { value: 'Another name' }, 48 | }) 49 | 50 | await wait(() => { 51 | expect(getByText('__is_not_helena_error__')).toBeDefined() 52 | }) 53 | 54 | getByText('Submit').click() 55 | fireEvent.change(stringField, { 56 | target: { value: 'Helena' }, 57 | }) 58 | 59 | await wait(() => { 60 | expect(queryByText('__is_not_helena_error__')).toBeNull() 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /src/hooks/__tests__/useObject.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render, wait } from '@vtex/test-tools/react' 3 | 4 | import { MockObject } from '../../__mocks__/mockObjectComponent' 5 | import { FormContext } from '../../components' 6 | import mockObjectSchema from '../__mocks__/mockSchema' 7 | import { UISchemaType, UITypes } from '../types' 8 | 9 | const mockUISchema: UISchemaType = { 10 | type: UITypes.default, 11 | properties: { 12 | stringTest: { 13 | type: UITypes.radio, 14 | }, 15 | numberTest: { 16 | type: UITypes.select, 17 | }, 18 | }, 19 | } 20 | 21 | test('should render all child properties of the schema', () => { 22 | const { getByText } = render( 23 | 24 | 25 | 26 | ) 27 | 28 | expect(getByText('stringTest')).toBeDefined() 29 | expect(getByText('integerTest')).toBeDefined() 30 | expect(getByText('numberTest')).toBeDefined() 31 | expect(getByText('booleanTest')).toBeDefined() 32 | expect(getByText('errorTest')).toBeDefined() 33 | }) 34 | 35 | test('should raise error', async () => { 36 | const { getByText } = render( 37 | // esling-disable-next-line no-console 38 | { 41 | return 42 | }} 43 | > 44 | 45 | 46 | 47 | ) 48 | 49 | getByText('Submit').click() 50 | 51 | await wait(() => expect(getByText('This is an error!')).toBeDefined()) 52 | }) 53 | 54 | test('ui schema should render number and input as select', async () => { 55 | const { getByText } = render( 56 | 57 | 58 | 59 | ) 60 | 61 | expect(getByText('0')).toBeDefined() 62 | expect(getByText('0.1')).toBeDefined() 63 | expect(getByText('0.2')).toBeDefined() 64 | expect(getByText('0.3')).toBeDefined() 65 | expect(getByText('0.4')).toBeDefined() 66 | expect(getByText('0.5')).toBeDefined() 67 | }) 68 | -------------------------------------------------------------------------------- /example/rollup.config.js: -------------------------------------------------------------------------------- 1 | import childProcess from 'child_process' 2 | import path from 'path' 3 | 4 | import resolve from '@rollup/plugin-node-resolve' 5 | import commonjs from '@rollup/plugin-commonjs' 6 | import livereload from 'rollup-plugin-livereload' 7 | import { terser } from 'rollup-plugin-terser' 8 | import babel from 'rollup-plugin-babel' 9 | import alias from '@rollup/plugin-alias' 10 | import replace from '@rollup/plugin-replace' 11 | 12 | const production = !process.env.ROLLUP_WATCH 13 | 14 | function serve() { 15 | let started = false 16 | 17 | return { 18 | writeBundle() { 19 | if (!started) { 20 | started = true 21 | 22 | childProcess.spawn('npm', ['run', 'start', '--', '--dev'], { 23 | stdio: ['ignore', 'inherit', 'inherit'], 24 | shell: true, 25 | }) 26 | } 27 | }, 28 | } 29 | } 30 | 31 | export default { 32 | input: 'src/index.jsx', 33 | output: { 34 | sourcemap: true, 35 | format: 'iife', 36 | name: 'app', 37 | file: 'public/build/bundle.js', 38 | }, 39 | plugins: [ 40 | alias({ 41 | resolve: ['.jsx', '.js'], 42 | entries: [ 43 | { find: 'react', replacement: path.resolve('node_modules/react') }, 44 | ], 45 | }), 46 | resolve({ 47 | browser: true, 48 | }), 49 | commonjs({ 50 | include: 'node_modules/**', 51 | namedExports: { 52 | // eslint-disable-next-line 53 | react: Object.keys(require('react')), 54 | // eslint-disable-next-line 55 | 'react-dom': Object.keys(require('react-dom')), 56 | }, 57 | }), 58 | babel({ 59 | babelrc: false, 60 | presets: ['@babel/preset-react'], 61 | }), 62 | replace({ 63 | 'process.env.NODE_ENV': production ? '"production"' : '"development"', 64 | }), 65 | 66 | // In dev mode, call `npm run start` once 67 | // the bundle has been generated 68 | !production && serve(), 69 | 70 | // Watch the `public` directory and refresh the 71 | // browser on changes when not in production 72 | !production && livereload('public'), 73 | 74 | // If we're building for production (npm run build 75 | // instead of npm run dev), minify 76 | production && terser(), 77 | ], 78 | watch: { 79 | clearScreen: false, 80 | }, 81 | } 82 | -------------------------------------------------------------------------------- /src/hooks/useGenericInput.ts: -------------------------------------------------------------------------------- 1 | import { FieldError } from 'react-hook-form' 2 | 3 | import { 4 | GenericInputParameters, 5 | BasicInputReturnType, 6 | InputTypes, 7 | } from './types' 8 | import { useFormContext, JSONFormContextValues } from '../components' 9 | import { JSONSubSchemaInfo } from '../JSONSchema' 10 | import { useAnnotatedSchemaFromPointer } from '../JSONSchema/path-handler' 11 | import { getObjectFromForm } from '../JSONSchema/logic' 12 | import { 13 | getError, 14 | getNumberMaximum, 15 | getNumberMinimum, 16 | getNumberStep, 17 | getValidator, 18 | } from './validators' 19 | 20 | export const getGenericInput = ( 21 | formContext: JSONFormContextValues, 22 | subSchemaInfo: JSONSubSchemaInfo, 23 | pointer: string 24 | ): BasicInputReturnType => { 25 | const { JSONSchema, isRequired, objectName } = subSchemaInfo 26 | 27 | let minimum: number | undefined 28 | let maximum: number | undefined 29 | let step: number | 'any' 30 | 31 | if (JSONSchema.type === 'number' || JSONSchema.type === 'integer') { 32 | const stepAndDecimalPlaces = getNumberStep(JSONSchema) 33 | step = stepAndDecimalPlaces[0] 34 | 35 | minimum = getNumberMinimum(JSONSchema) 36 | maximum = getNumberMaximum(JSONSchema) 37 | } 38 | 39 | return { 40 | name: objectName, 41 | pointer: pointer, 42 | isRequired: isRequired, 43 | formContext: formContext, 44 | type: InputTypes.generic, 45 | validator: getValidator(subSchemaInfo, formContext.customValidators ?? {}), 46 | getError: () => 47 | getError( 48 | formContext.errors[pointer] 49 | ? (formContext.errors[pointer] as FieldError) 50 | : undefined, 51 | JSONSchema, 52 | isRequired, 53 | formContext, 54 | pointer, 55 | minimum, 56 | maximum, 57 | step 58 | ), 59 | getObject: () => JSONSchema, 60 | getCurrentValue: () => { 61 | return formContext.getValues()[pointer] 62 | }, 63 | } 64 | } 65 | 66 | export const useGenericInput: GenericInputParameters = pointer => { 67 | const formContext = useFormContext() 68 | const data = getObjectFromForm(formContext.schema, formContext.getValues()) 69 | const subSchemaInfo = useAnnotatedSchemaFromPointer(pointer, data) 70 | return getGenericInput(formContext, subSchemaInfo, pointer) 71 | } 72 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-hook-form-jsonschema", 3 | "version": "0.2.0", 4 | "description": "Wrapper arround react-hook-form to create forms from a JSON schema.", 5 | "main": "output/index.cjs.js", 6 | "module": "output/index.esm.js", 7 | "types": "output/index.d.ts", 8 | "files": [ 9 | "output" 10 | ], 11 | "author": "Helena Steck ", 12 | "license": "MIT", 13 | "devDependencies": { 14 | "@rollup/plugin-commonjs": "^11.0.1", 15 | "@rollup/plugin-node-resolve": "^7.0.0", 16 | "@testing-library/react-hooks": "^7.0.1", 17 | "@types/react": "^16.8.22", 18 | "@types/react-intl": "^2.3.18", 19 | "@vtex/test-tools": "^0.3.2", 20 | "@vtex/tsconfig": "^0.2.0", 21 | "eslint": "^6.8.0", 22 | "eslint-config-vtex-react": "^5.1.0", 23 | "husky": "^4.2.0", 24 | "mutationobserver-shim": "^0.3.3", 25 | "npm-run-all": "^4.1.5", 26 | "prettier": "^1.18.2", 27 | "prop-types": "^15.7.2", 28 | "react": "^16.12.0", 29 | "react-dom": "^16.12.0", 30 | "rollup": "^1.29.0", 31 | "rollup-plugin-terser": "^5.2.0", 32 | "rollup-plugin-typescript2": "^0.25.3", 33 | "typescript": "^3.7.4" 34 | }, 35 | "jest": { 36 | "setupFilesAfterEnv": [ 37 | "./setupTest.js" 38 | ] 39 | }, 40 | "scripts": { 41 | "build": "rm -rf output && rollup -c", 42 | "build:watch": "rollup -cw", 43 | "test": "vtex-test-tools test", 44 | "test:watch": "vtex-test-tools test --watch", 45 | "lint": "tsc --noEmit && eslint --quiet --fix --ext ts,tsx src/", 46 | "prepublishOnly": "run-s lint test build" 47 | }, 48 | "husky": { 49 | "hooks": { 50 | "pre-commit": "run-s lint", 51 | "pre-push": "set -o pipefail && git stash push --keep-index -m 'husky-pre-commit' && run-s test && git stash pop || true" 52 | } 53 | }, 54 | "dependencies": { 55 | "react-hook-form": "^4.4.4" 56 | }, 57 | "peerDependencies": { 58 | "react": "^16.12.0", 59 | "react-dom": "^16.12.0" 60 | }, 61 | "repository": "git@github.com:vtex/react-hook-form-jsonschema.git", 62 | "keywords": [ 63 | "react", 64 | "hooks", 65 | "json-schema", 66 | "jsonschema", 67 | "form", 68 | "form-validation", 69 | "validation", 70 | "typescript", 71 | "react-hooks" 72 | ], 73 | "homepage": "https://github.com/vtex/react-hook-form-jsonschema" 74 | } 75 | -------------------------------------------------------------------------------- /src/JSONSchema/__tests__/testFrozenObject.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render, wait } from '@vtex/test-tools/react' 3 | 4 | import { FormContext } from '../../components' 5 | import { UISchemaType, UITypes } from '../../hooks' 6 | import mockObjectSchema from '../../hooks/__mocks__/mockSchema' 7 | import { MockObject } from '../../__mocks__/mockObjectComponent' 8 | import { deepFreeze } from '../__mocks__/deepFreeze' 9 | 10 | const mockUISchema: UISchemaType = { 11 | type: UITypes.default, 12 | properties: { 13 | numberTest: { 14 | type: UITypes.select, 15 | }, 16 | }, 17 | } 18 | 19 | const frozenSchema = deepFreeze(mockObjectSchema) 20 | test('should render all child properties of the schema', async () => { 21 | const { getByText } = render( 22 | { 25 | return 26 | }} 27 | > 28 | 29 | 30 | 31 | ) 32 | 33 | expect(getByText('stringTest')).toBeDefined() 34 | expect(getByText('integerTest')).toBeDefined() 35 | expect(getByText('numberTest')).toBeDefined() 36 | expect(getByText('booleanTest')).toBeDefined() 37 | expect(getByText('errorTest')).toBeDefined() 38 | 39 | getByText('Submit').click() 40 | 41 | await wait(() => expect(getByText('This is an error!')).toBeDefined()) 42 | }) 43 | 44 | test('should raise error', async () => { 45 | const { getByText } = render( 46 | // esling-disable-next-line no-console 47 | { 50 | return 51 | }} 52 | > 53 | 54 | 55 | 56 | ) 57 | 58 | getByText('Submit').click() 59 | 60 | await wait(() => expect(getByText('This is an error!')).toBeDefined()) 61 | }) 62 | 63 | test('ui schema should render number and input as select', async () => { 64 | const { getByText } = render( 65 | 66 | 67 | 68 | ) 69 | 70 | expect(getByText('0')).toBeDefined() 71 | expect(getByText('0.1')).toBeDefined() 72 | expect(getByText('0.2')).toBeDefined() 73 | expect(getByText('0.3')).toBeDefined() 74 | expect(getByText('0.4')).toBeDefined() 75 | expect(getByText('0.5')).toBeDefined() 76 | }) 77 | -------------------------------------------------------------------------------- /src/hooks/validators/getGenericValidator.ts: -------------------------------------------------------------------------------- 1 | import { ValidationOptions } from 'react-hook-form' 2 | 3 | import { 4 | ErrorTypes, 5 | CustomValidators, 6 | CustomValidatorReturnValue, 7 | } from './types' 8 | import { getNumberValidator } from './getNumberValidator' 9 | import { getStringValidator } from './getStringValidator' 10 | import { JSONSubSchemaInfo } from '../../JSONSchema' 11 | 12 | type GetCustomValidatorReturnType = Record< 13 | string, 14 | (value: string) => CustomValidatorReturnValue 15 | > 16 | 17 | function getCustomValidator( 18 | customValidators: CustomValidators, 19 | context: JSONSubSchemaInfo 20 | ): GetCustomValidatorReturnType { 21 | return Object.keys(customValidators).reduce( 22 | (acc: GetCustomValidatorReturnType, key: string) => { 23 | acc[key] = (value: string) => { 24 | return customValidators[key](value, context) 25 | } 26 | return acc 27 | }, 28 | {} 29 | ) 30 | } 31 | 32 | export const getValidator = ( 33 | context: JSONSubSchemaInfo, 34 | customValidators: CustomValidators 35 | ): ValidationOptions => { 36 | const { JSONSchema, isRequired } = context 37 | 38 | // The use of this variable prevents a strange undocumented behaviour of react-hook-form 39 | // that is it fails to validate if the `validate` field exists but is empty. 40 | const hasValidate = 41 | Object.keys(customValidators).length > 0 || JSONSchema.enum 42 | const validator: ValidationOptions = { 43 | ...(hasValidate 44 | ? { 45 | validate: { 46 | ...getCustomValidator(customValidators, context), 47 | 48 | ...(JSONSchema.enum 49 | ? { 50 | enumValidator: (value: string) => { 51 | if (!JSONSchema.enum || !value) { 52 | return true 53 | } 54 | 55 | for (const item of JSONSchema.enum) { 56 | if (item == value) { 57 | return true 58 | } 59 | } 60 | return ErrorTypes.notInEnum 61 | }, 62 | } 63 | : undefined), 64 | }, 65 | } 66 | : undefined), 67 | } 68 | 69 | if (isRequired) { 70 | validator.required = ErrorTypes.required 71 | } 72 | 73 | switch (JSONSchema.type) { 74 | case 'integer': 75 | case 'number': 76 | return getNumberValidator(JSONSchema, validator) 77 | case 'string': 78 | return getStringValidator(JSONSchema, validator) 79 | case 'boolean': 80 | return validator 81 | default: 82 | return {} 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/components/FormContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, createContext, useContext, useMemo } from 'react' 2 | import { useForm, FieldValues } from 'react-hook-form' 3 | 4 | import { FormContextProps, JSONFormContextValues } from './types' 5 | import { 6 | getObjectFromForm, 7 | getIdSchemaPairs, 8 | resolveRefs, 9 | } from '../JSONSchema/logic' 10 | 11 | export const InternalFormContext = createContext( 12 | null 13 | ) 14 | 15 | export function useFormContext< 16 | T extends FieldValues = FieldValues 17 | >(): JSONFormContextValues { 18 | return useContext(InternalFormContext) as JSONFormContextValues 19 | } 20 | 21 | export const FormContext: FC = props => { 22 | const { 23 | formProps: userFormProps, 24 | onChange, 25 | validationMode = 'onSubmit', 26 | revalidateMode = 'onChange', 27 | submitFocusError = true, 28 | defaultValues, 29 | } = props 30 | 31 | const methods = useForm({ 32 | defaultValues, 33 | mode: validationMode, 34 | reValidateMode: revalidateMode, 35 | submitFocusError: submitFocusError, 36 | }) 37 | 38 | const isFirstRender = React.useRef(true) 39 | 40 | if (typeof onChange === 'function') { 41 | const watchedInputs = methods.watch() 42 | 43 | if (isFirstRender.current === false) { 44 | onChange(getObjectFromForm(props.schema, watchedInputs)) 45 | } 46 | } 47 | 48 | const idMap = useMemo(() => getIdSchemaPairs(props.schema), [props.schema]) 49 | 50 | const resolvedSchemaRefs = useMemo( 51 | () => resolveRefs(props.schema, idMap, []), 52 | [props.schema, idMap] 53 | ) 54 | 55 | const formContext: JSONFormContextValues = useMemo(() => { 56 | return { 57 | ...methods, 58 | schema: resolvedSchemaRefs, 59 | idMap: idMap, 60 | customValidators: props.customValidators, 61 | } 62 | }, [methods, resolvedSchemaRefs, idMap, props.customValidators]) 63 | 64 | const formProps: React.ComponentProps<'form'> = { ...userFormProps } 65 | 66 | formProps.onSubmit = methods.handleSubmit(async (data, event) => { 67 | if (props.onSubmit) { 68 | return props.onSubmit({ 69 | data: getObjectFromForm(props.schema, data), 70 | event: event, 71 | methods: formContext, 72 | }) 73 | } 74 | return 75 | }) 76 | 77 | if (props.noNativeValidate) { 78 | formProps.noValidate = props.noNativeValidate 79 | } 80 | 81 | if (isFirstRender.current === true) { 82 | isFirstRender.current = false 83 | } 84 | 85 | return ( 86 | 87 |
{props.children}
88 |
89 | ) 90 | } 91 | -------------------------------------------------------------------------------- /src/hooks/useRawInput.ts: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { 4 | UseRawInputParameters, 5 | BasicInputReturnType, 6 | UseRawInputReturnType, 7 | InputTypes, 8 | } from './types' 9 | import { 10 | getNumberMaximum, 11 | getNumberMinimum, 12 | getNumberStep, 13 | toFixed, 14 | } from './validators' 15 | 16 | const getInputId = (pointer: string, inputType: string): string => { 17 | return pointer + '-' + inputType + '-input' 18 | } 19 | 20 | const getLabelId = (pointer: string, inputType: string): string => { 21 | return pointer + '-' + inputType + '-label' 22 | } 23 | 24 | export const getRawInputCustomFields = ( 25 | baseInput: BasicInputReturnType, 26 | inputType: string 27 | ): UseRawInputReturnType => { 28 | const { register } = baseInput.formContext 29 | const { validator } = baseInput 30 | 31 | const currentObject = baseInput.getObject() 32 | 33 | let minimum: number | undefined 34 | let maximum: number | undefined 35 | let step: number | 'any' 36 | let decimalPlaces: number | undefined 37 | 38 | const itemProps: React.ComponentProps<'input'> = { key: '' } 39 | if (currentObject.type === 'string') { 40 | itemProps.pattern = currentObject.pattern 41 | itemProps.minLength = currentObject.minLength 42 | itemProps.maxLength = currentObject.maxLength 43 | } else if ( 44 | currentObject.type === 'number' || 45 | currentObject.type === 'integer' 46 | ) { 47 | const stepAndDecimalPlaces = getNumberStep(currentObject) 48 | step = stepAndDecimalPlaces[0] 49 | decimalPlaces = stepAndDecimalPlaces[1] 50 | 51 | minimum = getNumberMinimum(currentObject) 52 | maximum = getNumberMaximum(currentObject) 53 | 54 | itemProps.min = `${minimum}` 55 | itemProps.max = `${maximum}` 56 | itemProps.step = 57 | step === 'any' ? 'any' : toFixed(step, decimalPlaces ? decimalPlaces : 0) 58 | } 59 | 60 | return { 61 | ...baseInput, 62 | type: InputTypes.input, 63 | getLabelProps: () => { 64 | const itemProps: React.ComponentProps<'label'> = {} 65 | itemProps.id = getLabelId(baseInput.pointer, inputType) 66 | itemProps.htmlFor = getInputId(baseInput.pointer, inputType) 67 | 68 | return itemProps 69 | }, 70 | getInputProps: () => { 71 | itemProps.name = baseInput.pointer 72 | itemProps.ref = register(validator) 73 | itemProps.type = inputType 74 | itemProps.required = baseInput.isRequired 75 | itemProps.id = getInputId(baseInput.pointer, inputType) 76 | 77 | return itemProps 78 | }, 79 | } 80 | } 81 | 82 | export const useRawInput: UseRawInputParameters = (baseObject, inputType) => { 83 | return getRawInputCustomFields(baseObject, inputType) 84 | } 85 | -------------------------------------------------------------------------------- /src/hooks/__tests__/useTextArea.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { render, wait, fireEvent } from '@vtex/test-tools/react' 3 | 4 | import { useTextArea } from '../useTextArea' 5 | import { FormContext } from '../../components' 6 | import mockTextAreaSchema from '../__mocks__/mockTextSchema' 7 | 8 | const MockTextArea: FC<{ pointer: string }> = props => { 9 | const methods = useTextArea(props.pointer) 10 | 11 | return ( 12 | <> 13 | 14 |