NestedFolders: Connect Search input fields to state manager (#67193)

* NestedFolders: Connect Search input fields to state manager

* Fix tag list not loading

* Clear includePanels checkbox when leaving search

* fix test

* Fix extra right margin

* fix missing style

* cleanup

* fix placeholder

* fix test
This commit is contained in:
Josh Hunt
2023-04-26 15:42:25 +01:00
committed by GitHub
parent d0ced39847
commit 9796b6b000
13 changed files with 121 additions and 98 deletions
@@ -61,7 +61,7 @@ describe('browse-dashboards BrowseDashboardsPage', () => {
it('displays a search input', async () => {
render(<BrowseDashboardsPage {...props} />);
expect(await screen.findByPlaceholderText('Search box')).toBeInTheDocument();
expect(await screen.findByPlaceholderText('Search for dashboards and folders')).toBeInTheDocument();
});
it('displays the filters and hides the actions initially', async () => {
@@ -1,15 +1,15 @@
import { css } from '@emotion/css';
import React, { memo, useMemo } from 'react';
import React, { memo, useEffect, useMemo } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { GrafanaTheme2 } from '@grafana/data';
import { locationSearchToObject } from '@grafana/runtime';
import { Input, useStyles2 } from '@grafana/ui';
import { FilterInput, useStyles2 } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { buildNavModel } from '../folders/state/navModel';
import { parseRouteParams } from '../search/utils';
import { useSearchStateManager } from '../search/state/SearchStateManager';
import { getSearchPlaceholder } from '../search/tempI18nPhrases';
import { skipToken, useGetFolderQuery } from './api/browseDashboardsAPI';
import { BrowseActions } from './components/BrowseActions/BrowseActions';
@@ -27,13 +27,22 @@ export interface Props extends GrafanaRouteComponentProps<BrowseDashboardsPageRo
// New Browse/Manage/Search Dashboards views for nested folders
const BrowseDashboardsPage = memo(({ match, location }: Props) => {
const styles = useStyles2(getStyles);
const BrowseDashboardsPage = memo(({ match }: Props) => {
const { uid: folderUID } = match.params;
const searchState = useMemo(() => {
return parseRouteParams(locationSearchToObject(location.search));
}, [location.search]);
const styles = useStyles2(getStyles);
const [searchState, stateManager] = useSearchStateManager();
const isSearching = stateManager.hasSearchFilters();
useEffect(() => stateManager.initStateFromUrl(folderUID), [folderUID, stateManager]);
useEffect(() => {
// Clear the search results when we leave SearchView to prevent old results flashing
// when starting a new search
if (!isSearching && searchState.result) {
stateManager.setState({ result: undefined, includePanels: undefined });
}
}, [isSearching, searchState.result, stateManager]);
const { data: folderDTO } = useGetFolderQuery(folderUID ?? skipToken);
const navModel = useMemo(() => (folderDTO ? buildNavModel(folderDTO) : undefined), [folderDTO]);
@@ -42,15 +51,20 @@ const BrowseDashboardsPage = memo(({ match, location }: Props) => {
return (
<Page navId="dashboards/browse" pageNav={navModel}>
<Page.Contents className={styles.pageContents}>
<Input placeholder="Search box" />
<FilterInput
placeholder={getSearchPlaceholder(searchState.includePanels)}
value={searchState.query}
escapeRegex={false}
onChange={(e) => stateManager.onQueryChange(e)}
/>
{hasSelection ? <BrowseActions /> : <BrowseFilters />}
<div className={styles.subView}>
<AutoSizer>
{({ width, height }) =>
searchState.query ? (
<SearchView width={width} height={height} folderUID={folderUID} />
isSearching ? (
<SearchView width={width} height={height} />
) : (
<BrowseView width={width} height={height} folderUID={folderUID} />
)
@@ -1,33 +1,29 @@
import React, { useMemo } from 'react';
import React from 'react';
import { ActionRow } from 'app/features/search/page/components/ActionRow';
import { SearchLayout } from 'app/features/search/types';
import { getGrafanaSearcher } from 'app/features/search/service';
import { useSearchStateManager } from 'app/features/search/state/SearchStateManager';
export function BrowseFilters() {
const fakeState = useMemo(() => {
return {
query: '',
tag: [],
starred: false,
layout: SearchLayout.Folders,
eventTrackingNamespace: 'manage_dashboards' as const,
};
}, []);
const [searchState, stateManager] = useSearchStateManager();
return (
<div>
<ActionRow
includePanels={false}
state={fakeState}
getTagOptions={() => Promise.resolve([])}
getSortOptions={() => Promise.resolve([])}
onLayoutChange={() => {}}
onSortChange={() => {}}
onStarredFilterChange={() => {}}
onTagFilterChange={() => {}}
onDatasourceChange={() => {}}
onPanelTypeChange={() => {}}
onSetIncludePanels={() => {}}
hideLayout
showStarredFilter
state={searchState}
getTagOptions={stateManager.getTagOptions}
getSortOptions={getGrafanaSearcher().getSortOptions}
sortPlaceholder={getGrafanaSearcher().sortPlaceholder}
includePanels={searchState.includePanels ?? false}
onLayoutChange={stateManager.onLayoutChange}
onStarredFilterChange={stateManager.onStarredFilterChange}
onSortChange={stateManager.onSortChange}
onTagFilterChange={stateManager.onTagFilterChange}
onDatasourceChange={stateManager.onDatasourceChange}
onPanelTypeChange={stateManager.onPanelTypeChange}
onSetIncludePanels={stateManager.onSetIncludePanels}
/>
</div>
);
@@ -1,9 +1,9 @@
import React, { useCallback, useEffect } from 'react';
import React, { useCallback } from 'react';
import { Spinner } from '@grafana/ui';
import { useKeyNavigationListener } from 'app/features/search/hooks/useSearchKeyboardSelection';
import { SearchResultsProps, SearchResultsTable } from 'app/features/search/page/components/SearchResultsTable';
import { getSearchStateManager } from 'app/features/search/state/SearchStateManager';
import { useSearchStateManager } from 'app/features/search/state/SearchStateManager';
import { DashboardViewItemKind } from 'app/features/search/types';
import { useDispatch, useSelector } from 'app/types';
@@ -12,20 +12,16 @@ import { setItemSelectionState } from '../state';
interface SearchViewProps {
height: number;
width: number;
folderUID: string | undefined;
}
export function SearchView({ folderUID, width, height }: SearchViewProps) {
export function SearchView({ width, height }: SearchViewProps) {
const dispatch = useDispatch();
const selectedItems = useSelector((wholeState) => wholeState.browseDashboards.selectedItems);
const { keyboardEvents } = useKeyNavigationListener();
const [searchState, stateManager] = useSearchStateManager();
const stateManager = getSearchStateManager();
useEffect(() => stateManager.initStateFromUrl(folderUID), [folderUID, stateManager]);
const state = stateManager.useState();
const value = state.result;
const value = searchState.result;
const selectionChecker = useCallback(
(kind: string | undefined, uid: string): boolean => {
@@ -55,12 +51,16 @@ export function SearchView({ folderUID, width, height }: SearchViewProps) {
if (!value) {
return (
<div>
<div style={{ width }}>
<Spinner />
</div>
);
}
if (value.totalRows === 0) {
return <div style={{ width }}>No search results</div>;
}
const props: SearchResultsProps = {
response: value,
selection: selectionChecker,
@@ -70,7 +70,7 @@ export function SearchView({ folderUID, width, height }: SearchViewProps) {
height: height,
onTagSelected: stateManager.onAddTag,
keyboardEvents,
onDatasourceChange: state.datasource ? stateManager.onDatasourceChange : undefined,
onDatasourceChange: searchState.datasource ? stateManager.onDatasourceChange : undefined,
onClickItem: stateManager.onSearchItemClicked,
};
@@ -3,13 +3,13 @@ import React, { useEffect } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2, FilterInput } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { contextSrv } from 'app/core/services/context_srv';
import { FolderDTO, AccessControlAction } from 'app/types';
import { useKeyNavigationListener } from '../hooks/useSearchKeyboardSelection';
import { SearchView } from '../page/components/SearchView';
import { getSearchStateManager } from '../state/SearchStateManager';
import { getSearchPlaceholder } from '../tempI18nPhrases';
import { DashboardActions } from './DashboardActions';
@@ -50,11 +50,7 @@ export const ManageDashboardsNew = React.memo(({ folder }: Props) => {
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
spellCheck={false}
placeholder={
state.includePanels
? t('search.search-input.include-panels-placeholder', 'Search for dashboards and panels')
: t('search.search-input.placeholder', 'Search for dashboards')
}
placeholder={getSearchPlaceholder(state.includePanels)}
escapeRegex={false}
className={styles.searchInput}
/>
@@ -105,25 +105,23 @@ export const ActionRow = ({
)}
</HorizontalGroup>
<div className={styles.rowContainer}>
<HorizontalGroup spacing="md" width="auto">
{!hideLayout && (
<RadioButtonGroup
options={getLayoutOptions()}
disabledOptions={disabledOptions}
onChange={onLayoutChange}
value={layout}
/>
)}
<SortPicker
onChange={(change) => onSortChange(change?.value)}
value={state.sort}
getSortOptions={getSortOptions}
placeholder={sortPlaceholder || t('search.actions.sort-placeholder', 'Sort')}
isClearable
<HorizontalGroup spacing="md" width="auto">
{!hideLayout && (
<RadioButtonGroup
options={getLayoutOptions()}
disabledOptions={disabledOptions}
onChange={onLayoutChange}
value={layout}
/>
</HorizontalGroup>
</div>
)}
<SortPicker
onChange={(change) => onSortChange(change?.value)}
value={state.sort}
getSortOptions={getSortOptions}
placeholder={sortPlaceholder || t('search.actions.sort-placeholder', 'Sort')}
isClearable
/>
</HorizontalGroup>
</div>
);
};
@@ -143,9 +141,6 @@ export const getStyles = (theme: GrafanaTheme2) => {
width: 100%;
}
`,
rowContainer: css`
margin-right: ${theme.v1.spacing.md};
`,
checkboxWrapper: css`
label {
line-height: 1.2;
@@ -43,17 +43,15 @@ export function ManageActions({ items, folder, onChange, clearSelection }: Props
return (
<div className={styles.actionRow} data-testid="manage-actions">
<div className={styles.rowContainer}>
<HorizontalGroup spacing="md" width="auto">
<IconButton name="check-square" onClick={clearSelection} title="Uncheck everything" />
<Button disabled={!canMove} onClick={onMove} icon="exchange-alt" variant="secondary">
Move
</Button>
<Button disabled={!canDelete} onClick={onDelete} icon="trash-alt" variant="destructive">
Delete
</Button>
</HorizontalGroup>
</div>
<HorizontalGroup spacing="md" width="auto">
<IconButton name="check-square" onClick={clearSelection} title="Uncheck everything" />
<Button disabled={!canMove} onClick={onMove} icon="exchange-alt" variant="secondary">
Move
</Button>
<Button disabled={!canDelete} onClick={onDelete} icon="trash-alt" variant="destructive">
Delete
</Button>
</HorizontalGroup>
{isDeleteModalOpen && (
<ConfirmDeleteModal onDeleteItems={onChange} results={items} onDismiss={() => setIsDeleteModalOpen(false)} />
@@ -115,7 +115,7 @@ export const SearchView = ({ showManage, folderDTO, hidePseudoFolders, keyboardE
}
return (
<div style={{ content: 'auto-sizer-wrapper', height: '100%', width: '100%' }}>
<div style={{ height: '100%', width: '100%' }}>
<AutoSizer>
{({ width, height }) => {
const props: SearchResultsProps = {
@@ -40,7 +40,7 @@ export class SearchStateManager extends StateManagerBase<SearchState> {
doSearchWithDebounce = debounce(() => this.doSearch(), 300);
lastQuery?: SearchQuery;
initStateFromUrl(folderUid?: string) {
initStateFromUrl(folderUid?: string, doInitialSearch = true) {
const stateFromUrl = parseRouteParams(locationService.getSearchObject());
// Force list view when conditions are specified from the URL
@@ -54,8 +54,11 @@ export class SearchStateManager extends StateManagerBase<SearchState> {
eventTrackingNamespace: folderUid ? 'manage_dashboards' : 'dashboard_search',
});
this.doSearch();
if (doInitialSearch && this.hasSearchFilters()) {
this.doSearch();
}
}
/**
* Updates internal and url state, then triggers a new search
*/
@@ -162,7 +165,7 @@ export class SearchStateManager extends StateManagerBase<SearchState> {
};
hasSearchFilters() {
return this.state.query || this.state.tag.length || this.state.starred || this.state.panel_type;
return this.state.query || this.state.tag.length || this.state.starred || this.state.panel_type || this.state.sort;
}
getSearchQuery() {
@@ -244,7 +247,11 @@ export class SearchStateManager extends StateManagerBase<SearchState> {
// This gets the possible tags from within the query results
getTagOptions = (): Promise<TermCount[]> => {
return getGrafanaSearcher().tags(this.lastQuery!);
const query = this.lastQuery ?? {
kind: ['dashboard', 'folder'],
query: '*',
};
return getGrafanaSearcher().tags(query);
};
/**
@@ -299,3 +306,10 @@ export function getSearchStateManager() {
return stateManager;
}
export function useSearchStateManager() {
const stateManager = getSearchStateManager();
const state = stateManager.useState();
return [state, stateManager] as const;
}
@@ -0,0 +1,10 @@
// Temporary place to collect phrases we reuse between new and old browse/search
// TODO: remove this when new Browse Dashboards UI is no longer feature flagged
import { t } from 'app/core/internationalization';
export function getSearchPlaceholder(includePanels = false) {
return includePanels
? t('search.search-input.include-panels-placeholder', 'Search for dashboards, folders, and panels')
: t('search.search-input.placeholder', 'Search for dashboards and folders');
}