@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 +25 -0
- package/README.mdx +243 -0
- package/index.d.ts +48 -0
- package/index.tsx +164 -0
- package/package.json +22 -0
- package/useFormFields$.ts +24 -0
- package/useFormFields.ts +12 -0
- package/useFormProps.ts +9 -0
- package/useValidate.ts +258 -0
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
|
+
}
|
package/useFormFields.ts
ADDED
|
@@ -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
|
+
}
|
package/useFormProps.ts
ADDED
|
@@ -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
|
+
}
|