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' && (
+
+ )}
+ >
);
}
diff --git a/webapp/javascript/components/__snapshots__/Toolbar.spec.tsx.snap b/webapp/javascript/components/__snapshots__/Toolbar.spec.tsx.snap
index 6863d71ab4..2c5692e50f 100644
--- a/webapp/javascript/components/__snapshots__/Toolbar.spec.tsx.snap
+++ b/webapp/javascript/components/__snapshots__/Toolbar.spec.tsx.snap
@@ -10,7 +10,7 @@ exports[`ProfileHeader shifts between visualization modes 1`] = `
class="navbar"
>
{
+ state.linkedSearchQuery = action.payload;
+ },
+
+ toggleLinkedSearch: (state, action) => {
+ const { isSearchLinked } = state;
+
+ if (isSearchLinked === false) {
+ state.isSearchLinked = true;
+ state.resetLinkedSearchSide = '';
+ } else {
+ switch (action.payload) {
+ case 'left':
+ state.resetLinkedSearchSide = 'right';
+ break;
+
+ case 'right':
+ state.resetLinkedSearchSide = 'left';
+
+ break;
+
+ case 'both':
+ state.isSearchLinked = false;
+ state.resetLinkedSearchSide = '';
+ break;
+
+ default:
+ break;
+ }
+ }
+ },
+ },
+});
+export const { setSearchQuery, toggleLinkedSearch } = searchSlice.actions;
+
+export const isSearchLinked = (state: RootState) => state.search.isSearchLinked;
+export const linkedSearchQuery = (state: RootState) =>
+ state.search.linkedSearchQuery;
+export const resetLinkedSearchSide = (state: RootState) =>
+ state.search.resetLinkedSearchSide;
+
+export default searchSlice.reducer;
diff --git a/webapp/javascript/redux/store.ts b/webapp/javascript/redux/store.ts
index 187e28a64f..021d163244 100644
--- a/webapp/javascript/redux/store.ts
+++ b/webapp/javascript/redux/store.ts
@@ -9,6 +9,7 @@ import rootReducer from './reducers';
import history from '../util/history';
import viewsReducer from './reducers/views';
+import searchReducer from './reducers/search';
import newRootStore from './reducers/newRoot';
import {
@@ -35,6 +36,7 @@ const store = configureStore({
newRoot: newRootStore,
root: rootReducer,
views: viewsReducer,
+ search: searchReducer,
},
// middleware: [thunkMiddleware],
});
diff --git a/webapp/javascript/ui/Button.tsx b/webapp/javascript/ui/Button.tsx
index db6e59f633..276703bc76 100644
--- a/webapp/javascript/ui/Button.tsx
+++ b/webapp/javascript/ui/Button.tsx
@@ -26,6 +26,8 @@ export interface ButtonProps {
className?: string;
id?: string;
+
+ title?: string;
}
export default function Button({
@@ -38,6 +40,7 @@ export default function Button({
onClick,
id,
className,
+ title,
...props
}: ButtonProps) {
return (
@@ -51,6 +54,7 @@ export default function Button({
className={`${styles.button} ${
grouped ? styles.grouped : ''
} ${getKindStyles(kind)} ${className}`}
+ title={title}
>
{icon ? (