codeforlife 2.6.12 → 2.6.13

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,10 @@
1
+ ## [2.6.13](https://github.com/ocadotechnology/codeforlife-package-javascript/compare/v2.6.12...v2.6.13) (2025-03-25)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * char set validators ([#81](https://github.com/ocadotechnology/codeforlife-package-javascript/issues/81)) ([9ff1f60](https://github.com/ocadotechnology/codeforlife-package-javascript/commit/9ff1f60808f4a50eb2cbcae997de4ae892665c28))
7
+
1
8
  ## [2.6.12](https://github.com/ocadotechnology/codeforlife-package-javascript/compare/v2.6.11...v2.6.12) (2025-03-20)
2
9
 
3
10
 
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.12",
5
+ "version": "2.6.13",
6
6
  "type": "module",
7
7
  "scripts": {
8
8
  "cli": "VITE_CONFIG=./vite.config.ts ../scripts/frontend/cli $@"
package/src/api/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { default as createApi } from "./createApi"
2
2
  export * from "./models"
3
+ export * as schemas from "./schemas"
3
4
  export { default as tagTypes } from "./tagTypes"
4
5
  export { default as urls } from "./urls"
@@ -0,0 +1,117 @@
1
+ import * as yup from "yup"
2
+
3
+ import { UK_COUNTIES, COUNTRY_ISO_CODES } from "../utils/general"
4
+ import type {
5
+ User,
6
+ Teacher,
7
+ Student,
8
+ Class,
9
+ School,
10
+ AuthFactor,
11
+ OtpBypassToken,
12
+ } from "./models"
13
+ import {
14
+ unicodeAlphanumericString,
15
+ uppercaseAsciiAlphanumericString,
16
+ lowercaseAsciiAlphanumericString,
17
+ numericId,
18
+ } from "../utils/schema"
19
+ import { type Schemas } from "../utils/api"
20
+
21
+ // NOTE: do not use .required() here.
22
+ const id = {
23
+ user: numericId(),
24
+ teacher: numericId(),
25
+ student: numericId(),
26
+ school: numericId(),
27
+ klass: uppercaseAsciiAlphanumericString().length(5),
28
+ authFactor: numericId(),
29
+ otpBypassToken: numericId(),
30
+ }
31
+
32
+ const _userTeacher: Omit<Schemas<Teacher>, "user"> = {
33
+ id: id.teacher.required(),
34
+ school: id.school,
35
+ is_admin: yup.bool().required(),
36
+ }
37
+
38
+ const _userStudent: Omit<Schemas<Student>, "user"> = {
39
+ id: id.student.required(),
40
+ school: id.school.required(),
41
+ klass: id.klass.required(),
42
+ auto_gen_password: yup.string().required(),
43
+ }
44
+
45
+ export const user: Schemas<User> = {
46
+ id: id.user.required(),
47
+ requesting_to_join_class: id.klass,
48
+ first_name: unicodeAlphanumericString({
49
+ spaces: true,
50
+ specialChars: "-'",
51
+ })
52
+ .required()
53
+ .max(150),
54
+ last_name: unicodeAlphanumericString({
55
+ spaces: true,
56
+ specialChars: "-'",
57
+ }).max(150),
58
+ last_login: yup.date(),
59
+ email: yup.string().email(),
60
+ password: yup.string().required(),
61
+ is_staff: yup.bool().required(),
62
+ is_active: yup.bool().required(),
63
+ date_joined: yup.date().required(),
64
+ teacher: yup.object(_userTeacher).optional(),
65
+ student: yup.object(_userStudent).optional(),
66
+ }
67
+
68
+ export const teacher: Schemas<Teacher> = {
69
+ ..._userTeacher,
70
+ user: id.user.required(),
71
+ }
72
+
73
+ export const student: Schemas<Student> = {
74
+ ..._userStudent,
75
+ user: id.user.required(),
76
+ }
77
+
78
+ export const school: Schemas<School> = {
79
+ id: id.school.required(),
80
+ name: unicodeAlphanumericString({
81
+ spaces: true,
82
+ specialChars: "'.",
83
+ })
84
+ .required()
85
+ .max(200),
86
+ country: yup.string().oneOf(COUNTRY_ISO_CODES),
87
+ uk_county: yup.string().oneOf(UK_COUNTIES),
88
+ }
89
+
90
+ export const klass: Schemas<Class> = {
91
+ id: id.klass.required(),
92
+ teacher: id.teacher.required(),
93
+ school: id.school.required(),
94
+ name: unicodeAlphanumericString({
95
+ spaces: true,
96
+ specialChars: "-_",
97
+ })
98
+ .required()
99
+ .max(200),
100
+ read_classmates_data: yup.bool().required(),
101
+ receive_requests_until: yup.date(),
102
+ }
103
+
104
+ export const authFactor: Schemas<AuthFactor> = {
105
+ id: id.authFactor.required(),
106
+ user: id.user.required(),
107
+ type: yup
108
+ .string()
109
+ .oneOf(["otp"] as const)
110
+ .required(),
111
+ }
112
+
113
+ export const otpBypassToken: Schemas<OtpBypassToken> = {
114
+ id: id.otpBypassToken.required(),
115
+ user: id.user.required(),
116
+ token: lowercaseAsciiAlphanumericString().required().length(8),
117
+ }
@@ -3,7 +3,7 @@ import { InputAdornment } from "@mui/material"
3
3
  import type { FC } from "react"
4
4
 
5
5
  import TextField, { type TextFieldProps } from "./TextField"
6
- import { firstNameSchema } from "../../schemas/user"
6
+ import { schemas } from "../../api"
7
7
 
8
8
  export type FirstNameFieldProps = Omit<
9
9
  TextFieldProps,
@@ -20,7 +20,7 @@ const FirstNameField: FC<FirstNameFieldProps> = ({
20
20
  }) => {
21
21
  return (
22
22
  <TextField
23
- schema={firstNameSchema}
23
+ schema={schemas.user.first_name}
24
24
  name={name}
25
25
  label={label}
26
26
  placeholder={placeholder}
@@ -4,13 +4,7 @@ import {
4
4
  } from "@mui/material"
5
5
  import { Field, type FieldConfig, type FieldProps } from "formik"
6
6
  import { type FC, useState, useEffect } from "react"
7
- import {
8
- type ArraySchema,
9
- type StringSchema,
10
- type ValidateOptions,
11
- array as YupArray,
12
- type Schema,
13
- } from "yup"
7
+ import { type StringSchema, type ValidateOptions, array as YupArray } from "yup"
14
8
 
15
9
  import { schemaToFieldValidator } from "../../utils/form"
16
10
  import { getNestedProperty } from "../../utils/general"
@@ -52,18 +46,40 @@ const TextField: FC<TextFieldProps> = ({
52
46
 
53
47
  const dotPath = name.split(".")
54
48
 
55
- let _schema: Schema = schema
56
- if (split) {
57
- _schema = YupArray().of(_schema)
58
- if (unique || uniqueCaseInsensitive) {
59
- _schema = _schema.test({
49
+ function buildSchema() {
50
+ // Build a schema for a single string.
51
+ let stringSchema = schema
52
+ // 1: Validate string is required.
53
+ stringSchema = required ? stringSchema.required() : stringSchema.optional()
54
+ // 2: Validate string is dirty.
55
+ if (dirty && !split)
56
+ stringSchema = stringSchema.notOneOf(
57
+ [initialValue as string],
58
+ "cannot be initial value",
59
+ )
60
+ // Return a schema for a single string.
61
+ if (!split) return stringSchema
62
+
63
+ // Build a schema for an array of strings.
64
+ let arraySchema = YupArray().of(stringSchema)
65
+ // 1: Validate array has min one string.
66
+ arraySchema = required
67
+ ? arraySchema.required().min(1)
68
+ : arraySchema.optional()
69
+ // 2: Validate array has unique strings.
70
+ if (unique || uniqueCaseInsensitive)
71
+ arraySchema = arraySchema.test({
60
72
  message: "cannot have duplicates",
61
73
  test: values => {
62
- if (Array.isArray(values) && values.length >= 2) {
74
+ if (
75
+ Array.isArray(values) &&
76
+ values.length >= 2 &&
77
+ values.every(value => typeof value === "string")
78
+ ) {
63
79
  return (
64
80
  new Set(
65
- uniqueCaseInsensitive && typeof values[0] === "string"
66
- ? values.map(value => value.toLowerCase())
81
+ uniqueCaseInsensitive
82
+ ? values.map(value => (value as string).toLowerCase())
67
83
  : values,
68
84
  ).size === values.length
69
85
  )
@@ -72,19 +88,20 @@ const TextField: FC<TextFieldProps> = ({
72
88
  return true
73
89
  },
74
90
  })
75
- }
76
- }
77
- if (required) {
78
- _schema = _schema.required()
79
- if (split) _schema = (_schema as ArraySchema<string[], any>).min(1)
91
+ // 3: Validate array is dirty.
92
+ if (dirty)
93
+ arraySchema = arraySchema.notOneOf(
94
+ [initialValue as string[]],
95
+ "cannot be initial value",
96
+ )
97
+ // Return a schema for an array of strings.
98
+ return arraySchema
80
99
  }
81
- if (dirty)
82
- _schema = _schema.notOneOf([initialValue], "cannot be initial value")
83
100
 
84
101
  const fieldConfig: FieldConfig = {
85
102
  name,
86
103
  type,
87
- validate: schemaToFieldValidator(_schema, validateOptions),
104
+ validate: schemaToFieldValidator(buildSchema(), validateOptions),
88
105
  }
89
106
 
90
107
  const _Field: FC<FieldProps> = ({ form }) => {
package/src/utils/api.tsx CHANGED
@@ -7,8 +7,9 @@ import type {
7
7
  } from "@reduxjs/toolkit/query/react"
8
8
  import { type ReactNode } from "react"
9
9
 
10
- import SyncError from "../components/SyncError"
11
10
  import { type Optional, type Required, getNestedProperty } from "./general"
11
+ import { type SchemaMap } from "./schema"
12
+ import SyncError from "../components/SyncError"
12
13
 
13
14
  // -----------------------------------------------------------------------------
14
15
  // Model Types
@@ -33,6 +34,10 @@ export type Model<Id extends ModelId, MFields extends Fields = Fields> = {
33
34
  id: Id
34
35
  } & Omit<MFields, "id">
35
36
 
37
+ export type Schemas<M extends Model<any>> = {
38
+ [K in keyof M]-?: SchemaMap<M[K]>
39
+ }
40
+
36
41
  export type Result<
37
42
  M extends Model<any>,
38
43
  MFields extends keyof Omit<M, "id"> = never,
@@ -1,5 +1,10 @@
1
1
  export type Required<T, K extends keyof T> = { [P in K]-?: T[P] }
2
2
  export type Optional<T, K extends keyof T> = Partial<Pick<T, K>>
3
+ export type OptionalPropertyNames<T> = {
4
+ [K in keyof T]-?: {} extends { [P in K]: T[K] } ? K : never
5
+ }[keyof T]
6
+ export type IsOptional<T, K extends keyof T> =
7
+ K extends OptionalPropertyNames<T> ? true : false
3
8
 
4
9
  export function openInNewTab(url: string, target = "_blank"): void {
5
10
  window.open(url, target)
@@ -9,6 +9,13 @@ import {
9
9
  type Schema,
10
10
  type TypeFromShape,
11
11
  type ValidateOptions,
12
+ type StringSchema,
13
+ type NumberSchema,
14
+ type Flags,
15
+ type BooleanSchema,
16
+ type DateSchema,
17
+ string as YupString,
18
+ number as YupNumber,
12
19
  } from "yup"
13
20
 
14
21
  export type _<T> = T extends {}
@@ -30,6 +37,209 @@ export type ObjectSchemaFromShape<Shape extends ObjectShape> = ObjectSchema<
30
37
  ""
31
38
  >
32
39
 
40
+ export type SchemaMap<
41
+ TType,
42
+ TContext = AnyObject,
43
+ TDefault = any,
44
+ TFlags extends Flags = "",
45
+ > =
46
+ NonNullable<TType> extends string
47
+ ? StringSchema<
48
+ // @ts-expect-error type is fine
49
+ TType extends undefined ? TType | undefined : TType,
50
+ TContext,
51
+ TDefault,
52
+ TFlags
53
+ >
54
+ : NonNullable<TType> extends number
55
+ ? NumberSchema<
56
+ // @ts-expect-error type is fine
57
+ TType extends undefined ? TType | undefined : TType,
58
+ TContext,
59
+ TDefault,
60
+ TFlags
61
+ >
62
+ : NonNullable<TType> extends boolean
63
+ ? BooleanSchema<
64
+ // @ts-expect-error type is fine
65
+ TType extends undefined ? TType | undefined : TType,
66
+ TContext,
67
+ TDefault,
68
+ TFlags
69
+ >
70
+ : NonNullable<TType> extends Date
71
+ ? DateSchema<
72
+ // @ts-expect-error type is fine
73
+ TType extends undefined ? TType | undefined : TType,
74
+ TContext,
75
+ TDefault,
76
+ TFlags
77
+ >
78
+ : NonNullable<TType> extends object
79
+ ? ObjectSchema<
80
+ // @ts-expect-error type is fine
81
+ TType extends undefined ? TType | undefined : TType,
82
+ TContext,
83
+ TDefault,
84
+ TFlags
85
+ >
86
+ : Schema<TType, TContext, TDefault, TFlags>
87
+
88
+ export function numericId(schema: NumberSchema = YupNumber()) {
89
+ return schema.min(1)
90
+ }
91
+
92
+ // -----------------------------------------------------------------------------
93
+ // Limited Character Sets
94
+ // -----------------------------------------------------------------------------
95
+
96
+ export type MatchesCharSetOptions = Partial<{
97
+ schema: StringSchema
98
+ flags: string
99
+ }>
100
+
101
+ export function matchesCharSet(
102
+ charSet: string,
103
+ message: string,
104
+ options: MatchesCharSetOptions = {},
105
+ ) {
106
+ const { schema = YupString(), flags } = options
107
+
108
+ return schema.matches(new RegExp(`^[${charSet}]*$`, flags), message)
109
+ }
110
+
111
+ export type BuildCharSetOptions = MatchesCharSetOptions &
112
+ Partial<{ spaces: boolean; specialChars: string }>
113
+
114
+ export function buildCharSet(
115
+ charSet: string,
116
+ description: string,
117
+ options: BuildCharSetOptions = {},
118
+ ) {
119
+ const { spaces = false, specialChars, ...matchesCharSetOptions } = options
120
+
121
+ let message = `can only contain: ${description}`
122
+
123
+ if (spaces) {
124
+ charSet += " "
125
+ message += ", spaces"
126
+ }
127
+ if (specialChars) {
128
+ charSet += specialChars
129
+ message += `, special characters (${specialChars})`
130
+ }
131
+
132
+ return matchesCharSet(charSet, message, matchesCharSetOptions)
133
+ }
134
+
135
+ export function buildUnicodeCharSet(
136
+ charSet: string,
137
+ description: string,
138
+ options: BuildCharSetOptions = {},
139
+ ) {
140
+ let { flags = "u", ...otherOptions } = options
141
+
142
+ if (!flags.includes("u")) flags += "u"
143
+
144
+ return buildCharSet(charSet, description, { flags, ...otherOptions })
145
+ }
146
+
147
+ export function asciiAlphaString(options?: BuildCharSetOptions) {
148
+ return buildCharSet("a-zA-Z", "ASCII alpha characters (a-z, A-Z)", options)
149
+ }
150
+
151
+ export function lowercaseAsciiAlphaString(options?: BuildCharSetOptions) {
152
+ return buildCharSet("a-z", "lowercase ASCII alpha characters (a-z)", options)
153
+ }
154
+
155
+ export function uppercaseAsciiAlphaString(options?: BuildCharSetOptions) {
156
+ return buildCharSet("A-Z", "uppercase ASCII alpha characters (A-Z)", options)
157
+ }
158
+
159
+ export function asciiNumericString(options?: BuildCharSetOptions) {
160
+ return buildCharSet("0-9", "ASCII numbers (0-9)", options)
161
+ }
162
+
163
+ export function asciiAlphanumericString(options?: BuildCharSetOptions) {
164
+ return buildCharSet(
165
+ "a-zA-Z0-9",
166
+ "ASCII alphanumeric characters (a-z, A-Z, 0-9)",
167
+ options,
168
+ )
169
+ }
170
+
171
+ export function lowercaseAsciiAlphanumericString(
172
+ options?: BuildCharSetOptions,
173
+ ) {
174
+ return buildCharSet(
175
+ "a-z0-9",
176
+ "lowercase ASCII alphanumeric characters (a-z, 0-9)",
177
+ options,
178
+ )
179
+ }
180
+
181
+ export function uppercaseAsciiAlphanumericString(
182
+ options?: BuildCharSetOptions,
183
+ ) {
184
+ return buildCharSet(
185
+ "A-Z0-9",
186
+ "uppercase ASCII alphanumeric characters (A-Z, 0-9)",
187
+ options,
188
+ )
189
+ }
190
+
191
+ export function unicodeAlphaString(options?: BuildCharSetOptions) {
192
+ return buildUnicodeCharSet("\\p{L}", "unicode alpha characters", options)
193
+ }
194
+
195
+ export function lowercaseUnicodeAlphaString(options?: BuildCharSetOptions) {
196
+ return buildUnicodeCharSet(
197
+ "\\p{Ll}",
198
+ "lowercase unicode alpha characters",
199
+ options,
200
+ )
201
+ }
202
+
203
+ export function uppercaseUnicodeAlphaString(options?: BuildCharSetOptions) {
204
+ return buildUnicodeCharSet(
205
+ "\\p{Lu}",
206
+ "uppercase unicode alpha characters",
207
+ options,
208
+ )
209
+ }
210
+
211
+ export function unicodeNumericString(options?: BuildCharSetOptions) {
212
+ return buildUnicodeCharSet("\\p{N}", "unicode numbers", options)
213
+ }
214
+
215
+ export function unicodeAlphanumericString(options?: BuildCharSetOptions) {
216
+ return buildUnicodeCharSet(
217
+ "\\p{L}\\p{N}",
218
+ "unicode alphanumeric characters",
219
+ options,
220
+ )
221
+ }
222
+
223
+ export function lowercaseUnicodeAlphanumericString(
224
+ options?: BuildCharSetOptions,
225
+ ) {
226
+ return buildUnicodeCharSet(
227
+ "\\p{Ll}\\p{N}",
228
+ "lowercase unicode alphanumeric characters",
229
+ options,
230
+ )
231
+ }
232
+
233
+ export function uppercaseUnicodeAlphanumericString(
234
+ options?: BuildCharSetOptions,
235
+ ) {
236
+ return buildUnicodeCharSet(
237
+ "\\p{Lu}\\p{N}",
238
+ "uppercase unicode alphanumeric characters",
239
+ options,
240
+ )
241
+ }
242
+
33
243
  // -----------------------------------------------------------------------------
34
244
  // Try Validate Sync
35
245
  // -----------------------------------------------------------------------------
package/codecov.yml DELETED
@@ -1,11 +0,0 @@
1
- coverage: # https://docs.codecov.com/docs/codecov-yaml
2
- precision: 2
3
- round: down
4
- range: 90...90
5
- status: # https://docs.codecov.com/docs/commit-status
6
- project:
7
- default:
8
- target: 90%
9
- threshold: 0%
10
-
11
- comment: false
@@ -1,4 +0,0 @@
1
- import * as yup from "yup"
2
-
3
- // TODO: restrict character set; no special characters
4
- export const firstNameSchema = yup.string().max(150)