Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion goldens/public-api/forms/signals/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ export interface FormFieldBindingOptions {

// @public
export interface FormOptions<TModel> {
debugName?: string;
injector?: Injector;
name?: string;
submission?: FormSubmitOptions<TModel, unknown>;
Expand Down Expand Up @@ -717,7 +718,7 @@ export function submit<TModel>(form: FieldTree<TModel>, options?: NoInfer<FormSu
export function submit<TModel>(form: FieldTree<TModel>, action: NoInfer<FormSubmitOptions<unknown, TModel>['action']>): Promise<boolean>;

// @public
export function transformedValue<TValue, TRaw>(value: ModelSignal<TValue>, options: TransformedValueOptions<TValue, TRaw>): TransformedValueSignal<TRaw>;
export function transformedValue<TValue, TRaw>(value: ModelSignal<TValue>, options: TransformedValueOptions<TValue, TRaw>, debugFormFieldName?: string): TransformedValueSignal<TRaw>;

// @public
export interface TransformedValueOptions<TValue, TRaw> {
Expand Down
8 changes: 5 additions & 3 deletions packages/forms/signals/compat/src/compat_field_adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {CompatFieldNode} from './compat_field_node';
import {CompatNodeState} from './compat_node_state';
import {CompatChildFieldNodeOptions, CompatStructure} from './compat_structure';
import {CompatValidationState} from './compat_validation_state';
import {formDebugObj} from '../../src/util/debug';

/**
* This is a tree-shakable Field adapter that can create a compat node
Expand Down Expand Up @@ -120,9 +121,10 @@ export function createCompatNode(options: FieldNodeOptions) {
const control = (
options.kind === 'root'
? options.value
: computed(() => {
return options.parent.value()[options.initialKeyInParent];
})
: computed(
() => options.parent.value()[options.initialKeyInParent],
ngDevMode ? formDebugObj(options.debugName, 'control') : undefined,
)
) as Signal<AbstractControl>;

return new CompatFieldNode({
Expand Down
9 changes: 8 additions & 1 deletion packages/forms/signals/compat/src/compat_field_node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {map, takeUntil} from 'rxjs/operators';
import {FieldNode} from '../../src/field/node';
import {getInjectorFromOptions} from '../../src/field/util';
import type {CompatFieldNodeOptions} from './compat_structure';
import {formDebugObj} from '../../src/util/debug';

/**
* Field node with additional control property.
Expand Down Expand Up @@ -72,11 +73,15 @@ export function extractControlPropToSignal<T, R = T>(
return runInInjectionContext(injector, () => makeSignal(control, createDestroySubject()));
});
},
...(ngDevMode ? formDebugObj(options.debugName, 'signalOfControlSignal') : undefined),
});

// We have to have computed, because we need to react to both:
// linked signal changes as well as the inner signal changes.
return computed(() => signalOfControlSignal()());
return computed(
() => signalOfControlSignal()(),
ngDevMode ? formDebugObj(options.debugName, 'controlPropToSignal') : undefined,
);
}

/**
Expand All @@ -99,6 +104,7 @@ export const getControlStatusSignal = <T>(
),
{
initialValue: getValue(c),
...(ngDevMode ? formDebugObj(options.debugName, 'controlStatus') : undefined),
},
),
);
Expand Down Expand Up @@ -126,6 +132,7 @@ export const getControlEventsSignal = <T>(
),
{
initialValue: getValue(c),
...(ngDevMode ? formDebugObj(options.debugName, 'controlEvents') : undefined),
},
),
);
Expand Down
10 changes: 7 additions & 3 deletions packages/forms/signals/compat/src/compat_node_state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {AbstractControl} from '@angular/forms';
import {FieldNodeState} from '../../src/field/state';
import {CompatFieldNode, getControlEventsSignal, getControlStatusSignal} from './compat_field_node';
import {CompatFieldNodeOptions} from './compat_structure';
import {formDebugObj} from '../../src/util/debug';

/**
* A FieldNodeState class wrapping a FormControl and proxying it's state.
Expand All @@ -31,9 +32,12 @@ export class CompatNodeState extends FieldNodeState {
this.dirty = getControlEventsSignal(options, (c) => c.dirty);
const controlDisabled = getControlStatusSignal(options, (c) => c.disabled);

this.disabled = computed(() => {
return controlDisabled() || this.disabledReasons().length > 0;
});
this.disabled = computed(
() => {
return controlDisabled() || this.disabledReasons().length > 0;
},
ngDevMode ? formDebugObj(options.debugName, 'disabled') : undefined,
);
}

override markAsDirty() {
Expand Down
19 changes: 15 additions & 4 deletions packages/forms/signals/compat/src/compat_structure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {FormFieldManager} from '../../src/field/manager';
import {FieldNode, ParentFieldNode} from '../../src/field/node';
import {
ChildFieldNodeOptions,
ChildrenData,
FieldNodeOptions,
FieldNodeStructure,
RootFieldNodeOptions,
Expand All @@ -27,6 +28,7 @@ import {toSignal} from '@angular/core/rxjs-interop';
import {AbstractControl} from '@angular/forms';
import {map, takeUntil} from 'rxjs/operators';
import {extractControlPropToSignal} from './compat_field_node';
import {formDebugObj} from '../../src/util/debug';

/**
* Child Field Node options also exposing control property.
Expand Down Expand Up @@ -86,6 +88,7 @@ function getControlValueSignal<T>(options: CompatFieldNodeOptions) {
),
{
initialValue: control.getRawValue(),
...(ngDevMode ? formDebugObj(options.debugName, 'value') : undefined),
},
);
}) as WritableSignal<T>;
Expand All @@ -111,8 +114,8 @@ export class CompatStructure extends FieldNodeStructure {
override keyInParent: Signal<string>;
override root: FieldNode;
override pathKeys: Signal<readonly string[]>;
override readonly children = signal([]);
override readonly childrenMap = computed(() => undefined);
override readonly children: WritableSignal<FieldNode[]>;
override readonly childrenMap: Signal<ChildrenData | undefined>;
override readonly parent: ParentFieldNode | undefined;
override readonly fieldManager: FormFieldManager;
override readonly isOrphaned: Signal<boolean>;
Expand Down Expand Up @@ -140,8 +143,16 @@ export class CompatStructure extends FieldNodeStructure {
this.keyInParent = signals.keyInParent;
this.isOrphaned = signals.isOrphaned;

this.pathKeys = computed(() =>
this.parent ? [...this.parent.structure.pathKeys(), this.keyInParent()] : [],
this.pathKeys = computed(
() => (this.parent ? [...this.parent.structure.pathKeys(), this.keyInParent()] : []),
ngDevMode ? formDebugObj(this.node.debugName, 'pathKeys') : undefined,
);

this.children = signal([], ngDevMode ? formDebugObj(node.debugName, 'children') : undefined);

this.childrenMap = computed(
() => undefined,
ngDevMode ? formDebugObj(node.debugName, 'childrenMap') : undefined,
);
}

Expand Down
63 changes: 44 additions & 19 deletions packages/forms/signals/compat/src/compat_validation_state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from '../../src/compat/validation_errors';
import {CompatFieldNode, getControlStatusSignal} from './compat_field_node';
import {CompatFieldNodeOptions} from './compat_structure';
import {formDebugObj} from '../../src/util/debug';

// Readonly signal containing an empty array, used for optimization.
const EMPTY_ARRAY_SIGNAL = computed(() => []);
Expand All @@ -33,7 +34,25 @@ export class CompatValidationState implements ValidationState {
readonly invalid: Signal<boolean>;
readonly valid: Signal<boolean>;

readonly parseErrors: Signal<ValidationError.WithFormField[]> = computed(() => []);
readonly parseErrors: Signal<ValidationError.WithFormField[]>;

// Compat fields can't have validation rules applied to them; however, there are other
// features that depend on this property, such as `markAsTouched()`.
readonly shouldSkipValidation: Signal<boolean>;

/**
* Computes status based on whether the field is valid/invalid/pending.
*/
readonly status: Signal<'valid' | 'invalid' | 'unknown'>;

readonly rawSyncTreeErrors: Signal<ValidationError.WithFieldTree[]>;
readonly syncErrors: Signal<ValidationError.WithFieldTree[]>;
readonly rawAsyncErrors: Signal<(ValidationError.WithFieldTree | 'pending')[]>;

asyncErrors: Signal<(ValidationError.WithFieldTree | 'pending')[]>;
errorSummary: Signal<ValidationError.WithFieldTree[]>;

private readonly emptyArraySignal: Signal<never[]>;

constructor(
private readonly node: CompatFieldNode,
Expand All @@ -50,26 +69,32 @@ export class CompatValidationState implements ValidationState {
this.invalid = getControlStatusSignal(options, (c) => {
return c.invalid;
});
}

asyncErrors: Signal<(ValidationError.WithFieldTree | 'pending')[]> = EMPTY_ARRAY_SIGNAL;
errorSummary: Signal<ValidationError.WithFieldTree[]> = EMPTY_ARRAY_SIGNAL;
this.parseErrors = computed(
() => [],
ngDevMode ? formDebugObj(node.debugName, 'parseErrors') : undefined,
);

// Those are irrelevant for compat mode, as it has no children
rawSyncTreeErrors = EMPTY_ARRAY_SIGNAL;
syncErrors = EMPTY_ARRAY_SIGNAL;
rawAsyncErrors = EMPTY_ARRAY_SIGNAL;
this.shouldSkipValidation = computed(
() => this.node.hidden() || this.node.disabled() || this.node.readonly(),
ngDevMode ? formDebugObj(node.debugName, 'shouldSkipValidation') : undefined,
);

// Compat fields can't have validation rules applied to them; however, there are other
// features that depend on this property, such as `markAsTouched()`.
readonly shouldSkipValidation = computed(
() => this.node.hidden() || this.node.disabled() || this.node.readonly(),
);
this.status = computed(
() => calculateValidationSelfStatus(this),
ngDevMode ? formDebugObj(node.debugName, 'status') : undefined,
);

/**
* Computes status based on whether the field is valid/invalid/pending.
*/
readonly status: Signal<'valid' | 'invalid' | 'unknown'> = computed(() => {
return calculateValidationSelfStatus(this);
});
this.emptyArraySignal = ngDevMode
? computed(() => [], formDebugObj(node.debugName, 'EMPTY_ARRAY_SIGNAL'))
: EMPTY_ARRAY_SIGNAL;

this.asyncErrors = this.emptyArraySignal;
this.errorSummary = this.emptyArraySignal;

// Those are irrelevant for compat mode, as it has no children
this.rawSyncTreeErrors = this.emptyArraySignal;
this.syncErrors = this.emptyArraySignal;
this.rawAsyncErrors = this.emptyArraySignal;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {RuntimeErrorCode} from '../../../src/errors';
import {FieldNode} from '../../../src/field/node';
import {normalizeFormArgs} from '../../../src/util/normalize_form_args';
import {compatForm} from '../api/compat_form';
import {formDebugObj} from '../../../src/util/debug';

/** Options used to update the control value. */
export type ValueUpdateOptions = {
Expand Down Expand Up @@ -96,7 +97,11 @@ export class SignalFormControl<T> extends AbstractControl {
constructor(value: T, schemaOrOptions?: SchemaFn<T> | FormOptions<T>, options?: FormOptions<T>) {
super(null, null);

const [model, schema, opts] = normalizeFormArgs<T>([signal(value), schemaOrOptions, options]);
const [model, schema, opts] = normalizeFormArgs<T>([
signal(value, ngDevMode ? formDebugObj(options?.debugName, 'value') : undefined),
schemaOrOptions,
options,
]);
this.sourceValue = model;
this.initialValue = value;
const injector = opts?.injector ?? inject(Injector);
Expand All @@ -122,7 +127,10 @@ export class SignalFormControl<T> extends AbstractControl {
this.emitControlEvent(new ValueChangeEvent(value, this));
});
},
{injector},
{
injector,
...(ngDevMode ? formDebugObj(options?.debugName, 'valueChanges') : undefined),
},
);

// Status changes effect
Expand All @@ -134,7 +142,10 @@ export class SignalFormControl<T> extends AbstractControl {
});
this.emitControlEvent(new StatusChangeEvent(status, this));
},
{injector},
{
injector,
...(ngDevMode ? formDebugObj(options?.debugName, 'statusChanges') : undefined),
},
);

// Disabled changes effect
Expand All @@ -147,7 +158,10 @@ export class SignalFormControl<T> extends AbstractControl {
}
});
},
{injector},
{
injector,
...(ngDevMode ? formDebugObj(options?.debugName, 'disabledChanges') : undefined),
},
);

// Touched changes effect
Expand All @@ -165,7 +179,10 @@ export class SignalFormControl<T> extends AbstractControl {
parent.markAsTouched();
}
},
{injector},
{
injector,
...(ngDevMode ? formDebugObj(options?.debugName, 'touchedChanges') : undefined),
},
);

// Dirty changes effect
Expand All @@ -183,7 +200,10 @@ export class SignalFormControl<T> extends AbstractControl {
parent.markAsPristine();
}
},
{injector},
{
injector,
...(ngDevMode ? formDebugObj(options?.debugName, 'dirtyChanges') : undefined),
},
);
}

Expand Down
8 changes: 5 additions & 3 deletions packages/forms/signals/src/api/structure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
WritableSignal,
} from '@angular/core';
import {RuntimeErrorCode} from '../errors';
import {BasicFieldAdapter, FieldAdapter} from '../field/field_adapter';
import {BasicFieldAdapter} from '../field/field_adapter';
import {FormFieldManager} from '../field/manager';
import {FieldNode} from '../field/node';
import {addDefaultField} from '../field/validation';
Expand Down Expand Up @@ -55,6 +55,8 @@ export interface FormOptions<TModel> {
name?: string;
/** Options that define how to handle form submission. */
submission?: FormSubmitOptions<TModel, unknown>;
/** A debug name for the form. Used in Angular DevTools to identify the node. */
debugName?: string;
}

/**
Expand Down Expand Up @@ -194,8 +196,8 @@ export function form<TModel>(...args: any[]): FieldTree<TModel> {
options?.submission as FormSubmitOptions<unknown, unknown> | undefined,
);
const adapter = options?.adapter ?? new BasicFieldAdapter();
const fieldRoot = FieldNode.newRoot(fieldManager, model, pathNode, adapter);
fieldManager.createFieldManagementEffect(fieldRoot.structure);
const fieldRoot = FieldNode.newRoot(fieldManager, model, pathNode, adapter, options?.debugName);
fieldManager.createFieldManagementEffect(fieldRoot.structure, options?.debugName);

return fieldRoot.fieldTree as FieldTree<TModel>;
}
Expand Down
9 changes: 7 additions & 2 deletions packages/forms/signals/src/api/transformed_value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {createParser} from '../util/parser';
import type {ValidationError} from './rules';
import type {OneOrMany} from './types';
import {ɵFORM_CONTROL_INTEGRATION as FORM_CONTROL_INTEGRATION} from '@angular/forms';
import {formFieldDebugObj} from '../util/debug';

/**
* Result of parsing a raw value into a model value.
Expand Down Expand Up @@ -114,12 +115,16 @@ export interface TransformedValueSignal<TRaw> extends WritableSignal<TRaw> {
export function transformedValue<TValue, TRaw>(
value: ModelSignal<TValue>,
options: TransformedValueOptions<TValue, TRaw>,
debugFormFieldName?: string,
): TransformedValueSignal<TRaw> {
const {parse, format} = options;
const parser = createParser(value, value.set, parse);
const parser = createParser(value, value.set, parse, debugFormFieldName);

// Create the result signal with overridden set/update and a `parseErrors` property.
const rawValue = linkedSignal(() => format(value()));
const rawValue = linkedSignal(
() => format(value()),
ngDevMode ? formFieldDebugObj(debugFormFieldName, 'rawValue') : undefined,
);
const result = rawValue as WritableSignal<TRaw> & {
parseErrors: Signal<readonly ValidationError.WithoutFieldTree[]>;
};
Expand Down
Loading
Loading