mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
The `fieldTree` property of `FieldState` returns its associated `FieldTree`. Note that the round trip from `FieldTree` to `FieldState` and back will lose type information. This is because `FieldState` intentionally does not know whether it came from a pure Signal Forms field tree, or a Reactive Forms compatible field tree: ```ts // Pure Signal Forms: const x: FieldTree<string>; x(); // FieldState<string>; x().fieldTree; // FieldTree<unknown> // Reactive Forms compatibility: const y: FieldTree<FormControl<string>>; y(); // FieldState<string>; y().fieldTree; // FieldTree<unknown>; ```
1451 lines
39 KiB
TypeScript
1451 lines
39 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright Google LLC All Rights Reserved.
|
|
*
|
|
* Use of this source code is governed by an MIT-style license that can be
|
|
* found in the LICENSE file at https://angular.dev/license
|
|
*/
|
|
|
|
import {Component, computed, effect, Injector, signal, WritableSignal} from '@angular/core';
|
|
import {TestBed} from '@angular/core/testing';
|
|
import {
|
|
apply,
|
|
applyEach,
|
|
debounce,
|
|
disabled,
|
|
form,
|
|
FormField,
|
|
hidden,
|
|
readonly,
|
|
required,
|
|
requiredError,
|
|
Schema,
|
|
schema,
|
|
SchemaOrSchemaFn,
|
|
SchemaPath,
|
|
SchemaPathTree,
|
|
validate,
|
|
validateTree,
|
|
ValidationError,
|
|
} from '../../public_api';
|
|
import {SchemaImpl} from '../../src/schema/schema';
|
|
|
|
describe('FieldNode', () => {
|
|
it('can get a child of a key that exists', () => {
|
|
const f = form(
|
|
signal({
|
|
a: 1,
|
|
b: 2,
|
|
}),
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
expect(f.a).toBeDefined();
|
|
expect(f.a().value()).toBe(1);
|
|
});
|
|
|
|
describe('instances', () => {
|
|
it('should get the same instance when asking for a child multiple times', () => {
|
|
const f = form(
|
|
signal({
|
|
a: 1,
|
|
b: 2,
|
|
}),
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
const child = f.a;
|
|
expect(f.a).toBe(child);
|
|
});
|
|
|
|
it('should get the same instance when asking for a child multiple times (after update)', () => {
|
|
const value = signal<{a: number; b?: number}>({a: 1, b: 2});
|
|
const f = form(value, {injector: TestBed.inject(Injector)});
|
|
const child = f.a;
|
|
value.set({a: 3});
|
|
expect(f.a).toBe(child);
|
|
});
|
|
});
|
|
|
|
it('cannot get a child of a key that does not exist', () => {
|
|
const f = form(
|
|
signal<{a: number; b: number; c?: number}>({
|
|
a: 1,
|
|
b: 2,
|
|
}),
|
|
{
|
|
injector: TestBed.inject(Injector),
|
|
},
|
|
);
|
|
expect(f.c).toBeUndefined();
|
|
});
|
|
|
|
it('can get a child inside of a computed', () => {
|
|
const f = form(
|
|
signal({
|
|
a: 1,
|
|
b: 2,
|
|
}),
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
const childA = computed(() => f.a);
|
|
expect(childA()).toBeDefined();
|
|
});
|
|
|
|
describe('resetting', () => {
|
|
it('can be reset with a value', () => {
|
|
const model = signal({a: 1, b: 2});
|
|
const f = form(model, {injector: TestBed.inject(Injector)});
|
|
f.a().markAsDirty();
|
|
f.a().markAsTouched();
|
|
|
|
f().reset({a: 5, b: 8});
|
|
expect(f.a().value()).toBe(5);
|
|
expect(f.a().dirty()).toBe(false);
|
|
expect(f.a().touched()).toBe(false);
|
|
});
|
|
|
|
it('can be reset without a value', () => {
|
|
const model = signal({a: 1, b: 2});
|
|
const f = form(model, {injector: TestBed.inject(Injector)});
|
|
|
|
f().reset();
|
|
expect(f.a().value()).toBe(1);
|
|
});
|
|
|
|
it('can reset with empty string', () => {
|
|
const model = signal('hello');
|
|
const f = form(model, {injector: TestBed.inject(Injector)});
|
|
f().reset('');
|
|
expect(f().value()).toBe('');
|
|
});
|
|
|
|
it('can reset with false', () => {
|
|
const model = signal(true);
|
|
const f = form(model, {injector: TestBed.inject(Injector)});
|
|
f().reset(false);
|
|
expect(f().value()).toBe(false);
|
|
});
|
|
|
|
it('can reset with null', () => {
|
|
const model: WritableSignal<string | null> = signal('hello');
|
|
const f = form(model, {injector: TestBed.inject(Injector)});
|
|
f().reset(null);
|
|
expect(f().value()).toBeNull();
|
|
});
|
|
|
|
it('can reset with 0', () => {
|
|
const model = signal(5);
|
|
const f = form(model, {injector: TestBed.inject(Injector)});
|
|
f().reset(0);
|
|
expect(f().value()).toBe(0);
|
|
});
|
|
|
|
it('can reset with NaN', () => {
|
|
const model = signal(5);
|
|
const f = form(model, {injector: TestBed.inject(Injector)});
|
|
f().reset(NaN);
|
|
expect(f().value()).toBeNaN();
|
|
});
|
|
});
|
|
|
|
describe('fieldTree', () => {
|
|
it('should return the associated field tree from state produced by the root field tree', () => {
|
|
const f = form(signal(''), {injector: TestBed.inject(Injector)});
|
|
|
|
expect(f().fieldTree).toBe(f);
|
|
});
|
|
|
|
it('should return the associated field tree from state produced by a child field tree', () => {
|
|
const f = form(signal({a: 1, b: 2}), {injector: TestBed.inject(Injector)});
|
|
|
|
expect(f.a().fieldTree).toBe(f.a);
|
|
});
|
|
|
|
it('should return the associated field tree from state produced by an array item field tree', () => {
|
|
const f = form(signal([1, 2, 3]), {injector: TestBed.inject(Injector)});
|
|
|
|
expect(f[0]().fieldTree).toBe(f[0]);
|
|
});
|
|
});
|
|
|
|
describe('dirty', () => {
|
|
it('is not dirty initially', () => {
|
|
const f = form(
|
|
signal({
|
|
a: 1,
|
|
b: 2,
|
|
}),
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
expect(f().dirty()).toBe(false);
|
|
expect(f.a().dirty()).toBe(false);
|
|
});
|
|
|
|
it('can be marked as dirty', () => {
|
|
const f = form(
|
|
signal({
|
|
a: 1,
|
|
b: 2,
|
|
}),
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
expect(f().dirty()).toBe(false);
|
|
|
|
f().markAsDirty();
|
|
expect(f().dirty()).toBe(true);
|
|
});
|
|
|
|
it('can be reset', () => {
|
|
const model = signal({a: 1, b: 2});
|
|
const f = form(model, {injector: TestBed.inject(Injector)});
|
|
f().markAsDirty();
|
|
expect(f().dirty()).toBe(true);
|
|
|
|
f().reset();
|
|
expect(f().dirty()).toBe(false);
|
|
});
|
|
|
|
it('propagates from the children', () => {
|
|
const f = form(
|
|
signal({
|
|
a: 1,
|
|
b: 2,
|
|
}),
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
expect(f().dirty()).toBe(false);
|
|
|
|
f.a().markAsDirty();
|
|
expect(f().dirty()).toBe(true);
|
|
});
|
|
|
|
it('does not propagate down', () => {
|
|
const f = form(
|
|
signal({
|
|
a: 1,
|
|
b: 2,
|
|
}),
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(f.a().dirty()).toBe(false);
|
|
f().markAsDirty();
|
|
expect(f.a().dirty()).toBe(false);
|
|
});
|
|
|
|
it('does not consider children that get removed', () => {
|
|
const value = signal<{a: number; b?: number}>({a: 1, b: 2});
|
|
const f = form(value, {injector: TestBed.inject(Injector)});
|
|
expect(f().dirty()).toBe(false);
|
|
|
|
f.b!().markAsDirty();
|
|
expect(f().dirty()).toBe(true);
|
|
|
|
value.set({a: 2});
|
|
expect(f().dirty()).toBe(false);
|
|
expect(f.b).toBeUndefined();
|
|
});
|
|
|
|
it('should not be marked as dirty when is readonly', () => {
|
|
const f = form(
|
|
signal({
|
|
a: 1,
|
|
b: 2,
|
|
}),
|
|
(p) => {
|
|
readonly(p);
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(f().dirty()).toBe(false);
|
|
|
|
f().markAsDirty();
|
|
expect(f().dirty()).toBe(false);
|
|
});
|
|
it('should not be marked as dirty when is disabled', () => {
|
|
const f = form(
|
|
signal({
|
|
a: 1,
|
|
b: 2,
|
|
}),
|
|
(p) => {
|
|
disabled(p);
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(f().dirty()).toBe(false);
|
|
|
|
f().markAsDirty();
|
|
expect(f().dirty()).toBe(false);
|
|
});
|
|
|
|
it('should not be marked as dirty when is hidden', () => {
|
|
const f = form(
|
|
signal({
|
|
a: 1,
|
|
b: 2,
|
|
}),
|
|
(p) => {
|
|
hidden(p, () => true);
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(f().dirty()).toBe(false);
|
|
|
|
f().markAsDirty();
|
|
|
|
expect(f().dirty()).toBe(false);
|
|
});
|
|
|
|
it('should be marked as dirty when not readonly', () => {
|
|
const f = form(
|
|
signal({
|
|
a: 1,
|
|
b: 2,
|
|
}),
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(f().dirty()).toBe(false);
|
|
|
|
f().markAsDirty();
|
|
expect(f().dirty()).toBe(true);
|
|
});
|
|
|
|
it('should be marked as dirty when not disabled', () => {
|
|
const f = form(
|
|
signal({
|
|
a: 1,
|
|
b: 2,
|
|
}),
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(f().dirty()).toBe(false);
|
|
|
|
f().markAsDirty();
|
|
expect(f().dirty()).toBe(true);
|
|
});
|
|
|
|
it('should be marked as dirty when not hidden', () => {
|
|
const f = form(
|
|
signal({
|
|
a: 1,
|
|
b: 2,
|
|
}),
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(f().dirty()).toBe(false);
|
|
|
|
f().markAsDirty();
|
|
expect(f().dirty()).toBe(true);
|
|
});
|
|
|
|
it('should become pristine when field becomes non-interactive after being marked dirty', () => {
|
|
const isReadonly = signal(false);
|
|
const f = form(
|
|
signal({
|
|
a: 1,
|
|
b: 2,
|
|
}),
|
|
(p) => {
|
|
readonly(p, isReadonly);
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
// Initially interactive and not dirty
|
|
expect(f().readonly()).toBe(false);
|
|
expect(f().dirty()).toBe(false);
|
|
|
|
// Mark as dirty while interactive
|
|
f().markAsDirty();
|
|
expect(f().dirty()).toBe(true);
|
|
|
|
// Make non-interactive, should become pristine
|
|
isReadonly.set(true);
|
|
expect(f().readonly()).toBe(true);
|
|
expect(f().dirty()).toBe(false);
|
|
|
|
// Make interactive again, should still be dirty
|
|
isReadonly.set(false);
|
|
expect(f().readonly()).toBe(false);
|
|
expect(f().dirty()).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('touched', () => {
|
|
it('is untouched initially', () => {
|
|
const f = form(
|
|
signal({
|
|
a: 1,
|
|
b: 2,
|
|
}),
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
expect(f().touched()).toBe(false);
|
|
});
|
|
|
|
it('can be marked as touched', () => {
|
|
const f = form(
|
|
signal({
|
|
a: 1,
|
|
b: 2,
|
|
}),
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
expect(f().touched()).toBe(false);
|
|
|
|
f().markAsTouched();
|
|
expect(f().touched()).toBe(true);
|
|
});
|
|
|
|
it('propagates from the children', () => {
|
|
const f = form(
|
|
signal({
|
|
a: 1,
|
|
b: 2,
|
|
}),
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
expect(f().touched()).toBe(false);
|
|
|
|
f.a().markAsTouched();
|
|
expect(f().touched()).toBe(true);
|
|
});
|
|
|
|
it('does not propagate down', () => {
|
|
const f = form(
|
|
signal({
|
|
a: 1,
|
|
b: 2,
|
|
}),
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(f.a().touched()).toBe(false);
|
|
f().markAsTouched();
|
|
expect(f.a().touched()).toBe(false);
|
|
});
|
|
|
|
it('does not consider children that get removed', () => {
|
|
const value = signal<{a: number; b?: number}>({a: 1, b: 2});
|
|
const f = form(value, {injector: TestBed.inject(Injector)});
|
|
expect(f().touched()).toBe(false);
|
|
|
|
f.b!().markAsTouched();
|
|
expect(f().touched()).toBe(true);
|
|
|
|
value.set({a: 2});
|
|
expect(f().touched()).toBe(false);
|
|
expect(f.b).toBeUndefined();
|
|
});
|
|
|
|
it('can be reset', () => {
|
|
const model = signal({a: 1, b: 2});
|
|
const f = form(model, {injector: TestBed.inject(Injector)});
|
|
f().markAsTouched();
|
|
expect(f().touched()).toBe(true);
|
|
|
|
f().reset();
|
|
expect(f().touched()).toBe(false);
|
|
});
|
|
|
|
it('reset should not track model changes', () => {
|
|
const f = form(signal(''), {injector: TestBed.inject(Injector)});
|
|
const spy = jasmine.createSpy();
|
|
effect(
|
|
() => {
|
|
spy();
|
|
f().reset();
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
TestBed.tick();
|
|
expect(spy).toHaveBeenCalledTimes(1);
|
|
|
|
f().value.set('hi');
|
|
|
|
TestBed.tick();
|
|
expect(spy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should not be marked as touched when is readonly', () => {
|
|
const f = form(
|
|
signal({
|
|
a: 1,
|
|
b: 2,
|
|
}),
|
|
(p) => {
|
|
readonly(p);
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(f().touched()).toBe(false);
|
|
|
|
f().markAsTouched();
|
|
expect(f().touched()).toBe(false);
|
|
});
|
|
it('should not be marked as touched when is disabled', () => {
|
|
const f = form(
|
|
signal({
|
|
a: 1,
|
|
b: 2,
|
|
}),
|
|
(p) => {
|
|
disabled(p);
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(f().touched()).toBe(false);
|
|
|
|
f().markAsTouched();
|
|
expect(f().touched()).toBe(false);
|
|
});
|
|
|
|
it('should not be marked as touched when is hidden', () => {
|
|
const f = form(
|
|
signal({
|
|
a: 1,
|
|
b: 2,
|
|
}),
|
|
(p) => {
|
|
hidden(p, () => true);
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(f().touched()).toBe(false);
|
|
|
|
f().markAsTouched();
|
|
|
|
expect(f().touched()).toBe(false);
|
|
});
|
|
|
|
it('should be marked as touched when not readonly', () => {
|
|
const f = form(
|
|
signal({
|
|
a: 1,
|
|
b: 2,
|
|
}),
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(f().touched()).toBe(false);
|
|
|
|
f().markAsTouched();
|
|
expect(f().touched()).toBe(true);
|
|
});
|
|
|
|
it('should be marked as touched when not disabled', () => {
|
|
const f = form(
|
|
signal({
|
|
a: 1,
|
|
b: 2,
|
|
}),
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(f().touched()).toBe(false);
|
|
|
|
f().markAsTouched();
|
|
expect(f().touched()).toBe(true);
|
|
});
|
|
|
|
it('should be marked as touched when not hidden', () => {
|
|
const f = form(
|
|
signal({
|
|
a: 1,
|
|
b: 2,
|
|
}),
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(f().touched()).toBe(false);
|
|
|
|
f().markAsTouched();
|
|
expect(f().touched()).toBe(true);
|
|
});
|
|
|
|
it('should become untouched when field becomes non-interactive after being marked touched', () => {
|
|
const isHidden = signal(false);
|
|
const f = form(
|
|
signal({
|
|
a: 1,
|
|
b: 2,
|
|
}),
|
|
(p) => {
|
|
hidden(p, isHidden);
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
// Initially interactive and not touched
|
|
expect(f().hidden()).toBe(false);
|
|
expect(f().touched()).toBe(false);
|
|
|
|
// Mark as touched while interactive
|
|
f().markAsTouched();
|
|
expect(f().touched()).toBe(true);
|
|
|
|
// Make non-interactive, should become untouched
|
|
isHidden.set(true);
|
|
expect(f().hidden()).toBe(true);
|
|
expect(f().touched()).toBe(false);
|
|
|
|
// Make interactive again, should still be touched
|
|
isHidden.set(false);
|
|
expect(f().hidden()).toBe(false);
|
|
expect(f().touched()).toBe(true);
|
|
});
|
|
|
|
it('should flush pending control value sync on touch', () => {
|
|
const product = {
|
|
id: 'a',
|
|
name: 'a',
|
|
displayName: 'A',
|
|
imgUrl: 'https://a.png',
|
|
};
|
|
const myForm = form(
|
|
signal(product),
|
|
(p) => {
|
|
debounce(p.name, () => new Promise(() => {}));
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
myForm.name().markAsTouched();
|
|
// Same object identity because there was no change to flush to the name.
|
|
expect(myForm().value()).toBe(product);
|
|
|
|
myForm.name().controlValue.set('b');
|
|
// Same object identity because the name change is still pending.
|
|
expect(myForm().value()).toBe(product);
|
|
|
|
myForm.name().markAsTouched();
|
|
// Different object identity because the name change was flushed.
|
|
expect(myForm().value()).not.toBe(product);
|
|
});
|
|
});
|
|
|
|
describe('arrays', () => {
|
|
it('should only have child nodes for elements that exist', () => {
|
|
const f = form(signal([1, 2]), {injector: TestBed.inject(Injector)});
|
|
expect(f[0]).toBeDefined();
|
|
expect(f[1]).toBeDefined();
|
|
expect(f[2]).not.toBeDefined();
|
|
expect(f['length']).toBe(2);
|
|
});
|
|
|
|
it('should get the element node', () => {
|
|
const f = form(
|
|
signal({names: [{name: 'Alex'}, {name: 'Miles'}]}),
|
|
(p) => {
|
|
applyEach(p.names, (a) => {
|
|
disabled(a.name, ({value, fieldTreeOf}) => {
|
|
const el = fieldTreeOf(a);
|
|
expect(el().value().name).toBe(value());
|
|
expect([...fieldTreeOf(p).names].findIndex((e: any) => e === el)).not.toBe(-1);
|
|
return true;
|
|
});
|
|
});
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
expect(f.names[0].name().disabled()).toBe(true);
|
|
expect(f.names[1].name().disabled()).toBe(true);
|
|
});
|
|
|
|
it('should support element-level logic', () => {
|
|
const f = form(
|
|
signal([1, 2, 3]),
|
|
(p) => {
|
|
applyEach(p, (a) => {
|
|
a;
|
|
disabled(a, ({value}) => value() % 2 === 0);
|
|
});
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
expect(f[0]().disabled()).toBe(false);
|
|
expect(f[1]().disabled()).toBe(true);
|
|
expect(f[2]().disabled()).toBe(false);
|
|
});
|
|
|
|
it('should support dynamic elements', () => {
|
|
const model = signal([1, 2, 3]);
|
|
const f = form(
|
|
model,
|
|
(p) => {
|
|
applyEach(p, (el) => {
|
|
// Disabled if even.
|
|
disabled(el, ({value}) => value() % 2 === 0);
|
|
});
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
model.update((v) => [...v, 4]);
|
|
expect(f[3]().disabled()).toBe(true);
|
|
});
|
|
|
|
it('should support removing elements', () => {
|
|
const value = signal([1, 2, 3]);
|
|
const f = form(value, {injector: TestBed.inject(Injector)});
|
|
f[2]().markAsTouched();
|
|
expect(f().touched()).toBe(true);
|
|
|
|
value.set([1, 2]);
|
|
expect(f().touched()).toBe(false);
|
|
});
|
|
|
|
describe('tracking', () => {
|
|
it('maintains identity across value moves', () => {
|
|
const value = signal([{name: 'Alex'}, {name: 'Kirill'}]);
|
|
const f = form(value, {injector: TestBed.inject(Injector)});
|
|
const alex = f[0];
|
|
const kirill = f[1];
|
|
|
|
value.update((old) => [old[1], old[0]]);
|
|
|
|
expect(f[0] === kirill).toBeTrue();
|
|
expect(f[1] === alex).toBeTrue();
|
|
});
|
|
|
|
it('maintains identity across value update', () => {
|
|
const value = signal([{name: 'Alex'}, {name: 'Kirill'}]);
|
|
const f = form(value, {injector: TestBed.inject(Injector)});
|
|
const alex = f[0];
|
|
const kirill = f[1];
|
|
|
|
value.update((old) => [old[1], {...old[0], name: 'Pawel'}]);
|
|
|
|
expect(f[0] === kirill).toBeTrue();
|
|
expect(f[1] === alex).toBeTrue();
|
|
});
|
|
|
|
it('uses index as identity for primitive values', () => {
|
|
const value = signal([1, 'two']);
|
|
const f = form(value, {injector: TestBed.inject(Injector)});
|
|
const first = f[0];
|
|
const second = f[1];
|
|
|
|
value.update((old) => [old[1], old[0]]);
|
|
|
|
expect(f[0] === first).toBeTrue();
|
|
expect(f[1] === second).toBeTrue();
|
|
});
|
|
|
|
it('uses index as identity for array values', () => {
|
|
const value = signal([[1], ['two']]);
|
|
const f = form(value, {injector: TestBed.inject(Injector)});
|
|
const first = f[0];
|
|
const second = f[1];
|
|
|
|
value.update((old) => [old[1], old[0]]);
|
|
|
|
expect(f[0] === first).toBeTrue();
|
|
expect(f[1] === second).toBeTrue();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('names', () => {
|
|
it('auto-generates a name for the form', () => {
|
|
const f = form(signal({}), {injector: TestBed.inject(Injector)});
|
|
expect(f().name()).toMatch(/^a.form\d+$/);
|
|
});
|
|
|
|
it('uses a specific name for the form when given', () => {
|
|
const f = form(signal({}), {injector: TestBed.inject(Injector), name: 'test'});
|
|
expect(f().name()).toBe('test');
|
|
});
|
|
|
|
it('derives child field names from parents', () => {
|
|
const f = form(signal({user: {firstName: 'Alex'}}), {
|
|
injector: TestBed.inject(Injector),
|
|
name: 'test',
|
|
});
|
|
expect(f.user().name()).toBe('test.user');
|
|
expect(f.user.firstName().name()).toBe('test.user.firstName');
|
|
});
|
|
});
|
|
|
|
describe('disabled', () => {
|
|
it('should allow logic to make a node disabled', () => {
|
|
const f = form(
|
|
signal({a: 1, b: 2}),
|
|
(p) => {
|
|
disabled(p.a, ({value}) => value() !== 2);
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
const a = f.a;
|
|
expect(f().disabled()).toBe(false);
|
|
expect(a().disabled()).toBe(true);
|
|
expect(a().disabledReasons()).toEqual([{fieldTree: f.a}]);
|
|
|
|
a().value.set(2);
|
|
expect(f().disabled()).toBe(false);
|
|
expect(a().disabled()).toBe(false);
|
|
});
|
|
|
|
it('should disable with reason', () => {
|
|
const f = form(
|
|
signal({a: 1, b: 2}),
|
|
(p) => {
|
|
disabled(p.a, () => 'a cannot be changed');
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(f.a().disabled()).toBe(true);
|
|
expect(f.a().disabledReasons()).toEqual([
|
|
{
|
|
fieldTree: f.a,
|
|
message: 'a cannot be changed',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should not have disabled reason if not disabled', () => {
|
|
const f = form(
|
|
signal({a: 1, b: 2}),
|
|
(p) => {
|
|
disabled(p.a, ({value}) => (value() > 5 ? 'a cannot be changed' : false));
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(f.a().disabled()).toBe(false);
|
|
expect(f.a().disabledReasons()).toEqual([]);
|
|
|
|
f.a().value.set(6);
|
|
|
|
expect(f.a().disabled()).toBe(true);
|
|
expect(f.a().disabledReasons()).toEqual([
|
|
{
|
|
fieldTree: f.a,
|
|
message: 'a cannot be changed',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('disabled reason should propagate to children', () => {
|
|
const f = form(
|
|
signal({a: 1, b: 2}),
|
|
(p) => {
|
|
disabled(p, () => 'form unavailable');
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(f().disabled()).toBe(true);
|
|
expect(f().disabledReasons()).toEqual([
|
|
{
|
|
fieldTree: f,
|
|
message: 'form unavailable',
|
|
},
|
|
]);
|
|
expect(f.a().disabled()).toBe(true);
|
|
expect(f.a().disabledReasons()).toEqual([
|
|
{
|
|
fieldTree: f,
|
|
message: 'form unavailable',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should disable unconditionally', () => {
|
|
const f = form(
|
|
signal({a: '', b: ''}),
|
|
(p) => {
|
|
disabled(p.a);
|
|
disabled(p.b, 'disabled!');
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(f.a().disabledReasons()).toEqual([
|
|
{
|
|
fieldTree: f.a,
|
|
},
|
|
]);
|
|
expect(f.b().disabledReasons()).toEqual([
|
|
{
|
|
fieldTree: f.b,
|
|
message: 'disabled!',
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('readonly', () => {
|
|
it('should allow logic to make a field readonly', () => {
|
|
const f = form(
|
|
signal({a: 1, b: 2}),
|
|
(p) => {
|
|
readonly(p.a);
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(f().readonly()).toBe(false);
|
|
expect(f.a().readonly()).toBe(true);
|
|
expect(f.b().readonly()).toBe(false);
|
|
});
|
|
|
|
it('should allow logic to make a field conditionally readonly', () => {
|
|
const f = form(
|
|
signal({a: 1, b: 2}),
|
|
(p) => {
|
|
readonly(p.a, ({value}) => value() > 10);
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(f.a().readonly()).toBe(false);
|
|
|
|
f.a().value.set(11);
|
|
expect(f.a().readonly()).toBe(true);
|
|
});
|
|
|
|
it('should make children of readonly parent readonly', () => {
|
|
const f = form(
|
|
signal({a: 1, b: 2}),
|
|
(p) => {
|
|
readonly(p);
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(f().readonly()).toBe(true);
|
|
expect(f.a().readonly()).toBe(true);
|
|
expect(f.b().readonly()).toBe(true);
|
|
});
|
|
|
|
it('should not validate readonly fields', () => {
|
|
const isReadonly = signal(false);
|
|
const f = form(
|
|
signal(''),
|
|
(p) => {
|
|
readonly(p, isReadonly);
|
|
required(p);
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(f().required()).toBe(true);
|
|
expect(f().valid()).toBe(false);
|
|
expect(f().readonly()).toBe(false);
|
|
|
|
isReadonly.set(true);
|
|
expect(f().required()).toBe(true);
|
|
expect(f().valid()).toBe(true);
|
|
expect(f().readonly()).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('validation', () => {
|
|
it('should validate field', () => {
|
|
const f = form(
|
|
signal({a: 1, b: 2}),
|
|
(p) => {
|
|
validate(p.a, ({value}) => {
|
|
if (value() > 10) {
|
|
return {kind: 'too-damn-high'};
|
|
}
|
|
return undefined;
|
|
});
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(f.a().errors()).toEqual([]);
|
|
expect(f.a().valid()).toBe(true);
|
|
expect(f.a().errors()).toEqual([]);
|
|
expect(f().valid()).toBe(true);
|
|
|
|
f.a().value.set(11);
|
|
expect(f.a().errors()).toEqual([{kind: 'too-damn-high', fieldTree: f.a}]);
|
|
expect(f.a().valid()).toBe(false);
|
|
expect(f().errors()).toEqual([]);
|
|
expect(f().valid()).toBe(false);
|
|
});
|
|
|
|
it('should validate with multiple errors', () => {
|
|
const f = form(
|
|
signal({a: 1, b: 2}),
|
|
(p) => {
|
|
validate(p.a, ({value}) => {
|
|
if (value() > 10) {
|
|
return [{kind: 'too-damn-high'}, {kind: 'bad'}];
|
|
}
|
|
return undefined;
|
|
});
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(f.a().errors()).toEqual([]);
|
|
expect(f.a().valid()).toBe(true);
|
|
|
|
f.a().value.set(11);
|
|
expect(f.a().errors()).toEqual([
|
|
{kind: 'too-damn-high', fieldTree: f.a},
|
|
{kind: 'bad', fieldTree: f.a},
|
|
]);
|
|
expect(f.a().valid()).toBe(false);
|
|
});
|
|
|
|
it('should validate required field', () => {
|
|
const data = signal({first: '', last: ''});
|
|
const f = form(
|
|
data,
|
|
(name) => {
|
|
required(name.first);
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(f.first().errors()).toEqual([requiredError({fieldTree: f.first})]);
|
|
expect(f.first().valid()).toBe(false);
|
|
expect(f.first().required()).toBe(true);
|
|
|
|
f.first().value.set('Bob');
|
|
|
|
expect(f.first().errors()).toEqual([]);
|
|
expect(f.first().valid()).toBe(true);
|
|
expect(f.first().required()).toBe(true);
|
|
});
|
|
|
|
it('should validate conditionally required field', () => {
|
|
const data = signal({first: '', last: ''});
|
|
const f = form(
|
|
data,
|
|
(name) => {
|
|
// first name required if last name specified
|
|
required(name.first, {when: ({valueOf}) => valueOf(name.last) !== ''});
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(f.first().errors()).toEqual([]);
|
|
expect(f.first().valid()).toBe(true);
|
|
expect(f.first().required()).toBe(false);
|
|
|
|
f.last().value.set('Loblaw');
|
|
|
|
expect(f.first().errors()).toEqual([requiredError({fieldTree: f.first})]);
|
|
expect(f.first().valid()).toBe(false);
|
|
expect(f.first().required()).toBe(true);
|
|
|
|
f.first().value.set('Bob');
|
|
|
|
expect(f.first().errors()).toEqual([]);
|
|
expect(f.first().valid()).toBe(true);
|
|
expect(f.first().required()).toBe(true);
|
|
});
|
|
|
|
it('should link required error messages to their predicate', () => {
|
|
const data = signal({country: '', amount: 0, name: ''});
|
|
const f = form(
|
|
data,
|
|
(tx) => {
|
|
required(tx.name, {
|
|
when: ({valueOf}) => valueOf(tx.country) === 'USA',
|
|
error: requiredError({message: 'Name is required in your country'}),
|
|
});
|
|
required(tx.name, {
|
|
when: ({valueOf}) => valueOf(tx.amount) >= 1000,
|
|
error: requiredError({
|
|
message: 'Name is required for large transactions',
|
|
}),
|
|
});
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(f.name().errors()).toEqual([]);
|
|
|
|
f.country().value.set('USA');
|
|
expect(f.name().errors()).toEqual([
|
|
requiredError({
|
|
message: 'Name is required in your country',
|
|
fieldTree: f.name,
|
|
}),
|
|
]);
|
|
|
|
f.amount().value.set(1000);
|
|
expect(f.name().errors()).toEqual([
|
|
requiredError({
|
|
message: 'Name is required in your country',
|
|
fieldTree: f.name,
|
|
}),
|
|
requiredError({
|
|
message: 'Name is required for large transactions',
|
|
fieldTree: f.name,
|
|
}),
|
|
]);
|
|
|
|
f.country().value.set('Canada');
|
|
expect(f.name().errors()).toEqual([
|
|
requiredError({
|
|
message: 'Name is required for large transactions',
|
|
fieldTree: f.name,
|
|
}),
|
|
]);
|
|
|
|
f.amount().value.set(100);
|
|
expect(f.name().errors()).toEqual([]);
|
|
});
|
|
|
|
it('should allow validate logic to return null to indicate no error', () => {
|
|
const f = form(
|
|
signal({a: 1, b: 2}),
|
|
(p) => {
|
|
validate(p.a, ({value}) => (value() > 1 ? {kind: 'error'} : null));
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(f.a().errors()).toEqual([]);
|
|
expect(f.a().valid()).toBe(true);
|
|
|
|
f.a().value.set(2);
|
|
expect(f.a().errors()).toEqual([{kind: 'error', fieldTree: f.a}]);
|
|
expect(f.a().valid()).toBe(false);
|
|
});
|
|
|
|
describe('tree validation', () => {
|
|
it('should push errors to children', () => {
|
|
const cat = signal({name: 'Fluffy', age: 5});
|
|
const f = form(
|
|
cat,
|
|
(p) => {
|
|
validateTree(p, ({value, fieldTreeOf}) => {
|
|
const errors: ValidationError.WithOptionalFieldTree[] = [];
|
|
if (value().name.length > 8) {
|
|
errors.push({kind: 'long_name', fieldTree: fieldTreeOf(p.name)});
|
|
}
|
|
if (value().age < 0) {
|
|
errors.push({kind: 'temporal_anomaly', fieldTree: fieldTreeOf(p.age)});
|
|
}
|
|
return errors;
|
|
});
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(f.name().errors()).toEqual([]);
|
|
expect(f.age().errors()).toEqual([]);
|
|
|
|
f.age().value.set(-10);
|
|
|
|
expect(f.name().errors()).toEqual([]);
|
|
expect(f.age().errors()).toEqual([{kind: 'temporal_anomaly', fieldTree: f.age}]);
|
|
|
|
cat.set({name: 'Fluffy McFluffington', age: 10});
|
|
expect(f.name().errors()).toEqual([{kind: 'long_name', fieldTree: f.name}]);
|
|
expect(f.age().errors()).toEqual([]);
|
|
});
|
|
|
|
it('should push errors to children async', () => {
|
|
const cat = signal({name: 'Fluffy', age: 5});
|
|
const f = form(
|
|
cat,
|
|
(p) => {
|
|
validateTree(p, ({value, fieldTreeOf}) => {
|
|
const errors: ValidationError.WithOptionalFieldTree[] = [];
|
|
if (value().name.length > 8) {
|
|
errors.push({kind: 'long_name', fieldTree: fieldTreeOf(p.name)});
|
|
}
|
|
if (value().age < 0) {
|
|
errors.push({kind: 'temporal_anomaly', fieldTree: fieldTreeOf(p.age)});
|
|
}
|
|
return errors;
|
|
});
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(f.name().errors()).toEqual([]);
|
|
expect(f.age().errors()).toEqual([]);
|
|
|
|
f.age().value.set(-10);
|
|
|
|
expect(f.name().errors()).toEqual([]);
|
|
expect(f.age().errors()).toEqual([{kind: 'temporal_anomaly', fieldTree: f.age}]);
|
|
|
|
cat.set({name: 'Fluffy McFluffington', age: 10});
|
|
expect(f.name().errors()).toEqual([{kind: 'long_name', fieldTree: f.name}]);
|
|
expect(f.age().errors()).toEqual([]);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('errorSummary', () => {
|
|
it('should be empty', () => {
|
|
const data = signal({});
|
|
const f = form(data, {injector: TestBed.inject(Injector)});
|
|
|
|
expect(f().errorSummary()).toEqual([]);
|
|
});
|
|
|
|
it('should contain errors from current field', () => {
|
|
const data = signal('');
|
|
const f = form(
|
|
data,
|
|
(p) => {
|
|
required(p);
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(f().errorSummary()).toEqual([requiredError({fieldTree: f})]);
|
|
});
|
|
|
|
it('should contain errors from child fields', () => {
|
|
const name = signal({first: '', last: ''});
|
|
const f = form(
|
|
name,
|
|
(p) => {
|
|
required(p.first);
|
|
required(p.last);
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(f().errorSummary()).toEqual([
|
|
requiredError({fieldTree: f.first}),
|
|
requiredError({fieldTree: f.last}),
|
|
]);
|
|
});
|
|
|
|
it('should accumulate errors of all descendants', () => {
|
|
const data = signal({
|
|
child: {
|
|
child: {},
|
|
},
|
|
});
|
|
const f = form(
|
|
data,
|
|
(p) => {
|
|
validate(p, () => ({kind: 'root'}));
|
|
validate(p.child, () => ({kind: 'child'}));
|
|
validate(p.child.child, () => ({kind: 'grandchild'}));
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(f.child.child().errorSummary()).toEqual([
|
|
{kind: 'grandchild', fieldTree: f.child.child},
|
|
]);
|
|
expect(f.child().errorSummary()).toEqual([
|
|
{kind: 'child', fieldTree: f.child},
|
|
{kind: 'grandchild', fieldTree: f.child.child},
|
|
]);
|
|
expect(f().errorSummary()).toEqual([
|
|
{kind: 'root', fieldTree: f},
|
|
{kind: 'child', fieldTree: f.child},
|
|
{kind: 'grandchild', fieldTree: f.child.child},
|
|
]);
|
|
});
|
|
|
|
it('should sort errors by DOM position', async () => {
|
|
@Component({
|
|
template: `
|
|
<input [formField]="f.b" />
|
|
<input [formField]="f.a" />
|
|
`,
|
|
imports: [FormField],
|
|
})
|
|
class TestCmp {
|
|
f = form(signal({a: '', b: ''}), (p) => {
|
|
validate(p.a, () => ({kind: 'error-a'}));
|
|
validate(p.b, () => ({kind: 'error-b'}));
|
|
});
|
|
}
|
|
|
|
const fixture = TestBed.createComponent(TestCmp);
|
|
const cmp = fixture.componentInstance;
|
|
fixture.detectChanges();
|
|
|
|
expect(cmp.f().errorSummary()).toEqual([
|
|
jasmine.objectContaining({kind: 'error-b'}),
|
|
jasmine.objectContaining({kind: 'error-a'}),
|
|
]);
|
|
});
|
|
|
|
it('should sort bound errors before unbound errors', () => {
|
|
@Component({
|
|
template: ` <input [formField]="f.a" /> `,
|
|
imports: [FormField],
|
|
})
|
|
class TestCmp {
|
|
f = form(signal({a: '', b: ''}), (p) => {
|
|
validate(p.a, () => ({kind: 'error-a'}));
|
|
validate(p.b, () => ({kind: 'error-b'}));
|
|
});
|
|
}
|
|
|
|
const fixture = TestBed.createComponent(TestCmp);
|
|
const cmp = fixture.componentInstance;
|
|
fixture.detectChanges();
|
|
|
|
expect(cmp.f().errorSummary()).toEqual([
|
|
jasmine.objectContaining({kind: 'error-a'}),
|
|
jasmine.objectContaining({kind: 'error-b'}),
|
|
]);
|
|
});
|
|
|
|
it('should sort errors from nested fields by DOM position', () => {
|
|
@Component({
|
|
template: `
|
|
<input [formField]="f.group.child" />
|
|
<input [formField]="f.other" />
|
|
`,
|
|
imports: [FormField],
|
|
})
|
|
class TestCmp {
|
|
f = form(signal({group: {child: ''}, other: ''}), (p) => {
|
|
validate(p.group.child, () => ({kind: 'child'}));
|
|
validate(p.other, () => ({kind: 'other'}));
|
|
});
|
|
}
|
|
|
|
const fixture = TestBed.createComponent(TestCmp);
|
|
const cmp = fixture.componentInstance;
|
|
fixture.detectChanges();
|
|
|
|
expect(cmp.f().errorSummary()).toEqual([
|
|
jasmine.objectContaining({kind: 'child'}),
|
|
jasmine.objectContaining({kind: 'other'}),
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('composition', () => {
|
|
it('should apply schema to field', () => {
|
|
interface Address {
|
|
street: string;
|
|
city: string;
|
|
}
|
|
|
|
const addressSchema: SchemaOrSchemaFn<Address> = (p) => {
|
|
disabled(p.street, () => true);
|
|
};
|
|
|
|
const data = signal<{name: string; address: Address}>({
|
|
name: '',
|
|
address: {street: '', city: ''},
|
|
});
|
|
|
|
const f = form(
|
|
data,
|
|
(p) => {
|
|
apply(p.address, addressSchema);
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(f.address.street().disabled()).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('predefined schema', () => {
|
|
it('should compile schema once per form', () => {
|
|
const opts = {injector: TestBed.inject(Injector)};
|
|
const subFn = jasmine.createSpy('schemaFn');
|
|
const sub: Schema<string> = schema(subFn);
|
|
const s = schema((p: SchemaPathTree<{a: string; b: string}>) => {
|
|
apply(p.a, sub);
|
|
apply(p.b, sub);
|
|
});
|
|
expect(subFn).toHaveBeenCalledTimes(0);
|
|
|
|
form(signal({a: '', b: ''}), s, opts);
|
|
expect(subFn).toHaveBeenCalledTimes(1);
|
|
|
|
form(signal({a: '', b: ''}), s, opts);
|
|
expect(subFn).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('should resolve predefined schema paths within the local context', () => {
|
|
const s = schema<{a: string; b: string}>((p) => {
|
|
disabled(p.b, ({valueOf}) => valueOf(p.a) === 'disable-b');
|
|
});
|
|
|
|
const f = form(
|
|
signal({first: {a: '', b: ''}, second: {a: 'disable-b', b: ''}}),
|
|
(p) => {
|
|
apply(p.first, s);
|
|
apply(p.second, s);
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(f.first.b().disabled()).toBe(false);
|
|
expect(f.second.b().disabled()).toBe(true);
|
|
});
|
|
|
|
it('should resolve predefined schema paths deeply nested within the schema', () => {
|
|
const s = schema<{a: string; b: string}>((p) => {
|
|
disabled(p.b, ({valueOf}) => valueOf(p.a) === 'disable-b');
|
|
});
|
|
|
|
const f = form(
|
|
signal({first: {second: {a: 'disable-b', b: ''}}}),
|
|
(p) => {
|
|
apply(p.first, (p) => {
|
|
apply(p.second, s);
|
|
});
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(f.first.second.b().disabled()).toBe(true);
|
|
});
|
|
|
|
it('should error on resolving predefined schema path that is not part of the form', () => {
|
|
let otherP: SchemaPath<string>;
|
|
const s = schema<string>((p) => (otherP = p));
|
|
SchemaImpl.rootCompile(s);
|
|
|
|
const f = form(
|
|
signal(''),
|
|
(p) => {
|
|
disabled(p, ({fieldTreeOf}) => {
|
|
fieldTreeOf(otherP);
|
|
return true;
|
|
});
|
|
},
|
|
{injector: TestBed.inject(Injector)},
|
|
);
|
|
|
|
expect(() => f().disabled()).toThrowError(/Path is not part of this field tree\./);
|
|
});
|
|
});
|
|
|
|
describe('reset', () => {
|
|
it('should propagate to descendants', () => {
|
|
const model = signal({a: {b: 2}});
|
|
const f = form(model, {injector: TestBed.inject(Injector)});
|
|
|
|
f.a.b().markAsDirty();
|
|
expect(f().dirty()).toBe(true);
|
|
expect(f.a().dirty()).toBe(true);
|
|
expect(f.a.b().dirty()).toBe(true);
|
|
|
|
f().reset();
|
|
expect(f().dirty()).toBe(false);
|
|
expect(f.a().dirty()).toBe(false);
|
|
expect(f.a.b().dirty()).toBe(false);
|
|
});
|
|
});
|
|
});
|