├── packages ├── core │ ├── src │ │ ├── index.ts │ │ ├── types.ts │ │ ├── client.ts │ │ └── validator.ts │ ├── tsconfig.json │ ├── LICENSE.md │ ├── package.json │ ├── README.md │ └── tests │ │ ├── client.test.js │ │ └── validator.test.js ├── react │ ├── tsconfig.json │ ├── LICENSE.md │ ├── src │ │ ├── types.ts │ │ └── index.ts │ ├── package.json │ └── README.md ├── alpine │ ├── tsconfig.json │ ├── src │ │ ├── types.ts │ │ └── index.ts │ ├── LICENSE.md │ ├── package.json │ └── README.md └── vue │ ├── tsconfig.json │ ├── LICENSE.md │ ├── src │ ├── types.ts │ └── index.ts │ ├── package.json │ └── README.md ├── bin └── release ├── LICENSE.md ├── package.json ├── README.md └── eslint.config.js /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | export { client, resolveUrl, resolveMethod } from './client.js' 2 | export { createValidator, toSimpleValidationErrors, resolveName } from './validator.js' 3 | export * from './types.js' 4 | -------------------------------------------------------------------------------- /bin/release: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | # Make sure the release tag is provided. 6 | if (( "$#" != 1 )) 7 | then 8 | echo "Version type has to be provided: major|minor|patch." 9 | 10 | exit 1 11 | fi 12 | 13 | npm version $1 \ 14 | --workspace=packages/core \ 15 | --workspace=packages/react \ 16 | --workspace=packages/vue \ 17 | --workspace=packages/alpine 18 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | "target": "ES2020", 5 | "module": "ES2020", 6 | "moduleResolution": "node", 7 | "resolveJsonModule": true, 8 | "strict": true, 9 | "declaration": true, 10 | "esModuleInterop": true 11 | }, 12 | "include": [ 13 | "./src/index.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /packages/react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | "target": "ES2020", 5 | "module": "ES2020", 6 | "moduleResolution": "node", 7 | "resolveJsonModule": true, 8 | "strict": true, 9 | "declaration": true, 10 | "esModuleInterop": true 11 | }, 12 | "include": [ 13 | "./src/index.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /packages/alpine/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | "target": "ES2020", 5 | "module": "ES2020", 6 | "moduleResolution": "node", 7 | "resolveJsonModule": true, 8 | "strict": true, 9 | "declaration": true, 10 | "esModuleInterop": true 11 | }, 12 | "include": [ 13 | "./src/index.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /packages/vue/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "skipLibCheck": true, 4 | "outDir": "./dist", 5 | "target": "ES2020", 6 | "module": "ES2020", 7 | "moduleResolution": "node", 8 | "resolveJsonModule": true, 9 | "strict": true, 10 | "declaration": true, 11 | "esModuleInterop": true, 12 | "types": [ 13 | "vue" 14 | ] 15 | }, 16 | "include": [ 17 | "./src/index.ts" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /packages/alpine/src/types.ts: -------------------------------------------------------------------------------- 1 | import { Config, NamedInputEvent, SimpleValidationErrors, ValidationConfig, ValidationErrors } from 'laravel-precognition' 2 | 3 | export interface Form> { 4 | processing: boolean, 5 | validating: boolean, 6 | touched(name?: string): boolean, 7 | touch(name: string | NamedInputEvent | Array): Data & Form, 8 | data(): Data, 9 | errors: Record, 10 | hasErrors: boolean, 11 | valid(name: string): boolean, 12 | invalid(name: string): boolean, 13 | validate(name?: string | NamedInputEvent | ValidationConfig, config?: ValidationConfig): Data & Form, 14 | setErrors(errors: SimpleValidationErrors | ValidationErrors): Data & Form 15 | forgetError(name: string | NamedInputEvent): Data & Form 16 | setValidationTimeout(duration: number): Data & Form, 17 | submit(config?: Config): Promise, 18 | reset(...keys: string[]): Data & Form, 19 | validateFiles(): Data & Form, 20 | withoutFileValidation(): Data & Form, 21 | } 22 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Taylor Otwell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/alpine/LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Taylor Otwell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/core/LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Taylor Otwell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/react/LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Taylor Otwell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/vue/LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Taylor Otwell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/react/src/types.ts: -------------------------------------------------------------------------------- 1 | import { Config, NamedInputEvent, ValidationConfig, Validator } from 'laravel-precognition' 2 | 3 | export interface Form> { 4 | processing: boolean, 5 | validating: boolean, 6 | touched(name?: keyof Data): boolean, 7 | touch(name: string | NamedInputEvent | Array): Form, 8 | data: Data, 9 | setData(key: Data | keyof Data, value?: unknown): Form, 10 | errors: Partial>, 11 | hasErrors: boolean, 12 | valid(name: keyof Data): boolean, 13 | invalid(name: keyof Data): boolean, 14 | validate(name?: keyof Data | NamedInputEvent | ValidationConfig, config?: ValidationConfig): Form, 15 | setErrors(errors: Partial>): Form 16 | forgetError(string: keyof Data | NamedInputEvent): Form 17 | setValidationTimeout(duration: number): Form, 18 | submit(config?: Config): Promise, 19 | reset(...names: (keyof Partial)[]): Form, 20 | validateFiles(): Form, 21 | withoutFileValidation(): Form, 22 | validator(): Validator, 23 | } 24 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laravel-precognition", 3 | "version": "1.0.0", 4 | "description": "Laravel Precognition.", 5 | "keywords": [ 6 | "laravel", 7 | "precognition" 8 | ], 9 | "homepage": "https://github.com/laravel/precognition", 10 | "type": "module", 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/laravel/precognition" 14 | }, 15 | "license": "MIT", 16 | "author": "Laravel", 17 | "main": "dist/index.js", 18 | "types": "dist/index.d.ts", 19 | "files": [ 20 | "/dist" 21 | ], 22 | "scripts": { 23 | "watch": "rm -rf dist && tsc --watch --preserveWatchOutput", 24 | "build": "rm -rf dist && tsc", 25 | "typeCheck": "tsc --noEmit", 26 | "prepublishOnly": "npm run build", 27 | "test": "vitest run" 28 | }, 29 | "dependencies": { 30 | "axios": "^1.4.0", 31 | "lodash-es": "^4.17.21" 32 | }, 33 | "devDependencies": { 34 | "@types/lodash-es": "^4.17.12", 35 | "@types/node": "^22.5.0", 36 | "typescript": "^5.0.0", 37 | "vitest": "^2.0.5" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/vue/src/types.ts: -------------------------------------------------------------------------------- 1 | import { ValidationConfig, Config, NamedInputEvent, Validator } from 'laravel-precognition' 2 | 3 | export interface Form> { 4 | processing: boolean, 5 | validating: boolean, 6 | touched(name?: keyof Data): boolean, 7 | touch(name: string | NamedInputEvent | Array): Data & Form, 8 | data(): Data, 9 | setData(data: Record): Data & Form, 10 | errors: Partial>, 11 | hasErrors: boolean, 12 | valid(name: keyof Data): boolean, 13 | invalid(name: keyof Data): boolean, 14 | validate(name?: (keyof Data | NamedInputEvent) | ValidationConfig, config?: ValidationConfig): Data & Form, 15 | setErrors(errors: Partial>): Data & Form 16 | forgetError(string: keyof Data | NamedInputEvent): Data & Form 17 | setValidationTimeout(duration: number): Data & Form, 18 | submit(config?: Config): Promise, 19 | reset(...keys: (keyof Partial)[]): Data & Form, 20 | validateFiles(): Data & Form, 21 | withoutFileValidation(): Data & Form, 22 | validator(): Validator, 23 | } 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "type": "module", 4 | "workspaces": [ 5 | "packages/core", 6 | "packages/react", 7 | "packages/vue", 8 | "packages/alpine" 9 | ], 10 | "scripts": { 11 | "watch": "npx concurrently \"npm run watch --workspace=packages/core\" \"npm run watch --workspace=packages/react\" \"npm run watch \"npm run watch --workspace=packages/vue\" \"npm run watch \"npm run watch --workspace=packages/alpine\" --names=core,react,vue,alpine", 12 | "build": "npm run build --workspaces", 13 | "link": "npm link --workspaces", 14 | "typeCheck": "npm run typeCheck --workspaces", 15 | "lint": "eslint", 16 | "test": "npm run test --workspaces --if-present" 17 | }, 18 | "devDependencies": { 19 | "@eslint/eslintrc": "^3.1.0", 20 | "@eslint/js": "^9.9.1", 21 | "@stylistic/eslint-plugin": "^2.6.4", 22 | "@typescript-eslint/eslint-plugin": "^8.3.0", 23 | "@typescript-eslint/parser": "^8.3.0", 24 | "eslint": "^9.9.1", 25 | "eslint-plugin-react": "^7.35.2", 26 | "eslint-plugin-vue": "^9.28.0", 27 | "globals": "^15.9.0", 28 | "typescript-eslint": "^8.4.0" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/vue/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laravel-precognition-vue", 3 | "version": "1.0.0", 4 | "description": "Laravel Precognition (Vue).", 5 | "keywords": [ 6 | "laravel", 7 | "precognition", 8 | "vue" 9 | ], 10 | "homepage": "https://github.com/laravel/precognition", 11 | "type": "module", 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/laravel/precognition" 15 | }, 16 | "license": "MIT", 17 | "author": "Laravel", 18 | "main": "dist/index.js", 19 | "files": [ 20 | "/dist" 21 | ], 22 | "scripts": { 23 | "watch": "rm -rf dist && tsc --watch --preserveWatchOutput", 24 | "build": "rm -rf dist && tsc", 25 | "typeCheck": "tsc --noEmit", 26 | "prepublishOnly": "npm run build", 27 | "version": "npm pkg set dependencies.laravel-precognition=$npm_package_version" 28 | }, 29 | "peerDependencies": { 30 | "vue": "^3.0.0" 31 | }, 32 | "dependencies": { 33 | "laravel-precognition": "1.0.0", 34 | "lodash-es": "^4.17.21" 35 | }, 36 | "devDependencies": { 37 | "@types/lodash-es": "^4.17.12", 38 | "typescript": "^5.0.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /packages/alpine/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laravel-precognition-alpine", 3 | "version": "1.0.0", 4 | "description": "Laravel Precognition (Alpine).", 5 | "keywords": [ 6 | "laravel", 7 | "precognition", 8 | "alpine" 9 | ], 10 | "homepage": "https://github.com/laravel/precognition", 11 | "type": "module", 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/laravel/precognition" 15 | }, 16 | "license": "MIT", 17 | "author": "Laravel", 18 | "main": "dist/index.js", 19 | "files": [ 20 | "/dist" 21 | ], 22 | "scripts": { 23 | "watch": "rm -rf dist && tsc --watch --preserveWatchOutput", 24 | "build": "rm -rf dist && tsc", 25 | "typeCheck": "tsc --noEmit", 26 | "prepublishOnly": "npm run build", 27 | "version": "npm pkg set dependencies.laravel-precognition=$npm_package_version" 28 | }, 29 | "peerDependencies": { 30 | "alpinejs": "^3.12.1" 31 | }, 32 | "dependencies": { 33 | "laravel-precognition": "1.0.0", 34 | "lodash-es": "^4.17.21" 35 | }, 36 | "devDependencies": { 37 | "@types/alpinejs": "^3.7.1", 38 | "@types/lodash-es": "^4.17.12", 39 | "typescript": "^5.0.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laravel-precognition-react", 3 | "version": "1.0.0", 4 | "description": "Laravel Precognition (React).", 5 | "keywords": [ 6 | "laravel", 7 | "precognition", 8 | "react" 9 | ], 10 | "homepage": "https://github.com/laravel/precognition", 11 | "type": "module", 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/laravel/precognition" 15 | }, 16 | "license": "MIT", 17 | "author": "Laravel", 18 | "main": "dist/index.js", 19 | "files": [ 20 | "/dist" 21 | ], 22 | "scripts": { 23 | "watch": "rm -rf dist && tsc --watch --preserveWatchOutput", 24 | "build": "rm -rf dist && tsc", 25 | "typeCheck": "tsc --noEmit", 26 | "prepublishOnly": "npm run build", 27 | "version": "npm pkg set dependencies.laravel-precognition=$npm_package_version" 28 | }, 29 | "peerDependencies": { 30 | "react": "^18.0.0 || ^19.0.0" 31 | }, 32 | "dependencies": { 33 | "laravel-precognition": "1.0.0", 34 | "lodash-es": "^4.17.21" 35 | }, 36 | "devDependencies": { 37 | "@types/lodash-es": "^4.17.12", 38 | "@types/react": "^18.2.6 || ^19.0.0", 39 | "typescript": "^5.0.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Precognition 2 | 3 | Test Status 4 | Total Downloads 5 | Latest Stable Version 6 | License 7 | 8 | ## Introduction 9 | 10 | Laravel Precognition allows you to anticipate the outcome of a future HTTP request. One of the primary use cases of Precognition is the ability to provide "live" validation in your frontend application. 11 | 12 | ## Official Documentation 13 | 14 | Documentation for Laravel Precognition can be found on the [Laravel website](https://laravel.com/docs/precognition). 15 | 16 | ## Contributing 17 | 18 | Thank you for considering contributing to Laravel Precognition! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). 19 | 20 | ## Code of Conduct 21 | 22 | In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). 23 | 24 | ## Security Vulnerabilities 25 | 26 | Please review [our security policy](https://github.com/laravel/precognition/security/policy) on how to report security vulnerabilities. 27 | 28 | ## License 29 | 30 | Laravel Precognition is open-sourced software licensed under the [MIT license](LICENSE.md). 31 | -------------------------------------------------------------------------------- /packages/core/README.md: -------------------------------------------------------------------------------- 1 | # Laravel Precognition 2 | 3 | Test Status 4 | Build Status 5 | Total Downloads 6 | Latest Stable Version 7 | License 8 | 9 | ## Introduction 10 | 11 | Laravel Precognition allows you to anticipate the outcome of a future HTTP request. One of the primary use cases of Precognition is the ability to provide "live" validation in your frontend application. 12 | 13 | ## Official Documentation 14 | 15 | Documentation for Laravel Precognition can be found on the [Laravel website](https://laravel.com/docs/precognition). 16 | 17 | ## Contributing 18 | 19 | Thank you for considering contributing to Laravel Precognition! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). 20 | 21 | ## Code of Conduct 22 | 23 | In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). 24 | 25 | ## Security Vulnerabilities 26 | 27 | Please review [our security policy](https://github.com/laravel/precognition/security/policy) on how to report security vulnerabilities. 28 | 29 | ## License 30 | 31 | Laravel Precognition is open-sourced software licensed under the [MIT license](LICENSE.md). 32 | -------------------------------------------------------------------------------- /packages/react/README.md: -------------------------------------------------------------------------------- 1 | # Laravel Precognition 2 | 3 | Test Status 4 | Build Status 5 | Total Downloads 6 | Latest Stable Version 7 | License 8 | 9 | ## Introduction 10 | 11 | Laravel Precognition allows you to anticipate the outcome of a future HTTP request. One of the primary use cases of Precognition is the ability to provide "live" validation in your frontend application. 12 | 13 | ## Official Documentation 14 | 15 | Documentation for Laravel Precognition can be found on the [Laravel website](https://laravel.com/docs/precognition). 16 | 17 | ## Contributing 18 | 19 | Thank you for considering contributing to Laravel Precognition! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). 20 | 21 | ## Code of Conduct 22 | 23 | In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). 24 | 25 | ## Security Vulnerabilities 26 | 27 | Please review [our security policy](https://github.com/laravel/precognition/security/policy) on how to report security vulnerabilities. 28 | 29 | ## License 30 | 31 | Laravel Precognition is open-sourced software licensed under the [MIT license](LICENSE.md). 32 | -------------------------------------------------------------------------------- /packages/vue/README.md: -------------------------------------------------------------------------------- 1 | # Laravel Precognition 2 | 3 | Test Status 4 | Build Status 5 | Total Downloads 6 | Latest Stable Version 7 | License 8 | 9 | ## Introduction 10 | 11 | Laravel Precognition allows you to anticipate the outcome of a future HTTP request. One of the primary use cases of Precognition is the ability to provide "live" validation in your frontend application. 12 | 13 | ## Official Documentation 14 | 15 | Documentation for Laravel Precognition can be found on the [Laravel website](https://laravel.com/docs/precognition). 16 | 17 | ## Contributing 18 | 19 | Thank you for considering contributing to Laravel Precognition! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). 20 | 21 | ## Code of Conduct 22 | 23 | In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). 24 | 25 | ## Security Vulnerabilities 26 | 27 | Please review [our security policy](https://github.com/laravel/precognition/security/policy) on how to report security vulnerabilities. 28 | 29 | ## License 30 | 31 | Laravel Precognition is open-sourced software licensed under the [MIT license](LICENSE.md). 32 | -------------------------------------------------------------------------------- /packages/alpine/README.md: -------------------------------------------------------------------------------- 1 | # Laravel Precognition 2 | 3 | Test Status 4 | Build Status 5 | Total Downloads 6 | Latest Stable Version 7 | License 8 | 9 | ## Introduction 10 | 11 | Laravel Precognition allows you to anticipate the outcome of a future HTTP request. One of the primary use cases of Precognition is the ability to provide "live" validation in your frontend application. 12 | 13 | ## Official Documentation 14 | 15 | Documentation for Laravel Precognition can be found on the [Laravel website](https://laravel.com/docs/precognition). 16 | 17 | ## Contributing 18 | 19 | Thank you for considering contributing to Laravel Precognition! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). 20 | 21 | ## Code of Conduct 22 | 23 | In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). 24 | 25 | ## Security Vulnerabilities 26 | 27 | Please review [our security policy](https://github.com/laravel/precognition/security/policy) on how to report security vulnerabilities. 28 | 29 | ## License 30 | 31 | Laravel Precognition is open-sourced software licensed under the [MIT license](LICENSE.md). 32 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from 'globals' 2 | import pluginJs from '@eslint/js' 3 | import pluginReact from 'eslint-plugin-react' 4 | import pluginTs from 'typescript-eslint' 5 | import pluginVue from 'eslint-plugin-vue' 6 | import stylistic from '@stylistic/eslint-plugin' 7 | import tsParser from '@typescript-eslint/parser' 8 | import typescriptEslint from '@typescript-eslint/eslint-plugin' 9 | 10 | export default [ 11 | { 12 | files: ['**/*.{js,jsx,mjs,cjs,ts,tsx}'], 13 | ignores: ['**/dist/**'], 14 | 15 | languageOptions: { 16 | globals: globals.browser, 17 | parser: tsParser, 18 | }, 19 | 20 | settings: { 21 | react: { 22 | version: '18', 23 | }, 24 | }, 25 | 26 | plugins: { 27 | 'js': pluginJs, 28 | 'react': pluginReact, 29 | 'vue': pluginVue, 30 | '@stylistic': stylistic, 31 | '@typescript-eslint': typescriptEslint, 32 | }, 33 | 34 | rules: { 35 | ...pluginJs.configs.recommended.rules, 36 | ...pluginTs.configs.recommended.reduce((carry, config) => ({ ...carry, ...config.rules }), {}), 37 | ...pluginReact.configs.flat.recommended.rules, 38 | ...pluginVue.configs['flat/recommended'].reduce((carry, config) => ({ ...carry, ...config.rules }), {}), 39 | 'quotes': ['error', 'single'], 40 | 'no-trailing-spaces': ['error'], 41 | 'object-curly-spacing': ['error', 'always'], 42 | 'comma-dangle': ['error', 'always-multiline'], 43 | 'semi': ['error', 'never'], 44 | '@typescript-eslint/no-non-null-assertion': ['off'], 45 | '@typescript-eslint/ban-ts-comment': ['off'], 46 | '@typescript-eslint/no-explicit-any': ['off'], 47 | '@stylistic/no-extra-semi': ['error'], 48 | '@stylistic/arrow-parens': ['error'], 49 | '@stylistic/space-infix-ops': ['error'], 50 | '@stylistic/indent': ['error', 4], 51 | }, 52 | }, 53 | ] 54 | -------------------------------------------------------------------------------- /packages/core/src/types.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios' 2 | 3 | export type StatusHandler = (response: AxiosResponse, axiosError?: AxiosError) => unknown 4 | 5 | export type ValidationErrors = Record> 6 | 7 | export type SimpleValidationErrors = Record 8 | 9 | export type Config = AxiosRequestConfig & { 10 | precognitive?: boolean, 11 | /** @deprecated Use `only` instead */ 12 | validate?: Iterable | ArrayLike, 13 | only?: Iterable | ArrayLike, 14 | fingerprint?: string | null, 15 | onBefore?: () => boolean | undefined, 16 | onStart?: () => void, 17 | onSuccess?: (response: AxiosResponse) => unknown, 18 | onPrecognitionSuccess?: (response: AxiosResponse) => unknown, 19 | onValidationError?: StatusHandler, 20 | onUnauthorized?: StatusHandler, 21 | onForbidden?: StatusHandler, 22 | onNotFound?: StatusHandler, 23 | onConflict?: StatusHandler, 24 | onLocked?: StatusHandler, 25 | onFinish?: () => void, 26 | } 27 | 28 | interface RevalidatePayload { 29 | data: Record | null, 30 | touched: Array, 31 | } 32 | 33 | export type ValidationConfig = Config & { 34 | onBeforeValidation?: (newRequest: RevalidatePayload, oldRequest: RevalidatePayload) => boolean | undefined, 35 | } 36 | 37 | export type RequestFingerprintResolver = (config: Config, axios: AxiosInstance) => string | null 38 | 39 | export type SuccessResolver = (response: AxiosResponse) => boolean 40 | 41 | export interface Client { 42 | get(url: string, data?: Record, config?: Config): Promise, 43 | post(url: string, data?: Record, config?: Config): Promise, 44 | patch(url: string, data?: Record, config?: Config): Promise, 45 | put(url: string, data?: Record, config?: Config): Promise, 46 | delete(url: string, data?: Record, config?: Config): Promise, 47 | use(axios: AxiosInstance): Client, 48 | fingerprintRequestsUsing(callback: RequestFingerprintResolver | null): Client, 49 | determineSuccessUsing(callback: SuccessResolver): Client, 50 | axios(): AxiosInstance, 51 | } 52 | 53 | export interface Validator { 54 | touched(): Array, 55 | validate(input?: string | NamedInputEvent | ValidationConfig, value?: unknown, config?: ValidationConfig): Validator, 56 | touch(input: string | NamedInputEvent | Array): Validator, 57 | validating(): boolean, 58 | valid(): Array, 59 | errors(): ValidationErrors, 60 | setErrors(errors: ValidationErrors | SimpleValidationErrors): Validator, 61 | hasErrors(): boolean, 62 | forgetError(error: string | NamedInputEvent): Validator, 63 | reset(...names: string[]): Validator, 64 | setTimeout(duration: number): Validator, 65 | on(event: keyof ValidatorListeners, callback: () => void): Validator, 66 | validateFiles(): Validator, 67 | withoutFileValidation(): Validator, 68 | defaults(data: Record): Validator, 69 | } 70 | 71 | export interface ValidatorListeners { 72 | errorsChanged: Array<() => void>, 73 | validatingChanged: Array<() => void>, 74 | touchedChanged: Array<() => void>, 75 | validatedChanged: Array<() => void>, 76 | } 77 | 78 | export type RequestMethod = 'get' | 'post' | 'patch' | 'put' | 'delete' 79 | 80 | export type ValidationCallback = (client: { 81 | get(url: string, data?: Record, config?: ValidationConfig): Promise, 82 | post(url: string, data?: Record, config?: ValidationConfig): Promise, 83 | patch(url: string, data?: Record, config?: ValidationConfig): Promise, 84 | put(url: string, data?: Record, config?: ValidationConfig): Promise, 85 | delete(url: string, data?: Record, config?: ValidationConfig): Promise, 86 | }) => Promise 87 | 88 | interface NamedEventTarget extends EventTarget { 89 | name: string 90 | } 91 | 92 | export interface NamedInputEvent extends InputEvent { 93 | readonly target: NamedEventTarget; 94 | } 95 | 96 | declare module 'axios' { 97 | export function mergeConfig(config1: AxiosRequestConfig, config2: AxiosRequestConfig): AxiosRequestConfig 98 | } 99 | -------------------------------------------------------------------------------- /packages/vue/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Config, RequestMethod, client, createValidator, toSimpleValidationErrors, ValidationConfig, resolveUrl, resolveMethod , resolveName } from 'laravel-precognition' 2 | import { Form } from './types.js' 3 | import { reactive, ref, toRaw } from 'vue' 4 | import { cloneDeep, get, set } from 'lodash-es' 5 | 6 | export { client, Form } 7 | 8 | export const useForm = >(method: RequestMethod | (() => RequestMethod), url: string | (() => string), inputs: Data | (() => Data), config: ValidationConfig = {}): Data & Form => { 9 | /** 10 | * The original data. 11 | */ 12 | const originalData = typeof inputs === 'function' ? cloneDeep(inputs()) : cloneDeep(inputs) 13 | 14 | /** 15 | * The original input names. 16 | */ 17 | const originalInputs: (keyof Data)[] = Object.keys(originalData) 18 | 19 | /** 20 | * Reactive valid state. 21 | */ 22 | const valid = ref<(keyof Data)[]>([]) 23 | 24 | /** 25 | * Reactive touched state. 26 | */ 27 | const touched = ref<(keyof Partial)[]>([]) 28 | 29 | /** 30 | * The validator instance. 31 | */ 32 | const validator = createValidator((client) => client[resolveMethod(method)](resolveUrl(url), form.data(), config), originalData) 33 | .on('validatingChanged', () => { 34 | form.validating = validator.validating() 35 | }) 36 | .on('validatedChanged', () => { 37 | valid.value = validator.valid() 38 | }) 39 | .on('touchedChanged', () => { 40 | touched.value = validator.touched() 41 | }) 42 | .on('errorsChanged', () => { 43 | form.hasErrors = validator.hasErrors() 44 | 45 | // @ts-expect-error 46 | form.errors = toSimpleValidationErrors(validator.errors()) 47 | 48 | valid.value = validator.valid() 49 | }) 50 | 51 | /** 52 | * Resolve the config for a form submission. 53 | */ 54 | const resolveSubmitConfig = (config: Config): Config => ({ 55 | ...config, 56 | precognitive: false, 57 | onStart: () => { 58 | form.processing = true; 59 | 60 | (config.onStart ?? (() => null))() 61 | }, 62 | onFinish: () => { 63 | form.processing = false; 64 | 65 | (config.onFinish ?? (() => null))() 66 | }, 67 | onValidationError: (response, error) => { 68 | validator.setErrors(response.data.errors) 69 | 70 | return config.onValidationError 71 | ? config.onValidationError(response) 72 | : Promise.reject(error) 73 | }, 74 | }) 75 | 76 | /** 77 | * Create a new form instance. 78 | */ 79 | let form: Data & Form = { 80 | ...cloneDeep(originalData), 81 | data() { 82 | const data = cloneDeep(toRaw(form)) 83 | 84 | return originalInputs.reduce>((carry, name) => ({ 85 | ...carry, 86 | [name]: data[name], 87 | }), {}) as Data 88 | }, 89 | setData(data: Record) { 90 | Object.keys(data).forEach((input) => { 91 | // @ts-expect-error 92 | form[input] = data[input] 93 | }) 94 | 95 | return form 96 | }, 97 | touched(name) { 98 | if (typeof name === 'string') { 99 | // @ts-expect-error 100 | return touched.value.includes(name) 101 | } else { 102 | return touched.value.length > 0 103 | } 104 | }, 105 | touch(name) { 106 | validator.touch(name) 107 | 108 | return form 109 | }, 110 | validate(name, config) { 111 | if (typeof name === 'object' && !('target' in name)) { 112 | config = name 113 | name = undefined 114 | } 115 | 116 | if (typeof name === 'undefined') { 117 | validator.validate(config) 118 | } else { 119 | // @ts-expect-error 120 | name = resolveName(name) 121 | 122 | validator.validate(name, get(form.data(), name), config) 123 | } 124 | 125 | return form 126 | }, 127 | validating: false, 128 | valid(name) { 129 | // @ts-expect-error 130 | return valid.value.includes(name) 131 | }, 132 | invalid(name) { 133 | return typeof form.errors[name] !== 'undefined' 134 | }, 135 | errors: {}, 136 | hasErrors: false, 137 | setErrors(errors) { 138 | // @ts-expect-error 139 | validator.setErrors(errors) 140 | 141 | return form 142 | }, 143 | forgetError(name) { 144 | // @ts-expect-error 145 | validator.forgetError(name) 146 | 147 | return form 148 | }, 149 | reset(...names) { 150 | const original = typeof inputs === 'function' ? cloneDeep(inputs()) : cloneDeep(originalData) 151 | 152 | if (names.length === 0) { 153 | // @ts-expect-error 154 | originalInputs.forEach((name) => (form[name] = original[name])) 155 | } else { 156 | names.forEach((name) => set(form, name, get(original, name))) 157 | } 158 | 159 | // @ts-expect-error 160 | validator.reset(...names) 161 | 162 | return form 163 | }, 164 | setValidationTimeout(duration) { 165 | validator.setTimeout(duration) 166 | 167 | return form 168 | }, 169 | processing: false, 170 | async submit(config = {}) { 171 | return client[resolveMethod(method)](resolveUrl(url), form.data(), resolveSubmitConfig(config)) 172 | }, 173 | validateFiles() { 174 | validator.validateFiles() 175 | 176 | return form 177 | }, 178 | withoutFileValidation() { 179 | validator.withoutFileValidation() 180 | 181 | return form 182 | }, 183 | validator() { 184 | return validator 185 | }, 186 | } 187 | 188 | form = reactive(form) as Data & Form 189 | 190 | return form 191 | } 192 | -------------------------------------------------------------------------------- /packages/react/src/index.ts: -------------------------------------------------------------------------------- 1 | import { resolveName, client, createValidator, Config, RequestMethod, Validator, toSimpleValidationErrors, ValidationConfig, resolveUrl, resolveMethod } from 'laravel-precognition' 2 | import { cloneDeep, get, set } from 'lodash-es' 3 | import { useRef, useState } from 'react' 4 | import { Form } from './types.js' 5 | 6 | export { client, Form } 7 | 8 | export const useForm = >(method: RequestMethod | (() => RequestMethod), url: string | (() => string), input: Data, config: ValidationConfig = {}): Form => { 9 | /** 10 | * The original data. 11 | */ 12 | const originalData = useRef(null) 13 | 14 | if (originalData.current === null) { 15 | originalData.current = cloneDeep(input) 16 | } 17 | 18 | /** 19 | * The original input names. 20 | */ 21 | const originalInputs = useRef<(keyof Data)[] | null>(null) 22 | 23 | if (originalInputs.current === null) { 24 | originalInputs.current = Object.keys(originalData) 25 | } 26 | 27 | /** 28 | * The current payload. 29 | */ 30 | const payload = useRef(originalData.current) 31 | 32 | /** 33 | * The reactive valid state. 34 | */ 35 | const [valid, setValid] = useState<(keyof Partial)[]>([]) 36 | 37 | /** 38 | * The reactive touched state. 39 | */ 40 | const [touched, setTouched] = useState<(keyof Partial)[]>([]) 41 | 42 | /** 43 | * The reactive validating state. 44 | */ 45 | const [validating, setValidating] = useState(false) 46 | 47 | /** 48 | * The reactive validating state. 49 | */ 50 | const [processing, setProcessing] = useState(false) 51 | 52 | /** 53 | * The reactive errors state. 54 | */ 55 | const [errors, setErrors] = useState>>({}) 56 | 57 | /** 58 | * The reactive hasErrors state. 59 | */ 60 | const [hasErrors, setHasErrors] = useState(false) 61 | 62 | /** 63 | * The reactive data state. 64 | */ 65 | const [data, setData] = useState(() => cloneDeep(originalData.current!)) 66 | 67 | /** 68 | * The validator instance. 69 | */ 70 | const validator = useRef(null) 71 | 72 | if (validator.current === null) { 73 | validator.current = createValidator((client) => client[resolveMethod(method)](resolveUrl(url), payload.current, config), input) 74 | .on('validatingChanged', () => { 75 | setValidating(validator.current!.validating()) 76 | }) 77 | .on('validatedChanged', () => { 78 | setValid(validator.current!.valid()) 79 | }) 80 | .on('touchedChanged', () => { 81 | setTouched(validator.current!.touched()) 82 | }) 83 | .on('errorsChanged', () => { 84 | setHasErrors(validator.current!.hasErrors()) 85 | 86 | // @ts-expect-error 87 | setErrors(toSimpleValidationErrors(validator.current!.errors())) 88 | 89 | setValid(validator.current!.valid()) 90 | }) 91 | } 92 | 93 | /** 94 | * Resolve the config for a form submission. 95 | */ 96 | const resolveSubmitConfig = (config: Config): Config => ({ 97 | ...config, 98 | precognitive: false, 99 | onStart: () => { 100 | setProcessing(true); 101 | 102 | (config.onStart ?? (() => null))() 103 | }, 104 | onFinish: () => { 105 | setProcessing(false); 106 | 107 | (config.onFinish ?? (() => null))() 108 | }, 109 | onValidationError: (response, error) => { 110 | validator.current!.setErrors(response.data.errors) 111 | 112 | return config.onValidationError 113 | ? config.onValidationError(response) 114 | : Promise.reject(error) 115 | }, 116 | }) 117 | 118 | /** 119 | * The form instance. 120 | */ 121 | const form: Form = { 122 | data, 123 | setData(key, value) { 124 | if (typeof key === 'object') { 125 | payload.current = key 126 | 127 | setData(key) 128 | } else { 129 | const newData = cloneDeep(payload.current!) 130 | 131 | payload.current = set(newData, key, value) 132 | 133 | setData(payload.current) 134 | } 135 | 136 | return form 137 | }, 138 | touched(name) { 139 | if (typeof name === 'string') { 140 | return touched.includes(name) 141 | } else { 142 | return touched.length > 0 143 | } 144 | }, 145 | touch(name) { 146 | validator.current!.touch(name) 147 | 148 | return form 149 | }, 150 | validate(name, config) { 151 | if (typeof name === 'object' && !('target' in name)) { 152 | config = name 153 | name = undefined 154 | } 155 | 156 | if (typeof name === 'undefined') { 157 | validator.current!.validate(config) 158 | } else { 159 | // @ts-expect-error 160 | name = resolveName(name) 161 | 162 | validator.current!.validate(name, get(payload.current, name), config) 163 | } 164 | 165 | return form 166 | }, 167 | validating, 168 | valid(name) { 169 | return valid.includes(name) 170 | }, 171 | invalid(name) { 172 | return typeof errors[name] !== 'undefined' 173 | }, 174 | errors, 175 | hasErrors, 176 | setErrors(errors) { 177 | // @ts-expect-error 178 | validator.current!.setErrors(errors) 179 | 180 | return form 181 | }, 182 | forgetError(name) { 183 | // @ts-expect-error 184 | validator.current!.forgetError(name) 185 | 186 | return form 187 | }, 188 | reset(...names) { 189 | const original = cloneDeep(originalData.current)! 190 | 191 | if (names.length === 0) { 192 | payload.current = original 193 | 194 | setData(original) 195 | } else { 196 | names.forEach((name) => (set(payload.current, name, get(original, name)))) 197 | 198 | setData(payload.current) 199 | } 200 | 201 | // @ts-expect-error 202 | validator.current!.reset(...names) 203 | 204 | return form 205 | }, 206 | setValidationTimeout(duration) { 207 | validator.current!.setTimeout(duration) 208 | 209 | return form 210 | }, 211 | processing, 212 | async submit(config = {}) { 213 | return client[resolveMethod(method)](resolveUrl(url), payload.current, resolveSubmitConfig(config)) 214 | }, 215 | validateFiles() { 216 | validator.current!.validateFiles() 217 | 218 | return form 219 | }, 220 | withoutFileValidation() { 221 | validator.current!.withoutFileValidation() 222 | 223 | return form 224 | }, 225 | validator() { 226 | return validator.current! 227 | }, 228 | } 229 | 230 | return form 231 | } 232 | -------------------------------------------------------------------------------- /packages/alpine/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Alpine as TAlpine } from 'alpinejs' 2 | import { client, Config, createValidator, RequestMethod, resolveName, toSimpleValidationErrors, ValidationConfig, resolveUrl, resolveMethod } from 'laravel-precognition' 3 | import { cloneDeep, get, set } from 'lodash-es' 4 | import { Form } from './types.js' 5 | 6 | export { client, Form } 7 | 8 | export default function (Alpine: TAlpine) { 9 | Alpine.magic('form', (el) => >(method: RequestMethod | (() => RequestMethod), url: string | (() => string), inputs: Data, config: ValidationConfig = {}): Data & Form => { 10 | /** 11 | * The original data. 12 | */ 13 | const originalData = cloneDeep(inputs) 14 | 15 | /** 16 | * The original input names. 17 | */ 18 | const originalInputs = Object.keys(originalData) 19 | 20 | /** 21 | * Internal reactive state. 22 | */ 23 | const state: { 24 | touched: string[], 25 | valid: string[], 26 | } = Alpine.reactive({ 27 | touched: [], 28 | valid: [], 29 | }) 30 | 31 | /** 32 | * The validator instance. 33 | */ 34 | const validator = createValidator((client) => client[resolveMethod(method)](resolveUrl(url), form.data(), config), originalData) 35 | .on('validatingChanged', () => { 36 | form.validating = validator.validating() 37 | }) 38 | .on('validatedChanged', () => { 39 | state.valid = validator.valid() 40 | }) 41 | .on('touchedChanged', () => { 42 | state.touched = validator.touched() 43 | }) 44 | .on('errorsChanged', () => { 45 | form.hasErrors = validator.hasErrors() 46 | 47 | form.errors = toSimpleValidationErrors(validator.errors()) 48 | 49 | state.valid = validator.valid() 50 | }) 51 | 52 | /** 53 | * Resolve the config for a form submission. 54 | */ 55 | const resolveSubmitConfig = (config: Config): Config => ({ 56 | ...config, 57 | precognitive: false, 58 | onStart: () => { 59 | form.processing = true; 60 | 61 | (config.onStart ?? (() => null))() 62 | }, 63 | onFinish: () => { 64 | form.processing = false; 65 | 66 | (config.onFinish ?? (() => null))() 67 | }, 68 | onValidationError: (response, error) => { 69 | validator.setErrors(response.data.errors) 70 | 71 | return config.onValidationError 72 | ? config.onValidationError(response) 73 | : Promise.reject(error) 74 | }, 75 | }) 76 | 77 | /** 78 | * Create a new form instance. 79 | */ 80 | const createForm = (): Data & Form => ({ 81 | ...cloneDeep(inputs), 82 | data() { 83 | const newForm = cloneDeep(form) 84 | 85 | return originalInputs.reduce((carry, name) => ({ 86 | ...carry, 87 | [name]: newForm[name], 88 | }), {}) as Data 89 | }, 90 | touched(name) { 91 | if (typeof name === 'string') { 92 | return state.touched.includes(name) 93 | } else { 94 | return state.touched.length > 0 95 | } 96 | }, 97 | touch(name) { 98 | validator.touch(name) 99 | 100 | return form 101 | }, 102 | validate(name, config) { 103 | if (typeof name === 'object' && !('target' in name)) { 104 | config = name 105 | name = undefined 106 | } 107 | 108 | if (typeof name === 'undefined') { 109 | validator.validate(config) 110 | } else { 111 | name = resolveName(name) 112 | 113 | validator.validate(name, get(form.data(), name), config) 114 | } 115 | 116 | return form 117 | }, 118 | validating: false, 119 | valid(name) { 120 | return state.valid.includes(name) 121 | }, 122 | invalid(name) { 123 | return typeof form.errors[name] !== 'undefined' 124 | }, 125 | errors: {}, 126 | hasErrors: false, 127 | setErrors(errors) { 128 | validator.setErrors(errors) 129 | 130 | return form 131 | }, 132 | forgetError(name) { 133 | validator.forgetError(name) 134 | 135 | return form 136 | }, 137 | reset(...names) { 138 | const original = cloneDeep(originalData) 139 | 140 | if (names.length === 0) { 141 | // @ts-expect-error 142 | originalInputs.forEach((name) => (form[name] = original[name])) 143 | } else { 144 | names.forEach((name) => set(form, name, get(original, name))) 145 | } 146 | 147 | validator.reset(...names) 148 | 149 | return form 150 | }, 151 | setValidationTimeout(duration) { 152 | validator.setTimeout(duration) 153 | 154 | return form 155 | }, 156 | processing: false, 157 | async submit(config = {}) { 158 | return client[resolveMethod(method)](resolveUrl(url), form.data(), resolveSubmitConfig(config)) 159 | }, 160 | validateFiles() { 161 | validator.validateFiles() 162 | 163 | return form 164 | }, 165 | withoutFileValidation() { 166 | validator.withoutFileValidation() 167 | 168 | return form 169 | }, 170 | }) 171 | 172 | /** 173 | * The form instance. 174 | */ 175 | const form = Alpine.reactive(createForm()) as Data & Form 176 | 177 | syncWithDom(Alpine, el, resolveMethod(method), resolveUrl(url), form) 178 | 179 | return form 180 | }) 181 | } 182 | 183 | /** 184 | * Sync the DOM form with the Precognitive form. 185 | */ 186 | const syncWithDom = >(Alpine: TAlpine, el: Node, method: RequestMethod, url: string, form: Form): void => { 187 | if (! (el instanceof Element && el.nodeName === 'FORM')) { 188 | return 189 | } 190 | 191 | syncSyntheticMethodInput(el, method) 192 | syncMethodAttribute(el, method) 193 | syncActionAttribute(el, url) 194 | addProcessingListener(Alpine, el, form) 195 | } 196 | 197 | /** 198 | * Sync the form's "method" attribute. 199 | */ 200 | const syncMethodAttribute = (el: Element, method: RequestMethod) => { 201 | if (method !== 'get' && ! el.hasAttribute('method')) { 202 | el.setAttribute('method', 'POST') 203 | } 204 | } 205 | 206 | /** 207 | * Sync the form's "action" attribute. 208 | */ 209 | const syncActionAttribute = (el: Element, url: string) => { 210 | if (! el.hasAttribute('action')) { 211 | el.setAttribute('action', url) 212 | } 213 | } 214 | 215 | /** 216 | * Sync the form's sythentic "method" input. 217 | */ 218 | const syncSyntheticMethodInput = (el: Element, method: RequestMethod) => { 219 | if (['get', 'post'].includes(method)) { 220 | return 221 | } 222 | 223 | const existing = el.querySelector('input[type="hidden"][name="_method"]') 224 | 225 | if (existing !== null) { 226 | return 227 | } 228 | 229 | const input = el.insertAdjacentElement('afterbegin', document.createElement('input')) 230 | 231 | if (input === null) { 232 | return 233 | } 234 | 235 | input.setAttribute('type', 'hidden') 236 | input.setAttribute('name', '_method') 237 | input.setAttribute('value', method.toUpperCase()) 238 | } 239 | 240 | /** 241 | * Add processing listener. 242 | */ 243 | const addProcessingListener = >(Alpine: TAlpine, el: Element, form: Form) => el.addEventListener('submit', () => { 244 | Alpine.nextTick(() => form.processing = true) 245 | }) 246 | -------------------------------------------------------------------------------- /packages/core/src/client.ts: -------------------------------------------------------------------------------- 1 | import { isAxiosError, isCancel, AxiosInstance, AxiosResponse, default as Axios } from 'axios' 2 | import { merge } from 'lodash-es' 3 | import { Config, Client, RequestFingerprintResolver, StatusHandler, SuccessResolver, RequestMethod } from './types.js' 4 | 5 | /** 6 | * The configured axios client. 7 | */ 8 | let axiosClient: AxiosInstance = Axios.create() 9 | 10 | /** 11 | * The request fingerprint resolver. 12 | */ 13 | let requestFingerprintResolver: RequestFingerprintResolver = (config, axios) => `${config.method}:${config.baseURL ?? axios.defaults.baseURL ?? ''}${config.url}` 14 | 15 | /** 16 | * The precognition success resolver. 17 | */ 18 | let successResolver: SuccessResolver = (response: AxiosResponse) => response.status === 204 && response.headers['precognition-success'] === 'true' 19 | 20 | /** 21 | * The abort controller cache. 22 | */ 23 | const abortControllers: Record = {} 24 | 25 | /** 26 | * The precognitive HTTP client instance. 27 | */ 28 | export const client: Client = { 29 | get: (url, data = {}, config = {}) => request(mergeConfig('get', url, data, config)), 30 | post: (url, data = {}, config = {}) => request(mergeConfig('post', url, data, config)), 31 | patch: (url, data = {}, config = {}) => request(mergeConfig('patch', url, data, config)), 32 | put: (url, data = {}, config = {}) => request(mergeConfig('put', url, data, config)), 33 | delete: (url, data = {}, config = {}) => request(mergeConfig('delete', url, data, config)), 34 | use(axios) { 35 | axiosClient = axios 36 | 37 | return client 38 | }, 39 | axios() { 40 | return axiosClient 41 | }, 42 | fingerprintRequestsUsing(callback) { 43 | requestFingerprintResolver = callback === null 44 | ? () => null 45 | : callback 46 | 47 | return client 48 | }, 49 | determineSuccessUsing(callback) { 50 | successResolver = callback 51 | 52 | return client 53 | }, 54 | } 55 | 56 | /** 57 | * Merge the client specified arguments with the provided configuration. 58 | */ 59 | const mergeConfig = (method: RequestMethod, url: string, data?: Record, config?: Config) => ({ 60 | url, 61 | method, 62 | ...config, 63 | ...(['get', 'delete'].includes(method) ? { 64 | params: merge({}, data, config?.params), 65 | } : { 66 | data: merge({}, data, config?.data), 67 | }), 68 | }) 69 | 70 | /** 71 | * Send and handle a new request. 72 | */ 73 | const request = (userConfig: Config = {}): Promise => { 74 | const config = [ 75 | resolveConfig, 76 | abortMatchingRequests, 77 | refreshAbortController, 78 | ].reduce((config, callback) => callback(config), userConfig) 79 | 80 | if ((config.onBefore ?? (() => true))() === false) { 81 | return Promise.resolve(null) 82 | } 83 | 84 | (config.onStart ?? (() => null))() 85 | 86 | return axiosClient.request(config).then(async (response) => { 87 | if (config.precognitive) { 88 | validatePrecognitionResponse(response) 89 | } 90 | 91 | const status = response.status 92 | 93 | let payload: any = response 94 | 95 | if (config.precognitive && config.onPrecognitionSuccess && successResolver(payload)) { 96 | payload = await Promise.resolve(config.onPrecognitionSuccess(payload) ?? payload) 97 | } 98 | 99 | if (config.onSuccess && isSuccess(status)) { 100 | payload = await Promise.resolve(config.onSuccess(payload) ?? payload) 101 | } 102 | 103 | const statusHandler = resolveStatusHandler(config, status) 104 | ?? ((response) => response) 105 | 106 | return statusHandler(payload) ?? payload 107 | }, (error) => { 108 | if (isNotServerGeneratedError(error)) { 109 | return Promise.reject(error) 110 | } 111 | 112 | if (config.precognitive) { 113 | validatePrecognitionResponse(error.response) 114 | } 115 | 116 | const statusHandler = resolveStatusHandler(config, error.response.status) 117 | ?? ((_, error) => Promise.reject(error)) 118 | 119 | return statusHandler(error.response, error) 120 | }).finally(config.onFinish ?? (() => null)) 121 | } 122 | 123 | /** 124 | * Resolve the configuration. 125 | */ 126 | const resolveConfig = (config: Config): Config => { 127 | const only = config.only ?? config.validate 128 | 129 | return { 130 | ...config, 131 | timeout: config.timeout ?? axiosClient.defaults['timeout'] ?? 30000, 132 | precognitive: config.precognitive !== false, 133 | fingerprint: typeof config.fingerprint === 'undefined' 134 | ? requestFingerprintResolver(config, axiosClient) 135 | : config.fingerprint, 136 | headers: { 137 | ...config.headers, 138 | 'Content-Type': resolveContentType(config), 139 | ...config.precognitive !== false ? { 140 | Precognition: true, 141 | } : {}, 142 | ...only ? { 143 | 'Precognition-Validate-Only': Array.from(only).join(), 144 | } : {}, 145 | }, 146 | } 147 | } 148 | 149 | /** 150 | * Determine if the status is successful. 151 | */ 152 | const isSuccess = (status: number) => status >= 200 && status < 300 153 | 154 | /** 155 | * Abort an existing request with the same configured fingerprint. 156 | */ 157 | const abortMatchingRequests = (config: Config): Config => { 158 | if (typeof config.fingerprint !== 'string') { 159 | return config 160 | } 161 | 162 | abortControllers[config.fingerprint]?.abort() 163 | 164 | delete abortControllers[config.fingerprint] 165 | 166 | return config 167 | } 168 | 169 | /** 170 | * Create and configure the abort controller for a new request. 171 | */ 172 | const refreshAbortController = (config: Config): Config => { 173 | if ( 174 | typeof config.fingerprint !== 'string' 175 | || config.signal 176 | || config.cancelToken 177 | || ! config.precognitive 178 | ) { 179 | return config 180 | } 181 | 182 | abortControllers[config.fingerprint] = new AbortController 183 | 184 | return { 185 | ...config, 186 | signal: abortControllers[config.fingerprint].signal, 187 | } 188 | } 189 | 190 | /** 191 | * Ensure that the response is a Precognition response. 192 | */ 193 | const validatePrecognitionResponse = (response: AxiosResponse): void => { 194 | if (response.headers?.precognition !== 'true') { 195 | throw Error('Did not receive a Precognition response. Ensure you have the Precognition middleware in place for the route.') 196 | } 197 | } 198 | 199 | /** 200 | * Determine if the error was not triggered by a server response. 201 | */ 202 | const isNotServerGeneratedError = (error: unknown): boolean => { 203 | return ! isAxiosError(error) || typeof error.response?.status !== 'number' || isCancel(error) 204 | } 205 | 206 | /** 207 | * Resolve the handler for the given HTTP response status. 208 | */ 209 | const resolveStatusHandler = (config: Config, code: number): StatusHandler | undefined => ({ 210 | 401: config.onUnauthorized, 211 | 403: config.onForbidden, 212 | 404: config.onNotFound, 213 | 409: config.onConflict, 214 | 422: config.onValidationError, 215 | 423: config.onLocked, 216 | }[code]) 217 | 218 | /** 219 | * Resolve the request's "Content-Type" header. 220 | */ 221 | const resolveContentType = (config: Config): string => config.headers?.['Content-Type'] 222 | ?? config.headers?.['Content-type'] 223 | ?? config.headers?.['content-type'] 224 | ?? (hasFiles(config.data) ? 'multipart/form-data' : 'application/json') 225 | 226 | /** 227 | * Determine if the payload has any files. 228 | * 229 | * @see https://github.com/inertiajs/inertia/blob/master/packages/core/src/files.ts 230 | */ 231 | const hasFiles = (data: unknown): boolean => isFile(data) 232 | || (typeof data === 'object' && data !== null && Object.values(data).some((value) => hasFiles(value))) 233 | 234 | /** 235 | * Determine if the value is a file. 236 | */ 237 | export const isFile = (value: unknown): boolean => (typeof File !== 'undefined' && value instanceof File) 238 | || value instanceof Blob 239 | || (typeof FileList !== 'undefined' && value instanceof FileList && value.length > 0) 240 | 241 | /** 242 | * Resolve the url from a potential callback. 243 | */ 244 | export const resolveUrl = (url: string | (() => string)): string => typeof url === 'string' 245 | ? url 246 | : url() 247 | 248 | /** 249 | * Resolve the method from a potential callback. 250 | */ 251 | export const resolveMethod = (method: RequestMethod | (() => RequestMethod)): RequestMethod => typeof method === 'string' 252 | ? method.toLowerCase() as RequestMethod 253 | : method() 254 | -------------------------------------------------------------------------------- /packages/core/src/validator.ts: -------------------------------------------------------------------------------- 1 | import { debounce, isEqual, get, set, omit, merge } from 'lodash-es' 2 | import { ValidationCallback, Config, NamedInputEvent, SimpleValidationErrors, ValidationErrors, Validator as TValidator, ValidatorListeners, ValidationConfig } from './types.js' 3 | import { client, isFile } from './client.js' 4 | import { isAxiosError, isCancel, mergeConfig } from 'axios' 5 | 6 | export const createValidator = (callback: ValidationCallback, initialData: Record = {}): TValidator => { 7 | /** 8 | * Event listener state. 9 | */ 10 | const listeners: ValidatorListeners = { 11 | errorsChanged: [], 12 | touchedChanged: [], 13 | validatingChanged: [], 14 | validatedChanged: [], 15 | } 16 | 17 | /** 18 | * Validate files state. 19 | */ 20 | let validateFiles = false 21 | 22 | /** 23 | * Processing validation state. 24 | */ 25 | let validating = false 26 | 27 | /** 28 | * Set the validating inputs. 29 | * 30 | * Returns an array of listeners that should be invoked once all state 31 | * changes have taken place. 32 | */ 33 | const setValidating = (value: boolean): (() => void)[] => { 34 | if (value !== validating) { 35 | validating = value 36 | 37 | return listeners.validatingChanged 38 | } 39 | 40 | return [] 41 | } 42 | 43 | /** 44 | * Inputs that have been validated. 45 | */ 46 | let validated: Array = [] 47 | 48 | /** 49 | * Set the validated inputs. 50 | * 51 | * Returns an array of listeners that should be invoked once all state 52 | * changes have taken place. 53 | */ 54 | const setValidated = (value: Array): (() => void)[] => { 55 | const uniqueNames = [...new Set(value)] 56 | 57 | if (validated.length !== uniqueNames.length || ! uniqueNames.every((name) => validated.includes(name))) { 58 | validated = uniqueNames 59 | 60 | return listeners.validatedChanged 61 | } 62 | 63 | return [] 64 | } 65 | 66 | /** 67 | * Valid validation state. 68 | */ 69 | const valid = () => validated.filter((name) => typeof errors[name] === 'undefined') 70 | 71 | /** 72 | * Touched input state. 73 | */ 74 | let touched: Array = [] 75 | 76 | /** 77 | * Set the touched inputs. 78 | * 79 | * Returns an array of listeners that should be invoked once all state 80 | * changes have taken place. 81 | */ 82 | const setTouched = (value: Array): (() => void)[] => { 83 | const uniqueNames = [...new Set(value)] 84 | 85 | if (touched.length !== uniqueNames.length || ! uniqueNames.every((name) => touched.includes(name))) { 86 | touched = uniqueNames 87 | 88 | return listeners.touchedChanged 89 | } 90 | 91 | return [] 92 | } 93 | 94 | /** 95 | * Validation errors state. 96 | */ 97 | let errors: ValidationErrors = {} 98 | 99 | /** 100 | * Set the input errors. 101 | * 102 | * Returns an array of listeners that should be invoked once all state 103 | * changes have taken place. 104 | */ 105 | const setErrors = (value: ValidationErrors | SimpleValidationErrors): (() => void)[] => { 106 | const prepared = toValidationErrors(value) 107 | 108 | if (! isEqual(errors, prepared)) { 109 | errors = prepared 110 | 111 | return listeners.errorsChanged 112 | } 113 | 114 | return [] 115 | } 116 | 117 | /** 118 | * Forget the given input's errors. 119 | * 120 | * Returns an array of listeners that should be invoked once all state 121 | * changes have taken place. 122 | */ 123 | const forgetError = (name: string | NamedInputEvent): (() => void)[] => { 124 | const newErrors = { ...errors } 125 | 126 | delete newErrors[resolveName(name)] 127 | 128 | return setErrors(newErrors) 129 | } 130 | 131 | /** 132 | * Has errors state. 133 | */ 134 | const hasErrors = () => Object.keys(errors).length > 0 135 | 136 | /** 137 | * Debouncing timeout state. 138 | */ 139 | let debounceTimeoutDuration = 1500 140 | 141 | const setDebounceTimeout = (value: number) => { 142 | debounceTimeoutDuration = value 143 | 144 | validator.cancel() 145 | 146 | validator = createValidator() 147 | } 148 | 149 | /** 150 | * The old data. 151 | */ 152 | let oldData = initialData 153 | 154 | /** 155 | * The data currently being validated. 156 | */ 157 | let validatingData: null | Record = null 158 | 159 | /** 160 | * The old touched. 161 | */ 162 | let oldTouched: string[] = [] 163 | 164 | /** 165 | * The touched currently being validated. 166 | */ 167 | let validatingTouched: null | string[] = null 168 | 169 | /** 170 | * Create a debounced validation callback. 171 | */ 172 | const createValidator = () => debounce((instanceConfig: ValidationConfig) => { 173 | callback({ 174 | get: (url, data = {}, globalConfig = {}) => client.get(url, parseData(data), resolveConfig(globalConfig, instanceConfig, data)), 175 | post: (url, data = {}, globalConfig = {}) => client.post(url, parseData(data), resolveConfig(globalConfig, instanceConfig, data)), 176 | patch: (url, data = {}, globalConfig = {}) => client.patch(url, parseData(data), resolveConfig(globalConfig, instanceConfig, data)), 177 | put: (url, data = {}, globalConfig = {}) => client.put(url, parseData(data), resolveConfig(globalConfig, instanceConfig, data)), 178 | delete: (url, data = {}, globalConfig = {}) => client.delete(url, parseData(data), resolveConfig(globalConfig, instanceConfig, data)), 179 | }).catch((error) => { 180 | // Precognition can often cancel in-flight requests. Instead of 181 | // throwing an exception for this expected behaviour, we silently 182 | // discard cancelled request errors to not flood the console with 183 | // expected errors. 184 | if (isCancel(error)) { 185 | return null 186 | } 187 | 188 | // Unlike other status codes, 422 responses are expected and 189 | // regularly occur with Precognition requests. We silently ignore 190 | // these so we do not flood the console with expected errors. If 191 | // needed, they can be intercepted by the `onValidationError` 192 | // config option instead. 193 | if (isAxiosError(error) && error.response?.status === 422) { 194 | return null 195 | } 196 | 197 | return Promise.reject(error) 198 | }) 199 | }, debounceTimeoutDuration, { leading: true, trailing: true }) 200 | 201 | /** 202 | * Validator state. 203 | */ 204 | let validator = createValidator() 205 | 206 | /** 207 | * Resolve the configuration. 208 | */ 209 | const resolveConfig = ( 210 | globalConfig: ValidationConfig, 211 | instanceConfig: ValidationConfig, 212 | data: Record = {}, 213 | ): Config => { 214 | const config: ValidationConfig = { 215 | ...globalConfig, 216 | ...instanceConfig, 217 | } 218 | 219 | const only = Array.from(config.only ?? config.validate ?? touched) 220 | 221 | return { 222 | ...instanceConfig, 223 | // Axios has special rules for merging global and local config. We 224 | // use their merge function here to make sure things like headers 225 | // merge in an expected way. 226 | ...mergeConfig(globalConfig, instanceConfig), 227 | only, 228 | timeout: config.timeout ?? 5000, 229 | onValidationError: (response, axiosError) => { 230 | [ 231 | ...setValidated([...validated, ...only]), 232 | ...setErrors(merge(omit({ ...errors }, only), response.data.errors)), 233 | ].forEach((listener) => listener()) 234 | 235 | return config.onValidationError 236 | ? config.onValidationError(response, axiosError) 237 | : Promise.reject(axiosError) 238 | }, 239 | onSuccess: (response) => { 240 | setValidated([...validated, ...only]).forEach((listener) => listener()) 241 | 242 | return config.onSuccess 243 | ? config.onSuccess(response) 244 | : response 245 | }, 246 | onPrecognitionSuccess: (response) => { 247 | [ 248 | ...setValidated([...validated, ...only]), 249 | ...setErrors(omit({ ...errors }, only)), 250 | ].forEach((listener) => listener()) 251 | 252 | return config.onPrecognitionSuccess 253 | ? config.onPrecognitionSuccess(response) 254 | : response 255 | }, 256 | onBefore: () => { 257 | if (config.onBeforeValidation && config.onBeforeValidation({ data, touched }, { data: oldData, touched: oldTouched }) === false) { 258 | return false 259 | } 260 | 261 | const beforeResult = (config.onBefore || (() => true))() 262 | 263 | if (beforeResult === false) { 264 | return false 265 | } 266 | 267 | validatingTouched = touched 268 | 269 | validatingData = data 270 | 271 | return true 272 | }, 273 | onStart: () => { 274 | setValidating(true).forEach((listener) => listener()); 275 | 276 | (config.onStart ?? (() => null))() 277 | }, 278 | onFinish: () => { 279 | setValidating(false).forEach((listener) => listener()) 280 | 281 | oldTouched = validatingTouched! 282 | 283 | oldData = validatingData! 284 | 285 | validatingTouched = validatingData = null; 286 | 287 | (config.onFinish ?? (() => null))() 288 | }, 289 | } 290 | } 291 | 292 | /** 293 | * Validate the given input. 294 | */ 295 | const validate = (name?: string | NamedInputEvent, value?: unknown, config?: ValidationConfig): void => { 296 | if (typeof name === 'undefined') { 297 | const only = Array.from(config?.only ?? config?.validate ?? []) 298 | 299 | setTouched([...touched, ...only]).forEach((listener) => listener()) 300 | 301 | validator(config ?? {}) 302 | 303 | return 304 | } 305 | 306 | if (isFile(value) && !validateFiles) { 307 | console.warn('Precognition file validation is not active. Call the "validateFiles" function on your form to enable it.') 308 | 309 | return 310 | } 311 | 312 | name = resolveName(name) 313 | 314 | if (get(oldData, name) !== value) { 315 | setTouched([name, ...touched]).forEach((listener) => listener()) 316 | 317 | validator(config ?? {}) 318 | } 319 | } 320 | 321 | /** 322 | * Parse the validated data. 323 | */ 324 | const parseData = (data: Record): Record => validateFiles === false 325 | ? forgetFiles(data) 326 | : data 327 | 328 | /** 329 | * The form validator instance. 330 | */ 331 | const form: TValidator = { 332 | touched: () => touched, 333 | validate(name, value, config) { 334 | if (typeof name === 'object' && ! ('target' in name)) { 335 | config = name 336 | name = value = undefined 337 | } 338 | 339 | validate(name, value, config) 340 | 341 | return form 342 | }, 343 | touch(input) { 344 | const inputs = Array.isArray(input) 345 | ? input 346 | : [resolveName(input)] 347 | 348 | setTouched([...touched, ...inputs]).forEach((listener) => listener()) 349 | 350 | return form 351 | }, 352 | validating: () => validating, 353 | valid, 354 | errors: () => errors, 355 | hasErrors, 356 | setErrors(value) { 357 | setErrors(value).forEach((listener) => listener()) 358 | 359 | return form 360 | }, 361 | forgetError(name) { 362 | forgetError(name).forEach((listener) => listener()) 363 | 364 | return form 365 | }, 366 | defaults(data) { 367 | initialData = data 368 | oldData = data 369 | 370 | return form 371 | }, 372 | reset(...names) { 373 | if (names.length === 0) { 374 | setTouched([]).forEach((listener) => listener()) 375 | } else { 376 | const newTouched = [...touched] 377 | 378 | names.forEach((name) => { 379 | if (newTouched.includes(name)) { 380 | newTouched.splice(newTouched.indexOf(name), 1) 381 | } 382 | 383 | set(oldData, name, get(initialData, name)) 384 | }) 385 | 386 | setTouched(newTouched).forEach((listener) => listener()) 387 | } 388 | 389 | return form 390 | }, 391 | setTimeout(value) { 392 | setDebounceTimeout(value) 393 | 394 | return form 395 | }, 396 | on(event, callback) { 397 | listeners[event].push(callback) 398 | 399 | return form 400 | }, 401 | validateFiles() { 402 | validateFiles = true 403 | 404 | return form 405 | }, 406 | withoutFileValidation() { 407 | validateFiles = false 408 | 409 | return form 410 | }, 411 | } 412 | 413 | return form 414 | } 415 | 416 | /** 417 | * Normalise the validation errors as Inertia formatted errors. 418 | */ 419 | export const toSimpleValidationErrors = (errors: ValidationErrors | SimpleValidationErrors): SimpleValidationErrors => { 420 | return Object.keys(errors).reduce((carry, key) => ({ 421 | ...carry, 422 | [key]: Array.isArray(errors[key]) 423 | ? errors[key][0] 424 | : errors[key], 425 | }), {}) 426 | } 427 | 428 | /** 429 | * Normalise the validation errors as Laravel formatted errors. 430 | */ 431 | export const toValidationErrors = (errors: ValidationErrors | SimpleValidationErrors): ValidationErrors => { 432 | return Object.keys(errors).reduce((carry, key) => ({ 433 | ...carry, 434 | [key]: typeof errors[key] === 'string' ? [errors[key]] : errors[key], 435 | }), {}) 436 | } 437 | 438 | /** 439 | * Resolve the input's "name" attribute. 440 | */ 441 | export const resolveName = (name: string | NamedInputEvent): string => { 442 | return typeof name !== 'string' 443 | ? name.target.name 444 | : name 445 | } 446 | 447 | /** 448 | * Forget any files from the payload. 449 | */ 450 | const forgetFiles = (data: Record): Record => { 451 | const newData = { ...data } 452 | 453 | Object.keys(newData).forEach((name) => { 454 | const value = newData[name] 455 | 456 | if (value === null) { 457 | return 458 | } 459 | 460 | if (isFile(value)) { 461 | delete newData[name] 462 | 463 | return 464 | } 465 | 466 | if (Array.isArray(value)) { 467 | newData[name] = Object.values(forgetFiles({ ...value })) 468 | 469 | return 470 | } 471 | 472 | if (typeof value === 'object') { 473 | // @ts-expect-error 474 | newData[name] = forgetFiles(newData[name]) 475 | 476 | return 477 | } 478 | }) 479 | 480 | return newData 481 | } 482 | -------------------------------------------------------------------------------- /packages/core/tests/client.test.js: -------------------------------------------------------------------------------- 1 | import { it, vi, expect, beforeEach, afterEach } from 'vitest' 2 | import axios from 'axios' 3 | import { client } from '../src/index' 4 | 5 | beforeEach(() => { 6 | vi.mock('axios') 7 | client.use(axios) 8 | vi.useFakeTimers() 9 | }) 10 | 11 | afterEach(() => { 12 | vi.restoreAllMocks() 13 | vi.runAllTimers() 14 | }) 15 | 16 | it('can handle a successful precognition response via config handler', async () => { 17 | expect.assertions(2) 18 | 19 | const response = { headers: { precognition: 'true', 'precognition-success': 'true' }, status: 204, data: 'data' } 20 | axios.request.mockResolvedValueOnce(response) 21 | 22 | await client.get('https://laravel.com', {}, { 23 | onPrecognitionSuccess: (r) => { 24 | expect(r).toBe(response) 25 | 26 | return 'expected value' 27 | }, 28 | }).then((value) => expect(value).toBe('expected value')) 29 | }) 30 | 31 | it('can handle a success response via a fulfilled promise', async () => { 32 | expect.assertions(1) 33 | 34 | const response = { headers: { precognition: 'true', 'precognition-success': 'true' }, status: 204, data: 'data' } 35 | axios.request.mockResolvedValueOnce(response) 36 | 37 | await client.post('https://laravel.com').then((r) => expect(r).toBe(response)) 38 | }) 39 | 40 | it('can handle a validation response via a config handler', async () => { 41 | expect.assertions(3) 42 | 43 | const error = { 44 | response: { 45 | headers: { precognition: 'true' }, 46 | status: 422, 47 | data: { 48 | message: 'expected message', 49 | errors: { name: ['expected error'] }, 50 | }, 51 | }, 52 | } 53 | axios.request.mockRejectedValueOnce(error) 54 | axios.isAxiosError.mockReturnValueOnce(true) 55 | 56 | await client.patch('https://laravel.com', {}, { 57 | onValidationError: (p, e) => { 58 | expect(p).toBe(error.response) 59 | expect(e).toBe(error) 60 | 61 | return 'expected value' 62 | }, 63 | }).then((value) => expect(value).toBe('expected value')) 64 | }) 65 | 66 | it('can handle an unauthorized response via a config handler', async () => { 67 | expect.assertions(3) 68 | 69 | const error = { 70 | response: { 71 | headers: { precognition: 'true' }, 72 | status: 401, 73 | data: 'expected data', 74 | }, 75 | } 76 | axios.request.mockRejectedValueOnce(error) 77 | axios.isAxiosError.mockReturnValueOnce(true) 78 | 79 | await client.delete('https://laravel.com', {}, { 80 | onUnauthorized: (p, e) => { 81 | expect(p).toBe(error.response) 82 | expect(e).toBe(error) 83 | 84 | return 'expected value' 85 | }, 86 | }).then((value) => expect(value).toBe('expected value')) 87 | }) 88 | 89 | it('can handle a forbidden response via a config handler', async () => { 90 | expect.assertions(3) 91 | 92 | const error = { 93 | response: { 94 | headers: { precognition: 'true' }, 95 | status: 403, 96 | data: 'expected data', 97 | }, 98 | } 99 | axios.request.mockRejectedValueOnce(error) 100 | axios.isAxiosError.mockReturnValueOnce(true) 101 | 102 | await client.put('https://laravel.com', {}, { 103 | onForbidden: (p, e) => { 104 | expect(p).toBe(error.response) 105 | expect(e).toBe(error) 106 | 107 | return 'expected value' 108 | }, 109 | }).then((value) => expect(value).toBe('expected value')) 110 | }) 111 | 112 | it('can handle a not found response via a config handler', async () => { 113 | expect.assertions(3) 114 | 115 | const error = { 116 | response: { 117 | headers: { precognition: 'true' }, 118 | status: 404, 119 | data: 'data', 120 | }, 121 | } 122 | axios.request.mockRejectedValueOnce(error) 123 | axios.isAxiosError.mockReturnValueOnce(true) 124 | 125 | await client.get('https://laravel.com', {}, { 126 | onNotFound: (p, e) => { 127 | expect(p).toBe(error.response) 128 | expect(e).toBe(error) 129 | 130 | return 'expected value' 131 | }, 132 | }).then((value) => expect(value).toBe('expected value')) 133 | }) 134 | 135 | it('can handle a conflict response via a config handler', async () => { 136 | expect.assertions(3) 137 | 138 | const error = { 139 | response: { 140 | headers: { precognition: 'true' }, 141 | status: 409, 142 | data: 'expected data', 143 | }, 144 | } 145 | axios.request.mockRejectedValueOnce(error) 146 | axios.isAxiosError.mockReturnValueOnce(true) 147 | 148 | await client.get('https://laravel.com', {}, { 149 | onConflict: (p, e) => { 150 | expect(p).toBe(error.response) 151 | expect(e).toBe(error) 152 | 153 | return 'expected value' 154 | }, 155 | }).then((value) => expect(value).toBe('expected value')) 156 | }) 157 | 158 | it('can handle a locked response via a config handler', async () => { 159 | expect.assertions(3) 160 | 161 | const error = { 162 | response: { 163 | headers: { precognition: 'true' }, 164 | status: 423, 165 | data: 'expected data', 166 | }, 167 | } 168 | axios.request.mockRejectedValueOnce(error) 169 | axios.isAxiosError.mockReturnValueOnce(true) 170 | 171 | await client.get('https://laravel.com', {}, { 172 | onLocked: (p, e) => { 173 | expect(p).toBe(error.response) 174 | expect(e).toBe(error) 175 | 176 | return 'expected value' 177 | }, 178 | }).then((value) => expect(value).toBe('expected value')) 179 | }) 180 | 181 | it('can provide input names to validate via config', async () => { 182 | expect.assertions(1) 183 | 184 | let config 185 | axios.request.mockImplementationOnce((c) => { 186 | config = c 187 | return Promise.resolve({ headers: { precognition: 'true' } }) 188 | }) 189 | 190 | await client.get('https://laravel.com', {}, { 191 | only: ['username', 'email'], 192 | }) 193 | 194 | expect(config.headers['Precognition-Validate-Only']).toBe('username,email') 195 | }) 196 | 197 | it('continues to support the deprecated "validate" key as fallback of "only"', async () => { 198 | expect.assertions(1) 199 | 200 | let config 201 | axios.request.mockImplementationOnce((c) => { 202 | config = c 203 | return Promise.resolve({ headers: { precognition: 'true' } }) 204 | }) 205 | 206 | await client.get('https://laravel.com', {}, { 207 | validate: ['username', 'email'], 208 | }) 209 | 210 | expect(config.headers['Precognition-Validate-Only']).toBe('username,email') 211 | }) 212 | 213 | it('throws an error if the precognition header is not present on a success response', async () => { 214 | expect.assertions(2) 215 | 216 | axios.request.mockResolvedValueOnce({ headers: {}, status: 204 }) 217 | 218 | await client.get('https://laravel.com').catch((e) => { 219 | expect(e).toBeInstanceOf(Error) 220 | expect(e.message).toBe('Did not receive a Precognition response. Ensure you have the Precognition middleware in place for the route.') 221 | }) 222 | }) 223 | 224 | it('does not consider 204 response to be success without "Precognition-Success" header', async () => { 225 | expect.assertions(2) 226 | 227 | axios.request.mockResolvedValueOnce({ headers: { precognition: 'true' }, status: 204 }) 228 | let precognitionSucess = false 229 | let responseSuccess = false 230 | 231 | await client.get('https://laravel.com', {}, { 232 | onPrecognitionSuccess() { 233 | precognitionSucess = true 234 | }, 235 | onSuccess() { 236 | responseSuccess = true 237 | }, 238 | }) 239 | 240 | expect(precognitionSucess).toBe(false) 241 | expect(responseSuccess).toBe(true) 242 | }) 243 | 244 | it('throws an error if the precognition header is not present on an error response', async () => { 245 | expect.assertions(2) 246 | 247 | axios.request.mockRejectedValueOnce({ response: { status: 500 } }) 248 | axios.isAxiosError.mockReturnValueOnce(true) 249 | 250 | await client.get('https://laravel.com').catch((e) => { 251 | expect(e).toBeInstanceOf(Error) 252 | expect(e.message).toBe('Did not receive a Precognition response. Ensure you have the Precognition middleware in place for the route.') 253 | }) 254 | }) 255 | 256 | it('returns a non-axios error via a rejected promise', async () => { 257 | expect.assertions(1) 258 | 259 | const error = { expected: 'error' } 260 | axios.request.mockRejectedValueOnce(error) 261 | axios.isAxiosError.mockReturnValueOnce(false) 262 | 263 | await client.get('https://laravel.com').catch((e) => { 264 | expect(e).toBe(error) 265 | }) 266 | }) 267 | 268 | it('returns a cancelled request error va rejected promise', async () => { 269 | expect.assertions(1) 270 | 271 | const error = { expected: 'error' } 272 | axios.request.mockRejectedValueOnce(error) 273 | axios.isAxiosError.mockReturnValueOnce(true) 274 | axios.isCancel.mockReturnValueOnce(true) 275 | 276 | await client.get('https://laravel.com').catch((e) => { 277 | expect(e).toBe(error) 278 | }) 279 | }) 280 | 281 | it('an axerror without a "status" property returns a rejected promise', async () => { 282 | expect.assertions(1) 283 | 284 | const error = { expected: 'error' } 285 | axios.request.mockRejectedValueOnce(error) 286 | axios.isAxiosError.mockReturnValueOnce(true) 287 | 288 | await client.get('https://laravel.com').catch((e) => { 289 | expect(e).toBe(error) 290 | }) 291 | }) 292 | 293 | it('can handle error responses via a rejected promise', async () => { 294 | expect.assertions(1) 295 | 296 | const error = { 297 | response: { 298 | headers: { precognition: 'true' }, 299 | status: 422, 300 | data: { 301 | message: 'expected message', 302 | errors: { name: ['expected error'] }, 303 | }, 304 | }, 305 | } 306 | axios.request.mockRejectedValueOnce(error) 307 | axios.isAxiosError.mockReturnValueOnce(true) 308 | 309 | await client.get('https://laravel.com').catch((e) => expect(e).toBe(error)) 310 | }) 311 | 312 | it('can customize how it determines a successful precognition response', async () => { 313 | expect.assertions(3) 314 | 315 | let response = { headers: { precognition: 'true' }, status: 999, data: 'data' } 316 | axios.request.mockResolvedValueOnce(response) 317 | 318 | client.determineSuccessUsing((response) => response.status === 999) 319 | 320 | await client.get('https://laravel.com', {}, { 321 | onPrecognitionSuccess: (r) => { 322 | expect(r).toBe(response) 323 | 324 | return 'expected value' 325 | }, 326 | }).then((value) => expect(value).toBe('expected value')) 327 | 328 | response = { headers: { precognition: 'true', 'precognition-success': 'true' }, status: 204, data: 'data' } 329 | axios.request.mockResolvedValueOnce(response) 330 | 331 | await client.get('https://laravel.com', {}, { 332 | onPrecognitionSuccess: () => { 333 | return 'xxxx' 334 | }, 335 | }).then((value) => expect(value).toBe(response)) 336 | }) 337 | 338 | it('creates a request fingerprint and an abort signal if none are configured', async () => { 339 | expect.assertions(2) 340 | 341 | let config 342 | axios.request.mockImplementationOnce((c) => { 343 | config = c 344 | return Promise.resolve({ headers: { precognition: 'true' } }) 345 | }) 346 | 347 | await client.get('https://laravel.com') 348 | 349 | expect(config.fingerprint).toBe('get:https://laravel.com') 350 | expect(config.signal).toBeInstanceOf(AbortSignal) 351 | }) 352 | 353 | it('uses the default axios baseURL in the request fingerprint', async () => { 354 | expect.assertions(2) 355 | 356 | let config 357 | axios.defaults.baseURL = 'https://laravel.com' 358 | axios.request.mockImplementationOnce((c) => { 359 | config = c 360 | return Promise.resolve({ headers: { precognition: 'true' } }) 361 | }) 362 | 363 | await client.get('/docs') 364 | 365 | expect(config.fingerprint).toBe('get:https://laravel.com/docs') 366 | expect(config.signal).toBeInstanceOf(AbortSignal) 367 | }) 368 | 369 | it('the confbaseURL takes precedence over the axios default baseURL for request id', async () => { 370 | expect.assertions(2) 371 | 372 | let config 373 | axios.defaults.baseURL = 'https://laravel.com' 374 | axios.request.mockImplementationOnce((c) => { 375 | config = c 376 | return Promise.resolve({ headers: { precognition: 'true' } }) 377 | }) 378 | 379 | await client.get('/docs', {}, { 380 | baseURL: 'https://forge.laravel.com', 381 | }) 382 | 383 | expect(config.fingerprint).toBe('get:https://forge.laravel.com/docs') 384 | expect(config.signal).toBeInstanceOf(AbortSignal) 385 | }) 386 | 387 | it('can specify the request fingerprint via config', async () => { 388 | expect.assertions(2) 389 | 390 | let config 391 | axios.request.mockImplementationOnce((c) => { 392 | config = c 393 | return Promise.resolve({ headers: { precognition: 'true' } }) 394 | }) 395 | 396 | await client.get('/docs', {}, { 397 | fingerprint: 'expected-id', 398 | }) 399 | 400 | expect(config.fingerprint).toBe('expected-id') 401 | expect(config.signal).toBeInstanceOf(AbortSignal) 402 | }) 403 | 404 | it('can customize how the request fingerprint is created', async () => { 405 | expect.assertions(2) 406 | 407 | let config 408 | axios.request.mockImplementationOnce((c) => { 409 | config = c 410 | return Promise.resolve({ headers: { precognition: 'true' } }) 411 | }) 412 | client.fingerprintRequestsUsing(() => 'expected-id') 413 | 414 | await client.get('/docs') 415 | 416 | expect(config.fingerprint).toBe('expected-id') 417 | expect(config.signal).toBeInstanceOf(AbortSignal) 418 | }) 419 | 420 | it('the conffingerprint takes precedence over the global fingerprint for request id', async () => { 421 | expect.assertions(2) 422 | 423 | let config 424 | axios.request.mockImplementationOnce((c) => { 425 | config = c 426 | return Promise.resolve({ headers: { precognition: 'true' } }) 427 | }) 428 | client.fingerprintRequestsUsing(() => 'foo') 429 | 430 | await client.get('/docs', {}, { 431 | fingerprint: 'expected-id', 432 | }) 433 | 434 | expect(config.fingerprint).toBe('expected-id') 435 | expect(config.signal).toBeInstanceOf(AbortSignal) 436 | }) 437 | 438 | it('can opt out of automatic request aborting', async () => { 439 | expect.assertions(2) 440 | 441 | let config 442 | axios.request.mockImplementationOnce((c) => { 443 | config = c 444 | return Promise.resolve({ headers: { precognition: 'true' } }) 445 | }) 446 | 447 | await client.get('/docs', {}, { 448 | fingerprint: null, 449 | }) 450 | 451 | expect(config.fingerprint).toBe(null) 452 | expect(config.signal).toBeUndefined() 453 | }) 454 | 455 | it('can specify the abort controller via config', async () => { 456 | expect.assertions(1) 457 | 458 | let config 459 | axios.request.mockImplementationOnce((c) => { 460 | config = c 461 | return Promise.resolve({ headers: { precognition: 'true' } }) 462 | }) 463 | let called = false 464 | const controller = new AbortController 465 | controller.signal.addEventListener('foo', () => { 466 | called = true 467 | }) 468 | 469 | await client.get('/docs', {}, { 470 | signal: controller.signal, 471 | }) 472 | config.signal.dispatchEvent(new Event('foo')) 473 | 474 | expect(called).toBe(true) 475 | }) 476 | 477 | it('does not create an abort controller when a cancelToken is provided', async () => { 478 | expect.assertions(1) 479 | 480 | let config 481 | axios.request.mockImplementationOnce((c) => { 482 | config = c 483 | return Promise.resolve({ headers: { precognition: 'true' } }) 484 | }) 485 | 486 | await client.get('/docs', {}, { 487 | cancelToken: { /* ... */ }, 488 | }) 489 | 490 | expect(config.signal).toBeUndefined() 491 | }) 492 | 493 | it('overrides request method url with config url', async () => { 494 | expect.assertions(5) 495 | 496 | let config 497 | axios.request.mockImplementation((c) => { 498 | config = c 499 | return Promise.resolve({ headers: { precognition: 'true', 'precognition-success': 'true' }, status: 204, data: 'data' }) 500 | }) 501 | 502 | await client.get('https://laravel.com', {}, { 503 | url: 'https://forge.laravel.com', 504 | }) 505 | expect(config.url).toBe('https://forge.laravel.com') 506 | 507 | await client.post('https://laravel.com', {}, { 508 | url: 'https://forge.laravel.com', 509 | }) 510 | expect(config.url).toBe('https://forge.laravel.com') 511 | 512 | await client.patch('https://laravel.com', {}, { 513 | url: 'https://forge.laravel.com', 514 | }) 515 | expect(config.url).toBe('https://forge.laravel.com') 516 | 517 | await client.put('https://laravel.com', {}, { 518 | url: 'https://forge.laravel.com', 519 | }) 520 | expect(config.url).toBe('https://forge.laravel.com') 521 | 522 | await client.delete('https://laravel.com', {}, { 523 | url: 'https://forge.laravel.com', 524 | }) 525 | expect(config.url).toBe('https://forge.laravel.com') 526 | }) 527 | 528 | it('overrides the request data with the config data', async () => { 529 | expect.assertions(5) 530 | 531 | let config 532 | axios.request.mockImplementation((c) => { 533 | config = c 534 | return Promise.resolve({ headers: { precognition: 'true', 'precognition-success': 'true' }, status: 204, data: 'data' }) 535 | }) 536 | 537 | await client.get('https://laravel.com', { expected: false }, { 538 | data: { expected: true }, 539 | }) 540 | expect(config.data).toEqual({ expected: true }) 541 | 542 | await client.post('https://laravel.com', { expected: false }, { 543 | data: { expected: true }, 544 | }) 545 | expect(config.data).toEqual({ expected: true }) 546 | 547 | await client.patch('https://laravel.com', { expected: false }, { 548 | data: { expected: true }, 549 | }) 550 | expect(config.data).toEqual({ expected: true }) 551 | 552 | await client.put('https://laravel.com', { expected: false }, { 553 | data: { expected: true }, 554 | }) 555 | expect(config.data).toEqual({ expected: true }) 556 | 557 | await client.delete('https://laravel.com', { expected: false }, { 558 | data: { expected: true }, 559 | }) 560 | expect(config.data).toEqual({ expected: true }) 561 | }) 562 | 563 | it('merges request data with config data', async () => { 564 | expect.assertions(7) 565 | 566 | let config 567 | axios.request.mockImplementation((c) => { 568 | config = c 569 | return Promise.resolve({ headers: { precognition: 'true', 'precognition-success': 'true' }, status: 204, data: 'data' }) 570 | }) 571 | 572 | await client.get('https://laravel.com', { request: true }, { 573 | data: { config: true }, 574 | }) 575 | expect(config.data).toEqual({ config: true }) 576 | expect(config.params).toEqual({ request: true }) 577 | 578 | await client.post('https://laravel.com', { request: true }, { 579 | data: { config: true }, 580 | }) 581 | expect(config.data).toEqual({ request: true, config: true }) 582 | 583 | await client.patch('https://laravel.com', { request: true }, { 584 | data: { config: true }, 585 | }) 586 | expect(config.data).toEqual({ request: true, config: true }) 587 | 588 | await client.put('https://laravel.com', { request: true }, { 589 | data: { config: true }, 590 | }) 591 | expect(config.data).toEqual({ request: true, config: true }) 592 | 593 | await client.delete('https://laravel.com', { request: true }, { 594 | data: { config: true }, 595 | }) 596 | expect(config.data).toEqual({ config: true }) 597 | expect(config.params).toEqual({ request: true }) 598 | }) 599 | 600 | it('merges request data with config params for get and delete requests', async () => { 601 | expect.assertions(4) 602 | 603 | let config 604 | axios.request.mockImplementation((c) => { 605 | config = c 606 | return Promise.resolve({ headers: { precognition: 'true', 'precognition-success': 'true' }, status: 204, data: 'data' }) 607 | }) 608 | 609 | await client.get('https://laravel.com', { data: true }, { 610 | params: { param: true }, 611 | }) 612 | expect(config.params).toEqual({ data: true, param: true }) 613 | expect(config.data).toBeUndefined() 614 | 615 | await client.delete('https://laravel.com', { data: true }, { 616 | params: { param: true }, 617 | }) 618 | expect(config.params).toEqual({ data: true, param: true }) 619 | expect(config.data).toBeUndefined() 620 | }) 621 | -------------------------------------------------------------------------------- /packages/core/tests/validator.test.js: -------------------------------------------------------------------------------- 1 | import { it, vi, expect, beforeEach, afterEach } from 'vitest' 2 | import axios from 'axios' 3 | import { client } from '../src/client' 4 | import { createValidator } from '../src/validator' 5 | import { merge } from 'lodash-es' 6 | 7 | const precognitionSuccessResponse = (payload) => merge({ 8 | status: 204, 9 | data: {}, 10 | headers: { 11 | precognition: 'true', 12 | 'precognition-success': 'true', 13 | }, 14 | }, payload) 15 | 16 | const precognitionFailedResponse = (payload) => precognitionSuccessResponse(merge({ 17 | status: 422, 18 | data: { errors: {} }, 19 | headers: { 20 | 'precognition-success': '', 21 | }, 22 | }, payload)) 23 | 24 | const assertPendingValidateDebounceAndClear = async () => { 25 | const counters = [vi.getTimerCount()] 26 | await vi.advanceTimersByTimeAsync(1499) 27 | counters.push(vi.getTimerCount()) 28 | await vi.advanceTimersByTimeAsync(1) 29 | counters.push(vi.getTimerCount()) 30 | 31 | expect(counters).toStrictEqual([1, 1, 0]) 32 | } 33 | 34 | beforeEach(() => { 35 | vi.mock('axios', async () => { 36 | const axios = await vi.importActual('axios') 37 | 38 | const isAxiosError = (value) => typeof value === 'object' && typeof value.response === 'object' && value.response.status >= 400 39 | 40 | return { 41 | ...axios, 42 | isAxiosError, 43 | default: { 44 | ...axios.default, 45 | request: vi.fn(), 46 | isAxiosError, 47 | }, 48 | } 49 | }) 50 | 51 | vi.useFakeTimers() 52 | client.use(axios) 53 | }) 54 | 55 | afterEach(() => { 56 | vi.restoreAllMocks() 57 | 58 | if (vi.getTimerCount() > 0) { 59 | throw `There are ${vi.getTimerCount()} active timers` 60 | } 61 | }) 62 | 63 | it('revalidates data when validate is called', async () => { 64 | expect.assertions(4) 65 | 66 | let requests = 0 67 | axios.request.mockImplementation(() => { 68 | requests++ 69 | 70 | return Promise.resolve(precognitionSuccessResponse()) 71 | }) 72 | let data 73 | const validator = createValidator((client) => client.post('/foo', data)) 74 | 75 | expect(requests).toBe(0) 76 | 77 | data = { name: 'Tim' } 78 | validator.validate('name', 'Tim') 79 | expect(requests).toBe(1) 80 | await vi.advanceTimersByTimeAsync(1500) 81 | 82 | data = { name: 'Jess' } 83 | validator.validate('name', 'Jess') 84 | expect(requests).toBe(2) 85 | await vi.advanceTimersByTimeAsync(1500) 86 | 87 | data = { name: 'Taylor' } 88 | validator.validate('name', 'Taylor') 89 | expect(requests).toBe(3) 90 | await vi.advanceTimersByTimeAsync(1500) 91 | }) 92 | 93 | it('does not revalidate data when data is unchanged', async () => { 94 | expect.assertions(4) 95 | 96 | let requests = 0 97 | axios.request.mockImplementation(() => { 98 | requests++ 99 | 100 | return Promise.resolve(precognitionSuccessResponse()) 101 | }) 102 | let data = {} 103 | const validator = createValidator((client) => client.post('/foo', data)) 104 | 105 | expect(requests).toBe(0) 106 | 107 | data = { first: true } 108 | validator.validate('first', true) 109 | expect(requests).toBe(1) 110 | await vi.advanceTimersByTimeAsync(1500) 111 | 112 | data = { first: true } 113 | validator.validate('first', true) 114 | expect(requests).toBe(1) 115 | await vi.advanceTimersByTimeAsync(1500) 116 | 117 | data = { second: true } 118 | validator.validate('second', true) 119 | expect(requests).toBe(2) 120 | await vi.advanceTimersByTimeAsync(1500) 121 | }) 122 | 123 | it('accepts laravel formatted validation errors for setErrors', () => { 124 | expect.assertions(1) 125 | 126 | const validator = createValidator((client) => client.post('/foo', {}), { 127 | name: 'Tim', 128 | location: 'Melbourne', 129 | }) 130 | 131 | validator.setErrors({ 132 | name: ['xxxx'], 133 | location: ['xxxx', 'yyyy'], 134 | }) 135 | expect(validator.errors()).toEqual({ 136 | name: ['xxxx'], 137 | location: ['xxxx', 'yyyy'], 138 | }) 139 | }) 140 | 141 | it('accepts inertia formatted validation errors for setErrors', () => { 142 | expect.assertions(1) 143 | 144 | const validator = createValidator((client) => client.post('/foo', {}), { 145 | name: 'Tim', 146 | location: 'Melbourne', 147 | }) 148 | 149 | validator.setErrors({ 150 | name: 'xxxx', 151 | location: 'yyyy', 152 | }) 153 | expect(validator.errors()).toEqual({ 154 | name: ['xxxx'], 155 | location: ['yyyy'], 156 | }) 157 | }) 158 | 159 | it('triggers errorsChanged event when setting errors', () => { 160 | expect.assertions(2) 161 | 162 | const validator = createValidator((client) => client.post('/foo', {}), { 163 | name: 'Tim', 164 | }) 165 | let triggered = 0 166 | 167 | validator.on('errorsChanged', () => triggered++) 168 | 169 | validator.setErrors({ 170 | name: 'xxxx', 171 | }) 172 | expect(triggered).toEqual(1) 173 | 174 | validator.setErrors({ 175 | name: 'yyyy', 176 | }) 177 | expect(triggered).toEqual(2) 178 | }) 179 | 180 | it('doesnt trigger errorsChanged event when errors are the same', () => { 181 | expect.assertions(2) 182 | 183 | const validator = createValidator((client) => client.post('/foo', {}), { 184 | name: 'Tim', 185 | }) 186 | let triggered = 0 187 | 188 | validator.on('errorsChanged', () => triggered++) 189 | 190 | validator.setErrors({ 191 | name: 'xxxx', 192 | }) 193 | expect(triggered).toEqual(1) 194 | 195 | validator.setErrors({ 196 | name: 'xxxx', 197 | }) 198 | expect(triggered).toEqual(1) 199 | }) 200 | 201 | it('returns errors via hasErrors function', () => { 202 | expect.assertions(3) 203 | 204 | const validator = createValidator((client) => client.post('/foo', {}), { 205 | name: 'Tim', 206 | }) 207 | 208 | expect(validator.hasErrors()).toBe(false) 209 | 210 | validator.setErrors({ 211 | name: 'xxxx', 212 | }) 213 | expect(validator.hasErrors()).toBe(true) 214 | 215 | validator.setErrors({}) 216 | expect(validator.hasErrors()).toBe(false) 217 | }) 218 | 219 | it('is not valid before it has been validated', async () => { 220 | expect.assertions(2) 221 | 222 | const validator = createValidator((client) => client.post('/foo', {}), { 223 | name: 'Tim', 224 | }) 225 | 226 | expect(validator.valid()).toEqual([]) 227 | 228 | validator.setErrors({ 229 | name: 'xxxx', 230 | }) 231 | 232 | expect(validator.valid()).toEqual([]) 233 | }) 234 | 235 | it('does not validate if the field has not been changed', async () => { 236 | let requestMade = false 237 | axios.request.mockImplementation(() => { 238 | requestMade = true 239 | return Promise.resolve(precognitionSuccessResponse()) 240 | }) 241 | const validator = createValidator((client) => client.post('/foo', {}), { 242 | name: 'Tim', 243 | }) 244 | 245 | validator.validate('name', 'Tim') 246 | 247 | expect(requestMade).toBe(false) 248 | }) 249 | 250 | it('filters out files', async () => { 251 | let config 252 | axios.request.mockImplementationOnce((c) => { 253 | config = c 254 | return Promise.resolve(precognitionSuccessResponse()) 255 | }) 256 | const validator = createValidator((client) => client.post('/foo', { 257 | name: 'Tim', 258 | email: null, 259 | fruits: [ 260 | 'apple', 261 | 'banana', 262 | new Blob([], { type: 'image/png' }), 263 | ['nested', new Blob([], { type: 'image/png' })], 264 | { 265 | name: 'Tim', 266 | email: null, 267 | avatar: new Blob([], { type: 'image/png' }), 268 | }, 269 | ], 270 | avatar: new Blob([], { type: 'image/png' }), 271 | nested: { 272 | name: 'Tim', 273 | email: null, 274 | fruits: [ 275 | 'apple', 276 | 'banana', 277 | new Blob([], { type: 'image/png' }), 278 | ], 279 | avatar: new Blob([], { type: 'image/png' }), 280 | nested: { 281 | name: 'Tim', 282 | email: null, 283 | fruits: [ 284 | 'apple', 285 | 'banana', 286 | new Blob([], { type: 'image/png' }), 287 | ], 288 | avatar: new Blob([], { type: 'image/png' }), 289 | }, 290 | }, 291 | })) 292 | 293 | validator.validate('text', 'Tim') 294 | 295 | expect(config.data).toEqual({ 296 | name: 'Tim', 297 | email: null, 298 | fruits: [ 299 | 'apple', 300 | 'banana', 301 | ['nested'], 302 | { 303 | name: 'Tim', 304 | email: null, 305 | }, 306 | ], 307 | nested: { 308 | name: 'Tim', 309 | email: null, 310 | fruits: [ 311 | 'apple', 312 | 'banana', 313 | ], 314 | nested: { 315 | name: 'Tim', 316 | email: null, 317 | fruits: [ 318 | 'apple', 319 | 'banana', 320 | ], 321 | }, 322 | }, 323 | }) 324 | 325 | await assertPendingValidateDebounceAndClear() 326 | }) 327 | 328 | it('doesnt filter data when file validation is enabled', async () => { 329 | let config 330 | axios.request.mockImplementationOnce((c) => { 331 | config = c 332 | return Promise.resolve(precognitionSuccessResponse()) 333 | }) 334 | 335 | const data = { 336 | name: 'Tim', 337 | email: null, 338 | avatar: new Blob([], { type: 'image/png' }), 339 | } 340 | 341 | const validator = createValidator((client) => client.post('/foo', data)) 342 | 343 | validator.validateFiles() 344 | validator.validate('text', 'Tim') 345 | 346 | expect(config.data).toEqual(data) 347 | 348 | await assertPendingValidateDebounceAndClear() 349 | }) 350 | 351 | it('can disable file validation after enabling it', async () => { 352 | let config 353 | axios.request.mockImplementationOnce((c) => { 354 | config = c 355 | return Promise.resolve(precognitionSuccessResponse()) 356 | }) 357 | 358 | const validator = createValidator((client) => client.post('/foo', { 359 | name: 'Tim', 360 | email: null, 361 | avatar: new Blob([], { type: 'image/png' }), 362 | })) 363 | 364 | validator.validateFiles() 365 | validator.withoutFileValidation() 366 | validator.validate('text', 'Tim') 367 | 368 | expect(config.data).toEqual({ 369 | name: 'Tim', 370 | email: null, 371 | }) 372 | 373 | await assertPendingValidateDebounceAndClear() 374 | }) 375 | 376 | it('doesnt mark fields as validated while response is pending', async () => { 377 | expect.assertions(10) 378 | 379 | let resolver = null 380 | let rejector = null 381 | let onValidatedChangedCalledTimes = 0 382 | axios.request.mockImplementation(() => { 383 | return new Promise((resolve, reject) => { 384 | resolver = resolve 385 | rejector = (response) => reject({ response: response }) 386 | }) 387 | }) 388 | let data = {} 389 | const validator = createValidator((client) => client.post('/foo', data)) 390 | validator.on('validatedChanged', () => onValidatedChangedCalledTimes++) 391 | 392 | expect(validator.valid()).toEqual([]) 393 | expect(onValidatedChangedCalledTimes).toEqual(0) 394 | 395 | data = { app: 'Laravel' } 396 | expect(validator.valid()).toEqual([]) 397 | 398 | validator.validate('app', 'Laravel') 399 | expect(validator.valid()).toEqual([]) 400 | 401 | resolver(precognitionSuccessResponse()) 402 | await vi.advanceTimersByTimeAsync(1500) 403 | expect(validator.valid()).toEqual(['app']) 404 | expect(onValidatedChangedCalledTimes).toEqual(1) 405 | 406 | data = { app: 'Laravel', version: '10' } 407 | expect(validator.valid()).toEqual(['app']) 408 | 409 | validator.validate('version', '10') 410 | expect(validator.valid()).toEqual(['app']) 411 | 412 | rejector(precognitionFailedResponse()) 413 | await vi.advanceTimersByTimeAsync(1500) 414 | expect(validator.valid()).toEqual(['app', 'version']) 415 | expect(onValidatedChangedCalledTimes).toEqual(2) 416 | }) 417 | 418 | it('doesnt mark fields as validated on error status', async () => { 419 | expect.assertions(6) 420 | 421 | let rejector = null 422 | let onValidatedChangedCalledTimes = 0 423 | axios.request.mockImplementation(() => { 424 | return new Promise((_, reject) => { 425 | rejector = (response) => reject({ response: response }) 426 | }) 427 | }) 428 | let data = {} 429 | const validator = createValidator((client) => client.post('/foo', data)) 430 | validator.on('validatedChanged', () => onValidatedChangedCalledTimes++) 431 | 432 | expect(validator.valid()).toEqual([]) 433 | expect(onValidatedChangedCalledTimes).toEqual(0) 434 | 435 | data = { app: 'Laravel' } 436 | expect(validator.valid()).toEqual([]) 437 | 438 | validator.validate('app', 'Laravel', { 439 | onUnauthorized: () => null, 440 | }) 441 | expect(validator.valid()).toEqual([]) 442 | 443 | rejector(precognitionFailedResponse({ status: 401 })) 444 | await vi.advanceTimersByTimeAsync(1500) 445 | 446 | expect(validator.valid()).toEqual([]) 447 | expect(onValidatedChangedCalledTimes).toEqual(0) 448 | }) 449 | 450 | it('does mark fields as validated on success status', async () => { 451 | expect.assertions(6) 452 | 453 | let resolver = null 454 | let promise = null 455 | let onValidatedChangedCalledTimes = 0 456 | axios.request.mockImplementation(() => { 457 | promise = new Promise((resolve) => { 458 | resolver = resolve 459 | }) 460 | 461 | return promise 462 | }) 463 | let data = {} 464 | const validator = createValidator((client) => client.post('/foo', data)) 465 | validator.on('validatedChanged', () => onValidatedChangedCalledTimes++) 466 | 467 | expect(validator.valid()).toEqual([]) 468 | expect(onValidatedChangedCalledTimes).toEqual(0) 469 | 470 | data = { app: 'Laravel' } 471 | expect(validator.valid()).toEqual([]) 472 | 473 | validator.validate('app', 'Laravel') 474 | expect(validator.valid()).toEqual([]) 475 | 476 | resolver(precognitionSuccessResponse()) 477 | await vi.advanceTimersByTimeAsync(1500) 478 | expect(validator.valid()).toEqual(['app']) 479 | expect(onValidatedChangedCalledTimes).toEqual(1) 480 | }) 481 | 482 | it('can mark fields as touched', () => { 483 | const validator = createValidator((client) => client.post('/foo', {})) 484 | 485 | validator.touch('name') 486 | expect(validator.touched()).toEqual(['name']) 487 | 488 | validator.touch(['foo', 'bar']) 489 | expect(validator.touched()).toEqual(['name', 'foo', 'bar']) 490 | }) 491 | 492 | it('revalidates when touched changes', async () => { 493 | expect.assertions(1) 494 | 495 | let requests = 0 496 | const resolvers = [] 497 | const promises = [] 498 | const configs = [] 499 | axios.request.mockImplementation((c) => { 500 | requests++ 501 | configs.push(c) 502 | 503 | const promise = new Promise((resolve) => { 504 | resolvers.push(resolve) 505 | }) 506 | 507 | promises.push(promise) 508 | 509 | return promise 510 | }) 511 | let data = { version: '10' } 512 | const validator = createValidator((client) => client.post('/foo', data)) 513 | 514 | data = { app: 'Laravel' } 515 | validator.validate('app', 'Laravel') 516 | validator.touch('version') 517 | validator.validate('app', 'Laravel') 518 | await vi.advanceTimersByTimeAsync(1500) 519 | expect(requests).toBe(2) 520 | }) 521 | 522 | it('validates touched fields when calling validate without specifying any fields', async () => { 523 | expect.assertions(2) 524 | 525 | let config 526 | axios.request.mockImplementation((c) => { 527 | config = c 528 | return Promise.resolve(precognitionSuccessResponse()) 529 | }) 530 | const data = { name: 'Tim', framework: 'Laravel' } 531 | const validator = createValidator((client) => client.post('/foo', data)) 532 | 533 | validator.touch(['name', 'framework']).validate() 534 | expect(config.headers['Precognition-Validate-Only']).toBe('name,framework') 535 | 536 | await assertPendingValidateDebounceAndClear() 537 | }) 538 | 539 | it('marks fields as valid on precognition success', async () => { 540 | expect.assertions(5) 541 | 542 | let requests = 0 543 | axios.request.mockImplementation(() => { 544 | requests++ 545 | 546 | return Promise.resolve(precognitionSuccessResponse()) 547 | }) 548 | const validator = createValidator((client) => client.post('/foo', {})) 549 | let valid = null 550 | validator.setErrors({ name: 'Required' }).touch('name').on('errorsChanged', () => { 551 | valid = validator.valid() 552 | }) 553 | 554 | expect(validator.valid()).toStrictEqual([]) 555 | expect(valid).toBeNull() 556 | 557 | validator.validate() 558 | await vi.advanceTimersByTimeAsync(1500) 559 | 560 | expect(requests).toBe(1) 561 | expect(validator.valid()).toStrictEqual(['name']) 562 | expect(valid).toStrictEqual(['name']) 563 | }) 564 | 565 | it('calls locally configured onSuccess handler', async () => { 566 | let response = null 567 | axios.request.mockImplementation(async () => precognitionSuccessResponse({ data: 'response-data' })) 568 | const validator = createValidator((client) => client.post('/users', {})) 569 | 570 | validator.validate('name', 'Tim', { 571 | onSuccess: (r) => response = r, 572 | }) 573 | await vi.advanceTimersByTimeAsync(1500) 574 | 575 | expect(response.data).toBe('response-data') 576 | }) 577 | 578 | it('calls globally configured onSuccess handler', async () => { 579 | let response = null 580 | axios.request.mockImplementation(async () => precognitionSuccessResponse({ data: 'response-data' })) 581 | const validator = createValidator((client) => client.post('/users', {}, { 582 | onSuccess: (r) => response = r, 583 | })) 584 | 585 | validator.validate('name', 'Tim') 586 | await vi.advanceTimersByTimeAsync(1500) 587 | 588 | expect(response.data).toBe('response-data') 589 | }) 590 | 591 | it('local config overrides global config', async () => { 592 | let response 593 | axios.request.mockImplementation(async () => precognitionSuccessResponse({ data: 'response-data' })) 594 | const validator = createValidator((client) => client.post('/users', { name: 'Tim' }, { 595 | onPrecognitionSuccess: (r) => { 596 | r.data = r.data + ':global-handler' 597 | response = r 598 | }, 599 | })) 600 | validator.touch('name') 601 | 602 | validator.validate({ 603 | onPrecognitionSuccess: (r) => { 604 | r.data = r.data + ':local-handler' 605 | 606 | response = r 607 | }, 608 | }) 609 | await vi.advanceTimersByTimeAsync(1500) 610 | 611 | expect(response.data).toBe('response-data:local-handler') 612 | }) 613 | 614 | it('correctly merges axios config', async () => { 615 | let config = null 616 | axios.request.mockImplementation(async (c) => { 617 | config = c 618 | 619 | return precognitionSuccessResponse() 620 | }) 621 | const validator = createValidator((client) => client.post('/users', {}, { 622 | headers: { 623 | 'X-Global': '1', 624 | 'X-Both': ['global'], 625 | }, 626 | })) 627 | 628 | validator.validate('name', 'Tim', { 629 | headers: { 630 | 'X-Local': '1', 631 | 'X-Both': ['local'], 632 | }, 633 | }) 634 | await vi.advanceTimersByTimeAsync(1500) 635 | 636 | expect(config.headers).toEqual({ 637 | 'X-Global': '1', 638 | 'X-Local': '1', 639 | 'X-Both': ['local'], 640 | // others... 641 | 'Content-Type': 'application/json', 642 | Precognition: true, 643 | 'Precognition-Validate-Only': 'name', 644 | }) 645 | }) 646 | 647 | it('uses the lastest config values', async () => { 648 | const config = [] 649 | axios.request.mockImplementation(async (c) => { 650 | config.push(c) 651 | 652 | return precognitionSuccessResponse() 653 | }) 654 | let data = {} 655 | const validator = createValidator((client) => client.post('/users', data)) 656 | 657 | data = { name: 'Tim' } 658 | validator.validate('name', data.name, { 659 | headers: { 660 | 'X-Header': '1', 661 | }, 662 | }) 663 | data = { name: 'Jess' } 664 | validator.validate('name', data.name, { 665 | headers: { 666 | 'X-Header': '2', 667 | }, 668 | }) 669 | data = { name: 'Taylor' } 670 | validator.validate('name', data.name, { 671 | headers: { 672 | 'X-Header': '3', 673 | }, 674 | }) 675 | await vi.advanceTimersByTimeAsync(1500) 676 | 677 | expect(config).toHaveLength(2) 678 | expect(config[0].headers['X-Header']).toBe('1') 679 | expect(config[1].headers['X-Header']).toBe('3') 680 | }) 681 | 682 | it('does not cancel submit requests', async () => { 683 | const data = {} 684 | let submitConfig 685 | let validateConfig 686 | axios.request.mockImplementation((config) => { 687 | if (config.precognitive) { 688 | validateConfig = config 689 | } else { 690 | submitConfig = config 691 | } 692 | 693 | return Promise.resolve({ headers: { precognition: 'true', 'precognition-success': 'true' }, status: 204, data: '' }) 694 | }) 695 | const validator = createValidator((client) => client.post('/foo', data)) 696 | 697 | data.name = 'Tim' 698 | client.post('/foo', data, { precognitive: false }) 699 | validator.validate('name', 'Tim') 700 | 701 | expect(submitConfig.signal).toBeUndefined() 702 | expect(validateConfig.signal).toBeInstanceOf(AbortSignal) 703 | expect(validateConfig.signal.aborted).toBe(false) 704 | 705 | await assertPendingValidateDebounceAndClear() 706 | }) 707 | 708 | it('does not cancel submit requests with custom abort signal', async () => { 709 | const data = {} 710 | let submitConfig 711 | let validateConfig 712 | const abortController = new AbortController 713 | axios.request.mockImplementation((config) => { 714 | if (config.precognitive) { 715 | validateConfig = config 716 | } else { 717 | submitConfig = config 718 | } 719 | 720 | return Promise.resolve({ headers: { precognition: 'true', 'precognition-success': 'true' }, status: 204, data: '' }) 721 | }) 722 | const validator = createValidator((client) => client.post('/foo', data)) 723 | 724 | data.name = 'Tim' 725 | client.post('/foo', data, { precognitive: false, signal: abortController.signal }) 726 | validator.validate('name', 'Tim') 727 | 728 | expect(submitConfig.signal).toBe(abortController.signal) 729 | expect(submitConfig.signal.aborted).toBe(false) 730 | expect(validateConfig.signal).toBeInstanceOf(AbortSignal) 731 | expect(validateConfig.signal.aborted).toBe(false) 732 | 733 | await assertPendingValidateDebounceAndClear() 734 | }) 735 | 736 | it('supports async validate with only key for untouched values', async () => { 737 | let config 738 | axios.request.mockImplementation((c) => { 739 | config = c 740 | 741 | return Promise.resolve({ headers: { precognition: 'true', 'precognition-success': 'true' }, status: 204, data: '' }) 742 | }) 743 | const validator = createValidator((client) => client.post('/foo', {})) 744 | 745 | validator.validate({ 746 | only: ['name', 'email'], 747 | }) 748 | 749 | expect(config.headers['Precognition-Validate-Only']).toBe('name,email') 750 | 751 | await assertPendingValidateDebounceAndClear() 752 | }) 753 | 754 | it('supports async validate with depricated validate key for untouched values', async () => { 755 | let config 756 | axios.request.mockImplementation((c) => { 757 | config = c 758 | 759 | return Promise.resolve({ headers: { precognition: 'true', 'precognition-success': 'true' }, status: 204, data: '' }) 760 | }) 761 | const validator = createValidator((client) => client.post('/foo', {})) 762 | 763 | validator.validate({ 764 | validate: ['name', 'email'], 765 | }) 766 | 767 | expect(config.headers['Precognition-Validate-Only']).toBe('name,email') 768 | 769 | await assertPendingValidateDebounceAndClear() 770 | }) 771 | 772 | it('does not include already touched keys when specifying keys via only', async () => { 773 | let config 774 | axios.request.mockImplementation((c) => { 775 | config = c 776 | 777 | return Promise.resolve({ headers: { precognition: 'true', 'precognition-success': 'true' }, status: 204, data: '' }) 778 | }) 779 | const validator = createValidator((client) => client.post('/foo', {})) 780 | 781 | 782 | validator.touch(['email']).validate({ 783 | only: ['name'], 784 | }) 785 | 786 | expect(config.headers['Precognition-Validate-Only']).toBe('name') 787 | 788 | await assertPendingValidateDebounceAndClear() 789 | }) 790 | 791 | it('marks fields as touched when the input has been included in validation', async () => { 792 | axios.request.mockImplementation(() => { 793 | return Promise.resolve({ headers: { precognition: 'true', 'precognition-success': 'true' }, status: 204, data: '' }) 794 | }) 795 | const validator = createValidator((client) => client.post('/foo', {})) 796 | 797 | 798 | validator.touch(['email']).validate({ 799 | only: ['name'], 800 | }) 801 | 802 | expect(validator.touched()).toEqual(['email', 'name']) 803 | 804 | await assertPendingValidateDebounceAndClear() 805 | }) 806 | 807 | it('can override the old data via the defaults function', () => { 808 | let requests = 0 809 | axios.request.mockImplementation(() => { 810 | requests++ 811 | 812 | return Promise.resolve(precognitionSuccessResponse()) 813 | }) 814 | 815 | const validator = createValidator((client) => client.post('/foo', {}), { 816 | name: 'Tim', 817 | }) 818 | 819 | expect(validator.defaults({ 820 | name: 'Jess', 821 | })).toBe(validator) 822 | 823 | validator.validate('name', 'Jess') 824 | expect(requests).toBe(0) 825 | }) 826 | 827 | it('can override the initial data via the defaults function', async () => { 828 | expect.assertions(2) 829 | let requests = 0 830 | axios.request.mockImplementation(() => { 831 | requests++ 832 | 833 | return Promise.resolve(precognitionSuccessResponse()) 834 | }) 835 | 836 | const validator = createValidator((client) => client.post('/foo', {}), { 837 | name: 'Tim', 838 | }).defaults({ 839 | name: 'Jess', 840 | }) 841 | 842 | validator.validate('name', 'Taylor') 843 | expect(requests).toBe(1) 844 | 845 | await vi.advanceTimersByTimeAsync(1500) 846 | 847 | validator.reset('name') 848 | validator.validate('name', 'Jess') 849 | expect(requests).toBe(1) 850 | }) 851 | --------------------------------------------------------------------------------