codeforlife 2.6.6 → 2.6.8

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 CHANGED
@@ -1,3 +1,17 @@
1
+ ## [2.6.8](https://github.com/ocadotechnology/codeforlife-package-javascript/compare/v2.6.7...v2.6.8) (2025-02-24)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * form submission behavior ([#79](https://github.com/ocadotechnology/codeforlife-package-javascript/issues/79)) ([9b1ac2d](https://github.com/ocadotechnology/codeforlife-package-javascript/commit/9b1ac2df0049cf473266798b6d210106045a86ee))
7
+
8
+ ## [2.6.7](https://github.com/ocadotechnology/codeforlife-package-javascript/compare/v2.6.6...v2.6.7) (2025-02-19)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * Package javascript 76 ([#77](https://github.com/ocadotechnology/codeforlife-package-javascript/issues/77)) ([73c7113](https://github.com/ocadotechnology/codeforlife-package-javascript/commit/73c7113d50f7e1bff64655cf30da30013f051950))
14
+
1
15
  ## [2.6.6](https://github.com/ocadotechnology/codeforlife-package-javascript/compare/v2.6.5...v2.6.6) (2025-01-27)
2
16
 
3
17
 
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "codeforlife",
3
3
  "description": "Common frontend code",
4
4
  "private": false,
5
- "version": "2.6.6",
5
+ "version": "2.6.8",
6
6
  "type": "module",
7
7
  "scripts": {
8
8
  "dev": "vite",
@@ -1,27 +1,106 @@
1
+ import { FormHelperText, type FormHelperTextProps } from "@mui/material"
1
2
  import {
2
3
  Formik,
3
4
  Form as FormikForm,
4
5
  type FormikConfig,
5
6
  type FormikErrors,
7
+ type FormikProps,
6
8
  } from "formik"
9
+ import {
10
+ type ReactNode,
11
+ type FC,
12
+ useRef,
13
+ useEffect,
14
+ type RefObject,
15
+ } from "react"
7
16
  import type { TypedUseMutation } from "@reduxjs/toolkit/query/react"
8
17
 
18
+ import { getKeyPaths } from "../../utils/general"
9
19
  import {
10
20
  submitForm,
11
21
  type SubmitFormOptions,
12
22
  type FormValues,
13
23
  } from "../../utils/form"
14
24
 
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>({
16
63
  children,
64
+ scrollIntoViewOptions = SCROLL_INTO_VIEW_OPTIONS,
65
+ nonFieldErrorsProps,
66
+ order,
17
67
  ...otherFormikProps
18
- }: FormikConfig<Values>) => (
68
+ }: BaseFormProps<Values>) => (
19
69
  <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
+ }
81
+ // If a submission was attempted and refs to the fields were provided.
82
+ else if (formik.isSubmitting && order && order.length) {
83
+ const errorNames = getKeyPaths(formik.errors)
84
+
85
+ const inputRef = order.find(({ name }) =>
86
+ errorNames.includes(name),
87
+ )?.inputRef
88
+
89
+ if (inputRef?.current) {
90
+ inputRef.current.scrollIntoView(scrollIntoViewOptions)
91
+ }
92
+ }
93
+ }
94
+
95
+ return (
96
+ <>
97
+ {nonFieldErrors}
98
+ <FormikForm>
99
+ {typeof children === "function" ? children(formik) : children}
100
+ </FormikForm>
101
+ </>
102
+ )
103
+ }}
25
104
  </Formik>
26
105
  )
27
106
 
@@ -29,7 +108,7 @@ type SubmitFormProps<
29
108
  Values extends FormValues,
30
109
  QueryArg extends FormValues,
31
110
  ResultType,
32
- > = Omit<FormikConfig<Values>, "onSubmit"> & {
111
+ > = Omit<BaseFormProps<Values>, "onSubmit"> & {
33
112
  useMutation: TypedUseMutation<ResultType, QueryArg, any>
34
113
  } & (Values extends QueryArg
35
114
  ? { submitOptions?: SubmitFormOptions<Values, QueryArg, ResultType> }
@@ -42,15 +121,16 @@ const SubmitForm = <
42
121
  >({
43
122
  useMutation,
44
123
  submitOptions,
45
- ...formikProps
124
+ ...baseFormProps
46
125
  }: SubmitFormProps<Values, QueryArg, ResultType>): JSX.Element => {
47
126
  const [trigger] = useMutation()
48
127
 
49
128
  return (
50
- <_
51
- {...formikProps}
129
+ <BaseForm
130
+ {...baseFormProps}
52
131
  onSubmit={submitForm<Values, QueryArg, ResultType>(
53
132
  trigger,
133
+ baseFormProps.initialValues,
54
134
  submitOptions as SubmitFormOptions<Values, QueryArg, ResultType>,
55
135
  )}
56
136
  />
@@ -61,10 +141,10 @@ export type FormProps<
61
141
  Values extends FormValues,
62
142
  QueryArg extends FormValues,
63
143
  ResultType,
64
- > = FormikConfig<Values> | SubmitFormProps<Values, QueryArg, ResultType>
144
+ > = BaseFormProps<Values> | SubmitFormProps<Values, QueryArg, ResultType>
65
145
 
66
146
  const Form: {
67
- <Values extends FormValues>(props: FormikConfig<Values>): JSX.Element
147
+ <Values extends FormValues>(props: BaseFormProps<Values>): JSX.Element
68
148
  <Values extends FormValues, QueryArg extends FormValues, ResultType>(
69
149
  props: SubmitFormProps<Values, QueryArg, ResultType>,
70
150
  ): JSX.Element
@@ -75,8 +155,7 @@ const Form: {
75
155
  >(
76
156
  props: FormProps<Values, QueryArg, ResultType>,
77
157
  ): JSX.Element => {
78
- return "onSubmit" in props ? <_ {...props} /> : SubmitForm(props)
158
+ return "onSubmit" in props ? <BaseForm {...props} /> : SubmitForm(props)
79
159
  }
80
160
 
81
161
  export default Form
82
- export { type FormikErrors as FormErrors }
@@ -32,7 +32,13 @@ const SubmitButton: FC<SubmitButtonProps> = ({
32
32
  type="button"
33
33
  onClick={() => {
34
34
  form.setTouched(getTouched(form.values), true).then(errors => {
35
- if (!errors || !Object.keys(errors).length) form.submitForm()
35
+ const hasErrors = Boolean(errors && Object.keys(errors).length)
36
+ // If has errors, set isSubmitting=true so fields in the form are
37
+ // aware that a submission was attempted. Else, set
38
+ // isSubmitting=false as it will be set to true when calling
39
+ // submitForm().
40
+ form.setSubmitting(hasErrors)
41
+ if (!hasErrors) form.submitForm()
36
42
  })
37
43
  }}
38
44
  {...otherButtonProps}
@@ -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
+ }
@@ -1,4 +1,5 @@
1
1
  export * from "./api"
2
2
  export * from "./auth"
3
+ export * from "./form"
3
4
  export * from "./general"
4
5
  export * from "./router"
@@ -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
+ })
package/src/utils/form.ts CHANGED
@@ -2,7 +2,7 @@ import type { TypedMutationTrigger } from "@reduxjs/toolkit/query/react"
2
2
  import type { FieldValidator, FormikHelpers } from "formik"
3
3
  import { ValidationError, type Schema, type ValidateOptions } from "yup"
4
4
 
5
- import { excludeKeyPaths } from "./general"
5
+ import { excludeKeyPaths, getNestedProperty, getKeyPaths } from "./general"
6
6
 
7
7
  export type FormValues = Record<string, any>
8
8
 
@@ -40,6 +40,8 @@ export type SubmitFormOptions<
40
40
  ResultType,
41
41
  > = Partial<{
42
42
  exclude: string[]
43
+ include: string[]
44
+ onlyDirtyValues: boolean
43
45
  then: (
44
46
  result: ResultType,
45
47
  values: Values,
@@ -67,6 +69,7 @@ export function submitForm<
67
69
  ResultType,
68
70
  >(
69
71
  trigger: TypedMutationTrigger<ResultType, QueryArg, any>,
72
+ initialValues: Values,
70
73
  options?: SubmitFormOptions<Values, QueryArg, ResultType>,
71
74
  ): SubmitFormHandler<Values>
72
75
 
@@ -76,6 +79,7 @@ export function submitForm<
76
79
  ResultType,
77
80
  >(
78
81
  trigger: TypedMutationTrigger<ResultType, QueryArg, any>,
82
+ initialValues: Values,
79
83
  options: SubmitFormOptions<Values, QueryArg, ResultType>,
80
84
  ): SubmitFormHandler<Values>
81
85
 
@@ -85,9 +89,17 @@ export function submitForm<
85
89
  ResultType,
86
90
  >(
87
91
  trigger: TypedMutationTrigger<ResultType, QueryArg, any>,
92
+ initialValues: Values,
88
93
  options?: SubmitFormOptions<Values, QueryArg, ResultType>,
89
94
  ): 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 || {}
91
103
 
92
104
  return (values, helpers) => {
93
105
  let arg =
@@ -95,7 +107,18 @@ export function submitForm<
95
107
  ? options.clean(values as QueryArg & FormValues)
96
108
  : (values as unknown as QueryArg)
97
109
 
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)
99
122
 
100
123
  trigger(arg)
101
124
  .unwrap()
@@ -131,23 +154,35 @@ export function schemaToFieldValidator(
131
154
 
132
155
  // Checking if individual fields are dirty is not currently supported.
133
156
  // 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>(
138
158
  values: Values,
139
159
  initialValues: Values,
140
- names: Names,
141
- ): Record<Names[number], boolean> {
160
+ names?: string[],
161
+ ): Record<string, boolean> {
162
+ if (!names) names = getKeyPaths(values)
163
+
142
164
  return Object.fromEntries(
143
165
  names.map(name => [name, isDirty(values, initialValues, name)]),
144
- ) as Record<Names[number], boolean>
166
+ )
145
167
  }
146
168
 
147
- export function isDirty<Values extends FormValues, Name extends keyof Values>(
169
+ export function isDirty<Values extends FormValues>(
148
170
  values: Values,
149
171
  initialValues: Values,
150
- name: Name,
172
+ name: string,
151
173
  ): 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)
153
188
  }
@@ -1,4 +1,9 @@
1
- import { excludeKeyPaths, getNestedProperty, withKeyPaths } from "./general"
1
+ import {
2
+ excludeKeyPaths,
3
+ getNestedProperty,
4
+ withKeyPaths,
5
+ getKeyPaths,
6
+ } from "./general"
2
7
 
3
8
  // getNestedProperty
4
9
 
@@ -24,12 +29,20 @@ test("get a nested property that doesn't exist", () => {
24
29
 
25
30
  // withKeyPaths
26
31
 
27
- test("get the paths of nested keys", () => {
32
+ test("set the paths of nested keys", () => {
28
33
  const obj = withKeyPaths({ a: 1, b: { c: 2, d: { e: 3 } } })
29
34
 
30
35
  expect(obj).toMatchObject({ a: 1, b: { "b.c": 2, "b.d": { "b.d.e": 3 } } })
31
36
  })
32
37
 
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
+
33
46
  // excludeKeyPaths
34
47
 
35
48
  test("exclude nested keys by their path", () => {
@@ -966,7 +966,8 @@ export function withKeyPaths(obj: object, delimiter: string = "."): object {
966
966
  Object.entries(obj).map(([key, value]) => {
967
967
  const _path = [...path, key]
968
968
 
969
- if (typeof value === "object") value = _withKeyPaths(value, _path)
969
+ if (typeof value === "object" && value !== null)
970
+ value = _withKeyPaths(value, _path)
970
971
 
971
972
  return [_path.join(delimiter), value]
972
973
  }),
@@ -976,6 +977,23 @@ export function withKeyPaths(obj: object, delimiter: string = "."): object {
976
977
  return _withKeyPaths(obj, [])
977
978
  }
978
979
 
980
+ export function getKeyPaths(obj: object, delimiter: string = "."): string[] {
981
+ function _getKeyPaths(obj: object, path: string[]): string[] {
982
+ return Object.entries(obj)
983
+ .map(([key, value]) => {
984
+ const _path = [...path, key]
985
+ const keyPath = _path.join(delimiter)
986
+
987
+ return typeof value === "object" && value !== null
988
+ ? [keyPath, ..._getKeyPaths(value, _path)]
989
+ : [keyPath]
990
+ })
991
+ .flat()
992
+ }
993
+
994
+ return _getKeyPaths(obj, [])
995
+ }
996
+
979
997
  export function excludeKeyPaths(
980
998
  obj: object,
981
999
  exclude: string[],