diff --git a/TODO.md b/TODO.md index dbabf03..5f7bf16 100644 --- a/TODO.md +++ b/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 diff --git a/src/__mocks__/fs-extra.js b/src/__mocks__/fs-extra.js new file mode 100644 index 0000000..b32444b --- /dev/null +++ b/src/__mocks__/fs-extra.js @@ -0,0 +1,6 @@ +module.exports = { + outputFile: jest.fn(), + pathExists: jest.fn(), + readFile: jest.fn(), + remove: jest.fn(), +}; diff --git a/src/components/network/NetworkActions.spec.tsx b/src/components/network/NetworkActions.spec.tsx index 07fba09..8affb7a 100644 --- a/src/components/network/NetworkActions.spec.tsx +++ b/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(); diff --git a/src/components/network/NetworkActions.tsx b/src/components/network/NetworkActions.tsx index 5f12db6..a64ae5d 100644 --- a/src/components/network/NetworkActions.tsx +++ b/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 = ({ network, onClick, onRenameClick }) => { +const NetworkActions: React.FC = ({ + network, + onClick, + onRenameClick, + onDeleteClick, +}) => { const { l } = usePrefixedTranslation('cmps.network.NetworkActions'); const { status, nodes } = network; @@ -75,11 +81,11 @@ const NetworkActions: React.FC = ({ network, onClick, onRenameClick }) => const menu = ( - + {l('menuRename')} - + {l('menuDelete')} diff --git a/src/components/network/NetworkView.spec.tsx b/src/components/network/NetworkView.spec.tsx index 5a8aa6f..f73553a 100644 --- a/src/components/network/NetworkView.spec.tsx +++ b/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; const lndServiceMock = injections.lndService as jest.Mocked; 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(() => { - expect(store.getState().network.networkById(1).name).toBe('new network name'); - }); + 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(() => { - expect(getByText('Failed to rename the network')).toBeInTheDocument(); - }); + 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; }); }); }); diff --git a/src/components/network/NetworkView.tsx b/src/components/network/NetworkView.tsx index 14b5cbd..cace969 100644 --- a/src/components/network/NetworkView.tsx +++ b/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> = ({ 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> = ({ 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> = ({ match }) => { setEditing(true); setEditingName(network.name); }} + onDeleteClick={() => removeAsync.execute(network.id, network.name)} /> } /> diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 0b7c55a..e7743ed 100644 --- a/src/i18n/locales/en-US.json +++ b/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", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 5a210c7..ccd7aba 100644 --- a/src/i18n/locales/es.json +++ b/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", diff --git a/src/store/models/designer.spec.ts b/src/store/models/designer.spec.ts index 57869d7..8d5cd7f 100644 --- a/src/store/models/designer.spec.ts +++ b/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 => diff --git a/src/store/models/designer.ts b/src/store/models/designer.ts index 779bb06..69b8ee4 100644 --- a/src/store/models/designer.ts +++ b/src/store/models/designer.ts @@ -27,6 +27,7 @@ export interface DesignerModel { setActiveId: Action; setAllCharts: Action>; addChart: Action; + removeChart: Action; redrawChart: Action; syncChart: Thunk; onNetworkSetStatus: ActionOn; @@ -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 diff --git a/src/store/models/network.spec.ts b/src/store/models/network.spec.ts index e0af199..eaa78a2 100644 --- a/src/store/models/network.spec.ts +++ b/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(); + }); }); }); diff --git a/src/store/models/network.ts b/src/store/models/network.ts index daba038..840aa80 100644 --- a/src/store/models/network.ts +++ b/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 >; + remove: Thunk>; } 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; diff --git a/src/utils/files.ts b/src/utils/files.ts index 8f41805..a1a43ff 100644 --- a/src/utils/files.ts +++ b/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 => export const readHex = async (filePath: string): Promise => (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 => 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