diff --git a/aio/tools/transforms/angular-api-package/processors/mergeOverriddenImplementation.js b/aio/tools/transforms/angular-api-package/processors/mergeOverriddenImplementation.js index 6477cbae2f4..c801f18ac6d 100644 --- a/aio/tools/transforms/angular-api-package/processors/mergeOverriddenImplementation.js +++ b/aio/tools/transforms/angular-api-package/processors/mergeOverriddenImplementation.js @@ -8,27 +8,29 @@ * This looks like the following: * * ``` + * /** + * * @overriddenImplementation FooCtor + * *\/ * export interface Foo { * bar(); * } * - * export class FooImpl { - * bar() {} - * } + * type FooInterface = Foo; + * + * export class FooImpl { ... } * * export interface FooCtor { * new(): Foo; * } * - * export const Foo: FooCtor = FooImpl as FooCtor; + * export const Foo: FooCtor = + (class Foo implements FooInterface { ... } * ``` * - * This processor will correct the docs for symbol `Foo` by copying them over from `FooImpl` - * to the exported symbol `Foo`. The processor will also copy all documented constructor overrides from `FooCtor`. + * This processor will extend the docs for symbol `Foo` by copying all documented constructor overrides from `FooCtor`. * - * In order to use this processor, annotate the exported constant with `@overriddenImplementation`, - * and mark the implementation and constructor types as `@internal`. Place the desired - * documentation on the implementation class. + * In order to use this processor, annotate the exported interface with `@overriddenImplementation`. Place the desired + * documentation on the interface and constructor signatures. */ module.exports = function mergeOverriddenImplementation(getDocFromAlias, log) { return { @@ -40,62 +42,33 @@ ], $process(docs) { docs.forEach(doc => { - if (doc.overriddenImplementation) { - // Check the AST is of the expected expression shape, and extract the identifiers. - const symbolAstObjects = [doc.declaration?.name, doc.declaration?.type, doc.declaration?.initializer?.expression]; - if (symbolAstObjects.some(symbol => symbol === undefined)) { - throw new Error('@overriddenImplementation must have format `export const Foo: FooCtor = FooImpl as FooCtor;`'); - } - - // Convert the AST nodes into docs. - const symbolNames = symbolAstObjects.map(s => s.getText()); - const symbolDocArrays = symbolNames.map(symbol => getDocFromAlias(symbol)); - for (let i = 0; i < symbolDocArrays.length; i++) { - if (symbolDocArrays[i].length === 0) { - throw new Error(`@overriddenImplementation failed to find a doc for ${symbolNames[i]}. Are you sure this symbol is documented and exported?`); + if (doc.overriddenImplementation) { + // Convert the specified name into a doc. + const ctorDocArray = getDocFromAlias(doc.overriddenImplementation); + if (ctorDocArray.length === 0) { + throw new Error(`@overriddenImplementation failed to find a doc for ${doc.overriddenImplementation}. Are you sure this symbol is documented and exported?`); } - if (symbolDocArrays[i].length >= 2) { - throw new Error(`@overriddenImplementation found multiple docs for ${symbolNames[i]}. You may only have one documented symbol for each.`); + if (ctorDocArray.length >= 2) { + throw new Error(`@overriddenImplementation found multiple docs for ${doc.overriddenImplementation}. You may only have one documented symbol for each.`); } - } - const symbolDocs = symbolDocArrays.map(a => a[0]); - const exportedNameDoc = symbolDocs[0]; - const ctorDoc = symbolDocs[1]; - const implDoc = symbolDocs[2]; - - // Clean out the unwanted properties from the exported doc. - Object.keys(doc).forEach(key => { - if (!this.propertiesToKeep.includes(key)) { - delete doc[key]; - } - }); - - // Copy over all the properties from the implementation doc. - Object.keys(implDoc).forEach(key => { - if (!this.propertiesToKeep.includes(key)) { - exportedNameDoc[key] = implDoc[key]; - } - }); + + const ctorDoc = ctorDocArray[0]; // Copy the constructor overrides from the constructor doc, if any are present. - if (!ctorDoc.members || ctorDoc.members.length !== 1 || !ctorDoc.members[0].name.includes('new')) { - throw new Error(`@overriddenImplementation requires that the provided constructor ${symbolNames[1]} have exactly one member called "new", possibly with multiple overrides.`); + if (!ctorDoc.members || ctorDoc.members.length === 0 || !ctorDoc.members[0].name.includes('new')) { + throw new Error(`@overriddenImplementation requires that the provided constructor ${ctorDoc} have a member called "new", possibly with multiple overrides.`); } - exportedNameDoc.constructorDoc = ctorDoc.members[0]; + doc.constructorDoc = ctorDoc.members[0]; - // Mark symbols other than the exported name as internal. + // Mark the constructor doc internal. if (!ctorDoc.internal) { - log.warn(`Constructor doc ${symbolNames[1]} was not marked '@internal'; adding this annotation.`); + log.warn(`Constructor doc ${ctorDoc} was not marked '@internal'; adding this annotation.`); ctorDoc.internal = true; } - if (!implDoc.internal) { - log.warn(`Implementation doc ${symbolNames[2]} was not marked '@internal'; adding this annotation.`); - implDoc.internal = true; - } - // The exported doc should not be private, unlike the implementation doc. - exportedNameDoc.privateExport = false; - exportedNameDoc.internal = false; + // The exported doc should not be private. + doc.privateExport = false; + doc.internal = false; } }); } diff --git a/aio/tools/transforms/angular-api-package/processors/mergeOverriddenImplementation.spec.js b/aio/tools/transforms/angular-api-package/processors/mergeOverriddenImplementation.spec.js index 252e1bc483c..cde145e98e8 100644 --- a/aio/tools/transforms/angular-api-package/processors/mergeOverriddenImplementation.spec.js +++ b/aio/tools/transforms/angular-api-package/processors/mergeOverriddenImplementation.spec.js @@ -39,15 +39,8 @@ describe('mergeOverriddenImplementation processor', () => { it('should replace properties with those from the implementation and constructor docs', () => { const exportedNameDoc = { - overriddenImplementation: 'Foo has an overridden implementation.', // This processor should apply - declaration: { // Imitate a valid AST - name: {getText: () => 'Foo'}, - type: {getText: () => 'FooCtor'}, - initializer: { - expression: {getText: () => 'FooImpl'}, - }, - }, - exportedProp: true, // This prop will be removed + overriddenImplementation: 'FooCtor', // This processor should apply + exportedProp: true, // Documentation on the exported interface will remain }; const docs = [exportedNameDoc]; @@ -55,7 +48,6 @@ describe('mergeOverriddenImplementation processor', () => { switch(docName) { case 'Foo': return [exportedNameDoc]; case 'FooCtor': return [{ctorProp: true, members: [{name: 'new'}]}]; - case 'FooImpl': return [{implProp: true}]; } }; @@ -63,8 +55,9 @@ describe('mergeOverriddenImplementation processor', () => { processor.$process(docs); expect(docs).toEqual([{ - // Property copied from the implementation - implProp: true, + overriddenImplementation: 'FooCtor', + // Original documentation property + exportedProp: true, // Constructor signature property constructorDoc: {name: 'new'}, // The exported symbol should be explicitly marked non-internal diff --git a/goldens/public-api/forms/forms.md b/goldens/public-api/forms/forms.md index 182b6c72e63..40bfc39c2b1 100644 --- a/goldens/public-api/forms/forms.md +++ b/goldens/public-api/forms/forms.md @@ -296,8 +296,7 @@ export class FormBuilder { } // @public -export class FormControl extends AbstractControl { - constructor(formState?: any, validatorOrOpts?: ValidatorFn | ValidatorFn[] | FormControlOptions | null, asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null); +export interface FormControl extends AbstractControl { readonly defaultValue: any; patchValue(value: any, options?: { onlySelf?: boolean; @@ -319,6 +318,9 @@ export class FormControl extends AbstractControl { }): void; } +// @public (undocumented) +export const FormControl: ɵFormControlCtor; + // @public export class FormControlDirective extends NgControl implements OnChanges, OnDestroy { constructor(validators: (Validator | ValidatorFn)[], asyncValidators: (AsyncValidator | AsyncValidatorFn)[], valueAccessors: ControlValueAccessor[], _ngModelWarningConfig: string | null); diff --git a/packages/forms/src/forms.ts b/packages/forms/src/forms.ts index 712f8c413f8..4eefc1b1c36 100644 --- a/packages/forms/src/forms.ts +++ b/packages/forms/src/forms.ts @@ -17,7 +17,6 @@ * explicitly. */ - export {ɵInternalFormsSharedModule} from './directives'; export {AbstractControlDirective} from './directives/abstract_control_directive'; export {AbstractFormGroupDirective} from './directives/abstract_form_group_directive'; @@ -43,7 +42,7 @@ export {NgSelectOption, SelectControlValueAccessor} from './directives/select_co export {SelectMultipleControlValueAccessor, ɵNgSelectMultipleOption} from './directives/select_multiple_control_value_accessor'; export {AsyncValidator, AsyncValidatorFn, CheckboxRequiredValidator, EmailValidator, MaxLengthValidator, MaxValidator, MinLengthValidator, MinValidator, PatternValidator, RequiredValidator, ValidationErrors, Validator, ValidatorFn} from './directives/validators'; export {FormBuilder} from './form_builder'; -export {AbstractControl, AbstractControlOptions, FormArray, FormControl, FormControlOptions, FormControlStatus, FormGroup} from './model'; +export {AbstractControl, AbstractControlOptions, FormArray, FormControl, FormControlOptions, FormControlStatus, FormGroup, ɵFormControlCtor} from './model'; export {NG_ASYNC_VALIDATORS, NG_VALIDATORS, Validators} from './validators'; export {VERSION} from './version'; diff --git a/packages/forms/src/model.ts b/packages/forms/src/model.ts index 9d5b5ca78ba..db242a343ce 100644 --- a/packages/forms/src/model.ts +++ b/packages/forms/src/model.ts @@ -161,9 +161,9 @@ export interface AbstractControlOptions { export interface FormControlOptions extends AbstractControlOptions { /** * @description - * Whether to use the initial value used to construct the FormControl as its default value as - * well. If this option is false or not provided, the default value of a FormControl is `null`. - * When a FormControl is {@link FormControl#reset} without an explicit value, its value reverts to + * Whether to use the initial value used to construct the {@link FormControl} as its default value + * as well. If this option is false or not provided, the default value of a FormControl is `null`. + * When a FormControl is reset without an explicit value, its value reverts to * its default value. */ initialValueIsDefault?: boolean; @@ -1177,7 +1177,6 @@ export abstract class AbstractControl { this._updateOn = opts.updateOn!; } } - /** * Check to see if parent has been marked artificially dirty. * @@ -1201,6 +1200,10 @@ export abstract class AbstractControl { * @see [Reactive Forms Guide](guide/reactive-forms) * @see [Usage Notes](#usage-notes) * + * @publicApi + * + * @overriddenImplementation ɵFormControlCtor + * * @usageNotes * * ### Initializing Form Controls @@ -1210,7 +1213,7 @@ export abstract class AbstractControl { * ```ts * const control = new FormControl('some value'); * console.log(control.value); // 'some value' - *``` + * ``` * * The following example initializes the control with a form state object. The `value` * and `disabled` keys are required in this case. @@ -1283,20 +1286,17 @@ export abstract class AbstractControl { * console.log(control.value); // 'Drew' * console.log(control.status); // 'DISABLED' * ``` - * - * @publicApi */ -export class FormControl extends AbstractControl { +export interface FormControl extends AbstractControl { /** * The default value of this FormControl, used whenever the control is reset without an explicit * value. See {@link FormControlOptions#initialValueIsDefault} for more information on configuring * a default value. - * @publicApi */ - public readonly defaultValue: any = null; + readonly defaultValue: any; /** @internal */ - _onChange: Array = []; + _onChange: Function[]; /** * This field holds a pending value that has not yet been applied to the form's value. @@ -1306,44 +1306,7 @@ export class FormControl extends AbstractControl { _pendingValue: any; /** @internal */ - _pendingChange: boolean = false; - - /** - * Creates a new `FormControl` instance. - * - * @param formState Initializes the control with an initial value, - * or an object that defines the initial value and disabled state. - * - * @param validatorOrOpts A synchronous validator function, or an array of - * such functions, or an `AbstractControlOptions` object that contains validation functions - * and a validation trigger. - * - * @param asyncValidator A single async validator or array of async validator functions - * - */ - constructor( - formState: any = null, validatorOrOpts?: ValidatorFn|ValidatorFn[]|FormControlOptions|null, - asyncValidator?: AsyncValidatorFn|AsyncValidatorFn[]|null) { - super(pickValidators(validatorOrOpts), pickAsyncValidators(asyncValidator, validatorOrOpts)); - this._applyFormState(formState); - this._setUpdateStrategy(validatorOrOpts); - this._initObservables(); - this.updateValueAndValidity({ - onlySelf: true, - // If `asyncValidator` is present, it will trigger control status change from `PENDING` to - // `VALID` or `INVALID`. - // The status should be broadcasted via the `statusChanges` observable, so we set `emitEvent` - // to `true` to allow that during the control creation process. - emitEvent: !!this.asyncValidator - }); - if (isOptionsObj(validatorOrOpts) && validatorOrOpts.initialValueIsDefault) { - if (this._isBoxedValue(formState)) { - this.defaultValue = formState.value; - } else { - this.defaultValue = formState; - } - } - } + _pendingChange: boolean; /** * Sets a new value for the form control. @@ -1368,19 +1331,12 @@ export class FormControl extends AbstractControl { * event to update the model. * */ - override setValue(value: any, options: { + setValue(value: any, options?: { onlySelf?: boolean, emitEvent?: boolean, emitModelToViewChange?: boolean, emitViewToModelChange?: boolean - } = {}): void { - (this as {value: any}).value = this._pendingValue = value; - if (this._onChange.length && options.emitModelToViewChange !== false) { - this._onChange.forEach( - (changeFn) => changeFn(this.value, options.emitViewToModelChange !== false)); - } - this.updateValueAndValidity(options); - } + }): void; /** * Patches the value of a control. @@ -1391,14 +1347,12 @@ export class FormControl extends AbstractControl { * * @see `setValue` for options */ - override patchValue(value: any, options: { + patchValue(value: any, options?: { onlySelf?: boolean, emitEvent?: boolean, emitModelToViewChange?: boolean, emitViewToModelChange?: boolean - } = {}): void { - this.setValue(value, options); - } + }): void; /** * Resets the form control, marking it `pristine` and `untouched`, and resetting @@ -1433,99 +1387,229 @@ export class FormControl extends AbstractControl { * When false, no events are emitted. * */ - override reset(formState: any = this.defaultValue, options: { - onlySelf?: boolean, - emitEvent?: boolean - } = {}): void { - this._applyFormState(formState); - this.markAsPristine(options); - this.markAsUntouched(options); - this.setValue(this.value, options); - this._pendingChange = false; - } + reset(formState?: any, options?: {onlySelf?: boolean, emitEvent?: boolean}): void; /** * @internal */ - override _updateValue() {} + _updateValue(): void; /** * @internal */ - override _anyControls(condition: (c: AbstractControl) => boolean): boolean { - return false; - } + _anyControls(condition: (c: AbstractControl) => boolean): boolean; /** * @internal */ - override _allControlsDisabled(): boolean { - return this.disabled; - } + _allControlsDisabled(): boolean; + /** * Register a listener for change events. * * @param fn The method that is called when the value changes */ - registerOnChange(fn: Function): void { - this._onChange.push(fn); - } + registerOnChange(fn: Function): void; + /** * Internal function to unregister a change events listener. * @internal */ - _unregisterOnChange(fn: (value?: any, emitModelEvent?: boolean) => void): void { - removeListItem(this._onChange, fn); - } + _unregisterOnChange(fn: (value?: any, emitModelEvent?: boolean) => void): void; /** * Register a listener for disabled events. * * @param fn The method that is called when the disabled status changes. */ - registerOnDisabledChange(fn: (isDisabled: boolean) => void): void { - this._onDisabledChange.push(fn); - } + registerOnDisabledChange(fn: (isDisabled: boolean) => void): void; /** * Internal function to unregister a disabled event listener. * @internal */ - _unregisterOnDisabledChange(fn: (isDisabled: boolean) => void): void { - removeListItem(this._onDisabledChange, fn); - } + _unregisterOnDisabledChange(fn: (isDisabled: boolean) => void): void; /** * @internal */ - override _forEachChild(cb: (c: AbstractControl) => void): void {} + _forEachChild(cb: (c: AbstractControl) => void): void; /** @internal */ - override _syncPendingControls(): boolean { - if (this.updateOn === 'submit') { - if (this._pendingDirty) this.markAsDirty(); - if (this._pendingTouched) this.markAsTouched(); - if (this._pendingChange) { - this.setValue(this._pendingValue, {onlySelf: true, emitModelToViewChange: false}); - return true; - } - } - return false; - } - - private _applyFormState(formState: any) { - if (this._isBoxedValue(formState)) { - (this as {value: any}).value = this._pendingValue = formState.value; - formState.disabled ? this.disable({onlySelf: true, emitEvent: false}) : - this.enable({onlySelf: true, emitEvent: false}); - } else { - (this as {value: any}).value = this._pendingValue = formState; - } - } + _syncPendingControls(): boolean; } +type FormControlInterface = FormControl; + +/** + * Various available constructors for `FormControl`. + * Do not use this interface directly. Instead, use `FormControl`: + * ``` + * const fc = new FormControl('foo'); + * ``` + * This symbol is prefixed with ɵ to make plain that it is an internal symbol. + */ +export interface ɵFormControlCtor { + /** + * Construct a FormControl with no initial value or validators. + */ + new(): FormControl; + + /** + * Creates a new `FormControl` instance. + * + * @param formState Initializes the control with an initial value, + * or an object that defines the initial value and disabled state. + * + * @param validatorOrOpts A synchronous validator function, or an array of + * such functions, or a `FormControlOptions` object that contains validation functions + * and a validation trigger. + * + * @param asyncValidator A single async validator or array of async validator functions. + */ + new(formState: any, validatorOrOpts?: ValidatorFn|ValidatorFn[]|FormControlOptions|null, + asyncValidator?: AsyncValidatorFn|AsyncValidatorFn[]|null): FormControl; + + /** + * The presence of an explicit `prototype` property provides backwards-compatibility for apps that + * manually inspect the prototype chain. + */ + prototype: FormControl; +} + +export const FormControl: ɵFormControlCtor = + (class FormControl extends AbstractControl implements FormControlInterface { + /** @publicApi */ + public readonly defaultValue: any = null; + + /** @internal */ + _onChange: Function[] = []; + + /** @internal */ + _pendingValue: any; + + /** @internal */ + _pendingChange: boolean = false; + + constructor( + formState: any = null, + validatorOrOpts?: ValidatorFn|ValidatorFn[]|FormControlOptions|null, + asyncValidator?: AsyncValidatorFn|AsyncValidatorFn[]|null) { + super( + pickValidators(validatorOrOpts), pickAsyncValidators(asyncValidator, validatorOrOpts)); + this._applyFormState(formState); + this._setUpdateStrategy(validatorOrOpts); + this._initObservables(); + this.updateValueAndValidity({ + onlySelf: true, + // If `asyncValidator` is present, it will trigger control status change from `PENDING` to + // `VALID` or `INVALID`. + // The status should be broadcasted via the `statusChanges` observable, so we set + // `emitEvent` to `true` to allow that during the control creation process. + emitEvent: !!this.asyncValidator + }); + if (isOptionsObj(validatorOrOpts) && validatorOrOpts.initialValueIsDefault) { + if (this._isBoxedValue(formState)) { + (this.defaultValue as any) = formState.value; + } else { + (this.defaultValue as any) = formState; + } + } + } + + override setValue(value: any, options: { + onlySelf?: boolean, + emitEvent?: boolean, + emitModelToViewChange?: boolean, + emitViewToModelChange?: boolean + } = {}): void { + (this as {value: any}).value = this._pendingValue = value; + if (this._onChange.length && options.emitModelToViewChange !== false) { + this._onChange.forEach( + (changeFn) => changeFn(this.value, options.emitViewToModelChange !== false)); + } + this.updateValueAndValidity(options); + } + + override patchValue(value: any, options: { + onlySelf?: boolean, + emitEvent?: boolean, + emitModelToViewChange?: boolean, + emitViewToModelChange?: boolean + } = {}): void { + this.setValue(value, options); + } + + override reset( + formState: any = this.defaultValue, + options: {onlySelf?: boolean, emitEvent?: boolean} = {}): void { + this._applyFormState(formState); + this.markAsPristine(options); + this.markAsUntouched(options); + this.setValue(this.value, options); + this._pendingChange = false; + } + + /** @internal */ + override _updateValue(): void {} + + /** @internal */ + override _anyControls(condition: (c: AbstractControl) => boolean): boolean { + return false; + } + + /** @internal */ + override _allControlsDisabled(): boolean { + return this.disabled; + } + + registerOnChange(fn: Function): void { + this._onChange.push(fn); + } + + /** @internal */ + _unregisterOnChange(fn: (value?: any, emitModelEvent?: boolean) => void): void { + removeListItem(this._onChange, fn); + } + + registerOnDisabledChange(fn: (isDisabled: boolean) => void): void { + this._onDisabledChange.push(fn); + } + + /** @internal */ + _unregisterOnDisabledChange(fn: (isDisabled: boolean) => void): void { + removeListItem(this._onDisabledChange, fn); + } + + /** @internal */ + override _forEachChild(cb: (c: AbstractControl) => void): void {} + + /** @internal */ + override _syncPendingControls(): boolean { + if (this.updateOn === 'submit') { + if (this._pendingDirty) this.markAsDirty(); + if (this._pendingTouched) this.markAsTouched(); + if (this._pendingChange) { + this.setValue(this._pendingValue, {onlySelf: true, emitModelToViewChange: false}); + return true; + } + } + return false; + } + + private _applyFormState(formState: any) { + if (this._isBoxedValue(formState)) { + (this as {value: any}).value = this._pendingValue = formState.value; + formState.disabled ? this.disable({onlySelf: true, emitEvent: false}) : + this.enable({onlySelf: true, emitEvent: false}); + } else { + (this as {value: any}).value = this._pendingValue = formState; + } + } + }); + /** * Tracks the value and validity state of a group of `FormControl` instances. * diff --git a/packages/forms/test/form_control_spec.ts b/packages/forms/test/form_control_spec.ts index 16efb9a0d60..4e86a61578e 100644 --- a/packages/forms/test/form_control_spec.ts +++ b/packages/forms/test/form_control_spec.ts @@ -1526,5 +1526,56 @@ describe('FormControl', () => { }); }); }); + + describe('can be extended', () => { + // We don't technically support extending Forms classes, but people do it anyway. + // We need to make sure that there is some way to extend them to avoid causing breakage. + + class FCExt extends FormControl { + constructor(formState?: any|{ + value?: any; + disabled?: boolean; + }, ...args: any) { + super(formState, ...args); + } + } + + it('should perform basic FormControl operations', () => { + const nc = new FCExt({value: 'foo'}); + nc.setValue('bar'); + // There is no need to assert because, if this test compiles, then it is possible to correctly + // extend FormControl. + }); + }); + + describe('inspecting the prototype still provides FormControl type', () => { + // The constructor should be a function with a prototype property of T. + // (This is the assumption we don't want to break.) + type Constructor = Function&{prototype: T}; + + function isInstanceOf(value: Constructor, arg: unknown): arg is T { + // The implementation does not matter; we want to check whether this guard narrows the type. + return true; + } + + // This is a nullable FormControl, and we want isInstanceOf to narrow the type. + const fcOrNull: FormControl|null = new FormControl(42); + + it('and is appropriately narrowed', () => { + if (isInstanceOf(FormControl, fcOrNull)) { + // If the guard does not work, then this code will not compile due to null being in the + // type. + fcOrNull.setValue(7); + } + }); + }); + + describe('Function.name', () => { + it('returns FormControl', () => { + // This is always true in the trivial case, but can be broken by various methods of overriding + // FormControl's exported constructor. + expect(FormControl.name).toBe('FormControl'); + }); + }); }); })();