mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06: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:
parent
d0ced39847
commit
9796b6b000
@ -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;
|
||||
}
|
||||
|
10
public/app/features/search/tempI18nPhrases.ts
Normal file
10
public/app/features/search/tempI18nPhrases.ts
Normal 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');
|
||||
}
|
@ -439,8 +439,8 @@
|
||||
"type-header": "Type"
|
||||
},
|
||||
"search-input": {
|
||||
"include-panels-placeholder": "Search for dashboards and panels",
|
||||
"placeholder": "Search for dashboards"
|
||||
"include-panels-placeholder": "Search for dashboards, folders, and panels",
|
||||
"placeholder": "Search for dashboards and folders"
|
||||
}
|
||||
},
|
||||
"share-modal": {
|
||||
|
@ -55,7 +55,7 @@
|
||||
"query-tab": "Requête",
|
||||
"stats-tab": "Statistiques",
|
||||
"subtitle": "{{queryCount}} requêtes avec un délai total de requête de {{formatted}}",
|
||||
"title": "Inspecter : {{panelTitle}}"
|
||||
"title": "Inspecter\u00a0: {{panelTitle}}"
|
||||
},
|
||||
"inspect-data": {
|
||||
"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-label": "Panneau JSON",
|
||||
"select-source": "Sélectionner la source",
|
||||
"unknown": "Objet inconnu : {{show}}"
|
||||
"unknown": "Objet inconnu\u00a0: {{show}}"
|
||||
},
|
||||
"inspect-meta": {
|
||||
"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.",
|
||||
"explanation": "Pour visualiser vos données, vous devrez d’abord les connecter.",
|
||||
"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",
|
||||
"viewAll": "Afficher tout",
|
||||
"welcome": "Bienvenue aux tableaux de bord Grafana !"
|
||||
@ -148,7 +148,7 @@
|
||||
},
|
||||
"library-panels": {
|
||||
"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é"
|
||||
}
|
||||
},
|
||||
@ -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-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",
|
||||
"mistake-message": "Avez-vous commis une erreur ? ",
|
||||
"mistake-message": "Avez-vous commis une erreur\u00a0? ",
|
||||
"name": "Nom de l'instantané",
|
||||
"timeout": "Délai d’expiration (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.",
|
||||
|
@ -439,8 +439,8 @@
|
||||
"type-header": "Ŧypę"
|
||||
},
|
||||
"search-input": {
|
||||
"include-panels-placeholder": "Ŝęäřčĥ ƒőř đäşĥþőäřđş äʼnđ päʼnęľş",
|
||||
"placeholder": "Ŝęäřčĥ ƒőř đäşĥþőäřđş"
|
||||
"include-panels-placeholder": "Ŝęäřčĥ ƒőř đäşĥþőäřđş, ƒőľđęřş, äʼnđ päʼnęľş",
|
||||
"placeholder": "Ŝęäřčĥ ƒőř đäşĥþőäřđş äʼnđ ƒőľđęřş"
|
||||
}
|
||||
},
|
||||
"share-modal": {
|
||||
|
Loading…
Reference in New Issue
Block a user