[Time to Visualize] Stay in Edit Mode After Dashboard Quicksave (#91729) (#92053)

* Make quicksave function stay in edit mode
This commit is contained in:
Devon Thomson 2021-02-19 14:44:28 -05:00 committed by GitHub
parent dc206e8a5d
commit ee7cb03a25
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 162 additions and 116 deletions

View file

@ -294,6 +294,7 @@ export function DashboardApp({
}}
viewMode={viewMode}
lastDashboardId={savedDashboardId}
clearUnsavedChanges={() => setUnsavedChanges(false)}
timefilter={data.query.timefilter.timefilter}
onQuerySubmit={(_payload, isUpdate) => {
if (isUpdate === false) {

View file

@ -345,7 +345,7 @@ export class DashboardStateManager {
/**
* Resets the state back to the last saved version of the dashboard.
*/
public resetState() {
public resetState(resetViewMode: boolean) {
// In order to show the correct warning, we have to store the unsaved
// title on the dashboard object. We should fix this at some point, but this is how all the other object
// save panels work at the moment.
@ -366,9 +366,14 @@ export class DashboardStateManager {
this.stateDefaults.query = this.lastSavedDashboardFilters.query;
// Need to make a copy to ensure they are not overwritten.
this.stateDefaults.filters = [...this.getLastSavedFilterBars()];
this.isDirty = false;
this.stateContainer.set(this.stateDefaults);
if (resetViewMode) {
this.stateContainer.set(this.stateDefaults);
} else {
const currentViewMode = this.stateContainer.get().viewMode;
this.stateContainer.set({ ...this.stateDefaults, viewMode: currentViewMode });
}
}
/**

View file

@ -11,6 +11,8 @@ import { SavedObjectSaveOpts } from '../../services/saved_objects';
import { updateSavedDashboard } from './update_saved_dashboard';
import { DashboardStateManager } from '../dashboard_state_manager';
export type SavedDashboardSaveOpts = SavedObjectSaveOpts & { stayInEditMode?: boolean };
/**
* Saves the dashboard.
* @param toJson A custom toJson function. Used because the previous code used
@ -23,7 +25,7 @@ export function saveDashboard(
toJson: (obj: any) => string,
timeFilter: TimefilterContract,
dashboardStateManager: DashboardStateManager,
saveOptions: SavedObjectSaveOpts
saveOptions: SavedDashboardSaveOpts
): Promise<string> {
const savedDashboard = dashboardStateManager.savedDashboard;
const appState = dashboardStateManager.appState;
@ -36,7 +38,7 @@ export function saveDashboard(
// reset state only when save() was successful
// e.g. save() could be interrupted if title is duplicated and not confirmed
dashboardStateManager.lastSavedDashboardFilters = dashboardStateManager.getFilterState();
dashboardStateManager.resetState();
dashboardStateManager.resetState(!saveOptions.stayInEditMode);
}
return id;

View file

@ -18,21 +18,23 @@ import {
} from '@elastic/eui';
import React from 'react';
import { OverlayStart } from '../../../../../core/public';
import { createConfirmStrings, leaveConfirmStrings } from '../../dashboard_strings';
import {
createConfirmStrings,
discardConfirmStrings,
leaveEditModeConfirmStrings,
} from '../../dashboard_strings';
import { toMountPoint } from '../../services/kibana_react';
export const confirmDiscardUnsavedChanges = (
overlays: OverlayStart,
discardCallback: () => void,
cancelButtonText = leaveConfirmStrings.getCancelButtonText()
) =>
export type DiscardOrKeepSelection = 'cancel' | 'discard' | 'keep';
export const confirmDiscardUnsavedChanges = (overlays: OverlayStart, discardCallback: () => void) =>
overlays
.openConfirm(leaveConfirmStrings.getDiscardSubtitle(), {
confirmButtonText: leaveConfirmStrings.getConfirmButtonText(),
cancelButtonText,
.openConfirm(discardConfirmStrings.getDiscardSubtitle(), {
confirmButtonText: discardConfirmStrings.getDiscardConfirmButtonText(),
cancelButtonText: discardConfirmStrings.getDiscardCancelButtonText(),
buttonColor: 'danger',
defaultFocusedButton: EUI_MODAL_CANCEL_BUTTON,
title: leaveConfirmStrings.getDiscardTitle(),
title: discardConfirmStrings.getDiscardTitle(),
})
.then((isConfirmed) => {
if (isConfirmed) {
@ -40,8 +42,6 @@ export const confirmDiscardUnsavedChanges = (
}
});
export type DiscardOrKeepSelection = 'cancel' | 'discard' | 'keep';
export const confirmDiscardOrKeepUnsavedChanges = (
overlays: OverlayStart
): Promise<DiscardOrKeepSelection> => {
@ -50,11 +50,13 @@ export const confirmDiscardOrKeepUnsavedChanges = (
toMountPoint(
<>
<EuiModalHeader data-test-subj="dashboardDiscardConfirm">
<EuiModalHeaderTitle>{leaveConfirmStrings.getLeaveEditModeTitle()}</EuiModalHeaderTitle>
<EuiModalHeaderTitle>
{leaveEditModeConfirmStrings.getLeaveEditModeTitle()}
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiText>{leaveConfirmStrings.getLeaveEditModeSubtitle()}</EuiText>
<EuiText>{leaveEditModeConfirmStrings.getLeaveEditModeSubtitle()}</EuiText>
</EuiModalBody>
<EuiModalFooter>
@ -62,19 +64,9 @@ export const confirmDiscardOrKeepUnsavedChanges = (
data-test-subj="dashboardDiscardConfirmCancel"
onClick={() => session.close()}
>
{leaveConfirmStrings.getCancelButtonText()}
{leaveEditModeConfirmStrings.getLeaveEditModeCancelButtonText()}
</EuiButtonEmpty>
<EuiButtonEmpty
data-test-subj="dashboardDiscardConfirmKeep"
onClick={() => {
session.close();
resolve('keep');
}}
>
{leaveConfirmStrings.getKeepChangesText()}
</EuiButtonEmpty>
<EuiButton
fill
color="danger"
data-test-subj="dashboardDiscardConfirmDiscard"
onClick={() => {
@ -82,13 +74,24 @@ export const confirmDiscardOrKeepUnsavedChanges = (
resolve('discard');
}}
>
{leaveConfirmStrings.getConfirmButtonText()}
{leaveEditModeConfirmStrings.getLeaveEditModeDiscardButtonText()}
</EuiButtonEmpty>
<EuiButton
fill
data-test-subj="dashboardDiscardConfirmKeep"
onClick={() => {
session.close();
resolve('keep');
}}
>
{leaveEditModeConfirmStrings.getLeaveEditModeKeepChangesText()}
</EuiButton>
</EuiModalFooter>
</>
),
{
'data-test-subj': 'dashboardDiscardConfirmModal',
maxWidth: 550,
}
);
});

View file

@ -17,11 +17,7 @@ import {
} from '@elastic/eui';
import React, { useCallback, useEffect, useState } from 'react';
import { DashboardSavedObject } from '../..';
import {
createConfirmStrings,
dashboardUnsavedListingStrings,
getNewDashboardTitle,
} from '../../dashboard_strings';
import { dashboardUnsavedListingStrings, getNewDashboardTitle } from '../../dashboard_strings';
import { useKibana } from '../../services/kibana_react';
import { DASHBOARD_PANELS_UNSAVED_ID } from '../lib/dashboard_panel_storage';
import { DashboardAppServices, DashboardRedirect } from '../types';
@ -136,14 +132,10 @@ export const DashboardUnsavedListing = ({
const onDiscard = useCallback(
(id?: string) => {
confirmDiscardUnsavedChanges(
overlays,
() => {
dashboardPanelStorage.clearPanels(id);
refreshUnsavedDashboards();
},
createConfirmStrings.getCancelButtonText()
);
confirmDiscardUnsavedChanges(overlays, () => {
dashboardPanelStorage.clearPanels(id);
refreshUnsavedDashboards();
});
},
[overlays, refreshUnsavedDashboards, dashboardPanelStorage]
);

View file

@ -19,12 +19,7 @@ import {
openAddPanelFlyout,
ViewMode,
} from '../../services/embeddable';
import {
getSavedObjectFinder,
SavedObjectSaveOpts,
SaveResult,
showSaveModal,
} from '../../services/saved_objects';
import { getSavedObjectFinder, SaveResult, showSaveModal } from '../../services/saved_objects';
import { NavAction } from '../../types';
import { DashboardSavedObject } from '../..';
@ -48,6 +43,7 @@ import { OverlayRef } from '../../../../../core/public';
import { getNewDashboardTitle, unsavedChangesBadge } from '../../dashboard_strings';
import { DASHBOARD_PANELS_UNSAVED_ID } from '../lib/dashboard_panel_storage';
import { DashboardContainer } from '..';
import { SavedDashboardSaveOpts } from '../lib/save_dashboard';
export interface DashboardTopNavState {
chromeIsVisible: boolean;
@ -64,13 +60,15 @@ export interface DashboardTopNavProps {
timefilter: TimefilterContract;
indexPatterns: IndexPattern[];
redirectTo: DashboardRedirect;
unsavedChanges?: boolean;
unsavedChanges: boolean;
clearUnsavedChanges: () => void;
lastDashboardId?: string;
viewMode: ViewMode;
}
export function DashboardTopNav({
dashboardStateManager,
clearUnsavedChanges,
dashboardContainer,
lastDashboardId,
unsavedChanges,
@ -98,6 +96,7 @@ export function DashboardTopNav({
} = useKibana<DashboardAppServices>().services;
const [state, setState] = useState<DashboardTopNavState>({ chromeIsVisible: false });
const [isSaveInProgress, setIsSaveInProgress] = useState(false);
useEffect(() => {
const visibleSubscription = chrome.getIsVisible$().subscribe((chromeIsVisible) => {
@ -177,7 +176,7 @@ export function DashboardTopNav({
}
function discardChanges() {
dashboardStateManager.resetState();
dashboardStateManager.resetState(true);
dashboardStateManager.clearUnsavedPanels();
// We need to do a hard reset of the timepicker. appState will not reload like
@ -222,7 +221,7 @@ export function DashboardTopNav({
* @resolved {String} - The id of the doc
*/
const save = useCallback(
async (saveOptions: SavedObjectSaveOpts) => {
async (saveOptions: SavedDashboardSaveOpts) => {
return saveDashboard(angular.toJson, timefilter, dashboardStateManager, saveOptions)
.then(function (id) {
if (id) {
@ -239,7 +238,6 @@ export function DashboardTopNav({
redirectTo({ destination: 'dashboard', id, useReplace: !lastDashboardId });
} else {
chrome.docTitle.change(dashboardStateManager.savedDashboard.lastSavedTitle);
dashboardStateManager.switchViewMode(ViewMode.VIEW);
}
}
return { id };
@ -355,7 +353,8 @@ export function DashboardTopNav({
}
}
save({}).then((response: SaveResult) => {
setIsSaveInProgress(true);
save({ stayInEditMode: true }).then((response: SaveResult) => {
// If the save wasn't successful, put the original values back.
if (!(response as { id: string }).id) {
dashboardStateManager.setTitle(currentTitle);
@ -364,10 +363,13 @@ export function DashboardTopNav({
if (savedObjectsTagging) {
dashboardStateManager.setTags(currentTags);
}
} else {
clearUnsavedChanges();
}
setIsSaveInProgress(false);
return response;
});
}, [save, savedObjectsTagging, dashboardStateManager]);
}, [save, savedObjectsTagging, dashboardStateManager, clearUnsavedChanges]);
const runClone = useCallback(() => {
const currentTitle = dashboardStateManager.getTitle();
@ -467,6 +469,7 @@ export function DashboardTopNav({
hideWriteControls: dashboardCapabilities.hideWriteControls,
isNewDashboard: !savedDashboard.id,
isDirty: dashboardStateManager.isDirty,
isSaveInProgress,
});
const badges = unsavedChanges

View file

@ -10,6 +10,7 @@ import { i18n } from '@kbn/i18n';
import { ViewMode } from '../../services/embeddable';
import { TopNavIds } from './top_nav_ids';
import { NavAction } from '../../types';
import { TopNavMenuData } from '../../../../navigation/public';
/**
* @param actions - A mapping of TopNavIds to an action function that should run when the
@ -20,7 +21,12 @@ import { NavAction } from '../../types';
export function getTopNavConfig(
dashboardMode: ViewMode,
actions: { [key: string]: NavAction },
options: { hideWriteControls: boolean; isNewDashboard: boolean; isDirty: boolean }
options: {
hideWriteControls: boolean;
isNewDashboard: boolean;
isDirty: boolean;
isSaveInProgress?: boolean;
}
) {
switch (dashboardMode) {
case ViewMode.VIEW:
@ -36,20 +42,17 @@ export function getTopNavConfig(
getEditConfig(actions[TopNavIds.ENTER_EDIT_MODE]),
];
case ViewMode.EDIT:
return options.isNewDashboard
? [
getOptionsConfig(actions[TopNavIds.OPTIONS]),
getShareConfig(actions[TopNavIds.SHARE]),
getViewConfig(actions[TopNavIds.EXIT_EDIT_MODE]),
getSaveConfig(actions[TopNavIds.SAVE], options.isNewDashboard),
]
: [
getOptionsConfig(actions[TopNavIds.OPTIONS]),
getShareConfig(actions[TopNavIds.SHARE]),
getViewConfig(actions[TopNavIds.EXIT_EDIT_MODE]),
getSaveConfig(actions[TopNavIds.SAVE]),
getQuickSave(actions[TopNavIds.QUICK_SAVE]),
];
const disableButton = options.isSaveInProgress;
const navItems: TopNavMenuData[] = [
getOptionsConfig(actions[TopNavIds.OPTIONS], disableButton),
getShareConfig(actions[TopNavIds.SHARE], disableButton),
getViewConfig(actions[TopNavIds.EXIT_EDIT_MODE], disableButton),
getSaveConfig(actions[TopNavIds.SAVE], options.isNewDashboard, disableButton),
];
if (!options.isNewDashboard) {
navItems.push(getQuickSave(actions[TopNavIds.QUICK_SAVE], disableButton, options.isDirty));
}
return navItems;
default:
return [];
}
@ -106,9 +109,12 @@ function getEditConfig(action: NavAction) {
/**
* @returns {kbnTopNavConfig}
*/
function getQuickSave(action: NavAction) {
function getQuickSave(action: NavAction, isLoading?: boolean, isDirty?: boolean) {
return {
isLoading,
disableButton: !isDirty,
id: 'quick-save',
iconType: 'save',
emphasize: true,
label: getSaveButtonLabel(),
description: i18n.translate('dashboard.topNave.saveConfigDescription', {
@ -122,10 +128,12 @@ function getQuickSave(action: NavAction) {
/**
* @returns {kbnTopNavConfig}
*/
function getSaveConfig(action: NavAction, isNewDashboard = false) {
function getSaveConfig(action: NavAction, isNewDashboard = false, disableButton?: boolean) {
return {
disableButton,
id: 'save',
label: isNewDashboard ? getSaveButtonLabel() : getSaveAsButtonLabel(),
iconType: isNewDashboard ? 'save' : undefined,
description: i18n.translate('dashboard.topNave.saveAsConfigDescription', {
defaultMessage: 'Save as a new dashboard',
}),
@ -138,11 +146,12 @@ function getSaveConfig(action: NavAction, isNewDashboard = false) {
/**
* @returns {kbnTopNavConfig}
*/
function getViewConfig(action: NavAction) {
function getViewConfig(action: NavAction, disableButton?: boolean) {
return {
disableButton,
id: 'cancel',
label: i18n.translate('dashboard.topNave.cancelButtonAriaLabel', {
defaultMessage: 'cancel',
defaultMessage: 'Return',
}),
description: i18n.translate('dashboard.topNave.viewConfigDescription', {
defaultMessage: 'Switch to view-only mode',
@ -172,7 +181,7 @@ function getCloneConfig(action: NavAction) {
/**
* @returns {kbnTopNavConfig}
*/
function getShareConfig(action: NavAction | undefined) {
function getShareConfig(action: NavAction | undefined, disableButton?: boolean) {
return {
id: 'share',
label: i18n.translate('dashboard.topNave.shareButtonAriaLabel', {
@ -184,15 +193,16 @@ function getShareConfig(action: NavAction | undefined) {
testId: 'shareTopNavButton',
run: action ?? (() => {}),
// disable the Share button if no action specified
disableButton: !action,
disableButton: !action || disableButton,
};
}
/**
* @returns {kbnTopNavConfig}
*/
function getOptionsConfig(action: NavAction) {
function getOptionsConfig(action: NavAction, disableButton?: boolean) {
return {
disableButton,
id: 'options',
label: i18n.translate('dashboard.topNave.optionsButtonAriaLabel', {
defaultMessage: 'options',

View file

@ -199,6 +199,25 @@ export const getNewDashboardTitle = () =>
defaultMessage: 'New Dashboard',
});
export const getDashboard60Warning = () =>
i18n.translate('dashboard.urlWasRemovedInSixZeroWarningMessage', {
defaultMessage: 'The url "dashboard/create" was removed in 6.0. Please update your bookmarks.',
});
export const dashboardReadonlyBadge = {
getText: () =>
i18n.translate('dashboard.badge.readOnly.text', {
defaultMessage: 'Read only',
}),
getTooltip: () =>
i18n.translate('dashboard.badge.readOnly.tooltip', {
defaultMessage: 'Unable to save dashboards',
}),
};
/*
Modals
*/
export const shareModalStrings = {
getTopMenuCheckbox: () =>
i18n.translate('dashboard.embedUrlParamExtension.topMenu', {
@ -222,22 +241,6 @@ export const shareModalStrings = {
}),
};
export const getDashboard60Warning = () =>
i18n.translate('dashboard.urlWasRemovedInSixZeroWarningMessage', {
defaultMessage: 'The url "dashboard/create" was removed in 6.0. Please update your bookmarks.',
});
export const dashboardReadonlyBadge = {
getText: () =>
i18n.translate('dashboard.badge.readOnly.text', {
defaultMessage: 'Read only',
}),
getTooltip: () =>
i18n.translate('dashboard.badge.readOnly.tooltip', {
defaultMessage: 'Unable to save dashboards',
}),
};
export const leaveConfirmStrings = {
getLeaveTitle: () =>
i18n.translate('dashboard.appLeaveConfirmModal.unsavedChangesTitle', {
@ -247,36 +250,54 @@ export const leaveConfirmStrings = {
i18n.translate('dashboard.appLeaveConfirmModal.unsavedChangesSubtitle', {
defaultMessage: 'Leave Dashboard with unsaved work?',
}),
getKeepChangesText: () =>
i18n.translate('dashboard.appLeaveConfirmModal.keepUnsavedChangesButtonLabel', {
defaultMessage: 'Keep unsaved changes',
getLeaveCancelButtonText: () =>
i18n.translate('dashboard.appLeaveConfirmModal.cancelButtonLabel', {
defaultMessage: 'Cancel',
}),
};
export const leaveEditModeConfirmStrings = {
getLeaveEditModeTitle: () =>
i18n.translate('dashboard.changeViewModeConfirmModal.leaveEditMode', {
defaultMessage: 'Leave edit mode with unsaved work?',
i18n.translate('dashboard.changeViewModeConfirmModal.leaveEditModeTitle', {
defaultMessage: 'You have unsaved changes',
}),
getLeaveEditModeSubtitle: () =>
i18n.translate('dashboard.changeViewModeConfirmModal.discardChangesOptionalDescription', {
defaultMessage: `If you discard your changes, there's no getting them back.`,
i18n.translate('dashboard.changeViewModeConfirmModal.description', {
defaultMessage: `You can keep or discard your changes on return to view mode. You can't recover discarded changes.`,
}),
getDiscardTitle: () =>
i18n.translate('dashboard.changeViewModeConfirmModal.discardChangesTitle', {
defaultMessage: 'Discard changes to dashboard?',
getLeaveEditModeKeepChangesText: () =>
i18n.translate('dashboard.changeViewModeConfirmModal.keepUnsavedChangesButtonLabel', {
defaultMessage: 'Keep changes',
}),
getDiscardSubtitle: () =>
i18n.translate('dashboard.changeViewModeConfirmModal.discardChangesDescription', {
defaultMessage: `Once you discard your changes, there's no getting them back.`,
}),
getConfirmButtonText: () =>
getLeaveEditModeDiscardButtonText: () =>
i18n.translate('dashboard.changeViewModeConfirmModal.confirmButtonLabel', {
defaultMessage: 'Discard changes',
}),
getCancelButtonText: () =>
getLeaveEditModeCancelButtonText: () =>
i18n.translate('dashboard.changeViewModeConfirmModal.cancelButtonLabel', {
defaultMessage: 'Continue editing',
}),
};
export const discardConfirmStrings = {
getDiscardTitle: () =>
i18n.translate('dashboard.discardChangesConfirmModal.discardChangesTitle', {
defaultMessage: 'Discard changes to dashboard?',
}),
getDiscardSubtitle: () =>
i18n.translate('dashboard.discardChangesConfirmModal.discardChangesDescription', {
defaultMessage: `Once you discard your changes, there's no getting them back.`,
}),
getDiscardConfirmButtonText: () =>
i18n.translate('dashboard.discardChangesConfirmModal.confirmButtonLabel', {
defaultMessage: 'Discard changes',
}),
getDiscardCancelButtonText: () =>
i18n.translate('dashboard.discardChangesConfirmModal.cancelButtonLabel', {
defaultMessage: 'Cancel',
}),
};
export const createConfirmStrings = {
getCreateTitle: () =>
i18n.translate('dashboard.createConfirmModal.unsavedChangesTitle', {
@ -290,13 +311,20 @@ export const createConfirmStrings = {
i18n.translate('dashboard.createConfirmModal.confirmButtonLabel', {
defaultMessage: 'Start over',
}),
getContinueButtonText: () => leaveConfirmStrings.getCancelButtonText(),
getContinueButtonText: () =>
i18n.translate('dashboard.createConfirmModal.continueButtonLabel', {
defaultMessage: 'Continue editing',
}),
getCancelButtonText: () =>
i18n.translate('dashboard.createConfirmModal.cancelButtonLabel', {
defaultMessage: 'Cancel',
}),
};
/*
Error Messages
*/
export const panelStorageErrorStrings = {
getPanelsGetError: (message: string) =>
i18n.translate('dashboard.panelStorageError.getError', {

View file

@ -20,6 +20,7 @@ export interface TopNavMenuData {
disableButton?: boolean | (() => boolean);
tooltip?: string | (() => string | undefined);
emphasize?: boolean;
isLoading?: boolean;
iconType?: string;
iconSide?: EuiButtonProps['iconSide'];
}

View file

@ -30,6 +30,7 @@ export function TopNavMenuItem(props: TopNavMenuData) {
const commonButtonProps = {
isDisabled: isDisabled(),
onClick: handleClick,
isLoading: props.isLoading,
iconType: props.iconType,
iconSide: props.iconSide,
'data-test-subj': props.testId,

View file

@ -130,7 +130,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.dashboard.clickQuickSave();
await testSubjects.existOrFail('saveDashboardSuccess');
await testSubjects.existOrFail('dashboardEditMode');
});
it('Stays in edit mode after performing a quick save', async function () {
await PageObjects.header.waitUntilLoadingHasFinished();
await testSubjects.existOrFail('dashboardQuickSaveMenuItem');
});
});
}

View file

@ -589,8 +589,6 @@
"dashboard.badge.readOnly.tooltip": "ダッシュボードを保存できません",
"dashboard.changeViewModeConfirmModal.cancelButtonLabel": "編集を続行",
"dashboard.changeViewModeConfirmModal.confirmButtonLabel": "変更を破棄",
"dashboard.changeViewModeConfirmModal.discardChangesDescription": "変更を破棄すると、元に戻すことはできません。",
"dashboard.changeViewModeConfirmModal.discardChangesTitle": "ダッシュボードへの変更を破棄しますか?",
"dashboard.cloneModal.cloneDashboardTitleAriaLabel": "クローンダッシュボードタイトル",
"dashboard.dashboardAppBreadcrumbsTitle": "ダッシュボード",
"dashboard.dashboardGrid.toast.unableToLoadDashboardDangerMessage": "ダッシュボードが読み込めません。",

View file

@ -589,8 +589,6 @@
"dashboard.badge.readOnly.tooltip": "无法保存仪表板",
"dashboard.changeViewModeConfirmModal.cancelButtonLabel": "继续编辑",
"dashboard.changeViewModeConfirmModal.confirmButtonLabel": "放弃更改",
"dashboard.changeViewModeConfirmModal.discardChangesDescription": "放弃更改后,它们将无法恢复。",
"dashboard.changeViewModeConfirmModal.discardChangesTitle": "放弃对仪表板的更改?",
"dashboard.cloneModal.cloneDashboardTitleAriaLabel": "克隆仪表板标题",
"dashboard.dashboardAppBreadcrumbsTitle": "仪表板",
"dashboard.dashboardGrid.toast.unableToLoadDashboardDangerMessage": "无法加载仪表板。",