codeforlife 2.6.5 → 2.6.7

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.7](https://github.com/ocadotechnology/codeforlife-package-javascript/compare/v2.6.6...v2.6.7) (2025-02-19)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * Package javascript 76 ([#77](https://github.com/ocadotechnology/codeforlife-package-javascript/issues/77)) ([73c7113](https://github.com/ocadotechnology/codeforlife-package-javascript/commit/73c7113d50f7e1bff64655cf30da30013f051950))
7
+
8
+ ## [2.6.6](https://github.com/ocadotechnology/codeforlife-package-javascript/compare/v2.6.5...v2.6.6) (2025-01-27)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * csrf header for non-safe http methods ([#75](https://github.com/ocadotechnology/codeforlife-package-javascript/issues/75)) ([d0b2b78](https://github.com/ocadotechnology/codeforlife-package-javascript/commit/d0b2b7852fbdc9f84ade5ec4d46cc8a980e60f1e))
14
+
1
15
  ## [2.6.5](https://github.com/ocadotechnology/codeforlife-package-javascript/compare/v2.6.4...v2.6.5) (2025-01-17)
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.5",
5
+ "version": "2.6.7",
6
6
  "type": "module",
7
7
  "scripts": {
8
8
  "dev": "vite",
@@ -1,12 +1,14 @@
1
1
  import {
2
2
  createApi as _createApi,
3
3
  fetchBaseQuery,
4
+ type FetchArgs,
4
5
  } from "@reduxjs/toolkit/query/react"
5
6
 
6
7
  import { SERVICE_API_URL } from "../settings"
7
8
  import defaultTagTypes from "./tagTypes"
8
9
  import { buildLogoutEndpoint } from "./endpoints/session"
9
10
  import { getCsrfCookie } from "../utils/auth"
11
+ import { isSafeHttpMethod } from "../utils/api"
10
12
 
11
13
  // TODO: decide if we want to keep any of this.
12
14
  // export function handleResponseError(error: FetchBaseQueryError): void {
@@ -36,8 +38,13 @@ export default function createApi<TagTypes extends string = never>({
36
38
  const fetch = fetchBaseQuery({
37
39
  baseUrl: `${SERVICE_API_URL}/`,
38
40
  credentials: "include",
39
- prepareHeaders: (headers, { type }) => {
40
- if (type === "mutation") {
41
+ prepareHeaders: (headers, endpoint) => {
42
+ const { type, arg } = endpoint as typeof endpoint & {
43
+ arg: string | FetchArgs
44
+ }
45
+ const method = typeof arg === "string" ? "GET" : arg.method || "GET"
46
+
47
+ if (type === "mutation" || !isSafeHttpMethod(method)) {
41
48
  let csrfToken = getCsrfCookie()
42
49
  if (csrfToken) headers.set("x-csrftoken", csrfToken)
43
50
  }
@@ -1,27 +1,104 @@
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
+ } 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
+ }}
25
102
  </Formik>
26
103
  )
27
104
 
@@ -29,7 +106,7 @@ type SubmitFormProps<
29
106
  Values extends FormValues,
30
107
  QueryArg extends FormValues,
31
108
  ResultType,
32
- > = Omit<FormikConfig<Values>, "onSubmit"> & {
109
+ > = Omit<BaseFormProps<Values>, "onSubmit"> & {
33
110
  useMutation: TypedUseMutation<ResultType, QueryArg, any>
34
111
  } & (Values extends QueryArg
35
112
  ? { submitOptions?: SubmitFormOptions<Values, QueryArg, ResultType> }
@@ -42,15 +119,16 @@ const SubmitForm = <
42
119
  >({
43
120
  useMutation,
44
121
  submitOptions,
45
- ...formikProps
122
+ ...baseFormProps
46
123
  }: SubmitFormProps<Values, QueryArg, ResultType>): JSX.Element => {
47
124
  const [trigger] = useMutation()
48
125
 
49
126
  return (
50
- <_
51
- {...formikProps}
127
+ <BaseForm
128
+ {...baseFormProps}
52
129
  onSubmit={submitForm<Values, QueryArg, ResultType>(
53
130
  trigger,
131
+ baseFormProps.initialValues,
54
132
  submitOptions as SubmitFormOptions<Values, QueryArg, ResultType>,
55
133
  )}
56
134
  />
@@ -61,10 +139,10 @@ export type FormProps<
61
139
  Values extends FormValues,
62
140
  QueryArg extends FormValues,
63
141
  ResultType,
64
- > = FormikConfig<Values> | SubmitFormProps<Values, QueryArg, ResultType>
142
+ > = BaseFormProps<Values> | SubmitFormProps<Values, QueryArg, ResultType>
65
143
 
66
144
  const Form: {
67
- <Values extends FormValues>(props: FormikConfig<Values>): JSX.Element
145
+ <Values extends FormValues>(props: BaseFormProps<Values>): JSX.Element
68
146
  <Values extends FormValues, QueryArg extends FormValues, ResultType>(
69
147
  props: SubmitFormProps<Values, QueryArg, ResultType>,
70
148
  ): JSX.Element
@@ -75,8 +153,7 @@ const Form: {
75
153
  >(
76
154
  props: FormProps<Values, QueryArg, ResultType>,
77
155
  ): JSX.Element => {
78
- return "onSubmit" in props ? <_ {...props} /> : SubmitForm(props)
156
+ return "onSubmit" in props ? <BaseForm {...props} /> : SubmitForm(props)
79
157
  }
80
158
 
81
159
  export default Form
82
- export { type FormikErrors as FormErrors }
@@ -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"
package/src/utils/api.tsx CHANGED
@@ -325,3 +325,8 @@ export function handleResultState<QueryArg, ResultType>(
325
325
  // Have yet to call the API.
326
326
  return loadingNode
327
327
  }
328
+
329
+ export function isSafeHttpMethod(method: string) {
330
+ // https://datatracker.ietf.org/doc/html/rfc9110.html#section-9.2.1
331
+ return ["GET", "HEAD", "OPTIONS", "TRACE"].includes(method.toUpperCase())
332
+ }
@@ -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[],