diff --git a/src/plugins/controls/public/control_group/editor/create_control.tsx b/src/plugins/controls/public/control_group/editor/create_control.tsx index a3da7071d7ceb..ec36030e9898e 100644 --- a/src/plugins/controls/public/control_group/editor/create_control.tsx +++ b/src/plugins/controls/public/control_group/editor/create_control.tsx @@ -31,7 +31,8 @@ export interface CreateControlButtonProps { setLastUsedDataViewId?: (newDataViewId: string) => void; getRelevantDataViewId?: () => string | undefined; buttonType: CreateControlButtonTypes; - closePopover?: () => void; + onClick?: () => void; + onFlyoutClose?: () => void; } interface CreateControlResult { @@ -44,7 +45,8 @@ export const CreateControlButton = ({ defaultControlWidth, defaultControlGrow, addNewEmbeddable, - closePopover, + onClick, + onFlyoutClose, getRelevantDataViewId, setLastUsedDataViewId, updateDefaultWidth, @@ -76,6 +78,7 @@ export const CreateControlButton = ({ if (confirmed) { reject(); ref.close(); + onFlyoutClose?.(); } }); }; @@ -93,6 +96,7 @@ export const CreateControlButton = ({ } resolve({ type, controlInput: inputToReturn }); ref.close(); + onFlyoutClose?.(); }; const flyoutInstance = openFlyout( @@ -140,9 +144,7 @@ export const CreateControlButton = ({ key: 'addControl', onClick: () => { createNewControl(); - if (closePopover) { - closePopover(); - } + onClick?.(); }, 'data-test-subj': 'controls-create-button', 'aria-label': ControlGroupStrings.management.getManageButtonTitle(), diff --git a/src/plugins/controls/public/control_group/editor/edit_control_group.tsx b/src/plugins/controls/public/control_group/editor/edit_control_group.tsx index 733d009878937..1527eff21535c 100644 --- a/src/plugins/controls/public/control_group/editor/edit_control_group.tsx +++ b/src/plugins/controls/public/control_group/editor/edit_control_group.tsx @@ -19,12 +19,14 @@ import { setFlyoutRef } from '../embeddable/control_group_container'; export interface EditControlGroupButtonProps { controlGroupContainer: ControlGroupContainer; - closePopover: () => void; + onClick?: () => void; + onFlyoutClose?: () => void; } export const EditControlGroup = ({ controlGroupContainer, - closePopover, + onClick, + onFlyoutClose, }: EditControlGroupButtonProps) => { const { overlays } = pluginServices.getServices(); const { openConfirm, openFlyout } = overlays; @@ -44,6 +46,7 @@ export const EditControlGroup = ({ controlGroupContainer.removeEmbeddable(panelId) ); ref.close(); + onFlyoutClose?.(); }); }; @@ -55,7 +58,10 @@ export const EditControlGroup = ({ updateInput={(changes) => controlGroupContainer.updateInput(changes)} controlCount={Object.keys(controlGroupContainer.getInput().panels ?? {}).length} onDeleteAll={() => onDeleteAll(flyoutInstance)} - onClose={() => flyoutInstance.close()} + onClose={() => { + flyoutInstance.close(); + onFlyoutClose?.(); + }} /> ), @@ -64,6 +70,7 @@ export const EditControlGroup = ({ onClose: () => { flyoutInstance.close(); setFlyoutRef(undefined); + onFlyoutClose?.(); }, } ); @@ -74,7 +81,7 @@ export const EditControlGroup = ({ key: 'manageControls', onClick: () => { editControlGroup(); - closePopover(); + onClick?.(); }, icon: 'gear', 'data-test-subj': 'controls-settings-button', diff --git a/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx b/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx index 7fda112d83c77..71ba48534f7ec 100644 --- a/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx +++ b/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx @@ -110,13 +110,14 @@ export class ControlGroupContainer extends Container< /** * Returns a button that allows controls to be created externally using the embeddable * @param buttonType Controls the button styling - * @param closePopover Closes the create control menu popover when flyout opens - only necessary if `buttonType === 'toolbar'` + * @param onClick Optional parameter that controls what secondary actions should happen when the "create control" button is clicked * @return If `buttonType == 'toolbar'`, returns `EuiContextMenuPanel` with input control types as items. * Otherwise, if `buttonType == 'callout'` returns `EuiButton` with popover containing input control types. */ public getCreateControlButton = ( buttonType: CreateControlButtonTypes, - closePopover?: () => void + onClick?: () => void, + onClose?: () => void ) => { return ( this.addNewEmbeddable(type, input)} - closePopover={closePopover} + onClick={onClick} + onFlyoutClose={onClose} getRelevantDataViewId={() => this.getMostRelevantDataViewId()} setLastUsedDataViewId={(newId) => this.setLastUsedDataViewId(newId)} /> ); }; - private getEditControlGroupButton = (closePopover: () => void) => { - return ; + private getEditControlGroupButton = (onClick: () => void, onClose?: () => void) => { + return ( + + ); }; /** * Returns the toolbar button that is used for creating controls and managing control settings * @return `SolutionToolbarPopover` button for input controls */ - public getToolbarButtons = () => { + public getToolbarButtons = ({ + onClick, + onClose, + }: { + onClick?: () => void; + onClose?: () => void; + }) => { return ( void }) => ( { + closePopover(); + onClick?.(); + }, + () => onClose?.() + ), + this.getEditControlGroupButton( + () => { + closePopover(); + onClick?.(); + }, + () => onClose?.() + ), ]} /> )} diff --git a/src/plugins/dashboard/public/application/dashboard_app.tsx b/src/plugins/dashboard/public/application/dashboard_app.tsx index 7474b32d3e272..8d463f948a394 100644 --- a/src/plugins/dashboard/public/application/dashboard_app.tsx +++ b/src/plugins/dashboard/public/application/dashboard_app.tsx @@ -24,6 +24,7 @@ import { DashboardTopNav, isCompleteDashboardAppState } from './top_nav/dashboar import { DashboardAppServices, DashboardEmbedSettings, DashboardRedirect } from '../types'; import { createKbnUrlStateStorage, withNotifyOnErrors } from '../services/kibana_utils'; import { DashboardAppNoDataPage } from './dashboard_app_no_data'; +import { DashboardEditTourProvider } from './tour'; export interface DashboardAppProps { history: History; savedDashboardId?: string; @@ -120,10 +121,14 @@ export function DashboardApp({ }; }, [data.search.session]); - const printMode = useMemo( - () => dashboardAppState.getLatestDashboardState?.().viewMode === ViewMode.PRINT, - [dashboardAppState] - ); + const [printMode, editMode, viewMode] = useMemo(() => { + const currentViewMode = dashboardAppState.getLatestDashboardState?.().viewMode; + return [ + currentViewMode === ViewMode.PRINT, + currentViewMode === ViewMode.EDIT, + currentViewMode === ViewMode.VIEW, + ]; + }, [dashboardAppState]); useEffect(() => { if (!embedSettings) chrome.setIsVisible(!printMode); @@ -135,7 +140,7 @@ export function DashboardApp({ setShowNoDataPage(false)} /> )} {!showNoDataPage && isCompleteDashboardAppState(dashboardAppState) && ( - <> + - + )} ); diff --git a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx index 5cbbd30c79a24..4237ff2db2155 100644 --- a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx +++ b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx @@ -54,6 +54,7 @@ import { useDashboardDispatch, useDashboardSelector, } from '../state'; +import { useDashboardEditTourContext } from '../tour'; export interface DashboardTopNavState { chromeIsVisible: boolean; @@ -129,6 +130,9 @@ export function DashboardTopNav({ const IS_DARK_THEME = uiSettings.get('theme:darkMode'); const isLabsEnabled = uiSettings.get(UI_SETTINGS.ENABLE_LABS_UI); + const { currentEditTourStep, getNextEditTourStep, onViewModeChange, setTourVisibility } = + useDashboardEditTourContext(); + const trackUiMetric = usageCollection?.reportUiCounter.bind( usageCollection, DashboardConstants.DASHBOARD_ID @@ -153,6 +157,7 @@ export function DashboardTopNav({ const addFromLibrary = useCallback(() => { if (!isErrorEmbeddable(dashboardAppState.dashboardContainer)) { + setTourVisibility(false); setState((s) => ({ ...s, addPanelOverlay: openAddPanelFlyout({ @@ -161,6 +166,9 @@ export function DashboardTopNav({ getFactory: embeddable.getEmbeddableFactory, notifications: core.notifications, overlays: core.overlays, + showTour: () => { + setTourVisibility(true); + }, SavedObjectFinder: getSavedObjectFinder(core.savedObjects, uiSettings), reportUiCounter: usageCollection?.reportUiCounter, theme: core.theme, @@ -177,6 +185,7 @@ export function DashboardTopNav({ core.theme, uiSettings, usageCollection, + setTourVisibility, ]); const createNewVisType = useCallback( @@ -208,8 +217,18 @@ export function DashboardTopNav({ searchSessionId: data.search.session.getSessionId(), }, }); + + if (currentEditTourStep === 1) { + getNextEditTourStep(); + } }, - [stateTransferService, data.search.session, trackUiMetric] + [ + stateTransferService, + data.search.session, + trackUiMetric, + currentEditTourStep, + getNextEditTourStep, + ] ); const closeAllFlyouts = useCallback(() => { @@ -221,7 +240,8 @@ export function DashboardTopNav({ }, [state.addPanelOverlay, dashboardAppState.dashboardContainer.controlGroup]); const onChangeViewMode = useCallback( - (newMode: ViewMode) => { + async (newMode: ViewMode) => { + onViewModeChange(newMode); closeAllFlyouts(); const willLoseChanges = newMode === ViewMode.VIEW && dashboardAppState.hasUnsavedChanges; @@ -234,7 +254,13 @@ export function DashboardTopNav({ dashboardAppState.resetToLastSavedState?.() ); }, - [closeAllFlyouts, core.overlays, dashboardAppState, dispatchDashboardStateChange] + [ + closeAllFlyouts, + core.overlays, + dashboardAppState, + dispatchDashboardStateChange, + onViewModeChange, + ] ); const runSaveAs = useCallback(async () => { @@ -617,7 +643,14 @@ export function DashboardTopNav({ onClick={addFromLibrary} data-test-subj="dashboardAddPanelButton" />, - dashboardAppState.dashboardContainer.controlGroup?.getToolbarButtons(), + dashboardAppState.dashboardContainer.controlGroup?.getToolbarButtons({ + onClick: () => { + setTourVisibility(false); + }, + onClose: () => { + setTourVisibility(true); + }, + }), ], }} diff --git a/src/plugins/dashboard/public/application/tour/custom_footers.tsx b/src/plugins/dashboard/public/application/tour/custom_footers.tsx new file mode 100644 index 0000000000000..2b0d4adb79a4b --- /dev/null +++ b/src/plugins/dashboard/public/application/tour/custom_footers.tsx @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + EuiButton, + EuiButtonEmpty, + EuiButtonProps, + EuiFlexGroup, + EuiFlexItem, + EuiI18n, +} from '@elastic/eui'; +import React from 'react'; +import { DashboardTourContextProps } from './dashboard_tour_context'; + +interface ViewFooterProps { + onFinishTour: () => void; +} + +interface EditFooterProps { + isLastStep: boolean; + onNextTourStep: DashboardTourContextProps['getNextEditTourStep']; + onFinishTour: DashboardTourContextProps['finishEditTour']; +} + +export const ViewTourFooter = ({ onFinishTour }: ViewFooterProps) => ( + + + {EuiI18n({ token: 'core.euiTourStep.closeTour', default: 'Close tour' })} + + +); + +export const EditTourFooter = ({ isLastStep, onNextTourStep, onFinishTour }: EditFooterProps) => { + const actionButtonProps: Partial = { + size: 's', + color: 'success', + }; + + return ( + + {!isLastStep && ( + + + {EuiI18n({ token: 'core.euiTourStep.skipTour', default: 'Skip tour' })} + + + )} + + {isLastStep ? ( + + {EuiI18n({ token: 'core.euiTourStep.endTour', default: 'End tour' })} + + ) : ( + onNextTourStep()} + data-test-subj="dashboardTourButtonNext" + > + {EuiI18n({ token: 'core.euiTourStep.nextStep', default: 'Next' })} + + )} + + + ); +}; diff --git a/src/plugins/dashboard/public/application/tour/dashboard_tour_context.tsx b/src/plugins/dashboard/public/application/tour/dashboard_tour_context.tsx new file mode 100644 index 0000000000000..663dd856193f2 --- /dev/null +++ b/src/plugins/dashboard/public/application/tour/dashboard_tour_context.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ViewMode } from '@kbn/embeddable-plugin/public'; +import { createContext, useContext } from 'react'; + +export interface DashboardTourContextProps { + currentEditTourStep: number; + getNextEditTourStep: (step?: number) => void; + finishEditTour: () => void; + onViewModeChange: (newMode: ViewMode) => void; + setTourVisibility: (visibility: boolean) => void; +} + +export const DashboardTourContext = createContext({ + currentEditTourStep: -1, + getNextEditTourStep: () => {}, + finishEditTour: () => {}, + onViewModeChange: () => {}, + setTourVisibility: () => {}, +}); + +export const useDashboardEditTourContext = () => { + return useContext(DashboardTourContext); +}; diff --git a/src/plugins/dashboard/public/application/tour/dashboard_tour_provider.tsx b/src/plugins/dashboard/public/application/tour/dashboard_tour_provider.tsx new file mode 100644 index 0000000000000..6cfedcb062c0f --- /dev/null +++ b/src/plugins/dashboard/public/application/tour/dashboard_tour_provider.tsx @@ -0,0 +1,177 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useEuiTour, EuiTourState, EuiTourStep } from '@elastic/eui'; +import { ViewMode } from '@kbn/embeddable-plugin/public'; +import { DashboardTourContext, DashboardTourContextProps } from './dashboard_tour_context'; +import { EditTourFooter, ViewTourFooter } from './custom_footers'; +import { + FIRST_STEP, + MAX_WIDTH, + editTourStepDefinitions, + viewTourStepDefinitions, + findNextAvailableStep, + prepareTourSteps, +} from './tour_steps'; + +const DASHBOARD_TOUR_STORAGE_KEY = 'dashboard.tourState'; + +interface DashboardTourState { + viewTourComplete: boolean; + editTourComplete: boolean; + editTourState: EuiTourState; +} + +const tourConfig: EuiTourState = { + currentTourStep: FIRST_STEP, + isTourActive: false, + tourPopoverWidth: MAX_WIDTH, + tourSubtitle: '', +}; + +const defaultTourState: DashboardTourState = { + viewTourComplete: false, + editTourComplete: false, + editTourState: tourConfig, +}; + +export const DashboardEditTourProvider: React.FC<{ viewMode: boolean; editMode: boolean }> = ({ + viewMode, + editMode, + children, +}) => { + const initialState = localStorage.getItem(DASHBOARD_TOUR_STORAGE_KEY); + let tourState: DashboardTourState; + if (initialState) { + tourState = JSON.parse(initialState); + } else { + tourState = { + ...defaultTourState, + editTourState: { ...tourConfig, isTourActive: editMode }, + }; + } + const dashboardTourStateRef = useRef(tourState); + const editTourSteps = prepareTourSteps(editTourStepDefinitions); + const viewTourStep = prepareTourSteps(viewTourStepDefinitions)[0]; + const [editSteps, editActions, editReducerState] = useEuiTour( + editTourSteps, + dashboardTourStateRef.current.editTourState + ); + const { currentTourStep: currentEditTourStep, isTourActive: isEditTourActive } = editReducerState; + + const [isViewTourActive, setViewTourActive] = useState( + viewMode && !dashboardTourStateRef.current.viewTourComplete + ); + const [stepVisible, setStepVisible] = useState(true); + + useEffect(() => { + localStorage.setItem( + DASHBOARD_TOUR_STORAGE_KEY, + JSON.stringify({ + ...dashboardTourStateRef.current, + editTourState: editReducerState, + }) + ); + }, [editReducerState, dashboardTourStateRef.current.viewTourComplete]); + + const getNextEditTourStep = useCallback( + (step?: number) => { + if (step) { + editActions.goToStep(step); + return; + } + + const nextAvailableStep = findNextAvailableStep(editSteps, currentEditTourStep); + if (nextAvailableStep) { + editActions.goToStep(nextAvailableStep); + } else { + editActions.finishTour(); + } + }, + [editActions, editSteps, currentEditTourStep] + ); + + const finishEditTour = useCallback(() => { + dashboardTourStateRef.current.editTourComplete = true; + + editActions.finishTour(); + }, [editActions]); + + const finishViewTour = useCallback(() => { + setViewTourActive(false); + dashboardTourStateRef.current.viewTourComplete = true; + }, [dashboardTourStateRef]); + + const onViewModeChange = useCallback( + (newMode: ViewMode) => { + if (editMode && newMode === ViewMode.VIEW) { + setViewTourActive(!dashboardTourStateRef.current.viewTourComplete); + editReducerState.isTourActive = false; + } else if (viewMode && newMode === ViewMode.EDIT) { + finishViewTour(); + editReducerState.isTourActive = true; + } + }, + [editMode, viewMode, editReducerState, finishViewTour] + ); + + const setTourVisibility = useCallback( + (newVisibility: boolean) => { + setStepVisible(newVisibility); + }, + [setStepVisible] + ); + + const contextValue: DashboardTourContextProps = useMemo( + () => ({ + currentEditTourStep, + getNextEditTourStep, + finishEditTour, + onViewModeChange, + setTourVisibility, + }), + [currentEditTourStep, getNextEditTourStep, finishEditTour, onViewModeChange, setTourVisibility] + ); + + return ( + + {stepVisible && (viewMode || editMode) && ( + <> + {isViewTourActive ? ( + } + /> + ) : ( + <> + {isEditTourActive && + !dashboardTourStateRef.current.editTourComplete && + editSteps.map((step) => ( + getNextEditTourStep()} + onFinishTour={finishEditTour} + /> + } + /> + ))} + + )} + + )} + {children} + + ); +}; diff --git a/src/plugins/dashboard/public/application/tour/index.ts b/src/plugins/dashboard/public/application/tour/index.ts new file mode 100644 index 0000000000000..7e681507b523a --- /dev/null +++ b/src/plugins/dashboard/public/application/tour/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { DashboardEditTourProvider } from './dashboard_tour_provider'; +export { useDashboardEditTourContext } from './dashboard_tour_context'; diff --git a/src/plugins/dashboard/public/application/tour/tour_steps.tsx b/src/plugins/dashboard/public/application/tour/tour_steps.tsx new file mode 100644 index 0000000000000..71a6d91b48eb1 --- /dev/null +++ b/src/plugins/dashboard/public/application/tour/tour_steps.tsx @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { EuiTourStepProps, EuiText } from '@elastic/eui'; +import { DashboardTourStrings } from './translations'; + +export const MAX_WIDTH = 350; +export const FIRST_STEP = 1; + +interface TourStepDefinition { + anchor: EuiTourStepProps['anchor']; + title: EuiTourStepProps['title']; + content: EuiTourStepProps['content']; + anchorPosition: EuiTourStepProps['anchorPosition']; +} + +export const viewTourStepDefinitions: TourStepDefinition[] = [ + { + anchor: '[data-test-subj="dashboardEditMode"]', + title: DashboardTourStrings.viewModeTour.getTitle(), + content: {DashboardTourStrings.viewModeTour.getDescription()}, + anchorPosition: 'downCenter', + }, +]; + +export const editTourStepDefinitions: TourStepDefinition[] = [ + { + anchor: '[data-test-subj="dashboardAddNewPanelButton"]', + title: DashboardTourStrings.editModeTour.createVisualization.getTitle(), + content: DashboardTourStrings.editModeTour.createVisualization.getDescription(), + anchorPosition: 'rightCenter', + }, + { + anchor: '[data-test-subj="embeddablePanelToggleMenuIcon"]', + title: DashboardTourStrings.editModeTour.panelOptions.getTitle(), + content: DashboardTourStrings.editModeTour.panelOptions.getDescription(), + anchorPosition: 'upCenter', + }, + { + anchor: '.kbnQueryBar__datePickerWrapper', + title: DashboardTourStrings.editModeTour.timePicker.getTitle(), + content: DashboardTourStrings.editModeTour.timePicker.getDescription(), + anchorPosition: 'downCenter', + }, + { + anchor: '#addFilterPopover', + title: DashboardTourStrings.editModeTour.filters.getTitle(), + content: DashboardTourStrings.editModeTour.filters.getDescription(), + anchorPosition: 'downLeft', + }, + { + anchor: '[data-test-subj="dashboard-controls-menu-button"]', + title: DashboardTourStrings.editModeTour.controls.getTitle(), + content: DashboardTourStrings.editModeTour.controls.getDescription(), + anchorPosition: 'upCenter', + }, +]; + +export const prepareTourSteps = (stepDefinitions: TourStepDefinition[]): EuiTourStepProps[] => + stepDefinitions.map((stepDefinition, index) => ({ + step: index + 1, + anchor: stepDefinition.anchor, + title: stepDefinition.title, + anchorPosition: stepDefinition.anchorPosition, + maxWidth: MAX_WIDTH, + content: ( + <> + +

{stepDefinition.content}

+
+ + ), + })) as EuiTourStepProps[]; + +export const findNextAvailableStep = ( + steps: EuiTourStepProps[], + currentTourStep: number +): number | null => { + const nextStep = steps.find( + (step) => + step.step > currentTourStep && + typeof step.anchor === 'string' && + document.querySelector(step.anchor) + ); + + return nextStep?.step ?? null; +}; diff --git a/src/plugins/dashboard/public/application/tour/translations.ts b/src/plugins/dashboard/public/application/tour/translations.ts new file mode 100644 index 0000000000000..1ef1ab77a03c0 --- /dev/null +++ b/src/plugins/dashboard/public/application/tour/translations.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; + +export const DashboardTourStrings = { + viewModeTour: { + getTitle: () => + i18n.translate('dashboard.tour.viewMode.title', { + defaultMessage: 'Ready to create beautiful visualizations?', + }), + getDescription: () => + i18n.translate('dashboard.tour.viewMode.firstStep.description', { + defaultMessage: 'Open your dashboard in Edit mode here.', + }), + }, + editModeTour: { + createVisualization: { + getTitle: () => + i18n.translate('dashboard.tour.editMode.createVisualization.title', { + defaultMessage: 'Get creative', + }), + getDescription: () => + i18n.translate('dashboard.tour.editMode.createVisualization.description', { + defaultMessage: + 'Create charts, maps, and other visualizations that best display your data.', + }), + }, + panelOptions: { + getTitle: () => + i18n.translate('dashboard.tour.editMode.panelOptions.title', { + defaultMessage: 'Add your style', + }), + getDescription: () => + i18n.translate('dashboard.tour.editMode.panelOptions.description', { + defaultMessage: 'Customize your visualization to add a personal touch.', + }), + }, + timePicker: { + getTitle: () => + i18n.translate('dashboard.tour.editMode.timePicker.title', { + defaultMessage: 'Adjust the time range', + }), + getDescription: () => + i18n.translate('dashboard.tour.editMode.timePicker.description', { + defaultMessage: + 'View data for a particular day, the last year, or whatever time range you want.', + }), + }, + filters: { + getTitle: () => + i18n.translate('dashboard.tour.editMode.addFilter.title', { + defaultMessage: 'Refine your data', + }), + getDescription: () => + i18n.translate('dashboard.tour.editMode.addFilter.description', { + defaultMessage: 'Filter for only the data you want to explore.', + }), + }, + controls: { + getTitle: () => + i18n.translate('dashboard.tour.editMode.controls.title', { + defaultMessage: 'Make it interactive', + }), + getDescription: () => + i18n.translate('dashboard.tour.editMode.controls.description', { + defaultMessage: 'Add Controls, or custom filters, for a more engaging experience.', + }), + }, + }, +}; diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/open_add_panel_flyout.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/open_add_panel_flyout.tsx index 4cc5a7ccb6e11..7e70b4451a02c 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/open_add_panel_flyout.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/open_add_panel_flyout.tsx @@ -19,6 +19,7 @@ export function openAddPanelFlyout(options: { getFactory: EmbeddableStart['getEmbeddableFactory']; getAllFactories: EmbeddableStart['getEmbeddableFactories']; overlays: OverlayStart; + showTour?: () => void; notifications: NotificationsStart; SavedObjectFinder: React.ComponentType; showCreateNewMenu?: boolean; @@ -30,21 +31,26 @@ export function openAddPanelFlyout(options: { getFactory, getAllFactories, overlays, + showTour, notifications, SavedObjectFinder, showCreateNewMenu, reportUiCounter, theme, } = options; + + const onClose = (flyoutSession: OverlayRef) => { + if (flyoutSession) { + flyoutSession.close(); + } + showTour?.(); + }; + const flyoutSession = overlays.openFlyout( toMountPoint( { - if (flyoutSession) { - flyoutSession.close(); - } - }} + onClose={() => onClose(flyoutSession)} getFactory={getFactory} getAllFactories={getAllFactories} notifications={notifications} @@ -57,6 +63,7 @@ export function openAddPanelFlyout(options: { { 'data-test-subj': 'dashboardAddPanel', ownFocus: true, + onClose: () => onClose(flyoutSession), } ); return flyoutSession; diff --git a/test/accessibility/apps/dashboard_panel.ts b/test/accessibility/apps/dashboard_panel.ts index b7975738104ce..7943cdbc5b7e9 100644 --- a/test/accessibility/apps/dashboard_panel.ts +++ b/test/accessibility/apps/dashboard_panel.ts @@ -17,7 +17,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('Dashboard Panel', () => { before(async () => { await PageObjects.common.navigateToApp('dashboard'); - await testSubjects.click('dashboardListingTitleLink-[Flights]-Global-Flight-Dashboard'); + await PageObjects.dashboard.loadSavedDashboard('[Flights] Global Flight Dashboard'); }); it('dashboard panel open ', async () => { diff --git a/test/functional/apps/dashboard/group3/bwc_shared_urls.ts b/test/functional/apps/dashboard/group3/bwc_shared_urls.ts index 35d13b715c14c..62c43e58d68ea 100644 --- a/test/functional/apps/dashboard/group3/bwc_shared_urls.ts +++ b/test/functional/apps/dashboard/group3/bwc_shared_urls.ts @@ -95,6 +95,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('6.0 urls', () => { let savedDashboardId: string; + beforeEach(async () => { + await PageObjects.dashboard.forceSkipTour(); + }); + it('loads an unsaved dashboard', async function () { const url = `${kibanaLegacyBaseUrl}#/dashboard?${urlQuery}`; log.debug(`Navigating to ${url}`); diff --git a/test/functional/apps/dashboard/group3/panel_context_menu.ts b/test/functional/apps/dashboard/group3/panel_context_menu.ts index f78cd27614b3b..dcc63e643e353 100644 --- a/test/functional/apps/dashboard/group3/panel_context_menu.ts +++ b/test/functional/apps/dashboard/group3/panel_context_menu.ts @@ -140,6 +140,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { before('reset dashboard', async () => { const currentUrl = await browser.getCurrentUrl(); await browser.get(currentUrl.toString(), false); + await PageObjects.dashboard.forceSkipTour(); }); before('and add one panel and save to put dashboard in "view" mode', async () => { diff --git a/test/functional/apps/dashboard/group6/dashboard_tour.ts b/test/functional/apps/dashboard/group6/dashboard_tour.ts new file mode 100644 index 0000000000000..0bb90c63f5293 --- /dev/null +++ b/test/functional/apps/dashboard/group6/dashboard_tour.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const browser = getService('browser'); + const find = getService('find'); + const retry = getService('retry'); + const PageObjects = getPageObjects(['common', 'dashboard']); + + const tourHeaderExists = async () => { + return await find.exists(async () => { + return await find.byXPath('//div[@class="euiPopoverTitle euiTourHeader"]'); + }); + }; + + describe('dashboard tour', function () { + before(async () => { + await PageObjects.dashboard.initTests(); + await PageObjects.dashboard.clickNewDashboard(); + await PageObjects.dashboard.saveDashboard('Test Dashboard Tour'); + }); + + describe('in view mode', async () => { + before(async () => { + await browser.removeLocalStorageItem('dashboard.view.tourState'); + await browser.refresh(); + }); + + it('tour starts', async () => { + await PageObjects.dashboard.clickCancelOutOfEditMode(); + expect(await tourHeaderExists()).to.be(true); + }); + + it('can manually skip tour', async () => { + const endTourButton = await find.byButtonText('Close tour'); + await endTourButton.click(); + retry.try(async () => { + expect(await tourHeaderExists()).to.be(false); + }); + }); + + it('can skip tour by clicking edit button', async () => { + await browser.removeLocalStorageItem('dashboard.view.tourState'); + await browser.refresh(); + expect(await tourHeaderExists()).to.be(true); + + await PageObjects.dashboard.switchToEditMode(); + expect(await tourHeaderExists()).to.be(false); + await PageObjects.dashboard.clickCancelOutOfEditMode(); + expect(await tourHeaderExists()).to.be(false); + }); + }); + + describe('in edit mode', async () => { + before(async () => { + await browser.removeLocalStorageItem('dashboard.edit.tourState'); + await browser.refresh(); + }); + + it('tour starts', async () => { + await PageObjects.dashboard.switchToEditMode(); + expect(await tourHeaderExists()).to.be(true); + }); + + it('can manually skip tour', async () => { + const endTourButton = await find.byButtonText('Skip tour'); + await endTourButton.click(); + retry.try(async () => { + expect(await tourHeaderExists()).to.be(false); + }); + }); + }); + }); +} diff --git a/test/functional/apps/dashboard/group6/index.ts b/test/functional/apps/dashboard/group6/index.ts index f78f7e2d549b8..e220cdebdc678 100644 --- a/test/functional/apps/dashboard/group6/index.ts +++ b/test/functional/apps/dashboard/group6/index.ts @@ -37,6 +37,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./dashboard_error_handling')); loadTestFile(require.resolve('./legacy_urls')); loadTestFile(require.resolve('./saved_search_embeddable')); + loadTestFile(require.resolve('./dashboard_tour')); // Note: This one must be last because it unloads some data for one of its tests! // No, this isn't ideal, but loading/unloading takes so much time and these are all bunched diff --git a/test/functional/page_objects/common_page.ts b/test/functional/page_objects/common_page.ts index 83bb0567a02f5..b008c797278a5 100644 --- a/test/functional/page_objects/common_page.ts +++ b/test/functional/page_objects/common_page.ts @@ -281,6 +281,13 @@ export class CommonPageObject extends FtrService { await this.browser.setLocalStorageItem('data.autocompleteFtuePopover', 'true'); await this.browser.setLocalStorageItem('data.newDataViewMenu', 'true'); } + if (appName === 'dashboard') { + const disableTour = JSON.stringify({ + isTourActive: false, + }); + await this.browser.setLocalStorageItem('dashboard.edit.tourState', disableTour); + await this.browser.setLocalStorageItem('dashboard.view.tourState', disableTour); + } return currentUrl; }); diff --git a/test/functional/page_objects/dashboard_page.ts b/test/functional/page_objects/dashboard_page.ts index 4ba341a4d8f3f..6e9bf7c196e9c 100644 --- a/test/functional/page_objects/dashboard_page.ts +++ b/test/functional/page_objects/dashboard_page.ts @@ -74,6 +74,14 @@ export class DashboardPageObject extends FtrService { await this.header.waitUntilLoadingHasFinished(); } + public async forceSkipTour() { + const disableTour = JSON.stringify({ + isTourActive: false, + }); + await this.browser.setLocalStorageItem('dashboard.edit.tourState', disableTour); + await this.browser.setLocalStorageItem('dashboard.view.tourState', disableTour); + } + public async clickFullScreenMode() { this.log.debug(`clickFullScreenMode`); await this.testSubjects.click('dashboardFullScreenMode'); @@ -203,6 +211,7 @@ export class DashboardPageObject extends FtrService { }); await this.expectExistsDashboardLandingPage(); } + await this.forceSkipTour(); } public async clickClone() { @@ -326,7 +335,9 @@ export class DashboardPageObject extends FtrService { } } - public async clickNewDashboard(continueEditing = false) { + public async clickNewDashboard(continueEditing = false, skipTour = true) { + if (skipTour) await this.forceSkipTour(); + const discardButtonExists = await this.testSubjects.exists('discardDashboardPromptButton'); if (!continueEditing && discardButtonExists) { this.log.debug('found discard button'); @@ -348,7 +359,9 @@ export class DashboardPageObject extends FtrService { await this.waitForRenderComplete(); } - public async clickNewDashboardExpectWarning(continueEditing = false) { + public async clickNewDashboardExpectWarning(continueEditing = false, skipTour = true) { + if (skipTour) await this.forceSkipTour(); + const discardButtonExists = await this.testSubjects.exists('discardDashboardPromptButton'); if (!continueEditing && discardButtonExists) { this.log.debug('found discard button'); @@ -370,6 +383,7 @@ export class DashboardPageObject extends FtrService { } public async clickCreateDashboardPrompt() { + await this.forceSkipTour(); await this.testSubjects.click('createDashboardPromptButton'); } diff --git a/test/functional/page_objects/home_page.ts b/test/functional/page_objects/home_page.ts index 4acd8a6e10e95..73ae5105070cc 100644 --- a/test/functional/page_objects/home_page.ts +++ b/test/functional/page_objects/home_page.ts @@ -13,6 +13,7 @@ export class HomePageObject extends FtrService { private readonly retry = this.ctx.getService('retry'); private readonly find = this.ctx.getService('find'); private readonly common = this.ctx.getPageObject('common'); + private readonly dashboard = this.ctx.getPageObject('dashboard'); private readonly log = this.ctx.getService('log'); async clickSynopsis(title: string) { @@ -85,6 +86,7 @@ export class HomePageObject extends FtrService { async launchSampleDashboard(id: string) { await this.launchSampleDataSet(id); + await this.dashboard.forceSkipTour(); await this.find.clickByLinkText('Dashboard'); } diff --git a/test/functional/page_objects/time_to_visualize_page.ts b/test/functional/page_objects/time_to_visualize_page.ts index 57a22103f6409..ab8a864b5bff4 100644 --- a/test/functional/page_objects/time_to_visualize_page.ts +++ b/test/functional/page_objects/time_to_visualize_page.ts @@ -68,6 +68,7 @@ export class TimeToVisualizePageObject extends FtrService { let option: DashboardPickerOption = 'add-to-library-option'; if (addToDashboard) { option = dashboardId ? 'existing-dashboard-option' : 'new-dashboard-option'; + await this.dashboard.forceSkipTour(); } this.log.debug('save modal dashboard selector, choosing option:', option); const dashboardSelector = await this.testSubjects.find('add-to-dashboard-options');