diff --git a/.eslintrc.json b/.eslintrc.json index 4338d1d8..243f8f5c 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -21,7 +21,7 @@ "ecmaVersion": 11, "sourceType": "module" }, - "plugins": ["react"], + "plugins": ["react", "@emotion"], "rules": { "class-methods-use-this": "off", "comma-dangle": "off", diff --git a/common/config.js b/common/config.js deleted file mode 100644 index bb557da4..00000000 --- a/common/config.js +++ /dev/null @@ -1,5 +0,0 @@ -const env = require('./env'); - -const config = { ...env }; - -module.exports = config; diff --git a/common/request.js b/common/request.js index 35ddc5ad..42cd4e99 100644 --- a/common/request.js +++ b/common/request.js @@ -1,7 +1,5 @@ const axios = require('axios'); -const storage = require('./storage'); - let instance; const TWENTY_SECONDS = 20000; @@ -43,13 +41,6 @@ const getInstance = () => instance; const reset = () => { instance = undefined; }; -if (storage.user.isDefined()) { - const { api_key, redmineEndpoint } = storage.user.get(); - if (api_key && redmineEndpoint) { - initialize(redmineEndpoint, api_key); - } -} - const handleReject = (error) => { // if this request was not cancelled if (!axios.isCancel(error)) { diff --git a/common/storage.js b/common/storage.js deleted file mode 100644 index c93edaeb..00000000 --- a/common/storage.js +++ /dev/null @@ -1,26 +0,0 @@ -const os = require('os'); -const Store = require('electron-store'); -const isDev = require('electron-is-dev'); -const { ENCRYPTION_KEY } = require('./env'); - -const storage = new Store({ - name: isDev ? 'config-dev' : 'config', - encryptionKey: ENCRYPTION_KEY -}); - -const api = { - settings: { - get: () => storage.get('settings', { os: os.platform }), - isDefined: () => storage.has('settings'), - set: (data) => storage.set('settings', data) - }, - user: { - get: () => storage.get('user'), - isDefined: () => storage.has('user'), - set: (data) => storage.set('user', data) - } -}; - -// TODO: save os.platform into the storage on start - -module.exports = api; diff --git a/common/env.js b/main/env.js similarity index 100% rename from common/env.js rename to main/env.js diff --git a/main/exceptionCatcher.js b/main/exceptionCatcher.js index 7986210a..8d69c1a2 100644 --- a/main/exceptionCatcher.js +++ b/main/exceptionCatcher.js @@ -5,7 +5,7 @@ const isDev = require('electron-is-dev'); const { app, dialog, clipboard } = require('electron'); const logger = require('electron-log'); -const { report } = require('../common/reporter'); +const { report } = require('./handlers/errorHandler'); const config = { showDialog: !isDev, diff --git a/common/reporter.js b/main/handlers/errorHandler.js similarity index 100% rename from common/reporter.js rename to main/handlers/errorHandler.js diff --git a/main/index.js b/main/index.js index e304e338..d1f56f57 100644 --- a/main/index.js +++ b/main/index.js @@ -12,7 +12,7 @@ const electronUtils = require('electron-util'); const isDev = require('electron-is-dev'); const logger = require('electron-log'); -const storage = require('../common/storage'); +const storage = require('./storage'); const { redmineClient } = require('./redmine'); const Tray = require('./tray'); @@ -344,32 +344,53 @@ const initialize = () => { notification.show(); }); - ipcMain.on('menu', (event, { settings }) => { + ipcMain.on('menu', (event, message) => { + const { settings } = JSON.stringify(message); generateMenu({ settings }); }); - ipcMain.on('storage', (event, message) => { - const { action, data } = JSON.parse(message); - if (action === 'read') { - event.reply('storage', storage.settings.get()); - } else if (action === 'save') { - storage.settings.set(data); - } else { - throw new Error('Unable to process the requested action', action); - } - }); + storage.initializeSessionEvents(ipcMain); ipcMain.on('request', async (event, message) => { - const { payload, config, id } = JSON.parse(message); - console.log('Received a request for query', { payload, config }); + const { payload, id } = JSON.parse(message); + console.log('Received a request for query', { payload }); + if (!redmineClient.isInitialized()) { - redmineClient.initialize(config); + const error = new Error('Unauthorized'); + error.status = 401; + event.reply(`response:${id}`, { success: false, error }); + return; } const response = await redmineClient.send(payload); event.reply(`response:${id}`, response); }); + + ipcMain.on('system-request', async (event, message) => { + const { action, payload, id } = JSON.parse(message); + console.log(`Received system request for ${action}`, { payload }); + + switch (action.toLowerCase()) { + case 'login': { + const response = await redmineClient.initialize({ + endpoint: payload.endpoint, + token: payload.token, + headers: payload.headers + }); + event.reply(`system-response:${id}`, response); + break; + } + case 'logout': { + redmineClient.reset(); + await storage.resetActiveSession(); + event.reply(`system-response:${id}`, { success: true }); + break; + } + default: + break; + } + }); }; app.on('certificate-error', (event, webContents, _url, error, certificate, callback) => { @@ -391,15 +412,8 @@ app.once('ready', () => { } // eslint-disable-next-line global-require - const config = require('../common/config'); + const config = require('./env'); PORT = config.PORT; - // eslint-disable-next-line global-require - require('../common/request'); // to initialize from storage - - if (!storage.settings.isDefined()) { - // sets defaul value that storage.settings.get returns as a fallback - storage.settings.set(storage.settings.get()); - } initialize(); generateMenu(); @@ -410,6 +424,7 @@ app.once('ready', () => { app.on('window-all-closed', () => { if (process.platform !== 'darwin') { + storage.disposeSessionEvents(ipcMain); app.quit(); } }); diff --git a/main/redmine.js b/main/redmine.js index ab0a2f91..c4ecf0ac 100644 --- a/main/redmine.js +++ b/main/redmine.js @@ -1,26 +1,76 @@ const got = require('got'); const { transform } = require('./transformers/index'); -const createRequestClient = (initConfig = {}) => { - let isInitialized = false; +const createRequestClient = () => { let instance; - - const initialize = (config) => { - isInitialized = Boolean(config.endpoint && config.token); - instance = got.extend({ - prefixUrl: config.endpoint, - headers: { 'X-Redmine-API-Key': config.token }, - responseType: 'json' - }); - }; + let isInitialized; const handleUnsuccessfulRequest = (error) => ({ success: false, error }); + const initialize = async (data) => { + if (isInitialized) { + reset(); + } + + const route = 'users/current.json'; + + const configuration = data.token + ? { + prefixUrl: data.endpoint, + headers: { 'X-Redmine-API-Key': data.token }, + responseType: 'json' + } + : { + prefixUrl: data.endpoint, + headers: { + Authorization: data.headers.Authorization + }, + responseType: 'json' + }; + + try { + const response = await got(route, { + method: 'GET', + ...configuration + }); + + console.log(response.statusCode, response.body); + + const loginSuccess = response.statusCode === 200; + isInitialized = loginSuccess; + + if (loginSuccess) { + const payload = transform(route, response.body); + + instance = got.extend({ + ...configuration, + headers: { + 'X-Redmine-API-Key': payload.token + } + }); + + return { + success: true, + payload + }; + } + + return handleUnsuccessfulRequest(response.body); + } catch (error) { + return handleUnsuccessfulRequest(error); + } + }; + + const reset = () => { + isInitialized = false; + instance = undefined; + }; + const send = async (data) => { - if (!instance && !data.headers?.Authorization) { + if (!isInitialized) { throw new Error('Http client is not initialized.'); } @@ -34,12 +84,8 @@ const createRequestClient = (initConfig = {}) => { console.log(response.statusCode, response.body); if (response.statusCode === 200) { - if (!isInitialized && data.route === 'users/current.json' && response.body.user?.api_key) { - initialize({ endpoint: initConfig.endpoint, token: response.body.user?.api_key }); - } - return { - data: transform(data.route, response.body), + payload: transform(data.route, response.body), success: true, }; } @@ -50,12 +96,11 @@ const createRequestClient = (initConfig = {}) => { } }; - initialize(initConfig); - return { initialize, isInitialized: () => isInitialized, - send + send, + reset }; }; diff --git a/main/storage.js b/main/storage.js new file mode 100644 index 00000000..fd2406ab --- /dev/null +++ b/main/storage.js @@ -0,0 +1,144 @@ +const os = require('os'); +const Store = require('electron-store'); +const isDev = require('electron-is-dev'); +const crypto = require('crypto'); + +const { ENCRYPTION_KEY } = require('./env'); + +const TOKEN_FALLBACK = ''; + +const storage = new Store({ + name: isDev ? 'config-dev' : 'config', + encryptionKey: ENCRYPTION_KEY +}); + +const hashToken = (token) => crypto.createHash('sha256').update(token || TOKEN_FALLBACK, 'utf8').digest('hex'); + +console.log(JSON.stringify(storage.get('activeSession'), null, 2)); +console.log(JSON.stringify(storage.get('persistedSessions'), null, 2)); + +const upsertSavedSession = (persistedSessions, activeSession) => { + const savedActiveSessionIndex = persistedSessions.findIndex((session) => session.hash === activeSession.hash); + + if (savedActiveSessionIndex !== -1) { + const persistedSessionsCopy = [...persistedSessions]; + persistedSessionsCopy[savedActiveSessionIndex] = activeSession; + storage.set('persistedSessions', persistedSessionsCopy); + } else { + const updatedPersistedSessions = [...persistedSessions, activeSession]; + storage.set('persistedSessions', updatedPersistedSessions); + } +}; + +const getActiveSession = () => { + const activeSession = storage.get('activeSession'); + if (activeSession) { + return { + ...activeSession, + platforn: os.platform + }; + } + + return activeSession; +}; + +const getAllSettings = () => { + const activeSession = getActiveSession(); + const persistedSessions = storage.get('persistedSessions', []); + return { + activeSession, + persistedSessions + }; +}; + +const getSession = (token) => { + const { activeSession, persistedSessions } = getAllSettings(); + const hash = hashToken(token); + + if (hash === activeSession?.hash) { + return getActiveSession(); + } + + const targetSession = persistedSessions.find(session => session.hash === hash); + + return targetSession; +}; + +const resetActiveSession = () => { + storage.delete('activeSession'); +}; + +const saveSession = (session) => { + const sessionHash = hashToken(session.token); + + const sessionObject = { + hash: sessionHash, + user: { + id: session.user.id, + firstName: session.user.firstName, + lastName: session.user.lastName, + createdOn: session.user.createdOn + }, + endpoint: session.endpoint, + settings: { + ...session.settings + } + }; + + storage.set('activeSession', sessionObject); + upsertSavedSession(storage.get('persistedSessions', []), sessionObject); +}; + +const eventHandlers = (event, message) => { + const { action, payload, id } = JSON.parse(message); + + console.log('Received session event', message); + + switch (action) { + case 'READ': { + const session = getSession(payload.token); + + if (!session) { + event.reply(`session-response:${id}`, { + success: false + }); + break; + } + + const { hash, ...sessionWithoutHash } = session; + + event.reply(`session-response:${id}`, { + success: true, + payload: sessionWithoutHash + }); + break; + } + case 'SAVE': { + saveSession(payload); + event.reply(`session-response:${id}`, { success: true }); + break; + } + default: { + event.reply(`session-response:${id}`, { + success: false, + error: new Error('Unable to process the requested action', action) + }); + } + } +}; + +const initializeSessionEvents = (ipcMain) => { + ipcMain.on('session-request', eventHandlers); +}; + +const disposeSessionEvents = (ipcMain) => { + ipcMain.off('session-request', eventHandlers); +}; + +module.exports = { + getActiveSession, + getAllSettings, + resetActiveSession, + initializeSessionEvents, + disposeSessionEvents +}; diff --git a/main/transformers/users.js b/main/transformers/users.js index cd205c0f..46d62bdf 100644 --- a/main/transformers/users.js +++ b/main/transformers/users.js @@ -4,9 +4,16 @@ const transform = (route, responseBody) => { switch (route) { case 'users/current.json': { const { - _login, _admin, _api_key, ...userPayload + // eslint-disable-next-line camelcase + login, admin, last_login_on, ...userPayload } = user; - return userPayload; + return { + id: userPayload.id, + firstName: userPayload.firstname, + lastName: userPayload.lastname, + createdOn: userPayload.created_on, + token: userPayload.api_key + }; } default: return responseBody; diff --git a/package.json b/package.json index d3422d27..7cc01a49 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "@babel/plugin-transform-modules-commonjs": "^7.15.4", "@babel/preset-env": "^7.15.6", "@babel/preset-react": "^7.14.5", + "@emotion/eslint-plugin": "^11.5.0", "@testing-library/jest-dom": "^5.1.1", "@testing-library/react": "^9.4.0", "@types/hapi__joi": "^17.1.7", @@ -138,6 +139,7 @@ "webpack-node-externals": "^3.0.0" }, "dependencies": { + "@emotion/react": "^11.5.0", "@hapi/joi": "^17.1.0", "axios": "^0.19.2", "clean-stack": "^2.2.0", @@ -148,6 +150,7 @@ "electron-updater": "^4.2.2", "electron-util": "^0.14.0", "emotion": "^11.0.0", + "emotion-theming": "^11.0.0", "ensure-error": "^2.0.0", "formik": "^2.1.4", "got": "^11.8.2", diff --git a/render/App.jsx b/render/App.jsx index 0fb67e07..0cac44c4 100644 --- a/render/App.jsx +++ b/render/App.jsx @@ -1,7 +1,7 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import debounce from 'lodash/debounce'; -import { Route, Switch } from 'react-router-dom'; +import { Route, Switch, useHistory } from 'react-router-dom'; import { toast } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; import cleanStack from 'clean-stack'; @@ -11,7 +11,9 @@ import { ipcRenderer } from "electron"; import AppView from './views/AppView'; import LoginView from './views/LoginView'; import Notification from './components/Notification'; -import actions from './actions'; +import { useOvermindActions } from './store'; +import { getStoredToken } from './helpers/utils'; +import { LoadingOverlay } from './components/LoadingOverlay'; toast.configure({ autoClose: 3000, @@ -22,6 +24,11 @@ toast.configure({ }); const Routes = ({ dispatch }) => { + const [isReady, setIsReady] = useState(false); + + const actions = useOvermindActions(); + const history = useHistory(); + const handleRejection = useCallback(debounce((event) => { event.preventDefault(); if (event.reason) { @@ -71,10 +78,27 @@ const Routes = ({ dispatch }) => { }; }, [handleError, handleRejection, settingsEventHandler]); + useEffect(() => { + const restoreLastSession = async () => { + const response = await actions.settings.restore(getStoredToken()); + if (response.success) { + history.replace('/app'); + } + + setIsReady(true); + }; + + restoreLastSession(); + }, []); + + if (!isReady) { + return ; + } + return ( - } /> + } /> ); }; diff --git a/render/about/AboutPage.jsx b/render/about/AboutPage.jsx index 9043058b..d1c84850 100644 --- a/render/about/AboutPage.jsx +++ b/render/about/AboutPage.jsx @@ -9,7 +9,7 @@ import LogoIcon from '../../assets/icon.png'; import DragArea from '../components/DragArea'; import Link from '../components/Link'; -import { report } from '../../common/reporter'; +// import { report } from '../../common/reporter'; import License from './License'; const FlexBox = styled.div` @@ -124,7 +124,7 @@ const Paragraph = styled.p` `; class AboutPage extends Component { - onReportButtonClick = () => report() + onReportButtonClick = () => { /* noop */ } // report() render() { return ( diff --git a/render/actions/__tests__/helper.spec.js b/render/actions/__tests__/helper.spec.js index 087284d0..bd1feed1 100644 --- a/render/actions/__tests__/helper.spec.js +++ b/render/actions/__tests__/helper.spec.js @@ -1,7 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import request, { - IssueFilter, notify, login, logout + IssueFilter, notify } from '../helper'; import axios from '../../../common/request'; @@ -23,8 +23,6 @@ describe('Helper module', () => { it('should expose all the necesary items', () => { expect(request).toBeTruthy(); expect(notify).toBeTruthy(); - expect(login).toBeTruthy(); - expect(logout).toBeTruthy(); expect(IssueFilter).toBeTruthy(); }); @@ -138,11 +136,6 @@ describe('Helper module', () => { axiosMock.onGet('/user').replyOnce(() => Promise.resolve([200, response])); - await expect(login({ - redmineEndpoint: 'redmine.test.com', - url: '/user' - })).resolves.toEqual({ data: response }); - expect(axios.getInstance()).toBeTruthy(); expect(axiosMock.history.get.length).toBe(1); expect(axiosMock.history.get[0].url).toBe('/user'); @@ -168,32 +161,11 @@ describe('Helper module', () => { axiosInstanceMock.restore(); axios.reset(); }); - - it('should throw if failed', async () => { - expect(axios.getInstance()).toBe(undefined); - - const error = new Error('Test response error'); - axiosMock.onGet('/user').replyOnce(() => Promise.reject(error)); - - try { - await login({ - redmineEndpoint: 'redmine.test.com', - url: '/user' - }); - } catch (e) { - expect(e).toBeInstanceOf(Error); - expect(e.message).toBe('Error (Test response error)'); - } - - expect(axios.getInstance()).toBe(undefined); - expect(axiosMock.history.get.length).toBe(1); - }); }); describe('Logout action', () => { it('should reset the axios instnace', () => { const spy = jest.spyOn(axios, 'reset'); - logout(); expect(spy).toHaveBeenCalled(); }); }); diff --git a/render/actions/__tests__/user.actions.spec.js b/render/actions/__tests__/user.actions.spec.js deleted file mode 100644 index ad4ab13d..00000000 --- a/render/actions/__tests__/user.actions.spec.js +++ /dev/null @@ -1,144 +0,0 @@ -import MockAdapter from 'axios-mock-adapter'; - -import * as userActions from '../user.actions'; -import { notify } from '../helper'; -import settingsActions from '../settings.actions'; -import axios from '../../../common/request'; - -const redmineEndpoint = 'redmine.test.com'; -const token = 'multipass'; -let axiosInstanceMock; -let axiosMock; - -describe('User actions', () => { - beforeAll(() => { - axiosMock = new MockAdapter(axios.default); - axios.initialize(redmineEndpoint, token); - axiosInstanceMock = new MockAdapter(axios.getInstance()); - }); - - afterAll(() => { - axiosInstanceMock.restore(); - axiosMock.restore(); - axios.reset(); - }); - - afterEach(() => { - axiosInstanceMock.reset(); - axiosMock.reset(); - }); - - it('should expose all the necessary actions', () => { - expect(userActions).toBeTruthy(); - expect(userActions.USER_LOGIN).toBeTruthy(); - expect(userActions.USER_LOGOUT).toBeTruthy(); - expect(userActions.USER_GET_CURRENT).toBeTruthy(); - - expect(userActions.default.checkLogin).toBeTruthy(); - expect(userActions.default.getCurrent).toBeTruthy(); - expect(userActions.default.logout).toBeTruthy(); - }); - - describe('checkLogin action', () => { - it('should make request and return the response with correct actions', async () => { - const response = { - user: {} - }; - const username = 'usernae'; - const password = 'password'; - - const settingsRestoreSpy = jest.spyOn(settingsActions, 'restore'); - const dispatch = jest.fn(); - axiosMock.onGet('/users/current.json').replyOnce(() => Promise.resolve([200, response])); - - await userActions.default.checkLogin({ - username, - password, - redmineEndpoint - })(dispatch); - expect(axiosMock.history.get.length).toBe(1); - expect(axiosMock.history.get[0].url).toBe('/users/current.json'); - expect(axiosMock.history.get[0].headers.Authorization).toBe(`Basic ${btoa(`${username}:${password}`)}`); - expect(dispatch).toHaveBeenCalledTimes(3); - expect(dispatch).toHaveBeenCalledWith(notify.start(userActions.USER_LOGIN)); - expect(dispatch).toHaveBeenCalledWith(notify.ok(userActions.USER_LOGIN, { - user: { - ...response.user, - redmineEndpoint - } - })); - expect(settingsRestoreSpy).toHaveBeenCalled(); - - // resetting, because checkLogin creates a new instance of axios if fullfilled - axiosInstanceMock.restore(); - axios.reset(); - axios.initialize(redmineEndpoint, token); - axiosInstanceMock = new MockAdapter(axios.getInstance()); - settingsRestoreSpy.mockRestore(); - }); - - it('should pass the error further with dispatch', async () => { - const response = new Error('Whoops'); - response.status = 500; - const username = 'username'; - const password = 'password'; - const dispatch = jest.fn(); - axiosMock.onGet('/users/current.json').replyOnce(() => Promise.reject(response)); - await userActions.default.checkLogin({ redmineEndpoint, username, password })(dispatch); - expect(axiosMock.history.get.length).toBe(1); - expect(axiosMock.history.get[0].url).toBe('/users/current.json'); - expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenCalledWith(notify.start(userActions.USER_LOGIN)); - expect(dispatch).toHaveBeenCalledWith( - notify.nok(userActions.USER_LOGIN, new Error(`Error ${response.status} (${response.message})`)) - ); - }); - }); - - describe('getCurrent action', () => { - it('should make request and return the response with correct actions', async () => { - const response = { - user: {} - }; - const dispatch = jest.fn(); - expect(axios.getInstance()); - axiosInstanceMock.onGet('/users/current.json').replyOnce(() => Promise.resolve([200, response])); - await userActions.default.getCurrent()(dispatch); - expect(axiosInstanceMock.history.get.length).toBe(1); - expect(axiosInstanceMock.history.get[0].url).toBe('/users/current.json'); - expect(axiosInstanceMock.history.get[0].headers['X-Redmine-API-Key']).toBe(token); - expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenCalledWith(notify.start(userActions.USER_GET_CURRENT)); - expect(dispatch).toHaveBeenCalledWith(notify.ok(userActions.USER_GET_CURRENT, response)); - }); - - it('should pass the error further with dispatch', async () => { - const response = new Error('Whoops'); - response.status = 500; - const dispatch = jest.fn(); - axiosInstanceMock.onGet('/users/current.json').replyOnce(() => Promise.reject(response)); - await userActions.default.getCurrent()(dispatch); - expect(axiosInstanceMock.history.get.length).toBe(1); - expect(axiosInstanceMock.history.get[0].url).toBe('/users/current.json'); - expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenCalledWith(notify.start(userActions.USER_GET_CURRENT)); - expect(dispatch).toHaveBeenCalledWith( - notify.nok(userActions.USER_GET_CURRENT, new Error(`Error ${response.status} (${response.message})`)) - ); - }); - }); - - describe('logout action', () => { - it('should make request and return the response with correct actions', () => { - const resetSpy = jest.spyOn(axios, 'reset'); - const dispatch = jest.fn(); - const settingsBackupSpy = jest.spyOn(settingsActions, 'backup'); - userActions.default.logout()(dispatch); - expect(dispatch).toHaveBeenCalledWith({ type: userActions.USER_LOGOUT }); - expect(settingsBackupSpy).toHaveBeenCalled(); - expect(resetSpy).toHaveBeenCalled(); - resetSpy.mockRestore(); - settingsBackupSpy.mockRestore(); - }); - }); -}); diff --git a/render/actions/helper.js b/render/actions/helper.js index 47c9cefa..fe32a0b2 100644 --- a/render/actions/helper.js +++ b/render/actions/helper.js @@ -109,26 +109,6 @@ const request = ({ return axios.authorizedRequest(requestConfig); }; -const login = ({ - redmineEndpoint, - url, - headers -}) => axios.request({ - baseURL: redmineEndpoint, - timeout: 20000, - headers: headers || {}, - url, - method: 'GET' -}).then((res) => { - const { api_key } = res.data.user || {}; - if (api_key) { - axios.initialize(redmineEndpoint, api_key); - } - return { data: res.data }; -}); - -const logout = () => axios.reset(); - const notify = { start: (type, info = {}) => ({ type, status: 'START', info }), ok: (type, data, info = {}) => ({ @@ -143,8 +123,6 @@ const notify = { export { IssueFilter, notify, - login, - logout }; export default request; diff --git a/render/actions/index.js b/render/actions/index.js index a912f0b6..e5a7e954 100644 --- a/render/actions/index.js +++ b/render/actions/index.js @@ -1,4 +1,3 @@ -import userActions from './user.actions'; import trackingActions from './tracking.actions'; import issuesActions from './issues.actions'; import issueActions from './issue.actions'; @@ -7,7 +6,6 @@ import timeEntryActions from './timeEntry.actions'; import settingsActions from './settings.actions'; export default { - user: userActions, issues: issuesActions, issue: issueActions, tracking: trackingActions, diff --git a/render/actions/user.actions.js b/render/actions/user.actions.js deleted file mode 100644 index cadc74c1..00000000 --- a/render/actions/user.actions.js +++ /dev/null @@ -1,61 +0,0 @@ -import settingsActions from './settings.actions'; -import request, { login, notify, logout } from './helper'; - -export const USER_LOGIN = 'USER_LOGIN'; -export const USER_LOGOUT = 'USER_LOGOUT'; -export const USER_GET_CURRENT = 'USER_GET_CURRENT'; - -const signout = () => (dispatch) => { - logout(); - dispatch(settingsActions.backup()); - dispatch({ type: USER_LOGOUT }); -}; - -const checkLogin = ({ - useApiKey, apiKey, username, password, redmineEndpoint -}) => (dispatch) => { - if (!redmineEndpoint) throw new Error('Unable to login to an undefined redmine endpoint'); - - dispatch(notify.start(USER_LOGIN)); - - const headers = {}; - if (useApiKey) { - headers['X-Redmine-API-Key'] = apiKey; - } else { - headers.Authorization = `Basic ${btoa(`${username}:${password}`)}`; - } - - return login({ - redmineEndpoint, - url: '/users/current.json', - headers, - }).then(({ data }) => { - Object.assign(data.user, { redmineEndpoint }); - dispatch(notify.ok(USER_LOGIN, data)); - dispatch(settingsActions.restore()); - }).catch((error) => { - // eslint-disable-next-line - console.error('Error when trying to get the info about current user', error); - dispatch(notify.nok(USER_LOGIN, error)); - }); -}; - -const getCurrent = () => (dispatch) => { - dispatch(notify.start(USER_GET_CURRENT)); - - return request({ - url: '/users/current.json', - id: 'getCurrentUserInfo' - }).then(({ data }) => dispatch(notify.ok(USER_GET_CURRENT, data))) - .catch((error) => { - // eslint-disable-next-line - console.error('Error when trying to get the info about current user', error); - dispatch(notify.nok(USER_GET_CURRENT, error)); - }); -}; - -export default { - checkLogin, - getCurrent, - logout: signout -}; diff --git a/render/components/IssueDetailsPage/TimeEntries.jsx b/render/components/IssueDetailsPage/TimeEntries.jsx index c799f3c6..f130b54b 100644 --- a/render/components/IssueDetailsPage/TimeEntries.jsx +++ b/render/components/IssueDetailsPage/TimeEntries.jsx @@ -1,17 +1,16 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; +import { css as emotionCss } from '@emotion/react'; import styled, { css, withTheme } from 'styled-components'; import PlusIcon from 'mdi-react/PlusIcon'; -import CloseIcon from 'mdi-react/CloseIcon'; import TimerIcon from 'mdi-react/TimerIcon'; import InfiniteScroll from '../InfiniteScroll'; -import ProcessIndicator from '../ProcessIndicator'; -import Button, { GhostButton } from '../Button'; +import { ProcessIndicator } from '../ProcessIndicator'; +import Button from '../Button'; import DateComponent from '../Date'; -import Dialog from '../Dialog'; import actions from '../../actions'; const HeaderContainer = styled.div` @@ -125,15 +124,20 @@ const ProcessIndicatorWrapper = styled.li` position: absolute; left: 24%; bottom: 0; - - span { - position: relative; - bottom: 5px; - left: 60px; - } } `; +const styles = { + processIndicatorText: emotionCss` + white-space: nowrap; + padding-left: 20px; + vertical-align: middle; + position: relative; + bottom: 5px; + left: 60px; + ` +}; + class TimeEntries extends Component { constructor(props) { super(props); @@ -206,7 +210,13 @@ class TimeEntries extends Component { isEnd={spentTime.data.length === spentTime.totalCount} // eslint-disable-next-line hasMore={!spentTime.isFetching && !spentTime.error && spentTime.data.length < spentTime.totalCount} - loadIndicator={} + loadIndicator={( + + + Please wait... + + + )} container={this.listRef.current} immediate={true} > diff --git a/render/components/IssueModal.jsx b/render/components/IssueModal.jsx index 1839c004..2b26cdac 100644 --- a/render/components/IssueModal.jsx +++ b/render/components/IssueModal.jsx @@ -2,6 +2,7 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import styled, { withTheme } from 'styled-components'; +import { css } from '@emotion/react'; import ClockIcon from 'mdi-react/ClockIcon'; import RawSlider from 'rc-slider'; @@ -9,7 +10,7 @@ import { Input, Label } from './Input'; import Button from './Button'; import ErrorMessage from './ErrorMessage'; import Modal from './Modal'; -import ProcessIndicator from './ProcessIndicator'; +import { ProcessIndicator } from './ProcessIndicator'; import Tooltip from './Tooltip'; import DatePicker from './DatePicker'; @@ -67,6 +68,14 @@ const DurationIcon = ( ); +const styles = { + processIndicatorText: css` + white-space: nowrap; + padding-left: 20px; + vertical-align: middle; + ` +}; + class IssueModal extends Component { constructor(props) { super(props); @@ -322,7 +331,7 @@ class IssueModal extends Component { > Submit - { issue.isFetching && () } + { issue.isFetching && (Please wait...) } diff --git a/render/components/LoadingOverlay.tsx b/render/components/LoadingOverlay.tsx new file mode 100644 index 00000000..001a61a7 --- /dev/null +++ b/render/components/LoadingOverlay.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { css } from '@emotion/react'; +import { ProcessIndicator } from './ProcessIndicator'; + +const styles = { + wrapper: css` + display: flex; + height: 100%; + width: 100%; + justify-content: center; + `, + text: css` + margin-left: 1rem; + ` +}; + +const LoadingOverlay = () => ( +
+ +

Initializing...

+
+
+); + +export { + LoadingOverlay +}; diff --git a/render/components/Navbar.jsx b/render/components/Navbar.jsx deleted file mode 100644 index c59ec357..00000000 --- a/render/components/Navbar.jsx +++ /dev/null @@ -1,143 +0,0 @@ -import React, { Component } from 'react'; -import styled, { css } from 'styled-components'; -import { connect } from 'react-redux'; -import { Link, NavLink } from 'react-router-dom'; -import PropTypes from 'prop-types'; - -import actions from '../actions'; -import { GhostButton } from './Button'; - -const Navbar = styled.nav` - position: fixed; - top: 0px; - left: 0px; - right: 0px; - z-index: 2; - height: 50px; - background: linear-gradient(to bottom, ${(props) => props.theme.bg} 85%, transparent); - display: flex; - align-items: center; - justify-content: space-between; - padding: 20px 40px 0px 40px; - - ul { - list-style-type: none; - padding: 0; - margin: 0; - display: flex; - align-items: center; - - li { - display: inline; - font-size: 15px; - font-weight: bold; - ${({ theme }) => css` - color: ${theme.normalText}; - transition: color ease ${theme.transitionTime}; - - a { - text-decoration: none; - color: ${theme.normalText}; - transition: color ease ${theme.transitionTime}; - padding-bottom: 5px; - } - - a.active { - color: ${theme.main}; - border-bottom: 2px solid ${theme.main}; - } - - &:hover { - cursor: pointer; - color: ${theme.main}; - - a { - color: ${theme.main}; - } - } - `} - } - } - - ul:first-child { - li { - margin: 0px 20px; - } - - li:first-child { - margin-left: 0; - } - } - - ul:last-child { - li { - margin: 0px 20px; - } - - li:last-child { - margin-right: 0px; - } - } -`; - -class NavigationBar extends Component { - signout = () => { - const { logout } = this.props; - logout(); - } - - render() { - const { user = {} } = this.props; - const { name } = user; - return ( - -
    -
  • - - Summary - -
  • - {/*
  • Issues
  • */} -
-
    -
  • - - {name} - -
  • -
  • - - Sign out - -
  • -
-
- ); - } -} - -NavigationBar.propTypes = { - user: PropTypes.shape({ - id: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.number - ]).isRequired, - name: PropTypes.string.isRequired, - api_key: PropTypes.string.isRequired, - redmineEndpoint: PropTypes.string.isRequired - }).isRequired, - logout: PropTypes.func.isRequired -}; - -const mapStateToProps = (state) => ({ - user: state.user -}); - -const mapDispatchToProps = (dispatch) => ({ - logout: () => dispatch(actions.user.logout()) -}); - -export default connect(mapStateToProps, mapDispatchToProps)(NavigationBar); diff --git a/render/components/Navbar.tsx b/render/components/Navbar.tsx new file mode 100644 index 00000000..6c4b89ed --- /dev/null +++ b/render/components/Navbar.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import styled, { css } from 'styled-components'; +import { Link, NavLink } from 'react-router-dom'; + +import { GhostButton } from './Button'; +import { useOvermindActions, useOvermindState } from '../store'; + +const StyledNavbar = styled.nav` + position: fixed; + top: 0px; + left: 0px; + right: 0px; + z-index: 2; + height: 50px; + background: linear-gradient(to bottom, ${props => props.theme.bg} 85%, transparent); + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 40px 0px 40px; + + ul { + list-style-type: none; + padding: 0; + margin: 0; + display: flex; + align-items: center; + + li { + display: inline; + font-size: 15px; + font-weight: bold; + ${({ theme }) => css` + color: ${theme.normalText}; + transition: color ease ${theme.transitionTime}; + + a { + text-decoration: none; + color: ${theme.normalText}; + transition: color ease ${theme.transitionTime}; + padding-bottom: 5px; + } + + a.active { + color: ${theme.main}; + border-bottom: 2px solid ${theme.main}; + } + + &:hover { + cursor: pointer; + color: ${theme.main}; + + a { + color: ${theme.main}; + } + } + `} + } + } + + ul:first-child { + li { + margin: 0px 20px; + } + + li:first-child { + margin-left: 0; + } + } + + ul:last-child { + li { + margin: 0px 20px; + } + + li:last-child { + margin-right: 0px; + } + } +`; + +const Navbar = () => { + const state = useOvermindState(); + const actions = useOvermindActions(); + + const userName = `${state.users.currentUser?.firstName} ${state.users.currentUser?.lastName}`; + + return ( + +
    +
  • + Summary +
  • + {/*
  • Issues
  • */} +
+
    +
  • + {userName} +
  • +
  • + + Sign out + +
  • +
+
+ ); +}; + +export { + Navbar +}; diff --git a/render/components/Notification.jsx b/render/components/Notification.jsx index 6b5cff63..fabc87f0 100644 --- a/render/components/Notification.jsx +++ b/render/components/Notification.jsx @@ -2,8 +2,6 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import styled from 'styled-components'; -import { report } from '../../common/reporter'; - const NotifyButton = styled.button` position: absolute; width: 92%; @@ -18,7 +16,7 @@ const NotifyButton = styled.button` class Notification extends Component { reportError = () => { const { error } = this.props; - report(error); + // report(error); } render() { diff --git a/render/components/ProcessIndicator.jsx b/render/components/ProcessIndicator.tsx similarity index 51% rename from render/components/ProcessIndicator.jsx rename to render/components/ProcessIndicator.tsx index a2fa386c..a2c40c76 100644 --- a/render/components/ProcessIndicator.jsx +++ b/render/components/ProcessIndicator.tsx @@ -1,6 +1,5 @@ import React from 'react'; import styled, { keyframes } from 'styled-components'; -import PropTypes from 'prop-types'; const flipAnimation = keyframes` 50% { @@ -11,43 +10,31 @@ const flipAnimation = keyframes` } `; -const ProcessIndicator = styled.div` +const StyledProcessIndicator = styled.div` display: flex; align-items: center; - - span { - white-space: nowrap; - padding-left: 20px; - vertical-align: middle; - } `; const Square = styled.div` - width: 2em; - height: 2em; background-color: ${(props) => props.theme.main}; transform: rotate(0); animation: ${flipAnimation} 1s infinite; `; -const ProcessIndicatorComponent = ({ className }) => ( - - - - Please Wait... - - +type ProcessIndicatorProps = { + className?: string; + size?: string; + children: JSX.Element; +} + +const ProcessIndicator = ({ className, size = '2em', children }: ProcessIndicatorProps) => ( + + + {children} + ); -ProcessIndicatorComponent.propTypes = { - className: PropTypes.string -}; - -ProcessIndicatorComponent.defaultProps = { - className: null -}; - -export const OverlayProcessIndicator = styled(ProcessIndicatorComponent)` +const OverlayProcessIndicator = styled(ProcessIndicator)` justify-content: center; position: absolute; top: 45%; @@ -59,4 +46,7 @@ export const OverlayProcessIndicator = styled(ProcessIndicatorComponent)` border-radius: 3px; `; -export default ProcessIndicatorComponent; +export { + ProcessIndicator, + OverlayProcessIndicator +}; diff --git a/render/components/SummaryPage/IssuesTable.jsx b/render/components/SummaryPage/IssuesTable.jsx index 6fabbd9b..7e42bb30 100644 --- a/render/components/SummaryPage/IssuesTable.jsx +++ b/render/components/SummaryPage/IssuesTable.jsx @@ -1,6 +1,7 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import _get from 'lodash/get'; +import { css as emotionCss } from '@emotion/react'; import { connect } from 'react-redux'; import { withRouter } from 'react-router-dom'; import styled, { withTheme, css } from 'styled-components'; @@ -8,7 +9,7 @@ import SortAscendingIcon from 'mdi-react/SortAscendingIcon'; import SortDescendingIcon from 'mdi-react/SortDescendingIcon'; import InfiniteScroll from '../InfiniteScroll'; -import ProcessIndicator, { OverlayProcessIndicator } from '../ProcessIndicator'; +import { ProcessIndicator, OverlayProcessIndicator } from '../ProcessIndicator'; import Date from '../Date'; const Table = styled.table` @@ -89,6 +90,14 @@ const ColorfulSpan = styled.span` } `; +const styles = { + processIndicatorText: emotionCss` + white-space: nowrap; + padding-left: 20px; + vertical-align: middle; + ` +}; + const colorMap = { closed: 'red', high: 'yellow', @@ -157,7 +166,7 @@ class IssuesTable extends Component { const userTasks = issues.data; return ( <> - { (!userTasks.length && issues.isFetching) && () } + { (!userTasks.length && issues.isFetching) && (Please wait...) } @@ -187,7 +196,9 @@ class IssuesTable extends Component { loadIndicator={( )} diff --git a/render/components/TimeEntryModal.jsx b/render/components/TimeEntryModal.jsx index 7bbb394a..156ebec3 100644 --- a/render/components/TimeEntryModal.jsx +++ b/render/components/TimeEntryModal.jsx @@ -5,6 +5,7 @@ import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import Select from 'react-select'; import styled, { withTheme } from 'styled-components'; +import { css } from '@emotion/react'; import ClockIcon from 'mdi-react/ClockIcon'; import { Input, Label } from './Input'; @@ -13,7 +14,7 @@ import MarkdownEditor from './MarkdownEditor'; import ErrorMessage from './ErrorMessage'; import DatePicker from './DatePicker'; import Modal from './Modal'; -import ProcessIndicator from './ProcessIndicator'; +import { ProcessIndicator } from './ProcessIndicator'; import Tooltip from './Tooltip'; import actions from '../actions'; @@ -65,6 +66,14 @@ const DurationIcon = ( ); +const styles = { + processIndicatorText: css` + white-space: nowrap; + padding-left: 20px; + vertical-align: middle; + ` +}; + const selectStyles = { container: (base) => ({ ...base }) }; @@ -364,7 +373,7 @@ class TimeEntryModal extends Component { > Submit - { time.isFetching && () } + { time.isFetching && (Please wait...) } ) : ( @@ -377,7 +386,7 @@ class TimeEntryModal extends Component { > Submit - { time.isFetching && () } + { time.isFetching && (Please wait...) } )} diff --git a/render/components/__tests__/Navbar.spec.jsx b/render/components/__tests__/Navbar.spec.jsx index 07280ef9..c8e7529d 100644 --- a/render/components/__tests__/Navbar.spec.jsx +++ b/render/components/__tests__/Navbar.spec.jsx @@ -6,8 +6,7 @@ import thunk from 'redux-thunk'; import { render, cleanup, fireEvent } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; -import { USER_LOGOUT } from '../../actions/user.actions'; -import Navbar from '../Navbar'; +import { Navbar } from '../Navbar'; const mockStore = configureStore([thunk]); @@ -58,8 +57,5 @@ describe('Navbar component', () => { expect(signoutBtn).toBeDefined(); fireEvent.click(signoutBtn); expect(store.getActions().length).toBe(2); - expect(store.getActions().pop()).toEqual({ - type: USER_LOGOUT - }); }); }); diff --git a/render/helpers/utils.ts b/render/helpers/utils.ts new file mode 100644 index 00000000..747d68df --- /dev/null +++ b/render/helpers/utils.ts @@ -0,0 +1,5 @@ +const getStoredToken = () => localStorage.getItem('token'); + +export { + getStoredToken +}; diff --git a/render/reducers/__tests__/project.reducer.spec.js b/render/reducers/__tests__/project.reducer.spec.js index 00abd067..479d4d2e 100644 --- a/render/reducers/__tests__/project.reducer.spec.js +++ b/render/reducers/__tests__/project.reducer.spec.js @@ -3,7 +3,6 @@ import _cloneDeep from 'lodash/cloneDeep'; import reducer, { initialState } from '../project.reducer'; import { notify } from '../../actions/helper'; import * as actions from '../../actions/project.actions'; -import storage from '../../../common/storage'; describe('Project reducer', () => { it('should return the initial state if an unknown action comes in', () => { @@ -29,7 +28,6 @@ describe('Project reducer', () => { }); it('status OK', () => { - const storageSpy = jest.spyOn(storage, 'set'); const error = new Error('Whoops'); const data = [ { @@ -128,8 +126,6 @@ describe('Project reducer', () => { notify.ok(actions.PROJECT_GET_ALL, data) ) ).toEqual(expectedNextState); - expect(storageSpy).toHaveBeenCalledWith('projects', expectedNextState); - storageSpy.mockRestore(); }); it('status NOK', () => { diff --git a/render/reducers/__tests__/settings.reducer.spec.js b/render/reducers/__tests__/settings.reducer.spec.js index fa1c2573..338bf788 100644 --- a/render/reducers/__tests__/settings.reducer.spec.js +++ b/render/reducers/__tests__/settings.reducer.spec.js @@ -1,24 +1,9 @@ import _cloneDeep from 'lodash/cloneDeep'; import reducer, { initialState } from '../settings.reducer'; -import storage from '../../../common/storage'; import * as actions from '../../actions/settings.actions'; -let storageSpy; - describe('Settings reducer', () => { - beforeAll(() => { - storageSpy = jest.spyOn(storage, 'set'); - }); - - afterEach(() => { - storageSpy.mockReset(); - }); - - afterAll(() => { - storageSpy.mockRestore(); - }); - it('should return the initial state by default', () => { expect(reducer(undefined, { type: 'NONE' })).toEqual(initialState); }); @@ -43,10 +28,6 @@ describe('Settings reducer', () => { } ) ).toEqual(expectedNextState); - expect(storageSpy).toHaveBeenCalledWith( - `settings.${data.redmineEndpoint}.${data.userId}`, - expectedNextState - ); }); it('should handle SETTINGS_USE_COLORS action', () => { @@ -69,10 +50,6 @@ describe('Settings reducer', () => { } ) ).toEqual(expectedNextState); - expect(storageSpy).toHaveBeenCalledWith( - `settings.${data.redmineEndpoint}.${data.userId}`, - expectedNextState - ); }); it('should handle SETTINGS_ISSUE_HEADERS action', () => { @@ -101,10 +78,6 @@ describe('Settings reducer', () => { } ) ).toEqual(expectedNextState); - expect(storageSpy).toHaveBeenCalledWith( - `settings.${data.redmineEndpoint}.${data.userId}`, - expectedNextState - ); }); it('should handle SETTINGS_BACKUP action', () => { @@ -121,7 +94,6 @@ describe('Settings reducer', () => { } ) ).toEqual(initialState); - expect(storageSpy).toHaveBeenCalledWith(`settings.${data.redmineEndpoint}.${data.userId}`, initialState); }); it('should handle SETTINGS_RESTORE action', () => { @@ -129,7 +101,7 @@ describe('Settings reducer', () => { redmineEndpoint: 'https://redmine.redmine', userId: 1 }; - const storageGetSpy = jest.spyOn(storage, 'get').mockReturnValue(_cloneDeep(initialState)); + expect( reducer( _cloneDeep(initialState), @@ -139,6 +111,5 @@ describe('Settings reducer', () => { } ) ).toEqual(initialState); - expect(storageGetSpy).toHaveBeenCalledWith(`settings.${data.redmineEndpoint}.${data.userId}`, initialState); }); }); diff --git a/render/reducers/__tests__/tracking.reducer.spec.js b/render/reducers/__tests__/tracking.reducer.spec.js index b7ea2aca..3750cdda 100644 --- a/render/reducers/__tests__/tracking.reducer.spec.js +++ b/render/reducers/__tests__/tracking.reducer.spec.js @@ -1,24 +1,9 @@ import _cloneDeep from 'lodash/cloneDeep'; -import storage from '../../../common/storage'; import reducer, { initialState } from '../tracking.reducer'; import actions from '../../actions/tracking.actions'; -let storageSpy; - describe('Tracking Reducer', () => { - beforeAll(() => { - storageSpy = jest.spyOn(storage, 'set'); - }); - - afterEach(() => { - storageSpy.mockReset(); - }); - - afterAll(() => { - storageSpy.mockRestore(); - }); - it('should return the initial state if an unknown action comes in', () => { expect( reducer( @@ -43,7 +28,6 @@ describe('Tracking Reducer', () => { actions.trackingStart({ id: 1 }) ) ).toEqual(expectedNextState); - expect(storageSpy).toHaveBeenCalledWith('time_tracking', expectedNextState); }); it('TRACKING_STOP action', () => { @@ -61,7 +45,6 @@ describe('Tracking Reducer', () => { actions.trackingStop(1000) ) ).toEqual(expectedNextState); - expect(storageSpy).toHaveBeenCalledWith('time_tracking', expectedNextState); }); it('TRACKING_PAUSE action', () => { @@ -82,7 +65,6 @@ describe('Tracking Reducer', () => { actions.trackingPause(1000) ) ).toEqual(expectedNextState); - expect(storageSpy).toHaveBeenCalledWith('time_tracking', expectedNextState); }); it('TRACKING_CONTINUE action', () => { @@ -104,17 +86,14 @@ describe('Tracking Reducer', () => { actions.trackingContinue() ) ).toEqual(expectedNextState); - expect(storageSpy).toHaveBeenCalledWith('time_tracking', expectedNextState); }); it('TRACKING_RESET action', () => { - const storageDeleteSpy = jest.spyOn(storage, 'delete'); expect( reducer( _cloneDeep(initialState), actions.trackingReset() ) ).toEqual(initialState); - expect(storageDeleteSpy).toHaveBeenCalledWith('time_tracking'); }); }); diff --git a/render/reducers/__tests__/user.reducer.spec.js b/render/reducers/__tests__/user.reducer.spec.js deleted file mode 100644 index 780731d0..00000000 --- a/render/reducers/__tests__/user.reducer.spec.js +++ /dev/null @@ -1,110 +0,0 @@ -import reducer from '../user.reducer'; -import { USER_LOGIN, USER_LOGOUT } from '../../actions/user.actions'; -import { notify } from '../../actions/helper'; - -import storage from '../../../common/storage'; - -jest.mock('electron-store'); - -describe('User reducer', () => { - const initialState = { - isFetching: false, - loginError: undefined, - id: undefined, - firstname: undefined, - lastname: undefined, - redmineEndpoint: undefined, - api_key: undefined - }; - - it('should return the initial state by default', () => { - expect(reducer(undefined, { type: 'NONE' })).toEqual(initialState); - }); - - describe('USER_LOGIN action', () => { - afterEach(storage.clear); - - it('should set isFetching to true during status START', () => { - expect(reducer(initialState, notify.start(USER_LOGIN))).toEqual({ - ...initialState, - isFetching: true - }); - }); - - it('should get user data and put it in storage on OK', () => { - const storageSetSpy = jest.spyOn(storage, 'set'); - const data = { - user: { - id: 1, - firstname: 'firstname', - lastname: 'lastname', - redmineEndpoint: 'https://redmine.domain', - api_key: '123abc' - } - }; - expect(reducer(initialState, notify.ok(USER_LOGIN, data))).toEqual({ - ...initialState, - id: data.user.id, - name: `${data.user.firstname} ${data.user.lastname}`, - redmineEndpoint: data.user.redmineEndpoint, - api_key: data.user.api_key - }); - expect(storageSetSpy).toHaveBeenCalledWith('user', { - id: data.user.id, - name: `${data.user.firstname} ${data.user.lastname}`, - redmineEndpoint: data.user.redmineEndpoint, - api_key: data.user.api_key - }); - - storageSetSpy.mockRestore(); - }); - - it('should set error on NOK', () => { - const error = new Error('ERROR'); - expect(reducer(initialState, notify.nok(USER_LOGIN, error))).toEqual({ - ...initialState, - loginError: error - }); - }); - }); - - describe('USER_LOGOUT action', () => { - it('should wipe the storage leaving only settings', () => { - const storageGetSpy = jest.spyOn(storage, 'get').mockImplementation(() => ({ - cors: true, - theme: 'dark' - })); - const storageSetSpy = jest.spyOn(storage, 'set'); - const storageClearSpy = jest.spyOn(storage, 'clear'); - - const defaultState = { - isFetching: false, - loginError: undefined, - id: 1, - firstname: 'firstname', - lastname: 'lastname', - redmineEndpoint: 'https://redmine.domain', - api_key: '123abc' - }; - expect( - reducer( - defaultState, - { - type: USER_LOGOUT - } - ) - ); - - expect(storageGetSpy).toHaveBeenCalledWith('settings'); - expect(storageClearSpy).toHaveBeenCalled(); - expect(storageSetSpy).toHaveBeenCalledWith('settings', { - cors: true, - theme: 'dark' - }); - - storageGetSpy.mockRestore(); - storageClearSpy.mockRestore(); - storageSetSpy.mockRestore(); - }); - }); -}); diff --git a/render/reducers/index.js b/render/reducers/index.js index deff2168..cb270ce1 100644 --- a/render/reducers/index.js +++ b/render/reducers/index.js @@ -1,5 +1,4 @@ import { combineReducers } from 'redux'; -import usersReducer from './user.reducer'; import settingsReducer from './settings.reducer'; import allIssuesReducer from './issues.reducer'; import issueReducer from './issue.reducer'; @@ -8,10 +7,7 @@ import trackingReducer from './tracking.reducer'; import projectReducer from './project.reducer'; import timeEntryReducer from './timeEntry.reducer'; -import { USER_LOGOUT } from '../actions/user.actions'; - const appReducer = combineReducers({ - user: usersReducer, settings: settingsReducer, issues: combineReducers({ all: allIssuesReducer, @@ -25,9 +21,6 @@ const appReducer = combineReducers({ export default (state, action) => { switch (action.type) { - case USER_LOGOUT: { - return appReducer(undefined, action); - } default: return appReducer(state, action); } diff --git a/render/reducers/project.reducer.js b/render/reducers/project.reducer.js index 9b5ae83b..799ffca8 100644 --- a/render/reducers/project.reducer.js +++ b/render/reducers/project.reducer.js @@ -1,6 +1,5 @@ import _ from 'lodash'; -// import storage from '../../common/storage'; import { PROJECT_GET_ALL } from '../actions/project.actions'; export const initialState = { diff --git a/render/reducers/tracking.reducer.js b/render/reducers/tracking.reducer.js index 079a922b..711040eb 100644 --- a/render/reducers/tracking.reducer.js +++ b/render/reducers/tracking.reducer.js @@ -6,7 +6,6 @@ import { TRACKING_SAVE, TRACKING_RESET } from '../actions/tracking.actions'; -// import storage from '../../common/storage'; export const initialState = { issue: {}, diff --git a/render/reducers/user.reducer.js b/render/reducers/user.reducer.js deleted file mode 100644 index a167e687..00000000 --- a/render/reducers/user.reducer.js +++ /dev/null @@ -1,54 +0,0 @@ -import _ from 'lodash'; -import { USER_LOGIN, USER_LOGOUT } from '../actions/user.actions'; -// import storage from '../../common/storage'; - -export const initialState = { - isFetching: false, - loginError: undefined, - id: undefined, - name: undefined, - redmineEndpoint: undefined, - api_key: undefined -}; - -const handleUserLogin = (state, action) => { - switch (action.status) { - case 'START': { - return { ...initialState, isFetching: true }; - } - case 'OK': { - const userData = _.get(action.data, 'user', {}); - const { firstname, lastname } = userData; - const payload = _.pick(userData, 'id', 'redmineEndpoint', 'api_key'); - payload.name = `${firstname} ${lastname}`; - // storage.user.set(payload); - return { - ...state, ...payload, isFetching: false, loginError: undefined - }; - } - case 'NOK': { - return { ...state, loginError: action.data, isFetching: false }; - } - default: - return state; - } -}; - -export default (state = initialState, action) => { - switch (action.type) { - case USER_LOGIN: { - return handleUserLogin(state, action); - } - case USER_LOGOUT: { - // we keep settings cause they are general app settings - // const settings = storage.settings.get('settings'); - // storage.clear(); - // if (settings) { - // storage.set('settings', settings); - // } - return { ...initialState }; - } - default: - return state; - } -}; diff --git a/render/reduxStore.ts b/render/reduxStore.ts index 3335509e..c3e18e20 100644 --- a/render/reduxStore.ts +++ b/render/reduxStore.ts @@ -2,12 +2,9 @@ import { createStore, applyMiddleware } from 'redux'; import thunk from 'redux-thunk'; // import _get from 'lodash/get'; -// import storage from '../common/storage'; import reducers from './reducers/index'; import notificationMiddleware from './middlewares/notification.middleware'; -// const initialState = storage.store; - // const user = _get(initialState, 'user', {}); // const { id, redmineEndpoint } = user; // const userSettings = _get(initialState, `settings.${redmineEndpoint}.${id}`); diff --git a/render/store/effects/index.ts b/render/store/effects/index.ts index 07be1e6c..49edb5bd 100644 --- a/render/store/effects/index.ts +++ b/render/store/effects/index.ts @@ -1,7 +1,5 @@ -import * as request from './request'; -import * as storage from './storage'; +import * as mainProcess from './mainProcess'; export { - request, - storage + mainProcess }; diff --git a/render/store/effects/mainProcess.ts b/render/store/effects/mainProcess.ts new file mode 100644 index 00000000..d101e469 --- /dev/null +++ b/render/store/effects/mainProcess.ts @@ -0,0 +1,37 @@ +import { ipcRenderer, remote } from 'electron'; +import { Response } from '../../../types'; + +const crypto = remote.require('crypto'); + +type MainProcessEventData = { + payload: Record; + action: string; +} + +type MainProcessEventTags = { + reqEvent: string; + resEvent: string; +} + +const query = ({ reqEvent, resEvent }: MainProcessEventTags, payload: MainProcessEventData): Promise => { + const id = crypto.randomBytes(10).toString('hex'); + + return new Promise((resolve) => { + ipcRenderer.send(reqEvent, JSON.stringify({ ...payload, id })); + + ipcRenderer.once(`${resEvent}:${id}`, (event, response) => { + console.log(resEvent, response); + resolve(response); + }); + }); +}; + +const request = (payload: MainProcessEventData) => query({ reqEvent: 'request', resEvent: 'response' }, payload); +const system = (payload: MainProcessEventData) => query({ reqEvent: 'system-request', resEvent: 'system-response' }, payload); +const session = (payload: MainProcessEventData) => query({ reqEvent: 'session-request', resEvent: 'session-response' }, payload); + +export { + request, + system, + session +}; diff --git a/render/store/effects/request.ts b/render/store/effects/request.ts deleted file mode 100644 index bcae933d..00000000 --- a/render/store/effects/request.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { ipcRenderer, remote } from 'electron'; -import { Response } from '../../../types'; - -const crypto = remote.require('crypto'); - -type Config = { - endpoint: string; - token?: string; -} - -type QueryConfig = { - payload: Record; - config?: Config; -} - -const query = (config: QueryConfig): Promise => { - const id = crypto.randomBytes(10).toString('hex'); - - return new Promise((resolve) => { - ipcRenderer.send('request', JSON.stringify({ ...config, id })); - - ipcRenderer.once(`response:${id}`, (event, response) => { - console.log('response:', response); - resolve(response); - }); - }); -}; - -export { - query, -}; - -export type { - QueryConfig -}; diff --git a/render/store/effects/storage.ts b/render/store/effects/storage.ts deleted file mode 100644 index 343ae509..00000000 --- a/render/store/effects/storage.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { ipcRenderer, remote } from 'electron'; -import type { Context } from 'overmind'; -import { StorageAction } from '../../../types'; - -const crypto = remote.require('crypto'); - -const save = (data: Context['state']['settings']) => { - const id = crypto.randomBytes(10).toString('hex'); - - return new Promise((resolve) => { - ipcRenderer.send('storage', JSON.stringify({ action: StorageAction.SAVE, payload: data, id })); - - ipcRenderer.once(`storage:${id}`, (event, response) => { - console.log('storage:save', response); - resolve(response); - }); - }); -}; - -type ReadArgs = { - userId: string; - endpoint: string; -}; - -const read = (payload: ReadArgs): Promise => { - const id = crypto.randomBytes(10).toString('hex'); - - return new Promise((resolve) => { - ipcRenderer.send('storage', JSON.stringify({ action: StorageAction.READ, payload, id })); - - ipcRenderer.once(`storage:${id}`, (event, response) => { - console.log('storage:read', response); - resolve(response); - }); - }); -}; - -export { - save, - read -}; diff --git a/render/store/index.ts b/render/store/index.ts index 38c6ea64..3f93a886 100644 --- a/render/store/index.ts +++ b/render/store/index.ts @@ -7,15 +7,14 @@ import { merge, namespaced } from 'overmind/config'; import * as settings from './settings'; import * as users from './users'; -import { request, storage } from './effects'; +import { mainProcess } from './effects'; const overmindStoreConfig = merge( { state: {}, actions: {}, effects: { - request, - storage + mainProcess, } }, namespaced({ diff --git a/render/store/settings/actions.ts b/render/store/settings/actions.ts index 6e233ac6..f8f8c340 100644 --- a/render/store/settings/actions.ts +++ b/render/store/settings/actions.ts @@ -1,22 +1,104 @@ import type { IAction, Context } from 'overmind'; -import type { State } from './state'; +import { defaultSettingsState, SettingsState } from './state'; -const update: IAction = ({ state, effects }: Context, settings) => { +import { Response, SessionAction, User } from '../../../types'; +import { getStoredToken } from '../../helpers/utils'; + +const update: IAction> = ({ state, effects }: Context, settings) => { state.settings = { ...settings }; - effects.storage.save(state.settings); + + const { endpoint, ...appSettings } = settings; + const currentUser = state.users.currentUser as User; + + return effects.mainProcess.session({ + action: SessionAction.SAVE, + payload: { + user: { + id: currentUser.id, + firstName: currentUser.firstName, + lastName: currentUser.lastName, + createdOn: currentUser.createdOn + }, + endpoint, + token: getStoredToken(), + settings: { + ...appSettings + } + } + }); }; -const restore: IAction> = async ({ state, effects }: Context) => { - const settings = await effects.storage.read({ - userId: state.users.currentUser?.id as string, - endpoint: state.settings.endpoint as string +const restore: IAction> = async ({ state, effects }: Context, token) => { + const response = await effects.mainProcess.session({ + action: SessionAction.READ, + payload: { + token + } + }); + + if (response.success && response.payload) { + const { endpoint, settings } = response.payload; + const currentUser = response.payload.user as User; + + // replacing the active session + const saveResponse = await effects.mainProcess.session({ + action: SessionAction.SAVE, + payload: { + user: { + id: currentUser.id, + firstName: currentUser.firstName, + lastName: currentUser.lastName, + createdOn: currentUser.createdOn + }, + endpoint, + token: getStoredToken(), + settings + } + }); + + if (!saveResponse.success) { + return { success: false }; + } + + state.users.currentUser = { ...currentUser }; + + state.settings = { + endpoint: response.payload.endpoint, + ...settings + }; + } + + return { success: response.success }; +}; + +const reset: IAction> = async ({ effects, state }: Context) => { + state.settings = { ...defaultSettingsState }; + + const { endpoint, ...appSettings } = defaultSettingsState; + const currentUser = state.users.currentUser as User; + + return effects.mainProcess.session({ + action: SessionAction.SAVE, + payload: { + user: { + id: currentUser?.id, + firstName: currentUser?.firstName, + lastName: currentUser?.lastName, + createdOn: currentUser?.createdOn + }, + endpoint, + token: getStoredToken(), + settings: { + ...appSettings + } + } }); - state.settings = { ...settings }; }; export { update, - restore + restore, + reset }; diff --git a/render/store/settings/state.ts b/render/store/settings/state.ts index 6fcf5289..d98fd8c0 100644 --- a/render/store/settings/state.ts +++ b/render/store/settings/state.ts @@ -1,13 +1,12 @@ import type { IssueHeader } from '../../../types'; -type State = { +type SettingsState = { showClosedIssues: boolean; issueHeaders: IssueHeader[]; - apiKey?: string; endpoint?: string; } -const state: State = { +const defaultSettingsState = { showClosedIssues: false, issueHeaders: [ { label: 'Id', isFixed: true, value: 'id' }, @@ -19,14 +18,18 @@ const state: State = { { label: 'Estimation', isFixed: false, value: 'estimated_hours' }, { label: 'Due Date', isFixed: false, value: 'due_date' } ], - apiKey: undefined, endpoint: undefined }; +const state: SettingsState = { + ...defaultSettingsState +}; + export { state, + defaultSettingsState }; export type { - State + SettingsState }; diff --git a/render/store/users/actions.ts b/render/store/users/actions.ts index 5c0425d6..5e58eebe 100644 --- a/render/store/users/actions.ts +++ b/render/store/users/actions.ts @@ -1,5 +1,7 @@ import type { IAction, Context } from 'overmind'; import { Response } from '../../../types'; +import { getStoredToken } from '../../helpers/utils'; +import { defaultSettingsState } from '../settings/state'; type LoginActionProps = { useApiKey: boolean; @@ -9,39 +11,54 @@ type LoginActionProps = { redmineEndpoint?: string; }; -const login: IAction> = async ({ effects, state }: Context, { +const login: IAction> = async ({ actions, effects, state }: Context, { apiKey, username, password, redmineEndpoint }) => { if (!redmineEndpoint) { throw new Error('Unable to send a request to an undefined redmine endpoint'); } - const headers: Record = apiKey ? { - 'X-Redmine-API-Key': apiKey + const token = apiKey || getStoredToken() || undefined; + + const headers: Record = token ? { + 'X-Redmine-API-Key': token } : { Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}` }; - const response = await effects.request.query({ + const loginResponse = await effects.mainProcess.system({ + action: 'login', payload: { headers, - route: 'users/current.json', - }, - config: { endpoint: redmineEndpoint, - token: apiKey + token } }); - if (response.success) { - state.users.currentUser = response.data; + if (loginResponse.success) { + state.users.currentUser = loginResponse.payload; + localStorage.setItem('token', loginResponse.payload.token); + + const restoreResponse = await actions.settings.restore(loginResponse.payload.token); + + if (!restoreResponse.success) { + await actions.settings.update({ ...defaultSettingsState, endpoint: redmineEndpoint }); + } } - return response; + return loginResponse; }; -const logout = () => { - /* noop */ +const logout: IAction> = async ({ state, effects }: Context) => { + const response = await effects.mainProcess.system({ + action: 'logout', + payload: {} + }); + + if (response.success) { + localStorage.removeItem('token'); + state.users.currentUser = undefined; + } }; export { diff --git a/render/store/users/state.ts b/render/store/users/state.ts index 04730d87..33837090 100644 --- a/render/store/users/state.ts +++ b/render/store/users/state.ts @@ -1,14 +1,10 @@ -type State = { - currentUser?: { - id: string; - firstName: string; - lastName: string; - createdOn: Date; - lastLoggedOn: Date; - } +import type { User } from '../../../types'; + +type UserState = { + currentUser?: User } -const state: State = { +const state: UserState = { currentUser: undefined }; @@ -17,5 +13,5 @@ export { }; export type { - State + UserState }; diff --git a/render/views/AppView.tsx b/render/views/AppView.tsx index 327a7eb7..97f6a365 100644 --- a/render/views/AppView.tsx +++ b/render/views/AppView.tsx @@ -9,13 +9,12 @@ import moment from 'moment'; import { ipcRenderer } from 'electron'; import actions from '../actions'; -import Navbar from '../components/Navbar'; +import { Navbar } from '../components/Navbar'; import Timer from '../components/Timer'; import SummaryPage from './AppViewPages/SummaryPage'; import IssueDetailsPage from './AppViewPages/IssueDetailsPage'; import TimeEntryModal from '../components/TimeEntryModal'; import DragArea from '../components/DragArea'; -// import storage from '../../common/storage'; import { hoursToDuration } from '../datetime'; import { useOvermindState } from '../store'; @@ -77,7 +76,6 @@ const AppView = ({ name: `${state.users.currentUser?.firstName} ${state.users.currentUser?.lastName}` } }); - // storage.delete('time_tracking'); }; const closeTimeEntryModal = () => { @@ -92,7 +90,7 @@ const AppView = ({ {!state.users.currentUser && ()} - } /> + } /> } /> ({ - api_key: state.user.api_key, projects: state.projects.data, idleBehavior: state.settings.idleBehavior, discardIdleTime: state.settings.discardIdleTime, @@ -143,7 +138,6 @@ const mapStateToProps = (state: any) => ({ }); const mapDispatchToProps = (dispatch: any) => ({ - logout: () => dispatch(actions.user.logout()), getProjectData: () => dispatch(actions.projects.getAll()), resetTimer: () => dispatch(actions.tracking.trackingReset()) }); diff --git a/render/views/AppViewPages/IssueDetailsPage.tsx b/render/views/AppViewPages/IssueDetailsPage.tsx index 2a35a184..acf4e0a8 100644 --- a/render/views/AppViewPages/IssueDetailsPage.tsx +++ b/render/views/AppViewPages/IssueDetailsPage.tsx @@ -421,7 +421,11 @@ const IssueDetailsPage = ({ )} ) - : ; + : ( + + Please wait... + + ); }; IssueDetailsPage.propTypes = { diff --git a/render/views/LoginView.tsx b/render/views/LoginView.tsx index 3724c330..70128128 100644 --- a/render/views/LoginView.tsx +++ b/render/views/LoginView.tsx @@ -107,7 +107,7 @@ const LoginView = () => { if (error) { setLoginError(error); } else if (success) { - history.push('/app/summary'); + history.push('/app'); } }); }; diff --git a/render/views/__tests__/AppView.spec.jsx b/render/views/__tests__/AppView.spec.jsx index 30a9ec6a..31db0487 100644 --- a/render/views/__tests__/AppView.spec.jsx +++ b/render/views/__tests__/AppView.spec.jsx @@ -10,13 +10,11 @@ import '@testing-library/jest-dom/extend-expect'; import configureStore from 'redux-mock-store'; import { ThemeProvider } from 'styled-components'; -import { USER_LOGOUT } from '../../actions/user.actions'; import { PROJECT_GET_ALL } from '../../actions/project.actions'; import { TRACKING_RESET } from '../../actions/tracking.actions'; import * as axios from '../../../common/request'; import theme from '../../theme'; import AppView from '../AppView'; -import storage from '../../../common/storage'; jest.mock('electron-store'); @@ -209,7 +207,6 @@ describe('AppView', () => { expect(historyMock.push).toHaveBeenCalledWith('/'); const actions = store.getActions(); expect(actions.length).toBeGreaterThan(0); - expect(actions.pop()).toEqual({ type: USER_LOGOUT }); }); it('should open the modal with a new timeEntry if timer stops and wipe the storage', (done) => { @@ -279,8 +276,6 @@ describe('AppView', () => { } }; - const storageSpy = jest.spyOn(storage, 'delete'); - render( @@ -299,13 +294,11 @@ describe('AppView', () => { ); fireEvent.click(document.querySelector('#stop-timer')); - expect(storageSpy).toHaveBeenCalledWith('time_tracking'); fireEvent.click(document.querySelector('#btn-add')); setTimeout(() => { expect( store.getActions().find((action) => action.type === TRACKING_RESET) ).toBeDefined(); - storageSpy.mockRestore(); done(); }, 1); }); diff --git a/render/views/__tests__/LoginView.spec.jsx b/render/views/__tests__/LoginView.spec.jsx index d97dbcf7..ee876510 100644 --- a/render/views/__tests__/LoginView.spec.jsx +++ b/render/views/__tests__/LoginView.spec.jsx @@ -11,9 +11,7 @@ import { ThemeProvider } from 'styled-components'; import MockAdapter from 'axios-mock-adapter'; import actions from '../../actions'; -import { USER_LOGIN } from '../../actions/user.actions'; import { notify } from '../../actions/helper'; -import storage from '../../../common/storage'; import axios from '../../../common/request'; import theme from '../../theme'; @@ -31,7 +29,6 @@ describe('Login view', () => { afterEach(() => { cleanup(); axiosMock.reset(); - storage.clear(); }); afterAll(() => { @@ -75,7 +72,6 @@ describe('Login view', () => { it('should not make a redmine api request if the form has errors', (done) => { const store = mockStore({ user: {} }); - const storageSetSpy = jest.spyOn(storage, 'set'); axiosMock.onGet('/users/current.json').reply(() => Promise.resolve([200])); const { getAllByText } = render( @@ -93,9 +89,7 @@ describe('Login view', () => { fireEvent.submit(submitButton); setTimeout(() => { expect(getAllByText(/is not allowed to be empty$/).length).toBeGreaterThan(0); - expect(storageSetSpy).not.toHaveBeenCalled(); expect(store.getActions()).toHaveLength(0); - storageSetSpy.mockRestore(); done(); }, 100); }); @@ -168,13 +162,6 @@ describe('Login view', () => { .toBe(`Basic ${btoa(`${returnedValues.username}:${returnedValues.password}`)}`); expect(store.getActions()).toHaveLength(3); - expect(store.getActions()[0]).toEqual(notify.start(USER_LOGIN)); - expect(store.getActions()[1]).toEqual(notify.ok(USER_LOGIN, { - user: { - ...userData, - redmineEndpoint: returnedValues.redmineEndpoint - } - })); store.clearActions(); done(); @@ -244,13 +231,6 @@ describe('Login view', () => { expect(axiosMock.history.get[0].url).toBe('/users/current.json'); expect(axiosMock.history.get[0].headers['X-Redmine-API-Key']).toBe(returnedValues.apiKey); expect(store.getActions()).toHaveLength(3); - expect(store.getActions()[0]).toEqual(notify.start(USER_LOGIN)); - expect(store.getActions()[1]).toEqual(notify.ok(USER_LOGIN, { - user: { - ...userData, - redmineEndpoint: returnedValues.redmineEndpoint - } - })); store.clearActions(); done(); @@ -313,8 +293,6 @@ describe('Login view', () => { expect(queryAllByText('Something went wrong').length).toBeGreaterThan(0); const reduxActions = store.getActions(); expect(reduxActions).toHaveLength(2); - expect(reduxActions[0]).toEqual(notify.start(USER_LOGIN)); - expect(reduxActions[1]).toEqual(notify.nok(USER_LOGIN, new Error(`Error (${expectedError.message})`))); store.clearActions(); loginActionSpy.mockRestore(); diff --git a/test/common/config.spec.js b/test/common/config.spec.js index b04659a4..5f3f2244 100644 --- a/test/common/config.spec.js +++ b/test/common/config.spec.js @@ -29,10 +29,6 @@ describe('Config', () => { }); it('overwrite default env data with user settings', () => { - const storage = require('../../common/storage.js'); // eslint-disable-line - const storageHasSpy = jest.spyOn(storage, 'has'); - const storageGetSpy = jest.spyOn(storage, 'get'); - const config = require('../../common/config.js'); // eslint-disable-line expect(config).toEqual({ @@ -41,10 +37,5 @@ describe('Config', () => { NODE_ENV: 'test', platform: process.platform }); - expect(storageHasSpy).toHaveBeenCalledWith('settings'); - expect(storageGetSpy).toHaveBeenCalledWith('settings', {}); - - storageHasSpy.mockRestore(); - storageGetSpy.mockRestore(); }); }); diff --git a/tsconfig.json b/tsconfig.json index 48fb43ac..6f0da7d6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,7 @@ // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ - // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ + "jsxImportSource": "@emotion/react", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ diff --git a/types/index.ts b/types/index.ts index ee8995c1..2d2a6a04 100644 --- a/types/index.ts +++ b/types/index.ts @@ -14,24 +14,33 @@ type CreateOvermindConfigParams = { endpoint: string; } -type Response = { - data: any; +type Response = { + payload: T; success: boolean; error?: Error; } // eslint-disable-next-line no-shadow -enum StorageAction { +enum SessionAction { READ = 'READ', - SAVE = 'SAVE' + SAVE = 'SAVE', + RESET = 'RESET' +} + +type User = { + id: string; + firstName: string; + lastName: string; + createdOn: string; } export { - StorageAction + SessionAction }; export type { IssueHeader, CreateOvermindConfigParams, - Response + Response, + User }; diff --git a/yarn.lock b/yarn.lock index 4ec4f1b1..12970102 100644 --- a/yarn.lock +++ b/yarn.lock @@ -941,6 +941,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.13.10": + version "7.16.0" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.0.tgz#e27b977f2e2088ba24748bf99b5e1dece64e4f0b" + integrity sha512-Nht8L0O8YCktmsDV6FqFue7vQLRx3Hb0B37lS5y0jDRqRxlBG4wIJHnf9/bgSE2UyipKFA01YtS+npRdTWBUyw== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/template@^7.15.4", "@babel/template@^7.3.3": version "7.15.4" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.15.4.tgz#51898d35dcf3faa670c4ee6afcfd517ee139f194" @@ -1027,11 +1034,32 @@ find-root "^1.1.0" source-map "^0.7.2" +"@emotion/cache@^11.5.0": + version "11.5.0" + resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-11.5.0.tgz#a5eb78cbef8163939ee345e3ddf0af217b845e62" + integrity sha512-mAZ5QRpLriBtaj/k2qyrXwck6yeoz1V5lMt/jfj6igWU35yYlNKs2LziXVgvH81gnJZ+9QQNGelSsnuoAy6uIw== + dependencies: + "@emotion/memoize" "^0.7.4" + "@emotion/sheet" "^1.0.3" + "@emotion/utils" "^1.0.0" + "@emotion/weak-memoize" "^0.2.5" + stylis "^4.0.10" + +"@emotion/eslint-plugin@^11.5.0": + version "11.5.0" + resolved "https://registry.yarnpkg.com/@emotion/eslint-plugin/-/eslint-plugin-11.5.0.tgz#1bad513b0d557e9a2cbf91415d409113595400c2" + integrity sha512-AyjeBIHuFYQIMLB+t5VRqXGzMkrD98p9aCgxTxt0DGBjQa6ExDU5xvhbeSjLRClc1zIYkDmbNBbGB71RAVgqFw== + "@emotion/hash@^0.6.2", "@emotion/hash@^0.6.6": version "0.6.6" resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.6.6.tgz#62266c5f0eac6941fece302abad69f2ee7e25e44" integrity sha512-ojhgxzUHZ7am3D2jHkMzPpsBAiB005GF5YU4ea+8DNPybMk01JJUM9V9YRlF/GE95tcOm8DxQvWA2jq19bGalQ== +"@emotion/hash@^0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.8.0.tgz#bbbff68978fefdbe68ccb533bc8cbe1d1afb5413" + integrity sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow== + "@emotion/is-prop-valid@^0.8.8": version "0.8.8" resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a" @@ -1049,6 +1077,24 @@ resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.6.6.tgz#004b98298d04c7ca3b4f50ca2035d4f60d2eed1b" integrity sha512-h4t4jFjtm1YV7UirAFuSuFGyLa+NNxjdkq6DpFLANNQY5rHueFZHVY+8Cu1HYVP6DrheB0kv4m5xPjo7eKT7yQ== +"@emotion/memoize@^0.7.4": + version "0.7.5" + resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.7.5.tgz#2c40f81449a4e554e9fc6396910ed4843ec2be50" + integrity sha512-igX9a37DR2ZPGYtV6suZ6whr8pTFtyHL3K/oLUotxpSVO2ASaprmAe2Dkq7tBo7CRY7MMDrAa9nuQP9/YG8FxQ== + +"@emotion/react@^11.5.0": + version "11.5.0" + resolved "https://registry.yarnpkg.com/@emotion/react/-/react-11.5.0.tgz#19b5771bbfbda5e8517e948a2d9064810f0022bd" + integrity sha512-MYq/bzp3rYbee4EMBORCn4duPQfgpiEB5XzrZEBnUZAL80Qdfr7CEv/T80jwaTl/dnZmt9SnTa8NkTrwFNpLlw== + dependencies: + "@babel/runtime" "^7.13.10" + "@emotion/cache" "^11.5.0" + "@emotion/serialize" "^1.0.2" + "@emotion/sheet" "^1.0.3" + "@emotion/utils" "^1.0.0" + "@emotion/weak-memoize" "^0.2.5" + hoist-non-react-statics "^3.3.1" + "@emotion/serialize@^0.9.1": version "0.9.1" resolved "https://registry.yarnpkg.com/@emotion/serialize/-/serialize-0.9.1.tgz#a494982a6920730dba6303eb018220a2b629c145" @@ -1059,6 +1105,22 @@ "@emotion/unitless" "^0.6.7" "@emotion/utils" "^0.8.2" +"@emotion/serialize@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@emotion/serialize/-/serialize-1.0.2.tgz#77cb21a0571c9f68eb66087754a65fa97bfcd965" + integrity sha512-95MgNJ9+/ajxU7QIAruiOAdYNjxZX7G2mhgrtDWswA21VviYIRP1R5QilZ/bDY42xiKsaktP4egJb3QdYQZi1A== + dependencies: + "@emotion/hash" "^0.8.0" + "@emotion/memoize" "^0.7.4" + "@emotion/unitless" "^0.7.5" + "@emotion/utils" "^1.0.0" + csstype "^3.0.2" + +"@emotion/sheet@^1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@emotion/sheet/-/sheet-1.0.3.tgz#00c326cd7985c5ccb8fe2c1b592886579dcfab8f" + integrity sha512-YoX5GyQ4db7LpbmXHMuc8kebtBGP6nZfRC5Z13OKJMixBEwdZrJ914D6yJv/P+ZH/YY3F5s89NYX2hlZAf3SRQ== + "@emotion/stylis@^0.7.0": version "0.7.1" resolved "https://registry.yarnpkg.com/@emotion/stylis/-/stylis-0.7.1.tgz#50f63225e712d99e2b2b39c19c70fff023793ca5" @@ -1074,7 +1136,7 @@ resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.6.7.tgz#53e9f1892f725b194d5e6a1684a7b394df592397" integrity sha512-Arj1hncvEVqQ2p7Ega08uHLr1JuRYBuO5cIvcA+WWEQ5+VmkOE3ZXzl04NbQxeQpWX78G7u6MqxKuNX3wvYZxg== -"@emotion/unitless@^0.7.4": +"@emotion/unitless@^0.7.4", "@emotion/unitless@^0.7.5": version "0.7.5" resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.7.5.tgz#77211291c1900a700b8a78cfafda3160d76949ed" integrity sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg== @@ -1084,6 +1146,16 @@ resolved "https://registry.yarnpkg.com/@emotion/utils/-/utils-0.8.2.tgz#576ff7fb1230185b619a75d258cbc98f0867a8dc" integrity sha512-rLu3wcBWH4P5q1CGoSSH/i9hrXs7SlbRLkoq9IGuoPYNGQvDJ3pt/wmOM+XgYjIDRMVIdkUWt0RsfzF50JfnCw== +"@emotion/utils@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@emotion/utils/-/utils-1.0.0.tgz#abe06a83160b10570816c913990245813a2fd6af" + integrity sha512-mQC2b3XLDs6QCW+pDQDiyO/EdGZYOygE8s5N5rrzjSI4M3IejPE/JPndCBwRT9z982aqQNi6beWs1UeayrQxxA== + +"@emotion/weak-memoize@^0.2.5": + version "0.2.5" + resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz#8eed982e2ee6f7f4e44c253e12962980791efd46" + integrity sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA== + "@eslint/eslintrc@^1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.0.3.tgz#41f08c597025605f672251dcc4e8be66b5ed7366" @@ -4715,6 +4787,11 @@ emojis-list@^3.0.0: resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== +emotion-theming@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/emotion-theming/-/emotion-theming-11.0.0.tgz#821de3c9804cfe7bb5fda2dd12ad722a6c5bcff5" + integrity sha512-OhYpCGBjaLcD9c4ptwCr9SxHjfRTDqeqdzMobusJ+a/drlfnJ3AT9gmGKIhNHiXtr6626h6fsvLY22Or9CxUqw== + emotion@^11.0.0: version "11.0.0" resolved "https://registry.yarnpkg.com/emotion/-/emotion-11.0.0.tgz#e33353668e72f0adea1f6fba790dc6c5b05b45d9" @@ -6293,7 +6370,7 @@ history@^4.9.0: tiny-warning "^1.0.0" value-equal "^1.0.1" -hoist-non-react-statics@^3.0.0, hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0: +hoist-non-react-statics@^3.0.0, hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.1: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -11358,6 +11435,11 @@ stylis@^3.5.0: resolved "https://registry.yarnpkg.com/stylis/-/stylis-3.5.4.tgz#f665f25f5e299cf3d64654ab949a57c768b73fbe" integrity sha512-8/3pSmthWM7lsPBKv7NXkzn2Uc9W7NotcwGNpJaa3k7WMM1XDCA4MgT5k/8BIexd5ydZdboXtU90XH9Ec4Bv/Q== +stylis@^4.0.10: + version "4.0.10" + resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.0.10.tgz#446512d1097197ab3f02fb3c258358c3f7a14240" + integrity sha512-m3k+dk7QeJw660eIKRRn3xPF6uuvHs/FFzjX3HQ5ove0qYsiygoAhwn5a3IYKaZPo5LrYD0rfVmtv1gNY1uYwg== + sumchecker@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/sumchecker/-/sumchecker-3.0.1.tgz#6377e996795abb0b6d348e9b3e1dfb24345a8e42"
- + + Please wait... +