Skip to content

Commit db95a5c

Browse files
committed
refactor(forms): loosen the error and disabled type on FormUiControl (#63455)
This allows passing errors and disabled reasons that did not originate from `@angular/forms/signals` in case the the control is being used separately from the forms system PR Close #63455
1 parent 10ef96a commit db95a5c

File tree

4 files changed

+106
-33
lines changed

4 files changed

+106
-33
lines changed

goldens/public-api/forms/signals/index.api.md

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -88,14 +88,18 @@ export class Control<T> {
8888
export function createProperty<TValue>(): Property<TValue>;
8989

9090
// @public
91-
export function customError<E extends Omit<Partial<ValidationError>, typeof BRAND>>(obj: WithField<E>): CustomValidationError;
91+
export function customError<E extends Partial<ValidationError>>(obj: WithField<E>): CustomValidationError;
9292

9393
// @public
94-
export function customError<E extends Omit<Partial<ValidationError>, typeof BRAND>>(obj?: E): WithoutField<CustomValidationError>;
94+
export function customError<E extends Partial<ValidationError>>(obj?: E): WithoutField<CustomValidationError>;
9595

9696
// @public
97-
export class CustomValidationError extends ValidationError {
97+
export class CustomValidationError implements ValidationError {
98+
constructor(options?: ValidationErrorOptions);
9899
[key: PropertyKey]: unknown;
100+
readonly field: Field<unknown>;
101+
readonly kind: string;
102+
readonly message?: string;
99103
}
100104

101105
// @public
@@ -194,8 +198,8 @@ export interface FormOptions {
194198
export interface FormUiControl {
195199
readonly dirty?: InputSignal<boolean>;
196200
readonly disabled?: InputSignal<boolean>;
197-
readonly disabledReasons?: InputSignal<readonly DisabledReason[]>;
198-
readonly errors?: InputSignal<readonly ValidationError[]>;
201+
readonly disabledReasons?: InputSignal<readonly WithOptionalField<DisabledReason>[]>;
202+
readonly errors?: InputSignal<readonly WithOptionalField<ValidationError>[]>;
199203
readonly hidden?: InputSignal<boolean>;
200204
readonly invalid?: InputSignal<boolean>;
201205
readonly max?: InputSignal<number | undefined>;
@@ -542,9 +546,7 @@ export function validateHttp<TValue, TResult = unknown, TPathKind extends PathKi
542546
export function validateTree<TValue, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, logic: NoInfer<TreeValidator<TValue, TPathKind>>): void;
543547

544548
// @public
545-
export abstract class ValidationError {
546-
[BRAND]: undefined;
547-
constructor(options?: ValidationErrorOptions);
549+
export interface ValidationError {
548550
readonly field: Field<unknown>;
549551
readonly kind: string;
550552
readonly message?: string;
@@ -565,7 +567,7 @@ export type WithField<T> = T & {
565567
};
566568

567569
// @public
568-
export type WithOptionalField<T> = T & {
570+
export type WithOptionalField<T> = Omit<T, 'field'> & {
569571
field?: Field<unknown>;
570572
};
571573

packages/forms/signals/src/api/control.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import {InputSignal, ModelSignal, OutputRef} from '@angular/core';
1010
import type {DisabledReason} from './types';
11-
import {ValidationError} from './validation_errors';
11+
import {ValidationError, type WithOptionalField} from './validation_errors';
1212

1313
/** The base set of properties shared by all form control contracts. */
1414
export interface FormUiControl {
@@ -20,7 +20,7 @@ export interface FormUiControl {
2020
* An input to receive the errors for the field. If implemented, the `Control` directive will
2121
* automatically bind errors from the bound field to this input.
2222
*/
23-
readonly errors?: InputSignal<readonly ValidationError[]>;
23+
readonly errors?: InputSignal<readonly WithOptionalField<ValidationError>[]>;
2424
/**
2525
* An input to receive the disabled status for the field. If implemented, the `Control` directive
2626
* will automatically bind the disabled status from the bound field to this input.
@@ -30,7 +30,7 @@ export interface FormUiControl {
3030
* An input to receive the reasons for the disablement of the field. If implemented, the `Control`
3131
* directive will automatically bind the disabled reason from the bound field to this input.
3232
*/
33-
readonly disabledReasons?: InputSignal<readonly DisabledReason[]>;
33+
readonly disabledReasons?: InputSignal<readonly WithOptionalField<DisabledReason>[]>;
3434
/**
3535
* An input to receive the readonly status for the field. If implemented, the `Control` directive
3636
* will automatically bind the readonly status from the bound field to this input.

packages/forms/signals/src/api/validation_errors.ts

Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,6 @@
99
import type {StandardSchemaV1} from '@standard-schema/spec';
1010
import {Field} from './types';
1111

12-
/** Internal symbol used for class branding. */
13-
const BRAND = Symbol();
14-
1512
/**
1613
* Options used to create a `ValidationError`.
1714
*/
@@ -30,7 +27,7 @@ export type WithField<T> = T & {field: Field<unknown>};
3027
* A type that allows the given type `T` to optionally have a `field` property.
3128
* @template T The type to optionally add a `field` to.
3229
*/
33-
export type WithOptionalField<T> = T & {field?: Field<unknown>};
30+
export type WithOptionalField<T> = Omit<T, 'field'> & {field?: Field<unknown>};
3431

3532
/**
3633
* A type that ensures the given type `T` does not have a `field` property.
@@ -226,17 +223,17 @@ export function standardSchemaError(
226223
* Create a custom error associated with the target field
227224
* @param obj The object to create an error from
228225
*/
229-
export function customError<E extends Omit<Partial<ValidationError>, typeof BRAND>>(
226+
export function customError<E extends Partial<ValidationError>>(
230227
obj: WithField<E>,
231228
): CustomValidationError;
232229
/**
233230
* Create a custom error
234231
* @param obj The object to create an error from
235232
*/
236-
export function customError<E extends Omit<Partial<ValidationError>, typeof BRAND>>(
233+
export function customError<E extends Partial<ValidationError>>(
237234
obj?: E,
238235
): WithoutField<CustomValidationError>;
239-
export function customError<E extends Omit<Partial<ValidationError>, typeof BRAND>>(
236+
export function customError<E extends Partial<ValidationError>>(
240237
obj?: E,
241238
): WithOptionalField<CustomValidationError> {
242239
return new CustomValidationError(obj);
@@ -247,9 +244,26 @@ export function customError<E extends Omit<Partial<ValidationError>, typeof BRAN
247244
*
248245
* Use the creation functions to create an instance (e.g. `requiredError`, `minError`, etc.).
249246
*/
250-
export abstract class ValidationError {
247+
export interface ValidationError {
248+
/** Identifies the kind of error. */
249+
readonly kind: string;
250+
/** The field associated with this error. */
251+
readonly field: Field<unknown>;
252+
/** Human readable error message. */
253+
readonly message?: string;
254+
}
255+
256+
/**
257+
* A custom error that may contain additional properties
258+
*/
259+
export class CustomValidationError implements ValidationError {
251260
/** Brand the class to avoid Typescript structural matching */
252-
[BRAND] = undefined;
261+
private __brand = undefined;
262+
263+
/**
264+
* Allow the user to attach arbitrary other properties.
265+
*/
266+
[key: PropertyKey]: unknown;
253267

254268
/** Identifies the kind of error. */
255269
readonly kind: string = '';
@@ -267,21 +281,29 @@ export abstract class ValidationError {
267281
}
268282
}
269283

270-
/**
271-
* A custom error that may contain additional properties
272-
*/
273-
export class CustomValidationError extends ValidationError {
274-
/**
275-
* Allow the user to attach arbitrary other properties.
276-
*/
277-
[key: PropertyKey]: unknown;
278-
}
279-
280284
/**
281285
* Internal version of `NgValidationError`, we create this separately so we can change its type on
282286
* the exported version to a type union of the possible sub-classes.
283287
*/
284-
abstract class _NgValidationError extends ValidationError {}
288+
abstract class _NgValidationError implements ValidationError {
289+
/** Brand the class to avoid Typescript structural matching */
290+
private __brand = undefined;
291+
292+
/** Identifies the kind of error. */
293+
readonly kind: string = '';
294+
295+
/** The field associated with this error. */
296+
readonly field!: Field<unknown>;
297+
298+
/** Human readable error message. */
299+
readonly message?: string;
300+
301+
constructor(options?: ValidationErrorOptions) {
302+
if (options) {
303+
Object.assign(this, options);
304+
}
305+
}
306+
}
285307

286308
/**
287309
* An error used to indicate that a required field is empty.

packages/forms/signals/test/web/control_directive.spec.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ import {
3434
type Field,
3535
type FormCheckboxControl,
3636
type FormValueControl,
37+
type ValidationError,
38+
type WithOptionalField,
3739
} from '../../public_api';
3840

3941
@Component({
@@ -459,7 +461,7 @@ describe('control directive', () => {
459461
})
460462
class CustomInput implements FormValueControl<string> {
461463
value = model('');
462-
disabledReasons = input<readonly DisabledReason[]>([]);
464+
disabledReasons = input<readonly WithOptionalField<DisabledReason>[]>([]);
463465
}
464466

465467
@Component({
@@ -523,6 +525,53 @@ describe('control directive', () => {
523525
act(() => field().reset());
524526
expect(myInput.touched()).toBe(false);
525527
});
528+
529+
it('should allow binding error and disabled messages through control or manually', () => {
530+
@Component({
531+
selector: 'my-input',
532+
template: `
533+
<input #i [value]="value()" (input)="value.set(i.value)" />
534+
@for (reason of disabledReasons(); track $index) {
535+
<p class="disabled-reason">{{reason.message}}</p>
536+
}
537+
@for (error of errors(); track $index) {
538+
<p class="error">{{error.message}}</p>
539+
}
540+
`,
541+
})
542+
class CustomInput implements FormValueControl<string> {
543+
value = model('');
544+
disabledReasons = input<readonly WithOptionalField<DisabledReason>[]>([]);
545+
errors = input<readonly WithOptionalField<ValidationError>[]>([]);
546+
}
547+
548+
@Component({
549+
imports: [Control, CustomInput],
550+
template: `
551+
<my-input [(value)]="model" [disabledReasons]="disabledReasons" [errors]="errors" />
552+
<my-input [control]="f" />
553+
`,
554+
})
555+
class TestCmp {
556+
model = signal('');
557+
f = form(this.model, (p) => {
558+
required(p, {message: 'schema error'});
559+
disabled(p, ({value}) => (value() === 'disabled' ? 'schema disabled' : false));
560+
});
561+
disabledReasons = [{message: 'manual disabled'}];
562+
errors = [{kind: 'error', message: 'manual error'}];
563+
}
564+
565+
const fix = act(() => TestBed.createComponent(TestCmp));
566+
expect([...fix.nativeElement.querySelectorAll('.error')].map((e) => e.textContent)).toEqual([
567+
'manual error',
568+
'schema error',
569+
]);
570+
act(() => fix.componentInstance.model.set('disabled'));
571+
expect(
572+
[...fix.nativeElement.querySelectorAll('.disabled-reason')].map((e) => e.textContent),
573+
).toEqual(['manual disabled', 'schema disabled']);
574+
});
526575
});
527576

528577
function setupRadioGroup() {

0 commit comments

Comments
 (0)