[embeddable rebuild] integrate react control group embeddable into dashboard container - reloaded (#192221)

PR replaces legacy embeddable control group implementation with react
control group implementation in DashboardContainer.

#### background
Work originally done in https://github.com/elastic/kibana/pull/190273.
https://github.com/elastic/kibana/pull/190273 was reverted by
https://github.com/elastic/kibana/pull/191993 because of dashboard
performance degradation. It was determined that degradation was because
new react embeddable controls fixed a regression where dashboard panels
are loading before control filters are created. This regression was
introduced by https://github.com/elastic/kibana/pull/187509.

The work around is that this PR keeps the currently broken behavior in
main and loads panels before control filters are ready. The thinking is
that the migration would replace like for like and not introduce any
performance changes. Then, at a later time, the regression could be
resolved.

#### reviewing
These are the same changes from
https://github.com/elastic/kibana/pull/190273 minus some work to
introduce a current regression in main. A full re-review is not needed.

---------

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: Hannah Mudge <hannah.wright@elastic.co>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Nathan Reese 2024-09-10 08:50:17 -06:00 committed by GitHub
parent 96739aad5c
commit 9dfad5b12a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
64 changed files with 1190 additions and 1074 deletions

View file

@ -91,12 +91,12 @@ const initialSerializedControlGroupState = {
} as object,
references: [
{
name: `controlGroup_${rangeSliderControlId}:${RANGE_SLIDER_CONTROL}DataView`,
name: `controlGroup_${rangeSliderControlId}:rangeSliderDataView`,
type: 'index-pattern',
id: WEB_LOGS_DATA_VIEW_ID,
},
{
name: `controlGroup_${optionsListId}:${OPTIONS_LIST_CONTROL}DataView`,
name: `controlGroup_${optionsListId}:optionsListDataView`,
type: 'index-pattern',
id: WEB_LOGS_DATA_VIEW_ID,
},

View file

@ -12,8 +12,7 @@ import React, { useEffect, useState } from 'react';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
import { EuiPanel, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui';
import { controlGroupInputBuilder } from '@kbn/controls-plugin/public';
import { getDefaultControlGroupInput } from '@kbn/controls-plugin/common';
import { controlGroupStateBuilder } from '@kbn/controls-plugin/public';
import {
AwaitingDashboardAPI,
DashboardRenderer,
@ -64,16 +63,15 @@ export const DashboardWithControlsExample = ({ dataView }: { dataView: DataView
<EuiPanel hasBorder={true}>
<DashboardRenderer
getCreationOptions={async (): Promise<DashboardCreationOptions> => {
const builder = controlGroupInputBuilder;
const controlGroupInput = getDefaultControlGroupInput();
await builder.addDataControlFromField(controlGroupInput, {
const controlGroupState = {};
await controlGroupStateBuilder.addDataControlFromField(controlGroupState, {
dataViewId: dataView.id ?? '',
title: 'Destintion country',
fieldName: 'geo.dest',
width: 'medium',
grow: false,
});
await builder.addDataControlFromField(controlGroupInput, {
await controlGroupStateBuilder.addDataControlFromField(controlGroupState, {
dataViewId: dataView.id ?? '',
fieldName: 'bytes',
width: 'medium',
@ -86,7 +84,7 @@ export const DashboardWithControlsExample = ({ dataView }: { dataView: DataView
getInitialInput: () => ({
timeRange: { from: 'now-30d', to: 'now' },
viewMode: ViewMode.VIEW,
controlGroupInput,
controlGroupState,
}),
};
}}

View file

@ -10,7 +10,6 @@
import deepEqual from 'fast-deep-equal';
import { SerializableRecord } from '@kbn/utility-types';
import { v4 } from 'uuid';
import { pick, omit, xor } from 'lodash';
import {
@ -24,7 +23,6 @@ import {
} from './control_group_panel_diff_system';
import { ControlGroupInput } from '..';
import {
ControlsPanels,
PersistableControlGroupInput,
persistableControlGroupInputKeys,
RawControlGroupAttributes,
@ -104,32 +102,6 @@ const getPanelsAreEqual = (
return true;
};
export const controlGroupInputToRawControlGroupAttributes = (
controlGroupInput: Omit<ControlGroupInput, 'id'>
): RawControlGroupAttributes => {
return {
controlStyle: controlGroupInput.controlStyle,
chainingSystem: controlGroupInput.chainingSystem,
showApplySelections: controlGroupInput.showApplySelections,
panelsJSON: JSON.stringify(controlGroupInput.panels),
ignoreParentSettingsJSON: JSON.stringify(controlGroupInput.ignoreParentSettings),
};
};
export const generateNewControlIds = (controlGroupInput?: PersistableControlGroupInput) => {
if (!controlGroupInput?.panels) return;
const newPanelsMap: ControlsPanels = {};
for (const panel of Object.values(controlGroupInput.panels)) {
const newId = v4();
newPanelsMap[newId] = {
...panel,
explicitInput: { ...panel.explicitInput, id: newId },
};
}
return { ...controlGroupInput, panels: newPanelsMap };
};
export const rawControlGroupAttributesToControlGroupInput = (
rawControlGroupAttributes: RawControlGroupAttributes
): PersistableControlGroupInput | undefined => {

View file

@ -23,14 +23,12 @@ export {
persistableControlGroupInputKeys,
} from './control_group/types';
export {
controlGroupInputToRawControlGroupAttributes,
rawControlGroupAttributesToControlGroupInput,
rawControlGroupAttributesToSerializable,
serializableToRawControlGroupAttributes,
getDefaultControlGroupPersistableInput,
persistableControlGroupInputIsEqual,
getDefaultControlGroupInput,
generateNewControlIds,
} from './control_group/control_group_persistence';
export {

View file

@ -122,6 +122,7 @@ export function ControlGroup({
paddingSize="none"
color={draggingId ? 'success' : 'transparent'}
className="controlsWrapper"
data-test-subj="controls-group-wrapper"
>
<EuiFlexGroup
gutterSize="s"

View file

@ -25,11 +25,7 @@ import { apiPublishesAsyncFilters } from '../controls/data_controls/publishes_as
export type ControlGroupComparatorState = Pick<
ControlGroupRuntimeState,
| 'autoApplySelections'
| 'chainingSystem'
| 'ignoreParentSettings'
| 'initialChildControlState'
| 'labelPosition'
'autoApplySelections' | 'chainingSystem' | 'ignoreParentSettings' | 'labelPosition'
> & {
controlsInOrder: ControlsInOrder;
};
@ -39,6 +35,7 @@ export function initializeControlGroupUnsavedChanges(
children$: PresentationContainer['children$'],
comparators: StateComparators<ControlGroupComparatorState>,
snapshotControlsRuntimeState: () => ControlPanelsState,
resetControlsUnsavedChanges: () => void,
parentApi: unknown,
lastSavedRuntimeState: ControlGroupRuntimeState
) {
@ -48,7 +45,6 @@ export function initializeControlGroupUnsavedChanges(
chainingSystem: lastSavedRuntimeState.chainingSystem,
controlsInOrder: getControlsInOrder(lastSavedRuntimeState.initialChildControlState),
ignoreParentSettings: lastSavedRuntimeState.ignoreParentSettings,
initialChildControlState: lastSavedRuntimeState.initialChildControlState,
labelPosition: lastSavedRuntimeState.labelPosition,
},
parentApi,
@ -73,6 +69,7 @@ export function initializeControlGroupUnsavedChanges(
),
asyncResetUnsavedChanges: async () => {
controlGroupUnsavedChanges.api.resetUnsavedChanges();
resetControlsUnsavedChanges();
const filtersReadyPromises: Array<Promise<void>> = [];
Object.values(children$.value).forEach((controlApi) => {

View file

@ -35,12 +35,19 @@ import { chaining$, controlFetch$, controlGroupFetch$ } from './control_fetch';
import { initControlsManager } from './init_controls_manager';
import { openEditControlGroupFlyout } from './open_edit_control_group_flyout';
import { deserializeControlGroup } from './serialization_utils';
import { ControlGroupApi, ControlGroupRuntimeState, ControlGroupSerializedState } from './types';
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';
const DEFAULT_CHAINING_SYSTEM = 'HIERARCHICAL';
export const getControlGroupEmbeddableFactory = (services: {
core: CoreStart;
dataViews: DataViewsPublicPluginStart;
@ -61,7 +68,6 @@ export const getControlGroupEmbeddableFactory = (services: {
lastSavedRuntimeState
) => {
const {
initialChildControlState,
labelPosition: initialLabelPosition,
chainingSystem,
autoApplySelections,
@ -69,19 +75,22 @@ export const getControlGroupEmbeddableFactory = (services: {
} = initialRuntimeState;
const autoApplySelections$ = new BehaviorSubject<boolean>(autoApplySelections);
const parentDataViewId = apiPublishesDataViews(parentApi)
? parentApi.dataViews.value?.[0]?.id
: undefined;
const defaultDataViewId = await services.dataViews.getDefaultId();
const lastSavedControlsState$ = new BehaviorSubject<ControlPanelsState>(
lastSavedRuntimeState.initialChildControlState
);
const controlsManager = initControlsManager(
initialChildControlState,
parentDataViewId ?? (await services.dataViews.getDefaultId())
initialRuntimeState.initialChildControlState,
lastSavedControlsState$
);
const selectionsManager = initSelectionsManager({
...controlsManager.api,
autoApplySelections$,
});
const dataViews = new BehaviorSubject<DataView[] | undefined>(undefined);
const chainingSystem$ = new BehaviorSubject<ControlGroupChainingSystem>(chainingSystem);
const chainingSystem$ = new BehaviorSubject<ControlGroupChainingSystem>(
chainingSystem ?? DEFAULT_CHAINING_SYSTEM
);
const ignoreParentSettings$ = new BehaviorSubject<ParentIgnoreSettings | undefined>(
ignoreParentSettings
);
@ -105,6 +114,7 @@ export const getControlGroupEmbeddableFactory = (services: {
chainingSystem: [
chainingSystem$,
(next: ControlGroupChainingSystem) => chainingSystem$.next(next),
(a, b) => (a ?? DEFAULT_CHAINING_SYSTEM) === (b ?? DEFAULT_CHAINING_SYSTEM),
],
ignoreParentSettings: [
ignoreParentSettings$,
@ -114,6 +124,7 @@ export const getControlGroupEmbeddableFactory = (services: {
labelPosition: [labelPosition$, (next: ControlStyle) => labelPosition$.next(next)],
},
controlsManager.snapshotControlsRuntimeState,
controlsManager.resetControlsUnsavedChanges,
parentApi,
lastSavedRuntimeState
);
@ -160,20 +171,28 @@ export const getControlGroupEmbeddableFactory = (services: {
i18n.translate('controls.controlGroup.displayName', {
defaultMessage: 'Controls',
}),
openAddDataControlFlyout: (settings) => {
const { controlInputTransform } = settings ?? {
controlInputTransform: (state) => state,
};
openAddDataControlFlyout: (options) => {
const parentDataViewId = apiPublishesDataViews(parentApi)
? parentApi.dataViews.value?.[0]?.id
: undefined;
const newControlState = controlsManager.getNewControlState();
openDataControlEditor({
initialState: controlsManager.getNewControlState(),
initialState: {
...newControlState,
dataViewId:
newControlState.dataViewId ?? parentDataViewId ?? defaultDataViewId ?? undefined,
},
onSave: ({ type: controlType, state: initialState }) => {
controlsManager.api.addNewPanel({
panelType: controlType,
initialState: controlInputTransform!(
initialState as Partial<ControlGroupSerializedState>,
controlType
),
initialState: options?.controlInputTransform
? options.controlInputTransform(
initialState as Partial<ControlGroupSerializedState>,
controlType
)
: initialState,
});
options?.onSave?.();
},
controlGroupApi: api,
services,
@ -208,21 +227,19 @@ export const getControlGroupEmbeddableFactory = (services: {
dataViews.next(newDataViews)
);
/** Fetch the allowExpensiveQuries setting for the children to use if necessary */
try {
const { allowExpensiveQueries } = await services.core.http.get<{
allowExpensiveQueries: boolean;
// TODO: Rename this route as part of https://github.com/elastic/kibana/issues/174961
}>('/internal/controls/optionsList/getExpensiveQueriesSetting', {
version: '1',
});
if (!allowExpensiveQueries) {
// only set if this returns false, since it defaults to true
allowExpensiveQueries$.next(allowExpensiveQueries);
}
} catch {
// do nothing - default to true on error (which it was initialized to)
}
const saveNotificationSubscription = apiHasSaveNotification(parentApi)
? parentApi.saveNotification$.subscribe(() => {
lastSavedControlsState$.next(controlsManager.snapshotControlsRuntimeState());
if (
typeof autoApplySelections$.value === 'boolean' &&
!autoApplySelections$.value &&
selectionsManager.hasUnappliedSelections$.value
) {
selectionsManager.applySelections();
}
})
: undefined;
return {
api,
@ -233,9 +250,29 @@ export const getControlGroupEmbeddableFactory = (services: {
);
useEffect(() => {
/** Fetch the allowExpensiveQuries setting for the children to use if necessary */
const fetchAllowExpensiveQueries = async () => {
try {
const { allowExpensiveQueries } = await services.core.http.get<{
allowExpensiveQueries: boolean;
// TODO: Rename this route as part of https://github.com/elastic/kibana/issues/174961
}>('/internal/controls/optionsList/getExpensiveQueriesSetting', {
version: '1',
});
if (!allowExpensiveQueries) {
// only set if this returns false, since it defaults to true
allowExpensiveQueries$.next(allowExpensiveQueries);
}
} catch {
// do nothing - default to true on error (which it was initialized to)
}
};
fetchAllowExpensiveQueries(); // no need to await - don't want to block anything waiting for this
return () => {
selectionsManager.cleanup();
childrenDataViewsSubscription.unsubscribe();
saveNotificationSubscription?.unsubscribe();
};
}, []);

View file

@ -7,27 +7,26 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { BehaviorSubject } from 'rxjs';
import { DefaultDataControlState } from '../controls/data_controls/types';
import { DefaultControlApi } from '../controls/types';
import { initControlsManager, getLastUsedDataViewId } from './init_controls_manager';
import { ControlPanelState } from './types';
import { ControlPanelState, ControlPanelsState } from './types';
jest.mock('uuid', () => ({
v4: jest.fn().mockReturnValue('delta'),
}));
const DEFAULT_DATA_VIEW_ID = 'myDataView';
describe('PresentationContainer api', () => {
const intialControlsState = {
alpha: { type: 'testControl', order: 0 },
bravo: { type: 'testControl', order: 1 },
charlie: { type: 'testControl', order: 2 },
};
const lastSavedControlsState$ = new BehaviorSubject<ControlPanelsState>(intialControlsState);
test('addNewPanel should add control at end of controls', async () => {
const controlsManager = initControlsManager(
{
alpha: { type: 'testControl', order: 0 },
bravo: { type: 'testControl', order: 1 },
charlie: { type: 'testControl', order: 2 },
},
DEFAULT_DATA_VIEW_ID
);
const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$);
const addNewPanelPromise = controlsManager.api.addNewPanel({
panelType: 'testControl',
initialState: {},
@ -43,14 +42,7 @@ describe('PresentationContainer api', () => {
});
test('removePanel should remove control', () => {
const controlsManager = initControlsManager(
{
alpha: { type: 'testControl', order: 0 },
bravo: { type: 'testControl', order: 1 },
charlie: { type: 'testControl', order: 2 },
},
DEFAULT_DATA_VIEW_ID
);
const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$);
controlsManager.api.removePanel('bravo');
expect(controlsManager.controlsInOrder$.value.map((element) => element.id)).toEqual([
'alpha',
@ -59,14 +51,7 @@ describe('PresentationContainer api', () => {
});
test('replacePanel should replace control', async () => {
const controlsManager = initControlsManager(
{
alpha: { type: 'testControl', order: 0 },
bravo: { type: 'testControl', order: 1 },
charlie: { type: 'testControl', order: 2 },
},
DEFAULT_DATA_VIEW_ID
);
const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$);
const replacePanelPromise = controlsManager.api.replacePanel('bravo', {
panelType: 'testControl',
initialState: {},
@ -82,13 +67,7 @@ describe('PresentationContainer api', () => {
describe('untilInitialized', () => {
test('should not resolve until all controls are initialized', async () => {
const controlsManager = initControlsManager(
{
alpha: { type: 'testControl', order: 0 },
bravo: { type: 'testControl', order: 1 },
},
DEFAULT_DATA_VIEW_ID
);
const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$);
let isDone = false;
controlsManager.api.untilInitialized().then(() => {
isDone = true;
@ -102,19 +81,18 @@ describe('PresentationContainer api', () => {
controlsManager.setControlApi('bravo', {} as unknown as DefaultControlApi);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(isDone).toBe(false);
controlsManager.setControlApi('charlie', {} as unknown as DefaultControlApi);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(isDone).toBe(true);
});
test('should resolve when all control already initialized ', async () => {
const controlsManager = initControlsManager(
{
alpha: { type: 'testControl', order: 0 },
bravo: { type: 'testControl', order: 1 },
},
DEFAULT_DATA_VIEW_ID
);
const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$);
controlsManager.setControlApi('alpha', {} as unknown as DefaultControlApi);
controlsManager.setControlApi('bravo', {} as unknown as DefaultControlApi);
controlsManager.setControlApi('charlie', {} as unknown as DefaultControlApi);
let isDone = false;
controlsManager.api.untilInitialized().then(() => {
@ -128,14 +106,14 @@ describe('PresentationContainer api', () => {
});
describe('snapshotControlsRuntimeState', () => {
const intialControlsState = {
alpha: { type: 'testControl', order: 1 },
bravo: { type: 'testControl', order: 0 },
};
const lastSavedControlsState$ = new BehaviorSubject<ControlPanelsState>(intialControlsState);
test('should snapshot runtime state for all controls', async () => {
const controlsManager = initControlsManager(
{
alpha: { type: 'testControl', order: 1 },
bravo: { type: 'testControl', order: 0 },
},
DEFAULT_DATA_VIEW_ID
);
const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$);
controlsManager.setControlApi('alpha', {
snapshotRuntimeState: () => {
return { key1: 'alpha value' };
@ -191,28 +169,120 @@ describe('getLastUsedDataViewId', () => {
});
});
describe('resetControlsUnsavedChanges', () => {
test(`should remove previous sessions's unsaved changes on reset`, () => {
// last session's unsaved changes added 1 control
const intialControlsState = {
alpha: { type: 'testControl', order: 0 },
};
// last saved state is empty control group
const lastSavedControlsState$ = new BehaviorSubject<ControlPanelsState>({});
const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$);
controlsManager.setControlApi('alpha', {} as unknown as DefaultControlApi);
expect(controlsManager.controlsInOrder$.value).toEqual([
{
id: 'alpha',
type: 'testControl',
},
]);
controlsManager.resetControlsUnsavedChanges();
expect(controlsManager.controlsInOrder$.value).toEqual([]);
});
test('should restore deleted control on reset', () => {
const intialControlsState = {
alpha: { type: 'testControl', order: 0 },
};
const lastSavedControlsState$ = new BehaviorSubject<ControlPanelsState>(intialControlsState);
const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$);
controlsManager.setControlApi('alpha', {} as unknown as DefaultControlApi);
// delete control
controlsManager.api.removePanel('alpha');
// deleted control should exist on reset
controlsManager.resetControlsUnsavedChanges();
expect(controlsManager.controlsInOrder$.value).toEqual([
{
id: 'alpha',
type: 'testControl',
},
]);
});
test('should restore controls to last saved state', () => {
const intialControlsState = {};
const lastSavedControlsState$ = new BehaviorSubject<ControlPanelsState>(intialControlsState);
const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$);
// add control
controlsManager.api.addNewPanel({ panelType: 'testControl' });
controlsManager.setControlApi('delta', {
snapshotRuntimeState: () => {
return {};
},
} as unknown as DefaultControlApi);
// simulate save
lastSavedControlsState$.next(controlsManager.snapshotControlsRuntimeState());
// saved control should exist on reset
controlsManager.resetControlsUnsavedChanges();
expect(controlsManager.controlsInOrder$.value).toEqual([
{
id: 'delta',
type: 'testControl',
},
]);
});
// Test edge case where adding a panel and resetting left orphaned control in children$
test('should remove orphaned children on reset', () => {
// baseline last saved state contains a single control
const intialControlsState = {
alpha: { type: 'testControl', order: 0 },
};
const lastSavedControlsState$ = new BehaviorSubject<ControlPanelsState>(intialControlsState);
const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$);
controlsManager.setControlApi('alpha', {} as unknown as DefaultControlApi);
// add another control
controlsManager.api.addNewPanel({ panelType: 'testControl' });
controlsManager.setControlApi('delta', {} as unknown as DefaultControlApi);
expect(Object.keys(controlsManager.api.children$.value).length).toBe(2);
// reset to lastSavedControlsState
controlsManager.resetControlsUnsavedChanges();
// children$ should no longer contain control removed by resetting back to original control baseline
expect(Object.keys(controlsManager.api.children$.value).length).toBe(1);
});
});
describe('getNewControlState', () => {
test('should contain defaults when there are no existing controls', () => {
const controlsManager = initControlsManager({}, DEFAULT_DATA_VIEW_ID);
const controlsManager = initControlsManager({}, new BehaviorSubject<ControlPanelsState>({}));
expect(controlsManager.getNewControlState()).toEqual({
grow: true,
width: 'medium',
dataViewId: DEFAULT_DATA_VIEW_ID,
dataViewId: undefined,
});
});
test('should start with defaults if there are existing controls', () => {
const intialControlsState = {
alpha: {
type: 'testControl',
order: 1,
dataViewId: 'myOtherDataViewId',
width: 'small',
grow: false,
} as ControlPanelState & Pick<DefaultDataControlState, 'dataViewId'>,
};
const controlsManager = initControlsManager(
{
alpha: {
type: 'testControl',
order: 1,
dataViewId: 'myOtherDataViewId',
width: 'small',
grow: false,
} as ControlPanelState & Pick<DefaultDataControlState, 'dataViewId'>,
},
DEFAULT_DATA_VIEW_ID
intialControlsState,
new BehaviorSubject<ControlPanelsState>(intialControlsState)
);
expect(controlsManager.getNewControlState()).toEqual({
grow: true,
@ -222,7 +292,7 @@ describe('getNewControlState', () => {
});
test('should contain values of last added control', () => {
const controlsManager = initControlsManager({}, DEFAULT_DATA_VIEW_ID);
const controlsManager = initControlsManager({}, new BehaviorSubject<ControlPanelsState>({}));
controlsManager.api.addNewPanel({
panelType: 'testControl',
initialState: {

View file

@ -39,22 +39,25 @@ export function getControlsInOrder(initialControlPanelsState: ControlPanelsState
}
export function initControlsManager(
initialControlPanelsState: ControlPanelsState,
defaultDataViewId: string | null
/**
* Composed from last saved controls state and previous sessions's unsaved changes to controls state
*/
initialControlsState: ControlPanelsState,
/**
* Observable that publishes last saved controls state only
*/
lastSavedControlsState$: PublishingSubject<ControlPanelsState>
) {
const lastSavedControlsPanelState$ = new BehaviorSubject(initialControlPanelsState);
const initialControlIds = Object.keys(initialControlPanelsState);
const initialControlIds = Object.keys(initialControlsState);
const children$ = new BehaviorSubject<{ [key: string]: DefaultControlApi }>({});
let controlsPanelState: { [panelId: string]: DefaultControlState } = {
...initialControlPanelsState,
let currentControlsState: { [panelId: string]: DefaultControlState } = {
...initialControlsState,
};
const controlsInOrder$ = new BehaviorSubject<ControlsInOrder>(
getControlsInOrder(initialControlPanelsState)
getControlsInOrder(initialControlsState)
);
const lastUsedDataViewId$ = new BehaviorSubject<string | undefined>(
getLastUsedDataViewId(controlsInOrder$.value, initialControlPanelsState) ??
defaultDataViewId ??
undefined
getLastUsedDataViewId(controlsInOrder$.value, initialControlsState)
);
const lastUsedWidth$ = new BehaviorSubject<ControlWidth>(DEFAULT_CONTROL_WIDTH);
const lastUsedGrow$ = new BehaviorSubject<boolean>(DEFAULT_CONTROL_GROW);
@ -109,12 +112,12 @@ export function initControlsManager(
type: panelType,
});
controlsInOrder$.next(nextControlsInOrder);
controlsPanelState[id] = initialState ?? {};
currentControlsState[id] = initialState ?? {};
return await untilControlLoaded(id);
}
function removePanel(panelId: string) {
delete controlsPanelState[panelId];
delete currentControlsState[panelId];
controlsInOrder$.next(controlsInOrder$.value.filter(({ id }) => id !== panelId));
children$.next(omit(children$.value, panelId));
}
@ -162,7 +165,7 @@ export function initControlsManager(
type: controlApi.type,
width,
/** Re-add the `explicitInput` layer on serialize so control group saved object retains shape */
explicitInput: rest,
explicitInput: { id, ...rest },
};
});
@ -185,9 +188,30 @@ export function initControlsManager(
});
return controlsRuntimeState;
},
resetControlsUnsavedChanges: () => {
currentControlsState = {
...lastSavedControlsState$.value,
};
const nextControlsInOrder = getControlsInOrder(currentControlsState as ControlPanelsState);
controlsInOrder$.next(nextControlsInOrder);
const nextControlIds = nextControlsInOrder.map(({ id }) => id);
const children = { ...children$.value };
let modifiedChildren = false;
Object.keys(children).forEach((controlId) => {
if (!nextControlIds.includes(controlId)) {
// remove children that no longer exist after reset
delete children[controlId];
modifiedChildren = true;
}
});
if (modifiedChildren) {
children$.next(children);
}
},
api: {
getSerializedStateForChild: (childId: string) => {
const controlPanelState = controlsPanelState[childId];
const controlPanelState = currentControlsState[childId];
return controlPanelState ? { rawState: controlPanelState } : undefined;
},
children$: children$ as PublishingSubject<{
@ -231,26 +255,10 @@ export function initControlsManager(
comparators: {
controlsInOrder: [
controlsInOrder$,
(next: ControlsInOrder) => controlsInOrder$.next(next),
(next: ControlsInOrder) => {}, // setter does nothing, controlsInOrder$ reset by resetControlsRuntimeState
fastIsEqual,
],
// Control state differences tracked by controlApi comparators
// Control ordering differences tracked by controlsInOrder comparator
// initialChildControlState comparatator exists to reset controls manager to last saved state
initialChildControlState: [
lastSavedControlsPanelState$,
(lastSavedControlPanelsState: ControlPanelsState) => {
lastSavedControlsPanelState$.next(lastSavedControlPanelsState);
controlsPanelState = {
...lastSavedControlPanelsState,
};
controlsInOrder$.next(getControlsInOrder(lastSavedControlPanelsState));
},
() => true,
],
} as StateComparators<
Pick<ControlGroupComparatorState, 'controlsInOrder' | 'initialChildControlState'>
>,
} as StateComparators<Pick<ControlGroupComparatorState, 'controlsInOrder'>>,
};
}

View file

@ -73,7 +73,7 @@ export const openEditControlGroupFlyout = (
Object.keys(controlGroupApi.children$.getValue()).forEach((childId) => {
controlGroupApi.removePanel(childId);
});
ref.close();
closeOverlay(ref);
});
};

View file

@ -10,6 +10,7 @@
import { SerializedPanelState } from '@kbn/presentation-containers';
import { omit } from 'lodash';
import { ControlGroupRuntimeState, ControlGroupSerializedState } from './types';
import { parseReferenceName } from '../controls/data_controls/reference_name_utils';
export const deserializeControlGroup = (
state: SerializedPanelState<ControlGroupSerializedState>
@ -21,9 +22,9 @@ export const deserializeControlGroup = (
const references = state.references ?? [];
references.forEach((reference) => {
const referenceName = reference.name;
const panelId = referenceName.substring('controlGroup_'.length, referenceName.lastIndexOf(':'));
if (panels[panelId]) {
panels[panelId].dataViewId = reference.id;
const { controlId } = parseReferenceName(referenceName);
if (panels[controlId]) {
panels[controlId].dataViewId = reference.id;
}
});

View file

@ -66,8 +66,9 @@ export type ControlGroupApi = PresentationContainer &
ignoreParentSettings$: PublishingSubject<ParentIgnoreSettings | undefined>;
allowExpensiveQueries$: PublishingSubject<boolean>;
untilInitialized: () => Promise<void>;
openAddDataControlFlyout: (settings?: {
openAddDataControlFlyout: (options?: {
controlInputTransform?: ControlInputTransform;
onSave?: () => void;
}) => void;
labelPosition: PublishingSubject<ControlStyle>;
};

View file

@ -52,6 +52,7 @@ describe('initializeDataControl', () => {
dataControl = initializeDataControl(
'myControlId',
'myControlType',
'referenceNameSuffix',
dataControlState,
editorStateManager,
controlGroupApi,
@ -83,6 +84,7 @@ describe('initializeDataControl', () => {
dataControl = initializeDataControl(
'myControlId',
'myControlType',
'referenceNameSuffix',
{
...dataControlState,
dataViewId: 'notGonnaFindMeDataViewId',
@ -121,6 +123,7 @@ describe('initializeDataControl', () => {
dataControl = initializeDataControl(
'myControlId',
'myControlType',
'referenceNameSuffix',
{
...dataControlState,
fieldName: 'notGonnaFindMeFieldName',

View file

@ -27,10 +27,12 @@ import { initializeDefaultControlApi } from '../initialize_default_control_api';
import { ControlApiInitialization, ControlStateManager, DefaultControlState } from '../types';
import { openDataControlEditor } from './open_data_control_editor';
import { DataControlApi, DataControlFieldFormatter, DefaultDataControlState } from './types';
import { getReferenceName } from './reference_name_utils';
export const initializeDataControl = <EditorState extends object = {}>(
controlId: string,
controlType: string,
referenceNameSuffix: string,
state: DefaultDataControlState,
/**
* `This state manager` should only include the state that the data control editor is
@ -243,7 +245,7 @@ export const initializeDataControl = <EditorState extends object = {}>(
},
references: [
{
name: `controlGroup_${controlId}:${controlType}DataView`,
name: getReferenceName(controlId, referenceNameSuffix),
type: DATA_VIEW_SAVED_OBJECT_TYPE,
id: dataViewId.getValue(),
},

View file

@ -9,6 +9,7 @@
import React, { useEffect } from 'react';
import { BehaviorSubject, combineLatest, debounceTime, filter, skip } from 'rxjs';
import fastIsEqual from 'fast-deep-equal';
import { buildExistsFilter, buildPhraseFilter, buildPhrasesFilter, Filter } from '@kbn/es-query';
import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
@ -88,6 +89,7 @@ export const getOptionsListControlFactory = (
>(
uuid,
OPTIONS_LIST_CONTROL,
'optionsListDataView',
initialState,
{ searchTechnique: searchTechnique$, singleSelect: singleSelect$ },
controlGroupApi,
@ -244,7 +246,7 @@ export const getOptionsListControlFactory = (
searchTechnique: searchTechnique$.getValue(),
runPastTimeout: runPastTimeout$.getValue(),
singleSelect: singleSelect$.getValue(),
selections: selections.selectedOptions$.getValue(),
selectedOptions: selections.selectedOptions$.getValue(),
sort: sort$.getValue(),
existsSelected: selections.existsSelected$.getValue(),
exclude: selections.exclude$.getValue(),
@ -278,7 +280,7 @@ export const getOptionsListControlFactory = (
sort: [
sort$,
(sort) => sort$.next(sort),
(a, b) => (a ?? OPTIONS_LIST_DEFAULT_SORT) === (b ?? OPTIONS_LIST_DEFAULT_SORT),
(a, b) => fastIsEqual(a ?? OPTIONS_LIST_DEFAULT_SORT, b ?? OPTIONS_LIST_DEFAULT_SORT),
],
/** This state cannot currently be changed after the control is created */

View file

@ -64,6 +64,7 @@ export const getRangesliderControlFactory = (
const dataControl = initializeDataControl<Pick<RangesliderControlState, 'step'>>(
uuid,
RANGE_SLIDER_CONTROL,
'rangeSliderDataView',
initialState,
{
step: step$,
@ -159,8 +160,8 @@ export const getRangesliderControlFactory = (
if (error) {
dataControl.api.setBlockingError(error);
}
max$.next(max);
min$.next(min);
max$.next(max !== undefined ? Math.ceil(max) : undefined);
min$.next(min !== undefined ? Math.floor(min) : undefined);
}
);

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".
*/
const REFERENCE_NAME_PREFIX = 'controlGroup_';
export function getReferenceName(controlId: string, referenceNameSuffix: string) {
return `${REFERENCE_NAME_PREFIX}${controlId}:${referenceNameSuffix}`;
}
export function parseReferenceName(referenceName: string) {
return {
controlId: referenceName.substring(
REFERENCE_NAME_PREFIX.length,
referenceName.lastIndexOf(':')
),
};
}

View file

@ -36,16 +36,17 @@ import './components/index.scss';
import { TimeSliderPrepend } from './components/time_slider_prepend';
import { TIME_SLIDER_CONTROL } from '../../../../common';
const displayName = i18n.translate('controls.timesliderControl.displayName', {
defaultMessage: 'Time slider',
});
export const getTimesliderControlFactory = (
services: Services
): ControlFactory<TimesliderControlState, TimesliderControlApi> => {
return {
type: TIME_SLIDER_CONTROL,
getIconType: () => 'search',
getDisplayName: () =>
i18n.translate('controls.timesliderControl.displayName', {
defaultMessage: 'Time slider',
}),
getDisplayName: () => displayName,
buildControl: async (initialState, buildApi, uuid, controlGroupApi) => {
const { timeRangeMeta$, formatDate, cleanupTimeRangeSubscription } =
initTimeRangeSubscription(controlGroupApi, services);
@ -204,6 +205,7 @@ export const getTimesliderControlFactory = (
const api = buildApi(
{
...defaultControl.api,
defaultPanelTitle: new BehaviorSubject<string | undefined>(displayName),
timeslice$,
serializeState: () => {
const { rawState: defaultControlState } = defaultControl.serialize();

View file

@ -9,7 +9,7 @@
import { CoreStart } from '@kbn/core/public';
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { PublishesTimeslice } from '@kbn/presentation-publishing';
import type { PublishesPanelTitle, PublishesTimeslice } from '@kbn/presentation-publishing';
import type { DefaultControlApi, DefaultControlState } from '../types';
export type Timeslice = [number, number];
@ -21,7 +21,9 @@ export interface TimesliderControlState extends DefaultControlState {
timesliceEndAsPercentageOfTimeRange?: number;
}
export type TimesliderControlApi = DefaultControlApi & PublishesTimeslice;
export type TimesliderControlApi = DefaultControlApi &
Pick<PublishesPanelTitle, 'defaultPanelTitle'> &
PublishesTimeslice;
export interface Services {
core: CoreStart;

View file

@ -8,7 +8,6 @@
*/
import type { Reference } from '@kbn/content-management-utils';
import { CONTROL_GROUP_TYPE, PersistableControlGroupInput } from '@kbn/controls-plugin/common';
import {
EmbeddableInput,
EmbeddablePersistableStateService,
@ -24,6 +23,10 @@ export const getReferencesForPanelId = (id: string, references: Reference[]): Re
return filteredReferences;
};
export const getReferencesForControls = (references: Reference[]): Reference[] => {
return references.filter((reference) => reference.name.startsWith(controlGroupReferencePrefix));
};
export const prefixReferencesFromPanel = (id: string, references: Reference[]): Reference[] => {
const prefix = `${id}:`;
return references
@ -35,7 +38,6 @@ export const prefixReferencesFromPanel = (id: string, references: Reference[]):
};
const controlGroupReferencePrefix = 'controlGroup_';
const controlGroupId = 'dashboard_control_group';
export const createInject = (
persistableStateService: EmbeddablePersistableStateService
@ -91,27 +93,6 @@ export const createInject = (
}
}
// since the controlGroup is not part of the panels array, its references need to be injected separately
if ('controlGroupInput' in workingState && workingState.controlGroupInput) {
const controlGroupReferences = references
.filter((reference) => reference.name.indexOf(controlGroupReferencePrefix) === 0)
.map((reference) => ({
...reference,
name: reference.name.replace(controlGroupReferencePrefix, ''),
}));
const { type, ...injectedControlGroupState } = persistableStateService.inject(
{
...workingState.controlGroupInput,
type: CONTROL_GROUP_TYPE,
id: controlGroupId,
},
controlGroupReferences
);
workingState.controlGroupInput =
injectedControlGroupState as unknown as PersistableControlGroupInput;
}
return workingState as EmbeddableStateWithType;
};
};
@ -161,23 +142,6 @@ export const createExtract = (
}
}
// since the controlGroup is not part of the panels array, its references need to be extracted separately
if ('controlGroupInput' in workingState && workingState.controlGroupInput) {
const { state: extractedControlGroupState, references: controlGroupReferences } =
persistableStateService.extract({
...workingState.controlGroupInput,
type: CONTROL_GROUP_TYPE,
id: controlGroupId,
});
workingState.controlGroupInput =
extractedControlGroupState as unknown as PersistableControlGroupInput;
const prefixedControlGroupReferences = controlGroupReferences.map((reference) => ({
...reference,
name: `${controlGroupReferencePrefix}${reference.name}`,
}));
references.push(...prefixedControlGroupReferences);
}
return { state: workingState as EmbeddableStateWithType, references };
};
};

View file

@ -9,7 +9,6 @@
import type { Reference } from '@kbn/content-management-utils';
import { EmbeddablePersistableStateService } from '@kbn/embeddable-plugin/common/types';
import { rawControlGroupAttributesToControlGroupInput } from '@kbn/controls-plugin/common';
import {
convertPanelMapToSavedPanels,
@ -34,9 +33,6 @@ function parseDashboardAttributesWithType(
}
return {
controlGroupInput:
attributes.controlGroupInput &&
rawControlGroupAttributesToControlGroupInput(attributes.controlGroupInput),
type: 'dashboard',
panels: convertSavedPanelsToPanelMap(parsedPanels),
} as ParsedDashboardAttributesWithType;
@ -60,13 +56,6 @@ export function injectReferences(
panelsJSON: JSON.stringify(injectedPanels),
} as DashboardAttributes;
if (attributes.controlGroupInput && injectedState.controlGroupInput) {
newAttributes.controlGroupInput = {
...attributes.controlGroupInput,
panelsJSON: JSON.stringify(injectedState.controlGroupInput.panels),
};
}
return newAttributes;
}
@ -97,13 +86,6 @@ export function extractReferences(
panelsJSON: JSON.stringify(extractedPanels),
} as DashboardAttributes;
if (attributes.controlGroupInput && extractedState.controlGroupInput) {
newAttributes.controlGroupInput = {
...attributes.controlGroupInput,
panelsJSON: JSON.stringify(extractedState.controlGroupInput.panels),
};
}
return {
references: [...references, ...extractedReferences],
attributes: newAttributes,

View file

@ -9,8 +9,6 @@
import type { Reference } from '@kbn/content-management-utils';
import { EmbeddableStateWithType } from '@kbn/embeddable-plugin/common';
import { PersistableControlGroupInput } from '@kbn/controls-plugin/common';
import { DashboardAttributes, SavedDashboardPanel } from './content_management';
import { DashboardContainerInput, DashboardPanelMap } from './dashboard_container/types';
@ -41,7 +39,6 @@ export type SharedDashboardState = Partial<
* A partially parsed version of the Dashboard Attributes used for inject and extract logic for both the Dashboard Container and the Dashboard Saved Object.
*/
export type ParsedDashboardAttributesWithType = EmbeddableStateWithType & {
controlGroupInput?: PersistableControlGroupInput;
panels: DashboardPanelMap;
type: 'dashboard';
};

View file

@ -10,9 +10,7 @@
import { DashboardAppLocatorDefinition } from './locator';
import { hashedItemStore } from '@kbn/kibana-utils-plugin/public';
import { mockStorage } from '@kbn/kibana-utils-plugin/public/storage/hashed_item_store/mock';
import { mockControlGroupInput } from '@kbn/controls-plugin/common/mocks';
import { FilterStateStore } from '@kbn/es-query';
import { SerializableControlGroupInput } from '@kbn/controls-plugin/common';
describe('dashboard locator', () => {
beforeEach(() => {
@ -194,16 +192,18 @@ describe('dashboard locator', () => {
useHashedUrl: false,
getDashboardFilterFields: async (dashboardId: string) => [],
});
const controlGroupInput = mockControlGroupInput() as unknown as SerializableControlGroupInput;
const controlGroupState = {
autoApplySelections: false,
};
const location = await definition.getLocation({
controlGroupInput,
controlGroupState,
});
expect(location).toMatchObject({
app: 'dashboards',
path: `#/create?_g=()`,
state: {
controlGroupInput,
controlGroupState,
},
});
});

View file

@ -9,16 +9,16 @@
import React from 'react';
import { EuiContextMenuItem } from '@elastic/eui';
import { ControlGroupContainer } from '@kbn/controls-plugin/public';
import { ControlGroupApi } from '@kbn/controls-plugin/public';
import { getAddControlButtonTitle } from '../../_dashboard_app_strings';
import { useDashboardAPI } from '../../dashboard_app';
interface Props {
closePopover: () => void;
controlGroup: ControlGroupContainer;
controlGroupApi?: ControlGroupApi;
}
export const AddDataControlButton = ({ closePopover, controlGroup, ...rest }: Props) => {
export const AddDataControlButton = ({ closePopover, controlGroupApi, ...rest }: Props) => {
const dashboard = useDashboardAPI();
const onSave = () => {
dashboard.scrollToTop();
@ -29,9 +29,10 @@ export const AddDataControlButton = ({ closePopover, controlGroup, ...rest }: Pr
{...rest}
icon="plusInCircle"
data-test-subj="controls-create-button"
disabled={!controlGroupApi}
aria-label={getAddControlButtonTitle()}
onClick={() => {
controlGroup.openAddDataControlFlyout({ onSave });
controlGroupApi?.openAddDataControlFlyout({ onSave });
closePopover();
}}
>

View file

@ -8,8 +8,12 @@
*/
import React, { useEffect, useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { EuiContextMenuItem } from '@elastic/eui';
import { ControlGroupContainer, TIME_SLIDER_CONTROL } from '@kbn/controls-plugin/public';
import type { ControlGroupApi } from '@kbn/controls-plugin/public';
import { TIME_SLIDER_CONTROL } from '@kbn/controls-plugin/common';
import { apiHasType } from '@kbn/presentation-publishing';
import {
getAddTimeSliderControlButtonTitle,
getOnlyOneTimeSliderControlMsg,
@ -18,40 +22,47 @@ import { useDashboardAPI } from '../../dashboard_app';
interface Props {
closePopover: () => void;
controlGroup: ControlGroupContainer;
controlGroupApi?: ControlGroupApi;
}
export const AddTimeSliderControlButton = ({ closePopover, controlGroup, ...rest }: Props) => {
export const AddTimeSliderControlButton = ({ closePopover, controlGroupApi, ...rest }: Props) => {
const [hasTimeSliderControl, setHasTimeSliderControl] = useState(false);
const dashboard = useDashboardAPI();
useEffect(() => {
const subscription = controlGroup.getInput$().subscribe(() => {
const childIds = controlGroup.getChildIds();
const nextHasTimeSliderControl = childIds.some((id: string) => {
const child = controlGroup.getChild(id);
return child.type === TIME_SLIDER_CONTROL;
if (!controlGroupApi) {
return;
}
const subscription = controlGroupApi.children$.subscribe((children) => {
const nextHasTimeSliderControl = Object.values(children).some((controlApi) => {
return apiHasType(controlApi) && controlApi.type === TIME_SLIDER_CONTROL;
});
if (nextHasTimeSliderControl !== hasTimeSliderControl) {
setHasTimeSliderControl(nextHasTimeSliderControl);
}
setHasTimeSliderControl(nextHasTimeSliderControl);
});
return () => {
subscription.unsubscribe();
};
}, [controlGroup, hasTimeSliderControl, setHasTimeSliderControl]);
}, [controlGroupApi]);
return (
<EuiContextMenuItem
{...rest}
icon="timeslider"
onClick={async () => {
await controlGroup.addTimeSliderControl();
controlGroupApi?.addNewPanel({
panelType: TIME_SLIDER_CONTROL,
initialState: {
grow: true,
width: 'large',
id: uuidv4(),
},
});
dashboard.scrollToTop();
closePopover();
}}
data-test-subj="controls-create-timeslider-button"
disabled={hasTimeSliderControl}
disabled={!controlGroupApi || hasTimeSliderControl}
toolTipContent={hasTimeSliderControl ? getOnlyOneTimeSliderControlMsg() : null}
>
{getAddTimeSliderControlButtonTitle()}

View file

@ -11,18 +11,18 @@ import React from 'react';
import { EuiContextMenuPanel, useEuiTheme } from '@elastic/eui';
import { ToolbarPopover } from '@kbn/shared-ux-button-toolbar';
import type { ControlGroupContainer } from '@kbn/controls-plugin/public';
import { ControlGroupApi } from '@kbn/controls-plugin/public';
import { getControlButtonTitle } from '../../_dashboard_app_strings';
import { AddDataControlButton } from './add_data_control_button';
import { AddTimeSliderControlButton } from './add_time_slider_control_button';
import { EditControlGroupButton } from './edit_control_group_button';
export function ControlsToolbarButton({
controlGroup,
controlGroupApi,
isDisabled,
}: {
controlGroup: ControlGroupContainer;
controlGroupApi?: ControlGroupApi;
isDisabled?: boolean;
}) {
const { euiTheme } = useEuiTheme();
@ -44,17 +44,17 @@ export function ControlsToolbarButton({
items={[
<AddDataControlButton
key="addControl"
controlGroup={controlGroup}
controlGroupApi={controlGroupApi}
closePopover={closePopover}
/>,
<AddTimeSliderControlButton
key="addTimeSliderControl"
controlGroup={controlGroup}
controlGroupApi={controlGroupApi}
closePopover={closePopover}
/>,
<EditControlGroupButton
key="manageControls"
controlGroup={controlGroup}
controlGroupApi={controlGroupApi}
closePopover={closePopover}
/>,
]}

View file

@ -9,23 +9,24 @@
import React from 'react';
import { EuiContextMenuItem } from '@elastic/eui';
import { ControlGroupContainer } from '@kbn/controls-plugin/public';
import { ControlGroupApi } from '@kbn/controls-plugin/public';
import { getEditControlGroupButtonTitle } from '../../_dashboard_app_strings';
interface Props {
closePopover: () => void;
controlGroup: ControlGroupContainer;
controlGroupApi?: ControlGroupApi;
}
export const EditControlGroupButton = ({ closePopover, controlGroup, ...rest }: Props) => {
export const EditControlGroupButton = ({ closePopover, controlGroupApi, ...rest }: Props) => {
return (
<EuiContextMenuItem
{...rest}
icon="gear"
data-test-subj="controls-settings-button"
disabled={!controlGroupApi}
aria-label={getEditControlGroupButtonTitle()}
onClick={() => {
controlGroup.openEditControlGroupFlyout();
controlGroupApi?.onEdit();
closePopover();
}}
>

View file

@ -15,6 +15,7 @@ import { useEuiTheme } from '@elastic/eui';
import { AddFromLibraryButton, Toolbar, ToolbarButton } from '@kbn/shared-ux-button-toolbar';
import { BaseVisType, VisTypeAlias } from '@kbn/visualizations-plugin/public';
import { useStateFromPublishingSubject } from '@kbn/presentation-publishing';
import { getCreateVisualizationButtonTitle } from '../_dashboard_app_strings';
import { EditorMenu } from './editor_menu';
import { useDashboardAPI } from '../dashboard_app';
@ -84,6 +85,7 @@ export function DashboardEditingToolbar({ isDisabled }: { isDisabled?: boolean }
* dismissNotification: Optional, if not passed a toast will appear in the dashboard
*/
const controlGroupApi = useStateFromPublishingSubject(dashboard.controlGroupApi$);
const extraButtons = [
<EditorMenu createNewVisType={createNewVisType} isDisabled={isDisabled} api={dashboard} />,
<AddFromLibraryButton
@ -92,12 +94,8 @@ export function DashboardEditingToolbar({ isDisabled }: { isDisabled?: boolean }
data-test-subj="dashboardAddFromLibraryButton"
isDisabled={isDisabled}
/>,
<ControlsToolbarButton isDisabled={isDisabled} controlGroupApi={controlGroupApi} />,
];
if (dashboard.controlGroup) {
extraButtons.push(
<ControlsToolbarButton isDisabled={isDisabled} controlGroup={dashboard.controlGroup} />
);
}
return (
<div

View file

@ -8,7 +8,6 @@
*/
import { EuiCheckboxGroup } from '@elastic/eui';
import type { SerializableControlGroupInput } from '@kbn/controls-plugin/common';
import type { Capabilities } from '@kbn/core/public';
import { QueryState } from '@kbn/data-plugin/common';
import { DASHBOARD_APP_LOCATOR } from '@kbn/deeplinks-analytics';
@ -27,6 +26,7 @@ import { DashboardLocatorParams } from '../../../dashboard_container';
import { pluginServices } from '../../../services/plugin_services';
import { dashboardUrlParams } from '../../dashboard_router';
import { shareModalStrings } from '../../_dashboard_app_strings';
import { PANELS_CONTROL_GROUP_KEY } from '../../../services/dashboard_backup/dashboard_backup_service';
const showFilterBarId = 'showFilterBar';
@ -170,7 +170,9 @@ export function ShowShareModal({
unsavedStateForLocator = {
query: unsavedDashboardState.query,
filters: unsavedDashboardState.filters,
controlGroupInput: unsavedDashboardState.controlGroupInput as SerializableControlGroupInput,
controlGroupState: panelModifications?.[
PANELS_CONTROL_GROUP_KEY
] as DashboardLocatorParams['controlGroupState'],
panels: allUnsavedPanels as DashboardLocatorParams['panels'],
// options

View file

@ -12,6 +12,7 @@ import { Dispatch, SetStateAction, useCallback, useMemo, useState } from 'react'
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { TopNavMenuData } from '@kbn/navigation-plugin/public';
import useMountedState from 'react-use/lib/useMountedState';
import { UI_SETTINGS } from '../../../common';
import { useDashboardAPI } from '../dashboard_app';
@ -33,6 +34,8 @@ export const useDashboardMenuItems = ({
maybeRedirect: (result?: SaveDashboardReturn) => void;
showResetChange?: boolean;
}) => {
const isMounted = useMountedState();
const [isSaveInProgress, setIsSaveInProgress] = useState(false);
/**
@ -100,6 +103,7 @@ export const useDashboardMenuItems = ({
* (1) reset the dashboard to the last saved state, and
* (2) if `switchToViewMode` is `true`, set the dashboard to view mode.
*/
const [isResetting, setIsResetting] = useState(false);
const resetChanges = useCallback(
(switchToViewMode: boolean = false) => {
dashboard.clearOverlays();
@ -114,13 +118,17 @@ export const useDashboardMenuItems = ({
return;
}
confirmDiscardUnsavedChanges(() => {
batch(() => {
dashboard.resetToLastSavedState();
switchModes?.();
batch(async () => {
setIsResetting(true);
await dashboard.asyncResetToLastSavedState();
if (isMounted()) {
setIsResetting(false);
switchModes?.();
}
});
}, viewMode);
},
[dashboard, dashboardBackup, hasUnsavedChanges, viewMode]
[dashboard, dashboardBackup, hasUnsavedChanges, viewMode, isMounted]
);
/**
@ -191,7 +199,8 @@ export const useDashboardMenuItems = ({
switchToViewMode: {
...topNavStrings.switchToViewMode,
id: 'cancel',
disableButton: disableTopNav || !lastSavedId,
disableButton: disableTopNav || !lastSavedId || isResetting,
isLoading: isResetting,
testId: 'dashboardViewOnlyMode',
run: () => resetChanges(true),
} as TopNavMenuData,
@ -227,6 +236,7 @@ export const useDashboardMenuItems = ({
dashboardBackup,
quickSaveDashboard,
resetChanges,
isResetting,
]);
const resetChangesMenuItem = useMemo(() => {
@ -235,12 +245,22 @@ export const useDashboardMenuItems = ({
id: 'reset',
testId: 'dashboardDiscardChangesMenuItem',
disableButton:
isResetting ||
!hasUnsavedChanges ||
hasOverlays ||
(viewMode === ViewMode.EDIT && (isSaveInProgress || !lastSavedId)),
isLoading: isResetting,
run: () => resetChanges(),
};
}, [hasOverlays, lastSavedId, resetChanges, viewMode, isSaveInProgress, hasUnsavedChanges]);
}, [
hasOverlays,
lastSavedId,
resetChanges,
viewMode,
isSaveInProgress,
hasUnsavedChanges,
isResetting,
]);
/**
* Build ordered menus for view and edit mode.

View file

@ -45,7 +45,7 @@ jest.mock('./dashboard_grid_item', () => {
};
});
const createAndMountDashboardGrid = () => {
const createAndMountDashboardGrid = async () => {
const dashboardContainer = buildMockDashboard({
overrides: {
panels: {
@ -62,6 +62,7 @@ const createAndMountDashboardGrid = () => {
},
},
});
await dashboardContainer.untilContainerInitialized();
const component = mountWithIntl(
<DashboardContainerContext.Provider value={dashboardContainer}>
<DashboardGrid viewportWidth={1000} />
@ -71,20 +72,20 @@ const createAndMountDashboardGrid = () => {
};
test('renders DashboardGrid', async () => {
const { component } = createAndMountDashboardGrid();
const { component } = await createAndMountDashboardGrid();
const panelElements = component.find('GridItem');
expect(panelElements.length).toBe(2);
});
test('renders DashboardGrid with no visualizations', async () => {
const { dashboardContainer, component } = createAndMountDashboardGrid();
const { dashboardContainer, component } = await createAndMountDashboardGrid();
dashboardContainer.updateInput({ panels: {} });
component.update();
expect(component.find('GridItem').length).toBe(0);
});
test('DashboardGrid removes panel when removed from container', async () => {
const { dashboardContainer, component } = createAndMountDashboardGrid();
const { dashboardContainer, component } = await createAndMountDashboardGrid();
const originalPanels = dashboardContainer.getInput().panels;
const filteredPanels = { ...originalPanels };
delete filteredPanels['1'];
@ -95,7 +96,7 @@ test('DashboardGrid removes panel when removed from container', async () => {
});
test('DashboardGrid renders expanded panel', async () => {
const { dashboardContainer, component } = createAndMountDashboardGrid();
const { dashboardContainer, component } = await createAndMountDashboardGrid();
dashboardContainer.setExpandedPanelId('1');
component.update();
// Both panels should still exist in the dom, so nothing needs to be re-fetched once minimized.
@ -113,7 +114,7 @@ test('DashboardGrid renders expanded panel', async () => {
});
test('DashboardGrid renders focused panel', async () => {
const { dashboardContainer, component } = createAndMountDashboardGrid();
const { dashboardContainer, component } = await createAndMountDashboardGrid();
dashboardContainer.setFocusedPanelId('2');
component.update();
// Both panels should still exist in the dom, so nothing needs to be re-fetched once minimized.

View file

@ -10,12 +10,19 @@
import { debounce } from 'lodash';
import classNames from 'classnames';
import useResizeObserver from 'use-resize-observer/polyfilled';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { EuiPortal } from '@elastic/eui';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { ReactEmbeddableRenderer, ViewMode } from '@kbn/embeddable-plugin/public';
import { ExitFullScreenButton } from '@kbn/shared-ux-button-exit-full-screen';
import {
ControlGroupApi,
ControlGroupRuntimeState,
ControlGroupSerializedState,
} from '@kbn/controls-plugin/public';
import { CONTROL_GROUP_TYPE } from '@kbn/controls-plugin/common';
import { useStateFromPublishingSubject } from '@kbn/presentation-publishing';
import { DashboardGrid } from '../grid';
import { useDashboardContainer } from '../../embeddable/dashboard_container';
import { DashboardEmptyScreen } from '../empty_screen/dashboard_empty_screen';
@ -35,23 +42,11 @@ export const useDebouncedWidthObserver = (skipDebounce = false, wait = 100) => {
};
export const DashboardViewportComponent = () => {
const controlsRoot = useRef(null);
const dashboard = useDashboardContainer();
/**
* Render Control group
*/
const controlGroup = dashboard.controlGroup;
useEffect(() => {
if (controlGroup && controlsRoot.current) controlGroup.render(controlsRoot.current);
}, [controlGroup]);
const controlGroupApi = useStateFromPublishingSubject(dashboard.controlGroupApi$);
const panelCount = Object.keys(dashboard.select((state) => state.explicitInput.panels)).length;
const controlCount = Object.keys(
controlGroup?.select((state) => state.explicitInput.panels) ?? {}
).length;
const [hasControls, setHasControls] = useState(false);
const viewMode = dashboard.select((state) => state.explicitInput.viewMode);
const dashboardTitle = dashboard.select((state) => state.explicitInput.title);
const useMargins = dashboard.select((state) => state.explicitInput.useMargins);
@ -66,17 +61,64 @@ export const DashboardViewportComponent = () => {
'dshDashboardViewport--panelExpanded': Boolean(expandedPanelId),
});
useEffect(() => {
if (!controlGroupApi) {
return;
}
const subscription = controlGroupApi.children$.subscribe((children) => {
setHasControls(Object.keys(children).length > 0);
});
return () => {
subscription.unsubscribe();
};
}, [controlGroupApi]);
// Bug in main where panels are loaded before control filters are ready
// Want to migrate to react embeddable controls with same behavior
// TODO - do not load panels until control filters are ready
/*
const [dashboardInitialized, setDashboardInitialized] = useState(false);
useEffect(() => {
let ignore = false;
dashboard.untilContainerInitialized().then(() => {
if (!ignore) {
setDashboardInitialized(true);
}
});
return () => {
ignore = true;
};
}, [dashboard]);
*/
return (
<div
className={classNames('dshDashboardViewportWrapper', {
'dshDashboardViewportWrapper--defaultBg': !useMargins,
})}
>
{controlGroup && viewMode !== ViewMode.PRINT ? (
<div
className={controlCount > 0 ? 'dshDashboardViewport-controls' : ''}
ref={controlsRoot}
/>
{viewMode !== ViewMode.PRINT ? (
<div className={hasControls ? 'dshDashboardViewport-controls' : ''}>
<ReactEmbeddableRenderer<
ControlGroupSerializedState,
ControlGroupRuntimeState,
ControlGroupApi
>
key={dashboard.getInput().id}
hidePanelChrome={true}
panelProps={{ hideLoader: true }}
type={CONTROL_GROUP_TYPE}
maybeId={'control_group'}
getParentApi={() => {
return {
...dashboard,
getSerializedStateForChild: dashboard.getSerializedStateForControlGroup,
getRuntimeStateForChild: dashboard.getRuntimeStateForControlGroup,
};
}}
onApiAvailable={(api) => dashboard.setControlGroupApi(api)}
/>
</div>
) : null}
{panelCount === 0 && <DashboardEmptyScreen />}
<div
@ -88,7 +130,9 @@ export const DashboardViewportComponent = () => {
data-shared-items-count={panelCount}
>
{/* Wait for `viewportWidth` to actually be set before rendering the dashboard grid -
otherwise, there is a race condition where the panels can end up being squashed */}
otherwise, there is a race condition where the panels can end up being squashed
TODO only render when dashboardInitialized
*/}
{viewportWidth !== 0 && <DashboardGrid viewportWidth={viewportWidth} />}
</div>
</div>

View file

@ -8,7 +8,6 @@
*/
import type { Reference } from '@kbn/content-management-utils';
import type { PersistableControlGroupInput } from '@kbn/controls-plugin/common';
import { reportPerformanceMetricEvent } from '@kbn/ebt-tools';
import {
EmbeddableInput,
@ -90,13 +89,17 @@ export async function runQuickSave(this: DashboardContainer) {
const { panels: nextPanels, references } = await serializeAllPanelState(this);
const dashboardStateToSave: DashboardContainerInput = { ...currentState, panels: nextPanels };
let stateToSave: SavedDashboardInput = dashboardStateToSave;
let persistableControlGroupInput: PersistableControlGroupInput | undefined;
if (this.controlGroup) {
persistableControlGroupInput = this.controlGroup.getPersistableInput();
stateToSave = { ...stateToSave, controlGroupInput: persistableControlGroupInput };
const controlGroupApi = this.controlGroupApi$.value;
let controlGroupReferences: Reference[] | undefined;
if (controlGroupApi) {
const { rawState: controlGroupSerializedState, references: extractedReferences } =
await controlGroupApi.serializeState();
controlGroupReferences = extractedReferences;
stateToSave = { ...stateToSave, controlGroupInput: controlGroupSerializedState };
}
const saveResult = await saveDashboardState({
controlGroupReferences,
panelReferences: references,
currentState: stateToSave,
saveOptions: {},
@ -106,9 +109,6 @@ export async function runQuickSave(this: DashboardContainer) {
this.savedObjectReferences = saveResult.references ?? [];
this.dispatch.setLastSavedInput(dashboardStateToSave);
this.saveNotification$.next();
if (this.controlGroup && persistableControlGroupInput) {
this.controlGroup.setSavedState(persistableControlGroupInput);
}
return saveResult;
}
@ -181,19 +181,20 @@ export async function runInteractiveSave(this: DashboardContainer, interactionMo
stateFromSaveModal.tags = newTags;
}
let dashboardStateToSave: DashboardContainerInput & {
controlGroupInput?: PersistableControlGroupInput;
} = {
let dashboardStateToSave: SavedDashboardInput = {
...currentState,
...stateFromSaveModal,
};
let persistableControlGroupInput: PersistableControlGroupInput | undefined;
if (this.controlGroup) {
persistableControlGroupInput = this.controlGroup.getPersistableInput();
const controlGroupApi = this.controlGroupApi$.value;
let controlGroupReferences: Reference[] | undefined;
if (controlGroupApi) {
const { rawState: controlGroupSerializedState, references } =
await controlGroupApi.serializeState();
controlGroupReferences = references;
dashboardStateToSave = {
...dashboardStateToSave,
controlGroupInput: persistableControlGroupInput,
controlGroupInput: controlGroupSerializedState,
};
}
@ -226,6 +227,7 @@ export async function runInteractiveSave(this: DashboardContainer, interactionMo
const beforeAddTime = window.performance.now();
const saveResult = await saveDashboardState({
controlGroupReferences,
panelReferences: references,
saveOptions,
currentState: {
@ -252,9 +254,6 @@ export async function runInteractiveSave(this: DashboardContainer, interactionMo
batch(() => {
this.dispatch.setStateFromSaveModal(stateFromSaveModal);
this.dispatch.setLastSavedInput(dashboardStateToSave);
if (this.controlGroup && persistableControlGroupInput) {
this.controlGroup.setSavedState(persistableControlGroupInput);
}
});
}

View file

@ -7,11 +7,9 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { mockControlGroupInput } from '@kbn/controls-plugin/common/mocks';
import { ControlGroupContainer } from '@kbn/controls-plugin/public/control_group/embeddable/control_group_container';
import { Filter } from '@kbn/es-query';
import { ReduxToolsPackage } from '@kbn/presentation-util-plugin/public';
import { combineDashboardFiltersWithControlGroupFilters } from './dashboard_control_group_integration';
import { BehaviorSubject } from 'rxjs';
jest.mock('@kbn/controls-plugin/public/control_group/embeddable/control_group_container');
@ -52,46 +50,41 @@ const testFilter3: Filter = {
},
};
const mockControlGroupContainer = new ControlGroupContainer(
{ getTools: () => {} } as unknown as ReduxToolsPackage,
mockControlGroupInput()
);
describe('combineDashboardFiltersWithControlGroupFilters', () => {
it('Combined filter pills do not get overwritten', async () => {
const dashboardFilterPills = [testFilter1, testFilter2];
const mockControlGroupApi = {
filters$: new BehaviorSubject<Filter[] | undefined>([]),
};
const combinedFilters = combineDashboardFiltersWithControlGroupFilters(
dashboardFilterPills,
mockControlGroupApi
);
expect(combinedFilters).toEqual(dashboardFilterPills);
});
describe('Test dashboard control group', () => {
describe('Combine dashboard filters with control group filters test', () => {
it('Combined filter pills do not get overwritten', async () => {
const dashboardFilterPills = [testFilter1, testFilter2];
mockControlGroupContainer.getOutput = jest.fn().mockReturnValue({ filters: [] });
const combinedFilters = combineDashboardFiltersWithControlGroupFilters(
dashboardFilterPills,
mockControlGroupContainer
);
expect(combinedFilters).toEqual(dashboardFilterPills);
});
it('Combined control filters do not get overwritten', async () => {
const controlGroupFilters = [testFilter1, testFilter2];
const mockControlGroupApi = {
filters$: new BehaviorSubject<Filter[] | undefined>(controlGroupFilters),
};
const combinedFilters = combineDashboardFiltersWithControlGroupFilters(
[] as Filter[],
mockControlGroupApi
);
expect(combinedFilters).toEqual(controlGroupFilters);
});
it('Combined control filters do not get overwritten', async () => {
const controlGroupFilters = [testFilter1, testFilter2];
mockControlGroupContainer.getOutput = jest
.fn()
.mockReturnValue({ filters: controlGroupFilters });
const combinedFilters = combineDashboardFiltersWithControlGroupFilters(
[] as Filter[],
mockControlGroupContainer
);
expect(combinedFilters).toEqual(controlGroupFilters);
});
it('Combined dashboard filter pills and control filters do not get overwritten', async () => {
const dashboardFilterPills = [testFilter1, testFilter2];
const controlGroupFilters = [testFilter3];
mockControlGroupContainer.getOutput = jest
.fn()
.mockReturnValue({ filters: controlGroupFilters });
const combinedFilters = combineDashboardFiltersWithControlGroupFilters(
dashboardFilterPills,
mockControlGroupContainer
);
expect(combinedFilters).toEqual(dashboardFilterPills.concat(controlGroupFilters));
});
it('Combined dashboard filter pills and control filters do not get overwritten', async () => {
const dashboardFilterPills = [testFilter1, testFilter2];
const controlGroupFilters = [testFilter3];
const mockControlGroupApi = {
filters$: new BehaviorSubject<Filter[] | undefined>(controlGroupFilters),
};
const combinedFilters = combineDashboardFiltersWithControlGroupFilters(
dashboardFilterPills,
mockControlGroupApi
);
expect(combinedFilters).toEqual(dashboardFilterPills.concat(controlGroupFilters));
});
});

View file

@ -7,114 +7,95 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { ControlGroupInput } from '@kbn/controls-plugin/common';
import { ControlGroupContainer } from '@kbn/controls-plugin/public';
import { compareFilters, COMPARE_ALL_OPTIONS, type Filter } from '@kbn/es-query';
import { combineCompatibleChildrenApis } from '@kbn/presentation-containers';
import { apiPublishesDataLoading, PublishesDataLoading } from '@kbn/presentation-publishing';
import deepEqual from 'fast-deep-equal';
import { isEqual } from 'lodash';
import { distinctUntilChanged, Observable, skip } from 'rxjs';
import { DashboardContainerInput } from '../../../../../common';
import { COMPARE_ALL_OPTIONS, compareFilters, type Filter } from '@kbn/es-query';
import {
BehaviorSubject,
combineLatest,
distinctUntilChanged,
map,
of,
skip,
startWith,
switchMap,
} from 'rxjs';
import { PublishesFilters, PublishingSubject } from '@kbn/presentation-publishing';
import { DashboardContainer } from '../../dashboard_container';
interface DiffChecks {
[key: string]: (a?: unknown, b?: unknown) => boolean;
}
export function startSyncingDashboardControlGroup(dashboard: DashboardContainer) {
const controlGroupFilters$ = dashboard.controlGroupApi$.pipe(
switchMap((controlGroupApi) => (controlGroupApi ? controlGroupApi.filters$ : of(undefined)))
);
const controlGroupTimeslice$ = dashboard.controlGroupApi$.pipe(
switchMap((controlGroupApi) => (controlGroupApi ? controlGroupApi.timeslice$ : of(undefined)))
);
const distinctUntilDiffCheck = <T extends {}>(a: T, b: T, diffChecks: DiffChecks) =>
!(Object.keys(diffChecks) as Array<keyof T>)
.map((key) => deepEqual(a[key], b[key]))
.includes(false);
type DashboardControlGroupCommonKeys = keyof Pick<
DashboardContainerInput | ControlGroupInput,
'filters' | 'lastReloadRequestTime' | 'timeRange' | 'query'
>;
export function startSyncingDashboardControlGroup(this: DashboardContainer) {
if (!this.controlGroup) return;
const compareAllFilters = (a?: Filter[], b?: Filter[]) =>
compareFilters(a ?? [], b ?? [], COMPARE_ALL_OPTIONS);
const dashboardRefetchDiff: DiffChecks = {
filters: (a, b) => compareAllFilters(a as Filter[], b as Filter[]),
timeRange: deepEqual,
query: deepEqual,
viewMode: deepEqual,
};
// pass down any pieces of input needed to refetch or force refetch data for the controls
this.integrationSubscriptions.add(
(this.getInput$() as Readonly<Observable<DashboardContainerInput>>)
// --------------------------------------------------------------------------------------
// dashboard.unifiedSearchFilters$
// --------------------------------------------------------------------------------------
const unifiedSearchFilters$ = new BehaviorSubject<Filter[] | undefined>(
dashboard.getInput().filters
);
dashboard.unifiedSearchFilters$ = unifiedSearchFilters$ as PublishingSubject<
Filter[] | undefined
>;
dashboard.publishingSubscription.add(
dashboard
.getInput$()
.pipe(
distinctUntilChanged((a, b) =>
distinctUntilDiffCheck<DashboardContainerInput>(a, b, dashboardRefetchDiff)
)
startWith(dashboard.getInput()),
map((input) => input.filters),
distinctUntilChanged((previous, current) => {
return compareFilters(previous ?? [], current ?? [], COMPARE_ALL_OPTIONS);
})
)
.subscribe(() => {
const newInput: { [key: string]: unknown } = {};
(Object.keys(dashboardRefetchDiff) as DashboardControlGroupCommonKeys[]).forEach((key) => {
if (
!dashboardRefetchDiff[key]?.(this.getInput()[key], this.controlGroup!.getInput()[key])
) {
newInput[key] = this.getInput()[key];
}
});
if (Object.keys(newInput).length > 0) {
this.controlGroup!.updateInput(newInput);
}
.subscribe((unifiedSearchFilters) => {
unifiedSearchFilters$.next(unifiedSearchFilters);
})
);
// --------------------------------------------------------------------------------------
// Set dashboard.filters$ to include unified search filters and control group filters
// --------------------------------------------------------------------------------------
function getCombinedFilters() {
return combineDashboardFiltersWithControlGroupFilters(
dashboard.getInput().filters ?? [],
dashboard.controlGroupApi$.value
);
}
const filters$ = new BehaviorSubject<Filter[] | undefined>(getCombinedFilters());
dashboard.filters$ = filters$;
dashboard.publishingSubscription.add(
combineLatest([dashboard.unifiedSearchFilters$, controlGroupFilters$]).subscribe(() => {
filters$.next(getCombinedFilters());
})
);
// --------------------------------------------------------------------------------------
// when control group outputs filters, force a refresh!
this.integrationSubscriptions.add(
this.controlGroup
.getOutput$()
// --------------------------------------------------------------------------------------
dashboard.publishingSubscription.add(
controlGroupFilters$
.pipe(
distinctUntilChanged(({ filters: filtersA }, { filters: filtersB }) =>
compareAllFilters(filtersA, filtersB)
),
skip(1) // skip first filter output because it will have been applied in initialize
)
.subscribe(() => this.forceRefresh(false)) // we should not reload the control group when the control group output changes - otherwise, performance is severely impacted
.subscribe(() => dashboard.forceRefresh(false)) // we should not reload the control group when the control group output changes - otherwise, performance is severely impacted
);
this.integrationSubscriptions.add(
this.controlGroup
.getOutput$()
.pipe(
distinctUntilChanged(({ timeslice: timesliceA }, { timeslice: timesliceB }) =>
isEqual(timesliceA, timesliceB)
)
)
.subscribe(({ timeslice }) => {
if (!isEqual(timeslice, this.getInput().timeslice)) {
this.dispatch.setTimeslice(timeslice);
}
})
);
// the Control Group needs to know when any dashboard children are loading in order to know when to move on to the next time slice when playing.
this.integrationSubscriptions.add(
combineCompatibleChildrenApis<PublishesDataLoading, boolean>(
this,
'dataLoading',
apiPublishesDataLoading,
false,
(childrenLoading) => childrenLoading.some(Boolean)
)
.pipe(skip(1)) // skip the initial output of "false"
.subscribe((anyChildLoading) =>
this.controlGroup?.anyControlOutputConsumerLoading$.next(anyChildLoading)
)
// --------------------------------------------------------------------------------------
// when control group outputs timeslice, dispatch timeslice
// --------------------------------------------------------------------------------------
dashboard.publishingSubscription.add(
controlGroupTimeslice$.subscribe((timeslice) => {
dashboard.dispatch.setTimeslice(timeslice);
})
);
}
export const combineDashboardFiltersWithControlGroupFilters = (
dashboardFilters: Filter[],
controlGroup: ControlGroupContainer
controlGroupApi?: PublishesFilters
): Filter[] => {
return [...dashboardFilters, ...(controlGroup.getOutput().filters ?? [])];
return [...dashboardFilters, ...(controlGroupApi?.filters$.value ?? [])];
};

View file

@ -7,8 +7,6 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { BehaviorSubject, Observable } from 'rxjs';
import {
ContactCardEmbeddable,
ContactCardEmbeddableFactory,
@ -16,11 +14,6 @@ import {
ContactCardEmbeddableOutput,
CONTACT_CARD_EMBEDDABLE,
} from '@kbn/embeddable-plugin/public/lib/test_samples';
import {
ControlGroupInput,
ControlGroupContainer,
ControlGroupContainerFactory,
} from '@kbn/controls-plugin/public';
import { Filter } from '@kbn/es-query';
import { EmbeddablePackageState, ViewMode } from '@kbn/embeddable-plugin/public';
import { createKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
@ -30,6 +23,7 @@ import { getSampleDashboardPanel } from '../../../mocks';
import { pluginServices } from '../../../services/plugin_services';
import { DashboardCreationOptions } from '../dashboard_container_factory';
import { DEFAULT_DASHBOARD_INPUT } from '../../../dashboard_constants';
import { mockControlGroupApi } from '../../../mocks';
test("doesn't throw error when no data views are available", async () => {
pluginServices.getServices().data.dataViews.defaultDataViewExists = jest
@ -417,6 +411,7 @@ test('creates new embeddable with incoming embeddable if id does not match exist
},
}),
});
dashboard?.setControlGroupApi(mockControlGroupApi);
// flush promises
await new Promise((r) => setTimeout(r, 1));
@ -477,6 +472,7 @@ test('creates new embeddable with specified size if size is provided', async ()
},
}),
});
dashboard?.setControlGroupApi(mockControlGroupApi);
// flush promises
await new Promise((r) => setTimeout(r, 1));
@ -498,42 +494,6 @@ test('creates new embeddable with specified size if size is provided', async ()
expect(dashboard!.getState().explicitInput.panels.new_panel.gridData.h).toBe(1);
});
test('creates a control group from the control group factory', async () => {
const mockControlGroupContainer = {
destroy: jest.fn(),
render: jest.fn(),
updateInput: jest.fn(),
getInput: jest.fn().mockReturnValue({}),
getInput$: jest.fn().mockReturnValue(new Observable()),
getOutput: jest.fn().mockReturnValue({}),
getOutput$: jest.fn().mockReturnValue(new Observable()),
onFiltersPublished$: new Observable(),
unsavedChanges: new BehaviorSubject(undefined),
} as unknown as ControlGroupContainer;
const mockControlGroupFactory = {
create: jest.fn().mockReturnValue(mockControlGroupContainer),
} as unknown as ControlGroupContainerFactory;
pluginServices.getServices().embeddable.getEmbeddableFactory = jest
.fn()
.mockReturnValue(mockControlGroupFactory);
await createDashboard({
useControlGroupIntegration: true,
getInitialInput: () => ({
controlGroupInput: { controlStyle: 'twoLine' } as unknown as ControlGroupInput,
}),
});
// flush promises
await new Promise((r) => setTimeout(r, 1));
expect(pluginServices.getServices().embeddable.getEmbeddableFactory).toHaveBeenCalledWith(
'control_group'
);
expect(mockControlGroupFactory.create).toHaveBeenCalledWith(
expect.objectContaining({ controlStyle: 'twoLine' }),
undefined,
{ lastSavedInput: expect.objectContaining({ controlStyle: 'oneLine' }) }
);
});
/*
* dashboard.getInput$() subscriptions are used to update:
* 1) dashboard instance searchSessionId state
@ -568,6 +528,7 @@ test('searchSessionId is updated prior to child embeddable parent subscription e
createSessionRestorationDataProvider: () => {},
} as unknown as DashboardCreationOptions['searchSessionSettings'],
});
dashboard?.setControlGroupApi(mockControlGroupApi);
expect(dashboard).toBeDefined();
const embeddable = await dashboard!.addNewEmbeddable<
ContactCardEmbeddableInput,

View file

@ -7,38 +7,12 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import {
ControlGroupInput,
CONTROL_GROUP_TYPE,
getDefaultControlGroupInput,
getDefaultControlGroupPersistableInput,
} from '@kbn/controls-plugin/common';
import {
ControlGroupContainerFactory,
ControlGroupOutput,
type ControlGroupContainer,
} from '@kbn/controls-plugin/public';
import { GlobalQueryStateFromUrl, syncGlobalQueryStateWithUrl } from '@kbn/data-plugin/public';
import { EmbeddableFactory, isErrorEmbeddable, ViewMode } from '@kbn/embeddable-plugin/public';
import {
AggregateQuery,
compareFilters,
COMPARE_ALL_OPTIONS,
Filter,
Query,
TimeRange,
} from '@kbn/es-query';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { TimeRange } from '@kbn/es-query';
import { lazyLoadReduxToolsPackage } from '@kbn/presentation-util-plugin/public';
import deepEqual from 'fast-deep-equal';
import { cloneDeep, identity, omit, pickBy } from 'lodash';
import {
BehaviorSubject,
combineLatest,
distinctUntilChanged,
map,
startWith,
Subject,
} from 'rxjs';
import { cloneDeep, omit } from 'lodash';
import { Subject } from 'rxjs';
import { v4 } from 'uuid';
import {
DashboardContainerInput,
@ -62,14 +36,11 @@ import { startDiffingDashboardState } from '../../state/diffing/dashboard_diffin
import { DashboardPublicState, UnsavedPanelState } from '../../types';
import { DashboardContainer } from '../dashboard_container';
import { DashboardCreationOptions } from '../dashboard_container_factory';
import {
combineDashboardFiltersWithControlGroupFilters,
startSyncingDashboardControlGroup,
} from './controls/dashboard_control_group_integration';
import { startSyncingDashboardDataViews } from './data_views/sync_dashboard_data_views';
import { startQueryPerformanceTracking } from './performance/query_performance_tracking';
import { startDashboardSearchSessionIntegration } from './search_sessions/start_dashboard_search_session_integration';
import { syncUnifiedSearchState } from './unified_search/sync_dashboard_unified_search_state';
import { PANELS_CONTROL_GROUP_KEY } from '../../../services/dashboard_backup/dashboard_backup_service';
/**
* Builds a new Dashboard from scratch.
@ -164,16 +135,13 @@ export const initializeDashboard = async ({
loadDashboardReturn,
untilDashboardReady,
creationOptions,
controlGroup,
}: {
loadDashboardReturn: LoadDashboardReturn;
untilDashboardReady: () => Promise<DashboardContainer>;
creationOptions?: DashboardCreationOptions;
controlGroup?: ControlGroupContainer;
}) => {
const {
dashboardBackup,
embeddable: { getEmbeddableFactory },
dashboardCapabilities: { showWriteControls },
embeddable: { reactEmbeddableRegistryHasKey },
data: {
@ -193,7 +161,6 @@ export const initializeDashboard = async ({
searchSessionSettings,
unifiedSearchSettings,
validateLoadedSavedObject,
useControlGroupIntegration,
useUnifiedSearchIntegration,
useSessionStorageIntegration,
} = creationOptions ?? {};
@ -293,11 +260,6 @@ export const initializeDashboard = async ({
cloneDeep(combinedOverrideInput),
'controlGroupInput'
);
const initialControlGroupInput: ControlGroupInput | {} = {
...(loadDashboardReturn?.dashboardInput?.controlGroupInput ?? {}),
...(sessionStorageInput?.controlGroupInput ?? {}),
...(overrideInput?.controlGroupInput ?? {}),
};
// Back up any view mode passed in explicitly.
if (overrideInput?.viewMode) {
@ -314,6 +276,7 @@ export const initializeDashboard = async ({
// --------------------------------------------------------------------------------------
untilDashboardReady().then((dashboard) => {
dashboard.savedObjectReferences = loadDashboardReturn?.references;
dashboard.controlGroupInput = loadDashboardReturn?.dashboardInput?.controlGroupInput;
});
// --------------------------------------------------------------------------------------
@ -476,6 +439,13 @@ export const initializeDashboard = async ({
// Set restored runtime state for react embeddables.
// --------------------------------------------------------------------------------------
untilDashboardReady().then((dashboardContainer) => {
if (overrideInput?.controlGroupState) {
dashboardContainer.setRuntimeStateForChild(
PANELS_CONTROL_GROUP_KEY,
overrideInput.controlGroupState
);
}
for (const idWithRuntimeState of Object.keys(runtimePanelsToRestore)) {
const restoredRuntimeStateForChild = runtimePanelsToRestore[idWithRuntimeState];
if (!restoredRuntimeStateForChild) continue;
@ -483,52 +453,6 @@ export const initializeDashboard = async ({
}
});
// --------------------------------------------------------------------------------------
// Start the control group integration.
// --------------------------------------------------------------------------------------
if (useControlGroupIntegration) {
const controlsGroupFactory = getEmbeddableFactory<
ControlGroupInput,
ControlGroupOutput,
ControlGroupContainer
>(CONTROL_GROUP_TYPE) as EmbeddableFactory<
ControlGroupInput,
ControlGroupOutput,
ControlGroupContainer
> & {
create: ControlGroupContainerFactory['create'];
};
const { filters, query, timeRange, viewMode, id } = initialDashboardInput;
const fullControlGroupInput = {
id: `control_group_${id ?? 'new_dashboard'}`,
...getDefaultControlGroupInput(),
...pickBy(initialControlGroupInput, identity), // undefined keys in initialInput should not overwrite defaults
timeRange,
viewMode,
filters,
query,
};
if (controlGroup) {
controlGroup.updateInputAndReinitialize(fullControlGroupInput);
} else {
const newControlGroup = await controlsGroupFactory?.create(fullControlGroupInput, this, {
lastSavedInput:
loadDashboardReturn?.dashboardInput?.controlGroupInput ??
getDefaultControlGroupPersistableInput(),
});
if (!newControlGroup || isErrorEmbeddable(newControlGroup)) {
throw new Error('Error in control group startup');
}
controlGroup = newControlGroup;
}
untilDashboardReady().then((dashboardContainer) => {
dashboardContainer.controlGroup = controlGroup;
startSyncingDashboardControlGroup.bind(dashboardContainer)();
});
}
// --------------------------------------------------------------------------------------
// Start the data views integration.
// --------------------------------------------------------------------------------------
@ -554,63 +478,6 @@ export const initializeDashboard = async ({
setTimeout(() => dashboard.dispatch.setAnimatePanelTransforms(true), 500)
);
// --------------------------------------------------------------------------------------
// Set parentApi.filters$ to include dashboardContainer filters and control group filters
// --------------------------------------------------------------------------------------
untilDashboardReady().then((dashboardContainer) => {
if (!dashboardContainer.controlGroup) {
return;
}
function getCombinedFilters() {
return combineDashboardFiltersWithControlGroupFilters(
dashboardContainer.getInput().filters ?? [],
dashboardContainer.controlGroup!
);
}
const filters$ = new BehaviorSubject<Filter[] | undefined>(getCombinedFilters());
dashboardContainer.filters$ = filters$;
const inputFilters$ = dashboardContainer.getInput$().pipe(
startWith(dashboardContainer.getInput()),
map((input) => input.filters),
distinctUntilChanged((previous, current) => {
return compareFilters(previous ?? [], current ?? [], COMPARE_ALL_OPTIONS);
})
);
// Can not use onFiltersPublished$ directly since it does not have an intial value and
// combineLatest will not emit until each observable emits at least one value
const controlGroupFilters$ = dashboardContainer.controlGroup.onFiltersPublished$.pipe(
startWith(dashboardContainer.controlGroup.getOutput().filters)
);
dashboardContainer.integrationSubscriptions.add(
combineLatest([inputFilters$, controlGroupFilters$]).subscribe(() => {
filters$.next(getCombinedFilters());
})
);
});
// --------------------------------------------------------------------------------------
// Set up parentApi.query$
// Can not use legacyEmbeddableToApi since query$ setting is delayed
// --------------------------------------------------------------------------------------
untilDashboardReady().then((dashboardContainer) => {
const query$ = new BehaviorSubject<Query | AggregateQuery | undefined>(
dashboardContainer.getInput().query
);
dashboardContainer.query$ = query$;
dashboardContainer.integrationSubscriptions.add(
dashboardContainer.getInput$().subscribe((input) => {
if (!deepEqual(query$.getValue() ?? [], input.query)) {
query$.next(input.query);
}
})
);
});
// --------------------------------------------------------------------------------------
// Set up search sessions integration.
// --------------------------------------------------------------------------------------
@ -631,7 +498,8 @@ export const initializeDashboard = async ({
sessionIdToRestore ??
(existingSession && incomingEmbeddable ? existingSession : session.start());
untilDashboardReady().then((container) => {
untilDashboardReady().then(async (container) => {
await container.untilContainerInitialized();
startDashboardSearchSessionIntegration.bind(container)(
creationOptions?.searchSessionSettings
);

View file

@ -11,7 +11,7 @@ import { DataView } from '@kbn/data-views-plugin/common';
import { combineCompatibleChildrenApis } from '@kbn/presentation-containers';
import { apiPublishesDataViews, PublishesDataViews } from '@kbn/presentation-publishing';
import { uniqBy } from 'lodash';
import { combineLatest, map, Observable, of, switchMap } from 'rxjs';
import { combineLatest, Observable, of, switchMap } from 'rxjs';
import { pluginServices } from '../../../../services/plugin_services';
import { DashboardContainer } from '../../dashboard_container';
@ -20,19 +20,11 @@ export function startSyncingDashboardDataViews(this: DashboardContainer) {
data: { dataViews },
} = pluginServices.getServices();
const controlGroupDataViewsPipe: Observable<DataView[]> = this.controlGroup
? this.controlGroup.getOutput$().pipe(
map((output) => output.dataViewIds ?? []),
switchMap(
(dataViewIds) =>
new Promise<DataView[]>((resolve) =>
Promise.all(dataViewIds.map((id) => dataViews.get(id))).then((nextDataViews) =>
resolve(nextDataViews)
)
)
)
)
: of([]);
const controlGroupDataViewsPipe: Observable<DataView[] | undefined> = this.controlGroupApi$.pipe(
switchMap((controlGroupApi) => {
return controlGroupApi ? controlGroupApi.dataViews : of([]);
})
);
const childDataViewsPipe = combineCompatibleChildrenApis<PublishesDataViews, DataView[]>(
this,
@ -44,7 +36,10 @@ export function startSyncingDashboardDataViews(this: DashboardContainer) {
return combineLatest([controlGroupDataViewsPipe, childDataViewsPipe])
.pipe(
switchMap(([controlGroupDataViews, childDataViews]) => {
const allDataViews = controlGroupDataViews.concat(childDataViews);
const allDataViews = [
...(controlGroupDataViews ? controlGroupDataViews : []),
...childDataViews,
];
if (allDataViews.length === 0) {
return (async () => {
const defaultDataViewId = await dataViews.getDefaultId();
@ -55,7 +50,6 @@ export function startSyncingDashboardDataViews(this: DashboardContainer) {
})
)
.subscribe((newDataViews) => {
if (newDataViews[0].id) this.controlGroup?.setRelevantDataViewId(newDataViews[0].id);
this.setAllDataViews(newDataViews);
});
}

View file

@ -19,7 +19,12 @@ import {
import type { TimeRange } from '@kbn/es-query';
import { mockedReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public/mocks';
import { buildMockDashboard, getSampleDashboardInput, getSampleDashboardPanel } from '../../mocks';
import {
buildMockDashboard,
getSampleDashboardInput,
getSampleDashboardPanel,
mockControlGroupApi,
} from '../../mocks';
import { pluginServices } from '../../services/plugin_services';
import { DashboardContainer } from './dashboard_container';
@ -171,6 +176,7 @@ test('searchSessionId propagates to children', async () => {
undefined,
{ lastSavedInput: sampleInput }
);
container?.setControlGroupApi(mockControlGroupApi);
const embeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
@ -190,11 +196,10 @@ describe('getInheritedInput', () => {
const dashboardTimeslice = [1688061910000, 1688062209000] as [number, number];
test('Should pass dashboard timeRange and timeslice to panel when panel does not have custom time range', async () => {
const container = buildMockDashboard({
overrides: {
timeRange: dashboardTimeRange,
timeslice: dashboardTimeslice,
},
const container = buildMockDashboard();
container.updateInput({
timeRange: dashboardTimeRange,
timeslice: dashboardTimeslice,
});
const embeddable = await container.addNewEmbeddable<ContactCardEmbeddableInput>(
CONTACT_CARD_EMBEDDABLE,
@ -215,11 +220,10 @@ describe('getInheritedInput', () => {
});
test('Should not pass dashboard timeRange and timeslice to panel when panel has custom time range', async () => {
const container = buildMockDashboard({
overrides: {
timeRange: dashboardTimeRange,
timeslice: dashboardTimeslice,
},
const container = buildMockDashboard();
container.updateInput({
timeRange: dashboardTimeRange,
timeslice: dashboardTimeslice,
});
const embeddableTimeRange = {
to: 'now',

View file

@ -9,7 +9,6 @@
import { METRIC_TYPE } from '@kbn/analytics';
import type { Reference } from '@kbn/content-management-utils';
import type { ControlGroupContainer } from '@kbn/controls-plugin/public';
import type { I18nStart, KibanaExecutionContext, OverlayRef } from '@kbn/core/public';
import {
type PublishingSubject,
@ -17,6 +16,8 @@ import {
apiPublishesUnsavedChanges,
getPanelTitle,
PublishesViewMode,
PublishesDataLoading,
apiPublishesDataLoading,
} from '@kbn/presentation-publishing';
import { RefreshInterval } from '@kbn/data-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
@ -33,7 +34,7 @@ import {
type EmbeddableOutput,
type IEmbeddable,
} from '@kbn/embeddable-plugin/public';
import type { Filter, Query, TimeRange } from '@kbn/es-query';
import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import {
HasRuntimeChildState,
@ -41,6 +42,7 @@ import {
HasSerializedChildState,
TrackContentfulRender,
TracksQueryPerformance,
combineCompatibleChildrenApis,
} from '@kbn/presentation-containers';
import { PanelPackage } from '@kbn/presentation-containers';
import { ReduxEmbeddableTools, ReduxToolsPackage } from '@kbn/presentation-util-plugin/public';
@ -51,14 +53,18 @@ import { omit } from 'lodash';
import React, { createContext, useContext } from 'react';
import ReactDOM from 'react-dom';
import { batch } from 'react-redux';
import { BehaviorSubject, Subject, Subscription } from 'rxjs';
import { BehaviorSubject, Subject, Subscription, first, skipWhile, switchMap } from 'rxjs';
import { distinctUntilChanged, map } from 'rxjs';
import { v4 } from 'uuid';
import { PublishesSettings } from '@kbn/presentation-containers/interfaces/publishes_settings';
import { apiHasSerializableState } from '@kbn/presentation-containers/interfaces/serialized_state';
import { ControlGroupApi, ControlGroupSerializedState } from '@kbn/controls-plugin/public';
import { DashboardLocatorParams, DASHBOARD_CONTAINER_TYPE } from '../..';
import { DashboardContainerInput, DashboardPanelState } from '../../../common';
import { getReferencesForPanelId } from '../../../common/dashboard_container/persistable_state/dashboard_container_references';
import { DashboardAttributes, DashboardContainerInput, DashboardPanelState } from '../../../common';
import {
getReferencesForControls,
getReferencesForPanelId,
} from '../../../common/dashboard_container/persistable_state/dashboard_container_references';
import {
DASHBOARD_APP_ID,
DASHBOARD_UI_METRIC_ID,
@ -85,7 +91,10 @@ import {
showSettings,
} from './api';
import { duplicateDashboardPanel } from './api/duplicate_dashboard_panel';
import { combineDashboardFiltersWithControlGroupFilters } from './create/controls/dashboard_control_group_integration';
import {
combineDashboardFiltersWithControlGroupFilters,
startSyncingDashboardControlGroup,
} from './create/controls/dashboard_control_group_integration';
import { initializeDashboard } from './create/create_dashboard';
import {
DashboardCreationOptions,
@ -93,6 +102,7 @@ import {
dashboardTypeDisplayName,
} from './dashboard_container_factory';
import { getPanelAddedSuccessString } from '../../dashboard_app/_dashboard_app_strings';
import { PANELS_CONTROL_GROUP_KEY } from '../../services/dashboard_backup/dashboard_backup_service';
export interface InheritedChildInput {
filters: Filter[];
@ -148,7 +158,7 @@ export class DashboardContainer
public integrationSubscriptions: Subscription = new Subscription();
public publishingSubscription: Subscription = new Subscription();
public diffingSubscription: Subscription = new Subscription();
public controlGroup?: ControlGroupContainer;
public controlGroupApi$: PublishingSubject<ControlGroupApi | undefined>;
public settings: Record<string, PublishingSubject<boolean | undefined>>;
public searchSessionId?: string;
@ -157,6 +167,7 @@ export class DashboardContainer
public reload$ = new Subject<void>();
public timeRestore$: BehaviorSubject<boolean | undefined>;
public timeslice$: BehaviorSubject<[number, number] | undefined>;
public unifiedSearchFilters$?: PublishingSubject<Filter[] | undefined>;
public locator?: Pick<LocatorPublic<DashboardLocatorParams>, 'navigate' | 'getRedirectUrl'>;
public readonly executionContext: KibanaExecutionContext;
@ -173,6 +184,9 @@ export class DashboardContainer
private hadContentfulRender = false;
private scrollPosition?: number;
// setup
public untilContainerInitialized: () => Promise<void>;
// cleanup
public stopSyncingWithUnifiedSearch?: () => void;
private cleanupStateTools: () => void;
@ -198,6 +212,7 @@ export class DashboardContainer
| undefined;
// new embeddable framework
public savedObjectReferences: Reference[] = [];
public controlGroupInput: DashboardAttributes['controlGroupInput'] | undefined;
constructor(
initialInput: DashboardContainerInput,
@ -208,19 +223,46 @@ export class DashboardContainer
creationOptions?: DashboardCreationOptions,
initialComponentState?: DashboardPublicState
) {
const controlGroupApi$ = new BehaviorSubject<ControlGroupApi | undefined>(undefined);
async function untilContainerInitialized(): Promise<void> {
return new Promise((resolve) => {
controlGroupApi$
.pipe(
skipWhile((controlGroupApi) => !controlGroupApi),
switchMap(async (controlGroupApi) => {
// Bug in main where panels are loaded before control filters are ready
// Want to migrate to react embeddable controls with same behavior
// TODO - do not load panels until control filters are ready
/*
await controlGroupApi?.untilInitialized();
*/
}),
first()
)
.subscribe(() => {
resolve();
});
});
}
const {
usageCollection,
embeddable: { getEmbeddableFactory },
} = pluginServices.getServices();
super(
{
...initialInput,
},
{ embeddableLoaded: {} },
getEmbeddableFactory,
parent
parent,
{ untilContainerInitialized }
);
this.controlGroupApi$ = controlGroupApi$;
this.untilContainerInitialized = untilContainerInitialized;
this.trackPanelAddMetric = usageCollection.reportUiCounter?.bind(
usageCollection,
DASHBOARD_UI_METRIC_ID
@ -312,7 +354,41 @@ export class DashboardContainer
DashboardContainerInput
>(this.publishingSubscription, this, 'lastReloadRequestTime');
startSyncingDashboardControlGroup(this);
this.executionContext = initialInput.executionContext;
this.dataLoading = new BehaviorSubject<boolean | undefined>(false);
this.publishingSubscription.add(
combineCompatibleChildrenApis<PublishesDataLoading, boolean | undefined>(
this,
'dataLoading',
apiPublishesDataLoading,
undefined,
// flatten method
(values) => {
return values.some((isLoading) => isLoading);
}
).subscribe((isAtLeastOneChildLoading) => {
(this.dataLoading as BehaviorSubject<boolean | undefined>).next(isAtLeastOneChildLoading);
})
);
this.dataViews = new BehaviorSubject<DataView[] | undefined>(this.getAllDataViews());
const query$ = new BehaviorSubject<Query | AggregateQuery | undefined>(this.getInput().query);
this.query$ = query$;
this.publishingSubscription.add(
this.getInput$().subscribe((input) => {
if (!deepEqual(query$.getValue() ?? [], input.query)) {
query$.next(input.query);
}
})
);
}
public setControlGroupApi(controlGroupApi: ControlGroupApi) {
(this.controlGroupApi$ as BehaviorSubject<ControlGroupApi | undefined>).next(controlGroupApi);
}
public getAppContext() {
@ -398,10 +474,10 @@ export class DashboardContainer
panels,
} = this.input;
let combinedFilters = filters;
if (this.controlGroup) {
combinedFilters = combineDashboardFiltersWithControlGroupFilters(filters, this.controlGroup);
}
const combinedFilters = combineDashboardFiltersWithControlGroupFilters(
filters,
this.controlGroupApi$?.value
);
const hasCustomTimeRange = Boolean(
(panels[id]?.explicitInput as Partial<InheritedChildInput>)?.timeRange
);
@ -430,7 +506,6 @@ export class DashboardContainer
public destroy() {
super.destroy();
this.cleanupStateTools();
this.controlGroup?.destroy();
this.diffingSubscription.unsubscribe();
this.publishingSubscription.unsubscribe();
this.integrationSubscriptions.unsubscribe();
@ -616,16 +691,12 @@ export class DashboardContainer
public forceRefresh(refreshControlGroup: boolean = true) {
this.dispatch.setLastReloadRequestTimeToNow({});
if (refreshControlGroup) {
this.controlGroup?.reload();
// only reload all panels if this refresh does not come from the control group.
this.reload$.next();
}
}
public onDataViewsUpdate$ = new Subject<DataView[]>();
public resetToLastSavedState() {
public async asyncResetToLastSavedState() {
this.dispatch.resetToLastSavedInput({});
const {
explicitInput: { timeRange, refreshInterval },
@ -634,8 +705,8 @@ export class DashboardContainer
},
} = this.getState();
if (this.controlGroup) {
this.controlGroup.resetToLastSavedState();
if (this.controlGroupApi$.value) {
await this.controlGroupApi$.value.asyncResetUnsavedChanges();
}
// if we are using the unified search integration, we need to force reset the time picker.
@ -680,7 +751,6 @@ export class DashboardContainer
const initializeResult = await initializeDashboard({
creationOptions: this.creationOptions,
controlGroup: this.controlGroup,
untilDashboardReady,
loadDashboardReturn,
});
@ -695,9 +765,6 @@ export class DashboardContainer
omit(loadDashboardReturn?.dashboardInput, 'controlGroupInput')
);
this.dispatch.setManaged(loadDashboardReturn?.managed);
if (this.controlGroup) {
this.controlGroup.setSavedState(loadDashboardReturn.dashboardInput?.controlGroupInput);
}
this.dispatch.setAnimatePanelTransforms(false); // prevents panels from animating on navigate.
this.dispatch.setLastSavedId(newSavedObjectId);
this.setExpandedPanelId(undefined);
@ -721,7 +788,7 @@ export class DashboardContainer
*/
public setAllDataViews = (newDataViews: DataView[]) => {
this.allDataViews = newDataViews;
this.onDataViewsUpdate$.next(newDataViews);
(this.dataViews as BehaviorSubject<DataView[] | undefined>).next(newDataViews);
};
public getExpandedPanelId = () => {
@ -744,7 +811,6 @@ export class DashboardContainer
public clearOverlays = () => {
this.dispatch.setHasOverlays(false);
this.dispatch.setFocusedPanelId(undefined);
this.controlGroup?.closeAllFlyouts();
this.overlayRef?.close();
};
@ -849,6 +915,22 @@ export class DashboardContainer
};
};
public getSerializedStateForControlGroup = () => {
return {
rawState: this.controlGroupInput
? (this.controlGroupInput as ControlGroupSerializedState)
: ({
controlStyle: 'oneLine',
chainingSystem: 'HIERARCHICAL',
showApplySelections: false,
panelsJSON: '{}',
ignoreParentSettingsJSON:
'{"ignoreFilters":false,"ignoreQuery":false,"ignoreTimerange":false,"ignoreValidations":false}',
} as ControlGroupSerializedState),
references: getReferencesForControls(this.savedObjectReferences),
};
};
private restoredRuntimeState: UnsavedPanelState | undefined = undefined;
public setRuntimeStateForChild = (childId: string, state: object) => {
const runtimeState = this.restoredRuntimeState ?? {};
@ -859,6 +941,10 @@ export class DashboardContainer
return this.restoredRuntimeState?.[childId];
};
public getRuntimeStateForControlGroup = () => {
return this.getRuntimeStateForChild(PANELS_CONTROL_GROUP_KEY);
};
public removePanel(id: string) {
const {
embeddable: { reactEmbeddableRegistryHasKey },

View file

@ -6,12 +6,10 @@
* 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 { PersistableControlGroupInput } from '@kbn/controls-plugin/common';
import { childrenUnsavedChanges$ } from '@kbn/presentation-containers';
import { omit } from 'lodash';
import { AnyAction, Middleware } from 'redux';
import { combineLatest, debounceTime, Observable, of, startWith, switchMap } from 'rxjs';
import { combineLatest, debounceTime, skipWhile, startWith, switchMap } from 'rxjs';
import { DashboardContainer, DashboardCreationOptions } from '../..';
import { DashboardContainerInput } from '../../../../common';
import { CHANGE_CHECK_DEBOUNCE } from '../../../dashboard_constants';
@ -19,6 +17,7 @@ import { pluginServices } from '../../../services/plugin_services';
import { UnsavedPanelState } from '../../types';
import { dashboardContainerReducers } from '../dashboard_container_reducers';
import { isKeyEqualAsync, unsavedChangesDiffingFunctions } from './dashboard_diffing_functions';
import { PANELS_CONTROL_GROUP_KEY } from '../../../services/dashboard_backup/dashboard_backup_service';
/**
* An array of reducers which cannot cause unsaved changes. Unsaved changes only compares the explicit input
@ -113,8 +112,12 @@ export function startDiffingDashboardState(
combineLatest([
dashboardUnsavedChanges,
childrenUnsavedChanges$(this.children$),
this.controlGroup?.unsavedChanges ??
(of(undefined) as Observable<PersistableControlGroupInput | undefined>),
this.controlGroupApi$.pipe(
skipWhile((controlGroupApi) => !controlGroupApi),
switchMap((controlGroupApi) => {
return controlGroupApi!.unsavedChanges;
})
),
]).subscribe(([dashboardChanges, unsavedPanelState, controlGroupChanges]) => {
// calculate unsaved changes
const hasUnsavedChanges =
@ -127,11 +130,11 @@ export function startDiffingDashboardState(
// backup unsaved changes if configured to do so
if (creationOptions?.useSessionStorageIntegration) {
backupUnsavedChanges.bind(this)(
dashboardChanges,
unsavedPanelState ? unsavedPanelState : {},
controlGroupChanges
);
const reactEmbeddableChanges = unsavedPanelState ? { ...unsavedPanelState } : {};
if (controlGroupChanges) {
reactEmbeddableChanges[PANELS_CONTROL_GROUP_KEY] = controlGroupChanges;
}
backupUnsavedChanges.bind(this)(dashboardChanges, reactEmbeddableChanges);
}
})
);
@ -183,8 +186,7 @@ export async function getDashboardUnsavedChanges(
function backupUnsavedChanges(
this: DashboardContainer,
dashboardChanges: Partial<DashboardContainerInput>,
reactEmbeddableChanges: UnsavedPanelState,
controlGroupChanges: PersistableControlGroupInput | undefined
reactEmbeddableChanges: UnsavedPanelState
) {
const { dashboardBackup } = pluginServices.getServices();
const dashboardStateToBackup = omit(dashboardChanges, keysToOmitFromSessionStorage);
@ -194,7 +196,6 @@ function backupUnsavedChanges(
{
...dashboardStateToBackup,
panels: dashboardChanges.panels,
controlGroupInput: controlGroupChanges,
},
reactEmbeddableChanges
);

View file

@ -7,11 +7,11 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { SerializableControlGroupInput } from '@kbn/controls-plugin/common';
import type { ContainerOutput } from '@kbn/embeddable-plugin/public';
import type { ReduxEmbeddableState } from '@kbn/presentation-util-plugin/public';
import { SerializableRecord } from '@kbn/utility-types';
import { ControlGroupRuntimeState } from '@kbn/controls-plugin/public';
import type { DashboardContainerInput, DashboardOptions } from '../../common';
import { SavedDashboardPanel } from '../../common/content_management';
@ -126,7 +126,7 @@ export type DashboardLocatorParams = Partial<
panels?: Array<SavedDashboardPanel & SerializableRecord>; // used SerializableRecord here to force the GridData type to be read as serializable
/**
* Control group input
* Control group changes
*/
controlGroupInput?: SerializableControlGroupInput;
controlGroupState?: Partial<ControlGroupRuntimeState> & SerializableRecord; // used SerializableRecord here to force the GridData type to be read as serializable
};

View file

@ -16,7 +16,6 @@ import {
getContextProvider as getPresentationUtilContextProvider,
} from '@kbn/presentation-util-plugin/public';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
import { TopNavMenuBadgeProps, TopNavMenuProps } from '@kbn/navigation-plugin/public';
import {
EuiBreadcrumb,
@ -30,6 +29,7 @@ import {
import { MountPoint } from '@kbn/core/public';
import { getManagedContentBadge } from '@kbn/managed-content-badge';
import { FormattedMessage } from '@kbn/i18n-react';
import { useStateFromPublishingSubject } from '@kbn/presentation-publishing';
import {
getDashboardTitle,
leaveConfirmStrings,
@ -114,16 +114,8 @@ export function InternalDashboardTopNav({
const query = dashboard.select((state) => state.explicitInput.query);
const title = dashboard.select((state) => state.explicitInput.title);
// store data views in state & subscribe to dashboard data view changes.
const [allDataViews, setAllDataViews] = useState<DataView[]>([]);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
useEffect(() => {
setAllDataViews(dashboard.getAllDataViews());
const subscription = dashboard.onDataViewsUpdate$.subscribe((dataViews) =>
setAllDataViews(dataViews)
);
return () => subscription.unsubscribe();
}, [dashboard]);
const allDataViews = useStateFromPublishingSubject(dashboard.dataViews);
const dashboardTitle = useMemo(() => {
return getDashboardTitle(title, viewMode, !lastSavedId);
@ -412,7 +404,7 @@ export function InternalDashboardTopNav({
screenTitle={title}
useDefaultBehaviors={true}
savedQueryId={savedQueryId}
indexPatterns={allDataViews}
indexPatterns={allDataViews ?? []}
saveQueryMenuVisibility={allowSaveQuery ? 'allowed_by_app_privilege' : 'globally_managed'}
appName={LEGACY_DASHBOARD_APP_ID}
visible={viewMode !== ViewMode.PRINT}

View file

@ -10,6 +10,8 @@
import { EmbeddableInput, ViewMode } from '@kbn/embeddable-plugin/public';
import { mockedReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public/mocks';
import { ControlGroupApi } from '@kbn/controls-plugin/public';
import { BehaviorSubject } from 'rxjs';
import { DashboardContainerInput, DashboardPanelState } from '../common';
import { DashboardContainer } from './dashboard_container/embeddable/dashboard_container';
import { DashboardStart } from './plugin';
@ -73,6 +75,15 @@ export function setupIntersectionObserverMock({
});
}
export const mockControlGroupApi = {
untilInitialized: async () => {},
filters$: new BehaviorSubject(undefined),
query$: new BehaviorSubject(undefined),
timeslice$: new BehaviorSubject(undefined),
dataViews: new BehaviorSubject(undefined),
unsavedChanges: new BehaviorSubject(undefined),
} as unknown as ControlGroupApi;
export function buildMockDashboard({
overrides,
savedObjectId,
@ -90,6 +101,7 @@ export function buildMockDashboard({
undefined,
{ lastSavedInput: initialInput, lastSavedId: savedObjectId }
);
dashboardContainer?.setControlGroupApi(mockControlGroupApi);
return dashboardContainer;
}

View file

@ -24,6 +24,7 @@ import { backupServiceStrings } from '../../dashboard_container/_dashboard_conta
import { UnsavedPanelState } from '../../dashboard_container/types';
export const DASHBOARD_PANELS_UNSAVED_ID = 'unsavedDashboard';
export const PANELS_CONTROL_GROUP_KEY = 'controlGroup';
const DASHBOARD_PANELS_SESSION_KEY = 'dashboardPanels';
const DASHBOARD_VIEWMODE_LOCAL_KEY = 'dashboardViewMode';
@ -113,6 +114,7 @@ class DashboardBackupService implements DashboardBackupServiceType {
const panels = this.sessionStorage.get(DASHBOARD_PANELS_SESSION_KEY)?.[this.activeSpaceId]?.[
id
] as UnsavedPanelState | undefined;
return { dashboardState, panels };
} catch (e) {
this.notifications.toasts.addDanger({

View file

@ -57,8 +57,15 @@ export const dashboardContentManagementServiceFactory: DashboardContentManagemen
contentManagement,
savedObjectsTagging,
}),
saveDashboardState: ({ currentState, saveOptions, lastSavedId, panelReferences }) =>
saveDashboardState: ({
controlGroupReferences,
currentState,
saveOptions,
lastSavedId,
panelReferences,
}) =>
saveDashboardState({
controlGroupReferences,
data,
embeddable,
saveOptions,

View file

@ -14,7 +14,6 @@ import { Filter, Query } from '@kbn/es-query';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/public';
import { cleanFiltersForSerialize } from '@kbn/presentation-util-plugin/public';
import { rawControlGroupAttributesToControlGroupInput } from '@kbn/controls-plugin/common';
import { parseSearchSourceJSON, injectSearchSourceReferences } from '@kbn/data-plugin/public';
import {
@ -189,9 +188,7 @@ export const loadDashboardState = async ({
viewMode: ViewMode.VIEW, // dashboards loaded from saved object default to view mode. If it was edited recently, the view mode from session storage will override this.
tags: savedObjectsTagging.getTagIdsFromReferences?.(references) ?? [],
controlGroupInput:
attributes.controlGroupInput &&
rawControlGroupAttributesToControlGroupInput(attributes.controlGroupInput),
controlGroupInput: attributes.controlGroupInput,
version: convertNumberToDashboardVersion(version),
},

View file

@ -7,9 +7,6 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { ControlGroupInput } from '@kbn/controls-plugin/common';
import { controlGroupInputBuilder } from '@kbn/controls-plugin/public';
import { getSampleDashboardInput, getSampleDashboardPanel } from '../../../mocks';
import { DashboardEmbeddableService } from '../../embeddable/types';
import { SavedDashboardInput } from '../types';
@ -33,23 +30,6 @@ describe('Migrate dashboard input', () => {
panel3: getSampleDashboardPanel({ type: 'ultraDiscover', explicitInput: { id: 'panel3' } }),
panel4: getSampleDashboardPanel({ type: 'ultraDiscover', explicitInput: { id: 'panel4' } }),
};
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
controlGroupInputBuilder.addOptionsListControl(controlGroupInput, {
dataViewId: 'positions-remain-fixed',
title: 'Results can be mixed',
fieldName: 'theres-a-stasis',
width: 'medium',
grow: false,
});
controlGroupInputBuilder.addRangeSliderControl(controlGroupInput, {
dataViewId: 'an-object-set-in-motion',
title: 'The arbiter of time',
fieldName: 'unexpressed-emotion',
width: 'medium',
grow: false,
});
controlGroupInputBuilder.addTimeSliderControl(controlGroupInput);
dashboardInput.controlGroupInput = controlGroupInput;
const embeddableService: DashboardEmbeddableService = {
getEmbeddableFactory: jest.fn(() => ({
@ -63,11 +43,8 @@ describe('Migrate dashboard input', () => {
// migration run should be true because the runEmbeddableFactoryMigrations mock above returns true.
expect(result.anyMigrationRun).toBe(true);
expect(embeddableService.getEmbeddableFactory).toHaveBeenCalledTimes(7); // should be called 4 times for the panels, and 3 times for the controls
expect(embeddableService.getEmbeddableFactory).toHaveBeenCalledTimes(4); // should be called 4 times for the panels, and 3 times for the controls
expect(embeddableService.getEmbeddableFactory).toHaveBeenCalledWith('superLens');
expect(embeddableService.getEmbeddableFactory).toHaveBeenCalledWith('ultraDiscover');
expect(embeddableService.getEmbeddableFactory).toHaveBeenCalledWith('optionsListControl');
expect(embeddableService.getEmbeddableFactory).toHaveBeenCalledWith('rangeSliderControl');
expect(embeddableService.getEmbeddableFactory).toHaveBeenCalledWith('timeSlider');
});
});

View file

@ -7,7 +7,6 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { ControlGroupInput } from '@kbn/controls-plugin/common';
import {
EmbeddableFactoryNotFoundError,
runEmbeddableFactoryMigrations,
@ -32,28 +31,7 @@ export const migrateDashboardInput = (
} = pluginServices.getServices();
let anyMigrationRun = false;
if (!dashboardInput) return dashboardInput;
if (dashboardInput.controlGroupInput) {
/**
* If any Control Group migrations are required, we will need to start storing a Control Group Input version
* string in Dashboard Saved Objects and then running the whole Control Group input through the embeddable
* factory migrations here.
*/
// Migrate all of the Control children as well.
const migratedControls: ControlGroupInput['panels'] = {};
Object.entries(dashboardInput.controlGroupInput.panels).forEach(([id, panel]) => {
const factory = embeddable.getEmbeddableFactory(panel.type);
if (!factory) throw new EmbeddableFactoryNotFoundError(panel.type);
const { input: newInput, migrationRun: controlMigrationRun } = runEmbeddableFactoryMigrations(
panel.explicitInput,
factory
);
if (controlMigrationRun) anyMigrationRun = true;
panel.explicitInput = newInput as DashboardPanelState['explicitInput'];
migratedControls[id] = panel;
});
}
const migratedPanels: DashboardContainerInput['panels'] = {};
for (const [id, panel] of Object.entries(dashboardInput.panels)) {
// if the panel type is registered in the new embeddable system, we do not need to run migrations for it.

View file

@ -10,12 +10,6 @@
import { pick } from 'lodash';
import moment, { Moment } from 'moment';
import {
controlGroupInputToRawControlGroupAttributes,
generateNewControlIds,
getDefaultControlGroupInput,
persistableControlGroupInputIsEqual,
} from '@kbn/controls-plugin/common';
import { extractSearchSourceReferences, RefreshInterval } from '@kbn/data-plugin/public';
import { isFilterPinned } from '@kbn/es-query';
@ -30,24 +24,10 @@ import {
DashboardContentManagementRequiredServices,
SaveDashboardProps,
SaveDashboardReturn,
SavedDashboardInput,
} from '../types';
import { convertDashboardVersionToNumber } from './dashboard_versioning';
import { generateNewPanelIds } from '../../../../common/lib/dashboard_panel_converters';
export const serializeControlGroupInput = (
controlGroupInput: SavedDashboardInput['controlGroupInput']
) => {
// only save to saved object if control group is not default
if (
!controlGroupInput ||
persistableControlGroupInputIsEqual(controlGroupInput, getDefaultControlGroupInput())
) {
return undefined;
}
return controlGroupInputToRawControlGroupAttributes(controlGroupInput);
};
export const convertTimeToUTCString = (time?: string | Moment): undefined | string => {
if (moment(time).isValid()) {
return moment(time).utc().format('YYYY-MM-DDTHH:mm:ss.SSS[Z]');
@ -69,6 +49,7 @@ type SaveDashboardStateProps = SaveDashboardProps & {
};
export const saveDashboardState = async ({
controlGroupReferences,
data,
embeddable,
lastSavedId,
@ -101,9 +82,10 @@ export const saveDashboardState = async ({
syncCursor,
syncTooltips,
hidePanelTitles,
controlGroupInput,
} = currentState;
let { panels, controlGroupInput } = currentState;
let { panels } = currentState;
let prefixedPanelReferences = panelReferences;
if (saveOptions.saveAsCopy) {
const { panels: newPanels, references: newPanelReferences } = generateNewPanelIds(
@ -112,7 +94,10 @@ export const saveDashboardState = async ({
);
panels = newPanels;
prefixedPanelReferences = newPanelReferences;
controlGroupInput = generateNewControlIds(controlGroupInput);
//
// do not need to generate new ids for controls.
// ControlGroup Component is keyed on dashboard id so changing dashboard id mounts new ControlGroup Component.
//
}
/**
@ -160,7 +145,7 @@ export const saveDashboardState = async ({
const rawDashboardAttributes: DashboardAttributes = {
version: convertDashboardVersionToNumber(LATEST_DASHBOARD_CONTAINER_VERSION),
controlGroupInput: serializeControlGroupInput(controlGroupInput),
controlGroupInput,
kibanaSavedObjectMeta: { searchSourceJSON },
description: description ?? '',
refreshInterval,
@ -187,7 +172,11 @@ export const saveDashboardState = async ({
? savedObjectsTagging.updateTagsReferences(dashboardReferences, tags)
: dashboardReferences;
const allReferences = [...references, ...(prefixedPanelReferences ?? [])];
const allReferences = [
...references,
...(prefixedPanelReferences ?? []),
...(controlGroupReferences ?? []),
];
/**
* Save the saved object using the content management

View file

@ -8,11 +8,11 @@
*/
import type { Reference } from '@kbn/content-management-utils';
import { PersistableControlGroupInput } from '@kbn/controls-plugin/common';
import { SavedObjectSaveOpts } from '@kbn/saved-objects-plugin/public';
import { ControlGroupRuntimeState } from '@kbn/controls-plugin/public';
import { DashboardContainerInput } from '../../../common';
import { DashboardCrudTypes } from '../../../common/content_management';
import { DashboardAttributes, DashboardCrudTypes } from '../../../common/content_management';
import { DashboardStartDependencies } from '../../plugin';
import { DashboardBackupServiceType } from '../dashboard_backup/types';
import { DashboardDataService } from '../data/types';
@ -65,7 +65,17 @@ export interface LoadDashboardFromSavedObjectProps {
type DashboardResolveMeta = DashboardCrudTypes['GetOut']['meta'];
export type SavedDashboardInput = DashboardContainerInput & {
controlGroupInput?: PersistableControlGroupInput;
/**
* Serialized control group state.
* Contains state loaded from dashboard saved object
*/
controlGroupInput?: DashboardAttributes['controlGroupInput'] | undefined;
/**
* Runtime control group state.
* Contains state passed from dashboard locator
* Use runtime state when building input for portable dashboards
*/
controlGroupState?: Partial<ControlGroupRuntimeState>;
};
export interface LoadDashboardReturn {
@ -90,6 +100,7 @@ export interface LoadDashboardReturn {
export type SavedDashboardSaveOpts = SavedObjectSaveOpts & { saveAsCopy?: boolean };
export interface SaveDashboardProps {
controlGroupReferences?: Reference[];
currentState: SavedDashboardInput;
saveOptions: SavedDashboardSaveOpts;
panelReferences?: Reference[];

View file

@ -83,6 +83,9 @@ export abstract class Container<
const init$ = this.getInput$().pipe(
take(1),
mergeMap(async (currentInput) => {
if (settings?.untilContainerInitialized) {
await settings.untilContainerInitialized();
}
const initPromise = this.initializeChildEmbeddables(currentInput, settings);
if (awaitingInitialize) await initPromise;
})

View file

@ -38,6 +38,8 @@ export interface EmbeddableContainerSettings {
* Initialise children in the order specified. If an ID does not match it will be skipped and if a child is not included it will be initialized in the default order after the list of provided IDs.
*/
childIdInitializeOrder?: string[];
untilContainerInitialized?: () => Promise<void>;
}
export interface IContainer<

View file

@ -52,7 +52,11 @@ export const embeddableInputToSubject = <
subscription.add(
embeddable
.getInput$()
.pipe(distinctUntilKeyChanged(key))
.pipe(
distinctUntilKeyChanged(key, (prev, current) => {
return deepEqual(prev, current);
})
)
.subscribe(() => subject.next(embeddable.getInput()?.[key] as ValueType))
);
}

View file

@ -59,16 +59,19 @@ export const genericEmbeddableInputIsEqual = (
const {
title: currentTitle,
hidePanelTitles: currentHidePanelTitles,
enhancements: currentEnhancements,
...current
} = pick(currentInput as GenericEmbedableInputToCompare, genericInputKeysToCompare);
const {
title: lastTitle,
hidePanelTitles: lastHidePanelTitles,
enhancements: lastEnhancements,
...last
} = pick(lastInput as GenericEmbedableInputToCompare, genericInputKeysToCompare);
if (currentTitle !== lastTitle) return false;
if (Boolean(currentHidePanelTitles) !== Boolean(lastHidePanelTitles)) return false;
if (!fastIsEqual(currentEnhancements ?? {}, lastEnhancements ?? {})) return false;
if (!fastIsEqual(current, last)) return false;
return true;
};

View file

@ -7,7 +7,6 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL } from '@kbn/controls-plugin/common';
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../../ftr_provider_context';
@ -15,77 +14,25 @@ import { FtrProviderContext } from '../../../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const pieChart = getService('pieChart');
const elasticChart = getService('elasticChart');
const testSubjects = getService('testSubjects');
const dashboardAddPanel = getService('dashboardAddPanel');
const { dashboard, header, dashboardControls, timePicker } = getPageObjects([
const { dashboard, header, dashboardControls } = getPageObjects([
'dashboardControls',
'timePicker',
'dashboard',
'header',
]);
describe('Dashboard control group apply button', () => {
let controlIds: string[];
const optionsListId = '41827e70-5285-4d44-8375-4c498449b9a7';
const rangeSliderId = '515e7b9f-4f1b-4a06-beec-763810e4951a';
before(async () => {
await dashboard.navigateToApp();
await dashboard.gotoDashboardLandingPage();
await dashboard.clickNewDashboard();
await timePicker.setDefaultDataRange();
await dashboard.loadSavedDashboard('Test Control Group Apply Button');
await dashboard.switchToEditMode();
await elasticChart.setNewChartUiDebugFlag();
await dashboardAddPanel.addVisualization('Rendering-Test:-animal-sounds-pie');
// save the dashboard before adding controls
await dashboard.saveDashboard('Test Control Group Apply Button', {
exitFromEditMode: false,
saveAsNew: true,
});
await header.waitUntilLoadingHasFinished();
await dashboard.waitForRenderComplete();
await dashboard.expectMissingUnsavedChangesBadge();
// populate an initial set of controls and get their ids.
await dashboardControls.createControl({
controlType: OPTIONS_LIST_CONTROL,
dataViewTitle: 'animals-*',
fieldName: 'animal.keyword',
title: 'Animal',
});
await dashboardControls.createControl({
controlType: RANGE_SLIDER_CONTROL,
dataViewTitle: 'animals-*',
fieldName: 'weightLbs',
title: 'Animal Name',
});
await dashboardControls.createTimeSliderControl();
// wait for all controls to finish loading before saving
controlIds = await dashboardControls.getAllControlIds();
await dashboardControls.optionsListWaitForLoading(controlIds[0]);
await dashboardControls.rangeSliderWaitForLoading(controlIds[1]);
// re-save the dashboard
await dashboard.clickQuickSave();
await header.waitUntilLoadingHasFinished();
await dashboard.waitForRenderComplete();
await dashboard.expectMissingUnsavedChangesBadge();
});
it('able to set apply button setting', async () => {
await dashboardControls.updateShowApplyButtonSetting(true);
await testSubjects.existOrFail('controlGroup--applyFiltersButton');
await dashboard.expectUnsavedChangesBadge();
await dashboard.clickQuickSave();
await header.waitUntilLoadingHasFinished();
await dashboard.expectMissingUnsavedChangesBadge();
});
it('renabling auto-apply forces filters to be published', async () => {
const optionsListId = controlIds[0];
await dashboardControls.verifyApplyButtonEnabled(false);
await dashboardControls.optionsListOpenPopover(optionsListId);
await dashboardControls.optionsListPopoverSelectOption('cat');
await dashboardControls.optionsListEnsurePopoverIsClosed(optionsListId);
@ -102,14 +49,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
describe('options list selections', () => {
let optionsListId: string;
before(async () => {
optionsListId = controlIds[0];
});
it('making selection enables apply button', async () => {
await dashboardControls.verifyApplyButtonEnabled(false);
await dashboardControls.optionsListOpenPopover(optionsListId);
await dashboardControls.optionsListPopoverSelectOption('cat');
await dashboardControls.optionsListEnsurePopoverIsClosed(optionsListId);
@ -118,7 +58,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
it('waits to apply filters until button is pressed', async () => {
await dashboard.expectMissingUnsavedChangesBadge();
expect(await pieChart.getPieSliceCount()).to.be(5);
await dashboardControls.clickApplyButton();
@ -140,27 +79,19 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await header.waitUntilLoadingHasFinished();
await dashboard.waitForRenderComplete();
await dashboard.expectMissingUnsavedChangesBadge();
expect(await pieChart.getPieSliceCount()).to.be(5);
await dashboardControls.verifyApplyButtonEnabled(false);
expect(await dashboardControls.optionsListGetSelectionsString(optionsListId)).to.be('Any');
});
});
describe('range slider selections', () => {
let rangeSliderId: string;
before(async () => {
rangeSliderId = controlIds[1];
});
it('making selection enables apply button', async () => {
await dashboardControls.verifyApplyButtonEnabled(false);
await dashboardControls.rangeSliderSetUpperBound(rangeSliderId, '30');
await dashboardControls.verifyApplyButtonEnabled();
});
it('waits to apply filters until apply button is pressed', async () => {
await dashboard.expectMissingUnsavedChangesBadge();
expect(await pieChart.getPieSliceCount()).to.be(5);
await dashboardControls.clickApplyButton();
@ -181,8 +112,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await header.waitUntilLoadingHasFinished();
await dashboard.waitForRenderComplete();
await dashboard.expectMissingUnsavedChangesBadge();
expect(await pieChart.getPieSliceCount()).to.be(5);
await dashboardControls.verifyApplyButtonEnabled(false);
expect(
await dashboardControls.rangeSliderGetLowerBoundAttribute(rangeSliderId, 'value')
).to.be('');
@ -200,7 +131,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
it('making selection enables apply button', async () => {
await dashboardControls.verifyApplyButtonEnabled(false);
await dashboardControls.gotoNextTimeSlice();
await dashboardControls.gotoNextTimeSlice(); // go to an empty timeslice
await header.waitUntilLoadingHasFinished();
@ -208,7 +138,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
it('waits to apply timeslice until apply button is pressed', async () => {
await dashboard.expectMissingUnsavedChangesBadge();
expect(await pieChart.getPieSliceCount()).to.be(5);
await dashboardControls.clickApplyButton();
@ -227,8 +156,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await header.waitUntilLoadingHasFinished();
await dashboard.waitForRenderComplete();
await dashboard.expectMissingUnsavedChangesBadge();
expect(await pieChart.getPieSliceCount()).to.be(5);
await dashboardControls.verifyApplyButtonEnabled(false);
const valueNow = await dashboardControls.getTimeSliceFromTimeSlider();
expect(valueNow).to.equal(valueBefore);
});

View file

@ -7,7 +7,6 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL } from '@kbn/controls-plugin/common';
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../../ftr_provider_context';
@ -18,17 +17,29 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const kibanaServer = getService('kibanaServer');
const filterBar = getService('filterBar');
const testSubjects = getService('testSubjects');
const dashboardAddPanel = getService('dashboardAddPanel');
const { common, dashboard, dashboardControls } = getPageObjects([
const { dashboard, dashboardControls } = getPageObjects([
'dashboardControls',
'dashboard',
'console',
'common',
'header',
]);
describe('Dashboard control group with multiple data views', () => {
let controlIds: string[];
// Controls from flights data view
const carrierControlId = '265b6a28-9ccb-44ae-83c9-3d7a7cac1961';
const ticketPriceControlId = 'ed2b93e2-da37-482b-ae43-586a41cc2399';
// Controls from logstash-* data view
const osControlId = '5e1b146b-8a8b-4117-9218-c4aeaee7bc9a';
const bytesControlId = 'c4760951-e793-45d5-a6b7-c72c145af7f9';
async function waitForAllConrolsLoading() {
await Promise.all([
dashboardControls.optionsListWaitForLoading(carrierControlId),
dashboardControls.rangeSliderWaitForLoading(ticketPriceControlId),
dashboardControls.optionsListWaitForLoading(osControlId),
dashboardControls.rangeSliderWaitForLoading(bytesControlId),
]);
}
before(async () => {
await security.testUser.setRoles(['kibana_admin', 'kibana_sample_admin']);
@ -40,50 +51,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await kibanaServer.importExport.load(
'test/functional/fixtures/kbn_archiver/kibana_sample_data_flights_index_pattern'
);
await kibanaServer.importExport.load(
'test/functional/fixtures/kbn_archiver/dashboard/current/multi_data_view_kibana'
);
await kibanaServer.uiSettings.replace({
defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c',
'courier:ignoreFilterIfFieldNotInIndex': true,
});
await common.setTime({
from: 'Apr 10, 2018 @ 00:00:00.000',
to: 'Nov 15, 2018 @ 00:00:00.000',
});
await dashboard.navigateToApp();
await dashboard.clickNewDashboard();
await dashboardControls.createControl({
controlType: OPTIONS_LIST_CONTROL,
dataViewTitle: 'kibana_sample_data_flights',
fieldName: 'Carrier',
title: 'Carrier',
});
await dashboardControls.createControl({
controlType: RANGE_SLIDER_CONTROL,
dataViewTitle: 'kibana_sample_data_flights',
fieldName: 'AvgTicketPrice',
title: 'Average Ticket Price',
});
await dashboardControls.createControl({
controlType: OPTIONS_LIST_CONTROL,
dataViewTitle: 'logstash-*',
fieldName: 'machine.os.raw',
title: 'Operating System',
});
await dashboardControls.createControl({
controlType: RANGE_SLIDER_CONTROL,
dataViewTitle: 'logstash-*',
fieldName: 'bytes',
title: 'Bytes',
});
await dashboardAddPanel.addSavedSearch('logstash hits');
controlIds = await dashboardControls.getAllControlIds();
});
after(async () => {
@ -94,96 +67,169 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await kibanaServer.importExport.unload(
'test/functional/fixtures/kbn_archiver/kibana_sample_data_flights_index_pattern'
);
await kibanaServer.importExport.unload(
'test/functional/fixtures/kbn_archiver/dashboard/current/multi_data_view_kibana'
);
await security.testUser.restoreDefaults();
await kibanaServer.uiSettings.unset('courier:ignoreFilterIfFieldNotInIndex');
await kibanaServer.uiSettings.unset('defaultIndex');
});
it('ignores global filters on controls using a data view without the filter field', async () => {
await filterBar.addFilter({ field: 'Carrier', operation: 'exists' });
describe('courier:ignoreFilterIfFieldNotInIndex enabled', () => {
before(async () => {
await kibanaServer.uiSettings.replace({
'courier:ignoreFilterIfFieldNotInIndex': true,
});
await dashboardControls.optionsListOpenPopover(controlIds[0]);
expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('4');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]);
await dashboard.navigateToApp();
await dashboard.loadSavedDashboard('Test Control Group With Multiple Data Views');
});
await dashboardControls.validateRange('placeholder', controlIds[1], '100', '1200');
after(async () => {
await kibanaServer.uiSettings.unset('courier:ignoreFilterIfFieldNotInIndex');
});
await dashboardControls.optionsListOpenPopover(controlIds[2]);
expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('5');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[2]);
describe('global filters', () => {
before(async () => {
await filterBar.addFilter({
field: 'Carrier',
operation: 'is',
value: 'Kibana Airlines',
});
await waitForAllConrolsLoading();
});
await dashboardControls.validateRange('placeholder', controlIds[3], '0', '19979');
after(async () => {
await dashboard.clickDiscardChanges();
});
it('applies global filters to controls with data view of filter field', async () => {
await dashboardControls.optionsListOpenPopover(carrierControlId);
expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('1');
await dashboardControls.optionsListEnsurePopoverIsClosed(carrierControlId);
await dashboardControls.validateRange('placeholder', ticketPriceControlId, '100', '1196');
});
it('ignores global filters to controls without data view of filter field', async () => {
await dashboardControls.optionsListOpenPopover(osControlId);
expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('5');
await dashboardControls.optionsListEnsurePopoverIsClosed(osControlId);
await dashboardControls.validateRange('placeholder', bytesControlId, '0', '19979');
});
});
describe('control filters', () => {
before(async () => {
await dashboardControls.optionsListOpenPopover(carrierControlId);
await dashboardControls.optionsListPopoverSelectOption('Kibana Airlines');
await dashboardControls.optionsListEnsurePopoverIsClosed(carrierControlId);
await waitForAllConrolsLoading();
});
after(async () => {
await dashboard.clickDiscardChanges();
});
it('applies control filters to controls with data view of control filter', async () => {
await dashboardControls.validateRange('placeholder', ticketPriceControlId, '100', '1196');
});
it('ignores control filters on controls without data view of control filter', async () => {
await dashboardControls.optionsListOpenPopover(osControlId);
expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('5');
await dashboardControls.optionsListEnsurePopoverIsClosed(osControlId);
await dashboardControls.validateRange('placeholder', bytesControlId, '0', '19979');
});
it('ignores control filters on panels without data view of control filter', async () => {
const logstashSavedSearchPanel = await testSubjects.find('embeddedSavedSearchDocTable');
expect(
await (
await logstashSavedSearchPanel.findByCssSelector('[data-document-number]')
).getAttribute('data-document-number')
).to.not.be('0');
});
});
});
it('ignores controls on other controls and panels using a data view without the control field by default', async () => {
await filterBar.removeFilter('Carrier');
await dashboardControls.optionsListOpenPopover(controlIds[0]);
await dashboardControls.optionsListPopoverSelectOption('Kibana Airlines');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]);
describe('courier:ignoreFilterIfFieldNotInIndex disabled', () => {
before(async () => {
await kibanaServer.uiSettings.replace({
'courier:ignoreFilterIfFieldNotInIndex': false,
});
await dashboardControls.validateRange('placeholder', controlIds[1], '100', '1196');
await dashboard.navigateToApp();
await dashboard.loadSavedDashboard('Test Control Group With Multiple Data Views');
});
await dashboardControls.optionsListOpenPopover(controlIds[2]);
expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('5');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[2]);
after(async () => {
await kibanaServer.uiSettings.unset('courier:ignoreFilterIfFieldNotInIndex');
});
await dashboardControls.validateRange('placeholder', controlIds[3], '0', '19979');
describe('global filters', () => {
before(async () => {
await filterBar.addFilter({
field: 'Carrier',
operation: 'is',
value: 'Kibana Airlines',
});
await waitForAllConrolsLoading();
});
const logstashSavedSearchPanel = await testSubjects.find('embeddedSavedSearchDocTable');
expect(
await (
await logstashSavedSearchPanel.findByCssSelector('[data-document-number]')
).getAttribute('data-document-number')
).to.not.be('0');
});
after(async () => {
await dashboard.clickDiscardChanges();
});
it('applies global filters on controls using data view a without the filter field', async () => {
await kibanaServer.uiSettings.update({ 'courier:ignoreFilterIfFieldNotInIndex': false });
await common.navigateToApp('dashboard');
await testSubjects.click('edit-unsaved-New-Dashboard');
await filterBar.addFilter({ field: 'Carrier', operation: 'exists' });
it('applies global filters to controls without data view of filter field', async () => {
await dashboardControls.optionsListOpenPopover(osControlId);
expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('0');
await dashboardControls.optionsListEnsurePopoverIsClosed(osControlId);
await Promise.all([
dashboardControls.optionsListWaitForLoading(controlIds[0]),
dashboardControls.rangeSliderWaitForLoading(controlIds[1]),
dashboardControls.optionsListWaitForLoading(controlIds[2]),
dashboardControls.rangeSliderWaitForLoading(controlIds[3]),
]);
await dashboardControls.validateRange(
'placeholder',
bytesControlId,
'-Infinity',
'Infinity'
);
});
});
await dashboardControls.clearControlSelections(controlIds[0]);
await dashboardControls.optionsListOpenPopover(controlIds[0]);
expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('4');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]);
describe('control filters', () => {
before(async () => {
await dashboardControls.optionsListOpenPopover(carrierControlId);
await dashboardControls.optionsListPopoverSelectOption('Kibana Airlines');
await dashboardControls.optionsListEnsurePopoverIsClosed(carrierControlId);
await waitForAllConrolsLoading();
});
await dashboardControls.validateRange('placeholder', controlIds[1], '100', '1200');
after(async () => {
await dashboard.clickDiscardChanges();
});
await dashboardControls.optionsListOpenPopover(controlIds[2]);
expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('0');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[2]);
it('applies control filters on controls without data view of control filter', async () => {
await dashboardControls.optionsListOpenPopover(osControlId);
expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('0');
await dashboardControls.optionsListEnsurePopoverIsClosed(osControlId);
await dashboardControls.validateRange('placeholder', controlIds[3], '0', '0');
});
await dashboardControls.validateRange(
'placeholder',
bytesControlId,
'-Infinity',
'Infinity'
);
});
it('applies global filters on controls using a data view without the filter field', async () => {
await filterBar.removeFilter('Carrier');
await dashboardControls.optionsListOpenPopover(controlIds[0]);
await dashboardControls.optionsListPopoverSelectOption('Kibana Airlines');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]);
await dashboardControls.validateRange('placeholder', controlIds[1], '100', '1196');
await dashboardControls.optionsListOpenPopover(controlIds[2]);
expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('0');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[2]);
await dashboardControls.validateRange('placeholder', controlIds[3], '0', '0');
const logstashSavedSearchPanel = await testSubjects.find('embeddedSavedSearchDocTable');
expect(
await (
await logstashSavedSearchPanel.findByCssSelector('[data-document-number]')
).getAttribute('data-document-number')
).to.be('0');
it('applies control filters on panels without data view of control filter', async () => {
const logstashSavedSearchPanel = await testSubjects.find('embeddedSavedSearchDocTable');
expect(
await (
await logstashSavedSearchPanel.findByCssSelector('[data-document-number]')
).getAttribute('data-document-number')
).to.be('0');
});
});
});
});
}

View file

@ -36,15 +36,17 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const replaceWithOptionsList = async (controlId: string, field: string) => {
await changeFieldType(controlId, field, OPTIONS_LIST_CONTROL);
await testSubjects.waitForEnabled(`optionsList-control-${controlId}`);
await dashboardControls.verifyControlType(controlId, 'optionsList-control');
const newControlId: string = (await dashboardControls.getAllControlIds())[0];
await testSubjects.waitForEnabled(`optionsList-control-${newControlId}`);
await dashboardControls.verifyControlType(newControlId, 'optionsList-control');
};
const replaceWithRangeSlider = async (controlId: string, field: string) => {
await changeFieldType(controlId, field, RANGE_SLIDER_CONTROL);
await retry.try(async () => {
await dashboardControls.rangeSliderWaitForLoading(controlId);
await dashboardControls.verifyControlType(controlId, 'range-slider-control');
const newControlId: string = (await dashboardControls.getAllControlIds())[0];
await dashboardControls.rangeSliderWaitForLoading(newControlId);
await dashboardControls.verifyControlType(newControlId, 'range-slider-control');
});
};
@ -69,7 +71,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
describe('Replace options list', () => {
beforeEach(async () => {
await dashboardControls.clearAllControls();
await dashboardControls.createControl({
controlType: OPTIONS_LIST_CONTROL,
dataViewTitle: 'animals-*',
@ -79,7 +80,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
afterEach(async () => {
await dashboard.clearUnsavedChanges();
await dashboardControls.clearAllControls();
});
it('with range slider - default title', async () => {
@ -101,7 +102,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
describe('Replace range slider', () => {
beforeEach(async () => {
await dashboardControls.clearAllControls();
await dashboardControls.createControl({
controlType: RANGE_SLIDER_CONTROL,
dataViewTitle: 'animals-*',
@ -112,7 +112,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
afterEach(async () => {
await dashboard.clearUnsavedChanges();
await dashboardControls.clearAllControls();
});
it('with options list - default title', async () => {

View file

@ -18,11 +18,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const { dashboardControls, dashboard, header } = getPageObjects([
'dashboardControls',
'timePicker',
'dashboard',
'settings',
'console',
'common',
'header',
]);
@ -53,6 +49,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
after(async () => {
await dashboardControls.optionsListEnsurePopoverIsClosed(controlId);
await dashboard.clickDiscardChanges();
});
it('sort alphabetically - descending', async () => {
@ -134,12 +131,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
for (let i = 0; i < sortedSuggestions.length - 1; i++) {
expect(sortedSuggestions[i]).to.be.lessThan(sortedSuggestions[i + 1]);
}
// revert to the old field name to keep state consistent for other tests
await dashboardControls.editExistingControl(controlId);
await dashboardControls.controlsEditorSetfield('sound.keyword');
await dashboardControls.optionsListSetAdditionalSettings({ searchTechnique: 'prefix' });
await dashboardControls.controlEditorSave();
});
});

View file

@ -10,7 +10,6 @@
import { pick } from 'lodash';
import expect from '@kbn/expect';
import { OPTIONS_LIST_CONTROL } from '@kbn/controls-plugin/common';
import { FtrProviderContext } from '../../../../ftr_provider_context';
import { OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS } from '../../../../page_objects/dashboard_page_controls';
@ -19,8 +18,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const queryBar = getService('queryBar');
const pieChart = getService('pieChart');
const filterBar = getService('filterBar');
const dashboardAddPanel = getService('dashboardAddPanel');
const dashboardPanelActions = getService('dashboardPanelActions');
const { dashboardControls, dashboard, header } = getPageObjects([
'dashboardControls',
@ -33,41 +30,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
]);
describe('Dashboard options list validation', () => {
let controlId: string;
const controlId = 'cd881630-fd28-4e9c-aec5-ae9711d48369';
before(async () => {
await dashboard.loadSavedDashboard('Test Options List Validation');
await dashboard.ensureDashboardIsInEditMode();
await dashboardControls.createControl({
controlType: OPTIONS_LIST_CONTROL,
dataViewTitle: 'animals-*',
fieldName: 'sound.keyword',
title: 'Animal Sounds',
});
controlId = (await dashboardControls.getAllControlIds())[0];
await dashboardAddPanel.addVisualization('Rendering-Test:-animal-sounds-pie');
await dashboard.clickQuickSave();
await header.waitUntilLoadingHasFinished();
});
after(async () => {
await filterBar.removeAllFilters();
await dashboardControls.deleteAllControls();
await dashboardPanelActions.removePanelByTitle('Rendering Test: animal sounds pie');
await dashboard.clickQuickSave();
});
describe('Options List dashboard validation', () => {
before(async () => {
await dashboardControls.optionsListOpenPopover(controlId);
await dashboardControls.optionsListPopoverSelectOption('meow');
await dashboardControls.optionsListPopoverSelectOption('bark');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlId);
});
after(async () => {
await dashboardControls.clearControlSelections(controlId);
// Instead of reset, filter must be manually deleted to avoid
// https://github.com/elastic/kibana/issues/191675
await filterBar.removeAllFilters();
await queryBar.clickQuerySubmitButton();
});
it('Can mark selections invalid with Query', async () => {
@ -119,13 +93,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
describe('Options List dashboard no validation', () => {
before(async () => {
await dashboardControls.optionsListOpenPopover(controlId);
await dashboardControls.optionsListPopoverSelectOption('meow');
await dashboardControls.optionsListPopoverSelectOption('bark');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlId);
await dashboardControls.updateValidationSetting(false);
});
after(async () => {
await dashboard.clickDiscardChanges();
});
it('Does not mark selections invalid with Query', async () => {
await queryBar.setQuery('NOT animal.keyword : "dog" ');
await queryBar.submitQuery();

View file

@ -3225,3 +3225,108 @@
"coreMigrationVersion": "8.8.0",
"typeMigrationVersion": "10.2.0"
}
{
"id": "55bc0b4b-a50f-46bf-b154-dd156067eea5",
"type": "dashboard",
"namespaces": [
"default"
],
"updated_at": "2024-08-26T13:30:47.442Z",
"created_at": "2024-08-26T13:29:23.580Z",
"version": "WzEwNiwxXQ==",
"attributes": {
"version": 2,
"controlGroupInput": {
"chainingSystem": "HIERARCHICAL",
"controlStyle": "oneLine",
"showApplySelections": true,
"ignoreParentSettingsJSON": "{\"ignoreFilters\":false,\"ignoreQuery\":false,\"ignoreTimerange\":false,\"ignoreValidations\":false}",
"panelsJSON": "{\"41827e70-5285-4d44-8375-4c498449b9a7\":{\"grow\":true,\"order\":0,\"type\":\"optionsListControl\",\"width\":\"medium\",\"explicitInput\":{\"dataViewId\":\"a0f483a0-3dc9-11e8-8660-4d65aa086b3c\",\"fieldName\":\"animal.keyword\",\"searchTechnique\":\"prefix\",\"selectedOptions\":[],\"sort\":{\"by\":\"_count\",\"direction\":\"desc\"}}},\"515e7b9f-4f1b-4a06-beec-763810e4951a\":{\"grow\":true,\"order\":1,\"type\":\"rangeSliderControl\",\"width\":\"medium\",\"explicitInput\":{\"dataViewId\":\"a0f483a0-3dc9-11e8-8660-4d65aa086b3c\",\"fieldName\":\"weightLbs\",\"step\":1}},\"b33b103a-84e2-4c2f-b4bd-be143dbd7e8a\":{\"grow\":true,\"order\":2,\"type\":\"timeSlider\",\"width\":\"large\",\"explicitInput\":{}}}"
},
"kibanaSavedObjectMeta": {
"searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}"
},
"description": "",
"refreshInterval": {
"pause": true,
"value": 60000
},
"timeRestore": true,
"optionsJSON": "{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}",
"panelsJSON": "[{\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"ffc13252-56b4-4e3f-847e-61373fa0be86\"},\"panelIndex\":\"ffc13252-56b4-4e3f-847e-61373fa0be86\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_ffc13252-56b4-4e3f-847e-61373fa0be86\"}]",
"timeFrom": "2018-01-01T00:00:00.000Z",
"title": "Test Control Group Apply Button",
"timeTo": "2018-04-13T00:00:00.000Z"
},
"references": [
{
"name": "ffc13252-56b4-4e3f-847e-61373fa0be86:panel_ffc13252-56b4-4e3f-847e-61373fa0be86",
"type": "visualization",
"id": "50643b60-3dd3-11e8-b2b9-5d5dc1715159"
},
{
"name": "controlGroup_41827e70-5285-4d44-8375-4c498449b9a7:optionsListControlDataView",
"type": "index-pattern",
"id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c"
},
{
"name": "controlGroup_515e7b9f-4f1b-4a06-beec-763810e4951a:rangeSliderControlDataView",
"type": "index-pattern",
"id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c"
}
],
"managed": false,
"coreMigrationVersion": "8.8.0",
"typeMigrationVersion": "10.2.0"
}
{
"id": "0b61857d-b7d3-4b4b-aa6b-773808361cd6",
"type": "dashboard",
"namespaces": [
"default"
],
"updated_at": "2024-08-26T15:23:33.053Z",
"created_at": "2024-08-26T15:22:39.194Z",
"version": "WzE1MTksMV0=",
"attributes": {
"version": 2,
"controlGroupInput": {
"chainingSystem": "HIERARCHICAL",
"controlStyle": "oneLine",
"showApplySelections": false,
"ignoreParentSettingsJSON": "{\"ignoreFilters\":false,\"ignoreQuery\":false,\"ignoreTimerange\":false,\"ignoreValidations\":false}",
"panelsJSON": "{\"cd881630-fd28-4e9c-aec5-ae9711d48369\":{\"grow\":true,\"order\":0,\"type\":\"optionsListControl\",\"width\":\"medium\",\"explicitInput\":{\"dataViewId\":\"a0f483a0-3dc9-11e8-8660-4d65aa086b3c\",\"fieldName\":\"sound.keyword\",\"searchTechnique\":\"prefix\",\"selectedOptions\":[\"meow\",\"bark\"],\"sort\":{\"by\":\"_count\",\"direction\":\"desc\"}}}}"
},
"kibanaSavedObjectMeta": {
"searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}"
},
"description": "",
"refreshInterval": {
"pause": true,
"value": 60000
},
"timeRestore": true,
"optionsJSON": "{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}",
"panelsJSON": "[{\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"12415efc-008a-4f02-bad4-5c1f0d9ba1c6\"},\"panelIndex\":\"12415efc-008a-4f02-bad4-5c1f0d9ba1c6\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_12415efc-008a-4f02-bad4-5c1f0d9ba1c6\"}]",
"timeFrom": "2018-01-01T00:00:00.000Z",
"title": "Test Options List Validation",
"timeTo": "2018-04-13T00:00:00.000Z"
},
"references": [
{
"name": "12415efc-008a-4f02-bad4-5c1f0d9ba1c6:panel_12415efc-008a-4f02-bad4-5c1f0d9ba1c6",
"type": "visualization",
"id": "50643b60-3dd3-11e8-b2b9-5d5dc1715159"
},
{
"name": "controlGroup_cd881630-fd28-4e9c-aec5-ae9711d48369:optionsListControlDataView",
"type": "index-pattern",
"id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c"
}
],
"managed": false,
"coreMigrationVersion": "8.8.0",
"typeMigrationVersion": "10.2.0"
}

View file

@ -0,0 +1,64 @@
{
"id": "2af8906f-143b-4152-9f74-4994fb9c7b3e",
"type": "dashboard",
"namespaces": [
"default"
],
"updated_at": "2024-08-27T16:43:33.847Z",
"created_at": "2024-08-27T16:43:33.847Z",
"version": "WzIwNSwxXQ==",
"attributes": {
"version": 2,
"controlGroupInput": {
"chainingSystem": "HIERARCHICAL",
"controlStyle": "oneLine",
"showApplySelections": false,
"ignoreParentSettingsJSON": "{\"ignoreFilters\":false,\"ignoreQuery\":false,\"ignoreTimerange\":false,\"ignoreValidations\":false}",
"panelsJSON": "{\"265b6a28-9ccb-44ae-83c9-3d7a7cac1961\":{\"grow\":true,\"order\":0,\"type\":\"optionsListControl\",\"width\":\"medium\",\"explicitInput\":{\"dataViewId\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"fieldName\":\"Carrier\",\"searchTechnique\":\"prefix\",\"selectedOptions\":[],\"sort\":{\"by\":\"_count\",\"direction\":\"desc\"}}},\"ed2b93e2-da37-482b-ae43-586a41cc2399\":{\"grow\":true,\"order\":1,\"type\":\"rangeSliderControl\",\"width\":\"medium\",\"explicitInput\":{\"dataViewId\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"fieldName\":\"AvgTicketPrice\",\"title\":\"Average Ticket Price\",\"step\":1}},\"5e1b146b-8a8b-4117-9218-c4aeaee7bc9a\":{\"grow\":true,\"order\":2,\"type\":\"optionsListControl\",\"width\":\"medium\",\"explicitInput\":{\"dataViewId\":\"0bf35f60-3dc9-11e8-8660-4d65aa086b3c\",\"fieldName\":\"machine.os.raw\",\"title\":\"Operating System\",\"searchTechnique\":\"prefix\",\"selectedOptions\":[],\"sort\":{\"by\":\"_count\",\"direction\":\"desc\"}}},\"c4760951-e793-45d5-a6b7-c72c145af7f9\":{\"grow\":true,\"order\":3,\"type\":\"rangeSliderControl\",\"width\":\"medium\",\"explicitInput\":{\"dataViewId\":\"0bf35f60-3dc9-11e8-8660-4d65aa086b3c\",\"fieldName\":\"bytes\",\"title\":\"Bytes\",\"step\":1}}}"
},
"kibanaSavedObjectMeta": {
"searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}"
},
"description": "",
"refreshInterval": {
"pause": true,
"value": 60000
},
"timeRestore": true,
"optionsJSON": "{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}",
"panelsJSON": "[{\"type\":\"search\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"d75a68e9-67d9-4bed-9dba-85490d3eec37\"},\"panelIndex\":\"d75a68e9-67d9-4bed-9dba-85490d3eec37\",\"embeddableConfig\":{\"description\":\"\",\"enhancements\":{}},\"title\":\"logstash hits\",\"panelRefName\":\"panel_d75a68e9-67d9-4bed-9dba-85490d3eec37\"}]",
"timeFrom": "2018-04-10T00:00:00.000Z",
"title": "Test Control Group With Multiple Data Views",
"timeTo": "2018-11-15T00:00:00.000Z"
},
"references": [
{
"name": "d75a68e9-67d9-4bed-9dba-85490d3eec37:panel_d75a68e9-67d9-4bed-9dba-85490d3eec37",
"type": "search",
"id": "2b9247e0-6458-11ed-9957-e76caeeb9f75"
},
{
"name": "controlGroup_265b6a28-9ccb-44ae-83c9-3d7a7cac1961:optionsListControlDataView",
"type": "index-pattern",
"id": "d3d7af60-4c81-11e8-b3d7-01146121b73d"
},
{
"name": "controlGroup_ed2b93e2-da37-482b-ae43-586a41cc2399:rangeSliderControlDataView",
"type": "index-pattern",
"id": "d3d7af60-4c81-11e8-b3d7-01146121b73d"
},
{
"name": "controlGroup_5e1b146b-8a8b-4117-9218-c4aeaee7bc9a:optionsListControlDataView",
"type": "index-pattern",
"id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c"
},
{
"name": "controlGroup_c4760951-e793-45d5-a6b7-c72c145af7f9:rangeSliderControlDataView",
"type": "index-pattern",
"id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c"
}
],
"managed": false,
"coreMigrationVersion": "8.8.0",
"typeMigrationVersion": "10.2.0"
}

View file

@ -476,7 +476,11 @@ export class DashboardPageControls extends FtrService {
await this.optionsListWaitForLoading(controlId);
if (!skipOpen) await this.optionsListOpenPopover(controlId);
await this.retry.try(async () => {
expect(await this.optionsListPopoverGetAvailableOptions()).to.eql(expectation);
const availableOptions = await this.optionsListPopoverGetAvailableOptions();
expect(availableOptions.suggestions).to.eql(expectation.suggestions);
expect(availableOptions.invalidSelections.sort()).to.eql(
expectation.invalidSelections.sort()
);
});
if (await this.testSubjects.exists('optionsList-cardinality-label')) {
expect(await this.optionsListGetCardinalityValue()).to.be(
@ -497,7 +501,9 @@ export class DashboardPageControls extends FtrService {
public async optionsListPopoverSearchForOption(search: string) {
this.log.debug(`searching for ${search} in options list`);
await this.optionsListPopoverAssertOpen();
await this.testSubjects.setValue(`optionsList-control-search-input`, search);
await this.testSubjects.setValue(`optionsList-control-search-input`, search, {
typeCharByChar: true,
});
await this.optionsListPopoverWaitForLoading();
}

View file

@ -16,8 +16,7 @@ import {
import { DataView } from '@kbn/data-views-plugin/common';
import { buildExistsFilter, buildPhraseFilter, Filter } from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import { controlGroupInputBuilder } from '@kbn/controls-plugin/public';
import { getDefaultControlGroupInput } from '@kbn/controls-plugin/common';
import { controlGroupStateBuilder } from '@kbn/controls-plugin/public';
import { NotificationsStart } from '@kbn/core/public';
import {
ENVIRONMENT_ALL,
@ -71,10 +70,9 @@ async function getCreationOptions(
dataView: DataView
): Promise<DashboardCreationOptions> {
try {
const builder = controlGroupInputBuilder;
const controlGroupInput = getDefaultControlGroupInput();
const controlGroupState = {};
await builder.addDataControlFromField(controlGroupInput, {
await controlGroupStateBuilder.addDataControlFromField(controlGroupState, {
dataViewId: dataView.id ?? '',
title: 'Node name',
fieldName: 'service.node.name',
@ -92,7 +90,7 @@ async function getCreationOptions(
getInitialInput: () => ({
viewMode: ViewMode.VIEW,
panels,
controlGroupInput,
controlGroupState,
}),
};
} catch (error) {