feat(aio): enable data driven homepage announcements (#22043)

PR Close #22043
This commit is contained in:
Pete Bacon Darwin 2018-02-06 17:55:43 +00:00 committed by Miško Hevery
parent aa456edafc
commit fbef94a8ee
7 changed files with 219 additions and 6 deletions

View File

@ -0,0 +1,9 @@
[
{
"startDate": "2018-01-01",
"endDate": "2018-02-02",
"message": "Join us in Atlanta for ngATL<br/>Jan 30 - Feb 2, 2018",
"imageUrl": "generated/images/marketing/home/ng-atl.png",
"linkUrl": "http://ng-atl.org/"
}
]

View File

@ -29,6 +29,8 @@
<div class="home-rows">
<aio-announcement-bar></aio-announcement-bar>
<!-- Group 1 -->
<div layout="row" layout-xs="column" class="home-row homepage-container">
<div class="promo-img-container promo-1">

View File

@ -23,14 +23,16 @@ describe('AppModule', () => {
});
it('should provide a list of eagerly-loaded embedded components', () => {
const eagerSelector = Object.keys(componentsMap).find(selector => Array.isArray(componentsMap[selector]))!;
const selectorCount = eagerSelector.split(',').length;
expect(eagerSelector).not.toBeNull();
expect(selectorCount).toBe(componentsMap[eagerSelector].length);
const eagerConfig = Object.keys(componentsMap).filter(selector => Array.isArray(componentsMap[selector]));
expect(eagerConfig.length).toBeGreaterThan(0);
const eagerSelectors = eagerConfig.reduce<string[]>((selectors, config) => selectors.concat(config.split(',')), []);
expect(eagerSelectors.length).toBeGreaterThan(0);
// For example...
expect(eagerSelector).toContain('aio-toc');
expect(eagerSelectors).toContain('aio-toc');
expect(eagerSelectors).toContain('aio-announcement-bar');
});
it('should provide a list of lazy-loaded embedded components', () => {

View File

@ -14,6 +14,7 @@ import { MatToolbarModule } from '@angular/material/toolbar';
import { ROUTES } from '@angular/router';
import { AnnouncementBarComponent } from 'app/embedded/announcement-bar/announcement-bar.component';
import { AppComponent } from 'app/app.component';
import { EMBEDDED_COMPONENTS, EmbeddedComponentsMap } from 'app/embed-components/embed-components.service';
import { CustomIconRegistry, SVG_ICONS } from 'app/shared/custom-icon-registry';
@ -110,6 +111,7 @@ export const svgIconProviders = [
SharedModule
],
declarations: [
AnnouncementBarComponent,
AppComponent,
DocViewerComponent,
DtComponent,
@ -145,6 +147,7 @@ export const svgIconProviders = [
provide: EMBEDDED_COMPONENTS,
useValue: {
/* tslint:disable: max-line-length */
'aio-announcement-bar': [AnnouncementBarComponent],
'aio-toc': [TocComponent],
'aio-api-list, aio-contributor-list, aio-file-not-found-search, aio-resource-list, code-example, code-tabs, current-location, live-example': embeddedModulePath,
/* tslint:enable: max-line-length */
@ -158,7 +161,7 @@ export const svgIconProviders = [
multi: true,
},
],
entryComponents: [ TocComponent ],
entryComponents: [ AnnouncementBarComponent, TocComponent ],
bootstrap: [ AppComponent ]
})
export class AppModule {

View File

@ -0,0 +1,109 @@
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Logger } from 'app/shared/logger.service';
import { MockLogger } from 'testing/logger.service';
import { AnnouncementBarComponent } from './announcement-bar.component';
const today = new Date();
const lastWeek = changeDays(today, -7);
const yesterday = changeDays(today, -1);
const tomorrow = changeDays(today, 1);
const nextWeek = changeDays(today, 7);
describe('AnnouncementBarComponent', () => {
let element: HTMLElement;
let fixture: ComponentFixture<AnnouncementBarComponent>;
let component: AnnouncementBarComponent;
let httpMock: HttpTestingController;
let mockLogger: MockLogger;
beforeEach(() => {
const injector = TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
declarations: [AnnouncementBarComponent],
providers: [{ provide: Logger, useClass: MockLogger }]
});
httpMock = injector.get(HttpTestingController);
mockLogger = injector.get(Logger);
fixture = TestBed.createComponent(AnnouncementBarComponent);
component = fixture.componentInstance;
element = fixture.nativeElement;
});
it('should have no announcement when first created', () => {
expect(component.announcement).toBeUndefined();
});
describe('ngOnInit', () => {
it('should make a single request to the server', () => {
component.ngOnInit();
httpMock.expectOne('generated/announcements.json');
});
it('should set the announcement to the first "live" one in the list loaded from `announcements.json`', () => {
component.ngOnInit();
const request = httpMock.expectOne('generated/announcements.json');
request.flush([
{ startDate: lastWeek, endDate: yesterday, message: 'Test Announcement 0' },
{ startDate: tomorrow, endDate: nextWeek, message: 'Test Announcement 1' },
{ startDate: yesterday, endDate: tomorrow, message: 'Test Announcement 2' },
{ startDate: yesterday, endDate: tomorrow, message: 'Test Announcement 3' }
]);
expect(component.announcement.message).toEqual('Test Announcement 2');
});
it('should set the announcement to `undefined` if there are no announcements in `announcements.json`', () => {
component.ngOnInit();
const request = httpMock.expectOne('generated/announcements.json');
request.flush([]);
expect(component.announcement).toBeUndefined();
});
it('should handle invalid data in `announcements.json`', () => {
component.ngOnInit();
const request = httpMock.expectOne('generated/announcements.json');
request.flush('some random response');
expect(component.announcement).toBeUndefined();
expect(mockLogger.output.error[0][0]).toContain('generated/announcements.json contains invalid data:');
});
it('should handle a failed request for `announcements.json`', () => {
component.ngOnInit();
const request = httpMock.expectOne('generated/announcements.json');
request.error(new ErrorEvent('404'));
expect(component.announcement).toBeUndefined();
expect(mockLogger.output.error[0][0]).toContain('generated/announcements.json request failed:');
});
});
describe('rendering', () => {
beforeEach(() => {
component.announcement = {
imageUrl: 'link/to/image',
linkUrl: 'link/to/website',
message: 'this is an <b>important</b> message',
endDate: '2018-03-01',
startDate: '2018-02-01'
};
fixture.detectChanges();
});
it('should display the message as HTML', () => {
expect(element.innerHTML).toContain('this is an <b>important</b> message');
});
it('should display an image', () => {
expect(element.querySelector('img')!.src).toContain('link/to/image');
});
it('should display a link', () => {
expect(element.querySelector('a')!.href).toContain('link/to/website');
});
});
});
function changeDays(initial: Date, days: number) {
return (new Date(initial.valueOf()).setDate(initial.getDate() + days));
}

View File

@ -0,0 +1,82 @@
import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Logger } from 'app/shared/logger.service';
import { CONTENT_URL_PREFIX } from 'app/documents/document.service';
const announcementsPath = CONTENT_URL_PREFIX + 'announcements.json';
export interface Announcement {
imageUrl: string;
message: string;
linkUrl: string;
startDate: string;
endDate: string;
}
/**
* Display the latest live announcement. This is used on the homepage.
*
* The data for the announcements is kept in `aio/content/marketing/announcements.json`.
*
* The format for that data file looks like:
*
* ```
* [
* {
* "startDate": "2018-02-01",
* "endDate": "2018-03-01",
* "message": "This is an <b>important</b> announcement",
* "imageUrl": "url/to/image",
* "linkUrl": "url/to/website"
* },
* ...
* ]
* ```
*
* Only one announcement will be shown at any time. This is determined as the first "live"
* announcement in the file, where "live" means that its start date is before today, and its
* end date is after today.
*
* **Security Note:**
* The `message` field can contain unsanitized HTML but this field should only updated by
* verified members of the Angular team.
*/
@Component({
selector: 'aio-announcement-bar',
template: `
<div class="homepage-container" *ngIf="announcement">
<div class="announcement-bar">
<img [src]="announcement.imageUrl">
<p [innerHTML]="announcement.message"></p>
<a class="button" [href]="announcement.linkUrl">Learn More</a>
</div>
</div>`
})
export class AnnouncementBarComponent implements OnInit {
announcement: Announcement;
constructor(private http: HttpClient, private logger: Logger) {}
ngOnInit() {
this.http.get<Announcement[]>(announcementsPath)
.catch(error => {
this.logger.error(`${announcementsPath} request failed: ${error.message}`);
return [];
})
.map(announcements => this.findCurrentAnnouncement(announcements))
.catch(error => {
this.logger.error(`${announcementsPath} contains invalid data: ${error.message}`);
return [];
})
.subscribe(announcement => this.announcement = announcement);
}
/**
* Get the first date in the list that is "live" now
*/
private findCurrentAnnouncement(announcements: Announcement[]) {
return announcements
.filter(announcement => new Date(announcement.startDate).valueOf() < Date.now())
.filter(announcement => new Date(announcement.endDate).valueOf() > Date.now())
[0];
}
}

View File

@ -67,6 +67,11 @@ module.exports = new Package('angular-content', [basePackage, contentPackage])
include: CONTENTS_PATH + '/navigation.json',
fileReader: 'jsonFileReader'
},
{
basePath: CONTENTS_PATH,
include: CONTENTS_PATH + '/marketing/announcements.json',
fileReader: 'jsonFileReader'
},
{
basePath: CONTENTS_PATH,
include: CONTENTS_PATH + '/marketing/contributors.json',
@ -104,6 +109,7 @@ module.exports = new Package('angular-content', [basePackage, contentPackage])
},
{docTypes: ['navigation-json'], pathTemplate: '${id}', outputPathTemplate: '../${id}.json'},
{docTypes: ['contributors-json'], pathTemplate: '${id}', outputPathTemplate: '../${id}.json'},
{docTypes: ['announcements-json'], pathTemplate: '${id}', outputPathTemplate: '../${id}.json'},
{docTypes: ['resources-json'], pathTemplate: '${id}', outputPathTemplate: '../${id}.json'}
]);
})