Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] [Feat] Template syntax parser #485

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/* eslint-disable @angular-eslint/no-conflicting-lifecycle */
import {
Directive,
DoCheck,
Inject,
Input,
OnChanges,
SimpleChanges,
} from '@angular/core';
import {
DynamicComponentInjector,
DynamicComponentInjectorToken,
} from '../component-injector';
import { IoService } from '../io';
import { IoAdapterService } from '../io/io-adapter.service';
import { IOData } from '../io/io-data';
import { TemplateParser, TemplateTokeniser } from '../template';

@Directive({
selector: '[ndcDynamicIo]',
exportAs: 'ndcDynamicIo',
providers: [IoService, IoAdapterService],
})
export class DynamicIoV2Directive implements DoCheck, OnChanges {
@Input()
ndcDynamicIo?: IOData | string | null;

private get componentInst(): Record<string, unknown> {
return (
(this.compInjector.componentRef?.instance as Record<string, unknown>) ??
{}
);
}

constructor(
private ioService: IoAdapterService,
@Inject(DynamicComponentInjectorToken)
private compInjector: DynamicComponentInjector,
) {}

async ngOnChanges(changes: SimpleChanges) {
if (changes['ndcDynamicIo'] && typeof this.ndcDynamicIo === 'string') {
this.updateIo(await this.strToIo(this.ndcDynamicIo));
}
}

ngDoCheck() {
if (typeof this.ndcDynamicIo !== 'string') {
this.updateIo(this.ndcDynamicIo);
}
}

private async updateIo(io?: IOData | null) {
this.ioService.update(io);
}

private strToIo(ioStr: string) {
const tokeniser = new TemplateTokeniser();
const parser = new TemplateParser(tokeniser, this.componentInst);
const ioPromise = parser.getIo();

tokeniser.feed(ioStr);

return ioPromise;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';

import { ComponentOutletInjectorModule } from '../component-outlet';
import { DynamicIoV2Directive } from './dynamic-io-v2.directive';

/**
* @public
*/
@NgModule({
imports: [CommonModule],
exports: [DynamicIoV2Directive, ComponentOutletInjectorModule],
declarations: [DynamicIoV2Directive],
})
export class DynamicIoV2Module {}
2 changes: 2 additions & 0 deletions projects/ng-dynamic-component/src/lib/dynamic-io-v2/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './dynamic-io-v2.directive';
export * from './dynamic-io-v2.module';
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
Expand Up @@ -2,3 +2,5 @@ export * from './types';
export * from './event-argument';
export * from './io.service';
export * from './io-factory.service';
export * from './io-data';
export * from './io-adapter.service';
184 changes: 184 additions & 0 deletions projects/ng-dynamic-component/src/lib/io/io-adapter.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { Inject, Injectable, KeyValueDiffers } from '@angular/core';
import {
DynamicComponentInjector,
DynamicComponentInjectorToken,
} from '../component-injector';
import { IOData } from './io-data';
import { IoService } from './io.service';
import { EventHandler, InputsType, OutputsType } from './types';

@Injectable()
export class IoAdapterService {
private ioDiffer = this.differs.find({}).create();

private inputs: InputsType = {};
private outputs: OutputsType = {};

private get componentInst(): Record<string, unknown> {
return (
(this.compInjector.componentRef?.instance as Record<string, unknown>) ??
{}
);
}

constructor(
private differs: KeyValueDiffers,
private ioService: IoService,
@Inject(DynamicComponentInjectorToken)
private compInjector: DynamicComponentInjector,
) {}

update(io?: IOData | null): void {
if (!io) {
io = {};
}

const ioChanges = this.ioDiffer.diff(io);

if (!ioChanges) {
return;
}

ioChanges.forEachRemovedItem((record) => {
const name = this.getIOName(record.key);
delete this.inputs[name];
delete this.outputs[name];
});

ioChanges.forEachAddedItem((record) => {
this.updateProp(record.key, record.currentValue);
});

ioChanges.forEachChangedItem((record) => {
this.updateProp(record.key, record.currentValue);
});

this.ioService.update(this.inputs, this.outputs);
}

private getIOName(prop: string) {
if (prop.startsWith('[') || prop.startsWith('(')) {
return prop.slice(1, -1);
}

if (prop.startsWith('[(')) {
return prop.slice(2, -2);
}

return prop;
}

private updateProp(prop: string, data: unknown) {
if (this.maybeInputBind(prop, data, this.inputs)) {
return;
}

if (this.maybeOutput(prop, data, this.outputs)) {
return;
}

if (this.maybeInput2W(prop, data, this.inputs, this.outputs)) {
return;
}

if (this.maybeInputProp(prop, data, this.inputs)) {
return;
}

throw new Error(`IoAdapterService: Unknown binding type '${prop}!'`);
}

private maybeInputBind(prop: string, data: unknown, record: InputsType) {
if (!prop.startsWith('[') || !prop.endsWith(']')) {
return false;
}

const name = prop.slice(1, -1);

if (typeof data === 'string' && data in this.componentInst) {
this.addPropGetter(record, name);
return true;
}

try {
if (typeof data === 'string') {
data = JSON.parse(data);
}
} catch {
throw new Error(
`Input binding must be a string or valid JSON string but given ${typeof data}!`,
);
}

record[name] = data;

return true;
}

private maybeInputProp(prop: string, data: unknown, inputs: InputsType) {
if (typeof data !== 'string') {
throw new Error(`Input binding should be a string!`);
}

inputs[prop] = data;

return true;
}

private maybeInput2W(
prop: string,
data: unknown,
inputs: InputsType,
outputs: OutputsType,
) {
if (!prop.startsWith('[(') || !prop.endsWith(')]')) {
return false;
}

if (typeof data !== 'string') {
throw new Error(`Two-way binding must be a string!`);
}

const input = prop.slice(2, -2);
const output = `${input}Change`;

this.addPropGetter(inputs, input, data);

outputs[output] = (value) => void (this.componentInst[data] = value);

return true;
}

private maybeOutput(prop: string, data: unknown, record: OutputsType) {
if (!prop.startsWith('(') || !prop.endsWith(')')) {
return false;
}

const name = prop.slice(1, -1);

if (typeof data === 'string' && data in this.componentInst) {
this.addPropGetter(record, name);
return true;
}

if (typeof data !== 'function') {
throw new Error(`Output binding must be function or method name!`);
}

record[name] = data as EventHandler;

return true;
}

private addPropGetter(
obj: Record<string, unknown>,
name: string,
prop = name,
) {
Object.defineProperty(obj, name, {
configurable: true,
enumerable: true,
get: () => this.componentInst[prop],
});
}
}
58 changes: 58 additions & 0 deletions projects/ng-dynamic-component/src/lib/io/io-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { EventEmitter } from '@angular/core';

export interface IOData {
[prop: string]: unknown;
}

type InferEventEmitter<T> = T extends EventEmitter<infer E> ? E : unknown;

type SkipPropsByType<T, TSkip> = {
[K in keyof T]: T[K] extends TSkip ? never : K;
}[keyof T];

type PickPropsWithOutputs<
O extends string | number | symbol,
I extends string | number | symbol,
> = O extends `${infer K}Change` ? (K extends I ? K : never) : never;

export type Inputs<K extends keyof T, T> = Pick<T, K>;

export type InputProps<K extends keyof T, T> = {
[P in K as `[${P & string}]`]: T[P];
};

export type Inputs2Way<K> = {
[P in K as `([${P & string}])`]: string;
};

export type InputsAttrs = {
[P in [] as `[attr.${string}]`]?: string | null;
};

export type InputsClasses = {
[P in [] as `[class.${string}]`]?: string | boolean | null;
};

export type InputsStyles = {
[P in [] as `[style.${string}]`]?: unknown;
};

export type Outputs<K extends keyof T, T> = {
[P in K as `(${P & string})`]: (event: InferEventEmitter<T[P]>) => void;
};

export type IO<
T,
I extends keyof T = SkipPropsByType<T, EventEmitter<any>>,
O extends keyof T = Exclude<keyof T, I>,
I2W extends keyof T = PickPropsWithOutputs<O, I>,
> = Partial<
Inputs<I, T> &
InputProps<I, T> &
Inputs2Way<I2W> &
Outputs<O, T> &
InputsAttrs &
InputsClasses &
InputsStyles &
Record<string, unknown>
>;
Loading