[Embeddable Rebuild] Migrate ControlGroupRenderer to new embeddable framework (#190561)

Closes https://github.com/elastic/kibana/issues/189820

### Summary

This PR converts the `ControlGroupRenderer` to use the new control group
embeddable, which is built on the new React embeddable framework. With
this conversion, there should not be **any** changes in user-facing
behaviour - therefore, testing of this PR should be focused on ensuring
that no behaviour is changed and/or broken with this refactor.

**Notes to Solution Reviewers:**
- There should be minimal changes to your uses of `ControlGroupRenderer`
- our goal here was to keep the exposed API more-or-less consistent with
this refactor. Therefore, most changes are simply renames + changes of
imports.
- That being said, `updateInput` and `getInput$` are **very much** tied
to the old embeddable infrastructure - so while they will continue to
work for now, they have been deprecated in favour of adding
setters/getters for the parts of the control group state that you need
to update / respond to.

**Notes to Presentation Reviewer:**
- The bundle size was originally being increased by this PR, so I
decided to remove a bunch of the public exports that are no longer
necessary as a final cleanup - this resulted in changes to imports in a
few files, but it was worth doing in this PR IMO so that we didn't have
to increase the Controls bundle limit. Now, this PR shrinks the bundle
size 🎉
- I fixed a small bug with the default value of `showApplySelections` in
this PR - since it was a one-line change, if felt like overkill to
separate it out. See
https://github.com/elastic/kibana/pull/190561/files#r1733253015

### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

### For maintainers

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

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Hannah Mudge 2024-09-10 11:35:54 -06:00 committed by GitHub
parent 3ac27f4ba3
commit 77e4728a34
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
75 changed files with 1361 additions and 1481 deletions

View file

@ -13,8 +13,6 @@ import useAsync from 'react-use/lib/useAsync';
import { EuiLoadingSpinner, EuiSpacer, EuiText } from '@elastic/eui';
import { SearchExample } from './control_group_renderer_examples/search_example';
import { EditExample } from './control_group_renderer_examples/edit_example';
import { BasicReduxExample } from './control_group_renderer_examples/basic_redux_example';
import { AddButtonExample } from './control_group_renderer_examples/add_button_example';
import { ControlsExampleStartDeps } from '../plugin';
export const ControlGroupRendererExamples = ({
@ -36,10 +34,6 @@ export const ControlGroupRendererExamples = ({
<SearchExample dataView={dataViews[0]} navigation={navigation} data={data} />
<EuiSpacer size="xl" />
<EditExample />
<EuiSpacer size="xl" />
<BasicReduxExample dataViewId={dataViews[0].id!} />
<EuiSpacer size="xl" />
<AddButtonExample dataViewId={dataViews[0].id!} />
</>
) : (
<EuiText>{'Install web logs sample data to run controls examples.'}</EuiText>

View file

@ -1,68 +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 { ViewMode } from '@kbn/embeddable-plugin/public';
import { EuiPanel, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui';
import { ControlGroupRenderer } from '@kbn/controls-plugin/public';
export const AddButtonExample = ({ dataViewId }: { dataViewId: string }) => {
return (
<>
<EuiTitle>
<h2>Add button example</h2>
</EuiTitle>
<EuiText>
<p>
Use the built in add button to add controls to a control group based on a hardcoded
dataViewId and a simplified editor flyout
</p>
</EuiText>
<EuiSpacer size="m" />
<EuiPanel hasBorder={true}>
<ControlGroupRenderer
getCreationOptions={async (initialInput, builder) => {
await builder.addDataControlFromField(initialInput, {
dataViewId,
title: 'Destintion',
fieldName: 'geo.dest',
grow: false,
width: 'small',
});
await builder.addDataControlFromField(initialInput, {
dataViewId,
fieldName: 'geo.src',
grow: false,
title: 'Source',
width: 'small',
});
return {
initialInput: {
...initialInput,
viewMode: ViewMode.EDIT,
defaultControlGrow: false,
defaultControlWidth: 'small',
},
settings: {
showAddButton: true,
staticDataViewId: dataViewId,
editorConfig: {
hideAdditionalSettings: true,
hideDataViewSelector: true,
hideWidthSettings: true,
},
},
};
}}
/>
</EuiPanel>
</>
);
};

View file

@ -1,84 +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 { ViewMode } from '@kbn/embeddable-plugin/public';
import { EuiButtonGroup, EuiPanel, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui';
import { ControlGroupRenderer, ControlStyle, ControlGroupAPI } from '@kbn/controls-plugin/public';
import { AwaitingControlGroupAPI } from '@kbn/controls-plugin/public/control_group';
export const BasicReduxExample = ({ dataViewId }: { dataViewId: string }) => {
const [controlGroupAPI, setControlGroupApi] = useState<AwaitingControlGroupAPI>(null);
const Buttons = ({ api }: { api: ControlGroupAPI }) => {
const controlStyle = api.select((state) => state.explicitInput.controlStyle);
return (
<EuiButtonGroup
legend="Text style"
options={[
{
id: `oneLine`,
label: 'One line',
value: 'oneLine' as ControlStyle,
},
{
id: `twoLine`,
label: 'Two lines',
value: 'twoLine' as ControlStyle,
},
]}
idSelected={controlStyle}
onChange={(id, value) => api.dispatch.setControlStyle(value)}
type="single"
/>
);
};
return (
<>
<EuiTitle>
<h2>Redux example</h2>
</EuiTitle>
<EuiText>
<p>Use the redux context from the control group to set layout style.</p>
</EuiText>
<EuiSpacer size="m" />
<EuiPanel hasBorder={true}>
{controlGroupAPI && <Buttons api={controlGroupAPI} />}
<ControlGroupRenderer
ref={setControlGroupApi}
getCreationOptions={async (initialInput, builder) => {
await builder.addDataControlFromField(initialInput, {
dataViewId,
title: 'Destintion country',
fieldName: 'geo.dest',
width: 'medium',
grow: false,
});
await builder.addDataControlFromField(initialInput, {
dataViewId,
fieldName: 'bytes',
width: 'medium',
grow: true,
title: 'Bytes',
});
return {
initialInput: {
...initialInput,
viewMode: ViewMode.VIEW,
},
};
}}
/>
</EuiPanel>
</>
);
};

View file

@ -8,7 +8,7 @@
*/
import { pickBy } from 'lodash';
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import {
EuiButton,
EuiButtonEmpty,
@ -23,50 +23,51 @@ import {
} 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 {
type ControlGroupInput,
ControlGroupRenderer,
AwaitingControlGroupAPI,
ACTION_EDIT_CONTROL,
ACTION_DELETE_CONTROL,
ACTION_EDIT_CONTROL,
ControlGroupRenderer,
} from '@kbn/controls-plugin/public';
import { ControlInputTransform } from '@kbn/controls-plugin/common/types';
import { ControlGroupRendererApi } from '@kbn/controls-plugin/public';
const INPUT_KEY = 'kbnControls:saveExample:input';
const WITH_CUSTOM_PLACEHOLDER = 'Custom Placeholder';
type StoredState = ControlGroupRuntimeState & { disabledActions: string[] };
export const EditExample = () => {
const [isSaving, setIsSaving] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [controlGroupAPI, setControlGroupAPI] = useState<AwaitingControlGroupAPI>(null);
const [controlGroupAPI, setControlGroupAPI] = useState<ControlGroupRendererApi | undefined>();
const [toggleIconIdToSelectedMapIcon, setToggleIconIdToSelectedMapIcon] = useState<{
[id: string]: boolean;
}>({});
function onChangeIconsMultiIcons(optionId: string) {
const newToggleIconIdToSelectedMapIcon = {
...toggleIconIdToSelectedMapIcon,
...{
[optionId]: !toggleIconIdToSelectedMapIcon[optionId],
},
};
useEffect(() => {
if (controlGroupAPI) {
const disabledActions: string[] = Object.keys(
pickBy(newToggleIconIdToSelectedMapIcon, (value) => value)
pickBy(
toggleIconIdToSelectedMapIcon,
(value, key) => value && key !== WITH_CUSTOM_PLACEHOLDER
)
);
controlGroupAPI.updateInput({ disabledActions });
controlGroupAPI.setDisabledActionIds(disabledActions);
}
setToggleIconIdToSelectedMapIcon(newToggleIconIdToSelectedMapIcon);
}
}, [controlGroupAPI, toggleIconIdToSelectedMapIcon]);
async function onSave() {
if (!controlGroupAPI) return;
setIsSaving(true);
localStorage.setItem(INPUT_KEY, JSON.stringify(controlGroupAPI.getInput()));
localStorage.setItem(
INPUT_KEY,
JSON.stringify({
...controlGroupAPI.snapshotRuntimeState(),
disabledActions: controlGroupAPI.disabledActionIds.getValue(), // not part of runtime
})
);
// simulated async save await
await new Promise((resolve) => setTimeout(resolve, 1000));
@ -80,12 +81,12 @@ export const EditExample = () => {
// simulated async load await
await new Promise((resolve) => setTimeout(resolve, 6000));
let input: Partial<ControlGroupInput> = {};
let state: Partial<StoredState> = {};
let disabledActions = [];
const inputAsString = localStorage.getItem(INPUT_KEY);
if (inputAsString) {
try {
input = JSON.parse(inputAsString);
const disabledActions = input.disabledActions ?? [];
({ disabledActions, ...state } = JSON.parse(inputAsString));
setToggleIconIdToSelectedMapIcon({
[ACTION_EDIT_CONTROL]: disabledActions.includes(ACTION_EDIT_CONTROL),
[ACTION_DELETE_CONTROL]: disabledActions.includes(ACTION_DELETE_CONTROL),
@ -96,10 +97,10 @@ export const EditExample = () => {
}
}
setIsLoading(false);
return input;
return state;
}
const controlInputTransform: ControlInputTransform = (newState, type) => {
const controlStateTransform: ControlStateTransform = (newState, type) => {
if (type === OPTIONS_LIST_CONTROL && toggleIconIdToSelectedMapIcon[WITH_CUSTOM_PLACEHOLDER]) {
return {
...newState,
@ -134,7 +135,8 @@ export const EditExample = () => {
iconType="plusInCircle"
isDisabled={controlGroupAPI === undefined}
onClick={() => {
controlGroupAPI!.openAddDataControlFlyout({ controlInputTransform });
if (!controlGroupAPI) return;
controlGroupAPI.openAddDataControlFlyout({ controlStateTransform });
}}
>
Add control
@ -163,7 +165,15 @@ export const EditExample = () => {
]}
idToSelectedMap={toggleIconIdToSelectedMapIcon}
type="multi"
onChange={(id: string) => onChangeIconsMultiIcons(id)}
onChange={(optionId: string) => {
const newToggleIconIdToSelectedMapIcon = {
...toggleIconIdToSelectedMapIcon,
...{
[optionId]: !toggleIconIdToSelectedMapIcon[optionId],
},
};
setToggleIconIdToSelectedMapIcon(newToggleIconIdToSelectedMapIcon);
}}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
@ -185,17 +195,17 @@ export const EditExample = () => {
</>
) : null}
<ControlGroupRenderer
ref={setControlGroupAPI}
getCreationOptions={async (initialInput, builder) => {
const persistedInput = await onLoad();
onApiAvailable={setControlGroupAPI}
getCreationOptions={async (initialState, builder) => {
const persistedState = await onLoad();
return {
initialInput: {
...initialInput,
...persistedInput,
viewMode: ViewMode.EDIT,
initialState: {
...initialState,
...persistedState,
},
};
}}
viewMode={ViewMode.EDIT}
/>
</EuiPanel>
</>

View file

@ -14,7 +14,6 @@ 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 { ViewMode } from '@kbn/embeddable-plugin/public';
import {
EuiCallOut,
EuiLoadingSpinner,
@ -23,7 +22,7 @@ import {
EuiText,
EuiTitle,
} from '@elastic/eui';
import { AwaitingControlGroupAPI, ControlGroupRenderer } from '@kbn/controls-plugin/public';
import { ControlGroupRenderer, ControlGroupRendererApi } from '@kbn/controls-plugin/public';
import { PLUGIN_ID } from '../../constants';
interface Props {
@ -34,7 +33,7 @@ interface Props {
export const SearchExample = ({ data, dataView, navigation }: Props) => {
const [controlFilters, setControlFilters] = useState<Filter[]>([]);
const [controlGroupAPI, setControlGroupAPI] = useState<AwaitingControlGroupAPI>();
const [controlGroupAPI, setControlGroupAPI] = useState<ControlGroupRendererApi | undefined>();
const [hits, setHits] = useState(0);
const [filters, setFilters] = useState<Filter[]>([]);
const [isSearching, setIsSearching] = useState(false);
@ -48,8 +47,8 @@ export const SearchExample = ({ data, dataView, navigation }: Props) => {
if (!controlGroupAPI) {
return;
}
const subscription = controlGroupAPI.onFiltersPublished$.subscribe((newFilters) => {
setControlFilters([...newFilters]);
const subscription = controlGroupAPI.filters$.subscribe((newFilters) => {
setControlFilters(newFilters ?? []);
});
return () => {
subscription.unsubscribe();
@ -131,15 +130,15 @@ export const SearchExample = ({ data, dataView, navigation }: Props) => {
/>
<ControlGroupRenderer
filters={filters}
getCreationOptions={async (initialInput, builder) => {
await builder.addDataControlFromField(initialInput, {
getCreationOptions={async (initialState, builder) => {
await builder.addDataControlFromField(initialState, {
dataViewId: dataView.id!,
title: 'Destintion country',
fieldName: 'geo.dest',
width: 'medium',
grow: false,
});
await builder.addDataControlFromField(initialInput, {
await builder.addDataControlFromField(initialState, {
dataViewId: dataView.id!,
fieldName: 'bytes',
width: 'medium',
@ -147,14 +146,11 @@ export const SearchExample = ({ data, dataView, navigation }: Props) => {
title: 'Bytes',
});
return {
initialInput: {
...initialInput,
viewMode: ViewMode.VIEW,
},
initialState,
};
}}
query={query}
ref={setControlGroupAPI}
onApiAvailable={setControlGroupAPI}
timeRange={timeRange}
/>
<EuiCallOut title="Search results">

View file

@ -26,9 +26,8 @@ import type {
import React, { useEffect, useState } from 'react';
import ReactDOM from 'react-dom';
import useObservable from 'react-use/lib/useObservable';
import { AwaitingControlGroupAPI, ControlGroupRenderer } from '@kbn/controls-plugin/public';
import { ControlGroupRendererApi, ControlGroupRenderer } from '@kbn/controls-plugin/public';
import { css } from '@emotion/react';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import type { ControlsPanels } from '@kbn/controls-plugin/common';
import { Route, Router, Routes } from '@kbn/shared-ux-router';
import { I18nProvider } from '@kbn/i18n-react';
@ -343,7 +342,9 @@ export class DiscoverCustomizationExamplesPlugin implements Plugin {
);
},
PrependFilterBar: () => {
const [controlGroupAPI, setControlGroupAPI] = useState<AwaitingControlGroupAPI>();
const [controlGroupAPI, setControlGroupAPI] = useState<
ControlGroupRendererApi | undefined
>();
const stateStorage = stateContainer.stateStorage;
const dataView = useObservable(
stateContainer.internalState.state$,
@ -357,18 +358,19 @@ export class DiscoverCustomizationExamplesPlugin implements Plugin {
const stateSubscription = stateStorage
.change$<ControlsPanels>('controlPanels')
.subscribe((panels) => controlGroupAPI.updateInput({ panels: panels ?? undefined }));
.subscribe((panels) =>
controlGroupAPI.updateInput({ initialChildControlState: panels ?? undefined })
);
const inputSubscription = controlGroupAPI.getInput$().subscribe((input) => {
if (input && input.panels) stateStorage.set('controlPanels', input.panels);
if (input && input.initialChildControlState)
stateStorage.set('controlPanels', input.initialChildControlState);
});
const filterSubscription = controlGroupAPI.onFiltersPublished$.subscribe(
(newFilters) => {
stateContainer.internalState.transitions.setCustomFilters(newFilters);
stateContainer.actions.fetchData();
}
);
const filterSubscription = controlGroupAPI.filters$.subscribe((newFilters = []) => {
stateContainer.internalState.transitions.setCustomFilters(newFilters);
stateContainer.actions.fetchData();
});
return () => {
stateSubscription.unsubscribe();
@ -406,12 +408,12 @@ export class DiscoverCustomizationExamplesPlugin implements Plugin {
`}
>
<ControlGroupRenderer
ref={setControlGroupAPI}
getCreationOptions={async (initialInput, builder) => {
onApiAvailable={setControlGroupAPI}
getCreationOptions={async (initialState, builder) => {
const panels = stateStorage.get<ControlsPanels>('controlPanels');
if (!panels) {
await builder.addOptionsListControl(initialInput, {
builder.addOptionsListControl(initialState, {
dataViewId: dataView?.id!,
title: fieldToFilterOn.name.split('.')[0],
fieldName: fieldToFilterOn.name,
@ -421,14 +423,13 @@ export class DiscoverCustomizationExamplesPlugin implements Plugin {
}
return {
initialInput: {
...initialInput,
panels: panels ?? initialInput.panels,
viewMode: ViewMode.VIEW,
filters: stateContainer.appState.get().filters ?? [],
initialState: {
...initialState,
initialChildControlState: panels ?? initialState.initialChildControlState,
},
};
}}
filters={stateContainer.appState.get().filters ?? []}
/>
</EuiFlexItem>
);

View file

@ -9,7 +9,6 @@
"@kbn/discover-plugin",
"@kbn/developer-examples-plugin",
"@kbn/controls-plugin",
"@kbn/embeddable-plugin",
"@kbn/shared-ux-router",
"@kbn/i18n-react",
"@kbn/react-kibana-context-theme",

View file

@ -7,8 +7,8 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { AddOptionsListControlProps } from '@kbn/controls-plugin/public';
import { ALERT_RULE_NAME, ALERT_STATUS } from '@kbn/rule-data-utils';
import { OptionsListControlState } from '@kbn/controls-plugin/public';
import { i18n } from '@kbn/i18n';
import { FilterControlConfig } from './types';
@ -65,14 +65,11 @@ export const TEST_IDS = {
},
};
export const COMMON_OPTIONS_LIST_CONTROL_INPUTS: Partial<AddOptionsListControlProps> = {
export const COMMON_OPTIONS_LIST_CONTROL_INPUTS: Partial<OptionsListControlState> = {
hideExclude: true,
hideSort: true,
hidePanelTitles: true,
placeholder: '',
ignoreParentSettings: {
ignoreValidations: true,
},
width: 'small',
};
export const TIMEOUTS = {

View file

@ -7,6 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { controlGroupStateBuilder } from '@kbn/controls-plugin/public';
import { EuiButtonIcon, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import { COMMON_OPTIONS_LIST_CONTROL_INPUTS, TEST_IDS } from './constants';
@ -24,7 +25,7 @@ export const FilterGroupContextMenu = () => {
const {
isViewMode,
controlGroupInputUpdates,
controlGroupStateUpdates,
controlGroup,
switchToViewMode,
switchToEditMode,
@ -51,29 +52,31 @@ export const FilterGroupContextMenu = () => {
);
const resetSelection = useCallback(async () => {
if (!controlGroupInputUpdates) return;
if (!controlGroupStateUpdates) return;
// remove existing embeddables
controlGroup?.updateInput({
panels: {},
});
const newInput = { initialChildControlState: {} };
for (let counter = 0; counter < initialControls.length; counter++) {
const control = initialControls[counter];
await controlGroup?.addOptionsListControl({
controlId: String(counter),
...COMMON_OPTIONS_LIST_CONTROL_INPUTS,
// option List controls will handle an invalid dataview
// & display an appropriate message
dataViewId: dataViewId ?? '',
...control,
});
controlGroupStateBuilder.addOptionsListControl(
newInput,
{
...COMMON_OPTIONS_LIST_CONTROL_INPUTS,
// option List controls will handle an invalid dataview
// & display an appropriate message
dataViewId: dataViewId ?? '',
...control,
},
String(counter)
);
controlGroup?.updateInput(newInput);
}
switchToViewMode();
setShowFiltersChangedBanner(false);
}, [
controlGroupInputUpdates,
controlGroupStateUpdates,
controlGroup,
initialControls,
dataViewId,

View file

@ -11,13 +11,9 @@ import { FilterGroup } from './filter_group';
import { FC } from 'react';
import React from 'react';
import { act, render, screen, fireEvent, waitFor } from '@testing-library/react';
import {
ControlGroupOutput,
ControlGroupInput,
ControlGroupContainer,
} from '@kbn/controls-plugin/public';
import { ControlGroupRendererApi, ControlGroupRuntimeState } from '@kbn/controls-plugin/public';
import { OPTIONS_LIST_CONTROL } from '@kbn/controls-plugin/common';
import { initialInputData, sampleOutputData } from './mocks/data';
import { ControlGroupOutput, initialInputData, sampleOutputData } from './mocks/data';
import {
COMMON_OPTIONS_LIST_CONTROL_INPUTS,
DEFAULT_CONTROLS,
@ -25,11 +21,14 @@ import {
URL_PARAM_KEY,
} from './constants';
import {
controlGroupFilterInputMock$,
controlGroupFilterOutputMock$,
controlGroupFilterStateMock$,
getControlGroupMock,
} from './mocks/control_group';
import { getMockedControlGroupRenderer } from './mocks/control_group_renderer';
import {
addOptionsListControlMock,
getMockedControlGroupRenderer,
} from './mocks/control_group_renderer';
import { URL_PARAM_ARRAY_EXCEPTION_MSG } from './translations';
import { AlertConsumers } from '@kbn/rule-data-utils';
import { Storage } from '@kbn/kibana-utils-plugin/public';
@ -41,19 +40,19 @@ const LOCAL_STORAGE_KEY = `${featureIds.join(',')}.${spaceId}.${URL_PARAM_KEY}`;
const controlGroupMock = getControlGroupMock();
const updateControlGroupInputMock = (newInput: ControlGroupInput) => {
const updateControlGroupInputMock = (newState: ControlGroupRuntimeState) => {
act(() => {
controlGroupFilterInputMock$.next(newInput);
controlGroupMock.getInput.mockReturnValue(newInput);
controlGroupMock.snapshotRuntimeState.mockReturnValue(newState);
controlGroupFilterStateMock$.next(newState);
});
};
const updateControlGroupOutputMock = (newOutput: ControlGroupOutput) => {
controlGroupFilterOutputMock$.next(newOutput);
controlGroupFilterOutputMock$.next(newOutput.filters);
};
const MockedControlGroupRenderer = getMockedControlGroupRenderer(
controlGroupMock as unknown as ControlGroupContainer
controlGroupMock as unknown as ControlGroupRendererApi
);
const onFilterChangeMock = jest.fn();
@ -100,6 +99,7 @@ describe(' Filter Group Component ', () => {
jest.clearAllMocks();
global.localStorage.clear();
});
it('should render', async () => {
render(<TestComponent />);
expect(screen.getByTestId(TEST_IDS.MOCKED_CONTROL)).toBeVisible();
@ -122,7 +122,7 @@ describe(' Filter Group Component ', () => {
it('should go into edit mode without any issues', async () => {
render(<TestComponent />);
updateControlGroupInputMock(initialInputData as ControlGroupInput);
updateControlGroupInputMock(initialInputData as ControlGroupRuntimeState);
await openContextMenu();
fireEvent.click(screen.getByTestId(TEST_IDS.CONTEXT_MENU.EDIT));
await waitFor(() => {
@ -135,7 +135,7 @@ describe(' Filter Group Component ', () => {
it('should have add button disable/enable when controls are more/less than max', async () => {
render(<TestComponent maxControls={4} />);
updateControlGroupInputMock(initialInputData as ControlGroupInput);
updateControlGroupInputMock(initialInputData as ControlGroupRuntimeState);
await openContextMenu();
@ -147,10 +147,10 @@ describe(' Filter Group Component ', () => {
// delete some panels
const newInputData = {
...initialInputData,
panels: {
'0': initialInputData.panels['0'],
initialChildControlState: {
'0': initialInputData.initialChildControlState['0'],
},
} as ControlGroupInput;
} as ControlGroupRuntimeState;
updateControlGroupInputMock(newInputData);
@ -165,7 +165,7 @@ describe(' Filter Group Component ', () => {
it('should open flyout when clicked on ADD', async () => {
render(<TestComponent maxControls={4} />);
updateControlGroupInputMock(initialInputData as ControlGroupInput);
updateControlGroupInputMock(initialInputData as ControlGroupRuntimeState);
await openContextMenu();
@ -177,10 +177,10 @@ describe(' Filter Group Component ', () => {
// delete some panels
const newInputData = {
...initialInputData,
panels: {
'0': initialInputData.panels['0'],
initialChildControlState: {
'0': initialInputData.initialChildControlState['0'],
},
} as ControlGroupInput;
} as ControlGroupRuntimeState;
updateControlGroupInputMock(newInputData);
@ -200,22 +200,22 @@ describe(' Filter Group Component ', () => {
it('should call controlGroupTransform which returns object WITHOUT placeholder when type != OPTION_LIST_CONTROL on opening Flyout', async () => {
const returnValueWatcher = jest.fn();
controlGroupMock.openAddDataControlFlyout.mockImplementationOnce(
({ controlInputTransform }) => {
if (controlInputTransform) {
const returnValue = controlInputTransform({}, 'NOT_OPTIONS_LIST_CONTROL');
(controlGroupMock as unknown as ControlGroupRendererApi).openAddDataControlFlyout = jest
.fn()
.mockImplementationOnce(({ controlStateTransform }) => {
if (controlStateTransform) {
const returnValue = controlStateTransform({}, 'NOT_OPTIONS_LIST_CONTROL');
returnValueWatcher(returnValue);
}
}
);
});
render(<TestComponent />);
// delete some panels
const newInputData = {
...initialInputData,
panels: {
'0': initialInputData.panels['0'],
initialChildControlState: {
'0': initialInputData.initialChildControlState['0'],
},
} as ControlGroupInput;
} as ControlGroupRuntimeState;
updateControlGroupInputMock(newInputData);
await openContextMenu();
@ -237,23 +237,23 @@ describe(' Filter Group Component ', () => {
it('should call controlGroupTransform which returns object WITH correct placeholder value when type = OPTION_LIST_CONTROL on opening Flyout', async () => {
const returnValueWatcher = jest.fn();
controlGroupMock.openAddDataControlFlyout.mockImplementationOnce(
({ controlInputTransform }) => {
if (controlInputTransform) {
const returnValue = controlInputTransform({}, OPTIONS_LIST_CONTROL);
(controlGroupMock as unknown as ControlGroupRendererApi).openAddDataControlFlyout = jest
.fn()
.mockImplementationOnce(({ controlStateTransform }) => {
if (controlStateTransform) {
const returnValue = controlStateTransform({}, OPTIONS_LIST_CONTROL);
returnValueWatcher(returnValue);
}
}
);
});
render(<TestComponent />);
// delete some panels
const newInputData = {
...initialInputData,
panels: {
'0': initialInputData.panels['0'],
initialChildControlState: {
'0': initialInputData.initialChildControlState['0'],
},
} as ControlGroupInput;
} as ControlGroupRuntimeState;
updateControlGroupInputMock(newInputData);
@ -276,77 +276,80 @@ describe(' Filter Group Component ', () => {
it('should not rebuild controls while saving controls when controls are in desired order', async () => {
render(<TestComponent />);
updateControlGroupInputMock(initialInputData as ControlGroupInput);
updateControlGroupInputMock(initialInputData as ControlGroupRuntimeState);
await openContextMenu();
fireEvent.click(screen.getByTestId(TEST_IDS.CONTEXT_MENU.EDIT));
// modify controls
const newInputData = {
...initialInputData,
panels: {
initialChildControlState: {
// status as persistent controls is first in the position with order as 0
'0': initialInputData.panels['0'],
'1': initialInputData.panels['1'],
'0': initialInputData.initialChildControlState['0'],
'1': initialInputData.initialChildControlState['1'],
},
} as ControlGroupInput;
} as ControlGroupRuntimeState;
updateControlGroupInputMock(newInputData);
// clear any previous calls to the API
controlGroupMock.addOptionsListControl.mockClear();
controlGroupMock.updateInput.mockClear();
addOptionsListControlMock.mockClear();
fireEvent.click(screen.getByTestId(TEST_IDS.SAVE_CONTROL));
// edit model gone
await waitFor(() => expect(screen.queryAllByTestId(TEST_IDS.SAVE_CONTROL)).toHaveLength(0));
// check if upsert was called correctly
expect(controlGroupMock.addOptionsListControl.mock.calls.length).toBe(0);
expect(addOptionsListControlMock.mock.calls.length).toBe(0);
expect(controlGroupMock.updateInput.mock.calls.length).toBe(0);
});
it('should rebuild and save controls successfully when controls are not in desired order', async () => {
render(<TestComponent />);
updateControlGroupInputMock(initialInputData as ControlGroupInput);
updateControlGroupInputMock(initialInputData as ControlGroupRuntimeState);
await openContextMenu();
fireEvent.click(screen.getByTestId(TEST_IDS.CONTEXT_MENU.EDIT));
// modify controls
const newInputData = {
...initialInputData,
panels: {
initialChildControlState: {
'0': {
...initialInputData.panels['0'],
...initialInputData.initialChildControlState['0'],
// status is second in position.
// this will force the rebuilding of controls
order: 1,
},
'1': {
...initialInputData.panels['1'],
...initialInputData.initialChildControlState['1'],
order: 0,
},
},
} as ControlGroupInput;
} as ControlGroupRuntimeState;
updateControlGroupInputMock(newInputData);
// clear any previous calls to the API
controlGroupMock.addOptionsListControl.mockClear();
controlGroupMock.updateInput.mockClear();
fireEvent.click(screen.getByTestId(TEST_IDS.SAVE_CONTROL));
// edit model gone
await waitFor(() => expect(screen.queryAllByTestId(TEST_IDS.SAVE_CONTROL)).toHaveLength(0));
// check if upsert was called correctly
expect(controlGroupMock.addOptionsListControl.mock.calls.length).toBe(2);
// field id is not required to be passed when creating a control
const { id, ...expectedInputData } = initialInputData.panels['0'].explicitInput;
expect(controlGroupMock.addOptionsListControl.mock.calls[0][0]).toMatchObject({
...expectedInputData,
expect(controlGroupMock.updateInput.mock.calls.length).toBe(1);
expect(controlGroupMock.updateInput.mock.calls[0][0]).toMatchObject({
initialChildControlState: {
'0': initialInputData.initialChildControlState['0'],
'1': initialInputData.initialChildControlState['1'],
},
});
});
it('should add persistable controls back on save, if deleted', async () => {
render(<TestComponent />);
updateControlGroupInputMock(initialInputData as ControlGroupInput);
updateControlGroupInputMock(initialInputData as ControlGroupRuntimeState);
await openContextMenu();
fireEvent.click(screen.getByTestId(TEST_IDS.CONTEXT_MENU.EDIT));
@ -354,16 +357,16 @@ describe(' Filter Group Component ', () => {
// modify controls
const newInputData = {
...initialInputData,
panels: {
initialChildControlState: {
// removed persitable control i.e. status at "0" key
'3': initialInputData.panels['3'],
'3': initialInputData.initialChildControlState['3'],
},
} as ControlGroupInput;
} as ControlGroupRuntimeState;
updateControlGroupInputMock(newInputData);
// clear any previous calls to the API
controlGroupMock.addOptionsListControl.mockClear();
controlGroupMock.updateInput.mockClear();
fireEvent.click(screen.getByTestId(TEST_IDS.SAVE_CONTROL));
@ -371,17 +374,12 @@ describe(' Filter Group Component ', () => {
// edit model gone
expect(screen.queryAllByTestId(TEST_IDS.SAVE_CONTROL)).toHaveLength(0);
// check if upsert was called correctly
expect(controlGroupMock.addOptionsListControl.mock.calls.length).toBe(2);
expect(controlGroupMock.addOptionsListControl.mock.calls[0][0]).toMatchObject({
...COMMON_OPTIONS_LIST_CONTROL_INPUTS,
...DEFAULT_CONTROLS[0],
});
// field id is not required to be passed when creating a control
const { id, ...expectedInputData } = initialInputData.panels['3'].explicitInput;
expect(controlGroupMock.addOptionsListControl.mock.calls[1][0]).toMatchObject({
...expectedInputData,
expect(controlGroupMock.updateInput.mock.calls.length).toBe(1);
expect(controlGroupMock.updateInput.mock.calls[0][0]).toMatchObject({
initialChildControlState: {
'0': { ...COMMON_OPTIONS_LIST_CONTROL_INPUTS, ...DEFAULT_CONTROLS[0] },
'1': { ...initialInputData.initialChildControlState['3'], order: 1 },
},
});
});
});
@ -389,7 +387,7 @@ describe(' Filter Group Component ', () => {
it('should have Context menu changed when pending changes', async () => {
render(<TestComponent />);
updateControlGroupInputMock(initialInputData as ControlGroupInput);
updateControlGroupInputMock(initialInputData as ControlGroupRuntimeState);
await openContextMenu();
@ -398,10 +396,10 @@ describe(' Filter Group Component ', () => {
// delete some panels
const newInputData = {
...initialInputData,
panels: {
'0': initialInputData.panels['0'],
initialChildControlState: {
'0': initialInputData.initialChildControlState['0'],
},
} as ControlGroupInput;
} as ControlGroupRuntimeState;
updateControlGroupInputMock(newInputData);
@ -419,7 +417,7 @@ describe(' Filter Group Component ', () => {
it('should be able to discard changes', async () => {
render(<TestComponent />);
updateControlGroupInputMock(initialInputData as ControlGroupInput);
updateControlGroupInputMock(initialInputData as ControlGroupRuntimeState);
await openContextMenu();
@ -428,16 +426,16 @@ describe(' Filter Group Component ', () => {
// delete some panels
const newInputData = {
...initialInputData,
panels: {
'0': initialInputData.panels['0'],
initialChildControlState: {
'0': initialInputData.initialChildControlState['0'],
},
} as ControlGroupInput;
} as ControlGroupRuntimeState;
updateControlGroupInputMock(newInputData);
// await waitFor(() => {
// expect(screen.getByTestId(TEST_IDS.SAVE_CHANGE_POPOVER)).toBeVisible();
// });
await waitFor(() => {
expect(screen.getByTestId(TEST_IDS.SAVE_CHANGE_POPOVER)).toBeVisible();
});
await openContextMenu();
await waitFor(() => {
@ -449,15 +447,10 @@ describe(' Filter Group Component ', () => {
await waitFor(() => {
expect(controlGroupMock.updateInput).toHaveBeenCalled();
expect(controlGroupMock.updateInput.mock.calls.length).toBe(2);
expect(controlGroupMock.updateInput.mock.calls.length).toBe(1);
// discard changes
expect(controlGroupMock.updateInput.mock.calls[0][0]).toMatchObject({
panels: initialInputData.panels,
});
// shift to view mode
expect(controlGroupMock.updateInput.mock.calls[1][0]).toMatchObject({
viewMode: 'view',
initialChildControlState: initialInputData.initialChildControlState,
});
});
});
@ -465,28 +458,27 @@ describe(' Filter Group Component ', () => {
it('should reset controls on clicking reset', async () => {
render(<TestComponent />);
updateControlGroupInputMock(initialInputData as ControlGroupInput);
updateControlGroupInputMock(initialInputData as ControlGroupRuntimeState);
await openContextMenu();
await waitFor(() => expect(screen.getByTestId(TEST_IDS.CONTEXT_MENU.RESET)).toBeVisible());
controlGroupMock.addOptionsListControl.mockClear();
controlGroupMock.updateInput.mockClear();
fireEvent.click(screen.getByTestId(TEST_IDS.CONTEXT_MENU.RESET));
// blanks the input
await waitFor(() => expect(controlGroupMock.updateInput.mock.calls.length).toBe(2));
expect(controlGroupMock.addOptionsListControl.mock.calls.length).toBe(5);
await waitFor(() => expect(controlGroupMock.updateInput.mock.calls.length).toBe(5));
});
it('should restore controls saved in local storage', () => {
it('should restore controls saved in local storage', async () => {
addOptionsListControlMock.mockClear();
global.localStorage.setItem(
LOCAL_STORAGE_KEY,
JSON.stringify({
...initialInputData,
panels: {
'0': initialInputData.panels['0'],
initialChildControlState: {
'0': initialInputData.initialChildControlState['0'],
},
})
);
@ -494,13 +486,13 @@ describe(' Filter Group Component ', () => {
// should create one control
//
render(<TestComponent />);
expect(controlGroupMock.addOptionsListControl.mock.calls.length).toBe(1);
expect(addOptionsListControlMock.mock.calls.length).toBe(1);
});
it('should show/hide pending changes popover on mouseout/mouseover', async () => {
render(<TestComponent />);
updateControlGroupInputMock(initialInputData as ControlGroupInput);
updateControlGroupInputMock(initialInputData as ControlGroupRuntimeState);
await openContextMenu();
@ -509,10 +501,10 @@ describe(' Filter Group Component ', () => {
// delete some panels
const newInputData = {
...initialInputData,
panels: {
'0': initialInputData.panels['0'],
initialChildControlState: {
'0': initialInputData.initialChildControlState['0'],
},
} as ControlGroupInput;
} as ControlGroupRuntimeState;
updateControlGroupInputMock(newInputData);
@ -531,36 +523,6 @@ describe(' Filter Group Component ', () => {
expect(screen.queryByTestId(TEST_IDS.SAVE_CHANGE_POPOVER)).toBeVisible();
});
});
it('should update controlGroup with new filters and queries when valid query is supplied', async () => {
const validQuery = { query: { language: 'kuery', query: '' } };
// pass an invalid query
render(<TestComponent {...validQuery} />);
await waitFor(() => {
expect(controlGroupMock.updateInput).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
filters: undefined,
query: validQuery.query,
})
);
});
});
it('should not update controlGroup with new filters and queries when invalid query is supplied', async () => {
const invalidQuery = { query: { language: 'kuery', query: '\\' } };
// pass an invalid query
render(<TestComponent {...invalidQuery} />);
await waitFor(() => {
expect(controlGroupMock.updateInput).toHaveBeenCalledWith(
expect.objectContaining({
filters: [],
query: undefined,
})
);
});
});
});
describe('Filter Changed Banner', () => {
@ -581,14 +543,16 @@ describe(' Filter Group Component ', () => {
]}
/>
);
updateControlGroupInputMock(initialInputData as ControlGroupInput);
updateControlGroupInputMock(initialInputData as ControlGroupRuntimeState);
await waitFor(() => {
expect(screen.getByTestId(TEST_IDS.FILTERS_CHANGED_BANNER)).toBeVisible();
});
});
it('should use url filters if url and stored filters are not same', async () => {
addOptionsListControlMock.mockClear();
global.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(initialInputData));
render(
<TestComponent
controlsUrlState={[
@ -598,18 +562,16 @@ describe(' Filter Group Component ', () => {
]}
/>
);
updateControlGroupInputMock(initialInputData as ControlGroupInput);
expect(controlGroupMock.addOptionsListControl.mock.calls.length).toBe(2);
expect(controlGroupMock.addOptionsListControl.mock.calls[0][1]).toMatchObject({
updateControlGroupInputMock(initialInputData as ControlGroupRuntimeState);
expect(addOptionsListControlMock.mock.calls.length).toBe(2);
expect(addOptionsListControlMock.mock.calls[0][1]).toMatchObject({
...COMMON_OPTIONS_LIST_CONTROL_INPUTS,
...DEFAULT_CONTROLS[0],
});
expect(controlGroupMock.addOptionsListControl.mock.calls[1][1]).toMatchObject({
expect(addOptionsListControlMock.mock.calls[1][1]).toMatchObject({
...COMMON_OPTIONS_LIST_CONTROL_INPUTS,
fieldName: 'abc',
});
await waitFor(() => {
expect(screen.getByTestId(TEST_IDS.FILTERS_CHANGED_BANNER)).toBeVisible();
});
@ -639,9 +601,10 @@ describe(' Filter Group Component ', () => {
jest.useFakeTimers();
global.localStorage.clear();
});
it('should call onFilterChange when new filters have been published', async () => {
render(<TestComponent />);
updateControlGroupInputMock(initialInputData as ControlGroupInput);
updateControlGroupInputMock(initialInputData as ControlGroupRuntimeState);
updateControlGroupOutputMock(sampleOutputData);
await waitFor(() => {
expect(onFilterChangeMock.mock.calls.length).toBe(1);
@ -658,13 +621,13 @@ describe(' Filter Group Component ', () => {
it('should pass empty onFilterChange as the initial state. Eg. in case of error', async () => {
render(<TestComponent />);
updateControlGroupInputMock(initialInputData as ControlGroupInput);
updateControlGroupInputMock(initialInputData as ControlGroupRuntimeState);
updateControlGroupOutputMock(sampleOutputData);
jest.advanceTimersByTime(1000);
updateControlGroupOutputMock({
...sampleOutputData,
filters: undefined,
filters: [],
});
await waitFor(() => {
expect(onFilterChangeMock.mock.calls.length).toBe(2);
@ -681,7 +644,7 @@ describe(' Filter Group Component ', () => {
it('should not call onFilterChange if same set of filters are published twice', async () => {
render(<TestComponent />);
updateControlGroupInputMock(initialInputData as ControlGroupInput);
updateControlGroupInputMock(initialInputData as ControlGroupRuntimeState);
updateControlGroupOutputMock(sampleOutputData);
jest.advanceTimersByTime(1000);
@ -703,18 +666,15 @@ describe(' Filter Group Component ', () => {
});
it('should restore from localstorage when one of the value is exists and exclude is false', async () => {
updateControlGroupInputMock(initialInputData as ControlGroupInput);
updateControlGroupInputMock(initialInputData as ControlGroupRuntimeState);
const savedData = {
...initialInputData,
panels: {
...initialInputData.panels,
initialChildControlState: {
...initialInputData.initialChildControlState,
'2': {
...initialInputData.panels['2'],
explicitInput: {
...initialInputData.panels['2'].explicitInput,
existsSelected: true,
exclude: false,
},
...initialInputData.initialChildControlState['2'],
existsSelected: true,
exclude: false,
},
},
};
@ -724,8 +684,8 @@ describe(' Filter Group Component ', () => {
render(<TestComponent />);
await waitFor(() => {
expect(controlGroupMock.addOptionsListControl.mock.calls.length).toBe(5);
expect(controlGroupMock.addOptionsListControl.mock.calls[2][1]).toMatchObject(
expect(addOptionsListControlMock.mock.calls.length).toBe(5);
expect(addOptionsListControlMock.mock.calls[2][1]).toMatchObject(
expect.objectContaining({
existsSelected: true,
exclude: false,
@ -737,15 +697,12 @@ describe(' Filter Group Component ', () => {
it('should restore from localstorage when one of the value has both exists and exclude true', async () => {
const savedData = {
...initialInputData,
panels: {
...initialInputData.panels,
initialChildControlState: {
...initialInputData.initialChildControlState,
'2': {
...initialInputData.panels['2'],
explicitInput: {
...initialInputData.panels['2'].explicitInput,
existsSelected: true,
exclude: true,
},
...initialInputData.initialChildControlState['2'],
existsSelected: true,
exclude: true,
},
},
};
@ -755,8 +712,8 @@ describe(' Filter Group Component ', () => {
render(<TestComponent />);
await waitFor(() => {
expect(controlGroupMock.addOptionsListControl.mock.calls.length).toBe(5);
expect(controlGroupMock.addOptionsListControl.mock.calls[2][1]).toMatchObject(
expect(addOptionsListControlMock.mock.calls.length).toBe(5);
expect(addOptionsListControlMock.mock.calls[2][1]).toMatchObject(
expect.objectContaining({
existsSelected: true,
exclude: true,
@ -764,17 +721,15 @@ describe(' Filter Group Component ', () => {
);
});
});
it('should restore from localstorage when some value has selected options', async () => {
const savedData = {
...initialInputData,
panels: {
...initialInputData.panels,
initialChildControlState: {
...initialInputData.initialChildControlState,
'2': {
...initialInputData.panels['2'],
explicitInput: {
...initialInputData.panels['2'].explicitInput,
selectedOptions: ['abc'],
},
...initialInputData.initialChildControlState['2'],
selectedOptions: ['abc'],
},
},
};
@ -784,8 +739,8 @@ describe(' Filter Group Component ', () => {
render(<TestComponent />);
await waitFor(() => {
expect(controlGroupMock.addOptionsListControl.mock.calls.length).toBe(5);
expect(controlGroupMock.addOptionsListControl.mock.calls[2][1]).toMatchObject(
expect(addOptionsListControlMock.mock.calls.length).toBe(5);
expect(addOptionsListControlMock.mock.calls[2][1]).toMatchObject(
expect.objectContaining({
selectedOptions: ['abc'],
})

View file

@ -9,25 +9,21 @@
import type { Filter } from '@kbn/es-query';
import { buildEsQuery } from '@kbn/es-query';
import type { ControlInputTransform } from '@kbn/controls-plugin/common';
import { OPTIONS_LIST_CONTROL } from '@kbn/controls-plugin/common';
import { controlGroupStateBuilder } from '@kbn/controls-plugin/public';
import type {
ControlGroupContainer,
ControlGroupInput,
ControlGroupInputBuilder,
ControlGroupOutput,
ControlGroupRendererApi,
ControlGroupRendererProps,
DataControlInput,
ControlGroupRuntimeState,
ControlGroupCreationOptions,
ControlGroupStateBuilder,
DefaultDataControlState,
ControlStateTransform,
} from '@kbn/controls-plugin/public';
import React, { PropsWithChildren, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import type { Subscription } from 'rxjs';
import { debounce, isEqual, isEqualWith } from 'lodash';
import type {
ControlGroupCreationOptions,
FieldFilterPredicate,
} from '@kbn/controls-plugin/public/control_group/types';
import { ViewMode } from '@kbn/embeddable-plugin/common';
import type { FilterGroupProps, FilterControlConfig } from './types';
import './index.scss';
import { FilterGroupLoading } from './loading';
@ -37,7 +33,7 @@ import { FilterGroupContextMenu } from './context_menu';
import { AddControl, SaveControls } from './buttons';
import {
getFilterControlsComparator,
getFilterItemObjListFromControlInput,
getFilterItemObjListFromControlState,
mergeControls,
reorderControlsWithDefaultControls,
} from './utils';
@ -80,7 +76,7 @@ export const FilterGroup = (props: PropsWithChildren<FilterGroupProps>) => {
[defaultControls]
);
const [controlGroup, setControlGroup] = useState<ControlGroupContainer>();
const [controlGroup, setControlGroup] = useState<ControlGroupRendererApi>();
const localStoragePageFilterKey = useMemo(
() => `${featureIds.join(',')}.${spaceId}.${URL_PARAM_KEY}`,
@ -98,14 +94,13 @@ export const FilterGroup = (props: PropsWithChildren<FilterGroupProps>) => {
switchToViewMode,
switchToEditMode,
setHasPendingChanges,
} = useViewEditMode({
controlGroup,
});
filterGroupMode,
} = useViewEditMode({});
const {
controlGroupInput: controlGroupInputUpdates,
setControlGroupInput: setControlGroupInputUpdates,
getStoredControlGroupInput: getStoredControlInput,
controlGroupState: controlGroupStateUpdates,
setControlGroupState: setControlGroupStateUpdates,
getStoredControlGroupState: getStoredControlState,
} = useControlGroupSyncToLocalStorage({
Storage,
storageKey: localStoragePageFilterKey,
@ -113,13 +108,13 @@ export const FilterGroup = (props: PropsWithChildren<FilterGroupProps>) => {
});
useEffect(() => {
if (controlGroupInputUpdates) {
const formattedFilters = getFilterItemObjListFromControlInput(controlGroupInputUpdates);
if (controlGroupStateUpdates) {
const formattedFilters = getFilterItemObjListFromControlState(controlGroupStateUpdates);
if (!formattedFilters) return;
if (controlGroupInputUpdates.viewMode !== 'view') return;
if (!isViewMode) return;
setControlsUrlState?.(formattedFilters);
}
}, [controlGroupInputUpdates, setControlsUrlState]);
}, [controlGroupStateUpdates, setControlsUrlState, isViewMode]);
const [showFiltersChangedBanner, setShowFiltersChangedBanner] = useState(false);
@ -160,34 +155,31 @@ export const FilterGroup = (props: PropsWithChildren<FilterGroupProps>) => {
}, [filters, query]);
useEffect(() => {
controlGroup?.updateInput({
filters: validatedFilters,
query: validatedQuery,
timeRange,
chainingSystem,
});
}, [timeRange, chainingSystem, controlGroup, validatedQuery, validatedFilters]);
controlGroup?.setChainingSystem(chainingSystem);
}, [chainingSystem, controlGroup]);
const handleInputUpdates = useCallback(
(newInput: ControlGroupInput) => {
if (isEqual(getStoredControlInput(), newInput)) {
const handleStateUpdates = useCallback(
(newState: ControlGroupRuntimeState) => {
if (isEqual(getStoredControlState(), newState)) {
return;
}
if (!isEqual(newInput.panels, getStoredControlInput()?.panels) && !isViewMode) {
if (
!isEqual(
newState?.initialChildControlState,
getStoredControlState()?.initialChildControlState
) &&
!isViewMode
) {
setHasPendingChanges(true);
}
setControlGroupInputUpdates(newInput);
setControlGroupStateUpdates(newState);
},
[setControlGroupInputUpdates, getStoredControlInput, isViewMode, setHasPendingChanges]
[setControlGroupStateUpdates, getStoredControlState, isViewMode, setHasPendingChanges]
);
const handleOutputFilterUpdates = useCallback(
({ filters: newFilters, embeddableLoaded }: ControlGroupOutput) => {
const haveAllEmbeddablesLoaded = Object.values(embeddableLoaded).every((v) =>
Boolean(v ?? true)
);
(newFilters: Filter[] = []) => {
if (isEqual(currentFiltersRef.current, newFilters)) return;
if (!haveAllEmbeddablesLoaded) return;
if (onFiltersChange) onFiltersChange(newFilters ?? []);
currentFiltersRef.current = newFilters ?? [];
},
@ -204,9 +196,9 @@ export const FilterGroup = (props: PropsWithChildren<FilterGroupProps>) => {
if (!Array.isArray(controlsUrlState)) {
throw new Error(URL_PARAM_ARRAY_EXCEPTION_MSG);
}
const storedControlGroupInput = getStoredControlInput();
if (storedControlGroupInput) {
const panelsFormatted = getFilterItemObjListFromControlInput(storedControlGroupInput);
const storedControlGroupState = getStoredControlState();
if (storedControlGroupState) {
const panelsFormatted = getFilterItemObjListFromControlState(storedControlGroupState);
if (
controlsUrlState.length &&
!isEqualWith(
@ -226,7 +218,7 @@ export const FilterGroup = (props: PropsWithChildren<FilterGroupProps>) => {
console.error(err);
}
setUrlStateInitialized(true);
}, [controlsUrlState, getStoredControlInput, switchToEditMode]);
}, [controlsUrlState, getStoredControlState, switchToEditMode]);
useEffect(() => {
if (controlsUrlState && !urlStateInitialized) {
@ -237,12 +229,12 @@ export const FilterGroup = (props: PropsWithChildren<FilterGroupProps>) => {
return;
}
filterChangedSubscription.current = controlGroup.getOutput$().subscribe({
filterChangedSubscription.current = controlGroup.filters$.subscribe({
next: debouncedFilterUpdates,
});
inputChangedSubscription.current = controlGroup.getInput$().subscribe({
next: handleInputUpdates,
next: handleStateUpdates,
});
return () => {
@ -254,18 +246,18 @@ export const FilterGroup = (props: PropsWithChildren<FilterGroupProps>) => {
controlGroup,
controlsUrlState,
debouncedFilterUpdates,
getStoredControlInput,
handleInputUpdates,
getStoredControlState,
handleStateUpdates,
initializeUrlState,
switchToEditMode,
urlStateInitialized,
]);
const onControlGroupLoadHandler = useCallback(
(controlGroupContainer: ControlGroupContainer) => {
if (!controlGroupContainer) return;
if (onInit) onInit(controlGroupContainer);
setControlGroup(controlGroupContainer);
(controlGroupRendererApi: ControlGroupRendererApi | undefined) => {
if (!controlGroupRendererApi) return;
if (onInit) onInit(controlGroupRendererApi);
setControlGroup(controlGroupRendererApi);
},
[onInit]
);
@ -281,9 +273,9 @@ export const FilterGroup = (props: PropsWithChildren<FilterGroupProps>) => {
* */
let controlsFromLocalStorage: FilterControlConfig[] = [];
const storedControlGroupInput = getStoredControlInput();
if (storedControlGroupInput) {
controlsFromLocalStorage = getFilterItemObjListFromControlInput(storedControlGroupInput);
const storedControlGroupState = getStoredControlState();
if (storedControlGroupState) {
controlsFromLocalStorage = getFilterItemObjListFromControlState(storedControlGroupState);
}
let overridingControls = mergeControls({
controlsWithPriority: [controlsFromUrl, controlsFromLocalStorage],
@ -307,78 +299,63 @@ export const FilterGroup = (props: PropsWithChildren<FilterGroupProps>) => {
controls: overridingControls,
defaultControls,
});
}, [getStoredControlInput, controlsFromUrl, defaultControlsObj, defaultControls]);
const fieldFilterPredicate: FieldFilterPredicate = useCallback((f) => f.type !== 'number', []);
}, [getStoredControlState, controlsFromUrl, defaultControlsObj, defaultControls]);
const getCreationOptions: ControlGroupRendererProps['getCreationOptions'] = useCallback(
async (
defaultInput: Partial<ControlGroupInput>,
{ addOptionsListControl }: ControlGroupInputBuilder
defaultState: Partial<ControlGroupRuntimeState>,
{ addOptionsListControl }: ControlGroupStateBuilder
) => {
const initialInput: Partial<ControlGroupInput> = {
...defaultInput,
defaultControlWidth: 'small',
viewMode: ViewMode.VIEW,
timeRange,
filters,
query,
const initialState: Partial<ControlGroupRuntimeState> = {
...defaultState,
chainingSystem,
ignoreParentSettings: {
ignoreValidations: true,
},
};
const finalControls = selectControlsWithPriority();
urlDataApplied.current = true;
finalControls.forEach((control, idx) => {
addOptionsListControl(initialInput, {
controlId: String(idx),
...COMMON_OPTIONS_LIST_CONTROL_INPUTS,
// option List controls will handle an invalid dataview
// & display an appropriate message
dataViewId: dataViewId ?? '',
...control,
});
});
return {
initialInput,
settings: {
showAddButton: false,
staticDataViewId: dataViewId ?? '',
editorConfig: {
hideWidthSettings: true,
hideDataViewSelector: true,
hideAdditionalSettings: true,
addOptionsListControl(
initialState,
{
...COMMON_OPTIONS_LIST_CONTROL_INPUTS,
// option List controls will handle an invalid dataview
// & display an appropriate message
dataViewId: dataViewId ?? '',
...control,
},
String(idx)
);
});
return {
initialState,
editorConfig: {
hideWidthSettings: true,
hideDataViewSelector: true,
hideAdditionalSettings: true,
fieldFilterPredicate: (f) => f.type !== 'number',
},
fieldFilterPredicate,
} as ControlGroupCreationOptions;
},
[
dataViewId,
timeRange,
filters,
chainingSystem,
query,
selectControlsWithPriority,
fieldFilterPredicate,
]
[dataViewId, chainingSystem, selectControlsWithPriority]
);
const discardChangesHandler = useCallback(() => {
const discardChangesHandler = useCallback(async () => {
if (hasPendingChanges) {
controlGroup?.updateInput({
panels: getStoredControlInput()?.panels,
initialChildControlState: getStoredControlState()?.initialChildControlState,
});
}
switchToViewMode();
setShowFiltersChangedBanner(false);
}, [controlGroup, switchToViewMode, getStoredControlInput, hasPendingChanges]);
}, [controlGroup, switchToViewMode, getStoredControlState, hasPendingChanges]);
const upsertPersistableControls = useCallback(async () => {
if (!controlGroup) return;
const currentPanels = getFilterItemObjListFromControlInput(controlGroup.getInput());
const currentPanels = getFilterItemObjListFromControlState(controlGroup.snapshotRuntimeState());
const reorderedControls = reorderControlsWithDefaultControls({
controls: currentPanels,
@ -388,19 +365,23 @@ export const FilterGroup = (props: PropsWithChildren<FilterGroupProps>) => {
if (!isEqualWith(reorderedControls, currentPanels, getFilterControlsComparator('fieldName'))) {
// reorder only if fields are in different order
// or not same.
controlGroup?.updateInput({ panels: {} });
for (const control of reorderedControls) {
await controlGroup?.addOptionsListControl({
title: control.title,
...COMMON_OPTIONS_LIST_CONTROL_INPUTS,
// option List controls will handle an invalid dataview
// & display an appropriate message
dataViewId: dataViewId ?? '',
selectedOptions: control.selectedOptions,
...control,
});
}
const newInput = { initialChildControlState: {} };
reorderedControls.forEach((control, idx) => {
controlGroupStateBuilder.addOptionsListControl(
newInput,
{
title: control.title,
...COMMON_OPTIONS_LIST_CONTROL_INPUTS,
// option List controls will handle an invalid dataview
// & display an appropriate message
dataViewId: dataViewId ?? '',
selectedOptions: control.selectedOptions,
...control,
},
String(idx)
);
});
controlGroup?.updateInput(newInput);
}
}, [controlGroup, dataViewId, defaultControls]);
@ -410,7 +391,7 @@ export const FilterGroup = (props: PropsWithChildren<FilterGroupProps>) => {
setShowFiltersChangedBanner(false);
}, [switchToViewMode, upsertPersistableControls]);
const newControlInputTransform: ControlInputTransform = useCallback(
const newControlStateTransform: ControlStateTransform<DefaultDataControlState> = useCallback(
(newInput, controlType) => {
// for any new controls, we want to avoid
// default placeholder
@ -421,10 +402,10 @@ export const FilterGroup = (props: PropsWithChildren<FilterGroupProps>) => {
...COMMON_OPTIONS_LIST_CONTROL_INPUTS,
};
if ((newInput as DataControlInput).fieldName in defaultControlsObj) {
if ((newInput as DefaultDataControlState).fieldName in defaultControlsObj) {
result = {
...result,
...defaultControlsObj[(newInput as DataControlInput).fieldName],
...defaultControlsObj[(newInput as DefaultDataControlState).fieldName],
// title should not be overridden by the initial controls, hence the hardcoding
title: newInput.title ?? result.title,
};
@ -437,9 +418,9 @@ export const FilterGroup = (props: PropsWithChildren<FilterGroupProps>) => {
const addControlsHandler = useCallback(() => {
controlGroup?.openAddDataControlFlyout({
controlInputTransform: newControlInputTransform,
controlStateTransform: newControlStateTransform,
});
}, [controlGroup, newControlInputTransform]);
}, [controlGroup, newControlStateTransform]);
if (!spaceId) {
return <FilterGroupLoading />;
@ -452,7 +433,7 @@ export const FilterGroup = (props: PropsWithChildren<FilterGroupProps>) => {
initialControls: defaultControls,
isViewMode,
controlGroup,
controlGroupInputUpdates,
controlGroupStateUpdates,
hasPendingChanges,
pendingChangesPopoverOpen,
setHasPendingChanges,
@ -470,8 +451,12 @@ export const FilterGroup = (props: PropsWithChildren<FilterGroupProps>) => {
{Array.isArray(controlsFromUrl) ? (
<EuiFlexItem grow={true} data-test-subj={TEST_IDS.FILTER_CONTROLS}>
<ControlGroupRenderer
ref={onControlGroupLoadHandler}
onApiAvailable={onControlGroupLoadHandler}
getCreationOptions={getCreationOptions}
timeRange={timeRange}
query={validatedQuery}
filters={validatedFilters}
viewMode={filterGroupMode}
/>
{!controlGroup ? <FilterGroupLoading /> : null}
</EuiFlexItem>
@ -482,8 +467,9 @@ export const FilterGroup = (props: PropsWithChildren<FilterGroupProps>) => {
<AddControl
onClick={addControlsHandler}
isDisabled={
controlGroupInputUpdates &&
Object.values(controlGroupInputUpdates.panels).length >= maxControls
controlGroupStateUpdates &&
Object.values(controlGroupStateUpdates.initialChildControlState).length >=
maxControls
}
/>
</EuiFlexItem>

View file

@ -7,15 +7,18 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { ControlGroupContainer, ControlGroupInput } from '@kbn/controls-plugin/public';
import type {
ControlGroupRendererApi,
ControlGroupRuntimeState,
} from '@kbn/controls-plugin/public';
import { createContext } from 'react';
import type { FilterControlConfig } from './types';
export interface FilterGroupContextType {
initialControls: FilterControlConfig[];
dataViewId: string;
controlGroup: ControlGroupContainer | undefined;
controlGroupInputUpdates: ControlGroupInput | undefined;
controlGroup: ControlGroupRendererApi | undefined;
controlGroupStateUpdates: ControlGroupRuntimeState | undefined;
isViewMode: boolean;
hasPendingChanges: boolean;
pendingChangesPopoverOpen: boolean;

View file

@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { ControlGroupInput } from '@kbn/controls-plugin/common';
import { ControlGroupRuntimeState } from '@kbn/controls-plugin/public';
import type { Storage } from '@kbn/kibana-utils-plugin/public';
import { useEffect, useRef, useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
@ -19,9 +19,9 @@ interface UseControlGroupSyncToLocalStorageArgs {
}
type UseControlGroupSyncToLocalStorage = (args: UseControlGroupSyncToLocalStorageArgs) => {
controlGroupInput: ControlGroupInput | undefined;
setControlGroupInput: Dispatch<SetStateAction<ControlGroupInput>>;
getStoredControlGroupInput: () => ControlGroupInput | undefined;
controlGroupState: ControlGroupRuntimeState | undefined;
setControlGroupState: Dispatch<SetStateAction<ControlGroupRuntimeState>>;
getStoredControlGroupState: () => ControlGroupRuntimeState | undefined;
};
export const useControlGroupSyncToLocalStorage: UseControlGroupSyncToLocalStorage = ({
@ -31,23 +31,23 @@ export const useControlGroupSyncToLocalStorage: UseControlGroupSyncToLocalStorag
}) => {
const storage = useRef(new Storage(localStorage));
const [controlGroupInput, setControlGroupInput] = useState(
() => (storage.current.get(storageKey) as ControlGroupInput) ?? undefined
const [controlGroupState, setControlGroupState] = useState(
() => (storage.current.get(storageKey) as ControlGroupRuntimeState) ?? undefined
);
useEffect(() => {
if (shouldSync && controlGroupInput) {
storage.current.set(storageKey, controlGroupInput);
if (shouldSync && controlGroupState) {
storage.current.set(storageKey, controlGroupState);
}
}, [shouldSync, controlGroupInput, storageKey]);
}, [shouldSync, controlGroupState, storageKey]);
const getStoredControlGroupInput = () => {
return (storage.current.get(storageKey) as ControlGroupInput) ?? undefined;
const getStoredControlGroupState = () => {
return (storage.current.get(storageKey) as ControlGroupRuntimeState) ?? undefined;
};
return {
controlGroupInput,
setControlGroupInput,
getStoredControlGroupInput,
controlGroupState,
setControlGroupState,
getStoredControlGroupState,
};
};

View file

@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { ControlGroupInput } from '@kbn/controls-plugin/common';
import type { ControlGroupRuntimeState } from '@kbn/controls-plugin/public';
import { renderHook } from '@testing-library/react-hooks';
import { useControlGroupSyncToLocalStorage } from './use_control_group_sync_to_local_storage';
import { Storage } from '@kbn/kibana-utils-plugin/public';
@ -15,11 +15,11 @@ import { Storage } from '@kbn/kibana-utils-plugin/public';
const TEST_STORAGE_KEY = 'test_key';
const DEFAULT_STORED_VALUE = {
val: 'default_local_storage_value',
} as unknown as ControlGroupInput;
} as unknown as ControlGroupRuntimeState;
const ANOTHER_SAMPLE_VALUE = {
val: 'another_local_storage_value',
} as unknown as ControlGroupInput;
} as unknown as ControlGroupRuntimeState;
let mockLocalStorage: Record<string, unknown> = {};
describe('Filters Sync to Local Storage', () => {
@ -47,7 +47,7 @@ describe('Filters Sync to Local Storage', () => {
})
);
waitForNextUpdate();
expect(result.current.controlGroupInput).toMatchObject(DEFAULT_STORED_VALUE);
expect(result.current.controlGroupState).toMatchObject(DEFAULT_STORED_VALUE);
});
it('should be undefined if localstorage as NO initial value', () => {
const { result, waitForNextUpdate } = renderHook(() =>
@ -58,8 +58,8 @@ describe('Filters Sync to Local Storage', () => {
})
);
waitForNextUpdate();
expect(result.current.controlGroupInput).toBeUndefined();
expect(result.current.setControlGroupInput).toBeTruthy();
expect(result.current.controlGroupState).toBeUndefined();
expect(result.current.setControlGroupState).toBeTruthy();
});
it('should be update values to local storage when sync is ON', () => {
const { result, waitFor } = renderHook(() =>
@ -70,12 +70,12 @@ describe('Filters Sync to Local Storage', () => {
})
);
waitFor(() => {
expect(result.current.controlGroupInput).toBeUndefined();
expect(result.current.setControlGroupInput).toBeTruthy();
expect(result.current.controlGroupState).toBeUndefined();
expect(result.current.setControlGroupState).toBeTruthy();
});
result.current.setControlGroupInput(DEFAULT_STORED_VALUE);
result.current.setControlGroupState(DEFAULT_STORED_VALUE);
waitFor(() => {
expect(result.current.controlGroupInput).toMatchObject(DEFAULT_STORED_VALUE);
expect(result.current.controlGroupState).toMatchObject(DEFAULT_STORED_VALUE);
expect(global.localStorage.getItem(TEST_STORAGE_KEY)).toBe(
JSON.stringify(DEFAULT_STORED_VALUE)
);
@ -92,20 +92,20 @@ describe('Filters Sync to Local Storage', () => {
// Sync is ON
waitFor(() => {
expect(result.current.controlGroupInput).toBeUndefined();
expect(result.current.setControlGroupInput).toBeTruthy();
expect(result.current.controlGroupState).toBeUndefined();
expect(result.current.setControlGroupState).toBeTruthy();
});
result.current.setControlGroupInput(DEFAULT_STORED_VALUE);
result.current.setControlGroupState(DEFAULT_STORED_VALUE);
waitFor(() => {
expect(result.current.controlGroupInput).toMatchObject(DEFAULT_STORED_VALUE);
expect(result.current.controlGroupState).toMatchObject(DEFAULT_STORED_VALUE);
});
// Sync is OFF
rerender({ storageKey: TEST_STORAGE_KEY, shouldSync: false });
result.current.setControlGroupInput(ANOTHER_SAMPLE_VALUE);
result.current.setControlGroupState(ANOTHER_SAMPLE_VALUE);
waitFor(() => {
expect(result.current.controlGroupInput).toMatchObject(ANOTHER_SAMPLE_VALUE);
expect(result.current.controlGroupState).toMatchObject(ANOTHER_SAMPLE_VALUE);
// old value
expect(global.localStorage.getItem(TEST_STORAGE_KEY)).toBe(
JSON.stringify(DEFAULT_STORED_VALUE)

View file

@ -7,30 +7,19 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { ControlGroupContainer } from '@kbn/controls-plugin/public';
import { ViewMode } from '@kbn/embeddable-plugin/common';
import { useCallback, useEffect, useState } from 'react';
interface UseViewEditModeArgs {
controlGroup: ControlGroupContainer | undefined;
initialMode?: ViewMode;
}
export const useViewEditMode = ({
controlGroup,
initialMode = ViewMode.VIEW,
}: UseViewEditModeArgs) => {
export const useViewEditMode = ({ initialMode = ViewMode.VIEW }: UseViewEditModeArgs) => {
const [filterGroupMode, setFilterGroupMode] = useState(initialMode);
const [hasPendingChanges, setHasPendingChanges] = useState(false);
const [pendingChangesPopoverOpen, setPendingChangesPopoverOpen] = useState(false);
useEffect(() => {
if (controlGroup && controlGroup.getInput().viewMode !== filterGroupMode) {
controlGroup.updateInput({ viewMode: filterGroupMode });
}
}, [controlGroup, filterGroupMode]);
useEffect(() => {
setPendingChangesPopoverOpen(hasPendingChanges);
}, [hasPendingChanges]);
@ -44,19 +33,18 @@ export const useViewEditMode = ({
}, [hasPendingChanges]);
const switchToEditMode = useCallback(() => {
controlGroup?.updateInput({ viewMode: ViewMode.EDIT });
setFilterGroupMode(ViewMode.EDIT);
}, [controlGroup]);
}, []);
const switchToViewMode = useCallback(() => {
controlGroup?.updateInput({ viewMode: ViewMode.VIEW });
setHasPendingChanges(false);
setFilterGroupMode(ViewMode.VIEW);
}, [controlGroup]);
}, []);
const isViewMode = filterGroupMode === ViewMode.VIEW;
return {
filterGroupMode,
isViewMode,
hasPendingChanges,
pendingChangesPopoverOpen,

View file

@ -7,27 +7,24 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { ControlGroupOutput, ControlGroupInput } from '@kbn/controls-plugin/public';
import { Subject } from 'rxjs';
import type { ControlGroupRuntimeState } from '@kbn/controls-plugin/public';
import { Filter } from '@kbn/es-query';
import { BehaviorSubject } from 'rxjs';
export const controlGroupFilterOutputMock$ = new Subject<ControlGroupOutput>();
export const controlGroupFilterInputMock$ = new Subject<ControlGroupInput>();
export const getInput$Mock = jest.fn(() => controlGroupFilterInputMock$);
export const getOutput$Mock = jest.fn(() => controlGroupFilterOutputMock$);
export const controlGroupFilterOutputMock$ = new BehaviorSubject<Filter[] | undefined>([]);
export const controlGroupFilterStateMock$ = new BehaviorSubject<ControlGroupRuntimeState>({
initialChildControlState: {},
} as unknown as ControlGroupRuntimeState);
export const getInput$Mock = jest.fn(() => controlGroupFilterStateMock$);
export const getControlGroupMock = () => {
return {
reload: jest.fn(),
getInput: jest.fn().mockReturnValue({
viewMode: 'VIEW',
}),
updateInput: jest.fn(),
getOutput$: getOutput$Mock,
getInput$: getInput$Mock,
openAddDataControlFlyout: jest.fn(),
addOptionsListControl: jest.fn(),
filters$: controlGroupFilterOutputMock$,
setChainingSystem: jest.fn(),
snapshotRuntimeState: jest.fn(),
};
};

View file

@ -7,38 +7,51 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type {
AwaitingControlGroupAPI,
ControlGroupContainer,
ControlGroupInputBuilder,
ControlGroupRendererProps,
import {
type ControlGroupRendererApi,
type ControlGroupRendererProps,
type ControlGroupStateBuilder,
} from '@kbn/controls-plugin/public';
import React, { useState, forwardRef, useEffect, useImperativeHandle } from 'react';
import React, { useEffect, useState } from 'react';
import { TEST_IDS } from '../constants';
import { getControlGroupMock } from './control_group';
export const getMockedControlGroupRenderer = (
controlGroupContainerMock: ControlGroupContainer | undefined
) => {
const controlGroupMock = controlGroupContainerMock ?? getControlGroupMock();
const MockedControlGroupRenderer = forwardRef<AwaitingControlGroupAPI, ControlGroupRendererProps>(
({ getCreationOptions }, ref) => {
useImperativeHandle(ref, () => controlGroupMock as unknown as ControlGroupContainer, []);
const [creationOptionsCalled, setCreationOptionsCalled] = useState(false);
useEffect(() => {
if (creationOptionsCalled) return;
setCreationOptionsCalled(true);
if (getCreationOptions) {
getCreationOptions({}, {
addOptionsListControl: controlGroupMock.addOptionsListControl,
} as unknown as ControlGroupInputBuilder);
}
}, [getCreationOptions, creationOptionsCalled]);
return <div data-test-subj={TEST_IDS.MOCKED_CONTROL} />;
export const addOptionsListControlMock = jest
.fn()
.mockImplementation((initialState, controlState, id) => {
if (!initialState.initialChildControlState) {
initialState.initialChildControlState = {};
}
);
initialState.initialChildControlState[id] = controlState;
});
export const getMockedControlGroupRenderer = (
controlGroupApiMock: ControlGroupRendererApi | undefined
) => {
const controlGroupMock = controlGroupApiMock ?? getControlGroupMock();
const MockedControlGroupRenderer = ({
onApiAvailable,
getCreationOptions,
}: ControlGroupRendererProps) => {
const [creationOptionsCalled, setCreationOptionsCalled] = useState(false);
useEffect(() => {
if (creationOptionsCalled) return;
setCreationOptionsCalled(true);
if (getCreationOptions) {
getCreationOptions({}, {
addOptionsListControl: addOptionsListControlMock,
} as unknown as ControlGroupStateBuilder);
}
}, [getCreationOptions, creationOptionsCalled]);
useEffect(() => {
onApiAvailable(controlGroupMock as unknown as ControlGroupRendererApi);
}, [onApiAvailable]);
return <div data-test-subj={TEST_IDS.MOCKED_CONTROL} />;
};
MockedControlGroupRenderer.displayName = 'MockedControlGroup';
return MockedControlGroupRenderer;

View file

@ -7,19 +7,21 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { ControlGroupRuntimeState, OptionsListControlState } from '@kbn/controls-plugin/public';
import { Filter } from '@kbn/es-query';
import { ALERT_DURATION, ALERT_RULE_NAME, ALERT_START, ALERT_STATUS } from '@kbn/rule-data-utils';
export const sampleOutputData = {
export interface ControlGroupOutput {
loading: boolean;
rendered: boolean;
dataViewIds: string[];
filters: Filter[];
}
export const sampleOutputData: ControlGroupOutput = {
loading: false,
rendered: true,
dataViewIds: ['alert-filters-test-dv'],
embeddableLoaded: {
'0': true,
'1': true,
'2': true,
'3': true,
'4': true,
},
filters: [
{
meta: {
@ -36,124 +38,86 @@ export const sampleOutputData = {
],
};
export const initialInputData = {
viewMode: 'view',
id: 'f9e81d5a-f6ab-4179-866d-c029554131be',
panels: {
export const initialInputData: ControlGroupRuntimeState<OptionsListControlState> = {
initialChildControlState: {
'0': {
type: 'optionsListControl',
order: 0,
grow: true,
width: 'small',
explicitInput: {
id: '0',
dataViewId: 'alert-filters-test-dv',
fieldName: ALERT_STATUS,
title: 'Status',
hideExclude: true,
hideSort: true,
hidePanelTitles: true,
placeholder: '',
selectedOptions: [],
existsSelected: false,
exclude: false,
},
dataViewId: 'alert-filters-test-dv',
fieldName: ALERT_STATUS,
title: 'Status',
hideExclude: true,
hideSort: true,
placeholder: '',
selectedOptions: [],
existsSelected: false,
exclude: false,
},
'1': {
type: 'optionsListControl',
order: 1,
grow: true,
width: 'small',
explicitInput: {
id: '1',
dataViewId: 'alert-filters-test-dv',
fieldName: ALERT_RULE_NAME,
title: 'Rule',
hideExclude: true,
hideSort: true,
hidePanelTitles: true,
placeholder: '',
selectedOptions: [],
existsSelected: false,
exclude: false,
},
dataViewId: 'alert-filters-test-dv',
fieldName: ALERT_RULE_NAME,
title: 'Rule',
hideExclude: true,
hideSort: true,
placeholder: '',
selectedOptions: [],
existsSelected: false,
exclude: false,
},
'2': {
type: 'optionsListControl',
order: 2,
grow: true,
width: 'small',
explicitInput: {
id: '2',
dataViewId: 'alert-filters-test-dv',
fieldName: ALERT_START,
title: 'Started at',
hideExclude: true,
hideSort: true,
hidePanelTitles: true,
placeholder: '',
selectedOptions: [],
existsSelected: true,
exclude: true,
},
dataViewId: 'alert-filters-test-dv',
fieldName: ALERT_START,
title: 'Started at',
hideExclude: true,
hideSort: true,
placeholder: '',
selectedOptions: [],
existsSelected: true,
exclude: true,
},
'3': {
type: 'optionsListControl',
order: 3,
grow: true,
width: 'small',
explicitInput: {
id: '3',
dataViewId: 'alert-filters-test-dv',
fieldName: ALERT_DURATION,
title: 'Duration',
hideExclude: true,
hideSort: true,
hidePanelTitles: true,
placeholder: '',
selectedOptions: [],
existsSelected: false,
exclude: false,
},
dataViewId: 'alert-filters-test-dv',
fieldName: ALERT_DURATION,
title: 'Duration',
hideExclude: true,
hideSort: true,
placeholder: '',
selectedOptions: [],
existsSelected: false,
exclude: false,
},
'4': {
type: 'optionsListControl',
order: 4,
grow: true,
width: 'small',
explicitInput: {
id: '4',
dataViewId: 'alert-filters-test-dv',
fieldName: 'host.name',
title: 'Host',
hideExclude: true,
hideSort: true,
hidePanelTitles: true,
placeholder: '',
selectedOptions: [],
existsSelected: false,
exclude: false,
},
dataViewId: 'alert-filters-test-dv',
fieldName: 'host.name',
title: 'Host',
hideExclude: true,
hideSort: true,
placeholder: '',
selectedOptions: [],
existsSelected: false,
exclude: false,
},
},
defaultControlWidth: 'small',
defaultControlGrow: true,
controlStyle: 'oneLine',
labelPosition: 'oneLine',
chainingSystem: 'HIERARCHICAL',
autoApplySelections: true,
ignoreParentSettings: {
ignoreFilters: false,
ignoreQuery: false,
ignoreTimerange: false,
ignoreValidations: false,
},
timeRange: {
from: '2007-04-20T14:00:52.236Z',
to: '2023-04-20T21:59:59.999Z',
mode: 'absolute',
},
filters: [],
query: {
query: '',
language: 'kuery',
},
};

View file

@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { ControlGroupContainer } from '@kbn/controls-plugin/public';
import type { ControlGroupRendererApi } from '@kbn/controls-plugin/public';
import type { Filter } from '@kbn/es-query';
import type { FC } from 'react';
import React, { useEffect } from 'react';
@ -67,7 +67,7 @@ export const mockAlertFilterControls = (outputFilters?: Filter[]) => {
const Component: FC<AlertFilterControlsProps> = ({ onInit, onFiltersChange }) => {
useEffect(() => {
if (onInit) {
onInit(getControlGroupMock() as unknown as ControlGroupContainer);
onInit(getControlGroupMock() as unknown as ControlGroupRendererApi);
}
if (onFiltersChange) {

View file

@ -7,20 +7,20 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { ControlGroupInput, OptionsListEmbeddableInput } from '@kbn/controls-plugin/common';
import type { Filter, Query, TimeRange } from '@kbn/es-query';
import type {
AddOptionsListControlProps,
ControlGroupContainer,
ControlGroupRenderer,
OptionsListControlState,
ControlGroupRuntimeState,
ControlGroupRendererApi,
} from '@kbn/controls-plugin/public';
import type { Filter } from '@kbn/es-query';
import type { ControlGroupRenderer } from '@kbn/controls-plugin/public';
import type { Storage } from '@kbn/kibana-utils-plugin/public';
import { AlertConsumers } from '@kbn/rule-data-utils';
export type FilterUrlFormat = Record<
string,
Pick<
OptionsListEmbeddableInput,
OptionsListControlState,
'selectedOptions' | 'title' | 'fieldName' | 'existsSelected' | 'exclude'
>
>;
@ -30,17 +30,20 @@ export interface FilterContextType {
addControl: (controls: FilterControlConfig) => void;
}
export type FilterControlConfig = Omit<AddOptionsListControlProps, 'controlId' | 'dataViewId'> & {
export type FilterControlConfig = Omit<OptionsListControlState, 'dataViewId'> & {
/*
* Determines the presence and order of a control
* */
persist?: boolean;
};
export type FilterGroupHandler = ControlGroupContainer;
export type FilterGroupHandler = ControlGroupRendererApi;
export interface FilterGroupProps extends Pick<ControlGroupRuntimeState, 'chainingSystem'> {
query?: Query;
filters?: Filter[];
timeRange?: TimeRange;
export interface FilterGroupProps
extends Pick<ControlGroupInput, 'timeRange' | 'filters' | 'query' | 'chainingSystem'> {
spaceId?: string;
dataViewId: string | null;
featureIds: AlertConsumers[];

View file

@ -7,9 +7,8 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { ControlGroupInput, OptionsListEmbeddableInput } from '@kbn/controls-plugin/common';
import {
getFilterItemObjListFromControlInput,
getFilterItemObjListFromControlState,
mergeControls,
reorderControlsWithDefaultControls,
getFilterControlsComparator,
@ -69,14 +68,13 @@ const defaultControlsObj = defaultControls.reduce((prev, current) => {
describe('utils', () => {
describe('getFilterItemObjListFromControlOutput', () => {
it('should return ordered filterItem where passed in order', () => {
const filterItemObjList = getFilterItemObjListFromControlInput(
initialInputData as ControlGroupInput
);
const filterItemObjList = getFilterItemObjListFromControlState(initialInputData);
filterItemObjList.forEach((item, idx) => {
const panelObj =
initialInputData.panels[String(idx) as keyof typeof initialInputData.panels]
.explicitInput;
initialInputData.initialChildControlState[
String(idx) as keyof typeof initialInputData.initialChildControlState
];
expect(item).toMatchObject({
fieldName: panelObj.fieldName,
selectedOptions: panelObj.selectedOptions,
@ -90,16 +88,14 @@ describe('utils', () => {
it('should return ordered filterItem where NOT passed in order', () => {
const newInputData = {
...initialInputData,
panels: {
'0': initialInputData.panels['3'],
'1': initialInputData.panels['0'],
initialChildControlState: {
'0': initialInputData.initialChildControlState['3'],
'1': initialInputData.initialChildControlState['0'],
},
};
const filterItemObjList = getFilterItemObjListFromControlInput(
newInputData as ControlGroupInput
);
const filterItemObjList = getFilterItemObjListFromControlState(newInputData);
let panelObj = newInputData.panels['1'].explicitInput as OptionsListEmbeddableInput;
let panelObj = newInputData.initialChildControlState['1'];
expect(filterItemObjList[0]).toMatchObject({
fieldName: panelObj.fieldName,
selectedOptions: panelObj.selectedOptions,
@ -108,7 +104,7 @@ describe('utils', () => {
exclude: panelObj.exclude,
});
panelObj = newInputData.panels['0'].explicitInput;
panelObj = newInputData.initialChildControlState['0'];
expect(filterItemObjList[1]).toMatchObject({
fieldName: panelObj.fieldName,
selectedOptions: panelObj.selectedOptions,

View file

@ -8,26 +8,24 @@
*/
import type {
ControlGroupInput,
ControlGroupRuntimeState,
OptionsListControlState,
ControlPanelState,
OptionsListEmbeddableInput,
} from '@kbn/controls-plugin/common';
} from '@kbn/controls-plugin/public';
import { isEmpty, isEqual, pick } from 'lodash';
import type { FilterControlConfig } from './types';
export const getPanelsInOrderFromControlsInput = (controlInput: ControlGroupInput) => {
const panels = controlInput.panels;
export const getPanelsInOrderFromControlsState = (controlState: ControlGroupRuntimeState) => {
const panels = controlState.initialChildControlState;
return Object.values(panels).sort((a, b) => a.order - b.order);
};
export const getFilterItemObjListFromControlInput = (controlInput: ControlGroupInput) => {
const panels = getPanelsInOrderFromControlsInput(controlInput);
export const getFilterItemObjListFromControlState = (controlState: ControlGroupRuntimeState) => {
const panels = getPanelsInOrderFromControlsState(controlState);
return panels.map((panel) => {
const {
explicitInput: { fieldName, selectedOptions, title, existsSelected, exclude, hideActionBar },
} = panel as ControlPanelState<OptionsListEmbeddableInput>;
const { fieldName, selectedOptions, title, existsSelected, exclude, hideActionBar } =
panel as ControlPanelState<OptionsListControlState>;
return {
fieldName: fieldName as string,

View file

@ -11,6 +11,7 @@ import { PublishingSubject } from '../publishing_subject';
export interface PublishesDisabledActionIds {
disabledActionIds: PublishingSubject<string[] | undefined>;
setDisabledActionIds: (ids: string[] | undefined) => void;
getAllTriggersDisabled?: () => boolean;
}
@ -22,6 +23,8 @@ export const apiPublishesDisabledActionIds = (
unknownApi: null | unknown
): unknownApi is PublishesDisabledActionIds => {
return Boolean(
unknownApi && (unknownApi as PublishesDisabledActionIds)?.disabledActionIds !== undefined
unknownApi &&
(unknownApi as PublishesDisabledActionIds)?.disabledActionIds !== undefined &&
typeof (unknownApi as PublishesDisabledActionIds)?.setDisabledActionIds === 'function'
);
};

View file

@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export type { ControlWidth, ControlInputTransform } from './types';
export type { ControlWidth, ControlInputTransform, ParentIgnoreSettings } from './types';
// Control Group exports
export {

View file

@ -7,12 +7,12 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { OptionsListEmbeddable, OptionsListEmbeddableFactory } from '../../public';
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 },

View file

@ -8,14 +8,10 @@
*/
import { RangeSliderEmbeddableInput } from '..';
import {
ControlFactory,
ControlOutput,
RangeSliderEmbeddable,
RangeSliderEmbeddableFactory,
} from '../../public';
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',

View file

@ -20,13 +20,7 @@ import {
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { EmbeddablePersistableStateService } from '@kbn/embeddable-plugin/common';
import {
ControlGroupContainerFactory,
OptionsListEmbeddableInput,
RangeSliderEmbeddableInput,
OPTIONS_LIST_CONTROL,
RANGE_SLIDER_CONTROL,
} from '..';
import { ControlGroupContainerFactory, OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL } from '..';
import { decorators } from './decorators';
import { ControlsPanels } from '../control_group/types';
@ -40,7 +34,9 @@ import {
OptionsListResponse,
OptionsListRequest,
OptionsListSuggestions,
OptionsListEmbeddableInput,
} from '../../common/options_list/types';
import { RangeSliderEmbeddableInput } from '../range_slider';
export default {
title: 'Controls',

View file

@ -11,7 +11,7 @@ import { OptionsListEmbeddableFactory } from '../options_list';
import { RangeSliderEmbeddableFactory } from '../range_slider';
import { TimeSliderEmbeddableFactory } from '../time_slider';
import { ControlsServiceType } from '../services/controls/types';
import { ControlFactory } from '..';
import { ControlFactory } from '../types';
export const populateStorybookControlFactories = (controlsServiceStub: ControlsServiceType) => {
const optionsListFactoryStub = new OptionsListEmbeddableFactory();

View file

@ -16,7 +16,6 @@ import { act, render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Provider } from 'react-redux';
import { OptionsListEmbeddableFactory } from '../..';
import { OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL } from '../../../common';
import { mockControlGroupContainer, mockControlGroupInput } from '../../../common/mocks';
import { RangeSliderEmbeddableFactory } from '../../range_slider';
@ -24,6 +23,7 @@ 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 */

View file

@ -15,7 +15,6 @@ 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 { OptionsListEmbeddableFactory } from '../..';
import {
OptionsListEmbeddableInput,
OPTIONS_LIST_CONTROL,
@ -35,6 +34,7 @@ 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 {

View file

@ -28,8 +28,7 @@ import {
} from '@elastic/eui';
import { ControlGroupInput } from '..';
import { ParentIgnoreSettings } from '../..';
import { getDefaultControlGroupInput } from '../../../common';
import { getDefaultControlGroupInput, ParentIgnoreSettings } from '../../../common';
import { ControlSettingTooltipLabel } from '../../components/control_setting_tooltip_label';
import { ControlStyle } from '../../types';
import { ControlGroupStrings } from '../control_group_strings';

View file

@ -8,64 +8,70 @@
*/
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import {
ControlGroupContainer,
ControlGroupContainerFactory,
ControlGroupRenderer,
CONTROL_GROUP_TYPE,
} from '..';
import { pluginServices } from '../../services/plugin_services';
import { ReactWrapper } from 'enzyme';
import { coreMock } from '@kbn/core/public/mocks';
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks';
import { Filter } from '@kbn/es-query';
import { PublishesUnifiedSearch, PublishingSubject } from '@kbn/presentation-publishing';
import { act, render, waitFor } from '@testing-library/react';
import { ControlGroupRendererApi } from '.';
import { getControlGroupEmbeddableFactory } from '../../react_controls/control_group/get_control_group_factory';
import { CONTROL_GROUP_TYPE } from '../types';
import { ControlGroupRenderer, ControlGroupRendererProps } from './control_group_renderer';
type ParentApiType = PublishesUnifiedSearch & {
unifiedSearchFilters$?: PublishingSubject<Filter[] | undefined>;
};
describe('control group renderer', () => {
let mockControlGroupFactory: ControlGroupContainerFactory;
let mockControlGroupContainer: ControlGroupContainer;
const core = coreMock.createStart();
const dataViews = dataViewPluginMocks.createStartContract();
const factory = getControlGroupEmbeddableFactory({ core, dataViews });
const buildControlGroupSpy = jest.spyOn(factory, 'buildEmbeddable');
const mountControlGroupRenderer = async (
props: Omit<ControlGroupRendererProps, 'onApiAvailable'> = {}
) => {
let controlGroupApi: ControlGroupRendererApi | undefined;
const component = render(
<ControlGroupRenderer
{...props}
onApiAvailable={(newApi) => {
controlGroupApi = newApi;
}}
/>
);
await waitFor(() => {
expect(controlGroupApi).toBeDefined();
});
return { component, api: controlGroupApi! as ControlGroupRendererApi };
};
beforeAll(() => {
const embeddable = embeddablePluginMock.createSetupContract();
embeddable.registerReactEmbeddableFactory(CONTROL_GROUP_TYPE, async () => {
return factory;
});
});
beforeEach(() => {
mockControlGroupContainer = {
destroy: jest.fn(),
render: jest.fn(),
updateInput: jest.fn(),
getInput: jest.fn().mockReturnValue({}),
} as unknown as ControlGroupContainer;
mockControlGroupFactory = {
create: jest.fn().mockReturnValue(mockControlGroupContainer),
} as unknown as ControlGroupContainerFactory;
pluginServices.getServices().embeddable.getEmbeddableFactory = jest
.fn()
.mockReturnValue(mockControlGroupFactory);
buildControlGroupSpy.mockClear();
});
test('calls create method on the Control Group embeddable factory with returned initial input', async () => {
await act(async () => {
mountWithIntl(
<ControlGroupRenderer
getCreationOptions={() => Promise.resolve({ initialInput: { controlStyle: 'twoLine' } })}
/>
);
});
expect(pluginServices.getServices().embeddable.getEmbeddableFactory).toHaveBeenCalledWith(
CONTROL_GROUP_TYPE
);
expect(mockControlGroupFactory.create).toHaveBeenCalledWith(
expect.objectContaining({ controlStyle: 'twoLine' }),
undefined,
{ lastSavedInput: expect.objectContaining({ controlStyle: 'twoLine' }) },
undefined
);
test('calls build method from the control group embeddable factory', async () => {
await mountControlGroupRenderer();
expect(buildControlGroupSpy).toBeCalledTimes(1);
});
test('destroys control group container on unmount', async () => {
let wrapper: ReactWrapper;
await act(async () => {
wrapper = await mountWithIntl(<ControlGroupRenderer />);
test('calling `updateInput` forces control group to be rebuilt', async () => {
const { api } = await mountControlGroupRenderer();
expect(buildControlGroupSpy).toBeCalledTimes(1);
act(() => api.updateInput({ autoApplySelections: false }));
await waitFor(() => {
expect(buildControlGroupSpy).toBeCalledTimes(2);
});
wrapper!.unmount();
expect(mockControlGroupContainer.destroy).toHaveBeenCalledTimes(1);
});
test('filter changes are dispatched to control group if they are different', async () => {
@ -75,57 +81,36 @@ describe('control group renderer', () => {
const updatedFilters: Filter[] = [
{ meta: { alias: 'test', disabled: false, negate: true, index: 'test' } },
];
let wrapper: ReactWrapper;
await act(async () => {
wrapper = mountWithIntl(
<ControlGroupRenderer
getCreationOptions={() => Promise.resolve({ initialInput: { filters: initialFilters } })}
/>
);
});
await act(async () => {
await wrapper.setProps({ filters: updatedFilters });
});
expect(mockControlGroupContainer.updateInput).toHaveBeenCalledWith(
expect.objectContaining({ filters: updatedFilters })
const { component, api } = await mountControlGroupRenderer({ filters: initialFilters });
expect((api.parentApi as ParentApiType).unifiedSearchFilters$?.getValue()).toEqual(
initialFilters
);
component.rerender(
<ControlGroupRenderer onApiAvailable={jest.fn()} filters={updatedFilters} />
);
expect((api.parentApi as ParentApiType).unifiedSearchFilters$?.getValue()).toEqual(
updatedFilters
);
});
test('query changes are dispatched to control group if they are different', async () => {
const initialQuery = { language: 'kql', query: 'query' };
const updatedQuery = { language: 'kql', query: 'super query' };
let wrapper: ReactWrapper;
await act(async () => {
wrapper = mountWithIntl(
<ControlGroupRenderer
getCreationOptions={() => Promise.resolve({ initialInput: { query: initialQuery } })}
/>
);
});
await act(async () => {
await wrapper.setProps({ query: updatedQuery });
});
expect(mockControlGroupContainer.updateInput).toHaveBeenCalledWith(
expect.objectContaining({ query: updatedQuery })
);
const { component, api } = await mountControlGroupRenderer({ query: initialQuery });
expect((api.parentApi as ParentApiType).query$.getValue()).toEqual(initialQuery);
component.rerender(<ControlGroupRenderer onApiAvailable={jest.fn()} query={updatedQuery} />);
expect((api.parentApi as ParentApiType).query$.getValue()).toEqual(updatedQuery);
});
test('time range changes are dispatched to control group if they are different', async () => {
const initialTime = { from: new Date().toISOString(), to: new Date().toISOString() };
const updatedTime = { from: new Date().toISOString() + 10, to: new Date().toISOString() + 20 };
let wrapper: ReactWrapper;
await act(async () => {
wrapper = mountWithIntl(
<ControlGroupRenderer
getCreationOptions={() => Promise.resolve({ initialInput: { timeRange: initialTime } })}
/>
);
});
await act(async () => {
await wrapper.setProps({ timeRange: updatedTime });
});
expect(mockControlGroupContainer.updateInput).toHaveBeenCalledWith(
expect.objectContaining({ timeRange: updatedTime })
);
const { component, api } = await mountControlGroupRenderer({ timeRange: initialTime });
expect((api.parentApi as ParentApiType).timeRange$.getValue()).toEqual(initialTime);
component.rerender(<ControlGroupRenderer onApiAvailable={jest.fn()} timeRange={updatedTime} />);
expect((api.parentApi as ParentApiType).timeRange$.getValue()).toEqual(updatedTime);
});
});

View file

@ -7,136 +7,186 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { isEqual, pick } from 'lodash';
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { omit } from 'lodash';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { BehaviorSubject, Subject } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
import { EmbeddableFactory } from '@kbn/embeddable-plugin/public';
import { ReactEmbeddableRenderer, ViewMode } from '@kbn/embeddable-plugin/public';
import type { Filter, Query, TimeRange } from '@kbn/es-query';
import { compareFilters } from '@kbn/es-query';
import { useSearchApi, type ViewMode as ViewModeType } from '@kbn/presentation-publishing';
import { CONTROL_GROUP_TYPE } from '../../../common';
import {
getDefaultControlGroupInput,
getDefaultControlGroupPersistableInput,
persistableControlGroupInputKeys,
} from '../../../common';
import { ControlGroupContainer } from '../embeddable/control_group_container';
import { ControlGroupContainerFactory } from '../embeddable/control_group_container_factory';
ControlGroupApi,
ControlGroupRuntimeState,
ControlGroupSerializedState,
} from '../../react_controls/control_group/types';
import {
ControlGroupCreationOptions,
ControlGroupInput,
ControlGroupOutput,
CONTROL_GROUP_TYPE,
} from '../types';
import {
AwaitingControlGroupAPI,
buildApiFromControlGroupContainer,
ControlGroupAPI,
} from './control_group_api';
import { controlGroupInputBuilder, ControlGroupInputBuilder } from './control_group_input_builder';
controlGroupStateBuilder,
type ControlGroupStateBuilder,
} from '../../react_controls/control_group/utils/control_group_state_builder';
import { getDefaultControlGroupRuntimeState } from '../../react_controls/control_group/utils/initialization_utils';
import { ControlGroupCreationOptions, ControlGroupRendererApi } from './types';
export interface ControlGroupRendererProps {
filters?: Filter[];
onApiAvailable: (api: ControlGroupRendererApi) => void;
getCreationOptions?: (
initialInput: Partial<ControlGroupInput>,
builder: ControlGroupInputBuilder
) => Promise<ControlGroupCreationOptions>;
initialState: Partial<ControlGroupRuntimeState>,
builder: ControlGroupStateBuilder
) => Promise<Partial<ControlGroupCreationOptions>>;
viewMode?: ViewModeType;
filters?: Filter[];
timeRange?: TimeRange;
query?: Query;
dataLoading?: boolean;
}
export const ControlGroupRenderer = forwardRef<AwaitingControlGroupAPI, ControlGroupRendererProps>(
({ getCreationOptions, filters, timeRange, query }, ref) => {
const [controlGroup, setControlGroup] = useState<ControlGroupContainer>();
export const ControlGroupRenderer = ({
onApiAvailable,
getCreationOptions,
filters,
timeRange,
query,
viewMode,
dataLoading,
}: ControlGroupRendererProps) => {
const id = useMemo(() => uuidv4(), []);
const [regenerateId, setRegenerateId] = useState(uuidv4());
const [controlGroup, setControlGroup] = useState<ControlGroupRendererApi | undefined>();
useImperativeHandle(
ref,
() => buildApiFromControlGroupContainer(controlGroup) as ControlGroupAPI,
[controlGroup]
);
/**
* Parent API set up
*/
const searchApi = useSearchApi({
filters,
query,
timeRange,
});
const controlGroupDomRef = useRef(null);
const id = useMemo(() => uuidv4(), []);
const viewMode$ = useMemo(
() => new BehaviorSubject<ViewModeType>(viewMode ?? ViewMode.VIEW),
// viewMode only used as initial value - changes do not effect memoized value.
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
useEffect(() => {
if (viewMode) viewMode$.next(viewMode);
}, [viewMode, viewMode$]);
// onMount
useEffect(() => {
let canceled = false;
let destroyControlGroup: () => void;
const dataLoading$ = useMemo(
() => new BehaviorSubject<boolean>(Boolean(dataLoading)),
// dataLoading only used as initial value - changes do not effect memoized value.
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
useEffect(() => {
if (dataLoading !== dataLoading$.getValue()) dataLoading$.next(Boolean(dataLoading));
}, [dataLoading, dataLoading$]);
(async () => {
// Lazy loading all services is required in this component because it is exported and contributes to the bundle size.
const { pluginServices } = await import('../../services/plugin_services');
const { embeddable } = pluginServices.getServices();
const reload$ = useMemo(() => new Subject<void>(), []);
const factory = embeddable.getEmbeddableFactory(CONTROL_GROUP_TYPE) as EmbeddableFactory<
ControlGroupInput,
ControlGroupOutput,
ControlGroupContainer
> & {
create: ControlGroupContainerFactory['create'];
};
const { initialInput, settings, fieldFilterPredicate } =
(await getCreationOptions?.(getDefaultControlGroupInput(), controlGroupInputBuilder)) ??
{};
const newControlGroup = (await factory?.create(
{
id,
...getDefaultControlGroupInput(),
...initialInput,
},
undefined,
{
...settings,
lastSavedInput: {
...getDefaultControlGroupPersistableInput(),
...pick(initialInput, persistableControlGroupInputKeys),
},
},
fieldFilterPredicate
)) as ControlGroupContainer;
/**
* Control group API set up
*/
const runtimeState$ = useMemo(
() => new BehaviorSubject<ControlGroupRuntimeState>(getDefaultControlGroupRuntimeState()),
[]
);
const [serializedState, setSerializedState] = useState<ControlGroupSerializedState | undefined>();
if (canceled) {
newControlGroup.destroy();
controlGroup?.destroy();
return;
}
const updateInput = useCallback(
(newState: Partial<ControlGroupRuntimeState>) => {
runtimeState$.next({
...runtimeState$.getValue(),
...newState,
});
},
[runtimeState$]
);
if (controlGroupDomRef.current) {
newControlGroup.render(controlGroupDomRef.current);
}
setControlGroup(newControlGroup);
destroyControlGroup = () => newControlGroup.destroy();
})();
return () => {
canceled = true;
destroyControlGroup?.();
/**
* To mimic `input$`, subscribe to unsaved changes and snapshot the runtime state whenever
* something change
*/
useEffect(() => {
if (!controlGroup) return;
const stateChangeSubscription = controlGroup.unsavedChanges.subscribe((changes) => {
runtimeState$.next({ ...runtimeState$.getValue(), ...changes });
});
return () => {
stateChangeSubscription.unsubscribe();
};
}, [controlGroup, runtimeState$]);
/**
* On mount
*/
useEffect(() => {
let cancelled = false;
(async () => {
const { initialState, editorConfig } =
(await getCreationOptions?.(
getDefaultControlGroupRuntimeState(),
controlGroupStateBuilder
)) ?? {};
updateInput({
...initialState,
editorConfig,
});
const state = {
...omit(initialState, ['initialChildControlState', 'ignoreParentSettings']),
editorConfig,
controlStyle: initialState?.labelPosition,
panelsJSON: JSON.stringify(initialState?.initialChildControlState ?? {}),
ignoreParentSettingsJSON: JSON.stringify(initialState?.ignoreParentSettings ?? {}),
};
// exhaustive deps disabled because we want the control group to be created only on first render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!controlGroup) return;
if (
(timeRange && !isEqual(controlGroup.getInput().timeRange, timeRange)) ||
!compareFilters(controlGroup.getInput().filters ?? [], filters ?? []) ||
!isEqual(controlGroup.getInput().query, query)
) {
controlGroup.updateInput({
timeRange,
query,
filters,
});
if (!cancelled) {
setSerializedState(state as ControlGroupSerializedState);
}
}, [query, filters, controlGroup, timeRange]);
})();
return () => {
cancelled = true;
};
// exhaustive deps disabled because we want the control group to be created only on first render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return <div ref={controlGroupDomRef} />;
}
);
return !serializedState ? null : (
<ReactEmbeddableRenderer<ControlGroupSerializedState, ControlGroupRuntimeState, ControlGroupApi>
key={regenerateId} // this key forces a re-mount when `updateInput` is called
maybeId={id}
type={CONTROL_GROUP_TYPE}
getParentApi={() => ({
reload$,
dataLoading: dataLoading$,
viewMode: viewMode$,
query$: searchApi.query$,
timeRange$: searchApi.timeRange$,
unifiedSearchFilters$: searchApi.filters$,
getSerializedStateForChild: () => ({
rawState: serializedState,
}),
getRuntimeStateForChild: () => {
return runtimeState$.getValue();
},
})}
onApiAvailable={(controlGroupApi) => {
const controlGroupRendererApi: ControlGroupRendererApi = {
...controlGroupApi,
reload: () => reload$.next(),
updateInput: (newInput) => {
updateInput(newInput);
setRegenerateId(uuidv4()); // force remount
},
getInput$: () => runtimeState$,
};
setControlGroup(controlGroupRendererApi);
onApiAvailable(controlGroupRendererApi);
}}
hidePanelChrome
panelProps={{ hideLoader: true }}
/>
);
};

View file

@ -0,0 +1,23 @@
/*
* 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 { dynamic } from '@kbn/shared-ux-utility';
import type { ControlGroupRendererProps } from './control_group_renderer';
const Component = dynamic(async () => {
const { ControlGroupRenderer } = await import('./control_group_renderer');
return {
default: ControlGroupRenderer,
};
});
export function LazyControlGroupRenderer(props: ControlGroupRendererProps) {
return <Component {...props} />;
}

View file

@ -7,11 +7,6 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { ControlGroupContainer } from '..';
// TODO lock down ControlGroupAPI
export type ControlGroupAPI = ControlGroupContainer;
export type AwaitingControlGroupAPI = ControlGroupAPI | null;
export const buildApiFromControlGroupContainer = (container?: ControlGroupContainer) =>
container ?? null;
export type { ControlGroupRendererProps } from './control_group_renderer';
export { LazyControlGroupRenderer as ControlGroupRenderer } from './control_group_renderer_lazy';
export type { ControlGroupCreationOptions, ControlGroupRendererApi } from './types';

View file

@ -0,0 +1,41 @@
/*
* 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 {
ControlGroupApi,
ControlGroupEditorConfig,
ControlGroupRuntimeState,
} from '../../react_controls/control_group/types';
export type ControlGroupRendererApi = ControlGroupApi & {
reload: () => void;
/**
* @deprecated
* Calling `updateInput` will cause the entire control group to be re-initialized.
*
* Therefore, to update the runtime state without `updateInput`, you should add public setters to the
* relavant API (`ControlGroupApi` or the individual control type APIs) for the state you wish to update
* and call those setters instead.
*/
updateInput: (input: Partial<ControlGroupRuntimeState>) => void;
/**
* @deprecated
* Instead of subscribing to the whole runtime state, it is more efficient to subscribe to the individual
* publishing subjects of the control group API.
*/
getInput$: () => BehaviorSubject<ControlGroupRuntimeState>;
};
export interface ControlGroupCreationOptions {
initialState?: Partial<ControlGroupRuntimeState>;
editorConfig?: ControlGroupEditorConfig;
}

View file

@ -10,22 +10,16 @@
export type { ControlGroupContainer } from './embeddable/control_group_container';
export type { ControlGroupInput, ControlGroupOutput } from './types';
export { CONTROL_GROUP_TYPE } from './types';
export { ControlGroupContainerFactory } from './embeddable/control_group_container_factory';
export { CONTROL_GROUP_TYPE } from './types';
export { ACTION_EDIT_CONTROL, ACTION_DELETE_CONTROL } from './actions';
export { ACTION_DELETE_CONTROL, ACTION_EDIT_CONTROL } from './actions';
export {
type AddDataControlProps,
type AddOptionsListControlProps,
type ControlGroupInputBuilder,
type AddRangeSliderControlProps,
controlGroupInputBuilder,
} from './external_api/control_group_input_builder';
export { controlGroupInputBuilder } from './external_api/control_group_input_builder';
export type { ControlGroupAPI, AwaitingControlGroupAPI } from './external_api/control_group_api';
export {
type ControlGroupRendererProps,
ControlGroupRenderer,
} from './external_api/control_group_renderer';
export { ControlGroupRenderer } from './external_api';
export type {
ControlGroupRendererApi,
ControlGroupRendererProps,
ControlGroupCreationOptions,
} from './external_api';

View file

@ -36,12 +36,6 @@ export type ControlGroupReduxState = ReduxEmbeddableState<
export type FieldFilterPredicate = (f: DataViewField) => boolean;
export interface ControlGroupCreationOptions {
initialInput?: Partial<ControlGroupInput>;
settings?: ControlGroupSettings;
fieldFilterPredicate?: FieldFilterPredicate;
}
export interface ControlGroupSettings {
showAddButton?: boolean;
staticDataViewId?: string;

View file

@ -13,37 +13,38 @@ 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 {
DataControlApi,
DefaultDataControlState,
DataControlFactory,
DataControlServices,
} from './react_controls/controls/data_controls/types';
export { controlGroupStateBuilder } from './react_controls/control_group/control_group_state_builder';
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
*/
export type {
ControlOutput,
ControlFactory,
ControlEmbeddable,
ControlEditorProps,
CommonControlOutput,
IEditableControlFactory,
CanClearSelections,
} from './types';
export type {
ControlWidth,
ControlStyle,
ParentIgnoreSettings,
ControlInput,
DataControlInput,
} from '../common/types';
export {
CONTROL_GROUP_TYPE,
OPTIONS_LIST_CONTROL,
@ -52,41 +53,13 @@ export {
} from '../common';
export {
type AddDataControlProps,
type AddOptionsListControlProps,
type AddRangeSliderControlProps,
type ControlGroupContainer,
ControlGroupContainerFactory,
type ControlGroupInput,
type ControlGroupInputBuilder,
type ControlGroupAPI,
type AwaitingControlGroupAPI,
type ControlGroupOutput,
controlGroupInputBuilder,
} from './control_group';
export {
OptionsListEmbeddableFactory,
type OptionsListEmbeddable,
type OptionsListEmbeddableInput,
} from './options_list';
export {
RangeSliderEmbeddableFactory,
type RangeSliderEmbeddable,
type RangeSliderEmbeddableInput,
} from './range_slider';
export {
ACTION_EDIT_CONTROL,
ACTION_DELETE_CONTROL,
ControlGroupRenderer,
type ControlGroupRendererProps,
} from './control_group';
/** TODO: Remove this once it is no longer needed in the examples plugin */
export { CONTROL_WIDTH_OPTIONS } from './control_group/editor/editor_constants';
export function plugin() {
return new ControlsPlugin();
}

View file

@ -12,10 +12,11 @@ import React from 'react';
import { render } from '@testing-library/react';
import { OptionsListEmbeddableContext } from '../embeddable/options_list_embeddable';
import { OptionsListComponentState, OptionsListReduxState } from '../types';
import { ControlOutput, OptionsListEmbeddableInput } from '../..';
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 = {

View file

@ -12,7 +12,6 @@ import useAsync from 'react-use/lib/useAsync';
import { Direction, EuiFormRow, EuiLoadingSpinner, EuiRadioGroup, EuiSwitch } from '@elastic/eui';
import { ControlEditorProps, OptionsListEmbeddableInput } from '../..';
import {
getCompatibleSearchTechniques,
OptionsListSearchTechnique,
@ -25,6 +24,8 @@ import {
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 = [
{

View file

@ -14,12 +14,13 @@ 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 { ControlOutput, OptionsListEmbeddableInput } from '../..';
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;

View file

@ -38,22 +38,17 @@ import { i18n } from '@kbn/i18n';
import { ReduxEmbeddableTools, ReduxToolsPackage } from '@kbn/presentation-util-plugin/public';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import {
ControlGroupContainer,
ControlInput,
ControlOutput,
OptionsListEmbeddableInput,
OPTIONS_LIST_CONTROL,
} from '../..';
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 } from '../../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,

View file

@ -28,21 +28,16 @@ import { i18n } from '@kbn/i18n';
import { ReduxEmbeddableTools, ReduxToolsPackage } from '@kbn/presentation-util-plugin/public';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import {
ControlGroupContainer,
ControlInput,
ControlOutput,
RangeSliderEmbeddableInput,
RANGE_SLIDER_CONTROL,
} from '../..';
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 } from '../../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,

View file

@ -14,7 +14,7 @@ import { i18n } from '@kbn/i18n';
import type { EmbeddableApiContext, HasUniqueId } from '@kbn/presentation-publishing';
import { type Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
const ACTION_EDIT_CONTROL = 'editDataControl';
export const ACTION_EDIT_CONTROL = 'editDataControl';
export class EditControlAction implements Action<EmbeddableApiContext> {
public readonly type = ACTION_EDIT_CONTROL;

View file

@ -11,8 +11,12 @@ import React from 'react';
import { BehaviorSubject } from 'rxjs';
import { render } from '@testing-library/react';
import { ControlGroupEditor } from './control_group_editor';
import { ControlGroupApi, ControlStyle, ParentIgnoreSettings } from '../../..';
import { ControlGroupChainingSystem, DEFAULT_CONTROL_STYLE } from '../../../../common';
import { ControlGroupApi, ControlStyle } from '../../..';
import {
ControlGroupChainingSystem,
DEFAULT_CONTROL_STYLE,
ParentIgnoreSettings,
} from '../../../../common';
import { DefaultControlApi } from '../../controls/types';
describe('render', () => {

View file

@ -28,11 +28,12 @@ import {
} from '@elastic/eui';
import { css } from '@emotion/react';
import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
import { ControlStyle, ParentIgnoreSettings } from '../../..';
import { ControlStyle } from '../../..';
import { ControlStateManager } from '../../controls/types';
import { ControlGroupStrings } from '../control_group_strings';
import { ControlGroupApi, ControlGroupEditorState } from '../types';
import { ParentIgnoreSettings } from '../../../../common';
const CONTROL_LAYOUT_OPTIONS = [
{

View file

@ -101,6 +101,7 @@ export const ControlPanel = <ApiType extends DefaultControlApi = DefaultControlA
grow,
width,
labelPosition,
disabledActionIds,
rawViewMode,
] = useBatchedOptionalPublishingSubjects(
api?.dataLoading,
@ -110,6 +111,7 @@ export const ControlPanel = <ApiType extends DefaultControlApi = DefaultControlA
api?.grow,
api?.width,
api?.parentApi?.labelPosition,
api?.parentApi?.disabledActionIds,
viewModeSubject
);
const usingTwoLineLayout = labelPosition === 'twoLine';
@ -149,7 +151,7 @@ export const ControlPanel = <ApiType extends DefaultControlApi = DefaultControlA
'controlFrameFloatingActions--oneLine': !usingTwoLineLayout,
})}
viewMode={viewMode}
disabledActions={[]}
disabledActions={disabledActionIds}
isEnabled={true}
>
<EuiFormRow

View file

@ -11,7 +11,7 @@ import { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
import { PublishesUnifiedSearch, PublishingSubject } from '@kbn/presentation-publishing';
import { apiPublishesReload } from '@kbn/presentation-publishing/interfaces/fetch/publishes_reload';
import { BehaviorSubject, debounceTime, map, merge, Observable, switchMap } from 'rxjs';
import { ParentIgnoreSettings } from '../../..';
import { ParentIgnoreSettings } from '../../../../common';
export interface ControlGroupFetchContext {
unifiedSearchFilters?: Filter[] | undefined;

View file

@ -7,9 +7,10 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import fastIsEqual from 'fast-deep-equal';
import React, { useEffect } from 'react';
import { BehaviorSubject } from 'rxjs';
import fastIsEqual from 'fast-deep-equal';
import { CoreStart } from '@kbn/core/public';
import { DataView } from '@kbn/data-views-plugin/common';
import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
@ -20,31 +21,33 @@ import {
combineCompatibleChildrenApis,
} from '@kbn/presentation-containers';
import {
apiPublishesDataViews,
PublishesDataViews,
apiPublishesDataViews,
useBatchedPublishingSubjects,
} from '@kbn/presentation-publishing';
import { apiPublishesReload } from '@kbn/presentation-publishing/interfaces/fetch/publishes_reload';
import { ControlStyle, ParentIgnoreSettings } from '../..';
import { ControlStyle } from '../..';
import {
ControlGroupChainingSystem,
CONTROL_GROUP_TYPE,
ControlGroupChainingSystem,
DEFAULT_CONTROL_STYLE,
ParentIgnoreSettings,
} from '../../../common';
import { openDataControlEditor } from '../controls/data_controls/open_data_control_editor';
import { ControlGroup } from './components/control_group';
import { chaining$, controlFetch$, controlGroupFetch$ } from './control_fetch';
import { initializeControlGroupUnsavedChanges } from './control_group_unsaved_changes_api';
import { initControlsManager } from './init_controls_manager';
import { openEditControlGroupFlyout } from './open_edit_control_group_flyout';
import { deserializeControlGroup } from './serialization_utils';
import { initSelectionsManager } from './selections_manager';
import {
ControlGroupApi,
ControlGroupRuntimeState,
ControlGroupSerializedState,
ControlPanelsState,
} from './types';
import { ControlGroup } from './components/control_group';
import { initSelectionsManager } from './selections_manager';
import { initializeControlGroupUnsavedChanges } from './control_group_unsaved_changes_api';
import { openDataControlEditor } from '../controls/data_controls/open_data_control_editor';
import { deserializeControlGroup } from './utils/serialization_utils';
const DEFAULT_CHAINING_SYSTEM = 'HIERARCHICAL';
@ -98,6 +101,7 @@ export const getControlGroupEmbeddableFactory = (services: {
initialLabelPosition ?? DEFAULT_CONTROL_STYLE // TODO: Rename `DEFAULT_CONTROL_STYLE`
);
const allowExpensiveQueries$ = new BehaviorSubject<boolean>(true);
const disabledActionIds$ = new BehaviorSubject<string[] | undefined>(undefined);
/** TODO: Handle loading; loading should be true if any child is loading */
const dataLoading$ = new BehaviorSubject<boolean | undefined>(false);
@ -131,9 +135,7 @@ export const getControlGroupEmbeddableFactory = (services: {
const api = setApi({
...controlsManager.api,
getLastSavedControlState: (controlUuid: string) => {
return lastSavedRuntimeState.initialChildControlState[controlUuid] ?? {};
},
disabledActionIds: disabledActionIds$,
...unsavedChanges.api,
...selectionsManager.api,
controlFetch$: (controlUuid: string) =>
@ -150,8 +152,13 @@ export const getControlGroupEmbeddableFactory = (services: {
autoApplySelections$,
allowExpensiveQueries$,
snapshotRuntimeState: () => {
// TODO: Remove this if it ends up being unnecessary
return {} as unknown as ControlGroupRuntimeState;
return {
chainingSystem: chainingSystem$.getValue(),
labelPosition: labelPosition$.getValue(),
autoApplySelections: autoApplySelections$.getValue(),
ignoreParentSettings: ignoreParentSettings$.getValue(),
initialChildControlState: controlsManager.snapshotControlsRuntimeState(),
};
},
dataLoading: dataLoading$,
onEdit: async () => {
@ -167,15 +174,12 @@ export const getControlGroupEmbeddableFactory = (services: {
);
},
isEditingEnabled: () => true,
getTypeDisplayName: () =>
i18n.translate('controls.controlGroup.displayName', {
defaultMessage: 'Controls',
}),
openAddDataControlFlyout: (options) => {
openAddDataControlFlyout: (settings) => {
const parentDataViewId = apiPublishesDataViews(parentApi)
? parentApi.dataViews.value?.[0]?.id
: undefined;
const newControlState = controlsManager.getNewControlState();
openDataControlEditor({
initialState: {
...newControlState,
@ -185,14 +189,11 @@ export const getControlGroupEmbeddableFactory = (services: {
onSave: ({ type: controlType, state: initialState }) => {
controlsManager.api.addNewPanel({
panelType: controlType,
initialState: options?.controlInputTransform
? options.controlInputTransform(
initialState as Partial<ControlGroupSerializedState>,
controlType
)
initialState: settings?.controlStateTransform
? settings.controlStateTransform(initialState, controlType)
: initialState,
});
options?.onSave?.();
settings?.onSave?.();
},
controlGroupApi: api,
services,
@ -217,6 +218,20 @@ export const getControlGroupEmbeddableFactory = (services: {
? parentApi.saveNotification$
: undefined,
reload$: apiPublishesReload(parentApi) ? parentApi.reload$ : undefined,
/** Public getters */
getTypeDisplayName: () =>
i18n.translate('controls.controlGroup.displayName', {
defaultMessage: 'Controls',
}),
getEditorConfig: () => initialRuntimeState.editorConfig,
getLastSavedControlState: (controlUuid: string) => {
return lastSavedRuntimeState.initialChildControlState[controlUuid] ?? {};
},
/** Public setters */
setDisabledActionIds: (ids) => disabledActionIds$.next(ids),
setChainingSystem: (newChainingSystem) => chainingSystem$.next(newChainingSystem),
});
/** Subscribe to all children's output data views, combine them, and output them */

View file

@ -264,9 +264,7 @@ export function initControlsManager(
export function getLastUsedDataViewId(
controlsInOrder: ControlsInOrder,
initialControlPanelsState: ControlPanelsState<
ControlPanelState & Partial<DefaultDataControlState>
>
initialControlPanelsState: ControlPanelsState<Partial<DefaultDataControlState>>
) {
let dataViewId: string | undefined;
for (let i = controlsInOrder.length - 1; i >= 0; i--) {

View file

@ -7,8 +7,6 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { Observable } from 'rxjs';
import { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public';
import { Filter } from '@kbn/es-query';
import {
@ -20,6 +18,7 @@ import {
HasEditCapabilities,
HasParentApi,
PublishesDataLoading,
PublishesDisabledActionIds,
PublishesFilters,
PublishesTimeslice,
PublishesUnifiedSearch,
@ -27,18 +26,26 @@ import {
PublishingSubject,
} from '@kbn/presentation-publishing';
import { PublishesDataViews } from '@kbn/presentation-publishing/interfaces/publishes_data_views';
import { Observable } from 'rxjs';
import { PublishesReload } from '@kbn/presentation-publishing/interfaces/fetch/publishes_reload';
import { ParentIgnoreSettings } from '../..';
import { ControlInputTransform } from '../../../common';
import { ControlGroupChainingSystem } from '../../../common/control_group/types';
import { ControlStyle } from '../../types';
import { DefaultControlState } from '../controls/types';
import { ControlFetchContext } from './control_fetch/control_fetch';
import { FieldFilterPredicate } from '../../control_group/types';
import { ParentIgnoreSettings } from '../../../common';
export interface ControlPanelsState<ControlState extends ControlPanelState = ControlPanelState> {
[panelId: string]: ControlState;
}
/**
* ----------------------------------------------------------------
* Control group API
* ----------------------------------------------------------------
*/
export type ControlStateTransform<State extends DefaultControlState = DefaultControlState> = (
newState: Partial<State>,
controlType: string
) => Partial<State>;
export type ControlGroupUnsavedChanges = Omit<
ControlGroupRuntimeState,
@ -47,8 +54,6 @@ export type ControlGroupUnsavedChanges = Omit<
filters: Filter[] | undefined;
};
export type ControlPanelState = DefaultControlState & { type: string; order: number };
export type ControlGroupApi = PresentationContainer &
DefaultEmbeddableApi<ControlGroupSerializedState, ControlGroupRuntimeState> &
PublishesFilters &
@ -56,46 +61,62 @@ export type ControlGroupApi = PresentationContainer &
HasSerializedChildState<ControlPanelState> &
HasEditCapabilities &
PublishesDataLoading &
Pick<PublishesUnsavedChanges, 'unsavedChanges'> &
Pick<PublishesUnsavedChanges<ControlGroupRuntimeState>, 'unsavedChanges'> &
PublishesTimeslice &
PublishesDisabledActionIds &
Partial<HasParentApi<PublishesUnifiedSearch> & HasSaveNotification & PublishesReload> & {
asyncResetUnsavedChanges: () => Promise<void>;
autoApplySelections$: PublishingSubject<boolean>;
controlFetch$: (controlUuid: string) => Observable<ControlFetchContext>;
getLastSavedControlState: (controlUuid: string) => object;
ignoreParentSettings$: PublishingSubject<ParentIgnoreSettings | undefined>;
allowExpensiveQueries$: PublishingSubject<boolean>;
untilInitialized: () => Promise<void>;
autoApplySelections$: PublishingSubject<boolean>;
ignoreParentSettings$: PublishingSubject<ParentIgnoreSettings | undefined>;
labelPosition: PublishingSubject<ControlStyle>;
asyncResetUnsavedChanges: () => Promise<void>;
controlFetch$: (controlUuid: string) => Observable<ControlFetchContext>;
openAddDataControlFlyout: (options?: {
controlInputTransform?: ControlInputTransform;
controlStateTransform?: ControlStateTransform;
onSave?: () => void;
}) => void;
labelPosition: PublishingSubject<ControlStyle>;
untilInitialized: () => Promise<void>;
/** Public getters */
getEditorConfig: () => ControlGroupEditorConfig | undefined;
getLastSavedControlState: (controlUuid: string) => object;
/** Public setters */
setChainingSystem: (chainingSystem: ControlGroupChainingSystem) => void;
};
export interface ControlGroupRuntimeState {
/**
* ----------------------------------------------------------------
* Control group state
* ----------------------------------------------------------------
*/
export interface ControlGroupEditorConfig {
hideDataViewSelector?: boolean;
hideWidthSettings?: boolean;
hideAdditionalSettings?: boolean;
fieldFilterPredicate?: FieldFilterPredicate;
}
export interface ControlGroupRuntimeState<State extends DefaultControlState = DefaultControlState> {
chainingSystem: ControlGroupChainingSystem;
labelPosition: ControlStyle; // TODO: Rename this type to ControlLabelPosition
autoApplySelections: boolean;
ignoreParentSettings?: ParentIgnoreSettings;
initialChildControlState: ControlPanelsState<ControlPanelState>;
/** TODO: Handle the editor config, which is used with the control group renderer component */
editorConfig?: {
hideDataViewSelector?: boolean;
hideWidthSettings?: boolean;
hideAdditionalSettings?: boolean;
};
initialChildControlState: ControlPanelsState<State>;
/*
* Configuration settings that are never persisted
* - remove after https://github.com/elastic/kibana/issues/189939 is resolved
*/
editorConfig?: ControlGroupEditorConfig;
}
export type ControlGroupEditorState = Pick<
ControlGroupRuntimeState,
'chainingSystem' | 'labelPosition' | 'autoApplySelections' | 'ignoreParentSettings'
>;
export interface ControlGroupSerializedState {
chainingSystem: ControlGroupChainingSystem;
panelsJSON: string;
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
@ -104,3 +125,23 @@ export interface ControlGroupSerializedState {
// to avoid migrations, we will continue to refer to this property as `showApplySelections` in the serialized state
showApplySelections: boolean | undefined;
}
export type ControlGroupEditorState = Pick<
ControlGroupRuntimeState,
'chainingSystem' | 'labelPosition' | 'autoApplySelections' | 'ignoreParentSettings'
>;
/**
* ----------------------------------------------------------------
* Control group panel state
* ----------------------------------------------------------------
*/
export interface ControlPanelsState<State extends DefaultControlState = DefaultControlState> {
[panelId: string]: ControlPanelState<State>;
}
export type ControlPanelState<State extends DefaultControlState = DefaultControlState> = State & {
type: string;
order: number;
};

View file

@ -9,13 +9,17 @@
import { v4 as uuidv4 } from 'uuid';
import { OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL, TIME_SLIDER_CONTROL } from '../../../common';
import { ControlGroupRuntimeState, ControlPanelsState } from './types';
import { pluginServices } from '../../services';
import { OptionsListControlState } from '../controls/data_controls/options_list_control/types';
import { RangesliderControlState } from '../controls/data_controls/range_slider/types';
import { DefaultDataControlState } from '../controls/data_controls/types';
import { getDataControlFieldRegistry } from '../controls/data_controls/data_control_editor_utils';
import {
OPTIONS_LIST_CONTROL,
RANGE_SLIDER_CONTROL,
TIME_SLIDER_CONTROL,
} from '../../../../common';
import { ControlGroupRuntimeState, ControlPanelsState } from '../types';
import { pluginServices } from '../../../services';
import { OptionsListControlState } from '../../controls/data_controls/options_list_control/types';
import { RangesliderControlState } from '../../controls/data_controls/range_slider/types';
import { DefaultDataControlState } from '../../controls/data_controls/types';
import { getDataControlFieldRegistry } from '../../controls/data_controls/data_control_editor_utils';
export type ControlGroupStateBuilder = typeof controlGroupStateBuilder;

View file

@ -0,0 +1,24 @@
/*
* 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 { DEFAULT_CONTROL_STYLE } from '../../../../common';
import { ControlGroupRuntimeState } from '../types';
export const getDefaultControlGroupRuntimeState = (): ControlGroupRuntimeState => ({
initialChildControlState: {},
labelPosition: DEFAULT_CONTROL_STYLE,
chainingSystem: 'HIERARCHICAL',
autoApplySelections: true,
ignoreParentSettings: {
ignoreFilters: false,
ignoreQuery: false,
ignoreTimerange: false,
ignoreValidations: false,
},
});

View file

@ -7,10 +7,12 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { SerializedPanelState } from '@kbn/presentation-containers';
import { omit } from 'lodash';
import { ControlGroupRuntimeState, ControlGroupSerializedState } from './types';
import { parseReferenceName } from '../controls/data_controls/reference_name_utils';
import { SerializedPanelState } from '@kbn/presentation-containers';
import { parseReferenceName } from '../../controls/data_controls/reference_name_utils';
import { ControlGroupRuntimeState, ControlGroupSerializedState } from '../types';
export const deserializeControlGroup = (
state: SerializedPanelState<ControlGroupSerializedState>
@ -45,7 +47,7 @@ export const deserializeControlGroup = (
autoApplySelections:
typeof state.rawState.showApplySelections === 'boolean'
? !state.rawState.showApplySelections
: false, // Rename "showApplySelections" to "autoApplySelections"
: true, // Rename "showApplySelections" to "autoApplySelections"
labelPosition: state.rawState.controlStyle, // Rename "controlStyle" to "labelPosition"
};
};

View file

@ -62,6 +62,7 @@ const controlGroupApi = {
parentApi: dashboardApi,
grow: new BehaviorSubject(DEFAULT_CONTROL_GROW),
width: new BehaviorSubject(DEFAULT_CONTROL_WIDTH),
getEditorConfig: () => undefined,
} as unknown as ControlGroupApi;
describe('Data control editor', () => {
@ -290,4 +291,54 @@ describe('Data control editor', () => {
expect(getPressedAttribute(controlEditor, 'create__search')).toBe('false');
});
});
describe('control editor config', () => {
const getEditorConfig = jest.fn().mockImplementation(() => undefined);
beforeAll(() => {
controlGroupApi.getEditorConfig = getEditorConfig;
});
test('all elements are visible when no editor config', async () => {
const controlEditor = await mountComponent({
initialState: {
fieldName: 'machine.os.raw',
},
controlType: 'optionsList',
controlId: 'testId',
initialDefaultPanelTitle: 'OS',
});
const dataViewPicker = controlEditor.queryByTestId('control-editor-data-view-picker');
expect(dataViewPicker).toBeInTheDocument();
const widthSettings = controlEditor.queryByTestId('control-editor-width-settings');
expect(widthSettings).toBeInTheDocument();
const customSettings = controlEditor.queryByTestId('control-editor-custom-settings');
expect(customSettings).toBeInTheDocument();
});
test('can hide elements with the editor config', async () => {
getEditorConfig.mockImplementationOnce(() => ({
hideDataViewSelector: true,
hideWidthSettings: true,
hideAdditionalSettings: true,
}));
const controlEditor = await mountComponent({
initialState: {
fieldName: 'machine.os.raw',
},
controlType: 'optionsList',
controlId: 'testId',
initialDefaultPanelTitle: 'OS',
});
const dataViewPicker = controlEditor.queryByTestId('control-editor-data-view-picker');
expect(dataViewPicker).not.toBeInTheDocument();
const widthSettings = controlEditor.queryByTestId('control-editor-width-settings');
expect(widthSettings).not.toBeInTheDocument();
const customSettings = controlEditor.queryByTestId('control-editor-custom-settings');
expect(customSettings).not.toBeInTheDocument();
});
});
});

View file

@ -39,15 +39,15 @@ import {
LazyFieldPicker,
withSuspense,
} from '@kbn/presentation-util-plugin/public';
import { DataControlFieldRegistry } from '../../../types';
import { CONTROL_WIDTH_OPTIONS } from '../../..';
import { ControlWidth, DEFAULT_CONTROL_GROW, DEFAULT_CONTROL_WIDTH } from '../../../../common';
import { DataControlFieldRegistry } from '../../../types';
import { ControlWidth, DEFAULT_CONTROL_GROW, DEFAULT_CONTROL_WIDTH } from '../../../../common';
import { getAllControlTypes, getControlFactory } from '../../control_factory_registry';
import { ControlGroupApi } from '../../control_group/types';
import { DataControlEditorStrings } from './data_control_constants';
import { getDataControlFieldRegistry } from './data_control_editor_utils';
import { DataControlFactory, DefaultDataControlState, isDataControlFactory } from './types';
import { CONTROL_WIDTH_OPTIONS } from '../../../control_group/editor/editor_constants';
export interface ControlEditorProps<
State extends DefaultDataControlState = DefaultDataControlState
@ -153,9 +153,7 @@ export const DataControlEditor = <State extends DefaultDataControlState = Defaul
const [panelTitle, setPanelTitle] = useState<string>(initialState.title ?? defaultPanelTitle);
const [selectedControlType, setSelectedControlType] = useState<string | undefined>(controlType);
const [controlOptionsValid, setControlOptionsValid] = useState<boolean>(true);
/** TODO: Make `editorConfig` work when refactoring the `ControlGroupRenderer` */
// const editorConfig = controlGroup.getEditorConfig();
const editorConfig = useMemo(() => controlGroupApi.getEditorConfig(), [controlGroupApi]);
// TODO: Maybe remove `useAsync` - see https://github.com/elastic/kibana/pull/182842#discussion_r1624909709
const {
@ -238,36 +236,37 @@ export const DataControlEditor = <State extends DefaultDataControlState = Defaul
title={<h2>{DataControlEditorStrings.manageControl.dataSource.getFormGroupTitle()}</h2>}
description={DataControlEditorStrings.manageControl.dataSource.getFormGroupDescription()}
>
{/* {!editorConfig?.hideDataViewSelector && ( */}
<EuiFormRow
label={DataControlEditorStrings.manageControl.dataSource.getDataViewTitle()}
>
{dataViewListError ? (
<EuiCallOut
color="danger"
iconType="error"
title={DataControlEditorStrings.manageControl.dataSource.getDataViewListErrorTitle()}
>
<p>{dataViewListError.message}</p>
</EuiCallOut>
) : (
<DataViewPicker
dataViews={dataViewListItems}
selectedDataViewId={editorState.dataViewId}
onChangeDataViewId={(newDataViewId) => {
setEditorState({ ...editorState, dataViewId: newDataViewId });
setSelectedControlType(undefined);
}}
trigger={{
label:
selectedDataView?.getName() ??
DataControlEditorStrings.manageControl.dataSource.getSelectDataViewMessage(),
}}
selectableProps={{ isLoading: dataViewListLoading }}
/>
)}
</EuiFormRow>
{/* )} */}
{!editorConfig?.hideDataViewSelector && (
<EuiFormRow
data-test-subj="control-editor-data-view-picker"
label={DataControlEditorStrings.manageControl.dataSource.getDataViewTitle()}
>
{dataViewListError ? (
<EuiCallOut
color="danger"
iconType="error"
title={DataControlEditorStrings.manageControl.dataSource.getDataViewListErrorTitle()}
>
<p>{dataViewListError.message}</p>
</EuiCallOut>
) : (
<DataViewPicker
dataViews={dataViewListItems}
selectedDataViewId={editorState.dataViewId}
onChangeDataViewId={(newDataViewId) => {
setEditorState({ ...editorState, dataViewId: newDataViewId });
setSelectedControlType(undefined);
}}
trigger={{
label:
selectedDataView?.getName() ??
DataControlEditorStrings.manageControl.dataSource.getSelectDataViewMessage(),
}}
selectableProps={{ isLoading: dataViewListLoading }}
/>
)}
</EuiFormRow>
)}
<EuiFormRow label={DataControlEditorStrings.manageControl.dataSource.getFieldTitle()}>
{fieldListError ? (
@ -281,9 +280,8 @@ export const DataControlEditor = <State extends DefaultDataControlState = Defaul
) : (
<FieldPicker
filterPredicate={(field: DataViewField) => {
/** TODO: Make `fieldFilterPredicate` work when refactoring the `ControlGroupRenderer` */
// const customPredicate = controlGroup.fieldFilterPredicate?.(field) ?? true;
return Boolean(fieldRegistry?.[field.name]);
const customPredicate = editorConfig?.fieldFilterPredicate?.(field) ?? true;
return Boolean(fieldRegistry?.[field.name]) && customPredicate;
}}
selectedFieldName={editorState.fieldName}
dataView={selectedDataView}
@ -356,33 +354,34 @@ export const DataControlEditor = <State extends DefaultDataControlState = Defaul
}}
/>
</EuiFormRow>
{/* {!editorConfig?.hideWidthSettings && ( */}
<EuiFormRow
label={DataControlEditorStrings.manageControl.displaySettings.getWidthInputTitle()}
>
<div>
<EuiButtonGroup
color="primary"
legend={DataControlEditorStrings.management.controlWidth.getWidthSwitchLegend()}
options={CONTROL_WIDTH_OPTIONS}
idSelected={editorState.width ?? DEFAULT_CONTROL_WIDTH}
onChange={(newWidth: string) =>
setEditorState({ ...editorState, width: newWidth as ControlWidth })
}
/>
<EuiSpacer size="s" />
<EuiSwitch
label={DataControlEditorStrings.manageControl.displaySettings.getGrowSwitchTitle()}
color="primary"
checked={editorState.grow ?? DEFAULT_CONTROL_GROW}
onChange={() => setEditorState({ ...editorState, grow: !editorState.grow })}
data-test-subj="control-editor-grow-switch"
/>
</div>
</EuiFormRow>
{/* )} */}
{!editorConfig?.hideWidthSettings && (
<EuiFormRow
data-test-subj="control-editor-width-settings"
label={DataControlEditorStrings.manageControl.displaySettings.getWidthInputTitle()}
>
<div>
<EuiButtonGroup
color="primary"
legend={DataControlEditorStrings.management.controlWidth.getWidthSwitchLegend()}
options={CONTROL_WIDTH_OPTIONS}
idSelected={editorState.width ?? DEFAULT_CONTROL_WIDTH}
onChange={(newWidth: string) =>
setEditorState({ ...editorState, width: newWidth as ControlWidth })
}
/>
<EuiSpacer size="s" />
<EuiSwitch
label={DataControlEditorStrings.manageControl.displaySettings.getGrowSwitchTitle()}
color="primary"
checked={editorState.grow ?? DEFAULT_CONTROL_GROW}
onChange={() => setEditorState({ ...editorState, grow: !editorState.grow })}
data-test-subj="control-editor-grow-switch"
/>
</div>
</EuiFormRow>
)}
</EuiDescribedFormGroup>
{CustomSettingsComponent}
{!editorConfig?.hideAdditionalSettings && CustomSettingsComponent}
{controlId && (
<>
<EuiSpacer size="l" />

View file

@ -9,8 +9,8 @@
import { EmbeddableFactory } from '@kbn/embeddable-plugin/public';
import { PluginServiceFactory } from '@kbn/presentation-util-plugin/public';
import { ControlEmbeddable, ControlFactory, ControlInput, ControlOutput } from '../..';
import { ControlsServiceType, ControlTypeRegistry } from './types';
import { ControlEmbeddable, ControlFactory, ControlInput, ControlOutput } from '../../types';
export type ControlsServiceFactory = PluginServiceFactory<ControlsServiceType>;
export const controlsServiceFactory = () => getStubControlsService();

View file

@ -9,8 +9,8 @@
import { EmbeddableFactory } from '@kbn/embeddable-plugin/public';
import { PluginServiceFactory } from '@kbn/presentation-util-plugin/public';
import { ControlEmbeddable, ControlFactory, ControlInput, ControlOutput } from '../..';
import { ControlsServiceType, ControlTypeRegistry } from './types';
import { ControlEmbeddable, ControlFactory, ControlInput, ControlOutput } from '../../types';
export type ControlsServiceFactory = PluginServiceFactory<ControlsServiceType>;
export const controlsServiceFactory = () => controlsService;

View file

@ -44,7 +44,8 @@
"@kbn/content-management-utils",
"@kbn/core-lifecycle-browser",
"@kbn/field-formats-plugin",
"@kbn/presentation-panel-plugin"
"@kbn/presentation-panel-plugin",
"@kbn/shared-ux-utility"
],
"exclude": ["target/**/*"]
}

View file

@ -267,6 +267,7 @@ export const legacyEmbeddableToApi = (
dataViews,
disabledActionIds,
setDisabledActionIds: (ids) => disabledActionIds.next(ids),
panelTitle,
setPanelTitle,

View file

@ -138,6 +138,7 @@ export abstract class Embeddable<
defaultPanelDescription: this.defaultPanelDescription,
canLinkToLibrary: this.canLinkToLibrary,
disabledActionIds: this.disabledActionIds,
setDisabledActionIds: this.setDisabledActionIds,
unlinkFromLibrary: this.unlinkFromLibrary,
setHidePanelTitle: this.setHidePanelTitle,
defaultPanelTitle: this.defaultPanelTitle,
@ -180,6 +181,7 @@ export abstract class Embeddable<
public panelDescription: LegacyEmbeddableAPI['panelDescription'];
public defaultPanelDescription: LegacyEmbeddableAPI['defaultPanelDescription'];
public disabledActionIds: LegacyEmbeddableAPI['disabledActionIds'];
public setDisabledActionIds: LegacyEmbeddableAPI['setDisabledActionIds'];
public unlinkFromLibrary: LegacyEmbeddableAPI['unlinkFromLibrary'];
public setTimeRange: LegacyEmbeddableAPI['setTimeRange'];
public defaultPanelTitle: LegacyEmbeddableAPI['defaultPanelTitle'];

View file

@ -5,19 +5,17 @@
* 2.0.
*/
import _ from 'lodash';
import React, { Component } from 'react';
import { Observable, Subscription } from 'rxjs';
import { distinctUntilChanged } from 'rxjs';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import React, { useEffect, useState } from 'react';
import { Observable, switchMap, tap } from 'rxjs';
import {
type ControlGroupInput,
type ControlGroupInputBuilder,
type AwaitingControlGroupAPI,
ControlGroupRenderer,
ControlGroupRendererApi,
type ControlGroupRuntimeState,
type ControlGroupStateBuilder,
} from '@kbn/controls-plugin/public';
import { first } from 'rxjs';
import type { TimeRange } from '@kbn/es-query';
import { Timeslice } from '../../../common/descriptor_types';
export interface Props {
@ -26,54 +24,23 @@ export interface Props {
waitForTimesliceToLoad$: Observable<void>;
}
export class Timeslider extends Component<Props, {}> {
private _isMounted: boolean = false;
private readonly _subscriptions = new Subscription();
export function Timeslider({ setTimeslice, timeRange, waitForTimesliceToLoad$ }: Props) {
const [dataLoading, setDataLoading] = useState(false);
const [api, setApi] = useState<ControlGroupRendererApi | undefined>();
componentWillUnmount() {
this._isMounted = false;
this._subscriptions.unsubscribe();
}
componentDidMount() {
this._isMounted = true;
}
_getCreationOptions = async (
initialInput: Partial<ControlGroupInput>,
builder: ControlGroupInputBuilder
) => {
builder.addTimeSliderControl(initialInput);
return {
initialInput: {
...initialInput,
viewMode: ViewMode.VIEW,
timeRange: this.props.timeRange,
},
};
};
_onLoadComplete = (controlGroup: AwaitingControlGroupAPI) => {
if (!this._isMounted || !controlGroup) {
useEffect(() => {
if (!api) {
return;
}
this._subscriptions.add(
controlGroup
.getOutput$()
.pipe(
distinctUntilChanged(({ timeslice: timesliceA }, { timeslice: timesliceB }) =>
_.isEqual(timesliceA, timesliceB)
)
)
.subscribe(({ timeslice }) => {
// use waitForTimesliceToLoad$ observable to wait until next frame loaded
// .pipe(first()) waits until the first value is emitted from an observable and then automatically unsubscribes
this.props.waitForTimesliceToLoad$.pipe(first()).subscribe(() => {
controlGroup.anyControlOutputConsumerLoading$.next(false);
});
this.props.setTimeslice(
let canceled = false;
const subscription = api.timeslice$
.pipe(
tap(() => {
if (!canceled) setDataLoading(true);
}),
switchMap((timeslice) => {
setTimeslice(
timeslice === undefined
? undefined
: {
@ -81,19 +48,37 @@ export class Timeslider extends Component<Props, {}> {
to: timeslice[1],
}
);
return waitForTimesliceToLoad$;
})
);
};
)
.subscribe(() => {
if (!canceled) setDataLoading(false);
});
render() {
return (
<div className="mapTimeslider mapTimeslider--animation">
<ControlGroupRenderer
ref={this._onLoadComplete}
getCreationOptions={this._getCreationOptions}
timeRange={this.props.timeRange}
/>
</div>
);
}
return () => {
subscription?.unsubscribe();
canceled = true;
};
}, [api, setTimeslice, waitForTimesliceToLoad$]);
return (
<div className="mapTimeslider mapTimeslider--animation">
<ControlGroupRenderer
onApiAvailable={(nextApi: ControlGroupRendererApi) => {
setApi(nextApi);
}}
dataLoading={dataLoading}
getCreationOptions={async (
initialState: Partial<ControlGroupRuntimeState>,
builder: ControlGroupStateBuilder
) => {
builder.addTimeSliderControl(initialState);
return {
initialState,
};
}}
timeRange={timeRange}
/>
</div>
);
}

View file

@ -7,14 +7,11 @@
import React, { useCallback, useEffect, useRef } from 'react';
import {
type ControlEmbeddable,
ControlGroupAPI,
ControlGroupRenderer,
type ControlInput,
type ControlOutput,
type ControlGroupInput,
ControlGroupRendererApi,
DataControlApi,
ControlGroupRuntimeState,
} from '@kbn/controls-plugin/public';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import type { Filter, Query, TimeRange } from '@kbn/es-query';
import { DataView } from '@kbn/data-views-plugin/public';
import { Subscription } from 'rxjs';
@ -41,46 +38,42 @@ export const ControlsContent: React.FC<Props> = ({
const subscriptions = useRef<Subscription>(new Subscription());
const getInitialInput = useCallback(
(loadedDataView: DataView) => async () => {
const initialInput: Partial<ControlGroupInput> = {
id: loadedDataView.id,
viewMode: ViewMode.VIEW,
() => async () => {
const initialInput: Partial<ControlGroupRuntimeState> = {
chainingSystem: 'HIERARCHICAL',
controlStyle: 'oneLine',
defaultControlWidth: 'small',
panels: controlPanels,
filters,
query,
timeRange,
labelPosition: 'oneLine',
initialChildControlState: controlPanels,
};
return { initialInput };
return { initialState: initialInput };
},
[controlPanels, filters, query, timeRange]
[controlPanels]
);
const loadCompleteHandler = useCallback(
(controlGroup: ControlGroupAPI) => {
(controlGroup: ControlGroupRendererApi) => {
if (!controlGroup) return;
controlGroup.untilAllChildrenReady().then(() => {
controlGroup.getChildIds().map((id) => {
const embeddable =
controlGroup.getChild<ControlEmbeddable<ControlInput, ControlOutput>>(id);
embeddable.renderPrepend = () => (
<ControlTitle title={embeddable.getTitle()} embeddableId={id} />
controlGroup.untilInitialized().then(() => {
const children = controlGroup.children$.getValue();
Object.keys(children).map((childId) => {
const child = children[childId] as DataControlApi;
child.CustomPrependComponent = () => (
<ControlTitle title={child.panelTitle.getValue()} embeddableId={childId} />
);
});
});
subscriptions.current.add(
controlGroup.onFiltersPublished$.subscribe((newFilters) => {
controlGroup.filters$.subscribe((newFilters = []) => {
onFiltersChange(newFilters);
})
);
subscriptions.current.add(
controlGroup.getInput$().subscribe(({ panels }) => setControlPanels(panels))
controlGroup
.getInput$()
.subscribe(({ initialChildControlState }) => setControlPanels(initialChildControlState))
);
},
[onFiltersChange, setControlPanels]
@ -100,8 +93,8 @@ export const ControlsContent: React.FC<Props> = ({
return (
<ControlGroupContainer>
<ControlGroupRenderer
getCreationOptions={getInitialInput(dataView)}
ref={loadCompleteHandler}
getCreationOptions={getInitialInput()}
onApiAvailable={loadCompleteHandler}
timeRange={timeRange}
query={query}
filters={filters}

View file

@ -28,33 +28,24 @@ const controlPanelConfigs: ControlPanels = {
width: 'medium',
grow: false,
type: 'optionsListControl',
explicitInput: {
id: availableControlsPanels.HOST_OS_NAME,
fieldName: availableControlsPanels.HOST_OS_NAME,
title: 'Operating System',
},
fieldName: availableControlsPanels.HOST_OS_NAME,
title: 'Operating System',
},
[availableControlsPanels.CLOUD_PROVIDER]: {
order: 1,
width: 'medium',
grow: false,
type: 'optionsListControl',
explicitInput: {
id: availableControlsPanels.CLOUD_PROVIDER,
fieldName: availableControlsPanels.CLOUD_PROVIDER,
title: 'Cloud Provider',
},
fieldName: availableControlsPanels.CLOUD_PROVIDER,
title: 'Cloud Provider',
},
[availableControlsPanels.SERVICE_NAME]: {
order: 2,
width: 'medium',
grow: false,
type: 'optionsListControl',
explicitInput: {
id: availableControlsPanels.SERVICE_NAME,
fieldName: availableControlsPanels.SERVICE_NAME,
title: 'Service Name',
},
fieldName: availableControlsPanels.SERVICE_NAME,
title: 'Service Name',
},
};
@ -108,7 +99,7 @@ const addDataViewIdToControlPanels = (controlPanels: ControlPanels, dataViewId:
...acc,
[key]: {
...controlPanelConfig,
explicitInput: { ...controlPanelConfig.explicitInput, dataViewId },
dataViewId,
},
};
}, {});
@ -116,11 +107,10 @@ const addDataViewIdToControlPanels = (controlPanels: ControlPanels, dataViewId:
const cleanControlPanels = (controlPanels: ControlPanels) => {
return Object.entries(controlPanels).reduce((acc, [key, controlPanelConfig]) => {
const { explicitInput } = controlPanelConfig;
const { dataViewId, ...rest } = explicitInput;
const { dataViewId, ...rest } = controlPanelConfig;
return {
...acc,
[key]: { ...controlPanelConfig, explicitInput: rest },
[key]: rest,
};
}, {});
};
@ -140,21 +130,20 @@ const mergeDefaultPanelsWithUrlConfig = (dataView: DataView, urlPanels: ControlP
);
};
const PanelRT = rt.type({
order: rt.number,
width: rt.union([rt.literal('medium'), rt.literal('small'), rt.literal('large')]),
grow: rt.boolean,
type: rt.string,
explicitInput: rt.intersection([
rt.type({ id: rt.string }),
rt.partial({
dataViewId: rt.string,
fieldName: rt.string,
title: rt.union([rt.string, rt.undefined]),
selectedOptions: rt.array(rt.string),
}),
]),
});
const PanelRT = rt.intersection([
rt.type({
order: rt.number,
type: rt.string,
}),
rt.partial({
width: rt.union([rt.literal('medium'), rt.literal('small'), rt.literal('large')]),
grow: rt.boolean,
dataViewId: rt.string,
fieldName: rt.string,
title: rt.union([rt.string, rt.undefined]),
selectedOptions: rt.array(rt.string),
}),
]);
const ControlPanelRT = rt.record(rt.string, PanelRT);

View file

@ -19,11 +19,8 @@ export const controlPanelConfigs: ControlPanels = {
width: 'medium',
grow: false,
type: 'optionsListControl',
explicitInput: {
id: availableControlsPanels.NAMESPACE,
fieldName: availableControlsPanels.NAMESPACE,
title: 'Namespace',
},
fieldName: availableControlsPanels.NAMESPACE,
title: 'Namespace',
},
};

View file

@ -6,23 +6,22 @@
*/
import * as rt from 'io-ts';
const PanelRT = rt.type({
order: rt.number,
width: rt.union([rt.literal('medium'), rt.literal('small'), rt.literal('large')]),
grow: rt.boolean,
type: rt.string,
explicitInput: rt.intersection([
rt.type({ id: rt.string }),
rt.partial({
dataViewId: rt.string,
exclude: rt.boolean,
existsSelected: rt.boolean,
fieldName: rt.string,
selectedOptions: rt.array(rt.string),
title: rt.union([rt.string, rt.undefined]),
}),
]),
});
const PanelRT = rt.intersection([
rt.type({
order: rt.number,
type: rt.string,
}),
rt.partial({
width: rt.union([rt.literal('medium'), rt.literal('small'), rt.literal('large')]),
grow: rt.boolean,
dataViewId: rt.string,
fieldName: rt.string,
exclude: rt.boolean,
existsSelected: rt.boolean,
title: rt.union([rt.string, rt.undefined]),
selectedOptions: rt.array(rt.string),
}),
]);
export const ControlPanelRT = rt.record(rt.string, PanelRT);

View file

@ -7,10 +7,7 @@
"id": "logsExplorer",
"server": true,
"browser": true,
"configPath": [
"xpack",
"logsExplorer"
],
"configPath": ["xpack", "logsExplorer"],
"requiredPlugins": [
"data",
"dataViews",
@ -20,12 +17,10 @@
"share",
"unifiedSearch",
"unifiedDocViewer",
"discoverShared",
"discoverShared"
],
"optionalPlugins": [],
"requiredBundles": ["controls","embeddable","fleet", "kibanaReact", "kibanaUtils"],
"extraPublicDirs": [
"common",
]
"requiredBundles": ["controls", "fleet", "kibanaReact", "kibanaUtils"],
"extraPublicDirs": ["common"]
}
}

View file

@ -83,12 +83,12 @@ const getPublicControlsStateFromControlPanels = (
const getOptionsListPublicControlStateFromControlPanel = (
optionsListControlPanel: ControlPanels[string]
): OptionsListControl => ({
mode: optionsListControlPanel.explicitInput.exclude ? 'exclude' : 'include',
selection: optionsListControlPanel.explicitInput.existsSelected
mode: optionsListControlPanel.exclude ? 'exclude' : 'include',
selection: optionsListControlPanel.existsSelected
? { type: 'exists' }
: {
type: 'options',
selectedOptions: optionsListControlPanel.explicitInput.selectedOptions ?? [],
selectedOptions: optionsListControlPanel.selectedOptions ?? [],
},
});
@ -121,16 +121,13 @@ const getControlPanelFromOptionsListPublicControlState = (
return {
...defaultControlPanelConfig,
explicitInput: {
...defaultControlPanelConfig.explicitInput,
exclude: publicControlState.mode === 'exclude',
...(publicControlState.selection.type === 'exists'
? {
existsSelected: true,
}
: {
selectedOptions: publicControlState.selection.selectedOptions,
}),
},
exclude: publicControlState.mode === 'exclude',
...(publicControlState.selection.type === 'exists'
? {
existsSelected: true,
}
: {
selectedOptions: publicControlState.selection.selectedOptions,
}),
};
};

View file

@ -22,7 +22,7 @@ const CustomDataSourceFilters = ({
logsExplorerControllerStateService,
data,
}: CustomDataSourceFiltersProps) => {
const { getInitialInput, setControlGroupAPI, query, filters, timeRange } = useControlPanels(
const { getInitialState, setControlGroupAPI, query, filters, timeRange } = useControlPanels(
logsExplorerControllerStateService,
data
);
@ -30,8 +30,8 @@ const CustomDataSourceFilters = ({
return (
<div data-test-subj={DATA_SOURCE_FILTERS_CUSTOMIZATION_ID}>
<ControlGroupRenderer
ref={setControlGroupAPI}
getCreationOptions={getInitialInput}
onApiAvailable={setControlGroupAPI}
getCreationOptions={getInitialState}
query={query as Query}
filters={filters ?? []}
timeRange={timeRange}

View file

@ -5,11 +5,9 @@
* 2.0.
*/
import { ControlGroupInput } from '@kbn/controls-plugin/common';
import { ControlGroupAPI } from '@kbn/controls-plugin/public';
import { ControlGroupRuntimeState, ControlGroupRendererApi } from '@kbn/controls-plugin/public';
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { Query, TimeRange } from '@kbn/es-query';
import { TimeRange } from '@kbn/es-query';
import { useQuerySubscriber } from '@kbn/unified-field-list';
import { useSelector } from '@xstate/react';
import { useCallback } from 'react';
@ -27,31 +25,28 @@ export const useControlPanels = (
return state.context.controlPanels;
});
const getInitialInput = useCallback(
async (initialInput: Partial<ControlGroupInput>) => {
const input: Partial<ControlGroupInput> = {
...initialInput,
viewMode: ViewMode.VIEW,
panels: controlPanels ?? initialInput.panels,
filters: filters ?? [],
query: query as Query,
timeRange: { from: fromDate!, to: toDate! },
const getInitialState = useCallback(
async (initialState: Partial<ControlGroupRuntimeState>) => {
const state: Partial<ControlGroupRuntimeState> = {
...initialState,
initialChildControlState: controlPanels ?? initialState.initialChildControlState,
};
return { initialInput: input };
return { initialState: state };
},
[controlPanels, filters, fromDate, query, toDate]
[controlPanels]
);
const setControlGroupAPI = useCallback(
(controlGroupAPI: ControlGroupAPI) => {
logsExplorerControllerStateService.send({
type: 'INITIALIZE_CONTROL_GROUP_API',
controlGroupAPI,
});
(controlGroupAPI: ControlGroupRendererApi | undefined) => {
if (controlGroupAPI)
logsExplorerControllerStateService.send({
type: 'INITIALIZE_CONTROL_GROUP_API',
controlGroupAPI,
});
},
[logsExplorerControllerStateService]
);
return { getInitialInput, setControlGroupAPI, query, filters, timeRange };
return { getInitialState, setControlGroupAPI, query, filters, timeRange };
};

View file

@ -7,7 +7,6 @@
import type { DataView } from '@kbn/data-views-plugin/public';
import { DiscoverStateContainer } from '@kbn/discover-plugin/public';
import deepEqual from 'fast-deep-equal';
import { mapValues, pick } from 'lodash';
import { InvokeCreator } from 'xstate';
import {
@ -35,22 +34,13 @@ export const subscribeControlGroup =
if (!('discoverStateContainer' in context)) return;
const { discoverStateContainer } = context;
const filtersSubscription = context.controlGroupAPI.onFiltersPublished$.subscribe(
(newFilters) => {
discoverStateContainer.internalState.transitions.setCustomFilters(newFilters);
discoverStateContainer.actions.fetchData();
}
);
const inputSubscription = context.controlGroupAPI.getInput$().subscribe(({ panels }) => {
if (!deepEqual(panels, context.controlPanels)) {
send({ type: 'UPDATE_CONTROL_PANELS', controlPanels: panels });
}
const filtersSubscription = context.controlGroupAPI.filters$.subscribe((newFilters = []) => {
discoverStateContainer.internalState.transitions.setCustomFilters(newFilters);
discoverStateContainer.actions.fetchData();
});
return () => {
filtersSubscription.unsubscribe();
inputSubscription.unsubscribe();
};
};
@ -71,7 +61,7 @@ export const updateControlPanels =
newControlPanels!
);
context.controlGroupAPI.updateInput({ panels: controlPanelsWithId });
context.controlGroupAPI.updateInput({ initialChildControlState: controlPanelsWithId });
return controlPanelsWithId;
};
@ -114,7 +104,7 @@ export const getVisibleControlPanelsConfig = (dataView?: DataView) => {
const addDataViewIdToControlPanels = (controlPanels: ControlPanels, dataViewId: string = '') => {
return mapValues(controlPanels, (controlPanelConfig) => ({
...controlPanelConfig,
explicitInput: { ...controlPanelConfig.explicitInput, dataViewId },
dataViewId,
}));
};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { ControlGroupAPI } from '@kbn/controls-plugin/public';
import { ControlGroupRendererApi } from '@kbn/controls-plugin/public';
import { QueryState, RefreshInterval, TimeRange } from '@kbn/data-plugin/common';
import type {
DiscoverAppState,
@ -30,7 +30,7 @@ export interface WithAllSelection {
allSelection: AllDatasetSelection;
}
export interface WithControlPanelGroupAPI {
controlGroupAPI: ControlGroupAPI;
controlGroupAPI: ControlGroupRendererApi;
}
export interface WithControlPanels {
@ -198,7 +198,7 @@ export type LogsExplorerControllerEvent =
}
| {
type: 'INITIALIZE_CONTROL_GROUP_API';
controlGroupAPI: ControlGroupAPI | undefined;
controlGroupAPI: ControlGroupRendererApi | undefined;
}
| {
type: 'UPDATE_CONTROL_PANELS';
@ -222,5 +222,5 @@ export type LogsExplorerControllerEvent =
}
| DoneInvokeEvent<DataSourceSelection>
| DoneInvokeEvent<ControlPanels>
| DoneInvokeEvent<ControlGroupAPI>
| DoneInvokeEvent<ControlGroupRendererApi>
| DoneInvokeEvent<Error>;

View file

@ -24,7 +24,6 @@
"@kbn/deeplinks-observability",
"@kbn/discover-plugin",
"@kbn/discover-utils",
"@kbn/embeddable-plugin",
"@kbn/es-query",
"@kbn/field-formats-plugin",
"@kbn/fleet-plugin",

View file

@ -6,9 +6,9 @@
*/
import { i18n } from '@kbn/i18n';
import { skip } from 'rxjs';
import React, { useEffect, useState } from 'react';
import { AwaitingControlGroupAPI, ControlGroupRenderer } from '@kbn/controls-plugin/public';
import { ViewMode } from '@kbn/embeddable-plugin/common';
import { ControlGroupRenderer, ControlGroupRendererApi } from '@kbn/controls-plugin/public';
import { DataView } from '@kbn/data-views-plugin/common';
import styled from 'styled-components';
import { Filter } from '@kbn/es-query';
@ -27,13 +27,13 @@ export function QuickFilters({
initialState: { tagsFilter, statusFilter },
onStateChange,
}: Props) {
const [controlGroupAPI, setControlGroupAPI] = useState<AwaitingControlGroupAPI>();
const [controlGroupAPI, setControlGroupAPI] = useState<ControlGroupRendererApi | undefined>();
useEffect(() => {
if (!controlGroupAPI) {
return;
}
const subscription = controlGroupAPI.onFiltersPublished$.subscribe((newFilters) => {
const subscription = controlGroupAPI.filters$.pipe(skip(1)).subscribe((newFilters = []) => {
if (newFilters.length === 0) {
onStateChange({ tagsFilter: undefined, statusFilter: undefined });
} else {
@ -55,39 +55,42 @@ export function QuickFilters({
return (
<Container>
<ControlGroupRenderer
getCreationOptions={async (initialInput, builder) => {
await builder.addOptionsListControl(initialInput, {
dataViewId: dataView.id!,
fieldName: 'status',
width: 'small',
grow: true,
title: STATUS_LABEL,
controlId: 'slo-status-filter',
exclude: statusFilter?.meta?.negate,
selectedOptions: getSelectedOptions(statusFilter),
existsSelected: Boolean(statusFilter?.query?.exists?.field === 'status'),
placeholder: ALL_LABEL,
});
await builder.addOptionsListControl(initialInput, {
dataViewId: dataView.id!,
title: TAGS_LABEL,
fieldName: 'slo.tags',
width: 'small',
grow: false,
controlId: 'slo-tags-filter',
selectedOptions: getSelectedOptions(tagsFilter),
exclude: statusFilter?.meta?.negate,
existsSelected: Boolean(tagsFilter?.query?.exists?.field === 'slo.tags'),
placeholder: ALL_LABEL,
});
return {
initialInput: {
...initialInput,
viewMode: ViewMode.VIEW,
onApiAvailable={setControlGroupAPI}
getCreationOptions={async (initialState, builder) => {
builder.addOptionsListControl(
initialState,
{
dataViewId: dataView.id!,
fieldName: 'status',
width: 'small',
grow: true,
title: STATUS_LABEL,
exclude: statusFilter?.meta?.negate,
selectedOptions: getSelectedOptions(statusFilter),
existsSelected: Boolean(statusFilter?.query?.exists?.field === 'status'),
placeholder: ALL_LABEL,
},
'slo-status-filter'
);
builder.addOptionsListControl(
initialState,
{
dataViewId: dataView.id!,
title: TAGS_LABEL,
fieldName: 'slo.tags',
width: 'small',
grow: false,
selectedOptions: getSelectedOptions(tagsFilter),
exclude: statusFilter?.meta?.negate,
existsSelected: Boolean(tagsFilter?.query?.exists?.field === 'slo.tags'),
placeholder: ALL_LABEL,
},
'slo-tags-filter'
);
return {
initialState,
};
}}
ref={setControlGroupAPI}
timeRange={{ from: 'now-24h', to: 'now' }}
/>
</Container>