[Controls] Add "Apply" button (#174714)

Closes https://github.com/elastic/kibana/issues/170396
Closes https://github.com/elastic/kibana/issues/135459

## Summary

This PR adds the option to **stop** selections from being auto-applied -
instead, authors can make it so that their selections are only applied
once the new apply button is clicked:



d785631c-0aa5-4e3f-81f9-2d1d0f582b70

### Brief Summary of Changes

- **Publishing Filters**
We used to publish the control group filters as soon as **any** child
changed its output - however, if the apply button is enabled, this logic
no longer works. So, we added an extra step to the publishing of
filters:

1. When a child's output changes, we check if the apply button is
enabled
2. If it is disabled, we publish the filters to the control group output
right away (like we used to); otherwise, we push the new filters to the
`unpublishedFilters` array.
3. Clicking the apply button will publish the `unpublishedFilters`
array.

- **Unsaved Changes**
We used to publish control group unsaved changes whenever **anything**
about the children panels' persistable input changed - however, this no
longer works with the apply button because we **don't** want selections
to trigger unsaved changes when it is enabled.
   
To get around this, we **no longer** take into account selections when
checking for unsaved changes - instead, we compare the **output
filters** from the control group to determine whether anything about the
children changed. As described above, if the control group has
"auto-apply selections" turned off, the control group waits to change
its output until the apply button is clicked - therefore, unsaved
changes will **also** not be triggered until the apply button is
clicked. This unsaved changes logic works **regardless** of if the apply
button is enabled or not.

- **Saved Object**
This required changes to the **dashboard** saved object because of how
we store the control group input as part of the dashboard SO - so, we
are now on a second version for the Dashboard SO. I've also made this
version **slightly less strict** by allowing unknown attributes in the
schema - that way, unknown attributes will no longer throw an error (as
they do on version 1).

### Checklist

- [x] 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/packages/kbn-i18n/README.md)
- [x] [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
- [x] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)


### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Nick Peihl <nick.peihl@elastic.co>
This commit is contained in:
Hannah Mudge 2024-03-12 12:22:39 -06:00 committed by GitHub
parent f549bffb15
commit 235c4d5ad7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 1375 additions and 315 deletions

View file

@ -246,6 +246,7 @@
"controlGroupInput.controlStyle",
"controlGroupInput.ignoreParentSettingsJSON",
"controlGroupInput.panelsJSON",
"controlGroupInput.showApplySelections",
"description",
"hits",
"kibanaSavedObjectMeta",

View file

@ -837,6 +837,11 @@
"panelsJSON": {
"index": false,
"type": "text"
},
"showApplySelections": {
"doc_values": false,
"index": false,
"type": "boolean"
}
}
},

View file

@ -82,7 +82,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
"connector_token": "5a9ac29fe9c740eb114e9c40517245c71706b005",
"core-usage-stats": "b3c04da317c957741ebcdedfea4524049fdc79ff",
"csp-rule-template": "c151324d5f85178169395eecb12bac6b96064654",
"dashboard": "0611794ce10d25a36da0770c91376c575e92e8f2",
"dashboard": "211e9ca30f5a95d5f3c27b1bf2b58e6cfa0c9ae9",
"endpoint:user-artifact-manifest": "1c3533161811a58772e30cdc77bac4631da3ef2b",
"enterprise_search_telemetry": "9ac912e1417fc8681e0cd383775382117c9e3d3d",
"epm-packages": "c23d3d00c051a08817335dba26f542b64b18a56a",

View file

@ -16,7 +16,11 @@ import { TimeSliderControlEmbeddableInput, TIME_SLIDER_CONTROL } from '../time_s
import { ControlPanelState } from './types';
interface DiffSystem {
getPanelIsEqual: (initialInput: ControlPanelState, newInput: ControlPanelState) => boolean;
getPanelIsEqual: (
initialInput: ControlPanelState,
newInput: ControlPanelState,
compareSelections?: boolean
) => boolean;
}
export const genericControlPanelDiffSystem: DiffSystem = {
@ -29,7 +33,7 @@ export const ControlPanelDiffSystems: {
[key: string]: DiffSystem;
} = {
[RANGE_SLIDER_CONTROL]: {
getPanelIsEqual: (initialInput, newInput) => {
getPanelIsEqual: (initialInput, newInput, compareSelections) => {
if (!deepEqual(omit(initialInput, 'explicitInput'), omit(newInput, 'explicitInput'))) {
return false;
}
@ -38,11 +42,11 @@ export const ControlPanelDiffSystems: {
initialInput.explicitInput;
const { value: valueB = ['', ''], ...inputB }: Partial<RangeSliderEmbeddableInput> =
newInput.explicitInput;
return isEqual(valueA, valueB) && deepEqual(inputA, inputB);
return (compareSelections ? isEqual(valueA, valueB) : true) && deepEqual(inputA, inputB);
},
},
[OPTIONS_LIST_CONTROL]: {
getPanelIsEqual: (initialInput, newInput) => {
getPanelIsEqual: (initialInput, newInput, compareSelections) => {
if (!deepEqual(omit(initialInput, 'explicitInput'), omit(newInput, 'explicitInput'))) {
return false;
}
@ -75,22 +79,24 @@ export const ControlPanelDiffSystems: {
}: Partial<OptionsListEmbeddableInput> = newInput.explicitInput;
return (
Boolean(excludeA) === Boolean(excludeB) &&
Boolean(hideSortA) === Boolean(hideSortB) &&
Boolean(hideExistsA) === Boolean(hideExistsB) &&
Boolean(hideExcludeA) === Boolean(hideExcludeB) &&
Boolean(singleSelectA) === Boolean(singleSelectB) &&
Boolean(existsSelectedA) === Boolean(existsSelectedB) &&
Boolean(runPastTimeoutA) === Boolean(runPastTimeoutB) &&
isEqual(searchTechniqueA ?? 'prefix', searchTechniqueB ?? 'prefix') &&
deepEqual(sortA ?? OPTIONS_LIST_DEFAULT_SORT, sortB ?? OPTIONS_LIST_DEFAULT_SORT) &&
isEqual(selectedA ?? [], selectedB ?? []) &&
(compareSelections
? Boolean(excludeA) === Boolean(excludeB) &&
Boolean(existsSelectedA) === Boolean(existsSelectedB) &&
isEqual(selectedA ?? [], selectedB ?? [])
: true) &&
deepEqual(inputA, inputB)
);
},
},
[TIME_SLIDER_CONTROL]: {
getPanelIsEqual: (initialInput, newInput) => {
getPanelIsEqual: (initialInput, newInput, compareSelections) => {
if (!deepEqual(omit(initialInput, 'explicitInput'), omit(newInput, 'explicitInput'))) {
return false;
}
@ -107,10 +113,12 @@ export const ControlPanelDiffSystems: {
}: Partial<TimeSliderControlEmbeddableInput> = newInput.explicitInput;
return (
Boolean(isAnchoredA) === Boolean(isAnchoredB) &&
Boolean(startA) === Boolean(startB) &&
startA === startB &&
Boolean(endA) === Boolean(endB) &&
endA === endB
(compareSelections
? Boolean(startA) === Boolean(startB) &&
startA === startB &&
Boolean(endA) === Boolean(endB) &&
endA === endB
: true)
);
},
},

View file

@ -44,6 +44,7 @@ export const getDefaultControlGroupInput = (): Omit<ControlGroupInput, 'id'> =>
defaultControlGrow: DEFAULT_CONTROL_GROW,
controlStyle: DEFAULT_CONTROL_STYLE,
chainingSystem: 'HIERARCHICAL',
showApplySelections: false,
ignoreParentSettings: {
ignoreFilters: false,
ignoreQuery: false,
@ -57,30 +58,29 @@ export const getDefaultControlGroupPersistableInput = (): PersistableControlGrou
export const persistableControlGroupInputIsEqual = (
a: PersistableControlGroupInput | undefined,
b: PersistableControlGroupInput | undefined
b: PersistableControlGroupInput | undefined,
compareSelections: boolean = true
) => {
const defaultInput = getDefaultControlGroupInput();
const defaultInput = getDefaultControlGroupPersistableInput();
const inputA = {
...defaultInput,
...pick(a, ['panels', 'chainingSystem', 'controlStyle', 'ignoreParentSettings']),
...pick(a, persistableControlGroupInputKeys),
};
const inputB = {
...defaultInput,
...pick(b, ['panels', 'chainingSystem', 'controlStyle', 'ignoreParentSettings']),
...pick(b, persistableControlGroupInputKeys),
};
if (
getPanelsAreEqual(inputA.panels, inputB.panels) &&
deepEqual(omit(inputA, 'panels'), omit(inputB, 'panels'))
)
return true;
return false;
return (
getPanelsAreEqual(inputA.panels, inputB.panels, compareSelections) &&
deepEqual(omit(inputA, ['panels']), omit(inputB, ['panels']))
);
};
const getPanelsAreEqual = (
originalPanels: PersistableControlGroupInput['panels'],
newPanels: PersistableControlGroupInput['panels']
newPanels: PersistableControlGroupInput['panels'],
compareSelections: boolean
) => {
const originalPanelIds = Object.keys(originalPanels);
const newPanelIds = Object.keys(newPanels);
@ -94,7 +94,8 @@ const getPanelsAreEqual = (
const panelIsEqual = ControlPanelDiffSystems[newPanelType]
? ControlPanelDiffSystems[newPanelType].getPanelIsEqual(
originalPanels[panelId],
newPanels[panelId]
newPanels[panelId],
compareSelections
)
: genericControlPanelDiffSystem.getPanelIsEqual(originalPanels[panelId], newPanels[panelId]);
if (!panelIsEqual) return false;
@ -108,6 +109,7 @@ export const controlGroupInputToRawControlGroupAttributes = (
return {
controlStyle: controlGroupInput.controlStyle,
chainingSystem: controlGroupInput.chainingSystem,
showApplySelections: controlGroupInput.showApplySelections,
panelsJSON: JSON.stringify(controlGroupInput.panels),
ignoreParentSettingsJSON: JSON.stringify(controlGroupInput.ignoreParentSettings),
};
@ -131,8 +133,13 @@ export const rawControlGroupAttributesToControlGroupInput = (
rawControlGroupAttributes: RawControlGroupAttributes
): PersistableControlGroupInput | undefined => {
const defaultControlGroupInput = getDefaultControlGroupInput();
const { chainingSystem, controlStyle, ignoreParentSettingsJSON, panelsJSON } =
rawControlGroupAttributes;
const {
chainingSystem,
controlStyle,
showApplySelections,
ignoreParentSettingsJSON,
panelsJSON,
} = rawControlGroupAttributes;
const panels = safeJSONParse<ControlGroupInput['panels']>(panelsJSON);
const ignoreParentSettings =
safeJSONParse<ControlGroupInput['ignoreParentSettings']>(ignoreParentSettingsJSON);
@ -140,6 +147,7 @@ export const rawControlGroupAttributesToControlGroupInput = (
...defaultControlGroupInput,
...(chainingSystem ? { chainingSystem } : {}),
...(controlStyle ? { controlStyle } : {}),
...(showApplySelections ? { showApplySelections } : {}),
...(ignoreParentSettings ? { ignoreParentSettings } : {}),
...(panels ? { panels } : {}),
};
@ -152,6 +160,7 @@ export const rawControlGroupAttributesToSerializable = (
return {
chainingSystem: rawControlGroupAttributes?.chainingSystem,
controlStyle: rawControlGroupAttributes?.controlStyle ?? defaultControlGroupInput.controlStyle,
showApplySelections: rawControlGroupAttributes?.showApplySelections,
ignoreParentSettings: safeJSONParse(rawControlGroupAttributes?.ignoreParentSettingsJSON) ?? {},
panels: safeJSONParse(rawControlGroupAttributes?.panelsJSON) ?? {},
};
@ -163,6 +172,7 @@ export const serializableToRawControlGroupAttributes = (
return {
controlStyle: serializable.controlStyle as RawControlGroupAttributes['controlStyle'],
chainingSystem: serializable.chainingSystem as RawControlGroupAttributes['chainingSystem'],
showApplySelections: Boolean(serializable.showApplySelections),
ignoreParentSettingsJSON: JSON.stringify(serializable.ignoreParentSettings),
panelsJSON: JSON.stringify(serializable.panels),
};

View file

@ -7,9 +7,10 @@
*/
import { EmbeddablePersistableStateService } from '@kbn/embeddable-plugin/common';
import { ControlGroupInput } from './types';
import { getDefaultControlGroupInput } from '..';
import { ControlGroupContainerFactory } from '../../public';
import { ControlGroupComponentState } from '../../public/control_group/types';
import { ControlGroupInput } from './types';
export const mockControlGroupInput = (partial?: Partial<ControlGroupInput>): ControlGroupInput => ({
id: 'mocked_control_group',
@ -48,14 +49,25 @@ export const mockControlGroupInput = (partial?: Partial<ControlGroupInput>): Con
...(partial ?? {}),
});
export const mockControlGroupContainer = async (explicitInput?: Partial<ControlGroupInput>) => {
export const mockControlGroupContainer = async (
explicitInput?: Partial<ControlGroupInput>,
initialComponentState?: Partial<ControlGroupComponentState>
) => {
const controlGroupFactoryStub = new ControlGroupContainerFactory(
{} as unknown as EmbeddablePersistableStateService
);
const controlGroupContainer = await controlGroupFactoryStub.create({
const input: ControlGroupInput = {
id: 'mocked-control-group',
...getDefaultControlGroupInput(),
...explicitInput,
};
const controlGroupContainer = await controlGroupFactoryStub.create(input, undefined, {
...initialComponentState,
lastSavedInput: {
panels: input.panels,
chainingSystem: 'HIERARCHICAL',
controlStyle: 'twoLine',
},
});
return controlGroupContainer;

View file

@ -31,6 +31,7 @@ export interface ControlGroupInput extends EmbeddableInput, ControlInput {
defaultControlGrow?: boolean;
controlStyle: ControlStyle;
panels: ControlsPanels;
showApplySelections?: boolean;
}
/**
@ -39,9 +40,9 @@ export interface ControlGroupInput extends EmbeddableInput, ControlInput {
export const persistableControlGroupInputKeys: Array<
keyof Pick<
ControlGroupInput,
'panels' | 'chainingSystem' | 'controlStyle' | 'ignoreParentSettings'
'panels' | 'chainingSystem' | 'controlStyle' | 'ignoreParentSettings' | 'showApplySelections'
>
> = ['panels', 'chainingSystem', 'controlStyle', 'ignoreParentSettings'];
> = ['panels', 'chainingSystem', 'controlStyle', 'ignoreParentSettings', 'showApplySelections'];
export type PersistableControlGroupInput = Pick<
ControlGroupInput,
typeof persistableControlGroupInputKeys[number]

View file

@ -12,6 +12,8 @@ import { EmbeddableInput } from '@kbn/embeddable-plugin/common/types';
export type ControlWidth = 'small' | 'medium' | 'large';
export type ControlStyle = 'twoLine' | 'oneLine';
export type TimeSlice = [number, number];
export interface ParentIgnoreSettings {
ignoreFilters?: boolean;
ignoreQuery?: boolean;
@ -23,7 +25,7 @@ export type ControlInput = EmbeddableInput & {
query?: Query;
filters?: Filter[];
timeRange?: TimeRange;
timeslice?: [number, number];
timeslice?: TimeSlice;
controlStyle?: ControlStyle;
ignoreParentSettings?: ParentIgnoreSettings;
};

View file

@ -19,11 +19,11 @@ import {
import { isErrorEmbeddable } from '@kbn/embeddable-plugin/public';
import { FloatingActions } from '@kbn/presentation-util-plugin/public';
import { useChildEmbeddable } from '../../hooks/use_child_embeddable';
import {
controlGroupSelector,
useControlGroupContainer,
} from '../embeddable/control_group_container';
import { useChildEmbeddable } from '../../hooks/use_child_embeddable';
import { ControlError } from './control_error_component';
export interface ControlFrameProps {
@ -58,13 +58,15 @@ export const ControlFrame = ({
const usingTwoLineLayout = controlStyle === 'twoLine';
useEffect(() => {
let mounted = true;
if (embeddableRoot.current) {
embeddable?.render(embeddableRoot.current);
}
const inputSubscription = embeddable
?.getInput$()
.subscribe((newInput) => setTitle(newInput.title));
const inputSubscription = embeddable?.getInput$().subscribe((newInput) => {
if (mounted) setTitle(newInput.title);
});
return () => {
mounted = false;
inputSubscription?.unsubscribe();
};
}, [embeddable, embeddableRoot]);

View file

@ -0,0 +1,202 @@
/*
* 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 { stubDataView } from '@kbn/data-views-plugin/common/data_view.stub';
import { pluginServices as presentationUtilPluginServices } from '@kbn/presentation-util-plugin/public/services';
import { registry as presentationUtilServicesRegistry } from '@kbn/presentation-util-plugin/public/services/plugin_services.story';
import { act, render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Provider } from 'react-redux';
import { OptionsListEmbeddableFactory } from '../..';
import { OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL } from '../../../common';
import { mockControlGroupContainer, mockControlGroupInput } from '../../../common/mocks';
import { RangeSliderEmbeddableFactory } from '../../range_slider';
import { pluginServices } from '../../services';
import { ControlGroupContainerContext } from '../embeddable/control_group_container';
import { ControlGroupComponentState, ControlGroupInput } from '../types';
import { ControlGroup } from './control_group_component';
jest.mock('@dnd-kit/core', () => ({
/** DnD kit has a memory leak based on this layout measuring strategy on unmount; setting it to undefined prevents this */
...jest.requireActual('@dnd-kit/core'),
LayoutMeasuringStrategy: { Always: undefined },
}));
describe('Control group component', () => {
interface MountOptions {
explicitInput?: Partial<ControlGroupInput>;
initialComponentState?: Partial<ControlGroupComponentState>;
}
presentationUtilServicesRegistry.start({});
presentationUtilPluginServices.setRegistry(presentationUtilServicesRegistry);
pluginServices.getServices().dataViews.get = jest.fn().mockResolvedValue(stubDataView);
pluginServices.getServices().dataViews.getIdsWithTitle = jest
.fn()
.mockResolvedValue([{ id: stubDataView.id, title: stubDataView.getIndexPattern() }]);
pluginServices.getServices().controls.getControlTypes = jest
.fn()
.mockReturnValue([OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL]);
pluginServices.getServices().controls.getControlFactory = jest
.fn()
.mockImplementation((type: string) => {
if (type === OPTIONS_LIST_CONTROL) return new OptionsListEmbeddableFactory();
if (type === RANGE_SLIDER_CONTROL) return new RangeSliderEmbeddableFactory();
});
async function mountComponent(options?: MountOptions) {
const controlGroupContainer = await mockControlGroupContainer(
mockControlGroupInput(options?.explicitInput),
options?.initialComponentState
);
const controlGroupComponent = render(
<Provider
// this store is ugly, but necessary because we are using controlGroupSelector rather than controlGroup.select
store={
{
subscribe: controlGroupContainer.onStateChange,
getState: controlGroupContainer.getState,
dispatch: jest.fn(),
} as any
}
>
<ControlGroupContainerContext.Provider value={controlGroupContainer}>
<ControlGroup />
</ControlGroupContainerContext.Provider>
</Provider>
);
await waitFor(() => {
// wait for control group to render all 3 controls before returning
expect(controlGroupComponent.queryAllByTestId('control-frame').length).toBe(3);
});
return { controlGroupComponent, controlGroupContainer };
}
test('does not render end button group by default', async () => {
const { controlGroupComponent } = await mountComponent();
expect(
controlGroupComponent.queryByTestId('controlGroup--endButtonGroup')
).not.toBeInTheDocument();
});
test('can render **just** add control button', async () => {
const { controlGroupComponent } = await mountComponent({
initialComponentState: { showAddButton: true },
});
expect(controlGroupComponent.queryByTestId('controlGroup--endButtonGroup')).toBeInTheDocument();
expect(
controlGroupComponent.queryByTestId('controlGroup--addControlButton')
).toBeInTheDocument();
expect(
controlGroupComponent.queryByTestId('controlGroup--applyFiltersButton')
).not.toBeInTheDocument();
});
test('can render **just** apply button', async () => {
const { controlGroupComponent } = await mountComponent({
explicitInput: { showApplySelections: true },
});
expect(controlGroupComponent.queryByTestId('controlGroup--endButtonGroup')).toBeInTheDocument();
expect(
controlGroupComponent.queryByTestId('controlGroup--addControlButton')
).not.toBeInTheDocument();
expect(
controlGroupComponent.queryByTestId('controlGroup--applyFiltersButton')
).toBeInTheDocument();
});
test('can render both buttons in the end button group', async () => {
const { controlGroupComponent } = await mountComponent({
explicitInput: { showApplySelections: true },
initialComponentState: { showAddButton: true },
});
expect(controlGroupComponent.queryByTestId('controlGroup--endButtonGroup')).toBeInTheDocument();
expect(
controlGroupComponent.queryByTestId('controlGroup--addControlButton')
).toBeInTheDocument();
expect(
controlGroupComponent.queryByTestId('controlGroup--applyFiltersButton')
).toBeInTheDocument();
});
test('enables apply button based on unpublished filters', async () => {
const { controlGroupComponent, controlGroupContainer } = await mountComponent({
explicitInput: { showApplySelections: true },
});
expect(controlGroupComponent.getByTestId('controlGroup--applyFiltersButton')).toBeDisabled();
act(() => controlGroupContainer.dispatch.setUnpublishedFilters({ filters: [] }));
expect(controlGroupComponent.getByTestId('controlGroup--applyFiltersButton')).toBeEnabled();
act(() => controlGroupContainer.dispatch.setUnpublishedFilters(undefined));
expect(controlGroupComponent.getByTestId('controlGroup--applyFiltersButton')).toBeDisabled();
act(() => controlGroupContainer.dispatch.setUnpublishedFilters({ timeslice: [0, 1] }));
expect(controlGroupComponent.getByTestId('controlGroup--applyFiltersButton')).toBeEnabled();
});
test('calls publish when apply button is clicked', async () => {
const { controlGroupComponent, controlGroupContainer } = await mountComponent({
explicitInput: { showApplySelections: true },
});
let applyButton = controlGroupComponent.getByTestId('controlGroup--applyFiltersButton');
expect(applyButton).toBeDisabled();
controlGroupContainer.publishFilters = jest.fn();
const unpublishedFilters: ControlGroupComponentState['unpublishedFilters'] = {
filters: [
{
query: { exists: { field: 'foo' } },
meta: { type: 'exists' },
},
],
timeslice: [0, 1],
};
act(() => controlGroupContainer.dispatch.setUnpublishedFilters(unpublishedFilters));
applyButton = controlGroupComponent.getByTestId('controlGroup--applyFiltersButton');
expect(applyButton).toBeEnabled();
userEvent.click(applyButton);
expect(controlGroupContainer.publishFilters).toBeCalledWith(unpublishedFilters);
});
test('ensure actions get rendered', async () => {
presentationUtilPluginServices.getServices().uiActions.getTriggerCompatibleActions = jest
.fn()
.mockImplementation(() => {
return [
{
isCompatible: jest.fn().mockResolvedValue(true),
id: 'testAction',
MenuItem: () => <div>test1</div>,
},
{
isCompatible: jest.fn().mockResolvedValue(true),
id: 'testAction2',
MenuItem: () => <div>test2</div>,
},
];
});
const { controlGroupComponent } = await mountComponent();
expect(
controlGroupComponent.queryByTestId('presentationUtil__floatingActions__control1')
).toBeInTheDocument();
expect(
controlGroupComponent.queryByTestId('presentationUtil__floatingActions__control2')
).toBeInTheDocument();
});
});

View file

@ -10,7 +10,6 @@ import '../control_group.scss';
import classNames from 'classnames';
import React, { useEffect, useMemo, useState } from 'react';
import { TypedUseSelectorHook, useSelector } from 'react-redux';
import {
closestCenter,
@ -38,28 +37,36 @@ import {
EuiIcon,
EuiPanel,
EuiText,
EuiToolTip,
EuiTourStep,
} from '@elastic/eui';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { ControlGroupStrings } from '../control_group_strings';
import { useControlGroupContainer } from '../embeddable/control_group_container';
import { ControlGroupReduxState } from '../types';
import {
controlGroupSelector,
useControlGroupContainer,
} from '../embeddable/control_group_container';
import { ControlClone, SortableControl } from './control_group_sortable_item';
const contextSelect = useSelector as TypedUseSelectorHook<ControlGroupReduxState>;
export const ControlGroup = () => {
const controlGroup = useControlGroupContainer();
// current state
const panels = contextSelect((state) => state.explicitInput.panels);
const viewMode = contextSelect((state) => state.explicitInput.viewMode);
const controlStyle = contextSelect((state) => state.explicitInput.controlStyle);
const showAddButton = contextSelect((state) => state.componentState.showAddButton);
const controlWithInvalidSelectionsId = contextSelect(
const panels = controlGroupSelector((state) => state.explicitInput.panels);
const viewMode = controlGroupSelector((state) => state.explicitInput.viewMode);
const controlStyle = controlGroupSelector((state) => state.explicitInput.controlStyle);
const showApplySelections = controlGroupSelector(
(state) => state.explicitInput.showApplySelections
);
const showAddButton = controlGroupSelector((state) => state.componentState.showAddButton);
const unpublishedFilters = controlGroupSelector(
(state) => state.componentState.unpublishedFilters
);
const controlWithInvalidSelectionsId = controlGroupSelector(
(state) => state.componentState.controlWithInvalidSelectionsId
);
const [tourStepOpen, setTourStepOpen] = useState<boolean>(true);
const [suppressTourChecked, setSuppressTourChecked] = useState<boolean>(false);
const [renderTourStep, setRenderTourStep] = useState(false);
@ -82,10 +89,49 @@ export const ControlGroup = () => {
* This forces the tour step to get unmounted so that it can attach to the new invalid
* control - otherwise, the anchor will remain attached to the old invalid control
*/
let mounted = true;
setRenderTourStep(false);
setTimeout(() => setRenderTourStep(true), 100);
setTimeout(() => {
if (mounted) {
setRenderTourStep(true);
}
}, 100);
return () => {
mounted = false;
};
}, [controlWithInvalidSelectionsId]);
const applyButtonEnabled = useMemo(() => {
/**
* this is undefined if there are no unpublished filters / timeslice; note that an empty filter array counts
* as unpublished filters and so the apply button should still be enabled in this case
*/
return Boolean(unpublishedFilters);
}, [unpublishedFilters]);
const showAppendedButtonGroup = useMemo(
() => showAddButton || showApplySelections,
[showAddButton, showApplySelections]
);
const ApplyButtonComponent = useMemo(() => {
return (
<EuiButtonIcon
size="m"
disabled={!applyButtonEnabled}
iconSize="m"
display="fill"
color={'success'}
iconType={'check'}
data-test-subj="controlGroup--applyFiltersButton"
aria-label={ControlGroupStrings.management.getApplyButtonTitle(applyButtonEnabled)}
onClick={() => {
if (unpublishedFilters) controlGroup.publishFilters(unpublishedFilters);
}}
/>
);
}, [applyButtonEnabled, unpublishedFilters, controlGroup]);
const tourStep = useMemo(() => {
if (
!renderTourStep ||
@ -208,10 +254,11 @@ export const ControlGroup = () => {
>
<EuiFlexGroup
wrap={false}
gutterSize="m"
gutterSize="s"
direction="row"
responsive={false}
alignItems="center"
alignItems="stretch"
justifyContent="center"
data-test-subj="controls-group"
>
{tourStep}
@ -254,16 +301,42 @@ export const ControlGroup = () => {
</DragOverlay>
</DndContext>
</EuiFlexItem>
{showAddButton && (
<EuiFlexItem grow={false}>
<EuiButtonIcon
size="s"
iconSize="m"
display="base"
iconType={'plusInCircle'}
aria-label={ControlGroupStrings.management.getAddControlTitle()}
onClick={() => controlGroup.openAddDataControlFlyout()}
/>
{showAppendedButtonGroup && (
<EuiFlexItem
grow={false}
className="controlGroup--endButtonGroup"
data-test-subj="controlGroup--endButtonGroup"
>
<EuiFlexGroup responsive={false} gutterSize="s" alignItems="center">
{showAddButton && (
<EuiFlexItem grow={false}>
<EuiToolTip content={ControlGroupStrings.management.getAddControlTitle()}>
<EuiButtonIcon
size="m"
iconSize="m"
display="base"
iconType={'plusInCircle'}
data-test-subj="controlGroup--addControlButton"
aria-label={ControlGroupStrings.management.getAddControlTitle()}
onClick={() => controlGroup.openAddDataControlFlyout()}
/>
</EuiToolTip>
</EuiFlexItem>
)}
{showApplySelections && (
<EuiFlexItem grow={false}>
{applyButtonEnabled ? (
ApplyButtonComponent
) : (
<EuiToolTip
content={ControlGroupStrings.management.getApplyButtonTitle(false)}
>
{ApplyButtonComponent}
</EuiToolTip>
)}
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
)}
</EuiFlexGroup>

View file

@ -3,8 +3,14 @@ $mediumControl: $euiSize * 25;
$largeControl: $euiSize * 50;
$controlMinWidth: $euiSize * 14;
.controlGroup {
.controlsWrapper {
display: flex;
align-items: center;
min-height: $euiSize * 4;
.controlGroup--endButtonGroup {
align-self: end;
}
}
.controlsWrapper--twoLine {

View file

@ -172,6 +172,14 @@ export const ControlGroupStrings = {
i18n.translate('controls.controlGroup.management.addControl', {
defaultMessage: 'Add control',
}),
getApplyButtonTitle: (applyResetButtonsEnabled: boolean) =>
applyResetButtonsEnabled
? i18n.translate('controls.controlGroup.management.applyButtonTooltip.enabled', {
defaultMessage: 'Apply selections',
})
: i18n.translate('controls.controlGroup.management.applyButtonTooltip.disabled', {
defaultMessage: 'No new selections to apply',
}),
getFlyoutTitle: () =>
i18n.translate('controls.controlGroup.management.flyoutTitle', {
defaultMessage: 'Control settings',
@ -292,8 +300,8 @@ export const ControlGroupStrings = {
i18n.translate('controls.controlGroup.management.validate.title', {
defaultMessage: 'Validate user selections',
}),
getValidateSelectionsSubTitle: () =>
i18n.translate('controls.controlGroup.management.validate.subtitle', {
getValidateSelectionsTooltip: () =>
i18n.translate('controls.controlGroup.management.validate.tooltip', {
defaultMessage: 'Highlight control selections that result in no data.',
}),
},
@ -302,12 +310,23 @@ export const ControlGroupStrings = {
i18n.translate('controls.controlGroup.management.hierarchy.title', {
defaultMessage: 'Chain controls',
}),
getHierarchySubTitle: () =>
i18n.translate('controls.controlGroup.management.hierarchy.subtitle', {
getHierarchyTooltip: () =>
i18n.translate('controls.controlGroup.management.hierarchy.tooltip', {
defaultMessage:
'Selections in one control narrow down available options in the next. Controls are chained from left to right.',
}),
},
showApplySelections: {
getShowApplySelectionsTitle: () =>
i18n.translate('controls.controlGroup.management.showApplySelections.title', {
defaultMessage: 'Apply selections automatically',
}),
getShowApplySelectionsTooltip: () =>
i18n.translate('controls.controlGroup.management.showApplySelections.tooltip', {
defaultMessage:
'If disabled, control selections will only be applied after clicking apply.',
}),
},
},
filteringSettings: {
getFilteringSettingsTitle: () =>

View file

@ -50,8 +50,6 @@ interface EditControlGroupProps {
onClose: () => void;
}
type EditorControlGroupInput = ControlGroupInput;
const editorControlGroupInputIsEqual = (a: ControlGroupInput, b: ControlGroupInput) =>
fastIsEqual(a, b);
@ -62,7 +60,7 @@ export const ControlGroupEditor = ({
onDeleteAll,
onClose,
}: EditControlGroupProps) => {
const [controlGroupEditorState, setControlGroupEditorState] = useState<EditorControlGroupInput>({
const [controlGroupEditorState, setControlGroupEditorState] = useState<ControlGroupInput>({
...getDefaultControlGroupInput(),
...initialInput,
});
@ -92,7 +90,9 @@ export const ControlGroupEditor = ({
const applyChangesToInput = useCallback(() => {
const inputToApply = { ...controlGroupEditorState };
if (!editorControlGroupInputIsEqual(inputToApply, initialInput)) updateInput(inputToApply);
if (!editorControlGroupInputIsEqual(inputToApply, initialInput)) {
updateInput(inputToApply);
}
}, [controlGroupEditorState, initialInput, updateInput]);
return (
@ -160,7 +160,7 @@ export const ControlGroupEditor = ({
label={
<ControlSettingTooltipLabel
label={ControlGroupStrings.management.selectionSettings.validateSelections.getValidateSelectionsTitle()}
tooltip={ControlGroupStrings.management.selectionSettings.validateSelections.getValidateSelectionsSubTitle()}
tooltip={ControlGroupStrings.management.selectionSettings.validateSelections.getValidateSelectionsTooltip()}
/>
}
checked={!Boolean(controlGroupEditorState.ignoreParentSettings?.ignoreValidations)}
@ -173,7 +173,7 @@ export const ControlGroupEditor = ({
label={
<ControlSettingTooltipLabel
label={ControlGroupStrings.management.selectionSettings.controlChaining.getHierarchyTitle()}
tooltip={ControlGroupStrings.management.selectionSettings.controlChaining.getHierarchySubTitle()}
tooltip={ControlGroupStrings.management.selectionSettings.controlChaining.getHierarchyTooltip()}
/>
}
checked={controlGroupEditorState.chainingSystem === 'HIERARCHICAL'}
@ -183,6 +183,23 @@ export const ControlGroupEditor = ({
})
}
/>
<EuiSpacer size="s" />
<EuiSwitch
compressed
data-test-subj="control-group-auto-apply-selections"
label={
<ControlSettingTooltipLabel
label={ControlGroupStrings.management.selectionSettings.showApplySelections.getShowApplySelectionsTitle()}
tooltip={ControlGroupStrings.management.selectionSettings.showApplySelections.getShowApplySelectionsTooltip()}
/>
}
checked={!controlGroupEditorState.showApplySelections}
onChange={(e) =>
updateControlGroupEditorSetting({
showApplySelections: !e.target.checked,
})
}
/>
</div>
</EuiFormRow>

View file

@ -18,6 +18,7 @@ import {
ControlGroupInput,
ControlsPanels,
} from '../../../common/control_group/types';
import { TimeSlice } from '../../../common/types';
interface GetPrecedingFiltersProps {
id: string;
@ -38,7 +39,7 @@ interface ChainingSystem {
) => EmbeddableContainerSettings | undefined;
getPrecedingFilters: (
props: GetPrecedingFiltersProps
) => { filters: Filter[]; timeslice?: [number, number] } | undefined;
) => { filters: Filter[]; timeslice?: TimeSlice } | undefined;
onChildChange: (props: OnChildChangedProps) => void;
}

View file

@ -10,9 +10,9 @@ import { compareFilters, COMPARE_ALL_OPTIONS, Filter, uniqFilters } from '@kbn/e
import { isEqual, pick } from 'lodash';
import React, { createContext, useContext } from 'react';
import ReactDOM from 'react-dom';
import { Provider, TypedUseSelectorHook, useSelector } from 'react-redux';
import { batch, Provider, TypedUseSelectorHook, useSelector } from 'react-redux';
import { BehaviorSubject, merge, Subject, Subscription } from 'rxjs';
import { debounceTime, distinctUntilChanged, skip } from 'rxjs/operators';
import { debounceTime, distinctUntilChanged, filter, first, skip } from 'rxjs/operators';
import { OverlayRef } from '@kbn/core/public';
import { Container, EmbeddableFactory } from '@kbn/embeddable-plugin/public';
@ -24,6 +24,7 @@ import {
persistableControlGroupInputIsEqual,
persistableControlGroupInputKeys,
} from '../../../common';
import { TimeSlice } from '../../../common/types';
import { pluginServices } from '../../services';
import { ControlsStorageService } from '../../services/storage/types';
import { ControlEmbeddable, ControlInput, ControlOutput } from '../../types';
@ -43,6 +44,7 @@ import { startDiffingControlGroupState } from '../state/control_group_diffing_in
import { controlGroupReducers } from '../state/control_group_reducers';
import {
ControlGroupComponentState,
ControlGroupFilterOutput,
ControlGroupInput,
ControlGroupOutput,
ControlGroupReduxState,
@ -168,8 +170,9 @@ export class ControlGroupContainer extends Container<
// when all children are ready setup subscriptions
this.untilAllChildrenReady().then(() => {
this.recalculateDataViews();
this.recalculateFilters();
this.setupSubscriptions();
const { filters, timeslice } = this.recalculateFilters();
this.publishFilters({ filters, timeslice });
this.initialized$.next(true);
});
@ -203,6 +206,29 @@ export class ControlGroupContainer extends Container<
};
private setupSubscriptions = () => {
/**
* on initialization, in order for comparison to be performed, calculate the last saved filters based on the
* selections from the last saved input and save them to component state. This is done as a subscription so that
* it can be done async without actually slowing down the loading of the controls.
*/
this.subscriptions.add(
this.initialized$
.pipe(
filter((isInitialized) => isInitialized),
first()
)
.subscribe(async () => {
const {
componentState: { lastSavedInput },
explicitInput: { panels },
} = this.getState();
const filterOutput = await this.calculateFiltersFromSelections(
lastSavedInput?.panels ?? panels
);
this.dispatch.setLastSavedFilters(filterOutput);
})
);
/**
* refresh control order cache and make all panels refreshInputFromParent whenever panel orders change
*/
@ -214,12 +240,29 @@ export class ControlGroupContainer extends Container<
)
.subscribe((input) => {
this.recalculateDataViews();
this.recalculateFilters();
this.recalculateFilters$.next(null);
const childOrderCache = cachedChildEmbeddableOrder(input.panels);
childOrderCache.idsInOrder.forEach((id) => this.getChild(id)?.refreshInputFromParent());
})
);
/**
* force publish filters when `showApplySelections` value changes to keep state clean
*/
this.subscriptions.add(
this.getInput$()
.pipe(
skip(1),
distinctUntilChanged(
(a, b) => Boolean(a.showApplySelections) === Boolean(b.showApplySelections)
)
)
.subscribe(() => {
const { filters, timeslice } = this.recalculateFilters();
this.publishFilters({ filters, timeslice });
})
);
/**
* run OnChildOutputChanged when any child's output has changed
*/
@ -240,17 +283,37 @@ export class ControlGroupContainer extends Container<
*/
this.subscriptions.add(
this.recalculateFilters$.pipe(debounceTime(10)).subscribe(() => {
this.recalculateFilters();
const { filters, timeslice } = this.recalculateFilters();
this.tryPublishFilters({ filters, timeslice });
})
);
};
public setSavedState(lastSavedInput: PersistableControlGroupInput): void {
batch(() => {
this.dispatch.setLastSavedInput(lastSavedInput);
const { filters, timeslice } = this.getState().output;
this.dispatch.setLastSavedFilters({ filters, timeslice });
});
}
public resetToLastSavedState() {
const {
explicitInput: { showApplySelections: currentShowApplySelections },
componentState: { lastSavedInput },
} = this.getState();
if (!persistableControlGroupInputIsEqual(this.getPersistableInput(), lastSavedInput)) {
if (
lastSavedInput &&
!persistableControlGroupInputIsEqual(this.getPersistableInput(), lastSavedInput)
) {
this.updateInput(lastSavedInput);
if (currentShowApplySelections || lastSavedInput.showApplySelections) {
/** If either the current or past state has auto-apply off, calling reset should force the changes to be published */
this.calculateFiltersFromSelections(lastSavedInput.panels).then((filterOutput) => {
this.publishFilters(filterOutput);
});
}
this.reload(); // this forces the children to update their inputs + perform validation as necessary
}
}
@ -271,7 +334,8 @@ export class ControlGroupContainer extends Container<
this.updateInput(newInput);
this.untilAllChildrenReady().then(() => {
this.recalculateDataViews();
this.recalculateFilters();
const { filters, timeslice } = this.recalculateFilters();
this.publishFilters({ filters, timeslice });
this.setupSubscriptions();
this.initialized$.next(true);
});
@ -326,7 +390,7 @@ export class ControlGroupContainer extends Container<
this.updateInput({ filters });
};
private recalculateFilters = () => {
private recalculateFilters = (): ControlGroupFilterOutput => {
const allFilters: Filter[] = [];
let timeslice;
Object.values(this.children).map((child: ControlEmbeddable) => {
@ -336,17 +400,71 @@ export class ControlGroupContainer extends Container<
timeslice = childOutput.timeslice;
}
});
return { filters: uniqFilters(allFilters), timeslice };
};
// if filters are different, publish them
private async calculateFiltersFromSelections(
panels: PersistableControlGroupInput['panels']
): Promise<ControlGroupFilterOutput> {
let filtersArray: Filter[] = [];
let timeslice;
await Promise.all(
Object.values(this.children).map(async (child) => {
if (panels[child.id]) {
const controlOutput =
(await (child as ControlEmbeddable).selectionsToFilters?.(
panels[child.id].explicitInput
)) ?? ({} as ControlGroupFilterOutput);
if (controlOutput.filters) {
filtersArray = [...filtersArray, ...controlOutput.filters];
} else if (controlOutput.timeslice) {
timeslice = controlOutput.timeslice;
}
}
})
);
return { filters: filtersArray, timeslice };
}
/**
* If apply button is enabled, add the new filters to the unpublished filters component state;
* otherwise, publish new filters right away
*/
private tryPublishFilters = ({
filters,
timeslice,
}: {
filters?: Filter[];
timeslice?: TimeSlice;
}) => {
// if filters are different, try publishing them
if (
!compareFilters(this.output.filters ?? [], allFilters ?? [], COMPARE_ALL_OPTIONS) ||
!compareFilters(this.output.filters ?? [], filters ?? [], COMPARE_ALL_OPTIONS) ||
!isEqual(this.output.timeslice, timeslice)
) {
this.updateOutput({ filters: uniqFilters(allFilters), timeslice });
this.onFiltersPublished$.next(allFilters);
const {
explicitInput: { showApplySelections },
} = this.getState();
if (!showApplySelections) {
this.publishFilters({ filters, timeslice });
} else {
this.dispatch.setUnpublishedFilters({ filters, timeslice });
}
} else {
this.dispatch.setUnpublishedFilters(undefined);
}
};
public publishFilters = ({ filters, timeslice }: ControlGroupFilterOutput) => {
this.updateOutput({
filters,
timeslice,
});
this.dispatch.setUnpublishedFilters(undefined);
this.onFiltersPublished$.next(filters ?? []);
};
private recalculateDataViews = () => {
const allDataViewIds: Set<string> = new Set();
Object.values(this.children).map((child) => {

View file

@ -6,9 +6,11 @@
* Side Public License, v 1.
*/
import { isEqual } from 'lodash';
import { AnyAction, Middleware } from 'redux';
import { debounceTime, Observable, startWith, Subject, switchMap } from 'rxjs';
import { compareFilters, COMPARE_ALL_OPTIONS } from '@kbn/es-query';
import { ControlGroupContainer } from '..';
import { persistableControlGroupInputIsEqual } from '../../../common';
import { CHANGE_CHECK_DEBOUNCE } from '../../constants';
@ -41,12 +43,20 @@ export function startDiffingControlGroupState(this: ControlGroupContainer) {
const {
explicitInput: currentInput,
componentState: { lastSavedInput },
componentState: { lastSavedInput, lastSavedFilters },
output: { filters, timeslice },
} = this.getState();
const hasUnsavedChanges = !persistableControlGroupInputIsEqual(
currentInput,
lastSavedInput
const hasUnsavedChanges = !(
persistableControlGroupInputIsEqual(
currentInput,
lastSavedInput,
false // never diff selections for unsaved changes - compare the output filters instead
) &&
compareFilters(filters ?? [], lastSavedFilters?.filters ?? [], COMPARE_ALL_OPTIONS) &&
isEqual(timeslice, lastSavedFilters?.timeslice)
);
this.unsavedChanges.next(hasUnsavedChanges ? this.getPersistableInput() : undefined);
});
})

View file

@ -13,17 +13,29 @@ import { ControlWidth } from '../../types';
import { ControlGroupComponentState, ControlGroupInput, ControlGroupReduxState } from '../types';
export const controlGroupReducers = {
setControlWithInvalidSelectionsId: (
state: WritableDraft<ControlGroupReduxState>,
action: PayloadAction<ControlGroupComponentState['controlWithInvalidSelectionsId']>
) => {
state.componentState.controlWithInvalidSelectionsId = action.payload;
},
setLastSavedInput: (
state: WritableDraft<ControlGroupReduxState>,
action: PayloadAction<ControlGroupComponentState['lastSavedInput']>
) => {
state.componentState.lastSavedInput = action.payload;
},
setControlWithInvalidSelectionsId: (
setLastSavedFilters: (
state: WritableDraft<ControlGroupReduxState>,
action: PayloadAction<ControlGroupComponentState['controlWithInvalidSelectionsId']>
action: PayloadAction<ControlGroupComponentState['lastSavedFilters']>
) => {
state.componentState.controlWithInvalidSelectionsId = action.payload;
state.componentState.lastSavedFilters = action.payload;
},
setUnpublishedFilters: (
state: WritableDraft<ControlGroupReduxState>,
action: PayloadAction<ControlGroupComponentState['unpublishedFilters']>
) => {
state.componentState.unpublishedFilters = action.payload;
},
setControlStyle: (
state: WritableDraft<ControlGroupReduxState>,

View file

@ -8,12 +8,23 @@
import { DataViewField } from '@kbn/data-views-plugin/common';
import { ContainerOutput } from '@kbn/embeddable-plugin/public';
import { Filter } from '@kbn/es-query';
import { ReduxEmbeddableState } from '@kbn/presentation-util-plugin/public';
import { ControlGroupInput, PersistableControlGroupInput } from '../../common/control_group/types';
import { CommonControlOutput } from '../types';
import { TimeSlice } from '../../common/types';
export interface ControlFilterOutput {
filters?: Filter[];
}
export interface ControlTimesliceOutput {
timeslice?: TimeSlice;
}
export type ControlGroupFilterOutput = ControlFilterOutput & ControlTimesliceOutput;
export type ControlGroupOutput = ContainerOutput &
Omit<CommonControlOutput, 'dataViewId'> & { dataViewIds: string[] };
ControlGroupFilterOutput & { dataViewIds: string[] };
// public only - redux embeddable state type
export type ControlGroupReduxState = ReduxEmbeddableState<
@ -41,7 +52,9 @@ export interface ControlGroupSettings {
}
export type ControlGroupComponentState = ControlGroupSettings & {
lastSavedInput: PersistableControlGroupInput;
lastSavedInput?: PersistableControlGroupInput;
lastSavedFilters?: ControlGroupFilterOutput;
unpublishedFilters?: ControlGroupFilterOutput;
controlWithInvalidSelectionsId?: string;
};

View file

@ -26,10 +26,12 @@ import { useOptionsList } from '../embeddable/options_list_embeddable';
const aggregationToggleButtons = [
{
id: 'optionsList__includeResults',
key: 'optionsList__includeResults',
label: OptionsListStrings.popover.getIncludeLabel(),
},
{
id: 'optionsList__excludeResults',
key: 'optionsList__excludeResults',
label: OptionsListStrings.popover.getExcludeLabel(),
},
];

View file

@ -6,15 +6,13 @@
* Side Public License, v 1.
*/
import { ControlGroupInput } from '../../../common';
import { lazyLoadReduxToolsPackage } from '@kbn/presentation-util-plugin/public';
import { storybookFlightsDataView } from '@kbn/presentation-util-plugin/public/mocks';
import { OPTIONS_LIST_CONTROL } from '../../../common';
import { ControlGroupContainer } from '../../control_group/embeddable/control_group_container';
import { ControlGroupInput, OPTIONS_LIST_CONTROL } from '../../../common';
import { mockControlGroupContainer } from '../../../common/mocks';
import { pluginServices } from '../../services';
import { injectStorybookDataView } from '../../services/data_views/data_views.story';
import { OptionsListEmbeddableFactory } from './options_list_embeddable_factory';
import { OptionsListEmbeddable } from './options_list_embeddable';
import { OptionsListEmbeddableFactory } from './options_list_embeddable_factory';
pluginServices.getServices().controls.getControlFactory = jest
.fn()
@ -25,9 +23,8 @@ pluginServices.getServices().controls.getControlFactory = jest
describe('initialize', () => {
describe('without selected options', () => {
test('should notify control group when initialization is finished', async () => {
const reduxEmbeddablePackage = await lazyLoadReduxToolsPackage();
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
const container = new ControlGroupContainer(reduxEmbeddablePackage, controlGroupInput);
const container = await mockControlGroupContainer(controlGroupInput);
// data view not required for test case
// setInitializationFinished is called before fetching options when value is not provided
@ -45,9 +42,8 @@ describe('initialize', () => {
describe('with selected options', () => {
test('should set error message when data view can not be found', async () => {
const reduxEmbeddablePackage = await lazyLoadReduxToolsPackage();
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
const container = new ControlGroupContainer(reduxEmbeddablePackage, controlGroupInput);
const container = await mockControlGroupContainer(controlGroupInput);
injectStorybookDataView(undefined);
@ -68,9 +64,8 @@ describe('initialize', () => {
});
test('should set error message when field can not be found', async () => {
const reduxEmbeddablePackage = await lazyLoadReduxToolsPackage();
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
const container = new ControlGroupContainer(reduxEmbeddablePackage, controlGroupInput);
const container = await mockControlGroupContainer(controlGroupInput);
injectStorybookDataView(storybookFlightsDataView);
@ -89,9 +84,8 @@ describe('initialize', () => {
});
test('should notify control group when initialization is finished', async () => {
const reduxEmbeddablePackage = await lazyLoadReduxToolsPackage();
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
const container = new ControlGroupContainer(reduxEmbeddablePackage, controlGroupInput);
const container = await mockControlGroupContainer(controlGroupInput);
injectStorybookDataView(storybookFlightsDataView);

View file

@ -35,6 +35,7 @@ import {
OptionsListEmbeddableInput,
OPTIONS_LIST_CONTROL,
} from '../..';
import { ControlFilterOutput } from '../../control_group/types';
import { pluginServices } from '../../services';
import { ControlsDataViewsService } from '../../services/data_views/types';
import { ControlsOptionsListService } from '../../services/options_list/types';
@ -145,7 +146,14 @@ export class OptionsListEmbeddable
private initialize = async () => {
const { selectedOptions: initialSelectedOptions } = this.getInput();
if (initialSelectedOptions) {
const filters = await this.buildFilter();
const {
explicitInput: { existsSelected, exclude },
} = this.getState();
const { filters } = await this.selectionsToFilters({
existsSelected,
exclude,
selectedOptions: initialSelectedOptions,
});
this.dispatch.publishFilters(filters);
}
this.setInitializationFinished();
@ -231,7 +239,7 @@ export class OptionsListEmbeddable
}
}),
switchMap(async () => {
const newFilters = await this.buildFilter();
const { filters: newFilters } = await this.buildFilter();
this.dispatch.publishFilters(newFilters);
})
)
@ -389,15 +397,17 @@ export class OptionsListEmbeddable
});
};
private buildFilter = async () => {
const { existsSelected, selectedOptions } = this.getState().explicitInput ?? {};
const { exclude } = this.getInput();
public selectionsToFilters = async (
input: Partial<OptionsListEmbeddableInput>
): Promise<ControlFilterOutput> => {
const { existsSelected, exclude, selectedOptions } = input;
if ((!selectedOptions || isEmpty(selectedOptions)) && !existsSelected) {
return [];
return { filters: [] };
}
const { dataView, field } = await this.getCurrentDataViewAndField();
if (!dataView || !field) return;
if (!dataView || !field) return { filters: [] };
let newFilter: Filter | undefined;
if (existsSelected) {
@ -410,11 +420,23 @@ export class OptionsListEmbeddable
}
}
if (!newFilter) return [];
if (!newFilter) return { filters: [] };
newFilter.meta.key = field?.name;
if (exclude) newFilter.meta.negate = true;
return [newFilter];
return { filters: [newFilter] };
};
private buildFilter = async (): Promise<ControlFilterOutput> => {
const {
componentState: { validSelections },
explicitInput: { existsSelected, exclude },
} = this.getState();
return await this.selectionsToFilters({
existsSelected,
exclude,
selectedOptions: validSelections,
});
};
public clearSelections() {

View file

@ -6,16 +6,14 @@
* Side Public License, v 1.
*/
import { of } from 'rxjs';
import { ControlGroupInput } from '../../../common';
import { lazyLoadReduxToolsPackage } from '@kbn/presentation-util-plugin/public';
import { storybookFlightsDataView } from '@kbn/presentation-util-plugin/public/mocks';
import { RANGE_SLIDER_CONTROL } from '../../../common';
import { ControlGroupContainer } from '../../control_group/embeddable/control_group_container';
import { of } from 'rxjs';
import { ControlGroupInput, RANGE_SLIDER_CONTROL } from '../../../common';
import { mockControlGroupContainer } from '../../../common/mocks';
import { pluginServices } from '../../services';
import { injectStorybookDataView } from '../../services/data_views/data_views.story';
import { RangeSliderEmbeddableFactory } from './range_slider_embeddable_factory';
import { RangeSliderEmbeddable } from './range_slider_embeddable';
import { RangeSliderEmbeddableFactory } from './range_slider_embeddable_factory';
let totalResults = 20;
beforeEach(() => {
@ -51,9 +49,8 @@ beforeEach(() => {
describe('initialize', () => {
describe('without selected range', () => {
test('should notify control group when initialization is finished', async () => {
const reduxEmbeddablePackage = await lazyLoadReduxToolsPackage();
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
const container = new ControlGroupContainer(reduxEmbeddablePackage, controlGroupInput);
const container = await mockControlGroupContainer(controlGroupInput);
// data view not required for test case
// setInitializationFinished is called before fetching slider range when value is not provided
@ -71,9 +68,8 @@ describe('initialize', () => {
describe('with selected range', () => {
test('should set error message when data view can not be found', async () => {
const reduxEmbeddablePackage = await lazyLoadReduxToolsPackage();
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
const container = new ControlGroupContainer(reduxEmbeddablePackage, controlGroupInput);
const container = await mockControlGroupContainer(controlGroupInput);
injectStorybookDataView(undefined);
@ -94,9 +90,8 @@ describe('initialize', () => {
});
test('should set error message when field can not be found', async () => {
const reduxEmbeddablePackage = await lazyLoadReduxToolsPackage();
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
const container = new ControlGroupContainer(reduxEmbeddablePackage, controlGroupInput);
const container = await mockControlGroupContainer(controlGroupInput);
injectStorybookDataView(storybookFlightsDataView);
@ -115,9 +110,8 @@ describe('initialize', () => {
});
test('should set invalid state when filter returns zero results', async () => {
const reduxEmbeddablePackage = await lazyLoadReduxToolsPackage();
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
const container = new ControlGroupContainer(reduxEmbeddablePackage, controlGroupInput);
const container = await mockControlGroupContainer(controlGroupInput);
injectStorybookDataView(storybookFlightsDataView);
totalResults = 0;
@ -137,9 +131,8 @@ describe('initialize', () => {
});
test('should set range and filter', async () => {
const reduxEmbeddablePackage = await lazyLoadReduxToolsPackage();
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
const container = new ControlGroupContainer(reduxEmbeddablePackage, controlGroupInput);
const container = await mockControlGroupContainer(controlGroupInput);
injectStorybookDataView(storybookFlightsDataView);
@ -168,9 +161,8 @@ describe('initialize', () => {
});
test('should notify control group when initialization is finished', async () => {
const reduxEmbeddablePackage = await lazyLoadReduxToolsPackage();
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
const container = new ControlGroupContainer(reduxEmbeddablePackage, controlGroupInput);
const container = await mockControlGroupContainer(controlGroupInput);
injectStorybookDataView(storybookFlightsDataView);
@ -185,9 +177,8 @@ describe('initialize', () => {
});
test('should notify control group when initialization throws', async () => {
const reduxEmbeddablePackage = await lazyLoadReduxToolsPackage();
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
const container = new ControlGroupContainer(reduxEmbeddablePackage, controlGroupInput);
const container = await mockControlGroupContainer(controlGroupInput);
injectStorybookDataView(storybookFlightsDataView);

View file

@ -34,6 +34,7 @@ import {
RangeSliderEmbeddableInput,
RANGE_SLIDER_CONTROL,
} from '../..';
import { ControlFilterOutput } from '../../control_group/types';
import { pluginServices } from '../../services';
import { ControlsDataService } from '../../services/data/types';
import { ControlsDataViewsService } from '../../services/data_views/types';
@ -135,8 +136,8 @@ export class RangeSliderEmbeddable
private initialize = async () => {
const [initialMin, initialMax] = this.getInput().value ?? [];
if (!isEmpty(initialMin) || !isEmpty(initialMax)) {
const filter = await this.buildFilter();
this.dispatch.publishFilters(filter);
const { filters: rangeFilter } = await this.buildFilter();
this.dispatch.publishFilters(rangeFilter);
}
this.setInitializationFinished();
@ -190,7 +191,7 @@ export class RangeSliderEmbeddable
switchMap(async () => {
try {
this.dispatch.setLoading(true);
const rangeFilter = await this.buildFilter();
const { filters: rangeFilter } = await this.buildFilter();
this.dispatch.publishFilters(rangeFilter);
await this.runValidations();
this.dispatch.setLoading(false);
@ -300,25 +301,22 @@ export class RangeSliderEmbeddable
return { min, max };
};
private buildFilter = async () => {
const {
explicitInput: { value },
} = this.getState();
public selectionsToFilters = async (
input: Partial<RangeSliderEmbeddableInput>
): Promise<ControlFilterOutput> => {
const { value } = input;
const [selectedMin, selectedMax] = value ?? ['', ''];
const [min, max] = [selectedMin, selectedMax].map(parseFloat);
const { dataView, field } = await this.getCurrentDataViewAndField();
if (!dataView || !field) return [];
if (isEmpty(selectedMin) && isEmpty(selectedMax)) return [];
if (!dataView || !field || (isEmpty(selectedMin) && isEmpty(selectedMax))) {
return { filters: [] };
}
const params = {} as RangeFilterParams;
if (selectedMin) {
params.gte = min;
}
if (selectedMax) {
params.lte = max;
}
@ -328,7 +326,14 @@ export class RangeSliderEmbeddable
rangeFilter.meta.type = 'range';
rangeFilter.meta.params = params;
return [rangeFilter];
return { filters: [rangeFilter] };
};
private buildFilter = async () => {
const {
explicitInput: { value },
} = this.getState();
return await this.selectionsToFilters({ value });
};
private onLoadingError(errorMessage: string) {

View file

@ -4,7 +4,7 @@
max-inline-size: 100% !important;
}
.timeSlider-playToggle {
.timeSlider-playToggle:enabled {
background-color: $euiColorPrimary !important;
}

View file

@ -6,11 +6,13 @@
* Side Public License, v 1.
*/
import React, { FC } from 'react';
import { EuiInputPopover } from '@elastic/eui';
import { FROM_INDEX, TO_INDEX } from '../time_utils';
import { getRoundedTimeRangeBounds } from '../time_slider_selectors';
import React, { FC } from 'react';
import { TimeSlice } from '../../../common/types';
import { useTimeSlider } from '../embeddable/time_slider_embeddable';
import { getRoundedTimeRangeBounds } from '../time_slider_selectors';
import { FROM_INDEX, TO_INDEX } from '../time_utils';
import { TimeSliderPopoverButton } from './time_slider_popover_button';
import { TimeSliderPopoverContent } from './time_slider_popover_content';
@ -18,7 +20,7 @@ import './index.scss';
interface Props {
formatDate: (epoch: number) => string;
onChange: (value?: [number, number]) => void;
onChange: (value?: TimeSlice) => void;
}
export const TimeSlider: FC<Props> = (props: Props) => {

View file

@ -9,10 +9,11 @@
import React from 'react';
import { EuiRange, EuiRangeTick } from '@elastic/eui';
import { _SingleRangeChangeEvent } from '@elastic/eui/src/components/form/range/types';
import { TimeSlice } from '../../../common/types';
interface Props {
value: [number, number];
onChange: (value?: [number, number]) => void;
value: TimeSlice;
onChange: (value?: TimeSlice) => void;
stepSize: number;
ticks: EuiRangeTick[];
timeRangeMin: number;

View file

@ -14,10 +14,11 @@ import { TimeSliderStrings } from './time_slider_strings';
import { useTimeSlider } from '../embeddable/time_slider_embeddable';
import { TimeSliderAnchoredRange } from './time_slider_anchored_range';
import { TimeSliderSlidingWindowRange } from './time_slider_sliding_window_range';
import { TimeSlice } from '../../../common/types';
interface Props {
value: [number, number];
onChange: (value?: [number, number]) => void;
value: TimeSlice;
onChange: (value?: TimeSlice) => void;
stepSize: number;
ticks: EuiRangeTick[];
timeRangeMin: number;

View file

@ -6,12 +6,14 @@
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import { first } from 'rxjs/operators';
import React, { FC, useState } from 'react';
import { EuiButtonIcon } from '@elastic/eui';
import { EuiButtonIcon, EuiToolTip } from '@elastic/eui';
import { ViewMode } from '@kbn/embeddable-plugin/common';
import React, { FC, useCallback, useMemo, useState } from 'react';
import { Observable, Subscription } from 'rxjs';
import { first } from 'rxjs/operators';
import { useControlGroupContainer } from '../../control_group/embeddable/control_group_container';
import { useTimeSlider } from '../embeddable/time_slider_embeddable';
import { TimeSliderStrings } from './time_slider_strings';
interface Props {
onNext: () => void;
@ -21,12 +23,18 @@ interface Props {
export const TimeSliderPrepend: FC<Props> = (props: Props) => {
const timeSlider = useTimeSlider();
const controlGroup = useControlGroupContainer();
const showApplySelectionsButton = controlGroup.select(
(state) => state.explicitInput.showApplySelections
);
const viewMode = controlGroup.select((state) => state.explicitInput.viewMode);
const [isPaused, setIsPaused] = useState(true);
const [timeoutId, setTimeoutId] = useState<number | undefined>(undefined);
const [subscription, setSubscription] = useState<Subscription | undefined>(undefined);
const playNextFrame = () => {
const playNextFrame = useCallback(() => {
// advance to next frame
props.onNext();
@ -42,15 +50,15 @@ export const TimeSliderPrepend: FC<Props> = (props: Props) => {
});
setSubscription(nextFrameSubscription);
}
};
}, [props]);
const onPlay = () => {
const onPlay = useCallback(() => {
timeSlider.dispatch.setIsOpen({ isOpen: true });
setIsPaused(false);
playNextFrame();
};
}, [timeSlider.dispatch, playNextFrame]);
const onPause = () => {
const onPause = useCallback(() => {
timeSlider.dispatch.setIsOpen({ isOpen: true });
setIsPaused(true);
if (subscription) {
@ -61,7 +69,32 @@ export const TimeSliderPrepend: FC<Props> = (props: Props) => {
clearTimeout(timeoutId);
setTimeoutId(undefined);
}
};
}, [timeSlider.dispatch, subscription, timeoutId]);
const PlayButton = useMemo(() => {
const Button = (
<EuiButtonIcon
className="timeSlider-playToggle"
onClick={isPaused ? onPlay : onPause}
disabled={showApplySelectionsButton}
iconType={isPaused ? 'playFilled' : 'pause'}
size="s"
display="fill"
aria-label={TimeSliderStrings.control.getPlayButtonAriaLabel(isPaused)}
/>
);
return (
<>
{showApplySelectionsButton ? (
<EuiToolTip content={TimeSliderStrings.control.getPlayButtonDisabledTooltip()}>
{Button}
</EuiToolTip>
) : (
Button
)}
</>
);
}, [isPaused, onPlay, onPause, showApplySelectionsButton]);
return (
<div>
@ -72,29 +105,13 @@ export const TimeSliderPrepend: FC<Props> = (props: Props) => {
}}
iconType="framePrevious"
color="text"
aria-label={i18n.translate('controls.timeSlider.previousLabel', {
defaultMessage: 'Previous time window',
})}
aria-label={TimeSliderStrings.control.getPreviousButtonAriaLabel()}
data-test-subj="timeSlider-previousTimeWindow"
/>
{props.waitForControlOutputConsumersToLoad$ === undefined ? null : (
<EuiButtonIcon
className="timeSlider-playToggle"
onClick={isPaused ? onPlay : onPause}
iconType={isPaused ? 'playFilled' : 'pause'}
size="s"
display="fill"
aria-label={
isPaused
? i18n.translate('controls.timeSlider.playLabel', {
defaultMessage: 'Play',
})
: i18n.translate('controls.timeSlider.pauseLabel', {
defaultMessage: 'Pause',
})
}
/>
)}
{props.waitForControlOutputConsumersToLoad$ === undefined ||
(showApplySelectionsButton && viewMode === ViewMode.VIEW)
? null
: PlayButton}
<EuiButtonIcon
onClick={() => {
onPause();
@ -102,9 +119,7 @@ export const TimeSliderPrepend: FC<Props> = (props: Props) => {
}}
iconType="frameNext"
color="text"
aria-label={i18n.translate('controls.timeSlider.nextLabel', {
defaultMessage: 'Next time window',
})}
aria-label={TimeSliderStrings.control.getNextButtonAriaLabel()}
data-test-subj="timeSlider-nextTimeWindow"
/>
</div>

View file

@ -8,10 +8,11 @@
import React from 'react';
import { EuiDualRange, EuiRangeTick } from '@elastic/eui';
import { TimeSlice } from '../../../common/types';
interface Props {
value: [number, number];
onChange: (value?: [number, number]) => void;
value: TimeSlice;
onChange: (value?: TimeSlice) => void;
stepSize: number;
ticks: EuiRangeTick[];
timeRangeMin: number;
@ -20,7 +21,7 @@ interface Props {
export function TimeSliderSlidingWindowRange(props: Props) {
function onChange(value?: [number | string, number | string]) {
props.onChange(value as [number, number]);
props.onChange(value as TimeSlice);
}
return (

View file

@ -18,5 +18,25 @@ export const TimeSliderStrings = {
i18n.translate('controls.timeSlider.settings.unpinStart', {
defaultMessage: 'Unpin start',
}),
getPlayButtonAriaLabel: (isPaused: boolean) =>
isPaused
? i18n.translate('controls.timeSlider.playLabel', {
defaultMessage: 'Play',
})
: i18n.translate('controls.timeSlider.pauseLabel', {
defaultMessage: 'Pause',
}),
getPreviousButtonAriaLabel: () =>
i18n.translate('controls.timeSlider.previousLabel', {
defaultMessage: 'Previous time window',
}),
getNextButtonAriaLabel: () =>
i18n.translate('controls.timeSlider.nextLabel', {
defaultMessage: 'Next time window',
}),
getPlayButtonDisabledTooltip: () =>
i18n.translate('controls.timeSlider.playButtonTooltip.disabled', {
defaultMessage: '"Apply selections automatically" is disabled in Control Settings.',
}),
},
};

View file

@ -20,7 +20,9 @@ import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme';
import { TIME_SLIDER_CONTROL } from '../..';
import { TimeSliderControlEmbeddableInput } from '../../../common/time_slider/types';
import { TimeSlice } from '../../../common/types';
import { ControlGroupContainer } from '../../control_group/embeddable/control_group_container';
import { ControlTimesliceOutput } from '../../control_group/types';
import { pluginServices } from '../../services';
import { ControlsDataService } from '../../services/data/types';
import { ControlsSettingsService } from '../../services/settings/types';
@ -156,11 +158,36 @@ export class TimeSliderControlEmbeddable
}
};
public selectionsToFilters = async (
input: Partial<TimeSliderControlEmbeddableInput>
): Promise<ControlTimesliceOutput> => {
const { timesliceStartAsPercentageOfTimeRange, timesliceEndAsPercentageOfTimeRange } = input;
if (
timesliceStartAsPercentageOfTimeRange === undefined ||
timesliceEndAsPercentageOfTimeRange === undefined
) {
return { timeslice: undefined };
}
const {
componentState: { stepSize, timeRangeBounds },
} = this.getState();
const timeRange = timeRangeBounds[TO_INDEX] - timeRangeBounds[FROM_INDEX];
const from = timeRangeBounds[FROM_INDEX] + timesliceStartAsPercentageOfTimeRange * timeRange;
const to = timeRangeBounds[FROM_INDEX] + timesliceEndAsPercentageOfTimeRange * timeRange;
const value = [
roundDownToNextStepSizeFactor(from, stepSize),
roundUpToNextStepSizeFactor(to, stepSize),
] as TimeSlice;
return { timeslice: value };
};
private onInputChange() {
const input = this.getInput();
const { timesliceStartAsPercentageOfTimeRange, timesliceEndAsPercentageOfTimeRange } =
this.prevTimesliceAsPercentage ?? {};
if (
timesliceStartAsPercentageOfTimeRange !== input.timesliceStartAsPercentageOfTimeRange ||
timesliceEndAsPercentageOfTimeRange !== input.timesliceEndAsPercentageOfTimeRange
@ -193,25 +220,18 @@ export class TimeSliderControlEmbeddable
private syncWithTimeRange() {
this.prevTimeRange = this.getInput().timeRange;
const stepSize = this.getState().componentState.stepSize;
const { explicitInput: currentInput } = this.getState();
const { timesliceStartAsPercentageOfTimeRange, timesliceEndAsPercentageOfTimeRange } =
this.getState().explicitInput;
currentInput;
if (
timesliceStartAsPercentageOfTimeRange !== undefined &&
timesliceEndAsPercentageOfTimeRange !== undefined
) {
const timeRangeBounds = this.getState().componentState.timeRangeBounds;
const timeRange = timeRangeBounds[TO_INDEX] - timeRangeBounds[FROM_INDEX];
const from = timeRangeBounds[FROM_INDEX] + timesliceStartAsPercentageOfTimeRange * timeRange;
const to = timeRangeBounds[FROM_INDEX] + timesliceEndAsPercentageOfTimeRange * timeRange;
const value = [
roundDownToNextStepSizeFactor(from, stepSize),
roundUpToNextStepSizeFactor(to, stepSize),
] as [number, number];
this.dispatch.publishValue({ value });
this.dispatch.setValue({ value });
this.onRangeChange(value[TO_INDEX] - value[FROM_INDEX]);
this.selectionsToFilters(currentInput).then(({ timeslice }) => {
this.dispatch.publishValue({ value: timeslice });
this.dispatch.setValue({ value: timeslice });
if (timeslice) this.onRangeChange(timeslice[TO_INDEX] - timeslice[FROM_INDEX]);
});
}
}
@ -226,11 +246,11 @@ export class TimeSliderControlEmbeddable
return;
}
private debouncedPublishChange = _.debounce((value?: [number, number]) => {
private debouncedPublishChange = _.debounce((value?: TimeSlice) => {
this.dispatch.publishValue({ value });
}, 500);
private getTimeSliceAsPercentageOfTimeRange(value?: [number, number]) {
private getTimeSliceAsPercentageOfTimeRange(value?: TimeSlice) {
let timesliceStartAsPercentageOfTimeRange: number | undefined;
let timesliceEndAsPercentageOfTimeRange: number | undefined;
if (value) {
@ -248,7 +268,7 @@ export class TimeSliderControlEmbeddable
return { timesliceStartAsPercentageOfTimeRange, timesliceEndAsPercentageOfTimeRange };
}
private onTimesliceChange = (value?: [number, number]) => {
private onTimesliceChange = (value?: TimeSlice) => {
const { timesliceStartAsPercentageOfTimeRange, timesliceEndAsPercentageOfTimeRange } =
this.getTimeSliceAsPercentageOfTimeRange(value);
@ -371,7 +391,7 @@ export class TimeSliderControlEmbeddable
<TimeSliderControlContext.Provider value={this}>
<TimeSlider
formatDate={this.formatDate}
onChange={(value?: [number, number]) => {
onChange={(value?: TimeSlice) => {
this.onTimesliceChange(value);
const range = value ? value[TO_INDEX] - value[FROM_INDEX] : undefined;
this.onRangeChange(range);

View file

@ -6,15 +6,17 @@
* Side Public License, v 1.
*/
import { EuiRangeTick } from '@elastic/eui';
import { PayloadAction } from '@reduxjs/toolkit';
import { WritableDraft } from 'immer/dist/types/types-external';
import { EuiRangeTick } from '@elastic/eui';
import { TimeSlice } from '../../common/types';
import { TimeSliderReduxState } from './types';
export const timeSliderReducers = {
publishValue: (
state: WritableDraft<TimeSliderReduxState>,
action: PayloadAction<{ value?: [number, number] }>
action: PayloadAction<{ value?: TimeSlice }>
) => {
state.output.timeslice = action.payload.value;
},
@ -47,7 +49,7 @@ export const timeSliderReducers = {
setValue: (
state: WritableDraft<TimeSliderReduxState>,
action: PayloadAction<{
value?: [number, number];
value?: TimeSlice;
}>
) => {
state.componentState.value = action.payload.value;

View file

@ -11,6 +11,7 @@ import { EuiRangeTick } from '@elastic/eui';
import { ControlOutput } from '../types';
import { TimeSliderControlEmbeddableInput } from '../../common/time_slider/types';
import { TimeSlice } from '../../common/types';
export * from '../../common/time_slider/types';
@ -22,7 +23,7 @@ export interface TimeSliderSubjectState {
stepSize: number;
ticks: EuiRangeTick[];
timeRangeBounds: [number, number];
value?: [number, number];
value?: TimeSlice;
}
// public only - redux embeddable state type

View file

@ -8,7 +8,8 @@
import { ReactNode } from 'react';
import { Filter } from '@kbn/es-query';
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { DataViewField, DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import {
EmbeddableFactory,
EmbeddableOutput,
@ -17,18 +18,15 @@ import {
IEmbeddable,
} from '@kbn/embeddable-plugin/public';
import { UiActionsStart } from '@kbn/ui-actions-plugin/public';
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { DataViewField, DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import { ControlInput, ControlWidth, DataControlInput } from '../common/types';
import { ControlGroupFilterOutput } from './control_group/types';
import { ControlsServiceType } from './services/controls/types';
export interface CommonControlOutput {
filters?: Filter[];
export type CommonControlOutput = ControlGroupFilterOutput & {
dataViewId?: string;
timeslice?: [number, number];
}
};
export type ControlOutput = EmbeddableOutput & CommonControlOutput;
@ -44,9 +42,14 @@ export type ControlEmbeddable<
> = IEmbeddable<TControlEmbeddableInput, TControlEmbeddableOutput> & {
isChained?: () => boolean;
renderPrepend?: () => ReactNode | undefined;
selectionsToFilters?: (
input: Partial<TControlEmbeddableInput>
) => Promise<ControlGroupFilterOutput>;
};
export interface IClearableControl extends ControlEmbeddable {
export interface IClearableControl<
TClearableControlEmbeddableInput extends ControlInput = ControlInput
> extends ControlEmbeddable {
clearSelections: () => void;
}
@ -113,4 +116,4 @@ export interface ControlsPluginStartDeps {
}
// re-export from common
export type { ControlWidth, ControlInput, DataControlInput, ControlStyle } from '../common/types';
export type { ControlInput, ControlStyle, ControlWidth, DataControlInput } from '../common/types';

View file

@ -13,8 +13,10 @@ import type {
// We export the versioned service definition from this file and not the barrel to avoid adding
// the schemas in the "public" js bundle
import { serviceDefinition as v1 } from './v1/cm_services';
import { serviceDefinition as v1 } from './v1';
import { serviceDefinition as v2 } from './v2';
export const cmServicesDefinition: { [version: Version]: ServicesDefinition } = {
1: v1,
2: v2,
};

View file

@ -6,6 +6,6 @@
* Side Public License, v 1.
*/
export const LATEST_VERSION = 1;
export const LATEST_VERSION = 2;
export const CONTENT_ID = 'dashboard';

View file

@ -17,8 +17,3 @@ export type {
DashboardAttributes,
SavedDashboardPanel,
} from './latest';
// Today "v1" === "latest" so the export under DashboardV1 namespace is not really useful
// We leave it as a reference for future version when it will be needed to export/support older types
// in the UIs.
export * as DashboardV1 from './v1';

View file

@ -6,5 +6,5 @@
* Side Public License, v 1.
*/
// Latest version is 1
export * from './v1';
// Latest version is 2
export * from './v2';

View file

@ -14,7 +14,16 @@ import {
createResultSchema,
} from '@kbn/content-management-utils';
const dashboardAttributesSchema = schema.object(
export const controlGroupInputSchema = schema
.object({
panelsJSON: schema.maybe(schema.string()),
controlStyle: schema.maybe(schema.string()),
chainingSystem: schema.maybe(schema.string()),
ignoreParentSettingsJSON: schema.maybe(schema.string()),
})
.extends({}, { unknowns: 'ignore' });
export const dashboardAttributesSchema = schema.object(
{
// General
title: schema.string(),
@ -39,14 +48,7 @@ const dashboardAttributesSchema = schema.object(
),
// Dashboard Content
controlGroupInput: schema.maybe(
schema.object({
panelsJSON: schema.maybe(schema.string()),
controlStyle: schema.maybe(schema.string()),
chainingSystem: schema.maybe(schema.string()),
ignoreParentSettingsJSON: schema.maybe(schema.string()),
})
),
controlGroupInput: schema.maybe(controlGroupInputSchema),
panelsJSON: schema.string({ defaultValue: '[]' }),
optionsJSON: schema.string({ defaultValue: '{}' }),
@ -57,7 +59,7 @@ const dashboardAttributesSchema = schema.object(
{ unknowns: 'forbid' }
);
const dashboardSavedObjectSchema = savedObjectSchema(dashboardAttributesSchema);
export const dashboardSavedObjectSchema = savedObjectSchema(dashboardAttributesSchema);
const searchOptionsSchema = schema.maybe(
schema.object(

View file

@ -6,11 +6,17 @@
* Side Public License, v 1.
*/
import { DashboardCrudTypes } from './types';
export type {
GridData,
DashboardItem,
DashboardCrudTypes,
DashboardAttributes,
SavedDashboardPanel,
} from './types';
export type DashboardItem = DashboardCrudTypes['Item'];
export {
serviceDefinition,
dashboardSavedObjectSchema,
controlGroupInputSchema,
dashboardAttributesSchema,
} from './cm_services';

View file

@ -28,6 +28,8 @@ export type DashboardCrudTypes = ContentManagementCrudTypes<
}
>;
export type DashboardItem = DashboardCrudTypes['Item'];
/**
* Grid type for React Grid Layout
*/
@ -59,9 +61,14 @@ export interface SavedDashboardPanel {
version?: string;
}
type ControlGroupAttributesV1 = Pick<
RawControlGroupAttributes,
'panelsJSON' | 'chainingSystem' | 'controlStyle' | 'ignoreParentSettingsJSON'
>;
/* eslint-disable-next-line @typescript-eslint/consistent-type-definitions */
export type DashboardAttributes = {
controlGroupInput?: RawControlGroupAttributes;
controlGroupInput?: ControlGroupAttributesV1;
refreshInterval?: RefreshInterval;
timeRestore: boolean;
optionsJSON?: string;

View file

@ -0,0 +1,77 @@
/*
* 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 { schema } from '@kbn/config-schema';
import {
createResultSchema,
objectTypeToGetResultSchema,
savedObjectSchema,
} from '@kbn/content-management-utils';
import type { ContentManagementServicesDefinition as ServicesDefinition } from '@kbn/object-versioning';
import {
controlGroupInputSchema as controlGroupInputSchemaV1,
dashboardAttributesSchema as dashboardAttributesSchemaV1,
serviceDefinition as serviceDefinitionV1,
} from '../v1';
export const dashboardAttributesSchema = dashboardAttributesSchemaV1.extends(
{
controlGroupInput: schema.maybe(
controlGroupInputSchemaV1.extends(
{
showApplySelections: schema.maybe(schema.boolean()),
},
{ unknowns: 'ignore' }
)
),
},
{ unknowns: 'ignore' }
);
export const dashboardSavedObjectSchema = savedObjectSchema(dashboardAttributesSchema);
// Content management service definition.
export const serviceDefinition: ServicesDefinition = {
get: {
out: {
result: {
schema: objectTypeToGetResultSchema(dashboardSavedObjectSchema),
},
},
},
create: {
in: {
...serviceDefinitionV1?.create?.in,
data: {
schema: dashboardAttributesSchema,
},
},
out: {
result: {
schema: createResultSchema(dashboardSavedObjectSchema),
},
},
},
update: {
in: {
...serviceDefinitionV1.update?.in,
data: {
schema: dashboardAttributesSchema,
},
},
},
search: {
in: serviceDefinitionV1.search?.in,
},
mSearch: {
out: {
result: {
schema: dashboardSavedObjectSchema,
},
},
},
};

View file

@ -0,0 +1,16 @@
/*
* 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 {
serviceDefinition,
dashboardSavedObjectSchema,
dashboardAttributesSchema,
} from './cm_services';
export type { GridData, DashboardItem, SavedDashboardPanel } from '../v1/types'; // no changes made to types from v1 to v2
export type { DashboardCrudTypes, DashboardAttributes } from './types';

View file

@ -0,0 +1,40 @@
/*
* 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 {
ContentManagementCrudTypes,
SavedObjectCreateOptions,
SavedObjectUpdateOptions,
} from '@kbn/content-management-utils';
import { RawControlGroupAttributes } from '@kbn/controls-plugin/common';
import { DashboardContentType } from '../types';
import { DashboardAttributes as DashboardAttributesV1 } from '../v1/types';
type ControlGroupAttributesV2 = Pick<
RawControlGroupAttributes,
| 'panelsJSON'
| 'chainingSystem'
| 'controlStyle'
| 'ignoreParentSettingsJSON'
| 'showApplySelections'
>;
export type DashboardAttributes = Omit<DashboardAttributesV1, 'controlGroupInput'> & {
controlGroupInput?: ControlGroupAttributesV2;
};
export type DashboardCrudTypes = ContentManagementCrudTypes<
DashboardContentType,
DashboardAttributes,
Pick<SavedObjectCreateOptions, 'id' | 'references' | 'overwrite'>,
Pick<SavedObjectUpdateOptions, 'references'>,
{
/** Flag to indicate to only search the text on the "title" field */
onlyTitle?: boolean;
}
>;

View file

@ -153,7 +153,7 @@ export function runSaveAs(this: DashboardContainer) {
this.dispatch.setStateFromSaveModal(stateFromSaveModal);
this.dispatch.setLastSavedInput(dashboardStateToSave);
if (this.controlGroup && persistableControlGroupInput) {
this.controlGroup.dispatch.setLastSavedInput(persistableControlGroupInput);
this.controlGroup.setSavedState(persistableControlGroupInput);
}
});
}
@ -214,7 +214,7 @@ export async function runQuickSave(this: DashboardContainer) {
this.dispatch.setLastSavedInput(dashboardStateToSave);
this.lastSavedState.next();
if (this.controlGroup && persistableControlGroupInput) {
this.controlGroup.dispatch.setLastSavedInput(persistableControlGroupInput);
this.controlGroup.setSavedState(persistableControlGroupInput);
}
return saveResult;

View file

@ -6,11 +6,12 @@
* Side Public License, v 1.
*/
import { LATEST_VERSION } from '../../common/content_management';
import { convertNumberToDashboardVersion } from '../services/dashboard_content_management/lib/dashboard_versioning';
export const DASHBOARD_CONTAINER_TYPE = 'dashboard';
export const LATEST_DASHBOARD_CONTAINER_VERSION = convertNumberToDashboardVersion(1);
export const LATEST_DASHBOARD_CONTAINER_VERSION = convertNumberToDashboardVersion(LATEST_VERSION);
export type { DashboardContainer } from './embeddable/dashboard_container';
export {

View file

@ -91,7 +91,6 @@ export const saveDashboardState = async ({
query,
title,
filters,
version,
timeRestore,
description,
@ -153,7 +152,7 @@ export const saveDashboardState = async ({
: undefined;
const rawDashboardAttributes: DashboardAttributes = {
version: convertDashboardVersionToNumber(version ?? LATEST_DASHBOARD_CONTAINER_VERSION),
version: convertDashboardVersionToNumber(LATEST_DASHBOARD_CONTAINER_VERSION),
controlGroupInput: serializeControlGroupInput(controlGroupInput),
kibanaSavedObjectMeta: { searchSourceJSON },
description: description ?? '',

View file

@ -0,0 +1,59 @@
/*
* 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 {
createModelVersionTestMigrator,
type ModelVersionTestMigrator,
} from '@kbn/core-test-helpers-model-versions';
import { createEmbeddableSetupMock } from '@kbn/embeddable-plugin/server/mocks';
import { createDashboardSavedObjectType } from './dashboard_saved_object';
const embeddableSetupMock = createEmbeddableSetupMock();
describe('dashboard saved object model version transformations', () => {
let migrator: ModelVersionTestMigrator;
beforeEach(() => {
migrator = createModelVersionTestMigrator({
type: createDashboardSavedObjectType({ migrationDeps: { embeddable: embeddableSetupMock } }),
});
});
describe('model version 2', () => {
const dashboard = {
id: 'some-id',
type: 'dashboard',
attributes: {
title: 'Some Title',
description: 'some description',
panelsJSON: 'some panels',
kibanaSavedObjectMeta: {},
optionsJSON: 'some options',
controlGroupInput: {},
},
references: [],
};
it('should properly remove the controlGroupInput.showApplySelections field when converting from v2 to v1', () => {
const migrated = migrator.migrate({
document: {
...dashboard,
attributes: {
...dashboard.attributes,
controlGroupInput: { showApplySelections: false },
},
},
fromVersion: 2,
toVersion: 1,
});
expect(migrated.attributes).toEqual(dashboard.attributes);
});
});
});

View file

@ -6,9 +6,11 @@
* Side Public License, v 1.
*/
import { schema } from '@kbn/config-schema';
import { ANALYTICS_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server';
import { SavedObjectsType } from '@kbn/core/server';
import { dashboardAttributesSchema as dashboardAttributesSchemaV1 } from '../../common/content_management/v1';
import { dashboardAttributesSchema as dashboardAttributesSchemaV2 } from '../../common/content_management/v2';
import {
createDashboardSavedObjectTypeMigrations,
DashboardSavedObjectTypeMigrationsDeps,
@ -38,6 +40,33 @@ export const createDashboardSavedObjectType = ({
};
},
},
modelVersions: {
1: {
changes: [],
schemas: {
forwardCompatibility: dashboardAttributesSchemaV1.extends({}, { unknowns: 'ignore' }),
create: dashboardAttributesSchemaV1,
},
},
2: {
changes: [
{
type: 'mappings_addition',
addedMappings: {
controlGroupInput: {
properties: {
showApplySelections: { type: 'boolean', index: false, doc_values: false },
},
},
},
},
],
schemas: {
forwardCompatibility: dashboardAttributesSchemaV2.extends({}, { unknowns: 'ignore' }),
create: dashboardAttributesSchemaV2,
},
},
},
mappings: {
properties: {
description: { type: 'text' },
@ -60,6 +89,7 @@ export const createDashboardSavedObjectType = ({
controlStyle: { type: 'keyword', index: false, doc_values: false },
chainingSystem: { type: 'keyword', index: false, doc_values: false },
panelsJSON: { type: 'text', index: false },
showApplySelections: { type: 'boolean', index: false, doc_values: false },
ignoreParentSettingsJSON: { type: 'text', index: false },
},
},
@ -71,45 +101,7 @@ export const createDashboardSavedObjectType = ({
},
},
schemas: {
'8.9.0': schema.object({
// General
title: schema.string(),
description: schema.string({ defaultValue: '' }),
// Search
kibanaSavedObjectMeta: schema.object({
searchSourceJSON: schema.maybe(schema.string()),
}),
// Time
timeRestore: schema.maybe(schema.boolean()),
timeFrom: schema.maybe(schema.string()),
timeTo: schema.maybe(schema.string()),
refreshInterval: schema.maybe(
schema.object({
pause: schema.boolean(),
value: schema.number(),
display: schema.maybe(schema.string()),
section: schema.maybe(schema.number()),
})
),
// Dashboard Content
controlGroupInput: schema.maybe(
schema.object({
panelsJSON: schema.maybe(schema.string()),
controlStyle: schema.maybe(schema.string()),
chainingSystem: schema.maybe(schema.string()),
ignoreParentSettingsJSON: schema.maybe(schema.string()),
})
),
panelsJSON: schema.string({ defaultValue: '[]' }),
optionsJSON: schema.string({ defaultValue: '{}' }),
// Legacy
hits: schema.maybe(schema.number()),
version: schema.maybe(schema.number()),
}),
'8.9.0': dashboardAttributesSchemaV1,
},
migrations: () => createDashboardSavedObjectTypeMigrations(migrationDeps),
});

View file

@ -76,6 +76,7 @@
"@kbn/content-management-table-list-view-common",
"@kbn/shared-ux-utility",
"@kbn/managed-content-badge",
"@kbn/core-test-helpers-model-versions",
],
"exclude": ["target/**/*"]
}

View file

@ -5,15 +5,15 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { FC, ReactElement, useEffect, useState } from 'react';
import classNames from 'classnames';
import React, { FC, ReactElement, useEffect, useState } from 'react';
import {
type ViewMode,
type IEmbeddable,
type EmbeddableInput,
panelHoverTrigger,
PANEL_HOVER_TRIGGER,
type EmbeddableInput,
type IEmbeddable,
type ViewMode,
} from '@kbn/embeddable-plugin/public';
import { Action } from '@kbn/ui-actions-plugin/public';
@ -41,13 +41,13 @@ export const FloatingActions: FC<FloatingActionsProps> = ({
const {
uiActions: { getTriggerCompatibleActions },
} = pluginServices.getServices();
const [floatingActions, setFloatingActions] = useState<JSX.Element | undefined>(undefined);
useEffect(() => {
if (!embeddable) return;
const getActions = async () => {
let mounted = true;
const context = {
embeddable,
trigger: panelHoverTrigger,
@ -57,6 +57,8 @@ export const FloatingActions: FC<FloatingActionsProps> = ({
return action.MenuItem !== undefined && (disabledActions ?? []).indexOf(action.id) === -1;
})
.sort((a, b) => (a.order || 0) - (b.order || 0));
if (!mounted) return;
if (actions.length > 0) {
setFloatingActions(
<>
@ -71,6 +73,9 @@ export const FloatingActions: FC<FloatingActionsProps> = ({
} else {
setFloatingActions(undefined);
}
return () => {
mounted = false;
};
};
getActions();
@ -80,7 +85,10 @@ export const FloatingActions: FC<FloatingActionsProps> = ({
<div className="presentationUtil__floatingActionsWrapper">
{children}
{isEnabled && floatingActions && (
<div className={classNames('presentationUtil__floatingActions', className)}>
<div
data-test-subj={`presentationUtil__floatingActions__${embeddable?.id}`}
className={classNames('presentationUtil__floatingActions', className)}
>
{floatingActions}
</div>
)}

View file

@ -230,10 +230,10 @@ export default function ({ getService }: FtrProviderContext) {
type: 'dashboard',
namespaces: ['default'],
migrationVersion: {
dashboard: '8.9.0',
dashboard: '10.2.0',
},
coreMigrationVersion: '8.8.0',
typeMigrationVersion: '8.9.0',
typeMigrationVersion: '10.2.0',
updated_at: '2015-01-01T00:00:00.000Z',
created_at: '2015-01-01T00:00:00.000Z',
version: resp.body.saved_objects[3].version,

View file

@ -80,10 +80,10 @@ export default function ({ getService }: FtrProviderContext) {
type: 'dashboard',
namespaces: ['default'],
migrationVersion: {
dashboard: '8.9.0',
dashboard: '10.2.0',
},
coreMigrationVersion: '8.8.0',
typeMigrationVersion: '8.9.0',
typeMigrationVersion: '10.2.0',
updated_at: resp.body.updated_at,
created_at: resp.body.created_at,
version: resp.body.version,

View file

@ -0,0 +1,223 @@
/*
* 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 { OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL } from '@kbn/controls-plugin/common';
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const pieChart = getService('pieChart');
const elasticChart = getService('elasticChart');
const testSubjects = getService('testSubjects');
const dashboardAddPanel = getService('dashboardAddPanel');
const { dashboard, header, dashboardControls, timePicker } = getPageObjects([
'dashboardControls',
'timePicker',
'dashboard',
'header',
]);
describe('Dashboard control group apply button', () => {
let controlIds: string[];
before(async () => {
await dashboard.navigateToApp();
await dashboard.gotoDashboardLandingPage();
await dashboard.clickNewDashboard();
await timePicker.setDefaultDataRange();
await elasticChart.setNewChartUiDebugFlag();
await dashboardAddPanel.addVisualization('Rendering-Test:-animal-sounds-pie');
// populate an initial set of controls and get their ids.
await dashboardControls.createControl({
controlType: OPTIONS_LIST_CONTROL,
dataViewTitle: 'animals-*',
fieldName: 'animal.keyword',
title: 'Animal',
});
await dashboardControls.createControl({
controlType: RANGE_SLIDER_CONTROL,
dataViewTitle: 'animals-*',
fieldName: 'weightLbs',
title: 'Animal Name',
});
await dashboardControls.createTimeSliderControl();
controlIds = await dashboardControls.getAllControlIds();
// save the dashboard
await dashboard.saveDashboard('Test Control Group Apply Button', { exitFromEditMode: false });
await header.waitUntilLoadingHasFinished();
await dashboard.waitForRenderComplete();
await dashboard.expectMissingUnsavedChangesBadge();
});
it('able to set apply button setting', async () => {
await dashboardControls.updateShowApplyButtonSetting(true);
await testSubjects.existOrFail('controlGroup--applyFiltersButton');
await dashboard.expectUnsavedChangesBadge();
await dashboard.clickQuickSave();
await header.waitUntilLoadingHasFinished();
await dashboard.expectMissingUnsavedChangesBadge();
});
it('renabling auto-apply forces filters to be published', async () => {
const optionsListId = controlIds[0];
await dashboardControls.verifyApplyButtonEnabled(false);
await dashboardControls.optionsListOpenPopover(optionsListId);
await dashboardControls.optionsListPopoverSelectOption('cat');
await dashboardControls.optionsListEnsurePopoverIsClosed(optionsListId);
await header.waitUntilLoadingHasFinished();
await dashboardControls.verifyApplyButtonEnabled();
await dashboardControls.updateShowApplyButtonSetting(false);
await header.waitUntilLoadingHasFinished();
await dashboard.waitForRenderComplete();
await dashboard.expectUnsavedChangesBadge();
expect(await pieChart.getPieSliceCount()).to.be(4);
await dashboard.clickDiscardChanges();
});
describe('options list selections', () => {
let optionsListId: string;
before(async () => {
optionsListId = controlIds[0];
});
it('making selection enables apply button', async () => {
await dashboardControls.verifyApplyButtonEnabled(false);
await dashboardControls.optionsListOpenPopover(optionsListId);
await dashboardControls.optionsListPopoverSelectOption('cat');
await dashboardControls.optionsListEnsurePopoverIsClosed(optionsListId);
await header.waitUntilLoadingHasFinished();
await dashboardControls.verifyApplyButtonEnabled();
});
it('waits to apply filters until button is pressed', async () => {
await dashboard.expectMissingUnsavedChangesBadge();
expect(await pieChart.getPieSliceCount()).to.be(5);
await dashboardControls.clickApplyButton();
await header.waitUntilLoadingHasFinished();
await dashboard.waitForRenderComplete();
await dashboard.expectUnsavedChangesBadge();
expect(await pieChart.getPieSliceCount()).to.be(4);
});
it('hitting dashboard resets selections + unapplies filters', async () => {
await dashboardControls.optionsListOpenPopover(optionsListId);
await dashboardControls.optionsListPopoverSelectOption('dog');
await dashboardControls.optionsListEnsurePopoverIsClosed(optionsListId);
await header.waitUntilLoadingHasFinished();
await dashboardControls.verifyApplyButtonEnabled();
await dashboard.clickDiscardChanges();
await header.waitUntilLoadingHasFinished();
await dashboard.waitForRenderComplete();
expect(await pieChart.getPieSliceCount()).to.be(5);
await dashboardControls.verifyApplyButtonEnabled(false);
expect(await dashboardControls.optionsListGetSelectionsString(optionsListId)).to.be('Any');
});
});
describe('range slider selections', () => {
let rangeSliderId: string;
before(async () => {
rangeSliderId = controlIds[1];
});
it('making selection enables apply button', async () => {
await dashboardControls.verifyApplyButtonEnabled(false);
await dashboardControls.rangeSliderSetUpperBound(rangeSliderId, '30');
await dashboardControls.verifyApplyButtonEnabled();
});
it('waits to apply filters until apply button is pressed', async () => {
await dashboard.expectMissingUnsavedChangesBadge();
expect(await pieChart.getPieSliceCount()).to.be(5);
await dashboardControls.clickApplyButton();
await header.waitUntilLoadingHasFinished();
await dashboard.waitForRenderComplete();
await dashboard.expectUnsavedChangesBadge();
expect(await pieChart.getPieSliceCount()).to.be(4);
});
it('hitting dashboard resets selections + unapplies filters', async () => {
await dashboardControls.rangeSliderSetLowerBound(rangeSliderId, '15');
await dashboardControls.rangeSliderEnsurePopoverIsClosed(rangeSliderId);
await header.waitUntilLoadingHasFinished();
await dashboardControls.verifyApplyButtonEnabled();
await dashboard.clickDiscardChanges();
await header.waitUntilLoadingHasFinished();
await dashboard.waitForRenderComplete();
expect(await pieChart.getPieSliceCount()).to.be(5);
await dashboardControls.verifyApplyButtonEnabled(false);
expect(
await dashboardControls.rangeSliderGetLowerBoundAttribute(rangeSliderId, 'value')
).to.be('');
expect(
await dashboardControls.rangeSliderGetUpperBoundAttribute(rangeSliderId, 'value')
).to.be('');
});
});
describe('time slider selections', () => {
let valueBefore: string;
before(async () => {
valueBefore = await dashboardControls.getTimeSliceFromTimeSlider();
});
it('making selection enables apply button', async () => {
await dashboardControls.verifyApplyButtonEnabled(false);
await dashboardControls.gotoNextTimeSlice();
await dashboardControls.gotoNextTimeSlice(); // go to an empty timeslice
await header.waitUntilLoadingHasFinished();
await dashboardControls.verifyApplyButtonEnabled();
});
it('waits to apply timeslice until apply button is pressed', async () => {
await dashboard.expectMissingUnsavedChangesBadge();
expect(await pieChart.getPieSliceCount()).to.be(5);
await dashboardControls.clickApplyButton();
await header.waitUntilLoadingHasFinished();
await dashboard.waitForRenderComplete();
await dashboard.expectUnsavedChangesBadge();
pieChart.expectEmptyPieChart();
});
it('hitting dashboard resets selections + unapplies timeslice', async () => {
await dashboardControls.gotoNextTimeSlice();
await dashboardControls.verifyApplyButtonEnabled();
await dashboard.clickDiscardChanges();
await header.waitUntilLoadingHasFinished();
await dashboard.waitForRenderComplete();
expect(await pieChart.getPieSliceCount()).to.be(5);
await dashboardControls.verifyApplyButtonEnabled(false);
const valueNow = await dashboardControls.getTimeSliceFromTimeSlider();
expect(valueNow).to.equal(valueBefore);
});
});
});
}

View file

@ -44,6 +44,7 @@ export default function ({ loadTestFile, getService, getPageObjects }: FtrProvid
loadTestFile(require.resolve('./range_slider'));
loadTestFile(require.resolve('./time_slider'));
loadTestFile(require.resolve('./control_group_chaining'));
loadTestFile(require.resolve('./control_group_apply_button'));
loadTestFile(require.resolve('./replace_controls'));
});
}

View file

@ -348,12 +348,14 @@ export class DashboardPageObject extends FtrService {
}
public async expectUnsavedChangesBadge() {
this.log.debug('Expect unsaved changes badge to be present');
await this.retry.try(async () => {
await this.testSubjects.existOrFail('dashboardUnsavedChangesBadge');
});
}
public async expectMissingUnsavedChangesBadge() {
this.log.debug('Expect there to be no unsaved changes badge');
await this.retry.try(async () => {
await this.testSubjects.missingOrFail('dashboardUnsavedChangesBadge');
});

View file

@ -203,6 +203,34 @@ export class DashboardPageControls extends FtrService {
await this.testSubjects.click('control-group-editor-save');
}
public async updateShowApplyButtonSetting(showApplyButton: boolean) {
this.log.debug(`Update show apply button setting to ${showApplyButton}`);
await this.openControlGroupSettingsFlyout();
// the "showApplyButton" toggle has in inverse relationship with the `showApplyButton` seting - so, negate `showApplyButton`
await this.setSwitchState(!showApplyButton, 'control-group-auto-apply-selections');
await this.testSubjects.click('control-group-editor-save');
}
public async clickApplyButton() {
this.log.debug('Clicking the apply button');
await this.verifyApplyButtonEnabled();
const applyButton = await this.testSubjects.find('controlGroup--applyFiltersButton');
await applyButton.click();
await this.verifyApplyButtonEnabled(false);
}
public async verifyApplyButtonEnabled(enabled: boolean = true) {
this.log.debug(
`Checking that control group apply button is ${enabled ? 'enabled' : 'not enabled'}`
);
const applyButton = await this.testSubjects.find('controlGroup--applyFiltersButton');
await this.retry.try(async () => {
expect(await applyButton.isEnabled()).to.be(enabled);
});
}
/* -----------------------------------------------------------
Individual controls functions
----------------------------------------------------------- */
@ -670,6 +698,10 @@ export class DashboardPageControls extends FtrService {
`range-slider-control-${controlId} > rangeSlider__lowerBoundFieldNumber`,
value
);
await this.testSubjects.pressEnter(
// force the change without waiting for the debounce
`range-slider-control-${controlId} > rangeSlider__lowerBoundFieldNumber`
);
expect(await this.rangeSliderGetLowerBoundAttribute(controlId, 'value')).to.be(value);
});
}
@ -681,6 +713,10 @@ export class DashboardPageControls extends FtrService {
`range-slider-control-${controlId} > rangeSlider__upperBoundFieldNumber`,
value
);
await this.testSubjects.pressEnter(
// force the change without waiting for the debounce
`range-slider-control-${controlId} > rangeSlider__upperBoundFieldNumber`
);
expect(await this.rangeSliderGetUpperBoundAttribute(controlId, 'value')).to.be(value);
});
}

View file

@ -11,7 +11,7 @@
}
.filter-group__wrapper {
.euiFlexGroup.controlGroup {
.euiPanel.controlsWrapper {
min-height: 34px;
}

View file

@ -408,7 +408,7 @@
"controls.controlGroup.management.discard.sub": "Les modifications apportées à ce contrôle seront ignorées. Voulez-vous vraiment continuer ?",
"controls.controlGroup.management.discard.title": "Abandonner les modifications ?",
"controls.controlGroup.management.flyoutTitle": "Paramètres du contrôle",
"controls.controlGroup.management.hierarchy.subtitle": "Les sélections dans un contrôle diminuent les options disponibles dans le suivant. Les contrôles se suivent de gauche à droite.",
"controls.controlGroup.management.hierarchy.tooltip": "Les sélections dans un contrôle diminuent les options disponibles dans le suivant. Les contrôles se suivent de gauche à droite.",
"controls.controlGroup.management.hierarchy.title": "Contrôles à la suite",
"controls.controlGroup.management.labelPosition.above": "Au-dessus",
"controls.controlGroup.management.labelPosition.designSwitchLegend": "Modifier la position de l'étiquette entre Aligné et Au-dessus",
@ -419,7 +419,7 @@
"controls.controlGroup.management.layout.large": "Large",
"controls.controlGroup.management.layout.medium": "Moyenne",
"controls.controlGroup.management.layout.small": "Petite",
"controls.controlGroup.management.validate.subtitle": "Ignorez automatiquement toutes les sélections de contrôle qui ne donneraient aucune donnée.",
"controls.controlGroup.management.validate.tooltip": "Ignorez automatiquement toutes les sélections de contrôle qui ne donneraient aucune donnée.",
"controls.controlGroup.management.validate.title": "Valider les sélections utilisateur",
"controls.controlGroup.timeSlider.title": "Curseur temporel",
"controls.controlGroup.title": "Groupe de contrôle",

View file

@ -408,7 +408,7 @@
"controls.controlGroup.management.discard.sub": "このコントロールの変更は破棄されます。続行しますか?",
"controls.controlGroup.management.discard.title": "変更を破棄しますか?",
"controls.controlGroup.management.flyoutTitle": "設定をコントロールします",
"controls.controlGroup.management.hierarchy.subtitle": "1つのコントロールで項目を選択すると、次で使用可能なオプションが絞り込まれます。コントロールは左から右に連鎖されます。",
"controls.controlGroup.management.hierarchy.tooltip": "1つのコントロールで項目を選択すると、次で使用可能なオプションが絞り込まれます。コントロールは左から右に連鎖されます。",
"controls.controlGroup.management.hierarchy.title": "コントロールの連鎖",
"controls.controlGroup.management.labelPosition.above": "上",
"controls.controlGroup.management.labelPosition.designSwitchLegend": "インラインと上記との間でラベル位置を切り替える",
@ -419,7 +419,7 @@
"controls.controlGroup.management.layout.large": "大",
"controls.controlGroup.management.layout.medium": "中",
"controls.controlGroup.management.layout.small": "小",
"controls.controlGroup.management.validate.subtitle": "データがないコントロール選択は自動的に無視されます。",
"controls.controlGroup.management.validate.tooltip": "データがないコントロール選択は自動的に無視されます。",
"controls.controlGroup.management.validate.title": "ユーザー選択を検証",
"controls.controlGroup.timeSlider.title": "時間スライダー",
"controls.controlGroup.title": "コントロールグループ",

View file

@ -408,7 +408,7 @@
"controls.controlGroup.management.discard.sub": "将放弃您对此控件所做的更改,是否确定要继续?",
"controls.controlGroup.management.discard.title": "放弃更改?",
"controls.controlGroup.management.flyoutTitle": "控制设置",
"controls.controlGroup.management.hierarchy.subtitle": "在一个控件中选择的内容会缩小下一个控件中可用选项的范围。控件将从左至右串接在一起。",
"controls.controlGroup.management.hierarchy.tooltip": "在一个控件中选择的内容会缩小下一个控件中可用选项的范围。控件将从左至右串接在一起。",
"controls.controlGroup.management.hierarchy.title": "串接控件",
"controls.controlGroup.management.labelPosition.above": "之上",
"controls.controlGroup.management.labelPosition.designSwitchLegend": "在内联与之上之间切换标签位置",
@ -419,7 +419,7 @@
"controls.controlGroup.management.layout.large": "大",
"controls.controlGroup.management.layout.medium": "中",
"controls.controlGroup.management.layout.small": "小",
"controls.controlGroup.management.validate.subtitle": "自动忽略所有不会生成数据的控件选择。",
"controls.controlGroup.management.validate.tooltip": "自动忽略所有不会生成数据的控件选择。",
"controls.controlGroup.management.validate.title": "验证用户选择",
"controls.controlGroup.timeSlider.title": "时间滑块",
"controls.controlGroup.title": "控件组",

View file

@ -313,8 +313,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.dashboardControls.rangeSliderWaitForLoading(rangeSliderControl); // wait for range slider to respond to options list selections before proceeding
await PageObjects.dashboardControls.rangeSliderSetLowerBound(rangeSliderControl, '1000');
await PageObjects.dashboardControls.rangeSliderSetUpperBound(rangeSliderControl, '15000');
await PageObjects.dashboard.clickQuickSave();
await PageObjects.dashboard.waitForRenderComplete();
await PageObjects.dashboard.clickQuickSave();
await PageObjects.header.waitUntilLoadingHasFinished();
/** Destination Dashboard */
await createControls(dashboardDrilldownsManage.DASHBOARD_WITH_AREA_CHART_NAME, [