You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
160 lines
4.0 KiB
TypeScript
160 lines
4.0 KiB
TypeScript
import { z } from "./deps/zod.ts";
|
|
import { autoProxy, unproxy } from "./lib/autoproxy.ts";
|
|
import { Const } from "./utils.ts";
|
|
import { ValidationErrors } from "./validation.ts";
|
|
|
|
const fDef = <OptionsSchema extends z.ZodTypeAny>(p: {
|
|
optionsSchema: OptionsSchema;
|
|
defaultOptions: z.infer<OptionsSchema>;
|
|
}) => p;
|
|
export const Fields = {
|
|
string: fDef({
|
|
optionsSchema: z.object({
|
|
displayAs: z.union([z.literal("input"), z.literal("textarea")]),
|
|
minLength: z.number().min(0),
|
|
maxLength: z.number().optional(),
|
|
}),
|
|
defaultOptions: {
|
|
displayAs: "input",
|
|
minLength: 0,
|
|
},
|
|
}),
|
|
number: fDef({
|
|
optionsSchema: z.object({
|
|
min: z.number().optional(),
|
|
max: z.number().optional(),
|
|
}),
|
|
defaultOptions: {},
|
|
}),
|
|
};
|
|
export type FieldOptions = {
|
|
[Kind in FieldKind]: z.infer<(typeof Fields)[Kind]["optionsSchema"]>;
|
|
};
|
|
|
|
export type FieldKind = keyof typeof Fields;
|
|
export const FieldKinds: readonly FieldKind[] = Object.keys(
|
|
Fields,
|
|
) as readonly FieldKind[];
|
|
|
|
export const defaultOptionsForFieldKind = <K extends FieldKind>(
|
|
kind: K,
|
|
): (typeof Fields)[K]["defaultOptions"] => Fields[kind].defaultOptions;
|
|
|
|
type ZodField<Kind extends FieldKind> = z.ZodObject<{
|
|
name: z.ZodString;
|
|
required: z.ZodBoolean;
|
|
kind: z.ZodLiteral<Kind>;
|
|
options: (typeof Fields)[Kind]["optionsSchema"];
|
|
}>;
|
|
|
|
type AtLeastTwo<V> = [V, V, ...V[]];
|
|
type ZodFields = {
|
|
[Kind in FieldKind]: ZodField<Kind>;
|
|
}[FieldKind];
|
|
|
|
const makeZodFields = (): AtLeastTwo<ZodFields> => {
|
|
const keys = Object.keys(Fields) as FieldKind[];
|
|
|
|
return keys.map((k) => {
|
|
const zf: ZodField<typeof k> = z.object({
|
|
name: z.string(),
|
|
required: z.boolean(),
|
|
kind: z.literal(k),
|
|
options: Fields[k].optionsSchema,
|
|
});
|
|
|
|
return zf;
|
|
}) as any;
|
|
};
|
|
|
|
export const Field = z.union(makeZodFields());
|
|
export type Field = z.infer<typeof Field>;
|
|
|
|
const SchemaBase = {
|
|
fields: z.array(Field),
|
|
name: z.string(),
|
|
};
|
|
export const Schema = z.object(SchemaBase);
|
|
|
|
export type ID<Brand extends string, T = number> = T & {
|
|
readonly [P in Brand]: never;
|
|
};
|
|
export type RefineID<T, IDT> = Omit<T, "id"> & {
|
|
id: IDT;
|
|
};
|
|
|
|
export type SchemaID = ID<"__schema_id">;
|
|
export type Schema = RefineID<z.infer<typeof Schema>, SchemaID>;
|
|
|
|
export const MaybePersistedSchema = z.object({
|
|
...SchemaBase,
|
|
id: z.number().optional(),
|
|
version: z.number().optional(),
|
|
});
|
|
export type MaybePersistedSchema = z.infer<typeof MaybePersistedSchema>;
|
|
|
|
export type ValidationErrors_<T> = ValidationErrors<Omit<T, "id">>;
|
|
export const validateSchema = (
|
|
schema: Const<MaybePersistedSchema>,
|
|
): ValidationErrors_<MaybePersistedSchema> => {
|
|
const errors = autoProxy<ValidateSchemaResult>({});
|
|
|
|
const fieldNameMap = new Map<string, { idx: number; count: number }>();
|
|
for (let i = 0; i < schema.fields.length; ++i) {
|
|
const f = schema.fields[i];
|
|
if (!fieldNameMap.has(f.name)) {
|
|
fieldNameMap.set(f.name, { idx: i, count: 0 });
|
|
}
|
|
|
|
const ent = fieldNameMap.get(f.name)!;
|
|
ent.count++;
|
|
ent.idx = i;
|
|
}
|
|
|
|
for (const [, v] of fieldNameMap) {
|
|
if (v.count > 1) {
|
|
errors.fields[v.idx].name = ["Duplicate field name"];
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < schema.fields.length; ++i) {
|
|
const field = schema.fields[i];
|
|
if (field.name == "") {
|
|
errors.fields[i].name = ["Field name cannot be empty"];
|
|
}
|
|
|
|
const kind = field.kind;
|
|
if (kind == "number") {
|
|
const options = field.options;
|
|
if (
|
|
options.max != undefined &&
|
|
options.min != undefined &&
|
|
options.min >= options.max
|
|
) {
|
|
(errors.fields[i].options as any).min = [
|
|
"Min must be less than or equal to max",
|
|
];
|
|
}
|
|
} else if (kind == "string") {
|
|
const options = field.options;
|
|
if (
|
|
options.maxLength != undefined &&
|
|
options.maxLength < options.minLength
|
|
) {
|
|
(errors.fields[i].options as any).maxLength = [
|
|
"Max length must be greater than or equal to min",
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
if (schema.name == "") {
|
|
errors.name = ["Name cannot be empty"];
|
|
}
|
|
|
|
// TODO: validate that schemas are named uniquely
|
|
|
|
return unproxy(errors);
|
|
};
|
|
export type ValidateSchemaResult = ReturnType<typeof validateSchema>;
|