[Embeddable Rebuild] [Controls] Remove non-React controls from controls plugin (#192017)

Part of https://github.com/elastic/kibana/issues/192005
Closes https://github.com/elastic/kibana/issues/176533

## Summary

This PR represents the first major cleanup task for the control group
embeddable refactor. The tasks included in this PR can be loosely
summarized as follows:
1. This PR removes the old, non-React version of controls 
- Note that the new controls are still included under the
`react_controls` folder - I will address this in a follow up PR.
2. This PR removes **all** types associated with the old embeddable
system; i.e. any `*input*` or `*output*` types.
- As part of cleaning up these types, some of the types included in the
`public/react_controls` folder had to be moved to `common` to make them
available to server-side code.
- This resulted in an... unfortunate number of import changes 🫠 Hence
the rather large file change count. I took this opportunity to organize
the imports, too - so a significant chunk of these files are simply
import changes.
3. This PR removes the controls Storybook and all related mocks
- Since the controls storybooks have been broken for awhile, and since
we had plans to remove them but never got around to it, I just decided
to delete them as part of this PR and close
https://github.com/elastic/kibana/issues/176533 rather than spending
time to fix the types for non-operational stories

### 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


### 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>
This commit is contained in:
Hannah Mudge 2024-09-17 08:12:54 -06:00 committed by GitHub
parent b3a1e5fb8f
commit 5082eef2f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
208 changed files with 1468 additions and 12964 deletions

View file

@ -22,7 +22,6 @@ const STORYBOOKS = [
'coloring',
'chart_icons',
'content_management_examples',
'controls',
'custom_integrations',
'dashboard_enhanced',
'dashboard',

View file

@ -9,6 +9,7 @@
import { pickBy } from 'lodash';
import React, { useEffect, useState } from 'react';
import {
EuiButton,
EuiButtonEmpty,
@ -16,20 +17,24 @@ import {
EuiFlexGroup,
EuiFlexItem,
EuiPanel,
EuiSkeletonRectangle,
EuiSpacer,
EuiText,
EuiTitle,
EuiSkeletonRectangle,
} from '@elastic/eui';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL } from '@kbn/controls-plugin/common';
import { ControlGroupRuntimeState, ControlStateTransform } from '@kbn/controls-plugin/public';
import {
OPTIONS_LIST_CONTROL,
RANGE_SLIDER_CONTROL,
type ControlGroupRuntimeState,
} from '@kbn/controls-plugin/common';
import {
ACTION_DELETE_CONTROL,
ACTION_EDIT_CONTROL,
ControlGroupRenderer,
ControlGroupRendererApi,
type ControlStateTransform,
} from '@kbn/controls-plugin/public';
import { ControlGroupRendererApi } from '@kbn/controls-plugin/public';
import { ViewMode } from '@kbn/embeddable-plugin/public';
const INPUT_KEY = 'kbnControls:saveExample:input';

View file

@ -8,12 +8,9 @@
*/
import React, { useEffect, useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { lastValueFrom } from 'rxjs';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
import type { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public';
import type { Filter, Query, TimeRange } from '@kbn/es-query';
import { v4 as uuidv4 } from 'uuid';
import {
EuiCallOut,
EuiLoadingSpinner,
@ -23,6 +20,11 @@ import {
EuiTitle,
} from '@elastic/eui';
import { ControlGroupRenderer, ControlGroupRendererApi } from '@kbn/controls-plugin/public';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
import type { Filter, Query, TimeRange } from '@kbn/es-query';
import type { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public';
import { PLUGIN_ID } from '../../constants';
interface Props {

View file

@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { ControlGroupRuntimeState } from '@kbn/controls-plugin/public';
import { ControlGroupRuntimeState } from '@kbn/controls-plugin/common';
const RUNTIME_STATE_SESSION_STORAGE_KEY =
'kibana.examples.controls.reactControlExample.controlGroupRuntimeState';

View file

@ -7,8 +7,8 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { SerializedPanelState } from '@kbn/presentation-containers';
import { ControlGroupSerializedState } from '@kbn/controls-plugin/public';
import type { SerializedPanelState } from '@kbn/presentation-containers';
import type { ControlGroupSerializedState } from '@kbn/controls-plugin/common';
import {
OPTIONS_LIST_CONTROL,
RANGE_SLIDER_CONTROL,

View file

@ -28,7 +28,7 @@ import ReactDOM from 'react-dom';
import useObservable from 'react-use/lib/useObservable';
import { ControlGroupRendererApi, ControlGroupRenderer } from '@kbn/controls-plugin/public';
import { css } from '@emotion/react';
import type { ControlsPanels } from '@kbn/controls-plugin/common';
import type { ControlPanelsState } from '@kbn/controls-plugin/common';
import { Route, Router, Routes } from '@kbn/shared-ux-router';
import { I18nProvider } from '@kbn/i18n-react';
import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme';
@ -357,7 +357,7 @@ export class DiscoverCustomizationExamplesPlugin implements Plugin {
}
const stateSubscription = stateStorage
.change$<ControlsPanels>('controlPanels')
.change$<ControlPanelsState>('controlPanels')
.subscribe((panels) =>
controlGroupAPI.updateInput({ initialChildControlState: panels ?? undefined })
);
@ -410,7 +410,7 @@ export class DiscoverCustomizationExamplesPlugin implements Plugin {
<ControlGroupRenderer
onApiAvailable={setControlGroupAPI}
getCreationOptions={async (initialState, builder) => {
const panels = stateStorage.get<ControlsPanels>('controlPanels');
const panels = stateStorage.get<ControlPanelsState>('controlPanels');
if (!panels) {
builder.addOptionsListControl(initialState, {

View file

@ -22,7 +22,6 @@ export const storybookAliases = {
language_documentation_popover: 'packages/kbn-language-documentation-popover/.storybook',
chart_icons: 'packages/kbn-chart-icons/.storybook',
content_management_examples: 'examples/content_management_examples/.storybook',
controls: 'src/plugins/controls/storybook',
custom_icons: 'packages/kbn-custom-icons/.storybook',
custom_integrations: 'src/plugins/custom_integrations/storybook',
dashboard_enhanced: 'x-pack/plugins/dashboard_enhanced/.storybook',

View file

@ -7,8 +7,12 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { ControlStyle, ControlWidth } from '../types';
import { ControlStyle, ControlWidth } from './types';
export const DEFAULT_CONTROL_WIDTH: ControlWidth = 'medium';
export const DEFAULT_CONTROL_GROW: boolean = true;
export const DEFAULT_CONTROL_STYLE: ControlStyle = 'oneLine';
export const TIME_SLIDER_CONTROL = 'timeSlider';
export const RANGE_SLIDER_CONTROL = 'rangeSliderControl';
export const OPTIONS_LIST_CONTROL = 'optionsListControl';

View file

@ -1,120 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import {
ControlWidth,
ControlPanelState,
OPTIONS_LIST_CONTROL,
RANGE_SLIDER_CONTROL,
OptionsListEmbeddableInput,
RangeSliderEmbeddableInput,
getDefaultControlGroupInput,
ControlGroupInput,
} from '..';
import { mockOptionsListEmbeddableInput, mockRangeSliderEmbeddableInput } from '../mocks';
import { removeHideExcludeAndHideExists } from './control_group_migrations';
describe('migrate control group', () => {
const getOptionsListControl = (order: number, input?: Partial<OptionsListEmbeddableInput>) => {
return {
type: OPTIONS_LIST_CONTROL,
order,
width: 'small' as ControlWidth,
grow: true,
explicitInput: { ...mockOptionsListEmbeddableInput, ...input },
} as ControlPanelState;
};
const getRangeSliderControl = (order: number, input?: Partial<RangeSliderEmbeddableInput>) => {
return {
type: RANGE_SLIDER_CONTROL,
order,
width: 'medium' as ControlWidth,
grow: false,
explicitInput: { ...mockRangeSliderEmbeddableInput, ...input },
} as ControlPanelState;
};
const getControlGroupInput = (panels: ControlPanelState[]): ControlGroupInput => {
const panelsObjects = panels.reduce((acc, panel) => {
return { ...acc, [panel.explicitInput.id]: panel };
}, {});
return {
id: 'testControlGroupMigration',
...getDefaultControlGroupInput(),
panels: panelsObjects,
};
};
describe('remove hideExclude and hideExists', () => {
test('should migrate single options list control', () => {
const migratedControlGroupInput: ControlGroupInput = removeHideExcludeAndHideExists(
getControlGroupInput([getOptionsListControl(0, { id: 'testPanelId', hideExclude: true })])
);
expect(migratedControlGroupInput.panels).toEqual({
testPanelId: getOptionsListControl(0, { id: 'testPanelId' }),
});
});
test('should migrate multiple options list controls', () => {
const migratedControlGroupInput: ControlGroupInput = removeHideExcludeAndHideExists(
getControlGroupInput([
getOptionsListControl(0, { id: 'testPanelId1' }),
getOptionsListControl(1, { id: 'testPanelId2', hideExclude: false }),
getOptionsListControl(2, { id: 'testPanelId3', hideExists: true }),
getOptionsListControl(3, {
id: 'testPanelId4',
hideExclude: true,
hideExists: false,
}),
getOptionsListControl(4, {
id: 'testPanelId5',
hideExists: true,
hideExclude: false,
singleSelect: true,
runPastTimeout: true,
selectedOptions: ['test'],
}),
])
);
expect(migratedControlGroupInput.panels).toEqual({
testPanelId1: getOptionsListControl(0, { id: 'testPanelId1' }),
testPanelId2: getOptionsListControl(1, { id: 'testPanelId2' }),
testPanelId3: getOptionsListControl(2, { id: 'testPanelId3' }),
testPanelId4: getOptionsListControl(3, {
id: 'testPanelId4',
}),
testPanelId5: getOptionsListControl(4, {
id: 'testPanelId5',
singleSelect: true,
runPastTimeout: true,
selectedOptions: ['test'],
}),
});
});
test('should migrate multiple different types of controls', () => {
const migratedControlGroupInput: ControlGroupInput = removeHideExcludeAndHideExists(
getControlGroupInput([
getOptionsListControl(0, {
id: 'testPanelId1',
hideExists: true,
hideExclude: true,
runPastTimeout: true,
}),
getRangeSliderControl(1, { id: 'testPanelId2' }),
])
);
expect(migratedControlGroupInput.panels).toEqual({
testPanelId1: getOptionsListControl(0, { id: 'testPanelId1', runPastTimeout: true }),
testPanelId2: getRangeSliderControl(1, { id: 'testPanelId2' }),
});
});
});
});

View file

@ -1,62 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { ControlGroupInput, ControlPanelState, ControlsPanels } from '..';
import { OptionsListEmbeddableInput, OPTIONS_LIST_CONTROL } from '../options_list/types';
export const makeControlOrdersZeroBased = (input: ControlGroupInput) => {
if (
input.panels &&
typeof input.panels === 'object' &&
Object.keys(input.panels).length > 0 &&
!Object.values(input.panels).find((panel) => (panel.order ?? 0) === 0)
) {
// 0th element could not be found. Reorder all panels from 0;
const newPanels = Object.values(input.panels)
.sort((a, b) => (a.order > b.order ? 1 : -1))
.map((panel, index) => {
panel.order = index;
return panel;
})
.reduce((acc, currentPanel) => {
acc[currentPanel.explicitInput.id] = currentPanel;
return acc;
}, {} as ControlsPanels);
input.panels = newPanels;
}
return input;
};
/**
* The UX for the "Allow include/exclude" and "Allow exists query" toggles was removed in 8.7.0 so, to
* prevent users from getting stuck when migrating from 8.6.0 (when the toggles were introduced) to 8.7.0
* we must set both the `hideExclude` and `hideExists` keys to `undefined` for all existing options
* list controls.
*/
export const removeHideExcludeAndHideExists = (input: ControlGroupInput) => {
if (input.panels && typeof input.panels === 'object' && Object.keys(input.panels).length > 0) {
const newPanels = Object.keys(input.panels).reduce<ControlsPanels>(
(panelAccumulator, panelId) => {
const panel: ControlPanelState = input.panels[panelId];
if (panel.type === OPTIONS_LIST_CONTROL) {
const explicitInput = panel.explicitInput as OptionsListEmbeddableInput;
delete explicitInput.hideExclude;
delete explicitInput.hideExists;
}
return {
...panelAccumulator,
[panelId]: panel,
};
},
{}
);
input.panels = newPanels;
}
return input;
};

View file

@ -1,126 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import deepEqual from 'fast-deep-equal';
import { omit, isEqual } from 'lodash';
import { OPTIONS_LIST_DEFAULT_SORT } from '../options_list/suggestions_sorting';
import { OptionsListEmbeddableInput, OPTIONS_LIST_CONTROL } from '../options_list/types';
import { RangeSliderEmbeddableInput, RANGE_SLIDER_CONTROL } from '../range_slider/types';
import { TimeSliderControlEmbeddableInput, TIME_SLIDER_CONTROL } from '../time_slider/types';
import { ControlPanelState } from './types';
interface DiffSystem {
getPanelIsEqual: (
initialInput: ControlPanelState,
newInput: ControlPanelState,
compareSelections?: boolean
) => boolean;
}
export const genericControlPanelDiffSystem: DiffSystem = {
getPanelIsEqual: (initialInput, newInput) => {
return deepEqual(initialInput, newInput);
},
};
export const ControlPanelDiffSystems: {
[key: string]: DiffSystem;
} = {
[RANGE_SLIDER_CONTROL]: {
getPanelIsEqual: (initialInput, newInput, compareSelections) => {
if (!deepEqual(omit(initialInput, 'explicitInput'), omit(newInput, 'explicitInput'))) {
return false;
}
const { value: valueA = ['', ''], ...inputA }: Partial<RangeSliderEmbeddableInput> =
initialInput.explicitInput;
const { value: valueB = ['', ''], ...inputB }: Partial<RangeSliderEmbeddableInput> =
newInput.explicitInput;
return (compareSelections ? isEqual(valueA, valueB) : true) && deepEqual(inputA, inputB);
},
},
[OPTIONS_LIST_CONTROL]: {
getPanelIsEqual: (initialInput, newInput, compareSelections) => {
if (!deepEqual(omit(initialInput, 'explicitInput'), omit(newInput, 'explicitInput'))) {
return false;
}
const {
sort: sortA,
exclude: excludeA,
hideSort: hideSortA,
hideExists: hideExistsA,
hideExclude: hideExcludeA,
selectedOptions: selectedA,
singleSelect: singleSelectA,
searchTechnique: searchTechniqueA,
existsSelected: existsSelectedA,
runPastTimeout: runPastTimeoutA,
...inputA
}: Partial<OptionsListEmbeddableInput> = initialInput.explicitInput;
const {
sort: sortB,
exclude: excludeB,
hideSort: hideSortB,
hideExists: hideExistsB,
hideExclude: hideExcludeB,
selectedOptions: selectedB,
singleSelect: singleSelectB,
searchTechnique: searchTechniqueB,
existsSelected: existsSelectedB,
runPastTimeout: runPastTimeoutB,
...inputB
}: Partial<OptionsListEmbeddableInput> = newInput.explicitInput;
return (
Boolean(hideSortA) === Boolean(hideSortB) &&
Boolean(hideExistsA) === Boolean(hideExistsB) &&
Boolean(hideExcludeA) === Boolean(hideExcludeB) &&
Boolean(singleSelectA) === Boolean(singleSelectB) &&
Boolean(runPastTimeoutA) === Boolean(runPastTimeoutB) &&
isEqual(searchTechniqueA ?? 'prefix', searchTechniqueB ?? 'prefix') &&
deepEqual(sortA ?? OPTIONS_LIST_DEFAULT_SORT, sortB ?? OPTIONS_LIST_DEFAULT_SORT) &&
(compareSelections
? Boolean(excludeA) === Boolean(excludeB) &&
Boolean(existsSelectedA) === Boolean(existsSelectedB) &&
isEqual(selectedA ?? [], selectedB ?? [])
: true) &&
deepEqual(inputA, inputB)
);
},
},
[TIME_SLIDER_CONTROL]: {
getPanelIsEqual: (initialInput, newInput, compareSelections) => {
if (!deepEqual(omit(initialInput, 'explicitInput'), omit(newInput, 'explicitInput'))) {
return false;
}
const {
isAnchored: isAnchoredA,
timesliceStartAsPercentageOfTimeRange: startA,
timesliceEndAsPercentageOfTimeRange: endA,
}: Partial<TimeSliderControlEmbeddableInput> = initialInput.explicitInput;
const {
isAnchored: isAnchoredB,
timesliceStartAsPercentageOfTimeRange: startB,
timesliceEndAsPercentageOfTimeRange: endB,
}: Partial<TimeSliderControlEmbeddableInput> = newInput.explicitInput;
return (
Boolean(isAnchoredA) === Boolean(isAnchoredB) &&
(compareSelections
? Boolean(startA) === Boolean(startB) &&
startA === startB &&
Boolean(endA) === Boolean(endB) &&
endA === endB
: true)
);
},
},
};

View file

@ -1,152 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import deepEqual from 'fast-deep-equal';
import { SerializableRecord } from '@kbn/utility-types';
import { pick, omit, xor } from 'lodash';
import {
DEFAULT_CONTROL_GROW,
DEFAULT_CONTROL_STYLE,
DEFAULT_CONTROL_WIDTH,
} from './control_group_constants';
import {
ControlPanelDiffSystems,
genericControlPanelDiffSystem,
} from './control_group_panel_diff_system';
import { ControlGroupInput } from '..';
import {
PersistableControlGroupInput,
persistableControlGroupInputKeys,
RawControlGroupAttributes,
} from './types';
const safeJSONParse = <OutType>(jsonString?: string): OutType | undefined => {
if (!jsonString && typeof jsonString !== 'string') return;
try {
return JSON.parse(jsonString) as OutType;
} catch {
return;
}
};
export const getDefaultControlGroupInput = (): Omit<ControlGroupInput, 'id'> => ({
panels: {},
defaultControlWidth: DEFAULT_CONTROL_WIDTH,
defaultControlGrow: DEFAULT_CONTROL_GROW,
controlStyle: DEFAULT_CONTROL_STYLE,
chainingSystem: 'HIERARCHICAL',
showApplySelections: false,
ignoreParentSettings: {
ignoreFilters: false,
ignoreQuery: false,
ignoreTimerange: false,
ignoreValidations: false,
},
});
export const getDefaultControlGroupPersistableInput = (): PersistableControlGroupInput =>
pick(getDefaultControlGroupInput(), persistableControlGroupInputKeys);
export const persistableControlGroupInputIsEqual = (
a: PersistableControlGroupInput | undefined,
b: PersistableControlGroupInput | undefined,
compareSelections: boolean = true
) => {
const defaultInput = getDefaultControlGroupPersistableInput();
const inputA = {
...defaultInput,
...pick(a, persistableControlGroupInputKeys),
};
const inputB = {
...defaultInput,
...pick(b, persistableControlGroupInputKeys),
};
return (
getPanelsAreEqual(inputA.panels, inputB.panels, compareSelections) &&
deepEqual(omit(inputA, ['panels']), omit(inputB, ['panels']))
);
};
const getPanelsAreEqual = (
originalPanels: PersistableControlGroupInput['panels'],
newPanels: PersistableControlGroupInput['panels'],
compareSelections: boolean
) => {
const originalPanelIds = Object.keys(originalPanels);
const newPanelIds = Object.keys(newPanels);
const panelIdDiff = xor(originalPanelIds, newPanelIds);
if (panelIdDiff.length > 0) {
return false;
}
for (const panelId of newPanelIds) {
const newPanelType = newPanels[panelId].type;
const panelIsEqual = ControlPanelDiffSystems[newPanelType]
? ControlPanelDiffSystems[newPanelType].getPanelIsEqual(
originalPanels[panelId],
newPanels[panelId],
compareSelections
)
: genericControlPanelDiffSystem.getPanelIsEqual(originalPanels[panelId], newPanels[panelId]);
if (!panelIsEqual) return false;
}
return true;
};
export const rawControlGroupAttributesToControlGroupInput = (
rawControlGroupAttributes: RawControlGroupAttributes
): PersistableControlGroupInput | undefined => {
const defaultControlGroupInput = getDefaultControlGroupInput();
const {
chainingSystem,
controlStyle,
showApplySelections,
ignoreParentSettingsJSON,
panelsJSON,
} = rawControlGroupAttributes;
const panels = safeJSONParse<ControlGroupInput['panels']>(panelsJSON);
const ignoreParentSettings =
safeJSONParse<ControlGroupInput['ignoreParentSettings']>(ignoreParentSettingsJSON);
return {
...defaultControlGroupInput,
...(chainingSystem ? { chainingSystem } : {}),
...(controlStyle ? { controlStyle } : {}),
...(showApplySelections ? { showApplySelections } : {}),
...(ignoreParentSettings ? { ignoreParentSettings } : {}),
...(panels ? { panels } : {}),
};
};
export const rawControlGroupAttributesToSerializable = (
rawControlGroupAttributes: Omit<RawControlGroupAttributes, 'id'>
): SerializableRecord => {
const defaultControlGroupInput = getDefaultControlGroupInput();
return {
chainingSystem: rawControlGroupAttributes?.chainingSystem,
controlStyle: rawControlGroupAttributes?.controlStyle ?? defaultControlGroupInput.controlStyle,
showApplySelections: rawControlGroupAttributes?.showApplySelections,
ignoreParentSettings: safeJSONParse(rawControlGroupAttributes?.ignoreParentSettingsJSON) ?? {},
panels: safeJSONParse(rawControlGroupAttributes?.panelsJSON) ?? {},
};
};
export const serializableToRawControlGroupAttributes = (
serializable: SerializableRecord
): Omit<RawControlGroupAttributes, 'id' | 'type'> => {
return {
controlStyle: serializable.controlStyle as RawControlGroupAttributes['controlStyle'],
chainingSystem: serializable.chainingSystem as RawControlGroupAttributes['chainingSystem'],
showApplySelections: Boolean(serializable.showApplySelections),
ignoreParentSettingsJSON: JSON.stringify(serializable.ignoreParentSettings),
panelsJSON: JSON.stringify(serializable.panels),
};
};

View file

@ -7,6 +7,13 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { defaultConfig } from '@kbn/storybook';
export type {
ControlGroupChainingSystem,
ControlGroupEditorConfig,
ControlGroupRuntimeState,
ControlGroupSerializedState,
ControlPanelState,
ControlPanelsState,
} from './types';
module.exports = defaultConfig;
export { CONTROL_GROUP_TYPE } from './types';

View file

@ -1,75 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { EmbeddablePersistableStateService } from '@kbn/embeddable-plugin/common';
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',
...getDefaultControlGroupInput(),
...{
panels: {
control1: {
order: 0,
width: 'medium',
grow: true,
type: 'mockedOptionsList',
explicitInput: {
id: 'control1',
},
},
control2: {
order: 1,
width: 'large',
grow: true,
type: 'mockedRangeSlider',
explicitInput: {
id: 'control2',
},
},
control3: {
order: 2,
width: 'small',
grow: true,
type: 'mockedOptionsList',
explicitInput: {
id: 'control3',
},
},
},
},
...(partial ?? {}),
});
export const mockControlGroupContainer = async (
explicitInput?: Partial<ControlGroupInput>,
initialComponentState?: Partial<ControlGroupComponentState>
) => {
const controlGroupFactoryStub = new ControlGroupContainerFactory(
{} as unknown as EmbeddablePersistableStateService
);
const input: ControlGroupInput = {
id: 'mocked-control-group',
...getDefaultControlGroupInput(),
...explicitInput,
};
const controlGroupContainer = await controlGroupFactoryStub.create(input, undefined, {
...initialComponentState,
lastSavedInput: {
panels: input.panels,
chainingSystem: 'HIERARCHICAL',
controlStyle: 'twoLine',
},
});
return controlGroupContainer;
};

View file

@ -7,83 +7,66 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { EmbeddableInput, PanelState } from '@kbn/embeddable-plugin/common/types';
import { SerializableRecord } from '@kbn/utility-types';
import { ControlInput, ControlStyle, ControlWidth } from '../types';
import { DataViewField } from '@kbn/data-views-plugin/common';
import { ControlStyle, DefaultControlState, ParentIgnoreSettings } from '../types';
export const CONTROL_GROUP_TYPE = 'control_group';
export interface ControlPanelState<TEmbeddableInput extends ControlInput = ControlInput>
extends PanelState<TEmbeddableInput> {
order: number;
width: ControlWidth;
grow: boolean;
}
export type ControlGroupChainingSystem = 'HIERARCHICAL' | 'NONE';
export interface ControlsPanels {
[panelId: string]: ControlPanelState;
export type FieldFilterPredicate = (f: DataViewField) => boolean;
/**
* ----------------------------------------------------------------
* Control group state
* ----------------------------------------------------------------
*/
export interface ControlGroupEditorConfig {
hideDataViewSelector?: boolean;
hideWidthSettings?: boolean;
hideAdditionalSettings?: boolean;
fieldFilterPredicate?: FieldFilterPredicate;
}
export interface ControlGroupInput extends EmbeddableInput, ControlInput {
export interface ControlGroupRuntimeState<State extends DefaultControlState = DefaultControlState> {
chainingSystem: ControlGroupChainingSystem;
defaultControlWidth?: ControlWidth;
defaultControlGrow?: boolean;
labelPosition: ControlStyle; // TODO: Rename this type to ControlLabelPosition
autoApplySelections: boolean;
ignoreParentSettings?: ParentIgnoreSettings;
initialChildControlState: ControlPanelsState<State>;
/*
* Configuration settings that are never persisted
* - remove after https://github.com/elastic/kibana/issues/189939 is resolved
*/
editorConfig?: ControlGroupEditorConfig;
}
export interface ControlGroupSerializedState
extends Pick<ControlGroupRuntimeState, 'chainingSystem' | 'editorConfig'> {
panelsJSON: string; // stringified version of ControlSerializedState
ignoreParentSettingsJSON: string;
// In runtime state, we refer to this property as `labelPosition`;
// to avoid migrations, we will continue to refer to this property as `controlStyle` in the serialized state
controlStyle: ControlStyle;
panels: ControlsPanels;
// In runtime state, we refer to the inverse of this property as `autoApplySelections`
// to avoid migrations, we will continue to refer to this property as `showApplySelections` in the serialized state
showApplySelections?: boolean;
}
/**
* Only parts of the Control Group Input should be persisted
* ----------------------------------------------------------------
* Control group panel state
* ----------------------------------------------------------------
*/
export const persistableControlGroupInputKeys: Array<
keyof Pick<
ControlGroupInput,
'panels' | 'chainingSystem' | 'controlStyle' | 'ignoreParentSettings' | 'showApplySelections'
>
> = ['panels', 'chainingSystem', 'controlStyle', 'ignoreParentSettings', 'showApplySelections'];
export type PersistableControlGroupInput = Pick<
ControlGroupInput,
(typeof persistableControlGroupInputKeys)[number]
>;
/**
* Some use cases need the Persistable Control Group Input to conform to the SerializableRecord format which requires string index signatures in any objects
*/
export type SerializableControlGroupInput = Omit<
PersistableControlGroupInput,
'panels' | 'ignoreParentSettings'
> & {
panels: ControlsPanels & SerializableRecord;
ignoreParentSettings: PersistableControlGroupInput['ignoreParentSettings'] & SerializableRecord;
};
// panels are json stringified for storage in a saved object.
export type RawControlGroupAttributes = Omit<
PersistableControlGroupInput,
'panels' | 'ignoreParentSettings'
> & {
ignoreParentSettingsJSON: string;
panelsJSON: string;
};
export interface ControlGroupTelemetry {
total: number;
chaining_system: {
[key: string]: number;
};
label_position: {
[key: string]: number;
};
ignore_settings: {
[key: string]: number;
};
by_type: {
[key: string]: {
total: number;
details: { [key: string]: number };
};
};
export interface ControlPanelsState<State extends DefaultControlState = DefaultControlState> {
[panelId: string]: ControlPanelState<State>;
}
export type ControlPanelState<State extends DefaultControlState = DefaultControlState> = State & {
type: string;
order: number;
};

View file

@ -7,37 +7,31 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export type { ControlWidth, ControlInputTransform, ParentIgnoreSettings } from './types';
// Control Group exports
export {
CONTROL_GROUP_TYPE,
type ControlsPanels,
type ControlGroupInput,
type ControlPanelState,
type ControlGroupTelemetry,
type RawControlGroupAttributes,
type PersistableControlGroupInput,
type SerializableControlGroupInput,
type ControlGroupChainingSystem,
persistableControlGroupInputKeys,
} from './control_group/types';
export {
rawControlGroupAttributesToControlGroupInput,
rawControlGroupAttributesToSerializable,
serializableToRawControlGroupAttributes,
getDefaultControlGroupPersistableInput,
persistableControlGroupInputIsEqual,
getDefaultControlGroupInput,
} from './control_group/control_group_persistence';
export type {
ControlStyle,
ControlWidth,
DefaultControlState,
DefaultDataControlState,
ParentIgnoreSettings,
SerializedControlState,
} from './types';
export {
DEFAULT_CONTROL_GROW,
DEFAULT_CONTROL_WIDTH,
DEFAULT_CONTROL_STYLE,
} from './control_group/control_group_constants';
DEFAULT_CONTROL_WIDTH,
OPTIONS_LIST_CONTROL,
RANGE_SLIDER_CONTROL,
TIME_SLIDER_CONTROL,
} from './constants';
// Control Type exports
export { OPTIONS_LIST_CONTROL, type OptionsListEmbeddableInput } from './options_list/types';
export { type RangeSliderEmbeddableInput, RANGE_SLIDER_CONTROL } from './range_slider/types';
export { TIME_SLIDER_CONTROL } from './time_slider/types';
export { CONTROL_GROUP_TYPE } from './control_group';
export type {
ControlGroupChainingSystem,
ControlGroupEditorConfig,
ControlGroupRuntimeState,
ControlGroupSerializedState,
ControlPanelState,
ControlPanelsState,
} from './control_group';

View file

@ -1,12 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export * from './control_group/mocks';
export * from './options_list/mocks';
export * from './range_slider/mocks';

View file

@ -7,20 +7,15 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import {
export { isValidSearch } from './is_valid_search';
export { getSelectionAsFieldType, type OptionsListSelection } from './options_list_selections';
export type { OptionsListSearchTechnique } from './suggestions_searching';
export type { OptionsListSortingType } from './suggestions_sorting';
export type {
OptionsListControlState,
OptionsListDisplaySettings,
OptionsListFailureResponse,
OptionsListRequest,
OptionsListResponse,
} from '../../../common/options_list/types';
export interface ControlsOptionsListService {
runOptionsListRequest: (
request: OptionsListRequest,
abortSignal: AbortSignal
) => Promise<OptionsListResponse>;
clearOptionsListCache: () => void;
optionsListResponseWasFailure: (
response: OptionsListResponse
) => response is OptionsListFailureResponse;
getAllowExpensiveQueries: () => Promise<boolean>;
}
OptionsListSuccessResponse,
OptionsListSuggestions,
} from './types';

View file

@ -1,71 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { OptionsListComponentState } from '../../public/options_list/types';
import { ControlFactory, ControlOutput } from '../../public/types';
import { OptionsListEmbeddableInput } from './types';
import * as optionsListStateModule from '../../public/options_list/options_list_reducers';
import { OptionsListEmbeddable, OptionsListEmbeddableFactory } from '../../public/options_list';
const mockOptionsListComponentState = {
searchString: { value: '', valid: true },
field: undefined,
totalCardinality: 0,
availableOptions: [
{ value: 'woof', docCount: 100 },
{ value: 'bark', docCount: 75 },
{ value: 'meow', docCount: 50 },
{ value: 'quack', docCount: 25 },
{ value: 'moo', docCount: 5 },
],
invalidSelections: [],
allowExpensiveQueries: true,
popoverOpen: false,
validSelections: [],
} as OptionsListComponentState;
export const mockOptionsListEmbeddableInput = {
id: 'sample options list',
fieldName: 'sample field',
dataViewId: 'sample id',
selectedOptions: [],
runPastTimeout: false,
singleSelect: false,
exclude: false,
} as OptionsListEmbeddableInput;
const mockOptionsListOutput = {
loading: false,
} as ControlOutput;
export const mockOptionsListEmbeddable = async (partialState?: {
explicitInput?: Partial<OptionsListEmbeddableInput>;
componentState?: Partial<OptionsListComponentState>;
}) => {
const optionsListFactoryStub = new OptionsListEmbeddableFactory();
const optionsListControlFactory = optionsListFactoryStub as unknown as ControlFactory;
optionsListControlFactory.getDefaultInput = () => ({});
// initial component state can be provided by overriding the defaults.
const initialComponentState = {
...mockOptionsListComponentState,
...partialState?.componentState,
};
jest
.spyOn(optionsListStateModule, 'getDefaultComponentState')
.mockImplementation(() => initialComponentState);
const mockEmbeddable = (await optionsListControlFactory.create({
...mockOptionsListEmbeddableInput,
...partialState?.explicitInput,
})) as OptionsListEmbeddable;
mockEmbeddable.getOutput = jest.fn().mockReturnValue(mockOptionsListOutput);
return mockEmbeddable;
};

View file

@ -10,28 +10,45 @@
import { DataView, FieldSpec, RuntimeFieldSpec } from '@kbn/data-views-plugin/common';
import type { AggregateQuery, BoolQuery, Filter, Query, TimeRange } from '@kbn/es-query';
import type { DataControlInput } from '../types';
import { OptionsListSelection } from './options_list_selections';
import { OptionsListSortingType } from './suggestions_sorting';
import { DefaultDataControlState } from '../types';
import { OptionsListSearchTechnique } from './suggestions_searching';
import type { OptionsListSortingType } from './suggestions_sorting';
export const OPTIONS_LIST_CONTROL = 'optionsListControl'; // TODO: Replace with OPTIONS_LIST_CONTROL_TYPE
export interface OptionsListEmbeddableInput extends DataControlInput {
/**
* ----------------------------------------------------------------
* Options list state types
* ----------------------------------------------------------------
*/
export interface OptionsListDisplaySettings {
placeholder?: string;
hideActionBar?: boolean;
hideExclude?: boolean;
hideExists?: boolean;
hideSort?: boolean;
}
export interface OptionsListControlState
extends DefaultDataControlState,
OptionsListDisplaySettings {
searchTechnique?: OptionsListSearchTechnique;
sort?: OptionsListSortingType;
selectedOptions?: OptionsListSelection[];
existsSelected?: boolean;
runPastTimeout?: boolean;
singleSelect?: boolean;
hideActionBar?: boolean;
hideExclude?: boolean;
hideExists?: boolean;
placeholder?: string;
hideSort?: boolean;
exclude?: boolean;
}
/**
* ----------------------------------------------------------------
* Options list server request + response types
* ----------------------------------------------------------------
*/
export type OptionsListSuggestions = Array<{ value: OptionsListSelection; docCount?: number }>;
/**
@ -77,7 +94,7 @@ export type OptionsListRequest = Omit<
*/
export interface OptionsListRequestBody
extends Pick<
OptionsListEmbeddableInput,
OptionsListControlState,
'fieldName' | 'searchTechnique' | 'sort' | 'selectedOptions'
> {
runtimeFieldMap?: Record<string, RuntimeFieldSpec>;

View file

@ -1,58 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { RangeSliderEmbeddableInput } from '..';
import { RangeSliderEmbeddable, RangeSliderEmbeddableFactory } from '../../public/range_slider';
import * as rangeSliderStateModule from '../../public/range_slider/range_slider_reducers';
import { RangeSliderComponentState } from '../../public/range_slider/types';
import { ControlFactory, ControlOutput } from '../../public/types';
export const mockRangeSliderEmbeddableInput = {
id: 'sample options list',
fieldName: 'sample field',
dataViewId: 'sample id',
value: ['0', '10'],
} as RangeSliderEmbeddableInput;
const mockRangeSliderComponentState = {
field: { name: 'bytes', type: 'number', aggregatable: true },
min: undefined,
max: undefined,
error: undefined,
isInvalid: false,
} as RangeSliderComponentState;
const mockRangeSliderOutput = {
loading: false,
} as ControlOutput;
export const mockRangeSliderEmbeddable = async (partialState?: {
explicitInput?: Partial<RangeSliderEmbeddableInput>;
componentState?: Partial<RangeSliderEmbeddableInput>;
}) => {
const rangeSliderFactoryStub = new RangeSliderEmbeddableFactory();
const rangeSliderControlFactory = rangeSliderFactoryStub as unknown as ControlFactory;
rangeSliderControlFactory.getDefaultInput = () => ({});
// initial component state can be provided by overriding the defaults.
const initialComponentState = {
...mockRangeSliderComponentState,
...partialState?.componentState,
};
jest
.spyOn(rangeSliderStateModule, 'getDefaultComponentState')
.mockImplementation(() => initialComponentState);
const mockEmbeddable = (await rangeSliderControlFactory.create({
...mockRangeSliderEmbeddableInput,
...partialState?.explicitInput,
})) as RangeSliderEmbeddable;
mockEmbeddable.getOutput = jest.fn().mockReturnValue(mockRangeSliderOutput);
return mockEmbeddable;
};

View file

@ -1,21 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { DataControlInput } from '../types';
export const RANGE_SLIDER_CONTROL = 'rangeSliderControl';
export type RangeValue = [string, string];
export interface RangeSliderEmbeddableInput extends DataControlInput {
value?: RangeValue;
step?: number;
}
export type RangeSliderInputWithType = Partial<RangeSliderEmbeddableInput> & { type: string };

View file

@ -1,19 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { ControlInput } from '../types';
export const TIME_SLIDER_CONTROL = 'timeSlider';
export interface TimeSliderControlEmbeddableInput extends ControlInput {
isAnchored?: boolean;
// Encode value as percentage of time range to support relative time ranges.
timesliceStartAsPercentageOfTimeRange?: number;
timesliceEndAsPercentageOfTimeRange?: number;
}

View file

@ -7,9 +7,6 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { Filter, Query, TimeRange } from '@kbn/es-query';
import { EmbeddableInput } from '@kbn/embeddable-plugin/common/types';
export type ControlWidth = 'small' | 'medium' | 'large';
export type ControlStyle = 'twoLine' | 'oneLine';
@ -22,21 +19,19 @@ export interface ParentIgnoreSettings {
ignoreValidations?: boolean;
}
export type ControlInput = EmbeddableInput & {
query?: Query;
filters?: Filter[];
timeRange?: TimeRange;
timeslice?: TimeSlice;
controlStyle?: ControlStyle;
ignoreParentSettings?: ParentIgnoreSettings;
};
export interface DefaultControlState {
grow?: boolean;
width?: ControlWidth;
}
export type DataControlInput = ControlInput & {
fieldName: string;
export interface SerializedControlState<ControlStateType extends object = object>
extends DefaultControlState {
type: string;
explicitInput: { id: string } & ControlStateType;
}
export interface DefaultDataControlState extends DefaultControlState {
dataViewId: string;
};
export type ControlInputTransform = (
newState: Partial<ControlInput>,
controlType: string
) => Partial<ControlInput>;
fieldName: string;
title?: string; // custom control label
}

View file

@ -9,7 +9,7 @@
// Start the services with stubs
import { pluginServices } from './public/services';
import { registry } from './public/services/plugin_services.story';
import { registry } from './public/services/plugin_services.stub';
registry.start({});
pluginServices.setRegistry(registry);

View file

@ -1,247 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { EuiFlexGroup, EuiFlexItem, EuiSwitch, EuiTextAlign } from '@elastic/eui';
import React, { useEffect, useMemo, useState, useCallback, FC } from 'react';
import useEffectOnce from 'react-use/lib/useEffectOnce';
import { v4 as uuidv4 } from 'uuid';
import {
getFlightOptionsAsync,
getFlightSearchOptions,
storybookFlightsDataView,
} from '@kbn/presentation-util-plugin/public/mocks';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { EmbeddablePersistableStateService } from '@kbn/embeddable-plugin/common';
import { ControlGroupContainerFactory, OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL } from '..';
import { decorators } from './decorators';
import { ControlsPanels } from '../control_group/types';
import { ControlGroupContainer } from '../control_group';
import { pluginServices, registry } from '../services/plugin_services.story';
import { injectStorybookDataView } from '../services/data_views/data_views.story';
import { replaceOptionsListMethod } from '../services/options_list/options_list.story';
import { populateStorybookControlFactories } from './storybook_control_factories';
import { replaceValueSuggestionMethod } from '../services/unified_search/unified_search.story';
import {
OptionsListResponse,
OptionsListRequest,
OptionsListSuggestions,
OptionsListEmbeddableInput,
} from '../../common/options_list/types';
import { RangeSliderEmbeddableInput } from '../range_slider';
export default {
title: 'Controls',
description: '',
decorators,
};
injectStorybookDataView(storybookFlightsDataView);
replaceValueSuggestionMethod(getFlightOptionsAsync);
const storybookStubOptionsListRequest = async (
request: OptionsListRequest,
abortSignal: AbortSignal
) =>
new Promise<OptionsListResponse>((r) =>
setTimeout(
() =>
r({
suggestions: getFlightSearchOptions(request.field.name, request.searchString).reduce(
(o, current, index) => {
return [...o, { value: current, docCount: index }];
},
[] as OptionsListSuggestions
),
totalCardinality: 100,
}),
120
)
);
replaceOptionsListMethod(storybookStubOptionsListRequest);
export const ControlGroupStoryComponent: FC<{
panels?: ControlsPanels;
edit?: boolean;
}> = ({ panels, edit }) => {
const embeddableRoot: React.RefObject<HTMLDivElement> = useMemo(() => React.createRef(), []);
const [embeddable, setEmbeddable] = useState<ControlGroupContainer>();
const [viewMode, setViewMode] = useState<ViewMode>(
edit === undefined || edit ? ViewMode.EDIT : ViewMode.VIEW
);
const handleToggleViewMode = useCallback(() => {
if (embeddable) {
const newViewMode =
embeddable.getInput().viewMode === ViewMode.EDIT ? ViewMode.VIEW : ViewMode.EDIT;
embeddable.updateInput({ viewMode: newViewMode });
}
}, [embeddable]);
pluginServices.setRegistry(registry.start({}));
populateStorybookControlFactories(pluginServices.getServices().controls);
useEffectOnce(() => {
(async () => {
const factory = new ControlGroupContainerFactory(
{} as unknown as EmbeddablePersistableStateService
);
const controlGroupContainerEmbeddable = await factory.create({
controlStyle: 'oneLine',
chainingSystem: 'NONE', // a chaining system doesn't make sense in storybook since the controls aren't backed by elasticsearch
panels: panels ?? {},
id: uuidv4(),
viewMode,
});
if (controlGroupContainerEmbeddable && embeddableRoot.current) {
controlGroupContainerEmbeddable.render(embeddableRoot.current);
}
setEmbeddable(controlGroupContainerEmbeddable);
})();
});
useEffect(() => {
if (embeddable) {
const subscription = embeddable.getInput$().subscribe((updatedInput) => {
if (updatedInput.viewMode) {
setViewMode(updatedInput.viewMode);
}
});
return () => subscription.unsubscribe();
}
}, [embeddable, setViewMode]);
return (
<>
<EuiFlexGroup>
<EuiFlexItem>
<EuiTextAlign textAlign="right">
<EuiSwitch checked={viewMode === 'edit'} label="Edit" onChange={handleToggleViewMode} />
</EuiTextAlign>
</EuiFlexItem>
</EuiFlexGroup>
<br />
<div ref={embeddableRoot} />
</>
);
};
export const EmptyControlGroupStory = () => <ControlGroupStoryComponent edit={false} />;
export const ConfiguredControlGroupStory = () => (
<ControlGroupStoryComponent
panels={{
optionsList1: {
type: OPTIONS_LIST_CONTROL,
order: 1,
width: 'small',
grow: true,
explicitInput: {
title: 'Origin City',
id: 'optionsList1',
dataViewId: 'demoDataFlights',
fieldName: 'OriginCityName',
selectedOptions: ['Toronto'],
} as OptionsListEmbeddableInput,
},
optionsList2: {
type: OPTIONS_LIST_CONTROL,
order: 2,
width: 'medium',
grow: true,
explicitInput: {
title: 'Destination City',
id: 'optionsList2',
dataViewId: 'demoDataFlights',
fieldName: 'DestCityName',
selectedOptions: ['London'],
} as OptionsListEmbeddableInput,
},
optionsList3: {
type: 'TIME_SLIDER',
order: 3,
width: 'large',
grow: true,
explicitInput: {
title: 'Carrier',
id: 'optionsList3',
dataViewId: 'demoDataFlights',
fieldName: 'Carrier',
} as OptionsListEmbeddableInput,
},
rangeSlider1: {
type: RANGE_SLIDER_CONTROL,
order: 4,
width: 'medium',
grow: true,
explicitInput: {
id: 'rangeSlider1',
title: 'Average ticket price',
dataViewId: 'demoDataFlights',
fieldName: 'AvgTicketPrice',
value: ['4', '12'],
step: 2,
} as RangeSliderEmbeddableInput,
},
}}
/>
);
export const RangeSliderControlGroupStory = () => (
<ControlGroupStoryComponent
panels={{
rangeSlider1: {
type: RANGE_SLIDER_CONTROL,
order: 1,
width: 'medium',
grow: true,
explicitInput: {
id: 'rangeSlider1',
title: 'Average ticket price',
dataViewId: 'demoDataFlights',
fieldName: 'AvgTicketPrice',
value: ['4', '12'],
step: 2,
} as RangeSliderEmbeddableInput,
},
rangeSlider2: {
type: RANGE_SLIDER_CONTROL,
order: 2,
width: 'medium',
grow: true,
explicitInput: {
id: 'rangeSlider2',
title: 'Total distance in miles',
dataViewId: 'demoDataFlights',
fieldName: 'DistanceMiles',
value: ['0', '100'],
step: 10,
} as RangeSliderEmbeddableInput,
},
rangeSlider3: {
type: RANGE_SLIDER_CONTROL,
order: 3,
width: 'medium',
grow: true,
explicitInput: {
id: 'rangeSlider3',
title: 'Flight duration in hour',
dataViewId: 'demoDataFlight',
fieldName: 'FlightTimeHour',
value: ['30', '600'],
step: 30,
} as RangeSliderEmbeddableInput,
},
}}
/>
);

View file

@ -1,50 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { Story } from '@storybook/react';
const bar = '#c5ced8';
const panel = '#ffff';
const background = '#FAFBFD';
const minHeight = 60;
const panelStyle = {
height: 165,
width: 400,
background: panel,
};
const kqlBarStyle = { background: bar, padding: 16, minHeight, fontStyle: 'italic' };
const layout = (OptionStory: Story) => (
<EuiFlexGroup style={{ background }} direction="column">
<EuiFlexItem style={kqlBarStyle}>KQL Bar</EuiFlexItem>
<EuiFlexItem>
<OptionStory />
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup>
<EuiFlexItem style={panelStyle} />
<EuiFlexItem style={panelStyle} />
<EuiFlexItem style={panelStyle} />
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup>
<EuiFlexItem style={panelStyle} />
<EuiFlexItem style={panelStyle} />
<EuiFlexItem style={panelStyle} />
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
);
export const decorators = [layout];

View file

@ -1,35 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { OptionsListEmbeddableFactory } from '../options_list';
import { RangeSliderEmbeddableFactory } from '../range_slider';
import { TimeSliderEmbeddableFactory } from '../time_slider';
import { ControlsServiceType } from '../services/controls/types';
import { ControlFactory } from '../types';
export const populateStorybookControlFactories = (controlsServiceStub: ControlsServiceType) => {
const optionsListFactoryStub = new OptionsListEmbeddableFactory();
// cast to unknown because the stub cannot use the embeddable start contract to transform the EmbeddableFactoryDefinition into an EmbeddableFactory
const optionsListControlFactory = optionsListFactoryStub as unknown as ControlFactory;
optionsListControlFactory.getDefaultInput = () => ({});
controlsServiceStub.registerControlType(optionsListControlFactory);
const rangeSliderFactoryStub = new RangeSliderEmbeddableFactory();
// cast to unknown because the stub cannot use the embeddable start contract to transform the EmbeddableFactoryDefinition into an EmbeddableFactory
const rangeSliderControlFactory = rangeSliderFactoryStub as unknown as ControlFactory;
rangeSliderControlFactory.getDefaultInput = () => ({});
controlsServiceStub.registerControlType(rangeSliderControlFactory);
const timesliderFactoryStub = new TimeSliderEmbeddableFactory();
const timeSliderControlFactory = timesliderFactoryStub as unknown as ControlFactory;
timeSliderControlFactory.getDefaultInput = () => ({});
controlsServiceStub.registerControlType(timeSliderControlFactory);
};

View file

@ -10,24 +10,27 @@
import React, { SyntheticEvent } from 'react';
import { EuiButtonIcon, EuiToolTip } from '@elastic/eui';
import { apiIsPresentationContainer, PresentationContainer } from '@kbn/presentation-containers';
import { i18n } from '@kbn/i18n';
import {
apiIsPresentationContainer,
type PresentationContainer,
} from '@kbn/presentation-containers';
import {
apiCanAccessViewMode,
apiHasParentApi,
apiHasType,
apiHasUniqueId,
apiIsOfType,
EmbeddableApiContext,
HasParentApi,
HasType,
HasUniqueId,
type EmbeddableApiContext,
type HasParentApi,
type HasType,
type HasUniqueId,
} from '@kbn/presentation-publishing';
import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
import { type Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
import { ACTION_CLEAR_CONTROL } from '.';
import { CanClearSelections, isClearableControl } from '../../types';
import { ControlGroupStrings } from '../control_group_strings';
import { CONTROL_GROUP_TYPE } from '../types';
import { CONTROL_GROUP_TYPE } from '..';
import { isClearableControl, type CanClearSelections } from '../types';
export type ClearControlActionApi = HasType &
HasUniqueId &
@ -73,7 +76,9 @@ export class ClearControlAction implements Action<EmbeddableApiContext> {
public getDisplayName({ embeddable }: EmbeddableApiContext) {
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
return ControlGroupStrings.floatingActions.getClearButtonTitle();
return i18n.translate('controls.controlGroup.floatingActions.clearTitle', {
defaultMessage: 'Clear',
});
}
public getIconType({ embeddable }: EmbeddableApiContext) {

View file

@ -0,0 +1,94 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { BehaviorSubject } from 'rxjs';
import { coreMock } from '@kbn/core/public/mocks';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import { ViewMode } from '@kbn/presentation-publishing';
import { getOptionsListControlFactory } from '../react_controls/controls/data_controls/options_list_control/get_options_list_control_factory';
import { OptionsListControlApi } from '../react_controls/controls/data_controls/options_list_control/types';
import {
getMockedBuildApi,
getMockedControlGroupApi,
} from '../react_controls/controls/mocks/control_mocks';
import { pluginServices } from '../services';
import { DeleteControlAction } from './delete_control_action';
const mockDataViews = dataViewPluginMocks.createStartContract();
const mockCore = coreMock.createStart();
const dashboardApi = {
viewMode: new BehaviorSubject<ViewMode>('view'),
};
const controlGroupApi = getMockedControlGroupApi(dashboardApi, {
removePanel: jest.fn(),
replacePanel: jest.fn(),
addNewPanel: jest.fn(),
children$: new BehaviorSubject({}),
});
let controlApi: OptionsListControlApi;
beforeAll(async () => {
const controlFactory = getOptionsListControlFactory({
core: mockCore,
data: dataPluginMock.createStartContract(),
dataViews: mockDataViews,
});
const uuid = 'testControl';
const control = await controlFactory.buildControl(
{
dataViewId: 'test-data-view',
title: 'test',
fieldName: 'test-field',
width: 'medium',
grow: false,
},
getMockedBuildApi(uuid, controlFactory, controlGroupApi),
uuid,
controlGroupApi
);
controlApi = control.api;
});
test('Execute throws an error when called with an embeddable not in a parent', async () => {
const deleteControlAction = new DeleteControlAction();
const { parentApi, ...rest } = controlApi;
await expect(async () => {
await deleteControlAction.execute({ embeddable: rest });
}).rejects.toThrow(Error);
});
describe('Execute should open a confirm modal', () => {
test('Canceling modal will keep control', async () => {
const spyOn = jest.fn().mockResolvedValue(false);
pluginServices.getServices().overlays.openConfirm = spyOn;
const deleteControlAction = new DeleteControlAction();
await deleteControlAction.execute({ embeddable: controlApi });
expect(spyOn).toHaveBeenCalled();
expect(controlGroupApi.removePanel).not.toHaveBeenCalled();
});
test('Confirming modal will delete control', async () => {
const spyOn = jest.fn().mockResolvedValue(true);
pluginServices.getServices().overlays.openConfirm = spyOn;
const deleteControlAction = new DeleteControlAction();
await deleteControlAction.execute({ embeddable: controlApi });
expect(spyOn).toHaveBeenCalled();
expect(controlGroupApi.removePanel).toHaveBeenCalledTimes(1);
});
});

View file

@ -11,26 +11,29 @@ import React from 'react';
import { EuiButtonIcon, EuiToolTip } from '@elastic/eui';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
import { apiIsPresentationContainer, PresentationContainer } from '@kbn/presentation-containers';
import { i18n } from '@kbn/i18n';
import {
apiIsPresentationContainer,
type PresentationContainer,
} from '@kbn/presentation-containers';
import {
apiCanAccessViewMode,
apiHasParentApi,
apiHasType,
apiHasUniqueId,
apiIsOfType,
EmbeddableApiContext,
getInheritedViewMode,
HasParentApi,
HasType,
HasUniqueId,
PublishesViewMode,
type EmbeddableApiContext,
type HasParentApi,
type HasType,
type HasUniqueId,
type PublishesViewMode,
} from '@kbn/presentation-publishing';
import { IncompatibleActionError, type Action } from '@kbn/ui-actions-plugin/public';
import { ACTION_DELETE_CONTROL } from '.';
import { pluginServices } from '../../services';
import { ControlGroupStrings } from '../control_group_strings';
import { CONTROL_GROUP_TYPE } from '../types';
import { CONTROL_GROUP_TYPE } from '..';
import { pluginServices } from '../services';
export type DeleteControlActionApi = HasType &
HasUniqueId &
@ -77,7 +80,9 @@ export class DeleteControlAction implements Action<EmbeddableApiContext> {
public getDisplayName({ embeddable }: EmbeddableApiContext) {
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
return ControlGroupStrings.floatingActions.getRemoveButtonTitle();
return i18n.translate('controls.controlGroup.floatingActions.removeTitle', {
defaultMessage: 'Delete',
});
}
public getIconType({ embeddable }: EmbeddableApiContext) {
@ -94,12 +99,23 @@ export class DeleteControlAction implements Action<EmbeddableApiContext> {
public async execute({ embeddable }: EmbeddableApiContext) {
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
this.openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), {
confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(),
cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(),
title: ControlGroupStrings.management.deleteControls.getDeleteTitle(),
buttonColor: 'danger',
}).then((confirmed) => {
this.openConfirm(
i18n.translate('controls.controlGroup.management.delete.sub', {
defaultMessage: 'Controls are not recoverable once removed.',
}),
{
confirmButtonText: i18n.translate('controls.controlGroup.management.delete.confirm', {
defaultMessage: 'Delete',
}),
cancelButtonText: i18n.translate('controls.controlGroup.management.delete.cancel', {
defaultMessage: 'Cancel',
}),
title: i18n.translate('controls.controlGroup.management.delete.deleteTitle', {
defaultMessage: 'Delete control?',
}),
buttonColor: 'danger',
}
).then((confirmed) => {
if (confirmed) {
embeddable.parentApi.removePanel(embeddable.uuid);
}

View file

@ -0,0 +1,127 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { BehaviorSubject } from 'rxjs';
import { coreMock } from '@kbn/core/public/mocks';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import dateMath from '@kbn/datemath';
import type { TimeRange } from '@kbn/es-query';
import type { ViewMode } from '@kbn/presentation-publishing';
import { getOptionsListControlFactory } from '../react_controls/controls/data_controls/options_list_control/get_options_list_control_factory';
import type { OptionsListControlApi } from '../react_controls/controls/data_controls/options_list_control/types';
import {
getMockedBuildApi,
getMockedControlGroupApi,
} from '../react_controls/controls/mocks/control_mocks';
import { getTimesliderControlFactory } from '../react_controls/controls/timeslider_control/get_timeslider_control_factory';
import { EditControlAction } from './edit_control_action';
const mockDataViews = dataViewPluginMocks.createStartContract();
const mockCore = coreMock.createStart();
const dataStartServiceMock = dataPluginMock.createStartContract();
dataStartServiceMock.query.timefilter.timefilter.calculateBounds = (timeRange: TimeRange) => {
const now = new Date();
return {
min: dateMath.parse(timeRange.from, { forceNow: now }),
max: dateMath.parse(timeRange.to, { roundUp: true, forceNow: now }),
};
};
const dashboardApi = {
viewMode: new BehaviorSubject<ViewMode>('view'),
};
const controlGroupApi = getMockedControlGroupApi(dashboardApi, {
removePanel: jest.fn(),
replacePanel: jest.fn(),
addNewPanel: jest.fn(),
children$: new BehaviorSubject({}),
});
let optionsListApi: OptionsListControlApi;
beforeAll(async () => {
const controlFactory = getOptionsListControlFactory({
core: mockCore,
data: dataStartServiceMock,
dataViews: mockDataViews,
});
const optionsListUuid = 'optionsListControl';
const optionsListControl = await controlFactory.buildControl(
{
dataViewId: 'test-data-view',
title: 'test',
fieldName: 'test-field',
width: 'medium',
grow: false,
},
getMockedBuildApi(optionsListUuid, controlFactory, controlGroupApi),
optionsListUuid,
controlGroupApi
);
optionsListApi = optionsListControl.api;
});
describe('Incompatible embeddables', () => {
test('Action is incompatible with embeddables that are not editable', async () => {
const timeSliderFactory = getTimesliderControlFactory({
core: mockCore,
data: dataStartServiceMock,
});
const timeSliderUuid = 'timeSliderControl';
const timeSliderControl = await timeSliderFactory.buildControl(
{},
getMockedBuildApi(timeSliderUuid, timeSliderFactory, controlGroupApi),
timeSliderUuid,
controlGroupApi
);
const editControlAction = new EditControlAction();
expect(
await editControlAction.isCompatible({
embeddable: timeSliderControl,
})
).toBe(false);
});
test('Execute throws an error when called with an embeddable not in a parent', async () => {
const editControlAction = new EditControlAction();
const noParentApi = { ...optionsListApi, parentApi: undefined };
await expect(async () => {
await editControlAction.execute({ embeddable: noParentApi });
}).rejects.toThrow(Error);
});
});
describe('Compatible embeddables', () => {
beforeAll(() => {
dashboardApi.viewMode.next('edit');
});
test('Action is compatible with embeddables that are editable', async () => {
const editControlAction = new EditControlAction();
expect(
await editControlAction.isCompatible({
embeddable: optionsListApi,
})
).toBe(true);
});
test('Execute should call `onEdit` provided by embeddable', async () => {
const onEditSpy = jest.fn();
optionsListApi.onEdit = onEditSpy;
const editControlAction = new EditControlAction();
expect(onEditSpy).not.toHaveBeenCalled();
await editControlAction.execute({ embeddable: optionsListApi });
expect(onEditSpy).toHaveBeenCalledTimes(1);
});
});

View file

@ -12,9 +12,9 @@ import React from 'react';
import { EuiButtonIcon, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { EmbeddableApiContext, HasUniqueId } from '@kbn/presentation-publishing';
import { type Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
import { IncompatibleActionError, type Action } from '@kbn/ui-actions-plugin/public';
export const ACTION_EDIT_CONTROL = 'editDataControl';
import { ACTION_EDIT_CONTROL } from '.';
export class EditControlAction implements Action<EmbeddableApiContext> {
public readonly type = ACTION_EDIT_CONTROL;
@ -48,12 +48,12 @@ export class EditControlAction implements Action<EmbeddableApiContext> {
}
public async isCompatible({ embeddable }: EmbeddableApiContext) {
const { isCompatible } = await import('./compatibility_check');
const { isCompatible } = await import('./edit_control_action_compatibility_check');
return isCompatible(embeddable);
}
public async execute({ embeddable }: EmbeddableApiContext) {
const { compatibilityCheck } = await import('./compatibility_check');
const { compatibilityCheck } = await import('./edit_control_action_compatibility_check');
if (!compatibilityCheck(embeddable)) throw new IncompatibleActionError();
await embeddable.onEdit();
}

View file

@ -18,8 +18,8 @@ import {
hasEditCapabilities,
} from '@kbn/presentation-publishing';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { CONTROL_GROUP_TYPE } from '../../../../common';
import { DataControlApi } from '../../controls/data_controls/types';
import { CONTROL_GROUP_TYPE } from '../../common';
import { DataControlApi } from '../react_controls/controls/data_controls/types';
export const compatibilityCheck = (api: unknown): api is DataControlApi => {
return Boolean(

View file

@ -7,6 +7,6 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export const ACTION_EDIT_CONTROL = 'editLegacyEmbeddableControl';
export const ACTION_EDIT_CONTROL = 'editDataControl';
export const ACTION_CLEAR_CONTROL = 'clearControl';
export const ACTION_DELETE_CONTROL = 'deleteControl';

View file

@ -1,33 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiIconTip } from '@elastic/eui';
import { css } from '@emotion/react';
export const ControlSettingTooltipLabel = ({
label,
tooltip,
}: {
label: string;
tooltip: string;
}) => (
<EuiFlexGroup alignItems="center" gutterSize="xs" responsive={false}>
<EuiFlexItem grow={false}>{label}</EuiFlexItem>
<EuiFlexItem
grow={false}
css={css`
margin-top: 0px !important;
`}
>
<EuiIconTip content={tooltip} position="right" />
</EuiFlexItem>
</EuiFlexGroup>
);

View file

@ -1,91 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { ErrorEmbeddable } from '@kbn/embeddable-plugin/public';
import { OPTIONS_LIST_CONTROL } from '../../../common';
import { ControlOutput } from '../../types';
import { ControlGroupInput } from '../types';
import { pluginServices } from '../../services';
import { DeleteControlAction } from './delete_control_action';
import { OptionsListEmbeddableInput } from '../../options_list';
import { controlGroupInputBuilder } from '../external_api/control_group_input_builder';
import { ControlGroupContainer } from '../embeddable/control_group_container';
import { OptionsListEmbeddableFactory } from '../../options_list/embeddable/options_list_embeddable_factory';
import { OptionsListEmbeddable } from '../../options_list/embeddable/options_list_embeddable';
import { mockedReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public/mocks';
let container: ControlGroupContainer;
let embeddable: OptionsListEmbeddable;
beforeAll(async () => {
pluginServices.getServices().controls.getControlFactory = jest
.fn()
.mockImplementation((type: string) => {
if (type === OPTIONS_LIST_CONTROL) return new OptionsListEmbeddableFactory();
});
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
controlGroupInputBuilder.addOptionsListControl(controlGroupInput, {
dataViewId: 'test-data-view',
title: 'test',
fieldName: 'test-field',
width: 'medium',
grow: false,
});
container = new ControlGroupContainer(mockedReduxEmbeddablePackage, controlGroupInput);
await container.untilInitialized();
embeddable = container.getChild(container.getChildIds()[0]);
expect(embeddable.type).toBe(OPTIONS_LIST_CONTROL);
});
test('Action is incompatible with Error Embeddables', async () => {
const deleteControlAction = new DeleteControlAction();
const errorEmbeddable = new ErrorEmbeddable('Wow what an awful error', { id: ' 404' });
expect(await deleteControlAction.isCompatible({ embeddable: errorEmbeddable as any })).toBe(
false
);
});
test('Execute throws an error when called with an embeddable not in a parent', async () => {
const deleteControlAction = new DeleteControlAction();
const optionsListEmbeddable = new OptionsListEmbeddable(
mockedReduxEmbeddablePackage,
{} as OptionsListEmbeddableInput,
{} as ControlOutput
);
await expect(async () => {
await deleteControlAction.execute({ embeddable: optionsListEmbeddable });
}).rejects.toThrow(Error);
});
describe('Execute should open a confirm modal', () => {
test('Canceling modal will keep control', async () => {
const spyOn = jest.fn().mockResolvedValue(false);
pluginServices.getServices().overlays.openConfirm = spyOn;
const deleteControlAction = new DeleteControlAction();
await deleteControlAction.execute({ embeddable });
expect(spyOn).toHaveBeenCalled();
expect(container.getPanelCount()).toBe(1);
});
test('Confirming modal will delete control', async () => {
const spyOn = jest.fn().mockResolvedValue(true);
pluginServices.getServices().overlays.openConfirm = spyOn;
const deleteControlAction = new DeleteControlAction();
await deleteControlAction.execute({ embeddable });
expect(spyOn).toHaveBeenCalled();
expect(container.getPanelCount()).toBe(0);
});
});

View file

@ -1,106 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { ErrorEmbeddable } from '@kbn/embeddable-plugin/public';
import { OPTIONS_LIST_CONTROL } from '../../../common';
import { ControlOutput } from '../../types';
import { ControlGroupInput } from '../types';
import { pluginServices } from '../../services';
import { EditLegacyEmbeddableControlAction } from './edit_control_action';
import { DeleteControlAction } from './delete_control_action';
import { TimeSliderEmbeddableFactory } from '../../time_slider';
import { OptionsListEmbeddableFactory, OptionsListEmbeddableInput } from '../../options_list';
import { ControlGroupContainer } from '../embeddable/control_group_container';
import { OptionsListEmbeddable } from '../../options_list/embeddable/options_list_embeddable';
import { mockedReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public/mocks';
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
const deleteControlAction = new DeleteControlAction();
test('Action is incompatible with Error Embeddables', async () => {
const editControlAction = new EditLegacyEmbeddableControlAction(deleteControlAction);
const errorEmbeddable = new ErrorEmbeddable('Wow what an awful error', { id: ' 404' });
expect(await editControlAction.isCompatible({ embeddable: errorEmbeddable as any })).toBe(false);
});
test('Action is incompatible with embeddables that are not editable', async () => {
const mockEmbeddableFactory = new TimeSliderEmbeddableFactory();
const mockGetFactory = jest.fn().mockReturnValue(mockEmbeddableFactory);
pluginServices.getServices().controls.getControlFactory = mockGetFactory;
pluginServices.getServices().embeddable.getEmbeddableFactory = mockGetFactory;
const editControlAction = new EditLegacyEmbeddableControlAction(deleteControlAction);
const emptyContainer = new ControlGroupContainer(mockedReduxEmbeddablePackage, controlGroupInput);
await emptyContainer.untilInitialized();
await emptyContainer.addTimeSliderControl();
expect(
await editControlAction.isCompatible({
embeddable: emptyContainer.getChild(emptyContainer.getChildIds()[0]) as any,
})
).toBe(false);
});
test('Action is compatible with embeddables that are editable', async () => {
const mockEmbeddableFactory = new OptionsListEmbeddableFactory();
const mockGetFactory = jest.fn().mockReturnValue(mockEmbeddableFactory);
pluginServices.getServices().controls.getControlFactory = mockGetFactory;
pluginServices.getServices().embeddable.getEmbeddableFactory = mockGetFactory;
const editControlAction = new EditLegacyEmbeddableControlAction(deleteControlAction);
const emptyContainer = new ControlGroupContainer(mockedReduxEmbeddablePackage, controlGroupInput);
await emptyContainer.untilInitialized();
const control = await emptyContainer.addOptionsListControl({
dataViewId: 'test-data-view',
title: 'test',
fieldName: 'test-field',
width: 'medium',
grow: false,
});
expect(emptyContainer.getInput().panels[control.getInput().id].type).toBe(OPTIONS_LIST_CONTROL);
expect(
await editControlAction.isCompatible({
embeddable: emptyContainer.getChild(emptyContainer.getChildIds()[0]) as any,
})
).toBe(true);
});
test('Execute throws an error when called with an embeddable not in a parent', async () => {
const editControlAction = new EditLegacyEmbeddableControlAction(deleteControlAction);
const optionsListEmbeddable = new OptionsListEmbeddable(
mockedReduxEmbeddablePackage,
{} as OptionsListEmbeddableInput,
{} as ControlOutput
);
await expect(async () => {
await editControlAction.execute({ embeddable: optionsListEmbeddable });
}).rejects.toThrow(Error);
});
test('Execute should open a flyout', async () => {
const spyOn = jest.fn().mockResolvedValue(undefined);
pluginServices.getServices().overlays.openFlyout = spyOn;
const emptyContainer = new ControlGroupContainer(mockedReduxEmbeddablePackage, controlGroupInput);
await emptyContainer.untilInitialized();
const control = (await emptyContainer.addOptionsListControl({
dataViewId: 'test-data-view',
title: 'test',
fieldName: 'test-field',
width: 'medium',
grow: false,
})) as OptionsListEmbeddable;
expect(emptyContainer.getInput().panels[control.getInput().id].type).toBe(OPTIONS_LIST_CONTROL);
const editControlAction = new EditLegacyEmbeddableControlAction(deleteControlAction);
await editControlAction.execute({ embeddable: control });
expect(spyOn).toHaveBeenCalled();
});

View file

@ -1,124 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { EuiButtonIcon, EuiToolTip } from '@elastic/eui';
import { isErrorEmbeddable, ViewMode } from '@kbn/embeddable-plugin/public';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
import { ACTION_EDIT_CONTROL, ControlGroupContainer } from '..';
import { pluginServices } from '../../services';
import { ControlEmbeddable, DataControlInput } from '../../types';
import { ControlGroupStrings } from '../control_group_strings';
import { ControlGroupContainerContext, setFlyoutRef } from '../embeddable/control_group_container';
import { isControlGroup } from '../embeddable/control_group_helpers';
import { DeleteControlAction } from './delete_control_action';
import { EditControlFlyout } from './edit_control_flyout';
export interface EditControlActionContext {
embeddable: ControlEmbeddable<DataControlInput>;
}
export class EditLegacyEmbeddableControlAction implements Action<EditControlActionContext> {
public readonly type = ACTION_EDIT_CONTROL;
public readonly id = ACTION_EDIT_CONTROL;
public order = 2;
private getEmbeddableFactory;
private openFlyout;
private theme;
private i18n;
constructor(private deleteControlAction: DeleteControlAction) {
({
embeddable: { getEmbeddableFactory: this.getEmbeddableFactory },
overlays: { openFlyout: this.openFlyout },
core: { theme: this.theme, i18n: this.i18n },
} = pluginServices.getServices());
}
public readonly MenuItem = ({ context }: { context: EditControlActionContext }) => {
const { embeddable } = context;
return (
<EuiToolTip content={this.getDisplayName(context)}>
<EuiButtonIcon
data-test-subj={`control-action-${embeddable.id}-edit`}
aria-label={this.getDisplayName(context)}
iconType={this.getIconType(context)}
onClick={() => this.execute(context)}
color="text"
/>
</EuiToolTip>
);
};
public getDisplayName({ embeddable }: EditControlActionContext) {
if (!embeddable.parent || !isControlGroup(embeddable.parent)) {
throw new IncompatibleActionError();
}
return ControlGroupStrings.floatingActions.getEditButtonTitle();
}
public getIconType({ embeddable }: EditControlActionContext) {
if (!embeddable.parent || !isControlGroup(embeddable.parent)) {
throw new IncompatibleActionError();
}
return 'pencil';
}
public async isCompatible({ embeddable }: EditControlActionContext) {
if (isErrorEmbeddable(embeddable)) return false;
const controlGroup = embeddable.parent;
const factory = this.getEmbeddableFactory(embeddable.type);
return Boolean(
controlGroup &&
isControlGroup(controlGroup) &&
controlGroup.getInput().viewMode === ViewMode.EDIT &&
factory &&
(await factory.isEditable())
);
}
public async execute({ embeddable }: EditControlActionContext) {
if (!embeddable.parent || !isControlGroup(embeddable.parent)) {
throw new IncompatibleActionError();
}
const controlGroup = embeddable.parent as ControlGroupContainer;
const flyoutInstance = this.openFlyout(
toMountPoint(
<ControlGroupContainerContext.Provider value={controlGroup}>
<EditControlFlyout
embeddable={embeddable}
removeControl={() => this.deleteControlAction.execute({ embeddable })}
closeFlyout={() => {
setFlyoutRef(undefined);
flyoutInstance.close();
}}
/>
</ControlGroupContainerContext.Provider>,
{ theme: this.theme, i18n: this.i18n }
),
{
'aria-label': ControlGroupStrings.manageControl.getFlyoutEditTitle(),
outsideClickCloses: false,
onClose: (flyout) => {
setFlyoutRef(undefined);
flyout.close();
},
ownFocus: true,
}
);
setFlyoutRef(flyoutInstance);
}
}

View file

@ -1,118 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { isEqual } from 'lodash';
import React from 'react';
import { EmbeddableFactoryNotFoundError } from '@kbn/embeddable-plugin/public';
import {
DataControlInput,
ControlEmbeddable,
IEditableControlFactory,
DataControlEditorChanges,
} from '../../types';
import { pluginServices } from '../../services';
import { ControlGroupStrings } from '../control_group_strings';
import { useControlGroupContainer } from '../embeddable/control_group_container';
import { ControlEditor } from '../editor/control_editor';
export const EditControlFlyout = ({
embeddable,
closeFlyout,
removeControl,
}: {
embeddable: ControlEmbeddable<DataControlInput>;
closeFlyout: () => void;
removeControl: () => void;
}) => {
// Controls Services Context
const {
overlays: { openConfirm },
controls: { getControlFactory },
} = pluginServices.getServices();
// Redux embeddable container Context
const controlGroup = useControlGroupContainer();
// current state
const panels = controlGroup.select((state) => state.explicitInput.panels);
const panel = panels[embeddable.id];
const onCancel = (changes: DataControlEditorChanges) => {
if (
isEqual(panel.explicitInput, {
...panel.explicitInput,
...changes.input,
}) &&
changes.grow === panel.grow &&
changes.width === panel.width
) {
closeFlyout();
return;
}
openConfirm(ControlGroupStrings.management.discardChanges.getSubtitle(), {
confirmButtonText: ControlGroupStrings.management.discardChanges.getConfirm(),
cancelButtonText: ControlGroupStrings.management.discardChanges.getCancel(),
title: ControlGroupStrings.management.discardChanges.getTitle(),
buttonColor: 'danger',
}).then((confirmed) => {
if (confirmed) {
closeFlyout();
}
});
};
const onSave = async (changes: DataControlEditorChanges, type?: string) => {
if (!type) {
closeFlyout();
return;
}
const factory = getControlFactory(type) as IEditableControlFactory;
if (!factory) throw new EmbeddableFactoryNotFoundError(type);
let inputToReturn = changes.input;
if (factory.presaveTransformFunction) {
inputToReturn = factory.presaveTransformFunction(inputToReturn, embeddable);
}
if (changes.width && changes.width !== panel.width)
controlGroup.dispatch.setControlWidth({
width: changes.width,
embeddableId: embeddable.id,
});
if (changes.grow !== undefined && changes.grow !== panel.grow) {
controlGroup.dispatch.setControlGrow({
grow: changes.grow,
embeddableId: embeddable.id,
});
}
closeFlyout();
if (panel.type === type) {
controlGroup.updateInputForChild(embeddable.id, inputToReturn);
} else {
await controlGroup.replaceEmbeddable(embeddable.id, inputToReturn, type);
}
};
return (
<ControlEditor
isCreate={false}
width={panel.width}
grow={panel.grow}
embeddable={embeddable}
onCancel={onCancel}
setLastUsedDataViewId={(lastUsed) => controlGroup.setLastUsedDataViewId(lastUsed)}
onSave={onSave}
removeControl={() => {
closeFlyout();
removeControl();
}}
/>
);
};

View file

@ -1,55 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useState } from 'react';
import { EuiButtonEmpty, EuiPopover } from '@elastic/eui';
import { FormattedMessage, I18nProvider } from '@kbn/i18n-react';
import { Markdown } from '@kbn/shared-ux-markdown';
interface ControlErrorProps {
error: Error | string;
}
export const ControlError = ({ error }: ControlErrorProps) => {
const [isPopoverOpen, setPopoverOpen] = useState(false);
const errorMessage = error instanceof Error ? error.message : error;
const popoverButton = (
<EuiButtonEmpty
color="danger"
iconSize="m"
iconType="error"
data-test-subj="control-frame-error"
onClick={() => setPopoverOpen((open) => !open)}
className={'errorEmbeddableCompact__button'}
textProps={{ className: 'errorEmbeddableCompact__text' }}
>
<FormattedMessage
id="controls.frame.error.message"
defaultMessage="An error occurred. View more"
/>
</EuiButtonEmpty>
);
return (
<I18nProvider>
<EuiPopover
button={popoverButton}
isOpen={isPopoverOpen}
className="errorEmbeddableCompact__popover"
closePopover={() => setPopoverOpen(false)}
>
<Markdown data-test-subj="errorMessageMarkdown" readOnly>
{errorMessage}
</Markdown>
</EuiPopover>
</I18nProvider>
);
};

View file

@ -1,147 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import classNames from 'classnames';
import React, { useEffect, useMemo, useState } from 'react';
import {
EuiFormControlLayout,
EuiFormLabel,
EuiFormRow,
EuiLoadingChart,
EuiToolTip,
} from '@elastic/eui';
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 { ControlError } from './control_error_component';
export interface ControlFrameProps {
customPrepend?: JSX.Element;
enableActions?: boolean;
embeddableId: string;
embeddableType: string;
}
export const ControlFrame = ({
customPrepend,
enableActions,
embeddableId,
embeddableType,
}: ControlFrameProps) => {
const embeddableRoot: React.RefObject<HTMLDivElement> = useMemo(() => React.createRef(), []);
const controlGroup = useControlGroupContainer();
const controlStyle = controlGroupSelector((state) => state.explicitInput.controlStyle);
const viewMode = controlGroupSelector((state) => state.explicitInput.viewMode);
const disabledActions = controlGroupSelector((state) => state.explicitInput.disabledActions);
const embeddable = useChildEmbeddable({
untilEmbeddableLoaded: controlGroup.untilEmbeddableLoaded.bind(controlGroup),
embeddableType,
embeddableId,
});
const [title, setTitle] = useState<string>();
const usingTwoLineLayout = controlStyle === 'twoLine';
useEffect(() => {
let mounted = true;
if (embeddableRoot.current) {
embeddable?.render(embeddableRoot.current);
}
const inputSubscription = embeddable?.getInput$().subscribe((newInput) => {
if (mounted) setTitle(newInput.title);
});
return () => {
mounted = false;
inputSubscription?.unsubscribe();
};
}, [embeddable, embeddableRoot]);
const embeddableParentClassNames = classNames('controlFrame__control', {
'controlFrame--twoLine': controlStyle === 'twoLine',
'controlFrame--oneLine': controlStyle === 'oneLine',
});
function renderEmbeddablePrepend() {
if (typeof embeddable?.renderPrepend === 'function') {
return embeddable.renderPrepend();
}
return usingTwoLineLayout ? undefined : (
<EuiToolTip anchorClassName="controlFrame__labelToolTip" content={title}>
<EuiFormLabel className="controlFrame__formControlLayoutLabel" htmlFor={embeddableId}>
{title}
</EuiFormLabel>
</EuiToolTip>
);
}
const form = (
<EuiFormControlLayout
className={classNames('controlFrame__formControlLayout', {
'controlFrameFormControlLayout--twoLine': controlStyle === 'twoLine',
})}
fullWidth
compressed
prepend={
<>
{(embeddable && customPrepend) ?? null}
{renderEmbeddablePrepend()}
</>
}
>
{embeddable && (
<div
className={embeddableParentClassNames}
id={`controlFrame--${embeddableId}`}
ref={embeddableRoot}
>
{isErrorEmbeddable(embeddable) && <ControlError error={embeddable.error} />}
</div>
)}
{!embeddable && (
<div className={embeddableParentClassNames} id={`controlFrame--${embeddableId}`}>
<div className="controlFrame--controlLoading">
<EuiLoadingChart />
</div>
</div>
)}
</EuiFormControlLayout>
);
return (
<FloatingActions
className={classNames({
'controlFrameFloatingActions--twoLine': usingTwoLineLayout,
'controlFrameFloatingActions--oneLine': !usingTwoLineLayout,
})}
viewMode={viewMode}
api={embeddable}
disabledActions={disabledActions}
isEnabled={embeddable && enableActions}
>
<EuiFormRow
data-test-subj="control-frame-title"
fullWidth
label={usingTwoLineLayout ? title || '...' : undefined}
>
{form}
</EuiFormRow>
</FloatingActions>
);
};

View file

@ -1,203 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", 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 { 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';
import { OptionsListEmbeddableFactory } from '../../options_list';
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();
await userEvent.click(applyButton);
expect(controlGroupContainer.publishFilters).toBeCalledWith(unpublishedFilters);
});
test('ensure actions get rendered', async () => {
presentationUtilPluginServices.getServices().uiActions.getTriggerCompatibleActions = jest
.fn()
.mockImplementation(() => {
return [
{
isCompatible: jest.fn().mockResolvedValue(true),
id: 'testAction',
MenuItem: () => <div>test1</div>,
},
{
isCompatible: jest.fn().mockResolvedValue(true),
id: 'testAction2',
MenuItem: () => <div>test2</div>,
},
];
});
const { controlGroupComponent } = await mountComponent();
expect(
controlGroupComponent.queryByTestId('presentationUtil__floatingActions__control1')
).toBeInTheDocument();
expect(
controlGroupComponent.queryByTestId('presentationUtil__floatingActions__control2')
).toBeInTheDocument();
});
});

View file

@ -1,352 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import '../control_group.scss';
import classNames from 'classnames';
import React, { useEffect, useMemo, useState } from 'react';
import {
closestCenter,
DndContext,
DragEndEvent,
DragOverlay,
KeyboardSensor,
PointerSensor,
MeasuringStrategy,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
rectSortingStrategy,
SortableContext,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable';
import {
EuiButtonEmpty,
EuiButtonIcon,
EuiCheckbox,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiPanel,
EuiText,
EuiToolTip,
EuiTourStep,
} from '@elastic/eui';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { ControlGroupStrings } from '../control_group_strings';
import {
controlGroupSelector,
useControlGroupContainer,
} from '../embeddable/control_group_container';
import { ControlClone, SortableControl } from './control_group_sortable_item';
export const ControlGroup = () => {
const controlGroup = useControlGroupContainer();
// current state
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);
const isEditable = viewMode === ViewMode.EDIT;
const idsInOrder = useMemo(
() =>
Object.values(panels)
.sort((a, b) => (a.order > b.order ? 1 : -1))
.reduce((acc, panel) => {
acc.push(panel.explicitInput.id);
return acc;
}, [] as string[]),
[panels]
);
useEffect(() => {
/**
* 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(() => {
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 ||
!controlGroup.canShowInvalidSelectionsWarning() ||
!tourStepOpen ||
!controlWithInvalidSelectionsId ||
!panels[controlWithInvalidSelectionsId]
) {
return null;
}
const invalidControlType = panels[controlWithInvalidSelectionsId].type;
return (
<EuiTourStep
step={1}
stepsTotal={1}
minWidth={300}
maxWidth={300}
display="block"
isStepOpen={true}
repositionOnScroll
onFinish={() => {}}
panelPaddingSize="m"
anchorPosition="downCenter"
panelClassName="controlGroup--invalidSelectionsTour"
anchor={`#controlFrame--${controlWithInvalidSelectionsId}`}
title={
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<EuiIcon type="warning" color="warning" />
</EuiFlexItem>
<EuiFlexItem>{ControlGroupStrings.invalidControlWarning.getTourTitle()}</EuiFlexItem>
</EuiFlexGroup>
}
content={ControlGroupStrings.invalidControlWarning.getTourContent(invalidControlType)}
footerAction={[
<EuiCheckbox
checked={suppressTourChecked}
id={'controlGroup--suppressTourCheckbox'}
className="controlGroup--suppressTourCheckbox"
onChange={(e) => setSuppressTourChecked(e.target.checked)}
label={
<EuiText size="xs" className="controlGroup--suppressTourCheckboxLabel">
{ControlGroupStrings.invalidControlWarning.getSuppressTourLabel()}
</EuiText>
}
/>,
<EuiButtonEmpty
size="xs"
flush="right"
color="text"
onClick={() => {
setTourStepOpen(false);
if (suppressTourChecked) {
controlGroup.suppressInvalidSelectionsWarning();
}
}}
>
{ControlGroupStrings.invalidControlWarning.getDismissButton()}
</EuiButtonEmpty>,
]}
/>
);
}, [
panels,
controlGroup,
tourStepOpen,
renderTourStep,
suppressTourChecked,
controlWithInvalidSelectionsId,
]);
const [draggingId, setDraggingId] = useState<string | null>(null);
const draggingIndex = useMemo(
() => (draggingId ? idsInOrder.indexOf(draggingId) : -1),
[idsInOrder, draggingId]
);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
const onDragEnd = ({ over }: DragEndEvent) => {
if (over) {
const overIndex = idsInOrder.indexOf(`${over.id}`);
if (draggingIndex !== overIndex) {
const newIndex = overIndex;
controlGroup.dispatch.setControlOrders({
ids: arrayMove([...idsInOrder], draggingIndex, newIndex),
});
}
}
(document.activeElement as HTMLElement)?.blur();
setDraggingId(null);
};
const emptyState = !(idsInOrder && idsInOrder.length > 0);
// Empty, non-editable view is null
if (!isEditable && emptyState) {
return null;
}
let panelBg: 'transparent' | 'plain' | 'success' = 'transparent';
if (emptyState) panelBg = 'plain';
if (draggingId) panelBg = 'success';
return (
<>
{idsInOrder.length > 0 || showAddButton ? (
<EuiPanel
borderRadius="m"
color={panelBg}
paddingSize={emptyState ? 's' : 'none'}
data-test-subj="controls-group-wrapper"
className={classNames('controlsWrapper', {
'controlsWrapper--empty': emptyState,
'controlsWrapper--twoLine': controlStyle === 'twoLine',
})}
>
<EuiFlexGroup
wrap={false}
gutterSize="s"
direction="row"
responsive={false}
alignItems="stretch"
justifyContent="center"
data-test-subj="controls-group"
>
{tourStep}
<EuiFlexItem>
<DndContext
onDragStart={({ active }) => setDraggingId(`${active.id}`)}
onDragEnd={onDragEnd}
onDragCancel={() => setDraggingId(null)}
sensors={sensors}
measuring={{
droppable: {
strategy: MeasuringStrategy.Always,
},
}}
collisionDetection={closestCenter}
>
<SortableContext items={idsInOrder} strategy={rectSortingStrategy}>
<EuiFlexGroup
className={classNames('controlGroup', {
'controlGroup-isDragging': draggingId,
})}
alignItems="center"
gutterSize="s"
wrap={true}
>
{idsInOrder.map(
(controlId, index) =>
panels[controlId] && (
<SortableControl
isEditable={isEditable}
dragInfo={{ index, draggingIndex }}
embeddableId={controlId}
embeddableType={panels[controlId].type}
key={controlId}
/>
)
)}
</EuiFlexGroup>
</SortableContext>
<DragOverlay>
{draggingId ? <ControlClone draggingId={draggingId} /> : null}
</DragOverlay>
</DndContext>
</EuiFlexItem>
{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>
</EuiPanel>
) : (
<></>
)}
</>
);
};

View file

@ -1,155 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import classNames from 'classnames';
import { CSS } from '@dnd-kit/utilities';
import { useSortable } from '@dnd-kit/sortable';
import React, { forwardRef, HTMLAttributes } from 'react';
import { EuiFlexItem, EuiFormLabel, EuiIcon, EuiFlexGroup } from '@elastic/eui';
import { ControlGroupStrings } from '../control_group_strings';
import { ControlFrame, ControlFrameProps } from './control_frame_component';
import { controlGroupSelector } from '../embeddable/control_group_container';
interface DragInfo {
isOver?: boolean;
isDragging?: boolean;
draggingIndex?: number;
index?: number;
}
export type SortableControlProps = ControlFrameProps & {
dragInfo: DragInfo;
isEditable: boolean;
};
/**
* A sortable wrapper around the generic control frame.
*/
export const SortableControl = (frameProps: SortableControlProps) => {
const { embeddableId, isEditable } = frameProps;
const { over, listeners, isSorting, transform, transition, attributes, isDragging, setNodeRef } =
useSortable({
id: embeddableId,
animateLayoutChanges: () => true,
disabled: !isEditable,
});
const sortableFrameProps = {
...frameProps,
dragInfo: { ...frameProps.dragInfo, isOver: over?.id === embeddableId, isDragging },
};
return (
<SortableControlInner
key={embeddableId}
ref={setNodeRef}
{...sortableFrameProps}
{...attributes}
{...listeners}
style={{
transition: transition ?? undefined,
transform: isSorting ? undefined : CSS.Translate.toString(transform),
}}
/>
);
};
const SortableControlInner = forwardRef<
HTMLButtonElement,
SortableControlProps & { style: HTMLAttributes<HTMLButtonElement>['style'] }
>(
(
{ embeddableId, embeddableType, dragInfo, style, isEditable, ...dragHandleProps },
dragHandleRef
) => {
const { isOver, isDragging, draggingIndex, index } = dragInfo;
const panels = controlGroupSelector((state) => state.explicitInput.panels);
const controlStyle = controlGroupSelector((state) => state.explicitInput.controlStyle);
const grow = panels[embeddableId].grow;
const width = panels[embeddableId].width;
const title = panels[embeddableId].explicitInput.title;
const dragHandle = isEditable ? (
<button
ref={dragHandleRef}
{...dragHandleProps}
aria-label={`${ControlGroupStrings.ariaActions.getMoveControlButtonAction(title)}`}
className="controlFrame__dragHandle"
>
<EuiIcon type="grabHorizontal" />
</button>
) : controlStyle === 'oneLine' ? (
<EuiIcon type="empty" size="s" />
) : undefined;
return (
<EuiFlexItem
grow={grow}
data-control-id={embeddableId}
data-test-subj={`control-frame`}
data-render-complete="true"
className={classNames('controlFrameWrapper', {
'controlFrameWrapper--grow': grow,
'controlFrameWrapper-isDragging': isDragging,
'controlFrameWrapper-isEditable': isEditable,
'controlFrameWrapper--small': width === 'small',
'controlFrameWrapper--medium': width === 'medium',
'controlFrameWrapper--large': width === 'large',
'controlFrameWrapper--insertBefore': isOver && (index ?? -1) < (draggingIndex ?? -1),
'controlFrameWrapper--insertAfter': isOver && (index ?? -1) > (draggingIndex ?? -1),
})}
style={style}
>
<ControlFrame
enableActions={draggingIndex === -1}
embeddableId={embeddableId}
embeddableType={embeddableType}
customPrepend={dragHandle}
/>
</EuiFlexItem>
);
}
);
/**
* A simplified clone version of the control which is dragged. This version only shows
* the title, because individual controls can be any size, and dragging a wide item
* can be quite cumbersome.
*/
export const ControlClone = ({ draggingId }: { draggingId: string }) => {
const panels = controlGroupSelector((state) => state.explicitInput.panels);
const controlStyle = controlGroupSelector((state) => state.explicitInput.controlStyle);
const width = panels[draggingId].width;
const title = panels[draggingId].explicitInput.title;
return (
<EuiFlexItem
className={classNames('controlFrameCloneWrapper', {
'controlFrameCloneWrapper--small': width === 'small',
'controlFrameCloneWrapper--medium': width === 'medium',
'controlFrameCloneWrapper--large': width === 'large',
'controlFrameCloneWrapper--twoLine': controlStyle === 'twoLine',
})}
>
{controlStyle === 'twoLine' ? <EuiFormLabel>{title}</EuiFormLabel> : undefined}
<EuiFlexGroup responsive={false} gutterSize="none" className={'controlFrame__draggable'}>
<EuiFlexItem grow={false}>
<EuiIcon type="grabHorizontal" className="controlFrame__dragHandle" />
</EuiFlexItem>
{controlStyle === 'oneLine' ? (
<EuiFlexItem>
<label className="controlFrameCloneWrapper__label">{title}</label>
</EuiFlexItem>
) : undefined}
</EuiFlexGroup>
</EuiFlexItem>
);
};

View file

@ -1,215 +0,0 @@
$smallControl: $euiSize * 14;
$mediumControl: $euiSize * 25;
$largeControl: $euiSize * 50;
$controlMinWidth: $euiSize * 14;
.controlsWrapper {
display: flex;
align-items: center;
.controlGroup--endButtonGroup {
align-self: end;
}
}
.controlsWrapper--twoLine {
.groupEditActions {
padding-top: $euiSize;
}
}
.controlFrameCloneWrapper {
width: max-content;
&--small {
width: $smallControl;
min-width:$smallControl;
}
&--medium {
width: $mediumControl;
min-width:$mediumControl;
}
&--large {
width: $largeControl;
min-width:$largeControl;
}
&--twoLine {
margin-top: -$euiSize * 1.25;
}
&__label {
cursor: grabbing !important; // prevents cursor flickering while dragging the clone
}
.controlFrame__draggable {
cursor: grabbing;
height: $euiButtonHeightSmall;
align-items: center;
border-radius: $euiBorderRadius;
font-weight: $euiFontWeightSemiBold;
@include euiFormControlDefaultShadow;
background-color: $euiFormInputGroupLabelBackground;
min-width: $controlMinWidth;
@include euiFontSizeXS;
}
.controlFrame__formControlLayout,
.controlFrame__draggable {
.controlFrame__dragHandle {
cursor: grabbing;
}
}
@include euiBreakpoint('xs', 's', 'm') {
width: 100%;
&--small {
min-width:unset;
}
&--medium {
min-width:unset;
}
&--large {
min-width:unset;
}
}
}
.controlFrameWrapper {
flex-basis: auto;
position: relative;
&:not(.controlFrameWrapper-isEditable) {
.controlFrame--twoLine {
border-radius: $euiFormControlBorderRadius !important;
}
}
.controlFrame__formControlLayout {
width: 100%;
min-width: $controlMinWidth;
transition: background-color .1s, color .1s;
.controlFrame__formControlLayoutLabel {
@include euiTextTruncate;
padding: 0;
}
&:not(.controlFrame__formControlLayout-clone) {
.controlFrame__dragHandle {
cursor: grab;
}
}
// Make sure controls with popover, tooltip, and tour wrappers inherit height correctly
[data-euiportal='true'],
.euiPopover,
.euiToolTipAnchor {
height: 100%;
}
.euiFormControlLayout__prepend {
padding-left: 0;
gap: 0;
}
.controlFrame__control {
height: 100%;
transition: opacity .1s;
}
.controlFrame--controlLoading {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
}
&--small {
width: $smallControl;
min-width: $smallControl;
}
&--medium {
width: $mediumControl;
min-width: $mediumControl;
}
&--large {
width: $largeControl;
min-width: $largeControl;
}
@include euiBreakpoint('xs', 's', 'm') {
&--small {
min-width:unset;
}
&--medium {
min-width:unset;
}
&--large {
min-width:unset;
}
}
&--insertBefore,
&--insertAfter {
.controlFrame__formControlLayout:after {
content: '';
position: absolute;
background-color: transparentize($euiColorPrimary, .5);
border-radius: $euiBorderRadius;
top: 0;
bottom: 0;
width: $euiSizeXS * .5;
}
}
&--insertBefore {
.controlFrame__formControlLayout:after {
left: -$euiSizeXS - 1;
}
}
&--insertAfter {
.controlFrame__formControlLayout:after {
right: -$euiSizeXS - 1;
}
}
&-isDragging {
opacity: 0; // hide dragged control, while control is dragged its replaced with ControlClone component
}
}
.controlFrameFloatingActions {
z-index: 1;
position: absolute;
&--oneLine {
padding: $euiSizeXS;
border-radius: $euiBorderRadius;
background-color: $euiColorEmptyShade;
box-shadow: 0 0 0 1px $euiColorLightShade;
}
&--twoLine {
top: (-$euiSizeXS) !important;
}
}
.controlGroup--invalidSelectionsTour {
.controlGroup--suppressTourCheckbox {
height: 22px;
&Label {
font-weight: $euiFontWeightMedium;
}
}
}

View file

@ -1,369 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { i18n } from '@kbn/i18n';
import { RANGE_SLIDER_CONTROL } from '../range_slider';
export const ControlGroupStrings = {
invalidControlWarning: {
getTourTitle: () =>
i18n.translate('controls.controlGroup.invalidControlWarning.tourStepTitle.default', {
defaultMessage: 'Invalid selections are no longer ignored',
}),
getTourContent: (controlType: string) => {
switch (controlType) {
case RANGE_SLIDER_CONTROL: {
return i18n.translate(
'controls.controlGroup.invalidControlWarning.tourStepContent.rangeSlider',
{
defaultMessage: 'The selected range is returning no results. Try changing the range.',
}
);
}
default: {
return i18n.translate(
'controls.controlGroup.invalidControlWarning.tourStepContent.default',
{
defaultMessage:
'Some selections are returning no results. Try changing the selections.',
}
);
}
}
},
getDismissButton: () =>
i18n.translate('controls.controlGroup.invalidControlWarning.dismissButtonLabel', {
defaultMessage: 'Dismiss',
}),
getSuppressTourLabel: () =>
i18n.translate('controls.controlGroup.invalidControlWarning.suppressTourLabel', {
defaultMessage: "Don't show again",
}),
},
manageControl: {
getFlyoutCreateTitle: () =>
i18n.translate('controls.controlGroup.manageControl.createFlyoutTitle', {
defaultMessage: 'Create control',
}),
getFlyoutEditTitle: () =>
i18n.translate('controls.controlGroup.manageControl.editFlyoutTitle', {
defaultMessage: 'Edit control',
}),
dataSource: {
getFormGroupTitle: () =>
i18n.translate('controls.controlGroup.manageControl.dataSource.formGroupTitle', {
defaultMessage: 'Data source',
}),
getFormGroupDescription: () =>
i18n.translate('controls.controlGroup.manageControl.dataSource.formGroupDescription', {
defaultMessage: 'Select the data view and field that you want to create a control for.',
}),
getSelectDataViewMessage: () =>
i18n.translate('controls.controlGroup.manageControl.dataSource.selectDataViewMessage', {
defaultMessage: 'Please select a data view',
}),
getDataViewTitle: () =>
i18n.translate('controls.controlGroup.manageControl.dataSource.dataViewTitle', {
defaultMessage: 'Data view',
}),
getFieldTitle: () =>
i18n.translate('controls.controlGroup.manageControl.dataSource.fieldTitle', {
defaultMessage: 'Field',
}),
getControlTypeTitle: () =>
i18n.translate('controls.controlGroup.manageControl.dataSource.controlTypesTitle', {
defaultMessage: 'Control type',
}),
getControlTypeErrorMessage: ({
fieldSelected,
controlType,
}: {
fieldSelected?: boolean;
controlType?: string;
}) => {
if (!fieldSelected) {
return i18n.translate(
'controls.controlGroup.manageControl.dataSource.controlTypErrorMessage.noField',
{
defaultMessage: 'Select a field first.',
}
);
}
switch (controlType) {
/**
* Note that options list controls are currently compatible with every field type; so, there is no
* need to have a special error message for these.
*/
case RANGE_SLIDER_CONTROL: {
return i18n.translate(
'controls.controlGroup.manageControl.dataSource.controlTypeErrorMessage.rangeSlider',
{
defaultMessage: 'Range sliders are only compatible with number fields.',
}
);
}
default: {
/** This shouldn't ever happen - but, adding just in case as a fallback. */
return i18n.translate(
'controls.controlGroup.manageControl.dataSource.controlTypeErrorMessage.default',
{
defaultMessage: 'Select a compatible control type.',
}
);
}
}
},
},
displaySettings: {
getFormGroupTitle: () =>
i18n.translate('controls.controlGroup.manageControl.displaySettings.formGroupTitle', {
defaultMessage: 'Display settings',
}),
getFormGroupDescription: () =>
i18n.translate('controls.controlGroup.manageControl.displaySettings.formGroupDescription', {
defaultMessage: 'Change how the control appears on your dashboard.',
}),
getTitleInputTitle: () =>
i18n.translate('controls.controlGroup.manageControl.displaySettings.titleInputTitle', {
defaultMessage: 'Label',
}),
getWidthInputTitle: () =>
i18n.translate('controls.controlGroup.manageControl.displaySettings.widthInputTitle', {
defaultMessage: 'Minimum width',
}),
getGrowSwitchTitle: () =>
i18n.translate('controls.controlGroup.manageControl.displaySettings.growSwitchTitle', {
defaultMessage: 'Expand width to fit available space',
}),
},
controlTypeSettings: {
getFormGroupTitle: (type: string) =>
i18n.translate('controls.controlGroup.manageControl.controlTypeSettings.formGroupTitle', {
defaultMessage: '{controlType} settings',
values: { controlType: type },
}),
getFormGroupDescription: (type: string) =>
i18n.translate(
'controls.controlGroup.manageControl.controlTypeSettings.formGroupDescription',
{
defaultMessage: 'Custom settings for your {controlType} control.',
values: { controlType: type.toLocaleLowerCase() },
}
),
},
getSaveChangesTitle: () =>
i18n.translate('controls.controlGroup.manageControl.saveChangesTitle', {
defaultMessage: 'Save and close',
}),
getCancelTitle: () =>
i18n.translate('controls.controlGroup.manageControl.cancelTitle', {
defaultMessage: 'Cancel',
}),
},
management: {
getAddControlTitle: () =>
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',
}),
getDeleteButtonTitle: () =>
i18n.translate('controls.controlGroup.management.delete', {
defaultMessage: 'Delete control',
}),
getDeleteAllButtonTitle: () =>
i18n.translate('controls.controlGroup.management.deleteAll', {
defaultMessage: 'Delete all',
}),
controlWidth: {
getWidthSwitchLegend: () =>
i18n.translate('controls.controlGroup.management.layout.controlWidthLegend', {
defaultMessage: 'Change control size',
}),
getAutoWidthTitle: () =>
i18n.translate('controls.controlGroup.management.layout.auto', {
defaultMessage: 'Auto',
}),
getSmallWidthTitle: () =>
i18n.translate('controls.controlGroup.management.layout.small', {
defaultMessage: 'Small',
}),
getMediumWidthTitle: () =>
i18n.translate('controls.controlGroup.management.layout.medium', {
defaultMessage: 'Medium',
}),
getLargeWidthTitle: () =>
i18n.translate('controls.controlGroup.management.layout.large', {
defaultMessage: 'Large',
}),
},
labelPosition: {
getLabelPositionTitle: () =>
i18n.translate('controls.controlGroup.management.labelPosition.title', {
defaultMessage: 'Label position',
}),
getLabelPositionLegend: () =>
i18n.translate('controls.controlGroup.management.labelPosition.designSwitchLegend', {
defaultMessage: 'Switch label position between inline and above',
}),
getInlineTitle: () =>
i18n.translate('controls.controlGroup.management.labelPosition.inline', {
defaultMessage: 'Inline',
}),
getAboveTitle: () =>
i18n.translate('controls.controlGroup.management.labelPosition.above', {
defaultMessage: 'Above',
}),
},
deleteControls: {
getDeleteAllTitle: () =>
i18n.translate('controls.controlGroup.management.delete.deleteAllTitle', {
defaultMessage: 'Delete all controls?',
}),
getDeleteTitle: () =>
i18n.translate('controls.controlGroup.management.delete.deleteTitle', {
defaultMessage: 'Delete control?',
}),
getSubtitle: () =>
i18n.translate('controls.controlGroup.management.delete.sub', {
defaultMessage: 'Controls are not recoverable once removed.',
}),
getConfirm: () =>
i18n.translate('controls.controlGroup.management.delete.confirm', {
defaultMessage: 'Delete',
}),
getCancel: () =>
i18n.translate('controls.controlGroup.management.delete.cancel', {
defaultMessage: 'Cancel',
}),
},
discardChanges: {
getTitle: () =>
i18n.translate('controls.controlGroup.management.discard.title', {
defaultMessage: 'Discard changes?',
}),
getSubtitle: () =>
i18n.translate('controls.controlGroup.management.discard.sub', {
defaultMessage: `Changes that you've made to this control will be discarded, are you sure you want to continue?`,
}),
getConfirm: () =>
i18n.translate('controls.controlGroup.management.discard.confirm', {
defaultMessage: 'Discard changes',
}),
getCancel: () =>
i18n.translate('controls.controlGroup.management.discard.cancel', {
defaultMessage: 'Cancel',
}),
},
discardNewControl: {
getTitle: () =>
i18n.translate('controls.controlGroup.management.deleteNew.title', {
defaultMessage: 'Discard new control',
}),
getSubtitle: () =>
i18n.translate('controls.controlGroup.management.deleteNew.sub', {
defaultMessage: `Changes that you've made to this control will be discarded, are you sure you want to continue?`,
}),
getConfirm: () =>
i18n.translate('controls.controlGroup.management.deleteNew.confirm', {
defaultMessage: 'Discard control',
}),
getCancel: () =>
i18n.translate('controls.controlGroup.management.deleteNew.cancel', {
defaultMessage: 'Cancel',
}),
},
selectionSettings: {
getSelectionSettingsTitle: () =>
i18n.translate('controls.controlGroup.management.selectionSettings', {
defaultMessage: 'Selections',
}),
validateSelections: {
getValidateSelectionsTitle: () =>
i18n.translate('controls.controlGroup.management.validate.title', {
defaultMessage: 'Validate user selections',
}),
getValidateSelectionsTooltip: () =>
i18n.translate('controls.controlGroup.management.validate.tooltip', {
defaultMessage: 'Highlight control selections that result in no data.',
}),
},
controlChaining: {
getHierarchyTitle: () =>
i18n.translate('controls.controlGroup.management.hierarchy.title', {
defaultMessage: 'Chain controls',
}),
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: () =>
i18n.translate('controls.controlGroup.management.filteringSettings', {
defaultMessage: 'Filtering',
}),
getUseGlobalFiltersTitle: () =>
i18n.translate('controls.controlGroup.management.filtering.useGlobalFilters', {
defaultMessage: 'Apply global filters to controls',
}),
getUseGlobalTimeRangeTitle: () =>
i18n.translate('controls.controlGroup.management.filtering.useGlobalTimeRange', {
defaultMessage: 'Apply global time range to controls',
}),
},
},
floatingActions: {
getEditButtonTitle: () =>
i18n.translate('controls.controlGroup.floatingActions.editTitle', {
defaultMessage: 'Edit',
}),
getRemoveButtonTitle: () =>
i18n.translate('controls.controlGroup.floatingActions.removeTitle', {
defaultMessage: 'Delete',
}),
getClearButtonTitle: () =>
i18n.translate('controls.controlGroup.floatingActions.clearTitle', {
defaultMessage: 'Clear',
}),
},
ariaActions: {
getMoveControlButtonAction: (controlTitle?: string) =>
i18n.translate('controls.controlGroup.ariaActions.moveControlButtonAction', {
defaultMessage: 'Move control {controlTitle}',
values: { controlTitle: controlTitle ?? '' },
}),
},
};

View file

@ -1,370 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { ReactWrapper } from 'enzyme';
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createStubDataView } from '@kbn/data-views-plugin/common/data_view.stub';
import { findTestSubject, mountWithIntl } from '@kbn/test-jest-helpers';
import { stubFieldSpecMap } from '@kbn/data-views-plugin/common/field.stub';
import {
OptionsListEmbeddableInput,
OPTIONS_LIST_CONTROL,
RANGE_SLIDER_CONTROL,
} from '../../../common';
import {
DEFAULT_CONTROL_GROW,
DEFAULT_CONTROL_WIDTH,
} from '../../../common/control_group/control_group_constants';
import {
mockControlGroupContainer,
mockOptionsListEmbeddable,
mockRangeSliderEmbeddable,
} from '../../../common/mocks';
import { RangeSliderEmbeddableFactory } from '../../range_slider';
import { pluginServices } from '../../services';
import { ControlGroupContainerContext } from '../embeddable/control_group_container';
import { ControlGroupInput } from '../types';
import { ControlEditor, EditControlProps } from './control_editor';
import { OptionsListEmbeddableFactory } from '../../options_list';
describe('Data control editor', () => {
interface MountOptions {
componentOptions?: Partial<EditControlProps>;
explicitInput?: Partial<ControlGroupInput>;
}
const stubDataView = createStubDataView({
spec: {
id: 'logstash-*',
fields: {
...stubFieldSpecMap,
'machine.os.raw': {
name: 'machine.os.raw',
customLabel: 'OS',
type: 'string',
esTypes: ['keyword'],
aggregatable: true,
searchable: true,
},
},
title: 'logstash-*',
timeFieldName: '@timestamp',
},
});
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();
});
let controlEditor: ReactWrapper = new ReactWrapper(<></>);
async function mountComponent(options?: MountOptions) {
const controlGroupContainer = await mockControlGroupContainer(options?.explicitInput);
await act(async () => {
controlEditor = mountWithIntl(
<ControlGroupContainerContext.Provider value={controlGroupContainer}>
<ControlEditor
setLastUsedDataViewId={jest.fn()}
getRelevantDataViewId={() => stubDataView.id}
isCreate={true}
width={DEFAULT_CONTROL_WIDTH}
grow={DEFAULT_CONTROL_GROW}
onSave={jest.fn()}
onCancel={jest.fn()}
{...options?.componentOptions}
/>
</ControlGroupContainerContext.Provider>
);
});
await new Promise(process.nextTick);
controlEditor.update();
}
const selectField = async (fieldName: string) => {
const option = findTestSubject(controlEditor, `field-picker-select-${fieldName}`);
await act(async () => {
option.simulate('click');
});
controlEditor.update();
};
describe('creating a new control', () => {
test('does not show non-aggregatable field', async () => {
await mountComponent();
const nonAggOption = findTestSubject(controlEditor, 'field-picker-select-machine.os');
expect(nonAggOption.exists()).toBe(false);
});
describe('selecting a keyword field', () => {
beforeEach(async () => {
await mountComponent();
await selectField('machine.os.raw');
});
test('can only create an options list control', async () => {
expect(
findTestSubject(controlEditor, 'create__optionsListControl').instance()
).toBeEnabled();
expect(
findTestSubject(controlEditor, 'create__rangeSliderControl').instance()
).not.toBeEnabled();
});
test('has custom settings', async () => {
const searchOptions = findTestSubject(controlEditor, 'control-editor-custom-settings');
expect(searchOptions.exists()).toBe(true);
});
test('has custom search options', async () => {
const searchOptions = findTestSubject(
controlEditor,
'optionsListControl__searchOptionsRadioGroup'
);
expect(searchOptions.exists()).toBe(true);
const options = searchOptions.find('div.euiRadioGroup__item');
expect(options.length).toBe(3);
});
});
describe('selecting an IP field', () => {
beforeEach(async () => {
await mountComponent();
await selectField('clientip');
});
test('can only create an options list control', async () => {
expect(
findTestSubject(controlEditor, 'create__optionsListControl').instance()
).toBeEnabled();
expect(
findTestSubject(controlEditor, 'create__rangeSliderControl').instance()
).not.toBeEnabled();
});
test('has custom search options', async () => {
const searchOptions = findTestSubject(
controlEditor,
'optionsListControl__searchOptionsRadioGroup'
);
expect(searchOptions.exists()).toBe(true);
const options = searchOptions.find('div.euiRadioGroup__item');
expect(options.length).toBe(2);
});
});
describe('selecting a number field', () => {
beforeEach(async () => {
await mountComponent();
await selectField('bytes');
});
test('can create an options list or range slider control', async () => {
expect(
findTestSubject(controlEditor, 'create__optionsListControl').instance()
).toBeEnabled();
expect(
findTestSubject(controlEditor, 'create__rangeSliderControl').instance()
).toBeEnabled();
});
test('defaults to options list creation', async () => {
expect(
findTestSubject(controlEditor, 'create__optionsListControl').prop('aria-pressed')
).toBe(true);
});
test('when creating options list, has custom settings', async () => {
findTestSubject(controlEditor, 'create__optionsListControl').simulate('click');
const customSettings = findTestSubject(controlEditor, 'control-editor-custom-settings');
expect(customSettings.exists()).toBe(true);
});
test('when creating options list, does not have custom search options', async () => {
findTestSubject(controlEditor, 'create__optionsListControl').simulate('click');
const searchOptions = findTestSubject(
controlEditor,
'optionsListControl__searchOptionsRadioGroup'
);
expect(searchOptions.exists()).toBe(false);
});
test('when creating range slider, does have custom settings', async () => {
findTestSubject(controlEditor, 'create__rangeSliderControl').simulate('click');
const searchOptions = findTestSubject(controlEditor, 'control-editor-custom-settings');
expect(searchOptions.exists()).toBe(true);
});
test('when creating range slider, validates step setting is greater than 0', async () => {
findTestSubject(controlEditor, 'create__rangeSliderControl').simulate('click');
const stepOption = findTestSubject(
controlEditor,
'rangeSliderControl__stepAdditionalSetting'
);
expect(stepOption.exists()).toBe(true);
const saveButton = findTestSubject(controlEditor, 'control-editor-save');
expect(saveButton.instance()).toBeEnabled();
stepOption.simulate('change', { target: { valueAsNumber: undefined } });
expect(saveButton.instance()).toBeDisabled();
stepOption.simulate('change', { target: { valueAsNumber: 0.5 } });
expect(saveButton.instance()).toBeEnabled();
stepOption.simulate('change', { target: { valueAsNumber: 0 } });
expect(saveButton.instance()).toBeDisabled();
stepOption.simulate('change', { target: { valueAsNumber: 1 } });
expect(saveButton.instance()).toBeEnabled();
});
});
test('selects given width and grow', async () => {
await mountComponent({ componentOptions: { grow: false, width: 'small' } });
const selectedClass = 'euiButtonGroupButton-isSelected';
expect(
findTestSubject(controlEditor, 'control-editor-width-medium').hasClass(selectedClass)
).toBe(false);
expect(
findTestSubject(controlEditor, 'control-editor-width-small').hasClass(selectedClass)
).toBe(true);
expect(
findTestSubject(controlEditor, 'control-editor-width-large').hasClass(selectedClass)
).toBe(false);
expect(
findTestSubject(controlEditor, 'control-editor-grow-switch').prop('aria-checked')
).toBe(false);
});
});
describe('editing existing options list control', () => {
const openEditor = async (
type: string,
explicitInput?: Partial<OptionsListEmbeddableInput>
) => {
const control =
type === 'optionsList'
? await mockOptionsListEmbeddable({
explicitInput: {
title: 'machine.os.raw',
dataViewId: stubDataView.id,
fieldName: 'machine.os.raw',
...explicitInput,
},
})
: await mockRangeSliderEmbeddable({
explicitInput: {
title: 'bytes',
dataViewId: stubDataView.id,
fieldName: 'bytes',
...explicitInput,
},
});
await mountComponent({
componentOptions: { isCreate: false, embeddable: control },
});
};
describe('control title', () => {
test('auto-fills default', async () => {
await openEditor('optionsList');
const titleInput = findTestSubject(controlEditor, 'control-editor-title-input');
expect(titleInput.prop('value')).toBe('machine.os.raw');
expect(titleInput.prop('placeholder')).toBe('machine.os.raw');
});
test('auto-fills custom title', async () => {
await openEditor('optionsList', { title: 'Custom Title' });
const titleInput = findTestSubject(controlEditor, 'control-editor-title-input');
expect(titleInput.prop('value')).toBe('Custom Title');
expect(titleInput.prop('placeholder')).toBe('machine.os.raw');
});
});
describe('control type', () => {
test('selects the default control type', async () => {
await openEditor('optionsList', { fieldName: 'bytes' });
expect(
findTestSubject(controlEditor, 'create__optionsListControl').prop('aria-pressed')
).toBe(true);
expect(
findTestSubject(controlEditor, 'create__rangeSliderControl').prop('aria-pressed')
).toBe(false);
});
test('selects the given, non-default control type', async () => {
await openEditor('rangeSlider', { fieldName: 'bytes' });
expect(
findTestSubject(controlEditor, 'create__optionsListControl').prop('aria-pressed')
).toBe(false);
expect(
findTestSubject(controlEditor, 'create__rangeSliderControl').prop('aria-pressed')
).toBe(true);
});
});
describe('selection options', () => {
test('selects default', async () => {
await openEditor('optionsList');
const radioGroup = findTestSubject(
controlEditor,
'optionsListControl__selectionOptionsRadioGroup'
);
expect(radioGroup.find('input#multi').prop('checked')).toBe(true);
expect(radioGroup.find('input#single').prop('checked')).toBe(false);
});
test('selects given', async () => {
await openEditor('optionsList', { singleSelect: true });
const radioGroup = findTestSubject(
controlEditor,
'optionsListControl__selectionOptionsRadioGroup'
);
expect(radioGroup.find('input#multi').prop('checked')).toBe(false);
expect(radioGroup.find('input#single').prop('checked')).toBe(true);
});
});
describe('search techniques', () => {
test('selects default', async () => {
await openEditor('optionsList');
const radioGroup = findTestSubject(
controlEditor,
'optionsListControl__searchOptionsRadioGroup'
);
expect(radioGroup.find('input#prefix').prop('checked')).toBe(true);
expect(radioGroup.find('input#wildcard').prop('checked')).toBe(false);
});
test('selects given', async () => {
await openEditor('optionsList', { searchTechnique: 'wildcard' });
const radioGroup = findTestSubject(
controlEditor,
'optionsListControl__searchOptionsRadioGroup'
);
expect(radioGroup.find('input#prefix').prop('checked')).toBe(false);
expect(radioGroup.find('input#wildcard').prop('checked')).toBe(true);
});
});
});
});

View file

@ -1,412 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import deepEqual from 'fast-deep-equal';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import useAsync from 'react-use/lib/useAsync';
import useMount from 'react-use/lib/useMount';
import {
EuiButton,
EuiButtonEmpty,
EuiButtonGroup,
EuiDescribedFormGroup,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiForm,
EuiFormRow,
EuiIcon,
EuiKeyPadMenu,
EuiKeyPadMenuItem,
EuiSpacer,
EuiSwitch,
EuiTitle,
EuiToolTip,
} from '@elastic/eui';
import { DataViewField } from '@kbn/data-views-plugin/common';
import {
LazyDataViewPicker,
LazyFieldPicker,
withSuspense,
} from '@kbn/presentation-util-plugin/public';
import { TIME_SLIDER_CONTROL } from '../../../common';
import { pluginServices } from '../../services';
import {
ControlEmbeddable,
ControlInput,
ControlWidth,
DataControlEditorChanges,
DataControlInput,
IEditableControlFactory,
} from '../../types';
import { ControlGroupStrings } from '../control_group_strings';
import { useControlGroupContainer } from '../embeddable/control_group_container';
import { getDataControlFieldRegistry } from './data_control_editor_tools';
import { CONTROL_WIDTH_OPTIONS } from './editor_constants';
export interface EditControlProps {
embeddable?: ControlEmbeddable<DataControlInput>;
isCreate: boolean;
width: ControlWidth;
onSave: (changes: DataControlEditorChanges, type?: string) => void;
grow: boolean;
onCancel: (changes: DataControlEditorChanges) => void;
removeControl?: () => void;
getRelevantDataViewId?: () => string | undefined;
setLastUsedDataViewId?: (newDataViewId: string) => void;
}
const FieldPicker = withSuspense(LazyFieldPicker, null);
const DataViewPicker = withSuspense(LazyDataViewPicker, null);
export const ControlEditor = ({
embeddable,
isCreate,
width,
grow,
onSave,
onCancel,
removeControl,
getRelevantDataViewId,
setLastUsedDataViewId,
}: EditControlProps) => {
const {
dataViews: { getIdsWithTitle, getDefaultId, get },
controls: { getControlFactory, getControlTypes },
} = pluginServices.getServices();
const controlGroup = useControlGroupContainer();
const editorConfig = controlGroup.select((state) => state.componentState.editorConfig);
const [currentGrow, setCurrentGrow] = useState(grow);
const [currentWidth, setCurrentWidth] = useState(width);
const [defaultTitle, setDefaultTitle] = useState<string>();
const [currentTitle, setCurrentTitle] = useState(embeddable?.getTitle() ?? '');
const [controlEditorValid, setControlEditorValid] = useState(false);
const [selectedDataViewId, setSelectedDataViewId] = useState<string>();
const [selectedField, setSelectedField] = useState<string | undefined>(
embeddable ? embeddable.getInput().fieldName : undefined
);
const [selectedControlType, setSelectedControlType] = useState<string | undefined>(
embeddable ? embeddable.type : undefined
);
const [customSettings, setCustomSettings] = useState<Partial<ControlInput>>();
const currentInput: Partial<DataControlInput> = useMemo(
() => ({
fieldName: selectedField,
dataViewId: selectedDataViewId,
title: currentTitle === '' ? defaultTitle ?? selectedField : currentTitle,
...customSettings,
}),
[currentTitle, defaultTitle, selectedField, selectedDataViewId, customSettings]
);
const startingInput = useRef(currentInput);
useMount(() => {
let mounted = true;
if (selectedField) setDefaultTitle(selectedField);
(async () => {
if (!mounted) return;
const initialId =
embeddable?.getInput().dataViewId ??
controlGroup.getOutput().dataViewIds?.[0] ??
getRelevantDataViewId?.() ??
(await getDefaultId());
if (initialId) {
setSelectedDataViewId(initialId);
startingInput.current = { ...startingInput.current, dataViewId: initialId };
}
})();
return () => {
mounted = false;
};
});
const { loading: dataViewListLoading, value: dataViewListItems = [] } = useAsync(() => {
return getIdsWithTitle();
});
const {
loading: dataViewLoading,
value: { selectedDataView, fieldRegistry } = {
selectedDataView: undefined,
fieldRegistry: undefined,
},
} = useAsync(async () => {
if (!selectedDataViewId) {
return;
}
const dataView = await get(selectedDataViewId);
const registry = await getDataControlFieldRegistry(dataView);
return {
selectedDataView: dataView,
fieldRegistry: registry,
};
}, [selectedDataViewId]);
useEffect(
() =>
setControlEditorValid(
Boolean(selectedField) && Boolean(selectedDataView) && Boolean(selectedControlType)
),
[selectedField, setControlEditorValid, selectedDataView, selectedControlType]
);
const CompatibleControlTypesComponent = useMemo(() => {
const allDataControlTypes = getControlTypes().filter((type) => type !== TIME_SLIDER_CONTROL);
return (
<EuiKeyPadMenu
data-test-subj={`controlTypeMenu`}
aria-label={ControlGroupStrings.manageControl.dataSource.getControlTypeTitle()}
>
{allDataControlTypes.map((controlType) => {
const factory = getControlFactory(controlType);
const disabled =
fieldRegistry && selectedField
? !fieldRegistry[selectedField]?.compatibleControlTypes.includes(controlType)
: true;
const keyPadMenuItem = (
<EuiKeyPadMenuItem
key={controlType}
id={`create__${controlType}`}
aria-label={factory.getDisplayName()}
data-test-subj={`create__${controlType}`}
isSelected={controlType === selectedControlType}
disabled={disabled}
onClick={() => setSelectedControlType(controlType)}
label={factory.getDisplayName()}
>
<EuiIcon type={factory.getIconType()} size="l" />
</EuiKeyPadMenuItem>
);
return disabled ? (
<EuiToolTip
key={`disabled__${controlType}`}
content={ControlGroupStrings.manageControl.dataSource.getControlTypeErrorMessage({
fieldSelected: Boolean(selectedField),
controlType,
})}
>
{keyPadMenuItem}
</EuiToolTip>
) : (
keyPadMenuItem
);
})}
</EuiKeyPadMenu>
);
}, [selectedField, fieldRegistry, getControlFactory, getControlTypes, selectedControlType]);
const CustomSettingsComponent = useMemo(() => {
if (!selectedControlType || !selectedField || !fieldRegistry) return;
const controlFactory = getControlFactory(selectedControlType);
const CustomSettings = (controlFactory as IEditableControlFactory)
.controlEditorOptionsComponent;
if (!CustomSettings) return;
return (
<EuiDescribedFormGroup
ratio="third"
title={
<h2>
{ControlGroupStrings.manageControl.controlTypeSettings.getFormGroupTitle(
controlFactory.getDisplayName()
)}
</h2>
}
description={ControlGroupStrings.manageControl.controlTypeSettings.getFormGroupDescription(
controlFactory.getDisplayName()
)}
data-test-subj="control-editor-custom-settings"
>
<CustomSettings
onChange={(settings) => setCustomSettings(settings)}
initialInput={embeddable?.getInput()}
fieldType={fieldRegistry[selectedField].field.type}
setControlEditorValid={setControlEditorValid}
/>
</EuiDescribedFormGroup>
);
}, [selectedControlType, selectedField, getControlFactory, fieldRegistry, embeddable]);
return (
<>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2>
{isCreate
? ControlGroupStrings.manageControl.getFlyoutCreateTitle()
: ControlGroupStrings.manageControl.getFlyoutEditTitle()}
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody data-test-subj="control-editor-flyout">
<EuiForm fullWidth>
<EuiDescribedFormGroup
ratio="third"
title={<h2>{ControlGroupStrings.manageControl.dataSource.getFormGroupTitle()}</h2>}
description={ControlGroupStrings.manageControl.dataSource.getFormGroupDescription()}
>
{!editorConfig?.hideDataViewSelector && (
<EuiFormRow label={ControlGroupStrings.manageControl.dataSource.getDataViewTitle()}>
<DataViewPicker
dataViews={dataViewListItems}
selectedDataViewId={selectedDataViewId}
onChangeDataViewId={(newDataViewId) => {
setLastUsedDataViewId?.(newDataViewId);
if (newDataViewId === selectedDataViewId) return;
setSelectedField(undefined);
setSelectedDataViewId(newDataViewId);
}}
trigger={{
label:
selectedDataView?.getName() ??
ControlGroupStrings.manageControl.dataSource.getSelectDataViewMessage(),
}}
selectableProps={{ isLoading: dataViewListLoading }}
/>
</EuiFormRow>
)}
<EuiFormRow label={ControlGroupStrings.manageControl.dataSource.getFieldTitle()}>
<FieldPicker
filterPredicate={(field: DataViewField) => {
const customPredicate = controlGroup.fieldFilterPredicate?.(field) ?? true;
return Boolean(fieldRegistry?.[field.name]) && customPredicate;
}}
selectedFieldName={selectedField}
dataView={selectedDataView}
onSelectField={(field) => {
const newDefaultTitle = field.displayName ?? field.name;
setDefaultTitle(newDefaultTitle);
setSelectedField(field.name);
setSelectedControlType(fieldRegistry?.[field.name]?.compatibleControlTypes[0]);
if (!currentTitle || currentTitle === defaultTitle) {
setCurrentTitle(newDefaultTitle);
}
}}
selectableProps={{ isLoading: dataViewListLoading || dataViewLoading }}
/>
</EuiFormRow>
<EuiFormRow label={ControlGroupStrings.manageControl.dataSource.getControlTypeTitle()}>
{CompatibleControlTypesComponent}
</EuiFormRow>
</EuiDescribedFormGroup>
<EuiDescribedFormGroup
ratio="third"
title={<h2>{ControlGroupStrings.manageControl.displaySettings.getFormGroupTitle()}</h2>}
description={ControlGroupStrings.manageControl.displaySettings.getFormGroupDescription()}
>
<EuiFormRow
label={ControlGroupStrings.manageControl.displaySettings.getTitleInputTitle()}
>
<EuiFieldText
data-test-subj="control-editor-title-input"
placeholder={defaultTitle}
value={currentTitle}
onChange={(e) => setCurrentTitle(e.target.value)}
/>
</EuiFormRow>
{!editorConfig?.hideWidthSettings && (
<EuiFormRow
label={ControlGroupStrings.manageControl.displaySettings.getWidthInputTitle()}
>
<div>
<EuiButtonGroup
color="primary"
legend={ControlGroupStrings.management.controlWidth.getWidthSwitchLegend()}
options={CONTROL_WIDTH_OPTIONS}
idSelected={currentWidth}
onChange={(newWidth: string) => setCurrentWidth(newWidth as ControlWidth)}
/>
<EuiSpacer size="s" />
<EuiSwitch
label={ControlGroupStrings.manageControl.displaySettings.getGrowSwitchTitle()}
color="primary"
checked={currentGrow}
onChange={() => setCurrentGrow(!currentGrow)}
data-test-subj="control-editor-grow-switch"
/>
</div>
</EuiFormRow>
)}
</EuiDescribedFormGroup>
{!editorConfig?.hideAdditionalSettings ? CustomSettingsComponent : null}
{removeControl && (
<>
<EuiSpacer size="l" />
<EuiButtonEmpty
aria-label={`delete-${currentInput.title}`}
iconType="trash"
flush="left"
color="danger"
onClick={() => {
onCancel({ input: currentInput, grow: currentGrow, width: currentWidth });
removeControl();
}}
>
{ControlGroupStrings.management.getDeleteButtonTitle()}
</EuiButtonEmpty>
</>
)}
</EuiForm>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup responsive={false} justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
aria-label={`cancel-${currentInput.title}`}
data-test-subj="control-editor-cancel"
iconType="cross"
onClick={() => {
const inputToReturn =
isCreate && deepEqual(startingInput.current, currentInput) ? {} : currentInput;
onCancel({
input: inputToReturn,
grow: currentGrow,
width: currentWidth,
});
}}
>
{ControlGroupStrings.manageControl.getCancelTitle()}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
aria-label={`save-${currentInput.title}`}
data-test-subj="control-editor-save"
iconType="check"
color="primary"
disabled={!controlEditorValid}
onClick={() =>
onSave(
{ input: currentInput, grow: currentGrow, width: currentWidth },
selectedControlType
)
}
>
{ControlGroupStrings.manageControl.getSaveChangesTitle()}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</>
);
};

View file

@ -1,249 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import fastIsEqual from 'fast-deep-equal';
import React, { useCallback, useState } from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiButtonGroup,
EuiFlexGroup,
EuiFlexItem,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiForm,
EuiFormRow,
EuiHorizontalRule,
EuiSpacer,
EuiSwitch,
EuiTitle,
} from '@elastic/eui';
import { ControlGroupInput } from '..';
import { getDefaultControlGroupInput, ParentIgnoreSettings } from '../../../common';
import { ControlSettingTooltipLabel } from '../../components/control_setting_tooltip_label';
import { ControlStyle } from '../../types';
import { ControlGroupStrings } from '../control_group_strings';
import { CONTROL_LAYOUT_OPTIONS } from './editor_constants';
interface EditControlGroupProps {
initialInput: ControlGroupInput;
controlCount: number;
updateInput: (input: Partial<ControlGroupInput>) => void;
onDeleteAll: () => void;
onClose: () => void;
}
const editorControlGroupInputIsEqual = (a: ControlGroupInput, b: ControlGroupInput) =>
fastIsEqual(a, b);
export const ControlGroupEditor = ({
controlCount,
initialInput,
updateInput,
onDeleteAll,
onClose,
}: EditControlGroupProps) => {
const [controlGroupEditorState, setControlGroupEditorState] = useState<ControlGroupInput>({
...getDefaultControlGroupInput(),
...initialInput,
});
const updateControlGroupEditorSetting = useCallback(
(newSettings: Partial<ControlGroupInput>) => {
setControlGroupEditorState({
...controlGroupEditorState,
...newSettings,
});
},
[controlGroupEditorState]
);
const updateIgnoreSetting = useCallback(
(newSettings: Partial<ParentIgnoreSettings>) => {
setControlGroupEditorState({
...controlGroupEditorState,
ignoreParentSettings: {
...(controlGroupEditorState.ignoreParentSettings ?? {}),
...newSettings,
},
});
},
[controlGroupEditorState]
);
const applyChangesToInput = useCallback(() => {
const inputToApply = { ...controlGroupEditorState };
if (!editorControlGroupInputIsEqual(inputToApply, initialInput)) {
updateInput(inputToApply);
}
}, [controlGroupEditorState, initialInput, updateInput]);
return (
<>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2>{ControlGroupStrings.management.getFlyoutTitle()}</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody data-test-subj="control-group-settings-flyout">
<EuiForm component="form" fullWidth>
<EuiFormRow label={ControlGroupStrings.management.labelPosition.getLabelPositionTitle()}>
<EuiButtonGroup
color="primary"
options={CONTROL_LAYOUT_OPTIONS}
data-test-subj="control-group-layout-options"
idSelected={controlGroupEditorState.controlStyle}
legend={ControlGroupStrings.management.labelPosition.getLabelPositionLegend()}
onChange={(newControlStyle: string) => {
// The UI copy calls this setting labelPosition, but to avoid an unnecessary migration it will be left as controlStyle in the state.
updateControlGroupEditorSetting({
controlStyle: newControlStyle as ControlStyle,
});
}}
/>
</EuiFormRow>
<EuiFormRow
label={ControlGroupStrings.management.filteringSettings.getFilteringSettingsTitle()}
>
<div>
<EuiSwitch
compressed
data-test-subj="control-group-filter-sync"
label={ControlGroupStrings.management.filteringSettings.getUseGlobalFiltersTitle()}
onChange={(e) =>
updateIgnoreSetting({
ignoreFilters: !e.target.checked,
ignoreQuery: !e.target.checked,
})
}
checked={
!Boolean(controlGroupEditorState.ignoreParentSettings?.ignoreFilters) ||
!Boolean(controlGroupEditorState.ignoreParentSettings?.ignoreQuery)
}
/>
<EuiSpacer size="s" />
<EuiSwitch
compressed
data-test-subj="control-group-query-sync-time-range"
label={ControlGroupStrings.management.filteringSettings.getUseGlobalTimeRangeTitle()}
onChange={(e) => updateIgnoreSetting({ ignoreTimerange: !e.target.checked })}
checked={!Boolean(controlGroupEditorState.ignoreParentSettings?.ignoreTimerange)}
/>
</div>
</EuiFormRow>
<EuiFormRow
label={ControlGroupStrings.management.selectionSettings.getSelectionSettingsTitle()}
>
<div>
<EuiSwitch
compressed
data-test-subj="control-group-validate-selections"
label={
<ControlSettingTooltipLabel
label={ControlGroupStrings.management.selectionSettings.validateSelections.getValidateSelectionsTitle()}
tooltip={ControlGroupStrings.management.selectionSettings.validateSelections.getValidateSelectionsTooltip()}
/>
}
checked={!Boolean(controlGroupEditorState.ignoreParentSettings?.ignoreValidations)}
onChange={(e) => updateIgnoreSetting({ ignoreValidations: !e.target.checked })}
/>
<EuiSpacer size="s" />
<EuiSwitch
compressed
data-test-subj="control-group-chaining"
label={
<ControlSettingTooltipLabel
label={ControlGroupStrings.management.selectionSettings.controlChaining.getHierarchyTitle()}
tooltip={ControlGroupStrings.management.selectionSettings.controlChaining.getHierarchyTooltip()}
/>
}
checked={controlGroupEditorState.chainingSystem === 'HIERARCHICAL'}
onChange={(e) =>
updateControlGroupEditorSetting({
chainingSystem: e.target.checked ? 'HIERARCHICAL' : 'NONE',
})
}
/>
<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>
{controlCount > 0 && (
<>
<EuiHorizontalRule margin="m" />
<EuiFormRow>
<EuiButtonEmpty
onClick={onDeleteAll}
data-test-subj="delete-all-controls-button"
aria-label={'delete-all'}
iconType="trash"
color="danger"
flush="left"
size="s"
>
{ControlGroupStrings.management.getDeleteAllButtonTitle()}
</EuiButtonEmpty>
</EuiFormRow>
</>
)}
</EuiForm>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup responsive={false} justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
aria-label={`cancel-editing-group`}
iconType="cross"
onClick={() => {
onClose();
}}
>
{ControlGroupStrings.manageControl.getCancelTitle()}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
aria-label={`save-group`}
iconType="check"
color="primary"
data-test-subj="control-group-editor-save"
onClick={() => {
applyChangesToInput();
onClose();
}}
>
{ControlGroupStrings.manageControl.getSaveChangesTitle()}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</>
);
};

View file

@ -1,49 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { memoize } from 'lodash';
import { DataView } from '@kbn/data-views-plugin/common';
import { pluginServices } from '../../services';
import { DataControlFieldRegistry, IEditableControlFactory } from '../../types';
export const getDataControlFieldRegistry = memoize(
async (dataView: DataView) => {
return await loadFieldRegistryFromDataView(dataView);
},
(dataView: DataView) => [dataView.id, JSON.stringify(dataView.fields.getAll())].join('|')
);
const loadFieldRegistryFromDataView = async (
dataView: DataView
): Promise<DataControlFieldRegistry> => {
const {
controls: { getControlTypes, getControlFactory },
} = pluginServices.getServices();
const controlFactories = getControlTypes().map(
(controlType) => getControlFactory(controlType) as IEditableControlFactory
);
const fieldRegistry: DataControlFieldRegistry = {};
return new Promise<DataControlFieldRegistry>((resolve) => {
for (const field of dataView.fields.getAll()) {
const compatibleControlTypes = [];
for (const factory of controlFactories) {
if (factory.isFieldCompatible && factory.isFieldCompatible(field)) {
compatibleControlTypes.push(factory.type);
}
}
if (compatibleControlTypes.length > 0) {
fieldRegistry[field.name] = { field, compatibleControlTypes };
}
}
resolve(fieldRegistry);
});
};

View file

@ -1,140 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { batch } from 'react-redux';
import { isErrorEmbeddable } from '@kbn/embeddable-plugin/public';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL } from '../..';
import {
DEFAULT_CONTROL_GROW,
DEFAULT_CONTROL_WIDTH,
} from '../../../common/control_group/control_group_constants';
import { ControlInputTransform } from '../../../common/types';
import { pluginServices } from '../../services';
import { DataControlEditorChanges, IEditableControlFactory } from '../../types';
import { ControlGroupStrings } from '../control_group_strings';
import {
ControlGroupContainer,
ControlGroupContainerContext,
setFlyoutRef,
} from '../embeddable/control_group_container';
import type {
AddDataControlProps,
AddOptionsListControlProps,
AddRangeSliderControlProps,
} from '../external_api/control_group_input_builder';
import { ControlEditor } from './control_editor';
export function openAddDataControlFlyout(
this: ControlGroupContainer,
options?: {
controlInputTransform?: ControlInputTransform;
onSave?: (id: string) => void;
}
) {
const { controlInputTransform, onSave } = options || {};
const {
core: { theme, i18n },
overlays: { openFlyout, openConfirm },
controls: { getControlFactory },
} = pluginServices.getServices();
const onCancel = (changes?: DataControlEditorChanges) => {
if (!changes || Object.keys(changes.input).length === 0) {
this.closeAllFlyouts();
return;
}
openConfirm(ControlGroupStrings.management.discardNewControl.getSubtitle(), {
confirmButtonText: ControlGroupStrings.management.discardNewControl.getConfirm(),
cancelButtonText: ControlGroupStrings.management.discardNewControl.getCancel(),
title: ControlGroupStrings.management.discardNewControl.getTitle(),
buttonColor: 'danger',
}).then((confirmed) => {
if (confirmed) {
this.closeAllFlyouts();
}
});
};
const onSaveFlyout = async (changes: DataControlEditorChanges, type?: string) => {
this.closeAllFlyouts();
if (!type) {
return;
}
let controlInput = changes.input;
const factory = getControlFactory(type) as IEditableControlFactory;
if (factory.presaveTransformFunction) {
controlInput = factory.presaveTransformFunction(controlInput);
}
if (controlInputTransform) {
controlInput = controlInputTransform({ ...controlInput }, type);
}
const dataControlInput = {
grow: changes.grow,
width: changes.width,
...controlInput,
};
let newControl;
switch (type) {
case OPTIONS_LIST_CONTROL:
newControl = await this.addOptionsListControl(
dataControlInput as AddOptionsListControlProps
);
break;
case RANGE_SLIDER_CONTROL:
newControl = await this.addRangeSliderControl(
dataControlInput as AddRangeSliderControlProps
);
break;
default:
newControl = await this.addDataControlFromField(dataControlInput as AddDataControlProps);
}
if (onSave && !isErrorEmbeddable(newControl)) {
onSave(newControl.id);
}
batch(() => {
this.dispatch.setDefaultControlGrow(changes.grow);
this.dispatch.setDefaultControlWidth(changes.width);
});
};
const flyoutInstance = openFlyout(
toMountPoint(
<ControlGroupContainerContext.Provider value={this}>
<ControlEditor
setLastUsedDataViewId={(newId) => this.setLastUsedDataViewId(newId)}
getRelevantDataViewId={this.getMostRelevantDataViewId}
isCreate={true}
width={this.getInput().defaultControlWidth ?? DEFAULT_CONTROL_WIDTH}
grow={this.getInput().defaultControlGrow ?? DEFAULT_CONTROL_GROW}
onSave={onSaveFlyout}
onCancel={onCancel}
/>
</ControlGroupContainerContext.Provider>,
{ theme, i18n }
),
{
'aria-label': ControlGroupStrings.manageControl.getFlyoutCreateTitle(),
outsideClickCloses: false,
onClose: () => {
onCancel();
},
}
);
setFlyoutRef(flyoutInstance);
}

View file

@ -1,65 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { OverlayRef } from '@kbn/core-mount-utils-browser';
import { toMountPoint } from '@kbn/react-kibana-mount';
import React from 'react';
import { pluginServices } from '../../services';
import { ControlGroupStrings } from '../control_group_strings';
import {
ControlGroupContainer,
ControlGroupContainerContext,
setFlyoutRef,
} from '../embeddable/control_group_container';
import { ControlGroupEditor } from './control_group_editor';
export function openEditControlGroupFlyout(this: ControlGroupContainer) {
const {
core: { theme, i18n },
overlays: { openFlyout, openConfirm },
} = pluginServices.getServices();
const onDeleteAll = (ref: OverlayRef) => {
openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), {
confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(),
cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(),
title: ControlGroupStrings.management.deleteControls.getDeleteAllTitle(),
buttonColor: 'danger',
}).then((confirmed) => {
if (confirmed)
Object.keys(this.getInput().panels).forEach((panelId) => this.removeEmbeddable(panelId));
ref.close();
});
};
const flyoutInstance = openFlyout(
toMountPoint(
<ControlGroupContainerContext.Provider value={this}>
<ControlGroupEditor
initialInput={this.getInput()}
updateInput={(changes) => this.updateInput(changes)}
controlCount={Object.keys(this.getInput().panels ?? {}).length}
onDeleteAll={() => onDeleteAll(flyoutInstance)}
onClose={() => flyoutInstance.close()}
/>
</ControlGroupContainerContext.Provider>,
{ theme, i18n }
),
{
size: 's',
'aria-label': ControlGroupStrings.manageControl.getFlyoutCreateTitle(),
outsideClickCloses: false,
onClose: () => {
this.closeAllFlyouts();
},
}
);
setFlyoutRef(flyoutInstance);
}

View file

@ -1,141 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { Subject } from 'rxjs';
import { memoize } from 'lodash';
import { Filter } from '@kbn/es-query';
import deepEqual from 'fast-deep-equal';
import { EmbeddableContainerSettings, isErrorEmbeddable } from '@kbn/embeddable-plugin/public';
import { ControlEmbeddable } from '../../types';
import {
ControlGroupChainingSystem,
ControlGroupInput,
ControlsPanels,
} from '../../../common/control_group/types';
import { TimeSlice } from '../../../common/types';
interface GetPrecedingFiltersProps {
id: string;
childOrder: ChildEmbeddableOrderCache;
getChild: (id: string) => ControlEmbeddable;
}
interface OnChildChangedProps {
childOutputChangedId: string;
recalculateFilters$: Subject<null>;
childOrder: ChildEmbeddableOrderCache;
getChild: (id: string) => ControlEmbeddable;
}
interface ChainingSystem {
getContainerSettings: (
initialInput: ControlGroupInput
) => EmbeddableContainerSettings | undefined;
getPrecedingFilters: (
props: GetPrecedingFiltersProps
) => { filters: Filter[]; timeslice?: TimeSlice } | undefined;
onChildChange: (props: OnChildChangedProps) => void;
}
export interface ChildEmbeddableOrderCache {
IdsToOrder: { [key: string]: number };
idsInOrder: string[];
lastChildId: string;
}
const getOrdersFromPanels = (panels?: ControlsPanels) => {
return Object.values(panels ?? {}).map((panel) => ({
id: panel.explicitInput.id,
order: panel.order,
}));
};
export const controlOrdersAreEqual = (panelsA?: ControlsPanels, panelsB?: ControlsPanels) =>
deepEqual(getOrdersFromPanels(panelsA), getOrdersFromPanels(panelsB));
export const cachedChildEmbeddableOrder = memoize(
(panels: ControlsPanels) => {
const IdsToOrder: { [key: string]: number } = {};
const idsInOrder: string[] = [];
Object.values(panels)
.sort((a, b) => (a.order > b.order ? 1 : -1))
.forEach((panel) => {
IdsToOrder[panel.explicitInput.id] = panel.order;
idsInOrder.push(panel.explicitInput.id);
});
const lastChildId = idsInOrder[idsInOrder.length - 1];
return { IdsToOrder, idsInOrder, lastChildId } as ChildEmbeddableOrderCache;
},
(panels) => JSON.stringify(getOrdersFromPanels(panels))
);
export const ControlGroupChainingSystems: {
[key in ControlGroupChainingSystem]: ChainingSystem;
} = {
HIERARCHICAL: {
getContainerSettings: (initialInput) => ({
childIdInitializeOrder: Object.values(initialInput.panels)
.sort((a, b) => (a.order > b.order ? 1 : -1))
.map((panel) => panel.explicitInput.id),
initializeSequentially: true,
}),
getPrecedingFilters: ({ id, childOrder, getChild }) => {
let filters: Filter[] = [];
let timeslice;
const order = childOrder.IdsToOrder?.[id];
if (!order || order === 0) return { filters, timeslice };
for (let i = 0; i < order; i++) {
const embeddable = getChild(childOrder.idsInOrder[i]);
if (!embeddable || isErrorEmbeddable(embeddable)) return { filters, timeslice };
const embeddableOutput = embeddable.getOutput();
if (embeddableOutput.timeslice) {
timeslice = embeddableOutput.timeslice;
}
filters = [...filters, ...(embeddableOutput.filters ?? [])];
}
return { filters, timeslice };
},
onChildChange: ({ childOutputChangedId, childOrder, recalculateFilters$, getChild }) => {
if (childOutputChangedId === childOrder.lastChildId) {
// the last control's output has updated, recalculate filters
recalculateFilters$.next(null);
return;
}
// when output changes on a child which isn't the last
let nextOrder = childOrder.IdsToOrder[childOutputChangedId] + 1;
while (nextOrder < childOrder.idsInOrder.length) {
const nextControl = getChild(childOrder.idsInOrder[nextOrder]);
// make the next chained embeddable updateInputFromParent
if (nextControl?.isChained?.()) {
setTimeout(
() => nextControl.refreshInputFromParent(),
1 // run on next tick
);
return;
}
// recalculate filters when there are no chained controls to the right of the updated control
if (nextControl.id === childOrder.lastChildId) {
recalculateFilters$.next(null);
return;
}
nextOrder += 1;
}
},
},
NONE: {
getContainerSettings: () => undefined,
getPrecedingFilters: () => undefined,
onChildChange: ({ recalculateFilters$ }) => recalculateFilters$.next(null),
},
};

View file

@ -1,603 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { compareFilters, COMPARE_ALL_OPTIONS, Filter, uniqFilters } from '@kbn/es-query';
import { isEqual, pick } from 'lodash';
import React, { createContext, useContext } from 'react';
import ReactDOM from 'react-dom';
import { batch, Provider, TypedUseSelectorHook, useSelector } from 'react-redux';
import {
BehaviorSubject,
debounceTime,
distinctUntilChanged,
merge,
skip,
Subject,
Subscription,
} from 'rxjs';
import { OverlayRef } from '@kbn/core/public';
import { Container, EmbeddableFactory } from '@kbn/embeddable-plugin/public';
import { ReduxEmbeddableTools, ReduxToolsPackage } from '@kbn/presentation-util-plugin/public';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import {
PersistableControlGroupInput,
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';
import { ControlGroup } from '../component/control_group_component';
import { openAddDataControlFlyout } from '../editor/open_add_data_control_flyout';
import { openEditControlGroupFlyout } from '../editor/open_edit_control_group_flyout';
import {
getDataControlPanelState,
getOptionsListPanelState,
getRangeSliderPanelState,
getTimeSliderPanelState,
type AddDataControlProps,
type AddOptionsListControlProps,
type AddRangeSliderControlProps,
} from '../external_api/control_group_input_builder';
import { startDiffingControlGroupState } from '../state/control_group_diffing_integration';
import { controlGroupReducers } from '../state/control_group_reducers';
import {
ControlGroupComponentState,
ControlGroupFilterOutput,
ControlGroupInput,
ControlGroupOutput,
ControlGroupReduxState,
ControlPanelState,
ControlsPanels,
CONTROL_GROUP_TYPE,
FieldFilterPredicate,
} from '../types';
import {
cachedChildEmbeddableOrder,
ControlGroupChainingSystems,
controlOrdersAreEqual,
} from './control_group_chaining_system';
import { getNextPanelOrder } from './control_group_helpers';
let flyoutRef: OverlayRef | undefined;
export const setFlyoutRef = (newRef: OverlayRef | undefined) => {
flyoutRef = newRef;
};
export const ControlGroupContainerContext = createContext<ControlGroupContainer | null>(null);
export const controlGroupSelector = useSelector as TypedUseSelectorHook<ControlGroupReduxState>;
export const useControlGroupContainer = (): ControlGroupContainer => {
const controlGroup = useContext<ControlGroupContainer | null>(ControlGroupContainerContext);
if (controlGroup == null) {
throw new Error('useControlGroupContainer must be used inside ControlGroupContainerContext.');
}
return controlGroup!;
};
type ControlGroupReduxEmbeddableTools = ReduxEmbeddableTools<
ControlGroupReduxState,
typeof controlGroupReducers
>;
export class ControlGroupContainer extends Container<
ControlInput,
ControlGroupInput,
ControlGroupOutput
> {
public readonly type = CONTROL_GROUP_TYPE;
public readonly anyControlOutputConsumerLoading$: Subject<boolean> = new Subject();
private initialized$ = new BehaviorSubject(false);
private storageService: ControlsStorageService;
private subscriptions: Subscription = new Subscription();
private domNode?: HTMLElement;
private recalculateFilters$: Subject<null>;
private relevantDataViewId?: string;
private lastUsedDataViewId?: string;
private invalidSelectionsState: { [childId: string]: boolean } = {};
public diffingSubscription: Subscription = new Subscription();
// state management
public select: ControlGroupReduxEmbeddableTools['select'];
public getState: ControlGroupReduxEmbeddableTools['getState'];
public dispatch: ControlGroupReduxEmbeddableTools['dispatch'];
public onStateChange: ControlGroupReduxEmbeddableTools['onStateChange'];
private store: ControlGroupReduxEmbeddableTools['store'];
private cleanupStateTools: () => void;
public onFiltersPublished$: Subject<Filter[]>;
public onControlRemoved$: Subject<string>;
/** This currently reports the **entire** persistable control group input on unsaved changes */
public unsavedChanges: BehaviorSubject<PersistableControlGroupInput | undefined>;
public fieldFilterPredicate: FieldFilterPredicate | undefined;
constructor(
reduxToolsPackage: ReduxToolsPackage,
initialInput: ControlGroupInput,
parent?: Container,
initialComponentState?: ControlGroupComponentState,
fieldFilterPredicate?: FieldFilterPredicate
) {
super(
initialInput,
{ dataViewIds: [], embeddableLoaded: {}, filters: [] },
pluginServices.getServices().controls.getControlFactory,
parent,
ControlGroupChainingSystems[initialInput.chainingSystem]?.getContainerSettings(initialInput)
);
({ storage: this.storageService } = pluginServices.getServices());
this.recalculateFilters$ = new Subject();
this.onFiltersPublished$ = new Subject<Filter[]>();
this.onControlRemoved$ = new Subject<string>();
// start diffing control group state
this.unsavedChanges = new BehaviorSubject<PersistableControlGroupInput | undefined>(undefined);
const diffingMiddleware = startDiffingControlGroupState.bind(this)();
// build redux embeddable tools
const reduxEmbeddableTools = reduxToolsPackage.createReduxEmbeddableTools<
ControlGroupReduxState,
typeof controlGroupReducers
>({
embeddable: this,
reducers: controlGroupReducers,
additionalMiddleware: [diffingMiddleware],
initialComponentState,
});
this.select = reduxEmbeddableTools.select;
this.getState = reduxEmbeddableTools.getState;
this.dispatch = reduxEmbeddableTools.dispatch;
this.cleanupStateTools = reduxEmbeddableTools.cleanup;
this.onStateChange = reduxEmbeddableTools.onStateChange;
this.store = reduxEmbeddableTools.store;
// when all children are ready setup subscriptions
this.untilAllChildrenReady().then(() => {
this.invalidSelectionsState = this.getChildIds().reduce((prev, id) => {
return { ...prev, [id]: false };
}, {});
this.recalculateDataViews();
this.setupSubscriptions();
const { filters, timeslice } = this.recalculateFilters();
this.publishFilters({ filters, timeslice });
this.calculateFiltersFromSelections(initialComponentState?.lastSavedInput?.panels ?? {}).then(
(filterOutput) => {
this.dispatch.setLastSavedFilters(filterOutput);
}
);
this.initialized$.next(true);
});
this.fieldFilterPredicate = fieldFilterPredicate;
}
public canShowInvalidSelectionsWarning = () =>
this.storageService.getShowInvalidSelectionWarning() ?? true;
public suppressInvalidSelectionsWarning = () => {
this.storageService.setShowInvalidSelectionWarning(false);
};
public reportInvalidSelections = ({
id,
hasInvalidSelections,
}: {
id: string;
hasInvalidSelections: boolean;
}) => {
this.invalidSelectionsState = { ...this.invalidSelectionsState, [id]: hasInvalidSelections };
const childrenWithInvalidSelections = cachedChildEmbeddableOrder(
this.getInput().panels
).idsInOrder.filter((childId) => {
return this.invalidSelectionsState[childId];
});
this.dispatch.setControlWithInvalidSelectionsId(
childrenWithInvalidSelections.length > 0 ? childrenWithInvalidSelections[0] : undefined
);
};
private setupSubscriptions = () => {
/**
* refresh control order cache and make all panels refreshInputFromParent whenever panel orders change
*/
this.subscriptions.add(
this.getInput$()
.pipe(
skip(1),
distinctUntilChanged((a, b) => controlOrdersAreEqual(a.panels, b.panels))
)
.subscribe((input) => {
this.recalculateDataViews();
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(
distinctUntilChanged(
(a, b) => Boolean(a.showApplySelections) === Boolean(b.showApplySelections)
),
skip(1)
)
.subscribe(() => {
const { filters, timeslice } = this.recalculateFilters();
this.publishFilters({ filters, timeslice });
})
);
/**
* run OnChildOutputChanged when any child's output has changed
*/
this.subscriptions.add(
this.getAnyChildOutputChange$().subscribe((childOutputChangedId) => {
this.recalculateDataViews();
ControlGroupChainingSystems[this.getInput().chainingSystem].onChildChange({
childOutputChangedId,
childOrder: cachedChildEmbeddableOrder(this.getInput().panels),
getChild: (id) => this.getChild(id),
recalculateFilters$: this.recalculateFilters$,
});
})
);
/**
* debounce output recalculation
*/
this.subscriptions.add(
this.recalculateFilters$.pipe(debounceTime(10)).subscribe(() => {
const { filters, timeslice } = this.recalculateFilters();
this.tryPublishFilters({ filters, timeslice });
})
);
};
public setSavedState(lastSavedInput: PersistableControlGroupInput | undefined): void {
this.calculateFiltersFromSelections(lastSavedInput?.panels ?? {}).then((filterOutput) => {
batch(() => {
this.dispatch.setLastSavedInput(lastSavedInput);
this.dispatch.setLastSavedFilters(filterOutput);
});
});
}
public resetToLastSavedState() {
const {
explicitInput: { showApplySelections: currentShowApplySelections },
componentState: { lastSavedInput },
} = this.getState();
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
}
}
public reload() {
super.reload();
}
public getPersistableInput: () => PersistableControlGroupInput & { id: string } = () => {
const input = this.getInput();
return pick(input, [...persistableControlGroupInputKeys, 'id']);
};
public updateInputAndReinitialize = (newInput: Partial<ControlGroupInput>) => {
this.subscriptions.unsubscribe();
this.subscriptions = new Subscription();
this.initialized$.next(false);
this.updateInput(newInput);
this.untilAllChildrenReady().then(() => {
this.dispatch.setControlWithInvalidSelectionsId(undefined);
this.invalidSelectionsState = this.getChildIds().reduce((prev, id) => {
return { ...prev, [id]: false };
}, {});
this.recalculateDataViews();
const { filters, timeslice } = this.recalculateFilters();
this.publishFilters({ filters, timeslice });
this.setupSubscriptions();
this.initialized$.next(true);
});
};
public setLastUsedDataViewId = (lastUsedDataViewId: string) => {
this.lastUsedDataViewId = lastUsedDataViewId;
};
public setRelevantDataViewId = (newRelevantDataViewId: string) => {
this.relevantDataViewId = newRelevantDataViewId;
};
public getMostRelevantDataViewId = () => {
return this.lastUsedDataViewId ?? this.relevantDataViewId;
};
public closeAllFlyouts() {
flyoutRef?.close();
flyoutRef = undefined;
}
public async addDataControlFromField(controlProps: AddDataControlProps) {
const panelState = await getDataControlPanelState(this.getInput(), controlProps);
return this.createAndSaveEmbeddable(panelState.type, panelState, this.getInput().panels);
}
public addOptionsListControl(controlProps: AddOptionsListControlProps) {
const panelState = getOptionsListPanelState(this.getInput(), controlProps);
return this.createAndSaveEmbeddable(panelState.type, panelState, this.getInput().panels);
}
public addRangeSliderControl(controlProps: AddRangeSliderControlProps) {
const panelState = getRangeSliderPanelState(this.getInput(), controlProps);
return this.createAndSaveEmbeddable(panelState.type, panelState, this.getInput().panels);
}
public addTimeSliderControl() {
const panelState = getTimeSliderPanelState(this.getInput());
return this.createAndSaveEmbeddable(panelState.type, panelState, this.getInput().panels);
}
public openAddDataControlFlyout = openAddDataControlFlyout;
public openEditControlGroupFlyout = openEditControlGroupFlyout;
public getPanelCount = () => {
return Object.keys(this.getInput().panels).length;
};
public updateFilterContext = (filters: Filter[]) => {
this.updateInput({ filters });
};
private recalculateFilters = (): ControlGroupFilterOutput => {
const allFilters: Filter[] = [];
let timeslice;
const controlChildren = Object.values(this.children$.value) as ControlEmbeddable[];
controlChildren.map((child: ControlEmbeddable) => {
const childOutput = child.getOutput() as ControlOutput;
allFilters.push(...(childOutput?.filters ?? []));
if (childOutput.timeslice) {
timeslice = childOutput.timeslice;
}
});
return { filters: uniqFilters(allFilters), timeslice };
};
private async calculateFiltersFromSelections(
panels: PersistableControlGroupInput['panels']
): Promise<ControlGroupFilterOutput> {
let filtersArray: Filter[] = [];
let timeslice;
const controlChildren = Object.values(this.children$.value) as ControlEmbeddable[];
await Promise.all(
controlChildren.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 ?? [], filters ?? [], COMPARE_ALL_OPTIONS) ||
!isEqual(this.output.timeslice, timeslice)
) {
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();
const controlChildren = Object.values(this.children$.value) as ControlEmbeddable[];
controlChildren.map((child) => {
const dataViewId = (child.getOutput() as ControlOutput).dataViewId;
if (dataViewId) allDataViewIds.add(dataViewId);
});
this.updateOutput({ dataViewIds: Array.from(allDataViewIds) });
};
protected createNewPanelState<TEmbeddableInput extends ControlInput = ControlInput>(
factory: EmbeddableFactory<ControlInput, ControlOutput, ControlEmbeddable>,
partial: Partial<TEmbeddableInput> = {},
otherPanels: ControlGroupInput['panels']
) {
const { newPanel } = super.createNewPanelState(factory, partial);
return {
newPanel: {
order: getNextPanelOrder(this.getInput().panels),
width: this.getInput().defaultControlWidth,
grow: this.getInput().defaultControlGrow,
...newPanel,
} as ControlPanelState<TEmbeddableInput>,
otherPanels,
};
}
public removePanel(id: string): void {
/** TODO: This is a temporary wrapper until the control group refactor is complete */
super.removeEmbeddable(id);
}
protected onRemoveEmbeddable(idToRemove: string) {
const newPanels = super.onRemoveEmbeddable(idToRemove) as ControlsPanels;
const childOrderCache = cachedChildEmbeddableOrder(this.getInput().panels);
const removedOrder = childOrderCache.IdsToOrder[idToRemove];
for (let i = removedOrder + 1; i < childOrderCache.idsInOrder.length; i++) {
const currentOrder = newPanels[childOrderCache.idsInOrder[i]].order;
newPanels[childOrderCache.idsInOrder[i]] = {
...newPanels[childOrderCache.idsInOrder[i]],
order: currentOrder - 1,
};
}
this.onControlRemoved$.next(idToRemove);
return newPanels;
}
protected getInheritedInput(id: string): ControlInput {
const { filters, query, ignoreParentSettings, timeRange, chainingSystem, panels } =
this.getInput();
const precedingFilters = ControlGroupChainingSystems[chainingSystem].getPrecedingFilters({
id,
childOrder: cachedChildEmbeddableOrder(panels),
getChild: (getChildId: string) => this.getChild<ControlEmbeddable>(getChildId),
});
const allFilters = [
...(ignoreParentSettings?.ignoreFilters ? [] : filters ?? []),
...(precedingFilters?.filters ?? []),
];
return {
ignoreParentSettings,
filters: allFilters,
query: ignoreParentSettings?.ignoreQuery ? undefined : query,
timeRange: ignoreParentSettings?.ignoreTimerange ? undefined : timeRange,
timeslice: ignoreParentSettings?.ignoreTimerange ? undefined : precedingFilters?.timeslice,
id,
};
}
public untilAllChildrenReady = () => {
const panelsLoading = () =>
Object.keys(this.getInput().panels).some(
(panelId) => !this.getOutput().embeddableLoaded[panelId]
);
if (panelsLoading()) {
return new Promise<void>((resolve, reject) => {
const subscription = merge(this.getOutput$(), this.getInput$()).subscribe(() => {
if (this.destroyed) {
subscription.unsubscribe();
reject();
}
if (!panelsLoading()) {
subscription.unsubscribe();
resolve();
}
});
});
}
return Promise.resolve();
};
public untilInitialized = () => {
if (this.initialized$.value === false) {
return new Promise<void>((resolve, reject) => {
const subscription = this.initialized$.subscribe((isInitialized) => {
if (this.destroyed) {
subscription.unsubscribe();
reject();
}
if (isInitialized) {
subscription.unsubscribe();
resolve();
}
});
});
}
return Promise.resolve();
};
public render(dom: HTMLElement) {
if (this.domNode) {
ReactDOM.unmountComponentAtNode(this.domNode);
}
this.domNode = dom;
ReactDOM.render(
<KibanaRenderContextProvider {...pluginServices.getServices().core}>
<Provider store={this.store}>
<ControlGroupContainerContext.Provider value={this}>
<ControlGroup />
</ControlGroupContainerContext.Provider>
</Provider>
</KibanaRenderContextProvider>,
dom
);
}
public destroy() {
super.destroy();
this.closeAllFlyouts();
this.subscriptions.unsubscribe();
this.cleanupStateTools();
if (this.domNode) ReactDOM.unmountComponentAtNode(this.domNode);
}
}

View file

@ -1,66 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { i18n } from '@kbn/i18n';
import { Container, EmbeddableFactoryDefinition } from '@kbn/embeddable-plugin/public';
import { lazyLoadReduxToolsPackage } from '@kbn/presentation-util-plugin/public';
import { EmbeddablePersistableStateService } from '@kbn/embeddable-plugin/common';
import {
ControlGroupComponentState,
ControlGroupInput,
CONTROL_GROUP_TYPE,
FieldFilterPredicate,
} from '../types';
import {
createControlGroupExtract,
createControlGroupInject,
} from '../../../common/control_group/control_group_persistable_state';
import { getDefaultControlGroupInput } from '../../../common';
export class ControlGroupContainerFactory implements EmbeddableFactoryDefinition {
public readonly isContainerType = true;
public readonly type = CONTROL_GROUP_TYPE;
public inject: EmbeddablePersistableStateService['inject'];
public extract: EmbeddablePersistableStateService['extract'];
constructor(private persistableStateService: EmbeddablePersistableStateService) {
this.inject = createControlGroupInject(this.persistableStateService);
this.extract = createControlGroupExtract(this.persistableStateService);
}
public isEditable = async () => false;
public readonly getDisplayName = () => {
return i18n.translate('controls.controlGroup.title', {
defaultMessage: 'Control group',
});
};
public getDefaultInput(): Partial<ControlGroupInput> {
return getDefaultControlGroupInput();
}
public create = async (
initialInput: ControlGroupInput,
parent?: Container,
initialComponentState?: ControlGroupComponentState,
fieldFilterPredicate?: FieldFilterPredicate
) => {
const reduxEmbeddablePackage = await lazyLoadReduxToolsPackage();
const { ControlGroupContainer } = await import('./control_group_container');
return new ControlGroupContainer(
reduxEmbeddablePackage,
initialInput,
parent,
initialComponentState,
fieldFilterPredicate
);
};
}

View file

@ -1,45 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { type IEmbeddable } from '@kbn/embeddable-plugin/public';
import { getDataControlFieldRegistry } from '../editor/data_control_editor_tools';
import { type ControlGroupContainer } from './control_group_container';
import { pluginServices } from '../../services';
import { CONTROL_GROUP_TYPE } from '../types';
import { ControlsPanels } from '../types';
export const getNextPanelOrder = (panels?: ControlsPanels) => {
let nextOrder = 0;
if (Object.keys(panels ?? {}).length > 0) {
nextOrder =
Object.values(panels ?? {}).reduce((highestSoFar, panel) => {
if (panel.order > highestSoFar) highestSoFar = panel.order;
return highestSoFar;
}, 0) + 1;
}
return nextOrder;
};
export const getCompatibleControlType = async ({
dataViewId,
fieldName,
}: {
dataViewId: string;
fieldName: string;
}) => {
const dataView = await pluginServices.getServices().dataViews.get(dataViewId);
const fieldRegistry = await getDataControlFieldRegistry(dataView);
const field = fieldRegistry[fieldName];
return field.compatibleControlTypes[0];
};
export const isControlGroup = (embeddable: IEmbeddable): embeddable is ControlGroupContainer => {
return embeddable.isContainer && embeddable.type === CONTROL_GROUP_TYPE;
};

View file

@ -1,160 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { i18n } from '@kbn/i18n';
import { v4 as uuidv4 } from 'uuid';
import {
ControlPanelState,
ControlWidth,
OptionsListEmbeddableInput,
OPTIONS_LIST_CONTROL,
TIME_SLIDER_CONTROL,
} from '../../../common';
import {
DEFAULT_CONTROL_GROW,
DEFAULT_CONTROL_WIDTH,
} from '../../../common/control_group/control_group_constants';
import { ControlGroupInput } from '../types';
import { ControlInput, DataControlInput } from '../../types';
import { RangeValue, RANGE_SLIDER_CONTROL } from '../../../common/range_slider/types';
import { getCompatibleControlType, getNextPanelOrder } from '../embeddable/control_group_helpers';
export interface AddDataControlProps {
controlId?: string;
dataViewId: string;
fieldName: string;
grow?: boolean;
title?: string;
width?: ControlWidth;
}
export type AddOptionsListControlProps = AddDataControlProps & Partial<OptionsListEmbeddableInput>;
export type AddRangeSliderControlProps = AddDataControlProps & {
value?: RangeValue;
};
export type ControlGroupInputBuilder = typeof controlGroupInputBuilder;
export const controlGroupInputBuilder = {
addDataControlFromField: async (
initialInput: Partial<ControlGroupInput>,
controlProps: AddDataControlProps
) => {
const panelState = await getDataControlPanelState(initialInput, controlProps);
initialInput.panels = {
...initialInput.panels,
[panelState.explicitInput.id]: panelState,
};
},
addOptionsListControl: (
initialInput: Partial<ControlGroupInput>,
controlProps: AddOptionsListControlProps
) => {
const panelState = getOptionsListPanelState(initialInput, controlProps);
initialInput.panels = {
...initialInput.panels,
[panelState.explicitInput.id]: panelState,
};
},
addRangeSliderControl: (
initialInput: Partial<ControlGroupInput>,
controlProps: AddRangeSliderControlProps
) => {
const panelState = getRangeSliderPanelState(initialInput, controlProps);
initialInput.panels = {
...initialInput.panels,
[panelState.explicitInput.id]: panelState,
};
},
addTimeSliderControl: (initialInput: Partial<ControlGroupInput>) => {
const panelState = getTimeSliderPanelState(initialInput);
initialInput.panels = {
...initialInput.panels,
[panelState.explicitInput.id]: panelState,
};
},
};
export async function getDataControlPanelState(
input: Partial<ControlGroupInput>,
controlProps: AddDataControlProps
) {
const { controlId, dataViewId, fieldName, title } = controlProps;
return {
type: await getCompatibleControlType({ dataViewId, fieldName }),
...getPanelState(input, controlProps),
explicitInput: {
id: controlId ? controlId : uuidv4(),
dataViewId,
fieldName,
title: title ?? fieldName,
},
} as ControlPanelState<DataControlInput>;
}
export function getOptionsListPanelState(
input: Partial<ControlGroupInput>,
controlProps: AddOptionsListControlProps
) {
const { controlId, dataViewId, fieldName, title, ...rest } = controlProps;
return {
type: OPTIONS_LIST_CONTROL,
...getPanelState(input, controlProps),
explicitInput: {
id: controlId ? controlId : uuidv4(),
dataViewId,
fieldName,
title: title ?? fieldName,
...rest,
},
} as ControlPanelState<DataControlInput>;
}
export function getRangeSliderPanelState(
input: Partial<ControlGroupInput>,
controlProps: AddRangeSliderControlProps
) {
const { controlId, dataViewId, fieldName, title, ...rest } = controlProps;
return {
type: RANGE_SLIDER_CONTROL,
...getPanelState(input, controlProps),
explicitInput: {
id: controlId ? controlId : uuidv4(),
dataViewId,
fieldName,
title: title ?? fieldName,
...rest,
},
} as ControlPanelState<DataControlInput>;
}
export function getTimeSliderPanelState(input: Partial<ControlGroupInput>) {
return {
type: TIME_SLIDER_CONTROL,
order: getNextPanelOrder(input.panels),
grow: true,
width: 'large',
explicitInput: {
id: uuidv4(),
title: i18n.translate('controls.controlGroup.timeSlider.title', {
defaultMessage: 'Time slider',
}),
},
} as ControlPanelState<ControlInput>;
}
function getPanelState(input: Partial<ControlGroupInput>, controlProps: AddDataControlProps) {
return {
order: getNextPanelOrder(input.panels),
grow: controlProps.grow ?? input.defaultControlGrow ?? DEFAULT_CONTROL_GROW,
width: controlProps.width ?? input.defaultControlWidth ?? DEFAULT_CONTROL_WIDTH,
};
}

View file

@ -1,25 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export type { ControlGroupContainer } from './embeddable/control_group_container';
export type { ControlGroupInput, ControlGroupOutput } from './types';
export { ControlGroupContainerFactory } from './embeddable/control_group_container_factory';
export { CONTROL_GROUP_TYPE } from './types';
export { ACTION_DELETE_CONTROL, ACTION_EDIT_CONTROL } from './actions';
export { controlGroupInputBuilder } from './external_api/control_group_input_builder';
export { ControlGroupRenderer } from './external_api';
export type {
ControlGroupRendererApi,
ControlGroupRendererProps,
ControlGroupCreationOptions,
} from './external_api';

View file

@ -1,79 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server 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';
import { controlGroupReducers } from './control_group_reducers';
/**
* An array of reducers which cannot cause unsaved changes. Unsaved changes only compares the explicit input
* and the last saved input, so we can safely ignore any output reducers, and most componentState reducers.
* This is only for performance reasons, because the diffing function itself can be quite heavy.
*/
export const reducersToIgnore: Array<keyof typeof controlGroupReducers> = [
'setDefaultControlWidth',
'setDefaultControlGrow',
];
/**
* Does an initial diff between @param initialInput and @param initialLastSavedInput, and created a middleware
* which listens to the redux store and checks for & publishes the unsaved changes on dispatches.
*/
export function startDiffingControlGroupState(this: ControlGroupContainer) {
const checkForUnsavedChangesSubject$ = new Subject<null>();
this.diffingSubscription.add(
checkForUnsavedChangesSubject$
.pipe(
startWith(null),
debounceTime(CHANGE_CHECK_DEBOUNCE),
switchMap(() => {
return new Observable((observer) => {
if (observer.closed) return;
const {
explicitInput: currentInput,
componentState: { lastSavedInput, lastSavedFilters },
output: { filters, timeslice },
} = this.getState();
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);
});
})
)
.subscribe()
);
const diffingMiddleware: Middleware<AnyAction> = (store) => (next) => (action) => {
const dispatchedActionName = action.type.split('/')?.[1];
if (
dispatchedActionName &&
dispatchedActionName !== 'updateEmbeddableReduxOutput' && // ignore any generic output updates.
!reducersToIgnore.includes(dispatchedActionName)
) {
checkForUnsavedChangesSubject$.next(null);
}
next(action);
};
return diffingMiddleware;
}

View file

@ -1,85 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { PayloadAction } from '@reduxjs/toolkit';
import { WritableDraft } from 'immer/dist/types/types-external';
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;
},
setLastSavedFilters: (
state: WritableDraft<ControlGroupReduxState>,
action: PayloadAction<ControlGroupComponentState['lastSavedFilters']>
) => {
state.componentState.lastSavedFilters = action.payload;
},
setUnpublishedFilters: (
state: WritableDraft<ControlGroupReduxState>,
action: PayloadAction<ControlGroupComponentState['unpublishedFilters']>
) => {
state.componentState.unpublishedFilters = action.payload;
},
setControlStyle: (
state: WritableDraft<ControlGroupReduxState>,
action: PayloadAction<ControlGroupInput['controlStyle']>
) => {
state.explicitInput.controlStyle = action.payload;
},
setChainingSystem: (
state: WritableDraft<ControlGroupReduxState>,
action: PayloadAction<ControlGroupInput['chainingSystem']>
) => {
state.explicitInput.chainingSystem = action.payload;
},
setDefaultControlWidth: (
state: WritableDraft<ControlGroupReduxState>,
action: PayloadAction<ControlGroupInput['defaultControlWidth']>
) => {
state.explicitInput.defaultControlWidth = action.payload;
},
setDefaultControlGrow: (
state: WritableDraft<ControlGroupReduxState>,
action: PayloadAction<ControlGroupInput['defaultControlGrow']>
) => {
state.explicitInput.defaultControlGrow = action.payload;
},
setControlWidth: (
state: WritableDraft<ControlGroupReduxState>,
action: PayloadAction<{ width: ControlWidth; embeddableId: string }>
) => {
state.explicitInput.panels[action.payload.embeddableId].width = action.payload.width;
},
setControlGrow: (
state: WritableDraft<ControlGroupReduxState>,
action: PayloadAction<{ grow: boolean; embeddableId: string }>
) => {
state.explicitInput.panels[action.payload.embeddableId].grow = action.payload.grow;
},
setControlOrders: (
state: WritableDraft<ControlGroupReduxState>,
action: PayloadAction<{ ids: string[] }>
) => {
action.payload.ids.forEach((id, index) => {
state.explicitInput.panels[id].order = index;
});
},
};

View file

@ -1,61 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
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 { TimeSlice } from '../../common/types';
export interface ControlFilterOutput {
filters?: Filter[];
}
export interface ControlTimesliceOutput {
timeslice?: TimeSlice;
}
export type ControlGroupFilterOutput = ControlFilterOutput & ControlTimesliceOutput;
export type ControlGroupOutput = ContainerOutput &
ControlGroupFilterOutput & { dataViewIds: string[] };
// public only - redux embeddable state type
export type ControlGroupReduxState = ReduxEmbeddableState<
ControlGroupInput,
ControlGroupOutput,
ControlGroupComponentState
>;
export type FieldFilterPredicate = (f: DataViewField) => boolean;
export interface ControlGroupSettings {
showAddButton?: boolean;
staticDataViewId?: string;
editorConfig?: {
hideDataViewSelector?: boolean;
hideWidthSettings?: boolean;
hideAdditionalSettings?: boolean;
};
}
export type ControlGroupComponentState = ControlGroupSettings & {
lastSavedInput?: PersistableControlGroupInput;
lastSavedFilters?: ControlGroupFilterOutput;
unpublishedFilters?: ControlGroupFilterOutput;
controlWithInvalidSelectionsId?: string;
};
export {
CONTROL_GROUP_TYPE,
type ControlGroupInput,
type ControlPanelState,
type ControlsPanels,
} from '../../common/control_group/types';

View file

@ -1,37 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { useEffect, useState } from 'react';
import { ControlEmbeddable } from '../types';
export const useChildEmbeddable = ({
untilEmbeddableLoaded,
embeddableId,
embeddableType,
}: {
untilEmbeddableLoaded: (embeddableId: string) => Promise<ControlEmbeddable>;
embeddableId: string;
embeddableType: string;
}) => {
const [embeddable, setEmbeddable] = useState<ControlEmbeddable>();
useEffect(() => {
let mounted = true;
(async () => {
const newEmbeddable = await untilEmbeddableLoaded(embeddableId);
if (!mounted) return;
setEmbeddable(newEmbeddable);
})();
return () => {
mounted = false;
};
}, [untilEmbeddableLoaded, embeddableId, embeddableType]);
return embeddable;
};

View file

@ -1,45 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { useEffect, useState } from 'react';
import { FieldSpec } from '@kbn/data-views-plugin/common';
import { pluginServices } from '../services';
export const useFieldFormatter = ({
dataViewId,
fieldSpec,
}: {
dataViewId?: string;
fieldSpec?: FieldSpec;
}) => {
const {
dataViews: { get: getDataViewById },
} = pluginServices.getServices();
const [fieldFormatter, setFieldFormatter] = useState(() => (toFormat: any) => String(toFormat));
/**
* derive field formatter from fieldSpec and dataViewId
*/
useEffect(() => {
(async () => {
if (!dataViewId || !fieldSpec) return;
// dataViews are cached, and should always be available without having to hit ES.
const dataView = await getDataViewById(dataViewId);
setFieldFormatter(
() =>
dataView?.getFormatterForField(fieldSpec).getConverterFor('text') ??
((toFormat: any) => String(toFormat))
);
})();
}, [fieldSpec, dataViewId, getDataViewById]);
return fieldFormatter;
};

View file

@ -9,41 +9,27 @@
import { ControlsPlugin } from './plugin';
export type {
ControlGroupApi,
ControlGroupRuntimeState,
ControlGroupSerializedState,
ControlStateTransform,
ControlPanelState,
} from './react_controls/control_group/types';
export {
controlGroupStateBuilder,
type ControlGroupStateBuilder,
} from './react_controls/control_group/utils/control_group_state_builder';
export type { ControlGroupApi, ControlStateTransform } from './react_controls/control_group/types';
export { ACTION_CLEAR_CONTROL, ACTION_DELETE_CONTROL, ACTION_EDIT_CONTROL } from './actions';
export type {
DataControlApi,
DefaultDataControlState,
DataControlFactory,
DataControlServices,
} from './react_controls/controls/data_controls/types';
export type { OptionsListControlState } from './react_controls/controls/data_controls/options_list_control/types';
export { ACTION_EDIT_CONTROL } from './react_controls/actions/edit_control_action/edit_control_action';
export {
ACTION_DELETE_CONTROL,
ControlGroupRenderer,
type ControlGroupRendererProps,
type ControlGroupRendererApi,
type ControlGroupCreationOptions,
} from './control_group';
export type { ControlWidth, ControlStyle } from '../common/types';
/**
* TODO: remove all exports below this when control group embeddable is removed
*/
type ControlGroupRendererApi,
type ControlGroupRendererProps,
} from './react_controls/external_api';
export {
CONTROL_GROUP_TYPE,
@ -51,14 +37,14 @@ export {
RANGE_SLIDER_CONTROL,
TIME_SLIDER_CONTROL,
} from '../common';
export {
type ControlGroupContainer,
ControlGroupContainerFactory,
type ControlGroupInput,
type ControlGroupOutput,
controlGroupInputBuilder,
} from './control_group';
export type {
ControlGroupRuntimeState,
ControlGroupSerializedState,
ControlPanelState,
ControlPanelsState,
DefaultDataControlState,
} from '../common';
export type { OptionsListControlState } from '../common/options_list';
export function plugin() {
return new ControlsPlugin();

View file

@ -1,100 +0,0 @@
.optionsList--filterGroup {
width: 100%;
box-shadow: none;
background-color: transparent;
.optionsList__inputButtonOverride {
max-inline-size: none; // overwrite the default `max-inline-size` that's coming from EUI
}
.optionsList--filterBtn {
height: $euiButtonHeightSmall;
&.optionsList--filterBtnPlaceholder {
color: $euiTextSubduedColor;
font-weight: $euiFontWeightRegular;
}
.optionsList__selections {
overflow: hidden !important;
}
.optionsList__filter {
font-weight: $euiFontWeightMedium;
}
.optionsList__filterInvalid {
color: $euiColorWarningText;
}
.optionsList__negateLabel {
font-weight: $euiFontWeightSemiBold;
font-size: $euiSizeM;
color: $euiColorDanger;
}
.optionsList--selectionText {
flex-grow: 1;
text-align: left;
}
}
}
.optionsList--sortPopover {
width: $euiSizeXL * 7;
}
.optionsList__existsFilter {
font-style: italic;
font-weight: $euiFontWeightMedium;
}
.optionsList__popoverOverride {
@include euiBottomShadowMedium;
filter: none; // overwrite the default popover shadow
}
.optionsList__popover {
.optionsList__actions {
padding: 0 $euiSizeS;
border-bottom: $euiBorderThin;
border-color: darken($euiColorLightestShade, 2%);
.optionsList__searchRow {
padding-top: $euiSizeS
}
.optionsList__actionsRow {
margin: calc($euiSizeS / 2) 0 !important;
.optionsList__actionBarDivider {
height: $euiSize;
border-right: $euiBorderThin;
}
}
}
.optionsList-control-ignored-selection-title {
padding-left: $euiSizeM;
}
.optionsList__selectionInvalid {
color: $euiColorWarningText;
}
.optionslist--loadingMoreGroupLabel {
text-align: center;
padding: $euiSizeM;
font-style: italic;
height: $euiSizeXXL !important;
}
.optionslist--endOfOptionsGroupLabel {
text-align: center;
font-size: $euiSizeM;
height: auto !important;
color: $euiTextSubduedColor;
padding: $euiSizeM;
}
}

View file

@ -1,62 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { render } from '@testing-library/react';
import { OptionsListEmbeddableContext } from '../embeddable/options_list_embeddable';
import { OptionsListComponentState, OptionsListReduxState } from '../types';
import { mockOptionsListEmbeddable } from '../../../common/mocks';
import { OptionsListControl } from './options_list_control';
import { BehaviorSubject } from 'rxjs';
import { OptionsListEmbeddableInput } from '..';
import { ControlOutput } from '../../types';
describe('Options list control', () => {
const defaultProps = {
typeaheadSubject: new BehaviorSubject(''),
loadMoreSubject: new BehaviorSubject(10),
};
interface MountOptions {
componentState: Partial<OptionsListComponentState>;
explicitInput: Partial<OptionsListEmbeddableInput>;
output: Partial<ControlOutput>;
}
async function mountComponent(options?: Partial<MountOptions>) {
const optionsListEmbeddable = await mockOptionsListEmbeddable({
componentState: options?.componentState ?? {},
explicitInput: options?.explicitInput ?? {},
output: options?.output ?? {},
} as Partial<OptionsListReduxState>);
return render(
<OptionsListEmbeddableContext.Provider value={optionsListEmbeddable}>
<OptionsListControl {...defaultProps} />
</OptionsListEmbeddableContext.Provider>
);
}
test('if exclude = false and existsSelected = true, then the option should read "Exists"', async () => {
const control = await mountComponent({
explicitInput: { id: 'testExists', exclude: false, existsSelected: true },
});
const existsOption = control.getByTestId('optionsList-control-testExists');
expect(existsOption).toHaveTextContent('Exists');
});
test('if exclude = true and existsSelected = true, then the option should read "Does not exist"', async () => {
const control = await mountComponent({
explicitInput: { id: 'testDoesNotExist', exclude: true, existsSelected: true },
});
const existsOption = control.getByTestId('optionsList-control-testDoesNotExist');
expect(existsOption).toHaveTextContent('DOES NOT Exist');
});
});

View file

@ -1,245 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import classNames from 'classnames';
import { debounce, isEmpty } from 'lodash';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Subject } from 'rxjs';
import {
EuiFilterButton,
EuiFilterGroup,
EuiFlexGroup,
EuiFlexItem,
EuiInputPopover,
EuiToken,
EuiToolTip,
htmlIdGenerator,
} from '@elastic/eui';
import { OptionsListSelection } from '../../../common/options_list/options_list_selections';
import { MIN_POPOVER_WIDTH } from '../../constants';
import { ControlError } from '../../control_group/component/control_error_component';
import { useFieldFormatter } from '../../hooks/use_field_formatter';
import { useOptionsList } from '../embeddable/options_list_embeddable';
import { MAX_OPTIONS_LIST_REQUEST_SIZE } from '../types';
import { OptionsListPopover } from './options_list_popover';
import { OptionsListStrings } from './options_list_strings';
import './options_list.scss';
export const OptionsListControl = ({
typeaheadSubject,
loadMoreSubject,
}: {
typeaheadSubject: Subject<string>;
loadMoreSubject: Subject<number>;
}) => {
const optionsList = useOptionsList();
const popoverId = useMemo(() => htmlIdGenerator()(), []);
const error = optionsList.select((state) => state.componentState.error);
const isPopoverOpen = optionsList.select((state) => state.componentState.popoverOpen);
const invalidSelections = optionsList.select((state) => state.componentState.invalidSelections);
const fieldSpec = optionsList.select((state) => state.componentState.field);
const id = optionsList.select((state) => state.explicitInput.id);
const exclude = optionsList.select((state) => state.explicitInput.exclude);
const fieldName = optionsList.select((state) => state.explicitInput.fieldName);
const fieldTitle = optionsList.select((state) => state.explicitInput.title);
const placeholder = optionsList.select((state) => state.explicitInput.placeholder);
const controlStyle = optionsList.select((state) => state.explicitInput.controlStyle);
const singleSelect = optionsList.select((state) => state.explicitInput.singleSelect);
const existsSelected = optionsList.select((state) => state.explicitInput.existsSelected);
const selectedOptions = optionsList.select((state) => state.explicitInput.selectedOptions);
const loading = optionsList.select((state) => state.output.loading);
const dataViewId = optionsList.select((state) => state.output.dataViewId);
const fieldFormatter = useFieldFormatter({ dataViewId, fieldSpec });
useEffect(() => {
return () => {
optionsList.dispatch.setPopoverOpen(false); // on unmount, close the popover
};
}, [optionsList]);
// debounce loading state so loading doesn't flash when user types
const [debouncedLoading, setDebouncedLoading] = useState(true);
const debounceSetLoading = useMemo(
() =>
debounce((latestLoading: boolean) => {
setDebouncedLoading(latestLoading);
}, 100),
[]
);
useEffect(() => debounceSetLoading(loading ?? false), [loading, debounceSetLoading]);
// remove all other selections if this control is single select
useEffect(() => {
if (singleSelect && selectedOptions && selectedOptions?.length > 1) {
optionsList.dispatch.replaceSelection(selectedOptions[0]);
}
}, [selectedOptions, singleSelect, optionsList.dispatch]);
const updateSearchString = useCallback(
(newSearchString: string) => {
typeaheadSubject.next(newSearchString);
optionsList.dispatch.setSearchString(newSearchString);
},
[typeaheadSubject, optionsList.dispatch]
);
const loadMoreSuggestions = useCallback(
(cardinality: number) => {
loadMoreSubject.next(Math.min(cardinality, MAX_OPTIONS_LIST_REQUEST_SIZE));
},
[loadMoreSubject]
);
const delimiter = useMemo(
() => OptionsListStrings.control.getSeparator(fieldSpec?.type),
[fieldSpec?.type]
);
const { hasSelections, selectionDisplayNode, selectedOptionsCount } = useMemo(() => {
return {
hasSelections: !isEmpty(selectedOptions),
selectedOptionsCount: selectedOptions?.length,
selectionDisplayNode: (
<EuiFlexGroup alignItems="center" responsive={false} gutterSize="xs">
<EuiFlexItem className="optionsList__selections">
<div className="eui-textTruncate">
{exclude && (
<>
<span className="optionsList__negateLabel">
{existsSelected
? OptionsListStrings.control.getExcludeExists()
: OptionsListStrings.control.getNegate()}
</span>{' '}
</>
)}
{existsSelected ? (
<span className={`optionsList__existsFilter`}>
{OptionsListStrings.controlAndPopover.getExists(+Boolean(exclude))}
</span>
) : (
<>
{selectedOptions?.length
? selectedOptions.map((value: OptionsListSelection, i, { length }) => {
const text = `${fieldFormatter(value)}${
i + 1 === length ? '' : delimiter
} `;
const isInvalid = invalidSelections?.includes(value);
return (
<span
key={text} // each item must have a unique key to prevent warning
className={`optionsList__filter ${
isInvalid ? 'optionsList__filterInvalid' : ''
}`}
>
{text}
</span>
);
})
: null}
</>
)}
</div>
</EuiFlexItem>
{invalidSelections && invalidSelections.length > 0 && (
<EuiFlexItem grow={false}>
<EuiToolTip
position="top"
content={OptionsListStrings.control.getInvalidSelectionWarningLabel(
invalidSelections.length
)}
delay="long"
>
<EuiToken
tabIndex={0}
iconType="alert"
size="s"
color="euiColorVis5"
shape="square"
fill="dark"
title={OptionsListStrings.control.getInvalidSelectionWarningLabel(
invalidSelections.length
)}
css={{ verticalAlign: 'text-bottom' }} // Align with the notification badge
/>
</EuiToolTip>
</EuiFlexItem>
)}
</EuiFlexGroup>
),
};
}, [selectedOptions, exclude, existsSelected, fieldFormatter, delimiter, invalidSelections]);
const button = (
<>
<EuiFilterButton
badgeColor="success"
iconType="arrowDown"
isLoading={debouncedLoading}
className={classNames('optionsList--filterBtn', {
'optionsList--filterBtnSingle': controlStyle !== 'twoLine',
'optionsList--filterBtnPlaceholder': !hasSelections,
})}
data-test-subj={`optionsList-control-${id}`}
onClick={() => optionsList.dispatch.setPopoverOpen(!isPopoverOpen)}
isSelected={isPopoverOpen}
numActiveFilters={selectedOptionsCount}
hasActiveFilters={Boolean(selectedOptionsCount)}
textProps={{ className: 'optionsList--selectionText' }}
aria-label={fieldTitle ?? fieldName}
aria-expanded={isPopoverOpen}
aria-controls={popoverId}
role="combobox"
>
{hasSelections || existsSelected
? selectionDisplayNode
: placeholder ?? OptionsListStrings.control.getPlaceholder()}
</EuiFilterButton>
</>
);
return error ? (
<ControlError error={error} />
) : (
<EuiFilterGroup
className={classNames('optionsList--filterGroup', {
'optionsList--filterGroupSingle': controlStyle !== 'twoLine',
})}
compressed
>
<EuiInputPopover
id={popoverId}
ownFocus
input={button}
hasArrow={false}
repositionOnScroll
isOpen={isPopoverOpen}
panelPaddingSize="none"
panelMinWidth={MIN_POPOVER_WIDTH}
className="optionsList__inputButtonOverride"
initialFocus={'[data-test-subj=optionsList-control-search-input]'}
closePopover={() => optionsList.dispatch.setPopoverOpen(false)}
panelClassName="optionsList__popoverOverride"
panelProps={{
'aria-label': OptionsListStrings.popover.getAriaLabel(fieldTitle ?? fieldName),
}}
>
<OptionsListPopover
isLoading={debouncedLoading}
updateSearchString={updateSearchString}
loadMoreSuggestions={loadMoreSuggestions}
/>
</EuiInputPopover>
</EuiFilterGroup>
);
};

View file

@ -1,204 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useEffect, useMemo, useState } from 'react';
import useAsync from 'react-use/lib/useAsync';
import { Direction, EuiFormRow, EuiLoadingSpinner, EuiRadioGroup, EuiSwitch } from '@elastic/eui';
import {
getCompatibleSearchTechniques,
OptionsListSearchTechnique,
} from '../../../common/options_list/suggestions_searching';
import {
getCompatibleSortingTypes,
OptionsListSortBy,
OPTIONS_LIST_DEFAULT_SORT,
} from '../../../common/options_list/suggestions_sorting';
import { pluginServices } from '../../services';
import { OptionsListStrings } from './options_list_strings';
import { ControlSettingTooltipLabel } from '../../components/control_setting_tooltip_label';
import { OptionsListEmbeddableInput } from '..';
import { ControlEditorProps } from '../../types';
const selectionOptions = [
{
id: 'multi',
label: OptionsListStrings.editor.selectionTypes.multi.getLabel(),
'data-test-subj': 'optionsListControl__multiSearchOptionAdditionalSetting',
},
{
id: 'single',
label: OptionsListStrings.editor.selectionTypes.single.getLabel(),
'data-test-subj': 'optionsListControl__singleSearchOptionAdditionalSetting',
},
];
const allSearchOptions = [
{
id: 'prefix',
label: (
<ControlSettingTooltipLabel
label={OptionsListStrings.editor.searchTypes.prefix.getLabel()}
tooltip={OptionsListStrings.editor.searchTypes.prefix.getTooltip()}
/>
),
'data-test-subj': 'optionsListControl__prefixSearchOptionAdditionalSetting',
},
{
id: 'wildcard',
label: (
<ControlSettingTooltipLabel
label={OptionsListStrings.editor.searchTypes.wildcard.getLabel()}
tooltip={OptionsListStrings.editor.searchTypes.wildcard.getTooltip()}
/>
),
'data-test-subj': 'optionsListControl__wildcardSearchOptionAdditionalSetting',
},
{
id: 'exact',
label: (
<ControlSettingTooltipLabel
label={OptionsListStrings.editor.searchTypes.exact.getLabel()}
tooltip={OptionsListStrings.editor.searchTypes.exact.getTooltip()}
/>
),
'data-test-subj': 'optionsListControl__exactSearchOptionAdditionalSetting',
},
];
interface OptionsListEditorState {
sortDirection: Direction;
runPastTimeout?: boolean;
searchTechnique?: OptionsListSearchTechnique;
singleSelect?: boolean;
hideExclude?: boolean;
hideExists?: boolean;
hideSort?: boolean;
sortBy: OptionsListSortBy;
}
export const OptionsListEditorOptions = ({
initialInput,
onChange,
fieldType,
}: ControlEditorProps<OptionsListEmbeddableInput>) => {
const [state, setState] = useState<OptionsListEditorState>({
sortDirection: initialInput?.sort?.direction ?? OPTIONS_LIST_DEFAULT_SORT.direction,
sortBy: initialInput?.sort?.by ?? OPTIONS_LIST_DEFAULT_SORT.by,
searchTechnique: initialInput?.searchTechnique,
runPastTimeout: initialInput?.runPastTimeout,
singleSelect: initialInput?.singleSelect,
hideExclude: initialInput?.hideExclude,
hideExists: initialInput?.hideExists,
hideSort: initialInput?.hideSort,
});
const { loading: waitingForAllowExpensiveQueries, value: allowExpensiveQueries } =
useAsync(async () => {
const { optionsList: optionsListService } = pluginServices.getServices();
return optionsListService.getAllowExpensiveQueries();
}, []);
const compatibleSearchTechniques = useMemo(
() => getCompatibleSearchTechniques(fieldType),
[fieldType]
);
const searchOptions = useMemo(() => {
return allSearchOptions.filter((searchOption) => {
return compatibleSearchTechniques.includes(searchOption.id as OptionsListSearchTechnique);
});
}, [compatibleSearchTechniques]);
useEffect(() => {
// when field type changes, ensure that the selected sort type is still valid
if (!getCompatibleSortingTypes(fieldType).includes(state.sortBy)) {
onChange({ sort: OPTIONS_LIST_DEFAULT_SORT });
setState((s) => ({
...s,
sortBy: OPTIONS_LIST_DEFAULT_SORT.by,
sortDirection: OPTIONS_LIST_DEFAULT_SORT.direction,
}));
}
}, [fieldType, onChange, state.sortBy]);
useEffect(() => {
// when field type changes, ensure that the selected search technique is still valid;
// if the selected search technique **isn't** valid, reset to the default
const searchTechnique =
initialInput?.searchTechnique &&
compatibleSearchTechniques.includes(initialInput.searchTechnique)
? initialInput.searchTechnique
: compatibleSearchTechniques[0];
onChange({ searchTechnique });
setState((s) => ({
...s,
searchTechnique,
}));
}, [compatibleSearchTechniques, onChange, initialInput]);
return (
<>
<EuiFormRow
label={OptionsListStrings.editor.getSelectionOptionsTitle()}
data-test-subj="optionsListControl__selectionOptionsRadioGroup"
>
<EuiRadioGroup
options={selectionOptions}
idSelected={state.singleSelect ? 'single' : 'multi'}
onChange={(id) => {
const newSingleSelect = id === 'single';
onChange({ singleSelect: newSingleSelect });
setState((s) => ({ ...s, singleSelect: newSingleSelect }));
}}
/>
</EuiFormRow>
{waitingForAllowExpensiveQueries ? (
<EuiFormRow>
<EuiLoadingSpinner size="l" />
</EuiFormRow>
) : (
allowExpensiveQueries &&
compatibleSearchTechniques.length > 1 && (
<EuiFormRow
label={OptionsListStrings.editor.getSearchOptionsTitle()}
data-test-subj="optionsListControl__searchOptionsRadioGroup"
>
<EuiRadioGroup
options={searchOptions}
idSelected={state.searchTechnique}
onChange={(id) => {
const searchTechnique = id as OptionsListSearchTechnique;
onChange({ searchTechnique });
setState((s) => ({ ...s, searchTechnique }));
}}
/>
</EuiFormRow>
)
)}
<EuiFormRow label={OptionsListStrings.editor.getAdditionalSettingsTitle()}>
<EuiSwitch
label={
<ControlSettingTooltipLabel
label={OptionsListStrings.editor.getRunPastTimeoutTitle()}
tooltip={OptionsListStrings.editor.getRunPastTimeoutTooltip()}
/>
}
checked={Boolean(state.runPastTimeout)}
onChange={() => {
onChange({ runPastTimeout: !state.runPastTimeout });
setState((s) => ({ ...s, runPastTimeout: !s.runPastTimeout }));
}}
data-test-subj={'optionsListControl__runPastTimeoutAdditionalSetting'}
/>
</EuiFormRow>
</>
);
};

View file

@ -1,453 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { FieldSpec } from '@kbn/data-views-plugin/common';
import { stubDataView } from '@kbn/data-views-plugin/common/data_view.stub';
import { render, RenderResult, within } from '@testing-library/react';
import userEvent, { type UserEvent } from '@testing-library/user-event';
import { mockOptionsListEmbeddable } from '../../../common/mocks';
import { pluginServices } from '../../services';
import { OptionsListEmbeddableContext } from '../embeddable/options_list_embeddable';
import { OptionsListComponentState, OptionsListReduxState } from '../types';
import { OptionsListPopover, OptionsListPopoverProps } from './options_list_popover';
import { OptionsListEmbeddableInput } from '..';
import { ControlOutput } from '../../types';
describe('Options list popover', () => {
let user: UserEvent;
const defaultProps = {
isLoading: false,
updateSearchString: jest.fn(),
loadMoreSuggestions: jest.fn(),
};
interface MountOptions {
componentState: Partial<OptionsListComponentState>;
explicitInput: Partial<OptionsListEmbeddableInput>;
output: Partial<ControlOutput>;
popoverProps: Partial<OptionsListPopoverProps>;
}
async function mountComponent(options?: Partial<MountOptions>) {
const compProps = { ...defaultProps, ...(options?.popoverProps ?? {}) };
const optionsListEmbeddable = await mockOptionsListEmbeddable({
componentState: options?.componentState ?? {},
explicitInput: options?.explicitInput ?? {},
output: options?.output ?? {},
} as Partial<OptionsListReduxState>);
return render(
<OptionsListEmbeddableContext.Provider value={optionsListEmbeddable}>
<OptionsListPopover {...compProps} />
</OptionsListEmbeddableContext.Provider>
);
}
const clickShowOnlySelections = async (popover: RenderResult) => {
const showOnlySelectedButton = popover.getByTestId('optionsList-control-show-only-selected');
await user.click(showOnlySelectedButton);
};
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
beforeEach(() => {
// Workaround for timeout via https://github.com/testing-library/user-event/issues/833#issuecomment-1171452841
user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
});
afterEach(() => {
jest.clearAllMocks();
jest.clearAllTimers();
});
test('no available options', async () => {
const popover = await mountComponent({ componentState: { availableOptions: [] } });
const availableOptionsDiv = popover.getByTestId('optionsList-control-available-options');
const noOptionsDiv = within(availableOptionsDiv).getByTestId(
'optionsList-control-noSelectionsMessage'
);
expect(noOptionsDiv).toBeInTheDocument();
});
describe('show only selected', () => {
test('display error message when the show only selected toggle is true but there are no selections', async () => {
const popover = await mountComponent();
await clickShowOnlySelections(popover);
const availableOptionsDiv = popover.getByTestId('optionsList-control-available-options');
const noSelectionsDiv = within(availableOptionsDiv).getByTestId(
'optionsList-control-selectionsEmptyMessage'
);
expect(noSelectionsDiv).toBeInTheDocument();
});
test('show only selected options', async () => {
const selections = ['woof', 'bark'];
const popover = await mountComponent({
explicitInput: { selectedOptions: selections },
});
await clickShowOnlySelections(popover);
const availableOptionsDiv = popover.getByTestId('optionsList-control-available-options');
const availableOptionsList = within(availableOptionsDiv).getByRole('listbox');
const availableOptions = within(availableOptionsList).getAllByRole('option');
availableOptions.forEach((child, i) => {
expect(child).toHaveTextContent(`${selections[i]}. Checked option.`);
});
});
test('disable search and sort when show only selected toggle is true', async () => {
const selections = ['woof', 'bark'];
const popover = await mountComponent({
explicitInput: { selectedOptions: selections },
componentState: { field: { type: 'string' } as any as FieldSpec },
});
let searchBox = popover.getByTestId('optionsList-control-search-input');
let sortButton = popover.getByTestId('optionsListControl__sortingOptionsButton');
expect(searchBox).not.toBeDisabled();
expect(sortButton).not.toBeDisabled();
await clickShowOnlySelections(popover);
searchBox = popover.getByTestId('optionsList-control-search-input');
sortButton = popover.getByTestId('optionsListControl__sortingOptionsButton');
expect(searchBox).toBeDisabled();
expect(sortButton).toBeDisabled();
});
});
describe('invalid selections', () => {
test('test single invalid selection', async () => {
const popover = await mountComponent({
explicitInput: {
selectedOptions: ['bark', 'woof'],
},
componentState: {
availableOptions: [{ value: 'bark', docCount: 75 }],
validSelections: ['bark'],
invalidSelections: ['woof'],
},
});
const validSelection = popover.getByTestId('optionsList-control-selection-bark');
expect(validSelection).toHaveTextContent('bark. Checked option.');
expect(
within(validSelection).getByTestId('optionsList-document-count-badge')
).toHaveTextContent('75');
const title = popover.getByTestId('optionList__invalidSelectionLabel');
expect(title).toHaveTextContent('Invalid selection');
const invalidSelection = popover.getByTestId('optionsList-control-invalid-selection-woof');
expect(invalidSelection).toHaveTextContent('woof. Checked option.');
expect(invalidSelection).toHaveClass('optionsList__selectionInvalid');
});
test('test title when multiple invalid selections', async () => {
const popover = await mountComponent({
explicitInput: { selectedOptions: ['bark', 'woof', 'meow'] },
componentState: {
availableOptions: [{ value: 'bark', docCount: 75 }],
validSelections: ['bark'],
invalidSelections: ['woof', 'meow'],
},
});
const title = popover.getByTestId('optionList__invalidSelectionLabel');
expect(title).toHaveTextContent('Invalid selections');
});
});
describe('include/exclude toggle', () => {
test('should default to exclude = false', async () => {
const popover = await mountComponent();
const includeButton = popover.getByTestId('optionsList__includeResults');
const excludeButton = popover.getByTestId('optionsList__excludeResults');
expect(includeButton).toHaveAttribute('aria-pressed', 'true');
expect(excludeButton).toHaveAttribute('aria-pressed', 'false');
});
test('if exclude = true, select appropriate button in button group', async () => {
const popover = await mountComponent({
explicitInput: { exclude: true },
});
const includeButton = popover.getByTestId('optionsList__includeResults');
const excludeButton = popover.getByTestId('optionsList__excludeResults');
expect(includeButton).toHaveAttribute('aria-pressed', 'false');
expect(excludeButton).toHaveAttribute('aria-pressed', 'true');
});
});
describe('"Exists" option', () => {
test('clicking another option unselects "Exists"', async () => {
const popover = await mountComponent({
explicitInput: { existsSelected: true },
componentState: { field: { type: 'string' } as FieldSpec },
});
const woofOption = popover.getByTestId('optionsList-control-selection-woof');
await user.click(woofOption);
const availableOptionsDiv = popover.getByTestId('optionsList-control-available-options');
const availableOptionsList = within(availableOptionsDiv).getByRole('listbox');
const selectedOptions = within(availableOptionsList).getAllByRole('option', {
checked: true,
});
expect(selectedOptions).toHaveLength(1);
expect(selectedOptions[0]).toHaveTextContent('woof. Checked option.');
});
test('clicking "Exists" unselects all other selections', async () => {
const selections = ['woof', 'bark'];
const popover = await mountComponent({
explicitInput: { existsSelected: false, selectedOptions: selections },
componentState: { field: { type: 'number' } as FieldSpec },
});
const existsOption = popover.getByTestId('optionsList-control-selection-exists');
let availableOptionsDiv = popover.getByTestId('optionsList-control-available-options');
let checkedOptions = within(availableOptionsDiv).getAllByRole('option', { checked: true });
expect(checkedOptions).toHaveLength(2);
expect(checkedOptions[0]).toHaveTextContent('woof. Checked option.');
expect(checkedOptions[1]).toHaveTextContent('bark. Checked option.');
await user.click(existsOption);
availableOptionsDiv = popover.getByTestId('optionsList-control-available-options');
checkedOptions = within(availableOptionsDiv).getAllByRole('option', { checked: true });
expect(checkedOptions).toHaveLength(1);
expect(checkedOptions[0]).toHaveTextContent('Exists. Checked option.');
});
test('if existsSelected = false and no suggestions, then "Exists" does not show up', async () => {
const popover = await mountComponent({
componentState: { availableOptions: [] },
explicitInput: { existsSelected: false },
});
const existsOption = popover.queryByTestId('optionsList-control-selection-exists');
expect(existsOption).toBeNull();
});
test('if existsSelected = true, "Exists" is the only option when "Show only selected options" is toggled', async () => {
const popover = await mountComponent({
explicitInput: { existsSelected: true },
});
await clickShowOnlySelections(popover);
const availableOptionsDiv = popover.getByTestId('optionsList-control-available-options');
const availableOptionsList = within(availableOptionsDiv).getByRole('listbox');
const availableOptions = within(availableOptionsList).getAllByRole('option');
expect(availableOptions[0]).toHaveTextContent('Exists. Checked option.');
});
});
describe('sorting suggestions', () => {
test('when sorting suggestions, show both sorting types for keyword field', async () => {
const popover = await mountComponent({
componentState: {
field: { name: 'Test keyword field', type: 'keyword' } as FieldSpec,
},
});
const sortButton = popover.getByTestId('optionsListControl__sortingOptionsButton');
await user.click(sortButton);
expect(popover.getByTestId('optionsListControl__sortingOptions')).toBeInTheDocument();
const sortingOptionsDiv = popover.getByTestId('optionsListControl__sortingOptions');
const optionsText = within(sortingOptionsDiv)
.getAllByRole('option')
.map((el) => el.textContent);
expect(optionsText).toEqual(['By document count. Checked option.', 'Alphabetically']);
});
test('sorting popover selects appropriate sorting type on load', async () => {
const popover = await mountComponent({
explicitInput: { sort: { by: '_key', direction: 'asc' } },
componentState: {
field: { name: 'Test keyword field', type: 'keyword' } as FieldSpec,
},
});
const sortButton = popover.getByTestId('optionsListControl__sortingOptionsButton');
await user.click(sortButton);
expect(popover.getByTestId('optionsListControl__sortingOptions')).toBeInTheDocument();
const sortingOptionsDiv = popover.getByTestId('optionsListControl__sortingOptions');
const optionsText = within(sortingOptionsDiv)
.getAllByRole('option')
.map((el) => el.textContent);
expect(optionsText).toEqual(['By document count', 'Alphabetically. Checked option.']);
const ascendingButton = popover.getByTestId('optionsList__sortOrder_asc');
expect(ascendingButton).toHaveClass('euiButtonGroupButton-isSelected');
const descendingButton = popover.getByTestId('optionsList__sortOrder_desc');
expect(descendingButton).not.toHaveClass('euiButtonGroupButton-isSelected');
});
test('when sorting suggestions, only show document count sorting for IP fields', async () => {
const popover = await mountComponent({
componentState: { field: { name: 'Test IP field', type: 'ip' } as FieldSpec },
});
const sortButton = popover.getByTestId('optionsListControl__sortingOptionsButton');
await user.click(sortButton);
expect(popover.getByTestId('optionsListControl__sortingOptions')).toBeInTheDocument();
const sortingOptionsDiv = popover.getByTestId('optionsListControl__sortingOptions');
const optionsText = within(sortingOptionsDiv)
.getAllByRole('option')
.map((el) => el.textContent);
expect(optionsText).toEqual(['By document count. Checked option.']);
});
test('when sorting suggestions, show "By date" sorting option for date fields', async () => {
const popover = await mountComponent({
componentState: { field: { name: 'Test date field', type: 'date' } as FieldSpec },
});
const sortButton = popover.getByTestId('optionsListControl__sortingOptionsButton');
await user.click(sortButton);
expect(popover.getByTestId('optionsListControl__sortingOptions')).toBeInTheDocument();
const sortingOptionsDiv = popover.getByTestId('optionsListControl__sortingOptions');
const optionsText = within(sortingOptionsDiv)
.getAllByRole('option')
.map((el) => el.textContent);
expect(optionsText).toEqual(['By document count. Checked option.', 'By date']);
});
test('when sorting suggestions, show "Numerically" sorting option for number fields', async () => {
const popover = await mountComponent({
componentState: { field: { name: 'Test number field', type: 'number' } as FieldSpec },
});
const sortButton = popover.getByTestId('optionsListControl__sortingOptionsButton');
await user.click(sortButton);
expect(popover.getByTestId('optionsListControl__sortingOptions')).toBeInTheDocument();
const sortingOptionsDiv = popover.getByTestId('optionsListControl__sortingOptions');
const optionsText = within(sortingOptionsDiv)
.getAllByRole('option')
.map((el) => el.textContent);
expect(optionsText).toEqual(['By document count. Checked option.', 'Numerically']);
});
});
describe('allow expensive queries warning', () => {
test('ensure warning icon does not show up when testAllowExpensiveQueries = true/undefined', async () => {
const popover = await mountComponent({
componentState: { field: { name: 'Test keyword field', type: 'keyword' } as FieldSpec },
});
const warning = popover.queryByTestId('optionsList-allow-expensive-queries-warning');
expect(warning).toBeNull();
});
test('ensure warning icon shows up when testAllowExpensiveQueries = false', async () => {
pluginServices.getServices().optionsList.getAllowExpensiveQueries = jest.fn(() =>
Promise.resolve(false)
);
const popover = await mountComponent({
componentState: {
field: { name: 'Test keyword field', type: 'keyword' } as FieldSpec,
allowExpensiveQueries: false,
},
});
const warning = popover.getByTestId('optionsList-allow-expensive-queries-warning');
expect(warning).toBeInstanceOf(HTMLDivElement);
});
});
describe('advanced settings', () => {
const ensureComponentIsHidden = async ({
explicitInput,
testSubject,
}: {
explicitInput: Partial<OptionsListEmbeddableInput>;
testSubject: string;
}) => {
const popover = await mountComponent({
explicitInput,
});
const test = popover.queryByTestId(testSubject);
expect(test).toBeNull();
};
test('can hide exists option', async () => {
ensureComponentIsHidden({
explicitInput: { hideExists: true },
testSubject: 'optionsList-control-selection-exists',
});
});
test('can hide include/exclude toggle', async () => {
ensureComponentIsHidden({
explicitInput: { hideExclude: true },
testSubject: 'optionsList__includeExcludeButtonGroup',
});
});
test('can hide sorting button', async () => {
ensureComponentIsHidden({
explicitInput: { hideSort: true },
testSubject: 'optionsListControl__sortingOptionsButton',
});
});
});
describe('field formatter', () => {
const mockedFormatter = jest.fn().mockImplementation((value: unknown) => `formatted:${value}`);
beforeAll(() => {
stubDataView.getFormatterForField = jest.fn().mockReturnValue({
getConverterFor: () => mockedFormatter,
});
pluginServices.getServices().dataViews.get = jest.fn().mockResolvedValue(stubDataView);
});
afterEach(() => {
mockedFormatter.mockClear();
});
test('uses field formatter on suggestions', async () => {
const popover = await mountComponent({
componentState: {
field: stubDataView.fields.getByName('bytes')?.toSpec(),
availableOptions: [
{ value: 1000, docCount: 1 },
{ value: 123456789, docCount: 4 },
],
},
});
expect(mockedFormatter).toHaveBeenNthCalledWith(1, 1000);
expect(mockedFormatter).toHaveBeenNthCalledWith(2, 123456789);
const options = await popover.findAllByRole('option');
expect(options[0].textContent).toEqual('Exists');
expect(
options[1].getElementsByClassName('euiSelectableListItem__text')[0].textContent
).toEqual('formatted:1000');
expect(
options[2].getElementsByClassName('euiSelectableListItem__text')[0].textContent
).toEqual('formatted:123456789');
});
test('converts string to number for date field', async () => {
await mountComponent({
componentState: {
field: stubDataView.fields.getByName('@timestamp')?.toSpec(),
availableOptions: [
{ value: 1721283696000, docCount: 1 },
{ value: 1721295533000, docCount: 2 },
],
},
});
expect(mockedFormatter).toHaveBeenNthCalledWith(1, 1721283696000);
expect(mockedFormatter).toHaveBeenNthCalledWith(2, 1721295533000);
});
});
});

View file

@ -1,71 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { isEmpty } from 'lodash';
import React, { useState } from 'react';
import { useOptionsList } from '../embeddable/options_list_embeddable';
import { OptionsListPopoverFooter } from './options_list_popover_footer';
import { OptionsListPopoverActionBar } from './options_list_popover_action_bar';
import { OptionsListPopoverSuggestions } from './options_list_popover_suggestions';
import { OptionsListPopoverInvalidSelections } from './options_list_popover_invalid_selections';
export interface OptionsListPopoverProps {
isLoading: boolean;
loadMoreSuggestions: (cardinality: number) => void;
updateSearchString: (newSearchString: string) => void;
}
export const OptionsListPopover = ({
isLoading,
updateSearchString,
loadMoreSuggestions,
}: OptionsListPopoverProps) => {
const optionsList = useOptionsList();
const field = optionsList.select((state) => state.componentState.field);
const availableOptions = optionsList.select((state) => state.componentState.availableOptions);
const invalidSelections = optionsList.select((state) => state.componentState.invalidSelections);
const id = optionsList.select((state) => state.explicitInput.id);
const hideExclude = optionsList.select((state) => state.explicitInput.hideExclude);
const hideActionBar = optionsList.select((state) => state.explicitInput.hideActionBar);
const [showOnlySelected, setShowOnlySelected] = useState(false);
return (
<div
id={`control-popover-${id}`}
className={'optionsList__popover'}
data-test-subj={`optionsList-control-popover`}
>
{field?.type !== 'boolean' && !hideActionBar && (
<OptionsListPopoverActionBar
showOnlySelected={showOnlySelected}
updateSearchString={updateSearchString}
setShowOnlySelected={setShowOnlySelected}
/>
)}
<div
data-test-subj={`optionsList-control-available-options`}
data-option-count={isLoading ? 0 : Object.keys(availableOptions ?? {}).length}
style={{ width: '100%', height: '100%' }}
>
<OptionsListPopoverSuggestions
loadMoreSuggestions={loadMoreSuggestions}
showOnlySelected={showOnlySelected}
/>
{!showOnlySelected && invalidSelections && !isEmpty(invalidSelections) && (
<OptionsListPopoverInvalidSelections />
)}
</div>
{!hideExclude && <OptionsListPopoverFooter isLoading={isLoading} />}
</div>
);
};

View file

@ -1,151 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useMemo } from 'react';
import {
EuiButtonIcon,
EuiFieldSearch,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiText,
EuiToolTip,
} from '@elastic/eui';
import { getCompatibleSearchTechniques } from '../../../common/options_list/suggestions_searching';
import { useOptionsList } from '../embeddable/options_list_embeddable';
import { OptionsListPopoverSortingButton } from './options_list_popover_sorting_button';
import { OptionsListStrings } from './options_list_strings';
interface OptionsListPopoverProps {
showOnlySelected: boolean;
updateSearchString: (newSearchString: string) => void;
setShowOnlySelected: (value: boolean) => void;
}
export const OptionsListPopoverActionBar = ({
showOnlySelected,
updateSearchString,
setShowOnlySelected,
}: OptionsListPopoverProps) => {
const optionsList = useOptionsList();
const totalCardinality =
optionsList.select((state) => state.componentState.totalCardinality) ?? 0;
const fieldSpec = optionsList.select((state) => state.componentState.field);
const searchString = optionsList.select((state) => state.componentState.searchString);
const invalidSelections = optionsList.select((state) => state.componentState.invalidSelections);
const allowExpensiveQueries = optionsList.select(
(state) => state.componentState.allowExpensiveQueries
);
const hideSort = optionsList.select((state) => state.explicitInput.hideSort);
const searchTechnique = optionsList.select((state) => state.explicitInput.searchTechnique);
const compatibleSearchTechniques = useMemo(() => {
if (!fieldSpec) return [];
return getCompatibleSearchTechniques(fieldSpec.type);
}, [fieldSpec]);
const defaultSearchTechnique = useMemo(
() => searchTechnique ?? compatibleSearchTechniques[0],
[searchTechnique, compatibleSearchTechniques]
);
return (
<div className="optionsList__actions">
{compatibleSearchTechniques.length > 0 && (
<EuiFormRow className="optionsList__searchRow" fullWidth>
<EuiFieldSearch
isInvalid={!searchString.valid}
compressed
disabled={showOnlySelected}
fullWidth
onChange={(event) => updateSearchString(event.target.value)}
value={searchString.value}
data-test-subj="optionsList-control-search-input"
placeholder={OptionsListStrings.popover.getSearchPlaceholder(
allowExpensiveQueries ? defaultSearchTechnique : 'exact'
)}
/>
</EuiFormRow>
)}
<EuiFormRow className="optionsList__actionsRow" fullWidth>
<EuiFlexGroup
justifyContent="spaceBetween"
alignItems="center"
gutterSize="s"
responsive={false}
>
{allowExpensiveQueries && (
<EuiFlexItem grow={false}>
<EuiText size="xs" color="subdued" data-test-subj="optionsList-cardinality-label">
{OptionsListStrings.popover.getCardinalityLabel(totalCardinality)}
</EuiText>
</EuiFlexItem>
)}
{invalidSelections && invalidSelections.length > 0 && (
<>
{allowExpensiveQueries && (
<EuiFlexItem grow={false}>
<div className="optionsList__actionBarDivider" />
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<EuiText size="xs" color="subdued">
{OptionsListStrings.popover.getInvalidSelectionsLabel(invalidSelections.length)}
</EuiText>
</EuiFlexItem>
</>
)}
<EuiFlexItem grow={true}>
<EuiFlexGroup
gutterSize="xs"
alignItems="center"
justifyContent="flexEnd"
responsive={false}
>
<EuiFlexItem grow={false}>
<EuiToolTip
position="top"
content={
showOnlySelected
? OptionsListStrings.popover.getAllOptionsButtonTitle()
: OptionsListStrings.popover.getSelectedOptionsButtonTitle()
}
>
<EuiButtonIcon
size="xs"
iconType="list"
aria-pressed={showOnlySelected}
color={showOnlySelected ? 'primary' : 'text'}
display={showOnlySelected ? 'base' : 'empty'}
onClick={() => setShowOnlySelected(!showOnlySelected)}
data-test-subj="optionsList-control-show-only-selected"
aria-label={
showOnlySelected
? OptionsListStrings.popover.getAllOptionsButtonTitle()
: OptionsListStrings.popover.getSelectedOptionsButtonTitle()
}
/>
</EuiToolTip>
</EuiFlexItem>
{!hideSort && (
<EuiFlexItem grow={false}>
<OptionsListPopoverSortingButton showOnlySelected={showOnlySelected} />
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
</div>
);
};

View file

@ -1,53 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useMemo } from 'react';
import { EuiIcon, EuiSelectableMessage, EuiSpacer } from '@elastic/eui';
import { useOptionsList } from '../embeddable/options_list_embeddable';
import { OptionsListStrings } from './options_list_strings';
export const OptionsListPopoverEmptyMessage = ({
showOnlySelected,
}: {
showOnlySelected: boolean;
}) => {
const optionsList = useOptionsList();
const searchString = optionsList.select((state) => state.componentState.searchString);
const fieldSpec = optionsList.select((state) => state.componentState.field);
const searchTechnique = optionsList.select((state) => state.explicitInput.searchTechnique);
const noResultsMessage = useMemo(() => {
if (showOnlySelected) {
return OptionsListStrings.popover.getSelectionsEmptyMessage();
}
if (!searchString.valid && fieldSpec && searchTechnique) {
return OptionsListStrings.popover.getInvalidSearchMessage(fieldSpec.type);
}
return OptionsListStrings.popover.getEmptyMessage();
}, [showOnlySelected, fieldSpec, searchString.valid, searchTechnique]);
return (
<EuiSelectableMessage
tabIndex={0}
data-test-subj={`optionsList-control-${
showOnlySelected ? 'selectionsEmptyMessage' : 'noSelectionsMessage'
}`}
>
<EuiIcon
type={searchString.valid ? 'minusInCircle' : 'alert'}
color={searchString.valid ? 'default' : 'danger'}
/>
<EuiSpacer size="xs" />
{noResultsMessage}
</EuiSelectableMessage>
);
};

View file

@ -1,101 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import {
EuiIconTip,
EuiFlexItem,
EuiProgress,
EuiFlexGroup,
EuiButtonGroup,
EuiPopoverFooter,
useEuiPaddingSize,
useEuiBackgroundColor,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { OptionsListStrings } from './options_list_strings';
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(),
},
];
export const OptionsListPopoverFooter = ({ isLoading }: { isLoading: boolean }) => {
const optionsList = useOptionsList();
const exclude = optionsList.select((state) => state.explicitInput.exclude);
const allowExpensiveQueries = optionsList.select(
(state) => state.componentState.allowExpensiveQueries
);
return (
<>
<EuiPopoverFooter
paddingSize="none"
css={css`
background-color: ${useEuiBackgroundColor('subdued')};
`}
>
{isLoading && (
<div style={{ position: 'absolute', width: '100%' }}>
<EuiProgress
data-test-subj="optionsList-control-popover-loading"
size="xs"
color="accent"
/>
</div>
)}
<EuiFlexGroup
gutterSize="xs"
responsive={false}
alignItems="center"
css={css`
padding: ${useEuiPaddingSize('s')};
`}
justifyContent={'spaceBetween'}
>
<EuiFlexItem grow={false}>
<EuiButtonGroup
legend={OptionsListStrings.popover.getIncludeExcludeLegend()}
options={aggregationToggleButtons}
idSelected={exclude ? 'optionsList__excludeResults' : 'optionsList__includeResults'}
onChange={(optionId) =>
optionsList.dispatch.setExclude(optionId === 'optionsList__excludeResults')
}
buttonSize="compressed"
data-test-subj="optionsList__includeExcludeButtonGroup"
/>
</EuiFlexItem>
{!allowExpensiveQueries && (
<EuiFlexItem data-test-subj="optionsList-allow-expensive-queries-warning" grow={false}>
<EuiIconTip
type="warning"
color="warning"
content={OptionsListStrings.popover.getAllowExpensiveQueriesWarning()}
aria-label={OptionsListStrings.popover.getAllowExpensiveQueriesWarning()}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiPopoverFooter>
</>
);
};

View file

@ -1,111 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useEffect, useState } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiScreenReaderOnly,
EuiSelectable,
EuiSelectableOption,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import { getSelectionAsFieldType } from '../../../common/options_list/options_list_selections';
import { useFieldFormatter } from '../../hooks/use_field_formatter';
import { useOptionsList } from '../embeddable/options_list_embeddable';
import { OptionsListStrings } from './options_list_strings';
export const OptionsListPopoverInvalidSelections = () => {
const optionsList = useOptionsList();
const fieldName = optionsList.select((state) => state.explicitInput.fieldName);
const invalidSelections = optionsList.select((state) => state.componentState.invalidSelections);
const fieldSpec = optionsList.select((state) => state.componentState.field);
const dataViewId = optionsList.select((state) => state.output.dataViewId);
const fieldFormatter = useFieldFormatter({ dataViewId, fieldSpec });
const [selectableOptions, setSelectableOptions] = useState<EuiSelectableOption[]>([]); // will be set in following useEffect
useEffect(() => {
/* This useEffect makes selectableOptions responsive to unchecking options */
const options: EuiSelectableOption[] = (invalidSelections ?? []).map((key) => {
return {
key: String(key),
label: fieldFormatter(key),
checked: 'on',
className: 'optionsList__selectionInvalid',
'data-test-subj': `optionsList-control-invalid-selection-${key}`,
prepend: (
<EuiScreenReaderOnly>
<div>
{OptionsListStrings.popover.getInvalidSelectionScreenReaderText()}
{'" "'} {/* Adds a pause for the screen reader */}
</div>
</EuiScreenReaderOnly>
),
};
});
setSelectableOptions(options);
}, [fieldFormatter, invalidSelections]);
return (
<>
<EuiSpacer size="s" />
<EuiTitle
size="xxs"
className="optionsList-control-ignored-selection-title"
data-test-subj="optionList__invalidSelectionLabel"
>
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<EuiIcon
type="warning"
color="warning"
title={OptionsListStrings.popover.getInvalidSelectionScreenReaderText()}
size="s"
/>
</EuiFlexItem>
<EuiFlexItem>
<label>
{OptionsListStrings.popover.getInvalidSelectionsSectionTitle(
invalidSelections?.length ?? 0
)}
</label>
</EuiFlexItem>
</EuiFlexGroup>
</EuiTitle>
<EuiSelectable
aria-label={OptionsListStrings.popover.getInvalidSelectionsSectionAriaLabel(
fieldName,
invalidSelections?.length ?? 0
)}
options={selectableOptions}
listProps={{ onFocusBadge: false, isVirtualized: false }}
onChange={(newSuggestions, _, changedOption) => {
if (!fieldSpec || !changedOption.key) {
// this should never happen, but early return for type safety
// eslint-disable-next-line no-console
console.warn(OptionsListStrings.popover.getInvalidSelectionMessage());
return;
}
setSelectableOptions(newSuggestions);
const key = getSelectionAsFieldType(fieldSpec, changedOption.key);
optionsList.dispatch.deselectOption(key);
}}
>
{(list) => list}
</EuiSelectable>
</>
);
};

View file

@ -1,156 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useCallback, useState } from 'react';
import {
EuiButtonGroupOptionProps,
EuiSelectableOption,
EuiPopoverTitle,
EuiButtonGroup,
EuiSelectable,
EuiFlexGroup,
EuiFlexItem,
EuiPopover,
Direction,
EuiToolTip,
EuiButtonIcon,
} from '@elastic/eui';
import {
getCompatibleSortingTypes,
OPTIONS_LIST_DEFAULT_SORT,
OptionsListSortBy,
} from '../../../common/options_list/suggestions_sorting';
import { OptionsListStrings } from './options_list_strings';
import { useOptionsList } from '../embeddable/options_list_embeddable';
interface OptionsListSortingPopoverProps {
showOnlySelected: boolean;
}
type SortByItem = EuiSelectableOption & {
data: { sortBy: OptionsListSortBy };
};
const sortOrderOptions: EuiButtonGroupOptionProps[] = [
{
id: 'asc',
iconType: `sortAscending`,
'data-test-subj': `optionsList__sortOrder_asc`,
label: OptionsListStrings.editorAndPopover.sortOrder.asc.getSortOrderLabel(),
},
{
id: 'desc',
iconType: `sortDescending`,
'data-test-subj': `optionsList__sortOrder_desc`,
label: OptionsListStrings.editorAndPopover.sortOrder.desc.getSortOrderLabel(),
},
];
export const OptionsListPopoverSortingButton = ({
showOnlySelected,
}: OptionsListSortingPopoverProps) => {
const optionsList = useOptionsList();
const field = optionsList.select((state) => state.componentState.field);
const sort = optionsList.select((state) => state.explicitInput.sort ?? OPTIONS_LIST_DEFAULT_SORT);
const [isSortingPopoverOpen, setIsSortingPopoverOpen] = useState(false);
const [sortByOptions, setSortByOptions] = useState<SortByItem[]>(() => {
return getCompatibleSortingTypes(field?.type).map((key) => {
return {
onFocusBadge: false,
data: { sortBy: key },
checked: key === sort.by ? 'on' : undefined,
'data-test-subj': `optionsList__sortBy_${key}`,
label: OptionsListStrings.editorAndPopover.sortBy[key].getSortByLabel(field?.type),
} as SortByItem;
});
});
const onSortByChange = useCallback(
(updatedOptions: SortByItem[]) => {
setSortByOptions(updatedOptions);
const selectedOption = updatedOptions.find(({ checked }) => checked === 'on');
if (selectedOption) {
optionsList.dispatch.setSort({ by: selectedOption.data.sortBy });
}
},
[optionsList.dispatch]
);
const SortButton = () => (
<EuiButtonIcon
size="xs"
display="empty"
color="text"
iconType={sort?.direction === 'asc' ? 'sortAscending' : 'sortDescending'}
isDisabled={showOnlySelected}
className="optionsList__sortButton"
data-test-subj="optionsListControl__sortingOptionsButton"
onClick={() => setIsSortingPopoverOpen(!isSortingPopoverOpen)}
aria-label={OptionsListStrings.popover.getSortPopoverDescription()}
/>
);
return (
<EuiPopover
button={
<EuiToolTip
position="top"
content={
showOnlySelected
? OptionsListStrings.popover.getSortDisabledTooltip()
: OptionsListStrings.popover.getSortPopoverTitle()
}
>
<SortButton />
</EuiToolTip>
}
panelPaddingSize="none"
isOpen={isSortingPopoverOpen}
aria-labelledby="optionsList_sortingOptions"
closePopover={() => setIsSortingPopoverOpen(false)}
panelClassName={'optionsList--sortPopover'}
>
<span data-test-subj="optionsListControl__sortingOptionsPopover">
<EuiPopoverTitle paddingSize="s">
<EuiFlexGroup alignItems="center" responsive={false}>
<EuiFlexItem>{OptionsListStrings.popover.getSortPopoverTitle()}</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonGroup
isIconOnly
buttonSize="compressed"
options={sortOrderOptions}
idSelected={sort.direction}
legend={OptionsListStrings.editorAndPopover.getSortDirectionLegend()}
onChange={(value) =>
optionsList.dispatch.setSort({ direction: value as Direction })
}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPopoverTitle>
<EuiSelectable
options={sortByOptions}
singleSelection="always"
onChange={onSortByChange}
id="optionsList_sortingOptions"
listProps={{ bordered: false }}
data-test-subj="optionsListControl__sortingOptions"
aria-label={OptionsListStrings.popover.getSortPopoverDescription()}
>
{(list) => list}
</EuiSelectable>
</span>
</EuiPopover>
);
};

View file

@ -1,47 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { css } from '@emotion/react';
import { EuiScreenReaderOnly, EuiText, EuiToolTip, useEuiTheme } from '@elastic/eui';
import { OptionsListStrings } from './options_list_strings';
export const OptionsListPopoverSuggestionBadge = ({ documentCount }: { documentCount: number }) => {
const { euiTheme } = useEuiTheme();
return (
<>
<EuiToolTip
content={OptionsListStrings.popover.getDocumentCountTooltip(documentCount)}
position={'right'}
>
<EuiText
size="xs"
aria-hidden={true}
className="eui-textNumber"
color={euiTheme.colors.subduedText}
data-test-subj="optionsList-document-count-badge"
css={css`
font-weight: ${euiTheme.font.weight.medium} !important;
`}
>
{`${documentCount.toLocaleString()}`}
</EuiText>
</EuiToolTip>
<EuiScreenReaderOnly>
<div>
{'" "'} {/* Adds a pause for the screen reader */}
{OptionsListStrings.popover.getDocumentCountScreenReaderText(documentCount)}
</div>
</EuiScreenReaderOnly>
</>
);
};

View file

@ -1,230 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { EuiHighlight, EuiSelectable } from '@elastic/eui';
import { EuiSelectableOption } from '@elastic/eui/src/components/selectable/selectable_option';
import { euiThemeVars } from '@kbn/ui-theme';
import {
getSelectionAsFieldType,
OptionsListSelection,
} from '../../../common/options_list/options_list_selections';
import { useFieldFormatter } from '../../hooks/use_field_formatter';
import { useOptionsList } from '../embeddable/options_list_embeddable';
import { MAX_OPTIONS_LIST_REQUEST_SIZE } from '../types';
import { OptionsListPopoverEmptyMessage } from './options_list_popover_empty_message';
import { OptionsListPopoverSuggestionBadge } from './options_list_popover_suggestion_badge';
import { OptionsListStrings } from './options_list_strings';
interface OptionsListPopoverSuggestionsProps {
showOnlySelected: boolean;
loadMoreSuggestions: (cardinality: number) => void;
}
export const OptionsListPopoverSuggestions = ({
showOnlySelected,
loadMoreSuggestions,
}: OptionsListPopoverSuggestionsProps) => {
const optionsList = useOptionsList();
const fieldSpec = optionsList.select((state) => state.componentState.field);
const searchString = optionsList.select((state) => state.componentState.searchString);
const availableOptions = optionsList.select((state) => state.componentState.availableOptions);
const totalCardinality = optionsList.select((state) => state.componentState.totalCardinality);
const invalidSelections = optionsList.select((state) => state.componentState.invalidSelections);
const allowExpensiveQueries = optionsList.select(
(state) => state.componentState.allowExpensiveQueries
);
const sort = optionsList.select((state) => state.explicitInput.sort);
const fieldName = optionsList.select((state) => state.explicitInput.fieldName);
const hideExists = optionsList.select((state) => state.explicitInput.hideExists);
const singleSelect = optionsList.select((state) => state.explicitInput.singleSelect);
const existsSelected = optionsList.select((state) => state.explicitInput.existsSelected);
const searchTechnique = optionsList.select((state) => state.explicitInput.searchTechnique);
const selectedOptions = optionsList.select((state) => state.explicitInput.selectedOptions);
const dataViewId = optionsList.select((state) => state.output.dataViewId);
const isLoading = optionsList.select((state) => state.output.loading) ?? false;
const listRef = useRef<HTMLDivElement>(null);
const fieldFormatter = useFieldFormatter({ dataViewId, fieldSpec });
const canLoadMoreSuggestions = useMemo(
() =>
allowExpensiveQueries && searchString.valid && totalCardinality && !showOnlySelected
? (availableOptions ?? []).length <
Math.min(totalCardinality, MAX_OPTIONS_LIST_REQUEST_SIZE)
: false,
[availableOptions, totalCardinality, searchString, showOnlySelected, allowExpensiveQueries]
);
// track selectedOptions and invalidSelections in sets for more efficient lookup
const selectedOptionsSet = useMemo(
() => new Set<OptionsListSelection>(selectedOptions),
[selectedOptions]
);
const invalidSelectionsSet = useMemo(
() => new Set<OptionsListSelection>(invalidSelections),
[invalidSelections]
);
const suggestions = useMemo(() => {
return showOnlySelected ? selectedOptions : availableOptions ?? [];
}, [availableOptions, selectedOptions, showOnlySelected]);
const existsSelectableOption = useMemo<EuiSelectableOption | undefined>(() => {
if (hideExists || (!existsSelected && (showOnlySelected || suggestions?.length === 0))) return;
return {
key: 'exists-option',
checked: existsSelected ? 'on' : undefined,
label: OptionsListStrings.controlAndPopover.getExists(),
className: 'optionsList__existsFilter',
'data-test-subj': 'optionsList-control-selection-exists',
};
}, [suggestions, existsSelected, showOnlySelected, hideExists]);
const [selectableOptions, setSelectableOptions] = useState<EuiSelectableOption[]>([]); // will be set in following useEffect
useEffect(() => {
/* This useEffect makes selectableOptions responsive to search, show only selected, and clear selections */
const options: EuiSelectableOption[] = (suggestions ?? []).map((suggestion) => {
if (typeof suggestion !== 'object') {
// this means that `showOnlySelected` is true, and doc count is not known when this is the case
suggestion = { value: suggestion };
}
return {
key: String(suggestion.value),
label: fieldFormatter(suggestion.value) ?? String(suggestion.value),
checked: selectedOptionsSet?.has(suggestion.value) ? 'on' : undefined,
'data-test-subj': `optionsList-control-selection-${suggestion.value}`,
className:
showOnlySelected && invalidSelectionsSet.has(suggestion.value)
? 'optionsList__selectionInvalid'
: 'optionsList__validSuggestion',
append:
!showOnlySelected && suggestion?.docCount ? (
<OptionsListPopoverSuggestionBadge documentCount={suggestion.docCount} />
) : undefined,
};
});
if (canLoadMoreSuggestions) {
options.push({
key: 'loading-option',
className: 'optionslist--loadingMoreGroupLabel',
label: OptionsListStrings.popover.getLoadingMoreMessage(),
isGroupLabel: true,
});
} else if (options.length === MAX_OPTIONS_LIST_REQUEST_SIZE) {
options.push({
key: 'no-more-option',
className: 'optionslist--endOfOptionsGroupLabel',
label: OptionsListStrings.popover.getAtEndOfOptionsMessage(),
isGroupLabel: true,
});
}
setSelectableOptions(existsSelectableOption ? [existsSelectableOption, ...options] : options);
}, [
suggestions,
availableOptions,
showOnlySelected,
selectedOptionsSet,
invalidSelectionsSet,
existsSelectableOption,
canLoadMoreSuggestions,
fieldFormatter,
]);
const loadMoreOptions = useCallback(() => {
const listbox = listRef.current?.querySelector('.euiSelectableList__list');
if (!listbox) return;
const { scrollTop, scrollHeight, clientHeight } = listbox;
if (scrollTop + clientHeight >= scrollHeight - parseInt(euiThemeVars.euiSizeXXL, 10)) {
// reached the "bottom" of the list, where euiSizeXXL acts as a "margin of error" so that the user doesn't
// have to scroll **all the way** to the bottom in order to load more options
loadMoreSuggestions(totalCardinality ?? MAX_OPTIONS_LIST_REQUEST_SIZE);
}
}, [loadMoreSuggestions, totalCardinality]);
const renderOption = useCallback(
(option: EuiSelectableOption, searchStringValue: string) => {
if (!allowExpensiveQueries || searchTechnique === 'exact') return option.label;
return (
<EuiHighlight search={option.key === 'exists-option' ? '' : searchStringValue}>
{option.label}
</EuiHighlight>
);
},
[searchTechnique, allowExpensiveQueries]
);
useEffect(() => {
const container = listRef.current;
if (!isLoading && canLoadMoreSuggestions) {
container?.addEventListener('scroll', loadMoreOptions, true);
return () => {
container?.removeEventListener('scroll', loadMoreOptions, true);
};
}
}, [loadMoreOptions, isLoading, canLoadMoreSuggestions]);
useEffect(() => {
// scroll back to the top when changing the sorting or the search string
const listbox = listRef.current?.querySelector('.euiSelectableList__list');
listbox?.scrollTo({ top: 0 });
}, [sort, searchString]);
return (
<>
<div ref={listRef}>
<EuiSelectable
options={selectableOptions}
renderOption={(option) => renderOption(option, searchString.value)}
listProps={{ onFocusBadge: false }}
aria-label={OptionsListStrings.popover.getSuggestionsAriaLabel(
fieldName,
selectableOptions.length
)}
emptyMessage={<OptionsListPopoverEmptyMessage showOnlySelected={showOnlySelected} />}
onChange={(newSuggestions, _, changedOption) => {
if (!fieldSpec || !changedOption.key) {
// this should never happen, but early return for type safety
// eslint-disable-next-line no-console
console.warn(OptionsListStrings.popover.getInvalidSelectionMessage());
return;
}
setSelectableOptions(newSuggestions);
if (changedOption.key === 'exists-option') {
optionsList.dispatch.selectExists(!Boolean(existsSelected));
return;
}
const key = getSelectionAsFieldType(fieldSpec, changedOption.key);
// the order of these checks matters, so be careful if rearranging them
if (showOnlySelected || selectedOptionsSet.has(key)) {
optionsList.dispatch.deselectOption(key);
} else if (singleSelect) {
optionsList.dispatch.replaceSelection(key);
} else {
optionsList.dispatch.selectOption(key);
}
}}
>
{(list) => list}
</EuiSelectable>
</div>
</>
);
};

View file

@ -1,320 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { i18n } from '@kbn/i18n';
import { OptionsListSearchTechnique } from '../../../common/options_list/suggestions_searching';
export const OptionsListStrings = {
control: {
getSeparator: (type?: string) => {
if (['date', 'number'].includes(type ?? '')) {
return i18n.translate('controls.optionsList.control.dateSeparator', {
defaultMessage: '; ',
});
}
return i18n.translate('controls.optionsList.control.separator', {
defaultMessage: ', ',
});
},
getPlaceholder: () =>
i18n.translate('controls.optionsList.control.placeholder', {
defaultMessage: 'Any',
}),
getNegate: () =>
i18n.translate('controls.optionsList.control.negate', {
defaultMessage: 'NOT',
}),
getExcludeExists: () =>
i18n.translate('controls.optionsList.control.excludeExists', {
defaultMessage: 'DOES NOT',
}),
getInvalidSelectionWarningLabel: (invalidSelectionCount: number) =>
i18n.translate('controls.optionsList.control.invalidSelectionWarningLabel', {
defaultMessage:
'{invalidSelectionCount} {invalidSelectionCount, plural, one {selection returns} other {selections return}} no results.',
values: {
invalidSelectionCount,
},
}),
},
editor: {
getSelectionOptionsTitle: () =>
i18n.translate('controls.optionsList.editor.selectionOptionsTitle', {
defaultMessage: 'Selections',
}),
selectionTypes: {
multi: {
getLabel: () =>
i18n.translate('controls.optionsList.editor.multiSelectLabel', {
defaultMessage: 'Allow multiple selections',
}),
},
single: {
getLabel: () =>
i18n.translate('controls.optionsList.editor.singleSelectLabel', {
defaultMessage: 'Only allow a single selection',
}),
},
},
getSearchOptionsTitle: () =>
i18n.translate('controls.optionsList.editor.searchOptionsTitle', {
defaultMessage: `Searching`,
}),
searchTypes: {
prefix: {
getLabel: () =>
i18n.translate('controls.optionsList.editor.prefixSearchLabel', {
defaultMessage: 'Prefix',
}),
getTooltip: () =>
i18n.translate('controls.optionsList.editor.prefixSearchTooltip', {
defaultMessage: 'Matches values that begin with the given search string.',
}),
},
wildcard: {
getLabel: () =>
i18n.translate('controls.optionsList.editor.wildcardSearchLabel', {
defaultMessage: 'Contains',
}),
getTooltip: () =>
i18n.translate('controls.optionsList.editor.wildcardSearchTooltip', {
defaultMessage:
'Matches values that contain the given search string. Results might take longer to populate.',
}),
},
exact: {
getLabel: () =>
i18n.translate('controls.optionsList.editor.exactSearchLabel', {
defaultMessage: 'Exact',
}),
getTooltip: () =>
i18n.translate('controls.optionsList.editor.exactSearchTooltip', {
defaultMessage:
'Matches values that are equal to the given search string. Returns results quickly.',
}),
},
},
getAdditionalSettingsTitle: () =>
i18n.translate('controls.optionsList.editor.additionalSettingsTitle', {
defaultMessage: `Additional settings`,
}),
getRunPastTimeoutTitle: () =>
i18n.translate('controls.optionsList.editor.runPastTimeout', {
defaultMessage: 'Ignore timeout for results',
}),
getRunPastTimeoutTooltip: () =>
i18n.translate('controls.optionsList.editor.runPastTimeout.tooltip', {
defaultMessage:
'Wait to display results until the list is complete. This setting is useful for large data sets, but the results might take longer to populate.',
}),
},
popover: {
getAriaLabel: (fieldName: string) =>
i18n.translate('controls.optionsList.popover.ariaLabel', {
defaultMessage: 'Popover for {fieldName} control',
values: { fieldName },
}),
getSuggestionsAriaLabel: (fieldName: string, optionCount: number) =>
i18n.translate('controls.optionsList.popover.suggestionsAriaLabel', {
defaultMessage:
'Available {optionCount, plural, one {option} other {options}} for {fieldName}',
values: { fieldName, optionCount },
}),
getAllowExpensiveQueriesWarning: () =>
i18n.translate('controls.optionsList.popover.allowExpensiveQueriesWarning', {
defaultMessage:
'The cluster setting to allow expensive queries is off, so some features are disabled.',
}),
getLoadingMoreMessage: () =>
i18n.translate('controls.optionsList.popover.loadingMore', {
defaultMessage: 'Loading more options...',
}),
getAtEndOfOptionsMessage: () =>
i18n.translate('controls.optionsList.popover.endOfOptions', {
defaultMessage:
'The top 1,000 available options are displayed. View more options by searching for the name.',
}),
getEmptyMessage: () =>
i18n.translate('controls.optionsList.popover.empty', {
defaultMessage: 'No options found',
}),
getSelectionsEmptyMessage: () =>
i18n.translate('controls.optionsList.popover.selectionsEmpty', {
defaultMessage: 'You have no selections',
}),
getInvalidSelectionMessage: () =>
i18n.translate('controls.optionsList.popover.selectionError', {
defaultMessage: 'There was an error when making your selection',
}),
getInvalidSearchMessage: (fieldType: string) => {
switch (fieldType) {
case 'ip': {
return i18n.translate('controls.optionsList.popover.invalidSearch.ip', {
defaultMessage: 'Your search is not a valid IP address.',
});
}
case 'number': {
return i18n.translate('controls.optionsList.popover.invalidSearch.number', {
defaultMessage: 'Your search is not a valid number.',
});
}
default: {
// this shouldn't happen, but giving a fallback error message just in case
return i18n.translate('controls.optionsList.popover.invalidSearch.invalidCharacters', {
defaultMessage: 'Your search contains invalid characters.',
});
}
}
},
getAllOptionsButtonTitle: () =>
i18n.translate('controls.optionsList.popover.allOptionsTitle', {
defaultMessage: 'Show all options',
}),
getSelectedOptionsButtonTitle: () =>
i18n.translate('controls.optionsList.popover.selectedOptionsTitle', {
defaultMessage: 'Show only selected options',
}),
getSearchPlaceholder: (searchTechnique?: OptionsListSearchTechnique) => {
switch (searchTechnique) {
case 'prefix': {
return i18n.translate('controls.optionsList.popover.prefixSearchPlaceholder', {
defaultMessage: 'Starts with...',
});
}
case 'wildcard': {
return i18n.translate('controls.optionsList.popover.wildcardSearchPlaceholder', {
defaultMessage: 'Contains...',
});
}
case 'exact': {
return i18n.translate('controls.optionsList.popover.exactSearchPlaceholder', {
defaultMessage: 'Equals...',
});
}
}
},
getCardinalityLabel: (totalOptions: number) =>
i18n.translate('controls.optionsList.popover.cardinalityLabel', {
defaultMessage:
'{totalOptions, number} {totalOptions, plural, one {option} other {options}}',
values: { totalOptions },
}),
getInvalidSelectionsSectionAriaLabel: (fieldName: string, invalidSelectionCount: number) =>
i18n.translate('controls.optionsList.popover.invalidSelectionsAriaLabel', {
defaultMessage:
'Invalid {invalidSelectionCount, plural, one {selection} other {selections}} for {fieldName}',
values: { fieldName, invalidSelectionCount },
}),
getInvalidSelectionsSectionTitle: (invalidSelectionCount: number) =>
i18n.translate('controls.optionsList.popover.invalidSelectionsSectionTitle', {
defaultMessage:
'Invalid {invalidSelectionCount, plural, one {selection} other {selections}}',
values: { invalidSelectionCount },
}),
getInvalidSelectionsLabel: (selectedOptions: number) =>
i18n.translate('controls.optionsList.popover.invalidSelectionsLabel', {
defaultMessage:
'{selectedOptions} {selectedOptions, plural, one {selection} other {selections}} invalid',
values: { selectedOptions },
}),
getInvalidSelectionScreenReaderText: () =>
i18n.translate('controls.optionsList.popover.invalidSelectionScreenReaderText', {
defaultMessage: 'Invalid selection.',
}),
getIncludeLabel: () =>
i18n.translate('controls.optionsList.popover.includeLabel', {
defaultMessage: 'Include',
}),
getExcludeLabel: () =>
i18n.translate('controls.optionsList.popover.excludeLabel', {
defaultMessage: 'Exclude',
}),
getIncludeExcludeLegend: () =>
i18n.translate('controls.optionsList.popover.excludeOptionsLegend', {
defaultMessage: 'Include or exclude selections',
}),
getSortPopoverTitle: () =>
i18n.translate('controls.optionsList.popover.sortTitle', {
defaultMessage: 'Sort',
}),
getSortPopoverDescription: () =>
i18n.translate('controls.optionsList.popover.sortDescription', {
defaultMessage: 'Define the sort order',
}),
getSortDisabledTooltip: () =>
i18n.translate('controls.optionsList.popover.sortDisabledTooltip', {
defaultMessage: 'Sorting is ignored when “Show only selected” is true',
}),
getDocumentCountTooltip: (documentCount: number) =>
i18n.translate('controls.optionsList.popover.documentCountTooltip', {
defaultMessage:
'This value appears in {documentCount, number} {documentCount, plural, one {document} other {documents}}',
values: { documentCount },
}),
getDocumentCountScreenReaderText: (documentCount: number) =>
i18n.translate('controls.optionsList.popover.documentCountScreenReaderText', {
defaultMessage:
'Appears in {documentCount, number} {documentCount, plural, one {document} other {documents}}',
values: { documentCount },
}),
},
controlAndPopover: {
getExists: (negate: number = +false) =>
i18n.translate('controls.optionsList.controlAndPopover.exists', {
defaultMessage: '{negate, plural, one {Exist} other {Exists}}',
values: { negate },
}),
},
editorAndPopover: {
getSortDirectionLegend: () =>
i18n.translate('controls.optionsList.popover.sortDirections', {
defaultMessage: 'Sort directions',
}),
sortBy: {
_count: {
getSortByLabel: () =>
i18n.translate('controls.optionsList.popover.sortBy.docCount', {
defaultMessage: 'By document count',
}),
},
_key: {
getSortByLabel: (type?: string) => {
switch (type) {
case 'date':
return i18n.translate('controls.optionsList.popover.sortBy.date', {
defaultMessage: 'By date',
});
case 'number':
return i18n.translate('controls.optionsList.popover.sortBy.numeric', {
defaultMessage: 'Numerically',
});
default:
return i18n.translate('controls.optionsList.popover.sortBy.alphabetical', {
defaultMessage: 'Alphabetically',
});
}
},
},
},
sortOrder: {
asc: {
getSortOrderLabel: () =>
i18n.translate('controls.optionsList.popover.sortOrder.asc', {
defaultMessage: 'Ascending',
}),
},
desc: {
getSortOrderLabel: () =>
i18n.translate('controls.optionsList.popover.sortOrder.desc', {
defaultMessage: 'Descending',
}),
},
},
},
};

View file

@ -1,103 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { storybookFlightsDataView } from '@kbn/presentation-util-plugin/public/mocks';
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 { OptionsListEmbeddable } from './options_list_embeddable';
import { OptionsListEmbeddableFactory } from './options_list_embeddable_factory';
pluginServices.getServices().controls.getControlFactory = jest
.fn()
.mockImplementation((type: string) => {
if (type === OPTIONS_LIST_CONTROL) return new OptionsListEmbeddableFactory();
});
describe('initialize', () => {
describe('without selected options', () => {
test('should notify control group when initialization is finished', async () => {
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
const container = await mockControlGroupContainer(controlGroupInput);
// data view not required for test case
// setInitializationFinished is called before fetching options when value is not provided
injectStorybookDataView(undefined);
const control = await container.addOptionsListControl({
dataViewId: 'demoDataFlights',
fieldName: 'OriginCityName',
});
expect(container.getInput().panels[control.getInput().id].type).toBe(OPTIONS_LIST_CONTROL);
expect(container.getOutput().embeddableLoaded[control.getInput().id]).toBe(true);
});
});
describe('with selected options', () => {
test('should set error message when data view can not be found', async () => {
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
const container = await mockControlGroupContainer(controlGroupInput);
injectStorybookDataView(undefined);
const control = (await container.addOptionsListControl({
dataViewId: 'demoDataFlights',
fieldName: 'OriginCityName',
selectedOptions: ['Seoul', 'Tokyo'],
})) as OptionsListEmbeddable;
// await redux dispatch
await new Promise((resolve) => process.nextTick(resolve));
const reduxState = control.getState();
expect(reduxState.output.loading).toBe(false);
expect(reduxState.componentState.error).toBe(
'mock DataViews service currentDataView is undefined, call injectStorybookDataView to set'
);
});
test('should set error message when field can not be found', async () => {
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
const container = await mockControlGroupContainer(controlGroupInput);
injectStorybookDataView(storybookFlightsDataView);
const control = (await container.addOptionsListControl({
dataViewId: 'demoDataFlights',
fieldName: 'myField',
selectedOptions: ['Seoul', 'Tokyo'],
})) as OptionsListEmbeddable;
// await redux dispatch
await new Promise((resolve) => process.nextTick(resolve));
const reduxState = control.getState();
expect(reduxState.output.loading).toBe(false);
expect(reduxState.componentState.error).toBe('Could not locate field: myField');
});
test('should notify control group when initialization is finished', async () => {
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
const container = await mockControlGroupContainer(controlGroupInput);
injectStorybookDataView(storybookFlightsDataView);
const control = await container.addOptionsListControl({
dataViewId: 'demoDataFlights',
fieldName: 'OriginCityName',
selectedOptions: ['Seoul', 'Tokyo'],
});
expect(container.getInput().panels[control.getInput().id].type).toBe(OPTIONS_LIST_CONTROL);
expect(container.getOutput().embeddableLoaded[control.getInput().id]).toBe(true);
});
});
});

View file

@ -1,488 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import deepEqual from 'fast-deep-equal';
import { isEmpty, isEqual } from 'lodash';
import React, { createContext, useContext } from 'react';
import ReactDOM from 'react-dom';
import { batch } from 'react-redux';
import {
debounceTime,
distinctUntilChanged,
map,
merge,
skip,
Subject,
Subscription,
switchMap,
tap,
} from 'rxjs';
import { DataView, FieldSpec } from '@kbn/data-views-plugin/public';
import { Embeddable, IContainer } from '@kbn/embeddable-plugin/public';
import {
buildExistsFilter,
buildPhraseFilter,
buildPhrasesFilter,
compareFilters,
COMPARE_ALL_OPTIONS,
Filter,
} from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import { ReduxEmbeddableTools, ReduxToolsPackage } from '@kbn/presentation-util-plugin/public';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import { ControlGroupContainer, OPTIONS_LIST_CONTROL } from '../..';
import { OptionsListSelection } from '../../../common/options_list/options_list_selections';
import { ControlFilterOutput } from '../../control_group/types';
import { pluginServices } from '../../services';
import { ControlsDataViewsService } from '../../services/data_views/types';
import { ControlsOptionsListService } from '../../services/options_list/types';
import { CanClearSelections, ControlInput, ControlOutput } from '../../types';
import { OptionsListControl } from '../components/options_list_control';
import { getDefaultComponentState, optionsListReducers } from '../options_list_reducers';
import { MIN_OPTIONS_LIST_REQUEST_SIZE, OptionsListReduxState } from '../types';
import { OptionsListEmbeddableInput } from '..';
const diffDataFetchProps = (
last?: OptionsListDataFetchProps,
current?: OptionsListDataFetchProps
) => {
if (!current || !last) return false;
const { filters: currentFilters, ...currentWithoutFilters } = current;
const { filters: lastFilters, ...lastWithoutFilters } = last;
if (!deepEqual(currentWithoutFilters, lastWithoutFilters)) return false;
if (!compareFilters(lastFilters ?? [], currentFilters ?? [], COMPARE_ALL_OPTIONS)) return false;
return true;
};
interface OptionsListDataFetchProps {
search?: string;
fieldName: string;
dataViewId: string;
validate?: boolean;
query?: ControlInput['query'];
filters?: ControlInput['filters'];
}
export const OptionsListEmbeddableContext = createContext<OptionsListEmbeddable | null>(null);
export const useOptionsList = (): OptionsListEmbeddable => {
const optionsList = useContext<OptionsListEmbeddable | null>(OptionsListEmbeddableContext);
if (optionsList == null) {
throw new Error('useOptionsList must be used inside OptionsListEmbeddableContext.');
}
return optionsList!;
};
type OptionsListReduxEmbeddableTools = ReduxEmbeddableTools<
OptionsListReduxState,
typeof optionsListReducers
>;
export class OptionsListEmbeddable
extends Embeddable<OptionsListEmbeddableInput, ControlOutput>
implements CanClearSelections
{
public readonly type = OPTIONS_LIST_CONTROL;
public deferEmbeddableLoad = true;
public parent: ControlGroupContainer;
private subscriptions: Subscription = new Subscription();
private node?: HTMLElement;
// Controls services
private dataViewsService: ControlsDataViewsService;
private optionsListService: ControlsOptionsListService;
// Internal data fetching state for this input control.
private typeaheadSubject: Subject<string> = new Subject<string>();
private loadMoreSubject: Subject<number> = new Subject<number>();
private abortController?: AbortController;
private dataView?: DataView;
private field?: FieldSpec;
// state management
public select: OptionsListReduxEmbeddableTools['select'];
public getState: OptionsListReduxEmbeddableTools['getState'];
public dispatch: OptionsListReduxEmbeddableTools['dispatch'];
public onStateChange: OptionsListReduxEmbeddableTools['onStateChange'];
private cleanupStateTools: () => void;
constructor(
reduxToolsPackage: ReduxToolsPackage,
input: OptionsListEmbeddableInput,
output: ControlOutput,
parent?: IContainer
) {
super(input, output, parent);
this.parent = parent as ControlGroupContainer;
// Destructure controls services
({ dataViews: this.dataViewsService, optionsList: this.optionsListService } =
pluginServices.getServices());
this.typeaheadSubject = new Subject<string>();
this.loadMoreSubject = new Subject<number>();
// build redux embeddable tools
const reduxEmbeddableTools = reduxToolsPackage.createReduxEmbeddableTools<
OptionsListReduxState,
typeof optionsListReducers
>({
embeddable: this,
reducers: optionsListReducers,
initialComponentState: getDefaultComponentState(),
});
this.select = reduxEmbeddableTools.select;
this.getState = reduxEmbeddableTools.getState;
this.dispatch = reduxEmbeddableTools.dispatch;
this.cleanupStateTools = reduxEmbeddableTools.cleanup;
this.onStateChange = reduxEmbeddableTools.onStateChange;
this.initialize();
}
private initialize = async () => {
const { selectedOptions: initialSelectedOptions } = this.getInput();
if (initialSelectedOptions) {
const {
explicitInput: { existsSelected, exclude },
} = this.getState();
const { filters } = await this.selectionsToFilters({
existsSelected,
exclude,
selectedOptions: initialSelectedOptions,
});
this.dispatch.publishFilters(filters);
}
this.setInitializationFinished();
this.dispatch.setAllowExpensiveQueries(
await this.optionsListService.getAllowExpensiveQueries()
);
this.runOptionsListQuery().then(async () => {
this.setupSubscriptions();
});
};
private setupSubscriptions = () => {
const dataFetchPipe = this.getInput$().pipe(
map((newInput) => ({
validate: !Boolean(newInput.ignoreParentSettings?.ignoreValidations),
lastReloadRequestTime: newInput.lastReloadRequestTime,
existsSelected: newInput.existsSelected,
searchTechnique: newInput.searchTechnique,
dataViewId: newInput.dataViewId,
fieldName: newInput.fieldName,
timeRange: newInput.timeRange,
timeslice: newInput.timeslice,
exclude: newInput.exclude,
filters: newInput.filters,
query: newInput.query,
sort: newInput.sort,
})),
distinctUntilChanged(diffDataFetchProps)
);
// debounce typeahead pipe to slow down search string related queries
const typeaheadPipe = this.typeaheadSubject.pipe(debounceTime(100));
const loadMorePipe = this.loadMoreSubject.pipe(debounceTime(100));
// fetch available options when input changes or when search string has changed
this.subscriptions.add(
merge(dataFetchPipe, typeaheadPipe)
.pipe(skip(1)) // Skip the first input update because options list query will be run by initialize.
.subscribe(() => {
this.runOptionsListQuery();
})
);
// fetch more options when reaching the bottom of the available options
this.subscriptions.add(
loadMorePipe.subscribe((size) => {
this.runOptionsListQuery(size);
})
);
/**
* when input selectedOptions changes, check all selectedOptions against the latest value of invalidSelections, and publish filter
**/
this.subscriptions.add(
this.getInput$()
.pipe(
distinctUntilChanged(
(a, b) =>
a.exclude === b.exclude &&
a.existsSelected === b.existsSelected &&
isEqual(a.selectedOptions, b.selectedOptions)
),
tap(({ selectedOptions: newSelectedOptions }) => {
if (!newSelectedOptions || isEmpty(newSelectedOptions)) {
this.dispatch.clearValidAndInvalidSelections({});
} else {
const { invalidSelections } = this.getState().componentState ?? {};
const newValidSelections: OptionsListSelection[] = [];
const newInvalidSelections: OptionsListSelection[] = [];
for (const selectedOption of newSelectedOptions) {
if (invalidSelections?.includes(selectedOption)) {
newInvalidSelections.push(selectedOption);
continue;
}
newValidSelections.push(selectedOption);
}
this.dispatch.setValidAndInvalidSelections({
validSelections: newValidSelections,
invalidSelections: newInvalidSelections,
});
}
}),
switchMap(async () => {
const { filters: newFilters } = await this.buildFilter();
this.dispatch.publishFilters(newFilters);
})
)
.subscribe()
);
};
private getCurrentDataViewAndField = async (): Promise<{
dataView?: DataView;
field?: FieldSpec;
}> => {
const {
explicitInput: { dataViewId, fieldName },
} = this.getState();
if (!this.dataView || this.dataView.id !== dataViewId) {
try {
this.dataView = await this.dataViewsService.get(dataViewId);
} catch (e) {
this.dispatch.setErrorMessage(e.message);
}
this.dispatch.setDataViewId(this.dataView?.id);
}
if (this.dataView && (!this.field || this.field.name !== fieldName)) {
const field = this.dataView.getFieldByName(fieldName);
if (field) {
this.field = field.toSpec();
this.dispatch.setField(this.field);
} else {
this.dispatch.setErrorMessage(
i18n.translate('controls.optionsList.errors.fieldNotFound', {
defaultMessage: 'Could not locate field: {fieldName}',
values: { fieldName },
})
);
}
}
return { dataView: this.dataView, field: this.field };
};
private runOptionsListQuery = async (size: number = MIN_OPTIONS_LIST_REQUEST_SIZE) => {
const previousFieldName = this.field?.name;
const { dataView, field } = await this.getCurrentDataViewAndField();
if (!dataView || !field) return;
if (previousFieldName && field.name !== previousFieldName) {
this.dispatch.setSearchString('');
}
const {
componentState: { searchString, allowExpensiveQueries },
explicitInput: { selectedOptions, runPastTimeout, existsSelected, sort, searchTechnique },
} = this.getState();
this.dispatch.setLoading(true);
if (searchString.valid) {
// need to get filters, query, ignoreParentSettings, and timeRange from input for inheritance
const {
ignoreParentSettings,
filters,
query,
timeRange: globalTimeRange,
timeslice,
} = this.getInput();
if (this.abortController) this.abortController.abort();
this.abortController = new AbortController();
const timeRange =
timeslice !== undefined
? {
from: new Date(timeslice[0]).toISOString(),
to: new Date(timeslice[1]).toISOString(),
mode: 'absolute' as 'absolute',
}
: globalTimeRange;
const response = await this.optionsListService.runOptionsListRequest(
{
sort,
size,
field,
query,
filters,
dataView,
timeRange,
searchTechnique,
runPastTimeout,
selectedOptions,
allowExpensiveQueries,
searchString: searchString.value,
},
this.abortController.signal
);
if (this.optionsListService.optionsListResponseWasFailure(response)) {
if (response.error === 'aborted') {
// This prevents an aborted request (which can happen, for example, when a user types a search string too quickly)
// from prematurely setting loading to `false` and updating the suggestions to show "No results"
return;
}
this.dispatch.setErrorMessage(response.error.message);
return;
}
const { suggestions, invalidSelections, totalCardinality } = response;
if (
(!selectedOptions && !existsSelected) ||
isEmpty(invalidSelections) ||
ignoreParentSettings?.ignoreValidations
) {
this.dispatch.updateQueryResults({
availableOptions: suggestions,
invalidSelections: undefined,
validSelections: selectedOptions,
totalCardinality,
});
this.reportInvalidSelections(false);
} else {
const valid: OptionsListSelection[] = [];
const invalid: OptionsListSelection[] = [];
for (const selectedOption of selectedOptions ?? []) {
if (invalidSelections?.includes(selectedOption)) invalid.push(selectedOption);
else valid.push(selectedOption);
}
this.dispatch.updateQueryResults({
availableOptions: suggestions,
invalidSelections: invalid,
validSelections: valid,
totalCardinality,
});
this.reportInvalidSelections(true);
}
batch(() => {
this.dispatch.setErrorMessage(undefined);
this.dispatch.setLoading(false);
});
} else {
batch(() => {
this.dispatch.setErrorMessage(undefined);
this.dispatch.updateQueryResults({
availableOptions: [],
});
this.dispatch.setLoading(false);
});
}
};
private reportInvalidSelections = (hasInvalidSelections: boolean) => {
this.parent?.reportInvalidSelections({
id: this.id,
hasInvalidSelections,
});
};
public selectionsToFilters = async (
input: Partial<OptionsListEmbeddableInput>
): Promise<ControlFilterOutput> => {
const { existsSelected, exclude, selectedOptions } = input;
if ((!selectedOptions || isEmpty(selectedOptions)) && !existsSelected) {
return { filters: [] };
}
const { dataView, field } = await this.getCurrentDataViewAndField();
if (!dataView || !field) return { filters: [] };
let newFilter: Filter | undefined;
if (existsSelected) {
newFilter = buildExistsFilter(field, dataView);
} else if (selectedOptions) {
if (selectedOptions.length === 1) {
newFilter = buildPhraseFilter(field, selectedOptions[0], dataView);
} else {
newFilter = buildPhrasesFilter(field, selectedOptions, dataView);
}
}
if (!newFilter) return { filters: [] };
newFilter.meta.key = field?.name;
if (exclude) newFilter.meta.negate = true;
return { filters: [newFilter] };
};
private buildFilter = async (): Promise<ControlFilterOutput> => {
const {
explicitInput: { existsSelected, exclude, selectedOptions },
} = this.getState();
return await this.selectionsToFilters({
existsSelected,
exclude,
selectedOptions,
});
};
public clearSelections() {
this.dispatch.clearSelections({});
this.reportInvalidSelections(false);
}
reload = () => {
// clear cache when reload is requested
this.optionsListService.clearOptionsListCache();
this.runOptionsListQuery();
};
public destroy = () => {
super.destroy();
this.cleanupStateTools();
this.abortController?.abort();
this.subscriptions.unsubscribe();
if (this.node) ReactDOM.unmountComponentAtNode(this.node);
};
public render = (node: HTMLElement) => {
if (this.node) {
ReactDOM.unmountComponentAtNode(this.node);
}
this.node = node;
ReactDOM.render(
<KibanaRenderContextProvider {...pluginServices.getServices().core}>
<OptionsListEmbeddableContext.Provider value={this}>
<OptionsListControl
typeaheadSubject={this.typeaheadSubject}
loadMoreSubject={this.loadMoreSubject}
/>
</OptionsListEmbeddableContext.Provider>
</KibanaRenderContextProvider>,
node
);
};
public isChained() {
return true;
}
}

View file

@ -1,86 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import deepEqual from 'fast-deep-equal';
import { i18n } from '@kbn/i18n';
import { DataViewField } from '@kbn/data-views-plugin/common';
import { lazyLoadReduxToolsPackage } from '@kbn/presentation-util-plugin/public';
import { EmbeddableFactoryDefinition, IContainer } from '@kbn/embeddable-plugin/public';
import {
createOptionsListExtract,
createOptionsListInject,
} from '../../../common/options_list/options_list_persistable_state';
import {
OptionsListEmbeddableInput,
OPTIONS_LIST_CONTROL,
} from '../../../common/options_list/types';
import { OptionsListEditorOptions } from '../components/options_list_editor_options';
import { ControlEmbeddable, IEditableControlFactory } from '../../types';
export class OptionsListEmbeddableFactory
implements EmbeddableFactoryDefinition, IEditableControlFactory<OptionsListEmbeddableInput>
{
public type = OPTIONS_LIST_CONTROL;
public canCreateNew = () => false;
constructor() {}
public async create(initialInput: OptionsListEmbeddableInput, parent?: IContainer) {
const reduxEmbeddablePackage = await lazyLoadReduxToolsPackage();
const { OptionsListEmbeddable } = await import('./options_list_embeddable');
return Promise.resolve(
new OptionsListEmbeddable(reduxEmbeddablePackage, initialInput, {}, parent)
);
}
public presaveTransformFunction = (
newInput: Partial<OptionsListEmbeddableInput>,
embeddable?: ControlEmbeddable<OptionsListEmbeddableInput>
) => {
if (
embeddable &&
((newInput.fieldName && !deepEqual(newInput.fieldName, embeddable.getInput().fieldName)) ||
(newInput.dataViewId && !deepEqual(newInput.dataViewId, embeddable.getInput().dataViewId)))
) {
// if the field name or data view id has changed in this editing session, reset all selections
newInput.selectedOptions = undefined;
newInput.existsSelected = undefined;
newInput.exclude = undefined;
newInput.sort = undefined;
}
return newInput;
};
public isFieldCompatible = (field: DataViewField) => {
return (
!field.spec.scripted &&
field.aggregatable &&
['string', 'boolean', 'ip', 'date', 'number'].includes(field.type)
);
};
public controlEditorOptionsComponent = OptionsListEditorOptions;
public isEditable = () => Promise.resolve(true);
public getDisplayName = () =>
i18n.translate('controls.optionsList.displayName', {
defaultMessage: 'Options list',
});
public getIconType = () => 'editorChecklist';
public getDescription = () =>
i18n.translate('controls.optionsList.description', {
defaultMessage: 'Add a menu for selecting field values.',
});
public inject = createOptionsListInject();
public extract = createOptionsListExtract();
}

View file

@ -1,14 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export { OPTIONS_LIST_CONTROL } from '../../common/options_list/types';
export { OptionsListEmbeddableFactory } from './embeddable/options_list_embeddable_factory';
export type { OptionsListEmbeddable } from './embeddable/options_list_embeddable';
export type { OptionsListEmbeddableInput } from '../../common/options_list/types';

View file

@ -1,168 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { PayloadAction } from '@reduxjs/toolkit';
import { WritableDraft } from 'immer/dist/types/types-external';
import { FieldSpec } from '@kbn/data-views-plugin/common';
import { Filter } from '@kbn/es-query';
import { isValidSearch } from '../../common/options_list/is_valid_search';
import { OptionsListSelection } from '../../common/options_list/options_list_selections';
import {
OptionsListSortingType,
OPTIONS_LIST_DEFAULT_SORT,
} from '../../common/options_list/suggestions_sorting';
import { OptionsListComponentState, OptionsListReduxState } from './types';
export const getDefaultComponentState = (): OptionsListReduxState['componentState'] => ({
popoverOpen: false,
allowExpensiveQueries: true,
searchString: { value: '', valid: true },
});
export const optionsListReducers = {
deselectOption: (
state: WritableDraft<OptionsListReduxState>,
action: PayloadAction<OptionsListSelection>
) => {
if (!state.explicitInput.selectedOptions || !state.componentState.field) return;
const itemIndex = state.explicitInput.selectedOptions.indexOf(action.payload);
if (itemIndex !== -1) {
const newSelections = [...state.explicitInput.selectedOptions];
newSelections.splice(itemIndex, 1);
state.explicitInput.selectedOptions = newSelections;
}
},
setSearchString: (state: WritableDraft<OptionsListReduxState>, action: PayloadAction<string>) => {
state.componentState.searchString.value = action.payload;
state.componentState.searchString.valid = isValidSearch({
searchString: action.payload,
fieldType: state.componentState.field?.type,
searchTechnique: state.componentState.allowExpensiveQueries
? state.explicitInput.searchTechnique
: 'exact', // only exact match searching is supported when allowExpensiveQueries is false
});
},
setAllowExpensiveQueries: (
state: WritableDraft<OptionsListReduxState>,
action: PayloadAction<boolean>
) => {
state.componentState.allowExpensiveQueries = action.payload;
},
setInvalidSelectionWarningOpen: (
state: WritableDraft<OptionsListReduxState>,
action: PayloadAction<boolean>
) => {
state.componentState.showInvalidSelectionWarning = action.payload;
},
setPopoverOpen: (state: WritableDraft<OptionsListReduxState>, action: PayloadAction<boolean>) => {
state.componentState.popoverOpen = action.payload;
},
setSort: (
state: WritableDraft<OptionsListReduxState>,
action: PayloadAction<Partial<OptionsListSortingType>>
) => {
state.explicitInput.sort = {
...(state.explicitInput.sort ?? OPTIONS_LIST_DEFAULT_SORT),
...action.payload,
};
},
selectExists: (state: WritableDraft<OptionsListReduxState>, action: PayloadAction<boolean>) => {
if (action.payload) {
state.explicitInput.existsSelected = true;
state.explicitInput.selectedOptions = [];
} else {
state.explicitInput.existsSelected = false;
}
},
selectOption: (
state: WritableDraft<OptionsListReduxState>,
action: PayloadAction<OptionsListSelection>
) => {
if (!state.explicitInput.selectedOptions) state.explicitInput.selectedOptions = [];
if (state.explicitInput.existsSelected) state.explicitInput.existsSelected = false;
state.explicitInput.selectedOptions?.push(action.payload);
},
replaceSelection: (
state: WritableDraft<OptionsListReduxState>,
action: PayloadAction<OptionsListSelection>
) => {
state.explicitInput.selectedOptions = [action.payload];
if (state.explicitInput.existsSelected) state.explicitInput.existsSelected = false;
},
clearSelections: (state: WritableDraft<OptionsListReduxState>) => {
if (state.explicitInput.existsSelected) state.explicitInput.existsSelected = false;
if (state.explicitInput.selectedOptions) state.explicitInput.selectedOptions = [];
},
setExclude: (state: WritableDraft<OptionsListReduxState>, action: PayloadAction<boolean>) => {
state.explicitInput.exclude = action.payload;
},
clearValidAndInvalidSelections: (state: WritableDraft<OptionsListReduxState>) => {
state.componentState.invalidSelections = [];
state.componentState.validSelections = [];
},
setValidAndInvalidSelections: (
state: WritableDraft<OptionsListReduxState>,
action: PayloadAction<Pick<OptionsListComponentState, 'validSelections' | 'invalidSelections'>>
) => {
const { invalidSelections, validSelections } = action.payload;
state.componentState.invalidSelections = invalidSelections;
state.componentState.validSelections = validSelections;
},
setErrorMessage: (
state: WritableDraft<OptionsListReduxState>,
action: PayloadAction<string | undefined>
) => {
state.componentState.error = action.payload;
},
setLoading: (state: WritableDraft<OptionsListReduxState>, action: PayloadAction<boolean>) => {
state.output.loading = action.payload;
},
setField: (
state: WritableDraft<OptionsListReduxState>,
action: PayloadAction<FieldSpec | undefined>
) => {
state.componentState.field = action.payload;
},
updateQueryResults: (
state: WritableDraft<OptionsListReduxState>,
action: PayloadAction<
Pick<
OptionsListComponentState,
'availableOptions' | 'invalidSelections' | 'validSelections' | 'totalCardinality'
>
>
) => {
state.componentState = {
...(state.componentState ?? {}),
...action.payload,
};
},
publishFilters: (
state: WritableDraft<OptionsListReduxState>,
action: PayloadAction<Filter[] | undefined>
) => {
state.output.filters = action.payload;
},
setDataViewId: (
state: WritableDraft<OptionsListReduxState>,
action: PayloadAction<string | undefined>
) => {
state.output.dataViewId = action.payload;
},
setExplicitInputDataViewId: (
state: WritableDraft<OptionsListReduxState>,
action: PayloadAction<string>
) => {
state.explicitInput.dataViewId = action.payload;
},
};

View file

@ -1,48 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { FieldSpec } from '@kbn/data-views-plugin/common';
import { ReduxEmbeddableState } from '@kbn/presentation-util-plugin/public';
import { OptionsListSelection } from '../../common/options_list/options_list_selections';
import {
OptionsListEmbeddableInput,
OptionsListSuggestions,
} from '../../common/options_list/types';
import { ControlOutput } from '../types';
export const MIN_OPTIONS_LIST_REQUEST_SIZE = 10;
export const MAX_OPTIONS_LIST_REQUEST_SIZE = 1000;
interface SearchString {
value: string;
valid: boolean;
}
// Component state is only used by public components.
export interface OptionsListComponentState {
availableOptions?: OptionsListSuggestions;
invalidSelections?: OptionsListSelection[];
validSelections?: OptionsListSelection[];
allowExpensiveQueries: boolean;
searchString: SearchString;
totalCardinality?: number;
popoverOpen: boolean;
field?: FieldSpec;
error?: string;
showInvalidSelectionWarning?: boolean;
}
// public only - redux embeddable state type
export type OptionsListReduxState = ReduxEmbeddableState<
OptionsListEmbeddableInput,
ControlOutput,
OptionsListComponentState
>;

View file

@ -7,33 +7,21 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { CoreSetup, CoreStart, Plugin } from '@kbn/core/public';
import { EmbeddableFactory, PANEL_HOVER_TRIGGER } from '@kbn/embeddable-plugin/public';
import type { CoreSetup, CoreStart, Plugin } from '@kbn/core/public';
import { PANEL_HOVER_TRIGGER } from '@kbn/embeddable-plugin/public';
import {
ControlGroupContainerFactory,
CONTROL_GROUP_TYPE,
OPTIONS_LIST_CONTROL,
RANGE_SLIDER_CONTROL,
TIME_SLIDER_CONTROL,
} from '.';
import { OptionsListEmbeddableFactory, OptionsListEmbeddableInput } from './options_list';
import { RangeSliderEmbeddableFactory, RangeSliderEmbeddableInput } from './range_slider';
import { TimeSliderEmbeddableFactory, TimeSliderControlEmbeddableInput } from './time_slider';
import { controlsService } from './services/controls/controls_service';
import {
ControlsPluginSetup,
ControlsPluginStart,
ControlsPluginSetupDeps,
ControlsPluginStartDeps,
IEditableControlFactory,
ControlInput,
} from './types';
import { registerControlGroupEmbeddable } from './react_controls/control_group/register_control_group_embeddable';
import { registerOptionsListControl } from './react_controls/controls/data_controls/options_list_control/register_options_list_control';
import { registerRangeSliderControl } from './react_controls/controls/data_controls/range_slider/register_range_slider_control';
import { registerTimeSliderControl } from './react_controls/controls/timeslider_control/register_timeslider_control';
import { EditControlAction } from './react_controls/actions/edit_control_action/edit_control_action';
import { controlsService } from './services/controls/controls_service';
import type {
ControlsPluginSetup,
ControlsPluginSetupDeps,
ControlsPluginStart,
ControlsPluginStartDeps,
} from './types';
export class ControlsPlugin
implements
Plugin<
@ -51,22 +39,11 @@ export class ControlsPlugin
pluginServices.setRegistry(registry.start({ coreStart, startPlugins }));
}
private transferEditorFunctions<I extends ControlInput = ControlInput>(
factoryDef: IEditableControlFactory<I>,
factory: EmbeddableFactory
) {
(factory as IEditableControlFactory<I>).controlEditorOptionsComponent =
factoryDef.controlEditorOptionsComponent ?? undefined;
(factory as IEditableControlFactory<I>).presaveTransformFunction =
factoryDef.presaveTransformFunction;
(factory as IEditableControlFactory<I>).isFieldCompatible = factoryDef.isFieldCompatible;
}
public setup(
_coreSetup: CoreSetup<ControlsPluginStartDeps, ControlsPluginStart>,
_setupPlugins: ControlsPluginSetupDeps
): ControlsPluginSetup {
const { registerControlType } = controlsService;
const { registerControlFactory } = controlsService;
const { embeddable } = _setupPlugins;
registerControlGroupEmbeddable(_coreSetup, embeddable);
@ -74,51 +51,8 @@ export class ControlsPlugin
registerRangeSliderControl(_coreSetup);
registerTimeSliderControl(_coreSetup);
// register control group embeddable factory
_coreSetup.getStartServices().then(([, deps]) => {
embeddable.registerEmbeddableFactory(
CONTROL_GROUP_TYPE,
new ControlGroupContainerFactory(deps.embeddable)
);
// Options List control factory setup
const optionsListFactoryDef = new OptionsListEmbeddableFactory();
const optionsListFactory = embeddable.registerEmbeddableFactory(
OPTIONS_LIST_CONTROL,
optionsListFactoryDef
)();
this.transferEditorFunctions<OptionsListEmbeddableInput>(
optionsListFactoryDef,
optionsListFactory
);
registerControlType(optionsListFactory);
// Register range slider
const rangeSliderFactoryDef = new RangeSliderEmbeddableFactory();
const rangeSliderFactory = embeddable.registerEmbeddableFactory(
RANGE_SLIDER_CONTROL,
rangeSliderFactoryDef
)();
this.transferEditorFunctions<RangeSliderEmbeddableInput>(
rangeSliderFactoryDef,
rangeSliderFactory
);
registerControlType(rangeSliderFactory);
const timeSliderFactoryDef = new TimeSliderEmbeddableFactory();
const timeSliderFactory = embeddable.registerEmbeddableFactory(
TIME_SLIDER_CONTROL,
timeSliderFactoryDef
)();
this.transferEditorFunctions<TimeSliderControlEmbeddableInput>(
timeSliderFactoryDef,
timeSliderFactory
);
registerControlType(timeSliderFactory);
});
return {
registerControlType,
registerControlFactory,
};
}
@ -126,7 +60,13 @@ export class ControlsPlugin
this.startControlsKibanaServices(coreStart, startPlugins).then(async () => {
const { uiActions } = startPlugins;
const { DeleteControlAction } = await import('./control_group/actions/delete_control_action');
const [{ DeleteControlAction }, { EditControlAction }, { ClearControlAction }] =
await Promise.all([
import('./actions/delete_control_action'),
import('./actions/edit_control_action'),
import('./actions/clear_control_action'),
]);
const deleteControlAction = new DeleteControlAction();
uiActions.registerAction(deleteControlAction);
uiActions.attachAction(PANEL_HOVER_TRIGGER, deleteControlAction.id);
@ -135,28 +75,15 @@ export class ControlsPlugin
uiActions.registerAction(editControlAction);
uiActions.attachAction(PANEL_HOVER_TRIGGER, editControlAction.id);
/**
* TODO: Remove edit legacy control embeddable action when embeddable controls are removed
*/
const { EditLegacyEmbeddableControlAction } = await import(
'./control_group/actions/edit_control_action'
);
const editLegacyEmbeddableControlAction = new EditLegacyEmbeddableControlAction(
deleteControlAction
);
uiActions.registerAction(editLegacyEmbeddableControlAction);
uiActions.attachAction(PANEL_HOVER_TRIGGER, editLegacyEmbeddableControlAction.id);
const { ClearControlAction } = await import('./control_group/actions/clear_control_action');
const clearControlAction = new ClearControlAction();
uiActions.registerAction(clearControlAction);
uiActions.attachAction(PANEL_HOVER_TRIGGER, clearControlAction.id);
});
const { getControlFactory, getControlTypes } = controlsService;
const { getControlFactory, getAllControlTypes } = controlsService;
return {
getControlFactory,
getControlTypes,
getAllControlTypes,
};
}

View file

@ -1,233 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { debounce } from 'lodash';
import React, { FC, useState, useMemo, useEffect, useCallback, useRef } from 'react';
import {
EuiRangeTick,
EuiDualRange,
EuiDualRangeProps,
EuiToken,
EuiToolTip,
useEuiTheme,
} from '@elastic/eui';
import { RangeValue } from '../../../common/range_slider/types';
import { useRangeSlider } from '../embeddable/range_slider_embeddable';
import { ControlError } from '../../control_group/component/control_error_component';
import { MIN_POPOVER_WIDTH } from '../../constants';
import { useFieldFormatter } from '../../hooks/use_field_formatter';
import { rangeSliderControlStyles } from '../../react_controls/controls/data_controls/range_slider/components/range_slider.styles';
import { RangeSliderStrings } from './range_slider_strings';
export const RangeSliderControl: FC = () => {
/** Controls Services Context */
const rangeSlider = useRangeSlider();
const rangeSliderRef = useRef<EuiDualRangeProps | null>(null);
// Embeddable explicit input
const id = rangeSlider.select((state) => state.explicitInput.id);
const value = rangeSlider.select((state) => state.explicitInput.value);
const step = rangeSlider.select((state) => state.explicitInput.step);
// Embeddable component state
const min = rangeSlider.select((state) => state.componentState.min);
const max = rangeSlider.select((state) => state.componentState.max);
const error = rangeSlider.select((state) => state.componentState.error);
const fieldSpec = rangeSlider.select((state) => state.componentState.field);
const isInvalid = rangeSlider.select((state) => state.componentState.isInvalid);
// Embeddable output
const isLoading = rangeSlider.select((state) => state.output.loading);
const dataViewId = rangeSlider.select((state) => state.output.dataViewId);
// React component state
const [displayedValue, setDisplayedValue] = useState<RangeValue>(value ?? ['', '']);
const fieldFormatter = useFieldFormatter({ dataViewId, fieldSpec });
const debouncedOnChange = useMemo(
() =>
debounce((newRange: RangeValue) => {
rangeSlider.dispatch.setSelectedRange(newRange);
}, 750),
[rangeSlider.dispatch]
);
/**
* This will recalculate the displayed min/max of the range slider to allow for selections smaller
* than the `min` and larger than the `max`
*/
const [displayedMin, displayedMax] = useMemo((): [number, number] => {
if (min === undefined || max === undefined) return [-Infinity, Infinity];
const selectedValue = value ?? ['', ''];
const [selectedMin, selectedMax] = [
selectedValue[0] === '' ? min : parseFloat(selectedValue[0]),
selectedValue[1] === '' ? max : parseFloat(selectedValue[1]),
];
if (!step) return [Math.min(selectedMin, min), Math.max(selectedMax, max ?? Infinity)];
const minTick = Math.floor(Math.min(selectedMin, min) / step) * step;
const maxTick = Math.ceil(Math.max(selectedMax, max) / step) * step;
return [Math.min(selectedMin, min, minTick), Math.max(selectedMax, max ?? Infinity, maxTick)];
}, [min, max, value, step]);
/**
* The following `useEffect` ensures that the changes to the value that come from the embeddable (for example,
* from the `reset` button on the dashboard or via chaining) are reflected in the displayed value
*/
useEffect(() => {
setDisplayedValue(value ?? ['', '']);
}, [value]);
const ticks: EuiRangeTick[] = useMemo(() => {
return [
{ value: displayedMin ?? -Infinity, label: fieldFormatter(String(displayedMin)) },
{ value: displayedMax ?? Infinity, label: fieldFormatter(String(displayedMax)) },
];
}, [displayedMin, displayedMax, fieldFormatter]);
const levels = useMemo(() => {
if (!step || min === undefined || max === undefined) {
return [
{
min: min ?? -Infinity,
max: max ?? Infinity,
color: 'success',
},
];
}
const roundedMin = Math.floor(min / step) * step;
const roundedMax = Math.ceil(max / step) * step;
return [
{
min: roundedMin,
max: roundedMax,
color: 'success',
},
];
}, [step, min, max]);
const disablePopover = useMemo(
() =>
isLoading ||
displayedMin === -Infinity ||
displayedMax === Infinity ||
displayedMin === displayedMax,
[isLoading, displayedMin, displayedMax]
);
const euiTheme = useEuiTheme();
const styles = rangeSliderControlStyles(euiTheme);
const getCommonInputProps = useCallback(
({
inputValue,
testSubj,
placeholder,
}: {
inputValue: string;
testSubj: string;
placeholder: string;
}) => {
return {
isInvalid: undefined, // disabling this prop to handle our own validation styling
placeholder,
readOnly: false, // overwrites `canOpenPopover` to ensure that the inputs are always clickable
css: [
styles.fieldNumbers.rangeSliderFieldNumber,
isInvalid ? styles.fieldNumbers.invalid : styles.fieldNumbers.valid,
],
className: 'rangeSliderAnchor__fieldNumber',
'data-test-subj': `rangeSlider__${testSubj}`,
value: inputValue === placeholder ? '' : inputValue,
title: !isInvalid && step ? '' : undefined, // overwrites native number input validation error when the value falls between two steps
};
},
[isInvalid, step, styles]
);
return error ? (
<ControlError error={error} />
) : (
<div
css={[styles.rangeSliderControl, isInvalid && styles.invalid]}
className="rangeSliderAnchor__button"
data-test-subj={`range-slider-control-${id}`}
>
<EuiDualRange
ref={rangeSliderRef}
id={id}
fullWidth
showTicks
compressed
step={step}
ticks={ticks}
levels={levels}
min={displayedMin}
max={displayedMax}
isLoading={isLoading}
inputPopoverProps={{
panelMinWidth: MIN_POPOVER_WIDTH,
}}
append={
isInvalid ? (
<div className="rangeSlider__invalidToken">
<EuiToolTip
position="top"
content={RangeSliderStrings.control.getInvalidSelectionWarningLabel()}
delay="long"
>
<EuiToken
tabIndex={0}
iconType="alert"
size="s"
color="euiColorVis5"
shape="square"
fill="dark"
title={RangeSliderStrings.control.getInvalidSelectionWarningLabel()}
/>
</EuiToolTip>
</div>
) : undefined
}
onMouseUp={() => {
// when the pin is dropped (on mouse up), cancel any pending debounced changes and force the change
// in value to happen instantly (which, in turn, will re-calculate the min/max for the slider due to
// the `useEffect` above.
debouncedOnChange.cancel();
rangeSlider.dispatch.setSelectedRange(displayedValue);
}}
readOnly={disablePopover}
showInput={'inputWithPopover'}
data-test-subj="rangeSlider__slider"
minInputProps={getCommonInputProps({
inputValue: displayedValue[0],
testSubj: 'lowerBoundFieldNumber',
placeholder: String(min ?? -Infinity),
})}
maxInputProps={getCommonInputProps({
inputValue: displayedValue[1],
testSubj: 'upperBoundFieldNumber',
placeholder: String(max ?? Infinity),
})}
value={[displayedValue[0] || displayedMin, displayedValue[1] || displayedMax]}
onChange={([minSelection, maxSelection]: [number | string, number | string]) => {
setDisplayedValue([String(minSelection), String(maxSelection)]);
debouncedOnChange([String(minSelection), String(maxSelection)]);
}}
/>
</div>
);
};

View file

@ -1,43 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useState } from 'react';
import { EuiFormRow, EuiFieldNumber } from '@elastic/eui';
import type { RangeSliderEmbeddableInput } from '../../../common/range_slider/types';
import { ControlEditorProps } from '../../types';
import { RangeSliderStrings } from './range_slider_strings';
export const RangeSliderEditorOptions = ({
initialInput,
onChange,
setControlEditorValid,
}: ControlEditorProps<RangeSliderEmbeddableInput>) => {
const [step, setStep] = useState<number>(initialInput?.step || 1);
return (
<>
<EuiFormRow fullWidth label={RangeSliderStrings.editor.getStepTitle()}>
<EuiFieldNumber
value={step}
onChange={(event) => {
const newStep = event.target.valueAsNumber;
onChange({ step: newStep });
setStep(newStep);
setControlEditorValid(newStep > 0);
}}
min={0}
isInvalid={step <= 0}
data-test-subj="rangeSliderControl__stepAdditionalSetting"
/>
</EuiFormRow>
</>
);
};

View file

@ -1,31 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { i18n } from '@kbn/i18n';
export const RangeSliderStrings = {
control: {
getInvalidSelectionWarningLabel: () =>
i18n.translate('controls.rangeSlider.control.invalidSelectionWarningLabel', {
defaultMessage: 'Selected range returns no results.',
}),
},
editor: {
getStepTitle: () =>
i18n.translate('controls.rangeSlider.editor.stepSizeTitle', {
defaultMessage: 'Step size',
}),
},
popover: {
getNoAvailableDataHelpText: () =>
i18n.translate('controls.rangeSlider.popover.noAvailableDataHelpText', {
defaultMessage: 'There is no data to display. Adjust the time range and filters.',
}),
},
};

View file

@ -1,203 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { storybookFlightsDataView } from '@kbn/presentation-util-plugin/public/mocks';
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 { RangeSliderEmbeddable } from './range_slider_embeddable';
import { RangeSliderEmbeddableFactory } from './range_slider_embeddable_factory';
let totalResults = 20;
beforeEach(() => {
totalResults = 20;
pluginServices.getServices().controls.getControlFactory = jest
.fn()
.mockImplementation((type: string) => {
if (type === RANGE_SLIDER_CONTROL) return new RangeSliderEmbeddableFactory();
});
pluginServices.getServices().data.searchSource.create = jest.fn().mockImplementation(() => {
let isAggsRequest = false;
return {
setField: (key: string) => {
if (key === 'aggs') {
isAggsRequest = true;
}
},
fetch$: () => {
return isAggsRequest
? of({
rawResponse: { aggregations: { minAgg: { value: 0 }, maxAgg: { value: 1000 } } },
})
: of({
rawResponse: { hits: { total: { value: totalResults } } },
});
},
};
});
});
describe('initialize', () => {
describe('without selected range', () => {
test('should notify control group when initialization is finished', async () => {
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as 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
injectStorybookDataView(undefined);
const control = await container.addRangeSliderControl({
dataViewId: 'demoDataFlights',
fieldName: 'AvgTicketPrice',
});
expect(container.getInput().panels[control.getInput().id].type).toBe(RANGE_SLIDER_CONTROL);
expect(container.getOutput().embeddableLoaded[control.getInput().id]).toBe(true);
});
});
describe('with selected range', () => {
test('should set error message when data view can not be found', async () => {
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
const container = await mockControlGroupContainer(controlGroupInput);
injectStorybookDataView(undefined);
const control = (await container.addRangeSliderControl({
dataViewId: 'demoDataFlights',
fieldName: 'AvgTicketPrice',
value: ['150', '300'],
})) as RangeSliderEmbeddable;
// await redux dispatch
await new Promise((resolve) => process.nextTick(resolve));
const reduxState = control.getState();
expect(reduxState.output.loading).toBe(false);
expect(reduxState.componentState.error).toBe(
'mock DataViews service currentDataView is undefined, call injectStorybookDataView to set'
);
});
test('should set error message when field can not be found', async () => {
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
const container = await mockControlGroupContainer(controlGroupInput);
injectStorybookDataView(storybookFlightsDataView);
const control = (await container.addRangeSliderControl({
dataViewId: 'demoDataFlights',
fieldName: 'myField',
value: ['150', '300'],
})) as RangeSliderEmbeddable;
// await redux dispatch
await new Promise((resolve) => process.nextTick(resolve));
const reduxState = control.getState();
expect(reduxState.output.loading).toBe(false);
expect(reduxState.componentState.error).toBe('Could not locate field: myField');
});
test('should set invalid state when filter returns zero results', async () => {
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
const container = await mockControlGroupContainer(controlGroupInput);
injectStorybookDataView(storybookFlightsDataView);
totalResults = 0;
const control = (await container.addRangeSliderControl({
dataViewId: 'demoDataFlights',
fieldName: 'AvgTicketPrice',
value: ['150', '300'],
})) as RangeSliderEmbeddable;
// await redux dispatch
await new Promise((resolve) => process.nextTick(resolve));
const reduxState = control.getState();
expect(reduxState.output.filters?.length).toBe(1);
expect(reduxState.componentState.isInvalid).toBe(true);
});
test('should set range and filter', async () => {
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
const container = await mockControlGroupContainer(controlGroupInput);
injectStorybookDataView(storybookFlightsDataView);
const control = (await container.addRangeSliderControl({
dataViewId: 'demoDataFlights',
fieldName: 'AvgTicketPrice',
value: ['150', '300'],
})) as RangeSliderEmbeddable;
// await redux dispatch
await new Promise((resolve) => process.nextTick(resolve));
const reduxState = control.getState();
expect(reduxState.output.filters?.length).toBe(1);
expect(reduxState.output.filters?.[0].query).toEqual({
range: {
AvgTicketPrice: {
gte: 150,
lte: 300,
},
},
});
expect(reduxState.componentState.isInvalid).toBe(false);
expect(reduxState.componentState.min).toBe(0);
expect(reduxState.componentState.max).toBe(1000);
});
test('should notify control group when initialization is finished', async () => {
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
const container = await mockControlGroupContainer(controlGroupInput);
injectStorybookDataView(storybookFlightsDataView);
const control = await container.addRangeSliderControl({
dataViewId: 'demoDataFlights',
fieldName: 'AvgTicketPrice',
value: ['150', '300'],
});
expect(container.getInput().panels[control.getInput().id].type).toBe(RANGE_SLIDER_CONTROL);
expect(container.getOutput().embeddableLoaded[control.getInput().id]).toBe(true);
});
test('should notify control group when initialization throws', async () => {
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
const container = await mockControlGroupContainer(controlGroupInput);
injectStorybookDataView(storybookFlightsDataView);
pluginServices.getServices().data.searchSource.create = jest.fn().mockImplementation(() => ({
setField: () => {},
fetch$: () => {
throw new Error('Simulated _search request error');
},
}));
const control = await container.addRangeSliderControl({
dataViewId: 'demoDataFlights',
fieldName: 'AvgTicketPrice',
value: ['150', '300'],
});
expect(container.getInput().panels[control.getInput().id].type).toBe(RANGE_SLIDER_CONTROL);
expect(container.getOutput().embeddableLoaded[control.getInput().id]).toBe(true);
});
});
});

View file

@ -1,457 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import deepEqual from 'fast-deep-equal';
import { get, isEmpty, isEqual } from 'lodash';
import React, { createContext, useContext } from 'react';
import ReactDOM from 'react-dom';
import { batch } from 'react-redux';
import { lastValueFrom, Subscription, switchMap } from 'rxjs';
import { distinctUntilChanged, map } from 'rxjs';
import { DataView, DataViewField } from '@kbn/data-views-plugin/public';
import { Embeddable, IContainer } from '@kbn/embeddable-plugin/public';
import {
buildRangeFilter,
compareFilters,
COMPARE_ALL_OPTIONS,
Filter,
RangeFilterParams,
} from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import { ReduxEmbeddableTools, ReduxToolsPackage } from '@kbn/presentation-util-plugin/public';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import { ControlGroupContainer, 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';
import { CanClearSelections, ControlInput, ControlOutput } from '../../types';
import { RangeSliderControl } from '../components/range_slider_control';
import { getDefaultComponentState, rangeSliderReducers } from '../range_slider_reducers';
import { RangeSliderReduxState } from '../types';
import { RangeSliderEmbeddableInput } from '..';
const diffDataFetchProps = (
current?: RangeSliderDataFetchProps,
last?: RangeSliderDataFetchProps
) => {
if (!current || !last) return false;
const { filters: currentFilters, ...currentWithoutFilters } = current;
const { filters: lastFilters, ...lastWithoutFilters } = last;
if (!deepEqual(currentWithoutFilters, lastWithoutFilters)) return false;
if (!compareFilters(lastFilters ?? [], currentFilters ?? [], COMPARE_ALL_OPTIONS)) return false;
return true;
};
interface RangeSliderDataFetchProps {
fieldName: string;
dataViewId: string;
query?: ControlInput['query'];
filters?: ControlInput['filters'];
validate?: boolean;
}
export const RangeSliderControlContext = createContext<RangeSliderEmbeddable | null>(null);
export const useRangeSlider = (): RangeSliderEmbeddable => {
const rangeSlider = useContext<RangeSliderEmbeddable | null>(RangeSliderControlContext);
if (rangeSlider == null) {
throw new Error('useRangeSlider must be used inside RangeSliderControlContext.');
}
return rangeSlider!;
};
type RangeSliderReduxEmbeddableTools = ReduxEmbeddableTools<
RangeSliderReduxState,
typeof rangeSliderReducers
>;
export class RangeSliderEmbeddable
extends Embeddable<RangeSliderEmbeddableInput, ControlOutput>
implements CanClearSelections
{
public readonly type = RANGE_SLIDER_CONTROL;
public deferEmbeddableLoad = true;
public parent: ControlGroupContainer;
private subscriptions: Subscription = new Subscription();
private node?: HTMLElement;
// Controls services
private dataService: ControlsDataService;
private dataViewsService: ControlsDataViewsService;
// Internal data fetching state for this input control.
private dataView?: DataView;
private field?: DataViewField;
// state management
public select: RangeSliderReduxEmbeddableTools['select'];
public getState: RangeSliderReduxEmbeddableTools['getState'];
public dispatch: RangeSliderReduxEmbeddableTools['dispatch'];
public onStateChange: RangeSliderReduxEmbeddableTools['onStateChange'];
private cleanupStateTools: () => void;
constructor(
reduxToolsPackage: ReduxToolsPackage,
input: RangeSliderEmbeddableInput,
output: ControlOutput,
parent?: IContainer
) {
super(input, output, parent); // get filters for initial output...
this.parent = parent as ControlGroupContainer;
// Destructure controls services
({ data: this.dataService, dataViews: this.dataViewsService } = pluginServices.getServices());
const reduxEmbeddableTools = reduxToolsPackage.createReduxEmbeddableTools<
RangeSliderReduxState,
typeof rangeSliderReducers
>({
embeddable: this,
reducers: rangeSliderReducers,
initialComponentState: getDefaultComponentState(),
});
this.select = reduxEmbeddableTools.select;
this.getState = reduxEmbeddableTools.getState;
this.dispatch = reduxEmbeddableTools.dispatch;
this.onStateChange = reduxEmbeddableTools.onStateChange;
this.cleanupStateTools = reduxEmbeddableTools.cleanup;
this.initialize();
}
private initialize = async () => {
const [initialMin, initialMax] = this.getInput().value ?? [];
if (!isEmpty(initialMin) || !isEmpty(initialMax)) {
const { filters: rangeFilter } = await this.buildFilter();
this.dispatch.publishFilters(rangeFilter);
}
this.setInitializationFinished();
this.runRangeSliderQuery()
.then(async () => {
this.setupSubscriptions();
})
.catch((e) => this.onLoadingError(e.message));
};
private setupSubscriptions = () => {
const dataFetchPipe = this.getInput$().pipe(
map((newInput) => ({
validate: !Boolean(newInput.ignoreParentSettings?.ignoreValidations),
lastReloadRequestTime: newInput.lastReloadRequestTime,
dataViewId: newInput.dataViewId,
fieldName: newInput.fieldName,
timeRange: newInput.timeRange,
timeslice: newInput.timeslice,
filters: newInput.filters,
query: newInput.query,
})),
distinctUntilChanged(diffDataFetchProps)
);
const valueChangePipe = this.getInput$().pipe(
distinctUntilChanged((a, b) => isEqual(a.value ?? ['', ''], b.value ?? ['', '']))
);
this.subscriptions.add(
dataFetchPipe
.pipe(
switchMap(async () => {
try {
this.dispatch.setLoading(true);
await this.runRangeSliderQuery();
await this.runValidations();
this.dispatch.setLoading(false);
} catch (e) {
this.onLoadingError(e.message);
}
})
)
.subscribe()
);
// publish filters when value changes
this.subscriptions.add(
valueChangePipe
.pipe(
switchMap(async () => {
try {
this.dispatch.setLoading(true);
const { filters: rangeFilter } = await this.buildFilter();
this.dispatch.publishFilters(rangeFilter);
await this.runValidations();
this.dispatch.setLoading(false);
} catch (e) {
this.onLoadingError(e.message);
}
})
)
.subscribe()
);
};
private getCurrentDataViewAndField = async (): Promise<{
dataView?: DataView;
field?: DataViewField;
}> => {
const {
explicitInput: { dataViewId, fieldName },
} = this.getState();
if (!this.dataView || this.dataView.id !== dataViewId) {
try {
this.dataView = await this.dataViewsService.get(dataViewId);
this.dispatch.setDataViewId(this.dataView.id);
} catch (e) {
this.onLoadingError(e.message);
}
}
if (this.dataView && (!this.field || this.field.name !== fieldName)) {
this.field = this.dataView.getFieldByName(fieldName);
if (this.field) {
this.dispatch.setField(this.field?.toSpec());
} else {
this.onLoadingError(
i18n.translate('controls.rangeSlider.errors.fieldNotFound', {
defaultMessage: 'Could not locate field: {fieldName}',
values: { fieldName },
})
);
}
}
return { dataView: this.dataView, field: this.field };
};
private runRangeSliderQuery = async () => {
const { dataView, field } = await this.getCurrentDataViewAndField();
if (!dataView || !field) return;
const { min, max } = await this.fetchMinMax({
dataView,
field,
});
batch(() => {
this.dispatch.setMinMax({ min, max });
this.dispatch.setDataViewId(dataView.id);
this.dispatch.setErrorMessage(undefined);
});
};
private fetchMinMax = async ({
dataView,
field,
}: {
dataView: DataView;
field: DataViewField;
}): Promise<{ min?: number; max?: number }> => {
const { query } = this.getInput();
const searchSource = await this.dataService.searchSource.create();
searchSource.setField('size', 0);
searchSource.setField('index', dataView);
searchSource.setField('filter', this.getGlobalFilters(dataView));
if (query) {
searchSource.setField('query', query);
}
const aggBody: any = {};
if (field) {
if (field.scripted) {
aggBody.script = {
source: field.script,
lang: field.lang,
};
} else {
aggBody.field = field.name;
}
}
const aggs = {
maxAgg: {
max: aggBody,
},
minAgg: {
min: aggBody,
},
};
searchSource.setField('aggs', aggs);
const resp = await lastValueFrom(searchSource.fetch$());
const min = get(resp, 'rawResponse.aggregations.minAgg.value');
const max = get(resp, 'rawResponse.aggregations.maxAgg.value');
return { min, max };
};
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 || (isEmpty(selectedMin) && isEmpty(selectedMax))) {
return { filters: [] };
}
const params = {} as RangeFilterParams;
if (selectedMin) {
params.gte = min;
}
if (selectedMax) {
params.lte = max;
}
const rangeFilter = buildRangeFilter(field, params, dataView);
rangeFilter.meta.key = field?.name;
rangeFilter.meta.type = 'range';
rangeFilter.meta.params = params;
return { filters: [rangeFilter] };
};
private buildFilter = async () => {
const {
explicitInput: { value },
} = this.getState();
return await this.selectionsToFilters({ value });
};
private onLoadingError(errorMessage: string) {
batch(() => {
this.dispatch.setLoading(false);
this.dispatch.publishFilters([]);
this.dispatch.setErrorMessage(errorMessage);
});
}
private getGlobalFilters = (dataView: DataView) => {
const {
filters: globalFilters,
ignoreParentSettings,
timeRange: globalTimeRange,
timeslice,
} = this.getInput();
const filters: Filter[] = [];
if (!ignoreParentSettings?.ignoreFilters && globalFilters) {
filters.push(...globalFilters);
}
const timeRange =
timeslice !== undefined
? {
from: new Date(timeslice[0]).toISOString(),
to: new Date(timeslice[1]).toISOString(),
mode: 'absolute' as 'absolute',
}
: globalTimeRange;
if (!ignoreParentSettings?.ignoreTimerange && timeRange) {
const timeFilter = this.dataService.timefilter.createFilter(dataView, timeRange);
if (timeFilter) filters.push(timeFilter);
}
return filters;
};
private runValidations = async () => {
const { dataView } = await this.getCurrentDataViewAndField();
if (!dataView) return;
// Check if new range filter results in no data
const { ignoreParentSettings, query } = this.getInput();
if (ignoreParentSettings?.ignoreValidations) {
this.dispatch.setIsInvalid(false);
} else {
const searchSource = await this.dataService.searchSource.create();
const { filters: rangeFilters = [] } = this.getOutput();
const filters = this.getGlobalFilters(dataView).concat(rangeFilters);
searchSource.setField('size', 0);
searchSource.setField('index', dataView);
searchSource.setField('filter', filters);
if (query) {
searchSource.setField('query', query);
}
const resp = await lastValueFrom(searchSource.fetch$());
const total = resp?.rawResponse?.hits?.total;
const docCount = typeof total === 'number' ? total : total?.value;
const {
explicitInput: { value },
} = this.getState();
this.reportInvalidSelections(
!value || (value[0] === '' && value[1] === '') ? false : !docCount // don't set the range slider invalid if it has no selections
);
}
};
private reportInvalidSelections = (hasInvalidSelections: boolean) => {
this.dispatch.setIsInvalid(hasInvalidSelections);
this.parent?.reportInvalidSelections({
id: this.id,
hasInvalidSelections,
});
};
public clearSelections() {
this.dispatch.setSelectedRange(['', '']);
}
public reload = async () => {
this.dispatch.setLoading(true);
try {
await this.runRangeSliderQuery();
this.dispatch.setLoading(false);
} catch (e) {
this.onLoadingError(e.message);
}
};
public destroy = () => {
super.destroy();
this.cleanupStateTools();
this.subscriptions.unsubscribe();
};
public render = (node: HTMLElement) => {
if (this.node) {
ReactDOM.unmountComponentAtNode(this.node);
}
this.node = node;
const ControlsServicesProvider = pluginServices.getContextProvider();
ReactDOM.render(
<KibanaRenderContextProvider {...pluginServices.getServices().core}>
<ControlsServicesProvider>
<RangeSliderControlContext.Provider value={this}>
<RangeSliderControl />
</RangeSliderControlContext.Provider>
</ControlsServicesProvider>
</KibanaRenderContextProvider>,
node
);
};
public isChained() {
return true;
}
}

View file

@ -1,82 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { i18n } from '@kbn/i18n';
import deepEqual from 'fast-deep-equal';
import { DataViewField } from '@kbn/data-views-plugin/common';
import { lazyLoadReduxToolsPackage } from '@kbn/presentation-util-plugin/public';
import { EmbeddableFactoryDefinition, IContainer } from '@kbn/embeddable-plugin/public';
import {
createRangeSliderExtract,
createRangeSliderInject,
} from '../../../common/range_slider/range_slider_persistable_state';
import {
RangeSliderEmbeddableInput,
RANGE_SLIDER_CONTROL,
} from '../../../common/range_slider/types';
import { ControlEmbeddable, IEditableControlFactory } from '../../types';
import { RangeSliderEditorOptions } from '../components/range_slider_editor_options';
export class RangeSliderEmbeddableFactory
implements EmbeddableFactoryDefinition, IEditableControlFactory<RangeSliderEmbeddableInput>
{
public type = RANGE_SLIDER_CONTROL;
public getDisplayName = () =>
i18n.translate('controls.rangeSlider.displayName', {
defaultMessage: 'Range slider',
});
public getDescription = () =>
i18n.translate('controls.rangeSlider.description', {
defaultMessage: 'Add a control for selecting a range of field values.',
});
public getIconType = () => 'controlsHorizontal';
public canCreateNew = () => false;
public isEditable = () => Promise.resolve(true);
public controlEditorOptionsComponent = RangeSliderEditorOptions;
public async create(initialInput: RangeSliderEmbeddableInput, parent?: IContainer) {
const reduxEmbeddablePackage = await lazyLoadReduxToolsPackage();
const { RangeSliderEmbeddable } = await import('./range_slider_embeddable');
return Promise.resolve(
new RangeSliderEmbeddable(reduxEmbeddablePackage, initialInput, {}, parent)
);
}
public presaveTransformFunction = (
newInput: Partial<RangeSliderEmbeddableInput>,
embeddable?: ControlEmbeddable<RangeSliderEmbeddableInput>
) => {
if (
embeddable &&
((newInput.fieldName && !deepEqual(newInput.fieldName, embeddable.getInput().fieldName)) ||
(newInput.dataViewId && !deepEqual(newInput.dataViewId, embeddable.getInput().dataViewId)))
) {
// if the field name or data view id has changed in this editing session, selected values are invalid, so reset them.
newInput.value = ['', ''];
}
return newInput;
};
public isFieldCompatible = (field: DataViewField) => {
return field.aggregatable && field.type === 'number';
};
public inject = createRangeSliderInject();
public extract = createRangeSliderExtract();
}

View file

@ -1,14 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export type { RangeSliderEmbeddableInput } from '../../common/range_slider/types';
export { RANGE_SLIDER_CONTROL } from '../../common/range_slider/types';
export type { RangeSliderEmbeddable } from './embeddable/range_slider_embeddable';
export { RangeSliderEmbeddableFactory } from './embeddable/range_slider_embeddable_factory';

View file

@ -1,75 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { WritableDraft } from 'immer/dist/types/types-external';
import { PayloadAction } from '@reduxjs/toolkit';
import { FieldSpec } from '@kbn/data-views-plugin/common';
import { Filter } from '@kbn/es-query';
import { RangeSliderReduxState } from './types';
import { RangeValue } from '../../common/range_slider/types';
export const getDefaultComponentState = (): RangeSliderReduxState['componentState'] => ({
isInvalid: false,
});
export const rangeSliderReducers = {
setSelectedRange: (
state: WritableDraft<RangeSliderReduxState>,
action: PayloadAction<RangeValue>
) => {
const [minSelection, maxSelection]: RangeValue = action.payload;
if (
minSelection === String(state.componentState.min) &&
maxSelection === String(state.componentState.max)
) {
state.explicitInput.value = undefined;
} else {
state.explicitInput.value = action.payload;
}
},
setField: (
state: WritableDraft<RangeSliderReduxState>,
action: PayloadAction<FieldSpec | undefined>
) => {
state.componentState.field = action.payload;
},
setDataViewId: (
state: WritableDraft<RangeSliderReduxState>,
action: PayloadAction<string | undefined>
) => {
state.output.dataViewId = action.payload;
},
setErrorMessage: (
state: WritableDraft<RangeSliderReduxState>,
action: PayloadAction<string | undefined>
) => {
state.componentState.error = action.payload;
},
setLoading: (state: WritableDraft<RangeSliderReduxState>, action: PayloadAction<boolean>) => {
state.output.loading = action.payload;
},
setMinMax: (
state: WritableDraft<RangeSliderReduxState>,
action: PayloadAction<{ min?: number; max?: number }>
) => {
if (action.payload.min !== undefined) state.componentState.min = Math.floor(action.payload.min);
if (action.payload.max !== undefined) state.componentState.max = Math.ceil(action.payload.max);
},
publishFilters: (
state: WritableDraft<RangeSliderReduxState>,
action: PayloadAction<Filter[] | undefined>
) => {
state.output.filters = action.payload;
},
setIsInvalid: (state: WritableDraft<RangeSliderReduxState>, action: PayloadAction<boolean>) => {
state.componentState.isInvalid = action.payload;
},
};

View file

@ -1,30 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { FieldSpec } from '@kbn/data-views-plugin/common';
import type { ReduxEmbeddableState } from '@kbn/presentation-util-plugin/public';
import { RangeSliderEmbeddableInput } from '../../common/range_slider/types';
import { ControlOutput } from '../types';
// Component state is only used by public components.
export interface RangeSliderComponentState {
field?: FieldSpec;
min?: number;
max?: number;
error?: string;
isInvalid?: boolean;
}
// public only - redux embeddable state type
export type RangeSliderReduxState = ReduxEmbeddableState<
RangeSliderEmbeddableInput,
ControlOutput,
RangeSliderComponentState
>;

View file

@ -0,0 +1,8 @@
.controlsWrapper {
display: flex;
align-items: center;
.controlGroup--endButtonGroup {
align-self: end;
}
}

View file

@ -8,8 +8,8 @@
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { css } from '@emotion/react';
import { BehaviorSubject } from 'rxjs';
import {
DndContext,
DragEndEvent,
@ -21,20 +21,24 @@ import {
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
arrayMove,
rectSortingStrategy,
SortableContext,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable';
import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiPanel, EuiToolTip } from '@elastic/eui';
import { css } from '@emotion/react';
import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
import { ControlStyle } from '../../..';
import { ControlsInOrder } from '../init_controls_manager';
import { ControlGroupApi } from '../types';
import { ControlRenderer } from './control_renderer';
import { ControlClone } from './control_clone';
import { DefaultControlApi } from '../../controls/types';
import type { ControlStyle } from '../../../../common';
import type { DefaultControlApi } from '../../controls/types';
import { ControlGroupStrings } from '../control_group_strings';
import { ControlsInOrder } from '../init_controls_manager';
import type { ControlGroupApi } from '../types';
import { ControlClone } from './control_clone';
import { ControlRenderer } from './control_renderer';
import './control_group.scss';
interface Props {
applySelections: () => void;

View file

@ -9,15 +9,18 @@
import React from 'react';
import { BehaviorSubject } from 'rxjs';
import { render } from '@testing-library/react';
import { ControlGroupEditor } from './control_group_editor';
import { ControlGroupApi, ControlStyle } from '../../..';
import { ControlGroupApi } from '../../..';
import {
ControlGroupChainingSystem,
ControlStyle,
DEFAULT_CONTROL_STYLE,
ParentIgnoreSettings,
} from '../../../../common';
import { DefaultControlApi } from '../../controls/types';
import { ControlGroupEditor } from './control_group_editor';
describe('render', () => {
const children$ = new BehaviorSubject<{ [key: string]: DefaultControlApi }>({});

View file

@ -21,32 +21,18 @@ import {
EuiForm,
EuiFormRow,
EuiHorizontalRule,
EuiIconTip,
EuiSpacer,
EuiSwitch,
EuiTitle,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
import { ControlStyle } from '../../..';
import { ControlStateManager } from '../../controls/types';
import type { ControlStyle, ParentIgnoreSettings } from '../../../../common';
import { CONTROL_LAYOUT_OPTIONS } from '../../controls/data_controls/editor_constants';
import type { ControlStateManager } from '../../controls/types';
import { ControlGroupStrings } from '../control_group_strings';
import { ControlGroupApi, ControlGroupEditorState } from '../types';
import { ParentIgnoreSettings } from '../../../../common';
const CONTROL_LAYOUT_OPTIONS = [
{
id: `oneLine`,
'data-test-subj': 'control-editor-layout-oneLine',
label: ControlGroupStrings.management.labelPosition.getInlineTitle(),
},
{
id: `twoLine`,
'data-test-subj': 'control-editor-layout-twoLine',
label: ControlGroupStrings.management.labelPosition.getAboveTitle(),
},
];
import type { ControlGroupApi, ControlGroupEditorState } from '../types';
import { ControlSettingTooltipLabel } from './control_setting_tooltip_label';
interface Props {
onCancel: () => void;
@ -233,17 +219,3 @@ export const ControlGroupEditor = ({ onCancel, onSave, onDeleteAll, stateManager
</>
);
};
const ControlSettingTooltipLabel = ({ label, tooltip }: { label: string; tooltip: string }) => (
<EuiFlexGroup alignItems="center" gutterSize="xs" responsive={false}>
<EuiFlexItem grow={false}>{label}</EuiFlexItem>
<EuiFlexItem
grow={false}
css={css`
margin-top: 0px !important;
`}
>
<EuiIconTip content={tooltip} position="right" />
</EuiFlexItem>
</EuiFlexGroup>
);

View file

@ -1,3 +1,8 @@
$smallControl: $euiSize * 14;
$mediumControl: $euiSize * 25;
$largeControl: $euiSize * 50;
$controlMinWidth: $euiSize * 14;
.controlPanel {
width: 100%;
max-inline-size: 100% !important;
@ -11,8 +16,7 @@
}
&--labelWrapper {
height: $euiFormControlCompressedHeight;
height: 100%;
.controlPanel--label {
@include euiTextTruncate;
padding: 0;
@ -36,36 +40,207 @@
}
}
.controlFrame__dragHandle {
line-height: 0; // Vertically center the grab handle
}
.controlFrameWrapper {
// --------------------------
// Control panel sizes
// --------------------------
.controlFrame__formControlLayout:not(.controlFrame__formControlLayout--edit):not(.controlFrame__formControlLayout--twoLine) {
.euiFormControlLayout__prepend {
padding-inline-start: $euiSizeS;
&--small {
width: $smallControl;
min-width: $smallControl;
}
&--medium {
width: $mediumControl;
min-width: $mediumControl;
}
&--large {
width: $largeControl;
min-width: $largeControl;
}
@include euiBreakpoint('xs', 's', 'm') {
&--small {
min-width:unset;
}
&--medium {
min-width:unset;
}
&--large {
min-width:unset;
}
}
// --------------------------
// Dragging styles
// --------------------------
&:not(.controlFrame__formControlLayout-clone) {
.controlFrame__dragHandle {
cursor: grab;
}
}
&--insertBefore,
&--insertAfter {
.controlFrame__formControlLayout:after {
content: '';
position: absolute;
background-color: transparentize($euiColorPrimary, .5);
border-radius: $euiBorderRadius;
top: 0;
bottom: 0;
width: $euiSizeXS * .5;
}
}
&--insertBefore {
.controlFrame__formControlLayout:after {
left: -$euiSizeXS - 1;
}
}
&--insertAfter {
.controlFrame__formControlLayout:after {
right: -$euiSizeXS - 1;
}
}
&-isDragging {
opacity: 0; // hide dragged control, while control is dragged its replaced with ControlClone component
}
}
.controlFrame__formControlLayout:not(.controlFrame__formControlLayout--edit).timeSlider {
// --------------------------
// Control frame prepend
// --------------------------
.controlFrame__formControlLayout {
$parent: &;
.euiFormControlLayout__prepend {
padding-inline-start: 0;
padding-left: 0;
gap: 0;
.controlFrame__dragHandle {
line-height: 0; // Vertically center the grab handle
}
}
&--edit {
&:not(#{$parent}--twoLine) {
.euiFormControlLayout__prepend {
padding-inline-start: $euiSizeXS * .5; // skinny icon
}
}
&#{$parent}--twoLine {
.euiFormControlLayout__prepend {
padding-inline-end: 0;
padding-inline-start: 0;
}
}
}
&:not(&--edit) {
&:not(#{$parent}--twoLine) {
.euiFormControlLayout__prepend {
padding-inline-start: $euiSizeS;
}
}
&#{$parent}--twoLine {
.euiFormControlLayout__prepend {
padding-inline: 0;
}
}
&.timeSlider {
.euiFormControlLayout__prepend {
padding-inline-start: 0;
}
}
}
}
.controlFrame__formControlLayout.controlFrame__formControlLayout--twoLine.controlFrame__formControlLayout--edit {
.euiFormControlLayout__prepend {
padding-inline-end: 0;
// --------------------------
// Floating actions
// --------------------------
.controlFrameFloatingActions {
z-index: 1;
position: absolute;
&--oneLine {
padding: $euiSizeXS;
border-radius: $euiBorderRadius;
background-color: $euiColorEmptyShade;
box-shadow: 0 0 0 1px $euiColorLightShade;
}
&--twoLine {
top: (-$euiSizeXS) !important;
}
}
.controlFrame__formControlLayout.controlFrame__formControlLayout--twoLine:not(.controlFrame__formControlLayout--edit) {
.euiFormControlLayout__prepend {
padding-inline: 0;
}
}
// --------------------------
// Control frame drag preview
// --------------------------
.controlFrameCloneWrapper {
width: max-content;
.controlFrame__formControlLayout:not(.controlFrame__formControlLayout--twoLine).controlFrame__formControlLayout--edit {
.euiFormControlLayout__prepend {
padding-inline-start: $euiSizeXS * .5; // skinny icon
&--small {
width: $smallControl;
min-width:$smallControl;
}
&--medium {
width: $mediumControl;
min-width:$mediumControl;
}
&--large {
width: $largeControl;
min-width:$largeControl;
}
&--twoLine {
margin-top: -$euiSize * 1.25;
}
.controlFrame__draggable {
cursor: grabbing;
height: $euiButtonHeightSmall;
align-items: center;
border-radius: $euiBorderRadius;
font-weight: $euiFontWeightSemiBold;
@include euiFormControlDefaultShadow;
background-color: $euiFormInputGroupLabelBackground;
min-width: $controlMinWidth;
@include euiFontSizeXS;
}
.controlFrame__formControlLayout,
.controlFrame__draggable {
.controlFrame__dragHandle {
cursor: grabbing;
}
}
@include euiBreakpoint('xs', 's', 'm') {
width: 100%;
&--small {
min-width: unset;
}
&--medium {
min-width: unset;
}
&--large {
min-width: unset;
}
}
}

Some files were not shown because too many files have changed in this diff Show more