Skip to content

Commit 247d386

Browse files
cqliu1kibanamachineelasticmachine
authored
[Dashboard] Top Nav Save Menu (#237211)
## Summary Closes #192630. Closes [#237210](#237210). This moves the `Save as` and `Reset` top nav actions into a split button menu next to the `Save` button in edit mode. <img width="1188" height="777" alt="Screenshot 2025-10-29 at 10 18 39 AM" src="https://github.com/user-attachments/assets/06ac1310-e3f7-4251-ade0-3b94eeb5d8d2" /> #### With unsaved changes <img width="624" height="164" alt="Screenshot 2025-10-29 at 10 20 33 AM" src="https://github.com/user-attachments/assets/4e42212f-b7f1-4b29-9525-6b2b6ee6f207" /> #### View mode The duplicate and reset actions remain in the top nav in view mode. <img width="428" height="168" alt="Screenshot 2025-10-29 at 10 21 29 AM" src="https://github.com/user-attachments/assets/3d70796f-c2f6-4312-b017-3dc5f0461388" /> #### Editing a new dashboard The save button returns to a normal button with no additional save options. <img width="456" height="124" alt="Screenshot 2025-10-29 at 10 22 13 AM" src="https://github.com/user-attachments/assets/7c0ca3e6-e567-43d2-9a4e-6c25fd9e03f2" /> I also cleaned up styles for the add menu <img width="440" height="526" alt="Screenshot 2025-10-29 at 10 12 57 AM" src="https://github.com/user-attachments/assets/e8117793-14da-472a-9aaf-ebc3d55e94f0" /> ### Checklist Check the PR satisfies following conditions. Reviewers should verify this PR satisfies this list as well. - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This was checked for breaking HTTP API changes, and any breaking changes have been approved by the breaking-change committee. The `release_note:breaking` label should be applied in these situations. - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [ ] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) - [ ] Review the [backport guidelines](https://docs.google.com/document/d/1VyN5k91e5OVumlc0Gb9RPa3h1ewuPE705nRtioPiTvY/edit?usp=sharing) and apply applicable `backport:*` labels. ### Identify risks Does this PR introduce any risks? For example, consider risks like hard to test bugs, performance regression, potential of data loss. Describe the risk, its severity, and mitigation for each identified risk. Invite stakeholders and evaluate how to proceed before merging. - [ ] [See some risk examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) - [ ] ... --------- Co-authored-by: kibanamachine <[email protected]> Co-authored-by: Elastic Machine <[email protected]>
1 parent 30ca4be commit 247d386

File tree

14 files changed

+300
-71
lines changed

14 files changed

+300
-71
lines changed

packages/kbn-optimizer/limits.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ pageLoadAssetSize:
107107
ml: 89000
108108
mockIdpPlugin: 7544
109109
monitoring: 28983
110-
navigation: 11684
110+
navigation: 14000
111111
newsfeed: 12371
112112
noDataPage: 1749
113113
observability: 107955

src/platform/packages/private/kbn-split-button/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@
77
* License v3.0 only", or the "Server Side Public License, v 1".
88
*/
99

10+
export type { SplitButtonProps } from './src/split_button';
1011
export { SplitButton } from './src/split_button';

src/platform/packages/private/kbn-split-button/src/split_button.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@
88
*/
99

1010
import { EuiButton, EuiButtonIcon, useEuiTheme } from '@elastic/eui';
11-
import type { IconType, UseEuiTheme } from '@elastic/eui';
11+
import type { EuiButtonProps, IconType, UseEuiTheme } from '@elastic/eui';
1212
import { useMemoCss } from '@kbn/css-utils/public/use_memo_css';
1313
import React from 'react';
1414

15-
type SplitButtonProps = React.ComponentProps<typeof EuiButton> & {
15+
export type SplitButtonProps = React.ComponentProps<typeof EuiButton> & {
1616
isMainButtonLoading?: boolean;
1717
isMainButtonDisabled?: boolean;
1818
iconOnly?: boolean;
@@ -22,6 +22,7 @@ type SplitButtonProps = React.ComponentProps<typeof EuiButton> & {
2222
secondaryButtonIcon: IconType;
2323
secondaryButtonAriaLabel?: string;
2424
secondaryButtonTitle?: string;
25+
secondaryButtonFill?: boolean;
2526
onSecondaryButtonClick?: React.MouseEventHandler<HTMLButtonElement>;
2627
};
2728

@@ -39,6 +40,7 @@ export const SplitButton = ({
3940
secondaryButtonIcon,
4041
secondaryButtonAriaLabel,
4142
secondaryButtonTitle,
43+
secondaryButtonFill,
4244
onSecondaryButtonClick,
4345

4446
// Primary button props
@@ -56,7 +58,7 @@ export const SplitButton = ({
5658

5759
const areButtonsDisabled = disabled || isDisabled;
5860

59-
const commonMainButtonProps = {
61+
const commonMainButtonProps: EuiButtonProps = {
6062
css: styles.mainButton,
6163
style: {
6264
borderRightColor: borderColor,
@@ -83,8 +85,8 @@ export const SplitButton = ({
8385
css={styles.secondaryButton}
8486
data-test-subj={mainButtonProps['data-test-subj'] + `-secondary-button`}
8587
aria-label={secondaryButtonAriaLabel}
88+
display={secondaryButtonFill ? 'fill' : 'base'}
8689
title={secondaryButtonTitle}
87-
display="base"
8890
color={color}
8991
size={size}
9092
iconType={secondaryButtonIcon}

src/platform/plugins/shared/dashboard/public/dashboard_app/_dashboard_app_strings.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ export const topNavStrings = {
192192
},
193193
editModeInteractiveSave: {
194194
label: i18n.translate('dashboard.topNave.editModeInteractiveSaveButtonAriaLabel', {
195-
defaultMessage: 'save as',
195+
defaultMessage: 'Save as',
196196
}),
197197
description: i18n.translate('dashboard.topNave.editModeInteractiveSaveConfigDescription', {
198198
defaultMessage: 'Save as a new dashboard',
@@ -262,6 +262,14 @@ export const topNavStrings = {
262262
defaultMessage: 'Open background searches',
263263
}),
264264
},
265+
saveMenu: {
266+
label: i18n.translate('dashboard.topNave.saveMenuButtonAriaLabel', {
267+
defaultMessage: 'Save options',
268+
}),
269+
description: i18n.translate('dashboard.topNave.saveMenuDescription', {
270+
defaultMessage: 'Additional save options',
271+
}),
272+
},
265273
};
266274

267275
export const getControlButtonTitle = () =>

src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/add_menu/show_add_menu.tsx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
type EuiContextMenuPanelDescriptor,
1616
EuiContextMenu,
1717
EuiWrappingPopover,
18+
EuiThemeProvider,
1819
} from '@elastic/eui';
1920
import type { CoreStart } from '@kbn/core/public';
2021
import { TIME_SLIDER_CONTROL } from '@kbn/controls-constants';
@@ -263,6 +264,9 @@ export const AddMenu = ({ dashboardApi, anchorElement, coreServices }: AddMenuPr
263264
button={anchorElement}
264265
panelPaddingSize="none"
265266
repositionOnScroll
267+
attachToAnchor
268+
anchorPosition="downLeft"
269+
panelStyle={{ maxWidth: 200 }}
266270
>
267271
<EuiContextMenu initialPanelId={0} panels={panels} />
268272
</EuiWrappingPopover>
@@ -275,15 +279,19 @@ export function showAddMenu({ dashboardApi, anchorElement, coreServices }: AddMe
275279
return;
276280
}
277281

282+
const theme = coreServices.theme.getTheme();
283+
278284
isOpen = true;
279285
document.body.appendChild(container);
280286
ReactDOM.render(
281287
<KibanaContextProvider services={coreServices}>
282-
<AddMenu
283-
dashboardApi={dashboardApi}
284-
anchorElement={anchorElement}
285-
coreServices={coreServices}
286-
/>
288+
<EuiThemeProvider colorMode={theme.darkMode ? 'dark' : 'light'}>
289+
<AddMenu
290+
dashboardApi={dashboardApi}
291+
anchorElement={anchorElement}
292+
coreServices={coreServices}
293+
/>
294+
</EuiThemeProvider>
287295
</KibanaContextProvider>,
288296
container
289297
);
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
import React, { useCallback } from 'react';
11+
import ReactDOM from 'react-dom';
12+
13+
import { EuiContextMenu, EuiThemeProvider, EuiWrappingPopover } from '@elastic/eui';
14+
import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
15+
16+
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
17+
import type { CoreStart } from '@kbn/core/public';
18+
import type { DashboardApi } from '../../../dashboard_api/types';
19+
import { topNavStrings } from '../../_dashboard_app_strings';
20+
21+
interface SaveMenuProps {
22+
anchorElement: HTMLElement;
23+
dashboardApi: DashboardApi;
24+
isResetting: boolean;
25+
isSaveInProgress: boolean;
26+
resetChanges: () => void;
27+
dashboardInteractiveSave: () => void;
28+
}
29+
30+
const container = document.createElement('div');
31+
let isOpen = false;
32+
33+
function cleanup() {
34+
if (!isOpen) {
35+
return;
36+
}
37+
ReactDOM.unmountComponentAtNode(container);
38+
document.body.removeChild(container);
39+
isOpen = false;
40+
}
41+
42+
export const SaveMenu = ({
43+
dashboardApi,
44+
anchorElement,
45+
isResetting,
46+
isSaveInProgress,
47+
resetChanges,
48+
dashboardInteractiveSave,
49+
}: SaveMenuProps) => {
50+
const [hasOverlays, hasUnsavedChanges, lastSavedId] = useBatchedPublishingSubjects(
51+
dashboardApi.hasOverlays$,
52+
dashboardApi.hasUnsavedChanges$,
53+
dashboardApi.savedObjectId$
54+
);
55+
56+
const closePopover = useCallback(() => {
57+
cleanup();
58+
anchorElement.focus();
59+
}, [anchorElement]);
60+
61+
const panels = [
62+
{
63+
id: 0,
64+
initialFocusedItemIndex: 0,
65+
items: [
66+
{
67+
name: topNavStrings.editModeInteractiveSave.label,
68+
icon: 'save',
69+
'data-test-subj': 'dashboardInteractiveSaveMenuItem',
70+
iconType: lastSavedId ? undefined : 'save',
71+
onClick: () => {
72+
dashboardInteractiveSave();
73+
closePopover();
74+
},
75+
},
76+
{
77+
name: topNavStrings.resetChanges.label,
78+
icon: 'editorUndo',
79+
'data-test-subj': 'dashboardDiscardChangesMenuItem',
80+
isLoading: isResetting,
81+
disabled:
82+
isResetting || !hasUnsavedChanges || hasOverlays || isSaveInProgress || !lastSavedId,
83+
onClick: () => {
84+
resetChanges();
85+
closePopover();
86+
},
87+
},
88+
],
89+
},
90+
];
91+
92+
return (
93+
<EuiWrappingPopover
94+
isOpen={isOpen}
95+
closePopover={closePopover}
96+
button={anchorElement}
97+
panelPaddingSize="none"
98+
anchorPosition="downRight"
99+
attachToAnchor
100+
panelStyle={{ maxWidth: 100 }}
101+
buffer={0}
102+
repositionOnScroll
103+
>
104+
<EuiContextMenu initialPanelId={0} panels={panels} />
105+
</EuiWrappingPopover>
106+
);
107+
};
108+
109+
export function showSaveMenu({
110+
coreServices,
111+
...props
112+
}: SaveMenuProps & { coreServices: CoreStart }) {
113+
if (isOpen) {
114+
cleanup();
115+
return;
116+
}
117+
118+
const theme = coreServices.theme.getTheme();
119+
120+
isOpen = true;
121+
document.body.appendChild(container);
122+
ReactDOM.render(
123+
<KibanaContextProvider services={coreServices}>
124+
<EuiThemeProvider colorMode={theme.darkMode ? 'dark' : 'light'}>
125+
<SaveMenu {...props} />
126+
</EuiThemeProvider>
127+
</KibanaContextProvider>,
128+
container
129+
);
130+
}

src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { getDashboardCapabilities } from '../../utils/get_dashboard_capabilities
2626
import { topNavStrings } from '../_dashboard_app_strings';
2727
import { showAddMenu } from './add_menu/show_add_menu';
2828
import { ShowShareModal } from './share/show_share_modal';
29+
import { showSaveMenu } from './save_menu/show_save_menu';
2930

3031
export const useDashboardMenuItems = ({
3132
isLabsShown,
@@ -54,6 +55,7 @@ export const useDashboardMenuItems = ({
5455
dashboardApi.viewMode$
5556
);
5657
const disableTopNav = isSaveInProgress || hasOverlays;
58+
const isCreatingNewDashboard = viewMode === 'edit' && !lastSavedId;
5759

5860
/**
5961
* Show the Dashboard app's share menu
@@ -158,24 +160,41 @@ export const useDashboardMenuItems = ({
158160
id: 'quick-save',
159161
iconType: 'save',
160162
emphasize: true,
161-
isLoading: isSaveInProgress,
163+
fill: true,
162164
testId: 'dashboardQuickSaveMenuItem',
163-
disableButton: disableTopNav || !hasUnsavedChanges,
165+
disableButton: disableTopNav || isResetting,
164166
run: () => quickSaveDashboard(),
167+
splitButtonProps: {
168+
run: (anchorElement: HTMLElement) => {
169+
showSaveMenu({
170+
dashboardApi,
171+
anchorElement,
172+
resetChanges,
173+
isResetting,
174+
isSaveInProgress,
175+
dashboardInteractiveSave,
176+
coreServices,
177+
});
178+
},
179+
isMainButtonLoading: isSaveInProgress,
180+
isMainButtonDisabled: !hasUnsavedChanges,
181+
secondaryButtonAriaLabel: topNavStrings.saveMenu.label,
182+
secondaryButtonIcon: 'arrowDown',
183+
secondaryButtonFill: true,
184+
isSecondaryButtonDisabled: isSaveInProgress,
185+
},
165186
} as TopNavMenuData,
166187

167188
interactiveSave: {
168189
disableButton: disableTopNav,
169-
emphasize: !Boolean(lastSavedId),
190+
emphasize: isCreatingNewDashboard,
170191
id: 'interactive-save',
171192
testId: 'dashboardInteractiveSaveMenuItem',
193+
iconType: lastSavedId ? undefined : 'save',
172194
run: dashboardInteractiveSave,
173-
label:
174-
viewMode === 'view'
175-
? topNavStrings.viewModeInteractiveSave.label
176-
: Boolean(lastSavedId)
177-
? topNavStrings.editModeInteractiveSave.label
178-
: topNavStrings.quickSave.label,
195+
label: isCreatingNewDashboard
196+
? topNavStrings.quickSave.label
197+
: topNavStrings.viewModeInteractiveSave.label,
179198
description:
180199
viewMode === 'view'
181200
? topNavStrings.viewModeInteractiveSave.description
@@ -251,6 +270,7 @@ export const useDashboardMenuItems = ({
251270
disableTopNav,
252271
isSaveInProgress,
253272
hasUnsavedChanges,
273+
isCreatingNewDashboard,
254274
lastSavedId,
255275
dashboardInteractiveSave,
256276
viewMode,
@@ -355,11 +375,7 @@ export const useDashboardMenuItems = ({
355375
const editModeItems: TopNavMenuData[] = [];
356376

357377
if (lastSavedId) {
358-
editModeItems.push(menuItems.interactiveSave, menuItems.switchToViewMode);
359-
360-
if (showResetChange) {
361-
editModeItems.push(resetChangesMenuItem);
362-
}
378+
editModeItems.push(menuItems.switchToViewMode);
363379

364380
editModeItems.push(menuItems.add, menuItems.quickSave);
365381
} else {
@@ -389,8 +405,6 @@ export const useDashboardMenuItems = ({
389405
menuItems.backgroundSearch,
390406
hasExportIntegration,
391407
lastSavedId,
392-
showResetChange,
393-
resetChangesMenuItem,
394408
]);
395409

396410
return { viewModeTopNavConfig, editModeTopNavConfig };

src/platform/plugins/shared/dashboard/public/dashboard_listing/_dashboard_listing_strings.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,29 @@ export const resetConfirmStrings = {
129129
}),
130130
};
131131

132+
export const unsavedChangesConfirmStrings = {
133+
getUnsavedChangesTitle: () =>
134+
i18n.translate('dashboard.resetChangesConfirmModal.resetChangesTitle', {
135+
defaultMessage: 'Unsaved changes',
136+
}),
137+
getUnsavedChangesSubtitle: () =>
138+
i18n.translate('dashboard.discardChangesConfirmModal.discardChangesDescription', {
139+
defaultMessage: `You have unsaved changes. Would you like to save or discard your work?`,
140+
}),
141+
getCancelButtonLabel: () =>
142+
i18n.translate('dashboard.unsavedChangesConfirmModal.cancelButtonLabel', {
143+
defaultMessage: 'Cancel',
144+
}),
145+
getDiscardButtonText: () =>
146+
i18n.translate('dashboard.unsavedChangesConfirmModal.discardButtonLabel', {
147+
defaultMessage: 'Discard',
148+
}),
149+
getSaveButtonText: () =>
150+
i18n.translate('dashboard.unsavedChangesConfirmModal.saveButtonLabel', {
151+
defaultMessage: 'Save',
152+
}),
153+
};
154+
132155
export const createConfirmStrings = {
133156
getCreateTitle: () =>
134157
i18n.translate('dashboard.createConfirmModal.unsavedChangesTitle', {

0 commit comments

Comments
 (0)