Skip to content

Commit 9ff1f60

Browse files
authored
fix: char set validators (#81)
* fix: char set * fix: type error * fix: remove serve package * fix: common string patterns * fix: schema utils * fix: schemas * house keeping * delete codecov.yml * merge from main * fix: char sets * default to u * model schemas
1 parent f616d2a commit 9ff1f60

File tree

10 files changed

+382
-42
lines changed

10 files changed

+382
-42
lines changed

codecov.yml

Lines changed: 0 additions & 11 deletions
This file was deleted.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,4 @@
8484
"@semantic-release/github"
8585
]
8686
}
87-
}
87+
}

src/api/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { default as createApi } from "./createApi"
22
export * from "./models"
3+
export * as schemas from "./schemas"
34
export { default as tagTypes } from "./tagTypes"
45
export { default as urls } from "./urls"

src/api/schemas.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

src/components/form/FirstNameField.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { InputAdornment } from "@mui/material"
33
import type { FC } from "react"
44

55
import TextField, { type TextFieldProps } from "./TextField"
6-
import { firstNameSchema } from "../../schemas/user"
6+
import { schemas } from "../../api"
77

88
export type FirstNameFieldProps = Omit<
99
TextFieldProps,
@@ -20,7 +20,7 @@ const FirstNameField: FC<FirstNameFieldProps> = ({
2020
}) => {
2121
return (
2222
<TextField
23-
schema={firstNameSchema}
23+
schema={schemas.user.first_name}
2424
name={name}
2525
label={label}
2626
placeholder={placeholder}

src/components/form/TextField.tsx

Lines changed: 40 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,7 @@ import {
44
} from "@mui/material"
55
import { Field, type FieldConfig, type FieldProps } from "formik"
66
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"
148

159
import { schemaToFieldValidator } from "../../utils/form"
1610
import { getNestedProperty } from "../../utils/general"
@@ -52,18 +46,40 @@ const TextField: FC<TextFieldProps> = ({
5246

5347
const dotPath = name.split(".")
5448

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({
6072
message: "cannot have duplicates",
6173
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+
) {
6379
return (
6480
new Set(
65-
uniqueCaseInsensitive && typeof values[0] === "string"
66-
? values.map(value => value.toLowerCase())
81+
uniqueCaseInsensitive
82+
? values.map(value => (value as string).toLowerCase())
6783
: values,
6884
).size === values.length
6985
)
@@ -72,19 +88,20 @@ const TextField: FC<TextFieldProps> = ({
7288
return true
7389
},
7490
})
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
8099
}
81-
if (dirty)
82-
_schema = _schema.notOneOf([initialValue], "cannot be initial value")
83100

84101
const fieldConfig: FieldConfig = {
85102
name,
86103
type,
87-
validate: schemaToFieldValidator(_schema, validateOptions),
104+
validate: schemaToFieldValidator(buildSchema(), validateOptions),
88105
}
89106

90107
const _Field: FC<FieldProps> = ({ form }) => {

src/schemas/user.ts

Lines changed: 0 additions & 4 deletions
This file was deleted.

src/utils/api.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ import type {
77
} from "@reduxjs/toolkit/query/react"
88
import { type ReactNode } from "react"
99

10-
import SyncError from "../components/SyncError"
1110
import { type Optional, type Required, getNestedProperty } from "./general"
11+
import { type SchemaMap } from "./schema"
12+
import SyncError from "../components/SyncError"
1213

1314
// -----------------------------------------------------------------------------
1415
// Model Types
@@ -33,6 +34,10 @@ export type Model<Id extends ModelId, MFields extends Fields = Fields> = {
3334
id: Id
3435
} & Omit<MFields, "id">
3536

37+
export type Schemas<M extends Model<any>> = {
38+
[K in keyof M]-?: SchemaMap<M[K]>
39+
}
40+
3641
export type Result<
3742
M extends Model<any>,
3843
MFields extends keyof Omit<M, "id"> = never,

src/utils/general.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
export type Required<T, K extends keyof T> = { [P in K]-?: T[P] }
22
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
38

49
export function openInNewTab(url: string, target = "_blank"): void {
510
window.open(url, target)

0 commit comments

Comments
 (0)