diff --git a/csvjson.d.ts b/csvjson.d.ts deleted file mode 100644 index dbe9abde..00000000 --- a/csvjson.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -declare module 'csvjson' { - export declare class CsvJson { - public toObject(content: string): T[]; - } -} diff --git a/shlink-web-client.d.ts b/shlink-web-client.d.ts index 06f04f6c..10678f09 100644 --- a/shlink-web-client.d.ts +++ b/shlink-web-client.d.ts @@ -2,4 +2,11 @@ declare module 'event-source-polyfill' { export const EventSourcePolyfill: any; } +declare module 'csvjson' { + export declare class CsvJson { + public toObject(content: string): T[]; + public toCSV(data: T[], options: { headers: string }): string; + } +} + declare module '*.png' diff --git a/src/container/index.ts b/src/container/index.ts index 9b9a7c14..22271324 100644 --- a/src/container/index.ts +++ b/src/container/index.ts @@ -24,7 +24,7 @@ const mapActionService = (map: ActionMap, actionName: string): ActionMap => ({ // Wrap actual action service in a function so that it is lazily created the first time it is called [actionName]: lazyService(container, actionName), }); -const connect: ConnectDecorator = (propsFromState: string[], actionServiceNames: string[] = []) => +const connect: ConnectDecorator = (propsFromState: string[] | null, actionServiceNames: string[] = []) => reduxConnect( propsFromState ? pick(propsFromState) : null, actionServiceNames.reduce(mapActionService, {}), diff --git a/src/container/types.ts b/src/container/types.ts index 8b04e6fb..a9ebbde3 100644 --- a/src/container/types.ts +++ b/src/container/types.ts @@ -35,6 +35,6 @@ export interface ShlinkState { settings: Settings; } -export type ConnectDecorator = (props: string[], actions?: string[]) => any; +export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any; export type GetState = () => ShlinkState; diff --git a/src/servers/services/ServersExporter.js b/src/servers/services/ServersExporter.ts similarity index 66% rename from src/servers/services/ServersExporter.js rename to src/servers/services/ServersExporter.ts index 01a33316..fb610276 100644 --- a/src/servers/services/ServersExporter.js +++ b/src/servers/services/ServersExporter.ts @@ -1,6 +1,9 @@ import { dissoc, head, keys, values } from 'ramda'; +import { CsvJson } from 'csvjson'; +import LocalStorage from '../../utils/services/LocalStorage'; +import { ServersMap } from '../data'; -const saveCsv = (window, csv) => { +const saveCsv = (window: Window, csv: string) => { const { navigator, document } = window; const filename = 'shlink-servers.csv'; const blob = new Blob([ csv ], { type: 'text/csv;charset=utf-8;' }); @@ -25,14 +28,14 @@ const saveCsv = (window, csv) => { }; export default class ServersExporter { - constructor(storage, window, csvjson) { - this.storage = storage; - this.window = window; - this.csvjson = csvjson; - } + public constructor( + private readonly storage: LocalStorage, + private readonly window: Window, + private readonly csvjson: CsvJson, + ) {} - exportServers = async () => { - const servers = values(this.storage.get('servers') || {}).map(dissoc('id')); + public readonly exportServers = async () => { + const servers = values(this.storage.get('servers') || {}).map(dissoc('id')); try { const csv = this.csvjson.toCSV(servers, { diff --git a/src/servers/services/ServersImporter.ts b/src/servers/services/ServersImporter.ts index 8876d3fd..62de8f53 100644 --- a/src/servers/services/ServersImporter.ts +++ b/src/servers/services/ServersImporter.ts @@ -6,7 +6,7 @@ const CSV_MIME_TYPE = 'text/csv'; export default class ServersImporter { public constructor(private readonly csvjson: CsvJson, private readonly fileReaderFactory: () => FileReader) {} - public importServersFromFile = async (file?: File | null): Promise => { + public readonly importServersFromFile = async (file?: File | null): Promise => { if (!file || file.type !== CSV_MIME_TYPE) { throw new Error('No file provided or file is not a CSV'); } diff --git a/src/servers/services/provideServices.js b/src/servers/services/provideServices.ts similarity index 93% rename from src/servers/services/provideServices.js rename to src/servers/services/provideServices.ts index 7b8e7d99..b71bab9c 100644 --- a/src/servers/services/provideServices.js +++ b/src/servers/services/provideServices.ts @@ -1,4 +1,5 @@ import csvjson from 'csvjson'; +import Bottle, { Decorator } from 'bottlejs'; import CreateServer from '../CreateServer'; import ServersDropdown from '../ServersDropdown'; import DeleteServerModal from '../DeleteServerModal'; @@ -10,10 +11,11 @@ import { createServer, createServers, deleteServer, editServer } from '../reduce import { fetchServers } from '../reducers/remoteServers'; import ForServerVersion from '../helpers/ForServerVersion'; import { ServerError } from '../helpers/ServerError'; +import { ConnectDecorator } from '../../container/types'; import ServersImporter from './ServersImporter'; import ServersExporter from './ServersExporter'; -const provideServices = (bottle, connect, withRouter) => { +const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => { // Components bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useStateFlagTimeout'); bottle.decorator('CreateServer', connect([ 'selectedServer' ], [ 'createServer', 'resetSelectedServer' ])); diff --git a/src/utils/services/LocalStorage.ts b/src/utils/services/LocalStorage.ts new file mode 100644 index 00000000..20bbb491 --- /dev/null +++ b/src/utils/services/LocalStorage.ts @@ -0,0 +1,14 @@ +const PREFIX = 'shlink'; +const buildPath = (path: string) => `${PREFIX}.${path}`; + +export default class LocalStorage { + public constructor(private readonly localStorage: Storage) {} + + public readonly get = (key: string): T => { + const item = this.localStorage.getItem(buildPath(key)); + + return item ? JSON.parse(item) : undefined; + }; + + public readonly set = (key: string, value: any) => this.localStorage.setItem(buildPath(key), JSON.stringify(value)); +} diff --git a/src/utils/services/Storage.js b/src/utils/services/Storage.js deleted file mode 100644 index 35f9eb74..00000000 --- a/src/utils/services/Storage.js +++ /dev/null @@ -1,16 +0,0 @@ -const PREFIX = 'shlink'; -const buildPath = (path) => `${PREFIX}.${path}`; - -export default class Storage { - constructor(localStorage) { - this.localStorage = localStorage; - } - - get = (key) => { - const item = this.localStorage.getItem(buildPath(key)); - - return item ? JSON.parse(item) : undefined; - }; - - set = (key, value) => this.localStorage.setItem(buildPath(key), JSON.stringify(value)); -} diff --git a/src/utils/services/provideServices.ts b/src/utils/services/provideServices.ts index 458cb680..34574b6e 100644 --- a/src/utils/services/provideServices.ts +++ b/src/utils/services/provideServices.ts @@ -1,13 +1,13 @@ import axios from 'axios'; import Bottle from 'bottlejs'; import { useStateFlagTimeout } from '../helpers/hooks'; -import Storage from './Storage'; +import LocalStorage from './LocalStorage'; import ColorGenerator from './ColorGenerator'; import buildShlinkApiClient from './ShlinkApiClientBuilder'; const provideServices = (bottle: Bottle) => { bottle.constant('localStorage', (global as any).localStorage); - bottle.service('Storage', Storage, 'localStorage'); + bottle.service('Storage', LocalStorage, 'localStorage'); bottle.service('ColorGenerator', ColorGenerator, 'Storage'); bottle.constant('axios', axios); diff --git a/test/servers/services/ServersExporter.test.js b/test/servers/services/ServersExporter.test.ts similarity index 60% rename from test/servers/services/ServersExporter.test.js rename to test/servers/services/ServersExporter.test.ts index 1dc2156e..f4e689ff 100644 --- a/test/servers/services/ServersExporter.test.js +++ b/test/servers/services/ServersExporter.test.ts @@ -1,4 +1,7 @@ +import { Mock } from 'ts-mockery'; +import { CsvJson } from 'csvjson'; import ServersExporter from '../../../src/servers/services/ServersExporter'; +import LocalStorage from '../../../src/utils/services/LocalStorage'; describe('ServersExporter', () => { const createLinkMock = () => ({ @@ -6,7 +9,7 @@ describe('ServersExporter', () => { click: jest.fn(), style: {}, }); - const createWindowMock = (isIe10 = true) => ({ + const createWindowMock = (isIe10 = true) => Mock.of({ navigator: { msSaveBlob: isIe10 ? jest.fn() : undefined, }, @@ -18,7 +21,7 @@ describe('ServersExporter', () => { }, }, }); - const storageMock = { + const storageMock = Mock.of({ get: jest.fn(() => ({ abc123: { id: 'abc123', @@ -29,8 +32,8 @@ describe('ServersExporter', () => { name: 'bar', }, })), - }; - const createCsvjsonMock = (throwError = false) => ({ + }); + const createCsvjsonMock = (throwError = false) => Mock.of({ toCSV: jest.fn(() => { if (throwError) { throw new Error(''); @@ -41,13 +44,14 @@ describe('ServersExporter', () => { }); describe('exportServers', () => { - let originalConsole; + let originalConsole: Console; + const error = jest.fn(); beforeEach(() => { originalConsole = global.console; - global.console = { error: jest.fn() }; - global.Blob = class Blob {}; - global.URL = { createObjectURL: () => '' }; + global.console = Mock.of({ error }); + (global as any).Blob = class Blob {}; // eslint-disable-line @typescript-eslint/no-extraneous-class + (global as any).URL = { createObjectURL: () => '' }; }); afterEach(() => { global.console = originalConsole; @@ -57,34 +61,38 @@ describe('ServersExporter', () => { it('logs an error if something fails', () => { const csvjsonMock = createCsvjsonMock(true); const exporter = new ServersExporter(storageMock, createWindowMock(), csvjsonMock); + const { toCSV } = csvjsonMock; exporter.exportServers(); - expect(global.console.error).toHaveBeenCalledTimes(1); - expect(csvjsonMock.toCSV).toHaveBeenCalledTimes(1); + expect(error).toHaveBeenCalledTimes(1); + expect(toCSV).toHaveBeenCalledTimes(1); }); it('makes use of msSaveBlob API when available', () => { const windowMock = createWindowMock(); const exporter = new ServersExporter(storageMock, windowMock, createCsvjsonMock()); + const { navigator: { msSaveBlob }, document: { createElement } } = windowMock; exporter.exportServers(); expect(storageMock.get).toHaveBeenCalledTimes(1); - expect(windowMock.navigator.msSaveBlob).toHaveBeenCalledTimes(1); - expect(windowMock.document.createElement).not.toHaveBeenCalled(); + expect(msSaveBlob).toHaveBeenCalledTimes(1); + expect(createElement).not.toHaveBeenCalled(); }); it('makes use of download link API when available', () => { const windowMock = createWindowMock(false); const exporter = new ServersExporter(storageMock, windowMock, createCsvjsonMock()); + const { document: { createElement, body } } = windowMock; + const { appendChild, removeChild } = body; exporter.exportServers(); expect(storageMock.get).toHaveBeenCalledTimes(1); - expect(windowMock.document.createElement).toHaveBeenCalledTimes(1); - expect(windowMock.document.body.appendChild).toHaveBeenCalledTimes(1); - expect(windowMock.document.body.removeChild).toHaveBeenCalledTimes(1); + expect(createElement).toHaveBeenCalledTimes(1); + expect(appendChild).toHaveBeenCalledTimes(1); + expect(removeChild).toHaveBeenCalledTimes(1); }); }); }); diff --git a/test/utils/services/Storage.test.js b/test/utils/services/Storage.test.js index 290a5c09..ec427015 100644 --- a/test/utils/services/Storage.test.js +++ b/test/utils/services/Storage.test.js @@ -1,6 +1,6 @@ -import Storage from '../../../src/utils/services/Storage'; +import LocalStorage from '../../../src/utils/services/LocalStorage'; -describe('Storage', () => { +describe('LocalStorage', () => { const localStorageMock = { getItem: jest.fn((key) => key === 'shlink.foo' ? JSON.stringify({ foo: 'bar' }) : null), setItem: jest.fn(), @@ -11,7 +11,7 @@ describe('Storage', () => { localStorageMock.getItem.mockClear(); localStorageMock.setItem.mockReset(); - storage = new Storage(localStorageMock); + storage = new LocalStorage(localStorageMock); }); describe('set', () => {