NestedFolders: Show nested folders in Browse folder view (#63746)

* dirty dirty code for showing nested folders in folder view

refactor to NestedFolderItem

Update dashboard grid view to new types

update tests

REBASE OUT OF THIS BRANCH - joshhunt/star-by-uid merged into this

Squashed commit of the following:

commit d0f046ccd3
Author: joshhunt <josh@trtr.co>
Date:   Wed Feb 8 18:35:56 2023 +0000

    undo async

commit abe2777a1f
Author: joshhunt <josh@trtr.co>
Date:   Wed Feb 8 18:34:11 2023 +0000

    Dashboards: Star dashboards by UID

add type for dashboard search dto

clean DashboardSearchItem type

simplify DashboardSearchHit type

remove unused properties from DashboardSearchHit

make uid non-optional

rename + move NestedFolderItem type to DashboardViewItem

clean up

* wip

* fix checkbox selection of nested folders

* show folder's parent correctly

* Add dashboard result kind

* don't render folder empty view in SearchView

* call nested folders api only if feature flag enabled

* remove unused import

* un-rename variable to reduce PR diff

* fix typo in comment

* fix order of pseudoFolders

* Fix General folder not showing in browse

* rename folder view tests

---------

Co-authored-by: Ashley Harrison <ashley.harrison@grafana.com>
This commit is contained in:
Josh Hunt 2023-03-23 13:28:45 +00:00 committed by GitHub
parent da97139489
commit d13488a435
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 250 additions and 97 deletions

View File

@ -255,6 +255,7 @@ function mockDashboardSearchItem(searchItem: Partial<DashboardSearchItem>) {
uri: '',
items: [],
tags: [],
slug: '',
isStarred: false,
...searchItem,
};

View File

@ -3,7 +3,9 @@ import React, { useCallback } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { config } from '@grafana/runtime';
import { Card, Icon, IconName, TagList, useStyles2 } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { SEARCH_ITEM_HEIGHT } from '../constants';
import { getIconForKind } from '../service/utils';
@ -55,6 +57,16 @@ export const SearchItem = ({ item, isSelected, editable, onToggleChecked, onTagS
[item, onToggleChecked]
);
const description = config.featureToggles.nestedFolders ? (
<>
<Icon name={getIconForKind(item.kind)} aria-hidden /> {kindName(item.kind)}
</>
) : (
<>
<Icon name={getIconForKind(item.parentKind ?? 'folder')} aria-hidden /> {item.parentTitle || 'General'}
</>
);
return (
<Card
data-testid={selectors.dashboardItem(item.title)}
@ -64,6 +76,7 @@ export const SearchItem = ({ item, isSelected, editable, onToggleChecked, onTagS
onClick={onClickItem}
>
<Card.Heading>{item.title}</Card.Heading>
<Card.Figure align={'center'} className={styles.checkbox}>
<SearchCheckbox
aria-label="Select dashboard"
@ -72,11 +85,9 @@ export const SearchItem = ({ item, isSelected, editable, onToggleChecked, onTagS
onClick={handleCheckboxClick}
/>
</Card.Figure>
<Card.Meta separator={''}>
<span className={styles.metaContainer}>
<Icon name={getIconForKind(item.parentKind ?? 'folder')} aria-hidden />
{item.parentTitle || 'General'}
</span>
<span className={styles.metaContainer}>{description}</span>
{item.sortMetaName && (
<span className={styles.metaContainer}>
@ -92,6 +103,17 @@ export const SearchItem = ({ item, isSelected, editable, onToggleChecked, onTagS
);
};
function kindName(kind: DashboardViewItem['kind']) {
switch (kind) {
case 'folder':
return t('search.result-kind.folder', 'Folder');
case 'dashboard':
return t('search.result-kind.dashboard', 'Dashboard');
case 'panel':
return t('search.result-kind.panel', 'Panel');
}
}
const getStyles = (theme: GrafanaTheme2) => {
return {
container: css`

View File

@ -15,7 +15,7 @@ describe('FolderSection', () => {
const mockSelectionToggle = jest.fn();
const mockSelection = jest.fn();
const mockSection: DashboardViewItem = {
kind: 'folder' as const,
kind: 'folder',
uid: 'my-folder',
title: 'My folder',
};

View File

@ -5,12 +5,15 @@ import { useAsync, useLocalStorage } from 'react-use';
import { GrafanaTheme2, toIconName } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Card, Checkbox, CollapsableSection, Icon, Spinner, useStyles2 } from '@grafana/ui';
import { config } from 'app/core/config';
import { t } from 'app/core/internationalization';
import { getSectionStorageKey } from 'app/features/search/utils';
import { useUniqueId } from 'app/plugins/datasource/influxdb/components/useUniqueId';
import { SearchItem } from '../..';
import { getGrafanaSearcher, SearchQuery } from '../../service';
import { GENERAL_FOLDER_UID } from '../../constants';
import { getGrafanaSearcher } from '../../service';
import { getFolderChildren } from '../../service/folders';
import { queryResultToViewItem } from '../../service/utils';
import { DashboardViewItem } from '../../types';
import { SelectionChecker, SelectionToggle } from '../selection';
@ -25,6 +28,27 @@ interface SectionHeaderProps {
tags?: string[];
}
async function getChildren(section: DashboardViewItem, tags: string[] | undefined): Promise<DashboardViewItem[]> {
if (config.featureToggles.nestedFolders) {
return getFolderChildren(section.uid, section.title);
}
const query = section.itemsUIDs
? {
uid: section.itemsUIDs,
}
: {
query: '*',
kind: ['dashboard'],
location: section.uid,
sort: 'name_sort',
limit: 1000, // this component does not have infinite scroll, so we need to load everything upfront
};
const raw = await getGrafanaSearcher().search({ ...query, tags });
return raw.view.map((v) => queryResultToViewItem(v, raw.view));
}
export const FolderSection = ({
section,
selectionToggle,
@ -42,22 +66,10 @@ export const FolderSection = ({
if (!sectionExpanded && !renderStandaloneBody) {
return Promise.resolve([]);
}
let query: SearchQuery = {
query: '*',
kind: ['dashboard'],
location: section.uid,
sort: 'name_sort',
limit: 1000, // this component does not have infinate scroll, so we need to load everything upfront
};
if (section.itemsUIDs) {
query = {
uid: section.itemsUIDs, // array of UIDs
};
}
const raw = await getGrafanaSearcher().search({ ...query, tags });
const items = raw.view.map((v) => queryResultToViewItem(v, raw.view));
return items;
const childItems = getChildren(section, tags);
return childItems;
}, [sectionExpanded, tags]);
const onSectionExpand = () => {
@ -72,8 +84,8 @@ export const FolderSection = ({
selectionToggle(section.kind, section.uid);
const sub = results.value ?? [];
for (const item of sub) {
if (selection('dashboard', item.uid!) !== checked) {
selectionToggle('dashboard', item.uid!);
if (selection(item.kind, item.uid!) !== checked) {
selectionToggle(item.kind, item.uid!);
}
}
}
@ -104,14 +116,10 @@ export const FolderSection = ({
key={item.uid}
item={item}
onTagSelected={onTagSelected}
onToggleChecked={(item) => {
if (selectionToggle) {
selectionToggle('dashboard', item.uid!);
}
}}
onToggleChecked={(item) => selectionToggle?.(item.kind, item.uid)}
editable={Boolean(selection != null)}
onClickItem={onClickItem}
isSelected={selectionToggle && selection?.(item.kind, item.uid)}
isSelected={selection?.(item.kind, item.uid)}
/>
);
});
@ -153,7 +161,7 @@ export const FolderSection = ({
<div className={styles.text}>
<span id={labelId}>{section.title}</span>
{section.url && section.uid !== 'general' && (
{section.url && section.uid !== GENERAL_FOLDER_UID && (
<a href={section.url} className={styles.link}>
<span className={styles.separator}>|</span> <Icon name="folder-upload" />{' '}
{t('search.folder-view.go-to-folder', 'Go to folder')}
@ -170,6 +178,7 @@ export const FolderSection = ({
const getSectionHeaderStyles = (theme: GrafanaTheme2, editable: boolean) => {
const sm = theme.spacing(1);
return {
wrapper: css`
align-items: center;

View File

@ -9,7 +9,7 @@ import impressionSrv from '../../../../core/services/impression_srv';
import { DashboardQueryResult, getGrafanaSearcher, QueryResponse } from '../../service';
import { DashboardSearchItemType } from '../../types';
import { FolderView } from './FolderView';
import { RootFolderView } from './RootFolderView';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
@ -18,7 +18,7 @@ jest.mock('@grafana/runtime', () => ({
}),
}));
describe('FolderView', () => {
describe('RootFolderView', () => {
let grafanaSearcherSpy: jest.SpyInstance;
const mockOnTagSelected = jest.fn();
const mockSelectionToggle = jest.fn();
@ -69,7 +69,11 @@ describe('FolderView', () => {
grafanaSearcherSpy.mockImplementationOnce(() => promise);
render(
<FolderView onTagSelected={mockOnTagSelected} selection={mockSelection} selectionToggle={mockSelectionToggle} />
<RootFolderView
onTagSelected={mockOnTagSelected}
selection={mockSelection}
selectionToggle={mockSelectionToggle}
/>
);
expect(await screen.findByTestId('Spinner')).toBeInTheDocument();
@ -84,7 +88,11 @@ describe('FolderView', () => {
it('does not show the starred items if not signed in', async () => {
contextSrv.isSignedIn = false;
render(
<FolderView onTagSelected={mockOnTagSelected} selection={mockSelection} selectionToggle={mockSelectionToggle} />
<RootFolderView
onTagSelected={mockOnTagSelected}
selection={mockSelection}
selectionToggle={mockSelectionToggle}
/>
);
expect((await screen.findAllByTestId(selectors.components.Search.sectionV2))[0]).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Starred' })).not.toBeInTheDocument();
@ -93,7 +101,11 @@ describe('FolderView', () => {
it('shows the starred items if signed in', async () => {
contextSrv.isSignedIn = true;
render(
<FolderView onTagSelected={mockOnTagSelected} selection={mockSelection} selectionToggle={mockSelectionToggle} />
<RootFolderView
onTagSelected={mockOnTagSelected}
selection={mockSelection}
selectionToggle={mockSelectionToggle}
/>
);
expect(await screen.findByRole('button', { name: 'Starred' })).toBeInTheDocument();
});
@ -101,7 +113,11 @@ describe('FolderView', () => {
it('does not show the recent items if no dashboards have been opened recently', async () => {
jest.spyOn(impressionSrv, 'getDashboardOpened').mockResolvedValue([]);
render(
<FolderView onTagSelected={mockOnTagSelected} selection={mockSelection} selectionToggle={mockSelectionToggle} />
<RootFolderView
onTagSelected={mockOnTagSelected}
selection={mockSelection}
selectionToggle={mockSelectionToggle}
/>
);
expect((await screen.findAllByTestId(selectors.components.Search.sectionV2))[0]).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Recent' })).not.toBeInTheDocument();
@ -110,14 +126,22 @@ describe('FolderView', () => {
it('shows the recent items if any dashboards have recently been opened', async () => {
jest.spyOn(impressionSrv, 'getDashboardOpened').mockResolvedValue(['7MeksYbmk']);
render(
<FolderView onTagSelected={mockOnTagSelected} selection={mockSelection} selectionToggle={mockSelectionToggle} />
<RootFolderView
onTagSelected={mockOnTagSelected}
selection={mockSelection}
selectionToggle={mockSelectionToggle}
/>
);
expect(await screen.findByRole('button', { name: 'Recent' })).toBeInTheDocument();
});
it('shows the general folder by default', async () => {
render(
<FolderView onTagSelected={mockOnTagSelected} selection={mockSelection} selectionToggle={mockSelectionToggle} />
<RootFolderView
onTagSelected={mockOnTagSelected}
selection={mockSelection}
selectionToggle={mockSelectionToggle}
/>
);
expect(await screen.findByRole('button', { name: 'General' })).toBeInTheDocument();
});
@ -126,7 +150,7 @@ describe('FolderView', () => {
it('does not show the starred items even if signed in', async () => {
contextSrv.isSignedIn = true;
render(
<FolderView
<RootFolderView
hidePseudoFolders
onTagSelected={mockOnTagSelected}
selection={mockSelection}
@ -140,7 +164,7 @@ describe('FolderView', () => {
it('does not show the recent items even if recent dashboards have been opened', async () => {
jest.spyOn(impressionSrv, 'getDashboardOpened').mockResolvedValue(['7MeksYbmk']);
render(
<FolderView
<RootFolderView
hidePseudoFolders
onTagSelected={mockOnTagSelected}
selection={mockSelection}
@ -156,7 +180,11 @@ describe('FolderView', () => {
// reject with a specific Error object
grafanaSearcherSpy.mockRejectedValueOnce(new Error('Uh oh spagghettios!'));
render(
<FolderView onTagSelected={mockOnTagSelected} selection={mockSelection} selectionToggle={mockSelectionToggle} />
<RootFolderView
onTagSelected={mockOnTagSelected}
selection={mockSelection}
selectionToggle={mockSelectionToggle}
/>
);
expect(await screen.findByRole('alert', { name: 'Uh oh spagghettios!' })).toBeInTheDocument();
});
@ -165,7 +193,11 @@ describe('FolderView', () => {
// reject with nothing
grafanaSearcherSpy.mockRejectedValueOnce(null);
render(
<FolderView onTagSelected={mockOnTagSelected} selection={mockSelection} selectionToggle={mockSelectionToggle} />
<RootFolderView
onTagSelected={mockOnTagSelected}
selection={mockSelection}
selectionToggle={mockSelectionToggle}
/>
);
expect(await screen.findByRole('alert', { name: 'Something went wrong' })).toBeInTheDocument();
});

View File

@ -6,23 +6,39 @@ import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { getBackendSrv } from '@grafana/runtime';
import { Alert, Spinner, useStyles2 } from '@grafana/ui';
import config from 'app/core/config';
import { contextSrv } from '../../../../core/services/context_srv';
import impressionSrv from '../../../../core/services/impression_srv';
import { GENERAL_FOLDER_UID } from '../../constants';
import { getGrafanaSearcher } from '../../service';
import { getFolderChildren } from '../../service/folders';
import { queryResultToViewItem } from '../../service/utils';
import { DashboardViewItem } from '../../types';
import { SearchResultsProps } from '../components/SearchResultsTable';
import { FolderSection } from './FolderSection';
import { SearchResultsProps } from './SearchResultsTable';
async function getChildren() {
if (config.featureToggles.nestedFolders) {
return getFolderChildren();
}
const searcher = getGrafanaSearcher();
const results = await searcher.search({
query: '*',
kind: ['folder'],
sort: searcher.getFolderViewSort(),
limit: 1000,
});
return results.view.map((v) => queryResultToViewItem(v, results.view));
}
type Props = Pick<SearchResultsProps, 'selection' | 'selectionToggle' | 'onTagSelected' | 'onClickItem'> & {
tags?: string[];
hidePseudoFolders?: boolean;
};
export const FolderView = ({
export const RootFolderView = ({
selection,
selectionToggle,
onTagSelected,
@ -33,33 +49,22 @@ export const FolderView = ({
const styles = useStyles2(getStyles);
const results = useAsync(async () => {
const folders: DashboardViewItem[] = [];
const folders = await getChildren();
folders.unshift({ title: 'General', url: '/dashboards', kind: 'folder', uid: GENERAL_FOLDER_UID });
if (!hidePseudoFolders) {
const itemsUIDs = await impressionSrv.getDashboardOpened();
if (itemsUIDs.length) {
folders.unshift({ title: 'Recent', icon: 'clock-nine', kind: 'folder', uid: '__recent', itemsUIDs });
}
if (contextSrv.isSignedIn) {
const stars = await getBackendSrv().get('api/user/stars');
if (stars.length > 0) {
folders.push({ title: 'Starred', icon: 'star', kind: 'folder', uid: '__starred', itemsUIDs: stars });
folders.unshift({ title: 'Starred', icon: 'star', kind: 'folder', uid: '__starred', itemsUIDs: stars });
}
}
const itemsUIDs = await impressionSrv.getDashboardOpened();
if (itemsUIDs.length) {
folders.push({ title: 'Recent', icon: 'clock-nine', kind: 'folder', uid: '__recent', itemsUIDs });
}
}
folders.push({ title: 'General', url: '/dashboards', kind: 'folder', uid: GENERAL_FOLDER_UID });
const searcher = getGrafanaSearcher();
const results = await searcher.search({
query: '*',
kind: ['folder'],
sort: searcher.getFolderViewSort(),
limit: 1000,
});
for (const row of results.view) {
folders.push(queryResultToViewItem(row, results.view));
}
return folders;

View File

@ -48,6 +48,7 @@ export const SearchResultsCards = React.memo(
const item = response.view.get(rowIndex);
const searchItem = queryResultToViewItem(item, response.view);
const isSelected = selectionToggle && selection?.(searchItem.kind, searchItem.uid);
return (
<div style={style} key={item.uid} className={className} role="row">
@ -61,7 +62,7 @@ export const SearchResultsCards = React.memo(
}}
editable={Boolean(selection != null)}
onClickItem={onClickItem}
isSelected={selectionToggle && selection?.(searchItem.kind, searchItem.uid)}
isSelected={isSelected}
/>
</div>
);

View File

@ -5,6 +5,7 @@ import AutoSizer from 'react-virtualized-auto-sizer';
import { Observable } from 'rxjs';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { useStyles2, Spinner, Button } from '@grafana/ui';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { FolderDTO } from 'app/types';
@ -12,13 +13,13 @@ import { FolderDTO } from 'app/types';
import { PreviewsSystemRequirements } from '../../components/PreviewsSystemRequirements';
import { getGrafanaSearcher } from '../../service';
import { getSearchStateManager } from '../../state/SearchStateManager';
import { SearchLayout } from '../../types';
import { SearchLayout, DashboardViewItem } from '../../types';
import { newSearchSelection, updateSearchSelection } from '../selection';
import { ActionRow, getValidQueryLayout } from './ActionRow';
import { FolderSection } from './FolderSection';
import { FolderView } from './FolderView';
import { ManageActions } from './ManageActions';
import { RootFolderView } from './RootFolderView';
import { SearchResultsCards } from './SearchResultsCards';
import { SearchResultsGrid } from './SearchResultsGrid';
import { SearchResultsTable, SearchResultsProps } from './SearchResultsTable';
@ -86,11 +87,12 @@ export const SearchView = ({ showManage, folderDTO, hidePseudoFolders, keyboardE
}
const selection = showManage ? searchSelection.isSelected : undefined;
if (layout === SearchLayout.Folders) {
if (folderDTO) {
return (
<FolderSection
section={{ uid: folderDTO.uid, kind: 'folder', title: folderDTO.title }}
section={sectionForFolderView(folderDTO)}
selection={selection}
selectionToggle={toggleSelection}
onTagSelected={stateManager.onAddTag}
@ -102,7 +104,7 @@ export const SearchView = ({ showManage, folderDTO, hidePseudoFolders, keyboardE
);
}
return (
<FolderView
<RootFolderView
key={listKey}
selection={selection}
selectionToggle={toggleSelection}
@ -146,7 +148,15 @@ export const SearchView = ({ showManage, folderDTO, hidePseudoFolders, keyboardE
);
};
if (folderDTO && !state.loading && !state.result?.totalRows && !stateManager.hasSearchFilters()) {
if (
folderDTO &&
// With nested folders, SearchView doesn't know if it's fetched all children
// of a folder so don't show empty state here.
!config.featureToggles.nestedFolders &&
!state.loading &&
!state.result?.totalRows &&
!stateManager.hasSearchFilters()
) {
return (
<EmptyListCTA
title="This folder doesn't have any dashboards yet"
@ -215,3 +225,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
margin-top: ${theme.v1.spacing.md};
`,
});
function sectionForFolderView(folderDTO: FolderDTO): DashboardViewItem {
return { uid: folderDTO.uid, kind: 'folder', title: folderDTO.title };
}

View File

@ -0,0 +1,52 @@
import { getBackendSrv } from '@grafana/runtime';
import config from 'app/core/config';
import { DashboardViewItem } from '../types';
import { getGrafanaSearcher } from './searcher';
import { NestedFolderDTO } from './types';
import { queryResultToViewItem } from './utils';
export async function getFolderChildren(parentUid?: string, parentTitle?: string): Promise<DashboardViewItem[]> {
if (!config.featureToggles.nestedFolders) {
console.error('getFolderChildren requires nestedFolders feature toggle');
return [];
}
if (!parentUid) {
// We don't show dashboards at root in folder view yet - they're shown under a dummy 'general'
// folder that FolderView adds in
const folders = await getChildFolders();
return folders;
}
const searcher = getGrafanaSearcher();
const dashboardsResults = await searcher.search({
kind: ['dashboard'],
query: '*',
location: parentUid,
limit: 1000,
});
const dashboardItems = dashboardsResults.view.map((item) => {
return queryResultToViewItem(item, dashboardsResults.view);
});
const folders = await getChildFolders(parentUid, parentTitle);
return [...folders, ...dashboardItems];
}
async function getChildFolders(parentUid?: string, parentTitle?: string): Promise<DashboardViewItem[]> {
const backendSrv = getBackendSrv();
const folders = await backendSrv.get<NestedFolderDTO[]>('/api/folders', { parentUid });
return folders.map((item) => ({
kind: 'folder',
uid: item.uid,
title: item.title,
parentTitle,
url: `/dashboards/f/${item.uid}/`,
}));
}

View File

@ -81,3 +81,8 @@ export interface GrafanaSearcher {
/** Gets the default sort used for the Folder view */
getFolderViewSort: () => string;
}
export interface NestedFolderDTO {
uid: string;
title: string;
}

View File

@ -170,7 +170,7 @@ export function DashList(props: PanelProps<PanelOptions>) {
<ul className={css.gridContainer}>
{dashboards.map((dash) => (
<li key={dash.uid}>
<SearchCard item={{ ...dash, kind: 'folder' }} />
<SearchCard item={{ ...dash, kind: 'dashboard' }} />
</li>
))}
</ul>

View File

@ -9,8 +9,7 @@
"action": {
"change-theme": "",
"dark-theme": "",
"light-theme": "",
"search": ""
"light-theme": ""
},
"search-box": {
"placeholder": ""
@ -84,7 +83,6 @@
},
"toolbar": {
"add-panel": "Panel hinzufügen",
"comments-show": "Dashboard-Kommentare anzeigen",
"mark-favorite": "Als Favorit markieren",
"open-original": "Original-Dashboard öffnen",
"playlist-next": "Zum nächsten Dashboard",
@ -292,7 +290,6 @@
"title": "Szenen"
},
"search": {
"placeholder": "Grafana durchsuchen",
"placeholderCommandPalette": ""
},
"search-dashboards": {
@ -415,6 +412,11 @@
"go-to-folder": "",
"select-folder": ""
},
"result-kind": {
"dashboard": "",
"folder": "",
"panel": ""
},
"results-table": {
"datasource-header": "",
"location-header": "",

View File

@ -9,8 +9,7 @@
"action": {
"change-theme": "Change theme...",
"dark-theme": "Dark",
"light-theme": "Light",
"search": "Search"
"light-theme": "Light"
},
"search-box": {
"placeholder": "Search or jump to..."
@ -84,7 +83,6 @@
},
"toolbar": {
"add-panel": "Add panel",
"comments-show": "Show dashboard comments",
"mark-favorite": "Mark as favorite",
"open-original": "Open original dashboard",
"playlist-next": "Go to next dashboard",
@ -292,7 +290,6 @@
"title": "Scenes"
},
"search": {
"placeholder": "Search Grafana",
"placeholderCommandPalette": "Search or jump to..."
},
"search-dashboards": {
@ -415,6 +412,11 @@
"go-to-folder": "Go to folder",
"select-folder": "Select folder"
},
"result-kind": {
"dashboard": "Dashboard",
"folder": "Folder",
"panel": "Panel"
},
"results-table": {
"datasource-header": "Data source",
"location-header": "Location",

View File

@ -9,8 +9,7 @@
"action": {
"change-theme": "",
"dark-theme": "",
"light-theme": "",
"search": ""
"light-theme": ""
},
"search-box": {
"placeholder": ""
@ -84,7 +83,6 @@
},
"toolbar": {
"add-panel": "Añadir panel",
"comments-show": "Mostrar comentarios del panel de control",
"mark-favorite": "Marcar como favorito",
"open-original": "Abrir el panel de control original",
"playlist-next": "Ir al siguiente panel de control",
@ -292,7 +290,6 @@
"title": "Escenas"
},
"search": {
"placeholder": "Buscar Grafana",
"placeholderCommandPalette": ""
},
"search-dashboards": {
@ -415,6 +412,11 @@
"go-to-folder": "",
"select-folder": ""
},
"result-kind": {
"dashboard": "",
"folder": "",
"panel": ""
},
"results-table": {
"datasource-header": "",
"location-header": "",

View File

@ -9,8 +9,7 @@
"action": {
"change-theme": "",
"dark-theme": "",
"light-theme": "",
"search": ""
"light-theme": ""
},
"search-box": {
"placeholder": ""
@ -84,7 +83,6 @@
},
"toolbar": {
"add-panel": "Ajouter un panneau",
"comments-show": "Afficher les commentaires du tableau de bord",
"mark-favorite": "Marquer comme favori",
"open-original": "Ouvrir le tableau de bord d'origine",
"playlist-next": "Accéder au tableau de bord suivant",
@ -292,7 +290,6 @@
"title": "Scènes"
},
"search": {
"placeholder": "Rechercher dans Grafana",
"placeholderCommandPalette": ""
},
"search-dashboards": {
@ -415,6 +412,11 @@
"go-to-folder": "",
"select-folder": ""
},
"result-kind": {
"dashboard": "",
"folder": "",
"panel": ""
},
"results-table": {
"datasource-header": "",
"location-header": "",

View File

@ -9,8 +9,7 @@
"action": {
"change-theme": "Cĥäʼnģę ŧĥęmę...",
"dark-theme": "Đäřĸ",
"light-theme": "Ŀįģĥŧ",
"search": "Ŝęäřčĥ"
"light-theme": "Ŀįģĥŧ"
},
"search-box": {
"placeholder": "Ŝęäřčĥ őř ĵūmp ŧő..."
@ -84,7 +83,6 @@
},
"toolbar": {
"add-panel": "Åđđ päʼnęľ",
"comments-show": "Ŝĥőŵ đäşĥþőäřđ čőmmęʼnŧş",
"mark-favorite": "Mäřĸ äş ƒävőřįŧę",
"open-original": "Øpęʼn őřįģįʼnäľ đäşĥþőäřđ",
"playlist-next": "Ğő ŧő ʼnęχŧ đäşĥþőäřđ",
@ -292,7 +290,6 @@
"title": "Ŝčęʼnęş"
},
"search": {
"placeholder": "Ŝęäřčĥ Ğřäƒäʼnä",
"placeholderCommandPalette": "Ŝęäřčĥ őř ĵūmp ŧő..."
},
"search-dashboards": {
@ -415,6 +412,11 @@
"go-to-folder": "Ğő ŧő ƒőľđęř",
"select-folder": "Ŝęľęčŧ ƒőľđęř"
},
"result-kind": {
"dashboard": "Đäşĥþőäřđ",
"folder": "Főľđęř",
"panel": "Päʼnęľ"
},
"results-table": {
"datasource-header": "Đäŧä şőūřčę",
"location-header": "Ŀőčäŧįőʼn",

View File

@ -9,8 +9,7 @@
"action": {
"change-theme": "",
"dark-theme": "",
"light-theme": "",
"search": ""
"light-theme": ""
},
"search-box": {
"placeholder": ""
@ -84,7 +83,6 @@
},
"toolbar": {
"add-panel": "添加面板",
"comments-show": "显示仪表板备注",
"mark-favorite": "标记为收藏",
"open-original": "打开原始仪表板",
"playlist-next": "前往下一个仪表板",
@ -292,7 +290,6 @@
"title": "场景"
},
"search": {
"placeholder": "搜索 Grafana",
"placeholderCommandPalette": ""
},
"search-dashboards": {
@ -415,6 +412,11 @@
"go-to-folder": "",
"select-folder": ""
},
"result-kind": {
"dashboard": "",
"folder": "",
"panel": ""
},
"results-table": {
"datasource-header": "",
"location-header": "",