validation.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  1. import { z } from "zod";
  2. import type { DynamicValue, DataModel, LogicExpression } from "./types";
  3. import { DynamicValueSchema, resolveDynamicValue } from "./types";
  4. import { LogicExpressionSchema, evaluateLogicExpression } from "./visibility";
  5. /**
  6. * Validation check definition
  7. */
  8. export interface ValidationCheck {
  9. /** Function name (built-in or from catalog) */
  10. fn: string;
  11. /** Additional arguments for the function */
  12. args?: Record<string, DynamicValue>;
  13. /** Error message to display if check fails */
  14. message: string;
  15. }
  16. /**
  17. * Validation configuration for a field
  18. */
  19. export interface ValidationConfig {
  20. /** Array of checks to run */
  21. checks?: ValidationCheck[];
  22. /** When to run validation */
  23. validateOn?: "change" | "blur" | "submit";
  24. /** Condition for when validation is enabled */
  25. enabled?: LogicExpression;
  26. }
  27. /**
  28. * Schema for validation check
  29. */
  30. export const ValidationCheckSchema = z.object({
  31. fn: z.string(),
  32. args: z.record(DynamicValueSchema).optional(),
  33. message: z.string(),
  34. });
  35. /**
  36. * Schema for validation config
  37. */
  38. export const ValidationConfigSchema = z.object({
  39. checks: z.array(ValidationCheckSchema).optional(),
  40. validateOn: z.enum(["change", "blur", "submit"]).optional(),
  41. enabled: LogicExpressionSchema.optional(),
  42. });
  43. /**
  44. * Validation function signature
  45. */
  46. export type ValidationFunction = (
  47. value: unknown,
  48. args?: Record<string, unknown>,
  49. ) => boolean;
  50. /**
  51. * Validation function definition in catalog
  52. */
  53. export interface ValidationFunctionDefinition {
  54. /** The validation function */
  55. validate: ValidationFunction;
  56. /** Description for AI */
  57. description?: string;
  58. }
  59. /**
  60. * Built-in validation functions
  61. */
  62. export const builtInValidationFunctions: Record<string, ValidationFunction> = {
  63. /**
  64. * Check if value is not null, undefined, or empty string
  65. */
  66. required: (value: unknown) => {
  67. if (value === null || value === undefined) return false;
  68. if (typeof value === "string") return value.trim().length > 0;
  69. if (Array.isArray(value)) return value.length > 0;
  70. return true;
  71. },
  72. /**
  73. * Check if value is a valid email address
  74. */
  75. email: (value: unknown) => {
  76. if (typeof value !== "string") return false;
  77. return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
  78. },
  79. /**
  80. * Check minimum string length
  81. */
  82. minLength: (value: unknown, args?: Record<string, unknown>) => {
  83. if (typeof value !== "string") return false;
  84. const min = args?.min;
  85. if (typeof min !== "number") return false;
  86. return value.length >= min;
  87. },
  88. /**
  89. * Check maximum string length
  90. */
  91. maxLength: (value: unknown, args?: Record<string, unknown>) => {
  92. if (typeof value !== "string") return false;
  93. const max = args?.max;
  94. if (typeof max !== "number") return false;
  95. return value.length <= max;
  96. },
  97. /**
  98. * Check if string matches a regex pattern
  99. */
  100. pattern: (value: unknown, args?: Record<string, unknown>) => {
  101. if (typeof value !== "string") return false;
  102. const pattern = args?.pattern;
  103. if (typeof pattern !== "string") return false;
  104. try {
  105. return new RegExp(pattern).test(value);
  106. } catch {
  107. return false;
  108. }
  109. },
  110. /**
  111. * Check minimum numeric value
  112. */
  113. min: (value: unknown, args?: Record<string, unknown>) => {
  114. if (typeof value !== "number") return false;
  115. const min = args?.min;
  116. if (typeof min !== "number") return false;
  117. return value >= min;
  118. },
  119. /**
  120. * Check maximum numeric value
  121. */
  122. max: (value: unknown, args?: Record<string, unknown>) => {
  123. if (typeof value !== "number") return false;
  124. const max = args?.max;
  125. if (typeof max !== "number") return false;
  126. return value <= max;
  127. },
  128. /**
  129. * Check if value is a number
  130. */
  131. numeric: (value: unknown) => {
  132. if (typeof value === "number") return !isNaN(value);
  133. if (typeof value === "string") return !isNaN(parseFloat(value));
  134. return false;
  135. },
  136. /**
  137. * Check if value is a valid URL
  138. */
  139. url: (value: unknown) => {
  140. if (typeof value !== "string") return false;
  141. try {
  142. new URL(value);
  143. return true;
  144. } catch {
  145. return false;
  146. }
  147. },
  148. /**
  149. * Check if value matches another field
  150. */
  151. matches: (value: unknown, args?: Record<string, unknown>) => {
  152. const other = args?.other;
  153. return value === other;
  154. },
  155. };
  156. /**
  157. * Validation result for a single check
  158. */
  159. export interface ValidationCheckResult {
  160. fn: string;
  161. valid: boolean;
  162. message: string;
  163. }
  164. /**
  165. * Full validation result for a field
  166. */
  167. export interface ValidationResult {
  168. valid: boolean;
  169. errors: string[];
  170. checks: ValidationCheckResult[];
  171. }
  172. /**
  173. * Context for running validation
  174. */
  175. export interface ValidationContext {
  176. /** Current value to validate */
  177. value: unknown;
  178. /** Full data model for resolving paths */
  179. dataModel: DataModel;
  180. /** Custom validation functions from catalog */
  181. customFunctions?: Record<string, ValidationFunction>;
  182. }
  183. /**
  184. * Run a single validation check
  185. */
  186. export function runValidationCheck(
  187. check: ValidationCheck,
  188. ctx: ValidationContext,
  189. ): ValidationCheckResult {
  190. const { value, dataModel, customFunctions } = ctx;
  191. // Resolve args
  192. const resolvedArgs: Record<string, unknown> = {};
  193. if (check.args) {
  194. for (const [key, argValue] of Object.entries(check.args)) {
  195. resolvedArgs[key] = resolveDynamicValue(argValue, dataModel);
  196. }
  197. }
  198. // Find the validation function
  199. const fn =
  200. builtInValidationFunctions[check.fn] ?? customFunctions?.[check.fn];
  201. if (!fn) {
  202. console.warn(`Unknown validation function: ${check.fn}`);
  203. return {
  204. fn: check.fn,
  205. valid: true, // Don't fail on unknown functions
  206. message: check.message,
  207. };
  208. }
  209. const valid = fn(value, resolvedArgs);
  210. return {
  211. fn: check.fn,
  212. valid,
  213. message: check.message,
  214. };
  215. }
  216. /**
  217. * Run all validation checks for a field
  218. */
  219. export function runValidation(
  220. config: ValidationConfig,
  221. ctx: ValidationContext & { authState?: { isSignedIn: boolean } },
  222. ): ValidationResult {
  223. const checks: ValidationCheckResult[] = [];
  224. const errors: string[] = [];
  225. // Check if validation is enabled
  226. if (config.enabled) {
  227. const enabled = evaluateLogicExpression(config.enabled, {
  228. dataModel: ctx.dataModel,
  229. authState: ctx.authState,
  230. });
  231. if (!enabled) {
  232. return { valid: true, errors: [], checks: [] };
  233. }
  234. }
  235. // Run each check
  236. if (config.checks) {
  237. for (const check of config.checks) {
  238. const result = runValidationCheck(check, ctx);
  239. checks.push(result);
  240. if (!result.valid) {
  241. errors.push(result.message);
  242. }
  243. }
  244. }
  245. return {
  246. valid: errors.length === 0,
  247. errors,
  248. checks,
  249. };
  250. }
  251. /**
  252. * Helper to create validation checks
  253. */
  254. export const check = {
  255. required: (message = "This field is required"): ValidationCheck => ({
  256. fn: "required",
  257. message,
  258. }),
  259. email: (message = "Invalid email address"): ValidationCheck => ({
  260. fn: "email",
  261. message,
  262. }),
  263. minLength: (min: number, message?: string): ValidationCheck => ({
  264. fn: "minLength",
  265. args: { min },
  266. message: message ?? `Must be at least ${min} characters`,
  267. }),
  268. maxLength: (max: number, message?: string): ValidationCheck => ({
  269. fn: "maxLength",
  270. args: { max },
  271. message: message ?? `Must be at most ${max} characters`,
  272. }),
  273. pattern: (pattern: string, message = "Invalid format"): ValidationCheck => ({
  274. fn: "pattern",
  275. args: { pattern },
  276. message,
  277. }),
  278. min: (min: number, message?: string): ValidationCheck => ({
  279. fn: "min",
  280. args: { min },
  281. message: message ?? `Must be at least ${min}`,
  282. }),
  283. max: (max: number, message?: string): ValidationCheck => ({
  284. fn: "max",
  285. args: { max },
  286. message: message ?? `Must be at most ${max}`,
  287. }),
  288. url: (message = "Invalid URL"): ValidationCheck => ({
  289. fn: "url",
  290. message,
  291. }),
  292. matches: (
  293. otherPath: string,
  294. message = "Fields must match",
  295. ): ValidationCheck => ({
  296. fn: "matches",
  297. args: { other: { path: otherPath } },
  298. message,
  299. }),
  300. };