Browse Source

feat(network): implement deleting a network

feat/auto-update
jamaljsr 5 years ago
parent
commit
d1bca87fc1
  1. 2
      TODO.md
  2. 6
      src/__mocks__/fs-extra.js
  3. 23
      src/components/network/NetworkActions.spec.tsx
  4. 12
      src/components/network/NetworkActions.tsx
  5. 71
      src/components/network/NetworkView.spec.tsx
  6. 24
      src/components/network/NetworkView.tsx
  7. 6
      src/i18n/locales/en-US.json
  8. 6
      src/i18n/locales/es.json
  9. 28
      src/store/models/designer.spec.ts
  10. 7
      src/store/models/designer.ts
  11. 5
      src/store/models/network.spec.ts
  12. 15
      src/store/models/network.ts
  13. 8
      src/utils/files.ts

2
TODO.md

@ -2,8 +2,6 @@
Small Stuff
- display blockheight and mine button in network header
- implement delete network
- close channel
- implement real-time channel updates from LND via GRPC streams
- implement option to auto-mine every X minutes

6
src/__mocks__/fs-extra.js

@ -0,0 +1,6 @@
module.exports = {
outputFile: jest.fn(),
pathExists: jest.fn(),
readFile: jest.fn(),
remove: jest.fn(),
};

23
src/components/network/NetworkActions.spec.tsx

@ -7,6 +7,7 @@ import NetworkActions from './NetworkActions';
describe('NetworkActions Component', () => {
const handleClick = jest.fn();
const handleRenameClick = jest.fn();
const handleDeleteClick = jest.fn();
const renderComponent = (status: Status) => {
const network = getNetwork(1, 'test network', status);
@ -26,11 +27,15 @@ describe('NetworkActions Component', () => {
network={network}
onClick={handleClick}
onRenameClick={handleRenameClick}
onDeleteClick={handleDeleteClick}
/>,
{ initialState },
);
};
beforeEach(jest.useFakeTimers);
afterEach(jest.useRealTimers);
it('should render the Starting status', () => {
const { getByText } = renderComponent(Status.Starting);
const primaryBtn = getByText('Starting');
@ -73,6 +78,24 @@ describe('NetworkActions Component', () => {
expect(handleClick).toBeCalled();
});
it('should call onRenameClick when rename menu item clicked', async () => {
const { getByText, getByLabelText } = renderComponent(Status.Stopped);
fireEvent.mouseOver(getByLabelText('icon: more'));
await wait(() => jest.runOnlyPendingTimers());
fireEvent.click(getByText('Rename'));
await wait(() => jest.runOnlyPendingTimers());
expect(handleRenameClick).toBeCalled();
});
it('should call onDeleteClick when rename menu item clicked', async () => {
const { getByText, getByLabelText } = renderComponent(Status.Stopped);
fireEvent.mouseOver(getByLabelText('icon: more'));
await wait(() => jest.runOnlyPendingTimers());
fireEvent.click(getByText('Delete'));
await wait(() => jest.runOnlyPendingTimers());
expect(handleDeleteClick).toBeCalled();
});
it('should display the current block height', () => {
const { getByText } = renderComponent(Status.Started);
expect(getByText('height: 10')).toBeInTheDocument();

12
src/components/network/NetworkActions.tsx

@ -17,6 +17,7 @@ interface Props {
network: Network;
onClick: () => void;
onRenameClick: () => void;
onDeleteClick: () => void;
}
const config: {
@ -53,7 +54,12 @@ const config: {
},
};
const NetworkActions: React.FC<Props> = ({ network, onClick, onRenameClick }) => {
const NetworkActions: React.FC<Props> = ({
network,
onClick,
onRenameClick,
onDeleteClick,
}) => {
const { l } = usePrefixedTranslation('cmps.network.NetworkActions');
const { status, nodes } = network;
@ -75,11 +81,11 @@ const NetworkActions: React.FC<Props> = ({ network, onClick, onRenameClick }) =>
const menu = (
<Menu theme="dark">
<Menu.Item key="1" onClick={onRenameClick}>
<Menu.Item key="rename" onClick={onRenameClick}>
<Icon type="form" />
{l('menuRename')}
</Menu.Item>
<Menu.Item key="2">
<Menu.Item key="delete" onClick={onDeleteClick}>
<Icon type="close" />
{l('menuDelete')}
</Menu.Item>

71
src/components/network/NetworkView.spec.tsx

@ -1,11 +1,13 @@
import React from 'react';
import { fireEvent, wait } from '@testing-library/dom';
import fsExtra from 'fs-extra';
import { fireEvent, wait, waitForElement } from '@testing-library/dom';
import { createMemoryHistory } from 'history';
import { Status } from 'types';
import { initChartFromNetwork } from 'utils/chart';
import { getNetwork, injections, renderWithProviders } from 'utils/tests';
import NetworkView from './NetworkView';
const fsMock = fsExtra as jest.Mocked<typeof fsExtra>;
const lndServiceMock = injections.lndService as jest.Mocked<typeof injections.lndService>;
const bitcoindServiceMock = injections.bitcoindService as jest.Mocked<
typeof injections.bitcoindService
@ -135,24 +137,79 @@ describe('NetworkView Component', () => {
const input = await findByDisplayValue('test network');
fireEvent.change(input, { target: { value: 'new network name' } });
fireEvent.click(getByText('Save'));
await wait(() => {
await wait(() => jest.runOnlyPendingTimers());
expect(store.getState().network.networkById(1).name).toBe('new network name');
});
});
it('should display an error if renaming fails', async () => {
const { getByLabelText, getByText, findByDisplayValue, store } = renderComponent(
'1',
);
const { getByLabelText, getByText, findByDisplayValue } = renderComponent('1');
fireEvent.mouseOver(getByLabelText('icon: more'));
await wait(() => jest.runOnlyPendingTimers());
fireEvent.click(getByText('Rename'));
const input = await findByDisplayValue('test network');
fireEvent.change(input, { target: { value: '' } });
fireEvent.click(getByText('Save'));
await wait(() => {
await wait(() => jest.runOnlyPendingTimers());
expect(getByText('Failed to rename the network')).toBeInTheDocument();
});
});
describe('delete network', () => {
beforeEach(jest.useFakeTimers);
afterEach(jest.useRealTimers);
it('should show the confirm modal', async () => {
const { getByLabelText, getByText } = renderComponent('1');
fireEvent.mouseOver(getByLabelText('icon: more'));
await wait(() => jest.runOnlyPendingTimers());
fireEvent.click(getByText('Delete'));
await wait(() => jest.runOnlyPendingTimers());
expect(
getByText('Are you sure you want to delete this network?'),
).toBeInTheDocument();
expect(getByText('Yes')).toBeInTheDocument();
expect(getByText('Cancel')).toBeInTheDocument();
});
it('should delete the network', async () => {
const { getByLabelText, getByText, getAllByText, store } = renderComponent(
'1',
Status.Started,
);
const path = store.getState().network.networks[0].path;
fireEvent.mouseOver(getByLabelText('icon: more'));
await wait(() => jest.runOnlyPendingTimers());
fireEvent.click(getByText('Delete'));
await wait(() => jest.runOnlyPendingTimers());
// antd creates two modals in the DOM for some silly reason. Need to click one
fireEvent.click(getAllByText('Yes')[0]);
// wait for the error notification to be displayed
await waitForElement(() => getByLabelText('icon: check-circle-o'));
expect(
getByText("The network 'test network' and its data has been deleted!"),
).toBeInTheDocument();
expect(fsMock.remove).toBeCalledWith(expect.stringContaining(path));
});
it('should display an error if the delete fails', async () => {
// antd Modal.confirm logs a console error when onOk fails
// this supresses those errors from being displayed in test runs
const oldConsoleErr = console.error;
console.error = () => {};
fsMock.remove = jest.fn().mockRejectedValue(new Error('cannot delete'));
const { getByLabelText, getByText, getAllByText, store } = renderComponent('1');
fireEvent.mouseOver(getByLabelText('icon: more'));
await wait(() => jest.runOnlyPendingTimers());
fireEvent.click(getByText('Delete'));
await wait(() => jest.runOnlyPendingTimers());
// antd creates two modals in the DOM for some silly reason. Need to click one
fireEvent.click(getAllByText('Yes')[0]);
// wait for the error notification to be displayed
await waitForElement(() => getByLabelText('icon: close-circle-o'));
expect(getByText('cannot delete')).toBeInTheDocument();
expect(store.getState().network.networks).toHaveLength(1);
expect(store.getState().designer.allCharts[1]).toBeDefined();
console.error = oldConsoleErr;
});
});
});

24
src/components/network/NetworkView.tsx

@ -3,7 +3,7 @@ import { useAsyncCallback } from 'react-async-hook';
import { RouteComponentProps } from 'react-router';
import { info } from 'electron-log';
import styled from '@emotion/styled';
import { Alert, Button, Empty, Input, PageHeader } from 'antd';
import { Alert, Button, Empty, Input, Modal, PageHeader } from 'antd';
import { usePrefixedTranslation } from 'hooks';
import { useStoreActions, useStoreState } from 'store';
import { StatusTag } from 'components/common';
@ -56,7 +56,7 @@ const NetworkView: React.FC<RouteComponentProps<MatchParams>> = ({ match }) => {
const [editingName, setEditingName] = useState('');
const { navigateTo, notify } = useStoreActions(s => s.app);
const { toggle, rename } = useStoreActions(s => s.network);
const { toggle, rename, remove } = useStoreActions(s => s.network);
const toggleAsync = useAsyncCallback(toggle);
const renameAsync = useAsyncCallback(async (payload: { id: number; name: string }) => {
try {
@ -66,6 +66,25 @@ const NetworkView: React.FC<RouteComponentProps<MatchParams>> = ({ match }) => {
notify({ message: l('renameError'), error });
}
});
const removeAsync = useAsyncCallback(async (networkId: number, name: string) => {
Modal.confirm({
title: l('deleteTitle'),
content: l('deleteContent'),
okText: l('deleteConfirmBtn'),
okType: 'danger',
cancelText: l('deleteCancelBtn'),
onOk: async () => {
try {
await remove(networkId);
notify({ message: l('deleteSuccess', { name }) });
// no need to navigate away since it will be done by useEffect below
} catch (error) {
notify({ message: l('deleteError'), error });
throw error;
}
},
});
});
useEffect(() => {
if (!network) navigateTo(HOME);
@ -113,6 +132,7 @@ const NetworkView: React.FC<RouteComponentProps<MatchParams>> = ({ match }) => {
setEditing(true);
setEditingName(network.name);
}}
onDeleteClick={() => removeAsync.execute(network.id, network.name)}
/>
}
/>

6
src/i18n/locales/en-US.json

@ -115,6 +115,12 @@
"cmps.network.NetworkView.renameSave": "Save",
"cmps.network.NetworkView.renameCancel": "Cancel",
"cmps.network.NetworkView.renameError": "Failed to rename the network",
"cmps.network.NetworkView.deleteTitle": "Are you sure you want to delete this network?",
"cmps.network.NetworkView.deleteContent": "All of the blockchain data will be permanently removed.",
"cmps.network.NetworkView.deleteConfirmBtn": "Yes",
"cmps.network.NetworkView.deleteCancelBtn": "Cancel",
"cmps.network.NetworkView.deleteSuccess": "The network '{{name}}' and its data has been deleted!",
"cmps.network.NetworkView.deleteError": "Unable to delete the network",
"cmps.network.NewNetwork.title": "Create a new Lightning Network",
"cmps.network.NewNetwork.nameLabel": "Network Name",
"cmps.network.NewNetwork.namePhldr": "My Lightning Simnet",

6
src/i18n/locales/es.json

@ -115,6 +115,12 @@
"cmps.network.NetworkView.renameSave": "Salvar",
"cmps.network.NetworkView.renameCancel": "Cancelar",
"cmps.network.NetworkView.renameError": "Error al cambiar el nombre de la red",
"cmps.network.NetworkView.deleteTitle": "¿Estás seguro de que deseas eliminar esta red?",
"cmps.network.NetworkView.deleteContent": "Todos los datos de blockchain se eliminarán permanentemente.",
"cmps.network.NetworkView.deleteConfirmBtn": "Si",
"cmps.network.NetworkView.deleteCancelBtn": "Cancelar",
"cmps.network.NetworkView.deleteSuccess": "¡La red '{{name}}' y sus datos han sido eliminados!",
"cmps.network.NetworkView.deleteError": "No se puede eliminar la red",
"cmps.network.NewNetwork.title": "Crear una nueva red Lightning",
"cmps.network.NewNetwork.nameLabel": "Nombre de red",
"cmps.network.NewNetwork.namePhldr": "Mi Lightning Simnet",

28
src/store/models/designer.spec.ts

@ -55,9 +55,9 @@ describe('Designer model', () => {
const firstNetwork = () => store.getState().network.networks[0];
const firstChart = () => store.getState().designer.allCharts[firstNetwork().id];
beforeEach(() => {
beforeEach(async () => {
const { addNetwork } = store.getActions().network;
addNetwork({ name: 'test', lndNodes: 2, bitcoindNodes: 1 });
await addNetwork({ name: 'test', lndNodes: 2, bitcoindNodes: 1 });
});
it('should have a chart in state', () => {
@ -73,10 +73,32 @@ describe('Designer model', () => {
store.getActions().designer.setActiveId(firstNetwork().id);
const { activeId, activeChart } = store.getState().designer;
expect(activeId).toBe(firstNetwork().id);
expect(activeChart).not.toBeUndefined();
expect(activeChart).toBeDefined();
expect(Object.keys(activeChart.nodes)).toHaveLength(3);
});
it('should remove the active chart', () => {
store.getActions().designer.setActiveId(firstNetwork().id);
const { removeChart } = store.getActions().designer;
removeChart(firstNetwork().id);
const { activeId, activeChart } = store.getState().designer;
expect(activeId).toBe(-1);
expect(activeChart).toBeUndefined();
});
it('should remove an inactive chart', async () => {
const { addNetwork } = store.getActions().network;
await addNetwork({ name: 'test 2', lndNodes: 2, bitcoindNodes: 1 });
store.getActions().designer.setActiveId(firstNetwork().id);
const { removeChart } = store.getActions().designer;
const idToRemove = store.getState().network.networks[1].id;
removeChart(idToRemove);
const { activeId, activeChart, allCharts } = store.getState().designer;
expect(allCharts[idToRemove]).toBeUndefined();
expect(activeId).toBe(firstNetwork().id);
expect(activeChart).toBeDefined();
});
it('should update the nodes status when the network is updated', () => {
let chart = firstChart();
Object.keys(chart.nodes).forEach(name =>

7
src/store/models/designer.ts

@ -27,6 +27,7 @@ export interface DesignerModel {
setActiveId: Action<DesignerModel, number>;
setAllCharts: Action<DesignerModel, Record<number, IChart>>;
addChart: Action<DesignerModel, { id: number; chart: IChart }>;
removeChart: Action<DesignerModel, number>;
redrawChart: Action<DesignerModel>;
syncChart: Thunk<DesignerModel, Network, StoreInjections, RootModel>;
onNetworkSetStatus: ActionOn<DesignerModel, RootModel>;
@ -71,6 +72,12 @@ const designerModel: DesignerModel = {
addChart: action((state, { id, chart }) => {
state.allCharts[id] = chart;
}),
removeChart: action((state, id) => {
delete state.allCharts[id];
if (state.activeId === id) {
state.activeId = -1;
}
}),
redrawChart: action(state => {
// This is a bit of a hack to make a minor tweak to the chart because
// sometimes when updating the state, the chart links do not position

5
src/store/models/network.spec.ts

@ -371,5 +371,10 @@ describe('Network model', () => {
const { rename } = store.getActions().network;
await expect(rename({ id: 10, name: 'asdf' })).rejects.toThrow();
});
it('should fail to remove with an invalid id', async () => {
const { remove } = store.getActions().network;
await expect(remove(10)).rejects.toThrow();
});
});
});

15
src/store/models/network.ts

@ -3,6 +3,7 @@ import { push } from 'connected-react-router';
import { Action, action, Computed, computed, Thunk, thunk } from 'easy-peasy';
import { CommonNode, LndNode, LndVersion, Network, Status, StoreInjections } from 'types';
import { initChartFromNetwork } from 'utils/chart';
import { rm } from 'utils/files';
import { createLndNetworkNode, createNetwork, ensureOpenPorts } from 'utils/network';
import { prefixTranslation } from 'utils/translate';
import { NETWORK_VIEW } from 'components/routing';
@ -53,6 +54,7 @@ export interface NetworkModel {
RootModel,
Promise<void>
>;
remove: Thunk<NetworkModel, number, StoreInjections, RootModel, Promise<void>>;
}
const networkModel: NetworkModel = {
@ -216,6 +218,19 @@ const networkModel: NetworkModel = {
actions.setNetworks(networks);
await actions.save();
}),
remove: thunk(async (actions, networkId, { getState, getStoreActions }) => {
const { networks } = getState();
const network = networks.find(n => n.id === networkId);
if (!network) throw new Error(l('networkByIdErr', { networkId }));
if (network.status !== Status.Stopped) {
await actions.stop(networkId);
}
await rm(network.path);
const newNetworks = networks.filter(n => n.id !== networkId);
actions.setNetworks(newNetworks);
getStoreActions().designer.removeChart(networkId);
await actions.save();
}),
};
export default networkModel;

8
src/utils/files.ts

@ -1,4 +1,4 @@
import { outputFile, pathExists, readFile } from 'fs-extra';
import { outputFile, pathExists, readFile, remove } from 'fs-extra';
import { isAbsolute, join } from 'path';
import { waitFor } from './async';
import { dataPath } from './config';
@ -34,6 +34,12 @@ export const exists = async (filePath: string): Promise<boolean> =>
export const readHex = async (filePath: string): Promise<string> =>
(await readFile(abs(filePath))).toString('hex');
/**
* Deletes a file or directory from disk. The directory can have contents. Like `rm -rf`
* @param path the path to the file or directory. either absolute or relative to the app's data dir
*/
export const rm = async (path: string): Promise<void> => await remove(abs(path));
/**
* Returns a promise that will ressolve when the file exists or the timeout expires
* @param filePath the path to the file. either absolute or relative to the app's data dir

Loading…
Cancel
Save