diff --git a/src/components/app/ProfileFilterNavigator.tsx b/src/components/app/ProfileFilterNavigator.tsx index dd1041d4da..e7c0ad583a 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>; @@ -83,6 +91,12 @@ class ProfileFilterNavigatorBarImpl extends React.PureComponent { pageDataByTabID, tabFilter, profileTimelineUnit, + committedRange, + previewSelection, + updatePreviewSelection, + commitRange, + uncommittedInputFieldRef, + zeroAt, } = this.props; let firstItem; @@ -180,6 +194,12 @@ class ProfileFilterNavigatorBarImpl extends React.PureComponent { onFirstItemClick={ isFirstItemClickable ? this._onFirstItemClick : undefined } + committedRange={committedRange} + previewSelection={previewSelection} + updatePreviewSelection={updatePreviewSelection} + commitRange={commitRange} + uncommittedInputFieldRef={uncommittedInputFieldRef} + zeroAt={zeroAt} /> {pageDataByTabID && pageDataByTabID.size > 0 ? ( @@ -189,12 +209,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); @@ -204,6 +229,7 @@ export const ProfileFilterNavigator = explicitConnect< profileTimelineUnit ) : undefined; + const zeroAt = getZeroAt(state); const pageDataByTabID = getProfileFilterPageDataByTabID(state); const tabFilter = getTabFilter(state); @@ -219,10 +245,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 d539b820f0..718fad3850 100644 --- a/src/components/shared/FilterNavigatorBar.tsx +++ b/src/components/shared/FilterNavigatorBar.tsx @@ -6,6 +6,20 @@ 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 @@ -14,12 +28,62 @@ type FilterNavigatorBarListItemProps = { 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; +}; + +type FilterNavigatorBarListItemState = { + isFocused: boolean; + uncommittedValue: string; }; -class FilterNavigatorBarListItem extends React.PureComponent { +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': + case 'μs': + 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 = (event: React.MouseEvent) => { const { index, onClick } = this.props; if (onClick) { @@ -27,16 +91,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}
  • ); } @@ -66,6 +215,12 @@ type Props = { readonly onFirstItemClick?: (event: React.MouseEvent) => 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 { @@ -88,6 +243,12 @@ export class FilterNavigatorBar extends React.PureComponent { selectedItem, uncommittedItem, onFirstItemClick, + updatePreviewSelection, + commitRange, + zeroAt, + uncommittedInputFieldRef, + committedRange, + previewSelection, } = this.props; return ( @@ -110,6 +271,7 @@ export class FilterNavigatorBar extends React.PureComponent { isFirstItem={i === 0} isLastItem={i === items.length - 1} isSelectedItem={i === selectedItem} + isUncommittedItem={false} > {item} @@ -121,11 +283,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 22fe8cadea..c92c335b0c 100644 --- a/src/test/components/FilterNavigatorBar.test.tsx +++ b/src/test/components/FilterNavigatorBar.test.tsx @@ -56,6 +56,182 @@ 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' + ) as HTMLInputElement; + fireEvent.focus(uncommittedItem); + + // Setting a valid value should update. + fireEvent.change(uncommittedItem, { + target: { value: '20ms' }, + }); + expect(updatePreviewSelection).toHaveBeenCalledWith({ + isModifying: false, + selectionStart: 1050, + selectionEnd: 1070, + }); + expect(uncommittedItem.value).toBe('20ms'); + + 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, + }); + + fireEvent.change(uncommittedItem, { + target: { value: '110' }, + }); + expect(updatePreviewSelection).toHaveBeenCalledWith({ + isModifying: false, + selectionStart: 1050, + selectionEnd: 1160, + }); + + // Setting invalid value shouldn't trigger update, + // and the next valid value should be reflected. + fireEvent.change(uncommittedItem, { + target: { value: 'a' }, + }); + fireEvent.change(uncommittedItem, { + target: { value: '100' }, + }); + expect(updatePreviewSelection).toHaveBeenCalledWith({ + isModifying: false, + selectionStart: 1050, + selectionEnd: 1150, + }); + + // 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' + ) as HTMLInputElement; + + fireEvent.submit(uncommittedItem, {}); + expect(commitRange).toHaveBeenCalledWith(1050, 1060); + }); + + it(`resets to the unmodified value on blur`, () => { + 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' + ) as HTMLInputElement; + fireEvent.focus(uncommittedItem); + + fireEvent.change(uncommittedItem, { + target: { value: '20ms' }, + }); + expect(uncommittedItem.value).toBe('20ms'); + + fireEvent.blur(uncommittedItem); + expect(uncommittedItem.value).toBe('10ms'); + + fireEvent.focus(uncommittedItem); + expect(uncommittedItem.value).toBe('10ms'); + }); }); describe('app/ProfileFilterNavigator', () => { diff --git a/src/test/components/ProfileViewer.test.tsx b/src/test/components/ProfileViewer.test.tsx index e5fba9e71f..adec6b603f 100644 --- a/src/test/components/ProfileViewer.test.tsx +++ b/src/test/components/ProfileViewer.test.tsx @@ -4,7 +4,10 @@ import ReactDOM from 'react-dom'; import { Provider } from 'react-redux'; -import { render } from 'firefox-profiler/test/fixtures/testing-library'; +import { + render, + fireEvent, +} from 'firefox-profiler/test/fixtures/testing-library'; import { ProfileViewer } from 'firefox-profiler/components/app/ProfileViewer'; import { getTimelineHeight } from 'firefox-profiler/selectors/app'; import { updateUrlState } from 'firefox-profiler/actions/app'; @@ -68,4 +71,136 @@ describe('ProfileViewer', function () { // Note: You should update this total height if you changed the height calculation algorithm. expect(getTimelineHeight(getState())).toBe(1224); }); + + it('unfocuses the uncommited input field on selection move', () => { + const { container } = setup(); + + const selection = container.querySelector('.timelineSelection')!; + + jest.spyOn(Element.prototype, 'getClientRects').mockImplementation( + jest.fn(function () { + return { + item() { + return { x: 0, y: 0, width: 100, height: 100 } as DOMRect; + }, + length: 1, + '0': { x: 0, y: 0, width: 100, height: 100 } as DOMRect, + [Symbol.iterator]() { + // The iterator is not used. + // Defined here just to make the tsc happy. + return { + next() { + return { + done: true, + }; + }, + } as ArrayIterator; + }, + } as DOMRectList; + }) + ); + + fireEvent.mouseDown(selection, { + button: 0, + buttons: 1, + clientX: 10, + clientY: 10, + }); + + fireEvent.mouseMove(selection, { + button: 0, + buttons: 1, + clientX: 20, + clientY: 10, + }); + + fireEvent.mouseUp(selection, { + button: 0, + buttons: 1, + clientX: 20, + clientY: 10, + }); + + const uncommittedItem = container.querySelector( + '.filterNavigatorBarItemUncommittedFieldInput' + )! as HTMLInputElement; + expect(uncommittedItem.value).toBe('100μs'); + + fireEvent.focus(uncommittedItem); + + fireEvent.change(uncommittedItem, { + target: { value: '200us' }, + }); + expect(uncommittedItem.value).toBe('200us'); + + const blur = jest.fn(); + jest.spyOn(HTMLElement.prototype, 'blur').mockImplementation(blur); + + fireEvent.mouseDown(selection, { + button: 0, + buttons: 1, + clientX: 10, + clientY: 10, + }); + + fireEvent.mouseMove(selection, { + button: 0, + buttons: 1, + clientX: 50, + clientY: 10, + }); + + fireEvent.mouseUp(selection, { + button: 0, + buttons: 1, + clientX: 50, + clientY: 10, + }); + + // Due to the restriction on the mock, blur() call does not trigger + // blur event. + expect(blur).toHaveBeenCalled(); + fireEvent.blur(uncommittedItem); + + expect(uncommittedItem.value).toBe('400μs'); + + fireEvent.focus(uncommittedItem); + + fireEvent.change(uncommittedItem, { + target: { value: '200us' }, + }); + expect(uncommittedItem.value).toBe('200us'); + + const grip = container.querySelector( + '.timelineSelectionGrippyRangeStart' + ) as HTMLElement; + + fireEvent.mouseDown(grip, { + button: 0, + buttons: 1, + clientX: 10, + clientY: 10, + }); + + fireEvent.mouseMove(grip, { + button: 0, + buttons: 1, + clientX: 50, + clientY: 10, + }); + + fireEvent.mouseUp(grip, { + button: 0, + buttons: 1, + clientX: 50, + clientY: 10, + }); + + // Due to the restriction on the mock, blur() call does not trigger + // blur event. + expect(blur).toHaveBeenCalled(); + fireEvent.blur(uncommittedItem); + + expect(uncommittedItem.value).toBe('67μs'); + }); }); diff --git a/src/test/components/__snapshots__/FilterNavigatorBar.test.tsx.snap b/src/test/components/__snapshots__/FilterNavigatorBar.test.tsx.snap index e4643957e5..b36c60ca78 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 - +
    + +
    `;