From 06f42e8246778c2d17263363b2ecbe1cf4944b76 Mon Sep 17 00:00:00 2001 From: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com> Date: Tue, 4 Jun 2024 01:57:52 -0700 Subject: [PATCH] Mobile: Implement plugin screen redesign (#10465) --- .eslintignore | 21 +- .gitignore | 21 +- .../controls/plugins/PluginBox.tsx | 1 + .../controls/plugins/PluginsStates.tsx | 1 + .../components/DismissibleDialog.tsx | 24 +- .../components/buttons/TextButton.tsx | 81 ++++++ .../app-mobile/components/buttons/index.tsx | 14 + .../screens/ConfigScreen/ConfigScreen.tsx | 26 +- .../plugins/EnablePluginSupportPage.tsx | 20 +- .../plugins/InstalledPluginBox.tsx | 52 ++++ .../plugins/PluginBox/PluginChips.tsx | 138 +++++++++ .../plugins/PluginBox/PluginInfoButton.tsx | 115 -------- .../plugins/PluginBox/PluginTitle.tsx | 31 ++ .../plugins/PluginBox/StyledChip.tsx | 39 +++ .../ConfigScreen/plugins/PluginBox/index.tsx | 243 +++++----------- .../ConfigScreen/plugins/PluginInfoModal.tsx | 265 ++++++++++++++++++ ...st.tsx => PluginStates.installed.test.tsx} | 193 +++++++++---- ....test.tsx => PluginStates.search.test.tsx} | 100 ++++--- .../ConfigScreen/plugins/PluginStates.tsx | 133 ++++++--- .../ConfigScreen/plugins/PluginToggle.tsx | 108 ------- .../plugins/PluginUploadButton.tsx | 26 +- .../ConfigScreen/plugins/SearchPlugins.tsx | 109 ++++--- .../{PluginBox => buttons}/ActionButton.tsx | 14 +- .../plugins/buttons/InstallButton.tsx | 34 +++ .../plugins/testUtils/WrappedPluginStates.tsx | 45 +++ .../testUtils/mockRepositoryApiConstructor.ts | 17 ++ .../plugins/utils/usePluginCallbacks.ts | 75 +++++ .../plugins/utils/usePluginItem.ts | 38 +++ .../plugins/utils/useUpdateState.ts | 39 +++ .../components/screens/ConfigScreen/types.ts | 4 + .../dialogs/PluginPanelViewer.tsx | 3 +- .../app-mobile/plugins/hooks/usePlugin.ts | 38 ++- packages/app-mobile/root.tsx | 6 +- .../components/shared/config/plugins/types.ts | 1 + .../lib/services/plugins/PluginService.ts | 13 +- .../lib/services/style/themeToCss.test.ts | 2 + packages/lib/themes/dark.ts | 1 + packages/lib/themes/light.ts | 1 + packages/lib/themes/type.ts | 1 + 39 files changed, 1460 insertions(+), 633 deletions(-) create mode 100644 packages/app-mobile/components/buttons/TextButton.tsx create mode 100644 packages/app-mobile/components/buttons/index.tsx create mode 100644 packages/app-mobile/components/screens/ConfigScreen/plugins/InstalledPluginBox.tsx create mode 100644 packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginChips.tsx delete mode 100644 packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginInfoButton.tsx create mode 100644 packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginTitle.tsx create mode 100644 packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/StyledChip.tsx create mode 100644 packages/app-mobile/components/screens/ConfigScreen/plugins/PluginInfoModal.tsx rename packages/app-mobile/components/screens/ConfigScreen/plugins/{PluginStates.test.tsx => PluginStates.installed.test.tsx} (52%) rename packages/app-mobile/components/screens/ConfigScreen/plugins/{SearchPlugins.test.tsx => PluginStates.search.test.tsx} (57%) delete mode 100644 packages/app-mobile/components/screens/ConfigScreen/plugins/PluginToggle.tsx rename packages/app-mobile/components/screens/ConfigScreen/plugins/{PluginBox => buttons}/ActionButton.tsx (66%) create mode 100644 packages/app-mobile/components/screens/ConfigScreen/plugins/buttons/InstallButton.tsx create mode 100644 packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/WrappedPluginStates.tsx create mode 100644 packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/mockRepositoryApiConstructor.ts create mode 100644 packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginCallbacks.ts create mode 100644 packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginItem.ts create mode 100644 packages/app-mobile/components/screens/ConfigScreen/plugins/utils/useUpdateState.ts diff --git a/.eslintignore b/.eslintignore index 681f232bf..894d5d04e 100644 --- a/.eslintignore +++ b/.eslintignore @@ -586,6 +586,8 @@ packages/app-mobile/components/base-screen.js packages/app-mobile/components/biometrics/BiometricPopup.js packages/app-mobile/components/biometrics/biometricAuthenticate.js packages/app-mobile/components/biometrics/sensorInfo.js +packages/app-mobile/components/buttons/TextButton.js +packages/app-mobile/components/buttons/index.js packages/app-mobile/components/getResponsiveValue.test.js packages/app-mobile/components/getResponsiveValue.js packages/app-mobile/components/global-style.js @@ -610,19 +612,28 @@ packages/app-mobile/components/screens/ConfigScreen/SettingsButton.js packages/app-mobile/components/screens/ConfigScreen/SettingsToggle.js packages/app-mobile/components/screens/ConfigScreen/configScreenStyles.js packages/app-mobile/components/screens/ConfigScreen/plugins/EnablePluginSupportPage.js -packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/ActionButton.js -packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginInfoButton.js +packages/app-mobile/components/screens/ConfigScreen/plugins/InstalledPluginBox.js +packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginChips.js +packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginTitle.js +packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/StyledChip.js packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/index.js -packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.test.js +packages/app-mobile/components/screens/ConfigScreen/plugins/PluginInfoModal.js +packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.installed.test.js +packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.search.test.js packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.js -packages/app-mobile/components/screens/ConfigScreen/plugins/PluginToggle.js packages/app-mobile/components/screens/ConfigScreen/plugins/PluginUploadButton.js -packages/app-mobile/components/screens/ConfigScreen/plugins/SearchPlugins.test.js packages/app-mobile/components/screens/ConfigScreen/plugins/SearchPlugins.js +packages/app-mobile/components/screens/ConfigScreen/plugins/buttons/ActionButton.js +packages/app-mobile/components/screens/ConfigScreen/plugins/buttons/InstallButton.js +packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/WrappedPluginStates.js +packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/mockRepositoryApiConstructor.js packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/newRepoApi.js packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/pluginServiceSetup.js packages/app-mobile/components/screens/ConfigScreen/plugins/utils/openWebsiteForPlugin.js +packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginCallbacks.js +packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginItem.js packages/app-mobile/components/screens/ConfigScreen/plugins/utils/useRepoApi.js +packages/app-mobile/components/screens/ConfigScreen/plugins/utils/useUpdateState.js packages/app-mobile/components/screens/ConfigScreen/types.js packages/app-mobile/components/screens/JoplinCloudLoginScreen.js packages/app-mobile/components/screens/LogScreen.js diff --git a/.gitignore b/.gitignore index 8506c3228..f97ad7090 100644 --- a/.gitignore +++ b/.gitignore @@ -565,6 +565,8 @@ packages/app-mobile/components/base-screen.js packages/app-mobile/components/biometrics/BiometricPopup.js packages/app-mobile/components/biometrics/biometricAuthenticate.js packages/app-mobile/components/biometrics/sensorInfo.js +packages/app-mobile/components/buttons/TextButton.js +packages/app-mobile/components/buttons/index.js packages/app-mobile/components/getResponsiveValue.test.js packages/app-mobile/components/getResponsiveValue.js packages/app-mobile/components/global-style.js @@ -589,19 +591,28 @@ packages/app-mobile/components/screens/ConfigScreen/SettingsButton.js packages/app-mobile/components/screens/ConfigScreen/SettingsToggle.js packages/app-mobile/components/screens/ConfigScreen/configScreenStyles.js packages/app-mobile/components/screens/ConfigScreen/plugins/EnablePluginSupportPage.js -packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/ActionButton.js -packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginInfoButton.js +packages/app-mobile/components/screens/ConfigScreen/plugins/InstalledPluginBox.js +packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginChips.js +packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginTitle.js +packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/StyledChip.js packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/index.js -packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.test.js +packages/app-mobile/components/screens/ConfigScreen/plugins/PluginInfoModal.js +packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.installed.test.js +packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.search.test.js packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.js -packages/app-mobile/components/screens/ConfigScreen/plugins/PluginToggle.js packages/app-mobile/components/screens/ConfigScreen/plugins/PluginUploadButton.js -packages/app-mobile/components/screens/ConfigScreen/plugins/SearchPlugins.test.js packages/app-mobile/components/screens/ConfigScreen/plugins/SearchPlugins.js +packages/app-mobile/components/screens/ConfigScreen/plugins/buttons/ActionButton.js +packages/app-mobile/components/screens/ConfigScreen/plugins/buttons/InstallButton.js +packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/WrappedPluginStates.js +packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/mockRepositoryApiConstructor.js packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/newRepoApi.js packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/pluginServiceSetup.js packages/app-mobile/components/screens/ConfigScreen/plugins/utils/openWebsiteForPlugin.js +packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginCallbacks.js +packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginItem.js packages/app-mobile/components/screens/ConfigScreen/plugins/utils/useRepoApi.js +packages/app-mobile/components/screens/ConfigScreen/plugins/utils/useUpdateState.js packages/app-mobile/components/screens/ConfigScreen/types.js packages/app-mobile/components/screens/JoplinCloudLoginScreen.js packages/app-mobile/components/screens/LogScreen.js diff --git a/packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.tsx b/packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.tsx index 7fe971e03..356cf8714 100644 --- a/packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.tsx +++ b/packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.tsx @@ -38,6 +38,7 @@ interface Props { function manifestToItem(manifest: PluginManifest): PluginItem { return { manifest: manifest, + installed: true, enabled: true, deleted: false, devMode: false, diff --git a/packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.tsx b/packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.tsx index 425818807..651a61047 100644 --- a/packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.tsx +++ b/packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.tsx @@ -83,6 +83,7 @@ function usePluginItems(plugins: Plugins, settings: PluginSettings): PluginItem[ output.push({ manifest: plugin.manifest, + installed: true, enabled: setting.enabled, deleted: setting.deleted, devMode: plugin.devMode, diff --git a/packages/app-mobile/components/DismissibleDialog.tsx b/packages/app-mobile/components/DismissibleDialog.tsx index 67c610687..6300e8f36 100644 --- a/packages/app-mobile/components/DismissibleDialog.tsx +++ b/packages/app-mobile/components/DismissibleDialog.tsx @@ -6,20 +6,32 @@ import { themeStyle } from './global-style'; import Modal from './Modal'; import { _ } from '@joplin/lib/locale'; +export enum DialogSize { + Small = 'small', + + // Ideal for panels and dialogs that should be fullscreen even on large devices + Large = 'large', +} + interface Props { themeId: number; visible: boolean; onDismiss: ()=> void; containerStyle?: ViewStyle; children: React.ReactNode; + + size: DialogSize; } -const useStyles = (themeId: number, containerStyle: ViewStyle) => { +const useStyles = (themeId: number, containerStyle: ViewStyle, size: DialogSize) => { const windowSize = useWindowDimensions(); return useMemo(() => { const theme = themeStyle(themeId); + const maxWidth = size === DialogSize.Large ? Infinity : 500; + const maxHeight = size === DialogSize.Large ? Infinity : 700; + return StyleSheet.create({ webView: { backgroundColor: 'transparent', @@ -38,8 +50,10 @@ const useStyles = (themeId: number, containerStyle: ViewStyle) => { borderRadius: 12, padding: 10, - height: windowSize.height * 0.9, - width: windowSize.width * 0.97, + // Use Math.min with width and height -- the maxWidth and maxHeight style + // properties don't seem to limit the size for this. + height: Math.min(maxHeight, windowSize.height * 0.9), + width: Math.min(maxWidth, windowSize.width * 0.97), flexShrink: 1, // Center @@ -56,11 +70,11 @@ const useStyles = (themeId: number, containerStyle: ViewStyle) => { flexGrow: 1, }, }); - }, [themeId, windowSize.width, windowSize.height, containerStyle]); + }, [themeId, windowSize.width, windowSize.height, containerStyle, size]); }; const DismissibleDialog: React.FC = props => { - const styles = useStyles(props.themeId, props.containerStyle); + const styles = useStyles(props.themeId, props.containerStyle, props.size); const closeButton = ( diff --git a/packages/app-mobile/components/buttons/TextButton.tsx b/packages/app-mobile/components/buttons/TextButton.tsx new file mode 100644 index 000000000..acc93b574 --- /dev/null +++ b/packages/app-mobile/components/buttons/TextButton.tsx @@ -0,0 +1,81 @@ +import * as React from 'react'; +import { ReactNode, useMemo } from 'react'; +import { themeStyle } from '../global-style'; +import { Button, ButtonProps } from 'react-native-paper'; +import { connect } from 'react-redux'; +import { AppState } from '../../utils/types'; + +export enum ButtonType { + Primary, + Secondary, + Delete, + Link, +} + +interface Props extends Omit { + themeId: number; + type: ButtonType; + onPress: ()=> void; + children: ReactNode; +} + +export type TextButtonProps = Omit; + +const useStyles = ({ themeId }: Props) => { + return useMemo(() => { + const theme = themeStyle(themeId); + + const themeOverride = { + secondaryButton: { + colors: { + primary: theme.color4, + outline: theme.color4, + }, + }, + deleteButton: { + colors: { + primary: theme.destructiveColor, + outline: theme.destructiveColor, + }, + }, + primaryButton: { }, + }; + + return { themeOverride }; + }, [themeId]); +}; + +const TextButton: React.FC = props => { + const { themeOverride } = useStyles(props); + + let mode: ButtonProps['mode']; + let theme: ButtonProps['theme']; + + if (props.type === ButtonType.Primary) { + theme = themeOverride.primaryButton; + mode = 'contained'; + } else if (props.type === ButtonType.Secondary) { + theme = themeOverride.secondaryButton; + mode = 'outlined'; + } else if (props.type === ButtonType.Delete) { + theme = themeOverride.deleteButton; + mode = 'outlined'; + } else if (props.type === ButtonType.Link) { + theme = themeOverride.secondaryButton; + mode = 'text'; + } else { + const exhaustivenessCheck: never = props.type; + return exhaustivenessCheck; + } + + return ; +}; + +export default connect((state: AppState) => { + return { themeId: state.settings.theme }; +})(TextButton); diff --git a/packages/app-mobile/components/buttons/index.tsx b/packages/app-mobile/components/buttons/index.tsx new file mode 100644 index 000000000..577f721ba --- /dev/null +++ b/packages/app-mobile/components/buttons/index.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import TextButton, { ButtonType, TextButtonProps } from './TextButton'; + +type Props = Omit; + +const makeTextButtonComponent = (type: ButtonType) => { + return (props: Props) => { + return ; + }; +}; + +export const PrimaryButton = makeTextButtonComponent(ButtonType.Primary); +export const SecondaryButton = makeTextButtonComponent(ButtonType.Secondary); +export const LinkButton = makeTextButtonComponent(ButtonType.Link); diff --git a/packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.tsx b/packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.tsx index cdb2a4204..96be78e86 100644 --- a/packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.tsx +++ b/packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { Platform, Linking, View, Switch, ScrollView, Text, TouchableOpacity, Alert, PermissionsAndroid, Dimensions, AccessibilityInfo } from 'react-native'; -import Setting, { AppType, SettingItem, SettingMetadataSection } from '@joplin/lib/models/Setting'; +import Setting, { AppType, SettingMetadataSection } from '@joplin/lib/models/Setting'; import NavService from '@joplin/lib/services/NavService'; import SearchEngine from '@joplin/lib/services/search/SearchEngine'; import checkPermissions from '../../../utils/checkPermissions'; @@ -26,7 +26,7 @@ import ExportProfileButton, { exportProfileButtonTitle } from './NoteExportSecti import SettingComponent from './SettingComponent'; import ExportDebugReportButton, { exportDebugReportTitle } from './NoteExportSection/ExportDebugReportButton'; import SectionSelector from './SectionSelector'; -import { Button, TextInput } from 'react-native-paper'; +import { TextInput, List } from 'react-native-paper'; import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService'; import PluginStates, { getSearchText as getPluginStatesSearchText } from './plugins/PluginStates'; import PluginUploadButton, { canInstallPluginsFromFile, buttonLabel as pluginUploadButtonSearchText } from './plugins/PluginUploadButton'; @@ -389,7 +389,7 @@ class ConfigScreenComponent extends BaseScreenComponent { const hiddenBySearch = this.state.searching && !matchesSearchQuery(relatedText); if (component && !hiddenBySearch) { @@ -503,8 +503,10 @@ class ConfigScreenComponent extends BaseScreenComponent, pluginUploadButtonSearchText(), + { advanced: true }, ); } } else { @@ -663,19 +665,15 @@ class ConfigScreenComponent extends BaseScreenComponent { if (!advancedSettingComps.length) return null; - const toggleAdvancedLabel = this.state.showAdvancedSettings ? _('Hide Advanced Settings') : _('Show Advanced Settings'); + const toggleAdvancedLabel = _('Advanced settings'); return ( - <> - - + this.setState({ showAdvancedSettings: !this.state.showAdvancedSettings })} + > {this.state.showAdvancedSettings ? advancedSettingComps : null} - + ); }; diff --git a/packages/app-mobile/components/screens/ConfigScreen/plugins/EnablePluginSupportPage.tsx b/packages/app-mobile/components/screens/ConfigScreen/plugins/EnablePluginSupportPage.tsx index 45c1116f2..511616ce2 100644 --- a/packages/app-mobile/components/screens/ConfigScreen/plugins/EnablePluginSupportPage.tsx +++ b/packages/app-mobile/components/screens/ConfigScreen/plugins/EnablePluginSupportPage.tsx @@ -3,7 +3,8 @@ import { themeStyle } from '../../../global-style'; import * as React from 'react'; import { useMemo } from 'react'; import { Linking, View, StyleSheet, ViewStyle, TextStyle } from 'react-native'; -import { Button, Card, Divider, Icon, List, Text } from 'react-native-paper'; +import { Card, Divider, Icon, List, Text } from 'react-native-paper'; +import { LinkButton, PrimaryButton } from '../../../buttons'; interface Props { themeId: number; @@ -50,7 +51,6 @@ const useStyles = (themeId: number) => { marginBottom: 0, }, actionButton: { - borderRadius: 10, marginLeft: theme.marginLeft * 2, marginRight: theme.marginRight * 2, marginBottom: theme.margin, @@ -58,18 +58,6 @@ const useStyles = (themeId: number) => { }); const themeOverride = { - secondaryButton: { - colors: { - primary: theme.color4, - outline: theme.color4, - }, - }, - primaryButton: { - colors: { - primary: theme.color4, - onPrimary: theme.backgroundColor4, - }, - }, card: { colors: { outline: theme.codeBorderColor, @@ -127,8 +115,8 @@ const EnablePluginSupportPage: React.FC = props => { {renderCard('source-branch-check', _('Open Source'), _('Most plugins have source code available for review on the plugin website.'))} {renderCard('flag-remove', _('Report system'), _('We have a system for reporting and removing problematic plugins.'))} - - + {_('Learn more')} + {_('Enable plugin support')} ); diff --git a/packages/app-mobile/components/screens/ConfigScreen/plugins/InstalledPluginBox.tsx b/packages/app-mobile/components/screens/ConfigScreen/plugins/InstalledPluginBox.tsx new file mode 100644 index 000000000..fc83b1ecf --- /dev/null +++ b/packages/app-mobile/components/screens/ConfigScreen/plugins/InstalledPluginBox.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService'; +import { useMemo } from 'react'; +import PluginBox from './PluginBox'; +import useUpdateState from './utils/useUpdateState'; +import { PluginCallback, PluginCallbacks } from './utils/usePluginCallbacks'; +import usePluginItem from './utils/usePluginItem'; +import { PluginStatusRecord } from '../types'; + +interface Props { + themeId: number; + + pluginId: string; + pluginSettings: PluginSettings; + updatablePluginIds: PluginStatusRecord; + updatingPluginIds: PluginStatusRecord; + showInstalledChip: boolean; + + callbacks: PluginCallbacks; + onShowPluginInfo: PluginCallback; +} + +const InstalledPluginBox: React.FC = props => { + const pluginId = props.pluginId; + const updateState = useUpdateState({ + pluginId, + updatablePluginIds: props.updatablePluginIds, + updatingPluginIds: props.updatingPluginIds, + pluginSettings: props.pluginSettings, + }); + const pluginItem = usePluginItem(pluginId, props.pluginSettings, null); + + const plugin = useMemo(() => PluginService.instance().pluginById(pluginId), [pluginId]); + const isCompatible = useMemo(() => { + return PluginService.instance().isCompatible(plugin.manifest); + }, [plugin]); + + return ( + + ); +}; + +export default InstalledPluginBox; diff --git a/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginChips.tsx b/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginChips.tsx new file mode 100644 index 000000000..cffaccd36 --- /dev/null +++ b/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginChips.tsx @@ -0,0 +1,138 @@ +import { PluginItem } from '@joplin/lib/components/shared/config/plugins/types'; +import PluginService from '@joplin/lib/services/plugins/PluginService'; +import shim from '@joplin/lib/shim'; +import * as React from 'react'; +import { Alert, Linking, View, ViewStyle } from 'react-native'; +import { _ } from '@joplin/lib/locale'; +import { PluginCallback } from '../utils/usePluginCallbacks'; +import StyledChip from './StyledChip'; +import { themeStyle } from '../../../../global-style'; + +interface Props { + themeId: number; + item: PluginItem; + hasErrors: boolean; + isCompatible: boolean; + canUpdate: boolean; + showInstalledChip: boolean; + + onShowPluginLog?: PluginCallback; +} + +const onRecommendedPress = () => { + Alert.alert( + '', + _('The Joplin team has vetted this plugin and it meets our standards for security and performance.'), + [ + { + text: _('Learn more'), + onPress: () => Linking.openURL('https://github.com/joplin/plugins/blob/master/readme/recommended.md'), + }, + { + text: _('OK'), + }, + ], + { cancelable: true }, + ); +}; + +const containerStyle: ViewStyle = { + flexDirection: 'row', + gap: 4, + + // Smaller than default chip size + transform: [{ scale: 0.84 }], + transformOrigin: 'left', +}; + +const PluginChips: React.FC = props => { + const item = props.item; + + const theme = themeStyle(props.themeId); + + const renderErrorsChip = () => { + if (!props.hasErrors) return null; + + return ( + props.onShowPluginLog({ item })} + > + {_('Error')} + + ); + }; + + const renderRecommendedChip = () => { + if (!props.item.manifest._recommended || !props.isCompatible) { + return null; + } + return {_('Recommended')}; + }; + + const renderBuiltInChip = () => { + if (!props.item.builtIn) { + return null; + } + return {_('Built-in')}; + }; + + const renderIncompatibleChip = () => { + if (props.isCompatible) return null; + return ( + { + void shim.showMessageBox( + PluginService.instance().describeIncompatibility(props.item.manifest), + { buttons: [_('OK')] }, + ); + }} + >{_('Incompatible')} + ); + }; + + const renderUpdatableChip = () => { + if (!props.isCompatible || !props.canUpdate) return null; + + return ( + {_('Update available')} + ); + }; + + const renderDisabledChip = () => { + if (props.item.enabled || !props.item.installed) { + return null; + } + return {_('Disabled')}; + }; + + const renderInstalledChip = () => { + if (!props.showInstalledChip) { + return null; + } + return {_('Installed')}; + }; + + return + {renderIncompatibleChip()} + {renderInstalledChip()} + {renderErrorsChip()} + {renderRecommendedChip()} + {renderBuiltInChip()} + {renderUpdatableChip()} + {renderDisabledChip()} + ; +}; + +export default PluginChips; diff --git a/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginInfoButton.tsx b/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginInfoButton.tsx deleted file mode 100644 index 9bcfcac36..000000000 --- a/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginInfoButton.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { PluginItem } from '@joplin/lib/components/shared/config/plugins/types'; -import * as React from 'react'; -import { _ } from '@joplin/lib/locale'; -import { useCallback, useMemo, useState } from 'react'; -import { Button, IconButton, List, Portal, Text } from 'react-native-paper'; -import getPluginIssueReportUrl from '@joplin/lib/services/plugins/utils/getPluginIssueReportUrl'; -import { Linking, ScrollView, StyleSheet, View } from 'react-native'; -import DismissibleDialog from '../../../../DismissibleDialog'; -import openWebsiteForPlugin from '../utils/openWebsiteForPlugin'; - -interface Props { - themeId: number; - size: number; - item: PluginItem; - onModalDismiss?: ()=> void; -} - -const styles = StyleSheet.create({ - aboutPluginContainer: { - paddingLeft: 10, - paddingRight: 10, - paddingBottom: 10, - }, - descriptionText: { - marginTop: 5, - marginBottom: 5, - }, - fraudulentPluginButton: { - opacity: 0.6, - }, -}); - -const PluginInfoModal: React.FC = props => { - const aboutPlugin = ( - - {props.item.manifest.name} - {props.item.manifest.author ? _('by %s', props.item.manifest.author) : ''} - {props.item.manifest.description ?? _('No description')} - - ); - - const onAboutPress = useCallback(() => { - void openWebsiteForPlugin({ item: props.item }); - }, [props.item]); - - const reportIssueUrl = useMemo(() => { - return getPluginIssueReportUrl(props.item.manifest); - }, [props.item]); - - const onReportIssuePress = useCallback(() => { - void Linking.openURL(reportIssueUrl); - }, [reportIssueUrl]); - - const reportIssueButton = ( - } - title={_('Report an issue')} - onPress={onReportIssuePress} - /> - ); - - const onReportFraudulentPress = useCallback(() => { - void Linking.openURL('https://github.com/laurent22/joplin/security/advisories/new'); - }, []); - - return ( - - - - {aboutPlugin} - } - title={_('About')} - onPress={onAboutPress} - /> - { reportIssueUrl ? reportIssueButton : null } - - - - - ); -}; - -const PluginInfoButton: React.FC = props => { - const [showInfoModal, setShowInfoModal] = useState(false); - const onInfoButtonPress = useCallback(() => { - setShowInfoModal(true); - }, []); - - const onModalDismiss = useCallback(() => { - setShowInfoModal(false); - props.onModalDismiss?.(); - }, [props.onModalDismiss]); - - return ( - <> - {showInfoModal ? : null} - - - ); -}; - -export default PluginInfoButton; diff --git a/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginTitle.tsx b/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginTitle.tsx new file mode 100644 index 000000000..1cb2a6466 --- /dev/null +++ b/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginTitle.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { PluginManifest } from '@joplin/lib/services/plugins/utils/types'; +import { Text } from 'react-native-paper'; +import { StyleSheet } from 'react-native'; + +interface Props { + manifest: PluginManifest; +} + +const styles = StyleSheet.create({ + versionText: { + opacity: 0.8, + }, + title: { + // Prevents the title text from being clipped on Android + verticalAlign: 'middle', + fontWeight: 'bold', + }, +}); + +const PluginTitle: React.FC = props => { + return + { + props.manifest.name + } v{ + props.manifest.version + } + ; +}; + +export default PluginTitle; diff --git a/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/StyledChip.tsx b/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/StyledChip.tsx new file mode 100644 index 000000000..feb33ede6 --- /dev/null +++ b/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/StyledChip.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; +import { Chip, ChipProps } from 'react-native-paper'; +import { useMemo } from 'react'; + +type Props = ({ + foreground: string; + background: string; +}|{ + foreground?: undefined; + background?: undefined; +}) & ChipProps; + +const RecommendedChip: React.FC = props => { + const themeOverride = useMemo(() => { + if (!props.foreground) return {}; + return { + colors: { + secondaryContainer: props.background, + onSecondaryContainer: props.foreground, + primary: props.foreground, + }, + }; + }, [props.foreground, props.background]); + + const accessibilityProps: Partial = {}; + if (!props.onPress) { + // Note: May have no effect until a future version of RN Paper. + // See https://github.com/callstack/react-native-paper/pull/4327 + accessibilityProps.accessibilityRole = 'text'; + } + + return ; +}; + +export default RecommendedChip; diff --git a/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/index.tsx b/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/index.tsx index c6b9b82d3..34c369ac9 100644 --- a/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/index.tsx +++ b/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/index.tsx @@ -1,12 +1,16 @@ import * as React from 'react'; -import { Icon, Card, Chip, Text } from 'react-native-paper'; +import { Card, Text, TouchableRipple } from 'react-native-paper'; import { _ } from '@joplin/lib/locale'; -import { Alert, Linking, StyleSheet, View } from 'react-native'; import { PluginItem } from '@joplin/lib/components/shared/config/plugins/types'; -import shim from '@joplin/lib/shim'; -import PluginService from '@joplin/lib/services/plugins/PluginService'; -import ActionButton, { PluginCallback } from './ActionButton'; -import PluginInfoButton from './PluginInfoButton'; +import ActionButton from '../buttons/ActionButton'; +import { ButtonType } from '../../../../buttons/TextButton'; +import PluginChips from './PluginChips'; +import { UpdateState } from '../utils/useUpdateState'; +import { PluginCallback } from '../utils/usePluginCallbacks'; +import { useCallback, useMemo } from 'react'; +import { StyleSheet } from 'react-native'; +import InstallButton from '../buttons/InstallButton'; +import PluginTitle from './PluginTitle'; export enum InstallState { NotInstalled, @@ -14,197 +18,98 @@ export enum InstallState { Installed, } -export enum UpdateState { - Idle = 1, - CanUpdate = 2, - Updating = 3, - HasBeenUpdated = 4, -} - interface Props { themeId: number; item: PluginItem; isCompatible: boolean; + // In some cases, showing an "installed" chip is redundant (e.g. in the "installed plugins" + // tab). In other places (e.g. search), an "installed" chip is important. + showInstalledChip: boolean; + hasErrors?: boolean; installState?: InstallState; updateState?: UpdateState; onAboutPress?: PluginCallback; onInstall?: PluginCallback; - onUpdate?: PluginCallback; - onDelete?: PluginCallback; - onToggle?: PluginCallback; onShowPluginLog?: PluginCallback; + onShowPluginInfo?: PluginCallback; } -const onRecommendedPress = () => { - Alert.alert( - '', - _('The Joplin team has vetted this plugin and it meets our standards for security and performance.'), - [ - { - text: _('Learn more'), - onPress: () => Linking.openURL('https://github.com/joplin/plugins/blob/master/readme/recommended.md'), +const useStyles = (compatible: boolean) => { + return useMemo(() => { + // For the TouchableRipple to work on Android, the card needs a transparent background. + const baseCard = { backgroundColor: 'transparent' }; + return StyleSheet.create({ + cardContainer: { + margin: 0, + marginTop: 8, + padding: 0, + borderRadius: 14, }, - { - text: _('OK'), + card: !compatible ? { + ...baseCard, + opacity: 0.7, + } : baseCard, + content: { + gap: 5, }, - ], - { cancelable: true }, - ); + }); + }, [compatible]); }; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied -const PluginIcon = (props: any) => ; - -const styles = StyleSheet.create({ - versionText: { - opacity: 0.8, - }, - title: { - // Prevents the title text from being clipped on Android - verticalAlign: 'middle', - }, -}); const PluginBox: React.FC = props => { const manifest = props.item.manifest; const item = props.item; - const installButtonTitle = () => { - if (props.installState === InstallState.Installing) return _('Installing...'); - if (props.installState === InstallState.NotInstalled) return _('Install'); - if (props.installState === InstallState.Installed) return _('Installed'); - return `Invalid install state: ${props.installState}`; - }; + const installButton = ; - const installButton = ( - - ); + const aboutButton = ; - const getUpdateButtonTitle = () => { - if (props.updateState === UpdateState.Updating) return _('Updating...'); - if (props.updateState === UpdateState.HasBeenUpdated) return _('Updated'); - return _('Update'); - }; + const onPress = useCallback(() => { + props.onShowPluginInfo?.({ item: props.item }); + }, [props.onShowPluginInfo, props.item]); - const updateButton = ( - - ); + const styles = useStyles(props.isCompatible); - const deleteButton = ( - - ); - const disableButton = ; - const enableButton = ; - const aboutButton = ; - - const renderErrorsChip = () => { - if (!props.hasErrors) return null; - - return ( - props.onShowPluginLog({ item })} - > - {_('Error')} - - ); - }; - - const renderRecommendedChip = () => { - if (!props.item.manifest._recommended || !props.isCompatible) { - return null; - } - return - {_('Recommended')} - ; - }; - - const renderBuiltInChip = () => { - if (!props.item.builtIn) { - return null; - } - return {_('Built-in')}; - }; - - const renderIncompatibleChip = () => { - if (props.isCompatible) return null; - return ( - { - void shim.showMessageBox( - PluginService.instance().describeIncompatibility(props.item.manifest), - { buttons: [_('OK')] }, - ); - }} - >{_('Incompatible')} - ); - }; - - const renderRightEdgeButton = (buttonProps: { size: number }) => { - // If .onAboutPress is given (e.g. when searching), there's another way to get information - // about the plugin. In this case, we don't show the right-side information link. - if (props.onAboutPress) return null; - return ; - }; - - const updateStateIsIdle = props.updateState !== UpdateState.Idle; - - const titleComponent = <> - {manifest.name} v{manifest.version} - ; return ( - - - - - {renderIncompatibleChip()} - {renderErrorsChip()} - {renderRecommendedChip()} - {renderBuiltInChip()} - - - - {props.onAboutPress ? aboutButton : null} - {props.onInstall ? installButton : null} - {props.onDelete && !props.item.builtIn ? deleteButton : null} - {props.onUpdate && updateStateIsIdle ? updateButton : null} - {props.onToggle && props.item.enabled ? disableButton : null} - {props.onToggle && !props.item.enabled ? enableButton : null} - - + + + + + {manifest.description} + + + + {props.onAboutPress ? aboutButton : null} + {props.onInstall ? installButton : null} + + + ); }; diff --git a/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginInfoModal.tsx b/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginInfoModal.tsx new file mode 100644 index 000000000..ae6869e73 --- /dev/null +++ b/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginInfoModal.tsx @@ -0,0 +1,265 @@ +import { PluginItem } from '@joplin/lib/components/shared/config/plugins/types'; +import * as React from 'react'; +import { _ } from '@joplin/lib/locale'; +import { useCallback, useMemo } from 'react'; +import { Card, Divider, List, Portal, Switch, Text } from 'react-native-paper'; +import getPluginIssueReportUrl from '@joplin/lib/services/plugins/utils/getPluginIssueReportUrl'; +import { Linking, ScrollView, StyleSheet, View, ViewStyle } from 'react-native'; +import DismissibleDialog, { DialogSize } from '../../../DismissibleDialog'; +import openWebsiteForPlugin from './utils/openWebsiteForPlugin'; +import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService'; +import PluginTitle from './PluginBox/PluginTitle'; +import ActionButton from './buttons/ActionButton'; +import TextButton, { ButtonType } from '../../../buttons/TextButton'; +import useUpdateState, { UpdateState } from './utils/useUpdateState'; +import { PluginCallback, PluginCallbacks } from './utils/usePluginCallbacks'; +import usePluginItem from './utils/usePluginItem'; +import InstallButton from './buttons/InstallButton'; +import { InstallState } from './PluginBox'; +import PluginChips from './PluginBox/PluginChips'; +import { PluginStatusRecord } from '../types'; + +interface Props { + themeId: number; + + visible: boolean; + item: PluginItem|null; + + updatablePluginIds: PluginStatusRecord; + updatingPluginIds: PluginStatusRecord; + installingPluginIds: PluginStatusRecord; + + pluginCallbacks: PluginCallbacks; + pluginSettings: PluginSettings; + onModalDismiss: ()=> void; +} + +const styles = (() => { + const baseButtonContainer: ViewStyle = { + display: 'flex', + flexDirection: 'column', + gap: 20, + marginLeft: 10, + marginRight: 10, + }; + return StyleSheet.create({ + descriptionText: { + marginTop: 5, + marginBottom: 5, + }, + buttonContainer: { + ...baseButtonContainer, + marginTop: 26, + marginBottom: 26, + }, + accordionContent: { + ...baseButtonContainer, + marginTop: 12, + }, + fraudulentPluginButton: { + opacity: 0.6, + }, + enabledSwitchContainer: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 10, + marginTop: 12, + marginBottom: 14, + }, + pluginDescriptionContainer: { + marginTop: 8, + gap: 8, + }, + }); +})(); + +interface EnabledSwitchProps { + item: PluginItem; + onToggle: PluginCallback; +} + +const EnabledSwitch: React.FC = props => { + const onChange = useCallback((value: boolean) => { + if (value !== props.item.enabled) { + props.onToggle({ item: props.item }); + } + }, [props.item, props.onToggle]); + + if (!props.item?.installed || props.item.deleted) { + return null; + } + + return + {_('Enabled')} + + ; +}; + +const PluginInfoModalContent: React.FC = props => { + const initialItem = props.item; + const pluginId = initialItem.manifest.id; + const item = usePluginItem(pluginId, props.pluginSettings, initialItem); + + const manifest = item.manifest; + const isCompatible = useMemo(() => { + return PluginService.instance().isCompatible(manifest); + }, [manifest]); + + const plugin = useMemo(() => { + const service = PluginService.instance(); + if (!service.pluginIds.includes(pluginId)) { + return null; + } + return service.pluginById(pluginId); + }, [pluginId]); + + const updateState = useUpdateState({ + pluginId: plugin?.id, + pluginSettings: props.pluginSettings, + updatablePluginIds: props.updatablePluginIds, + updatingPluginIds: props.updatingPluginIds, + }); + + const aboutPlugin = ( + + + + {_('by %s', manifest.author)} + + + {manifest.description} + + + + ); + + const onAboutPress = useCallback(() => { + void openWebsiteForPlugin({ item }); + }, [item]); + + const reportIssueUrl = useMemo(() => { + return getPluginIssueReportUrl(manifest); + }, [manifest]); + + const onReportIssuePress = useCallback(() => { + void Linking.openURL(reportIssueUrl); + }, [reportIssueUrl]); + + const reportIssueButton = ( + {_('Report an issue')} + ); + + const onReportFraudulentPress = useCallback(() => { + void Linking.openURL('https://github.com/laurent22/joplin/security/advisories/new'); + }, []); + + const getUpdateButtonTitle = () => { + if (updateState === UpdateState.Updating) return _('Updating...'); + if (updateState === UpdateState.HasBeenUpdated) return _('Updated'); + return _('Update'); + }; + + const updateButton = ( + + ); + + const installState = (() => { + if (item.installed) return InstallState.Installed; + if (props.installingPluginIds[pluginId]) return InstallState.Installing; + return InstallState.NotInstalled; + })(); + + const installButton = ( + + ); + + const deleteButton = ( + + ); + + const deleteButtonContainer = <> + + {deleteButton} + + + ; + + const reportIssuesContainer = ( + + + {_('Report fraudulent plugin')} + {reportIssueButton} + + + ); + + return <> + + {aboutPlugin} + + + + {!item.installed ? installButton : null} + {_('About')} + {updateState !== UpdateState.Idle ? updateButton : null} + + + { item.installed ? deleteButtonContainer : null } + {reportIssuesContainer} + + ; +}; + +const PluginInfoModal: React.FC = props => { + return ( + + + { props.item ? : null } + + + ); +}; + +export default PluginInfoModal; diff --git a/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.test.tsx b/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.installed.test.tsx similarity index 52% rename from packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.test.tsx rename to packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.installed.test.tsx index 2192efd2a..676230dbd 100644 --- a/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.test.tsx +++ b/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.installed.test.tsx @@ -1,65 +1,25 @@ import * as React from 'react'; -import RepositoryApi from '@joplin/lib/services/plugins/RepositoryApi'; -import { createTempDir, mockMobilePlatform, setupDatabaseAndSynchronizer, supportDir, switchClient } from '@joplin/lib/testing/test-utils'; +import { createTempDir, mockMobilePlatform, setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils'; -import { act, render, screen } from '@testing-library/react-native'; +import { act, fireEvent, render, screen, userEvent, waitFor } from '@testing-library/react-native'; import '@testing-library/react-native/extend-expect'; -import Setting from '@joplin/lib/models/Setting'; import PluginService, { PluginSettings, defaultPluginSetting } from '@joplin/lib/services/plugins/PluginService'; -import { useCallback, useState } from 'react'; import pluginServiceSetup from './testUtils/pluginServiceSetup'; -import PluginStates from './PluginStates'; -import configScreenStyles from '../configScreenStyles'; -import { remove, writeFile } from 'fs-extra'; +import { writeFile } from 'fs-extra'; import { join } from 'path'; import shim from '@joplin/lib/shim'; import { resetRepoApi } from './utils/useRepoApi'; import { Store } from 'redux'; import { AppState } from '../../../../utils/types'; import createMockReduxStore from '../../../../utils/testing/createMockReduxStore'; +import WrappedPluginStates from './testUtils/WrappedPluginStates'; +import mockRepositoryApiConstructor from './testUtils/mockRepositoryApiConstructor'; +import Setting from '@joplin/lib/models/Setting'; -interface WrapperProps { - initialPluginSettings: PluginSettings; -} let reduxStore: Store = null; -const shouldShowBasedOnSettingSearchQuery = ()=>true; -const PluginStatesWrapper = (props: WrapperProps) => { - const styles = configScreenStyles(Setting.THEME_LIGHT); - - const [pluginSettings, setPluginSettings] = useState(() => { - return props.initialPluginSettings ?? {}; - }); - - const updatePluginStates = useCallback((newStates: PluginSettings) => { - setPluginSettings(newStates); - }, []); - - return ( - - ); -}; - -let repoTempDir: string|null = null; -const mockRepositoryApiConstructor = async () => { - if (repoTempDir) { - await remove(repoTempDir); - } - repoTempDir = await createTempDir(); - - RepositoryApi.ofDefaultJoplinRepo = jest.fn((_tempDirPath: string, appType, installMode) => { - return new RepositoryApi(`${supportDir}/pluginRepo`, repoTempDir, appType, installMode); - }); -}; - const loadMockPlugin = async (id: string, name: string, version: string, pluginSettings: PluginSettings) => { const service = PluginService.instance(); const pluginSource = ` @@ -87,7 +47,12 @@ const loadMockPlugin = async (id: string, name: string, version: string, pluginS }); }; -describe('PluginStates', () => { +const showInstalledTab = async () => { + const installedTab = await screen.findByText('Installed plugins'); + await userEvent.press(installedTab); +}; + +describe('PluginStates.installed', () => { beforeEach(async () => { await setupDatabaseAndSynchronizer(0); await switchClient(0); @@ -128,23 +93,27 @@ describe('PluginStates', () => { await loadMockPlugin(backlinksPluginId, 'Backlinks to note', '0.0.1', defaultPluginSettings); expect(PluginService.instance().plugins[backlinksPluginId]).toBeTruthy(); - render( - , ); + await showInstalledTab(); expect(await screen.findByText(/^ABC Sheet Music/)).toBeVisible(); expect(await screen.findByText(/^Backlinks to note/)).toBeVisible(); - expect(await screen.findByRole('button', { name: 'Update ABC Sheet Music', disabled: false })).toBeVisible(); + const updateMarkers = await screen.findAllByText('Update available'); // Backlinks to note should not be updatable on iOS (it's not _recommended). - const backlinksToNoteQuery = { name: 'Update Backlinks to note', disabled: false }; + // ABC Sheet Music should always be updatable if (platform === 'android') { - expect(await screen.findByRole('button', backlinksToNoteQuery)).toBeVisible(); + expect(updateMarkers).toHaveLength(2); } else { - expect(await screen.queryByRole('button', backlinksToNoteQuery)).toBeNull(); + expect(updateMarkers).toHaveLength(1); } + + wrapper.unmount(); }); it('should show the current plugin version on updatable plugins', async () => { @@ -155,24 +124,32 @@ describe('PluginStates', () => { await loadMockPlugin(abcPluginId, 'ABC Sheet Music', outdatedVersion, defaultPluginSettings); expect(PluginService.instance().plugins[abcPluginId]).toBeTruthy(); - render( - , ); - expect(await screen.findByText(/^ABC Sheet Music/)).toBeVisible(); - expect(await screen.findByRole('button', { name: 'Update ABC Sheet Music', disabled: false })).toBeVisible(); + await showInstalledTab(); + + const abcSheetMusicCard = await screen.findByText(/^ABC Sheet Music/); + expect(abcSheetMusicCard).toBeVisible(); + expect(await screen.findByText('Update available')).toBeVisible(); expect(await screen.findByText(`v${outdatedVersion}`)).toBeVisible(); + + wrapper.unmount(); }); it('should update the list of installed plugins when a plugin is installed and uninstalled', async () => { const pluginSettings: PluginSettings = { }; - render( - , ); + await showInstalledTab(); // Initially, no plugins should be visible. expect(screen.queryByText(/^ABC Sheet Music/)).toBeNull(); @@ -191,5 +168,103 @@ describe('PluginStates', () => { await act(() => PluginService.instance().uninstallPlugin(testPluginId1)); expect(await screen.findByText(/^A test plugin/)).toBeVisible(); expect(screen.queryByText(/^ABC Sheet Music/)).toBeNull(); + + wrapper.unmount(); + }); + + it('should support disabling plugins from the info modal', async () => { + const abcPluginId = 'org.joplinapp.plugins.AbcSheetMusic'; + const defaultPluginSettings: PluginSettings = { [abcPluginId]: defaultPluginSetting() }; + + await loadMockPlugin(abcPluginId, 'ABC Sheet Music', '1.2.3', defaultPluginSettings); + expect(PluginService.instance().plugins[abcPluginId]).toBeTruthy(); + + const wrapper = render( + , + ); + await showInstalledTab(); + + const card = await screen.findByText('ABC Sheet Music'); + const user = userEvent.setup(); + + // Open the plugin dialog + await user.press(card); + + const enabledSwitch = await screen.findByLabelText('Enabled'); + expect(enabledSwitch).toBeVisible(); + + // Use fireEvent instead of userEvent.press -- .press doesn't seem to work + // for Switches. Similar issue: https://github.com/callstack/react-native-testing-library/issues/518. + fireEvent(enabledSwitch, 'valueChange', false); + + // The plugin should now be disabled + await waitFor(() => { + expect(Setting.value('plugins.states')).toMatchObject({ + [abcPluginId]: { enabled: false }, + }); + }); + + wrapper.unmount(); + }); + + it('should support updating plugins from the info modal', async () => { + await mockRepositoryApiConstructor(); + + const abcPluginId = 'org.joplinapp.plugins.AbcSheetMusic'; + + const defaultPluginSettings: PluginSettings = { + [abcPluginId]: defaultPluginSetting(), + }; + + // Load an outdated recommended plugin + await loadMockPlugin(abcPluginId, 'ABC Sheet Music', '0.0.1', defaultPluginSettings); + expect(PluginService.instance().plugins[abcPluginId]).toBeTruthy(); + + const wrapper = render( + , + ); + await showInstalledTab(); + + // Open the plugin dialog + const card = await screen.findByText('ABC Sheet Music'); + const user = userEvent.setup(); + await user.press(card); + + const updateButton = await screen.findByRole('button', { name: 'Update' }); + expect(updateButton).toBeVisible(); + await user.press(updateButton); + + // After updating, the update button should read "updated" + const updatedButton = await screen.findByRole('button', { name: 'Updated', disabled: true, timeout: 8000 }); + expect(updatedButton).toBeVisible(); + + // Should be marked as updated. + await waitFor(() => { + expect(Setting.value('plugins.states')).toMatchObject({ + [abcPluginId]: { enabled: true, hasBeenUpdated: true }, + }); + }); + + // Simulate the behavior of the plugin loader -- unloading and reloading plugins is generally + // handled elsewhere. This does, however, help verify that the verison number changes correctly + // in the UI. + await act(async () => { + await PluginService.instance().unloadPlugin(abcPluginId); + await loadMockPlugin(abcPluginId, 'ABC Sheet Music', '0.0.2', defaultPluginSettings); + }); + + // Version should change in two places -- the plugin list and the modal. + await waitFor(() => { + const versionText = screen.getAllByText('v0.0.2'); + expect(versionText).toHaveLength(2); + }); + + wrapper.unmount(); }); }); diff --git a/packages/app-mobile/components/screens/ConfigScreen/plugins/SearchPlugins.test.tsx b/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.search.test.tsx similarity index 57% rename from packages/app-mobile/components/screens/ConfigScreen/plugins/SearchPlugins.test.tsx rename to packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.search.test.tsx index 1160c5087..b5954708b 100644 --- a/packages/app-mobile/components/screens/ConfigScreen/plugins/SearchPlugins.test.tsx +++ b/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.search.test.tsx @@ -1,37 +1,16 @@ import * as React from 'react'; -import RepositoryApi, { InstallMode } from '@joplin/lib/services/plugins/RepositoryApi'; import { mockMobilePlatform, setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils'; import { render, screen, userEvent, waitFor } from '@testing-library/react-native'; import '@testing-library/react-native/extend-expect'; -import SearchPlugins from './SearchPlugins'; -import Setting from '@joplin/lib/models/Setting'; -import { PluginSettings } from '@joplin/lib/services/plugins/PluginService'; import pluginServiceSetup from './testUtils/pluginServiceSetup'; -import newRepoApi from './testUtils/newRepoApi'; import createMockReduxStore from '../../../../utils/testing/createMockReduxStore'; - -interface WrapperProps { - repoApi: RepositoryApi; - repoApiInitialized?: boolean; - pluginSettings?: PluginSettings; - onUpdatePluginStates?: (states: PluginSettings)=> void; -} - -const noOpFunction = ()=>{}; - -const SearchWrapper = (props: WrapperProps) => { - return ( - - ); -}; +import WrappedPluginStates from './testUtils/WrappedPluginStates'; +import { AppState } from '../../../../utils/types'; +import { Store } from 'redux'; +import mockRepositoryApiConstructor from './testUtils/mockRepositoryApiConstructor'; +import { resetRepoApi } from './utils/useRepoApi'; const expectSearchResultCountToBe = async (count: number) => { await waitFor(() => { @@ -39,24 +18,43 @@ const expectSearchResultCountToBe = async (count: number) => { }); }; -describe('SearchPlugins', () => { +const showSearchTab = async () => { + const searchAccordion = await screen.findByText('Install new plugins'); + await userEvent.press(searchAccordion); +}; + +// The search box is initially read-only -- waits for it to be editable. +const getEditableSearchBox = async () => { + const searchBox = await screen.findByPlaceholderText('Search plugins'); + expect(searchBox).toBeVisible(); + + await waitFor(() => { + expect(searchBox.props.editable).toBe(true); + }); + + return searchBox; +}; + +let reduxStore: Store; + +describe('PluginStates.search', () => { beforeEach(async () => { await setupDatabaseAndSynchronizer(0); await switchClient(0); - pluginServiceSetup(createMockReduxStore()); + reduxStore = createMockReduxStore(); + pluginServiceSetup(reduxStore); + mockMobilePlatform('android'); + resetRepoApi(); + + await mockRepositoryApiConstructor(); }); it('should find results', async () => { - const repoApi = await newRepoApi(InstallMode.Default); - render(); - - const searchBox = screen.queryByPlaceholderText('Search'); - expect(searchBox).toBeVisible(); - - // No plugin cards should be visible by default - expect(screen.queryAllByTestId('plugin-card')).toHaveLength(0); + const wrapper = render(); const user = userEvent.setup(); + await showSearchTab(); + const searchBox = await getEditableSearchBox(); await user.type(searchBox, 'backlinks'); // Should find one result @@ -71,19 +69,27 @@ describe('SearchPlugins', () => { await waitFor(() => { expect(screen.queryAllByTestId('plugin-card').length).toBeGreaterThan(2); }); + + wrapper.unmount(); }); it('should only show recommended plugin search results on iOS-like environments', async () => { // iOS uses restricted install mode - const repoApi = await newRepoApi(InstallMode.Restricted); - render(); + mockMobilePlatform('ios'); + await mockRepositoryApiConstructor(); - const searchBox = screen.queryByPlaceholderText('Search'); - expect(searchBox).toBeVisible(); + const wrapper = render(); const user = userEvent.setup(); + await showSearchTab(); + + const searchBox = await getEditableSearchBox(); + + await user.press(searchBox); await user.type(searchBox, 'abc'); + expect(searchBox.props.value).toBe('abc'); + // Should find recommended plugins await expectSearchResultCountToBe(1); @@ -97,16 +103,20 @@ describe('SearchPlugins', () => { await expectSearchResultCountToBe(1); expect(screen.getByText(/ABC Sheet Music/i)).toBeTruthy(); expect(screen.queryByText(/backlink/i)).toBeNull(); + + wrapper.unmount(); }); it('should mark incompatible plugins as incompatible', async () => { - const mock = mockMobilePlatform('android'); - const repoApi = await newRepoApi(InstallMode.Default); - render(); + const wrapper = render(); - const searchBox = screen.queryByPlaceholderText('Search'); const user = userEvent.setup(); + await showSearchTab(); + + const searchBox = await getEditableSearchBox(); + await user.press(searchBox); await user.type(searchBox, 'abc'); + expect(searchBox.props.value).toBe('abc'); await expectSearchResultCountToBe(1); expect(screen.queryByText('Incompatible')).toBeNull(); @@ -117,6 +127,6 @@ describe('SearchPlugins', () => { expect(await screen.findByText(/Note list and side bar/i)).toBeVisible(); expect(await screen.findByText('Incompatible')).toBeVisible(); - mock.reset(); + wrapper.unmount(); }); }); diff --git a/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.tsx b/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.tsx index 58a66fef3..7241211d8 100644 --- a/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.tsx +++ b/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.tsx @@ -1,17 +1,17 @@ import * as React from 'react'; -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { ConfigScreenStyles } from '../configScreenStyles'; -import { View } from 'react-native'; -import { Banner, Button, Text } from 'react-native-paper'; -import { _ } from '@joplin/lib/locale'; +import { View, StyleSheet } from 'react-native'; +import { Banner, Text, Button, ProgressBar, List, Divider } from 'react-native-paper'; +import { _, _n } from '@joplin/lib/locale'; import PluginService, { PluginSettings, SerializedPluginSettings } from '@joplin/lib/services/plugins/PluginService'; -import PluginToggle from './PluginToggle'; +import InstalledPluginBox from './InstalledPluginBox'; import SearchPlugins from './SearchPlugins'; -import { ItemEvent } from '@joplin/lib/components/shared/config/plugins/types'; -import NavService from '@joplin/lib/services/NavService'; +import { ItemEvent, PluginItem } from '@joplin/lib/components/shared/config/plugins/types'; import useRepoApi from './utils/useRepoApi'; import RepositoryApi from '@joplin/lib/services/plugins/RepositoryApi'; -import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect'; +import PluginInfoModal from './PluginInfoModal'; +import usePluginCallbacks from './utils/usePluginCallbacks'; interface Props { themeId: number; @@ -43,21 +43,33 @@ const useLoadedPluginIds = () => { }, []); const [loadedPluginIds, setLoadedPluginIds] = useState(getLoadedPlugins); - useAsyncEffect(async event => { - while (!event.cancelled) { - await PluginService.instance().waitForLoadedPluginsChange(); + useEffect(() => { + const { remove } = PluginService.instance().addLoadedPluginsChangeListener(() => { setLoadedPluginIds(getLoadedPlugins()); - } - }, []); + }); + + return () => { + remove(); + }; + }, [getLoadedPlugins]); return loadedPluginIds; }; +const styles = StyleSheet.create({ + installedPluginsContainer: { + marginLeft: 8, + marginRight: 8, + marginBottom: 10, + }, +}); + const PluginStates: React.FC = props => { const [repoApiError, setRepoApiError] = useState(null); const [repoApiLoaded, setRepoApiLoaded] = useState(false); const [reloadRepoCounter, setRepoReloadCounter] = useState(0); const [updatablePluginIds, setUpdatablePluginIds] = useState>({}); + const [shownInDialogItem, setShownInDialogItem] = useState(null); const onRepoApiLoaded = useCallback(async (repoApi: RepositoryApi) => { const manifests = Object.values(PluginService.instance().plugins) @@ -98,15 +110,26 @@ const PluginStates: React.FC = props => { ; } else { - return {_('Loading plugin repository...')}; + return ; } }; - const onShowPluginLog = useCallback((event: ItemEvent) => { - const pluginId = event.item.manifest.id; - void NavService.go('Log', { defaultFilter: pluginId }); + const onShowPluginInfo = useCallback((event: ItemEvent) => { + setShownInDialogItem(event.item); }, []); + const onPluginDialogClosed = useCallback(() => { + setShownInDialogItem(null); + }, []); + + const pluginSettings = useMemo(() => { + return PluginService.instance().unserializePluginSettings(props.pluginSettings); + }, [props.pluginSettings]); + + const { callbacks: pluginCallbacks, updatingPluginIds, installingPluginIds } = usePluginCallbacks({ + pluginSettings, updatePluginStates: props.updatePluginStates, repoApi, + }); + const installedPluginCards = []; const pluginService = PluginService.instance(); @@ -116,16 +139,16 @@ const PluginStates: React.FC = props => { if (!props.shouldShowBasedOnSearchQuery || props.shouldShowBasedOnSearchQuery(plugin.manifest.name)) { installedPluginCards.push( - , ); } @@ -135,21 +158,65 @@ const PluginStates: React.FC = props => { !props.shouldShowBasedOnSearchQuery || props.shouldShowBasedOnSearchQuery(searchInputSearchText()) ); - const searchComponent = ( - + const [searchQuery, setSearchQuery] = useState(''); + + const searchAccordion = ( + + + ); + const isSearching = !!props.shouldShowBasedOnSearchQuery; + // Don't include the number of installed plugins when searching -- only a few of the total + // may be shown by the search. + const installedAccordionDescription = !isSearching ? _n('You currently have %d plugin installed.', 'You currently have %d plugins installed.', pluginIds.length, pluginIds.length) : null; + return ( {renderRepoApiStatus()} - {installedPluginCards} - {showSearch ? searchComponent : null} + + + + {installedPluginCards} + + + + {showSearch ? searchAccordion : null} + + + ); }; diff --git a/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginToggle.tsx b/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginToggle.tsx deleted file mode 100644 index a1b95bc57..000000000 --- a/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginToggle.tsx +++ /dev/null @@ -1,108 +0,0 @@ - -import * as React from 'react'; -import { ConfigScreenStyles } from '../configScreenStyles'; -import PluginService, { PluginSettings, defaultPluginSetting, SerializedPluginSettings } from '@joplin/lib/services/plugins/PluginService'; -import { useCallback, useMemo, useState } from 'react'; -import PluginBox, { UpdateState } from './PluginBox'; -import useOnDeleteHandler from '@joplin/lib/components/shared/config/plugins/useOnDeleteHandler'; -import { ItemEvent, OnPluginSettingChangeEvent } from '@joplin/lib/components/shared/config/plugins/types'; -import useOnInstallHandler from '@joplin/lib/components/shared/config/plugins/useOnInstallHandler'; -import RepositoryApi from '@joplin/lib/services/plugins/RepositoryApi'; - -interface Props { - pluginId: string; - themeId: number; - styles: ConfigScreenStyles; - pluginSettings: SerializedPluginSettings; - updatablePluginIds: Record; - repoApi: RepositoryApi; - - onShowPluginLog: (event: ItemEvent)=> void; - updatePluginStates: (settingValue: PluginSettings)=> void; -} - -const PluginToggle: React.FC = props => { - const pluginService = useMemo(() => PluginService.instance(), []); - const plugin = useMemo(() => { - return pluginService.pluginById(props.pluginId); - }, [pluginService, props.pluginId]); - - const pluginSettings = useMemo(() => { - const settings = { ...pluginService.unserializePluginSettings(props.pluginSettings) }; - - if (!settings[props.pluginId]) { - settings[props.pluginId] = defaultPluginSetting(); - } - - return settings; - }, [props.pluginSettings, pluginService, props.pluginId]); - - const onPluginSettingsChange = useCallback((event: OnPluginSettingChangeEvent) => { - props.updatePluginStates(event.value); - }, [props.updatePluginStates]); - - const updatePluginEnabled = useCallback((enabled: boolean) => { - const newSettings = { ...pluginSettings }; - newSettings[props.pluginId].enabled = enabled; - - props.updatePluginStates(newSettings); - }, [pluginSettings, props.pluginId, props.updatePluginStates]); - - const pluginId = plugin.manifest.id; - const onToggle = useCallback(() => { - const settings = pluginSettings[pluginId]; - updatePluginEnabled(!settings.enabled); - }, [pluginSettings, updatePluginEnabled, pluginId]); - - const onDelete = useOnDeleteHandler(pluginSettings, onPluginSettingsChange, true); - - const [updatingPluginIds, setUpdatingPluginIds] = useState>({}); - const onUpdate = useOnInstallHandler(setUpdatingPluginIds, pluginSettings, props.repoApi, onPluginSettingsChange, true); - - const updateState = useMemo(() => { - const settings = pluginSettings[pluginId]; - - if (settings.hasBeenUpdated) { - return UpdateState.HasBeenUpdated; - } - if (updatingPluginIds[pluginId]) { - return UpdateState.Updating; - } - if (props.updatablePluginIds[pluginId]) { - return UpdateState.CanUpdate; - } - return UpdateState.Idle; - }, [pluginSettings, updatingPluginIds, pluginId, props.updatablePluginIds]); - - const pluginItem = useMemo(() => { - const settings = pluginSettings[pluginId]; - return { - manifest: plugin.manifest, - enabled: settings.enabled, - deleted: settings.deleted, - devMode: plugin.devMode, - builtIn: plugin.builtIn, - hasBeenUpdated: settings.hasBeenUpdated, - }; - }, [plugin, pluginId, pluginSettings]); - - const isCompatible = useMemo(() => { - return PluginService.instance().isCompatible(plugin.manifest); - }, [plugin]); - - return ( - - ); -}; - -export default PluginToggle; diff --git a/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginUploadButton.tsx b/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginUploadButton.tsx index 988dc7492..63c80ec9c 100644 --- a/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginUploadButton.tsx +++ b/packages/app-mobile/components/screens/ConfigScreen/plugins/PluginUploadButton.tsx @@ -3,18 +3,20 @@ import { _ } from '@joplin/lib/locale'; import PluginService, { PluginSettings, SerializedPluginSettings, defaultPluginSetting } from '@joplin/lib/services/plugins/PluginService'; import * as React from 'react'; import { useCallback, useState } from 'react'; -import { Button } from 'react-native-paper'; import pickDocument from '../../../../utils/pickDocument'; import shim from '@joplin/lib/shim'; import Logger from '@joplin/utils/Logger'; -import { Platform } from 'react-native'; +import { Platform, View, ViewStyle } from 'react-native'; import { join, extname } from 'path'; import uuid from '@joplin/lib/uuid'; import Setting from '@joplin/lib/models/Setting'; +import TextButton, { ButtonType } from '../../../buttons/TextButton'; +import { ConfigScreenStyles } from '../configScreenStyles'; interface Props { updatePluginStates: (settingValue: PluginSettings)=> void; pluginSettings: SerializedPluginSettings; + styles: ConfigScreenStyles; } const logger = Logger.create('PluginUploadButton'); @@ -26,6 +28,8 @@ export const canInstallPluginsFromFile = () => { return shim.mobilePlatform() !== 'ios' || Setting.value('env') === 'dev'; }; +const buttonStyle: ViewStyle = { flexGrow: 1 }; + const PluginUploadButton: React.FC = props => { const [showLoadingAnimation, setShowLoadingAnimation] = useState(false); @@ -85,13 +89,17 @@ const PluginUploadButton: React.FC = props => { }, [props.pluginSettings, props.updatePluginStates]); return ( - + + + {buttonLabel()} + + ); }; diff --git a/packages/app-mobile/components/screens/ConfigScreen/plugins/SearchPlugins.tsx b/packages/app-mobile/components/screens/ConfigScreen/plugins/SearchPlugins.tsx index 40a08b3a3..263a3fabb 100644 --- a/packages/app-mobile/components/screens/ConfigScreen/plugins/SearchPlugins.tsx +++ b/packages/app-mobile/components/screens/ConfigScreen/plugins/SearchPlugins.tsx @@ -4,21 +4,32 @@ import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect'; import { _ } from '@joplin/lib/locale'; import { PluginManifest } from '@joplin/lib/services/plugins/utils/types'; import { useCallback, useMemo, useState } from 'react'; -import { FlatList, View } from 'react-native'; -import { Searchbar } from 'react-native-paper'; +import { FlatList, StyleSheet, View } from 'react-native'; +import { TextInput, Text } from 'react-native-paper'; import PluginBox, { InstallState } from './PluginBox'; -import PluginService, { PluginSettings, SerializedPluginSettings } from '@joplin/lib/services/plugins/PluginService'; -import useInstallHandler from '@joplin/lib/components/shared/config/plugins/useOnInstallHandler'; -import { OnPluginSettingChangeEvent, PluginItem } from '@joplin/lib/components/shared/config/plugins/types'; +import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService'; +import { PluginItem } from '@joplin/lib/components/shared/config/plugins/types'; import RepositoryApi from '@joplin/lib/services/plugins/RepositoryApi'; import openWebsiteForPlugin from './utils/openWebsiteForPlugin'; +import { PluginCallback, PluginCallbacks } from './utils/usePluginCallbacks'; +import InstalledPluginBox from './InstalledPluginBox'; interface Props { themeId: number; - pluginSettings: SerializedPluginSettings; + pluginSettings: PluginSettings; repoApiInitialized: boolean; onUpdatePluginStates: (states: PluginSettings)=> void; repoApi: RepositoryApi; + + installingPluginIds: Record; + updatingPluginIds: Record; + updatablePluginIds: Record; + + callbacks: PluginCallbacks; + onShowPluginInfo: PluginCallback; + + searchQuery: string; + setSearchQuery: (newQuery: string)=> void; } interface SearchResultRecord { @@ -27,8 +38,20 @@ interface SearchResultRecord { installState: InstallState; } +const styles = StyleSheet.create({ + container: { + flexDirection: 'column', + margin: 12, + }, + resultsCounter: { + margin: 12, + marginTop: 17, + marginBottom: 4, + }, +}); + const PluginSearch: React.FC = props => { - const [searchQuery, setSearchQuery] = useState(''); + const { searchQuery, setSearchQuery } = props; const [searchResultManifests, setSearchResultManifests] = useState([]); useAsyncEffect(async event => { @@ -42,8 +65,6 @@ const PluginSearch: React.FC = props => { } }, [searchQuery, props.repoApi, setSearchResultManifests, props.repoApiInitialized]); - const [installingPluginsIds, setInstallingPluginIds] = useState>({}); - const pluginSettings = useMemo(() => { return { ...PluginService.instance().unserializePluginSettings(props.pluginSettings) }; }, [props.pluginSettings]); @@ -56,12 +77,13 @@ const PluginSearch: React.FC = props => { if (settings && !settings.deleted) { installState = InstallState.Installed; } - if (installingPluginsIds[manifest.id]) { + if (props.installingPluginIds[manifest.id]) { installState = InstallState.Installing; } const item: PluginItem = { manifest, + installed: !!settings, enabled: settings && settings.enabled, deleted: settings && !settings.deleted, devMode: false, @@ -75,41 +97,62 @@ const PluginSearch: React.FC = props => { installState, }; }); - }, [searchResultManifests, installingPluginsIds, pluginSettings]); + }, [searchResultManifests, props.installingPluginIds, pluginSettings]); - const onPluginSettingsChange = useCallback((event: OnPluginSettingChangeEvent) => { - props.onUpdatePluginStates(event.value); - }, [props.onUpdatePluginStates]); - - const installPlugin = useInstallHandler( - setInstallingPluginIds, pluginSettings, props.repoApi, onPluginSettingsChange, false, - ); + const onInstall = props.callbacks.onInstall; const renderResult = useCallback(({ item }: { item: SearchResultRecord }) => { const manifest = item.item.manifest; - return ( - - ); - }, [installPlugin, props.themeId]); + if (item.installState === InstallState.Installed && PluginService.instance().isPluginLoaded(manifest.id)) { + return ( + + ); + } else { + return ( + + ); + } + }, [onInstall, props.themeId, props.pluginSettings, props.updatingPluginIds, props.updatablePluginIds, props.onShowPluginInfo, props.callbacks]); + + const renderResultsCount = () => { + if (!searchQuery.length) return null; + + return + {_('Results (%d):', searchResults.length)} + ; + }; return ( - - + } + placeholder={_('Search plugins')} onChangeText={setSearchQuery} value={searchQuery} editable={props.repoApiInitialized} /> + {renderResultsCount()} void; -interface Props extends Omit { +interface Props extends Omit { item: PluginItem; + type?: ButtonType; onPress?: PluginCallback; title: string; } @@ -24,11 +25,12 @@ const ActionButton: React.FC = props => { // marked as translatable. const accessibilityLabel = `${props.title} ${props.item.manifest.name}`; return ( - + >{props.title} ); }; diff --git a/packages/app-mobile/components/screens/ConfigScreen/plugins/buttons/InstallButton.tsx b/packages/app-mobile/components/screens/ConfigScreen/plugins/buttons/InstallButton.tsx new file mode 100644 index 000000000..cf6354bff --- /dev/null +++ b/packages/app-mobile/components/screens/ConfigScreen/plugins/buttons/InstallButton.tsx @@ -0,0 +1,34 @@ +import { PluginItem } from '@joplin/lib/components/shared/config/plugins/types'; +import * as React from 'react'; +import ActionButton from './ActionButton'; +import { PluginCallback } from '../utils/usePluginCallbacks'; +import { InstallState } from '../PluginBox'; +import { _ } from '@joplin/lib/locale'; + +interface Props { + item: PluginItem; + onInstall: PluginCallback; + installState: InstallState; + isCompatible: boolean; +} + +const InstallButton: React.FC = props => { + const installButtonTitle = () => { + if (props.installState === InstallState.Installing) return _('Installing...'); + if (props.installState === InstallState.NotInstalled) return _('Install'); + if (props.installState === InstallState.Installed) return _('Installed'); + return `Invalid install state: ${props.installState}`; + }; + + return ( + + ); +}; + +export default InstallButton; diff --git a/packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/WrappedPluginStates.tsx b/packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/WrappedPluginStates.tsx new file mode 100644 index 000000000..c4d2abe54 --- /dev/null +++ b/packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/WrappedPluginStates.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import { PluginSettings } from '@joplin/lib/services/plugins/PluginService'; +import configScreenStyles from '../../configScreenStyles'; +import Setting from '@joplin/lib/models/Setting'; +import { Provider } from 'react-redux'; +import { Store } from 'redux'; +import { PaperProvider } from 'react-native-paper'; +import PluginStates from '../PluginStates'; +import { AppState } from '../../../../../utils/types'; +import { useCallback, useState } from 'react'; + +interface WrapperProps { + initialPluginSettings: PluginSettings; + store: Store; +} +const shouldShowBasedOnSettingSearchQuery = ()=>true; + +const PluginStatesWrapper = (props: WrapperProps) => { + const styles = configScreenStyles(Setting.THEME_LIGHT); + + const [pluginSettings, setPluginSettings] = useState(() => { + return props.initialPluginSettings ?? {}; + }); + + const updatePluginStates = useCallback((newStates: PluginSettings) => { + setPluginSettings(newStates); + Setting.setValue('plugins.states', newStates); + }, []); + + return ( + + + + + + ); +}; + +export default PluginStatesWrapper; diff --git a/packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/mockRepositoryApiConstructor.ts b/packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/mockRepositoryApiConstructor.ts new file mode 100644 index 000000000..22cd96389 --- /dev/null +++ b/packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/mockRepositoryApiConstructor.ts @@ -0,0 +1,17 @@ +import RepositoryApi from '@joplin/lib/services/plugins/RepositoryApi'; +import { createTempDir, supportDir } from '@joplin/lib/testing/test-utils'; +import { remove } from 'fs-extra'; + +let repoTempDir: string|null = null; +const mockRepositoryApiConstructor = async () => { + if (repoTempDir) { + await remove(repoTempDir); + } + repoTempDir = await createTempDir(); + + RepositoryApi.ofDefaultJoplinRepo = jest.fn((_tempDirPath: string, appType, installMode) => { + return new RepositoryApi(`${supportDir}/pluginRepo`, repoTempDir, appType, installMode); + }); +}; + +export default mockRepositoryApiConstructor; diff --git a/packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginCallbacks.ts b/packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginCallbacks.ts new file mode 100644 index 000000000..bb3b000e2 --- /dev/null +++ b/packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginCallbacks.ts @@ -0,0 +1,75 @@ +import { ItemEvent, OnPluginSettingChangeEvent } from '@joplin/lib/components/shared/config/plugins/types'; +import useOnDeleteHandler from '@joplin/lib/components/shared/config/plugins/useOnDeleteHandler'; +import useOnInstallHandler from '@joplin/lib/components/shared/config/plugins/useOnInstallHandler'; +import NavService from '@joplin/lib/services/NavService'; +import { PluginSettings } from '@joplin/lib/services/plugins/PluginService'; +import RepositoryApi from '@joplin/lib/services/plugins/RepositoryApi'; +import { useCallback, useMemo, useState } from 'react'; + +interface Props { + updatePluginStates: (settingValue: PluginSettings)=> void; + pluginSettings: PluginSettings; + repoApi: RepositoryApi; +} + +export type PluginCallback = (event: ItemEvent)=> void; + +export interface PluginCallbacks { + onToggle: PluginCallback; + onUpdate: PluginCallback; + onInstall: PluginCallback; + onDelete: PluginCallback; + onShowPluginLog: PluginCallback; +} + +const usePluginCallbacks = (props: Props) => { + const onPluginSettingsChange = useCallback((event: OnPluginSettingChangeEvent) => { + props.updatePluginStates(event.value); + }, [props.updatePluginStates]); + + const updatePluginEnabled = useCallback((pluginId: string, enabled: boolean) => { + const newSettings = { ...props.pluginSettings }; + newSettings[pluginId].enabled = enabled; + + props.updatePluginStates(newSettings); + }, [props.pluginSettings, props.updatePluginStates]); + + const onToggle = useCallback((event: ItemEvent) => { + const pluginId = event.item.manifest.id; + const settings = props.pluginSettings[pluginId]; + updatePluginEnabled(pluginId, !settings.enabled); + }, [props.pluginSettings, updatePluginEnabled]); + + const onDelete = useOnDeleteHandler(props.pluginSettings, onPluginSettingsChange, true); + + const [updatingPluginIds, setUpdatingPluginIds] = useState>({}); + const onUpdate = useOnInstallHandler(setUpdatingPluginIds, props.pluginSettings, props.repoApi, onPluginSettingsChange, true); + + const [installingPluginIds, setInstallingPluginIds] = useState>({}); + const onInstall = useOnInstallHandler( + setInstallingPluginIds, props.pluginSettings, props.repoApi, onPluginSettingsChange, false, + ); + + const onShowPluginLog = useCallback((event: ItemEvent) => { + const pluginId = event.item.manifest.id; + void NavService.go('Log', { defaultFilter: pluginId }); + }, []); + + const callbacks = useMemo((): PluginCallbacks => { + return { + onToggle, + onDelete, + onUpdate, + onInstall, + onShowPluginLog, + }; + }, [onToggle, onDelete, onUpdate, onInstall, onShowPluginLog]); + + return { + callbacks, + updatingPluginIds, + installingPluginIds, + }; +}; + +export default usePluginCallbacks; diff --git a/packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginItem.ts b/packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginItem.ts new file mode 100644 index 000000000..b3085b976 --- /dev/null +++ b/packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginItem.ts @@ -0,0 +1,38 @@ +import { PluginItem } from '@joplin/lib/components/shared/config/plugins/types'; +import { PluginSettings } from '@joplin/lib/services/plugins/PluginService'; +import { PluginManifest } from '@joplin/lib/services/plugins/utils/types'; +import { useMemo, useRef } from 'react'; +import usePlugin from '../../../../../plugins/hooks/usePlugin'; + +// initialItem is used when the plugin is not installed. For example, if the plugin item is being +// created from search results. +const usePluginItem = (id: string, pluginSettings: PluginSettings, initialItem: PluginItem|null): PluginItem => { + const plugin = usePlugin(id); + + const lastManifest = useRef(); + if (plugin) { + lastManifest.current = plugin.manifest; + } else if (!lastManifest.current) { + lastManifest.current = initialItem?.manifest; + } + const manifest = lastManifest.current; + + return useMemo(() => { + if (!manifest) return null; + const settings = pluginSettings[id]; + + return { + id, + manifest, + + installed: !!settings, + enabled: settings?.enabled ?? false, + deleted: settings?.deleted ?? false, + hasBeenUpdated: settings?.hasBeenUpdated ?? false, + devMode: plugin?.devMode ?? false, + builtIn: plugin?.builtIn ?? false, + }; + }, [plugin, id, pluginSettings, manifest]); +}; + +export default usePluginItem; diff --git a/packages/app-mobile/components/screens/ConfigScreen/plugins/utils/useUpdateState.ts b/packages/app-mobile/components/screens/ConfigScreen/plugins/utils/useUpdateState.ts new file mode 100644 index 000000000..2b02a622e --- /dev/null +++ b/packages/app-mobile/components/screens/ConfigScreen/plugins/utils/useUpdateState.ts @@ -0,0 +1,39 @@ +import { PluginSettings } from '@joplin/lib/services/plugins/PluginService'; +import { useMemo } from 'react'; + +export enum UpdateState { + Idle = 1, + CanUpdate = 2, + Updating = 3, + HasBeenUpdated = 4, +} + +interface Props { + pluginId: string; + + pluginSettings: PluginSettings; + updatingPluginIds: Record; + updatablePluginIds: Record; +} + +const useUpdateState = ({ pluginId, pluginSettings, updatablePluginIds, updatingPluginIds }: Props) => { + return useMemo(() => { + const settings = pluginSettings[pluginId]; + + // Uninstalled + if (!settings) return UpdateState.Idle; + + if (settings.hasBeenUpdated) { + return UpdateState.HasBeenUpdated; + } + if (updatingPluginIds[pluginId]) { + return UpdateState.Updating; + } + if (updatablePluginIds[pluginId]) { + return UpdateState.CanUpdate; + } + return UpdateState.Idle; + }, [pluginSettings, updatingPluginIds, pluginId, updatablePluginIds]); +}; + +export default useUpdateState; diff --git a/packages/app-mobile/components/screens/ConfigScreen/types.ts b/packages/app-mobile/components/screens/ConfigScreen/types.ts index 0252f72bc..c66eb19f8 100644 --- a/packages/app-mobile/components/screens/ConfigScreen/types.ts +++ b/packages/app-mobile/components/screens/ConfigScreen/types.ts @@ -9,3 +9,7 @@ export interface CustomSettingSection { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied export type UpdateSettingValueCallback = (key: string, value: any)=> Promise; + +export interface PluginStatusRecord { + [pluginId: string]: boolean; +} diff --git a/packages/app-mobile/plugins/PluginRunner/dialogs/PluginPanelViewer.tsx b/packages/app-mobile/plugins/PluginRunner/dialogs/PluginPanelViewer.tsx index b27e03d53..df8c890c7 100644 --- a/packages/app-mobile/plugins/PluginRunner/dialogs/PluginPanelViewer.tsx +++ b/packages/app-mobile/plugins/PluginRunner/dialogs/PluginPanelViewer.tsx @@ -13,7 +13,7 @@ import { View, StyleSheet, AccessibilityInfo } from 'react-native'; import { _ } from '@joplin/lib/locale'; import Setting from '@joplin/lib/models/Setting'; import { Dispatch } from 'redux'; -import DismissibleDialog from '../../../components/DismissibleDialog'; +import DismissibleDialog, { DialogSize } from '../../../components/DismissibleDialog'; interface Props { themeId: number; @@ -168,6 +168,7 @@ const PluginPanelViewer: React.FC = props => { {renderTabContent()} diff --git a/packages/app-mobile/plugins/hooks/usePlugin.ts b/packages/app-mobile/plugins/hooks/usePlugin.ts index 663463cb9..4593d685a 100644 --- a/packages/app-mobile/plugins/hooks/usePlugin.ts +++ b/packages/app-mobile/plugins/hooks/usePlugin.ts @@ -1,10 +1,42 @@ import PluginService from '@joplin/lib/services/plugins/PluginService'; -import { useMemo } from 'react'; +import Logger from '@joplin/utils/Logger'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +const logger = Logger.create('usePlugin'); const usePlugin = (pluginId: string) => { - return useMemo(() => { + const [pluginReloadCounter, setPluginReloadCounter] = useState(0); + + const plugin = useMemo(() => { + if (!PluginService.instance().pluginIds.includes(pluginId)) { + return null; + } + + if (pluginReloadCounter > 0) { + logger.debug('Reloading plugin', pluginId, 'because the set of loaded plugins changed.'); + } + return PluginService.instance().pluginById(pluginId); - }, [pluginId]); + // The dependency on pluginReloadCounter is important -- it ensures that the plugin + // matches the one loaded in the PluginService. + }, [pluginId, pluginReloadCounter]); + + const reloadCounterRef = useRef(0); + reloadCounterRef.current = pluginReloadCounter; + + // The plugin may need to be re-fetched from the PluginService. When a plugin is reloaded, + // its Plugin object is replaced with a new one. + useEffect(() => { + const { remove } = PluginService.instance().addLoadedPluginsChangeListener(() => { + setPluginReloadCounter(reloadCounterRef.current + 1); + }); + + return () => { + remove(); + }; + }, []); + + return plugin; }; export default usePlugin; diff --git a/packages/app-mobile/root.tsx b/packages/app-mobile/root.tsx index 89152df1a..2b5f715c8 100644 --- a/packages/app-mobile/root.tsx +++ b/packages/app-mobile/root.tsx @@ -1202,8 +1202,10 @@ class AppComponent extends React.Component { onPrimaryContainer: theme.color5, primaryContainer: theme.backgroundColor5, - primary: theme.color, - onPrimary: theme.backgroundColor, + outline: theme.codeBorderColor, + + primary: theme.color4, + onPrimary: theme.backgroundColor4, background: theme.backgroundColor, diff --git a/packages/lib/components/shared/config/plugins/types.ts b/packages/lib/components/shared/config/plugins/types.ts index dea2b2c6d..4b7057a76 100644 --- a/packages/lib/components/shared/config/plugins/types.ts +++ b/packages/lib/components/shared/config/plugins/types.ts @@ -4,6 +4,7 @@ import { PluginManifest } from '../../../../services/plugins/utils/types'; export interface PluginItem { manifest: PluginManifest; + installed: boolean; enabled: boolean; deleted: boolean; devMode: boolean; diff --git a/packages/lib/services/plugins/PluginService.ts b/packages/lib/services/plugins/PluginService.ts index 636c35607..8c70487f2 100644 --- a/packages/lib/services/plugins/PluginService.ts +++ b/packages/lib/services/plugins/PluginService.ts @@ -142,17 +142,20 @@ export default class PluginService extends BaseService { this.isSafeMode_ = v; } - public waitForLoadedPluginsChange() { - return new Promise(resolve => { - this.pluginsChangeListeners_.push(() => resolve()); - }); + public addLoadedPluginsChangeListener(listener: ()=> void) { + this.pluginsChangeListeners_.push(listener); + + return { + remove: () => { + this.pluginsChangeListeners_ = this.pluginsChangeListeners_.filter(l => (l !== listener)); + }, + }; } private dispatchPluginsChangeListeners() { for (const listener of this.pluginsChangeListeners_) { listener(); } - this.pluginsChangeListeners_ = []; } private setPluginAt(pluginId: string, plugin: Plugin) { diff --git a/packages/lib/services/style/themeToCss.test.ts b/packages/lib/services/style/themeToCss.test.ts index cd176b834..8ff2f2c39 100644 --- a/packages/lib/services/style/themeToCss.test.ts +++ b/packages/lib/services/style/themeToCss.test.ts @@ -45,6 +45,7 @@ const input: Theme = { searchMarkerColor: 'black', warningBackgroundColor: '#FFD08D', + destructiveColor: '#F00000', tableBackgroundColor: 'rgb(247, 247, 247)', codeBackgroundColor: 'rgb(243, 243, 243)', @@ -89,6 +90,7 @@ const expected = ` --joplin-color-warn2: #ffcb81; --joplin-color-warn3: #ff7626; --joplin-color-warn-url: #155BDA; + --joplin-destructive-color: #F00000; --joplin-divider-color: #dddddd; --joplin-header-background-color: #ffffff; --joplin-odd-background-color: #eeeeee; diff --git a/packages/lib/themes/dark.ts b/packages/lib/themes/dark.ts index c76700af1..28c7e79ba 100644 --- a/packages/lib/themes/dark.ts +++ b/packages/lib/themes/dark.ts @@ -48,6 +48,7 @@ const theme: Theme = { searchMarkerColor: 'black', warningBackgroundColor: '#013F74', + destructiveColor: '#F07777', tableBackgroundColor: 'rgb(40, 41, 42)', codeBackgroundColor: 'rgb(47, 48, 49)', diff --git a/packages/lib/themes/light.ts b/packages/lib/themes/light.ts index fe2d36b33..28e39b9e8 100644 --- a/packages/lib/themes/light.ts +++ b/packages/lib/themes/light.ts @@ -45,6 +45,7 @@ const theme: Theme = { searchMarkerColor: 'black', warningBackgroundColor: '#FFD08D', + destructiveColor: '#D00707', tableBackgroundColor: 'rgb(247, 247, 247)', codeBackgroundColor: 'rgb(243, 243, 243)', diff --git a/packages/lib/themes/type.ts b/packages/lib/themes/type.ts index a734e80d5..49a629131 100644 --- a/packages/lib/themes/type.ts +++ b/packages/lib/themes/type.ts @@ -50,6 +50,7 @@ export interface Theme { searchMarkerColor: string; warningBackgroundColor: string; + destructiveColor: string; tableBackgroundColor: string; codeBackgroundColor: string;