[Time to Visualize] Unsaved Changes Badge (#91073) (#91540)

* Added unsaved changes badge to dashboards. Removed (unsaved) from the dashboard title
This commit is contained in:
Devon Thomson 2021-02-16 16:01:01 -05:00 committed by GitHub
parent d41df11804
commit f81cdc3ff7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 148 additions and 49 deletions

View file

@ -67,7 +67,13 @@ export function DashboardApp({
savedDashboard,
history
);
const dashboardContainer = useDashboardContainer(dashboardStateManager, history, false);
const [unsavedChanges, setUnsavedChanges] = useState(false);
const dashboardContainer = useDashboardContainer({
timeFilter: data.query.timefilter.timefilter,
dashboardStateManager,
setUnsavedChanges,
history,
});
const searchSessionIdQuery$ = useMemo(
() => createQueryParamObservable(history, DashboardConstants.SEARCH_SESSION_ID),
[history]
@ -200,6 +206,7 @@ export function DashboardApp({
);
dashboardStateManager.registerChangeListener(() => {
setUnsavedChanges(dashboardStateManager?.hasUnsavedPanelState());
// we aren't checking dirty state because there are changes the container needs to know about
// that won't make the dashboard "dirty" - like a view mode change.
triggerRefresh$.next();
@ -281,6 +288,7 @@ export function DashboardApp({
embedSettings,
indexPatterns,
savedDashboard,
unsavedChanges,
dashboardContainer,
dashboardStateManager,
}}

View file

@ -17,6 +17,7 @@ import { createKbnUrlStateStorage } from '../services/kibana_utils';
import { InputTimeRange, TimefilterContract, TimeRange } from '../services/data';
import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks';
import { coreMock } from '../../../../core/public/mocks';
describe('DashboardState', function () {
let dashboardState: DashboardStateManager;
@ -45,6 +46,7 @@ describe('DashboardState', function () {
kibanaVersion: '7.0.0',
kbnUrlStateStorage: createKbnUrlStateStorage(),
history: createBrowserHistory(),
toasts: coreMock.createStart().notifications.toasts,
hasTaggingCapabilities: mockHasTaggingCapabilities,
});
}

View file

@ -43,6 +43,8 @@ import {
syncState,
} from '../services/kibana_utils';
import { STATE_STORAGE_KEY } from '../url_generator';
import { NotificationsStart } from '../services/core';
import { getMigratedToastText } from '../dashboard_strings';
/**
* Dashboard state manager handles connecting angular and redux state between the angular and react portions of the
@ -59,10 +61,12 @@ export class DashboardStateManager {
query: Query;
};
private stateDefaults: DashboardAppStateDefaults;
private toasts: NotificationsStart['toasts'];
private hideWriteControls: boolean;
private kibanaVersion: string;
public isDirty: boolean;
private changeListeners: Array<(status: { dirty: boolean }) => void>;
private hasShownMigrationToast = false;
public get appState(): DashboardAppState {
return this.stateContainer.get();
@ -93,6 +97,7 @@ export class DashboardStateManager {
* @param
*/
constructor({
toasts,
history,
kibanaVersion,
savedDashboard,
@ -108,11 +113,13 @@ export class DashboardStateManager {
hideWriteControls: boolean;
allowByValueEmbeddables: boolean;
savedDashboard: DashboardSavedObject;
toasts: NotificationsStart['toasts'];
usageCollection?: UsageCollectionSetup;
kbnUrlStateStorage: IKbnUrlStateStorage;
dashboardPanelStorage?: DashboardPanelStorage;
hasTaggingCapabilities: SavedObjectTagDecoratorTypeGuard;
}) {
this.toasts = toasts;
this.kibanaVersion = kibanaVersion;
this.savedDashboard = savedDashboard;
this.hideWriteControls = hideWriteControls;
@ -283,6 +290,10 @@ export class DashboardStateManager {
if (dirty) {
this.stateContainer.transitions.set('panels', Object.values(convertedPanelStateMap));
if (dirtyBecauseOfInitialStateMigration) {
if (this.getIsEditMode() && !this.hasShownMigrationToast) {
this.toasts.addSuccess(getMigratedToastText());
this.hasShownMigrationToast = true;
}
this.saveState({ replace: true });
}
@ -693,6 +704,11 @@ export class DashboardStateManager {
this.dashboardPanelStorage.clearPanels(this.savedDashboard?.id);
}
public hasUnsavedPanelState(): boolean {
const panels = this.dashboardPanelStorage?.getPanels(this.savedDashboard?.id);
return panels !== undefined && panels.length > 0;
}
private getUnsavedPanelState(): { panels?: SavedDashboardPanel[] } {
if (!this.allowByValueEmbeddables || this.getIsViewMode() || !this.dashboardPanelStorage) {
return {};

View file

@ -45,7 +45,6 @@ export const useDashboardBreadcrumbs = (
text: getDashboardTitle(
dashboardStateManager.getTitle(),
dashboardStateManager.getViewMode(),
dashboardStateManager.getIsDirty(timefilter),
dashboardStateManager.isNew()
),
},

View file

@ -20,6 +20,7 @@ import { DashboardCapabilities } from '../types';
import { EmbeddableFactory } from '../../../../embeddable/public';
import { HelloWorldEmbeddable } from '../../../../embeddable/public/tests/fixtures';
import { DashboardContainer } from '../embeddable';
import { coreMock } from 'src/core/public/mocks';
const savedDashboard = getSavedDashboardMock();
@ -32,12 +33,13 @@ const history = createBrowserHistory();
const createDashboardState = () =>
new DashboardStateManager({
savedDashboard,
kibanaVersion: '7.0.0',
hideWriteControls: false,
allowByValueEmbeddables: false,
kibanaVersion: '7.0.0',
kbnUrlStateStorage: createKbnUrlStateStorage(),
history: createBrowserHistory(),
kbnUrlStateStorage: createKbnUrlStateStorage(),
hasTaggingCapabilities: mockHasTaggingCapabilities,
toasts: coreMock.createStart().notifications.toasts,
});
const defaultCapabilities: DashboardCapabilities = {
@ -83,9 +85,9 @@ const setupEmbeddableFactory = () => {
test('container is destroyed on unmount', async () => {
const { createEmbeddable, destroySpy, embeddable } = setupEmbeddableFactory();
const state = createDashboardState();
const dashboardStateManager = createDashboardState();
const { result, unmount, waitForNextUpdate } = renderHook(
() => useDashboardContainer(state, history, false),
() => useDashboardContainer({ dashboardStateManager, history }),
{
wrapper: ({ children }) => (
<KibanaContextProvider services={services}>{children}</KibanaContextProvider>
@ -113,7 +115,7 @@ test('old container is destroyed on new dashboardStateManager', async () => {
const { result, waitForNextUpdate, rerender } = renderHook<
DashboardStateManager,
DashboardContainer | null
>((dashboardState) => useDashboardContainer(dashboardState, history, false), {
>((dashboardStateManager) => useDashboardContainer({ dashboardStateManager, history }), {
wrapper: ({ children }) => (
<KibanaContextProvider services={services}>{children}</KibanaContextProvider>
),
@ -148,7 +150,7 @@ test('destroyed if rerendered before resolved', async () => {
const { result, waitForNextUpdate, rerender } = renderHook<
DashboardStateManager,
DashboardContainer | null
>((dashboardState) => useDashboardContainer(dashboardState, history, false), {
>((dashboardStateManager) => useDashboardContainer({ dashboardStateManager, history }), {
wrapper: ({ children }) => (
<KibanaContextProvider services={services}>{children}</KibanaContextProvider>
),

View file

@ -24,12 +24,21 @@ import { getDashboardContainerInput, getSearchSessionIdFromURL } from '../dashbo
import { DashboardConstants, DashboardContainer, DashboardContainerInput } from '../..';
import { DashboardAppServices } from '../types';
import { DASHBOARD_CONTAINER_TYPE } from '..';
import { TimefilterContract } from '../../services/data';
export const useDashboardContainer = (
dashboardStateManager: DashboardStateManager | null,
history: History,
isEmbeddedExternally: boolean
) => {
export const useDashboardContainer = ({
history,
timeFilter,
setUnsavedChanges,
dashboardStateManager,
isEmbeddedExternally,
}: {
history: History;
isEmbeddedExternally?: boolean;
timeFilter?: TimefilterContract;
setUnsavedChanges?: (dirty: boolean) => void;
dashboardStateManager: DashboardStateManager | null;
}) => {
const {
dashboardCapabilities,
data,
@ -72,15 +81,20 @@ export const useDashboardContainer = (
.getStateTransfer()
.getIncomingEmbeddablePackage(DashboardConstants.DASHBOARDS_ID, true);
// when dashboard state manager initially loads, determine whether or not there are unsaved changes
setUnsavedChanges?.(
Boolean(incomingEmbeddable) || dashboardStateManager.hasUnsavedPanelState()
);
let canceled = false;
let pendingContainer: DashboardContainer | ErrorEmbeddable | null | undefined;
(async function createContainer() {
pendingContainer = await dashboardFactory.create(
getDashboardContainerInput({
isEmbeddedExternally: Boolean(isEmbeddedExternally),
dashboardCapabilities,
dashboardStateManager,
incomingEmbeddable,
isEmbeddedExternally,
query,
searchSessionId: searchSessionIdFromURL ?? searchSession.start(),
})
@ -141,8 +155,10 @@ export const useDashboardContainer = (
dashboardCapabilities,
dashboardStateManager,
isEmbeddedExternally,
setUnsavedChanges,
searchSession,
scopedHistory,
timeFilter,
embeddable,
history,
query,

View file

@ -87,6 +87,7 @@ export const useDashboardStateManager = (
});
const stateManager = new DashboardStateManager({
toasts: core.notifications.toasts,
hasTaggingCapabilities,
dashboardPanelStorage,
hideWriteControls,
@ -160,7 +161,6 @@ export const useDashboardStateManager = (
const dashboardTitle = getDashboardTitle(
stateManager.getTitle(),
stateManager.getViewMode(),
stateManager.getIsDirty(timefilter),
stateManager.isNew()
);
@ -213,6 +213,7 @@ export const useDashboardStateManager = (
uiSettings,
usageCollection,
allowByValueEmbeddables,
core.notifications.toasts,
dashboardCapabilities.storeSearchSession,
]);

View file

@ -45,7 +45,7 @@ import { ShowShareModal } from './show_share_modal';
import { PanelToolbar } from './panel_toolbar';
import { confirmDiscardOrKeepUnsavedChanges } from '../listing/confirm_overlays';
import { OverlayRef } from '../../../../../core/public';
import { getNewDashboardTitle } from '../../dashboard_strings';
import { getNewDashboardTitle, unsavedChangesBadge } from '../../dashboard_strings';
import { DASHBOARD_PANELS_UNSAVED_ID } from '../lib/dashboard_panel_storage';
import { DashboardContainer } from '..';
@ -64,6 +64,7 @@ export interface DashboardTopNavProps {
timefilter: TimefilterContract;
indexPatterns: IndexPattern[];
redirectTo: DashboardRedirect;
unsavedChanges?: boolean;
lastDashboardId?: string;
viewMode: ViewMode;
}
@ -72,6 +73,7 @@ export function DashboardTopNav({
dashboardStateManager,
dashboardContainer,
lastDashboardId,
unsavedChanges,
savedDashboard,
onQuerySubmit,
embedSettings,
@ -467,7 +469,18 @@ export function DashboardTopNav({
isDirty: dashboardStateManager.isDirty,
});
const badges = unsavedChanges
? [
{
'data-test-subj': 'dashboardUnsavedChangesBadge',
badgeText: unsavedChangesBadge.getUnsavedChangedBadgeText(),
color: 'secondary',
},
]
: undefined;
return {
badges,
appName: 'dashboard',
config: showTopNavMenu ? topNav : undefined,
className: isFullScreenMode ? 'kbnTopNavMenu-isFullScreen' : undefined,

View file

@ -12,37 +12,31 @@ import { ViewMode } from './services/embeddable';
/**
* @param title {string} the current title of the dashboard
* @param viewMode {DashboardViewMode} the current mode. If in editing state, prepends 'Editing ' to the title.
* @param isDirty {boolean} if the dashboard is in a dirty state. If in dirty state, adds (unsaved) to the
* end of the title.
* @returns {string} A title to display to the user based on the above parameters.
*/
export function getDashboardTitle(
title: string,
viewMode: ViewMode,
isDirty: boolean,
isNew: boolean
): string {
export function getDashboardTitle(title: string, viewMode: ViewMode, isNew: boolean): string {
const isEditMode = viewMode === ViewMode.EDIT;
let displayTitle: string;
const dashboardTitle = isNew ? getNewDashboardTitle() : title;
if (isEditMode && isDirty) {
displayTitle = i18n.translate('dashboard.strings.dashboardUnsavedEditTitle', {
defaultMessage: 'Editing {title} (unsaved)',
values: { title: dashboardTitle },
});
} else if (isEditMode) {
displayTitle = i18n.translate('dashboard.strings.dashboardEditTitle', {
defaultMessage: 'Editing {title}',
values: { title: dashboardTitle },
});
} else {
displayTitle = dashboardTitle;
}
return displayTitle;
return isEditMode
? i18n.translate('dashboard.strings.dashboardEditTitle', {
defaultMessage: 'Editing {title}',
values: { title: dashboardTitle },
})
: dashboardTitle;
}
export const unsavedChangesBadge = {
getUnsavedChangedBadgeText: () =>
i18n.translate('dashboard.unsavedChangesBadge', {
defaultMessage: 'Unsaved changes',
}),
};
export const getMigratedToastText = () =>
i18n.translate('dashboard.migratedChanges', {
defaultMessage: 'Some panels have been successfully updated to the latest version.',
});
/*
Plugin
*/

View file

@ -1,3 +1,12 @@
.kbnTopNavMenu {
margin-right: $euiSizeXS;
}
.kbnTopNavMenu__badgeWrapper {
display: flex;
align-items: baseline;
}
.kbnTopNavMenu__badgeGroup {
margin-right: $euiSizeM;
}

View file

@ -7,7 +7,7 @@
*/
import React, { ReactElement } from 'react';
import { EuiHeaderLinks } from '@elastic/eui';
import { EuiBadge, EuiBadgeGroup, EuiBadgeProps, EuiHeaderLinks } from '@elastic/eui';
import classNames from 'classnames';
import { MountPoint } from '../../../../core/public';
@ -23,6 +23,7 @@ import { TopNavMenuItem } from './top_nav_menu_item';
export type TopNavMenuProps = StatefulSearchBarProps &
Omit<SearchBarProps, 'kibana' | 'intl' | 'timeHistory'> & {
config?: TopNavMenuData[];
badges?: Array<EuiBadgeProps & { badgeText: string }>;
showSearchBar?: boolean;
showQueryBar?: boolean;
showQueryInput?: boolean;
@ -61,12 +62,28 @@ export type TopNavMenuProps = StatefulSearchBarProps &
**/
export function TopNavMenu(props: TopNavMenuProps): ReactElement | null {
const { config, showSearchBar, ...searchBarProps } = props;
const { config, badges, showSearchBar, ...searchBarProps } = props;
if ((!config || config.length === 0) && (!showSearchBar || !props.data)) {
return null;
}
function renderBadges(): ReactElement | null {
if (!badges || badges.length === 0) return null;
return (
<EuiBadgeGroup className={'kbnTopNavMenu__badgeGroup'}>
{badges.map((badge: EuiBadgeProps & { badgeText: string }, i: number) => {
const { badgeText, ...badgeProps } = badge;
return (
<EuiBadge key={`nav-menu-badge-${i}`} {...badgeProps}>
{badgeText}
</EuiBadge>
);
})}
</EuiBadgeGroup>
);
}
function renderItems(): ReactElement[] | null {
if (!config || config.length === 0) return null;
return config.map((menuItem: TopNavMenuData, i: number) => {
@ -98,7 +115,10 @@ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null {
return (
<>
<MountPointPortal setMountPoint={setMenuMountPoint}>
<span className={wrapperClassName}>{renderMenu(menuClassName)}</span>
<span className={`${wrapperClassName} kbnTopNavMenu__badgeWrapper`}>
{renderBadges()}
{renderMenu(menuClassName)}
</span>
</MountPointPortal>
<span className={wrapperClassName}>{renderSearchBar()}</span>
</>

View file

@ -115,7 +115,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await testSubjects.click('confirmCopyToButton');
await PageObjects.dashboard.waitForRenderComplete();
await PageObjects.dashboard.expectOnDashboard(`Editing New Dashboard (unsaved)`);
await PageObjects.dashboard.expectOnDashboard(`Editing New Dashboard`);
});
it('it always appends new panels instead of overwriting', async () => {

View file

@ -13,6 +13,7 @@ import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const PageObjects = getPageObjects(['dashboard', 'header', 'visualize', 'settings', 'common']);
const esArchiver = getService('esArchiver');
const testSubjects = getService('testSubjects');
const kibanaServer = getService('kibanaServer');
const dashboardAddPanel = getService('dashboardAddPanel');
@ -29,10 +30,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.common.navigateToApp('dashboard');
await PageObjects.dashboard.preserveCrossAppState();
await PageObjects.dashboard.loadSavedDashboard('few panels');
await PageObjects.dashboard.switchToEditMode();
await PageObjects.header.waitUntilLoadingHasFinished();
originalPanelCount = await PageObjects.dashboard.getPanelCount();
});
it('does not show unsaved changes badge when there are no unsaved changes', async () => {
await testSubjects.missingOrFail('dashboardUnsavedChangesBadge');
});
it('shows the unsaved changes badge after adding panels', async () => {
await PageObjects.dashboard.switchToEditMode();
// add an area chart by value
await dashboardAddPanel.clickCreateNewLink();
await PageObjects.visualize.clickAggBasedVisualizations();
@ -42,6 +49,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
// add a metric by reference
await dashboardAddPanel.addVisualization('Rendering-Test: metric');
await PageObjects.header.waitUntilLoadingHasFinished();
await testSubjects.existOrFail('dashboardUnsavedChangesBadge');
});
it('has correct number of panels', async () => {
@ -73,10 +83,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('resets to original panel count upon entering view mode', async () => {
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.dashboard.clickCancelOutOfEditMode();
await PageObjects.header.waitUntilLoadingHasFinished();
const currentPanelCount = await PageObjects.dashboard.getPanelCount();
expect(currentPanelCount).to.eql(originalPanelCount);
});
it('shows unsaved changes badge in view mode if changes have not been discarded', async () => {
await testSubjects.existOrFail('dashboardUnsavedChangesBadge');
});
it('retains unsaved panel count after returning to edit mode', async () => {
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.dashboard.switchToEditMode();
@ -84,5 +99,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const currentPanelCount = await PageObjects.dashboard.getPanelCount();
expect(currentPanelCount).to.eql(unsavedPanelCount);
});
it('does not show unsaved changes badge after saving', async () => {
await PageObjects.dashboard.saveDashboard('Unsaved State Test');
await PageObjects.header.waitUntilLoadingHasFinished();
await testSubjects.missingOrFail('dashboardUnsavedChangesBadge');
});
});
}

View file

@ -646,7 +646,6 @@
"dashboard.savedDashboard.newDashboardTitle": "新規ダッシュボード",
"dashboard.stateManager.timeNotSavedWithDashboardErrorMessage": "このダッシュボードに時刻が保存されていないため、同期できません。",
"dashboard.strings.dashboardEditTitle": "{title}を編集中",
"dashboard.strings.dashboardUnsavedEditTitle": "{title}を編集中(未保存)",
"dashboard.topNav.cloneModal.cancelButtonLabel": "キャンセル",
"dashboard.topNav.cloneModal.cloneDashboardModalHeaderTitle": "ダッシュボードのクローンを作成",
"dashboard.topNav.cloneModal.confirmButtonLabel": "クローンの確認",

View file

@ -646,7 +646,6 @@
"dashboard.savedDashboard.newDashboardTitle": "新建仪表板",
"dashboard.stateManager.timeNotSavedWithDashboardErrorMessage": "时间未随此仪表板保存,因此无法同步。",
"dashboard.strings.dashboardEditTitle": "正在编辑 {title}",
"dashboard.strings.dashboardUnsavedEditTitle": "正在编辑 {title}(未保存)",
"dashboard.topNav.cloneModal.cancelButtonLabel": "取消",
"dashboard.topNav.cloneModal.cloneDashboardModalHeaderTitle": "克隆仪表板",
"dashboard.topNav.cloneModal.confirmButtonLabel": "确认克隆",