@dynamic-field-kit/angular 1.2.10 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +113 -4
- package/dist/README.md +113 -4
- package/dist/components/BaseInput.d.ts +23 -11
- package/dist/components/DynamicInput.d.ts +19 -8
- package/dist/components/FieldInput.d.ts +15 -7
- package/dist/components/MultiFieldInput.d.ts +30 -4
- package/dist/esm2022/components/BaseInput.mjs +17 -18
- package/dist/esm2022/components/DynamicInput.mjs +166 -84
- package/dist/esm2022/components/FieldInput.mjs +54 -26
- package/dist/esm2022/components/MultiFieldInput.mjs +282 -29
- package/dist/esm2022/fieldRegistryToken.mjs +12 -0
- package/dist/esm2022/layout/defaultLayouts.mjs +70 -23
- package/dist/esm2022/layout/index.mjs +2 -1
- package/dist/esm2022/layout/layoutRegistry.mjs +14 -0
- package/dist/esm2022/public-api.mjs +6 -2
- package/dist/esm2022/types/layout.mjs +1 -1
- package/dist/fesm2022/dynamic-field-kit-angular.mjs +604 -173
- package/dist/fesm2022/dynamic-field-kit-angular.mjs.map +1 -1
- package/dist/fieldRegistryToken.d.ts +3 -0
- package/dist/layout/defaultLayouts.d.ts +21 -3
- package/dist/layout/index.d.ts +1 -0
- package/dist/layout/layoutRegistry.d.ts +12 -0
- package/dist/public-api.d.ts +5 -2
- package/dist/types/layout.d.ts +4 -1
- package/package.json +19 -8
- package/src/components/BaseInput.ts +36 -10
- package/src/components/DynamicInput.ts +196 -104
- package/src/components/FieldInput.ts +43 -18
- package/src/components/MultiFieldInput.ts +275 -19
- package/src/fieldRegistryToken.ts +15 -0
- package/src/layout/defaultLayouts.ts +42 -10
- package/src/layout/index.ts +1 -0
- package/src/layout/layoutRegistry.ts +25 -0
- package/src/public-api.ts +40 -24
- package/src/types/layout.ts +14 -1
- package/test/DynamicInput.spec.ts +230 -0
- package/test/FieldInput.spec.ts +146 -0
- package/test/MultiFieldInput.spec.ts +256 -0
- package/test/helpers/renderers.ts +89 -0
- package/test/layout.spec.ts +119 -0
- package/test/publicApi.spec.ts +64 -0
- package/test/setup.ts +12 -0
- package/test/smoke.spec.ts +27 -0
- package/tsconfig.json +13 -13
- package/tsconfig.spec.json +9 -0
- package/vitest.config.ts +30 -0
- package/src/adapters/tailwind.ts +0 -13
- package/src/components/imports.ts +0 -3
- package/src/examples/index.ts +0 -4
- package/src/examples/registerExample.ts +0 -7
- package/src/examples/text-field.component.ts +0 -18
- package/src/types.ts +0 -1
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { TestBed } from '@angular/core/testing';
|
|
2
|
+
import { By } from '@angular/platform-browser';
|
|
3
|
+
import { DynamicInput } from '../src/components/DynamicInput';
|
|
4
|
+
import { FIELD_REGISTRY } from '../src/fieldRegistryToken';
|
|
5
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
6
|
+
import {
|
|
7
|
+
DefaultsRendererComponent,
|
|
8
|
+
makeRegistry,
|
|
9
|
+
TextRendererComponent,
|
|
10
|
+
LegacyOutputRendererComponent,
|
|
11
|
+
fallbackRenderer,
|
|
12
|
+
} from './helpers/renderers';
|
|
13
|
+
|
|
14
|
+
describe('DynamicInput', () => {
|
|
15
|
+
let registry: ReturnType<typeof makeRegistry>;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
registry = makeRegistry();
|
|
19
|
+
TestBed.configureTestingModule({
|
|
20
|
+
imports: [DynamicInput],
|
|
21
|
+
providers: [{ provide: FIELD_REGISTRY, useValue: registry }],
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('renders a registered Angular component class', () => {
|
|
26
|
+
registry.register('text', TextRendererComponent as never);
|
|
27
|
+
|
|
28
|
+
const fixture = TestBed.createComponent(DynamicInput);
|
|
29
|
+
fixture.componentRef.setInput('type', 'text');
|
|
30
|
+
fixture.componentRef.setInput('value', 'hello');
|
|
31
|
+
fixture.detectChanges();
|
|
32
|
+
|
|
33
|
+
const input: HTMLInputElement =
|
|
34
|
+
fixture.nativeElement.querySelector('input.txt');
|
|
35
|
+
expect(input).not.toBeNull();
|
|
36
|
+
expect(input.value).toBe('hello');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('does not clobber a renderer input default when DynamicInput was never given that prop', () => {
|
|
40
|
+
registry.register('defaults', DefaultsRendererComponent as never);
|
|
41
|
+
|
|
42
|
+
const fixture = TestBed.createComponent(DynamicInput);
|
|
43
|
+
fixture.componentRef.setInput('type', 'defaults');
|
|
44
|
+
// Deliberately never setInput('label', ...): DynamicInput was never
|
|
45
|
+
// given a value for `label`, so the renderer's own initializer
|
|
46
|
+
// (`label = 'None'`) must survive applyProps untouched.
|
|
47
|
+
fixture.detectChanges();
|
|
48
|
+
|
|
49
|
+
const label: HTMLElement = fixture.nativeElement.querySelector('.label');
|
|
50
|
+
expect(label.textContent).toBe('None');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('forwards KNOWN_PROPS to the rendered instance', () => {
|
|
54
|
+
registry.register('text', TextRendererComponent as never);
|
|
55
|
+
|
|
56
|
+
const fixture = TestBed.createComponent(DynamicInput);
|
|
57
|
+
fixture.componentRef.setInput('type', 'text');
|
|
58
|
+
fixture.componentRef.setInput('placeholder', 'Your name');
|
|
59
|
+
fixture.componentRef.setInput('disabled', true);
|
|
60
|
+
fixture.detectChanges();
|
|
61
|
+
|
|
62
|
+
const input: HTMLInputElement =
|
|
63
|
+
fixture.nativeElement.querySelector('input.txt');
|
|
64
|
+
expect(input.placeholder).toBe('Your name');
|
|
65
|
+
expect(input.disabled).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('forwards extraProps verbatim', () => {
|
|
69
|
+
registry.register('text', TextRendererComponent as never);
|
|
70
|
+
|
|
71
|
+
const fixture = TestBed.createComponent(DynamicInput);
|
|
72
|
+
fixture.componentRef.setInput('type', 'text');
|
|
73
|
+
fixture.componentRef.setInput('extraProps', { hint: 'be brief' });
|
|
74
|
+
fixture.detectChanges();
|
|
75
|
+
|
|
76
|
+
expect(fixture.nativeElement.querySelector('.hint').textContent).toBe(
|
|
77
|
+
'be brief'
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('syncs prop changes to an already-rendered instance', () => {
|
|
82
|
+
registry.register('text', TextRendererComponent as never);
|
|
83
|
+
|
|
84
|
+
const fixture = TestBed.createComponent(DynamicInput);
|
|
85
|
+
fixture.componentRef.setInput('type', 'text');
|
|
86
|
+
fixture.componentRef.setInput('value', 'first');
|
|
87
|
+
fixture.detectChanges();
|
|
88
|
+
|
|
89
|
+
fixture.componentRef.setInput('value', 'second');
|
|
90
|
+
fixture.detectChanges();
|
|
91
|
+
|
|
92
|
+
const input: HTMLInputElement =
|
|
93
|
+
fixture.nativeElement.querySelector('input.txt');
|
|
94
|
+
expect(input.value).toBe('second');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('emits valueChange and onChange when the renderer emits valueChange', () => {
|
|
98
|
+
registry.register('text', TextRendererComponent as never);
|
|
99
|
+
|
|
100
|
+
const fixture = TestBed.createComponent(DynamicInput);
|
|
101
|
+
fixture.componentRef.setInput('type', 'text');
|
|
102
|
+
fixture.detectChanges();
|
|
103
|
+
|
|
104
|
+
const seen: unknown[] = [];
|
|
105
|
+
const legacy: unknown[] = [];
|
|
106
|
+
fixture.componentInstance.valueChange.subscribe((v) => seen.push(v));
|
|
107
|
+
fixture.componentInstance.onChange.subscribe((v) => legacy.push(v));
|
|
108
|
+
|
|
109
|
+
const input: HTMLInputElement =
|
|
110
|
+
fixture.nativeElement.querySelector('input.txt');
|
|
111
|
+
input.value = 'typed';
|
|
112
|
+
input.dispatchEvent(new Event('input'));
|
|
113
|
+
|
|
114
|
+
expect(seen).toEqual(['typed']);
|
|
115
|
+
expect(legacy).toEqual(['typed']);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('binds the legacy onValueChange output name', () => {
|
|
119
|
+
registry.register('text', LegacyOutputRendererComponent as never);
|
|
120
|
+
|
|
121
|
+
const fixture = TestBed.createComponent(DynamicInput);
|
|
122
|
+
fixture.componentRef.setInput('type', 'text');
|
|
123
|
+
fixture.detectChanges();
|
|
124
|
+
|
|
125
|
+
const seen: unknown[] = [];
|
|
126
|
+
fixture.componentInstance.valueChange.subscribe((v) => seen.push(v));
|
|
127
|
+
|
|
128
|
+
fixture.nativeElement.querySelector('button.legacy-btn').click();
|
|
129
|
+
|
|
130
|
+
expect(seen).toEqual(['legacy']);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('renders a plain function renderer as fallback HTML', () => {
|
|
134
|
+
registry.register('text', fallbackRenderer as never);
|
|
135
|
+
|
|
136
|
+
const fixture = TestBed.createComponent(DynamicInput);
|
|
137
|
+
fixture.componentRef.setInput('type', 'text');
|
|
138
|
+
fixture.componentRef.setInput('label', 'Name');
|
|
139
|
+
fixture.componentRef.setInput('value', 'Ada');
|
|
140
|
+
fixture.detectChanges();
|
|
141
|
+
|
|
142
|
+
expect(fixture.nativeElement.querySelector('.fallback').textContent).toBe(
|
|
143
|
+
'Name:Ada'
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('rejects a subclass without its own @Component decorator instead of inheriting the parent renderer', () => {
|
|
148
|
+
// Sub has no own `ɵcmp`: it only inherits TextRendererComponent's via
|
|
149
|
+
// the prototype chain. isComponentType must use hasOwnProperty (not
|
|
150
|
+
// `in`) so Sub is not mistaken for a renderable component in its own
|
|
151
|
+
// right - the DOM must not show TextRendererComponent's template.
|
|
152
|
+
class Sub extends TextRendererComponent {}
|
|
153
|
+
registry.register('text', Sub as never);
|
|
154
|
+
|
|
155
|
+
const fixture = TestBed.createComponent(DynamicInput);
|
|
156
|
+
fixture.componentRef.setInput('type', 'text');
|
|
157
|
+
fixture.detectChanges();
|
|
158
|
+
|
|
159
|
+
expect(fixture.nativeElement.querySelector('input.txt')).toBeNull();
|
|
160
|
+
expect(fixture.nativeElement.textContent).toContain(
|
|
161
|
+
'Failed to render field: text'
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('renders an error for an unknown field type', () => {
|
|
166
|
+
const fixture = TestBed.createComponent(DynamicInput);
|
|
167
|
+
fixture.componentRef.setInput('type', 'nope');
|
|
168
|
+
fixture.detectChanges();
|
|
169
|
+
|
|
170
|
+
expect(fixture.nativeElement.textContent).toContain(
|
|
171
|
+
'Unknown field type: nope'
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('re-renders when type changes', () => {
|
|
176
|
+
registry.register('text', TextRendererComponent as never);
|
|
177
|
+
registry.register('number', fallbackRenderer as never);
|
|
178
|
+
|
|
179
|
+
const fixture = TestBed.createComponent(DynamicInput);
|
|
180
|
+
fixture.componentRef.setInput('type', 'text');
|
|
181
|
+
fixture.detectChanges();
|
|
182
|
+
expect(fixture.nativeElement.querySelector('input.txt')).not.toBeNull();
|
|
183
|
+
|
|
184
|
+
fixture.componentRef.setInput('type', 'number');
|
|
185
|
+
fixture.detectChanges();
|
|
186
|
+
|
|
187
|
+
expect(fixture.nativeElement.querySelector('input.txt')).toBeNull();
|
|
188
|
+
expect(fixture.nativeElement.querySelector('.fallback')).not.toBeNull();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('unsubscribes from renderer outputs on destroy', () => {
|
|
192
|
+
registry.register('text', TextRendererComponent as never);
|
|
193
|
+
|
|
194
|
+
const fixture = TestBed.createComponent(DynamicInput);
|
|
195
|
+
fixture.componentRef.setInput('type', 'text');
|
|
196
|
+
fixture.detectChanges();
|
|
197
|
+
|
|
198
|
+
const rendererInstance = fixture.debugElement.query(
|
|
199
|
+
By.directive(TextRendererComponent)
|
|
200
|
+
).componentInstance as TextRendererComponent;
|
|
201
|
+
|
|
202
|
+
const seen: unknown[] = [];
|
|
203
|
+
fixture.componentInstance.valueChange.subscribe((v) => seen.push(v));
|
|
204
|
+
|
|
205
|
+
fixture.destroy();
|
|
206
|
+
rendererInstance.valueChange.emit('after destroy'); // bypasses DOM entirely
|
|
207
|
+
|
|
208
|
+
expect(seen).toEqual([]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('unsubscribes from the legacy onValueChange output on destroy', () => {
|
|
212
|
+
registry.register('text', LegacyOutputRendererComponent as never);
|
|
213
|
+
|
|
214
|
+
const fixture = TestBed.createComponent(DynamicInput);
|
|
215
|
+
fixture.componentRef.setInput('type', 'text');
|
|
216
|
+
fixture.detectChanges();
|
|
217
|
+
|
|
218
|
+
const rendererInstance = fixture.debugElement.query(
|
|
219
|
+
By.directive(LegacyOutputRendererComponent)
|
|
220
|
+
).componentInstance as LegacyOutputRendererComponent;
|
|
221
|
+
|
|
222
|
+
const seen: unknown[] = [];
|
|
223
|
+
fixture.componentInstance.valueChange.subscribe((v) => seen.push(v));
|
|
224
|
+
|
|
225
|
+
fixture.destroy();
|
|
226
|
+
rendererInstance.onValueChange.emit('after destroy');
|
|
227
|
+
|
|
228
|
+
expect(seen).toEqual([]);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { ChangeDetectorRef } from '@angular/core';
|
|
2
|
+
import { TestBed } from '@angular/core/testing';
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { BaseInputComponent } from '../src/components/BaseInput';
|
|
5
|
+
import { FieldInput } from '../src/components/FieldInput';
|
|
6
|
+
import { FIELD_REGISTRY } from '../src/fieldRegistryToken';
|
|
7
|
+
import {
|
|
8
|
+
DefaultsRendererComponent,
|
|
9
|
+
makeRegistry,
|
|
10
|
+
TextRendererComponent,
|
|
11
|
+
} from './helpers/renderers';
|
|
12
|
+
|
|
13
|
+
describe('FieldInput', () => {
|
|
14
|
+
let registry: ReturnType<typeof makeRegistry>;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
registry = makeRegistry();
|
|
18
|
+
registry.register('text', TextRendererComponent as never);
|
|
19
|
+
TestBed.configureTestingModule({
|
|
20
|
+
imports: [FieldInput],
|
|
21
|
+
providers: [{ provide: FIELD_REGISTRY, useValue: registry }],
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('renders nothing once fieldDescription is cleared', () => {
|
|
26
|
+
// Mounting with no inputs at all passes on the `shouldRender = false`
|
|
27
|
+
// field initializer without ever exercising ngOnChanges's guard. Mount
|
|
28
|
+
// WITH a fieldDescription first, then clear it, so the assertion
|
|
29
|
+
// actually depends on `shouldRender = !!this.fieldDescription`.
|
|
30
|
+
const fixture = TestBed.createComponent(FieldInput);
|
|
31
|
+
fixture.componentRef.setInput('fieldDescription', {
|
|
32
|
+
name: 'first',
|
|
33
|
+
type: 'text',
|
|
34
|
+
});
|
|
35
|
+
fixture.detectChanges();
|
|
36
|
+
expect(fixture.nativeElement.querySelector('input.txt')).not.toBeNull();
|
|
37
|
+
|
|
38
|
+
fixture.componentRef.setInput('fieldDescription', undefined);
|
|
39
|
+
fixture.detectChanges();
|
|
40
|
+
|
|
41
|
+
expect(fixture.nativeElement.querySelector('input.txt')).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('renders the field described by fieldDescription', () => {
|
|
45
|
+
const fixture = TestBed.createComponent(FieldInput);
|
|
46
|
+
fixture.componentRef.setInput('fieldDescription', {
|
|
47
|
+
name: 'first',
|
|
48
|
+
type: 'text',
|
|
49
|
+
placeholder: 'First name',
|
|
50
|
+
});
|
|
51
|
+
fixture.componentRef.setInput('value', 'Ada');
|
|
52
|
+
fixture.detectChanges();
|
|
53
|
+
|
|
54
|
+
const input: HTMLInputElement =
|
|
55
|
+
fixture.nativeElement.querySelector('input.txt');
|
|
56
|
+
expect(input.value).toBe('Ada');
|
|
57
|
+
expect(input.placeholder).toBe('First name');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('emits onValueChangeField with the field name as key', () => {
|
|
61
|
+
const fixture = TestBed.createComponent(FieldInput);
|
|
62
|
+
fixture.componentRef.setInput('fieldDescription', {
|
|
63
|
+
name: 'first',
|
|
64
|
+
type: 'text',
|
|
65
|
+
});
|
|
66
|
+
fixture.detectChanges();
|
|
67
|
+
|
|
68
|
+
const seen: unknown[] = [];
|
|
69
|
+
fixture.componentInstance.onValueChangeField.subscribe((e) => seen.push(e));
|
|
70
|
+
|
|
71
|
+
const input: HTMLInputElement =
|
|
72
|
+
fixture.nativeElement.querySelector('input.txt');
|
|
73
|
+
input.value = 'Grace';
|
|
74
|
+
input.dispatchEvent(new Event('input'));
|
|
75
|
+
|
|
76
|
+
expect(seen).toEqual([{ value: 'Grace', key: 'first' }]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('forwards error, disabled and readOnly to the renderer', () => {
|
|
80
|
+
const fixture = TestBed.createComponent(FieldInput);
|
|
81
|
+
fixture.componentRef.setInput('fieldDescription', {
|
|
82
|
+
name: 'email',
|
|
83
|
+
type: 'text',
|
|
84
|
+
});
|
|
85
|
+
fixture.componentRef.setInput('error', ['Required', 'Invalid']);
|
|
86
|
+
fixture.componentRef.setInput('disabled', true);
|
|
87
|
+
fixture.componentRef.setInput('readOnly', true);
|
|
88
|
+
fixture.detectChanges();
|
|
89
|
+
|
|
90
|
+
const input: HTMLInputElement =
|
|
91
|
+
fixture.nativeElement.querySelector('input.txt');
|
|
92
|
+
expect(input.disabled).toBe(true);
|
|
93
|
+
expect(input.readOnly).toBe(true);
|
|
94
|
+
expect(fixture.nativeElement.querySelector('.err').textContent).toBe(
|
|
95
|
+
'Required, Invalid'
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('forwards FieldDescription.props as extraProps', () => {
|
|
100
|
+
const fixture = TestBed.createComponent(FieldInput);
|
|
101
|
+
fixture.componentRef.setInput('fieldDescription', {
|
|
102
|
+
name: 'first',
|
|
103
|
+
type: 'text',
|
|
104
|
+
props: { hint: 'keep it short' },
|
|
105
|
+
});
|
|
106
|
+
fixture.detectChanges();
|
|
107
|
+
|
|
108
|
+
expect(fixture.nativeElement.querySelector('.hint').textContent).toBe(
|
|
109
|
+
'keep it short'
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('documents current behaviour: a renderer default IS overwritten on the real FieldInput path', () => {
|
|
114
|
+
// Unlike mounting DynamicInput directly, FieldInput's template binds
|
|
115
|
+
// every KNOWN_PROP unconditionally, so DynamicInput sees a firstChange
|
|
116
|
+
// SimpleChange for `label` even though fieldDescription never set one.
|
|
117
|
+
// The `supplied` gate in DynamicInput.applyProps therefore does NOT
|
|
118
|
+
// protect the renderer's own default here - this pins that fact rather
|
|
119
|
+
// than asserting a guarantee that doesn't hold on this path.
|
|
120
|
+
registry.register('defaults', DefaultsRendererComponent as never);
|
|
121
|
+
|
|
122
|
+
const fixture = TestBed.createComponent(FieldInput);
|
|
123
|
+
fixture.componentRef.setInput('fieldDescription', {
|
|
124
|
+
name: 'first',
|
|
125
|
+
type: 'defaults',
|
|
126
|
+
// label intentionally omitted.
|
|
127
|
+
});
|
|
128
|
+
fixture.detectChanges();
|
|
129
|
+
|
|
130
|
+
const label: HTMLElement = fixture.nativeElement.querySelector('.label');
|
|
131
|
+
expect(label.textContent).toBe('');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('BaseInputComponent', () => {
|
|
136
|
+
class TestInput extends BaseInputComponent {}
|
|
137
|
+
|
|
138
|
+
it('marks for check on input changes', () => {
|
|
139
|
+
const cdr = { markForCheck: vi.fn() } as unknown as ChangeDetectorRef;
|
|
140
|
+
const input = new TestInput(cdr);
|
|
141
|
+
|
|
142
|
+
input.ngOnChanges({});
|
|
143
|
+
|
|
144
|
+
expect(cdr.markForCheck).toHaveBeenCalledTimes(1);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { TestBed } from '@angular/core/testing';
|
|
2
|
+
import type {
|
|
3
|
+
FieldDescription,
|
|
4
|
+
ValidationResult,
|
|
5
|
+
} from '@dynamic-field-kit/core';
|
|
6
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
7
|
+
import { MultiFieldInput } from '../src/components/MultiFieldInput';
|
|
8
|
+
import { FIELD_REGISTRY } from '../src/fieldRegistryToken';
|
|
9
|
+
import { makeRegistry, TextRendererComponent } from './helpers/renderers';
|
|
10
|
+
|
|
11
|
+
describe('MultiFieldInput', () => {
|
|
12
|
+
let registry: ReturnType<typeof makeRegistry>;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
registry = makeRegistry();
|
|
16
|
+
registry.register('text', TextRendererComponent as never);
|
|
17
|
+
TestBed.configureTestingModule({
|
|
18
|
+
imports: [MultiFieldInput],
|
|
19
|
+
providers: [{ provide: FIELD_REGISTRY, useValue: registry }],
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function mount(
|
|
24
|
+
fields: FieldDescription[],
|
|
25
|
+
properties: Record<string, unknown>
|
|
26
|
+
) {
|
|
27
|
+
const fixture = TestBed.createComponent(MultiFieldInput);
|
|
28
|
+
fixture.componentRef.setInput('fieldDescriptions', fields);
|
|
29
|
+
fixture.componentRef.setInput('properties', properties);
|
|
30
|
+
fixture.detectChanges();
|
|
31
|
+
return fixture;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
it('renders one input per field', () => {
|
|
35
|
+
const fixture = mount(
|
|
36
|
+
[
|
|
37
|
+
{ name: 'first', type: 'text' },
|
|
38
|
+
{ name: 'last', type: 'text' },
|
|
39
|
+
],
|
|
40
|
+
{ first: 'Ada', last: 'Lovelace' }
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const inputs: HTMLInputElement[] = Array.from(
|
|
44
|
+
fixture.nativeElement.querySelectorAll('input.txt')
|
|
45
|
+
);
|
|
46
|
+
expect(inputs.map((i) => i.value)).toEqual(['Ada', 'Lovelace']);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('hides fields whose appearCondition is false', () => {
|
|
50
|
+
const fields: FieldDescription[] = [
|
|
51
|
+
{ name: 'kind', type: 'text' },
|
|
52
|
+
{
|
|
53
|
+
name: 'company',
|
|
54
|
+
type: 'text',
|
|
55
|
+
appearCondition: (data) => data['kind'] === 'business',
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
expect(
|
|
60
|
+
mount(fields, { kind: 'personal' }).nativeElement.querySelectorAll(
|
|
61
|
+
'input.txt'
|
|
62
|
+
).length
|
|
63
|
+
).toBe(1);
|
|
64
|
+
expect(
|
|
65
|
+
mount(fields, { kind: 'business' }).nativeElement.querySelectorAll(
|
|
66
|
+
'input.txt'
|
|
67
|
+
).length
|
|
68
|
+
).toBe(2);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('applies computeValue to the data it emits', () => {
|
|
72
|
+
const fields: FieldDescription[] = [
|
|
73
|
+
{ name: 'price', type: 'text' },
|
|
74
|
+
{
|
|
75
|
+
name: 'total',
|
|
76
|
+
type: 'text',
|
|
77
|
+
computeValue: (data) => `${Number(data['price']) * 2}`,
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
const fixture = mount(fields, { price: '5' });
|
|
82
|
+
|
|
83
|
+
const inputs: HTMLInputElement[] = Array.from(
|
|
84
|
+
fixture.nativeElement.querySelectorAll('input.txt')
|
|
85
|
+
);
|
|
86
|
+
expect(inputs[1].value).toBe('10');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('emits onChange with the updated data when a field changes', () => {
|
|
90
|
+
const fixture = mount([{ name: 'first', type: 'text' }], { first: 'Ada' });
|
|
91
|
+
|
|
92
|
+
const seen: unknown[] = [];
|
|
93
|
+
fixture.componentInstance.onChange.subscribe((d) => seen.push(d));
|
|
94
|
+
|
|
95
|
+
const input: HTMLInputElement =
|
|
96
|
+
fixture.nativeElement.querySelector('input.txt');
|
|
97
|
+
input.value = 'Grace';
|
|
98
|
+
input.dispatchEvent(new Event('input'));
|
|
99
|
+
|
|
100
|
+
expect(seen).toEqual([{ first: 'Grace' }]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('passes validation errors down to the renderer', () => {
|
|
104
|
+
const fixture = mount(
|
|
105
|
+
[
|
|
106
|
+
{
|
|
107
|
+
name: 'email',
|
|
108
|
+
type: 'text',
|
|
109
|
+
validate: (value) =>
|
|
110
|
+
String(value).includes('@') ? undefined : 'Invalid email',
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
{ email: 'nope' }
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
expect(fixture.nativeElement.querySelector('.err').textContent).toBe(
|
|
117
|
+
'Invalid email'
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('resolves disabledCondition and readOnlyCondition', () => {
|
|
122
|
+
const fixture = mount(
|
|
123
|
+
[
|
|
124
|
+
{
|
|
125
|
+
name: 'a',
|
|
126
|
+
type: 'text',
|
|
127
|
+
disabledCondition: (data) => data['frozen'] === true,
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: 'b',
|
|
131
|
+
type: 'text',
|
|
132
|
+
readOnlyCondition: (data) => data['frozen'] === true,
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
{ frozen: true }
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const inputs: HTMLInputElement[] = Array.from(
|
|
139
|
+
fixture.nativeElement.querySelectorAll('input.txt')
|
|
140
|
+
);
|
|
141
|
+
expect(inputs[0].disabled).toBe(true);
|
|
142
|
+
expect(inputs[1].readOnly).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('does not report errors for disabled fields', () => {
|
|
146
|
+
const fixture = mount(
|
|
147
|
+
[
|
|
148
|
+
{
|
|
149
|
+
name: 'email',
|
|
150
|
+
type: 'text',
|
|
151
|
+
disabled: true,
|
|
152
|
+
validate: () => 'Invalid email',
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
{ email: 'nope' }
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
expect(fixture.nativeElement.querySelector('.err')).toBeNull();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('emits validityChange on init and on every change', () => {
|
|
162
|
+
const seen: ValidationResult[] = [];
|
|
163
|
+
const fixture = TestBed.createComponent(MultiFieldInput);
|
|
164
|
+
fixture.componentInstance.validityChange.subscribe((r) => seen.push(r));
|
|
165
|
+
fixture.componentRef.setInput('fieldDescriptions', [
|
|
166
|
+
{
|
|
167
|
+
name: 'email',
|
|
168
|
+
type: 'text',
|
|
169
|
+
validate: (value: unknown) =>
|
|
170
|
+
String(value).includes('@') ? undefined : 'Invalid email',
|
|
171
|
+
},
|
|
172
|
+
]);
|
|
173
|
+
fixture.componentRef.setInput('properties', { email: 'nope' });
|
|
174
|
+
fixture.detectChanges();
|
|
175
|
+
|
|
176
|
+
expect(seen[seen.length - 1]).toEqual({
|
|
177
|
+
valid: false,
|
|
178
|
+
errors: { email: ['Invalid email'] },
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const input: HTMLInputElement =
|
|
182
|
+
fixture.nativeElement.querySelector('input.txt');
|
|
183
|
+
input.value = 'ada@example.com';
|
|
184
|
+
input.dispatchEvent(new Event('input'));
|
|
185
|
+
|
|
186
|
+
expect(seen[seen.length - 1]).toEqual({ valid: true, errors: {} });
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('renders a repeatable group item per entry and adds one on Add', () => {
|
|
190
|
+
const fields: FieldDescription[] = [
|
|
191
|
+
{
|
|
192
|
+
name: 'contacts',
|
|
193
|
+
type: 'text',
|
|
194
|
+
label: 'Contacts',
|
|
195
|
+
fields: [{ name: 'email', type: 'text' }],
|
|
196
|
+
},
|
|
197
|
+
];
|
|
198
|
+
|
|
199
|
+
const fixture = mount(fields, {
|
|
200
|
+
contacts: [{ email: 'a@x.com' }, { email: 'b@x.com' }],
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
expect(fixture.nativeElement.querySelectorAll('input.txt').length).toBe(2);
|
|
204
|
+
|
|
205
|
+
const seen: unknown[] = [];
|
|
206
|
+
fixture.componentInstance.onChange.subscribe((d) => seen.push(d));
|
|
207
|
+
|
|
208
|
+
const addBtn: HTMLButtonElement = Array.from<HTMLButtonElement>(
|
|
209
|
+
fixture.nativeElement.querySelectorAll('button')
|
|
210
|
+
).find((b) => b.textContent?.trim() === 'Add')!;
|
|
211
|
+
addBtn.click();
|
|
212
|
+
fixture.detectChanges();
|
|
213
|
+
|
|
214
|
+
expect((seen[0] as Record<string, unknown[]>)['contacts'].length).toBe(3);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('removes a group item on Remove', () => {
|
|
218
|
+
const fields: FieldDescription[] = [
|
|
219
|
+
{
|
|
220
|
+
name: 'contacts',
|
|
221
|
+
type: 'text',
|
|
222
|
+
fields: [{ name: 'email', type: 'text' }],
|
|
223
|
+
},
|
|
224
|
+
];
|
|
225
|
+
|
|
226
|
+
const fixture = mount(fields, {
|
|
227
|
+
contacts: [{ email: 'a@x.com' }, { email: 'b@x.com' }],
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const seen: unknown[] = [];
|
|
231
|
+
fixture.componentInstance.onChange.subscribe((d) => seen.push(d));
|
|
232
|
+
|
|
233
|
+
const removeBtn: HTMLButtonElement = Array.from<HTMLButtonElement>(
|
|
234
|
+
fixture.nativeElement.querySelectorAll('button')
|
|
235
|
+
).find((b) => b.textContent?.trim() === 'Remove')!;
|
|
236
|
+
removeBtn.click();
|
|
237
|
+
fixture.detectChanges();
|
|
238
|
+
|
|
239
|
+
expect((seen[0] as Record<string, unknown[]>)['contacts']).toEqual([
|
|
240
|
+
{ email: 'b@x.com' },
|
|
241
|
+
]);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('applies the grid layout', () => {
|
|
245
|
+
const fixture = TestBed.createComponent(MultiFieldInput);
|
|
246
|
+
fixture.componentRef.setInput('fieldDescriptions', [
|
|
247
|
+
{ name: 'a', type: 'text' },
|
|
248
|
+
]);
|
|
249
|
+
fixture.componentRef.setInput('properties', { a: '1' });
|
|
250
|
+
fixture.componentRef.setInput('layout', { type: 'grid', columns: 3 });
|
|
251
|
+
fixture.detectChanges();
|
|
252
|
+
|
|
253
|
+
const container: HTMLElement = fixture.nativeElement.firstElementChild;
|
|
254
|
+
expect(container.style.gridTemplateColumns).toBe('repeat(3, 1fr)');
|
|
255
|
+
});
|
|
256
|
+
});
|