refactor(docs-infra): provide `local-/sessionStorage` via DI (#42259)
Previously, we had the same logic in a couple of places to safely access the `Window`'s `local-/sessionStorage` and provide a no-op fallback if necessary. Soon, we will need the same logic for the cookies popup (see #42209). This commit reduces code duplication by providing `local-/sessionStorage` as injectables and sharing the logic for accessing them safely. This also makes it easier to mock the storage in tests without having to mess with the actual `Window` object. NOTE: This commit actually decreases the payload size in the `main` bundle by 40B. PR Close #42259
This commit is contained in:
parent
a7d1e65a51
commit
1a6a79b63a
|
@ -21,6 +21,7 @@ import { ModeBannerComponent } from 'app/layout/mode-banner/mode-banner.componen
|
||||||
import { GaService } from 'app/shared/ga.service';
|
import { GaService } from 'app/shared/ga.service';
|
||||||
import { Logger } from 'app/shared/logger.service';
|
import { Logger } from 'app/shared/logger.service';
|
||||||
import { LocationService } from 'app/shared/location.service';
|
import { LocationService } from 'app/shared/location.service';
|
||||||
|
import { STORAGE_PROVIDERS } from 'app/shared/storage.service';
|
||||||
import { NavigationService } from 'app/navigation/navigation.service';
|
import { NavigationService } from 'app/navigation/navigation.service';
|
||||||
import { DocumentService } from 'app/documents/document.service';
|
import { DocumentService } from 'app/documents/document.service';
|
||||||
import { SearchService } from 'app/search/search.service';
|
import { SearchService } from 'app/search/search.service';
|
||||||
|
@ -187,6 +188,7 @@ export const svgIconProviders = [
|
||||||
ScrollService,
|
ScrollService,
|
||||||
ScrollSpyService,
|
ScrollSpyService,
|
||||||
SearchService,
|
SearchService,
|
||||||
|
STORAGE_PROVIDERS,
|
||||||
svgIconProviders,
|
svgIconProviders,
|
||||||
TocService,
|
TocService,
|
||||||
{ provide: CurrentDateToken, useFactory: currentDateProvider },
|
{ provide: CurrentDateToken, useFactory: currentDateProvider },
|
||||||
|
|
|
@ -3,8 +3,8 @@ import { Component, NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
import { By } from '@angular/platform-browser';
|
import { By } from '@angular/platform-browser';
|
||||||
import { CurrentDateToken } from 'app/shared/current-date';
|
import { CurrentDateToken } from 'app/shared/current-date';
|
||||||
|
import { LocalStorage, NoopStorage } from 'app/shared/storage.service';
|
||||||
import { NotificationComponent } from './notification.component';
|
import { NotificationComponent } from './notification.component';
|
||||||
import { WindowToken } from 'app/shared/window';
|
|
||||||
|
|
||||||
describe('NotificationComponent', () => {
|
describe('NotificationComponent', () => {
|
||||||
let component: NotificationComponent;
|
let component: NotificationComponent;
|
||||||
|
@ -14,7 +14,7 @@ describe('NotificationComponent', () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
declarations: [TestComponent, NotificationComponent],
|
declarations: [TestComponent, NotificationComponent],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: WindowToken, useClass: MockWindow },
|
{ provide: LocalStorage, useValue: new NoopStorage() },
|
||||||
{ provide: CurrentDateToken, useValue: now },
|
{ provide: CurrentDateToken, useValue: now },
|
||||||
],
|
],
|
||||||
imports: [NoopAnimationsModule],
|
imports: [NoopAnimationsModule],
|
||||||
|
@ -92,7 +92,8 @@ describe('NotificationComponent', () => {
|
||||||
it('should update localStorage key when dismiss is called', () => {
|
it('should update localStorage key when dismiss is called', () => {
|
||||||
configTestingModule();
|
configTestingModule();
|
||||||
createComponent();
|
createComponent();
|
||||||
const setItemSpy: jasmine.Spy = (TestBed.inject(WindowToken) as MockWindow).localStorage.setItem;
|
const localStorage = TestBed.inject(LocalStorage);
|
||||||
|
const setItemSpy = spyOn(localStorage, 'setItem');
|
||||||
component.dismiss();
|
component.dismiss();
|
||||||
expect(setItemSpy).toHaveBeenCalledWith('aio-notification/survey-january-2018', 'hide');
|
expect(setItemSpy).toHaveBeenCalledWith('aio-notification/survey-january-2018', 'hide');
|
||||||
});
|
});
|
||||||
|
@ -105,28 +106,12 @@ describe('NotificationComponent', () => {
|
||||||
|
|
||||||
it('should not show the notification if the there is a "hide" flag in localStorage', () => {
|
it('should not show the notification if the there is a "hide" flag in localStorage', () => {
|
||||||
configTestingModule();
|
configTestingModule();
|
||||||
const getItemSpy: jasmine.Spy = (TestBed.inject(WindowToken) as MockWindow).localStorage.getItem;
|
const localStorage = TestBed.inject(LocalStorage);
|
||||||
getItemSpy.and.returnValue('hide');
|
const getItemSpy = spyOn(localStorage, 'getItem').and.returnValue('hide');
|
||||||
createComponent();
|
createComponent();
|
||||||
expect(getItemSpy).toHaveBeenCalledWith('aio-notification/survey-january-2018');
|
expect(getItemSpy).toHaveBeenCalledWith('aio-notification/survey-january-2018');
|
||||||
expect(component.showNotification).toBe('hide');
|
expect(component.showNotification).toBe('hide');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not break when cookies are disabled in the browser', () => {
|
|
||||||
configTestingModule();
|
|
||||||
|
|
||||||
// Simulate `window.localStorage` being inaccessible, when cookies are disabled.
|
|
||||||
const mockWindow: MockWindow = TestBed.inject(WindowToken);
|
|
||||||
Object.defineProperty(mockWindow, 'localStorage', {
|
|
||||||
get() { throw new Error('The operation is insecure'); },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(() => createComponent()).not.toThrow();
|
|
||||||
expect(component.showNotification).toBe('show');
|
|
||||||
|
|
||||||
component.dismiss();
|
|
||||||
expect(component.showNotification).toBe('hide');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
@ -145,7 +130,3 @@ describe('NotificationComponent', () => {
|
||||||
})
|
})
|
||||||
class TestComponent {
|
class TestComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
class MockWindow {
|
|
||||||
localStorage = jasmine.createSpyObj('localStorage', ['getItem', 'setItem']);
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { animate, state, style, trigger, transition } from '@angular/animations';
|
import { animate, state, style, trigger, transition } from '@angular/animations';
|
||||||
import { Component, EventEmitter, HostBinding, Inject, Input, OnInit, Output } from '@angular/core';
|
import { Component, EventEmitter, HostBinding, Inject, Input, OnInit, Output } from '@angular/core';
|
||||||
import { CurrentDateToken } from 'app/shared/current-date';
|
import { CurrentDateToken } from 'app/shared/current-date';
|
||||||
import { WindowToken } from 'app/shared/window';
|
import { LocalStorage } from 'app/shared/storage.service';
|
||||||
|
|
||||||
const LOCAL_STORAGE_NAMESPACE = 'aio-notification/';
|
const LOCAL_STORAGE_NAMESPACE = 'aio-notification/';
|
||||||
|
|
||||||
|
@ -20,8 +20,6 @@ const LOCAL_STORAGE_NAMESPACE = 'aio-notification/';
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class NotificationComponent implements OnInit {
|
export class NotificationComponent implements OnInit {
|
||||||
private storage: Storage;
|
|
||||||
|
|
||||||
@Input() dismissOnContentClick: boolean;
|
@Input() dismissOnContentClick: boolean;
|
||||||
@Input() notificationId: string;
|
@Input() notificationId: string;
|
||||||
@Input() expirationDate: string;
|
@Input() expirationDate: string;
|
||||||
|
@ -30,25 +28,7 @@ export class NotificationComponent implements OnInit {
|
||||||
@HostBinding('@hideAnimation')
|
@HostBinding('@hideAnimation')
|
||||||
showNotification: 'show'|'hide';
|
showNotification: 'show'|'hide';
|
||||||
|
|
||||||
constructor(
|
constructor(@Inject(LocalStorage) private storage: Storage, @Inject(CurrentDateToken) private currentDate: Date) {}
|
||||||
@Inject(WindowToken) window: Window,
|
|
||||||
@Inject(CurrentDateToken) private currentDate: Date
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
this.storage = window.localStorage;
|
|
||||||
} catch {
|
|
||||||
// When cookies are disabled in the browser, even trying to access
|
|
||||||
// `window.localStorage` throws an error. Use a no-op storage.
|
|
||||||
this.storage = {
|
|
||||||
length: 0,
|
|
||||||
clear: () => undefined,
|
|
||||||
getItem: () => null,
|
|
||||||
key: () => null,
|
|
||||||
removeItem: () => undefined,
|
|
||||||
setItem: () => undefined
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
const previouslyHidden = this.storage.getItem(LOCAL_STORAGE_NAMESPACE + this.notificationId) === 'hide';
|
const previouslyHidden = this.storage.getItem(LOCAL_STORAGE_NAMESPACE + this.notificationId) === 'hide';
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {Injector} from '@angular/core';
|
||||||
import {fakeAsync, tick} from '@angular/core/testing';
|
import {fakeAsync, tick} from '@angular/core/testing';
|
||||||
|
|
||||||
import {ScrollService, topMargin} from './scroll.service';
|
import {ScrollService, topMargin} from './scroll.service';
|
||||||
|
import {SessionStorage, NoopStorage} from './storage.service';
|
||||||
|
|
||||||
describe('ScrollService', () => {
|
describe('ScrollService', () => {
|
||||||
const scrollServiceInstances: ScrollService[] = [];
|
const scrollServiceInstances: ScrollService[] = [];
|
||||||
|
@ -20,6 +21,7 @@ describe('ScrollService', () => {
|
||||||
let platformLocation: MockPlatformLocation;
|
let platformLocation: MockPlatformLocation;
|
||||||
let scrollService: ScrollService;
|
let scrollService: ScrollService;
|
||||||
let location: SpyLocation;
|
let location: SpyLocation;
|
||||||
|
let sessionStorage: Storage;
|
||||||
|
|
||||||
class MockPlatformLocation {
|
class MockPlatformLocation {
|
||||||
hash: string;
|
hash: string;
|
||||||
|
@ -46,13 +48,14 @@ describe('ScrollService', () => {
|
||||||
{
|
{
|
||||||
provide: ScrollService,
|
provide: ScrollService,
|
||||||
useFactory: createScrollService,
|
useFactory: createScrollService,
|
||||||
deps: [DOCUMENT, PlatformLocation, ViewportScroller, Location],
|
deps: [DOCUMENT, PlatformLocation, ViewportScroller, Location, SessionStorage],
|
||||||
},
|
},
|
||||||
{provide: Location, useClass: SpyLocation, deps: [] },
|
{provide: Location, useClass: SpyLocation, deps: []},
|
||||||
{provide: DOCUMENT, useClass: MockDocument, deps: []},
|
{provide: DOCUMENT, useClass: MockDocument, deps: []},
|
||||||
{provide: PlatformLocation, useClass: MockPlatformLocation, deps: []},
|
{provide: PlatformLocation, useClass: MockPlatformLocation, deps: []},
|
||||||
{provide: ViewportScroller, useValue: viewportScrollerStub},
|
{provide: ViewportScroller, useValue: viewportScrollerStub},
|
||||||
{provide: LocationStrategy, useClass: MockLocationStrategy, deps: []}
|
{provide: LocationStrategy, useClass: MockLocationStrategy, deps: []},
|
||||||
|
{provide: SessionStorage, useValue: new NoopStorage()},
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -60,13 +63,13 @@ describe('ScrollService', () => {
|
||||||
document = injector.get(DOCUMENT) as unknown as MockDocument;
|
document = injector.get(DOCUMENT) as unknown as MockDocument;
|
||||||
scrollService = injector.get(ScrollService);
|
scrollService = injector.get(ScrollService);
|
||||||
location = injector.get(Location) as unknown as SpyLocation;
|
location = injector.get(Location) as unknown as SpyLocation;
|
||||||
|
sessionStorage = injector.get(SessionStorage);
|
||||||
|
|
||||||
spyOn(window, 'scrollBy');
|
spyOn(window, 'scrollBy');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
scrollServiceInstances.forEach(instance => instance.ngOnDestroy());
|
scrollServiceInstances.forEach(instance => instance.ngOnDestroy());
|
||||||
window.sessionStorage.clear();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should debounce `updateScrollPositonInHistory()`', fakeAsync(() => {
|
it('should debounce `updateScrollPositonInHistory()`', fakeAsync(() => {
|
||||||
|
@ -92,7 +95,8 @@ describe('ScrollService', () => {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
});
|
});
|
||||||
scrollService = createScrollService(
|
scrollService = createScrollService(
|
||||||
document, platformLocation as PlatformLocation, viewportScrollerStub, location);
|
document, platformLocation as PlatformLocation, viewportScrollerStub, location,
|
||||||
|
sessionStorage);
|
||||||
|
|
||||||
expect(scrollService.supportManualScrollRestoration).toBe(false);
|
expect(scrollService.supportManualScrollRestoration).toBe(false);
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -112,32 +116,6 @@ describe('ScrollService', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not break when cookies are disabled in the browser', () => {
|
|
||||||
expect(() => {
|
|
||||||
const originalSessionStorage = Object.getOwnPropertyDescriptor(window, 'sessionStorage') as PropertyDescriptor;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Simulate `window.sessionStorage` being inaccessible, when cookies are disabled.
|
|
||||||
Object.defineProperty(window, 'sessionStorage', {
|
|
||||||
get() {
|
|
||||||
throw new Error('The operation is insecure');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const platformLoc = platformLocation as PlatformLocation;
|
|
||||||
const service = createScrollService(document, platformLoc, viewportScrollerStub, location);
|
|
||||||
|
|
||||||
service.updateScrollLocationHref();
|
|
||||||
expect(service.getStoredScrollLocationHref()).toBeNull();
|
|
||||||
|
|
||||||
service.removeStoredScrollInfo();
|
|
||||||
expect(service.getStoredScrollPosition()).toBeNull();
|
|
||||||
} finally {
|
|
||||||
Object.defineProperty(window, 'sessionStorage', originalSessionStorage);
|
|
||||||
}
|
|
||||||
}).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('#topOffset', () => {
|
describe('#topOffset', () => {
|
||||||
it('should query for the top-bar by CSS selector', () => {
|
it('should query for the top-bar by CSS selector', () => {
|
||||||
expect(document.querySelector).not.toHaveBeenCalled();
|
expect(document.querySelector).not.toHaveBeenCalled();
|
||||||
|
|
|
@ -2,6 +2,7 @@ import {DOCUMENT, Location, PlatformLocation, PopStateEvent, ViewportScroller} f
|
||||||
import {Inject, Injectable, OnDestroy} from '@angular/core';
|
import {Inject, Injectable, OnDestroy} from '@angular/core';
|
||||||
import {fromEvent, Subject} from 'rxjs';
|
import {fromEvent, Subject} from 'rxjs';
|
||||||
import {debounceTime, takeUntil} from 'rxjs/operators';
|
import {debounceTime, takeUntil} from 'rxjs/operators';
|
||||||
|
import {SessionStorage} from './storage.service';
|
||||||
|
|
||||||
type ScrollPosition = [number, number];
|
type ScrollPosition = [number, number];
|
||||||
interface ScrollPositionPopStateEvent extends PopStateEvent {
|
interface ScrollPositionPopStateEvent extends PopStateEvent {
|
||||||
|
@ -18,7 +19,6 @@ export class ScrollService implements OnDestroy {
|
||||||
private _topOffset: number|null;
|
private _topOffset: number|null;
|
||||||
private _topOfPageElement: Element;
|
private _topOfPageElement: Element;
|
||||||
private onDestroy = new Subject<void>();
|
private onDestroy = new Subject<void>();
|
||||||
private storage: Storage;
|
|
||||||
|
|
||||||
// The scroll position which has to be restored, after a `popstate` event.
|
// The scroll position which has to be restored, after a `popstate` event.
|
||||||
poppedStateScrollPosition: ScrollPosition|null = null;
|
poppedStateScrollPosition: ScrollPosition|null = null;
|
||||||
|
@ -45,22 +45,8 @@ export class ScrollService implements OnDestroy {
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DOCUMENT) private document: any, private platformLocation: PlatformLocation,
|
@Inject(DOCUMENT) private document: any, private platformLocation: PlatformLocation,
|
||||||
private viewportScroller: ViewportScroller, private location: Location) {
|
private viewportScroller: ViewportScroller, private location: Location,
|
||||||
try {
|
@Inject(SessionStorage) private storage: Storage) {
|
||||||
this.storage = window.sessionStorage;
|
|
||||||
} catch {
|
|
||||||
// When cookies are disabled in the browser, even trying to access
|
|
||||||
// `window.sessionStorage` throws an error. Use a no-op storage.
|
|
||||||
this.storage = {
|
|
||||||
length: 0,
|
|
||||||
clear: () => undefined,
|
|
||||||
getItem: () => null,
|
|
||||||
key: () => null,
|
|
||||||
removeItem: () => undefined,
|
|
||||||
setItem: () => undefined
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// On resize, the toolbar might change height, so "invalidate" the top offset.
|
// On resize, the toolbar might change height, so "invalidate" the top offset.
|
||||||
fromEvent(window, 'resize')
|
fromEvent(window, 'resize')
|
||||||
.pipe(takeUntil(this.onDestroy))
|
.pipe(takeUntil(this.onDestroy))
|
||||||
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { Injector } from '@angular/core';
|
||||||
|
import { LocalStorage, NoopStorage, SessionStorage, STORAGE_PROVIDERS } from './storage.service';
|
||||||
|
import { WindowToken } from './window';
|
||||||
|
|
||||||
|
[
|
||||||
|
['localStorage', LocalStorage] as const,
|
||||||
|
['sessionStorage', SessionStorage] as const,
|
||||||
|
].forEach(([storagePropName, storageToken]) => {
|
||||||
|
let getStorageSpy: jasmine.Spy;
|
||||||
|
let injector: Injector;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
getStorageSpy = jasmine.createSpy(`get ${storagePropName}`);
|
||||||
|
injector = Injector.create({
|
||||||
|
providers: [
|
||||||
|
STORAGE_PROVIDERS,
|
||||||
|
{
|
||||||
|
provide: WindowToken,
|
||||||
|
useValue: Object.defineProperty({}, storagePropName, { get: getStorageSpy }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the storage from `window`', () => {
|
||||||
|
const mockStorage = { mock: true } as unknown as Storage;
|
||||||
|
getStorageSpy.and.returnValue(mockStorage);
|
||||||
|
|
||||||
|
expect(injector.get(storageToken)).toBe(mockStorage);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return a no-op storage if accessing the storage on `window` errors', () => {
|
||||||
|
getStorageSpy.and.throwError('Can\'t touch this!');
|
||||||
|
|
||||||
|
expect(injector.get(storageToken)).toBeInstanceOf(NoopStorage);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { InjectionToken, StaticProvider } from '@angular/core';
|
||||||
|
import { WindowToken } from './window';
|
||||||
|
|
||||||
|
export const LocalStorage = new InjectionToken<Storage>('LocalStorage');
|
||||||
|
export const SessionStorage = new InjectionToken<Storage>('SessionStorage');
|
||||||
|
|
||||||
|
export const STORAGE_PROVIDERS: StaticProvider[] = [
|
||||||
|
{ provide: LocalStorage, useFactory: (win: Window) => getStorage(win, 'localStorage'), deps: [WindowToken] },
|
||||||
|
{ provide: SessionStorage, useFactory: (win: Window) => getStorage(win, 'sessionStorage'), deps: [WindowToken] },
|
||||||
|
];
|
||||||
|
|
||||||
|
export class NoopStorage implements Storage {
|
||||||
|
length = 0;
|
||||||
|
clear() {}
|
||||||
|
getItem() { return null; }
|
||||||
|
key() { return null; }
|
||||||
|
removeItem() {}
|
||||||
|
setItem() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStorage(win: Window, storageType: 'localStorage' | 'sessionStorage'): Storage {
|
||||||
|
// When cookies are disabled in the browser, even trying to access `window[storageType]` throws an
|
||||||
|
// error. If so, return a no-op storage.
|
||||||
|
try {
|
||||||
|
return win[storageType];
|
||||||
|
} catch {
|
||||||
|
return new NoopStorage();
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,43 +1,8 @@
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { By } from '@angular/platform-browser';
|
import { By } from '@angular/platform-browser';
|
||||||
import { ThemeStorage, ThemeToggleComponent } from './theme-toggle.component';
|
import { LocalStorage, NoopStorage } from '../storage.service';
|
||||||
|
import { storageKey as themeStorageKey, ThemeToggleComponent } from './theme-toggle.component';
|
||||||
class FakeThemeStorage implements ThemeStorage {
|
|
||||||
fakeStorage: string | null = null;
|
|
||||||
|
|
||||||
getThemePreference(): string | null {
|
|
||||||
return this.fakeStorage;
|
|
||||||
}
|
|
||||||
|
|
||||||
setThemePreference(isDark: boolean): void {
|
|
||||||
this.fakeStorage = String(isDark);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify that FakeThemeStorage behaves like ThemeStorage would
|
|
||||||
describe('FakeThemeStorage', () => {
|
|
||||||
let themeStorage: ThemeStorage;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
themeStorage = new FakeThemeStorage();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have null stored initially', () => {
|
|
||||||
expect(themeStorage.getThemePreference()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should store true as a string if isDark is true', () => {
|
|
||||||
themeStorage.setThemePreference(true);
|
|
||||||
expect(themeStorage.getThemePreference()).toBe('true');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should store false as a string if isDark is false', () => {
|
|
||||||
themeStorage.setThemePreference(false);
|
|
||||||
expect(themeStorage.getThemePreference()).toBe('false');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
describe('ThemeToggleComponent', () => {
|
describe('ThemeToggleComponent', () => {
|
||||||
let component: ThemeToggleComponent;
|
let component: ThemeToggleComponent;
|
||||||
|
@ -47,6 +12,7 @@ describe('ThemeToggleComponent', () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
declarations: [ ThemeToggleComponent ],
|
declarations: [ ThemeToggleComponent ],
|
||||||
imports: [ MatIconModule ],
|
imports: [ MatIconModule ],
|
||||||
|
providers: [ { provide: LocalStorage, useValue: new NoopStorage() } ],
|
||||||
});
|
});
|
||||||
|
|
||||||
fixture = TestBed.createComponent(ThemeToggleComponent);
|
fixture = TestBed.createComponent(ThemeToggleComponent);
|
||||||
|
@ -82,6 +48,30 @@ describe('ThemeToggleComponent', () => {
|
||||||
expect(component.getToggleLabel()).toBe('Switch to light mode');
|
expect(component.getToggleLabel()).toBe('Switch to light mode');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should store the theme in `localStorage`', () => {
|
||||||
|
const storage = TestBed.inject(LocalStorage);
|
||||||
|
const setItemSpy = spyOn(storage, 'setItem');
|
||||||
|
|
||||||
|
component.toggleTheme();
|
||||||
|
expect(setItemSpy).toHaveBeenCalledWith(themeStorageKey, 'true');
|
||||||
|
|
||||||
|
component.toggleTheme();
|
||||||
|
expect(setItemSpy).toHaveBeenCalledWith(themeStorageKey, 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize the theme from `localStorage`', () => {
|
||||||
|
const storage = TestBed.inject(LocalStorage);
|
||||||
|
const getItemSpy = spyOn(storage, 'getItem').withArgs(themeStorageKey);
|
||||||
|
|
||||||
|
getItemSpy.and.returnValue('false');
|
||||||
|
const component1 = TestBed.createComponent(ThemeToggleComponent).componentInstance;
|
||||||
|
expect(component1.isDark).toBe(false);
|
||||||
|
|
||||||
|
getItemSpy.and.returnValue('true');
|
||||||
|
const component2 = TestBed.createComponent(ThemeToggleComponent).componentInstance;
|
||||||
|
expect(component2.isDark).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
function getToggleButton(): HTMLButtonElement {
|
function getToggleButton(): HTMLButtonElement {
|
||||||
return fixture.debugElement.query(By.css('button')).nativeElement;
|
return fixture.debugElement.query(By.css('button')).nativeElement;
|
||||||
|
|
|
@ -1,27 +1,8 @@
|
||||||
import { DOCUMENT } from '@angular/common';
|
import { DOCUMENT } from '@angular/common';
|
||||||
import { Component, Inject, Injectable } from '@angular/core';
|
import { Component, Inject } from '@angular/core';
|
||||||
|
import { LocalStorage } from 'app/shared/storage.service';
|
||||||
|
|
||||||
/** Injectable facade around localStorage for theme preference to make testing easier. */
|
export const storageKey = 'aio-theme';
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class ThemeStorage {
|
|
||||||
getThemePreference(): string | null {
|
|
||||||
// Wrap localStorage access in try/catch because user agents can block localStorage. If it is
|
|
||||||
// blocked, we treat it as if no preference was previously stored.
|
|
||||||
try {
|
|
||||||
return localStorage.getItem('aio-theme');
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setThemePreference(isDark: boolean): void {
|
|
||||||
// Wrap localStorage access in try/catch because user agents can block localStorage. If it
|
|
||||||
// fails, we persist nothing.
|
|
||||||
try {
|
|
||||||
localStorage.setItem('aio-theme', String(isDark));
|
|
||||||
} catch { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'aio-theme-toggle',
|
selector: 'aio-theme-toggle',
|
||||||
|
@ -37,7 +18,7 @@ export class ThemeStorage {
|
||||||
export class ThemeToggleComponent {
|
export class ThemeToggleComponent {
|
||||||
isDark = false;
|
isDark = false;
|
||||||
|
|
||||||
constructor(@Inject(DOCUMENT) private document: Document, private readonly themeStorage: ThemeStorage) {
|
constructor(@Inject(DOCUMENT) private document: Document, @Inject(LocalStorage) private storage: Storage) {
|
||||||
this.initializeThemeFromPreferences();
|
this.initializeThemeFromPreferences();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -48,7 +29,7 @@ export class ThemeToggleComponent {
|
||||||
|
|
||||||
private initializeThemeFromPreferences(): void {
|
private initializeThemeFromPreferences(): void {
|
||||||
// Check whether there's an explicit preference in localStorage.
|
// Check whether there's an explicit preference in localStorage.
|
||||||
const storedPreference = this.themeStorage.getThemePreference();
|
const storedPreference = this.storage.getItem(storageKey);
|
||||||
|
|
||||||
// If we do have a preference in localStorage, use that. Otherwise,
|
// If we do have a preference in localStorage, use that. Otherwise,
|
||||||
// initialize based on the prefers-color-scheme media query.
|
// initialize based on the prefers-color-scheme media query.
|
||||||
|
@ -86,6 +67,6 @@ export class ThemeToggleComponent {
|
||||||
customLinkElement.href = `${this.getThemeName()}-theme.css`;
|
customLinkElement.href = `${this.getThemeName()}-theme.css`;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.themeStorage.setThemePreference(this.isDark);
|
this.storage.setItem(storageKey, String(this.isDark));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
"master": {
|
"master": {
|
||||||
"uncompressed": {
|
"uncompressed": {
|
||||||
"runtime-es2017": 4619,
|
"runtime-es2017": 4619,
|
||||||
"main-es2017": 454043,
|
"main-es2017": 454003,
|
||||||
"polyfills-es2017": 55210
|
"polyfills-es2017": 55210
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,7 +12,7 @@
|
||||||
"master": {
|
"master": {
|
||||||
"uncompressed": {
|
"uncompressed": {
|
||||||
"runtime-es2017": 4619,
|
"runtime-es2017": 4619,
|
||||||
"main-es2017": 454178,
|
"main-es2017": 454138,
|
||||||
"polyfills-es2017": 55348
|
"polyfills-es2017": 55348
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue