Basic plugin info view for open-source builds

Summary: Show a new view when a disabled/uninstalled plugin is selected in open-source Flipper build. I'm going to improve it later with additional info from npm packages metadata. For now it just shows status of the plugin and button to install/enable it.

Reviewed By: mweststrate

Differential Revision: D29482937

fbshipit-source-id: 45d207d3f6e846c354184f2b5fd911751d3164b0
This commit is contained in:
Anton Nikolaev
2021-06-30 04:15:30 -07:00
committed by Facebook GitHub Bot
parent 039d3a4a08
commit 7a1b2ecc73
4 changed files with 165 additions and 84 deletions

View File

@@ -0,0 +1,126 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {
DownloadOutlined,
LoadingOutlined,
PlusOutlined,
} from '@ant-design/icons';
import {Alert, Button} from 'antd';
import {
BundledPluginDetails,
DownloadablePluginDetails,
} from 'flipper-plugin-lib';
import React, {useMemo} from 'react';
import {useCallback} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {PluginDefinition} from '../plugin';
import {startPluginDownload} from '../reducers/pluginDownloads';
import {loadPlugin, switchPlugin} from '../reducers/pluginManager';
import {
getActiveClient,
getPluginDownloadStatusMap,
} from '../selectors/connections';
import {Layout} from '../ui';
import {ActivePluginListItem} from '../utils/pluginUtils';
export function PluginActions({
activePlugin,
type,
}: {
activePlugin: ActivePluginListItem;
type: 'link' | 'primary';
}) {
switch (activePlugin.status) {
case 'disabled': {
return <EnableButton plugin={activePlugin.definition} type={type} />;
}
case 'uninstalled': {
return <InstallButton plugin={activePlugin.details} type={type} />;
}
case 'unavailable': {
return type === 'primary' ? (
<UnavailabilityAlert reason={activePlugin.reason} />
) : null;
}
default:
return null;
}
}
function EnableButton({
plugin,
type,
}: {
plugin: PluginDefinition;
type: 'link' | 'primary';
}) {
const dispatch = useDispatch();
const client = useSelector(getActiveClient);
const enableOrDisablePlugin = useCallback(() => {
dispatch(switchPlugin({plugin, selectedApp: client?.query?.app}));
}, [dispatch, plugin, client]);
return (
<Button
type={type}
icon={<PlusOutlined />}
onClick={enableOrDisablePlugin}
style={{flexGrow: type == 'primary' ? 1 : 0}}>
Enable Plugin
</Button>
);
}
function UnavailabilityAlert({reason}: {reason: string}) {
return (
<Layout.Container center>
<Alert message={reason} type="warning" />
</Layout.Container>
);
}
function InstallButton({
plugin,
type = 'primary',
}: {
plugin: DownloadablePluginDetails | BundledPluginDetails;
type: 'link' | 'primary';
}) {
const dispatch = useDispatch();
const installPlugin = useCallback(() => {
if (plugin.isBundled) {
dispatch(loadPlugin({plugin, enable: true, notifyIfFailed: true}));
} else {
dispatch(startPluginDownload({plugin, startedByUser: true}));
}
}, [plugin, dispatch]);
const downloads = useSelector(getPluginDownloadStatusMap);
const downloadStatus = useMemo(
() => downloads.get(plugin.id),
[downloads, plugin],
);
return (
<Button
type={type}
disabled={!!downloadStatus}
icon={
downloadStatus ? (
<LoadingOutlined size={16} />
) : (
<DownloadOutlined size={16} />
)
}
onClick={installPlugin}
style={{
flexGrow: type === 'primary' ? 1 : 0,
}}>
Install Plugin
</Button>
);
}

View File

@@ -7,94 +7,46 @@
* @format
*/
import React, {useCallback, useMemo} from 'react';
import {Label, ToggleButton, SmallText, styled, Layout} from '../../ui';
import {useDispatch, useStore} from '../../utils/useStore';
import {switchPlugin} from '../../reducers/pluginManager';
import {isPluginEnabled} from '../../reducers/connections';
import {theme} from 'flipper-plugin';
import {PluginDefinition} from '../../plugin';
import React from 'react';
import {useSelector} from 'react-redux';
import {getActiveClient} from '../../selectors/connections';
import {getActivePlugin} from '../../selectors/connections';
import {ActivePluginListItem} from '../../utils/pluginUtils';
import {Layout} from '../../ui';
import {CenteredContainer} from '../../sandy-chrome/CenteredContainer';
import {Typography} from 'antd';
import {PluginActions} from '../PluginActions';
import {CoffeeOutlined} from '@ant-design/icons';
const Waiting = styled(Layout.Container)({
width: '100%',
height: '100%',
flexGrow: 1,
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
});
const {Text, Title} = Typography;
export function PluginInfo() {
const pluginId = useStore((state) => state.connections.selectedPlugin);
const enabledPlugins = useStore((state) => state.connections.enabledPlugins);
const enabledDevicePlugins = useStore(
(state) => state.connections.enabledDevicePlugins,
);
const activeClient = useSelector(getActiveClient);
const clientPlugins = useStore((state) => state.plugins.clientPlugins);
const devicePlugins = useStore((state) => state.plugins.devicePlugins);
const selectedClientId = activeClient?.id ?? null;
const selectedApp = activeClient?.query.app ?? null;
const disabledPlugin = useMemo(
() =>
pluginId &&
!isPluginEnabled(
enabledPlugins,
enabledDevicePlugins,
selectedClientId,
pluginId,
)
? clientPlugins.get(pluginId) ?? devicePlugins.get(pluginId)
: undefined,
[
pluginId,
enabledPlugins,
enabledDevicePlugins,
selectedClientId,
clientPlugins,
devicePlugins,
],
);
return disabledPlugin ? (
<PluginEnabler plugin={disabledPlugin} selectedApp={selectedApp} />
) : null;
const activePlugin = useSelector(getActivePlugin);
if (activePlugin) {
return <PluginMarketplace activePlugin={activePlugin} />;
} else {
return null;
}
}
function PluginEnabler({
plugin,
selectedApp,
function PluginMarketplace({
activePlugin,
}: {
plugin: PluginDefinition;
selectedApp: string | null;
activePlugin: ActivePluginListItem;
}) {
const dispatch = useDispatch();
const enablePlugin = useCallback(() => {
dispatch(switchPlugin({plugin, selectedApp: selectedApp ?? undefined}));
}, [dispatch, plugin, selectedApp]);
return (
<Layout.Container grow>
<Waiting>
<Layout.Container>
<Layout.Horizontal>
<Label
style={{
fontSize: '16px',
color: theme.textColorSecondary,
textTransform: 'uppercase',
}}>
{plugin.title}
</Label>
</Layout.Horizontal>
</Layout.Container>
<Layout.Container>
<ToggleButton toggled={false} onClick={enablePlugin} large />
</Layout.Container>
<Layout.Container>
<SmallText>Click to enable this plugin</SmallText>
</Layout.Container>
</Waiting>
</Layout.Container>
<CenteredContainer>
<Layout.Container center gap style={{maxWidth: 350}}>
<CoffeeOutlined style={{fontSize: '24px'}} />
<Title level={4}>
Plugin '{activePlugin.details.title}' is {activePlugin.status}
</Title>
{activePlugin.status === 'unavailable' ? (
<Text style={{textAlign: 'center'}}>{activePlugin.reason}.</Text>
) : null}
<Layout.Horizontal gap>
<PluginActions activePlugin={activePlugin} type="link" />
</Layout.Horizontal>
</Layout.Container>
</CenteredContainer>
);
}

View File

@@ -244,7 +244,7 @@ describe('basic getActiveDevice with metro present', () => {
unavailablePlugins: [
[
gateKeepedPlugin,
"This plugin is only available to members of gatekeeper 'not for you'",
"Plugin 'Gatekeeped Plugin' is only available to members of gatekeeper 'not for you'",
],
[
unsupportedDevicePlugin.details,

View File

@@ -301,18 +301,21 @@ export function computePluginLists(
// process problematic plugins
plugins.disabledPlugins.forEach((plugin) => {
unavailablePlugins.push([plugin, 'Plugin is disabled by configuration']);
unavailablePlugins.push([
plugin,
`Plugin '${plugin.title}' is disabled by configuration`,
]);
});
plugins.gatekeepedPlugins.forEach((plugin) => {
unavailablePlugins.push([
plugin,
`This plugin is only available to members of gatekeeper '${plugin.gatekeeper}'`,
`Plugin '${plugin.title}' is only available to members of gatekeeper '${plugin.gatekeeper}'`,
]);
});
plugins.failedPlugins.forEach(([plugin, error]) => {
unavailablePlugins.push([
plugin,
`Flipper failed to load this plugin: '${error}'`,
`Plugin '${plugin.title}' failed to load: '${error}'`,
]);
});