Skip to content

Commit

Permalink
feat(outputs): add ability to pass template variables to outputs
Browse files Browse the repository at this point in the history
closes #331
  • Loading branch information
gund committed Mar 13, 2020
1 parent 5f2985b commit a13c7d6
Show file tree
Hide file tree
Showing 8 changed files with 208 additions and 16 deletions.
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,37 @@ class MyDynamicComponent1 {
Here you can update your inputs (ex. `inputs.hello = 'WORLD'`) and they will trigger standard Angular's life-cycle hooks
(of course you should consider which change detection strategy you are using).

#### Output template variables

**Since v6.1.0**

When you want to provide some values to your output handlers from template -
you can do so by supplying a special object to your output that has shape `{handler: fn, args: []}`:

```ts
@Component({
selector: 'my-component',
template: `
<ndc-dynamic
[ndcDynamicComponent]="component"
[ndcDynamicOutputs]="{
onSomething: { handler: doSomething, args: ['$event', tplVar] }
}"
></ndc-dynamic>
`,
})
class MyComponent {
component = MyDynamicComponent1;
tplVar = 'some value';
doSomething(event, tplValue) {}
}
```

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.

### Component Creation Events

You can subscribe to component creation events, being passed a reference to the `ComponentRef`:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
DynamicComponentInjectorToken,
} from '../component-injector';
import { DynamicIoDirective } from './dynamic-io.directive';
import { EventArgumentToken } from '../io';

const getComponentInjectorFrom = getByPredicate<ComponentInjectorComponent>(
By.directive(ComponentInjectorComponent),
Expand Down Expand Up @@ -399,6 +400,99 @@ describe('Directive: DynamicIo', () => {
});
});

describe('outputs with template arguments', () => {
let fixture: ComponentFixture<TestComponent>;
let injectorComp: ComponentInjectorComponent;
let injectedComp: MockedInjectedComponent;
let outputSpy: jest.Mock;

const init = (template: string) => {
TestBed.overrideTemplate(TestComponent, template);
fixture = TestBed.createComponent(TestComponent);
injectorComp = getComponentInjectorFrom(fixture).component;
injectedComp = injectorComp.component;
outputSpy = jest.fn();

fixture.componentInstance['outputSpy'] = outputSpy;
};

it('should bind outputs with event without specifying template arguments', () => {
init(
`<component-injector
[ndcDynamicOutputs]="{ onEvent: { handler: outputSpy } }"
></component-injector>`,
);
fixture.detectChanges();

injectedComp.onEvent.next('data');

expect(outputSpy).toHaveBeenCalledWith('data');
});

it('should bind outputs without event when set to null/undefined', () => {
init(
`<component-injector
[ndcDynamicOutputs]="{ onEvent: { handler: outputSpy, args: null } }"
></component-injector>`,
);
fixture.detectChanges();

injectedComp.onEvent.next('data');

expect(outputSpy).toHaveBeenCalledWith();
});

it('should bind outputs with event and template arguments', () => {
init(
`<component-injector
[ndcDynamicOutputs]="{ onEvent: { handler: outputSpy, args: ['$event', tplVar] } }"
></component-injector>`,
);
fixture.componentInstance['tplVar'] = 'from-template';
fixture.detectChanges();

injectedComp.onEvent.next('data');

expect(outputSpy).toHaveBeenCalledWith('data', 'from-template');
});

it('should bind outputs with updated template arguments', () => {
init(
`<component-injector
[ndcDynamicOutputs]="{ onEvent: { handler: outputSpy, args: ['$event', tplVar] } }"
></component-injector>`,
);
fixture.componentInstance['tplVar'] = 'from-template';
fixture.detectChanges();
injectedComp.onEvent.next('data');

expect(outputSpy).toHaveBeenCalledWith('data', 'from-template');

fixture.componentInstance['tplVar'] = 'new-value';
fixture.detectChanges();
injectedComp.onEvent.next('new-data');

expect(outputSpy).toHaveBeenCalledWith('new-data', 'new-value');
});

it('should bind outputs with custom event ID', () => {
TestBed.configureTestingModule({
providers: [{ provide: EventArgumentToken, useValue: '$e' }],
});
init(
`<component-injector
[ndcDynamicOutputs]="{ onEvent: { handler: outputSpy, args: ['$e', tplVar] } }"
></component-injector>`,
);
fixture.componentInstance['tplVar'] = 'from-template';
fixture.detectChanges();

injectedComp.onEvent.next('data');

expect(outputSpy).toHaveBeenCalledWith('data', 'from-template');
});
});

describe('outputs with `NgComponentOutlet`', () => {
let fixture: ComponentFixture<TestComponent>;
let outputSpy: jasmine.Spy;
Expand Down
10 changes: 10 additions & 0 deletions projects/ng-dynamic-component/src/lib/io/event-argument.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { InjectionToken } from '@angular/core';

export function defaultEventArgumentFactory() {
return '$event';
}

export const EventArgumentToken = new InjectionToken<string>('EventArgument', {
providedIn: 'root',
factory: defaultEventArgumentFactory,
});
2 changes: 2 additions & 0 deletions projects/ng-dynamic-component/src/lib/io/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export * from './types';
export * from './event-argument';
export * from './io.service';
export * from './io-factory.service';
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
import {
ComponentFactoryResolver,
Inject,
Injectable,
KeyValueDiffers,
} from '@angular/core';

import { EventArgumentToken } from './event-argument';
import { IoService } from './io.service';

@Injectable({ providedIn: 'root' })
export class IoFactoryService {
constructor(
private differs: KeyValueDiffers,
private cfr: ComponentFactoryResolver,
@Inject(EventArgumentToken)
private eventArgument: unknown,
) {}

create() {
return new IoService(this.differs, this.cfr);
return new IoService(this.differs, this.cfr, this.eventArgument);
}
}
62 changes: 47 additions & 15 deletions projects/ng-dynamic-component/src/lib/io/io.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
ChangeDetectorRef,
ComponentFactory,
ComponentFactoryResolver,
Inject,
Injectable,
KeyValueChanges,
KeyValueDiffers,
Expand All @@ -13,14 +14,9 @@ import { takeUntil } from 'rxjs/operators';

import { DynamicComponentInjector } from '../component-injector';
import { changesFromRecord, createNewChange, noop } from '../util';
import { EventArgumentToken } from './event-argument';
import { EventHandler, InputsType, OutputsType, OutputWithArgs } from './types';

export interface InputsType {
[k: string]: any;
}
export interface OutputsType {
// tslint:disable-next-line: ban-types
[k: string]: Function;
}
export interface IOMapInfo {
propName: string;
templateName: string;
Expand All @@ -32,6 +28,10 @@ export interface IoInitOptions {
trackOutputChanges?: boolean;
}

interface OutputsTypeProcessed extends OutputsType {
[k: string]: EventHandler;
}

const recordToChanges = changesFromRecord({ isFirstChanges: true });
const recordToNewChanges = changesFromRecord({ onlyNewChanges: true });

Expand Down Expand Up @@ -75,6 +75,8 @@ export class IoService implements OnDestroy {
constructor(
private differs: KeyValueDiffers,
private cfr: ComponentFactoryResolver,
@Inject(EventArgumentToken)
private eventArgument: string,
) {}

ngOnDestroy(): void {
Expand Down Expand Up @@ -106,7 +108,7 @@ export class IoService implements OnDestroy {
const compChanged = this.componentInstChanged;

if (compChanged || inputsChanged) {
const inputsChanges = this._getInputsChanges(this.inputs);
const inputsChanges = this._getInputsChanges();
if (inputsChanges) {
this._updateInputChanges(inputsChanges);
}
Expand Down Expand Up @@ -135,7 +137,7 @@ export class IoService implements OnDestroy {
return;
}

const inputsChanges = this._getInputsChanges(this.inputs);
const inputsChanges = this._getInputsChanges();

if (inputsChanges) {
const isNotFirstChange = !!this.lastInputChanges;
Expand Down Expand Up @@ -193,7 +195,7 @@ export class IoService implements OnDestroy {
.forEach(p =>
compInst[p]
.pipe(takeUntil(this.outputsShouldDisconnect$))
.subscribe(outputs[p]),
.subscribe((event: any) => (outputs[p] as EventHandler)(event)),
);
}

Expand All @@ -217,7 +219,7 @@ export class IoService implements OnDestroy {
this.outputsShouldDisconnect$.next();
}

private _getInputsChanges(inputs: any): KeyValueChangesAny {
private _getInputsChanges(): KeyValueChangesAny {
return this.inputsDiffer.diff(this.inputs);
}

Expand Down Expand Up @@ -265,22 +267,49 @@ export class IoService implements OnDestroy {
this.compFactory = this._resolveCompFactory();
}

private _resolveInputs(inputs: any): any {
private _resolveInputs(inputs: InputsType): InputsType {
if (!this.compFactory) {
return inputs;
}

return this._remapIO(inputs, this.compFactory.inputs);
}

private _resolveOutputs(outputs: any): any {
private _resolveOutputs(outputs: OutputsType): OutputsType {
outputs = this._processOutputs(outputs);

if (!this.compFactory) {
return outputs;
}

return this._remapIO(outputs, this.compFactory.outputs);
}

private _processOutputs(outputs: OutputsType): OutputsTypeProcessed {
const processedOutputs: OutputsTypeProcessed = {};

Object.keys(outputs).forEach(key => {
const outputExpr = outputs[key];

if (typeof outputExpr === 'function') {
processedOutputs[key] = outputExpr;
} else {
processedOutputs[key] =
outputExpr && this._processOutputArgs(outputExpr);
}
});

return processedOutputs;
}

private _processOutputArgs(output: OutputWithArgs): EventHandler {
const { handler } = output;
const args = 'args' in output ? output.args || [] : [this.eventArgument];

return event =>
handler(...args.map(arg => (arg === this.eventArgument ? event : arg)));
}

private _resolveChanges(changes: SimpleChanges): SimpleChanges {
if (!this.compFactory) {
return changes;
Expand All @@ -289,15 +318,18 @@ export class IoService implements OnDestroy {
return this._remapIO(changes, this.compFactory.inputs);
}

private _remapIO(io: any, mapping: IOMappingList): any {
private _remapIO<T extends Record<string, any>>(
io: T,
mapping: IOMappingList,
): T {
const newIO = {};

Object.keys(io).forEach(key => {
const newKey = this._findPropByTplInMapping(key, mapping) || key;
newIO[newKey] = io[key];
});

return newIO;
return newIO as T;
}

private _findPropByTplInMapping(
Expand Down
17 changes: 17 additions & 0 deletions projects/ng-dynamic-component/src/lib/io/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export interface InputsType {
[k: string]: any;
}
export interface OutputsType {
[k: string]: OutputExpression | undefined;
}

export interface OutputWithArgs {
handler: AnyFunction;
args?: any[];
}

export type OutputExpression = EventHandler | OutputWithArgs;

export type EventHandler<T = any> = (event: T) => any;

export type AnyFunction = (...args: any[]) => any;
2 changes: 2 additions & 0 deletions projects/ng-dynamic-component/src/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
* Public API Surface of ng-dynamic-component
*/

export * from './lib/io/types';
export * from './lib/io/event-argument';
export * from './lib/dynamic.module';
export * from './lib/component-injector';
export * from './lib/dynamic.component';
Expand Down

0 comments on commit a13c7d6

Please sign in to comment.