@startupjs-ui/form 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # Change Log
2
+
3
+ All notable changes to this project will be documented in this file.
4
+ See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
+
6
+ ## [0.1.3](https://github.com/startupjs/startupjs-ui/compare/v0.1.2...v0.1.3) (2025-12-29)
7
+
8
+ **Note:** Version bump only for package @startupjs-ui/form
9
+
10
+
11
+
12
+
13
+
14
+ ## [0.1.2](https://github.com/startupjs/startupjs-ui/compare/v0.1.1...v0.1.2) (2025-12-29)
15
+
16
+
17
+ ### Bug Fixes
18
+
19
+ * **form:** move useFormFields and useFormFileds$ from core into form package ([949493e](https://github.com/startupjs/startupjs-ui/commit/949493e89316e73b5cabb97480184ef30648e55a))
20
+
21
+
22
+ ### Features
23
+
24
+ * add mdx and docs packages. Refactor docs to get rid of any @startupjs/ui usage and use startupjs-ui instead ([703c926](https://github.com/startupjs/startupjs-ui/commit/703c92636efb0421ffd11783f692fc892b74018f))
25
+ * **form:** refactor Form component ([8c54f9b](https://github.com/startupjs/startupjs-ui/commit/8c54f9bcbd01ec6474bcb9e082951ddc6306fae7))
package/README.mdx ADDED
@@ -0,0 +1,243 @@
1
+ import { useMemo } from 'react'
2
+ import Form, { _PropsJsonSchema as FormPropsJsonSchema } from './index'
3
+ import { $ } from 'startupjs'
4
+ import { Sandbox } from '@startupjs-ui/docs'
5
+
6
+ # Form
7
+
8
+ Wrapper around ObjectInput which provides a way to add extra input types and saves its props into the context.
9
+
10
+ ```jsx
11
+ import { Form } from 'startupjs-ui'
12
+ ```
13
+
14
+ ## Simple example
15
+
16
+ ```jsx example
17
+ const $value = $({})
18
+
19
+ return (
20
+ <Form
21
+ $value={$value}
22
+ fields={{
23
+ name: {
24
+ type: 'string',
25
+ label: 'Your name',
26
+ required: true,
27
+ placeholder: 'John Smith'
28
+ },
29
+ gender: {
30
+ type: 'string',
31
+ enum: ['male', 'female', 'other']
32
+ },
33
+ age: {
34
+ type: 'number',
35
+ description: 'Your age (18+)',
36
+ min: 18,
37
+ max: 100,
38
+ step: 1
39
+ }
40
+ }}
41
+ />
42
+ )
43
+ ```
44
+
45
+ ## Custom inputs
46
+
47
+ Pass custom inputs to the `Form` using the `customInputs` prop.
48
+
49
+ Inside custom inputs you can access Form's props using the `useFormProps` hook.
50
+
51
+ ```jsx
52
+ import { observer } from 'startupjs'
53
+ import { Form, useFormProps, NumberInput } from 'startupjs-ui'
54
+
55
+ function App () {
56
+ const minAge = 18
57
+ const $value = $({})
58
+ return (
59
+ <Form
60
+ minAge={minAge}
61
+ $value={$value}
62
+ customInputs={{
63
+ age: CustomAgeInput
64
+ }}
65
+ fields={{
66
+ age: {
67
+ type: 'number',
68
+ input: 'age',
69
+ description: 'Your age (18+)'
70
+ }
71
+ }}
72
+ />
73
+ )
74
+ }
75
+
76
+ const CustomAgeInput = observer(({ $value, ...props }) => {
77
+ const { minAge } = useFormProps()
78
+ function setAge (age) {
79
+ if (age < minAge) return minAge
80
+ $value.set(age)
81
+ }
82
+ return <NumberInput value={$value.get()} onChangeNumber={setAge} {...props} />
83
+ })
84
+ ```
85
+
86
+ ### Adding custom inputs globally
87
+
88
+ You can add custom inputs globally by creating a plugin which uses the client's `customFormInputs` hook.
89
+
90
+ You should return an object with the custom inputs in this hook.
91
+
92
+ ```jsx
93
+ // startupjs.config.js
94
+ import { observer } from 'startupjs'
95
+ import { NumberInput } from 'startupjs-ui'
96
+ import { createPlugin } from 'startupjs/registry'
97
+
98
+ export default {
99
+ plugins: {
100
+ // if there are no plugin options, just specify `true` to enable the plugin.
101
+ myCustomInputs: {
102
+ minAge: 18
103
+ }
104
+ }
105
+ }
106
+
107
+ createPlugin({
108
+ name: 'myCustomInputs',
109
+ client: ({ minAge }) => ({
110
+ customFormInputs: () => ({
111
+ age: observer(({ $value }) => {
112
+ function setAge (age) {
113
+ if (age < minAge) age = minAge
114
+ $value.set(age)
115
+ }
116
+ return <NumberInput value={$value.get()} onChangeNumber={setAge} />
117
+ })
118
+ })
119
+ })
120
+ })
121
+ ```
122
+
123
+ Jxs will look like this:
124
+
125
+ ```jsx
126
+ import { observer } from 'startupjs'
127
+ import { Form } from 'startupjs-ui'
128
+
129
+ function App () {
130
+ const $value = $({})
131
+
132
+ return (
133
+ <Form
134
+ $value={$value}
135
+ fields={{
136
+ age: {
137
+ type: 'number',
138
+ input: 'age'
139
+ description: 'Your age (18+)'
140
+ }
141
+ }}
142
+ />
143
+ )
144
+ }
145
+ ```
146
+
147
+ We pass all necessary constants as options to the plugin (in our case, it's minAge).
148
+ In the form we define the type according to its actual type (in our case, it will be number),
149
+ and we also add an input property, which will have a custom type derived from the plugin (in our case, it's 'age').
150
+
151
+ ## JSON-Schema compatilibily
152
+
153
+ `fields` prop is json-schema compatible.
154
+
155
+ If you want to specify both the json-schema type and the input type, you can use `input` prop in addition to the `type` prop.
156
+
157
+ ## Validation
158
+
159
+ By default `Form` does not run any validation.
160
+
161
+ By passing `validate={true}` it will use the `fields` (which is json-schema compatible) to always validate your form and reactively show any errors.
162
+
163
+ ```jsx
164
+ <Form validate={true} />
165
+ ```
166
+
167
+ ### `useValidate()`
168
+
169
+ To trigger validation manually, use `useValidate()` hook.
170
+
171
+ It will return the `validate` function which you can call manually to run the validation. It will return `true` if validation has passed (there are NO errors).
172
+
173
+ If there are errors, they will be available in `validate.hasErrors` (`true` or `false`) and `validate.errors` (errors themselves as an object of field names and an array of errors: `{ name: ['This field is required'], age: ['must be above 18'] }`)
174
+
175
+ ### `useValidate({ always: true })`
176
+
177
+ By default, only after the first manual `validate()` call the `Form` will start showing errors and update them reactively (on each change to the form).
178
+
179
+ If you want the `Form` to start showing errors right away and update them reactively as soon as it renders, pass an `always: true` option to the hook.
180
+
181
+ This will effectively make the `Form` behave as if `validate={true}` was passed but also give you access to the `validate` function to manually call it when needed.
182
+
183
+ ### Example with errors validation
184
+
185
+ ```jsx
186
+ import { $ } from 'startupjs'
187
+ import { useValidate, Form, Button } from 'startupjs-ui'
188
+
189
+ const fields = {
190
+ name: { type: 'string', required: true },
191
+ age: { type: 'number' }
192
+ }
193
+ const { $newUser } = $.session
194
+
195
+ export default function App () {
196
+ const validate = useValidate()
197
+
198
+ function submit () {
199
+ if (!validate()) return
200
+ console.log('Create new user', $newUser.get())
201
+ }
202
+
203
+ return [
204
+ <Form fields={fields} $value={$newUser} validate={validate} />,
205
+ <Button disabled={validate.hasErrors} onPress={submit}>Submit</Button>
206
+ ]
207
+ }
208
+ ```
209
+
210
+ ## Sandbox
211
+
212
+ export function SandboxWrapper () {
213
+ const $value = $()
214
+ const fields = useMemo(() => ({
215
+ name: {
216
+ type: 'string',
217
+ label: 'Your name',
218
+ required: true,
219
+ placeholder: 'John Smith'
220
+ },
221
+ age: {
222
+ type: 'number',
223
+ description: 'Your age (18+)',
224
+ min: 18,
225
+ max: 100,
226
+ step: 1
227
+ }
228
+ }), [])
229
+
230
+ return (
231
+ <Sandbox
232
+ Component={Form}
233
+ propsJsonSchema={FormPropsJsonSchema}
234
+ props={{
235
+ $value,
236
+ fields
237
+ }}
238
+ block
239
+ />
240
+ )
241
+ }
242
+
243
+ <SandboxWrapper />
package/index.d.ts ADDED
@@ -0,0 +1,48 @@
1
+ /* eslint-disable */
2
+ // DO NOT MODIFY THIS FILE - IT IS AUTOMATICALLY GENERATED ON COMMITS.
3
+
4
+ import { type ReactNode } from 'react';
5
+ import { type StyleProp, type ViewStyle } from 'react-native';
6
+ export declare const _PropsJsonSchema: {};
7
+ export interface FormProps {
8
+ /** Schema describing form fields (json-schema compatible) */
9
+ fields?: Record<string, any>;
10
+ /** Reactive schema (overrides `fields`) */
11
+ $fields?: any;
12
+ /** Reactive errors model (managed by validation) */
13
+ $errors?: any;
14
+ /** Styles for the wrapper */
15
+ style?: StyleProp<ViewStyle>;
16
+ /** Styles for the inner input container */
17
+ inputStyle?: StyleProp<ViewStyle>;
18
+ /** Order of rendered fields */
19
+ order?: string[];
20
+ /** Render inputs in a row */
21
+ row?: boolean;
22
+ /** Explicit errors object (overrides `$errors`) */
23
+ errors?: Record<string, any>;
24
+ /** Custom inputs by type key */
25
+ customInputs?: Record<string, any>;
26
+ /** Custom wrapper renderer for inputs */
27
+ _renderWrapper?: (params: {
28
+ style: StyleProp<ViewStyle> | undefined;
29
+ }, children: ReactNode) => ReactNode;
30
+ /** Enable validation or pass validate hook from useValidate */
31
+ validate?: boolean | any;
32
+ /** Disable interactions */
33
+ disabled?: boolean;
34
+ /** Render as read-only */
35
+ readonly?: boolean;
36
+ /** Model binding for form values */
37
+ $value: any;
38
+ /** Do not use; pass `fields` instead (will throw if set) */
39
+ properties?: Record<string, any>;
40
+ /** Additional props passed to custom inputs via `useFormProps` */
41
+ [key: string]: any;
42
+ }
43
+ declare const _default: import("react").ComponentType<FormProps>;
44
+ export default _default;
45
+ export { default as useFormProps } from './useFormProps';
46
+ export { default as useValidate } from './useValidate';
47
+ export { default as useFormFields } from './useFormFields';
48
+ export { default as useFormFields$ } from './useFormFields$';
package/index.tsx ADDED
@@ -0,0 +1,164 @@
1
+ import { useMemo, useCallback, useState, useId, useRef, type ReactNode } from 'react'
2
+ import { type StyleProp, type ViewStyle } from 'react-native'
3
+ import { pug, observer, $ } from 'startupjs'
4
+ import ObjectInput from '@startupjs-ui/object-input'
5
+ import { CustomInputsContext } from '@startupjs-ui/input'
6
+ import _debounce from 'lodash/debounce'
7
+ import { FormPropsContext } from './useFormProps'
8
+ import { Validator } from './useValidate'
9
+
10
+ export const _PropsJsonSchema = {/* FormProps */}
11
+
12
+ export interface FormProps {
13
+ /** Schema describing form fields (json-schema compatible) */
14
+ fields?: Record<string, any>
15
+ /** Reactive schema (overrides `fields`) */
16
+ $fields?: any
17
+ /** Reactive errors model (managed by validation) */
18
+ $errors?: any
19
+ /** Styles for the wrapper */
20
+ style?: StyleProp<ViewStyle>
21
+ /** Styles for the inner input container */
22
+ inputStyle?: StyleProp<ViewStyle>
23
+ /** Order of rendered fields */
24
+ order?: string[]
25
+ /** Render inputs in a row */
26
+ row?: boolean
27
+ /** Explicit errors object (overrides `$errors`) */
28
+ errors?: Record<string, any>
29
+ /** Custom inputs by type key */
30
+ customInputs?: Record<string, any>
31
+ /** Custom wrapper renderer for inputs */
32
+ _renderWrapper?: (params: { style: StyleProp<ViewStyle> | undefined }, children: ReactNode) => ReactNode
33
+ /** Enable validation or pass validate hook from useValidate */
34
+ validate?: boolean | any
35
+ /** Disable interactions */
36
+ disabled?: boolean
37
+ /** Render as read-only */
38
+ readonly?: boolean
39
+ /** Model binding for form values */
40
+ $value: any
41
+ /** Do not use; pass `fields` instead (will throw if set) */
42
+ properties?: Record<string, any>
43
+ /** Additional props passed to custom inputs via `useFormProps` */
44
+ [key: string]: any
45
+ }
46
+
47
+ function Form ({
48
+ fields = {},
49
+ $fields,
50
+ $errors,
51
+ properties,
52
+ order,
53
+ row,
54
+ errors,
55
+ _renderWrapper,
56
+ validate,
57
+ style,
58
+ inputStyle,
59
+ customInputs = {},
60
+ ...props
61
+ }: FormProps): ReactNode {
62
+ if (properties) throw Error(ERROR_PROPERTIES)
63
+ const { disabled, readonly, $value } = props
64
+ if (!$value) throw Error('<Form />: $value prop is required')
65
+
66
+ const formId = useId()
67
+ const forceUpdate = useForceUpdate()
68
+
69
+ const memoizedFields = useMemo(
70
+ // eslint-disable-next-line react-hooks/exhaustive-deps
71
+ () => fields, [JSON.stringify(fields)]
72
+ )
73
+
74
+ if (!$errors) $errors = $() // eslint-disable-line react-hooks/rules-of-hooks
75
+ const validator = useMemo(() => new Validator(), [])
76
+
77
+ // eslint-disable-next-line react-hooks/exhaustive-deps
78
+ useMemo(() => {
79
+ validator.init({
80
+ fields: $fields?.get() || memoizedFields,
81
+ getValue: () => $value.get(),
82
+ $errors,
83
+ forceUpdate,
84
+ formId
85
+ })
86
+ }, [JSON.stringify(memoizedFields), $fields, $value, $errors, forceUpdate, formId]) // eslint-disable-line react-hooks/exhaustive-deps
87
+
88
+ const memoizedProps = useMemo(
89
+ // eslint-disable-next-line react-hooks/exhaustive-deps
90
+ () => props, [...Object.keys(props), ...Object.values(props)]
91
+ )
92
+
93
+ const memoizedCustomInputs = useMemo(
94
+ // eslint-disable-next-line react-hooks/exhaustive-deps
95
+ () => customInputs, [...Object.keys(customInputs), ...Object.values(customInputs)]
96
+ )
97
+
98
+ // eslint-disable-next-line react-hooks/exhaustive-deps
99
+ const debouncedValidate = useCallback(
100
+ _debounce(() => validator.run(), 30, { leading: false, trailing: true }),
101
+ [validator]
102
+ )
103
+
104
+ // TODO: $(fn, triggerFn)
105
+ // $(() => JSON.stringify($value.get()), debouncedValidate)
106
+ useRef<any>($(() => {
107
+ JSON.stringify($value.get())
108
+ debouncedValidate()
109
+ }))
110
+
111
+ // eslint-disable-next-line react-hooks/exhaustive-deps
112
+ useMemo(() => {
113
+ // if validate prop is set, trigger validation right away on mount.
114
+ if (validate === true) {
115
+ validator.activate()
116
+ validator.run()
117
+ } else if (typeof validate === 'function') {
118
+ validate.reset()
119
+ validate.init({ validator, formId })
120
+ if (validate.always) {
121
+ validator.activate()
122
+ validator.run()
123
+ }
124
+ // when Form is unmounted, reset the parent's validate
125
+ return () => validate.reset({ formId })
126
+ }
127
+ }, []) // eslint-disable-line react-hooks/exhaustive-deps
128
+
129
+ return pug`
130
+ FormPropsContext.Provider(value=memoizedProps)
131
+ CustomInputsContext.Provider(value=memoizedCustomInputs)
132
+ ObjectInput(
133
+ properties=$fields?.get() || memoizedFields
134
+ $value=$value
135
+ order=order
136
+ row=row
137
+ errors=errors || $errors.get()
138
+ style=style
139
+ inputStyle=inputStyle
140
+ _renderWrapper=_renderWrapper
141
+ disabled=disabled
142
+ readonly=readonly
143
+ )
144
+ `
145
+ }
146
+
147
+ export default observer(Form)
148
+
149
+ export { default as useFormProps } from './useFormProps'
150
+ export { default as useValidate } from './useValidate'
151
+ export { default as useFormFields } from './useFormFields'
152
+ export { default as useFormFields$ } from './useFormFields$'
153
+
154
+ function useForceUpdate (): () => void {
155
+ const [, setState] = useState(Math.random())
156
+ return useCallback(() => {
157
+ setState(Math.random())
158
+ }, [])
159
+ }
160
+
161
+ const ERROR_PROPERTIES = `
162
+ Form: 'properties' prop can only be used directly in ObjectInput.
163
+ Use 'fields' instead
164
+ `
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@startupjs-ui/form",
3
+ "version": "0.1.3",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "main": "index.tsx",
8
+ "types": "index.d.ts",
9
+ "type": "module",
10
+ "dependencies": {
11
+ "@startupjs-ui/core": "^0.1.3",
12
+ "@startupjs-ui/input": "^0.1.3",
13
+ "@startupjs-ui/object-input": "^0.1.3",
14
+ "lodash": "^4.17.20"
15
+ },
16
+ "peerDependencies": {
17
+ "react": "*",
18
+ "react-native": "*",
19
+ "startupjs": "*"
20
+ },
21
+ "gitHead": "fd964ebc3892d3dd0a6c85438c0af619cc50c3f0"
22
+ }
@@ -0,0 +1,24 @@
1
+ import { useRef, useMemo } from 'react'
2
+ import { $ } from 'startupjs'
3
+ import useFormFields from './useFormFields'
4
+
5
+ export default function useFormFields$ (schema: any, options?: Record<string, any>): any {
6
+ const firstRenderRef = useRef(true)
7
+ const prevFieldsRef = useRef<any>(undefined)
8
+ const fields = useFormFields(schema, options ?? {})
9
+ const $fields = $(fields)
10
+
11
+ const [firstRender, prevFields] = useMemo(() => {
12
+ const firstRender = firstRenderRef.current
13
+ firstRenderRef.current = false
14
+ const prevFields = prevFieldsRef.current
15
+ prevFieldsRef.current = fields
16
+ return [firstRender, prevFields]
17
+ }, [JSON.stringify(fields)]) // eslint-disable-line react-hooks/exhaustive-deps
18
+
19
+ if (!firstRender && prevFields !== fields) {
20
+ $fields.set(fields)
21
+ }
22
+
23
+ return $fields
24
+ }
@@ -0,0 +1,12 @@
1
+ import { useMemo } from 'react'
2
+ import { pickFormFields } from 'startupjs'
3
+
4
+ export default function useFormFields (
5
+ schema: any,
6
+ options: Record<string, any> = {}
7
+ ): Record<string, any> {
8
+ return useMemo(() => {
9
+ const fields = pickFormFields(schema, options)
10
+ return JSON.parse(JSON.stringify(fields))
11
+ }, [schema, JSON.stringify(options)]) // eslint-disable-line react-hooks/exhaustive-deps
12
+ }
@@ -0,0 +1,9 @@
1
+ import { createContext, useContext, useState } from 'react'
2
+
3
+ export const FormPropsContext = createContext<Record<string, any> | undefined>(undefined)
4
+
5
+ export default function useFormProps (): Record<string, any> {
6
+ // useState is used to avoid re-creating the object on every render
7
+ const [empty] = useState<Record<string, any>>({})
8
+ return useContext(FormPropsContext) ?? empty
9
+ }
package/useValidate.ts ADDED
@@ -0,0 +1,258 @@
1
+ import { useMemo, useState, useCallback } from 'react'
2
+ import { transformSchema, ajv } from 'startupjs/schema'
3
+ import _set from 'lodash/set'
4
+ import _get from 'lodash/get'
5
+
6
+ export default function useValidate (
7
+ { always = false }: { always?: boolean } = {}
8
+ ): any {
9
+ const forceUpdate = useForceUpdate()
10
+ // eslint-disable-next-line react-hooks/exhaustive-deps
11
+ return useMemo(() => createValidateWrapper({ always, forceUpdate }), [])
12
+ }
13
+
14
+ function createValidateWrapper (
15
+ { always, forceUpdate }: { always: boolean, forceUpdate: () => void }
16
+ ): any {
17
+ const _validate = new Validate({ always, forceUpdate })
18
+ function validate (): boolean | undefined {
19
+ if (!_validate.hasValidator()) throw Error(ERRORS.notInitialized)
20
+ _validate.activate()
21
+ return _validate.run()
22
+ }
23
+ // wrap 'validate' function into Proxy to make it behave like an instance of Validate class
24
+ // while still being callable directly
25
+ const methods = new WeakMap<(...args: any[]) => any, (...args: any[]) => any>()
26
+ return new Proxy(validate, {
27
+ get (target, prop: string | symbol) {
28
+ // This is a hack to force update the component the parent component when the errors change
29
+ // if the parent component is using the errors prop to render something
30
+ // (like disabling a Submit button when there are errors)
31
+ // TODO: _shouldForceUpdate should correctly reset back to false if Form unmounts
32
+ if (prop === 'errors') {
33
+ _validate.makeReactive()
34
+ return _validate.getErrors()
35
+ } else if (prop === 'hasErrors') {
36
+ _validate.makeReactive()
37
+ return _validate.getHasErrors()
38
+ }
39
+ let res = Reflect.get(_validate as unknown as object, prop)
40
+ // bind methods called on validate (which is a function) to the _validate object
41
+ if (typeof res === 'function') {
42
+ if (!methods.has(res)) methods.set(res, res.bind(_validate))
43
+ res = methods.get(res)
44
+ }
45
+ return res
46
+ }
47
+ })
48
+ }
49
+
50
+ function useForceUpdate (): () => void {
51
+ const [, setState] = useState(Math.random())
52
+ // parent component's forceUpdate might be called from a child component
53
+ // so we need to use setTimeout to make sure it runs asynchronously
54
+ return useCallback(() => {
55
+ setTimeout(() => {
56
+ setState(Math.random())
57
+ }, 0)
58
+ }, [])
59
+ }
60
+
61
+ const ERRORS = {
62
+ notInitialized: `
63
+ useValidate():
64
+ 'validate' is not initialized with the Form component.
65
+
66
+ You must pass 'validate' from useValidate()
67
+ to the 'validate' prop of the Form component:
68
+
69
+ const validate = useValidate()
70
+ <Form validate={validate} ... />
71
+ `
72
+ }
73
+
74
+ class Validate {
75
+ always: boolean
76
+
77
+ private _isReactive?: boolean
78
+ private _lastHasErrors?: boolean
79
+ private readonly _forceUpdate: () => void
80
+ private _validator?: Validator
81
+ private _formId?: string
82
+
83
+ constructor ({ forceUpdate, always }: { forceUpdate: () => void, always: boolean }) {
84
+ this._forceUpdate = forceUpdate
85
+ this.always = always
86
+ }
87
+
88
+ init ({ validator, formId }: { validator: Validator, formId: string }): void {
89
+ this._validator = validator
90
+ this._formId = formId
91
+ this._validator.onHasErrorsChange = ({ formId }: { formId?: string } = {}) => {
92
+ // only the Form which is currently associated with this 'validate' can force an update
93
+ if (formId && formId !== this._formId) return
94
+ if (this._isReactive) this._forceUpdate()
95
+ }
96
+ }
97
+
98
+ activate (): void {
99
+ this._validator?.activate()
100
+ }
101
+
102
+ deactivate (): void {
103
+ this._validator?.deactivate()
104
+ }
105
+
106
+ makeReactive (): void {
107
+ this._isReactive = true
108
+ }
109
+
110
+ hasValidator (): boolean {
111
+ return !!this._validator
112
+ }
113
+
114
+ run (): boolean | undefined {
115
+ if (!this._validator) throw Error('Validator is not set')
116
+ return this._validator.run()
117
+ }
118
+
119
+ getErrors (): any {
120
+ return this._validator?.getErrors()
121
+ }
122
+
123
+ getHasErrors (): boolean | undefined {
124
+ const hasErrors = this._validator?.getHasErrors()
125
+ this._lastHasErrors = hasErrors
126
+ return hasErrors
127
+ }
128
+
129
+ reset ({ formId }: { formId?: string } = {}): void {
130
+ // if formId is set, reset only if it matches the formId currently associated with this 'validate'.
131
+ // This prevents race conditions when a Form is unmounted and another Form is mounted right away
132
+ // which uses the same 'validate' prop.
133
+ // Only one Form can be associated with one 'validate' at a time.
134
+ // TODO: add check to init() to prevent multiple Forms from using the same 'validate'.
135
+ if (formId && formId !== this._formId) return
136
+ this._validator = undefined
137
+ this._formId = undefined
138
+ if (this._lastHasErrors) {
139
+ this._lastHasErrors = undefined
140
+ if (this._isReactive) this._forceUpdate()
141
+ }
142
+ }
143
+ }
144
+
145
+ export class Validator {
146
+ onHasErrorsChange?: (args?: { formId?: string }) => void
147
+
148
+ private _active?: boolean
149
+ private _validate?: any
150
+ private _getValue?: () => any
151
+ private _hasErrors?: boolean
152
+ private _$errors?: any
153
+ private _initialized?: boolean
154
+ private _forceUpdate?: () => void
155
+ private _formId?: string
156
+
157
+ init ({
158
+ fields, // either simplified schema or full schema
159
+ getValue, // obtain current value to validate (usually it will be from a closure of Form component)
160
+ $errors, // reactive object to store errors, this is basically a hack to use model's .setDiffDeep()
161
+ forceUpdate, // force update Form itself
162
+ formId
163
+ }: {
164
+ fields: Record<string, any> | any
165
+ getValue: () => any
166
+ $errors: any
167
+ forceUpdate: () => void
168
+ formId: string
169
+ }): void {
170
+ let schema = fields
171
+ // we allow extra properties in Form to let people just pass the full document
172
+ // instead of forcing them to pick only the fields used in schema
173
+ schema = transformSchema(schema, { additionalProperties: true })
174
+ this._validate = ajv.compile(schema)
175
+ this._getValue = getValue
176
+ this._$errors = $errors
177
+ this._forceUpdate = forceUpdate
178
+ this._formId = formId
179
+ this._initialized = true
180
+ }
181
+
182
+ activate (): void {
183
+ this._active = true
184
+ }
185
+
186
+ deactivate (): void {
187
+ this._active = false
188
+ }
189
+
190
+ getErrors (): any {
191
+ return this._$errors?.get()
192
+ }
193
+
194
+ getHasErrors (): boolean | undefined {
195
+ return this._hasErrors
196
+ }
197
+
198
+ run (): boolean | undefined {
199
+ if (!this._active) return
200
+ if (!this._initialized) throw Error('Validator is not initialized')
201
+ const valid = this._validate(this._getValue?.())
202
+ if (valid) {
203
+ if (this._hasErrors) {
204
+ this._hasErrors = undefined
205
+ this._$errors?.del()
206
+ this._forceUpdate?.()
207
+ this.onHasErrorsChange?.({ formId: this._formId })
208
+ }
209
+ return true
210
+ } else {
211
+ const newErrors = transformAjvErrors(this._validate.errors)
212
+ const hadErrors = this._hasErrors
213
+ this._hasErrors = true
214
+ this._$errors?.set(newErrors)
215
+ if (!hadErrors) {
216
+ this._forceUpdate?.()
217
+ this.onHasErrorsChange?.({ formId: this._formId })
218
+ }
219
+ return false
220
+ }
221
+ }
222
+ }
223
+
224
+ // transform errors from ajv to our format
225
+ function transformAjvErrors (errors?: any[]): Record<string, any> {
226
+ const res: Record<string, any> = {}
227
+
228
+ for (const error of errors ?? []) {
229
+ let path: any
230
+ let message: any
231
+
232
+ // Handling errors related to required fields
233
+ // since required errors are declared at the root of the schema,
234
+ // the instancePath for them is ''
235
+ if (error.instancePath === '') {
236
+ if (error.keyword === 'required') {
237
+ path = error.params.missingProperty
238
+ message = 'This field is required'
239
+ } else if (
240
+ error.keyword === 'errorMessage' &&
241
+ error.params.errors[0].keyword === 'required'
242
+ ) {
243
+ // Handling ajv-errors errors
244
+ // ajv-errors generates the 'errorMessage' keyword with params.errors
245
+ path = error.params.errors[0].params.missingProperty
246
+ message = error.message
247
+ }
248
+ } else {
249
+ // Handling other types of errors
250
+ path = error.instancePath.replace(/^\//, '').split('/')
251
+ message = error.message
252
+ }
253
+ if (!_get(res, path)) _set(res, path, [])
254
+ _get(res, path).push(message)
255
+ }
256
+
257
+ return res
258
+ }