Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(rename): Rename Nodes #841

Merged
merged 24 commits into from
Jun 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
c4e74c2
feat(rename): add rename button
Jem256 Mar 1, 2024
1124206
feat(rename): add rename node method
Jem256 Mar 4, 2024
7aca1a9
feat(rename): rename node method to network store
Jem256 Mar 5, 2024
908fb54
feat(rename): update designer store
Jem256 Mar 6, 2024
a06e24c
feat(rename): fix ports issue
Jem256 Mar 10, 2024
09fc9d7
feat(rename): stop node before rename
Jem256 Mar 11, 2024
2fceec2
feat(rename): fix save issue
Jem256 Mar 13, 2024
44790a2
feat(rename): remove side button and add a button to actions tab
Jem256 Mar 15, 2024
91c91d6
feat(rename): add to rightclick menu
Jem256 Mar 18, 2024
05cfa17
feat(rename): rename bitcoin node
Jem256 Mar 18, 2024
b530180
feat(rename): rename taproot node
Jem256 Mar 18, 2024
16584c9
feat(rename): rename lnd host directory
Jem256 Apr 3, 2024
d805f8f
feat(rename): update file paths
Jem256 Apr 5, 2024
793a17a
feat(rename): update bitcoin node links and fix tap paths
Jem256 Apr 24, 2024
ded5e4a
Revert "feat(rename): add rename button"
Jem256 Apr 29, 2024
a0610ab
feat(rename node): rename node modal
Jem256 Apr 30, 2024
4c08139
feat(rename node): add update dir function to docker service
Jem256 May 2, 2024
dd0d69f
feat(rename node): update node name
Jem256 May 2, 2024
ae5a41d
feat(rename node): dockerService, status update, file util
Jem256 May 11, 2024
8555a19
feat(rename node): modal title to include name
Jem256 May 11, 2024
d041e9a
feat(rename node): address review comments
Jem256 Jun 4, 2024
645158d
feat(rename node): node name validation
Jem256 Jun 9, 2024
e04ec9d
feat(rename node): update util tests
Jem256 Jun 10, 2024
027079e
test(network): add unit tests for renaming nodes
jamaljsr Jun 19, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions src/components/common/RenameNodeButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import React from 'react';
import { FormOutlined } from '@ant-design/icons';
import { Button, Form } from 'antd';
import { usePrefixedTranslation } from 'hooks';
import { AnyNode } from 'shared/types';
import { useStoreActions } from 'store';

interface Props {
node: AnyNode;
type?: 'button' | 'menu';
}

const RenameNodeButton: React.FC<Props> = ({ node, type }) => {
const { l } = usePrefixedTranslation('cmps.common.RenameNodeButton');
const { showRenameNode } = useStoreActions(s => s.modals);
const handleClick = () => {
showRenameNode({
oldNodeName: node.name,
});
};

// render a menu item inside of the NodeContextMenu
if (type === 'menu') {
return (
<div onClick={handleClick}>
<FormOutlined />
<span>{l('menu')}</span>
</div>
);
}

return (
<Form.Item label={l('title')} colon={false}>
<Button icon={<FormOutlined />} block onClick={handleClick}>
{l('btn')}
</Button>
</Form.Item>
);
};

export default RenameNodeButton;
179 changes: 179 additions & 0 deletions src/components/common/RenameNodeModal.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import React from 'react';
import { fireEvent, waitFor } from '@testing-library/react';
import { Status } from 'shared/types';
import { BitcoindLibrary } from 'types';
import * as asyncUtil from 'utils/async';
import { initChartFromNetwork } from 'utils/chart';
import { defaultRepoState } from 'utils/constants';
import { createNetwork, createTapdNetworkNode } from 'utils/network';
import {
injections,
lightningServiceMock,
renderWithProviders,
tapServiceMock,
testManagedImages,
testNodeDocker,
} from 'utils/tests';
import RenameNodeModal from './RenameNodeModal';

jest.mock('utils/async');
const asyncUtilMock = asyncUtil as jest.Mocked<typeof asyncUtil>;

const dockerServiceMock = injections.dockerService as jest.Mocked<
typeof injections.dockerService
>;
const bitcoindServiceMock = injections.bitcoindService as jest.Mocked<BitcoindLibrary>;

describe('RenameNodeModal', () => {
let unmount: () => void;

const renderComponent = async (status?: Status, nodeName = 'alice') => {
const network = createNetwork({
id: 1,
name: 'test network',
lndNodes: 2,
clightningNodes: 1,
eclairNodes: 1,
bitcoindNodes: 3,
status,
repoState: defaultRepoState,
managedImages: testManagedImages,
customImages: [],
});
network.nodes.tap.push(
createTapdNetworkNode(
network,
defaultRepoState.images.tapd.latest,
defaultRepoState.images.tapd.compatibility,
testNodeDocker,
status,
),
);
const initialState = {
network: {
networks: [network],
},
designer: {
activeId: network.id,
allCharts: {
[network.id]: initChartFromNetwork(network),
},
},
modals: {
renameNode: {
visible: true,
oldNodeName: nodeName,
},
},
};
const cmp = <RenameNodeModal network={network} />;
const result = renderWithProviders(cmp, { initialState });
unmount = result.unmount;
return {
...result,
network,
};
};

afterEach(() => unmount());

it('should render labels', async () => {
const { getByText } = await renderComponent();
expect(getByText('Rename Node alice')).toBeInTheDocument();
expect(getByText('New Node Name')).toBeInTheDocument();
});

it('should render form inputs', async () => {
const { getByLabelText } = await renderComponent();
expect(getByLabelText('New Node Name')).toBeInTheDocument();
});

it('should render button', async () => {
const { getByText } = await renderComponent();
const btn = getByText('Save');
expect(btn).toBeInTheDocument();
expect(btn.parentElement).toBeInstanceOf(HTMLButtonElement);
});

it('should render a alert for started nodes', async () => {
const { getByText } = await renderComponent(Status.Started);
expect(
getByText('The network will be restarted to perform this operation'),
).toBeInTheDocument();
});

it('should hide modal when cancel is clicked', async () => {
const { getByText, queryByText } = await renderComponent();
const btn = getByText('Cancel');
expect(btn).toBeInTheDocument();
expect(btn.parentElement).toBeInstanceOf(HTMLButtonElement);
fireEvent.click(getByText('Cancel'));
expect(queryByText('Cancel')).not.toBeInTheDocument();
});

it('should do nothing if an invalid node name is used', async () => {
const { getByText } = await renderComponent(Status.Stopped, 'invalid');
fireEvent.click(getByText('Save'));
await waitFor(() => {
expect(getByText('Save')).toBeInTheDocument();
});
});

describe('with form submitted', () => {
it('should update the Lightning node name', async () => {
const { getByText, getByLabelText, store } = await renderComponent();
fireEvent.change(getByLabelText('New Node Name'), { target: { value: 'test' } });
fireEvent.click(getByText('Save'));
await waitFor(() => {
expect(store.getState().modals.advancedOptions.visible).toBe(false);
expect(store.getState().network.networks[0].nodes.lightning[0].name).toBe('test');
});
expect(getByText('The node alice has been renamed to test')).toBeInTheDocument();
});

it('should update the TAP node name', async () => {
const { getByText, getByLabelText, store } = await renderComponent(
Status.Stopped,
'alice-tap',
);
fireEvent.change(getByLabelText('New Node Name'), { target: { value: 'test' } });
fireEvent.click(getByText('Save'));
await waitFor(() => {
expect(store.getState().modals.advancedOptions.visible).toBe(false);
expect(store.getState().network.networks[0].nodes.tap[0].name).toBe('test');
});
expect(
getByText('The node alice-tap has been renamed to test'),
).toBeInTheDocument();
});

it('should update the started Backend node name', async () => {
asyncUtilMock.delay.mockResolvedValue(Promise.resolve());
lightningServiceMock.waitUntilOnline.mockResolvedValue();
bitcoindServiceMock.waitUntilOnline.mockResolvedValue();
tapServiceMock.waitUntilOnline.mockResolvedValue();
const { getByText, getByLabelText, store } = await renderComponent(
Status.Started,
'backend1',
);
fireEvent.change(getByLabelText('New Node Name'), { target: { value: 'test' } });
fireEvent.click(getByText('Save'));
await waitFor(() => {
expect(store.getState().modals.advancedOptions.visible).toBe(false);
expect(store.getState().network.networks[0].nodes.bitcoin[0].name).toBe('test');
});
expect(getByText('The node backend1 has been renamed to test')).toBeInTheDocument();
});

it('should display an error if it fails', async () => {
dockerServiceMock.saveComposeFile.mockRejectedValue(new Error('test-error'));
const { getByText, getByLabelText } = await renderComponent();
fireEvent.change(getByLabelText('New Node Name'), { target: { value: 'test' } });
fireEvent.click(getByText('Save'));
await waitFor(() => {
expect(getByText('Unable to rename the node')).toBeInTheDocument();
expect(getByText('test-error')).toBeInTheDocument();
});
});
});
});
80 changes: 80 additions & 0 deletions src/components/common/RenameNodeModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import React from 'react';
import { useAsyncCallback } from 'react-async-hook';
import { Alert, Form, Input, Modal } from 'antd';
import { usePrefixedTranslation } from 'hooks';
import { AnyNode, Status } from 'shared/types';
import { useStoreActions, useStoreState } from 'store';
import { Network } from 'types';

interface Props {
network: Network;
}

const RenameNodeModal: React.FC<Props> = ({ network }) => {
const { l } = usePrefixedTranslation('cmps.common.RenameNodeModal');

const [form] = Form.useForm();
const { visible, oldNodeName } = useStoreState(s => s.modals.renameNode);
const { hideRenameNode } = useStoreActions(s => s.modals);
const { renameNode } = useStoreActions(s => s.network);
const { notify } = useStoreActions(s => s.app);

const updateAsync = useAsyncCallback(async (node: AnyNode, newName: string) => {
try {
const name = node.name;
await renameNode({ node, newName });
hideRenameNode();
notify({ message: l('success', { name, newName }) });
} catch (error: any) {
notify({ message: l('error'), error });
}
});

const { lightning, bitcoin, tap } = network.nodes;
const nodes: AnyNode[] = [...lightning, ...bitcoin, ...tap];
const node = nodes.find(n => n.name === oldNodeName);
const handleSubmit = (values: any) => {
if (!node) return;
updateAsync.execute(node, values.newNodeName);
};

return (
<Modal
title={l('title', { name: oldNodeName })}
open={visible}
onCancel={() => hideRenameNode()}
destroyOnClose
cancelText={l('cancelBtn')}
okText={l('okBtn')}
okButtonProps={{
loading: updateAsync.loading,
}}
onOk={form.submit}
>
<Form
form={form}
layout="vertical"
hideRequiredMark
colon={false}
initialValues={{ newNodeName: oldNodeName }}
onFinish={handleSubmit}
>
{node?.status === Status.Started ? (
<Alert type="warning" message={l('alert')} />
) : null}
<Form.Item
name="newNodeName"
label={l('label')}
rules={[
{ required: true, message: l('cmps.forms.required') },
{ pattern: /^[a-zA-Z0-9_-]+$/, message: l('invalidName') },
]}
>
<Input placeholder="Enter node name" disabled={updateAsync.loading} />
</Form.Item>
</Form>
</Modal>
);
};

export default RenameNodeModal;
2 changes: 2 additions & 0 deletions src/components/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ export { default as RestartNode } from './RestartNode';
export { default as StatusBadge } from './StatusBadge';
export { default as StatusTag } from './StatusTag';
export { default as RemoveNode } from './RemoveNode';
export { default as RenameNodeModal } from './RenameNodeModal';
export { default as RenameNodeButton } from './RenameNodeButton';
10 changes: 10 additions & 0 deletions src/components/designer/NetworkDesigner.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,16 @@ describe('NetworkDesigner Component', () => {
fireEvent.click(getByText('Cancel'));
});

it('should display the Rename Node modal', async () => {
const { getByText, findByText, store } = renderComponent();
expect(await findByText('backend1')).toBeInTheDocument();
act(() => {
store.getActions().modals.showRenameNode({ oldNodeName: 'alice' });
});
expect(await findByText('Rename Node alice')).toBeInTheDocument();
fireEvent.click(getByText('Cancel'));
});

it('should remove a node from the network', async () => {
const { getByText, findByText, queryByText, store } = renderComponent();
// add a new LN node that doesn't have a tap node connected
Expand Down
4 changes: 3 additions & 1 deletion src/components/designer/NetworkDesigner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { useDebounce } from 'hooks';
import { useTheme } from 'hooks/useTheme';
import { useStoreActions, useStoreState } from 'store';
import { Network } from 'types';
import { Loader } from 'components/common';
import { Loader, RenameNodeModal } from 'components/common';
import AdvancedOptionsModal from 'components/common/AdvancedOptionsModal';
import SendOnChainModal from './bitcoind/actions/SendOnChainModal';
import { CanvasOuterDark, Link, NodeInner, Port, Ports } from './custom';
Expand Down Expand Up @@ -61,6 +61,7 @@ const NetworkDesigner: React.FC<Props> = ({ network, updateStateDelay = 3000 })
sendOnChain,
advancedOptions,
changeTapBackend,
renameNode,
} = useStoreState(s => s.modals);

const { save } = useStoreActions(s => s.network);
Expand Down Expand Up @@ -108,6 +109,7 @@ const NetworkDesigner: React.FC<Props> = ({ network, updateStateDelay = 3000 })
{newAddress.visible && <NewAddressModal network={network} />}
{changeTapBackend.visible && <ChangeTapBackendModal network={network} />}
{sendAsset.visible && <SendAssetModal network={network} />}
{renameNode.visible && <RenameNodeModal network={network} />}
</Styled.Designer>
);
};
Expand Down
8 changes: 7 additions & 1 deletion src/components/designer/NodeContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import { INode } from '@mrblenny/react-flow-chart';
import { Dropdown, MenuProps } from 'antd';
import { BitcoinNode, LightningNode, Status, TapNode } from 'shared/types';
import { useStoreState } from 'store';
import { AdvancedOptionsButton, RemoveNode, RestartNode } from 'components/common';
import {
AdvancedOptionsButton,
RemoveNode,
RestartNode,
RenameNodeButton,
} from 'components/common';
import { ViewLogsButton } from 'components/dockerLogs';
import { OpenTerminalButton } from 'components/terminal';
import SendOnChainButton from './bitcoind/actions/SendOnChainButton';
Expand Down Expand Up @@ -102,6 +107,7 @@ const NodeContextMenu: React.FC<Props> = ({ node: { id }, children }) => {
<ViewLogsButton type="menu" node={node} />,
[Status.Starting, Status.Started, Status.Error].includes(node.status),
),
addItemIf('rename', <RenameNodeButton type="menu" node={node} />),
addItemIf('options', <AdvancedOptionsButton type="menu" node={node} />),
addItemIf('remove', <RemoveNode type="menu" node={node} />),
);
Expand Down
8 changes: 7 additions & 1 deletion src/components/designer/bitcoind/ActionsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import React from 'react';
import styled from '@emotion/styled';
import { Form } from 'antd';
import { BitcoinNode, Status } from 'shared/types';
import { AdvancedOptionsButton, RemoveNode, RestartNode } from 'components/common';
import {
AdvancedOptionsButton,
RemoveNode,
RestartNode,
RenameNodeButton,
} from 'components/common';
import { ViewLogsButton } from 'components/dockerLogs';
import { OpenTerminalButton } from 'components/terminal';
import MineBlocksInput from './actions/MineBlocksInput';
Expand Down Expand Up @@ -31,6 +36,7 @@ const ActionsTab: React.FC<Props> = ({ node }) => {
</>
)}
<RestartNode node={node} />
<RenameNodeButton node={node} />
<AdvancedOptionsButton node={node} />
<RemoveNode node={node} />
</Form>
Expand Down
Loading
Loading