Skip to content

Commit 73c7113

Browse files
authored
fix: Package javascript 76 (#77)
* fix: get clean names of form values * fix: only dirty values * fix: include fields * fix: non field errors * fix: scroll to first field depending on order
1 parent 00040ed commit 73c7113

File tree

7 files changed

+236
-31
lines changed

7 files changed

+236
-31
lines changed

src/components/form/Form.tsx

+92-15
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,112 @@
1+
import { FormHelperText, type FormHelperTextProps } from "@mui/material"
12
import {
23
Formik,
34
Form as FormikForm,
45
type FormikConfig,
56
type FormikErrors,
7+
type FormikProps,
68
} from "formik"
9+
import {
10+
type ReactNode,
11+
type FC,
12+
useRef,
13+
useEffect,
14+
type RefObject,
15+
} from "react"
716
import type { TypedUseMutation } from "@reduxjs/toolkit/query/react"
817

18+
import { getKeyPaths } from "../../utils/general"
919
import {
1020
submitForm,
1121
type SubmitFormOptions,
1222
type FormValues,
1323
} from "../../utils/form"
1424

15-
const _ = <Values extends FormValues>({
25+
const SCROLL_INTO_VIEW_OPTIONS: ScrollIntoViewOptions = {
26+
behavior: "smooth",
27+
block: "start",
28+
}
29+
30+
type NonFieldErrorsProps = Omit<FormHelperTextProps, "error" | "ref"> & {
31+
scrollIntoViewOptions?: ScrollIntoViewOptions
32+
}
33+
34+
const NonFieldErrors: FC<NonFieldErrorsProps> = ({
35+
scrollIntoViewOptions = SCROLL_INTO_VIEW_OPTIONS,
36+
...formHelperTextProps
37+
}) => {
38+
const pRef = useRef<HTMLParagraphElement>(null)
39+
40+
useEffect(() => {
41+
if (pRef.current) pRef.current.scrollIntoView(scrollIntoViewOptions)
42+
}, [scrollIntoViewOptions])
43+
44+
return <FormHelperText ref={pRef} error {...formHelperTextProps} />
45+
}
46+
47+
export type FormErrors<Values> = FormikErrors<
48+
Omit<Values, "non_field_errors"> & { non_field_errors: string }
49+
>
50+
51+
type _FormikProps<Values> = Omit<FormikProps<Values>, "errors"> & {
52+
errors: FormErrors<Values>
53+
}
54+
55+
type BaseFormProps<Values> = Omit<FormikConfig<Values>, "children"> & {
56+
children: ReactNode | ((props: _FormikProps<Values>) => ReactNode)
57+
scrollIntoViewOptions?: ScrollIntoViewOptions
58+
nonFieldErrorsProps?: Omit<NonFieldErrorsProps, "children">
59+
order?: Array<{ name: string; inputRef: RefObject<HTMLInputElement> }>
60+
}
61+
62+
const BaseForm = <Values extends FormValues>({
1663
children,
64+
scrollIntoViewOptions = SCROLL_INTO_VIEW_OPTIONS,
65+
nonFieldErrorsProps,
66+
order,
1767
...otherFormikProps
18-
}: FormikConfig<Values>) => (
68+
}: BaseFormProps<Values>) => (
1969
<Formik {...otherFormikProps}>
20-
{formik => (
21-
<FormikForm>
22-
{typeof children === "function" ? children(formik) : children}
23-
</FormikForm>
24-
)}
70+
{/* @ts-expect-error */}
71+
{(formik: _FormikProps<Values>) => {
72+
let nonFieldErrors: undefined | JSX.Element = undefined
73+
if (Object.keys(formik.errors).length) {
74+
if (typeof formik.errors.non_field_errors === "string") {
75+
nonFieldErrors = (
76+
<NonFieldErrors {...nonFieldErrorsProps}>
77+
{formik.errors.non_field_errors}
78+
</NonFieldErrors>
79+
)
80+
} else if (order && order.length) {
81+
const errorNames = getKeyPaths(formik.errors)
82+
83+
const inputRef = order.find(({ name }) =>
84+
errorNames.includes(name),
85+
)?.inputRef
86+
87+
if (inputRef?.current) {
88+
inputRef.current.scrollIntoView(scrollIntoViewOptions)
89+
}
90+
}
91+
}
92+
93+
return (
94+
<>
95+
{nonFieldErrors}
96+
<FormikForm>
97+
{typeof children === "function" ? children(formik) : children}
98+
</FormikForm>
99+
</>
100+
)
101+
}}
25102
</Formik>
26103
)
27104

28105
type SubmitFormProps<
29106
Values extends FormValues,
30107
QueryArg extends FormValues,
31108
ResultType,
32-
> = Omit<FormikConfig<Values>, "onSubmit"> & {
109+
> = Omit<BaseFormProps<Values>, "onSubmit"> & {
33110
useMutation: TypedUseMutation<ResultType, QueryArg, any>
34111
} & (Values extends QueryArg
35112
? { submitOptions?: SubmitFormOptions<Values, QueryArg, ResultType> }
@@ -42,15 +119,16 @@ const SubmitForm = <
42119
>({
43120
useMutation,
44121
submitOptions,
45-
...formikProps
122+
...baseFormProps
46123
}: SubmitFormProps<Values, QueryArg, ResultType>): JSX.Element => {
47124
const [trigger] = useMutation()
48125

49126
return (
50-
<_
51-
{...formikProps}
127+
<BaseForm
128+
{...baseFormProps}
52129
onSubmit={submitForm<Values, QueryArg, ResultType>(
53130
trigger,
131+
baseFormProps.initialValues,
54132
submitOptions as SubmitFormOptions<Values, QueryArg, ResultType>,
55133
)}
56134
/>
@@ -61,10 +139,10 @@ export type FormProps<
61139
Values extends FormValues,
62140
QueryArg extends FormValues,
63141
ResultType,
64-
> = FormikConfig<Values> | SubmitFormProps<Values, QueryArg, ResultType>
142+
> = BaseFormProps<Values> | SubmitFormProps<Values, QueryArg, ResultType>
65143

66144
const Form: {
67-
<Values extends FormValues>(props: FormikConfig<Values>): JSX.Element
145+
<Values extends FormValues>(props: BaseFormProps<Values>): JSX.Element
68146
<Values extends FormValues, QueryArg extends FormValues, ResultType>(
69147
props: SubmitFormProps<Values, QueryArg, ResultType>,
70148
): JSX.Element
@@ -75,8 +153,7 @@ const Form: {
75153
>(
76154
props: FormProps<Values, QueryArg, ResultType>,
77155
): JSX.Element => {
78-
return "onSubmit" in props ? <_ {...props} /> : SubmitForm(props)
156+
return "onSubmit" in props ? <BaseForm {...props} /> : SubmitForm(props)
79157
}
80158

81159
export default Form
82-
export { type FormikErrors as FormErrors }

src/hooks/form.tsx

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { useRef } from "react"
2+
3+
/**
4+
* Shorthand for a reference to a HTML input element since this is so common for
5+
* forms.
6+
*
7+
* @returns Ref object to a HTML input element.
8+
*/
9+
export function useInputRef() {
10+
return useRef<HTMLInputElement>(null)
11+
}

src/hooks/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from "./api"
22
export * from "./auth"
3+
export * from "./form"
34
export * from "./general"
45
export * from "./router"

src/utils/form.test.ts

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { isDirty, getDirty, getCleanNames } from "./form"
2+
3+
const VALUES = {
4+
name: { first: "Peter", last: "Parker" },
5+
}
6+
const INITIAL_VALUES: typeof VALUES = {
7+
name: { first: "Peter", last: "Robbins" },
8+
}
9+
10+
// isDirty
11+
12+
test("value is dirty", () => {
13+
const value = isDirty(VALUES, INITIAL_VALUES, "name.last")
14+
15+
expect(value).toBe(true)
16+
})
17+
18+
test("value is clean", () => {
19+
const value = isDirty(VALUES, INITIAL_VALUES, "name.first")
20+
21+
expect(value).toBe(false)
22+
})
23+
24+
// getDirty
25+
26+
test("get dirty values", () => {
27+
const dirty = getDirty(VALUES, INITIAL_VALUES)
28+
29+
expect(dirty).toMatchObject({ "name.first": false, "name.last": true })
30+
})
31+
32+
test("get subset of dirty values", () => {
33+
const dirty = getDirty(VALUES, INITIAL_VALUES, ["name.first"])
34+
35+
expect(dirty).toMatchObject({ "name.first": false })
36+
})
37+
38+
// getCleanNames
39+
40+
test("get clean names", () => {
41+
const cleanNames = getCleanNames(VALUES, INITIAL_VALUES)
42+
43+
expect(cleanNames).toMatchObject(["name.first"])
44+
})
45+
46+
test("get subset of clean names", () => {
47+
const cleanNames = getCleanNames(VALUES, INITIAL_VALUES, ["name.last"])
48+
49+
expect(cleanNames).toMatchObject([])
50+
})

src/utils/form.ts

+48-13
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { TypedMutationTrigger } from "@reduxjs/toolkit/query/react"
22
import type { FieldValidator, FormikHelpers } from "formik"
33
import { ValidationError, type Schema, type ValidateOptions } from "yup"
44

5-
import { excludeKeyPaths } from "./general"
5+
import { excludeKeyPaths, getNestedProperty, getKeyPaths } from "./general"
66

77
export type FormValues = Record<string, any>
88

@@ -40,6 +40,8 @@ export type SubmitFormOptions<
4040
ResultType,
4141
> = Partial<{
4242
exclude: string[]
43+
include: string[]
44+
onlyDirtyValues: boolean
4345
then: (
4446
result: ResultType,
4547
values: Values,
@@ -67,6 +69,7 @@ export function submitForm<
6769
ResultType,
6870
>(
6971
trigger: TypedMutationTrigger<ResultType, QueryArg, any>,
72+
initialValues: Values,
7073
options?: SubmitFormOptions<Values, QueryArg, ResultType>,
7174
): SubmitFormHandler<Values>
7275

@@ -76,6 +79,7 @@ export function submitForm<
7679
ResultType,
7780
>(
7881
trigger: TypedMutationTrigger<ResultType, QueryArg, any>,
82+
initialValues: Values,
7983
options: SubmitFormOptions<Values, QueryArg, ResultType>,
8084
): SubmitFormHandler<Values>
8185

@@ -85,17 +89,36 @@ export function submitForm<
8589
ResultType,
8690
>(
8791
trigger: TypedMutationTrigger<ResultType, QueryArg, any>,
92+
initialValues: Values,
8893
options?: SubmitFormOptions<Values, QueryArg, ResultType>,
8994
): SubmitFormHandler<Values> {
90-
const { exclude, then, catch: _catch, finally: _finally } = options || {}
95+
let {
96+
exclude = [],
97+
include,
98+
onlyDirtyValues = false,
99+
then,
100+
catch: _catch,
101+
finally: _finally,
102+
} = options || {}
91103

92104
return (values, helpers) => {
93105
let arg =
94106
options && options.clean
95107
? options.clean(values as QueryArg & FormValues)
96108
: (values as unknown as QueryArg)
97109

98-
if (exclude) arg = excludeKeyPaths(arg, exclude)
110+
if (onlyDirtyValues) {
111+
exclude = [
112+
...exclude,
113+
...getCleanNames(values, initialValues).filter(
114+
cleanName => !exclude.includes(cleanName),
115+
),
116+
]
117+
}
118+
119+
if (include) exclude = exclude.filter(name => !include.includes(name))
120+
121+
if (exclude.length) arg = excludeKeyPaths(arg, exclude)
99122

100123
trigger(arg)
101124
.unwrap()
@@ -131,23 +154,35 @@ export function schemaToFieldValidator(
131154

132155
// Checking if individual fields are dirty is not currently supported.
133156
// https://github.com/jaredpalmer/formik/issues/1421
134-
export function getDirty<
135-
Values extends FormValues,
136-
Names extends Array<keyof Values>,
137-
>(
157+
export function getDirty<Values extends FormValues>(
138158
values: Values,
139159
initialValues: Values,
140-
names: Names,
141-
): Record<Names[number], boolean> {
160+
names?: string[],
161+
): Record<string, boolean> {
162+
if (!names) names = getKeyPaths(values)
163+
142164
return Object.fromEntries(
143165
names.map(name => [name, isDirty(values, initialValues, name)]),
144-
) as Record<Names[number], boolean>
166+
)
145167
}
146168

147-
export function isDirty<Values extends FormValues, Name extends keyof Values>(
169+
export function isDirty<Values extends FormValues>(
148170
values: Values,
149171
initialValues: Values,
150-
name: Name,
172+
name: string,
151173
): boolean {
152-
return values[name] !== initialValues[name]
174+
const value = getNestedProperty(values, name)
175+
const initialValue = getNestedProperty(initialValues, name)
176+
177+
return value !== initialValue
178+
}
179+
180+
export function getCleanNames<Values extends FormValues>(
181+
values: Values,
182+
initialValues: Values,
183+
names?: string[],
184+
): string[] {
185+
return Object.entries(getDirty(values, initialValues, names))
186+
.filter(([_, isDirty]) => !isDirty)
187+
.map(([name]) => name)
153188
}

src/utils/general.test.ts

+15-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { excludeKeyPaths, getNestedProperty, withKeyPaths } from "./general"
1+
import {
2+
excludeKeyPaths,
3+
getNestedProperty,
4+
withKeyPaths,
5+
getKeyPaths,
6+
} from "./general"
27

38
// getNestedProperty
49

@@ -24,12 +29,20 @@ test("get a nested property that doesn't exist", () => {
2429

2530
// withKeyPaths
2631

27-
test("get the paths of nested keys", () => {
32+
test("set the paths of nested keys", () => {
2833
const obj = withKeyPaths({ a: 1, b: { c: 2, d: { e: 3 } } })
2934

3035
expect(obj).toMatchObject({ a: 1, b: { "b.c": 2, "b.d": { "b.d.e": 3 } } })
3136
})
3237

38+
// getKeyPaths
39+
40+
test("get the paths of nested keys", () => {
41+
const keyPaths = getKeyPaths({ a: 1, b: { c: 2, d: { e: 3 } } })
42+
43+
expect(keyPaths).toMatchObject(["a", "b", "b.c", "b.d", "b.d.e"])
44+
})
45+
3346
// excludeKeyPaths
3447

3548
test("exclude nested keys by their path", () => {

0 commit comments

Comments
 (0)