diff --git a/web/package.json b/web/package.json index 65c2f1cc7..6b3e9a391 100644 --- a/web/package.json +++ b/web/package.json @@ -7,14 +7,17 @@ "start": "gulp" }, "dependencies": { + "@popperjs/core": "^2.9.2", "bootstrap": "^3.3.7", "classnames": "^2.3.1", "lodash": "^4.17.21", "mock-xmlhttprequest": "^1.1.0", + "popper.js": "^1.16.1", "prop-types": "^15.7.2", "react": "^17.0.2", "react-codemirror": "^1.0.0", "react-dom": "^17.0.2", + "react-popper": "^2.2.5", "react-redux": "^7.2.4", "react-test-renderer": "^17.0.2", "redux": "^4.1.0", diff --git a/web/src/css/flowtable.less b/web/src/css/flowtable.less index 9f7dbc40a..7e6d63d46 100644 --- a/web/src/css/flowtable.less +++ b/web/src/css/flowtable.less @@ -190,4 +190,52 @@ transform: translate(-1px, 2px); } + .dropdown-submenu { + position:relative; + } + + .dropdown-submenu>.dropdown-menu { + top:0; + left:100%; + margin-top:-6px; + margin-left:-1px; + -webkit-border-radius:0 6px 6px 6px; + -moz-border-radius:0 6px 6px 6px; + border-radius:0 6px 6px 6px; + } + + .dropdown-submenu:hover>.dropdown-menu { + display:block; + } + + .dropdown-submenu>a:after { + display:block; + content:" "; + float:right; + width:0; + height:0; + border-color:transparent; + border-style:solid; + border-width:5px 0 5px 5px; + border-left-color:#cccccc; + margin-top:5px; + margin-right:-10px; + } + + .dropdown-submenu:hover>a:after { + border-left-color:#ffffff; + } + + .dropdown-submenu.pull-left { + float:none; + } + + .dropdown-submenu.pull-left>.dropdown-menu { + left:-100%; + margin-left:10px; + -webkit-border-radius:6px 0 6px 6px; + -moz-border-radius:6px 0 6px 6px; + border-radius:6px 0 6px 6px; + } + } diff --git a/web/src/js/__tests__/components/FlowTable/__snapshots__/FlowColumnsSpec.js.snap b/web/src/js/__tests__/components/FlowTable/__snapshots__/FlowColumnsSpec.js.snap index dd444abfd..860253bc4 100644 --- a/web/src/js/__tests__/components/FlowTable/__snapshots__/FlowColumnsSpec.js.snap +++ b/web/src/js/__tests__/components/FlowTable/__snapshots__/FlowColumnsSpec.js.snap @@ -102,15 +102,7 @@ exports[`Flowcolumns Components should render QuickActionsColumn 1`] = ` -
-
- -
-
+
`; diff --git a/web/src/js/__tests__/components/FlowTable/__snapshots__/FlowRowSpec.js.snap b/web/src/js/__tests__/components/FlowTable/__snapshots__/FlowRowSpec.js.snap index 8edcbd2a2..a5966feb0 100644 --- a/web/src/js/__tests__/components/FlowTable/__snapshots__/FlowRowSpec.js.snap +++ b/web/src/js/__tests__/components/FlowTable/__snapshots__/FlowRowSpec.js.snap @@ -51,72 +51,7 @@ exports[`FlowRow Component should render correctly 1`] = ` -
-
- -
-
+
-
- - Actions - - -
`; diff --git a/web/src/js/components/FlowTable/FlowColumns.jsx b/web/src/js/components/FlowTable/FlowColumns.jsx index 9896ec591..c26d9b326 100644 --- a/web/src/js/components/FlowTable/FlowColumns.jsx +++ b/web/src/js/components/FlowTable/FlowColumns.jsx @@ -4,6 +4,7 @@ import classnames from 'classnames' import {RequestUtils, ResponseUtils} from '../../flow/utils.js' import {formatSize, formatTimeDelta, formatTimeStamp} from '../../utils.js' import * as flowActions from "../../ducks/flows"; +import HoverMenu from "./HoverMenu"; export const defaultColumnNames = ["tls", "icon", "path", "method", "status", "size", "time"] @@ -163,7 +164,7 @@ export function TimeStampColumn({flow}) { TimeStampColumn.headerClass = 'col-timestamp' TimeStampColumn.headerName = 'TimeStamp' -export function QuickActionsColumn({flow}) { +export function QuickActionsColumn({flow, selected}) { const dispatch = useDispatch() function resume(e) { @@ -175,7 +176,9 @@ export function QuickActionsColumn({flow}) { return (
-
+ {selected ? +
+ : null} {flow.intercepted ?
: null} diff --git a/web/src/js/components/FlowTable/FlowRow.jsx b/web/src/js/components/FlowTable/FlowRow.jsx index c3973d515..7c2b14ba2 100644 --- a/web/src/js/components/FlowTable/FlowRow.jsx +++ b/web/src/js/components/FlowTable/FlowRow.jsx @@ -4,7 +4,6 @@ import classnames from 'classnames' import {defaultColumnNames} from './FlowColumns' import { pure } from '../../utils' import {getDisplayColumns} from './FlowTableHead' -import HoverMenu from './HoverMenu' import { connect } from 'react-redux' FlowRow.propTypes = { @@ -28,9 +27,8 @@ function FlowRow({ flow, selected, highlighted, onSelect, displayColumnNames }) return ( onSelect(flow.id)}> {displayColumns.map(Column => ( - + ))} - ) } diff --git a/web/src/js/components/FlowTable/HoverMenu.jsx b/web/src/js/components/FlowTable/HoverMenu.jsx index 8016c9471..f3e531041 100644 --- a/web/src/js/components/FlowTable/HoverMenu.jsx +++ b/web/src/js/components/FlowTable/HoverMenu.jsx @@ -1,16 +1,12 @@ -import React, { Component } from 'react' +import React, { Component, useCallback } from 'react' import { connect } from 'react-redux' import { update as updateSettings } from "../../ducks/settings" import Dropdown from '../common/Dropdown' +import DropdownSubMenu from '../common/DropdownSubMenu' -class HoverMenu extends Component { - constructor(props, context) { - super(props, context) - } - - onClick(e, example) { +let SubMenu = ({flow, intercept, updateSettings}) => { + const onClick = useCallback((e, example) => { e.preventDefault(); - let intercept = this.props.intercept if (intercept && intercept.includes(example)) { return } @@ -19,7 +15,46 @@ class HoverMenu extends Component { } else { intercept = `${intercept} | ${example}` } - this.props.updateSettings({ intercept }) + updateSettings({ intercept }) + }, [intercept]) + + return ( + + { + onClick(e, flow.request.host) + }}> + +  Intercept {flow.request.host} + + { flow.request.path != "/" ? { + onClick(e, flow.request.host + flow.request.path) + }}> + +  Intercept {flow.request.host + flow.request.path} + : null } + { + onClick(e, `~m POST & ${flow.request.host}`) + }}> + +  Intercept all POST requests from this host + + + ) +} + +SubMenu = connect( + state => ({ + flow: state.flows.byId[state.flows.selected[0]], + intercept: state.settings.intercept, + }), + { + updateSettings, + } +)(SubMenu) + +class HoverMenu extends Component { + constructor(props, context) { + super(props, context) } render() { @@ -28,24 +63,18 @@ class HoverMenu extends Component { return null } return ( - + }> { - this.onClick(e, flow.request.host) + e.preventDefault() }}> -  Intercept {flow.request.host} +  Copy as Curl - { flow.request.path != "/" ? { - this.onClick(e, flow.request.host + flow.request.path) - }}> - -  Intercept {flow.request.host + flow.request.path} - : null } { - this.onClick(e, `~m POST & ${flow.request.host}`) + e.preventDefault() }}> -  Intercept all POST requests from this host +  Download HAR ) @@ -55,9 +84,6 @@ class HoverMenu extends Component { export default connect( state => ({ flow: state.flows.byId[state.flows.selected[0]], - intercept: state.settings.intercept, }), - { - updateSettings, - } + null )(HoverMenu) \ No newline at end of file diff --git a/web/src/js/components/common/Dropdown.jsx b/web/src/js/components/common/Dropdown.jsx index 4999b420b..f339bcea0 100644 --- a/web/src/js/components/common/Dropdown.jsx +++ b/web/src/js/components/common/Dropdown.jsx @@ -39,15 +39,17 @@ export default class Dropdown extends Component { } render() { - const {dropup, className, btnClass, text, children} = this.props + const {dropup, className, btnClass, text, icon, children, submenu} = this.props return (
+ {icon ? : null} {text}
    {children.map((item, i) =>
  • {item}
  • )} + {submenu}
) diff --git a/web/src/js/components/common/DropdownSubMenu.jsx b/web/src/js/components/common/DropdownSubMenu.jsx new file mode 100644 index 000000000..c1c7656de --- /dev/null +++ b/web/src/js/components/common/DropdownSubMenu.jsx @@ -0,0 +1,73 @@ +import React, {Component} from 'react' +import PropTypes from 'prop-types' +import { Manager, Reference, Popper } from 'react-popper'; +import classnames from 'classnames' + +export const Divider = () =>
+ +export default class DropdownSubMenu extends Component { + + static propTypes = { + dropup: PropTypes.bool, + className: PropTypes.string, + } + + static defaultProps = { + dropup: false + } + + constructor(props, context) { + super(props, context) + this.state = {open: false} + this.close = this.close.bind(this) + this.open = this.open.bind(this) + } + + close() { + this.setState({open: false}) + document.removeEventListener('click', this.close) + } + + open(e) { + e.preventDefault() + if (this.state.open) { + return + } + e.stopPropagation(); + this.setState({open: !this.state.open}) + document.addEventListener('click', this.close) + } + + render() { + const {text, children} = this.props + + return ( +
  • + + + {({ ref }) => ( + {text} + )} + + + {({ ref, style, placement, arrowProps }) => ( +
      + {children.map((item, i) =>
    • {item}
    • )} +
    + )} +
    +
    +
  • + ) + } +}