@neko-os/ui 0.0.12 → 0.1.0
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/dist/abstractions/KeyboardAvoidingView.js +1 -0
- package/dist/abstractions/KeyboardAvoidingView.native.js +1 -0
- package/dist/components/actions/ActionsDrawer.js +1 -0
- package/dist/components/actions/Button.js +1 -1
- package/dist/components/actions/Dropdown.js +1 -1
- package/dist/components/actions/FloatingMenu.js +1 -0
- package/dist/components/actions/index.js +1 -1
- package/dist/components/actions/menu/VerticalMenu.js +1 -1
- package/dist/components/animations/AnimatedTopBar.js +1 -0
- package/dist/components/animations/AnimatedTopBar.native.js +1 -0
- package/dist/components/animations/AnimatedTopBar.web.js +1 -0
- package/dist/components/animations/ParallaxHeader.js +1 -0
- package/dist/components/animations/ParallaxHeader.native.js +1 -0
- package/dist/components/animations/ParallaxHeader.web.js +1 -0
- package/dist/components/animations/ReanimatedScrollHandler.js +1 -0
- package/dist/components/animations/ReanimatedScrollHandler.native.js +1 -0
- package/dist/components/animations/ReanimatedScrollHandler.web.js +1 -0
- package/dist/components/animations/index.js +1 -1
- package/dist/components/form/FormItem.js +1 -1
- package/dist/components/form/FormList.js +1 -1
- package/dist/components/form/SubmitButton.js +1 -1
- package/dist/components/form/index.js +1 -1
- package/dist/components/form/useNewForm.js +1 -1
- package/dist/components/form/validation/defaultMessages.js +1 -0
- package/dist/components/form/validation/index.js +1 -0
- package/dist/components/form/validation/normalizeRules.js +1 -0
- package/dist/components/form/validation/shouldValidateOn.js +1 -0
- package/dist/components/form/validation/validateRules.js +1 -0
- package/dist/components/form/validation/validators.js +1 -0
- package/dist/components/index.js +1 -1
- package/dist/components/inputs/InputWrapper.js +1 -1
- package/dist/components/inputs/NumberInput.js +1 -1
- package/dist/components/inputs/Picker.js +1 -1
- package/dist/components/inputs/Select.js +1 -1
- package/dist/components/presentation/Avatar.js +1 -1
- package/dist/components/presentation/AvatarLabel.js +1 -1
- package/dist/components/presentation/LabelValue.js +1 -1
- package/dist/components/presentation/Result.js +1 -1
- package/dist/components/presentation/Tooltip.js +1 -1
- package/dist/components/sections/Section.js +1 -0
- package/dist/components/sections/SectionItem.js +1 -0
- package/dist/components/sections/SectionItemLink.js +1 -0
- package/dist/components/sections/index.js +1 -0
- package/dist/components/state/StatePresenter.js +1 -0
- package/dist/components/state/index.js +1 -1
- package/dist/components/structure/BlurView.js +1 -1
- package/dist/components/structure/KeyboardAvoidingView.js +1 -0
- package/dist/components/structure/TopBar.js +1 -0
- package/dist/components/structure/bottomDrawer/index.js +1 -1
- package/dist/components/structure/bottomDrawer/index.native.js +1 -1
- package/dist/components/structure/bottomDrawer/index.web.js +1 -1
- package/dist/components/structure/bottomDrawer/native/BottomDrawer.js +1 -1
- package/dist/components/structure/bottomDrawer/native/DrawerScrollView.js +1 -1
- package/dist/components/structure/bottomDrawer/native/createDrawerScrollComponent.js +1 -0
- package/dist/components/structure/drawer/Drawer.web.js +1 -0
- package/dist/components/structure/index.js +1 -1
- package/dist/components/text/DateText.js +1 -0
- package/dist/components/text/index.js +1 -1
- package/dist/components/theme/ThemePicker.js +1 -1
- package/dist/helpers/index.js +1 -1
- package/dist/helpers/storage.js +1 -1
- package/dist/responsive/responsiveHooks.js +1 -1
- package/dist/theme/ThemeHandler.js +1 -1
- package/dist/theme/default/base.js +1 -1
- package/dist/theme/default/blackTheme.js +1 -1
- package/dist/theme/default/cyberpunkTheme.js +1 -1
- package/dist/theme/default/darkTheme.js +1 -1
- package/dist/theme/default/hackerTheme.js +1 -1
- package/dist/theme/default/lightTheme.js +1 -1
- package/dist/theme/default/msdosTheme.js +1 -1
- package/dist/theme/default/paperTheme.js +1 -1
- package/package.json +1 -1
- package/src/abstractions/KeyboardAvoidingView.js +3 -0
- package/src/abstractions/KeyboardAvoidingView.native.js +3 -0
- package/src/components/actions/ActionsDrawer.js +68 -0
- package/src/components/actions/Button.js +2 -1
- package/src/components/actions/Dropdown.js +11 -8
- package/src/components/actions/FloatingMenu.js +39 -0
- package/src/components/actions/index.js +2 -0
- package/src/components/actions/menu/VerticalMenu.js +1 -2
- package/src/components/animations/AnimatedTopBar.js +10 -0
- package/src/components/animations/AnimatedTopBar.native.js +34 -0
- package/src/components/animations/AnimatedTopBar.web.js +1 -0
- package/src/components/animations/ParallaxHeader.js +9 -0
- package/src/components/animations/ParallaxHeader.native.js +32 -0
- package/src/components/animations/ParallaxHeader.web.js +32 -0
- package/src/components/animations/ReanimatedScrollHandler.js +8 -0
- package/src/components/animations/ReanimatedScrollHandler.native.js +24 -0
- package/src/components/animations/ReanimatedScrollHandler.web.js +1 -0
- package/src/components/animations/index.js +3 -0
- package/src/components/form/FormItem.js +42 -5
- package/src/components/form/FormList.js +23 -4
- package/src/components/form/SubmitButton.js +4 -2
- package/src/components/form/index.js +1 -0
- package/src/components/form/useNewForm.js +108 -15
- package/src/components/form/validation/defaultMessages.js +20 -0
- package/src/components/form/validation/index.js +5 -0
- package/src/components/form/validation/normalizeRules.js +22 -0
- package/src/components/form/validation/shouldValidateOn.js +21 -0
- package/src/components/form/validation/validateRules.js +83 -0
- package/src/components/form/validation/validators.js +82 -0
- package/src/components/index.js +1 -0
- package/src/components/inputs/InputWrapper.js +1 -1
- package/src/components/inputs/NumberInput.js +6 -5
- package/src/components/inputs/Picker.js +3 -2
- package/src/components/inputs/Select.js +31 -15
- package/src/components/presentation/Avatar.js +2 -2
- package/src/components/presentation/AvatarLabel.js +2 -0
- package/src/components/presentation/LabelValue.js +7 -5
- package/src/components/presentation/Result.js +2 -2
- package/src/components/presentation/Tooltip.js +1 -1
- package/src/components/sections/Section.js +50 -0
- package/src/components/sections/SectionItem.js +24 -0
- package/src/components/sections/SectionItemLink.js +33 -0
- package/src/components/sections/index.js +3 -0
- package/src/components/state/StatePresenter.js +41 -0
- package/src/components/state/index.js +1 -0
- package/src/components/structure/BlurView.js +1 -0
- package/src/components/structure/KeyboardAvoidingView.js +52 -0
- package/src/components/structure/TopBar.js +45 -0
- package/src/components/structure/bottomDrawer/index.js +2 -0
- package/src/components/structure/bottomDrawer/index.native.js +2 -1
- package/src/components/structure/bottomDrawer/index.web.js +2 -1
- package/src/components/structure/bottomDrawer/native/BottomDrawer.js +14 -20
- package/src/components/structure/bottomDrawer/native/DrawerScrollView.js +4 -82
- package/src/components/structure/bottomDrawer/native/createDrawerScrollComponent.js +131 -0
- package/src/components/structure/drawer/Drawer.web.js +3 -0
- package/src/components/structure/index.js +2 -0
- package/src/components/text/DateText.js +11 -0
- package/src/components/text/index.js +1 -0
- package/src/components/theme/ThemePicker.js +1 -2
- package/src/helpers/index.js +1 -0
- package/src/helpers/storage.js +32 -9
- package/src/responsive/responsiveHooks.js +6 -0
- package/src/theme/ThemeHandler.js +6 -3
- package/src/theme/default/base.js +16 -4
- package/src/theme/default/blackTheme.js +1 -0
- package/src/theme/default/cyberpunkTheme.js +10 -0
- package/src/theme/default/darkTheme.js +1 -0
- package/src/theme/default/hackerTheme.js +17 -3
- package/src/theme/default/lightTheme.js +1 -0
- package/src/theme/default/msdosTheme.js +9 -10
- package/src/theme/default/paperTheme.js +10 -0
|
@@ -5,38 +5,75 @@ import { Text } from '../text/Text'
|
|
|
5
5
|
import { View } from '../structure/View'
|
|
6
6
|
import { clearProps } from '../../modifiers/_helpers'
|
|
7
7
|
import { useFormInstance, useFormState } from './Form'
|
|
8
|
+
import { shouldValidateOn } from './validation'
|
|
8
9
|
|
|
9
|
-
export function FormItem({
|
|
10
|
+
export function FormItem({
|
|
11
|
+
name,
|
|
12
|
+
label,
|
|
13
|
+
isAbsolutePath,
|
|
14
|
+
children,
|
|
15
|
+
useDefaultValue,
|
|
16
|
+
rules,
|
|
17
|
+
validateTrigger = 'onSubmit',
|
|
18
|
+
...props
|
|
19
|
+
}) {
|
|
10
20
|
const form = useFormInstance()
|
|
11
21
|
const formState = useFormState()
|
|
12
22
|
const listPath = useRelativePath(name, { isAbsolutePath })
|
|
23
|
+
const listPathStr = listPath.join('$NEKOJOIN$')
|
|
13
24
|
const [value, setValue] = React.useState(form.getFieldValue(listPath))
|
|
14
|
-
const error = form.getError(listPath)
|
|
25
|
+
const [error, setError] = React.useState(form.getError(listPath))
|
|
15
26
|
|
|
27
|
+
// Register rules with the form
|
|
28
|
+
React.useEffect(() => {
|
|
29
|
+
return form.registerRules(listPath, rules, validateTrigger)
|
|
30
|
+
}, [listPathStr, JSON.stringify(rules), validateTrigger])
|
|
31
|
+
|
|
32
|
+
// Listen for value changes
|
|
16
33
|
React.useEffect(() => {
|
|
17
34
|
return form.registerListener(listPath, (val) => setValue(val))
|
|
18
|
-
}, [
|
|
35
|
+
}, [listPathStr])
|
|
36
|
+
|
|
37
|
+
// Listen for error changes
|
|
38
|
+
React.useEffect(() => {
|
|
39
|
+
return form.registerErrorListener(listPath, (err) => setError(err))
|
|
40
|
+
}, [listPathStr])
|
|
19
41
|
|
|
20
42
|
const handleChange = (e) => {
|
|
21
43
|
const val = e?.target?.value ?? e
|
|
22
44
|
form.setFieldValue(listPath, val)
|
|
45
|
+
|
|
46
|
+
if (shouldValidateOn('onChange', rules, validateTrigger)) {
|
|
47
|
+
form.validateField(listPath, 'onChange')
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const handleBlur = (e, originalOnBlur) => {
|
|
52
|
+
if (originalOnBlur) originalOnBlur(e)
|
|
53
|
+
|
|
54
|
+
if (shouldValidateOn('onBlur', rules, validateTrigger)) {
|
|
55
|
+
form.validateField(listPath, 'onBlur')
|
|
56
|
+
}
|
|
23
57
|
}
|
|
24
58
|
|
|
25
59
|
let valueKey = 'value'
|
|
26
60
|
if (!!useDefaultValue) valueKey = 'defaultValue'
|
|
27
61
|
|
|
62
|
+
const child = typeof children === 'function' ? null : React.Children.only(children)
|
|
63
|
+
const originalOnBlur = child?.props?.onBlur
|
|
64
|
+
|
|
28
65
|
const childProps = clearProps({
|
|
29
66
|
[valueKey]: value === undefined ? '' : value,
|
|
30
67
|
onChange: handleChange,
|
|
31
|
-
|
|
68
|
+
onBlur: (e) => handleBlur(e, originalOnBlur),
|
|
32
69
|
disabled: formState?.disabled === true || undefined,
|
|
70
|
+
error: !!error || undefined,
|
|
33
71
|
})
|
|
34
72
|
|
|
35
73
|
let content
|
|
36
74
|
if (typeof children === 'function') {
|
|
37
75
|
content = children(childProps)
|
|
38
76
|
} else {
|
|
39
|
-
const child = React.Children.only(children)
|
|
40
77
|
content = React.cloneElement(child, { ...child.props, ...childProps })
|
|
41
78
|
}
|
|
42
79
|
|
|
@@ -3,22 +3,32 @@ import React from 'react'
|
|
|
3
3
|
import { FormGroup, useRelativePath } from './FormGroup'
|
|
4
4
|
import { Text } from '../text/Text'
|
|
5
5
|
import { useFormInstance } from './Form'
|
|
6
|
+
import { shouldValidateOn } from './validation'
|
|
6
7
|
|
|
7
8
|
const FormListContext = React.createContext(null)
|
|
8
9
|
const useFormList = () => React.useContext(FormListContext)
|
|
9
10
|
|
|
10
|
-
export function FormList({ name, isAbsolutePath, children }) {
|
|
11
|
+
export function FormList({ name, isAbsolutePath, children, rules, validateTrigger = 'onSubmit' }) {
|
|
11
12
|
const form = useFormInstance()
|
|
12
13
|
const listPath = useRelativePath(name, { isAbsolutePath })
|
|
13
|
-
// To avoid watch being recalled
|
|
14
14
|
const listPathStr = listPath.join('$NEKOJOIN$')
|
|
15
|
-
const error = form.getError(listPath)
|
|
15
|
+
const [error, setError] = React.useState(form.getError(listPath))
|
|
16
16
|
|
|
17
17
|
// Counter to generate unique keys
|
|
18
18
|
const keyCounter = React.useRef(0)
|
|
19
19
|
// Map to track keys by value reference
|
|
20
20
|
const keysMap = React.useRef(new WeakMap())
|
|
21
21
|
|
|
22
|
+
// Register rules with the form
|
|
23
|
+
React.useEffect(() => {
|
|
24
|
+
return form.registerRules(listPath, rules, validateTrigger)
|
|
25
|
+
}, [listPathStr, JSON.stringify(rules), validateTrigger])
|
|
26
|
+
|
|
27
|
+
// Listen for error changes
|
|
28
|
+
React.useEffect(() => {
|
|
29
|
+
return form.registerErrorListener(listPath, (err) => setError(err))
|
|
30
|
+
}, [listPathStr])
|
|
31
|
+
|
|
22
32
|
const generateFields = (items) => {
|
|
23
33
|
if (!Array.isArray(items)) return []
|
|
24
34
|
return items.map((item, index) => {
|
|
@@ -47,14 +57,22 @@ export function FormList({ name, isAbsolutePath, children }) {
|
|
|
47
57
|
})
|
|
48
58
|
}, [listPathStr])
|
|
49
59
|
|
|
60
|
+
const validateOnChange = () => {
|
|
61
|
+
if (shouldValidateOn('onChange', rules, validateTrigger)) {
|
|
62
|
+
form.validateField(listPath, 'onChange')
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
50
66
|
const add = (defaultValue = {}) => {
|
|
51
67
|
const current = form.getFieldValue(listPath) || []
|
|
52
68
|
form.setFieldValue(listPath, [...current, defaultValue])
|
|
69
|
+
validateOnChange()
|
|
53
70
|
}
|
|
54
71
|
|
|
55
72
|
const addOn = (index, defaultValue = {}) => {
|
|
56
73
|
const current = form.getFieldValue(listPath) || []
|
|
57
74
|
form.setFieldValue(listPath, [...current.slice(0, index), defaultValue, ...current.slice(index)])
|
|
75
|
+
validateOnChange()
|
|
58
76
|
}
|
|
59
77
|
|
|
60
78
|
const replace = (index, value) => {
|
|
@@ -91,6 +109,7 @@ export function FormList({ name, isAbsolutePath, children }) {
|
|
|
91
109
|
listPath,
|
|
92
110
|
current.filter((_, i) => i !== index)
|
|
93
111
|
)
|
|
112
|
+
validateOnChange()
|
|
94
113
|
}
|
|
95
114
|
|
|
96
115
|
const actions = React.useMemo(
|
|
@@ -102,7 +121,7 @@ export function FormList({ name, isAbsolutePath, children }) {
|
|
|
102
121
|
move,
|
|
103
122
|
duplicate,
|
|
104
123
|
}),
|
|
105
|
-
[listPathStr]
|
|
124
|
+
[listPathStr, rules, validateTrigger]
|
|
106
125
|
)
|
|
107
126
|
|
|
108
127
|
let content
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { Button } from '../actions/Button'
|
|
2
2
|
import { useFormInstance, useFormState } from './Form'
|
|
3
3
|
|
|
4
|
-
export function SubmitButton({ form, disabled, ...props }) {
|
|
4
|
+
export function SubmitButton({ form, disabled, Wrapper, ...props }) {
|
|
5
5
|
const formState = useFormState()
|
|
6
6
|
const contextForm = useFormInstance()
|
|
7
7
|
form = form || contextForm
|
|
8
8
|
disabled = formState?.disabled || disabled
|
|
9
9
|
|
|
10
|
+
Wrapper = Wrapper || Button
|
|
11
|
+
|
|
10
12
|
const handleSubmit = () => {
|
|
11
13
|
if (!form) {
|
|
12
14
|
console.error('No form provided to useWatch. Pass it as params or wrap it inside a <Form> component.')
|
|
@@ -16,5 +18,5 @@ export function SubmitButton({ form, disabled, ...props }) {
|
|
|
16
18
|
form.handleSubmit()
|
|
17
19
|
}
|
|
18
20
|
|
|
19
|
-
return <
|
|
21
|
+
return <Wrapper {...props} disabled={disabled} onPress={handleSubmit} />
|
|
20
22
|
}
|
|
@@ -1,34 +1,61 @@
|
|
|
1
1
|
import { assocPath, path } from 'ramda'
|
|
2
2
|
import React from 'react'
|
|
3
|
+
import { validateRules, validateAllFields, normalizeRules } from './validation'
|
|
3
4
|
|
|
4
5
|
export function useNewForm({ initialValues = {}, validate, onSubmit } = {}) {
|
|
5
6
|
const valuesRef = React.useRef({ ...initialValues })
|
|
6
|
-
const errorsRef = React.useRef({})
|
|
7
|
+
const errorsRef = React.useRef({}) // Flat structure: { 'users': 'error', 'users.0.name': 'error' }
|
|
7
8
|
const listenersRef = React.useRef({})
|
|
9
|
+
const errorListenersRef = React.useRef({})
|
|
10
|
+
const rulesRegistryRef = React.useRef(new Map())
|
|
8
11
|
|
|
9
12
|
const formApi = React.useMemo(() => {
|
|
13
|
+
const toKey = (name) => (Array.isArray(name) ? name.join('.') : name)
|
|
14
|
+
const toPath = (name) => (Array.isArray(name) ? name : [name])
|
|
15
|
+
|
|
10
16
|
const notify = (name) => {
|
|
11
|
-
const key =
|
|
17
|
+
const key = toKey(name)
|
|
12
18
|
if (listenersRef.current[key]) {
|
|
13
|
-
listenersRef.current[key].forEach((cb) => cb(path(name, valuesRef.current)))
|
|
19
|
+
listenersRef.current[key].forEach((cb) => cb(path(toPath(name), valuesRef.current)))
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const notifyError = (name) => {
|
|
24
|
+
const key = toKey(name)
|
|
25
|
+
if (errorListenersRef.current[key]) {
|
|
26
|
+
errorListenersRef.current[key].forEach((cb) => cb(errorsRef.current[key]))
|
|
14
27
|
}
|
|
15
28
|
}
|
|
16
29
|
|
|
17
30
|
const setFieldValue = (name, value) => {
|
|
18
|
-
valuesRef.current = assocPath(name, value, valuesRef.current)
|
|
31
|
+
valuesRef.current = assocPath(toPath(name), value, valuesRef.current)
|
|
19
32
|
notify(name)
|
|
20
33
|
}
|
|
21
34
|
|
|
22
|
-
const getFieldValue = (name) => path(name, valuesRef.current)
|
|
35
|
+
const getFieldValue = (name) => path(toPath(name), valuesRef.current)
|
|
23
36
|
|
|
24
|
-
|
|
37
|
+
// Flat error lookup by key
|
|
38
|
+
const getError = (name) => {
|
|
39
|
+
const key = toKey(name)
|
|
40
|
+
return errorsRef.current[key]
|
|
41
|
+
}
|
|
25
42
|
|
|
26
43
|
const setError = (name, error) => {
|
|
27
|
-
|
|
44
|
+
const key = toKey(name)
|
|
45
|
+
if (error) {
|
|
46
|
+
errorsRef.current[key] = error
|
|
47
|
+
} else {
|
|
48
|
+
delete errorsRef.current[key]
|
|
49
|
+
}
|
|
50
|
+
notifyError(name)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const clearErrors = () => {
|
|
54
|
+
errorsRef.current = {}
|
|
28
55
|
}
|
|
29
56
|
|
|
30
57
|
const registerListener = (name, cb) => {
|
|
31
|
-
const key =
|
|
58
|
+
const key = toKey(name)
|
|
32
59
|
if (!listenersRef.current[key]) {
|
|
33
60
|
listenersRef.current[key] = []
|
|
34
61
|
}
|
|
@@ -38,15 +65,77 @@ export function useNewForm({ initialValues = {}, validate, onSubmit } = {}) {
|
|
|
38
65
|
}
|
|
39
66
|
}
|
|
40
67
|
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
68
|
+
const registerErrorListener = (name, cb) => {
|
|
69
|
+
const key = toKey(name)
|
|
70
|
+
if (!errorListenersRef.current[key]) {
|
|
71
|
+
errorListenersRef.current[key] = []
|
|
72
|
+
}
|
|
73
|
+
errorListenersRef.current[key].push(cb)
|
|
74
|
+
return () => {
|
|
75
|
+
errorListenersRef.current[key] = errorListenersRef.current[key].filter((fn) => fn !== cb)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const registerRules = (name, rules, defaultTrigger = 'onSubmit') => {
|
|
80
|
+
if (!rules) return
|
|
81
|
+
const key = toKey(name)
|
|
82
|
+
const rulesArray = normalizeRules(rules).map((rule) => ({
|
|
83
|
+
...rule,
|
|
84
|
+
trigger: rule.trigger || defaultTrigger,
|
|
85
|
+
}))
|
|
86
|
+
rulesRegistryRef.current.set(key, { path: name, rules: rulesArray })
|
|
87
|
+
return () => rulesRegistryRef.current.delete(key)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const validateField = async (name, trigger = 'onSubmit') => {
|
|
91
|
+
const key = toKey(name)
|
|
92
|
+
const entry = rulesRegistryRef.current.get(key)
|
|
93
|
+
if (!entry) return null
|
|
94
|
+
|
|
95
|
+
const value = path(name, valuesRef.current)
|
|
96
|
+
const error = await validateRules(value, entry.rules, trigger)
|
|
97
|
+
|
|
98
|
+
if (error) {
|
|
99
|
+
errorsRef.current[key] = error
|
|
100
|
+
} else {
|
|
101
|
+
delete errorsRef.current[key]
|
|
102
|
+
}
|
|
103
|
+
notifyError(name)
|
|
104
|
+
return error
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const validateForm = async () => {
|
|
108
|
+
// Clear previous errors
|
|
109
|
+
errorsRef.current = {}
|
|
110
|
+
|
|
111
|
+
// Run rules-based validation
|
|
112
|
+
const rulesErrors = await validateAllFields(valuesRef.current, rulesRegistryRef.current)
|
|
113
|
+
|
|
114
|
+
// Run legacy validate function if provided
|
|
115
|
+
const legacyErrors = validate ? validate(valuesRef.current) || {} : {}
|
|
116
|
+
|
|
117
|
+
// Store errors in flat structure
|
|
118
|
+
Object.entries(rulesErrors).forEach(([key, error]) => {
|
|
119
|
+
errorsRef.current[key] = error
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// Legacy errors are already flat (or should be converted)
|
|
123
|
+
Object.entries(legacyErrors).forEach(([key, error]) => {
|
|
124
|
+
if (!errorsRef.current[key]) {
|
|
125
|
+
errorsRef.current[key] = error
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
// Notify all error listeners
|
|
130
|
+
rulesRegistryRef.current.forEach((_, key) => {
|
|
131
|
+
notifyError(key)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
return Object.keys(errorsRef.current).length === 0
|
|
46
135
|
}
|
|
47
136
|
|
|
48
|
-
const handleSubmit = () => {
|
|
49
|
-
const isValid = validateForm()
|
|
137
|
+
const handleSubmit = async () => {
|
|
138
|
+
const isValid = await validateForm()
|
|
50
139
|
if (!isValid) return
|
|
51
140
|
console.log('SUBMIT')
|
|
52
141
|
onSubmit(valuesRef.current)
|
|
@@ -57,7 +146,11 @@ export function useNewForm({ initialValues = {}, validate, onSubmit } = {}) {
|
|
|
57
146
|
getFieldValue,
|
|
58
147
|
getError,
|
|
59
148
|
setError,
|
|
149
|
+
clearErrors,
|
|
60
150
|
registerListener,
|
|
151
|
+
registerErrorListener,
|
|
152
|
+
registerRules,
|
|
153
|
+
validateField,
|
|
61
154
|
handleSubmit,
|
|
62
155
|
valuesRef,
|
|
63
156
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const defaultMessages = {
|
|
2
|
+
required: 'This field is required',
|
|
3
|
+
type: {
|
|
4
|
+
email: 'Please enter a valid email address',
|
|
5
|
+
url: 'Please enter a valid URL',
|
|
6
|
+
number: 'Please enter a valid number',
|
|
7
|
+
integer: 'Please enter a valid integer',
|
|
8
|
+
},
|
|
9
|
+
min: {
|
|
10
|
+
string: (min) => `Must be at least ${min} characters`,
|
|
11
|
+
number: (min) => `Must be at least ${min}`,
|
|
12
|
+
array: (min) => `Must have at least ${min} items`,
|
|
13
|
+
},
|
|
14
|
+
max: {
|
|
15
|
+
string: (max) => `Must be at most ${max} characters`,
|
|
16
|
+
number: (max) => `Must be at most ${max}`,
|
|
17
|
+
array: (max) => `Must have at most ${max} items`,
|
|
18
|
+
},
|
|
19
|
+
pattern: 'Invalid format',
|
|
20
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { defaultMessages } from './defaultMessages'
|
|
2
|
+
export { validators } from './validators'
|
|
3
|
+
export { validateRules, validateAllFields } from './validateRules'
|
|
4
|
+
export { normalizeRules } from './normalizeRules'
|
|
5
|
+
export { shouldValidateOn } from './shouldValidateOn'
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalizes rules to array format.
|
|
3
|
+
* Accepts either array format or object shorthand.
|
|
4
|
+
*
|
|
5
|
+
* Array format (full control):
|
|
6
|
+
* [{ required: true, message: 'Required' }, { min: 2 }]
|
|
7
|
+
*
|
|
8
|
+
* Object shorthand (simple cases):
|
|
9
|
+
* { required: true, min: 2, max: 7, type: 'email' }
|
|
10
|
+
* -> converts to: [{ required: true }, { min: 2 }, { max: 7 }, { type: 'email' }]
|
|
11
|
+
*
|
|
12
|
+
* @param {Array|Object} rules
|
|
13
|
+
* @returns {Array}
|
|
14
|
+
*/
|
|
15
|
+
export function normalizeRules(rules) {
|
|
16
|
+
if (!rules) return []
|
|
17
|
+
if (Array.isArray(rules)) return rules
|
|
18
|
+
if (typeof rules === 'object') {
|
|
19
|
+
return Object.entries(rules).map(([key, value]) => ({ [key]: value }))
|
|
20
|
+
}
|
|
21
|
+
return []
|
|
22
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { normalizeRules } from './normalizeRules'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Checks if validation should run for a given trigger.
|
|
5
|
+
* @param {string} trigger - The trigger to check ('onChange', 'onBlur', 'onSubmit')
|
|
6
|
+
* @param {Array|Object} rules - The rules (array or object format)
|
|
7
|
+
* @param {string|string[]} validateTrigger - The default trigger(s) for the field
|
|
8
|
+
* @returns {boolean}
|
|
9
|
+
*/
|
|
10
|
+
export function shouldValidateOn(trigger, rules, validateTrigger = 'onSubmit') {
|
|
11
|
+
if (!rules) return false
|
|
12
|
+
|
|
13
|
+
const triggers = Array.isArray(validateTrigger) ? validateTrigger : [validateTrigger]
|
|
14
|
+
if (triggers.includes(trigger)) return true
|
|
15
|
+
|
|
16
|
+
// Check per-rule triggers
|
|
17
|
+
const rulesArray = normalizeRules(rules)
|
|
18
|
+
return rulesArray.some(
|
|
19
|
+
(rule) => rule.trigger === trigger || (Array.isArray(rule.trigger) && rule.trigger.includes(trigger))
|
|
20
|
+
)
|
|
21
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { path as getPath } from 'ramda'
|
|
2
|
+
import { validators } from './validators'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Validates a value against an array of rules
|
|
6
|
+
* @param {any} value - The value to validate
|
|
7
|
+
* @param {Array} rules - Array of rule objects
|
|
8
|
+
* @param {string} trigger - Current validation trigger ('onSubmit', 'onBlur', 'onChange')
|
|
9
|
+
* @returns {Promise<string|null>} - First error message or null if valid
|
|
10
|
+
*/
|
|
11
|
+
export async function validateRules(value, rules, trigger = 'onSubmit') {
|
|
12
|
+
if (!rules || rules.length === 0) return null
|
|
13
|
+
|
|
14
|
+
for (const rule of rules) {
|
|
15
|
+
const ruleTrigger = rule.trigger || 'onSubmit'
|
|
16
|
+
const triggers = Array.isArray(ruleTrigger) ? ruleTrigger : [ruleTrigger]
|
|
17
|
+
|
|
18
|
+
// Always run on submit, otherwise check trigger match
|
|
19
|
+
if (trigger !== 'onSubmit' && !triggers.includes(trigger)) {
|
|
20
|
+
continue
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let error = null
|
|
24
|
+
|
|
25
|
+
// Custom validator takes precedence
|
|
26
|
+
if (rule.validator) {
|
|
27
|
+
try {
|
|
28
|
+
await rule.validator(rule, value)
|
|
29
|
+
} catch (e) {
|
|
30
|
+
error = e.message || rule.message || 'Validation failed'
|
|
31
|
+
}
|
|
32
|
+
} else {
|
|
33
|
+
// Run built-in validators
|
|
34
|
+
if (rule.required) {
|
|
35
|
+
error = validators.required(value, rule)
|
|
36
|
+
}
|
|
37
|
+
if (!error && rule.type) {
|
|
38
|
+
error = validators.type(value, rule)
|
|
39
|
+
}
|
|
40
|
+
if (!error && rule.min !== undefined) {
|
|
41
|
+
error = validators.min(value, rule)
|
|
42
|
+
}
|
|
43
|
+
if (!error && rule.max !== undefined) {
|
|
44
|
+
error = validators.max(value, rule)
|
|
45
|
+
}
|
|
46
|
+
if (!error && rule.pattern) {
|
|
47
|
+
error = validators.pattern(value, rule)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (error) {
|
|
52
|
+
return error
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Validates multiple fields at once (for form-level validation)
|
|
61
|
+
* @param {Object} values - All form values
|
|
62
|
+
* @param {Map} rulesRegistry - Map of field path keys to { path: array, rules: array }
|
|
63
|
+
* @returns {Promise<Object>} - Object of path key -> error message
|
|
64
|
+
*/
|
|
65
|
+
export async function validateAllFields(values, rulesRegistry) {
|
|
66
|
+
const errors = {}
|
|
67
|
+
const validationPromises = []
|
|
68
|
+
|
|
69
|
+
rulesRegistry.forEach(({ path, rules }, pathKey) => {
|
|
70
|
+
const value = getPath(path, values)
|
|
71
|
+
|
|
72
|
+
validationPromises.push(
|
|
73
|
+
validateRules(value, rules, 'onSubmit').then((error) => {
|
|
74
|
+
if (error) {
|
|
75
|
+
errors[pathKey] = error
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
await Promise.all(validationPromises)
|
|
82
|
+
return errors
|
|
83
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { defaultMessages } from './defaultMessages'
|
|
2
|
+
|
|
3
|
+
const isEmpty = (value) => {
|
|
4
|
+
if (value === undefined || value === null) return true
|
|
5
|
+
if (typeof value === 'string' && value.trim() === '') return true
|
|
6
|
+
if (Array.isArray(value) && value.length === 0) return true
|
|
7
|
+
return false
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const validators = {
|
|
11
|
+
required: (value, rule) => {
|
|
12
|
+
if (isEmpty(value)) {
|
|
13
|
+
return rule.message || defaultMessages.required
|
|
14
|
+
}
|
|
15
|
+
return null
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
type: (value, rule) => {
|
|
19
|
+
if (isEmpty(value)) return null
|
|
20
|
+
|
|
21
|
+
const typeValidators = {
|
|
22
|
+
email: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
|
|
23
|
+
url: (v) => {
|
|
24
|
+
try {
|
|
25
|
+
new URL(v)
|
|
26
|
+
return true
|
|
27
|
+
} catch {
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
number: (v) => !isNaN(Number(v)),
|
|
32
|
+
integer: (v) => Number.isInteger(Number(v)) && !isNaN(Number(v)),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const validator = typeValidators[rule.type]
|
|
36
|
+
if (validator && !validator(value)) {
|
|
37
|
+
return rule.message || defaultMessages.type[rule.type] || `Invalid ${rule.type}`
|
|
38
|
+
}
|
|
39
|
+
return null
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
min: (value, rule) => {
|
|
43
|
+
if (isEmpty(value)) return null
|
|
44
|
+
|
|
45
|
+
if (typeof value === 'string' && value.length < rule.min) {
|
|
46
|
+
return rule.message || defaultMessages.min.string(rule.min)
|
|
47
|
+
}
|
|
48
|
+
if (typeof value === 'number' && value < rule.min) {
|
|
49
|
+
return rule.message || defaultMessages.min.number(rule.min)
|
|
50
|
+
}
|
|
51
|
+
if (Array.isArray(value) && value.length < rule.min) {
|
|
52
|
+
return rule.message || defaultMessages.min.array(rule.min)
|
|
53
|
+
}
|
|
54
|
+
return null
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
max: (value, rule) => {
|
|
58
|
+
if (isEmpty(value)) return null
|
|
59
|
+
|
|
60
|
+
if (typeof value === 'string' && value.length > rule.max) {
|
|
61
|
+
return rule.message || defaultMessages.max.string(rule.max)
|
|
62
|
+
}
|
|
63
|
+
if (typeof value === 'number' && value > rule.max) {
|
|
64
|
+
return rule.message || defaultMessages.max.number(rule.max)
|
|
65
|
+
}
|
|
66
|
+
if (Array.isArray(value) && value.length > rule.max) {
|
|
67
|
+
return rule.message || defaultMessages.max.array(rule.max)
|
|
68
|
+
}
|
|
69
|
+
return null
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
pattern: (value, rule) => {
|
|
73
|
+
if (isEmpty(value)) return null
|
|
74
|
+
|
|
75
|
+
const regex = rule.pattern instanceof RegExp ? rule.pattern : new RegExp(rule.pattern)
|
|
76
|
+
|
|
77
|
+
if (!regex.test(String(value))) {
|
|
78
|
+
return rule.message || defaultMessages.pattern
|
|
79
|
+
}
|
|
80
|
+
return null
|
|
81
|
+
},
|
|
82
|
+
}
|
package/src/components/index.js
CHANGED
|
@@ -48,7 +48,7 @@ export function InputWrapper({
|
|
|
48
48
|
if (!!suffix && is(String, suffix)) suffix = <Text>{suffix}</Text>
|
|
49
49
|
if (!prefix && !!prefixIcon) prefix = <Icon name={prefixIcon} size={sizeCode} color={prefixIconColor} />
|
|
50
50
|
if (!suffix && !!suffixIcon) suffix = <Icon name={suffixIcon} size={sizeCode} color={suffixIconColor} />
|
|
51
|
-
if (!prefix && !!error) suffix = <Icon name="
|
|
51
|
+
if (!prefix && !!error) suffix = <Icon name="alert-fill" size={sizeCode} red />
|
|
52
52
|
if (!!loading) suffix = <Loading size={sizeCode} />
|
|
53
53
|
|
|
54
54
|
let borderColor = !!hover ? 'primary_op40' : 'divider'
|
|
@@ -73,11 +73,11 @@ export function formatNumericValue(newValue, prevValue, options = {}) {
|
|
|
73
73
|
return numericValue
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
export function NumberInput({ onChange, value, useInt, precision, min, max, error, ...props }) {
|
|
76
|
+
export function NumberInput({ onChange, onBlur, value, useInt, precision, min, max, error, ...props }) {
|
|
77
77
|
const [hasError, setHasError] = React.useState(false)
|
|
78
78
|
const [inputValue, setInputValue] = React.useState(value)
|
|
79
79
|
const [localValue, setLocalValue] = React.useState(value)
|
|
80
|
-
React.useEffect(() => setInputValue(value), [value])
|
|
80
|
+
React.useEffect(() => setInputValue(value?.toString() || ''), [value])
|
|
81
81
|
|
|
82
82
|
if (useInt) precision = 0
|
|
83
83
|
if (!useInt && precision === 0) useInt = true
|
|
@@ -85,6 +85,7 @@ export function NumberInput({ onChange, value, useInt, precision, min, max, erro
|
|
|
85
85
|
|
|
86
86
|
return (
|
|
87
87
|
<TextInput
|
|
88
|
+
{...props}
|
|
88
89
|
onChange={(newValue) => {
|
|
89
90
|
const numericValue = formatNumericValue(newValue, localValue, opts)
|
|
90
91
|
setInputValue(newValue?.toString() || '')
|
|
@@ -92,14 +93,14 @@ export function NumberInput({ onChange, value, useInt, precision, min, max, erro
|
|
|
92
93
|
onChange?.(numericValue)
|
|
93
94
|
setHasError(!isValidNumber(newValue, opts))
|
|
94
95
|
}}
|
|
95
|
-
onBlur={() => {
|
|
96
|
-
setInputValue(localValue)
|
|
96
|
+
onBlur={(e) => {
|
|
97
|
+
setInputValue(localValue?.toString() || '')
|
|
97
98
|
setHasError(!isValidNumber(localValue, opts))
|
|
99
|
+
onBlur?.(e)
|
|
98
100
|
}}
|
|
99
101
|
value={inputValue}
|
|
100
102
|
keyboardType={useInt ? 'number-pad' : 'decimal-pad'}
|
|
101
103
|
error={error || hasError}
|
|
102
|
-
{...props}
|
|
103
104
|
/>
|
|
104
105
|
)
|
|
105
106
|
}
|
|
@@ -2,9 +2,10 @@ import { is } from 'ramda'
|
|
|
2
2
|
import React from 'react'
|
|
3
3
|
|
|
4
4
|
import { Col } from '../structure/Col'
|
|
5
|
-
import {
|
|
5
|
+
import { DrawerFlatList } from '../structure/bottomDrawer'
|
|
6
6
|
import { LoadingView } from '../state/LoadingView'
|
|
7
7
|
import { Row } from '../structure/Row'
|
|
8
|
+
import { View } from '../structure'
|
|
8
9
|
import { normalizeString } from '../../helpers/string'
|
|
9
10
|
import { useOptions } from '../../helpers/options'
|
|
10
11
|
|
|
@@ -94,7 +95,7 @@ function DefaultPickerWrapper({ renderItem, options, ...props }) {
|
|
|
94
95
|
|
|
95
96
|
function FlatListPickerWrapper({ renderItem, options, valueKey, ...props }) {
|
|
96
97
|
return (
|
|
97
|
-
<
|
|
98
|
+
<DrawerFlatList
|
|
98
99
|
keyExtractor={(i) => i[valueKey]}
|
|
99
100
|
data={options}
|
|
100
101
|
divider
|