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/biometricAuthenticate.js
|
||||
packages/app-mobile/components/biometrics/sensorInfo.js
|
||||
packages/app-mobile/components/buttons/TextButton.js
|
||||
packages/app-mobile/components/buttons/index.js
|
||||
packages/app-mobile/components/getResponsiveValue.test.js
|
||||
packages/app-mobile/components/getResponsiveValue.js
|
||||
packages/app-mobile/components/global-style.js
|
||||
@ -610,19 +612,28 @@ packages/app-mobile/components/screens/ConfigScreen/SettingsButton.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/SettingsToggle.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/configScreenStyles.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/EnablePluginSupportPage.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/ActionButton.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginInfoButton.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/InstalledPluginBox.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginChips.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginTitle.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/StyledChip.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/index.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.test.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginInfoModal.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.installed.test.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.search.test.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginToggle.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginUploadButton.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/SearchPlugins.test.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/SearchPlugins.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/buttons/ActionButton.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/buttons/InstallButton.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/WrappedPluginStates.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/mockRepositoryApiConstructor.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/newRepoApi.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/pluginServiceSetup.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/openWebsiteForPlugin.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginCallbacks.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginItem.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/useRepoApi.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/useUpdateState.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/types.js
|
||||
packages/app-mobile/components/screens/JoplinCloudLoginScreen.js
|
||||
packages/app-mobile/components/screens/LogScreen.js
|
||||
|
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/biometricAuthenticate.js
|
||||
packages/app-mobile/components/biometrics/sensorInfo.js
|
||||
packages/app-mobile/components/buttons/TextButton.js
|
||||
packages/app-mobile/components/buttons/index.js
|
||||
packages/app-mobile/components/getResponsiveValue.test.js
|
||||
packages/app-mobile/components/getResponsiveValue.js
|
||||
packages/app-mobile/components/global-style.js
|
||||
@ -589,19 +591,28 @@ packages/app-mobile/components/screens/ConfigScreen/SettingsButton.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/SettingsToggle.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/configScreenStyles.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/EnablePluginSupportPage.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/ActionButton.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginInfoButton.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/InstalledPluginBox.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginChips.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginTitle.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/StyledChip.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/index.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.test.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginInfoModal.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.installed.test.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.search.test.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginToggle.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginUploadButton.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/SearchPlugins.test.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/SearchPlugins.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/buttons/ActionButton.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/buttons/InstallButton.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/WrappedPluginStates.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/mockRepositoryApiConstructor.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/newRepoApi.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/pluginServiceSetup.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/openWebsiteForPlugin.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginCallbacks.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginItem.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/useRepoApi.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/useUpdateState.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/types.js
|
||||
packages/app-mobile/components/screens/JoplinCloudLoginScreen.js
|
||||
packages/app-mobile/components/screens/LogScreen.js
|
||||
|
@ -38,6 +38,7 @@ interface Props {
|
||||
function manifestToItem(manifest: PluginManifest): PluginItem {
|
||||
return {
|
||||
manifest: manifest,
|
||||
installed: true,
|
||||
enabled: true,
|
||||
deleted: false,
|
||||
devMode: false,
|
||||
|
@ -83,6 +83,7 @@ function usePluginItems(plugins: Plugins, settings: PluginSettings): PluginItem[
|
||||
|
||||
output.push({
|
||||
manifest: plugin.manifest,
|
||||
installed: true,
|
||||
enabled: setting.enabled,
|
||||
deleted: setting.deleted,
|
||||
devMode: plugin.devMode,
|
||||
|
@ -6,20 +6,32 @@ import { themeStyle } from './global-style';
|
||||
import Modal from './Modal';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
export enum DialogSize {
|
||||
Small = 'small',
|
||||
|
||||
// Ideal for panels and dialogs that should be fullscreen even on large devices
|
||||
Large = 'large',
|
||||
}
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
visible: boolean;
|
||||
onDismiss: ()=> void;
|
||||
containerStyle?: ViewStyle;
|
||||
children: React.ReactNode;
|
||||
|
||||
size: DialogSize;
|
||||
}
|
||||
|
||||
const useStyles = (themeId: number, containerStyle: ViewStyle) => {
|
||||
const useStyles = (themeId: number, containerStyle: ViewStyle, size: DialogSize) => {
|
||||
const windowSize = useWindowDimensions();
|
||||
|
||||
return useMemo(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
|
||||
const maxWidth = size === DialogSize.Large ? Infinity : 500;
|
||||
const maxHeight = size === DialogSize.Large ? Infinity : 700;
|
||||
|
||||
return StyleSheet.create({
|
||||
webView: {
|
||||
backgroundColor: 'transparent',
|
||||
@ -38,8 +50,10 @@ const useStyles = (themeId: number, containerStyle: ViewStyle) => {
|
||||
borderRadius: 12,
|
||||
padding: 10,
|
||||
|
||||
height: windowSize.height * 0.9,
|
||||
width: windowSize.width * 0.97,
|
||||
// Use Math.min with width and height -- the maxWidth and maxHeight style
|
||||
// properties don't seem to limit the size for this.
|
||||
height: Math.min(maxHeight, windowSize.height * 0.9),
|
||||
width: Math.min(maxWidth, windowSize.width * 0.97),
|
||||
flexShrink: 1,
|
||||
|
||||
// Center
|
||||
@ -56,11 +70,11 @@ const useStyles = (themeId: number, containerStyle: ViewStyle) => {
|
||||
flexGrow: 1,
|
||||
},
|
||||
});
|
||||
}, [themeId, windowSize.width, windowSize.height, containerStyle]);
|
||||
}, [themeId, windowSize.width, windowSize.height, containerStyle, size]);
|
||||
};
|
||||
|
||||
const DismissibleDialog: React.FC<Props> = props => {
|
||||
const styles = useStyles(props.themeId, props.containerStyle);
|
||||
const styles = useStyles(props.themeId, props.containerStyle, props.size);
|
||||
|
||||
const closeButton = (
|
||||
<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 { Platform, Linking, View, Switch, ScrollView, Text, TouchableOpacity, Alert, PermissionsAndroid, Dimensions, AccessibilityInfo } from 'react-native';
|
||||
import Setting, { AppType, SettingItem, SettingMetadataSection } from '@joplin/lib/models/Setting';
|
||||
import Setting, { AppType, SettingMetadataSection } from '@joplin/lib/models/Setting';
|
||||
import NavService from '@joplin/lib/services/NavService';
|
||||
import SearchEngine from '@joplin/lib/services/search/SearchEngine';
|
||||
import checkPermissions from '../../../utils/checkPermissions';
|
||||
@ -26,7 +26,7 @@ import ExportProfileButton, { exportProfileButtonTitle } from './NoteExportSecti
|
||||
import SettingComponent from './SettingComponent';
|
||||
import ExportDebugReportButton, { exportDebugReportTitle } from './NoteExportSection/ExportDebugReportButton';
|
||||
import SectionSelector from './SectionSelector';
|
||||
import { Button, TextInput } from 'react-native-paper';
|
||||
import { TextInput, List } from 'react-native-paper';
|
||||
import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService';
|
||||
import PluginStates, { getSearchText as getPluginStatesSearchText } from './plugins/PluginStates';
|
||||
import PluginUploadButton, { canInstallPluginsFromFile, buttonLabel as pluginUploadButtonSearchText } from './plugins/PluginUploadButton';
|
||||
@ -389,7 +389,7 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
|
||||
const addSettingComponent = (
|
||||
component: ReactElement,
|
||||
relatedText: string|string[],
|
||||
settingMetadata?: SettingItem,
|
||||
settingMetadata?: { advanced?: boolean },
|
||||
) => {
|
||||
const hiddenBySearch = this.state.searching && !matchesSearchQuery(relatedText);
|
||||
if (component && !hiddenBySearch) {
|
||||
@ -503,8 +503,10 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
|
||||
key='plugins-install-from-file'
|
||||
pluginSettings={settings[pluginStatesKey]}
|
||||
updatePluginStates={updatePluginStates}
|
||||
styles={this.styles()}
|
||||
/>,
|
||||
pluginUploadButtonSearchText(),
|
||||
{ advanced: true },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@ -663,19 +665,15 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
|
||||
const renderAdvancedSettings = () => {
|
||||
if (!advancedSettingComps.length) return null;
|
||||
|
||||
const toggleAdvancedLabel = this.state.showAdvancedSettings ? _('Hide Advanced Settings') : _('Show Advanced Settings');
|
||||
const toggleAdvancedLabel = _('Advanced settings');
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
style={{ marginBottom: 20 }}
|
||||
icon={this.state.showAdvancedSettings ? 'menu-down' : 'menu-right'}
|
||||
<List.Accordion
|
||||
title={toggleAdvancedLabel}
|
||||
expanded={this.state.showAdvancedSettings}
|
||||
onPress={() => this.setState({ showAdvancedSettings: !this.state.showAdvancedSettings })}
|
||||
>
|
||||
<Text>{toggleAdvancedLabel}</Text>
|
||||
</Button>
|
||||
|
||||
{this.state.showAdvancedSettings ? advancedSettingComps : null}
|
||||
</>
|
||||
</List.Accordion>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -3,7 +3,8 @@ import { themeStyle } from '../../../global-style';
|
||||
import * as React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { Linking, View, StyleSheet, ViewStyle, TextStyle } from 'react-native';
|
||||
import { Button, Card, Divider, Icon, List, Text } from 'react-native-paper';
|
||||
import { Card, Divider, Icon, List, Text } from 'react-native-paper';
|
||||
import { LinkButton, PrimaryButton } from '../../../buttons';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
@ -50,7 +51,6 @@ const useStyles = (themeId: number) => {
|
||||
marginBottom: 0,
|
||||
},
|
||||
actionButton: {
|
||||
borderRadius: 10,
|
||||
marginLeft: theme.marginLeft * 2,
|
||||
marginRight: theme.marginRight * 2,
|
||||
marginBottom: theme.margin,
|
||||
@ -58,18 +58,6 @@ const useStyles = (themeId: number) => {
|
||||
});
|
||||
|
||||
const themeOverride = {
|
||||
secondaryButton: {
|
||||
colors: {
|
||||
primary: theme.color4,
|
||||
outline: theme.color4,
|
||||
},
|
||||
},
|
||||
primaryButton: {
|
||||
colors: {
|
||||
primary: theme.color4,
|
||||
onPrimary: theme.backgroundColor4,
|
||||
},
|
||||
},
|
||||
card: {
|
||||
colors: {
|
||||
outline: theme.codeBorderColor,
|
||||
@ -127,8 +115,8 @@ const EnablePluginSupportPage: React.FC<Props> = props => {
|
||||
{renderCard('source-branch-check', _('Open Source'), _('Most plugins have source code available for review on the plugin website.'))}
|
||||
{renderCard('flag-remove', _('Report system'), _('We have a system for reporting and removing problematic plugins.'))}
|
||||
<View>
|
||||
<Button style={styles.actionButton} theme={themeOverride.secondaryButton} onPress={onLearnMorePress}>{_('Learn more')}</Button>
|
||||
<Button style={styles.actionButton} theme={themeOverride.primaryButton} mode='contained' onPress={props.onEnablePluginSupport}>{_('Enable plugin support')}</Button>
|
||||
<LinkButton style={styles.actionButton} onPress={onLearnMorePress}>{_('Learn more')}</LinkButton>
|
||||
<PrimaryButton style={styles.actionButton} onPress={props.onEnablePluginSupport}>{_('Enable plugin support')}</PrimaryButton>
|
||||
</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 { Icon, Card, Chip, Text } from 'react-native-paper';
|
||||
import { Card, Text, TouchableRipple } from 'react-native-paper';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { Alert, Linking, StyleSheet, View } from 'react-native';
|
||||
import { PluginItem } from '@joplin/lib/components/shared/config/plugins/types';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
||||
import ActionButton, { PluginCallback } from './ActionButton';
|
||||
import PluginInfoButton from './PluginInfoButton';
|
||||
import ActionButton from '../buttons/ActionButton';
|
||||
import { ButtonType } from '../../../../buttons/TextButton';
|
||||
import PluginChips from './PluginChips';
|
||||
import { UpdateState } from '../utils/useUpdateState';
|
||||
import { PluginCallback } from '../utils/usePluginCallbacks';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import InstallButton from '../buttons/InstallButton';
|
||||
import PluginTitle from './PluginTitle';
|
||||
|
||||
export enum InstallState {
|
||||
NotInstalled,
|
||||
@ -14,197 +18,98 @@ export enum InstallState {
|
||||
Installed,
|
||||
}
|
||||
|
||||
export enum UpdateState {
|
||||
Idle = 1,
|
||||
CanUpdate = 2,
|
||||
Updating = 3,
|
||||
HasBeenUpdated = 4,
|
||||
}
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
item: PluginItem;
|
||||
isCompatible: boolean;
|
||||
|
||||
// In some cases, showing an "installed" chip is redundant (e.g. in the "installed plugins"
|
||||
// tab). In other places (e.g. search), an "installed" chip is important.
|
||||
showInstalledChip: boolean;
|
||||
|
||||
hasErrors?: boolean;
|
||||
installState?: InstallState;
|
||||
updateState?: UpdateState;
|
||||
|
||||
onAboutPress?: PluginCallback;
|
||||
onInstall?: PluginCallback;
|
||||
onUpdate?: PluginCallback;
|
||||
onDelete?: PluginCallback;
|
||||
onToggle?: PluginCallback;
|
||||
onShowPluginLog?: PluginCallback;
|
||||
onShowPluginInfo?: PluginCallback;
|
||||
}
|
||||
|
||||
const onRecommendedPress = () => {
|
||||
Alert.alert(
|
||||
'',
|
||||
_('The Joplin team has vetted this plugin and it meets our standards for security and performance.'),
|
||||
[
|
||||
{
|
||||
text: _('Learn more'),
|
||||
onPress: () => Linking.openURL('https://github.com/joplin/plugins/blob/master/readme/recommended.md'),
|
||||
const useStyles = (compatible: boolean) => {
|
||||
return useMemo(() => {
|
||||
// For the TouchableRipple to work on Android, the card needs a transparent background.
|
||||
const baseCard = { backgroundColor: 'transparent' };
|
||||
return StyleSheet.create({
|
||||
cardContainer: {
|
||||
margin: 0,
|
||||
marginTop: 8,
|
||||
padding: 0,
|
||||
borderRadius: 14,
|
||||
},
|
||||
{
|
||||
text: _('OK'),
|
||||
card: !compatible ? {
|
||||
...baseCard,
|
||||
opacity: 0.7,
|
||||
} : baseCard,
|
||||
content: {
|
||||
gap: 5,
|
||||
},
|
||||
],
|
||||
{ cancelable: true },
|
||||
);
|
||||
});
|
||||
}, [compatible]);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const PluginIcon = (props: any) => <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 manifest = props.item.manifest;
|
||||
const item = props.item;
|
||||
|
||||
const installButtonTitle = () => {
|
||||
if (props.installState === InstallState.Installing) return _('Installing...');
|
||||
if (props.installState === InstallState.NotInstalled) return _('Install');
|
||||
if (props.installState === InstallState.Installed) return _('Installed');
|
||||
return `Invalid install state: ${props.installState}`;
|
||||
};
|
||||
|
||||
const installButton = (
|
||||
<ActionButton
|
||||
const installButton = <InstallButton
|
||||
item={item}
|
||||
onPress={props.onInstall}
|
||||
disabled={props.installState !== InstallState.NotInstalled || !props.isCompatible}
|
||||
loading={props.installState === InstallState.Installing}
|
||||
title={installButtonTitle()}
|
||||
/>
|
||||
);
|
||||
onInstall={props.onInstall}
|
||||
installState={props.installState}
|
||||
isCompatible={props.isCompatible}
|
||||
/>;
|
||||
|
||||
const getUpdateButtonTitle = () => {
|
||||
if (props.updateState === UpdateState.Updating) return _('Updating...');
|
||||
if (props.updateState === UpdateState.HasBeenUpdated) return _('Updated');
|
||||
return _('Update');
|
||||
};
|
||||
const aboutButton = <ActionButton type={ButtonType.Link} item={item} onPress={props.onAboutPress} title={_('About')}/>;
|
||||
|
||||
const updateButton = (
|
||||
<ActionButton
|
||||
item={item}
|
||||
onPress={props.onUpdate}
|
||||
disabled={props.updateState !== UpdateState.CanUpdate || !props.isCompatible}
|
||||
loading={props.updateState === UpdateState.Updating}
|
||||
title={getUpdateButtonTitle()}
|
||||
/>
|
||||
);
|
||||
const onPress = useCallback(() => {
|
||||
props.onShowPluginInfo?.({ item: props.item });
|
||||
}, [props.onShowPluginInfo, props.item]);
|
||||
|
||||
const deleteButton = (
|
||||
<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;
|
||||
const styles = useStyles(props.isCompatible);
|
||||
|
||||
return (
|
||||
<Chip
|
||||
icon='alert'
|
||||
mode='outlined'
|
||||
onPress={() => props.onShowPluginLog({ item })}
|
||||
<TouchableRipple
|
||||
accessibilityRole='button'
|
||||
accessible={true}
|
||||
onPress={props.onShowPluginInfo ? onPress : null}
|
||||
style={styles.cardContainer}
|
||||
>
|
||||
{_('Error')}
|
||||
</Chip>
|
||||
);
|
||||
};
|
||||
|
||||
const renderRecommendedChip = () => {
|
||||
if (!props.item.manifest._recommended || !props.isCompatible) {
|
||||
return null;
|
||||
}
|
||||
return <Chip
|
||||
icon='crown'
|
||||
<Card
|
||||
mode='outlined'
|
||||
onPress={onRecommendedPress}
|
||||
style={styles.card}
|
||||
testID='plugin-card'
|
||||
>
|
||||
{_('Recommended')}
|
||||
</Chip>;
|
||||
};
|
||||
|
||||
const renderBuiltInChip = () => {
|
||||
if (!props.item.builtIn) {
|
||||
return null;
|
||||
}
|
||||
return <Chip icon='code-tags-check' mode='outlined'>{_('Built-in')}</Chip>;
|
||||
};
|
||||
|
||||
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 style={styles.content}>
|
||||
<PluginTitle manifest={item.manifest} />
|
||||
<Text numberOfLines={2}>{manifest.description}</Text>
|
||||
<PluginChips
|
||||
themeId={props.themeId}
|
||||
item={props.item}
|
||||
showInstalledChip={props.showInstalledChip}
|
||||
hasErrors={props.hasErrors}
|
||||
canUpdate={props.updateState === UpdateState.CanUpdate}
|
||||
onShowPluginLog={props.onShowPluginLog}
|
||||
isCompatible={props.isCompatible}
|
||||
/>
|
||||
<Card.Content>
|
||||
<View style={{ flexDirection: 'row' }}>
|
||||
{renderIncompatibleChip()}
|
||||
{renderErrorsChip()}
|
||||
{renderRecommendedChip()}
|
||||
{renderBuiltInChip()}
|
||||
</View>
|
||||
</Card.Content>
|
||||
<Card.Actions>
|
||||
{props.onAboutPress ? aboutButton : null}
|
||||
{props.onInstall ? installButton : null}
|
||||
{props.onDelete && !props.item.builtIn ? deleteButton : null}
|
||||
{props.onUpdate && updateStateIsIdle ? updateButton : null}
|
||||
{props.onToggle && props.item.enabled ? disableButton : null}
|
||||
{props.onToggle && !props.item.enabled ? enableButton : null}
|
||||
</Card.Actions>
|
||||
</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 RepositoryApi from '@joplin/lib/services/plugins/RepositoryApi';
|
||||
import { createTempDir, mockMobilePlatform, setupDatabaseAndSynchronizer, supportDir, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
import { createTempDir, mockMobilePlatform, setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
|
||||
import { act, render, screen } from '@testing-library/react-native';
|
||||
import { act, fireEvent, render, screen, userEvent, waitFor } from '@testing-library/react-native';
|
||||
import '@testing-library/react-native/extend-expect';
|
||||
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import PluginService, { PluginSettings, defaultPluginSetting } from '@joplin/lib/services/plugins/PluginService';
|
||||
import { useCallback, useState } from 'react';
|
||||
import pluginServiceSetup from './testUtils/pluginServiceSetup';
|
||||
import PluginStates from './PluginStates';
|
||||
import configScreenStyles from '../configScreenStyles';
|
||||
import { remove, writeFile } from 'fs-extra';
|
||||
import { writeFile } from 'fs-extra';
|
||||
import { join } from 'path';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { resetRepoApi } from './utils/useRepoApi';
|
||||
import { Store } from 'redux';
|
||||
import { AppState } from '../../../../utils/types';
|
||||
import createMockReduxStore from '../../../../utils/testing/createMockReduxStore';
|
||||
import WrappedPluginStates from './testUtils/WrappedPluginStates';
|
||||
import mockRepositoryApiConstructor from './testUtils/mockRepositoryApiConstructor';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
|
||||
interface WrapperProps {
|
||||
initialPluginSettings: PluginSettings;
|
||||
}
|
||||
|
||||
let reduxStore: Store<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 service = PluginService.instance();
|
||||
const pluginSource = `
|
||||
@ -87,7 +47,12 @@ const loadMockPlugin = async (id: string, name: string, version: string, pluginS
|
||||
});
|
||||
};
|
||||
|
||||
describe('PluginStates', () => {
|
||||
const showInstalledTab = async () => {
|
||||
const installedTab = await screen.findByText('Installed plugins');
|
||||
await userEvent.press(installedTab);
|
||||
};
|
||||
|
||||
describe('PluginStates.installed', () => {
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(0);
|
||||
await switchClient(0);
|
||||
@ -128,23 +93,27 @@ describe('PluginStates', () => {
|
||||
await loadMockPlugin(backlinksPluginId, 'Backlinks to note', '0.0.1', defaultPluginSettings);
|
||||
expect(PluginService.instance().plugins[backlinksPluginId]).toBeTruthy();
|
||||
|
||||
render(
|
||||
<PluginStatesWrapper
|
||||
const wrapper = render(
|
||||
<WrappedPluginStates
|
||||
initialPluginSettings={defaultPluginSettings}
|
||||
store={reduxStore}
|
||||
/>,
|
||||
);
|
||||
await showInstalledTab();
|
||||
expect(await screen.findByText(/^ABC Sheet Music/)).toBeVisible();
|
||||
expect(await screen.findByText(/^Backlinks to note/)).toBeVisible();
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Update ABC Sheet Music', disabled: false })).toBeVisible();
|
||||
const updateMarkers = await screen.findAllByText('Update available');
|
||||
|
||||
// Backlinks to note should not be updatable on iOS (it's not _recommended).
|
||||
const backlinksToNoteQuery = { name: 'Update Backlinks to note', disabled: false };
|
||||
// ABC Sheet Music should always be updatable
|
||||
if (platform === 'android') {
|
||||
expect(await screen.findByRole('button', backlinksToNoteQuery)).toBeVisible();
|
||||
expect(updateMarkers).toHaveLength(2);
|
||||
} else {
|
||||
expect(await screen.queryByRole('button', backlinksToNoteQuery)).toBeNull();
|
||||
expect(updateMarkers).toHaveLength(1);
|
||||
}
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('should show the current plugin version on updatable plugins', async () => {
|
||||
@ -155,24 +124,32 @@ describe('PluginStates', () => {
|
||||
await loadMockPlugin(abcPluginId, 'ABC Sheet Music', outdatedVersion, defaultPluginSettings);
|
||||
expect(PluginService.instance().plugins[abcPluginId]).toBeTruthy();
|
||||
|
||||
render(
|
||||
<PluginStatesWrapper
|
||||
const wrapper = render(
|
||||
<WrappedPluginStates
|
||||
initialPluginSettings={defaultPluginSettings}
|
||||
store={reduxStore}
|
||||
/>,
|
||||
);
|
||||
expect(await screen.findByText(/^ABC Sheet Music/)).toBeVisible();
|
||||
expect(await screen.findByRole('button', { name: 'Update ABC Sheet Music', disabled: false })).toBeVisible();
|
||||
await showInstalledTab();
|
||||
|
||||
const abcSheetMusicCard = await screen.findByText(/^ABC Sheet Music/);
|
||||
expect(abcSheetMusicCard).toBeVisible();
|
||||
expect(await screen.findByText('Update available')).toBeVisible();
|
||||
expect(await screen.findByText(`v${outdatedVersion}`)).toBeVisible();
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('should update the list of installed plugins when a plugin is installed and uninstalled', async () => {
|
||||
const pluginSettings: PluginSettings = { };
|
||||
|
||||
render(
|
||||
<PluginStatesWrapper
|
||||
const wrapper = render(
|
||||
<WrappedPluginStates
|
||||
initialPluginSettings={pluginSettings}
|
||||
store={reduxStore}
|
||||
/>,
|
||||
);
|
||||
await showInstalledTab();
|
||||
|
||||
// Initially, no plugins should be visible.
|
||||
expect(screen.queryByText(/^ABC Sheet Music/)).toBeNull();
|
||||
@ -191,5 +168,103 @@ describe('PluginStates', () => {
|
||||
await act(() => PluginService.instance().uninstallPlugin(testPluginId1));
|
||||
expect(await screen.findByText(/^A test plugin/)).toBeVisible();
|
||||
expect(screen.queryByText(/^ABC Sheet Music/)).toBeNull();
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('should support disabling plugins from the info modal', async () => {
|
||||
const abcPluginId = 'org.joplinapp.plugins.AbcSheetMusic';
|
||||
const defaultPluginSettings: PluginSettings = { [abcPluginId]: defaultPluginSetting() };
|
||||
|
||||
await loadMockPlugin(abcPluginId, 'ABC Sheet Music', '1.2.3', defaultPluginSettings);
|
||||
expect(PluginService.instance().plugins[abcPluginId]).toBeTruthy();
|
||||
|
||||
const wrapper = render(
|
||||
<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 RepositoryApi, { InstallMode } from '@joplin/lib/services/plugins/RepositoryApi';
|
||||
import { mockMobilePlatform, setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
|
||||
import { render, screen, userEvent, waitFor } from '@testing-library/react-native';
|
||||
import '@testing-library/react-native/extend-expect';
|
||||
|
||||
import SearchPlugins from './SearchPlugins';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { PluginSettings } from '@joplin/lib/services/plugins/PluginService';
|
||||
import pluginServiceSetup from './testUtils/pluginServiceSetup';
|
||||
import newRepoApi from './testUtils/newRepoApi';
|
||||
import createMockReduxStore from '../../../../utils/testing/createMockReduxStore';
|
||||
|
||||
interface WrapperProps {
|
||||
repoApi: RepositoryApi;
|
||||
repoApiInitialized?: boolean;
|
||||
pluginSettings?: PluginSettings;
|
||||
onUpdatePluginStates?: (states: PluginSettings)=> void;
|
||||
}
|
||||
|
||||
const noOpFunction = ()=>{};
|
||||
|
||||
const SearchWrapper = (props: WrapperProps) => {
|
||||
return (
|
||||
<SearchPlugins
|
||||
themeId={Setting.THEME_LIGHT}
|
||||
pluginSettings={props.pluginSettings ?? {}}
|
||||
repoApiInitialized={props.repoApiInitialized ?? true}
|
||||
repoApi={props.repoApi}
|
||||
onUpdatePluginStates={props.onUpdatePluginStates ?? noOpFunction}
|
||||
/>
|
||||
);
|
||||
};
|
||||
import WrappedPluginStates from './testUtils/WrappedPluginStates';
|
||||
import { AppState } from '../../../../utils/types';
|
||||
import { Store } from 'redux';
|
||||
import mockRepositoryApiConstructor from './testUtils/mockRepositoryApiConstructor';
|
||||
import { resetRepoApi } from './utils/useRepoApi';
|
||||
|
||||
const expectSearchResultCountToBe = async (count: number) => {
|
||||
await waitFor(() => {
|
||||
@ -39,24 +18,43 @@ const expectSearchResultCountToBe = async (count: number) => {
|
||||
});
|
||||
};
|
||||
|
||||
describe('SearchPlugins', () => {
|
||||
const showSearchTab = async () => {
|
||||
const searchAccordion = await screen.findByText('Install new plugins');
|
||||
await userEvent.press(searchAccordion);
|
||||
};
|
||||
|
||||
// The search box is initially read-only -- waits for it to be editable.
|
||||
const getEditableSearchBox = async () => {
|
||||
const searchBox = await screen.findByPlaceholderText('Search plugins');
|
||||
expect(searchBox).toBeVisible();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(searchBox.props.editable).toBe(true);
|
||||
});
|
||||
|
||||
return searchBox;
|
||||
};
|
||||
|
||||
let reduxStore: Store<AppState>;
|
||||
|
||||
describe('PluginStates.search', () => {
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(0);
|
||||
await switchClient(0);
|
||||
pluginServiceSetup(createMockReduxStore());
|
||||
reduxStore = createMockReduxStore();
|
||||
pluginServiceSetup(reduxStore);
|
||||
mockMobilePlatform('android');
|
||||
resetRepoApi();
|
||||
|
||||
await mockRepositoryApiConstructor();
|
||||
});
|
||||
|
||||
it('should find results', async () => {
|
||||
const repoApi = await newRepoApi(InstallMode.Default);
|
||||
render(<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 wrapper = render(<WrappedPluginStates initialPluginSettings={{}} store={reduxStore}/>);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await showSearchTab();
|
||||
const searchBox = await getEditableSearchBox();
|
||||
await user.type(searchBox, 'backlinks');
|
||||
|
||||
// Should find one result
|
||||
@ -71,19 +69,27 @@ describe('SearchPlugins', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.queryAllByTestId('plugin-card').length).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('should only show recommended plugin search results on iOS-like environments', async () => {
|
||||
// iOS uses restricted install mode
|
||||
const repoApi = await newRepoApi(InstallMode.Restricted);
|
||||
render(<SearchWrapper repoApi={repoApi}/>);
|
||||
mockMobilePlatform('ios');
|
||||
await mockRepositoryApiConstructor();
|
||||
|
||||
const searchBox = screen.queryByPlaceholderText('Search');
|
||||
expect(searchBox).toBeVisible();
|
||||
const wrapper = render(<WrappedPluginStates initialPluginSettings={{}} store={reduxStore}/>);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await showSearchTab();
|
||||
|
||||
const searchBox = await getEditableSearchBox();
|
||||
|
||||
await user.press(searchBox);
|
||||
await user.type(searchBox, 'abc');
|
||||
|
||||
expect(searchBox.props.value).toBe('abc');
|
||||
|
||||
// Should find recommended plugins
|
||||
await expectSearchResultCountToBe(1);
|
||||
|
||||
@ -97,16 +103,20 @@ describe('SearchPlugins', () => {
|
||||
await expectSearchResultCountToBe(1);
|
||||
expect(screen.getByText(/ABC Sheet Music/i)).toBeTruthy();
|
||||
expect(screen.queryByText(/backlink/i)).toBeNull();
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('should mark incompatible plugins as incompatible', async () => {
|
||||
const mock = mockMobilePlatform('android');
|
||||
const repoApi = await newRepoApi(InstallMode.Default);
|
||||
render(<SearchWrapper repoApi={repoApi}/>);
|
||||
const wrapper = render(<WrappedPluginStates initialPluginSettings={{}} store={reduxStore}/>);
|
||||
|
||||
const searchBox = screen.queryByPlaceholderText('Search');
|
||||
const user = userEvent.setup();
|
||||
await showSearchTab();
|
||||
|
||||
const searchBox = await getEditableSearchBox();
|
||||
await user.press(searchBox);
|
||||
await user.type(searchBox, 'abc');
|
||||
expect(searchBox.props.value).toBe('abc');
|
||||
|
||||
await expectSearchResultCountToBe(1);
|
||||
expect(screen.queryByText('Incompatible')).toBeNull();
|
||||
@ -117,6 +127,6 @@ describe('SearchPlugins', () => {
|
||||
expect(await screen.findByText(/Note list and side bar/i)).toBeVisible();
|
||||
expect(await screen.findByText('Incompatible')).toBeVisible();
|
||||
|
||||
mock.reset();
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
@ -1,17 +1,17 @@
|
||||
import * as React from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ConfigScreenStyles } from '../configScreenStyles';
|
||||
import { View } from 'react-native';
|
||||
import { Banner, Button, Text } from 'react-native-paper';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { Banner, Text, Button, ProgressBar, List, Divider } from 'react-native-paper';
|
||||
import { _, _n } from '@joplin/lib/locale';
|
||||
import PluginService, { PluginSettings, SerializedPluginSettings } from '@joplin/lib/services/plugins/PluginService';
|
||||
import PluginToggle from './PluginToggle';
|
||||
import InstalledPluginBox from './InstalledPluginBox';
|
||||
import SearchPlugins from './SearchPlugins';
|
||||
import { ItemEvent } from '@joplin/lib/components/shared/config/plugins/types';
|
||||
import NavService from '@joplin/lib/services/NavService';
|
||||
import { ItemEvent, PluginItem } from '@joplin/lib/components/shared/config/plugins/types';
|
||||
import useRepoApi from './utils/useRepoApi';
|
||||
import RepositoryApi from '@joplin/lib/services/plugins/RepositoryApi';
|
||||
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import PluginInfoModal from './PluginInfoModal';
|
||||
import usePluginCallbacks from './utils/usePluginCallbacks';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
@ -43,21 +43,33 @@ const useLoadedPluginIds = () => {
|
||||
}, []);
|
||||
const [loadedPluginIds, setLoadedPluginIds] = useState(getLoadedPlugins);
|
||||
|
||||
useAsyncEffect(async event => {
|
||||
while (!event.cancelled) {
|
||||
await PluginService.instance().waitForLoadedPluginsChange();
|
||||
useEffect(() => {
|
||||
const { remove } = PluginService.instance().addLoadedPluginsChangeListener(() => {
|
||||
setLoadedPluginIds(getLoadedPlugins());
|
||||
}
|
||||
}, []);
|
||||
});
|
||||
|
||||
return () => {
|
||||
remove();
|
||||
};
|
||||
}, [getLoadedPlugins]);
|
||||
|
||||
return loadedPluginIds;
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
installedPluginsContainer: {
|
||||
marginLeft: 8,
|
||||
marginRight: 8,
|
||||
marginBottom: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const PluginStates: React.FC<Props> = props => {
|
||||
const [repoApiError, setRepoApiError] = useState(null);
|
||||
const [repoApiLoaded, setRepoApiLoaded] = useState(false);
|
||||
const [reloadRepoCounter, setRepoReloadCounter] = useState(0);
|
||||
const [updatablePluginIds, setUpdatablePluginIds] = useState<Record<string, boolean>>({});
|
||||
const [shownInDialogItem, setShownInDialogItem] = useState<PluginItem|null>(null);
|
||||
|
||||
const onRepoApiLoaded = useCallback(async (repoApi: RepositoryApi) => {
|
||||
const manifests = Object.values(PluginService.instance().plugins)
|
||||
@ -98,15 +110,26 @@ const PluginStates: React.FC<Props> = props => {
|
||||
<Button onPress={reloadPluginRepo}>{_('Retry')}</Button>
|
||||
</View>;
|
||||
} else {
|
||||
return <Text>{_('Loading plugin repository...')}</Text>;
|
||||
return <ProgressBar accessibilityLabel={_('Loading...')} indeterminate={true} />;
|
||||
}
|
||||
};
|
||||
|
||||
const onShowPluginLog = useCallback((event: ItemEvent) => {
|
||||
const pluginId = event.item.manifest.id;
|
||||
void NavService.go('Log', { defaultFilter: pluginId });
|
||||
const onShowPluginInfo = useCallback((event: ItemEvent) => {
|
||||
setShownInDialogItem(event.item);
|
||||
}, []);
|
||||
|
||||
const onPluginDialogClosed = useCallback(() => {
|
||||
setShownInDialogItem(null);
|
||||
}, []);
|
||||
|
||||
const pluginSettings = useMemo(() => {
|
||||
return PluginService.instance().unserializePluginSettings(props.pluginSettings);
|
||||
}, [props.pluginSettings]);
|
||||
|
||||
const { callbacks: pluginCallbacks, updatingPluginIds, installingPluginIds } = usePluginCallbacks({
|
||||
pluginSettings, updatePluginStates: props.updatePluginStates, repoApi,
|
||||
});
|
||||
|
||||
const installedPluginCards = [];
|
||||
const pluginService = PluginService.instance();
|
||||
|
||||
@ -116,16 +139,16 @@ const PluginStates: React.FC<Props> = props => {
|
||||
|
||||
if (!props.shouldShowBasedOnSearchQuery || props.shouldShowBasedOnSearchQuery(plugin.manifest.name)) {
|
||||
installedPluginCards.push(
|
||||
<PluginToggle
|
||||
<InstalledPluginBox
|
||||
key={`plugin-${pluginId}`}
|
||||
themeId={props.themeId}
|
||||
pluginId={pluginId}
|
||||
styles={props.styles}
|
||||
pluginSettings={props.pluginSettings}
|
||||
pluginSettings={pluginSettings}
|
||||
updatablePluginIds={updatablePluginIds}
|
||||
updatePluginStates={props.updatePluginStates}
|
||||
onShowPluginLog={onShowPluginLog}
|
||||
repoApi={repoApi}
|
||||
updatingPluginIds={updatingPluginIds}
|
||||
showInstalledChip={false}
|
||||
onShowPluginInfo={onShowPluginInfo}
|
||||
callbacks={pluginCallbacks}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
@ -135,21 +158,65 @@ const PluginStates: React.FC<Props> = props => {
|
||||
!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
|
||||
pluginSettings={props.pluginSettings}
|
||||
pluginSettings={pluginSettings}
|
||||
themeId={props.themeId}
|
||||
onUpdatePluginStates={props.updatePluginStates}
|
||||
installingPluginIds={installingPluginIds}
|
||||
callbacks={pluginCallbacks}
|
||||
repoApiInitialized={repoApiLoaded}
|
||||
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 (
|
||||
<View>
|
||||
{renderRepoApiStatus()}
|
||||
<List.AccordionGroup>
|
||||
<List.Accordion
|
||||
title={_('Installed plugins')}
|
||||
description={installedAccordionDescription}
|
||||
id='installed'
|
||||
>
|
||||
<View style={styles.installedPluginsContainer}>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
@ -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 * as React from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Button } from 'react-native-paper';
|
||||
import pickDocument from '../../../../utils/pickDocument';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { Platform } from 'react-native';
|
||||
import { Platform, View, ViewStyle } from 'react-native';
|
||||
import { join, extname } from 'path';
|
||||
import uuid from '@joplin/lib/uuid';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import TextButton, { ButtonType } from '../../../buttons/TextButton';
|
||||
import { ConfigScreenStyles } from '../configScreenStyles';
|
||||
|
||||
interface Props {
|
||||
updatePluginStates: (settingValue: PluginSettings)=> void;
|
||||
pluginSettings: SerializedPluginSettings;
|
||||
styles: ConfigScreenStyles;
|
||||
}
|
||||
|
||||
const logger = Logger.create('PluginUploadButton');
|
||||
@ -26,6 +28,8 @@ export const canInstallPluginsFromFile = () => {
|
||||
return shim.mobilePlatform() !== 'ios' || Setting.value('env') === 'dev';
|
||||
};
|
||||
|
||||
const buttonStyle: ViewStyle = { flexGrow: 1 };
|
||||
|
||||
const PluginUploadButton: React.FC<Props> = props => {
|
||||
const [showLoadingAnimation, setShowLoadingAnimation] = useState(false);
|
||||
|
||||
@ -85,13 +89,17 @@ const PluginUploadButton: React.FC<Props> = props => {
|
||||
}, [props.pluginSettings, props.updatePluginStates]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
<View style={props.styles.getContainerStyle(false)}>
|
||||
<TextButton
|
||||
type={ButtonType.Primary}
|
||||
onPress={onInstallFromFile}
|
||||
style={buttonStyle}
|
||||
disabled={showLoadingAnimation || !canInstallPluginsFromFile()}
|
||||
loading={showLoadingAnimation}
|
||||
>
|
||||
{buttonLabel()}
|
||||
</Button>
|
||||
</TextButton>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -4,21 +4,32 @@ import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { PluginManifest } from '@joplin/lib/services/plugins/utils/types';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { FlatList, View } from 'react-native';
|
||||
import { Searchbar } from 'react-native-paper';
|
||||
import { FlatList, StyleSheet, View } from 'react-native';
|
||||
import { TextInput, Text } from 'react-native-paper';
|
||||
import PluginBox, { InstallState } from './PluginBox';
|
||||
import PluginService, { PluginSettings, SerializedPluginSettings } from '@joplin/lib/services/plugins/PluginService';
|
||||
import useInstallHandler from '@joplin/lib/components/shared/config/plugins/useOnInstallHandler';
|
||||
import { OnPluginSettingChangeEvent, PluginItem } from '@joplin/lib/components/shared/config/plugins/types';
|
||||
import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService';
|
||||
import { PluginItem } from '@joplin/lib/components/shared/config/plugins/types';
|
||||
import RepositoryApi from '@joplin/lib/services/plugins/RepositoryApi';
|
||||
import openWebsiteForPlugin from './utils/openWebsiteForPlugin';
|
||||
import { PluginCallback, PluginCallbacks } from './utils/usePluginCallbacks';
|
||||
import InstalledPluginBox from './InstalledPluginBox';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
pluginSettings: SerializedPluginSettings;
|
||||
pluginSettings: PluginSettings;
|
||||
repoApiInitialized: boolean;
|
||||
onUpdatePluginStates: (states: PluginSettings)=> void;
|
||||
repoApi: RepositoryApi;
|
||||
|
||||
installingPluginIds: Record<string, boolean>;
|
||||
updatingPluginIds: Record<string, boolean>;
|
||||
updatablePluginIds: Record<string, boolean>;
|
||||
|
||||
callbacks: PluginCallbacks;
|
||||
onShowPluginInfo: PluginCallback;
|
||||
|
||||
searchQuery: string;
|
||||
setSearchQuery: (newQuery: string)=> void;
|
||||
}
|
||||
|
||||
interface SearchResultRecord {
|
||||
@ -27,8 +38,20 @@ interface SearchResultRecord {
|
||||
installState: InstallState;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'column',
|
||||
margin: 12,
|
||||
},
|
||||
resultsCounter: {
|
||||
margin: 12,
|
||||
marginTop: 17,
|
||||
marginBottom: 4,
|
||||
},
|
||||
});
|
||||
|
||||
const PluginSearch: React.FC<Props> = props => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const { searchQuery, setSearchQuery } = props;
|
||||
const [searchResultManifests, setSearchResultManifests] = useState<PluginManifest[]>([]);
|
||||
|
||||
useAsyncEffect(async event => {
|
||||
@ -42,8 +65,6 @@ const PluginSearch: React.FC<Props> = props => {
|
||||
}
|
||||
}, [searchQuery, props.repoApi, setSearchResultManifests, props.repoApiInitialized]);
|
||||
|
||||
const [installingPluginsIds, setInstallingPluginIds] = useState<Record<string, boolean>>({});
|
||||
|
||||
const pluginSettings = useMemo(() => {
|
||||
return { ...PluginService.instance().unserializePluginSettings(props.pluginSettings) };
|
||||
}, [props.pluginSettings]);
|
||||
@ -56,12 +77,13 @@ const PluginSearch: React.FC<Props> = props => {
|
||||
if (settings && !settings.deleted) {
|
||||
installState = InstallState.Installed;
|
||||
}
|
||||
if (installingPluginsIds[manifest.id]) {
|
||||
if (props.installingPluginIds[manifest.id]) {
|
||||
installState = InstallState.Installing;
|
||||
}
|
||||
|
||||
const item: PluginItem = {
|
||||
manifest,
|
||||
installed: !!settings,
|
||||
enabled: settings && settings.enabled,
|
||||
deleted: settings && !settings.deleted,
|
||||
devMode: false,
|
||||
@ -75,41 +97,62 @@ const PluginSearch: React.FC<Props> = props => {
|
||||
installState,
|
||||
};
|
||||
});
|
||||
}, [searchResultManifests, installingPluginsIds, pluginSettings]);
|
||||
}, [searchResultManifests, props.installingPluginIds, pluginSettings]);
|
||||
|
||||
const onPluginSettingsChange = useCallback((event: OnPluginSettingChangeEvent) => {
|
||||
props.onUpdatePluginStates(event.value);
|
||||
}, [props.onUpdatePluginStates]);
|
||||
|
||||
const installPlugin = useInstallHandler(
|
||||
setInstallingPluginIds, pluginSettings, props.repoApi, onPluginSettingsChange, false,
|
||||
);
|
||||
|
||||
const onInstall = props.callbacks.onInstall;
|
||||
const renderResult = useCallback(({ item }: { item: SearchResultRecord }) => {
|
||||
const manifest = item.item.manifest;
|
||||
|
||||
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 (
|
||||
<PluginBox
|
||||
themeId={props.themeId}
|
||||
key={manifest.id}
|
||||
item={item.item}
|
||||
installState={item.installState}
|
||||
showInstalledChip={false}
|
||||
isCompatible={PluginService.instance().isCompatible(manifest)}
|
||||
onInstall={installPlugin}
|
||||
onInstall={onInstall}
|
||||
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 (
|
||||
<View style={{ flexDirection: 'column' }}>
|
||||
<Searchbar
|
||||
<View style={styles.container}>
|
||||
<TextInput
|
||||
testID='searchbar'
|
||||
placeholder={_('Search')}
|
||||
mode='outlined'
|
||||
left={<TextInput.Icon icon='magnify' />}
|
||||
placeholder={_('Search plugins')}
|
||||
onChangeText={setSearchQuery}
|
||||
value={searchQuery}
|
||||
editable={props.repoApiInitialized}
|
||||
/>
|
||||
{renderResultsCount()}
|
||||
<FlatList
|
||||
data={searchResults}
|
||||
renderItem={renderResult}
|
||||
|
@ -1,12 +1,13 @@
|
||||
import * as React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { ItemEvent, PluginItem } from '@joplin/lib/components/shared/config/plugins/types';
|
||||
import { Button, ButtonProps } from 'react-native-paper';
|
||||
import { PluginItem } from '@joplin/lib/components/shared/config/plugins/types';
|
||||
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;
|
||||
type?: ButtonType;
|
||||
onPress?: PluginCallback;
|
||||
title: string;
|
||||
}
|
||||
@ -24,11 +25,12 @@ const ActionButton: React.FC<Props> = props => {
|
||||
// marked as translatable.
|
||||
const accessibilityLabel = `${props.title} ${props.item.manifest.name}`;
|
||||
return (
|
||||
<Button
|
||||
<TextButton
|
||||
type={ButtonType.Primary}
|
||||
{...props}
|
||||
onPress={onPress}
|
||||
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
|
||||
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 Setting from '@joplin/lib/models/Setting';
|
||||
import { Dispatch } from 'redux';
|
||||
import DismissibleDialog from '../../../components/DismissibleDialog';
|
||||
import DismissibleDialog, { DialogSize } from '../../../components/DismissibleDialog';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
@ -168,6 +168,7 @@ const PluginPanelViewer: React.FC<Props> = props => {
|
||||
<DismissibleDialog
|
||||
themeId={props.themeId}
|
||||
visible={props.visible}
|
||||
size={DialogSize.Large}
|
||||
onDismiss={onClose}
|
||||
>
|
||||
{renderTabContent()}
|
||||
|
@ -1,10 +1,42 @@
|
||||
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
||||
import { useMemo } from 'react';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
const logger = Logger.create('usePlugin');
|
||||
|
||||
const usePlugin = (pluginId: string) => {
|
||||
return useMemo(() => {
|
||||
const [pluginReloadCounter, setPluginReloadCounter] = useState(0);
|
||||
|
||||
const plugin = useMemo(() => {
|
||||
if (!PluginService.instance().pluginIds.includes(pluginId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (pluginReloadCounter > 0) {
|
||||
logger.debug('Reloading plugin', pluginId, 'because the set of loaded plugins changed.');
|
||||
}
|
||||
|
||||
return PluginService.instance().pluginById(pluginId);
|
||||
}, [pluginId]);
|
||||
// The dependency on pluginReloadCounter is important -- it ensures that the plugin
|
||||
// matches the one loaded in the PluginService.
|
||||
}, [pluginId, pluginReloadCounter]);
|
||||
|
||||
const reloadCounterRef = useRef(0);
|
||||
reloadCounterRef.current = pluginReloadCounter;
|
||||
|
||||
// The plugin may need to be re-fetched from the PluginService. When a plugin is reloaded,
|
||||
// its Plugin object is replaced with a new one.
|
||||
useEffect(() => {
|
||||
const { remove } = PluginService.instance().addLoadedPluginsChangeListener(() => {
|
||||
setPluginReloadCounter(reloadCounterRef.current + 1);
|
||||
});
|
||||
|
||||
return () => {
|
||||
remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return plugin;
|
||||
};
|
||||
|
||||
export default usePlugin;
|
||||
|
@ -1202,8 +1202,10 @@ class AppComponent extends React.Component {
|
||||
onPrimaryContainer: theme.color5,
|
||||
primaryContainer: theme.backgroundColor5,
|
||||
|
||||
primary: theme.color,
|
||||
onPrimary: theme.backgroundColor,
|
||||
outline: theme.codeBorderColor,
|
||||
|
||||
primary: theme.color4,
|
||||
onPrimary: theme.backgroundColor4,
|
||||
|
||||
background: theme.backgroundColor,
|
||||
|
||||
|
@ -4,6 +4,7 @@ import { PluginManifest } from '../../../../services/plugins/utils/types';
|
||||
|
||||
export interface PluginItem {
|
||||
manifest: PluginManifest;
|
||||
installed: boolean;
|
||||
enabled: boolean;
|
||||
deleted: boolean;
|
||||
devMode: boolean;
|
||||
|
@ -142,17 +142,20 @@ export default class PluginService extends BaseService {
|
||||
this.isSafeMode_ = v;
|
||||
}
|
||||
|
||||
public waitForLoadedPluginsChange() {
|
||||
return new Promise<void>(resolve => {
|
||||
this.pluginsChangeListeners_.push(() => resolve());
|
||||
});
|
||||
public addLoadedPluginsChangeListener(listener: ()=> void) {
|
||||
this.pluginsChangeListeners_.push(listener);
|
||||
|
||||
return {
|
||||
remove: () => {
|
||||
this.pluginsChangeListeners_ = this.pluginsChangeListeners_.filter(l => (l !== listener));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private dispatchPluginsChangeListeners() {
|
||||
for (const listener of this.pluginsChangeListeners_) {
|
||||
listener();
|
||||
}
|
||||
this.pluginsChangeListeners_ = [];
|
||||
}
|
||||
|
||||
private setPluginAt(pluginId: string, plugin: Plugin) {
|
||||
|
@ -45,6 +45,7 @@ const input: Theme = {
|
||||
searchMarkerColor: 'black',
|
||||
|
||||
warningBackgroundColor: '#FFD08D',
|
||||
destructiveColor: '#F00000',
|
||||
|
||||
tableBackgroundColor: 'rgb(247, 247, 247)',
|
||||
codeBackgroundColor: 'rgb(243, 243, 243)',
|
||||
@ -89,6 +90,7 @@ const expected = `
|
||||
--joplin-color-warn2: #ffcb81;
|
||||
--joplin-color-warn3: #ff7626;
|
||||
--joplin-color-warn-url: #155BDA;
|
||||
--joplin-destructive-color: #F00000;
|
||||
--joplin-divider-color: #dddddd;
|
||||
--joplin-header-background-color: #ffffff;
|
||||
--joplin-odd-background-color: #eeeeee;
|
||||
|
@ -48,6 +48,7 @@ const theme: Theme = {
|
||||
searchMarkerColor: 'black',
|
||||
|
||||
warningBackgroundColor: '#013F74',
|
||||
destructiveColor: '#F07777',
|
||||
|
||||
tableBackgroundColor: 'rgb(40, 41, 42)',
|
||||
codeBackgroundColor: 'rgb(47, 48, 49)',
|
||||
|
@ -45,6 +45,7 @@ const theme: Theme = {
|
||||
searchMarkerColor: 'black',
|
||||
|
||||
warningBackgroundColor: '#FFD08D',
|
||||
destructiveColor: '#D00707',
|
||||
|
||||
tableBackgroundColor: 'rgb(247, 247, 247)',
|
||||
codeBackgroundColor: 'rgb(243, 243, 243)',
|
||||
|
@ -50,6 +50,7 @@ export interface Theme {
|
||||
searchMarkerColor: string;
|
||||
|
||||
warningBackgroundColor: string;
|
||||
destructiveColor: string;
|
||||
|
||||
tableBackgroundColor: string;
|
||||
codeBackgroundColor: string;
|
||||
|
Loading…
Reference in New Issue
Block a user