feat(docs-infra): implement popup to inform about the use of cookies (#42259)

This commit adds a popup to angular.io to inform the user about the use
of cookies. Once the user confirms having read the info, the popup will
not be shown on subsequent visits.

This commit is partly based on angular/material.angular.io#988.

Fixes #42209

PR Close #42259
This commit is contained in:
George Kalpakas 2021-06-11 20:14:57 +03:00 committed by Alex Rickabaugh
parent 1a6a79b63a
commit 828fde6e0d
12 changed files with 262 additions and 4 deletions

View File

@ -1,5 +1,7 @@
<div id="top-of-page"></div> <div id="top-of-page"></div>
<aio-cookies-popup></aio-cookies-popup>
<div *ngIf="isFetching" class="progress-bar-container"> <div *ngIf="isFetching" class="progress-bar-container">
<mat-progress-bar mode="indeterminate" color="warn"></mat-progress-bar> <mat-progress-bar mode="indeterminate" color="warn"></mat-progress-bar>
</div> </div>

View File

@ -7,6 +7,7 @@ import { MatSidenav } from '@angular/material/sidenav';
import { By, Title } from '@angular/platform-browser'; import { By, Title } from '@angular/platform-browser';
import { ElementsLoader } from 'app/custom-elements/elements-loader'; import { ElementsLoader } from 'app/custom-elements/elements-loader';
import { DocumentService } from 'app/documents/document.service'; 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 { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component';
import { CurrentNodes } from 'app/navigation/navigation.model'; import { CurrentNodes } from 'app/navigation/navigation.model';
import { NavigationNode, NavigationService } from 'app/navigation/navigation.service'; 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', () => { describe('deployment banner', () => {
it('should show a message if the deployment mode is "archive"', async () => { it('should show a message if the deployment mode is "archive"', async () => {
createTestingModule('a/b', 'archive'); createTestingModule('a/b', 'archive');

View File

@ -15,6 +15,7 @@ import { MatToolbarModule } from '@angular/material/toolbar';
import { AppComponent } from 'app/app.component'; import { AppComponent } from 'app/app.component';
import { CustomIconRegistry, SVG_ICONS } from 'app/shared/custom-icon-registry'; import { CustomIconRegistry, SVG_ICONS } from 'app/shared/custom-icon-registry';
import { Deployment } from 'app/shared/deployment.service'; 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 { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component';
import { DtComponent } from 'app/layout/doc-viewer/dt.component'; import { DtComponent } from 'app/layout/doc-viewer/dt.component';
import { ModeBannerComponent } from 'app/layout/mode-banner/mode-banner.component'; import { ModeBannerComponent } from 'app/layout/mode-banner/mode-banner.component';
@ -163,6 +164,7 @@ export const svgIconProviders = [
], ],
declarations: [ declarations: [
AppComponent, AppComponent,
CookiesPopupComponent,
DocViewerComponent, DocViewerComponent,
DtComponent, DtComponent,
FooterComponent, FooterComponent,

View File

@ -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<CookiesPopupComponent>;
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<HTMLAnchorElement>('a[mat-button]:nth-child(1)');
const okBtn = popup.querySelector<HTMLButtonElement>('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<Storage, 'getItem' | 'setItem'> {
private items = new Map<string, string>();
getItem(key: string): string | null {
return this.items.get(key) ?? null;
}
setItem(key: string, val: string): void {
this.items.set(key, val);
}
}
});

View File

@ -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: `
<div class="cookies-popup no-print" *ngIf="!hasAcceptedCookies">
<h2 class="visually-hidden">Cookies concent notice</h2>
This site uses cookies from Google to deliver its services and to analyze traffic.
<div class="actions">
<a mat-button href="https://policies.google.com/technologies/cookies" target="_blank" rel="noopener">
Learn more
</a>
<button mat-button (click)="acceptCookies()">
OK, got it
</button>
</div>
</div>
`,
})
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;
}
}

View File

@ -12,6 +12,7 @@
@forward 'cli-pages/cli-pages'; @forward 'cli-pages/cli-pages';
@forward 'code/code'; @forward 'code/code';
@forward 'contribute/contribute'; @forward 'contribute/contribute';
@forward 'cookies-popup/cookies-popup';
@forward 'contributor/contributor'; @forward 'contributor/contributor';
@forward 'deploy-theme/deploy-theme'; @forward 'deploy-theme/deploy-theme';
@forward 'details/details'; @forward 'details/details';

View File

@ -7,6 +7,7 @@
@use 'card/card-theme'; @use 'card/card-theme';
@use 'code/code-theme'; @use 'code/code-theme';
@use 'contributor/contributor-theme'; @use 'contributor/contributor-theme';
@use 'cookies-popup/cookies-popup-theme';
@use 'deploy-theme/deploy-theme'; @use 'deploy-theme/deploy-theme';
@use 'details/details-theme'; @use 'details/details-theme';
@use 'errors/errors-theme'; @use 'errors/errors-theme';
@ -33,6 +34,7 @@
@include card-theme.theme($theme); @include card-theme.theme($theme);
@include code-theme.theme($theme); @include code-theme.theme($theme);
@include contributor-theme.theme($theme); @include contributor-theme.theme($theme);
@include cookies-popup-theme.theme($theme);
@include deploy-theme.theme($theme); @include deploy-theme.theme($theme);
@include details-theme.theme($theme); @include details-theme.theme($theme);
@include errors-theme.theme($theme); @include errors-theme.theme($theme);

View File

@ -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);
}
}
}
}
}
}

View File

@ -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;
}
}
}
}

View File

@ -9,6 +9,7 @@ export class SitePage {
docsMenuLink = element(by.cssContainingText('aio-top-menu a', 'Docs')); docsMenuLink = element(by.cssContainingText('aio-top-menu a', 'Docs'));
sidenav = element(by.css('mat-sidenav')); sidenav = element(by.css('mat-sidenav'));
docViewer = element(by.css('aio-doc-viewer')); docViewer = element(by.css('aio-doc-viewer'));
cookiesPopup = element(by.css('.cookies-popup'));
codeExample = element.all(by.css('aio-doc-viewer pre > code')); codeExample = element.all(by.css('aio-doc-viewer pre > code'));
ghLinks = this.docViewer ghLinks = this.docViewer
.all(by.css('a')) .all(by.css('a'))
@ -39,10 +40,15 @@ export class SitePage {
ga() { return browser.executeScript<any[][]>('return window["ga"].q'); } ga() { return browser.executeScript<any[][]>('return window["ga"].q'); }
locationPath() { return browser.executeScript<string>('return document.location.pathname'); } locationPath() { return browser.executeScript<string>('return document.location.pathname'); }
async navigateTo(pageUrl: string) { async navigateTo(pageUrl: string, keepCookiesPopup = false) {
// Navigate to the page, disable animations, and wait for Angular. // Navigate to the page, disable animations, potentially hide the cookies popup, and wait for
// Angular.
await browser.get(`/${pageUrl.replace(/^\//, '')}`); await browser.get(`/${pageUrl.replace(/^\//, '')}`);
await browser.executeScript('document.body.classList.add(\'no-animations\')'); 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(); await browser.waitForAngular();
} }

View File

@ -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);
});
});

View File

@ -3,7 +3,7 @@
"master": { "master": {
"uncompressed": { "uncompressed": {
"runtime-es2017": 4619, "runtime-es2017": 4619,
"main-es2017": 454003, "main-es2017": 456795,
"polyfills-es2017": 55210 "polyfills-es2017": 55210
} }
} }
@ -12,7 +12,7 @@
"master": { "master": {
"uncompressed": { "uncompressed": {
"runtime-es2017": 4619, "runtime-es2017": 4619,
"main-es2017": 454138, "main-es2017": 456930,
"polyfills-es2017": 55348 "polyfills-es2017": 55348
} }
} }