Skip to content

Commit

Permalink
feat(io): add support for signal based dynamic components IO
Browse files Browse the repository at this point in the history
Now you can enable support for signal based components IO if you are using Angular versions that support it.
  • Loading branch information
gund committed Dec 17, 2024
1 parent 787135c commit bad63a5
Show file tree
Hide file tree
Showing 16 changed files with 371 additions and 60 deletions.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@ will not work and you will have to import them separately (see their respective
If you still need to use both `<ndc-dynamic>` and dynamic inputs/outputs it is recommended
to keep using `DynamicModule` API.

### Singal based inputs/outputs (experimental)

**Since v10.8.0**

If you want to dynamically render signal based components - see [`signal-component-io`](projects/ng-dynamic-component/signal-component-io/README.md) package.

### NgComponentOutlet

You can also use [`NgComponentOutlet`](https://angular.io/api/common/NgComponentOutlet)
Expand Down
36 changes: 26 additions & 10 deletions goldens/ng-dynamic-component/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { IterableDiffers } from '@angular/core';
import { KeyValueDiffers } from '@angular/core';
import { NgComponentOutlet } from '@angular/common';
import { NgModuleRef } from '@angular/core';
import { Observable } from 'rxjs';
import { OnChanges } from '@angular/core';
import { OnDestroy } from '@angular/core';
import { Renderer2 } from '@angular/core';
Expand All @@ -36,6 +37,21 @@ export interface AttributesMap {
[key: string]: string;
}

// @public (undocumented)
export type ComponentInputKey<T> = keyof T & string;

// @public (undocumented)
export abstract class ComponentIO {
// (undocumented)
abstract getOutput<T, K extends ComponentInputKey<T>>(componentRef: ComponentRef<T>, name: K): Observable<unknown>;
// (undocumented)
abstract setInput<T, K extends ComponentInputKey<T>>(componentRef: ComponentRef<T>, name: K, value: T[K]): void;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration<ComponentIO, never>;
// (undocumented)
static ɵprov: i0.ɵɵInjectableDeclaration<ComponentIO>;
}

// @public (undocumented)
export class ComponentOutletInjectorDirective implements DynamicComponentInjector {
constructor(componentOutlet: NgComponentOutlet);
Expand Down Expand Up @@ -123,11 +139,11 @@ export class DynamicAttributesModule {
static ɵfac: i0.ɵɵFactoryDeclaration<DynamicAttributesModule, never>;
// (undocumented)
static ɵinj: i0.ɵɵInjectorDeclaration<DynamicAttributesModule>;
// Warning: (ae-forgotten-export) The symbol "i1_4" needs to be exported by the entry point public-api.d.ts
// Warning: (ae-forgotten-export) The symbol "i1_2" needs to be exported by the entry point public-api.d.ts
// Warning: (ae-forgotten-export) The symbol "i2_2" needs to be exported by the entry point public-api.d.ts
//
// (undocumented)
static ɵmod: i0.ɵɵNgModuleDeclaration<DynamicAttributesModule, never, [typeof i1_4.DynamicAttributesDirective], [typeof i1_4.DynamicAttributesDirective, typeof i2_2.ComponentOutletInjectorModule]>;
static ɵmod: i0.ɵɵNgModuleDeclaration<DynamicAttributesModule, never, [typeof i1_2.DynamicAttributesDirective], [typeof i1_2.DynamicAttributesDirective, typeof i2_2.ComponentOutletInjectorModule]>;
}

// @public (undocumented)
Expand Down Expand Up @@ -206,10 +222,10 @@ export class DynamicDirectivesModule {
static ɵfac: i0.ɵɵFactoryDeclaration<DynamicDirectivesModule, never>;
// (undocumented)
static ɵinj: i0.ɵɵInjectorDeclaration<DynamicDirectivesModule>;
// Warning: (ae-forgotten-export) The symbol "i1_5" needs to be exported by the entry point public-api.d.ts
// Warning: (ae-forgotten-export) The symbol "i1_3" needs to be exported by the entry point public-api.d.ts
//
// (undocumented)
static ɵmod: i0.ɵɵNgModuleDeclaration<DynamicDirectivesModule, never, [typeof i1_5.DynamicDirectivesDirective], [typeof i1_5.DynamicDirectivesDirective, typeof i2_2.ComponentOutletInjectorModule]>;
static ɵmod: i0.ɵɵNgModuleDeclaration<DynamicDirectivesModule, never, [typeof i1_3.DynamicDirectivesDirective], [typeof i1_3.DynamicDirectivesDirective, typeof i2_2.ComponentOutletInjectorModule]>;
}

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

// @public (undocumented)
Expand All @@ -245,11 +261,11 @@ export class DynamicModule {
static ɵfac: i0.ɵɵFactoryDeclaration<DynamicModule, never>;
// (undocumented)
static ɵinj: i0.ɵɵInjectorDeclaration<DynamicModule>;
// Warning: (ae-forgotten-export) The symbol "i1_2" needs to be exported by the entry point public-api.d.ts
// Warning: (ae-forgotten-export) The symbol "i1_5" needs to be exported by the entry point public-api.d.ts
// Warning: (ae-forgotten-export) The symbol "i2_3" needs to be exported by the entry point public-api.d.ts
//
// (undocumented)
static ɵmod: i0.ɵɵNgModuleDeclaration<DynamicModule, never, [typeof i1_2.DynamicIoModule, typeof i2_3.DynamicComponent], [typeof i1_2.DynamicIoModule, typeof i2_3.DynamicComponent]>;
static ɵmod: i0.ɵɵNgModuleDeclaration<DynamicModule, never, [typeof i1_5.DynamicIoModule, typeof i2_3.DynamicComponent], [typeof i1_5.DynamicIoModule, typeof i2_3.DynamicComponent]>;
}

// @public @deprecated (undocumented)
Expand Down Expand Up @@ -292,12 +308,12 @@ export interface IoFactoryServiceOptions {

// @public (undocumented)
export class IoService implements OnDestroy {
constructor(injector: Injector, differs: KeyValueDiffers, cfr: ComponentFactoryResolver, options: IoServiceOptions, compInjector: DynamicComponentInjector, eventArgument: string, cdr: ChangeDetectorRef, eventContextProvider: StaticProvider);
constructor(injector: Injector, differs: KeyValueDiffers, cfr: ComponentFactoryResolver, options: IoServiceOptions, compInjector: DynamicComponentInjector, eventArgument: string, cdr: ChangeDetectorRef, eventContextProvider: StaticProvider, componentIO: ComponentIO);
// (undocumented)
ngOnDestroy(): void;
update(inputs?: InputsType | null, outputs?: OutputsType | null): void;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration<IoService, [null, null, null, null, null, null, null, { optional: true; }]>;
static ɵfac: i0.ɵɵFactoryDeclaration<IoService, [null, null, null, null, null, null, null, { optional: true; }, null]>;
// (undocumented)
static ɵprov: i0.ɵɵInjectableDeclaration<IoService>;
}
Expand Down
12 changes: 10 additions & 2 deletions projects/ng-dynamic-component/project.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"name": "ng-dynamic-component",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "library",
"sourceRoot": "projects/ng-dynamic-component/src",
Expand Down Expand Up @@ -26,14 +27,21 @@
},
"test": {
"executor": "@angular-builders/jest:run",
"options": {}
"options": {},
"configurations": {
"watch": {
"watch": true
}
}
},
"lint": {
"executor": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"projects/ng-dynamic-component/**/*.ts",
"projects/ng-dynamic-component/**/*.html"
"projects/ng-dynamic-component/**/*.html",
"projects/ng-dynamic-component/signal-component-io/**/*.ts",
"projects/ng-dynamic-component/signal-component-io/**/*.html"
]
}
}
Expand Down
34 changes: 34 additions & 0 deletions projects/ng-dynamic-component/signal-component-io/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# ng-dynamic-component/signal-component-io

> Secondary entry point of `ng-dynamic-component`. It can be used by importing from `ng-dynamic-component/signal-component-io`.
This package enables signal based inputs/outputs support for dynamically rendered components.

## Prerequisites

This package requires Angular version which supports signals.
Please refer to (Angular docs)[https://angular.dev/] to see which minimal version is required.

## Warning: Experimental

This package is still **experimental** and not ready for producation!
APIs may change in the future or be removed completely and never make it to stable release!
Only use it to evaluate the features and provide feedback.

## Usage

**Since v10.8.0**

Import `SignalComponentIoModule` in your application module or config:

```ts
import { NgModule } from '@angular/core';
import { SignalComponentIoModule } from 'ng-dynamic-component/signal-component-io';

@NgModule({
imports: [SignalComponentIoModule],
})
class AppModule {}
```

Now you can render dynamic components with signal based inputs/outputs!
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"lib": {
"entryFile": "src/public-api.ts"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { NgModule } from '@angular/core';
import { ComponentIO } from 'ng-dynamic-component';
import { SignalComponentIO } from './signal-component-io';

/**
* @public
* @experimental
*/
@NgModule({
providers: [{ provide: ComponentIO, useClass: SignalComponentIO }],
})
export class SignalComponentIoModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { ComponentRef } from '@angular/core';
// @ts-ignore
import { outputToObservable } from '@angular/core/rxjs-interop';
import { SignalComponentIO } from './signal-component-io';
import { of } from 'rxjs';

jest.mock(
'@angular/core/rxjs-interop',
() => ({ outputToObservable: jest.fn() }),
{ virtual: true },
);

class MockComponentRef<C> {
constructor(public instance: C) {}
setInput = jest.fn();
}

describe('SignalComponentIO', () => {
function setup<C>(instance: C = {} as any) {
const componentIO = new SignalComponentIO();
const mockComponentRef = new MockComponentRef(
instance,
) as MockComponentRef<C> & ComponentRef<Record<string, unknown>>;
const mockOutputToObservable = outputToObservable as jest.Mock;

return { componentIO, mockComponentRef, mockOutputToObservable };
}

describe('setInput()', () => {
it('should call ComponentRef.setInput()', () => {
const { componentIO, mockComponentRef } = setup();

componentIO.setInput(mockComponentRef, 'prop', 'value');

expect(mockComponentRef.setInput).toHaveBeenCalledWith('prop', 'value');
});
});

describe('getOutput()', () => {
it('should return observable output as is', () => {
const output = of('event');
const { componentIO, mockComponentRef } = setup({ output });

componentIO.getOutput(mockComponentRef, 'output');

expect(componentIO.getOutput(mockComponentRef, 'output')).toBe(output);
});

it('should convert signal output to observalbe', () => {
const signal = { subscribe: jest.fn() };
const observable = of('signal');
const { componentIO, mockComponentRef, mockOutputToObservable } = setup({
signal,
});

mockOutputToObservable.mockReturnValue(observable);

expect(componentIO.getOutput(mockComponentRef, 'signal')).toBe(
observable,
);
expect(mockOutputToObservable).toHaveBeenCalledWith(signal);
});

it('should throw if output not an observable/signal', () => {
const output = 'not observable/signal';
const { componentIO, mockComponentRef } = setup({ output });

expect(() =>
componentIO.getOutput(mockComponentRef, 'output'),
).toThrowError('Component output is not an output!');
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { ComponentRef, Injectable } from '@angular/core';
// @ts-ignore
import { outputToObservable } from '@angular/core/rxjs-interop';
import { ComponentIO, ComponentInputKey } from 'ng-dynamic-component';
import { Observable, isObservable } from 'rxjs';

/**
* @internal
* @experimental
*/
@Injectable()
export class SignalComponentIO implements ComponentIO {
setInput<T, K extends ComponentInputKey<T>>(
componentRef: ComponentRef<T>,
name: K,
value: T[K],
): void {
componentRef.setInput(name, value);
}

getOutput<T, K extends ComponentInputKey<T>>(
componentRef: ComponentRef<T>,
name: K,
): Observable<unknown> {
const output = componentRef.instance[name];

if (isObservable(output)) {
return output;
}

if (this.isOutputSignal(output)) {
return outputToObservable(output);
}

throw new Error(`Component ${name} is not an output!`);
}

private isOutputSignal(value: unknown): boolean {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as any)['subscribe'] === 'function'
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './lib/signal-component-io.module';
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { ComponentRef } from '@angular/core';
import { of } from 'rxjs';
import { ClassicComponentIO } from './classic-component-io';

class MockComponentRef<C> {
constructor(public instance: C) {}
setInput = jest.fn();
}

describe('ClassicComponentIO', () => {
function setup<C>(instance: C = {} as any) {
const componentIO = new ClassicComponentIO();
const mockComponentRef = new MockComponentRef(
instance,
) as MockComponentRef<C> & ComponentRef<Record<string, unknown>>;

return { componentIO, mockComponentRef };
}

describe('setInput()', () => {
it('should call ComponentRef.setInput()', () => {
const { componentIO, mockComponentRef } = setup();

componentIO.setInput(mockComponentRef, 'prop', 'value');

expect(mockComponentRef.setInput).toHaveBeenCalledWith('prop', 'value');
});
});

describe('getOutput()', () => {
it('should return observable output as is', () => {
const output = of('event');
const { componentIO, mockComponentRef } = setup({ output });

componentIO.getOutput(mockComponentRef, 'output');

expect(componentIO.getOutput(mockComponentRef, 'output')).toBe(output);
});

it('should throw if output not an observable', () => {
const output = 'not observable';
const { componentIO, mockComponentRef } = setup({ output });

expect(() =>
componentIO.getOutput(mockComponentRef, 'output'),
).toThrowError('Component output is not an output!');
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { isObservable, Observable } from 'rxjs';
import { ComponentInputKey, ComponentIO } from './component-io';
import { ComponentRef, Injectable } from '@angular/core';

/** @internal */
@Injectable()
export class ClassicComponentIO implements ComponentIO {
setInput<T, K extends ComponentInputKey<T>>(
componentRef: ComponentRef<T>,
name: K,
value: T[K],
): void {
componentRef.setInput(name, value);
}

getOutput<T, K extends ComponentInputKey<T>>(
componentRef: ComponentRef<T>,
name: K,
): Observable<unknown> {
const output = componentRef.instance[name];

if (!isObservable(output)) {
throw new Error(`Component ${name} is not an output!`);
}

return output;
}
}
Loading

0 comments on commit bad63a5

Please sign in to comment.