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

closes https://github.com/elastic/kibana/issues/191137,
https://github.com/elastic/kibana/issues/190988,
https://github.com/elastic/kibana/issues/191155

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

### Test instructions
1. Open dashboard via dashboard application or portable dashboard
2. Mess around with controls. There should be no changes in behavior

---------

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Hannah Mudge <Heenawter@users.noreply.github.com>
This commit is contained in:
Nathan Reese 2024-08-30 09:10:59 -06:00 committed by GitHub
parent cd56f4103b
commit c272d9715e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
64 changed files with 1164 additions and 1058 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -34,12 +34,19 @@ import { chaining$, controlFetch$, controlGroupFetch$ } from './control_fetch';
import { initControlsManager } from './init_controls_manager';
import { openEditControlGroupFlyout } from './open_edit_control_group_flyout';
import { deserializeControlGroup } from './serialization_utils';
import { ControlGroupApi, ControlGroupRuntimeState, ControlGroupSerializedState } from './types';
import {
ControlGroupApi,
ControlGroupRuntimeState,
ControlGroupSerializedState,
ControlPanelsState,
} from './types';
import { ControlGroup } from './components/control_group';
import { initSelectionsManager } from './selections_manager';
import { initializeControlGroupUnsavedChanges } from './control_group_unsaved_changes_api';
import { openDataControlEditor } from '../controls/data_controls/open_data_control_editor';
const DEFAULT_CHAINING_SYSTEM = 'HIERARCHICAL';
export const getControlGroupEmbeddableFactory = (services: {
core: CoreStart;
dataViews: DataViewsPublicPluginStart;
@ -60,7 +67,6 @@ export const getControlGroupEmbeddableFactory = (services: {
lastSavedRuntimeState
) => {
const {
initialChildControlState,
labelPosition: initialLabelPosition,
chainingSystem,
autoApplySelections,
@ -68,19 +74,22 @@ export const getControlGroupEmbeddableFactory = (services: {
} = initialRuntimeState;
const autoApplySelections$ = new BehaviorSubject<boolean>(autoApplySelections);
const parentDataViewId = apiPublishesDataViews(parentApi)
? parentApi.dataViews.value?.[0]?.id
: undefined;
const defaultDataViewId = await services.dataViews.getDefaultId();
const lastSavedControlsState$ = new BehaviorSubject<ControlPanelsState>(
lastSavedRuntimeState.initialChildControlState
);
const controlsManager = initControlsManager(
initialChildControlState,
parentDataViewId ?? (await services.dataViews.getDefaultId())
initialRuntimeState.initialChildControlState,
lastSavedControlsState$
);
const selectionsManager = initSelectionsManager({
...controlsManager.api,
autoApplySelections$,
});
const dataViews = new BehaviorSubject<DataView[] | undefined>(undefined);
const chainingSystem$ = new BehaviorSubject<ControlGroupChainingSystem>(chainingSystem);
const chainingSystem$ = new BehaviorSubject<ControlGroupChainingSystem>(
chainingSystem ?? DEFAULT_CHAINING_SYSTEM
);
const ignoreParentSettings$ = new BehaviorSubject<ParentIgnoreSettings | undefined>(
ignoreParentSettings
);
@ -104,6 +113,7 @@ export const getControlGroupEmbeddableFactory = (services: {
chainingSystem: [
chainingSystem$,
(next: ControlGroupChainingSystem) => chainingSystem$.next(next),
(a, b) => (a ?? DEFAULT_CHAINING_SYSTEM) === (b ?? DEFAULT_CHAINING_SYSTEM),
],
ignoreParentSettings: [
ignoreParentSettings$,
@ -113,6 +123,7 @@ export const getControlGroupEmbeddableFactory = (services: {
labelPosition: [labelPosition$, (next: ControlStyle) => labelPosition$.next(next)],
},
controlsManager.snapshotControlsRuntimeState,
controlsManager.resetControlsUnsavedChanges,
parentApi,
lastSavedRuntimeState
);
@ -159,20 +170,28 @@ export const getControlGroupEmbeddableFactory = (services: {
i18n.translate('controls.controlGroup.displayName', {
defaultMessage: 'Controls',
}),
openAddDataControlFlyout: (settings) => {
const { controlInputTransform } = settings ?? {
controlInputTransform: (state) => state,
};
openAddDataControlFlyout: (options) => {
const parentDataViewId = apiPublishesDataViews(parentApi)
? parentApi.dataViews.value?.[0]?.id
: undefined;
const newControlState = controlsManager.getNewControlState();
openDataControlEditor({
initialState: controlsManager.getNewControlState(),
initialState: {
...newControlState,
dataViewId:
newControlState.dataViewId ?? parentDataViewId ?? defaultDataViewId ?? undefined,
},
onSave: ({ type: controlType, state: initialState }) => {
controlsManager.api.addNewPanel({
panelType: controlType,
initialState: controlInputTransform!(
initialState as Partial<ControlGroupSerializedState>,
controlType
),
initialState: options?.controlInputTransform
? options.controlInputTransform(
initialState as Partial<ControlGroupSerializedState>,
controlType
)
: initialState,
});
options?.onSave?.();
},
controlGroupApi: api,
services,
@ -207,6 +226,20 @@ 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<{
@ -235,6 +268,7 @@ export const getControlGroupEmbeddableFactory = (services: {
return () => {
selectionsManager.cleanup();
childrenDataViewsSubscription.unsubscribe();
saveNotificationSubscription?.unsubscribe();
};
}, []);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,22 @@
/*
* 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,16 +35,17 @@ import './components/index.scss';
import { TimeSliderPrepend } from './components/time_slider_prepend';
import { TIME_SLIDER_CONTROL } from '../../../../common';
const displayName = i18n.translate('controls.timesliderControl.displayName', {
defaultMessage: 'Time slider',
});
export const getTimesliderControlFactory = (
services: Services
): ControlFactory<TimesliderControlState, TimesliderControlApi> => {
return {
type: TIME_SLIDER_CONTROL,
getIconType: () => 'search',
getDisplayName: () =>
i18n.translate('controls.timesliderControl.displayName', {
defaultMessage: 'Time slider',
}),
getDisplayName: () => displayName,
buildControl: async (initialState, buildApi, uuid, controlGroupApi) => {
const { timeRangeMeta$, formatDate, cleanupTimeRangeSubscription } =
initTimeRangeSubscription(controlGroupApi, services);
@ -203,6 +204,7 @@ 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 { PublishesTimeslice } from '@kbn/presentation-publishing';
import type { PublishesPanelTitle, PublishesTimeslice } from '@kbn/presentation-publishing';
import type { DefaultControlApi, DefaultControlState } from '../types';
export type Timeslice = [number, number];
@ -20,7 +20,9 @@ export interface TimesliderControlState extends DefaultControlState {
timesliceEndAsPercentageOfTimeRange?: number;
}
export type TimesliderControlApi = DefaultControlApi & PublishesTimeslice;
export type TimesliderControlApi = DefaultControlApi &
Pick<PublishesPanelTitle, 'defaultPanelTitle'> &
PublishesTimeslice;
export interface Services {
core: CoreStart;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -9,12 +9,19 @@
import { debounce } from 'lodash';
import classNames from 'classnames';
import useResizeObserver from 'use-resize-observer/polyfilled';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { EuiPortal } from '@elastic/eui';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { ReactEmbeddableRenderer, ViewMode } from '@kbn/embeddable-plugin/public';
import { ExitFullScreenButton } from '@kbn/shared-ux-button-exit-full-screen';
import {
ControlGroupApi,
ControlGroupRuntimeState,
ControlGroupSerializedState,
} from '@kbn/controls-plugin/public';
import { CONTROL_GROUP_TYPE } from '@kbn/controls-plugin/common';
import { useStateFromPublishingSubject } from '@kbn/presentation-publishing';
import { DashboardGrid } from '../grid';
import { useDashboardContainer } from '../../embeddable/dashboard_container';
import { DashboardEmptyScreen } from '../empty_screen/dashboard_empty_screen';
@ -34,23 +41,11 @@ export const useDebouncedWidthObserver = (skipDebounce = false, wait = 100) => {
};
export const DashboardViewportComponent = () => {
const controlsRoot = useRef(null);
const dashboard = useDashboardContainer();
/**
* Render Control group
*/
const controlGroup = dashboard.controlGroup;
useEffect(() => {
if (controlGroup && controlsRoot.current) controlGroup.render(controlsRoot.current);
}, [controlGroup]);
const controlGroupApi = useStateFromPublishingSubject(dashboard.controlGroupApi$);
const panelCount = Object.keys(dashboard.select((state) => state.explicitInput.panels)).length;
const controlCount = Object.keys(
controlGroup?.select((state) => state.explicitInput.panels) ?? {}
).length;
const [hasControls, setHasControls] = useState(false);
const viewMode = dashboard.select((state) => state.explicitInput.viewMode);
const dashboardTitle = dashboard.select((state) => state.explicitInput.title);
const useMargins = dashboard.select((state) => state.explicitInput.useMargins);
@ -65,17 +60,59 @@ 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,
})}
>
{controlGroup && viewMode !== ViewMode.PRINT ? (
<div
className={controlCount > 0 ? 'dshDashboardViewport-controls' : ''}
ref={controlsRoot}
/>
{viewMode !== ViewMode.PRINT ? (
<div className={hasControls ? 'dshDashboardViewport-controls' : ''}>
<ReactEmbeddableRenderer<
ControlGroupSerializedState,
ControlGroupRuntimeState,
ControlGroupApi
>
key={dashboard.getInput().id}
hidePanelChrome={true}
panelProps={{ hideLoader: true }}
type={CONTROL_GROUP_TYPE}
maybeId={'control_group'}
getParentApi={() => {
return {
...dashboard,
getSerializedStateForChild: dashboard.getSerializedStateForControlGroup,
getRuntimeStateForChild: dashboard.getRuntimeStateForControlGroup,
};
}}
onApiAvailable={(api) => dashboard.setControlGroupApi(api)}
/>
</div>
) : null}
{panelCount === 0 && <DashboardEmptyScreen />}
<div
@ -88,7 +125,9 @@ 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 && <DashboardGrid viewportWidth={viewportWidth} />}
{viewportWidth !== 0 && dashboardInitialized && (
<DashboardGrid viewportWidth={viewportWidth} />
)}
</div>
</div>
);

View file

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

View file

@ -6,11 +6,9 @@
* 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');
@ -51,46 +49,41 @@ const testFilter3: Filter = {
},
};
const mockControlGroupContainer = new ControlGroupContainer(
{ getTools: () => {} } as unknown as ReduxToolsPackage,
mockControlGroupInput()
);
describe('combineDashboardFiltersWithControlGroupFilters', () => {
it('Combined filter pills do not get overwritten', async () => {
const dashboardFilterPills = [testFilter1, testFilter2];
const mockControlGroupApi = {
filters$: new BehaviorSubject<Filter[] | undefined>([]),
};
const combinedFilters = combineDashboardFiltersWithControlGroupFilters(
dashboardFilterPills,
mockControlGroupApi
);
expect(combinedFilters).toEqual(dashboardFilterPills);
});
describe('Test dashboard control group', () => {
describe('Combine dashboard filters with control group filters test', () => {
it('Combined filter pills do not get overwritten', async () => {
const dashboardFilterPills = [testFilter1, testFilter2];
mockControlGroupContainer.getOutput = jest.fn().mockReturnValue({ filters: [] });
const combinedFilters = combineDashboardFiltersWithControlGroupFilters(
dashboardFilterPills,
mockControlGroupContainer
);
expect(combinedFilters).toEqual(dashboardFilterPills);
});
it('Combined control filters do not get overwritten', async () => {
const controlGroupFilters = [testFilter1, testFilter2];
const mockControlGroupApi = {
filters$: new BehaviorSubject<Filter[] | undefined>(controlGroupFilters),
};
const combinedFilters = combineDashboardFiltersWithControlGroupFilters(
[] as Filter[],
mockControlGroupApi
);
expect(combinedFilters).toEqual(controlGroupFilters);
});
it('Combined control filters do not get overwritten', async () => {
const controlGroupFilters = [testFilter1, testFilter2];
mockControlGroupContainer.getOutput = jest
.fn()
.mockReturnValue({ filters: controlGroupFilters });
const combinedFilters = combineDashboardFiltersWithControlGroupFilters(
[] as Filter[],
mockControlGroupContainer
);
expect(combinedFilters).toEqual(controlGroupFilters);
});
it('Combined dashboard filter pills and control filters do not get overwritten', async () => {
const dashboardFilterPills = [testFilter1, testFilter2];
const controlGroupFilters = [testFilter3];
mockControlGroupContainer.getOutput = jest
.fn()
.mockReturnValue({ filters: controlGroupFilters });
const combinedFilters = combineDashboardFiltersWithControlGroupFilters(
dashboardFilterPills,
mockControlGroupContainer
);
expect(combinedFilters).toEqual(dashboardFilterPills.concat(controlGroupFilters));
});
it('Combined dashboard filter pills and control filters do not get overwritten', async () => {
const dashboardFilterPills = [testFilter1, testFilter2];
const controlGroupFilters = [testFilter3];
const mockControlGroupApi = {
filters$: new BehaviorSubject<Filter[] | undefined>(controlGroupFilters),
};
const combinedFilters = combineDashboardFiltersWithControlGroupFilters(
dashboardFilterPills,
mockControlGroupApi
);
expect(combinedFilters).toEqual(dashboardFilterPills.concat(controlGroupFilters));
});
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -8,7 +8,6 @@
import { METRIC_TYPE } from '@kbn/analytics';
import type { Reference } from '@kbn/content-management-utils';
import type { ControlGroupContainer } from '@kbn/controls-plugin/public';
import type { I18nStart, KibanaExecutionContext, OverlayRef } from '@kbn/core/public';
import {
type PublishingSubject,
@ -16,6 +15,8 @@ import {
apiPublishesUnsavedChanges,
getPanelTitle,
PublishesViewMode,
PublishesDataLoading,
apiPublishesDataLoading,
} from '@kbn/presentation-publishing';
import { RefreshInterval } from '@kbn/data-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
@ -32,7 +33,7 @@ import {
type EmbeddableOutput,
type IEmbeddable,
} from '@kbn/embeddable-plugin/public';
import type { Filter, Query, TimeRange } from '@kbn/es-query';
import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import {
HasRuntimeChildState,
@ -40,6 +41,7 @@ import {
HasSerializedChildState,
TrackContentfulRender,
TracksQueryPerformance,
combineCompatibleChildrenApis,
} from '@kbn/presentation-containers';
import { PanelPackage } from '@kbn/presentation-containers';
import { ReduxEmbeddableTools, ReduxToolsPackage } from '@kbn/presentation-util-plugin/public';
@ -50,14 +52,18 @@ import { omit } from 'lodash';
import React, { createContext, useContext } from 'react';
import ReactDOM from 'react-dom';
import { batch } from 'react-redux';
import { BehaviorSubject, Subject, Subscription } from 'rxjs';
import { BehaviorSubject, Subject, Subscription, first, skipWhile, switchMap } from 'rxjs';
import { distinctUntilChanged, map } from 'rxjs';
import { v4 } from 'uuid';
import { PublishesSettings } from '@kbn/presentation-containers/interfaces/publishes_settings';
import { apiHasSerializableState } from '@kbn/presentation-containers/interfaces/serialized_state';
import { ControlGroupApi, ControlGroupSerializedState } from '@kbn/controls-plugin/public';
import { DashboardLocatorParams, DASHBOARD_CONTAINER_TYPE } from '../..';
import { DashboardContainerInput, DashboardPanelState } from '../../../common';
import { getReferencesForPanelId } from '../../../common/dashboard_container/persistable_state/dashboard_container_references';
import { DashboardAttributes, DashboardContainerInput, DashboardPanelState } from '../../../common';
import {
getReferencesForControls,
getReferencesForPanelId,
} from '../../../common/dashboard_container/persistable_state/dashboard_container_references';
import {
DASHBOARD_APP_ID,
DASHBOARD_UI_METRIC_ID,
@ -84,7 +90,10 @@ import {
showSettings,
} from './api';
import { duplicateDashboardPanel } from './api/duplicate_dashboard_panel';
import { combineDashboardFiltersWithControlGroupFilters } from './create/controls/dashboard_control_group_integration';
import {
combineDashboardFiltersWithControlGroupFilters,
startSyncingDashboardControlGroup,
} from './create/controls/dashboard_control_group_integration';
import { initializeDashboard } from './create/create_dashboard';
import {
DashboardCreationOptions,
@ -92,6 +101,7 @@ import {
dashboardTypeDisplayName,
} from './dashboard_container_factory';
import { getPanelAddedSuccessString } from '../../dashboard_app/_dashboard_app_strings';
import { PANELS_CONTROL_GROUP_KEY } from '../../services/dashboard_backup/dashboard_backup_service';
export interface InheritedChildInput {
filters: Filter[];
@ -147,7 +157,7 @@ export class DashboardContainer
public integrationSubscriptions: Subscription = new Subscription();
public publishingSubscription: Subscription = new Subscription();
public diffingSubscription: Subscription = new Subscription();
public controlGroup?: ControlGroupContainer;
public controlGroupApi$: PublishingSubject<ControlGroupApi | undefined>;
public settings: Record<string, PublishingSubject<boolean | undefined>>;
public searchSessionId?: string;
@ -156,6 +166,7 @@ export class DashboardContainer
public reload$ = new Subject<void>();
public timeRestore$: BehaviorSubject<boolean | undefined>;
public timeslice$: BehaviorSubject<[number, number] | undefined>;
public unifiedSearchFilters$?: PublishingSubject<Filter[] | undefined>;
public locator?: Pick<LocatorPublic<DashboardLocatorParams>, 'navigate' | 'getRedirectUrl'>;
public readonly executionContext: KibanaExecutionContext;
@ -172,6 +183,9 @@ export class DashboardContainer
private hadContentfulRender = false;
private scrollPosition?: number;
// setup
public untilContainerInitialized: () => Promise<void>;
// cleanup
public stopSyncingWithUnifiedSearch?: () => void;
private cleanupStateTools: () => void;
@ -197,6 +211,7 @@ export class DashboardContainer
| undefined;
// new embeddable framework
public savedObjectReferences: Reference[] = [];
public controlGroupInput: DashboardAttributes['controlGroupInput'] | undefined;
constructor(
initialInput: DashboardContainerInput,
@ -207,19 +222,43 @@ 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
parent,
{
untilContainerInitialized,
}
);
this.controlGroupApi$ = controlGroupApi$;
this.untilContainerInitialized = untilContainerInitialized;
this.trackPanelAddMetric = usageCollection.reportUiCounter?.bind(
usageCollection,
DASHBOARD_UI_METRIC_ID
@ -311,7 +350,41 @@ export class DashboardContainer
DashboardContainerInput
>(this.publishingSubscription, this, 'lastReloadRequestTime');
startSyncingDashboardControlGroup(this);
this.executionContext = initialInput.executionContext;
this.dataLoading = new BehaviorSubject<boolean | undefined>(false);
this.publishingSubscription.add(
combineCompatibleChildrenApis<PublishesDataLoading, boolean | undefined>(
this,
'dataLoading',
apiPublishesDataLoading,
undefined,
// flatten method
(values) => {
return values.some((isLoading) => isLoading);
}
).subscribe((isAtLeastOneChildLoading) => {
(this.dataLoading as BehaviorSubject<boolean | undefined>).next(isAtLeastOneChildLoading);
})
);
this.dataViews = new BehaviorSubject<DataView[] | undefined>(this.getAllDataViews());
const query$ = new BehaviorSubject<Query | AggregateQuery | undefined>(this.getInput().query);
this.query$ = query$;
this.publishingSubscription.add(
this.getInput$().subscribe((input) => {
if (!deepEqual(query$.getValue() ?? [], input.query)) {
query$.next(input.query);
}
})
);
}
public setControlGroupApi(controlGroupApi: ControlGroupApi) {
(this.controlGroupApi$ as BehaviorSubject<ControlGroupApi | undefined>).next(controlGroupApi);
}
public getAppContext() {
@ -397,10 +470,10 @@ export class DashboardContainer
panels,
} = this.input;
let combinedFilters = filters;
if (this.controlGroup) {
combinedFilters = combineDashboardFiltersWithControlGroupFilters(filters, this.controlGroup);
}
const combinedFilters = combineDashboardFiltersWithControlGroupFilters(
filters,
this.controlGroupApi$?.value
);
const hasCustomTimeRange = Boolean(
(panels[id]?.explicitInput as Partial<InheritedChildInput>)?.timeRange
);
@ -429,7 +502,6 @@ export class DashboardContainer
public destroy() {
super.destroy();
this.cleanupStateTools();
this.controlGroup?.destroy();
this.diffingSubscription.unsubscribe();
this.publishingSubscription.unsubscribe();
this.integrationSubscriptions.unsubscribe();
@ -615,16 +687,12 @@ export class DashboardContainer
public forceRefresh(refreshControlGroup: boolean = true) {
this.dispatch.setLastReloadRequestTimeToNow({});
if (refreshControlGroup) {
this.controlGroup?.reload();
// only reload all panels if this refresh does not come from the control group.
this.reload$.next();
}
}
public onDataViewsUpdate$ = new Subject<DataView[]>();
public resetToLastSavedState() {
public async asyncResetToLastSavedState() {
this.dispatch.resetToLastSavedInput({});
const {
explicitInput: { timeRange, refreshInterval },
@ -633,8 +701,8 @@ export class DashboardContainer
},
} = this.getState();
if (this.controlGroup) {
this.controlGroup.resetToLastSavedState();
if (this.controlGroupApi$.value) {
await this.controlGroupApi$.value.asyncResetUnsavedChanges();
}
// if we are using the unified search integration, we need to force reset the time picker.
@ -679,7 +747,6 @@ export class DashboardContainer
const initializeResult = await initializeDashboard({
creationOptions: this.creationOptions,
controlGroup: this.controlGroup,
untilDashboardReady,
loadDashboardReturn,
});
@ -694,9 +761,6 @@ export class DashboardContainer
omit(loadDashboardReturn?.dashboardInput, 'controlGroupInput')
);
this.dispatch.setManaged(loadDashboardReturn?.managed);
if (this.controlGroup) {
this.controlGroup.setSavedState(loadDashboardReturn.dashboardInput?.controlGroupInput);
}
this.dispatch.setAnimatePanelTransforms(false); // prevents panels from animating on navigate.
this.dispatch.setLastSavedId(newSavedObjectId);
this.setExpandedPanelId(undefined);
@ -720,7 +784,7 @@ export class DashboardContainer
*/
public setAllDataViews = (newDataViews: DataView[]) => {
this.allDataViews = newDataViews;
this.onDataViewsUpdate$.next(newDataViews);
(this.dataViews as BehaviorSubject<DataView[] | undefined>).next(newDataViews);
};
public getExpandedPanelId = () => {
@ -743,7 +807,6 @@ export class DashboardContainer
public clearOverlays = () => {
this.dispatch.setHasOverlays(false);
this.dispatch.setFocusedPanelId(undefined);
this.controlGroup?.closeAllFlyouts();
this.overlayRef?.close();
};
@ -848,6 +911,22 @@ export class DashboardContainer
};
};
public getSerializedStateForControlGroup = () => {
return {
rawState: this.controlGroupInput
? (this.controlGroupInput as ControlGroupSerializedState)
: ({
controlStyle: 'oneLine',
chainingSystem: 'HIERARCHICAL',
showApplySelections: false,
panelsJSON: '{}',
ignoreParentSettingsJSON:
'{"ignoreFilters":false,"ignoreQuery":false,"ignoreTimerange":false,"ignoreValidations":false}',
} as ControlGroupSerializedState),
references: getReferencesForControls(this.savedObjectReferences),
};
};
private restoredRuntimeState: UnsavedPanelState | undefined = undefined;
public setRuntimeStateForChild = (childId: string, state: object) => {
const runtimeState = this.restoredRuntimeState ?? {};
@ -858,6 +937,10 @@ export class DashboardContainer
return this.restoredRuntimeState?.[childId];
};
public getRuntimeStateForControlGroup = () => {
return this.getRuntimeStateForChild(PANELS_CONTROL_GROUP_KEY);
};
public removePanel(id: string) {
const {
embeddable: { reactEmbeddableRegistryHasKey },

View file

@ -5,11 +5,10 @@
* 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, Observable, of, startWith, switchMap } from 'rxjs';
import { combineLatest, debounceTime, skipWhile, startWith, switchMap } from 'rxjs';
import { DashboardContainer, DashboardCreationOptions } from '../..';
import { DashboardContainerInput } from '../../../../common';
import { CHANGE_CHECK_DEBOUNCE } from '../../../dashboard_constants';
@ -17,6 +16,7 @@ import { pluginServices } from '../../../services/plugin_services';
import { UnsavedPanelState } from '../../types';
import { dashboardContainerReducers } from '../dashboard_container_reducers';
import { isKeyEqualAsync, unsavedChangesDiffingFunctions } from './dashboard_diffing_functions';
import { PANELS_CONTROL_GROUP_KEY } from '../../../services/dashboard_backup/dashboard_backup_service';
/**
* An array of reducers which cannot cause unsaved changes. Unsaved changes only compares the explicit input
@ -111,8 +111,12 @@ export function startDiffingDashboardState(
combineLatest([
dashboardUnsavedChanges,
childrenUnsavedChanges$(this.children$),
this.controlGroup?.unsavedChanges ??
(of(undefined) as Observable<PersistableControlGroupInput | undefined>),
this.controlGroupApi$.pipe(
skipWhile((controlGroupApi) => !controlGroupApi),
switchMap((controlGroupApi) => {
return controlGroupApi!.unsavedChanges;
})
),
]).subscribe(([dashboardChanges, unsavedPanelState, controlGroupChanges]) => {
// calculate unsaved changes
const hasUnsavedChanges =
@ -125,11 +129,11 @@ export function startDiffingDashboardState(
// backup unsaved changes if configured to do so
if (creationOptions?.useSessionStorageIntegration) {
backupUnsavedChanges.bind(this)(
dashboardChanges,
unsavedPanelState ? unsavedPanelState : {},
controlGroupChanges
);
const reactEmbeddableChanges = unsavedPanelState ? { ...unsavedPanelState } : {};
if (controlGroupChanges) {
reactEmbeddableChanges[PANELS_CONTROL_GROUP_KEY] = controlGroupChanges;
}
backupUnsavedChanges.bind(this)(dashboardChanges, reactEmbeddableChanges);
}
})
);
@ -181,8 +185,7 @@ export async function getDashboardUnsavedChanges(
function backupUnsavedChanges(
this: DashboardContainer,
dashboardChanges: Partial<DashboardContainerInput>,
reactEmbeddableChanges: UnsavedPanelState,
controlGroupChanges: PersistableControlGroupInput | undefined
reactEmbeddableChanges: UnsavedPanelState
) {
const { dashboardBackup } = pluginServices.getServices();
const dashboardStateToBackup = omit(dashboardChanges, keysToOmitFromSessionStorage);
@ -192,7 +195,6 @@ 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 input
* Control group changes
*/
controlGroupInput?: SerializableControlGroupInput;
controlGroupState?: Partial<ControlGroupRuntimeState> & SerializableRecord; // used SerializableRecord here to force the GridData type to be read as serializable
};

View file

@ -15,7 +15,6 @@ import {
getContextProvider as getPresentationUtilContextProvider,
} from '@kbn/presentation-util-plugin/public';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
import { TopNavMenuBadgeProps, TopNavMenuProps } from '@kbn/navigation-plugin/public';
import {
EuiBreadcrumb,
@ -29,6 +28,7 @@ import {
import { MountPoint } from '@kbn/core/public';
import { getManagedContentBadge } from '@kbn/managed-content-badge';
import { FormattedMessage } from '@kbn/i18n-react';
import { useStateFromPublishingSubject } from '@kbn/presentation-publishing';
import {
getDashboardTitle,
leaveConfirmStrings,
@ -113,16 +113,8 @@ export function InternalDashboardTopNav({
const query = dashboard.select((state) => state.explicitInput.query);
const title = dashboard.select((state) => state.explicitInput.title);
// store data views in state & subscribe to dashboard data view changes.
const [allDataViews, setAllDataViews] = useState<DataView[]>([]);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
useEffect(() => {
setAllDataViews(dashboard.getAllDataViews());
const subscription = dashboard.onDataViewsUpdate$.subscribe((dataViews) =>
setAllDataViews(dataViews)
);
return () => subscription.unsubscribe();
}, [dashboard]);
const allDataViews = useStateFromPublishingSubject(dashboard.dataViews);
const dashboardTitle = useMemo(() => {
return getDashboardTitle(title, viewMode, !lastSavedId);
@ -411,7 +403,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,6 +9,8 @@
import { EmbeddableInput, ViewMode } from '@kbn/embeddable-plugin/public';
import { mockedReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public/mocks';
import { ControlGroupApi } from '@kbn/controls-plugin/public';
import { BehaviorSubject } from 'rxjs';
import { DashboardContainerInput, DashboardPanelState } from '../common';
import { DashboardContainer } from './dashboard_container/embeddable/dashboard_container';
import { DashboardStart } from './plugin';
@ -72,6 +74,15 @@ export function setupIntersectionObserverMock({
});
}
export const mockControlGroupApi = {
untilInitialized: async () => {},
filters$: new BehaviorSubject(undefined),
query$: new BehaviorSubject(undefined),
timeslice$: new BehaviorSubject(undefined),
dataViews: new BehaviorSubject(undefined),
unsavedChanges: new BehaviorSubject(undefined),
} as unknown as ControlGroupApi;
export function buildMockDashboard({
overrides,
savedObjectId,
@ -89,6 +100,7 @@ export function buildMockDashboard({
undefined,
{ lastSavedInput: initialInput, lastSavedId: savedObjectId }
);
dashboardContainer?.setControlGroupApi(mockControlGroupApi);
return dashboardContainer;
}

View file

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

View file

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

View file

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

View file

@ -6,9 +6,6 @@
* 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';
@ -32,23 +29,6 @@ describe('Migrate dashboard input', () => {
panel3: getSampleDashboardPanel({ type: 'ultraDiscover', explicitInput: { id: 'panel3' } }),
panel4: getSampleDashboardPanel({ type: 'ultraDiscover', explicitInput: { id: 'panel4' } }),
};
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
controlGroupInputBuilder.addOptionsListControl(controlGroupInput, {
dataViewId: 'positions-remain-fixed',
title: 'Results can be mixed',
fieldName: 'theres-a-stasis',
width: 'medium',
grow: false,
});
controlGroupInputBuilder.addRangeSliderControl(controlGroupInput, {
dataViewId: 'an-object-set-in-motion',
title: 'The arbiter of time',
fieldName: 'unexpressed-emotion',
width: 'medium',
grow: false,
});
controlGroupInputBuilder.addTimeSliderControl(controlGroupInput);
dashboardInput.controlGroupInput = controlGroupInput;
const embeddableService: DashboardEmbeddableService = {
getEmbeddableFactory: jest.fn(() => ({
@ -62,11 +42,8 @@ describe('Migrate dashboard input', () => {
// migration run should be true because the runEmbeddableFactoryMigrations mock above returns true.
expect(result.anyMigrationRun).toBe(true);
expect(embeddableService.getEmbeddableFactory).toHaveBeenCalledTimes(7); // should be called 4 times for the panels, and 3 times for the controls
expect(embeddableService.getEmbeddableFactory).toHaveBeenCalledTimes(4); // should be called 4 times for the panels, and 3 times for the controls
expect(embeddableService.getEmbeddableFactory).toHaveBeenCalledWith('superLens');
expect(embeddableService.getEmbeddableFactory).toHaveBeenCalledWith('ultraDiscover');
expect(embeddableService.getEmbeddableFactory).toHaveBeenCalledWith('optionsListControl');
expect(embeddableService.getEmbeddableFactory).toHaveBeenCalledWith('rangeSliderControl');
expect(embeddableService.getEmbeddableFactory).toHaveBeenCalledWith('timeSlider');
});
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,7 +6,6 @@
* 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';
@ -14,77 +13,25 @@ import { FtrProviderContext } from '../../../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const pieChart = getService('pieChart');
const elasticChart = getService('elasticChart');
const testSubjects = getService('testSubjects');
const dashboardAddPanel = getService('dashboardAddPanel');
const { dashboard, header, dashboardControls, timePicker } = getPageObjects([
const { dashboard, header, dashboardControls } = getPageObjects([
'dashboardControls',
'timePicker',
'dashboard',
'header',
]);
describe('Dashboard control group apply button', () => {
let controlIds: string[];
const optionsListId = '41827e70-5285-4d44-8375-4c498449b9a7';
const rangeSliderId = '515e7b9f-4f1b-4a06-beec-763810e4951a';
before(async () => {
await dashboard.navigateToApp();
await dashboard.gotoDashboardLandingPage();
await dashboard.clickNewDashboard();
await timePicker.setDefaultDataRange();
await dashboard.loadSavedDashboard('Test Control Group Apply Button');
await dashboard.switchToEditMode();
await elasticChart.setNewChartUiDebugFlag();
await dashboardAddPanel.addVisualization('Rendering-Test:-animal-sounds-pie');
// save the dashboard before adding controls
await dashboard.saveDashboard('Test Control Group Apply Button', {
exitFromEditMode: false,
saveAsNew: true,
});
await header.waitUntilLoadingHasFinished();
await dashboard.waitForRenderComplete();
await dashboard.expectMissingUnsavedChangesBadge();
// populate an initial set of controls and get their ids.
await dashboardControls.createControl({
controlType: OPTIONS_LIST_CONTROL,
dataViewTitle: 'animals-*',
fieldName: 'animal.keyword',
title: 'Animal',
});
await dashboardControls.createControl({
controlType: RANGE_SLIDER_CONTROL,
dataViewTitle: 'animals-*',
fieldName: 'weightLbs',
title: 'Animal Name',
});
await dashboardControls.createTimeSliderControl();
// wait for all controls to finish loading before saving
controlIds = await dashboardControls.getAllControlIds();
await dashboardControls.optionsListWaitForLoading(controlIds[0]);
await dashboardControls.rangeSliderWaitForLoading(controlIds[1]);
// re-save the dashboard
await dashboard.clickQuickSave();
await header.waitUntilLoadingHasFinished();
await dashboard.waitForRenderComplete();
await dashboard.expectMissingUnsavedChangesBadge();
});
it('able to set apply button setting', async () => {
await dashboardControls.updateShowApplyButtonSetting(true);
await testSubjects.existOrFail('controlGroup--applyFiltersButton');
await dashboard.expectUnsavedChangesBadge();
await dashboard.clickQuickSave();
await header.waitUntilLoadingHasFinished();
await dashboard.expectMissingUnsavedChangesBadge();
});
it('renabling auto-apply forces filters to be published', async () => {
const optionsListId = controlIds[0];
await dashboardControls.verifyApplyButtonEnabled(false);
await dashboardControls.optionsListOpenPopover(optionsListId);
await dashboardControls.optionsListPopoverSelectOption('cat');
await dashboardControls.optionsListEnsurePopoverIsClosed(optionsListId);
@ -101,14 +48,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
describe('options list selections', () => {
let optionsListId: string;
before(async () => {
optionsListId = controlIds[0];
});
it('making selection enables apply button', async () => {
await dashboardControls.verifyApplyButtonEnabled(false);
await dashboardControls.optionsListOpenPopover(optionsListId);
await dashboardControls.optionsListPopoverSelectOption('cat');
await dashboardControls.optionsListEnsurePopoverIsClosed(optionsListId);
@ -117,7 +57,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
it('waits to apply filters until button is pressed', async () => {
await dashboard.expectMissingUnsavedChangesBadge();
expect(await pieChart.getPieSliceCount()).to.be(5);
await dashboardControls.clickApplyButton();
@ -139,27 +78,19 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await header.waitUntilLoadingHasFinished();
await dashboard.waitForRenderComplete();
await dashboard.expectMissingUnsavedChangesBadge();
expect(await pieChart.getPieSliceCount()).to.be(5);
await dashboardControls.verifyApplyButtonEnabled(false);
expect(await dashboardControls.optionsListGetSelectionsString(optionsListId)).to.be('Any');
});
});
describe('range slider selections', () => {
let rangeSliderId: string;
before(async () => {
rangeSliderId = controlIds[1];
});
it('making selection enables apply button', async () => {
await dashboardControls.verifyApplyButtonEnabled(false);
await dashboardControls.rangeSliderSetUpperBound(rangeSliderId, '30');
await dashboardControls.verifyApplyButtonEnabled();
});
it('waits to apply filters until apply button is pressed', async () => {
await dashboard.expectMissingUnsavedChangesBadge();
expect(await pieChart.getPieSliceCount()).to.be(5);
await dashboardControls.clickApplyButton();
@ -180,8 +111,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('');
@ -199,7 +130,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
it('making selection enables apply button', async () => {
await dashboardControls.verifyApplyButtonEnabled(false);
await dashboardControls.gotoNextTimeSlice();
await dashboardControls.gotoNextTimeSlice(); // go to an empty timeslice
await header.waitUntilLoadingHasFinished();
@ -207,7 +137,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
it('waits to apply timeslice until apply button is pressed', async () => {
await dashboard.expectMissingUnsavedChangesBadge();
expect(await pieChart.getPieSliceCount()).to.be(5);
await dashboardControls.clickApplyButton();
@ -226,8 +155,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,7 +6,6 @@
* 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';
@ -17,17 +16,29 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const kibanaServer = getService('kibanaServer');
const filterBar = getService('filterBar');
const testSubjects = getService('testSubjects');
const dashboardAddPanel = getService('dashboardAddPanel');
const { common, dashboard, dashboardControls } = getPageObjects([
const { dashboard, dashboardControls } = getPageObjects([
'dashboardControls',
'dashboard',
'console',
'common',
'header',
]);
describe('Dashboard control group with multiple data views', () => {
let controlIds: string[];
// Controls from flights data view
const carrierControlId = '265b6a28-9ccb-44ae-83c9-3d7a7cac1961';
const ticketPriceControlId = 'ed2b93e2-da37-482b-ae43-586a41cc2399';
// Controls from logstash-* data view
const osControlId = '5e1b146b-8a8b-4117-9218-c4aeaee7bc9a';
const bytesControlId = 'c4760951-e793-45d5-a6b7-c72c145af7f9';
async function waitForAllConrolsLoading() {
await Promise.all([
dashboardControls.optionsListWaitForLoading(carrierControlId),
dashboardControls.rangeSliderWaitForLoading(ticketPriceControlId),
dashboardControls.optionsListWaitForLoading(osControlId),
dashboardControls.rangeSliderWaitForLoading(bytesControlId),
]);
}
before(async () => {
await security.testUser.setRoles(['kibana_admin', 'kibana_sample_admin']);
@ -39,50 +50,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await kibanaServer.importExport.load(
'test/functional/fixtures/kbn_archiver/kibana_sample_data_flights_index_pattern'
);
await kibanaServer.importExport.load(
'test/functional/fixtures/kbn_archiver/dashboard/current/multi_data_view_kibana'
);
await kibanaServer.uiSettings.replace({
defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c',
'courier:ignoreFilterIfFieldNotInIndex': true,
});
await common.setTime({
from: 'Apr 10, 2018 @ 00:00:00.000',
to: 'Nov 15, 2018 @ 00:00:00.000',
});
await dashboard.navigateToApp();
await dashboard.clickNewDashboard();
await dashboardControls.createControl({
controlType: OPTIONS_LIST_CONTROL,
dataViewTitle: 'kibana_sample_data_flights',
fieldName: 'Carrier',
title: 'Carrier',
});
await dashboardControls.createControl({
controlType: RANGE_SLIDER_CONTROL,
dataViewTitle: 'kibana_sample_data_flights',
fieldName: 'AvgTicketPrice',
title: 'Average Ticket Price',
});
await dashboardControls.createControl({
controlType: OPTIONS_LIST_CONTROL,
dataViewTitle: 'logstash-*',
fieldName: 'machine.os.raw',
title: 'Operating System',
});
await dashboardControls.createControl({
controlType: RANGE_SLIDER_CONTROL,
dataViewTitle: 'logstash-*',
fieldName: 'bytes',
title: 'Bytes',
});
await dashboardAddPanel.addSavedSearch('logstash hits');
controlIds = await dashboardControls.getAllControlIds();
});
after(async () => {
@ -93,96 +66,169 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await kibanaServer.importExport.unload(
'test/functional/fixtures/kbn_archiver/kibana_sample_data_flights_index_pattern'
);
await kibanaServer.importExport.unload(
'test/functional/fixtures/kbn_archiver/dashboard/current/multi_data_view_kibana'
);
await security.testUser.restoreDefaults();
await kibanaServer.uiSettings.unset('courier:ignoreFilterIfFieldNotInIndex');
await kibanaServer.uiSettings.unset('defaultIndex');
});
it('ignores global filters on controls using a data view without the filter field', async () => {
await filterBar.addFilter({ field: 'Carrier', operation: 'exists' });
describe('courier:ignoreFilterIfFieldNotInIndex enabled', () => {
before(async () => {
await kibanaServer.uiSettings.replace({
'courier:ignoreFilterIfFieldNotInIndex': true,
});
await dashboardControls.optionsListOpenPopover(controlIds[0]);
expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('4');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]);
await dashboard.navigateToApp();
await dashboard.loadSavedDashboard('Test Control Group With Multiple Data Views');
});
await dashboardControls.validateRange('placeholder', controlIds[1], '100', '1200');
after(async () => {
await kibanaServer.uiSettings.unset('courier:ignoreFilterIfFieldNotInIndex');
});
await dashboardControls.optionsListOpenPopover(controlIds[2]);
expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('5');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[2]);
describe('global filters', () => {
before(async () => {
await filterBar.addFilter({
field: 'Carrier',
operation: 'is',
value: 'Kibana Airlines',
});
await waitForAllConrolsLoading();
});
await dashboardControls.validateRange('placeholder', controlIds[3], '0', '19979');
after(async () => {
await dashboard.clickDiscardChanges();
});
it('applies global filters to controls with data view of filter field', async () => {
await dashboardControls.optionsListOpenPopover(carrierControlId);
expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('1');
await dashboardControls.optionsListEnsurePopoverIsClosed(carrierControlId);
await dashboardControls.validateRange('placeholder', ticketPriceControlId, '100', '1196');
});
it('ignores global filters to controls without data view of filter field', async () => {
await dashboardControls.optionsListOpenPopover(osControlId);
expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('5');
await dashboardControls.optionsListEnsurePopoverIsClosed(osControlId);
await dashboardControls.validateRange('placeholder', bytesControlId, '0', '19979');
});
});
describe('control filters', () => {
before(async () => {
await dashboardControls.optionsListOpenPopover(carrierControlId);
await dashboardControls.optionsListPopoverSelectOption('Kibana Airlines');
await dashboardControls.optionsListEnsurePopoverIsClosed(carrierControlId);
await waitForAllConrolsLoading();
});
after(async () => {
await dashboard.clickDiscardChanges();
});
it('applies control filters to controls with data view of control filter', async () => {
await dashboardControls.validateRange('placeholder', ticketPriceControlId, '100', '1196');
});
it('ignores control filters on controls without data view of control filter', async () => {
await dashboardControls.optionsListOpenPopover(osControlId);
expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('5');
await dashboardControls.optionsListEnsurePopoverIsClosed(osControlId);
await dashboardControls.validateRange('placeholder', bytesControlId, '0', '19979');
});
it('ignores control filters on panels without data view of control filter', async () => {
const logstashSavedSearchPanel = await testSubjects.find('embeddedSavedSearchDocTable');
expect(
await (
await logstashSavedSearchPanel.findByCssSelector('[data-document-number]')
).getAttribute('data-document-number')
).to.not.be('0');
});
});
});
it('ignores controls on other controls and panels using a data view without the control field by default', async () => {
await filterBar.removeFilter('Carrier');
await dashboardControls.optionsListOpenPopover(controlIds[0]);
await dashboardControls.optionsListPopoverSelectOption('Kibana Airlines');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]);
describe('courier:ignoreFilterIfFieldNotInIndex disabled', () => {
before(async () => {
await kibanaServer.uiSettings.replace({
'courier:ignoreFilterIfFieldNotInIndex': false,
});
await dashboardControls.validateRange('placeholder', controlIds[1], '100', '1196');
await dashboard.navigateToApp();
await dashboard.loadSavedDashboard('Test Control Group With Multiple Data Views');
});
await dashboardControls.optionsListOpenPopover(controlIds[2]);
expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('5');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[2]);
after(async () => {
await kibanaServer.uiSettings.unset('courier:ignoreFilterIfFieldNotInIndex');
});
await dashboardControls.validateRange('placeholder', controlIds[3], '0', '19979');
describe('global filters', () => {
before(async () => {
await filterBar.addFilter({
field: 'Carrier',
operation: 'is',
value: 'Kibana Airlines',
});
await waitForAllConrolsLoading();
});
const logstashSavedSearchPanel = await testSubjects.find('embeddedSavedSearchDocTable');
expect(
await (
await logstashSavedSearchPanel.findByCssSelector('[data-document-number]')
).getAttribute('data-document-number')
).to.not.be('0');
});
after(async () => {
await dashboard.clickDiscardChanges();
});
it('applies global filters on controls using data view a without the filter field', async () => {
await kibanaServer.uiSettings.update({ 'courier:ignoreFilterIfFieldNotInIndex': false });
await common.navigateToApp('dashboard');
await testSubjects.click('edit-unsaved-New-Dashboard');
await filterBar.addFilter({ field: 'Carrier', operation: 'exists' });
it('applies global filters to controls without data view of filter field', async () => {
await dashboardControls.optionsListOpenPopover(osControlId);
expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('0');
await dashboardControls.optionsListEnsurePopoverIsClosed(osControlId);
await Promise.all([
dashboardControls.optionsListWaitForLoading(controlIds[0]),
dashboardControls.rangeSliderWaitForLoading(controlIds[1]),
dashboardControls.optionsListWaitForLoading(controlIds[2]),
dashboardControls.rangeSliderWaitForLoading(controlIds[3]),
]);
await dashboardControls.validateRange(
'placeholder',
bytesControlId,
'-Infinity',
'Infinity'
);
});
});
await dashboardControls.clearControlSelections(controlIds[0]);
await dashboardControls.optionsListOpenPopover(controlIds[0]);
expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('4');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]);
describe('control filters', () => {
before(async () => {
await dashboardControls.optionsListOpenPopover(carrierControlId);
await dashboardControls.optionsListPopoverSelectOption('Kibana Airlines');
await dashboardControls.optionsListEnsurePopoverIsClosed(carrierControlId);
await waitForAllConrolsLoading();
});
await dashboardControls.validateRange('placeholder', controlIds[1], '100', '1200');
after(async () => {
await dashboard.clickDiscardChanges();
});
await dashboardControls.optionsListOpenPopover(controlIds[2]);
expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('0');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[2]);
it('applies control filters on controls without data view of control filter', async () => {
await dashboardControls.optionsListOpenPopover(osControlId);
expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('0');
await dashboardControls.optionsListEnsurePopoverIsClosed(osControlId);
await dashboardControls.validateRange('placeholder', controlIds[3], '0', '0');
});
await dashboardControls.validateRange(
'placeholder',
bytesControlId,
'-Infinity',
'Infinity'
);
});
it('applies global filters on controls using a data view without the filter field', async () => {
await filterBar.removeFilter('Carrier');
await dashboardControls.optionsListOpenPopover(controlIds[0]);
await dashboardControls.optionsListPopoverSelectOption('Kibana Airlines');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]);
await dashboardControls.validateRange('placeholder', controlIds[1], '100', '1196');
await dashboardControls.optionsListOpenPopover(controlIds[2]);
expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('0');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[2]);
await dashboardControls.validateRange('placeholder', controlIds[3], '0', '0');
const logstashSavedSearchPanel = await testSubjects.find('embeddedSavedSearchDocTable');
expect(
await (
await logstashSavedSearchPanel.findByCssSelector('[data-document-number]')
).getAttribute('data-document-number')
).to.be('0');
it('applies control filters on panels without data view of control filter', async () => {
const logstashSavedSearchPanel = await testSubjects.find('embeddedSavedSearchDocTable');
expect(
await (
await logstashSavedSearchPanel.findByCssSelector('[data-document-number]')
).getAttribute('data-document-number')
).to.be('0');
});
});
});
});
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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