From 04619f7e5c0b87a22593c2d7121a52a8585455c5 Mon Sep 17 00:00:00 2001 From: Daniel Vasylenko Date: Sat, 30 Oct 2021 18:16:18 +0200 Subject: [PATCH 01/11] refactor: revamp settings and migrate to overmind --- common/config.js | 5 - common/request.js | 9 -- common/storage.js | 26 ---- {common => main}/env.js | 0 main/exceptionCatcher.js | 2 +- .../handlers/errorHandler.js | 0 main/index.js | 26 +--- main/redmine.js | 2 +- main/storage.js | 124 ++++++++++++++++++ render/about/AboutPage.jsx | 4 +- render/components/Notification.jsx | 4 +- .../__tests__/project.reducer.spec.js | 4 - .../__tests__/settings.reducer.spec.js | 31 +---- .../__tests__/tracking.reducer.spec.js | 21 --- .../reducers/__tests__/user.reducer.spec.js | 31 ----- render/reducers/project.reducer.js | 1 - render/reducers/tracking.reducer.js | 1 - render/reducers/user.reducer.js | 41 +----- render/reduxStore.ts | 3 - render/store/effects/storage.ts | 42 +++++- render/store/settings/actions.ts | 21 ++- render/store/settings/state.ts | 2 - render/store/users/actions.ts | 2 +- render/views/AppView.tsx | 2 - render/views/__tests__/AppView.spec.jsx | 5 - render/views/__tests__/LoginView.spec.jsx | 5 - test/common/config.spec.js | 9 -- types/index.ts | 4 +- 28 files changed, 193 insertions(+), 234 deletions(-) delete mode 100644 common/config.js delete mode 100644 common/storage.js rename {common => main}/env.js (100%) rename common/reporter.js => main/handlers/errorHandler.js (100%) create mode 100644 main/storage.js 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..791ab165 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,20 +344,12 @@ 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.initializeStorageEvents(ipcMain); ipcMain.on('request', async (event, message) => { const { payload, config, id } = JSON.parse(message); @@ -391,15 +383,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 +395,7 @@ app.once('ready', () => { app.on('window-all-closed', () => { if (process.platform !== 'darwin') { + storage.disposeStorageEvents(ipcMain); app.quit(); } }); diff --git a/main/redmine.js b/main/redmine.js index ab0a2f91..99aa290e 100644 --- a/main/redmine.js +++ b/main/redmine.js @@ -39,7 +39,7 @@ const createRequestClient = (initConfig = {}) => { } return { - data: transform(data.route, response.body), + payload: transform(data.route, response.body), success: true, }; } diff --git a/main/storage.js b/main/storage.js new file mode 100644 index 00000000..551fdbb8 --- /dev/null +++ b/main/storage.js @@ -0,0 +1,124 @@ +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 updateSavedSession = (persistedSessions, activeSession) => { + const savedActiveSessionIndex = persistedSessions.findIndex((session) => session.token === activeSession.token + && session.endpoint === activeSession.endpoint); + + if (savedActiveSessionIndex !== -1) { + const persistedSessionsCopy = [...persistedSessions]; + persistedSessionsCopy[savedActiveSessionIndex] = activeSession; + storage.set('persistedSessions', persistedSessionsCopy); + } +}; + +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, endpoint }) => { + const { activeSession, persistedSessions } = getAllSettings(); + + if (token === activeSession.token) { + return getActiveSession(); + } + + updateSavedSession(persistedSessions, activeSession); + + const targetSession = persistedSessions.find(session => session.token === token && session.endpoint === endpoint); + + return targetSession; +}; + +const resetActiveSession = () => { + storage.delete('activeSession'); +}; + +const saveSession = (session) => { + const sessionObject = { + user: { + id: session.user.id, + firstName: session.user.firstName, + lastName: session.user.lastName, + createdOn: session.user.createdOn + }, + endpoint: session.endpoint, + token: session.token, + settings: { + ...session.settings + } + }; + + storage.set('activeSession', sessionObject); + updateSavedSession(storage.get('persistedSessions'), sessionObject); +}; + +const eventHandlers = (event, message) => { + const { action, payload, id } = JSON.parse(message); + + switch (action) { + case 'read': { + const { token, ...sessionWithoutToken } = getSession({ + token: payload.token, + endpoint: payload.endpoint + }); + + event.reply(`storage:${id}`, { + success: true, + payload: sessionWithoutToken + }); + break; + } + case 'save': { + saveSession(payload); + event.reply(`storage:${id}`, { success: true }); + break; + } + default: { + event.reply(`storage:${id}`, { + success: false, + error: new Error('Unable to process the requested action', action) + }); + } + } +}; + +const initializeStorageEvents = (ipcMain) => { + ipcMain.on('storage', eventHandlers); +}; + +const disposeStorageEvents = (ipcMain) => { + ipcMain.off('storage', eventHandlers); +}; + +module.exports = { + getActiveSession, + getAllSettings, + resetActiveSession, + initializeStorageEvents, + disposeStorageEvents +}; 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/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/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 index 780731d0..754f81d4 100644 --- a/render/reducers/__tests__/user.reducer.spec.js +++ b/render/reducers/__tests__/user.reducer.spec.js @@ -2,8 +2,6 @@ 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', () => { @@ -22,8 +20,6 @@ describe('User reducer', () => { }); 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, @@ -32,7 +28,6 @@ describe('User reducer', () => { }); it('should get user data and put it in storage on OK', () => { - const storageSetSpy = jest.spyOn(storage, 'set'); const data = { user: { id: 1, @@ -49,14 +44,6 @@ describe('User reducer', () => { 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', () => { @@ -70,13 +57,6 @@ describe('User reducer', () => { 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, @@ -94,17 +74,6 @@ describe('User reducer', () => { } ) ); - - 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/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 index a167e687..188d3a13 100644 --- a/render/reducers/user.reducer.js +++ b/render/reducers/user.reducer.js @@ -1,51 +1,12 @@ import _ from 'lodash'; -import { USER_LOGIN, USER_LOGOUT } from '../actions/user.actions'; -// import storage from '../../common/storage'; +import { USER_LOGOUT } from '../actions/user.actions'; 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: 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/storage.ts b/render/store/effects/storage.ts index 343ae509..67546535 100644 --- a/render/store/effects/storage.ts +++ b/render/store/effects/storage.ts @@ -1,14 +1,35 @@ import { ipcRenderer, remote } from 'electron'; import type { Context } from 'overmind'; -import { StorageAction } from '../../../types'; +import { Response, StorageAction } from '../../../types'; const crypto = remote.require('crypto'); -const save = (data: Context['state']['settings']) => { +type SaveArgs = { + settings: Context['state']['settings']; + currentUser: Context['state']['users']['currentUser']; +}; + +const save = ({ settings, currentUser }: SaveArgs): Promise => { const id = crypto.randomBytes(10).toString('hex'); + const { endpoint, ...appSettings } = settings; return new Promise((resolve) => { - ipcRenderer.send('storage', JSON.stringify({ action: StorageAction.SAVE, payload: data, id })); + ipcRenderer.send('storage', JSON.stringify({ + action: StorageAction.SAVE, + payload: { + user: { + id: currentUser?.id, + firstName: currentUser?.firstName, + lastName: currentUser?.lastName, + createdOn: currentUser?.createdOn + }, + endpoint, + settings: { + ...appSettings + } + }, + id + })); ipcRenderer.once(`storage:${id}`, (event, response) => { console.log('storage:save', response); @@ -22,7 +43,7 @@ type ReadArgs = { endpoint: string; }; -const read = (payload: ReadArgs): Promise => { +const read = (payload: ReadArgs): Promise> => { const id = crypto.randomBytes(10).toString('hex'); return new Promise((resolve) => { @@ -30,7 +51,18 @@ const read = (payload: ReadArgs): Promise => { ipcRenderer.once(`storage:${id}`, (event, response) => { console.log('storage:read', response); - resolve(response); + const settings = response.success + ? { + endpoint: response.payload.redmine.endpoint, + ...response.payload.settings + } + : undefined; + + resolve({ + success: response.success, + error: response.error, + payload: settings + }); }); }); }; diff --git a/render/store/settings/actions.ts b/render/store/settings/actions.ts index 6e233ac6..a6a63f89 100644 --- a/render/store/settings/actions.ts +++ b/render/store/settings/actions.ts @@ -1,22 +1,33 @@ import type { IAction, Context } from 'overmind'; import type { State } from './state'; -const update: IAction = ({ state, effects }: Context, settings) => { +import { Response } from '../../../types'; + +const save: IAction> = ({ state, effects }: Context, settings) => { state.settings = { ...settings }; - effects.storage.save(state.settings); + + return effects.storage.save({ + settings, + currentUser: state.users.currentUser + }); }; const restore: IAction> = async ({ state, effects }: Context) => { - const settings = await effects.storage.read({ + const response = await effects.storage.read({ userId: state.users.currentUser?.id as string, endpoint: state.settings.endpoint as string }); - state.settings = { ...settings }; + + if (response.success && response.payload) { + state.settings = { + ...response.payload + }; + } }; export { - update, + save, restore }; diff --git a/render/store/settings/state.ts b/render/store/settings/state.ts index 6fcf5289..458a671c 100644 --- a/render/store/settings/state.ts +++ b/render/store/settings/state.ts @@ -3,7 +3,6 @@ import type { IssueHeader } from '../../../types'; type State = { showClosedIssues: boolean; issueHeaders: IssueHeader[]; - apiKey?: string; endpoint?: string; } @@ -19,7 +18,6 @@ const state: State = { { label: 'Estimation', isFixed: false, value: 'estimated_hours' }, { label: 'Due Date', isFixed: false, value: 'due_date' } ], - apiKey: undefined, endpoint: undefined }; diff --git a/render/store/users/actions.ts b/render/store/users/actions.ts index 5c0425d6..125c0414 100644 --- a/render/store/users/actions.ts +++ b/render/store/users/actions.ts @@ -34,7 +34,7 @@ const login: IAction> = async ({ effects, st }); if (response.success) { - state.users.currentUser = response.data; + state.users.currentUser = response.payload; } return response; diff --git a/render/views/AppView.tsx b/render/views/AppView.tsx index 327a7eb7..1b106471 100644 --- a/render/views/AppView.tsx +++ b/render/views/AppView.tsx @@ -15,7 +15,6 @@ 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 = () => { diff --git a/render/views/__tests__/AppView.spec.jsx b/render/views/__tests__/AppView.spec.jsx index 30a9ec6a..e1e2166d 100644 --- a/render/views/__tests__/AppView.spec.jsx +++ b/render/views/__tests__/AppView.spec.jsx @@ -16,7 +16,6 @@ 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'); @@ -279,8 +278,6 @@ describe('AppView', () => { } }; - const storageSpy = jest.spyOn(storage, 'delete'); - render( @@ -299,13 +296,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..3af93d15 100644 --- a/render/views/__tests__/LoginView.spec.jsx +++ b/render/views/__tests__/LoginView.spec.jsx @@ -13,7 +13,6 @@ 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 +30,6 @@ describe('Login view', () => { afterEach(() => { cleanup(); axiosMock.reset(); - storage.clear(); }); afterAll(() => { @@ -75,7 +73,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 +90,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); }); 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/types/index.ts b/types/index.ts index ee8995c1..3551654a 100644 --- a/types/index.ts +++ b/types/index.ts @@ -14,8 +14,8 @@ type CreateOvermindConfigParams = { endpoint: string; } -type Response = { - data: any; +type Response = { + payload: T; success: boolean; error?: Error; } From be9da191ec57cad44fb355f22e6347a407a1c259 Mon Sep 17 00:00:00 2001 From: Daniel Vasylenko Date: Sun, 31 Oct 2021 01:45:27 +0200 Subject: [PATCH 02/11] feat: save token on login and reset session and settings on log out via overmind --- main/storage.js | 13 +++++++++++-- render/store/effects/storage.ts | 22 ++++++++++++++++++---- render/store/settings/actions.ts | 26 +++++++++++++++++++++++--- render/store/users/actions.ts | 23 +++++++++++++++++++---- types/index.ts | 3 ++- 5 files changed, 73 insertions(+), 14 deletions(-) diff --git a/main/storage.js b/main/storage.js index 551fdbb8..1c2fa4dc 100644 --- a/main/storage.js +++ b/main/storage.js @@ -8,6 +8,9 @@ const storage = new Store({ encryptionKey: ENCRYPTION_KEY }); +console.log(JSON.stringify(storage.get('activeSession'), null, 2)); +console.log(JSON.stringify(storage.get('persistedSessions'), null, 2)); + const updateSavedSession = (persistedSessions, activeSession) => { const savedActiveSessionIndex = persistedSessions.findIndex((session) => session.token === activeSession.token && session.endpoint === activeSession.endpoint); @@ -80,8 +83,10 @@ const saveSession = (session) => { const eventHandlers = (event, message) => { const { action, payload, id } = JSON.parse(message); + console.log('Received storage event', JSON.stringify(message, null, 2)); + switch (action) { - case 'read': { + case 'READ': { const { token, ...sessionWithoutToken } = getSession({ token: payload.token, endpoint: payload.endpoint @@ -93,11 +98,15 @@ const eventHandlers = (event, message) => { }); break; } - case 'save': { + case 'SAVE': { saveSession(payload); event.reply(`storage:${id}`, { success: true }); break; } + case 'RESET': { + resetActiveSession(); + break; + } default: { event.reply(`storage:${id}`, { success: false, diff --git a/render/store/effects/storage.ts b/render/store/effects/storage.ts index 67546535..a3e7c2fd 100644 --- a/render/store/effects/storage.ts +++ b/render/store/effects/storage.ts @@ -9,7 +9,7 @@ type SaveArgs = { currentUser: Context['state']['users']['currentUser']; }; -const save = ({ settings, currentUser }: SaveArgs): Promise => { +const saveActiveSession = ({ settings, currentUser }: SaveArgs): Promise => { const id = crypto.randomBytes(10).toString('hex'); const { endpoint, ...appSettings } = settings; @@ -43,7 +43,7 @@ type ReadArgs = { endpoint: string; }; -const read = (payload: ReadArgs): Promise> => { +const getSession = (payload: ReadArgs): Promise> => { const id = crypto.randomBytes(10).toString('hex'); return new Promise((resolve) => { @@ -67,7 +67,21 @@ const read = (payload: ReadArgs): Promise => { + const id = crypto.randomBytes(10).toString('hex'); + + return new Promise((resolve) => { + ipcRenderer.send('storage', JSON.stringify({ action: StorageAction.RESET, id })); + + ipcRenderer.once(`storage:${id}`, (event, response) => { + console.log('storage:reset', response); + resolve(response); + }); + }); +}; + export { - save, - read + saveActiveSession, + getSession, + resetActiveSession }; diff --git a/render/store/settings/actions.ts b/render/store/settings/actions.ts index a6a63f89..286a48ff 100644 --- a/render/store/settings/actions.ts +++ b/render/store/settings/actions.ts @@ -8,14 +8,14 @@ const save: IAction> = ({ state, effects }: Context, se ...settings }; - return effects.storage.save({ + return effects.storage.saveActiveSession({ settings, currentUser: state.users.currentUser }); }; const restore: IAction> = async ({ state, effects }: Context) => { - const response = await effects.storage.read({ + const response = await effects.storage.getSession({ userId: state.users.currentUser?.id as string, endpoint: state.settings.endpoint as string }); @@ -27,7 +27,27 @@ const restore: IAction> = async ({ state, effects }: Context } }; +const reset: IAction> = async ({ effects, state }: Context) => { + state.settings = { + showClosedIssues: false, + issueHeaders: [ + { label: 'Id', isFixed: true, value: 'id' }, + { label: 'Subject', isFixed: true, value: 'subject' }, + { label: 'Project', isFixed: false, value: 'project.name' }, + { label: 'Tracker', isFixed: false, value: 'tracker.name' }, + { label: 'Status', isFixed: false, value: 'status.name' }, + { label: 'Priority', isFixed: false, value: 'priority.name' }, + { label: 'Estimation', isFixed: false, value: 'estimated_hours' }, + { label: 'Due Date', isFixed: false, value: 'due_date' } + ], + endpoint: undefined + }; + + return effects.storage.resetActiveSession(); +}; + export { save, - restore + restore, + reset }; diff --git a/render/store/users/actions.ts b/render/store/users/actions.ts index 125c0414..b05b40e5 100644 --- a/render/store/users/actions.ts +++ b/render/store/users/actions.ts @@ -1,4 +1,4 @@ -import type { IAction, Context } from 'overmind'; +import type { IAction, Context, IReaction } from 'overmind'; import { Response } from '../../../types'; type LoginActionProps = { @@ -35,16 +35,31 @@ const login: IAction> = async ({ effects, st if (response.success) { state.users.currentUser = response.payload; + sessionStorage.setItem('token', response.payload.api_key); } return response; }; -const logout = () => { - /* noop */ +const logout: IAction = ({ state, actions }: Context) => { + state.users.currentUser = undefined; + actions.settings.reset(); +}; + +const onInitializeOvermind: IAction = ({ effects }: Context, overmind) => { + overmind.reaction( + (state) => state.users.currentUser, + async (user) => { + if (user === undefined) { + sessionStorage.removeItem('token'); + await effects.storage.resetActiveSession(); + } + } + ); }; export { login, - logout + logout, + onInitializeOvermind }; diff --git a/types/index.ts b/types/index.ts index 3551654a..169ea807 100644 --- a/types/index.ts +++ b/types/index.ts @@ -23,7 +23,8 @@ type Response = { // eslint-disable-next-line no-shadow enum StorageAction { READ = 'READ', - SAVE = 'SAVE' + SAVE = 'SAVE', + RESET = 'RESET' } export { From f7da25c749790d28d9fa0e79a02e851b84ba0d00 Mon Sep 17 00:00:00 2001 From: Daniel Vasylenko Date: Sun, 31 Oct 2021 02:17:52 +0100 Subject: [PATCH 03/11] feat: upsert session on login --- main/storage.js | 20 ++++++++++++++------ main/transformers/users.js | 10 ++++++++-- render/store/effects/storage.ts | 6 +++--- render/store/settings/actions.ts | 25 +++++++++++++++++++------ render/store/settings/state.ts | 11 ++++++++--- render/store/users/actions.ts | 26 ++++++++++++++++++-------- render/store/users/state.ts | 9 ++++----- 7 files changed, 74 insertions(+), 33 deletions(-) diff --git a/main/storage.js b/main/storage.js index 1c2fa4dc..397f695e 100644 --- a/main/storage.js +++ b/main/storage.js @@ -46,12 +46,10 @@ const getAllSettings = () => { const getSession = ({ token, endpoint }) => { const { activeSession, persistedSessions } = getAllSettings(); - if (token === activeSession.token) { + if (token === activeSession?.token) { return getActiveSession(); } - updateSavedSession(persistedSessions, activeSession); - const targetSession = persistedSessions.find(session => session.token === token && session.endpoint === endpoint); return targetSession; @@ -77,21 +75,30 @@ const saveSession = (session) => { }; storage.set('activeSession', sessionObject); - updateSavedSession(storage.get('persistedSessions'), sessionObject); + updateSavedSession(storage.get('persistedSessions', []), sessionObject); }; const eventHandlers = (event, message) => { const { action, payload, id } = JSON.parse(message); - console.log('Received storage event', JSON.stringify(message, null, 2)); + console.log('Received storage event', message); switch (action) { case 'READ': { - const { token, ...sessionWithoutToken } = getSession({ + const session = getSession({ token: payload.token, endpoint: payload.endpoint }); + if (!session) { + event.reply(`storage:${id}`, { + success: false + }); + break; + } + + const { token, ...sessionWithoutToken } = session; + event.reply(`storage:${id}`, { success: true, payload: sessionWithoutToken @@ -105,6 +112,7 @@ const eventHandlers = (event, message) => { } case 'RESET': { resetActiveSession(); + event.reply(`storage:${id}`, { success: true }); break; } default: { diff --git a/main/transformers/users.js b/main/transformers/users.js index cd205c0f..1b4c318d 100644 --- a/main/transformers/users.js +++ b/main/transformers/users.js @@ -4,9 +4,15 @@ const transform = (route, responseBody) => { switch (route) { case 'users/current.json': { const { - _login, _admin, _api_key, ...userPayload + // eslint-disable-next-line camelcase + login, admin, api_key, last_login_on, ...userPayload } = user; - return userPayload; + return { + id: userPayload.id, + firstName: userPayload.firstname, + lastName: userPayload.lastname, + createdOn: userPayload.created_on + }; } default: return responseBody; diff --git a/render/store/effects/storage.ts b/render/store/effects/storage.ts index a3e7c2fd..6d080faf 100644 --- a/render/store/effects/storage.ts +++ b/render/store/effects/storage.ts @@ -38,12 +38,12 @@ const saveActiveSession = ({ settings, currentUser }: SaveArgs): Promise> => { +const getSession = (payload: GetSessionArgs): Promise> => { const id = crypto.randomBytes(10).toString('hex'); return new Promise((resolve) => { diff --git a/render/store/settings/actions.ts b/render/store/settings/actions.ts index 286a48ff..7b103104 100644 --- a/render/store/settings/actions.ts +++ b/render/store/settings/actions.ts @@ -1,9 +1,9 @@ import type { IAction, Context } from 'overmind'; -import type { State } from './state'; +import type { SettingsState } from './state'; import { Response } from '../../../types'; -const save: IAction> = ({ state, effects }: Context, settings) => { +const update: IAction> = ({ state, effects }: Context, settings) => { state.settings = { ...settings }; @@ -14,17 +14,30 @@ const save: IAction> = ({ state, effects }: Context, se }); }; -const restore: IAction> = async ({ state, effects }: Context) => { +type RestoreSettingsArgs = { + token: string; + endpoint: string; +}; + +const restore: IAction, Promise<{ success: boolean }>> = async ({ state, effects }: Context, payload) => { const response = await effects.storage.getSession({ - userId: state.users.currentUser?.id as string, - endpoint: state.settings.endpoint as string + token: payload.token || localStorage.getItem('token') as string, + endpoint: payload.endpoint || state.settings.endpoint as string }); if (response.success && response.payload) { + const saveResponse = await effects.storage.saveActiveSession({ settings: response.payload, currentUser: state.users.currentUser }); + + if (!saveResponse.success) { + return { success: false }; + } + state.settings = { ...response.payload }; } + + return { success: response.success }; }; const reset: IAction> = async ({ effects, state }: Context) => { @@ -47,7 +60,7 @@ const reset: IAction> = async ({ effects, state }: Conte }; export { - save, + update, restore, reset }; diff --git a/render/store/settings/state.ts b/render/store/settings/state.ts index 458a671c..d98fd8c0 100644 --- a/render/store/settings/state.ts +++ b/render/store/settings/state.ts @@ -1,12 +1,12 @@ import type { IssueHeader } from '../../../types'; -type State = { +type SettingsState = { showClosedIssues: boolean; issueHeaders: IssueHeader[]; endpoint?: string; } -const state: State = { +const defaultSettingsState = { showClosedIssues: false, issueHeaders: [ { label: 'Id', isFixed: true, value: 'id' }, @@ -21,10 +21,15 @@ const state: State = { 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 b05b40e5..3ee1abd5 100644 --- a/render/store/users/actions.ts +++ b/render/store/users/actions.ts @@ -1,5 +1,6 @@ -import type { IAction, Context, IReaction } from 'overmind'; +import type { IAction, Context } from 'overmind'; import { Response } from '../../../types'; +import { defaultSettingsState } from '../settings/state'; type LoginActionProps = { useApiKey: boolean; @@ -9,7 +10,7 @@ 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) { @@ -22,7 +23,7 @@ const login: IAction> = async ({ effects, st Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}` }; - const response = await effects.request.query({ + const loginResponse = await effects.request.query({ payload: { headers, route: 'users/current.json', @@ -33,12 +34,21 @@ const login: IAction> = async ({ effects, st } }); - if (response.success) { - state.users.currentUser = response.payload; - sessionStorage.setItem('token', response.payload.api_key); + if (loginResponse.success) { + state.users.currentUser = loginResponse.payload; + localStorage.setItem('token', loginResponse.payload.token); + + const restoreResponse = await actions.settings.restore({ + endpoint: redmineEndpoint, + token: loginResponse.payload.token + }); + + if (!restoreResponse.success) { + await actions.settings.update({ ...defaultSettingsState }); + } } - return response; + return loginResponse; }; const logout: IAction = ({ state, actions }: Context) => { @@ -51,7 +61,7 @@ const onInitializeOvermind: IAction = ({ effects }: Context, over (state) => state.users.currentUser, async (user) => { if (user === undefined) { - sessionStorage.removeItem('token'); + localStorage.removeItem('token'); await effects.storage.resetActiveSession(); } } diff --git a/render/store/users/state.ts b/render/store/users/state.ts index 04730d87..e202693f 100644 --- a/render/store/users/state.ts +++ b/render/store/users/state.ts @@ -1,14 +1,13 @@ -type State = { +type UserState = { currentUser?: { id: string; firstName: string; lastName: string; - createdOn: Date; - lastLoggedOn: Date; + createdOn: string; } } -const state: State = { +const state: UserState = { currentUser: undefined }; @@ -17,5 +16,5 @@ export { }; export type { - State + UserState }; From b64d996f30466a5e7e08c34707c32d4c777a7cf5 Mon Sep 17 00:00:00 2001 From: Daniel Vasylenko Date: Sun, 31 Oct 2021 16:07:01 +0100 Subject: [PATCH 04/11] refactor: get rid of redux user state, actions, reducer --- main/storage.js | 7 +- render/actions/__tests__/user.actions.spec.js | 144 ------------------ render/actions/index.js | 2 - render/actions/user.actions.js | 61 -------- render/components/Navbar.jsx | 143 ----------------- render/components/Navbar.tsx | 111 ++++++++++++++ render/components/__tests__/Navbar.spec.jsx | 6 +- .../reducers/__tests__/user.reducer.spec.js | 79 ---------- render/reducers/index.js | 7 - render/reducers/user.reducer.js | 15 -- render/views/AppView.tsx | 4 +- render/views/__tests__/AppView.spec.jsx | 2 - render/views/__tests__/LoginView.spec.jsx | 17 --- 13 files changed, 118 insertions(+), 480 deletions(-) delete mode 100644 render/actions/__tests__/user.actions.spec.js delete mode 100644 render/actions/user.actions.js delete mode 100644 render/components/Navbar.jsx create mode 100644 render/components/Navbar.tsx delete mode 100644 render/reducers/__tests__/user.reducer.spec.js delete mode 100644 render/reducers/user.reducer.js diff --git a/main/storage.js b/main/storage.js index 397f695e..201444af 100644 --- a/main/storage.js +++ b/main/storage.js @@ -11,7 +11,7 @@ const storage = new Store({ console.log(JSON.stringify(storage.get('activeSession'), null, 2)); console.log(JSON.stringify(storage.get('persistedSessions'), null, 2)); -const updateSavedSession = (persistedSessions, activeSession) => { +const upsertSavedSession = (persistedSessions, activeSession) => { const savedActiveSessionIndex = persistedSessions.findIndex((session) => session.token === activeSession.token && session.endpoint === activeSession.endpoint); @@ -19,6 +19,9 @@ const updateSavedSession = (persistedSessions, activeSession) => { const persistedSessionsCopy = [...persistedSessions]; persistedSessionsCopy[savedActiveSessionIndex] = activeSession; storage.set('persistedSessions', persistedSessionsCopy); + } else { + const updatedPersistedSessions = [...persistedSessions, activeSession]; + storage.set('persistedSessions', updatedPersistedSessions); } }; @@ -75,7 +78,7 @@ const saveSession = (session) => { }; storage.set('activeSession', sessionObject); - updateSavedSession(storage.get('persistedSessions', []), sessionObject); + upsertSavedSession(storage.get('persistedSessions', []), sessionObject); }; const eventHandlers = (event, message) => { 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/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/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..243edf2f --- /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/__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/reducers/__tests__/user.reducer.spec.js b/render/reducers/__tests__/user.reducer.spec.js deleted file mode 100644 index 754f81d4..00000000 --- a/render/reducers/__tests__/user.reducer.spec.js +++ /dev/null @@ -1,79 +0,0 @@ -import reducer from '../user.reducer'; -import { USER_LOGIN, USER_LOGOUT } from '../../actions/user.actions'; -import { notify } from '../../actions/helper'; - -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', () => { - 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 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 - }); - }); - - 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 defaultState = { - isFetching: false, - loginError: undefined, - id: 1, - firstname: 'firstname', - lastname: 'lastname', - redmineEndpoint: 'https://redmine.domain', - api_key: '123abc' - }; - expect( - reducer( - defaultState, - { - type: USER_LOGOUT - } - ) - ); - }); - }); -}); 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/user.reducer.js b/render/reducers/user.reducer.js deleted file mode 100644 index 188d3a13..00000000 --- a/render/reducers/user.reducer.js +++ /dev/null @@ -1,15 +0,0 @@ -import _ from 'lodash'; -import { USER_LOGOUT } from '../actions/user.actions'; - -export const initialState = { -}; - -export default (state = initialState, action) => { - switch (action.type) { - case USER_LOGOUT: { - return { ...initialState }; - } - default: - return state; - } -}; diff --git a/render/views/AppView.tsx b/render/views/AppView.tsx index 1b106471..a6426591 100644 --- a/render/views/AppView.tsx +++ b/render/views/AppView.tsx @@ -9,7 +9,7 @@ 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'; @@ -115,7 +115,6 @@ AppView.propTypes = { match: PropTypes.shape({ path: PropTypes.string.isRequired }).isRequired, - logout: PropTypes.func.isRequired, resetTimer: PropTypes.func.isRequired, getProjectData: PropTypes.func.isRequired, projects: PropTypes.shape({ @@ -141,7 +140,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/__tests__/AppView.spec.jsx b/render/views/__tests__/AppView.spec.jsx index e1e2166d..31db0487 100644 --- a/render/views/__tests__/AppView.spec.jsx +++ b/render/views/__tests__/AppView.spec.jsx @@ -10,7 +10,6 @@ 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'; @@ -208,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) => { diff --git a/render/views/__tests__/LoginView.spec.jsx b/render/views/__tests__/LoginView.spec.jsx index 3af93d15..ee876510 100644 --- a/render/views/__tests__/LoginView.spec.jsx +++ b/render/views/__tests__/LoginView.spec.jsx @@ -11,7 +11,6 @@ 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 axios from '../../../common/request'; @@ -163,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(); @@ -239,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(); @@ -308,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(); From 590882091397b187c96134d2e3810e39b941d464 Mon Sep 17 00:00:00 2001 From: Daniel Vasylenko Date: Mon, 1 Nov 2021 02:19:10 +0100 Subject: [PATCH 05/11] feat: saving the session after login. re-initialise the api client --- main/redmine.js | 32 +++++++++++++++++++++++++------- main/storage.js | 27 +++++++++++++++------------ main/transformers/users.js | 5 +++-- render/helpers/utils.ts | 13 +++++++++++++ render/store/effects/storage.ts | 14 +++++++------- render/store/settings/actions.ts | 12 ++---------- render/store/users/actions.ts | 23 ++++++++++++----------- 7 files changed, 77 insertions(+), 49 deletions(-) create mode 100644 render/helpers/utils.ts diff --git a/main/redmine.js b/main/redmine.js index 99aa290e..c974d732 100644 --- a/main/redmine.js +++ b/main/redmine.js @@ -6,12 +6,19 @@ const createRequestClient = (initConfig = {}) => { 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' - }); + isInitialized = Boolean(config.endpoint && config.token && config.token !== 'undefined'); + const configuration = config.token + ? { + prefixUrl: config.endpoint, + headers: { 'X-Redmine-API-Key': config.token }, + responseType: 'json' + } + : { + prefixUrl: config.endpoint, + responseType: 'json' + }; + + instance = got.extend(configuration); }; const handleUnsuccessfulRequest = (error) => ({ @@ -19,6 +26,11 @@ const createRequestClient = (initConfig = {}) => { error }); + const reset = () => { + isInitialized = false; + instance = got; + }; + const send = async (data) => { if (!instance && !data.headers?.Authorization) { throw new Error('Http client is not initialized.'); @@ -44,6 +56,11 @@ const createRequestClient = (initConfig = {}) => { }; } + // prevents the instance from being stuck because some random value was set as a token + if (response.statusCode === 401 && isInitialized) { + reset(); + } + return handleUnsuccessfulRequest(response.body); } catch (error) { return handleUnsuccessfulRequest(error); @@ -55,7 +72,8 @@ const createRequestClient = (initConfig = {}) => { return { initialize, isInitialized: () => isInitialized, - send + send, + reset }; }; diff --git a/main/storage.js b/main/storage.js index 201444af..0864924f 100644 --- a/main/storage.js +++ b/main/storage.js @@ -1,6 +1,8 @@ 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 storage = new Store({ @@ -8,12 +10,13 @@ const storage = new Store({ encryptionKey: ENCRYPTION_KEY }); +const hashToken = (token) => crypto.createHash('sha256').update(token, '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.token === activeSession.token - && session.endpoint === activeSession.endpoint); + const savedActiveSessionIndex = persistedSessions.findIndex((session) => session.hash === activeSession.hash); if (savedActiveSessionIndex !== -1) { const persistedSessionsCopy = [...persistedSessions]; @@ -46,14 +49,15 @@ const getAllSettings = () => { }; }; -const getSession = ({ token, endpoint }) => { +const getSession = (token) => { const { activeSession, persistedSessions } = getAllSettings(); + const hash = hashToken(token); - if (token === activeSession?.token) { + if (hash === activeSession?.hash) { return getActiveSession(); } - const targetSession = persistedSessions.find(session => session.token === token && session.endpoint === endpoint); + const targetSession = persistedSessions.find(session => session.hash === hash); return targetSession; }; @@ -63,7 +67,10 @@ const resetActiveSession = () => { }; const saveSession = (session) => { + const sessionHash = hashToken(session.token); + const sessionObject = { + hash: sessionHash, user: { id: session.user.id, firstName: session.user.firstName, @@ -71,7 +78,6 @@ const saveSession = (session) => { createdOn: session.user.createdOn }, endpoint: session.endpoint, - token: session.token, settings: { ...session.settings } @@ -88,10 +94,7 @@ const eventHandlers = (event, message) => { switch (action) { case 'READ': { - const session = getSession({ - token: payload.token, - endpoint: payload.endpoint - }); + const session = getSession(payload.token); if (!session) { event.reply(`storage:${id}`, { @@ -100,11 +103,11 @@ const eventHandlers = (event, message) => { break; } - const { token, ...sessionWithoutToken } = session; + const { hash, ...sessionWithoutHash } = session; event.reply(`storage:${id}`, { success: true, - payload: sessionWithoutToken + payload: sessionWithoutHash }); break; } diff --git a/main/transformers/users.js b/main/transformers/users.js index 1b4c318d..46d62bdf 100644 --- a/main/transformers/users.js +++ b/main/transformers/users.js @@ -5,13 +5,14 @@ const transform = (route, responseBody) => { case 'users/current.json': { const { // eslint-disable-next-line camelcase - login, admin, api_key, last_login_on, ...userPayload + login, admin, last_login_on, ...userPayload } = user; return { id: userPayload.id, firstName: userPayload.firstname, lastName: userPayload.lastname, - createdOn: userPayload.created_on + createdOn: userPayload.created_on, + token: userPayload.api_key }; } default: diff --git a/render/helpers/utils.ts b/render/helpers/utils.ts new file mode 100644 index 00000000..c2aacbb8 --- /dev/null +++ b/render/helpers/utils.ts @@ -0,0 +1,13 @@ +const getStoredToken = () => { + const token = localStorage.getItem('token'); + + if (!token || token === 'undefined') { + return undefined; + } + + return token; +}; + +export { + getStoredToken +}; diff --git a/render/store/effects/storage.ts b/render/store/effects/storage.ts index 6d080faf..f6e01d2d 100644 --- a/render/store/effects/storage.ts +++ b/render/store/effects/storage.ts @@ -1,6 +1,7 @@ import { ipcRenderer, remote } from 'electron'; import type { Context } from 'overmind'; import { Response, StorageAction } from '../../../types'; +import { getStoredToken } from '../../helpers/utils'; const crypto = remote.require('crypto'); @@ -24,6 +25,7 @@ const saveActiveSession = ({ settings, currentUser }: SaveArgs): Promise> => { +const getSession = (token: string): Promise> => { const id = crypto.randomBytes(10).toString('hex'); return new Promise((resolve) => { - ipcRenderer.send('storage', JSON.stringify({ action: StorageAction.READ, payload, id })); + ipcRenderer.send('storage', JSON.stringify({ action: StorageAction.READ, payload: { token }, id })); ipcRenderer.once(`storage:${id}`, (event, response) => { console.log('storage:read', response); @@ -74,6 +71,9 @@ const resetActiveSession = (): Promise => { ipcRenderer.send('storage', JSON.stringify({ action: StorageAction.RESET, id })); ipcRenderer.once(`storage:${id}`, (event, response) => { + if (response.success) { + localStorage.removeItem('token'); + } console.log('storage:reset', response); resolve(response); }); diff --git a/render/store/settings/actions.ts b/render/store/settings/actions.ts index 7b103104..42138cd5 100644 --- a/render/store/settings/actions.ts +++ b/render/store/settings/actions.ts @@ -14,16 +14,8 @@ const update: IAction> = ({ state, effects }: C }); }; -type RestoreSettingsArgs = { - token: string; - endpoint: string; -}; - -const restore: IAction, Promise<{ success: boolean }>> = async ({ state, effects }: Context, payload) => { - const response = await effects.storage.getSession({ - token: payload.token || localStorage.getItem('token') as string, - endpoint: payload.endpoint || state.settings.endpoint as string - }); +const restore: IAction, Promise<{ success: boolean }>> = async ({ state, effects }: Context, token) => { + const response = await effects.storage.getSession(token || localStorage.getItem('token') as string); if (response.success && response.payload) { const saveResponse = await effects.storage.saveActiveSession({ settings: response.payload, currentUser: state.users.currentUser }); diff --git a/render/store/users/actions.ts b/render/store/users/actions.ts index 3ee1abd5..60c864df 100644 --- a/render/store/users/actions.ts +++ b/render/store/users/actions.ts @@ -1,5 +1,6 @@ import type { IAction, Context } from 'overmind'; import { Response } from '../../../types'; +import { getStoredToken } from '../../helpers/utils'; import { defaultSettingsState } from '../settings/state'; type LoginActionProps = { @@ -17,8 +18,10 @@ const login: IAction> = async ({ actions, ef 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')}` }; @@ -30,7 +33,7 @@ const login: IAction> = async ({ actions, ef }, config: { endpoint: redmineEndpoint, - token: apiKey + token } }); @@ -38,10 +41,7 @@ const login: IAction> = async ({ actions, ef state.users.currentUser = loginResponse.payload; localStorage.setItem('token', loginResponse.payload.token); - const restoreResponse = await actions.settings.restore({ - endpoint: redmineEndpoint, - token: loginResponse.payload.token - }); + const restoreResponse = await actions.settings.restore(loginResponse.payload.token); if (!restoreResponse.success) { await actions.settings.update({ ...defaultSettingsState }); @@ -51,9 +51,11 @@ const login: IAction> = async ({ actions, ef return loginResponse; }; -const logout: IAction = ({ state, actions }: Context) => { - state.users.currentUser = undefined; - actions.settings.reset(); +const logout: IAction> = async ({ state, actions }: Context) => { + const response = await actions.settings.reset(); + if (response.success) { + state.users.currentUser = undefined; + } }; const onInitializeOvermind: IAction = ({ effects }: Context, overmind) => { @@ -61,7 +63,6 @@ const onInitializeOvermind: IAction = ({ effects }: Context, over (state) => state.users.currentUser, async (user) => { if (user === undefined) { - localStorage.removeItem('token'); await effects.storage.resetActiveSession(); } } From 30947eaf2acc2ea11bb8507cb747239d3ecb7c9e Mon Sep 17 00:00:00 2001 From: Daniel Vasylenko Date: Sat, 6 Nov 2021 02:15:35 +0100 Subject: [PATCH 06/11] feat: wire up logout from overmind actions --- render/actions/__tests__/helper.spec.js | 30 +------------------------ render/actions/helper.js | 22 ------------------ render/store/effects/storage.ts | 2 +- render/views/AppView.tsx | 2 -- 4 files changed, 2 insertions(+), 54 deletions(-) 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/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/store/effects/storage.ts b/render/store/effects/storage.ts index f6e01d2d..78d6817d 100644 --- a/render/store/effects/storage.ts +++ b/render/store/effects/storage.ts @@ -50,7 +50,7 @@ const getSession = (token: string): Promise ({ - api_key: state.user.api_key, projects: state.projects.data, idleBehavior: state.settings.idleBehavior, discardIdleTime: state.settings.discardIdleTime, From 11c2c2fc555cbe52d5f9077d9ea73977bc334c17 Mon Sep 17 00:00:00 2001 From: Daniel Vasylenko Date: Sat, 6 Nov 2021 04:04:58 +0100 Subject: [PATCH 07/11] refactor: revamp effects. Revamp login, logout --- main/index.js | 39 ++++++++++-- main/redmine.js | 81 ++++++++++++++++--------- main/storage.js | 27 ++++----- render/helpers/utils.ts | 10 +--- render/store/effects/index.ts | 6 +- render/store/effects/mainProcess.ts | 37 ++++++++++++ render/store/effects/request.ts | 35 ----------- render/store/effects/storage.ts | 87 --------------------------- render/store/index.ts | 5 +- render/store/settings/actions.ts | 92 +++++++++++++++++++++-------- render/store/users/actions.ts | 29 ++++----- render/store/users/state.ts | 9 +-- types/index.ts | 14 ++++- 13 files changed, 234 insertions(+), 237 deletions(-) create mode 100644 render/store/effects/mainProcess.ts delete mode 100644 render/store/effects/request.ts delete mode 100644 render/store/effects/storage.ts diff --git a/main/index.js b/main/index.js index 791ab165..d1f56f57 100644 --- a/main/index.js +++ b/main/index.js @@ -349,19 +349,48 @@ const initialize = () => { generateMenu({ settings }); }); - storage.initializeStorageEvents(ipcMain); + 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) => { @@ -395,7 +424,7 @@ app.once('ready', () => { app.on('window-all-closed', () => { if (process.platform !== 'darwin') { - storage.disposeStorageEvents(ipcMain); + storage.disposeSessionEvents(ipcMain); app.quit(); } }); diff --git a/main/redmine.js b/main/redmine.js index c974d732..c4ecf0ac 100644 --- a/main/redmine.js +++ b/main/redmine.js @@ -1,38 +1,76 @@ const got = require('got'); const { transform } = require('./transformers/index'); -const createRequestClient = (initConfig = {}) => { - let isInitialized = false; +const createRequestClient = () => { let instance; + let isInitialized; - const initialize = (config) => { - isInitialized = Boolean(config.endpoint && config.token && config.token !== 'undefined'); - const configuration = config.token + const handleUnsuccessfulRequest = (error) => ({ + success: false, + error + }); + + const initialize = async (data) => { + if (isInitialized) { + reset(); + } + + const route = 'users/current.json'; + + const configuration = data.token ? { - prefixUrl: config.endpoint, - headers: { 'X-Redmine-API-Key': config.token }, + prefixUrl: data.endpoint, + headers: { 'X-Redmine-API-Key': data.token }, responseType: 'json' } : { - prefixUrl: config.endpoint, + prefixUrl: data.endpoint, + headers: { + Authorization: data.headers.Authorization + }, responseType: 'json' }; - instance = got.extend(configuration); - }; + try { + const response = await got(route, { + method: 'GET', + ...configuration + }); - const handleUnsuccessfulRequest = (error) => ({ - success: false, - error - }); + 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 = got; + instance = undefined; }; const send = async (data) => { - if (!instance && !data.headers?.Authorization) { + if (!isInitialized) { throw new Error('Http client is not initialized.'); } @@ -46,29 +84,18 @@ 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 { payload: transform(data.route, response.body), success: true, }; } - // prevents the instance from being stuck because some random value was set as a token - if (response.statusCode === 401 && isInitialized) { - reset(); - } - return handleUnsuccessfulRequest(response.body); } catch (error) { return handleUnsuccessfulRequest(error); } }; - initialize(initConfig); - return { initialize, isInitialized: () => isInitialized, diff --git a/main/storage.js b/main/storage.js index 0864924f..b893caca 100644 --- a/main/storage.js +++ b/main/storage.js @@ -90,14 +90,14 @@ const saveSession = (session) => { const eventHandlers = (event, message) => { const { action, payload, id } = JSON.parse(message); - console.log('Received storage event', message); + console.log('Received session event', message); switch (action) { case 'READ': { const session = getSession(payload.token); if (!session) { - event.reply(`storage:${id}`, { + event.reply(`session-response:${id}`, { success: false }); break; @@ -105,7 +105,7 @@ const eventHandlers = (event, message) => { const { hash, ...sessionWithoutHash } = session; - event.reply(`storage:${id}`, { + event.reply(`session-response:${id}`, { success: true, payload: sessionWithoutHash }); @@ -113,16 +113,11 @@ const eventHandlers = (event, message) => { } case 'SAVE': { saveSession(payload); - event.reply(`storage:${id}`, { success: true }); - break; - } - case 'RESET': { - resetActiveSession(); - event.reply(`storage:${id}`, { success: true }); + event.reply(`session-response:${id}`, { success: true }); break; } default: { - event.reply(`storage:${id}`, { + event.reply(`session-response:${id}`, { success: false, error: new Error('Unable to process the requested action', action) }); @@ -130,18 +125,18 @@ const eventHandlers = (event, message) => { } }; -const initializeStorageEvents = (ipcMain) => { - ipcMain.on('storage', eventHandlers); +const initializeSessionEvents = (ipcMain) => { + ipcMain.on('session-request', eventHandlers); }; -const disposeStorageEvents = (ipcMain) => { - ipcMain.off('storage', eventHandlers); +const disposeSessionEvents = (ipcMain) => { + ipcMain.off('session-request', eventHandlers); }; module.exports = { getActiveSession, getAllSettings, resetActiveSession, - initializeStorageEvents, - disposeStorageEvents + initializeSessionEvents, + disposeSessionEvents }; diff --git a/render/helpers/utils.ts b/render/helpers/utils.ts index c2aacbb8..747d68df 100644 --- a/render/helpers/utils.ts +++ b/render/helpers/utils.ts @@ -1,12 +1,4 @@ -const getStoredToken = () => { - const token = localStorage.getItem('token'); - - if (!token || token === 'undefined') { - return undefined; - } - - return token; -}; +const getStoredToken = () => localStorage.getItem('token'); export { getStoredToken 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 78d6817d..00000000 --- a/render/store/effects/storage.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { ipcRenderer, remote } from 'electron'; -import type { Context } from 'overmind'; -import { Response, StorageAction } from '../../../types'; -import { getStoredToken } from '../../helpers/utils'; - -const crypto = remote.require('crypto'); - -type SaveArgs = { - settings: Context['state']['settings']; - currentUser: Context['state']['users']['currentUser']; -}; - -const saveActiveSession = ({ settings, currentUser }: SaveArgs): Promise => { - const id = crypto.randomBytes(10).toString('hex'); - const { endpoint, ...appSettings } = settings; - - return new Promise((resolve) => { - ipcRenderer.send('storage', JSON.stringify({ - action: StorageAction.SAVE, - payload: { - user: { - id: currentUser?.id, - firstName: currentUser?.firstName, - lastName: currentUser?.lastName, - createdOn: currentUser?.createdOn - }, - endpoint, - token: getStoredToken(), - settings: { - ...appSettings - } - }, - id - })); - - ipcRenderer.once(`storage:${id}`, (event, response) => { - console.log('storage:save', response); - resolve(response); - }); - }); -}; - -const getSession = (token: string): Promise> => { - const id = crypto.randomBytes(10).toString('hex'); - - return new Promise((resolve) => { - ipcRenderer.send('storage', JSON.stringify({ action: StorageAction.READ, payload: { token }, id })); - - ipcRenderer.once(`storage:${id}`, (event, response) => { - console.log('storage:read', response); - const settings = response.success - ? { - endpoint: response.payload.endpoint, - ...response.payload.settings - } - : undefined; - - resolve({ - success: response.success, - error: response.error, - payload: settings - }); - }); - }); -}; - -const resetActiveSession = (): Promise => { - const id = crypto.randomBytes(10).toString('hex'); - - return new Promise((resolve) => { - ipcRenderer.send('storage', JSON.stringify({ action: StorageAction.RESET, id })); - - ipcRenderer.once(`storage:${id}`, (event, response) => { - if (response.success) { - localStorage.removeItem('token'); - } - console.log('storage:reset', response); - resolve(response); - }); - }); -}; - -export { - saveActiveSession, - getSession, - resetActiveSession -}; 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 42138cd5..7b640e8b 100644 --- a/render/store/settings/actions.ts +++ b/render/store/settings/actions.ts @@ -1,31 +1,72 @@ import type { IAction, Context } from 'overmind'; -import type { SettingsState } from './state'; +import { defaultSettingsState, SettingsState } from './state'; -import { Response } from '../../../types'; +import { Response, SessionAction, User } from '../../../types'; +import { getStoredToken } from '../../helpers/utils'; const update: IAction> = ({ state, effects }: Context, settings) => { state.settings = { ...settings }; - return effects.storage.saveActiveSession({ - settings, - currentUser: state.users.currentUser + 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, Promise<{ success: boolean }>> = async ({ state, effects }: Context, token) => { - const response = await effects.storage.getSession(token || localStorage.getItem('token') as string); + const response = await effects.mainProcess.session({ + action: SessionAction.READ, + payload: { + token: token || getStoredToken() + } + }); if (response.success && response.payload) { - const saveResponse = await effects.storage.saveActiveSession({ settings: response.payload, currentUser: state.users.currentUser }); + const currentUser = state.users.currentUser as User; + + const { endpoint, ...appSettings } = response.payload; + + 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: { + ...appSettings + } + } + }); if (!saveResponse.success) { return { success: false }; } state.settings = { - ...response.payload + endpoint: response.payload.endpoint, + ...response.payload.settings }; } @@ -33,22 +74,27 @@ const restore: IAction, Promise<{ success: boolean }>> = async ( }; const reset: IAction> = async ({ effects, state }: Context) => { - state.settings = { - showClosedIssues: false, - issueHeaders: [ - { label: 'Id', isFixed: true, value: 'id' }, - { label: 'Subject', isFixed: true, value: 'subject' }, - { label: 'Project', isFixed: false, value: 'project.name' }, - { label: 'Tracker', isFixed: false, value: 'tracker.name' }, - { label: 'Status', isFixed: false, value: 'status.name' }, - { label: 'Priority', isFixed: false, value: 'priority.name' }, - { label: 'Estimation', isFixed: false, value: 'estimated_hours' }, - { label: 'Due Date', isFixed: false, value: 'due_date' } - ], - endpoint: undefined - }; + state.settings = { ...defaultSettingsState }; + + const { endpoint, ...appSettings } = defaultSettingsState; + const currentUser = state.users.currentUser as User; - return effects.storage.resetActiveSession(); + 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 + } + } + }); }; export { diff --git a/render/store/users/actions.ts b/render/store/users/actions.ts index 60c864df..905524b3 100644 --- a/render/store/users/actions.ts +++ b/render/store/users/actions.ts @@ -26,12 +26,10 @@ const login: IAction> = async ({ actions, ef Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}` }; - const loginResponse = await effects.request.query({ + const loginResponse = await effects.mainProcess.system({ + action: 'login', payload: { headers, - route: 'users/current.json', - }, - config: { endpoint: redmineEndpoint, token } @@ -51,26 +49,19 @@ const login: IAction> = async ({ actions, ef return loginResponse; }; -const logout: IAction> = async ({ state, actions }: Context) => { - const response = await actions.settings.reset(); +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; } }; -const onInitializeOvermind: IAction = ({ effects }: Context, overmind) => { - overmind.reaction( - (state) => state.users.currentUser, - async (user) => { - if (user === undefined) { - await effects.storage.resetActiveSession(); - } - } - ); -}; - export { login, - logout, - onInitializeOvermind + logout }; diff --git a/render/store/users/state.ts b/render/store/users/state.ts index e202693f..33837090 100644 --- a/render/store/users/state.ts +++ b/render/store/users/state.ts @@ -1,10 +1,7 @@ +import type { User } from '../../../types'; + type UserState = { - currentUser?: { - id: string; - firstName: string; - lastName: string; - createdOn: string; - } + currentUser?: User } const state: UserState = { diff --git a/types/index.ts b/types/index.ts index 169ea807..2d2a6a04 100644 --- a/types/index.ts +++ b/types/index.ts @@ -21,18 +21,26 @@ type Response = { } // eslint-disable-next-line no-shadow -enum StorageAction { +enum SessionAction { READ = 'READ', SAVE = 'SAVE', RESET = 'RESET' } +type User = { + id: string; + firstName: string; + lastName: string; + createdOn: string; +} + export { - StorageAction + SessionAction }; export type { IssueHeader, CreateOvermindConfigParams, - Response + Response, + User }; From 15baeaff558ee81fdd528e68eee835878ab1a4e6 Mon Sep 17 00:00:00 2001 From: Daniel Vasylenko Date: Sat, 6 Nov 2021 04:49:11 +0100 Subject: [PATCH 08/11] feat: redirect to the main window if already logged in --- main/storage.js | 4 +++- render/App.jsx | 29 ++++++++++++++++++++++++++--- render/store/settings/actions.ts | 24 ++++++++++++------------ render/store/users/actions.ts | 2 +- 4 files changed, 42 insertions(+), 17 deletions(-) diff --git a/main/storage.js b/main/storage.js index b893caca..fd2406ab 100644 --- a/main/storage.js +++ b/main/storage.js @@ -5,12 +5,14 @@ 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, 'utf8').digest('hex'); +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)); diff --git a/render/App.jsx b/render/App.jsx index 0fb67e07..582912b7 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,8 @@ 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'; toast.configure({ autoClose: 3000, @@ -22,6 +23,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,6 +77,23 @@ const Routes = ({ dispatch }) => { }; }, [handleError, handleRejection, settingsEventHandler]); + useEffect(() => { + const restoreLastSession = async () => { + const response = await actions.settings.restore(getStoredToken()); + if (response.success) { + history.push('/app', { replace: true }); + } + + setIsReady(true); + }; + + restoreLastSession(); + }, []); + + if (!isReady) { + return

Loading...

; + } + return ( diff --git a/render/store/settings/actions.ts b/render/store/settings/actions.ts index 7b640e8b..ec829a68 100644 --- a/render/store/settings/actions.ts +++ b/render/store/settings/actions.ts @@ -30,33 +30,31 @@ const update: IAction> = ({ state, effects }: C }); }; -const restore: IAction, Promise<{ success: boolean }>> = async ({ state, effects }: Context, token) => { +const restore: IAction> = async ({ state, effects }: Context, token) => { const response = await effects.mainProcess.session({ action: SessionAction.READ, payload: { - token: token || getStoredToken() + token } }); if (response.success && response.payload) { - const currentUser = state.users.currentUser as User; - - const { endpoint, ...appSettings } = 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 + id: currentUser.id, + firstName: currentUser.firstName, + lastName: currentUser.lastName, + createdOn: currentUser.createdOn }, endpoint, token: getStoredToken(), - settings: { - ...appSettings - } + settings } }); @@ -64,6 +62,8 @@ const restore: IAction, Promise<{ success: boolean }>> = async ( return { success: false }; } + state.users.currentUser = { ...currentUser }; + state.settings = { endpoint: response.payload.endpoint, ...response.payload.settings diff --git a/render/store/users/actions.ts b/render/store/users/actions.ts index 905524b3..5e58eebe 100644 --- a/render/store/users/actions.ts +++ b/render/store/users/actions.ts @@ -42,7 +42,7 @@ const login: IAction> = async ({ actions, ef const restoreResponse = await actions.settings.restore(loginResponse.payload.token); if (!restoreResponse.success) { - await actions.settings.update({ ...defaultSettingsState }); + await actions.settings.update({ ...defaultSettingsState, endpoint: redmineEndpoint }); } } From d38bb17caff648a4f1ad41bd2d3281f8f94f26d9 Mon Sep 17 00:00:00 2001 From: Daniel Vasylenko Date: Sun, 7 Nov 2021 00:13:05 +0100 Subject: [PATCH 09/11] fix: fix redirect link to app view main page --- render/App.jsx | 4 ++-- render/components/Navbar.tsx | 4 ++-- render/views/AppView.tsx | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/render/App.jsx b/render/App.jsx index 582912b7..93ae9662 100644 --- a/render/App.jsx +++ b/render/App.jsx @@ -81,7 +81,7 @@ const Routes = ({ dispatch }) => { const restoreLastSession = async () => { const response = await actions.settings.restore(getStoredToken()); if (response.success) { - history.push('/app', { replace: true }); + history.replace('/app'); } setIsReady(true); @@ -97,7 +97,7 @@ const Routes = ({ dispatch }) => { return ( - } /> + } /> ); }; diff --git a/render/components/Navbar.tsx b/render/components/Navbar.tsx index 243edf2f..6c4b89ed 100644 --- a/render/components/Navbar.tsx +++ b/render/components/Navbar.tsx @@ -88,13 +88,13 @@ const Navbar = () => {
  • - Summary + Summary
  • {/*
  • Issues
  • */}
  • - {userName} + {userName}
  • diff --git a/render/views/AppView.tsx b/render/views/AppView.tsx index 728c5ed3..97f6a365 100644 --- a/render/views/AppView.tsx +++ b/render/views/AppView.tsx @@ -90,7 +90,7 @@ const AppView = ({ {!state.users.currentUser && ()} - } /> + } /> } /> Date: Sun, 7 Nov 2021 03:45:53 +0100 Subject: [PATCH 10/11] feat: initializing overlay. Fix redirect route after login --- .eslintrc.json | 2 +- package.json | 3 + render/App.jsx | 3 +- render/components/LoadingOverlay.tsx | 35 ++++++++ ...cessIndicator.jsx => ProcessIndicator.tsx} | 44 ++++------ render/store/settings/actions.ts | 4 +- .../views/AppViewPages/IssueDetailsPage.tsx | 6 +- render/views/LoginView.tsx | 2 +- tsconfig.json | 2 +- yarn.lock | 86 ++++++++++++++++++- 10 files changed, 151 insertions(+), 36 deletions(-) create mode 100644 render/components/LoadingOverlay.tsx rename render/components/{ProcessIndicator.jsx => ProcessIndicator.tsx} (51%) 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/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 93ae9662..0cac44c4 100644 --- a/render/App.jsx +++ b/render/App.jsx @@ -13,6 +13,7 @@ import LoginView from './views/LoginView'; import Notification from './components/Notification'; import { useOvermindActions } from './store'; import { getStoredToken } from './helpers/utils'; +import { LoadingOverlay } from './components/LoadingOverlay'; toast.configure({ autoClose: 3000, @@ -91,7 +92,7 @@ const Routes = ({ dispatch }) => { }, []); if (!isReady) { - return

    Loading...

    ; + return ; } return ( diff --git a/render/components/LoadingOverlay.tsx b/render/components/LoadingOverlay.tsx new file mode 100644 index 00000000..71835304 --- /dev/null +++ b/render/components/LoadingOverlay.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { css } from '@emotion/react'; +import { ProcessIndicator } from './ProcessIndicator'; + +// span { +// white-space: nowrap; +// padding-left: 20px; +// vertical-align: middle; +// } + +// eslint-disable-next-line arrow-body-style + +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/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/store/settings/actions.ts b/render/store/settings/actions.ts index ec829a68..f8f8c340 100644 --- a/render/store/settings/actions.ts +++ b/render/store/settings/actions.ts @@ -39,7 +39,7 @@ const restore: IAction> = async ({ state, }); if (response.success && response.payload) { - const { endpoint, ...settings } = response.payload; + const { endpoint, settings } = response.payload; const currentUser = response.payload.user as User; // replacing the active session @@ -66,7 +66,7 @@ const restore: IAction> = async ({ state, state.settings = { endpoint: response.payload.endpoint, - ...response.payload.settings + ...settings }; } 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/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/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" From 7e45e2499e6106992444929586dac01fe0a44a55 Mon Sep 17 00:00:00 2001 From: Daniel Vasylenko Date: Sun, 7 Nov 2021 03:46:51 +0100 Subject: [PATCH 11/11] refactor: update process indicator --- .../IssueDetailsPage/TimeEntries.jsx | 32 ++++++++++++------- render/components/IssueModal.jsx | 13 ++++++-- render/components/LoadingOverlay.tsx | 8 ----- render/components/SummaryPage/IssuesTable.jsx | 17 ++++++++-- render/components/TimeEntryModal.jsx | 15 +++++++-- 5 files changed, 58 insertions(+), 27 deletions(-) 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 index 71835304..001a61a7 100644 --- a/render/components/LoadingOverlay.tsx +++ b/render/components/LoadingOverlay.tsx @@ -2,14 +2,6 @@ import React from 'react'; import { css } from '@emotion/react'; import { ProcessIndicator } from './ProcessIndicator'; -// span { -// white-space: nowrap; -// padding-left: 20px; -// vertical-align: middle; -// } - -// eslint-disable-next-line arrow-body-style - const styles = { wrapper: css` display: flex; 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...) } )}
    - + + Please wait... +