@saas-ui/forms 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.
Files changed (45) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/Field.d.ts +79 -0
  3. package/dist/Field.d.ts.map +1 -0
  4. package/dist/array-field.d.ts +48 -0
  5. package/dist/array-field.d.ts.map +1 -0
  6. package/dist/auto-form.d.ts +10 -0
  7. package/dist/auto-form.d.ts.map +1 -0
  8. package/dist/display-field.d.ts +7 -0
  9. package/dist/display-field.d.ts.map +1 -0
  10. package/dist/fields.d.ts +6 -0
  11. package/dist/fields.d.ts.map +1 -0
  12. package/dist/form.d.ts +16 -0
  13. package/dist/form.d.ts.map +1 -0
  14. package/dist/index.d.ts +13 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +2 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/index.modern.js +2 -0
  19. package/dist/index.modern.js.map +1 -0
  20. package/dist/layout.d.ts +5 -0
  21. package/dist/layout.d.ts.map +1 -0
  22. package/dist/object-field.d.ts +11 -0
  23. package/dist/object-field.d.ts.map +1 -0
  24. package/dist/resolvers/yup.d.ts +12 -0
  25. package/dist/resolvers/yup.d.ts.map +1 -0
  26. package/dist/submit-button.d.ts +3 -0
  27. package/dist/submit-button.d.ts.map +1 -0
  28. package/dist/use-array-field.d.ts +95 -0
  29. package/dist/use-array-field.d.ts.map +1 -0
  30. package/dist/utils.d.ts +3 -0
  31. package/dist/utils.d.ts.map +1 -0
  32. package/package.json +75 -0
  33. package/src/array-field.tsx +196 -0
  34. package/src/auto-form.tsx +35 -0
  35. package/src/display-field.tsx +31 -0
  36. package/src/field.tsx +295 -0
  37. package/src/fields.tsx +51 -0
  38. package/src/form.tsx +49 -0
  39. package/src/index.ts +14 -0
  40. package/src/layout.tsx +37 -0
  41. package/src/object-field.tsx +25 -0
  42. package/src/resolvers/yup.ts +81 -0
  43. package/src/submit-button.tsx +25 -0
  44. package/src/use-array-field.tsx +152 -0
  45. package/src/utils.ts +13 -0
package/src/field.tsx ADDED
@@ -0,0 +1,295 @@
1
+ import * as React from 'react'
2
+ import { useFormContext, FormState, Controller, get } from 'react-hook-form'
3
+
4
+ import {
5
+ forwardRef,
6
+ Box,
7
+ FormControl,
8
+ FormControlProps,
9
+ FormLabel,
10
+ FormHelperText,
11
+ FormErrorMessage,
12
+ Input,
13
+ Textarea,
14
+ Checkbox,
15
+ Switch,
16
+ useMergeRefs,
17
+ } from '@chakra-ui/react'
18
+
19
+ import { NumberInput } from '@saas-ui/number-input'
20
+ import { PasswordInput } from '@saas-ui/password-input'
21
+ import { RadioInput } from '@saas-ui/radio'
22
+ import { PinInput } from '@saas-ui/pin-input'
23
+ import { Select, NativeSelect } from '@saas-ui/select'
24
+
25
+ export interface FieldProps extends Omit<FormControlProps, 'label'> {
26
+ /**
27
+ * The field name
28
+ */
29
+ name: string
30
+ /**
31
+ * The field label
32
+ */
33
+ label?: string
34
+ /**
35
+ * Hide the field label
36
+ */
37
+ hideLabel?: boolean
38
+ /**
39
+ * Field help text
40
+ */
41
+ help?: string
42
+ /**
43
+ * React hook form rules
44
+ */
45
+ rules?: any
46
+ /**
47
+ * Options used for selects and radio fields
48
+ */
49
+ options?: any
50
+ /**
51
+ * The field type
52
+ * Build-in types:
53
+ * - text
54
+ * - number
55
+ * - password
56
+ * - textarea
57
+ * - select
58
+ * - nativeselect
59
+ * - checkbox
60
+ * - radio
61
+ * - switch
62
+ * - pin
63
+ *
64
+ * Will default to a text field if there is no matching type.
65
+ * @default 'text'
66
+ */
67
+ type?: string
68
+ /**
69
+ * The input placeholder
70
+ */
71
+ placeholder?: string
72
+ }
73
+
74
+ const inputTypes: Record<string, any> = {}
75
+
76
+ const defaultInputType = 'text'
77
+
78
+ const getInput = (type: string) => {
79
+ return inputTypes[type] || inputTypes[defaultInputType]
80
+ }
81
+
82
+ const getError = (name: string, formState: FormState<{ [x: string]: any }>) => {
83
+ return get(formState.errors, name)
84
+ }
85
+
86
+ const isTouched = (
87
+ name: string,
88
+ formState: FormState<{ [x: string]: any }>
89
+ ) => {
90
+ return get(formState.touchedFields, name)
91
+ }
92
+
93
+ export const BaseField: React.FC<FieldProps> = (props) => {
94
+ const { name, label, help, variant, hideLabel, children, ...controlProps } =
95
+ props
96
+
97
+ const { formState } = useFormContext()
98
+
99
+ const error = getError(name, formState)
100
+
101
+ return (
102
+ <FormControl isInvalid={!!error} variant={variant} {...controlProps}>
103
+ {label && !hideLabel ? (
104
+ <FormLabel variant={variant}>{label}</FormLabel>
105
+ ) : null}
106
+ <Box>
107
+ {children}
108
+ {help && !error?.message ? (
109
+ <FormHelperText>{help}</FormHelperText>
110
+ ) : null}
111
+ {error?.message && (
112
+ <FormErrorMessage>{error?.message}</FormErrorMessage>
113
+ )}
114
+ </Box>
115
+ </FormControl>
116
+ )
117
+ }
118
+
119
+ export const Field = forwardRef<FieldProps, typeof FormControl>(
120
+ (props, ref) => {
121
+ const { type = defaultInputType } = props
122
+ const InputComponent = getInput(type)
123
+
124
+ return <InputComponent ref={ref} {...props} />
125
+ }
126
+ )
127
+
128
+ interface CreateFieldProps {
129
+ displayName: string
130
+ hideLabel?: boolean
131
+ BaseField: React.FC<any>
132
+ }
133
+
134
+ const createField = (
135
+ InputComponent: React.FC<any>,
136
+ { displayName, hideLabel, BaseField }: CreateFieldProps
137
+ ) => {
138
+ const Field = forwardRef<FieldProps, typeof FormControl>((props, ref) => {
139
+ const {
140
+ label,
141
+ isDisabled,
142
+ isInvalid,
143
+ isReadOnly,
144
+ isRequired,
145
+ isOptional,
146
+ variant,
147
+ ...inputProps
148
+ } = props
149
+
150
+ return (
151
+ <BaseField
152
+ label={label}
153
+ hideLabel={hideLabel}
154
+ isDiabled={isDisabled}
155
+ isInvalid={isInvalid}
156
+ isReadOnly={isReadOnly}
157
+ isRequired={isRequired}
158
+ isOptional={isOptional}
159
+ variant={variant}
160
+ >
161
+ <InputComponent ref={ref} label={label} {...inputProps} />
162
+ </BaseField>
163
+ )
164
+ })
165
+ Field.displayName = displayName
166
+
167
+ return Field
168
+ }
169
+
170
+ export const withControlledInput = (InputComponent: any) => {
171
+ return forwardRef(({ name, rules, ...inputProps }, ref) => {
172
+ const { control } = useFormContext()
173
+
174
+ return (
175
+ <Controller
176
+ name={name}
177
+ control={control}
178
+ rules={rules}
179
+ render={({ field: { ref: _ref, ...field } }) => (
180
+ <InputComponent
181
+ {...field}
182
+ {...inputProps}
183
+ ref={useMergeRefs(ref, _ref)}
184
+ />
185
+ )}
186
+ />
187
+ )
188
+ })
189
+ }
190
+
191
+ export const withUncontrolledInput = (InputComponent: any) => {
192
+ return forwardRef(({ name, rules, ...inputProps }, ref) => {
193
+ const { register } = useFormContext()
194
+
195
+ const { ref: _ref, ...field } = register(name, rules)
196
+
197
+ return (
198
+ <InputComponent
199
+ {...field}
200
+ {...inputProps}
201
+ ref={useMergeRefs(ref, _ref)}
202
+ />
203
+ )
204
+ })
205
+ }
206
+
207
+ export interface RegisterFieldTypeOptions {
208
+ isControlled?: boolean
209
+ hideLabel?: boolean
210
+ BaseField?: React.FC<any>
211
+ }
212
+
213
+ /**
214
+ * Register a new field type
215
+ * @param type The name for this field in kebab-case, eg `email` or `array-field`
216
+ * @param component The React component
217
+ * @param options
218
+ * @param options.isControlled Set this to true if this is a controlled field.
219
+ * @param options.hideLabel Hide the field label, for example for checkbox or switch field.
220
+ */
221
+ export const registerFieldType = (
222
+ type: string,
223
+ component: any,
224
+ options?: RegisterFieldTypeOptions
225
+ ) => {
226
+ let InputComponent
227
+ if (options?.isControlled) {
228
+ InputComponent = withControlledInput(component)
229
+ } else {
230
+ InputComponent = withUncontrolledInput(component)
231
+ }
232
+
233
+ const Field = createField(InputComponent, {
234
+ displayName: `${type
235
+ .split('-')
236
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
237
+ .join('')}Field`,
238
+ hideLabel: options?.hideLabel,
239
+ BaseField: options?.BaseField || BaseField,
240
+ })
241
+
242
+ inputTypes[type] = Field
243
+
244
+ return Field
245
+ }
246
+
247
+ // @todo Consider not registering all fields by default to lower the package size and computations.
248
+ // Not all types may be required in a project.
249
+ export const InputField = registerFieldType('text', Input)
250
+ export const NumberInputField = registerFieldType('number', NumberInput, {
251
+ isControlled: true,
252
+ })
253
+ export const PasswordInputFIeld = registerFieldType('password', PasswordInput)
254
+ export const TextareaField = registerFieldType('textarea', Textarea)
255
+ export const SwitchField = registerFieldType(
256
+ 'switch',
257
+ forwardRef(({ label, ...props }: { label?: string }, ref) => {
258
+ return (
259
+ <Switch ref={ref} {...props}>
260
+ {label}
261
+ </Switch>
262
+ )
263
+ }),
264
+ {
265
+ isControlled: true,
266
+ hideLabel: true,
267
+ }
268
+ )
269
+ export const SelectField = registerFieldType('select', Select, {
270
+ isControlled: true,
271
+ })
272
+ export const CheckboxField = registerFieldType(
273
+ 'checkbox',
274
+ forwardRef(({ label, ...props }: { label?: string }, ref) => {
275
+ return (
276
+ <Checkbox ref={ref} {...props}>
277
+ {label}
278
+ </Checkbox>
279
+ )
280
+ }),
281
+ {
282
+ hideLabel: true,
283
+ }
284
+ )
285
+ export const RadioField = registerFieldType('radio', RadioInput, {
286
+ isControlled: true,
287
+ })
288
+ export const PinField = registerFieldType('pin', PinInput, {
289
+ isControlled: true,
290
+ })
291
+ export const NativeSelectField = registerFieldType(
292
+ 'native-select',
293
+ NativeSelect,
294
+ { isControlled: true }
295
+ )
package/src/fields.tsx ADDED
@@ -0,0 +1,51 @@
1
+ import * as React from 'react'
2
+ import { getFieldsFromSchema, getNestedSchema } from './resolvers/yup'
3
+
4
+ import { FormLayout } from './layout'
5
+ import { Field, FieldProps } from './field'
6
+
7
+ import { ArrayField } from './array-field'
8
+ import { ObjectField } from './object-field'
9
+
10
+ export interface FieldsProps {
11
+ schema: any
12
+ }
13
+
14
+ const getNestedFields = (schema: any, name: string) => {
15
+ return getFieldsFromSchema(getNestedSchema(schema, name)).map(
16
+ ({ name, type, ...nestedFieldProps }: FieldProps): React.ReactNode => (
17
+ <Field key={name} name={name} type={type} {...nestedFieldProps} />
18
+ )
19
+ )
20
+ }
21
+
22
+ export const Fields: React.FC<FieldsProps> = ({ schema, ...props }) => {
23
+ return (
24
+ <FormLayout {...props}>
25
+ {getFieldsFromSchema(schema).map(
26
+ ({
27
+ name,
28
+ type,
29
+ defaultValue,
30
+ ...fieldProps
31
+ }: FieldProps): React.ReactNode => {
32
+ if (type === 'array') {
33
+ return (
34
+ <ArrayField name={name} {...fieldProps}>
35
+ {getNestedFields(schema, name)}
36
+ </ArrayField>
37
+ )
38
+ } else if (type === 'object') {
39
+ return (
40
+ <ObjectField name={name} {...fieldProps}>
41
+ {getNestedFields(schema, name)}
42
+ </ObjectField>
43
+ )
44
+ }
45
+
46
+ return <Field key={name} name={name} type={type} {...fieldProps} />
47
+ }
48
+ )}
49
+ </FormLayout>
50
+ )
51
+ }
package/src/form.tsx ADDED
@@ -0,0 +1,49 @@
1
+ import * as React from 'react'
2
+
3
+ import { chakra, HTMLChakraProps, forwardRef } from '@chakra-ui/react'
4
+
5
+ import {
6
+ useForm,
7
+ FormProvider,
8
+ UseFormProps,
9
+ FieldErrors,
10
+ } from 'react-hook-form'
11
+
12
+ import { resolver } from './resolvers/yup'
13
+
14
+ interface FormOptions {
15
+ schema?: any
16
+ submitLabel?: false | string
17
+ }
18
+
19
+ export interface FormProps extends HTMLChakraProps<'form'>, FormOptions {
20
+ defaultValues: Record<string, any>
21
+ onSubmit: (arg: any) => Promise<any> | void
22
+ onError?: (errors: FieldErrors) => void
23
+ children?: React.ReactNode
24
+ }
25
+
26
+ export const Form = forwardRef<FormProps, 'form'>(
27
+ ({ schema, defaultValues, onSubmit, onError, children, ...props }, ref) => {
28
+ const form: UseFormProps = { mode: 'all', defaultValues }
29
+
30
+ if (schema) {
31
+ form.resolver = resolver(schema)
32
+ }
33
+
34
+ const methods = useForm(form)
35
+ const { handleSubmit } = methods
36
+
37
+ return (
38
+ <FormProvider {...methods}>
39
+ <chakra.form
40
+ ref={ref}
41
+ onSubmit={handleSubmit(onSubmit, onError)}
42
+ {...props}
43
+ >
44
+ {children}
45
+ </chakra.form>
46
+ </FormProvider>
47
+ )
48
+ }
49
+ )
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ export * from './display-field'
2
+ export * from './field'
3
+ export * from './fields'
4
+ export * from './form'
5
+ export * from './auto-form'
6
+ export * from './layout'
7
+ export * from './submit-button'
8
+ export * from './array-field'
9
+ export * from './use-array-field'
10
+ export * from './object-field'
11
+
12
+ export * from '@saas-ui/input-right-button'
13
+
14
+ export type { FieldErrors } from 'react-hook-form'
package/src/layout.tsx ADDED
@@ -0,0 +1,37 @@
1
+ import * as React from 'react'
2
+
3
+ import { chakra, SimpleGrid, SimpleGridProps, useTheme } from '@chakra-ui/react'
4
+
5
+ export type FormLayoutProps = SimpleGridProps
6
+
7
+ interface FormLayoutItemProps {
8
+ children: React.ReactNode
9
+ }
10
+
11
+ const FormLayoutItem: React.FC<FormLayoutItemProps> = ({ children }) => {
12
+ return <chakra.div>{children}</chakra.div>
13
+ }
14
+
15
+ export const FormLayout = ({ children, ...props }: FormLayoutProps) => {
16
+ const theme = useTheme()
17
+
18
+ const defaultProps = theme.components?.FormLayout?.defaultProps ?? {
19
+ spacing: 4,
20
+ }
21
+
22
+ const gridProps = {
23
+ ...defaultProps,
24
+ ...props,
25
+ }
26
+
27
+ return (
28
+ <SimpleGrid {...gridProps}>
29
+ {React.Children.map(children, (child) => {
30
+ if (React.isValidElement(child)) {
31
+ return <FormLayoutItem>{child}</FormLayoutItem>
32
+ }
33
+ return child
34
+ })}
35
+ </SimpleGrid>
36
+ )
37
+ }
@@ -0,0 +1,25 @@
1
+ import * as React from 'react'
2
+ import { ResponsiveValue } from '@chakra-ui/system'
3
+
4
+ import { FormLayout } from './layout'
5
+ import { BaseField, FieldProps } from './field'
6
+
7
+ import { mapNestedFields } from './utils'
8
+
9
+ export interface ObjectFieldProps extends FieldProps {
10
+ name: string
11
+ children: React.ReactNode
12
+ columns?: ResponsiveValue<number>
13
+ spacing?: ResponsiveValue<string | number>
14
+ }
15
+
16
+ export const ObjectField: React.FC<ObjectFieldProps> = (props) => {
17
+ const { name, children, columns, spacing, ...fieldProps } = props
18
+ return (
19
+ <BaseField name={name} {...fieldProps}>
20
+ <FormLayout columns={columns} gridGap={spacing}>
21
+ {mapNestedFields(name, children)}
22
+ </FormLayout>
23
+ </BaseField>
24
+ )
25
+ }
@@ -0,0 +1,81 @@
1
+ import { SchemaOf, AnySchema, reach } from 'yup'
2
+ import { yupResolver } from '@hookform/resolvers/yup'
3
+
4
+ import { FieldProps } from '../Field'
5
+
6
+ export const resolver = yupResolver
7
+
8
+ // @TODO get proper typings for the schema fields
9
+
10
+ const getType = (field: any) => {
11
+ if (field.spec.meta?.type) {
12
+ return field.spec.meta.type
13
+ }
14
+
15
+ switch (field.type) {
16
+ case 'array':
17
+ return 'array'
18
+ case 'object':
19
+ return 'object'
20
+ case 'number':
21
+ return 'number'
22
+ case 'date':
23
+ return 'date'
24
+ case 'string':
25
+ default:
26
+ return 'text'
27
+ }
28
+ }
29
+
30
+ type Options = {
31
+ min?: number
32
+ max?: number
33
+ }
34
+
35
+ const getArrayOption = (field: any, name: string) => {
36
+ for (const test of field.tests) {
37
+ if (test.OPTIONS?.params[name]) return test.OPTIONS.params[name]
38
+ }
39
+ }
40
+
41
+ /**
42
+ * A helper function to render forms automatically based on a Yup schema
43
+ *
44
+ * @param schema The Yup schema
45
+ * @returns {FieldProps[]}
46
+ */
47
+ export const getFieldsFromSchema = (
48
+ schema: SchemaOf<AnySchema>
49
+ ): FieldProps[] => {
50
+ const fields = []
51
+
52
+ let schemaFields: Record<string, any> = {}
53
+ if (schema.type === 'array') {
54
+ /* @ts-ignore this is actually valid */
55
+ schemaFields = schema.innerType.fields
56
+ } else {
57
+ schemaFields = schema.fields
58
+ }
59
+
60
+ for (const name in schemaFields) {
61
+ const field = schemaFields[name]
62
+
63
+ const options: Options = {}
64
+ if (field.type === 'array') {
65
+ options.min = getArrayOption(field, 'min')
66
+ options.max = getArrayOption(field, 'max')
67
+ }
68
+
69
+ fields.push({
70
+ name,
71
+ label: field.spec.label || name,
72
+ type: getType(field),
73
+ ...options,
74
+ })
75
+ }
76
+ return fields
77
+ }
78
+
79
+ export const getNestedSchema = (schema: SchemaOf<AnySchema>, path: string) => {
80
+ return reach(schema, path)
81
+ }
@@ -0,0 +1,25 @@
1
+ import * as React from 'react'
2
+
3
+ import { useFormContext } from 'react-hook-form'
4
+
5
+ import { Button, ButtonProps } from '@saas-ui/button'
6
+
7
+ import { forwardRef } from '@chakra-ui/system'
8
+
9
+ export const SubmitButton = forwardRef<ButtonProps, 'button'>(
10
+ ({ children, ...props }, ref) => {
11
+ const data = useFormContext()
12
+
13
+ return (
14
+ <Button
15
+ type="submit"
16
+ isLoading={data.formState.isSubmitting}
17
+ isPrimary
18
+ ref={ref}
19
+ {...props}
20
+ >
21
+ {children}
22
+ </Button>
23
+ )
24
+ }
25
+ )