diff --git a/.eslintignore b/.eslintignore index 64e753d9e7..7750b89de4 100644 --- a/.eslintignore +++ b/.eslintignore @@ -924,6 +924,7 @@ packages/app-mobile/utils/ShareUtils.test.js packages/app-mobile/utils/ShareUtils.js packages/app-mobile/utils/TlsUtils.js packages/app-mobile/utils/appDefaultState.js +packages/app-mobile/utils/appReducer.js packages/app-mobile/utils/autodetectTheme.js packages/app-mobile/utils/buildStartupTasks.js packages/app-mobile/utils/checkPermissions.js diff --git a/.gitignore b/.gitignore index 5011cc88c2..8b9f8ff8d7 100644 --- a/.gitignore +++ b/.gitignore @@ -897,6 +897,7 @@ packages/app-mobile/utils/ShareUtils.test.js packages/app-mobile/utils/ShareUtils.js packages/app-mobile/utils/TlsUtils.js packages/app-mobile/utils/appDefaultState.js +packages/app-mobile/utils/appReducer.js packages/app-mobile/utils/autodetectTheme.js packages/app-mobile/utils/buildStartupTasks.js packages/app-mobile/utils/checkPermissions.js diff --git a/packages/app-mobile/components/plugins/PluginRunnerWebView.test.tsx b/packages/app-mobile/components/plugins/PluginRunnerWebView.test.tsx index b033f26e70..05a757b11b 100644 --- a/packages/app-mobile/components/plugins/PluginRunnerWebView.test.tsx +++ b/packages/app-mobile/components/plugins/PluginRunnerWebView.test.tsx @@ -6,11 +6,12 @@ import createMockReduxStore from '../../utils/testing/createMockReduxStore'; import setupGlobalStore from '../../utils/testing/setupGlobalStore'; import PluginRunnerWebView from './PluginRunnerWebView'; import TestProviderStack from '../testing/TestProviderStack'; -import { render, waitFor } from '../../utils/testing/testingLibrary'; +import { act, render, screen, waitFor } from '../../utils/testing/testingLibrary'; import createTestPlugin from '@joplin/lib/testing/plugins/createTestPlugin'; import getWebViewDomById from '../../utils/testing/getWebViewDomById'; import Setting from '@joplin/lib/models/Setting'; import PluginService from '@joplin/lib/services/plugins/PluginService'; +import CommandService from '@joplin/lib/services/CommandService'; let store: Store; @@ -30,6 +31,16 @@ const defaultManifestProperties = { name: 'Some plugin name', }; +type PluginSlice = { manifest: { id: string } }; +const waitForPluginToLoad = (plugin: PluginSlice) => { + return waitFor(async () => { + expect(PluginService.instance().pluginById(plugin.manifest.id)).toBeTruthy(); + }); +}; + +const webViewId = 'joplin__PluginDialogWebView'; +const getUserWebViewDom = () => getWebViewDomById(webViewId); + describe('PluginRunnerWebView', () => { beforeEach(async () => { await setupDatabaseAndSynchronizer(0); @@ -56,16 +67,68 @@ describe('PluginRunnerWebView', () => { `, }); render(); - - // Should load the plugin - await waitFor(async () => { - expect(PluginService.instance().pluginById(testPlugin.manifest.id)).toBeTruthy(); - }); + await waitForPluginToLoad(testPlugin); // Should show the dialog await waitFor(async () => { - const dom = await getWebViewDomById('joplin__PluginDialogWebView'); + const dom = await getUserWebViewDom(); expect(dom.querySelector('h1').textContent).toBe('Test!'); }); }); + + test('should load a plugin that adds a panel', async () => { + const testPlugin = await createTestPlugin({ + ...defaultManifestProperties, + id: 'org.joplinapp.panel-test', + }, { + onStart: ` + const panels = joplin.views.panels; + const handle = await panels.create('test-panel'); + await panels.setHtml( + handle, + '

Panel content

Test

', + ); + + const commands = joplin.commands; + await commands.register({ + name: 'hideTestPanel', + label: 'Hide the test plugin panel', + execute: async () => { + await panels.hide(handle); + }, + }); + + await commands.register({ + name: 'showTestPanel', + execute: async () => { + await panels.show(handle); + }, + }); + `, + }); + render(); + await waitForPluginToLoad(testPlugin); + + act(() => { + store.dispatch({ type: 'SET_PLUGIN_PANELS_DIALOG_VISIBLE', visible: true }); + }); + + const expectPanelVisible = async () => { + const dom = await getUserWebViewDom(); + await waitFor(async () => { + expect(dom.querySelector('h1').textContent).toBe('Panel content'); + }); + }; + await expectPanelVisible(); + + // Should hide the panel + await act(() => CommandService.instance().execute('hideTestPanel')); + await waitFor(() => { + expect(screen.queryByTestId('webViewId')).toBeNull(); + }); + + // Should show the panel again + await act(() => CommandService.instance().execute('showTestPanel')); + await expectPanelVisible(); + }); }); diff --git a/packages/app-mobile/components/plugins/dialogs/PluginPanelViewer.tsx b/packages/app-mobile/components/plugins/dialogs/PluginPanelViewer.tsx index d0a9756d26..fdb7d728f6 100644 --- a/packages/app-mobile/components/plugins/dialogs/PluginPanelViewer.tsx +++ b/packages/app-mobile/components/plugins/dialogs/PluginPanelViewer.tsx @@ -120,7 +120,7 @@ const PluginPanelViewer: React.FC = props => { } return ( - + (next: any) => async (action: any) => return result; }; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied -const navHistory: any[] = []; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied -function historyCanGoBackTo(route: any) { - if (route.routeName === 'Folder') return false; - - // This is an intermediate screen that acts more like a modal -- it should be skipped in the - // navigation history. - if (route.routeName === 'DocumentScanner') return false; - - // There's no point going back to these screens in general and, at least in OneDrive case, - // it can be buggy to do so, due to incorrectly relying on global state (reg.syncTarget...) - if (route.routeName === 'OneDriveLogin') return false; - if (route.routeName === 'DropboxLogin') return false; - - return true; -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied -const appReducer = (state = appDefaultState, action: any) => { - let newState = state; - let historyGoingBack = false; - - try { - switch (action.type) { - - case 'NAV_BACK': - case 'NAV_GO': - - if (action.type === 'NAV_BACK') { - if (!navHistory.length) break; - - const newAction = navHistory.pop(); - action = newAction ? newAction : navHistory.pop(); - - historyGoingBack = true; - } - - { - const currentRoute = state.route; - - if (!historyGoingBack && historyCanGoBackTo(currentRoute)) { - const previousRoute = navHistory.length && navHistory[navHistory.length - 1]; - const isDifferentRoute = !previousRoute || !fastDeepEqual(navHistory[navHistory.length - 1], currentRoute); - - // Avoid multiple consecutive duplicate screens in the navigation history -- these can make - // pressing "back" seem to have no effect. - if (isDifferentRoute) { - navHistory.push(currentRoute); - } - } - - if (action.clearHistory) { - navHistory.splice(0, navHistory.length); - } - - newState = { ...state }; - - newState.selectedNoteHash = ''; - - if (action.routeName === 'Search') { - newState.notesParentType = 'Search'; - } - - if ('noteId' in action) { - newState.selectedNoteIds = action.noteId ? [action.noteId] : []; - } - - if ('folderId' in action) { - newState.selectedFolderId = action.folderId; - newState.notesParentType = 'Folder'; - } - - if ('tagId' in action) { - newState.selectedTagId = action.tagId; - newState.notesParentType = 'Tag'; - } - - if ('smartFilterId' in action) { - newState.smartFilterId = action.smartFilterId; - newState.selectedSmartFilterId = action.smartFilterId; - newState.notesParentType = 'SmartFilter'; - } - - if ('itemType' in action) { - newState.selectedItemType = action.itemType; - } - - if ('noteHash' in action) { - newState.selectedNoteHash = action.noteHash; - } - - if ('sharedData' in action) { - newState.sharedData = action.sharedData; - } else { - newState.sharedData = null; - } - - newState.route = action; - newState.historyCanGoBack = !!navHistory.length; - - logger.debug('Navigated to route:', newState.route?.routeName, 'with notesParentType:', newState.notesParentType); - } - break; - - case 'SIDE_MENU_TOGGLE': - - newState = { ...state }; - newState.showSideMenu = !newState.showSideMenu; - break; - - case 'SIDE_MENU_OPEN': - - newState = { ...state }; - newState.showSideMenu = true; - break; - - case 'SIDE_MENU_CLOSE': - - newState = { ...state }; - newState.showSideMenu = false; - break; - - case 'SET_PLUGIN_PANELS_DIALOG_VISIBLE': - newState = { ...state }; - newState.showPanelsDialog = action.visible; - break; - - case 'NOTE_SELECTION_TOGGLE': - - { - newState = { ...state }; - - const noteId = action.id; - const newSelectedNoteIds = state.selectedNoteIds.slice(); - const existingIndex = state.selectedNoteIds.indexOf(noteId); - - if (existingIndex >= 0) { - newSelectedNoteIds.splice(existingIndex, 1); - } else { - newSelectedNoteIds.push(noteId); - } - - newState.selectedNoteIds = newSelectedNoteIds; - newState.noteSelectionEnabled = !!newSelectedNoteIds.length; - } - break; - - case 'NOTE_SELECTION_START': - - if (!state.noteSelectionEnabled) { - newState = { ...state }; - newState.noteSelectionEnabled = true; - newState.selectedNoteIds = [action.id]; - } - break; - - case 'NOTE_SELECTION_END': - - newState = { ...state }; - newState.noteSelectionEnabled = false; - newState.selectedNoteIds = []; - break; - - case 'NOTE_SIDE_MENU_OPTIONS_SET': - - newState = { ...state }; - newState.noteSideMenuOptions = action.options; - break; - - case 'SET_SIDE_MENU_TOUCH_GESTURES_DISABLED': - newState = { ...state }; - newState.disableSideMenuGestures = action.disableSideMenuGestures; - break; - - case 'MOBILE_DATA_WARNING_UPDATE': - - newState = { ...state }; - newState.isOnMobileData = action.isOnMobileData; - break; - - case 'KEYBOARD_VISIBLE_CHANGE': - newState = { ...state, keyboardVisible: action.visible }; - break; - - case 'NOTE_EDITOR_VISIBLE_CHANGE': - newState = { ...state, noteEditorVisible: action.visible }; - break; - } - } catch (error) { - error.message = `In reducer: ${error.message} Action: ${JSON.stringify(action)}`; - throw error; - } - - return reducer(newState, action) as AppState; -}; - const store = createStore(appReducer, applyMiddleware(generalMiddleware)); storeDispatch = store.dispatch; diff --git a/packages/app-mobile/utils/appReducer.ts b/packages/app-mobile/utils/appReducer.ts new file mode 100644 index 0000000000..ad03db8139 --- /dev/null +++ b/packages/app-mobile/utils/appReducer.ts @@ -0,0 +1,207 @@ +import reducer from '@joplin/lib/reducer'; +import { AppState } from './types'; +import appDefaultState from './appDefaultState'; +import fastDeepEqual = require('fast-deep-equal'); +import Logger from '@joplin/utils/Logger'; + +const logger = Logger.create('appReducer'); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied +const navHistory: any[] = []; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied +function historyCanGoBackTo(route: any) { + if (route.routeName === 'Folder') return false; + + // This is an intermediate screen that acts more like a modal -- it should be skipped in the + // navigation history. + if (route.routeName === 'DocumentScanner') return false; + + // There's no point going back to these screens in general and, at least in OneDrive case, + // it can be buggy to do so, due to incorrectly relying on global state (reg.syncTarget...) + if (route.routeName === 'OneDriveLogin') return false; + if (route.routeName === 'DropboxLogin') return false; + + return true; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied +const appReducer = (state = appDefaultState, action: any) => { + let newState = state; + let historyGoingBack = false; + + try { + switch (action.type) { + + case 'NAV_BACK': + case 'NAV_GO': + + if (action.type === 'NAV_BACK') { + if (!navHistory.length) break; + + const newAction = navHistory.pop(); + action = newAction ? newAction : navHistory.pop(); + + historyGoingBack = true; + } + + { + const currentRoute = state.route; + + if (!historyGoingBack && historyCanGoBackTo(currentRoute)) { + const previousRoute = navHistory.length && navHistory[navHistory.length - 1]; + const isDifferentRoute = !previousRoute || !fastDeepEqual(navHistory[navHistory.length - 1], currentRoute); + + // Avoid multiple consecutive duplicate screens in the navigation history -- these can make + // pressing "back" seem to have no effect. + if (isDifferentRoute) { + navHistory.push(currentRoute); + } + } + + if (action.clearHistory) { + navHistory.splice(0, navHistory.length); + } + + newState = { ...state }; + + newState.selectedNoteHash = ''; + + if (action.routeName === 'Search') { + newState.notesParentType = 'Search'; + } + + if ('noteId' in action) { + newState.selectedNoteIds = action.noteId ? [action.noteId] : []; + } + + if ('folderId' in action) { + newState.selectedFolderId = action.folderId; + newState.notesParentType = 'Folder'; + } + + if ('tagId' in action) { + newState.selectedTagId = action.tagId; + newState.notesParentType = 'Tag'; + } + + if ('smartFilterId' in action) { + newState.smartFilterId = action.smartFilterId; + newState.selectedSmartFilterId = action.smartFilterId; + newState.notesParentType = 'SmartFilter'; + } + + if ('itemType' in action) { + newState.selectedItemType = action.itemType; + } + + if ('noteHash' in action) { + newState.selectedNoteHash = action.noteHash; + } + + if ('sharedData' in action) { + newState.sharedData = action.sharedData; + } else { + newState.sharedData = null; + } + + newState.route = action; + newState.historyCanGoBack = !!navHistory.length; + + logger.debug('Navigated to route:', newState.route?.routeName, 'with notesParentType:', newState.notesParentType); + } + break; + + case 'SIDE_MENU_TOGGLE': + + newState = { ...state }; + newState.showSideMenu = !newState.showSideMenu; + break; + + case 'SIDE_MENU_OPEN': + + newState = { ...state }; + newState.showSideMenu = true; + break; + + case 'SIDE_MENU_CLOSE': + + newState = { ...state }; + newState.showSideMenu = false; + break; + + case 'SET_PLUGIN_PANELS_DIALOG_VISIBLE': + newState = { ...state }; + newState.showPanelsDialog = action.visible; + break; + + case 'NOTE_SELECTION_TOGGLE': + + { + newState = { ...state }; + + const noteId = action.id; + const newSelectedNoteIds = state.selectedNoteIds.slice(); + const existingIndex = state.selectedNoteIds.indexOf(noteId); + + if (existingIndex >= 0) { + newSelectedNoteIds.splice(existingIndex, 1); + } else { + newSelectedNoteIds.push(noteId); + } + + newState.selectedNoteIds = newSelectedNoteIds; + newState.noteSelectionEnabled = !!newSelectedNoteIds.length; + } + break; + + case 'NOTE_SELECTION_START': + + if (!state.noteSelectionEnabled) { + newState = { ...state }; + newState.noteSelectionEnabled = true; + newState.selectedNoteIds = [action.id]; + } + break; + + case 'NOTE_SELECTION_END': + + newState = { ...state }; + newState.noteSelectionEnabled = false; + newState.selectedNoteIds = []; + break; + + case 'NOTE_SIDE_MENU_OPTIONS_SET': + + newState = { ...state }; + newState.noteSideMenuOptions = action.options; + break; + + case 'SET_SIDE_MENU_TOUCH_GESTURES_DISABLED': + newState = { ...state }; + newState.disableSideMenuGestures = action.disableSideMenuGestures; + break; + + case 'MOBILE_DATA_WARNING_UPDATE': + + newState = { ...state }; + newState.isOnMobileData = action.isOnMobileData; + break; + + case 'KEYBOARD_VISIBLE_CHANGE': + newState = { ...state, keyboardVisible: action.visible }; + break; + + case 'NOTE_EDITOR_VISIBLE_CHANGE': + newState = { ...state, noteEditorVisible: action.visible }; + break; + } + } catch (error) { + error.message = `In reducer: ${error.message} Action: ${JSON.stringify(action)}`; + throw error; + } + + return reducer(newState, action) as AppState; +}; + +export default appReducer; diff --git a/packages/app-mobile/utils/testing/createMockReduxStore.ts b/packages/app-mobile/utils/testing/createMockReduxStore.ts index 147657438c..8d4b5e190f 100644 --- a/packages/app-mobile/utils/testing/createMockReduxStore.ts +++ b/packages/app-mobile/utils/testing/createMockReduxStore.ts @@ -1,15 +1,16 @@ -import reducer from '@joplin/lib/reducer'; import { createStore } from 'redux'; import appDefaultState from '../appDefaultState'; import Setting from '@joplin/lib/models/Setting'; import { AppState } from '../types'; +import appReducer from '../appReducer'; const testReducer = (state: AppState|undefined, action: unknown): AppState => { state ??= { ...appDefaultState, settings: Setting.toPlainObject(), }; - return { ...state, ...reducer(state, action) }; + + return { ...state, ...appReducer(state, action) }; }; const createMockReduxStore = () => {