replace dropdown with popper.js-based implementation

This commit is contained in:
Maximilian Hils 2021-06-16 22:28:29 +02:00
parent f69c91cb36
commit a034b7c2c1
15 changed files with 234 additions and 345 deletions

View File

@ -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",

View File

@ -19,3 +19,4 @@ html {
@import (less) "codemirror.less";
@import (less) "contentview.less";
@import (less) "modal.less";
@import (less) "dropdown.less";

View File

@ -1,4 +1,3 @@
hr.divider {
margin-top: 5px;
margin-bottom: 5px;
.dropdown-menu > li > a {
padding: 3px 10px;
}

View File

@ -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;
}
}

View File

@ -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;
}
}
}

View File

@ -3,27 +3,18 @@ import renderer from 'react-test-renderer'
import Dropdown, { Divider } from '../../../components/common/Dropdown'
describe('Dropdown Component', () => {
let dropup = renderer.create(<Dropdown dropup btnClass="foo">
<a href="#">1</a>
<Divider/>
<a href="#">2</a>
</Dropdown>),
dropdown = renderer.create(<Dropdown btnClass="foo">
let dropdown = renderer.create(<Dropdown text="open me">
<a href="#">1</a>
<a href="#">2</a>
</Dropdown>)
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()

View File

@ -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 = <span> <b>View:</b> {activeView.toLowerCase()} <span className="caret"/> </span>
export function ViewSelector({contentViews, activeView, setContentView}) {
let inner = <span><b>View:</b> {activeView.toLowerCase()} <span className="caret"/></span>
return (
<Dropdown dropup className="pull-left" btnClass="btn btn-default btn-xs" text={inner}>
<Dropdown
text={inner}
className="btn btn-default btn-xs pull-left"
options={{placement:"top-start"}}>
{contentViews.map(name =>
<a href="#" key={name} onClick={e => {e.preventDefault(); setContentView(name)}}>
<MenuItem key={name} onClick={() => setContentView(name)}>
{name.toLowerCase().replace('_', ' ')}
</a>
)
}
</MenuItem>
)}
</Dropdown>
)
}
export default connect (
export default connect(
state => ({
contentViews: state.settings.contentViews || [],
activeView: state.ui.flow.contentView,

View File

@ -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 = <a href="#" className="quickaction" onClick={() => dispatch(flowActions.resume(flow))}>
<i className="fa fa-fw fa-play text-success"/>
</a>;
}
return (
<td className="col-quickactions">
<td className={classnames("col-quickactions", {hover: open})} onClick={(e) => e.stopPropagation()}>
<div>
{selected ?
<div className="quickaction"><HoverMenu /></div>
: null}
{flow.intercepted
? <div className="quickaction" onClick={resume}><i className="fa fa-fw fa-play text-success"/></div>
: null}
{intercept}
<Dropdown text={<i className="fa fa-fw fa-ellipsis-h"/>} className="quickaction" onOpen={setOpen} options={{placement: "bottom-end"}}>
<MenuItem onClick={() => alert("Foo!")}>Foo</MenuItem>
<SubMenu title="Bar">
<MenuItem>Qux</MenuItem>
<MenuItem>Quux</MenuItem>
</SubMenu>
<MenuItem>Baz</MenuItem>
</Dropdown>
</div>
</td>
)
}

View File

@ -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 (
<DropdownSubMenu text="Intercept requests like this">
<a href="#" onClick={(e) =>{
onClick(e, flow.request.host)
}}>
<i className="fa fa-fw fa-plus"></i>
&nbsp;Intercept {flow.request.host}
</a>
{ flow.request.path != "/" ? <a href="#" onClick={(e) =>{
onClick(e, flow.request.host + flow.request.path)
}}>
<i className="fa fa-fw fa-plus"></i>
&nbsp;Intercept {flow.request.host + flow.request.path}
</a> : null }
<a href="#" onClick={(e) =>{
onClick(e, `~m POST & ${flow.request.host}`)
}}>
<i className="fa fa-fw fa-plus"></i>
&nbsp;Intercept all POST requests from this host
</a>
</DropdownSubMenu>
)
}
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 (
<Dropdown className="pull-right" btnClass="special" icon="fa fa-fw fa-ellipsis-h" submenu={<SubMenu />}>
<a href="#" onClick={(e) =>{
e.preventDefault()
}}>
<i className="fa fa-fw fa-plus"></i>
&nbsp;Copy as Curl
</a>
<a href="#" onClick={(e) =>{
e.preventDefault()
}}>
<i className="fa fa-fw fa-plus"></i>
&nbsp;Download HAR
</a>
</Dropdown>
)
}
}
export default connect(
state => ({
flow: state.flows.byId[state.flows.selected[0]],
}),
null
)(HoverMenu)

View File

@ -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 (
<Dropdown className="pull-left" btnClass="special" text="mitmproxy">
<a href="#" onClick={e => FileMenu.onNewClick(e, clearFlows)}>
<i className="fa fa-fw fa-trash"></i>
export function FileMenu({clearFlows, loadFlows, saveFlows}) {
return (
<Dropdown className="pull-left special" text="mitmproxy" options={{"placement": "bottom-start"}}>
<MenuItem onClick={e => FileMenu.onNewClick(e, clearFlows)}>
<i className="fa fa-fw fa-trash"/>
&nbsp;Clear All
</a>
<FileChooser
icon="fa-folder-open"
text="&nbsp;Open..."
onOpenFile={file => loadFlows(file)}
/>
<a href="#" onClick={e =>{ e.preventDefault(); saveFlows();}}>
<i className="fa fa-fw fa-floppy-o"></i>
</MenuItem>
<li>
<FileChooser
icon="fa-folder-open"
text="&nbsp;Open..."
onOpenFile={file => loadFlows(file)}
/>
</li>
<MenuItem onClick={e => {
e.preventDefault();
saveFlows();
}}>
<i className="fa fa-fw fa-floppy-o"/>
&nbsp;Save...
</a>
</MenuItem>
<HideInStatic>
<Divider/>
<a href="http://mitm.it/" target="_blank">
<i className="fa fa-fw fa-external-link"></i>
&nbsp;Install Certificates...
</a>
<Divider/>
<li>
<a href="http://mitm.it/" target="_blank">
<i className="fa fa-fw fa-external-link"/>
&nbsp;Install Certificates...
</a>
</li>
</HideInStatic>
</Dropdown>
)

View File

@ -1,57 +0,0 @@
import React, {Component} from 'react'
import PropTypes from 'prop-types'
import classnames from 'classnames'
export const Divider = () => <hr className="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 (
<div className={classnames((dropup ? 'dropup' : 'dropdown'), className, {open: this.state.open})}>
<a href='#' className={btnClass}
onClick={this.open}>
{icon ? <i className={icon}/> : null}
{text}
</a>
<ul className="dropdown-menu" role="menu">
{children.map((item, i) => <li key={i}> {item} </li>)}
{submenu}
</ul>
</div>
)
}
}

View File

@ -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 = () => <li role="separator" className="divider"/>;
type MenuItemProps = {
onClick: () => void,
children: React.ReactNode
}
export function MenuItem({onClick, children, ...attrs}: MenuItemProps) {
const click = (e) => {
e.preventDefault();
onClick();
}
return <li>
<a
href="#"
onClick={click}
{...attrs}>
{children}
</a>
</li>
}
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 = <ul className="dropdown-menu show" ref={setPopperElement}
style={styles.popper} {...attributes.popper}>{children}</ul>;
}
return (
<li
ref={setReferenceElement}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
<a><i className="fa fa-caret-right pull-right" aria-hidden="true"/> {title}</a>
{submenu}
</li>
)
}
type DropdownProps = {
text: React.ReactNode,
children: React.ReactNode,
options?: Partial<PopperJS.Options>,
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 = <ul
className="dropdown-menu show"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}>
{children}
</ul>
} else {
contents = null;
}
return <>
<a href="#"
ref={setRefElement}
className={classnames(className, {"open": open})}
onClick={() => setOpen(true)}
{...attrs}>
{text}
</a>
{contents}
</>;
}
export default Dropdown;

View File

@ -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 = () => <hr className="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 (
<li className="dropdown-submenu">
<Manager>
<Reference>
{({ ref }) => (
<a tabIndex="-1" ref={ref} href="#">{text}</a>
)}
</Reference>
<Popper
placement="bottom-end"
modifiers={[
{
name: 'offset',
options: {
offset: [0, 30],
}
},
]}
>
{({ ref, style, placement, arrowProps }) => (
<ul ref={ref} style={style} data-placement={placement} className="dropdown-menu pull-left">
{children.map((item, i) => <li key={i}> {item} </li>)}
</ul>
)}
</Popper>
</Manager>
</li>
)
}
}

6
web/tsconfig.json Normal file
View File

@ -0,0 +1,6 @@
{
"compilerOptions": {
"esModuleInterop": true,
"jsx": "react"
}
}

View File

@ -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"