From a7a5416e05abe0dbf49f4f1e22c8724497ef3b05 Mon Sep 17 00:00:00 2001 From: Bruno Tot Date: Wed, 6 Sep 2023 17:16:28 +0200 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20[refactor]:=20Add=20playgr?= =?UTF-8?q?ound=20app=20for=20local=20development,=20add=20abstracted=20ho?= =?UTF-8?q?oks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/index.ts | 9 +- packages/core/package.json | 1 + .../src/model/processor/EntityProcessor.ts | 21 ++-- .../examples/basic-example-form/src/main.tsx | 7 +- .../react/examples/playground/.eslintrc.cjs | 18 ++++ packages/react/examples/playground/.gitignore | 24 +++++ packages/react/examples/playground/README.md | 27 +++++ .../react/examples/playground/dev-watch.js | 39 +++++++ packages/react/examples/playground/index.html | 12 +++ .../react/examples/playground/package.json | 33 ++++++ .../react/examples/playground/src/App.tsx | 11 ++ .../examples/playground/src/assets/react.svg | 1 + .../components/controls/UserFormControls.tsx | 102 ++++++++++++++++++ .../src/components/shared/Errors.tsx | 54 ++++++++++ .../src/components/shared/Input.tsx | 46 ++++++++ .../react/examples/playground/src/main.tsx | 5 + .../playground/src/models/LocationForm.ts | 20 ++++ .../playground/src/models/UserForm.ts | 44 ++++++++ .../src/validators/AdultAgeValid/index.ts | 14 +++ .../CaseInsensitiveContains/index.ts | 19 ++++ .../validators/PasswordsMustMatch/index.ts | 15 +++ .../examples/playground/src/vite-env.d.ts | 1 + .../react/examples/playground/tsconfig.json | 25 +++++ .../examples/playground/tsconfig.node.json | 10 ++ .../react/examples/playground/vite.config.ts | 7 ++ packages/react/package.json | 1 + .../src/hooks/useEntityProcessor/index.ts | 10 ++ .../src/hooks/useEntityProcessor/types.ts | 7 ++ packages/react/src/hooks/useForm/index.tsx | 49 +++------ packages/react/src/hooks/useForm/types.ts | 28 ++++- .../react/src/hooks/useMutations/index.ts | 43 ++++++++ .../react/src/hooks/useMutations/types.ts | 9 ++ packages/react/src/hooks/useReset/index.ts | 47 ++++++++ packages/react/src/hooks/useReset/types.ts | 13 +++ .../react/src/hooks/useValidation/index.ts | 13 +-- 35 files changed, 724 insertions(+), 61 deletions(-) create mode 100644 packages/react/examples/playground/.eslintrc.cjs create mode 100644 packages/react/examples/playground/.gitignore create mode 100644 packages/react/examples/playground/README.md create mode 100644 packages/react/examples/playground/dev-watch.js create mode 100644 packages/react/examples/playground/index.html create mode 100644 packages/react/examples/playground/package.json create mode 100644 packages/react/examples/playground/src/App.tsx create mode 100644 packages/react/examples/playground/src/assets/react.svg create mode 100644 packages/react/examples/playground/src/components/controls/UserFormControls.tsx create mode 100644 packages/react/examples/playground/src/components/shared/Errors.tsx create mode 100644 packages/react/examples/playground/src/components/shared/Input.tsx create mode 100644 packages/react/examples/playground/src/main.tsx create mode 100644 packages/react/examples/playground/src/models/LocationForm.ts create mode 100644 packages/react/examples/playground/src/models/UserForm.ts create mode 100644 packages/react/examples/playground/src/validators/AdultAgeValid/index.ts create mode 100644 packages/react/examples/playground/src/validators/CaseInsensitiveContains/index.ts create mode 100644 packages/react/examples/playground/src/validators/PasswordsMustMatch/index.ts create mode 100644 packages/react/examples/playground/src/vite-env.d.ts create mode 100644 packages/react/examples/playground/tsconfig.json create mode 100644 packages/react/examples/playground/tsconfig.node.json create mode 100644 packages/react/examples/playground/vite.config.ts create mode 100644 packages/react/src/hooks/useEntityProcessor/index.ts create mode 100644 packages/react/src/hooks/useEntityProcessor/types.ts create mode 100644 packages/react/src/hooks/useMutations/index.ts create mode 100644 packages/react/src/hooks/useMutations/types.ts create mode 100644 packages/react/src/hooks/useReset/index.ts create mode 100644 packages/react/src/hooks/useReset/types.ts diff --git a/packages/core/index.ts b/packages/core/index.ts index afd760e19..b773c302e 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -5,13 +5,17 @@ import { } from "./src/decorators/types/DecoratorContext.type"; import { ValidationGroup } from "./src/decorators/types/DecoratorProps.type"; import { Locale, getLocale, setLocale } from "./src/messages/model/Locale"; -import EntityProcessor from "./src/model/processor/EntityProcessor"; +import EntityProcessor, { + EntityProcessorConfig, +} from "./src/model/processor/EntityProcessor"; import { Class, StripClass } from "./src/types/Class.type"; import { DetailedErrors } from "./src/types/DetailedErrors.type"; import { Errors } from "./src/types/Errors.type"; import { Payload } from "./src/types/Payload.type"; import { ValidationEvaluator } from "./src/types/ValidationEvaluator.type"; import { ValidationResult } from "./src/types/ValidationResult.type"; +import { Condition } from "./src/types/namespace/Condition.ns"; +import { $ } from "./src/types/namespace/Utility.ns"; import validators from "./validators"; import Rule from "./validators/any/Rule"; @@ -19,13 +23,16 @@ export interface PrimitiveSetAppend {} export type { Class, + Condition, DecoratorContext, DecoratorContextMetadata, DetailedErrors, + EntityProcessorConfig, Errors, Locale, Payload, StripClass, + $ as TypeUtils, ValidationEvaluator, ValidationGroup, ValidationResult, diff --git a/packages/core/package.json b/packages/core/package.json index 4de2186fb..ddf5fd05e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -12,6 +12,7 @@ "scripts": { "clean": "rm -rf dist", "build": "npm run test && npm run clean && tsc && cp polyfill.d.ts ./dist && cp polyfill.d.ts ./dist/types", + "build:noTest": "npm run clean && tsc && cp polyfill.d.ts ./dist && cp polyfill.d.ts ./dist/types", "deploy:minor": "bash ../../scripts/deploy.sh core minor", "deploy:major": "bash ../../scripts/deploy.sh core major", "deploy:patch": "bash ../../scripts/deploy.sh core patch", diff --git a/packages/core/src/model/processor/EntityProcessor.ts b/packages/core/src/model/processor/EntityProcessor.ts index b0ec6eabd..d28cea35d 100644 --- a/packages/core/src/model/processor/EntityProcessor.ts +++ b/packages/core/src/model/processor/EntityProcessor.ts @@ -21,7 +21,7 @@ import MetadataProcessor from "./MetadataProcessor"; (Symbol as any).metadata ??= Symbol("Symbol.metadata"); -type EntityProcessorConfig = { +export type EntityProcessorConfig = { defaultValue?: TBody; groups?: ValidationGroup[]; }; @@ -58,20 +58,29 @@ export default class EntityProcessor { return this.#noArgsInstance; } - #buildEmptyInstance( + public static buildEmptyInstance( clazz: Class, - config?: EntityProcessorConfig + defaultValue?: TBody | undefined ) { - return (config?.defaultValue ?? new clazz()) as TBody; + return (defaultValue ?? new clazz()) as TBody; + } + + public static getClassFieldNames( + clazz: Class + ) { + return getClassFieldNames(clazz) as (keyof TBody)[]; } constructor(clazz: Class, config?: EntityProcessorConfig) { const groups = config?.groups ?? []; - this.#noArgsInstance = this.#buildEmptyInstance(clazz, config); + this.#noArgsInstance = EntityProcessor.buildEmptyInstance( + clazz, + config?.defaultValue + ); this.#clazz = clazz; this.#groups = Array.from(new Set(groups)); this.#cache = {} as EntityProcessorCache; - this.#fields = getClassFieldNames(clazz) as (keyof TBody)[]; + this.#fields = EntityProcessor.getClassFieldNames(clazz); this.#setMetadata(this.#noArgsInstance as Payload); } diff --git a/packages/react/examples/basic-example-form/src/main.tsx b/packages/react/examples/basic-example-form/src/main.tsx index 6396d1a5f..64e4ae30f 100644 --- a/packages/react/examples/basic-example-form/src/main.tsx +++ b/packages/react/examples/basic-example-form/src/main.tsx @@ -1,10 +1,5 @@ -import React from "react"; import ReactDOM from "react-dom/client"; import "tdv-core/dist/polyfill.d.ts"; import App from "./App.tsx"; -ReactDOM.createRoot(document.getElementById("root")!).render( - - - -); +ReactDOM.createRoot(document.getElementById("root")!).render(); diff --git a/packages/react/examples/playground/.eslintrc.cjs b/packages/react/examples/playground/.eslintrc.cjs new file mode 100644 index 000000000..d6c953795 --- /dev/null +++ b/packages/react/examples/playground/.eslintrc.cjs @@ -0,0 +1,18 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + ], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parser: '@typescript-eslint/parser', + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, +} diff --git a/packages/react/examples/playground/.gitignore b/packages/react/examples/playground/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/packages/react/examples/playground/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/packages/react/examples/playground/README.md b/packages/react/examples/playground/README.md new file mode 100644 index 000000000..1ebe379f5 --- /dev/null +++ b/packages/react/examples/playground/README.md @@ -0,0 +1,27 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: + +- Configure the top-level `parserOptions` property like this: + +```js + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + project: ['./tsconfig.json', './tsconfig.node.json'], + tsconfigRootDir: __dirname, + }, +``` + +- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` +- Optionally add `plugin:@typescript-eslint/stylistic-type-checked` +- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list diff --git a/packages/react/examples/playground/dev-watch.js b/packages/react/examples/playground/dev-watch.js new file mode 100644 index 000000000..87cb30905 --- /dev/null +++ b/packages/react/examples/playground/dev-watch.js @@ -0,0 +1,39 @@ +import { exec } from "child_process"; +import { watch } from "chokidar"; + +// Specify the directories you want to watch for changes +const directoriesToWatch = ["./../../../core/src", "./../../src"]; // Adjust as needed + +// Define the command to restart your Vite development server +const restartCommand = "npm run dev"; + +// Create a chokidar watcher +const watcher = watch(directoriesToWatch, { + ignoreInitial: true, // Ignore initial scan to avoid unnecessary restarts +}); + +// Start the Vite development server +exec(restartCommand, (error, stdout, stderr) => { + if (error) { + console.error(`Error starting the server: ${error}`); + return; + } + console.log(`Server started successfully.`); +}); + +// When a change is detected, execute the restart command +watcher.on("change", (path) => { + console.log(`Change detected in ${path}. Restarting server...`); + exec(restartCommand, (error, stdout, stderr) => { + if (error) { + console.error(`Error during server restart: ${error}`); + return; + } + console.log(`Server restarted successfully.`); + }); +}); + +// Handle errors +watcher.on("error", (error) => { + console.error(`Watcher error: ${error}`); +}); diff --git a/packages/react/examples/playground/index.html b/packages/react/examples/playground/index.html new file mode 100644 index 000000000..3120dc86f --- /dev/null +++ b/packages/react/examples/playground/index.html @@ -0,0 +1,12 @@ + + + + + + tdv-react example + + +
+ + + diff --git a/packages/react/examples/playground/package.json b/packages/react/examples/playground/package.json new file mode 100644 index 000000000..faa31f503 --- /dev/null +++ b/packages/react/examples/playground/package.json @@ -0,0 +1,33 @@ +{ + "name": "basic-example-form", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "start": "npm run dev", + "dev:watch": "node dev-watch.js", + "dev": "npm i --silent && npm run build:noTest --prefix=../../../core && npm run build:noTest --prefix=../.. && vite", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "tdv-core": "file:../../../core/dist", + "tdv-react": "file:../../dist" + }, + "devDependencies": { + "@types/react": "^18.2.15", + "@types/react-dom": "^18.2.7", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", + "@vitejs/plugin-react": "^4.0.3", + "chokidar-cli": "^3.0.0", + "eslint": "^8.45.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.3", + "typescript": "^5.2.2", + "vite": "^4.4.5" + } +} diff --git a/packages/react/examples/playground/src/App.tsx b/packages/react/examples/playground/src/App.tsx new file mode 100644 index 000000000..524a6cde8 --- /dev/null +++ b/packages/react/examples/playground/src/App.tsx @@ -0,0 +1,11 @@ +import UserFormControls from "./components/controls/UserFormControls"; + +function App() { + return ( +
+ +
+ ); +} + +export default App; diff --git a/packages/react/examples/playground/src/assets/react.svg b/packages/react/examples/playground/src/assets/react.svg new file mode 100644 index 000000000..6c87de9bb --- /dev/null +++ b/packages/react/examples/playground/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/react/examples/playground/src/components/controls/UserFormControls.tsx b/packages/react/examples/playground/src/components/controls/UserFormControls.tsx new file mode 100644 index 000000000..2de3451ff --- /dev/null +++ b/packages/react/examples/playground/src/components/controls/UserFormControls.tsx @@ -0,0 +1,102 @@ +import { useState } from "react"; +import { FormProvider, useForm } from "tdv-react"; +import UserForm from "../../models/UserForm"; +import Input from "../shared/Input"; + +const SUBMIT_BUTTON_TRIGGERS_VALIDATION = true; +const VALIDATION_GROUPS_FACTORY: "native" | "custom" | "both" = "both"; +const GROUPS = + VALIDATION_GROUPS_FACTORY === "both" + ? ["custom", "native"] + : VALIDATION_GROUPS_FACTORY === "custom" + ? ["custom"] + : ["native"]; + +function onSubmitValidationFail(e: any) { + alert( + "Form has invalid data. Please correct them\n" + JSON.stringify(e, null, 2) + ); +} + +export default function UserFormInput() { + const [numberOfStateChanges, setNumberOfStateChanges] = useState(0); + const [ + form, + _setForm, + { providerProps, errors, isSubmitted, isValid, onSubmit, reset, mutations }, + ] = useForm(UserForm, { + validationGroups: GROUPS, + onSubmit: () => alert("Congrats, you don't have any validation errors!"), + whenChanged: () => setNumberOfStateChanges((p) => ++p), + defaultValue: undefined, + standalone: true, + validateImmediately: !SUBMIT_BUTTON_TRIGGERS_VALIDATION, + onSubmitValidationFail, + }); + + console.count("UserFormControls"); + + const dateOfBirthValue = form!.dateOfBirth + ? form.dateOfBirth.toISOString().substring(0, 10) + : ""; + + return ( + <> + +
+ + + + + mutations.dateOfBirth!(v ? new Date(v) : null!)} + errors={errors.dateOfBirth} + type="date" + label="Date of birth" + /> + {SUBMIT_BUTTON_TRIGGERS_VALIDATION && ( + + )} +

Form changed state count: {numberOfStateChanges}

+ +
+
+ + ); +} diff --git a/packages/react/examples/playground/src/components/shared/Errors.tsx b/packages/react/examples/playground/src/components/shared/Errors.tsx new file mode 100644 index 000000000..cd5525642 --- /dev/null +++ b/packages/react/examples/playground/src/components/shared/Errors.tsx @@ -0,0 +1,54 @@ +export type ErrorsProps = { + errors: string[]; +}; + +/** + * A sample errors container. + */ +export default function Errors({ errors }: ErrorsProps) { + return ( + <> + {/* + Try returning this block of code instead to see how to limit the amount of error messages being displayed to one. + */} + {/*{errors.testEmail?.length > 0 && ( + + {errors.testEmail[0]} + + )}*/} + {errors?.length > 0 && ( +
    + {errors.map((msg) => ( +
  • + {msg} +
  • + ))} +
+ )} + + ); +} diff --git a/packages/react/examples/playground/src/components/shared/Input.tsx b/packages/react/examples/playground/src/components/shared/Input.tsx new file mode 100644 index 000000000..63dd88a81 --- /dev/null +++ b/packages/react/examples/playground/src/components/shared/Input.tsx @@ -0,0 +1,46 @@ +import Errors from "./Errors"; + +export type InputProps = { + label: string; + placeholder?: string; + value: string; + onChange: (value: string) => void; + errors?: string[]; + type?: "text" | "password" | "date"; +}; + +/** + * A sample input component. + */ +export default function Input({ + value, + onChange, + label, + placeholder, + errors = [], + type = "text", +}: InputProps) { + return ( +
+ {label} + onChange(e.target.value)} + /> + +
+ ); +} diff --git a/packages/react/examples/playground/src/main.tsx b/packages/react/examples/playground/src/main.tsx new file mode 100644 index 000000000..64e4ae30f --- /dev/null +++ b/packages/react/examples/playground/src/main.tsx @@ -0,0 +1,5 @@ +import ReactDOM from "react-dom/client"; +import "tdv-core/dist/polyfill.d.ts"; +import App from "./App.tsx"; + +ReactDOM.createRoot(document.getElementById("root")!).render(); diff --git a/packages/react/examples/playground/src/models/LocationForm.ts b/packages/react/examples/playground/src/models/LocationForm.ts new file mode 100644 index 000000000..edc487bbf --- /dev/null +++ b/packages/react/examples/playground/src/models/LocationForm.ts @@ -0,0 +1,20 @@ +import { validators } from "tdv-core"; + +const { Required } = validators.string; + +export type Location = { + country: string; + city: string; + address: string; +}; + +export default class LocationForm implements Location { + @Required({ groups: ["native"] }) + country: string = ""; + + @Required({ groups: ["native"] }) + city: string = ""; + + @Required({ groups: ["native"] }) + address: string = ""; +} diff --git a/packages/react/examples/playground/src/models/UserForm.ts b/packages/react/examples/playground/src/models/UserForm.ts new file mode 100644 index 000000000..1538ef562 --- /dev/null +++ b/packages/react/examples/playground/src/models/UserForm.ts @@ -0,0 +1,44 @@ +import { validators } from "tdv-core"; +import AdultAgeValid from "../validators/AdultAgeValid"; +import CaseInsensitiveContains from "../validators/CaseInsensitiveContains"; +import PasswordsMustMatch from "../validators/PasswordsMustMatch"; +import LocationForm from "./LocationForm"; + +export type User = { + testEmail: string; + age: string; + password: string; + confirmPassword: string; + dateOfBirth?: Date; +}; + +export default class UserForm implements User { + @CaseInsensitiveContains("Test", "custom") + @validators.string.Email({ groups: "native" }) + @validators.string.Required({ groups: "native" }) + testEmail: string = ""; + + @AdultAgeValid("custom") + @validators.string.Numeric({ groups: "native" }) + age: string = ""; + + @validators.string.Password({ groups: "native" }) + @validators.string.Required({ groups: "native" }) + password: string = ""; + + @PasswordsMustMatch("custom") + confirmPassword: string = ""; + + @validators.date.Required({ groups: "native" }) + dateOfBirth?: Date; + + @validators.boolean.Truthy({ + message: "Passwords must match!", + groups: "native", + }) + get arePasswordsEqual() { + return this.password === this.confirmPassword; + } + + locationForm: LocationForm = new LocationForm(); +} diff --git a/packages/react/examples/playground/src/validators/AdultAgeValid/index.ts b/packages/react/examples/playground/src/validators/AdultAgeValid/index.ts new file mode 100644 index 000000000..9f4b09b07 --- /dev/null +++ b/packages/react/examples/playground/src/validators/AdultAgeValid/index.ts @@ -0,0 +1,14 @@ +import { Rule, ValidationGroup } from "tdv-core"; + +const AdultAgeValid = (...groups: ValidationGroup[]) => { + return Rule({ + groups, + isValid: (v) => ({ + key: "Adult", + message: "Must enter amount between 18 and 100 inclusive", + valid: Number(v) >= 18 && Number(v) <= 100, + }), + }); +}; + +export default AdultAgeValid; diff --git a/packages/react/examples/playground/src/validators/CaseInsensitiveContains/index.ts b/packages/react/examples/playground/src/validators/CaseInsensitiveContains/index.ts new file mode 100644 index 000000000..a4cf9cacb --- /dev/null +++ b/packages/react/examples/playground/src/validators/CaseInsensitiveContains/index.ts @@ -0,0 +1,19 @@ +import { Rule, ValidationGroup } from "tdv-core"; +import UserForm from "../../models/UserForm"; + +const CaseInsensitiveContains = ( + containText: string, + ...groups: ValidationGroup[] +) => { + const containTextLowercase = containText.toLowerCase(); + return Rule({ + groups, + isValid: (current, _context: UserForm) => ({ + valid: (current ?? "").toLowerCase().includes(containTextLowercase), + key: "CaseInsensitiveContains", + message: `Text must contain \"${containTextLowercase}\"`, + }), + }); +}; + +export default CaseInsensitiveContains; diff --git a/packages/react/examples/playground/src/validators/PasswordsMustMatch/index.ts b/packages/react/examples/playground/src/validators/PasswordsMustMatch/index.ts new file mode 100644 index 000000000..cf5cf1b96 --- /dev/null +++ b/packages/react/examples/playground/src/validators/PasswordsMustMatch/index.ts @@ -0,0 +1,15 @@ +import { Rule, ValidationGroup } from "tdv-core"; +import UserForm from "../../models/UserForm"; + +const PasswordsMustMatch = (...groups: ValidationGroup[]) => { + return Rule({ + groups, + isValid: (v, _this: UserForm) => ({ + valid: v === _this.password, + key: "PasswordsMustMatch", + message: "Passwords must match", + }), + }); +}; + +export default PasswordsMustMatch; diff --git a/packages/react/examples/playground/src/vite-env.d.ts b/packages/react/examples/playground/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/packages/react/examples/playground/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/react/examples/playground/tsconfig.json b/packages/react/examples/playground/tsconfig.json new file mode 100644 index 000000000..4cab3f20f --- /dev/null +++ b/packages/react/examples/playground/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": false, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/packages/react/examples/playground/tsconfig.node.json b/packages/react/examples/playground/tsconfig.node.json new file mode 100644 index 000000000..42872c59f --- /dev/null +++ b/packages/react/examples/playground/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/packages/react/examples/playground/vite.config.ts b/packages/react/examples/playground/vite.config.ts new file mode 100644 index 000000000..5a33944a9 --- /dev/null +++ b/packages/react/examples/playground/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], +}) diff --git a/packages/react/package.json b/packages/react/package.json index 98aebc6ea..ea5909630 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -11,6 +11,7 @@ "scripts": { "clean": "rm -rf dist", "build": "npm run test && npm run clean && tsc", + "build:noTest": "npm run clean && tsc", "deploy:minor": "bash ../../scripts/deploy.sh react minor", "deploy:major": "bash ../../scripts/deploy.sh react major", "deploy:patch": "bash ../../scripts/deploy.sh react patch", diff --git a/packages/react/src/hooks/useEntityProcessor/index.ts b/packages/react/src/hooks/useEntityProcessor/index.ts new file mode 100644 index 000000000..8f9251409 --- /dev/null +++ b/packages/react/src/hooks/useEntityProcessor/index.ts @@ -0,0 +1,10 @@ +import { useMemo } from "react"; +import { Class, EntityProcessor } from "tdv-core"; +import ns from "./types"; + +export default function useEntityProcessor( + model: Class, + config?: ns.UseEntityProcessorConfig +) { + return useMemo(() => new EntityProcessor(model, config), []); +} diff --git a/packages/react/src/hooks/useEntityProcessor/types.ts b/packages/react/src/hooks/useEntityProcessor/types.ts new file mode 100644 index 000000000..e4ec040b3 --- /dev/null +++ b/packages/react/src/hooks/useEntityProcessor/types.ts @@ -0,0 +1,7 @@ +import { EntityProcessorConfig } from "tdv-core"; + +namespace UseEntityProcessorHook { + export type UseEntityProcessorConfig = EntityProcessorConfig; +} + +export default UseEntityProcessorHook; diff --git a/packages/react/src/hooks/useForm/index.tsx b/packages/react/src/hooks/useForm/index.tsx index 8a32df938..44caba22c 100644 --- a/packages/react/src/hooks/useForm/index.tsx +++ b/packages/react/src/hooks/useForm/index.tsx @@ -1,7 +1,9 @@ -import { useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { useContext, useEffect, useMemo, useState } from "react"; import { Class } from "tdv-core"; import { FormContext } from "../../contexts/FormContext"; import useEffectWhenMounted from "../useAfterMount"; +import useMutations from "../useMutations"; +import useReset from "../useReset"; import useValidation from "../useValidation"; import FormContextNamespace from "./../../contexts/FormContext/types"; import ns from "./types"; @@ -119,33 +121,6 @@ export default function useForm( await onSubmitParam(); }; - const handleChange: ns.UseFormSetterFn = useCallback( - (key, value) => { - setForm((prev) => { - const obj: any = {}; - for (const prop of processor.fields) { - obj[prop] = (prev as any)[prop]; - } - obj[key] = - typeof value === "function" ? (value as any)(prev[key]) : value; - return obj; - }); - }, - [setForm] - ); - - const cachedHandlers: ns.UseFormChangeHandlerMap = useMemo( - () => - processor.fields.reduce( - (prev, prop) => ({ - ...prev, - [prop]: (value: any) => handleChange(prop, value), - }), - {} - ), - [] - ) as ns.UseFormChangeHandlerMap; - const providerProps: Omit< FormContextNamespace.FormProviderProps, "children" @@ -155,20 +130,24 @@ export default function useForm( validateImmediately, }; - const resetForm = () => { - setForm(processor.noArgsInstance); - handleSetSubmitted(false); - }; + const mutations = useMutations(model, { setForm }); + + const reset = useReset({ + form, + handleSetSubmitted, + setForm, + processor, + submitted, + }); const data: ns.UseFormData = { isValid, isSubmitted, - cachedHandlers, + mutations, onSubmit, - handleChange, providerProps, errors: validateImmediately || isSubmitted ? errors : {}, - resetForm, + reset, }; return [form, setForm, data]; diff --git a/packages/react/src/hooks/useForm/types.ts b/packages/react/src/hooks/useForm/types.ts index fa19b3cea..88a5b1dd3 100644 --- a/packages/react/src/hooks/useForm/types.ts +++ b/packages/react/src/hooks/useForm/types.ts @@ -1,7 +1,28 @@ import { Dispatch, SetStateAction } from "react"; -import { Errors, ValidationGroup } from "tdv-core"; +import { Condition, Errors, TypeUtils, ValidationGroup } from "tdv-core"; import FormContextNamespace from "../../contexts/FormContext/types"; +// prettier-ignore +type ObjectPathEvaluator = K extends keyof T + ? K extends TypeUtils.WritableKeys + ? K | `${K}.${PayloadFieldPath}` + : '' + : never; + +// prettier-ignore +type PayloadFieldPathEvaluator = { + [K in keyof T]-?: K extends string + ? Condition.isFunction extends true ? never : + Condition.isArray extends true ? K : + Condition.isObject extends true ? ObjectPathEvaluator : + K extends TypeUtils.WritableKeys ? K : never : never; +} + +// prettier-ignore +type PayloadFieldPath = + Condition.isFunction extends true ? '' : + Condition.isObject extends true ? PayloadFieldPathEvaluator[keyof T] : ''; + namespace UseFormHook { export type UseFormConfig = { defaultValue?: TBody; @@ -16,12 +37,11 @@ namespace UseFormHook { export type UseFormData = { isValid: boolean; isSubmitted: boolean; - cachedHandlers: UseFormChangeHandlerMap; onSubmit: () => Promise; - handleChange: UseFormSetterFn; + mutations: UseFormChangeHandlerMap; providerProps: Omit; errors: Errors; - resetForm: () => void; + reset: (...fieldPaths: PayloadFieldPath[]) => void; }; export type UseFormReturn = readonly [ diff --git a/packages/react/src/hooks/useMutations/index.ts b/packages/react/src/hooks/useMutations/index.ts new file mode 100644 index 000000000..fa3347a43 --- /dev/null +++ b/packages/react/src/hooks/useMutations/index.ts @@ -0,0 +1,43 @@ +import { useMemo } from "react"; +import { Class, EntityProcessor } from "tdv-core"; +import UseFormNamespace from "./../useForm/types"; +import ns from "./types"; + +export default function useMutations( + clazz: Class, + { setForm }: ns.UseMutationsConfig +): UseFormNamespace.UseFormChangeHandlerMap { + const fields = useMemo( + () => EntityProcessor.getClassFieldNames(clazz), + [] + ); + + const handleChange: UseFormNamespace.UseFormSetterFn = ( + key, + value + ) => { + setForm((prev) => { + const obj: any = {}; + for (const prop of fields) { + obj[prop] = (prev as any)[prop]; + } + obj[key] = + typeof value === "function" ? (value as any)(prev[key]) : value; + return obj; + }); + }; + + const mutations: UseFormNamespace.UseFormChangeHandlerMap = useMemo( + () => + fields.reduce( + (prev, prop) => ({ + ...prev, + [prop]: (value: any) => handleChange(prop, value), + }), + {} + ), + [] + ) as UseFormNamespace.UseFormChangeHandlerMap; + + return mutations; +} diff --git a/packages/react/src/hooks/useMutations/types.ts b/packages/react/src/hooks/useMutations/types.ts new file mode 100644 index 000000000..54d2155e3 --- /dev/null +++ b/packages/react/src/hooks/useMutations/types.ts @@ -0,0 +1,9 @@ +import { Dispatch, SetStateAction } from "react"; + +namespace UseMutationsHook { + export type UseMutationsConfig = { + setForm: Dispatch>; + }; +} + +export default UseMutationsHook; diff --git a/packages/react/src/hooks/useReset/index.ts b/packages/react/src/hooks/useReset/index.ts new file mode 100644 index 000000000..5a5e45cf6 --- /dev/null +++ b/packages/react/src/hooks/useReset/index.ts @@ -0,0 +1,47 @@ +import UseFormNamespace from "./../useForm/types"; +import ns from "./types"; + +export default function useReset({ + processor, + form, + setForm, + submitted, + handleSetSubmitted, +}: ns.UseResetConfig) { + const reset: UseFormNamespace.UseFormData["reset"] = (...paths) => { + const noArgsInstance = processor.noArgsInstance; + + if (paths.length === 0) { + setForm(noArgsInstance); + handleSetSubmitted(false); + return; + } + + function cloneField(from: any, to: any, paths: string[]): boolean { + if (paths.length === 0) { + return false; + } + if (paths.length === 1) { + if (JSON.stringify(to[paths[0]]) !== JSON.stringify(from[paths[0]])) { + to[paths[0]] = from[paths[0]]; + return true; + } + return false; + } + const [parentPath, ...restPaths] = paths; + return cloneField(from[parentPath], to[parentPath], restPaths); + } + + const formClone = structuredClone(form); + const hasCloned = paths.some((p) => + cloneField(noArgsInstance, formClone, p.split(".")) + ); + if (hasCloned) { + setForm(formClone); + } + if (submitted) { + handleSetSubmitted(false); + } + }; + return reset; +} diff --git a/packages/react/src/hooks/useReset/types.ts b/packages/react/src/hooks/useReset/types.ts new file mode 100644 index 000000000..11a664b5e --- /dev/null +++ b/packages/react/src/hooks/useReset/types.ts @@ -0,0 +1,13 @@ +import { EntityProcessor } from "tdv-core"; + +namespace UseResetHook { + export type UseResetConfig = { + processor: EntityProcessor; + form: TBody; + setForm: (v: TBody) => void; + submitted: boolean; + handleSetSubmitted: (v: boolean) => void; + }; +} + +export default UseResetHook; diff --git a/packages/react/src/hooks/useValidation/index.ts b/packages/react/src/hooks/useValidation/index.ts index 4b1dd924a..e31e94255 100644 --- a/packages/react/src/hooks/useValidation/index.ts +++ b/packages/react/src/hooks/useValidation/index.ts @@ -1,11 +1,6 @@ -import { useEffect, useMemo, useState } from "react"; -import { - Class, - DetailedErrors, - EntityProcessor, - Errors, - Payload, -} from "tdv-core"; +import { useEffect, useState } from "react"; +import { Class, DetailedErrors, Errors, Payload } from "tdv-core"; +import useEntityProcessor from "../useEntityProcessor"; import ns from "./types"; /** @@ -39,7 +34,7 @@ export default function useValidation( const defaultValue = config?.defaultValue; const groups = config?.groups ?? []; // prettier-ignore - const poc = useMemo(() => new EntityProcessor(model, { groups, defaultValue }), []); + const poc = useEntityProcessor(model, {groups, defaultValue}); const initialForm = defaultValue ?? poc.noArgsInstance; const [form, setForm] = useState(initialForm as TBody); const [details, setDetails] = useState({} as DetailedErrors);