Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit e61da79

Browse filesBrowse files
authored
feat(forms): Support marking a field as dirty (#61183)
Co-authored-by: kirjs <kirjs@users.noreply.github.com>
1 parent aa31886 commit e61da79
Copy full SHA for e61da79

File tree

Expand file treeCollapse file tree

8 files changed

+116
-4
lines changed
Filter options
Expand file treeCollapse file tree

8 files changed

+116
-4
lines changed

‎packages/forms/experimental/src/api/data.ts

Copy file name to clipboardExpand all lines: packages/forms/experimental/src/api/data.ts
+8Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
18
import {computed, Resource, ResourceRef, ResourceStatus, signal, Signal} from '@angular/core';
29
import type {FieldContext, FieldPath, LogicFn} from './types';
310
import {assertPathIsCurrent} from '../schema';
411
import {FieldPathNode} from '../path_node';
512

613
export class DataKey<TValue> {
14+
/** @internal */
715
protected __phantom!: TValue;
816
}
917

‎packages/forms/experimental/src/api/types.ts

Copy file name to clipboardExpand all lines: packages/forms/experimental/src/api/types.ts
+8Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ export interface FieldState<T> {
8282
* A signal indicating whether the field has been touched by the user.
8383
*/
8484
readonly touched: Signal<boolean>;
85+
/**
86+
* A signal indicating whether field value has been changed by user.
87+
*/
88+
readonly dirty: Signal<boolean>;
8589
/**
8690
* A signal indicating whether the field is currently disabled.
8791
*/
@@ -124,6 +128,10 @@ export interface FieldState<T> {
124128
* Sets the touched status of the field to `true`.
125129
*/
126130
markAsTouched(): void;
131+
/**
132+
* Sets the dirty status of the field to `true`.
133+
*/
134+
markAsDirty(): void;
127135
/**
128136
* Resets the `submittedStatus` of the field and all descendant fields to unsubmitted.
129137
*/

‎packages/forms/experimental/src/controls/field.ts

Copy file name to clipboardExpand all lines: packages/forms/experimental/src/controls/field.ts
+1-1Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,12 @@ export class FieldDirective<T> {
5959
const cmp = illegallyGetComponentInstance(injector);
6060
if (this.el.nativeElement instanceof HTMLInputElement) {
6161
// Bind our field to an <input>
62-
6362
const i = this.el.nativeElement;
6463
const isCheckbox = i.type === 'checkbox';
6564

6665
i.addEventListener('input', () => {
6766
this.field().$state.value.set((!isCheckbox ? i.value : i.checked) as T);
67+
this.field().$state.markAsDirty();
6868
});
6969
i.addEventListener('blur', () => this.field().$state.markAsTouched());
7070

‎packages/forms/experimental/src/field_node.ts

Copy file name to clipboardExpand all lines: packages/forms/experimental/src/field_node.ts
+25-1Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,14 @@ type DestroyableInjector = Injector & {destroy(): void};
8282
*/
8383
export class FieldNode implements FieldState<unknown> {
8484
/**
85-
* Whether this specific field has been touched.
85+
* Field is considered touched when a user stops editing it for the first time (is our case on blur)
8686
*/
8787
private _touched = signal(false);
88+
/**
89+
* Field is considered dirty if a user changed the value of the field at least once.
90+
*/
91+
private _dirty = signal(false);
92+
8893
private _submittedStatus = signal<SubmittedStatus>('unsubmitted');
8994

9095
/**
@@ -230,6 +235,18 @@ export class FieldNode implements FieldState<unknown> {
230235
}
231236
}
232237

238+
/**
239+
* A field is dirty if the user changed the value of the field, or any of
240+
* its children through UI.
241+
*/
242+
readonly dirty: Signal<boolean> = computed(() => {
243+
return this.reduceChildren(
244+
this._dirty(),
245+
(child, value) => value || child.dirty(),
246+
shortCircuitTrue,
247+
);
248+
});
249+
233250
/**
234251
* Whether this field is considered touched.
235252
*
@@ -427,6 +444,13 @@ export class FieldNode implements FieldState<unknown> {
427444
this._touched.set(true);
428445
}
429446

447+
/**
448+
* Marks this specific field as dirty.
449+
*/
450+
markAsDirty(): void {
451+
this._dirty.set(true);
452+
}
453+
430454
/**
431455
* Retrieve a child `FieldNode` of this node by property key.
432456
*/

‎packages/forms/experimental/src/logic_node.ts

Copy file name to clipboardExpand all lines: packages/forms/experimental/src/logic_node.ts
+1-1Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {FieldNode} from './field_node';
2020
/**
2121
* Special key which is used to represent a dynamic index in a `FieldLogicNode` path.
2222
*/
23-
export const DYNAMIC = Symbol('DYNAMIC');
23+
export const DYNAMIC: unique symbol = Symbol('DYNAMIC');
2424

2525
export interface Predicate {
2626
readonly fn: LogicFn<any, boolean>;

‎packages/forms/experimental/src/path_node.ts

Copy file name to clipboardExpand all lines: packages/forms/experimental/src/path_node.ts
+7Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
18
import {FieldPath} from './api/types';
29
import {DYNAMIC, FieldLogicNode, Predicate} from './logic_node';
310

‎packages/forms/experimental/test/node.spec.ts renamed to ‎packages/forms/experimental/test/field_node.spec.ts

Copy file name to clipboardExpand all lines: packages/forms/experimental/test/field_node.spec.ts
+59-1Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
19
import {computed, Injector, signal} from '@angular/core';
210
import {TestBed} from '@angular/core/testing';
311
import {disabled, error, readonly, required, validate, validateTree} from '../src/api/logic';
@@ -7,7 +15,7 @@ import {FormTreeError, SchemaOrSchemaFn} from '../src/api/types';
715

816
const noopSchema: SchemaOrSchemaFn<unknown> = () => {};
917

10-
describe('Node', () => {
18+
describe('FieldNode', () => {
1119
it('is untouched initially', () => {
1220
const f = form(signal({a: 1, b: 2}), noopSchema, {injector: TestBed.inject(Injector)});
1321
expect(f.$state.touched()).toBe(false);
@@ -54,7 +62,57 @@ describe('Node', () => {
5462
expect(childA()).toBeDefined();
5563
});
5664

65+
describe('dirty', () => {
66+
it('is not dirty initially', () => {
67+
const f = form(signal({a: 1, b: 2}), noopSchema, {injector: TestBed.inject(Injector)});
68+
expect(f.$state.dirty()).toBe(false);
69+
expect(f.a.$state.dirty()).toBe(false);
70+
});
71+
72+
it('can be marked as dirty', () => {
73+
const f = form(signal({a: 1, b: 2}), noopSchema, {injector: TestBed.inject(Injector)});
74+
expect(f.$state.dirty()).toBe(false);
75+
76+
f.$state.markAsDirty();
77+
expect(f.$state.dirty()).toBe(true);
78+
});
79+
80+
it('propagates from the children', () => {
81+
const f = form(signal({a: 1, b: 2}), noopSchema, {injector: TestBed.inject(Injector)});
82+
expect(f.$state.dirty()).toBe(false);
83+
84+
f.a.$state.markAsDirty();
85+
expect(f.$state.dirty()).toBe(true);
86+
});
87+
88+
it('does not propagate down', () => {
89+
const f = form(signal({a: 1, b: 2}), noopSchema, {injector: TestBed.inject(Injector)});
90+
91+
expect(f.a.$state.dirty()).toBe(false);
92+
f.$state.markAsDirty();
93+
expect(f.a.$state.dirty()).toBe(false);
94+
});
95+
96+
it('does not consider children that get removed', () => {
97+
const value = signal<{a: number; b?: number}>({a: 1, b: 2});
98+
const f = form(value, noopSchema, {injector: TestBed.inject(Injector)});
99+
expect(f.$state.dirty()).toBe(false);
100+
101+
f.b!.$state.markAsDirty();
102+
expect(f.$state.dirty()).toBe(true);
103+
104+
value.set({a: 2});
105+
expect(f.$state.dirty()).toBe(false);
106+
expect(f.b).toBeUndefined();
107+
});
108+
});
109+
57110
describe('touched', () => {
111+
it('is untouched initially', () => {
112+
const f = form(signal({a: 1, b: 2}), noopSchema, {injector: TestBed.inject(Injector)});
113+
expect(f.$state.touched()).toBe(false);
114+
});
115+
58116
it('can be marked as touched', () => {
59117
const f = form(signal({a: 1, b: 2}), noopSchema, {injector: TestBed.inject(Injector)});
60118
expect(f.$state.touched()).toBe(false);

‎packages/forms/experimental/test/resource.spec.ts

Copy file name to clipboardExpand all lines: packages/forms/experimental/test/resource.spec.ts
+7Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
18
import {ApplicationRef, Injector, resource, signal} from '@angular/core';
29
import {TestBed} from '@angular/core/testing';
310
import {validateAsync} from '../src/api/async';

0 commit comments

Comments
0 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.