diff --git a/src/plugins/controls/public/time_slider/embeddable/time_slider_embeddable.tsx b/src/plugins/controls/public/time_slider/embeddable/time_slider_embeddable.tsx index ffb7bd2610fc55..98c36bcec4eea1 100644 --- a/src/plugins/controls/public/time_slider/embeddable/time_slider_embeddable.tsx +++ b/src/plugins/controls/public/time_slider/embeddable/time_slider_embeddable.tsx @@ -41,6 +41,11 @@ export class TimeSliderControlEmbeddable extends Embeddable< private getDateFormat: ControlsSettingsService['getDateFormat']; private getTimezone: ControlsSettingsService['getTimezone']; private timefilter: ControlsDataService['timefilter']; + private prevTimeRange: TimeRange | undefined; + private prevTimesliceAsPercentage: { + timesliceStartAsPercentageOfTimeRange?: number; + timesliceEndAsPercentageOfTimeRange?: number; + }; private readonly waitForControlOutputConsumersToLoad$; private reduxEmbeddableTools: ReduxEmbeddableTools< @@ -98,6 +103,10 @@ export class TimeSliderControlEmbeddable extends Embeddable< ) : undefined; + this.prevTimesliceAsPercentage = { + timesliceStartAsPercentageOfTimeRange: this.getInput().timesliceStartAsPercentageOfTimeRange, + timesliceEndAsPercentageOfTimeRange: this.getInput().timesliceEndAsPercentageOfTimeRange, + }; this.syncWithTimeRange(); } @@ -111,17 +120,34 @@ export class TimeSliderControlEmbeddable extends Embeddable< private onInputChange() { const input = this.getInput(); + const { timesliceStartAsPercentageOfTimeRange, timesliceEndAsPercentageOfTimeRange } = + this.prevTimesliceAsPercentage ?? {}; - if (!input.timeRange) { - return; - } - - const nextBounds = this.timeRangeToBounds(input.timeRange); - const { actions, dispatch, getState } = this.reduxEmbeddableTools; - if (!_.isEqual(nextBounds, getState().componentState.timeRangeBounds)) { + const { actions, dispatch } = this.reduxEmbeddableTools; + if ( + timesliceStartAsPercentageOfTimeRange !== input.timesliceStartAsPercentageOfTimeRange || + timesliceEndAsPercentageOfTimeRange !== input.timesliceEndAsPercentageOfTimeRange + ) { + // Discarding edit mode changes results in replacing edited input with original input + // Re-sync with time range when edited input timeslice changes are discarded + if ( + !input.timesliceStartAsPercentageOfTimeRange && + !input.timesliceEndAsPercentageOfTimeRange + ) { + // If no selections have been saved into the timeslider, then both `timesliceStartAsPercentageOfTimeRange` + // and `timesliceEndAsPercentageOfTimeRange` will be undefined - so, need to reset component state to match + dispatch(actions.publishValue({ value: undefined })); + dispatch(actions.setValue({ value: undefined })); + } else { + // Otherwise, need to call `syncWithTimeRange` so that the component state value can be calculated and set + this.syncWithTimeRange(); + } + } else if (input.timeRange && !_.isEqual(input.timeRange, this.prevTimeRange)) { + const nextBounds = this.timeRangeToBounds(input.timeRange); + const ticks = getTicks(nextBounds[FROM_INDEX], nextBounds[TO_INDEX], this.getTimezone()); dispatch( actions.setTimeRangeBounds({ - ticks: getTicks(nextBounds[FROM_INDEX], nextBounds[TO_INDEX], this.getTimezone()), + ticks, timeRangeBounds: nextBounds, }) ); @@ -135,6 +161,7 @@ export class TimeSliderControlEmbeddable extends Embeddable< getState().explicitInput.timesliceStartAsPercentageOfTimeRange; const timesliceEndAsPercentageOfTimeRange = getState().explicitInput.timesliceEndAsPercentageOfTimeRange; + if ( timesliceStartAsPercentageOfTimeRange !== undefined && timesliceEndAsPercentageOfTimeRange !== undefined @@ -167,8 +194,8 @@ export class TimeSliderControlEmbeddable extends Embeddable< dispatch(actions.publishValue({ value })); }, 500); - private onTimesliceChange = (value?: [number, number]) => { - const { actions, dispatch, getState } = this.reduxEmbeddableTools; + private getTimeSliceAsPercentageOfTimeRange(value?: [number, number]) { + const { getState } = this.reduxEmbeddableTools; let timesliceStartAsPercentageOfTimeRange: number | undefined; let timesliceEndAsPercentageOfTimeRange: number | undefined; if (value) { @@ -179,6 +206,18 @@ export class TimeSliderControlEmbeddable extends Embeddable< timesliceEndAsPercentageOfTimeRange = (value[TO_INDEX] - timeRangeBounds[FROM_INDEX]) / timeRange; } + this.prevTimesliceAsPercentage = { + timesliceStartAsPercentageOfTimeRange, + timesliceEndAsPercentageOfTimeRange, + }; + return { timesliceStartAsPercentageOfTimeRange, timesliceEndAsPercentageOfTimeRange }; + } + + private onTimesliceChange = (value?: [number, number]) => { + const { actions, dispatch } = this.reduxEmbeddableTools; + + const { timesliceStartAsPercentageOfTimeRange, timesliceEndAsPercentageOfTimeRange } = + this.getTimeSliceAsPercentageOfTimeRange(value); dispatch( actions.setValueAsPercentageOfTimeRange({ timesliceStartAsPercentageOfTimeRange, diff --git a/src/plugins/presentation_util/public/redux_embeddables/create_redux_embeddable_tools.tsx b/src/plugins/presentation_util/public/redux_embeddables/create_redux_embeddable_tools.tsx index f6400d1424ffe0..df5384da03d282 100644 --- a/src/plugins/presentation_util/public/redux_embeddables/create_redux_embeddable_tools.tsx +++ b/src/plugins/presentation_util/public/redux_embeddables/create_redux_embeddable_tools.tsx @@ -48,17 +48,17 @@ export const createReduxEmbeddableTools = < }): ReduxEmbeddableTools => { // Additional generic reducers to aid in embeddable syncing const genericReducers = { - updateEmbeddableReduxInput: ( + replaceEmbeddableReduxInput: ( state: Draft, - action: PayloadAction> + action: PayloadAction ) => { - state.explicitInput = { ...state.explicitInput, ...action.payload }; + state.explicitInput = action.payload; }, - updateEmbeddableReduxOutput: ( + replaceEmbeddableReduxOutput: ( state: Draft, - action: PayloadAction> + action: PayloadAction ) => { - state.output = { ...state.output, ...action.payload }; + state.output = action.payload; }, }; diff --git a/src/plugins/presentation_util/public/redux_embeddables/sync_redux_embeddable.ts b/src/plugins/presentation_util/public/redux_embeddables/sync_redux_embeddable.ts index 5f4dd20818ba21..37aadd1e9fe478 100644 --- a/src/plugins/presentation_util/public/redux_embeddables/sync_redux_embeddable.ts +++ b/src/plugins/presentation_util/public/redux_embeddables/sync_redux_embeddable.ts @@ -81,7 +81,7 @@ export const syncReduxEmbeddable = < if (!inputEqual(reduxExplicitInput, embeddableExplictInput)) { store.dispatch( - actions.updateEmbeddableReduxInput(cleanInputForRedux(embeddableExplictInput)) + actions.replaceEmbeddableReduxInput(cleanInputForRedux(embeddableExplictInput)) ); } embeddableToReduxInProgress = false; @@ -93,7 +93,7 @@ export const syncReduxEmbeddable = < embeddableToReduxInProgress = true; const reduxState = store.getState(); if (!outputEqual(reduxState.output, embeddableOutput)) { - store.dispatch(actions.updateEmbeddableReduxOutput(embeddableOutput)); + store.dispatch(actions.replaceEmbeddableReduxOutput(embeddableOutput)); } embeddableToReduxInProgress = false; }); diff --git a/test/functional/apps/dashboard_elements/controls/options_list.ts b/test/functional/apps/dashboard_elements/controls/options_list.ts index e7ec21d6164827..779c4e2100024b 100644 --- a/test/functional/apps/dashboard_elements/controls/options_list.ts +++ b/test/functional/apps/dashboard_elements/controls/options_list.ts @@ -388,6 +388,23 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await pieChart.getPieSliceCount()).to.be(2); await dashboard.clearUnsavedChanges(); }); + + it('changes to selections can be discarded', async () => { + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverSelectOption('bark'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + let selections = await dashboardControls.optionsListGetSelectionsString(controlId); + expect(selections).to.equal('hiss, grr, bark'); + + await dashboard.clickCancelOutOfEditMode(); + selections = await dashboardControls.optionsListGetSelectionsString(controlId); + expect(selections).to.equal('hiss, grr'); + }); + + it('dashboard does not load with unsaved changes when changes are discarded', async () => { + await dashboard.switchToEditMode(); + await testSubjects.missingOrFail('dashboardUnsavedChangesBadge'); + }); }); describe('test data view runtime field', async () => { diff --git a/test/functional/apps/dashboard_elements/controls/range_slider.ts b/test/functional/apps/dashboard_elements/controls/range_slider.ts index e75c83c53bc18b..17d787b55c5d1f 100644 --- a/test/functional/apps/dashboard_elements/controls/range_slider.ts +++ b/test/functional/apps/dashboard_elements/controls/range_slider.ts @@ -183,6 +183,27 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const firstId = (await dashboardControls.getAllControlIds())[0]; await dashboardControls.rangeSliderClearSelection(firstId); await dashboardControls.validateRange('value', firstId, '', ''); + await dashboard.clearUnsavedChanges(); + }); + + it('making changes to range causes unsaved changes', async () => { + const firstId = (await dashboardControls.getAllControlIds())[0]; + await dashboardControls.rangeSliderSetLowerBound(firstId, '0'); + await dashboardControls.rangeSliderSetUpperBound(firstId, '3'); + await dashboardControls.rangeSliderWaitForLoading(); + await testSubjects.existOrFail('dashboardUnsavedChangesBadge'); + }); + + it('changes to range can be discarded', async () => { + const firstId = (await dashboardControls.getAllControlIds())[0]; + await dashboardControls.validateRange('value', firstId, '0', '3'); + await dashboard.clickCancelOutOfEditMode(); + await dashboardControls.validateRange('value', firstId, '', ''); + }); + + it('dashboard does not load with unsaved changes when changes are discarded', async () => { + await dashboard.switchToEditMode(); + await testSubjects.missingOrFail('dashboardUnsavedChangesBadge'); }); it('deletes an existing control', async () => { diff --git a/test/functional/apps/dashboard_elements/controls/time_slider.ts b/test/functional/apps/dashboard_elements/controls/time_slider.ts index 985d31400efc3b..88236a43860f27 100644 --- a/test/functional/apps/dashboard_elements/controls/time_slider.ts +++ b/test/functional/apps/dashboard_elements/controls/time_slider.ts @@ -16,6 +16,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const security = getService('security'); const testSubjects = getService('testSubjects'); const kibanaServer = getService('kibanaServer'); + const { dashboardControls, discover, timePicker, common, dashboard } = getPageObjects([ 'dashboardControls', 'discover', @@ -52,7 +53,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await security.testUser.restoreDefaults(); }); - describe('create and delete', async () => { + describe('create, edit, and delete', async () => { before(async () => { await common.navigateToApp('dashboard'); await dashboard.preserveCrossAppState(); @@ -62,11 +63,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'Oct 22, 2018 @ 00:00:00.000', 'Dec 3, 2018 @ 00:00:00.000' ); + await dashboard.saveDashboard('test time slider control', { exitFromEditMode: false }); }); it('can create a new time slider control from a blank state', async () => { await dashboardControls.createTimeSliderControl(); expect(await dashboardControls.getControlsCount()).to.be(1); + await dashboard.clearUnsavedChanges(); }); it('can not add a second time slider control', async () => { @@ -87,13 +90,34 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardControls.validateRange('placeholder', secondId, '100', '1200'); }); - it('applies filter from the first control on the second control', async () => { + it('making changes to time slice causes unsaved changes', async () => { await dashboardControls.gotoNextTimeSlice(); + await dashboard.clearUnsavedChanges(); + }); + + it('applies filter from the first control on the second control', async () => { await dashboardControls.rangeSliderWaitForLoading(); const secondId = (await dashboardControls.getAllControlIds())[1]; await dashboardControls.validateRange('placeholder', secondId, '101', '1000'); }); + it('changes to time slice can be discarded', async () => { + const valueBefore = await dashboardControls.getTimeSliceFromTimeSlider(); + await dashboardControls.gotoNextTimeSlice(); + const valueAfter = await dashboardControls.getTimeSliceFromTimeSlider(); + expect(valueBefore).to.not.equal(valueAfter); + + await dashboardControls.closeTimeSliderPopover(); + await dashboard.clickCancelOutOfEditMode(); + const valueNow = await dashboardControls.getTimeSliceFromTimeSlider(); + expect(valueNow).to.equal(valueBefore); + }); + + it('dashboard does not load with unsaved changes when changes are discarded', async () => { + await dashboard.switchToEditMode(); + await testSubjects.missingOrFail('dashboardUnsavedChangesBadge'); + }); + it('deletes an existing control', async () => { const firstId = (await dashboardControls.getAllControlIds())[0]; await dashboardControls.removeExistingControl(firstId); diff --git a/test/functional/page_objects/dashboard_page_controls.ts b/test/functional/page_objects/dashboard_page_controls.ts index 4980898a58c1b9..2a483220b8da8c 100644 --- a/test/functional/page_objects/dashboard_page_controls.ts +++ b/test/functional/page_objects/dashboard_page_controls.ts @@ -564,4 +564,21 @@ export class DashboardPageControls extends FtrService { await this.testSubjects.click('timeSlider-popoverToggleButton'); } } + + public async getTimeSliceFromTimeSlider() { + const isOpen = await this.testSubjects.exists('timeSlider-popoverContents'); + if (!isOpen) { + await this.testSubjects.click('timeSlider-popoverToggleButton'); + await this.retry.try(async () => { + await this.testSubjects.existOrFail('timeSlider-popoverContents'); + }); + } + const popover = await this.testSubjects.find('timeSlider-popoverContents'); + const dualRangeSlider = await this.find.descendantDisplayedByCssSelector( + '.euiRangeDraggable', + popover + ); + const value = await dualRangeSlider.getAttribute('aria-valuetext'); + return value; + } }