mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[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:
parent
b3a1e5fb8f
commit
5082eef2f1
208 changed files with 1468 additions and 12964 deletions
|
@ -22,7 +22,6 @@ const STORYBOOKS = [
|
|||
'coloring',
|
||||
'chart_icons',
|
||||
'content_management_examples',
|
||||
'controls',
|
||||
'custom_integrations',
|
||||
'dashboard_enhanced',
|
||||
'dashboard',
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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, {
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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';
|
|
@ -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' }),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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)
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
|
@ -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),
|
||||
};
|
||||
};
|
|
@ -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';
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
|
@ -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';
|
|
@ -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;
|
||||
};
|
|
@ -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>;
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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 };
|
|
@ -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;
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
|
@ -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];
|
|
@ -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);
|
||||
};
|
|
@ -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) {
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
127
src/plugins/controls/public/actions/edit_control_action.test.tsx
Normal file
127
src/plugins/controls/public/actions/edit_control_action.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
}
|
|
@ -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(
|
|
@ -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';
|
|
@ -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>
|
||||
);
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 ?? '' },
|
||||
}),
|
||||
},
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
};
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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),
|
||||
},
|
||||
};
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
);
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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';
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
});
|
||||
},
|
||||
};
|
|
@ -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';
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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',
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
|
@ -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';
|
|
@ -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;
|
||||
},
|
||||
};
|
|
@ -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
|
||||
>;
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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.',
|
||||
}),
|
||||
},
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
|
@ -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';
|
|
@ -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;
|
||||
},
|
||||
};
|
|
@ -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
|
||||
>;
|
|
@ -0,0 +1,8 @@
|
|||
.controlsWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.controlGroup--endButtonGroup {
|
||||
align-self: end;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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 }>({});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue