├── .babelrc ├── .changeset ├── README.md └── config.json ├── .editorconfig ├── .eslintrc.cjs ├── .github └── workflows │ ├── codeql-analysis.yml │ └── release.yml ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── jest.config.json ├── package-lock.json ├── package.json ├── src ├── app.html ├── global.d.ts └── lib │ ├── FormField.spec.ts │ ├── FormField.svelte │ ├── __test__ │ └── test.models.ts │ ├── forms │ ├── commons │ │ ├── constants.ts │ │ ├── generic-types.ts │ │ └── utils.ts │ ├── decorators │ │ └── field.ts │ ├── svelte-form.spec.ts │ └── svelte-form.ts │ └── index.ts ├── svelte.config.js ├── tsconfig.json └── tsconfig.spec.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-typescript" 5 | ], 6 | "plugins": [ 7 | [ 8 | "@babel/plugin-proposal-decorators", 9 | { 10 | "legacy": true 11 | } 12 | ] 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@1.6.4/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "linked": [], 6 | "access": "restricted", 7 | "baseBranch": "master", 8 | "updateInternalDependencies": "patch", 9 | "ignore": [] 10 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | # top-most EditorConfig file 3 | root = true 4 | # Unix-style newlines with a newline ending every file 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_size = 4 9 | indent_style = space 10 | max_line_length = 200 11 | 12 | # Matches multiple files with brace expansion notation 13 | # Set default charset 14 | [*.{js,py}] 15 | charset = utf-8 16 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], 5 | plugins: ['svelte3', '@typescript-eslint'], 6 | ignorePatterns: ['*.cjs'], 7 | overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }], 8 | settings: { 9 | 'svelte3/typescript': () => require('typescript') 10 | }, 11 | parserOptions: { 12 | sourceType: 'module', 13 | ecmaVersion: 2020 14 | }, 15 | env: { 16 | browser: true, 17 | es2017: true, 18 | node: true 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '33 17 * * 0' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v2 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v1 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v1 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v1 71 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout Repo 14 | uses: actions/checkout@v2 15 | with: 16 | # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits 17 | fetch-depth: 0 18 | 19 | - name: Setup Node.js 16.x 20 | uses: actions/setup-node@v2 21 | with: 22 | node-version: 16.x 23 | 24 | - name: Install Dependencies 25 | run: npm ci 26 | 27 | - name: Running the tests 28 | run: npm test 29 | 30 | - name: Packaging 31 | run: npm run package 32 | 33 | - name: Create Release Pull Request or Publish to npm 34 | id: changesets 35 | uses: changesets/action@v1.2.2 36 | with: 37 | # This expects you to have a script called release which does a build for your packages and calls changeset publish 38 | publish: npm publish ./package 39 | createGithubReleases: true 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.NODIFY_GITHUB_TOKEN }} 42 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | .npmrc 4 | /build 5 | /.svelte-kit 6 | /package 7 | .env 8 | .env.* 9 | .idea 10 | .vscode 11 | coverage 12 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | .svelte-kit 3 | node_modules 4 | .npmrc 5 | coverage 6 | .github 7 | .changeset 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # svelte-formify 2 | 3 | ## 1.1.16 4 | 5 | ### Patch Changes 6 | 7 | - ac134a3: feature: improve type checks 8 | - 7046c1d: Trying to release a new tag 9 | 10 | ## 1.1.4 11 | 12 | ### Patch Changes 13 | 14 | - 08902d5: update the documentation to describe form validation 15 | 16 | ## 1.1.3 17 | 18 | ### Patch Changes 19 | 20 | - 971f278: feature: added changeset support for release management and add .npmrc to gitignore 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2020 The Backstage Authors 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # svelte-formify 2 | A library to manage and validate the forms. This library uses decorators to define validations. 3 | 4 | #### This project is still under development, be careful if you decide to use on a production environment 5 | 6 | ## Installation 7 | ```shell 8 | npm i svelte-formify 9 | ``` 10 | 11 | ## Components 12 | This library provides 3 main core components: 13 | 14 | * ``` @Field ``` to define properties and validations 15 | * ``` SvelteForm ``` to manage form state, get errors and the validated input values 16 | * ``` ``` a simple input field to use and prevents boilerplate codes while create input fields 17 | 18 | ### @Field 19 | Field decorator expects the declarations from `yup` library, you can nest any yup validation function while creating 20 | a Field. 21 | 22 | > Important: We must pass the Type explicitly for nested types. ESBuild does not support emitDecoratorMetadata yet. 23 | 24 | #### Usage example: 25 | ```typescript 26 | import { number, object, string } from 'yup' 27 | import { Field } from 'svelte-formify' 28 | 29 | export class Role { 30 | @Field(string().required().oneOf(['admin', 'test'])) name: string 31 | } 32 | 33 | export class User { 34 | @Field(string().required()) username: string 35 | @Field(string().required()) password: string 36 | @Field(string().required()) firstName: string 37 | @Field(string().required()) lastName: string 38 | @Field(object(), Role) role: Role // due to a restriction on ESBuild we can not emit decorator metadata for now, therefore we must pass the type for nested values explicitly 39 | } 40 | ``` 41 | 42 | ### SvelteForm 43 | SvelteForm class encapsulates the validations, listens for input changes and updates the errors so you can 44 | show validates states interactively to the user. 45 | 46 | SvelteForm expect 2 parameters while constructing it. 47 | 48 | 1. target: class defines the validation properties 49 | 2. initialValues: initial values for this class 50 | 51 | SvelteForm stores all data in a ```Context ``` class. A context contains the properties described as below: 52 | * **error** : ValidationError thrown by yup validation 53 | * **touched** true on blur otherwise false 54 | * **dirty** if user is typing 55 | * **value** the value typed by user 56 | 57 | #### How get raw values 58 | You can call `getRawValues` function if you need the raw values (e.g. : sending the form) 59 | 60 | ```typescript 61 | const values = form.getRawValues() 62 | ``` 63 | 64 | #### Listening validation result 65 | You can use `isValid` property which is a Writable to get validation status each time after user changes something. 66 | 67 | Example: you want to enable/disable a button depends on validation status: 68 | 69 | ```sveltehtml 70 | 84 | 85 | 86 | ``` 87 | 88 | 89 | ### General usage example 90 | ```html 91 | 114 | 115 | {#if $values.username.error } 116 | show some error 117 | {/if} 118 | 119 | 120 | 121 | ``` 122 | 123 | 124 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "transform": { 3 | "^.+\\.js$": "babel-jest", 4 | "^.+\\.svelte$": [ 5 | "./node_modules/svelte-jester/dist/transformer.mjs", 6 | { 7 | "preprocess": true 8 | } 9 | ], 10 | "^.+\\.ts$": "ts-jest" 11 | }, 12 | "moduleNameMapper": { 13 | "^\\$lib(.*)$": "/src/lib$1", 14 | "^\\$app(.*)$": [ 15 | "/.svelte-kit/dev/runtime/app$1", 16 | "/.svelte-kit/build/runtime/app$1" 17 | ] 18 | }, 19 | "extensionsToTreatAsEsm": [ 20 | ".svelte", 21 | ".ts" 22 | ], 23 | "moduleFileExtensions": [ 24 | "js", 25 | "svelte", 26 | "ts" 27 | ], 28 | "setupFilesAfterEnv": [ 29 | "@testing-library/jest-dom/extend-expect" 30 | ], 31 | "globals": { 32 | "ts-jest": { 33 | "tsconfig": "tsconfig.spec.json", 34 | "useESM": true 35 | } 36 | }, 37 | "testEnvironment": "jsdom", 38 | "collectCoverage": true, 39 | "coverageReporters": ["text", "html"] 40 | } 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svelte-formify", 3 | "version": "1.1.16", 4 | "types": "index.d.ts", 5 | "keywords": [ 6 | "svelte", 7 | "svelte-forms", 8 | "svelte validation" 9 | ], 10 | "repository": "https://github.com/nodify-at/svelte-formify", 11 | "author": { 12 | "name": "Hasan Oezdemir", 13 | "url": "https://github.com/nodify-at" 14 | }, 15 | "scripts": { 16 | "dev": "svelte-kit dev", 17 | "build": "svelte-kit build", 18 | "package": "svelte-kit package", 19 | "preview": "svelte-kit preview", 20 | "check": "svelte-check --tsconfig ./tsconfig.json", 21 | "check:watch": "svelte-check --tsconfig ./tsconfig.json --watch", 22 | "lint": "eslint --ignore-path .gitignore .", 23 | "test": "NODE_OPTIONS=--experimental-vm-modules jest src --config jest.config.json --coverage", 24 | "test:watch": "npm run test -- --watch" 25 | }, 26 | "devDependencies": { 27 | "@babel/core": "^7.17.9", 28 | "@babel/plugin-proposal-decorators": "^7.17.9", 29 | "@babel/preset-env": "^7.16.11", 30 | "@babel/preset-typescript": "^7.16.7", 31 | "@changesets/cli": "^2.22.0", 32 | "@rollup/plugin-commonjs": "^21.0.3", 33 | "@rollup/plugin-node-resolve": "^13.1.3", 34 | "@sveltejs/adapter-auto": "next", 35 | "@sveltejs/kit": "next", 36 | "@testing-library/jest-dom": "^5.16.4", 37 | "@testing-library/svelte": "^3.1.1", 38 | "@types/jest": "^27.4.1", 39 | "@types/testing-library__jest-dom": "^5.14.3", 40 | "@types/yup": "^0.29.13", 41 | "@typescript-eslint/eslint-plugin": "^5.18.0", 42 | "@typescript-eslint/parser": "^5.18.0", 43 | "babel-jest": "^27.5.1", 44 | "eslint": "^8.13.0", 45 | "eslint-plugin-svelte3": "^3.4.1", 46 | "jest": "^27.5.1", 47 | "rollup-plugin-svelte": "^7.1.0", 48 | "svelte": "^3.49.0", 49 | "svelte-check": "^2.6.0", 50 | "svelte-jester": "^2.3.2", 51 | "svelte-preprocess": "^4.10.5", 52 | "svelte2tsx": "^0.5.8", 53 | "ts-jest": "^27.1.4", 54 | "ts-node": "^10.7.0", 55 | "tslib": "^2.3.1", 56 | "typescript": "^4.6.3", 57 | "yup": "^0.32.11" 58 | }, 59 | "peerDependencies": { 60 | "svelte": "^3.49.0", 61 | "yup": "^0.32.11" 62 | }, 63 | "type": "module", 64 | "dependencies": { 65 | "@abraham/reflection": "^0.10.0" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | %svelte.head% 9 | 10 | 11 |
%svelte.body%
12 | 13 | 14 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/lib/FormField.spec.ts: -------------------------------------------------------------------------------- 1 | import {fireEvent, render, waitFor} from "@testing-library/svelte"; 2 | import {Context, FormField, SvelteForm} from "./index"; 3 | import {Test} from "./__test__/test.models"; 4 | import type {ExtendedObject} from "./forms/commons/generic-types"; 5 | 6 | describe('FormField', () => { 7 | let form: SvelteForm 8 | let test: ExtendedObject 9 | 10 | const isValid = () => { 11 | let isValid = false 12 | form.isValid.subscribe(current => isValid = current) 13 | return isValid 14 | } 15 | 16 | beforeEach(function () { 17 | form = new SvelteForm(Test, Test.defaults) 18 | form.values.subscribe(current => test = current)() 19 | }); 20 | 21 | it('should render an input field and change the value on input', async () => { 22 | const rendered = render(FormField, {property: test.name, form, classes: 'rounded', placeholder: 'test-input', disabled: undefined}) 23 | const input = rendered.container.querySelector('input') 24 | 25 | await fireEvent.input(input, {target: {value: 'test-changed', name: input.name}}) 26 | expect(form.getRawValues().name).toEqual('test-changed') 27 | expect(isValid()).toBeFalsy() // we didn't render all values 28 | }) 29 | 30 | it('should be dirty if user types', async () => { 31 | const rendered = render(FormField, {property: test.name, form, classes: 'rounded', placeholder: 'test-input', disabled: undefined}) 32 | const input = rendered.container.querySelector('input') 33 | 34 | expect(test.name.dirty).toBeFalsy() 35 | 36 | await fireEvent.input(input, {target: {value: 'test-changed', name: input.name}}) 37 | expect(test.name.dirty).toBeTruthy() 38 | }) 39 | 40 | it('should be touched on blur', async () => { 41 | const rendered = render(FormField, {property: test.name, form, classes: 'rounded', placeholder: 'test-input', disabled: undefined}) 42 | const input = rendered.container.querySelector('input') 43 | 44 | expect(test.name.touched).toBeFalsy() 45 | 46 | await waitFor(() => fireEvent.blur(input), { container: rendered.container }) 47 | expect(test.name.touched).toBeTruthy() 48 | }) 49 | 50 | it('should clear all errors on focus', async () => { 51 | const rendered = render(FormField, {property: test.name, form, classes: 'rounded', placeholder: 'test-input', disabled: undefined}) 52 | const input = rendered.container.querySelector('input') 53 | 54 | expect(test.name.error).toBeUndefined() 55 | 56 | await waitFor(() => fireEvent.blur(input), { container: rendered.container }) 57 | expect(test.name.error).toBeDefined() 58 | 59 | 60 | await waitFor(() => fireEvent.focus(input), { container: rendered.container }) 61 | expect(test.name.error).toBeUndefined() 62 | expect(test.name.dirty).toBeFalsy() 63 | expect(test.name.touched).toBeTruthy() 64 | }) 65 | 66 | }) 67 | -------------------------------------------------------------------------------- /src/lib/FormField.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 24 | -------------------------------------------------------------------------------- /src/lib/__test__/test.models.ts: -------------------------------------------------------------------------------- 1 | import {Field} from "$lib"; 2 | import { array, number, object, string } from "yup"; 3 | 4 | export class Role { 5 | @Field(number()) identified: number 6 | } 7 | export class Test { 8 | static readonly defaults = {name: '', role: { identified: 0 }} 9 | 10 | @Field(string().required()) name = '' 11 | @Field(array().optional(), Role) roles?: Role[] 12 | 13 | @Field(object(), Role) role: Role = { identified: 0 } 14 | } 15 | 16 | -------------------------------------------------------------------------------- /src/lib/forms/commons/constants.ts: -------------------------------------------------------------------------------- 1 | 2 | export const REFLECT_VALIDATION_KEY = 'svelte-forms:validaton' -------------------------------------------------------------------------------- /src/lib/forms/commons/generic-types.ts: -------------------------------------------------------------------------------- 1 | // see: https://stackoverflow.com/a/58436959 2 | import type { ValidationError } from 'yup' 3 | 4 | export type Prototyped = { prototype: T } 5 | 6 | export type ExtendedObject = T extends object ? { [P in keyof T] : ExtendedObject } : V 7 | export type RecordType = Record 8 | export type Context = { 9 | __context: boolean; 10 | __key: string; 11 | error?: ValidationError 12 | touched?: boolean, 13 | dirty?: boolean, 14 | value: string | number | boolean | Record 15 | } 16 | 17 | -------------------------------------------------------------------------------- /src/lib/forms/commons/utils.ts: -------------------------------------------------------------------------------- 1 | 2 | export class Utils { 3 | 4 | static set(object: T, path: string, value: string | number | boolean | Record): T { 5 | const decomposedPath = path.split('.') 6 | const base = decomposedPath[0] 7 | 8 | if (base === undefined) { 9 | return object 10 | } 11 | 12 | if (!object) { 13 | return undefined 14 | } 15 | 16 | // assign an empty object in order to spread object 17 | object[base] = {} 18 | 19 | // Determine if there is still layers to traverse 20 | value = decomposedPath.length <= 1 ? value : Utils.set(object[base], decomposedPath.slice(1).join('.'), value) 21 | return { 22 | ...object, 23 | [base]: value 24 | } 25 | } 26 | 27 | static get(data: P, path: string, defaultValue?: R): P | R { 28 | const value = path.split(/[.[\]]/).filter(Boolean).reduce((value, key) => (value as any)?.[key], data); 29 | return value ? value : defaultValue; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/lib/forms/decorators/field.ts: -------------------------------------------------------------------------------- 1 | import '@abraham/reflection' 2 | import type { ArraySchema, BaseSchema, ObjectSchema } from 'yup' 3 | import { REFLECT_VALIDATION_KEY } from '../commons/constants' 4 | import type { ObjectShape } from 'yup/lib/object' 5 | import { object as yupObject } from 'yup' 6 | 7 | type Prototyped = { prototype: object } 8 | 9 | export const Field = (schema: BaseSchema, objectType?: Prototyped) => (target: object, property: string): void => { 10 | 11 | const type = Reflect.getMetadata<{ prototype: object}>('design:type', target, property) 12 | 13 | if (!Reflect.hasMetadata(REFLECT_VALIDATION_KEY, target)) { 14 | Reflect.defineMetadata(REFLECT_VALIDATION_KEY, {}, target) 15 | } 16 | 17 | if (schema.type === 'object') { 18 | const prototype = type ? type.prototype : objectType?.prototype 19 | if (!prototype) { 20 | throw new Error('It seems like we can not emit decorator types, please pass the type as a second argument. e.g.: @Field') 21 | } 22 | schema = (schema as ObjectSchema).shape(Reflect.getMetadata(REFLECT_VALIDATION_KEY, prototype) as never) 23 | } 24 | 25 | if (schema.type === 'array') { 26 | const prototype = objectType?.prototype 27 | if (!prototype) { 28 | throw new Error('For the arrays, you must give the type explicitly!') 29 | } 30 | 31 | const current = Reflect.getMetadata(REFLECT_VALIDATION_KEY, prototype) as never 32 | schema = (schema as ArraySchema).of(yupObject().shape(current)) 33 | } 34 | const metadata = Reflect.getMetadata(REFLECT_VALIDATION_KEY, target) 35 | metadata[property] = schema 36 | } 37 | -------------------------------------------------------------------------------- /src/lib/forms/svelte-form.spec.ts: -------------------------------------------------------------------------------- 1 | import {Context, SvelteForm} from "$lib"; 2 | import type {ExtendedObject} from "./commons/generic-types"; 3 | import {Test} from "../__test__/test.models"; 4 | 5 | describe('Svelte Form', () => { 6 | const form = new SvelteForm(Test, Test.defaults) 7 | let value: ExtendedObject 8 | form.values.subscribe(current => value = current) 9 | 10 | it('should create a form', () => { 11 | form.onInput({target: {value: 'test', name: 'name'}} as never) 12 | expect(form.getRawValues().name).toEqual('test') 13 | }) 14 | 15 | it('should notify changes', () => { 16 | let value: ExtendedObject 17 | 18 | form.values.subscribe(current => value = current) 19 | form.onInput({target: {value: 'test', name: 'name'}} as never) 20 | 21 | expect(value.name.touched).toBeFalsy() 22 | expect(value.name.dirty).toBeTruthy() 23 | expect(value.name.value).toEqual('test') 24 | }) 25 | 26 | it('should be touched on blur', async () => { 27 | await form.handleBlur({target: {name: 'name'}} as never) 28 | 29 | expect(value.name.touched).toBeTruthy() 30 | expect(value.name.dirty).toBeFalsy() 31 | }) 32 | 33 | it('should clean error on focus', async () => { 34 | let value: ExtendedObject 35 | 36 | form.values.subscribe(current => value = current) 37 | 38 | await form.handleBlur({target: {name: 'name'}} as never) 39 | expect(value.name.error).toBeDefined() 40 | 41 | await form.handleFocus({target: {name: 'name'}} as never) 42 | expect(value.name.error).toBeUndefined() 43 | }) 44 | 45 | it('should have an error if the value is invalid', async () => { 46 | form.onInput({target: {value: 'test', name: 'role.identified'}} as never) 47 | await form.handleBlur({target: {name: 'role.identified'}} as never) 48 | 49 | expect(value.role.identified.error).toBeDefined() 50 | }) 51 | 52 | it('should not have an error if the value is valid', async () => { 53 | form.onInput({target: {value: '0', name: 'role.identified'}} as never) 54 | await form.handleBlur({target: {name: 'role.identified'}} as never) 55 | expect(value.role.identified.error).toBeUndefined() 56 | }) 57 | 58 | it('should not have an error if the value is valid in the array', async () => { 59 | const item = form.fill({name: undefined, role: {identified: 1}, roles: [{ identified: 2 }]}) 60 | form.values.set(item) 61 | 62 | form. onInput({target: {value: '0', name: 'roles[0].identified'}} as never) 63 | await form.handleBlur({target: {name: 'roles[0].identified', value: '0'}} as never) 64 | 65 | expect(value.roles[0].identified.error).toBeUndefined() 66 | expect(form.getRawValues().roles[0].identified).toEqual('0') 67 | }) 68 | 69 | it('should fill the default values', function () { 70 | const item = form.fill({name: 'test', role: {identified: 1}}) 71 | expect(item.role.identified.value).toEqual(1) 72 | expect(item.name.value).toEqual('test') 73 | }) 74 | 75 | it('should fill the values even if a field is undefined', function () { 76 | const item = form.fill({name: undefined, role: {identified: 1}}) 77 | expect(item.role.identified.value).toEqual(1) 78 | expect(item.name).toBeDefined() 79 | expect(item.name.value).toEqual(undefined) 80 | }) 81 | 82 | it('should fill the array values', function () { 83 | form.patch({name: undefined, role: {identified: 1}, roles: [{ identified: 2 }]}) 84 | 85 | const values = form.getRawValues() 86 | expect(values.roles[0].identified).toEqual(2) 87 | }) 88 | }) 89 | -------------------------------------------------------------------------------- /src/lib/forms/svelte-form.ts: -------------------------------------------------------------------------------- 1 | import { Writable, writable } from 'svelte/store' 2 | import type { BaseSchema, ValidationError } from 'yup' 3 | import { object } from 'yup' 4 | import { Utils } from './commons/utils' 5 | import { REFLECT_VALIDATION_KEY } from './commons/constants' 6 | import type { Context, ExtendedObject, Prototyped, RecordType } from '$lib/forms/commons/generic-types' 7 | 8 | export class SvelteForm { 9 | 10 | readonly values: Writable> 11 | readonly isValid: Writable = writable(false) 12 | private readonly schema: BaseSchema 13 | 14 | constructor(target: Prototyped, initialValues?: T) { 15 | this.schema = createValidator(target) 16 | 17 | this.values = writable({} as ExtendedObject) 18 | this.values.update(() => this.fill(initialValues)) 19 | this.values.subscribe(async () => this.isValid.set(await this.schema.isValid(this.getRawValues()))) 20 | } 21 | 22 | validate = () => this.schema.validate(this.getRawValues()) 23 | 24 | patch = (value: T) => { 25 | const context = this.fill(value) 26 | this.values.set(context) 27 | } 28 | 29 | fill = (values: T, parents: string[] = []): ExtendedObject => { 30 | const item = {} as ExtendedObject 31 | Object.keys(values).forEach(key => { 32 | item[key] = {} 33 | if (Array.isArray(values[key]) ) { 34 | item[key] = values[key].map(current => this.fill(current, [key, ...parents])) 35 | } 36 | else if (values[key] !== undefined && values[key] !== null && typeof values[key] === 'object') { 37 | item[key] = this.fill(values[key], [key, ...parents]) 38 | } else { 39 | item[key] = { 40 | __context: true, 41 | __key: parents.length ? `${parents.join('.')}.${key}` : key, 42 | value: values[key] === null ? undefined : values[key] 43 | } as Context 44 | } 45 | }) 46 | return item 47 | } 48 | 49 | handleBlur = async ({target}: { target: HTMLInputElement }): Promise => { 50 | let error: ValidationError | undefined = undefined 51 | try { 52 | await this.schema.validateAt(target.name, this.getRawValues(), {recursive: true}) 53 | } catch (e) { 54 | error = e 55 | } finally { 56 | this.values.update(value => { 57 | const current = Utils.get, Context>(value, target.name) as Context 58 | current.value = target.value 59 | current.touched = true 60 | current.dirty = false 61 | current.error = error 62 | return value 63 | }) 64 | } 65 | } 66 | 67 | handleFocus = ({target}: { target: HTMLInputElement }): void => { 68 | this.values.update(value => { 69 | const current = Utils.get, Context>(value, target.name) as Context 70 | current.value = target.value 71 | current.error = undefined 72 | return value 73 | }) 74 | } 75 | 76 | onInput = ({target}: { target: HTMLInputElement }): void => { 77 | this.values.update(value => { 78 | const current = Utils.get, Context>(value, target.name) as Context 79 | current.value = target.value 80 | current.dirty = true 81 | current.error = undefined 82 | return value 83 | }) 84 | } 85 | 86 | getRawValues = () => { 87 | return this.collectValues(this.updatedValues as never, {} as T) 88 | } 89 | 90 | private collectValues(values: RecordType, actualObject: T): T { 91 | for (const key in values) { 92 | if (Array.isArray(values[key])) { 93 | actualObject[key] = values[key].map(current => this.collectValues(current, actualObject[key] ?? {})) 94 | } 95 | else if (typeof values[key] == 'object') { 96 | if (values[key].__context) { 97 | actualObject[key] = values[key].value 98 | } else { 99 | actualObject[key] = {} 100 | actualObject[key] = this.collectValues(values[key], actualObject[key]) 101 | } 102 | } else if (values[key] && values[key].value) { 103 | actualObject[key] = values[key].value 104 | } 105 | } 106 | return actualObject 107 | } 108 | 109 | private get updatedValues() { 110 | let val: ExtendedObject 111 | this.values.subscribe(current => val = current)() 112 | return val 113 | } 114 | } 115 | 116 | const createValidator = (target: Prototyped): BaseSchema => { 117 | return object().shape({ 118 | ...Reflect.getMetadata(REFLECT_VALIDATION_KEY, target.prototype) 119 | }) as unknown as BaseSchema 120 | } 121 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export { default as FormField } from './FormField.svelte' 2 | export { SvelteForm } from './forms/svelte-form' 3 | export type {Context} from './forms/commons/generic-types' 4 | export { Field } from './forms/decorators/field' 5 | -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-auto'; 2 | import preprocess from 'svelte-preprocess'; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | // Consult https://github.com/sveltejs/svelte-preprocess 7 | // for more information about preprocessors 8 | preprocess: preprocess(), 9 | 10 | kit: { 11 | adapter: adapter() 12 | } 13 | }; 14 | 15 | export default config; 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "module": "es2020", 5 | "lib": [ 6 | "es2020", 7 | "DOM" 8 | ], 9 | "target": "es2020", 10 | "experimentalDecorators": true, 11 | "emitDecoratorMetadata": true, 12 | "importsNotUsedAsValues": "error", 13 | "isolatedModules": true, 14 | "resolveJsonModule": true, 15 | "sourceMap": true, 16 | "esModuleInterop": true, 17 | "skipLibCheck": true, 18 | "forceConsistentCasingInFileNames": true, 19 | "baseUrl": ".", 20 | "allowJs": true, 21 | "checkJs": true, 22 | "paths": { 23 | "$lib": [ 24 | "src/lib" 25 | ], 26 | "$lib/*": [ 27 | "src/lib/*" 28 | ] 29 | } 30 | }, 31 | "include": [ 32 | "src/**/*.d.ts", 33 | "src/**/*.js", 34 | "src/**/*.ts", 35 | "src/**/*.svelte" 36 | ], 37 | "exclude": [ 38 | "src/**/*.spec.ts" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "isolatedModules": false, 5 | "types": [ 6 | "node", 7 | "svelte", 8 | "jest" 9 | ] 10 | }, 11 | "exclude": [ 12 | "src/**" 13 | ], 14 | "include": [ 15 | "src/**/*.spec.ts" 16 | ] 17 | } 18 | --------------------------------------------------------------------------------