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 handling
This commit is contained in:
Ashley Harrison 2023-11-22 15:22:00 +00:00 committed by GitHub
parent 40c8e2fc75
commit 4290ed3d86
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
60 changed files with 92 additions and 4117 deletions

View File

@ -4360,10 +4360,7 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "13"],
[0, 0, 0, "Unexpected any. Specify a different type.", "14"],
[0, 0, 0, "Unexpected any. Specify a different type.", "15"],
[0, 0, 0, "Unexpected any. Specify a different type.", "16"],
[0, 0, 0, "Unexpected any. Specify a different type.", "17"],
[0, 0, 0, "Unexpected any. Specify a different type.", "18"],
[0, 0, 0, "Unexpected any. Specify a different type.", "19"]
[0, 0, 0, "Unexpected any. Specify a different type.", "16"]
],
"public/app/features/manage-dashboards/state/reducers.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
@ -4660,81 +4657,10 @@ exports[`better eslint`] = {
"public/app/features/sandbox/TestStuffPage.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/search/components/DashboardListPage.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"]
],
"public/app/features/search/components/ManageDashboardsNew.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"]
],
"public/app/features/search/components/SearchCard.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"],
[0, 0, 0, "Styles should be written using objects.", "5"],
[0, 0, 0, "Styles should be written using objects.", "6"],
[0, 0, 0, "Styles should be written using objects.", "7"],
[0, 0, 0, "Styles should be written using objects.", "8"],
[0, 0, 0, "Styles should be written using objects.", "9"],
[0, 0, 0, "Styles should be written using objects.", "10"],
[0, 0, 0, "Styles should be written using objects.", "11"]
],
"public/app/features/search/components/SearchCardExpanded.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"],
[0, 0, 0, "Styles should be written using objects.", "5"],
[0, 0, 0, "Styles should be written using objects.", "6"],
[0, 0, 0, "Styles should be written using objects.", "7"],
[0, 0, 0, "Styles should be written using objects.", "8"],
[0, 0, 0, "Styles should be written using objects.", "9"],
[0, 0, 0, "Styles should be written using objects.", "10"]
],
"public/app/features/search/components/SearchItem.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"]
],
"public/app/features/search/page/components/ActionRow.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"]
],
"public/app/features/search/page/components/ConfirmDeleteModal.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"]
],
"public/app/features/search/page/components/FolderSection.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"],
[0, 0, 0, "Styles should be written using objects.", "5"],
[0, 0, 0, "Styles should be written using objects.", "6"],
[0, 0, 0, "Styles should be written using objects.", "7"],
[0, 0, 0, "Styles should be written using objects.", "8"]
],
"public/app/features/search/page/components/MoveToFolderModal.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"]
],
"public/app/features/search/page/components/RootFolderView.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"]
],
"public/app/features/search/page/components/SearchResultsCards.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"]
],
"public/app/features/search/page/components/SearchResultsTable.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
@ -4750,11 +4676,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Styles should be written using objects.", "11"],
[0, 0, 0, "Styles should be written using objects.", "12"]
],
"public/app/features/search/page/components/SearchView.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"]
],
"public/app/features/search/page/components/columns.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],

View File

@ -49,7 +49,6 @@ Some features are enabled by default. You can disable these feature by setting t
| `recordedQueriesMulti` | Enables writing multiple items from a single query within Recorded Queries | Yes |
| `transformationsRedesign` | Enables the transformations redesign | Yes |
| `prometheusConfigOverhaulAuth` | Update the Prometheus configuration page with the new auth component | Yes |
| `newBrowseDashboards` | New browse/manage dashboards UI | Yes |
| `alertingInsights` | Show the new alerting insights landing page | Yes |
| `cloudWatchWildCardDimensionValues` | Fetches dimension values from CloudWatch to correctly label wildcard dimensions | Yes |

View File

@ -116,7 +116,6 @@ export interface FeatureToggles {
angularDeprecationUI?: boolean;
dashgpt?: boolean;
reportingRetries?: boolean;
newBrowseDashboards?: boolean;
sseGroupByDatasource?: boolean;
requestInstrumentationStatusSource?: boolean;
libraryPanelRBAC?: boolean;

View File

@ -733,15 +733,6 @@ var (
Owner: grafanaSharingSquad,
RequiresRestart: true,
},
{
Name: "newBrowseDashboards",
Description: "New browse/manage dashboards UI",
Stage: FeatureStageGeneralAvailability,
Owner: grafanaFrontendPlatformSquad,
FrontendOnly: true,
Expression: "true", // on by default
AllowSelfServe: truePtr,
},
{
Name: "sseGroupByDatasource",
Description: "Send query to the same datasource in a single request when using server side expressions",

View File

@ -97,7 +97,6 @@ alertingNoDataErrorExecution,privatePreview,@grafana/alerting-squad,false,false,
angularDeprecationUI,experimental,@grafana/plugins-platform-backend,false,false,false,true
dashgpt,preview,@grafana/dashboards-squad,false,false,false,true
reportingRetries,preview,@grafana/sharing-squad,false,false,true,false
newBrowseDashboards,GA,@grafana/grafana-frontend-platform,false,false,false,true
sseGroupByDatasource,experimental,@grafana/observability-metrics,false,false,false,false
requestInstrumentationStatusSource,experimental,@grafana/plugins-platform-backend,false,false,false,false
libraryPanelRBAC,experimental,@grafana/dashboards-squad,false,false,true,false

1 Name Stage Owner requiresDevMode RequiresLicense RequiresRestart FrontendOnly
97 angularDeprecationUI experimental @grafana/plugins-platform-backend false false false true
98 dashgpt preview @grafana/dashboards-squad false false false true
99 reportingRetries preview @grafana/sharing-squad false false true false
newBrowseDashboards GA @grafana/grafana-frontend-platform false false false true
100 sseGroupByDatasource experimental @grafana/observability-metrics false false false false
101 requestInstrumentationStatusSource experimental @grafana/plugins-platform-backend false false false false
102 libraryPanelRBAC experimental @grafana/dashboards-squad false false true false

View File

@ -399,10 +399,6 @@ const (
// Enables rendering retries for the reporting feature
FlagReportingRetries = "reportingRetries"
// FlagNewBrowseDashboards
// New browse/manage dashboards UI
FlagNewBrowseDashboards = "newBrowseDashboards"
// FlagSseGroupByDatasource
// Send query to the same datasource in a single request when using server side expressions
FlagSseGroupByDatasource = "sseGroupByDatasource"

View File

@ -9,7 +9,6 @@ import { SlideDown } from 'app/core/components/Animations/SlideDown';
import { Trans, t } from 'app/core/internationalization';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { DescendantCount } from 'app/features/browse-dashboards/components/BrowseActions/DescendantCount';
import { newBrowseDashboardsEnabled } from 'app/features/browse-dashboards/featureFlag';
import { AddPermission } from './AddPermission';
import { PermissionList } from './PermissionList';
@ -156,7 +155,7 @@ export const Permissions = ({
<div>
{canSetPermissions && (
<>
{newBrowseDashboardsEnabled() && resource === 'folders' && (
{resource === 'folders' && (
<>
<Trans i18nKey="access-control.permissions.permissions-change-warning">
This will change permissions for this folder and all its descendants. In total, this will affect:

View File

@ -76,7 +76,7 @@ describe('OldFolderPicker', () => {
});
});
it('should show the General folder by default for editors', async () => {
it('should show the Dashboards root by default for editors', async () => {
jest
.spyOn(api, 'searchFolders')
.mockResolvedValue([
@ -94,10 +94,10 @@ describe('OldFolderPicker', () => {
const pickerOptions = await screen.findAllByLabelText('Select option');
expect(pickerOptions[0]).toHaveTextContent('General');
expect(pickerOptions[0]).toHaveTextContent('Dashboards');
});
it('should not show the General folder by default if showRoot is false', async () => {
it('should not show the Dashboards root by default if showRoot is false', async () => {
jest
.spyOn(api, 'searchFolders')
.mockResolvedValue([
@ -115,10 +115,10 @@ describe('OldFolderPicker', () => {
const pickerOptions = await screen.findAllByLabelText('Select option');
expect(pickerOptions[0]).not.toHaveTextContent('General');
expect(pickerOptions[0]).not.toHaveTextContent('Dashboards');
});
it('should not show the General folder by default for not editors', async () => {
it('should not show the Dashboards root by default for not editors', async () => {
jest
.spyOn(api, 'searchFolders')
.mockResolvedValue([
@ -136,7 +136,7 @@ describe('OldFolderPicker', () => {
const pickerOptions = await screen.findAllByLabelText('Select option');
expect(pickerOptions[0]).not.toHaveTextContent('General');
expect(pickerOptions[0]).not.toHaveTextContent('Dashboards');
});
it('should return the correct search results when typing in the select', async () => {

View File

@ -10,7 +10,6 @@ import { useStyles2, ActionMeta, Input, InputActionMeta, AsyncVirtualizedSelect
import appEvents from 'app/core/app_events';
import { t } from 'app/core/internationalization';
import { contextSrv } from 'app/core/services/context_srv';
import { newBrowseDashboardsEnabled } from 'app/features/browse-dashboards/featureFlag';
import { createFolder, getFolderByUid, searchFolders } from 'app/features/manage-dashboards/state/actions';
import { DashboardSearchHit } from 'app/features/search/types';
import { AccessControlAction, PermissionLevelString, SearchQueryType } from 'app/types';
@ -81,7 +80,7 @@ export function OldFolderPicker(props: Props) {
folderWarning,
} = props;
const rootName = rootNameProp ?? newBrowseDashboardsEnabled() ? 'Dashboards' : 'General';
const rootName = rootNameProp ?? 'Dashboards';
const [folder, setFolder] = useState<SelectedFolder | null>(null);
const [isCreatingNew, setIsCreatingNew] = useState(false);

View File

@ -1,7 +1,6 @@
import memoizeOne from 'memoize-one';
import { NavModel, NavModelItem, NavIndex } from '@grafana/data';
import { newBrowseDashboardsEnabled } from 'app/features/browse-dashboards/featureFlag';
import { FOLDER_ID } from 'app/features/folders/state/navModel';
import { HOME_NAV_ID } from '../reducers/navModel';
@ -44,7 +43,7 @@ export const getNavModel = memoizeOne(
export function getRootSectionForNode(node: NavModelItem): NavModelItem {
// Don't recurse fully up the folder tree when nested folders is enabled
if (newBrowseDashboardsEnabled() && node.id === FOLDER_ID) {
if (node.id === FOLDER_ID) {
return node;
} else {
return node.parentItem && node.parentItem.id !== HOME_NAV_ID ? getRootSectionForNode(node.parentItem) : node;

View File

@ -1,5 +0,0 @@
import { config } from '@grafana/runtime';
export function newBrowseDashboardsEnabled() {
return config.featureToggles.nestedFolders || config.featureToggles.newBrowseDashboards;
}

View File

@ -3,7 +3,6 @@ import { getBackendSrv, isFetchError, locationService } from '@grafana/runtime';
import { updateNavIndex } from 'app/core/actions';
import { StateManagerBase } from 'app/core/services/StateManagerBase';
import { backendSrv } from 'app/core/services/backend_srv';
import { newBrowseDashboardsEnabled } from 'app/features/browse-dashboards/featureFlag';
import { dashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { buildNavModel } from 'app/features/folders/state/navModel';
@ -161,7 +160,7 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc
// only the folder API has information about ancestors
// get parent folder (if it exists) and put it in the store
// this will be used to populate the full breadcrumb trail
if (newBrowseDashboardsEnabled() && dashboard.meta.folderUid) {
if (dashboard.meta.folderUid) {
try {
const folder = await backendSrv.getFolderByUid(dashboard.meta.folderUid);
store.dispatch(updateNavIndex(buildNavModel(folder)));

View File

@ -18,7 +18,6 @@ import {
} from '@grafana/scenes';
import appEvents from 'app/core/app_events';
import { getNavModel } from 'app/core/selectors/navModel';
import { newBrowseDashboardsEnabled } from 'app/features/browse-dashboards/featureFlag';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { VariablesChanged } from 'app/features/variables/types';
import { DashboardMeta } from 'app/types';
@ -182,29 +181,17 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
}),
};
const { folderTitle, folderUid } = meta;
const { folderUid } = meta;
if (folderUid) {
if (newBrowseDashboardsEnabled()) {
const folderNavModel = getNavModel(navIndex, `folder-dashboards-${folderUid}`).main;
// If the folder hasn't loaded (maybe user doesn't have permission on it?) then
// don't show the "page not found" breadcrumb
if (folderNavModel.id !== 'not-found') {
pageNav = {
...pageNav,
parentItem: folderNavModel,
};
}
} else {
if (folderTitle) {
pageNav = {
...pageNav,
parentItem: {
text: folderTitle,
url: `/dashboards/f/${meta.folderUid}`,
},
};
}
const folderNavModel = getNavModel(navIndex, `folder-dashboards-${folderUid}`).main;
// If the folder hasn't loaded (maybe user doesn't have permission on it?) then
// don't show the "page not found" breadcrumb
if (folderNavModel.id !== 'not-found') {
pageNav = {
...pageNav,
parentItem: folderNavModel,
};
}
}

View File

@ -11,6 +11,7 @@ interface SaveDashboardButtonProps {
dashboard: DashboardModel;
onSaveSuccess?: () => void;
size?: ComponentSize;
onClick?: () => void;
}
export const SaveDashboardButton = ({ dashboard, onSaveSuccess, size }: SaveDashboardButtonProps) => {
@ -39,7 +40,7 @@ export const SaveDashboardButton = ({ dashboard, onSaveSuccess, size }: SaveDash
type Props = SaveDashboardButtonProps & { variant?: ButtonVariant };
export const SaveDashboardAsButton = ({ dashboard, onSaveSuccess, variant, size }: Props) => {
export const SaveDashboardAsButton = ({ dashboard, onClick, onSaveSuccess, variant, size }: Props) => {
return (
<ModalsController>
{({ showModal, hideModal }) => {
@ -48,6 +49,7 @@ export const SaveDashboardAsButton = ({ dashboard, onSaveSuccess, variant, size
size={size}
onClick={() => {
reportInteraction('grafana_dashboard_save_as_clicked');
onClick?.();
showModal(SaveDashboardDrawer, {
dashboard,
onSaveSuccess,

View File

@ -10,20 +10,19 @@ import { createDashboardModelFixture } from '../../state/__fixtures__/dashboardF
import { SaveDashboardDrawer } from './SaveDashboardDrawer';
const saveDashboardMutationMock = jest.fn();
jest.mock('app/core/core', () => ({
...jest.requireActual('app/core/core'),
contextSrv: {},
}));
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => ({
post: mockPost,
}),
jest.mock('app/features/browse-dashboards/api/browseDashboardsAPI', () => ({
...jest.requireActual('app/features/browse-dashboards/api/browseDashboardsAPI'),
useSaveDashboardMutation: () => [saveDashboardMutationMock],
}));
const store = configureStore();
const mockPost = jest.fn();
const buildMocks = () => ({
dashboard: createDashboardModelFixture({
uid: 'mockDashboardUid',
@ -53,13 +52,15 @@ const setup = (options: CompProps) => waitFor(() => render(<CompWithProvider {..
describe('SaveDashboardDrawer', () => {
beforeEach(() => {
mockPost.mockClear();
saveDashboardMutationMock.mockClear();
jest.spyOn(console, 'error').mockImplementation();
});
it("renders a modal if there's an unhandled error", async () => {
const { onDismiss, dashboard, error } = buildMocks();
mockPost.mockRejectedValueOnce(error);
saveDashboardMutationMock.mockResolvedValue({
error,
});
await setup({ dashboard, onDismiss });
@ -72,15 +73,20 @@ describe('SaveDashboardDrawer', () => {
it('should render corresponding save modal once the error is handled', async () => {
const { onDismiss, dashboard, error } = buildMocks();
mockPost.mockRejectedValueOnce(error);
saveDashboardMutationMock.mockResolvedValue({
error,
});
const { rerender } = await setup({ dashboard, onDismiss });
await userEvent.click(screen.getByRole('button', { name: /save/i }));
rerender(<CompWithProvider dashboard={dashboard} onDismiss={onDismiss} />);
mockPost.mockClear();
mockPost.mockRejectedValueOnce({ ...error, isHandled: true });
saveDashboardMutationMock.mockClear();
saveDashboardMutationMock.mockResolvedValue({
error,
isHandled: true,
});
await userEvent.click(screen.getByRole('button', { name: /save/i }));

View File

@ -19,6 +19,7 @@ export const SaveDashboardDrawer = ({ dashboard, onDismiss, onSaveSuccess, isCop
const previous = dashboard.getOriginalDashboard();
const isProvisioned = dashboard.meta.provisioned;
const isNew = dashboard.version === 0;
const [errorIsHandled, setErrorIsHandled] = useState(false);
const data = useMemo<SaveDashboardData>(() => {
const clone = dashboard.getSaveModelClone({
@ -89,18 +90,14 @@ export const SaveDashboardDrawer = ({ dashboard, onDismiss, onSaveSuccess, isCop
);
};
if (
state.error &&
isFetchError(state.error) &&
!state.error.isHandled &&
proxyHandlesError(state.error.data.status)
) {
if (state.error && !errorIsHandled && isFetchError(state.error) && proxyHandlesError(state.error.data.status)) {
return (
<SaveDashboardErrorProxy
error={state.error}
dashboard={dashboard}
dashboardSaveModel={data.clone}
onDismiss={onDismiss}
setErrorIsHandled={setErrorIsHandled}
/>
);
}

View File

@ -1,5 +1,5 @@
import { css } from '@emotion/css';
import React, { useEffect } from 'react';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { FetchError } from '@grafana/runtime';
@ -19,6 +19,7 @@ interface SaveDashboardErrorProxyProps {
dashboardSaveModel: Dashboard;
error: FetchError;
onDismiss: () => void;
setErrorIsHandled: React.Dispatch<React.SetStateAction<boolean>>;
}
export const SaveDashboardErrorProxy = ({
@ -26,15 +27,10 @@ export const SaveDashboardErrorProxy = ({
dashboardSaveModel,
error,
onDismiss,
setErrorIsHandled,
}: SaveDashboardErrorProxyProps) => {
const { onDashboardSave } = useDashboardSave();
useEffect(() => {
if (error.data && proxyHandlesError(error.data.status)) {
error.isHandled = true;
}
}, [error]);
return (
<>
{error.data && error.data.status === 'version-mismatch' && (
@ -73,7 +69,13 @@ export const SaveDashboardErrorProxy = ({
/>
)}
{error.data && error.data.status === 'plugin-dashboard' && (
<ConfirmPluginDashboardSaveModal dashboard={dashboard} onDismiss={onDismiss} />
<ConfirmPluginDashboardSaveModal
dashboard={dashboard}
onDismiss={() => {
setErrorIsHandled(true);
onDismiss();
}}
/>
)}
</>
);
@ -96,7 +98,7 @@ const ConfirmPluginDashboardSaveModal = ({ onDismiss, dashboard }: SaveDashboard
<Button variant="secondary" onClick={onDismiss} fill="outline">
Cancel
</Button>
<SaveDashboardAsButton dashboard={dashboard} onSaveSuccess={onDismiss} />
<SaveDashboardAsButton onClick={onDismiss} dashboard={dashboard} onSaveSuccess={onDismiss} />
<Button
variant="destructive"
onClick={async () => {

View File

@ -5,12 +5,9 @@ import { locationService, reportInteraction } from '@grafana/runtime';
import { Dashboard } from '@grafana/schema';
import appEvents from 'app/core/app_events';
import { useAppNotification } from 'app/core/copy/appNotification';
import { contextSrv } from 'app/core/core';
import { updateDashboardName } from 'app/core/reducers/navBarTree';
import { useSaveDashboardMutation } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
import { newBrowseDashboardsEnabled } from 'app/features/browse-dashboards/featureFlag';
import { DashboardModel } from 'app/features/dashboard/state';
import { saveDashboard as saveDashboardApiCall } from 'app/features/manage-dashboards/state/actions';
import { useDispatch } from 'app/types';
import { DashboardSavedEvent } from 'app/types/events';
@ -24,30 +21,18 @@ const saveDashboard = async (
dashboard: DashboardModel,
saveDashboardRtkQuery: ReturnType<typeof useSaveDashboardMutation>[0]
) => {
if (newBrowseDashboardsEnabled()) {
const query = await saveDashboardRtkQuery({
dashboard: saveModel,
folderUid: options.folderUid ?? dashboard.meta.folderUid ?? saveModel.meta.folderUid,
message: options.message,
overwrite: options.overwrite,
});
const query = await saveDashboardRtkQuery({
dashboard: saveModel,
folderUid: options.folderUid ?? dashboard.meta.folderUid ?? saveModel.meta?.folderUid,
message: options.message,
overwrite: options.overwrite,
});
if ('error' in query) {
throw query.error;
}
return query.data;
} else {
let folderUid = options.folderUid;
if (folderUid === undefined) {
folderUid = dashboard.meta.folderUid ?? saveModel.folderUid;
}
const result = await saveDashboardApiCall({ ...options, folderUid, dashboard: saveModel });
// fetch updated access control permissions
await contextSrv.fetchUserPermissions();
return result;
if ('error' in query) {
throw query.error;
}
return query.data;
};
export const useDashboardSave = (isCopy = false) => {

View File

@ -14,7 +14,6 @@ import { createErrorNotification } from 'app/core/copy/appNotification';
import { getKioskMode } from 'app/core/navigation/kiosk';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { getNavModel } from 'app/core/selectors/navModel';
import { newBrowseDashboardsEnabled } from 'app/features/browse-dashboards/featureFlag';
import { PanelModel } from 'app/features/dashboard/state';
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
import { AngularDeprecationNotice } from 'app/features/plugins/angularDeprecation/AngularDeprecationNotice';
@ -425,29 +424,16 @@ function updateStatePageNavFromProps(props: Props, state: State): State {
};
}
const { folderTitle, folderUid } = dashboard.meta;
const { folderUid } = dashboard.meta;
if (folderUid && pageNav) {
if (newBrowseDashboardsEnabled()) {
const folderNavModel = getNavModel(navIndex, `folder-dashboards-${folderUid}`).main;
// If the folder hasn't loaded (maybe user doesn't have permission on it?) then
// don't show the "page not found" breadcrumb
if (folderNavModel.id !== 'not-found') {
pageNav = {
...pageNav,
parentItem: folderNavModel,
};
}
} else {
// Check if folder changed
if (folderTitle && pageNav.parentItem?.text !== folderTitle) {
pageNav = {
...pageNav,
parentItem: {
text: folderTitle,
url: `/dashboards/f/${dashboard.meta.folderUid}`,
},
};
}
const folderNavModel = getNavModel(navIndex, `folder-dashboards-${folderUid}`).main;
// If the folder hasn't loaded (maybe user doesn't have permission on it?) then
// don't show the "page not found" breadcrumb
if (folderNavModel.id !== 'not-found') {
pageNav = {
...pageNav,
parentItem: folderNavModel,
};
}
}

View File

@ -6,7 +6,6 @@ import { createErrorNotification } from 'app/core/copy/appNotification';
import { backendSrv } from 'app/core/services/backend_srv';
import { KeybindingSrv } from 'app/core/services/keybindingSrv';
import store from 'app/core/store';
import { newBrowseDashboardsEnabled } from 'app/features/browse-dashboards/featureFlag';
import { dashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv';
import { DashboardSrv, getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
@ -100,7 +99,7 @@ async function fetchDashboard(
// only the folder API has information about ancestors
// get parent folder (if it exists) and put it in the store
// this will be used to populate the full breadcrumb trail
if (newBrowseDashboardsEnabled() && dashDTO.meta.folderUid) {
if (dashDTO.meta.folderUid) {
try {
await dispatch(getFolderByUid(dashDTO.meta.folderUid));
} catch (err) {
@ -128,7 +127,7 @@ async function fetchDashboard(
// only the folder API has information about ancestors
// get parent folder (if it exists) and put it in the store
// this will be used to populate the full breadcrumb trail
if (newBrowseDashboardsEnabled() && args.urlFolderUid) {
if (args.urlFolderUid) {
await dispatch(getFolderByUid(args.urlFolderUid));
}
return getNewDashboardModelData(args.urlFolderUid, args.panelType);

View File

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

View File

@ -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;

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,6 @@
import { locationUtil } from '@grafana/data';
import { getBackendSrv, locationService } from '@grafana/runtime';
import { notifyApp, updateNavIndex } from 'app/core/actions';
import { createSuccessNotification } from 'app/core/copy/appNotification';
import { contextSrv } from 'app/core/core';
import { updateNavIndex } from 'app/core/actions';
import { backendSrv } from 'app/core/services/backend_srv';
import { FolderDTO, FolderState, ThunkResult } from 'app/types';
import { FolderDTO, ThunkResult } from 'app/types';
import { buildNavModel } from './navModel';
import { loadFolder } from './reducers';
@ -17,32 +13,3 @@ export function getFolderByUid(uid: string): ThunkResult<Promise<FolderDTO>> {
return folder;
};
}
export function saveFolder(folder: FolderState): ThunkResult<void> {
return async (dispatch) => {
const res = await backendSrv.put(`/api/folders/${folder.uid}`, {
title: folder.title,
version: folder.version,
});
dispatch(notifyApp(createSuccessNotification('Folder saved')));
dispatch(loadFolder(res));
locationService.push(locationUtil.stripBaseFromUrl(`${res.url}/settings`));
};
}
export function deleteFolder(uid: string): ThunkResult<void> {
return async () => {
await backendSrv.delete(`/api/folders/${uid}?forceDeleteRules=false`);
locationService.push('/dashboards');
};
}
export function createNewFolder(folderName: string, uid?: string): ThunkResult<void> {
return async (dispatch) => {
const newFolder = await getBackendSrv().post('/api/folders', { title: folderName, parentUid: uid });
await contextSrv.fetchUserPermissions();
dispatch(notifyApp(createSuccessNotification('Folder Created', 'OK')));
locationService.push(locationUtil.stripBaseFromUrl(newFolder.url));
};
}

View File

@ -3,7 +3,6 @@ import { config } from '@grafana/runtime';
import { t } from 'app/core/internationalization';
import { contextSrv } from 'app/core/services/context_srv';
import { getNavSubTitle } from 'app/core/utils/navBarItem-translations';
import { newBrowseDashboardsEnabled } from 'app/features/browse-dashboards/featureFlag';
import { AccessControlAction, FolderDTO } from 'app/types';
export const FOLDER_ID = 'manage-folder';
@ -56,28 +55,6 @@ export function buildNavModel(folder: FolderDTO, parents = folder.parents): NavM
});
}
if (!newBrowseDashboardsEnabled()) {
if (folder.canAdmin) {
model.children!.push({
active: false,
icon: 'lock',
id: getPermissionsTabID(folder.uid),
text: t('browse-dashboards.manage-folder-nav.permissions', 'Permissions'),
url: `${folder.url}/permissions`,
});
}
if (folder.canSave) {
model.children!.push({
active: false,
icon: 'cog',
id: getSettingsTabID(folder.uid),
text: t('browse-dashboards.manage-folder-nav.settings', 'Settings'),
url: `${folder.url}/settings`,
});
}
}
return model;
}

View File

@ -4,7 +4,7 @@ import { notifyApp } from 'app/core/actions';
import { createErrorNotification } from 'app/core/copy/appNotification';
import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types';
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
import { DashboardDTO, FolderInfo, PermissionLevelString, SearchQueryType, ThunkResult } from 'app/types';
import { FolderInfo, PermissionLevelString, SearchQueryType, ThunkResult } from 'app/types';
import {
Input,
@ -282,60 +282,6 @@ export async function moveFolders(folderUIDs: string[], toFolder: FolderInfo) {
return result;
}
export function moveDashboards(dashboardUids: string[], toFolder: FolderInfo) {
const tasks = [];
for (const uid of dashboardUids) {
tasks.push(createTask(moveDashboard, true, uid, toFolder));
}
return executeInOrder(tasks).then((result: any) => {
return {
totalCount: result.length,
successCount: result.filter((res: any) => res.succeeded).length,
alreadyInFolderCount: result.filter((res: any) => res.alreadyInFolder).length,
};
});
}
async function moveDashboard(uid: string, toFolder: FolderInfo) {
const fullDash: DashboardDTO = await getBackendSrv().get(`/api/dashboards/uid/${uid}`);
if (
((fullDash.meta.folderUid === undefined || fullDash.meta.folderUid === null) && toFolder.uid === '') ||
fullDash.meta.folderUid === toFolder.uid
) {
return { alreadyInFolder: true };
}
const options = {
dashboard: fullDash.dashboard,
folderUid: toFolder.uid,
overwrite: false,
};
try {
await saveDashboard(options);
return { succeeded: true };
} catch (err) {
if (isFetchError(err)) {
if (err.data?.status !== 'plugin-dashboard') {
return { succeeded: false };
}
err.isHandled = true;
}
options.overwrite = true;
try {
await saveDashboard(options);
return { succeeded: true };
} catch (e) {
return { succeeded: false };
}
}
}
function createTask(fn: (...args: any[]) => Promise<any>, ignoreRejections: boolean, ...args: any[]) {
return async (result: any) => {
try {

View File

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

View File

@ -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;

View File

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

View File

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

View File

@ -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;
`,
};
};

View File

@ -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;
`,
};
};

View File

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

View File

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

View File

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

View File

@ -1 +0,0 @@
export { SearchItem } from './components/SearchItem';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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;
`,
};
};

View File

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

View File

@ -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};
`,
};
};

View File

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

View File

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

View File

@ -10,7 +10,6 @@ import { contextSrv } from 'app/core/services/context_srv';
import UserAdminPage from 'app/features/admin/UserAdminPage';
import LdapPage from 'app/features/admin/ldap/LdapPage';
import { getAlertingRoutes } from 'app/features/alerting/routes';
import { newBrowseDashboardsEnabled } from 'app/features/browse-dashboards/featureFlag';
import { ConnectionsRedirectNotice } from 'app/features/connections/components/ConnectionsRedirectNotice';
import { ROUTES as CONNECTIONS_ROUTES } from 'app/features/connections/constants';
import { getRoutes as getDataConnectionsRoutes } from 'app/features/connections/routes';
@ -139,38 +138,19 @@ export function getAppRoutes(): RouteDescriptor[] {
{
path: '/dashboards',
component: SafeDynamicImport(
() => import(/* webpackChunkName: "DashboardListPage"*/ 'app/features/search/components/DashboardListPage')
),
},
{
path: '/dashboards/folder/new',
roles: () => contextSrv.evaluatePermission([AccessControlAction.FoldersCreate]),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "NewDashboardsFolder"*/ 'app/features/folders/components/NewDashboardsFolder')
),
},
!newBrowseDashboardsEnabled() && {
path: '/dashboards/f/:uid/:slug/permissions',
component: SafeDynamicImport(
() => import(/* webpackChunkName: "FolderPermissions"*/ 'app/features/folders/AccessControlFolderPermissions')
),
},
!newBrowseDashboardsEnabled() && {
path: '/dashboards/f/:uid/:slug/settings',
component: SafeDynamicImport(
() => import(/* webpackChunkName: "FolderSettingsPage"*/ 'app/features/folders/FolderSettingsPage')
() => import(/* webpackChunkName: "DashboardListPage"*/ 'app/features/browse-dashboards/BrowseDashboardsPage')
),
},
{
path: '/dashboards/f/:uid/:slug',
component: SafeDynamicImport(
() => import(/* webpackChunkName: "DashboardListPage"*/ 'app/features/search/components/DashboardListPage')
() => import(/* webpackChunkName: "DashboardListPage"*/ 'app/features/browse-dashboards/BrowseDashboardsPage')
),
},
{
path: '/dashboards/f/:uid',
component: SafeDynamicImport(
() => import(/* webpackChunkName: "DashboardListPage"*/ 'app/features/search/components/DashboardListPage')
() => import(/* webpackChunkName: "DashboardListPage"*/ 'app/features/browse-dashboards/BrowseDashboardsPage')
),
},
{
@ -454,23 +434,17 @@ export function getAppRoutes(): RouteDescriptor[] {
{
path: '/dashboards/f/:uid/:slug/library-panels',
component: SafeDynamicImport(
newBrowseDashboardsEnabled()
? () =>
import(
/* webpackChunkName: "FolderLibraryPanelsPage"*/ 'app/features/browse-dashboards/BrowseFolderLibraryPanelsPage'
)
: () =>
import(/* webpackChunkName: "FolderLibraryPanelsPage"*/ 'app/features/folders/FolderLibraryPanelsPage')
() =>
import(
/* webpackChunkName: "FolderLibraryPanelsPage"*/ 'app/features/browse-dashboards/BrowseFolderLibraryPanelsPage'
)
),
},
{
path: '/dashboards/f/:uid/:slug/alerting',
roles: () => contextSrv.evaluatePermission([AccessControlAction.AlertingRuleRead]),
component: SafeDynamicImport(
newBrowseDashboardsEnabled()
? () =>
import(/* webpackChunkName: "FolderAlerting"*/ 'app/features/browse-dashboards/BrowseFolderAlertingPage')
: () => import(/* webpackChunkName: "FolderAlerting"*/ 'app/features/folders/FolderAlerting')
() => import(/* webpackChunkName: "FolderAlerting"*/ 'app/features/browse-dashboards/BrowseFolderAlertingPage')
),
},
{

View File

@ -81,9 +81,7 @@
"manage-folder-nav": {
"alert-rules": "Warnregeln",
"dashboards": "Dashboards",
"panels": "Fenster",
"permissions": "Berechtigungen",
"settings": "Einstellungen"
"panels": "Fenster"
},
"new-folder-form": {
"cancel-label": "Abbrechen",
@ -1085,15 +1083,6 @@
"new-dashboard": "Neues Dashboard",
"new-folder": "Neuer Ordner"
},
"folder-view": {
"go-to-folder": "Zum Ordner",
"select-folder": "Ordner auswählen"
},
"result-kind": {
"dashboard": "Dashboard",
"folder": "Ordner",
"panel": "Panel"
},
"results-table": {
"datasource-header": "Datenquelle",
"location-header": "Standort",
@ -1108,12 +1097,6 @@
"placeholder": "Nach Dashboards suchen"
}
},
"search-view": {
"no-results": {
"clear": "Suche und Filter löschen",
"text": "Keine Ergebnisse für Ihre Abfrage gefunden."
}
},
"share-modal": {
"dashboard": {
"title": "Teilen"

View File

@ -81,9 +81,7 @@
"manage-folder-nav": {
"alert-rules": "Alert rules",
"dashboards": "Dashboards",
"panels": "Panels",
"permissions": "Permissions",
"settings": "Settings"
"panels": "Panels"
},
"new-folder-form": {
"cancel-label": "Cancel",
@ -1085,15 +1083,6 @@
"new-dashboard": "New dashboard",
"new-folder": "New folder"
},
"folder-view": {
"go-to-folder": "Go to folder",
"select-folder": "Select folder"
},
"result-kind": {
"dashboard": "Dashboard",
"folder": "Folder",
"panel": "Panel"
},
"results-table": {
"datasource-header": "Data source",
"location-header": "Location",
@ -1108,12 +1097,6 @@
"placeholder": "Search for dashboards and folders"
}
},
"search-view": {
"no-results": {
"clear": "Clear search and filters",
"text": "No results found for your query."
}
},
"share-modal": {
"dashboard": {
"title": "Share"

View File

@ -86,9 +86,7 @@
"manage-folder-nav": {
"alert-rules": "Reglas de alerta",
"dashboards": "Paneles de control",
"panels": "Paneles",
"permissions": "Permisos",
"settings": "Configuración"
"panels": "Paneles"
},
"new-folder-form": {
"cancel-label": "Cancelar",
@ -1091,15 +1089,6 @@
"new-dashboard": "Nuevo panel de control",
"new-folder": "Nueva carpeta"
},
"folder-view": {
"go-to-folder": "Ir a la carpeta",
"select-folder": "Seleccionar carpeta"
},
"result-kind": {
"dashboard": "Panel de control",
"folder": "Carpeta",
"panel": "Panel"
},
"results-table": {
"datasource-header": "Fuente de datos",
"location-header": "Ubicación",
@ -1114,12 +1103,6 @@
"placeholder": "Buscar paneles de control"
}
},
"search-view": {
"no-results": {
"clear": "Borrar la búsqueda y los filtros",
"text": "No se ha encontrado ningún resultado para su consulta."
}
},
"share-modal": {
"dashboard": {
"title": "Compartir"

View File

@ -86,9 +86,7 @@
"manage-folder-nav": {
"alert-rules": "Règles d'alerte",
"dashboards": "Tableaux de bord",
"panels": "Panneaux",
"permissions": "Permissions",
"settings": "Paramètres"
"panels": "Panneaux"
},
"new-folder-form": {
"cancel-label": "Annuler",
@ -1091,15 +1089,6 @@
"new-dashboard": "Nouveau tableau de bord",
"new-folder": "Nouveau dossier"
},
"folder-view": {
"go-to-folder": "Aller dans le dossier",
"select-folder": "Sélectionner un dossier"
},
"result-kind": {
"dashboard": "Tableau de bord",
"folder": "Dossier",
"panel": "Panneau"
},
"results-table": {
"datasource-header": "Source de données",
"location-header": "Emplacement",
@ -1114,12 +1103,6 @@
"placeholder": "Rechercher des tableaux de bord"
}
},
"search-view": {
"no-results": {
"clear": "Effacer la recherche et les filtres",
"text": "Aucun résultat trouvé pour votre requête."
}
},
"share-modal": {
"dashboard": {
"title": "Partager"

View File

@ -81,9 +81,7 @@
"manage-folder-nav": {
"alert-rules": "Åľęřŧ řūľęş",
"dashboards": "Đäşĥþőäřđş",
"panels": "Päʼnęľş",
"permissions": "Pęřmįşşįőʼnş",
"settings": "Ŝęŧŧįʼnģş"
"panels": "Päʼnęľş"
},
"new-folder-form": {
"cancel-label": "Cäʼnčęľ",
@ -1085,15 +1083,6 @@
"new-dashboard": "Ńęŵ đäşĥþőäřđ",
"new-folder": "Ńęŵ ƒőľđęř"
},
"folder-view": {
"go-to-folder": "Ğő ŧő ƒőľđęř",
"select-folder": "Ŝęľęčŧ ƒőľđęř"
},
"result-kind": {
"dashboard": "Đäşĥþőäřđ",
"folder": "Főľđęř",
"panel": "Päʼnęľ"
},
"results-table": {
"datasource-header": "Đäŧä şőūřčę",
"location-header": "Ŀőčäŧįőʼn",
@ -1108,12 +1097,6 @@
"placeholder": "Ŝęäřčĥ ƒőř đäşĥþőäřđş äʼnđ ƒőľđęřş"
}
},
"search-view": {
"no-results": {
"clear": "Cľęäř şęäřčĥ äʼnđ ƒįľŧęřş",
"text": "Ńő řęşūľŧş ƒőūʼnđ ƒőř yőūř qūęřy."
}
},
"share-modal": {
"dashboard": {
"title": "Ŝĥäřę"

View File

@ -76,9 +76,7 @@
"manage-folder-nav": {
"alert-rules": "警报规则",
"dashboards": "仪表板",
"panels": "面板",
"permissions": "权限",
"settings": "设置"
"panels": "面板"
},
"new-folder-form": {
"cancel-label": "取消",
@ -1079,15 +1077,6 @@
"new-dashboard": "新建仪表板",
"new-folder": "新建文件夹"
},
"folder-view": {
"go-to-folder": "前往文件夹",
"select-folder": "选择文件夹"
},
"result-kind": {
"dashboard": "仪表板",
"folder": "文件夹",
"panel": "面板"
},
"results-table": {
"datasource-header": "数据源",
"location-header": "位置",
@ -1102,12 +1091,6 @@
"placeholder": "搜索仪表板"
}
},
"search-view": {
"no-results": {
"clear": "清除搜索和筛选条件",
"text": "您的查询未找到结果。"
}
},
"share-modal": {
"dashboard": {
"title": "分享"