feat(aio): enable data driven homepage announcements (#22043)
PR Close #22043
This commit is contained in:
parent
aa456edafc
commit
fbef94a8ee
|
@ -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/"
|
||||
}
|
||||
]
|
|
@ -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">
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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));
|
||||
}
|
|
@ -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];
|
||||
}
|
||||
}
|
|
@ -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'}
|
||||
]);
|
||||
})
|
||||
|
|
Loading…
Reference in New Issue