@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +113 -4
  3. package/dist/README.md +113 -4
  4. package/dist/components/BaseInput.d.ts +23 -11
  5. package/dist/components/DynamicInput.d.ts +19 -8
  6. package/dist/components/FieldInput.d.ts +15 -7
  7. package/dist/components/MultiFieldInput.d.ts +30 -4
  8. package/dist/esm2022/components/BaseInput.mjs +17 -18
  9. package/dist/esm2022/components/DynamicInput.mjs +166 -84
  10. package/dist/esm2022/components/FieldInput.mjs +54 -26
  11. package/dist/esm2022/components/MultiFieldInput.mjs +282 -29
  12. package/dist/esm2022/fieldRegistryToken.mjs +12 -0
  13. package/dist/esm2022/layout/defaultLayouts.mjs +70 -23
  14. package/dist/esm2022/layout/index.mjs +2 -1
  15. package/dist/esm2022/layout/layoutRegistry.mjs +14 -0
  16. package/dist/esm2022/public-api.mjs +6 -2
  17. package/dist/esm2022/types/layout.mjs +1 -1
  18. package/dist/fesm2022/dynamic-field-kit-angular.mjs +604 -173
  19. package/dist/fesm2022/dynamic-field-kit-angular.mjs.map +1 -1
  20. package/dist/fieldRegistryToken.d.ts +3 -0
  21. package/dist/layout/defaultLayouts.d.ts +21 -3
  22. package/dist/layout/index.d.ts +1 -0
  23. package/dist/layout/layoutRegistry.d.ts +12 -0
  24. package/dist/public-api.d.ts +5 -2
  25. package/dist/types/layout.d.ts +4 -1
  26. package/package.json +19 -8
  27. package/src/components/BaseInput.ts +36 -10
  28. package/src/components/DynamicInput.ts +196 -104
  29. package/src/components/FieldInput.ts +43 -18
  30. package/src/components/MultiFieldInput.ts +275 -19
  31. package/src/fieldRegistryToken.ts +15 -0
  32. package/src/layout/defaultLayouts.ts +42 -10
  33. package/src/layout/index.ts +1 -0
  34. package/src/layout/layoutRegistry.ts +25 -0
  35. package/src/public-api.ts +40 -24
  36. package/src/types/layout.ts +14 -1
  37. package/test/DynamicInput.spec.ts +230 -0
  38. package/test/FieldInput.spec.ts +146 -0
  39. package/test/MultiFieldInput.spec.ts +256 -0
  40. package/test/helpers/renderers.ts +89 -0
  41. package/test/layout.spec.ts +119 -0
  42. package/test/publicApi.spec.ts +64 -0
  43. package/test/setup.ts +12 -0
  44. package/test/smoke.spec.ts +27 -0
  45. package/tsconfig.json +13 -13
  46. package/tsconfig.spec.json +9 -0
  47. package/vitest.config.ts +30 -0
  48. package/src/adapters/tailwind.ts +0 -13
  49. package/src/components/imports.ts +0 -3
  50. package/src/examples/index.ts +0 -4
  51. package/src/examples/registerExample.ts +0 -7
  52. package/src/examples/text-field.component.ts +0 -18
  53. package/src/types.ts +0 -1
@@ -1,27 +1,45 @@
1
1
  import { CommonModule } from '@angular/common';
2
2
  import {
3
+ AfterViewInit,
4
+ ChangeDetectionStrategy,
3
5
  Component,
4
6
  ComponentRef,
5
- Input,
6
- Output,
7
7
  EventEmitter,
8
- ViewChild,
9
- ViewContainerRef,
8
+ inject,
9
+ Input,
10
10
  OnChanges,
11
- AfterViewInit,
12
11
  OnDestroy,
12
+ Output,
13
13
  SimpleChanges,
14
14
  Type,
15
- SimpleChange,
15
+ ViewChild,
16
+ ViewContainerRef,
16
17
  } from '@angular/core';
17
- import { fieldRegistry, FieldTypeKey } from '@dynamic-field-kit/core';
18
+ import { FieldTypeKey, Properties } from '@dynamic-field-kit/core';
18
19
  import { Subscription } from 'rxjs';
20
+ import { FIELD_REGISTRY } from '../fieldRegistryToken';
19
21
  import { BaseInputComponent } from './BaseInput';
20
22
 
23
+ // Only the framework-agnostic FieldRendererProps keys. Domain-specific props
24
+ // reach the renderer through `extraProps` (FieldDescription.props) instead.
25
+ const KNOWN_PROPS = [
26
+ 'value',
27
+ 'label',
28
+ 'placeholder',
29
+ 'required',
30
+ 'disabled',
31
+ 'readOnly',
32
+ 'error',
33
+ 'options',
34
+ 'className',
35
+ 'description',
36
+ ] as const;
37
+
21
38
  @Component({
22
39
  selector: 'dfk-dynamic-input',
23
40
  standalone: true,
24
41
  imports: [CommonModule],
42
+ changeDetection: ChangeDetectionStrategy.OnPush,
25
43
  template: `<div #host style="display: contents;"></div>`,
26
44
  })
27
45
  export class DynamicInput
@@ -29,160 +47,234 @@ export class DynamicInput
29
47
  implements OnChanges, AfterViewInit, OnDestroy
30
48
  {
31
49
  @Input() type!: FieldTypeKey;
32
- @Output() override valueChange = new EventEmitter<any>();
33
- @Output() onChange = new EventEmitter<any>(); // backward compat
50
+ // Extra, framework-agnostic props forwarded verbatim to the renderer.
51
+ @Input() extraProps?: Properties;
52
+ @Output() override valueChange = new EventEmitter<unknown>();
53
+ @Output() onChange = new EventEmitter<unknown>();
54
+
55
+ private registry = inject(FIELD_REGISTRY);
34
56
 
35
57
  @ViewChild('host', { read: ViewContainerRef, static: false })
36
58
  host!: ViewContainerRef;
37
- private compRef?: ComponentRef<any>;
38
- private inputInstance?: any;
59
+ private compRef?: ComponentRef<unknown>;
60
+ private inputInstance?: unknown;
39
61
  private subscriptions: Subscription[] = [];
62
+ // Tracks which KNOWN_PROPS were actually supplied to DynamicInput (via
63
+ // SimpleChanges), independent of TS class-field emit. See applyProps.
64
+ private supplied = new Set<string>();
40
65
 
41
- ngOnChanges(changes: SimpleChanges) {
42
- if (changes['type'] && !changes['type'].firstChange && this.host) {
43
- this.render();
44
- return;
66
+ ngOnChanges(changes: SimpleChanges): void {
67
+ super.ngOnChanges(changes);
68
+
69
+ for (const prop of KNOWN_PROPS) {
70
+ if (changes[prop]) {
71
+ this.supplied.add(prop);
72
+ }
45
73
  }
46
74
 
47
75
  if (this.inputInstance) {
48
- this.applyKnownProps(this.inputInstance);
49
-
50
- const childChanges: SimpleChanges = {};
51
- for (const prop in changes) {
52
- childChanges[prop] = new SimpleChange(
53
- changes[prop].previousValue,
54
- changes[prop].currentValue,
55
- changes[prop].firstChange
56
- );
57
- }
76
+ this.syncPropsToInstance(changes);
77
+ }
58
78
 
59
- this.inputInstance.ngOnChanges?.(childChanges);
60
- this.compRef?.changeDetectorRef.detectChanges();
61
- } else if (this.host) {
79
+ if (changes['type'] && !changes['type'].firstChange && this.host) {
80
+ this.render();
81
+ } else if (!this.inputInstance && this.host) {
62
82
  this.render();
63
83
  }
64
84
  }
65
85
 
66
- ngAfterViewInit() {
86
+ ngAfterViewInit(): void {
67
87
  this.render();
68
88
  }
69
89
 
70
- ngOnDestroy() {
90
+ ngOnDestroy(): void {
91
+ this.cleanup();
92
+ }
93
+
94
+ private syncPropsToInstance(changes: SimpleChanges): void {
95
+ if (!this.inputInstance) {
96
+ return;
97
+ }
98
+
99
+ for (const prop of KNOWN_PROPS) {
100
+ if (changes[prop] && this.inputInstance) {
101
+ (this.inputInstance as Record<string, unknown>)[prop] = (
102
+ this as Record<string, unknown>
103
+ )[prop];
104
+ }
105
+ }
106
+ if (changes['extraProps']) {
107
+ this.applyExtraProps(this.inputInstance);
108
+ }
109
+ this.compRef?.changeDetectorRef?.detectChanges();
110
+ }
111
+
112
+ private cleanup(): void {
71
113
  this.cleanupSubscriptions();
72
114
  this.cleanupRenderedComponent();
73
115
  }
74
116
 
75
- private cleanupSubscriptions() {
117
+ private cleanupSubscriptions(): void {
76
118
  this.subscriptions.forEach((s) => s.unsubscribe());
77
119
  this.subscriptions = [];
78
120
  }
79
121
 
80
- private render() {
81
- const Renderer = fieldRegistry.get(this.type as FieldTypeKey);
82
- this.cleanupRenderedComponent();
83
- this.cleanupSubscriptions();
122
+ private cleanupRenderedComponent(): void {
123
+ this.compRef?.destroy();
124
+ this.compRef = undefined;
125
+ this.inputInstance = undefined;
126
+ }
127
+
128
+ private render(): void {
129
+ const Renderer = this.registry.get(this.type);
130
+ this.cleanup();
84
131
  this.host.clear();
132
+
85
133
  if (!Renderer) {
86
- const el = document.createElement('div');
87
- el.textContent = `Unknown field type: ${this.type}`;
88
- this.host.element.nativeElement.appendChild(el);
134
+ this.renderError(`Unknown field type: ${this.type}`);
89
135
  return;
90
136
  }
91
137
 
138
+ if (this.isComponentType(Renderer)) {
139
+ this.renderComponent(Renderer as unknown as Type<unknown>);
140
+ } else if (typeof Renderer === 'function') {
141
+ this.renderFallback(Renderer);
142
+ } else {
143
+ this.renderError(`Invalid renderer for type: ${this.type}`);
144
+ }
145
+ }
146
+
147
+ // Angular component classes carry a static ɵcmp. Checked instead of
148
+ // reflectComponentType() because the peer range starts at Angular 13 and
149
+ // reflectComponentType is v14+. Plain function renderers have no ɵcmp and
150
+ // fall through to renderFallback. Uses hasOwnProperty (not `in`) so a
151
+ // class that merely extends a component without its own @Component
152
+ // decorator - and would otherwise inherit the parent's static ɵcmp via the
153
+ // prototype chain - is not mistaken for a component in its own right.
154
+ private isComponentType(renderer: unknown): boolean {
155
+ return (
156
+ typeof renderer === 'function' &&
157
+ Object.prototype.hasOwnProperty.call(renderer, 'ɵcmp')
158
+ );
159
+ }
160
+
161
+ private renderComponent(compType: Type<unknown>): void {
92
162
  try {
93
- const compType = Renderer as unknown as Type<any>;
94
163
  const compRef = this.host.createComponent(compType);
95
164
  const instance = compRef.instance;
165
+
96
166
  if (!instance) {
97
- throw new Error(`Failed to create instance for ${this.type}`);
167
+ this.renderError(`Failed to create instance for ${this.type}`);
168
+ return;
98
169
  }
99
170
 
100
- this.applyKnownProps(instance);
171
+ this.applyProps(instance);
172
+ this.bindOutputs(instance);
101
173
 
102
- const subA = this.bindOutput(instance, 'valueChange');
103
- if (subA) {
104
- this.subscriptions.push(subA);
105
- }
106
- const subB = this.bindOutput(instance, 'onValueChange');
107
- if (subB) {
108
- this.subscriptions.push(subB);
109
- }
110
-
111
- instance.changeDetectorRef?.detectChanges();
112
174
  this.compRef = compRef;
113
175
  this.inputInstance = instance;
114
176
  compRef.changeDetectorRef.detectChanges();
115
- } catch (err) {
116
- // Fallback to function renderer
117
- try {
118
- const props: any = {
119
- value: this.value,
120
- onValueChange: (v: any) => this.emitValue(v),
121
- label: this.label,
122
- placeholder: this.placeholder,
123
- required: this.required,
124
- options: this.options,
125
- className: this.className,
126
- description: this.description,
127
- disabled: this.disabled,
128
- errorMessage: this.errorMessage,
129
- };
130
- const out = (Renderer as (props: unknown) => unknown)(props);
131
- if (typeof out === 'string') {
132
- const el = document.createElement('div');
133
- el.innerHTML = out;
134
- this.host.element.nativeElement.appendChild(el);
135
- }
136
- } catch (e) {
177
+ } catch {
178
+ this.renderError(`Failed to render field: ${this.type}`);
179
+ }
180
+ }
181
+
182
+ private renderFallback(renderer: unknown): void {
183
+ try {
184
+ const props = this.getFallbackProps();
185
+ const result = (renderer as (props: unknown) => string)(props);
186
+
187
+ if (typeof result === 'string') {
137
188
  const el = document.createElement('div');
138
- el.textContent = `Failed to render field: ${this.type}`;
189
+ el.innerHTML = result;
139
190
  this.host.element.nativeElement.appendChild(el);
140
191
  }
192
+ } catch {
193
+ this.renderError(`Failed to render field: ${this.type}`);
141
194
  }
142
195
  }
143
196
 
144
- private applyKnownProps(instance: any) {
145
- const knownProps = [
146
- 'value',
147
- 'label',
148
- 'placeholder',
149
- 'required',
150
- 'disabled',
151
- 'options',
152
- 'className',
153
- 'description',
154
- 'errorMessage',
155
- ];
156
- for (const prop of knownProps) {
157
- if (prop in this && prop in instance) {
158
- instance[prop] = (this as any)[prop];
197
+ private getFallbackProps(): Record<string, unknown> {
198
+ return {
199
+ ...this.extraProps,
200
+ value: this.value,
201
+ onValueChange: (v: unknown) => this.emitValue(v),
202
+ label: this.label ?? '',
203
+ placeholder: this.placeholder ?? '',
204
+ required: this.required ?? false,
205
+ disabled: this.disabled ?? false,
206
+ readOnly: this.readOnly ?? false,
207
+ error: this.error,
208
+ options: this.options ?? [],
209
+ className: this.className ?? '',
210
+ description: this.description ?? '',
211
+ };
212
+ }
213
+
214
+ // Gates on `this.supplied` (populated from SimpleChanges in ngOnChanges),
215
+ // not on `prop in instanceObj` or `prop in this`. Both `in` checks test a
216
+ // TypeScript class-field emit artifact rather than intent: whether a
217
+ // property slot exists at runtime depends on the compile target
218
+ // (useDefineForClassFields is false below ES2022, true at ES2022+), so the
219
+ // same source can behave oppositely between this package's test build and
220
+ // its shipped ES2022 bundle, where every declared field is always an own
221
+ // property. `this.supplied` instead reflects only whether Angular ever
222
+ // fired a SimpleChange for that input, i.e. whether the prop was *bound*
223
+ // at all - it skips only props DynamicInput was never given a binding
224
+ // for. It is NOT a general "renderer defaults survive" guarantee: Angular
225
+ // fires a firstChange SimpleChange for every bound input even when the
226
+ // bound value is undefined, and FieldInput's template binds all
227
+ // KNOWN_PROPS unconditionally, so on the real
228
+ // MultiFieldInput -> FieldInput -> DynamicInput path every prop counts as
229
+ // supplied and renderer-side defaults (e.g. `@Input() label = 'None'`)
230
+ // are still overwritten with undefined. This only helps when DynamicInput
231
+ // is mounted directly with some inputs left unbound.
232
+ private applyProps(instance: unknown): void {
233
+ const instanceObj = instance as Record<string, unknown>;
234
+ for (const prop of KNOWN_PROPS) {
235
+ if (this.supplied.has(prop)) {
236
+ instanceObj[prop] = (this as Record<string, unknown>)[prop];
159
237
  }
160
238
  }
239
+ this.applyExtraProps(instance);
161
240
  }
162
241
 
163
- private bindOutput(
164
- instance: any,
165
- outputName: 'valueChange' | 'onValueChange'
166
- ): Subscription | undefined {
167
- const output = instance?.[outputName];
168
- if (!output || typeof output.subscribe !== 'function') {
242
+ private applyExtraProps(instance: unknown): void {
243
+ if (!instance || !this.extraProps) {
169
244
  return;
170
245
  }
246
+ const instanceObj = instance as Record<string, unknown>;
247
+ for (const [key, value] of Object.entries(this.extraProps)) {
248
+ instanceObj[key] = value;
249
+ }
250
+ }
251
+
252
+ private bindOutputs(instance: unknown): void {
253
+ const outputNames: Array<'valueChange' | 'onValueChange'> = [
254
+ 'valueChange',
255
+ 'onValueChange',
256
+ ];
171
257
 
172
- const sub = (output as EventEmitter<any>).subscribe((value) => {
173
- this.emitValue(value);
174
- });
175
- return sub;
258
+ for (const outputName of outputNames) {
259
+ const output = (instance as Record<string, unknown>)[outputName];
260
+ if (output && typeof output === 'object' && 'subscribe' in output) {
261
+ const sub = (
262
+ output as { subscribe: (cb: (v: unknown) => void) => Subscription }
263
+ ).subscribe((value: unknown) => this.emitValue(value));
264
+ this.subscriptions.push(sub);
265
+ }
266
+ }
176
267
  }
177
268
 
178
- private emitValue(value: any) {
269
+ private emitValue(value: unknown): void {
179
270
  this.valueChange.emit(value);
180
271
  this.onChange.emit(value);
181
272
  }
182
273
 
183
- private cleanupRenderedComponent() {
184
- this.compRef?.destroy();
185
- this.compRef = undefined;
186
- this.inputInstance = undefined;
274
+ private renderError(message: string): void {
275
+ const el = document.createElement('div');
276
+ el.textContent = message;
277
+ el.style.color = 'red';
278
+ this.host.element.nativeElement.appendChild(el);
187
279
  }
188
280
  }
@@ -1,49 +1,74 @@
1
1
  import { NgIf } from '@angular/common';
2
- import { Component, Input, Output, EventEmitter } from '@angular/core';
3
- import { FieldDescription, Properties } from '@dynamic-field-kit/core';
2
+ import {
3
+ ChangeDetectionStrategy,
4
+ ChangeDetectorRef,
5
+ Component,
6
+ EventEmitter,
7
+ Input,
8
+ OnChanges,
9
+ Output,
10
+ SimpleChanges,
11
+ } from '@angular/core';
12
+ import { FieldDescription } from '@dynamic-field-kit/core';
4
13
  import { DynamicInput } from './DynamicInput';
5
14
 
6
15
  @Component({
7
16
  selector: 'dfk-field-input',
8
17
  standalone: true,
9
18
  imports: [NgIf, DynamicInput],
19
+ changeDetection: ChangeDetectionStrategy.OnPush,
10
20
  template: `
11
21
  <dfk-dynamic-input
12
- *ngIf="fieldDescription && renderInfos"
22
+ *ngIf="shouldRender"
13
23
  [type]="fieldDescription!.type"
14
- [value]="getFieldValue()"
24
+ [value]="value"
15
25
  [label]="fieldDescription!.label"
16
26
  [placeholder]="fieldDescription!.placeholder"
17
27
  [required]="fieldDescription!.required"
18
- [description]="fieldDescription!.description"
19
- [options]="fieldDescription!.options"
28
+ [description]="$any(fieldDescription!.description)"
29
+ [options]="resolvedOptions"
20
30
  [className]="fieldDescription!.className"
21
31
  (valueChange)="
22
32
  onValueChangeField.emit({ value: $event, key: fieldDescription!.name })
23
33
  "
24
- [disabled]="false"
25
- [errorMessage]="''"
34
+ [disabled]="disabled"
35
+ [readOnly]="readOnly"
36
+ [error]="$any(error)"
37
+ [extraProps]="fieldDescription!.props"
26
38
  ></dfk-dynamic-input>
27
39
  `,
28
40
  })
29
- export class FieldInput {
41
+ export class FieldInput implements OnChanges {
30
42
  @Input() fieldDescription?: FieldDescription;
31
- @Input() renderInfos?: Properties;
43
+ @Input() value?: unknown;
44
+ @Input() options?: Record<string, unknown>[];
45
+ @Input() disabled?: boolean;
46
+ @Input() readOnly?: boolean;
47
+ @Input() error?: string | string[];
32
48
  @Output() onValueChangeField = new EventEmitter<{
33
- value: any;
49
+ value: unknown;
34
50
  key: string;
35
51
  }>();
36
52
 
37
- getFieldValue() {
38
- if (!this.fieldDescription || !this.renderInfos) {
53
+ shouldRender = false;
54
+
55
+ get resolvedOptions(): Record<string, unknown>[] | undefined {
56
+ if (this.options) {
57
+ return this.options;
58
+ }
59
+ if (!this.fieldDescription?.options) {
39
60
  return undefined;
40
61
  }
41
-
42
- const value = this.renderInfos[this.fieldDescription.name];
43
- if (value === undefined && this.fieldDescription.type === 'text') {
44
- return '';
62
+ if (typeof this.fieldDescription.options === 'function') {
63
+ return undefined;
45
64
  }
65
+ return this.fieldDescription.options;
66
+ }
67
+
68
+ constructor(private cdr: ChangeDetectorRef) {}
46
69
 
47
- return value;
70
+ ngOnChanges(_changes: SimpleChanges): void {
71
+ this.shouldRender = !!this.fieldDescription;
72
+ this.cdr.markForCheck();
48
73
  }
49
74
  }