├── .editorconfig ├── .gitattributes ├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── BUG_REPORT.md │ ├── FEATURE_REQUEST.md │ └── config.yml ├── PULL_REQUEST_TEMPLATE.md ├── SECURITY.md ├── SUPPORT.md └── workflows │ ├── lint.yml │ ├── publish.yml │ ├── test.yml │ └── update-changelog.yml ├── .gitignore ├── .husky └── pre-commit ├── .npmignore ├── .npmrc ├── .nvmrc ├── .prettierrc.cjs ├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets ├── Banner.png └── Social.png ├── eslint.config.js ├── package-lock.json ├── package.json ├── src ├── enum │ ├── form.ts │ ├── method.ts │ └── reserved-field-names.ts ├── form.ts ├── index.ts ├── timeout-manager.ts ├── types │ ├── error.ts │ ├── form-data-convertible.ts │ ├── form-data.ts │ ├── form-options.ts │ ├── form.ts │ ├── method.ts │ ├── progress.ts │ ├── request-payload.ts │ └── validation.ts ├── use-form.ts └── utils │ ├── deep-clone.ts │ ├── error-formatter.ts │ ├── field-name-validator.ts │ ├── file.ts │ ├── form-data.ts │ ├── form-proxy.ts │ ├── http-helpers.ts │ ├── object-helpers.ts │ ├── progress-tracker.ts │ └── timeout.ts ├── tests ├── field-name-validator.test.ts ├── file.test.ts ├── form-data.test.ts ├── form.test.ts └── use-form.test.ts ├── tsconfig.json ├── vite.config.ts └── vitest.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | 11 | # Matches multiple files with brace expansion notation 12 | # Set default charset 13 | [*.{js,ts,tsx}] 14 | charset = utf-8 15 | 16 | # 2 space indentation for JavaScript and TypeScript files 17 | [*.{js,ts,tsx}] 18 | indent_style = space 19 | indent_size = 2 20 | 21 | # Tab indentation (no size specified) for Makefile 22 | [Makefile] 23 | indent_style = tab 24 | 25 | # Indentation override for all JS under lib directory 26 | [lib/**.js] 27 | indent_style = space 28 | indent_size = 2 29 | 30 | # Matches the exact files either package.json or .travis.yml 31 | [{package.json,.travis.yml}] 32 | indent_style = space 33 | indent_size = 2 34 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.lockb binary diff=lockb 3 | 4 | # TypeScript files should be treated as text 5 | *.ts text 6 | 7 | # TypeScript declaration files should also be treated as text 8 | *.d.ts text 9 | 10 | # JavaScript files generated from TypeScript should be treated as text 11 | *.js text 12 | 13 | # JSON files should be treated as text 14 | *.json text 15 | 16 | # TypeScript source map files should be treated as binary 17 | *.map binary 18 | 19 | # Configuration files 20 | tsconfig.json text 21 | jest.config.js text 22 | 23 | # Tests should be treated as text 24 | __tests__/**/* text 25 | tests/**/* text 26 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | 3 | ### Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and 6 | our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, level of experience, nationality, personal appearance, race and religion. 7 | 8 | ### Our Standards 9 | 10 | Examples of behavior that contributes to creating a positive environment 11 | include: 12 | 13 | - Using welcoming and inclusive language 14 | - Being respectful of differing viewpoints and experiences 15 | - Gracefully accepting constructive criticism 16 | - Focusing on what is best for the community 17 | - Showing empathy towards other community members 18 | 19 | Examples of unacceptable behavior by participants include: 20 | 21 | - The use of sexualized language or imagery and unwelcome sexual attention or 22 | advances 23 | - Trolling, insulting/derogatory comments, and personal or political attacks 24 | - Public or private harassment 25 | - Publishing others' private information, such as a physical or electronic 26 | address, without explicit permission 27 | - Other conduct which could reasonably be considered inappropriate in a 28 | professional setting 29 | 30 | ### Our Responsibilities 31 | 32 | Project maintainers are responsible for clarifying the standards of acceptable 33 | behavior and are expected to take appropriate and fair corrective action in 34 | response to any instances of unacceptable behavior. 35 | 36 | Project maintainers have the right and responsibility to remove, edit, or 37 | reject comments, commits, code, wiki edits, issues, and other contributions 38 | that are not aligned to this Code of Conduct, or to ban temporarily or 39 | permanently any contributor for other behaviors that they deem inappropriate, 40 | threatening, offensive, or harmful. 41 | 42 | ### Scope 43 | 44 | This Code of Conduct applies both within project spaces and in public spaces 45 | when an individual is representing the project or its community. Examples of 46 | representing a project or community include using an official project e-mail 47 | address, posting via an official social media account, or acting as an appointed 48 | representative at an online or offline event. Representation of a project may be 49 | further defined and clarified by project maintainers. 50 | 51 | ### Enforcement 52 | 53 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 54 | reported by contacting the project owner at [tjthavarshan@gmail.com]. All 55 | complaints will be reviewed and investigated and will result in a response that 56 | is deemed necessary and appropriate to the circumstances. The project owner is 57 | obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 58 | 59 | Project maintainers who do not follow or enforce the Code of Conduct in good 60 | faith may face temporary or permanent repercussions as determined by other 61 | members of the project's leadership. 62 | 63 | ### Attribution 64 | 65 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 66 | 67 | [homepage]: http://contributor-covenant.org 68 | [version]: http://contributor-covenant.org/version/1/4/ 69 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guide 2 | 3 | When contributing to this repository, please first discuss the change you wish to make via issue, email, or any other method with the owners of this repository before making a change. 4 | 5 | Please note we have a code of conduct, please follow it in all your interactions with the project. 6 | 7 | ## Pull Request Process 8 | 9 | 1. Ensure any install or build dependencies are removed before the end of the layer when doing a build. 10 | 2. Update the README.md with details of changes to the interface, this includes new environment variables, exposed ports, useful file locations and container parameters. 11 | 3. Increase the version numbers in any examples files and the README.md to the new version that this Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). 12 | 4. You may merge the Pull Request in once you have the sign-off of two other developers, or if you do not have permission to do that, you may request the second reviewer to merge it for you. 13 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: thavarshan 2 | buy_me_a_coffee: thavarshan 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUG_REPORT.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Bug report' 3 | about: "Report something that's broken." 4 | --- 5 | 6 | 7 | 8 | 9 | - Formlink Version: 1.0.0 10 | - Node Version: 18.x / 19.x / 20.x 11 | - TypeScript Version: 5.6 12 | 13 | ### Description 14 | 15 | ### Steps To Reproduce 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Feature request' 3 | about: 'Request a new and or additional feature.' 4 | --- 5 | 6 | 7 | 8 | 9 | ### Description 10 | 11 | 12 | 13 | ### Feature Details 14 | 15 | 16 | 17 | #### Additional Information 18 | 19 | 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Help & Support 4 | url: https://github.com/Thavarshan/formlink/issues 5 | about: 'This repository is only for reporting bugs. If you have a question or need help using the library, click:' 6 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Purpose 2 | 3 | _Describe the problem or feature in addition to a link to the issues._ 4 | 5 | ## Approach 6 | 7 | _How does this change address the problem?_ 8 | 9 | #### Open Questions and Pre-Merge TODOs 10 | 11 | - [ ] Use github checklists. When solved, check the box and explain the answer. 12 | 13 | ## Learning 14 | 15 | _Describe the research stage_ 16 | 17 | _Links to blog posts, patterns, libraries or addons used to solve this problem_ 18 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | **PLEASE DON'T DISCLOSE SECURITY-RELATED ISSUES PUBLICLY, [SEE BELOW](#reporting-a-vulnerability).** 4 | 5 | ## Supported Versions 6 | 7 | | Version | Security Fixes Until | 8 | | ------- | -------------------- | 9 | | 1.0.0 | Oct 15th, 2025 | 10 | 11 | ## Reporting a Vulnerability 12 | 13 | If you discover a security vulnerability within Formlink, please send an email to (). All security vulnerabilities will be promptly addressed. 14 | 15 | ### Public PGP Key 16 | 17 | ``` 18 | -----BEGIN PGP PUBLIC KEY BLOCK----- 19 | Version: Keybase OpenPGP v1.0.0 20 | Comment: https://keybase.io/crypto 21 | 22 | xsBNBGUglfkBCACc0wIkNOJpimgh+v+CzlYsWze2ow6IOZEWtcsnTM46OI6CkW4u 23 | w7KjKyZJYQLDJjV8lFVywAsUMqs4N/m0/XOuouZyUpZSXP6PAnFwP+sV9gFXqhUs 24 | q3dKmMeiVquzRgnZf0GdYFvHAW7KzIrL1s9eiTMiZ9zGf/u0ExDSzp2TXXwMsRyt 25 | rGc/Cr8XgFo25dXE9agUVpJELLHsnwqQ7CoKu1u/mtsVgrOPft3gyciiUXNuZauy 26 | Oj7sE0IHQUJUXedJUP+gaD4TQygRHGUQVAnImjl5ekKOIghjNgbrYlyT+o+HJ5bc 27 | WBrxaHVFAEGYmH3qIEBlAU34jRJh363cnxXFABEBAAHNNEplcm9tZSBUaGF5YW5h 28 | bnRoYWpvdGh5IDx0aGF2YXJzaGFuQG1hY3JvYWN0aXZlLmNvbT7CwG0EEwEKABcF 29 | AmUglfkCGy8DCwkHAxUKCAIeAQIXgAAKCRDqXuVp5ep45bXXB/9BUt6IDYDsWuCj 30 | 5utW+8ZiZ8B4jFvMR8XaQ630qGBNIszfy4FKdPh9+UZWnPRQ2036ilX7pbU4cDuL 31 | g0XnJIW2aA2be7G55s0pPaeafp19d8v2G8FUHr+EPfT2w9APY6E5nGbRKpg4iI07 32 | bhjUZWpRMZy/I1IzLvtRm+ecz9kavYxVCFsgHzwixkRccM5c2aAQuzihQyg/l4ik 33 | VXXw5CprEJMLA29dPvKtF7k9XuycBz9SVIebfmgeHHqTMfa+2z55Skdca+gbsgfu 34 | 98Dw3wQJvYHLF6Jlux4I0NXWxBnWr7MhVqt8RoVuTBD+NKa2cbVyNIjxcZOKw/n3 35 | YhFoz5lQzsBNBGUglfkBCADMCfmW9EblQJ97Tk22a0phMSLE5qoKGkOEFoRprauQ 36 | e1kHAya7MdKUaAfimAXkqksP0x89sQiWOfv8N+jO3HvVASb5AvnZjqSiT/90MCrl 37 | 4zyzJPXElDB1NHRZzovFfqmtuuzRx9zVYbeBW2xMxcCeTyhMV4DvKw4GiOiqi+zU 38 | 3A4JAR0ljXigE0O+/rFUM3xYQcki7xTuTUVGu4gy8njZbetUrjNab8TC+zUwCt5X 39 | Y8B1fD5fsMNhl1nGKjOrHo33RJke5vS/Uai8V3i4rFf2H0l/1e06NS9kuzHridh0 40 | eq9/OqTMLJNaLkh0Al7dTjXrbQ4CJ3+63o2HkUzf1MHpABEBAAHCwYQEGAEKAA8F 41 | AmUglfkFCQ8JnAACGy4BKQkQ6l7laeXqeOXAXSAEGQEKAAYFAmUglfkACgkQiyeW 42 | oCQoIaZg2Qf+OWgwryqNrOiLkB2i+rUuBmhOlLv++/gsktLN11QvPVW6aXNZxLxd 43 | F2szlKuMDEa1k10JQP/e/Wa0XgsXBkcYJaBeXLYeVa0LAopZ+Pg+h+uzHuNiMXqk 44 | q5F2JgEs90O0DfuFmeW1Bh6AFu8y/9kmz7uJotA9PzUVERY5j4MCURuhIITMn7k3 45 | Wfjlcjm/Zx+tDwvMSNbVEHtxzkK2mt8+SrQvEHRewIWoyKJq6kag92AdxSeyjsx4 46 | ZIfhwS3FHfAvmXTDntEBrJYArBsQmHTEPmimWDjhRuNSawPJkZ1yIIakwK/sXLya 47 | 29CLdh4xCVWkcvzWygBFoU+A5v42lTTR4+PYB/4iV+8G+XWOGFS+eGPUEFiVuLuI 48 | M9YzZb/BDOIKVCWHh6Pydd2HgS73XzZd0ebfU0Wx24qrMU/Aks14EAR+N7W641hV 49 | HCEmfzasx14bsWvqSzrwQl4knE/KuCKFmLCqBlmCQVf5mheBmp+gqWiU5ajs5NZF 50 | ZoiJU/uWPnPWaUQABANVgWsSBjn3+AXbjrsbJbPDPxQZYyN+C9gT7WamEWZ00xgc 51 | UAU2+5OinaDKpO9dSqi4zdtmaB5f5kOQpKL7WnK5c3GpQtQfbWvKh0C7mvxsQ7XW 52 | fXPwjoFFWp0p+XyJfm3oS8i+jYnSaEMPmZhujmHDZMIRfMNGJyhBhntLblEtzsBN 53 | BGUglfkBCADoWMNFlKKZ3xQxIAEpxjCtkvaSDqjrP83QnRb30NqBx26m0nIN26mU 54 | yPe+dg8QshEPFGkq5LnE9VJJSUygiq4M5rnqypxCufUii5V1/mOCd2AlMMgOmKn9 55 | sIBe871RCN+tW1ZMyKnUzm96xoXikWknjguOO+FtbzlIci+Zi7lWiMOSUKTZaEOu 56 | 9S8qw51dD01lxVQpw8gwprrXMJ7lOSfSBALkmdezMiOgk0hvp5ijZb6hFvzEiGXl 57 | 6EH6Iq2mZPdKEXqU5eGp9o58jxPVhBomA3t73XteBPsAFH7AutjQzTCO2A2WVzCq 58 | epRqUnUEJ9pOxArfN5Fye73PB4HsoTe5ABEBAAHCwYQEGAEKAA8FAmUglfkFCQ8J 59 | nAACGy4BKQkQ6l7laeXqeOXAXSAEGQEKAAYFAmUglfkACgkQ9JKRVORxE4JDPgf/ 60 | RPYmi2e4VBoozvUgco0m94npBesjx9mY+1frWKtrmqg6+8zuN9JuyOunJtBfuIEx 61 | 5bL4jk1qe8vO5/LQHur/Rhgaw3OvMwJaPS7/KeDoiSndpl/+EhLfN+CAgCRu5mIz 62 | PMKzZHHb4gQ8pj1bDDdD4ZilLhIcjMk3fCm6PVJTi8/J1m7M/MtBd082JnwTXKi0 63 | ymc6g383Gs4sqw0n8rjOBXIiWFnAdpazs+fpMpgEV5pC0zx+AUGrIGXUdxDQtkhx 64 | JOfPwRv/9zYmceak0OOBNB1UNxpo5fSK6Dmi2IyLaiuJsFCHHuSvi/C5kRVqQzWy 65 | 55A2URSaSRGb9p5TkSZ6c4KIB/9y6UVzqQKShKb3s1T85TOSm/B7YX5kcqEGRBbp 66 | MsNXRWXz/XhGXxHmlYEsdBvbxtlR9g/rxeKNH/qu7aj3SXlo8my9lVGyxfLtXWTo 67 | Xo1pj394WFwQqI6IlLFPO1y2qqdXqGCs4Y25I88s/QbLONoI4dRweTrJv+0RdIK+ 68 | NAtd+wMbOSWAOds9lnkk5xxtkhWeNEKAG4Hj0s8bMNbGKmjLM7BFCyHBBZDvOFzc 69 | XDbCOl9Gr1ESBGPf6+4IVT6yMVM5dPgpqDGQ4lmFts9eUlWaC1pptvTueQ91MDjz 70 | 3dt8q9jiqTmlxEcvrfMy9krYmRP8tyQ3oRpP2nk3jJl6b7iz 71 | =VqlN 72 | -----END PGP PUBLIC KEY BLOCK----- 73 | ``` 74 | -------------------------------------------------------------------------------- /.github/SUPPORT.md: -------------------------------------------------------------------------------- 1 | # Support Questions 2 | 3 | GitHub issue trackers are not intended to provide Sentinel help or support. Instead, use one of the following channels: 4 | 5 | - [Github discussions](https://github.com/Thavarshan/formlink/issues) 6 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: ['main', 'development'] 6 | pull_request: 7 | branches: ['main'] 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [18.x, 19.x, 20.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | cache: 'npm' 25 | 26 | - run: npm i 27 | - run: npm run lint 28 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: actions/setup-node@v3 13 | with: 14 | node-version: 18 15 | - run: npm ci 16 | - run: npm run lint 17 | - run: npm run build 18 | - run: npm test 19 | 20 | publish-npm: 21 | needs: build 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v3 25 | - uses: actions/setup-node@v3 26 | with: 27 | node-version: 18 28 | registry-url: https://registry.npmjs.org/ 29 | - run: npm ci 30 | - run: npm run build 31 | - run: npm publish 32 | env: 33 | NODE_AUTH_TOKEN: ${{ secrets.AUTH_TOKEN }} 34 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: ['main', 'development'] 6 | pull_request: 7 | branches: ['main'] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [18.x, 19.x, 20.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | cache: 'npm' 25 | 26 | - run: npm i 27 | - run: npm run build --if-present 28 | - run: npm test 29 | -------------------------------------------------------------------------------- /.github/workflows/update-changelog.yml: -------------------------------------------------------------------------------- 1 | name: 'Update Changelog' 2 | 3 | on: 4 | release: 5 | types: [released] 6 | 7 | jobs: 8 | update: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v4 14 | with: 15 | ref: main 16 | token: ${{ secrets.GH_TOKEN }} 17 | 18 | - name: Update Changelog 19 | uses: stefanzweifel/changelog-updater-action@v1 20 | with: 21 | latest-version: ${{ github.event.release.name }} 22 | release-notes: ${{ github.event.release.body }} 23 | 24 | - name: Commit updated CHANGELOG 25 | uses: stefanzweifel/git-auto-commit-action@v5 26 | with: 27 | branch: main 28 | commit_message: Update CHANGELOG 29 | file_pattern: CHANGELOG.md 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | package-lock.json 4 | yarn.lock 5 | /test-results/ 6 | /playwright-report/ 7 | /blob-report/ 8 | /playwright/.cache/ 9 | .DS_Store 10 | note.md 11 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e # this makes the script fail on first error 4 | 5 | # Run lint and tests before allowing commit 6 | npm run format 7 | npm run lint:fix 8 | npm run build 9 | npm test 10 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .git/ 2 | 3 | # for WebStorm 4 | .idea/ 5 | 6 | # for Visual Studio Code 7 | .vscode/ 8 | 9 | # for npm 10 | node_modules/ 11 | npm-shrinkwrap.json 12 | 13 | # for grunt 14 | typings/ 15 | .tscache/ 16 | espowered/ 17 | 18 | # for https://github.com/Microsoft/TypeScript/issues/4667 19 | lib/**/*.ts 20 | !lib/**/*.d.ts 21 | 22 | # codes 23 | test/ 24 | 25 | # misc 26 | example/ 27 | .gitignore 28 | .editorconfig 29 | circle.yml 30 | setup.sh 31 | Gruntfile.js 32 | tslint.json 33 | 34 | npm-debug.log 35 | notes.txt 36 | notes.md 37 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.11.1 2 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: 'none', 4 | singleQuote: true, 5 | printWidth: 120, 6 | tabWidth: 2, 7 | useTabs: false, 8 | endOfLine: 'auto' 9 | }; 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Release Notes 2 | 3 | ## [Unreleased](https://github.com/Thavarshan/formlink/compare/v1.3.0...HEAD) 4 | 5 | ## [v1.3.0](https://github.com/Thavarshan/formlink/compare/v1.2.6...v1.3.0) - 2025-05-23 6 | 7 | ### Added 8 | 9 | - **Debug Mode**: Added development-friendly debug mode with automatic detection based on `NODE_ENV` 10 | 11 | - Safe environment checks for browser/Node.js compatibility 12 | - Prefixed debug messages with `[Form Debug]` for easy identification 13 | - Optional data parameter for detailed debugging output 14 | 15 | - **Type Guards**: Implemented robust type safety improvements 16 | 17 | - `isFile()` type guard with environment safety checks 18 | - `isBlob()` type guard for better file handling 19 | - Enhanced cross-platform compatibility for browser and Node.js environments 20 | 21 | - **Advanced State Management**: Comprehensive form state tracking and reporting 22 | 23 | - `getStateSummary()` method for complete form state overview 24 | - `isState()` method for checking specific form states 25 | - Enhanced dirty field tracking with granular control 26 | - Form state enumeration (`FormState.IDLE`, `PROCESSING`, `SUCCESS`, `ERROR`, `CANCELLED`) 27 | 28 | - **Enhanced Validation System**: Improved validation capabilities 29 | 30 | - `validateField()` for individual field validation 31 | - `validateDirtyFields()` for validating only modified fields 32 | - Async validation support with proper error handling 33 | - Better validation error reporting and management 34 | 35 | - **Input Validation**: Added constructor input validation for better error prevention 36 | 37 | - Validates `initialData` parameter to ensure it's a valid object 38 | - Throws descriptive errors for invalid input 39 | - Prevents runtime errors from malformed initialization 40 | 41 | - **Cross-Platform Improvements**: Enhanced compatibility across different environments 42 | 43 | - Safe `process` existence checks for browser compatibility 44 | - Graceful fallbacks for environment-specific APIs 45 | - Better error handling for different runtime environments 46 | 47 | 48 | ### Changed 49 | 50 | - **Enhanced Data Cloning**: Improved cloning strategy with performance optimizations 51 | 52 | - Uses `structuredClone` when available for better performance 53 | - Graceful fallback to utility `deepClone` function 54 | - Separate methods for full data cloning vs. field-level cloning 55 | - Better handling of complex nested objects and circular references 56 | 57 | - **FormData Generation**: Enhanced file handling in `toFormData()` method 58 | 59 | - Uses new type guards for safer File/Blob detection 60 | - Better handling of nested objects and arrays 61 | - Improved FormData key generation for complex structures 62 | - Enhanced null/undefined value handling 63 | 64 | - **Form Reset Logic**: Improved reset functionality with better state management 65 | 66 | - Enhanced field-specific reset with proper dirty field cleanup 67 | - Better default value handling and restoration 68 | - Improved state cleanup after reset operations 69 | - More reliable form state restoration 70 | 71 | - **Error Handling**: Significantly improved error management 72 | 73 | - DRY error assignment with helper functions 74 | - Better HTTP status code handling (401, 403, 404, 422, 5xx) 75 | - Enhanced network error detection and reporting 76 | - Improved validation error formatting and display 77 | 78 | - **Resource Management**: Enhanced cleanup and disposal mechanisms 79 | 80 | - Better timeout management and cleanup 81 | - Improved request cancellation handling 82 | - More thorough resource disposal in `dispose()` method 83 | - Enhanced memory management for long-running applications 84 | 85 | 86 | ### Fixed 87 | 88 | - **Constructor Safety**: Fixed potential runtime errors from invalid initialization data 89 | 90 | - Added comprehensive input validation 91 | - Better error messages for debugging 92 | - Prevents crashes from malformed initial data 93 | 94 | - **Environment Compatibility**: Resolved cross-platform compatibility issues 95 | 96 | - Fixed `process` access in browser environments 97 | - Better handling of Node.js vs. browser APIs 98 | - Improved error handling for missing environment features 99 | 100 | - **File Upload Reliability**: Enhanced file handling robustness 101 | 102 | - Better type checking for File and Blob objects 103 | - Improved error handling for unsupported file types 104 | - Enhanced FormData generation for complex file structures 105 | 106 | - **State Consistency**: Fixed various state management edge cases 107 | 108 | - Better dirty field tracking consistency 109 | - Improved form state transitions 110 | - Enhanced error state management 111 | - More reliable success/failure state handling 112 | 113 | - **Memory Leaks**: Addressed potential memory leaks and resource cleanup 114 | 115 | - Better timeout cleanup in all scenarios 116 | - Improved request cancellation handling 117 | - Enhanced disposal of event listeners and callbacks 118 | - More thorough cleanup of internal state 119 | 120 | 121 | ### Developer Experience 122 | 123 | - **Enhanced Documentation**: Comprehensive JSDoc improvements 124 | 125 | - Detailed parameter descriptions for all methods 126 | - Better return type documentation 127 | - Enhanced usage examples in comments 128 | - Improved TypeScript intellisense support 129 | 130 | - **Better Debugging**: Improved development experience 131 | 132 | - Debug mode for development environments 133 | - Better error messages with context 134 | - Enhanced logging for troubleshooting 135 | - More descriptive validation error messages 136 | 137 | - **Improved Tooling**: Enhanced development and build tooling 138 | 139 | - Better TypeScript integration 140 | - Improved linting rules and fixes 141 | - Enhanced testing capabilities 142 | - Better IDE support and autocomplete 143 | 144 | 145 | ## [v1.2.6](https://github.com/Thavarshan/formlink/compare/v1.2.5...v1.2.6) - 2025-04-08 146 | 147 | ### Added 148 | 149 | - **Utility Abstractions**: Introduced several utility functions to improve modularity and testability: 150 | 151 | - `createFormProxy` (moved proxy logic out of class) 152 | - `deepClone` (replaces internal `deepClone` method) 153 | - `prepareSubmissionData` (encapsulates data transformation and file handling logic) 154 | - `getDefaultHeaders` (extracts CSRF token header logic) 155 | - `createProgressObject` (standardizes upload progress structure) 156 | - `formatGeneralError` and `formatValidationErrors` (modular error formatting) 157 | - `createTimeout` (abstracts timeout creation) 158 | 159 | - **Debounce Time Configuration**: Added optional `debounceTime` parameter to `submitDebounced` method for customizable delay duration. 160 | 161 | 162 | ### Changed 163 | 164 | - **Proxy Creation**: Replaced inline proxy logic within the constructor with `createFormProxy()` helper. 165 | - **Deep Cloning**: Refactored cloning logic to use the `deepClone()` utility instead of a private method. 166 | - **Data Preparation**: Moved data transformation and file handling into `prepareSubmissionData()`. 167 | - **Header Management**: Extracted CSRF token header logic into `getDefaultHeaders()`. 168 | - **Progress Tracking**: Refactored `updateProgress` to use `createProgressObject()` for standardized formatting. 169 | - **Error Handling**: Replaced inline error formatting with `formatValidationErrors()` and `formatGeneralError()` for better readability and separation of concerns. 170 | - **Timeout Management**: Switched from `window.setTimeout` to `createTimeout()` for better control and consistency. 171 | - **Cleaner Disposal**: Updated `dispose()` to use `Object.keys().forEach()` for better clarity and reliability when clearing object keys. 172 | 173 | ### Fixed 174 | 175 | - **Potential Proxy Redundancy**: Improved property fallback logic by moving proxy logic out, reducing chances of conflicts or duplication. 176 | - **Error Object Casting**: Made error response casting and fallback more robust using type-safe utilities. 177 | - **Form Reset Edge Cases**: Fixed edge case where resetting with specific fields might not deep clone defaults properly. 178 | - **Timeout Cleanup**: Ensured all timeouts (including debounce) are properly cleared in all scenarios, improving memory safety. 179 | 180 | ## [v1.2.5](https://github.com/Thavarshan/formlink/compare/v1.2.4...v1.2.5) - 2025-04-05 181 | 182 | ### Changed 183 | 184 | - Strip `lodash` and use native JS/TS functions instead 185 | 186 | ## [v1.2.4](https://github.com/Thavarshan/formlink/compare/v2.0.0...v1.2.4) - 2024-11-15 187 | 188 | ### Added 189 | 190 | - Option to install as Vue plugin 191 | 192 | ### Fixed 193 | 194 | - Laravel validation errors set without input name as key in `errors` object (#59) 195 | 196 | ## [v2.0.0](https://github.com/Thavarshan/formlink/compare/v1.2.2...v2.0.0) - 2024-11-15 197 | 198 | Bumping version to `2.0.0` to avoid version collisions when publishing to `npm` registry. 199 | 200 | ## [v1.2.2](https://github.com/Thavarshan/formlink/compare/v1.2.1...v1.2.2) - 2024-11-15 201 | 202 | ### Fixed 203 | 204 | - Lodash debounce method not found (#58) 205 | 206 | ## [v1.2.1](https://github.com/Thavarshan/formlink/compare/v1.2.0...v1.2.1) - 2024-11-15 207 | 208 | ### Fixed 209 | 210 | - Lodash debounce method not found (#58) 211 | 212 | ## [v1.2.0](https://github.com/Thavarshan/formlink/compare/v1.0.11...v1.2.0) - 2024-10-19 213 | 214 | ### Added 215 | 216 | - **Form Validation**: Introduced a new form validation feature that validates form data based on provided rules before submission, ensuring correct data is sent.- **File Upload Progress Tracking**: Added support for tracking the progress of file uploads during form submission. 217 | - **Debounced Form Submission**: Added support for debounced form submissions, reducing redundant network requests by delaying execution for a specified time. 218 | 219 | ### Changed 220 | 221 | - **Improved Error Handling**: The error handling mechanism has been improved to integrate more effectively with Laravel's backend for validation errors. 222 | - **Dependency Updates**: Project dependencies have been updated to ensure compatibility and performance improvements. 223 | 224 | ### Fixed 225 | 226 | - **Form Error Handling**: Fixed issues where form errors were not being correctly cleared or reset upon new submissions. 227 | 228 | ## [v1.0.11](https://github.com/Thavarshan/formlink/compare/v0.0.11...v1.0.11) - 2024-10-18 229 | 230 | ### Added 231 | 232 | - Complete code refactor and restructure 233 | - Added support for file upload progress tracking. 234 | - Added handling of Laravel validation error responses within the form. 235 | 236 | ### Changed 237 | 238 | - Updated API to support all common HTTP methods (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`). 239 | - Improved error handling and validation mechanisms to integrate seamlessly with Laravel. 240 | - Updted dependencies 241 | 242 | ### Fixed 243 | 244 | - Fixed CSRF token management for automatic inclusion in form requests. 245 | - Fixed issues where form errors were not being properly cleared or reset upon new submissions. 246 | 247 | ## [v0.0.11](https://github.com/Thavarshan/formlink/compare/v0.0.10...v0.0.11) - 17-10-2023 248 | 249 | ### Added 250 | 251 | - Add `getInitial` method to Form 252 | 253 | ### Changed 254 | 255 | - Update dependencies 256 | - Update docblocks to option types and update `package.json` 257 | - Update proxy instance to use lodash when checking for reserved field names 258 | 259 | ### Fixed 260 | 261 | - Fix props being set directly on form client 262 | - Fix all props being set inside data and initial data props of form client 263 | 264 | ## [v0.0.10](https://github.com/Thavarshan/formlink/compare/v0.0.9...v0.0.10) - 11-10-2023 265 | 266 | ### Changed 267 | 268 | - Update import statements to use proper relative paths 269 | - Export response types and error types from `index.ts` 270 | 271 | ## [v0.0.9](https://github.com/Thavarshan/formlink/compare/v0.0.6...v0.0.9) - 10-10-2023 272 | 273 | ### Added 274 | 275 | - Add `extractError` private method to Form 276 | - Add `getFirstInputFieldName` private method to Form 277 | - Add `exception` enum 278 | - Add `initialise` private method to Form 279 | 280 | ### Changed 281 | 282 | - Update type hints on `Form` class 283 | - Update http initialise method call priority 284 | - Update README.md with CI badges 285 | - Update error handler to extract error from response 286 | 287 | ### Fixed 288 | 289 | - Fix typo on ErrorResponse interface name 290 | 291 | ## [v0.0.6](https://github.com/fornlinkjs/fornlink/compare/v0.0.5...v0.0.6) - 09-10-2023 292 | 293 | ### Changed 294 | 295 | - Update set data method and error handler 296 | - Update README.md with more information about how to use with Vue 3 Composition API 297 | 298 | ## [v0.0.5](https://github.com/fornlinkjs/fornlink/compare/v0.0.4...v0.0.5) - 09-10-2023 299 | 300 | ### Added 301 | 302 | - Add `getIsDirty` method to Form 303 | - Add `setIsDirty` method to Form 304 | - Add `isDirty` property to Form 305 | 306 | ### Changed 307 | 308 | - Update initials data setting mechanism 309 | - Update `allErrors` method to `errors` 310 | - Integrate Axios types into Formlink types 311 | 312 | ## [v0.0.4](https://github.com/fornlinkjs/fornlink/compare/v0.0.3...v0.0.4) - 08-10-2023 313 | 314 | ### Changed 315 | 316 | - Create proxy instance when Form is instantiated 317 | - Minor method refactors 318 | 319 | ## [v0.0.3](https://github.com/fornlinkjs/fornlink/compare/v0.0.2...v0.0.3) - 08-10-2023 320 | 321 | ### Changed 322 | 323 | - Update `package.json` with more information about the project 324 | - Update `package.json` with proper export details 325 | 326 | ## [v0.0.2](https://github.com/fornlinkjs/fornlink/compare/v0.0.1...v0.0.2) - 08-10-2023 327 | 328 | ### Changed 329 | 330 | - Update `README.md` with more information about the project 331 | - Update package description 332 | 333 | ## v0.0.1 - 08-10-2023 334 | 335 | Initial release (alpha) 336 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Jerome Thayananthajothy 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Formlink](./assets/Banner.png)](https://github.com/Thavarshan/formlink) 2 | 3 | # Formlink 4 | 5 | [![Latest Version on npm](https://img.shields.io/npm/v/formlink.svg)](https://www.npmjs.com/package/formlink) 6 | [![Test](https://github.com/Thavarshan/formlink/actions/workflows/test.yml/badge.svg)](https://github.com/Thavarshan/formlink/actions/workflows/test.yml) 7 | [![Lint](https://github.com/Thavarshan/formlink/actions/workflows/lint.yml/badge.svg)](https://github.com/Thavarshan/formlink/actions/workflows/lint.yml) 8 | [![Total Downloads](https://img.shields.io/npm/dt/formlink.svg)](https://www.npmjs.com/package/formlink) 9 | [![TypeScript](https://img.shields.io/badge/TypeScript-Ready-blue.svg)](https://www.typescriptlang.org/) 10 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 11 | 12 | Formlink is a comprehensive, type-safe form-handling library for modern web applications. Built with TypeScript-first design, it provides seamless form state management, validation, file uploads, and HTTP request handling with built-in error management and progress tracking. 13 | 14 | ## Features 15 | 16 | - Full TypeScript Support: Complete type safety with intelligent type inference 17 | - Zero Configuration: Works out of the box with any backend framework 18 | - Built-in CSRF Protection: Automatic CSRF token handling for Laravel and other frameworks 19 | - Real-time Progress Tracking: File upload progress with detailed metrics 20 | - Intelligent Error Handling: Automatic validation error management with HTTP status awareness 21 | - Advanced State Management: Comprehensive form state tracking with dirty field detection 22 | - Event-driven Architecture: Rich lifecycle hooks for complete control 23 | - Framework Agnostic: Works with Vue, React, Angular, or vanilla JavaScript 24 | - Complete HTTP Support: All HTTP methods with request/response transformation 25 | - Smart Reset & Defaults: Flexible form reset with customizable default values 26 | - Debounced Operations: Built-in debouncing for search and auto-save scenarios 27 | - Request Cancellation: Cancel ongoing requests with proper cleanup 28 | - Debug Mode: Development-friendly debugging with detailed logging 29 | - Cross-platform: Browser and Node.js compatible 30 | 31 | ## Installation 32 | 33 | ```bash 34 | npm install formlink 35 | # or 36 | yarn add formlink 37 | # or 38 | pnpm add formlink 39 | ``` 40 | 41 | ## Quick Start 42 | 43 | ### Basic Form 44 | 45 | ```typescript 46 | import { useForm } from 'formlink'; 47 | 48 | interface ContactForm { 49 | name: string; 50 | email: string; 51 | message: string; 52 | } 53 | 54 | const form = useForm({ 55 | name: '', 56 | email: '', 57 | message: '' 58 | }); 59 | 60 | // Simple submission 61 | await form.post('/api/contact'); 62 | 63 | // Check form state 64 | console.log(form.processing); // false 65 | console.log(form.wasSuccessful); // true 66 | console.log(form.errors); // {} 67 | ``` 68 | 69 | ### Complete Vue.js Example 70 | 71 | ```vue 72 | 142 | 143 | 222 | ``` 223 | 224 | ## API Reference 225 | 226 | ### Form Creation 227 | 228 | #### `useForm(initialData: T, axiosInstance?: AxiosInstance): Form` 229 | 230 | Creates a new form instance with the specified initial data. 231 | 232 | ```typescript 233 | const form = useForm({ 234 | username: '', 235 | password: '' 236 | }); 237 | 238 | // With custom Axios instance 239 | const customAxios = axios.create({ baseURL: '/api' }); 240 | const form = useForm(data, customAxios); 241 | ``` 242 | 243 | ### Form Properties 244 | 245 | | Property | Type | Description | 246 | | -------------------- | ------------------------------------------------- | ---------------------------------------------------------------- | 247 | | `data` | `T` | The current form data | 248 | | `errors` | `Partial>` | Validation errors for each field | 249 | | `processing` | `boolean` | Whether the form is currently being submitted | 250 | | `progress` | `Progress \| null` | Upload progress information | 251 | | `wasSuccessful` | `boolean` | Whether the last submission was successful | 252 | | `recentlySuccessful` | `boolean` | Whether the form was recently successful (UI feedback) | 253 | | `isDirty` | `boolean` | Whether any field has been modified | 254 | | `rules` | `ValidationRules` | Validation rules for form fields | 255 | | `state` | `FormState` | Current form state (IDLE, PROCESSING, SUCCESS, ERROR, CANCELLED) | 256 | 257 | ### Form Methods 258 | 259 | #### HTTP Methods 260 | 261 | ```typescript 262 | // HTTP request methods 263 | form.get(url: string, options?: FormOptions): Promise 264 | form.post(url: string, options?: FormOptions): Promise 265 | form.put(url: string, options?: FormOptions): Promise 266 | form.patch(url: string, options?: FormOptions): Promise 267 | form.delete(url: string, options?: FormOptions): Promise 268 | form.options(url: string, options?: FormOptions): Promise 269 | 270 | // Generic submission method 271 | form.submit(method: Method, url: string, options?: FormOptions): Promise 272 | 273 | // Debounced submission (useful for search/auto-save) 274 | form.submitDebounced(method: Method, url: string, options?: FormOptions, debounceTime?: number): void 275 | ``` 276 | 277 | #### State Management 278 | 279 | ```typescript 280 | // Dirty field tracking 281 | form.markFieldDirty(field: keyof T): void 282 | form.isFieldDirty(field: keyof T): boolean 283 | form.getDirtyFields(): Set 284 | form.clearDirtyFields(): void 285 | 286 | // State checking 287 | form.isState(state: FormState): boolean 288 | form.getStateSummary(): FormStateSummary 289 | ``` 290 | 291 | #### Error Handling 292 | 293 | ```typescript 294 | // Error management 295 | form.setError(field: keyof T | 'formError', message: string): void 296 | form.setErrors(errors: Partial>): void 297 | form.clearErrors(): void 298 | form.clearError(field: keyof T | 'formError'): void 299 | form.hasErrors(): boolean 300 | form.hasError(field: keyof T | 'formError'): boolean 301 | form.getError(field: keyof T | 'formError'): string | undefined 302 | ``` 303 | 304 | #### Form Reset & Defaults 305 | 306 | ```typescript 307 | // Reset functionality 308 | form.reset(): void // Reset all fields 309 | form.reset(...fields: (keyof T)[]): void // Reset specific fields 310 | 311 | // Default value management 312 | form.setDefaults(): void // Set current data as defaults 313 | form.setDefaults(field: keyof T, value: any): void // Set single field default 314 | form.setDefaults(fields: Partial): void // Set multiple defaults 315 | ``` 316 | 317 | #### Validation 318 | 319 | ```typescript 320 | // Field validation 321 | form.validateField(field: keyof T): Promise 322 | form.validateDirtyFields(): Promise 323 | form.validate(onlyDirty?: boolean): Promise 324 | ``` 325 | 326 | #### Data Transformation & Serialization 327 | 328 | ```typescript 329 | // Data transformation before submission 330 | form.transform(callback: (data: T) => object): Form 331 | 332 | // Serialization 333 | form.toJSON(includeDefaults?: boolean): string 334 | form.fromJSON(json: string, setAsDefaults?: boolean): void 335 | form.toFormData(): FormData 336 | ``` 337 | 338 | #### Request Management 339 | 340 | ```typescript 341 | // Request cancellation 342 | form.cancel(): void 343 | 344 | // Resource cleanup 345 | form.dispose(): void 346 | ``` 347 | 348 | ### Form Options 349 | 350 | The `FormOptions` interface provides comprehensive hooks for form submission lifecycle: 351 | 352 | ```typescript 353 | interface FormOptions { 354 | resetOnSuccess?: boolean; // Reset form after success 355 | onBefore?: () => void; // Before submission starts 356 | onSuccess?: (response: AxiosResponse) => void; // On successful response 357 | onCanceled?: () => void; // On request cancellation 358 | onError?: (errors: Partial>) => void; // On validation errors 359 | onFinish?: () => void; // After submission completes 360 | onProgress?: (progress: Progress) => void; // On upload progress 361 | } 362 | ``` 363 | 364 | ### Form States 365 | 366 | ```typescript 367 | enum FormState { 368 | IDLE = 'idle', // Form is ready for input 369 | PROCESSING = 'processing', // Form is being submitted 370 | SUCCESS = 'success', // Last submission was successful 371 | ERROR = 'error', // Last submission had errors 372 | CANCELLED = 'cancelled' // Last submission was cancelled 373 | } 374 | ``` 375 | 376 | ### Progress Object 377 | 378 | ```typescript 379 | interface Progress { 380 | percentage: number; // Upload percentage (0-100) 381 | loaded: number; // Bytes uploaded 382 | total: number; // Total bytes to upload 383 | rate?: number; // Upload rate (bytes/second) 384 | estimated?: number; // Estimated time remaining (seconds) 385 | } 386 | ``` 387 | 388 | ## Advanced Usage Examples 389 | 390 | ### Custom Validation Rules 391 | 392 | ```typescript 393 | interface UserRegistration { 394 | username: string; 395 | email: string; 396 | password: string; 397 | confirmPassword: string; 398 | } 399 | 400 | const form = useForm({ 401 | username: '', 402 | email: '', 403 | password: '', 404 | confirmPassword: '' 405 | }); 406 | 407 | // Advanced validation rules 408 | form.rules = { 409 | username: [ 410 | { validate: (value) => !!value, message: 'Username is required' }, 411 | { validate: (value) => (value as string).length >= 3, message: 'Username must be at least 3 characters' }, 412 | { 413 | validate: async (value) => { 414 | // Async validation - check username availability 415 | const response = await fetch(`/api/check-username/${value}`); 416 | const data = await response.json(); 417 | return data.available; 418 | }, 419 | message: 'Username is already taken' 420 | } 421 | ], 422 | email: [ 423 | { validate: (value) => !!value, message: 'Email is required' }, 424 | { validate: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value as string), message: 'Invalid email format' } 425 | ], 426 | password: [ 427 | { validate: (value) => !!value, message: 'Password is required' }, 428 | { validate: (value) => (value as string).length >= 8, message: 'Password must be at least 8 characters' }, 429 | { 430 | validate: (value) => /(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(value as string), 431 | message: 'Password must contain uppercase, lowercase, and number' 432 | } 433 | ], 434 | confirmPassword: [{ validate: (value) => value === form.password, message: 'Passwords do not match' }] 435 | }; 436 | ``` 437 | 438 | ### File Upload with Multiple Files 439 | 440 | ```typescript 441 | interface FileUploadForm { 442 | title: string; 443 | description: string; 444 | files: File[]; 445 | category: string; 446 | } 447 | 448 | const form = useForm({ 449 | title: '', 450 | description: '', 451 | files: [], 452 | category: '' 453 | }); 454 | 455 | const handleMultipleFiles = (e: Event) => { 456 | const files = Array.from((e.target as HTMLInputElement).files || []); 457 | form.files = files; 458 | form.markFieldDirty('files'); 459 | }; 460 | 461 | // Submit with progress tracking 462 | await form.post('/api/upload', { 463 | onProgress: (progress) => { 464 | console.log(`Uploading: ${progress.percentage}%`); 465 | console.log(`Speed: ${(progress.rate! / 1024 / 1024).toFixed(2)} MB/s`); 466 | console.log(`ETA: ${progress.estimated} seconds`); 467 | }, 468 | onSuccess: (response) => { 469 | console.log('Files uploaded:', response.data.uploadedFiles); 470 | } 471 | }); 472 | ``` 473 | 474 | ### Search Form with Debouncing 475 | 476 | ```typescript 477 | interface SearchForm { 478 | query: string; 479 | filters: { 480 | category: string; 481 | dateRange: string; 482 | sortBy: string; 483 | }; 484 | } 485 | 486 | const searchForm = useForm({ 487 | query: '', 488 | filters: { 489 | category: '', 490 | dateRange: '', 491 | sortBy: 'relevance' 492 | } 493 | }); 494 | 495 | // Debounced search - only search after user stops typing for 500ms 496 | const performSearch = () => { 497 | searchForm.submitDebounced( 498 | 'get', 499 | '/api/search', 500 | { 501 | onSuccess: (response) => { 502 | // Update search results 503 | searchResults.value = response.data.results; 504 | } 505 | }, 506 | 500 507 | ); 508 | }; 509 | 510 | // Watch for changes and trigger debounced search 511 | watch(() => searchForm.query, performSearch); 512 | watch(() => searchForm.filters, performSearch, { deep: true }); 513 | ``` 514 | 515 | ### Form with Data Transformation 516 | 517 | ```typescript 518 | interface ProfileForm { 519 | firstName: string; 520 | lastName: string; 521 | birthDate: string; 522 | bio: string; 523 | tags: string[]; 524 | } 525 | 526 | const form = useForm({ 527 | firstName: '', 528 | lastName: '', 529 | birthDate: '', 530 | bio: '', 531 | tags: [] 532 | }); 533 | 534 | // Transform data before submission 535 | form.transform((data) => ({ 536 | ...data, 537 | firstName: data.firstName.trim(), 538 | lastName: data.lastName.trim(), 539 | fullName: `${data.firstName.trim()} ${data.lastName.trim()}`, 540 | birthDate: new Date(data.birthDate).toISOString(), 541 | bio: data.bio.trim(), 542 | tags: data.tags.filter((tag) => tag.trim() !== '').map((tag) => tag.toLowerCase()) 543 | })); 544 | ``` 545 | 546 | ### Error Recovery and Retry Logic 547 | 548 | ```typescript 549 | const form = useForm({ data: 'value' }); 550 | 551 | let retryCount = 0; 552 | const maxRetries = 3; 553 | 554 | const submitWithRetry = async () => { 555 | try { 556 | await form.post('/api/endpoint', { 557 | onError: async (errors) => { 558 | if (errors.formError?.includes('Network error') && retryCount < maxRetries) { 559 | retryCount++; 560 | console.log(`Retrying... Attempt ${retryCount}/${maxRetries}`); 561 | 562 | // Wait before retry (exponential backoff) 563 | await new Promise((resolve) => setTimeout(resolve, Math.pow(2, retryCount) * 1000)); 564 | 565 | // Retry the submission 566 | submitWithRetry(); 567 | } else { 568 | console.error('Max retries reached or non-recoverable error'); 569 | } 570 | }, 571 | onSuccess: () => { 572 | retryCount = 0; // Reset retry count on success 573 | } 574 | }); 575 | } catch (error) { 576 | console.error('Submission failed:', error); 577 | } 578 | }; 579 | ``` 580 | 581 | ### Form State Persistence 582 | 583 | ```typescript 584 | const form = useForm({ 585 | email: '', 586 | preferences: { 587 | newsletter: false, 588 | notifications: true 589 | } 590 | }); 591 | 592 | // Save form state to localStorage 593 | const saveFormState = () => { 594 | localStorage.setItem('formDraft', form.toJSON()); 595 | }; 596 | 597 | // Restore form state from localStorage 598 | const restoreFormState = () => { 599 | const saved = localStorage.getItem('formDraft'); 600 | if (saved) { 601 | form.fromJSON(saved); 602 | } 603 | }; 604 | 605 | // Auto-save on changes (debounced) 606 | watch(() => form.data, saveFormState, { deep: true }); 607 | 608 | // Restore on component mount 609 | onMounted(restoreFormState); 610 | 611 | // Clear saved data on successful submission 612 | await form.post('/api/submit', { 613 | onSuccess: () => { 614 | localStorage.removeItem('formDraft'); 615 | } 616 | }); 617 | ``` 618 | 619 | ## Framework Integration 620 | 621 | ### Vue 3 Composition API 622 | 623 | ```typescript 624 | import { useForm } from 'formlink'; 625 | import { computed, watch } from 'vue'; 626 | 627 | export function useContactForm() { 628 | const form = useForm({ 629 | name: '', 630 | email: '', 631 | message: '' 632 | }); 633 | 634 | const canSubmit = computed(() => form.isDirty && !form.processing && !form.hasErrors()); 635 | 636 | const submitForm = async () => { 637 | const isValid = await form.validate(); 638 | if (isValid) { 639 | await form.post('/api/contact'); 640 | } 641 | }; 642 | 643 | return { 644 | form, 645 | canSubmit, 646 | submitForm 647 | }; 648 | } 649 | ``` 650 | 651 | ### React Hook 652 | 653 | ```typescript 654 | import { useForm } from 'formlink'; 655 | import { useMemo, useCallback } from 'react'; 656 | 657 | export function useContactForm() { 658 | const form = useForm({ 659 | name: '', 660 | email: '', 661 | message: '' 662 | }); 663 | 664 | const canSubmit = useMemo( 665 | () => form.isDirty && !form.processing && !form.hasErrors(), 666 | [form.isDirty, form.processing, form.errors] 667 | ); 668 | 669 | const submitForm = useCallback(async () => { 670 | const isValid = await form.validate(); 671 | if (isValid) { 672 | await form.post('/api/contact'); 673 | } 674 | }, [form]); 675 | 676 | return { 677 | form, 678 | canSubmit, 679 | submitForm 680 | }; 681 | } 682 | ``` 683 | 684 | ## Development & Contributing 685 | 686 | ### Development Setup 687 | 688 | ```bash 689 | # Clone the repository 690 | git clone https://github.com/Thavarshan/formlink.git 691 | cd formlink 692 | 693 | # Install dependencies 694 | npm install 695 | 696 | # Run tests 697 | npm test 698 | 699 | # Run tests in watch mode 700 | npm run test:watch 701 | 702 | # Build the package 703 | npm run build 704 | 705 | # Run linting 706 | npm run lint 707 | 708 | # Type checking 709 | npm run type-check 710 | ``` 711 | 712 | ### Testing 713 | 714 | Formlink includes comprehensive tests covering all functionality: 715 | 716 | ```bash 717 | # Run all tests 718 | npm test 719 | 720 | # Run tests with coverage 721 | npm run test:coverage 722 | 723 | # Run specific test file 724 | npm test -- form.test.ts 725 | ``` 726 | 727 | ### Contributing Guidelines 728 | 729 | We welcome contributions! Please see our [Contributing Guide](https://github.com/Thavarshan/formlink/blob/main/.github/CONTRIBUTING.md) for details. 730 | 731 | 1. **Fork** the repository 732 | 2. **Create** your feature branch (`git checkout -b feature/amazing-feature`) 733 | 3. **Commit** your changes (`git commit -m 'Add amazing feature'`) 734 | 4. **Push** to the branch (`git push origin feature/amazing-feature`) 735 | 5. **Open** a Pull Request 736 | 737 | ### Code Quality Standards 738 | 739 | - ✅ **TypeScript**: Full type safety required 740 | - ✅ **Tests**: All new features must include tests 741 | - ✅ **Documentation**: Update docs for any API changes 742 | - ✅ **Linting**: Code must pass ESLint checks 743 | - ✅ **Formatting**: Code must be formatted with Prettier 744 | 745 | ## License 746 | 747 | Formlink is open-sourced software licensed under the [MIT license](LICENSE.md). 748 | 749 | ## Acknowledgments 750 | 751 | Special thanks to: 752 | 753 | - [**Jonathan Reinink**](https://github.com/reinink) for [**Inertia.js**](https://inertiajs.com/) which inspired this project 754 | - The TypeScript community for excellent tooling and type definitions 755 | - All contributors who help make Formlink better 756 | 757 | ## Support 758 | 759 | - 📖 [Documentation](https://github.com/Thavarshan/formlink) 760 | - 🐛 [Issue Tracker](https://github.com/Thavarshan/formlink/issues) 761 | - 💬 [Discussions](https://github.com/Thavarshan/formlink/discussions) 762 | - 📧 [Email Support](mailto:support@formlink.dev) 763 | 764 | --- 765 | 766 | Made with ❤️ by [Thavarshan](https://github.com/Thavarshan) 767 | -------------------------------------------------------------------------------- /assets/Banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Thavarshan/formlink/1840e85d1de17e46e4a9ac02116dca4bbb7957fe/assets/Banner.png -------------------------------------------------------------------------------- /assets/Social.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Thavarshan/formlink/1840e85d1de17e46e4a9ac02116dca4bbb7957fe/assets/Social.png -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import tseslint from '@typescript-eslint/eslint-plugin'; 2 | import tsParser from '@typescript-eslint/parser'; 3 | import * as mdx from 'eslint-plugin-mdx'; 4 | 5 | export default [ 6 | { 7 | ignores: ['temp.js', '**/vendor/*.js', '*.spec.ts', '*.test.ts'], 8 | files: ['**/*.{js,ts,tsx,mdx}'], 9 | languageOptions: { 10 | ecmaVersion: 2022, 11 | sourceType: 'module', 12 | parser: tsParser, 13 | parserOptions: { 14 | ecmaVersion: 2022, 15 | sourceType: 'module', 16 | ecmaFeatures: { 17 | jsx: false 18 | } 19 | } 20 | }, 21 | plugins: { 22 | '@typescript-eslint': tseslint, 23 | mdx 24 | }, 25 | rules: { 26 | // ESLint recommended 27 | 'no-unused-vars': 'off', 28 | 'no-console': 'error', 29 | indent: ['error', 2, { SwitchCase: 1 }], 30 | quotes: ['warn', 'single'], 31 | semi: 'off', 32 | 'comma-dangle': ['error', 'never'], 33 | // TypeScript recommended 34 | '@typescript-eslint/no-explicit-any': 'off', 35 | '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }] 36 | // Add more rules as needed 37 | } 38 | } 39 | ]; 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "formlink", 3 | "description": "Laravel-Vue form helper library.", 4 | "version": "1.3.0", 5 | "type": "module", 6 | "author": { 7 | "name": "Jerome Thayananthajothy", 8 | "email": "tjthavarshan@gmail.com" 9 | }, 10 | "main": "./dist/index.cjs.js", 11 | "module": "./dist/index.es.js", 12 | "types": "./dist/types/index.d.ts", 13 | "exports": { 14 | ".": { 15 | "types": "./dist/types/index.d.ts", 16 | "import": "./dist/index.es.js", 17 | "require": "./dist/index.cjs.js" 18 | }, 19 | "./dist/*": "./dist/*", 20 | "./package.json": "./package.json" 21 | }, 22 | "license": "MIT", 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/Thavarshan/formlink.git" 26 | }, 27 | "bugs": { 28 | "url": "https://github.com/Thavarshan/formlink.git/issues" 29 | }, 30 | "homepage": "https://thavarshan.com", 31 | "publishConfig": { 32 | "access": "public", 33 | "registry": "https://registry.npmjs.org" 34 | }, 35 | "files": [ 36 | "dist" 37 | ], 38 | "scripts": { 39 | "build": "tsc && vite build --config vite.config.ts", 40 | "lint": "eslint ./src --ext .ts", 41 | "lint:fix": "npm run lint -- --fix", 42 | "test": "vitest", 43 | "test:watch": "vitest --watch", 44 | "format": "prettier --write .", 45 | "clean": "rm -rf dist", 46 | "prepare": "husky" 47 | }, 48 | "dependencies": { 49 | "axios": "^1.9.0", 50 | "vue": "^3.5" 51 | }, 52 | "devDependencies": { 53 | "@rollup/plugin-typescript": "^12.1.2", 54 | "@types/node": "^22.15.21", 55 | "@typescript-eslint/eslint-plugin": "^8.32.1", 56 | "@typescript-eslint/parser": "^8.32.1", 57 | "axios-mock-adapter": "^2.1.0", 58 | "eslint": "^9.27.0", 59 | "eslint-config-prettier": "^10.1.5", 60 | "eslint-define-config": "^2.1.0", 61 | "eslint-plugin-import": "^2.31.0", 62 | "eslint-plugin-mdx": "^3.4.2", 63 | "eslint-plugin-prettier": "^5.4.0", 64 | "eslint-plugin-vue": "^10.1.0", 65 | "eslint-plugin-vuejs-accessibility": "^2.4.1", 66 | "husky": "^9.1.7", 67 | "jsdom": "^26.1.0", 68 | "prettier": "^3.5.3", 69 | "rollup-plugin-typescript-paths": "^1.5.0", 70 | "typescript": "^5.8.3", 71 | "vite": "^6.3.5", 72 | "vitest": "^3.1.4" 73 | }, 74 | "engines": { 75 | "node": ">=18.0.0" 76 | }, 77 | "keywords": [ 78 | "form", 79 | "helper", 80 | "library", 81 | "typescript" 82 | ] 83 | } 84 | -------------------------------------------------------------------------------- /src/enum/form.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Form state enumeration for better state management 3 | */ 4 | export enum FormState { 5 | IDLE = 'idle', 6 | PROCESSING = 'processing', 7 | SUCCESS = 'success', 8 | ERROR = 'error', 9 | CANCELLED = 'cancelled' 10 | } 11 | -------------------------------------------------------------------------------- /src/enum/method.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Methods enum for HTTP request types. 3 | */ 4 | export enum Methods { 5 | GET = 'get', 6 | POST = 'post', 7 | PUT = 'put', 8 | PATCH = 'patch', 9 | DELETE = 'delete', 10 | HEAD = 'head', 11 | OPTIONS = 'options' 12 | } 13 | -------------------------------------------------------------------------------- /src/enum/reserved-field-names.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Reserved field names for form handling. 3 | */ 4 | export enum ReservedFieldNames { 5 | OPTIONS = '__options', 6 | RULES = 'rules', 7 | PAGE = '__page', 8 | VALIDATE_REQUEST_TYPE = '__validateRequestType', 9 | DATA = 'data', 10 | DELETE = 'delete', 11 | ERROR_FOR = 'errorFor', 12 | ERRORS_FOR = 'errorsFor', 13 | HAS_ERRORS = 'hasErrors', 14 | INITIAL = 'initial', 15 | IS_DIRTY = 'isDirty', 16 | ON_FAIL = 'onFail', 17 | ON_SUCCESS = 'onSuccess', 18 | PATCH = 'patch', 19 | POST = 'post', 20 | PROCESSING = 'processing', 21 | PUT = 'put', 22 | RECENTLY_SUCCESSFUL = 'recentlySuccessful', 23 | RESET = 'reset', 24 | SUBMIT = 'submit', 25 | SUCCESSFUL = 'successful', 26 | WITH_DATA = 'withData', 27 | WITH_OPTIONS = 'withOptions', 28 | FORM_ERROR = 'formError', 29 | WAS_SUCCESSFUL = 'wasSuccessful', 30 | CANCEL = 'cancel', 31 | TRANSFORM = 'transform', 32 | SET_ERROR = 'setError', 33 | SET_ERRORS = 'setErrors', 34 | CLEAR_ERRORS = 'clearErrors', 35 | SET_DEFAULTS = 'setDefaults', 36 | RESET_FORM = 'resetForm', 37 | SUBMIT_FORM = 'submitForm', 38 | GET = 'get', 39 | POST_FORM = 'postForm', 40 | PUT_FORM = 'putForm', 41 | PATCH_FORM = 'patchForm', 42 | DELETE_FORM = 'deleteForm', 43 | OPTIONS_FORM = 'optionsForm', 44 | MARK_RECENTLY_SUCCESSFUL = 'markRecentlySuccessful' 45 | } 46 | -------------------------------------------------------------------------------- /src/form.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosInstance, AxiosProgressEvent, AxiosResponse, CancelTokenSource, AxiosError } from 'axios'; 2 | import { Form as IForm } from './types/form'; 3 | import { NestedFormData } from './types/form-data'; 4 | import { FormDataConvertible } from './types/form-data-convertible'; 5 | import { FormOptions } from './types/form-options'; 6 | import { Method } from './types/method'; 7 | import { Progress } from './types/progress'; 8 | import { ValidationRules } from './types/validation'; 9 | import { ApiValidationError } from './types/error'; 10 | import { createFormProxy } from './utils/form-proxy'; 11 | import { deepClone } from './utils/deep-clone'; 12 | import { getDefaultHeaders, prepareSubmissionData } from './utils/http-helpers'; 13 | import { createProgressObject } from './utils/progress-tracker'; 14 | import { formatGeneralError, formatValidationErrors } from './utils/error-formatter'; 15 | import { FormState } from './enum/form'; 16 | import { TimeoutManager } from './timeout-manager'; 17 | 18 | /** 19 | * The Form class provides a comprehensive solution for managing form state, validation, and submission in a TypeScript application. 20 | * It supports dirty field tracking, error handling, progress tracking, and integrates with Axios for HTTP requests. 21 | * 22 | * @template TForm - The type of the form data, extending NestedFormData for deep structure support. 23 | */ 24 | export class Form> implements IForm { 25 | /** 26 | * The current form data. 27 | */ 28 | public data: TForm; 29 | /** 30 | * Errors for each field or the form as a whole. 31 | */ 32 | public errors: Partial> = {}; 33 | /** 34 | * Indicates if the form is currently processing a submission. 35 | */ 36 | public processing = false; 37 | /** 38 | * Progress information for file uploads or long requests. 39 | */ 40 | public progress: Progress | null = null; 41 | /** 42 | * Indicates if the last submission was successful. 43 | */ 44 | public wasSuccessful = false; 45 | /** 46 | * Indicates if the form was recently successful (for UI feedback). 47 | */ 48 | public recentlySuccessful = false; 49 | /** 50 | * Indicates if any field in the form has been modified. 51 | */ 52 | public isDirty = false; 53 | /** 54 | * Validation rules for each field. 55 | */ 56 | public rules: ValidationRules = {} as ValidationRules; 57 | /** 58 | * The current state of the form (idle, processing, error, etc.). 59 | */ 60 | public state: FormState = FormState.IDLE; 61 | 62 | /** 63 | * Default values for the form, used for resetting. 64 | */ 65 | protected defaults: TForm; 66 | /** 67 | * Optional callback to transform data before submission. 68 | */ 69 | protected transformCallback: ((data: TForm) => object) | null = null; 70 | /** 71 | * Axios cancel token for aborting requests. 72 | */ 73 | protected cancelTokenSource: CancelTokenSource | null = null; 74 | /** 75 | * Axios instance used for HTTP requests. 76 | */ 77 | protected axiosInstance: AxiosInstance; 78 | /** 79 | * Timeout manager for debouncing and UI feedback. 80 | */ 81 | protected timeoutManager = new TimeoutManager(); 82 | /** 83 | * Tracks which fields have been modified (dirty). 84 | */ 85 | private dirtyFields = new Set(); 86 | 87 | /** 88 | * Type guard to check if a value is a File. 89 | */ 90 | private isFile(value: unknown): value is File { 91 | return typeof File !== 'undefined' && value instanceof File; 92 | } 93 | 94 | /** 95 | * Type guard to check if a value is a Blob. 96 | */ 97 | private isBlob(value: unknown): value is Blob { 98 | return typeof Blob !== 'undefined' && value instanceof Blob; 99 | } 100 | 101 | /** 102 | * Create a new form instance. 103 | * @param {TForm} initialData - The initial form data. 104 | * @param {AxiosInstance} [axiosInstance=axios] - The Axios instance to use for requests. 105 | * @returns {Form} A proxied form instance for reactivity. 106 | */ 107 | constructor(initialData: TForm, axiosInstance: AxiosInstance = axios) { 108 | if (!initialData || typeof initialData !== 'object') { 109 | throw new Error('initialData must be a valid object'); 110 | } 111 | this.data = initialData; 112 | this.defaults = this.deepCloneData(initialData); 113 | this.axiosInstance = axiosInstance; 114 | 115 | // Return a proxy to enable reactivity and field tracking 116 | return createFormProxy(this); 117 | } 118 | 119 | /** 120 | * Deep clone data using structuredClone if available, fallback to deepClone utility 121 | * @param {TForm} data - The data to clone 122 | * @returns {TForm} The cloned data 123 | */ 124 | private deepCloneData(data: TForm): TForm { 125 | // Use structuredClone if available and is a function 126 | if (typeof globalThis.structuredClone === 'function') { 127 | try { 128 | return globalThis.structuredClone(data); 129 | } catch { 130 | // Fallback to utility function 131 | return deepClone(data); 132 | } 133 | } 134 | return deepClone(data); 135 | } 136 | 137 | /** 138 | * Mark a field as dirty (modified). 139 | * @param {keyof TForm} field - The field to mark as dirty 140 | * @returns {void} 141 | */ 142 | public markFieldDirty(field: keyof TForm): void { 143 | this.dirtyFields.add(field); 144 | this.isDirty = true; 145 | } 146 | 147 | /** 148 | * Check if a field is dirty (has been modified). 149 | * @param {keyof TForm} field - The field to check 150 | * @returns {boolean} Whether the field is dirty 151 | */ 152 | public isFieldDirty(field: keyof TForm): boolean { 153 | return this.dirtyFields.has(field); 154 | } 155 | 156 | /** 157 | * Get all dirty fields. 158 | * @returns {Set} Set of dirty fields 159 | */ 160 | public getDirtyFields(): Set { 161 | return new Set(this.dirtyFields); 162 | } 163 | 164 | /** 165 | * Clear dirty field tracking. 166 | * @returns {void} 167 | */ 168 | public clearDirtyFields(): void { 169 | this.dirtyFields.clear(); 170 | this.isDirty = false; 171 | } 172 | 173 | /** 174 | * Set a specific error for a form field or the form as a whole. 175 | * @param {keyof TForm | 'formError'} field - The form field or 'formError'. 176 | * @param {string} message - The error message. 177 | * @returns {void} 178 | */ 179 | public setError(field: keyof TForm | 'formError', message: string): void { 180 | this.errors[field] = message; 181 | } 182 | 183 | /** 184 | * Set multiple errors for the form. 185 | * @param {Partial>} errors - The form errors. 186 | * @returns {void} 187 | */ 188 | public setErrors(errors: Partial>): void { 189 | this.errors = errors; 190 | } 191 | 192 | /** 193 | * Clear all form errors. 194 | * @returns {void} 195 | */ 196 | public clearErrors(): void { 197 | this.errors = {}; 198 | } 199 | 200 | /** 201 | * Clear error for a specific field. 202 | * @param {keyof TForm | 'formError'} field - The field to clear error for. 203 | * @returns {void} 204 | */ 205 | public clearError(field: keyof TForm | 'formError'): void { 206 | delete this.errors[field]; 207 | } 208 | 209 | /** 210 | * Check if the form has any errors. 211 | * @returns {boolean} Whether the form has errors 212 | */ 213 | public hasErrors(): boolean { 214 | return Object.keys(this.errors).length > 0; 215 | } 216 | 217 | /** 218 | * Check if a specific field has an error. 219 | * @param {keyof TForm | 'formError'} field - The field to check. 220 | * @returns {boolean} Whether the field has an error 221 | */ 222 | public hasError(field: keyof TForm | 'formError'): boolean { 223 | return field in this.errors && !!this.errors[field]; 224 | } 225 | 226 | /** 227 | * Get error message for a specific field. 228 | * @param {keyof TForm | 'formError'} field - The field to get error for. 229 | * @returns {string | undefined} The error message 230 | */ 231 | public getError(field: keyof TForm | 'formError'): string | undefined { 232 | return this.errors[field]; 233 | } 234 | 235 | /** 236 | * Reset form data to defaults. You can optionally reset specific fields. 237 | * @param {...(keyof TForm)[]} fields - The fields to reset. 238 | * @returns {void} 239 | */ 240 | public reset(...fields: (keyof TForm)[]): void { 241 | if (fields.length === 0) { 242 | // Replace the data object reference for full reset 243 | this.data = this.deepCloneData(this.defaults); 244 | this.clearDirtyFields(); 245 | } else { 246 | fields.forEach((field) => { 247 | // Use deepClone for field-level (single value) clone 248 | this.data[field] = deepClone(this.defaults[field]); 249 | this.dirtyFields.delete(field); 250 | }); 251 | this.isDirty = this.dirtyFields.size > 0; 252 | } 253 | this.clearErrors(); 254 | this.state = FormState.IDLE; 255 | this.wasSuccessful = false; 256 | this.recentlySuccessful = false; 257 | } 258 | 259 | /** 260 | * Set new default values for the form. 261 | * @param {keyof TForm | Partial} [fieldOrFields] - The field or fields to set as defaults. 262 | * @param {FormDataConvertible} [value] - The value to set for the field. 263 | * @returns {void} 264 | */ 265 | public setDefaults(fieldOrFields?: keyof TForm | Partial, value?: FormDataConvertible): void { 266 | if (typeof fieldOrFields === 'undefined') { 267 | this.defaults = this.deepCloneData(this.data); 268 | } else if (typeof fieldOrFields === 'string') { 269 | // Use deepClone for field-level (single value) clone 270 | this.defaults = { ...this.defaults, [fieldOrFields]: deepClone(value as TForm[keyof TForm]) }; 271 | } else { 272 | // Use deepClone for partials 273 | const partial = deepClone(fieldOrFields) as Partial; 274 | this.defaults = { ...this.deepCloneData(this.defaults), ...partial }; 275 | } 276 | } 277 | 278 | /** 279 | * Apply a transformation to the form data before submission. 280 | * @param {(data: TForm) => object} callback - The transformation callback. 281 | * @returns {this} The form instance. 282 | */ 283 | public transform(callback: (data: TForm) => object): this { 284 | this.transformCallback = callback; 285 | return this; 286 | } 287 | 288 | /** 289 | * Submit the form with the specified method and URL using Axios. 290 | * Handles progress, error, and success callbacks. 291 | * @param {Method} method - The HTTP method. 292 | * @param {string} url - The URL to submit to. 293 | * @param {Partial>} [options] - The form options. 294 | * @returns {Promise} A promise that resolves when the form is submitted. 295 | */ 296 | public async submit(method: Method, url: string, options?: Partial>): Promise { 297 | this.processing = true; 298 | this.state = FormState.PROCESSING; 299 | this.clearErrors(); 300 | 301 | this.cancelTokenSource = axios.CancelToken.source(); 302 | 303 | try { 304 | if (options?.onBefore) options.onBefore(); 305 | 306 | // Prepare data for submission, applying any transformation 307 | const dataToSubmit = prepareSubmissionData(this.data, this.transformCallback); 308 | 309 | interface SubmitRequestConfig { 310 | method: Method; 311 | url: string; 312 | data: object | FormData; 313 | cancelToken: CancelTokenSource['token']; 314 | headers: Record; 315 | onUploadProgress: (event: AxiosProgressEvent) => void; 316 | } 317 | 318 | const requestConfig: SubmitRequestConfig = { 319 | method, 320 | url, 321 | data: dataToSubmit, 322 | cancelToken: this.cancelTokenSource.token, 323 | headers: getDefaultHeaders(), 324 | onUploadProgress: (event: AxiosProgressEvent): void => { 325 | if (event.total) { 326 | this.updateProgress(event, options); 327 | } 328 | } 329 | }; 330 | 331 | // Perform the HTTP request 332 | const response: AxiosResponse = await this.axiosInstance(requestConfig); 333 | 334 | this.handleSuccess(response, options); 335 | } catch (error: unknown) { 336 | this.handleError(error, options); 337 | } finally { 338 | this.processing = false; 339 | if (options?.onFinish) options.onFinish(); 340 | } 341 | } 342 | 343 | /** 344 | * Update the progress based on the Axios progress event. 345 | * @param {AxiosProgressEvent} event - The Axios progress event. 346 | * @param {Partial>} [options] - The form options. 347 | * @returns {void} 348 | */ 349 | protected updateProgress(event: AxiosProgressEvent, options?: Partial>): void { 350 | if (event.total) { 351 | this.progress = createProgressObject(event); 352 | 353 | if (options?.onProgress && this.progress) { 354 | options.onProgress(this.progress); 355 | } 356 | } 357 | } 358 | 359 | /** 360 | * Handle the success response from the Axios request. 361 | * @param {AxiosResponse} response - The Axios response object. 362 | * @param {Partial>} [options] - The form options. 363 | * @returns {void} 364 | */ 365 | protected handleSuccess(response: AxiosResponse, options?: Partial>): void { 366 | this.wasSuccessful = true; 367 | this.state = FormState.SUCCESS; 368 | this.clearDirtyFields(); 369 | this.markRecentlySuccessful(); 370 | 371 | if (options?.onSuccess) { 372 | options.onSuccess(response); 373 | } 374 | } 375 | 376 | /** 377 | * Handle an error response from an Axios request. 378 | * Sets appropriate error messages based on error type and status code. 379 | * @param {unknown} error - The error object. 380 | * @param {Partial>} [options] - The form options. 381 | * @returns {void} 382 | */ 383 | protected handleError(error: unknown, options?: Partial>): void { 384 | if (axios.isCancel(error)) { 385 | this.state = FormState.CANCELLED; 386 | return; 387 | } 388 | 389 | this.state = FormState.ERROR; 390 | 391 | // DRY up error assignment 392 | const setFormError = (msg: string) => { 393 | this.errors = { formError: msg } as Partial>; 394 | }; 395 | 396 | if (axios.isAxiosError(error)) { 397 | const axiosError = error as AxiosError; 398 | const status = axiosError.response?.status; 399 | if (!axiosError.response) { 400 | setFormError('Network error. Please check your connection and try again.'); 401 | } else if (status === 422) { 402 | const validationError = axiosError.response.data as ApiValidationError; 403 | this.errors = formatValidationErrors(validationError) as Partial>; 404 | } else if (status === 404) { 405 | setFormError('The requested resource was not found.'); 406 | } else if (status === 403) { 407 | setFormError('You do not have permission to perform this action.'); 408 | } else if (status === 401) { 409 | setFormError('Authentication required. Please log in and try again.'); 410 | } else if (status && status >= 500) { 411 | setFormError('Server error. Please try again later.'); 412 | } else { 413 | setFormError(`Server returned an error (${status ?? 'unknown'}). Please try again.`); 414 | } 415 | } else { 416 | this.errors = formatGeneralError(error) as Partial>; 417 | } 418 | 419 | if (options?.onError) { 420 | options.onError(this.errors); 421 | } 422 | } 423 | 424 | // HTTP method helpers 425 | /** 426 | * Submit the form with a GET request. 427 | * @param {string} url - The URL to submit to. 428 | * @param {Partial>} [options] - The form options. 429 | * @returns {Promise} A promise that resolves when the form is submitted. 430 | */ 431 | public get(url: string, options?: Partial>): Promise { 432 | return this.submit('get', url, options); 433 | } 434 | 435 | /** 436 | * Submit the form with a POST request. 437 | * @param {string} url - The URL to submit to. 438 | * @param {Partial>} [options] - The form options. 439 | * @returns {Promise} A promise that resolves when the form is submitted. 440 | */ 441 | public post(url: string, options?: Partial>): Promise { 442 | return this.submit('post', url, options); 443 | } 444 | 445 | /** 446 | * Submit the form with a PUT request. 447 | * @param {string} url - The URL to submit to. 448 | * @param {Partial>} [options] - The form options. 449 | * @returns {Promise} A promise that resolves when the form is submitted. 450 | */ 451 | public put(url: string, options?: Partial>): Promise { 452 | return this.submit('put', url, options); 453 | } 454 | 455 | /** 456 | * Submit the form with a PATCH request. 457 | * @param {string} url - The URL to submit to. 458 | * @param {Partial>} [options] - The form options. 459 | * @returns {Promise} A promise that resolves when the form is submitted. 460 | */ 461 | public patch(url: string, options?: Partial>): Promise { 462 | return this.submit('patch', url, options); 463 | } 464 | 465 | /** 466 | * Submit the form with a DELETE request. 467 | * @param {string} url - The URL to submit to. 468 | * @param {Partial>} [options] - The form options. 469 | * @returns {Promise} A promise that resolves when the form is submitted. 470 | */ 471 | public delete(url: string, options?: Partial>): Promise { 472 | return this.submit('delete', url, options); 473 | } 474 | 475 | /** 476 | * Submit the form with an OPTIONS request. 477 | * @param {string} url - The URL to submit to. 478 | * @param {Partial>} [options] - The form options. 479 | * @returns {Promise} A promise that resolves when the form is submitted. 480 | */ 481 | public options(url: string, options?: Partial>): Promise { 482 | return this.submit('options', url, options); 483 | } 484 | 485 | /** 486 | * Submit the form with the specified method and URL using Axios, debounced. 487 | * Useful for autosave or live validation scenarios. 488 | * @param {Method} method - The HTTP method. 489 | * @param {string} url - The URL to submit to. 490 | * @param {Partial>} [options] - The form options. 491 | * @param {number} [debounceTime=300] - The debounce time in milliseconds. 492 | * @returns {void} 493 | */ 494 | public submitDebounced( 495 | method: Method, 496 | url: string, 497 | options?: Partial>, 498 | debounceTime: number = 300 499 | ): void { 500 | this.timeoutManager.set( 501 | 'debounce', 502 | () => { 503 | this.submit(method, url, options); 504 | }, 505 | debounceTime 506 | ); 507 | } 508 | 509 | /** 510 | * Validate a specific field against its defined rules. 511 | * @param {keyof TForm} field - The field to validate. 512 | * @returns {Promise} A promise that resolves with a boolean indicating if the field is valid. 513 | */ 514 | public async validateField(field: keyof TForm): Promise { 515 | const rules = this.rules[field]; 516 | if (!rules?.length) return true; 517 | 518 | // Clear existing error for this field 519 | this.clearError(field); 520 | 521 | const value = this.data[field]; 522 | for (const rule of rules) { 523 | try { 524 | const isValid = await rule.validate(value); 525 | if (!isValid) { 526 | this.setError(field, rule.message); 527 | return false; 528 | } 529 | } catch { 530 | this.setError(field, 'Validation error occurred'); 531 | return false; 532 | } 533 | } 534 | return true; 535 | } 536 | 537 | /** 538 | * Validate only the dirty fields against their defined rules. 539 | * @returns {Promise} A promise that resolves with a boolean indicating if all dirty fields are valid. 540 | */ 541 | public async validateDirtyFields(): Promise { 542 | let isValid = true; 543 | 544 | for (const field of this.dirtyFields) { 545 | const fieldValid = await this.validateField(field); 546 | if (!fieldValid) { 547 | isValid = false; 548 | } 549 | } 550 | 551 | return isValid; 552 | } 553 | 554 | /** 555 | * Validate the form data against the defined rules. 556 | * @param {boolean} [onlyDirty=false] - Whether to validate only dirty fields. 557 | * @returns {Promise} A promise that resolves with a boolean indicating if the form is valid. 558 | */ 559 | public async validate(onlyDirty: boolean = false): Promise { 560 | if (onlyDirty) { 561 | return this.validateDirtyFields(); 562 | } 563 | 564 | this.clearErrors(); 565 | let isValid = true; 566 | 567 | for (const [field, rules] of Object.entries(this.rules)) { 568 | if (!rules || rules.length === 0) continue; 569 | 570 | const fieldValid = await this.validateField(field as keyof TForm); 571 | if (!fieldValid) { 572 | isValid = false; 573 | } 574 | } 575 | 576 | return isValid; 577 | } 578 | 579 | /** 580 | * Cancel a form submission in progress. 581 | * @returns {void} 582 | */ 583 | public cancel(): void { 584 | if (this.cancelTokenSource) { 585 | this.cancelTokenSource.cancel('Form submission canceled.'); 586 | } 587 | this.processing = false; 588 | this.progress = null; 589 | this.state = FormState.CANCELLED; 590 | } 591 | 592 | /** 593 | * Mark the form as recently successful for a short duration (for UI feedback). 594 | * @param {number} [timeout=2000] - The duration in milliseconds. 595 | * @returns {void} 596 | */ 597 | protected markRecentlySuccessful(timeout: number = 2000): void { 598 | this.recentlySuccessful = true; 599 | this.timeoutManager.set( 600 | 'recentlySuccessful', 601 | () => { 602 | this.recentlySuccessful = false; 603 | }, 604 | timeout 605 | ); 606 | } 607 | 608 | /** 609 | * Serialize form data to JSON string. 610 | * @param {boolean} [includeDefaults=false] - Whether to include default values. 611 | * @returns {string} JSON string representation of form data. 612 | */ 613 | public toJSON(includeDefaults: boolean = false): string { 614 | const data = includeDefaults ? { data: this.data, defaults: this.defaults } : this.data; 615 | return JSON.stringify(data); 616 | } 617 | 618 | /** 619 | * Convert form data to FormData object for file uploads. 620 | * Handles nested objects and arrays. 621 | * @returns {FormData} FormData object containing form data. 622 | */ 623 | public toFormData(): FormData { 624 | const formData = new FormData(); 625 | 626 | // Recursively append data to FormData 627 | const appendToFormData = (data: any, parentKey: string = ''): void => { 628 | if (data === undefined || data === null) return; // skip undefined/null 629 | if (this.isFile(data) || this.isBlob(data)) { 630 | if (parentKey) formData.append(parentKey, data); 631 | } else if (Array.isArray(data)) { 632 | data.forEach((item, index) => { 633 | appendToFormData(item, `${parentKey}[${index}]`); 634 | }); 635 | } else if (typeof data === 'object' && data !== null) { 636 | Object.keys(data).forEach((key) => { 637 | const value = data[key]; 638 | const formKey = parentKey ? `${parentKey}[${key}]` : key; 639 | appendToFormData(value, formKey); 640 | }); 641 | } else if (parentKey) { 642 | formData.append(parentKey, String(data)); 643 | } 644 | }; 645 | 646 | appendToFormData(this.data); 647 | return formData; 648 | } 649 | 650 | /** 651 | * Load form data from JSON string. 652 | * Optionally set as defaults. 653 | * @param {string} json - JSON string to parse. 654 | * @param {boolean} [setAsDefaults=false] - Whether to also set as default values. 655 | * @returns {void} 656 | */ 657 | public fromJSON(json: string, setAsDefaults: boolean = false): void { 658 | try { 659 | const parsed = JSON.parse(json); 660 | this.data = parsed.data || parsed; 661 | 662 | if (setAsDefaults) { 663 | this.defaults = this.deepCloneData(this.data); 664 | } 665 | 666 | this.clearDirtyFields(); 667 | this.clearErrors(); 668 | } catch { 669 | throw new Error('Invalid JSON provided to fromJSON method'); 670 | } 671 | } 672 | 673 | /** 674 | * Check if the form is in a specific state. 675 | * @param {FormState} state - The state to check. 676 | * @returns {boolean} Whether the form is in the specified state. 677 | */ 678 | public isState(state: FormState): boolean { 679 | return this.state === state; 680 | } 681 | 682 | /** 683 | * Get a summary of the form's current state for UI or debugging. 684 | * @returns {object} Form state summary. 685 | */ 686 | public getStateSummary(): { 687 | state: FormState; 688 | hasErrors: boolean; 689 | errorCount: number; 690 | isDirty: boolean; 691 | dirtyFieldCount: number; 692 | processing: boolean; 693 | wasSuccessful: boolean; 694 | recentlySuccessful: boolean; 695 | } { 696 | return { 697 | state: this.state, 698 | hasErrors: this.hasErrors(), 699 | errorCount: Object.keys(this.errors).length, 700 | isDirty: this.isDirty, 701 | dirtyFieldCount: this.dirtyFields.size, 702 | processing: this.processing, 703 | wasSuccessful: this.wasSuccessful, 704 | recentlySuccessful: this.recentlySuccessful 705 | }; 706 | } 707 | 708 | /** 709 | * Clean up and dispose of the form instance. 710 | * Cancels any pending requests and resets all state. 711 | * @returns {void} 712 | */ 713 | public dispose(): void { 714 | this.cancel(); 715 | this.clearErrors(); 716 | this.timeoutManager.clearAll(); 717 | 718 | // Reset to clean state instead of deleting properties 719 | this.data = {} as TForm; 720 | this.defaults = {} as TForm; 721 | this.transformCallback = null; 722 | this.dirtyFields.clear(); 723 | this.rules = {} as ValidationRules; 724 | this.state = FormState.IDLE; 725 | this.processing = false; 726 | this.wasSuccessful = false; 727 | this.recentlySuccessful = false; 728 | this.isDirty = false; 729 | this.progress = null; 730 | } 731 | } 732 | // End of Form class implementation 733 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { App } from 'vue'; 2 | import { Form } from './form'; 3 | import { useForm } from './use-form'; 4 | 5 | const install = (app: App) => { 6 | app.component('Form', Form); 7 | app.config.globalProperties.$useForm = useForm; 8 | }; 9 | 10 | export { install }; 11 | export { Form } from './form'; 12 | export { useForm } from './use-form'; 13 | -------------------------------------------------------------------------------- /src/timeout-manager.ts: -------------------------------------------------------------------------------- 1 | import { createTimeout } from './utils/timeout'; 2 | 3 | /** 4 | * Timeout manager for better timeout handling 5 | */ 6 | export class TimeoutManager { 7 | /** 8 | * Map to store timeouts with their IDs 9 | */ 10 | private timeouts = new Map(); 11 | 12 | /** 13 | * Sets a timeout for a given key 14 | * @param key - The key to associate with the timeout 15 | * @param callback - The function to call after the timeout 16 | * @param delay - The delay in milliseconds 17 | * @returns void 18 | */ 19 | set(key: string, callback: () => void, delay: number): void { 20 | this.clear(key); 21 | const id = createTimeout(callback, delay); 22 | this.timeouts.set(key, id); 23 | } 24 | 25 | /** 26 | * Clears the timeout associated with the given key 27 | * @param key - The key whose timeout to clear 28 | * @returns void 29 | */ 30 | clear(key: string): void { 31 | const id = this.timeouts.get(key); 32 | if (id) { 33 | clearTimeout(id); 34 | this.timeouts.delete(key); 35 | } 36 | } 37 | 38 | /** 39 | * Clears all timeouts 40 | * @returns void 41 | */ 42 | clearAll(): void { 43 | for (const id of this.timeouts.values()) { 44 | clearTimeout(id); 45 | } 46 | this.timeouts.clear(); 47 | } 48 | 49 | /** 50 | * Checks if a timeout exists for the given key 51 | * @param key - The key to check 52 | * @returns boolean - True if the timeout exists, false otherwise 53 | */ 54 | has(key: string): boolean { 55 | return this.timeouts.has(key); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/types/error.ts: -------------------------------------------------------------------------------- 1 | export interface ApiValidationError { 2 | errors: Record; 3 | message: string; 4 | } 5 | 6 | export interface FormError { 7 | message: string; 8 | field?: string; 9 | code?: string; 10 | } 11 | -------------------------------------------------------------------------------- /src/types/form-data-convertible.ts: -------------------------------------------------------------------------------- 1 | export type FormDataConvertible = 2 | | Array 3 | | { [key: string]: FormDataConvertible } 4 | | Blob 5 | | FormDataEntryValue 6 | | Date 7 | | boolean 8 | | number 9 | | null 10 | | undefined; 11 | -------------------------------------------------------------------------------- /src/types/form-data.ts: -------------------------------------------------------------------------------- 1 | import { FormDataConvertible } from './form-data-convertible'; 2 | 3 | export type FormDataType = Record; 4 | 5 | export type NestedFormData = { 6 | [K in keyof T]: T[K] extends object 7 | ? NestedFormData 8 | : T[K] extends File | File[] | null 9 | ? T[K] 10 | : FormDataConvertible; 11 | }; 12 | -------------------------------------------------------------------------------- /src/types/form-options.ts: -------------------------------------------------------------------------------- 1 | import { AxiosResponse } from 'axios'; 2 | 3 | import { Progress } from './progress'; 4 | 5 | export interface FormOptions { 6 | /** 7 | * Determines whether the form should reset to its initial state after a successful submission. 8 | */ 9 | resetOnSuccess: boolean; 10 | 11 | /** 12 | * Hook called before the form submission starts. 13 | */ 14 | onBefore?: () => void; 15 | 16 | /** 17 | * Hook called when the form submission is successful. 18 | * @param response - The Axios response from the server. 19 | */ 20 | onSuccess?: (response: AxiosResponse) => void; 21 | 22 | /** 23 | * Hook called when the form submission is canceled. 24 | */ 25 | onCanceled?: () => void; 26 | 27 | /** 28 | * Hook called when the form submission fails, specifically for handling errors (e.g., Laravel validation errors). 29 | * @param errors - An object containing form validation errors mapped by field name. 30 | */ 31 | onError?: (errors: Partial>) => void; 32 | 33 | /** 34 | * Hook called when the form submission finishes, whether it's successful or not. 35 | */ 36 | onFinish?: () => void; 37 | 38 | /** 39 | * Hook called periodically during file upload progress or long-running requests. 40 | * @param progress - The current progress of the upload, including total, loaded, and percentage. 41 | */ 42 | onProgress?: (progress: Progress) => void; 43 | } 44 | -------------------------------------------------------------------------------- /src/types/form.ts: -------------------------------------------------------------------------------- 1 | import { FormDataType } from './form-data'; 2 | import { FormDataConvertible } from './form-data-convertible'; 3 | import { FormOptions } from './form-options'; 4 | import { Method } from './method'; 5 | import { Progress } from './progress'; 6 | 7 | /** 8 | * Interface for the Form class. 9 | * @template TForm - The type of form data. 10 | */ 11 | export interface Form extends Record { 12 | /** 13 | * The current form data. 14 | */ 15 | [key: string]: any; 16 | readonly data: TForm; 17 | 18 | /** 19 | * The form errors. 20 | */ 21 | errors: Partial>; 22 | 23 | /** 24 | * Indicates if the form is being processed. 25 | */ 26 | processing: boolean; 27 | 28 | /** 29 | * The progress of the form submission. 30 | */ 31 | progress: Progress | null; 32 | 33 | /** 34 | * Indicates if the form was successful. 35 | */ 36 | wasSuccessful: boolean; 37 | 38 | /** 39 | * Indicates if the form was recently successful. 40 | */ 41 | recentlySuccessful: boolean; 42 | 43 | /** 44 | * Indicates if the form is dirty (has unsaved changes). 45 | */ 46 | isDirty: boolean; 47 | 48 | /** 49 | * Set a specific error for a form field. 50 | * @param {keyof TForm} field - The form field. 51 | * @param {string} message - The error message. 52 | * @returns {void} 53 | */ 54 | setError(field: keyof TForm, message: string): void; 55 | 56 | /** 57 | * Set multiple errors for the form. 58 | * @param {Partial>} errors - The form errors. 59 | * @returns {void} 60 | */ 61 | setErrors(errors: Partial>): void; 62 | 63 | /** 64 | * Clear all form errors. 65 | * @returns {void} 66 | */ 67 | clearErrors(): void; 68 | 69 | /** 70 | * Reset form data to defaults. Optionally reset specific fields. 71 | * @param {...(keyof TForm)[]} fields - The fields to reset. 72 | * @returns {void} 73 | */ 74 | reset(...fields: (keyof TForm)[]): void; 75 | 76 | /** 77 | * Set new default values for the form. 78 | * @param {keyof TForm | Partial} [fieldOrFields] - The field or fields to set as defaults. 79 | * @param {FormDataConvertible} [value] - The value to set for the field. 80 | * @returns {void} 81 | */ 82 | setDefaults(fieldOrFields?: keyof TForm | Partial, value?: FormDataConvertible): void; 83 | 84 | /** 85 | * Apply a transformation to the form data before submission. 86 | * @param {(data: TForm) => object} callback - The transformation callback. 87 | * @returns {this} The form instance. 88 | */ 89 | transform(callback: (data: TForm) => object): this; 90 | 91 | /** 92 | * Submit the form with the specified method and URL using Axios. 93 | * @param {Method} method - The HTTP method. 94 | * @param {string} url - The URL to submit to. 95 | * @param {Partial>} [options] - The form options. 96 | * @returns {Promise} A promise that resolves when the form is submitted. 97 | */ 98 | submit(method: Method, url: string, options?: Partial>): Promise; 99 | 100 | /** 101 | * Submit the form with a GET request. 102 | * @param {string} url - The URL to submit to. 103 | * @param {Partial>} [options] - The form options. 104 | * @returns {Promise} A promise that resolves when the form is submitted. 105 | */ 106 | get(url: string, options?: Partial>): Promise; 107 | 108 | /** 109 | * Submit the form with a POST request. 110 | * @param {string} url - The URL to submit to. 111 | * @param {Partial>} [options] - The form options. 112 | * @returns {Promise} A promise that resolves when the form is submitted. 113 | */ 114 | post(url: string, options?: Partial>): Promise; 115 | 116 | /** 117 | * Submit the form with a PUT request. 118 | * @param {string} url - The URL to submit to. 119 | * @param {Partial>} [options] - The form options. 120 | * @returns {Promise} A promise that resolves when the form is submitted. 121 | */ 122 | put(url: string, options?: Partial>): Promise; 123 | 124 | /** 125 | * Submit the form with a PATCH request. 126 | * @param {string} url - The URL to submit to. 127 | * @param {Partial>} [options] - The form options. 128 | * @returns {Promise} A promise that resolves when the form is submitted. 129 | */ 130 | patch(url: string, options?: Partial>): Promise; 131 | 132 | /** 133 | * Submit the form with a DELETE request. 134 | * @param {string} url - The URL to submit to. 135 | * @param {Partial>} [options] - The form options. 136 | * @returns {Promise} A promise that resolves when the form is submitted. 137 | */ 138 | delete(url: string, options?: Partial>): Promise; 139 | 140 | /** 141 | * Submit the form with an OPTIONS request. 142 | * @param {string} url - The URL to submit to. 143 | * @param {Partial>} [options] - The form options. 144 | * @returns {Promise} A promise that resolves when the form is submitted. 145 | */ 146 | options(url: string, options?: Partial>): Promise; 147 | 148 | /** 149 | * Cancel a form submission in progress. 150 | * @returns {void} 151 | */ 152 | cancel(): void; 153 | } 154 | -------------------------------------------------------------------------------- /src/types/method.ts: -------------------------------------------------------------------------------- 1 | export type Method = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'options'; 2 | -------------------------------------------------------------------------------- /src/types/progress.ts: -------------------------------------------------------------------------------- 1 | import { AxiosProgressEvent } from 'axios'; 2 | 3 | export type Progress = { percentage: number } & AxiosProgressEvent; 4 | 5 | export interface FormProgress { 6 | upload?: Progress; 7 | download?: Progress; 8 | state: 'idle' | 'uploading' | 'downloading' | 'processing'; 9 | } 10 | -------------------------------------------------------------------------------- /src/types/request-payload.ts: -------------------------------------------------------------------------------- 1 | import { FormDataConvertible } from './form-data-convertible'; 2 | 3 | export type RequestPayload = Record | FormData; 4 | -------------------------------------------------------------------------------- /src/types/validation.ts: -------------------------------------------------------------------------------- 1 | export interface ValidationRule { 2 | validate: (value: any) => boolean | Promise; 3 | message: string; 4 | } 5 | 6 | export type ValidationRules = { 7 | [key in keyof T | string]: Array; 8 | }; 9 | -------------------------------------------------------------------------------- /src/use-form.ts: -------------------------------------------------------------------------------- 1 | import { reactive } from 'vue'; 2 | 3 | import { Form } from './form'; 4 | 5 | /** 6 | * useForm composable for managing form state and submissions. 7 | * @param {TForm} initialData - The initial form data. 8 | * @returns {object} Reactive form state and methods. 9 | */ 10 | export function useForm>(initialData: TForm) { 11 | // Create an instance of the Form class, which already has reactive data 12 | // Use Vue's reactive system to expose the entire form instance 13 | return reactive(new Form(initialData)); 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/deep-clone.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Deep clones an object or value. 3 | * @param {T} obj - The object to clone. 4 | * @returns {T} - The cloned object. 5 | */ 6 | export function deepClone(obj: T): T { 7 | if (obj === null || typeof obj !== 'object') { 8 | return obj; 9 | } 10 | 11 | if (Array.isArray(obj)) { 12 | return obj.map((item) => deepClone(item)) as unknown as T; 13 | } 14 | 15 | return Object.entries(obj).reduce((acc, [key, value]) => { 16 | acc[key as keyof T] = deepClone(value); 17 | return acc; 18 | }, {} as T); 19 | } 20 | -------------------------------------------------------------------------------- /src/utils/error-formatter.ts: -------------------------------------------------------------------------------- 1 | import { ApiValidationError } from '@/types/error'; 2 | 3 | /** 4 | * Formats API validation errors into a standardized error object. 5 | * @param {ApiValidationError} validationError - The validation error from the API. 6 | * @returns {Record} The formatted errors. 7 | */ 8 | export function formatValidationErrors(validationError: ApiValidationError): Record { 9 | return Object.entries(validationError.errors).reduce( 10 | (acc, [key, messages]) => ({ 11 | ...acc, 12 | [key]: Array.isArray(messages) ? messages[0] : messages // Use the first error message if it's an array 13 | }), 14 | {} 15 | ); 16 | } 17 | 18 | /** 19 | * Formats a general error into a standardized error object. 20 | * @param {unknown} error - The error object. 21 | * @returns {Record} The formatted error. 22 | */ 23 | export function formatGeneralError(error: unknown): Record { 24 | return { 25 | formError: error instanceof Error ? error.message : 'An unexpected error occurred' 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /src/utils/field-name-validator.ts: -------------------------------------------------------------------------------- 1 | import { ReservedFieldNames } from '../enum/reserved-field-names'; 2 | 3 | /** 4 | * List of reserved field names. 5 | * @type {ReservedFieldNames[]} 6 | */ 7 | export const reservedFieldNames: ReservedFieldNames[] = [ 8 | ReservedFieldNames.OPTIONS, 9 | ReservedFieldNames.RULES, 10 | ReservedFieldNames.PAGE, 11 | ReservedFieldNames.VALIDATE_REQUEST_TYPE, 12 | ReservedFieldNames.DATA, 13 | ReservedFieldNames.DELETE, 14 | ReservedFieldNames.ERROR_FOR, 15 | ReservedFieldNames.ERRORS_FOR, 16 | ReservedFieldNames.HAS_ERRORS, 17 | ReservedFieldNames.INITIAL, 18 | ReservedFieldNames.IS_DIRTY, 19 | ReservedFieldNames.ON_FAIL, 20 | ReservedFieldNames.ON_SUCCESS, 21 | ReservedFieldNames.PATCH, 22 | ReservedFieldNames.POST, 23 | ReservedFieldNames.PROCESSING, 24 | ReservedFieldNames.PUT, 25 | ReservedFieldNames.RECENTLY_SUCCESSFUL, 26 | ReservedFieldNames.RESET, 27 | ReservedFieldNames.SUBMIT, 28 | ReservedFieldNames.SUCCESSFUL, 29 | ReservedFieldNames.WITH_DATA, 30 | ReservedFieldNames.WITH_OPTIONS, 31 | ReservedFieldNames.FORM_ERROR, 32 | ReservedFieldNames.WAS_SUCCESSFUL, 33 | ReservedFieldNames.CANCEL, 34 | ReservedFieldNames.TRANSFORM, 35 | ReservedFieldNames.SET_ERROR, 36 | ReservedFieldNames.SET_ERRORS, 37 | ReservedFieldNames.CLEAR_ERRORS, 38 | ReservedFieldNames.SET_DEFAULTS, 39 | ReservedFieldNames.RESET_FORM, 40 | ReservedFieldNames.SUBMIT_FORM, 41 | ReservedFieldNames.GET, 42 | ReservedFieldNames.POST_FORM, 43 | ReservedFieldNames.PUT_FORM, 44 | ReservedFieldNames.PATCH_FORM, 45 | ReservedFieldNames.DELETE_FORM, 46 | ReservedFieldNames.OPTIONS_FORM, 47 | ReservedFieldNames.MARK_RECENTLY_SUCCESSFUL 48 | ]; 49 | 50 | /** 51 | * Guard against a list of reserved field names. 52 | * 53 | * @param {ReservedFieldNames | string} fieldName - The field name to check. 54 | * @throws {Error} If the field name is reserved. 55 | * @returns {void} 56 | */ 57 | export const guardAgainstReservedFieldName = (fieldName: ReservedFieldNames | string): void => { 58 | if (reservedFieldNames.includes(fieldName as ReservedFieldNames)) { 59 | throw new Error(`The field name "${fieldName}" is reserved and cannot be used in a Form or Errors instance.`); 60 | } 61 | }; 62 | -------------------------------------------------------------------------------- /src/utils/file.ts: -------------------------------------------------------------------------------- 1 | import { FormDataConvertible } from '../types/form-data-convertible'; 2 | import { RequestPayload } from '../types/request-payload'; 3 | 4 | /** 5 | * Checks if the provided data contains any files. 6 | * @param {RequestPayload | FormDataConvertible} data - The data to check. 7 | * @returns {boolean} True if the data contains files, otherwise false. 8 | */ 9 | export function hasFiles(data: RequestPayload | FormDataConvertible): boolean { 10 | if (data instanceof File || data instanceof Blob) { 11 | return true; 12 | } 13 | 14 | if (data instanceof FileList) { 15 | return data.length > 0; 16 | } 17 | 18 | if (data instanceof FormData) { 19 | for (const [, value] of (data as any).entries()) { 20 | if (hasFiles(value)) { 21 | return true; 22 | } 23 | } 24 | return false; 25 | } 26 | 27 | if (typeof data === 'object' && data !== null) { 28 | for (const value of Object.values(data)) { 29 | if (hasFiles(value)) { 30 | return true; 31 | } 32 | } 33 | return false; 34 | } 35 | 36 | return false; 37 | } 38 | -------------------------------------------------------------------------------- /src/utils/form-data.ts: -------------------------------------------------------------------------------- 1 | import { FormDataConvertible } from '../types/form-data-convertible'; 2 | 3 | /** 4 | * Checks if the given value is an instance of FormData. 5 | * @param value - The value to check. 6 | * @returns True if the value is FormData, otherwise false. 7 | */ 8 | export const isFormData = (value: unknown): value is FormData => value instanceof FormData; 9 | 10 | /** 11 | * Converts an object to FormData. 12 | * @param source - The source object to convert. 13 | * @param form - The FormData instance to append to. 14 | * @param parentKey - The parent key for nested objects. 15 | * @returns The FormData instance with appended values. 16 | */ 17 | export function objectToFormData( 18 | source: Record = {}, 19 | form: FormData = new FormData(), 20 | parentKey: string | null = null 21 | ): FormData { 22 | for (const [key, value] of Object.entries(source)) { 23 | append(form, composeKey(parentKey, key), value); 24 | } 25 | return form; 26 | } 27 | 28 | /** 29 | * Composes a key for nested objects. 30 | * @param parent - The parent key. 31 | * @param key - The current key. 32 | * @returns The composed key. 33 | */ 34 | function composeKey(parent: string | null, key: string): string { 35 | return parent ? `${parent}[${key}]` : key; 36 | } 37 | 38 | /** 39 | * Appends a value to the FormData instance. 40 | * @param form - The FormData instance. 41 | * @param key - The key to append. 42 | * @param value - The value to append. 43 | */ 44 | function append(form: FormData, key: string, value: FormDataConvertible): void { 45 | switch (true) { 46 | case Array.isArray(value): 47 | value.forEach((item, index) => append(form, composeKey(key, index.toString()), item)); 48 | break; 49 | case value instanceof Date: 50 | form.append(key, value.toISOString()); 51 | break; 52 | case value instanceof File: 53 | form.append(key, value, value.name); 54 | break; 55 | case value instanceof Blob: 56 | form.append(key, value); 57 | break; 58 | case typeof value === 'boolean': 59 | form.append(key, value ? '1' : '0'); 60 | break; 61 | case typeof value === 'string' || typeof value === 'number': 62 | form.append(key, value.toString()); 63 | break; 64 | case value === null || value === undefined: 65 | form.append(key, ''); 66 | break; 67 | case typeof value === 'object': 68 | objectToFormData(value, form, key); 69 | break; 70 | default: 71 | throw new TypeError(`Unsupported value type: ${typeof value} for key: ${key}`); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/utils/form-proxy.ts: -------------------------------------------------------------------------------- 1 | import { Form } from '@/form'; 2 | import { NestedFormData } from '@/types/form-data'; 3 | import { hasOwnProperty, keyExistsIn } from './object-helpers'; 4 | import { guardAgainstReservedFieldName } from './field-name-validator'; 5 | import { FormDataConvertible } from '@/types/form-data-convertible'; 6 | 7 | /** 8 | * Creates a proxy for the form instance to allow for dynamic property access. 9 | * @param {Form} instance - The form instance. 10 | * @returns {Form} The proxied form instance. 11 | */ 12 | export function createFormProxy>(instance: Form): Form { 13 | return new Proxy(instance, { 14 | get(target, key: string) { 15 | // Check if the key exists in the form data first (most common case) 16 | if (keyExistsIn(target.data, key)) { 17 | return target.data[key as keyof TForm]; 18 | } 19 | 20 | // Check if the property is a method in the class prototype 21 | const value = Reflect.get(target, key, target); 22 | 23 | if (typeof value === 'function') { 24 | return value.bind(target); 25 | } 26 | 27 | // Check if the key exists on the instance itself 28 | if (keyExistsIn(target, key)) { 29 | return (target as unknown as Record)[key]; 30 | } 31 | 32 | return undefined; 33 | }, 34 | set(target, key, value) { 35 | if (hasOwnProperty(target, key)) { 36 | (target as unknown as Record)[key as string] = value; 37 | return true; 38 | } 39 | 40 | guardAgainstReservedFieldName(key as string); 41 | 42 | if ( 43 | keyExistsIn(target.data, key) && 44 | typeof key === 'string' && 45 | key in target.data && 46 | target.data[key as keyof TForm] !== value 47 | ) { 48 | target.setDefaults(key as keyof TForm, target.data[key as keyof TForm] as FormDataConvertible); 49 | target.data[key as keyof TForm] = value; 50 | target.isDirty = true; 51 | return true; 52 | } 53 | 54 | // Default action if it's a form-level field 55 | if (keyExistsIn(target, key)) { 56 | (target as unknown as Record)[key as string] = value; 57 | return true; 58 | } 59 | 60 | return false; 61 | } 62 | }); 63 | } 64 | -------------------------------------------------------------------------------- /src/utils/http-helpers.ts: -------------------------------------------------------------------------------- 1 | import { FormDataConvertible } from '@/types/form-data-convertible'; 2 | import { RequestPayload } from '@/types/request-payload'; 3 | import { hasFiles } from './file'; 4 | import { objectToFormData } from './form-data'; 5 | 6 | /** 7 | * Retrieves the CSRF token from the document meta tag. 8 | * @returns {string} The CSRF token. 9 | */ 10 | export function getCsrfToken(): string { 11 | return (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content || ''; 12 | } 13 | 14 | /** 15 | * Gets default headers for HTTP requests. 16 | * @returns {Record} The headers. 17 | */ 18 | export function getDefaultHeaders(): Record { 19 | return { 20 | 'X-CSRF-TOKEN': getCsrfToken() 21 | }; 22 | } 23 | 24 | /** 25 | * Prepares data for submission based on content type. 26 | * @param {TForm} data - The form data. 27 | * @param {((data: TForm) => object) | null} transformCallback - Optional data transformation. 28 | * @returns {object | FormData} The prepared data for submission. 29 | */ 30 | export function prepareSubmissionData( 31 | data: TForm, 32 | transformCallback: ((data: TForm) => object) | null 33 | ): object | FormData { 34 | const preprocessedData = transformCallback ? transformCallback(data) : data; 35 | 36 | return hasFiles(data as FormDataConvertible | RequestPayload) 37 | ? objectToFormData(data as Record) 38 | : (preprocessedData as object | FormData); 39 | } 40 | -------------------------------------------------------------------------------- /src/utils/object-helpers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Safely checks if an object has a property as its own property. 3 | * @param {object} obj - The object to check. 4 | * @param {string | symbol} prop - The property to check. 5 | * @returns {boolean} Whether the object has the property. 6 | */ 7 | export function hasOwnProperty(obj: object, prop: string | symbol): boolean { 8 | return Object.prototype.hasOwnProperty.call(obj, prop); 9 | } 10 | 11 | /** 12 | * Checks if a property exists on an object (either as its own property or in its prototype chain). 13 | * @param {object} obj - The object to check. 14 | * @param {string | symbol} prop - The property to check. 15 | * @returns {boolean} Whether the property exists on the object. 16 | */ 17 | export function hasProperty(obj: object, prop: string | symbol): boolean { 18 | return prop in obj; 19 | } 20 | 21 | /** 22 | * Checks if a key exists in an object (either as its own property or in the prototype chain). 23 | * @param {object} obj - The object to check. 24 | * @param {string | symbol} key - The key to check. 25 | * @returns {boolean} Whether the key exists. 26 | */ 27 | export function keyExistsIn(obj: object, key: string | symbol): boolean { 28 | return hasOwnProperty(obj, key) || hasProperty(obj, key); 29 | } 30 | -------------------------------------------------------------------------------- /src/utils/progress-tracker.ts: -------------------------------------------------------------------------------- 1 | import { Progress } from '@/types/progress'; 2 | import { AxiosProgressEvent } from 'axios'; 3 | 4 | /** 5 | * Creates a progress object from an upload progress event. 6 | * @param {AxiosProgressEvent} event - The progress event. 7 | * @returns {Progress} The progress object. 8 | */ 9 | export function createProgressObject(event: AxiosProgressEvent): Progress { 10 | return { 11 | total: event.total || 0, 12 | loaded: event.loaded, 13 | percentage: Math.round((event.loaded / (event.total || 1)) * 100), 14 | bytes: event.loaded, 15 | lengthComputable: event.lengthComputable || false 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/timeout.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates a timeout and returns its ID. 3 | * @param {Function} callback - The function to call after the timeout. 4 | * @param {number} delay - The delay in milliseconds. 5 | * @returns {number} The timeout ID. 6 | */ 7 | export function createTimeout(callback: () => void, delay: number): number { 8 | return window.setTimeout(callback, delay); 9 | } 10 | 11 | /** 12 | * Clears a timeout. 13 | * @param {number} timeoutId - The timeout ID to clear. 14 | */ 15 | export function clearTimeout(timeoutId: number): void { 16 | window.clearTimeout(timeoutId); 17 | } 18 | -------------------------------------------------------------------------------- /tests/field-name-validator.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { guardAgainstReservedFieldName, reservedFieldNames } from '../src/utils/field-name-validator'; 3 | 4 | describe('guardAgainstReservedFieldName', () => { 5 | it('should throw an error for reserved field names', () => { 6 | reservedFieldNames.forEach((fieldName) => { 7 | expect(() => guardAgainstReservedFieldName(fieldName)).toThrow( 8 | `The field name "${fieldName}" is reserved and cannot be used in a Form or Errors instance.` 9 | ); 10 | }); 11 | }); 12 | 13 | it('should not throw an error for non-reserved field names', () => { 14 | const nonReservedFieldNames = ['customField1', 'customField2', 'customField3']; 15 | nonReservedFieldNames.forEach((fieldName) => { 16 | expect(() => guardAgainstReservedFieldName(fieldName)).not.toThrow(); 17 | }); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /tests/file.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { hasFiles } from '../src/utils/file'; 3 | import { RequestPayload } from '../src/types/request-payload'; 4 | 5 | describe('hasFiles', () => { 6 | it('should return true for File instances', () => { 7 | const file = new File(['content'], 'file.txt', { type: 'text/plain' }); 8 | expect(hasFiles(file)).toBe(true); 9 | }); 10 | 11 | it('should return true for Blob instances', () => { 12 | const blob = new Blob(['content'], { type: 'text/plain' }); 13 | expect(hasFiles(blob)).toBe(true); 14 | }); 15 | 16 | it('should return true for FileList instances with files', () => { 17 | const file = new File(['content'], 'file.txt', { type: 'text/plain' }); 18 | const fileList = { 19 | 0: file, 20 | length: 1, 21 | item: (index: number) => file 22 | } as unknown as FileList; 23 | expect(hasFiles(fileList)).toBe(true); 24 | }); 25 | 26 | it('should return false for empty FileList instances', () => { 27 | const fileList = { 28 | length: 0, 29 | item: (index: number) => null 30 | } as unknown as FileList; 31 | expect(hasFiles(fileList)).toBe(false); 32 | }); 33 | 34 | it('should return true for FormData instances with files', () => { 35 | const formData = new FormData(); 36 | const file = new File(['content'], 'file.txt', { type: 'text/plain' }); 37 | formData.append('file', file); 38 | expect(hasFiles(formData)).toBe(true); 39 | }); 40 | 41 | it('should return false for FormData instances without files', () => { 42 | const formData = new FormData(); 43 | formData.append('field', 'value'); 44 | expect(hasFiles(formData)).toBe(false); 45 | }); 46 | 47 | it('should return true for objects containing files', () => { 48 | const file = new File(['content'], 'file.txt', { type: 'text/plain' }); 49 | const data: RequestPayload = { file }; 50 | expect(hasFiles(data)).toBe(true); 51 | }); 52 | 53 | it('should return false for objects without files', () => { 54 | const data: RequestPayload = { field: 'value' }; 55 | expect(hasFiles(data)).toBe(false); 56 | }); 57 | 58 | it('should return false for non-object, non-file values', () => { 59 | expect(hasFiles('string')).toBe(false); 60 | expect(hasFiles(123)).toBe(false); 61 | expect(hasFiles(null)).toBe(false); 62 | expect(hasFiles(undefined)).toBe(false); 63 | }); 64 | 65 | it('should return true for nested objects containing files', () => { 66 | const file = new File(['content'], 'file.txt', { type: 'text/plain' }); 67 | const data: RequestPayload = { nested: { file } }; 68 | expect(hasFiles(data)).toBe(true); 69 | }); 70 | 71 | it('should return false for nested objects without files', () => { 72 | const data: RequestPayload = { nested: { field: 'value' } }; 73 | expect(hasFiles(data)).toBe(false); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /tests/form-data.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { isFormData, objectToFormData } from '../src/utils/form-data'; 3 | 4 | describe('isFormData', () => { 5 | it('should return true for FormData instances', () => { 6 | const formData = new FormData(); 7 | expect(isFormData(formData)).toBe(true); 8 | }); 9 | 10 | it('should return false for non-FormData instances', () => { 11 | expect(isFormData({})).toBe(false); 12 | expect(isFormData(null)).toBe(false); 13 | expect(isFormData(undefined)).toBe(false); 14 | expect(isFormData('string')).toBe(false); 15 | expect(isFormData(123)).toBe(false); 16 | }); 17 | }); 18 | 19 | describe('objectToFormData', () => { 20 | it('should convert a simple object to FormData', () => { 21 | const source = { name: 'John', age: 30 }; 22 | const formData = objectToFormData(source); 23 | expect(formData.get('name')).toBe('John'); 24 | expect(formData.get('age')).toBe('30'); 25 | }); 26 | 27 | it('should handle nested objects', () => { 28 | const source = { user: { name: 'John', age: 30 } }; 29 | const formData = objectToFormData(source); 30 | expect(formData.get('user[name]')).toBe('John'); 31 | expect(formData.get('user[age]')).toBe('30'); 32 | }); 33 | 34 | it('should handle arrays', () => { 35 | const source = { items: ['item1', 'item2'] }; 36 | const formData = objectToFormData(source); 37 | expect(formData.get('items[0]')).toBe('item1'); 38 | expect(formData.get('items[1]')).toBe('item2'); 39 | }); 40 | 41 | it('should handle dates', () => { 42 | const date = new Date(); 43 | const source = { date }; 44 | const formData = objectToFormData(source); 45 | expect(formData.get('date')).toBe(date.toISOString()); 46 | }); 47 | 48 | it('should handle files', () => { 49 | const file = new File(['content'], 'file.txt', { type: 'text/plain' }); 50 | const source = { file }; 51 | const formData = objectToFormData(source); 52 | expect(formData.get('file')).toStrictEqual(file); 53 | }); 54 | 55 | it('should handle blobs', () => { 56 | const blob = new Blob(['content'], { type: 'text/plain' }); 57 | const source = { blob }; 58 | const formData = objectToFormData(source); 59 | const receivedBlob = formData.get('blob') as Blob; 60 | expect(receivedBlob.size).toBe(blob.size); 61 | expect(receivedBlob.type).toBe(blob.type); 62 | }); 63 | 64 | it('should handle booleans', () => { 65 | const source = { active: true, inactive: false }; 66 | const formData = objectToFormData(source); 67 | expect(formData.get('active')).toBe('1'); 68 | expect(formData.get('inactive')).toBe('0'); 69 | }); 70 | 71 | it('should handle null and undefined', () => { 72 | const source = { empty: null, missing: undefined }; 73 | const formData = objectToFormData(source); 74 | expect(formData.get('empty')).toBe(''); 75 | expect(formData.get('missing')).toBe(''); 76 | }); 77 | 78 | it('should throw an error for unsupported types', () => { 79 | const source = { unsupported: Symbol('symbol') }; 80 | expect(() => objectToFormData(source as any)).toThrow(TypeError); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /tests/form.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; 2 | import axios from 'axios'; 3 | import MockAdapter from 'axios-mock-adapter'; 4 | import { Form } from '../src/form'; 5 | import { FormDataType } from '../src/types/form-data'; 6 | import { Method } from '../src/types/method'; 7 | 8 | interface TestFormData extends FormDataType { 9 | name: string; 10 | email: string; 11 | } 12 | 13 | describe('Form', () => { 14 | let form: Form; 15 | let mock: MockAdapter; 16 | 17 | beforeEach(() => { 18 | form = new Form({ name: '', email: '' }); 19 | mock = new MockAdapter(axios); 20 | // Mock the CSRF token meta tag 21 | document.head.innerHTML = ''; 22 | }); 23 | 24 | afterEach(() => { 25 | mock.restore(); 26 | document.head.innerHTML = ''; // Clean up the mocked CSRF token 27 | }); 28 | 29 | it('should initialize with default values', () => { 30 | expect(form.name).toBe(''); 31 | expect(form.email).toBe(''); 32 | expect(form.errors).toEqual({}); 33 | expect(form.processing).toBe(false); 34 | expect(form.progress).toBeNull(); 35 | expect(form.recentlySuccessful).toBe(false); 36 | expect(form.isDirty).toBe(false); // Updated expectation 37 | }); 38 | 39 | it('should set and get form data', () => { 40 | form.name = 'John Doe'; 41 | form.email = 'john@example.com'; 42 | expect(form.name).toBe('John Doe'); 43 | expect(form.email).toBe('john@example.com'); 44 | expect(form.data).toEqual({ name: 'John Doe', email: 'john@example.com' }); 45 | }); 46 | 47 | it('should set and get form errors', () => { 48 | form.setError('name', 'Name is required'); 49 | expect(form.errors).toEqual({ name: 'Name is required' }); 50 | form.setErrors({ email: 'Email is invalid' }); 51 | expect(form.errors).toEqual({ email: 'Email is invalid' }); 52 | }); 53 | 54 | it('should clear form errors', () => { 55 | form.setErrors({ name: 'Name is required', email: 'Email is invalid' }); 56 | form.clearErrors(); 57 | expect(form.errors).toEqual({}); 58 | }); 59 | 60 | it('should reset form data to defaults', () => { 61 | form.name = 'John Doe'; 62 | form.email = 'john@example.com'; 63 | form.reset(); 64 | expect(form.name).toBe(''); 65 | expect(form.email).toBe(''); 66 | }); 67 | 68 | it('should reset specific fields to defaults', () => { 69 | form.name = 'John Doe'; 70 | form.email = 'john@example.com'; 71 | form.reset('name'); 72 | expect(form.name).toBe(''); 73 | expect(form.email).toBe('john@example.com'); 74 | }); 75 | 76 | it('should set new default values', () => { 77 | form.name = 'John Doe'; 78 | form.setDefaults(); 79 | form.reset(); 80 | expect(form.name).toBe('John Doe'); 81 | }); 82 | 83 | it('should apply a transformation to the form data before submission', () => { 84 | form.transform((data) => ({ ...data, name: data.name.toUpperCase() })); 85 | form.name = 'John Doe'; 86 | expect(form.name).toBe('John Doe'); 87 | expect(form['transformCallback']!(form.data)).toEqual({ name: 'JOHN DOE', email: '' }); 88 | }); 89 | 90 | it('should submit the form successfully', async () => { 91 | const response = { data: 'success' }; 92 | mock.onPost('/submit').reply(200, response); 93 | 94 | const onBefore = vi.fn(); 95 | const onSuccess = vi.fn(); 96 | const onError = vi.fn(); 97 | const onFinish = vi.fn(); 98 | 99 | await form.submit('post' as Method, '/submit', { onBefore, onSuccess, onError, onFinish }); 100 | 101 | expect(onBefore).toHaveBeenCalled(); 102 | expect(onSuccess).toHaveBeenCalledWith( 103 | expect.objectContaining({ 104 | data: response 105 | }) 106 | ); 107 | expect(onError).not.toHaveBeenCalled(); 108 | expect(onFinish).toHaveBeenCalled(); 109 | expect(form.recentlySuccessful).toBe(true); 110 | }); 111 | 112 | it('should handle form submission errors', async () => { 113 | const errorResponse = { errors: { name: 'Name is required' } }; 114 | mock.onPost('/submit').reply(422, errorResponse); 115 | 116 | const onBefore = vi.fn(); 117 | const onSuccess = vi.fn(); 118 | const onError = vi.fn(); 119 | const onFinish = vi.fn(); 120 | 121 | await form.submit('post' as Method, '/submit', { onBefore, onSuccess, onError, onFinish }); 122 | 123 | expect(onBefore).toHaveBeenCalled(); 124 | expect(onSuccess).not.toHaveBeenCalled(); 125 | expect(onError).toHaveBeenCalledWith({ name: 'Name is required' }); 126 | expect(onFinish).toHaveBeenCalled(); 127 | expect(form.errors).toEqual({ name: 'Name is required' }); 128 | }); 129 | 130 | it('should cancel a form submission', async () => { 131 | mock.onPost('/submit').reply( 132 | () => 133 | new Promise((resolve, reject) => { 134 | setTimeout(() => reject({ message: 'Form submission canceled', __CANCEL__: true }), 100); 135 | }) 136 | ); 137 | 138 | const onBefore = vi.fn(); 139 | const onSuccess = vi.fn(); 140 | const onError = vi.fn(); 141 | const onFinish = vi.fn(); 142 | 143 | form.submit('post' as Method, '/submit', { onBefore, onSuccess, onError, onFinish }); 144 | form.cancel(); 145 | 146 | await new Promise((resolve) => setTimeout(resolve, 200)); // Wait for the mock to resolve 147 | 148 | expect(onBefore).toHaveBeenCalled(); 149 | expect(onSuccess).not.toHaveBeenCalled(); 150 | expect(onError).not.toHaveBeenCalled(); // Updated to check if onError is not called 151 | expect(onFinish).toHaveBeenCalled(); 152 | expect(form.processing).toBe(false); 153 | expect(form.progress).toBeNull(); 154 | }); 155 | 156 | it('should submit the form with a GET request', async () => { 157 | const response = { data: 'success' }; 158 | mock.onGet('/submit').reply(200, response); 159 | 160 | const onBefore = vi.fn(); 161 | const onSuccess = vi.fn(); 162 | const onError = vi.fn(); 163 | const onFinish = vi.fn(); 164 | 165 | await form.get('/submit', { onBefore, onSuccess, onError, onFinish }); 166 | 167 | expect(onBefore).toHaveBeenCalled(); 168 | expect(onSuccess).toHaveBeenCalledWith( 169 | expect.objectContaining({ 170 | data: response 171 | }) 172 | ); 173 | expect(onError).not.toHaveBeenCalled(); 174 | expect(onFinish).toHaveBeenCalled(); 175 | expect(form.recentlySuccessful).toBe(true); 176 | }); 177 | 178 | it('should submit the form with a POST request', async () => { 179 | const response = { data: 'success' }; 180 | mock.onPost('/submit').reply(200, response); 181 | 182 | const onBefore = vi.fn(); 183 | const onSuccess = vi.fn(); 184 | const onError = vi.fn(); 185 | const onFinish = vi.fn(); 186 | 187 | await form.post('/submit', { onBefore, onSuccess, onError, onFinish }); 188 | 189 | expect(onBefore).toHaveBeenCalled(); 190 | expect(onSuccess).toHaveBeenCalledWith( 191 | expect.objectContaining({ 192 | data: response 193 | }) 194 | ); 195 | expect(onError).not.toHaveBeenCalled(); 196 | expect(onFinish).toHaveBeenCalled(); 197 | expect(form.recentlySuccessful).toBe(true); 198 | }); 199 | 200 | it('should submit the form with a PUT request', async () => { 201 | const response = { data: 'success' }; 202 | mock.onPut('/submit').reply(200, response); 203 | 204 | const onBefore = vi.fn(); 205 | const onSuccess = vi.fn(); 206 | const onError = vi.fn(); 207 | const onFinish = vi.fn(); 208 | 209 | await form.put('/submit', { onBefore, onSuccess, onError, onFinish }); 210 | 211 | expect(onBefore).toHaveBeenCalled(); 212 | expect(onSuccess).toHaveBeenCalledWith( 213 | expect.objectContaining({ 214 | data: response 215 | }) 216 | ); 217 | expect(onError).not.toHaveBeenCalled(); 218 | expect(onFinish).toHaveBeenCalled(); 219 | expect(form.recentlySuccessful).toBe(true); 220 | }); 221 | 222 | it('should submit the form with a PATCH request', async () => { 223 | const response = { data: 'success' }; 224 | mock.onPatch('/submit').reply(200, response); 225 | 226 | const onBefore = vi.fn(); 227 | const onSuccess = vi.fn(); 228 | const onError = vi.fn(); 229 | const onFinish = vi.fn(); 230 | 231 | await form.patch('/submit', { onBefore, onSuccess, onError, onFinish }); 232 | 233 | expect(onBefore).toHaveBeenCalled(); 234 | expect(onSuccess).toHaveBeenCalledWith( 235 | expect.objectContaining({ 236 | data: response 237 | }) 238 | ); 239 | expect(onError).not.toHaveBeenCalled(); 240 | expect(onFinish).toHaveBeenCalled(); 241 | expect(form.recentlySuccessful).toBe(true); 242 | }); 243 | 244 | it('should submit the form with a DELETE request', async () => { 245 | const response = { data: 'success' }; 246 | mock.onDelete('/submit').reply(200, response); 247 | 248 | const onBefore = vi.fn(); 249 | const onSuccess = vi.fn(); 250 | const onError = vi.fn(); 251 | const onFinish = vi.fn(); 252 | 253 | await form.delete('/submit', { onBefore, onSuccess, onError, onFinish }); 254 | 255 | expect(onBefore).toHaveBeenCalled(); 256 | expect(onSuccess).toHaveBeenCalledWith( 257 | expect.objectContaining({ 258 | data: response 259 | }) 260 | ); 261 | expect(onError).not.toHaveBeenCalled(); 262 | expect(onFinish).toHaveBeenCalled(); 263 | expect(form.recentlySuccessful).toBe(true); 264 | }); 265 | 266 | it('should submit the form with an OPTIONS request', async () => { 267 | const response = { data: 'success' }; 268 | mock.onOptions('/submit').reply(200, response); 269 | 270 | const onBefore = vi.fn(); 271 | const onSuccess = vi.fn(); 272 | const onError = vi.fn(); 273 | const onFinish = vi.fn(); 274 | 275 | await form.options('/submit', { onBefore, onSuccess, onError, onFinish }); 276 | 277 | expect(onBefore).toHaveBeenCalled(); 278 | expect(onSuccess).toHaveBeenCalledWith( 279 | expect.objectContaining({ 280 | data: response 281 | }) 282 | ); 283 | expect(onError).not.toHaveBeenCalled(); 284 | expect(onFinish).toHaveBeenCalled(); 285 | expect(form.recentlySuccessful).toBe(true); 286 | }); 287 | 288 | describe('Form Validation', () => { 289 | it('should validate form data before submission', async () => { 290 | form.rules = { 291 | email: [ 292 | { 293 | validate: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value), 294 | message: 'Invalid email format' 295 | } 296 | ] 297 | }; 298 | 299 | form.email = 'invalid-email'; 300 | const isValid = await form.validate(); 301 | 302 | expect(isValid).toBe(false); 303 | expect(form.errors.email).toBe('Invalid email format'); 304 | }); 305 | }); 306 | }); 307 | -------------------------------------------------------------------------------- /tests/use-form.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach } from 'vitest'; 2 | import { isReactive } from 'vue'; 3 | import { useForm } from '../src/use-form'; 4 | 5 | interface TestFormData { 6 | name: string; 7 | email: string; 8 | } 9 | 10 | describe('useForm', () => { 11 | let initialData: TestFormData; 12 | 13 | beforeEach(() => { 14 | initialData = { name: '', email: '' }; 15 | }); 16 | 17 | it('should create a reactive form instance', () => { 18 | const form = useForm(initialData); 19 | expect(isReactive(form)).toBe(true); 20 | }); 21 | 22 | it('should initialize with default values', () => { 23 | const form = useForm(initialData); 24 | expect(form.data).toEqual({ name: '', email: '' }); 25 | expect(form.errors).toEqual({}); 26 | expect(form.processing).toBe(false); 27 | expect(form.progress).toBeNull(); 28 | expect(form.recentlySuccessful).toBe(false); 29 | expect(form.isDirty).toBe(false); 30 | }); 31 | 32 | it('should set and get form data', () => { 33 | const form = useForm(initialData); 34 | form.name = 'John Doe'; 35 | form.email = 'john@example.com'; 36 | expect(form.data).toEqual({ name: 'John Doe', email: 'john@example.com' }); 37 | }); 38 | 39 | it('should set and get form errors', () => { 40 | const form = useForm(initialData); 41 | form.setError('name', 'Name is required'); 42 | expect(form.errors).toEqual({ name: 'Name is required' }); 43 | form.setErrors({ email: 'Email is invalid' }); 44 | expect(form.errors).toEqual({ email: 'Email is invalid' }); 45 | }); 46 | 47 | it('should clear form errors', () => { 48 | const form = useForm(initialData); 49 | form.setErrors({ name: 'Name is required', email: 'Email is invalid' }); 50 | form.clearErrors(); 51 | expect(form.errors).toEqual({}); 52 | }); 53 | 54 | it('should reset form data to defaults', () => { 55 | const form = useForm(initialData); 56 | form.name = 'John Doe'; 57 | form.email = 'john@example.com'; 58 | form.reset(); 59 | expect(form.data).toEqual({ name: '', email: '' }); 60 | }); 61 | 62 | it('should reset specific fields to defaults', () => { 63 | const form = useForm(initialData); 64 | form.name = 'John Doe'; 65 | form.email = 'john@example.com'; 66 | form.reset('name'); 67 | expect(form.data).toEqual({ name: '', email: 'john@example.com' }); 68 | }); 69 | 70 | it('should set new default values', () => { 71 | const form = useForm(initialData); 72 | form.name = 'John Doe'; 73 | form.setDefaults(); 74 | form.reset(); 75 | expect(form.data).toEqual({ name: 'John Doe', email: '' }); 76 | }); 77 | 78 | it('should apply a transformation to the form data before submission', () => { 79 | const form = useForm(initialData); 80 | form.transform((data) => ({ ...data, name: data.name.toUpperCase() })); 81 | form.name = 'John Doe'; 82 | expect(form.name).toBe('John Doe'); 83 | expect(form['transformCallback']!(form.data)).toEqual({ name: 'JOHN DOE', email: '' }); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": ".", 4 | "noEmitOnError": true, 5 | "lib": ["DOM", "DOM.Iterable", "ES2020"], 6 | "target": "ES2020", 7 | "types": ["node"], 8 | "declaration": false, 9 | "outDir": "./dist", 10 | "module": "ES2020", 11 | "moduleResolution": "Node", 12 | "resolveJsonModule": true, 13 | "allowSyntheticDefaultImports": true, 14 | "noImplicitThis": false, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "preserveConstEnums": true, 18 | "removeComments": false, 19 | "typeRoots": ["./node_modules/@types"], 20 | "strict": true, 21 | "skipLibCheck": true, 22 | "baseUrl": ".", 23 | "paths": { 24 | "@/*": ["src/*"] 25 | } 26 | }, 27 | "exclude": ["tests", "vitest.config.ts", "src/types"] 28 | } 29 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { defineConfig } from 'vite'; 3 | import typescript from '@rollup/plugin-typescript'; 4 | import { typescriptPaths } from 'rollup-plugin-typescript-paths'; 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | build: { 9 | manifest: true, 10 | minify: true, 11 | reportCompressedSize: true, 12 | lib: { 13 | entry: path.resolve(__dirname, 'src/index.ts'), 14 | formats: ['es', 'cjs'], 15 | fileName: (format) => `index.${format}.js` 16 | }, 17 | rollupOptions: { 18 | external: ['axios', 'lodash', 'vue'], 19 | plugins: [ 20 | typescriptPaths({ 21 | preserveExtensions: true 22 | }), 23 | typescript({ 24 | sourceMap: false, 25 | declaration: true, 26 | declarationMap: false, 27 | declarationDir: path.resolve(__dirname, 'dist/types'), 28 | outDir: path.resolve(__dirname, 'dist'), 29 | rootDir: path.resolve(__dirname, 'src'), 30 | exclude: ['src/types'] 31 | }) 32 | ] 33 | } 34 | }, 35 | 36 | resolve: { 37 | alias: [ 38 | { 39 | find: '@', 40 | replacement: path.resolve(__dirname, './src') 41 | } 42 | ] 43 | }, 44 | 45 | plugins: [] 46 | }); 47 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import path from 'path'; 3 | import { defineConfig } from 'vitest/config'; 4 | 5 | export default defineConfig({ 6 | resolve: { 7 | alias: { 8 | '@': path.resolve(__dirname, './src') 9 | } 10 | }, 11 | 12 | test: { 13 | globals: true, 14 | environment: 'jsdom', 15 | include: ['**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'] 16 | } 17 | }); 18 | --------------------------------------------------------------------------------