diff --git a/.eslintrc.json b/.eslintrc.json
new file mode 100644
index 00000000..6b5753cf
--- /dev/null
+++ b/.eslintrc.json
@@ -0,0 +1,51 @@
+{
+ "root": true,
+ "ignorePatterns": ["projects/**/*"],
+ "overrides": [
+ {
+ "files": ["*.ts"],
+ "parserOptions": {
+ "project": ["tsconfig.json", "e2e/tsconfig.json"],
+ "createDefaultProgram": true
+ },
+ "extends": [
+ "plugin:@angular-eslint/ng-cli-compat",
+ "plugin:@angular-eslint/ng-cli-compat--formatting-add-on",
+ "plugin:@angular-eslint/template/process-inline-templates",
+ "prettier"
+ ],
+ "rules": {
+ // @todo: restore this one once the one below to turn it off temporarily is there
+ // "@typescript-eslint/consistent-type-definitions": "error",
+ "@typescript-eslint/dot-notation": "off",
+ "@typescript-eslint/explicit-member-accessibility": [
+ "off",
+ {
+ "accessibility": "explicit"
+ }
+ ],
+ "@typescript-eslint/no-unused-expressions": "off",
+ "id-blacklist": "off",
+ "id-match": "off",
+ "no-underscore-dangle": "off",
+ // @todo: restore following
+ // during the migration to ng 13, did remove tslint in favor of eslint
+ // but don't want to have to deal with loads of errors during the upgrade
+ "@typescript-eslint/naming-convention": "off",
+ "@typescript-eslint/ban-types": "off",
+ "@typescript-eslint/member-ordering": "off",
+ "prefer-arrow/prefer-arrow-functions": "off",
+ "object-shorthand": "off",
+ "@typescript-eslint/no-empty-interface": "off",
+ "prefer-const": "off",
+ "arrow-body-style": "off",
+ "@typescript-eslint/consistent-type-definitions": "off"
+ }
+ },
+ {
+ "files": ["*.html"],
+ "extends": ["plugin:@angular-eslint/template/recommended"],
+ "rules": {}
+ }
+ ]
+}
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
new file mode 100644
index 00000000..40f4a799
--- /dev/null
+++ b/.github/workflows/main.yml
@@ -0,0 +1,55 @@
+# GitHub Actions docs
+# https://help.github.com/en/articles/about-github-actions
+# https://help.github.com/en/articles/workflow-syntax-for-github-actions
+name: CI
+
+on: [push]
+
+jobs:
+ build:
+ # Machine environment:
+ # https://help.github.com/en/articles/software-in-virtual-environments-for-github-actions#ubuntu-1804-lts
+ # We specify the Node.js version manually below, and use versioned Chrome from Puppeteer.
+ runs-on: ubuntu-18.04
+
+ steps:
+ - uses: actions/checkout@v1
+ - name: Use Node.js 14.17
+ uses: actions/setup-node@v1
+ with:
+ node-version: 14.17
+ - name: Install dependencies
+ run: yarn --frozen-lockfile --non-interactive --no-progress
+ - name: Lint Demo
+ run: yarn demo:lint:check
+ - name: Format check
+ run: yarn prettier:check
+ - name: Check Readme
+ run: yarn readme:check
+ - name: Test
+ run: yarn lib:test:ci
+ - name: Build Lib
+ run: yarn lib:build:prod
+ - name: Cypress run
+ uses: cypress-io/github-action@v2
+ with:
+ browser: chrome
+ start: yarn demo:start --port 4765
+ wait-on: http://localhost:4765/
+ - name: Copy built README & LICENCE into dist
+ run: cp README.md LICENSE dist/ngx-sub-form
+ - name: Build Demo
+ if: contains('refs/heads/master', github.ref)
+ run: yarn run demo:build:prod --progress=false --base-href "https://cloudnc.github.io/ngx-sub-form/"
+ - name: Deploy
+ if: contains('refs/heads/master', github.ref)
+ uses: peaceiris/actions-gh-pages@v3
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ publish_dir: ./dist/ngx-sub-form-demo
+ - name: Release
+ if: contains('refs/heads/master refs/heads/next refs/heads/feat-rewrite', github.ref)
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
+ run: npx semantic-release
diff --git a/.gitignore b/.gitignore
index ee5c9d83..fd5cbe68 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,6 +16,7 @@
*.launch
.settings/
*.sublime-workspace
+.history
# IDE - VSCode
.vscode/*
@@ -25,6 +26,7 @@
!.vscode/extensions.json
# misc
+/.angular/cache
/.sass-cache
/connect.lock
/coverage
diff --git a/.prettierignore b/.prettierignore
index 719ab8a1..e9504f86 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -2,3 +2,4 @@ package.json
dist
node_modules
.history
+.angular
diff --git a/.releaserc b/.releaserc
index 5eb1bfff..377e626d 100644
--- a/.releaserc
+++ b/.releaserc
@@ -1,3 +1,11 @@
{
- "pkgRoot": "dist/ngx-sub-form"
+ "pkgRoot": "dist/ngx-sub-form",
+ "branches": [
+ "master",
+ {
+ "name": "feat-rewrite",
+ "channel": "feat-rewrite",
+ "prerelease": true
+ }
+ ]
}
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 778dd2ae..00000000
--- a/.travis.yml
+++ /dev/null
@@ -1,58 +0,0 @@
-sudo: false
-
-dist: bionic
-
-language: node_js
-node_js:
- - '12'
-
-# https://github.com/cypress-io/cypress/issues/4069
-addons:
- apt:
- packages:
- - libgconf-2-4
- chrome: stable
-
-cache:
- directories:
- - ~/.cache
- - node_modules
-
-before_install:
- - curl -o- -L https://yarnpkg.com/install.sh | bash
- - export PATH="$HOME/.yarn/bin:$PATH"
-
-install:
- - yarn --frozen-lockfile --non-interactive --no-progress
-
-script:
- # lint
- - yarn run demo:lint:check
- - yarn run prettier:check
- # tests
- - yarn run readme:check
- - yarn run lib:test:ci
- # build
- - yarn run lib:build:prod:view-engine
- - yarn run demo:build:prod --progress=false --base-href "https://cloudnc.github.io/ngx-sub-form/"
- # e2e tests
- - sed -i 's///g' dist/ngx-sub-form-demo/index.html
- - nohup http-server-spa ./dist/ngx-sub-form-demo index.html 4765 &
- - sleep 5
- - yarn run demo:test:e2e:ci
- - sed -i 's///g' dist/ngx-sub-form-demo/index.html
- # copy the readme so it can be accessible from npm
- - cp README.md LICENSE dist/ngx-sub-form
-
-deploy:
- - provider: script
- skip_cleanup: true
- script: yarn semantic-release
- on:
- branch: master
- - provider: pages
- skip_cleanup: true
- github_token: $GH_TOKEN
- local_dir: dist/ngx-sub-form-demo
- on:
- branch: master
diff --git a/README.md b/README.md
index e3bbeaba..52558059 100644
--- a/README.md
+++ b/README.md
@@ -2,611 +2,398 @@
![ngx-sub-form logo](https://user-images.githubusercontent.com/4950209/53812385-45f48900-3f53-11e9-8687-b57cd335f26e.png)
-Utility library to manage forms with Angular.
+Utility library to improve the robustness of your Angular forms.
-Really small bundle (< 15kb) and no module to setup. Pick the class you need and extend it.
+Whether you have simple and tiny forms or huge and complex ones, `ngx-sub-form` will help you build a solid base for them.
-Built for **all your different forms** (tiny to extra large!), this library will deal with all the boilerplate required to use a [`ControlValueAccessor`](https://blog.angularindepth.com/never-again-be-confused-when-implementing-controlvalueaccessor-in-angular-forms-93b9eee9ee83) internally and let you manage your most complex forms in a fast and easy way.
+- 🗜️ Tiny bundle
+ _(currently ~30kb as we support both the old api and the new one but soon to be ~15kb!)_
+- ✅ Simple API: No angular module to setup, no `ControlValueAccessor` by hand, no inheritance, no boilerplate. Only one function to create all your forms!
+- 🤖 Adds type safety to your forms
+- ✂️ Lets you break down huge forms into smaller ones for simplicity and reusability
-From creating a small custom input, to breaking down a form into multiple sub components, `ngx-sub-form` will give you a lot of functionalities like better type safety to survive future refactors (from both `TS` and `HTML`), remapping external data to the shape you need within your form, access nested errors and many more. It'll also save you from passing a `FormGroup` to an `@Input` :pray:.
+_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!_
-It also works particularly well with polymorphic data structures.
+# Table of contents
-[![npm version](https://badge.fury.io/js/ngx-sub-form.svg)](https://www.npmjs.com/package/ngx-sub-form)
-[![Build Status](https://travis-ci.org/cloudnc/ngx-sub-form.svg?branch=master)](https://travis-ci.org/cloudnc/ngx-sub-form)
-[![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](https://commitizen.github.io/cz-cli/)
+- [Basic API usage](#basic-api-usage)
+- [Setup](#setup)
+- [Migration guide to the new API](#migration-guide-to-the-new-api)
+- [Principles](#principles)
+ - [Root forms](#root-forms)
+ - [Sub forms](#sub-forms)
+ - [Remap](#remap)
+ - [Dealing with arrays](#dealing-with-arrays)
+- [Contribution](#contribution)
+- [Tell us about your experience with ngx-sub-form](#tell-us-about-your-experience-with-ngx-sub-form)
-## Blog post
+# Basic API usage
-This README focuses on explaining how to use `ngx-sub-form`.
+As a picture is often worth a 1000 words, let's take a quick overlook at the API before explaining in details further on:
-If you first want to know more about the context, what we tried before creating that library and discover it through detailed examples, you can read a blog post about it here: https://dev.to/maxime1992/building-scalable-robust-and-type-safe-forms-with-angular-3nf9
+![basic-api-usage](https://user-images.githubusercontent.com/4950209/110140660-9cac2c00-7dd4-11eb-8dc1-421089c5c016.png)
-## Install
+# Setup
-Install the [npm package](https://www.npmjs.com/package/ngx-sub-form): `ngx-sub-form`
+`ngx-sub-form` is available on [NPM](https://www.npmjs.com/package/ngx-sub-form):
-_Note about the versions:_
+```
+npm i ngx-sub-form
+```
-Angular <= 7: `2.7.1`
-Angular 8.x : `4.x`
-Angular >= 9: Latest
+**Note about the versions:**
-## Demo
+| `@angular` version | `ngx-sub-form` version |
+| ---------------------- | ---------------------- |
+| <= `7` | <= `2.7.1` |
+| `8.x` | `4.x` |
+| `9.x`, `10.x` | `5.1.2` |
+| `11.x`, `12.x`, `13.x` | `6.0.0` |
-_Before we get started with how to use the library and give some examples, a complete demo is available on [this repo](https://github.com/cloudnc/ngx-sub-form), within the [`src`](https://github.com/cloudnc/ngx-sub-form/tree/master/src) folder.
-Demo is built around a concept of galactic sales. You can sell either Droids (Protocol, Medical, Astromech, Assassin) or Vehicles (Spaceship, Speeder).
-**This will also be used for the following examples**.
-If you want to see the demo in action, please visit [https://cloudnc.github.io/ngx-sub-form](https://cloudnc.github.io/ngx-sub-form)._
+The major bump from version `5.1.2` to `6.0.0` doesn't bring any changes to the public API of `ngx-sub-form`.
+It's only a major bump for Angular 11 support and you should be able to upgrade without having to update any of your forms.
-## Setup
+That said, the version `6.0.0` also brings some exciting news!
+We sneaked into that release a [complete rewrite of ngx-sub-form to get rid of inheritance](https://github.com/cloudnc/ngx-sub-form/issues/171) 🎉. The best part being: **It's been done in a non breaking way to guarantee a smooth upgrade, which can be done incrementally, one form at a time, from the old API to the new one**.
-`ngx-sub-form` provides
+The old API has been marked as deprecated and will be removed in a few months as part of a major version bump, to give you time to upgrade.
-- 2 classes for top level form components: `NgxRootFormComponent`, `NgxAutomaticRootFormComponent`
-- 2 classes for sub level form components: `NgxSubFormComponent`, `NgxSubFormRemapComponent`
-- 7 interfaces: `Controls`, `ControlsNames`, `FormGroupOptions`, `TypedFormGroup`, `TypedFormArray`, `TypedFormControl`, `TypedAbstractControl`
-- 1 function: `subformComponentProviders`
+# Migration guide to the new API
-So there's actually nothing to setup (like a module), you can just use them directly.
+If your project is not using `ngx-sub-form` yet, feel free to skip this migration guide.
+On the other hand, **if your project is using `ngx-sub-form` with the inheritance API please read the following**.
-## Usage
+High level explanation:
-### When should I use `ngx-sub-form`?
+- On the public API, the required changes are mostly moving things around as none of the core concepts have changed
+- Depending on how much forms you have, this may be a long and boring task as we don't have any schematics to make those changes automatically for you
+- On the bright side, it should be a fairly easy task in terms of complexity
+- You should be able to make the upgrade **incrementally** as well _(form after form if you want to instead of a big bang rewrite!)_. This is because behind the scenes the root and sub forms communicate through the `ControlValueAccessor` interface and as this one is from Angular and didn't change, it should be fine updating one form at a time
-**Short answer:** As soon as you've got a form!
+The simplest thing to understand the new syntax is probably to have a look on the [basic API usage](#basic-api-usage) example which covers most of the cases. But let's describe a step by step approach how to update your forms:
-**Detailed answer:**
+- `createForm` is the new function to create both your root and sub forms. It's very similar in terms of configuration to all the attributes and methods that you needed to implement after extending from `NgxRootFormComponent` or `NgxSubFormComponent`
+- The first parameter that you should be providing in the configuration object of `createForm` is `formType` which can be either `FormType.ROOT` or `FormType.SUB`
+- Then, you can provide the following ones for a sub form:
-- When you want to create a `ControlValueAccessor`
-- When you want to create a simple form, it'll give you better typings
-- When you want to create a bigger form that you need to split up into sub components
-- When dealing with polymorphic data that you want to display in a form
+ - `formControls`
+ - `emitNullOnDestroy` _(optional)_
+ - `formGroupOptions` _(optional)_
+ - `toFormGroup` _(optional: If you have only 1 interface, required if you passed a second type to define a remap)_
+ - `fromFormGroup` _(optional: If you have only 1 interface, required if you passed a second type to define a remap)_
-### Type safety you said?
+- And for a root form you **additionally** provide the following bindings:
-When extending one of the 4 core classes:
+ - `input$`
+ - `output$`
+ - `disabled$`
+ - `manualSave$` _(optional)_
+ - `handleEmissionRate` _(optional)_
-- `NgxRootFormComponent`
-- `NgxAutomaticRootFormComponent`
-- `NgxSubFormComponent`
-- `NgxSubFormRemapComponent`
+Most of the attributes and methods have the same name as they had before so it shouldn't be too much of a trouble to move from a class approach with attributes and methods to a configuration object.
-You'll have access to the following properties (within your `.ts` **and** `.html` files):
+On the template side, assuming that you've saved the return of the `createForm` in a `form` variable:
-- `formGroup`: The actual form group, useful to define the binding `[formGroup]="formGroup"` into the view
-- `formControlNames`: All the control names available in your form. Use it when defining a `formControlName` like that ``
-- `formGroupControls`: All the controls of your form, helpful to avoid doing `formGroup.get(formControlNames.yourControl)`, instead just do `formGroupControls.yourControl`
-- `formGroupValues`: Access all the values of your form directly without doing `formGroup.get(formControlNames.yourControl).value`, instead just do `formGroupValues.yourControl` (and it'll be correctly typed!)
-- `formGroupErrors`: All the errors of the current form **including** the sub errors (if any), just use `formGroupErrors` or `formGroupErrors?.yourControl`. Notice the question mark in `formGroupErrors?.yourControl`, it **will return `null` if there's no error**
+- `formGroupControls` will now be `form.formGroup.controls`
+- `formGroupValues` will now be `form.formGroup.value`
-With AOT turned on you'll get proper type checking within your TS **and** HTML files.
-When refactoring your interfaces/classes, your form will error at build time if a property should no longer be here or if one is missing.
+We're exposing the original `formGroup` object but it has been augmented on the type level by making it a `TypedFormGroup` which provides type safety on a bunch of attributes and methods (`value`, `valueChanges`, `controls`, `setValue`, `patchValue`, `getRawValue`). See the `TypedFormGroup` interface in `projects/ngx-sub-form/src/lib/shared/ngx-sub-form-utils.ts` if you want to know more. As a result of this, we now don't need to provide `formGroupControls` nor `formGroupValues` for type safety any more.
-### Angular hooks
+Previously, `transformToFormGroup` _(which is now as you guessed it `toFormGroup`)_ was taking as the first parameter `obj: ControlInterface | null` and as a second one `defaultValues: Partial | null`. This was pretty annoying as you needed to define a `getDefaultValues` method to provide your default values. Now you simply define your default values within the `formControls` function on each of the form controls as you'd expect. Behind the scenes, when the component is created for the first time we make a deep copy of those default values and apply them automatically if the root form or the sub form is being updated upstream with `null` or `undefined`.
-ngx-sub-form uses `ngOnInit` and `ngOnDestroy` internally.
-If you need to use them too, do not forget to call `super.ngOnInit()` and `super.ngOnDestroy()` otherwise you might end with with the form not working correctly or a memory leak.
-Unfortunately, there's currently no way of making sure that inheriting classes call these methods, so keep that in mind.
+If you were previously using inheritance to set some defaults globally, for example on your root forms for the `handleEmissionRate` method, you cannot do that anymore and you'll need to define those on a per component basis! So if you were extending your own class, itself inheriting from a root form or a sub form, don't forget about that. We're considering passing a token through DI to be able to set some of those settings globally. But it's not done yet and give us feedback if you think it should.
-### First component level
+For root forms, the helper `DataInput` has been removed. It is now by default slightly more verbose to get the input data as you have to declare a `Subject` and push values into it yourself (by using either a setter on your input or the `ngOnChanges` hook). `DataInput` was originally created to reduce this boilerplate but as there are plenty of libraries available to transform an input into an observable, we let the choice to either do it manually or install a library on your side to transform the input into an observable for you.
-Within the component where the (top) form will be handled, you have to define the top level structure. You can do it manually as you'd usually do (by defining your own `FormGroup`), but it's better to extend from either `NgxRootFormComponent` or `NgxAutomaticRootFormComponent` as you'll get some type safety and other useful helpers. If dealing with polymorphic data, **each type must have it's own form control**:
-(_even if it doesn't match your model, we'll talk about that later_)
+You can also have a look into our demo app located here: `src/app`. You'll find `main` and `main-rewrite` which are exactly the same applications but `main` is using the deprecated API (the one with inheritance) while `main-rewrite` is using the new one. As those 2 applications showcase all the features of ngx-sub-form you can easily find what you're looking for and compare both if we forgot to cover anything. Just as an FYI, we've kept both apps for now which are tested by the same E2E test suite to make sure that nothing got broken on the old API during the rewrite. When we decide to remove the old API we'll of course remove the demo implementation which is using the old API.
-Before explaining the difference between `NgxRootFormComponent` or `NgxAutomaticRootFormComponent`, let's look at an example with a polymorphic type:
+# API
-```ts
-// src/readme/listing.component.ts#L8-L58
+There's one function available to create all your forms: `createForm`.
-enum ListingType {
- VEHICLE = 'Vehicle',
- DROID = 'Droid',
-}
+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.
-export interface OneListingForm {
- id: string;
- title: string;
- price: number;
- imageUrl: string;
+## `createForm` configuration object:
- // polymorphic form where product can either be a vehicle or a droid
- listingType: ListingType | null;
- vehicleProduct: OneVehicle | null;
- droidProduct: OneDroid | null;
-}
+
-@Component({
- selector: 'app-listing',
- templateUrl: './listing.component.html',
- styleUrls: ['./listing.component.scss'],
-})
-export class ListingComponent extends NgxAutomaticRootFormComponent {
- // as we're renaming the input, it'd be impossible for ngx-sub-form to guess
- // the name of your input to then check within the `ngOnChanges` hook whether
- // it has been updated or not
- // another solution would be to ask you to use a setter and call a hook but
- // this is too verbose, that's why we created a decorator `@DataInput`
- @DataInput()
- // tslint:disable-next-line:no-input-rename
- @Input('listing')
- public dataInput: OneListing | null | undefined;
-
- // tslint:disable-next-line:no-output-rename
- @Output('listingUpdated') public dataOutput: EventEmitter = new EventEmitter();
-
- // to access it from the view
- public ListingType = ListingType;
-
- protected getFormControls(): Controls {
- return {
- vehicleProduct: new FormControl(null),
- droidProduct: new FormControl(null),
- listingType: new FormControl(null, Validators.required),
- id: new FormControl(null, Validators.required),
- title: new FormControl(null, Validators.required),
- imageUrl: new FormControl(null, Validators.required),
- price: new FormControl(null, Validators.required),
- };
- }
-}
-```
+| Key | Type | Optional or required | Root form | Sub form | What is it for? |
+| ----------------------- | --------------------------------------------------------------------------- | -------------------- | --------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `formType` | `FormType` | Required | ✅ | ✅ | Defines the type of the form. Can either be `FormType.ROOT` or `FormType.SUB` |
+| `disabled$` | `Observable` | Required | ✅ | ❌ | When this observable emits `true`, the whole form (including the root form and all the sub forms) will be disabled |
+| `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 |
+| `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 |
+| `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$`) |
+| `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 |
+| `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))` |
-Then, within the `.component.html` we:
+# Principles
-- Define the `formGroup`
-- Create a `select` tag to choose between the 2 types
-- Use `ngSwitch` directive to create either a `DroidProductComponent` or a `VehicleProductComponent`
+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**.
-```html
-
-
-
-```
+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.
-One thing to notice above: `` and `` **are** custom `ControlValueAccessor`s and let us bind them to `formControlName`, as we would with a regular `input` tag.
+## Root forms
-Every time the form changes, that component will `emit` a value from the `dataOutput` output (that you can rename). On the other hand, if there's an update, simply pass the new object as input and the form will be updated.
+Root forms let you isolate a form from the rest of your app.
+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.
-From the parent component you can do like the following:
+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.
-```html
-
-
-
-```
-
-_Note the presence of disabled, this is an optional input provided by both `NgxRootFormComponent` and `NgxAutomaticRootFormComponent` that let you disable (or enable when true) the whole form._
+Let's have a look with a very simple workflow:
-Differences between:
+- Imagine an application with a list of people and when you click on one of them you can edit the person details
+- A smart component is aware of the currently selected person (our _"container component"_)
+- A root form component lets us display the data we retrieved in a form and also edit them
-- `NgxRootFormComponent`: Will never emit the form value automatically when it changes, to emit the value you'll have to call the method `manualSave` when needed
-- `NgxAutomaticRootFormComponent`: Will emit the form value as soon as there's a change. It's possible to customize the emission rate by overriding the `handleEmissionRate` method
-
-The method `handleEmissionRate` is available across **all** the classes that `ngx-sub-form` offers. It takes an observable as input and expect another observable as output. One common case is to simply [`debounce`](http://reactivex.io/rxjs/class/es6/Observable.js~Observable.html#instance-method-debounce) the emission. If that's what you want to do, instead of manipulating the observable chain yourself you can just do:
+In this scenario, the smart component could look like the following:
```ts
-// src/readme/handle-emission-rate.ts#L6-L9
-
-protected handleEmissionRate(): (obs$: Observable) => Observable {
- // debounce by 500ms
- return NGX_SUB_FORM_HANDLE_VALUE_CHANGES_RATE_STRATEGIES.debounce(500);
-}
-```
-
-### Second component level (optional)
-
-_Only useful if you're breaking up a form into sub components._
-
-All you have to do is:
-
-1. Add required providers using the utility function `subformComponentProviders`:
-
-```ts
-// src/readme/steps/add-providers.ts#L2-L10
-
-import { subformComponentProviders } from 'ngx-sub-form';
-
@Component({
- selector: 'app-vehicle-product',
- templateUrl: './vehicle-product.component.html',
- styleUrls: ['./vehicle-product.component.scss'],
- providers: subformComponentProviders(VehicleProductComponent), // <-- Add this
+ selector: 'person-container',
+ template: `
+
+ `,
})
-export class VehicleProductComponent {}
-```
+export class PersonContainer {
+ public person$: Observable = this.personService.person$;
-2. Make your original class extend `NgxSubFormComponent` **or** `NgxSubFormRemapComponent` if you need to remap the data (will be explained later):
-3. Implement the required interface by defining the controls of your form (as we previously did in the top form component):
+ constructor(private personService: PersonService) {}
-```ts
-// src/readme/steps/add-controls.ts#L12-L20
-
-export class VehicleProductComponent extends NgxSubFormComponent {
- protected getFormControls(): Controls {
- return {
- speeder: new FormControl(null),
- spaceship: new FormControl(null),
- vehicleType: new FormControl(null, { validators: [Validators.required] }),
- };
+ public personUpdate(person: Person): void {
+ this.personService.update(person);
}
}
```
-_Simplified from the original example into src folder to keep the example as minimal and relevant as possible._
-
-### Remapping Data
-
-It is a frequent pattern to have the data that you're trying to modify in a format that is inconvenient to the angular forms structural constraints. For this reason, `ngx-form-component` offers a separate class `NgxSubFormRemapComponent`
-which will require you to define two interfaces:
+This component is only responsible to get the correct data and manage updates _(if any)_. It completely delegates to the root form:
-- One to model the data going into the form
-- The other to describe the data that will be set as the value
+- How the data will be displayed to the user as a form
+- How the user will interact with them
-Example, take a look at [`VehicleProductComponent`](https://github.com/cloudnc/ngx-sub-form/blob/master/src/app/main/listing/listing-form/vehicle-listing/vehicle-product.component.ts):
+Now let's talk about the actual **root form**:
```ts
-// src/readme/vehicle-product.component.simplified.ts#L7-L74
-
-// merged few files together to make it easier to follow
-export interface BaseVehicle {
- color: string;
- canFire: boolean;
- crewMemberCount: number;
-}
-
-export interface Spaceship extends BaseVehicle {
- vehicleType: VehicleType.SPACESHIP;
- wingCount: number;
-}
-
-export interface Speeder extends BaseVehicle {
- vehicleType: VehicleType.SPEEDER;
- maximumSpeed: number;
-}
-
-export type OneVehicle = Spaceship | Speeder;
-
-interface OneVehicleForm {
- speeder: Speeder | null;
- spaceship: Spaceship | null;
- vehicleType: VehicleType | null;
-}
-
@Component({
- selector: 'app-vehicle-product',
- templateUrl: './vehicle-product.component.html',
- styleUrls: ['./vehicle-product.component.scss'],
- providers: subformComponentProviders(VehicleProductComponent),
+ selector: 'person-form',
+ template: `
+
+ `,
})
-export class VehicleProductComponent extends NgxSubFormRemapComponent {
- public VehicleType = VehicleType;
-
- protected getFormControls(): Controls {
- return {
- speeder: new FormControl(null),
- spaceship: new FormControl(null),
- vehicleType: new FormControl(null, { validators: [Validators.required] }),
- };
+export class PersonForm {
+ private input$: Subject = new Subject();
+ @Input() set person(person: Person | undefined) {
+ this.input$.next(person);
}
- protected transformToFormGroup(obj: OneVehicle | null): OneVehicleForm | null {
- if (!obj) {
- return null;
- }
-
- return {
- speeder: obj.vehicleType === VehicleType.SPEEDER ? obj : null,
- spaceship: obj.vehicleType === VehicleType.SPACESHIP ? obj : null,
- vehicleType: obj.vehicleType,
- };
+ private disabled$: Subject = new Subject();
+ @Input() set disabled(value: boolean | undefined) {
+ this.disabled$.next(!!value);
}
- protected transformFromFormGroup(formValue: OneVehicleForm): OneVehicle | null {
- switch (formValue.vehicleType) {
- case VehicleType.SPEEDER:
- return formValue.speeder;
- case VehicleType.SPACESHIP:
- return formValue.spaceship;
- case null:
- return null;
- default:
- throw new UnreachableCase(formValue.vehicleType);
- }
- }
+ @Output() personUpdate: Subject = new Subject();
+
+ public form = createForm(this, {
+ formType: FormType.ROOT,
+ disabled$: this.disabled$,
+ input$: this.input$,
+ output$: this.personUpdate,
+ formControls: {
+ id: new FormControl(null, Validators.required),
+ firstName: new FormControl(null, Validators.required),
+ lastName: new FormControl(null, Validators.required),
+ address: new FormControl(null, Validators.required),
+ },
+ });
}
```
-**You're always better off making your data structure better suit Angular forms, than abusing forms to fit your data pattern**
-
-For a complete example of this see `https://github.com/cloudnc/ngx-sub-form/blob/master/src/app/main/listing/listing-form/vehicle-listing/vehicle-product.component.ts` (repeated below):
+We'll go through the example above bit by bit.
```ts
-// src/app/main/listing/listing-form/vehicle-listing/vehicle-product.component.ts#L7-L55
+public form = createForm(this, {
+ formType: FormType.ROOT,
+ disabled$: this.disabled$,
+ input$: this.input$,
+ output$: this.personUpdate,
+ formControls: {
+ id: new FormControl(null, Validators.required),
+ firstName: new FormControl(null, Validators.required),
+ lastName: new FormControl(null, Validators.required),
+ address: new FormControl(null, Validators.required),
+ },
+});
+```
-export interface OneVehicleForm {
- speeder: Speeder | null;
- spaceship: Spaceship | null;
- vehicleType: VehicleType | null;
-}
+This is what we provide to create a form with `ngx-sub-form`:
-@Component({
- selector: 'app-vehicle-product',
- templateUrl: './vehicle-product.component.html',
- styleUrls: ['./vehicle-product.component.scss'],
- providers: subformComponentProviders(VehicleProductComponent),
-})
-export class VehicleProductComponent extends NgxSubFormRemapComponent {
- public VehicleType = VehicleType;
-
- protected getFormControls(): Controls {
- return {
- speeder: new FormControl(null),
- spaceship: new FormControl(null),
- vehicleType: new FormControl(null, { validators: [Validators.required] }),
- };
- }
+- A type _(either `FormType.ROOT` or `FormType.SUB`)_
+- A `disabled$` stream to know whether we should disable the whole form or not _(including all the sub forms as well)_
+- An `input$` stream which is the data we'll use to update the form
+- An `output$` stream, which would usually be our `EventEmitter` so that a parent component can listen to the form update through an output
+- The `formControls`, which is exactly what you'd pass when creating a `FormGroup`
- protected transformToFormGroup(obj: OneVehicle | null): OneVehicleForm | null {
- if (!obj) {
- return null;
- }
+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.
- return {
- speeder: obj.vehicleType === VehicleType.SPEEDER ? obj : null,
- spaceship: obj.vehicleType === VehicleType.SPACESHIP ? obj : null,
- vehicleType: obj.vehicleType,
- };
- }
+```ts
+private input$: Subject = new Subject();
+@Input() set person(person: Person | undefined) {
+ this.input$.next(person);
+}
- protected transformFromFormGroup(formValue: OneVehicleForm): OneVehicle | null {
- switch (formValue.vehicleType) {
- case VehicleType.SPEEDER:
- return formValue.speeder;
- case VehicleType.SPACESHIP:
- return formValue.spaceship;
- case null:
- return null;
- default:
- throw new UnreachableCase(formValue.vehicleType);
- }
- }
+private disabled$: Subject = new Subject();
+@Input() set disabled(value: boolean | undefined) {
+ this.disabled$.next(!!value);
}
```
-Our "incoming" object is of type `OneVehicle` but into that component we treat it as a `OneVehicleForm` to split the vehicle (either a `speeder` or `spaceship`) in 2 **separate** properties.
-
-### Dealing with arrays
-
-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.
+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.
-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.
+```ts
+@Output() personUpdate: Subject = new Subject();
+```
-If you have custom validations on the form controls, implement the `NgxFormWithArrayControls` interface, which gives the library a hook with which to construct new form controls for the form array with the correct validators applied.
+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.
-Example:
+Finally, our template:
-```ts
-// src/app/main/listing/listing-form/vehicle-listing/crew-members/crew-members.component.ts#L13-L76
+```html
+
+```
-interface CrewMembersForm {
- crewMembers: CrewMember[];
-}
+Our `createForm` function will return an object of type `NgxRootForm`. It means we'll then have access to the following properties:
-@Component({
- selector: 'app-crew-members',
- templateUrl: './crew-members.component.html',
- styleUrls: ['./crew-members.component.scss'],
- providers: subformComponentProviders(CrewMembersComponent),
-})
-export class CrewMembersComponent extends NgxSubFormRemapComponent
- implements NgxFormWithArrayControls {
- protected getFormControls(): Controls {
- return {
- crewMembers: new FormArray([]),
- };
- }
+- **`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`
+- **`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)_
+- **`formGroupErrors`**: An object holding all the errors in the form. Bonus point: It also includes all the nested errors from the sub forms!
+- **`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
+- **`createFormArrayControl`**: We'll cover this one in the [remap](#remap) section, after the sub forms
- public getDefaultValues(): Partial | null {
- return {
- crewMembers: [],
- };
- }
+## Sub forms
- protected transformToFormGroup(obj: CrewMember[] | null): CrewMembersForm | null {
- return {
- crewMembers: !obj ? [] : obj,
- };
- }
+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.
- protected transformFromFormGroup(formValue: CrewMembersForm): CrewMember[] | null {
- return formValue.crewMembers;
- }
+This is great for a couple of reasons:
- public removeCrewMember(index: number): void {
- this.formGroupControls.crewMembers.removeAt(index);
- }
+- You can break down the complexity of your forms into smaller components
+- 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
- public addCrewMember(): void {
- this.formGroupControls.crewMembers.push(
- this.createFormArrayControl('crewMembers', {
- firstName: '',
- lastName: '',
- }),
- );
- }
+Here's a full example:
- // following method is not required and return by default a simple FormControl
- // if needed, you can use the `createFormArrayControl` hook to customize the creation
- // of your `FormControl`s that will be added to the `FormArray`
- public createFormArrayControl(
- key: ArrayPropertyKey | undefined,
- value: ArrayPropertyValue,
- ): FormControl {
- switch (key) {
- // note: the following string is type safe based on your form properties!
- case 'crewMembers':
- return new FormControl(value, [Validators.required]);
- default:
- return new FormControl(value);
- }
- }
+```ts
+@Component({
+ selector: 'address-control',
+ template: `
+
+
+
+
+
+
+ `,
+ providers: subformComponentProviders(PersonForm),
+})
+export class PersonForm {
+ public form = createForm(this, {
+ formType: FormType.SUB,
+ formControls: {
+ street: new FormControl(null, Validators.required),
+ city: new FormControl(null, Validators.required),
+ state: new FormControl(null, Validators.required),
+ zipCode: new FormControl(null, Validators.required),
+ },
+ });
}
```
-Then our view will look like the following:
+A sub form looks very much like a root form but with an API that is even simpler.
+When you call the `createForm` function, start by setting the `formType` to `FormType.SUB` and then define your `formControls`.
-```html
-
-
-
+One important thing to note:
+
+```ts
+providers: subformComponentProviders(PersonForm);
```
-The `app-crew-member` component is a simple `NgxSubFormComponent` as you can imagine:
+`subformComponentProviders` is only here to help reduce the number of lines needed for each sub form component. It returns the following providers:
```ts
-// src/app/main/listing/listing-form/vehicle-listing/crew-members/crew-member/crew-member.component.ts#L6-L19
-
-@Component({
- selector: 'app-crew-member',
- templateUrl: './crew-member.component.html',
- styleUrls: ['./crew-member.component.scss'],
- providers: subformComponentProviders(CrewMemberComponent),
-})
-export class CrewMemberComponent extends NgxSubFormComponent {
- protected getFormControls(): Controls {
- return {
- firstName: new FormControl(null, [Validators.required]),
- lastName: new FormControl(null, [Validators.required]),
- };
- }
-}
+return [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: component,
+ multi: true,
+ },
+ {
+ provide: NG_VALIDATORS,
+ useExisting: component,
+ multi: true,
+ },
+];
```
-### Helpers
+Behind the scenes those providers are allowing us to have a component considered as a [ControlValueAccessor](https://angular.io/api/forms/ControlValueAccessor).
+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.
-**Properties**
+Just like the root form, the `createForm` function will return an object containing the following:
-- `emitNullOnDestroy`: By default is set to `true` for `NgxSubFormComponent`, `NgxSubFormRemapComponent` and to `false` for `NgxRootFormComponent` and `NgxAutomaticRootFormComponent`. When set to `true`, if the sub form component is being destroyed, it will emit one last value: `null`. It might be useful to set it to `false` for e.g. when you've got a form across multiple tabs and once a part of the form is filled you want to destroy it
-- `emitInitialValueOnInit`: By default is set to `true` for `NgxSubFormComponent`, `NgxSubFormRemapComponent` and to `false` for `NgxRootFormComponent` and `NgxAutomaticRootFormComponent`. When set to `true`, the sub form component will emit the first value straight away (default one unless the component above as a value already set on the `formControl`)
+- `formGroup`
+- `formControlNames`
+- `formGroupErrors`
+- `createFormArrayControl`
+- `controlValue$`
-**Hooks**
+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.
-- `onFormUpdate` [**_deprecated_**]: Allows you to react whenever the form is being modified. Instead of subscribing to `this.formGroup.valueChanges` or `this.formControls.someProp.valueChanges` you will not have to deal with anything asynchronous nor have to worry about subscriptions and memory leaks. Just implement the method `onFormUpdate(formUpdate: FormUpdate): void` and if you need to know which property changed do a check like the following: `if (formUpdate.yourProperty) {}`. Be aware that this method will be called only when there are either local changes to the form or changes coming from subforms. If the parent `setValue` or `patchValue` this method won't be triggered
-- `getFormGroupControlOptions`: Allows you to define control options for construction of the internal FormGroup. Use this to define form-level validators
-- `createFormArrayControl`: Allows you to create the `FormControl` of a given property of your form (to define validators for example). When you want to use this hook, implement the following interface `NgxFormWithArrayControls`
-- `handleEmissionRate`: Allows you to define a custom emission rate (top level or any sub level)
-- `getDefaultValues`: Allows you to set defaults values for the form. This method **will be called when the form is created** and applied to the form straight away. To avoid any confusion or repetitions when defining that method, we recommend in the `getFormControls` method to set all the default values of the controls to `null`. This method will also be called to reset the sub form if you try to set a `formControl` from the parent to `null` (which in some cases might be useful). You can also use that method to reset your form with default values, e.g. `this.formGroup.reset(this.getDefaultValues())`
+## Remap
-e.g.
+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:
-```ts
-// src/readme/password-sub-form.component.ts#L5-L39
+- Take the input value and remap it to match the shape expected by the form
+- Take the form value and remap it to match the shape expected as the output
-interface PasswordForm {
- password: string;
- passwordRepeat: string;
-}
+Here are the 2 interfaces:
-@Component({
- selector: 'app-password-sub-form',
- templateUrl: './password-sub-form.component.html',
- styleUrls: ['./password-sub-form.component.scss'],
- providers: subformComponentProviders(PasswordSubFormComponent),
-})
-class PasswordSubFormComponent extends NgxSubFormComponent {
- protected getFormControls() {
- return {
- password: new FormControl(null, [Validators.required, Validators.minLength(8)]),
- passwordRepeat: new FormControl(null, Validators.required),
- };
- }
+- `toFormGroup: (obj: ControlInterface) => FormInterface;`
+- `fromFormGroup: (formValue: FormInterface) => ControlInterface;`
- public getFormGroupControlOptions(): FormGroupOptions {
- return {
- validators: [
- formGroup => {
- if (formGroup.value.password !== formGroup.value.passwordRepeat) {
- return {
- passwordsMustMatch: true,
- };
- }
-
- return null;
- },
- ],
- };
- }
-}
-```
+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.
-Errors are exposed under the key `errors.formGroup` e.g.
+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-rewrite/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.
+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)_.
-```html
-
+## Dealing with arrays
-
-Password too short
+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.
-
-Passwords do not match
+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.
+
+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.
+
+Its definition is the following:
+
+```ts
+createFormArrayControl(key, value) => FormControl;
```
-## Be aware of
+Where key is a key of your main form and value, its associated value.
-There's currently a weird behavior ~~[issue (?)](https://github.com/angular/angular/issues/18004)~~ when checking for form validity.
-CF that [issue](https://github.com/angular/angular/issues/18004) and that [comment](https://github.com/angular/angular/issues/18004#issuecomment-328806479).
-It is also detailed into [`listing.component.html`](https://github.com/cloudnc/ngx-sub-form/blob/master/src/app/main/listing/listing.component.html).
+To see a complete example please refer to `src/app/main-rewrite/listing/listing-form/vehicle-listing/crew-members/crew-members.component.ts` and its `html` part.
-## Contribution
+# Contribution
Please, feel free to contribute to `ngx-sub-form`.
-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.
+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.
Remember that contributing doesn't necessarily mean to make a pull request, you can raise an issue, edit the documentation (readme), etc.
+
+# Tell us about your experience with ngx-sub-form
+
+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).
diff --git a/angular.json b/angular.json
index 4307445d..3b8daf42 100644
--- a/angular.json
+++ b/angular.json
@@ -10,14 +10,13 @@
"prefix": "app",
"schematics": {
"@schematics/angular:component": {
- "styleext": "scss"
+ "style": "scss"
}
},
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
- "aot": true,
"outputPath": "dist/ngx-sub-form-demo",
"index": "src/index.html",
"main": "src/main.ts",
@@ -25,7 +24,13 @@
"tsConfig": "src/tsconfig.app.json",
"assets": ["src/favicon.ico", "src/assets"],
"styles": ["src/styles.scss"],
- "scripts": []
+ "scripts": [],
+ "vendorChunk": true,
+ "extractLicenses": false,
+ "buildOptimizer": false,
+ "sourceMap": true,
+ "optimization": false,
+ "namedChunks": true
},
"configurations": {
"production": {
@@ -38,9 +43,7 @@
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
- "extractCss": true,
"namedChunks": false,
- "aot": true,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true,
@@ -56,7 +59,8 @@
}
]
}
- }
+ },
+ "defaultConfiguration": ""
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
@@ -88,10 +92,9 @@
}
},
"lint": {
- "builder": "@angular-devkit/build-angular:tslint",
+ "builder": "@angular-eslint/builder:lint",
"options": {
- "tsConfig": ["src/tsconfig.app.json", "src/tsconfig.spec.json"],
- "exclude": ["**/node_modules/**"]
+ "lintFilePatterns": ["src/**/*.ts", "src/**/*.html"]
}
}
}
@@ -103,7 +106,7 @@
"prefix": "lib",
"architect": {
"build": {
- "builder": "@angular-devkit/build-ng-packagr:build",
+ "builder": "@angular-devkit/build-angular:ng-packagr",
"options": {
"tsConfig": "projects/ngx-sub-form/tsconfig.lib.json",
"project": "projects/ngx-sub-form/ng-package.json"
@@ -123,14 +126,16 @@
}
},
"lint": {
- "builder": "@angular-devkit/build-angular:tslint",
+ "builder": "@angular-eslint/builder:lint",
"options": {
- "tsConfig": ["projects/ngx-sub-form/tsconfig.lib.json", "projects/ngx-sub-form/tsconfig.spec.json"],
- "exclude": ["**/node_modules/**"]
+ "lintFilePatterns": ["projects/ngx-sub-form/**/*.ts", "projects/ngx-sub-form/**/*.html"]
}
}
}
}
},
- "defaultProject": "ngx-sub-form-demo"
+ "defaultProject": "ngx-sub-form-demo",
+ "cli": {
+ "defaultCollection": "@angular-eslint/schematics"
+ }
}
diff --git a/cypress/helpers/data.helper.ts b/cypress/helpers/data.helper.ts
index 34645121..ea3f690b 100644
--- a/cypress/helpers/data.helper.ts
+++ b/cypress/helpers/data.helper.ts
@@ -1,8 +1,8 @@
-import { OneListing, ListingType } from '../../src/app/interfaces/listing.interface';
-import { UnreachableCase } from '../../src/app/shared/utils';
+import { CrewMember } from '../../src/app/interfaces/crew-member.interface';
import { DroidType } from '../../src/app/interfaces/droid.interface';
+import { ListingType, OneListing } from '../../src/app/interfaces/listing.interface';
import { VehicleType } from '../../src/app/interfaces/vehicle.interface';
-import { CrewMember } from '../../src/app/interfaces/crew-member.interface';
+import { UnreachableCase } from '../../src/app/shared/utils';
export interface ListElement {
readonly title: string;
@@ -140,4 +140,6 @@ export const hardcodedElementToTestElement = (item: OneListing): ListElement =>
export const hardcodedElementsToTestList = (items: OneListing[]): ListElement[] =>
items.map(item => hardcodedElementToTestElement(item));
-export const extractErrors = (errors: JQuery) => cy.wrap(JSON.parse(errors.text().trim()));
+export const extractErrors = (errors: JQuery) => {
+ return JSON.parse(errors.text().trim());
+};
diff --git a/cypress/helpers/dom.helper.ts b/cypress/helpers/dom.helper.ts
index 1945478c..6ac1376e 100644
--- a/cypress/helpers/dom.helper.ts
+++ b/cypress/helpers/dom.helper.ts
@@ -3,7 +3,6 @@
import { DroidType } from '../../src/app/interfaces/droid.interface';
import { ListingType } from '../../src/app/interfaces/listing.interface';
import { VehicleType } from '../../src/app/interfaces/vehicle.interface';
-import { extractErrors, FormElement, ListElement } from './data.helper';
const getTextFromTag = (element: HTMLElement, tag: string): string =>
Cypress.$(element)
@@ -36,13 +35,6 @@ const getCrewMembers = (element: HTMLElement): { firstName: string; lastName: st
}))
.get();
-export const expectAll = (selector: string, cb: (el: Cypress.Chainable) => void) =>
- cy.get(selector).then($elements => {
- $elements.each((_, $element) => {
- cb(cy.wrap($element));
- });
- });
-
export const DOM = {
get createNewButton() {
return cy.get('*[data-create-new]');
@@ -61,19 +53,6 @@ export const DOM = {
},
};
},
- get objList(): Cypress.Chainable {
- return DOM.list.elements.cy.then($elements => {
- return $elements
- .map((_, element) => ({
- title: getTextFromTag(element, 'title'),
- type: getTextFromTag(element, 'type'),
- price: getTextFromTag(element, 'price'),
- subType: getTextFromTag(element, 'sub-type'),
- details: getTextFromTag(element, 'details'),
- }))
- .get();
- });
- },
};
},
get readonlyToggle() {
@@ -85,59 +64,11 @@ export const DOM = {
return cy.get('app-listing');
},
get errors() {
- return {
- get cy() {
- return cy.get(`*[data-errors]`);
- },
- get obj() {
- return DOM.form.errors.cy.then(extractErrors);
- },
- };
+ return cy.get(`*[data-errors]`);
},
get noErrors() {
return cy.get(`*[data-no-error]`);
},
- getObj(type: VehicleType): Cypress.Chainable {
- const getVehicleObj = (element: HTMLElement, vehicleType: VehicleType) =>
- ({
- Spaceship: {
- spaceshipForm: {
- color: getTextFromInput(element, 'input-color'),
- canFire: getToggleValue(element, 'input-can-fire'),
- crewMembers: getCrewMembers(element),
- wingCount: +getTextFromInput(element, 'input-number-of-wings'),
- },
- },
- Speeder: {
- speederForm: {
- color: getTextFromInput(element, 'input-color'),
- canFire: getToggleValue(element, 'input-can-fire'),
- crewMembers: getCrewMembers(element),
- maximumSpeed: +getTextFromInput(element, 'input-maximum-speed'),
- },
- },
- }[vehicleType]);
-
- return DOM.form.cy.then($element => {
- return $element
- .map((_, element) => ({
- title: getTextFromTag(element, 'title'),
- price: getTextFromTag(element, 'price'),
- inputs: {
- id: getTextFromInput(element, 'input-id'),
- title: getTextFromInput(element, 'input-title'),
- imageUrl: getTextFromInput(element, 'input-image-url'),
- price: getTextFromInput(element, 'input-price'),
- listingType: getSelectedOptionFromSelect(element, 'select-listing-type'),
- vehicleForm: {
- vehicleType: getSelectedOptionFromSelect(element, 'select-vehicle-type'),
- ...getVehicleObj(element, type),
- },
- },
- }))
- .get()[0];
- });
- },
get elements() {
return {
get title() {
@@ -195,3 +126,54 @@ export const DOM = {
};
},
};
+
+const getVehicleObj = (element: HTMLElement, vehicleType: VehicleType) =>
+ ({
+ Spaceship: {
+ spaceshipForm: {
+ color: getTextFromInput(element, 'input-color'),
+ canFire: getToggleValue(element, 'input-can-fire'),
+ crewMembers: getCrewMembers(element),
+ wingCount: +getTextFromInput(element, 'input-number-of-wings'),
+ },
+ },
+ Speeder: {
+ speederForm: {
+ color: getTextFromInput(element, 'input-color'),
+ canFire: getToggleValue(element, 'input-can-fire'),
+ crewMembers: getCrewMembers(element),
+ maximumSpeed: +getTextFromInput(element, 'input-maximum-speed'),
+ },
+ },
+ }[vehicleType]);
+
+export const getFormValue = (form: JQuery, type: VehicleType) =>
+ form
+ .map((_, element) => ({
+ title: getTextFromTag(element, 'title'),
+ price: getTextFromTag(element, 'price'),
+ inputs: {
+ id: getTextFromInput(element, 'input-id'),
+ title: getTextFromInput(element, 'input-title'),
+ imageUrl: getTextFromInput(element, 'input-image-url'),
+ price: getTextFromInput(element, 'input-price'),
+ listingType: getSelectedOptionFromSelect(element, 'select-listing-type'),
+ vehicleForm: {
+ vehicleType: getSelectedOptionFromSelect(element, 'select-vehicle-type'),
+ ...getVehicleObj(element, type),
+ },
+ },
+ }))
+ .get()[0];
+
+export const getFormList = ($elements: JQuery) => {
+ return $elements
+ .map((_, element) => ({
+ title: getTextFromTag(element, 'title'),
+ type: getTextFromTag(element, 'type'),
+ price: getTextFromTag(element, 'price'),
+ subType: getTextFromTag(element, 'sub-type'),
+ details: getTextFromTag(element, 'details'),
+ }))
+ .get();
+};
diff --git a/cypress/support/index.js b/cypress/support/index.js
index 37a498fb..dce51337 100644
--- a/cypress/support/index.js
+++ b/cypress/support/index.js
@@ -18,3 +18,14 @@ import './commands';
// Alternatively you can use CommonJS syntax:
// require('./commands')
+
+Cypress.on('window:before:load', win => {
+ cy.stub(win.console, 'error', msg => {
+ cy.now('task', 'error', msg);
+ throw new Error(msg); // all we needed to add!
+ });
+
+ cy.stub(win.console, 'warn', msg => {
+ cy.now('task', 'warn', msg);
+ });
+});
diff --git a/package.json b/package.json
index c88a83b7..da644800 100644
--- a/package.json
+++ b/package.json
@@ -13,16 +13,15 @@
"---------------------- DEMO ----------------------": "",
"demo:start": "yarn run ng serve",
"demo:build:base": "yarn run ng build",
- "demo:build:prod": "yarn run demo:build:base --prod",
+ "demo:build:prod": "yarn run demo:build:base --configuration production",
"demo:test": "yarn run ng test",
"demo:test:e2e:watch": "yarn run cy open",
"demo:test:e2e:ci": "yarn run cy run",
"demo:lint:check": "yarn run ng lint",
"demo:lint:fix": "yarn run demo:lint:check --fix",
"------------------ LIB ngx-sub-form ------------------": "",
- "lib:build:prod:ivy": "yarn run ng build --project ngx-sub-form",
- "lib:build:prod:view-engine": "yarn run lib:build:prod:ivy --prod",
- "lib:build:watch": "yarn run lib:build:prod:view-engine --watch",
+ "lib:build:prod": "yarn run ng build --project ngx-sub-form --configuration production",
+ "lib:build:watch": "yarn run lib:build:prod --watch",
"lib:test:watch": "yarn run ng test --project ngx-sub-form",
"lib:test:ci": "yarn run ng test --project ngx-sub-form --watch false --browsers=ChromeHeadless",
"------------------ Quick Commands ------------------": "",
@@ -31,59 +30,72 @@
"test": "yarn lib:test:watch",
"commit": "git add . && git-cz",
"readme:build": "embedme README.md && yarn run prettier README.md --write",
- "readme:check": "yarn readme:build && ! git status | grep README.md || (echo 'You must commit build and commit changes to README.md!' && exit 1)"
+ "readme:check": "yarn readme:build && ! git status | grep README.md || (echo 'You must commit build and commit changes to README.md!' && exit 1)",
+ "ngcc": "[ ! -f ./node_modules/.bin/ngcc ] || node --max_old_space_size=8000 ./node_modules/.bin/ngcc",
+ "postinstall": "yarn run ngcc",
+ "lint": "ng lint"
},
"private": true,
"dependencies": {
- "@angular/animations": "9.0.1",
- "@angular/cdk": "9.0.0",
- "@angular/common": "9.0.1",
- "@angular/compiler": "9.0.1",
- "@angular/core": "9.0.1",
- "@angular/forms": "9.0.1",
- "@angular/http": "7.2.16",
- "@angular/material": "9.0.0",
- "@angular/platform-browser": "9.0.1",
- "@angular/platform-browser-dynamic": "9.0.1",
- "@angular/router": "9.0.1",
+ "@angular/animations": "13.0.2",
+ "@angular/cdk": "13.0.2",
+ "@angular/common": "13.0.2",
+ "@angular/compiler": "13.0.2",
+ "@angular/core": "13.0.2",
+ "@angular/forms": "13.0.2",
+ "@angular/material": "13.0.2",
+ "@angular/platform-browser": "13.0.2",
+ "@angular/platform-browser-dynamic": "13.0.2",
+ "@angular/router": "13.0.2",
"@types/uuid": "3.4.7",
"commitizen": "4.0.3",
"core-js": "3.6.4",
"fast-deep-equal": "3.1.1",
+ "ngx-observable-lifecycle": "2.1.4",
"rxjs": "6.5.4",
- "tslib": "1.10.0",
+ "tslib": "^2.3.1",
"uuid": "3.4.0",
- "zone.js": "0.10.2"
+ "zone.js": "~0.11.4"
},
"devDependencies": {
- "@angular-devkit/build-angular": "0.900.1",
- "@angular-devkit/build-ng-packagr": "0.900.1",
- "@angular/cli": "9.0.1",
- "@angular/compiler-cli": "9.0.1",
- "@angular/language-service": "9.0.1",
+ "@angular-devkit/build-angular": "13.0.3",
+ "@angular-eslint/builder": "13.0.0-alpha.0",
+ "@angular-eslint/eslint-plugin": "13.0.0-alpha.0",
+ "@angular-eslint/eslint-plugin-template": "13.0.0-alpha.0",
+ "@angular-eslint/schematics": "13.0.0-alpha.0",
+ "@angular-eslint/template-parser": "13.0.0-alpha.0",
+ "@angular/cli": "13.0.3",
+ "@angular/compiler-cli": "13.0.2",
+ "@angular/language-service": "13.0.2",
"@bahmutov/add-typescript-to-cypress": "2.1.2",
- "@types/jasmine": "3.5.5",
+ "@types/jasmine": "~3.6.0",
"@types/jasminewd2": "2.0.8",
"@types/node": "13.7.2",
- "codelyzer": "5.2.1",
- "cypress": "4.0.2",
+ "@typescript-eslint/eslint-plugin": "5.3.0",
+ "@typescript-eslint/parser": "5.3.0",
+ "cypress": "4.5.0",
"cz-conventional-changelog": "3.1.0",
"embedme": "1.20.0",
+ "eslint": "^8.1.0",
+ "eslint-config-prettier": "8.3.0",
+ "eslint-plugin-import": "latest",
+ "eslint-plugin-jsdoc": "latest",
+ "eslint-plugin-prefer-arrow": "latest",
"http-server-spa": "1.3.0",
- "jasmine-core": "3.5.0",
- "jasmine-spec-reporter": "4.2.1",
- "karma": "4.4.1",
- "karma-chrome-launcher": "3.1.0",
- "karma-coverage-istanbul-reporter": "2.1.1",
- "karma-jasmine": "3.1.1",
- "karma-jasmine-html-reporter": "1.5.2",
- "ng-packagr": "9.0.1",
+ "jasmine-core": "~3.6.0",
+ "jasmine-spec-reporter": "~5.0.0",
+ "karma": "~6.3.9",
+ "karma-chrome-launcher": "~3.1.0",
+ "karma-coverage-istanbul-reporter": "~3.0.2",
+ "karma-jasmine": "~4.0.0",
+ "karma-jasmine-html-reporter": "^1.5.0",
+ "ng-packagr": "^13.0.5",
"prettier": "1.19.1",
- "semantic-release": "17.0.4",
+ "semantic-release": "17.1.1",
"ts-node": "8.6.2",
"tsconfig-paths-webpack-plugin": "3.2.0",
- "tslint": "6.0.0",
- "typescript": "3.7.5"
+ "tsdef": "0.0.13",
+ "typescript": "4.4.4"
},
"repository": {
"type": "git",
diff --git a/projects/ngx-sub-form/.eslintrc.json b/projects/ngx-sub-form/.eslintrc.json
new file mode 100644
index 00000000..6a75a955
--- /dev/null
+++ b/projects/ngx-sub-form/.eslintrc.json
@@ -0,0 +1,32 @@
+{
+ "extends": "../../.eslintrc.json",
+ "ignorePatterns": ["!**/*"],
+ "overrides": [
+ {
+ "files": ["*.ts"],
+ "parserOptions": {
+ "project": ["projects/ngx-sub-form/tsconfig.lib.json", "projects/ngx-sub-form/tsconfig.spec.json"],
+ "createDefaultProgram": true
+ },
+ "extends": ["prettier"],
+ "rules": {
+ "@typescript-eslint/consistent-type-definitions": "error",
+ "@typescript-eslint/dot-notation": "off",
+ "@typescript-eslint/explicit-member-accessibility": [
+ "off",
+ {
+ "accessibility": "explicit"
+ }
+ ],
+ "@typescript-eslint/no-unused-expressions": "off",
+ "id-blacklist": "off",
+ "id-match": "off",
+ "no-underscore-dangle": "off"
+ }
+ },
+ {
+ "files": ["*.html"],
+ "rules": {}
+ }
+ ]
+}
diff --git a/projects/ngx-sub-form/ng-package.json b/projects/ngx-sub-form/ng-package.json
index aebe8a10..ad4b9a9d 100644
--- a/projects/ngx-sub-form/ng-package.json
+++ b/projects/ngx-sub-form/ng-package.json
@@ -3,6 +3,5 @@
"dest": "../../dist/ngx-sub-form",
"lib": {
"entryFile": "src/public_api.ts"
- },
- "whitelistedNonPeerDependencies": ["fast-deep-equal"]
+ }
}
diff --git a/projects/ngx-sub-form/package.json b/projects/ngx-sub-form/package.json
index 36e0aa45..f8c1eae4 100644
--- a/projects/ngx-sub-form/package.json
+++ b/projects/ngx-sub-form/package.json
@@ -3,11 +3,14 @@
"version": "0.0.1",
"license": "MIT",
"dependencies": {
- "fast-deep-equal": "^3.0.1"
+ "tslib": "^2.0.0"
},
"peerDependencies": {
- "@angular/common": "^9.0.0",
- "@angular/core": "^9.0.0"
+ "fast-deep-equal": "^3.0.1",
+ "@angular/common": ">= 10 < 13",
+ "@angular/core": ">= 10 < 13",
+ "@angular/forms": ">= 10 < 13",
+ "ngx-observable-lifecycle": "^2.1.4"
},
"keywords": [
"Angular",
diff --git a/projects/ngx-sub-form/src/lib/ngx-automatic-root-form.component.spec.ts b/projects/ngx-sub-form/src/lib/deprecated/ngx-automatic-root-form.component.spec.ts
similarity index 88%
rename from projects/ngx-sub-form/src/lib/ngx-automatic-root-form.component.spec.ts
rename to projects/ngx-sub-form/src/lib/deprecated/ngx-automatic-root-form.component.spec.ts
index 271c381d..d409a700 100644
--- a/projects/ngx-sub-form/src/lib/ngx-automatic-root-form.component.spec.ts
+++ b/projects/ngx-sub-form/src/lib/deprecated/ngx-automatic-root-form.component.spec.ts
@@ -1,11 +1,11 @@
-import { EventEmitter, Input, Component, Output, DebugElement } from '@angular/core';
-import { Controls } from './ngx-sub-form-utils';
-import { FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
-import { BehaviorSubject } from 'rxjs';
-import { TestBed, async, ComponentFixture } from '@angular/core/testing';
+import { Component, DebugElement, EventEmitter, Input, Output } from '@angular/core';
+import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
+import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { By } from '@angular/platform-browser';
-import { DataInput } from './ngx-sub-form.decorators';
+import { BehaviorSubject } from 'rxjs';
+import { Controls } from '../shared/ngx-sub-form-utils';
import { NgxAutomaticRootFormComponent } from './ngx-automatic-root-form.component';
+import { DataInput } from './ngx-sub-form.decorators';
interface Vehicle {
color?: string | null;
@@ -61,11 +61,11 @@ class TestWrapperComponent {
})
class AutomaticRootFormComponent extends NgxAutomaticRootFormComponent {
@DataInput()
- // tslint:disable-next-line:no-input-rename
+ // eslint-disable-next-line @angular-eslint/no-input-rename
@Input('vehicle')
public dataInput: Required | null = null;
- // tslint:disable-next-line:no-output-rename
+ // eslint-disable-next-line @angular-eslint/no-output-rename
@Output('vehicleUpdated')
public dataOutput: EventEmitter = new EventEmitter();
@@ -87,12 +87,14 @@ describe(`NgxAutomaticRootFormComponent`, () => {
let component: TestWrapperComponent;
let componentForm: AutomaticRootFormComponent;
- beforeEach(async(() => {
- TestBed.configureTestingModule({
- imports: [ReactiveFormsModule],
- declarations: [TestWrapperComponent, AutomaticRootFormComponent],
- }).compileComponents();
- }));
+ beforeEach(
+ waitForAsync(() => {
+ TestBed.configureTestingModule({
+ imports: [ReactiveFormsModule],
+ declarations: [TestWrapperComponent, AutomaticRootFormComponent],
+ }).compileComponents();
+ }),
+ );
beforeEach(() => {
componentFixture = TestBed.createComponent(TestWrapperComponent);
diff --git a/projects/ngx-sub-form/src/lib/ngx-automatic-root-form.component.ts b/projects/ngx-sub-form/src/lib/deprecated/ngx-automatic-root-form.component.ts
similarity index 81%
rename from projects/ngx-sub-form/src/lib/ngx-automatic-root-form.component.ts
rename to projects/ngx-sub-form/src/lib/deprecated/ngx-automatic-root-form.component.ts
index dc7ff9e5..e54457d2 100644
--- a/projects/ngx-sub-form/src/lib/ngx-automatic-root-form.component.ts
+++ b/projects/ngx-sub-form/src/lib/deprecated/ngx-automatic-root-form.component.ts
@@ -1,8 +1,11 @@
-import { OnInit, Directive } from '@angular/core';
+import { Directive, OnInit } from '@angular/core';
import { NgxRootFormComponent } from './ngx-root-form.component';
+/* eslint-disable @angular-eslint/directive-class-suffix */
@Directive()
-// tslint:disable-next-line: directive-class-suffix
+/**
+ * @deprecated
+ */
export abstract class NgxAutomaticRootFormComponent
extends NgxRootFormComponent
implements OnInit {
diff --git a/projects/ngx-sub-form/src/lib/ngx-root-form.component.spec.ts b/projects/ngx-sub-form/src/lib/deprecated/ngx-root-form.component.spec.ts
similarity index 87%
rename from projects/ngx-sub-form/src/lib/ngx-root-form.component.spec.ts
rename to projects/ngx-sub-form/src/lib/deprecated/ngx-root-form.component.spec.ts
index fc862779..743d6e07 100644
--- a/projects/ngx-sub-form/src/lib/ngx-root-form.component.spec.ts
+++ b/projects/ngx-sub-form/src/lib/deprecated/ngx-root-form.component.spec.ts
@@ -1,12 +1,12 @@
-import { NgxRootFormComponent } from './ngx-root-form.component';
-import { EventEmitter, Input, Component, Output, DebugElement } from '@angular/core';
-import { Controls, ArrayPropertyKey, ArrayPropertyValue } from './ngx-sub-form-utils';
-import { FormControl, Validators, ReactiveFormsModule, FormArray } from '@angular/forms';
-import { BehaviorSubject } from 'rxjs';
-import { TestBed, async, ComponentFixture, fakeAsync, tick } from '@angular/core/testing';
+import { Component, DebugElement, EventEmitter, Input, Output } from '@angular/core';
+import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
+import { FormArray, FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { By } from '@angular/platform-browser';
+import { BehaviorSubject } from 'rxjs';
+import { ArrayPropertyKey, ArrayPropertyValue, Controls } from '../shared/ngx-sub-form-utils';
+import { NgxFormWithArrayControls } from '../shared/ngx-sub-form.types';
+import { NgxRootFormComponent } from './ngx-root-form.component';
import { DataInput } from './ngx-sub-form.decorators';
-import { NgxFormWithArrayControls } from './ngx-sub-form.types';
interface Vehicle {
color?: string | null;
@@ -59,11 +59,11 @@ class TestWrapperComponent {
})
class RootFormComponent extends NgxRootFormComponent {
@DataInput()
- // tslint:disable-next-line:no-input-rename
+ // eslint-disable-next-line @angular-eslint/no-input-rename
@Input('vehicle')
public dataInput: Required | null | undefined = null;
- // tslint:disable-next-line:no-output-rename
+ // eslint-disable-next-line @angular-eslint/no-output-rename
@Output('vehicleUpdated')
public dataOutput: EventEmitter = new EventEmitter();
@@ -85,12 +85,14 @@ describe(`NgxRootFormComponent`, () => {
let component: TestWrapperComponent;
let componentForm: RootFormComponent;
- beforeEach(async(() => {
- TestBed.configureTestingModule({
- imports: [ReactiveFormsModule],
- declarations: [TestWrapperComponent, RootFormComponent],
- }).compileComponents();
- }));
+ beforeEach(
+ waitForAsync(() => {
+ TestBed.configureTestingModule({
+ imports: [ReactiveFormsModule],
+ declarations: [TestWrapperComponent, RootFormComponent],
+ }).compileComponents();
+ }),
+ );
beforeEach(() => {
componentFixture = TestBed.createComponent(TestWrapperComponent);
@@ -189,11 +191,11 @@ interface VehiclesArrayForm {
class RootFormArrayComponent extends NgxRootFormComponent
implements NgxFormWithArrayControls {
@DataInput()
- // tslint:disable-next-line:no-input-rename
+ // eslint-disable-next-line @angular-eslint/no-input-rename
@Input('vehicles')
public dataInput: Required | null | undefined = null;
- // tslint:disable-next-line:no-output-rename
+ // eslint-disable-next-line @angular-eslint/no-output-rename
@Output('vehiclesUpdated')
public dataOutput: EventEmitter = new EventEmitter();
@@ -227,12 +229,14 @@ describe(`NgxRootFormComponent with an array`, () => {
let component: ArrayTestWrapperComponent;
let componentForm: RootFormArrayComponent;
- beforeEach(async(() => {
- TestBed.configureTestingModule({
- imports: [ReactiveFormsModule],
- declarations: [ArrayTestWrapperComponent, RootFormArrayComponent],
- }).compileComponents();
- }));
+ beforeEach(
+ waitForAsync(() => {
+ TestBed.configureTestingModule({
+ imports: [ReactiveFormsModule],
+ declarations: [ArrayTestWrapperComponent, RootFormArrayComponent],
+ }).compileComponents();
+ }),
+ );
beforeEach(() => {
componentFixture = TestBed.createComponent(ArrayTestWrapperComponent);
diff --git a/projects/ngx-sub-form/src/lib/ngx-root-form.component.ts b/projects/ngx-sub-form/src/lib/deprecated/ngx-root-form.component.ts
similarity index 93%
rename from projects/ngx-sub-form/src/lib/ngx-root-form.component.ts
rename to projects/ngx-sub-form/src/lib/deprecated/ngx-root-form.component.ts
index e443bedc..a0e3e185 100644
--- a/projects/ngx-sub-form/src/lib/ngx-root-form.component.ts
+++ b/projects/ngx-sub-form/src/lib/deprecated/ngx-root-form.component.ts
@@ -1,12 +1,15 @@
-import { EventEmitter, OnInit, Input, Component, Directive } from '@angular/core';
+import { Directive, EventEmitter, Input, OnInit } from '@angular/core';
import isEqual from 'fast-deep-equal';
import { BehaviorSubject, Subject } from 'rxjs';
import { filter, tap } from 'rxjs/operators';
+import { isNullOrUndefined, takeUntilDestroyed } from '../shared/ngx-sub-form-utils';
import { NgxSubFormRemapComponent } from './ngx-sub-form.component';
-import { takeUntilDestroyed, isNullOrUndefined } from './ngx-sub-form-utils';
+/* eslint-disable @angular-eslint/directive-class-suffix */
@Directive()
-// tslint:disable-next-line: directive-class-suffix
+/**
+ * @deprecated
+ */
export abstract class NgxRootFormComponent
extends NgxSubFormRemapComponent
implements OnInit {
diff --git a/projects/ngx-sub-form/src/lib/ngx-sub-form.component.spec.ts b/projects/ngx-sub-form/src/lib/deprecated/ngx-sub-form.component.spec.ts
similarity index 97%
rename from projects/ngx-sub-form/src/lib/ngx-sub-form.component.spec.ts
rename to projects/ngx-sub-form/src/lib/deprecated/ngx-sub-form.component.spec.ts
index 44b5bce9..9798e1d2 100644
--- a/projects/ngx-sub-form/src/lib/ngx-sub-form.component.spec.ts
+++ b/projects/ngx-sub-form/src/lib/deprecated/ngx-sub-form.component.spec.ts
@@ -1,18 +1,17 @@
///
-import { FormControl, Validators, FormArray } from '@angular/forms';
+import { FormArray, FormControl, Validators } from '@angular/forms';
+import { Observable } from 'rxjs';
import {
- FormGroupOptions,
- NgxSubFormComponent,
- NgxSubFormRemapComponent,
- MissingFormControlsError,
- NGX_SUB_FORM_HANDLE_VALUE_CHANGES_RATE_STRATEGIES,
- Controls,
ArrayPropertyKey,
ArrayPropertyValue,
- NgxFormWithArrayControls,
-} from '../public_api';
-import { Observable } from 'rxjs';
+ Controls,
+ MissingFormControlsError,
+ NGX_SUB_FORM_HANDLE_VALUE_CHANGES_RATE_STRATEGIES,
+} from '../shared/ngx-sub-form-utils';
+import { FormGroupOptions, NgxFormWithArrayControls } from '../shared/ngx-sub-form.types';
+import { NgxSubFormComponent, NgxSubFormRemapComponent } from './ngx-sub-form.component';
+import { Component, Directive } from '@angular/core';
interface Vehicle {
color?: string | null;
@@ -35,6 +34,7 @@ const getDefaultValues = (): Required => ({
crewMemberCount: 10,
});
+@Component({ template: '' })
class SubComponent extends NgxSubFormComponent {
protected getFormControls() {
// even though optional, if we comment out color there should be a TS error
@@ -51,13 +51,15 @@ class SubComponent extends NgxSubFormComponent {
const DEBOUNCE_TIMING = 500;
+@Component({ template: '' })
class DebouncedSubComponent extends SubComponent {
protected handleEmissionRate(): (obs$: Observable) => Observable {
return NGX_SUB_FORM_HANDLE_VALUE_CHANGES_RATE_STRATEGIES.debounce(DEBOUNCE_TIMING);
}
}
-class SubComponentWithDefaultValues extends NgxSubFormComponent {
+@Component({ template: '' })
+class SubFormWithDefaultValuesComponent extends NgxSubFormComponent {
protected getFormControls() {
return {
color: new FormControl(),
@@ -92,12 +94,12 @@ describe(`Common`, () => {
describe(`NgxSubFormComponent`, () => {
let subComponent: SubComponent;
let debouncedSubComponent: DebouncedSubComponent;
- let subComponentWithDefaultValues: SubComponentWithDefaultValues;
+ let subComponentWithDefaultValues: SubFormWithDefaultValuesComponent;
beforeEach((done: () => void) => {
subComponent = new SubComponent();
debouncedSubComponent = new DebouncedSubComponent();
- subComponentWithDefaultValues = new SubComponentWithDefaultValues();
+ subComponentWithDefaultValues = new SubFormWithDefaultValuesComponent();
// we have to call `updateValueAndValidity` within the constructor in an async way
// and here we need to wait for it to run
@@ -451,6 +453,7 @@ describe(`NgxSubFormComponent`, () => {
numberTwo: number;
}
+ @Component({ template: '' })
class ValidatedSubComponent extends NgxSubFormComponent {
protected getFormControls() {
return {
@@ -483,6 +486,7 @@ describe(`NgxSubFormComponent`, () => {
passwordRepeat: string;
}
+ @Component({ template: '' })
class PasswordSubComponent extends NgxSubFormComponent {
protected getFormControls() {
return {
@@ -548,6 +552,7 @@ interface VehicleForm {
vehiclecrewMemberCount: Vehicle['crewMemberCount'] | null;
}
+@Component({ template: '' })
class SubRemapComponent extends NgxSubFormRemapComponent {
getFormControls() {
// even though optional, if we comment out vehicleColor there should be a TS error
@@ -654,6 +659,7 @@ interface VehiclesArrayForm {
vehicles: Vehicle[];
}
+@Component({ template: '' })
class SubArrayComponent extends NgxSubFormRemapComponent
implements NgxFormWithArrayControls {
protected getFormControls(): Controls {
diff --git a/projects/ngx-sub-form/src/lib/ngx-sub-form.component.ts b/projects/ngx-sub-form/src/lib/deprecated/ngx-sub-form.component.ts
similarity index 98%
rename from projects/ngx-sub-form/src/lib/ngx-sub-form.component.ts
rename to projects/ngx-sub-form/src/lib/deprecated/ngx-sub-form.component.ts
index e68f48d1..4dac87c1 100644
--- a/projects/ngx-sub-form/src/lib/ngx-sub-form.component.ts
+++ b/projects/ngx-sub-form/src/lib/deprecated/ngx-sub-form.component.ts
@@ -1,30 +1,30 @@
-import { OnDestroy, Directive, Component } from '@angular/core';
+import { Directive, OnDestroy } from '@angular/core';
import {
AbstractControl,
AbstractControlOptions,
ControlValueAccessor,
+ FormArray,
+ FormControl,
FormGroup,
ValidationErrors,
Validator,
- FormArray,
- FormControl,
} from '@angular/forms';
import { merge, Observable, Subscription } from 'rxjs';
import { delay, filter, map, startWith, withLatestFrom } from 'rxjs/operators';
import {
+ ArrayPropertyKey,
ControlMap,
Controls,
ControlsNames,
- FormUpdate,
- MissingFormControlsError,
+ ControlsType,
FormErrors,
+ FormUpdate,
isNullOrUndefined,
- ControlsType,
- ArrayPropertyKey,
+ MissingFormControlsError,
TypedAbstractControl,
TypedFormGroup,
-} from './ngx-sub-form-utils';
-import { FormGroupOptions, NgxFormWithArrayControls, OnFormUpdate } from './ngx-sub-form.types';
+} from '../shared/ngx-sub-form-utils';
+import { FormGroupOptions, NgxFormWithArrayControls, OnFormUpdate } from '../shared/ngx-sub-form.types';
type MapControlFunction = (
ctrl: TypedAbstractControl,
@@ -36,8 +36,11 @@ type FilterControlFunction = (
isCtrlWithinFormArray: boolean,
) => boolean;
+/* eslint-disable @angular-eslint/directive-class-suffix */
@Directive()
-// tslint:disable-next-line: directive-class-suffix
+/**
+ * @deprecated
+ */
export abstract class NgxSubFormComponent
implements ControlValueAccessor, Validator, OnDestroy, OnFormUpdate {
public get formGroupControls(): ControlsType {
@@ -52,7 +55,7 @@ export abstract class NgxSubFormComponent {
// see @note form-group-undefined for non-null assertion reason
- // tslint:disable-next-line:no-non-null-assertion
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return this.mapControls(ctrl => ctrl.value)!;
}
@@ -428,7 +431,9 @@ export abstract class NgxSubFormComponent extends NgxSubFormComponent<
ControlInterface,
FormInterface
diff --git a/projects/ngx-sub-form/src/lib/ngx-sub-form.decorators.ts b/projects/ngx-sub-form/src/lib/deprecated/ngx-sub-form.decorators.ts
similarity index 95%
rename from projects/ngx-sub-form/src/lib/ngx-sub-form.decorators.ts
rename to projects/ngx-sub-form/src/lib/deprecated/ngx-sub-form.decorators.ts
index b06df88d..3fd282e1 100644
--- a/projects/ngx-sub-form/src/lib/ngx-sub-form.decorators.ts
+++ b/projects/ngx-sub-form/src/lib/deprecated/ngx-sub-form.decorators.ts
@@ -1,5 +1,8 @@
import { NgxRootFormComponent } from './ngx-root-form.component';
+/**
+ * @deprecated
+ */
export class DataInputUsedOnWrongPropertyError extends Error {
constructor(calledOnPropertyKey: string) {
super(
@@ -8,6 +11,9 @@ export class DataInputUsedOnWrongPropertyError extends Error {
}
}
+/**
+ * @deprecated
+ */
export function DataInput() {
return function(
target: NgxRootFormComponent,
diff --git a/projects/ngx-sub-form/src/lib/helpers.ts b/projects/ngx-sub-form/src/lib/helpers.ts
new file mode 100644
index 00000000..8659d2a8
--- /dev/null
+++ b/projects/ngx-sub-form/src/lib/helpers.ts
@@ -0,0 +1,170 @@
+import { AbstractControlOptions, ControlValueAccessor, FormArray, FormGroup, ValidationErrors } from '@angular/forms';
+import { ReplaySubject } from 'rxjs';
+import { Nilable } from 'tsdef';
+import {
+ ControlValueAccessorComponentInstance,
+ FormBindings,
+ NgxSubFormArrayOptions,
+ NgxSubFormOptions,
+} from './ngx-sub-form.types';
+import {
+ ArrayPropertyKey,
+ ControlsNames,
+ NewFormErrors,
+ OneOfControlsTypes,
+ TypedFormGroup,
+} from './shared/ngx-sub-form-utils';
+
+export const deepCopy = (value: T): T => JSON.parse(JSON.stringify(value));
+
+/** @internal */
+export const patchClassInstance = (componentInstance: any, obj: Object) => {
+ Object.entries(obj).forEach(([key, newMethod]) => {
+ componentInstance[key] = newMethod;
+ });
+};
+
+/** @internal */
+export const getControlValueAccessorBindings = (
+ componentInstance: ControlValueAccessorComponentInstance,
+): FormBindings => {
+ const writeValue$$: ReplaySubject> = new ReplaySubject(1);
+ const registerOnChange$$: ReplaySubject<(formValue: ControlInterface | null) => void> = new ReplaySubject(1);
+ const registerOnTouched$$: ReplaySubject<() => void> = new ReplaySubject(1);
+ const setDisabledState$$: ReplaySubject = new ReplaySubject(1);
+
+ const controlValueAccessorPatch: Required = {
+ writeValue: (obj: Nilable): void => {
+ writeValue$$.next(obj);
+ },
+ registerOnChange: (fn: (formValue: ControlInterface | null) => void): void => {
+ registerOnChange$$.next(fn);
+ },
+ registerOnTouched: (fn: () => void): void => {
+ registerOnTouched$$.next(fn);
+ },
+ setDisabledState: (shouldDisable: boolean | undefined): void => {
+ setDisabledState$$.next(shouldDisable);
+ },
+ };
+
+ patchClassInstance(componentInstance, controlValueAccessorPatch);
+
+ return {
+ writeValue$: writeValue$$.asObservable(),
+ registerOnChange$: registerOnChange$$.asObservable(),
+ registerOnTouched$: registerOnTouched$$.asObservable(),
+ setDisabledState$: setDisabledState$$.asObservable(),
+ };
+};
+
+export const getFormGroupErrors = (
+ formGroup: TypedFormGroup,
+): NewFormErrors => {
+ const formErrors: NewFormErrors = Object.entries(formGroup.controls).reduce<
+ Exclude, null>
+ >((acc, [key, control]) => {
+ if (control.errors) {
+ // all of FormControl, FormArray and FormGroup can have errors so we assign them first
+ const accumulatedGenericError = acc as Record;
+ accumulatedGenericError[key as keyof ControlInterface] = control.errors;
+ }
+
+ if (control instanceof FormArray) {
+ // errors within an array are represented as a map
+ // with the index and the error
+ // this way, we avoid holding a lot of potential `null`
+ // values in the array for the valid form controls
+ const errorsInArray: Record = {};
+
+ for (let i = 0; i < control.length; i++) {
+ const controlErrors = control.at(i).errors;
+ if (controlErrors) {
+ errorsInArray[i] = controlErrors;
+ }
+ }
+
+ if (Object.values(errorsInArray).length > 0) {
+ const accumulatedArrayErrors = acc as Record>;
+ if (!(key in accumulatedArrayErrors)) {
+ accumulatedArrayErrors[key as keyof ControlInterface] = {};
+ }
+ Object.assign(accumulatedArrayErrors[key as keyof ControlInterface], errorsInArray);
+ }
+ }
+
+ return acc;
+ }, {});
+
+ if (!formGroup.errors && !Object.values(formErrors).length) {
+ return null;
+ }
+
+ // todo remove any
+ return Object.assign({}, formGroup.errors ? { formGroup: formGroup.errors } : {}, formErrors);
+};
+
+interface FormArrayWrapper {
+ key: keyof FormInterface;
+ control: FormArray;
+}
+
+export function createFormDataFromOptions(
+ options: NgxSubFormOptions,
+) {
+ const formGroup: TypedFormGroup = new FormGroup(
+ options.formControls,
+ options.formGroupOptions as AbstractControlOptions,
+ ) as TypedFormGroup;
+ const defaultValues: FormInterface = deepCopy(formGroup.value);
+ const formGroupKeys: (keyof FormInterface)[] = Object.keys(defaultValues) as (keyof FormInterface)[];
+ const formControlNames: ControlsNames = formGroupKeys.reduce>(
+ (acc, curr) => {
+ acc[curr] = curr;
+ return acc;
+ },
+ {} as ControlsNames,
+ );
+
+ const formArrays: FormArrayWrapper[] = formGroupKeys.reduce[]>(
+ (acc, key) => {
+ const control = formGroup.get(key as string);
+ if (control instanceof FormArray) {
+ acc.push({ key, control });
+ }
+ return acc;
+ },
+ [],
+ );
+ return { formGroup, defaultValues, formControlNames, formArrays };
+}
+
+export const handleFormArrays = (
+ formArrayWrappers: FormArrayWrapper[],
+ obj: FormInterface,
+ createFormArrayControl: Required>['createFormArrayControl'],
+) => {
+ if (!formArrayWrappers.length) {
+ return;
+ }
+
+ formArrayWrappers.forEach(({ key, control }) => {
+ const value = obj[key];
+
+ if (!Array.isArray(value)) {
+ return;
+ }
+
+ // instead of creating a new array every time and push a new FormControl
+ // we just remove or add what is necessary so that:
+ // - it is as efficient as possible and do not create unnecessary FormControl every time
+ // - validators are not destroyed/created again and eventually fire again for no reason
+ while (control.length > value.length) {
+ control.removeAt(control.length - 1);
+ }
+
+ for (let i = control.length; i < value.length; i++) {
+ control.insert(i, createFormArrayControl(key as ArrayPropertyKey, value[i]));
+ }
+ });
+};
diff --git a/projects/ngx-sub-form/src/lib/ngx-sub-form-tokens.ts b/projects/ngx-sub-form/src/lib/ngx-sub-form-tokens.ts
deleted file mode 100644
index d04ff268..00000000
--- a/projects/ngx-sub-form/src/lib/ngx-sub-form-tokens.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { InjectionToken } from '@angular/core';
-import { NgxSubFormComponent } from './ngx-sub-form.component';
-
-// ----------------------------------------------------------------------------------------
-// no need to expose that token out of the lib, do not export that file from public_api.ts!
-// ----------------------------------------------------------------------------------------
-
-// see https://github.com/angular/angular/issues/8277#issuecomment-263029485
-// this basically allows us to access the host component
-// from a directive without knowing the type of the component at run time
-export const SUB_FORM_COMPONENT_TOKEN = new InjectionToken>('NgxSubFormComponentToken');
diff --git a/projects/ngx-sub-form/src/lib/ngx-sub-form.ts b/projects/ngx-sub-form/src/lib/ngx-sub-form.ts
new file mode 100644
index 00000000..afe4a9c3
--- /dev/null
+++ b/projects/ngx-sub-form/src/lib/ngx-sub-form.ts
@@ -0,0 +1,275 @@
+import { ɵmarkDirty as markDirty } from '@angular/core';
+import { FormControl } from '@angular/forms';
+import isEqual from 'fast-deep-equal';
+import { getObservableLifecycle } from 'ngx-observable-lifecycle';
+import { combineLatest, concat, defer, EMPTY, forkJoin, identity, merge, Observable, of, timer } from 'rxjs';
+import {
+ catchError,
+ delay,
+ exhaustMap,
+ filter,
+ finalize,
+ ignoreElements,
+ map,
+ mapTo,
+ shareReplay,
+ startWith,
+ switchMap,
+ take,
+ takeUntil,
+ tap,
+ withLatestFrom,
+} from 'rxjs/operators';
+import {
+ createFormDataFromOptions,
+ getControlValueAccessorBindings,
+ getFormGroupErrors,
+ handleFormArrays,
+ patchClassInstance,
+} from './helpers';
+import {
+ ComponentHooks,
+ ControlValueAccessorComponentInstance,
+ FormBindings,
+ FormType,
+ NgxFormOptions,
+ NgxRootForm,
+ NgxRootFormOptions,
+ NgxSubForm,
+ NgxSubFormArrayOptions,
+ NgxSubFormOptions,
+} from './ngx-sub-form.types';
+import { isNullOrUndefined } from './shared/ngx-sub-form-utils';
+
+const optionsHaveInstructionsToCreateArrays = (
+ options: NgxFormOptions & Partial>,
+): options is NgxSubFormOptions & NgxSubFormArrayOptions =>
+ !!options.createFormArrayControl;
+
+// @todo find a better name
+const isRoot = (
+ options: any,
+): options is NgxRootFormOptions => {
+ const opt = options as NgxRootFormOptions;
+ return opt.formType === FormType.ROOT;
+};
+
+export function createForm(
+ componentInstance: ControlValueAccessorComponentInstance,
+ options: NgxRootFormOptions,
+): NgxRootForm;
+export function createForm(
+ componentInstance: ControlValueAccessorComponentInstance,
+ options: NgxSubFormOptions,
+): NgxSubForm;
+export function createForm(
+ componentInstance: ControlValueAccessorComponentInstance,
+ options: NgxFormOptions,
+): NgxSubForm {
+ const { formGroup, defaultValues, formControlNames, formArrays } = createFormDataFromOptions<
+ ControlInterface,
+ FormInterface
+ >(options);
+
+ let isRemoved = false;
+
+ const lifecyleHooks: ComponentHooks = options.componentHooks ?? {
+ onDestroy: getObservableLifecycle(componentInstance).ngOnDestroy,
+ afterViewInit: getObservableLifecycle(componentInstance).ngAfterViewInit,
+ };
+
+ lifecyleHooks.onDestroy.pipe(take(1)).subscribe(() => {
+ isRemoved = true;
+ });
+
+ // define the `validate` method to improve errors
+ // and support nested errors
+ patchClassInstance(componentInstance, {
+ validate: () => {
+ if (isRemoved) return null;
+
+ if (formGroup.valid) {
+ return null;
+ }
+
+ return getFormGroupErrors(formGroup);
+ },
+ });
+
+ // in order to ensure the form has the correct state (and validation errors) we update the value and validity
+ // immediately after the first tick
+ const updateValueAndValidity$ = timer(0);
+
+ const componentHooks = getControlValueAccessorBindings(componentInstance);
+
+ const writeValue$: FormBindings['writeValue$'] = isRoot(options)
+ ? options.input$.pipe(
+ // we need to start with a value here otherwise if a root form does not bind
+ // its input (and only uses an output, for example a filter) then
+ // `broadcastValueToParent$` would never start and we would never get updates
+ startWith(null),
+ )
+ : componentHooks.writeValue$;
+
+ const registerOnChange$: FormBindings['registerOnChange$'] = isRoot<
+ ControlInterface,
+ FormInterface
+ >(options)
+ ? of(data => {
+ if (!data) {
+ return;
+ }
+ options.output$.next(data);
+ })
+ : componentHooks.registerOnChange$;
+
+ const setDisabledState$: FormBindings['setDisabledState$'] = isRoot<
+ ControlInterface,
+ FormInterface
+ >(options)
+ ? options.disabled$ ?? of(false)
+ : componentHooks.setDisabledState$;
+
+ const transformedValue$: Observable = writeValue$.pipe(
+ map(value => {
+ if (isNullOrUndefined(value)) {
+ return defaultValues;
+ }
+
+ if (options.toFormGroup) {
+ return options.toFormGroup(value);
+ }
+
+ // if it's not a remap component, the ControlInterface === the FormInterface
+ return (value as any) as FormInterface;
+ }),
+ shareReplay({ refCount: true, bufferSize: 1 }),
+ );
+
+ const broadcastValueToParent$: Observable = transformedValue$.pipe(
+ switchMap(transformedValue => {
+ if (!isRoot(options)) {
+ return formGroup.valueChanges.pipe(delay(0));
+ } else {
+ const formValues$ = options.manualSave$
+ ? options.manualSave$.pipe(
+ withLatestFrom(formGroup.valueChanges),
+ map(([_, formValue]) => formValue),
+ )
+ : formGroup.valueChanges;
+
+ // it might be surprising to see formGroup validity being checked twice
+ // here, however this is intentional. The delay(0) allows any sub form
+ // components to populate values into the form, and it is possible for
+ // the form to be invalid after this process. In which case we suppress
+ // outputting an invalid value, and wait for the user to make the value
+ // become valid.
+ return formValues$.pipe(
+ filter(() => formGroup.valid),
+ delay(0),
+ filter(formValue => {
+ if (formGroup.invalid) {
+ return false;
+ }
+
+ if (options.outputFilterPredicate) {
+ return options.outputFilterPredicate(transformedValue, formValue);
+ }
+
+ return !isEqual(transformedValue, formValue);
+ }),
+ options.handleEmissionRate ?? identity,
+ );
+ }
+ }),
+ map(value =>
+ options.fromFormGroup
+ ? options.fromFormGroup(value)
+ : // if it's not a remap component, the ControlInterface === the FormInterface
+ ((value as any) as ControlInterface),
+ ),
+ );
+
+ // components often need to know what the current value of the FormControl that it is representing is, usually for
+ // display purposes in the template. This value is the composition of the value written from the parent, and the
+ // transformed current value that was most recently written to the parent
+ const controlValue$: NgxSubForm['controlValue$'] = merge(
+ writeValue$,
+ broadcastValueToParent$,
+ ).pipe(shareReplay({ bufferSize: 1, refCount: true }));
+
+ const emitNullOnDestroy$: Observable =
+ // emit null when destroyed by default
+ isNullOrUndefined(options.emitNullOnDestroy) || options.emitNullOnDestroy
+ ? lifecyleHooks.onDestroy.pipe(mapTo(null))
+ : EMPTY;
+
+ const createFormArrayControl: Required>['createFormArrayControl'] =
+ optionsHaveInstructionsToCreateArrays(options) && options.createFormArrayControl
+ ? options.createFormArrayControl
+ : (key, initialValue) => new FormControl(initialValue);
+
+ const sideEffects = {
+ broadcastValueToParent$: registerOnChange$.pipe(
+ switchMap(onChange => broadcastValueToParent$.pipe(tap(value => onChange(value)))),
+ ),
+ applyUpstreamUpdateOnLocalForm$: transformedValue$.pipe(
+ tap(value => {
+ handleFormArrays(formArrays, value, createFormArrayControl);
+
+ formGroup.reset(value, { emitEvent: false });
+ }),
+ ),
+ supportChangeDetectionStrategyOnPush: concat(
+ lifecyleHooks.afterViewInit.pipe(take(1)),
+ merge(controlValue$, setDisabledState$).pipe(
+ delay(0),
+ tap(() => {
+ // support `changeDetection: ChangeDetectionStrategy.OnPush`
+ // on the component hosting a form
+ // fixes https://github.com/cloudnc/ngx-sub-form/issues/93
+ markDirty(componentInstance);
+ }),
+ ),
+ ),
+ setDisabledState$: setDisabledState$.pipe(
+ tap((shouldDisable: boolean) => {
+ shouldDisable ? formGroup.disable({ emitEvent: false }) : formGroup.enable({ emitEvent: false });
+ }),
+ ),
+ updateValue$: updateValueAndValidity$.pipe(
+ tap(() => {
+ formGroup.updateValueAndValidity({ emitEvent: false });
+ }),
+ ),
+ bindTouched$: combineLatest([componentHooks.registerOnTouched$, options.touched$ ?? EMPTY]).pipe(
+ delay(0),
+ tap(([onTouched]) => onTouched()),
+ ),
+ };
+
+ merge(...Object.values(sideEffects))
+ .pipe(takeUntil(lifecyleHooks.onDestroy))
+ .subscribe();
+
+ // following cannot be part of `forkJoin(sideEffects)`
+ // because it uses `takeUntilDestroyed` which destroys
+ // the subscription when the component is being destroyed
+ // and therefore prevents the emit of the null value if needed
+ registerOnChange$
+ .pipe(
+ switchMap(onChange => emitNullOnDestroy$.pipe(tap(value => onChange(value)))),
+ takeUntil(lifecyleHooks.onDestroy.pipe(delay(0))),
+ )
+ .subscribe();
+
+ return {
+ formGroup,
+ formControlNames,
+ get formGroupErrors() {
+ return getFormGroupErrors(formGroup);
+ },
+ createFormArrayControl,
+ controlValue$,
+ };
+}
diff --git a/projects/ngx-sub-form/src/lib/ngx-sub-form.types.ts b/projects/ngx-sub-form/src/lib/ngx-sub-form.types.ts
index 8f881c3d..abdbb7fe 100644
--- a/projects/ngx-sub-form/src/lib/ngx-sub-form.types.ts
+++ b/projects/ngx-sub-form/src/lib/ngx-sub-form.types.ts
@@ -1,43 +1,110 @@
-import { FormControl, FormGroup, ValidationErrors } from '@angular/forms';
-import { Observable } from 'rxjs';
-import { ArrayPropertyKey, ArrayPropertyValue, Controls, FormUpdate, TypedFormGroup } from './ngx-sub-form-utils';
-
-// @deprecated
-export interface OnFormUpdate {
- // @deprecated
- onFormUpdate?: (formUpdate: FormUpdate) => void;
+import { ControlValueAccessor, FormControl, Validator } from '@angular/forms';
+import { Observable, Subject } from 'rxjs';
+import { Nilable } from 'tsdef';
+import {
+ ArrayPropertyKey,
+ ArrayPropertyValue,
+ Controls,
+ ControlsNames,
+ NewFormErrors,
+ TypedFormGroup,
+} from './shared/ngx-sub-form-utils';
+import { FormGroupOptions } from './shared/ngx-sub-form.types';
+
+export interface ComponentHooks {
+ onDestroy: Observable;
+ afterViewInit: Observable;
+}
+
+export interface FormBindings {
+ readonly writeValue$: Observable>;
+ readonly registerOnChange$: Observable<(formValue: ControlInterface | null) => void>;
+ readonly registerOnTouched$: Observable<() => void>;
+ readonly setDisabledState$: Observable;
+}
+
+export type ControlValueAccessorComponentInstance = Object &
+ // ControlValueAccessor methods are called
+ // directly by Angular and expects a value
+ // so we have to define it within ngx-sub-form
+ // and this should *never* be overridden by the component
+ Partial & Record>;
+
+export interface NgxSubForm {
+ readonly formGroup: TypedFormGroup;
+ readonly formControlNames: ControlsNames;
+ readonly formGroupErrors: NewFormErrors;
+ readonly createFormArrayControl: CreateFormArrayControlMethod;
+ readonly controlValue$: Observable>;
+}
+
+export type CreateFormArrayControlMethod = >(
+ key: K,
+ initialValue: ArrayPropertyValue,
+) => FormControl;
+
+export interface NgxRootForm extends NgxSubForm {
+ // @todo: anything else needed here?
}
-type Nullable = T | null;
-
-export type NullableObject = { [P in keyof T]: Nullable };
-
-export type TypedValidatorFn = (formGroup: TypedFormGroup) => ValidationErrors | null;
-
-export type TypedAsyncValidatorFn = (
- formGroup: TypedFormGroup,
-) => Promise | Observable;
-
-export interface FormGroupOptions {
- /**
- * @description
- * The list of validators applied to a control.
- */
- validators?: TypedValidatorFn | TypedValidatorFn[] | null;
- /**
- * @description
- * The list of async validators applied to control.
- */
- asyncValidators?: TypedAsyncValidatorFn | TypedAsyncValidatorFn[] | null;
- /**
- * @description
- * The event name for control to update upon.
- */
- updateOn?: 'change' | 'blur' | 'submit';
+export interface NgxSubFormArrayOptions {
+ createFormArrayControl?: CreateFormArrayControlMethod;
}
-// Unfortunately due to https://github.com/microsoft/TypeScript/issues/13995#issuecomment-504664533 the initial value
-// cannot be fully type narrowed to the exact type that will be passed.
-export interface NgxFormWithArrayControls {
- createFormArrayControl(key: ArrayPropertyKey, value: ArrayPropertyValue): FormControl;
+export interface NgxSubFormRemapOptions {
+ toFormGroup: (obj: ControlInterface) => FormInterface;
+ fromFormGroup: (formValue: FormInterface) => ControlInterface;
}
+
+export type AreTypesSimilar = T extends U ? (U extends T ? true : false) : false;
+
+// if the 2 types are the same, instead of hiding the remap options
+// we expose them as optional so that it's possible for example to
+// override some defaults
+type NgxSubFormRemap = AreTypesSimilar extends true // we expose them
+ ? Partial>
+ : NgxSubFormRemapOptions;
+
+type NgxSubFormArray = ArrayPropertyKey extends never
+ ? {} // no point defining `createFormArrayControl` if there's not a single array in the `FormInterface`
+ : NgxSubFormArrayOptions;
+
+export type NgxSubFormOptions = {
+ formType: FormType;
+ formControls: Controls;
+ formGroupOptions?: FormGroupOptions;
+ emitNullOnDestroy?: boolean;
+ componentHooks?: ComponentHooks;
+ // emit on this observable to mark the control as touched
+ touched$?: Observable;
+} & NgxSubFormRemap &
+ NgxSubFormArray;
+
+export type NgxRootFormOptions = NgxSubFormOptions<
+ ControlInterface,
+ FormInterface
+> & {
+ input$: Observable;
+ output$: Subject;
+ disabled$?: Observable;
+ // by default, a root form is considered as an automatic root form
+ // if you want to transform it into a manual root form, provide the
+ // following observable which trigger a save every time a value is emitted
+ manualSave$?: Observable;
+ // The default behavior is to compare the current transformed value of input$ with the current value of the form, and
+ // if these are equal emission on output$ is suppressed to prevent the from broadcasting the current value.
+ // Configure this option to provide your own custom predicate whether or not the form should emit.
+ outputFilterPredicate?: (currentInputValue: FormInterface, outputValue: FormInterface) => boolean;
+ // if you want to control how frequently the form emits on the output$, you can customise the emission rate with this
+ // option. e.g. `handleEmissionRate: formValue$ => formValue$.pipe(debounceTime(300)),`
+ handleEmissionRate?: (obs$: Observable) => Observable;
+};
+
+export enum FormType {
+ SUB = 'Sub',
+ ROOT = 'Root',
+}
+
+export type NgxFormOptions =
+ | NgxSubFormOptions
+ | NgxRootFormOptions;
diff --git a/projects/ngx-sub-form/src/lib/ngx-sub-form-utils.ts b/projects/ngx-sub-form/src/lib/shared/ngx-sub-form-utils.ts
similarity index 77%
rename from projects/ngx-sub-form/src/lib/ngx-sub-form-utils.ts
rename to projects/ngx-sub-form/src/lib/shared/ngx-sub-form-utils.ts
index 8bfb5f4a..650c5069 100644
--- a/projects/ngx-sub-form/src/lib/ngx-sub-form-utils.ts
+++ b/projects/ngx-sub-form/src/lib/shared/ngx-sub-form-utils.ts
@@ -1,18 +1,18 @@
+import { InjectionToken, Type } from '@angular/core';
import {
+ AbstractControl,
ControlValueAccessor,
- NG_VALUE_ACCESSOR,
- NG_VALIDATORS,
- ValidationErrors,
- FormControl,
FormArray,
- AbstractControl,
+ FormControl,
FormGroup,
+ NG_VALIDATORS,
+ NG_VALUE_ACCESSOR,
+ ValidationErrors,
} from '@angular/forms';
-import { InjectionToken, Type, forwardRef, OnDestroy } from '@angular/core';
+import { getObservableLifecycle } from 'ngx-observable-lifecycle';
import { Observable, Subject, timer } from 'rxjs';
-import { takeUntil, debounce } from 'rxjs/operators';
-import { SUB_FORM_COMPONENT_TOKEN } from './ngx-sub-form-tokens';
-import { NgxSubFormComponent } from './ngx-sub-form.component';
+import { debounce, takeUntil } from 'rxjs/operators';
+import { NgxSubFormComponent } from '../deprecated/ngx-sub-form.component';
export type Controls = { [K in keyof T]-?: AbstractControl };
@@ -24,18 +24,41 @@ export type ControlsType = {
[K in keyof T]-?: T[K] extends any[] ? TypedFormArray : TypedFormControl | TypedFormGroup;
};
+export type OneOfControlsTypes = ControlsType[keyof ControlsType];
+
+/**
+ * @deprecated
+ */
export type FormErrorsType = {
[K in keyof T]-?: T[K] extends any[] ? (null | ValidationErrors)[] : ValidationErrors;
};
+/**
+ * @deprecated
+ */
export type FormUpdate = { [FormControlInterface in keyof FormInterface]?: true };
+/**
+ * @deprecated
+ */
export type FormErrors = null | Partial<
FormErrorsType & {
formGroup?: ValidationErrors;
}
>;
+// @todo rename to `FormErrorsType` once the deprecated one is removed
+export type NewFormErrorsType = {
+ [K in keyof T]-?: T[K] extends any[] ? Record : ValidationErrors;
+};
+
+// @todo rename to `FormErrors` once the deprecated one is removed
+export type NewFormErrors = null | Partial<
+ NewFormErrorsType & {
+ formGroup?: ValidationErrors;
+ }
+>;
+
// using set/patch value options signature from form controls to allow typing without additional casting
export interface TypedAbstractControl extends AbstractControl {
value: TValue;
@@ -87,18 +110,14 @@ export function subformComponentProviders(
return [
{
provide: NG_VALUE_ACCESSOR,
- useExisting: forwardRef(() => component),
+ useExisting: component,
multi: true,
},
{
provide: NG_VALIDATORS,
- useExisting: forwardRef(() => component),
+ useExisting: component,
multi: true,
},
- {
- provide: SUB_FORM_COMPONENT_TOKEN,
- useExisting: forwardRef(() => component),
- },
];
}
@@ -122,24 +141,10 @@ export const NGX_SUB_FORM_HANDLE_VALUE_CHANGES_RATE_STRATEGIES = {
/**
* Easily unsubscribe from an observable stream by appending `takeUntilDestroyed(this)` to the observable pipe.
* If the component already has a `ngOnDestroy` method defined, it will call this first.
- * Note that the component *must* implement OnDestroy for this to work (the typings will enforce this anyway)
*/
-export function takeUntilDestroyed(component: OnDestroy): (source: Observable) => Observable {
- return (source: Observable): Observable => {
- const onDestroy = new Subject();
- const previousOnDestroy = component.ngOnDestroy;
-
- component.ngOnDestroy = () => {
- if (previousOnDestroy) {
- previousOnDestroy.apply(component);
- }
-
- onDestroy.next();
- onDestroy.complete();
- };
-
- return source.pipe(takeUntil(onDestroy));
- };
+export function takeUntilDestroyed(component: any): (source: Observable) => Observable {
+ const { ngOnDestroy } = getObservableLifecycle(component);
+ return (source: Observable): Observable => source.pipe(takeUntil(ngOnDestroy));
}
/** @internal */
diff --git a/projects/ngx-sub-form/src/lib/ngx-sub-form.types.spec.ts b/projects/ngx-sub-form/src/lib/shared/ngx-sub-form.types.spec.ts
similarity index 100%
rename from projects/ngx-sub-form/src/lib/ngx-sub-form.types.spec.ts
rename to projects/ngx-sub-form/src/lib/shared/ngx-sub-form.types.spec.ts
diff --git a/projects/ngx-sub-form/src/lib/shared/ngx-sub-form.types.ts b/projects/ngx-sub-form/src/lib/shared/ngx-sub-form.types.ts
new file mode 100644
index 00000000..22cb23be
--- /dev/null
+++ b/projects/ngx-sub-form/src/lib/shared/ngx-sub-form.types.ts
@@ -0,0 +1,44 @@
+import { FormControl, ValidationErrors } from '@angular/forms';
+import { Observable } from 'rxjs';
+import { ArrayPropertyKey, ArrayPropertyValue, FormUpdate, TypedFormGroup } from './ngx-sub-form-utils';
+
+/**
+ * @deprecated
+ */
+export interface OnFormUpdate {
+ /**
+ * @deprecated
+ */
+ onFormUpdate?: (formUpdate: FormUpdate) => void;
+}
+
+type Nullable = T | null;
+
+export type NullableObject = { [P in keyof T]: Nullable };
+
+export type TypedValidatorFn