Browse Dashboards: Split new browse UI from nested folders backend (#74435)

* create new feature toggle + start to put stuff behind it

* block move, tidy up interfaces

* fix new/folder actions buttons

* show warning when deleting library panels/alert rules + run i18n:extract

* pseudo

* update unit tests

* pass alert in description
This commit is contained in:
Ashley Harrison 2023-09-07 11:41:00 +01:00 committed by GitHub
parent 96facbdfa2
commit ebe13a53f7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 317 additions and 132 deletions

View File

@ -28,7 +28,7 @@ Some features are enabled by default. You can disable these feature by setting t
| `redshiftAsyncQueryDataSupport` | Enable async query data support for Redshift | Yes |
| `athenaAsyncQueryDataSupport` | Enable async query data support for Athena | Yes |
| `newPanelChromeUI` | Show updated look and feel of grafana-ui PanelChrome: panel header, icons, and menu | Yes |
| `nestedFolderPicker` | Enables the new folder picker to work with nested folders. Requires the folderPicker feature flag | Yes |
| `nestedFolderPicker` | Enables the new folder picker to work with nested folders. Requires the nestedFolders feature flag | Yes |
| `accessTokenExpirationCheck` | Enable OAuth access_token expiration check and token refresh using the refresh_token | |
| `emptyDashboardPage` | Enable the redesigned user interface of a dashboard page that includes no panels | Yes |
| `disablePrometheusExemplarSampling` | Disable Prometheus exemplar sampling | |
@ -72,6 +72,7 @@ Some features are enabled by default. You can disable these feature by setting t
| `sqlDatasourceDatabaseSelection` | Enables previous SQL data source dataset dropdown behavior |
| `splitScopes` | Support faster dashboard and folder search by splitting permission scopes into parts |
| `reportingRetries` | Enables rendering retries for the reporting feature |
| `newBrowseDashboards` | New browse/manage dashboards UI |
## Experimental feature toggles

View File

@ -122,4 +122,5 @@ export interface FeatureToggles {
angularDeprecationUI?: boolean;
dashgpt?: boolean;
reportingRetries?: boolean;
newBrowseDashboards?: boolean;
}

View File

@ -230,7 +230,7 @@ var (
},
{
Name: "nestedFolderPicker",
Description: "Enables the new folder picker to work with nested folders. Requires the folderPicker feature flag",
Description: "Enables the new folder picker to work with nested folders. Requires the nestedFolders feature flag",
Stage: FeatureStageGeneralAvailability,
Owner: grafanaFrontendPlatformSquad,
FrontendOnly: true,
@ -724,5 +724,12 @@ var (
Owner: grafanaSharingSquad,
RequiresRestart: true,
},
{
Name: "newBrowseDashboards",
Description: "New browse/manage dashboards UI",
Stage: FeatureStagePublicPreview,
Owner: grafanaFrontendPlatformSquad,
FrontendOnly: true,
},
}
)

View File

@ -103,3 +103,4 @@ alertingNoDataErrorExecution,privatePreview,@grafana/alerting-squad,false,false,
angularDeprecationUI,experimental,@grafana/plugins-platform-backend,false,false,false,true
dashgpt,experimental,@grafana/dashboards-squad,false,false,false,true
reportingRetries,preview,@grafana/sharing-squad,false,false,true,false
newBrowseDashboards,preview,@grafana/grafana-frontend-platform,false,false,false,true

1 Name Stage Owner requiresDevMode RequiresLicense RequiresRestart FrontendOnly
103 angularDeprecationUI experimental @grafana/plugins-platform-backend false false false true
104 dashgpt experimental @grafana/dashboards-squad false false false true
105 reportingRetries preview @grafana/sharing-squad false false true false
106 newBrowseDashboards preview @grafana/grafana-frontend-platform false false false true

View File

@ -140,7 +140,7 @@ const (
FlagNestedFolders = "nestedFolders"
// FlagNestedFolderPicker
// Enables the new folder picker to work with nested folders. Requires the folderPicker feature flag
// Enables the new folder picker to work with nested folders. Requires the nestedFolders feature flag
FlagNestedFolderPicker = "nestedFolderPicker"
// FlagAccessTokenExpirationCheck
@ -422,4 +422,8 @@ const (
// FlagReportingRetries
// Enables rendering retries for the reporting feature
FlagReportingRetries = "reportingRetries"
// FlagNewBrowseDashboards
// New browse/manage dashboards UI
FlagNewBrowseDashboards = "newBrowseDashboards"
)

View File

@ -4,12 +4,12 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Space } from '@grafana/experimental';
import { config } from '@grafana/runtime';
import { Button, useStyles2 } from '@grafana/ui';
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';
@ -140,7 +140,7 @@ export const Permissions = ({
<div>
{canSetPermissions && (
<>
{config.featureToggles.nestedFolders && resource === 'folders' && (
{newBrowseDashboardsEnabled() && resource === 'folders' && (
<>
<Trans i18nKey="access-control.permissions.permissions-change-warning">
This will change permissions for this folder and all its descendants. In total, this will affect:

View File

@ -5,11 +5,12 @@ import { useAsync } from 'react-use';
import { AppEvents, SelectableValue, GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { config, reportInteraction } from '@grafana/runtime';
import { reportInteraction } from '@grafana/runtime';
import { useStyles2, ActionMeta, Input, InputActionMeta, AsyncVirtualizedSelect } from '@grafana/ui';
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';
@ -80,7 +81,7 @@ export function OldFolderPicker(props: Props) {
folderWarning,
} = props;
const rootName = rootNameProp ?? config.featureToggles.nestedFolders ? 'Dashboards' : 'General';
const rootName = rootNameProp ?? newBrowseDashboardsEnabled() ? 'Dashboards' : 'General';
const [folder, setFolder] = useState<SelectedFolder | null>(null);
const [isCreatingNew, setIsCreatingNew] = useState(false);

View File

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

View File

@ -173,12 +173,11 @@ export const browseDashboardsAPI = createApi({
};
for (const folderCounts of results) {
totalCounts.folder += folderCounts.folder;
// TODO remove nullish coalescing once nestedFolders is toggled on
totalCounts.folder += folderCounts.folder ?? 0;
totalCounts.dashboard += folderCounts.dashboard;
totalCounts.alertRule += folderCounts.alertrule ?? 0;
// TODO enable these once the backend correctly returns them
// totalCounts.libraryPanel += folderCounts.libraryPanel;
totalCounts.alertRule += folderCounts.alertrule;
totalCounts.libraryPanel += folderCounts.librarypanel;
}
return { data: totalCounts };

View File

@ -1,4 +1,4 @@
import { getBackendSrv } from '@grafana/runtime';
import { config, getBackendSrv } from '@grafana/runtime';
import { GENERAL_FOLDER_UID } from 'app/features/search/constants';
import { getGrafanaSearcher, NestedFolderDTO } from 'app/features/search/service';
import { queryResultToViewItem } from 'app/features/search/service/utils';
@ -12,6 +12,10 @@ export async function listFolders(
page = 1,
pageSize = PAGE_SIZE
): Promise<DashboardViewItem[]> {
if (parentUID && !config.featureToggles.nestedFolders) {
return [];
}
const backendSrv = getBackendSrv();
const folders = await backendSrv.get<NestedFolderDTO[]>('/api/folders', {

View File

@ -1,11 +1,11 @@
import { css } from '@emotion/css';
import React from 'react';
import React, { useMemo } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { Button, useStyles2 } from '@grafana/ui';
import { config, reportInteraction } from '@grafana/runtime';
import { Button, Tooltip, useStyles2 } from '@grafana/ui';
import appEvents from 'app/core/app_events';
import { Trans } from 'app/core/internationalization';
import { t, Trans } from 'app/core/internationalization';
import { useSearchStateManager } from 'app/features/search/state/SearchStateManager';
import { useDispatch } from 'app/types';
import { ShowModalReactEvent } from 'app/types/events';
@ -27,6 +27,12 @@ export function BrowseActions() {
const [moveItems] = useMoveItemsMutation();
const [, stateManager] = useSearchStateManager();
// Folders can only be moved if nested folders is enabled
const moveIsInvalid = useMemo(
() => !config.featureToggles.nestedFolders && Object.values(selectedItems.folder).some((v) => v),
[selectedItems]
);
const isSearching = stateManager.hasSearchFilters();
const onActionComplete = () => {
@ -74,11 +80,21 @@ export function BrowseActions() {
);
};
const moveButton = (
<Button onClick={showMoveModal} variant="secondary" disabled={moveIsInvalid}>
<Trans i18nKey="browse-dashboards.action.move-button">Move</Trans>
</Button>
);
return (
<div className={styles.row} data-testid="manage-actions">
<Button onClick={showMoveModal} variant="secondary">
<Trans i18nKey="browse-dashboards.action.move-button">Move</Trans>
</Button>
{moveIsInvalid ? (
<Tooltip content={t('browse-dashboards.action.cannot-move-folders', 'Folders cannot be moved')}>
{moveButton}
</Tooltip>
) : (
moveButton
)}
<Button onClick={showDeleteModal} variant="destructive">
<Trans i18nKey="browse-dashboards.action.delete-button">Delete</Trans>
@ -96,13 +112,12 @@ const getStyles = (theme: GrafanaTheme2) => ({
}),
});
type actionType = 'move' | 'delete';
const actionMap: Record<actionType, string> = {
const actionMap = {
move: 'grafana_manage_dashboards_item_moved',
delete: 'grafana_manage_dashboards_item_deleted',
};
} as const;
function trackAction(action: actionType, selectedItems: Omit<DashboardTreeSelection, 'panel' | '$all'>) {
function trackAction(action: keyof typeof actionMap, selectedItems: Omit<DashboardTreeSelection, 'panel' | '$all'>) {
const selectedDashboards = Object.keys(selectedItems.dashboard).filter((uid) => selectedItems.dashboard[uid]);
const selectedFolders = Object.keys(selectedItems.folder).filter((uid) => selectedItems.folder[uid]);

View File

@ -1,9 +1,11 @@
import React, { useState } from 'react';
import { Space } from '@grafana/experimental';
import { ConfirmModal, Text } from '@grafana/ui';
import { config } from '@grafana/runtime';
import { Alert, ConfirmModal, Text } from '@grafana/ui';
import { Trans, t } from 'app/core/internationalization';
import { useGetAffectedItemsQuery } from '../../api/browseDashboardsAPI';
import { DashboardTreeSelection } from '../../types';
import { DescendantCount } from './DescendantCount';
@ -16,6 +18,8 @@ export interface Props {
}
export const DeleteModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Props) => {
const { data } = useGetAffectedItemsQuery(selectedItems);
const deleteIsInvalid = !config.featureToggles.nestedFolders && data && (data.alertRule || data.libraryPanel);
const [isDeleting, setIsDeleting] = useState(false);
const onDelete = async () => {
setIsDeleting(true);
@ -41,6 +45,20 @@ export const DeleteModal = ({ onConfirm, onDismiss, selectedItems, ...props }: P
<Space v={2} />
</>
}
description={
<>
{deleteIsInvalid ? (
<Alert
severity="warning"
title={t('browse-dashboards.action.delete-modal-invalid-title', 'Cannot delete folder')}
>
<Trans i18nKey="browse-dashboards.action.delete-modal-invalid-text">
One or more folders contain library panels or alert rules. Delete these first in order to proceed.
</Trans>
</Alert>
) : null}
</>
}
confirmationText="Delete"
confirmText={
isDeleting

View File

@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
import React from 'react';
import { TestProvider } from 'test/helpers/TestProvider';
import { config } from '@grafana/runtime';
import { appEvents, contextSrv } from 'app/core/core';
import { AccessControlAction } from 'app/types';
import { ShowModalReactEvent } from 'app/types/events';
@ -33,97 +34,191 @@ describe('browse-dashboards FolderActionsButton', () => {
jest.restoreAllMocks();
});
it('does not render anything when the user has no permissions to do anything', () => {
jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false);
render(<FolderActionsButton folder={mockFolder} />);
expect(screen.queryByRole('button', { name: 'Folder actions' })).not.toBeInTheDocument();
describe('with nestedFolders enabled', () => {
beforeAll(() => {
config.featureToggles.nestedFolders = true;
});
afterAll(() => {
config.featureToggles.nestedFolders = false;
});
it('does not render anything when the user has no permissions to do anything', () => {
jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false);
render(<FolderActionsButton folder={mockFolder} />);
expect(screen.queryByRole('button', { name: 'Folder actions' })).not.toBeInTheDocument();
});
it('renders a "Folder actions" button when the user has permissions to do something', () => {
render(<FolderActionsButton folder={mockFolder} />);
expect(screen.getByRole('button', { name: 'Folder actions' })).toBeInTheDocument();
});
it('renders all the options if the user has full permissions', async () => {
render(<FolderActionsButton folder={mockFolder} />);
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
expect(screen.getByRole('menuitem', { name: 'Manage permissions' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Move' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Delete' })).toBeInTheDocument();
});
it('does not render the "Manage permissions" option if the user does not have permission to view permissions', async () => {
jest
.spyOn(contextSrv, 'hasPermission')
.mockImplementation((permission: string) => permission !== AccessControlAction.FoldersPermissionsRead);
render(<FolderActionsButton folder={mockFolder} />);
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
expect(screen.queryByRole('menuitem', { name: 'Manage permissions' })).not.toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Move' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Delete' })).toBeInTheDocument();
});
it('does not render the "Move" option if the user does not have permission to edit', async () => {
jest
.spyOn(contextSrv, 'hasPermission')
.mockImplementation((permission: string) => permission !== AccessControlAction.FoldersWrite);
render(<FolderActionsButton folder={mockFolder} />);
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
expect(screen.getByRole('menuitem', { name: 'Manage permissions' })).toBeInTheDocument();
expect(screen.queryByRole('menuitem', { name: 'Move' })).not.toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Delete' })).toBeInTheDocument();
});
it('does not render the "Delete" option if the user does not have permission to delete', async () => {
jest
.spyOn(contextSrv, 'hasPermission')
.mockImplementation((permission: string) => permission !== AccessControlAction.FoldersDelete);
render(<FolderActionsButton folder={mockFolder} />);
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
expect(screen.getByRole('menuitem', { name: 'Manage permissions' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Move' })).toBeInTheDocument();
expect(screen.queryByRole('menuitem', { name: 'Delete' })).not.toBeInTheDocument();
});
it('clicking the "Manage permissions" option opens the permissions drawer', async () => {
render(<FolderActionsButton folder={mockFolder} />);
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
await userEvent.click(screen.getByRole('menuitem', { name: 'Manage permissions' }));
expect(screen.getByRole('dialog', { name: 'Drawer title Manage permissions' })).toBeInTheDocument();
});
it('clicking the "Move" option opens the move modal', async () => {
jest.spyOn(appEvents, 'publish');
render(<FolderActionsButton folder={mockFolder} />);
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
await userEvent.click(screen.getByRole('menuitem', { name: 'Move' }));
expect(appEvents.publish).toHaveBeenCalledWith(
new ShowModalReactEvent(
expect.objectContaining({
component: MoveModal,
})
)
);
});
it('clicking the "Delete" option opens the delete modal', async () => {
jest.spyOn(appEvents, 'publish');
render(<FolderActionsButton folder={mockFolder} />);
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
await userEvent.click(screen.getByRole('menuitem', { name: 'Delete' }));
expect(appEvents.publish).toHaveBeenCalledWith(
new ShowModalReactEvent(
expect.objectContaining({
component: DeleteModal,
})
)
);
});
});
it('renders a "Folder actions" button when the user has permissions to do something', () => {
render(<FolderActionsButton folder={mockFolder} />);
expect(screen.getByRole('button', { name: 'Folder actions' })).toBeInTheDocument();
});
describe('with nestedFolders disabled', () => {
it('does not render anything when the user has no permissions to do anything', () => {
jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false);
render(<FolderActionsButton folder={mockFolder} />);
expect(screen.queryByRole('button', { name: 'Folder actions' })).not.toBeInTheDocument();
});
it('renders all the options if the user has full permissions', async () => {
render(<FolderActionsButton folder={mockFolder} />);
it('renders a "Folder actions" button when the user has permissions to do something', () => {
render(<FolderActionsButton folder={mockFolder} />);
expect(screen.getByRole('button', { name: 'Folder actions' })).toBeInTheDocument();
});
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
expect(screen.getByRole('menuitem', { name: 'Manage permissions' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Move' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Delete' })).toBeInTheDocument();
});
it('does not render a "Move" button even if it has permissions', async () => {
render(<FolderActionsButton folder={mockFolder} />);
it('does not render the "Manage permissions" option if the user does not have permission to view permissions', async () => {
jest
.spyOn(contextSrv, 'hasPermission')
.mockImplementation((permission: string) => permission !== AccessControlAction.FoldersPermissionsRead);
render(<FolderActionsButton folder={mockFolder} />);
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
expect(screen.queryByRole('menuitem', { name: 'Move' })).not.toBeInTheDocument();
});
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
expect(screen.queryByRole('menuitem', { name: 'Manage permissions' })).not.toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Move' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Delete' })).toBeInTheDocument();
});
it('renders all the options if the user has full permissions', async () => {
render(<FolderActionsButton folder={mockFolder} />);
it('does not render the "Move" option if the user does not have permission to edit', async () => {
jest
.spyOn(contextSrv, 'hasPermission')
.mockImplementation((permission: string) => permission !== AccessControlAction.FoldersWrite);
render(<FolderActionsButton folder={mockFolder} />);
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
expect(screen.getByRole('menuitem', { name: 'Manage permissions' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Delete' })).toBeInTheDocument();
});
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
expect(screen.getByRole('menuitem', { name: 'Manage permissions' })).toBeInTheDocument();
expect(screen.queryByRole('menuitem', { name: 'Move' })).not.toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Delete' })).toBeInTheDocument();
});
it('does not render the "Manage permissions" option if the user does not have permission to view permissions', async () => {
jest
.spyOn(contextSrv, 'hasPermission')
.mockImplementation((permission: string) => permission !== AccessControlAction.FoldersPermissionsRead);
render(<FolderActionsButton folder={mockFolder} />);
it('does not render the "Delete" option if the user does not have permission to delete', async () => {
jest
.spyOn(contextSrv, 'hasPermission')
.mockImplementation((permission: string) => permission !== AccessControlAction.FoldersDelete);
render(<FolderActionsButton folder={mockFolder} />);
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
expect(screen.queryByRole('menuitem', { name: 'Manage permissions' })).not.toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Delete' })).toBeInTheDocument();
});
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
expect(screen.getByRole('menuitem', { name: 'Manage permissions' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Move' })).toBeInTheDocument();
expect(screen.queryByRole('menuitem', { name: 'Delete' })).not.toBeInTheDocument();
});
it('does not render the "Move" option if the user does not have permission to edit', async () => {
jest
.spyOn(contextSrv, 'hasPermission')
.mockImplementation((permission: string) => permission !== AccessControlAction.FoldersWrite);
render(<FolderActionsButton folder={mockFolder} />);
it('clicking the "Manage permissions" option opens the permissions drawer', async () => {
render(<FolderActionsButton folder={mockFolder} />);
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
expect(screen.getByRole('menuitem', { name: 'Manage permissions' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Delete' })).toBeInTheDocument();
});
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
await userEvent.click(screen.getByRole('menuitem', { name: 'Manage permissions' }));
expect(screen.getByRole('dialog', { name: 'Drawer title Manage permissions' })).toBeInTheDocument();
});
it('does not render the "Delete" option if the user does not have permission to delete', async () => {
jest
.spyOn(contextSrv, 'hasPermission')
.mockImplementation((permission: string) => permission !== AccessControlAction.FoldersDelete);
render(<FolderActionsButton folder={mockFolder} />);
it('clicking the "Move" option opens the move modal', async () => {
jest.spyOn(appEvents, 'publish');
render(<FolderActionsButton folder={mockFolder} />);
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
expect(screen.getByRole('menuitem', { name: 'Manage permissions' })).toBeInTheDocument();
expect(screen.queryByRole('menuitem', { name: 'Delete' })).not.toBeInTheDocument();
});
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
await userEvent.click(screen.getByRole('menuitem', { name: 'Move' }));
expect(appEvents.publish).toHaveBeenCalledWith(
new ShowModalReactEvent(
expect.objectContaining({
component: MoveModal,
})
)
);
});
it('clicking the "Manage permissions" option opens the permissions drawer', async () => {
render(<FolderActionsButton folder={mockFolder} />);
it('clicking the "Delete" option opens the delete modal', async () => {
jest.spyOn(appEvents, 'publish');
render(<FolderActionsButton folder={mockFolder} />);
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
await userEvent.click(screen.getByRole('menuitem', { name: 'Manage permissions' }));
expect(screen.getByRole('dialog', { name: 'Drawer title Manage permissions' })).toBeInTheDocument();
});
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
await userEvent.click(screen.getByRole('menuitem', { name: 'Delete' }));
expect(appEvents.publish).toHaveBeenCalledWith(
new ShowModalReactEvent(
expect.objectContaining({
component: DeleteModal,
})
)
);
it('clicking the "Delete" option opens the delete modal', async () => {
jest.spyOn(appEvents, 'publish');
render(<FolderActionsButton folder={mockFolder} />);
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
await userEvent.click(screen.getByRole('menuitem', { name: 'Delete' }));
expect(appEvents.publish).toHaveBeenCalledWith(
new ShowModalReactEvent(
expect.objectContaining({
component: DeleteModal,
})
)
);
});
});
});

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { locationService, reportInteraction } from '@grafana/runtime';
import { config, locationService, reportInteraction } from '@grafana/runtime';
import { Button, Drawer, Dropdown, Icon, Menu, MenuItem } from '@grafana/ui';
import { Permissions } from 'app/core/components/AccessControl';
import { appEvents, contextSrv } from 'app/core/core';
@ -24,7 +24,9 @@ export function FolderActionsButton({ folder }: Props) {
const [deleteFolder] = useDeleteFolderMutation();
const canViewPermissions = contextSrv.hasPermission(AccessControlAction.FoldersPermissionsRead);
const canSetPermissions = contextSrv.hasPermission(AccessControlAction.FoldersPermissionsWrite);
const canMoveFolder = contextSrv.hasPermission(AccessControlAction.FoldersWrite);
// Can only move folders when nestedFolders is enabled
const canMoveFolder =
config.featureToggles.nestedFolders && contextSrv.hasPermission(AccessControlAction.FoldersWrite);
const canDeleteFolder = contextSrv.hasPermission(AccessControlAction.FoldersDelete);
const onMove = async (destinationUID: string) => {

View File

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

View File

@ -1,3 +1,4 @@
import { config } from '@grafana/runtime';
import { contextSrv } from 'app/core/core';
import { AccessControlAction, FolderDTO } from 'app/types';
@ -12,7 +13,13 @@ export function getFolderPermissions(folderDTO?: FolderDTO) {
const canEditInFolderFallback = folderDTO ? folderDTO.canSave : contextSrv.hasEditPermissionInFolders;
const canEditInFolder = checkFolderPermission(AccessControlAction.FoldersWrite, canEditInFolderFallback, folderDTO);
const canCreateFolder = checkFolderPermission(AccessControlAction.FoldersCreate, contextSrv.isEditor);
// Can only create a folder if at root or nestedFolders is enabled and we have permission
const canCreateFolder =
!folderDTO ||
Boolean(
config.featureToggles.nestedFolders &&
checkFolderPermission(AccessControlAction.FoldersCreate, contextSrv.isEditor)
);
const canCreateDashboards = checkFolderPermission(
AccessControlAction.DashboardsCreate,
canEditInFolderFallback || !!folderDTO?.canSave

View File

@ -1,12 +1,13 @@
import { useAsyncFn } from 'react-use';
import { locationUtil } from '@grafana/data';
import { config, locationService, reportInteraction } from '@grafana/runtime';
import { locationService, reportInteraction } from '@grafana/runtime';
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';
@ -22,7 +23,7 @@ const saveDashboard = async (
dashboard: DashboardModel,
saveDashboardRtkQuery: ReturnType<typeof useSaveDashboardMutation>[0]
) => {
if (config.featureToggles.nestedFolders) {
if (newBrowseDashboardsEnabled()) {
const query = await saveDashboardRtkQuery({
dashboard: saveModel,
folderUid: options.folderUid ?? dashboard.meta.folderUid ?? saveModel.meta.folderUid,
@ -35,17 +36,17 @@ const saveDashboard = async (
}
return query.data;
}
} else {
let folderUid = options.folderUid;
if (folderUid === undefined) {
folderUid = dashboard.meta.folderUid ?? saveModel.folderUid;
}
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;
}
const result = await saveDashboardApiCall({ ...options, folderUid, dashboard: saveModel });
// fetch updated access control permissions
await contextSrv.fetchUserPermissions();
return result;
};
export const useDashboardSave = (dashboard: DashboardModel, isCopy = false) => {

View File

@ -14,6 +14,7 @@ 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';
@ -446,7 +447,7 @@ function updateStatePageNavFromProps(props: Props, state: State): State {
const { folderTitle, folderUid } = dashboard.meta;
if (folderUid && pageNav) {
if (config.featureToggles.nestedFolders) {
if (newBrowseDashboardsEnabled()) {
const folderNavModel = getNavModel(navIndex, `folder-dashboards-${folderUid}`).main;
pageNav = {
...pageNav,

View File

@ -6,6 +6,7 @@ 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';
@ -91,7 +92,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 (config.featureToggles.nestedFolders && dashDTO.meta.folderUid) {
if (newBrowseDashboardsEnabled() && dashDTO.meta.folderUid) {
await dispatch(getFolderByUid(dashDTO.meta.folderUid));
}
if (args.fixUrl && dashDTO.meta.url && !playlistSrv.isPlaying) {
@ -114,7 +115,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 (config.featureToggles.nestedFolders && args.urlFolderUid) {
if (newBrowseDashboardsEnabled() && args.urlFolderUid) {
await dispatch(getFolderByUid(args.urlFolderUid));
}
return getNewDashboardModelData(args.urlFolderUid, args.panelType);

View File

@ -3,6 +3,7 @@ import { config } from '@grafana/runtime';
import { getNavSubTitle } from 'app/core/components/AppChrome/MegaMenu/navBarItem-translations';
import { t } from 'app/core/internationalization';
import { contextSrv } from 'app/core/services/context_srv';
import { newBrowseDashboardsEnabled } from 'app/features/browse-dashboards/featureFlag';
import { AccessControlAction, FolderDTO } from 'app/types';
export const FOLDER_ID = 'manage-folder';
@ -55,7 +56,7 @@ export function buildNavModel(folder: FolderDTO, parents = folder.parents): NavM
});
}
if (!config.featureToggles.nestedFolders) {
if (!newBrowseDashboardsEnabled()) {
if (folder.canAdmin) {
model.children!.push({
active: false,

View File

@ -3,10 +3,11 @@ import React, { memo } from 'react';
import { useAsync } from 'react-use';
import { locationUtil, NavModelItem } from '@grafana/data';
import { config, locationService } from '@grafana/runtime';
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';
@ -21,7 +22,7 @@ export interface DashboardListPageRouteParams {
interface Props extends GrafanaRouteComponentProps<DashboardListPageRouteParams> {}
export const DashboardListPageFeatureToggle = memo((props: Props) => {
if (config.featureToggles.nestedFolders) {
if (newBrowseDashboardsEnabled()) {
return <NewBrowseDashboardsPage {...props} />;
}

View File

@ -5,10 +5,10 @@ import AutoSizer from 'react-virtualized-auto-sizer';
import { Observable } from 'rxjs';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
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';
@ -149,7 +149,7 @@ export const SearchView = ({ showManage, folderDTO, hidePseudoFolders, keyboardE
folderDTO &&
// With nested folders, SearchView doesn't know if it's fetched all children
// of a folder so don't show empty state here.
!config.featureToggles.nestedFolders &&
!newBrowseDashboardsEnabled() &&
!state.loading &&
!state.result?.totalRows &&
!stateManager.hasSearchFilters()

View File

@ -10,6 +10,7 @@ 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';
@ -148,13 +149,13 @@ export function getAppRoutes(): RouteDescriptor[] {
() => import(/* webpackChunkName: "NewDashboardsFolder"*/ 'app/features/folders/components/NewDashboardsFolder')
),
},
!config.featureToggles.nestedFolders && {
!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')
@ -461,7 +462,7 @@ export function getAppRoutes(): RouteDescriptor[] {
{
path: '/dashboards/f/:uid/:slug/library-panels',
component: SafeDynamicImport(
config.featureToggles.nestedFolders
newBrowseDashboardsEnabled()
? () =>
import(
/* webpackChunkName: "FolderLibraryPanelsPage"*/ 'app/features/browse-dashboards/BrowseFolderLibraryPanelsPage'
@ -474,7 +475,7 @@ export function getAppRoutes(): RouteDescriptor[] {
path: '/dashboards/f/:uid/:slug/alerting',
roles: () => contextSrv.evaluatePermission([AccessControlAction.AlertingRuleRead]),
component: SafeDynamicImport(
config.featureToggles.nestedFolders
newBrowseDashboardsEnabled()
? () =>
import(/* webpackChunkName: "FolderAlerting"*/ 'app/features/browse-dashboards/BrowseFolderAlertingPage')
: () => import(/* webpackChunkName: "FolderAlerting"*/ 'app/features/folders/FolderAlerting')

View File

@ -35,10 +35,11 @@ export interface FolderState {
}
export interface DescendantCountDTO {
folder: number;
// TODO: make this required once nestedFolders is enabled by default
folder?: number;
dashboard: number;
libraryPanel: number;
alertrule?: number;
librarypanel: number;
alertrule: number;
}
export interface DescendantCount {

View File

@ -26,7 +26,10 @@
"browse-dashboards": {
"action": {
"cancel-button": "",
"cannot-move-folders": "",
"delete-button": "",
"delete-modal-invalid-text": "",
"delete-modal-invalid-title": "",
"delete-modal-text": "",
"delete-modal-title": "",
"deleting": "",

View File

@ -26,7 +26,10 @@
"browse-dashboards": {
"action": {
"cancel-button": "Cancel",
"cannot-move-folders": "Folders can not be moved",
"delete-button": "Delete",
"delete-modal-invalid-text": "One or more folders contain library panels or alert rules. Delete these first in order to proceed.",
"delete-modal-invalid-title": "Cannot delete folder",
"delete-modal-text": "This action will delete the following content:",
"delete-modal-title": "Delete",
"deleting": "Deleting...",

View File

@ -26,7 +26,10 @@
"browse-dashboards": {
"action": {
"cancel-button": "",
"cannot-move-folders": "",
"delete-button": "",
"delete-modal-invalid-text": "",
"delete-modal-invalid-title": "",
"delete-modal-text": "",
"delete-modal-title": "",
"deleting": "",

View File

@ -26,7 +26,10 @@
"browse-dashboards": {
"action": {
"cancel-button": "",
"cannot-move-folders": "",
"delete-button": "",
"delete-modal-invalid-text": "",
"delete-modal-invalid-title": "",
"delete-modal-text": "",
"delete-modal-title": "",
"deleting": "",

View File

@ -26,7 +26,10 @@
"browse-dashboards": {
"action": {
"cancel-button": "Cäʼnčęľ",
"cannot-move-folders": "Főľđęřş čäʼn ʼnőŧ þę mővęđ",
"delete-button": "Đęľęŧę",
"delete-modal-invalid-text": "Øʼnę őř mőřę ƒőľđęřş čőʼnŧäįʼn ľįþřäřy päʼnęľş őř äľęřŧ řūľęş. Đęľęŧę ŧĥęşę ƒįřşŧ įʼn őřđęř ŧő přőčęęđ.",
"delete-modal-invalid-title": "Cäʼnʼnőŧ đęľęŧę ƒőľđęř",
"delete-modal-text": "Ŧĥįş äčŧįőʼn ŵįľľ đęľęŧę ŧĥę ƒőľľőŵįʼnģ čőʼnŧęʼnŧ:",
"delete-modal-title": "Đęľęŧę",
"deleting": "Đęľęŧįʼnģ...",

View File

@ -26,7 +26,10 @@
"browse-dashboards": {
"action": {
"cancel-button": "",
"cannot-move-folders": "",
"delete-button": "",
"delete-modal-invalid-text": "",
"delete-modal-invalid-title": "",
"delete-modal-text": "",
"delete-modal-title": "",
"deleting": "",