Skip to content

Commit

Permalink
CMDCT-4190: adding validation and schemaMap files with tests
Browse files Browse the repository at this point in the history
  • Loading branch information
angelaco11 committed Dec 18, 2024
1 parent a57d63b commit 4e059c0
Show file tree
Hide file tree
Showing 7 changed files with 645 additions and 1 deletion.
3 changes: 2 additions & 1 deletion services/app-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@
"jsdom": "20.0.0",
"jwt-decode": "3.1.2",
"ksuid": "^3.0.0",
"util": "^0.12.5"
"util": "^0.12.5",
"yup": "^1.6.1"
},
"jest": {
"verbose": true,
Expand Down
4 changes: 4 additions & 0 deletions services/app-api/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,7 @@ export interface AuthenticatedRequest<TParams> {
export type HandlerLambda<TParams> = (
request: AuthenticatedRequest<TParams>
) => Promise<HttpResponse>;

export interface AnyObject {
[key: string]: any;
}
255 changes: 255 additions & 0 deletions services/app-api/utils/schemaMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
import {
array,
boolean,
mixed,
number as numberSchema,
object,
string,
StringSchema,
} from "yup";

const error = {
REQUIRED_GENERIC: "A response is required",
INVALID_EMAIL: "Response must be a valid email address",
INVALID_URL: "Response must be a valid hyperlink/URL",
INVALID_DATE: "Response must be a valid date",
INVALID_END_DATE: "End date can't be before start date",
NUMBER_LESS_THAN_ONE: "Response must be greater than or equal to one",
NUMBER_LESS_THAN_ZERO: "Response must be greater than or equal to zero",
INVALID_NUMBER: "Response must be a valid number",
INVALID_NUMBER_OR_NA: 'Response must be a valid number or "N/A"',
INVALID_RATIO: "Response must be a valid ratio",
};

const isWhitespaceString = (value?: string) => value?.trim().length === 0;

// CHECKBOX
export const checkbox = () =>
array()
.min(0)
.of(object({ key: text(), value: text() }));
export const checkboxOptional = () => checkbox();
export const checkboxSingle = () => boolean();

// TEXT
export const text = (): StringSchema => string();
export const textOptional = () => text();

// DATE
export const dateFormatRegex =
/^((0[1-9]|1[0-2])\/(0[1-9]|1\d|2\d|3[01])\/(19|20)\d{2})|((0[1-9]|1[0-2])(0[1-9]|1\d|2\d|3[01])(19|20)\d{2})|\s$/;

export const date = () =>
string().test({
message: error.INVALID_DATE,
test: (value) => !!value?.match(dateFormatRegex) || value?.trim() === "",
});

export const dateOptional = () => date();
export const endDate = (startDateField: string) =>
date().test(
"is-after-start-date",
error.INVALID_END_DATE,
(endDateString, context) => {
return isEndDateAfterStartDate(
context.parent[startDateField],
endDateString as string
);
}
);

export const isEndDateAfterStartDate = (
startDateString: string,
endDateString: string
) => {
const startDate = new Date(startDateString);
const endDate = new Date(endDateString!);
return endDate >= startDate;
};

// DROPDOWN
export const dropdown = () => object({ label: text(), value: text() });

// DYNAMIC
export const dynamic = () => array().min(0).of(mixed());
export const dynamicOptional = () => array().notRequired().nullable();

// EMAIL
export const email = () => text().email(error.INVALID_EMAIL);
export const emailOptional = () => email();

// NUMBER
export const validNAValues = [
"N/A",
"NA",
"na",
"n/a",
"N/a",
"Data not available",
];

/** This regex must be at least as permissive as the one in ui-src */
const validNumberRegex = /^\.$|[0-9]/;

export const number = () =>
string().test({
message: error.INVALID_NUMBER_OR_NA,
test: (value) => {
if (value) {
const isValidStringValue = validNAValues.includes(value);
const isValidNumberValue = validNumberRegex.test(value);
return isValidStringValue || isValidNumberValue;
} else return true;
},
});

export const numberNotLessThanOne = () =>
string()
.required(error.REQUIRED_GENERIC)
.test({
test: (value) => validNumberRegex.test(value!),
message: error.INVALID_NUMBER,
})
.test({
test: (value) => parseInt(value!) >= 1,
message: error.NUMBER_LESS_THAN_ONE,
});

export const numberNotLessThanZero = () =>
string()
.required(error.REQUIRED_GENERIC)
.test({
test: (value) => validNumberRegex.test(value!),
message: error.INVALID_NUMBER,
})
.test({
test: (value) => parseFloat(value!) >= 0,
message: error.NUMBER_LESS_THAN_ZERO,
});

export const numberOptional = () => number();

const validNumberSchema = () =>
string().test({
message: error.INVALID_NUMBER,
test: (value) => {
return typeof value !== "undefined"
? validNumberRegex.test(value)
: false;
},
});

export const validNumber = () =>
validNumberSchema()
.required(error.REQUIRED_GENERIC)
.test({
test: (value) => !isWhitespaceString(value),
message: error.REQUIRED_GENERIC,
});

export const validNumberOptional = () =>
validNumberSchema().notRequired().nullable();

// OBJECT ARRAY
export const objectArray = () => array().of(mixed());

// RADIO
export const radio = () =>
array()
.min(0)
.of(object({ key: text(), value: text() }));
export const radioOptional = () => radio();

// Number - Ratio
const valueCleaningNumberSchema = (value: string, charsToReplace: RegExp) => {
return numberSchema().transform((_value) => {
return Number(value.replace(charsToReplace, ""));
});
};

export const ratio = () =>
mixed().test({
message: error.INVALID_RATIO,
test: (val: any) => {
// allow if blank
if (val === "" || !val) return true;

const replaceCharsRegex = /[,.:]/g;
const ratio = val.split(":");

// Double check and make sure that a ratio contains numbers on both sides
if (
ratio.length != 2 ||
ratio[0].trim().length == 0 ||
ratio[1].trim().length == 0
) {
return false;
}

// Check if the left side of the ratio is a valid number
const firstTest = valueCleaningNumberSchema(
ratio[0],
replaceCharsRegex
).isValidSync(val);

// Check if the right side of the ratio is a valid number
const secondTest = valueCleaningNumberSchema(
ratio[1],
replaceCharsRegex
).isValidSync(val);

// If both sides are valid numbers, return true!
return firstTest && secondTest;
},
});

// URL
export const url = () => text().url(error.INVALID_URL);
export const urlOptional = () => url();

// NESTED
export const nested = (
fieldSchema: Function,
parentFieldName: string,
parentOptionId: string
) => {
const fieldTypeMap = {
array: array(),
string: string(),
date: date(),
object: object(),
};
const fieldType: keyof typeof fieldTypeMap = fieldSchema().type;
const baseSchema: any = fieldTypeMap[fieldType];
return baseSchema.when(parentFieldName, {
is: (value: any[]) =>
// look for parentOptionId in checked Choices
value?.find((option: any) => option.key === parentOptionId),
then: () => fieldSchema(), // returns standard field schema (required)
otherwise: () => baseSchema, // returns not-required Yup base schema
});
};

export const schemaMap: any = {
checkbox: checkbox(),
checkboxOptional: checkboxOptional(),
checkboxSingle: checkboxSingle(),
date: date(),
dateOptional: dateOptional(),
dropdown: dropdown(),
dynamic: dynamic(),
dynamicOptional: dynamicOptional(),
email: email(),
emailOptional: emailOptional(),
number: number(),
numberNotLessThanOne: numberNotLessThanOne(),
numberOptional: numberOptional(),
objectArray: objectArray(),
radio: radio(),
radioOptional: radioOptional(),
ratio: ratio(),
text: text(),
textOptional: textOptional(),
url: url(),
urlOptional: urlOptional(),
};
Loading

0 comments on commit 4e059c0

Please sign in to comment.