mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
96facbdfa2
commit
ebe13a53f7
@ -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
|
||||
|
||||
|
@ -122,4 +122,5 @@ export interface FeatureToggles {
|
||||
angularDeprecationUI?: boolean;
|
||||
dashgpt?: boolean;
|
||||
reportingRetries?: boolean;
|
||||
newBrowseDashboards?: boolean;
|
||||
}
|
||||
|
@ -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,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
@ -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
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
@ -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:
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
|
@ -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 };
|
||||
|
@ -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', {
|
||||
|
@ -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]);
|
||||
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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) => {
|
||||
|
5
public/app/features/browse-dashboards/featureFlag.ts
Normal file
5
public/app/features/browse-dashboards/featureFlag.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
export function newBrowseDashboardsEnabled() {
|
||||
return config.featureToggles.nestedFolders || config.featureToggles.newBrowseDashboards;
|
||||
}
|
@ -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
|
||||
|
@ -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) => {
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
|
@ -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,
|
||||
|
@ -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} />;
|
||||
}
|
||||
|
||||
|
@ -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()
|
||||
|
@ -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')
|
||||
|
@ -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 {
|
||||
|
@ -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": "",
|
||||
|
@ -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...",
|
||||
|
@ -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": "",
|
||||
|
@ -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": "",
|
||||
|
@ -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ģ...",
|
||||
|
@ -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": "",
|
||||
|
Loading…
Reference in New Issue
Block a user