Skip to content

Commit

Permalink
feat(outputs): allow to specify context for output handlers via Tokens
Browse files Browse the repository at this point in the history
Add `IoEventContextToken` and `IoEventContextProviderToken` that will be used as a context when
output handlers are invoked.

Now `IoService` is part of the public API and you may use it to bind inputs/outputs imperatively
anywhere from your code. To provide it use `IoServiceProvider` to remain BC compatible in the future.

Also most of the codebase was refactored to replace `any` with more typesafe `unknown` and one bug
in dynamic directive ref was fixed when host component was typed as a class instead of an instance.

DEPRECATIONS:
- `EventArgumentToken`: please use `IoEventArgumentToken`
  • Loading branch information
gund committed Aug 27, 2022
1 parent ae527d6 commit 9a03765
Show file tree
Hide file tree
Showing 15 changed files with 390 additions and 186 deletions.
48 changes: 47 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,53 @@ class MyComponent {
Here you can specify at which argument event value should arrive via `'$event'` literal.

_HINT:_ You can override event literal by providing
[`EventArgumentToken`](projects/ng-dynamic-component/src/lib/io/event-argument.ts) in DI.
[`IoEventArgumentToken`](projects/ng-dynamic-component/src/lib/io/event-argument.ts) in DI.

#### Output Handler Context

**Since v7.1.0**

You can specify the context (`this`) that will be used when calling
the output handlers by providing either:

- [`IoEventContextToken`](projects/ng-dynamic-component/src/lib/io/event-context.ts) - which will be;
injected and used directly as a context value
- [`IoEventContextProviderToken`](projects/ng-dynamic-component/src/lib/io/event-context.ts) - which
will be provided and instantiated within the `IoService` and used as a context value.
This useful if you have some generic way of retrieving a
context for every dynamic component so you may encapsulate
it in an Angular DI provider that will be instantiated
within every component's injector;

Example using your component as an output context:

```ts
import { IoEventContextToken } from 'ng-dynamic-component';

@Component({
selector: 'my-component',
template: `
<ndc-dynamic
[ndcDynamicComponent]="component"
[ndcDynamicOutputs]="{
onSomething: doSomething
}"
></ndc-dynamic>
`,
providers: [
{
provide: IoEventContextToken,
useExisting: MyComponent,
},
],
})
class MyComponent {
component = MyDynamicComponent1;
doSomething(event) {
// Here `this` will be an instance of `MyComponent`
}
}
```

### Component Creation Events

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
export class ComponentOutletInjectorDirective
implements DynamicComponentInjector
{
get componentRef(): ComponentRef<any> {
get componentRef(): ComponentRef<unknown> {
// NOTE: Accessing private APIs of Angular
return (this.componentOutlet as any)._componentRef;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ComponentRef, InjectionToken } from '@angular/core';

export interface DynamicComponentInjector {
componentRef: ComponentRef<any> | null;
componentRef: ComponentRef<unknown> | null;
}

export const DynamicComponentInjectorToken =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export class DynamicAttributesDirective implements DoCheck {
ngComponentOutletNdcDynamicAttributes: AttributesMap;

private attrsDiffer = this.differs.find({}).create<string, string>();
private lastCompType: Type<any>;
private lastCompType: Type<unknown>;
private lastAttrActions: AttributeActions;

private get _attributes() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
Optional,
Output,
Type,
ViewContainerRef,
ViewRef,
} from '@angular/core';

Expand All @@ -23,7 +22,7 @@ import {
DynamicComponentInjectorToken,
} from '../component-injector';
import { InputsType, IoFactoryService, IoService, OutputsType } from '../io';
import { extractNgParamTypes, getCtorParamTypes } from '../util';
import { extractNgParamTypes, getCtorParamTypes, isOnDestroy } from '../util';
import { WindowRefService } from '../window-ref';

export interface DynamicDirectiveDef<T> {
Expand All @@ -44,7 +43,7 @@ export interface DirectiveRef<T> {
instance: T;
type: Type<T>;
injector: Injector;
hostComponent: Type<any>;
hostComponent: unknown;
hostView: ViewRef;
location: ElementRef;
changeDetectorRef: ChangeDetectorRef;
Expand All @@ -65,14 +64,14 @@ export interface DirectiveRef<T> {
})
export class DynamicDirectivesDirective implements OnDestroy, DoCheck {
@Input()
ndcDynamicDirectives?: DynamicDirectiveDef<any>[];
ndcDynamicDirectives?: DynamicDirectiveDef<unknown>[];
@Input()
ngComponentOutletNdcDynamicDirectives?: DynamicDirectiveDef<any>[];
ngComponentOutletNdcDynamicDirectives?: DynamicDirectiveDef<unknown>[];

@Output()
ndcDynamicDirectivesCreated = new EventEmitter<DirectiveRef<any>[]>();
ndcDynamicDirectivesCreated = new EventEmitter<DirectiveRef<unknown>[]>();

private lastCompInstance: any;
private lastCompInstance: unknown;

private get directives() {
return (
Expand Down Expand Up @@ -100,23 +99,18 @@ export class DynamicDirectivesDirective implements OnDestroy, DoCheck {
return this.componentRef.injector;
}

private get hostVcr(): ViewContainerRef {
// NOTE: Accessing private APIs of Angular
// eslint-disable-next-line @typescript-eslint/dot-notation
return this.componentRef['_viewRef']['_viewContainerRef'];
}

private get reflect() {
return (this.windowRef.nativeWindow as any).Reflect;
}

private dirRef = new Map<Type<any>, DirectiveRef<any>>();
private dirIo = new Map<Type<any>, IoService>();
private dirRef = new Map<Type<unknown>, DirectiveRef<unknown>>();
private dirIo = new Map<Type<unknown>, IoService>();
private dirsDiffer = this.iterableDiffers
.find([])
.create<DynamicDirectiveDef<any>>((_, def) => def.type);
.create<DynamicDirectiveDef<unknown>>((_, def) => def.type);

constructor(
private injector: Injector,
private iterableDiffers: IterableDiffers,
private ioFactoryService: IoFactoryService,
private windowRef: WindowRefService,
Expand Down Expand Up @@ -153,7 +147,7 @@ export class DynamicDirectivesDirective implements OnDestroy, DoCheck {
}

private processDirChanges(
changes: IterableChanges<DynamicDirectiveDef<any>>,
changes: IterableChanges<DynamicDirectiveDef<unknown>>,
) {
changes.forEachRemovedItem(({ item }) => this.destroyDirective(item));

Expand All @@ -171,21 +165,21 @@ export class DynamicDirectivesDirective implements OnDestroy, DoCheck {
this.directives?.forEach((dir) => this.updateDirective(dir));
}

private updateDirective(dirDef: DynamicDirectiveDef<any>) {
private updateDirective(dirDef: DynamicDirectiveDef<unknown>) {
const io = this.dirIo.get(dirDef.type);
io.update(dirDef.inputs, dirDef.outputs, false, false);
io.update(dirDef.inputs, dirDef.outputs);
io.maybeUpdate();
}

private initDirective(
dirDef: DynamicDirectiveDef<any>,
): DirectiveRef<any> | undefined {
dirDef: DynamicDirectiveDef<unknown>,
): DirectiveRef<unknown> | undefined {
if (this.dirRef.has(dirDef.type)) {
return;
}

const instance = this.createDirective(dirDef.type);
const directiveRef: DirectiveRef<any> = {
const directiveRef: DirectiveRef<unknown> = {
instance,
type: dirDef.type,
injector: this.hostInjector,
Expand All @@ -210,7 +204,7 @@ export class DynamicDirectivesDirective implements OnDestroy, DoCheck {
this.dirIo.clear();
}

private destroyDirective(dirDef: DynamicDirectiveDef<any>) {
private destroyDirective(dirDef: DynamicDirectiveDef<unknown>) {
this.destroyDirRef(this.dirRef.get(dirDef.type));
this.dirRef.delete(dirDef.type);
this.dirIo.delete(dirDef.type);
Expand All @@ -221,18 +215,18 @@ export class DynamicDirectivesDirective implements OnDestroy, DoCheck {
dirDef: DynamicDirectiveDef<any>,
) {
const io = this.ioFactoryService.create();
this.dirIo.set(dirRef.type, io);
io.init(
{ componentRef: this.dirToCompDef(dirRef, dirDef) },
{ trackOutputChanges: true },
);
io.update(dirDef.inputs, dirDef.outputs, !!dirDef.inputs, !!dirDef.outputs);
io.update(dirDef.inputs, dirDef.outputs);
this.dirIo.set(dirRef.type, io);
}

private dirToCompDef(
dirRef: DirectiveRef<any>,
dirDef: DynamicDirectiveDef<any>,
): ComponentRef<any> {
dirRef: DirectiveRef<unknown>,
dirDef: DynamicDirectiveDef<unknown>,
): ComponentRef<unknown> {
return {
changeDetectorRef: this.componentRef.changeDetectorRef,
hostView: this.componentRef.hostView,
Expand All @@ -246,11 +240,11 @@ export class DynamicDirectivesDirective implements OnDestroy, DoCheck {
};
}

private destroyDirRef(dir: DirectiveRef<any>) {
private destroyDirRef(dir: DirectiveRef<unknown>) {
const io = this.dirIo.get(dir.type);
io.ngOnDestroy();

if ('ngOnDestroy' in dir.instance) {
if (isOnDestroy(dir.instance)) {
dir.instance.ngOnDestroy();
}
}
Expand All @@ -272,7 +266,7 @@ export class DynamicDirectivesDirective implements OnDestroy, DoCheck {
return directiveInjector.get(dirType);
}

private resolveDirParamTypes(dirType: Type<any>): any[] {
private resolveDirParamTypes(dirType: Type<unknown>): unknown[] {
return (
// First try Angular Compiler's metadata
extractNgParamTypes(dirType) ??
Expand All @@ -283,7 +277,7 @@ export class DynamicDirectivesDirective implements OnDestroy, DoCheck {
);
}

private callInitHooks(obj: any) {
private callInitHooks(obj: unknown) {
this.callHook(obj, 'ngOnInit');
this.callHook(obj, 'ngDoCheck');
this.callHook(obj, 'ngAfterContentInit');
Expand All @@ -292,7 +286,7 @@ export class DynamicDirectivesDirective implements OnDestroy, DoCheck {
this.callHook(obj, 'ngAfterViewChecked');
}

private callHook(obj: any, hook: string, args: any[] = []) {
private callHook(obj: unknown, hook: string, args: unknown[] = []) {
if (obj[hook]) {
obj[hook](...args);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ import { By } from '@angular/platform-browser';
import { TestFixture, TestSetup } from '../../test';
import { ComponentOutletInjectorDirective } from '../component-injector';
import { DynamicComponent as NdcDynamicComponent } from '../dynamic.component';
import { EventArgumentToken, InputsType, OutputsType } from '../io';
import { IoEventArgumentToken, InputsType, OutputsType } from '../io';
import {
IoEventContextProviderToken,
IoEventContextToken,
} from '../io/event-context';
import { DynamicIoDirective } from './dynamic-io.directive';

describe('Directive: DynamicIo', () => {
Expand Down Expand Up @@ -550,7 +554,77 @@ describe('Directive: DynamicIo', () => {
></ng-container>
`,
ngModule: {
providers: [{ provide: EventArgumentToken, useValue: '$e' }],
providers: [{ provide: IoEventArgumentToken, useValue: '$e' }],
},
});

fixture.setHostProps({ tplVar: 'from-template' });

fixture.getDynamicComponent().output1.emit('val1');

expect(outputs.output).toHaveBeenCalledTimes(1);
expect(outputs.output).toHaveBeenCalledWith('val1', 'from-template');
});

it('should bind outputs with custom global context', async () => {
const customEventContext = { customEventContext: 'global' };
const outputs = {
output: jest.fn().mockImplementation(function () {
// Use non-strict equal due to a bug in Jest
// that clones `this` object and destroys original ref
expect(this).toEqual(customEventContext);
}),
};

const fixture = await testSetup.redner<{ tplVar: any }>({
props: { outputs },
template: `
<ng-container [ngComponentOutlet]="component"
[ndcDynamicOutputs]="{ output1: { handler: outputs.output, args: ['$event', tplVar] } }"
></ng-container>
`,
ngModule: {
providers: [
{ provide: IoEventContextToken, useValue: customEventContext },
],
},
});

fixture.setHostProps({ tplVar: 'from-template' });

fixture.getDynamicComponent().output1.emit('val1');

expect(outputs.output).toHaveBeenCalledTimes(1);
expect(outputs.output).toHaveBeenCalledWith('val1', 'from-template');
});

it('should bind outputs with custom local context', async () => {
const customEventContext = { customEventContext: 'local' };
const outputs = {
output: jest.fn().mockImplementation(function () {
// Use non-strict equal due to a bug in Jest
// that clones `this` object and destroys original ref
expect(this).toEqual(customEventContext);
}),
};

const fixture = await testSetup.redner<{ tplVar: any }>({
props: { outputs },
template: `
<ng-container [ngComponentOutlet]="component"
[ndcDynamicOutputs]="{ output1: { handler: outputs.output, args: ['$event', tplVar] } }"
></ng-container>
`,
ngModule: {
providers: [
{
provide: IoEventContextProviderToken,
useValue: {
provide: IoEventContextToken,
useValue: customEventContext,
},
},
],
},
});

Expand Down
Loading

0 comments on commit 9a03765

Please sign in to comment.