mirror of
https://github.com/laurent22/joplin.git
synced 2024-12-21 09:38:01 +02:00
This commit is contained in:
parent
a4a4170d49
commit
e465b45d6e
@ -619,9 +619,10 @@ packages/app-mobile/components/screens/ConfigScreen/SettingsToggle.js
|
|||||||
packages/app-mobile/components/screens/ConfigScreen/configScreenStyles.js
|
packages/app-mobile/components/screens/ConfigScreen/configScreenStyles.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/EnablePluginSupportPage.js
|
packages/app-mobile/components/screens/ConfigScreen/plugins/EnablePluginSupportPage.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/InstalledPluginBox.js
|
packages/app-mobile/components/screens/ConfigScreen/plugins/InstalledPluginBox.js
|
||||||
|
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginChip.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginChips.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/PluginTitle.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/StyledChip.js
|
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/RecommendedBadge.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/index.js
|
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/index.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginInfoModal.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.installed.test.js
|
||||||
@ -629,6 +630,7 @@ packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.search.
|
|||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.js
|
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginUploadButton.js
|
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginUploadButton.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/SearchPlugins.js
|
packages/app-mobile/components/screens/ConfigScreen/plugins/SearchPlugins.js
|
||||||
|
packages/app-mobile/components/screens/ConfigScreen/plugins/SectionLabel.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/buttons/ActionButton.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/buttons/InstallButton.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/WrappedPluginStates.js
|
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/WrappedPluginStates.js
|
||||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -598,9 +598,10 @@ packages/app-mobile/components/screens/ConfigScreen/SettingsToggle.js
|
|||||||
packages/app-mobile/components/screens/ConfigScreen/configScreenStyles.js
|
packages/app-mobile/components/screens/ConfigScreen/configScreenStyles.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/EnablePluginSupportPage.js
|
packages/app-mobile/components/screens/ConfigScreen/plugins/EnablePluginSupportPage.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/InstalledPluginBox.js
|
packages/app-mobile/components/screens/ConfigScreen/plugins/InstalledPluginBox.js
|
||||||
|
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginChip.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginChips.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/PluginTitle.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/StyledChip.js
|
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/RecommendedBadge.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/index.js
|
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/index.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginInfoModal.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.installed.test.js
|
||||||
@ -608,6 +609,7 @@ packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.search.
|
|||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.js
|
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginUploadButton.js
|
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginUploadButton.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/SearchPlugins.js
|
packages/app-mobile/components/screens/ConfigScreen/plugins/SearchPlugins.js
|
||||||
|
packages/app-mobile/components/screens/ConfigScreen/plugins/SectionLabel.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/buttons/ActionButton.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/buttons/InstallButton.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/WrappedPluginStates.js
|
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/WrappedPluginStates.js
|
||||||
|
@ -0,0 +1,57 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Chip, ChipProps } from 'react-native-paper';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { AppState } from '../../../../../utils/types';
|
||||||
|
import { themeStyle } from '../../../../global-style';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
themeId: number;
|
||||||
|
color?: string;
|
||||||
|
faded?: boolean;
|
||||||
|
onPress?: ()=> void;
|
||||||
|
icon?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fadedStyle = { opacity: 0.87 };
|
||||||
|
|
||||||
|
const PluginChip: React.FC<Props> = props => {
|
||||||
|
const themeOverride = useMemo(() => {
|
||||||
|
const theme = themeStyle(props.themeId);
|
||||||
|
const foreground = props.color ?? theme.color;
|
||||||
|
const background = theme.backgroundColor;
|
||||||
|
return {
|
||||||
|
colors: {
|
||||||
|
secondaryContainer: background,
|
||||||
|
onSecondaryContainer: foreground,
|
||||||
|
primary: foreground,
|
||||||
|
|
||||||
|
outline: foreground,
|
||||||
|
onPrimary: foreground,
|
||||||
|
onSurfaceVariant: foreground,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [props.themeId, props.color]);
|
||||||
|
|
||||||
|
const accessibilityProps: Partial<ChipProps> = {};
|
||||||
|
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}
|
||||||
|
style={props.faded ? fadedStyle : null}
|
||||||
|
mode='outlined'
|
||||||
|
{...accessibilityProps}
|
||||||
|
{...props}
|
||||||
|
/>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect((state: AppState) => {
|
||||||
|
return {
|
||||||
|
themeId: state.settings.theme,
|
||||||
|
};
|
||||||
|
})(PluginChip);
|
@ -2,10 +2,10 @@ import { PluginItem } from '@joplin/lib/components/shared/config/plugins/types';
|
|||||||
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
||||||
import shim from '@joplin/lib/shim';
|
import shim from '@joplin/lib/shim';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Alert, Linking, View, ViewStyle } from 'react-native';
|
import { View, ViewStyle } from 'react-native';
|
||||||
import { _ } from '@joplin/lib/locale';
|
import { _ } from '@joplin/lib/locale';
|
||||||
import { PluginCallback } from '../utils/usePluginCallbacks';
|
import { PluginCallback } from '../utils/usePluginCallbacks';
|
||||||
import StyledChip from './StyledChip';
|
import PluginChip from './PluginChip';
|
||||||
import { themeStyle } from '../../../../global-style';
|
import { themeStyle } from '../../../../global-style';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -19,23 +19,6 @@ interface Props {
|
|||||||
onShowPluginLog?: PluginCallback;
|
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 = {
|
const containerStyle: ViewStyle = {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
gap: 4,
|
gap: 4,
|
||||||
@ -54,43 +37,28 @@ const PluginChips: React.FC<Props> = props => {
|
|||||||
if (!props.hasErrors) return null;
|
if (!props.hasErrors) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledChip
|
<PluginChip
|
||||||
background={theme.backgroundColor2}
|
color={theme.colorError2}
|
||||||
foreground={theme.colorError2}
|
|
||||||
icon='alert'
|
icon='alert'
|
||||||
mode='flat'
|
|
||||||
onPress={() => props.onShowPluginLog({ item })}
|
onPress={() => props.onShowPluginLog({ item })}
|
||||||
>
|
>
|
||||||
{_('Error')}
|
{_('Error')}
|
||||||
</StyledChip>
|
</PluginChip>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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 = () => {
|
const renderBuiltInChip = () => {
|
||||||
if (!props.item.builtIn) {
|
if (!props.item.builtIn) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return <StyledChip icon='code-tags-check'>{_('Built-in')}</StyledChip>;
|
return <PluginChip icon='code-tags-check'>{_('Built-in')}</PluginChip>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderIncompatibleChip = () => {
|
const renderIncompatibleChip = () => {
|
||||||
if (props.isCompatible) return null;
|
if (props.isCompatible) return null;
|
||||||
return (
|
return (
|
||||||
<StyledChip
|
<PluginChip
|
||||||
background={theme.backgroundColor3}
|
color={theme.color3}
|
||||||
foreground={theme.color3}
|
|
||||||
icon='alert'
|
icon='alert'
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
void shim.showMessageBox(
|
void shim.showMessageBox(
|
||||||
@ -98,7 +66,7 @@ const PluginChips: React.FC<Props> = props => {
|
|||||||
{ buttons: [_('OK')] },
|
{ buttons: [_('OK')] },
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>{_('Incompatible')}</StyledChip>
|
>{_('Incompatible')}</PluginChip>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -106,7 +74,7 @@ const PluginChips: React.FC<Props> = props => {
|
|||||||
if (!props.isCompatible || !props.canUpdate) return null;
|
if (!props.isCompatible || !props.canUpdate) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledChip>{_('Update available')}</StyledChip>
|
<PluginChip>{_('Update available')}</PluginChip>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -114,21 +82,20 @@ const PluginChips: React.FC<Props> = props => {
|
|||||||
if (props.item.enabled || !props.item.installed) {
|
if (props.item.enabled || !props.item.installed) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return <StyledChip>{_('Disabled')}</StyledChip>;
|
return <PluginChip faded={true}>{_('Disabled')}</PluginChip>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderInstalledChip = () => {
|
const renderInstalledChip = () => {
|
||||||
if (!props.showInstalledChip) {
|
if (!props.showInstalledChip) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return <StyledChip>{_('Installed')}</StyledChip>;
|
return <PluginChip faded={true}>{_('Installed')}</PluginChip>;
|
||||||
};
|
};
|
||||||
|
|
||||||
return <View style={containerStyle}>
|
return <View style={containerStyle}>
|
||||||
{renderIncompatibleChip()}
|
{renderIncompatibleChip()}
|
||||||
{renderInstalledChip()}
|
{renderInstalledChip()}
|
||||||
{renderErrorsChip()}
|
{renderErrorsChip()}
|
||||||
{renderRecommendedChip()}
|
|
||||||
{renderBuiltInChip()}
|
{renderBuiltInChip()}
|
||||||
{renderUpdatableChip()}
|
{renderUpdatableChip()}
|
||||||
{renderDisabledChip()}
|
{renderDisabledChip()}
|
||||||
|
@ -0,0 +1,74 @@
|
|||||||
|
import { _ } from '@joplin/lib/locale';
|
||||||
|
import { PluginManifest } from '@joplin/lib/services/plugins/utils/types';
|
||||||
|
import * as React from 'react';
|
||||||
|
import IconButton from '../../../../IconButton';
|
||||||
|
import { Alert, Linking, StyleSheet } from 'react-native';
|
||||||
|
import { themeStyle } from '../../../../global-style';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
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 },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
themeId: number;
|
||||||
|
manifest: PluginManifest;
|
||||||
|
isCompatible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = (themeId: number) => {
|
||||||
|
return useMemo(() => {
|
||||||
|
const theme = themeStyle(themeId);
|
||||||
|
return StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
wrapper: {
|
||||||
|
borderColor: theme.colorWarn,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 20,
|
||||||
|
justifyContent: 'center',
|
||||||
|
height: 32,
|
||||||
|
width: 32,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: theme.colorWarn,
|
||||||
|
marginLeft: 'auto',
|
||||||
|
marginRight: 'auto',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [themeId]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RecommendedBadge: React.FC<Props> = props => {
|
||||||
|
const styles = useStyles(props.themeId);
|
||||||
|
|
||||||
|
if (!props.manifest._recommended || !props.isCompatible) return null;
|
||||||
|
|
||||||
|
return <IconButton
|
||||||
|
onPress={onRecommendedPress}
|
||||||
|
iconName='fas fa-crown'
|
||||||
|
containerStyle={styles.container}
|
||||||
|
contentWrapperStyle={styles.wrapper}
|
||||||
|
iconStyle={styles.icon}
|
||||||
|
themeId={props.themeId}
|
||||||
|
description={_('Recommended')}
|
||||||
|
/>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RecommendedBadge;
|
@ -1,39 +0,0 @@
|
|||||||
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;
|
|
@ -8,9 +8,10 @@ import PluginChips from './PluginChips';
|
|||||||
import { UpdateState } from '../utils/useUpdateState';
|
import { UpdateState } from '../utils/useUpdateState';
|
||||||
import { PluginCallback } from '../utils/usePluginCallbacks';
|
import { PluginCallback } from '../utils/usePluginCallbacks';
|
||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { StyleSheet } from 'react-native';
|
import { StyleSheet, View } from 'react-native';
|
||||||
import InstallButton from '../buttons/InstallButton';
|
import InstallButton from '../buttons/InstallButton';
|
||||||
import PluginTitle from './PluginTitle';
|
import PluginTitle from './PluginTitle';
|
||||||
|
import RecommendedBadge from './RecommendedBadge';
|
||||||
|
|
||||||
export enum InstallState {
|
export enum InstallState {
|
||||||
NotInstalled,
|
NotInstalled,
|
||||||
@ -92,8 +93,13 @@ const PluginBox: React.FC<Props> = props => {
|
|||||||
testID='plugin-card'
|
testID='plugin-card'
|
||||||
>
|
>
|
||||||
<Card.Content style={styles.content}>
|
<Card.Content style={styles.content}>
|
||||||
<PluginTitle manifest={item.manifest} />
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
|
||||||
<Text numberOfLines={2}>{manifest.description}</Text>
|
<View style={{ flexShrink: 1 }}>
|
||||||
|
<PluginTitle manifest={item.manifest} />
|
||||||
|
<Text numberOfLines={2}>{manifest.description}</Text>
|
||||||
|
</View>
|
||||||
|
<RecommendedBadge manifest={item.manifest} isCompatible={props.isCompatible} themeId={props.themeId} />
|
||||||
|
</View>
|
||||||
<PluginChips
|
<PluginChips
|
||||||
themeId={props.themeId}
|
themeId={props.themeId}
|
||||||
item={props.item}
|
item={props.item}
|
||||||
|
@ -47,11 +47,6 @@ const loadMockPlugin = async (id: string, name: string, version: string, pluginS
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const showInstalledTab = async () => {
|
|
||||||
const installedTab = await screen.findByText('Installed plugins');
|
|
||||||
await userEvent.press(installedTab);
|
|
||||||
};
|
|
||||||
|
|
||||||
const abcPluginId = 'org.joplinapp.plugins.AbcSheetMusic';
|
const abcPluginId = 'org.joplinapp.plugins.AbcSheetMusic';
|
||||||
const backlinksPluginId = 'joplin.plugin.ambrt.backlinksToNote';
|
const backlinksPluginId = 'joplin.plugin.ambrt.backlinksToNote';
|
||||||
|
|
||||||
@ -99,7 +94,6 @@ describe('PluginStates.installed', () => {
|
|||||||
store={reduxStore}
|
store={reduxStore}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await showInstalledTab();
|
|
||||||
expect(await screen.findByText(/^ABC Sheet Music/)).toBeVisible();
|
expect(await screen.findByText(/^ABC Sheet Music/)).toBeVisible();
|
||||||
expect(await screen.findByText(/^Backlinks to note/)).toBeVisible();
|
expect(await screen.findByText(/^Backlinks to note/)).toBeVisible();
|
||||||
|
|
||||||
@ -129,7 +123,6 @@ describe('PluginStates.installed', () => {
|
|||||||
store={reduxStore}
|
store={reduxStore}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await showInstalledTab();
|
|
||||||
|
|
||||||
const abcSheetMusicCard = await screen.findByText(/^ABC Sheet Music/);
|
const abcSheetMusicCard = await screen.findByText(/^ABC Sheet Music/);
|
||||||
expect(abcSheetMusicCard).toBeVisible();
|
expect(abcSheetMusicCard).toBeVisible();
|
||||||
@ -150,7 +143,7 @@ describe('PluginStates.installed', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Initially, no plugins should be installed
|
// Initially, no plugins should be installed
|
||||||
expect(screen.queryByText(/^You currently have 0 plugins? installed/)).toBeNull();
|
expect(screen.queryByText('Installed (0):')).toBeNull();
|
||||||
|
|
||||||
const testPluginId1 = 'org.joplinapp.plugins.AbcSheetMusic';
|
const testPluginId1 = 'org.joplinapp.plugins.AbcSheetMusic';
|
||||||
const testPluginId2 = 'org.joplinapp.plugins.test.plugin.id';
|
const testPluginId2 = 'org.joplinapp.plugins.test.plugin.id';
|
||||||
@ -158,8 +151,6 @@ describe('PluginStates.installed', () => {
|
|||||||
await act(() => loadMockPlugin(testPluginId2, 'A test plugin', '1.0.0', pluginSettings));
|
await act(() => loadMockPlugin(testPluginId2, 'A test plugin', '1.0.0', pluginSettings));
|
||||||
expect(PluginService.instance().plugins[testPluginId1]).toBeTruthy();
|
expect(PluginService.instance().plugins[testPluginId1]).toBeTruthy();
|
||||||
|
|
||||||
await showInstalledTab();
|
|
||||||
|
|
||||||
// Should update the list of installed plugins even though the plugin settings didn't change.
|
// Should update the list of installed plugins even though the plugin settings didn't change.
|
||||||
expect(await screen.findByText(/^ABC Sheet Music/)).toBeVisible();
|
expect(await screen.findByText(/^ABC Sheet Music/)).toBeVisible();
|
||||||
expect(await screen.findByText(/^A test plugin/)).toBeVisible();
|
expect(await screen.findByText(/^A test plugin/)).toBeVisible();
|
||||||
@ -184,7 +175,6 @@ describe('PluginStates.installed', () => {
|
|||||||
store={reduxStore}
|
store={reduxStore}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await showInstalledTab();
|
|
||||||
|
|
||||||
const card = await screen.findByText('ABC Sheet Music');
|
const card = await screen.findByText('ABC Sheet Music');
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
@ -226,7 +216,6 @@ describe('PluginStates.installed', () => {
|
|||||||
store={reduxStore}
|
store={reduxStore}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await showInstalledTab();
|
|
||||||
|
|
||||||
// Open the plugin dialog
|
// Open the plugin dialog
|
||||||
const card = await screen.findByText('ABC Sheet Music');
|
const card = await screen.findByText('ABC Sheet Music');
|
||||||
@ -279,7 +268,6 @@ describe('PluginStates.installed', () => {
|
|||||||
store={reduxStore}
|
store={reduxStore}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await showInstalledTab();
|
|
||||||
|
|
||||||
// Should be shown as installed.
|
// Should be shown as installed.
|
||||||
const card = await screen.findByText('ABC Sheet Music');
|
const card = await screen.findByText('ABC Sheet Music');
|
||||||
|
@ -18,14 +18,9 @@ const expectSearchResultCountToBe = async (count: number) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
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.
|
// The search box is initially read-only -- waits for it to be editable.
|
||||||
const getEditableSearchBox = async () => {
|
const getEditableSearchBox = async () => {
|
||||||
const searchBox = await screen.findByPlaceholderText('Search plugins');
|
const searchBox = await screen.findByPlaceholderText('Search for plugins...');
|
||||||
expect(searchBox).toBeVisible();
|
expect(searchBox).toBeVisible();
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@ -53,7 +48,6 @@ describe('PluginStates.search', () => {
|
|||||||
const wrapper = render(<WrappedPluginStates initialPluginSettings={{}} store={reduxStore}/>);
|
const wrapper = render(<WrappedPluginStates initialPluginSettings={{}} store={reduxStore}/>);
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
await showSearchTab();
|
|
||||||
const searchBox = await getEditableSearchBox();
|
const searchBox = await getEditableSearchBox();
|
||||||
await user.type(searchBox, 'backlinks');
|
await user.type(searchBox, 'backlinks');
|
||||||
|
|
||||||
@ -81,8 +75,6 @@ describe('PluginStates.search', () => {
|
|||||||
const wrapper = render(<WrappedPluginStates initialPluginSettings={{}} store={reduxStore}/>);
|
const wrapper = render(<WrappedPluginStates initialPluginSettings={{}} store={reduxStore}/>);
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
await showSearchTab();
|
|
||||||
|
|
||||||
const searchBox = await getEditableSearchBox();
|
const searchBox = await getEditableSearchBox();
|
||||||
|
|
||||||
await user.press(searchBox);
|
await user.press(searchBox);
|
||||||
@ -111,8 +103,6 @@ describe('PluginStates.search', () => {
|
|||||||
const wrapper = render(<WrappedPluginStates initialPluginSettings={{}} store={reduxStore}/>);
|
const wrapper = render(<WrappedPluginStates initialPluginSettings={{}} store={reduxStore}/>);
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
await showSearchTab();
|
|
||||||
|
|
||||||
const searchBox = await getEditableSearchBox();
|
const searchBox = await getEditableSearchBox();
|
||||||
await user.press(searchBox);
|
await user.press(searchBox);
|
||||||
await user.type(searchBox, 'abc');
|
await user.type(searchBox, 'abc');
|
||||||
|
@ -2,8 +2,8 @@ import * as React from 'react';
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { ConfigScreenStyles } from '../configScreenStyles';
|
import { ConfigScreenStyles } from '../configScreenStyles';
|
||||||
import { View, StyleSheet } from 'react-native';
|
import { View, StyleSheet } from 'react-native';
|
||||||
import { Banner, Text, Button, ProgressBar, List, Divider } from 'react-native-paper';
|
import { Banner, Text, Button, ProgressBar, Divider } from 'react-native-paper';
|
||||||
import { _, _n } from '@joplin/lib/locale';
|
import { _ } from '@joplin/lib/locale';
|
||||||
import PluginService, { PluginSettings, SerializedPluginSettings } from '@joplin/lib/services/plugins/PluginService';
|
import PluginService, { PluginSettings, SerializedPluginSettings } from '@joplin/lib/services/plugins/PluginService';
|
||||||
import InstalledPluginBox from './InstalledPluginBox';
|
import InstalledPluginBox from './InstalledPluginBox';
|
||||||
import SearchPlugins from './SearchPlugins';
|
import SearchPlugins from './SearchPlugins';
|
||||||
@ -13,6 +13,7 @@ import RepositoryApi from '@joplin/lib/services/plugins/RepositoryApi';
|
|||||||
import PluginInfoModal from './PluginInfoModal';
|
import PluginInfoModal from './PluginInfoModal';
|
||||||
import usePluginCallbacks from './utils/usePluginCallbacks';
|
import usePluginCallbacks from './utils/usePluginCallbacks';
|
||||||
import BetaChip from '../../../BetaChip';
|
import BetaChip from '../../../BetaChip';
|
||||||
|
import SectionLabel from './SectionLabel';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
themeId: number;
|
themeId: number;
|
||||||
@ -59,8 +60,8 @@ const useLoadedPluginIds = () => {
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
installedPluginsContainer: {
|
installedPluginsContainer: {
|
||||||
marginLeft: 8,
|
marginLeft: 12,
|
||||||
marginRight: 8,
|
marginRight: 12,
|
||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -134,11 +135,16 @@ const PluginStates: React.FC<Props> = props => {
|
|||||||
const installedPluginCards = [];
|
const installedPluginCards = [];
|
||||||
const pluginService = PluginService.instance();
|
const pluginService = PluginService.instance();
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const isPluginSearching = !!searchQuery;
|
||||||
|
|
||||||
const pluginIds = useLoadedPluginIds();
|
const pluginIds = useLoadedPluginIds();
|
||||||
for (const pluginId of pluginIds) {
|
for (const pluginId of pluginIds) {
|
||||||
const plugin = pluginService.plugins[pluginId];
|
const plugin = pluginService.plugins[pluginId];
|
||||||
|
|
||||||
if (!props.shouldShowBasedOnSearchQuery || props.shouldShowBasedOnSearchQuery(plugin.manifest.name)) {
|
const matchesGlobalSearch = !props.shouldShowBasedOnSearchQuery || props.shouldShowBasedOnSearchQuery(plugin.manifest.name);
|
||||||
|
const showCard = !isPluginSearching && matchesGlobalSearch;
|
||||||
|
if (showCard) {
|
||||||
installedPluginCards.push(
|
installedPluginCards.push(
|
||||||
<InstalledPluginBox
|
<InstalledPluginBox
|
||||||
key={`plugin-${pluginId}`}
|
key={`plugin-${pluginId}`}
|
||||||
@ -159,40 +165,25 @@ const PluginStates: React.FC<Props> = props => {
|
|||||||
!props.shouldShowBasedOnSearchQuery || props.shouldShowBasedOnSearchQuery(searchInputSearchText())
|
!props.shouldShowBasedOnSearchQuery || props.shouldShowBasedOnSearchQuery(searchInputSearchText())
|
||||||
);
|
);
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const searchSection = (
|
||||||
|
<SearchPlugins
|
||||||
|
pluginSettings={pluginSettings}
|
||||||
|
themeId={props.themeId}
|
||||||
|
onUpdatePluginStates={props.updatePluginStates}
|
||||||
|
installingPluginIds={installingPluginIds}
|
||||||
|
callbacks={pluginCallbacks}
|
||||||
|
repoApiInitialized={repoApiLoaded}
|
||||||
|
repoApi={repoApi}
|
||||||
|
updatingPluginIds={updatingPluginIds}
|
||||||
|
updatablePluginIds={updatablePluginIds}
|
||||||
|
onShowPluginInfo={onShowPluginInfo}
|
||||||
|
|
||||||
const searchAccordion = (
|
searchQuery={searchQuery}
|
||||||
<List.Accordion
|
setSearchQuery={setSearchQuery}
|
||||||
title={_('Install new plugins')}
|
/>
|
||||||
description={_('Browse and install community plugins.')}
|
|
||||||
id='search'
|
|
||||||
>
|
|
||||||
<SearchPlugins
|
|
||||||
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;
|
const isSearching = !!props.shouldShowBasedOnSearchQuery || isPluginSearching;
|
||||||
// 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;
|
|
||||||
|
|
||||||
// Using a different wrapper prevents the installed item group from being openable when
|
|
||||||
// there are no plugins:
|
|
||||||
const InstalledItemWrapper = pluginIds.length ? List.Accordion : List.Item;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
@ -202,20 +193,14 @@ const PluginStates: React.FC<Props> = props => {
|
|||||||
</Banner>
|
</Banner>
|
||||||
<Divider/>
|
<Divider/>
|
||||||
|
|
||||||
<List.AccordionGroup>
|
{showSearch ? searchSection : null}
|
||||||
<InstalledItemWrapper
|
<View style={styles.installedPluginsContainer}>
|
||||||
title={_('Installed plugins')}
|
<SectionLabel visible={!isSearching}>
|
||||||
description={installedAccordionDescription}
|
{pluginIds.length ? _('Installed (%d):', pluginIds.length) : _('No plugins are installed.')}
|
||||||
id='installed'
|
</SectionLabel>
|
||||||
>
|
{installedPluginCards}
|
||||||
<View style={styles.installedPluginsContainer}>
|
</View>
|
||||||
{installedPluginCards}
|
|
||||||
</View>
|
|
||||||
</InstalledItemWrapper>
|
|
||||||
<Divider/>
|
|
||||||
{showSearch ? searchAccordion : null}
|
|
||||||
<Divider/>
|
|
||||||
</List.AccordionGroup>
|
|
||||||
<PluginInfoModal
|
<PluginInfoModal
|
||||||
themeId={props.themeId}
|
themeId={props.themeId}
|
||||||
pluginSettings={pluginSettings}
|
pluginSettings={pluginSettings}
|
||||||
@ -227,6 +212,7 @@ const PluginStates: React.FC<Props> = props => {
|
|||||||
onModalDismiss={onPluginDialogClosed}
|
onModalDismiss={onPluginDialogClosed}
|
||||||
pluginCallbacks={pluginCallbacks}
|
pluginCallbacks={pluginCallbacks}
|
||||||
/>
|
/>
|
||||||
|
<Divider/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -5,7 +5,7 @@ import { _ } from '@joplin/lib/locale';
|
|||||||
import { PluginManifest } from '@joplin/lib/services/plugins/utils/types';
|
import { PluginManifest } from '@joplin/lib/services/plugins/utils/types';
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { FlatList, StyleSheet, View } from 'react-native';
|
import { FlatList, StyleSheet, View } from 'react-native';
|
||||||
import { TextInput, Text } from 'react-native-paper';
|
import { TextInput } from 'react-native-paper';
|
||||||
import PluginBox, { InstallState } from './PluginBox';
|
import PluginBox, { InstallState } from './PluginBox';
|
||||||
import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService';
|
import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService';
|
||||||
import { PluginItem } from '@joplin/lib/components/shared/config/plugins/types';
|
import { PluginItem } from '@joplin/lib/components/shared/config/plugins/types';
|
||||||
@ -13,6 +13,7 @@ import RepositoryApi from '@joplin/lib/services/plugins/RepositoryApi';
|
|||||||
import openWebsiteForPlugin from './utils/openWebsiteForPlugin';
|
import openWebsiteForPlugin from './utils/openWebsiteForPlugin';
|
||||||
import { PluginCallback, PluginCallbacks } from './utils/usePluginCallbacks';
|
import { PluginCallback, PluginCallbacks } from './utils/usePluginCallbacks';
|
||||||
import InstalledPluginBox from './InstalledPluginBox';
|
import InstalledPluginBox from './InstalledPluginBox';
|
||||||
|
import SectionLabel from './SectionLabel';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
themeId: number;
|
themeId: number;
|
||||||
@ -42,14 +43,11 @@ const styles = StyleSheet.create({
|
|||||||
container: {
|
container: {
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
margin: 12,
|
margin: 12,
|
||||||
},
|
marginBottom: 0,
|
||||||
resultsCounter: {
|
|
||||||
margin: 12,
|
|
||||||
marginTop: 17,
|
|
||||||
marginBottom: 4,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const PluginSearch: React.FC<Props> = props => {
|
const PluginSearch: React.FC<Props> = props => {
|
||||||
const { searchQuery, setSearchQuery } = props;
|
const { searchQuery, setSearchQuery } = props;
|
||||||
const [searchResultManifests, setSearchResultManifests] = useState<PluginManifest[]>([]);
|
const [searchResultManifests, setSearchResultManifests] = useState<PluginManifest[]>([]);
|
||||||
@ -133,12 +131,16 @@ const PluginSearch: React.FC<Props> = props => {
|
|||||||
}
|
}
|
||||||
}, [onInstall, props.themeId, props.pluginSettings, props.updatingPluginIds, props.updatablePluginIds, props.onShowPluginInfo, props.callbacks]);
|
}, [onInstall, props.themeId, props.pluginSettings, props.updatingPluginIds, props.updatablePluginIds, props.onShowPluginInfo, props.callbacks]);
|
||||||
|
|
||||||
const renderResultsCount = () => {
|
const onClearSearch = useCallback(() => {
|
||||||
if (!searchQuery.length) return null;
|
setSearchQuery('');
|
||||||
|
}, [setSearchQuery]);
|
||||||
|
|
||||||
return <Text style={styles.resultsCounter} variant='labelLarge'>
|
const renderSearchButton = () => {
|
||||||
{_('Results (%d):', searchResults.length)}
|
if (searchQuery) {
|
||||||
</Text>;
|
return <TextInput.Icon onPress={onClearSearch} accessibilityLabel={_('Clear search')} icon='close' />;
|
||||||
|
} else {
|
||||||
|
return <TextInput.Icon icon='magnify' aria-hidden={true} importantForAccessibility='no-hide-descendants'/>;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -146,13 +148,13 @@ const PluginSearch: React.FC<Props> = props => {
|
|||||||
<TextInput
|
<TextInput
|
||||||
testID='searchbar'
|
testID='searchbar'
|
||||||
mode='outlined'
|
mode='outlined'
|
||||||
left={<TextInput.Icon icon='magnify' />}
|
right={renderSearchButton()}
|
||||||
placeholder={_('Search plugins')}
|
placeholder={_('Search for plugins...')}
|
||||||
onChangeText={setSearchQuery}
|
onChangeText={setSearchQuery}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
editable={props.repoApiInitialized}
|
editable={props.repoApiInitialized}
|
||||||
/>
|
/>
|
||||||
{renderResultsCount()}
|
<SectionLabel visible={!!searchQuery.length}>{_('Results (%d):', searchResults.length)}</SectionLabel>
|
||||||
<FlatList
|
<FlatList
|
||||||
data={searchResults}
|
data={searchResults}
|
||||||
renderItem={renderResult}
|
renderItem={renderResult}
|
||||||
|
@ -0,0 +1,24 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { ViewStyle } from 'react-native';
|
||||||
|
import { Text } from 'react-native-paper';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: React.ReactNode;
|
||||||
|
visible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const style: ViewStyle = {
|
||||||
|
margin: 12,
|
||||||
|
marginTop: 17,
|
||||||
|
marginBottom: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SectionLabel: React.FC<Props> = props => {
|
||||||
|
if (!props.visible) return null;
|
||||||
|
|
||||||
|
return <Text style={style} variant='labelLarge'>
|
||||||
|
{props.children}
|
||||||
|
</Text>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SectionLabel;
|
@ -8,6 +8,7 @@ import { PaperProvider } from 'react-native-paper';
|
|||||||
import PluginStates from '../PluginStates';
|
import PluginStates from '../PluginStates';
|
||||||
import { AppState } from '../../../../../utils/types';
|
import { AppState } from '../../../../../utils/types';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
import { MenuProvider } from 'react-native-popup-menu';
|
||||||
|
|
||||||
interface WrapperProps {
|
interface WrapperProps {
|
||||||
initialPluginSettings: PluginSettings;
|
initialPluginSettings: PluginSettings;
|
||||||
@ -29,15 +30,17 @@ const PluginStatesWrapper = (props: WrapperProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Provider store={props.store}>
|
<Provider store={props.store}>
|
||||||
<PaperProvider>
|
<MenuProvider>
|
||||||
<PluginStates
|
<PaperProvider>
|
||||||
styles={styles}
|
<PluginStates
|
||||||
themeId={Setting.THEME_LIGHT}
|
styles={styles}
|
||||||
updatePluginStates={updatePluginStates}
|
themeId={Setting.THEME_LIGHT}
|
||||||
pluginSettings={pluginSettings}
|
updatePluginStates={updatePluginStates}
|
||||||
shouldShowBasedOnSearchQuery={shouldShowBasedOnSettingSearchQuery}
|
pluginSettings={pluginSettings}
|
||||||
/>
|
shouldShowBasedOnSearchQuery={shouldShowBasedOnSettingSearchQuery}
|
||||||
</PaperProvider>
|
/>
|
||||||
|
</PaperProvider>
|
||||||
|
</MenuProvider>
|
||||||
</Provider>
|
</Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user