diff --git a/src/components/layouts/AppLayout.spec.tsx b/src/components/layouts/AppLayout.spec.tsx index 42e7534..f3507eb 100644 --- a/src/components/layouts/AppLayout.spec.tsx +++ b/src/components/layouts/AppLayout.spec.tsx @@ -46,9 +46,9 @@ describe('AppLayout component', () => { expect(getByText("Let's get started!")).toBeInTheDocument(); fireEvent.mouseEnter(getByText('English')); fireEvent.click(await findByText('Español (es-ES)')); - expect(getByText('¡Empecemos!')).toBeInTheDocument(); + expect(await findByText('¡Empecemos!')).toBeInTheDocument(); fireEvent.click(getByText('English (en-US)')); - expect(getByText("Let's get started!")).toBeInTheDocument(); + expect(await findByText("Let's get started!")).toBeInTheDocument(); }); it('should set language to Spanish', async () => { @@ -56,7 +56,7 @@ describe('AppLayout component', () => { expect(getByText("Let's get started!")).toBeInTheDocument(); fireEvent.mouseEnter(getByText('English')); fireEvent.click(await findByText('Español (es-ES)')); - expect(getByText('¡Empecemos!')).toBeInTheDocument(); + expect(await findByText('¡Empecemos!')).toBeInTheDocument(); }); }); }); diff --git a/src/components/layouts/LocaleSwitch.tsx b/src/components/layouts/LocaleSwitch.tsx index 9a3ebcb..3be57ed 100644 --- a/src/components/layouts/LocaleSwitch.tsx +++ b/src/components/layouts/LocaleSwitch.tsx @@ -1,9 +1,10 @@ import React from 'react'; -import { useTranslation } from 'react-i18next'; import styled from '@emotion/styled'; import { Button, Dropdown, Icon, Menu } from 'antd'; import { ClickParam } from 'antd/lib/menu'; +import { useStoreState } from 'easy-peasy'; import { localeConfig } from 'i18n'; +import { useStoreActions } from 'store'; const Styled = { Button: styled(Button)` @@ -12,13 +13,14 @@ const Styled = { }; const LocaleSwitch: React.FC = () => { - const { i18n } = useTranslation(); + const { settings } = useStoreState(s => s.app); + const { updateSettings } = useStoreActions(s => s.app); const changeLanguage = (e: ClickParam) => { - i18n.changeLanguage(e.key); + updateSettings({ lang: e.key }); }; const menu = ( - + {Object.entries(localeConfig.languages).map(([key, lang]) => ( {lang} ({key}) @@ -32,7 +34,7 @@ const LocaleSwitch: React.FC = () => { - {localeConfig.languages[i18n.language]} + {localeConfig.languages[settings.lang]} diff --git a/src/lib/settings/index.ts b/src/lib/settings/index.ts new file mode 100644 index 0000000..4b0934c --- /dev/null +++ b/src/lib/settings/index.ts @@ -0,0 +1 @@ +export { default as settingsService } from './settingsService'; diff --git a/src/lib/settings/settingsService.spec.ts b/src/lib/settings/settingsService.spec.ts new file mode 100644 index 0000000..a502bc8 --- /dev/null +++ b/src/lib/settings/settingsService.spec.ts @@ -0,0 +1,45 @@ +import { AppSettings } from 'types'; +import * as files from 'utils/files'; +import { settingsService } from './'; + +jest.mock('utils/files', () => ({ + write: jest.fn(), + read: jest.fn(), + exists: jest.fn(), +})); + +const filesMock = files as jest.Mocked; + +describe('SettingsService', () => { + let settings: AppSettings; + + beforeEach(() => { + settings = { + lang: 'en-US', + showAllNodeVersions: true, + }; + }); + + it('should save the settings to disk', () => { + settingsService.save(settings); + expect(filesMock.write).toBeCalledWith( + expect.stringContaining('settings.json'), + expect.stringContaining(`"lang": "en-US"`), + ); + }); + + it('should load the settings from disk', async () => { + filesMock.exists.mockResolvedValue(true); + filesMock.read.mockResolvedValue('{ "lang": "en-US" }'); + const settings = await settingsService.load(); + expect(settings).toBeDefined(); + expect(settings && settings.lang).toBe('en-US'); + expect(filesMock.read).toBeCalledWith(expect.stringContaining('settings.json')); + }); + + it('should return undefined if no settings are saved', async () => { + filesMock.exists.mockResolvedValue(false); + const settings = await settingsService.load(); + expect(settings).toBeUndefined(); + }); +}); diff --git a/src/lib/settings/settingsService.ts b/src/lib/settings/settingsService.ts new file mode 100644 index 0000000..95c413a --- /dev/null +++ b/src/lib/settings/settingsService.ts @@ -0,0 +1,35 @@ +import { info } from 'electron-log'; +import { join } from 'path'; +import { AppSettings, SettingsInjection } from 'types'; +import { dataPath } from 'utils/config'; +import { exists, read, write } from 'utils/files'; + +class SettingsService implements SettingsInjection { + /** + * Saves the given settings to the file system + * @param settings the list of settings to save + */ + async save(data: AppSettings) { + const json = JSON.stringify(data, null, 2); + const path = join(dataPath, 'settings.json'); + await write(path, json); + info(`saved settings to '${path}'`, json); + } + + /** + * Loads a list of settings from the file system + */ + async load(): Promise { + const path = join(dataPath, 'settings.json'); + if (await exists(path)) { + const json = await read(path); + const data = JSON.parse(json); + info(`loaded app settings from '${path}'`, data); + return data; + } else { + info(`skipped loading app settings because the file '${path}' doesn't exist`); + } + } +} + +export default new SettingsService(); diff --git a/src/store/index.ts b/src/store/index.ts index 9ae633a..9b1cfc1 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -6,6 +6,7 @@ import { bitcoindService } from 'lib/bitcoin'; import { dockerService } from 'lib/docker'; import { createIpcSender } from 'lib/ipc/ipcService'; import { LightningFactory } from 'lib/lightning'; +import { settingsService } from 'lib/settings'; import { createModel, RootModel } from 'store/models'; import { StoreInjections } from 'types'; @@ -55,6 +56,7 @@ export const createReduxStore = (options?: { // see https://easy-peasy.now.sh/docs/testing/testing-components.html#mocking-calls-to-services const injections: StoreInjections = { ipc: createIpcSender('AppModel', 'app'), + settingsService, dockerService, bitcoindService, lightningFactory: new LightningFactory(), diff --git a/src/store/models/app.spec.ts b/src/store/models/app.spec.ts index 2b38850..ffe5fc9 100644 --- a/src/store/models/app.spec.ts +++ b/src/store/models/app.spec.ts @@ -7,6 +7,9 @@ import networkModel from './network'; const mockDockerService = injections.dockerService as jest.Mocked< typeof injections.dockerService >; +const mockSettingsService = injections.settingsService as jest.Mocked< + typeof injections.settingsService +>; describe('App model', () => { const rootModel = { @@ -22,15 +25,25 @@ describe('App model', () => { store = createStore(rootModel, { injections }); mockDockerService.getVersions.mockResolvedValue({ docker: '', compose: '' }); mockDockerService.loadNetworks.mockResolvedValue({ networks: [], charts: {} }); + mockSettingsService.load.mockResolvedValue({ + lang: 'en-US', + showAllNodeVersions: true, + }); }); it('should initialize', async () => { await store.getActions().app.initialize(); expect(store.getState().app.initialized).toBe(true); + expect(mockSettingsService.load).toBeCalledTimes(1); expect(mockDockerService.getVersions).toBeCalledTimes(1); expect(mockDockerService.loadNetworks).toBeCalledTimes(1); }); + it('should update settings', async () => { + store.getActions().app.updateSettings({ showAllNodeVersions: true }); + expect(store.getState().app.settings.showAllNodeVersions).toBe(true); + }); + describe('with mocked actions', () => { beforeEach(() => { // reset the store before each test run diff --git a/src/store/models/app.ts b/src/store/models/app.ts index 8eec70a..5a8a113 100644 --- a/src/store/models/app.ts +++ b/src/store/models/app.ts @@ -1,16 +1,14 @@ +import { getI18n } from 'react-i18next'; import { shell } from 'electron'; import { notification } from 'antd'; import { ArgsProps } from 'antd/lib/notification'; import { push } from 'connected-react-router'; import { Action, action, Thunk, thunk } from 'easy-peasy'; import { ipcChannels } from 'shared'; -import { DockerVersions, StoreInjections } from 'types'; +import { localeConfig } from 'i18n'; +import { AppSettings, DockerVersions, StoreInjections } from 'types'; import { RootModel } from './'; -export interface AppSettings { - showAllNodeVersions: boolean; -} - export interface NotifyOptions { message: string; description?: string; @@ -24,6 +22,8 @@ export interface AppModel { dockerImages: string[]; setInitialized: Action; setSettings: Action>; + loadSettings: Thunk; + updateSettings: Thunk, StoreInjections, RootModel>; initialize: Thunk; setDockerVersions: Action; getDockerVersions: Thunk; @@ -40,6 +40,7 @@ const appModel: AppModel = { // state properties initialized: false, settings: { + lang: localeConfig.fallbackLng, showAllNodeVersions: false, }, dockerVersions: { docker: '', compose: '' }, @@ -48,7 +49,8 @@ const appModel: AppModel = { setInitialized: action((state, initialized) => { state.initialized = initialized; }), - initialize: thunk(async (actions, payload, { getStoreActions }) => { + initialize: thunk(async (actions, _, { getStoreActions }) => { + await actions.loadSettings(); await getStoreActions().network.load(); await actions.getDockerVersions({}); await actions.getDockerImages(); @@ -60,6 +62,19 @@ const appModel: AppModel = { ...settings, }; }), + loadSettings: thunk(async (actions, _, { injections }) => { + const settings = await injections.settingsService.load(); + if (settings) { + actions.setSettings(settings); + await getI18n().changeLanguage(settings.lang); + } + }), + updateSettings: thunk(async (actions, updates, { injections, getState }) => { + actions.setSettings(updates); + const { settings } = getState(); + await injections.settingsService.save(settings); + if (updates.lang) await getI18n().changeLanguage(settings.lang); + }), setDockerVersions: action((state, versions) => { state.dockerVersions = versions; }), diff --git a/src/types/index.ts b/src/types/index.ts index f9583f7..9fc53fc 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -22,6 +22,16 @@ export interface Network { }; } +export interface AppSettings { + lang: string; + showAllNodeVersions: boolean; +} + +export interface SettingsInjection { + save: (settings: AppSettings) => Promise; + load: () => Promise; +} + export interface DockerVersions { docker: string; compose: string; @@ -77,6 +87,7 @@ export interface LightningFactoryInjection { export interface StoreInjections { ipc: IpcSender; + settingsService: SettingsInjection; dockerService: DockerLibrary; bitcoindService: BitcoindLibrary; lightningFactory: LightningFactoryInjection; diff --git a/src/utils/tests/renderWithProviders.tsx b/src/utils/tests/renderWithProviders.tsx index 193d0aa..2bc6e4c 100644 --- a/src/utils/tests/renderWithProviders.tsx +++ b/src/utils/tests/renderWithProviders.tsx @@ -23,6 +23,10 @@ export const lightningServiceMock: jest.Mocked = { // injections allow you to mock the dependencies of redux store actions export const injections: StoreInjections = { ipc: jest.fn(), + settingsService: { + load: jest.fn(), + save: jest.fn(), + }, dockerService: { getVersions: jest.fn(), getImages: jest.fn(),