mirror of
https://github.com/grafana/grafana.git
synced 2026-09-05 04:40:13 -05:00
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:
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user