Revert "Integrate react control group embeddable into dashboard container (#190273) (#191993)

#190273 introduced a performance regression. Reverting to move metrics to baseline again and create some time to identify root cause.

Co-authored-by: Thomas Neirynck <thomas@elastic.co>
This commit is contained in:
Nathan Reese 2024-09-04 12:11:04 -06:00 committed by GitHub
parent 1aaee64008
commit 86a63dabef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
64 changed files with 1058 additions and 1164 deletions

View file

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

View file

@ -11,7 +11,8 @@ 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 { controlGroupStateBuilder } from '@kbn/controls-plugin/public';
import { controlGroupInputBuilder } from '@kbn/controls-plugin/public';
import { getDefaultControlGroupInput } from '@kbn/controls-plugin/common';
import {
AwaitingDashboardAPI,
DashboardRenderer,
@ -62,15 +63,16 @@ export const DashboardWithControlsExample = ({ dataView }: { dataView: DataView
<EuiPanel hasBorder={true}>
<DashboardRenderer
getCreationOptions={async (): Promise<DashboardCreationOptions> => {
const controlGroupState = {};
await controlGroupStateBuilder.addDataControlFromField(controlGroupState, {
const builder = controlGroupInputBuilder;
const controlGroupInput = getDefaultControlGroupInput();
await builder.addDataControlFromField(controlGroupInput, {
dataViewId: dataView.id ?? '',
title: 'Destintion country',
fieldName: 'geo.dest',
width: 'medium',
grow: false,
});
await controlGroupStateBuilder.addDataControlFromField(controlGroupState, {
await builder.addDataControlFromField(controlGroupInput, {
dataViewId: dataView.id ?? '',
fieldName: 'bytes',
width: 'medium',
@ -83,7 +85,7 @@ export const DashboardWithControlsExample = ({ dataView }: { dataView: DataView
getInitialInput: () => ({
timeRange: { from: 'now-30d', to: 'now' },
viewMode: ViewMode.VIEW,
controlGroupState,
controlGroupInput,
}),
};
}}

View file

@ -9,6 +9,7 @@
import deepEqual from 'fast-deep-equal';
import { SerializableRecord } from '@kbn/utility-types';
import { v4 } from 'uuid';
import { pick, omit, xor } from 'lodash';
import {
@ -22,6 +23,7 @@ import {
} from './control_group_panel_diff_system';
import { ControlGroupInput } from '..';
import {
ControlsPanels,
PersistableControlGroupInput,
persistableControlGroupInputKeys,
RawControlGroupAttributes,
@ -101,6 +103,32 @@ 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

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

View file

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

View file

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

View file

@ -34,19 +34,12 @@ 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,
ControlPanelsState,
} from './types';
import { ControlGroupApi, ControlGroupRuntimeState, ControlGroupSerializedState } 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;
@ -67,6 +60,7 @@ export const getControlGroupEmbeddableFactory = (services: {
lastSavedRuntimeState
) => {
const {
initialChildControlState,
labelPosition: initialLabelPosition,
chainingSystem,
autoApplySelections,
@ -74,22 +68,19 @@ export const getControlGroupEmbeddableFactory = (services: {
} = initialRuntimeState;
const autoApplySelections$ = new BehaviorSubject<boolean>(autoApplySelections);
const defaultDataViewId = await services.dataViews.getDefaultId();
const lastSavedControlsState$ = new BehaviorSubject<ControlPanelsState>(
lastSavedRuntimeState.initialChildControlState
);
const parentDataViewId = apiPublishesDataViews(parentApi)
? parentApi.dataViews.value?.[0]?.id
: undefined;
const controlsManager = initControlsManager(
initialRuntimeState.initialChildControlState,
lastSavedControlsState$
initialChildControlState,
parentDataViewId ?? (await services.dataViews.getDefaultId())
);
const selectionsManager = initSelectionsManager({
...controlsManager.api,
autoApplySelections$,
});
const dataViews = new BehaviorSubject<DataView[] | undefined>(undefined);
const chainingSystem$ = new BehaviorSubject<ControlGroupChainingSystem>(
chainingSystem ?? DEFAULT_CHAINING_SYSTEM
);
const chainingSystem$ = new BehaviorSubject<ControlGroupChainingSystem>(chainingSystem);
const ignoreParentSettings$ = new BehaviorSubject<ParentIgnoreSettings | undefined>(
ignoreParentSettings
);
@ -113,7 +104,6 @@ export const getControlGroupEmbeddableFactory = (services: {
chainingSystem: [
chainingSystem$,
(next: ControlGroupChainingSystem) => chainingSystem$.next(next),
(a, b) => (a ?? DEFAULT_CHAINING_SYSTEM) === (b ?? DEFAULT_CHAINING_SYSTEM),
],
ignoreParentSettings: [
ignoreParentSettings$,
@ -123,7 +113,6 @@ export const getControlGroupEmbeddableFactory = (services: {
labelPosition: [labelPosition$, (next: ControlStyle) => labelPosition$.next(next)],
},
controlsManager.snapshotControlsRuntimeState,
controlsManager.resetControlsUnsavedChanges,
parentApi,
lastSavedRuntimeState
);
@ -170,28 +159,20 @@ export const getControlGroupEmbeddableFactory = (services: {
i18n.translate('controls.controlGroup.displayName', {
defaultMessage: 'Controls',
}),
openAddDataControlFlyout: (options) => {
const parentDataViewId = apiPublishesDataViews(parentApi)
? parentApi.dataViews.value?.[0]?.id
: undefined;
const newControlState = controlsManager.getNewControlState();
openAddDataControlFlyout: (settings) => {
const { controlInputTransform } = settings ?? {
controlInputTransform: (state) => state,
};
openDataControlEditor({
initialState: {
...newControlState,
dataViewId:
newControlState.dataViewId ?? parentDataViewId ?? defaultDataViewId ?? undefined,
},
initialState: controlsManager.getNewControlState(),
onSave: ({ type: controlType, state: initialState }) => {
controlsManager.api.addNewPanel({
panelType: controlType,
initialState: options?.controlInputTransform
? options.controlInputTransform(
initialState as Partial<ControlGroupSerializedState>,
controlType
)
: initialState,
initialState: controlInputTransform!(
initialState as Partial<ControlGroupSerializedState>,
controlType
),
});
options?.onSave?.();
},
controlGroupApi: api,
services,
@ -226,20 +207,6 @@ export const getControlGroupEmbeddableFactory = (services: {
dataViews.next(newDataViews)
);
const saveNotificationSubscription = apiHasSaveNotification(parentApi)
? parentApi.saveNotification$.subscribe(() => {
lastSavedControlsState$.next(controlsManager.snapshotControlsRuntimeState());
if (
typeof autoApplySelections$.value === 'boolean' &&
!autoApplySelections$.value &&
selectionsManager.hasUnappliedSelections$.value
) {
selectionsManager.applySelections();
}
})
: undefined;
/** Fetch the allowExpensiveQuries setting for the children to use if necessary */
try {
const { allowExpensiveQueries } = await services.core.http.get<{
@ -268,7 +235,6 @@ export const getControlGroupEmbeddableFactory = (services: {
return () => {
selectionsManager.cleanup();
childrenDataViewsSubscription.unsubscribe();
saveNotificationSubscription?.unsubscribe();
};
}, []);

View file

@ -6,26 +6,27 @@
* 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, ControlPanelsState } from './types';
import { ControlPanelState } from './types';
jest.mock('uuid', () => ({
v4: jest.fn().mockReturnValue('delta'),
}));
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);
const DEFAULT_DATA_VIEW_ID = 'myDataView';
describe('PresentationContainer api', () => {
test('addNewPanel should add control at end of controls', async () => {
const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$);
const controlsManager = initControlsManager(
{
alpha: { type: 'testControl', order: 0 },
bravo: { type: 'testControl', order: 1 },
charlie: { type: 'testControl', order: 2 },
},
DEFAULT_DATA_VIEW_ID
);
const addNewPanelPromise = controlsManager.api.addNewPanel({
panelType: 'testControl',
initialState: {},
@ -41,7 +42,14 @@ describe('PresentationContainer api', () => {
});
test('removePanel should remove control', () => {
const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$);
const controlsManager = initControlsManager(
{
alpha: { type: 'testControl', order: 0 },
bravo: { type: 'testControl', order: 1 },
charlie: { type: 'testControl', order: 2 },
},
DEFAULT_DATA_VIEW_ID
);
controlsManager.api.removePanel('bravo');
expect(controlsManager.controlsInOrder$.value.map((element) => element.id)).toEqual([
'alpha',
@ -50,7 +58,14 @@ describe('PresentationContainer api', () => {
});
test('replacePanel should replace control', async () => {
const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$);
const controlsManager = initControlsManager(
{
alpha: { type: 'testControl', order: 0 },
bravo: { type: 'testControl', order: 1 },
charlie: { type: 'testControl', order: 2 },
},
DEFAULT_DATA_VIEW_ID
);
const replacePanelPromise = controlsManager.api.replacePanel('bravo', {
panelType: 'testControl',
initialState: {},
@ -66,7 +81,13 @@ describe('PresentationContainer api', () => {
describe('untilInitialized', () => {
test('should not resolve until all controls are initialized', async () => {
const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$);
const controlsManager = initControlsManager(
{
alpha: { type: 'testControl', order: 0 },
bravo: { type: 'testControl', order: 1 },
},
DEFAULT_DATA_VIEW_ID
);
let isDone = false;
controlsManager.api.untilInitialized().then(() => {
isDone = true;
@ -80,18 +101,19 @@ 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(intialControlsState, lastSavedControlsState$);
const controlsManager = initControlsManager(
{
alpha: { type: 'testControl', order: 0 },
bravo: { type: 'testControl', order: 1 },
},
DEFAULT_DATA_VIEW_ID
);
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(() => {
@ -105,14 +127,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(intialControlsState, lastSavedControlsState$);
const controlsManager = initControlsManager(
{
alpha: { type: 'testControl', order: 1 },
bravo: { type: 'testControl', order: 0 },
},
DEFAULT_DATA_VIEW_ID
);
controlsManager.setControlApi('alpha', {
snapshotRuntimeState: () => {
return { key1: 'alpha value' };
@ -168,120 +190,28 @@ 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({}, new BehaviorSubject<ControlPanelsState>({}));
const controlsManager = initControlsManager({}, DEFAULT_DATA_VIEW_ID);
expect(controlsManager.getNewControlState()).toEqual({
grow: true,
width: 'medium',
dataViewId: undefined,
dataViewId: DEFAULT_DATA_VIEW_ID,
});
});
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(
intialControlsState,
new BehaviorSubject<ControlPanelsState>(intialControlsState)
{
alpha: {
type: 'testControl',
order: 1,
dataViewId: 'myOtherDataViewId',
width: 'small',
grow: false,
} as ControlPanelState & Pick<DefaultDataControlState, 'dataViewId'>,
},
DEFAULT_DATA_VIEW_ID
);
expect(controlsManager.getNewControlState()).toEqual({
grow: true,
@ -291,7 +221,7 @@ describe('getNewControlState', () => {
});
test('should contain values of last added control', () => {
const controlsManager = initControlsManager({}, new BehaviorSubject<ControlPanelsState>({}));
const controlsManager = initControlsManager({}, DEFAULT_DATA_VIEW_ID);
controlsManager.api.addNewPanel({
panelType: 'testControl',
initialState: {

View file

@ -38,25 +38,22 @@ export function getControlsInOrder(initialControlPanelsState: ControlPanelsState
}
export function initControlsManager(
/**
* 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>
initialControlPanelsState: ControlPanelsState,
defaultDataViewId: string | null
) {
const initialControlIds = Object.keys(initialControlsState);
const lastSavedControlsPanelState$ = new BehaviorSubject(initialControlPanelsState);
const initialControlIds = Object.keys(initialControlPanelsState);
const children$ = new BehaviorSubject<{ [key: string]: DefaultControlApi }>({});
let currentControlsState: { [panelId: string]: DefaultControlState } = {
...initialControlsState,
let controlsPanelState: { [panelId: string]: DefaultControlState } = {
...initialControlPanelsState,
};
const controlsInOrder$ = new BehaviorSubject<ControlsInOrder>(
getControlsInOrder(initialControlsState)
getControlsInOrder(initialControlPanelsState)
);
const lastUsedDataViewId$ = new BehaviorSubject<string | undefined>(
getLastUsedDataViewId(controlsInOrder$.value, initialControlsState)
getLastUsedDataViewId(controlsInOrder$.value, initialControlPanelsState) ??
defaultDataViewId ??
undefined
);
const lastUsedWidth$ = new BehaviorSubject<ControlWidth>(DEFAULT_CONTROL_WIDTH);
const lastUsedGrow$ = new BehaviorSubject<boolean>(DEFAULT_CONTROL_GROW);
@ -111,12 +108,12 @@ export function initControlsManager(
type: panelType,
});
controlsInOrder$.next(nextControlsInOrder);
currentControlsState[id] = initialState ?? {};
controlsPanelState[id] = initialState ?? {};
return await untilControlLoaded(id);
}
function removePanel(panelId: string) {
delete currentControlsState[panelId];
delete controlsPanelState[panelId];
controlsInOrder$.next(controlsInOrder$.value.filter(({ id }) => id !== panelId));
children$.next(omit(children$.value, panelId));
}
@ -164,7 +161,7 @@ export function initControlsManager(
type: controlApi.type,
width,
/** Re-add the `explicitInput` layer on serialize so control group saved object retains shape */
explicitInput: { id, ...rest },
explicitInput: rest,
};
});
@ -187,30 +184,9 @@ 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 = currentControlsState[childId];
const controlPanelState = controlsPanelState[childId];
return controlPanelState ? { rawState: controlPanelState } : undefined;
},
children$: children$ as PublishingSubject<{
@ -254,10 +230,26 @@ export function initControlsManager(
comparators: {
controlsInOrder: [
controlsInOrder$,
(next: ControlsInOrder) => {}, // setter does nothing, controlsInOrder$ reset by resetControlsRuntimeState
(next: ControlsInOrder) => controlsInOrder$.next(next),
fastIsEqual,
],
} as StateComparators<Pick<ControlGroupComparatorState, 'controlsInOrder'>>,
// 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'>
>,
};
}

View file

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

View file

@ -9,7 +9,6 @@
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 +20,9 @@ export const deserializeControlGroup = (
const references = state.references ?? [];
references.forEach((reference) => {
const referenceName = reference.name;
const { controlId } = parseReferenceName(referenceName);
if (panels[controlId]) {
panels[controlId].dataViewId = reference.id;
const panelId = referenceName.substring('controlGroup_'.length, referenceName.lastIndexOf(':'));
if (panels[panelId]) {
panels[panelId].dataViewId = reference.id;
}
});

View file

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

View file

@ -51,7 +51,6 @@ describe('initializeDataControl', () => {
dataControl = initializeDataControl(
'myControlId',
'myControlType',
'referenceNameSuffix',
dataControlState,
editorStateManager,
controlGroupApi,
@ -83,7 +82,6 @@ describe('initializeDataControl', () => {
dataControl = initializeDataControl(
'myControlId',
'myControlType',
'referenceNameSuffix',
{
...dataControlState,
dataViewId: 'notGonnaFindMeDataViewId',
@ -122,7 +120,6 @@ describe('initializeDataControl', () => {
dataControl = initializeDataControl(
'myControlId',
'myControlType',
'referenceNameSuffix',
{
...dataControlState,
fieldName: 'notGonnaFindMeFieldName',

View file

@ -26,12 +26,10 @@ 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
@ -244,7 +242,7 @@ export const initializeDataControl = <EditorState extends object = {}>(
},
references: [
{
name: getReferenceName(controlId, referenceNameSuffix),
name: `controlGroup_${controlId}:${controlType}DataView`,
type: DATA_VIEW_SAVED_OBJECT_TYPE,
id: dataViewId.getValue(),
},

View file

@ -8,7 +8,6 @@
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,7 +87,6 @@ export const getOptionsListControlFactory = (
>(
uuid,
OPTIONS_LIST_CONTROL,
'optionsListDataView',
initialState,
{ searchTechnique: searchTechnique$, singleSelect: singleSelect$ },
controlGroupApi,
@ -245,7 +243,7 @@ export const getOptionsListControlFactory = (
searchTechnique: searchTechnique$.getValue(),
runPastTimeout: runPastTimeout$.getValue(),
singleSelect: singleSelect$.getValue(),
selectedOptions: selections.selectedOptions$.getValue(),
selections: selections.selectedOptions$.getValue(),
sort: sort$.getValue(),
existsSelected: selections.existsSelected$.getValue(),
exclude: selections.exclude$.getValue(),
@ -279,7 +277,7 @@ export const getOptionsListControlFactory = (
sort: [
sort$,
(sort) => sort$.next(sort),
(a, b) => fastIsEqual(a ?? OPTIONS_LIST_DEFAULT_SORT, b ?? OPTIONS_LIST_DEFAULT_SORT),
(a, b) => (a ?? OPTIONS_LIST_DEFAULT_SORT) === (b ?? OPTIONS_LIST_DEFAULT_SORT),
],
/** This state cannot currently be changed after the control is created */

View file

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

View file

@ -1,22 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
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

@ -35,17 +35,16 @@ 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: () => displayName,
getDisplayName: () =>
i18n.translate('controls.timesliderControl.displayName', {
defaultMessage: 'Time slider',
}),
buildControl: async (initialState, buildApi, uuid, controlGroupApi) => {
const { timeRangeMeta$, formatDate, cleanupTimeRangeSubscription } =
initTimeRangeSubscription(controlGroupApi, services);
@ -204,7 +203,6 @@ export const getTimesliderControlFactory = (
const api = buildApi(
{
...defaultControl.api,
defaultPanelTitle: new BehaviorSubject<string | undefined>(displayName),
timeslice$,
serializeState: () => {
const { rawState: defaultControlState } = defaultControl.serialize();

View file

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

View file

@ -7,6 +7,7 @@
*/
import type { Reference } from '@kbn/content-management-utils';
import { CONTROL_GROUP_TYPE, PersistableControlGroupInput } from '@kbn/controls-plugin/common';
import {
EmbeddableInput,
EmbeddablePersistableStateService,
@ -22,10 +23,6 @@ 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
@ -37,6 +34,7 @@ export const prefixReferencesFromPanel = (id: string, references: Reference[]):
};
const controlGroupReferencePrefix = 'controlGroup_';
const controlGroupId = 'dashboard_control_group';
export const createInject = (
persistableStateService: EmbeddablePersistableStateService
@ -92,6 +90,27 @@ 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;
};
};
@ -141,6 +160,23 @@ 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

@ -8,6 +8,7 @@
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,
@ -32,6 +33,9 @@ function parseDashboardAttributesWithType(
}
return {
controlGroupInput:
attributes.controlGroupInput &&
rawControlGroupAttributesToControlGroupInput(attributes.controlGroupInput),
type: 'dashboard',
panels: convertSavedPanelsToPanelMap(parsedPanels),
} as ParsedDashboardAttributesWithType;
@ -55,6 +59,13 @@ 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;
}
@ -85,6 +96,13 @@ 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

@ -8,6 +8,8 @@
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';
@ -38,6 +40,7 @@ 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

@ -9,7 +9,9 @@
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(() => {
@ -191,18 +193,16 @@ describe('dashboard locator', () => {
useHashedUrl: false,
getDashboardFilterFields: async (dashboardId: string) => [],
});
const controlGroupState = {
autoApplySelections: false,
};
const controlGroupInput = mockControlGroupInput() as unknown as SerializableControlGroupInput;
const location = await definition.getLocation({
controlGroupState,
controlGroupInput,
});
expect(location).toMatchObject({
app: 'dashboards',
path: `#/create?_g=()`,
state: {
controlGroupState,
controlGroupInput,
},
});
});

View file

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

View file

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

View file

@ -10,18 +10,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({
controlGroupApi,
controlGroup,
isDisabled,
}: {
controlGroupApi?: ControlGroupApi;
controlGroup: ControlGroupContainer;
isDisabled?: boolean;
}) {
const { euiTheme } = useEuiTheme();
@ -43,17 +43,17 @@ export function ControlsToolbarButton({
items={[
<AddDataControlButton
key="addControl"
controlGroupApi={controlGroupApi}
controlGroup={controlGroup}
closePopover={closePopover}
/>,
<AddTimeSliderControlButton
key="addTimeSliderControl"
controlGroupApi={controlGroupApi}
controlGroup={controlGroup}
closePopover={closePopover}
/>,
<EditControlGroupButton
key="manageControls"
controlGroupApi={controlGroupApi}
controlGroup={controlGroup}
closePopover={closePopover}
/>,
]}

View file

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

View file

@ -13,7 +13,6 @@ 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';
@ -83,7 +82,6 @@ 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,8 +90,12 @@ 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

@ -7,6 +7,7 @@
*/
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';
@ -25,7 +26,6 @@ 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';
@ -169,9 +169,7 @@ export function ShowShareModal({
unsavedStateForLocator = {
query: unsavedDashboardState.query,
filters: unsavedDashboardState.filters,
controlGroupState: panelModifications?.[
PANELS_CONTROL_GROUP_KEY
] as DashboardLocatorParams['controlGroupState'],
controlGroupInput: unsavedDashboardState.controlGroupInput as SerializableControlGroupInput,
panels: allUnsavedPanels as DashboardLocatorParams['panels'],
// options

View file

@ -11,7 +11,6 @@ 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,8 +32,6 @@ export const useDashboardMenuItems = ({
maybeRedirect: (result?: SaveDashboardReturn) => void;
showResetChange?: boolean;
}) => {
const isMounted = useMountedState();
const [isSaveInProgress, setIsSaveInProgress] = useState(false);
/**
@ -102,7 +99,6 @@ 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();
@ -117,17 +113,13 @@ export const useDashboardMenuItems = ({
return;
}
confirmDiscardUnsavedChanges(() => {
batch(async () => {
setIsResetting(true);
await dashboard.asyncResetToLastSavedState();
if (isMounted()) {
setIsResetting(false);
switchModes?.();
}
batch(() => {
dashboard.resetToLastSavedState();
switchModes?.();
});
}, viewMode);
},
[dashboard, dashboardBackup, hasUnsavedChanges, viewMode, isMounted]
[dashboard, dashboardBackup, hasUnsavedChanges, viewMode]
);
/**
@ -198,8 +190,7 @@ export const useDashboardMenuItems = ({
switchToViewMode: {
...topNavStrings.switchToViewMode,
id: 'cancel',
disableButton: disableTopNav || !lastSavedId || isResetting,
isLoading: isResetting,
disableButton: disableTopNav || !lastSavedId,
testId: 'dashboardViewOnlyMode',
run: () => resetChanges(true),
} as TopNavMenuData,
@ -235,7 +226,6 @@ export const useDashboardMenuItems = ({
dashboardBackup,
quickSaveDashboard,
resetChanges,
isResetting,
]);
const resetChangesMenuItem = useMemo(() => {
@ -244,22 +234,12 @@ 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,
isResetting,
]);
}, [hasOverlays, lastSavedId, resetChanges, viewMode, isSaveInProgress, hasUnsavedChanges]);
/**
* Build ordered menus for view and edit mode.

View file

@ -44,7 +44,7 @@ jest.mock('./dashboard_grid_item', () => {
};
});
const createAndMountDashboardGrid = async () => {
const createAndMountDashboardGrid = () => {
const dashboardContainer = buildMockDashboard({
overrides: {
panels: {
@ -61,7 +61,6 @@ const createAndMountDashboardGrid = async () => {
},
},
});
await dashboardContainer.untilContainerInitialized();
const component = mountWithIntl(
<DashboardContainerContext.Provider value={dashboardContainer}>
<DashboardGrid viewportWidth={1000} />
@ -71,20 +70,20 @@ const createAndMountDashboardGrid = async () => {
};
test('renders DashboardGrid', async () => {
const { component } = await createAndMountDashboardGrid();
const { component } = createAndMountDashboardGrid();
const panelElements = component.find('GridItem');
expect(panelElements.length).toBe(2);
});
test('renders DashboardGrid with no visualizations', async () => {
const { dashboardContainer, component } = await createAndMountDashboardGrid();
const { dashboardContainer, component } = 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 } = await createAndMountDashboardGrid();
const { dashboardContainer, component } = createAndMountDashboardGrid();
const originalPanels = dashboardContainer.getInput().panels;
const filteredPanels = { ...originalPanels };
delete filteredPanels['1'];
@ -95,7 +94,7 @@ test('DashboardGrid removes panel when removed from container', async () => {
});
test('DashboardGrid renders expanded panel', async () => {
const { dashboardContainer, component } = await createAndMountDashboardGrid();
const { dashboardContainer, component } = 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 +112,7 @@ test('DashboardGrid renders expanded panel', async () => {
});
test('DashboardGrid renders focused panel', async () => {
const { dashboardContainer, component } = await createAndMountDashboardGrid();
const { dashboardContainer, component } = 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

@ -9,19 +9,12 @@
import { debounce } from 'lodash';
import classNames from 'classnames';
import useResizeObserver from 'use-resize-observer/polyfilled';
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { EuiPortal } from '@elastic/eui';
import { ReactEmbeddableRenderer, ViewMode } from '@kbn/embeddable-plugin/public';
import { 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';
@ -41,11 +34,23 @@ export const useDebouncedWidthObserver = (skipDebounce = false, wait = 100) => {
};
export const DashboardViewportComponent = () => {
const controlsRoot = useRef(null);
const dashboard = useDashboardContainer();
const controlGroupApi = useStateFromPublishingSubject(dashboard.controlGroupApi$);
/**
* Render Control group
*/
const controlGroup = dashboard.controlGroup;
useEffect(() => {
if (controlGroup && controlsRoot.current) controlGroup.render(controlsRoot.current);
}, [controlGroup]);
const panelCount = Object.keys(dashboard.select((state) => state.explicitInput.panels)).length;
const [hasControls, setHasControls] = useState(false);
const controlCount = Object.keys(
controlGroup?.select((state) => state.explicitInput.panels) ?? {}
).length;
const viewMode = dashboard.select((state) => state.explicitInput.viewMode);
const dashboardTitle = dashboard.select((state) => state.explicitInput.title);
const useMargins = dashboard.select((state) => state.explicitInput.useMargins);
@ -60,59 +65,17 @@ 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]);
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,
})}
>
{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>
{controlGroup && viewMode !== ViewMode.PRINT ? (
<div
className={controlCount > 0 ? 'dshDashboardViewport-controls' : ''}
ref={controlsRoot}
/>
) : null}
{panelCount === 0 && <DashboardEmptyScreen />}
<div
@ -125,9 +88,7 @@ export const DashboardViewportComponent = () => {
>
{/* 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 */}
{viewportWidth !== 0 && dashboardInitialized && (
<DashboardGrid viewportWidth={viewportWidth} />
)}
{viewportWidth !== 0 && <DashboardGrid viewportWidth={viewportWidth} />}
</div>
</div>
);

View file

@ -7,6 +7,7 @@
*/
import type { Reference } from '@kbn/content-management-utils';
import type { PersistableControlGroupInput } from '@kbn/controls-plugin/common';
import { reportPerformanceMetricEvent } from '@kbn/ebt-tools';
import {
EmbeddableInput,
@ -88,17 +89,13 @@ export async function runQuickSave(this: DashboardContainer) {
const { panels: nextPanels, references } = await serializeAllPanelState(this);
const dashboardStateToSave: DashboardContainerInput = { ...currentState, panels: nextPanels };
let stateToSave: SavedDashboardInput = dashboardStateToSave;
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 };
let persistableControlGroupInput: PersistableControlGroupInput | undefined;
if (this.controlGroup) {
persistableControlGroupInput = this.controlGroup.getPersistableInput();
stateToSave = { ...stateToSave, controlGroupInput: persistableControlGroupInput };
}
const saveResult = await saveDashboardState({
controlGroupReferences,
panelReferences: references,
currentState: stateToSave,
saveOptions: {},
@ -108,6 +105,9 @@ 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;
}
@ -180,20 +180,19 @@ export async function runInteractiveSave(this: DashboardContainer, interactionMo
stateFromSaveModal.tags = newTags;
}
let dashboardStateToSave: SavedDashboardInput = {
let dashboardStateToSave: DashboardContainerInput & {
controlGroupInput?: PersistableControlGroupInput;
} = {
...currentState,
...stateFromSaveModal,
};
const controlGroupApi = this.controlGroupApi$.value;
let controlGroupReferences: Reference[] | undefined;
if (controlGroupApi) {
const { rawState: controlGroupSerializedState, references } =
await controlGroupApi.serializeState();
controlGroupReferences = references;
let persistableControlGroupInput: PersistableControlGroupInput | undefined;
if (this.controlGroup) {
persistableControlGroupInput = this.controlGroup.getPersistableInput();
dashboardStateToSave = {
...dashboardStateToSave,
controlGroupInput: controlGroupSerializedState,
controlGroupInput: persistableControlGroupInput,
};
}
@ -226,7 +225,6 @@ export async function runInteractiveSave(this: DashboardContainer, interactionMo
const beforeAddTime = window.performance.now();
const saveResult = await saveDashboardState({
controlGroupReferences,
panelReferences: references,
saveOptions,
currentState: {
@ -253,6 +251,9 @@ 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

@ -6,9 +6,11 @@
* 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');
@ -49,41 +51,46 @@ const testFilter3: Filter = {
},
};
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);
});
const mockControlGroupContainer = new ControlGroupContainer(
{ getTools: () => {} } as unknown as ReduxToolsPackage,
mockControlGroupInput()
);
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);
});
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 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));
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));
});
});
});

View file

@ -6,95 +6,114 @@
* Side Public License, v 1.
*/
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 { 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 { DashboardContainer } from '../../dashboard_container';
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)))
);
interface DiffChecks {
[key: string]: (a?: unknown, b?: unknown) => boolean;
}
// --------------------------------------------------------------------------------------
// dashboard.unifiedSearchFilters$
// --------------------------------------------------------------------------------------
const unifiedSearchFilters$ = new BehaviorSubject<Filter[] | undefined>(
dashboard.getInput().filters
);
dashboard.unifiedSearchFilters$ = unifiedSearchFilters$ as PublishingSubject<
Filter[] | undefined
>;
dashboard.publishingSubscription.add(
dashboard
.getInput$()
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>>)
.pipe(
startWith(dashboard.getInput()),
map((input) => input.filters),
distinctUntilChanged((previous, current) => {
return compareFilters(previous ?? [], current ?? [], COMPARE_ALL_OPTIONS);
})
distinctUntilChanged((a, b) =>
distinctUntilDiffCheck<DashboardContainerInput>(a, b, dashboardRefetchDiff)
)
)
.subscribe((unifiedSearchFilters) => {
unifiedSearchFilters$.next(unifiedSearchFilters);
.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);
}
})
);
// --------------------------------------------------------------------------------------
// 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!
// --------------------------------------------------------------------------------------
dashboard.publishingSubscription.add(
controlGroupFilters$
this.integrationSubscriptions.add(
this.controlGroup
.getOutput$()
.pipe(
distinctUntilChanged(({ filters: filtersA }, { filters: filtersB }) =>
compareAllFilters(filtersA, filtersB)
),
skip(1) // skip first filter output because it will have been applied in initialize
)
.subscribe(() => dashboard.forceRefresh(false)) // we should not reload the control group when the control group output changes - otherwise, performance is severely impacted
.subscribe(() => this.forceRefresh(false)) // we should not reload the control group when the control group output changes - otherwise, performance is severely impacted
);
// --------------------------------------------------------------------------------------
// when control group outputs timeslice, dispatch timeslice
// --------------------------------------------------------------------------------------
dashboard.publishingSubscription.add(
controlGroupTimeslice$.subscribe((timeslice) => {
dashboard.dispatch.setTimeslice(timeslice);
})
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)
)
);
}
export const combineDashboardFiltersWithControlGroupFilters = (
dashboardFilters: Filter[],
controlGroupApi?: PublishesFilters
controlGroup: ControlGroupContainer
): Filter[] => {
return [...dashboardFilters, ...(controlGroupApi?.filters$.value ?? [])];
return [...dashboardFilters, ...(controlGroup.getOutput().filters ?? [])];
};

View file

@ -6,6 +6,8 @@
* Side Public License, v 1.
*/
import { BehaviorSubject, Observable } from 'rxjs';
import {
ContactCardEmbeddable,
ContactCardEmbeddableFactory,
@ -13,6 +15,11 @@ 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';
@ -22,7 +29,6 @@ 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
@ -410,7 +416,6 @@ 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));
@ -471,7 +476,6 @@ test('creates new embeddable with specified size if size is provided', async ()
},
}),
});
dashboard?.setControlGroupApi(mockControlGroupApi);
// flush promises
await new Promise((r) => setTimeout(r, 1));
@ -493,6 +497,42 @@ 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
@ -527,7 +567,6 @@ 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

@ -5,13 +5,38 @@
* in compliance with, at your election, the Elastic License 2.0 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 { ViewMode } from '@kbn/embeddable-plugin/public';
import { TimeRange } from '@kbn/es-query';
import { EmbeddableFactory, isErrorEmbeddable, ViewMode } from '@kbn/embeddable-plugin/public';
import {
AggregateQuery,
compareFilters,
COMPARE_ALL_OPTIONS,
Filter,
Query,
TimeRange,
} from '@kbn/es-query';
import { lazyLoadReduxToolsPackage } from '@kbn/presentation-util-plugin/public';
import { cloneDeep, omit } from 'lodash';
import { Subject } from 'rxjs';
import deepEqual from 'fast-deep-equal';
import { cloneDeep, identity, omit, pickBy } from 'lodash';
import {
BehaviorSubject,
combineLatest,
distinctUntilChanged,
map,
startWith,
Subject,
} from 'rxjs';
import { v4 } from 'uuid';
import {
DashboardContainerInput,
@ -35,11 +60,14 @@ 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.
@ -134,13 +162,16 @@ 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: {
@ -160,6 +191,7 @@ export const initializeDashboard = async ({
searchSessionSettings,
unifiedSearchSettings,
validateLoadedSavedObject,
useControlGroupIntegration,
useUnifiedSearchIntegration,
useSessionStorageIntegration,
} = creationOptions ?? {};
@ -259,6 +291,11 @@ 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) {
@ -275,7 +312,6 @@ export const initializeDashboard = async ({
// --------------------------------------------------------------------------------------
untilDashboardReady().then((dashboard) => {
dashboard.savedObjectReferences = loadDashboardReturn?.references;
dashboard.controlGroupInput = loadDashboardReturn?.dashboardInput?.controlGroupInput;
});
// --------------------------------------------------------------------------------------
@ -438,13 +474,6 @@ 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;
@ -452,6 +481,52 @@ 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.
// --------------------------------------------------------------------------------------
@ -477,6 +552,63 @@ 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.
// --------------------------------------------------------------------------------------
@ -497,8 +629,7 @@ export const initializeDashboard = async ({
sessionIdToRestore ??
(existingSession && incomingEmbeddable ? existingSession : session.start());
untilDashboardReady().then(async (container) => {
await container.untilContainerInitialized();
untilDashboardReady().then((container) => {
startDashboardSearchSessionIntegration.bind(container)(
creationOptions?.searchSessionSettings
);

View file

@ -10,7 +10,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, Observable, of, switchMap } from 'rxjs';
import { combineLatest, map, Observable, of, switchMap } from 'rxjs';
import { pluginServices } from '../../../../services/plugin_services';
import { DashboardContainer } from '../../dashboard_container';
@ -19,11 +19,19 @@ export function startSyncingDashboardDataViews(this: DashboardContainer) {
data: { dataViews },
} = pluginServices.getServices();
const controlGroupDataViewsPipe: Observable<DataView[] | undefined> = this.controlGroupApi$.pipe(
switchMap((controlGroupApi) => {
return controlGroupApi ? controlGroupApi.dataViews : of([]);
})
);
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 childDataViewsPipe = combineCompatibleChildrenApis<PublishesDataViews, DataView[]>(
this,
@ -35,10 +43,7 @@ export function startSyncingDashboardDataViews(this: DashboardContainer) {
return combineLatest([controlGroupDataViewsPipe, childDataViewsPipe])
.pipe(
switchMap(([controlGroupDataViews, childDataViews]) => {
const allDataViews = [
...(controlGroupDataViews ? controlGroupDataViews : []),
...childDataViews,
];
const allDataViews = controlGroupDataViews.concat(childDataViews);
if (allDataViews.length === 0) {
return (async () => {
const defaultDataViewId = await dataViews.getDefaultId();
@ -49,6 +54,7 @@ export function startSyncingDashboardDataViews(this: DashboardContainer) {
})
)
.subscribe((newDataViews) => {
if (newDataViews[0].id) this.controlGroup?.setRelevantDataViewId(newDataViews[0].id);
this.setAllDataViews(newDataViews);
});
}

View file

@ -18,12 +18,7 @@ import {
import type { TimeRange } from '@kbn/es-query';
import { mockedReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public/mocks';
import {
buildMockDashboard,
getSampleDashboardInput,
getSampleDashboardPanel,
mockControlGroupApi,
} from '../../mocks';
import { buildMockDashboard, getSampleDashboardInput, getSampleDashboardPanel } from '../../mocks';
import { pluginServices } from '../../services/plugin_services';
import { DashboardContainer } from './dashboard_container';
@ -175,7 +170,6 @@ test('searchSessionId propagates to children', async () => {
undefined,
{ lastSavedInput: sampleInput }
);
container?.setControlGroupApi(mockControlGroupApi);
const embeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
@ -195,10 +189,11 @@ 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();
container.updateInput({
timeRange: dashboardTimeRange,
timeslice: dashboardTimeslice,
const container = buildMockDashboard({
overrides: {
timeRange: dashboardTimeRange,
timeslice: dashboardTimeslice,
},
});
const embeddable = await container.addNewEmbeddable<ContactCardEmbeddableInput>(
CONTACT_CARD_EMBEDDABLE,
@ -219,10 +214,11 @@ describe('getInheritedInput', () => {
});
test('Should not pass dashboard timeRange and timeslice to panel when panel has custom time range', async () => {
const container = buildMockDashboard();
container.updateInput({
timeRange: dashboardTimeRange,
timeslice: dashboardTimeslice,
const container = buildMockDashboard({
overrides: {
timeRange: dashboardTimeRange,
timeslice: dashboardTimeslice,
},
});
const embeddableTimeRange = {
to: 'now',

View file

@ -8,6 +8,7 @@
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,
@ -15,8 +16,6 @@ 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 +32,7 @@ import {
type EmbeddableOutput,
type IEmbeddable,
} from '@kbn/embeddable-plugin/public';
import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
import type { Filter, Query, TimeRange } from '@kbn/es-query';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import {
HasRuntimeChildState,
@ -41,7 +40,6 @@ import {
HasSerializedChildState,
TrackContentfulRender,
TracksQueryPerformance,
combineCompatibleChildrenApis,
} from '@kbn/presentation-containers';
import { PanelPackage } from '@kbn/presentation-containers';
import { ReduxEmbeddableTools, ReduxToolsPackage } from '@kbn/presentation-util-plugin/public';
@ -52,18 +50,14 @@ import { omit } from 'lodash';
import React, { createContext, useContext } from 'react';
import ReactDOM from 'react-dom';
import { batch } from 'react-redux';
import { BehaviorSubject, Subject, Subscription, first, skipWhile, switchMap } from 'rxjs';
import { BehaviorSubject, Subject, Subscription } 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 { DashboardAttributes, DashboardContainerInput, DashboardPanelState } from '../../../common';
import {
getReferencesForControls,
getReferencesForPanelId,
} from '../../../common/dashboard_container/persistable_state/dashboard_container_references';
import { DashboardContainerInput, DashboardPanelState } from '../../../common';
import { getReferencesForPanelId } from '../../../common/dashboard_container/persistable_state/dashboard_container_references';
import {
DASHBOARD_APP_ID,
DASHBOARD_UI_METRIC_ID,
@ -90,10 +84,7 @@ import {
showSettings,
} from './api';
import { duplicateDashboardPanel } from './api/duplicate_dashboard_panel';
import {
combineDashboardFiltersWithControlGroupFilters,
startSyncingDashboardControlGroup,
} from './create/controls/dashboard_control_group_integration';
import { combineDashboardFiltersWithControlGroupFilters } from './create/controls/dashboard_control_group_integration';
import { initializeDashboard } from './create/create_dashboard';
import {
DashboardCreationOptions,
@ -101,7 +92,6 @@ 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[];
@ -157,7 +147,7 @@ export class DashboardContainer
public integrationSubscriptions: Subscription = new Subscription();
public publishingSubscription: Subscription = new Subscription();
public diffingSubscription: Subscription = new Subscription();
public controlGroupApi$: PublishingSubject<ControlGroupApi | undefined>;
public controlGroup?: ControlGroupContainer;
public settings: Record<string, PublishingSubject<boolean | undefined>>;
public searchSessionId?: string;
@ -166,7 +156,6 @@ 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;
@ -183,9 +172,6 @@ export class DashboardContainer
private hadContentfulRender = false;
private scrollPosition?: number;
// setup
public untilContainerInitialized: () => Promise<void>;
// cleanup
public stopSyncingWithUnifiedSearch?: () => void;
private cleanupStateTools: () => void;
@ -211,7 +197,6 @@ export class DashboardContainer
| undefined;
// new embeddable framework
public savedObjectReferences: Reference[] = [];
public controlGroupInput: DashboardAttributes['controlGroupInput'] | undefined;
constructor(
initialInput: DashboardContainerInput,
@ -222,43 +207,19 @@ 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) => {
await controlGroupApi?.untilInitialized();
}),
first()
)
.subscribe(() => {
resolve();
});
});
}
const {
usageCollection,
embeddable: { getEmbeddableFactory },
} = pluginServices.getServices();
super(
{
...initialInput,
},
{ embeddableLoaded: {} },
getEmbeddableFactory,
parent,
{
untilContainerInitialized,
}
parent
);
this.controlGroupApi$ = controlGroupApi$;
this.untilContainerInitialized = untilContainerInitialized;
this.trackPanelAddMetric = usageCollection.reportUiCounter?.bind(
usageCollection,
DASHBOARD_UI_METRIC_ID
@ -350,41 +311,7 @@ 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() {
@ -470,10 +397,10 @@ export class DashboardContainer
panels,
} = this.input;
const combinedFilters = combineDashboardFiltersWithControlGroupFilters(
filters,
this.controlGroupApi$?.value
);
let combinedFilters = filters;
if (this.controlGroup) {
combinedFilters = combineDashboardFiltersWithControlGroupFilters(filters, this.controlGroup);
}
const hasCustomTimeRange = Boolean(
(panels[id]?.explicitInput as Partial<InheritedChildInput>)?.timeRange
);
@ -502,6 +429,7 @@ export class DashboardContainer
public destroy() {
super.destroy();
this.cleanupStateTools();
this.controlGroup?.destroy();
this.diffingSubscription.unsubscribe();
this.publishingSubscription.unsubscribe();
this.integrationSubscriptions.unsubscribe();
@ -687,12 +615,16 @@ 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 async asyncResetToLastSavedState() {
public onDataViewsUpdate$ = new Subject<DataView[]>();
public resetToLastSavedState() {
this.dispatch.resetToLastSavedInput({});
const {
explicitInput: { timeRange, refreshInterval },
@ -701,8 +633,8 @@ export class DashboardContainer
},
} = this.getState();
if (this.controlGroupApi$.value) {
await this.controlGroupApi$.value.asyncResetUnsavedChanges();
if (this.controlGroup) {
this.controlGroup.resetToLastSavedState();
}
// if we are using the unified search integration, we need to force reset the time picker.
@ -747,6 +679,7 @@ export class DashboardContainer
const initializeResult = await initializeDashboard({
creationOptions: this.creationOptions,
controlGroup: this.controlGroup,
untilDashboardReady,
loadDashboardReturn,
});
@ -761,6 +694,9 @@ 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);
@ -784,7 +720,7 @@ export class DashboardContainer
*/
public setAllDataViews = (newDataViews: DataView[]) => {
this.allDataViews = newDataViews;
(this.dataViews as BehaviorSubject<DataView[] | undefined>).next(newDataViews);
this.onDataViewsUpdate$.next(newDataViews);
};
public getExpandedPanelId = () => {
@ -807,6 +743,7 @@ export class DashboardContainer
public clearOverlays = () => {
this.dispatch.setHasOverlays(false);
this.dispatch.setFocusedPanelId(undefined);
this.controlGroup?.closeAllFlyouts();
this.overlayRef?.close();
};
@ -911,22 +848,6 @@ 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 ?? {};
@ -937,10 +858,6 @@ 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

@ -5,10 +5,11 @@
* in compliance with, at your election, the Elastic License 2.0 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, skipWhile, startWith, switchMap } from 'rxjs';
import { combineLatest, debounceTime, Observable, of, startWith, switchMap } from 'rxjs';
import { DashboardContainer, DashboardCreationOptions } from '../..';
import { DashboardContainerInput } from '../../../../common';
import { CHANGE_CHECK_DEBOUNCE } from '../../../dashboard_constants';
@ -16,7 +17,6 @@ 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
@ -111,12 +111,8 @@ export function startDiffingDashboardState(
combineLatest([
dashboardUnsavedChanges,
childrenUnsavedChanges$(this.children$),
this.controlGroupApi$.pipe(
skipWhile((controlGroupApi) => !controlGroupApi),
switchMap((controlGroupApi) => {
return controlGroupApi!.unsavedChanges;
})
),
this.controlGroup?.unsavedChanges ??
(of(undefined) as Observable<PersistableControlGroupInput | undefined>),
]).subscribe(([dashboardChanges, unsavedPanelState, controlGroupChanges]) => {
// calculate unsaved changes
const hasUnsavedChanges =
@ -129,11 +125,11 @@ export function startDiffingDashboardState(
// backup unsaved changes if configured to do so
if (creationOptions?.useSessionStorageIntegration) {
const reactEmbeddableChanges = unsavedPanelState ? { ...unsavedPanelState } : {};
if (controlGroupChanges) {
reactEmbeddableChanges[PANELS_CONTROL_GROUP_KEY] = controlGroupChanges;
}
backupUnsavedChanges.bind(this)(dashboardChanges, reactEmbeddableChanges);
backupUnsavedChanges.bind(this)(
dashboardChanges,
unsavedPanelState ? unsavedPanelState : {},
controlGroupChanges
);
}
})
);
@ -185,7 +181,8 @@ export async function getDashboardUnsavedChanges(
function backupUnsavedChanges(
this: DashboardContainer,
dashboardChanges: Partial<DashboardContainerInput>,
reactEmbeddableChanges: UnsavedPanelState
reactEmbeddableChanges: UnsavedPanelState,
controlGroupChanges: PersistableControlGroupInput | undefined
) {
const { dashboardBackup } = pluginServices.getServices();
const dashboardStateToBackup = omit(dashboardChanges, keysToOmitFromSessionStorage);
@ -195,6 +192,7 @@ function backupUnsavedChanges(
{
...dashboardStateToBackup,
panels: dashboardChanges.panels,
controlGroupInput: controlGroupChanges,
},
reactEmbeddableChanges
);

View file

@ -6,11 +6,11 @@
* 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';
@ -125,7 +125,7 @@ export type DashboardLocatorParams = Partial<
panels?: Array<SavedDashboardPanel & SerializableRecord>; // used SerializableRecord here to force the GridData type to be read as serializable
/**
* Control group changes
* Control group input
*/
controlGroupState?: Partial<ControlGroupRuntimeState> & SerializableRecord; // used SerializableRecord here to force the GridData type to be read as serializable
controlGroupInput?: SerializableControlGroupInput;
};

View file

@ -15,6 +15,7 @@ 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,
@ -28,7 +29,6 @@ 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,
@ -113,8 +113,16 @@ 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);
const allDataViews = useStateFromPublishingSubject(dashboard.dataViews);
useEffect(() => {
setAllDataViews(dashboard.getAllDataViews());
const subscription = dashboard.onDataViewsUpdate$.subscribe((dataViews) =>
setAllDataViews(dataViews)
);
return () => subscription.unsubscribe();
}, [dashboard]);
const dashboardTitle = useMemo(() => {
return getDashboardTitle(title, viewMode, !lastSavedId);
@ -403,7 +411,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

@ -9,8 +9,6 @@
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';
@ -74,15 +72,6 @@ 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,
@ -100,7 +89,6 @@ export function buildMockDashboard({
undefined,
{ lastSavedInput: initialInput, lastSavedId: savedObjectId }
);
dashboardContainer?.setControlGroupApi(mockControlGroupApi);
return dashboardContainer;
}

View file

@ -23,7 +23,6 @@ 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,7 +112,6 @@ 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

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

View file

@ -12,6 +12,7 @@ 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 {
@ -186,7 +187,9 @@ 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,
controlGroupInput:
attributes.controlGroupInput &&
rawControlGroupAttributesToControlGroupInput(attributes.controlGroupInput),
version: convertNumberToDashboardVersion(version),
},

View file

@ -6,6 +6,9 @@
* 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';
@ -29,6 +32,23 @@ 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(() => ({
@ -42,8 +62,11 @@ 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(4); // should be called 4 times for the panels, and 3 times for the controls
expect(embeddableService.getEmbeddableFactory).toHaveBeenCalledTimes(7); // 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

@ -6,6 +6,7 @@
* Side Public License, v 1.
*/
import { ControlGroupInput } from '@kbn/controls-plugin/common';
import {
EmbeddableFactoryNotFoundError,
runEmbeddableFactoryMigrations,
@ -30,7 +31,28 @@ 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

@ -9,6 +9,12 @@
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';
@ -23,10 +29,24 @@ 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]');
@ -48,7 +68,6 @@ type SaveDashboardStateProps = SaveDashboardProps & {
};
export const saveDashboardState = async ({
controlGroupReferences,
data,
embeddable,
lastSavedId,
@ -81,10 +100,9 @@ export const saveDashboardState = async ({
syncCursor,
syncTooltips,
hidePanelTitles,
controlGroupInput,
} = currentState;
let { panels } = currentState;
let { panels, controlGroupInput } = currentState;
let prefixedPanelReferences = panelReferences;
if (saveOptions.saveAsCopy) {
const { panels: newPanels, references: newPanelReferences } = generateNewPanelIds(
@ -93,10 +111,7 @@ export const saveDashboardState = async ({
);
panels = newPanels;
prefixedPanelReferences = newPanelReferences;
//
// do not need to generate new ids for controls.
// ControlGroup Component is keyed on dashboard id so changing dashboard id mounts new ControlGroup Component.
//
controlGroupInput = generateNewControlIds(controlGroupInput);
}
/**
@ -144,7 +159,7 @@ export const saveDashboardState = async ({
const rawDashboardAttributes: DashboardAttributes = {
version: convertDashboardVersionToNumber(LATEST_DASHBOARD_CONTAINER_VERSION),
controlGroupInput,
controlGroupInput: serializeControlGroupInput(controlGroupInput),
kibanaSavedObjectMeta: { searchSourceJSON },
description: description ?? '',
refreshInterval,
@ -171,11 +186,7 @@ export const saveDashboardState = async ({
? savedObjectsTagging.updateTagsReferences(dashboardReferences, tags)
: dashboardReferences;
const allReferences = [
...references,
...(prefixedPanelReferences ?? []),
...(controlGroupReferences ?? []),
];
const allReferences = [...references, ...(prefixedPanelReferences ?? [])];
/**
* Save the saved object using the content management

View file

@ -7,11 +7,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 { DashboardAttributes, DashboardCrudTypes } from '../../../common/content_management';
import { DashboardCrudTypes } from '../../../common/content_management';
import { DashboardStartDependencies } from '../../plugin';
import { DashboardBackupServiceType } from '../dashboard_backup/types';
import { DashboardDataService } from '../data/types';
@ -64,17 +64,7 @@ export interface LoadDashboardFromSavedObjectProps {
type DashboardResolveMeta = DashboardCrudTypes['GetOut']['meta'];
export type SavedDashboardInput = DashboardContainerInput & {
/**
* 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>;
controlGroupInput?: PersistableControlGroupInput;
};
export interface LoadDashboardReturn {
@ -99,7 +89,6 @@ export interface LoadDashboardReturn {
export type SavedDashboardSaveOpts = SavedObjectSaveOpts & { saveAsCopy?: boolean };
export interface SaveDashboardProps {
controlGroupReferences?: Reference[];
currentState: SavedDashboardInput;
saveOptions: SavedDashboardSaveOpts;
panelReferences?: Reference[];

View file

@ -82,9 +82,6 @@ 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

@ -37,8 +37,6 @@ 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

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

View file

@ -58,19 +58,16 @@ 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

@ -6,6 +6,7 @@
* 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';
@ -13,25 +14,77 @@ 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 } = getPageObjects([
const { dashboard, header, dashboardControls, timePicker } = getPageObjects([
'dashboardControls',
'timePicker',
'dashboard',
'header',
]);
describe('Dashboard control group apply button', () => {
const optionsListId = '41827e70-5285-4d44-8375-4c498449b9a7';
const rangeSliderId = '515e7b9f-4f1b-4a06-beec-763810e4951a';
let controlIds: string[];
before(async () => {
await dashboard.navigateToApp();
await dashboard.loadSavedDashboard('Test Control Group Apply Button');
await dashboard.switchToEditMode();
await dashboard.gotoDashboardLandingPage();
await dashboard.clickNewDashboard();
await timePicker.setDefaultDataRange();
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);
@ -48,7 +101,14 @@ 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);
@ -57,6 +117,7 @@ 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();
@ -78,19 +139,27 @@ 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();
@ -111,8 +180,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('');
@ -130,6 +199,7 @@ 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();
@ -137,6 +207,7 @@ 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();
@ -155,8 +226,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

@ -6,6 +6,7 @@
* 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';
@ -16,29 +17,17 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const kibanaServer = getService('kibanaServer');
const filterBar = getService('filterBar');
const testSubjects = getService('testSubjects');
const { dashboard, dashboardControls } = getPageObjects([
const dashboardAddPanel = getService('dashboardAddPanel');
const { common, dashboard, dashboardControls } = getPageObjects([
'dashboardControls',
'dashboard',
'console',
'common',
'header',
]);
describe('Dashboard control group with multiple data views', () => {
// 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),
]);
}
let controlIds: string[];
before(async () => {
await security.testUser.setRoles(['kibana_admin', 'kibana_sample_admin']);
@ -50,12 +39,50 @@ 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 () => {
@ -66,169 +93,96 @@ 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');
});
describe('courier:ignoreFilterIfFieldNotInIndex enabled', () => {
before(async () => {
await kibanaServer.uiSettings.replace({
'courier:ignoreFilterIfFieldNotInIndex': true,
});
it('ignores global filters on controls using a data view without the filter field', async () => {
await filterBar.addFilter({ field: 'Carrier', operation: 'exists' });
await dashboard.navigateToApp();
await dashboard.loadSavedDashboard('Test Control Group With Multiple Data Views');
});
await dashboardControls.optionsListOpenPopover(controlIds[0]);
expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('4');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]);
after(async () => {
await kibanaServer.uiSettings.unset('courier:ignoreFilterIfFieldNotInIndex');
});
await dashboardControls.validateRange('placeholder', controlIds[1], '100', '1200');
describe('global filters', () => {
before(async () => {
await filterBar.addFilter({
field: 'Carrier',
operation: 'is',
value: 'Kibana Airlines',
});
await waitForAllConrolsLoading();
});
await dashboardControls.optionsListOpenPopover(controlIds[2]);
expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('5');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[2]);
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');
});
});
await dashboardControls.validateRange('placeholder', controlIds[3], '0', '19979');
});
describe('courier:ignoreFilterIfFieldNotInIndex disabled', () => {
before(async () => {
await kibanaServer.uiSettings.replace({
'courier:ignoreFilterIfFieldNotInIndex': false,
});
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]);
await dashboard.navigateToApp();
await dashboard.loadSavedDashboard('Test Control Group With Multiple Data Views');
});
await dashboardControls.validateRange('placeholder', controlIds[1], '100', '1196');
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();
});
const logstashSavedSearchPanel = await testSubjects.find('embeddedSavedSearchDocTable');
expect(
await (
await logstashSavedSearchPanel.findByCssSelector('[data-document-number]')
).getAttribute('data-document-number')
).to.not.be('0');
});
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);
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' });
await dashboardControls.validateRange(
'placeholder',
bytesControlId,
'-Infinity',
'Infinity'
);
});
});
await Promise.all([
dashboardControls.optionsListWaitForLoading(controlIds[0]),
dashboardControls.rangeSliderWaitForLoading(controlIds[1]),
dashboardControls.optionsListWaitForLoading(controlIds[2]),
dashboardControls.rangeSliderWaitForLoading(controlIds[3]),
]);
describe('control filters', () => {
before(async () => {
await dashboardControls.optionsListOpenPopover(carrierControlId);
await dashboardControls.optionsListPopoverSelectOption('Kibana Airlines');
await dashboardControls.optionsListEnsurePopoverIsClosed(carrierControlId);
await waitForAllConrolsLoading();
});
await dashboardControls.clearControlSelections(controlIds[0]);
await dashboardControls.optionsListOpenPopover(controlIds[0]);
expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('4');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]);
after(async () => {
await dashboard.clickDiscardChanges();
});
await dashboardControls.validateRange('placeholder', controlIds[1], '100', '1200');
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.optionsListOpenPopover(controlIds[2]);
expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('0');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[2]);
await dashboardControls.validateRange(
'placeholder',
bytesControlId,
'-Infinity',
'Infinity'
);
});
await dashboardControls.validateRange('placeholder', controlIds[3], '0', '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');
});
});
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');
});
});
}

View file

@ -35,17 +35,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const replaceWithOptionsList = async (controlId: string, field: string) => {
await changeFieldType(controlId, field, OPTIONS_LIST_CONTROL);
const newControlId: string = (await dashboardControls.getAllControlIds())[0];
await testSubjects.waitForEnabled(`optionsList-control-${newControlId}`);
await dashboardControls.verifyControlType(newControlId, 'optionsList-control');
await testSubjects.waitForEnabled(`optionsList-control-${controlId}`);
await dashboardControls.verifyControlType(controlId, 'optionsList-control');
};
const replaceWithRangeSlider = async (controlId: string, field: string) => {
await changeFieldType(controlId, field, RANGE_SLIDER_CONTROL);
await retry.try(async () => {
const newControlId: string = (await dashboardControls.getAllControlIds())[0];
await dashboardControls.rangeSliderWaitForLoading(newControlId);
await dashboardControls.verifyControlType(newControlId, 'range-slider-control');
await dashboardControls.rangeSliderWaitForLoading(controlId);
await dashboardControls.verifyControlType(controlId, 'range-slider-control');
});
};
@ -70,6 +68,7 @@ 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 +78,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
afterEach(async () => {
await dashboardControls.clearAllControls();
await dashboard.clearUnsavedChanges();
});
it('with range slider - default title', async () => {
@ -101,6 +100,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
describe('Replace range slider', () => {
beforeEach(async () => {
await dashboardControls.clearAllControls();
await dashboardControls.createControl({
controlType: RANGE_SLIDER_CONTROL,
dataViewTitle: 'animals-*',
@ -111,7 +111,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
afterEach(async () => {
await dashboardControls.clearAllControls();
await dashboard.clearUnsavedChanges();
});
it('with options list - default title', async () => {

View file

@ -17,7 +17,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const { dashboardControls, dashboard, header } = getPageObjects([
'dashboardControls',
'timePicker',
'dashboard',
'settings',
'console',
'common',
'header',
]);
@ -48,7 +52,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
after(async () => {
await dashboardControls.optionsListEnsurePopoverIsClosed(controlId);
await dashboard.clickDiscardChanges();
});
it('sort alphabetically - descending', async () => {
@ -130,6 +133,12 @@ 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

@ -9,6 +9,7 @@
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';
@ -17,6 +18,8 @@ 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',
@ -29,18 +32,41 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
]);
describe('Dashboard options list validation', () => {
const controlId = 'cd881630-fd28-4e9c-aec5-ae9711d48369';
let controlId: string;
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 () => {
// Instead of reset, filter must be manually deleted to avoid
// https://github.com/elastic/kibana/issues/191675
await dashboardControls.clearControlSelections(controlId);
await filterBar.removeAllFilters();
await queryBar.clickQuerySubmitButton();
});
it('Can mark selections invalid with Query', async () => {
@ -92,13 +118,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,108 +3225,3 @@
"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

@ -1,64 +0,0 @@
{
"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

@ -475,11 +475,7 @@ export class DashboardPageControls extends FtrService {
await this.optionsListWaitForLoading(controlId);
if (!skipOpen) await this.optionsListOpenPopover(controlId);
await this.retry.try(async () => {
const availableOptions = await this.optionsListPopoverGetAvailableOptions();
expect(availableOptions.suggestions).to.eql(expectation.suggestions);
expect(availableOptions.invalidSelections.sort()).to.eql(
expectation.invalidSelections.sort()
);
expect(await this.optionsListPopoverGetAvailableOptions()).to.eql(expectation);
});
if (await this.testSubjects.exists('optionsList-cardinality-label')) {
expect(await this.optionsListGetCardinalityValue()).to.be(
@ -500,9 +496,7 @@ 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, {
typeCharByChar: true,
});
await this.testSubjects.setValue(`optionsList-control-search-input`, search);
await this.optionsListPopoverWaitForLoading();
}

View file

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