Add ability to favourite virtual devices

Summary:
With a lot of virtual devices it can be confusing to remember which one contains your builds. This allows user to favourite certain ones to avoid this

Changelog: Added ability to favourite emulators / simulators in the launch virtual devices dialog

Reviewed By: mweststrate

Differential Revision: D47724521

fbshipit-source-id: aaec56608ad6ba23634797315f6f9fd77fc8b258
This commit is contained in:
Luke De Feo
2023-07-25 07:28:03 -07:00
committed by Facebook GitHub Bot
parent a0d6c9a1b8
commit d52eeffb86
2 changed files with 235 additions and 62 deletions

View File

@@ -8,12 +8,12 @@
*/ */
import React, {useEffect, useState} from 'react'; import React, {useEffect, useState} from 'react';
import {Modal, Button, message, Alert, Menu, Dropdown} from 'antd'; import {Modal, Button, message, Alert, Menu, Dropdown, Typography} from 'antd';
import { import {
AppleOutlined,
PoweroffOutlined, PoweroffOutlined,
MoreOutlined, MoreOutlined,
AndroidOutlined, HeartOutlined,
HeartFilled,
} from '@ant-design/icons'; } from '@ant-design/icons';
import {Store} from '../../reducers'; import {Store} from '../../reducers';
import {useStore} from '../../utils/useStore'; import {useStore} from '../../utils/useStore';
@@ -22,12 +22,16 @@ import {
Spinner, Spinner,
renderReactRoot, renderReactRoot,
withTrackingScope, withTrackingScope,
useLocalStorageState,
theme,
} from 'flipper-plugin'; } from 'flipper-plugin';
import {Provider} from 'react-redux'; import {Provider} from 'react-redux';
import {IOSDeviceParams} from 'flipper-common'; import {IOSDeviceParams} from 'flipper-common';
import {getRenderHostInstance} from 'flipper-frontend-core'; import {getRenderHostInstance} from 'flipper-frontend-core';
import SettingsSheet from '../../chrome/SettingsSheet'; import SettingsSheet from '../../chrome/SettingsSheet';
import {Link} from '../../ui'; import {Link} from '../../ui';
import {chain, uniq, without} from 'lodash';
import {ReactNode} from 'react-markdown';
const COLD_BOOT = 'cold-boot'; const COLD_BOOT = 'cold-boot';
@@ -90,6 +94,17 @@ export const LaunchEmulatorDialog = withTrackingScope(
const [waitingForAndroid, setWaitingForAndroid] = useState(androidEnabled); const [waitingForAndroid, setWaitingForAndroid] = useState(androidEnabled);
const waitingForResults = waitingForIos || waitingForAndroid; const waitingForResults = waitingForIos || waitingForAndroid;
const [favoriteVirtualDevices, setFavoriteVirtualDevices] =
useLocalStorageState<string[]>('favourite-virtual-devices', []);
const addToFavorites = (deviceName: string) => {
setFavoriteVirtualDevices(uniq([deviceName, ...favoriteVirtualDevices]));
};
const removeFromFavorites = (deviceName: string) => {
setFavoriteVirtualDevices(without(favoriteVirtualDevices, deviceName));
};
useEffect(() => { useEffect(() => {
if (!iosEnabled) { if (!iosEnabled) {
return; return;
@@ -131,70 +146,105 @@ export const LaunchEmulatorDialog = withTrackingScope(
} }
const items = [ const items = [
...(androidEmulators.length > 0 androidEmulators.length > 0 ? (
? [<AndroidOutlined key="android logo" />] <Title key="android-title" name="Android emulators" />
: []), ) : null,
...androidEmulators.map((name) => { ...chain(
const launch = (coldBoot: boolean) => { androidEmulators.map((name) => ({
getRenderHostInstance() name,
.flipperServer.exec('android-launch-emulator', name, coldBoot) isFavorite: favoriteVirtualDevices.includes(name),
.then(onClose) })),
.catch((e) => { )
console.error('Failed to start emulator: ', e); .sortBy((item) => [!item.isFavorite, item.name])
message.error('Failed to start emulator: ' + e); .map(({name, isFavorite}) => {
}); const launch = (coldBoot: boolean) => {
};
const menu = (
<Menu
onClick={({key}) => {
switch (key) {
case COLD_BOOT: {
launch(true);
break;
}
}
}}>
<Menu.Item key={COLD_BOOT} icon={<PoweroffOutlined />}>
Cold Boot
</Menu.Item>
</Menu>
);
return (
<Dropdown.Button
key={name}
overlay={menu}
icon={<MoreOutlined />}
onClick={() => launch(false)}>
{name}
</Dropdown.Button>
);
}),
...(iosEmulators.length > 0 ? [<AppleOutlined key="ios logo" />] : []),
...iosEmulators.map((device) => (
<Button
key={device.udid}
onClick={() =>
getRenderHostInstance() getRenderHostInstance()
.flipperServer.exec('ios-launch-simulator', device.udid) .flipperServer.exec('android-launch-emulator', name, coldBoot)
.catch((e) => {
console.warn('Failed to start simulator: ', e);
message.error('Failed to start simulator: ' + e);
})
.then(onClose) .then(onClose)
}> .catch((e) => {
{device.name} console.error('Failed to start emulator: ', e);
</Button> message.error('Failed to start emulator: ' + e);
)), });
]; };
const menu = (
<Menu
onClick={({key}) => {
switch (key) {
case COLD_BOOT: {
launch(true);
break;
}
}
}}>
<Menu.Item key={COLD_BOOT} icon={<PoweroffOutlined />}>
Cold Boot
</Menu.Item>
</Menu>
);
return (
<VirtualDeviceRow
key={name}
addToFavorites={addToFavorites}
removeFromFavorites={removeFromFavorites}
isFavorite={isFavorite}
name={name}>
<Dropdown.Button
overlay={menu}
icon={<MoreOutlined />}
onClick={() => launch(false)}>
{name}
</Dropdown.Button>
</VirtualDeviceRow>
);
})
.value(),
iosEmulators.length > 0 ? (
<Title key="android-title" name="iOS Simulators" />
) : null,
...chain(iosEmulators)
.map((device) => ({
device,
isFavorite: favoriteVirtualDevices.includes(device.name),
}))
.sortBy((item) => [!item.isFavorite, item.device.name])
.map(({device, isFavorite}) => (
<VirtualDeviceRow
key={device.udid}
addToFavorites={addToFavorites}
removeFromFavorites={removeFromFavorites}
isFavorite={isFavorite}
name={device.name}>
<Button
type="default"
key={device.udid}
style={{width: '100%'}}
onClick={() =>
getRenderHostInstance()
.flipperServer.exec('ios-launch-simulator', device.udid)
.catch((e) => {
console.warn('Failed to start simulator: ', e);
message.error('Failed to start simulator: ' + e);
})
.then(onClose)
}>
{device.name}
</Button>
</VirtualDeviceRow>
))
.value(),
].filter((item) => item != null);
const loadingSpinner = ( const loadingSpinner = (
<> <>
{waitingForResults && <Spinner />} {waitingForResults && <Spinner key="spinner" />}
{!waitingForResults && items.length === 0 && ( {!waitingForResults && items.length === 0 && (
<Alert <Alert
key=" alert-nodevices"
message={ message={
<> <>
No emulators available. <br /> No virtual devices available.
<br />
<Link href="http://fbflipper.com/docs/getting-started/troubleshooting/general/#i-see-no-emulators-available"> <Link href="http://fbflipper.com/docs/getting-started/troubleshooting/general/#i-see-no-emulators-available">
Learn more Learn more
</Link> </Link>
@@ -210,10 +260,10 @@ export const LaunchEmulatorDialog = withTrackingScope(
visible visible
centered centered
onCancel={onClose} onCancel={onClose}
title="Launch Emulator" title="Launch Virtual device"
footer={null} footer={null}
bodyStyle={{maxHeight: 400, height: 400, overflow: 'auto'}}> bodyStyle={{maxHeight: 400, height: 400, overflow: 'auto'}}>
<Layout.Container gap> <Layout.Container gap="medium">
{items.length ? items : <></>} {items.length ? items : <></>}
{loadingSpinner} {loadingSpinner}
</Layout.Container> </Layout.Container>
@@ -221,3 +271,52 @@ export const LaunchEmulatorDialog = withTrackingScope(
); );
}, },
); );
const FavIconStyle = {fontSize: 16, color: theme.primaryColor};
function Title({name}: {name: string}) {
return (
<Typography.Title style={{padding: 4}} level={3} key={name}>
{name}
</Typography.Title>
);
}
function VirtualDeviceRow({
isFavorite,
name,
addToFavorites,
removeFromFavorites,
children,
}: {
children: ReactNode;
isFavorite: boolean;
name: string;
addToFavorites: (deviceName: string) => void;
removeFromFavorites: (deviceName: string) => void;
}) {
return (
<Layout.Horizontal gap="medium" center grow key={name}>
{children}
{isFavorite ? (
<HeartFilled
testing-id="favorite"
aria-label="favorite"
onClick={() => {
removeFromFavorites(name);
}}
style={FavIconStyle}
/>
) : (
<HeartOutlined
testing-id="not-favorite"
aria-label="not-favorite"
onClick={() => {
addToFavorites(name);
}}
style={FavIconStyle}
/>
)}
</Layout.Horizontal>
);
}

View File

@@ -16,6 +16,7 @@ import {LaunchEmulatorDialog} from '../LaunchEmulator';
import {createRootReducer} from '../../../reducers'; import {createRootReducer} from '../../../reducers';
import {sleep} from 'flipper-plugin'; import {sleep} from 'flipper-plugin';
import {getRenderHostInstance} from 'flipper-frontend-core'; import {getRenderHostInstance} from 'flipper-frontend-core';
import {last} from 'lodash';
test('Can render and launch android apps - no emulators', async () => { test('Can render and launch android apps - no emulators', async () => {
const store = createStore(createRootReducer()); const store = createStore(createRootReducer());
@@ -43,11 +44,12 @@ test('Can render and launch android apps - no emulators', async () => {
</Provider>, </Provider>,
); );
expect(await renderer.findByText(/No emulators/)).toMatchInlineSnapshot(` expect(await renderer.findByText(/No virtual devices/))
.toMatchInlineSnapshot(`
<div <div
class="ant-alert-message" class="ant-alert-message"
> >
No emulators available. No virtual devices available.
<br /> <br />
<a <a
class="ant-typography" class="ant-typography"
@@ -126,6 +128,12 @@ test('Can render and launch android apps', async () => {
expect(await renderer.findAllByText(/emulator/)).toMatchInlineSnapshot(` expect(await renderer.findAllByText(/emulator/)).toMatchInlineSnapshot(`
[ [
<h3
class="ant-typography"
style="padding: 4px;"
>
Android emulators
</h3>,
<span> <span>
emulator1 emulator1
</span>, </span>,
@@ -152,3 +160,69 @@ test('Can render and launch android apps', async () => {
] ]
`); `);
}); });
test('Favouriting a virtual device brings it to the top', async () => {
const store = createStore(createRootReducer());
const exec = jest.fn().mockImplementation(async (cmd) => {
if (cmd === 'android-get-emulators') {
return ['emulator1', 'emulator2'];
}
});
getRenderHostInstance().flipperServer.exec = exec;
store.dispatch({
type: 'UPDATE_SETTINGS',
payload: {
...store.getState().settingsState,
enableAndroid: true,
},
});
const onClose = jest.fn();
const renderer = render(
<Provider store={store}>
<LaunchEmulatorDialog onClose={onClose} />
</Provider>,
);
await sleep(1); // give exec time to resolve
expect(await renderer.findAllByText(/emulator/)).toMatchInlineSnapshot(`
[
<h3
class="ant-typography"
style="padding: 4px;"
>
Android emulators
</h3>,
<span>
emulator1
</span>,
<span>
emulator2
</span>,
]
`);
const lastFavourite = last(renderer.getAllByLabelText('not-favorite'))!;
fireEvent.click(lastFavourite);
expect(await renderer.findAllByText(/emulator/)).toMatchInlineSnapshot(`
[
<h3
class="ant-typography"
style="padding: 4px;"
>
Android emulators
</h3>,
<span>
emulator2
</span>,
<span>
emulator1
</span>,
]
`);
});