@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
|
@@ -16,9 +16,15 @@ import {
|
|
|
16
16
|
canAddGroupItem,
|
|
17
17
|
canRemoveGroupItem,
|
|
18
18
|
createGroupItem,
|
|
19
|
+
resolveDisabled,
|
|
20
|
+
resolveOptions,
|
|
21
|
+
resolveReadOnly,
|
|
22
|
+
validateField,
|
|
23
|
+
validateFields,
|
|
19
24
|
FieldDescription,
|
|
20
25
|
Properties,
|
|
21
26
|
} from '@dynamic-field-kit/core';
|
|
27
|
+
import type { ValidationResult } from '@dynamic-field-kit/core';
|
|
22
28
|
import { BaseLayoutConfig, LayoutConfig } from '../types/layout';
|
|
23
29
|
import { FieldInput } from './FieldInput';
|
|
24
30
|
|
|
@@ -53,6 +59,10 @@ const DEFAULT_BREAKPOINT = 768;
|
|
|
53
59
|
*ngIf="!field.fields"
|
|
54
60
|
[fieldDescription]="field"
|
|
55
61
|
[value]="data[field.name]"
|
|
62
|
+
[options]="getResolvedOptions(field)"
|
|
63
|
+
[disabled]="getDisabled(field)"
|
|
64
|
+
[readOnly]="getReadOnly(field)"
|
|
65
|
+
[error]="getError(field)"
|
|
56
66
|
(onValueChangeField)="onFieldChange($event)"
|
|
57
67
|
></dfk-field-input>
|
|
58
68
|
|
|
@@ -62,7 +72,7 @@ const DEFAULT_BREAKPOINT = 768;
|
|
|
62
72
|
*ngFor="
|
|
63
73
|
let item of getItems(field);
|
|
64
74
|
let i = index;
|
|
65
|
-
trackBy:
|
|
75
|
+
trackBy: groupTrackBy(field)
|
|
66
76
|
"
|
|
67
77
|
style="display: flex; align-items: flex-start; gap: 8px;"
|
|
68
78
|
>
|
|
@@ -70,11 +80,19 @@ const DEFAULT_BREAKPOINT = 768;
|
|
|
70
80
|
<dfk-multi-field-input
|
|
71
81
|
[fieldDescriptions]="field.fields"
|
|
72
82
|
[properties]="item"
|
|
83
|
+
[rootData]="rootData ?? data"
|
|
73
84
|
(onChange)="onGroupItemChange(field, i, $event)"
|
|
74
85
|
></dfk-multi-field-input>
|
|
75
86
|
</div>
|
|
76
87
|
<button
|
|
77
88
|
type="button"
|
|
89
|
+
[attr.aria-label]="
|
|
90
|
+
(field.removeLabel || 'Remove') +
|
|
91
|
+
' ' +
|
|
92
|
+
(field.label || field.name) +
|
|
93
|
+
' ' +
|
|
94
|
+
(i + 1)
|
|
95
|
+
"
|
|
78
96
|
(click)="onGroupItemRemove(field, i)"
|
|
79
97
|
[disabled]="!canRemoveItem(field)"
|
|
80
98
|
>
|
|
@@ -83,6 +101,9 @@ const DEFAULT_BREAKPOINT = 768;
|
|
|
83
101
|
</div>
|
|
84
102
|
<button
|
|
85
103
|
type="button"
|
|
104
|
+
[attr.aria-label]="
|
|
105
|
+
(field.addLabel || 'Add') + ' ' + (field.label || field.name)
|
|
106
|
+
"
|
|
86
107
|
(click)="onGroupItemAdd(field)"
|
|
87
108
|
[disabled]="!canAddItem(field)"
|
|
88
109
|
>
|
|
@@ -97,7 +118,12 @@ export class MultiFieldInput implements OnInit, OnChanges {
|
|
|
97
118
|
@Input() fieldDescriptions: FieldDescription[] = [];
|
|
98
119
|
@Input() properties?: Properties;
|
|
99
120
|
@Output() onChange = new EventEmitter<Properties>();
|
|
121
|
+
@Output() validityChange = new EventEmitter<ValidationResult>();
|
|
100
122
|
@Input() layout: LayoutConfig = 'column';
|
|
123
|
+
// Top-level form data, threaded down through repeatable groups so a nested
|
|
124
|
+
// field's appearCondition/computeValue can read the root form. Omitted at
|
|
125
|
+
// the top level, where the form's own data is the root.
|
|
126
|
+
@Input() rootData?: Properties;
|
|
101
127
|
|
|
102
128
|
data: Properties = {};
|
|
103
129
|
visibleFields: FieldDescription[] = [];
|
|
@@ -150,6 +176,26 @@ export class MultiFieldInput implements OnInit, OnChanges {
|
|
|
150
176
|
return index;
|
|
151
177
|
}
|
|
152
178
|
|
|
179
|
+
private groupTrackByCache = new WeakMap<
|
|
180
|
+
FieldDescription,
|
|
181
|
+
(index: number, item: Properties) => unknown
|
|
182
|
+
>();
|
|
183
|
+
|
|
184
|
+
// Returns a stable trackBy per group field: item[keyField] when configured,
|
|
185
|
+
// else the index. Cached by field reference so the template hands Angular the
|
|
186
|
+
// same function each change-detection pass.
|
|
187
|
+
groupTrackBy(
|
|
188
|
+
field: FieldDescription
|
|
189
|
+
): (index: number, item: Properties) => unknown {
|
|
190
|
+
let fn = this.groupTrackByCache.get(field);
|
|
191
|
+
if (!fn) {
|
|
192
|
+
fn = (index: number, item: Properties) =>
|
|
193
|
+
field.keyField ? item[field.keyField] ?? index : index;
|
|
194
|
+
this.groupTrackByCache.set(field, fn);
|
|
195
|
+
}
|
|
196
|
+
return fn;
|
|
197
|
+
}
|
|
198
|
+
|
|
153
199
|
ngOnInit() {
|
|
154
200
|
this.updateIsMobile();
|
|
155
201
|
this.init();
|
|
@@ -164,22 +210,37 @@ export class MultiFieldInput implements OnInit, OnChanges {
|
|
|
164
210
|
typeof this.layout === 'object' && this.layout.type === 'responsive'
|
|
165
211
|
? this.layout.breakpoint ?? DEFAULT_BREAKPOINT
|
|
166
212
|
: DEFAULT_BREAKPOINT;
|
|
167
|
-
|
|
168
|
-
typeof window !== 'undefined' &&
|
|
213
|
+
if (
|
|
214
|
+
typeof window !== 'undefined' &&
|
|
215
|
+
typeof window.matchMedia === 'function'
|
|
216
|
+
) {
|
|
217
|
+
this.isMobile = window.matchMedia(
|
|
218
|
+
`(max-width: ${breakpoint - 1}px)`
|
|
219
|
+
).matches;
|
|
220
|
+
} else {
|
|
221
|
+
this.isMobile =
|
|
222
|
+
typeof window !== 'undefined' && window.innerWidth < breakpoint;
|
|
223
|
+
}
|
|
169
224
|
}
|
|
170
225
|
|
|
171
226
|
private init() {
|
|
172
227
|
if (this.properties) {
|
|
173
|
-
this.data = applyComputedValues(
|
|
174
|
-
|
|
175
|
-
|
|
228
|
+
this.data = applyComputedValues(
|
|
229
|
+
this.fieldDescriptions,
|
|
230
|
+
{ ...this.properties },
|
|
231
|
+
this.rootData
|
|
232
|
+
);
|
|
176
233
|
}
|
|
177
234
|
this.updateVisibleFields();
|
|
235
|
+
this.validityChange.emit(
|
|
236
|
+
validateFields(this.fieldDescriptions, this.data, this.rootData)
|
|
237
|
+
);
|
|
178
238
|
}
|
|
179
239
|
|
|
180
240
|
private updateVisibleFields() {
|
|
241
|
+
const root = this.rootData ?? this.data;
|
|
181
242
|
this.visibleFields = this.fieldDescriptions.filter(
|
|
182
|
-
(f) => !f.appearCondition || f.appearCondition(this.data)
|
|
243
|
+
(f) => !f.appearCondition || f.appearCondition(this.data, root)
|
|
183
244
|
);
|
|
184
245
|
}
|
|
185
246
|
|
|
@@ -192,6 +253,31 @@ export class MultiFieldInput implements OnInit, OnChanges {
|
|
|
192
253
|
return Array.isArray(value) ? (value as Properties[]) : [];
|
|
193
254
|
}
|
|
194
255
|
|
|
256
|
+
getResolvedOptions(field: FieldDescription): Properties[] | undefined {
|
|
257
|
+
return resolveOptions(field, this.data, this.rootData);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
getDisabled(field: FieldDescription): boolean {
|
|
261
|
+
return resolveDisabled(field, this.data, this.rootData);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
getReadOnly(field: FieldDescription): boolean {
|
|
265
|
+
return resolveReadOnly(field, this.data, this.rootData);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
getError(field: FieldDescription): string[] | undefined {
|
|
269
|
+
if (this.getDisabled(field)) {
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
const errors = validateField(
|
|
273
|
+
field,
|
|
274
|
+
this.data[field.name],
|
|
275
|
+
this.data,
|
|
276
|
+
this.rootData
|
|
277
|
+
);
|
|
278
|
+
return errors.length > 0 ? errors : undefined;
|
|
279
|
+
}
|
|
280
|
+
|
|
195
281
|
canAddItem(field: FieldDescription): boolean {
|
|
196
282
|
return canAddGroupItem(field, this.getItems(field));
|
|
197
283
|
}
|
|
@@ -230,9 +316,16 @@ export class MultiFieldInput implements OnInit, OnChanges {
|
|
|
230
316
|
}
|
|
231
317
|
|
|
232
318
|
private commitData(nextData: Properties): void {
|
|
233
|
-
this.data = applyComputedValues(
|
|
319
|
+
this.data = applyComputedValues(
|
|
320
|
+
this.fieldDescriptions,
|
|
321
|
+
nextData,
|
|
322
|
+
this.rootData
|
|
323
|
+
);
|
|
234
324
|
this.updateVisibleFields();
|
|
235
325
|
this.onChange.emit(this.data);
|
|
326
|
+
this.validityChange.emit(
|
|
327
|
+
validateFields(this.fieldDescriptions, this.data, this.rootData)
|
|
328
|
+
);
|
|
236
329
|
this.cdr.markForCheck();
|
|
237
330
|
}
|
|
238
331
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { InjectionToken } from '@angular/core';
|
|
2
|
+
import { FieldRegistry, fieldRegistry } from '@dynamic-field-kit/core';
|
|
3
|
+
|
|
4
|
+
// Injecting this token yields the process-wide singleton by default, so code
|
|
5
|
+
// that never provides it keeps working. Provide it on a component/route to give
|
|
6
|
+
// that subtree an isolated set of renderers:
|
|
7
|
+
//
|
|
8
|
+
// providers: [{ provide: FIELD_REGISTRY, useValue: myScopedRegistry }]
|
|
9
|
+
export const FIELD_REGISTRY = new InjectionToken<FieldRegistry>(
|
|
10
|
+
'dfk.FieldRegistry',
|
|
11
|
+
{
|
|
12
|
+
providedIn: 'root',
|
|
13
|
+
factory: () => fieldRegistry,
|
|
14
|
+
}
|
|
15
|
+
);
|
package/src/public-api.ts
CHANGED
|
@@ -1,24 +1,40 @@
|
|
|
1
|
-
// public-api.ts
|
|
2
|
-
|
|
3
|
-
// Components
|
|
4
|
-
export * from './components/BaseInput';
|
|
5
|
-
export * from './components/DynamicInput';
|
|
6
|
-
export * from './components/FieldInput';
|
|
7
|
-
export * from './components/MultiFieldInput';
|
|
8
|
-
|
|
9
|
-
// Layout
|
|
10
|
-
export * from './layout';
|
|
11
|
-
export * from './types/layout';
|
|
12
|
-
|
|
13
|
-
// Module
|
|
14
|
-
export * from './lib/dynamic-field-kit.module';
|
|
15
|
-
|
|
16
|
-
// Re-export types from core (only type, not export * )
|
|
17
|
-
export type {
|
|
18
|
-
FieldDescription,
|
|
19
|
-
FieldRendererProps,
|
|
20
|
-
FieldTypeKey,
|
|
21
|
-
} from '@dynamic-field-kit/core';
|
|
22
|
-
|
|
23
|
-
// Optional: expose registry for advanced use cases, but not required for basic usage
|
|
24
|
-
export { fieldRegistry } from '@dynamic-field-kit/core';
|
|
1
|
+
// public-api.ts
|
|
2
|
+
|
|
3
|
+
// Components
|
|
4
|
+
export * from './components/BaseInput';
|
|
5
|
+
export * from './components/DynamicInput';
|
|
6
|
+
export * from './components/FieldInput';
|
|
7
|
+
export * from './components/MultiFieldInput';
|
|
8
|
+
|
|
9
|
+
// Layout
|
|
10
|
+
export * from './layout';
|
|
11
|
+
export * from './types/layout';
|
|
12
|
+
|
|
13
|
+
// Module
|
|
14
|
+
export * from './lib/dynamic-field-kit.module';
|
|
15
|
+
|
|
16
|
+
// Re-export types from core (only type, not export * )
|
|
17
|
+
export type {
|
|
18
|
+
FieldDescription,
|
|
19
|
+
FieldRendererProps,
|
|
20
|
+
FieldTypeKey,
|
|
21
|
+
} from '@dynamic-field-kit/core';
|
|
22
|
+
|
|
23
|
+
// Optional: expose registry for advanced use cases, but not required for basic usage
|
|
24
|
+
export { fieldRegistry, FieldRegistry } from '@dynamic-field-kit/core';
|
|
25
|
+
|
|
26
|
+
export {
|
|
27
|
+
validateField,
|
|
28
|
+
validateFieldAsync,
|
|
29
|
+
validateFields,
|
|
30
|
+
validateFieldsAsync,
|
|
31
|
+
resolveDisabled,
|
|
32
|
+
resolveReadOnly,
|
|
33
|
+
resolveOptions,
|
|
34
|
+
validators,
|
|
35
|
+
} from '@dynamic-field-kit/core';
|
|
36
|
+
export type { ValidationResult } from '@dynamic-field-kit/core';
|
|
37
|
+
|
|
38
|
+
// Scoped registry: provide FIELD_REGISTRY on a component/route to give that
|
|
39
|
+
// subtree an isolated set of renderers.
|
|
40
|
+
export { FIELD_REGISTRY } from './fieldRegistryToken';
|
package/src/types/layout.ts
CHANGED
|
@@ -1,36 +1,14 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
export interface ColumnLayoutConfig {
|
|
16
|
-
type: 'column';
|
|
17
|
-
gap?: number;
|
|
18
|
-
width?: string;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export type BaseLayoutConfig =
|
|
22
|
-
| 'column'
|
|
23
|
-
| 'row'
|
|
24
|
-
| 'grid'
|
|
25
|
-
| GridLayoutConfig
|
|
26
|
-
| RowLayoutConfig
|
|
27
|
-
| ColumnLayoutConfig;
|
|
28
|
-
|
|
29
|
-
export interface ResponsiveLayoutConfig {
|
|
30
|
-
type: 'responsive';
|
|
31
|
-
mobile: BaseLayoutConfig;
|
|
32
|
-
desktop: BaseLayoutConfig;
|
|
33
|
-
breakpoint?: number;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export type LayoutConfig = BaseLayoutConfig | ResponsiveLayoutConfig;
|
|
1
|
+
// Layout config types live in @dynamic-field-kit/core so the contract stays
|
|
2
|
+
// identical across every framework adapter. Re-exported here (with the
|
|
3
|
+
// historical Angular ...Config aliases) to preserve existing import paths.
|
|
4
|
+
import type { BaseLayout, ResponsiveLayout } from '@dynamic-field-kit/core';
|
|
5
|
+
|
|
6
|
+
export type {
|
|
7
|
+
ColumnLayoutConfig,
|
|
8
|
+
RowLayoutConfig,
|
|
9
|
+
GridLayoutConfig,
|
|
10
|
+
LayoutConfig,
|
|
11
|
+
} from '@dynamic-field-kit/core';
|
|
12
|
+
|
|
13
|
+
export type BaseLayoutConfig = BaseLayout;
|
|
14
|
+
export type ResponsiveLayoutConfig = ResponsiveLayout;
|
|
@@ -1,30 +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
|
+
|
|
1
14
|
describe('DynamicInput', () => {
|
|
2
|
-
|
|
3
|
-
|
|
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);
|
|
4
66
|
});
|
|
5
67
|
|
|
6
|
-
it('
|
|
7
|
-
|
|
8
|
-
|
|
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
|
+
);
|
|
9
79
|
});
|
|
10
80
|
|
|
11
|
-
it('
|
|
12
|
-
|
|
13
|
-
|
|
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');
|
|
14
95
|
});
|
|
15
96
|
|
|
16
|
-
it('
|
|
17
|
-
|
|
18
|
-
|
|
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']);
|
|
19
116
|
});
|
|
20
117
|
|
|
21
|
-
it('
|
|
22
|
-
|
|
23
|
-
|
|
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']);
|
|
24
131
|
});
|
|
25
132
|
|
|
26
|
-
it('
|
|
27
|
-
|
|
28
|
-
|
|
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([]);
|
|
29
229
|
});
|
|
30
230
|
});
|