diff --git a/README.md b/README.md index fa0934bd4d..f5c5d19d3d 100644 --- a/README.md +++ b/README.md @@ -129,8 +129,8 @@ To start contributing, check out our [Contributing Guide](CONTRIBUTING.md) [//]: contributor-faces - + @@ -149,6 +149,7 @@ To start contributing, check out our [Contributing Guide](CONTRIBUTING.md) + @@ -169,7 +170,6 @@ To start contributing, check out our [Contributing Guide](CONTRIBUTING.md) - diff --git a/cypress/integration/webapp/basic.ts b/cypress/integration/webapp/basic.ts index 6837347de0..58119264cf 100644 --- a/cypress/integration/webapp/basic.ts +++ b/cypress/integration/webapp/basic.ts @@ -56,6 +56,18 @@ describe('basic test', () => { ); }); + it('Inputbox text should sync when search is linked', () => { + cy.intercept('**/render*', { + fixture: 'simple-golang-app-cpu.json', + }).as('render'); + + cy.visit('/comparison'); + + cy.findByTestId('link-search-btn-left').click(); + cy.findByTestId('flamegraph-search-left').type('main'); + cy.findByTestId('flamegraph-search-right').should('have.value', 'main'); + }); + it('view buttons should change view when clicked', () => { // mock data since the first preselected application // could have no data diff --git a/webapp/javascript/components/FlameGraph/FlameGraphRenderer.jsx b/webapp/javascript/components/FlameGraph/FlameGraphRenderer.jsx index 4b6d3cc222..4f74a8ccd8 100644 --- a/webapp/javascript/components/FlameGraph/FlameGraphRenderer.jsx +++ b/webapp/javascript/components/FlameGraph/FlameGraphRenderer.jsx @@ -6,6 +6,8 @@ /* eslint-disable no-nested-ternary */ import React from 'react'; +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; import clsx from 'clsx'; import { Maybe } from '@utils/fp'; import Graph from './FlameGraphComponent'; @@ -16,6 +18,11 @@ import styles from './FlamegraphRenderer.module.css'; import ExportData from '../ExportData'; +import { + toggleLinkedSearch, + setSearchQuery, +} from '../../redux/reducers/search'; + class FlameGraphRenderer extends React.Component { // TODO: this could come from some other state // eg localstorage @@ -57,6 +64,23 @@ class FlameGraphRenderer extends React.Component { if (prevState.flamegraphConfigs !== this.state.flamegraphConfigs) { this.updateFlamegraphDirtiness(); } + + if (this.props.isSearchLinked) { + if (this.props.linkedSearchQuery !== prevState.highlightQuery) { + // disable eslint rule to allow updating state inside componentDidUpdate + // eslint-disable-next-line react/no-did-update-set-state + this.setState({ + highlightQuery: this.props.linkedSearchQuery, + }); + } + if (this.props.resetLinkedSearchSide === this.props.viewSide) { + // eslint-disable-next-line react/no-did-update-set-state + this.setState({ + highlightQuery: '', + }); + this.props.actions.toggleLinkedSearch('both'); + } + } } componentWillUnmount() { @@ -81,6 +105,10 @@ class FlameGraphRenderer extends React.Component { this.setState({ highlightQuery: e, }); + + if (this.props.isSearchLinked) { + this.props.actions.setSearchQuery(e); + } }; onReset = () => { @@ -275,6 +303,15 @@ class FlameGraphRenderer extends React.Component { tablePane ); + const toggleLinkedSearchAction = () => { + if (this.props.isSearchLinked) { + this.props.actions.toggleLinkedSearch(this.props.viewSide); + } else { + this.props.actions.setSearchQuery(this.state.highlightQuery); + this.props.actions.toggleLinkedSearch('both'); + } + }; + return (
{ this.onFocusOnNode(i, j); }} + viewType={this.props.viewType} + viewSide={this.props.viewSide} + toggleLinkedSearch={toggleLinkedSearchAction} /> )} {this.props.children} @@ -355,4 +395,20 @@ function figureFlamegraphDataTestId(viewType, viewSide) { } } -export default FlameGraphRenderer; +const mapStateToProps = (state) => ({ + isSearchLinked: state.search.isSearchLinked, + linkedSearchQuery: state.search.linkedSearchQuery, + resetLinkedSearchSide: state.search.resetLinkedSearchSide, +}); + +const mapDispatchToProps = (dispatch) => ({ + actions: bindActionCreators( + { + toggleLinkedSearch, + setSearchQuery, + }, + dispatch + ), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(FlameGraphRenderer); diff --git a/webapp/javascript/components/ProfilerHeader.module.css b/webapp/javascript/components/ProfilerHeader.module.css index 1177313dda..ffb7386f0e 100644 --- a/webapp/javascript/components/ProfilerHeader.module.css +++ b/webapp/javascript/components/ProfilerHeader.module.css @@ -24,6 +24,38 @@ background: rgba(255, 255, 255, 1); } +.linked-search-input { + border-right: none !important; + margin-right: 0 !important; + border-top-right-radius: 0%; + border-bottom-right-radius: 0%; +} +.linked-search-btn { + margin-left: 0px !important; + background: rgba(255, 255, 255, 0.8); + color: #333; + z-index: 1; + border-top-left-radius: 0%; + border-bottom-left-radius: 0%; +} +.linked-search-btn.active { + margin-left: 0px !important; + background: rgba(255, 255, 255, 0.8); + border: 1px solid #ea6460; + z-index: 1; + border-top-left-radius: 0%; + border-bottom-left-radius: 0%; + color: #ea6460; +} +.linked-search-btn:hover { + cursor: pointer; + background: rgba(255, 255, 255, 0.8) !important; +} + +.linked-search-input.active { + border: 1px solid #ea6460; +} + .search-small { width: 100px; } diff --git a/webapp/javascript/components/Toolbar.spec.tsx b/webapp/javascript/components/Toolbar.spec.tsx index 34312b5fc1..b1acd47fc6 100644 --- a/webapp/javascript/components/Toolbar.spec.tsx +++ b/webapp/javascript/components/Toolbar.spec.tsx @@ -2,9 +2,18 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Maybe } from '@utils/fp'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; import Toolbar, { TOOLBAR_MODE_WIDTH_THRESHOLD } from './Toolbar'; import { FitModes } from '../util/fitMode'; +import searchReducer from '../redux/reducers/search'; +const store = configureStore({ + reducer: { + search: searchReducer, + }, + // middleware: [thunkMiddleware], +}); // since 'react-debounce-input' uses lodash.debounce under the hood jest.mock('lodash.debounce', () => jest.fn((fn) => { @@ -58,19 +67,21 @@ describe('ProfileHeader', () => { setWindowSize('large'); const { asFragment, rerender } = render( - {}} - reset={() => {}} - updateFitMode={() => {}} - fitMode={FitModes.HEAD} - updateView={() => {}} - updateViewDiff={() => {}} - isFlamegraphDirty={false} - selectedNode={Maybe.nothing()} - onFocusOnSubtree={() => {}} - /> + + {}} + reset={() => {}} + updateFitMode={() => {}} + fitMode={FitModes.HEAD} + updateView={() => {}} + updateViewDiff={() => {}} + isFlamegraphDirty={false} + selectedNode={Maybe.nothing()} + onFocusOnSubtree={() => {}} + /> + ); expect(screen.getByRole('toolbar')).toHaveAttribute('data-mode', 'large'); @@ -79,19 +90,21 @@ describe('ProfileHeader', () => { setWindowSize('small'); rerender( - {}} - reset={() => {}} - updateFitMode={() => {}} - fitMode={FitModes.HEAD} - updateView={() => {}} - updateViewDiff={() => {}} - isFlamegraphDirty={false} - selectedNode={Maybe.nothing()} - onFocusOnSubtree={() => {}} - /> + + {}} + reset={() => {}} + updateFitMode={() => {}} + fitMode={FitModes.HEAD} + updateView={() => {}} + updateViewDiff={() => {}} + isFlamegraphDirty={false} + selectedNode={Maybe.nothing()} + onFocusOnSubtree={() => {}} + /> + ); expect(screen.getByRole('toolbar')).toHaveAttribute('data-mode', 'small'); @@ -109,20 +122,22 @@ describe('ProfileHeader', () => { it('renders as disabled when flamegraph is not dirty', () => { const component = ( - {}} - reset={onReset} - updateFitMode={() => {}} - fitMode={FitModes.HEAD} - updateView={() => {}} - updateViewDiff={() => {}} - selectedNode={Maybe.nothing()} - onFocusOnSubtree={() => {}} - /> + + {}} + reset={onReset} + updateFitMode={() => {}} + fitMode={FitModes.HEAD} + updateView={() => {}} + updateViewDiff={() => {}} + selectedNode={Maybe.nothing()} + onFocusOnSubtree={() => {}} + /> + ); render(component); expect(screen.getByRole('button', { name: /Reset/ })).toBeDisabled(); @@ -130,20 +145,22 @@ describe('ProfileHeader', () => { it('calls onReset when clicked (and enabled)', () => { const component = ( - {}} - reset={onReset} - updateFitMode={() => {}} - fitMode={FitModes.HEAD} - updateView={() => {}} - updateViewDiff={() => {}} - selectedNode={Maybe.nothing()} - onFocusOnSubtree={() => {}} - /> + + {}} + reset={onReset} + updateFitMode={() => {}} + fitMode={FitModes.HEAD} + updateView={() => {}} + updateViewDiff={() => {}} + selectedNode={Maybe.nothing()} + onFocusOnSubtree={() => {}} + /> + ); render(component); expect(screen.getByRole('button', { name: /Reset/ })).not.toBeDisabled(); @@ -156,20 +173,22 @@ describe('ProfileHeader', () => { setWindowSize('large'); const component = ( - {}} - reset={onReset} - updateFitMode={() => {}} - fitMode={FitModes.HEAD} - updateView={() => {}} - updateViewDiff={() => {}} - selectedNode={Maybe.nothing()} - onFocusOnSubtree={() => {}} - /> + + {}} + reset={onReset} + updateFitMode={() => {}} + fitMode={FitModes.HEAD} + updateView={() => {}} + updateViewDiff={() => {}} + selectedNode={Maybe.nothing()} + onFocusOnSubtree={() => {}} + /> + ); render(component); expect( @@ -181,20 +200,22 @@ describe('ProfileHeader', () => { setWindowSize('small'); const component = ( - {}} - reset={onReset} - updateFitMode={() => {}} - fitMode={FitModes.HEAD} - updateView={() => {}} - updateViewDiff={() => {}} - selectedNode={Maybe.nothing()} - onFocusOnSubtree={() => {}} - /> + + {}} + reset={onReset} + updateFitMode={() => {}} + fitMode={FitModes.HEAD} + updateView={() => {}} + updateViewDiff={() => {}} + selectedNode={Maybe.nothing()} + onFocusOnSubtree={() => {}} + /> + ); render(component); expect(screen.getByRole('button', { name: 'Reset' })).toBeInTheDocument(); @@ -206,20 +227,22 @@ describe('ProfileHeader', () => { const onChange = jest.fn(); const component = ( - {}} - updateFitMode={() => {}} - fitMode={FitModes.HEAD} - updateView={() => {}} - updateViewDiff={() => {}} - selectedNode={Maybe.nothing()} - onFocusOnSubtree={() => {}} - /> + + {}} + updateFitMode={() => {}} + fitMode={FitModes.HEAD} + updateView={() => {}} + updateViewDiff={() => {}} + selectedNode={Maybe.nothing()} + onFocusOnSubtree={() => {}} + /> + ); render(component); @@ -228,23 +251,56 @@ describe('ProfileHeader', () => { }); }); + describe('LinkedSearch', () => { + it('calls callback when search is linked', () => { + const toggleLinkedSearch = jest.fn(); + + const component = ( + + {}} + reset={() => {}} + updateFitMode={() => {}} + fitMode={FitModes.HEAD} + updateView={() => {}} + updateViewDiff={() => {}} + selectedNode={Maybe.nothing()} + onFocusOnSubtree={() => {}} + viewType="double" + viewSide="left" + toggleLinkedSearch={toggleLinkedSearch} + /> + + ); + + render(component); + userEvent.click(screen.getByTestId('link-search-btn-left')); + expect(toggleLinkedSearch).toHaveBeenCalled(); + }); + }); + describe('FitMode', () => { const updateFitMode = jest.fn(); const component = ( - {}} - reset={() => {}} - updateFitMode={updateFitMode} - fitMode={FitModes.HEAD} - updateView={() => {}} - updateViewDiff={() => {}} - isFlamegraphDirty={false} - selectedNode={Maybe.nothing()} - onFocusOnSubtree={() => {}} - /> + + {}} + reset={() => {}} + updateFitMode={updateFitMode} + fitMode={FitModes.HEAD} + updateView={() => {}} + updateViewDiff={() => {}} + isFlamegraphDirty={false} + selectedNode={Maybe.nothing()} + onFocusOnSubtree={() => {}} + /> + ); beforeEach(() => { @@ -276,20 +332,22 @@ describe('ProfileHeader', () => { describe('Focus on subtree', () => { it('renders as disabled when theres no selected node', () => { const component = ( - {}} - reset={() => {}} - updateFitMode={() => {}} - fitMode={FitModes.HEAD} - updateView={() => {}} - updateViewDiff={() => {}} - selectedNode={Maybe.nothing()} - onFocusOnSubtree={() => {}} - /> + + {}} + reset={() => {}} + updateFitMode={() => {}} + fitMode={FitModes.HEAD} + updateView={() => {}} + updateViewDiff={() => {}} + selectedNode={Maybe.nothing()} + onFocusOnSubtree={() => {}} + /> + ); render(component); expect(screen.getByRole('button', { name: /Focus/ })).toBeDisabled(); @@ -298,20 +356,22 @@ describe('ProfileHeader', () => { it('calls callback when clicked', () => { const onFocusOnSubtree = jest.fn(); const component = ( - {}} - reset={() => {}} - updateFitMode={() => {}} - fitMode={FitModes.HEAD} - updateView={() => {}} - updateViewDiff={() => {}} - selectedNode={Maybe.just({ i: 999, j: 999 })} - onFocusOnSubtree={onFocusOnSubtree} - /> + + {}} + reset={() => {}} + updateFitMode={() => {}} + fitMode={FitModes.HEAD} + updateView={() => {}} + updateViewDiff={() => {}} + selectedNode={Maybe.just({ i: 999, j: 999 })} + onFocusOnSubtree={onFocusOnSubtree} + /> + ); render(component); @@ -323,20 +383,22 @@ describe('ProfileHeader', () => { it('shows short text', () => { setWindowSize('small'); const component = ( - {}} - reset={() => {}} - updateFitMode={() => {}} - fitMode={FitModes.HEAD} - updateView={() => {}} - updateViewDiff={() => {}} - selectedNode={Maybe.nothing()} - onFocusOnSubtree={() => {}} - /> + + {}} + reset={() => {}} + updateFitMode={() => {}} + fitMode={FitModes.HEAD} + updateView={() => {}} + updateViewDiff={() => {}} + selectedNode={Maybe.nothing()} + onFocusOnSubtree={() => {}} + /> + ); render(component); expect(screen.getByRole('button', { name: 'Focus' })).toBeDisabled(); @@ -345,20 +407,22 @@ describe('ProfileHeader', () => { it('shows long text', () => { setWindowSize('large'); const component = ( - {}} - reset={() => {}} - updateFitMode={() => {}} - fitMode={FitModes.HEAD} - updateView={() => {}} - updateViewDiff={() => {}} - selectedNode={Maybe.nothing()} - onFocusOnSubtree={() => {}} - /> + + {}} + reset={() => {}} + updateFitMode={() => {}} + fitMode={FitModes.HEAD} + updateView={() => {}} + updateViewDiff={() => {}} + selectedNode={Maybe.nothing()} + onFocusOnSubtree={() => {}} + /> + ); render(component); expect( @@ -370,37 +434,41 @@ describe('ProfileHeader', () => { describe('DiffSection', () => { const updateViewDiff = jest.fn(); const component = ( - {}} - reset={() => {}} - updateFitMode={() => {}} - fitMode={FitModes.HEAD} - updateView={() => {}} - updateViewDiff={updateViewDiff} - isFlamegraphDirty={false} - selectedNode={Maybe.nothing()} - onFocusOnSubtree={() => {}} - /> - ); - - it('doesnt render if viewDiff is not set', () => { - render( + {}} reset={() => {}} updateFitMode={() => {}} fitMode={FitModes.HEAD} updateView={() => {}} - updateViewDiff={() => {}} + updateViewDiff={updateViewDiff} isFlamegraphDirty={false} selectedNode={Maybe.nothing()} onFocusOnSubtree={() => {}} /> + + ); + + it('doesnt render if viewDiff is not set', () => { + render( + + {}} + reset={() => {}} + updateFitMode={() => {}} + fitMode={FitModes.HEAD} + updateView={() => {}} + updateViewDiff={() => {}} + isFlamegraphDirty={false} + selectedNode={Maybe.nothing()} + onFocusOnSubtree={() => {}} + /> + ); expect(screen.queryByTestId('diff-view')).toBeNull(); @@ -471,19 +539,21 @@ describe('ProfileHeader', () => { describe('ViewSection', () => { const updateView = jest.fn(); const component = ( - {}} - reset={() => {}} - updateFitMode={() => {}} - fitMode={FitModes.HEAD} - updateView={updateView} - updateViewDiff={() => {}} - isFlamegraphDirty={false} - selectedNode={Maybe.nothing()} - onFocusOnSubtree={() => {}} - /> + + {}} + reset={() => {}} + updateFitMode={() => {}} + fitMode={FitModes.HEAD} + updateView={updateView} + updateViewDiff={() => {}} + isFlamegraphDirty={false} + selectedNode={Maybe.nothing()} + onFocusOnSubtree={() => {}} + /> + ); describe('large mode', () => { diff --git a/webapp/javascript/components/Toolbar.tsx b/webapp/javascript/components/Toolbar.tsx index 4247d0bc81..0d28203ea1 100644 --- a/webapp/javascript/components/Toolbar.tsx +++ b/webapp/javascript/components/Toolbar.tsx @@ -1,4 +1,5 @@ -import React from 'react'; +import React, { useState } from 'react'; +import { useSelector } from 'react-redux'; import clsx from 'clsx'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faAlignLeft } from '@fortawesome/free-solid-svg-icons/faAlignLeft'; @@ -9,6 +10,7 @@ import { faListUl } from '@fortawesome/free-solid-svg-icons/faListUl'; import { faTable } from '@fortawesome/free-solid-svg-icons/faTable'; import { faUndo } from '@fortawesome/free-solid-svg-icons/faUndo'; import { faCompressAlt } from '@fortawesome/free-solid-svg-icons/faCompressAlt'; +import { faLink } from '@fortawesome/free-solid-svg-icons/faLink'; import { DebounceInput } from 'react-debounce-input'; import { Maybe } from '@utils/fp'; import useResizeObserver from '@react-hook/resize-observer'; @@ -16,6 +18,12 @@ import Button from '@ui/Button'; import { FitModes } from '../util/fitMode'; import styles from './ProfilerHeader.module.css'; +import { + isSearchLinked, + linkedSearchQuery, + resetLinkedSearchSide, +} from '../redux/reducers/search'; + // arbitrary value // as a simple heuristic, try to run the comparison view // and see when the buttons start to overlap @@ -74,6 +82,9 @@ interface ProfileHeaderProps { */ selectedNode: Maybe<{ i: number; j: number }>; onFocusOnSubtree: (i: number, j: number) => void; + viewType?: 'single' | 'double' | 'diff'; + viewSide?: 'left' | 'right'; + toggleLinkedSearch?: () => void; } const Toolbar = React.memo( @@ -88,9 +99,11 @@ const Toolbar = React.memo( updateView, updateViewDiff, display, - selectedNode, onFocusOnSubtree, + viewType, + viewSide, + toggleLinkedSearch, }: ProfileHeaderProps) => { const toolbarRef = React.useRef(); const showMode = useSizeMode(toolbarRef); @@ -101,6 +114,9 @@ const Toolbar = React.memo( { - onHighlightChange(e.target.value); - }} - /> + <> + { + onHighlightChange(e.target.value); + }} + /> + {searchInputType === 'linked' && ( +