@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
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
|
2
|
+
import { DynamicComponentHost } from '@dotjem/angular-dynamic-components';
|
|
3
|
+
|
|
4
|
+
interface DynamicConfiguration {
|
|
5
|
+
type: string;
|
|
6
|
+
field?: string;
|
|
7
|
+
config: Record<string, unknown>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Root component – demonstrates consumer-driven iteration over dynamic host entries.
|
|
12
|
+
*/
|
|
13
|
+
@Component({
|
|
14
|
+
selector: 'app-root',
|
|
15
|
+
imports: [DynamicComponentHost],
|
|
16
|
+
templateUrl: './app.html',
|
|
17
|
+
styles: `
|
|
18
|
+
:host { font-family: system-ui, sans-serif; }
|
|
19
|
+
.page { max-width: 640px; margin: 2rem auto; padding: 0 1rem; }
|
|
20
|
+
header { margin-bottom: 1.5rem; }
|
|
21
|
+
.panel { margin-bottom: 1.5rem; }
|
|
22
|
+
h1 { color: #1e3a8a; }
|
|
23
|
+
footer { margin-top: 2rem; }
|
|
24
|
+
pre {
|
|
25
|
+
background: #f1f5f9;
|
|
26
|
+
padding: 1rem;
|
|
27
|
+
border-radius: 6px;
|
|
28
|
+
overflow: auto;
|
|
29
|
+
font-size: .8rem;
|
|
30
|
+
}
|
|
31
|
+
`,
|
|
32
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
33
|
+
})
|
|
34
|
+
export class App {
|
|
35
|
+
readonly editorConfigurations: DynamicConfiguration[] = [
|
|
36
|
+
{ type: 'section-heading', config: { title: 'Personal Information' } },
|
|
37
|
+
{
|
|
38
|
+
type: 'text-field',
|
|
39
|
+
field: 'firstName',
|
|
40
|
+
config: { name: 'firstName', label: 'First name', placeholder: 'Jane' },
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
type: 'text-field',
|
|
44
|
+
field: 'lastName',
|
|
45
|
+
config: { name: 'lastName', label: 'Last name', placeholder: 'Doe' },
|
|
46
|
+
},
|
|
47
|
+
{ type: 'section-heading', config: { title: 'Contact Details' } },
|
|
48
|
+
{
|
|
49
|
+
type: 'select-field',
|
|
50
|
+
field: 'country',
|
|
51
|
+
config: {
|
|
52
|
+
name: 'country',
|
|
53
|
+
label: 'Country',
|
|
54
|
+
options: [
|
|
55
|
+
{ value: 'dk', label: 'Denmark' },
|
|
56
|
+
{ value: 'se', label: 'Sweden' },
|
|
57
|
+
{ value: 'no', label: 'Norway' },
|
|
58
|
+
{ value: 'fi', label: 'Finland' },
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
editorData: Record<string, unknown> = {
|
|
65
|
+
firstName: { value: 'Jane' },
|
|
66
|
+
lastName: { value: 'Doe' },
|
|
67
|
+
country: { value: 'dk' },
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
readonly searchConfigurations: DynamicConfiguration[] = [
|
|
71
|
+
{
|
|
72
|
+
type: 'text-field',
|
|
73
|
+
field: 'query',
|
|
74
|
+
config: { placeholder: 'Search docs', buttonLabel: 'Find' },
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
searchData: Record<string, unknown> = {
|
|
79
|
+
query: { value: 'dynamic forms' },
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
handleEditorDataChange(field: string | undefined, value: unknown): void {
|
|
83
|
+
if (!field) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
this.editorData = {
|
|
87
|
+
...this.editorData,
|
|
88
|
+
[field]: value,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
handleSearchDataChange(field: string | undefined, value: unknown): void {
|
|
93
|
+
if (!field) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
this.searchData = {
|
|
97
|
+
...this.searchData,
|
|
98
|
+
[field]: value,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
get configurationJson(): string {
|
|
103
|
+
return JSON.stringify(
|
|
104
|
+
{ editor: this.editorConfigurations, search: this.searchConfigurations },
|
|
105
|
+
null,
|
|
106
|
+
2
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
get dataJson(): string {
|
|
111
|
+
return JSON.stringify(
|
|
112
|
+
{ editor: this.editorData, search: this.searchData },
|
|
113
|
+
null,
|
|
114
|
+
2
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChangeDetectionStrategy,
|
|
3
|
+
Component,
|
|
4
|
+
input,
|
|
5
|
+
output,
|
|
6
|
+
} from '@angular/core';
|
|
7
|
+
|
|
8
|
+
interface SearchFieldData {
|
|
9
|
+
value: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface SearchFieldConfig {
|
|
13
|
+
placeholder: string;
|
|
14
|
+
buttonLabel: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Search-style field used in the `search` registry context. */
|
|
18
|
+
@Component({
|
|
19
|
+
selector: 'demo-search-text-field',
|
|
20
|
+
template: `
|
|
21
|
+
<div class="search-field">
|
|
22
|
+
<input
|
|
23
|
+
[value]="data()?.value ?? ''"
|
|
24
|
+
[placeholder]="config().placeholder"
|
|
25
|
+
type="search"
|
|
26
|
+
(input)="updateValue($any($event.target).value || '')"
|
|
27
|
+
/>
|
|
28
|
+
<button type="button">{{ config().buttonLabel }}</button>
|
|
29
|
+
</div>
|
|
30
|
+
`,
|
|
31
|
+
styles: `
|
|
32
|
+
.search-field { display: flex; gap: .5rem; margin-bottom: 1rem; }
|
|
33
|
+
input { flex: 1; border: 1px solid #a7f3d0; border-radius: 9999px; padding: .4rem .75rem; background: #ecfdf5; }
|
|
34
|
+
button { border: 1px solid #34d399; border-radius: 9999px; background: #10b981; color: white; padding: .4rem .9rem; cursor: pointer; }
|
|
35
|
+
`,
|
|
36
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
37
|
+
})
|
|
38
|
+
export class SearchTextFieldComponent {
|
|
39
|
+
readonly data = input<SearchFieldData | null>(null);
|
|
40
|
+
readonly config = input<SearchFieldConfig>({
|
|
41
|
+
placeholder: 'Search...',
|
|
42
|
+
buttonLabel: 'Search',
|
|
43
|
+
});
|
|
44
|
+
readonly dataChange = output<SearchFieldData>();
|
|
45
|
+
|
|
46
|
+
updateValue(value: string): void {
|
|
47
|
+
this.dataChange.emit({ value });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChangeDetectionStrategy,
|
|
3
|
+
Component,
|
|
4
|
+
input,
|
|
5
|
+
} from '@angular/core';
|
|
6
|
+
|
|
7
|
+
/** A section heading component for grouping form fields. */
|
|
8
|
+
@Component({
|
|
9
|
+
selector: 'demo-section-heading',
|
|
10
|
+
template: `<h2 class="section-heading">{{ config()?.title }}</h2>`,
|
|
11
|
+
styles: `
|
|
12
|
+
.section-heading {
|
|
13
|
+
font-size: 1.1rem;
|
|
14
|
+
font-weight: 700;
|
|
15
|
+
border-bottom: 2px solid #3b82f6;
|
|
16
|
+
padding-bottom: .25rem;
|
|
17
|
+
margin: 1.5rem 0 .75rem;
|
|
18
|
+
color: #1e40af;
|
|
19
|
+
}
|
|
20
|
+
`,
|
|
21
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
22
|
+
})
|
|
23
|
+
export class SectionHeadingComponent {
|
|
24
|
+
readonly config = input<{ title: string } | null>(null);
|
|
25
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChangeDetectionStrategy,
|
|
3
|
+
Component,
|
|
4
|
+
input,
|
|
5
|
+
output,
|
|
6
|
+
} from '@angular/core';
|
|
7
|
+
|
|
8
|
+
interface SelectFieldData {
|
|
9
|
+
value: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface SelectFieldConfig {
|
|
13
|
+
name: string;
|
|
14
|
+
label: string;
|
|
15
|
+
options: Array<{ value: string; label: string }>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Editor-style select field that consumes external model data and config. */
|
|
19
|
+
@Component({
|
|
20
|
+
selector: 'demo-select-field',
|
|
21
|
+
template: `
|
|
22
|
+
<div class="field editor-field">
|
|
23
|
+
<label [for]="config().name">{{ config().label }}</label>
|
|
24
|
+
<select
|
|
25
|
+
[id]="config().name"
|
|
26
|
+
[name]="config().name"
|
|
27
|
+
[value]="data()?.value ?? ''"
|
|
28
|
+
(change)="updateValue($any($event.target).value || '')"
|
|
29
|
+
>
|
|
30
|
+
<option value="">-- choose --</option>
|
|
31
|
+
@for (opt of config().options; track opt.value) {
|
|
32
|
+
<option [value]="opt.value">{{ opt.label }}</option>
|
|
33
|
+
}
|
|
34
|
+
</select>
|
|
35
|
+
</div>
|
|
36
|
+
`,
|
|
37
|
+
styles: `
|
|
38
|
+
.field { margin-bottom: 1rem; }
|
|
39
|
+
label { display: block; font-weight: 600; margin-bottom: .25rem; color: #1d4ed8; }
|
|
40
|
+
select { border: 1px solid #93c5fd; border-radius: 4px; padding: .4rem .6rem; width: 100%; box-sizing: border-box; background: #eff6ff; }
|
|
41
|
+
`,
|
|
42
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
43
|
+
})
|
|
44
|
+
export class SelectFieldComponent {
|
|
45
|
+
readonly data = input<SelectFieldData | null>(null);
|
|
46
|
+
readonly config = input<SelectFieldConfig>({
|
|
47
|
+
name: 'field',
|
|
48
|
+
label: 'Field',
|
|
49
|
+
options: [],
|
|
50
|
+
});
|
|
51
|
+
readonly dataChange = output<SelectFieldData>();
|
|
52
|
+
|
|
53
|
+
updateValue(value: string): void {
|
|
54
|
+
this.dataChange.emit({ value });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChangeDetectionStrategy,
|
|
3
|
+
Component,
|
|
4
|
+
input,
|
|
5
|
+
output,
|
|
6
|
+
} from '@angular/core';
|
|
7
|
+
|
|
8
|
+
interface TextFieldData {
|
|
9
|
+
value: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface TextFieldConfig {
|
|
13
|
+
name: string;
|
|
14
|
+
label: string;
|
|
15
|
+
placeholder?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Editor-style text field that consumes external model data and config. */
|
|
19
|
+
@Component({
|
|
20
|
+
selector: 'demo-text-field',
|
|
21
|
+
template: `
|
|
22
|
+
<div class="field editor-field">
|
|
23
|
+
<label [for]="config().name">{{ config().label }}</label>
|
|
24
|
+
<input
|
|
25
|
+
[id]="config().name"
|
|
26
|
+
[name]="config().name"
|
|
27
|
+
[placeholder]="config().placeholder ?? ''"
|
|
28
|
+
[value]="data()?.value ?? ''"
|
|
29
|
+
type="text"
|
|
30
|
+
(input)="updateValue($any($event.target).value || '')"
|
|
31
|
+
/>
|
|
32
|
+
</div>
|
|
33
|
+
`,
|
|
34
|
+
styles: `
|
|
35
|
+
.field { margin-bottom: 1rem; }
|
|
36
|
+
label { display: block; font-weight: 600; margin-bottom: .25rem; color: #1d4ed8; }
|
|
37
|
+
input { border: 1px solid #93c5fd; border-radius: 4px; padding: .4rem .6rem; width: 100%; box-sizing: border-box; background: #eff6ff; }
|
|
38
|
+
`,
|
|
39
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
40
|
+
})
|
|
41
|
+
export class TextFieldComponent {
|
|
42
|
+
readonly data = input<TextFieldData | null>(null);
|
|
43
|
+
readonly config = input<TextFieldConfig>({
|
|
44
|
+
name: 'field',
|
|
45
|
+
label: 'Field',
|
|
46
|
+
placeholder: '',
|
|
47
|
+
});
|
|
48
|
+
readonly dataChange = output<TextFieldData>();
|
|
49
|
+
|
|
50
|
+
updateValue(value: string): void {
|
|
51
|
+
this.dataChange.emit({ value });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<title>Demo</title>
|
|
6
|
+
<base href="/" />
|
|
7
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
8
|
+
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<app-root></app-root>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/* You can add global styles to this file, and also import other style files */
|
|
@@ -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.json",
|
|
5
|
+
"compilerOptions": {
|
|
6
|
+
"outDir": "../../out-tsc/app",
|
|
7
|
+
"types": []
|
|
8
|
+
},
|
|
9
|
+
"include": ["src/**/*.ts"],
|
|
10
|
+
"exclude": ["src/**/*.spec.ts"]
|
|
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
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dotjem/angular-dynamic-components",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Dynamic component hosting for Angular 21+ — render UI components configured as plain JSON.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"angular",
|
|
7
|
+
"dynamic",
|
|
8
|
+
"components",
|
|
9
|
+
"forms",
|
|
10
|
+
"json",
|
|
11
|
+
"schema"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/dotJEM/angular-dynamic-components.git"
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"@angular/common": "^21.2.0",
|
|
20
|
+
"@angular/core": "^21.2.0",
|
|
21
|
+
"@angular/forms": "^21.2.0"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"tslib": "^2.3.0"
|
|
25
|
+
},
|
|
26
|
+
"sideEffects": false
|
|
27
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { TestBed } from '@angular/core/testing';
|
|
2
|
+
import {
|
|
3
|
+
ComponentRegistry,
|
|
4
|
+
provideDynamicComponents,
|
|
5
|
+
DYNAMIC_COMPONENT_REGISTRATIONS,
|
|
6
|
+
} from './component-registry';
|
|
7
|
+
import { Component } from '@angular/core';
|
|
8
|
+
|
|
9
|
+
@Component({ selector: 'dx-fake-a', template: 'A', standalone: true })
|
|
10
|
+
class FakeComponentA {}
|
|
11
|
+
|
|
12
|
+
@Component({ selector: 'dx-fake-b', template: 'B', standalone: true })
|
|
13
|
+
class FakeComponentB {}
|
|
14
|
+
|
|
15
|
+
describe('ComponentRegistry', () => {
|
|
16
|
+
describe('with no providers', () => {
|
|
17
|
+
beforeEach(() =>
|
|
18
|
+
TestBed.configureTestingModule({
|
|
19
|
+
providers: [ComponentRegistry],
|
|
20
|
+
})
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
it('should be created', () => {
|
|
24
|
+
const registry = TestBed.inject(ComponentRegistry);
|
|
25
|
+
expect(registry).toBeTruthy();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should return null for unknown types', () => {
|
|
29
|
+
const registry = TestBed.inject(ComponentRegistry);
|
|
30
|
+
expect(registry.resolve('unknown')).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should register and resolve a component', () => {
|
|
34
|
+
const registry = TestBed.inject(ComponentRegistry);
|
|
35
|
+
registry.register('fake-a', FakeComponentA);
|
|
36
|
+
expect(registry.resolve('fake-a')).toBe(FakeComponentA);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should support same type in different registries', () => {
|
|
40
|
+
const registry = TestBed.inject(ComponentRegistry);
|
|
41
|
+
registry.register('text-field', FakeComponentA, 'editor');
|
|
42
|
+
registry.register('text-field', FakeComponentB, 'search');
|
|
43
|
+
expect(registry.resolve('text-field', 'editor')).toBe(FakeComponentA);
|
|
44
|
+
expect(registry.resolve('text-field', 'search')).toBe(FakeComponentB);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should report has() correctly', () => {
|
|
48
|
+
const registry = TestBed.inject(ComponentRegistry);
|
|
49
|
+
expect(registry.has('fake-a')).toBe(false);
|
|
50
|
+
registry.register('fake-a', FakeComponentA);
|
|
51
|
+
expect(registry.has('fake-a')).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should list registered types', () => {
|
|
55
|
+
const registry = TestBed.inject(ComponentRegistry);
|
|
56
|
+
registry.register('fake-a', FakeComponentA);
|
|
57
|
+
registry.register('fake-b', FakeComponentB);
|
|
58
|
+
expect(registry.types()).toContain('fake-a');
|
|
59
|
+
expect(registry.types()).toContain('fake-b');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should throw when registering a duplicate type', () => {
|
|
63
|
+
const registry = TestBed.inject(ComponentRegistry);
|
|
64
|
+
registry.register('fake-a', FakeComponentA);
|
|
65
|
+
expect(() => registry.register('fake-a', FakeComponentA)).toThrow(
|
|
66
|
+
/already registered/
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should throw only for duplicates within the same registry', () => {
|
|
71
|
+
const registry = TestBed.inject(ComponentRegistry);
|
|
72
|
+
registry.register('fake-a', FakeComponentA, 'editor');
|
|
73
|
+
expect(() =>
|
|
74
|
+
registry.register('fake-a', FakeComponentB, 'editor')
|
|
75
|
+
).toThrow(/already registered/);
|
|
76
|
+
expect(() =>
|
|
77
|
+
registry.register('fake-a', FakeComponentB, 'search')
|
|
78
|
+
).not.toThrow();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('with provideDynamicComponents()', () => {
|
|
83
|
+
beforeEach(() =>
|
|
84
|
+
TestBed.configureTestingModule({
|
|
85
|
+
providers: [
|
|
86
|
+
ComponentRegistry,
|
|
87
|
+
provideDynamicComponents({
|
|
88
|
+
'fake-a': FakeComponentA,
|
|
89
|
+
'fake-b': FakeComponentB,
|
|
90
|
+
}),
|
|
91
|
+
provideDynamicComponents(
|
|
92
|
+
{
|
|
93
|
+
'fake-a': FakeComponentB,
|
|
94
|
+
},
|
|
95
|
+
'search'
|
|
96
|
+
),
|
|
97
|
+
],
|
|
98
|
+
})
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
it('should resolve pre-registered components', () => {
|
|
102
|
+
const registry = TestBed.inject(ComponentRegistry);
|
|
103
|
+
expect(registry.resolve('fake-a')).toBe(FakeComponentA);
|
|
104
|
+
expect(registry.resolve('fake-b')).toBe(FakeComponentB);
|
|
105
|
+
expect(registry.resolve('fake-a', 'search')).toBe(FakeComponentB);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('provideDynamicComponents()', () => {
|
|
110
|
+
it('should produce a multi provider for DYNAMIC_COMPONENT_REGISTRATIONS', () => {
|
|
111
|
+
const provider = provideDynamicComponents({ 'fake-a': FakeComponentA });
|
|
112
|
+
expect(provider.provide).toBe(DYNAMIC_COMPONENT_REGISTRATIONS);
|
|
113
|
+
expect(provider.multi).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should include the registry name in the provider payload', () => {
|
|
117
|
+
const provider = provideDynamicComponents(
|
|
118
|
+
{ 'fake-a': FakeComponentA },
|
|
119
|
+
'editor'
|
|
120
|
+
);
|
|
121
|
+
expect(provider.useValue.registry).toBe('editor');
|
|
122
|
+
expect(provider.useValue.components['fake-a'].component).toBe(
|
|
123
|
+
FakeComponentA
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { inject, Injectable, InjectionToken, Type } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
/** Describes a component registration entry inside the registry. */
|
|
4
|
+
export interface ComponentRegistration {
|
|
5
|
+
/** The Angular component class to instantiate. */
|
|
6
|
+
component: Type<unknown>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Injection token used to provide component registrations at bootstrap time. */
|
|
10
|
+
export const DYNAMIC_COMPONENT_REGISTRATIONS = new InjectionToken<
|
|
11
|
+
NamedComponentRegistration[]
|
|
12
|
+
>('DYNAMIC_COMPONENT_REGISTRATIONS');
|
|
13
|
+
|
|
14
|
+
/** Named registration payload used for bootstrap providers. */
|
|
15
|
+
export interface NamedComponentRegistration {
|
|
16
|
+
registry: string;
|
|
17
|
+
components: Record<string, ComponentRegistration>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Registry service that maps type-string keys to Angular component classes.
|
|
22
|
+
*
|
|
23
|
+
* Components are registered either via `provideDynamicComponents()` at
|
|
24
|
+
* bootstrap time or imperatively via `register()` at runtime.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
@Injectable({ providedIn: 'root' })
|
|
28
|
+
export class ComponentRegistry {
|
|
29
|
+
private readonly registries = new Map<
|
|
30
|
+
string,
|
|
31
|
+
Map<string, ComponentRegistration>
|
|
32
|
+
>();
|
|
33
|
+
|
|
34
|
+
constructor() {
|
|
35
|
+
const registrations =
|
|
36
|
+
inject(DYNAMIC_COMPONENT_REGISTRATIONS, { optional: true }) ?? [];
|
|
37
|
+
for (const namedRegistration of registrations) {
|
|
38
|
+
const registry = this.ensureRegistry(namedRegistration.registry);
|
|
39
|
+
for (const [type, reg] of Object.entries(namedRegistration.components)) {
|
|
40
|
+
registry.set(type, reg);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Registers a component class under the given `type` key.
|
|
47
|
+
* Throws if the type is already registered.
|
|
48
|
+
*/
|
|
49
|
+
register(
|
|
50
|
+
type: string,
|
|
51
|
+
component: Type<unknown>,
|
|
52
|
+
registry = 'default'
|
|
53
|
+
): void {
|
|
54
|
+
const targetRegistry = this.ensureRegistry(registry);
|
|
55
|
+
if (targetRegistry.has(type)) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`ComponentRegistry: type "${type}" is already registered in registry "${registry}".`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
targetRegistry.set(type, { component });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolves the component class for the given `type` key.
|
|
65
|
+
* Returns `null` when the type is not found.
|
|
66
|
+
*/
|
|
67
|
+
resolve(type: string, registry = 'default'): Type<unknown> | null {
|
|
68
|
+
return this.registries.get(registry)?.get(type)?.component ?? null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Returns `true` when the given `type` key has a registration.
|
|
73
|
+
*/
|
|
74
|
+
has(type: string, registry = 'default'): boolean {
|
|
75
|
+
return this.registries.get(registry)?.has(type) ?? false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Returns all currently registered type keys.
|
|
80
|
+
*/
|
|
81
|
+
types(registry = 'default'): string[] {
|
|
82
|
+
return Array.from(this.registries.get(registry)?.keys() ?? []);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Returns the names of all known registries. */
|
|
86
|
+
registryNames(): string[] {
|
|
87
|
+
return Array.from(this.registries.keys());
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private ensureRegistry(
|
|
91
|
+
registry: string
|
|
92
|
+
): Map<string, ComponentRegistration> {
|
|
93
|
+
const existing = this.registries.get(registry);
|
|
94
|
+
if (existing) {
|
|
95
|
+
return existing;
|
|
96
|
+
}
|
|
97
|
+
const created = new Map<string, ComponentRegistration>();
|
|
98
|
+
this.registries.set(registry, created);
|
|
99
|
+
return created;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Helper type used in `provideDynamicComponents()`.
|
|
105
|
+
*/
|
|
106
|
+
export type ComponentMap = Record<string, Type<unknown>>;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Creates an Angular provider that pre-registers a map of components
|
|
110
|
+
* with the `ComponentRegistry`.
|
|
111
|
+
*
|
|
112
|
+
* @example
|
|
113
|
+
* ```ts
|
|
114
|
+
* bootstrapApplication(AppComponent, {
|
|
115
|
+
* providers: [
|
|
116
|
+
* provideDynamicComponents({
|
|
117
|
+
* 'text-input': TextInputComponent,
|
|
118
|
+
* 'select': SelectComponent,
|
|
119
|
+
* }),
|
|
120
|
+
* ],
|
|
121
|
+
* });
|
|
122
|
+
* ```
|
|
123
|
+
*/
|
|
124
|
+
export function provideDynamicComponents(
|
|
125
|
+
components: ComponentMap,
|
|
126
|
+
registry = 'default'
|
|
127
|
+
) {
|
|
128
|
+
const registrations: Record<string, ComponentRegistration> = {};
|
|
129
|
+
for (const [type, component] of Object.entries(components)) {
|
|
130
|
+
registrations[type] = { component };
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
provide: DYNAMIC_COMPONENT_REGISTRATIONS,
|
|
134
|
+
useValue: { registry, components: registrations },
|
|
135
|
+
multi: true,
|
|
136
|
+
};
|
|
137
|
+
}
|