Skip to content

Commit

Permalink
feat: support exact control types in FormGroup and FormArray (#35)
Browse files Browse the repository at this point in the history
* feature: support exact control types in FormGroup and FormArray

Support generic type that contains the type of the controls, so it's possible to infer it properly.

* fix(pr): form group controls type

* fix: conflicts

* fix: test

Add explicit generic type to avoid type error in formGroup.spec.ts

* fix(type): solve issue with union types

Prevent distributive conditional types so ControlOfValue<boolean> is FormControl<boolean> and not FormControl<true> | FormControl<false>
See microsoft/TypeScript#37279

* feat: deep-controls interface

* feat: override group controls type

* rename DeepControlsOf to ControlsOf as requested
* Added the ability to override the default behavior of this type.

* fix(types): remove flat controls interface

* fix(test): stabilize tests

* fix(test): stabilize tests

* fix(conflict): resolve conflicts

* fix(tests): fixed tests typing

Seems to be like TypeScript needed a little help inferring some of the types.

Co-authored-by: sharshuv-quotient <[email protected]>
  • Loading branch information
2 people authored and itayod committed Oct 22, 2020
1 parent 23a5eac commit 0734d55
Show file tree
Hide file tree
Showing 12 changed files with 516 additions and 144 deletions.
57 changes: 45 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,19 +50,32 @@ Let's take a look at all the neat things we provide:

## Control Type

Each `AbstractControl` takes a generic, which serves as the `type` for any method exposed by Angular or this library:

Each `AbstractControl` takes a generic, which can be neither the value `type` (all `AbstractControl`s) or the type of the controls (`FormGroup`/`FormArray`). This type is than used to enhance every method exposed by Angular or this library.
Use it with a `FormControl`:

```ts
import { FormControl } from '@ngneat/reactive-forms';

const control = new FormControl('');
const control = new FormControl<string>('');
control.valueChanges.subscribe(value => {
// value is typed as string
});
```

Use it with a `FormArray`:

```ts
import { FormArray, FormControl } from '@ngneat/reactive-forms';

const control = new FormArray<string>([new FormControl()]);

control.value$.subscribe(value => {
// value is typed as string[]
});
```


Use it with a `FormGroup`:

```ts
Expand Down Expand Up @@ -92,18 +105,37 @@ profileForm.setValue(new Profile());
profileForm.patchValue({ firstName: 'Netanel' });
```

Use it with a `FormArray`:
Or alternatively, with the controls as the generic type:

```ts
import { FormArray, FormControl } from '@ngneat/reactive-forms';
import { FormGroup } from '@ngneat/reactive-forms';

const control = new FormArray<string>([new FormControl()]);
interface ProfileControls {
firstName: string; // Note that for primitive types the type "FormControl" is infered, so no need to write that.
lastName: string;
address: FormGroup<
street: string,
city: string,
>;
}

control.value$.subscribe(value => {
// value is typed as string[]
const profileForm = new FormGroup<ProfileControls>({
firstName: new FormControl(''),
lastName: new FormControl(''),
address: new FormGroup({
street: new FormControl(''),
city: new FormControl('')
})
});

// typed as Profile
profileForm.setValue(new Profile());
// typed as Partial<Profile>
profileForm.patchValue({ firstName: 'Netanel' });
```

(Note supplying the controls type will enable you to access individual controls later with type inference, and avoid unneeded casting.)

## Control Queries

### `value$`
Expand Down Expand Up @@ -345,16 +377,17 @@ control.setDisable(false);
### `getControl()`

A method with `typed` parameters which obtains a reference to a specific control.
When supplying the controls type, type inference for the returned control will be available for up to 2 keys in path. (Thus, for example `getControl('a', 'b', 'c')` will always return `AbstractControl` )

```ts
import { FormGroup } from '@ngneat/reactive-forms';

const group = new FormGroup<Profile>(...);
const address = group.getControl('name') as FormGroup<Profile['address']>;
const city = group.getControl('address', 'city') as FormControl<string>;
const group = new FormGroup<ProfileControls>(...);
const address: FormControl<string> = group.getControl('name');
const city: FormControl<string> = group.getControl('address', 'city');
```

**Note that the return type should still be inferred.**
Note that if you're only passing the "value" type (e.g. `FormGroup<Profile>`), this method will always return AbstractControl. In that case the return type might need to be inferred.

### `mergeErrors()`

Expand Down Expand Up @@ -409,7 +442,7 @@ array.removeIf((control) => control.get('address').get('city').value !== 'New Yo
The **array** path variation of `hasError()`, `getError()`, and `get()` is now `typed`:

```ts
const num = group.get(['phone', 'num']) as FormControl<string>;
const num = group.get(['phone', 'num']);
const hasError = group.hasError('required', ['phone', 'num']);
const getError = group.getError('required', ['phone', 'num']);
```
Expand Down
2 changes: 1 addition & 1 deletion projects/ngneat/reactive-forms/src/lib/formArray.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ describe('FormArray', () => {
});

it('should removeIf with nested form groups', () => {
const control = createArray();
const control = new FormArray([]);
control.clear();
control.push(new FormGroup({ type: new FormControl('Jedi'), name: new FormControl('Luke') }));
control.push(new FormGroup({ type: new FormControl('Sith'), name: new FormControl('Vader') }));
Expand Down
46 changes: 25 additions & 21 deletions projects/ngneat/reactive-forms/src/lib/formArray.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import {
mergeControlValidators
} from './control-actions';
import {
AbstractControl,
AsyncValidator,
AsyncValidatorFn,
ControlEventOptions,
Expand All @@ -28,14 +27,16 @@ import {
ExtractStrings,
OnlySelf,
Validator,
ValidatorFn,
ValidatorOrOpts
ValidatorOrOpts,
ControlValue,
AbstractControlOf,
ValidatorFn
} from './types';
import { coerceArray, mergeErrors, removeError } from './utils';

export class FormArray<T = any, E extends object = any> extends NgFormArray {
readonly value: T[];
readonly valueChanges: Observable<T[]>;
readonly value: ControlValue<T>[];
readonly valueChanges: Observable<ControlValue<T>[]>;
readonly status: ControlState;
readonly statusChanges: Observable<ControlState>;
readonly errors: E | null;
Expand All @@ -47,7 +48,7 @@ export class FormArray<T = any, E extends object = any> extends NgFormArray {
readonly touch$ = this.touchChanges.asObservable().pipe(distinctUntilChanged());
readonly dirty$ = this.dirtyChanges.asObservable().pipe(distinctUntilChanged());

readonly value$ = controlValueChanges$<T[]>(this);
readonly value$ = controlValueChanges$<ControlValue<T>[]>(this);
readonly disabled$ = controlDisabled$(this);
readonly enabled$ = controlEnabled$(this);
readonly status$ = controlStatusChanges$(this);
Expand All @@ -68,37 +69,40 @@ export class FormArray<T = any, E extends object = any> extends NgFormArray {
}

constructor(
public controls: Array<AbstractControl<T>>,
public controls: Array<AbstractControlOf<T>>,
validatorOrOpts?: ValidatorOrOpts,
asyncValidator?: AsyncValidator
) {
super(controls, validatorOrOpts, asyncValidator);
}

select<R>(mapFn: (state: T[]) => R): Observable<R> {
select<R>(mapFn: (state: ControlValue<T>[]) => R): Observable<R> {
return this.value$.pipe(map(mapFn), distinctUntilChanged());
}

getRawValue(): T[] {
getRawValue(): ControlValue<T>[] {
return super.getRawValue();
}

at(index: number): AbstractControl<T> {
return super.at(index) as AbstractControl<T>;
at(index: number): AbstractControlOf<T> {
return super.at(index) as AbstractControlOf<T>;
}

setValue(valueOrObservable: Observable<T[]>, options?: ControlEventOptions): Subscription;
setValue(valueOrObservable: T[], options?: ControlEventOptions): void;
setValue(valueOrObservable: T[] | Observable<T[]>, options?: ControlEventOptions): Subscription | void {
setValue(valueOrObservable: Observable<ControlValue<T>[]>, options?: ControlEventOptions): Subscription;
setValue(valueOrObservable: ControlValue<T>[], options?: ControlEventOptions): void;
setValue(
valueOrObservable: ControlValue<T>[] | Observable<ControlValue<T>[]>,
options?: ControlEventOptions
): Subscription | void {
if (isObservable(valueOrObservable)) {
return valueOrObservable.subscribe(value => super.setValue(value, options));
}

super.setValue(valueOrObservable, options);
}

patchValue(valueOrObservable: Observable<T[]>, options?: ControlEventOptions): Subscription;
patchValue(valueOrObservable: T[], options?: ControlEventOptions): void;
patchValue(valueOrObservable: Observable<Partial<ControlValue<T>>[]>, options?: ControlEventOptions): Subscription;
patchValue(valueOrObservable: Partial<ControlValue<T>>[], options?: ControlEventOptions): void;
patchValue(valueOrObservable: any, options?: ControlEventOptions): Subscription | void {
if (isObservable(valueOrObservable)) {
return valueOrObservable.subscribe((value: T[]) => super.patchValue(value, options));
Expand All @@ -107,15 +111,15 @@ export class FormArray<T = any, E extends object = any> extends NgFormArray {
super.patchValue(valueOrObservable as T[], options);
}

push(control: AbstractControl<T>): void {
push(control: AbstractControlOf<T>): void {
return super.push(control);
}

insert(index: number, control: AbstractControl<T>): void {
insert(index: number, control: AbstractControlOf<T>): void {
return super.insert(index, control);
}

setControl(index: number, control: AbstractControl<T>): void {
setControl(index: number, control: AbstractControlOf<T>): void {
return super.setControl(index, control);
}

Expand Down Expand Up @@ -160,7 +164,7 @@ export class FormArray<T = any, E extends object = any> extends NgFormArray {
markAllDirty(this);
}

reset(value?: T[], options?: ControlEventOptions): void {
reset(value?: ControlValue<T>[], options?: ControlEventOptions): void {
super.reset(value, options);
}

Expand Down Expand Up @@ -221,7 +225,7 @@ export class FormArray<T = any, E extends object = any> extends NgFormArray {
this.removeWhen(v => v.value === value);
}

removeWhen(predicate: (element: AbstractControl<T>) => boolean): void {
removeWhen(predicate: (element: AbstractControlOf<T>) => boolean): void {
const toRemove: number[] = [];
for (let i = this.length - 1; i >= 0; --i) {
if (predicate(this.at(i))) {
Expand Down
18 changes: 14 additions & 4 deletions projects/ngneat/reactive-forms/src/lib/formBuilder.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,30 @@ describe('FormBuilder', () => {

describe('group', () => {
it('should accept an object', () => {
const group: FormGroup<User> = fb.group({ name: 'ngneat', id: 1, address: fb.group({ city: 'Hello' }) });
const group: FormGroup<User> = fb.group({
name: 'ngneat',
id: 1,
address: fb.group<{ city: string }>({ city: 'Hello' })
});
expect(group.getRawValue()).toEqual({ name: 'ngneat', id: 1, address: { city: 'Hello' } });
});

it('should accept boxed value', () => {
const group = fb.group<User>({
name: ['ngneat', Validators.required],
id: [{ value: 1, disabled: true }],
address: fb.group({ city: 'Hello' })
address: fb.group<{ city: string }>({ city: 'Hello' })
});

expect(group.getRawValue()).toEqual({ name: 'ngneat', id: 1, address: { city: 'Hello' } });
});

it('should have extended keys', () => {
const group: FormGroup<User> = fb.group({ name: 'ngneat', id: 1, address: fb.group({ city: '' }) });
const group: FormGroup<User> = fb.group({
name: 'ngneat',
id: 1,
address: fb.group<{ city: string }>({ city: '' })
});
const keys: (keyof FormGroup)[] = [
'getControl',
'enabledWhile',
Expand Down Expand Up @@ -90,7 +98,9 @@ describe('FormBuilder', () => {
});

it('should array', () => {
const array = fb.array<User>([fb.group({ name: 'ngneat', id: 1, address: fb.group({ city: 'ngneat' }) })]);
const array = fb.array<User>([
fb.group<User>({ name: 'ngneat', id: 1, address: fb.group<{ city: string }>({ city: 'ngneat' }) })
]);
expect(array.getRawValue()).toEqual([
{
name: 'ngneat',
Expand Down
10 changes: 8 additions & 2 deletions projects/ngneat/reactive-forms/src/lib/formBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@ import { FormBuilder as NgFormBuilder } from '@angular/forms';
import { FormArray } from './formArray';
import { FormControl } from './formControl';
import { FormGroup } from './formGroup';
import { AbstractControlOptions, AsyncValidatorFn, AbstractControl, OrBoxedValue, ValidatorFn } from './types';
import {
AbstractControlOptions,
AsyncValidatorFn,
AbstractControl,
OrBoxedValue,
ValidatorFn,
ControlsOf
} from './types';

function isAbstractControlOptions<T>(
options: AbstractControlOptions<T> | { [key: string]: any }
Expand Down Expand Up @@ -55,7 +62,6 @@ export class FormBuilder extends NgFormBuilder {
}
}

// Todo: dan remove the any
return new FormGroup(controls, { asyncValidators, updateOn, validators });
}

Expand Down
Loading

0 comments on commit 0734d55

Please sign in to comment.