diff --git a/aio/content/marketing/announcements.json b/aio/content/marketing/announcements.json new file mode 100644 index 0000000000..32d44b8220 --- /dev/null +++ b/aio/content/marketing/announcements.json @@ -0,0 +1,9 @@ +[ + { + "startDate": "2018-01-01", + "endDate": "2018-02-02", + "message": "Join us in Atlanta for ngATL
Jan 30 - Feb 2, 2018", + "imageUrl": "generated/images/marketing/home/ng-atl.png", + "linkUrl": "http://ng-atl.org/" + } +] \ No newline at end of file diff --git a/aio/content/marketing/index.html b/aio/content/marketing/index.html index b1ce56ce40..7de22cc0f2 100755 --- a/aio/content/marketing/index.html +++ b/aio/content/marketing/index.html @@ -29,6 +29,8 @@
+ +
diff --git a/aio/src/app/app.module.spec.ts b/aio/src/app/app.module.spec.ts index 26269d6f36..749eb4ef51 100644 --- a/aio/src/app/app.module.spec.ts +++ b/aio/src/app/app.module.spec.ts @@ -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((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', () => { diff --git a/aio/src/app/app.module.ts b/aio/src/app/app.module.ts index 2b533646e0..3026ff534a 100644 --- a/aio/src/app/app.module.ts +++ b/aio/src/app/app.module.ts @@ -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 { diff --git a/aio/src/app/embedded/announcement-bar/announcement-bar.component.spec.ts b/aio/src/app/embedded/announcement-bar/announcement-bar.component.spec.ts new file mode 100644 index 0000000000..009c720c15 --- /dev/null +++ b/aio/src/app/embedded/announcement-bar/announcement-bar.component.spec.ts @@ -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; + 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 important 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 important 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)); +} diff --git a/aio/src/app/embedded/announcement-bar/announcement-bar.component.ts b/aio/src/app/embedded/announcement-bar/announcement-bar.component.ts new file mode 100644 index 0000000000..ebe511873f --- /dev/null +++ b/aio/src/app/embedded/announcement-bar/announcement-bar.component.ts @@ -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 important 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: ` +
+
+ +

+ Learn More +
+
` +}) +export class AnnouncementBarComponent implements OnInit { + announcement: Announcement; + + constructor(private http: HttpClient, private logger: Logger) {} + + ngOnInit() { + this.http.get(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]; + } +} diff --git a/aio/tools/transforms/angular-content-package/index.js b/aio/tools/transforms/angular-content-package/index.js index fc9c21e045..8a064a4605 100644 --- a/aio/tools/transforms/angular-content-package/index.js +++ b/aio/tools/transforms/angular-content-package/index.js @@ -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'} ]); })