@dynamic-field-kit/angular 1.3.3 → 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 +50 -3
- package/dist/README.md +50 -3
- package/dist/components/BaseInput.d.ts +5 -11
- package/dist/components/DynamicInput.d.ts +6 -2
- package/dist/components/FieldInput.d.ts +6 -2
- package/dist/components/MultiFieldInput.d.ts +10 -1
- package/dist/esm2022/components/BaseInput.mjs +8 -17
- package/dist/esm2022/components/DynamicInput.mjs +67 -14
- package/dist/esm2022/components/FieldInput.mjs +39 -20
- package/dist/esm2022/components/MultiFieldInput.mjs +87 -13
- package/dist/esm2022/fieldRegistryToken.mjs +12 -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 +208 -61
- package/dist/fesm2022/dynamic-field-kit-angular.mjs.map +1 -1
- package/dist/fieldRegistryToken.d.ts +3 -0
- package/dist/public-api.d.ts +4 -1
- package/dist/types/layout.d.ts +4 -25
- package/package.json +14 -16
- package/src/components/BaseInput.ts +7 -10
- package/src/components/DynamicInput.ts +67 -11
- package/src/components/FieldInput.ts +23 -16
- package/src/components/MultiFieldInput.ts +101 -8
- package/src/fieldRegistryToken.ts +15 -0
- package/src/public-api.ts +40 -24
- package/src/types/layout.ts +14 -36
- package/test/DynamicInput.spec.ts +217 -17
- package/test/FieldInput.spec.ts +131 -29
- 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.ts → test/setup.ts} +2 -2
- package/test/smoke.spec.ts +27 -0
- package/tsconfig.json +13 -13
- package/tsconfig.spec.json +4 -16
- package/vitest.config.ts +30 -0
- package/angular.json +0 -27
- package/karma.conf.js +0 -37
- package/test/angular.spec.ts +0 -102
- package/test/integration.spec.ts +0 -57
- package/test/layouts.spec.ts +0 -26
package/test/FieldInput.spec.ts
CHANGED
|
@@ -1,44 +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
|
+
|
|
1
13
|
describe('FieldInput', () => {
|
|
2
|
-
|
|
3
|
-
|
|
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();
|
|
4
42
|
});
|
|
5
43
|
|
|
6
|
-
it('
|
|
7
|
-
const
|
|
8
|
-
|
|
44
|
+
it('renders the field described by fieldDescription', () => {
|
|
45
|
+
const fixture = TestBed.createComponent(FieldInput);
|
|
46
|
+
fixture.componentRef.setInput('fieldDescription', {
|
|
47
|
+
name: 'first',
|
|
9
48
|
type: 'text',
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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');
|
|
16
58
|
});
|
|
17
59
|
|
|
18
|
-
it('
|
|
19
|
-
const
|
|
20
|
-
|
|
60
|
+
it('emits onValueChangeField with the field name as key', () => {
|
|
61
|
+
const fixture = TestBed.createComponent(FieldInput);
|
|
62
|
+
fixture.componentRef.setInput('fieldDescription', {
|
|
63
|
+
name: 'first',
|
|
21
64
|
type: 'text',
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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' }]);
|
|
25
77
|
});
|
|
26
78
|
|
|
27
|
-
it('
|
|
28
|
-
const
|
|
29
|
-
|
|
79
|
+
it('forwards error, disabled and readOnly to the renderer', () => {
|
|
80
|
+
const fixture = TestBed.createComponent(FieldInput);
|
|
81
|
+
fixture.componentRef.setInput('fieldDescription', {
|
|
82
|
+
name: 'email',
|
|
30
83
|
type: 'text',
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
+
);
|
|
34
97
|
});
|
|
35
98
|
|
|
36
|
-
it('
|
|
37
|
-
const
|
|
38
|
-
|
|
99
|
+
it('forwards FieldDescription.props as extraProps', () => {
|
|
100
|
+
const fixture = TestBed.createComponent(FieldInput);
|
|
101
|
+
fixture.componentRef.setInput('fieldDescription', {
|
|
102
|
+
name: 'first',
|
|
39
103
|
type: 'text',
|
|
40
|
-
|
|
41
|
-
};
|
|
42
|
-
|
|
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);
|
|
43
145
|
});
|
|
44
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
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { NgIf } from '@angular/common';
|
|
2
|
+
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
|
3
|
+
import { FieldRegistry } from '@dynamic-field-kit/core';
|
|
4
|
+
|
|
5
|
+
// The field types the specs register. Without this augmentation `keyof
|
|
6
|
+
// FieldTypeMap` is `never`, so `registry.register('text', …)` fails to compile
|
|
7
|
+
// once vitest type-checking is enabled. Every spec imports this helper, so the
|
|
8
|
+
// augmentation applies across the whole test compilation.
|
|
9
|
+
declare module '@dynamic-field-kit/core' {
|
|
10
|
+
interface FieldTypeMap {
|
|
11
|
+
text: string;
|
|
12
|
+
number: number;
|
|
13
|
+
defaults: string;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@Component({
|
|
18
|
+
selector: 'dfk-test-text',
|
|
19
|
+
standalone: true,
|
|
20
|
+
imports: [NgIf],
|
|
21
|
+
template: `
|
|
22
|
+
<input
|
|
23
|
+
class="txt"
|
|
24
|
+
[value]="value ?? ''"
|
|
25
|
+
[disabled]="!!disabled"
|
|
26
|
+
[readOnly]="!!readOnly"
|
|
27
|
+
[placeholder]="placeholder ?? ''"
|
|
28
|
+
(input)="valueChange.emit($any($event.target).value)"
|
|
29
|
+
/>
|
|
30
|
+
<span class="err" *ngIf="error">{{ errorText }}</span>
|
|
31
|
+
<span class="hint" *ngIf="hint">{{ hint }}</span>
|
|
32
|
+
`,
|
|
33
|
+
})
|
|
34
|
+
export class TextRendererComponent {
|
|
35
|
+
@Input() value?: unknown;
|
|
36
|
+
@Input() label?: string;
|
|
37
|
+
@Input() placeholder?: string;
|
|
38
|
+
@Input() required?: boolean;
|
|
39
|
+
@Input() disabled?: boolean;
|
|
40
|
+
@Input() readOnly?: boolean;
|
|
41
|
+
@Input() error?: string | string[];
|
|
42
|
+
@Input() options?: unknown[];
|
|
43
|
+
@Input() className?: string;
|
|
44
|
+
@Input() description?: string;
|
|
45
|
+
// Not a FieldRendererProps key: proves extraProps reach the instance.
|
|
46
|
+
@Input() hint?: string;
|
|
47
|
+
|
|
48
|
+
@Output() valueChange = new EventEmitter<unknown>();
|
|
49
|
+
|
|
50
|
+
get errorText(): string {
|
|
51
|
+
return ([] as string[]).concat(this.error ?? []).join(', ');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@Component({
|
|
56
|
+
selector: 'dfk-test-legacy',
|
|
57
|
+
standalone: true,
|
|
58
|
+
template: `<button class="legacy-btn" (click)="onValueChange.emit('legacy')">
|
|
59
|
+
go
|
|
60
|
+
</button>`,
|
|
61
|
+
})
|
|
62
|
+
export class LegacyOutputRendererComponent {
|
|
63
|
+
@Input() value?: unknown;
|
|
64
|
+
// Deliberately the legacy output name, to cover DynamicInput.bindOutputs.
|
|
65
|
+
@Output() onValueChange = new EventEmitter<unknown>();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@Component({
|
|
69
|
+
selector: 'dfk-test-defaults',
|
|
70
|
+
standalone: true,
|
|
71
|
+
template: `<span class="label">{{ label }}</span>`,
|
|
72
|
+
})
|
|
73
|
+
export class DefaultsRendererComponent {
|
|
74
|
+
// Initialized input: proves DynamicInput does not overwrite a renderer's
|
|
75
|
+
// own default with undefined for a prop it was never given.
|
|
76
|
+
@Input() label = 'None';
|
|
77
|
+
@Input() value?: unknown;
|
|
78
|
+
@Output() valueChange = new EventEmitter<unknown>();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function fallbackRenderer(props: Record<string, unknown>): string {
|
|
82
|
+
return `<span class="fallback">${String(props['label'] ?? '')}:${String(
|
|
83
|
+
props['value'] ?? ''
|
|
84
|
+
)}</span>`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function makeRegistry(): FieldRegistry {
|
|
88
|
+
return new FieldRegistry();
|
|
89
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { Component, TemplateRef, ViewChild } from '@angular/core';
|
|
2
|
+
import { TestBed } from '@angular/core/testing';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import {
|
|
5
|
+
ColumnLayout,
|
|
6
|
+
GridLayout,
|
|
7
|
+
RowLayout,
|
|
8
|
+
} from '../src/layout/defaultLayouts';
|
|
9
|
+
import { LayoutRegistry, layoutRegistry } from '../src/layout/layoutRegistry';
|
|
10
|
+
|
|
11
|
+
@Component({
|
|
12
|
+
standalone: true,
|
|
13
|
+
imports: [ColumnLayout, RowLayout, GridLayout],
|
|
14
|
+
template: `
|
|
15
|
+
<ng-template #tpl><span class="child">x</span></ng-template>
|
|
16
|
+
<dfk-column-layout
|
|
17
|
+
[template]="tpl"
|
|
18
|
+
[config]="{ gap: 20 }"
|
|
19
|
+
></dfk-column-layout>
|
|
20
|
+
<dfk-row-layout [template]="tpl"></dfk-row-layout>
|
|
21
|
+
<dfk-grid-layout
|
|
22
|
+
[template]="tpl"
|
|
23
|
+
[config]="{ columns: 3 }"
|
|
24
|
+
></dfk-grid-layout>
|
|
25
|
+
`,
|
|
26
|
+
})
|
|
27
|
+
class LayoutHost {
|
|
28
|
+
@ViewChild('tpl', { static: true }) tpl!: TemplateRef<unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@Component({
|
|
32
|
+
standalone: true,
|
|
33
|
+
imports: [GridLayout],
|
|
34
|
+
template: `
|
|
35
|
+
<ng-template #tpl><span class="child">x</span></ng-template>
|
|
36
|
+
<dfk-grid-layout [template]="tpl"></dfk-grid-layout>
|
|
37
|
+
`,
|
|
38
|
+
})
|
|
39
|
+
class GridDefaultsHost {
|
|
40
|
+
@ViewChild('tpl', { static: true }) tpl!: TemplateRef<unknown>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('LayoutRegistry', () => {
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
vi.restoreAllMocks();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('registers and retrieves a layout', () => {
|
|
49
|
+
const registry = new LayoutRegistry();
|
|
50
|
+
registry.register('custom', ColumnLayout);
|
|
51
|
+
|
|
52
|
+
expect(registry.get('custom')).toBe(ColumnLayout);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('returns undefined for an unknown layout', () => {
|
|
56
|
+
expect(new LayoutRegistry().get('nope')).toBeUndefined();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('warns when a layout type is registered twice', () => {
|
|
60
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
61
|
+
const registry = new LayoutRegistry();
|
|
62
|
+
registry.register('custom', ColumnLayout);
|
|
63
|
+
registry.register('custom', RowLayout);
|
|
64
|
+
|
|
65
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
66
|
+
expect(registry.get('custom')).toBe(RowLayout);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('exports a shared registry instance', () => {
|
|
70
|
+
expect(layoutRegistry).toBeInstanceOf(LayoutRegistry);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('default layouts', () => {
|
|
75
|
+
it('renders the projected template with the configured gap', () => {
|
|
76
|
+
const fixture = TestBed.createComponent(LayoutHost);
|
|
77
|
+
fixture.detectChanges();
|
|
78
|
+
|
|
79
|
+
const column: HTMLElement = fixture.nativeElement.querySelector(
|
|
80
|
+
'dfk-column-layout > div'
|
|
81
|
+
);
|
|
82
|
+
expect(column.style.flexDirection).toBe('column');
|
|
83
|
+
expect(column.style.gap).toBe('20px');
|
|
84
|
+
expect(column.querySelector('.child')).not.toBeNull();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('defaults the gap to 12px', () => {
|
|
88
|
+
const fixture = TestBed.createComponent(LayoutHost);
|
|
89
|
+
fixture.detectChanges();
|
|
90
|
+
|
|
91
|
+
const row: HTMLElement = fixture.nativeElement.querySelector(
|
|
92
|
+
'dfk-row-layout > div'
|
|
93
|
+
);
|
|
94
|
+
expect(row.style.flexDirection).toBe('row');
|
|
95
|
+
expect(row.style.gap).toBe('12px');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('renders the grid layout with the configured column count', () => {
|
|
99
|
+
const fixture = TestBed.createComponent(LayoutHost);
|
|
100
|
+
fixture.detectChanges();
|
|
101
|
+
|
|
102
|
+
const grid: HTMLElement = fixture.nativeElement.querySelector(
|
|
103
|
+
'dfk-grid-layout > div'
|
|
104
|
+
);
|
|
105
|
+
expect(grid.style.display).toBe('grid');
|
|
106
|
+
expect(grid.style.gridTemplateColumns).toBe('repeat(3, 1fr)');
|
|
107
|
+
expect(grid.style.gap).toBe('12px');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('defaults the grid columns to 2', () => {
|
|
111
|
+
const fixture = TestBed.createComponent(GridDefaultsHost);
|
|
112
|
+
fixture.detectChanges();
|
|
113
|
+
|
|
114
|
+
const grid: HTMLElement = fixture.nativeElement.querySelector(
|
|
115
|
+
'dfk-grid-layout > div'
|
|
116
|
+
);
|
|
117
|
+
expect(grid.style.gridTemplateColumns).toBe('repeat(2, 1fr)');
|
|
118
|
+
});
|
|
119
|
+
});
|