Skip to content

Commit

Permalink
feat(common): introduce ng-dynamic directive
Browse files Browse the repository at this point in the history
  • Loading branch information
trotyl committed Sep 21, 2019
1 parent 6e185bb commit cd4843d
Show file tree
Hide file tree
Showing 19 changed files with 340 additions and 9 deletions.
2 changes: 2 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ jobs:
paths:
- node_modules
key: v1-dependencies-{{ checksum "yarn.lock" }}

- run: yarn build:all

- run: yarn test

Expand Down
3 changes: 2 additions & 1 deletion projects/common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"version": "{{ version }}",
"peerDependencies": {
"@angular/common": "^{{ angularCompatVersion }}",
"@angular/core": "^{{ angularCompatVersion }}"
"@angular/core": "^{{ angularCompatVersion }}",
"@angular-contrib/core": "{{ version }}"
}
}
26 changes: 26 additions & 0 deletions projects/common/src/ng-dynamic/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# NgDynamic

Helper directive to render dynamic tag.

## Type

**Directive**

## Provenance

None.

## NgModule

`@angular-contrib/core#ContribNgDynamicModule`

## Usage

```typescript
@Component({
template: `
<ng-dynamic [tag]="tag" [props]="props" [attrs]="attrs" [styles]="styles"></ng-dynamic>
`
})
class MyComponent {}
```
2 changes: 2 additions & 0 deletions projects/common/src/ng-dynamic/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './ng-dynamic';
export * from './ng-dynamic.module';
8 changes: 8 additions & 0 deletions projects/common/src/ng-dynamic/ng-dynamic.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { NgModule } from '@angular/core';
import { NgDynamic } from './ng-dynamic';

@NgModule({
declarations: [NgDynamic],
exports: [NgDynamic],
})
export class ContribNgDynamicModule {}
65 changes: 65 additions & 0 deletions projects/common/src/ng-dynamic/ng-dynamic.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ContribNgDynamicModule } from './ng-dynamic.module';
import { By } from '@angular/platform-browser';

describe('ng-dynamic', () => {
let fixture: ComponentFixture<TestComponent>;
let component: TestComponent;

beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [TestComponent],
imports: [CommonModule, ContribNgDynamicModule],
}).compileComponents();
}));

beforeEach(() => {
fixture = TestBed.createComponent(TestComponent);
component = fixture.componentInstance;
});

it('should render dynamic element', () => {
fixture.detectChanges();

const elements = fixture.debugElement.queryAll(By.css('section'));

expect(elements[0].nativeElement.outerHTML).toBe(`<section>Content</section>`);
expect(elements[1].nativeElement.outerHTML).toBe(`<section id="test-id">Content</section>`);
expect(elements[2].nativeElement.outerHTML).toBe(`<section title="test-title">Content</section>`);
expect(elements[3].nativeElement.outerHTML).toBe(`<section style="height: 50px;">Content</section>`);
});

it('should support changing tag name', () => {
fixture.detectChanges();

component.tag = 'article';
component.props = { id: 'test-id2' };
component.attrs = { title: 'test-title2' };
component.styles = { height: '60px' };
fixture.detectChanges();

const elements = fixture.debugElement.queryAll(By.css('article'));

expect(elements[0].nativeElement.outerHTML).toBe(`<article>Content</article>`);
expect(elements[1].nativeElement.outerHTML).toBe(`<article id="test-id2">Content</article>`);
expect(elements[2].nativeElement.outerHTML).toBe(`<article title="test-title2">Content</article>`);
expect(elements[3].nativeElement.outerHTML).toBe(`<article style="height: 60px;">Content</article>`);
});
});

@Component({
template: `
<ng-dynamic [tag]="tag">Content</ng-dynamic>
<ng-dynamic [tag]="tag" [props]="props">Content</ng-dynamic>
<ng-dynamic [tag]="tag" [attrs]="attrs">Content</ng-dynamic>
<ng-dynamic [tag]="tag" [styles]="styles">Content</ng-dynamic>
`,
})
class TestComponent {
tag = 'section';
props = { id: 'test-id' };
attrs = { title: 'test-title' };
styles = { height: '50px' };
}
158 changes: 158 additions & 0 deletions projects/common/src/ng-dynamic/ng-dynamic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { Input, KeyValueDiffer, KeyValueDiffers, DoCheck, SimpleChanges, OnChanges, OnInit, ElementRef, Renderer2, Component, ViewChild, AfterContentInit, HostBinding, Directive } from '@angular/core';
import { RendererExtension } from '@angular-contrib/core';

const EMPTY_OBJ = {};

@Directive({
selector: 'ng-dynamic',
})
export class NgDynamic implements AfterContentInit, DoCheck, OnChanges, OnInit {
@Input() tag: string = 'ng-unknown';
@Input() props: Record<string, unknown> = {};
@Input() attrs: Record<string, string | null> = {};
@Input() styles: Record<string, string | number | null> = {};

@HostBinding('style.display') display = 'none';

private hostParent!: Element;
private el: Element | null = null;
private propsDiffer: KeyValueDiffer<string, unknown> | null = null;
private attrsDiffer: KeyValueDiffer<string, string | null> | null = null;
private stylesDiffer: KeyValueDiffer<string, string | number | null> | null = null;

constructor(
private host: ElementRef<HTMLElement>,
private renderer: Renderer2,
private kvDiffers: KeyValueDiffers,
private rendererEx: RendererExtension,
) {}

ngOnChanges(changes: SimpleChanges): void {
if (this.hostParent == null) {
this.hostParent = this.renderer.parentNode(this.host.nativeElement);
}

if (changes['tag']) {
this.createNewElement();
}

if (this.propsDiffer == null && changes['props']) {
this.propsDiffer = this.kvDiffers.find(EMPTY_OBJ).create();
}

if (this.attrsDiffer == null && changes['attrs']) {
this.attrsDiffer = this.kvDiffers.find(EMPTY_OBJ).create();
}

if (this.stylesDiffer == null && changes['styles']) {
this.stylesDiffer = this.kvDiffers.find(EMPTY_OBJ).create();
}
}

ngOnInit(): void {
if (this.hostParent == null) {
this.hostParent = this.renderer.parentNode(this.host.nativeElement);
}

if (this.el == null) {
this.createNewElement();
}
}

ngDoCheck(): void {
if (this.propsDiffer != null) {
const changes = this.propsDiffer.diff(this.props);
if (changes != null) {
changes.forEachRemovedItem((record) => this.setProp(record.key, null));
changes.forEachAddedItem((record) => this.setProp(record.key, record.currentValue));
changes.forEachChangedItem((record) => this.setProp(record.key, record.currentValue));
}
}

if (this.attrsDiffer != null) {
const changes = this.attrsDiffer.diff(this.attrs);
if (changes != null) {
changes.forEachRemovedItem((record) => this.setAttr(record.key, null));
changes.forEachAddedItem((record) => this.setAttr(record.key, record.currentValue));
changes.forEachChangedItem((record) => this.setAttr(record.key, record.currentValue));
}
}

if (this.stylesDiffer != null) {
const changes = this.stylesDiffer.diff(this.styles);
if (changes != null) {
changes.forEachRemovedItem((record) => this.setStyle(record.key, null));
changes.forEachAddedItem((record) => this.setStyle(record.key, record.currentValue));
changes.forEachChangedItem((record) => this.setStyle(record.key, record.currentValue));
}
}
}

ngAfterContentInit(): void {
this.moveChildNodes(this.host.nativeElement, this.el!);
}

private createNewElement(): void {
const newElement = this.renderer.createElement(this.tag);

if (this.el != null) {
this.renderer.insertBefore(this.hostParent, this.host.nativeElement, this.el);
this.renderer.removeChild(this.hostParent, this.el);
this.moveChildNodes(this.el, newElement);
}

this.el = newElement;

const propKeys = Object.keys(this.props);
const attrKeys = Object.keys(this.attrs);
const styleKeys = Object.keys(this.styles);

for (let i = 0; i < propKeys.length; i++) {
const prop = propKeys[i];
this.setProp(prop, this.props[prop]);
}

for (let i = 0; i < attrKeys.length; i++) {
const attr = attrKeys[i];
this.setProp(attr, this.attrs[attr]);
}

for (let i = 0; i < styleKeys.length; i++) {
const style = styleKeys[i];
this.setStyle(style, this.styles[style]);
}

this.renderer.insertBefore(this.hostParent, this.el, this.host.nativeElement);
this.renderer.removeChild(this.hostParent, this.host.nativeElement);
}

private moveChildNodes(from: Node, to: Node): void {
const nodes = this.rendererEx.getChildNodes(from);
for (let i = 0; i < nodes.length; i++) {
this.renderer.appendChild(to, nodes[i]);
}
}

private setProp(name: string, value: unknown): void {
this.renderer.setProperty(this.el, name, value);
}

private setAttr(name: string, value: string | null): void {
if (value != null) {
this.renderer.setAttribute(this.el, name, value);
} else {
this.renderer.removeAttribute(this.el, name);
}
}

private setStyle(nameAndUnit: string, value: string | number | null): void {
const [name, unit] = nameAndUnit.split('.');
value = value != null && unit ? `${value}${unit}` : value;

if (value != null) {
this.renderer.setStyle(this.el, name, value as string);
} else {
this.renderer.removeStyle(this.el, name);
}
}
}
15 changes: 12 additions & 3 deletions projects/common/src/ng-let/ng-let.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import { Component, Input } from '@angular/core';
import { Component, Input, ElementRef, Renderer2, OnInit } from '@angular/core';

@Component({
selector: 'ng-let[data]',
template: `<ng-content></ng-content>`,
template: ``,
})
export class NgLet<T = unknown> {
export class NgLet<T = unknown> implements OnInit {
@Input() data!: T;

constructor(
private renderer: Renderer2,
private host: ElementRef<HTMLElement>,
) {}

ngOnInit(): void {
this.renderer.removeChild(this.renderer.parentNode(this.host.nativeElement), this.host.nativeElement);
}
}
1 change: 1 addition & 0 deletions projects/common/src/public-api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './ng-dynamic/index';
export * from './ng-for-in/index';
export * from './ng-host/index';
export * from './ng-init/index';
Expand Down
2 changes: 1 addition & 1 deletion projects/core/src/change-detection-scheduler/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Automatically trigger change detection without Zone.js.

## Type

**Enhancement**
**Patch**

## Provenance

Expand Down
2 changes: 1 addition & 1 deletion projects/core/src/iterable-differs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Supports extending `IterableDiffers` with custom differ implementations.

## Usage

Provding custom `IterableDifferFactory` (Non-exclusive):
Providing custom `IterableDifferFactory` (Non-exclusive):

```typescript
import { ContribIterableDiffersModule, ITERABLE_DIFFER_FACTORIES } from '@angular-contrib/core';
Expand Down
2 changes: 1 addition & 1 deletion projects/core/src/key-value-differs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Supports extending `KeyValueDiffers` with custom differ implementations.

## Usage

Provding custom `KeyValueDifferFactory` (Non-exclusive):
Providing custom `KeyValueDifferFactory` (Non-exclusive):

```typescript
import { ContribKeyValueDiffersModule, KEY_VALUE_DIFFER_FACTORIES } from '@angular-contrib/core';
Expand Down
1 change: 1 addition & 0 deletions projects/core/src/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './change-detection-scheduler/index';
export * from './fast-iterable-differ/index';
export * from './iterable-differs/index';
export * from './key-value-differs/index';
export * from './renderer-extension/index';
34 changes: 34 additions & 0 deletions projects/core/src/renderer-extension/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Renderer Extension

Addition renderer methods for low-level operations.

## Type

**Service**

## Provenance

None.

## NgModule

`@angular-contrib/core#ContribRendererExtensionModule`

## Usage

```typescript
import { RendererExtension } from '@angular-contrib/core';

@Component()
class MyComponent {
constructor(private rendererEx: RendererExtension) {}

onAction(node: Node) {
const childLength = this.rendererEx.getChildNodes(node).length;

if (childLength === 0) {
throw new Error(`No children available`);
}
}
}
```
2 changes: 2 additions & 0 deletions projects/core/src/renderer-extension/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './renderer-extension';
export * from './renderer-extension.module';
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { NgModule } from '@angular/core';
import { RendererExtension } from './renderer-extension';

@NgModule({
providers: [ RendererExtension ],
})
export class ContribRendererExtensionModule {}
Empty file.
10 changes: 10 additions & 0 deletions projects/core/src/renderer-extension/renderer-extension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Injectable } from '@angular/core';

@Injectable({
providedIn: 'root',
})
export class RendererExtension {
getChildNodes(node: Node): NodeList {
return node.childNodes;
}
}
Loading

0 comments on commit cd4843d

Please sign in to comment.