From 4290ed3d861bc6799f913e30c3ec2503975f9f3d Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Wed, 22 Nov 2023 15:22:00 +0000 Subject: [PATCH] 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 --- .betterer.results | 81 +----- .../feature-toggles/index.md | 1 - .../src/types/featureToggles.gen.ts | 1 - pkg/services/featuremgmt/registry.go | 9 - pkg/services/featuremgmt/toggles_gen.csv | 1 - pkg/services/featuremgmt/toggles_gen.go | 4 - .../components/AccessControl/Permissions.tsx | 3 +- .../Select/OldFolderPicker.test.tsx | 12 +- .../components/Select/OldFolderPicker.tsx | 3 +- public/app/core/selectors/navModel.ts | 3 +- .../features/browse-dashboards/featureFlag.ts | 5 - .../pages/DashboardScenePageStateManager.ts | 3 +- .../dashboard-scene/scene/DashboardScene.tsx | 31 +-- .../SaveDashboard/SaveDashboardButton.tsx | 4 +- .../SaveDashboardDrawer.test.tsx | 28 +- .../SaveDashboard/SaveDashboardDrawer.tsx | 9 +- .../SaveDashboard/SaveDashboardErrorProxy.tsx | 20 +- .../SaveDashboard/useDashboardSave.tsx | 35 +-- .../dashboard/containers/DashboardPage.tsx | 32 +-- .../features/dashboard/state/initDashboard.ts | 5 +- .../AccessControlFolderPermissions.tsx | 47 ---- .../app/features/folders/FolderAlerting.tsx | 35 --- .../folders/FolderLibraryPanelsPage.tsx | 55 ---- .../folders/FolderSettingsPage.test.tsx | 189 ------------- .../features/folders/FolderSettingsPage.tsx | 119 -------- .../components/NewDashboardsFolder.tsx | 86 ------ public/app/features/folders/state/actions.ts | 37 +-- public/app/features/folders/state/navModel.ts | 23 -- .../manage-dashboards/state/actions.ts | 56 +--- .../search/components/DashboardActions.tsx | 97 ------- .../search/components/DashboardListPage.tsx | 71 ----- .../components/ManageDashboards.test.tsx | 49 ---- .../search/components/ManageDashboardsNew.tsx | 109 -------- .../features/search/components/SearchCard.tsx | 262 ------------------ .../search/components/SearchCardExpanded.tsx | 165 ----------- .../search/components/SearchCheckbox.tsx | 21 -- .../search/components/SearchItem.test.tsx | 68 ----- .../features/search/components/SearchItem.tsx | 141 ---------- public/app/features/search/index.ts | 1 - .../components/ConfirmDeleteModal.test.tsx | 45 --- .../page/components/ConfirmDeleteModal.tsx | 71 ----- .../page/components/FolderSection.test.tsx | 238 ---------------- .../search/page/components/FolderSection.tsx | 258 ----------------- .../page/components/ManageActions.test.tsx | 121 -------- .../search/page/components/ManageActions.tsx | 65 ----- .../components/MoveToFolderModal.test.tsx | 159 ----------- .../page/components/MoveToFolderModal.tsx | 193 ------------- .../page/components/RootFolderView.test.tsx | 204 -------------- .../search/page/components/RootFolderView.tsx | 131 --------- .../components/SearchResultsCards.test.tsx | 131 --------- .../page/components/SearchResultsCards.tsx | 125 --------- .../page/components/SearchView.test.tsx | 170 ------------ .../search/page/components/SearchView.tsx | 221 --------------- public/app/routes/routes.tsx | 42 +-- public/locales/de-DE/grafana.json | 19 +- public/locales/en-US/grafana.json | 19 +- public/locales/es-ES/grafana.json | 19 +- public/locales/fr-FR/grafana.json | 19 +- public/locales/pseudo-LOCALE/grafana.json | 19 +- public/locales/zh-Hans/grafana.json | 19 +- 60 files changed, 92 insertions(+), 4117 deletions(-) delete mode 100644 public/app/features/browse-dashboards/featureFlag.ts delete mode 100644 public/app/features/folders/AccessControlFolderPermissions.tsx delete mode 100644 public/app/features/folders/FolderAlerting.tsx delete mode 100644 public/app/features/folders/FolderLibraryPanelsPage.tsx delete mode 100644 public/app/features/folders/FolderSettingsPage.test.tsx delete mode 100644 public/app/features/folders/FolderSettingsPage.tsx delete mode 100644 public/app/features/folders/components/NewDashboardsFolder.tsx delete mode 100644 public/app/features/search/components/DashboardActions.tsx delete mode 100644 public/app/features/search/components/DashboardListPage.tsx delete mode 100644 public/app/features/search/components/ManageDashboards.test.tsx delete mode 100644 public/app/features/search/components/ManageDashboardsNew.tsx delete mode 100644 public/app/features/search/components/SearchCard.tsx delete mode 100644 public/app/features/search/components/SearchCardExpanded.tsx delete mode 100644 public/app/features/search/components/SearchCheckbox.tsx delete mode 100644 public/app/features/search/components/SearchItem.test.tsx delete mode 100644 public/app/features/search/components/SearchItem.tsx delete mode 100644 public/app/features/search/index.ts delete mode 100644 public/app/features/search/page/components/ConfirmDeleteModal.test.tsx delete mode 100644 public/app/features/search/page/components/ConfirmDeleteModal.tsx delete mode 100644 public/app/features/search/page/components/FolderSection.test.tsx delete mode 100644 public/app/features/search/page/components/FolderSection.tsx delete mode 100644 public/app/features/search/page/components/ManageActions.test.tsx delete mode 100644 public/app/features/search/page/components/ManageActions.tsx delete mode 100644 public/app/features/search/page/components/MoveToFolderModal.test.tsx delete mode 100644 public/app/features/search/page/components/MoveToFolderModal.tsx delete mode 100644 public/app/features/search/page/components/RootFolderView.test.tsx delete mode 100644 public/app/features/search/page/components/RootFolderView.tsx delete mode 100644 public/app/features/search/page/components/SearchResultsCards.test.tsx delete mode 100644 public/app/features/search/page/components/SearchResultsCards.tsx delete mode 100644 public/app/features/search/page/components/SearchView.test.tsx delete mode 100644 public/app/features/search/page/components/SearchView.tsx diff --git a/.betterer.results b/.betterer.results index ae90f276b56..43158964214 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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"] ], diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 12d33557fa0..3c0861d1837 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -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 | diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 681b48d49f4..5ca588e103e 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -116,7 +116,6 @@ export interface FeatureToggles { angularDeprecationUI?: boolean; dashgpt?: boolean; reportingRetries?: boolean; - newBrowseDashboards?: boolean; sseGroupByDatasource?: boolean; requestInstrumentationStatusSource?: boolean; libraryPanelRBAC?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 7b53db06e66..81791f22a9c 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -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", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index b1cbe037f8d..8f9a770964e 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -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 diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index bd7634f084e..cfef22fc19a 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -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" diff --git a/public/app/core/components/AccessControl/Permissions.tsx b/public/app/core/components/AccessControl/Permissions.tsx index 92dfb78e7bd..1fb44372b52 100644 --- a/public/app/core/components/AccessControl/Permissions.tsx +++ b/public/app/core/components/AccessControl/Permissions.tsx @@ -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 = ({
{canSetPermissions && ( <> - {newBrowseDashboardsEnabled() && resource === 'folders' && ( + {resource === 'folders' && ( <> This will change permissions for this folder and all its descendants. In total, this will affect: diff --git a/public/app/core/components/Select/OldFolderPicker.test.tsx b/public/app/core/components/Select/OldFolderPicker.test.tsx index 2b27a1fbfd7..0be47d4847a 100644 --- a/public/app/core/components/Select/OldFolderPicker.test.tsx +++ b/public/app/core/components/Select/OldFolderPicker.test.tsx @@ -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 () => { diff --git a/public/app/core/components/Select/OldFolderPicker.tsx b/public/app/core/components/Select/OldFolderPicker.tsx index 77fbf504c08..4a7d73d3484 100644 --- a/public/app/core/components/Select/OldFolderPicker.tsx +++ b/public/app/core/components/Select/OldFolderPicker.tsx @@ -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(null); const [isCreatingNew, setIsCreatingNew] = useState(false); diff --git a/public/app/core/selectors/navModel.ts b/public/app/core/selectors/navModel.ts index f4f04de9994..fc758061db5 100644 --- a/public/app/core/selectors/navModel.ts +++ b/public/app/core/selectors/navModel.ts @@ -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; diff --git a/public/app/features/browse-dashboards/featureFlag.ts b/public/app/features/browse-dashboards/featureFlag.ts deleted file mode 100644 index f2e424835d3..00000000000 --- a/public/app/features/browse-dashboards/featureFlag.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { config } from '@grafana/runtime'; - -export function newBrowseDashboardsEnabled() { - return config.featureToggles.nestedFolders || config.featureToggles.newBrowseDashboards; -} diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index b8ec4022740..971d0599a00 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -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 { }), }; - 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, + }; } } diff --git a/public/app/features/dashboard/components/SaveDashboard/SaveDashboardButton.tsx b/public/app/features/dashboard/components/SaveDashboard/SaveDashboardButton.tsx index 12d51e7dd44..34107900acb 100644 --- a/public/app/features/dashboard/components/SaveDashboard/SaveDashboardButton.tsx +++ b/public/app/features/dashboard/components/SaveDashboard/SaveDashboardButton.tsx @@ -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 ( {({ 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, diff --git a/public/app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer.test.tsx b/public/app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer.test.tsx index ee9bb67e9df..1303bcefd16 100644 --- a/public/app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer.test.tsx +++ b/public/app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer.test.tsx @@ -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( { 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(); - mockPost.mockClear(); - mockPost.mockRejectedValueOnce({ ...error, isHandled: true }); + saveDashboardMutationMock.mockClear(); + saveDashboardMutationMock.mockResolvedValue({ + error, + isHandled: true, + }); await userEvent.click(screen.getByRole('button', { name: /save/i })); diff --git a/public/app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer.tsx b/public/app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer.tsx index 5b81085c3a0..1ee7e8aa335 100644 --- a/public/app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer.tsx +++ b/public/app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer.tsx @@ -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(() => { 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 ( ); } diff --git a/public/app/features/dashboard/components/SaveDashboard/SaveDashboardErrorProxy.tsx b/public/app/features/dashboard/components/SaveDashboard/SaveDashboardErrorProxy.tsx index 23882f5af60..60d5533e153 100644 --- a/public/app/features/dashboard/components/SaveDashboard/SaveDashboardErrorProxy.tsx +++ b/public/app/features/dashboard/components/SaveDashboard/SaveDashboardErrorProxy.tsx @@ -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>; } 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' && ( - + { + setErrorIsHandled(true); + onDismiss(); + }} + /> )} ); @@ -96,7 +98,7 @@ const ConfirmPluginDashboardSaveModal = ({ onDismiss, dashboard }: SaveDashboard - + - -
- - )} - - - - ); - } -} - -export default connector(FolderSettingsPage); diff --git a/public/app/features/folders/components/NewDashboardsFolder.tsx b/public/app/features/folders/components/NewDashboardsFolder.tsx deleted file mode 100644 index 8844e162078..00000000000 --- a/public/app/features/folders/components/NewDashboardsFolder.tsx +++ /dev/null @@ -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; - -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 ( - - -
- {({ register, errors }) => ( - <> - - await validateFolderName(v), - })} - /> - - - - - Cancel - - - - )} -
-
-
- ); -} - -export default connector(NewDashboardsFolder); diff --git a/public/app/features/folders/state/actions.ts b/public/app/features/folders/state/actions.ts index 3f9c869289f..e79c97f97f5 100644 --- a/public/app/features/folders/state/actions.ts +++ b/public/app/features/folders/state/actions.ts @@ -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> { return folder; }; } - -export function saveFolder(folder: FolderState): ThunkResult { - 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 { - return async () => { - await backendSrv.delete(`/api/folders/${uid}?forceDeleteRules=false`); - locationService.push('/dashboards'); - }; -} - -export function createNewFolder(folderName: string, uid?: string): ThunkResult { - 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)); - }; -} diff --git a/public/app/features/folders/state/navModel.ts b/public/app/features/folders/state/navModel.ts index 7e714ffeb21..5c7e0d4ffb4 100644 --- a/public/app/features/folders/state/navModel.ts +++ b/public/app/features/folders/state/navModel.ts @@ -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; } diff --git a/public/app/features/manage-dashboards/state/actions.ts b/public/app/features/manage-dashboards/state/actions.ts index 5474afacf21..15193dc2f23 100644 --- a/public/app/features/manage-dashboards/state/actions.ts +++ b/public/app/features/manage-dashboards/state/actions.ts @@ -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, ignoreRejections: boolean, ...args: any[]) { return async (result: any) => { try { diff --git a/public/app/features/search/components/DashboardActions.tsx b/public/app/features/search/components/DashboardActions.tsx deleted file mode 100644 index 12dc125b50e..00000000000 --- a/public/app/features/search/components/DashboardActions.tsx +++ /dev/null @@ -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>([['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 ( - - {canCreateDashboards && ( - - reportInteraction('grafana_menu_item_clicked', { url: actionUrl('new'), from: '/dashboards' }) - } - /> - )} - {canCreateFolders && (config.featureToggles.nestedFolders || !folder?.uid) && ( - - reportInteraction('grafana_menu_item_clicked', { url: actionUrl('new_folder'), from: '/dashboards' }) - } - /> - )} - {canCreateDashboards && ( - - reportInteraction('grafana_menu_item_clicked', { url: actionUrl('import'), from: '/dashboards' }) - } - /> - )} - - ); - }; - - return ( - <> -
- - {canMove && ( - - )} - - - - -
- - {canMove && isMoveModalOpen && ( - {}} results={moveSelection} onDismiss={() => setIsMoveModalOpen(false)} /> - )} - - ); -}; diff --git a/public/app/features/search/components/DashboardListPage.tsx b/public/app/features/search/components/DashboardListPage.tsx deleted file mode 100644 index 48a744064e5..00000000000 --- a/public/app/features/search/components/DashboardListPage.tsx +++ /dev/null @@ -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 {} - -export const DashboardListPageFeatureToggle = memo((props: Props) => { - if (newBrowseDashboardsEnabled()) { - return ; - } - - return ; -}); -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 ( - - - - - - ); -}); - -DashboardListPage.displayName = 'DashboardListPage'; - -export default DashboardListPageFeatureToggle; diff --git a/public/app/features/search/components/ManageDashboards.test.tsx b/public/app/features/search/components/ManageDashboards.test.tsx deleted file mode 100644 index e07cd425f10..00000000000 --- a/public/app/features/search/components/ManageDashboards.test.tsx +++ /dev/null @@ -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()); - - 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()); - - expect(screen.getByRole('button', { name: /new/i })).toBeInTheDocument(); - }); -}); diff --git a/public/app/features/search/components/ManageDashboardsNew.tsx b/public/app/features/search/components/ManageDashboardsNew.tsx deleted file mode 100644 index 6dd4643276e..00000000000 --- a/public/app/features/search/components/ManageDashboardsNew.tsx +++ /dev/null @@ -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 ( - <> -
-
- 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} - /> -
- {viewActions && ( - - )} -
- - - - ); -}); - -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}; - `, -}); diff --git a/public/app/features/search/components/SearchCard.tsx b/public/app/features/search/components/SearchCard.tsx deleted file mode 100644 index 4219f5d5a99..00000000000 --- a/public/app/features/search/components/SearchCard.tsx +++ /dev/null @@ -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) => 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(null); - const [showExpandedView, setShowExpandedView] = useState(false); - const timeout = useRef(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(null); - const [popperElement, setPopperElement] = React.useState(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 ( - setMarkerElement(ref as unknown as HTMLDivElement)} - onMouseEnter={onMouseEnter} - onMouseLeave={onMouseLeave} - onMouseMove={onMouseMove} - onClick={onClick} - > -
- - {hasImage ? ( - Dashboard preview setHasImage(false)} - /> - ) : ( -
- {item.icon ? ( - - ) : ( - - )} -
- )} -
-
-
{item.title}
- -
- {showExpandedView && ( - -
- -
-
- )} -
- ); -} - -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; - `, - }; -}; diff --git a/public/app/features/search/components/SearchCardExpanded.tsx b/public/app/features/search/components/SearchCardExpanded.tsx deleted file mode 100644 index 86028c590cf..00000000000 --- a/public/app/features/search/components/SearchCardExpanded.tsx +++ /dev/null @@ -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) => 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 ( - -
- {hasImage ? ( - Dashboard preview setHasImage(true)} - onError={() => setHasImage(false)} - /> - ) : ( -
- {item.icon ? ( - - ) : ( - - )} -
- )} -
-
-
-
-
{item.title}
-
- - {folderTitle} -
-
- {lastUpdated !== null && ( -
-
Last updated
- {lastUpdated ?
{lastUpdated}
: } -
- )} -
-
- -
-
-
- ); -} - -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; - `, - }; -}; diff --git a/public/app/features/search/components/SearchCheckbox.tsx b/public/app/features/search/components/SearchCheckbox.tsx deleted file mode 100644 index c80ea84db75..00000000000 --- a/public/app/features/search/components/SearchCheckbox.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React, { memo } from 'react'; - -import { Checkbox } from '@grafana/ui'; - -interface Props { - checked?: boolean; - onClick?: React.MouseEventHandler; - className?: string; - editable?: boolean; - 'aria-label'?: string; -} - -export const SearchCheckbox = memo( - ({ onClick, className, checked = false, editable = false, 'aria-label': ariaLabel }: Props) => { - return editable ? ( - - ) : null; - } -); - -SearchCheckbox.displayName = 'SearchCheckbox'; diff --git a/public/app/features/search/components/SearchItem.test.tsx b/public/app/features/search/components/SearchItem.test.tsx deleted file mode 100644 index 50c92b69f1a..00000000000 --- a/public/app/features/search/components/SearchItem.test.tsx +++ /dev/null @@ -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) => { - const props: Props = { - item: data, - onTagSelected: jest.fn(), - editable: false, - }; - - Object.assign(props, propOverrides); - - render(); -}; - -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'); - }); -}); diff --git a/public/app/features/search/components/SearchItem.tsx b/public/app/features/search/components/SearchItem.tsx deleted file mode 100644 index 60de8daf986..00000000000 --- a/public/app/features/search/components/SearchItem.tsx +++ /dev/null @@ -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) => void; -} - -const selectors = e2eSelectors.components.Search; - -const getIconFromMeta = (meta = ''): IconName => { - const metaIconMap = new Map([ - ['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) => { - 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 ? ( - <> - {kindName(item.kind)} - - ) : ( - <> - {item.parentTitle || 'General'} - - ); - - return ( -
- - - - {item.title} - - - {description} - - {item.sortMetaName && ( - - - {item.sortMeta} {item.sortMetaName} - - )} - - - `Filter by tag "${tag}"`} /> - - -
- ); -}; - -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)}; - } - `, - }; -}; diff --git a/public/app/features/search/index.ts b/public/app/features/search/index.ts deleted file mode 100644 index f8282672f91..00000000000 --- a/public/app/features/search/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SearchItem } from './components/SearchItem'; diff --git a/public/app/features/search/page/components/ConfirmDeleteModal.test.tsx b/public/app/features/search/page/components/ConfirmDeleteModal.test.tsx deleted file mode 100644 index a782e526a7a..00000000000 --- a/public/app/features/search/page/components/ConfirmDeleteModal.test.tsx +++ /dev/null @@ -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( {}} 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( {}} results={selectedItems} onDismiss={() => {}} />); - - expect(screen.getByPlaceholderText('Type "delete" to confirm')).toBeInTheDocument(); - }); - }); -}); diff --git a/public/app/features/search/page/components/ConfirmDeleteModal.tsx b/public/app/features/search/page/components/ConfirmDeleteModal.tsx deleted file mode 100644 index ec7b7808b8e..00000000000 --- a/public/app/features/search/page/components/ConfirmDeleteModal.tsx +++ /dev/null @@ -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>; - 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 ( - - {text} {subtitle &&
{subtitle}
} - - } - 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)}; - `, -}); diff --git a/public/app/features/search/page/components/FolderSection.test.tsx b/public/app/features/search/page/components/FolderSection.test.tsx deleted file mode 100644 index cd2d3483715..00000000000 --- a/public/app/features/search/page/components/FolderSection.test.tsx +++ /dev/null @@ -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(emptySearchData), - }; - - beforeAll(() => { - grafanaSearcherSpy = jest.spyOn(getGrafanaSearcher(), 'search').mockResolvedValue(mockSearchResult); - }); - - it('shows the folder title as the header', async () => { - render(); - 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(); - 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(); - 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(); - - 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(searchData), - }; - - beforeAll(() => { - grafanaSearcherSpy = jest.spyOn(getGrafanaSearcher(), 'search').mockResolvedValue(mockSearchResult); - }); - - it('shows the folder title as the header', async () => { - render(); - 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(); - 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(); - 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(); - - 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( - - ); - - 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( - - ); - - 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( - - ); - - 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(); - - 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(); - }); - }); - }); -}); diff --git a/public/app/features/search/page/components/FolderSection.tsx b/public/app/features/search/page/components/FolderSection.tsx deleted file mode 100644 index 7183143594e..00000000000 --- a/public/app/features/search/page/components/FolderSection.tsx +++ /dev/null @@ -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) => 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 { - 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 ( - - No results found - - ); - } - - return results.value.map((item) => { - return ( - selectionToggle?.(item.kind, item.uid)} - editable={Boolean(selection != null)} - onClickItem={onClickItem} - isSelected={selection?.(item.kind, item.uid)} - /> - ); - }); - }; - - // Skip the folder wrapper - if (renderStandaloneBody) { - return ( -
- {!results.value?.length && results.loading ? : renderResults()} -
- ); - } - - return ( - - {selectionToggle && selection && ( - // TODO: fix keyboard a11y - // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -
- -
- )} - -
- -
- -
- {section.title} - {section.url && section.uid !== GENERAL_FOLDER_UID && ( - - | {' '} - {t('search.folder-view.go-to-folder', 'Go to folder')} - - )} -
- - } - > - {results.value &&
    {renderResults()}
} -
- ); -}; - -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), - }), - }; -}; diff --git a/public/app/features/search/page/components/ManageActions.test.tsx b/public/app/features/search/page/components/ManageActions.test.tsx deleted file mode 100644 index cd78a0c0899..00000000000 --- a/public/app/features/search/page/components/ManageActions.test.tsx +++ /dev/null @@ -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( - - - - ); - 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( - - - - ); - 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( - - - - ); - 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( - - - - ); - expect(screen.getByTestId('manage-actions')).toBeInTheDocument(); - expect(await screen.findByRole('button', { name: 'Delete', hidden: true })).toBeDisabled(); - }); - }); -}); diff --git a/public/app/features/search/page/components/ManageActions.tsx b/public/app/features/search/page/components/ManageActions.tsx deleted file mode 100644 index 2e68ac9b6cb..00000000000 --- a/public/app/features/search/page/components/ManageActions.tsx +++ /dev/null @@ -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>; - 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 ( -
- - - - - - - {isDeleteModalOpen && ( - setIsDeleteModalOpen(false)} /> - )} - - {isMoveModalOpen && ( - setIsMoveModalOpen(false)} /> - )} -
- ); -} diff --git a/public/app/features/search/page/components/MoveToFolderModal.test.tsx b/public/app/features/search/page/components/MoveToFolderModal.test.tsx deleted file mode 100644 index 3da2968e8d2..00000000000 --- a/public/app/features/search/page/components/MoveToFolderModal.test.tsx +++ /dev/null @@ -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( - - {}} /> - - ); - - // 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( - - {}} /> - - ); - - // 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( - - {}} /> - - ); - - // 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', - }); - }); - }); -}); diff --git a/public/app/features/search/page/components/MoveToFolderModal.tsx b/public/app/features/search/page/components/MoveToFolderModal.tsx deleted file mode 100644 index e0451bf6e6c..00000000000 --- a/public/app/features/search/page/components/MoveToFolderModal.tsx +++ /dev/null @@ -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>; - onDismiss: () => void; -} - -export const MoveToFolderModal = ({ results, onMoveItems, onDismiss }: Props) => { - const [folder, setFolder] = useState(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 ( - - <> -
- {nestedFoldersEnabled && selectedFolders.length > 0 && ( - - )} - -

Move {thingsMoving} to:

- - -
- - - - - - -
- ); -}; - -interface NotifyCounts { - selectedDashboardsCount: number; - selectedFoldersCount: number; - totalCount: number; - successCount: number; -} - -function notifyNestedMoveResult( - notifyApp: ReturnType, - 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)}; - `, - }; -}; diff --git a/public/app/features/search/page/components/RootFolderView.test.tsx b/public/app/features/search/page/components/RootFolderView.test.tsx deleted file mode 100644 index d49324a7bc1..00000000000 --- a/public/app/features/search/page/components/RootFolderView.test.tsx +++ /dev/null @@ -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(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( - - ); - 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( - - ); - 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( - - ); - 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( - - ); - 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( - - ); - expect(await screen.findByRole('button', { name: 'Recent' })).toBeInTheDocument(); - }); - - it('shows the general folder by default', async () => { - render( - - ); - 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( - - ); - 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( - - ); - 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( - - ); - 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( - - ); - expect(await screen.findByRole('alert', { name: 'Something went wrong' })).toBeInTheDocument(); - }); -}); diff --git a/public/app/features/search/page/components/RootFolderView.tsx b/public/app/features/search/page/components/RootFolderView.tsx deleted file mode 100644 index 1a311e0ac68..00000000000 --- a/public/app/features/search/page/components/RootFolderView.tsx +++ /dev/null @@ -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 & { - 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 ; - } else if (!results.value) { - return ; - } else { - return results.value.map((section) => ( -
- {section.title && ( - - )} -
- )); - } - }; - - return
{renderResults()}
; -}; - -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; - `, - }; -}; diff --git a/public/app/features/search/page/components/SearchResultsCards.test.tsx b/public/app/features/search/page/components/SearchResultsCards.test.tsx deleted file mode 100644 index 941a8035464..00000000000 --- a/public/app/features/search/page/components/SearchResultsCards.test.tsx +++ /dev/null @@ -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(); - - 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(searchData), - }; - - beforeAll(() => { - jest.spyOn(getGrafanaSearcher(), 'search').mockResolvedValue(mockSearchResult); - }); - - it('shows the list with the correct accessible label', () => { - render( - - ); - expect(screen.getByRole('list', { name: 'Search results list' })).toBeInTheDocument(); - }); - - it('displays the data correctly in the table', () => { - render( - - ); - - 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(emptySearchData), - }; - - beforeAll(() => { - jest.spyOn(getGrafanaSearcher(), 'search').mockResolvedValue(mockEmptySearchResult); - }); - - it('shows a "No data" message', () => { - render( - - ); - expect(screen.queryByRole('list', { name: 'Search results list' })).not.toBeInTheDocument(); - expect(screen.getByText('No data')).toBeInTheDocument(); - }); - }); -}); diff --git a/public/app/features/search/page/components/SearchResultsCards.tsx b/public/app/features/search/page/components/SearchResultsCards.tsx deleted file mode 100644 index 0bd0c509001..00000000000 --- a/public/app/features/search/page/components/SearchResultsCards.tsx +++ /dev/null @@ -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(null); - const [listEl, setListEl] = useState(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 ( -
- { - if (selectionToggle) { - selectionToggle('dashboard', item.uid!); - } - }} - editable={Boolean(selection != null)} - onClickItem={onClickItem} - isSelected={isSelected} - /> -
- ); - }, - [response.view, highlightIndex, styles, onTagSelected, selection, selectionToggle, onClickItem] - ); - - if (!response.totalRows) { - return ( -
- No data -
- ); - } - - return ( -
- - {({ onItemsRendered, ref }) => ( - { - ref(innerRef); - setListEl(innerRef); - }} - onItemsRendered={onItemsRendered} - height={height} - itemCount={response.totalRows} - itemSize={72} - width="100%" - style={{ overflow: 'hidden auto' }} - > - {RenderRow} - - )} - -
- ); - } -); -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}; - `, - }; -}; diff --git a/public/app/features/search/page/components/SearchView.test.tsx b/public/app/features/search/page/components/SearchView.test.tsx deleted file mode 100644 index e2808e1a62d..00000000000 --- a/public/app/features/search/page/components/SearchView.test.tsx +++ /dev/null @@ -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, stateOverrides?: Partial) => { - const props: SearchViewProps = { - showManage: false, - keyboardEvents: {} as Observable, - ...propOverrides, - }; - - stateManager.setState({ ...initialState, ...stateOverrides }); - - const mockStore = configureMockStore(); - const store = mockStore({ searchQuery: { ...initialState } }); - - render( - - - - ); -}; - -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(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({ 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({ 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({ 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(); - }); - }); -}); diff --git a/public/app/features/search/page/components/SearchView.tsx b/public/app/features/search/page/components/SearchView.tsx deleted file mode 100644 index bc89febeb22..00000000000 --- a/public/app/features/search/page/components/SearchView.tsx +++ /dev/null @@ -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; -}; - -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 ; - } - - return ( -
-
- No results found for your query. -
-
- -
- ); - } - - const selection = showManage ? searchSelection.isSelected : undefined; - - if (layout === SearchLayout.Folders) { - if (folderDTO) { - return ( - - ); - } - return ( - - ); - } - - return ( -
- - {({ 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 ; - } - - return ; - }} - -
- ); - }; - - 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 ( - - ); - } - - return ( - <> - {Boolean(searchSelection.items.size > 0) ? ( - - ) : ( - - )} - - {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 }; -} diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index 415c0fb9af9..ad175440b2e 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -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') ), }, { diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index 81bc2812917..4b9e4b2fa0d 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -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" diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index db5fa55c05e..33ef5f29eaf 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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" diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 8ca611c38c7..c79d6c2442e 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -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" diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 6dc7d3cae03..bd64c9a5295 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -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" diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index bff14de3d53..77be386cb16 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -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": "Ŝĥäřę" diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index 1cd5fe36376..a6594545607 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -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": "分享"