diff --git a/aio/src/app/app.component.html b/aio/src/app/app.component.html index be0db3b713..a8834fc492 100644 --- a/aio/src/app/app.component.html +++ b/aio/src/app/app.component.html @@ -1,5 +1,7 @@
+ +
diff --git a/aio/src/app/app.component.spec.ts b/aio/src/app/app.component.spec.ts index 16967f7ce3..18385db24a 100644 --- a/aio/src/app/app.component.spec.ts +++ b/aio/src/app/app.component.spec.ts @@ -7,6 +7,7 @@ import { MatSidenav } from '@angular/material/sidenav'; import { By, Title } from '@angular/platform-browser'; import { ElementsLoader } from 'app/custom-elements/elements-loader'; import { DocumentService } from 'app/documents/document.service'; +import { CookiesPopupComponent } from 'app/layout/cookies-popup/cookies-popup.component'; import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component'; import { CurrentNodes } from 'app/navigation/navigation.model'; import { NavigationNode, NavigationService } from 'app/navigation/navigation.service'; @@ -701,6 +702,13 @@ describe('AppComponent', () => { }); }); + describe('aio-cookies-popup', () => { + it('should have a cookies popup', () => { + const cookiesPopupDe = fixture.debugElement.query(By.directive(CookiesPopupComponent)); + expect(cookiesPopupDe.componentInstance).toBeInstanceOf(CookiesPopupComponent); + }); + }); + describe('deployment banner', () => { it('should show a message if the deployment mode is "archive"', async () => { createTestingModule('a/b', 'archive'); diff --git a/aio/src/app/app.module.ts b/aio/src/app/app.module.ts index 17e8bdba11..4a27d5b562 100644 --- a/aio/src/app/app.module.ts +++ b/aio/src/app/app.module.ts @@ -15,6 +15,7 @@ import { MatToolbarModule } from '@angular/material/toolbar'; import { AppComponent } from 'app/app.component'; import { CustomIconRegistry, SVG_ICONS } from 'app/shared/custom-icon-registry'; import { Deployment } from 'app/shared/deployment.service'; +import { CookiesPopupComponent } from 'app/layout/cookies-popup/cookies-popup.component'; import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component'; import { DtComponent } from 'app/layout/doc-viewer/dt.component'; import { ModeBannerComponent } from 'app/layout/mode-banner/mode-banner.component'; @@ -163,6 +164,7 @@ export const svgIconProviders = [ ], declarations: [ AppComponent, + CookiesPopupComponent, DocViewerComponent, DtComponent, FooterComponent, diff --git a/aio/src/app/layout/cookies-popup/cookies-popup.component.spec.ts b/aio/src/app/layout/cookies-popup/cookies-popup.component.spec.ts new file mode 100644 index 0000000000..dcae1b1c23 --- /dev/null +++ b/aio/src/app/layout/cookies-popup/cookies-popup.component.spec.ts @@ -0,0 +1,92 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { LocalStorage } from 'app/shared/storage.service'; +import { CookiesPopupComponent, storageKey } from './cookies-popup.component'; + +describe('CookiesPopupComponent', () => { + let mockLocalStorage: MockLocalStorage; + let fixture: ComponentFixture; + + beforeEach(() => { + mockLocalStorage = new MockLocalStorage(); + + TestBed.configureTestingModule({ + declarations: [ + CookiesPopupComponent, + ], + providers: [ + { provide: LocalStorage, useValue: mockLocalStorage }, + ], + }); + + fixture = TestBed.createComponent(CookiesPopupComponent); + }); + + it('should make the popup visible by default', () => { + fixture.detectChanges(); + + expect(getCookiesPopup()).not.toBeNull(); + }); + + it('should include the correct content in the popup', () => { + fixture.detectChanges(); + + const popup = getCookiesPopup() as Element; + const infoBtn = popup.querySelector('a[mat-button]:nth-child(1)'); + const okBtn = popup.querySelector('button[mat-button]:nth-child(2)'); + + expect(popup.textContent).toContain( + 'This site uses cookies from Google to deliver its services and to analyze traffic.'); + + expect(infoBtn).toBeInstanceOf(HTMLElement); + expect(infoBtn?.href).toBe('https://policies.google.com/technologies/cookies'); + expect(infoBtn?.textContent).toMatch(/learn more/i); + + expect(okBtn).toBeInstanceOf(HTMLElement); + expect(okBtn?.textContent).toMatch(/ok, got it/i); + }); + + it('should hide the cookies popup if the user has already accepted cookies', () => { + mockLocalStorage.setItem(storageKey, 'true'); + fixture = TestBed.createComponent(CookiesPopupComponent); + + fixture.detectChanges(); + + expect(getCookiesPopup()).toBeNull(); + }); + + describe('acceptCookies()', () => { + it('should hide the cookies popup', () => { + fixture.detectChanges(); + expect(getCookiesPopup()).not.toBeNull(); + + fixture.componentInstance.acceptCookies(); + fixture.detectChanges(); + expect(getCookiesPopup()).toBeNull(); + }); + + it('should store the user\'s confirmation', () => { + fixture.detectChanges(); + expect(mockLocalStorage.getItem(storageKey)).toBeNull(); + + fixture.componentInstance.acceptCookies(); + expect(mockLocalStorage.getItem(storageKey)).toBe('true'); + }); + }); + + // Helpers + function getCookiesPopup() { + return (fixture.nativeElement as HTMLElement).querySelector('.cookies-popup'); + } + + class MockLocalStorage implements Pick { + private items = new Map(); + + getItem(key: string): string | null { + return this.items.get(key) ?? null; + } + + setItem(key: string, val: string): void { + this.items.set(key, val); + } + } +}); diff --git a/aio/src/app/layout/cookies-popup/cookies-popup.component.ts b/aio/src/app/layout/cookies-popup/cookies-popup.component.ts new file mode 100644 index 0000000000..c398c8d9a2 --- /dev/null +++ b/aio/src/app/layout/cookies-popup/cookies-popup.component.ts @@ -0,0 +1,37 @@ +import { Component, Inject } from '@angular/core'; +import { LocalStorage } from 'app/shared/storage.service'; + +export const storageKey = 'aio-accepts-cookies'; + +@Component({ + selector: 'aio-cookies-popup', + template: ` +
+

Cookies concent notice

+ + This site uses cookies from Google to deliver its services and to analyze traffic. + +
+ + Learn more + + +
+
+ `, +}) +export class CookiesPopupComponent { + /** Whether the user has already accepted the cookies disclaimer. */ + hasAcceptedCookies: boolean; + + constructor(@Inject(LocalStorage) private storage: Storage) { + this.hasAcceptedCookies = this.storage.getItem(storageKey) === 'true'; + } + + acceptCookies() { + this.storage.setItem(storageKey, 'true'); + this.hasAcceptedCookies = true; + } +} diff --git a/aio/src/styles/2-modules/_index.scss b/aio/src/styles/2-modules/_index.scss index 66f600a7af..913e3a9c58 100644 --- a/aio/src/styles/2-modules/_index.scss +++ b/aio/src/styles/2-modules/_index.scss @@ -12,6 +12,7 @@ @forward 'cli-pages/cli-pages'; @forward 'code/code'; @forward 'contribute/contribute'; +@forward 'cookies-popup/cookies-popup'; @forward 'contributor/contributor'; @forward 'deploy-theme/deploy-theme'; @forward 'details/details'; diff --git a/aio/src/styles/2-modules/_theme.scss b/aio/src/styles/2-modules/_theme.scss index fa0e651be1..62919bd78e 100644 --- a/aio/src/styles/2-modules/_theme.scss +++ b/aio/src/styles/2-modules/_theme.scss @@ -7,6 +7,7 @@ @use 'card/card-theme'; @use 'code/code-theme'; @use 'contributor/contributor-theme'; +@use 'cookies-popup/cookies-popup-theme'; @use 'deploy-theme/deploy-theme'; @use 'details/details-theme'; @use 'errors/errors-theme'; @@ -33,6 +34,7 @@ @include card-theme.theme($theme); @include code-theme.theme($theme); @include contributor-theme.theme($theme); + @include cookies-popup-theme.theme($theme); @include deploy-theme.theme($theme); @include details-theme.theme($theme); @include errors-theme.theme($theme); diff --git a/aio/src/styles/2-modules/cookies-popup/_cookies-popup-theme.scss b/aio/src/styles/2-modules/cookies-popup/_cookies-popup-theme.scss new file mode 100644 index 0000000000..a5bb9ec4a1 --- /dev/null +++ b/aio/src/styles/2-modules/cookies-popup/_cookies-popup-theme.scss @@ -0,0 +1,27 @@ +@use 'sass:map'; +@use '../../constants' as c; +@use '~@angular/material' as mat; + +@mixin theme($theme) { + $is-dark-theme: map.get($theme, is-dark); + + aio-cookies-popup { + .cookies-popup { + background: if($is-dark-theme, map.get(mat.$grey-palette, 50), #252525); + color: if($is-dark-theme, + map.get(map.get(mat.$grey-palette, contrast), 50), + map.get(mat.$dark-theme-foreground-palette, secondary-text) + ); + + .actions { + .mat-button { + color: if($is-dark-theme, c.$blue, c.$lightblue); + + .mat-button-focus-overlay { + background: if($is-dark-theme, c.$black, c.$white); + } + } + } + } + } +} diff --git a/aio/src/styles/2-modules/cookies-popup/_cookies-popup.scss b/aio/src/styles/2-modules/cookies-popup/_cookies-popup.scss new file mode 100644 index 0000000000..a7ee2675f7 --- /dev/null +++ b/aio/src/styles/2-modules/cookies-popup/_cookies-popup.scss @@ -0,0 +1,28 @@ +@use '~@angular/cdk' as cdk; +@use '~@angular/material' as mat; + +$inner-spacing: 16px; + +aio-cookies-popup { + .cookies-popup { + @include mat.elevation(6); + border-radius: 4px; + bottom: 0; + left: 0; + position: fixed; + margin: 24px; + max-width: 430px; + padding: $inner-spacing $inner-spacing $inner-spacing / 2; + z-index: cdk.$overlay-container-z-index + 1; + + .actions { + display: flex; + justify-content: flex-end; + margin: $inner-spacing $inner-spacing / -2 0 0; + + .mat-button { + text-transform: uppercase; + } + } + } +} diff --git a/aio/tests/e2e/src/app.po.ts b/aio/tests/e2e/src/app.po.ts index f10ab9fe6b..b91a0ff576 100644 --- a/aio/tests/e2e/src/app.po.ts +++ b/aio/tests/e2e/src/app.po.ts @@ -9,6 +9,7 @@ export class SitePage { docsMenuLink = element(by.cssContainingText('aio-top-menu a', 'Docs')); sidenav = element(by.css('mat-sidenav')); docViewer = element(by.css('aio-doc-viewer')); + cookiesPopup = element(by.css('.cookies-popup')); codeExample = element.all(by.css('aio-doc-viewer pre > code')); ghLinks = this.docViewer .all(by.css('a')) @@ -39,10 +40,15 @@ export class SitePage { ga() { return browser.executeScript('return window["ga"].q'); } locationPath() { return browser.executeScript('return document.location.pathname'); } - async navigateTo(pageUrl: string) { - // Navigate to the page, disable animations, and wait for Angular. + async navigateTo(pageUrl: string, keepCookiesPopup = false) { + // Navigate to the page, disable animations, potentially hide the cookies popup, and wait for + // Angular. await browser.get(`/${pageUrl.replace(/^\//, '')}`); await browser.executeScript('document.body.classList.add(\'no-animations\')'); + if (!keepCookiesPopup) { + // Hide the cookies popup to prevent it from obscuring other elements. + await browser.executeScript('arguments[0].remove()', this.cookiesPopup); + } await browser.waitForAngular(); } diff --git a/aio/tests/e2e/src/cookies-popup.e2e-spec.ts b/aio/tests/e2e/src/cookies-popup.e2e-spec.ts new file mode 100644 index 0000000000..1ae42752bf --- /dev/null +++ b/aio/tests/e2e/src/cookies-popup.e2e-spec.ts @@ -0,0 +1,53 @@ +import { browser, by } from 'protractor'; +import { SitePage } from './app.po'; + +describe('cookies popup', () => { + const getButton = (idx: number) => page.cookiesPopup.all(by.css('.actions .mat-button')).get(idx); + let page: SitePage; + + beforeEach(async () => { + page = new SitePage(); + await page.navigateTo('', true); + }); + + afterEach(() => browser.executeScript('localStorage.clear()')); + + it('should be shown by default', async () => { + expect(await page.cookiesPopup.isDisplayed()).toBe(true); + }); + + it('should open a new tab with more info when clicking the first button', async () => { + // Click the "Learn more" button. + await page.click(getButton(0)); + + // Switch to the newly opened tab. + const originalWindowHandle = await browser.getWindowHandle(); + const openedWindowHandle = (await browser.getAllWindowHandles()).pop() as string; + await browser.switchTo().window(openedWindowHandle); + await browser.waitForAngularEnabled(false); + + // Verify the tab's URL. + expect(await browser.getCurrentUrl()).toBe('https://policies.google.com/technologies/cookies'); + + // Close the tab and switch back to the original tab. + await browser.waitForAngularEnabled(true); + await browser.close(); + await browser.switchTo().window(originalWindowHandle); + }); + + it('should not hide the popup when clicking the first button', async () => { + await page.click(getButton(0)); + + expect(await page.cookiesPopup.isDisplayed()).toBe(true); + }); + + it('should hide the popup when clicking the second button', async () => { + expect(await page.cookiesPopup.isDisplayed()).toBe(true); + + await page.click(getButton(1)); + expect(await page.cookiesPopup.isPresent()).toBe(false); + + await page.navigateTo('', true); + expect(await page.cookiesPopup.isPresent()).toBe(false); + }); +}); diff --git a/goldens/size-tracking/aio-payloads.json b/goldens/size-tracking/aio-payloads.json index fa92ee0f9c..4dd3b70d74 100755 --- a/goldens/size-tracking/aio-payloads.json +++ b/goldens/size-tracking/aio-payloads.json @@ -3,7 +3,7 @@ "master": { "uncompressed": { "runtime-es2017": 4619, - "main-es2017": 454003, + "main-es2017": 456795, "polyfills-es2017": 55210 } } @@ -12,7 +12,7 @@ "master": { "uncompressed": { "runtime-es2017": 4619, - "main-es2017": 454138, + "main-es2017": 456930, "polyfills-es2017": 55348 } }