mirror of https://github.com/grafana/grafana.git
				
				
				
			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() {
 | 
			
		|||
    );
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className={styles.row} data-testid="manage-actions">
 | 
			
		||||
      <Button onClick={showMoveModal} variant="secondary">
 | 
			
		||||
  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">
 | 
			
		||||
      {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,6 +34,15 @@ describe('browse-dashboards FolderActionsButton', () => {
 | 
			
		|||
    jest.restoreAllMocks();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  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} />);
 | 
			
		||||
| 
						 | 
				
			
			@ -126,4 +136,89 @@ describe('browse-dashboards FolderActionsButton', () => {
 | 
			
		|||
        )
 | 
			
		||||
      );
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  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 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('does not render a "Move" button even if it has permissions', async () => {
 | 
			
		||||
      render(<FolderActionsButton folder={mockFolder} />);
 | 
			
		||||
 | 
			
		||||
      await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
 | 
			
		||||
      expect(screen.queryByRole('menuitem', { name: 'Move' })).not.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: '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: '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.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.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 "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) => {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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,8 +36,7 @@ const saveDashboard = async (
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    return query.data;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  } else {
 | 
			
		||||
    let folderUid = options.folderUid;
 | 
			
		||||
    if (folderUid === undefined) {
 | 
			
		||||
      folderUid = dashboard.meta.folderUid ?? saveModel.folderUid;
 | 
			
		||||
| 
						 | 
				
			
			@ -46,6 +46,7 @@ const saveDashboard = async (
 | 
			
		|||
    // 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