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