diff --git a/packages/grafana-ui/src/components/Collapse/Collapse.tsx b/packages/grafana-ui/src/components/Collapse/Collapse.tsx index 79453de0984..753b1c9fe84 100644 --- a/packages/grafana-ui/src/components/Collapse/Collapse.tsx +++ b/packages/grafana-ui/src/components/Collapse/Collapse.tsx @@ -83,6 +83,8 @@ const getStyles = (theme: GrafanaTheme2) => ({ font-weight: ${theme.typography.fontWeightMedium}; margin-right: ${theme.spacing(1)}; font-size: ${theme.typography.size.md}; + display: flex; + flex: 0 0 100%; `, icon: css` label: collapse__icon; diff --git a/public/app/features/explore/RawPrometheus/RawPrometheusContainer.tsx b/public/app/features/explore/RawPrometheus/RawPrometheusContainer.tsx index a4b226c8578..d330b7ce167 100644 --- a/public/app/features/explore/RawPrometheus/RawPrometheusContainer.tsx +++ b/public/app/features/explore/RawPrometheus/RawPrometheusContainer.tsx @@ -81,6 +81,7 @@ export class RawPrometheusContainer extends PureComponent> = TABLE_RESULTS_STYLES.map((style) => ({ value: style, diff --git a/public/app/features/explore/TraceView/TraceViewContainer.test.tsx b/public/app/features/explore/TraceView/TraceViewContainer.test.tsx index 118acbc24e5..696d4325ec7 100644 --- a/public/app/features/explore/TraceView/TraceViewContainer.test.tsx +++ b/public/app/features/explore/TraceView/TraceViewContainer.test.tsx @@ -136,13 +136,13 @@ describe('TraceViewContainer', () => { it('can select next/prev results', async () => { config.featureToggles.newTraceViewHeader = true; renderTraceViewContainer(); - const spanFiltersButton = screen.getByRole('button', { name: 'Span Filters' }); + const spanFiltersButton = screen.getByRole('button', { name: 'Span Filters 3 spans Prev Next' }); await user.click(spanFiltersButton); const nextResultButton = screen.getByRole('button', { name: 'Next result button' }); const prevResultButton = screen.getByRole('button', { name: 'Prev result button' }); - expect((nextResultButton as HTMLButtonElement)['disabled']).toBe(true); - expect((prevResultButton as HTMLButtonElement)['disabled']).toBe(true); + expect(nextResultButton.getAttribute('tabindex')).toBe('-1'); + expect(prevResultButton.getAttribute('tabindex')).toBe('-1'); await user.click(screen.getByLabelText('Select tag key')); const tagOption = screen.getByText('component'); @@ -161,8 +161,8 @@ describe('TraceViewContainer', () => { ).toContain('rowMatchingFilter'); }); - expect((nextResultButton as HTMLButtonElement)['disabled']).toBe(false); - expect((prevResultButton as HTMLButtonElement)['disabled']).toBe(false); + expect(nextResultButton.getAttribute('tabindex')).toBe('0'); + expect(prevResultButton.getAttribute('tabindex')).toBe('0'); await user.click(nextResultButton); await waitFor(() => { expect( @@ -186,7 +186,7 @@ describe('TraceViewContainer', () => { it('show matches only works as expected', async () => { config.featureToggles.newTraceViewHeader = true; renderTraceViewContainer(); - const spanFiltersButton = screen.getByRole('button', { name: 'Span Filters' }); + const spanFiltersButton = screen.getByRole('button', { name: 'Span Filters 3 spans Prev Next' }); await user.click(spanFiltersButton); await user.click(screen.getByLabelText('Select tag key')); diff --git a/public/app/features/explore/TraceView/TraceViewContainer.tsx b/public/app/features/explore/TraceView/TraceViewContainer.tsx index 29668965cd3..789440cc7ce 100644 --- a/public/app/features/explore/TraceView/TraceViewContainer.tsx +++ b/public/app/features/explore/TraceView/TraceViewContainer.tsx @@ -7,7 +7,7 @@ import { useStyles2 } from '@grafana/ui'; import { StoreState, useSelector } from 'app/types'; import { TraceView } from './TraceView'; -import TracePageSearchBar from './components/TracePageHeader/TracePageSearchBar'; +import TracePageSearchBar from './components/TracePageHeader/SearchBar/TracePageSearchBar'; import { TopOfViewRefType } from './components/TraceTimelineViewer/VirtualizedTraceView'; import { useSearch } from './useSearch'; import { transformDataFrames } from './utils/transform'; diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageSearchBar.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageSearchBar.tsx deleted file mode 100644 index d4c74114a22..00000000000 --- a/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageSearchBar.tsx +++ /dev/null @@ -1,236 +0,0 @@ -// Copyright (c) 2018 Uber Technologies, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { css } from '@emotion/css'; -import React, { memo, Dispatch, SetStateAction, useEffect, useMemo, useState } from 'react'; - -import { GrafanaTheme2 } from '@grafana/data'; -import { config, reportInteraction } from '@grafana/runtime'; -import { Button, Icon, Switch, Tooltip, useStyles2 } from '@grafana/ui'; - -import { SearchProps } from '../../useSearch'; -import { convertTimeFilter } from '../utils/filter-spans'; - -export type TracePageSearchBarProps = { - search: SearchProps; - spanFilterMatches: Set | undefined; - showSpanFilterMatchesOnly: boolean; - setShowSpanFilterMatchesOnly: (showMatchesOnly: boolean) => void; - setFocusedSpanIdForSearch: Dispatch>; - datasourceType: string; - clear: () => void; - totalSpans: number; -}; - -export default memo(function NewTracePageSearchBar(props: TracePageSearchBarProps) { - const { - search, - spanFilterMatches, - showSpanFilterMatchesOnly, - setShowSpanFilterMatchesOnly, - setFocusedSpanIdForSearch, - datasourceType, - clear, - totalSpans, - } = props; - const [currentSpanIndex, setCurrentSpanIndex] = useState(-1); - const styles = useStyles2(getStyles); - - useEffect(() => { - setCurrentSpanIndex(-1); - setFocusedSpanIdForSearch(''); - }, [setFocusedSpanIdForSearch, spanFilterMatches]); - - useEffect(() => { - if (spanFilterMatches) { - const spanMatches = Array.from(spanFilterMatches!); - setFocusedSpanIdForSearch(spanMatches[currentSpanIndex]); - } - }, [currentSpanIndex, setFocusedSpanIdForSearch, spanFilterMatches]); - - const nextResult = () => { - reportInteraction('grafana_traces_trace_view_find_next_prev_clicked', { - datasourceType: datasourceType, - grafana_version: config.buildInfo.version, - direction: 'next', - }); - - // new query || at end, go to start - if (currentSpanIndex === -1 || (spanFilterMatches && currentSpanIndex === spanFilterMatches.size - 1)) { - setCurrentSpanIndex(0); - return; - } - - // get next - setCurrentSpanIndex(currentSpanIndex + 1); - }; - - const prevResult = () => { - reportInteraction('grafana_traces_trace_view_find_next_prev_clicked', { - datasourceType: datasourceType, - grafana_version: config.buildInfo.version, - direction: 'prev', - }); - - // new query || at start, go to end - if (spanFilterMatches && (currentSpanIndex === -1 || currentSpanIndex === 0)) { - setCurrentSpanIndex(spanFilterMatches.size - 1); - return; - } - - // get prev - setCurrentSpanIndex(currentSpanIndex - 1); - }; - - const buttonEnabled = spanFilterMatches && spanFilterMatches?.size > 0; - const clearEnabled = useMemo(() => { - return ( - (search.serviceName && search.serviceName !== '') || - (search.spanName && search.spanName !== '') || - convertTimeFilter(search.from || '') || - convertTimeFilter(search.to || '') || - search.tags.length > 1 || - search.tags.some((tag) => { - return tag.key; - }) - ); - }, [search.serviceName, search.spanName, search.from, search.to, search.tags]); - - const amountText = spanFilterMatches?.size === 1 ? 'match' : 'matches'; - const matches = - spanFilterMatches?.size === 0 ? ( - <> - 0 matches - - - - - - - ) : currentSpanIndex !== -1 ? ( - `${currentSpanIndex + 1}/${spanFilterMatches?.size} ${amountText}` - ) : ( - `${spanFilterMatches?.size} ${amountText}` - ); - - return ( -
-
- <> -
- -
- setShowSpanFilterMatchesOnly(value.currentTarget.checked ?? false)} - label="Show matches only switch" - /> - -
-
-
- {spanFilterMatches ? matches : `${totalSpans} spans`} - - -
- -
-
- ); -}); - -export const getStyles = (theme: GrafanaTheme2) => { - return { - searchBar: css` - display: inline; - `, - matchesOnly: css` - display: inline-flex; - margin: 0 0 0 10px; - vertical-align: middle; - align-items: center; - - span { - cursor: pointer; - } - `, - buttons: css` - display: flex; - justify-content: flex-end; - margin: 5px 0 0 0; - `, - clearButton: css` - order: 1; - `, - clearMatchesButton: css` - color: ${theme.colors.text.primary}; - &:hover { - background: inherit; - color: inherit; - } - `, - nextPrevButtons: css` - margin-left: auto; - order: 2; - - button { - margin-left: 8px; - } - `, - matches: css` - margin-right: 5px; - `, - matchesTooltip: css` - color: #aaa; - margin: -2px 0 0 10px; - `, - }; -}; diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/NewTracePageSearchBar.test.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/NewTracePageSearchBar.test.tsx new file mode 100644 index 00000000000..2a252b606d8 --- /dev/null +++ b/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/NewTracePageSearchBar.test.tsx @@ -0,0 +1,57 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { defaultFilters } from '../../../useSearch'; + +import NewTracePageSearchBar from './NewTracePageSearchBar'; + +describe('', () => { + const NewTracePageSearchBarWithProps = (props: { matches: string[] | undefined }) => { + const searchBarProps = { + search: defaultFilters, + spanFilterMatches: props.matches ? new Set(props.matches) : undefined, + showSpanFilterMatchesOnly: false, + setShowSpanFilterMatchesOnly: jest.fn(), + setFocusedSpanIdForSearch: jest.fn(), + focusedSpanIndexForSearch: -1, + setFocusedSpanIndexForSearch: jest.fn(), + datasourceType: '', + clear: jest.fn(), + totalSpans: 100, + showSpanFilters: true, + }; + + return ; + }; + + it('should render', () => { + expect(() => render()).not.toThrow(); + }); + + it('renders clear filter button', () => { + render(); + const clearFiltersButton = screen.getByRole('button', { name: 'Clear filters button' }); + expect(clearFiltersButton).toBeInTheDocument(); + expect((clearFiltersButton as HTMLButtonElement)['disabled']).toBe(true); + }); + + it('renders show span filter matches only switch', async () => { + render(); + const matchesSwitch = screen.getByRole('checkbox', { name: 'Show matches only switch' }); + expect(matchesSwitch).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/NewTracePageSearchBar.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/NewTracePageSearchBar.tsx new file mode 100644 index 00000000000..fa649bcd4d9 --- /dev/null +++ b/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/NewTracePageSearchBar.tsx @@ -0,0 +1,158 @@ +// Copyright (c) 2018 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { css } from '@emotion/css'; +import React, { memo, Dispatch, SetStateAction, useMemo } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Button, Switch, useStyles2 } from '@grafana/ui'; +import { getButtonStyles } from '@grafana/ui/src/components/Button'; + +import { SearchProps } from '../../../useSearch'; +import { convertTimeFilter } from '../../utils/filter-spans'; + +import NextPrevResult from './NextPrevResult'; + +export type TracePageSearchBarProps = { + search: SearchProps; + spanFilterMatches: Set | undefined; + showSpanFilterMatchesOnly: boolean; + setShowSpanFilterMatchesOnly: (showMatchesOnly: boolean) => void; + focusedSpanIndexForSearch: number; + setFocusedSpanIndexForSearch: Dispatch>; + setFocusedSpanIdForSearch: Dispatch>; + datasourceType: string; + totalSpans: number; + clear: () => void; + showSpanFilters: boolean; +}; + +export default memo(function NewTracePageSearchBar(props: TracePageSearchBarProps) { + const { + search, + spanFilterMatches, + showSpanFilterMatchesOnly, + setShowSpanFilterMatchesOnly, + focusedSpanIndexForSearch, + setFocusedSpanIndexForSearch, + setFocusedSpanIdForSearch, + datasourceType, + totalSpans, + clear, + showSpanFilters, + } = props; + const styles = useStyles2(getStyles); + + const clearEnabled = useMemo(() => { + return ( + (search.serviceName && search.serviceName !== '') || + (search.spanName && search.spanName !== '') || + convertTimeFilter(search.from || '') || + convertTimeFilter(search.to || '') || + search.tags.length > 1 || + search.tags.some((tag) => { + return tag.key; + }) + ); + }, [search.serviceName, search.spanName, search.from, search.to, search.tags]); + + return ( +
+
+ <> +
+ +
+ setShowSpanFilterMatchesOnly(value.currentTarget.checked ?? false)} + label="Show matches only switch" + /> + +
+
+
+ +
+ +
+
+ ); +}); + +export const getStyles = (theme: GrafanaTheme2) => { + const buttonStyles = getButtonStyles({ theme, variant: 'secondary', size: 'md', iconOnly: false, fill: 'outline' }); + + return { + button: css(buttonStyles.button), + buttonDisabled: css(buttonStyles.disabled, { pointerEvents: 'none', cursor: 'not-allowed' }), + container: css` + display: inline; + `, + controls: css` + display: flex; + justify-content: flex-end; + margin: 5px 0 0 0; + `, + clearButton: css` + order: 1; + `, + matchesOnly: css` + display: inline-flex; + margin: 0 0 0 10px; + vertical-align: middle; + align-items: center; + + span { + cursor: pointer; + margin: 0 0 0 5px; + } + `, + clearMatchesButton: css` + color: ${theme.colors.text.primary}; + &:hover { + background: inherit; + color: inherit; + } + `, + nextPrevResult: css` + margin-left: auto; + order: 2; + `, + }; +}; diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageSearchBar.test.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/NextPrevResult.test.tsx similarity index 66% rename from public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageSearchBar.test.tsx rename to public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/NextPrevResult.test.tsx index 411a7b9b55b..149dbcb0616 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageSearchBar.test.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/NextPrevResult.test.tsx @@ -14,15 +14,15 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import React from 'react'; +import React, { useState } from 'react'; import { createTheme } from '@grafana/data'; -import { defaultFilters } from '../../useSearch'; +import { defaultFilters } from '../../../useSearch'; -import NewTracePageSearchBar, { getStyles } from './NewTracePageSearchBar'; +import NextPrevResult, { getStyles } from './NextPrevResult'; -describe('', () => { +describe('', () => { let user: ReturnType; beforeEach(() => { jest.useFakeTimers(); @@ -34,56 +34,58 @@ describe('', () => { jest.useRealTimers(); }); - const NewTracePageSearchBarWithProps = (props: { matches: string[] | undefined }) => { + const NextPrevResultWithProps = (props: { matches: string[] | undefined }) => { + const [focusedSpanIndexForSearch, setFocusedSpanIndexForSearch] = useState(-1); const searchBarProps = { search: defaultFilters, spanFilterMatches: props.matches ? new Set(props.matches) : undefined, showSpanFilterMatchesOnly: false, setShowSpanFilterMatchesOnly: jest.fn(), setFocusedSpanIdForSearch: jest.fn(), + focusedSpanIndexForSearch: focusedSpanIndexForSearch, + setFocusedSpanIndexForSearch: setFocusedSpanIndexForSearch, datasourceType: '', clear: jest.fn(), totalSpans: 100, + showSpanFilters: true, }; - return ; + return ; }; it('should render', () => { - expect(() => render()).not.toThrow(); + expect(() => render()).not.toThrow(); }); - it('renders buttons', () => { - render(); + it('renders UI properly', () => { + render(); const nextResButton = screen.queryByRole('button', { name: 'Next result button' }); const prevResButton = screen.queryByRole('button', { name: 'Prev result button' }); - const clearFiltersButton = screen.getByRole('button', { name: 'Clear filters button' }); expect(nextResButton).toBeInTheDocument(); expect(prevResButton).toBeInTheDocument(); - expect(clearFiltersButton).toBeInTheDocument(); - expect((nextResButton as HTMLButtonElement)['disabled']).toBe(true); - expect((prevResButton as HTMLButtonElement)['disabled']).toBe(true); - expect((clearFiltersButton as HTMLButtonElement)['disabled']).toBe(true); + expect(nextResButton as HTMLDivElement).toHaveStyle('pointer-events: none'); + expect(prevResButton as HTMLDivElement).toHaveStyle('pointer-events: none'); + expect(screen.getByText('0 matches')).toBeDefined(); }); it('renders total spans', async () => { - render(); + render(); expect(screen.getByText('100 spans')).toBeDefined(); }); it('renders buttons that can be used to search if filters added', () => { - render(); + render(); const nextResButton = screen.queryByRole('button', { name: 'Next result button' }); const prevResButton = screen.queryByRole('button', { name: 'Prev result button' }); expect(nextResButton).toBeInTheDocument(); expect(prevResButton).toBeInTheDocument(); - expect((nextResButton as HTMLButtonElement)['disabled']).toBe(false); - expect((prevResButton as HTMLButtonElement)['disabled']).toBe(false); + expect(nextResButton as HTMLDivElement).not.toHaveStyle('pointer-events: none'); + expect(prevResButton as HTMLDivElement).not.toHaveStyle('pointer-events: none'); expect(screen.getByText('1 match')).toBeDefined(); }); it('renders correctly when moving through matches', async () => { - render(); + render(); const nextResButton = screen.queryByRole('button', { name: 'Next result button' }); const prevResButton = screen.queryByRole('button', { name: 'Prev result button' }); expect(screen.getByText('3 matches')).toBeDefined(); @@ -102,9 +104,9 @@ describe('', () => { }); it('renders correctly when there are no matches i.e. too many filters added', async () => { - const { container } = render(); - const styles = getStyles(createTheme()); - const tooltip = container.querySelector('.' + styles.matchesTooltip); + const { container } = render(); + const theme = createTheme(); + const tooltip = container.querySelector('.' + getStyles(theme, true).matchesTooltip); expect(screen.getByText('0 matches')).toBeDefined(); userEvent.hover(tooltip!); jest.advanceTimersByTime(1000); @@ -112,10 +114,4 @@ describe('', () => { expect(screen.getByText(/0 span matches for the filters selected/)).toBeDefined(); }); }); - - it('renders show span filter matches only switch', async () => { - render(); - const matchesSwitch = screen.getByRole('checkbox', { name: 'Show matches only switch' }); - expect(matchesSwitch).toBeInTheDocument(); - }); }); diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/NextPrevResult.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/NextPrevResult.tsx new file mode 100644 index 00000000000..79230e4a76c --- /dev/null +++ b/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/NextPrevResult.tsx @@ -0,0 +1,194 @@ +// Copyright (c) 2018 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { css, cx } from '@emotion/css'; +import React, { memo, Dispatch, SetStateAction, useEffect } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { config, reportInteraction } from '@grafana/runtime'; +import { Icon, Tooltip, useTheme2 } from '@grafana/ui'; +import { getButtonStyles } from '@grafana/ui/src/components/Button'; + +export type NextPrevResultProps = { + spanFilterMatches: Set | undefined; + setFocusedSpanIdForSearch: Dispatch>; + focusedSpanIndexForSearch: number; + setFocusedSpanIndexForSearch: Dispatch>; + datasourceType: string; + totalSpans: number; + showSpanFilters: boolean; +}; + +export default memo(function NextPrevResult(props: NextPrevResultProps) { + const { + spanFilterMatches, + setFocusedSpanIdForSearch, + focusedSpanIndexForSearch, + setFocusedSpanIndexForSearch, + datasourceType, + totalSpans, + showSpanFilters, + } = props; + const styles = getStyles(useTheme2(), showSpanFilters); + + useEffect(() => { + if (spanFilterMatches && focusedSpanIndexForSearch !== -1) { + const spanMatches = Array.from(spanFilterMatches!); + setFocusedSpanIdForSearch(spanMatches[focusedSpanIndexForSearch]); + } + }, [focusedSpanIndexForSearch, setFocusedSpanIdForSearch, spanFilterMatches]); + + const nextResult = (event: React.UIEvent, buttonEnabled: boolean) => { + event.preventDefault(); + event.stopPropagation(); + + if (buttonEnabled) { + reportInteraction('grafana_traces_trace_view_find_next_prev_clicked', { + datasourceType: datasourceType, + grafana_version: config.buildInfo.version, + direction: 'next', + }); + + // new query || at end, go to start + if ( + focusedSpanIndexForSearch === -1 || + (spanFilterMatches && focusedSpanIndexForSearch === spanFilterMatches.size - 1) + ) { + setFocusedSpanIndexForSearch(0); + return; + } + + // get next + setFocusedSpanIndexForSearch(focusedSpanIndexForSearch + 1); + } + }; + + const prevResult = (event: React.UIEvent, buttonEnabled: boolean) => { + event.preventDefault(); + event.stopPropagation(); + + if (buttonEnabled) { + reportInteraction('grafana_traces_trace_view_find_next_prev_clicked', { + datasourceType: datasourceType, + grafana_version: config.buildInfo.version, + direction: 'prev', + }); + + // new query || at start, go to end + if (spanFilterMatches && (focusedSpanIndexForSearch === -1 || focusedSpanIndexForSearch === 0)) { + setFocusedSpanIndexForSearch(spanFilterMatches.size - 1); + return; + } + + // get prev + setFocusedSpanIndexForSearch(focusedSpanIndexForSearch - 1); + } + }; + + const nextResultOnKeyDown = (event: React.KeyboardEvent, buttonEnabled: boolean) => { + if (event.key === 'Enter') { + nextResult(event, buttonEnabled); + } + }; + + const prevResultOnKeyDown = (event: React.KeyboardEvent, buttonEnabled: boolean) => { + if (event.key === 'Enter') { + prevResult(event, buttonEnabled); + } + }; + + const buttonEnabled = (spanFilterMatches && spanFilterMatches?.size > 0) ?? false; + const amountText = spanFilterMatches?.size === 1 ? 'match' : 'matches'; + const matches = + spanFilterMatches?.size === 0 ? ( + <> + 0 matches + + + + + + + ) : focusedSpanIndexForSearch !== -1 ? ( + `${focusedSpanIndexForSearch + 1}/${spanFilterMatches?.size} ${amountText}` + ) : ( + `${spanFilterMatches?.size} ${amountText}` + ); + const buttonClass = buttonEnabled ? styles.button : cx(styles.button, styles.buttonDisabled); + + return ( + <> + {spanFilterMatches ? matches : `${totalSpans} spans`} +
+
prevResult(event, buttonEnabled)} + onKeyDown={(event) => prevResultOnKeyDown(event, buttonEnabled)} + role="button" + tabIndex={buttonEnabled ? 0 : -1} + > + Prev +
+
nextResult(event, buttonEnabled)} + onKeyDown={(event) => nextResultOnKeyDown(event, buttonEnabled)} + role="button" + tabIndex={buttonEnabled ? 0 : -1} + > + Next +
+
+ + ); +}); + +export const getStyles = (theme: GrafanaTheme2, showSpanFilters: boolean) => { + const buttonStyles = getButtonStyles({ + theme, + variant: 'secondary', + size: showSpanFilters ? 'md' : 'sm', + iconOnly: false, + fill: 'outline', + }); + + return { + buttons: css` + display: inline-flex; + gap: 4px; + `, + buttonsDisabled: css` + cursor: not-allowed; + `, + button: css` + ${buttonStyles.button}; + `, + buttonDisabled: css` + ${buttonStyles.disabled}; + pointer-events: none; + `, + matches: css` + margin-right: ${theme.spacing(2)}; + `, + matchesTooltip: css` + color: #aaa; + margin: -2px 0 0 10px; + `, + }; +}; diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/TracePageSearchBar.test.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/TracePageSearchBar.test.tsx similarity index 100% rename from public/app/features/explore/TraceView/components/TracePageHeader/TracePageSearchBar.test.tsx rename to public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/TracePageSearchBar.test.tsx diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/TracePageSearchBar.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/TracePageSearchBar.tsx similarity index 98% rename from public/app/features/explore/TraceView/components/TracePageHeader/TracePageSearchBar.tsx rename to public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/TracePageSearchBar.tsx index 2c6838f9c21..84dfa6234ec 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/TracePageSearchBar.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/TracePageSearchBar.tsx @@ -20,8 +20,8 @@ import { GrafanaTheme2 } from '@grafana/data'; import { config, reportInteraction } from '@grafana/runtime'; import { Button, useStyles2 } from '@grafana/ui'; -import SearchBarInput from '../common/SearchBarInput'; -import { ubFlexAuto, ubJustifyEnd } from '../uberUtilityStyles'; +import SearchBarInput from '../../common/SearchBarInput'; +import { ubFlexAuto, ubJustifyEnd } from '../../uberUtilityStyles'; // eslint-disable-next-line no-duplicate-imports diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.test.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.test.tsx index ed7894d742b..a6e2fe2f46b 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.test.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.test.tsx @@ -43,11 +43,11 @@ const trace: Trace = { describe('SpanFilters', () => { let user: ReturnType; - const SpanFiltersWithProps = () => { + const SpanFiltersWithProps = ({ showFilters = true }) => { const [search, setSearch] = useState(defaultFilters); const props = { trace: trace, - showSpanFilters: true, + showSpanFilters: showFilters, setShowSpanFilters: jest.fn(), showSpanFilterMatchesOnly: false, setShowSpanFilterMatchesOnly: jest.fn(), @@ -217,6 +217,12 @@ describe('SpanFilters', () => { expect(screen.queryByText('TagKey0')).not.toBeInTheDocument(); expect(screen.queryByText('TagValue0')).not.toBeInTheDocument(); }); + + it('renders buttons when span filters is collapsed', async () => { + render(); + expect(screen.queryByRole('button', { name: 'Next result button' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Prev result button' })).toBeInTheDocument(); + }); }); const selectAndCheckValue = async (user: ReturnType, elem: HTMLElement, text: string) => { diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.tsx index f22727b9ec2..414b0678e9d 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.tsx @@ -17,7 +17,7 @@ import { SpanStatusCode } from '@opentelemetry/api'; import { uniq } from 'lodash'; import React, { useState, useEffect, memo, useCallback } from 'react'; -import { SelectableValue, toOption } from '@grafana/data'; +import { GrafanaTheme2, SelectableValue, toOption } from '@grafana/data'; import { AccessoryButton } from '@grafana/experimental'; import { Collapse, @@ -34,7 +34,8 @@ import { import { defaultFilters, randomId, SearchProps, Tag } from '../../../useSearch'; import { KIND, LIBRARY_NAME, LIBRARY_VERSION, STATUS, STATUS_MESSAGE, TRACE_STATE, ID } from '../../constants/span'; import { Trace } from '../../types'; -import NewTracePageSearchBar from '../NewTracePageSearchBar'; +import NewTracePageSearchBar from '../SearchBar/NewTracePageSearchBar'; +import NextPrevResult from '../SearchBar/NextPrevResult'; export type SpanFilterProps = { trace: Trace; @@ -67,6 +68,7 @@ export const SpanFilters = memo((props: SpanFilterProps) => { const [spanNames, setSpanNames] = useState>>(); const [tagKeys, setTagKeys] = useState>>(); const [tagValues, setTagValues] = useState<{ [key: string]: Array> }>({}); + const [focusedSpanIndexForSearch, setFocusedSpanIndexForSearch] = useState(-1); const clear = useCallback(() => { setServiceNames(undefined); @@ -84,6 +86,12 @@ export const SpanFilters = memo((props: SpanFilterProps) => { return null; } + const setSpanFiltersSearch = (spanSearch: SearchProps) => { + setFocusedSpanIndexForSearch(-1); + setFocusedSpanIdForSearch(''); + setSearch(spanSearch); + }; + const getServiceNames = () => { if (!serviceNames) { const serviceNames = trace.spans.map((span) => { @@ -262,15 +270,31 @@ export const SpanFilters = memo((props: SpanFilterProps) => { }; const collapseLabel = ( - - - Span Filters - - - + <> + + + Span Filters + + + + + {!showSpanFilters && ( +
+ +
+ )} + ); return ( @@ -281,14 +305,14 @@ export const SpanFilters = memo((props: SpanFilterProps) => { setSearch({ ...search, serviceName: v?.value || '' })} + onChange={(v) => setSpanFiltersSearch({ ...search, serviceName: v?.value || '' })} onOpenMenu={getServiceNames} options={serviceNames} placeholder="All service names" @@ -302,14 +326,14 @@ export const SpanFilters = memo((props: SpanFilterProps) => { setSearch({ ...search, spanName: v?.value || '' })} + onChange={(v) => setSpanFiltersSearch({ ...search, spanName: v?.value || '' })} onOpenMenu={getSpanNames} options={spanNames} placeholder="All span names" @@ -323,26 +347,26 @@ export const SpanFilters = memo((props: SpanFilterProps) => { setSearch({ ...search, from: v.currentTarget.value })} + onChange={(v) => setSpanFiltersSearch({ ...search, from: v.currentTarget.value })} placeholder="e.g. 100ms, 1.2s" value={search.from || ''} width={18} /> setSearch({ ...search, to: v.currentTarget.value })} + onChange={(v) => setSpanFiltersSearch({ ...search, to: v.currentTarget.value })} placeholder="e.g. 100ms, 1.2s" value={search.to || ''} width={18} @@ -369,7 +393,7 @@ export const SpanFilters = memo((props: SpanFilterProps) => {