mirror of
https://github.com/laurent22/joplin.git
synced 2024-12-21 09:38:01 +02:00
Mobile: Implement plugin screen redesign (#10465)
This commit is contained in:
parent
19f0b667b1
commit
06f42e8246
@ -586,6 +586,8 @@ packages/app-mobile/components/base-screen.js
|
|||||||
packages/app-mobile/components/biometrics/BiometricPopup.js
|
packages/app-mobile/components/biometrics/BiometricPopup.js
|
||||||
packages/app-mobile/components/biometrics/biometricAuthenticate.js
|
packages/app-mobile/components/biometrics/biometricAuthenticate.js
|
||||||
packages/app-mobile/components/biometrics/sensorInfo.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.test.js
|
||||||
packages/app-mobile/components/getResponsiveValue.js
|
packages/app-mobile/components/getResponsiveValue.js
|
||||||
packages/app-mobile/components/global-style.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/SettingsToggle.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/configScreenStyles.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/EnablePluginSupportPage.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/ActionButton.js
|
packages/app-mobile/components/screens/ConfigScreen/plugins/InstalledPluginBox.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginInfoButton.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/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/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/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/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/newRepoApi.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/pluginServiceSetup.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/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/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/ConfigScreen/types.js
|
||||||
packages/app-mobile/components/screens/JoplinCloudLoginScreen.js
|
packages/app-mobile/components/screens/JoplinCloudLoginScreen.js
|
||||||
packages/app-mobile/components/screens/LogScreen.js
|
packages/app-mobile/components/screens/LogScreen.js
|
||||||
|
21
.gitignore
vendored
21
.gitignore
vendored
@ -565,6 +565,8 @@ packages/app-mobile/components/base-screen.js
|
|||||||
packages/app-mobile/components/biometrics/BiometricPopup.js
|
packages/app-mobile/components/biometrics/BiometricPopup.js
|
||||||
packages/app-mobile/components/biometrics/biometricAuthenticate.js
|
packages/app-mobile/components/biometrics/biometricAuthenticate.js
|
||||||
packages/app-mobile/components/biometrics/sensorInfo.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.test.js
|
||||||
packages/app-mobile/components/getResponsiveValue.js
|
packages/app-mobile/components/getResponsiveValue.js
|
||||||
packages/app-mobile/components/global-style.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/SettingsToggle.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/configScreenStyles.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/EnablePluginSupportPage.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/ActionButton.js
|
packages/app-mobile/components/screens/ConfigScreen/plugins/InstalledPluginBox.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginInfoButton.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/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/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/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/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/newRepoApi.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/pluginServiceSetup.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/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/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/ConfigScreen/types.js
|
||||||
packages/app-mobile/components/screens/JoplinCloudLoginScreen.js
|
packages/app-mobile/components/screens/JoplinCloudLoginScreen.js
|
||||||
packages/app-mobile/components/screens/LogScreen.js
|
packages/app-mobile/components/screens/LogScreen.js
|
||||||
|
@ -38,6 +38,7 @@ interface Props {
|
|||||||
function manifestToItem(manifest: PluginManifest): PluginItem {
|
function manifestToItem(manifest: PluginManifest): PluginItem {
|
||||||
return {
|
return {
|
||||||
manifest: manifest,
|
manifest: manifest,
|
||||||
|
installed: true,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
deleted: false,
|
deleted: false,
|
||||||
devMode: false,
|
devMode: false,
|
||||||
|
@ -83,6 +83,7 @@ function usePluginItems(plugins: Plugins, settings: PluginSettings): PluginItem[
|
|||||||
|
|
||||||
output.push({
|
output.push({
|
||||||
manifest: plugin.manifest,
|
manifest: plugin.manifest,
|
||||||
|
installed: true,
|
||||||
enabled: setting.enabled,
|
enabled: setting.enabled,
|
||||||
deleted: setting.deleted,
|
deleted: setting.deleted,
|
||||||
devMode: plugin.devMode,
|
devMode: plugin.devMode,
|
||||||
|
@ -6,20 +6,32 @@ import { themeStyle } from './global-style';
|
|||||||
import Modal from './Modal';
|
import Modal from './Modal';
|
||||||
import { _ } from '@joplin/lib/locale';
|
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 {
|
interface Props {
|
||||||
themeId: number;
|
themeId: number;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
onDismiss: ()=> void;
|
onDismiss: ()=> void;
|
||||||
containerStyle?: ViewStyle;
|
containerStyle?: ViewStyle;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
|
||||||
|
size: DialogSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useStyles = (themeId: number, containerStyle: ViewStyle) => {
|
const useStyles = (themeId: number, containerStyle: ViewStyle, size: DialogSize) => {
|
||||||
const windowSize = useWindowDimensions();
|
const windowSize = useWindowDimensions();
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const theme = themeStyle(themeId);
|
const theme = themeStyle(themeId);
|
||||||
|
|
||||||
|
const maxWidth = size === DialogSize.Large ? Infinity : 500;
|
||||||
|
const maxHeight = size === DialogSize.Large ? Infinity : 700;
|
||||||
|
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
webView: {
|
webView: {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
@ -38,8 +50,10 @@ const useStyles = (themeId: number, containerStyle: ViewStyle) => {
|
|||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
padding: 10,
|
padding: 10,
|
||||||
|
|
||||||
height: windowSize.height * 0.9,
|
// Use Math.min with width and height -- the maxWidth and maxHeight style
|
||||||
width: windowSize.width * 0.97,
|
// 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,
|
flexShrink: 1,
|
||||||
|
|
||||||
// Center
|
// Center
|
||||||
@ -56,11 +70,11 @@ const useStyles = (themeId: number, containerStyle: ViewStyle) => {
|
|||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [themeId, windowSize.width, windowSize.height, containerStyle]);
|
}, [themeId, windowSize.width, windowSize.height, containerStyle, size]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const DismissibleDialog: React.FC<Props> = props => {
|
const DismissibleDialog: React.FC<Props> = props => {
|
||||||
const styles = useStyles(props.themeId, props.containerStyle);
|
const styles = useStyles(props.themeId, props.containerStyle, props.size);
|
||||||
|
|
||||||
const closeButton = (
|
const closeButton = (
|
||||||
<View style={styles.closeButtonContainer}>
|
<View style={styles.closeButtonContainer}>
|
||||||
|
81
packages/app-mobile/components/buttons/TextButton.tsx
Normal file
81
packages/app-mobile/components/buttons/TextButton.tsx
Normal file
@ -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<ButtonProps, 'item'|'onPress'|'children'> {
|
||||||
|
themeId: number;
|
||||||
|
type: ButtonType;
|
||||||
|
onPress: ()=> void;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TextButtonProps = Omit<Props, 'themeId'>;
|
||||||
|
|
||||||
|
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> = 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 <Button
|
||||||
|
{...props}
|
||||||
|
theme={theme}
|
||||||
|
mode={mode}
|
||||||
|
onPress={props.onPress}
|
||||||
|
>{props.children}</Button>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect((state: AppState) => {
|
||||||
|
return { themeId: state.settings.theme };
|
||||||
|
})(TextButton);
|
14
packages/app-mobile/components/buttons/index.tsx
Normal file
14
packages/app-mobile/components/buttons/index.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import TextButton, { ButtonType, TextButtonProps } from './TextButton';
|
||||||
|
|
||||||
|
type Props = Omit<TextButtonProps, 'type'>;
|
||||||
|
|
||||||
|
const makeTextButtonComponent = (type: ButtonType) => {
|
||||||
|
return (props: Props) => {
|
||||||
|
return <TextButton {...props} type={type} />;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PrimaryButton = makeTextButtonComponent(ButtonType.Primary);
|
||||||
|
export const SecondaryButton = makeTextButtonComponent(ButtonType.Secondary);
|
||||||
|
export const LinkButton = makeTextButtonComponent(ButtonType.Link);
|
@ -1,6 +1,6 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Platform, Linking, View, Switch, ScrollView, Text, TouchableOpacity, Alert, PermissionsAndroid, Dimensions, AccessibilityInfo } from 'react-native';
|
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 NavService from '@joplin/lib/services/NavService';
|
||||||
import SearchEngine from '@joplin/lib/services/search/SearchEngine';
|
import SearchEngine from '@joplin/lib/services/search/SearchEngine';
|
||||||
import checkPermissions from '../../../utils/checkPermissions';
|
import checkPermissions from '../../../utils/checkPermissions';
|
||||||
@ -26,7 +26,7 @@ import ExportProfileButton, { exportProfileButtonTitle } from './NoteExportSecti
|
|||||||
import SettingComponent from './SettingComponent';
|
import SettingComponent from './SettingComponent';
|
||||||
import ExportDebugReportButton, { exportDebugReportTitle } from './NoteExportSection/ExportDebugReportButton';
|
import ExportDebugReportButton, { exportDebugReportTitle } from './NoteExportSection/ExportDebugReportButton';
|
||||||
import SectionSelector from './SectionSelector';
|
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 PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService';
|
||||||
import PluginStates, { getSearchText as getPluginStatesSearchText } from './plugins/PluginStates';
|
import PluginStates, { getSearchText as getPluginStatesSearchText } from './plugins/PluginStates';
|
||||||
import PluginUploadButton, { canInstallPluginsFromFile, buttonLabel as pluginUploadButtonSearchText } from './plugins/PluginUploadButton';
|
import PluginUploadButton, { canInstallPluginsFromFile, buttonLabel as pluginUploadButtonSearchText } from './plugins/PluginUploadButton';
|
||||||
@ -389,7 +389,7 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
|
|||||||
const addSettingComponent = (
|
const addSettingComponent = (
|
||||||
component: ReactElement,
|
component: ReactElement,
|
||||||
relatedText: string|string[],
|
relatedText: string|string[],
|
||||||
settingMetadata?: SettingItem,
|
settingMetadata?: { advanced?: boolean },
|
||||||
) => {
|
) => {
|
||||||
const hiddenBySearch = this.state.searching && !matchesSearchQuery(relatedText);
|
const hiddenBySearch = this.state.searching && !matchesSearchQuery(relatedText);
|
||||||
if (component && !hiddenBySearch) {
|
if (component && !hiddenBySearch) {
|
||||||
@ -503,8 +503,10 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
|
|||||||
key='plugins-install-from-file'
|
key='plugins-install-from-file'
|
||||||
pluginSettings={settings[pluginStatesKey]}
|
pluginSettings={settings[pluginStatesKey]}
|
||||||
updatePluginStates={updatePluginStates}
|
updatePluginStates={updatePluginStates}
|
||||||
|
styles={this.styles()}
|
||||||
/>,
|
/>,
|
||||||
pluginUploadButtonSearchText(),
|
pluginUploadButtonSearchText(),
|
||||||
|
{ advanced: true },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -663,19 +665,15 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
|
|||||||
const renderAdvancedSettings = () => {
|
const renderAdvancedSettings = () => {
|
||||||
if (!advancedSettingComps.length) return null;
|
if (!advancedSettingComps.length) return null;
|
||||||
|
|
||||||
const toggleAdvancedLabel = this.state.showAdvancedSettings ? _('Hide Advanced Settings') : _('Show Advanced Settings');
|
const toggleAdvancedLabel = _('Advanced settings');
|
||||||
return (
|
return (
|
||||||
<>
|
<List.Accordion
|
||||||
<Button
|
title={toggleAdvancedLabel}
|
||||||
style={{ marginBottom: 20 }}
|
expanded={this.state.showAdvancedSettings}
|
||||||
icon={this.state.showAdvancedSettings ? 'menu-down' : 'menu-right'}
|
|
||||||
onPress={() => this.setState({ showAdvancedSettings: !this.state.showAdvancedSettings })}
|
onPress={() => this.setState({ showAdvancedSettings: !this.state.showAdvancedSettings })}
|
||||||
>
|
>
|
||||||
<Text>{toggleAdvancedLabel}</Text>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{this.state.showAdvancedSettings ? advancedSettingComps : null}
|
{this.state.showAdvancedSettings ? advancedSettingComps : null}
|
||||||
</>
|
</List.Accordion>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -3,7 +3,8 @@ import { themeStyle } from '../../../global-style';
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Linking, View, StyleSheet, ViewStyle, TextStyle } from 'react-native';
|
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 {
|
interface Props {
|
||||||
themeId: number;
|
themeId: number;
|
||||||
@ -50,7 +51,6 @@ const useStyles = (themeId: number) => {
|
|||||||
marginBottom: 0,
|
marginBottom: 0,
|
||||||
},
|
},
|
||||||
actionButton: {
|
actionButton: {
|
||||||
borderRadius: 10,
|
|
||||||
marginLeft: theme.marginLeft * 2,
|
marginLeft: theme.marginLeft * 2,
|
||||||
marginRight: theme.marginRight * 2,
|
marginRight: theme.marginRight * 2,
|
||||||
marginBottom: theme.margin,
|
marginBottom: theme.margin,
|
||||||
@ -58,18 +58,6 @@ const useStyles = (themeId: number) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const themeOverride = {
|
const themeOverride = {
|
||||||
secondaryButton: {
|
|
||||||
colors: {
|
|
||||||
primary: theme.color4,
|
|
||||||
outline: theme.color4,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
primaryButton: {
|
|
||||||
colors: {
|
|
||||||
primary: theme.color4,
|
|
||||||
onPrimary: theme.backgroundColor4,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
card: {
|
card: {
|
||||||
colors: {
|
colors: {
|
||||||
outline: theme.codeBorderColor,
|
outline: theme.codeBorderColor,
|
||||||
@ -127,8 +115,8 @@ const EnablePluginSupportPage: React.FC<Props> = props => {
|
|||||||
{renderCard('source-branch-check', _('Open Source'), _('Most plugins have source code available for review on the plugin website.'))}
|
{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.'))}
|
{renderCard('flag-remove', _('Report system'), _('We have a system for reporting and removing problematic plugins.'))}
|
||||||
<View>
|
<View>
|
||||||
<Button style={styles.actionButton} theme={themeOverride.secondaryButton} onPress={onLearnMorePress}>{_('Learn more')}</Button>
|
<LinkButton style={styles.actionButton} onPress={onLearnMorePress}>{_('Learn more')}</LinkButton>
|
||||||
<Button style={styles.actionButton} theme={themeOverride.primaryButton} mode='contained' onPress={props.onEnablePluginSupport}>{_('Enable plugin support')}</Button>
|
<PrimaryButton style={styles.actionButton} onPress={props.onEnablePluginSupport}>{_('Enable plugin support')}</PrimaryButton>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
@ -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> = 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 (
|
||||||
|
<PluginBox
|
||||||
|
themeId={props.themeId}
|
||||||
|
item={pluginItem}
|
||||||
|
isCompatible={isCompatible}
|
||||||
|
hasErrors={plugin.hasErrors}
|
||||||
|
showInstalledChip={props.showInstalledChip}
|
||||||
|
onShowPluginLog={props.callbacks.onShowPluginLog}
|
||||||
|
onShowPluginInfo={props.onShowPluginInfo}
|
||||||
|
updateState={updateState}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InstalledPluginBox;
|
@ -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> = props => {
|
||||||
|
const item = props.item;
|
||||||
|
|
||||||
|
const theme = themeStyle(props.themeId);
|
||||||
|
|
||||||
|
const renderErrorsChip = () => {
|
||||||
|
if (!props.hasErrors) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledChip
|
||||||
|
background={theme.backgroundColor2}
|
||||||
|
foreground={theme.colorError2}
|
||||||
|
icon='alert'
|
||||||
|
mode='flat'
|
||||||
|
onPress={() => props.onShowPluginLog({ item })}
|
||||||
|
>
|
||||||
|
{_('Error')}
|
||||||
|
</StyledChip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderRecommendedChip = () => {
|
||||||
|
if (!props.item.manifest._recommended || !props.isCompatible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return <StyledChip
|
||||||
|
background={theme.searchMarkerBackgroundColor}
|
||||||
|
foreground={theme.searchMarkerColor}
|
||||||
|
icon='crown'
|
||||||
|
onPress={onRecommendedPress}
|
||||||
|
>{_('Recommended')}</StyledChip>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderBuiltInChip = () => {
|
||||||
|
if (!props.item.builtIn) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return <StyledChip icon='code-tags-check'>{_('Built-in')}</StyledChip>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderIncompatibleChip = () => {
|
||||||
|
if (props.isCompatible) return null;
|
||||||
|
return (
|
||||||
|
<StyledChip
|
||||||
|
background={theme.backgroundColor3}
|
||||||
|
foreground={theme.color3}
|
||||||
|
icon='alert'
|
||||||
|
onPress={() => {
|
||||||
|
void shim.showMessageBox(
|
||||||
|
PluginService.instance().describeIncompatibility(props.item.manifest),
|
||||||
|
{ buttons: [_('OK')] },
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>{_('Incompatible')}</StyledChip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderUpdatableChip = () => {
|
||||||
|
if (!props.isCompatible || !props.canUpdate) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledChip>{_('Update available')}</StyledChip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderDisabledChip = () => {
|
||||||
|
if (props.item.enabled || !props.item.installed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return <StyledChip>{_('Disabled')}</StyledChip>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderInstalledChip = () => {
|
||||||
|
if (!props.showInstalledChip) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return <StyledChip>{_('Installed')}</StyledChip>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return <View style={containerStyle}>
|
||||||
|
{renderIncompatibleChip()}
|
||||||
|
{renderInstalledChip()}
|
||||||
|
{renderErrorsChip()}
|
||||||
|
{renderRecommendedChip()}
|
||||||
|
{renderBuiltInChip()}
|
||||||
|
{renderUpdatableChip()}
|
||||||
|
{renderDisabledChip()}
|
||||||
|
</View>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PluginChips;
|
@ -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> = props => {
|
|
||||||
const aboutPlugin = (
|
|
||||||
<View style={styles.aboutPluginContainer}>
|
|
||||||
<Text variant='titleLarge'>{props.item.manifest.name}</Text>
|
|
||||||
<Text variant='bodyLarge'>{props.item.manifest.author ? _('by %s', props.item.manifest.author) : ''}</Text>
|
|
||||||
<Text style={styles.descriptionText}>{props.item.manifest.description ?? _('No description')}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
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 = (
|
|
||||||
<List.Item
|
|
||||||
left={props => <List.Icon {...props} icon='bug'/>}
|
|
||||||
title={_('Report an issue')}
|
|
||||||
onPress={onReportIssuePress}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const onReportFraudulentPress = useCallback(() => {
|
|
||||||
void Linking.openURL('https://github.com/laurent22/joplin/security/advisories/new');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Portal>
|
|
||||||
<DismissibleDialog
|
|
||||||
themeId={props.themeId}
|
|
||||||
visible={true}
|
|
||||||
onDismiss={props.onModalDismiss}
|
|
||||||
>
|
|
||||||
<ScrollView>
|
|
||||||
{aboutPlugin}
|
|
||||||
<List.Item
|
|
||||||
left={props => <List.Icon {...props} icon='web'/>}
|
|
||||||
title={_('About')}
|
|
||||||
onPress={onAboutPress}
|
|
||||||
/>
|
|
||||||
{ reportIssueUrl ? reportIssueButton : null }
|
|
||||||
</ScrollView>
|
|
||||||
<Button
|
|
||||||
icon='shield-bug'
|
|
||||||
style={styles.fraudulentPluginButton}
|
|
||||||
onPress={onReportFraudulentPress}
|
|
||||||
>{_('Report fraudulent plugin')}</Button>
|
|
||||||
</DismissibleDialog>
|
|
||||||
</Portal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const PluginInfoButton: React.FC<Props> = props => {
|
|
||||||
const [showInfoModal, setShowInfoModal] = useState(false);
|
|
||||||
const onInfoButtonPress = useCallback(() => {
|
|
||||||
setShowInfoModal(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onModalDismiss = useCallback(() => {
|
|
||||||
setShowInfoModal(false);
|
|
||||||
props.onModalDismiss?.();
|
|
||||||
}, [props.onModalDismiss]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{showInfoModal ? <PluginInfoModal {...props} onModalDismiss={onModalDismiss} /> : null}
|
|
||||||
<IconButton
|
|
||||||
size={props.size}
|
|
||||||
icon='information'
|
|
||||||
onPress={onInfoButtonPress}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PluginInfoButton;
|
|
@ -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> = props => {
|
||||||
|
return <Text style={styles.title}>
|
||||||
|
<Text variant='titleMedium'>{
|
||||||
|
props.manifest.name
|
||||||
|
}</Text> <Text variant='bodySmall' style={styles.versionText}>v{
|
||||||
|
props.manifest.version
|
||||||
|
}</Text>
|
||||||
|
</Text>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PluginTitle;
|
@ -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> = 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<Props> = {};
|
||||||
|
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 <Chip
|
||||||
|
theme={themeOverride}
|
||||||
|
{...accessibilityProps}
|
||||||
|
{...props}
|
||||||
|
/>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RecommendedChip;
|
@ -1,12 +1,16 @@
|
|||||||
import * as React from 'react';
|
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 { _ } from '@joplin/lib/locale';
|
||||||
import { Alert, Linking, StyleSheet, View } from 'react-native';
|
|
||||||
import { PluginItem } from '@joplin/lib/components/shared/config/plugins/types';
|
import { PluginItem } from '@joplin/lib/components/shared/config/plugins/types';
|
||||||
import shim from '@joplin/lib/shim';
|
import ActionButton from '../buttons/ActionButton';
|
||||||
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
import { ButtonType } from '../../../../buttons/TextButton';
|
||||||
import ActionButton, { PluginCallback } from './ActionButton';
|
import PluginChips from './PluginChips';
|
||||||
import PluginInfoButton from './PluginInfoButton';
|
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 {
|
export enum InstallState {
|
||||||
NotInstalled,
|
NotInstalled,
|
||||||
@ -14,197 +18,98 @@ export enum InstallState {
|
|||||||
Installed,
|
Installed,
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum UpdateState {
|
|
||||||
Idle = 1,
|
|
||||||
CanUpdate = 2,
|
|
||||||
Updating = 3,
|
|
||||||
HasBeenUpdated = 4,
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
themeId: number;
|
themeId: number;
|
||||||
item: PluginItem;
|
item: PluginItem;
|
||||||
isCompatible: boolean;
|
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;
|
hasErrors?: boolean;
|
||||||
installState?: InstallState;
|
installState?: InstallState;
|
||||||
updateState?: UpdateState;
|
updateState?: UpdateState;
|
||||||
|
|
||||||
onAboutPress?: PluginCallback;
|
onAboutPress?: PluginCallback;
|
||||||
onInstall?: PluginCallback;
|
onInstall?: PluginCallback;
|
||||||
onUpdate?: PluginCallback;
|
|
||||||
onDelete?: PluginCallback;
|
|
||||||
onToggle?: PluginCallback;
|
|
||||||
onShowPluginLog?: PluginCallback;
|
onShowPluginLog?: PluginCallback;
|
||||||
|
onShowPluginInfo?: PluginCallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
const onRecommendedPress = () => {
|
const useStyles = (compatible: boolean) => {
|
||||||
Alert.alert(
|
return useMemo(() => {
|
||||||
'',
|
// For the TouchableRipple to work on Android, the card needs a transparent background.
|
||||||
_('The Joplin team has vetted this plugin and it meets our standards for security and performance.'),
|
const baseCard = { backgroundColor: 'transparent' };
|
||||||
[
|
return StyleSheet.create({
|
||||||
{
|
cardContainer: {
|
||||||
text: _('Learn more'),
|
margin: 0,
|
||||||
onPress: () => Linking.openURL('https://github.com/joplin/plugins/blob/master/readme/recommended.md'),
|
marginTop: 8,
|
||||||
|
padding: 0,
|
||||||
|
borderRadius: 14,
|
||||||
},
|
},
|
||||||
{
|
card: !compatible ? {
|
||||||
text: _('OK'),
|
...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) => <Icon {...props} source='puzzle'/>;
|
|
||||||
|
|
||||||
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> = props => {
|
const PluginBox: React.FC<Props> = props => {
|
||||||
const manifest = props.item.manifest;
|
const manifest = props.item.manifest;
|
||||||
const item = props.item;
|
const item = props.item;
|
||||||
|
|
||||||
const installButtonTitle = () => {
|
const installButton = <InstallButton
|
||||||
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 = (
|
|
||||||
<ActionButton
|
|
||||||
item={item}
|
item={item}
|
||||||
onPress={props.onInstall}
|
onInstall={props.onInstall}
|
||||||
disabled={props.installState !== InstallState.NotInstalled || !props.isCompatible}
|
installState={props.installState}
|
||||||
loading={props.installState === InstallState.Installing}
|
isCompatible={props.isCompatible}
|
||||||
title={installButtonTitle()}
|
/>;
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const getUpdateButtonTitle = () => {
|
const aboutButton = <ActionButton type={ButtonType.Link} item={item} onPress={props.onAboutPress} title={_('About')}/>;
|
||||||
if (props.updateState === UpdateState.Updating) return _('Updating...');
|
|
||||||
if (props.updateState === UpdateState.HasBeenUpdated) return _('Updated');
|
|
||||||
return _('Update');
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateButton = (
|
const onPress = useCallback(() => {
|
||||||
<ActionButton
|
props.onShowPluginInfo?.({ item: props.item });
|
||||||
item={item}
|
}, [props.onShowPluginInfo, props.item]);
|
||||||
onPress={props.onUpdate}
|
|
||||||
disabled={props.updateState !== UpdateState.CanUpdate || !props.isCompatible}
|
|
||||||
loading={props.updateState === UpdateState.Updating}
|
|
||||||
title={getUpdateButtonTitle()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const deleteButton = (
|
const styles = useStyles(props.isCompatible);
|
||||||
<ActionButton
|
|
||||||
item={item}
|
|
||||||
onPress={props.onDelete}
|
|
||||||
disabled={props.item.deleted}
|
|
||||||
title={props.item.deleted ? _('Deleted') : _('Delete')}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
const disableButton = <ActionButton item={item} onPress={props.onToggle} title={_('Disable')}/>;
|
|
||||||
const enableButton = <ActionButton item={item} onPress={props.onToggle} title={_('Enable')}/>;
|
|
||||||
const aboutButton = <ActionButton item={item} onPress={props.onAboutPress} icon='web' title={_('About')}/>;
|
|
||||||
|
|
||||||
const renderErrorsChip = () => {
|
|
||||||
if (!props.hasErrors) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Chip
|
<TouchableRipple
|
||||||
icon='alert'
|
accessibilityRole='button'
|
||||||
mode='outlined'
|
accessible={true}
|
||||||
onPress={() => props.onShowPluginLog({ item })}
|
onPress={props.onShowPluginInfo ? onPress : null}
|
||||||
|
style={styles.cardContainer}
|
||||||
>
|
>
|
||||||
{_('Error')}
|
<Card
|
||||||
</Chip>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderRecommendedChip = () => {
|
|
||||||
if (!props.item.manifest._recommended || !props.isCompatible) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return <Chip
|
|
||||||
icon='crown'
|
|
||||||
mode='outlined'
|
mode='outlined'
|
||||||
onPress={onRecommendedPress}
|
style={styles.card}
|
||||||
|
testID='plugin-card'
|
||||||
>
|
>
|
||||||
{_('Recommended')}
|
<Card.Content style={styles.content}>
|
||||||
</Chip>;
|
<PluginTitle manifest={item.manifest} />
|
||||||
};
|
<Text numberOfLines={2}>{manifest.description}</Text>
|
||||||
|
<PluginChips
|
||||||
const renderBuiltInChip = () => {
|
themeId={props.themeId}
|
||||||
if (!props.item.builtIn) {
|
item={props.item}
|
||||||
return null;
|
showInstalledChip={props.showInstalledChip}
|
||||||
}
|
hasErrors={props.hasErrors}
|
||||||
return <Chip icon='code-tags-check' mode='outlined'>{_('Built-in')}</Chip>;
|
canUpdate={props.updateState === UpdateState.CanUpdate}
|
||||||
};
|
onShowPluginLog={props.onShowPluginLog}
|
||||||
|
isCompatible={props.isCompatible}
|
||||||
const renderIncompatibleChip = () => {
|
|
||||||
if (props.isCompatible) return null;
|
|
||||||
return (
|
|
||||||
<Chip
|
|
||||||
icon='alert'
|
|
||||||
mode='outlined'
|
|
||||||
onPress={() => {
|
|
||||||
void shim.showMessageBox(
|
|
||||||
PluginService.instance().describeIncompatibility(props.item.manifest),
|
|
||||||
{ buttons: [_('OK')] },
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>{_('Incompatible')}</Chip>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 <PluginInfoButton {...buttonProps} themeId={props.themeId} item={props.item}/>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateStateIsIdle = props.updateState !== UpdateState.Idle;
|
|
||||||
|
|
||||||
const titleComponent = <>
|
|
||||||
<Text variant='titleMedium'>{manifest.name}</Text> <Text variant='bodySmall' style={styles.versionText}>v{manifest.version}</Text>
|
|
||||||
</>;
|
|
||||||
return (
|
|
||||||
<Card style={{ margin: 8, opacity: props.isCompatible ? undefined : 0.75 }} testID='plugin-card'>
|
|
||||||
<Card.Title
|
|
||||||
title={titleComponent}
|
|
||||||
titleStyle={styles.title}
|
|
||||||
subtitle={manifest.description}
|
|
||||||
left={PluginIcon}
|
|
||||||
right={renderRightEdgeButton}
|
|
||||||
/>
|
/>
|
||||||
<Card.Content>
|
|
||||||
<View style={{ flexDirection: 'row' }}>
|
|
||||||
{renderIncompatibleChip()}
|
|
||||||
{renderErrorsChip()}
|
|
||||||
{renderRecommendedChip()}
|
|
||||||
{renderBuiltInChip()}
|
|
||||||
</View>
|
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
<Card.Actions>
|
<Card.Actions>
|
||||||
{props.onAboutPress ? aboutButton : null}
|
{props.onAboutPress ? aboutButton : null}
|
||||||
{props.onInstall ? installButton : 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}
|
|
||||||
</Card.Actions>
|
</Card.Actions>
|
||||||
</Card>
|
</Card>
|
||||||
|
</TouchableRipple>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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<EnabledSwitchProps> = 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 <View style={styles.enabledSwitchContainer}>
|
||||||
|
<Text nativeID='enabledLabel'>{_('Enabled')}</Text>
|
||||||
|
<Switch accessibilityLabelledBy='enabledLabel' value={props.item.enabled} onValueChange={onChange} />
|
||||||
|
</View>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PluginInfoModalContent: React.FC<Props> = 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 = (
|
||||||
|
<Card mode='outlined' style={{ margin: 8 }} testID='plugin-card'>
|
||||||
|
<Card.Content>
|
||||||
|
<PluginTitle manifest={manifest}/>
|
||||||
|
<Text variant='bodyMedium'>{_('by %s', manifest.author)}</Text>
|
||||||
|
<View style={styles.pluginDescriptionContainer}>
|
||||||
|
<PluginChips
|
||||||
|
themeId={props.themeId}
|
||||||
|
item={item}
|
||||||
|
showInstalledChip={false}
|
||||||
|
hasErrors={plugin.hasErrors}
|
||||||
|
canUpdate={false}
|
||||||
|
onShowPluginLog={props.pluginCallbacks.onShowPluginLog}
|
||||||
|
isCompatible={isCompatible}
|
||||||
|
/>
|
||||||
|
<Text>{manifest.description}</Text>
|
||||||
|
</View>
|
||||||
|
</Card.Content>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
const onAboutPress = useCallback(() => {
|
||||||
|
void openWebsiteForPlugin({ item });
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
const reportIssueUrl = useMemo(() => {
|
||||||
|
return getPluginIssueReportUrl(manifest);
|
||||||
|
}, [manifest]);
|
||||||
|
|
||||||
|
const onReportIssuePress = useCallback(() => {
|
||||||
|
void Linking.openURL(reportIssueUrl);
|
||||||
|
}, [reportIssueUrl]);
|
||||||
|
|
||||||
|
const reportIssueButton = (
|
||||||
|
<TextButton
|
||||||
|
type={ButtonType.Secondary}
|
||||||
|
onPress={onReportIssuePress}
|
||||||
|
>{_('Report an issue')}</TextButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
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 = (
|
||||||
|
<ActionButton
|
||||||
|
item={item}
|
||||||
|
type={ButtonType.Secondary}
|
||||||
|
onPress={props.pluginCallbacks.onUpdate}
|
||||||
|
disabled={updateState !== UpdateState.CanUpdate || !isCompatible}
|
||||||
|
loading={updateState === UpdateState.Updating}
|
||||||
|
title={getUpdateButtonTitle()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const installState = (() => {
|
||||||
|
if (item.installed) return InstallState.Installed;
|
||||||
|
if (props.installingPluginIds[pluginId]) return InstallState.Installing;
|
||||||
|
return InstallState.NotInstalled;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const installButton = (
|
||||||
|
<InstallButton
|
||||||
|
item={item}
|
||||||
|
onInstall={props.pluginCallbacks.onInstall}
|
||||||
|
installState={installState}
|
||||||
|
isCompatible={isCompatible}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteButton = (
|
||||||
|
<ActionButton
|
||||||
|
item={item}
|
||||||
|
type={ButtonType.Delete}
|
||||||
|
onPress={props.pluginCallbacks.onDelete}
|
||||||
|
disabled={item.builtIn || (item?.deleted ?? true)}
|
||||||
|
title={item?.deleted ? _('Deleted') : _('Delete')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteButtonContainer = <>
|
||||||
|
<View style={styles.buttonContainer}>
|
||||||
|
{deleteButton}
|
||||||
|
</View>
|
||||||
|
<Divider />
|
||||||
|
</>;
|
||||||
|
|
||||||
|
const reportIssuesContainer = (
|
||||||
|
<List.Accordion title={_('Report any issues concerning the plugin.')} titleNumberOfLines={2}>
|
||||||
|
<View style={styles.accordionContent}>
|
||||||
|
<TextButton
|
||||||
|
type={ButtonType.Secondary}
|
||||||
|
onPress={onReportFraudulentPress}
|
||||||
|
>{_('Report fraudulent plugin')}</TextButton>
|
||||||
|
{reportIssueButton}
|
||||||
|
</View>
|
||||||
|
</List.Accordion>
|
||||||
|
);
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<ScrollView>
|
||||||
|
{aboutPlugin}
|
||||||
|
<EnabledSwitch item={item} onToggle={props.pluginCallbacks.onToggle}/>
|
||||||
|
<Divider />
|
||||||
|
<View style={styles.buttonContainer}>
|
||||||
|
{!item.installed ? installButton : null}
|
||||||
|
<TextButton
|
||||||
|
type={item.installed ? ButtonType.Primary : ButtonType.Secondary}
|
||||||
|
onPress={onAboutPress}
|
||||||
|
>{_('About')}</TextButton>
|
||||||
|
{updateState !== UpdateState.Idle ? updateButton : null}
|
||||||
|
</View>
|
||||||
|
<Divider />
|
||||||
|
{ item.installed ? deleteButtonContainer : null }
|
||||||
|
{reportIssuesContainer}
|
||||||
|
</ScrollView>
|
||||||
|
</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PluginInfoModal: React.FC<Props> = props => {
|
||||||
|
return (
|
||||||
|
<Portal>
|
||||||
|
<DismissibleDialog
|
||||||
|
themeId={props.themeId}
|
||||||
|
visible={props.visible}
|
||||||
|
size={DialogSize.Small}
|
||||||
|
onDismiss={props.onModalDismiss}
|
||||||
|
>
|
||||||
|
{ props.item ? <PluginInfoModalContent {...props}/> : null }
|
||||||
|
</DismissibleDialog>
|
||||||
|
</Portal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PluginInfoModal;
|
@ -1,65 +1,25 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import RepositoryApi from '@joplin/lib/services/plugins/RepositoryApi';
|
import { createTempDir, mockMobilePlatform, setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||||
import { createTempDir, mockMobilePlatform, setupDatabaseAndSynchronizer, supportDir, 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 '@testing-library/react-native/extend-expect';
|
||||||
|
|
||||||
import Setting from '@joplin/lib/models/Setting';
|
|
||||||
import PluginService, { PluginSettings, defaultPluginSetting } from '@joplin/lib/services/plugins/PluginService';
|
import PluginService, { PluginSettings, defaultPluginSetting } from '@joplin/lib/services/plugins/PluginService';
|
||||||
import { useCallback, useState } from 'react';
|
|
||||||
import pluginServiceSetup from './testUtils/pluginServiceSetup';
|
import pluginServiceSetup from './testUtils/pluginServiceSetup';
|
||||||
import PluginStates from './PluginStates';
|
import { writeFile } from 'fs-extra';
|
||||||
import configScreenStyles from '../configScreenStyles';
|
|
||||||
import { remove, writeFile } from 'fs-extra';
|
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import shim from '@joplin/lib/shim';
|
import shim from '@joplin/lib/shim';
|
||||||
import { resetRepoApi } from './utils/useRepoApi';
|
import { resetRepoApi } from './utils/useRepoApi';
|
||||||
import { Store } from 'redux';
|
import { Store } from 'redux';
|
||||||
import { AppState } from '../../../../utils/types';
|
import { AppState } from '../../../../utils/types';
|
||||||
import createMockReduxStore from '../../../../utils/testing/createMockReduxStore';
|
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<AppState> = null;
|
let reduxStore: Store<AppState> = 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 (
|
|
||||||
<PluginStates
|
|
||||||
styles={styles}
|
|
||||||
themeId={Setting.THEME_LIGHT}
|
|
||||||
updatePluginStates={updatePluginStates}
|
|
||||||
pluginSettings={pluginSettings}
|
|
||||||
shouldShowBasedOnSearchQuery={shouldShowBasedOnSettingSearchQuery}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 loadMockPlugin = async (id: string, name: string, version: string, pluginSettings: PluginSettings) => {
|
||||||
const service = PluginService.instance();
|
const service = PluginService.instance();
|
||||||
const pluginSource = `
|
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 () => {
|
beforeEach(async () => {
|
||||||
await setupDatabaseAndSynchronizer(0);
|
await setupDatabaseAndSynchronizer(0);
|
||||||
await switchClient(0);
|
await switchClient(0);
|
||||||
@ -128,23 +93,27 @@ describe('PluginStates', () => {
|
|||||||
await loadMockPlugin(backlinksPluginId, 'Backlinks to note', '0.0.1', defaultPluginSettings);
|
await loadMockPlugin(backlinksPluginId, 'Backlinks to note', '0.0.1', defaultPluginSettings);
|
||||||
expect(PluginService.instance().plugins[backlinksPluginId]).toBeTruthy();
|
expect(PluginService.instance().plugins[backlinksPluginId]).toBeTruthy();
|
||||||
|
|
||||||
render(
|
const wrapper = render(
|
||||||
<PluginStatesWrapper
|
<WrappedPluginStates
|
||||||
initialPluginSettings={defaultPluginSettings}
|
initialPluginSettings={defaultPluginSettings}
|
||||||
|
store={reduxStore}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
await showInstalledTab();
|
||||||
expect(await screen.findByText(/^ABC Sheet Music/)).toBeVisible();
|
expect(await screen.findByText(/^ABC Sheet Music/)).toBeVisible();
|
||||||
expect(await screen.findByText(/^Backlinks to note/)).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).
|
// 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') {
|
if (platform === 'android') {
|
||||||
expect(await screen.findByRole('button', backlinksToNoteQuery)).toBeVisible();
|
expect(updateMarkers).toHaveLength(2);
|
||||||
} else {
|
} 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 () => {
|
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);
|
await loadMockPlugin(abcPluginId, 'ABC Sheet Music', outdatedVersion, defaultPluginSettings);
|
||||||
expect(PluginService.instance().plugins[abcPluginId]).toBeTruthy();
|
expect(PluginService.instance().plugins[abcPluginId]).toBeTruthy();
|
||||||
|
|
||||||
render(
|
const wrapper = render(
|
||||||
<PluginStatesWrapper
|
<WrappedPluginStates
|
||||||
initialPluginSettings={defaultPluginSettings}
|
initialPluginSettings={defaultPluginSettings}
|
||||||
|
store={reduxStore}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(await screen.findByText(/^ABC Sheet Music/)).toBeVisible();
|
await showInstalledTab();
|
||||||
expect(await screen.findByRole('button', { name: 'Update ABC Sheet Music', disabled: false })).toBeVisible();
|
|
||||||
|
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();
|
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 () => {
|
it('should update the list of installed plugins when a plugin is installed and uninstalled', async () => {
|
||||||
const pluginSettings: PluginSettings = { };
|
const pluginSettings: PluginSettings = { };
|
||||||
|
|
||||||
render(
|
const wrapper = render(
|
||||||
<PluginStatesWrapper
|
<WrappedPluginStates
|
||||||
initialPluginSettings={pluginSettings}
|
initialPluginSettings={pluginSettings}
|
||||||
|
store={reduxStore}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
await showInstalledTab();
|
||||||
|
|
||||||
// Initially, no plugins should be visible.
|
// Initially, no plugins should be visible.
|
||||||
expect(screen.queryByText(/^ABC Sheet Music/)).toBeNull();
|
expect(screen.queryByText(/^ABC Sheet Music/)).toBeNull();
|
||||||
@ -191,5 +168,103 @@ describe('PluginStates', () => {
|
|||||||
await act(() => PluginService.instance().uninstallPlugin(testPluginId1));
|
await act(() => PluginService.instance().uninstallPlugin(testPluginId1));
|
||||||
expect(await screen.findByText(/^A test plugin/)).toBeVisible();
|
expect(await screen.findByText(/^A test plugin/)).toBeVisible();
|
||||||
expect(screen.queryByText(/^ABC Sheet Music/)).toBeNull();
|
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(
|
||||||
|
<WrappedPluginStates
|
||||||
|
initialPluginSettings={defaultPluginSettings}
|
||||||
|
store={reduxStore}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<WrappedPluginStates
|
||||||
|
initialPluginSettings={defaultPluginSettings}
|
||||||
|
store={reduxStore}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
@ -1,37 +1,16 @@
|
|||||||
import * as React from 'react';
|
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 { mockMobilePlatform, setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||||
|
|
||||||
import { render, screen, userEvent, waitFor } from '@testing-library/react-native';
|
import { render, screen, userEvent, waitFor } from '@testing-library/react-native';
|
||||||
import '@testing-library/react-native/extend-expect';
|
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 pluginServiceSetup from './testUtils/pluginServiceSetup';
|
||||||
import newRepoApi from './testUtils/newRepoApi';
|
|
||||||
import createMockReduxStore from '../../../../utils/testing/createMockReduxStore';
|
import createMockReduxStore from '../../../../utils/testing/createMockReduxStore';
|
||||||
|
import WrappedPluginStates from './testUtils/WrappedPluginStates';
|
||||||
interface WrapperProps {
|
import { AppState } from '../../../../utils/types';
|
||||||
repoApi: RepositoryApi;
|
import { Store } from 'redux';
|
||||||
repoApiInitialized?: boolean;
|
import mockRepositoryApiConstructor from './testUtils/mockRepositoryApiConstructor';
|
||||||
pluginSettings?: PluginSettings;
|
import { resetRepoApi } from './utils/useRepoApi';
|
||||||
onUpdatePluginStates?: (states: PluginSettings)=> void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const noOpFunction = ()=>{};
|
|
||||||
|
|
||||||
const SearchWrapper = (props: WrapperProps) => {
|
|
||||||
return (
|
|
||||||
<SearchPlugins
|
|
||||||
themeId={Setting.THEME_LIGHT}
|
|
||||||
pluginSettings={props.pluginSettings ?? {}}
|
|
||||||
repoApiInitialized={props.repoApiInitialized ?? true}
|
|
||||||
repoApi={props.repoApi}
|
|
||||||
onUpdatePluginStates={props.onUpdatePluginStates ?? noOpFunction}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const expectSearchResultCountToBe = async (count: number) => {
|
const expectSearchResultCountToBe = async (count: number) => {
|
||||||
await waitFor(() => {
|
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<AppState>;
|
||||||
|
|
||||||
|
describe('PluginStates.search', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await setupDatabaseAndSynchronizer(0);
|
await setupDatabaseAndSynchronizer(0);
|
||||||
await switchClient(0);
|
await switchClient(0);
|
||||||
pluginServiceSetup(createMockReduxStore());
|
reduxStore = createMockReduxStore();
|
||||||
|
pluginServiceSetup(reduxStore);
|
||||||
|
mockMobilePlatform('android');
|
||||||
|
resetRepoApi();
|
||||||
|
|
||||||
|
await mockRepositoryApiConstructor();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should find results', async () => {
|
it('should find results', async () => {
|
||||||
const repoApi = await newRepoApi(InstallMode.Default);
|
const wrapper = render(<WrappedPluginStates initialPluginSettings={{}} store={reduxStore}/>);
|
||||||
render(<SearchWrapper repoApi={repoApi}/>);
|
|
||||||
|
|
||||||
const searchBox = screen.queryByPlaceholderText('Search');
|
|
||||||
expect(searchBox).toBeVisible();
|
|
||||||
|
|
||||||
// No plugin cards should be visible by default
|
|
||||||
expect(screen.queryAllByTestId('plugin-card')).toHaveLength(0);
|
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
await showSearchTab();
|
||||||
|
const searchBox = await getEditableSearchBox();
|
||||||
await user.type(searchBox, 'backlinks');
|
await user.type(searchBox, 'backlinks');
|
||||||
|
|
||||||
// Should find one result
|
// Should find one result
|
||||||
@ -71,19 +69,27 @@ describe('SearchPlugins', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.queryAllByTestId('plugin-card').length).toBeGreaterThan(2);
|
expect(screen.queryAllByTestId('plugin-card').length).toBeGreaterThan(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should only show recommended plugin search results on iOS-like environments', async () => {
|
it('should only show recommended plugin search results on iOS-like environments', async () => {
|
||||||
// iOS uses restricted install mode
|
// iOS uses restricted install mode
|
||||||
const repoApi = await newRepoApi(InstallMode.Restricted);
|
mockMobilePlatform('ios');
|
||||||
render(<SearchWrapper repoApi={repoApi}/>);
|
await mockRepositoryApiConstructor();
|
||||||
|
|
||||||
const searchBox = screen.queryByPlaceholderText('Search');
|
const wrapper = render(<WrappedPluginStates initialPluginSettings={{}} store={reduxStore}/>);
|
||||||
expect(searchBox).toBeVisible();
|
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
await showSearchTab();
|
||||||
|
|
||||||
|
const searchBox = await getEditableSearchBox();
|
||||||
|
|
||||||
|
await user.press(searchBox);
|
||||||
await user.type(searchBox, 'abc');
|
await user.type(searchBox, 'abc');
|
||||||
|
|
||||||
|
expect(searchBox.props.value).toBe('abc');
|
||||||
|
|
||||||
// Should find recommended plugins
|
// Should find recommended plugins
|
||||||
await expectSearchResultCountToBe(1);
|
await expectSearchResultCountToBe(1);
|
||||||
|
|
||||||
@ -97,16 +103,20 @@ describe('SearchPlugins', () => {
|
|||||||
await expectSearchResultCountToBe(1);
|
await expectSearchResultCountToBe(1);
|
||||||
expect(screen.getByText(/ABC Sheet Music/i)).toBeTruthy();
|
expect(screen.getByText(/ABC Sheet Music/i)).toBeTruthy();
|
||||||
expect(screen.queryByText(/backlink/i)).toBeNull();
|
expect(screen.queryByText(/backlink/i)).toBeNull();
|
||||||
|
|
||||||
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should mark incompatible plugins as incompatible', async () => {
|
it('should mark incompatible plugins as incompatible', async () => {
|
||||||
const mock = mockMobilePlatform('android');
|
const wrapper = render(<WrappedPluginStates initialPluginSettings={{}} store={reduxStore}/>);
|
||||||
const repoApi = await newRepoApi(InstallMode.Default);
|
|
||||||
render(<SearchWrapper repoApi={repoApi}/>);
|
|
||||||
|
|
||||||
const searchBox = screen.queryByPlaceholderText('Search');
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
await showSearchTab();
|
||||||
|
|
||||||
|
const searchBox = await getEditableSearchBox();
|
||||||
|
await user.press(searchBox);
|
||||||
await user.type(searchBox, 'abc');
|
await user.type(searchBox, 'abc');
|
||||||
|
expect(searchBox.props.value).toBe('abc');
|
||||||
|
|
||||||
await expectSearchResultCountToBe(1);
|
await expectSearchResultCountToBe(1);
|
||||||
expect(screen.queryByText('Incompatible')).toBeNull();
|
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(/Note list and side bar/i)).toBeVisible();
|
||||||
expect(await screen.findByText('Incompatible')).toBeVisible();
|
expect(await screen.findByText('Incompatible')).toBeVisible();
|
||||||
|
|
||||||
mock.reset();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
});
|
});
|
@ -1,17 +1,17 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { ConfigScreenStyles } from '../configScreenStyles';
|
import { ConfigScreenStyles } from '../configScreenStyles';
|
||||||
import { View } from 'react-native';
|
import { View, StyleSheet } from 'react-native';
|
||||||
import { Banner, Button, Text } from 'react-native-paper';
|
import { Banner, Text, Button, ProgressBar, List, Divider } from 'react-native-paper';
|
||||||
import { _ } from '@joplin/lib/locale';
|
import { _, _n } from '@joplin/lib/locale';
|
||||||
import PluginService, { PluginSettings, SerializedPluginSettings } from '@joplin/lib/services/plugins/PluginService';
|
import PluginService, { PluginSettings, SerializedPluginSettings } from '@joplin/lib/services/plugins/PluginService';
|
||||||
import PluginToggle from './PluginToggle';
|
import InstalledPluginBox from './InstalledPluginBox';
|
||||||
import SearchPlugins from './SearchPlugins';
|
import SearchPlugins from './SearchPlugins';
|
||||||
import { ItemEvent } from '@joplin/lib/components/shared/config/plugins/types';
|
import { ItemEvent, PluginItem } from '@joplin/lib/components/shared/config/plugins/types';
|
||||||
import NavService from '@joplin/lib/services/NavService';
|
|
||||||
import useRepoApi from './utils/useRepoApi';
|
import useRepoApi from './utils/useRepoApi';
|
||||||
import RepositoryApi from '@joplin/lib/services/plugins/RepositoryApi';
|
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 {
|
interface Props {
|
||||||
themeId: number;
|
themeId: number;
|
||||||
@ -43,21 +43,33 @@ const useLoadedPluginIds = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
const [loadedPluginIds, setLoadedPluginIds] = useState(getLoadedPlugins);
|
const [loadedPluginIds, setLoadedPluginIds] = useState(getLoadedPlugins);
|
||||||
|
|
||||||
useAsyncEffect(async event => {
|
useEffect(() => {
|
||||||
while (!event.cancelled) {
|
const { remove } = PluginService.instance().addLoadedPluginsChangeListener(() => {
|
||||||
await PluginService.instance().waitForLoadedPluginsChange();
|
|
||||||
setLoadedPluginIds(getLoadedPlugins());
|
setLoadedPluginIds(getLoadedPlugins());
|
||||||
}
|
});
|
||||||
}, []);
|
|
||||||
|
return () => {
|
||||||
|
remove();
|
||||||
|
};
|
||||||
|
}, [getLoadedPlugins]);
|
||||||
|
|
||||||
return loadedPluginIds;
|
return loadedPluginIds;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
installedPluginsContainer: {
|
||||||
|
marginLeft: 8,
|
||||||
|
marginRight: 8,
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const PluginStates: React.FC<Props> = props => {
|
const PluginStates: React.FC<Props> = props => {
|
||||||
const [repoApiError, setRepoApiError] = useState(null);
|
const [repoApiError, setRepoApiError] = useState(null);
|
||||||
const [repoApiLoaded, setRepoApiLoaded] = useState(false);
|
const [repoApiLoaded, setRepoApiLoaded] = useState(false);
|
||||||
const [reloadRepoCounter, setRepoReloadCounter] = useState(0);
|
const [reloadRepoCounter, setRepoReloadCounter] = useState(0);
|
||||||
const [updatablePluginIds, setUpdatablePluginIds] = useState<Record<string, boolean>>({});
|
const [updatablePluginIds, setUpdatablePluginIds] = useState<Record<string, boolean>>({});
|
||||||
|
const [shownInDialogItem, setShownInDialogItem] = useState<PluginItem|null>(null);
|
||||||
|
|
||||||
const onRepoApiLoaded = useCallback(async (repoApi: RepositoryApi) => {
|
const onRepoApiLoaded = useCallback(async (repoApi: RepositoryApi) => {
|
||||||
const manifests = Object.values(PluginService.instance().plugins)
|
const manifests = Object.values(PluginService.instance().plugins)
|
||||||
@ -98,15 +110,26 @@ const PluginStates: React.FC<Props> = props => {
|
|||||||
<Button onPress={reloadPluginRepo}>{_('Retry')}</Button>
|
<Button onPress={reloadPluginRepo}>{_('Retry')}</Button>
|
||||||
</View>;
|
</View>;
|
||||||
} else {
|
} else {
|
||||||
return <Text>{_('Loading plugin repository...')}</Text>;
|
return <ProgressBar accessibilityLabel={_('Loading...')} indeterminate={true} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onShowPluginLog = useCallback((event: ItemEvent) => {
|
const onShowPluginInfo = useCallback((event: ItemEvent) => {
|
||||||
const pluginId = event.item.manifest.id;
|
setShownInDialogItem(event.item);
|
||||||
void NavService.go('Log', { defaultFilter: pluginId });
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
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 installedPluginCards = [];
|
||||||
const pluginService = PluginService.instance();
|
const pluginService = PluginService.instance();
|
||||||
|
|
||||||
@ -116,16 +139,16 @@ const PluginStates: React.FC<Props> = props => {
|
|||||||
|
|
||||||
if (!props.shouldShowBasedOnSearchQuery || props.shouldShowBasedOnSearchQuery(plugin.manifest.name)) {
|
if (!props.shouldShowBasedOnSearchQuery || props.shouldShowBasedOnSearchQuery(plugin.manifest.name)) {
|
||||||
installedPluginCards.push(
|
installedPluginCards.push(
|
||||||
<PluginToggle
|
<InstalledPluginBox
|
||||||
key={`plugin-${pluginId}`}
|
key={`plugin-${pluginId}`}
|
||||||
themeId={props.themeId}
|
themeId={props.themeId}
|
||||||
pluginId={pluginId}
|
pluginId={pluginId}
|
||||||
styles={props.styles}
|
pluginSettings={pluginSettings}
|
||||||
pluginSettings={props.pluginSettings}
|
|
||||||
updatablePluginIds={updatablePluginIds}
|
updatablePluginIds={updatablePluginIds}
|
||||||
updatePluginStates={props.updatePluginStates}
|
updatingPluginIds={updatingPluginIds}
|
||||||
onShowPluginLog={onShowPluginLog}
|
showInstalledChip={false}
|
||||||
repoApi={repoApi}
|
onShowPluginInfo={onShowPluginInfo}
|
||||||
|
callbacks={pluginCallbacks}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -135,21 +158,65 @@ const PluginStates: React.FC<Props> = props => {
|
|||||||
!props.shouldShowBasedOnSearchQuery || props.shouldShowBasedOnSearchQuery(searchInputSearchText())
|
!props.shouldShowBasedOnSearchQuery || props.shouldShowBasedOnSearchQuery(searchInputSearchText())
|
||||||
);
|
);
|
||||||
|
|
||||||
const searchComponent = (
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
const searchAccordion = (
|
||||||
|
<List.Accordion
|
||||||
|
title={_('Install new plugins')}
|
||||||
|
description={_('Browse and install community plugins.')}
|
||||||
|
id='search'
|
||||||
|
>
|
||||||
<SearchPlugins
|
<SearchPlugins
|
||||||
pluginSettings={props.pluginSettings}
|
pluginSettings={pluginSettings}
|
||||||
themeId={props.themeId}
|
themeId={props.themeId}
|
||||||
onUpdatePluginStates={props.updatePluginStates}
|
onUpdatePluginStates={props.updatePluginStates}
|
||||||
|
installingPluginIds={installingPluginIds}
|
||||||
|
callbacks={pluginCallbacks}
|
||||||
repoApiInitialized={repoApiLoaded}
|
repoApiInitialized={repoApiLoaded}
|
||||||
repoApi={repoApi}
|
repoApi={repoApi}
|
||||||
|
updatingPluginIds={updatingPluginIds}
|
||||||
|
updatablePluginIds={updatablePluginIds}
|
||||||
|
onShowPluginInfo={onShowPluginInfo}
|
||||||
|
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
setSearchQuery={setSearchQuery}
|
||||||
/>
|
/>
|
||||||
|
</List.Accordion>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<View>
|
<View>
|
||||||
{renderRepoApiStatus()}
|
{renderRepoApiStatus()}
|
||||||
|
<List.AccordionGroup>
|
||||||
|
<List.Accordion
|
||||||
|
title={_('Installed plugins')}
|
||||||
|
description={installedAccordionDescription}
|
||||||
|
id='installed'
|
||||||
|
>
|
||||||
|
<View style={styles.installedPluginsContainer}>
|
||||||
{installedPluginCards}
|
{installedPluginCards}
|
||||||
{showSearch ? searchComponent : null}
|
</View>
|
||||||
|
</List.Accordion>
|
||||||
|
<Divider/>
|
||||||
|
{showSearch ? searchAccordion : null}
|
||||||
|
<Divider/>
|
||||||
|
</List.AccordionGroup>
|
||||||
|
<PluginInfoModal
|
||||||
|
themeId={props.themeId}
|
||||||
|
pluginSettings={pluginSettings}
|
||||||
|
updatablePluginIds={updatablePluginIds}
|
||||||
|
updatingPluginIds={updatingPluginIds}
|
||||||
|
installingPluginIds={installingPluginIds}
|
||||||
|
item={shownInDialogItem}
|
||||||
|
visible={!!shownInDialogItem}
|
||||||
|
onModalDismiss={onPluginDialogClosed}
|
||||||
|
pluginCallbacks={pluginCallbacks}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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<string, boolean>;
|
|
||||||
repoApi: RepositoryApi;
|
|
||||||
|
|
||||||
onShowPluginLog: (event: ItemEvent)=> void;
|
|
||||||
updatePluginStates: (settingValue: PluginSettings)=> void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PluginToggle: React.FC<Props> = 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<Record<string, boolean>>({});
|
|
||||||
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 (
|
|
||||||
<PluginBox
|
|
||||||
themeId={props.themeId}
|
|
||||||
item={pluginItem}
|
|
||||||
isCompatible={isCompatible}
|
|
||||||
hasErrors={plugin.hasErrors}
|
|
||||||
onShowPluginLog={props.onShowPluginLog}
|
|
||||||
onToggle={onToggle}
|
|
||||||
onDelete={onDelete}
|
|
||||||
onUpdate={onUpdate}
|
|
||||||
updateState={updateState}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PluginToggle;
|
|
@ -3,18 +3,20 @@ import { _ } from '@joplin/lib/locale';
|
|||||||
import PluginService, { PluginSettings, SerializedPluginSettings, defaultPluginSetting } from '@joplin/lib/services/plugins/PluginService';
|
import PluginService, { PluginSettings, SerializedPluginSettings, defaultPluginSetting } from '@joplin/lib/services/plugins/PluginService';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { Button } from 'react-native-paper';
|
|
||||||
import pickDocument from '../../../../utils/pickDocument';
|
import pickDocument from '../../../../utils/pickDocument';
|
||||||
import shim from '@joplin/lib/shim';
|
import shim from '@joplin/lib/shim';
|
||||||
import Logger from '@joplin/utils/Logger';
|
import Logger from '@joplin/utils/Logger';
|
||||||
import { Platform } from 'react-native';
|
import { Platform, View, ViewStyle } from 'react-native';
|
||||||
import { join, extname } from 'path';
|
import { join, extname } from 'path';
|
||||||
import uuid from '@joplin/lib/uuid';
|
import uuid from '@joplin/lib/uuid';
|
||||||
import Setting from '@joplin/lib/models/Setting';
|
import Setting from '@joplin/lib/models/Setting';
|
||||||
|
import TextButton, { ButtonType } from '../../../buttons/TextButton';
|
||||||
|
import { ConfigScreenStyles } from '../configScreenStyles';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
updatePluginStates: (settingValue: PluginSettings)=> void;
|
updatePluginStates: (settingValue: PluginSettings)=> void;
|
||||||
pluginSettings: SerializedPluginSettings;
|
pluginSettings: SerializedPluginSettings;
|
||||||
|
styles: ConfigScreenStyles;
|
||||||
}
|
}
|
||||||
|
|
||||||
const logger = Logger.create('PluginUploadButton');
|
const logger = Logger.create('PluginUploadButton');
|
||||||
@ -26,6 +28,8 @@ export const canInstallPluginsFromFile = () => {
|
|||||||
return shim.mobilePlatform() !== 'ios' || Setting.value('env') === 'dev';
|
return shim.mobilePlatform() !== 'ios' || Setting.value('env') === 'dev';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buttonStyle: ViewStyle = { flexGrow: 1 };
|
||||||
|
|
||||||
const PluginUploadButton: React.FC<Props> = props => {
|
const PluginUploadButton: React.FC<Props> = props => {
|
||||||
const [showLoadingAnimation, setShowLoadingAnimation] = useState(false);
|
const [showLoadingAnimation, setShowLoadingAnimation] = useState(false);
|
||||||
|
|
||||||
@ -85,13 +89,17 @@ const PluginUploadButton: React.FC<Props> = props => {
|
|||||||
}, [props.pluginSettings, props.updatePluginStates]);
|
}, [props.pluginSettings, props.updatePluginStates]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<View style={props.styles.getContainerStyle(false)}>
|
||||||
|
<TextButton
|
||||||
|
type={ButtonType.Primary}
|
||||||
onPress={onInstallFromFile}
|
onPress={onInstallFromFile}
|
||||||
|
style={buttonStyle}
|
||||||
disabled={showLoadingAnimation || !canInstallPluginsFromFile()}
|
disabled={showLoadingAnimation || !canInstallPluginsFromFile()}
|
||||||
loading={showLoadingAnimation}
|
loading={showLoadingAnimation}
|
||||||
>
|
>
|
||||||
{buttonLabel()}
|
{buttonLabel()}
|
||||||
</Button>
|
</TextButton>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -4,21 +4,32 @@ import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
|||||||
import { _ } from '@joplin/lib/locale';
|
import { _ } from '@joplin/lib/locale';
|
||||||
import { PluginManifest } from '@joplin/lib/services/plugins/utils/types';
|
import { PluginManifest } from '@joplin/lib/services/plugins/utils/types';
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { FlatList, View } from 'react-native';
|
import { FlatList, StyleSheet, View } from 'react-native';
|
||||||
import { Searchbar } from 'react-native-paper';
|
import { TextInput, Text } from 'react-native-paper';
|
||||||
import PluginBox, { InstallState } from './PluginBox';
|
import PluginBox, { InstallState } from './PluginBox';
|
||||||
import PluginService, { PluginSettings, SerializedPluginSettings } from '@joplin/lib/services/plugins/PluginService';
|
import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService';
|
||||||
import useInstallHandler from '@joplin/lib/components/shared/config/plugins/useOnInstallHandler';
|
import { PluginItem } from '@joplin/lib/components/shared/config/plugins/types';
|
||||||
import { OnPluginSettingChangeEvent, PluginItem } from '@joplin/lib/components/shared/config/plugins/types';
|
|
||||||
import RepositoryApi from '@joplin/lib/services/plugins/RepositoryApi';
|
import RepositoryApi from '@joplin/lib/services/plugins/RepositoryApi';
|
||||||
import openWebsiteForPlugin from './utils/openWebsiteForPlugin';
|
import openWebsiteForPlugin from './utils/openWebsiteForPlugin';
|
||||||
|
import { PluginCallback, PluginCallbacks } from './utils/usePluginCallbacks';
|
||||||
|
import InstalledPluginBox from './InstalledPluginBox';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
themeId: number;
|
themeId: number;
|
||||||
pluginSettings: SerializedPluginSettings;
|
pluginSettings: PluginSettings;
|
||||||
repoApiInitialized: boolean;
|
repoApiInitialized: boolean;
|
||||||
onUpdatePluginStates: (states: PluginSettings)=> void;
|
onUpdatePluginStates: (states: PluginSettings)=> void;
|
||||||
repoApi: RepositoryApi;
|
repoApi: RepositoryApi;
|
||||||
|
|
||||||
|
installingPluginIds: Record<string, boolean>;
|
||||||
|
updatingPluginIds: Record<string, boolean>;
|
||||||
|
updatablePluginIds: Record<string, boolean>;
|
||||||
|
|
||||||
|
callbacks: PluginCallbacks;
|
||||||
|
onShowPluginInfo: PluginCallback;
|
||||||
|
|
||||||
|
searchQuery: string;
|
||||||
|
setSearchQuery: (newQuery: string)=> void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SearchResultRecord {
|
interface SearchResultRecord {
|
||||||
@ -27,8 +38,20 @@ interface SearchResultRecord {
|
|||||||
installState: InstallState;
|
installState: InstallState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flexDirection: 'column',
|
||||||
|
margin: 12,
|
||||||
|
},
|
||||||
|
resultsCounter: {
|
||||||
|
margin: 12,
|
||||||
|
marginTop: 17,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const PluginSearch: React.FC<Props> = props => {
|
const PluginSearch: React.FC<Props> = props => {
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const { searchQuery, setSearchQuery } = props;
|
||||||
const [searchResultManifests, setSearchResultManifests] = useState<PluginManifest[]>([]);
|
const [searchResultManifests, setSearchResultManifests] = useState<PluginManifest[]>([]);
|
||||||
|
|
||||||
useAsyncEffect(async event => {
|
useAsyncEffect(async event => {
|
||||||
@ -42,8 +65,6 @@ const PluginSearch: React.FC<Props> = props => {
|
|||||||
}
|
}
|
||||||
}, [searchQuery, props.repoApi, setSearchResultManifests, props.repoApiInitialized]);
|
}, [searchQuery, props.repoApi, setSearchResultManifests, props.repoApiInitialized]);
|
||||||
|
|
||||||
const [installingPluginsIds, setInstallingPluginIds] = useState<Record<string, boolean>>({});
|
|
||||||
|
|
||||||
const pluginSettings = useMemo(() => {
|
const pluginSettings = useMemo(() => {
|
||||||
return { ...PluginService.instance().unserializePluginSettings(props.pluginSettings) };
|
return { ...PluginService.instance().unserializePluginSettings(props.pluginSettings) };
|
||||||
}, [props.pluginSettings]);
|
}, [props.pluginSettings]);
|
||||||
@ -56,12 +77,13 @@ const PluginSearch: React.FC<Props> = props => {
|
|||||||
if (settings && !settings.deleted) {
|
if (settings && !settings.deleted) {
|
||||||
installState = InstallState.Installed;
|
installState = InstallState.Installed;
|
||||||
}
|
}
|
||||||
if (installingPluginsIds[manifest.id]) {
|
if (props.installingPluginIds[manifest.id]) {
|
||||||
installState = InstallState.Installing;
|
installState = InstallState.Installing;
|
||||||
}
|
}
|
||||||
|
|
||||||
const item: PluginItem = {
|
const item: PluginItem = {
|
||||||
manifest,
|
manifest,
|
||||||
|
installed: !!settings,
|
||||||
enabled: settings && settings.enabled,
|
enabled: settings && settings.enabled,
|
||||||
deleted: settings && !settings.deleted,
|
deleted: settings && !settings.deleted,
|
||||||
devMode: false,
|
devMode: false,
|
||||||
@ -75,41 +97,62 @@ const PluginSearch: React.FC<Props> = props => {
|
|||||||
installState,
|
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 renderResult = useCallback(({ item }: { item: SearchResultRecord }) => {
|
||||||
const manifest = item.item.manifest;
|
const manifest = item.item.manifest;
|
||||||
|
|
||||||
|
if (item.installState === InstallState.Installed && PluginService.instance().isPluginLoaded(manifest.id)) {
|
||||||
|
return (
|
||||||
|
<InstalledPluginBox
|
||||||
|
pluginId={manifest.id}
|
||||||
|
themeId={props.themeId}
|
||||||
|
pluginSettings={props.pluginSettings}
|
||||||
|
updatablePluginIds={props.updatablePluginIds}
|
||||||
|
updatingPluginIds={props.updatingPluginIds}
|
||||||
|
showInstalledChip={true}
|
||||||
|
callbacks={props.callbacks}
|
||||||
|
onShowPluginInfo={props.onShowPluginInfo}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
return (
|
return (
|
||||||
<PluginBox
|
<PluginBox
|
||||||
themeId={props.themeId}
|
themeId={props.themeId}
|
||||||
key={manifest.id}
|
key={manifest.id}
|
||||||
item={item.item}
|
item={item.item}
|
||||||
installState={item.installState}
|
installState={item.installState}
|
||||||
|
showInstalledChip={false}
|
||||||
isCompatible={PluginService.instance().isCompatible(manifest)}
|
isCompatible={PluginService.instance().isCompatible(manifest)}
|
||||||
onInstall={installPlugin}
|
onInstall={onInstall}
|
||||||
onAboutPress={openWebsiteForPlugin}
|
onAboutPress={openWebsiteForPlugin}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}, [installPlugin, props.themeId]);
|
}
|
||||||
|
}, [onInstall, props.themeId, props.pluginSettings, props.updatingPluginIds, props.updatablePluginIds, props.onShowPluginInfo, props.callbacks]);
|
||||||
|
|
||||||
|
const renderResultsCount = () => {
|
||||||
|
if (!searchQuery.length) return null;
|
||||||
|
|
||||||
|
return <Text style={styles.resultsCounter} variant='labelLarge'>
|
||||||
|
{_('Results (%d):', searchResults.length)}
|
||||||
|
</Text>;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flexDirection: 'column' }}>
|
<View style={styles.container}>
|
||||||
<Searchbar
|
<TextInput
|
||||||
testID='searchbar'
|
testID='searchbar'
|
||||||
placeholder={_('Search')}
|
mode='outlined'
|
||||||
|
left={<TextInput.Icon icon='magnify' />}
|
||||||
|
placeholder={_('Search plugins')}
|
||||||
onChangeText={setSearchQuery}
|
onChangeText={setSearchQuery}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
editable={props.repoApiInitialized}
|
editable={props.repoApiInitialized}
|
||||||
/>
|
/>
|
||||||
|
{renderResultsCount()}
|
||||||
<FlatList
|
<FlatList
|
||||||
data={searchResults}
|
data={searchResults}
|
||||||
renderItem={renderResult}
|
renderItem={renderResult}
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { ItemEvent, PluginItem } from '@joplin/lib/components/shared/config/plugins/types';
|
import { PluginItem } from '@joplin/lib/components/shared/config/plugins/types';
|
||||||
import { Button, ButtonProps } from 'react-native-paper';
|
import TextButton, { TextButtonProps, ButtonType } from '../../../../buttons/TextButton';
|
||||||
|
import { PluginCallback } from '../utils/usePluginCallbacks';
|
||||||
|
|
||||||
export type PluginCallback = (event: ItemEvent)=> void;
|
|
||||||
|
|
||||||
interface Props extends Omit<ButtonProps, 'item'|'onPress'|'children'> {
|
interface Props extends Omit<TextButtonProps, 'type'|'item'|'onPress'|'children'> {
|
||||||
item: PluginItem;
|
item: PluginItem;
|
||||||
|
type?: ButtonType;
|
||||||
onPress?: PluginCallback;
|
onPress?: PluginCallback;
|
||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
@ -24,11 +25,12 @@ const ActionButton: React.FC<Props> = props => {
|
|||||||
// marked as translatable.
|
// marked as translatable.
|
||||||
const accessibilityLabel = `${props.title} ${props.item.manifest.name}`;
|
const accessibilityLabel = `${props.title} ${props.item.manifest.name}`;
|
||||||
return (
|
return (
|
||||||
<Button
|
<TextButton
|
||||||
|
type={ButtonType.Primary}
|
||||||
{...props}
|
{...props}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
accessibilityLabel={accessibilityLabel}
|
accessibilityLabel={accessibilityLabel}
|
||||||
>{props.title}</Button>
|
>{props.title}</TextButton>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -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> = 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 (
|
||||||
|
<ActionButton
|
||||||
|
item={props.item}
|
||||||
|
onPress={props.onInstall}
|
||||||
|
disabled={props.installState !== InstallState.NotInstalled || !props.isCompatible}
|
||||||
|
loading={props.installState === InstallState.Installing}
|
||||||
|
title={installButtonTitle()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InstallButton;
|
@ -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<AppState>;
|
||||||
|
}
|
||||||
|
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 (
|
||||||
|
<Provider store={props.store}>
|
||||||
|
<PaperProvider>
|
||||||
|
<PluginStates
|
||||||
|
styles={styles}
|
||||||
|
themeId={Setting.THEME_LIGHT}
|
||||||
|
updatePluginStates={updatePluginStates}
|
||||||
|
pluginSettings={pluginSettings}
|
||||||
|
shouldShowBasedOnSearchQuery={shouldShowBasedOnSettingSearchQuery}
|
||||||
|
/>
|
||||||
|
</PaperProvider>
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PluginStatesWrapper;
|
@ -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;
|
@ -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<Record<string, boolean>>({});
|
||||||
|
const onUpdate = useOnInstallHandler(setUpdatingPluginIds, props.pluginSettings, props.repoApi, onPluginSettingsChange, true);
|
||||||
|
|
||||||
|
const [installingPluginIds, setInstallingPluginIds] = useState<Record<string, boolean>>({});
|
||||||
|
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;
|
@ -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<PluginManifest>();
|
||||||
|
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;
|
@ -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<string, boolean>;
|
||||||
|
updatablePluginIds: Record<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
@ -9,3 +9,7 @@ export interface CustomSettingSection {
|
|||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||||
export type UpdateSettingValueCallback = (key: string, value: any)=> Promise<void>;
|
export type UpdateSettingValueCallback = (key: string, value: any)=> Promise<void>;
|
||||||
|
|
||||||
|
export interface PluginStatusRecord {
|
||||||
|
[pluginId: string]: boolean;
|
||||||
|
}
|
||||||
|
@ -13,7 +13,7 @@ import { View, StyleSheet, AccessibilityInfo } from 'react-native';
|
|||||||
import { _ } from '@joplin/lib/locale';
|
import { _ } from '@joplin/lib/locale';
|
||||||
import Setting from '@joplin/lib/models/Setting';
|
import Setting from '@joplin/lib/models/Setting';
|
||||||
import { Dispatch } from 'redux';
|
import { Dispatch } from 'redux';
|
||||||
import DismissibleDialog from '../../../components/DismissibleDialog';
|
import DismissibleDialog, { DialogSize } from '../../../components/DismissibleDialog';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
themeId: number;
|
themeId: number;
|
||||||
@ -168,6 +168,7 @@ const PluginPanelViewer: React.FC<Props> = props => {
|
|||||||
<DismissibleDialog
|
<DismissibleDialog
|
||||||
themeId={props.themeId}
|
themeId={props.themeId}
|
||||||
visible={props.visible}
|
visible={props.visible}
|
||||||
|
size={DialogSize.Large}
|
||||||
onDismiss={onClose}
|
onDismiss={onClose}
|
||||||
>
|
>
|
||||||
{renderTabContent()}
|
{renderTabContent()}
|
||||||
|
@ -1,10 +1,42 @@
|
|||||||
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
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) => {
|
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);
|
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;
|
export default usePlugin;
|
||||||
|
@ -1202,8 +1202,10 @@ class AppComponent extends React.Component {
|
|||||||
onPrimaryContainer: theme.color5,
|
onPrimaryContainer: theme.color5,
|
||||||
primaryContainer: theme.backgroundColor5,
|
primaryContainer: theme.backgroundColor5,
|
||||||
|
|
||||||
primary: theme.color,
|
outline: theme.codeBorderColor,
|
||||||
onPrimary: theme.backgroundColor,
|
|
||||||
|
primary: theme.color4,
|
||||||
|
onPrimary: theme.backgroundColor4,
|
||||||
|
|
||||||
background: theme.backgroundColor,
|
background: theme.backgroundColor,
|
||||||
|
|
||||||
|
@ -4,6 +4,7 @@ import { PluginManifest } from '../../../../services/plugins/utils/types';
|
|||||||
|
|
||||||
export interface PluginItem {
|
export interface PluginItem {
|
||||||
manifest: PluginManifest;
|
manifest: PluginManifest;
|
||||||
|
installed: boolean;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
deleted: boolean;
|
deleted: boolean;
|
||||||
devMode: boolean;
|
devMode: boolean;
|
||||||
|
@ -142,17 +142,20 @@ export default class PluginService extends BaseService {
|
|||||||
this.isSafeMode_ = v;
|
this.isSafeMode_ = v;
|
||||||
}
|
}
|
||||||
|
|
||||||
public waitForLoadedPluginsChange() {
|
public addLoadedPluginsChangeListener(listener: ()=> void) {
|
||||||
return new Promise<void>(resolve => {
|
this.pluginsChangeListeners_.push(listener);
|
||||||
this.pluginsChangeListeners_.push(() => resolve());
|
|
||||||
});
|
return {
|
||||||
|
remove: () => {
|
||||||
|
this.pluginsChangeListeners_ = this.pluginsChangeListeners_.filter(l => (l !== listener));
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private dispatchPluginsChangeListeners() {
|
private dispatchPluginsChangeListeners() {
|
||||||
for (const listener of this.pluginsChangeListeners_) {
|
for (const listener of this.pluginsChangeListeners_) {
|
||||||
listener();
|
listener();
|
||||||
}
|
}
|
||||||
this.pluginsChangeListeners_ = [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private setPluginAt(pluginId: string, plugin: Plugin) {
|
private setPluginAt(pluginId: string, plugin: Plugin) {
|
||||||
|
@ -45,6 +45,7 @@ const input: Theme = {
|
|||||||
searchMarkerColor: 'black',
|
searchMarkerColor: 'black',
|
||||||
|
|
||||||
warningBackgroundColor: '#FFD08D',
|
warningBackgroundColor: '#FFD08D',
|
||||||
|
destructiveColor: '#F00000',
|
||||||
|
|
||||||
tableBackgroundColor: 'rgb(247, 247, 247)',
|
tableBackgroundColor: 'rgb(247, 247, 247)',
|
||||||
codeBackgroundColor: 'rgb(243, 243, 243)',
|
codeBackgroundColor: 'rgb(243, 243, 243)',
|
||||||
@ -89,6 +90,7 @@ const expected = `
|
|||||||
--joplin-color-warn2: #ffcb81;
|
--joplin-color-warn2: #ffcb81;
|
||||||
--joplin-color-warn3: #ff7626;
|
--joplin-color-warn3: #ff7626;
|
||||||
--joplin-color-warn-url: #155BDA;
|
--joplin-color-warn-url: #155BDA;
|
||||||
|
--joplin-destructive-color: #F00000;
|
||||||
--joplin-divider-color: #dddddd;
|
--joplin-divider-color: #dddddd;
|
||||||
--joplin-header-background-color: #ffffff;
|
--joplin-header-background-color: #ffffff;
|
||||||
--joplin-odd-background-color: #eeeeee;
|
--joplin-odd-background-color: #eeeeee;
|
||||||
|
@ -48,6 +48,7 @@ const theme: Theme = {
|
|||||||
searchMarkerColor: 'black',
|
searchMarkerColor: 'black',
|
||||||
|
|
||||||
warningBackgroundColor: '#013F74',
|
warningBackgroundColor: '#013F74',
|
||||||
|
destructiveColor: '#F07777',
|
||||||
|
|
||||||
tableBackgroundColor: 'rgb(40, 41, 42)',
|
tableBackgroundColor: 'rgb(40, 41, 42)',
|
||||||
codeBackgroundColor: 'rgb(47, 48, 49)',
|
codeBackgroundColor: 'rgb(47, 48, 49)',
|
||||||
|
@ -45,6 +45,7 @@ const theme: Theme = {
|
|||||||
searchMarkerColor: 'black',
|
searchMarkerColor: 'black',
|
||||||
|
|
||||||
warningBackgroundColor: '#FFD08D',
|
warningBackgroundColor: '#FFD08D',
|
||||||
|
destructiveColor: '#D00707',
|
||||||
|
|
||||||
tableBackgroundColor: 'rgb(247, 247, 247)',
|
tableBackgroundColor: 'rgb(247, 247, 247)',
|
||||||
codeBackgroundColor: 'rgb(243, 243, 243)',
|
codeBackgroundColor: 'rgb(243, 243, 243)',
|
||||||
|
@ -50,6 +50,7 @@ export interface Theme {
|
|||||||
searchMarkerColor: string;
|
searchMarkerColor: string;
|
||||||
|
|
||||||
warningBackgroundColor: string;
|
warningBackgroundColor: string;
|
||||||
|
destructiveColor: string;
|
||||||
|
|
||||||
tableBackgroundColor: string;
|
tableBackgroundColor: string;
|
||||||
codeBackgroundColor: string;
|
codeBackgroundColor: string;
|
||||||
|
Loading…
Reference in New Issue
Block a user