diff --git a/web/package.json b/web/package.json index 6b3e9a391..7b4d64a55 100644 --- a/web/package.json +++ b/web/package.json @@ -12,7 +12,6 @@ "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", diff --git a/web/src/css/app.less b/web/src/css/app.less index 1f246b192..7fbf65044 100644 --- a/web/src/css/app.less +++ b/web/src/css/app.less @@ -19,3 +19,4 @@ html { @import (less) "codemirror.less"; @import (less) "contentview.less"; @import (less) "modal.less"; +@import (less) "dropdown.less"; diff --git a/web/src/css/dropdown.less b/web/src/css/dropdown.less index ba8442df3..e8b7d696e 100644 --- a/web/src/css/dropdown.less +++ b/web/src/css/dropdown.less @@ -1,4 +1,3 @@ -hr.divider { - margin-top: 5px; - margin-bottom: 5px; +.dropdown-menu > li > a { + padding: 3px 10px; } diff --git a/web/src/css/flowtable.less b/web/src/css/flowtable.less index 7e6d63d46..a002c4a77 100644 --- a/web/src/css/flowtable.less +++ b/web/src/css/flowtable.less @@ -17,14 +17,14 @@ table-layout: fixed; } - thead { + thead tr { background-color: #F2F2F2; + border-bottom: solid #bebebe 1px; line-height: 23px; } th { font-weight: normal; - box-shadow: 0 1px 0 #a6a6a6; position: relative !important; padding-left: 1px; .user-select(none); @@ -155,12 +155,15 @@ .col-quickactions { width: 0; direction: rtl; + * { + direction: ltr; + } overflow: hidden; background-color: inherit; font-size: 20px; } - tr:hover .col-quickactions { + tr:hover .col-quickactions, .col-quickactions.hover { overflow: visible; } @@ -170,72 +173,24 @@ display: inline-flex; align-items: center; - div { + > a { margin-right: 2px; height: 32px; width: 32px; border-radius: 16px; + text-align: center; - &:hover { - background-color: rgba(0, 0, 0, 5%); + &:hover, &.open { + background-color: rgba(0, 0, 0, 5%); } } } .col-quickactions .fa-ellipsis-h { - transform: translate(-3px, 3px); + transform: translate(0, 3px); } .col-quickactions .fa-play { - transform: translate(-1px, 2px); + 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/css/tabs.less b/web/src/css/tabs.less index 43f7264ee..2c2573100 100644 --- a/web/src/css/tabs.less +++ b/web/src/css/tabs.less @@ -4,7 +4,7 @@ border-bottom: solid @separator-color 1px; - a { + > a { display: inline-block; border: solid transparent 1px; text-decoration: none; @@ -30,20 +30,20 @@ } .nav-tabs-lg { - a { + > a { padding: 3px 14px; margin: 0 2px -1px; } } .nav-tabs-sm { - a { + > a { padding: 0px 7px; margin: 2px 2px -1px; } - a.nav-action { + > a.nav-action { float: right; padding: 0; margin: 1px 0 0px; } -} \ No newline at end of file +} diff --git a/web/src/js/__tests__/components/common/DropdownSpec.js b/web/src/js/__tests__/components/common/DropdownSpec.js index 218b3590f..fbd1579aa 100644 --- a/web/src/js/__tests__/components/common/DropdownSpec.js +++ b/web/src/js/__tests__/components/common/DropdownSpec.js @@ -3,27 +3,18 @@ import renderer from 'react-test-renderer' import Dropdown, { Divider } from '../../../components/common/Dropdown' describe('Dropdown Component', () => { - let dropup = renderer.create( - 1 - - 2 - ), - dropdown = renderer.create( + let dropdown = renderer.create( 1 2 ) it('should render correctly', () => { - let tree = dropup.toJSON() - expect(tree).toMatchSnapshot() - - tree = dropdown.toJSON() + let tree = dropdown.toJSON() expect(tree).toMatchSnapshot() }) it('should handle open/close action', () => { - document.body.addEventListener('click', ()=>{}) - let tree = dropup.toJSON(), + let tree = dropdown.toJSON(), e = { preventDefault: jest.fn(), stopPropagation: jest.fn() } tree.children[0].props.onClick(e) expect(tree).toMatchSnapshot() @@ -31,6 +22,9 @@ describe('Dropdown Component', () => { // click action when the state is open tree.children[0].props.onClick(e) + // open again + tree.children[0].props.onClick(e) + // close document.body.click() expect(tree).toMatchSnapshot() diff --git a/web/src/js/components/ContentView/ViewSelector.jsx b/web/src/js/components/ContentView/ViewSelector.jsx index 69a7024d3..263540abd 100644 --- a/web/src/js/components/ContentView/ViewSelector.jsx +++ b/web/src/js/components/ContentView/ViewSelector.jsx @@ -1,8 +1,8 @@ -import React, { Component } from 'react' +import React from 'react' import PropTypes from 'prop-types' -import { connect } from 'react-redux' -import { setContentView } from '../../ducks/ui/flow'; -import Dropdown from '../common/Dropdown' +import {connect} from 'react-redux' +import {setContentView} from '../../ducks/ui/flow'; +import Dropdown, {MenuItem} from '../common/Dropdown' ViewSelector.propTypes = { @@ -11,22 +11,24 @@ ViewSelector.propTypes = { setContentView: PropTypes.func.isRequired } -export function ViewSelector ({contentViews, activeView, setContentView}){ - let inner = View: {activeView.toLowerCase()} +export function ViewSelector({contentViews, activeView, setContentView}) { + let inner = View: {activeView.toLowerCase()} return ( - + {contentViews.map(name => - {e.preventDefault(); setContentView(name)}}> + setContentView(name)}> {name.toLowerCase().replace('_', ' ')} - - ) - } + + )} ) } -export default connect ( +export default connect( state => ({ contentViews: state.settings.contentViews || [], activeView: state.ui.flow.contentView, diff --git a/web/src/js/components/FlowTable/FlowColumns.jsx b/web/src/js/components/FlowTable/FlowColumns.jsx index c26d9b326..7c46a4575 100644 --- a/web/src/js/components/FlowTable/FlowColumns.jsx +++ b/web/src/js/components/FlowTable/FlowColumns.jsx @@ -1,10 +1,10 @@ -import React from 'react' +import React, {useEffect, useState} from 'react' import {useDispatch} from 'react-redux' 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"; +import Dropdown, {MenuItem, SubMenu} from "../common/Dropdown"; export const defaultColumnNames = ["tls", "icon", "path", "method", "status", "size", "time"] @@ -167,22 +167,29 @@ TimeStampColumn.headerName = 'TimeStamp' export function QuickActionsColumn({flow, selected}) { const dispatch = useDispatch() - function resume(e) { - dispatch(flowActions.resume(flow)) - e.preventDefault() - e.stopPropagation() + let [open, setOpen] = useState(false) + + let intercept = null; + if (flow.intercepted) { + intercept = dispatch(flowActions.resume(flow))}> + + ; } return ( - + e.stopPropagation()}> - {selected ? - - : null} - {flow.intercepted - ? - : null} + {intercept} + } className="quickaction" onOpen={setOpen} options={{placement: "bottom-end"}}> + alert("Foo!")}>Foo + + Qux + Quux + + Baz + + ) } diff --git a/web/src/js/components/FlowTable/HoverMenu.jsx b/web/src/js/components/FlowTable/HoverMenu.jsx deleted file mode 100644 index f3e531041..000000000 --- a/web/src/js/components/FlowTable/HoverMenu.jsx +++ /dev/null @@ -1,89 +0,0 @@ -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' - -let SubMenu = ({flow, intercept, updateSettings}) => { - const onClick = useCallback((e, example) => { - e.preventDefault(); - if (intercept && intercept.includes(example)) { - return - } - if (!intercept) { - intercept = example - } else { - intercept = `${intercept} | ${example}` - } - 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() { - const { flow } = this.props - if (!flow) { - return null - } - return ( - }> - { - e.preventDefault() - }}> - - Copy as Curl - - { - e.preventDefault() - }}> - - Download HAR - - - ) - } -} - -export default connect( - state => ({ - flow: state.flows.byId[state.flows.selected[0]], - }), - null -)(HoverMenu) \ No newline at end of file diff --git a/web/src/js/components/Header/FileMenu.jsx b/web/src/js/components/Header/FileMenu.jsx index f0790c390..6a6a3fbdd 100644 --- a/web/src/js/components/Header/FileMenu.jsx +++ b/web/src/js/components/Header/FileMenu.jsx @@ -1,10 +1,9 @@ -import React, { Component } from 'react' +import React from 'react' import PropTypes from 'prop-types' -import { connect } from 'react-redux' +import {connect} from 'react-redux' import FileChooser from '../common/FileChooser' -import Dropdown, {Divider} from '../common/Dropdown' +import Dropdown, {Divider, MenuItem} from '../common/Dropdown' import * as flowsActions from '../../ducks/flows' -import * as modalActions from '../../ducks/ui/modal' import HideInStatic from "../common/HideInStatic"; FileMenu.propTypes = { @@ -19,29 +18,36 @@ FileMenu.onNewClick = (e, clearFlows) => { clearFlows() } -export function FileMenu ({clearFlows, loadFlows, saveFlows}) { - return ( - - FileMenu.onNewClick(e, clearFlows)}> - +export function FileMenu({clearFlows, loadFlows, saveFlows}) { + return ( + + FileMenu.onNewClick(e, clearFlows)}> + Clear All - - loadFlows(file)} - /> - { e.preventDefault(); saveFlows();}}> - + + + loadFlows(file)} + /> + + { + e.preventDefault(); + saveFlows(); + }}> + Save... - + - - - - Install Certificates... - + + + + + Install Certificates... + + ) diff --git a/web/src/js/components/common/Dropdown.jsx b/web/src/js/components/common/Dropdown.jsx deleted file mode 100644 index f339bcea0..000000000 --- a/web/src/js/components/common/Dropdown.jsx +++ /dev/null @@ -1,57 +0,0 @@ -import React, {Component} from 'react' -import PropTypes from 'prop-types' -import classnames from 'classnames' - -export const Divider = () => - -export default class Dropdown extends Component { - - static propTypes = { - dropup: PropTypes.bool, - className: PropTypes.string, - btnClass: PropTypes.string.isRequired - } - - 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 {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/Dropdown.tsx b/web/src/js/components/common/Dropdown.tsx new file mode 100644 index 000000000..32964bfe9 --- /dev/null +++ b/web/src/js/components/common/Dropdown.tsx @@ -0,0 +1,114 @@ +import React, {useEffect, useState} from 'react' +import {usePopper} from "react-popper"; +import * as PopperJS from "@popperjs/core"; +import classnames from "classnames"; + +export const Divider = () => ; + + +type MenuItemProps = { + onClick: () => void, + children: React.ReactNode +} + +export function MenuItem({onClick, children, ...attrs}: MenuItemProps) { + + const click = (e) => { + e.preventDefault(); + onClick(); + } + + return + + {children} + + +} + +type SubMenuProps = { + title: string, + children: React.ReactNode, +} + +export function SubMenu({title, children}: SubMenuProps) { + const [open, setOpen] = useState(false); + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + const {styles, attributes} = usePopper(referenceElement, popperElement, {placement: "right-start"}); + + let submenu = null; + if (open) { + submenu = {children}; + } + + return ( + setOpen(true)} + onMouseLeave={() => setOpen(false)} + > + {title} + {submenu} + + ) +} + +type DropdownProps = { + text: React.ReactNode, + children: React.ReactNode, + options?: Partial, + className?: string, + onOpen?: (boolean) => void +} +const Dropdown = ({text, children, options, className, onOpen, ...attrs}: DropdownProps) => { + + const [refElement, setRefElement] = useState(null); + const [open, _setOpen] = useState(false); + const [popperElement, setPopperElement] = useState(null); + const {styles, attributes} = usePopper(refElement, popperElement, {...options}); + + let setOpen = (b: boolean) => { + _setOpen(b); + onOpen && onOpen(b); + }; + + useEffect(() => { + if (!open) + return + document.addEventListener("click", () => { + // a bit tricky: we need to wait here for a bit so that we don't double-toggle + // when clicking the dropdown button. + setTimeout(() => setOpen(false)); + }, {capture: true, once: true}); + }, [open]); + + let contents; + if (open) { + contents = + {children} + + } else { + contents = null; + } + + return <> + setOpen(true)} + {...attrs}> + {text} + + + {contents} + >; +} +export default Dropdown; diff --git a/web/src/js/components/common/DropdownSubMenu.jsx b/web/src/js/components/common/DropdownSubMenu.jsx deleted file mode 100644 index c1c7656de..000000000 --- a/web/src/js/components/common/DropdownSubMenu.jsx +++ /dev/null @@ -1,73 +0,0 @@ -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} )} - - )} - - - - ) - } -} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 000000000..892f11aa0 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "jsx": "react" + } +} diff --git a/web/yarn.lock b/web/yarn.lock index f207f88d2..b1abf4f62 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -1208,6 +1208,11 @@ "@types/yargs" "^16.0.0" chalk "^4.0.0" +"@popperjs/core@^2.9.2": + version "2.9.2" + resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.9.2.tgz#adea7b6953cbb34651766b0548468e743c6a2353" + integrity sha512-VZMYa7+fXHdwIq1TDhSXoVmSPEGM/aa+6Aiq3nVVJ9bXr24zScr+NlKFKC3iPljA7ho/GAZr+d2jOf5GIRC30Q== + "@sinonjs/commons@^1.7.0": version "1.8.3" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d" @@ -5927,6 +5932,11 @@ react-dom@^17.0.2: object-assign "^4.1.1" scheduler "^0.20.2" +react-fast-compare@^3.0.1: + version "3.2.0" + resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb" + integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA== + "react-is@^16.12.0 || ^17.0.0", react-is@^17.0.1, react-is@^17.0.2: version "17.0.2" resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" @@ -5937,6 +5947,14 @@ react-is@^16.13.1, react-is@^16.7.0, react-is@^16.8.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== +react-popper@^2.2.5: + version "2.2.5" + resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-2.2.5.tgz#1214ef3cec86330a171671a4fbcbeeb65ee58e96" + integrity sha512-kxGkS80eQGtLl18+uig1UIf9MKixFSyPxglsgLBxlYnyDf65BiY9B3nZSc6C9XUNDgStROB0fMQlTEz1KxGddw== + dependencies: + react-fast-compare "^3.0.1" + warning "^4.0.2" + react-redux@^7.2.4: version "7.2.4" resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.4.tgz#1ebb474032b72d806de2e0519cd07761e222e225" @@ -7321,6 +7339,13 @@ walker@^1.0.7, walker@~1.0.5: dependencies: makeerror "1.0.x" +warning@^4.0.2: + version "4.0.3" + resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3" + integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w== + dependencies: + loose-envify "^1.0.0" + webidl-conversions@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-5.0.0.tgz#ae59c8a00b121543a2acc65c0434f57b0fc11aff"