validation.tsx 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. import React, {
  2. createContext,
  3. useContext,
  4. useState,
  5. useCallback,
  6. useMemo,
  7. type ReactNode,
  8. } from "react";
  9. import {
  10. runValidation,
  11. type ValidationConfig,
  12. type ValidationFunction,
  13. type ValidationResult,
  14. } from "@json-render/core";
  15. import { useStateStore } from "./state";
  16. export interface FieldValidationState {
  17. touched: boolean;
  18. validated: boolean;
  19. result: ValidationResult | null;
  20. }
  21. export interface ValidationContextValue {
  22. customFunctions: Record<string, ValidationFunction>;
  23. fieldStates: Record<string, FieldValidationState>;
  24. validate: (path: string, config: ValidationConfig) => ValidationResult;
  25. touch: (path: string) => void;
  26. clear: (path: string) => void;
  27. validateAll: () => boolean;
  28. registerField: (path: string, config: ValidationConfig) => void;
  29. }
  30. const ValidationContext = createContext<ValidationContextValue | null>(null);
  31. export interface ValidationProviderProps {
  32. customFunctions?: Record<string, ValidationFunction>;
  33. children: ReactNode;
  34. }
  35. function dynamicArgsEqual(
  36. a: Record<string, unknown> | undefined,
  37. b: Record<string, unknown> | undefined,
  38. ): boolean {
  39. if (a === b) return true;
  40. if (!a || !b) return false;
  41. const keysA = Object.keys(a);
  42. const keysB = Object.keys(b);
  43. if (keysA.length !== keysB.length) return false;
  44. for (const key of keysA) {
  45. const va = a[key];
  46. const vb = b[key];
  47. if (va === vb) continue;
  48. if (
  49. typeof va === "object" &&
  50. va !== null &&
  51. typeof vb === "object" &&
  52. vb !== null
  53. ) {
  54. const sa = (va as Record<string, unknown>).$state;
  55. const sb = (vb as Record<string, unknown>).$state;
  56. if (typeof sa === "string" && sa === sb) continue;
  57. }
  58. return false;
  59. }
  60. return true;
  61. }
  62. function validationConfigEqual(
  63. a: ValidationConfig,
  64. b: ValidationConfig,
  65. ): boolean {
  66. if (a === b) return true;
  67. if (a.validateOn !== b.validateOn) return false;
  68. const ac = a.checks ?? [];
  69. const bc = b.checks ?? [];
  70. if (ac.length !== bc.length) return false;
  71. for (let i = 0; i < ac.length; i++) {
  72. const ca = ac[i]!;
  73. const cb = bc[i]!;
  74. if (ca.type !== cb.type) return false;
  75. if (ca.message !== cb.message) return false;
  76. if (!dynamicArgsEqual(ca.args, cb.args)) return false;
  77. }
  78. return true;
  79. }
  80. export function ValidationProvider({
  81. customFunctions = {},
  82. children,
  83. }: ValidationProviderProps) {
  84. const { state } = useStateStore();
  85. const [fieldStates, setFieldStates] = useState<
  86. Record<string, FieldValidationState>
  87. >({});
  88. const [fieldConfigs, setFieldConfigs] = useState<
  89. Record<string, ValidationConfig>
  90. >({});
  91. const registerField = useCallback(
  92. (path: string, config: ValidationConfig) => {
  93. setFieldConfigs((prev) => {
  94. const existing = prev[path];
  95. if (existing && validationConfigEqual(existing, config)) {
  96. return prev;
  97. }
  98. return { ...prev, [path]: config };
  99. });
  100. },
  101. [],
  102. );
  103. const validate = useCallback(
  104. (path: string, config: ValidationConfig): ValidationResult => {
  105. const segments = path.split("/").filter(Boolean);
  106. let value: unknown = state;
  107. for (const seg of segments) {
  108. if (value != null && typeof value === "object") {
  109. value = (value as Record<string, unknown>)[seg];
  110. } else {
  111. value = undefined;
  112. break;
  113. }
  114. }
  115. const result = runValidation(config, {
  116. value,
  117. stateModel: state,
  118. customFunctions,
  119. });
  120. setFieldStates((prev) => ({
  121. ...prev,
  122. [path]: {
  123. touched: prev[path]?.touched ?? true,
  124. validated: true,
  125. result,
  126. },
  127. }));
  128. return result;
  129. },
  130. [state, customFunctions],
  131. );
  132. const touch = useCallback((path: string) => {
  133. setFieldStates((prev) => ({
  134. ...prev,
  135. [path]: {
  136. ...prev[path],
  137. touched: true,
  138. validated: prev[path]?.validated ?? false,
  139. result: prev[path]?.result ?? null,
  140. },
  141. }));
  142. }, []);
  143. const clear = useCallback((path: string) => {
  144. setFieldStates((prev) => {
  145. const { [path]: _, ...rest } = prev;
  146. return rest;
  147. });
  148. }, []);
  149. const validateAll = useCallback(() => {
  150. let allValid = true;
  151. for (const [path, config] of Object.entries(fieldConfigs)) {
  152. const result = validate(path, config);
  153. if (!result.valid) {
  154. allValid = false;
  155. }
  156. }
  157. return allValid;
  158. }, [fieldConfigs, validate]);
  159. const value = useMemo<ValidationContextValue>(
  160. () => ({
  161. customFunctions,
  162. fieldStates,
  163. validate,
  164. touch,
  165. clear,
  166. validateAll,
  167. registerField,
  168. }),
  169. [
  170. customFunctions,
  171. fieldStates,
  172. validate,
  173. touch,
  174. clear,
  175. validateAll,
  176. registerField,
  177. ],
  178. );
  179. return (
  180. <ValidationContext.Provider value={value}>
  181. {children}
  182. </ValidationContext.Provider>
  183. );
  184. }
  185. export function useValidation(): ValidationContextValue {
  186. const ctx = useContext(ValidationContext);
  187. if (!ctx) {
  188. throw new Error("useValidation must be used within a ValidationProvider");
  189. }
  190. return ctx;
  191. }
  192. export function useFieldValidation(
  193. path: string,
  194. config?: ValidationConfig,
  195. ): {
  196. state: FieldValidationState;
  197. validate: () => ValidationResult;
  198. touch: () => void;
  199. clear: () => void;
  200. errors: string[];
  201. isValid: boolean;
  202. } {
  203. const {
  204. fieldStates,
  205. validate: validateField,
  206. touch: touchField,
  207. clear: clearField,
  208. registerField,
  209. } = useValidation();
  210. React.useEffect(() => {
  211. if (config) {
  212. registerField(path, config);
  213. }
  214. }, [path, config, registerField]);
  215. const state = fieldStates[path] ?? {
  216. touched: false,
  217. validated: false,
  218. result: null,
  219. };
  220. const validate = useCallback(
  221. () => validateField(path, config ?? { checks: [] }),
  222. [path, config, validateField],
  223. );
  224. const touch = useCallback(() => touchField(path), [path, touchField]);
  225. const clear = useCallback(() => clearField(path), [path, clearField]);
  226. return {
  227. state,
  228. validate,
  229. touch,
  230. clear,
  231. errors: state.result?.errors ?? [],
  232. isValid: state.result?.valid ?? true,
  233. };
  234. }