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