diff --git a/src/components/app/ProfileFilterNavigator.tsx b/src/components/app/ProfileFilterNavigator.tsx index d4bb3873a8..93bcdc9839 100644 --- a/src/components/app/ProfileFilterNavigator.tsx +++ b/src/components/app/ProfileFilterNavigator.tsx @@ -9,13 +9,19 @@ import { showMenu } from '@firefox-devtools/react-contextmenu'; import classNames from 'classnames'; import explicitConnect from 'firefox-profiler/utils/connect'; -import { popCommittedRanges } from 'firefox-profiler/actions/profile-view'; +import { + popCommittedRanges, + updatePreviewSelection, + commitRange, +} from 'firefox-profiler/actions/profile-view'; import { getPreviewSelection, getProfileFilterPageDataByTabID, getProfileRootRange, getProfileTimelineUnit, + getCommittedRange, getCommittedRangeLabels, + getZeroAt, } from 'firefox-profiler/selectors/profile'; import { getTabFilter } from 'firefox-profiler/selectors/url-state'; import { getFormattedTimelineValue } from 'firefox-profiler/profile-logic/committed-ranges'; @@ -40,6 +46,8 @@ type Props = { type DispatchProps = { readonly onPop: Props['onPop']; + readonly updatePreviewSelection?: Props['updatePreviewSelection']; + readonly commitRange?: Props['commitRange']; }; type StateProps = Readonly>; @@ -79,6 +87,12 @@ class ProfileFilterNavigatorBarImpl extends React.PureComponent { pageDataByTabID, tabFilter, profileTimelineUnit, + committedRange, + previewSelection, + updatePreviewSelection, + commitRange, + uncommittedInputFieldRef, + zeroAt, } = this.props; let firstItem; @@ -169,6 +183,12 @@ class ProfileFilterNavigatorBarImpl extends React.PureComponent { selectedItem={selectedItem} uncommittedItem={uncommittedItem} onPop={onPop} + committedRange={committedRange} + previewSelection={previewSelection} + updatePreviewSelection={updatePreviewSelection} + commitRange={commitRange} + uncommittedInputFieldRef={uncommittedInputFieldRef} + zeroAt={zeroAt} /> {pageDataByTabID && pageDataByTabID.size > 0 ? ( @@ -178,12 +198,17 @@ class ProfileFilterNavigatorBarImpl extends React.PureComponent { } } +type OwnProps = { + readonly uncommittedInputFieldRef?: React.RefObject; +}; + export const ProfileFilterNavigator = explicitConnect< - {}, + OwnProps, StateProps, DispatchProps >({ mapStateToProps: (state) => { + const committedRange = getCommittedRange(state); const items = getCommittedRangeLabels(state); const previewSelection = getPreviewSelection(state); const profileTimelineUnit = getProfileTimelineUnit(state); @@ -193,6 +218,7 @@ export const ProfileFilterNavigator = explicitConnect< profileTimelineUnit ) : undefined; + const zeroAt = getZeroAt(state); const pageDataByTabID = getProfileFilterPageDataByTabID(state); const tabFilter = getTabFilter(state); @@ -208,10 +234,15 @@ export const ProfileFilterNavigator = explicitConnect< tabFilter, rootRange, profileTimelineUnit, + committedRange, + previewSelection, + zeroAt, }; }, mapDispatchToProps: { onPop: popCommittedRanges, + updatePreviewSelection, + commitRange, }, component: ProfileFilterNavigatorBarImpl, }); diff --git a/src/components/app/ProfileViewer.tsx b/src/components/app/ProfileViewer.tsx index dcfeafcb9c..0c446004d2 100644 --- a/src/components/app/ProfileViewer.tsx +++ b/src/components/app/ProfileViewer.tsx @@ -2,7 +2,7 @@ * license, v. 2.0. if a copy of the mpl was not distributed with this * file, you can obtain one at http://mozilla.org/mpl/2.0/. */ -import { PureComponent } from 'react'; +import * as React from 'react'; import explicitConnect from 'firefox-profiler/utils/connect'; import { DetailsContainer } from './DetailsContainer'; @@ -59,7 +59,16 @@ type DispatchProps = { type Props = ConnectedProps<{}, StateProps, DispatchProps>; -class ProfileViewerImpl extends PureComponent { +class ProfileViewerImpl extends React.PureComponent { + uncommittedInputFieldRef = React.createRef(); + + _onSelectionMove = () => { + if (!this.uncommittedInputFieldRef.current) { + return; + } + this.uncommittedInputFieldRef.current.blur(); + }; + override render() { const { hasZipFile, @@ -114,7 +123,9 @@ class ProfileViewerImpl extends PureComponent { /> ) : null} - + { // Define a spacer in the middle that will shrink based on the availability // of space in the top bar. It will shrink away before any of the items @@ -139,7 +150,7 @@ class ProfileViewerImpl extends PureComponent { secondaryInitialSize={270} onDragEnd={invalidatePanelLayout} > - + .nodeIcon { margin-inline-end: 5px; } diff --git a/src/components/shared/FilterNavigatorBar.tsx b/src/components/shared/FilterNavigatorBar.tsx index 12011ef64d..b3d34650cb 100644 --- a/src/components/shared/FilterNavigatorBar.tsx +++ b/src/components/shared/FilterNavigatorBar.tsx @@ -6,18 +6,81 @@ import * as React from 'react'; import classNames from 'classnames'; import './FilterNavigatorBar.css'; +import type { + commitRange, + updatePreviewSelection, +} from 'firefox-profiler/actions/profile-view'; + +import type { WrapFunctionInDispatch } from 'firefox-profiler/utils/connect'; +import type { + Milliseconds, + PreviewSelection, + StartEndRange, +} from 'firefox-profiler/types'; + +type UpdatePreviewSelection = typeof updatePreviewSelection; + type FilterNavigatorBarListItemProps = { readonly onClick?: null | ((index: number) => unknown); readonly index: number; readonly isFirstItem: boolean; readonly isLastItem: boolean; readonly isSelectedItem: boolean; + readonly isUncommittedItem: boolean; + readonly uncommittedValue?: string; readonly title?: string; readonly additionalClassName?: string; - readonly children: React.ReactNode; + readonly children?: React.ReactNode; + readonly updatePreviewSelection?: WrapFunctionInDispatch; + readonly commitRange?: typeof commitRange; + readonly uncommittedInputFieldRef?: React.RefObject; + readonly committedRange?: StartEndRange; + readonly previewSelection?: PreviewSelection | null; + readonly zeroAt?: Milliseconds; }; -class FilterNavigatorBarListItem extends React.PureComponent { +type FilterNavigatorBarListItemState = { + isFocused: boolean; + uncommittedValue: string; +}; + +function parseDuration(duration: string): number { + const m = duration.match(/([0-9.]+)([mu]?s)?/); + if (!m) { + return parseFloat(duration); + } + const num = m[1]; + const unit = m[2]; + let scale; + switch (unit) { + case 's': + scale = 1000; + break; + case 'ms': + scale = 1; + break; + case 'us': + scale = 0.001; + break; + default: + scale = 1; + break; + } + return parseFloat(num) * scale; +} + +class FilterNavigatorBarListItem extends React.PureComponent< + FilterNavigatorBarListItemProps, + FilterNavigatorBarListItemState +> { + constructor(props: FilterNavigatorBarListItemProps) { + super(props); + this.state = { + isFocused: false, + uncommittedValue: props.uncommittedValue || '', + }; + } + _onClick = () => { const { index, onClick } = this.props; if (onClick) { @@ -25,16 +88,107 @@ class FilterNavigatorBarListItem extends React.PureComponent) => { + this.setState({ + uncommittedValue: e.currentTarget.value, + }); + + const duration = parseDuration(e.currentTarget.value); + if (Number.isNaN(duration)) { + return; + } + + const { committedRange, previewSelection, updatePreviewSelection } = + this.props; + if (!committedRange || !previewSelection || !updatePreviewSelection) { + return; + } + + const { isModifying, selectionStart } = previewSelection; + + const selectionEnd = Math.min( + selectionStart + duration, + committedRange.end + ); + + updatePreviewSelection({ + isModifying, + selectionStart, + selectionEnd, + }); + }; + + _onUncommittedFieldFocus = (e: React.FocusEvent) => { + this.setState({ + uncommittedValue: e.currentTarget.value, + isFocused: true, + }); + }; + + _onUncommittedFieldBlur = () => { + this.setState({ + isFocused: false, + }); + }; + + _onUncommittedFieldSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + const { previewSelection, zeroAt, commitRange } = this.props; + if (!previewSelection || zeroAt === undefined || !commitRange) { + return; + } + + commitRange( + previewSelection.selectionStart - zeroAt, + previewSelection.selectionEnd - zeroAt + ); + }; + override render() { const { isFirstItem, isLastItem, isSelectedItem, + isUncommittedItem, children, additionalClassName, onClick, title, + uncommittedValue, + uncommittedInputFieldRef, } = this.props; + + let item; + if (onClick) { + item = ( + + ); + } else if (isUncommittedItem) { + item = ( +
+ +
+ ); + } else { + item = {children}; + } + return (
  • - {onClick ? ( - - ) : ( - {children} - )} + {item}
  • ); } @@ -63,12 +211,29 @@ type Props = { readonly onPop: (param: number) => void; readonly selectedItem: number; readonly uncommittedItem?: string; + readonly updatePreviewSelection?: WrapFunctionInDispatch; + readonly commitRange?: typeof commitRange; + readonly uncommittedInputFieldRef?: React.RefObject; + readonly committedRange?: StartEndRange; + readonly previewSelection?: PreviewSelection | null; + readonly zeroAt?: Milliseconds; }; export class FilterNavigatorBar extends React.PureComponent { override render() { - const { className, items, selectedItem, uncommittedItem, onPop } = - this.props; + const { + className, + items, + selectedItem, + uncommittedItem, + updatePreviewSelection, + commitRange, + zeroAt, + uncommittedInputFieldRef, + committedRange, + previewSelection, + onPop, + } = this.props; return (
      @@ -80,6 +245,7 @@ export class FilterNavigatorBar extends React.PureComponent { isFirstItem={i === 0} isLastItem={i === items.length - 1} isSelectedItem={i === selectedItem} + isUncommittedItem={false} > {item} @@ -90,11 +256,17 @@ export class FilterNavigatorBar extends React.PureComponent { isFirstItem={false} isLastItem={true} isSelectedItem={false} + isUncommittedItem={true} additionalClassName="filterNavigatorBarUncommittedItem" title={uncommittedItem} - > - {uncommittedItem} - + uncommittedValue={uncommittedItem} + updatePreviewSelection={updatePreviewSelection} + commitRange={commitRange} + uncommittedInputFieldRef={uncommittedInputFieldRef} + committedRange={committedRange} + previewSelection={previewSelection} + zeroAt={zeroAt} + > ) : null}
    ); diff --git a/src/components/timeline/FullTimeline.tsx b/src/components/timeline/FullTimeline.tsx index 97eb3b65d3..9a1725fa5a 100644 --- a/src/components/timeline/FullTimeline.tsx +++ b/src/components/timeline/FullTimeline.tsx @@ -50,6 +50,7 @@ import type { ConnectedProps } from 'firefox-profiler/utils/connect'; type OwnProps = { // This ref will be added to the inner container. readonly innerElementRef?: React.Ref; + readonly onSelectionMove?: () => void; }; type StateProps = { @@ -149,11 +150,12 @@ class FullTimelineImpl extends React.PureComponent { trackCount, changeRightClickedTrack, innerElementRef, + onSelectionMove, } = this.props; return ( <> - +
    {trackCount.total > 1 ? ( void; }; type StateProps = { @@ -98,7 +99,11 @@ class TimelineRulerAndSelection extends React.PureComponent { // browsers. event.preventDefault(); - const { committedRange } = this.props; + const { committedRange, onSelectionMove } = this.props; + if (onSelectionMove) { + onSelectionMove(); + } + const minSelectionStartWidth: CssPixels = 3; const mouseDownX = event.pageX; const mouseDownTime = @@ -286,7 +291,12 @@ class TimelineRulerAndSelection extends React.PureComponent { dx: number, isModifying: boolean ) { - const { committedRange, width, updatePreviewSelection } = this.props; + const { committedRange, width, updatePreviewSelection, onSelectionMove } = + this.props; + if (onSelectionMove) { + onSelectionMove(); + } + const delta = (dx / width) * (committedRange.end - committedRange.start); const selectionDeltas = selectionDeltasForDx(delta); let selectionStart = clamp( diff --git a/src/components/timeline/index.tsx b/src/components/timeline/index.tsx index 6aeb8e6c32..ab52922f49 100644 --- a/src/components/timeline/index.tsx +++ b/src/components/timeline/index.tsx @@ -5,7 +5,9 @@ import { PureComponent } from 'react'; import { FullTimeline } from 'firefox-profiler/components/timeline/FullTimeline'; -type TimelineProps = {}; +type TimelineProps = { + readonly onSelectionMove?: () => void; +}; export class Timeline extends PureComponent { // This may contain a function that's called whenever we want to remove the @@ -66,6 +68,13 @@ export class Timeline extends PureComponent { } override render() { - return ; + const { onSelectionMove } = this.props; + + return ( + + ); } } diff --git a/src/test/components/FilterNavigatorBar.test.tsx b/src/test/components/FilterNavigatorBar.test.tsx index b572bddc12..49d4fe0cc7 100644 --- a/src/test/components/FilterNavigatorBar.test.tsx +++ b/src/test/components/FilterNavigatorBar.test.tsx @@ -56,6 +56,110 @@ describe('shared/FilterNavigatorBar', () => { fireEvent.click(lastElement); expect(onPop).toHaveBeenCalledWith(1); }); + + it(`updates the preview selection when duration is modified`, () => { + const onPop = jest.fn(); + const updatePreviewSelection = jest.fn(); + const committedRange = { + start: 1000, + end: 3000, + }; + const previewSelection = { + isModifying: false, + selectionStart: 1050, + selectionEnd: 1060, + }; + + render( + + ); + + const uncommittedItem = screen.getByDisplayValue('10ms'); + + // Setting a valid value should update. + fireEvent.change(uncommittedItem, { + target: { value: '20ms' }, + }); + expect(updatePreviewSelection).toHaveBeenCalledWith({ + isModifying: false, + selectionStart: 1050, + selectionEnd: 1070, + }); + + fireEvent.change(uncommittedItem, { + target: { value: '20.5ms' }, + }); + expect(updatePreviewSelection).toHaveBeenCalledWith({ + isModifying: false, + selectionStart: 1050, + selectionEnd: 1070.5, + }); + + fireEvent.change(uncommittedItem, { + target: { value: '500us' }, + }); + expect(updatePreviewSelection).toHaveBeenCalledWith({ + isModifying: false, + selectionStart: 1050, + selectionEnd: 1050.5, + }); + + fireEvent.change(uncommittedItem, { + target: { value: '1.5s' }, + }); + expect(updatePreviewSelection).toHaveBeenCalledWith({ + isModifying: false, + selectionStart: 1050, + selectionEnd: 2550, + }); + + // The selectionEnd should not exceed the committedRange. + fireEvent.change(uncommittedItem, { + target: { value: '200s' }, + }); + expect(updatePreviewSelection).toHaveBeenCalledWith({ + isModifying: false, + selectionStart: 1050, + selectionEnd: 3000, + }); + }); + + it(`commits the range when duration is submitted`, () => { + const onPop = jest.fn(); + const commitRange = jest.fn(); + const previewSelection = { + isModifying: false, + selectionStart: 1050, + selectionEnd: 1060, + }; + + render( + + ); + + const uncommittedItem = screen.getByDisplayValue('10ms'); + + fireEvent.submit(uncommittedItem, {}); + expect(commitRange).toHaveBeenCalledWith(1050, 1060); + }); }); describe('app/ProfileFilterNavigator', () => { diff --git a/src/test/components/__snapshots__/FilterNavigatorBar.test.tsx.snap b/src/test/components/__snapshots__/FilterNavigatorBar.test.tsx.snap index b1d6e6c631..e0b9b6ee50 100644 --- a/src/test/components/__snapshots__/FilterNavigatorBar.test.tsx.snap +++ b/src/test/components/__snapshots__/FilterNavigatorBar.test.tsx.snap @@ -83,11 +83,14 @@ exports[`app/ProfileFilterNavigator renders ProfileFilterNavigator properly 3`] class="filterNavigatorBarItem filterNavigatorBarUncommittedItem filterNavigatorBarLeafItem" title="100μs" > - - 100μs - +
    + +
    `;