Browse Source

feat(designer): add right-click menu to links

master
jamaljsr 5 years ago
parent
commit
1f4ab9306e
  1. 75
      src/components/designer/custom/Link.tsx
  2. 55
      src/components/designer/custom/LinkContextMenu.tsx
  3. 4
      src/components/designer/custom/NodeContextMenu.tsx
  4. 16
      src/components/designer/link/Backend.tsx
  5. 39
      src/components/designer/link/ChangeBackendButton.tsx
  6. 27
      src/components/designer/link/Channel.tsx
  7. 54
      src/components/designer/link/CloseChannelButton.tsx
  8. 14
      src/i18n/locales/en-US.json

75
src/components/designer/custom/Link.tsx

@ -2,6 +2,7 @@ import React, { useMemo } from 'react';
import { ILinkDefaultProps, IPosition } from '@mrblenny/react-flow-chart';
import { useTheme } from 'hooks/useTheme';
import { LinkProperties } from 'utils/chart';
import LinkContextMenu from './LinkContextMenu';
export const generateCurvePath = (startPos: IPosition, endPos: IPosition): string => {
const width = Math.abs(startPos.x - endPos.x);
@ -90,43 +91,45 @@ const CustomLink: React.FC<ILinkDefaultProps> = ({
const gradientId = `lg-${link.id}`;
return (
<svg
style={{
overflow: 'visible',
position: 'absolute',
cursor: 'pointer',
left: 0,
right: 0,
}}
>
<defs>
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor={leftColor} />
<stop offset={`${leftStop}%`} stopColor={leftColor} />
<stop offset={`${rightStop}%`} stopColor={rightColor} />
<stop offset="100%" stopColor={rightColor} />
</linearGradient>
</defs>
<circle r="4" cx={startPos.x} cy={startPos.y} fill={`url(#${gradientId})`} />
{/* Main line */}
<path d={points} stroke={`url(#${gradientId})`} strokeWidth="3" fill="none" />
{/* Thick line to make selection easier */}
<path
d={points}
stroke={`url(#${gradientId})`}
strokeWidth="20"
fill="none"
strokeLinecap="round"
strokeOpacity={isHovered || isSelected ? 0.1 : 0}
onMouseEnter={() => onLinkMouseEnter({ config, linkId: link.id })}
onMouseLeave={() => onLinkMouseLeave({ config, linkId: link.id })}
onClick={e => {
onLinkClick({ config, linkId: link.id });
e.stopPropagation();
<LinkContextMenu link={link}>
<svg
style={{
overflow: 'visible',
position: 'absolute',
cursor: 'pointer',
left: 0,
right: 0,
}}
/>
<circle r="4" cx={endPos.x} cy={endPos.y} fill={`url(#${gradientId})`} />
</svg>
>
<defs>
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor={leftColor} />
<stop offset={`${leftStop}%`} stopColor={leftColor} />
<stop offset={`${rightStop}%`} stopColor={rightColor} />
<stop offset="100%" stopColor={rightColor} />
</linearGradient>
</defs>
<circle r="4" cx={startPos.x} cy={startPos.y} fill={`url(#${gradientId})`} />
{/* Main line */}
<path d={points} stroke={`url(#${gradientId})`} strokeWidth="3" fill="none" />
{/* Thick line to make selection easier */}
<path
d={points}
stroke={`url(#${gradientId})`}
strokeWidth="20"
fill="none"
strokeLinecap="round"
strokeOpacity={isHovered || isSelected ? 0.1 : 0}
onMouseEnter={() => onLinkMouseEnter({ config, linkId: link.id })}
onMouseLeave={() => onLinkMouseLeave({ config, linkId: link.id })}
onClick={e => {
onLinkClick({ config, linkId: link.id });
e.stopPropagation();
}}
/>
<circle r="4" cx={endPos.x} cy={endPos.y} fill={`url(#${gradientId})`} />
</svg>
</LinkContextMenu>
);
};

55
src/components/designer/custom/LinkContextMenu.tsx

@ -0,0 +1,55 @@
import React, { ReactNode } from 'react';
import { ILink } from '@mrblenny/react-flow-chart';
import { Dropdown, Menu } from 'antd';
import { useStoreState } from 'store';
import { LinkProperties } from 'utils/chart';
import ChangeBackendButton from '../link/ChangeBackendButton';
import CloseChannelButton from '../link/CloseChannelButton';
interface Props {
link: ILink;
}
const LinkContextMenu: React.FC<Props> = ({ link, children }) => {
const { activeId } = useStoreState(s => s.designer);
const network = useStoreState(s => s.network.networkById(activeId));
const { type, channelPoint } = (link.properties as LinkProperties) || {};
let menuItem: ReactNode;
if (type === 'open-channel') {
// find the lightning node by name
const node = network.nodes.lightning.find(n => n.name === link.from.nodeId);
// don't add a context menu if the node is not valid
if (node) {
menuItem = (
<CloseChannelButton type="menu" node={node} channelPoint={channelPoint} />
);
}
} else if (type === 'backend') {
menuItem = (
<ChangeBackendButton
type="menu"
lnName={link.from.nodeId}
backendName={link.to.nodeId as string}
/>
);
}
// don't add a context menu if there is no menu item
if (!menuItem) return <>{children}</>;
return (
<Dropdown
overlay={
<Menu style={{ width: 200 }}>
<Menu.Item>{menuItem}</Menu.Item>
</Menu>
}
trigger={['contextMenu']}
>
{children}
</Dropdown>
);
};
export default LinkContextMenu;

4
src/components/designer/custom/NodeContextMenu.tsx

@ -69,12 +69,12 @@ const NodeContextMenu: React.FC<Props> = ({ node: { id }, children }) => {
{createItem(
'start',
<RestartNode menuType="start" node={node} />,
[Status.Stopped].includes(node.status),
[Status.Stopped, Status.Error].includes(node.status),
)}
{createItem(
'stop',
<RestartNode menuType="stop" node={node} />,
[Status.Started, Status.Error].includes(node.status),
[Status.Started].includes(node.status),
)}
{createItem('options', <AdvancedOptionsButton type="menu" node={node} />)}
{createItem('remove', <RemoveNode type="menu" node={node} />)}

16
src/components/designer/link/Backend.tsx

@ -1,11 +1,11 @@
import React from 'react';
import { Button } from 'antd';
import { usePrefixedTranslation } from 'hooks';
import { BitcoinNode, LightningNode, Status } from 'shared/types';
import { useStoreActions } from 'store';
import { StatusBadge } from 'components/common';
import DetailsList, { DetailValues } from 'components/common/DetailsList';
import SidebarCard from '../SidebarCard';
import ChangeBackendButton from './ChangeBackendButton';
interface Props {
bitcoinNode: BitcoinNode;
@ -15,14 +15,6 @@ interface Props {
const Backend: React.FC<Props> = ({ bitcoinNode, lightningNode }) => {
const { l } = usePrefixedTranslation('cmps.designer.link.Backend');
const { showChangeBackend } = useStoreActions(s => s.modals);
const handleChangeClick = () => {
showChangeBackend({
lnName: lightningNode.name,
backendName: bitcoinNode.name,
});
};
const backendDetails: DetailValues = [
{ label: l('name'), value: bitcoinNode.name },
{ label: l('implementation'), value: bitcoinNode.implementation },
@ -52,9 +44,7 @@ const Backend: React.FC<Props> = ({ bitcoinNode, lightningNode }) => {
<p>{l('desc')}</p>
<DetailsList title={l('lightningTitle')} details={lightningDetails} />
<DetailsList title={l('bitcoinTitle')} details={backendDetails} />
<Button block onClick={handleChangeClick}>
{l('btnText')}
</Button>
<ChangeBackendButton lnName={lightningNode.name} backendName={bitcoinNode.name} />
</SidebarCard>
);
};

39
src/components/designer/link/ChangeBackendButton.tsx

@ -0,0 +1,39 @@
import React from 'react';
import { ApiOutlined } from '@ant-design/icons';
import { Button } from 'antd';
import { usePrefixedTranslation } from 'hooks';
import { useStoreActions } from 'store';
interface Props {
lnName: string;
backendName: string;
type?: 'button' | 'menu';
}
const ChangeBackendButton: React.FC<Props> = ({ lnName, backendName, type }) => {
const { l } = usePrefixedTranslation('cmps.designer.link.ChangeBackendButton');
const { showChangeBackend } = useStoreActions(s => s.modals);
const handleChangeClick = () => {
showChangeBackend({ lnName, backendName });
};
// render a menu item inside of the NodeContextMenu
if (type === 'menu') {
return (
<span onClick={handleChangeClick}>
<ApiOutlined />
<span>{l('btnText')}</span>
</span>
);
}
return (
<Button block onClick={handleChangeClick}>
<ApiOutlined />
{l('btnText')}
</Button>
);
};
export default ChangeBackendButton;

27
src/components/designer/link/Channel.tsx

@ -1,15 +1,14 @@
import React from 'react';
import { ILink } from '@mrblenny/react-flow-chart';
import { Button, Modal } from 'antd';
import { usePrefixedTranslation } from 'hooks';
import { LightningNode, Status } from 'shared/types';
import { useStoreActions } from 'store';
import { LinkProperties } from 'utils/chart';
import { ellipseInner } from 'utils/strings';
import { format } from 'utils/units';
import { CopyIcon, DetailsList, StatusBadge } from 'components/common';
import { DetailValues } from 'components/common/DetailsList';
import SidebarCard from '../SidebarCard';
import CloseChannelButton from './CloseChannelButton';
interface Props {
link: ILink;
@ -28,26 +27,6 @@ const Channel: React.FC<Props> = ({ link, from, to }) => {
channelPoint,
} = link.properties as LinkProperties;
const { notify } = useStoreActions(s => s.app);
const { closeChannel } = useStoreActions(s => s.lightning);
const showCloseChanModal = () => {
Modal.confirm({
title: l('closeChanModalTitle'),
okText: l('closeChanConfirmBtn'),
okType: 'danger',
cancelText: l('closeChanCancelBtn'),
onOk: async () => {
try {
await closeChannel({ node: from, channelPoint });
notify({ message: l('closeChanSuccess') });
} catch (error) {
notify({ message: l('closeChanError'), error });
throw error;
}
},
});
};
const channelDetails: DetailValues = [
{ label: l('status'), value: status },
{ label: l('capacity'), value: `${format(capacity)} sats` },
@ -86,9 +65,7 @@ const Channel: React.FC<Props> = ({ link, from, to }) => {
<DetailsList title={l('sourceTitle')} details={fromDetails} />
<DetailsList title={l('destinationTitle')} details={toDetails} />
{type === 'open-channel' && (
<Button type="danger" block ghost onClick={showCloseChanModal}>
{l('closeChanBtn')}
</Button>
<CloseChannelButton node={from} channelPoint={channelPoint} />
)}
</SidebarCard>
);

54
src/components/designer/link/CloseChannelButton.tsx

@ -0,0 +1,54 @@
import React from 'react';
import { CloseOutlined } from '@ant-design/icons';
import { Button, Modal } from 'antd';
import { usePrefixedTranslation } from 'hooks';
import { LightningNode } from 'shared/types';
import { useStoreActions } from 'store';
interface Props {
node: LightningNode;
channelPoint: string;
type?: 'button' | 'menu';
}
const CloseChannelButton: React.FC<Props> = ({ node, channelPoint, type }) => {
const { l } = usePrefixedTranslation('cmps.designer.link.CloseChannelButton');
const { notify } = useStoreActions(s => s.app);
const { closeChannel } = useStoreActions(s => s.lightning);
const showCloseChanModal = () => {
Modal.confirm({
title: l('title'),
okText: l('confirmBtn'),
okType: 'danger',
cancelText: l('cancelBtn'),
onOk: async () => {
try {
await closeChannel({ node, channelPoint });
notify({ message: l('success') });
} catch (error) {
notify({ message: l('error'), error });
throw error;
}
},
});
};
// render a menu item inside of the NodeContextMenu
if (type === 'menu') {
return (
<span onClick={showCloseChanModal}>
<CloseOutlined />
<span>{l('btnText')}</span>
</span>
);
}
return (
<Button type="danger" block ghost onClick={showCloseChanModal}>
{l('btnText')}
</Button>
);
};
export default CloseChannelButton;

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

@ -91,13 +91,13 @@
"cmps.designer.default.ImageUpdatesModal.error": "Unable to update",
"cmps.designer.link.Backend.title": "Chain Backend Connection",
"cmps.designer.link.Backend.desc": "Lightning nodes rely on blockchain nodes to send transactions and receive confirmation notifications about transactions they care about. You can change this connection if you'd like to.",
"cmps.designer.link.Backend.btnText": "Change Backend",
"cmps.designer.link.Backend.lightningTitle": "Lightning Node",
"cmps.designer.link.Backend.bitcoinTitle": "Bitcoin Node",
"cmps.designer.link.Backend.name": "Name",
"cmps.designer.link.Backend.implementation": "Implementation",
"cmps.designer.link.Backend.version": "Version",
"cmps.designer.link.Backend.status": "Status",
"cmps.designer.link.ChangeBackendButton.btnText": "Change Backend",
"cmps.designer.link.Channel.title": "Channel Details",
"cmps.designer.link.Channel.sourceTitle": "Source Node",
"cmps.designer.link.Channel.destinationTitle": "Destination Node",
@ -109,12 +109,12 @@
"cmps.designer.link.Channel.name": "Name",
"cmps.designer.link.Channel.implementation": "Implementation",
"cmps.designer.link.Channel.version": "Version",
"cmps.designer.link.Channel.closeChanBtn": "Close Channel",
"cmps.designer.link.Channel.closeChanModalTitle": "Are you sure you want to close this channel?",
"cmps.designer.link.Channel.closeChanConfirmBtn": "Yes",
"cmps.designer.link.Channel.closeChanCancelBtn": "Cancel",
"cmps.designer.link.Channel.closeChanSuccess": "The channel has been closed",
"cmps.designer.link.Channel.closeChanError": "Unable to close the channel",
"cmps.designer.link.CloseChannelButton.btnText": "Close Channel",
"cmps.designer.link.CloseChannelButton.title": "Are you sure you want to close this channel?",
"cmps.designer.link.CloseChannelButton.confirmBtn": "Yes",
"cmps.designer.link.CloseChannelButton.cancelBtn": "Cancel",
"cmps.designer.link.CloseChannelButton.success": "The channel has been closed",
"cmps.designer.link.CloseChannelButton.error": "Unable to close the channel",
"cmps.designer.link.LinkDetails.invalidTitle": "Invalid Selection",
"cmps.designer.link.LinkDetails.invalidMsg": "You've somehow managed to select an invalid link. Click the reload icon above to ensure your chart accurately represents the state in your nodes",
"cmps.designer.link.Peer.title": "Bitcoin Peer Connection",

Loading…
Cancel
Save