├── .editorconfig ├── .eslintrc.json ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .releaserc ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── angular.json ├── cypress.config.ts ├── cypress ├── e2e │ └── app.cy.ts ├── fixtures │ └── example.json ├── helpers │ ├── data.helper.ts │ └── dom.helper.ts ├── support │ ├── commands.ts │ └── e2e.ts └── tsconfig.json ├── package.json ├── prettier.config.js ├── projects └── ngx-sub-form │ ├── .eslintrc.json │ ├── karma.conf.js │ ├── ng-package.json │ ├── package.json │ ├── src │ ├── lib │ │ ├── create-form.ts │ │ ├── helpers.spec.ts │ │ ├── helpers.ts │ │ ├── ngx-sub-form.types.ts │ │ └── shared │ │ │ ├── ngx-sub-form-utils.ts │ │ │ ├── ngx-sub-form.types.spec.ts │ │ │ └── ngx-sub-form.types.ts │ ├── public_api.ts │ └── test.ts │ ├── tsconfig.lib.json │ ├── tsconfig.lib.prod.json │ └── tsconfig.spec.json ├── readme-assets ├── assets-to-build-the-basic-api-usage-picture │ ├── address-control.png │ ├── basic-api-usages.ts │ ├── ngx-sub-form-mini-presentation.psd │ ├── person-container.png │ └── person-form.png └── basic-api-usage.png ├── src ├── .browserslistrc ├── app │ ├── app.component.html │ ├── app.component.scss │ ├── app.component.ts │ ├── app.module.ts │ ├── interfaces │ │ ├── crew-member.interface.ts │ │ ├── droid.interface.ts │ │ ├── listing.interface.ts │ │ └── vehicle.interface.ts │ ├── main │ │ ├── listing │ │ │ ├── listing-form │ │ │ │ ├── droid-listing │ │ │ │ │ ├── assassin-droid │ │ │ │ │ │ ├── assassin-droid.component.html │ │ │ │ │ │ ├── assassin-droid.component.scss │ │ │ │ │ │ └── assassin-droid.component.ts │ │ │ │ │ ├── astromech-droid │ │ │ │ │ │ ├── astromech-droid.component.html │ │ │ │ │ │ ├── astromech-droid.component.scss │ │ │ │ │ │ └── astromech-droid.component.ts │ │ │ │ │ ├── droid-product.component.html │ │ │ │ │ ├── droid-product.component.scss │ │ │ │ │ ├── droid-product.component.ts │ │ │ │ │ ├── medical-droid │ │ │ │ │ │ ├── medical-droid.component.html │ │ │ │ │ │ ├── medical-droid.component.scss │ │ │ │ │ │ └── medical-droid.component.ts │ │ │ │ │ └── protocol-droid │ │ │ │ │ │ ├── protocol-droid.component.html │ │ │ │ │ │ ├── protocol-droid.component.scss │ │ │ │ │ │ └── protocol-droid.component.ts │ │ │ │ ├── listing-form.component.html │ │ │ │ ├── listing-form.component.scss │ │ │ │ ├── listing-form.component.ts │ │ │ │ ├── test-types.ts │ │ │ │ └── vehicle-listing │ │ │ │ │ ├── crew-members │ │ │ │ │ ├── crew-member │ │ │ │ │ │ ├── crew-member.component.html │ │ │ │ │ │ ├── crew-member.component.scss │ │ │ │ │ │ └── crew-member.component.ts │ │ │ │ │ ├── crew-members.component.html │ │ │ │ │ ├── crew-members.component.scss │ │ │ │ │ └── crew-members.component.ts │ │ │ │ │ ├── spaceship │ │ │ │ │ ├── spaceship.component.html │ │ │ │ │ ├── spaceship.component.scss │ │ │ │ │ └── spaceship.component.ts │ │ │ │ │ ├── speeder │ │ │ │ │ ├── speeder.component.html │ │ │ │ │ ├── speeder.component.scss │ │ │ │ │ └── speeder.component.ts │ │ │ │ │ ├── vehicle-product.component.html │ │ │ │ │ ├── vehicle-product.component.scss │ │ │ │ │ └── vehicle-product.component.ts │ │ │ ├── listing.component.html │ │ │ ├── listing.component.scss │ │ │ └── listing.component.ts │ │ ├── listings │ │ │ ├── display-crew-members.pipe.ts │ │ │ ├── listings.component.html │ │ │ ├── listings.component.scss │ │ │ └── listings.component.ts │ │ ├── main.component.html │ │ ├── main.component.scss │ │ ├── main.component.ts │ │ └── main.module.ts │ ├── services │ │ ├── listing.service.ts │ │ ├── listings.data.ts │ │ └── uuid.service.ts │ └── shared │ │ ├── shared.module.ts │ │ └── utils.ts ├── assets │ ├── .gitkeep │ └── ewok-no-bg.png ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── favicon.ico ├── index.html ├── karma.conf.js ├── main.ts ├── polyfills.ts ├── styles.scss ├── test.ts ├── tsconfig.app.json ├── tsconfig.e2e.json ├── tsconfig.spec.json └── tslint.json ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": ["projects/**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts"], 7 | "parserOptions": { 8 | "project": ["tsconfig.json", "e2e/tsconfig.json"], 9 | "createDefaultProgram": true 10 | }, 11 | "extends": ["plugin:@angular-eslint/template/process-inline-templates", "prettier"], 12 | "rules": { 13 | // @todo: restore this one once the one below to turn it off temporarily is there 14 | // "@typescript-eslint/consistent-type-definitions": "error", 15 | "@typescript-eslint/dot-notation": "off", 16 | "@typescript-eslint/explicit-member-accessibility": [ 17 | "off", 18 | { 19 | "accessibility": "explicit" 20 | } 21 | ], 22 | "@typescript-eslint/no-unused-expressions": "off", 23 | "id-blacklist": "off", 24 | "id-match": "off", 25 | "no-underscore-dangle": "off", 26 | // @todo: restore following 27 | // during the migration to ng 13, did remove tslint in favor of eslint 28 | // but don't want to have to deal with loads of errors during the upgrade 29 | "@typescript-eslint/naming-convention": "off", 30 | "@typescript-eslint/ban-types": "off", 31 | "@typescript-eslint/member-ordering": "off", 32 | "prefer-arrow/prefer-arrow-functions": "off", 33 | "object-shorthand": "off", 34 | "@typescript-eslint/no-empty-interface": "off", 35 | "prefer-const": "off", 36 | "arrow-body-style": "off" 37 | } 38 | }, 39 | { 40 | "files": ["*.html"], 41 | "extends": ["plugin:@angular-eslint/template/recommended"], 42 | "rules": {} 43 | } 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # GitHub Actions docs 2 | # https://help.github.com/en/articles/about-github-actions 3 | # https://help.github.com/en/articles/workflow-syntax-for-github-actions 4 | name: CI 5 | 6 | on: [push] 7 | 8 | jobs: 9 | build: 10 | # Machine environment: 11 | # https://help.github.com/en/articles/software-in-virtual-environments-for-github-actions#ubuntu-1804-lts 12 | # We specify the Node.js version manually below, and use versioned Chrome from Puppeteer. 13 | runs-on: ubuntu-20.04 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: 22.12.x 20 | - name: Install dependencies 21 | run: yarn --frozen-lockfile --non-interactive --no-progress 22 | - name: Lint Demo 23 | run: yarn demo:lint:check 24 | - name: Format check 25 | run: yarn prettier:check 26 | - name: Check Readme 27 | run: yarn readme:check 28 | - name: Test 29 | run: yarn lib:test:ci 30 | - name: Build Lib 31 | run: yarn lib:build:prod 32 | - name: Copy built README & LICENCE into dist 33 | run: cp README.md LICENSE dist/ngx-sub-form 34 | - name: Build Demo 35 | run: yarn run demo:build:prod --progress=false --base-href "https://cloudnc.github.io/ngx-sub-form/" 36 | - name: Cypress run 37 | uses: cypress-io/github-action@v6 38 | with: 39 | browser: chrome 40 | start: yarn start-e2e-file-server 41 | wait-on: http://localhost:4765/ 42 | wait-on-timeout: 500 43 | - name: Deploy 44 | if: contains('refs/heads/master', github.ref) 45 | uses: peaceiris/actions-gh-pages@v3 46 | with: 47 | github_token: ${{ secrets.GITHUB_TOKEN }} 48 | publish_dir: ./dist/ngx-sub-form-demo 49 | - name: Release 50 | if: contains('refs/heads/master refs/heads/next refs/heads/feat-upgrade-angular-15', github.ref) 51 | env: 52 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 53 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 54 | run: npx semantic-release 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | 8 | # dependencies 9 | /node_modules 10 | 11 | # IDEs and editors 12 | /.idea 13 | .project 14 | .classpath 15 | .c9/ 16 | *.launch 17 | .settings/ 18 | *.sublime-workspace 19 | .history 20 | 21 | # IDE - VSCode 22 | .vscode/* 23 | !.vscode/settings.json 24 | !.vscode/tasks.json 25 | !.vscode/launch.json 26 | !.vscode/extensions.json 27 | 28 | # misc 29 | /.angular/cache 30 | /.sass-cache 31 | /connect.lock 32 | /coverage 33 | /libpeerconnection.log 34 | npm-debug.log 35 | yarn-error.log 36 | testem.log 37 | /typings 38 | 39 | # System Files 40 | .DS_Store 41 | Thumbs.db 42 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v22.12.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | dist 3 | node_modules 4 | .history 5 | .angular 6 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "pkgRoot": "dist/ngx-sub-form", 3 | "branches": [ 4 | "master", 5 | { 6 | "name": "feat-upgrade-angular-15", 7 | "channel": "feat-upgrade-angular-15", 8 | "prerelease": true 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Please note we have a code of conduct, please follow it in all your interactions with the project. 4 | 5 | ## Pull Request Process 6 | 7 | 1. Before making any pull request, please first discuss the change you mish to make via issue with at least one of the maintainers 8 | 2. Keep your pull request as small as possible and focused on one `feature`, `fix`, etc 9 | 3. Fork the project to your own repository 10 | 4. Clone your own version of `ngx-sub-form` 11 | 5. Branch off master and prefix the branch name with either `fix`, `feat`, `chore`, `docs`, `style`, `refactor` or `test`: `git checkout master && git pull && git checkout -b feat/your-feature-name` for e.g. 12 | 6. Make your changes 13 | 7. Edit, remove or add relevant tests (either unit/integration or E2E with cypress) 14 | 8. Run the tests locally by running `yarn run lib:test:watch` and `yarn run demo:test:e2e:watch` 15 | 9. Run linting by running `yarn run lint` 16 | 10. Update the `README.md` accordingly to your changes if needed 17 | 11. Run `Prettier` on the project: `yarn run prettier:write` 18 | 12. Once you're done and ready to make a commit, please follow those conventions: https://github.com/angular/angular/blob/master/CONTRIBUTING.md#commit and don't forget to close the related issue either in your commit or the pull request header by writting `This closes cloudnc/ngx-sub-form#X` where `X` is the issue number 19 | 13. Push your branch to your own repository 20 | 14. Raise a pull request on `ngx-sub-form` 21 | 15. If any change is asked on the pull request, try to keep a clean history as much as possible by either using `git rebase` or `git commit --amend` (talk to us on your pull request if you're unsure) 22 | 23 | ## Code of Conduct 24 | 25 | ### Our Pledge 26 | 27 | In the interest of fostering an open and welcoming environment, we as 28 | contributors and maintainers pledge to making participation in our project and 29 | our community a harassment-free experience for everyone, regardless of age, body 30 | size, disability, ethnicity, gender identity and expression, level of experience, 31 | nationality, personal appearance, race, religion, or sexual identity and 32 | orientation. 33 | 34 | ### Our Standards 35 | 36 | Examples of behavior that contributes to creating a positive environment 37 | include: 38 | 39 | - Using welcoming and inclusive language 40 | - Being respectful of differing viewpoints and experiences 41 | - Gracefully accepting constructive criticism 42 | - Focusing on what is best for the community 43 | - Showing empathy towards other community members 44 | 45 | Examples of unacceptable behavior by participants include: 46 | 47 | - The use of sexualized language or imagery and unwelcome sexual attention or 48 | advances 49 | - Trolling, insulting/derogatory comments, and personal or political attacks 50 | - Public or private harassment 51 | - Publishing others' private information, such as a physical or electronic 52 | address, without explicit permission 53 | - Other conduct which could reasonably be considered inappropriate in a 54 | professional setting 55 | 56 | ### Our Responsibilities 57 | 58 | Project maintainers are responsible for clarifying the standards of acceptable 59 | behavior and are expected to take appropriate and fair corrective action in 60 | response to any instances of unacceptable behavior. 61 | 62 | Project maintainers have the right and responsibility to remove, edit, or 63 | reject comments, commits, code, wiki edits, issues, and other contributions 64 | that are not aligned to this Code of Conduct, or to ban temporarily or 65 | permanently any contributor for other behaviors that they deem inappropriate, 66 | threatening, offensive, or harmful. 67 | 68 | ### Scope 69 | 70 | This Code of Conduct applies both within project spaces and in public spaces 71 | when an individual is representing the project or its community. Examples of 72 | representing a project or community include using an official project e-mail 73 | address, posting via an official social media account, or acting as an appointed 74 | representative at an online or offline event. Representation of a project may be 75 | further defined and clarified by project maintainers. 76 | 77 | ### Enforcement 78 | 79 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 80 | reported by contacting the project team. All 81 | complaints will be reviewed and investigated and will result in a response that 82 | is deemed necessary and appropriate to the circumstances. The project team is 83 | obligated to maintain confidentiality with regard to the reporter of an incident. 84 | Further details of specific enforcement policies may be posted separately. 85 | 86 | Project maintainers who do not follow or enforce the Code of Conduct in good 87 | faith may face temporary or permanent repercussions as determined by other 88 | members of the project's leadership. 89 | 90 | ### Attribution 91 | 92 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 93 | available at [http://contributor-covenant.org/version/1/4][version] 94 | 95 | [homepage]: http://contributor-covenant.org 96 | [version]: http://contributor-covenant.org/version/1/4/ 97 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 CloudNC Ltd 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 | # NgxSubForm 2 | 3 | ![ngx-sub-form logo](https://user-images.githubusercontent.com/4950209/53812385-45f48900-3f53-11e9-8687-b57cd335f26e.png) 4 | 5 | Utility library to improve the robustness of your Angular forms. 6 | 7 | Whether you have simple and tiny forms or huge and complex ones, `ngx-sub-form` will help you build a solid base for them. 8 | 9 | - ✅ Simple API: No angular module to setup, no `ControlValueAccessor` by hand, no inheritance, no boilerplate. Only one function to create all your forms! 10 | - 🤖 Adds type safety to your forms 11 | - ✂️ Lets you break down huge forms into smaller ones for simplicity and reusability 12 | 13 | _Please note one thing: If your goal is to generate forms dynamically (based on some JSON configuration for example) `ngx-sub-form` is **not** here for that!_ 14 | 15 | # Table of contents 16 | 17 | - [Basic API usage](#basic-api-usage) 18 | - [Setup](#setup) 19 | - [Migration guide to the new API](#migration-guide-to-the-new-api) 20 | - [Principles](#principles) 21 | - [Root forms](#root-forms) 22 | - [Sub forms](#sub-forms) 23 | - [Remap](#remap) 24 | - [Dealing with arrays](#dealing-with-arrays) 25 | - [Contribution](#contribution) 26 | - [Tell us about your experience with ngx-sub-form](#tell-us-about-your-experience-with-ngx-sub-form) 27 | 28 | # Basic API usage 29 | 30 | As a picture is often worth a 1000 words, let's take a quick overlook at the API before explaining in details further on: 31 | 32 | ![basic-api-usage](https://user-images.githubusercontent.com/4950209/110140660-9cac2c00-7dd4-11eb-8dc1-421089c5c016.png) 33 | 34 | # Setup 35 | 36 | `ngx-sub-form` is available on [NPM](https://www.npmjs.com/package/ngx-sub-form): 37 | 38 | ``` 39 | npm i ngx-sub-form 40 | ``` 41 | 42 | **Note about the versions:** 43 | 44 | | `@angular` version | `ngx-sub-form` version | 45 | | -------------------- | ---------------------------------------------------- | 46 | | v <= `7` | v <= `2.7.1` | 47 | | `8.x` | `4.x` | 48 | | `9.x` <= v <= `12.x` | `5.1.2` | 49 | | `13.x` | `5.2.0` (non breaking but new API available as well) | 50 | | `14.x` | `6.0.0` (Angular 14 upgrade only) | 51 | | `14.x` | `7.0.0` (deprecated API is now removed) | 52 | | `15.x` | `8.0.0` | 53 | | `16.x` | `9.0.0` | 54 | | `17.x` | `10.0.0` | 55 | | `18.x` | `11.0.0` | 56 | | `19.x` | `12.0.0` | 57 | 58 | # API 59 | 60 | There's one function available to create all your forms: `createForm`. 61 | 62 | This function takes as parameter a configuration object and returns an object ready to be used to use your form and all its new utilities. In this section we'll discover what configuration we can pass to `createForm` and what exactly we'll be getting back. 63 | 64 | ## `createForm` configuration object: 65 | 66 | 67 | 68 | | Key | Type | Optional or required | Root form | Sub form | What is it for? | 69 | | ----------------------- | --------------------------------------------------------------------------- | -------------------- | --------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 70 | | `formType` | `FormType` | Required | ✅ | ✅ | Defines the type of the form. Can either be `FormType.ROOT` or `FormType.SUB` | 71 | | `disabled$` | `Observable` | Required | ✅ | ❌ | When this observable emits `true`, the whole form (including the root form and all the sub forms) will be disabled | 72 | | `input$` | `Observable` | Required | ✅ | ❌ | A root form is a component in between the parent passing raw data and the form itself. This property is an observable that you must provide which will be used behind the scenes to update for you the form values | 73 | | `output$` | `Subject` | Required | ✅ | ❌ | A root form is a component in between the parent passing raw data and the form itself. This property is an observable that you must provide which will be used behind the scenes to broadcast the form value to the parent when it changes | 74 | | `manualSave$` | `Observable` | Optional | ✅ | ❌ | By default a root form will automatically broadcast all the form updates (through the `output$`) as soon as there's a change. If you wish to "save" the form only when you click on a save button for example, you can create a subject on your side and pass it here. Whenever you call `next` on your subject, assuming the form is valid, it'll broadcast te form value to the parent (through the `output$`) | 75 | | `outputFilterPredicate` | `(currentInputValue: FormInterface, outputValue: FormInterface) => boolean` | Optional | ✅ | ❌ | The default behaviour is to compare the current transformed value of `input$` with the current value of the form _(deep check)_, and if these are equal, the value won't be passed to `output$` in order to prevent the broadcast | 76 | | `handleEmissionRate` | `(obs$: Observable) => Observable` | Optional | ✅ | ❌ | If you want to control how frequently the form emits on the `output$`, you can customise the emission rate with this. Example: `handleEmissionRate: formValue$ => formValue$.pipe(debounceTime(300))` | 77 | 78 | # Principles 79 | 80 | As simple as forms can look when they only have a few fields, their complexity can increase quite quickly. In order to keep your code as simple as possible and isolate the different concepts, **we do recommend to write forms in complete isolation from the rest of your app**. 81 | 82 | In order to do so, you can create some top level forms that we call "**root forms**". As one form can become bigger and bigger over time, we also help by letting you create "**sub forms**" _(without the pain of dealing manually with a [ControlValueAccessor](https://angular.io/api/forms/ControlValueAccessor)!)_. Let's dig into their specifics, how they differ and how to use them. 83 | 84 | ## Root forms 85 | 86 | Root forms let you isolate a form from the rest of your app. 87 | You can encapsulate them and (pretty much) never have to deal with `patchValue` or `setValue` to update the form nor subscribe to `valueChanges` to listen to the updates. 88 | 89 | Instead, you'll be able to create a dedicated **form component and pass data using an input, receive updates using an output**. Just like you would with a dumb component. 90 | 91 | Let's have a look with a very simple workflow: 92 | 93 | - Imagine an application with a list of people and when you click on one of them you can edit the person details 94 | - A smart component is aware of the currently selected person (our _"container component"_) 95 | - A root form component lets us display the data we retrieved in a form and also edit them 96 | 97 | In this scenario, the smart component could look like the following: 98 | 99 | ```ts 100 | @Component({ 101 | selector: 'person-container', 102 | template: ` 103 | 104 | `, 105 | }) 106 | export class PersonContainer { 107 | public person$: Observable = this.personService.person$; 108 | 109 | constructor(private personService: PersonService) {} 110 | 111 | public personUpdate(person: Person): void { 112 | this.personService.update(person); 113 | } 114 | } 115 | ``` 116 | 117 | This component is only responsible to get the correct data and manage updates _(if any)_. It completely delegates to the root form: 118 | 119 | - How the data will be displayed to the user as a form 120 | - How the user will interact with them 121 | 122 | Now let's talk about the actual **root form**: 123 | 124 | ```ts 125 | @Component({ 126 | selector: 'person-form', 127 | template: ` 128 |
129 | 130 | 131 | 132 |
133 | `, 134 | }) 135 | export class PersonForm { 136 | private input$: Subject = new Subject(); 137 | @Input() set person(person: Person | undefined) { 138 | this.input$.next(person); 139 | } 140 | 141 | private disabled$: Subject = new Subject(); 142 | @Input() set disabled(value: boolean | undefined) { 143 | this.disabled$.next(!!value); 144 | } 145 | 146 | @Output() personUpdate: Subject = new Subject(); 147 | 148 | public form = createForm(this, { 149 | formType: FormType.ROOT, 150 | disabled$: this.disabled$, 151 | input$: this.input$, 152 | output$: this.personUpdate, 153 | formControls: { 154 | id: new FormControl(null, Validators.required), 155 | firstName: new FormControl(null, Validators.required), 156 | lastName: new FormControl(null, Validators.required), 157 | address: new FormControl(null, Validators.required), 158 | }, 159 | }); 160 | } 161 | ``` 162 | 163 | We'll go through the example above bit by bit. 164 | 165 | ```ts 166 | public form = createForm(this, { 167 | formType: FormType.ROOT, 168 | disabled$: this.disabled$, 169 | input$: this.input$, 170 | output$: this.personUpdate, 171 | formControls: { 172 | id: new FormControl(null, Validators.required), 173 | firstName: new FormControl(null, Validators.required), 174 | lastName: new FormControl(null, Validators.required), 175 | address: new FormControl(null, Validators.required), 176 | }, 177 | }); 178 | ``` 179 | 180 | This is what we provide to create a form with `ngx-sub-form`: 181 | 182 | - A type _(either `FormType.ROOT` or `FormType.SUB`)_ 183 | - A `disabled$` stream to know whether we should disable the whole form or not _(including all the sub forms as well)_ 184 | - An `input$` stream which is the data we'll use to update the form 185 | - An `output$` stream, which would usually be our `EventEmitter` so that a parent component can listen to the form update through an output 186 | - The `formControls`, which is exactly what you'd pass when creating a `FormGroup` 187 | 188 | One thing to note: The `createForm` function takes a generic which will let you **type our form**. In this case, if you forgot to pass a property of the form in the `formControls` it'd be caught at build time by Typescript. 189 | 190 | ```ts 191 | private input$: Subject = new Subject(); 192 | @Input() set person(person: Person | undefined) { 193 | this.input$.next(person); 194 | } 195 | 196 | private disabled$: Subject = new Subject(); 197 | @Input() set disabled(value: boolean | undefined) { 198 | this.disabled$.next(!!value); 199 | } 200 | ``` 201 | 202 | This is simply a way of binding an input to an observable. We do this because the `createForm` function requires us to pass an `input$` stream and a `disabled$` one. Hopefully Angular lets us one day access [inputs as observables natively](https://github.com/angular/angular/issues/5689). In the meantime if you want to reduce this boilerplate even further, you can search on NPM for libraries which are doing this already. It's not as good as what Angular could do if it was built in, but it's still useful. 203 | 204 | ```ts 205 | @Output() personUpdate: Subject = new Subject(); 206 | ``` 207 | 208 | This is an `Output`. It could be an `EventEmitter` if you prefer a "classic" way of creating an output but really all we need is a `Subject` so that internally, the `createForm` function is able to push the form value whenever it's been updated. 209 | 210 | Finally, our template: 211 | 212 | ```html 213 |
214 | 215 | 216 | 217 |
218 | ``` 219 | 220 | Our `createForm` function will return an object of type `NgxRootForm`. It means we'll then have access to the following properties: 221 | 222 | - **`formGroup`**: The `FormGroup` instance with augmented capacity for type safety. While at runtime this object is really the form group itself, it is now defined as a `TypedFormGroup` which provides type safety on a bunch of attributes and methods (`value`, `valueChanges`, `controls`, `setValue`, `patchValue`, `getRawValue`). If you want to know more about the `TypedFormGroup` interface, have a look in `projects/ngx-sub-form/src/lib/shared/ngx-sub-form-utils.ts` 223 | - **`formControlNames`**: A typed object containing our form control names. The advantage of using this instead of a simple string is in case you ever update the type passed as the generic of the form _(through a refactor or a change in the API upstream, etc)_. If you remove or update an existing property and forget to update the template, Typescript will catch the error _(assuming you're using AoT which is the case by default)_ 224 | - **`formGroupErrors`**: An object holding all the errors in the form. Bonus point: It also includes all the nested errors from the sub forms! 225 | - **`controlValue$`**: If you want to listen to the form value, just use `form.formGroup.valueChanges`. But keep in mind that it will not be triggered when the form is being updated by the parent ⚠️. It'll only be triggered when the form is changed locally. If you want to know what's the latest form value from either the parent OR the local changes, you should use `form.controlValue$` instead 226 | - **`createFormArrayControl`**: We'll cover this one in the [remap](#remap) section, after the sub forms 227 | 228 | ## Sub forms 229 | 230 | When you've got a form represented by an object containing not one level of info but multiple ones _(like a person which has an address, the address contains itself multiple fields)_, you should create a sub form to manage the `address` in isolation. 231 | 232 | This is great for a couple of reasons: 233 | 234 | - You can break down the complexity of your forms into smaller components 235 | - You can reuse sub forms into other sub forms and root forms. It becomes easy to compose different bits of sub forms to create a bigger one 236 | 237 | Here's a full example: 238 | 239 | ```ts 240 | @Component({ 241 | selector: 'address-control', 242 | template: ` 243 |
244 | 245 | 246 | 247 | 248 |
249 | `, 250 | providers: subformComponentProviders(PersonForm), 251 | }) 252 | export class PersonForm { 253 | public form = createForm
(this, { 254 | formType: FormType.SUB, 255 | formControls: { 256 | street: new FormControl(null, Validators.required), 257 | city: new FormControl(null, Validators.required), 258 | state: new FormControl(null, Validators.required), 259 | zipCode: new FormControl(null, Validators.required), 260 | }, 261 | }); 262 | } 263 | ``` 264 | 265 | A sub form looks very much like a root form but with an API that is even simpler. 266 | When you call the `createForm` function, start by setting the `formType` to `FormType.SUB` and then define your `formControls`. 267 | 268 | One important thing to note: 269 | 270 | ```ts 271 | providers: subformComponentProviders(PersonForm); 272 | ``` 273 | 274 | `subformComponentProviders` is only here to help reduce the number of lines needed for each sub form component. It returns the following providers: 275 | 276 | ```ts 277 | return [ 278 | { 279 | provide: NG_VALUE_ACCESSOR, 280 | useExisting: component, 281 | multi: true, 282 | }, 283 | { 284 | provide: NG_VALIDATORS, 285 | useExisting: component, 286 | multi: true, 287 | }, 288 | ]; 289 | ``` 290 | 291 | Behind the scenes those providers are allowing us to have a component considered as a [ControlValueAccessor](https://angular.io/api/forms/ControlValueAccessor). 292 | If you've ever created a `ControlValueAccessor` yourself, you can probably appreciate the amount of boilerplate `ngx-sub-form` is removing while adding features on top of it. 293 | 294 | Just like the root form, the `createForm` function will return an object containing the following: 295 | 296 | - `formGroup` 297 | - `formControlNames` 298 | - `formGroupErrors` 299 | - `createFormArrayControl` 300 | - `controlValue$` 301 | 302 | As they're exactly the same as the ones in the root form we're not going to go over them again, feel free to check the previous section. 303 | 304 | ## Remap 305 | 306 | Sometimes a given data structure may not match the one you'd like to have internally for a form. When that's the case, `ngx-sub-form` offers 2 functions to: 307 | 308 | - Take the input value and remap it to match the shape expected by the form 309 | - Take the form value and remap it to match the shape expected as the output 310 | 311 | Here are the 2 interfaces: 312 | 313 | - `toFormGroup: (obj: ControlInterface) => FormInterface;` 314 | - `fromFormGroup: (formValue: FormInterface) => ControlInterface;` 315 | 316 | Example of a remap could be getting a date object that you want to convert to an ISO string date before passing that value to a date picker and before broadcasting that value back to the parent, convert it back to a date. Or vice versa. 317 | 318 | A really interesting use case is to deal with polymorphic values. If we take the example of our live demo: https://cloudnc.github.io/ngx-sub-form we've got `src/app/main/listing/listing-form/listing-form.component.ts`. This form can receive either a `vehicle` or a `droid`. While polymorphism works great on typescript side, when it comes to templates... It's an other story! The best way is to have 2 sub components, which will handle 1 and 1 thing: Either a `vehicle` **or** a `droid`. And in the template use an `ngIf` or an `ngSwitch` to dynamically create the expected sub form. 319 | That said, to be able to `switch` on a value, we need to know that value: A discriminator. It'll let us know what's the type of our current object really easily, without having to create a [type guard](https://www.typescriptlang.org/docs/handbook/advanced-types.html#type-guards-and-differentiating-types) for example. And a remap is a perfect candidate for this. If you want a full example please have a look to the `listing-form.component.ts` _(path shown above)_. 320 | 321 | ## Dealing with arrays 322 | 323 | When your data structure contains one or more arrays, you may want to simply display the values in the view but chances are you want to bind them to the form. 324 | 325 | In that case, working with a `FormArray` is the right way to go and for that, we will take advantage of the remap principles explained in the previous section. 326 | 327 | If you have custom validations to set on the form controls, you can implement the `createFormArrayControl` function, which gives the library a hook with which to construct new form controls for the form array with the correct validators applied. 328 | 329 | Its definition is the following: 330 | 331 | ```ts 332 | createFormArrayControl(key, value) => FormControl; 333 | ``` 334 | 335 | Where key is a key of your main form and value, its associated value. 336 | 337 | To see a complete example please refer to `src/app/main/listing/listing-form/vehicle-listing/crew-members/crew-members.component.ts` and its `html` part. 338 | 339 | # Contribution 340 | 341 | Please, feel free to contribute to `ngx-sub-form`. 342 | We've done our best to come up with a solution that helped us and our own needs when dealing with forms. But we might have forgotten some use cases that might be worth implementing in the core or the lib rather than on every project. 343 | Remember that contributing doesn't necessarily mean to make a pull request, you can raise an issue, edit the documentation (readme), etc. 344 | 345 | # Tell us about your experience with ngx-sub-form 346 | 347 | We'd love to know more about who's using ngx-sub-form in production and on what kind of project! We've created an [issue where everyone can share more about their experience](https://github.com/cloudnc/ngx-sub-form/issues/112). 348 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "ngx-sub-form-demo": { 7 | "root": "", 8 | "sourceRoot": "src", 9 | "projectType": "application", 10 | "prefix": "app", 11 | "schematics": { 12 | "@schematics/angular:component": { 13 | "style": "scss" 14 | } 15 | }, 16 | "architect": { 17 | "build": { 18 | "builder": "@angular-devkit/build-angular:application", 19 | "options": { 20 | "outputPath": { 21 | "base": "dist/ngx-sub-form-demo" 22 | }, 23 | "index": "src/index.html", 24 | "polyfills": ["src/polyfills.ts"], 25 | "tsConfig": "src/tsconfig.app.json", 26 | "assets": ["src/favicon.ico", "src/assets"], 27 | "styles": ["src/styles.scss"], 28 | "scripts": [], 29 | "extractLicenses": false, 30 | "sourceMap": true, 31 | "optimization": false, 32 | "namedChunks": true, 33 | "browser": "src/main.ts" 34 | }, 35 | "configurations": { 36 | "production": { 37 | "fileReplacements": [ 38 | { 39 | "replace": "src/environments/environment.ts", 40 | "with": "src/environments/environment.prod.ts" 41 | } 42 | ], 43 | "optimization": true, 44 | "outputHashing": "all", 45 | "sourceMap": false, 46 | "namedChunks": false, 47 | "extractLicenses": true, 48 | "budgets": [ 49 | { 50 | "type": "initial", 51 | "maximumWarning": "2mb", 52 | "maximumError": "5mb" 53 | }, 54 | { 55 | "type": "anyComponentStyle", 56 | "maximumWarning": "6kb" 57 | } 58 | ] 59 | } 60 | }, 61 | "defaultConfiguration": "" 62 | }, 63 | "serve": { 64 | "builder": "@angular-devkit/build-angular:dev-server", 65 | "options": { 66 | "buildTarget": "ngx-sub-form-demo:build" 67 | }, 68 | "configurations": { 69 | "production": { 70 | "buildTarget": "ngx-sub-form-demo:build:production" 71 | } 72 | } 73 | }, 74 | "extract-i18n": { 75 | "builder": "@angular-devkit/build-angular:extract-i18n", 76 | "options": { 77 | "buildTarget": "ngx-sub-form-demo:build" 78 | } 79 | }, 80 | "test": { 81 | "builder": "@angular-devkit/build-angular:karma", 82 | "options": { 83 | "main": "src/test.ts", 84 | "polyfills": "src/polyfills.ts", 85 | "tsConfig": "src/tsconfig.spec.json", 86 | "karmaConfig": "src/karma.conf.js", 87 | "styles": ["src/styles.scss"], 88 | "scripts": [], 89 | "assets": ["src/favicon.ico", "src/assets"] 90 | } 91 | }, 92 | "lint": { 93 | "builder": "@angular-eslint/builder:lint", 94 | "options": { 95 | "lintFilePatterns": ["src/**/*.ts", "src/**/*.html"] 96 | } 97 | } 98 | } 99 | }, 100 | "ngx-sub-form": { 101 | "root": "projects/ngx-sub-form", 102 | "sourceRoot": "projects/ngx-sub-form/src", 103 | "projectType": "library", 104 | "prefix": "lib", 105 | "architect": { 106 | "build": { 107 | "builder": "@angular-devkit/build-angular:ng-packagr", 108 | "options": { 109 | "tsConfig": "projects/ngx-sub-form/tsconfig.lib.json", 110 | "project": "projects/ngx-sub-form/ng-package.json" 111 | }, 112 | "configurations": { 113 | "production": { 114 | "tsConfig": "projects/ngx-sub-form/tsconfig.lib.prod.json" 115 | } 116 | } 117 | }, 118 | "test": { 119 | "builder": "@angular-devkit/build-angular:karma", 120 | "options": { 121 | "main": "projects/ngx-sub-form/src/test.ts", 122 | "tsConfig": "projects/ngx-sub-form/tsconfig.spec.json", 123 | "karmaConfig": "projects/ngx-sub-form/karma.conf.js" 124 | } 125 | }, 126 | "lint": { 127 | "builder": "@angular-eslint/builder:lint", 128 | "options": { 129 | "lintFilePatterns": ["projects/ngx-sub-form/**/*.ts", "projects/ngx-sub-form/**/*.html"] 130 | } 131 | } 132 | } 133 | } 134 | }, 135 | "cli": { 136 | "schematicCollections": ["@angular-eslint/schematics"] 137 | }, 138 | "schematics": { 139 | "@angular-eslint/schematics:application": { 140 | "setParserOptionsProject": true 141 | }, 142 | "@angular-eslint/schematics:library": { 143 | "setParserOptionsProject": true 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress'; 2 | 3 | export default defineConfig({ 4 | video: false, 5 | viewportWidth: 1500, 6 | viewportHeight: 1400, 7 | 8 | e2e: { 9 | baseUrl: 'http://localhost:4765', 10 | setupNodeEvents(on, config) { 11 | // implement node event listeners here 12 | }, 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /cypress/e2e/app.cy.ts: -------------------------------------------------------------------------------- 1 | import { extractErrors, FormElement, hardcodedElementsToTestList } from '../../cypress/helpers/data.helper'; 2 | import { DOM, getFormList, getFormValue } from '../../cypress/helpers/dom.helper'; 3 | import { DroidType } from '../../src/app/interfaces/droid.interface'; 4 | import { ListingType, VehicleListing } from '../../src/app/interfaces/listing.interface'; 5 | import { Spaceship, VehicleType } from '../../src/app/interfaces/vehicle.interface'; 6 | import { hardCodedListings } from '../../src/app/services/listings.data'; 7 | 8 | context(`EJawa demo`, () => { 9 | beforeEach(() => { 10 | cy.visit(''); 11 | }); 12 | 13 | it(`should have a default list displayed`, () => { 14 | DOM.list.elements.cy.should($el => { 15 | expect(getFormList($el)).to.eql(hardcodedElementsToTestList(hardCodedListings)); 16 | }); 17 | }); 18 | 19 | it(`should click on the first element and display its data in the form`, () => { 20 | DOM.list.elements.cy.first().click(); 21 | 22 | const x = hardCodedListings[0] as VehicleListing; 23 | const v = x.product as Spaceship; 24 | 25 | const expectedObj: FormElement = { 26 | title: x.title, 27 | price: '£' + x.price.toLocaleString(), 28 | inputs: { 29 | id: x.id, 30 | title: x.title, 31 | imageUrl: x.imageUrl, 32 | price: x.price + '', 33 | listingType: x.listingType, 34 | vehicleForm: { 35 | vehicleType: x.product.vehicleType, 36 | spaceshipForm: { 37 | color: v.color, 38 | canFire: v.canFire, 39 | wingCount: v.wingCount, 40 | crewMembers: v.crewMembers, 41 | }, 42 | }, 43 | }, 44 | }; 45 | 46 | DOM.form.cy.should($el => { 47 | expect(getFormValue($el, VehicleType.SPACESHIP)).to.eql(expectedObj); 48 | }); 49 | }); 50 | 51 | it(`should be able to go from a spaceship to a speeder and update the form`, () => { 52 | DOM.list.elements.cy.eq(0).click(); 53 | DOM.list.elements.cy.eq(1).click(); 54 | 55 | const vehicle = hardCodedListings[1] as VehicleListing; 56 | const speeder = vehicle.product as Speeder; 57 | 58 | const expectedObj: FormElement = { 59 | title: vehicle.title, 60 | price: '£' + vehicle.price.toLocaleString(), 61 | inputs: { 62 | id: vehicle.id, 63 | title: vehicle.title, 64 | imageUrl: vehicle.imageUrl, 65 | price: vehicle.price + '', 66 | listingType: vehicle.listingType, 67 | vehicleForm: { 68 | vehicleType: vehicle.product.vehicleType, 69 | speederForm: { 70 | color: speeder.color, 71 | canFire: speeder.canFire, 72 | crewMembers: speeder.crewMembers, 73 | maximumSpeed: speeder.maximumSpeed, 74 | }, 75 | }, 76 | }, 77 | }; 78 | 79 | DOM.form.cy.should($el => { 80 | expect(getFormValue($el, VehicleType.SPEEDER)).to.eql(expectedObj); 81 | }); 82 | }); 83 | 84 | it(`should be able to go from a spaceship to a speeder AND back and restore the original value`, () => { 85 | DOM.list.elements.cy.eq(0).click(); 86 | DOM.list.elements.cy.eq(1).click(); 87 | DOM.list.elements.cy.eq(0).click(); 88 | 89 | const vehicle = hardCodedListings[0] as VehicleListing; 90 | const spaceship = vehicle.product as Spaceship; 91 | 92 | const expectedObj: FormElement = { 93 | title: vehicle.title, 94 | price: '£' + vehicle.price.toLocaleString(), 95 | inputs: { 96 | id: vehicle.id, 97 | title: vehicle.title, 98 | imageUrl: vehicle.imageUrl, 99 | price: vehicle.price + '', 100 | listingType: vehicle.listingType, 101 | vehicleForm: { 102 | vehicleType: vehicle.product.vehicleType, 103 | spaceshipForm: { 104 | color: spaceship.color, 105 | canFire: spaceship.canFire, 106 | crewMembers: spaceship.crewMembers, 107 | wingCount: spaceship.wingCount, 108 | }, 109 | }, 110 | }, 111 | }; 112 | 113 | DOM.form.cy.should($el => { 114 | expect(getFormValue($el, VehicleType.SPACESHIP)).to.eql(expectedObj); 115 | }); 116 | }); 117 | 118 | it(`should display the (nested) errors from the form`, () => { 119 | DOM.createNewButton.click(); 120 | 121 | DOM.form.errors.should($el => { 122 | expect(extractErrors($el)).to.eql({ 123 | listingType: { 124 | required: true, 125 | }, 126 | title: { 127 | required: true, 128 | }, 129 | imageUrl: { 130 | required: true, 131 | }, 132 | price: { 133 | required: true, 134 | }, 135 | }); 136 | }); 137 | 138 | DOM.form.elements.selectListingTypeByType(ListingType.VEHICLE); 139 | 140 | DOM.form.errors.should($el => { 141 | expect(extractErrors($el)).to.eql({ 142 | vehicleProduct: { 143 | vehicleType: { 144 | required: true, 145 | }, 146 | }, 147 | title: { 148 | required: true, 149 | }, 150 | imageUrl: { 151 | required: true, 152 | }, 153 | price: { 154 | required: true, 155 | }, 156 | }); 157 | }); 158 | 159 | DOM.form.elements.vehicleForm.selectVehicleTypeByType(VehicleType.SPACESHIP); 160 | 161 | DOM.form.errors.should($el => { 162 | expect(extractErrors($el)).to.eql({ 163 | vehicleProduct: { 164 | spaceship: { 165 | color: { 166 | required: true, 167 | }, 168 | crewMembers: { 169 | required: true, 170 | }, 171 | wingCount: { 172 | required: true, 173 | }, 174 | }, 175 | }, 176 | title: { 177 | required: true, 178 | }, 179 | imageUrl: { 180 | required: true, 181 | }, 182 | price: { 183 | required: true, 184 | }, 185 | }); 186 | }); 187 | 188 | DOM.form.elements.vehicleForm.addCrewMemberButton.click(); 189 | 190 | DOM.form.errors.should($el => { 191 | expect(extractErrors($el)).to.eql({ 192 | vehicleProduct: { 193 | spaceship: { 194 | color: { 195 | required: true, 196 | }, 197 | crewMembers: { 198 | crewMembers: { 199 | minimumCrewMemberCount: 2, 200 | 0: { 201 | firstName: { 202 | required: true, 203 | }, 204 | lastName: { 205 | required: true, 206 | }, 207 | }, 208 | }, 209 | }, 210 | wingCount: { 211 | required: true, 212 | }, 213 | }, 214 | }, 215 | title: { 216 | required: true, 217 | }, 218 | imageUrl: { 219 | required: true, 220 | }, 221 | price: { 222 | required: true, 223 | }, 224 | }); 225 | }); 226 | 227 | DOM.form.elements.selectListingTypeByType(ListingType.DROID); 228 | 229 | DOM.form.errors.should($el => { 230 | expect(extractErrors($el)).to.eql({ 231 | droidProduct: { 232 | droidType: { 233 | required: true, 234 | }, 235 | }, 236 | title: { 237 | required: true, 238 | }, 239 | imageUrl: { 240 | required: true, 241 | }, 242 | price: { 243 | required: true, 244 | }, 245 | }); 246 | }); 247 | 248 | DOM.form.elements.droidForm.selectDroidTypeByType(DroidType.ASSASSIN); 249 | 250 | DOM.form.errors.should($el => { 251 | expect(extractErrors($el)).to.eql({ 252 | droidProduct: { 253 | assassinDroid: { 254 | color: { 255 | required: true, 256 | }, 257 | name: { 258 | required: true, 259 | }, 260 | weapons: { 261 | required: true, 262 | }, 263 | }, 264 | }, 265 | title: { 266 | required: true, 267 | }, 268 | imageUrl: { 269 | required: true, 270 | }, 271 | price: { 272 | required: true, 273 | }, 274 | }); 275 | }); 276 | 277 | DOM.form.elements.droidForm.name.type(`IG-86 sentinel`); 278 | 279 | DOM.form.errors.should($el => { 280 | expect(extractErrors($el)).to.eql({ 281 | droidProduct: { 282 | assassinDroid: { 283 | color: { 284 | required: true, 285 | }, 286 | weapons: { 287 | required: true, 288 | }, 289 | }, 290 | }, 291 | title: { 292 | required: true, 293 | }, 294 | imageUrl: { 295 | required: true, 296 | }, 297 | price: { 298 | required: true, 299 | }, 300 | }); 301 | }); 302 | }); 303 | 304 | it(`should display no error when form is valid`, () => { 305 | // we want to make sure that it's easy to detect from the template that there's no error 306 | // previously we returned an empty object which made that check way harder in the template 307 | DOM.list.elements.cy.eq(0).click(); 308 | 309 | DOM.form.errors.should('not.exist'); 310 | DOM.form.noErrors.should('exist'); 311 | }); 312 | 313 | it(`should recursively disable the form when disabling the top formGroup`, () => { 314 | DOM.list.elements.cy.eq(0).click(); 315 | 316 | DOM.form.cy.within(() => { 317 | cy.get(`[data-card-form]`).within(() => { 318 | cy.get(`input`).should('be.enabled'); 319 | cy.get(`mat-select`).should('not.have.class', 'mat-mdc-select-disabled'); 320 | cy.get(`mat-slide-toggle .mdc-switch`).should('not.have.class', 'mdc-switch--disabled'); 321 | cy.get(`button`).should('be.enabled'); 322 | }); 323 | }); 324 | 325 | DOM.readonlyToggle.click(); 326 | 327 | DOM.form.cy.within(() => { 328 | cy.get(`[data-card-form]`).within(() => { 329 | cy.get(`input`).should('be.disabled'); 330 | cy.get(`mat-select`).should('have.class', 'mat-mdc-select-disabled'); 331 | cy.get(`mat-slide-toggle .mdc-switch`).should('have.class', 'mdc-switch--disabled'); 332 | cy.get(`button`).should('be.disabled'); 333 | }); 334 | }); 335 | }); 336 | }); 337 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /cypress/helpers/data.helper.ts: -------------------------------------------------------------------------------- 1 | import { CrewMember } from '../../src/app/interfaces/crew-member.interface'; 2 | import { DroidType } from '../../src/app/interfaces/droid.interface'; 3 | import { ListingType, OneListing } from '../../src/app/interfaces/listing.interface'; 4 | import { VehicleType } from '../../src/app/interfaces/vehicle.interface'; 5 | import { UnreachableCase } from '../../src/app/shared/utils'; 6 | 7 | export interface ListElement { 8 | readonly title: string; 9 | readonly type: string; 10 | readonly price: string; 11 | readonly subType: string; 12 | readonly details: string; 13 | } 14 | 15 | export type VehicleFormElement = { 16 | readonly vehicleType: string; 17 | } & ( 18 | | { 19 | readonly spaceshipForm: { 20 | readonly color: string; 21 | readonly canFire: boolean; 22 | readonly crewMembers: CrewMember[]; 23 | readonly wingCount: number; 24 | }; 25 | } 26 | | { 27 | readonly speederForm: { 28 | readonly color: string; 29 | readonly canFire: boolean; 30 | readonly crewMembers: CrewMember[]; 31 | readonly maximumSpeed: number; 32 | }; 33 | } 34 | ); 35 | 36 | export interface DroidFormElement { 37 | readonly droidType: string; 38 | // @todo handle different cases 39 | readonly protocolDroidForm: { 40 | readonly color: string; 41 | readonly name: string; 42 | readonly spokenLanguages: string; 43 | }; 44 | } 45 | 46 | export interface FormElement { 47 | readonly title: string; 48 | readonly price: string; 49 | readonly inputs: { 50 | readonly id: string; 51 | readonly title: string; 52 | readonly imageUrl: string; 53 | readonly price: string; 54 | readonly listingType: string; 55 | } & { readonly vehicleForm: VehicleFormElement }; 56 | } 57 | 58 | export const hardcodedElementToTestElement = (item: OneListing): ListElement => { 59 | const title: string = item.title; 60 | const type: string = item.listingType; 61 | const price: string = item.price.toLocaleString(); 62 | 63 | let subType: string; 64 | let details: string; 65 | 66 | switch (item.listingType) { 67 | case ListingType.DROID: { 68 | subType = item.product.droidType; 69 | 70 | switch (item.product.droidType) { 71 | case DroidType.ASSASSIN: 72 | details = `Weapons: ${item.product.weapons.join(', ')}`; 73 | break; 74 | 75 | case DroidType.ASTROMECH: 76 | details = `Number of tools: ${item.product.toolCount}`; 77 | break; 78 | 79 | case DroidType.MEDICAL: 80 | details = [ 81 | item.product.canHealHumans ? `Can heal humans` : `Can't heal humans`, 82 | item.product.canFixRobots ? `can fix robots` : `can't fix robots`, 83 | ].join(', '); 84 | break; 85 | 86 | case DroidType.PROTOCOL: 87 | details = `Spoken languages: ${item.product.spokenLanguages.join(', ')}`; 88 | break; 89 | 90 | default: 91 | throw new UnreachableCase(item.product); 92 | } 93 | break; 94 | } 95 | 96 | case ListingType.VEHICLE: { 97 | subType = item.product.vehicleType; 98 | 99 | switch (item.product.vehicleType) { 100 | case VehicleType.SPACESHIP: 101 | details = [ 102 | `Crew members: ${item.product.crewMembers 103 | .map(crewMember => `${crewMember.firstName} ${crewMember.lastName}`) 104 | .join(', ')}`, 105 | item.product.canFire ? `can fire` : `can't fire`, 106 | `number of wings: ${item.product.wingCount}`, 107 | ].join(', '); 108 | break; 109 | 110 | case VehicleType.SPEEDER: 111 | details = [ 112 | `Crew members: ${item.product.crewMembers 113 | .map(crewMember => `${crewMember.firstName} ${crewMember.lastName}`) 114 | .join(', ')}`, 115 | item.product.canFire ? `can fire` : `can't fire`, 116 | `maximum speed: ${item.product.maximumSpeed}kph`, 117 | ].join(', '); 118 | break; 119 | 120 | default: 121 | throw new UnreachableCase(item.product); 122 | } 123 | 124 | break; 125 | } 126 | 127 | default: 128 | throw new UnreachableCase(item); 129 | } 130 | 131 | return { 132 | title, 133 | type, 134 | price, 135 | subType, 136 | details, 137 | }; 138 | }; 139 | 140 | export const hardcodedElementsToTestList = (items: OneListing[]): ListElement[] => 141 | items.map(item => hardcodedElementToTestElement(item)); 142 | 143 | export const extractErrors = (errors: JQuery) => { 144 | return JSON.parse(errors.text().trim()); 145 | }; 146 | -------------------------------------------------------------------------------- /cypress/helpers/dom.helper.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { DroidType } from '../../src/app/interfaces/droid.interface'; 4 | import { ListingType } from '../../src/app/interfaces/listing.interface'; 5 | import { VehicleType } from '../../src/app/interfaces/vehicle.interface'; 6 | 7 | const getTextFromTag = (element: HTMLElement, tag: string): string => 8 | Cypress.$(element).find(`*[data-${tag}]`).text().trim(); 9 | 10 | const getTextFromInput = (element: HTMLElement, tag: string): string => 11 | Cypress.$(element).find(`*[data-${tag}]`).val() + ''; 12 | 13 | const getSelectedOptionFromSelect = (element: HTMLElement, tag: string): string => 14 | Cypress.$(element).find(`*[data-${tag}] .mat-mdc-select-min-line`).text().trim(); 15 | 16 | const getToggleValue = (element: HTMLElement, tag: string): boolean => 17 | Cypress.$(element).find(`*[data-${tag}]`).hasClass('mat-mdc-slide-toggle-checked'); 18 | 19 | const getCrewMembers = (element: HTMLElement): { firstName: string; lastName: string }[] => 20 | Cypress.$(element) 21 | .find('*[data-crew-member]') 22 | .map((_, $element) => ({ 23 | firstName: getTextFromInput($element, 'input-crew-member-first-name'), 24 | lastName: getTextFromInput($element, 'input-crew-member-last-name'), 25 | })) 26 | .get(); 27 | 28 | export const DOM = { 29 | get createNewButton() { 30 | return cy.get('*[data-create-new]'); 31 | }, 32 | get list() { 33 | return { 34 | get cy() { 35 | return cy.get(`app-listings`); 36 | }, 37 | get elements() { 38 | return { 39 | get cy() { 40 | return DOM.list.cy.find('*[data-list-item]'); 41 | }, 42 | }; 43 | }, 44 | }; 45 | }, 46 | get readonlyToggle() { 47 | return cy.get(`*[data-readonly]`); 48 | }, 49 | get form() { 50 | return { 51 | get cy() { 52 | return cy.get('app-listing'); 53 | }, 54 | get errors() { 55 | return cy.get(`*[data-errors]`); 56 | }, 57 | get noErrors() { 58 | return cy.get(`*[data-no-error]`); 59 | }, 60 | get elements() { 61 | return { 62 | get title() { 63 | return cy.get(`*[data-input-title]`); 64 | }, 65 | get imageUrl() { 66 | return cy.get(`*[data-input-image-url]`); 67 | }, 68 | get price() { 69 | return cy.get(`*[data-input-price]`); 70 | }, 71 | get selectListingType() { 72 | return cy.get(`*[data-select-listing-type]`); 73 | }, 74 | selectListingTypeByType: (type: ListingType) => { 75 | DOM.form.elements.selectListingType.click(); 76 | 77 | return cy.contains(`*[data-select-listing-type-option]`, type).click(); 78 | }, 79 | get droidForm() { 80 | return { 81 | get name() { 82 | return cy.get(`*[data-input-name]`); 83 | }, 84 | get selectDroidType() { 85 | return cy.get(`*[data-select-droid-type]`); 86 | }, 87 | selectDroidTypeByType: (type: DroidType) => { 88 | DOM.form.elements.droidForm.selectDroidType.click(); 89 | 90 | return cy.contains(`*[data-select-droid-type-option]`, type).click(); 91 | }, 92 | }; 93 | }, 94 | get vehicleForm() { 95 | return { 96 | get name() { 97 | return cy.get(`*[data-input-name]`); 98 | }, 99 | get selectVehicleType() { 100 | return cy.get(`*[data-select-vehicle-type]`); 101 | }, 102 | selectVehicleTypeByType: (type: VehicleType) => { 103 | DOM.form.elements.vehicleForm.selectVehicleType.click(); 104 | 105 | return cy.contains(`*[data-select-vehicle-type-option]`, type).click(); 106 | }, 107 | get addCrewMemberButton() { 108 | return cy.get(`*[data-btn-add-crew-member]`); 109 | }, 110 | }; 111 | }, 112 | }; 113 | }, 114 | }; 115 | }, 116 | }; 117 | 118 | const getVehicleObj = (element: HTMLElement, vehicleType: VehicleType) => 119 | ({ 120 | Spaceship: { 121 | spaceshipForm: { 122 | color: getTextFromInput(element, 'input-color'), 123 | canFire: getToggleValue(element, 'input-can-fire'), 124 | crewMembers: getCrewMembers(element), 125 | wingCount: +getTextFromInput(element, 'input-number-of-wings'), 126 | }, 127 | }, 128 | Speeder: { 129 | speederForm: { 130 | color: getTextFromInput(element, 'input-color'), 131 | canFire: getToggleValue(element, 'input-can-fire'), 132 | crewMembers: getCrewMembers(element), 133 | maximumSpeed: +getTextFromInput(element, 'input-maximum-speed'), 134 | }, 135 | }, 136 | }[vehicleType]); 137 | 138 | export const getFormValue = (form: JQuery, type: VehicleType) => 139 | form 140 | .map((_, element) => ({ 141 | title: getTextFromTag(element, 'title'), 142 | price: getTextFromTag(element, 'price'), 143 | inputs: { 144 | id: getTextFromInput(element, 'input-id'), 145 | title: getTextFromInput(element, 'input-title'), 146 | imageUrl: getTextFromInput(element, 'input-image-url'), 147 | price: getTextFromInput(element, 'input-price'), 148 | listingType: getSelectedOptionFromSelect(element, 'select-listing-type'), 149 | vehicleForm: { 150 | vehicleType: getSelectedOptionFromSelect(element, 'select-vehicle-type'), 151 | ...getVehicleObj(element, type), 152 | }, 153 | }, 154 | })) 155 | .get()[0]; 156 | 157 | export const getFormList = ($elements: JQuery) => { 158 | return $elements 159 | .map((_, element) => ({ 160 | title: getTextFromTag(element, 'title'), 161 | type: getTextFromTag(element, 'type'), 162 | price: getTextFromTag(element, 'price'), 163 | subType: getTextFromTag(element, 'sub-type'), 164 | details: getTextFromTag(element, 'details'), 165 | })) 166 | .get(); 167 | }; 168 | -------------------------------------------------------------------------------- /cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************** 3 | // This example commands.ts shows you how to 4 | // create various custom commands and overwrite 5 | // existing commands. 6 | // 7 | // For more comprehensive examples of custom 8 | // commands please read more here: 9 | // https://on.cypress.io/custom-commands 10 | // *********************************************** 11 | // 12 | // 13 | // -- This is a parent command -- 14 | // Cypress.Commands.add('login', (email, password) => { ... }) 15 | // 16 | // 17 | // -- This is a child command -- 18 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 19 | // 20 | // 21 | // -- This is a dual command -- 22 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 23 | // 24 | // 25 | // -- This will overwrite an existing command -- 26 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 27 | // 28 | // declare global { 29 | // namespace Cypress { 30 | // interface Chainable { 31 | // login(email: string, password: string): Chainable 32 | // drag(subject: string, options?: Partial): Chainable 33 | // dismiss(subject: string, options?: Partial): Chainable 34 | // visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable 35 | // } 36 | // } 37 | // } 38 | -------------------------------------------------------------------------------- /cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands'; 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | /** 2 | * Cypress 12 has an issue in combination with typescript 5.0.x 3 | * "Option 'sourceMap' cannot be specified with option 'inlineSourceMap'" 4 | * https://github.com/cypress-io/cypress/issues/26203 5 | **/ 6 | { 7 | "extends": "../tsconfig", 8 | "compilerOptions": { 9 | "sourceMap": false 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ngx-sub-form-demo", 3 | "version": "0.0.0-development", 4 | "license": "MIT", 5 | "scripts": { 6 | "------------------ BASE COMMANDS -----------------": "", 7 | "ng": "ng", 8 | "prettier": "prettier", 9 | "prettier:base": "yarn run prettier \"**/*.{js,json,scss,md,ts,html,component.html}\"", 10 | "prettier:write": "yarn run prettier:base --write", 11 | "prettier:check": "yarn run prettier:base --list-different", 12 | "cy": "cypress", 13 | "---------------------- DEMO ----------------------": "", 14 | "demo:start": "yarn run ng serve", 15 | "demo:start:e2e": "yarn run ng serve --port 4765 -o", 16 | "demo:build:base": "yarn run ng build", 17 | "demo:build:prod": "yarn run demo:build:base --configuration production", 18 | "demo:test": "yarn run ng test", 19 | "demo:test:e2e:watch": "yarn run cy open", 20 | "demo:test:e2e:ci": "yarn run cy run", 21 | "demo:lint:check": "yarn run ng lint", 22 | "demo:lint:fix": "yarn run demo:lint:check --fix", 23 | "------------------ LIB ngx-sub-form ------------------": "", 24 | "lib:build:prod": "yarn run ng build --project ngx-sub-form --configuration production", 25 | "lib:build:watch": "yarn run lib:build:prod --watch", 26 | "lib:test:watch": "yarn run ng test --project ngx-sub-form", 27 | "lib:test:ci": "yarn run ng test --project ngx-sub-form --watch false --browsers=ChromeHeadless", 28 | "------------------ Quick Commands ------------------": "", 29 | "lint:fix": "yarn demo:lint:fix && yarn prettier:write", 30 | "semantic-release": "semantic-release", 31 | "test": "yarn lib:test:watch", 32 | "commit": "git add . && git-cz", 33 | "readme:build": "embedme README.md && yarn run prettier README.md --write", 34 | "readme:check": "yarn readme:build && ! git status | grep README.md || (echo 'You must commit build and commit changes to README.md!' && exit 1)", 35 | "lint": "ng lint", 36 | "------------------------ CI ------------------------": "", 37 | "start-e2e-file-server": "cp -r dist/ngx-sub-form-demo dist/ngx-sub-form-demo-e2e && sed -i 's/base href=\"https:\\/\\/cloudnc.github.io\\/ngx-sub-form\\/\"/base href=\"\\/\"/' dist/ngx-sub-form-demo-e2e/browser/index.html && npx http-server --port 4765 ./dist/ngx-sub-form-demo-e2e/browser" 38 | }, 39 | "private": true, 40 | "dependencies": { 41 | "@angular/animations": "19.0.3", 42 | "@angular/cdk": "19.0.3", 43 | "@angular/common": "19.0.3", 44 | "@angular/compiler": "19.0.3", 45 | "@angular/core": "19.0.3", 46 | "@angular/forms": "19.0.3", 47 | "@angular/material": "19.0.3", 48 | "@angular/platform-browser": "19.0.3", 49 | "@angular/platform-browser-dynamic": "19.0.3", 50 | "@angular/router": "19.0.3", 51 | "@types/uuid": "9.0.0", 52 | "commitizen": "4.2.6", 53 | "core-js": "3.23.1", 54 | "fast-deep-equal": "3.1.3", 55 | "lodash-es": "4.17.21", 56 | "ngx-observable-lifecycle": "2.2.1", 57 | "rxjs": "7.6.0", 58 | "tslib": "2.4.1", 59 | "uuid": "9.0.0", 60 | "zone.js": "0.15.0" 61 | }, 62 | "devDependencies": { 63 | "@angular-devkit/build-angular": "19.0.4", 64 | "@angular-eslint/builder": "19.0.2", 65 | "@angular-eslint/eslint-plugin": "19.0.2", 66 | "@angular-eslint/eslint-plugin-template": "19.0.2", 67 | "@angular-eslint/schematics": "19.0.2", 68 | "@angular-eslint/template-parser": "19.0.2", 69 | "@angular/cli": "19.0.4", 70 | "@angular/compiler-cli": "19.0.3", 71 | "@angular/language-service": "19.0.3", 72 | "@types/jasmine": "4.3.1", 73 | "@types/jasminewd2": "2.0.10", 74 | "@types/lodash": "4.14.191", 75 | "@types/lodash-es": "4.17.6", 76 | "@types/node": "22.10.2", 77 | "@typescript-eslint/eslint-plugin": "7.11.0", 78 | "@typescript-eslint/parser": "7.11.0", 79 | "cypress": "13.16.1", 80 | "cz-conventional-changelog": "3.3.0", 81 | "embedme": "1.22.1", 82 | "eslint": "9.3.0", 83 | "eslint-config-prettier": "9.0.0", 84 | "eslint-plugin-import": "2.29.0", 85 | "eslint-plugin-jsdoc": "46.9.0", 86 | "eslint-plugin-prefer-arrow": "1.2.3", 87 | "http-server-spa": "1.3.0", 88 | "jasmine-core": "4.5.0", 89 | "jasmine-spec-reporter": "7.0.0", 90 | "karma": "6.4.1", 91 | "karma-chrome-launcher": "3.1.1", 92 | "karma-coverage-istanbul-reporter": "3.0.3", 93 | "karma-jasmine": "5.1.0", 94 | "karma-jasmine-html-reporter": "2.0.0", 95 | "ng-packagr": "19.0.1", 96 | "prettier": "2.7.1", 97 | "semantic-release": "19.0.5", 98 | "ts-node": "10.9.1", 99 | "tsconfig-paths-webpack-plugin": "3.5.2", 100 | "tsdef": "0.0.14", 101 | "typescript": "5.6.3" 102 | }, 103 | "repository": { 104 | "type": "git", 105 | "url": "https://github.com/cloudnc/ngx-sub-form.git" 106 | }, 107 | "config": { 108 | "commitizen": { 109 | "path": "./node_modules/cz-conventional-changelog" 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 120, 3 | tabWidth: 2, 4 | useTabs: false, 5 | semi: true, 6 | singleQuote: true, 7 | trailingComma: 'all', 8 | bracketSpacing: true, 9 | arrowParens: 'avoid', 10 | rangeStart: 0, 11 | rangeEnd: Infinity, 12 | requirePragma: false, 13 | insertPragma: false, 14 | proseWrap: 'preserve', 15 | htmlWhitespaceSensitivity: 'ignore', 16 | endOfLine: 'lf', 17 | }; 18 | -------------------------------------------------------------------------------- /projects/ngx-sub-form/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../.eslintrc.json", 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts"], 7 | "parserOptions": { 8 | "project": ["projects/ngx-sub-form/tsconfig.lib.json", "projects/ngx-sub-form/tsconfig.spec.json"], 9 | "createDefaultProgram": true 10 | }, 11 | "extends": ["prettier"], 12 | "rules": { 13 | "@typescript-eslint/dot-notation": "off", 14 | "@typescript-eslint/explicit-member-accessibility": [ 15 | "off", 16 | { 17 | "accessibility": "explicit" 18 | } 19 | ], 20 | "@typescript-eslint/no-unused-expressions": "off", 21 | "id-blacklist": "off", 22 | "id-match": "off", 23 | "no-underscore-dangle": "off" 24 | } 25 | }, 26 | { 27 | "files": ["*.html"], 28 | "rules": {} 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /projects/ngx-sub-form/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | require('@angular-devkit/build-angular/plugins/karma'), 14 | ], 15 | client: { 16 | clearContext: false, // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | dir: require('path').join(__dirname, '../../coverage'), 20 | reports: ['html', 'lcovonly'], 21 | fixWebpackSourcePaths: true, 22 | }, 23 | reporters: ['progress', 'kjhtml'], 24 | port: 9876, 25 | colors: true, 26 | logLevel: config.LOG_INFO, 27 | autoWatch: true, 28 | browsers: ['ChromiumHeadlessNoSandbox'], 29 | customLaunchers: { 30 | ChromiumHeadlessNoSandbox: { 31 | base: 'ChromiumHeadless', 32 | flags: ['--no-sandbox'], 33 | }, 34 | }, 35 | singleRun: false, 36 | }); 37 | }; 38 | -------------------------------------------------------------------------------- /projects/ngx-sub-form/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../../dist/ngx-sub-form", 4 | "lib": { 5 | "entryFile": "src/public_api.ts" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /projects/ngx-sub-form/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ngx-sub-form", 3 | "version": "0.0.1", 4 | "license": "MIT", 5 | "dependencies": { 6 | "tslib": "^2.0.0" 7 | }, 8 | "peerDependencies": { 9 | "@angular/common": "^19.0.0", 10 | "@angular/core": "^19.0.0", 11 | "@angular/forms": "^19.0.0", 12 | "fast-deep-equal": "^3.1.3", 13 | "lodash-es": "^4.17.21", 14 | "ngx-observable-lifecycle": "^2.2.1", 15 | "rxjs": "^7.6.0", 16 | "tsdef": "0.0.14" 17 | }, 18 | "keywords": [ 19 | "Angular", 20 | "ngx", 21 | "SubForm", 22 | "Nested", 23 | "Reactive", 24 | "ControlValueAccessor" 25 | ], 26 | "repository": { 27 | "type": "git", 28 | "url": "https://github.com/cloudnc/ngx-sub-form.git" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /projects/ngx-sub-form/src/lib/create-form.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectorRef, inject } from '@angular/core'; 2 | import { UntypedFormControl } from '@angular/forms'; 3 | import isEqual from 'fast-deep-equal'; 4 | import { getObservableLifecycle } from 'ngx-observable-lifecycle'; 5 | import { combineLatest, concat, EMPTY, identity, merge, Observable, of, timer } from 'rxjs'; 6 | import { 7 | delay, 8 | filter, 9 | map, 10 | mapTo, 11 | shareReplay, 12 | startWith, 13 | switchMap, 14 | take, 15 | takeUntil, 16 | tap, 17 | withLatestFrom, 18 | } from 'rxjs/operators'; 19 | import { 20 | createFormDataFromOptions, 21 | getControlValueAccessorBindings, 22 | getFormGroupErrors, 23 | handleFormArrays, 24 | patchClassInstance, 25 | } from './helpers'; 26 | import { 27 | ComponentHooks, 28 | ControlValueAccessorComponentInstance, 29 | FormBindings, 30 | FormType, 31 | NgxFormOptions, 32 | NgxRootForm, 33 | NgxRootFormOptions, 34 | NgxSubForm, 35 | NgxSubFormArrayOptions, 36 | NgxSubFormOptions, 37 | } from './ngx-sub-form.types'; 38 | import { isNullOrUndefined } from './shared/ngx-sub-form-utils'; 39 | 40 | const optionsHaveInstructionsToCreateArrays = ( 41 | options: NgxFormOptions & Partial>, 42 | ): options is NgxSubFormOptions & NgxSubFormArrayOptions => 43 | !!options.createFormArrayControl; 44 | 45 | // @todo find a better name 46 | const isRoot = ( 47 | options: any, 48 | ): options is NgxRootFormOptions => { 49 | const opt = options as NgxRootFormOptions; 50 | return opt.formType === FormType.ROOT; 51 | }; 52 | 53 | export function createForm< 54 | ControlInterface, 55 | FormInterface extends {} = ControlInterface extends {} ? ControlInterface : never, 56 | >( 57 | componentInstance: ControlValueAccessorComponentInstance, 58 | options: NgxRootFormOptions, 59 | ): NgxRootForm; 60 | export function createForm< 61 | ControlInterface, 62 | FormInterface extends {} = ControlInterface extends {} ? ControlInterface : never, 63 | >( 64 | componentInstance: ControlValueAccessorComponentInstance, 65 | options: NgxSubFormOptions, 66 | ): NgxSubForm; 67 | export function createForm( 68 | componentInstance: ControlValueAccessorComponentInstance, 69 | options: NgxFormOptions, 70 | ): NgxSubForm { 71 | const { formGroup, defaultValues, formControlNames, formArrays } = createFormDataFromOptions< 72 | ControlInterface, 73 | FormInterface 74 | >(options); 75 | 76 | let isRemoved = false; 77 | 78 | const lifecyleHooks: ComponentHooks = options.componentHooks ?? { 79 | onDestroy: getObservableLifecycle(componentInstance).ngOnDestroy, 80 | afterViewInit: getObservableLifecycle(componentInstance).ngAfterViewInit, 81 | }; 82 | 83 | const changeDetectorRef = inject(ChangeDetectorRef); 84 | 85 | lifecyleHooks.onDestroy.pipe(take(1)).subscribe(() => { 86 | isRemoved = true; 87 | }); 88 | 89 | // define the `validate` method to improve errors 90 | // and support nested errors 91 | patchClassInstance(componentInstance, { 92 | validate: () => { 93 | if (isRemoved) return null; 94 | 95 | if (formGroup.valid) { 96 | return null; 97 | } 98 | 99 | return getFormGroupErrors(formGroup); 100 | }, 101 | }); 102 | 103 | // in order to ensure the form has the correct state (and validation errors) we update the value and validity 104 | // immediately after the first tick 105 | const updateValueAndValidity$ = timer(0); 106 | 107 | const componentHooks = getControlValueAccessorBindings(componentInstance); 108 | 109 | const writeValue$: FormBindings['writeValue$'] = isRoot(options) 110 | ? options.input$.pipe( 111 | // we need to start with a value here otherwise if a root form does not bind 112 | // its input (and only uses an output, for example a filter) then 113 | // `broadcastValueToParent$` would never start and we would never get updates 114 | startWith(null), 115 | ) 116 | : componentHooks.writeValue$; 117 | 118 | const registerOnChange$: FormBindings['registerOnChange$'] = isRoot< 119 | ControlInterface, 120 | FormInterface 121 | >(options) 122 | ? of(data => { 123 | if (!data) { 124 | return; 125 | } 126 | options.output$.next(data); 127 | }) 128 | : componentHooks.registerOnChange$; 129 | 130 | const setDisabledState$: FormBindings['setDisabledState$'] = isRoot< 131 | ControlInterface, 132 | FormInterface 133 | >(options) 134 | ? options.disabled$ ?? of(false) 135 | : componentHooks.setDisabledState$; 136 | 137 | const transformedValue$: Observable = writeValue$.pipe( 138 | map(value => { 139 | if (isNullOrUndefined(value)) { 140 | return defaultValues; 141 | } 142 | 143 | if (options.toFormGroup) { 144 | return options.toFormGroup(value); 145 | } 146 | 147 | // if it's not a remap component, the ControlInterface === the FormInterface 148 | return value as any as FormInterface; 149 | }), 150 | shareReplay({ refCount: true, bufferSize: 1 }), 151 | ); 152 | 153 | const broadcastValueToParent$: Observable = transformedValue$.pipe( 154 | switchMap(transformedValue => { 155 | if (!isRoot(options)) { 156 | return formGroup.valueChanges.pipe(delay(0)); 157 | } else { 158 | const formValues$ = options.manualSave$ 159 | ? options.manualSave$.pipe( 160 | withLatestFrom(formGroup.valueChanges), 161 | map(([_, formValue]) => formValue), 162 | ) 163 | : formGroup.valueChanges; 164 | 165 | // it might be surprising to see formGroup validity being checked twice 166 | // here, however this is intentional. The delay(0) allows any sub form 167 | // components to populate values into the form, and it is possible for 168 | // the form to be invalid after this process. In which case we suppress 169 | // outputting an invalid value, and wait for the user to make the value 170 | // become valid. 171 | return formValues$.pipe( 172 | filter(() => formGroup.valid), 173 | delay(0), 174 | filter(formValue => { 175 | if (formGroup.invalid) { 176 | return false; 177 | } 178 | 179 | if (options.outputFilterPredicate) { 180 | return options.outputFilterPredicate(transformedValue, formValue); 181 | } 182 | 183 | return !isEqual(transformedValue, formValue); 184 | }), 185 | options.handleEmissionRate ?? identity, 186 | ); 187 | } 188 | }), 189 | map(value => 190 | options.fromFormGroup 191 | ? options.fromFormGroup(value) 192 | : // if it's not a remap component, the ControlInterface === the FormInterface 193 | (value as any as ControlInterface), 194 | ), 195 | ); 196 | 197 | // components often need to know what the current value of the FormControl that it is representing is, usually for 198 | // display purposes in the template. This value is the composition of the value written from the parent, and the 199 | // transformed current value that was most recently written to the parent 200 | const controlValue$: NgxSubForm['controlValue$'] = merge( 201 | writeValue$, 202 | broadcastValueToParent$, 203 | ).pipe(shareReplay({ bufferSize: 1, refCount: true })); 204 | 205 | const emitNullOnDestroy$: Observable = 206 | // emit null when destroyed by default 207 | isNullOrUndefined(options.emitNullOnDestroy) || options.emitNullOnDestroy 208 | ? lifecyleHooks.onDestroy.pipe(mapTo(null)) 209 | : EMPTY; 210 | 211 | const createFormArrayControl: Required>['createFormArrayControl'] = 212 | optionsHaveInstructionsToCreateArrays(options) && options.createFormArrayControl 213 | ? options.createFormArrayControl 214 | : (key, initialValue) => new UntypedFormControl(initialValue); 215 | 216 | const sideEffects = { 217 | broadcastValueToParent$: registerOnChange$.pipe( 218 | switchMap(onChange => broadcastValueToParent$.pipe(tap(value => onChange(value)))), 219 | ), 220 | applyUpstreamUpdateOnLocalForm$: transformedValue$.pipe( 221 | tap(value => { 222 | handleFormArrays(formArrays, value, createFormArrayControl); 223 | 224 | formGroup.reset(value, { emitEvent: false }); 225 | }), 226 | ), 227 | supportChangeDetectionStrategyOnPush: concat( 228 | lifecyleHooks.afterViewInit.pipe(take(1)), 229 | merge(controlValue$, setDisabledState$).pipe( 230 | delay(0), 231 | tap(() => { 232 | changeDetectorRef.markForCheck(); 233 | }), 234 | ), 235 | ), 236 | setDisabledState$: setDisabledState$.pipe( 237 | tap((shouldDisable: boolean) => { 238 | shouldDisable ? formGroup.disable({ emitEvent: false }) : formGroup.enable({ emitEvent: false }); 239 | }), 240 | ), 241 | updateValue$: updateValueAndValidity$.pipe( 242 | tap(() => { 243 | formGroup.updateValueAndValidity({ emitEvent: false }); 244 | }), 245 | ), 246 | bindTouched$: combineLatest([componentHooks.registerOnTouched$, options.touched$ ?? EMPTY]).pipe( 247 | delay(0), 248 | tap(([onTouched]) => onTouched()), 249 | ), 250 | }; 251 | 252 | merge(...Object.values(sideEffects)) 253 | .pipe(takeUntil(lifecyleHooks.onDestroy)) 254 | .subscribe(); 255 | 256 | // following cannot be part of `forkJoin(sideEffects)` 257 | // because it uses `takeUntilDestroyed` which destroys 258 | // the subscription when the component is being destroyed 259 | // and therefore prevents the emit of the null value if needed 260 | registerOnChange$ 261 | .pipe( 262 | switchMap(onChange => emitNullOnDestroy$.pipe(tap(value => onChange(value)))), 263 | takeUntil(lifecyleHooks.onDestroy.pipe(delay(0))), 264 | ) 265 | .subscribe(); 266 | 267 | return { 268 | formGroup, 269 | formControlNames, 270 | get formGroupErrors() { 271 | return getFormGroupErrors(formGroup); 272 | }, 273 | createFormArrayControl, 274 | controlValue$, 275 | }; 276 | } 277 | -------------------------------------------------------------------------------- /projects/ngx-sub-form/src/lib/helpers.spec.ts: -------------------------------------------------------------------------------- 1 | import { UntypedFormArray, UntypedFormControl } from '@angular/forms'; 2 | import { handleFormArrays } from 'ngx-sub-form'; 3 | 4 | describe(`helpers`, () => { 5 | describe('handleFormArrays', () => { 6 | interface FormInterface { 7 | foo: number[]; 8 | } 9 | 10 | it('should insert as many elements into the form array as there are in the original object', () => { 11 | const arrayControls: { key: keyof FormInterface; control: UntypedFormArray }[] = [ 12 | { 13 | key: 'foo', 14 | control: new UntypedFormArray([]), 15 | }, 16 | ]; 17 | 18 | const formValue: FormInterface = { 19 | foo: [1, 2, 3], 20 | }; 21 | expect(arrayControls[0].control.controls.length).toBe(0); 22 | 23 | handleFormArrays(arrayControls, formValue, () => new UntypedFormControl()); 24 | 25 | expect(arrayControls[0].control.controls.length).toBe(3); 26 | }); 27 | 28 | it('should preserve the disabled state of the array control when creating child array elements', () => { 29 | const arrayControls: { key: keyof FormInterface; control: UntypedFormArray }[] = [ 30 | { 31 | key: 'foo', 32 | control: new UntypedFormArray([]), 33 | }, 34 | ]; 35 | 36 | arrayControls[0].control.disable(); 37 | 38 | const formValue: FormInterface = { 39 | foo: [1, 2, 3], 40 | }; 41 | expect(arrayControls[0].control.controls.length).toBe(0); 42 | 43 | handleFormArrays(arrayControls, formValue, () => new UntypedFormControl()); 44 | 45 | expect(arrayControls[0].control.disabled).toBe(true); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /projects/ngx-sub-form/src/lib/helpers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AbstractControlOptions, 3 | ControlValueAccessor, 4 | UntypedFormArray, 5 | UntypedFormGroup, 6 | ValidationErrors, 7 | } from '@angular/forms'; 8 | import { cloneDeep } from 'lodash-es'; 9 | import { ReplaySubject } from 'rxjs'; 10 | import { Nilable } from 'tsdef'; 11 | import { 12 | ControlValueAccessorComponentInstance, 13 | FormBindings, 14 | NgxSubFormArrayOptions, 15 | NgxSubFormOptions, 16 | } from './ngx-sub-form.types'; 17 | import { 18 | ArrayPropertyKey, 19 | Controls, 20 | ControlsNames, 21 | NewFormErrors, 22 | OneOfControlsTypes, 23 | TypedFormGroup, 24 | } from './shared/ngx-sub-form-utils'; 25 | 26 | /** @internal */ 27 | export const patchClassInstance = (componentInstance: any, obj: Object) => { 28 | Object.entries(obj).forEach(([key, newMethod]) => { 29 | componentInstance[key] = newMethod; 30 | }); 31 | }; 32 | 33 | /** @internal */ 34 | export const getControlValueAccessorBindings = ( 35 | componentInstance: ControlValueAccessorComponentInstance, 36 | ): FormBindings => { 37 | const writeValue$$: ReplaySubject> = new ReplaySubject(1); 38 | const registerOnChange$$: ReplaySubject<(formValue: ControlInterface | null) => void> = new ReplaySubject(1); 39 | const registerOnTouched$$: ReplaySubject<() => void> = new ReplaySubject(1); 40 | const setDisabledState$$: ReplaySubject = new ReplaySubject(1); 41 | 42 | const controlValueAccessorPatch: Required = { 43 | writeValue: (obj: Nilable): void => { 44 | writeValue$$.next(obj); 45 | }, 46 | registerOnChange: (fn: (formValue: ControlInterface | null) => void): void => { 47 | registerOnChange$$.next(fn); 48 | }, 49 | registerOnTouched: (fn: () => void): void => { 50 | registerOnTouched$$.next(fn); 51 | }, 52 | setDisabledState: (shouldDisable: boolean | undefined): void => { 53 | setDisabledState$$.next(!!shouldDisable); 54 | }, 55 | }; 56 | 57 | patchClassInstance(componentInstance, controlValueAccessorPatch); 58 | 59 | return { 60 | writeValue$: writeValue$$.asObservable(), 61 | registerOnChange$: registerOnChange$$.asObservable(), 62 | registerOnTouched$: registerOnTouched$$.asObservable(), 63 | setDisabledState$: setDisabledState$$.asObservable(), 64 | }; 65 | }; 66 | 67 | export const getFormGroupErrors = ( 68 | formGroup: TypedFormGroup, 69 | ): NewFormErrors => { 70 | const formErrors: NewFormErrors = Object.entries(formGroup.controls).reduce< 71 | Exclude, null> 72 | >((acc, [key, control]) => { 73 | if (control.errors) { 74 | // all of FormControl, FormArray and FormGroup can have errors so we assign them first 75 | const accumulatedGenericError = acc as Record; 76 | accumulatedGenericError[key as keyof ControlInterface] = control.errors; 77 | } 78 | 79 | if (control instanceof UntypedFormArray) { 80 | // errors within an array are represented as a map 81 | // with the index and the error 82 | // this way, we avoid holding a lot of potential `null` 83 | // values in the array for the valid form controls 84 | const errorsInArray: Record = {}; 85 | 86 | for (let i = 0; i < control.length; i++) { 87 | const controlErrors = control.at(i).errors; 88 | if (controlErrors) { 89 | errorsInArray[i] = controlErrors; 90 | } 91 | } 92 | 93 | if (Object.values(errorsInArray).length > 0) { 94 | const accumulatedArrayErrors = acc as Record>; 95 | if (!(key in accumulatedArrayErrors)) { 96 | accumulatedArrayErrors[key as keyof ControlInterface] = {}; 97 | } 98 | Object.assign(accumulatedArrayErrors[key as keyof ControlInterface], errorsInArray); 99 | } 100 | } 101 | 102 | return acc; 103 | }, {}); 104 | 105 | if (!formGroup.errors && !Object.values(formErrors).length) { 106 | return null; 107 | } 108 | 109 | // todo remove any 110 | return Object.assign({}, formGroup.errors ? { formGroup: formGroup.errors } : {}, formErrors); 111 | }; 112 | 113 | interface FormArrayWrapper { 114 | key: keyof FormInterface; 115 | control: UntypedFormArray; 116 | } 117 | 118 | export function createFormDataFromOptions( 119 | options: NgxSubFormOptions, 120 | ) { 121 | const formGroup: TypedFormGroup = new UntypedFormGroup( 122 | options.formControls, 123 | options.formGroupOptions as AbstractControlOptions, 124 | ) as TypedFormGroup; 125 | const defaultValues: FormInterface = cloneDeep(formGroup.value); 126 | const formGroupKeys: (keyof Controls)[] = Object.keys( 127 | options.formControls, 128 | ) as (keyof Controls)[]; 129 | const formControlNames: ControlsNames = formGroupKeys.reduce>( 130 | (acc, curr) => { 131 | acc[curr] = curr; 132 | return acc; 133 | }, 134 | {} as ControlsNames, 135 | ); 136 | 137 | const formArrays: FormArrayWrapper[] = formGroupKeys.reduce[]>( 138 | (acc, key) => { 139 | const control = formGroup.get(key as string); 140 | if (control instanceof UntypedFormArray) { 141 | acc.push({ key, control }); 142 | } 143 | return acc; 144 | }, 145 | [], 146 | ); 147 | return { formGroup, defaultValues, formControlNames, formArrays }; 148 | } 149 | 150 | export const handleFormArrays = ( 151 | formArrayWrappers: FormArrayWrapper[], 152 | obj: FormInterface, 153 | createFormArrayControl: Required>['createFormArrayControl'], 154 | ) => { 155 | if (!formArrayWrappers.length) { 156 | return; 157 | } 158 | 159 | formArrayWrappers.forEach(({ key, control }) => { 160 | const value = obj[key]; 161 | 162 | if (!Array.isArray(value)) { 163 | return; 164 | } 165 | 166 | // instead of creating a new array every time and push a new FormControl 167 | // we just remove or add what is necessary so that: 168 | // - it is as efficient as possible and do not create unnecessary FormControl every time 169 | // - validators are not destroyed/created again and eventually fire again for no reason 170 | while (control.length > value.length) { 171 | control.removeAt(control.length - 1); 172 | } 173 | 174 | for (let i = control.length; i < value.length; i++) { 175 | const newControl = createFormArrayControl(key as ArrayPropertyKey, value[i]); 176 | if (control.disabled) { 177 | newControl.disable(); 178 | } 179 | control.insert(i, newControl); 180 | } 181 | }); 182 | }; 183 | -------------------------------------------------------------------------------- /projects/ngx-sub-form/src/lib/ngx-sub-form.types.ts: -------------------------------------------------------------------------------- 1 | import { ControlValueAccessor, UntypedFormControl, Validator } from '@angular/forms'; 2 | import { Observable, Subject } from 'rxjs'; 3 | import { Nilable } from 'tsdef'; 4 | import { 5 | ArrayPropertyKey, 6 | ArrayPropertyValue, 7 | Controls, 8 | ControlsNames, 9 | NewFormErrors, 10 | TypedFormGroup, 11 | } from './shared/ngx-sub-form-utils'; 12 | import { FormGroupOptions } from './shared/ngx-sub-form.types'; 13 | 14 | export interface ComponentHooks { 15 | onDestroy: Observable; 16 | afterViewInit: Observable; 17 | } 18 | 19 | export interface FormBindings { 20 | readonly writeValue$: Observable>; 21 | readonly registerOnChange$: Observable<(formValue: ControlInterface | null) => void>; 22 | readonly registerOnTouched$: Observable<() => void>; 23 | readonly setDisabledState$: Observable; 24 | } 25 | 26 | export type ControlValueAccessorComponentInstance = Object & 27 | // ControlValueAccessor methods are called 28 | // directly by Angular and expects a value 29 | // so we have to define it within ngx-sub-form 30 | // and this should *never* be overridden by the component 31 | Partial & Record>; 32 | 33 | export interface NgxSubForm { 34 | readonly formGroup: TypedFormGroup; 35 | readonly formControlNames: ControlsNames; 36 | readonly formGroupErrors: NewFormErrors; 37 | readonly createFormArrayControl: CreateFormArrayControlMethod; 38 | readonly controlValue$: Observable>; 39 | } 40 | 41 | export type CreateFormArrayControlMethod = >( 42 | key: K, 43 | initialValue: ArrayPropertyValue, 44 | ) => UntypedFormControl; 45 | 46 | export interface NgxRootForm 47 | extends NgxSubForm { 48 | // @todo: anything else needed here? 49 | } 50 | 51 | export interface NgxSubFormArrayOptions { 52 | createFormArrayControl?: CreateFormArrayControlMethod; 53 | } 54 | 55 | export interface NgxSubFormRemapOptions { 56 | toFormGroup: (obj: ControlInterface) => FormInterface; 57 | fromFormGroup: (formValue: FormInterface) => ControlInterface; 58 | } 59 | 60 | export type AreTypesSimilar = T extends U ? (U extends T ? true : false) : false; 61 | 62 | // if the 2 types are the same, instead of hiding the remap options 63 | // we expose them as optional so that it's possible for example to 64 | // override some defaults 65 | type NgxSubFormRemap = AreTypesSimilar extends true // we expose them 66 | ? Partial> 67 | : NgxSubFormRemapOptions; 68 | 69 | type NgxSubFormArray = ArrayPropertyKey extends never 70 | ? {} // no point defining `createFormArrayControl` if there's not a single array in the `FormInterface` 71 | : NgxSubFormArrayOptions; 72 | 73 | export type NgxSubFormOptions< 74 | ControlInterface, 75 | FormInterface extends {} = ControlInterface extends {} ? ControlInterface : never, 76 | > = { 77 | formType: FormType; 78 | formControls: Controls; 79 | formGroupOptions?: FormGroupOptions; 80 | emitNullOnDestroy?: boolean; 81 | componentHooks?: ComponentHooks; 82 | // emit on this observable to mark the control as touched 83 | touched$?: Observable; 84 | } & NgxSubFormRemap & 85 | NgxSubFormArray; 86 | 87 | export type NgxRootFormOptions< 88 | ControlInterface, 89 | FormInterface extends {} = ControlInterface extends {} ? ControlInterface : never, 90 | > = NgxSubFormOptions & { 91 | input$: Observable; 92 | output$: Subject; 93 | disabled$?: Observable; 94 | // by default, a root form is considered as an automatic root form 95 | // if you want to transform it into a manual root form, provide the 96 | // following observable which trigger a save every time a value is emitted 97 | manualSave$?: Observable; 98 | // The default behavior is to compare the current transformed value of input$ with the current value of the form, and 99 | // if these are equal emission on output$ is suppressed to prevent the from broadcasting the current value. 100 | // Configure this option to provide your own custom predicate whether or not the form should emit. 101 | outputFilterPredicate?: (currentInputValue: FormInterface, outputValue: FormInterface) => boolean; 102 | // if you want to control how frequently the form emits on the output$, you can customise the emission rate with this 103 | // option. e.g. `handleEmissionRate: formValue$ => formValue$.pipe(debounceTime(300)),` 104 | handleEmissionRate?: (obs$: Observable) => Observable; 105 | }; 106 | 107 | export enum FormType { 108 | SUB = 'Sub', 109 | ROOT = 'Root', 110 | } 111 | 112 | export type NgxFormOptions = 113 | | NgxSubFormOptions 114 | | NgxRootFormOptions; 115 | -------------------------------------------------------------------------------- /projects/ngx-sub-form/src/lib/shared/ngx-sub-form-utils.ts: -------------------------------------------------------------------------------- 1 | import { InjectionToken, Type } from '@angular/core'; 2 | import { 3 | AbstractControl, 4 | ControlValueAccessor, 5 | NG_VALIDATORS, 6 | NG_VALUE_ACCESSOR, 7 | UntypedFormArray, 8 | UntypedFormControl, 9 | UntypedFormGroup, 10 | ValidationErrors, 11 | } from '@angular/forms'; 12 | import { getObservableLifecycle } from 'ngx-observable-lifecycle'; 13 | import { Observable, timer } from 'rxjs'; 14 | import { debounce, takeUntil } from 'rxjs/operators'; 15 | 16 | export type Controls = { [K in keyof T]-?: AbstractControl }; 17 | 18 | export type ControlsNames = { [K in keyof T]-?: K }; 19 | 20 | export type ControlMap = { [K in keyof T]-?: V }; 21 | 22 | export type ControlsType = { 23 | [K in keyof T]-?: T[K] extends any[] 24 | ? TypedFormArray 25 | : TypedFormControl | (T[K] extends {} ? TypedFormGroup : never); 26 | }; 27 | 28 | export type OneOfControlsTypes = ControlsType[keyof ControlsType]; 29 | 30 | // @todo rename to `FormErrorsType` once the deprecated one is removed 31 | export type NewFormErrorsType = { 32 | [K in keyof T]-?: T[K] extends any[] ? Record : ValidationErrors; 33 | }; 34 | 35 | // @todo rename to `FormErrors` once the deprecated one is removed 36 | export type NewFormErrors = null | Partial< 37 | NewFormErrorsType & { 38 | formGroup?: ValidationErrors; 39 | } 40 | >; 41 | 42 | // using set/patch value options signature from form controls to allow typing without additional casting 43 | export interface TypedAbstractControl extends AbstractControl { 44 | value: TValue; 45 | valueChanges: Observable; 46 | setValue(value: TValue, options?: Parameters[1]): void; 47 | patchValue(value: Partial, options?: Parameters[1]): void; 48 | } 49 | 50 | export interface TypedFormGroup extends UntypedFormGroup { 51 | value: TValue; 52 | valueChanges: Observable; 53 | controls: ControlsType; 54 | setValue(value: TValue, options?: Parameters[1]): void; 55 | patchValue(value: Partial, options?: Parameters[1]): void; 56 | getRawValue(): TValue; 57 | } 58 | 59 | export interface TypedFormArray extends UntypedFormArray { 60 | value: TValue; 61 | valueChanges: Observable; 62 | controls: TypedAbstractControl[]; 63 | setValue(value: TValue, options?: Parameters[1]): void; 64 | patchValue(value: TValue, options?: Parameters[1]): void; 65 | getRawValue(): TValue; 66 | } 67 | 68 | export interface TypedFormControl extends UntypedFormControl { 69 | value: TValue; 70 | valueChanges: Observable; 71 | setValue(value: TValue, options?: Parameters[1]): void; 72 | patchValue(value: Partial, options?: Parameters[1]): void; 73 | } 74 | 75 | export type KeysWithType = { [K in keyof T]: T[K] extends V ? K : never }[keyof T]; 76 | 77 | export type ArrayPropertyKey = KeysWithType>; 78 | 79 | export type ArrayPropertyValue = ArrayPropertyKey> = T[K] extends Array 80 | ? U 81 | : never; 82 | 83 | export function subformComponentProviders(component: any): { 84 | provide: InjectionToken; 85 | useExisting: Type; 86 | multi?: boolean; 87 | }[] { 88 | return [ 89 | { 90 | provide: NG_VALUE_ACCESSOR, 91 | useExisting: component, 92 | multi: true, 93 | }, 94 | { 95 | provide: NG_VALIDATORS, 96 | useExisting: component, 97 | multi: true, 98 | }, 99 | ]; 100 | } 101 | 102 | const wrapAsQuote = (str: string): string => `"${str}"`; 103 | 104 | export class MissingFormControlsError extends Error { 105 | constructor(missingFormControls: T[]) { 106 | super( 107 | `Attempt to update the form value with an object that doesn't contains some of the required form control keys.\nMissing: ${missingFormControls 108 | .map(wrapAsQuote) 109 | .join(`, `)}`, 110 | ); 111 | } 112 | } 113 | 114 | export const NGX_SUB_FORM_HANDLE_VALUE_CHANGES_RATE_STRATEGIES = { 115 | debounce: 116 | (time: number) => 117 | (obs: Observable): Observable => 118 | obs.pipe(debounce(() => timer(time))), 119 | }; 120 | 121 | /** 122 | * Easily unsubscribe from an observable stream by appending `takeUntilDestroyed(this)` to the observable pipe. 123 | * If the component already has a `ngOnDestroy` method defined, it will call this first. 124 | */ 125 | export function takeUntilDestroyed(component: any): (source: Observable) => Observable { 126 | const { ngOnDestroy } = getObservableLifecycle(component); 127 | return (source: Observable): Observable => source.pipe(takeUntil(ngOnDestroy)); 128 | } 129 | 130 | /** @internal */ 131 | export function isNullOrUndefined(obj: any): obj is null | undefined { 132 | return obj === null || obj === undefined; 133 | } 134 | -------------------------------------------------------------------------------- /projects/ngx-sub-form/src/lib/shared/ngx-sub-form.types.spec.ts: -------------------------------------------------------------------------------- 1 | import { UntypedFormControl, UntypedFormGroup } from '@angular/forms'; 2 | import { TypedFormGroup } from 'ngx-sub-form'; 3 | 4 | describe(`TypedFormGroup`, () => { 5 | it(`should be assignable to the base @angular/forms FormGroup`, () => { 6 | let formGroup: UntypedFormGroup; 7 | 8 | const typedFormGroup = new UntypedFormGroup({ foo: new UntypedFormControl() }) as TypedFormGroup<{ foo: true }>; 9 | 10 | formGroup = typedFormGroup; 11 | 12 | expect(true).toBe(true); // this is a type-only test, if the type breaks the test will not compile 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /projects/ngx-sub-form/src/lib/shared/ngx-sub-form.types.ts: -------------------------------------------------------------------------------- 1 | import { UntypedFormControl, ValidationErrors } from '@angular/forms'; 2 | import { Observable } from 'rxjs'; 3 | import { ArrayPropertyKey, ArrayPropertyValue, TypedFormGroup } from './ngx-sub-form-utils'; 4 | 5 | type Nullable = T | null; 6 | 7 | export type NullableObject = { [P in keyof T]: Nullable }; 8 | 9 | export type TypedValidatorFn = (formGroup: TypedFormGroup) => ValidationErrors | null; 10 | 11 | export type TypedAsyncValidatorFn = ( 12 | formGroup: TypedFormGroup, 13 | ) => Promise | Observable; 14 | 15 | export interface FormGroupOptions { 16 | /** 17 | * @description The list of validators applied to a control. 18 | */ 19 | validators?: TypedValidatorFn | TypedValidatorFn[] | null; 20 | /** 21 | * @description The list of async validators applied to control. 22 | */ 23 | asyncValidators?: TypedAsyncValidatorFn | TypedAsyncValidatorFn[] | null; 24 | /** 25 | * @description The event name for control to update upon. 26 | */ 27 | updateOn?: 'change' | 'blur' | 'submit'; 28 | } 29 | 30 | // Unfortunately due to https://github.com/microsoft/TypeScript/issues/13995#issuecomment-504664533 the initial value 31 | // cannot be fully type narrowed to the exact type that will be passed. 32 | export interface NgxFormWithArrayControls { 33 | createFormArrayControl(key: ArrayPropertyKey, value: ArrayPropertyValue): UntypedFormControl; 34 | } 35 | -------------------------------------------------------------------------------- /projects/ngx-sub-form/src/public_api.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Public API Surface of sub-form 3 | */ 4 | 5 | export * from './lib/shared/ngx-sub-form-utils'; 6 | export * from './lib/shared/ngx-sub-form.types'; 7 | 8 | export * from './lib/helpers'; 9 | export * from './lib/create-form'; 10 | export * from './lib/ngx-sub-form.types'; 11 | -------------------------------------------------------------------------------- /projects/ngx-sub-form/src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js'; 4 | import 'zone.js/testing'; 5 | import { getTestBed } from '@angular/core/testing'; 6 | import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; 7 | import 'core-js/proposals/reflect-metadata'; 8 | 9 | // First, initialize the Angular testing environment. 10 | getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting(), { 11 | teardown: { destroyAfterEach: false }, 12 | }); 13 | -------------------------------------------------------------------------------- /projects/ngx-sub-form/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../out-tsc/lib", 5 | "declarationMap": true, 6 | "module": "es2015", 7 | "moduleResolution": "node", 8 | "declaration": true, 9 | "sourceMap": true, 10 | "inlineSources": true, 11 | "experimentalDecorators": true, 12 | "importHelpers": true, 13 | "types": [], 14 | "lib": ["dom", "es2018"] 15 | }, 16 | "angularCompilerOptions": { 17 | "skipTemplateCodegen": true, 18 | "strictMetadataEmit": true, 19 | "strictTemplates": true, 20 | "strictInjectionParameters": true, 21 | "enableResourceInlining": true 22 | }, 23 | "exclude": ["src/test.ts", "**/*.spec.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /projects/ngx-sub-form/tsconfig.lib.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.lib.json", 3 | "compilerOptions": { 4 | "declarationMap": false 5 | }, 6 | "angularCompilerOptions": { 7 | "enableIvy": true, 8 | "compilationMode": "partial" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /projects/ngx-sub-form/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../out-tsc/spec", 5 | "types": ["jasmine", "node"] 6 | }, 7 | "files": ["src/test.ts"], 8 | "include": ["**/*.spec.ts", "**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /readme-assets/assets-to-build-the-basic-api-usage-picture/address-control.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudnc/ngx-sub-form/2784202be44cb059fcc11f05477cce7d8a593c0b/readme-assets/assets-to-build-the-basic-api-usage-picture/address-control.png -------------------------------------------------------------------------------- /readme-assets/assets-to-build-the-basic-api-usage-picture/basic-api-usages.ts: -------------------------------------------------------------------------------- 1 | // code used for the basic api usage picture 2 | // process if we need to update this: 3 | // create 3 code snippets as pictures using carbon.now.sh 4 | // here's the exact configuration used: https://carbon.now.sh/?bg=rgba%2882%2C161%2C209%2C1%29&t=seti&wt=none&l=application%2Ftypescript&ds=false&dsyoff=20px&dsblur=68px&wc=false&wa=true&pv=56px&ph=56px&ln=false&fl=1&fm=Hack&fs=14px&lh=152%25&si=false&es=2x&wm=false&code=code%2520goes%2520here 5 | // open up `ngx-sub-form-mini-presentation.psd` here: https://www.photopea.com 6 | // replace the pictures 7 | // export the new psd file and the new png to the repo 8 | @Component({ 9 | selector: 'person-container', 10 | template: ` 11 | 12 | `, 13 | }) 14 | export class PersonContainer { 15 | public person$: Observable = this.personService.person$; 16 | 17 | constructor(private personService: PersonService) {} 18 | 19 | public personUpdate(person: Person): void { 20 | this.personService.update(person); 21 | } 22 | } 23 | 24 | // note on this one, remove the `prettier-ignore` comment once 25 | // the code is on carbon.now.sh, this is just to keep a good 26 | // ratio for the image in the readme 27 | @Component({ 28 | selector: 'person-form', 29 | // prettier-ignore 30 | template: ` 31 |
32 | 33 | 34 | 37 |
38 | `, 39 | }) 40 | export class PersonForm { 41 | private input$: Subject = new Subject(); 42 | @Input() set person(person: Person | undefined) { 43 | this.input$.next(person); 44 | } 45 | 46 | private disabled$: Subject = new Subject(); 47 | @Input() set disabled(value: boolean | undefined) { 48 | this.disabled$.next(!!value); 49 | } 50 | 51 | @Output() personUpdate: Subject = new Subject(); 52 | 53 | public form = createForm(this, { 54 | formType: FormType.ROOT, 55 | disabled$: this.disabled$, 56 | input$: this.input$, 57 | output$: this.personUpdate, 58 | formControls: { 59 | id: new FormControl(null, Validators.required), 60 | firstName: new FormControl(null, Validators.required), 61 | lastName: new FormControl(null, Validators.required), 62 | address: new FormControl(null, Validators.required), 63 | }, 64 | }); 65 | } 66 | 67 | @Component({ 68 | selector: 'address-control', 69 | template: ` 70 |
71 | 72 | 73 | 74 | 75 |
76 | `, 77 | providers: subformComponentProviders(PersonForm), 78 | }) 79 | export class PersonForm { 80 | public form = createForm
(this, { 81 | formType: FormType.SUB, 82 | formControls: { 83 | street: new FormControl(null, Validators.required), 84 | city: new FormControl(null, Validators.required), 85 | state: new FormControl(null, Validators.required), 86 | zipCode: new FormControl(null, Validators.required), 87 | }, 88 | }); 89 | } 90 | -------------------------------------------------------------------------------- /readme-assets/assets-to-build-the-basic-api-usage-picture/ngx-sub-form-mini-presentation.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudnc/ngx-sub-form/2784202be44cb059fcc11f05477cce7d8a593c0b/readme-assets/assets-to-build-the-basic-api-usage-picture/ngx-sub-form-mini-presentation.psd -------------------------------------------------------------------------------- /readme-assets/assets-to-build-the-basic-api-usage-picture/person-container.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudnc/ngx-sub-form/2784202be44cb059fcc11f05477cce7d8a593c0b/readme-assets/assets-to-build-the-basic-api-usage-picture/person-container.png -------------------------------------------------------------------------------- /readme-assets/assets-to-build-the-basic-api-usage-picture/person-form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudnc/ngx-sub-form/2784202be44cb059fcc11f05477cce7d8a593c0b/readme-assets/assets-to-build-the-basic-api-usage-picture/person-form.png -------------------------------------------------------------------------------- /readme-assets/basic-api-usage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudnc/ngx-sub-form/2784202be44cb059fcc11f05477cce7d8a593c0b/readme-assets/basic-api-usage.png -------------------------------------------------------------------------------- /src/.browserslistrc: -------------------------------------------------------------------------------- 1 | # This file is currently used by autoprefixer to adjust CSS to support the below specified browsers 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | # 5 | # For IE 9-11 support, please remove 'not' from the last line of the file and adjust as needed 6 | 7 | > 0.5% 8 | last 2 versions 9 | Firefox ESR 10 | not dead 11 | not IE 9-11 -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | app-main { 2 | display: block; 3 | margin: 0; 4 | width: 100%; 5 | height: 100%; 6 | } 7 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-root', 5 | templateUrl: './app.component.html', 6 | styleUrls: ['./app.component.scss'], 7 | standalone: false, 8 | }) 9 | export class AppComponent {} 10 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { BrowserModule } from '@angular/platform-browser'; 3 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; 4 | import { RouterModule } from '@angular/router'; 5 | import { AppComponent } from './app.component'; 6 | import { SharedModule } from './shared/shared.module'; 7 | 8 | @NgModule({ 9 | declarations: [AppComponent], 10 | imports: [ 11 | BrowserModule, 12 | BrowserAnimationsModule, 13 | RouterModule.forRoot([ 14 | { 15 | path: '', 16 | loadChildren: () => import('./main/main.module').then(x => x.MainModule), 17 | }, 18 | ]), 19 | SharedModule, 20 | ], 21 | providers: [], 22 | bootstrap: [AppComponent], 23 | }) 24 | export class AppModule {} 25 | -------------------------------------------------------------------------------- /src/app/interfaces/crew-member.interface.ts: -------------------------------------------------------------------------------- 1 | export interface CrewMember { 2 | firstName: string; 3 | lastName: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/app/interfaces/droid.interface.ts: -------------------------------------------------------------------------------- 1 | export enum DroidType { 2 | PROTOCOL = 'Protocol', 3 | MEDICAL = 'Medical', 4 | ASTROMECH = 'Astromech', 5 | ASSASSIN = 'Assassin', 6 | } 7 | 8 | export interface BaseDroid { 9 | color: string; 10 | name: string; 11 | } 12 | 13 | export enum Languages { 14 | DROIDSPEAK = 'Droidspeak', 15 | EWOKESE = 'Ewokese', 16 | HUTTESE = 'Huttese', 17 | JAWAESE = 'Jawaese', 18 | SITH = 'Sith', 19 | SHYRIIWOOK = 'Shyriiwook', 20 | } 21 | 22 | export interface ProtocolDroid extends BaseDroid { 23 | droidType: DroidType.PROTOCOL; 24 | spokenLanguages: Languages[]; 25 | } 26 | 27 | export interface MedicalDroid extends BaseDroid { 28 | droidType: DroidType.MEDICAL; 29 | canHealHumans: boolean; 30 | canFixRobots: boolean; 31 | } 32 | 33 | export enum AstromechDroidShape { 34 | REGULAR = 'Regular', 35 | SPHERE = 'Sphere', 36 | } 37 | 38 | export interface AstromechDroid extends BaseDroid { 39 | droidType: DroidType.ASTROMECH; 40 | toolCount: number; 41 | shape: AstromechDroidShape; 42 | } 43 | 44 | export enum AssassinDroidWeapon { 45 | SABER = 'Saber', 46 | FLAME_THROWER = 'FlameThrower', 47 | GUN = 'Gun', 48 | AXE = 'Axe', 49 | } 50 | 51 | export interface AssassinDroid extends BaseDroid { 52 | droidType: DroidType.ASSASSIN; 53 | weapons: AssassinDroidWeapon[]; 54 | } 55 | 56 | export type OneDroid = ProtocolDroid | MedicalDroid | AstromechDroid | AssassinDroid; 57 | -------------------------------------------------------------------------------- /src/app/interfaces/listing.interface.ts: -------------------------------------------------------------------------------- 1 | import { OneDroid } from './droid.interface'; 2 | import { OneVehicle } from './vehicle.interface'; 3 | 4 | export enum ListingType { 5 | VEHICLE = 'Vehicle', 6 | DROID = 'Droid', 7 | } 8 | 9 | export interface BaseListing { 10 | id: string; 11 | title: string; 12 | imageUrl: string; 13 | price: number; 14 | } 15 | 16 | export interface VehicleListing extends BaseListing { 17 | listingType: ListingType.VEHICLE; 18 | product: OneVehicle; 19 | } 20 | 21 | export interface DroidListing extends BaseListing { 22 | listingType: ListingType.DROID; 23 | product: OneDroid; 24 | } 25 | 26 | export type OneListing = VehicleListing | DroidListing; 27 | -------------------------------------------------------------------------------- /src/app/interfaces/vehicle.interface.ts: -------------------------------------------------------------------------------- 1 | import { CrewMember } from './crew-member.interface'; 2 | 3 | export enum VehicleType { 4 | SPACESHIP = 'Spaceship', 5 | SPEEDER = 'Speeder', 6 | } 7 | 8 | export interface BaseVehicle { 9 | color: string; 10 | canFire: boolean; 11 | crewMembers: CrewMember[]; 12 | } 13 | 14 | export interface Spaceship extends BaseVehicle { 15 | vehicleType: VehicleType.SPACESHIP; 16 | wingCount: number; 17 | } 18 | 19 | export interface Speeder extends BaseVehicle { 20 | vehicleType: VehicleType.SPEEDER; 21 | maximumSpeed: number; 22 | } 23 | 24 | export type OneVehicle = Spaceship | Speeder; 25 | -------------------------------------------------------------------------------- /src/app/main/listing/listing-form/droid-listing/assassin-droid/assassin-droid.component.html: -------------------------------------------------------------------------------- 1 |
2 | Assassin Droid form 3 | 4 | 5 | 13 | 14 | 15 | 16 | 24 | 25 | 26 | 27 | 33 | 38 | {{ assassinDroidWeaponText[weapon.value] }} 39 | 40 | 41 | 42 |
43 | -------------------------------------------------------------------------------- /src/app/main/listing/listing-form/droid-listing/assassin-droid/assassin-droid.component.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-direction: column; 4 | } 5 | -------------------------------------------------------------------------------- /src/app/main/listing/listing-form/droid-listing/assassin-droid/assassin-droid.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | import { UntypedFormControl, Validators } from '@angular/forms'; 3 | import { createForm, FormType, subformComponentProviders } from 'ngx-sub-form'; 4 | import { AssassinDroid, AssassinDroidWeapon, DroidType } from 'src/app/interfaces/droid.interface'; 5 | 6 | export const ASSASSIN_DROID_WEAPON_TEXT: { [K in AssassinDroidWeapon]: string } = { 7 | [AssassinDroidWeapon.SABER]: 'Saber', 8 | [AssassinDroidWeapon.FLAME_THROWER]: 'Flame thrower', 9 | [AssassinDroidWeapon.GUN]: 'Gun', 10 | [AssassinDroidWeapon.AXE]: 'Axe', 11 | }; 12 | 13 | @Component({ 14 | selector: 'app-assassin-droid', 15 | templateUrl: './assassin-droid.component.html', 16 | styleUrls: ['./assassin-droid.component.scss'], 17 | providers: subformComponentProviders(AssassinDroidComponent), 18 | changeDetection: ChangeDetectionStrategy.OnPush, 19 | standalone: false, 20 | }) 21 | export class AssassinDroidComponent { 22 | public AssassinDroidWeapon = AssassinDroidWeapon; 23 | 24 | public assassinDroidWeaponText = ASSASSIN_DROID_WEAPON_TEXT; 25 | 26 | public form = createForm(this, { 27 | formType: FormType.SUB, 28 | formControls: { 29 | color: new UntypedFormControl(null, { validators: [Validators.required] }), 30 | name: new UntypedFormControl(null, { validators: [Validators.required] }), 31 | droidType: new UntypedFormControl(DroidType.ASSASSIN, { validators: [Validators.required] }), 32 | weapons: new UntypedFormControl([], { validators: [Validators.required] }), 33 | }, 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /src/app/main/listing/listing-form/droid-listing/astromech-droid/astromech-droid.component.html: -------------------------------------------------------------------------------- 1 |
2 | Astromech Droid form 3 | 4 | 5 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 26 | 27 | 28 | 29 | 30 | 31 | {{ shape.value }} 32 | 33 | 34 | 35 |
36 | -------------------------------------------------------------------------------- /src/app/main/listing/listing-form/droid-listing/astromech-droid/astromech-droid.component.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-direction: column; 4 | } 5 | -------------------------------------------------------------------------------- /src/app/main/listing/listing-form/droid-listing/astromech-droid/astromech-droid.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | import { UntypedFormControl, Validators } from '@angular/forms'; 3 | import { createForm, FormType, subformComponentProviders } from 'ngx-sub-form'; 4 | import { AstromechDroid, AstromechDroidShape, DroidType } from '../../../../../interfaces/droid.interface'; 5 | 6 | @Component({ 7 | selector: 'app-astromech-droid', 8 | templateUrl: './astromech-droid.component.html', 9 | styleUrls: ['./astromech-droid.component.scss'], 10 | providers: subformComponentProviders(AstromechDroidComponent), 11 | changeDetection: ChangeDetectionStrategy.OnPush, 12 | standalone: false, 13 | }) 14 | export class AstromechDroidComponent { 15 | public AstromechDroidShape = AstromechDroidShape; 16 | 17 | public form = createForm(this, { 18 | formType: FormType.SUB, 19 | formControls: { 20 | color: new UntypedFormControl(null, { validators: [Validators.required] }), 21 | name: new UntypedFormControl(null, { validators: [Validators.required] }), 22 | droidType: new UntypedFormControl(DroidType.ASTROMECH, { validators: [Validators.required] }), 23 | toolCount: new UntypedFormControl(null, { validators: [Validators.required] }), 24 | shape: new UntypedFormControl(null, { validators: [Validators.required] }), 25 | }, 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /src/app/main/listing/listing-form/droid-listing/droid-product.component.html: -------------------------------------------------------------------------------- 1 |
2 | Droid form 3 | 4 | 5 | 10 | 15 | {{ droidType.value }} 16 | 17 | 18 | 19 | 20 |
21 | 25 | 29 | 33 | 37 |
38 |
39 | -------------------------------------------------------------------------------- /src/app/main/listing/listing-form/droid-listing/droid-product.component.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-direction: column; 4 | } 5 | -------------------------------------------------------------------------------- /src/app/main/listing/listing-form/droid-listing/droid-product.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | import { UntypedFormControl, Validators } from '@angular/forms'; 3 | import { createForm, FormType, subformComponentProviders } from 'ngx-sub-form'; 4 | import { 5 | AssassinDroid, 6 | AstromechDroid, 7 | DroidType, 8 | MedicalDroid, 9 | OneDroid, 10 | ProtocolDroid, 11 | } from 'src/app/interfaces/droid.interface'; 12 | import { UnreachableCase } from '../../../../shared/utils'; 13 | 14 | interface OneDroidForm { 15 | protocolDroid: ProtocolDroid | null; 16 | medicalDroid: MedicalDroid | null; 17 | astromechDroid: AstromechDroid | null; 18 | assassinDroid: AssassinDroid | null; 19 | droidType: DroidType | null; 20 | } 21 | 22 | @Component({ 23 | selector: 'app-droid-product', 24 | templateUrl: './droid-product.component.html', 25 | styleUrls: ['./droid-product.component.scss'], 26 | providers: subformComponentProviders(DroidProductComponent), 27 | changeDetection: ChangeDetectionStrategy.OnPush, 28 | standalone: false, 29 | }) 30 | export class DroidProductComponent { 31 | public DroidType = DroidType; 32 | 33 | public form = createForm(this, { 34 | formType: FormType.SUB, 35 | formControls: { 36 | protocolDroid: new UntypedFormControl(null), 37 | medicalDroid: new UntypedFormControl(null), 38 | astromechDroid: new UntypedFormControl(null), 39 | assassinDroid: new UntypedFormControl(null), 40 | droidType: new UntypedFormControl(null, { validators: [Validators.required] }), 41 | }, 42 | toFormGroup: (obj: OneDroid): OneDroidForm => { 43 | return { 44 | protocolDroid: obj.droidType === DroidType.PROTOCOL ? obj : null, 45 | medicalDroid: obj.droidType === DroidType.MEDICAL ? obj : null, 46 | astromechDroid: obj.droidType === DroidType.ASTROMECH ? obj : null, 47 | assassinDroid: obj.droidType === DroidType.ASSASSIN ? obj : null, 48 | droidType: obj.droidType, 49 | }; 50 | }, 51 | fromFormGroup: (formValue: OneDroidForm): OneDroid => { 52 | switch (formValue.droidType) { 53 | case DroidType.PROTOCOL: 54 | return formValue.protocolDroid as any; // todo 55 | case DroidType.MEDICAL: 56 | return formValue.medicalDroid as any; // todo 57 | case DroidType.ASTROMECH: 58 | return formValue.astromechDroid as any; // todo 59 | case DroidType.ASSASSIN: 60 | return formValue.assassinDroid as any; // todo 61 | case null: 62 | return null as any; // todo 63 | default: 64 | throw new UnreachableCase(formValue.droidType); 65 | } 66 | }, 67 | }); 68 | } 69 | -------------------------------------------------------------------------------- /src/app/main/listing/listing-form/droid-listing/medical-droid/medical-droid.component.html: -------------------------------------------------------------------------------- 1 |
2 | Medical Droid form 3 | 4 | 5 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | Can heal humans 19 | 20 | Can fix robots 21 |
22 | -------------------------------------------------------------------------------- /src/app/main/listing/listing-form/droid-listing/medical-droid/medical-droid.component.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-direction: column; 4 | } 5 | -------------------------------------------------------------------------------- /src/app/main/listing/listing-form/droid-listing/medical-droid/medical-droid.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | import { UntypedFormControl, Validators } from '@angular/forms'; 3 | import { createForm, FormType, subformComponentProviders } from 'ngx-sub-form'; 4 | import { DroidType, MedicalDroid } from 'src/app/interfaces/droid.interface'; 5 | 6 | @Component({ 7 | selector: 'app-medical-droid', 8 | templateUrl: './medical-droid.component.html', 9 | styleUrls: ['./medical-droid.component.scss'], 10 | providers: subformComponentProviders(MedicalDroidComponent), 11 | changeDetection: ChangeDetectionStrategy.OnPush, 12 | standalone: false, 13 | }) 14 | export class MedicalDroidComponent { 15 | public form = createForm(this, { 16 | formType: FormType.SUB, 17 | formControls: { 18 | color: new UntypedFormControl(null, { validators: [Validators.required] }), 19 | name: new UntypedFormControl(null, { validators: [Validators.required] }), 20 | droidType: new UntypedFormControl(DroidType.MEDICAL, { validators: [Validators.required] }), 21 | canHealHumans: new UntypedFormControl(false, { validators: [Validators.required] }), 22 | canFixRobots: new UntypedFormControl(false, { validators: [Validators.required] }), 23 | }, 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /src/app/main/listing/listing-form/droid-listing/protocol-droid/protocol-droid.component.html: -------------------------------------------------------------------------------- 1 |
2 | Protocol Droid form 3 | 4 | 5 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 24 | 25 | {{ language.value }} 26 | 27 | 28 | 29 |
30 | -------------------------------------------------------------------------------- /src/app/main/listing/listing-form/droid-listing/protocol-droid/protocol-droid.component.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-direction: column; 4 | } 5 | -------------------------------------------------------------------------------- /src/app/main/listing/listing-form/droid-listing/protocol-droid/protocol-droid.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | import { UntypedFormControl, Validators } from '@angular/forms'; 3 | import { createForm, FormType, subformComponentProviders } from 'ngx-sub-form'; 4 | import { DroidType, Languages, ProtocolDroid } from '../../../../../interfaces/droid.interface'; 5 | 6 | @Component({ 7 | selector: 'app-protocol-droid', 8 | templateUrl: './protocol-droid.component.html', 9 | styleUrls: ['./protocol-droid.component.scss'], 10 | providers: subformComponentProviders(ProtocolDroidComponent), 11 | changeDetection: ChangeDetectionStrategy.OnPush, 12 | standalone: false, 13 | }) 14 | export class ProtocolDroidComponent { 15 | public Languages = Languages; 16 | 17 | public form = createForm(this, { 18 | formType: FormType.SUB, 19 | formControls: { 20 | color: new UntypedFormControl(null, { validators: [Validators.required] }), 21 | name: new UntypedFormControl(null, { validators: [Validators.required] }), 22 | droidType: new UntypedFormControl(DroidType.PROTOCOL, { validators: [Validators.required] }), 23 | spokenLanguages: new UntypedFormControl(null, { validators: [Validators.required] }), 24 | }, 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /src/app/main/listing/listing-form/listing-form.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ form.formGroup.value.title }} 5 | 6 | 7 | £{{ form.formGroup.value.price | number }} 8 | 9 | 10 |
11 | Photo of {{ form.formGroup.value.title }} 17 |
18 | 19 |
20 | 21 | 29 | 30 | 31 | 32 | 33 | ID is required 34 | 35 | 36 | 44 | 45 | 46 | Title is required 47 | 48 | 49 | 57 | 58 | 59 | 60 | Image url is required 61 | 62 | 63 | 64 | 72 | 73 | 74 | Price is required 75 | 76 | 77 | 82 | 87 | {{ listingType.value }} 88 | 89 | 90 | 91 | 92 |
93 | 97 | 98 | 102 |
103 |
104 |
105 | 106 | 120 |
121 | 122 | 130 | 131 |
Form is invalid
132 |
133 |
134 |
135 | 136 | 137 | Form errors 138 | 139 | 140 | 144 |
{{ errors | json }}
145 | 146 | 147 | Form is valid, no error! 148 | 149 |
150 |
151 | 152 | 153 | Form values 154 | 155 | 156 |
{{ form.formGroup.value | json }}
157 |
158 |
159 | -------------------------------------------------------------------------------- /src/app/main/listing/listing-form/listing-form.component.scss: -------------------------------------------------------------------------------- 1 | img { 2 | max-width: 300px; 3 | max-height: 500px; 4 | object-fit: contain; 5 | } 6 | 7 | .img-container { 8 | width: 100%; 9 | text-align: center; 10 | } 11 | 12 | mat-card { 13 | margin-bottom: 15px; 14 | max-width: 500px; 15 | 16 | mat-card-title, 17 | mat-card-subtitle { 18 | min-height: 25px; 19 | } 20 | 21 | &.errors, 22 | &.values { 23 | mat-card-content { 24 | overflow: auto; 25 | } 26 | } 27 | } 28 | 29 | mat-form-field { 30 | width: 100%; 31 | } 32 | 33 | .invalid-form { 34 | padding: 15px 0; 35 | } 36 | -------------------------------------------------------------------------------- /src/app/main/listing/listing-form/listing-form.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, Input, Output } from '@angular/core'; 2 | import { UntypedFormControl, Validators } from '@angular/forms'; 3 | import { createForm, FormType } from 'ngx-sub-form'; 4 | import { Subject } from 'rxjs'; 5 | import { ListingType, OneListing } from 'src/app/interfaces/listing.interface'; 6 | import { OneDroid } from '../../../interfaces/droid.interface'; 7 | import { OneVehicle } from '../../../interfaces/vehicle.interface'; 8 | import { UnreachableCase } from '../../../shared/utils'; 9 | 10 | interface OneListingForm { 11 | vehicleProduct: OneVehicle | null; 12 | droidProduct: OneDroid | null; 13 | listingType: ListingType | null; 14 | id: string; 15 | title: string; 16 | imageUrl: string; 17 | price: number; 18 | } 19 | 20 | @Component({ 21 | selector: 'app-listing-form', 22 | templateUrl: './listing-form.component.html', 23 | styleUrls: ['./listing-form.component.scss'], 24 | changeDetection: ChangeDetectionStrategy.OnPush, 25 | standalone: false, 26 | }) 27 | export class ListingFormComponent { 28 | public ListingType: typeof ListingType = ListingType; 29 | 30 | private input$: Subject = new Subject(); 31 | @Input() set listing(value: OneListing | undefined) { 32 | this.input$.next(value); 33 | } 34 | 35 | private disabled$: Subject = new Subject(); 36 | @Input() set disabled(value: boolean | undefined) { 37 | this.disabled$.next(!value ? false : value); 38 | } 39 | 40 | @Output() listingUpdated: Subject = new Subject(); 41 | 42 | public manualSave$$: Subject = new Subject(); 43 | 44 | public form = createForm(this, { 45 | formType: FormType.ROOT, 46 | disabled$: this.disabled$, 47 | input$: this.input$, 48 | output$: this.listingUpdated, 49 | manualSave$: this.manualSave$$, 50 | formControls: { 51 | vehicleProduct: new UntypedFormControl(null), 52 | droidProduct: new UntypedFormControl(null), 53 | listingType: new UntypedFormControl(null, Validators.required), 54 | id: new UntypedFormControl(null, Validators.required), 55 | title: new UntypedFormControl(null, Validators.required), 56 | imageUrl: new UntypedFormControl(null, Validators.required), 57 | price: new UntypedFormControl(null, Validators.required), 58 | }, 59 | toFormGroup: (obj: OneListing): OneListingForm => { 60 | const { listingType, product, ...commonValues } = obj; 61 | 62 | return { 63 | vehicleProduct: obj.listingType === ListingType.VEHICLE ? obj.product : null, 64 | droidProduct: obj.listingType === ListingType.DROID ? obj.product : null, 65 | listingType: obj.listingType, 66 | ...commonValues, 67 | }; 68 | }, 69 | fromFormGroup: (formValue: OneListingForm): OneListing => { 70 | const { vehicleProduct, droidProduct, listingType, ...commonValues } = formValue; 71 | 72 | switch (listingType) { 73 | case ListingType.DROID: 74 | return droidProduct ? { product: droidProduct, listingType, ...commonValues } : (null as any); //todo; 75 | case ListingType.VEHICLE: 76 | return vehicleProduct ? { product: vehicleProduct, listingType, ...commonValues } : (null as any); //todo; 77 | case null: 78 | return null as any; // todo; 79 | default: 80 | throw new UnreachableCase(listingType); 81 | } 82 | }, 83 | }); 84 | } 85 | -------------------------------------------------------------------------------- /src/app/main/listing/listing-form/test-types.ts: -------------------------------------------------------------------------------- 1 | // export type Controls = { [K in keyof T]-?: AbstractControl }; 2 | 3 | type Hello = { a: number | null; b: number }; 4 | 5 | type AAAA = number | null; 6 | type BBBB = Extract; 7 | type CCCC = BBBB extends never ? never : BBBB; 8 | 9 | type H = { [key in keyof T]: Extract extends never ? key : never }; 10 | type DDDD = H; 11 | interface Person { 12 | id: string; 13 | name?: string; 14 | age: number | null; 15 | } 16 | 17 | type AAA = Exclude>; 18 | type NoUndefinedField = { [P in keyof T]-?: NoUndefinedField> }; 19 | 20 | type D = NoUndefinedField; 21 | 22 | type zzz = H; 23 | type AAAAAA = Pick>>; 24 | type KKKKKK = {} extends AAAAAA ? true : false; 25 | 26 | type B = Person['age'] extends null ? never : Person['age']; 27 | type RequiredKeys = { 28 | [K in keyof T]-?: {} extends H> ? never : K; 29 | }[keyof T]; 30 | type A = RequiredKeys; 31 | 32 | new FormGroup({}); 33 | -------------------------------------------------------------------------------- /src/app/main/listing/listing-form/vehicle-listing/crew-members/crew-member/crew-member.component.html: -------------------------------------------------------------------------------- 1 |
2 | Crew member form 3 | 4 | 5 | 13 | 14 | 15 | 16 | 24 | 25 |
26 | -------------------------------------------------------------------------------- /src/app/main/listing/listing-form/vehicle-listing/crew-members/crew-member/crew-member.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudnc/ngx-sub-form/2784202be44cb059fcc11f05477cce7d8a593c0b/src/app/main/listing/listing-form/vehicle-listing/crew-members/crew-member/crew-member.component.scss -------------------------------------------------------------------------------- /src/app/main/listing/listing-form/vehicle-listing/crew-members/crew-member/crew-member.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | import { UntypedFormControl, Validators } from '@angular/forms'; 3 | import { createForm, FormType, subformComponentProviders } from 'ngx-sub-form'; 4 | import { CrewMember } from '../../../../../../interfaces/crew-member.interface'; 5 | 6 | @Component({ 7 | selector: 'app-crew-member', 8 | templateUrl: './crew-member.component.html', 9 | styleUrls: ['./crew-member.component.scss'], 10 | providers: subformComponentProviders(CrewMemberComponent), 11 | changeDetection: ChangeDetectionStrategy.OnPush, 12 | standalone: false, 13 | }) 14 | export class CrewMemberComponent { 15 | public form = createForm(this, { 16 | formType: FormType.SUB, 17 | formControls: { 18 | firstName: new UntypedFormControl(null, [Validators.required]), 19 | lastName: new UntypedFormControl(null, [Validators.required]), 20 | }, 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /src/app/main/listing/listing-form/vehicle-listing/crew-members/crew-members.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | Crew members form 4 | (Minimum 2) 5 | 6 | 7 |
12 | 13 | 14 | 17 |
18 | 19 | 29 |
30 | -------------------------------------------------------------------------------- /src/app/main/listing/listing-form/vehicle-listing/crew-members/crew-members.component.scss: -------------------------------------------------------------------------------- 1 | .crew-member { 2 | display: flex; 3 | align-items: center; 4 | } 5 | 6 | app-crew-member { 7 | margin-bottom: 15px; 8 | } 9 | 10 | .add-crew-member { 11 | margin-top: 15px; 12 | } 13 | -------------------------------------------------------------------------------- /src/app/main/listing/listing-form/vehicle-listing/crew-members/crew-members.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | import { UntypedFormArray, UntypedFormControl, Validators } from '@angular/forms'; 3 | import { createForm, FormType, subformComponentProviders } from 'ngx-sub-form'; 4 | import { CrewMember } from '../../../../../interfaces/crew-member.interface'; 5 | 6 | interface CrewMembersForm { 7 | crewMembers: CrewMember[]; 8 | } 9 | 10 | @Component({ 11 | selector: 'app-crew-members', 12 | templateUrl: './crew-members.component.html', 13 | styleUrls: ['./crew-members.component.scss'], 14 | providers: subformComponentProviders(CrewMembersComponent), 15 | changeDetection: ChangeDetectionStrategy.OnPush, 16 | standalone: false, 17 | }) 18 | export class CrewMembersComponent { 19 | public form = createForm(this, { 20 | formType: FormType.SUB, 21 | formControls: { 22 | crewMembers: new UntypedFormArray([], { 23 | validators: formControl => (formControl.value.length >= 2 ? null : { minimumCrewMemberCount: 2 }), 24 | }), 25 | }, 26 | toFormGroup: (obj: CrewMember[]): CrewMembersForm => { 27 | return { 28 | crewMembers: !obj ? [] : obj, 29 | }; 30 | }, 31 | fromFormGroup: (formValue: CrewMembersForm): CrewMember[] => { 32 | return formValue.crewMembers; 33 | }, 34 | createFormArrayControl: (key, value) => { 35 | switch (key) { 36 | case 'crewMembers': 37 | return new UntypedFormControl(value, [Validators.required]); 38 | default: 39 | return new UntypedFormControl(value); 40 | } 41 | }, 42 | }); 43 | 44 | public removeCrewMember(index: number): void { 45 | this.form.formGroup.controls.crewMembers.removeAt(index); 46 | } 47 | 48 | public addCrewMember(): void { 49 | this.form.formGroup.controls.crewMembers.push( 50 | this.form.createFormArrayControl('crewMembers', { 51 | firstName: '', 52 | lastName: '', 53 | }), 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/app/main/listing/listing-form/vehicle-listing/spaceship/spaceship.component.html: -------------------------------------------------------------------------------- 1 |
2 | Spaceship form 3 | 4 | 5 | 13 | 14 | 15 | Can fire 16 | 17 | 18 | 26 | 27 | 28 | 29 |
30 | -------------------------------------------------------------------------------- /src/app/main/listing/listing-form/vehicle-listing/spaceship/spaceship.component.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-direction: column; 4 | } 5 | -------------------------------------------------------------------------------- /src/app/main/listing/listing-form/vehicle-listing/spaceship/spaceship.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | import { UntypedFormControl, Validators } from '@angular/forms'; 3 | import { createForm, FormType, subformComponentProviders } from 'ngx-sub-form'; 4 | import { Spaceship, VehicleType } from 'src/app/interfaces/vehicle.interface'; 5 | 6 | @Component({ 7 | selector: 'app-spaceship', 8 | templateUrl: './spaceship.component.html', 9 | styleUrls: ['./spaceship.component.scss'], 10 | providers: subformComponentProviders(SpaceshipComponent), 11 | changeDetection: ChangeDetectionStrategy.OnPush, 12 | standalone: false, 13 | }) 14 | export class SpaceshipComponent { 15 | public form = createForm(this, { 16 | formType: FormType.SUB, 17 | formControls: { 18 | color: new UntypedFormControl(null, { validators: [Validators.required] }), 19 | canFire: new UntypedFormControl(false, { validators: [Validators.required] }), 20 | crewMembers: new UntypedFormControl(null, { validators: [Validators.required] }), 21 | wingCount: new UntypedFormControl(null, { validators: [Validators.required] }), 22 | vehicleType: new UntypedFormControl(VehicleType.SPACESHIP, { validators: [Validators.required] }), 23 | }, 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /src/app/main/listing/listing-form/vehicle-listing/speeder/speeder.component.html: -------------------------------------------------------------------------------- 1 |
2 | Speeder form 3 | 4 | 5 | 13 | 14 | 15 | Can fire 16 | 17 | 18 | 19 | 20 | 28 | 29 |
30 | -------------------------------------------------------------------------------- /src/app/main/listing/listing-form/vehicle-listing/speeder/speeder.component.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-direction: column; 4 | } 5 | -------------------------------------------------------------------------------- /src/app/main/listing/listing-form/vehicle-listing/speeder/speeder.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | import { UntypedFormControl, Validators } from '@angular/forms'; 3 | import { createForm, FormType, subformComponentProviders } from 'ngx-sub-form'; 4 | import { Speeder, VehicleType } from 'src/app/interfaces/vehicle.interface'; 5 | 6 | @Component({ 7 | selector: 'app-speeder', 8 | templateUrl: './speeder.component.html', 9 | styleUrls: ['./speeder.component.scss'], 10 | providers: subformComponentProviders(SpeederComponent), 11 | changeDetection: ChangeDetectionStrategy.OnPush, 12 | standalone: false, 13 | }) 14 | export class SpeederComponent { 15 | public form = createForm(this, { 16 | formType: FormType.SUB, 17 | formControls: { 18 | color: new UntypedFormControl(null, { validators: [Validators.required] }), 19 | canFire: new UntypedFormControl(false, { validators: [Validators.required] }), 20 | crewMembers: new UntypedFormControl(null, { validators: [Validators.required] }), 21 | vehicleType: new UntypedFormControl(VehicleType.SPEEDER, { validators: [Validators.required] }), 22 | maximumSpeed: new UntypedFormControl(null, { validators: [Validators.required] }), 23 | }, 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /src/app/main/listing/listing-form/vehicle-listing/vehicle-product.component.html: -------------------------------------------------------------------------------- 1 |
2 | Vehicle form 3 | 4 | 5 | 10 | 15 | {{ vehicleType.value }} 16 | 17 | 18 | 19 | 20 |
21 | 25 | 26 | 27 |
28 |
29 | -------------------------------------------------------------------------------- /src/app/main/listing/listing-form/vehicle-listing/vehicle-product.component.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-direction: column; 4 | } 5 | -------------------------------------------------------------------------------- /src/app/main/listing/listing-form/vehicle-listing/vehicle-product.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | import { UntypedFormControl, Validators } from '@angular/forms'; 3 | import { createForm, FormType, subformComponentProviders } from 'ngx-sub-form'; 4 | import { OneVehicle, Spaceship, Speeder, VehicleType } from 'src/app/interfaces/vehicle.interface'; 5 | import { UnreachableCase } from 'src/app/shared/utils'; 6 | 7 | export interface OneVehicleForm { 8 | speeder: Speeder | null; 9 | spaceship: Spaceship | null; 10 | vehicleType: VehicleType | null; 11 | } 12 | 13 | @Component({ 14 | selector: 'app-vehicle-product', 15 | templateUrl: './vehicle-product.component.html', 16 | styleUrls: ['./vehicle-product.component.scss'], 17 | providers: subformComponentProviders(VehicleProductComponent), 18 | changeDetection: ChangeDetectionStrategy.OnPush, 19 | standalone: false, 20 | }) 21 | export class VehicleProductComponent { 22 | public VehicleType = VehicleType; 23 | 24 | public form = createForm(this, { 25 | formType: FormType.SUB, 26 | formControls: { 27 | speeder: new UntypedFormControl(null), 28 | spaceship: new UntypedFormControl(null), 29 | vehicleType: new UntypedFormControl(null, { validators: [Validators.required] }), 30 | }, 31 | toFormGroup: (obj: OneVehicle): OneVehicleForm => { 32 | return { 33 | speeder: obj.vehicleType === VehicleType.SPEEDER ? obj : null, 34 | spaceship: obj.vehicleType === VehicleType.SPACESHIP ? obj : null, 35 | vehicleType: obj.vehicleType, 36 | }; 37 | }, 38 | fromFormGroup: (formValue: OneVehicleForm): OneVehicle => { 39 | switch (formValue.vehicleType) { 40 | case VehicleType.SPEEDER: 41 | return formValue.speeder as any; // todo 42 | case VehicleType.SPACESHIP: 43 | return formValue.spaceship as any; // todo 44 | case null: 45 | return null as any; //todo 46 | default: 47 | throw new UnreachableCase(formValue.vehicleType); 48 | } 49 | }, 50 | }); 51 | } 52 | -------------------------------------------------------------------------------- /src/app/main/listing/listing.component.html: -------------------------------------------------------------------------------- 1 | Readonly 2 | 3 | 8 | -------------------------------------------------------------------------------- /src/app/main/listing/listing.component.scss: -------------------------------------------------------------------------------- 1 | .readonly { 2 | padding: 15px 0; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/main/listing/listing.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | import { UntypedFormControl } from '@angular/forms'; 3 | import { ActivatedRoute } from '@angular/router'; 4 | import { NullableObject } from 'ngx-sub-form'; 5 | import { Observable, of } from 'rxjs'; 6 | import { map, switchMap } from 'rxjs/operators'; 7 | import { OneListing } from 'src/app/interfaces/listing.interface'; 8 | import { ListingService } from 'src/app/services/listing.service'; 9 | import { UuidService } from '../../services/uuid.service'; 10 | 11 | @Component({ 12 | selector: 'app-listing', 13 | templateUrl: './listing.component.html', 14 | styleUrls: ['./listing.component.scss'], 15 | changeDetection: ChangeDetectionStrategy.OnPush, 16 | standalone: false, 17 | }) 18 | export class ListingComponent { 19 | public readonlyFormControl: UntypedFormControl = new UntypedFormControl(false); 20 | 21 | constructor( 22 | private route: ActivatedRoute, 23 | private listingService: ListingService, 24 | private uuidService: UuidService, 25 | ) {} 26 | 27 | public listing$: Observable> = this.route.paramMap.pipe( 28 | map(params => params.get('listingId')), 29 | switchMap(listingId => { 30 | if (listingId === 'new' || !listingId) { 31 | return of(null); 32 | } 33 | return this.listingService.getOneListing(listingId); 34 | }), 35 | map(listing => (listing ? listing : this.emptyListing())), 36 | ); 37 | 38 | private emptyListing(): NullableObject { 39 | return { 40 | id: this.uuidService.generate(), 41 | listingType: null, 42 | title: null, 43 | imageUrl: null, 44 | price: null, 45 | product: null, 46 | }; 47 | } 48 | 49 | public upsertListing(listing: OneListing): void { 50 | this.listingService.upsertListing(listing); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/app/main/listings/display-crew-members.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | import { CrewMember } from 'src/app/interfaces/crew-member.interface'; 3 | 4 | @Pipe({ 5 | name: 'displayCrewMembers', 6 | standalone: false, 7 | }) 8 | export class DisplayCrewMembersPipe implements PipeTransform { 9 | transform(crewMembers: CrewMember[]): string { 10 | return crewMembers.map(crewMember => `${crewMember.firstName} ${crewMember.lastName}`).join(', '); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/app/main/listings/listings.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ listing.title }} 5 | ( 6 | {{ listing.listingType }} 7 | ) £ 8 | {{ listing.price | number }} 9 | 10 | 11 | 12 | {{ listing.product.droidType }} 13 | - 14 | 15 | 16 | Weapons: {{ listing.product.weapons.join(', ') }} 17 | 18 | Number of tools: {{ listing.product.toolCount }} 19 | 20 | 21 | {{ listing.product.canHealHumans ? 'Can' : "Can't" }} heal humans, 22 | {{ listing.product.canFixRobots ? 'can' : "can't" }} fix robots 23 | 24 | 25 | 26 | Spoken languages: {{ listing.product.spokenLanguages.join(', ') }} 27 | 28 | 29 | 30 | 31 | 32 | {{ listing.product.vehicleType }} 33 | - 34 | 35 | 36 | Crew members: {{ listing.product.crewMembers | displayCrewMembers }}, 37 | {{ listing.product.canFire ? 'can' : "can't" }} fire, 38 | 39 | maximum speed: {{ listing.product.maximumSpeed }}kph 40 | 41 | number of wings: {{ listing.product.wingCount }} 42 | 43 | 44 | 45 | 46 | 47 | 48 | Create new 49 | -------------------------------------------------------------------------------- /src/app/main/listings/listings.component.scss: -------------------------------------------------------------------------------- 1 | mat-nav-list { 2 | padding: 0; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/main/listings/listings.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; 2 | import { DroidType } from 'src/app/interfaces/droid.interface'; 3 | import { VehicleType } from 'src/app/interfaces/vehicle.interface'; 4 | import { ListingType, OneListing } from '../../interfaces/listing.interface'; 5 | 6 | @Component({ 7 | selector: 'app-listings', 8 | templateUrl: './listings.component.html', 9 | styleUrls: ['./listings.component.scss'], 10 | changeDetection: ChangeDetectionStrategy.OnPush, 11 | standalone: false, 12 | }) 13 | export class ListingsComponent { 14 | @Input() listings: OneListing[] = []; 15 | 16 | public ListingType = ListingType; 17 | 18 | public DroidType = DroidType; 19 | 20 | public VehicleType = VehicleType; 21 | } 22 | -------------------------------------------------------------------------------- /src/app/main/main.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 | 7 | 8 |
9 | 10 |
11 |
12 | -------------------------------------------------------------------------------- /src/app/main/main.component.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | height: calc(100% - 64px); 3 | display: flex; 4 | 5 | .left-part, 6 | .right-part { 7 | flex-grow: 1; 8 | height: 100%; 9 | } 10 | } 11 | 12 | .logo { 13 | max-width: 200px; 14 | } 15 | -------------------------------------------------------------------------------- /src/app/main/main.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | import { ListingService } from '../services/listing.service'; 3 | 4 | @Component({ 5 | selector: 'app-main', 6 | templateUrl: './main.component.html', 7 | styleUrls: ['./main.component.scss'], 8 | changeDetection: ChangeDetectionStrategy.OnPush, 9 | standalone: false, 10 | }) 11 | export class MainComponent { 12 | public listings$ = this.listingService.getListings(); 13 | 14 | constructor(private listingService: ListingService) {} 15 | } 16 | -------------------------------------------------------------------------------- /src/app/main/main.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { NgModule } from '@angular/core'; 3 | import { RouterModule } from '@angular/router'; 4 | import { SharedModule } from '../shared/shared.module'; 5 | import { AssassinDroidComponent } from './listing/listing-form/droid-listing/assassin-droid/assassin-droid.component'; 6 | import { AstromechDroidComponent } from './listing/listing-form/droid-listing/astromech-droid/astromech-droid.component'; 7 | import { DroidProductComponent } from './listing/listing-form/droid-listing/droid-product.component'; 8 | import { MedicalDroidComponent } from './listing/listing-form/droid-listing/medical-droid/medical-droid.component'; 9 | import { ProtocolDroidComponent } from './listing/listing-form/droid-listing/protocol-droid/protocol-droid.component'; 10 | import { ListingFormComponent } from './listing/listing-form/listing-form.component'; 11 | import { CrewMemberComponent } from './listing/listing-form/vehicle-listing/crew-members/crew-member/crew-member.component'; 12 | import { CrewMembersComponent } from './listing/listing-form/vehicle-listing/crew-members/crew-members.component'; 13 | import { SpaceshipComponent } from './listing/listing-form/vehicle-listing/spaceship/spaceship.component'; 14 | import { SpeederComponent } from './listing/listing-form/vehicle-listing/speeder/speeder.component'; 15 | import { VehicleProductComponent } from './listing/listing-form/vehicle-listing/vehicle-product.component'; 16 | import { ListingComponent } from './listing/listing.component'; 17 | import { DisplayCrewMembersPipe } from './listings/display-crew-members.pipe'; 18 | import { ListingsComponent } from './listings/listings.component'; 19 | import { MainComponent } from './main.component'; 20 | 21 | @NgModule({ 22 | declarations: [ 23 | MainComponent, 24 | ListingsComponent, 25 | ListingComponent, 26 | VehicleProductComponent, 27 | DroidProductComponent, 28 | SpaceshipComponent, 29 | SpeederComponent, 30 | ProtocolDroidComponent, 31 | MedicalDroidComponent, 32 | AstromechDroidComponent, 33 | AssassinDroidComponent, 34 | ListingFormComponent, 35 | CrewMembersComponent, 36 | CrewMemberComponent, 37 | DisplayCrewMembersPipe, 38 | ], 39 | imports: [ 40 | CommonModule, 41 | SharedModule, 42 | RouterModule.forChild([ 43 | { 44 | path: '', 45 | component: MainComponent, 46 | children: [ 47 | { 48 | path: 'listings', 49 | children: [ 50 | { path: ':listingId', component: ListingComponent }, 51 | { path: 'new', component: ListingComponent, pathMatch: 'full' }, 52 | ], 53 | }, 54 | ], 55 | }, 56 | { path: '**', pathMatch: 'full', redirectTo: '/' }, 57 | ]), 58 | ], 59 | }) 60 | export class MainModule {} 61 | -------------------------------------------------------------------------------- /src/app/services/listing.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { BehaviorSubject, Observable } from 'rxjs'; 3 | import { OneListing } from '../interfaces/listing.interface'; 4 | import { hardCodedListings } from './listings.data'; 5 | import { map } from 'rxjs/operators'; 6 | 7 | @Injectable({ 8 | providedIn: 'root', 9 | }) 10 | export class ListingService { 11 | private listings$: BehaviorSubject = new BehaviorSubject(hardCodedListings); 12 | 13 | public getListings(): Observable { 14 | return this.listings$.asObservable().pipe(map(this.listingsDeepCopy.bind(this))); 15 | } 16 | 17 | public upsertListing(listing: OneListing): void { 18 | const listings = this.listings$.getValue(); 19 | 20 | const existingListingIndex: number = listings.findIndex(s => s.id === listing.id); 21 | 22 | if (existingListingIndex > -1) { 23 | const listingsBefore = listings.slice(0, existingListingIndex); 24 | const listingAfter = listings.slice(existingListingIndex + 1); 25 | this.listings$.next([...listingsBefore, listing, ...listingAfter]); 26 | } else { 27 | this.listings$.next([listing, ...this.listings$.getValue()]); 28 | } 29 | } 30 | 31 | public getOneListing(id: string): Observable { 32 | return this.listings$.pipe( 33 | map(listings => { 34 | const listing = listings.find(s => s.id === id); 35 | if (!listing) { 36 | throw new Error('not found'); 37 | } 38 | return listing; 39 | }), 40 | map(this.listingDeepCopy), 41 | ); 42 | } 43 | 44 | private listingDeepCopy(listing: OneListing): OneListing { 45 | return JSON.parse(JSON.stringify(listing)); 46 | } 47 | 48 | private listingsDeepCopy(listings: OneListing[]): OneListing[] { 49 | return listings.map(this.listingDeepCopy); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/app/services/listings.data.ts: -------------------------------------------------------------------------------- 1 | import { ListingType, OneListing } from '../interfaces/listing.interface'; 2 | import { DroidType, Languages, AstromechDroidShape, AssassinDroidWeapon } from '../interfaces/droid.interface'; 3 | import { VehicleType } from '../interfaces/vehicle.interface'; 4 | 5 | export const hardCodedListings: OneListing[] = [ 6 | { 7 | id: '3f71b7eb-4a4f-40e6-9fca-e8cc7c0199c3', 8 | price: 45000000, 9 | title: 'Millenium Falcon', 10 | imageUrl: 11 | 'https://vignette.wikia.nocookie.net/starwars/images/4/43/MillenniumFalconTFA-Fathead.png/revision/latest/scale-to-width-down/1000?cb=20161110011442', 12 | listingType: ListingType.VEHICLE, 13 | product: { 14 | color: '#cec80d', 15 | canFire: true, 16 | crewMembers: [ 17 | { firstName: 'Obi-Wan', lastName: 'Kenobi' }, 18 | { firstName: 'R2', lastName: 'D2' }, 19 | ], 20 | wingCount: 0, 21 | vehicleType: VehicleType.SPACESHIP, 22 | }, 23 | }, 24 | { 25 | id: 'c01ad30c-d686-4db2-9a3c-6cf91c494bf0', 26 | price: 500000, 27 | title: 'X-34 landspeeder', 28 | imageUrl: 29 | 'https://vignette.wikia.nocookie.net/starwars/images/5/54/X34-landspeeder.jpg/revision/latest?cb=20080316031428', 30 | listingType: ListingType.VEHICLE, 31 | product: { 32 | color: '#2468f7', 33 | canFire: true, 34 | crewMembers: [{ firstName: 'Anakin', lastName: 'Skywalker' }], 35 | vehicleType: VehicleType.SPEEDER, 36 | maximumSpeed: 250, 37 | }, 38 | }, 39 | { 40 | id: '99178909-7db2-4b75-99e5-028b2d4f6755', 41 | price: 150000, 42 | title: 'C-3PO Protocol Droid', 43 | imageUrl: 44 | 'https://vignette.wikia.nocookie.net/starwars/images/5/51/C-3PO_EP3.png/revision/latest?cb=20131005124036', 45 | listingType: ListingType.DROID, 46 | product: { 47 | color: '#b38d03', 48 | name: 'Proto', 49 | droidType: DroidType.PROTOCOL, 50 | spokenLanguages: [Languages.DROIDSPEAK, Languages.HUTTESE], 51 | }, 52 | }, 53 | { 54 | id: '8aa98e18-838c-4a19-975e-2366c2566544', 55 | price: 210000, 56 | title: '2-1B Medial Droid', 57 | imageUrl: 58 | 'https://vignette.wikia.nocookie.net/starwars/images/b/b6/2-1B_negtd.jpg/revision/latest/scale-to-width-down/200?cb=20100616170941', 59 | listingType: ListingType.DROID, 60 | product: { 61 | color: '#07c911', 62 | name: 'Medic', 63 | droidType: DroidType.MEDICAL, 64 | canHealHumans: true, 65 | canFixRobots: true, 66 | }, 67 | }, 68 | { 69 | id: '08c2c071-f03e-4a63-93b7-bd3df0f2987c', 70 | price: 215000, 71 | title: 'R2D2 Astromech Droid', 72 | imageUrl: 73 | 'https://vignette.wikia.nocookie.net/starwars/images/e/eb/ArtooTFA2-Fathead.png/revision/latest/scale-to-width-down/1000?cb=20161108040914', 74 | listingType: ListingType.DROID, 75 | product: { 76 | color: '#ff0a0a', 77 | name: 'Test', 78 | droidType: DroidType.ASTROMECH, 79 | toolCount: 15, 80 | shape: AstromechDroidShape.REGULAR, 81 | }, 82 | }, 83 | { 84 | id: '0258166e-13b5-4580-a63b-7c1914ef660f', 85 | price: 350000, 86 | title: 'K2-S0 Security Droid', 87 | imageUrl: 88 | 'https://vignette.wikia.nocookie.net/starwars/images/f/fd/K-2SO_Sideshow.png/revision/latest?cb=20170302003128', 89 | listingType: ListingType.DROID, 90 | product: { 91 | color: '#cc6969', 92 | name: 'acwer fg', 93 | droidType: DroidType.ASSASSIN, 94 | weapons: [AssassinDroidWeapon.AXE, AssassinDroidWeapon.FLAME_THROWER], 95 | }, 96 | }, 97 | ]; 98 | -------------------------------------------------------------------------------- /src/app/services/uuid.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { v4 as uuid } from 'uuid'; 3 | 4 | @Injectable({ providedIn: 'root' }) 5 | export class UuidService { 6 | generate(): string { 7 | return uuid(); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/app/shared/shared.module.ts: -------------------------------------------------------------------------------- 1 | import { LayoutModule } from '@angular/cdk/layout'; 2 | import { CommonModule } from '@angular/common'; 3 | import { NgModule } from '@angular/core'; 4 | import { ReactiveFormsModule } from '@angular/forms'; 5 | import { MatButtonModule } from '@angular/material/button'; 6 | import { MatCardModule } from '@angular/material/card'; 7 | import { MatFormFieldModule } from '@angular/material/form-field'; 8 | import { MatIconModule } from '@angular/material/icon'; 9 | import { MatInputModule } from '@angular/material/input'; 10 | import { MatListModule } from '@angular/material/list'; 11 | import { MatSelectModule } from '@angular/material/select'; 12 | import { MatSidenavModule } from '@angular/material/sidenav'; 13 | import { MatSlideToggleModule } from '@angular/material/slide-toggle'; 14 | import { MatToolbarModule } from '@angular/material/toolbar'; 15 | 16 | const MATERIAL_MODULES = [ 17 | LayoutModule, 18 | MatToolbarModule, 19 | MatButtonModule, 20 | MatSidenavModule, 21 | MatIconModule, 22 | MatListModule, 23 | MatFormFieldModule, 24 | MatInputModule, 25 | MatSelectModule, 26 | MatSlideToggleModule, 27 | MatCardModule, 28 | ]; 29 | 30 | @NgModule({ 31 | imports: [CommonModule, ReactiveFormsModule, ...MATERIAL_MODULES], 32 | exports: [CommonModule, ReactiveFormsModule, ...MATERIAL_MODULES], 33 | }) 34 | export class SharedModule {} 35 | -------------------------------------------------------------------------------- /src/app/shared/utils.ts: -------------------------------------------------------------------------------- 1 | export class UnreachableCase { 2 | constructor(payload: never) {} 3 | } 4 | -------------------------------------------------------------------------------- /src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudnc/ngx-sub-form/2784202be44cb059fcc11f05477cce7d8a593c0b/src/assets/.gitkeep -------------------------------------------------------------------------------- /src/assets/ewok-no-bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudnc/ngx-sub-form/2784202be44cb059fcc11f05477cce7d8a593c0b/src/assets/ewok-no-bg.png -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true, 3 | }; 4 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false, 7 | }; 8 | 9 | /* 10 | * For easier debugging in development mode, you can import the following file 11 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 12 | * 13 | * This import should be commented out in production mode because it will have a negative impact 14 | * on performance if an error is thrown. 15 | */ 16 | // import 'zone.js/plugins/zone-error'; // Included with Angular CLI. 17 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudnc/ngx-sub-form/2784202be44cb059fcc11f05477cce7d8a593c0b/src/favicon.ico -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | eJawa - Ngx Sub form demo 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | require('@angular-devkit/build-angular/plugins/karma'), 14 | ], 15 | client: { 16 | clearContext: false, // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | dir: require('path').join(__dirname, '../coverage'), 20 | reports: ['html', 'lcovonly'], 21 | fixWebpackSourcePaths: true, 22 | }, 23 | reporters: ['progress', 'kjhtml'], 24 | port: 9876, 25 | colors: true, 26 | logLevel: config.LOG_INFO, 27 | autoWatch: true, 28 | browsers: ['ChromiumHeadlessNoSandbox'], 29 | customLaunchers: { 30 | ChromiumHeadlessNoSandbox: { 31 | base: 'ChromiumHeadless', 32 | flags: ['--no-sandbox'], 33 | }, 34 | }, 35 | singleRun: false, 36 | }); 37 | }; 38 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic() 12 | .bootstrapModule(AppModule) 13 | .catch(err => console.error(err)); 14 | -------------------------------------------------------------------------------- /src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/guide/browser-support 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** IE9, IE10 and IE11 requires all of the following polyfills. **/ 22 | // import 'core-js/es6/symbol'; 23 | // import 'core-js/es6/object'; 24 | // import 'core-js/es6/function'; 25 | // import 'core-js/es6/parse-int'; 26 | // import 'core-js/es6/parse-float'; 27 | // import 'core-js/es6/number'; 28 | // import 'core-js/es6/math'; 29 | // import 'core-js/es6/string'; 30 | // import 'core-js/es6/date'; 31 | // import 'core-js/es6/array'; 32 | // import 'core-js/es6/regexp'; 33 | // import 'core-js/es6/map'; 34 | // import 'core-js/es6/weak-map'; 35 | // import 'core-js/es6/set'; 36 | 37 | /** 38 | * If the application will be indexed by Google Search, the following is required. 39 | * Googlebot uses a renderer based on Chrome 41. 40 | * https://developers.google.com/search/docs/guides/rendering 41 | **/ 42 | // import 'core-js/es6/array'; 43 | 44 | /** IE10 and IE11 requires the following for the Reflect API. */ 45 | // import 'core-js/es6/reflect'; 46 | 47 | /** 48 | * By default, zone.js will patch all possible macroTask and DomEvents 49 | * user can disable parts of macroTask/DomEvents patch by setting following flags 50 | */ 51 | 52 | // (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 53 | // (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 54 | // (window as any).__zone_symbol__BLACK_LISTED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 55 | 56 | /* 57 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 58 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 59 | */ 60 | // (window as any).__Zone_enable_cross_context_check = true; 61 | 62 | /*************************************************************************************************** 63 | * Zone JS is required by default for Angular itself. 64 | */ 65 | import 'zone.js'; // Included with Angular CLI. 66 | 67 | /*************************************************************************************************** 68 | * APPLICATION IMPORTS 69 | */ 70 | -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | // https://material.angular.io/guide/theming#sass 2 | 3 | @use '@angular/material' as mat; 4 | 5 | @include mat.core(); 6 | 7 | $theme: mat.define-theme( 8 | ( 9 | color: ( 10 | theme-type: light, 11 | primary: mat.$blue-palette, 12 | ), 13 | typography: ( 14 | brand-family: 'Comic Sans', 15 | bold-weight: 900, 16 | ), 17 | density: ( 18 | scale: -1, 19 | ), 20 | ) 21 | ); 22 | 23 | html { 24 | @include mat.core-theme($theme); 25 | 26 | // Include styles for all material components used in the application 27 | @include mat.button-theme($theme); 28 | @include mat.card-theme($theme); 29 | @include mat.form-field-theme($theme); 30 | @include mat.list-theme($theme); 31 | @include mat.option-theme($theme); 32 | @include mat.select-theme($theme); 33 | @include mat.slide-toggle-theme($theme); 34 | @include mat.toolbar-theme($theme); 35 | } 36 | 37 | // Override the theme for a specific component 38 | .top-toolbar { 39 | --mat-toolbar-container-background-color: #3f51b5; 40 | } 41 | 42 | html, 43 | body, 44 | app-root { 45 | margin: 0; 46 | width: 100%; 47 | height: 100%; 48 | } 49 | 50 | fieldset { 51 | border: 1px solid grey; 52 | border-radius: 5px; 53 | 54 | &.ng-invalid { 55 | border-color: red; 56 | } 57 | 58 | legend { 59 | padding: 0 5px; 60 | color: grey; 61 | } 62 | } 63 | 64 | .not-visible { 65 | visibility: hidden; 66 | } 67 | -------------------------------------------------------------------------------- /src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import { getTestBed } from '@angular/core/testing'; 4 | import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; 5 | import 'zone.js/testing'; 6 | 7 | // First, initialize the Angular testing environment. 8 | getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting(), { 9 | teardown: { destroyAfterEach: false }, 10 | }); 11 | -------------------------------------------------------------------------------- /src/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "types": [] 6 | }, 7 | "files": ["main.ts", "polyfills.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /src/tsconfig.e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "module": "es2015", 6 | "target": "es5", 7 | "noUnusedLocals": false, 8 | "types": ["cypress"] 9 | }, 10 | "include": ["**/*.e2e.ts"], 11 | "exclude": ["**/*.spec.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /src/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/spec", 5 | "types": ["jasmine", "node"] 6 | }, 7 | "files": ["test.ts", "polyfills.ts"], 8 | "include": ["**/*.spec.ts", "**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /src/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tslint.json", 3 | "rules": { 4 | "directive-selector": [true, "attribute", "app", "camelCase"], 5 | "component-selector": [true, "element", "app", "kebab-case"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "./dist/out-tsc", 6 | "sourceMap": true, 7 | "esModuleInterop": true, 8 | "declaration": false, 9 | "module": "es2020", 10 | "moduleResolution": "node", 11 | "experimentalDecorators": true, 12 | "noImplicitAny": true, 13 | "noImplicitThis": true, 14 | "strictNullChecks": true, 15 | "strictFunctionTypes": true, 16 | "strictPropertyInitialization": true, 17 | "stripInternal": true, 18 | "target": "ES2022", 19 | "typeRoots": ["node_modules/@types"], 20 | "lib": ["es2018", "dom"], 21 | "paths": { 22 | "ngx-sub-form": ["projects/ngx-sub-form/src/public_api"] 23 | }, 24 | "useDefineForClassFields": false 25 | } 26 | } 27 | --------------------------------------------------------------------------------