From 9005a6f3cd8fba8bb0f258f7466355bd0399d8d5 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Thu, 11 Jan 2018 13:56:01 +0000 Subject: [PATCH] feat(aio): implement survey notification link (#21371) Closes #21094 PR Close #21371 --- aio/scripts/_payload-limits.json | 6 +- aio/src/app/app.component.html | 32 +++-- aio/src/app/app.component.ts | 26 +++- aio/src/app/app.module.ts | 28 ++++ .../notification/notification.component.html | 9 ++ .../notification.component.spec.ts | 123 ++++++++++++++++++ .../notification/notification.component.ts | 52 ++++++++ aio/src/app/shared/current-date.ts | 4 + aio/src/styles/1-layouts/_top-menu.scss | 5 +- aio/src/styles/2-modules/_modules-dir.scss | 1 + aio/src/styles/2-modules/_notification.scss | 106 +++++++++++++++ 11 files changed, 378 insertions(+), 14 deletions(-) create mode 100644 aio/src/app/layout/notification/notification.component.html create mode 100644 aio/src/app/layout/notification/notification.component.spec.ts create mode 100644 aio/src/app/layout/notification/notification.component.ts create mode 100644 aio/src/app/shared/current-date.ts create mode 100644 aio/src/styles/2-modules/_notification.scss diff --git a/aio/scripts/_payload-limits.json b/aio/scripts/_payload-limits.json index d6d3253ca8..9f1df56be2 100755 --- a/aio/scripts/_payload-limits.json +++ b/aio/scripts/_payload-limits.json @@ -3,17 +3,17 @@ "master": { "gzip7": { "inline": 961, - "main": 115381, + "main": 116924, "polyfills": 12962 }, "gzip9": { "inline": 961, - "main": 115186, + "main": 116712, "polyfills": 12958 }, "uncompressed": { "inline": 1602, - "main": 453905, + "main": 459119, "polyfills": 40264 } } diff --git a/aio/src/app/app.component.html b/aio/src/app/app.component.html index 733800c29d..bad7f69913 100644 --- a/aio/src/app/app.component.html +++ b/aio/src/app/app.component.html @@ -5,15 +5,29 @@ - - - Home - Home - - - + + + Help Angular by taking a 1 minute survey! + + + + + + Home + Home + + + + diff --git a/aio/src/app/app.component.ts b/aio/src/app/app.component.ts index 53c8ac4075..d35f9b57b5 100644 --- a/aio/src/app/app.component.ts +++ b/aio/src/app/app.component.ts @@ -6,6 +6,7 @@ import { CurrentNodes, NavigationService, NavigationNode, VersionInfo } from 'ap import { DocumentService, DocumentContents } from 'app/documents/document.service'; import { Deployment } from 'app/shared/deployment.service'; import { LocationService } from 'app/shared/location.service'; +import { NotificationComponent } from 'app/layout/notification/notification.component'; import { ScrollService } from 'app/shared/scroll.service'; import { SearchBoxComponent } from 'app/search/search-box/search-box.component'; import { SearchResults } from 'app/search/interfaces'; @@ -90,6 +91,10 @@ export class AppComponent implements OnInit { @ViewChild(MatSidenav) sidenav: MatSidenav; + @ViewChild(NotificationComponent) + notification: NotificationComponent; + notificationAnimating = false; + constructor( public deployment: Deployment, private documentService: DocumentService, @@ -273,14 +278,33 @@ export class AppComponent implements OnInit { this.folderId = (id === 'index') ? 'home' : id.split('/', 1)[0]; } + notificationDismissed() { + this.notificationAnimating = true; + // this should be kept in sync with the animation durations in: + // - aio/src/styles/2-modules/_notification.scss + // - aio/src/app/layout/notification/notification.component.ts + setTimeout(() => this.notificationAnimating = false, 250); + this.updateHostClasses(); + } + updateHostClasses() { const mode = `mode-${this.deployment.mode}`; const sideNavOpen = `sidenav-${this.sidenav.opened ? 'open' : 'closed'}`; const pageClass = `page-${this.pageId}`; const folderClass = `folder-${this.folderId}`; const viewClasses = Object.keys(this.currentNodes || {}).map(view => `view-${view}`).join(' '); + const notificationClass = `aio-notification-${this.notification.showNotification}`; + const notificationAnimatingClass = this.notificationAnimating ? 'aio-notification-animating' : ''; - this.hostClasses = `${mode} ${sideNavOpen} ${pageClass} ${folderClass} ${viewClasses}`; + this.hostClasses = [ + mode, + sideNavOpen, + pageClass, + folderClass, + viewClasses, + notificationClass, + notificationAnimatingClass + ].join(' '); } updateHostClassesForDoc(doc: DocumentContents) { diff --git a/aio/src/app/app.module.ts b/aio/src/app/app.module.ts index a7a7d9b83d..c14ce329d3 100644 --- a/aio/src/app/app.module.ts +++ b/aio/src/app/app.module.ts @@ -34,8 +34,10 @@ import { NavItemComponent } from 'app/layout/nav-item/nav-item.component'; import { ScrollService } from 'app/shared/scroll.service'; import { ScrollSpyService } from 'app/shared/scroll-spy.service'; import { SearchBoxComponent } from 'app/search/search-box/search-box.component'; +import { NotificationComponent } from 'app/layout/notification/notification.component'; import { TocComponent } from 'app/layout/toc/toc.component'; import { TocService } from 'app/shared/toc.service'; +import { CurrentDateToken, currentDateProvider } from 'app/shared/current-date'; import { WindowToken, windowProvider } from 'app/shared/window'; import { EmbedComponentsModule } from 'app/embed-components/embed-components.module'; @@ -65,6 +67,30 @@ export const svgIconProviders = [ 'viewBox="0 0 24 24">' }, multi: true + }, + { + provide: SVG_ICONS, + useValue: { + name: 'insert_comment', + svgSource: + '' + + '' + + '' + + '' + }, + multi: true + }, + { + provide: SVG_ICONS, + useValue: { + name: 'close', + svgSource: + '' + + '' + + '' + + '' + }, + multi: true } ]; @@ -91,6 +117,7 @@ export const svgIconProviders = [ NavMenuComponent, NavItemComponent, SearchBoxComponent, + NotificationComponent, TocComponent, TopMenuComponent, ], @@ -109,6 +136,7 @@ export const svgIconProviders = [ SearchService, svgIconProviders, TocService, + { provide: CurrentDateToken, useFactory: currentDateProvider }, { provide: WindowToken, useFactory: windowProvider }, { diff --git a/aio/src/app/layout/notification/notification.component.html b/aio/src/app/layout/notification/notification.component.html new file mode 100644 index 0000000000..5db9b72caf --- /dev/null +++ b/aio/src/app/layout/notification/notification.component.html @@ -0,0 +1,9 @@ + + + + {{buttonText}} + + + diff --git a/aio/src/app/layout/notification/notification.component.spec.ts b/aio/src/app/layout/notification/notification.component.spec.ts new file mode 100644 index 0000000000..f54335e8a7 --- /dev/null +++ b/aio/src/app/layout/notification/notification.component.spec.ts @@ -0,0 +1,123 @@ +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { CurrentDateToken } from 'app/shared/current-date'; +import { NotificationComponent } from './notification.component'; +import { WindowToken } from 'app/shared/window'; + +describe('NotificationComponent', () => { + let element: HTMLElement; + let component: NotificationComponent; + let fixture: ComponentFixture; + + function configTestingModule(now = new Date('2018-01-20')) { + TestBed.configureTestingModule({ + declarations: [TestComponent, NotificationComponent], + providers: [ + { provide: WindowToken, useClass: MockWindow }, + { provide: CurrentDateToken, useValue: now }, + ], + imports: [NoopAnimationsModule], + schemas: [NO_ERRORS_SCHEMA] + }); + } + + function createComponent() { + fixture = TestBed.createComponent(TestComponent); + const debugElement = fixture.debugElement.query(By.directive(NotificationComponent)); + element = debugElement.nativeElement; + component = debugElement.componentInstance; + component.ngOnInit(); + fixture.detectChanges(); + } + + it('should display the message', () => { + configTestingModule(); + createComponent(); + expect(fixture.nativeElement.innerHTML).toContain('Help Angular by taking a 1 minute survey!'); + }); + + it('should display an icon', () => { + configTestingModule(); + createComponent(); + const iconElement = fixture.debugElement.query(By.css('.icon')); + expect(iconElement.properties['svgIcon']).toEqual('insert_comment'); + expect(iconElement.attributes['aria-label']).toEqual('Survey'); + }); + + it('should display a button', () => { + configTestingModule(); + createComponent(); + const button = fixture.debugElement.query(By.css('.action-button')); + expect(button.nativeElement.textContent).toEqual('Go to survey'); + }); + + it('should call dismiss when the message link is clicked', () => { + configTestingModule(); + createComponent(); + spyOn(component, 'dismiss'); + fixture.debugElement.query(By.css('a')).triggerEventHandler('click', null); + fixture.detectChanges(); + expect(component.dismiss).toHaveBeenCalled(); + }); + + it('should call dismiss when the close button is clicked', () => { + configTestingModule(); + createComponent(); + spyOn(component, 'dismiss'); + fixture.debugElement.query(By.css('button')).triggerEventHandler('click', null); + fixture.detectChanges(); + expect(component.dismiss).toHaveBeenCalled(); + }); + + it('should hide the notification when dismiss is called', () => { + configTestingModule(); + createComponent(); + expect(component.showNotification).toBe('show'); + component.dismiss(); + expect(component.showNotification).toBe('hide'); + }); + + it('should update localStorage key when dismiss is called', () => { + configTestingModule(); + createComponent(); + const setItemSpy: jasmine.Spy = TestBed.get(WindowToken).localStorage.setItem; + component.dismiss(); + expect(setItemSpy).toHaveBeenCalledWith('aio-notification/survey-january-2018', 'hide'); + }); + + it('should not show the notification if the date is after the expiry date', () => { + configTestingModule(new Date('2018-01-23')); + createComponent(); + expect(component.showNotification).toBe('hide'); + }); + + it('should not show the notification if the there is a "hide" flag in localStorage', () => { + configTestingModule(); + const getItemSpy: jasmine.Spy = TestBed.get(WindowToken).localStorage.getItem; + getItemSpy.and.returnValue('hide'); + createComponent(); + expect(getItemSpy).toHaveBeenCalledWith('aio-notification/survey-january-2018'); + expect(component.showNotification).toBe('hide'); + }); +}); + +@Component({ + template: ` + + Help Angular by taking a 1 minute survey! + ` +}) +class TestComponent { +} + +class MockWindow { + localStorage = jasmine.createSpyObj('localStorage', ['getItem', 'setItem']); +} diff --git a/aio/src/app/layout/notification/notification.component.ts b/aio/src/app/layout/notification/notification.component.ts new file mode 100644 index 0000000000..1346c7ae6e --- /dev/null +++ b/aio/src/app/layout/notification/notification.component.ts @@ -0,0 +1,52 @@ +import { animate, state, style, trigger, transition } from '@angular/animations'; +import { Component, EventEmitter, HostBinding, Inject, Input, OnInit, Output } from '@angular/core'; +import { CurrentDateToken } from 'app/shared/current-date'; +import { WindowToken } from 'app/shared/window'; + +const LOCAL_STORAGE_NAMESPACE = 'aio-notification/'; + +@Component({ + selector: 'aio-notification', + templateUrl: 'notification.component.html', + animations: [ + trigger('hideAnimation', [ + state('show', style({height: '*'})), + state('hide', style({height: 0})), + // this should be kept in sync with the animation durations in: + // - aio/src/styles/2-modules/_notification.scss + // - aio/src/app/app.component.ts : notificationDismissed() + transition('show => hide', animate(250)) + ]) + ] +}) +export class NotificationComponent implements OnInit { + private get localStorage() { return this.window.localStorage; } + + @Input() icon: string; + @Input() iconLabel: string; + @Input() buttonText: string; + @Input() actionUrl: string; + @Input() notificationId: string; + @Input() expirationDate: string; + @Output() dismissed = new EventEmitter(); + + @HostBinding('@hideAnimation') + showNotification: 'show'|'hide'; + + constructor( + @Inject(WindowToken) private window: Window, + @Inject(CurrentDateToken) private currentDate: Date + ) {} + + ngOnInit() { + const previouslyHidden = this.localStorage.getItem(LOCAL_STORAGE_NAMESPACE + this.notificationId) === 'hide'; + const expired = this.currentDate > new Date(this.expirationDate); + this.showNotification = previouslyHidden || expired ? 'hide' : 'show'; + } + + dismiss() { + this.localStorage.setItem(LOCAL_STORAGE_NAMESPACE + this.notificationId, 'hide'); + this.showNotification = 'hide'; + this.dismissed.next(); + } +} diff --git a/aio/src/app/shared/current-date.ts b/aio/src/app/shared/current-date.ts new file mode 100644 index 0000000000..ab02331bff --- /dev/null +++ b/aio/src/app/shared/current-date.ts @@ -0,0 +1,4 @@ +import { InjectionToken } from '@angular/core'; + +export const CurrentDateToken = new InjectionToken('CurrentDate'); +export function currentDateProvider() { return new Date(); } diff --git a/aio/src/styles/1-layouts/_top-menu.scss b/aio/src/styles/1-layouts/_top-menu.scss index fdfb4e5f54..5fc6611596 100644 --- a/aio/src/styles/1-layouts/_top-menu.scss +++ b/aio/src/styles/1-layouts/_top-menu.scss @@ -10,9 +10,12 @@ mat-toolbar.mat-toolbar { right: 0; left: 0; z-index: 10; - padding: 0 16px 0 0; box-shadow: 0 2px 5px 0 rgba(0,0,0,0.30); + mat-toolbar-row { + padding: 0 16px 0 0; + } + mat-icon { color: $white; } diff --git a/aio/src/styles/2-modules/_modules-dir.scss b/aio/src/styles/2-modules/_modules-dir.scss index 3535f8ae5a..066d020812 100644 --- a/aio/src/styles/2-modules/_modules-dir.scss +++ b/aio/src/styles/2-modules/_modules-dir.scss @@ -29,3 +29,4 @@ @import 'toc'; @import 'select-menu'; @import 'deploy-theme'; + @import 'notification'; diff --git a/aio/src/styles/2-modules/_notification.scss b/aio/src/styles/2-modules/_notification.scss new file mode 100644 index 0000000000..f5fd0f91c4 --- /dev/null +++ b/aio/src/styles/2-modules/_notification.scss @@ -0,0 +1,106 @@ +$notificationHeight: 56px; + +// we need to override some of the toolbar styling +.mat-toolbar mat-toolbar-row.notification-container { + padding: 0; + height: auto; + overflow: hidden; +} + +aio-notification { + background: $darkgray; + display: flex; + position: relative; + align-items: center; + width: 100%; + height: $notificationHeight; + justify-content: center; + + @media (max-width: 430px) { + justify-content: flex-start; + padding-left: 10px; + } + + .close-button { + position: absolute; + top: 0; + right: 0; + width: $notificationHeight; + height: $notificationHeight; + background: $darkgray; + } + + .content { + display: flex; + max-width: calc(100% - #{$notificationHeight}); + text-transform: none; + padding: 0; + + .icon { + margin-right: 10px; + @media (max-width: 464px) { + display: none; + } + } + + .message { + overflow: hidden; + text-overflow: ellipsis; + } + + .action-button { + margin-left: 10px; + background: $brightred; + border-radius: 15px; + text-transform: uppercase; + padding: 0 10px; + font-size: 12px; + @media (max-width: 780px) { + display: none; + } + } + } +} + +// Here are all the hacks to make the content and sidebars the right height +// when the notification is visible +.aio-notification-show { + .sidenav-content { + padding-top: 80px + $notificationHeight; + } + + mat-sidenav.mat-sidenav.sidenav { + top: 56px + $notificationHeight; + + @media (max-width: 600px) { + top: 56px + $notificationHeight; + } + } + + .toc-container { + top: 76px + $notificationHeight; + } + + .search-results { + padding-top: 68px + $notificationHeight; + } + + &.page-home, &.page-resources, &.page-events, &.page-features, &.page-presskit, &.page-contribute { + section { + padding-top: $notificationHeight; + } + } +} + +// Animate the content when the notification bar is dismissed +// this should be kept in sync with the animation durations in +// - aio/src/app/layout/notification/notification.component.ts +// - aio/src/app/app.component.ts : notificationDismissed() +.aio-notification-animating { + .sidenav-content { + transition: padding-top 250ms ease; + } + mat-sidenav.mat-sidenav.sidenav, .toc-container { + transition: top 250ms ease; + } +}