Skip to content

Commit c3a1444

Browse files
committed
feat(io): add support for signal based dynamic components IO (#520)
Now you can enable support for signal based components IO if you are using Angular versions that support it.
1 parent 787135c commit c3a1444

19 files changed

+422
-62
lines changed

README.md

+6
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,12 @@ will not work and you will have to import them separately (see their respective
9898
If you still need to use both `<ndc-dynamic>` and dynamic inputs/outputs it is recommended
9999
to keep using `DynamicModule` API.
100100

101+
### Singal based inputs/outputs (experimental)
102+
103+
**Since v10.8.0**
104+
105+
If you want to dynamically render signal based components - see [`signal-component-io`](projects/ng-dynamic-component/signal-component-io/README.md) package.
106+
101107
### NgComponentOutlet
102108

103109
You can also use [`NgComponentOutlet`](https://angular.io/api/common/NgComponentOutlet)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
## API Report File for "signal-component-io"
2+
3+
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
4+
5+
```ts
6+
7+
import * as i0 from '@angular/core';
8+
9+
// @public (undocumented)
10+
export class SignalComponentIoModule {
11+
// (undocumented)
12+
static ɵfac: i0.ɵɵFactoryDeclaration<SignalComponentIoModule, never>;
13+
// (undocumented)
14+
static ɵinj: i0.ɵɵInjectorDeclaration<SignalComponentIoModule>;
15+
// (undocumented)
16+
static ɵmod: i0.ɵɵNgModuleDeclaration<SignalComponentIoModule, never, never, never>;
17+
}
18+
19+
// (No @packageDocumentation comment for this package)
20+
21+
```

goldens/ng-dynamic-component/api.md

+26-10
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { IterableDiffers } from '@angular/core';
1818
import { KeyValueDiffers } from '@angular/core';
1919
import { NgComponentOutlet } from '@angular/common';
2020
import { NgModuleRef } from '@angular/core';
21+
import { Observable } from 'rxjs';
2122
import { OnChanges } from '@angular/core';
2223
import { OnDestroy } from '@angular/core';
2324
import { Renderer2 } from '@angular/core';
@@ -36,6 +37,21 @@ export interface AttributesMap {
3637
[key: string]: string;
3738
}
3839

40+
// @public (undocumented)
41+
export type ComponentInputKey<T> = keyof T & string;
42+
43+
// @public (undocumented)
44+
export abstract class ComponentIO {
45+
// (undocumented)
46+
abstract getOutput<T, K extends ComponentInputKey<T>>(componentRef: ComponentRef<T>, name: K): Observable<unknown>;
47+
// (undocumented)
48+
abstract setInput<T, K extends ComponentInputKey<T>>(componentRef: ComponentRef<T>, name: K, value: T[K]): void;
49+
// (undocumented)
50+
static ɵfac: i0.ɵɵFactoryDeclaration<ComponentIO, never>;
51+
// (undocumented)
52+
static ɵprov: i0.ɵɵInjectableDeclaration<ComponentIO>;
53+
}
54+
3955
// @public (undocumented)
4056
export class ComponentOutletInjectorDirective implements DynamicComponentInjector {
4157
constructor(componentOutlet: NgComponentOutlet);
@@ -123,11 +139,11 @@ export class DynamicAttributesModule {
123139
static ɵfac: i0.ɵɵFactoryDeclaration<DynamicAttributesModule, never>;
124140
// (undocumented)
125141
static ɵinj: i0.ɵɵInjectorDeclaration<DynamicAttributesModule>;
126-
// Warning: (ae-forgotten-export) The symbol "i1_4" needs to be exported by the entry point public-api.d.ts
142+
// Warning: (ae-forgotten-export) The symbol "i1_2" needs to be exported by the entry point public-api.d.ts
127143
// Warning: (ae-forgotten-export) The symbol "i2_2" needs to be exported by the entry point public-api.d.ts
128144
//
129145
// (undocumented)
130-
static ɵmod: i0.ɵɵNgModuleDeclaration<DynamicAttributesModule, never, [typeof i1_4.DynamicAttributesDirective], [typeof i1_4.DynamicAttributesDirective, typeof i2_2.ComponentOutletInjectorModule]>;
146+
static ɵmod: i0.ɵɵNgModuleDeclaration<DynamicAttributesModule, never, [typeof i1_2.DynamicAttributesDirective], [typeof i1_2.DynamicAttributesDirective, typeof i2_2.ComponentOutletInjectorModule]>;
131147
}
132148

133149
// @public (undocumented)
@@ -206,10 +222,10 @@ export class DynamicDirectivesModule {
206222
static ɵfac: i0.ɵɵFactoryDeclaration<DynamicDirectivesModule, never>;
207223
// (undocumented)
208224
static ɵinj: i0.ɵɵInjectorDeclaration<DynamicDirectivesModule>;
209-
// Warning: (ae-forgotten-export) The symbol "i1_5" needs to be exported by the entry point public-api.d.ts
225+
// Warning: (ae-forgotten-export) The symbol "i1_3" needs to be exported by the entry point public-api.d.ts
210226
//
211227
// (undocumented)
212-
static ɵmod: i0.ɵɵNgModuleDeclaration<DynamicDirectivesModule, never, [typeof i1_5.DynamicDirectivesDirective], [typeof i1_5.DynamicDirectivesDirective, typeof i2_2.ComponentOutletInjectorModule]>;
228+
static ɵmod: i0.ɵɵNgModuleDeclaration<DynamicDirectivesModule, never, [typeof i1_3.DynamicDirectivesDirective], [typeof i1_3.DynamicDirectivesDirective, typeof i2_2.ComponentOutletInjectorModule]>;
213229
}
214230

215231
// @public (undocumented)
@@ -233,10 +249,10 @@ export class DynamicIoModule {
233249
static ɵfac: i0.ɵɵFactoryDeclaration<DynamicIoModule, never>;
234250
// (undocumented)
235251
static ɵinj: i0.ɵɵInjectorDeclaration<DynamicIoModule>;
236-
// Warning: (ae-forgotten-export) The symbol "i1_3" needs to be exported by the entry point public-api.d.ts
252+
// Warning: (ae-forgotten-export) The symbol "i1_4" needs to be exported by the entry point public-api.d.ts
237253
//
238254
// (undocumented)
239-
static ɵmod: i0.ɵɵNgModuleDeclaration<DynamicIoModule, never, [typeof i1_3.DynamicIoDirective], [typeof i1_3.DynamicIoDirective, typeof i2_2.ComponentOutletInjectorModule]>;
255+
static ɵmod: i0.ɵɵNgModuleDeclaration<DynamicIoModule, never, [typeof i1_4.DynamicIoDirective], [typeof i1_4.DynamicIoDirective, typeof i2_2.ComponentOutletInjectorModule]>;
240256
}
241257

242258
// @public (undocumented)
@@ -245,11 +261,11 @@ export class DynamicModule {
245261
static ɵfac: i0.ɵɵFactoryDeclaration<DynamicModule, never>;
246262
// (undocumented)
247263
static ɵinj: i0.ɵɵInjectorDeclaration<DynamicModule>;
248-
// Warning: (ae-forgotten-export) The symbol "i1_2" needs to be exported by the entry point public-api.d.ts
264+
// Warning: (ae-forgotten-export) The symbol "i1_5" needs to be exported by the entry point public-api.d.ts
249265
// Warning: (ae-forgotten-export) The symbol "i2_3" needs to be exported by the entry point public-api.d.ts
250266
//
251267
// (undocumented)
252-
static ɵmod: i0.ɵɵNgModuleDeclaration<DynamicModule, never, [typeof i1_2.DynamicIoModule, typeof i2_3.DynamicComponent], [typeof i1_2.DynamicIoModule, typeof i2_3.DynamicComponent]>;
268+
static ɵmod: i0.ɵɵNgModuleDeclaration<DynamicModule, never, [typeof i1_5.DynamicIoModule, typeof i2_3.DynamicComponent], [typeof i1_5.DynamicIoModule, typeof i2_3.DynamicComponent]>;
253269
}
254270

255271
// @public @deprecated (undocumented)
@@ -292,12 +308,12 @@ export interface IoFactoryServiceOptions {
292308

293309
// @public (undocumented)
294310
export class IoService implements OnDestroy {
295-
constructor(injector: Injector, differs: KeyValueDiffers, cfr: ComponentFactoryResolver, options: IoServiceOptions, compInjector: DynamicComponentInjector, eventArgument: string, cdr: ChangeDetectorRef, eventContextProvider: StaticProvider);
311+
constructor(injector: Injector, differs: KeyValueDiffers, cfr: ComponentFactoryResolver, options: IoServiceOptions, compInjector: DynamicComponentInjector, eventArgument: string, cdr: ChangeDetectorRef, eventContextProvider: StaticProvider, componentIO: ComponentIO);
296312
// (undocumented)
297313
ngOnDestroy(): void;
298314
update(inputs?: InputsType | null, outputs?: OutputsType | null): void;
299315
// (undocumented)
300-
static ɵfac: i0.ɵɵFactoryDeclaration<IoService, [null, null, null, null, null, null, null, { optional: true; }]>;
316+
static ɵfac: i0.ɵɵFactoryDeclaration<IoService, [null, null, null, null, null, null, null, { optional: true; }, null]>;
301317
// (undocumented)
302318
static ɵprov: i0.ɵɵInjectableDeclaration<IoService>;
303319
}

projects/ng-dynamic-component/project.json

+19-4
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{
2+
"name": "ng-dynamic-component",
23
"$schema": "../../node_modules/nx/schemas/project-schema.json",
34
"projectType": "library",
45
"sourceRoot": "projects/ng-dynamic-component/src",
@@ -15,25 +16,39 @@
1516
"executor": "nx:run-commands",
1617
"outputs": ["goldens/ng-dynamic-component", "dist/ng-dynamic-component"],
1718
"options": {
18-
"command": "npx api-extractor run -c projects/ng-dynamic-component/api-extractor.json"
19+
"commands": [
20+
"npx api-extractor run -c projects/ng-dynamic-component/api-extractor.json",
21+
"npx api-extractor run -c projects/ng-dynamic-component/signal-component-io/api-extractor.json"
22+
],
23+
"parallel": true
1924
},
2025
"configurations": {
2126
"local": {
22-
"command": "npx api-extractor run -c projects/ng-dynamic-component/api-extractor.json --local"
27+
"commands": [
28+
"npx api-extractor run -c projects/ng-dynamic-component/api-extractor.json --local",
29+
"npx api-extractor run -c projects/ng-dynamic-component/signal-component-io/api-extractor.json --local"
30+
]
2331
}
2432
},
2533
"dependsOn": ["build-lib", "^build-lib"]
2634
},
2735
"test": {
2836
"executor": "@angular-builders/jest:run",
29-
"options": {}
37+
"options": {},
38+
"configurations": {
39+
"watch": {
40+
"watch": true
41+
}
42+
}
3043
},
3144
"lint": {
3245
"executor": "@angular-eslint/builder:lint",
3346
"options": {
3447
"lintFilePatterns": [
3548
"projects/ng-dynamic-component/**/*.ts",
36-
"projects/ng-dynamic-component/**/*.html"
49+
"projects/ng-dynamic-component/**/*.html",
50+
"projects/ng-dynamic-component/signal-component-io/**/*.ts",
51+
"projects/ng-dynamic-component/signal-component-io/**/*.html"
3752
]
3853
}
3954
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# ng-dynamic-component/signal-component-io
2+
3+
> Secondary entry point of `ng-dynamic-component`. It can be used by importing from `ng-dynamic-component/signal-component-io`.
4+
5+
This package enables signal based inputs/outputs support for dynamically rendered components.
6+
7+
## Prerequisites
8+
9+
This package requires Angular version which supports signals.
10+
Please refer to (Angular docs)[https://angular.dev/] to see which minimal version is required.
11+
12+
## Warning: Experimental
13+
14+
This package is still **experimental** and not ready for producation!
15+
APIs may change in the future or be removed completely and never make it to stable release!
16+
Only use it to evaluate the features and provide feedback.
17+
18+
## Usage
19+
20+
**Since v10.8.0**
21+
22+
Import `SignalComponentIoModule` in your application module or config:
23+
24+
```ts
25+
import { NgModule } from '@angular/core';
26+
import { SignalComponentIoModule } from 'ng-dynamic-component/signal-component-io';
27+
28+
@NgModule({
29+
imports: [SignalComponentIoModule],
30+
})
31+
class AppModule {}
32+
```
33+
34+
Now you can render dynamic components with signal based inputs/outputs!
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
3+
"extends": "../api-extractor.json",
4+
"projectFolder": "../../..",
5+
"mainEntryPointFilePath": "<projectFolder>/dist/ng-dynamic-component/signal-component-io/public-api.d.ts",
6+
"apiReport": {
7+
"enabled": true,
8+
"reportFileName": "api-signal-component-io.md"
9+
},
10+
"dtsRollup": {
11+
"enabled": true,
12+
"untrimmedFilePath": "",
13+
"alphaTrimmedFilePath": "<projectFolder>/dist/ng-dynamic-component/signal-component-io/public-api-alpha.d.ts",
14+
"betaTrimmedFilePath": "<projectFolder>/dist/ng-dynamic-component/signal-component-io/public-api-beta.d.ts",
15+
"publicTrimmedFilePath": "<projectFolder>/dist/ng-dynamic-component/signal-component-io/public-api.d.ts",
16+
"omitTrimmingComments": true
17+
}
18+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"lib": {
3+
"entryFile": "src/public-api.ts"
4+
}
5+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"name": "signal-component-io"
3+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { NgModule } from '@angular/core';
2+
import { ComponentIO } from 'ng-dynamic-component';
3+
import { SignalComponentIO } from './signal-component-io';
4+
5+
/**
6+
* @public
7+
* @experimental
8+
*/
9+
@NgModule({
10+
providers: [{ provide: ComponentIO, useClass: SignalComponentIO }],
11+
})
12+
export class SignalComponentIoModule {}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { ComponentRef } from '@angular/core';
2+
// @ts-ignore
3+
import { outputToObservable } from '@angular/core/rxjs-interop';
4+
import { SignalComponentIO } from './signal-component-io';
5+
import { of } from 'rxjs';
6+
7+
jest.mock(
8+
'@angular/core/rxjs-interop',
9+
() => ({ outputToObservable: jest.fn() }),
10+
{ virtual: true },
11+
);
12+
13+
class MockComponentRef<C> {
14+
constructor(public instance: C) {}
15+
setInput = jest.fn();
16+
}
17+
18+
describe('SignalComponentIO', () => {
19+
function setup<C>(instance: C = {} as any) {
20+
const componentIO = new SignalComponentIO();
21+
const mockComponentRef = new MockComponentRef(
22+
instance,
23+
) as MockComponentRef<C> & ComponentRef<Record<string, unknown>>;
24+
const mockOutputToObservable = outputToObservable as jest.Mock;
25+
26+
return { componentIO, mockComponentRef, mockOutputToObservable };
27+
}
28+
29+
describe('setInput()', () => {
30+
it('should call ComponentRef.setInput()', () => {
31+
const { componentIO, mockComponentRef } = setup();
32+
33+
componentIO.setInput(mockComponentRef, 'prop', 'value');
34+
35+
expect(mockComponentRef.setInput).toHaveBeenCalledWith('prop', 'value');
36+
});
37+
});
38+
39+
describe('getOutput()', () => {
40+
it('should return observable output as is', () => {
41+
const output = of('event');
42+
const { componentIO, mockComponentRef } = setup({ output });
43+
44+
componentIO.getOutput(mockComponentRef, 'output');
45+
46+
expect(componentIO.getOutput(mockComponentRef, 'output')).toBe(output);
47+
});
48+
49+
it('should convert signal output to observalbe', () => {
50+
const signal = { subscribe: jest.fn() };
51+
const observable = of('signal');
52+
const { componentIO, mockComponentRef, mockOutputToObservable } = setup({
53+
signal,
54+
});
55+
56+
mockOutputToObservable.mockReturnValue(observable);
57+
58+
expect(componentIO.getOutput(mockComponentRef, 'signal')).toBe(
59+
observable,
60+
);
61+
expect(mockOutputToObservable).toHaveBeenCalledWith(signal);
62+
});
63+
64+
it('should throw if output not an observable/signal', () => {
65+
const output = 'not observable/signal';
66+
const { componentIO, mockComponentRef } = setup({ output });
67+
68+
expect(() =>
69+
componentIO.getOutput(mockComponentRef, 'output'),
70+
).toThrowError('Component output is not an output!');
71+
});
72+
});
73+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { ComponentRef, Injectable } from '@angular/core';
2+
// @ts-ignore
3+
import { outputToObservable } from '@angular/core/rxjs-interop';
4+
import { ComponentIO, ComponentInputKey } from 'ng-dynamic-component';
5+
import { Observable, isObservable } from 'rxjs';
6+
7+
/**
8+
* @internal
9+
* @experimental
10+
*/
11+
@Injectable()
12+
export class SignalComponentIO implements ComponentIO {
13+
setInput<T, K extends ComponentInputKey<T>>(
14+
componentRef: ComponentRef<T>,
15+
name: K,
16+
value: T[K],
17+
): void {
18+
componentRef.setInput(name, value);
19+
}
20+
21+
getOutput<T, K extends ComponentInputKey<T>>(
22+
componentRef: ComponentRef<T>,
23+
name: K,
24+
): Observable<unknown> {
25+
const output = componentRef.instance[name];
26+
27+
if (isObservable(output)) {
28+
return output;
29+
}
30+
31+
if (this.isOutputSignal(output)) {
32+
return outputToObservable(output);
33+
}
34+
35+
throw new Error(`Component ${name} is not an output!`);
36+
}
37+
38+
private isOutputSignal(value: unknown): boolean {
39+
return (
40+
typeof value === 'object' &&
41+
value !== null &&
42+
typeof (value as any)['subscribe'] === 'function'
43+
);
44+
}
45+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './lib/signal-component-io.module';

0 commit comments

Comments
 (0)