From e6bff3f2e0fa0189143070a8947516421bb498e2 Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Sat, 4 Sep 2021 12:37:22 +0100 Subject: [PATCH] Chore: Moved desktop app reducer to separate file so that it can be unit tested --- .eslintignore | 6 + .gitignore | 6 + packages/app-desktop/app.reducer.test.ts | 47 ++++ packages/app-desktop/app.reducer.ts | 283 +++++++++++++++++++++++ packages/app-desktop/app.ts | 273 +--------------------- 5 files changed, 349 insertions(+), 266 deletions(-) create mode 100644 packages/app-desktop/app.reducer.test.ts create mode 100644 packages/app-desktop/app.reducer.ts diff --git a/.eslintignore b/.eslintignore index 74f1f559b..1b1440e6c 100644 --- a/.eslintignore +++ b/.eslintignore @@ -117,6 +117,12 @@ packages/app-desktop/InteropServiceHelper.js.map packages/app-desktop/app.d.ts packages/app-desktop/app.js packages/app-desktop/app.js.map +packages/app-desktop/app.reducer.d.ts +packages/app-desktop/app.reducer.js +packages/app-desktop/app.reducer.js.map +packages/app-desktop/app.reducer.test.d.ts +packages/app-desktop/app.reducer.test.js +packages/app-desktop/app.reducer.test.js.map packages/app-desktop/bridge.d.ts packages/app-desktop/bridge.js packages/app-desktop/bridge.js.map diff --git a/.gitignore b/.gitignore index 07fe7e47f..a2a04558e 100644 --- a/.gitignore +++ b/.gitignore @@ -103,6 +103,12 @@ packages/app-desktop/InteropServiceHelper.js.map packages/app-desktop/app.d.ts packages/app-desktop/app.js packages/app-desktop/app.js.map +packages/app-desktop/app.reducer.d.ts +packages/app-desktop/app.reducer.js +packages/app-desktop/app.reducer.js.map +packages/app-desktop/app.reducer.test.d.ts +packages/app-desktop/app.reducer.test.js +packages/app-desktop/app.reducer.test.js.map packages/app-desktop/bridge.d.ts packages/app-desktop/bridge.js packages/app-desktop/bridge.js.map diff --git a/packages/app-desktop/app.reducer.test.ts b/packages/app-desktop/app.reducer.test.ts new file mode 100644 index 000000000..594f819d8 --- /dev/null +++ b/packages/app-desktop/app.reducer.test.ts @@ -0,0 +1,47 @@ +import { AppState } from './app'; +import appReducer, { createAppDefaultState } from './app.reducer'; + +describe('app.reducer', function() { + + it('DIALOG_OPEN', async () => { + const state: AppState = createAppDefaultState({}, {}); + + let newState = appReducer(state, { + type: 'DIALOG_OPEN', + name: 'syncWizard', + }); + + expect(newState.dialogs.length).toBe(1); + expect(newState.dialogs[0].name).toBe('syncWizard'); + + expect(() => appReducer(newState, { + type: 'DIALOG_OPEN', + name: 'syncWizard', + })).toThrow(); + + newState = appReducer(newState, { + type: 'DIALOG_CLOSE', + name: 'syncWizard', + }); + + expect(newState.dialogs.length).toBe(0); + + expect(() => appReducer(newState, { + type: 'DIALOG_CLOSE', + name: 'syncWizard', + })).toThrow(); + + newState = appReducer(newState, { + type: 'DIALOG_OPEN', + name: 'syncWizard', + }); + + newState = appReducer(newState, { + type: 'DIALOG_OPEN', + name: 'setPassword', + }); + + expect(newState.dialogs).toEqual([{ name: 'syncWizard' }, { name: 'setPassword' }]); + }); + +}); diff --git a/packages/app-desktop/app.reducer.ts b/packages/app-desktop/app.reducer.ts new file mode 100644 index 000000000..96633bcb9 --- /dev/null +++ b/packages/app-desktop/app.reducer.ts @@ -0,0 +1,283 @@ +import produce from 'immer'; +import Setting from '@joplin/lib/models/Setting'; +import { defaultState } from '@joplin/lib/reducer'; +import { AppState } from './app'; +import iterateItems from './gui/ResizableLayout/utils/iterateItems'; +import { LayoutItem } from './gui/ResizableLayout/utils/types'; +import validateLayout from './gui/ResizableLayout/utils/validateLayout'; + +export function createAppDefaultState(windowContentSize: any, resourceEditWatcherDefaultState: any): AppState { + return { + ...defaultState, + route: { + type: 'NAV_GO', + routeName: 'Main', + props: {}, + }, + navHistory: [], + noteVisiblePanes: ['editor', 'viewer'], + windowContentSize, // bridge().windowContentSize(), + watchedNoteFiles: [], + lastEditorScrollPercents: {}, + devToolsVisible: false, + visibleDialogs: {}, // empty object if no dialog is visible. Otherwise contains the list of visible dialogs. + focusedField: null, + layoutMoveMode: false, + mainLayout: null, + startupPluginsLoaded: false, + dialogs: [], + ...resourceEditWatcherDefaultState, + }; +} + +export default function(state: AppState, action: any) { + let newState = state; + + try { + switch (action.type) { + + case 'NAV_BACK': + case 'NAV_GO': + + { + const goingBack = action.type === 'NAV_BACK'; + + if (goingBack && !state.navHistory.length) break; + + const currentRoute = state.route; + + newState = Object.assign({}, state); + const newNavHistory = state.navHistory.slice(); + + if (goingBack) { + let newAction = null; + while (newNavHistory.length) { + newAction = newNavHistory.pop(); + if (newAction.routeName !== state.route.routeName) break; + } + + if (!newAction) break; + + action = newAction; + } + + if (!goingBack) newNavHistory.push(currentRoute); + newState.navHistory = newNavHistory; + newState.route = action; + } + break; + + case 'STARTUP_PLUGINS_LOADED': + + // When all startup plugins have loaded, we also recreate the + // main layout to ensure that it is updated in the UI. There's + // probably a cleaner way to do this, but for now that will do. + if (state.startupPluginsLoaded !== action.value) { + newState = { + ...newState, + startupPluginsLoaded: action.value, + mainLayout: JSON.parse(JSON.stringify(newState.mainLayout)), + }; + } + break; + + case 'WINDOW_CONTENT_SIZE_SET': + + newState = Object.assign({}, state); + newState.windowContentSize = action.size; + break; + + case 'NOTE_VISIBLE_PANES_TOGGLE': + + { + const getNextLayout = (currentLayout: any) => { + currentLayout = panes.length === 2 ? 'both' : currentLayout[0]; + + let paneOptions; + if (state.settings.layoutButtonSequence === Setting.LAYOUT_EDITOR_VIEWER) { + paneOptions = ['editor', 'viewer']; + } else if (state.settings.layoutButtonSequence === Setting.LAYOUT_EDITOR_SPLIT) { + paneOptions = ['editor', 'both']; + } else if (state.settings.layoutButtonSequence === Setting.LAYOUT_VIEWER_SPLIT) { + paneOptions = ['viewer', 'both']; + } else { + paneOptions = ['editor', 'viewer', 'both']; + } + + const currentLayoutIndex = paneOptions.indexOf(currentLayout); + const nextLayoutIndex = currentLayoutIndex === paneOptions.length - 1 ? 0 : currentLayoutIndex + 1; + + const nextLayout = paneOptions[nextLayoutIndex]; + return nextLayout === 'both' ? ['editor', 'viewer'] : [nextLayout]; + }; + + newState = Object.assign({}, state); + + const panes = state.noteVisiblePanes.slice(); + newState.noteVisiblePanes = getNextLayout(panes); + } + break; + + case 'NOTE_VISIBLE_PANES_SET': + + newState = Object.assign({}, state); + newState.noteVisiblePanes = action.panes; + break; + + case 'MAIN_LAYOUT_SET': + + newState = { + ...state, + mainLayout: action.value, + }; + break; + + case 'MAIN_LAYOUT_SET_ITEM_PROP': + + { + let newLayout = produce(state.mainLayout, (draftLayout: LayoutItem) => { + iterateItems(draftLayout, (_itemIndex: number, item: LayoutItem, _parent: LayoutItem) => { + if (item.key === action.itemKey) { + (item as any)[action.propName] = action.propValue; + return false; + } + return true; + }); + }); + + if (newLayout !== state.mainLayout) newLayout = validateLayout(newLayout); + + newState = { + ...state, + mainLayout: newLayout, + }; + } + + break; + + case 'NOTE_FILE_WATCHER_ADD': + + if (newState.watchedNoteFiles.indexOf(action.id) < 0) { + newState = Object.assign({}, state); + const watchedNoteFiles = newState.watchedNoteFiles.slice(); + watchedNoteFiles.push(action.id); + newState.watchedNoteFiles = watchedNoteFiles; + } + break; + + case 'NOTE_FILE_WATCHER_REMOVE': + + { + newState = Object.assign({}, state); + const idx = newState.watchedNoteFiles.indexOf(action.id); + if (idx >= 0) { + const watchedNoteFiles = newState.watchedNoteFiles.slice(); + watchedNoteFiles.splice(idx, 1); + newState.watchedNoteFiles = watchedNoteFiles; + } + } + break; + + case 'NOTE_FILE_WATCHER_CLEAR': + + if (state.watchedNoteFiles.length) { + newState = Object.assign({}, state); + newState.watchedNoteFiles = []; + } + break; + + case 'EDITOR_SCROLL_PERCENT_SET': + + { + newState = Object.assign({}, state); + const newPercents = Object.assign({}, newState.lastEditorScrollPercents); + newPercents[action.noteId] = action.percent; + newState.lastEditorScrollPercents = newPercents; + } + break; + + case 'NOTE_DEVTOOLS_TOGGLE': + newState = Object.assign({}, state); + newState.devToolsVisible = !newState.devToolsVisible; + break; + + case 'NOTE_DEVTOOLS_SET': + newState = Object.assign({}, state); + newState.devToolsVisible = action.value; + break; + + case 'VISIBLE_DIALOGS_ADD': + newState = Object.assign({}, state); + newState.visibleDialogs = Object.assign({}, newState.visibleDialogs); + newState.visibleDialogs[action.name] = true; + break; + + case 'VISIBLE_DIALOGS_REMOVE': + newState = Object.assign({}, state); + newState.visibleDialogs = Object.assign({}, newState.visibleDialogs); + delete newState.visibleDialogs[action.name]; + break; + + case 'FOCUS_SET': + + newState = Object.assign({}, state); + newState.focusedField = action.field; + break; + + case 'FOCUS_CLEAR': + + // A field can only clear its own state + if (action.field === state.focusedField) { + newState = Object.assign({}, state); + newState.focusedField = null; + } + break; + + case 'DIALOG_OPEN': + case 'DIALOG_CLOSE': + + { + let isOpen = true; + + if (action.type === 'DIALOG_CLOSE') { + isOpen = false; + } else { // DIALOG_OPEN + isOpen = action.isOpen !== false; + } + + newState = Object.assign({}, state); + + if (isOpen) { + const newDialogs = newState.dialogs.slice(); + + if (newDialogs.find(d => d.name === action.name)) throw new Error(`Trying to open a dialog is already open: ${action.name}`); + + newDialogs.push({ + name: action.name, + }); + + newState.dialogs = newDialogs; + } else { + if (!newState.dialogs.find(d => d.name === action.name)) throw new Error(`Trying to close a dialog that is not open: ${action.name}`); + const newDialogs = newState.dialogs.slice().filter(d => d.name !== action.name); + newState.dialogs = newDialogs; + } + } + break; + + case 'LAYOUT_MOVE_MODE_SET': + + newState = { + ...state, + layoutMoveMode: action.value, + }; + break; + + } + } catch (error) { + error.message = `In reducer: ${error.message} Action: ${JSON.stringify(action)}`; + throw error; + } + + return newState; +} diff --git a/packages/app-desktop/app.ts b/packages/app-desktop/app.ts index 59976939d..081283b15 100644 --- a/packages/app-desktop/app.ts +++ b/packages/app-desktop/app.ts @@ -3,7 +3,7 @@ import CommandService from '@joplin/lib/services/CommandService'; import KeymapService from '@joplin/lib/services/KeymapService'; import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService'; import resourceEditWatcherReducer, { defaultState as resourceEditWatcherDefaultState } from '@joplin/lib/services/ResourceEditWatcher/reducer'; -import { defaultState, State } from '@joplin/lib/reducer'; +import { State } from '@joplin/lib/reducer'; import PluginRunner from './services/plugins/PluginRunner'; import PlatformImplementation from './services/plugins/PlatformImplementation'; import shim from '@joplin/lib/shim'; @@ -23,9 +23,7 @@ import { LayoutItem } from './gui/ResizableLayout/utils/types'; import stateToWhenClauseContext from './services/commands/stateToWhenClauseContext'; import ResourceService from '@joplin/lib/services/ResourceService'; import ExternalEditWatcher from '@joplin/lib/services/ExternalEditWatcher'; -import produce from 'immer'; -import iterateItems from './gui/ResizableLayout/utils/iterateItems'; -import validateLayout from './gui/ResizableLayout/utils/validateLayout'; +import appReducer, { createAppDefaultState } from './app.reducer'; const { FoldersScreenUtils } = require('@joplin/lib/folders-screen-utils.js'); import Folder from '@joplin/lib/models/Folder'; const fs = require('fs-extra'); @@ -138,27 +136,10 @@ export interface AppState extends State { dialogs: AppStateDialog[]; } -const appDefaultState: AppState = { - ...defaultState, - route: { - type: 'NAV_GO', - routeName: 'Main', - props: {}, - }, - navHistory: [], - noteVisiblePanes: ['editor', 'viewer'], - windowContentSize: bridge().windowContentSize(), - watchedNoteFiles: [], - lastEditorScrollPercents: {}, - devToolsVisible: false, - visibleDialogs: {}, // empty object if no dialog is visible. Otherwise contains the list of visible dialogs. - focusedField: null, - layoutMoveMode: false, - mainLayout: null, - startupPluginsLoaded: false, - dialogs: [], - ...resourceEditWatcherDefaultState, -}; +const appDefaultState = createAppDefaultState( + bridge().windowContentSize(), + resourceEditWatcherDefaultState +); class Application extends BaseApplication { @@ -175,249 +156,9 @@ class Application extends BaseApplication { } reducer(state: AppState = appDefaultState, action: any) { - let newState = state; - - try { - switch (action.type) { - - case 'NAV_BACK': - case 'NAV_GO': - - { - const goingBack = action.type === 'NAV_BACK'; - - if (goingBack && !state.navHistory.length) break; - - const currentRoute = state.route; - - newState = Object.assign({}, state); - const newNavHistory = state.navHistory.slice(); - - if (goingBack) { - let newAction = null; - while (newNavHistory.length) { - newAction = newNavHistory.pop(); - if (newAction.routeName !== state.route.routeName) break; - } - - if (!newAction) break; - - action = newAction; - } - - if (!goingBack) newNavHistory.push(currentRoute); - newState.navHistory = newNavHistory; - newState.route = action; - } - break; - - case 'STARTUP_PLUGINS_LOADED': - - // When all startup plugins have loaded, we also recreate the - // main layout to ensure that it is updated in the UI. There's - // probably a cleaner way to do this, but for now that will do. - if (state.startupPluginsLoaded !== action.value) { - newState = { - ...newState, - startupPluginsLoaded: action.value, - mainLayout: JSON.parse(JSON.stringify(newState.mainLayout)), - }; - } - break; - - case 'WINDOW_CONTENT_SIZE_SET': - - newState = Object.assign({}, state); - newState.windowContentSize = action.size; - break; - - case 'NOTE_VISIBLE_PANES_TOGGLE': - - { - const getNextLayout = (currentLayout: any) => { - currentLayout = panes.length === 2 ? 'both' : currentLayout[0]; - - let paneOptions; - if (state.settings.layoutButtonSequence === Setting.LAYOUT_EDITOR_VIEWER) { - paneOptions = ['editor', 'viewer']; - } else if (state.settings.layoutButtonSequence === Setting.LAYOUT_EDITOR_SPLIT) { - paneOptions = ['editor', 'both']; - } else if (state.settings.layoutButtonSequence === Setting.LAYOUT_VIEWER_SPLIT) { - paneOptions = ['viewer', 'both']; - } else { - paneOptions = ['editor', 'viewer', 'both']; - } - - const currentLayoutIndex = paneOptions.indexOf(currentLayout); - const nextLayoutIndex = currentLayoutIndex === paneOptions.length - 1 ? 0 : currentLayoutIndex + 1; - - const nextLayout = paneOptions[nextLayoutIndex]; - return nextLayout === 'both' ? ['editor', 'viewer'] : [nextLayout]; - }; - - newState = Object.assign({}, state); - - const panes = state.noteVisiblePanes.slice(); - newState.noteVisiblePanes = getNextLayout(panes); - } - break; - - case 'NOTE_VISIBLE_PANES_SET': - - newState = Object.assign({}, state); - newState.noteVisiblePanes = action.panes; - break; - - case 'MAIN_LAYOUT_SET': - - newState = { - ...state, - mainLayout: action.value, - }; - break; - - case 'MAIN_LAYOUT_SET_ITEM_PROP': - - { - let newLayout = produce(state.mainLayout, (draftLayout: LayoutItem) => { - iterateItems(draftLayout, (_itemIndex: number, item: LayoutItem, _parent: LayoutItem) => { - if (item.key === action.itemKey) { - (item as any)[action.propName] = action.propValue; - return false; - } - return true; - }); - }); - - if (newLayout !== state.mainLayout) newLayout = validateLayout(newLayout); - - newState = { - ...state, - mainLayout: newLayout, - }; - } - - break; - - case 'NOTE_FILE_WATCHER_ADD': - - if (newState.watchedNoteFiles.indexOf(action.id) < 0) { - newState = Object.assign({}, state); - const watchedNoteFiles = newState.watchedNoteFiles.slice(); - watchedNoteFiles.push(action.id); - newState.watchedNoteFiles = watchedNoteFiles; - } - break; - - case 'NOTE_FILE_WATCHER_REMOVE': - - { - newState = Object.assign({}, state); - const idx = newState.watchedNoteFiles.indexOf(action.id); - if (idx >= 0) { - const watchedNoteFiles = newState.watchedNoteFiles.slice(); - watchedNoteFiles.splice(idx, 1); - newState.watchedNoteFiles = watchedNoteFiles; - } - } - break; - - case 'NOTE_FILE_WATCHER_CLEAR': - - if (state.watchedNoteFiles.length) { - newState = Object.assign({}, state); - newState.watchedNoteFiles = []; - } - break; - - case 'EDITOR_SCROLL_PERCENT_SET': - - { - newState = Object.assign({}, state); - const newPercents = Object.assign({}, newState.lastEditorScrollPercents); - newPercents[action.noteId] = action.percent; - newState.lastEditorScrollPercents = newPercents; - } - break; - - case 'NOTE_DEVTOOLS_TOGGLE': - newState = Object.assign({}, state); - newState.devToolsVisible = !newState.devToolsVisible; - break; - - case 'NOTE_DEVTOOLS_SET': - newState = Object.assign({}, state); - newState.devToolsVisible = action.value; - break; - - case 'VISIBLE_DIALOGS_ADD': - newState = Object.assign({}, state); - newState.visibleDialogs = Object.assign({}, newState.visibleDialogs); - newState.visibleDialogs[action.name] = true; - break; - - case 'VISIBLE_DIALOGS_REMOVE': - newState = Object.assign({}, state); - newState.visibleDialogs = Object.assign({}, newState.visibleDialogs); - delete newState.visibleDialogs[action.name]; - break; - - case 'FOCUS_SET': - - newState = Object.assign({}, state); - newState.focusedField = action.field; - break; - - case 'FOCUS_CLEAR': - - // A field can only clear its own state - if (action.field === state.focusedField) { - newState = Object.assign({}, state); - newState.focusedField = null; - } - break; - - case 'DIALOG_OPEN': - - { - newState = Object.assign({}, state); - const newDialogs = newState.dialogs.slice(); - - if (newDialogs.find(d => d.name === action.name)) throw new Error(`This dialog is already opened: ${action.name}`); - - newDialogs.push({ - name: action.name, - }); - newState.dialogs = newDialogs; - } - break; - - case 'DIALOG_CLOSE': - - { - newState = Object.assign({}, state); - const newDialogs = newState.dialogs.slice().filter(d => d.name !== action.name); - newState.dialogs = newDialogs; - } - break; - - case 'LAYOUT_MOVE_MODE_SET': - - newState = { - ...state, - layoutMoveMode: action.value, - }; - break; - - } - } catch (error) { - error.message = `In reducer: ${error.message} Action: ${JSON.stringify(action)}`; - throw error; - } - + let newState = appReducer(state, action); newState = resourceEditWatcherReducer(newState, action); newState = super.reducer(newState, action); - return newState; }