@dotjem/angular-dynamic-components 0.0.1
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/.editorconfig +17 -0
- package/.github/workflows/ci-publish.yml +113 -0
- package/.prettierrc +12 -0
- package/.vscode/extensions.json +4 -0
- package/.vscode/launch.json +20 -0
- package/.vscode/mcp.json +9 -0
- package/.vscode/tasks.json +42 -0
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/angular-dynamic-components-0.0.0.tgz +0 -0
- package/angular.json +102 -0
- package/dotjem-angular-dynamic-components-0.0.1.tgz +0 -0
- package/package.json +34 -0
- package/projects/demo/public/favicon.ico +0 -0
- package/projects/demo/src/app/app.config.ts +30 -0
- package/projects/demo/src/app/app.html +41 -0
- package/projects/demo/src/app/app.routes.ts +3 -0
- package/projects/demo/src/app/app.scss +0 -0
- package/projects/demo/src/app/app.spec.ts +27 -0
- package/projects/demo/src/app/app.ts +117 -0
- package/projects/demo/src/app/components/search-text-field.component.ts +49 -0
- package/projects/demo/src/app/components/section-heading.component.ts +25 -0
- package/projects/demo/src/app/components/select-field.component.ts +56 -0
- package/projects/demo/src/app/components/text-field.component.ts +53 -0
- package/projects/demo/src/index.html +13 -0
- package/projects/demo/src/main.ts +5 -0
- package/projects/demo/src/styles.scss +1 -0
- package/projects/demo/tsconfig.app.json +11 -0
- package/projects/demo/tsconfig.spec.json +10 -0
- package/projects/dotjem/angular-dynamic-components/README.md +5 -0
- package/projects/dotjem/angular-dynamic-components/ng-package.json +7 -0
- package/projects/dotjem/angular-dynamic-components/package.json +27 -0
- package/projects/dotjem/angular-dynamic-components/src/lib/component-registry.spec.ts +127 -0
- package/projects/dotjem/angular-dynamic-components/src/lib/component-registry.ts +137 -0
- package/projects/dotjem/angular-dynamic-components/src/lib/dynamic-component-host.component.spec.ts +151 -0
- package/projects/dotjem/angular-dynamic-components/src/lib/dynamic-component-host.component.ts +135 -0
- package/projects/dotjem/angular-dynamic-components/src/public-api.ts +6 -0
- package/projects/dotjem/angular-dynamic-components/tsconfig.lib.json +13 -0
- package/projects/dotjem/angular-dynamic-components/tsconfig.lib.prod.json +11 -0
- package/projects/dotjem/angular-dynamic-components/tsconfig.spec.json +10 -0
- package/tsconfig.json +42 -0
package/projects/dotjem/angular-dynamic-components/src/lib/dynamic-component-host.component.spec.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChangeDetectionStrategy,
|
|
3
|
+
Component,
|
|
4
|
+
input,
|
|
5
|
+
output,
|
|
6
|
+
} from '@angular/core';
|
|
7
|
+
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
8
|
+
import { By } from '@angular/platform-browser';
|
|
9
|
+
import { vi } from 'vitest';
|
|
10
|
+
import { DynamicComponentHost } from './dynamic-component-host.component';
|
|
11
|
+
import { ComponentRegistry } from './component-registry';
|
|
12
|
+
|
|
13
|
+
@Component({
|
|
14
|
+
selector: 'dx-stub-label',
|
|
15
|
+
template: `<span>{{ config()?.text }}</span>`,
|
|
16
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
17
|
+
})
|
|
18
|
+
class StubLabelComponent {
|
|
19
|
+
readonly config = input<{ text: string } | null>(null);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@Component({
|
|
23
|
+
selector: 'dx-stub-button',
|
|
24
|
+
template: `<button>{{ config()?.label }}</button>`,
|
|
25
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
26
|
+
})
|
|
27
|
+
class StubButtonComponent {
|
|
28
|
+
readonly config = input<{ label: string } | null>(null);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@Component({
|
|
32
|
+
selector: 'dx-stub-data-field',
|
|
33
|
+
template: `
|
|
34
|
+
<span>{{ data()?.value }}|{{ config()?.hint }}</span>
|
|
35
|
+
<button class="emit-data" type="button" (click)="emitChange()">Emit</button>
|
|
36
|
+
`,
|
|
37
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
38
|
+
})
|
|
39
|
+
class StubDataFieldComponent {
|
|
40
|
+
readonly data = input<{ value: string } | null>(null);
|
|
41
|
+
readonly config = input<{ hint: string } | null>(null);
|
|
42
|
+
readonly dataChange = output<{ value: string }>();
|
|
43
|
+
|
|
44
|
+
emitChange(): void {
|
|
45
|
+
this.dataChange.emit({ value: 'changed' });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('DynamicComponentHost', () => {
|
|
50
|
+
let fixture: ComponentFixture<DynamicComponentHost>;
|
|
51
|
+
let registry: ComponentRegistry;
|
|
52
|
+
|
|
53
|
+
beforeEach(async () => {
|
|
54
|
+
await TestBed.configureTestingModule({
|
|
55
|
+
imports: [DynamicComponentHost],
|
|
56
|
+
providers: [ComponentRegistry],
|
|
57
|
+
}).compileComponents();
|
|
58
|
+
|
|
59
|
+
registry = TestBed.inject(ComponentRegistry);
|
|
60
|
+
registry.register('stub-label', StubLabelComponent, 'editor');
|
|
61
|
+
registry.register('stub-label', StubButtonComponent, 'search');
|
|
62
|
+
registry.register('stub-button', StubButtonComponent, 'search');
|
|
63
|
+
registry.register('stub-data', StubDataFieldComponent, 'editor');
|
|
64
|
+
|
|
65
|
+
fixture = TestBed.createComponent(DynamicComponentHost);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should create the host component', () => {
|
|
69
|
+
fixture.componentRef.setInput('registry', 'editor');
|
|
70
|
+
fixture.componentRef.setInput('component', 'stub-label');
|
|
71
|
+
fixture.componentRef.setInput('config', { text: 'Hello' });
|
|
72
|
+
fixture.detectChanges();
|
|
73
|
+
expect(fixture.componentInstance).toBeTruthy();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('renders the registered component from a component key', () => {
|
|
77
|
+
fixture.componentRef.setInput('registry', 'editor');
|
|
78
|
+
fixture.componentRef.setInput('component', 'stub-label');
|
|
79
|
+
fixture.componentRef.setInput('config', { text: 'Hello' });
|
|
80
|
+
fixture.detectChanges();
|
|
81
|
+
|
|
82
|
+
const span = fixture.debugElement.query(By.css('span'));
|
|
83
|
+
expect(span).toBeTruthy();
|
|
84
|
+
expect(span.nativeElement.textContent).toContain('Hello');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('replaces the rendered component when component key changes', () => {
|
|
88
|
+
fixture.componentRef.setInput('registry', 'editor');
|
|
89
|
+
fixture.componentRef.setInput('component', 'stub-label');
|
|
90
|
+
fixture.componentRef.setInput('config', { text: 'Hello' });
|
|
91
|
+
fixture.detectChanges();
|
|
92
|
+
expect(fixture.debugElement.query(By.css('span'))).toBeTruthy();
|
|
93
|
+
|
|
94
|
+
fixture.componentRef.setInput('registry', 'search');
|
|
95
|
+
fixture.componentRef.setInput('component', 'stub-button');
|
|
96
|
+
fixture.componentRef.setInput('config', { label: 'Click' });
|
|
97
|
+
fixture.detectChanges();
|
|
98
|
+
|
|
99
|
+
expect(fixture.debugElement.query(By.css('span'))).toBeNull();
|
|
100
|
+
expect(fixture.debugElement.query(By.css('button'))).toBeTruthy();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('logs a warning for an unregistered type', () => {
|
|
104
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
105
|
+
fixture.componentRef.setInput('component', 'not-registered');
|
|
106
|
+
fixture.detectChanges();
|
|
107
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
108
|
+
expect.stringContaining('not-registered')
|
|
109
|
+
);
|
|
110
|
+
warnSpy.mockRestore();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('uses host-level registry for resolution', () => {
|
|
114
|
+
fixture.componentRef.setInput('registry', 'search');
|
|
115
|
+
fixture.componentRef.setInput('component', 'stub-label');
|
|
116
|
+
fixture.componentRef.setInput('config', { label: 'Lookup' });
|
|
117
|
+
fixture.detectChanges();
|
|
118
|
+
|
|
119
|
+
expect(fixture.debugElement.query(By.css('button'))).toBeTruthy();
|
|
120
|
+
expect(fixture.debugElement.query(By.css('span'))).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('binds external data and config as dedicated inputs', () => {
|
|
124
|
+
fixture.componentRef.setInput('registry', 'editor');
|
|
125
|
+
fixture.componentRef.setInput('component', 'stub-data');
|
|
126
|
+
fixture.componentRef.setInput('data', { value: 'john' });
|
|
127
|
+
fixture.componentRef.setInput('config', { hint: 'required' });
|
|
128
|
+
fixture.detectChanges();
|
|
129
|
+
|
|
130
|
+
const span = fixture.debugElement.query(By.css('span'));
|
|
131
|
+
expect(span.nativeElement.textContent).toContain('john|required');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('re-emits dataChange from the rendered component', () => {
|
|
135
|
+
let emitted: unknown = undefined;
|
|
136
|
+
fixture.componentInstance.dataChange.subscribe((value) => {
|
|
137
|
+
emitted = value;
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
fixture.componentRef.setInput('registry', 'editor');
|
|
141
|
+
fixture.componentRef.setInput('component', 'stub-data');
|
|
142
|
+
fixture.componentRef.setInput('data', { value: 'john' });
|
|
143
|
+
fixture.componentRef.setInput('config', { hint: 'required' });
|
|
144
|
+
fixture.detectChanges();
|
|
145
|
+
|
|
146
|
+
fixture.debugElement.query(By.css('.emit-data')).nativeElement.click();
|
|
147
|
+
fixture.detectChanges();
|
|
148
|
+
|
|
149
|
+
expect(emitted).toEqual({ value: 'changed' });
|
|
150
|
+
});
|
|
151
|
+
});
|
package/projects/dotjem/angular-dynamic-components/src/lib/dynamic-component-host.component.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChangeDetectionStrategy,
|
|
3
|
+
Component,
|
|
4
|
+
ComponentRef,
|
|
5
|
+
inject,
|
|
6
|
+
input,
|
|
7
|
+
output,
|
|
8
|
+
OnChanges,
|
|
9
|
+
OnDestroy,
|
|
10
|
+
SimpleChanges,
|
|
11
|
+
Type,
|
|
12
|
+
ViewContainerRef,
|
|
13
|
+
} from '@angular/core';
|
|
14
|
+
import { ComponentRegistry } from './component-registry';
|
|
15
|
+
|
|
16
|
+
interface Subscription {
|
|
17
|
+
unsubscribe(): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Renders a single registered component by type.
|
|
22
|
+
*
|
|
23
|
+
* Place `<dx-dynamic-component-host>` anywhere in a template and bind `[component]`
|
|
24
|
+
* to a registered type key. The component found in the registry for that type
|
|
25
|
+
* is created dynamically inside the host's view container and
|
|
26
|
+
* receives host-level `data`/`config` as `@Input()` bindings.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```html
|
|
30
|
+
* <dx-dynamic-component-host
|
|
31
|
+
* [component]="'my-button'"
|
|
32
|
+
* [registry]="'editor'"
|
|
33
|
+
* [data]="buttonModel"
|
|
34
|
+
* [config]="buttonConfig">
|
|
35
|
+
* </dx-dynamic-component-host>
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
@Component({
|
|
39
|
+
selector: 'dx-dynamic-component-host',
|
|
40
|
+
template: ``,
|
|
41
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
42
|
+
})
|
|
43
|
+
export class DynamicComponentHost implements OnChanges, OnDestroy {
|
|
44
|
+
/** The registered component type key to render. */
|
|
45
|
+
readonly component = input.required<string>();
|
|
46
|
+
/** Optional registry override for component resolution. */
|
|
47
|
+
readonly registry = input<string | null | undefined>(null);
|
|
48
|
+
/** Model data passed to the rendered component as its `data` input. */
|
|
49
|
+
readonly data = input<unknown>(undefined);
|
|
50
|
+
/** Static or semi-static configuration passed as the rendered component's `config` input. */
|
|
51
|
+
readonly config = input<Record<string, unknown> | null>(null);
|
|
52
|
+
/** Re-emits the rendered component's `dataChange` output to support `[(data)]`. */
|
|
53
|
+
readonly dataChange = output<unknown>();
|
|
54
|
+
|
|
55
|
+
private readonly vcr = inject(ViewContainerRef);
|
|
56
|
+
private readonly componentRegistry = inject(ComponentRegistry);
|
|
57
|
+
|
|
58
|
+
private componentRef: ComponentRef<unknown> | null = null;
|
|
59
|
+
private renderedComponentType: Type<unknown> | null = null;
|
|
60
|
+
private dataChangeSubscription: Subscription | null = null;
|
|
61
|
+
|
|
62
|
+
ngOnChanges(_changes: SimpleChanges): void {
|
|
63
|
+
this.syncComponent();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
ngOnDestroy(): void {
|
|
67
|
+
this.destroyComponent();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private syncComponent(): void {
|
|
71
|
+
const componentTypeKey = this.component();
|
|
72
|
+
const registryName = this.registry() ?? 'default';
|
|
73
|
+
const componentType = this.componentRegistry.resolve(componentTypeKey, registryName);
|
|
74
|
+
if (!componentType) {
|
|
75
|
+
this.destroyComponent();
|
|
76
|
+
console.warn(
|
|
77
|
+
`DynamicComponentHost: no component registered for type "${componentTypeKey}" in registry "${registryName}".`
|
|
78
|
+
);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!this.componentRef || this.renderedComponentType !== componentType) {
|
|
83
|
+
this.destroyComponent();
|
|
84
|
+
this.componentRef = this.vcr.createComponent(componentType);
|
|
85
|
+
this.renderedComponentType = componentType;
|
|
86
|
+
this.bindDataChangeOutput(this.componentRef);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!this.componentRef) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
this.applyStandardInputs(this.componentRef);
|
|
94
|
+
this.componentRef.changeDetectorRef.markForCheck();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private applyStandardInputs(ref: ComponentRef<unknown>): void {
|
|
98
|
+
const instance = ref.instance as Record<string, unknown>;
|
|
99
|
+
if ('data' in instance) {
|
|
100
|
+
ref.setInput('data', this.data());
|
|
101
|
+
}
|
|
102
|
+
if ('config' in instance) {
|
|
103
|
+
ref.setInput('config', this.config());
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private bindDataChangeOutput(ref: ComponentRef<unknown>): void {
|
|
108
|
+
this.dataChangeSubscription?.unsubscribe();
|
|
109
|
+
this.dataChangeSubscription = null;
|
|
110
|
+
|
|
111
|
+
const instance = ref.instance as Record<string, unknown>;
|
|
112
|
+
const output = instance['dataChange'];
|
|
113
|
+
if (
|
|
114
|
+
output &&
|
|
115
|
+
typeof output === 'object' &&
|
|
116
|
+
'subscribe' in output &&
|
|
117
|
+
typeof output.subscribe === 'function'
|
|
118
|
+
) {
|
|
119
|
+
this.dataChangeSubscription = output.subscribe((value: unknown) =>
|
|
120
|
+
this.dataChange.emit(value)
|
|
121
|
+
) as Subscription;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private destroyComponent(): void {
|
|
126
|
+
this.dataChangeSubscription?.unsubscribe();
|
|
127
|
+
this.dataChangeSubscription = null;
|
|
128
|
+
this.renderedComponentType = null;
|
|
129
|
+
if (this.componentRef) {
|
|
130
|
+
this.componentRef.destroy();
|
|
131
|
+
this.componentRef = null;
|
|
132
|
+
}
|
|
133
|
+
this.vcr.clear();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
|
2
|
+
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
|
3
|
+
{
|
|
4
|
+
"extends": "../../../tsconfig.json",
|
|
5
|
+
"compilerOptions": {
|
|
6
|
+
"outDir": "../../../out-tsc/lib",
|
|
7
|
+
"declaration": true,
|
|
8
|
+
"declarationMap": true,
|
|
9
|
+
"types": []
|
|
10
|
+
},
|
|
11
|
+
"include": ["src/**/*.ts"],
|
|
12
|
+
"exclude": ["**/*.spec.ts"]
|
|
13
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
|
2
|
+
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
|
3
|
+
{
|
|
4
|
+
"extends": "./tsconfig.lib.json",
|
|
5
|
+
"compilerOptions": {
|
|
6
|
+
"declarationMap": false
|
|
7
|
+
},
|
|
8
|
+
"angularCompilerOptions": {
|
|
9
|
+
"compilationMode": "partial"
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
|
2
|
+
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
|
3
|
+
{
|
|
4
|
+
"extends": "../../../tsconfig.json",
|
|
5
|
+
"compilerOptions": {
|
|
6
|
+
"outDir": "../../../out-tsc/spec",
|
|
7
|
+
"types": ["vitest/globals"]
|
|
8
|
+
},
|
|
9
|
+
"include": ["src/**/*.d.ts", "src/**/*.spec.ts"]
|
|
10
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
|
2
|
+
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
|
3
|
+
{
|
|
4
|
+
"compileOnSave": false,
|
|
5
|
+
"compilerOptions": {
|
|
6
|
+
"paths": {
|
|
7
|
+
"@dotjem/angular-dynamic-components": ["./dist/dotjem/angular-dynamic-components"]
|
|
8
|
+
},
|
|
9
|
+
"strict": true,
|
|
10
|
+
"noImplicitOverride": true,
|
|
11
|
+
"noPropertyAccessFromIndexSignature": true,
|
|
12
|
+
"noImplicitReturns": true,
|
|
13
|
+
"noFallthroughCasesInSwitch": true,
|
|
14
|
+
"skipLibCheck": true,
|
|
15
|
+
"isolatedModules": true,
|
|
16
|
+
"experimentalDecorators": true,
|
|
17
|
+
"importHelpers": true,
|
|
18
|
+
"target": "ES2022",
|
|
19
|
+
"module": "preserve"
|
|
20
|
+
},
|
|
21
|
+
"angularCompilerOptions": {
|
|
22
|
+
"enableI18nLegacyMessageIdFormat": false,
|
|
23
|
+
"strictInjectionParameters": true,
|
|
24
|
+
"strictInputAccessModifiers": true,
|
|
25
|
+
"strictTemplates": true
|
|
26
|
+
},
|
|
27
|
+
"files": [],
|
|
28
|
+
"references": [
|
|
29
|
+
{
|
|
30
|
+
"path": "./projects/dotjem/angular-dynamic-components/tsconfig.lib.json"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"path": "./projects/dotjem/angular-dynamic-components/tsconfig.spec.json"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"path": "./projects/demo/tsconfig.app.json"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"path": "./projects/demo/tsconfig.spec.json"
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
}
|