angular/packages/forms/signals/test/node/field_node.spec.ts
kirjs 60447945bc refactor(forms): add compatForm
This allows using reactive form controls in signal forms
2025-11-06 10:51:28 -08:00

1266 lines
33 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 {computed, Injector, signal} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {
apply,
applyEach,
customError,
disabled,
SchemaPath,
form,
hidden,
readonly,
required,
REQUIRED,
requiredError,
Schema,
schema,
SchemaOrSchemaFn,
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', () => {
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();
});
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('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('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);
});
});
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([{field: 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([
{
field: 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([
{
field: 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([
{
field: f,
message: 'form unavailable',
},
]);
expect(f.a().disabled()).toBe(true);
expect(f.a().disabledReasons()).toEqual([
{
field: 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([
{
field: f.a,
},
]);
expect(f.b().disabledReasons()).toEqual([
{
field: 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().metadata(REQUIRED)()).toBe(true);
expect(f().valid()).toBe(false);
expect(f().readonly()).toBe(false);
isReadonly.set(true);
expect(f().metadata(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 customError({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([customError({kind: 'too-damn-high', field: 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 [customError({kind: 'too-damn-high'}), customError({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([
customError({kind: 'too-damn-high', field: f.a}),
customError({kind: 'bad', field: 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({field: f.first})]);
expect(f.first().valid()).toBe(false);
expect(f.first().metadata(REQUIRED)()).toBe(true);
f.first().value.set('Bob');
expect(f.first().errors()).toEqual([]);
expect(f.first().valid()).toBe(true);
expect(f.first().metadata(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().metadata(REQUIRED)()).toBe(false);
f.last().value.set('Loblaw');
expect(f.first().errors()).toEqual([requiredError({field: f.first})]);
expect(f.first().valid()).toBe(false);
expect(f.first().metadata(REQUIRED)()).toBe(true);
f.first().value.set('Bob');
expect(f.first().errors()).toEqual([]);
expect(f.first().valid()).toBe(true);
expect(f.first().metadata(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',
field: f.name,
}),
]);
f.amount().value.set(1000);
expect(f.name().errors()).toEqual([
requiredError({
message: 'Name is required in your country',
field: f.name,
}),
requiredError({
message: 'Name is required for large transactions',
field: f.name,
}),
]);
f.country().value.set('Canada');
expect(f.name().errors()).toEqual([
requiredError({
message: 'Name is required for large transactions',
field: 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 ? customError() : 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([customError({field: 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[] = [];
if (value().name.length > 8) {
errors.push(customError({kind: 'long_name', field: fieldTreeOf(p.name)}));
}
if (value().age < 0) {
errors.push(customError({kind: 'temporal_anomaly', field: 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([customError({kind: 'temporal_anomaly', field: f.age})]);
cat.set({name: 'Fluffy McFluffington', age: 10});
expect(f.name().errors()).toEqual([customError({kind: 'long_name', field: 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[] = [];
if (value().name.length > 8) {
errors.push(customError({kind: 'long_name', field: fieldTreeOf(p.name)}));
}
if (value().age < 0) {
errors.push(customError({kind: 'temporal_anomaly', field: 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([customError({kind: 'temporal_anomaly', field: f.age})]);
cat.set({name: 'Fluffy McFluffington', age: 10});
expect(f.name().errors()).toEqual([customError({kind: 'long_name', field: 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({field: 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({field: f.first}),
requiredError({field: f.last}),
]);
});
it('should accumulate errors of all descendants', () => {
const data = signal({
child: {
child: {},
},
});
const f = form(
data,
(p) => {
validate(p, () => customError({kind: 'root'}));
validate(p.child, () => customError({kind: 'child'}));
validate(p.child.child, () => customError({kind: 'grandchild'}));
},
{injector: TestBed.inject(Injector)},
);
expect(f.child.child().errorSummary()).toEqual([
customError({kind: 'grandchild', field: f.child.child}),
]);
expect(f.child().errorSummary()).toEqual([
customError({kind: 'child', field: f.child}),
customError({kind: 'grandchild', field: f.child.child}),
]);
expect(f().errorSummary()).toEqual([
customError({kind: 'root', field: f}),
customError({kind: 'child', field: f.child}),
customError({kind: 'grandchild', field: f.child.child}),
]);
});
});
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<any>;
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);
});
});
});