├── 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 |
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 |
15 | {methods.getError() && This is an error!
}
16 | >
17 | )
18 | }
19 |
20 | test('should have string enum items', () => {
21 | const { getByText, container } = render(
22 | {
25 | return
26 | }}
27 | >
28 |
29 |
30 |
31 | )
32 |
33 | expect(container.querySelector('input')).toBeDefined()
34 | expect(container.querySelector('textarea')).toBeDefined()
35 | expect(getByText('stringTest')).toBeDefined()
36 | })
37 |
38 | test('should have all integers in interval', () => {
39 | const { getByText, container } = render(
40 | {
43 | return
44 | }}
45 | >
46 |
47 |
48 |
49 | )
50 |
51 | expect(container.querySelector('input')).toBeDefined()
52 | expect(container.querySelector('label')).toBeDefined()
53 | expect(getByText('integerTest')).toBeDefined()
54 | })
55 |
56 | test('should have all floats in interval, separated by step', () => {
57 | const { getByText, container } = render(
58 |
59 |
60 |
61 | )
62 |
63 | expect(container.querySelector('input')).toBeDefined()
64 | expect(container.querySelector('label')).toBeDefined()
65 | expect(getByText('numberTest')).toBeDefined()
66 | })
67 |
68 | test('should raise error', async () => {
69 | const { getByLabelText, getByText } = render(
70 | {
73 | return
74 | }}
75 | >
76 |
77 |
78 |
79 | )
80 |
81 | getByText('Submit').click()
82 | fireEvent.change(getByLabelText('errorTest'), { target: { value: 'a' } })
83 |
84 | await wait(() => {
85 | expect(getByText('This is an error!')).toBeDefined()
86 | })
87 | })
88 |
--------------------------------------------------------------------------------
/src/hooks/__tests__/useRawInput.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 | return (
13 |
18 | )
19 | }
20 |
21 | test('should have string enum items', () => {
22 | const { getByText, container } = render(
23 | {
26 | return
27 | }}
28 | >
29 |
30 |
31 |
32 | )
33 |
34 | expect(container.querySelector('input')).toBeDefined()
35 | expect(container.querySelector('label')).toBeDefined()
36 | expect(getByText('stringTest')).toBeDefined()
37 | })
38 |
39 | test('should have all integers in interval', () => {
40 | const { getByText, container } = render(
41 | {
44 | return
45 | }}
46 | >
47 |
48 |
49 |
50 | )
51 |
52 | expect(container.querySelector('input')).toBeDefined()
53 | expect(container.querySelector('label')).toBeDefined()
54 | expect(getByText('integerTest')).toBeDefined()
55 | })
56 |
57 | test('should have all floats in interval, separated by step', () => {
58 | const { getByText, container } = render(
59 |
60 |
61 |
62 | )
63 |
64 | expect(container.querySelector('input')).toBeDefined()
65 | expect(container.querySelector('label')).toBeDefined()
66 | expect(getByText('numberTest')).toBeDefined()
67 | })
68 |
69 | test('should raise error', async () => {
70 | const { getByLabelText, getByText } = render(
71 | {
74 | return
75 | }}
76 | >
77 |
78 |
79 |
80 | )
81 |
82 | getByText('Submit').click()
83 | fireEvent.change(getByLabelText('errorTest'), { target: { value: 'a' } })
84 |
85 | await wait(() => {
86 | expect(getByText('This is an error!')).toBeDefined()
87 | })
88 | })
89 |
--------------------------------------------------------------------------------
/src/JSONSchema/types/index.ts:
--------------------------------------------------------------------------------
1 | export type JSONSchemaType =
2 | | ArrayJSONSchemaType
3 | | BasicJSONSchemaType
4 | | BooleanJSONSchemaType
5 | | NumberJSONSchemaType
6 | | ObjectJSONSchemaType
7 | | StringJSONSchemaType
8 | | NullJSONSchemaType
9 |
10 | export interface BasicJSONSchemaType {
11 | type?: string
12 | title?: string
13 | description?: string
14 | $comment?: string
15 | $schema?: string
16 | $id?: string
17 | $ref?: string
18 | anyOf?: JSONSchemaType[]
19 | allOf?: JSONSchemaType[]
20 | oneOf?: JSONSchemaType[]
21 | not?: JSONSchemaType[]
22 | enum?: JSONSchemaBaseInstanceTypes[]
23 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
24 | const?: any
25 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
26 | default?: any
27 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
28 | examples?: any
29 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
30 | [key: string]: any
31 | }
32 |
33 | export type PropertyDependencies = Record
34 | export type SchemaDependencies = JSONSchemaType
35 | export interface ObjectJSONSchemaType extends BasicJSONSchemaType {
36 | type?: 'object'
37 | properties?: Record
38 | additionalProperties?: boolean
39 | required?: string[]
40 | propertyNames?: StringJSONSchemaType
41 | minProperties?: number
42 | maxProperties?: number
43 | dependencies?: PropertyDependencies | SchemaDependencies
44 | patternProperties?: Record
45 | }
46 |
47 | export interface StringJSONSchemaType extends BasicJSONSchemaType {
48 | type?: 'string'
49 | minLength?: number
50 | maxLength?: number
51 | pattern?: string
52 | format?: string
53 | contentMediaType?: string
54 | contentEncoding?: string
55 | }
56 |
57 | export interface NumberJSONSchemaType extends BasicJSONSchemaType {
58 | type?: 'number' | 'integer'
59 | multipleOf?: number
60 | minimum?: number
61 | exclusiveMinimum?: number
62 | maximum?: number
63 | exclusiveMaximum?: number
64 | }
65 |
66 | export interface ArrayJSONSchemaType extends BasicJSONSchemaType {
67 | type?: 'array'
68 | items?: JSONSchemaType | JSONSchemaType[]
69 | additionalItems?: boolean | JSONSchemaType
70 | contains?: JSONSchemaType
71 | minItems?: number
72 | maxItems?: number
73 | uniqueItems?: boolean
74 | }
75 |
76 | export interface BooleanJSONSchemaType extends BasicJSONSchemaType {
77 | type?: 'boolean'
78 | }
79 |
80 | export interface NullJSONSchemaType extends BasicJSONSchemaType {
81 | type?: 'null'
82 | }
83 |
84 | export type JSONSchemaBaseInstanceTypes = boolean | string | number | null
85 |
86 | export type JSONSubSchemaInfo = {
87 | JSONSchema: JSONSchemaType
88 | isRequired: boolean
89 | objectName: string
90 | invalidPointer: boolean
91 | pointer: string
92 | }
93 |
94 | export type IDSchemaPair = Record
95 |
--------------------------------------------------------------------------------
/src/hooks/validators/getError.ts:
--------------------------------------------------------------------------------
1 | import { FieldError, FormContextValues } from 'react-hook-form'
2 |
3 | import { ErrorTypes, ErrorMessage } from './types'
4 | import { JSONSchemaType } from '../../JSONSchema'
5 |
6 | export const getError = (
7 | errors: FieldError | undefined,
8 | currentObject: JSONSchemaType,
9 | isRequired: boolean,
10 | formContext: FormContextValues,
11 | pointer: string,
12 | minimum?: number,
13 | maximum?: number,
14 | step?: number | 'any'
15 | ): ErrorMessage => {
16 | // This is a special element to check errors against
17 | if (currentObject.type === 'array') {
18 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
19 | const currentValues: any[] | undefined = formContext.getValues({
20 | nest: true,
21 | })[pointer]
22 |
23 | if (currentValues) {
24 | const numberOfSelected =
25 | currentValues.filter(x => x !== false).length || 0
26 | if (currentObject.minItems && numberOfSelected < currentObject.minItems) {
27 | return {
28 | message: ErrorTypes.minLength,
29 | expected: currentObject.minItems,
30 | }
31 | } else if (
32 | currentObject.maxItems &&
33 | numberOfSelected > currentObject.maxItems
34 | ) {
35 | return {
36 | message: ErrorTypes.maxLength,
37 | expected: currentObject.maxItems,
38 | }
39 | }
40 | }
41 | }
42 |
43 | if (!errors) {
44 | return undefined
45 | }
46 |
47 | const retError: ErrorMessage = {
48 | message:
49 | typeof errors.message == 'string'
50 | ? errors.message
51 | : ErrorTypes.undefinedError,
52 | expected: undefined,
53 | }
54 |
55 | switch (errors.message) {
56 | case ErrorTypes.required:
57 | retError.message = ErrorTypes.required
58 | retError.expected = isRequired
59 | break
60 | case ErrorTypes.maxLength:
61 | retError.message = ErrorTypes.maxLength
62 | retError.expected = currentObject.maxLength
63 | break
64 | case ErrorTypes.minLength:
65 | retError.message = ErrorTypes.minLength
66 | retError.expected = currentObject.minLength
67 | break
68 | case ErrorTypes.maxValue:
69 | retError.message = ErrorTypes.maxValue
70 | retError.expected = maximum
71 | break
72 | case ErrorTypes.minValue:
73 | retError.message = ErrorTypes.minValue
74 | retError.expected = minimum
75 | break
76 | case ErrorTypes.multipleOf:
77 | retError.message = ErrorTypes.multipleOf
78 | retError.expected = step
79 | break
80 | case ErrorTypes.pattern:
81 | retError.message = ErrorTypes.pattern
82 | retError.expected = currentObject.pattern
83 | break
84 | case ErrorTypes.notInEnum:
85 | retError.message = ErrorTypes.notInEnum
86 | retError.expected = currentObject.enum
87 | }
88 | return retError
89 | }
90 |
--------------------------------------------------------------------------------
/src/hooks/__tests__/useRadio.test.tsx:
--------------------------------------------------------------------------------
1 | import React, { FC } from 'react'
2 | import { render, wait } from '@vtex/test-tools/react'
3 |
4 | import { useRadio } from '../useRadio'
5 | import { FormContext } from '../../components'
6 | import mockRadioSchema, { toFixed } from '../__mocks__/mockSchema'
7 |
8 | const MockRadio: FC<{ pointer: string }> = props => {
9 | const methods = useRadio(props.pointer)
10 |
11 | return (
12 |
23 | )
24 | }
25 |
26 | test('should have string enum items', () => {
27 | const { getByText } = render(
28 |
29 |
30 |
31 | )
32 |
33 | for (const item of mockRadioSchema.properties.stringTest.enum) {
34 | expect(getByText(item)).toBeDefined()
35 | }
36 | })
37 |
38 | test('should have all integers in interval', () => {
39 | const { getByText } = render(
40 |
41 |
42 |
43 | )
44 |
45 | expect(getByText('0')).toBeDefined()
46 | expect(getByText('2')).toBeDefined()
47 | expect(getByText('4')).toBeDefined()
48 | expect(getByText('6')).toBeDefined()
49 | })
50 |
51 | test('should have all floats in interval, separated by step', () => {
52 | const { getByText } = render(
53 |
54 |
55 |
56 | )
57 |
58 | expect(getByText('0')).toBeDefined()
59 | expect(getByText('0.1')).toBeDefined()
60 | expect(getByText('0.2')).toBeDefined()
61 | expect(getByText('0.3')).toBeDefined()
62 | expect(getByText('0.4')).toBeDefined()
63 | expect(getByText('0.5')).toBeDefined()
64 | })
65 |
66 | test('should have boolean true and false', () => {
67 | const { getByText } = render(
68 |
69 |
70 |
71 | )
72 |
73 | expect(getByText('true')).toBeDefined()
74 | expect(getByText('false')).toBeDefined()
75 | })
76 |
77 | test('should raise error', async () => {
78 | const { getByText } = render(
79 | {
82 | return
83 | }}
84 | >
85 |
86 |
87 |
88 | )
89 |
90 | getByText('Submit').click()
91 |
92 | await wait(() => expect(getByText('This is an error!')).toBeDefined())
93 | })
94 |
--------------------------------------------------------------------------------
/src/hooks/useSelect.ts:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import {
4 | UseSelectParameters,
5 | BasicInputReturnType,
6 | UseSelectReturnType,
7 | InputTypes,
8 | } from './types'
9 | import {
10 | getNumberMaximum,
11 | getNumberMinimum,
12 | getNumberStep,
13 | toFixed,
14 | } from './validators'
15 | import { useGenericInput } from './useGenericInput'
16 | import { getEnumAsStringArray } from './validators/getEnum'
17 |
18 | const getSelectId = (pointer: string): string => {
19 | return pointer + '-select'
20 | }
21 |
22 | const getOptionId = (
23 | pointer: string,
24 | index: number,
25 | items: string[]
26 | ): string => {
27 | return pointer + '-select-option-' + (items[index] ? items[index] : '')
28 | }
29 |
30 | export const getSelectCustomFields = (
31 | baseInput: BasicInputReturnType
32 | ): UseSelectReturnType => {
33 | const { register } = baseInput.formContext
34 | const { validator } = baseInput
35 |
36 | const currentObject = baseInput.getObject()
37 |
38 | let items: string[] = ['']
39 | let minimum: number | undefined
40 | let maximum: number | undefined
41 | let step: number | 'any'
42 | let decimalPlaces: number | undefined
43 |
44 | if (currentObject.type === 'string') {
45 | items = items.concat(getEnumAsStringArray(currentObject))
46 | } else if (
47 | currentObject.type === 'number' ||
48 | currentObject.type === 'integer'
49 | ) {
50 | const stepAndDecimalPlaces = getNumberStep(currentObject)
51 | step = stepAndDecimalPlaces[0]
52 | decimalPlaces = stepAndDecimalPlaces[1]
53 |
54 | minimum = getNumberMinimum(currentObject)
55 | maximum = getNumberMaximum(currentObject)
56 |
57 | if (minimum !== undefined && maximum !== undefined && step != 'any') {
58 | for (let i = minimum; i <= maximum; i += step) {
59 | items.push(toFixed(i, decimalPlaces ? decimalPlaces : 0))
60 | }
61 | }
62 | } else if (currentObject.type === 'boolean') {
63 | items = ['true', 'false']
64 | }
65 |
66 | return {
67 | ...baseInput,
68 | type: InputTypes.select,
69 | validator,
70 | getLabelProps: () => {
71 | const labelProps: React.ComponentProps<'label'> = {}
72 | labelProps.id = baseInput.pointer + '-label'
73 | labelProps.htmlFor = getSelectId(baseInput.pointer)
74 |
75 | return labelProps
76 | },
77 | getSelectProps: () => {
78 | const itemProps: React.ComponentProps<'select'> = {}
79 | itemProps.name = baseInput.pointer
80 | itemProps.ref = register(validator)
81 | itemProps.required = baseInput.isRequired
82 | itemProps.id = getSelectId(baseInput.pointer)
83 |
84 | return itemProps
85 | },
86 | getItemOptionProps: index => {
87 | const itemProps: React.ComponentProps<'option'> = {}
88 | itemProps.id = getOptionId(baseInput.pointer, index, items)
89 | itemProps.value = items[index]
90 |
91 | return itemProps
92 | },
93 | getItems: () => items,
94 | }
95 | }
96 |
97 | export const useSelect: UseSelectParameters = pointer => {
98 | return getSelectCustomFields(useGenericInput(pointer))
99 | }
100 |
--------------------------------------------------------------------------------
/src/hooks/__tests__/useSelect.test.tsx:
--------------------------------------------------------------------------------
1 | import React, { FC } from 'react'
2 | import { render, wait } from '@vtex/test-tools/react'
3 |
4 | import { useSelect } from '../useSelect'
5 | import { FormContext } from '../../components'
6 | import mockSelectSchema, { toFixed } from '../__mocks__/mockSchema'
7 |
8 | const MockSelect: FC<{ pointer: string }> = props => {
9 | const methods = useSelect(props.pointer)
10 |
11 | return (
12 | <>
13 | {methods.name}
14 |
26 | {methods.getError() && This is an error!
}
27 | >
28 | )
29 | }
30 |
31 | test('should have string enum items', () => {
32 | const { getByText } = render(
33 |
34 |
35 |
36 | )
37 |
38 | for (const item of mockSelectSchema.properties.stringTest.enum) {
39 | expect(getByText(item)).toBeDefined()
40 | }
41 | })
42 |
43 | test('should have all integers in interval', () => {
44 | const { getByText } = render(
45 |
46 |
47 |
48 | )
49 |
50 | expect(getByText('0')).toBeDefined()
51 | expect(getByText('2')).toBeDefined()
52 | expect(getByText('4')).toBeDefined()
53 | expect(getByText('6')).toBeDefined()
54 | })
55 |
56 | test('should have all floats in interval, separated by step', () => {
57 | const { getByText } = render(
58 |
59 |
60 |
61 | )
62 |
63 | expect(getByText('0')).toBeDefined()
64 | expect(getByText('0.1')).toBeDefined()
65 | expect(getByText('0.2')).toBeDefined()
66 | expect(getByText('0.3')).toBeDefined()
67 | expect(getByText('0.4')).toBeDefined()
68 | expect(getByText('0.5')).toBeDefined()
69 | })
70 |
71 | test('should have boolean true and false', () => {
72 | const { getByText } = render(
73 |
74 |
75 |
76 | )
77 |
78 | expect(getByText('true')).toBeDefined()
79 | expect(getByText('false')).toBeDefined()
80 | })
81 |
82 | test('should raise error', async () => {
83 | const { getByText } = render(
84 | // esling-disable-next-line no-console
85 | {
88 | return
89 | }}
90 | >
91 |
92 |
93 |
94 | )
95 |
96 | getByText('Submit').click()
97 |
98 | await wait(() => expect(getByText('This is an error!')).toBeDefined())
99 | })
100 |
--------------------------------------------------------------------------------
/src/__mocks__/mockObjectComponent.tsx:
--------------------------------------------------------------------------------
1 | import React, { FC } from 'react'
2 |
3 | import {
4 | useObject,
5 | UseRawInputReturnType,
6 | InputReturnTypes,
7 | InputTypes,
8 | UseRadioReturnType,
9 | UseSelectReturnType,
10 | UISchemaType,
11 | UseCheckboxReturnType,
12 | } from '../hooks'
13 |
14 | const SpecializedObject: FC<{ baseObject: InputReturnTypes }> = props => {
15 | switch (props.baseObject.type) {
16 | case InputTypes.input: {
17 | const inputObject = props.baseObject as UseRawInputReturnType
18 | return (
19 | <>
20 | {inputObject.name}
21 |
22 | >
23 | )
24 | }
25 | case InputTypes.radio: {
26 | const radioObject = props.baseObject as UseRadioReturnType
27 | return (
28 | <>
29 | {radioObject.name}
30 | {radioObject.getItems().map((value, index) => {
31 | return (
32 |
36 | {value}
37 |
38 |
39 | )
40 | })}
41 | >
42 | )
43 | }
44 | case InputTypes.select: {
45 | const selectObject = props.baseObject as UseSelectReturnType
46 | return (
47 | <>
48 | {selectObject.name}
49 |
61 | >
62 | )
63 | }
64 | case InputTypes.checkbox: {
65 | const checkboxObject = props.baseObject as UseCheckboxReturnType
66 | return (
67 | <>
68 | {checkboxObject.getItems().map((value, index) => {
69 | return (
70 |
74 | {checkboxObject.isSingle ? checkboxObject.name : value}
75 |
76 |
77 | )
78 | })}
79 | {checkboxObject.getError() && This is an error!
}
80 | >
81 | )
82 | }
83 | }
84 | return <>>
85 | }
86 |
87 | export const MockObject: FC<{
88 | pointer: string
89 | UISchema?: UISchemaType
90 | }> = props => {
91 | const methods = useObject({
92 | pointer: props.pointer,
93 | UISchema: props.UISchema,
94 | })
95 |
96 | return (
97 | <>
98 | {methods.map(obj => (
99 |
100 |
101 | {obj.getError() &&
This is an error!
}
102 |
103 | ))}
104 | >
105 | )
106 | }
107 |
--------------------------------------------------------------------------------
/src/hooks/useRadio.ts:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import {
4 | UseRadioParameters,
5 | BasicInputReturnType,
6 | UseRadioReturnType,
7 | InputTypes,
8 | } from './types'
9 | import {
10 | getNumberMaximum,
11 | getNumberMinimum,
12 | getNumberStep,
13 | toFixed,
14 | } from './validators'
15 | import { useGenericInput } from './useGenericInput'
16 | import { getEnumAsStringArray } from './validators/getEnum'
17 |
18 | const getItemInputId = (
19 | pointer: string,
20 | index: number,
21 | items: string[]
22 | ): string => {
23 | return pointer + '-radio-input-' + (items[index] ? items[index] : '')
24 | }
25 |
26 | const getItemLabelId = (
27 | pointer: string,
28 | index: number,
29 | items: string[]
30 | ): string => {
31 | return pointer + '-radio-label-' + (items[index] ? items[index] : '')
32 | }
33 |
34 | export const getRadioCustomFields = (
35 | baseInput: BasicInputReturnType
36 | ): UseRadioReturnType => {
37 | const { register } = baseInput.formContext
38 | const { validator } = baseInput
39 |
40 | const currentObject = baseInput.getObject()
41 |
42 | let items: string[] = []
43 | let minimum: number | undefined
44 | let maximum: number | undefined
45 | let step: number | 'any'
46 | let decimalPlaces: number | undefined
47 |
48 | if (currentObject.type === 'string') {
49 | items = getEnumAsStringArray(currentObject)
50 | } else if (
51 | currentObject.type === 'number' ||
52 | currentObject.type === 'integer'
53 | ) {
54 | const stepAndDecimalPlaces = getNumberStep(currentObject)
55 | step = stepAndDecimalPlaces[0]
56 | decimalPlaces = stepAndDecimalPlaces[1]
57 |
58 | minimum = getNumberMinimum(currentObject)
59 | maximum = getNumberMaximum(currentObject)
60 |
61 | if (minimum !== undefined && maximum !== undefined && step != 'any') {
62 | for (let i = minimum; i <= maximum; i += step) {
63 | items.push(toFixed(i, decimalPlaces ? decimalPlaces : 0))
64 | }
65 | }
66 | } else if (currentObject.type === 'boolean') {
67 | items = ['true', 'false']
68 | }
69 |
70 | return {
71 | ...baseInput,
72 | type: InputTypes.radio,
73 | getLabelProps: () => {
74 | const labelProps: React.ComponentProps<'label'> = {}
75 | labelProps.id = baseInput.pointer + '-label'
76 | labelProps.htmlFor =
77 | currentObject.title !== undefined
78 | ? currentObject.title
79 | : baseInput.pointer
80 | return labelProps
81 | },
82 | getItemInputProps: index => {
83 | const itemProps: React.ComponentProps<'input'> = { key: '' }
84 | itemProps.name = baseInput.pointer
85 | itemProps.ref = register(validator)
86 | itemProps.type = 'radio'
87 | itemProps.required = baseInput.isRequired
88 | itemProps.id = getItemInputId(baseInput.pointer, index, items)
89 | itemProps.value = items[index]
90 |
91 | return itemProps
92 | },
93 | getItemLabelProps: index => {
94 | const itemProps: React.ComponentProps<'label'> = {}
95 | itemProps.id = getItemLabelId(baseInput.pointer, index, items)
96 | itemProps.htmlFor = getItemInputId(baseInput.pointer, index, items)
97 |
98 | return itemProps
99 | },
100 | getItems: () => items,
101 | }
102 | }
103 |
104 | export const useRadio: UseRadioParameters = pointer => {
105 | return getRadioCustomFields(useGenericInput(pointer))
106 | }
107 |
--------------------------------------------------------------------------------
/src/hooks/useCheckbox.ts:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import {
4 | UseCheckboxParameters,
5 | BasicInputReturnType,
6 | UseCheckboxReturnType,
7 | InputTypes,
8 | } from './types'
9 | import {
10 | getNumberMaximum,
11 | getNumberMinimum,
12 | getNumberStep,
13 | toFixed,
14 | } from './validators'
15 | import { useGenericInput } from './useGenericInput'
16 | import { getEnumAsStringArray } from './validators/getEnum'
17 |
18 | const getItemInputId = (
19 | path: string,
20 | index: number,
21 | items: string[]
22 | ): string => {
23 | return path + '-checkbox-input-' + (items[index] ? items[index] : '')
24 | }
25 |
26 | const getItemLabelId = (
27 | path: string,
28 | index: number,
29 | items: string[]
30 | ): string => {
31 | return path + '-checkbox-label-' + (items[index] ? items[index] : '')
32 | }
33 |
34 | export const getCheckboxCustomFields = (
35 | baseInput: BasicInputReturnType
36 | ): UseCheckboxReturnType => {
37 | const { register } = baseInput.formContext
38 | const { validator } = baseInput
39 |
40 | const currentObject = baseInput.getObject()
41 |
42 | let items: string[] = []
43 | let minimum: number | undefined
44 | let maximum: number | undefined
45 | let step: number | 'any'
46 | let decimalPlaces: number | undefined
47 |
48 | if (currentObject.type === 'array') {
49 | if (currentObject.items.enum) {
50 | items = getEnumAsStringArray(currentObject.items)
51 | } else if (currentObject.items.type === 'string') {
52 | items = getEnumAsStringArray(currentObject)
53 | } else if (
54 | currentObject.items.type === 'number' ||
55 | currentObject.items.type === 'integer'
56 | ) {
57 | const stepAndDecimalPlaces = getNumberStep(currentObject)
58 | step = stepAndDecimalPlaces[0]
59 | decimalPlaces = stepAndDecimalPlaces[1]
60 |
61 | minimum = getNumberMinimum(currentObject)
62 | maximum = getNumberMaximum(currentObject)
63 |
64 | if (minimum !== undefined && maximum !== undefined && step != 'any') {
65 | for (let i = minimum; i <= maximum; i += step) {
66 | items.push(toFixed(i, decimalPlaces || 0))
67 | }
68 | }
69 | }
70 |
71 | if (currentObject.uniqueItems) {
72 | items = [...new Set(items)]
73 | }
74 | } else if (currentObject.type === 'boolean') {
75 | items = ['true']
76 | }
77 |
78 | return {
79 | ...baseInput,
80 | type: InputTypes.checkbox,
81 | isSingle: currentObject.type === 'boolean',
82 | getItemInputProps: index => {
83 | const itemProps: React.ComponentProps<'input'> = { key: '' }
84 | // This ternary decides wether to treat the input as an array or not
85 | itemProps.name =
86 | currentObject.type === 'array'
87 | ? `${baseInput.pointer}[${index}]`
88 | : baseInput.pointer
89 | itemProps.ref = register(validator)
90 | itemProps.type = 'checkbox'
91 | itemProps.id = getItemInputId(baseInput.pointer, index, items)
92 | itemProps.value = items[index]
93 |
94 | return itemProps
95 | },
96 | getItemLabelProps: index => {
97 | const itemProps: React.ComponentProps<'label'> = {}
98 | itemProps.id = getItemLabelId(baseInput.pointer, index, items)
99 | itemProps.htmlFor = getItemInputId(baseInput.pointer, index, items)
100 |
101 | return itemProps
102 | },
103 | getItems: () => items,
104 | }
105 | }
106 |
107 | export const useCheckbox: UseCheckboxParameters = path => {
108 | return getCheckboxCustomFields(useGenericInput(path))
109 | }
110 |
--------------------------------------------------------------------------------
/src/hooks/types/index.ts:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { ValidationOptions, FieldValues } from 'react-hook-form'
3 |
4 | import { ErrorMessage } from '../validators'
5 | import { JSONSchemaType } from '../../JSONSchema'
6 | import { JSONFormContextValues } from '../../components'
7 |
8 | export enum InputTypes {
9 | generic = 'generic',
10 | radio = 'radio',
11 | select = 'select',
12 | input = 'input',
13 | textArea = 'textArea',
14 | checkbox = 'checkbox',
15 | }
16 |
17 | export enum UITypes {
18 | default = 'default',
19 | radio = 'radio',
20 | select = 'select',
21 | input = 'input',
22 | hidden = 'hidden',
23 | password = 'password',
24 | textArea = 'textArea',
25 | checkbox = 'checkbox',
26 | }
27 |
28 | export interface BasicInputReturnType {
29 | getError(): ErrorMessage
30 | getObject(): JSONSchemaType
31 | getCurrentValue(): FieldValues
32 | formContext: JSONFormContextValues
33 | isRequired: boolean
34 | name: string
35 | type: InputTypes
36 | pointer: string
37 | validator: ValidationOptions
38 | }
39 |
40 | export interface GenericInputParameters {
41 | (pointer: string): BasicInputReturnType
42 | }
43 |
44 | export interface UseRadioReturnType extends BasicInputReturnType {
45 | getLabelProps(): React.ComponentProps<'label'>
46 | getItems(): string[]
47 | getItemInputProps(index: number): React.ComponentProps<'input'>
48 | getItemLabelProps(index: number): React.ComponentProps<'label'>
49 | }
50 |
51 | export interface UseRadioParameters {
52 | (pointer: string): UseRadioReturnType
53 | }
54 |
55 | export interface UseCheckboxReturnType extends BasicInputReturnType {
56 | getItems(): string[]
57 | getItemInputProps(index: number): React.ComponentProps<'input'>
58 | getItemLabelProps(index: number): React.ComponentProps<'label'>
59 | isSingle: boolean
60 | }
61 |
62 | export interface UseCheckboxParameters {
63 | (pointer: string): UseCheckboxReturnType
64 | }
65 |
66 | export interface UseSelectReturnType extends BasicInputReturnType {
67 | type: InputTypes.select
68 | getLabelProps(): React.ComponentProps<'label'>
69 | getItemOptionProps(index: number): React.ComponentProps<'option'>
70 | getItems(): string[]
71 | getSelectProps(): React.ComponentProps<'select'>
72 | }
73 |
74 | export interface UseSelectParameters {
75 | (pointer: string): UseSelectReturnType
76 | }
77 |
78 | export interface UseRawInputReturnType extends BasicInputReturnType {
79 | getLabelProps(): React.ComponentProps<'label'>
80 | getInputProps(): React.ComponentProps<'input'>
81 | }
82 |
83 | export interface UseRawInputParameters {
84 | (baseObject: BasicInputReturnType, inputType: string): UseRawInputReturnType
85 | }
86 |
87 | export interface UseInputParameters {
88 | (pointer: string): UseRawInputReturnType
89 | }
90 |
91 | export interface UseTextAreaReturnType extends BasicInputReturnType {
92 | getLabelProps(): React.ComponentProps<'label'>
93 | getTextAreaProps(): React.ComponentProps<'textarea'>
94 | }
95 |
96 | export interface UseTextAreaParameters {
97 | (pointer: string): UseTextAreaReturnType
98 | }
99 |
100 | export type InputReturnTypes =
101 | | UseRawInputReturnType
102 | | UseTextAreaReturnType
103 | | UseSelectReturnType
104 | | UseRadioReturnType
105 | | UseCheckboxReturnType
106 |
107 | export type UseObjectReturnType = InputReturnTypes[]
108 |
109 | export type UISchemaType = {
110 | type: UITypes
111 | properties?: {
112 | [key: string]: UISchemaType
113 | }
114 | }
115 |
116 | export type UseObjectParameters = { pointer: string; UISchema?: UISchemaType }
117 |
118 | export interface UseObjectProperties {
119 | (props: UseObjectParameters): UseObjectReturnType
120 | }
121 |
--------------------------------------------------------------------------------
/src/JSONSchema/logic/refHandlers.ts:
--------------------------------------------------------------------------------
1 | import { JSONSchemaType, IDSchemaPair } from '../types'
2 | import {
3 | getSplitPointer,
4 | concatFormPointer,
5 | JSONSchemaRootPointer,
6 | } from './pathUtils'
7 |
8 | const absoluteRegExp = /^[a-z][a-z0-9+.-]*:/i
9 | const isAbsoluteURI = (uri: string) => {
10 | return absoluteRegExp.test(uri)
11 | }
12 |
13 | const fragmentRegExp = /^#(\/(([^#/~])|(~[01]))*)*/i
14 | const isURIFragmentPointer = (pointer: string) => {
15 | return fragmentRegExp.test(pointer)
16 | }
17 |
18 | export const getSchemaFromRef = (
19 | $ref: string,
20 | IDRecord: Record,
21 | schema?: JSONSchemaType
22 | ): JSONSchemaType => {
23 | if (isAbsoluteURI($ref)) {
24 | const baseUrl = new URL($ref)
25 |
26 | if (baseUrl.hash) {
27 | return getSchemaFromRef(
28 | baseUrl.hash,
29 | IDRecord,
30 | IDRecord[`${baseUrl.origin}${baseUrl.pathname}`]
31 | )
32 | }
33 | } else if (isURIFragmentPointer($ref) && schema) {
34 | return getSplitPointer($ref).reduce(
35 | (currentSchema: JSONSchemaType, pointer: string) => {
36 | if (currentSchema) {
37 | return currentSchema[pointer]
38 | }
39 | },
40 | schema
41 | )
42 | }
43 |
44 | return IDRecord[$ref]
45 | }
46 |
47 | export const resolveRefs = (
48 | schema: JSONSchemaType,
49 | idMap: IDSchemaPair,
50 | usedRefs: string[]
51 | ): JSONSchemaType => {
52 | let resolvedRefs: JSONSchemaType = {}
53 |
54 | if (schema.$ref) {
55 | const $ref = schema.$ref
56 |
57 | if (usedRefs.indexOf($ref) > -1) {
58 | return resolvedRefs
59 | }
60 | usedRefs.push($ref)
61 |
62 | resolvedRefs = {
63 | ...getSchemaFromRef($ref, idMap),
64 | }
65 | } else {
66 | resolvedRefs = { ...schema }
67 | }
68 |
69 | return Object.keys(resolvedRefs).reduce(
70 | (acc: JSONSchemaType, key: string) => {
71 | if (
72 | typeof acc[key] == 'object' &&
73 | acc[key] !== null &&
74 | !Array.isArray(acc[key]) &&
75 | !(usedRefs.indexOf(acc[key].$ref) > -1)
76 | ) {
77 | acc[key] = resolveRefs(acc[key], idMap, usedRefs.slice())
78 | }
79 | return acc
80 | },
81 | resolvedRefs
82 | )
83 | }
84 |
85 | export const getIdSchemaPairs = (schema: JSONSchemaType) => {
86 | const recursiveGetIdSchemaPairs = (
87 | currentPointer: string,
88 | currentSchema: JSONSchemaType,
89 | baseUrl: URL | undefined
90 | ): Record => {
91 | return Object.keys(currentSchema).reduce(
92 | (IDs: Record, key: string) => {
93 | if (
94 | typeof currentSchema[key] == 'object' &&
95 | currentSchema[key] !== null &&
96 | !Array.isArray(currentSchema[key])
97 | ) {
98 | return {
99 | ...recursiveGetIdSchemaPairs(
100 | concatFormPointer(currentPointer, key),
101 | currentSchema[key],
102 | baseUrl
103 | ),
104 | ...IDs,
105 | }
106 | }
107 |
108 | const id = currentSchema[key]
109 | if (key === '$id' && id) {
110 | IDs[id] = currentSchema
111 |
112 | if (!isAbsoluteURI(id)) {
113 | try {
114 | IDs[new URL(id, baseUrl).href] = currentSchema
115 | } catch (e) {
116 | if (!(e instanceof TypeError)) {
117 | throw e
118 | }
119 | }
120 | }
121 | }
122 | return IDs
123 | },
124 | { [currentPointer]: currentSchema }
125 | )
126 | }
127 |
128 | let baseUrl: URL | undefined = undefined
129 | if (schema.$id && isAbsoluteURI(schema.$id)) {
130 | try {
131 | baseUrl = new URL(schema.$id)
132 | } catch (e) {
133 | baseUrl = undefined
134 | if (!(e instanceof TypeError)) {
135 | throw e
136 | }
137 | }
138 | }
139 |
140 | if (baseUrl) {
141 | return {
142 | [baseUrl.href]: schema,
143 | ...recursiveGetIdSchemaPairs(JSONSchemaRootPointer, schema, baseUrl),
144 | }
145 | }
146 | return recursiveGetIdSchemaPairs(JSONSchemaRootPointer, schema, baseUrl)
147 | }
148 |
--------------------------------------------------------------------------------
/src/hooks/validators/numberUtilities.ts:
--------------------------------------------------------------------------------
1 | import { ValidationOptions } from 'react-hook-form'
2 |
3 | import { JSONSchemaType } from '../../JSONSchema'
4 | import { ErrorTypes } from './types'
5 |
6 | // Used for exclusiveMinimum and exclusiveMaximum values
7 | const EPSILON = 0.0001
8 |
9 | export const toFixed = (value: number, precision: number): string => {
10 | const power = Math.pow(10, precision || 0)
11 | return String(Math.round(value * power) / power)
12 | }
13 |
14 | export const getNumberStep = (
15 | currentObject: JSONSchemaType
16 | ): [number | 'any', number | undefined] => {
17 | // Get dominant step value if it is defined
18 | const step =
19 | currentObject.multipleOf !== undefined
20 | ? currentObject.type === 'integer'
21 | ? parseInt(currentObject.multipleOf)
22 | : parseFloat(currentObject.multipleOf)
23 | : currentObject.type === 'integer'
24 | ? 1
25 | : 'any'
26 |
27 | let decimalPlaces = undefined
28 | if (currentObject.multipleOf) {
29 | const decimals = currentObject.multipleOf.toString().split('.')[1]
30 | if (decimals) {
31 | decimalPlaces = decimals.length
32 | } else {
33 | decimalPlaces = 0
34 | }
35 | }
36 |
37 | return [step, decimalPlaces]
38 | }
39 |
40 | export const getNumberMinimum = (
41 | currentObject: JSONSchemaType
42 | ): number | undefined => {
43 | const [step] = getNumberStep(currentObject)
44 |
45 | // Calculates whether there is a minimum or exclusiveMinimum value defined somewhere
46 | let minimum =
47 | currentObject.exclusiveMinimum !== undefined
48 | ? currentObject.exclusiveMinimum
49 | : currentObject.minimum !== undefined
50 | ? currentObject.minimum
51 | : undefined
52 | if (minimum !== undefined && currentObject.exclusiveMinimum !== undefined) {
53 | if (step && step != 'any') {
54 | minimum += step
55 | } else {
56 | minimum += EPSILON
57 | }
58 | }
59 | return minimum
60 | }
61 |
62 | export const getNumberMaximum = (
63 | currentObject: JSONSchemaType
64 | ): number | undefined => {
65 | const [step] = getNumberStep(currentObject)
66 |
67 | // Calculates wether there is a maximum or exclusiveMaximum value defined somewhere
68 | let maximum =
69 | currentObject.exclusiveMaximum !== undefined
70 | ? parseFloat(currentObject.exclusiveMaximum)
71 | : currentObject.maximum !== undefined
72 | ? parseFloat(currentObject.maximum)
73 | : undefined
74 | if (maximum !== undefined && currentObject.exclusiveMaximum !== undefined) {
75 | if (step && step != 'any') {
76 | maximum -= step
77 | } else {
78 | maximum -= EPSILON
79 | }
80 | }
81 |
82 | return maximum
83 | }
84 |
85 | export const getNumberValidator = (
86 | currentObject: JSONSchemaType,
87 | required: boolean
88 | ): ValidationOptions => {
89 | const minimum = getNumberMinimum(currentObject)
90 | const maximum = getNumberMaximum(currentObject)
91 |
92 | const validator: ValidationOptions = {
93 | validate: {
94 | multipleOf: (value: string) => {
95 | if (currentObject.type === 'integer' && value) {
96 | return (
97 | currentObject.multipleOf &&
98 | (parseInt(value) % parseInt(currentObject.multipleOf) === 0 ||
99 | ErrorTypes.multipleOf)
100 | )
101 | } else {
102 | // TODO: implement float checking with epsilon
103 | return true
104 | }
105 | },
106 | },
107 | }
108 |
109 | if (required) {
110 | validator.required = ErrorTypes.required
111 | }
112 |
113 | if (currentObject.type === 'integer') {
114 | validator.pattern = {
115 | value: /^([+-]?[1-9]\d*|0)$/,
116 | message: ErrorTypes.pattern,
117 | }
118 | } else {
119 | validator.pattern = {
120 | value: /^([0-9]+([,.][0-9]+))?$/,
121 | message: ErrorTypes.pattern,
122 | }
123 | }
124 |
125 | if (minimum || minimum === 0) {
126 | validator.min = {
127 | value: minimum,
128 | message: ErrorTypes.minValue,
129 | }
130 | }
131 |
132 | if (maximum || maximum === 0) {
133 | validator.max = {
134 | value: maximum,
135 | message: ErrorTypes.maxValue,
136 | }
137 | }
138 |
139 | return validator
140 | }
141 |
--------------------------------------------------------------------------------
/src/hooks/useObject.ts:
--------------------------------------------------------------------------------
1 | import {
2 | UseObjectProperties,
3 | UseObjectReturnType,
4 | BasicInputReturnType,
5 | UISchemaType,
6 | UITypes,
7 | } from './types'
8 | import { JSONSubSchemaInfo, JSONSchemaType } from '../JSONSchema'
9 | import {
10 | useAnnotatedSchemaFromPointer,
11 | concatFormPointer,
12 | } from '../JSONSchema/path-handler'
13 | import {
14 | getAnnotatedSchemaFromPointer,
15 | getObjectFromForm,
16 | } from '../JSONSchema/logic'
17 | import { getGenericInput } from './useGenericInput'
18 | import { getInputCustomFields } from './useInput'
19 | import { getRadioCustomFields } from './useRadio'
20 | import { useFormContext, JSONFormContextValues } from '../components'
21 | import { getSelectCustomFields } from './useSelect'
22 | import { getHiddenCustomFields } from './useHidden'
23 | import { getPasswordCustomFields } from './usePassword'
24 | import { getTextAreaCustomFields } from './useTextArea'
25 | import { getCheckboxCustomFields } from './useCheckbox'
26 |
27 | function getFromGeneric(
28 | genericInput: BasicInputReturnType
29 | ): UseObjectReturnType {
30 | const currentObject = genericInput.getObject()
31 | const inputs: UseObjectReturnType = []
32 |
33 | switch (currentObject.type) {
34 | case 'string':
35 | if (currentObject.enum) {
36 | inputs.push(getSelectCustomFields(genericInput))
37 | } else {
38 | inputs.push(getInputCustomFields(genericInput))
39 | }
40 | break
41 | case 'integer':
42 | case 'number':
43 | inputs.push(getInputCustomFields(genericInput))
44 | break
45 | case 'array':
46 | case 'boolean':
47 | inputs.push(getCheckboxCustomFields(genericInput))
48 | break
49 | }
50 | return inputs
51 | }
52 |
53 | // Outside of this function
54 | function getChildProperties(
55 | pointer: string,
56 | UISchema: UISchemaType | undefined,
57 | formContext: JSONFormContextValues,
58 | data: JSONSchemaType
59 | ) {
60 | return (allInputs: UseObjectReturnType, key: string) => {
61 | const newUISchema =
62 | UISchema && UISchema.properties ? UISchema.properties[key] : undefined
63 |
64 | const currentPointer = concatFormPointer(
65 | concatFormPointer(pointer, 'properties'),
66 | key
67 | )
68 |
69 | const currentPointerInfo = getAnnotatedSchemaFromPointer(
70 | currentPointer,
71 | data,
72 | formContext
73 | )
74 |
75 | // eslint-disable-next-line @typescript-eslint/no-use-before-define
76 | const newInput = getStructure(
77 | formContext,
78 | currentPointerInfo,
79 | currentPointer,
80 | newUISchema,
81 | data
82 | )
83 |
84 | return allInputs.concat(newInput)
85 | }
86 | }
87 |
88 | function getStructure(
89 | formContext: JSONFormContextValues,
90 | pointerInfo: JSONSubSchemaInfo,
91 | pointer: string,
92 | UISchema: UISchemaType | undefined,
93 | data: JSONSchemaType
94 | ): UseObjectReturnType {
95 | let inputs: UseObjectReturnType = []
96 | const { JSONSchema } = pointerInfo
97 |
98 | const genericInput = getGenericInput(formContext, pointerInfo, pointer)
99 |
100 | if (JSONSchema.type === 'object') {
101 | const objKeys = Object.keys(JSONSchema.properties)
102 | const childInputs = objKeys.reduce(
103 | getChildProperties(pointer, UISchema, formContext, data),
104 | []
105 | )
106 | return childInputs
107 | }
108 |
109 | if (!UISchema) {
110 | return inputs.concat(getFromGeneric(genericInput))
111 | }
112 |
113 | switch (UISchema.type) {
114 | case UITypes.default:
115 | inputs = inputs.concat(getFromGeneric(genericInput))
116 | break
117 | case UITypes.checkbox:
118 | inputs.push(getCheckboxCustomFields(genericInput))
119 | break
120 | case UITypes.hidden:
121 | inputs.push(getHiddenCustomFields(genericInput))
122 | break
123 | case UITypes.input:
124 | inputs.push(getInputCustomFields(genericInput))
125 | break
126 | case UITypes.password:
127 | inputs.push(getPasswordCustomFields(genericInput))
128 | break
129 | case UITypes.radio:
130 | inputs.push(getRadioCustomFields(genericInput))
131 | break
132 | case UITypes.select:
133 | inputs.push(getSelectCustomFields(genericInput))
134 | break
135 | case UITypes.textArea:
136 | inputs.push(getTextAreaCustomFields(genericInput))
137 | break
138 | }
139 |
140 | return inputs
141 | }
142 |
143 | export const useObject: UseObjectProperties = props => {
144 | const formContext = useFormContext()
145 | const data = getObjectFromForm(formContext.schema, formContext.getValues())
146 | const childArray = getStructure(
147 | formContext,
148 | useAnnotatedSchemaFromPointer(props.pointer, data),
149 | props.pointer,
150 | props.UISchema,
151 | data
152 | )
153 |
154 | return childArray
155 | }
156 |
--------------------------------------------------------------------------------
/example/src/index.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * This is just a working example code of how to use the
3 | * `react-hook-form-jsonschema`. For the full API read the readme in the root
4 | * of the project.
5 | */
6 |
7 | import React, { useState, useReducer } from 'react'
8 | import ReactDOM from 'react-dom'
9 | import {
10 | useObject,
11 | FormContext,
12 | UITypes,
13 | InputTypes,
14 | } from 'react-hook-form-jsonschema'
15 |
16 | const personSchema = {
17 | $id: 'https://example.com/person.schema.json',
18 | $schema: 'http://json-schema.org/draft-07/schema#',
19 | title: 'Person',
20 | type: 'object',
21 | properties: {
22 | firstName: {
23 | type: 'string',
24 | description: "The person's first name.",
25 | title: 'First Name',
26 | },
27 | lastName: {
28 | type: 'string',
29 | description: "The person's last name.",
30 | title: 'Last Name',
31 | },
32 | birthYear: {
33 | description: "The person's birth year.",
34 | type: 'integer',
35 | minimum: 1930,
36 | maximum: 2010,
37 | title: 'Birth Year',
38 | },
39 | },
40 | }
41 |
42 | const UISchema = {
43 | type: UITypes.default,
44 | properties: {
45 | birthYear: {
46 | type: UITypes.select,
47 | },
48 | },
49 | }
50 |
51 | function SpecializedObject(props) {
52 | switch (props.baseObject.type) {
53 | case InputTypes.input: {
54 | return (
55 | <>
56 |
57 | {props.baseObject.getObject().title}
58 |
59 |
60 | >
61 | )
62 | }
63 | case InputTypes.radio: {
64 | return (
65 | <>
66 |
67 | {props.baseObject.getObject().title}
68 |
69 | {props.baseObject.getItems().map((value, index) => {
70 | return (
71 |
75 | {value}
76 |
77 |
78 | )
79 | })}
80 | >
81 | )
82 | }
83 | case InputTypes.select: {
84 | return (
85 | <>
86 |
87 | {props.baseObject.getObject().title}
88 |
89 |
101 | >
102 | )
103 | }
104 | }
105 | return <>>
106 | }
107 |
108 | function ObjectRenderer(props) {
109 | const methods = useObject({ path: props.path, UISchema: props.UISchema })
110 |
111 | return (
112 | <>
113 | {methods.map(obj => (
114 |
115 |
116 | {obj.getError() &&
This is an error!
}
117 |
118 | ))}
119 | >
120 | )
121 | }
122 |
123 | function save(data) {
124 | return new Promise(resolve => {
125 | setTimeout(resolve, 2000)
126 | })
127 | }
128 |
129 | const initialState = { loading: false, error: null, success: null }
130 | function reducer(state, action) {
131 | switch (action.type) {
132 | case 'START_SAVING': {
133 | return { loading: true, error: null, success: null }
134 | }
135 | case 'SUCCESS_SAVING': {
136 | return { loading: false, error: false, success: true }
137 | }
138 | case 'ERROR_SAVING': {
139 | return { loading: false, error: true, success: false }
140 | }
141 | default:
142 | return state
143 | }
144 | }
145 |
146 | function RenderMyJSONSchema() {
147 | const [state, dispatch] = useReducer(reducer, initialState)
148 |
149 | return (
150 | {
153 | dispatch({ type: 'START_SAVING' })
154 | save(data)
155 | .then(() => dispatch({ type: 'SUCCESS_SAVING' }))
156 | .catch(() => dispatch({ type: 'ERROR_SAVING' }))
157 | }}
158 | >
159 |
160 |
161 | {state.loading && Loading...
}
162 | {state.error && Error saving!
}
163 | {state.success && Saved succesfully!
}
164 |
165 | )
166 | }
167 |
168 | ReactDOM.render(, document.getElementById('root'))
169 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 |
8 | ## [Unreleased]
9 |
10 | ## [0.2.0] - 2021-08-03
11 |
12 | ## [0.2.0-beta.13] - 2020-03-26
13 |
14 | ### Fixed
15 |
16 | - Prevent `onChange` from being triggered during the initial render.
17 |
18 | ## [0.2.0-beta.12] - 2020-03-20
19 |
20 | ### Added
21 |
22 | - `defaultValues` prop.
23 |
24 | ### Security
25 |
26 | - Upgrade all upgradable dependencies.
27 |
28 | ## [0.2.0-beta.11] - 2020-02-21
29 |
30 | ### Fixed
31 |
32 | - `getDataFromPath` to `getDataFromPointer` as declared in the `README.md`.
33 |
34 | ## [0.2.0-beta.10] - 2020-02-21
35 |
36 | ### Fixed
37 |
38 | - Typo in `ErrorTypes`
39 |
40 | ### Added
41 |
42 | - `onChange` and `formProps` props to `FormContext`.
43 |
44 | ### Changed
45 |
46 | - **BREAKING**: no longer uses paths starting with `$` (which was added at version `0.2.0-beta.6`) and all 'paths' should now be relative to the JSON Schema, respecting the [RFC 6901](https://tools.ietf.org/html/rfc6901) which defines the string syntax for JSON Pointers, pay special attention to [section 6](https://tools.ietf.org/html/rfc6901#section-6) which defines the URI frament identifier representation of JSON pointers.
47 |
48 | ## [0.2.0-beta.9] - 2020-02-18
49 |
50 | ### Fixed
51 |
52 | - `notInEnum` to return expected value in `expected` field of the `ErrorMessage`.
53 |
54 | ## [0.2.0-beta.8] - 2020-02-17
55 |
56 | ### Changed
57 |
58 | - **BREAKING**: `number` error messages to now return messages describing whether the input does not match the expected pattern for a number.
59 |
60 | ## [0.2.0-beta.7] - 2020-02-14
61 |
62 | ### Changed
63 |
64 | - **BREAKING**: `onSubmit` now passes an object with `data`, `event` and `methods` as members to the callback.
65 |
66 | ## [0.2.0-beta.6] - 2020-02-13
67 |
68 | ### Added
69 |
70 | - `$ref` and `$id` resolving in accord to the [JSON Schema specification](https://tools.ietf.org/html/draft-wright-json-schema-01)
71 |
72 | ### Changed
73 |
74 | - **BREAKING**: Now uses paths starting with `$` to represent objects of an instance of the JSON Schema, instead of a path starting with `#`, which resembled a URI fragment identifier representation of a JSON pointer.
75 | - custom validator `context` parameter now gives info as an annotated sub schema
76 |
77 | ### Fixed
78 |
79 | - Not checking if value exists before using enum validation on it
80 | - `isRequired` not evaluating correctly if it is inside another object that is not required
81 | - Empty data not being ignored when parsing form data
82 |
83 | ## [0.2.0-beta.5] - 2020-02-11
84 |
85 | ### Added
86 |
87 | - `customValidators` to allow the user to define their own validation functions
88 |
89 | ### Changed
90 |
91 | - **BREAKING**: Renamed `FormValuesWithSchema` type to `JSONFormContextValues`
92 |
93 | ## [0.2.0-beta.4] - 2020-02-05
94 |
95 | ### Fixed
96 |
97 | - Returning non-filled form inputs in the object passed to onSubmit
98 |
99 | ## [0.2.0-beta.3] - 2020-01-31
100 |
101 | ### Changed
102 |
103 | - **BREAKING**: Changed `InputTypes` and `UITypes` enum values to reflect their enum names and what was documented in the `README`
104 | - **BREAKING**: Changed `min` and `max` props returned from `getInputProps()` to be strings, not numbers
105 | - **BREAKING**: Just re-exports types and `Controller` component from `react-hook-form`
106 |
107 | ## [0.2.0-beta.2] - 2020-01-30
108 |
109 | ### Fixed
110 |
111 | - type of onChange possibly being undefined
112 |
113 | ## [0.2.0-beta.1] - 2020-01-29
114 |
115 | ### Added
116 |
117 | - Added `useCheckbox` hook
118 |
119 | ### Changed
120 |
121 | - Changed the BasicInputReturnType to also return a reference to the validator used
122 | - **BREAKING**: The `useObject` hook now automatically associates a boolean to a checkbox
123 |
124 | ### Fixed
125 |
126 | - Fixed returned form data not converting string values that represent booleans to actual booleans
127 |
128 | ## [0.2.0-beta.0] - 2020-01-23
129 |
130 | ### Changed
131 |
132 | - Renamed `'mode'` prop on FormContext to `'validationMode'`
133 |
134 | ## [0.2.0-beta] - 2020-01-22
135 |
136 | ### Added
137 |
138 | - Now re-exports the `react-hook-form` public API
139 |
140 | ## [0.1.3] - 2020-01-21
141 |
142 | ### Added
143 |
144 | - Added test to make sure schema is not modified
145 |
146 | ### Changed
147 |
148 | - Fixed tons of typos on README
149 | - Made README more friendly
150 | - Refactored internal API to be more easily expandable
151 | - Removed complexity from big function bodies
152 |
153 | ## [0.1.2] - 2020-01-21
154 |
155 | ### Added
156 |
157 | - Typings for JSON Schema object
158 | - Removed unused mock
159 |
160 | ### Changed
161 |
162 | - Add typings for JSON Schema object
163 | - Build process is cleaner
164 | - React hook form is now an external dependency, not bundled with the code anymore
165 |
166 | ## [0.1.1] - 2020-01-17
167 |
168 | ### Added
169 |
170 | - Initial implementation.
171 |
--------------------------------------------------------------------------------
/src/JSONSchema/logic/schemaHandlers.ts:
--------------------------------------------------------------------------------
1 | import { JSONSchemaType, JSONSubSchemaInfo } from '../types'
2 | import { JSONFormContextValues } from '../../components'
3 | import {
4 | concatFormPointer,
5 | JSONSchemaRootPointer,
6 | getSplitPointer,
7 | } from './pathUtils'
8 |
9 | const parsers: Record number | boolean> = {
10 | integer: (data: string): number => parseInt(data),
11 | number: (data: string): number => parseFloat(data),
12 | boolean: (data: string): boolean => data === 'true',
13 | }
14 |
15 | export const getObjectFromForm = (
16 | originalSchema: JSONSchemaType,
17 | data: JSONSchemaType
18 | ): JSONSchemaType => {
19 | return Object.keys(data)
20 | .sort()
21 | .reduce((objectFromData: JSONSchemaType, key: string) => {
22 | const splitPointer = getSplitPointer(key)
23 | if (!splitPointer || !data[key]) {
24 | return objectFromData
25 | }
26 |
27 | splitPointer.reduce(
28 | (currentContext, node: string, index: number, src: string[]) => {
29 | currentContext.currentSubSchema = currentContext.currentSubSchema
30 | ? currentContext.currentSubSchema[node]
31 | : undefined
32 |
33 | if (node === 'properties' && !currentContext.insideProperties) {
34 | return { ...currentContext, insideProperties: true }
35 | }
36 |
37 | if (index === src.length - 1 && currentContext.currentSubSchema) {
38 | currentContext.currentJSON[node] =
39 | currentContext.currentSubSchema.type &&
40 | parsers[currentContext.currentSubSchema.type]
41 | ? parsers[currentContext.currentSubSchema.type](data[key])
42 | : currentContext.targetData ?? {}
43 | } else if (
44 | !currentContext.currentJSON[node] &&
45 | currentContext.currentSubSchema
46 | ) {
47 | currentContext.currentJSON[node] = {}
48 | }
49 |
50 | currentContext.currentJSON = currentContext.currentJSON[node]
51 |
52 | return { ...currentContext, insideProperties: false }
53 | },
54 | {
55 | currentJSON: objectFromData,
56 | currentSubSchema: originalSchema,
57 | insideProperties: false,
58 | targetData: data[key],
59 | }
60 | )
61 | return objectFromData
62 | }, {})
63 | }
64 |
65 | interface ReducerSubSchemaInfo {
66 | JSONSchema: JSONSchemaType
67 | currentData: JSONSchemaType
68 | invalidPointer: boolean
69 | isRequired: boolean
70 | fatherExists: boolean
71 | fatherIsRequired: boolean
72 | pointer: string
73 | objectName: string
74 | insideProperties: boolean
75 | currentRequiredField: string[]
76 | }
77 |
78 | export const getAnnotatedSchemaFromPointer = (
79 | pointer: string,
80 | data: JSONSchemaType,
81 | formContext: JSONFormContextValues
82 | ): JSONSubSchemaInfo => {
83 | const { schema } = formContext
84 |
85 | const info = getSplitPointer(pointer).reduce(
86 | (currentInfo: ReducerSubSchemaInfo, node: string) => {
87 | const { JSONSchema, currentData } = currentInfo
88 |
89 | if (
90 | !(JSONSchema && JSONSchema.type === 'object') &&
91 | !currentInfo.insideProperties
92 | ) {
93 | return {
94 | ...currentInfo,
95 | JSONSchema: undefined,
96 | invalidPointer: true,
97 | }
98 | } else if (node === 'properties' && !currentInfo.insideProperties) {
99 | const fatherIsRequired = currentInfo.isRequired
100 |
101 | return {
102 | ...currentInfo,
103 | JSONSchema: JSONSchema.properties,
104 | fatherIsRequired,
105 | pointer: concatFormPointer(currentInfo.pointer, node),
106 | insideProperties: true,
107 | currentRequiredField: JSONSchema.required ?? [],
108 | }
109 | }
110 |
111 | const fatherExists = currentData ? true : false
112 | const newCurrentData = currentData ? currentData[node] : currentData
113 | const isRequired = currentInfo.currentRequiredField.indexOf(node) > -1
114 |
115 | return {
116 | ...currentInfo,
117 | JSONSchema: JSONSchema[node],
118 | currentData: newCurrentData,
119 | fatherExists,
120 | isRequired,
121 | invalidPointer: false,
122 | objectName: node,
123 | pointer: concatFormPointer(currentInfo.pointer, node),
124 | insideProperties: false,
125 | }
126 | },
127 | {
128 | JSONSchema: schema,
129 | currentData: data,
130 | fatherExists: true,
131 | fatherIsRequired: true,
132 | invalidPointer: false,
133 | isRequired: true,
134 | objectName: '',
135 | pointer: JSONSchemaRootPointer,
136 | insideProperties: false,
137 | currentRequiredField: schema.required ?? [],
138 | }
139 | )
140 |
141 | return {
142 | JSONSchema: info.JSONSchema,
143 | invalidPointer: info.invalidPointer,
144 | isRequired:
145 | (info.fatherIsRequired && info.isRequired) ||
146 | (!info.fatherIsRequired && info.isRequired && info.fatherExists),
147 | objectName: info.objectName,
148 | pointer: info.pointer,
149 | }
150 | }
151 |
--------------------------------------------------------------------------------
/src/hooks/validators/__tests__/errorMessages.test.tsx:
--------------------------------------------------------------------------------
1 | import React, { FC } from 'react'
2 | import { render, wait, fireEvent } from '@vtex/test-tools/react'
3 |
4 | import { useInput } from '../../useInput'
5 | import { FormContext } from '../../../components'
6 | import { ErrorTypes } from '../types'
7 | import mockSchema from '../../__mocks__/mockSchema'
8 |
9 | const MockInput: FC<{ path: string }> = props => {
10 | const methods = useInput(props.path)
11 | const error = methods.getError()
12 |
13 | return (
14 | <>
15 |
16 | {methods.getObject().title}
17 |
18 |
19 | {error && (
20 |
21 | This is an error:{' '}
22 | {`${error.message}:${error.expected?.toString() ?? ''}`}
23 |
24 | )}
25 | >
26 | )
27 | }
28 |
29 | test('should raise error when writing value not in enum', async () => {
30 | const { getByText, getByLabelText } = render(
31 | {
34 | return
35 | }}
36 | >
37 |
38 |
39 |
40 | )
41 | fireEvent.change(getByLabelText('test-useSelectString'), {
42 | target: { value: 'some value not in the enum' },
43 | })
44 | getByText('Submit').click()
45 |
46 | await wait(() =>
47 | expect(
48 | getByText(
49 | `This is an error: ${ErrorTypes.notInEnum}:this,tests,the,useSelect,hook`
50 | )
51 | ).toBeDefined()
52 | )
53 | })
54 |
55 | describe('testing integer boundaries', () => {
56 | it('should raise error for maximum', async () => {
57 | const { getByText, getByLabelText } = render(
58 | {
61 | return
62 | }}
63 | noNativeValidate
64 | >
65 |
66 |
67 |
68 | )
69 |
70 | fireEvent.change(getByLabelText('test-useSelectInteger'), {
71 | target: { value: 12 },
72 | })
73 | getByText('Submit').click()
74 |
75 | await wait(() =>
76 | expect(
77 | getByText(`This is an error: ${ErrorTypes.maxValue}:6`)
78 | ).toBeDefined()
79 | )
80 | })
81 |
82 | it('should raise error for minimum', async () => {
83 | const { getByText, getByLabelText } = render(
84 | {
87 | return
88 | }}
89 | noNativeValidate
90 | >
91 |
92 |
93 |
94 | )
95 |
96 | fireEvent.change(getByLabelText('test-useSelectInteger'), {
97 | target: { value: -12 },
98 | })
99 | getByText('Submit').click()
100 |
101 | await wait(() =>
102 | expect(
103 | getByText(`This is an error: ${ErrorTypes.minValue}:0`)
104 | ).toBeDefined()
105 | )
106 | })
107 |
108 | it('should raise error for multipleOf', async () => {
109 | const { getByText, getByLabelText } = render(
110 | {
113 | return
114 | }}
115 | noNativeValidate
116 | >
117 |
118 |
119 |
120 | )
121 |
122 | fireEvent.change(getByLabelText('test-useSelectInteger'), {
123 | target: { value: 5 },
124 | })
125 | getByText('Submit').click()
126 |
127 | await wait(() =>
128 | expect(
129 | getByText(`This is an error: ${ErrorTypes.multipleOf}:2`)
130 | ).toBeDefined()
131 | )
132 | })
133 |
134 | it('should raise error for notInteger', async () => {
135 | const { getByText, getByLabelText } = render(
136 | {
139 | return
140 | }}
141 | noNativeValidate
142 | >
143 |
144 |
145 |
146 | )
147 |
148 | fireEvent.change(getByLabelText('test-useSelectInteger'), {
149 | target: { value: 2.2 },
150 | })
151 | getByText('Submit').click()
152 |
153 | await wait(() =>
154 | expect(
155 | getByText(`This is an error: ${ErrorTypes.notInteger}:`)
156 | ).toBeDefined()
157 | )
158 | })
159 | })
160 |
161 | describe('testing float boundaries', () => {
162 | it('should raise error for maximum', async () => {
163 | const { getByText, getByLabelText } = render(
164 | {
167 | return
168 | }}
169 | noNativeValidate
170 | >
171 |
172 |
173 |
174 | )
175 |
176 | fireEvent.change(getByLabelText('test-useSelectNumber'), {
177 | target: { value: 12 },
178 | })
179 | getByText('Submit').click()
180 |
181 | await wait(() =>
182 | expect(
183 | getByText(`This is an error: ${ErrorTypes.maxValue}:0.5`)
184 | ).toBeDefined()
185 | )
186 | })
187 |
188 | it('should raise error for minimum', async () => {
189 | const { getByText, getByLabelText } = render(
190 | {
193 | return
194 | }}
195 | noNativeValidate
196 | >
197 |
198 |
199 |
200 | )
201 |
202 | fireEvent.change(getByLabelText('test-useSelectNumber'), {
203 | target: { value: -12 },
204 | })
205 | getByText('Submit').click()
206 |
207 | await wait(() =>
208 | expect(
209 | getByText(`This is an error: ${ErrorTypes.minValue}:0`)
210 | ).toBeDefined()
211 | )
212 | })
213 | })
214 |
215 | test('should raise required error', async () => {
216 | const { getByText } = render(
217 | {
220 | return
221 | }}
222 | >
223 |
224 |
225 |
226 | )
227 |
228 | getByText('Submit').click()
229 |
230 | await wait(() =>
231 | expect(
232 | getByText(`This is an error: ${ErrorTypes.required}:true`)
233 | ).toBeDefined()
234 | )
235 | })
236 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # react-hook-form-jsonschema
2 |
3 | > Small project based on [react-hook-form](https://github.com/react-hook-form/react-hook-form) that exposes an API for easily creating customizable forms based on a [JSON Schema](https://json-schema.org/understanding-json-schema/index.html) with built-in validation.
4 |
5 | `react-hook-form-jsonschema` is a React hooks library that manages all the stateful logic needed to make a functional form based on a JSON Schema. It returns a set of props that are meant to be called and their results destructured on the desired input field.
6 |
7 | Try a live demo on [CodeSandbox](https://codesandbox.io/s/react-hook-form-jsonschema-basic-example-u68o7)!
8 |
9 | [Supported JSON Schema keywords](#supported-json-schema-keywords)
10 |
11 | ## Table of Contents
12 |
13 | - [react-hook-form-jsonschema](#react-hook-form-jsonschema)
14 | - [Table of Contents](#table-of-contents)
15 | - [Simple Usage](#simple-usage)
16 | - [Installation](#installation)
17 | - [API](#api)
18 | - [Components API](#components-api)
19 | - [FormContext component API](#formcontext-component-api)
20 | - [Functions API](#functions-api)
21 | - [getDataFromPointer(pointer, data)](#getdatafrompointerpointer-data)
22 | - [Hooks API](#hooks-api)
23 | - [useCheckbox(pointer)](#usecheckboxpointer)
24 | - [useHidden(pointer)](#usehiddenpointer)
25 | - [useInput(pointer)](#useinputpointer)
26 | - [useObject(pointer, UISchema)](#useobjectpointer-uischema)
27 | - [usePassword(pointer)](#usepasswordpointer)
28 | - [useRadio(pointer)](#useradiopointer)
29 | - [useSelect(pointer)](#useselectpointer)
30 | - [useTextArea(pointer)](#usetextareapointer)
31 | - [Supported JSON Schema keywords](#supported-json-schema-keywords)
32 | - [TODO/Next Steps](#todonext-steps)
33 | - [Useful resources](#useful-resources)
34 |
35 | ## Simple Usage
36 |
37 | Suppose you have a simple JSON Schema that stores a person's first name:
38 |
39 | ```js
40 | const personSchema = {
41 | $id: 'https://example.com/person.schema.json',
42 | $schema: 'http://json-schema.org/draft-07/schema#',
43 | title: 'Person',
44 | type: 'object',
45 | properties: {
46 | firstName: {
47 | type: 'string',
48 | description: "The person's first name.",
49 | },
50 | },
51 | }
52 | ```
53 |
54 | And suppose you want to create a form field for the `firstName` field, simply use the `useInput()` hook and render the form using react:
55 |
56 | ```JSX
57 | function FirstNameField(props) {
58 | const inputMethods = useInput('#/properties/firstName');
59 |
60 | return (
61 |
62 |
63 | {inputMethods.name}
64 |
65 |
66 |
67 | )
68 | }
69 | ```
70 |
71 | ## Installation
72 |
73 | With npm:
74 |
75 | ```
76 | npm install react-hook-form-jsonschema --save
77 | ```
78 |
79 | Or with yarn:
80 |
81 | ```
82 | yarn add react-hook-form-jsonschema
83 | ```
84 |
85 | ## API
86 |
87 | This is the API documentation, `react-hook-form-jsonschema` also re-exports all the [`react-hook-form`](https://github.com/react-hook-form/react-hook-form) types and the `Controller` component. All of the other functionalities are abstracted by this library.
88 |
89 | ## Components API
90 |
91 | ### FormContext component API
92 |
93 | This component is the top-level component that creates the context with the schema and options all the hooks will need to be usable. So bear in mind that you **need** to define all the other components as children of `FormContext`.
94 |
95 | #### props:
96 |
97 | ##### Required:
98 |
99 | - `schema`: JSON Schema object which will be passed down by context for the inputs to use it for validation and the structure of the form itself.
100 |
101 | ##### Optional:
102 |
103 | - `customValidators`: An object where each member has to be a funtion with the following format:
104 | - `function(value: string, context: SubSchemaInfo) => CustomValidatorReturnValue`
105 | - `params`:
106 | - `value`: Is the current value in the form input.
107 | - `context`: Is an object with the following fields:
108 | - `JSONSchema`: Is the sub schema of the current field
109 | - `isRequired`: Whether the current field is required or not
110 | - `objectName`: The name of the sub schema
111 | - `invalidPointer`: A `boolean` indicating whether the referenced field was found within the schema or not. If it is false it is because of an error in the schema.
112 | - `pointer`: JSON Pointer to sub schema that should be validated. The pointer is always in the form: `#/properties/some/properties/data` where `#` represents the root of the schema, and the `properties/some/properties/data` represents the tree of objects (from `some` to `data`) to get to the desired field, which in this case is `data`. Also see the definition of JSON Pointers on [RFC 6901](https://tools.ietf.org/html/rfc6901).
113 | - `return value`: Must be either a `string` that identifies the error or a `true` value indicating the validation was succesfull.
114 | - `formProps`: An object that is passed to the underlying `