mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[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:
parent
f549bffb15
commit
235c4d5ad7
63 changed files with 1375 additions and 315 deletions
|
@ -246,6 +246,7 @@
|
|||
"controlGroupInput.controlStyle",
|
||||
"controlGroupInput.ignoreParentSettingsJSON",
|
||||
"controlGroupInput.panelsJSON",
|
||||
"controlGroupInput.showApplySelections",
|
||||
"description",
|
||||
"hits",
|
||||
"kibanaSavedObjectMeta",
|
||||
|
|
|
@ -837,6 +837,11 @@
|
|||
"panelsJSON": {
|
||||
"index": false,
|
||||
"type": "text"
|
||||
},
|
||||
"showApplySelections": {
|
||||
"doc_values": false,
|
||||
"index": false,
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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]);
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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: () =>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
})
|
||||
|
|
|
@ -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>,
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
@ -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(),
|
||||
},
|
||||
];
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
max-inline-size: 100% !important;
|
||||
}
|
||||
|
||||
.timeSlider-playToggle {
|
||||
.timeSlider-playToggle:enabled {
|
||||
background-color: $euiColorPrimary !important;
|
||||
}
|
||||
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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.',
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -6,6 +6,6 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export const LATEST_VERSION = 1;
|
||||
export const LATEST_VERSION = 2;
|
||||
|
||||
export const CONTENT_ID = 'dashboard';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -6,5 +6,5 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
// Latest version is 1
|
||||
export * from './v1';
|
||||
// Latest version is 2
|
||||
export * from './v2';
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
16
src/plugins/dashboard/common/content_management/v2/index.ts
Normal file
16
src/plugins/dashboard/common/content_management/v2/index.ts
Normal 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';
|
40
src/plugins/dashboard/common/content_management/v2/types.ts
Normal file
40
src/plugins/dashboard/common/content_management/v2/types.ts
Normal 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;
|
||||
}
|
||||
>;
|
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 ?? '',
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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),
|
||||
});
|
||||
|
|
|
@ -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/**/*"]
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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'));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
}
|
||||
|
||||
.filter-group__wrapper {
|
||||
.euiFlexGroup.controlGroup {
|
||||
.euiPanel.controlsWrapper {
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "コントロールグループ",
|
||||
|
|
|
@ -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": "控件组",
|
||||
|
|
|
@ -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, [
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue