mirror of https://github.com/grafana/grafana
Chore: Remove `newBrowseDashboards` feature toggle (#78190)
* remove all the things * fix OldFolderPicker tests * i18n * remove more unused code * remove mutation of error object since it's now frozen in the redux state * fix error handlingpull/78545/head
parent
40c8e2fc75
commit
4290ed3d86
|
@ -1,5 +0,0 @@ |
||||
import { config } from '@grafana/runtime'; |
||||
|
||||
export function newBrowseDashboardsEnabled() { |
||||
return config.featureToggles.nestedFolders || config.featureToggles.newBrowseDashboards; |
||||
} |
@ -1,47 +0,0 @@ |
||||
import React, { useEffect } from 'react'; |
||||
import { connect, ConnectedProps } from 'react-redux'; |
||||
|
||||
import { Permissions } from 'app/core/components/AccessControl'; |
||||
import { Page } from 'app/core/components/Page/Page'; |
||||
import { contextSrv } from 'app/core/core'; |
||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; |
||||
import { getNavModel } from 'app/core/selectors/navModel'; |
||||
import { AccessControlAction, StoreState } from 'app/types'; |
||||
|
||||
import { getFolderByUid } from './state/actions'; |
||||
import { getLoadingNav } from './state/navModel'; |
||||
|
||||
interface RouteProps extends GrafanaRouteComponentProps<{ uid: string }> {} |
||||
|
||||
function mapStateToProps(state: StoreState, props: RouteProps) { |
||||
const uid = props.match.params.uid; |
||||
return { |
||||
uid: uid, |
||||
pageNav: getNavModel(state.navIndex, `folder-permissions-${uid}`, getLoadingNav(1)), |
||||
}; |
||||
} |
||||
|
||||
const mapDispatchToProps = { |
||||
getFolderByUid, |
||||
}; |
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps); |
||||
export type Props = ConnectedProps<typeof connector>; |
||||
|
||||
export const AccessControlFolderPermissions = ({ uid, getFolderByUid, pageNav }: Props) => { |
||||
useEffect(() => { |
||||
getFolderByUid(uid); |
||||
}, [getFolderByUid, uid]); |
||||
|
||||
const canSetPermissions = contextSrv.hasPermission(AccessControlAction.FoldersPermissionsWrite); |
||||
|
||||
return ( |
||||
<Page navId="dashboards/browse" pageNav={pageNav.main}> |
||||
<Page.Contents> |
||||
<Permissions resource="folders" resourceId={uid} canSetPermissions={canSetPermissions} /> |
||||
</Page.Contents> |
||||
</Page> |
||||
); |
||||
}; |
||||
|
||||
export default connector(AccessControlFolderPermissions); |
@ -1,35 +0,0 @@ |
||||
import React from 'react'; |
||||
import { useAsync } from 'react-use'; |
||||
|
||||
import { Page } from 'app/core/components/Page/Page'; |
||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; |
||||
import { getNavModel } from 'app/core/selectors/navModel'; |
||||
import { useDispatch, useSelector } from 'app/types'; |
||||
|
||||
import { AlertsFolderView } from '../alerting/unified/AlertsFolderView'; |
||||
|
||||
import { getFolderByUid } from './state/actions'; |
||||
import { getLoadingNav } from './state/navModel'; |
||||
|
||||
export interface OwnProps extends GrafanaRouteComponentProps<{ uid: string }> {} |
||||
|
||||
const FolderAlerting = ({ match }: OwnProps) => { |
||||
const dispatch = useDispatch(); |
||||
const navIndex = useSelector((state) => state.navIndex); |
||||
const folder = useSelector((state) => state.folder); |
||||
|
||||
const uid = match.params.uid; |
||||
const pageNav = getNavModel(navIndex, `folder-alerting-${uid}`, getLoadingNav(1)); |
||||
|
||||
const { loading } = useAsync(async () => dispatch(getFolderByUid(uid)), [getFolderByUid, uid]); |
||||
|
||||
return ( |
||||
<Page navId="dashboards/browse" pageNav={pageNav.main}> |
||||
<Page.Contents isLoading={loading}> |
||||
<AlertsFolderView folder={folder} /> |
||||
</Page.Contents> |
||||
</Page> |
||||
); |
||||
}; |
||||
|
||||
export default FolderAlerting; |
@ -1,55 +0,0 @@ |
||||
import React, { useState } from 'react'; |
||||
import { connect, ConnectedProps } from 'react-redux'; |
||||
import { useAsync } from 'react-use'; |
||||
|
||||
import { Page } from 'app/core/components/Page/Page'; |
||||
|
||||
import { GrafanaRouteComponentProps } from '../../core/navigation/types'; |
||||
import { getNavModel } from '../../core/selectors/navModel'; |
||||
import { StoreState } from '../../types'; |
||||
import { LibraryPanelsSearch } from '../library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch'; |
||||
import { OpenLibraryPanelModal } from '../library-panels/components/OpenLibraryPanelModal/OpenLibraryPanelModal'; |
||||
import { LibraryElementDTO } from '../library-panels/types'; |
||||
|
||||
import { getFolderByUid } from './state/actions'; |
||||
import { getLoadingNav } from './state/navModel'; |
||||
|
||||
export interface OwnProps extends GrafanaRouteComponentProps<{ uid: string }> {} |
||||
|
||||
const mapStateToProps = (state: StoreState, props: OwnProps) => { |
||||
const uid = props.match.params.uid; |
||||
return { |
||||
pageNav: getNavModel(state.navIndex, `folder-library-panels-${uid}`, getLoadingNav(1)), |
||||
folderUid: uid, |
||||
}; |
||||
}; |
||||
|
||||
const mapDispatchToProps = { |
||||
getFolderByUid, |
||||
}; |
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps); |
||||
|
||||
export type Props = OwnProps & ConnectedProps<typeof connector>; |
||||
|
||||
export function FolderLibraryPanelsPage({ pageNav, getFolderByUid, folderUid }: Props): JSX.Element { |
||||
const { loading } = useAsync(async () => await getFolderByUid(folderUid), [getFolderByUid, folderUid]); |
||||
const [selected, setSelected] = useState<LibraryElementDTO | undefined>(undefined); |
||||
|
||||
return ( |
||||
<Page navId="dashboards/browse" pageNav={pageNav.main}> |
||||
<Page.Contents isLoading={loading}> |
||||
<LibraryPanelsSearch |
||||
onClick={setSelected} |
||||
currentFolderUID={folderUid} |
||||
showSecondaryActions |
||||
showSort |
||||
showPanelFilter |
||||
/> |
||||
{selected ? <OpenLibraryPanelModal onDismiss={() => setSelected(undefined)} libraryPanel={selected} /> : null} |
||||
</Page.Contents> |
||||
</Page> |
||||
); |
||||
} |
||||
|
||||
export default connector(FolderLibraryPanelsPage); |
@ -1,189 +0,0 @@ |
||||
import { render, screen, within } from '@testing-library/react'; |
||||
import userEvent from '@testing-library/user-event'; |
||||
import React from 'react'; |
||||
import { mockToolkitActionCreator } from 'test/core/redux/mocks'; |
||||
import { TestProvider } from 'test/helpers/TestProvider'; |
||||
|
||||
import { NavModel } from '@grafana/data'; |
||||
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps'; |
||||
import { ModalManager } from 'app/core/services/ModalManager'; |
||||
|
||||
import { FolderSettingsPage, Props } from './FolderSettingsPage'; |
||||
import { setFolderTitle } from './state/reducers'; |
||||
|
||||
const setup = (propOverrides?: object) => { |
||||
const props: Props = { |
||||
...getRouteComponentProps(), |
||||
pageNav: {} as NavModel, |
||||
folderUid: '1234', |
||||
folder: { |
||||
id: 0, |
||||
uid: '1234', |
||||
title: 'loading', |
||||
canSave: true, |
||||
canDelete: true, |
||||
url: 'url', |
||||
hasChanged: false, |
||||
version: 1, |
||||
}, |
||||
getFolderByUid: jest.fn(), |
||||
setFolderTitle: mockToolkitActionCreator(setFolderTitle), |
||||
saveFolder: jest.fn(), |
||||
deleteFolder: jest.fn(), |
||||
}; |
||||
|
||||
Object.assign(props, propOverrides); |
||||
|
||||
render( |
||||
<TestProvider> |
||||
<FolderSettingsPage {...props} /> |
||||
</TestProvider> |
||||
); |
||||
}; |
||||
|
||||
describe('FolderSettingsPage', () => { |
||||
it('should render without error', () => { |
||||
expect(() => setup()).not.toThrow(); |
||||
}); |
||||
|
||||
it('should enable save button when canSave is true and hasChanged is true', () => { |
||||
setup({ |
||||
folder: { |
||||
id: 1, |
||||
uid: '1234', |
||||
title: 'loading', |
||||
canSave: true, |
||||
canDelete: true, |
||||
hasChanged: true, |
||||
version: 1, |
||||
}, |
||||
}); |
||||
const saveButton = screen.getByRole('button', { name: 'Save' }); |
||||
expect(saveButton).not.toBeDisabled(); |
||||
}); |
||||
|
||||
it('should disable save button when canSave is false and hasChanged is false', () => { |
||||
setup({ |
||||
folder: { |
||||
id: 1, |
||||
uid: '1234', |
||||
title: 'loading', |
||||
canSave: false, |
||||
canDelete: true, |
||||
hasChanged: false, |
||||
version: 1, |
||||
}, |
||||
}); |
||||
const saveButton = screen.getByRole('button', { name: 'Save' }); |
||||
expect(saveButton).toBeDisabled(); |
||||
}); |
||||
|
||||
it('should disable save button when canSave is true and hasChanged is false', () => { |
||||
setup({ |
||||
folder: { |
||||
id: 1, |
||||
uid: '1234', |
||||
title: 'loading', |
||||
canSave: true, |
||||
canDelete: true, |
||||
hasChanged: false, |
||||
version: 1, |
||||
}, |
||||
}); |
||||
const saveButton = screen.getByRole('button', { name: 'Save' }); |
||||
expect(saveButton).toBeDisabled(); |
||||
}); |
||||
|
||||
it('should disable save button when canSave is false and hasChanged is true', () => { |
||||
setup({ |
||||
folder: { |
||||
id: 1, |
||||
uid: '1234', |
||||
title: 'loading', |
||||
canSave: false, |
||||
canDelete: true, |
||||
hasChanged: true, |
||||
version: 1, |
||||
}, |
||||
}); |
||||
const saveButton = screen.getByRole('button', { name: 'Save' }); |
||||
expect(saveButton).toBeDisabled(); |
||||
}); |
||||
|
||||
it('should call onSave when the saveButton is clicked', async () => { |
||||
const mockSaveFolder = jest.fn(); |
||||
const mockFolder = { |
||||
id: 1, |
||||
uid: '1234', |
||||
title: 'loading', |
||||
canSave: true, |
||||
canDelete: true, |
||||
hasChanged: true, |
||||
version: 1, |
||||
}; |
||||
setup({ |
||||
folder: mockFolder, |
||||
saveFolder: mockSaveFolder, |
||||
}); |
||||
const saveButton = screen.getByRole('button', { name: 'Save' }); |
||||
await userEvent.click(saveButton); |
||||
expect(mockSaveFolder).toHaveBeenCalledWith(mockFolder); |
||||
}); |
||||
|
||||
it('should disable delete button when canDelete is false', () => { |
||||
setup({ |
||||
folder: { |
||||
id: 1, |
||||
uid: '1234', |
||||
title: 'loading', |
||||
canSave: true, |
||||
canDelete: false, |
||||
hasChanged: true, |
||||
version: 1, |
||||
}, |
||||
}); |
||||
const deleteButton = screen.getByRole('button', { name: 'Delete' }); |
||||
expect(deleteButton).toBeDisabled(); |
||||
}); |
||||
|
||||
it('should enable delete button when canDelete is true', () => { |
||||
setup({ |
||||
folder: { |
||||
id: 1, |
||||
uid: '1234', |
||||
title: 'loading', |
||||
canSave: true, |
||||
canDelete: true, |
||||
hasChanged: true, |
||||
version: 1, |
||||
}, |
||||
}); |
||||
const deleteButton = screen.getByRole('button', { name: 'Delete' }); |
||||
expect(deleteButton).not.toBeDisabled(); |
||||
}); |
||||
|
||||
it('should call the publish event when the deleteButton is clicked', async () => { |
||||
new ModalManager().init(); |
||||
const mockDeleteFolder = jest.fn(); |
||||
const mockFolder = { |
||||
id: 1, |
||||
uid: '1234', |
||||
title: 'loading', |
||||
canSave: true, |
||||
canDelete: true, |
||||
hasChanged: true, |
||||
version: 1, |
||||
}; |
||||
setup({ |
||||
folder: mockFolder, |
||||
deleteFolder: mockDeleteFolder, |
||||
}); |
||||
const deleteButton = screen.getByRole('button', { name: 'Delete' }); |
||||
await userEvent.click(deleteButton); |
||||
const deleteModal = screen.getByRole('dialog', { name: 'Delete' }); |
||||
expect(deleteModal).toBeInTheDocument(); |
||||
const deleteButtonModal = within(deleteModal).getByRole('button', { name: 'Delete' }); |
||||
await userEvent.click(deleteButtonModal); |
||||
expect(mockDeleteFolder).toHaveBeenCalledWith(mockFolder.uid); |
||||
}); |
||||
}); |
@ -1,119 +0,0 @@ |
||||
import React, { PureComponent } from 'react'; |
||||
import { connect, ConnectedProps } from 'react-redux'; |
||||
|
||||
import { Field, Form, Button, Input, InputControl } from '@grafana/ui'; |
||||
import appEvents from 'app/core/app_events'; |
||||
import { Page } from 'app/core/components/Page/Page'; |
||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; |
||||
import { getNavModel } from 'app/core/selectors/navModel'; |
||||
import { StoreState } from 'app/types'; |
||||
|
||||
import { ShowConfirmModalEvent } from '../../types/events'; |
||||
|
||||
import { deleteFolder, getFolderByUid, saveFolder } from './state/actions'; |
||||
import { getLoadingNav } from './state/navModel'; |
||||
import { setFolderTitle } from './state/reducers'; |
||||
|
||||
export interface OwnProps extends GrafanaRouteComponentProps<{ uid: string }> {} |
||||
|
||||
const mapStateToProps = (state: StoreState, props: OwnProps) => { |
||||
const uid = props.match.params.uid; |
||||
return { |
||||
pageNav: getNavModel(state.navIndex, `folder-settings-${uid}`, getLoadingNav(2)), |
||||
folderUid: uid, |
||||
folder: state.folder, |
||||
}; |
||||
}; |
||||
|
||||
const mapDispatchToProps = { |
||||
getFolderByUid, |
||||
saveFolder, |
||||
setFolderTitle, |
||||
deleteFolder, |
||||
}; |
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps); |
||||
|
||||
export type Props = OwnProps & ConnectedProps<typeof connector>; |
||||
|
||||
export interface State { |
||||
isLoading: boolean; |
||||
} |
||||
|
||||
export class FolderSettingsPage extends PureComponent<Props, State> { |
||||
constructor(props: Props) { |
||||
super(props); |
||||
this.state = { |
||||
isLoading: false, |
||||
}; |
||||
} |
||||
|
||||
componentDidMount() { |
||||
this.props.getFolderByUid(this.props.folderUid); |
||||
} |
||||
|
||||
onTitleChange = (evt: React.ChangeEvent<HTMLInputElement>) => { |
||||
this.props.setFolderTitle(evt.target.value); |
||||
}; |
||||
|
||||
onSave = () => { |
||||
this.setState({ isLoading: true }); |
||||
this.props.saveFolder(this.props.folder); |
||||
this.setState({ isLoading: false }); |
||||
}; |
||||
|
||||
onDelete = (evt: React.MouseEvent<HTMLButtonElement>) => { |
||||
evt.stopPropagation(); |
||||
evt.preventDefault(); |
||||
|
||||
const confirmationText = `Do you want to delete this folder and all its dashboards and alerts?`; |
||||
appEvents.publish( |
||||
new ShowConfirmModalEvent({ |
||||
title: 'Delete', |
||||
text: confirmationText, |
||||
icon: 'trash-alt', |
||||
yesText: 'Delete', |
||||
onConfirm: () => { |
||||
this.props.deleteFolder(this.props.folder.uid); |
||||
}, |
||||
}) |
||||
); |
||||
}; |
||||
|
||||
render() { |
||||
const { pageNav, folder } = this.props; |
||||
|
||||
return ( |
||||
<Page navId="dashboards/browse" pageNav={pageNav.main}> |
||||
<Page.Contents isLoading={this.state.isLoading}> |
||||
<h3 className="page-sub-heading">Folder settings</h3> |
||||
<Form name="folderSettingsForm" onSubmit={this.onSave}> |
||||
{({ control, errors }) => ( |
||||
<> |
||||
<InputControl |
||||
render={({ field: { ref, ...field } }) => ( |
||||
<Field label="Title" invalid={!!errors.title} error={errors.title?.message}> |
||||
<Input {...field} autoFocus onChange={this.onTitleChange} value={folder.title} /> |
||||
</Field> |
||||
)} |
||||
control={control} |
||||
name="title" |
||||
/> |
||||
<div className="gf-form-button-row"> |
||||
<Button type="submit" disabled={!folder.canSave || !folder.hasChanged}> |
||||
Save |
||||
</Button> |
||||
<Button variant="destructive" onClick={this.onDelete} disabled={!folder.canDelete}> |
||||
Delete |
||||
</Button> |
||||
</div> |
||||
</> |
||||
)} |
||||
</Form> |
||||
</Page.Contents> |
||||
</Page> |
||||
); |
||||
} |
||||
} |
||||
|
||||
export default connector(FolderSettingsPage); |
@ -1,86 +0,0 @@ |
||||
import React from 'react'; |
||||
import { connect, ConnectedProps } from 'react-redux'; |
||||
|
||||
import { NavModelItem } from '@grafana/data'; |
||||
import { config } from '@grafana/runtime'; |
||||
import { Button, Input, Form, Field, HorizontalGroup, LinkButton } from '@grafana/ui'; |
||||
import { Page } from 'app/core/components/Page/Page'; |
||||
import { useQueryParams } from 'app/core/hooks/useQueryParams'; |
||||
|
||||
import { validationSrv } from '../../manage-dashboards/services/ValidationSrv'; |
||||
import { createNewFolder } from '../state/actions'; |
||||
|
||||
const mapDispatchToProps = { |
||||
createNewFolder, |
||||
}; |
||||
|
||||
const connector = connect(null, mapDispatchToProps); |
||||
|
||||
interface OwnProps {} |
||||
|
||||
interface FormModel { |
||||
folderName: string; |
||||
} |
||||
|
||||
type Props = OwnProps & ConnectedProps<typeof connector>; |
||||
|
||||
const initialFormModel: FormModel = { folderName: '' }; |
||||
|
||||
const pageNav: NavModelItem = { |
||||
text: 'Create a new folder', |
||||
subTitle: 'Folders provide a way to group dashboards and alert rules.', |
||||
}; |
||||
|
||||
function NewDashboardsFolder({ createNewFolder }: Props) { |
||||
const [queryParams] = useQueryParams(); |
||||
const onSubmit = (formData: FormModel) => { |
||||
const folderUid = typeof queryParams['folderUid'] === 'string' ? queryParams['folderUid'] : undefined; |
||||
|
||||
createNewFolder(formData.folderName, folderUid); |
||||
}; |
||||
|
||||
const validateFolderName = (folderName: string) => { |
||||
return validationSrv |
||||
.validateNewFolderName(folderName) |
||||
.then(() => { |
||||
return true; |
||||
}) |
||||
.catch((e) => { |
||||
return e.message; |
||||
}); |
||||
}; |
||||
|
||||
return ( |
||||
<Page navId="dashboards/browse" pageNav={pageNav}> |
||||
<Page.Contents> |
||||
<Form defaultValues={initialFormModel} onSubmit={onSubmit}> |
||||
{({ register, errors }) => ( |
||||
<> |
||||
<Field |
||||
label="Folder name" |
||||
invalid={!!errors.folderName} |
||||
error={errors.folderName && errors.folderName.message} |
||||
> |
||||
<Input |
||||
id="folder-name-input" |
||||
{...register('folderName', { |
||||
required: 'Folder name is required.', |
||||
validate: async (v) => await validateFolderName(v), |
||||
})} |
||||
/> |
||||
</Field> |
||||
<HorizontalGroup> |
||||
<Button type="submit">Create</Button> |
||||
<LinkButton variant="secondary" href={`${config.appSubUrl}/dashboards`}> |
||||
Cancel |
||||
</LinkButton> |
||||
</HorizontalGroup> |
||||
</> |
||||
)} |
||||
</Form> |
||||
</Page.Contents> |
||||
</Page> |
||||
); |
||||
} |
||||
|
||||
export default connector(NewDashboardsFolder); |
@ -1,97 +0,0 @@ |
||||
import React, { useMemo, useState } from 'react'; |
||||
|
||||
import { config, reportInteraction } from '@grafana/runtime'; |
||||
import { Menu, Dropdown, Button, Icon, HorizontalGroup } from '@grafana/ui'; |
||||
import { FolderDTO } from 'app/types'; |
||||
|
||||
import { MoveToFolderModal } from '../page/components/MoveToFolderModal'; |
||||
import { getImportPhrase, getNewDashboardPhrase, getNewFolderPhrase, getNewPhrase } from '../tempI18nPhrases'; |
||||
|
||||
export interface Props { |
||||
folder: FolderDTO | undefined; |
||||
canCreateFolders?: boolean; |
||||
canCreateDashboards?: boolean; |
||||
} |
||||
|
||||
export const DashboardActions = ({ folder, canCreateFolders = false, canCreateDashboards = false }: Props) => { |
||||
const [isMoveModalOpen, setIsMoveModalOpen] = useState(false); |
||||
const canMove = config.featureToggles.nestedFolders && (folder?.canSave ?? false); |
||||
|
||||
const moveSelection = useMemo( |
||||
() => new Map<string, Set<string>>([['folder', new Set(folder?.uid ? [folder.uid] : [])]]), |
||||
[folder] |
||||
); |
||||
|
||||
const actionUrl = (type: string) => { |
||||
let url = `dashboard/${type}`; |
||||
const isTypeNewFolder = type === 'new_folder'; |
||||
|
||||
if (isTypeNewFolder) { |
||||
url = `dashboards/folder/new/`; |
||||
} |
||||
|
||||
if (folder?.uid) { |
||||
url += `?folderUid=${folder.uid}`; |
||||
} |
||||
|
||||
return url; |
||||
}; |
||||
|
||||
const MenuActions = () => { |
||||
return ( |
||||
<Menu> |
||||
{canCreateDashboards && ( |
||||
<Menu.Item |
||||
url={actionUrl('new')} |
||||
label={getNewDashboardPhrase()} |
||||
onClick={() => |
||||
reportInteraction('grafana_menu_item_clicked', { url: actionUrl('new'), from: '/dashboards' }) |
||||
} |
||||
/> |
||||
)} |
||||
{canCreateFolders && (config.featureToggles.nestedFolders || !folder?.uid) && ( |
||||
<Menu.Item |
||||
url={actionUrl('new_folder')} |
||||
label={getNewFolderPhrase()} |
||||
onClick={() => |
||||
reportInteraction('grafana_menu_item_clicked', { url: actionUrl('new_folder'), from: '/dashboards' }) |
||||
} |
||||
/> |
||||
)} |
||||
{canCreateDashboards && ( |
||||
<Menu.Item |
||||
url={actionUrl('import')} |
||||
label={getImportPhrase()} |
||||
onClick={() => |
||||
reportInteraction('grafana_menu_item_clicked', { url: actionUrl('import'), from: '/dashboards' }) |
||||
} |
||||
/> |
||||
)} |
||||
</Menu> |
||||
); |
||||
}; |
||||
|
||||
return ( |
||||
<> |
||||
<div> |
||||
<HorizontalGroup> |
||||
{canMove && ( |
||||
<Button onClick={() => setIsMoveModalOpen(true)} icon="exchange-alt" variant="secondary"> |
||||
Move |
||||
</Button> |
||||
)} |
||||
<Dropdown overlay={MenuActions} placement="bottom-start"> |
||||
<Button variant="primary"> |
||||
{getNewPhrase()} |
||||
<Icon name="angle-down" /> |
||||
</Button> |
||||
</Dropdown> |
||||
</HorizontalGroup> |
||||
</div> |
||||
|
||||
{canMove && isMoveModalOpen && ( |
||||
<MoveToFolderModal onMoveItems={() => {}} results={moveSelection} onDismiss={() => setIsMoveModalOpen(false)} /> |
||||
)} |
||||
</> |
||||
); |
||||
}; |
@ -1,71 +0,0 @@ |
||||
import { css } from '@emotion/css'; |
||||
import React, { memo } from 'react'; |
||||
import { useAsync } from 'react-use'; |
||||
|
||||
import { locationUtil, NavModelItem } from '@grafana/data'; |
||||
import { locationService } from '@grafana/runtime'; |
||||
import { Page } from 'app/core/components/Page/Page'; |
||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; |
||||
import NewBrowseDashboardsPage from 'app/features/browse-dashboards/BrowseDashboardsPage'; |
||||
import { newBrowseDashboardsEnabled } from 'app/features/browse-dashboards/featureFlag'; |
||||
import { FolderDTO } from 'app/types'; |
||||
|
||||
import { loadFolderPage } from '../loaders'; |
||||
|
||||
import ManageDashboardsNew from './ManageDashboardsNew'; |
||||
|
||||
export interface DashboardListPageRouteParams { |
||||
uid?: string; |
||||
slug?: string; |
||||
} |
||||
|
||||
interface Props extends GrafanaRouteComponentProps<DashboardListPageRouteParams> {} |
||||
|
||||
export const DashboardListPageFeatureToggle = memo((props: Props) => { |
||||
if (newBrowseDashboardsEnabled()) { |
||||
return <NewBrowseDashboardsPage {...props} />; |
||||
} |
||||
|
||||
return <DashboardListPage {...props} />; |
||||
}); |
||||
DashboardListPageFeatureToggle.displayName = 'DashboardListPageFeatureToggle'; |
||||
|
||||
const DashboardListPage = memo(({ match, location }: Props) => { |
||||
const { loading, value } = useAsync<() => Promise<{ folder?: FolderDTO; pageNav?: NavModelItem }>>(() => { |
||||
const uid = match.params.uid; |
||||
const url = location.pathname; |
||||
|
||||
if (!uid || !url.startsWith('/dashboards')) { |
||||
return Promise.resolve({}); |
||||
} |
||||
|
||||
return loadFolderPage(uid!).then(({ folder, folderNav }) => { |
||||
const path = locationUtil.stripBaseFromUrl(folder.url); |
||||
|
||||
if (path !== location.pathname) { |
||||
locationService.replace(path); |
||||
} |
||||
|
||||
return { folder, pageNav: folderNav }; |
||||
}); |
||||
}, [match.params.uid]); |
||||
|
||||
return ( |
||||
<Page navId="dashboards/browse" pageNav={value?.pageNav}> |
||||
<Page.Contents |
||||
isLoading={loading} |
||||
className={css` |
||||
display: flex; |
||||
flex-direction: column; |
||||
height: 100%; |
||||
`}
|
||||
> |
||||
<ManageDashboardsNew folder={value?.folder} /> |
||||
</Page.Contents> |
||||
</Page> |
||||
); |
||||
}); |
||||
|
||||
DashboardListPage.displayName = 'DashboardListPage'; |
||||
|
||||
export default DashboardListPageFeatureToggle; |
@ -1,49 +0,0 @@ |
||||
import { render, screen, waitFor } from '@testing-library/react'; |
||||
import React from 'react'; |
||||
|
||||
import { contextSrv } from 'app/core/services/context_srv'; |
||||
import { FolderDTO } from 'app/types'; |
||||
|
||||
import ManageDashboardsNew from './ManageDashboardsNew'; |
||||
|
||||
jest.mock('app/core/services/context_srv', () => { |
||||
const originMock = jest.requireActual('app/core/services/context_srv'); |
||||
|
||||
return { |
||||
...originMock, |
||||
contextSrv: { |
||||
...originMock.context_srv, |
||||
user: {}, |
||||
hasPermission: jest.fn(() => false), |
||||
}, |
||||
}; |
||||
}); |
||||
|
||||
const setup = async (options?: { folder?: FolderDTO }) => { |
||||
const { folder = {} as FolderDTO } = options || {}; |
||||
|
||||
const { rerender } = await waitFor(() => render(<ManageDashboardsNew folder={folder} />)); |
||||
|
||||
return { rerender }; |
||||
}; |
||||
|
||||
jest.spyOn(console, 'error').mockImplementation(); |
||||
|
||||
describe('ManageDashboards', () => { |
||||
beforeEach(() => { |
||||
(contextSrv.hasPermission as jest.Mock).mockClear(); |
||||
}); |
||||
|
||||
it("should hide and show dashboard actions based on user's permissions", async () => { |
||||
(contextSrv.hasPermission as jest.Mock).mockReturnValue(false); |
||||
|
||||
const { rerender } = await setup(); |
||||
|
||||
expect(screen.queryByRole('button', { name: /new/i })).not.toBeInTheDocument(); |
||||
|
||||
(contextSrv.hasPermission as jest.Mock).mockReturnValue(true); |
||||
await waitFor(() => rerender(<ManageDashboardsNew folder={{ canEdit: true } as FolderDTO} />)); |
||||
|
||||
expect(screen.getByRole('button', { name: /new/i })).toBeInTheDocument(); |
||||
}); |
||||
}); |
@ -1,109 +0,0 @@ |
||||
import { css, cx } from '@emotion/css'; |
||||
import React, { useEffect } from 'react'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { useStyles2, FilterInput } from '@grafana/ui'; |
||||
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'; |
||||
|
||||
export interface Props { |
||||
folder?: FolderDTO; |
||||
} |
||||
|
||||
export const ManageDashboardsNew = React.memo(({ folder }: Props) => { |
||||
const styles = useStyles2(getStyles); |
||||
// since we don't use "query" from use search... it is not actually loaded from the URL!
|
||||
const stateManager = getSearchStateManager(); |
||||
const state = stateManager.useState(); |
||||
const { onKeyDown, keyboardEvents } = useKeyNavigationListener(); |
||||
|
||||
// TODO: we need to refactor DashboardActions to use folder.uid instead
|
||||
|
||||
const folderUid = folder?.uid; |
||||
const canSave = folder?.canSave; |
||||
const { isEditor } = contextSrv; |
||||
const hasEditPermissionInFolders = folder ? canSave : contextSrv.hasEditPermissionInFolders; |
||||
const canCreateFolders = contextSrv.hasPermission(AccessControlAction.FoldersCreate); |
||||
const canCreateDashboards = folderUid |
||||
? contextSrv.hasPermissionInMetadata(AccessControlAction.DashboardsCreate, folder) |
||||
: contextSrv.hasPermission(AccessControlAction.DashboardsCreate); |
||||
const viewActions = (folder === undefined && canCreateFolders) || canCreateDashboards; |
||||
|
||||
useEffect(() => stateManager.initStateFromUrl(folder?.uid), [folder?.uid, stateManager]); |
||||
|
||||
return ( |
||||
<> |
||||
<div className={cx(styles.actionBar, 'page-action-bar')}> |
||||
<div className={cx(styles.inputWrapper, 'gf-form gf-form--grow m-r-2')}> |
||||
<FilterInput |
||||
value={state.query ?? ''} |
||||
onChange={(e) => stateManager.onQueryChange(e)} |
||||
onKeyDown={onKeyDown} |
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus |
||||
spellCheck={false} |
||||
placeholder={getSearchPlaceholder(state.includePanels)} |
||||
escapeRegex={false} |
||||
className={styles.searchInput} |
||||
/> |
||||
</div> |
||||
{viewActions && ( |
||||
<DashboardActions |
||||
folder={folder} |
||||
canCreateFolders={canCreateFolders} |
||||
canCreateDashboards={canCreateDashboards} |
||||
/> |
||||
)} |
||||
</div> |
||||
|
||||
<SearchView |
||||
showManage={Boolean(isEditor || hasEditPermissionInFolders || canSave)} |
||||
folderDTO={folder} |
||||
hidePseudoFolders={true} |
||||
keyboardEvents={keyboardEvents} |
||||
/> |
||||
</> |
||||
); |
||||
}); |
||||
|
||||
ManageDashboardsNew.displayName = 'ManageDashboardsNew'; |
||||
|
||||
export default ManageDashboardsNew; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
actionBar: css` |
||||
${theme.breakpoints.down('sm')} { |
||||
flex-wrap: wrap; |
||||
} |
||||
`,
|
||||
inputWrapper: css` |
||||
${theme.breakpoints.down('sm')} { |
||||
margin-right: 0 !important; |
||||
} |
||||
`,
|
||||
searchInput: css` |
||||
margin-bottom: 6px; |
||||
min-height: ${theme.spacing(4)}; |
||||
`,
|
||||
unsupported: css` |
||||
padding: 10px; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
height: 100%; |
||||
font-size: 18px; |
||||
`,
|
||||
noResults: css` |
||||
padding: ${theme.v1.spacing.md}; |
||||
background: ${theme.v1.colors.bg2}; |
||||
font-style: italic; |
||||
margin-top: ${theme.v1.spacing.md}; |
||||
`,
|
||||
}); |
@ -1,262 +0,0 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { Placement, Rect } from '@popperjs/core'; |
||||
import React, { useCallback, useRef, useState } from 'react'; |
||||
import SVG from 'react-inlinesvg'; |
||||
import { usePopper } from 'react-popper'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { selectors } from '@grafana/e2e-selectors'; |
||||
import { Icon, Portal, TagList, useTheme2 } from '@grafana/ui'; |
||||
import { backendSrv } from 'app/core/services/backend_srv'; |
||||
|
||||
import { DashboardViewItem, OnToggleChecked } from '../types'; |
||||
|
||||
import { SearchCardExpanded } from './SearchCardExpanded'; |
||||
import { SearchCheckbox } from './SearchCheckbox'; |
||||
|
||||
const DELAY_BEFORE_EXPANDING = 500; |
||||
|
||||
export interface Props { |
||||
editable?: boolean; |
||||
item: DashboardViewItem; |
||||
isSelected?: boolean; |
||||
onTagSelected?: (name: string) => any; |
||||
onToggleChecked?: OnToggleChecked; |
||||
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void; |
||||
} |
||||
|
||||
export function getThumbnailURL(uid: string, isLight?: boolean) { |
||||
return `/api/dashboards/uid/${uid}/img/thumb/${isLight ? 'light' : 'dark'}`; |
||||
} |
||||
|
||||
export function SearchCard({ editable, item, isSelected, onTagSelected, onToggleChecked, onClick }: Props) { |
||||
const [hasImage, setHasImage] = useState(true); |
||||
const [lastUpdated, setLastUpdated] = useState<string | null>(null); |
||||
const [showExpandedView, setShowExpandedView] = useState(false); |
||||
const timeout = useRef<number | null>(null); |
||||
|
||||
// Popper specific logic
|
||||
const offsetCallback = useCallback( |
||||
({ placement, reference, popper }: { placement: Placement; reference: Rect; popper: Rect }) => { |
||||
let result: [number, number] = [0, 0]; |
||||
if (placement === 'bottom' || placement === 'top') { |
||||
result = [0, -(reference.height + popper.height) / 2]; |
||||
} else if (placement === 'left' || placement === 'right') { |
||||
result = [-(reference.width + popper.width) / 2, 0]; |
||||
} |
||||
return result; |
||||
}, |
||||
[] |
||||
); |
||||
const [markerElement, setMarkerElement] = React.useState<HTMLDivElement | null>(null); |
||||
const [popperElement, setPopperElement] = React.useState<HTMLDivElement | null>(null); |
||||
const { styles: popperStyles, attributes } = usePopper(markerElement, popperElement, { |
||||
modifiers: [ |
||||
{ |
||||
name: 'offset', |
||||
options: { |
||||
offset: offsetCallback, |
||||
}, |
||||
}, |
||||
], |
||||
}); |
||||
|
||||
const theme = useTheme2(); |
||||
const imageSrc = getThumbnailURL(item.uid!, theme.isLight); |
||||
const styles = getStyles( |
||||
theme, |
||||
markerElement?.getBoundingClientRect().width, |
||||
popperElement?.getBoundingClientRect().width |
||||
); |
||||
|
||||
const onShowExpandedView = async () => { |
||||
setShowExpandedView(true); |
||||
if (item.uid && !lastUpdated) { |
||||
const dashboard = await backendSrv.getDashboardByUid(item.uid); |
||||
const { updated } = dashboard.meta; |
||||
if (updated) { |
||||
setLastUpdated(new Date(updated).toLocaleString()); |
||||
} else { |
||||
setLastUpdated(null); |
||||
} |
||||
} |
||||
}; |
||||
|
||||
const onMouseEnter = () => { |
||||
timeout.current = window.setTimeout(onShowExpandedView, DELAY_BEFORE_EXPANDING); |
||||
}; |
||||
|
||||
const onMouseMove = () => { |
||||
if (timeout.current) { |
||||
window.clearTimeout(timeout.current); |
||||
} |
||||
timeout.current = window.setTimeout(onShowExpandedView, DELAY_BEFORE_EXPANDING); |
||||
}; |
||||
|
||||
const onMouseLeave = () => { |
||||
if (timeout.current) { |
||||
window.clearTimeout(timeout.current); |
||||
} |
||||
setShowExpandedView(false); |
||||
}; |
||||
|
||||
const onCheckboxClick = (ev: React.MouseEvent) => { |
||||
ev.stopPropagation(); |
||||
ev.preventDefault(); |
||||
|
||||
onToggleChecked?.(item); |
||||
}; |
||||
|
||||
const onTagClick = (tag: string, ev: React.MouseEvent) => { |
||||
ev.stopPropagation(); |
||||
ev.preventDefault(); |
||||
|
||||
onTagSelected?.(tag); |
||||
}; |
||||
|
||||
return ( |
||||
<a |
||||
data-testid={selectors.components.Search.dashboardCard(item.title)} |
||||
className={styles.card} |
||||
key={item.uid} |
||||
href={item.url} |
||||
ref={(ref) => setMarkerElement(ref as unknown as HTMLDivElement)} |
||||
onMouseEnter={onMouseEnter} |
||||
onMouseLeave={onMouseLeave} |
||||
onMouseMove={onMouseMove} |
||||
onClick={onClick} |
||||
> |
||||
<div className={styles.imageContainer}> |
||||
<SearchCheckbox |
||||
className={styles.checkbox} |
||||
aria-label={`Select dashboard ${item.title}`} |
||||
editable={editable} |
||||
checked={isSelected} |
||||
onClick={onCheckboxClick} |
||||
/> |
||||
{hasImage ? ( |
||||
<img |
||||
loading="lazy" |
||||
className={styles.image} |
||||
src={imageSrc} |
||||
alt="Dashboard preview" |
||||
onError={() => setHasImage(false)} |
||||
/> |
||||
) : ( |
||||
<div className={styles.imagePlaceholder}> |
||||
{item.icon ? ( |
||||
<SVG src={item.icon} width={36} height={36} title={item.title} /> |
||||
) : ( |
||||
<Icon name="apps" size="xl" /> |
||||
)} |
||||
</div> |
||||
)} |
||||
</div> |
||||
<div className={styles.info}> |
||||
<div className={styles.title}>{item.title}</div> |
||||
<TagList displayMax={1} tags={item.tags ?? []} onClick={onTagClick} /> |
||||
</div> |
||||
{showExpandedView && ( |
||||
<Portal className={styles.portal}> |
||||
<div ref={setPopperElement} style={popperStyles.popper} {...attributes.popper}> |
||||
<SearchCardExpanded |
||||
className={styles.expandedView} |
||||
imageHeight={240} |
||||
imageWidth={320} |
||||
item={item} |
||||
lastUpdated={lastUpdated} |
||||
onClick={onClick} |
||||
/> |
||||
</div> |
||||
</Portal> |
||||
)} |
||||
</a> |
||||
); |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2, markerWidth = 0, popperWidth = 0) => { |
||||
const IMAGE_HORIZONTAL_MARGIN = theme.spacing(4); |
||||
|
||||
return { |
||||
card: css` |
||||
background-color: ${theme.colors.background.secondary}; |
||||
border: 1px solid ${theme.colors.border.medium}; |
||||
border-radius: ${theme.shape.radius.default}; |
||||
display: flex; |
||||
flex-direction: column; |
||||
|
||||
&:hover { |
||||
background-color: ${theme.colors.emphasize(theme.colors.background.secondary, 0.03)}; |
||||
} |
||||
`,
|
||||
checkbox: css` |
||||
left: 0; |
||||
margin: ${theme.spacing(1)}; |
||||
position: absolute; |
||||
top: 0; |
||||
`,
|
||||
expandedView: css` |
||||
@keyframes expand { |
||||
0% { |
||||
transform: scale(${markerWidth / popperWidth}); |
||||
} |
||||
100% { |
||||
transform: scale(1); |
||||
} |
||||
} |
||||
|
||||
animation: expand ${theme.transitions.duration.shortest}ms ease-in-out 0s 1 normal; |
||||
background-color: ${theme.colors.emphasize(theme.colors.background.secondary, 0.03)}; |
||||
`,
|
||||
image: css` |
||||
aspect-ratio: 4 / 3; |
||||
box-shadow: ${theme.shadows.z1}; |
||||
margin: ${theme.spacing(1)} ${IMAGE_HORIZONTAL_MARGIN} 0; |
||||
width: calc(100% - (2 * ${IMAGE_HORIZONTAL_MARGIN})); |
||||
`,
|
||||
imageContainer: css` |
||||
flex: 1; |
||||
position: relative; |
||||
|
||||
&:after { |
||||
background: linear-gradient(180deg, rgba(196, 196, 196, 0) 0%, rgba(127, 127, 127, 0.25) 100%); |
||||
bottom: 0; |
||||
content: ''; |
||||
left: 0; |
||||
margin: ${theme.spacing(1)} ${IMAGE_HORIZONTAL_MARGIN} 0; |
||||
position: absolute; |
||||
right: 0; |
||||
top: 0; |
||||
} |
||||
`,
|
||||
imagePlaceholder: css` |
||||
align-items: center; |
||||
aspect-ratio: 4 / 3; |
||||
color: ${theme.colors.text.secondary}; |
||||
display: flex; |
||||
justify-content: center; |
||||
margin: ${theme.spacing(1)} ${IMAGE_HORIZONTAL_MARGIN} 0; |
||||
width: calc(100% - (2 * ${IMAGE_HORIZONTAL_MARGIN})); |
||||
`,
|
||||
info: css` |
||||
align-items: center; |
||||
background-color: ${theme.colors.background.canvas}; |
||||
border-bottom-left-radius: ${theme.shape.radius.default}; |
||||
border-bottom-right-radius: ${theme.shape.radius.default}; |
||||
display: flex; |
||||
height: ${theme.spacing(7)}; |
||||
gap: ${theme.spacing(1)}; |
||||
padding: 0 ${theme.spacing(2)}; |
||||
z-index: 1; |
||||
`,
|
||||
portal: css` |
||||
pointer-events: none; |
||||
`,
|
||||
title: css` |
||||
display: block; |
||||
overflow: hidden; |
||||
text-overflow: ellipsis; |
||||
white-space: nowrap; |
||||
`,
|
||||
}; |
||||
}; |
@ -1,165 +0,0 @@ |
||||
import { css } from '@emotion/css'; |
||||
import classNames from 'classnames'; |
||||
import React, { useState } from 'react'; |
||||
import SVG from 'react-inlinesvg'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { Icon, Spinner, TagList, useTheme2 } from '@grafana/ui'; |
||||
|
||||
import { DashboardViewItem } from '../types'; |
||||
|
||||
import { getThumbnailURL } from './SearchCard'; |
||||
|
||||
export interface Props { |
||||
className?: string; |
||||
imageHeight: number; |
||||
imageWidth: number; |
||||
item: DashboardViewItem; |
||||
lastUpdated?: string | null; |
||||
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void; |
||||
} |
||||
|
||||
export function SearchCardExpanded({ className, imageHeight, imageWidth, item, lastUpdated, onClick }: Props) { |
||||
const theme = useTheme2(); |
||||
const [hasImage, setHasImage] = useState(true); |
||||
const imageSrc = getThumbnailURL(item.uid!, theme.isLight); |
||||
const styles = getStyles(theme, imageHeight, imageWidth); |
||||
|
||||
const folderTitle = item.parentTitle || 'General'; |
||||
|
||||
return ( |
||||
<a className={classNames(className, styles.card)} key={item.uid} href={item.url} onClick={onClick}> |
||||
<div className={styles.imageContainer}> |
||||
{hasImage ? ( |
||||
<img |
||||
loading="lazy" |
||||
alt="Dashboard preview" |
||||
className={styles.image} |
||||
src={imageSrc} |
||||
onLoad={() => setHasImage(true)} |
||||
onError={() => setHasImage(false)} |
||||
/> |
||||
) : ( |
||||
<div className={styles.imagePlaceholder}> |
||||
{item.icon ? ( |
||||
<SVG src={item.icon} width={36} height={36} title={item.title} /> |
||||
) : ( |
||||
<Icon name="apps" size="xl" /> |
||||
)} |
||||
</div> |
||||
)} |
||||
</div> |
||||
<div className={styles.info}> |
||||
<div className={styles.infoHeader}> |
||||
<div className={styles.titleContainer}> |
||||
<div>{item.title}</div> |
||||
<div className={styles.folder}> |
||||
<Icon name={'folder'} /> |
||||
{folderTitle} |
||||
</div> |
||||
</div> |
||||
{lastUpdated !== null && ( |
||||
<div className={styles.updateContainer}> |
||||
<div>Last updated</div> |
||||
{lastUpdated ? <div className={styles.update}>{lastUpdated}</div> : <Spinner />} |
||||
</div> |
||||
)} |
||||
</div> |
||||
<div> |
||||
<TagList className={styles.tagList} tags={item.tags ?? []} /> |
||||
</div> |
||||
</div> |
||||
</a> |
||||
); |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2, imageHeight: Props['imageHeight'], imageWidth: Props['imageWidth']) => { |
||||
const IMAGE_HORIZONTAL_MARGIN = theme.spacing(4); |
||||
|
||||
return { |
||||
card: css` |
||||
background-color: ${theme.colors.background.secondary}; |
||||
border: 1px solid ${theme.colors.border.medium}; |
||||
border-radius: 4px; |
||||
box-shadow: ${theme.shadows.z3}; |
||||
display: flex; |
||||
flex-direction: column; |
||||
height: 100%; |
||||
max-width: calc(${imageWidth}px + (${IMAGE_HORIZONTAL_MARGIN} * 2))}; |
||||
width: 100%; |
||||
`,
|
||||
folder: css` |
||||
align-items: center; |
||||
color: ${theme.colors.text.secondary}; |
||||
display: flex; |
||||
font-size: ${theme.typography.size.sm}; |
||||
gap: ${theme.spacing(0.5)}; |
||||
`,
|
||||
image: css` |
||||
box-shadow: ${theme.shadows.z2}; |
||||
height: ${imageHeight}px; |
||||
margin: ${theme.spacing(1)} calc(${IMAGE_HORIZONTAL_MARGIN} - 1px) 0; |
||||
width: ${imageWidth}px; |
||||
`,
|
||||
imageContainer: css` |
||||
flex: 1; |
||||
position: relative; |
||||
|
||||
&:after { |
||||
background: linear-gradient(180deg, rgba(196, 196, 196, 0) 0%, rgba(127, 127, 127, 0.25) 100%); |
||||
bottom: 0; |
||||
content: ''; |
||||
left: 0; |
||||
margin: ${theme.spacing(1)} calc(${IMAGE_HORIZONTAL_MARGIN} - 1px) 0; |
||||
position: absolute; |
||||
right: 0; |
||||
top: 0; |
||||
} |
||||
`,
|
||||
imagePlaceholder: css` |
||||
align-items: center; |
||||
color: ${theme.colors.text.secondary}; |
||||
display: flex; |
||||
height: ${imageHeight}px; |
||||
justify-content: center; |
||||
margin: ${theme.spacing(1)} ${IMAGE_HORIZONTAL_MARGIN} 0; |
||||
width: ${imageWidth}px; |
||||
`,
|
||||
info: css` |
||||
background-color: ${theme.colors.background.canvas}; |
||||
border-bottom-left-radius: 4px; |
||||
border-bottom-right-radius: 4px; |
||||
display: flex; |
||||
flex-direction: column; |
||||
min-height: ${theme.spacing(7)}; |
||||
gap: ${theme.spacing(1)}; |
||||
padding: ${theme.spacing(1)} ${theme.spacing(2)}; |
||||
z-index: 1; |
||||
`,
|
||||
infoHeader: css` |
||||
display: flex; |
||||
gap: ${theme.spacing(1)}; |
||||
justify-content: space-between; |
||||
`,
|
||||
tagList: css` |
||||
justify-content: flex-start; |
||||
`,
|
||||
titleContainer: css` |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: ${theme.spacing(0.5)}; |
||||
`,
|
||||
updateContainer: css` |
||||
align-items: flex-end; |
||||
display: flex; |
||||
flex-direction: column; |
||||
flex-shrink: 0; |
||||
font-size: ${theme.typography.bodySmall.fontSize}; |
||||
gap: ${theme.spacing(0.5)}; |
||||
`,
|
||||
update: css` |
||||
color: ${theme.colors.text.secondary}; |
||||
text-align: right; |
||||
`,
|
||||
}; |
||||
}; |
@ -1,21 +0,0 @@ |
||||
import React, { memo } from 'react'; |
||||
|
||||
import { Checkbox } from '@grafana/ui'; |
||||
|
||||
interface Props { |
||||
checked?: boolean; |
||||
onClick?: React.MouseEventHandler<HTMLInputElement>; |
||||
className?: string; |
||||
editable?: boolean; |
||||
'aria-label'?: string; |
||||
} |
||||
|
||||
export const SearchCheckbox = memo( |
||||
({ onClick, className, checked = false, editable = false, 'aria-label': ariaLabel }: Props) => { |
||||
return editable ? ( |
||||
<Checkbox onClick={onClick} className={className} value={checked} aria-label={ariaLabel} /> |
||||
) : null; |
||||
} |
||||
); |
||||
|
||||
SearchCheckbox.displayName = 'SearchCheckbox'; |
@ -1,68 +0,0 @@ |
||||
import { fireEvent, render, screen } from '@testing-library/react'; |
||||
import React from 'react'; |
||||
|
||||
import { selectors } from '@grafana/e2e-selectors'; |
||||
|
||||
import { DashboardViewItem } from '../types'; |
||||
|
||||
import { Props, SearchItem } from './SearchItem'; |
||||
|
||||
beforeEach(() => { |
||||
jest.clearAllMocks(); |
||||
}); |
||||
|
||||
const data: DashboardViewItem = { |
||||
kind: 'dashboard' as const, |
||||
uid: 'lBdLINUWk', |
||||
title: 'Test 1', |
||||
url: '/d/lBdLINUWk/test1', |
||||
tags: ['Tag1', 'Tag2'], |
||||
}; |
||||
|
||||
const setup = (propOverrides?: Partial<Props>) => { |
||||
const props: Props = { |
||||
item: data, |
||||
onTagSelected: jest.fn(), |
||||
editable: false, |
||||
}; |
||||
|
||||
Object.assign(props, propOverrides); |
||||
|
||||
render(<SearchItem {...props} />); |
||||
}; |
||||
|
||||
describe('SearchItem', () => { |
||||
it('should render the item', () => { |
||||
setup(); |
||||
expect(screen.getAllByTestId(selectors.components.Search.dashboardItem('Test 1'))).toHaveLength(1); |
||||
expect(screen.getAllByText('Test 1')).toHaveLength(1); |
||||
}); |
||||
|
||||
it('should toggle items when checked', () => { |
||||
const mockedOnToggleChecked = jest.fn(); |
||||
setup({ editable: true, onToggleChecked: mockedOnToggleChecked }); |
||||
const checkbox = screen.getByRole('checkbox'); |
||||
expect(checkbox).not.toBeChecked(); |
||||
fireEvent.click(checkbox); |
||||
expect(mockedOnToggleChecked).toHaveBeenCalledTimes(1); |
||||
expect(mockedOnToggleChecked).toHaveBeenCalledWith(data); |
||||
}); |
||||
|
||||
it('should mark items as checked', () => { |
||||
setup({ editable: true, isSelected: true }); |
||||
expect(screen.getByRole('checkbox')).toBeChecked(); |
||||
}); |
||||
|
||||
it("should render item's tags", () => { |
||||
setup(); |
||||
expect(screen.getAllByText(/tag/i)).toHaveLength(2); |
||||
}); |
||||
|
||||
it('should select the tag on tag click', () => { |
||||
const mockOnTagSelected = jest.fn(); |
||||
setup({ onTagSelected: mockOnTagSelected }); |
||||
fireEvent.click(screen.getByText('Tag1')); |
||||
expect(mockOnTagSelected).toHaveBeenCalledTimes(1); |
||||
expect(mockOnTagSelected).toHaveBeenCalledWith('Tag1'); |
||||
}); |
||||
}); |
@ -1,141 +0,0 @@ |
||||
import { css } from '@emotion/css'; |
||||
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'; |
||||
import { DashboardViewItem, OnToggleChecked } from '../types'; |
||||
|
||||
import { SearchCheckbox } from './SearchCheckbox'; |
||||
|
||||
export interface Props { |
||||
item: DashboardViewItem; |
||||
isSelected?: boolean; |
||||
editable?: boolean; |
||||
onTagSelected: (name: string) => any; |
||||
onToggleChecked?: OnToggleChecked; |
||||
onClickItem?: (event: React.MouseEvent<HTMLElement>) => void; |
||||
} |
||||
|
||||
const selectors = e2eSelectors.components.Search; |
||||
|
||||
const getIconFromMeta = (meta = ''): IconName => { |
||||
const metaIconMap = new Map<string, IconName>([ |
||||
['errors', 'info-circle'], |
||||
['views', 'eye'], |
||||
]); |
||||
|
||||
return metaIconMap.has(meta) ? metaIconMap.get(meta)! : 'sort-amount-down'; |
||||
}; |
||||
|
||||
/** @deprecated */ |
||||
export const SearchItem = ({ item, isSelected, editable, onToggleChecked, onTagSelected, onClickItem }: Props) => { |
||||
const styles = useStyles2(getStyles); |
||||
const tagSelected = useCallback( |
||||
(tag: string, event: React.MouseEvent<HTMLElement>) => { |
||||
event.stopPropagation(); |
||||
event.preventDefault(); |
||||
onTagSelected(tag); |
||||
}, |
||||
[onTagSelected] |
||||
); |
||||
|
||||
const handleCheckboxClick = useCallback( |
||||
(ev: React.MouseEvent) => { |
||||
ev.stopPropagation(); |
||||
|
||||
if (onToggleChecked) { |
||||
onToggleChecked(item); |
||||
} |
||||
}, |
||||
[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 ( |
||||
<div className={styles.cardContainer}> |
||||
<SearchCheckbox |
||||
className={styles.checkbox} |
||||
aria-label="Select dashboard" |
||||
editable={editable} |
||||
checked={isSelected} |
||||
onClick={handleCheckboxClick} |
||||
/> |
||||
|
||||
<Card |
||||
className={styles.card} |
||||
data-testid={selectors.dashboardItem(item.title)} |
||||
href={item.url} |
||||
style={{ minHeight: SEARCH_ITEM_HEIGHT }} |
||||
onClick={onClickItem} |
||||
> |
||||
<Card.Heading>{item.title}</Card.Heading> |
||||
|
||||
<Card.Meta separator={''}> |
||||
<span className={styles.metaContainer}>{description}</span> |
||||
|
||||
{item.sortMetaName && ( |
||||
<span className={styles.metaContainer}> |
||||
<Icon name={getIconFromMeta(item.sortMetaName)} /> |
||||
{item.sortMeta} {item.sortMetaName} |
||||
</span> |
||||
)} |
||||
</Card.Meta> |
||||
<Card.Tags> |
||||
<TagList tags={item.tags ?? []} onClick={tagSelected} getAriaLabel={(tag) => `Filter by tag "${tag}"`} /> |
||||
</Card.Tags> |
||||
</Card> |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
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 { |
||||
cardContainer: css` |
||||
display: flex; |
||||
align-items: center; |
||||
margin-bottom: ${theme.spacing(0.75)}; |
||||
`,
|
||||
card: css` |
||||
padding: ${theme.spacing(1)} ${theme.spacing(2)}; |
||||
margin-bottom: 0; |
||||
`,
|
||||
checkbox: css({ |
||||
marginRight: theme.spacing(1), |
||||
}), |
||||
metaContainer: css` |
||||
display: flex; |
||||
align-items: center; |
||||
margin-right: ${theme.spacing(1)}; |
||||
|
||||
svg { |
||||
margin-right: ${theme.spacing(0.5)}; |
||||
} |
||||
`,
|
||||
}; |
||||
}; |
@ -1 +0,0 @@ |
||||
export { SearchItem } from './components/SearchItem'; |
@ -1,45 +0,0 @@ |
||||
import { render, screen } from '@testing-library/react'; |
||||
import React from 'react'; |
||||
|
||||
import { config } from 'app/core/config'; |
||||
|
||||
import { ConfirmDeleteModal } from './ConfirmDeleteModal'; |
||||
|
||||
describe('ConfirmModal', () => { |
||||
it('should render correct title, body, dismiss-, cancel- and delete-text', () => { |
||||
const selectedItems = new Map([['dashboard', new Set(['uid1', 'uid2'])]]); |
||||
|
||||
render(<ConfirmDeleteModal onDeleteItems={() => {}} results={selectedItems} onDismiss={() => {}} />); |
||||
|
||||
expect(screen.getByRole('heading', { name: 'Delete' })).toBeInTheDocument(); |
||||
expect(screen.getByText('Do you want to delete the 2 selected dashboards?')).toBeInTheDocument(); |
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); |
||||
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument(); |
||||
|
||||
expect(screen.queryByPlaceholderText('Type "delete" to confirm')).not.toBeInTheDocument(); |
||||
}); |
||||
|
||||
describe('with nestedFolders feature flag', () => { |
||||
let originalNestedFoldersValue = config.featureToggles.nestedFolders; |
||||
|
||||
beforeAll(() => { |
||||
originalNestedFoldersValue = config.featureToggles.nestedFolders; |
||||
config.featureToggles.nestedFolders = true; |
||||
}); |
||||
|
||||
afterAll(() => { |
||||
config.featureToggles.nestedFolders = originalNestedFoldersValue; |
||||
}); |
||||
|
||||
it("should ask to type 'delete' to confirm when a folder is selected", async () => { |
||||
const selectedItems = new Map([ |
||||
['dashboard', new Set(['uid1', 'uid2'])], |
||||
['folder', new Set(['uid3'])], |
||||
]); |
||||
|
||||
render(<ConfirmDeleteModal onDeleteItems={() => {}} results={selectedItems} onDismiss={() => {}} />); |
||||
|
||||
expect(screen.getByPlaceholderText('Type "delete" to confirm')).toBeInTheDocument(); |
||||
}); |
||||
}); |
||||
}); |
@ -1,71 +0,0 @@ |
||||
import { css } from '@emotion/css'; |
||||
import React from 'react'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { ConfirmModal, useStyles2 } from '@grafana/ui'; |
||||
import { config } from 'app/core/config'; |
||||
import { deleteFoldersAndDashboards } from 'app/features/manage-dashboards/state/actions'; |
||||
|
||||
import { OnMoveOrDeleleSelectedItems } from '../../types'; |
||||
|
||||
interface Props { |
||||
onDeleteItems: OnMoveOrDeleleSelectedItems; |
||||
results: Map<string, Set<string>>; |
||||
onDismiss: () => void; |
||||
} |
||||
|
||||
export const ConfirmDeleteModal = ({ results, onDeleteItems, onDismiss }: Props) => { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
const dashboards = Array.from(results.get('dashboard') ?? []); |
||||
const folders = Array.from(results.get('folder') ?? []); |
||||
|
||||
const folderCount = folders.length; |
||||
const dashCount = dashboards.length; |
||||
|
||||
let text = 'Do you want to delete the '; |
||||
let subtitle; |
||||
const dashEnding = dashCount === 1 ? '' : 's'; |
||||
const folderEnding = folderCount === 1 ? '' : 's'; |
||||
|
||||
if (folderCount > 0 && dashCount > 0) { |
||||
text += `selected folder${folderEnding} and dashboard${dashEnding}?\n`; |
||||
subtitle = `All dashboards and alerts of the selected folder${folderEnding} will also be deleted`; |
||||
} else if (folderCount > 0) { |
||||
text += `selected folder${folderEnding} and all ${folderCount === 1 ? 'its' : 'their'} dashboards and alerts?`; |
||||
} else { |
||||
text += `${dashCount} selected dashboard${dashEnding}?`; |
||||
} |
||||
|
||||
const deleteItems = () => { |
||||
deleteFoldersAndDashboards(folders, dashboards).then(() => { |
||||
onDeleteItems(); |
||||
onDismiss(); |
||||
}); |
||||
}; |
||||
|
||||
const requireDoubleConfirm = config.featureToggles.nestedFolders && folderCount > 0; |
||||
|
||||
return ( |
||||
<ConfirmModal |
||||
isOpen |
||||
title="Delete" |
||||
body={ |
||||
<> |
||||
{text} {subtitle && <div className={styles.subtitle}>{subtitle}</div>} |
||||
</> |
||||
} |
||||
confirmText="Delete" |
||||
confirmationText={requireDoubleConfirm ? 'delete' : undefined} |
||||
onConfirm={deleteItems} |
||||
onDismiss={onDismiss} |
||||
/> |
||||
); |
||||
}; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
subtitle: css` |
||||
font-size: ${theme.typography.fontSize}px; |
||||
padding-top: ${theme.spacing(2)}; |
||||
`,
|
||||
}); |
@ -1,238 +0,0 @@ |
||||
import { render, screen, act } from '@testing-library/react'; |
||||
import userEvent from '@testing-library/user-event'; |
||||
import React from 'react'; |
||||
|
||||
import { DataFrame, DataFrameView, FieldType } from '@grafana/data'; |
||||
|
||||
import { DashboardQueryResult, getGrafanaSearcher, QueryResponse } from '../../service'; |
||||
import { DashboardSearchItemType, DashboardViewItem } from '../../types'; |
||||
|
||||
import { FolderSection } from './FolderSection'; |
||||
|
||||
describe('FolderSection', () => { |
||||
let grafanaSearcherSpy: jest.SpyInstance; |
||||
const mockOnTagSelected = jest.fn(); |
||||
const mockSelectionToggle = jest.fn(); |
||||
const mockSelection = jest.fn(); |
||||
const mockSection: DashboardViewItem = { |
||||
kind: 'folder', |
||||
uid: 'my-folder', |
||||
title: 'My folder', |
||||
}; |
||||
|
||||
// need to make sure we clear localStorage
|
||||
// otherwise tests can interfere with each other and the starting expanded state of the component
|
||||
afterEach(() => { |
||||
window.localStorage.clear(); |
||||
}); |
||||
|
||||
describe('when there are no results', () => { |
||||
const emptySearchData: DataFrame = { |
||||
fields: [ |
||||
{ name: 'kind', type: FieldType.string, config: {}, values: [] }, |
||||
{ name: 'name', type: FieldType.string, config: {}, values: [] }, |
||||
{ name: 'uid', type: FieldType.string, config: {}, values: [] }, |
||||
{ name: 'url', type: FieldType.string, config: {}, values: [] }, |
||||
{ name: 'tags', type: FieldType.other, config: {}, values: [] }, |
||||
{ name: 'location', type: FieldType.string, config: {}, values: [] }, |
||||
], |
||||
length: 0, |
||||
}; |
||||
|
||||
const mockSearchResult: QueryResponse = { |
||||
isItemLoaded: jest.fn(), |
||||
loadMoreItems: jest.fn(), |
||||
totalRows: emptySearchData.length, |
||||
view: new DataFrameView<DashboardQueryResult>(emptySearchData), |
||||
}; |
||||
|
||||
beforeAll(() => { |
||||
grafanaSearcherSpy = jest.spyOn(getGrafanaSearcher(), 'search').mockResolvedValue(mockSearchResult); |
||||
}); |
||||
|
||||
it('shows the folder title as the header', async () => { |
||||
render(<FolderSection section={mockSection} onTagSelected={mockOnTagSelected} />); |
||||
expect(await screen.findByRole('button', { name: mockSection.title })).toBeInTheDocument(); |
||||
}); |
||||
|
||||
describe('when renderStandaloneBody is set', () => { |
||||
it('shows a "No results found" message and does not show the folder title header', async () => { |
||||
render(<FolderSection renderStandaloneBody section={mockSection} onTagSelected={mockOnTagSelected} />); |
||||
expect(await screen.findByText('No results found')).toBeInTheDocument(); |
||||
expect(screen.queryByRole('button', { name: mockSection.title })).not.toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('renders a loading spinner whilst waiting for the results', async () => { |
||||
// mock the query promise so we can resolve manually
|
||||
let promiseResolver: (arg0: QueryResponse) => void; |
||||
const promise = new Promise((resolve) => { |
||||
promiseResolver = resolve; |
||||
}); |
||||
grafanaSearcherSpy.mockImplementationOnce(() => promise); |
||||
|
||||
render(<FolderSection renderStandaloneBody section={mockSection} onTagSelected={mockOnTagSelected} />); |
||||
expect(await screen.findByTestId('Spinner')).toBeInTheDocument(); |
||||
|
||||
// resolve the promise
|
||||
await act(async () => { |
||||
promiseResolver(mockSearchResult); |
||||
}); |
||||
|
||||
expect(screen.queryByTestId('Spinner')).not.toBeInTheDocument(); |
||||
expect(await screen.findByText('No results found')).toBeInTheDocument(); |
||||
}); |
||||
}); |
||||
|
||||
it('shows a "No results found" message when expanding the folder', async () => { |
||||
render(<FolderSection section={mockSection} onTagSelected={mockOnTagSelected} />); |
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: mockSection.title })); |
||||
expect(getGrafanaSearcher().search).toHaveBeenCalled(); |
||||
expect(await screen.findByText('No results found')).toBeInTheDocument(); |
||||
}); |
||||
}); |
||||
|
||||
describe('when there are results', () => { |
||||
const searchData: DataFrame = { |
||||
fields: [ |
||||
{ name: 'kind', type: FieldType.string, config: {}, values: [DashboardSearchItemType.DashDB] }, |
||||
{ name: 'name', type: FieldType.string, config: {}, values: ['My dashboard 1'] }, |
||||
{ name: 'uid', type: FieldType.string, config: {}, values: ['my-dashboard-1'] }, |
||||
{ name: 'url', type: FieldType.string, config: {}, values: ['/my-dashboard-1'] }, |
||||
{ name: 'tags', type: FieldType.other, config: {}, values: [['foo', 'bar']] }, |
||||
{ name: 'location', type: FieldType.string, config: {}, values: ['my-folder-1'] }, |
||||
], |
||||
meta: { |
||||
custom: { |
||||
locationInfo: { |
||||
'my-folder-1': { |
||||
name: 'My folder 1', |
||||
kind: 'folder', |
||||
url: '/my-folder-1', |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
length: 1, |
||||
}; |
||||
|
||||
const mockSearchResult: QueryResponse = { |
||||
isItemLoaded: jest.fn(), |
||||
loadMoreItems: jest.fn(), |
||||
totalRows: searchData.length, |
||||
view: new DataFrameView<DashboardQueryResult>(searchData), |
||||
}; |
||||
|
||||
beforeAll(() => { |
||||
grafanaSearcherSpy = jest.spyOn(getGrafanaSearcher(), 'search').mockResolvedValue(mockSearchResult); |
||||
}); |
||||
|
||||
it('shows the folder title as the header', async () => { |
||||
render(<FolderSection section={mockSection} onTagSelected={mockOnTagSelected} />); |
||||
expect(await screen.findByRole('button', { name: mockSection.title })).toBeInTheDocument(); |
||||
}); |
||||
|
||||
describe('when renderStandaloneBody is set', () => { |
||||
it('shows the folder children and does not render the folder title', async () => { |
||||
render(<FolderSection renderStandaloneBody section={mockSection} onTagSelected={mockOnTagSelected} />); |
||||
expect(await screen.findByText('My dashboard 1')).toBeInTheDocument(); |
||||
expect(screen.queryByRole('button', { name: mockSection.title })).not.toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('renders a loading spinner whilst waiting for the results', async () => { |
||||
// mock the query promise so we can resolve manually
|
||||
let promiseResolver: (arg0: QueryResponse) => void; |
||||
const promise = new Promise((resolve) => { |
||||
promiseResolver = resolve; |
||||
}); |
||||
grafanaSearcherSpy.mockImplementationOnce(() => promise); |
||||
|
||||
render(<FolderSection renderStandaloneBody section={mockSection} onTagSelected={mockOnTagSelected} />); |
||||
expect(await screen.findByTestId('Spinner')).toBeInTheDocument(); |
||||
|
||||
// resolve the promise
|
||||
await act(async () => { |
||||
promiseResolver(mockSearchResult); |
||||
}); |
||||
|
||||
expect(screen.queryByTestId('Spinner')).not.toBeInTheDocument(); |
||||
expect(await screen.findByText('My dashboard 1')).toBeInTheDocument(); |
||||
}); |
||||
}); |
||||
|
||||
it('shows the folder contents when expanding the folder', async () => { |
||||
render(<FolderSection section={mockSection} onTagSelected={mockOnTagSelected} />); |
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: mockSection.title })); |
||||
expect(getGrafanaSearcher().search).toHaveBeenCalled(); |
||||
expect(await screen.findByText('My dashboard 1')).toBeInTheDocument(); |
||||
}); |
||||
|
||||
describe('when clicking the checkbox', () => { |
||||
it('does not expand the section', async () => { |
||||
render( |
||||
<FolderSection |
||||
section={mockSection} |
||||
selection={mockSelection} |
||||
selectionToggle={mockSelectionToggle} |
||||
onTagSelected={mockOnTagSelected} |
||||
/> |
||||
); |
||||
|
||||
await userEvent.click(await screen.findByRole('checkbox', { name: 'Select folder' })); |
||||
expect(screen.queryByText('My dashboard 1')).not.toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('selects only the folder if the folder is not expanded', async () => { |
||||
render( |
||||
<FolderSection |
||||
section={mockSection} |
||||
selection={mockSelection} |
||||
selectionToggle={mockSelectionToggle} |
||||
onTagSelected={mockOnTagSelected} |
||||
/> |
||||
); |
||||
|
||||
await userEvent.click(await screen.findByRole('checkbox', { name: 'Select folder' })); |
||||
expect(mockSelectionToggle).toHaveBeenCalledWith('folder', 'my-folder'); |
||||
expect(mockSelectionToggle).not.toHaveBeenCalledWith('dashboard', 'my-dashboard-1'); |
||||
}); |
||||
|
||||
it('selects the folder and all children when the folder is expanded', async () => { |
||||
render( |
||||
<FolderSection |
||||
section={mockSection} |
||||
selection={mockSelection} |
||||
selectionToggle={mockSelectionToggle} |
||||
onTagSelected={mockOnTagSelected} |
||||
/> |
||||
); |
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: mockSection.title })); |
||||
expect(getGrafanaSearcher().search).toHaveBeenCalled(); |
||||
|
||||
await userEvent.click(await screen.findByRole('checkbox', { name: 'Select folder' })); |
||||
expect(mockSelectionToggle).toHaveBeenCalledWith('folder', 'my-folder'); |
||||
expect(mockSelectionToggle).toHaveBeenCalledWith('dashboard', 'my-dashboard-1'); |
||||
}); |
||||
}); |
||||
|
||||
describe('when in a pseudo-folder (i.e. Starred/Recent)', () => { |
||||
const mockRecentSection: DashboardViewItem = { |
||||
kind: 'folder', |
||||
uid: '__recent', |
||||
title: 'Recent', |
||||
itemsUIDs: ['my-dashboard-1'], |
||||
}; |
||||
|
||||
it('shows the correct folder name next to the dashboard', async () => { |
||||
render(<FolderSection section={mockRecentSection} onTagSelected={mockOnTagSelected} />); |
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: mockRecentSection.title })); |
||||
expect(getGrafanaSearcher().search).toHaveBeenCalled(); |
||||
expect(await screen.findByText('My dashboard 1')).toBeInTheDocument(); |
||||
expect(await screen.findByText('My folder 1')).toBeInTheDocument(); |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
@ -1,258 +0,0 @@ |
||||
import { css } from '@emotion/css'; |
||||
import React, { useId, useState } from 'react'; |
||||
import { useAsync } 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 { SearchItem } from '../..'; |
||||
import { GENERAL_FOLDER_UID, SEARCH_EXPANDED_FOLDER_STORAGE_KEY } 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'; |
||||
|
||||
interface SectionHeaderProps { |
||||
selection?: SelectionChecker; |
||||
selectionToggle?: SelectionToggle; |
||||
onClickItem?: (e: React.MouseEvent<HTMLElement>) => void; |
||||
onTagSelected: (tag: string) => void; |
||||
section: DashboardViewItem; |
||||
renderStandaloneBody?: boolean; // render the body on its own
|
||||
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, |
||||
onClickItem, |
||||
onTagSelected, |
||||
selection, |
||||
renderStandaloneBody, |
||||
tags, |
||||
}: SectionHeaderProps) => { |
||||
const uid = section.uid; |
||||
const editable = selectionToggle != null; |
||||
|
||||
const styles = useStyles2(getSectionHeaderStyles, editable); |
||||
const [sectionExpanded, setSectionExpanded] = useState(() => { |
||||
const lastExpandedFolder = window.localStorage.getItem(SEARCH_EXPANDED_FOLDER_STORAGE_KEY); |
||||
return lastExpandedFolder === uid; |
||||
}); |
||||
|
||||
const results = useAsync(async () => { |
||||
if (!sectionExpanded && !renderStandaloneBody) { |
||||
return Promise.resolve([]); |
||||
} |
||||
|
||||
const childItems = getChildren(section, tags); |
||||
|
||||
return childItems; |
||||
}, [sectionExpanded, tags]); |
||||
|
||||
const onSectionExpand = () => { |
||||
const newExpandedValue = !sectionExpanded; |
||||
|
||||
if (newExpandedValue) { |
||||
// If we've just expanded the section, remember it to local storage
|
||||
window.localStorage.setItem(SEARCH_EXPANDED_FOLDER_STORAGE_KEY, uid); |
||||
} else { |
||||
// Else, when closing a section, remove it from local storage only if this folder was the most recently opened
|
||||
const lastExpandedFolder = window.localStorage.getItem(SEARCH_EXPANDED_FOLDER_STORAGE_KEY); |
||||
if (lastExpandedFolder === uid) { |
||||
window.localStorage.removeItem(SEARCH_EXPANDED_FOLDER_STORAGE_KEY); |
||||
} |
||||
} |
||||
|
||||
setSectionExpanded(newExpandedValue); |
||||
}; |
||||
|
||||
const onToggleFolder = (evt: React.FormEvent) => { |
||||
evt.preventDefault(); |
||||
evt.stopPropagation(); |
||||
if (selectionToggle && selection) { |
||||
const checked = !selection(section.kind, section.uid); |
||||
selectionToggle(section.kind, section.uid); |
||||
const sub = results.value ?? []; |
||||
for (const item of sub) { |
||||
if (selection(item.kind, item.uid!) !== checked) { |
||||
selectionToggle(item.kind, item.uid!); |
||||
} |
||||
} |
||||
} |
||||
}; |
||||
|
||||
const id = useId(); |
||||
const labelId = `section-header-label-${id}`; |
||||
|
||||
let icon = toIconName(section.icon ?? ''); |
||||
if (!icon) { |
||||
icon = sectionExpanded ? 'folder-open' : 'folder'; |
||||
} |
||||
|
||||
const renderResults = () => { |
||||
if (!results.value) { |
||||
return null; |
||||
} else if (results.value.length === 0 && !results.loading) { |
||||
return ( |
||||
<Card> |
||||
<Card.Heading>No results found</Card.Heading> |
||||
</Card> |
||||
); |
||||
} |
||||
|
||||
return results.value.map((item) => { |
||||
return ( |
||||
<SearchItem |
||||
key={item.uid} |
||||
item={item} |
||||
onTagSelected={onTagSelected} |
||||
onToggleChecked={(item) => selectionToggle?.(item.kind, item.uid)} |
||||
editable={Boolean(selection != null)} |
||||
onClickItem={onClickItem} |
||||
isSelected={selection?.(item.kind, item.uid)} |
||||
/> |
||||
); |
||||
}); |
||||
}; |
||||
|
||||
// Skip the folder wrapper
|
||||
if (renderStandaloneBody) { |
||||
return ( |
||||
<div className={styles.folderViewResults}> |
||||
{!results.value?.length && results.loading ? <Spinner className={styles.spinner} /> : renderResults()} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
return ( |
||||
<CollapsableSection |
||||
headerDataTestId={selectors.components.Search.folderHeader(section.title)} |
||||
contentDataTestId={selectors.components.Search.folderContent(section.title)} |
||||
isOpen={sectionExpanded ?? false} |
||||
onToggle={onSectionExpand} |
||||
className={styles.wrapper} |
||||
contentClassName={styles.content} |
||||
loading={results.loading} |
||||
labelId={labelId} |
||||
label={ |
||||
<> |
||||
{selectionToggle && selection && ( |
||||
// TODO: fix keyboard a11y
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div onClick={onToggleFolder}> |
||||
<Checkbox |
||||
className={styles.checkbox} |
||||
value={selection(section.kind, section.uid)} |
||||
aria-label={t('search.folder-view.select-folder', 'Select folder')} |
||||
/> |
||||
</div> |
||||
)} |
||||
|
||||
<div className={styles.icon}> |
||||
<Icon name={icon} /> |
||||
</div> |
||||
|
||||
<div className={styles.text}> |
||||
<span id={labelId}>{section.title}</span> |
||||
{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')} |
||||
</a> |
||||
)} |
||||
</div> |
||||
</> |
||||
} |
||||
> |
||||
{results.value && <ul className={styles.sectionItems}>{renderResults()}</ul>} |
||||
</CollapsableSection> |
||||
); |
||||
}; |
||||
|
||||
const getSectionHeaderStyles = (theme: GrafanaTheme2, editable: boolean) => { |
||||
const sm = theme.spacing(1); |
||||
|
||||
return { |
||||
wrapper: css` |
||||
align-items: center; |
||||
font-size: ${theme.typography.size.base}; |
||||
padding: 12px; |
||||
border-bottom: none; |
||||
color: ${theme.colors.text.secondary}; |
||||
z-index: 1; |
||||
|
||||
&:hover, |
||||
&.selected { |
||||
color: ${theme.colors.text}; |
||||
} |
||||
|
||||
&:hover, |
||||
&:focus-visible, |
||||
&:focus-within { |
||||
a { |
||||
opacity: 1; |
||||
} |
||||
} |
||||
`,
|
||||
sectionItems: css` |
||||
margin: 0 24px 0 32px; |
||||
`,
|
||||
icon: css` |
||||
padding: 0 ${sm} 0 ${editable ? 0 : sm}; |
||||
`,
|
||||
folderViewResults: css` |
||||
overflow: auto; |
||||
`,
|
||||
text: css` |
||||
flex-grow: 1; |
||||
line-height: 24px; |
||||
`,
|
||||
link: css` |
||||
padding: 2px 10px 0; |
||||
color: ${theme.colors.text.secondary}; |
||||
opacity: 0; |
||||
transition: opacity 150ms ease-in-out; |
||||
`,
|
||||
separator: css` |
||||
margin-right: 6px; |
||||
`,
|
||||
content: css` |
||||
padding-top: 0px; |
||||
padding-bottom: 0px; |
||||
`,
|
||||
spinner: css` |
||||
display: grid; |
||||
place-content: center; |
||||
padding-bottom: 1rem; |
||||
`,
|
||||
checkbox: css({ |
||||
marginRight: theme.spacing(1), |
||||
}), |
||||
}; |
||||
}; |
@ -1,121 +0,0 @@ |
||||
import { render, screen } from '@testing-library/react'; |
||||
import userEvent from '@testing-library/user-event'; |
||||
import React from 'react'; |
||||
import { Provider } from 'react-redux'; |
||||
import configureMockStore from 'redux-mock-store'; |
||||
|
||||
import { contextSrv } from 'app/core/services/context_srv'; |
||||
|
||||
import { ManageActions } from './ManageActions'; |
||||
|
||||
jest.mock('app/core/services/context_srv', () => ({ |
||||
contextSrv: { |
||||
hasEditPermissionInFolders: false, |
||||
}, |
||||
})); |
||||
|
||||
jest.mock('app/core/components/Select/OldFolderPicker', () => { |
||||
return { |
||||
OldFolderPicker: () => null, |
||||
}; |
||||
}); |
||||
|
||||
describe('ManageActions', () => { |
||||
describe('when user has edit permission in folders', () => { |
||||
// Permissions
|
||||
contextSrv.hasEditPermissionInFolders = true; |
||||
|
||||
// Mock selected dashboards
|
||||
const mockItemsSelected = new Map(); |
||||
const mockDashboardsUIDsSelected = new Set(); |
||||
mockDashboardsUIDsSelected.add('uid1'); |
||||
mockDashboardsUIDsSelected.add('uid2'); |
||||
mockItemsSelected.set('dashboard', mockDashboardsUIDsSelected); |
||||
|
||||
//Mock store redux for old MoveDashboards state action
|
||||
const mockStore = configureMockStore(); |
||||
const store = mockStore({ dashboard: { panels: [] } }); |
||||
|
||||
const onChange = jest.fn(); |
||||
const clearSelection = jest.fn(); |
||||
|
||||
it('should show move when user click the move button', async () => { |
||||
render( |
||||
<Provider store={store}> |
||||
<ManageActions items={mockItemsSelected} onChange={onChange} clearSelection={clearSelection} /> |
||||
</Provider> |
||||
); |
||||
expect(screen.getByTestId('manage-actions')).toBeInTheDocument(); |
||||
expect(await screen.findByRole('button', { name: 'Move', hidden: true })).not.toBeDisabled(); |
||||
expect(await screen.findByRole('button', { name: 'Delete', hidden: true })).not.toBeDisabled(); |
||||
|
||||
// open Move modal
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Move', hidden: true })); |
||||
expect(screen.getByText(/Move 2 dashboards to:/i)).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('should show delete modal when user click the delete button', async () => { |
||||
render( |
||||
<Provider store={store}> |
||||
<ManageActions items={mockItemsSelected} onChange={onChange} clearSelection={clearSelection} /> |
||||
</Provider> |
||||
); |
||||
expect(screen.getByTestId('manage-actions')).toBeInTheDocument(); |
||||
// open Delete modal
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Delete', hidden: true })); |
||||
expect(screen.getByText(/Do you want to delete the 2 selected dashboards\?/i)).toBeInTheDocument(); |
||||
}); |
||||
}); |
||||
|
||||
describe('when user has not edit permission in folders', () => { |
||||
it('should have disabled the Move button', async () => { |
||||
contextSrv.hasEditPermissionInFolders = false; |
||||
const mockItemsSelected = new Map(); |
||||
const mockDashboardsUIDsSelected = new Set(); |
||||
mockDashboardsUIDsSelected.add('uid1'); |
||||
mockDashboardsUIDsSelected.add('uid2'); |
||||
mockItemsSelected.set('dashboard', mockDashboardsUIDsSelected); |
||||
|
||||
//Mock store
|
||||
const mockStore = configureMockStore(); |
||||
const store = mockStore({ dashboard: { panels: [] } }); |
||||
|
||||
const onChange = jest.fn(); |
||||
const clearSelection = jest.fn(); |
||||
|
||||
render( |
||||
<Provider store={store}> |
||||
<ManageActions items={mockItemsSelected} onChange={onChange} clearSelection={clearSelection} /> |
||||
</Provider> |
||||
); |
||||
expect(screen.getByTestId('manage-actions')).toBeInTheDocument(); |
||||
expect(await screen.findByRole('button', { name: 'Move', hidden: true })).toBeDisabled(); |
||||
await userEvent.click(screen.getByRole('button', { name: 'Move', hidden: true })); |
||||
expect(screen.queryByText(/Choose Dashboard Folder/i)).toBeNull(); |
||||
}); |
||||
}); |
||||
describe('When user has selected General folder', () => { |
||||
contextSrv.hasEditPermissionInFolders = true; |
||||
const mockItemsSelected = new Map(); |
||||
const mockFolderUIDSelected = new Set(); |
||||
mockFolderUIDSelected.add('general'); |
||||
mockItemsSelected.set('folder', mockFolderUIDSelected); |
||||
|
||||
//Mock store
|
||||
const mockStore = configureMockStore(); |
||||
const store = mockStore({ dashboard: { panels: [] } }); |
||||
|
||||
const onChange = jest.fn(); |
||||
const clearSelection = jest.fn(); |
||||
|
||||
it('should disable the Delete button', async () => { |
||||
render( |
||||
<Provider store={store}> |
||||
<ManageActions items={mockItemsSelected} onChange={onChange} clearSelection={clearSelection} /> |
||||
</Provider> |
||||
); |
||||
expect(screen.getByTestId('manage-actions')).toBeInTheDocument(); |
||||
expect(await screen.findByRole('button', { name: 'Delete', hidden: true })).toBeDisabled(); |
||||
}); |
||||
}); |
||||
}); |
@ -1,65 +0,0 @@ |
||||
import React, { useState } from 'react'; |
||||
|
||||
import { Button, HorizontalGroup, IconButton, useStyles2 } from '@grafana/ui'; |
||||
import { contextSrv } from 'app/core/services/context_srv'; |
||||
import { FolderDTO } from 'app/types'; |
||||
|
||||
import { GENERAL_FOLDER_UID } from '../../constants'; |
||||
import { OnMoveOrDeleleSelectedItems } from '../../types'; |
||||
|
||||
import { getStyles } from './ActionRow'; |
||||
import { ConfirmDeleteModal } from './ConfirmDeleteModal'; |
||||
import { MoveToFolderModal } from './MoveToFolderModal'; |
||||
|
||||
type Props = { |
||||
items: Map<string, Set<string>>; |
||||
folder?: FolderDTO; // when we are loading in folder page
|
||||
onChange: OnMoveOrDeleleSelectedItems; |
||||
clearSelection: () => void; |
||||
}; |
||||
|
||||
export function ManageActions({ items, folder, onChange, clearSelection }: Props) { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
const canSave = folder?.canSave; |
||||
const hasEditPermissionInFolders = folder ? canSave : contextSrv.hasEditPermissionInFolders; |
||||
|
||||
const canMove = hasEditPermissionInFolders; |
||||
|
||||
const selectedFolders = Array.from(items.get('folder') ?? []); |
||||
const includesGeneralFolder = selectedFolders.find((result) => result === GENERAL_FOLDER_UID); |
||||
|
||||
const canDelete = hasEditPermissionInFolders && !includesGeneralFolder; |
||||
const [isMoveModalOpen, setIsMoveModalOpen] = useState(false); |
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); |
||||
|
||||
const onMove = () => { |
||||
setIsMoveModalOpen(true); |
||||
}; |
||||
|
||||
const onDelete = () => { |
||||
setIsDeleteModalOpen(true); |
||||
}; |
||||
|
||||
return ( |
||||
<div className={styles.actionRow} data-testid="manage-actions"> |
||||
<HorizontalGroup spacing="md" width="auto"> |
||||
<IconButton name="check-square" onClick={clearSelection} tooltip="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)} /> |
||||
)} |
||||
|
||||
{isMoveModalOpen && ( |
||||
<MoveToFolderModal onMoveItems={onChange} results={items} onDismiss={() => setIsMoveModalOpen(false)} /> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
@ -1,159 +0,0 @@ |
||||
import { render, screen } from '@testing-library/react'; |
||||
import userEvent from '@testing-library/user-event'; |
||||
import React from 'react'; |
||||
import { Provider } from 'react-redux'; |
||||
import configureMockStore from 'redux-mock-store'; |
||||
import { selectOptionInTest } from 'test/helpers/selectOptionInTest'; |
||||
|
||||
import { selectors } from '@grafana/e2e-selectors'; |
||||
import config from 'app/core/config'; |
||||
import * as api from 'app/features/manage-dashboards/state/actions'; |
||||
|
||||
import { DashboardSearchHit, DashboardSearchItemType } from '../../types'; |
||||
|
||||
import { MoveToFolderModal } from './MoveToFolderModal'; |
||||
|
||||
function makeSelections(dashboardUIDs: string[] = [], folderUIDs: string[] = []) { |
||||
const dashboards = new Set(dashboardUIDs); |
||||
const folders = new Set(folderUIDs); |
||||
|
||||
return new Map([ |
||||
['dashboard', dashboards], |
||||
['folder', folders], |
||||
]); |
||||
} |
||||
|
||||
function makeDashboardSearchHit(title: string, uid: string, type = DashboardSearchItemType.DashDB): DashboardSearchHit { |
||||
return { title, uid, tags: [], type, url: `/d/${uid}` }; |
||||
} |
||||
|
||||
describe('MoveToFolderModal', () => { |
||||
jest |
||||
.spyOn(api, 'searchFolders') |
||||
.mockResolvedValue([ |
||||
makeDashboardSearchHit('General', '', DashboardSearchItemType.DashFolder), |
||||
makeDashboardSearchHit('Folder 1', 'folder-uid-1', DashboardSearchItemType.DashFolder), |
||||
makeDashboardSearchHit('Folder 2', 'folder-uid-1', DashboardSearchItemType.DashFolder), |
||||
makeDashboardSearchHit('Folder 3', 'folder-uid-3', DashboardSearchItemType.DashFolder), |
||||
]); |
||||
|
||||
it('should render correct title, body, dismiss-, cancel- and move-text', async () => { |
||||
const items = makeSelections(['dash-uid-1', 'dash-uid-2']); |
||||
|
||||
const mockStore = configureMockStore(); |
||||
const store = mockStore({ dashboard: { panels: [] } }); |
||||
const onMoveItems = jest.fn(); |
||||
|
||||
render( |
||||
<Provider store={store}> |
||||
<MoveToFolderModal onMoveItems={onMoveItems} results={items} onDismiss={() => {}} /> |
||||
</Provider> |
||||
); |
||||
|
||||
// Wait for folder picker to finish rendering
|
||||
await screen.findByText('Choose'); |
||||
|
||||
expect(screen.getByRole('heading', { name: 'Choose Dashboard Folder' })).toBeInTheDocument(); |
||||
expect(screen.getByText('Move 2 dashboards to:')).toBeInTheDocument(); |
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); |
||||
expect(screen.getByRole('button', { name: 'Move' })).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('should move dashboards, but not folders', async () => { |
||||
const moveDashboardsMock = jest.spyOn(api, 'moveDashboards').mockResolvedValue({ |
||||
successCount: 2, |
||||
totalCount: 2, |
||||
alreadyInFolderCount: 0, |
||||
}); |
||||
|
||||
const moveFoldersMock = jest.spyOn(api, 'moveFolders').mockResolvedValue({ |
||||
successCount: 1, |
||||
totalCount: 1, |
||||
}); |
||||
|
||||
const items = makeSelections(['dash-uid-1', 'dash-uid-2'], ['folder-uid-1']); |
||||
|
||||
const mockStore = configureMockStore(); |
||||
const store = mockStore({ dashboard: { panels: [] } }); |
||||
const onMoveItems = jest.fn(); |
||||
|
||||
render( |
||||
<Provider store={store}> |
||||
<MoveToFolderModal onMoveItems={onMoveItems} results={items} onDismiss={() => {}} /> |
||||
</Provider> |
||||
); |
||||
|
||||
// Wait for folder picker to finish rendering
|
||||
await screen.findByText('Choose'); |
||||
|
||||
const folderPicker = screen.getByLabelText(selectors.components.FolderPicker.input); |
||||
await selectOptionInTest(folderPicker, 'Folder 3'); |
||||
|
||||
const moveButton = screen.getByText('Move'); |
||||
await userEvent.click(moveButton); |
||||
|
||||
expect(moveDashboardsMock).toHaveBeenCalledWith(['dash-uid-1', 'dash-uid-2'], { |
||||
title: 'Folder 3', |
||||
uid: 'folder-uid-3', |
||||
}); |
||||
|
||||
expect(moveFoldersMock).not.toHaveBeenCalled(); |
||||
}); |
||||
|
||||
describe('with nestedFolders feature flag', () => { |
||||
let originalNestedFoldersValue = config.featureToggles.nestedFolders; |
||||
|
||||
beforeAll(() => { |
||||
originalNestedFoldersValue = config.featureToggles.nestedFolders; |
||||
config.featureToggles.nestedFolders = true; |
||||
}); |
||||
|
||||
afterAll(() => { |
||||
config.featureToggles.nestedFolders = originalNestedFoldersValue; |
||||
}); |
||||
|
||||
it('should move folders and dashboards', async () => { |
||||
const moveDashboardsMock = jest.spyOn(api, 'moveDashboards').mockResolvedValue({ |
||||
successCount: 2, |
||||
totalCount: 2, |
||||
alreadyInFolderCount: 0, |
||||
}); |
||||
|
||||
const moveFoldersMock = jest.spyOn(api, 'moveFolders').mockResolvedValue({ |
||||
successCount: 1, |
||||
totalCount: 1, |
||||
}); |
||||
|
||||
const items = makeSelections(['dash-uid-1', 'dash-uid-2'], ['folder-uid-1']); |
||||
|
||||
const mockStore = configureMockStore(); |
||||
const store = mockStore({ dashboard: { panels: [] } }); |
||||
const onMoveItems = jest.fn(); |
||||
|
||||
render( |
||||
<Provider store={store}> |
||||
<MoveToFolderModal onMoveItems={onMoveItems} results={items} onDismiss={() => {}} /> |
||||
</Provider> |
||||
); |
||||
|
||||
// Wait for folder picker to finish rendering
|
||||
await screen.findByText('Choose'); |
||||
|
||||
const folderPicker = screen.getByLabelText(selectors.components.FolderPicker.input); |
||||
await selectOptionInTest(folderPicker, 'Folder 3'); |
||||
|
||||
const moveButton = screen.getByRole('button', { name: 'Move' }); |
||||
await userEvent.click(moveButton); |
||||
|
||||
expect(moveDashboardsMock).toHaveBeenCalledWith(['dash-uid-1', 'dash-uid-2'], { |
||||
title: 'Folder 3', |
||||
uid: 'folder-uid-3', |
||||
}); |
||||
|
||||
expect(moveFoldersMock).toHaveBeenCalledWith(['folder-uid-1'], { |
||||
title: 'Folder 3', |
||||
uid: 'folder-uid-3', |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
@ -1,193 +0,0 @@ |
||||
import { css } from '@emotion/css'; |
||||
import React, { useCallback, useState } from 'react'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { Alert, Button, HorizontalGroup, Modal, useStyles2 } from '@grafana/ui'; |
||||
import { OldFolderPicker } from 'app/core/components/Select/OldFolderPicker'; |
||||
import config from 'app/core/config'; |
||||
import { useAppNotification } from 'app/core/copy/appNotification'; |
||||
import { moveDashboards, moveFolders } from 'app/features/manage-dashboards/state/actions'; |
||||
import { FolderInfo } from 'app/types'; |
||||
|
||||
import { GENERAL_FOLDER_UID } from '../../constants'; |
||||
import { OnMoveOrDeleleSelectedItems } from '../../types'; |
||||
|
||||
interface Props { |
||||
onMoveItems: OnMoveOrDeleleSelectedItems; |
||||
results: Map<string, Set<string>>; |
||||
onDismiss: () => void; |
||||
} |
||||
|
||||
export const MoveToFolderModal = ({ results, onMoveItems, onDismiss }: Props) => { |
||||
const [folder, setFolder] = useState<FolderInfo | null>(null); |
||||
const styles = useStyles2(getStyles); |
||||
const notifyApp = useAppNotification(); |
||||
const [moving, setMoving] = useState(false); |
||||
|
||||
const nestedFoldersEnabled = config.featureToggles.nestedFolders; |
||||
|
||||
const selectedDashboards = Array.from(results.get('dashboard') ?? []); |
||||
const selectedFolders = nestedFoldersEnabled |
||||
? Array.from(results.get('folder') ?? []).filter((v) => v !== GENERAL_FOLDER_UID) |
||||
: []; |
||||
|
||||
const handleFolderChange = useCallback( |
||||
(newFolder: FolderInfo) => { |
||||
setFolder(newFolder); |
||||
}, |
||||
[setFolder] |
||||
); |
||||
|
||||
const moveTo = async () => { |
||||
if (!folder) { |
||||
return; |
||||
} |
||||
|
||||
if (nestedFoldersEnabled) { |
||||
setMoving(true); |
||||
let totalCount = 0; |
||||
let successCount = 0; |
||||
|
||||
if (selectedDashboards.length) { |
||||
const moveDashboardsResult = await moveDashboards(selectedDashboards, folder); |
||||
|
||||
totalCount += moveDashboardsResult.totalCount; |
||||
successCount += moveDashboardsResult.successCount; |
||||
} |
||||
|
||||
if (selectedFolders.length) { |
||||
const moveFoldersResult = await moveFolders(selectedFolders, folder); |
||||
|
||||
totalCount += moveFoldersResult.totalCount; |
||||
successCount += moveFoldersResult.successCount; |
||||
} |
||||
|
||||
const destTitle = folder.title ?? 'General'; |
||||
notifyNestedMoveResult(notifyApp, destTitle, { |
||||
selectedDashboardsCount: selectedDashboards.length, |
||||
selectedFoldersCount: selectedFolders.length, |
||||
totalCount, |
||||
successCount, |
||||
}); |
||||
|
||||
onMoveItems(); |
||||
setMoving(false); |
||||
onDismiss(); |
||||
|
||||
return; |
||||
} |
||||
|
||||
if (selectedDashboards.length) { |
||||
const folderTitle = folder.title ?? 'General'; |
||||
setMoving(true); |
||||
moveDashboards(selectedDashboards, folder).then((result) => { |
||||
if (result.successCount > 0) { |
||||
const ending = result.successCount === 1 ? '' : 's'; |
||||
const header = `Dashboard${ending} Moved`; |
||||
const msg = `${result.successCount} dashboard${ending} moved to ${folderTitle}`; |
||||
notifyApp.success(header, msg); |
||||
} |
||||
|
||||
if (result.totalCount === result.alreadyInFolderCount) { |
||||
notifyApp.error('Error', `Dashboard already belongs to folder ${folderTitle}`); |
||||
} else { |
||||
//update the list
|
||||
onMoveItems(); |
||||
} |
||||
|
||||
setMoving(false); |
||||
onDismiss(); |
||||
}); |
||||
} |
||||
}; |
||||
|
||||
const thingsMoving = [ |
||||
['folder', 'folders', selectedFolders.length] as const, |
||||
['dashboard', 'dashboards', selectedDashboards.length] as const, |
||||
] |
||||
.filter(([single, plural, count]) => count > 0) |
||||
.map(([single, plural, count]) => `${count.toLocaleString()} ${count === 1 ? single : plural}`) |
||||
.join(' and '); |
||||
|
||||
return ( |
||||
<Modal |
||||
isOpen |
||||
className={styles.modal} |
||||
title={nestedFoldersEnabled ? 'Move' : 'Choose Dashboard Folder'} |
||||
icon="folder-plus" |
||||
onDismiss={onDismiss} |
||||
> |
||||
<> |
||||
<div className={styles.content}> |
||||
{nestedFoldersEnabled && selectedFolders.length > 0 && ( |
||||
<Alert severity="warning" title=" Moving this item may change its permissions" /> |
||||
)} |
||||
|
||||
<p>Move {thingsMoving} to:</p> |
||||
|
||||
<OldFolderPicker allowEmpty={true} enableCreateNew={false} onChange={handleFolderChange} /> |
||||
</div> |
||||
|
||||
<HorizontalGroup justify="flex-end"> |
||||
<Button variant="secondary" onClick={onDismiss} fill="outline"> |
||||
Cancel |
||||
</Button> |
||||
<Button icon={moving ? 'spinner' : undefined} variant="primary" onClick={moveTo}> |
||||
Move |
||||
</Button> |
||||
</HorizontalGroup> |
||||
</> |
||||
</Modal> |
||||
); |
||||
}; |
||||
|
||||
interface NotifyCounts { |
||||
selectedDashboardsCount: number; |
||||
selectedFoldersCount: number; |
||||
totalCount: number; |
||||
successCount: number; |
||||
} |
||||
|
||||
function notifyNestedMoveResult( |
||||
notifyApp: ReturnType<typeof useAppNotification>, |
||||
destinationName: string, |
||||
{ selectedDashboardsCount, selectedFoldersCount, totalCount, successCount }: NotifyCounts |
||||
) { |
||||
let objectMoving: string | undefined; |
||||
const plural = successCount === 1 ? '' : 's'; |
||||
const failedCount = totalCount - successCount; |
||||
|
||||
if (selectedDashboardsCount && selectedFoldersCount) { |
||||
objectMoving = `Item${plural}`; |
||||
} else if (selectedDashboardsCount) { |
||||
objectMoving = `Dashboard${plural}`; |
||||
} else if (selectedFoldersCount) { |
||||
objectMoving = `Folder${plural}`; |
||||
} |
||||
|
||||
if (objectMoving) { |
||||
const objectLower = objectMoving?.toLocaleLowerCase(); |
||||
|
||||
if (totalCount === successCount) { |
||||
notifyApp.success(`${objectMoving} moved`, `Moved ${successCount} ${objectLower} to ${destinationName}`); |
||||
} else if (successCount === 0) { |
||||
notifyApp.error(`Failed to move ${objectLower}`, `Could not move ${totalCount} ${objectLower} due to an error`); |
||||
} else { |
||||
notifyApp.warning( |
||||
`Partially moved ${objectLower}`, |
||||
`Failed to move ${failedCount} ${objectLower} to ${destinationName}` |
||||
); |
||||
} |
||||
} |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
modal: css` |
||||
width: 500px; |
||||
`,
|
||||
content: css` |
||||
margin-bottom: ${theme.spacing(3)}; |
||||
`,
|
||||
}; |
||||
}; |
@ -1,204 +0,0 @@ |
||||
import { render, screen, act } from '@testing-library/react'; |
||||
import React from 'react'; |
||||
|
||||
import { DataFrame, DataFrameView, FieldType } from '@grafana/data'; |
||||
import { selectors } from '@grafana/e2e-selectors'; |
||||
|
||||
import { ContextSrv, setContextSrv } from '../../../../core/services/context_srv'; |
||||
import impressionSrv from '../../../../core/services/impression_srv'; |
||||
import { DashboardQueryResult, getGrafanaSearcher, QueryResponse } from '../../service'; |
||||
import { DashboardSearchItemType } from '../../types'; |
||||
|
||||
import { RootFolderView } from './RootFolderView'; |
||||
|
||||
jest.mock('@grafana/runtime', () => ({ |
||||
...jest.requireActual('@grafana/runtime'), |
||||
getBackendSrv: () => ({ |
||||
get: jest.fn().mockResolvedValue(['foo']), |
||||
}), |
||||
})); |
||||
|
||||
describe('RootFolderView', () => { |
||||
let grafanaSearcherSpy: jest.SpyInstance; |
||||
const mockOnTagSelected = jest.fn(); |
||||
const mockSelectionToggle = jest.fn(); |
||||
const mockSelection = jest.fn(); |
||||
|
||||
const folderData: DataFrame = { |
||||
fields: [ |
||||
{ |
||||
name: 'kind', |
||||
type: FieldType.string, |
||||
config: {}, |
||||
values: [DashboardSearchItemType.DashFolder], |
||||
}, |
||||
{ name: 'name', type: FieldType.string, config: {}, values: ['My folder 1'] }, |
||||
{ name: 'uid', type: FieldType.string, config: {}, values: ['my-folder-1'] }, |
||||
{ name: 'url', type: FieldType.string, config: {}, values: ['/my-folder-1'] }, |
||||
], |
||||
length: 1, |
||||
}; |
||||
|
||||
const mockSearchResult: QueryResponse = { |
||||
isItemLoaded: jest.fn(), |
||||
loadMoreItems: jest.fn(), |
||||
totalRows: folderData.length, |
||||
view: new DataFrameView<DashboardQueryResult>(folderData), |
||||
}; |
||||
|
||||
let contextSrv: ContextSrv; |
||||
|
||||
beforeAll(() => { |
||||
contextSrv = new ContextSrv(); |
||||
setContextSrv(contextSrv); |
||||
grafanaSearcherSpy = jest.spyOn(getGrafanaSearcher(), 'search').mockResolvedValue(mockSearchResult); |
||||
}); |
||||
|
||||
// need to make sure we clear localStorage
|
||||
// otherwise tests can interfere with each other and the starting expanded state of the component
|
||||
afterEach(() => { |
||||
window.localStorage.clear(); |
||||
}); |
||||
|
||||
it('shows a spinner whilst the results are loading', async () => { |
||||
// mock the query promise so we can resolve manually
|
||||
let promiseResolver: (arg0: QueryResponse) => void; |
||||
const promise = new Promise((resolve) => { |
||||
promiseResolver = resolve; |
||||
}); |
||||
grafanaSearcherSpy.mockImplementationOnce(() => promise); |
||||
|
||||
render( |
||||
<RootFolderView |
||||
onTagSelected={mockOnTagSelected} |
||||
selection={mockSelection} |
||||
selectionToggle={mockSelectionToggle} |
||||
/> |
||||
); |
||||
expect(await screen.findByTestId('Spinner')).toBeInTheDocument(); |
||||
|
||||
// resolve the promise
|
||||
await act(async () => { |
||||
promiseResolver(mockSearchResult); |
||||
}); |
||||
|
||||
expect(screen.queryByTestId('Spinner')).not.toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('does not show the starred items if not signed in', async () => { |
||||
contextSrv.isSignedIn = false; |
||||
render( |
||||
<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(); |
||||
}); |
||||
|
||||
it('shows the starred items if signed in', async () => { |
||||
contextSrv.isSignedIn = true; |
||||
render( |
||||
<RootFolderView |
||||
onTagSelected={mockOnTagSelected} |
||||
selection={mockSelection} |
||||
selectionToggle={mockSelectionToggle} |
||||
/> |
||||
); |
||||
expect(await screen.findByRole('button', { name: 'Starred' })).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('does not show the recent items if no dashboards have been opened recently', async () => { |
||||
jest.spyOn(impressionSrv, 'getDashboardOpened').mockResolvedValue([]); |
||||
render( |
||||
<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(); |
||||
}); |
||||
|
||||
it('shows the recent items if any dashboards have recently been opened', async () => { |
||||
jest.spyOn(impressionSrv, 'getDashboardOpened').mockResolvedValue(['7MeksYbmk']); |
||||
render( |
||||
<RootFolderView |
||||
onTagSelected={mockOnTagSelected} |
||||
selection={mockSelection} |
||||
selectionToggle={mockSelectionToggle} |
||||
/> |
||||
); |
||||
expect(await screen.findByRole('button', { name: 'Recent' })).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('shows the general folder by default', async () => { |
||||
render( |
||||
<RootFolderView |
||||
onTagSelected={mockOnTagSelected} |
||||
selection={mockSelection} |
||||
selectionToggle={mockSelectionToggle} |
||||
/> |
||||
); |
||||
expect(await screen.findByRole('button', { name: 'General' })).toBeInTheDocument(); |
||||
}); |
||||
|
||||
describe('when hidePseudoFolders is set', () => { |
||||
it('does not show the starred items even if signed in', async () => { |
||||
contextSrv.isSignedIn = true; |
||||
render( |
||||
<RootFolderView |
||||
hidePseudoFolders |
||||
onTagSelected={mockOnTagSelected} |
||||
selection={mockSelection} |
||||
selectionToggle={mockSelectionToggle} |
||||
/> |
||||
); |
||||
expect(await screen.findByRole('button', { name: 'General' })).toBeInTheDocument(); |
||||
expect(screen.queryByRole('button', { name: 'Starred' })).not.toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('does not show the recent items even if recent dashboards have been opened', async () => { |
||||
jest.spyOn(impressionSrv, 'getDashboardOpened').mockResolvedValue(['7MeksYbmk']); |
||||
render( |
||||
<RootFolderView |
||||
hidePseudoFolders |
||||
onTagSelected={mockOnTagSelected} |
||||
selection={mockSelection} |
||||
selectionToggle={mockSelectionToggle} |
||||
/> |
||||
); |
||||
expect(await screen.findByRole('button', { name: 'General' })).toBeInTheDocument(); |
||||
expect(screen.queryByRole('button', { name: 'Recent' })).not.toBeInTheDocument(); |
||||
}); |
||||
}); |
||||
|
||||
it('shows an error state if any of the calls reject for a specific reason', async () => { |
||||
// reject with a specific Error object
|
||||
grafanaSearcherSpy.mockRejectedValueOnce(new Error('Uh oh spagghettios!')); |
||||
render( |
||||
<RootFolderView |
||||
onTagSelected={mockOnTagSelected} |
||||
selection={mockSelection} |
||||
selectionToggle={mockSelectionToggle} |
||||
/> |
||||
); |
||||
expect(await screen.findByRole('alert', { name: 'Uh oh spagghettios!' })).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('shows a general error state if any of the calls reject', async () => { |
||||
// reject with nothing
|
||||
grafanaSearcherSpy.mockRejectedValueOnce(null); |
||||
render( |
||||
<RootFolderView |
||||
onTagSelected={mockOnTagSelected} |
||||
selection={mockSelection} |
||||
selectionToggle={mockSelectionToggle} |
||||
/> |
||||
); |
||||
expect(await screen.findByRole('alert', { name: 'Something went wrong' })).toBeInTheDocument(); |
||||
}); |
||||
}); |
@ -1,131 +0,0 @@ |
||||
import { css } from '@emotion/css'; |
||||
import React from 'react'; |
||||
import { useAsync } from 'react-use'; |
||||
|
||||
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 { 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 RootFolderView = ({ |
||||
selection, |
||||
selectionToggle, |
||||
onTagSelected, |
||||
tags, |
||||
hidePseudoFolders, |
||||
onClickItem, |
||||
}: Props) => { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
const results = useAsync(async () => { |
||||
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.unshift({ title: 'Starred', icon: 'star', kind: 'folder', uid: '__starred', itemsUIDs: stars }); |
||||
} |
||||
} |
||||
} |
||||
|
||||
return folders; |
||||
}, []); |
||||
|
||||
const renderResults = () => { |
||||
if (results.loading) { |
||||
return <Spinner className={styles.spinner} />; |
||||
} else if (!results.value) { |
||||
return <Alert className={styles.error} title={results.error ? results.error.message : 'Something went wrong'} />; |
||||
} else { |
||||
return results.value.map((section) => ( |
||||
<div data-testid={selectors.components.Search.sectionV2} className={styles.section} key={section.title}> |
||||
{section.title && ( |
||||
<FolderSection |
||||
selection={selection} |
||||
selectionToggle={selectionToggle} |
||||
onTagSelected={onTagSelected} |
||||
section={section} |
||||
tags={tags} |
||||
onClickItem={onClickItem} |
||||
/> |
||||
)} |
||||
</div> |
||||
)); |
||||
} |
||||
}; |
||||
|
||||
return <div className={styles.wrapper}>{renderResults()}</div>; |
||||
}; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
wrapper: css` |
||||
display: flex; |
||||
flex-direction: column; |
||||
overflow: auto; |
||||
|
||||
> ul { |
||||
list-style: none; |
||||
} |
||||
|
||||
border: solid 1px ${theme.v1.colors.border2}; |
||||
`,
|
||||
section: css` |
||||
display: flex; |
||||
flex-direction: column; |
||||
background: ${theme.v1.colors.panelBg}; |
||||
|
||||
&:not(:last-child) { |
||||
border-bottom: solid 1px ${theme.v1.colors.border2}; |
||||
} |
||||
`,
|
||||
spinner: css` |
||||
align-items: center; |
||||
display: flex; |
||||
justify-content: center; |
||||
min-height: 100px; |
||||
`,
|
||||
error: css` |
||||
margin: ${theme.spacing(4)} auto; |
||||
`,
|
||||
}; |
||||
}; |
@ -1,131 +0,0 @@ |
||||
import { render, screen } from '@testing-library/react'; |
||||
import React from 'react'; |
||||
import { Subject } from 'rxjs'; |
||||
|
||||
import { DataFrame, DataFrameView, FieldType } from '@grafana/data'; |
||||
|
||||
import { DashboardQueryResult, getGrafanaSearcher, QueryResponse } from '../../service'; |
||||
import { DashboardSearchItemType } from '../../types'; |
||||
|
||||
import { SearchResultsCards } from './SearchResultsCards'; |
||||
|
||||
describe('SearchResultsCards', () => { |
||||
const mockOnTagSelected = jest.fn(); |
||||
const mockClearSelection = jest.fn(); |
||||
const mockSelectionToggle = jest.fn(); |
||||
const mockSelection = jest.fn(); |
||||
const mockKeyboardEvents = new Subject<React.KeyboardEvent>(); |
||||
|
||||
describe('when there is data', () => { |
||||
const searchData: DataFrame = { |
||||
fields: [ |
||||
{ name: 'kind', type: FieldType.string, config: {}, values: [DashboardSearchItemType.DashDB] }, |
||||
{ name: 'uid', type: FieldType.string, config: {}, values: ['my-dashboard-1'] }, |
||||
{ name: 'name', type: FieldType.string, config: {}, values: ['My dashboard 1'] }, |
||||
{ name: 'panel_type', type: FieldType.string, config: {}, values: [''] }, |
||||
{ name: 'url', type: FieldType.string, config: {}, values: ['/my-dashboard-1'] }, |
||||
{ name: 'tags', type: FieldType.other, config: {}, values: [['foo', 'bar']] }, |
||||
{ name: 'ds_uid', type: FieldType.other, config: {}, values: [''] }, |
||||
{ name: 'location', type: FieldType.string, config: {}, values: ['folder0/my-dashboard-1'] }, |
||||
], |
||||
meta: { |
||||
custom: { |
||||
locationInfo: { |
||||
folder0: { name: 'Folder 0', uid: 'f0' }, |
||||
}, |
||||
}, |
||||
}, |
||||
length: 1, |
||||
}; |
||||
|
||||
const mockSearchResult: QueryResponse = { |
||||
isItemLoaded: () => true, |
||||
loadMoreItems: () => Promise.resolve(), |
||||
totalRows: searchData.length, |
||||
view: new DataFrameView<DashboardQueryResult>(searchData), |
||||
}; |
||||
|
||||
beforeAll(() => { |
||||
jest.spyOn(getGrafanaSearcher(), 'search').mockResolvedValue(mockSearchResult); |
||||
}); |
||||
|
||||
it('shows the list with the correct accessible label', () => { |
||||
render( |
||||
<SearchResultsCards |
||||
keyboardEvents={mockKeyboardEvents} |
||||
response={mockSearchResult} |
||||
onTagSelected={mockOnTagSelected} |
||||
selection={mockSelection} |
||||
selectionToggle={mockSelectionToggle} |
||||
clearSelection={mockClearSelection} |
||||
height={1000} |
||||
width={1000} |
||||
/> |
||||
); |
||||
expect(screen.getByRole('list', { name: 'Search results list' })).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('displays the data correctly in the table', () => { |
||||
render( |
||||
<SearchResultsCards |
||||
keyboardEvents={mockKeyboardEvents} |
||||
response={mockSearchResult} |
||||
onTagSelected={mockOnTagSelected} |
||||
selection={mockSelection} |
||||
selectionToggle={mockSelectionToggle} |
||||
clearSelection={mockClearSelection} |
||||
height={1000} |
||||
width={1000} |
||||
/> |
||||
); |
||||
|
||||
const rows = screen.getAllByRole('row'); |
||||
expect(rows).toHaveLength(searchData.length); |
||||
expect(screen.getByText('My dashboard 1')).toBeInTheDocument(); |
||||
expect(screen.getByText('foo')).toBeInTheDocument(); |
||||
expect(screen.getByText('bar')).toBeInTheDocument(); |
||||
}); |
||||
}); |
||||
|
||||
describe('when there is no data', () => { |
||||
const emptySearchData: DataFrame = { |
||||
fields: [ |
||||
{ name: 'kind', type: FieldType.string, config: {}, values: [] }, |
||||
{ name: 'name', type: FieldType.string, config: {}, values: [] }, |
||||
{ name: 'uid', type: FieldType.string, config: {}, values: [] }, |
||||
{ name: 'url', type: FieldType.string, config: {}, values: [] }, |
||||
{ name: 'tags', type: FieldType.other, config: {}, values: [] }, |
||||
{ name: 'location', type: FieldType.string, config: {}, values: [] }, |
||||
], |
||||
length: 0, |
||||
}; |
||||
|
||||
const mockEmptySearchResult: QueryResponse = { |
||||
isItemLoaded: jest.fn(), |
||||
loadMoreItems: jest.fn(), |
||||
totalRows: emptySearchData.length, |
||||
view: new DataFrameView<DashboardQueryResult>(emptySearchData), |
||||
}; |
||||
|
||||
beforeAll(() => { |
||||
jest.spyOn(getGrafanaSearcher(), 'search').mockResolvedValue(mockEmptySearchResult); |
||||
}); |
||||
|
||||
it('shows a "No data" message', () => { |
||||
render( |
||||
<SearchResultsCards |
||||
keyboardEvents={mockKeyboardEvents} |
||||
response={mockEmptySearchResult} |
||||
onTagSelected={mockOnTagSelected} |
||||
selection={mockSelection} |
||||
selectionToggle={mockSelectionToggle} |
||||
clearSelection={mockClearSelection} |
||||
height={1000} |
||||
width={1000} |
||||
/> |
||||
); |
||||
expect(screen.queryByRole('list', { name: 'Search results list' })).not.toBeInTheDocument(); |
||||
expect(screen.getByText('No data')).toBeInTheDocument(); |
||||
}); |
||||
}); |
||||
}); |
@ -1,125 +0,0 @@ |
||||
/* eslint-disable react/jsx-no-undef */ |
||||
import { css } from '@emotion/css'; |
||||
import React, { useEffect, useRef, useCallback, useState, CSSProperties } from 'react'; |
||||
import { FixedSizeList } from 'react-window'; |
||||
import InfiniteLoader from 'react-window-infinite-loader'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { useStyles2 } from '@grafana/ui'; |
||||
|
||||
import { SearchItem } from '../../components/SearchItem'; |
||||
import { useSearchKeyboardNavigation } from '../../hooks/useSearchKeyboardSelection'; |
||||
import { queryResultToViewItem } from '../../service/utils'; |
||||
|
||||
import { SearchResultsProps } from './SearchResultsTable'; |
||||
|
||||
export const SearchResultsCards = React.memo( |
||||
({ |
||||
response, |
||||
width, |
||||
height, |
||||
selection, |
||||
selectionToggle, |
||||
onTagSelected, |
||||
keyboardEvents, |
||||
onClickItem, |
||||
}: SearchResultsProps) => { |
||||
const styles = useStyles2(getStyles); |
||||
const infiniteLoaderRef = useRef<InfiniteLoader>(null); |
||||
const [listEl, setListEl] = useState<FixedSizeList | null>(null); |
||||
const highlightIndex = useSearchKeyboardNavigation(keyboardEvents, 0, response); |
||||
|
||||
// Scroll to the top and clear loader cache when the query results change
|
||||
useEffect(() => { |
||||
if (infiniteLoaderRef.current) { |
||||
infiniteLoaderRef.current.resetloadMoreItemsCache(); |
||||
} |
||||
if (listEl) { |
||||
listEl.scrollTo(0); |
||||
} |
||||
}, [response, listEl]); |
||||
|
||||
const RenderRow = useCallback( |
||||
({ index: rowIndex, style }: { index: number; style: CSSProperties }) => { |
||||
let className = ''; |
||||
if (rowIndex === highlightIndex.y) { |
||||
className += ' ' + styles.selectedRow; |
||||
} |
||||
|
||||
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"> |
||||
<SearchItem |
||||
item={searchItem} |
||||
onTagSelected={onTagSelected} |
||||
onToggleChecked={(item) => { |
||||
if (selectionToggle) { |
||||
selectionToggle('dashboard', item.uid!); |
||||
} |
||||
}} |
||||
editable={Boolean(selection != null)} |
||||
onClickItem={onClickItem} |
||||
isSelected={isSelected} |
||||
/> |
||||
</div> |
||||
); |
||||
}, |
||||
[response.view, highlightIndex, styles, onTagSelected, selection, selectionToggle, onClickItem] |
||||
); |
||||
|
||||
if (!response.totalRows) { |
||||
return ( |
||||
<div className={styles.noData} style={{ width }}> |
||||
No data |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
return ( |
||||
<div aria-label="Search results list" style={{ width }} role="list"> |
||||
<InfiniteLoader |
||||
ref={infiniteLoaderRef} |
||||
isItemLoaded={response.isItemLoaded} |
||||
itemCount={response.totalRows} |
||||
loadMoreItems={response.loadMoreItems} |
||||
> |
||||
{({ onItemsRendered, ref }) => ( |
||||
<FixedSizeList |
||||
ref={(innerRef) => { |
||||
ref(innerRef); |
||||
setListEl(innerRef); |
||||
}} |
||||
onItemsRendered={onItemsRendered} |
||||
height={height} |
||||
itemCount={response.totalRows} |
||||
itemSize={72} |
||||
width="100%" |
||||
style={{ overflow: 'hidden auto' }} |
||||
> |
||||
{RenderRow} |
||||
</FixedSizeList> |
||||
)} |
||||
</InfiniteLoader> |
||||
</div> |
||||
); |
||||
} |
||||
); |
||||
SearchResultsCards.displayName = 'SearchResultsCards'; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
noData: css` |
||||
display: flex; |
||||
flex-direction: column; |
||||
align-items: center; |
||||
justify-content: center; |
||||
height: 100%; |
||||
`,
|
||||
selectedRow: css` |
||||
border-left: 3px solid ${theme.colors.primary.border}; |
||||
`,
|
||||
}; |
||||
}; |
@ -1,170 +0,0 @@ |
||||
import { render, screen, waitFor } from '@testing-library/react'; |
||||
import userEvent from '@testing-library/user-event'; |
||||
import React from 'react'; |
||||
import { Provider } from 'react-redux'; |
||||
import configureMockStore from 'redux-mock-store'; |
||||
import { Observable } from 'rxjs'; |
||||
|
||||
import { DataFrame, DataFrameView, FieldType } from '@grafana/data'; |
||||
import { config } from '@grafana/runtime'; |
||||
|
||||
import { DashboardQueryResult, getGrafanaSearcher, QueryResponse } from '../../service'; |
||||
import { getSearchStateManager, initialState } from '../../state/SearchStateManager'; |
||||
import { DashboardSearchItemType, SearchLayout, SearchState } from '../../types'; |
||||
|
||||
import { SearchView, SearchViewProps } from './SearchView'; |
||||
|
||||
jest.mock('@grafana/runtime', () => { |
||||
const originalModule = jest.requireActual('@grafana/runtime'); |
||||
return { |
||||
...originalModule, |
||||
reportInteraction: jest.fn(), |
||||
}; |
||||
}); |
||||
|
||||
const stateManager = getSearchStateManager(); |
||||
|
||||
const setup = (propOverrides?: Partial<SearchViewProps>, stateOverrides?: Partial<SearchState>) => { |
||||
const props: SearchViewProps = { |
||||
showManage: false, |
||||
keyboardEvents: {} as Observable<React.KeyboardEvent>, |
||||
...propOverrides, |
||||
}; |
||||
|
||||
stateManager.setState({ ...initialState, ...stateOverrides }); |
||||
|
||||
const mockStore = configureMockStore(); |
||||
const store = mockStore({ searchQuery: { ...initialState } }); |
||||
|
||||
render( |
||||
<Provider store={store}> |
||||
<SearchView {...props} /> |
||||
</Provider> |
||||
); |
||||
}; |
||||
|
||||
describe('SearchView', () => { |
||||
const folderData: DataFrame = { |
||||
fields: [ |
||||
{ |
||||
name: 'kind', |
||||
type: FieldType.string, |
||||
config: {}, |
||||
values: [DashboardSearchItemType.DashFolder], |
||||
}, |
||||
{ name: 'name', type: FieldType.string, config: {}, values: ['My folder 1'] }, |
||||
{ name: 'uid', type: FieldType.string, config: {}, values: ['my-folder-1'] }, |
||||
{ name: 'url', type: FieldType.string, config: {}, values: ['/my-folder-1'] }, |
||||
], |
||||
length: 1, |
||||
}; |
||||
|
||||
const mockSearchResult: QueryResponse = { |
||||
isItemLoaded: jest.fn(), |
||||
loadMoreItems: jest.fn(), |
||||
totalRows: folderData.length, |
||||
view: new DataFrameView<DashboardQueryResult>(folderData), |
||||
}; |
||||
|
||||
beforeAll(() => { |
||||
jest.spyOn(getGrafanaSearcher(), 'search').mockResolvedValue(mockSearchResult); |
||||
}); |
||||
|
||||
beforeEach(() => { |
||||
config.featureToggles.panelTitleSearch = false; |
||||
}); |
||||
|
||||
it('does not show checkboxes or manage actions if showManage is false', async () => { |
||||
setup(); |
||||
await waitFor(() => expect(screen.queryAllByRole('checkbox')).toHaveLength(0)); |
||||
expect(screen.queryByTestId('manage-actions')).not.toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('shows checkboxes if showManage is true', async () => { |
||||
setup({ showManage: true }); |
||||
await waitFor(() => expect(screen.queryAllByRole('checkbox')).toHaveLength(2)); |
||||
}); |
||||
|
||||
it('shows the manage actions if show manage is true and the user clicked a checkbox', async () => { |
||||
setup({ showManage: true }); |
||||
await waitFor(() => userEvent.click(screen.getAllByRole('checkbox')[0])); |
||||
|
||||
expect(screen.queryByTestId('manage-actions')).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('shows an empty state if no data returned', async () => { |
||||
jest.spyOn(getGrafanaSearcher(), 'search').mockResolvedValue({ |
||||
...mockSearchResult, |
||||
totalRows: 0, |
||||
view: new DataFrameView<DashboardQueryResult>({ fields: [], length: 0 }), |
||||
}); |
||||
|
||||
setup(undefined, { query: 'asdfasdfasdf' }); |
||||
|
||||
await waitFor(() => expect(screen.queryByText('No results found for your query.')).toBeInTheDocument()); |
||||
expect(screen.getByRole('button', { name: 'Clear search and filters' })).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('shows an empty state if no starred dashboard returned', async () => { |
||||
jest.spyOn(getGrafanaSearcher(), 'search').mockResolvedValue({ |
||||
...mockSearchResult, |
||||
totalRows: 0, |
||||
view: new DataFrameView<DashboardQueryResult>({ fields: [], length: 0 }), |
||||
}); |
||||
|
||||
setup(undefined, { starred: true }); |
||||
|
||||
await waitFor(() => expect(screen.queryByText('No results found for your query.')).toBeInTheDocument()); |
||||
expect(screen.getByRole('button', { name: 'Clear search and filters' })).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('shows empty folder cta for empty folder', async () => { |
||||
jest.spyOn(getGrafanaSearcher(), 'search').mockResolvedValue({ |
||||
...mockSearchResult, |
||||
totalRows: 0, |
||||
view: new DataFrameView<DashboardQueryResult>({ fields: [], length: 0 }), |
||||
}); |
||||
|
||||
setup( |
||||
{ |
||||
folderDTO: { |
||||
id: 1, |
||||
uid: 'abc', |
||||
title: 'morning coffee', |
||||
url: '/morningcoffee', |
||||
version: 1, |
||||
canSave: true, |
||||
canEdit: true, |
||||
canAdmin: true, |
||||
canDelete: true, |
||||
created: '', |
||||
createdBy: '', |
||||
hasAcl: false, |
||||
updated: '', |
||||
updatedBy: '', |
||||
}, |
||||
}, |
||||
undefined |
||||
); |
||||
|
||||
await waitFor(() => expect(screen.queryByText("This folder doesn't have any dashboards yet")).toBeInTheDocument()); |
||||
}); |
||||
|
||||
describe('include panels', () => { |
||||
it('should be enabled when layout is list', async () => { |
||||
config.featureToggles.panelTitleSearch = true; |
||||
setup({}, { layout: SearchLayout.List }); |
||||
|
||||
await waitFor(() => expect(screen.getByLabelText(/include panels/i)).toBeInTheDocument()); |
||||
expect(screen.getByTestId('include-panels')).toBeEnabled(); |
||||
}); |
||||
|
||||
it('should be disabled when layout is folder', async () => { |
||||
config.featureToggles.panelTitleSearch = true; |
||||
setup({}, { layout: SearchLayout.Folders }); |
||||
|
||||
await waitFor(() => expect(screen.getByLabelText(/include panels/i)).toBeInTheDocument()); |
||||
expect(screen.getByTestId('include-panels')).toBeDisabled(); |
||||
}); |
||||
}); |
||||
}); |
@ -1,221 +0,0 @@ |
||||
import { css } from '@emotion/css'; |
||||
import React, { useCallback, useState } from 'react'; |
||||
import { useDebounce } from 'react-use'; |
||||
import AutoSizer from 'react-virtualized-auto-sizer'; |
||||
import { Observable } from 'rxjs'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { useStyles2, Spinner, Button } from '@grafana/ui'; |
||||
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; |
||||
import { Trans } from 'app/core/internationalization'; |
||||
import { newBrowseDashboardsEnabled } from 'app/features/browse-dashboards/featureFlag'; |
||||
import { FolderDTO } from 'app/types'; |
||||
|
||||
import { getGrafanaSearcher } from '../../service'; |
||||
import { getSearchStateManager } from '../../state/SearchStateManager'; |
||||
import { SearchLayout, DashboardViewItem } from '../../types'; |
||||
import { newSearchSelection, updateSearchSelection } from '../selection'; |
||||
|
||||
import { ActionRow, getValidQueryLayout } from './ActionRow'; |
||||
import { FolderSection } from './FolderSection'; |
||||
import { ManageActions } from './ManageActions'; |
||||
import { RootFolderView } from './RootFolderView'; |
||||
import { SearchResultsCards } from './SearchResultsCards'; |
||||
import { SearchResultsTable, SearchResultsProps } from './SearchResultsTable'; |
||||
|
||||
export type SearchViewProps = { |
||||
showManage: boolean; |
||||
folderDTO?: FolderDTO; |
||||
hidePseudoFolders?: boolean; // Recent + starred
|
||||
keyboardEvents: Observable<React.KeyboardEvent>; |
||||
}; |
||||
|
||||
export const SearchView = ({ showManage, folderDTO, hidePseudoFolders, keyboardEvents }: SearchViewProps) => { |
||||
const styles = useStyles2(getStyles); |
||||
const stateManager = getSearchStateManager(); // State is initialized from URL by parent component
|
||||
const state = stateManager.useState(); |
||||
|
||||
const [searchSelection, setSearchSelection] = useState(newSearchSelection()); |
||||
const layout = getValidQueryLayout(state); |
||||
const isFolders = layout === SearchLayout.Folders; |
||||
|
||||
const [listKey, setListKey] = useState(Date.now()); |
||||
|
||||
// Search usage reporting
|
||||
useDebounce(stateManager.onReportSearchUsage, 1000, []); |
||||
|
||||
const clearSelection = useCallback(() => { |
||||
searchSelection.items.clear(); |
||||
setSearchSelection({ ...searchSelection }); |
||||
}, [searchSelection]); |
||||
|
||||
const toggleSelection = useCallback( |
||||
(kind: string, uid: string) => { |
||||
const current = searchSelection.isSelected(kind, uid); |
||||
setSearchSelection(updateSearchSelection(searchSelection, !current, kind, [uid])); |
||||
}, |
||||
[searchSelection] |
||||
); |
||||
|
||||
// function to update items when dashboards or folders are moved or deleted
|
||||
const onChangeItemsList = async () => { |
||||
// clean up search selection
|
||||
clearSelection(); |
||||
setListKey(Date.now()); |
||||
// trigger again the search to the backend
|
||||
stateManager.onQueryChange(state.query); |
||||
}; |
||||
|
||||
const renderResults = () => { |
||||
const value = state.result; |
||||
|
||||
if ((!value || !value.totalRows) && !isFolders) { |
||||
if (state.loading && !value) { |
||||
return <Spinner />; |
||||
} |
||||
|
||||
return ( |
||||
<div className={styles.noResults}> |
||||
<div> |
||||
<Trans i18nKey="search-view.no-results.text">No results found for your query.</Trans> |
||||
</div> |
||||
<br /> |
||||
<Button variant="secondary" onClick={stateManager.onClearSearchAndFilters}> |
||||
<Trans i18nKey="search-view.no-results.clear">Clear search and filters</Trans> |
||||
</Button> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
const selection = showManage ? searchSelection.isSelected : undefined; |
||||
|
||||
if (layout === SearchLayout.Folders) { |
||||
if (folderDTO) { |
||||
return ( |
||||
<FolderSection |
||||
section={sectionForFolderView(folderDTO)} |
||||
selection={selection} |
||||
selectionToggle={toggleSelection} |
||||
onTagSelected={stateManager.onAddTag} |
||||
renderStandaloneBody={true} |
||||
tags={state.tag} |
||||
key={listKey} |
||||
onClickItem={stateManager.onSearchItemClicked} |
||||
/> |
||||
); |
||||
} |
||||
return ( |
||||
<RootFolderView |
||||
key={listKey} |
||||
selection={selection} |
||||
selectionToggle={toggleSelection} |
||||
tags={state.tag} |
||||
onTagSelected={stateManager.onAddTag} |
||||
hidePseudoFolders={hidePseudoFolders} |
||||
onClickItem={stateManager.onSearchItemClicked} |
||||
/> |
||||
); |
||||
} |
||||
|
||||
return ( |
||||
<div style={{ height: '100%', width: '100%' }}> |
||||
<AutoSizer> |
||||
{({ width, height }) => { |
||||
const props: SearchResultsProps = { |
||||
response: value!, |
||||
selection, |
||||
selectionToggle: toggleSelection, |
||||
clearSelection, |
||||
width: width, |
||||
height: height, |
||||
onTagSelected: stateManager.onAddTag, |
||||
keyboardEvents, |
||||
onDatasourceChange: state.datasource ? stateManager.onDatasourceChange : undefined, |
||||
onClickItem: stateManager.onSearchItemClicked, |
||||
}; |
||||
|
||||
if (width < 800) { |
||||
return <SearchResultsCards {...props} />; |
||||
} |
||||
|
||||
return <SearchResultsTable {...props} />; |
||||
}} |
||||
</AutoSizer> |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
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.
|
||||
!newBrowseDashboardsEnabled() && |
||||
!state.loading && |
||||
!state.result?.totalRows && |
||||
!stateManager.hasSearchFilters() |
||||
) { |
||||
return ( |
||||
<EmptyListCTA |
||||
title="This folder doesn't have any dashboards yet" |
||||
buttonIcon="plus" |
||||
buttonTitle="Create Dashboard" |
||||
buttonLink={`dashboard/new?folderUid=${folderDTO.uid}`} |
||||
proTip="Add/move dashboards to your folder at ->" |
||||
proTipLink="dashboards" |
||||
proTipLinkTitle="Manage dashboards" |
||||
proTipTarget="" |
||||
/> |
||||
); |
||||
} |
||||
|
||||
return ( |
||||
<> |
||||
{Boolean(searchSelection.items.size > 0) ? ( |
||||
<ManageActions items={searchSelection.items} onChange={onChangeItemsList} clearSelection={clearSelection} /> |
||||
) : ( |
||||
<ActionRow |
||||
onLayoutChange={stateManager.onLayoutChange} |
||||
showStarredFilter={hidePseudoFolders} |
||||
onStarredFilterChange={!hidePseudoFolders ? undefined : stateManager.onStarredFilterChange} |
||||
onSortChange={stateManager.onSortChange} |
||||
onTagFilterChange={stateManager.onTagFilterChange} |
||||
getTagOptions={stateManager.getTagOptions} |
||||
getSortOptions={getGrafanaSearcher().getSortOptions} |
||||
sortPlaceholder={getGrafanaSearcher().sortPlaceholder} |
||||
onDatasourceChange={stateManager.onDatasourceChange} |
||||
onPanelTypeChange={stateManager.onPanelTypeChange} |
||||
state={state} |
||||
includePanels={state.includePanels!} |
||||
onSetIncludePanels={stateManager.onSetIncludePanels} |
||||
/> |
||||
)} |
||||
|
||||
{renderResults()} |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
searchInput: css` |
||||
margin-bottom: 6px; |
||||
min-height: ${theme.spacing(4)}; |
||||
`,
|
||||
unsupported: css` |
||||
padding: 10px; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
height: 100%; |
||||
font-size: 18px; |
||||
`,
|
||||
noResults: css` |
||||
padding: ${theme.v1.spacing.md}; |
||||
background: ${theme.v1.colors.bg2}; |
||||
font-style: italic; |
||||
margin-top: ${theme.v1.spacing.md}; |
||||
`,
|
||||
}); |
||||
|
||||
function sectionForFolderView(folderDTO: FolderDTO): DashboardViewItem { |
||||
return { uid: folderDTO.uid, kind: 'folder', title: folderDTO.title }; |
||||
} |
Loading…
Reference in new issue