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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 121 additions and 98 deletions

View File

@ -61,7 +61,7 @@ describe('browse-dashboards BrowseDashboardsPage', () => {
it('displays a search input', async () => { it('displays a search input', async () => {
render(<BrowseDashboardsPage {...props} />); 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 () => { it('displays the filters and hides the actions initially', async () => {

View File

@ -1,15 +1,15 @@
import { css } from '@emotion/css'; 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 AutoSizer from 'react-virtualized-auto-sizer';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { locationSearchToObject } from '@grafana/runtime'; import { FilterInput, useStyles2 } from '@grafana/ui';
import { Input, useStyles2 } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page'; import { Page } from 'app/core/components/Page/Page';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { buildNavModel } from '../folders/state/navModel'; 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 { skipToken, useGetFolderQuery } from './api/browseDashboardsAPI';
import { BrowseActions } from './components/BrowseActions/BrowseActions'; 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 // New Browse/Manage/Search Dashboards views for nested folders
const BrowseDashboardsPage = memo(({ match, location }: Props) => { const BrowseDashboardsPage = memo(({ match }: Props) => {
const styles = useStyles2(getStyles);
const { uid: folderUID } = match.params; const { uid: folderUID } = match.params;
const searchState = useMemo(() => { const styles = useStyles2(getStyles);
return parseRouteParams(locationSearchToObject(location.search)); const [searchState, stateManager] = useSearchStateManager();
}, [location.search]); 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 { data: folderDTO } = useGetFolderQuery(folderUID ?? skipToken);
const navModel = useMemo(() => (folderDTO ? buildNavModel(folderDTO) : undefined), [folderDTO]); const navModel = useMemo(() => (folderDTO ? buildNavModel(folderDTO) : undefined), [folderDTO]);
@ -42,15 +51,20 @@ const BrowseDashboardsPage = memo(({ match, location }: Props) => {
return ( return (
<Page navId="dashboards/browse" pageNav={navModel}> <Page navId="dashboards/browse" pageNav={navModel}>
<Page.Contents className={styles.pageContents}> <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 />} {hasSelection ? <BrowseActions /> : <BrowseFilters />}
<div className={styles.subView}> <div className={styles.subView}>
<AutoSizer> <AutoSizer>
{({ width, height }) => {({ width, height }) =>
searchState.query ? ( isSearching ? (
<SearchView width={width} height={height} folderUID={folderUID} /> <SearchView width={width} height={height} />
) : ( ) : (
<BrowseView width={width} height={height} folderUID={folderUID} /> <BrowseView width={width} height={height} folderUID={folderUID} />
) )

View File

@ -1,33 +1,29 @@
import React, { useMemo } from 'react'; import React from 'react';
import { ActionRow } from 'app/features/search/page/components/ActionRow'; 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() { export function BrowseFilters() {
const fakeState = useMemo(() => { const [searchState, stateManager] = useSearchStateManager();
return {
query: '',
tag: [],
starred: false,
layout: SearchLayout.Folders,
eventTrackingNamespace: 'manage_dashboards' as const,
};
}, []);
return ( return (
<div> <div>
<ActionRow <ActionRow
includePanels={false} hideLayout
state={fakeState} showStarredFilter
getTagOptions={() => Promise.resolve([])} state={searchState}
getSortOptions={() => Promise.resolve([])} getTagOptions={stateManager.getTagOptions}
onLayoutChange={() => {}} getSortOptions={getGrafanaSearcher().getSortOptions}
onSortChange={() => {}} sortPlaceholder={getGrafanaSearcher().sortPlaceholder}
onStarredFilterChange={() => {}} includePanels={searchState.includePanels ?? false}
onTagFilterChange={() => {}} onLayoutChange={stateManager.onLayoutChange}
onDatasourceChange={() => {}} onStarredFilterChange={stateManager.onStarredFilterChange}
onPanelTypeChange={() => {}} onSortChange={stateManager.onSortChange}
onSetIncludePanels={() => {}} onTagFilterChange={stateManager.onTagFilterChange}
onDatasourceChange={stateManager.onDatasourceChange}
onPanelTypeChange={stateManager.onPanelTypeChange}
onSetIncludePanels={stateManager.onSetIncludePanels}
/> />
</div> </div>
); );

View File

@ -1,9 +1,9 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback } from 'react';
import { Spinner } from '@grafana/ui'; import { Spinner } from '@grafana/ui';
import { useKeyNavigationListener } from 'app/features/search/hooks/useSearchKeyboardSelection'; import { useKeyNavigationListener } from 'app/features/search/hooks/useSearchKeyboardSelection';
import { SearchResultsProps, SearchResultsTable } from 'app/features/search/page/components/SearchResultsTable'; 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 { DashboardViewItemKind } from 'app/features/search/types';
import { useDispatch, useSelector } from 'app/types'; import { useDispatch, useSelector } from 'app/types';
@ -12,20 +12,16 @@ import { setItemSelectionState } from '../state';
interface SearchViewProps { interface SearchViewProps {
height: number; height: number;
width: number; width: number;
folderUID: string | undefined;
} }
export function SearchView({ folderUID, width, height }: SearchViewProps) { export function SearchView({ width, height }: SearchViewProps) {
const dispatch = useDispatch(); const dispatch = useDispatch();
const selectedItems = useSelector((wholeState) => wholeState.browseDashboards.selectedItems); const selectedItems = useSelector((wholeState) => wholeState.browseDashboards.selectedItems);
const { keyboardEvents } = useKeyNavigationListener(); const { keyboardEvents } = useKeyNavigationListener();
const [searchState, stateManager] = useSearchStateManager();
const stateManager = getSearchStateManager(); const value = searchState.result;
useEffect(() => stateManager.initStateFromUrl(folderUID), [folderUID, stateManager]);
const state = stateManager.useState();
const value = state.result;
const selectionChecker = useCallback( const selectionChecker = useCallback(
(kind: string | undefined, uid: string): boolean => { (kind: string | undefined, uid: string): boolean => {
@ -55,12 +51,16 @@ export function SearchView({ folderUID, width, height }: SearchViewProps) {
if (!value) { if (!value) {
return ( return (
<div> <div style={{ width }}>
<Spinner /> <Spinner />
</div> </div>
); );
} }
if (value.totalRows === 0) {
return <div style={{ width }}>No search results</div>;
}
const props: SearchResultsProps = { const props: SearchResultsProps = {
response: value, response: value,
selection: selectionChecker, selection: selectionChecker,
@ -70,7 +70,7 @@ export function SearchView({ folderUID, width, height }: SearchViewProps) {
height: height, height: height,
onTagSelected: stateManager.onAddTag, onTagSelected: stateManager.onAddTag,
keyboardEvents, keyboardEvents,
onDatasourceChange: state.datasource ? stateManager.onDatasourceChange : undefined, onDatasourceChange: searchState.datasource ? stateManager.onDatasourceChange : undefined,
onClickItem: stateManager.onSearchItemClicked, onClickItem: stateManager.onSearchItemClicked,
}; };

View File

@ -3,13 +3,13 @@ import React, { useEffect } from 'react';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2, FilterInput } from '@grafana/ui'; import { useStyles2, FilterInput } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { contextSrv } from 'app/core/services/context_srv'; import { contextSrv } from 'app/core/services/context_srv';
import { FolderDTO, AccessControlAction } from 'app/types'; import { FolderDTO, AccessControlAction } from 'app/types';
import { useKeyNavigationListener } from '../hooks/useSearchKeyboardSelection'; import { useKeyNavigationListener } from '../hooks/useSearchKeyboardSelection';
import { SearchView } from '../page/components/SearchView'; import { SearchView } from '../page/components/SearchView';
import { getSearchStateManager } from '../state/SearchStateManager'; import { getSearchStateManager } from '../state/SearchStateManager';
import { getSearchPlaceholder } from '../tempI18nPhrases';
import { DashboardActions } from './DashboardActions'; import { DashboardActions } from './DashboardActions';
@ -50,11 +50,7 @@ export const ManageDashboardsNew = React.memo(({ folder }: Props) => {
// eslint-disable-next-line jsx-a11y/no-autofocus // eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus autoFocus
spellCheck={false} spellCheck={false}
placeholder={ placeholder={getSearchPlaceholder(state.includePanels)}
state.includePanels
? t('search.search-input.include-panels-placeholder', 'Search for dashboards and panels')
: t('search.search-input.placeholder', 'Search for dashboards')
}
escapeRegex={false} escapeRegex={false}
className={styles.searchInput} className={styles.searchInput}
/> />

View File

@ -105,25 +105,23 @@ export const ActionRow = ({
)} )}
</HorizontalGroup> </HorizontalGroup>
<div className={styles.rowContainer}> <HorizontalGroup spacing="md" width="auto">
<HorizontalGroup spacing="md" width="auto"> {!hideLayout && (
{!hideLayout && ( <RadioButtonGroup
<RadioButtonGroup options={getLayoutOptions()}
options={getLayoutOptions()} disabledOptions={disabledOptions}
disabledOptions={disabledOptions} onChange={onLayoutChange}
onChange={onLayoutChange} value={layout}
value={layout}
/>
)}
<SortPicker
onChange={(change) => onSortChange(change?.value)}
value={state.sort}
getSortOptions={getSortOptions}
placeholder={sortPlaceholder || t('search.actions.sort-placeholder', 'Sort')}
isClearable
/> />
</HorizontalGroup> )}
</div> <SortPicker
onChange={(change) => onSortChange(change?.value)}
value={state.sort}
getSortOptions={getSortOptions}
placeholder={sortPlaceholder || t('search.actions.sort-placeholder', 'Sort')}
isClearable
/>
</HorizontalGroup>
</div> </div>
); );
}; };
@ -143,9 +141,6 @@ export const getStyles = (theme: GrafanaTheme2) => {
width: 100%; width: 100%;
} }
`, `,
rowContainer: css`
margin-right: ${theme.v1.spacing.md};
`,
checkboxWrapper: css` checkboxWrapper: css`
label { label {
line-height: 1.2; line-height: 1.2;

View File

@ -43,17 +43,15 @@ export function ManageActions({ items, folder, onChange, clearSelection }: Props
return ( return (
<div className={styles.actionRow} data-testid="manage-actions"> <div className={styles.actionRow} data-testid="manage-actions">
<div className={styles.rowContainer}> <HorizontalGroup spacing="md" width="auto">
<HorizontalGroup spacing="md" width="auto"> <IconButton name="check-square" onClick={clearSelection} title="Uncheck everything" />
<IconButton name="check-square" onClick={clearSelection} title="Uncheck everything" /> <Button disabled={!canMove} onClick={onMove} icon="exchange-alt" variant="secondary">
<Button disabled={!canMove} onClick={onMove} icon="exchange-alt" variant="secondary"> Move
Move </Button>
</Button> <Button disabled={!canDelete} onClick={onDelete} icon="trash-alt" variant="destructive">
<Button disabled={!canDelete} onClick={onDelete} icon="trash-alt" variant="destructive"> Delete
Delete </Button>
</Button> </HorizontalGroup>
</HorizontalGroup>
</div>
{isDeleteModalOpen && ( {isDeleteModalOpen && (
<ConfirmDeleteModal onDeleteItems={onChange} results={items} onDismiss={() => setIsDeleteModalOpen(false)} /> <ConfirmDeleteModal onDeleteItems={onChange} results={items} onDismiss={() => setIsDeleteModalOpen(false)} />

View File

@ -115,7 +115,7 @@ export const SearchView = ({ showManage, folderDTO, hidePseudoFolders, keyboardE
} }
return ( return (
<div style={{ content: 'auto-sizer-wrapper', height: '100%', width: '100%' }}> <div style={{ height: '100%', width: '100%' }}>
<AutoSizer> <AutoSizer>
{({ width, height }) => { {({ width, height }) => {
const props: SearchResultsProps = { const props: SearchResultsProps = {

View File

@ -40,7 +40,7 @@ export class SearchStateManager extends StateManagerBase<SearchState> {
doSearchWithDebounce = debounce(() => this.doSearch(), 300); doSearchWithDebounce = debounce(() => this.doSearch(), 300);
lastQuery?: SearchQuery; lastQuery?: SearchQuery;
initStateFromUrl(folderUid?: string) { initStateFromUrl(folderUid?: string, doInitialSearch = true) {
const stateFromUrl = parseRouteParams(locationService.getSearchObject()); const stateFromUrl = parseRouteParams(locationService.getSearchObject());
// Force list view when conditions are specified from the URL // 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', eventTrackingNamespace: folderUid ? 'manage_dashboards' : 'dashboard_search',
}); });
this.doSearch(); if (doInitialSearch && this.hasSearchFilters()) {
this.doSearch();
}
} }
/** /**
* Updates internal and url state, then triggers a new search * Updates internal and url state, then triggers a new search
*/ */
@ -162,7 +165,7 @@ export class SearchStateManager extends StateManagerBase<SearchState> {
}; };
hasSearchFilters() { 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() { getSearchQuery() {
@ -244,7 +247,11 @@ export class SearchStateManager extends StateManagerBase<SearchState> {
// This gets the possible tags from within the query results // This gets the possible tags from within the query results
getTagOptions = (): Promise<TermCount[]> => { 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; return stateManager;
} }
export function useSearchStateManager() {
const stateManager = getSearchStateManager();
const state = stateManager.useState();
return [state, stateManager] as const;
}

View File

@ -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');
}

View File

@ -439,8 +439,8 @@
"type-header": "Type" "type-header": "Type"
}, },
"search-input": { "search-input": {
"include-panels-placeholder": "Search for dashboards and panels", "include-panels-placeholder": "Search for dashboards, folders, and panels",
"placeholder": "Search for dashboards" "placeholder": "Search for dashboards and folders"
} }
}, },
"share-modal": { "share-modal": {

View File

@ -55,7 +55,7 @@
"query-tab": "Requête", "query-tab": "Requête",
"stats-tab": "Statistiques", "stats-tab": "Statistiques",
"subtitle": "{{queryCount}} requêtes avec un délai total de requête de {{formatted}}", "subtitle": "{{queryCount}} requêtes avec un délai total de requête de {{formatted}}",
"title": "Inspecter : {{panelTitle}}" "title": "Inspecter\u00a0: {{panelTitle}}"
}, },
"inspect-data": { "inspect-data": {
"data-options": "Options de données", "data-options": "Options de données",
@ -85,7 +85,7 @@
"panel-json-description": "Le modèle enregistré dans le tableau de bord JSON qui configure comment tout fonctionne.", "panel-json-description": "Le modèle enregistré dans le tableau de bord JSON qui configure comment tout fonctionne.",
"panel-json-label": "Panneau JSON", "panel-json-label": "Panneau JSON",
"select-source": "Sélectionner la source", "select-source": "Sélectionner la source",
"unknown": "Objet inconnu : {{show}}" "unknown": "Objet inconnu\u00a0: {{show}}"
}, },
"inspect-meta": { "inspect-meta": {
"no-inspector": "Pas d'inspecteur de métadonnées" "no-inspector": "Pas d'inspecteur de métadonnées"
@ -118,7 +118,7 @@
"contact-admin": "Veuillez contacter votre administrateur pour configurer les sources de données.", "contact-admin": "Veuillez contacter votre administrateur pour configurer les sources de données.",
"explanation": "Pour visualiser vos données, vous devrez dabord les connecter.", "explanation": "Pour visualiser vos données, vous devrez dabord les connecter.",
"new-dashboard": "Nouveau tableau de bord", "new-dashboard": "Nouveau tableau de bord",
"preferred": "Connectez votre source de données préférée :", "preferred": "Connectez votre source de données préférée\u00a0:",
"sampleData": "Ou établissez un nouveau tableau de bord avec des exemples de données", "sampleData": "Ou établissez un nouveau tableau de bord avec des exemples de données",
"viewAll": "Afficher tout", "viewAll": "Afficher tout",
"welcome": "Bienvenue aux tableaux de bord Grafana !" "welcome": "Bienvenue aux tableaux de bord Grafana !"
@ -148,7 +148,7 @@
}, },
"library-panels": { "library-panels": {
"save": { "save": {
"error": "Erreur lors de l'enregistrement du panneau de bibliothèque : \"{{errorMsg}}\"", "error": "Erreur lors de l'enregistrement du panneau de bibliothèque\u00a0: \"{{errorMsg}}\"",
"success": "Panneau de bibliothèque enregistré" "success": "Panneau de bibliothèque enregistré"
} }
}, },
@ -495,7 +495,7 @@
"info-text-1": "Un instantané est un moyen instantané de partager publiquement un tableau de bord interactif. Lors de la création, nous supprimons les données sensibles telles que les requêtes (métrique, modèle et annotation) et les liens du panneau, pour ne laisser que les métriques visibles et les noms de séries intégrés dans votre tableau de bord.", "info-text-1": "Un instantané est un moyen instantané de partager publiquement un tableau de bord interactif. Lors de la création, nous supprimons les données sensibles telles que les requêtes (métrique, modèle et annotation) et les liens du panneau, pour ne laisser que les métriques visibles et les noms de séries intégrés dans votre tableau de bord.",
"info-text-2": "N'oubliez pas que votre instantané <1>peut être consulté par une personne</1> qui dispose du lien et qui peut accéder à l'URL. Partagez judicieusement.", "info-text-2": "N'oubliez pas que votre instantané <1>peut être consulté par une personne</1> qui dispose du lien et qui peut accéder à l'URL. Partagez judicieusement.",
"local-button": "Instantané local", "local-button": "Instantané local",
"mistake-message": "Avez-vous commis une erreur ? ", "mistake-message": "Avez-vous commis une erreur\u00a0? ",
"name": "Nom de l'instantané", "name": "Nom de l'instantané",
"timeout": "Délai dexpiration (secondes)", "timeout": "Délai dexpiration (secondes)",
"timeout-description": "Vous devrez peut-être configurer la valeur du délai d'expiration si la collecte des métriques de votre tableau de bord prend beaucoup de temps.", "timeout-description": "Vous devrez peut-être configurer la valeur du délai d'expiration si la collecte des métriques de votre tableau de bord prend beaucoup de temps.",

View File

@ -439,8 +439,8 @@
"type-header": "Ŧypę" "type-header": "Ŧypę"
}, },
"search-input": { "search-input": {
"include-panels-placeholder": "Ŝęäřčĥ ƒőř đäşĥþőäřđş äʼnđ päʼnęľş", "include-panels-placeholder": "Ŝęäřčĥ ƒőř đäşĥþőäřđş, ƒőľđęřş, äʼnđ päʼnęľş",
"placeholder": "Ŝęäřčĥ ƒőř đäşĥþőäřđş" "placeholder": "Ŝęäřčĥ ƒőř đäşĥþőäřđş äʼnđ ƒőľđęřş"
} }
}, },
"share-modal": { "share-modal": {