Skip to content
This repository was archived by the owner on May 7, 2018. It is now read-only.

Commit 5d6ff72

Browse files
committed
feat: ReactiveContentModule
1 parent f13a208 commit 5d6ff72

11 files changed

+444
-0
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
2+
import { HttpModule } from '@angular/http';
3+
4+
import { ContentHostComponent } from './content-host.component';
5+
import { ContentSourceService } from '../content-source.service';
6+
import { CONTENT_BASE_URL_DEFAULT, CONTENT_MAPPINGS } from '../content';
7+
8+
describe('ContentHostComponent', () => {
9+
let component: ContentHostComponent;
10+
let fixture: ComponentFixture<ContentHostComponent>;
11+
12+
beforeEach(async(() => {
13+
TestBed.configureTestingModule({
14+
imports: [ HttpModule ],
15+
providers: [
16+
ContentSourceService,
17+
CONTENT_BASE_URL_DEFAULT,
18+
{
19+
provide: CONTENT_MAPPINGS,
20+
useValue: {}
21+
}
22+
],
23+
declarations: [ ContentHostComponent ]
24+
})
25+
.compileComponents();
26+
}));
27+
28+
beforeEach(() => {
29+
fixture = TestBed.createComponent(ContentHostComponent);
30+
component = fixture.componentInstance;
31+
fixture.detectChanges();
32+
});
33+
34+
it('should create', () => {
35+
expect(component).toBeTruthy();
36+
});
37+
});
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import {
2+
Component,
3+
OnChanges,
4+
OnInit,
5+
ChangeDetectorRef,
6+
ComponentFactory,
7+
ComponentFactoryResolver,
8+
ComponentRef,
9+
ElementRef,
10+
Inject,
11+
Injector,
12+
Input,
13+
Renderer2,
14+
Type,
15+
SimpleChange,
16+
ViewChild,
17+
ViewContainerRef,
18+
} from '@angular/core';
19+
20+
import { Http, Response } from '@angular/http';
21+
22+
import { environment } from '../../../environments/environment';
23+
import { ContentSourceService } from '../content-source.service';
24+
import { CONTENT_MAPPINGS } from '../content';
25+
import { ContentOnCreate, ContentEmbeddable, ContentMappingType, ContentDocument } from '../content.interfaces';
26+
27+
const asContentCreate = (component: ComponentRef<any>): ContentOnCreate => {
28+
if (component.instance
29+
&& component.instance['contentOnCreate']
30+
&& typeof component.instance['contentOnCreate'] === 'function') {
31+
32+
return component.instance as ContentOnCreate;
33+
}
34+
35+
const componentName = component.instance['constructor'].name;
36+
throw new TypeError(`${componentName} needs to implement interface ContentCreate`);
37+
};
38+
39+
const asContentEmbeddable = (component: ComponentRef<any>): ContentEmbeddable | undefined => {
40+
if (component.instance
41+
&& component.instance['contentEmbeddable']
42+
&& typeof component.instance['contentEmbeddable'] === 'function') {
43+
44+
return component.instance as ContentEmbeddable;
45+
}
46+
};
47+
48+
49+
@Component({
50+
selector: 'rc-content-host',
51+
template: `<main><ng-container #container></ng-container></main><pre *ngIf="isDebug"><code>{{ json | json }}</code></pre>`,
52+
})
53+
export class ContentHostComponent implements OnInit, OnChanges {
54+
55+
public isDebug: boolean = !environment.production;
56+
57+
public json: any;
58+
59+
@ViewChild('container', {read: ViewContainerRef})
60+
public target: ViewContainerRef;
61+
62+
@Input()
63+
public document: ContentDocument;
64+
65+
constructor(
66+
private changeDetector: ChangeDetectorRef,
67+
private content: ContentSourceService,
68+
@Inject(CONTENT_MAPPINGS) private contentMappings: ContentMappingType,
69+
private componentFactoryResolver: ComponentFactoryResolver,
70+
private elementRef: ElementRef,
71+
private injector: Injector,
72+
private renderer: Renderer2
73+
) {}
74+
75+
76+
ngOnInit() {
77+
}
78+
79+
ngOnChanges(changes: {[propKey: string]: SimpleChange}) {
80+
81+
if (changes && changes.document && changes.document.currentValue) {
82+
this.updateHostedContent(changes.document.currentValue);
83+
}
84+
85+
}
86+
87+
private updateHostedContent(content: ContentDocument) {
88+
89+
// clear previous components and views
90+
this.target.clear();
91+
92+
console.info('Cleared previous component instances.');
93+
94+
// update current values
95+
this.json = content;
96+
97+
this.renderReactiveContent(content, this.target);
98+
99+
console.info('Rendered new component instances.');
100+
101+
}
102+
103+
private renderReactiveContent(content: ContentDocument, container: ViewContainerRef) {
104+
105+
// resolve content['_type'] to factory
106+
const type: Type<any> = this.contentMappings[content._type];
107+
if (!type) {
108+
throw new ReferenceError(`No content mapping for type=${content._type}`);
109+
}
110+
111+
const componentFactory: ComponentFactory<any> = this.componentFactoryResolver
112+
.resolveComponentFactory(type);
113+
// XX: renderer.appendChild() inserts INTO dom element. However, use with caution because ViewRef and ViewContainerRef are not set-up properly
114+
//const component: ComponentRef<any> = componentFactory.create(this.injector);
115+
//this.renderer.appendChild(container.element.nativeElement, component.location.nativeElement);
116+
const component = container.createComponent(componentFactory, container.length, this.injector);
117+
console.info('Component dynamically created ', component);
118+
119+
// content lifecycle hook: notify of new values
120+
const cmpCreate: ContentOnCreate = asContentCreate(component);
121+
cmpCreate.contentOnCreate(content);
122+
123+
// render embedded content
124+
if (content._embedded && Object.keys(content._embedded).length > 0) {
125+
126+
const cmpEmbeddable: ContentEmbeddable = asContentEmbeddable(component);
127+
if (cmpEmbeddable) {
128+
129+
// render in the target element of ContentEmbeddable
130+
const childContainer = cmpEmbeddable.contentEmbeddable();
131+
132+
Object.keys(content._embedded).forEach((key: string) => {
133+
const value = content._embedded[key];
134+
135+
// XX: recursive rendering
136+
if (value instanceof Array) {
137+
value.forEach((v: ContentDocument) => {
138+
this.renderReactiveContent(v, childContainer);
139+
});
140+
} else {
141+
this.renderReactiveContent(value, childContainer);
142+
}
143+
});
144+
145+
} else {
146+
147+
// fatal: embedded content must be hosted by ContentEmbeddable
148+
const cmpName = component.instance['constructor'].name;
149+
throw new TypeError([`Trying to render embedded content.`,
150+
`${cmpName} must implement interface ContentEmbeddable`].join(' '));
151+
152+
}
153+
154+
}
155+
156+
component.hostView.detectChanges();
157+
158+
}
159+
160+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<nav *ngIf="links && links.length > 0">
2+
3+
<button *ngFor="let link of links"
4+
(click)="selected.emit(link)"
5+
type="button"
6+
class="button button-clear">{{ link.rel }}</button>
7+
8+
</nav>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
2+
3+
import { ContentLinksComponent } from './content-links.component';
4+
5+
describe('ContentLinksComponent', () => {
6+
let component: ContentLinksComponent;
7+
let fixture: ComponentFixture<ContentLinksComponent>;
8+
9+
beforeEach(async(() => {
10+
TestBed.configureTestingModule({
11+
declarations: [ ContentLinksComponent ]
12+
})
13+
.compileComponents();
14+
}));
15+
16+
beforeEach(() => {
17+
fixture = TestBed.createComponent(ContentLinksComponent);
18+
component = fixture.componentInstance;
19+
fixture.detectChanges();
20+
});
21+
22+
it('should create', () => {
23+
expect(component).toBeTruthy();
24+
});
25+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { Component, EventEmitter, OnChanges, OnInit, Input, Output, SimpleChange } from '@angular/core';
2+
import { ContentDocument } from '../content.interfaces';
3+
4+
export interface LinkWithRel {
5+
rel: string;
6+
href: string;
7+
}
8+
9+
@Component({
10+
selector: 'rc-content-links',
11+
templateUrl: './content-links.component.html'
12+
})
13+
export class ContentLinksComponent implements OnChanges, OnInit {
14+
15+
public links: LinkWithRel[] = [];
16+
17+
@Input()
18+
public document: ContentDocument;
19+
20+
@Output()
21+
public selected: EventEmitter<LinkWithRel> = new EventEmitter<LinkWithRel>();
22+
23+
constructor() { }
24+
25+
ngOnInit() {
26+
}
27+
28+
ngOnChanges(changes: {[propKey: string]: SimpleChange}) {
29+
30+
if (changes && changes.document && changes.document.currentValue) {
31+
this.prepareLinks(changes.document.currentValue);
32+
}
33+
34+
}
35+
36+
private prepareLinks(document: ContentDocument) {
37+
38+
this.links = Object.keys(document._links)
39+
.map((rel: string) => {
40+
const href = document._links[rel].href;
41+
42+
return { rel, href };
43+
});
44+
45+
}
46+
47+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { TestBed, inject } from '@angular/core/testing';
2+
import { HttpModule, XHRBackend } from '@angular/http';
3+
import { MockBackend } from '@angular/http/testing';
4+
5+
import { ContentSourceService } from './content-source.service';
6+
import { CONTENT_BASE_URL_DEFAULT } from './content';
7+
8+
describe('ContentSourceService', () => {
9+
beforeEach(() => {
10+
TestBed.configureTestingModule({
11+
imports: [
12+
HttpModule
13+
],
14+
providers: [
15+
ContentSourceService,
16+
MockBackend,
17+
{ provide: XHRBackend, useExisting: MockBackend },
18+
CONTENT_BASE_URL_DEFAULT
19+
]
20+
});
21+
});
22+
23+
it('should create an instance', inject([ContentSourceService], (service: ContentSourceService) => {
24+
expect(service).toBeTruthy();
25+
}));
26+
27+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { Inject, Injectable, InjectionToken, Provider } from '@angular/core';
2+
import { Http, Response } from '@angular/http';
3+
import { Observable } from 'rxjs/Observable';
4+
import 'rxjs/add/operator/map';
5+
6+
import { CONTENT_BASE_URL } from './content';
7+
import { ContentDocument } from './content.interfaces';
8+
9+
@Injectable()
10+
export class ContentSourceService {
11+
12+
constructor(
13+
private http: Http,
14+
@Inject(CONTENT_BASE_URL) private baseUrl: string
15+
) {}
16+
17+
public index(): Observable<ContentDocument> {
18+
19+
return this.http.get(this.baseUrl)
20+
.map((res: Response) => res.json());
21+
}
22+
23+
public fetchLink(document: ContentDocument, rel: string) {
24+
const link = document._links[rel];
25+
26+
if (link) {
27+
// TODO: if (link.templated) ...
28+
29+
return this.http.get(link.href)
30+
.map((res: Response) => res.json());
31+
}
32+
33+
const availableRels = Object.keys(document._links).join(', ');
34+
throw new Error(`Link rel=${rel} does not exist in given document. Available rels are ${availableRels}`);
35+
36+
}
37+
38+
}

0 commit comments

Comments
 (0)