[embeddable rebuild][control group] Control group apply button (#188701)

PR does the following
* Adds `untilInitialized` to `ControlGroupApi`.
* Control group example updated to not mount data table react embeddable
until control group is initialized (and all control group filters are
available)
* Updates `buildControl` to be async
* Updates all controls to `await` filters before returning
`buildControl`
* Updates control group to display loading indicator until all controls
loaded
* Moves control group react logic into `ControlGroup` component
* Implements `Apply` button

<img width="600" alt="Screenshot 2024-07-24 at 7 33 25 AM"
src="https://github.com/user-attachments/assets/4840c731-2287-4a12-aa9c-3d9c83d64d14">

---------

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Nathan Reese 2024-07-26 11:55:07 -06:00 committed by GitHub
parent 74194fbf7a
commit 45071d1c09
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 918 additions and 325 deletions

View file

@ -16,9 +16,9 @@ import {
EuiTab,
EuiTabs,
} from '@elastic/eui';
import { I18nProvider } from '@kbn/i18n-react';
import React, { useState } from 'react';
import ReactDOM from 'react-dom';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import { AppMountParameters, CoreStart } from '@kbn/core/public';
import { ControlsExampleStartDeps } from '../plugin';
@ -48,7 +48,7 @@ const App = ({
}
return (
<I18nProvider>
<KibanaRenderContextProvider i18n={core.i18n} theme={core.theme}>
<EuiPage>
<EuiPageBody>
<EuiPageSection>
@ -78,7 +78,7 @@ const App = ({
</EuiPageTemplate.Section>
</EuiPageBody>
</EuiPage>
</I18nProvider>
</KibanaRenderContextProvider>
);
};

View file

@ -73,7 +73,6 @@ const controlGroupPanels = {
title: 'Message',
grow: true,
width: 'medium',
searchString: 'this',
enhancements: {},
},
},
@ -144,6 +143,7 @@ export const ReactControlExample = ({
);
const [controlGroupApi, setControlGroupApi] = useState<ControlGroupApi | undefined>(undefined);
const [isControlGroupInitialized, setIsControlGroupInitialized] = useState(false);
const [dataViewNotFound, setDataViewNotFound] = useState(false);
const dashboardApi = useMemo(() => {
@ -222,6 +222,22 @@ export const ReactControlExample = ({
};
}, [controlGroupFilters$, controlGroupApi]);
useEffect(() => {
if (!controlGroupApi) {
return;
}
let ignore = false;
controlGroupApi.untilInitialized().then(() => {
if (!ignore) {
setIsControlGroupInitialized(true);
}
});
return () => {
ignore = true;
};
}, [controlGroupApi]);
useEffect(() => {
if (!controlGroupApi) return;
@ -376,22 +392,24 @@ export const ReactControlExample = ({
key={`control_group`}
/>
<EuiSpacer size="l" />
<div style={{ height: '400px' }}>
<ReactEmbeddableRenderer
type={'data_table'}
getParentApi={() => ({
...dashboardApi,
getSerializedStateForChild: () => ({
rawState: {},
references: [],
}),
})}
hidePanelChrome={false}
onApiAvailable={(api) => {
dashboardApi?.setChild(api);
}}
/>
</div>
{isControlGroupInitialized && (
<div style={{ height: '400px' }}>
<ReactEmbeddableRenderer
type={'data_table'}
getParentApi={() => ({
...dashboardApi,
getSerializedStateForChild: () => ({
rawState: {},
references: [],
}),
})}
hidePanelChrome={false}
onApiAvailable={(api) => {
dashboardApi?.setChild(api);
}}
/>
</div>
)}
</>
);
};

View file

@ -10,11 +10,10 @@ import classNames from 'classnames';
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiFormLabel, EuiIcon } from '@elastic/eui';
import {
useBatchedOptionalPublishingSubjects,
useStateFromPublishingSubject,
} from '@kbn/presentation-publishing';
import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
import { DEFAULT_CONTROL_GROW } from '@kbn/controls-plugin/common';
import { BehaviorSubject } from 'rxjs';
import { DefaultControlApi } from '../types';
/**
@ -23,16 +22,16 @@ import { DefaultControlApi } from '../types';
* can be quite cumbersome.
*/
export const ControlClone = ({
controlStyle,
labelPosition,
controlApi,
}: {
controlStyle: string;
controlApi: DefaultControlApi;
labelPosition: string;
controlApi: DefaultControlApi | undefined;
}) => {
const width = useStateFromPublishingSubject(controlApi.width);
const [panelTitle, defaultPanelTitle] = useBatchedOptionalPublishingSubjects(
controlApi.panelTitle,
controlApi.defaultPanelTitle
const [width, panelTitle, defaultPanelTitle] = useBatchedPublishingSubjects(
controlApi ? controlApi.width : new BehaviorSubject(DEFAULT_CONTROL_GROW),
controlApi?.panelTitle ? controlApi.panelTitle : new BehaviorSubject(undefined),
controlApi?.defaultPanelTitle ? controlApi.defaultPanelTitle : new BehaviorSubject('')
);
return (
@ -41,17 +40,17 @@ export const ControlClone = ({
'controlFrameCloneWrapper--small': width === 'small',
'controlFrameCloneWrapper--medium': width === 'medium',
'controlFrameCloneWrapper--large': width === 'large',
'controlFrameCloneWrapper--twoLine': controlStyle === 'twoLine',
'controlFrameCloneWrapper--twoLine': labelPosition === 'twoLine',
})}
>
{controlStyle === 'twoLine' ? (
{labelPosition === 'twoLine' ? (
<EuiFormLabel>{panelTitle ?? defaultPanelTitle}</EuiFormLabel>
) : undefined}
<EuiFlexGroup responsive={false} gutterSize="none" className={'controlFrame__draggable'}>
<EuiFlexItem grow={false}>
<EuiIcon type="grabHorizontal" className="controlFrame__dragHandle" />
</EuiFlexItem>
{controlStyle === 'oneLine' ? (
{labelPosition === 'oneLine' ? (
<EuiFlexItem>
<label className="controlFrameCloneWrapper__label">
{panelTitle ?? defaultPanelTitle}

View file

@ -0,0 +1,164 @@
/*
* 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.
*/
import React, { useCallback, useEffect, useState } from 'react';
import { BehaviorSubject } from 'rxjs';
import {
DndContext,
DragEndEvent,
DragOverlay,
KeyboardSensor,
MeasuringStrategy,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
rectSortingStrategy,
SortableContext,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable';
import {
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingChart,
EuiPanel,
EuiToolTip,
} from '@elastic/eui';
import { ControlStyle } from '@kbn/controls-plugin/public';
import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
import { ControlsInOrder } from '../init_controls_manager';
import { ControlGroupApi } from '../types';
import { ControlRenderer } from '../../control_renderer';
import { ControlClone } from '../../components/control_clone';
import { DefaultControlApi } from '../../types';
import { ControlGroupStrings } from '../control_group_strings';
interface Props {
applySelections: () => void;
controlGroupApi: ControlGroupApi;
controlsManager: {
controlsInOrder$: BehaviorSubject<ControlsInOrder>;
getControlApi: (uuid: string) => DefaultControlApi | undefined;
setControlApi: (uuid: string, controlApi: DefaultControlApi) => void;
};
hasUnappliedSelections: boolean;
labelPosition: ControlStyle;
}
export function ControlGroup({
applySelections,
controlGroupApi,
controlsManager,
labelPosition,
hasUnappliedSelections,
}: Props) {
const [isInitialized, setIsInitialized] = useState(false);
const [autoApplySelections, controlsInOrder] = useBatchedPublishingSubjects(
controlGroupApi.autoApplySelections$,
controlsManager.controlsInOrder$
);
/** Handle drag and drop */
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
const [draggingId, setDraggingId] = useState<string | null>(null);
const onDragEnd = useCallback(
({ over, active }: DragEndEvent) => {
const oldIndex = active?.data.current?.sortable.index;
const newIndex = over?.data.current?.sortable.index;
if (oldIndex !== undefined && newIndex !== undefined && oldIndex !== newIndex) {
controlsManager.controlsInOrder$.next(arrayMove([...controlsInOrder], oldIndex, newIndex));
}
(document.activeElement as HTMLElement)?.blur(); // hide hover actions on drop; otherwise, they get stuck
setDraggingId(null);
},
[controlsInOrder, controlsManager.controlsInOrder$]
);
useEffect(() => {
let ignore = false;
controlGroupApi.untilInitialized().then(() => {
if (!ignore) {
setIsInitialized(true);
}
});
return () => {
ignore = true;
};
}, [controlGroupApi]);
return (
<EuiPanel borderRadius="m" paddingSize="none" color={draggingId ? 'success' : 'transparent'}>
<EuiFlexGroup alignItems="center" gutterSize="s" wrap={true}>
{!isInitialized && <EuiLoadingChart />}
<DndContext
onDragStart={({ active }) => setDraggingId(`${active.id}`)}
onDragEnd={onDragEnd}
onDragCancel={() => setDraggingId(null)}
sensors={sensors}
measuring={{
droppable: {
strategy: MeasuringStrategy.BeforeDragging,
},
}}
>
<SortableContext items={controlsInOrder} strategy={rectSortingStrategy}>
{controlsInOrder.map(({ id, type }) => (
<ControlRenderer
key={id}
uuid={id}
type={type}
getParentApi={() => controlGroupApi}
onApiAvailable={(controlApi) => {
controlsManager.setControlApi(id, controlApi);
}}
isControlGroupInitialized={isInitialized}
/>
))}
</SortableContext>
<DragOverlay>
{draggingId ? (
<ControlClone
key={draggingId}
labelPosition={labelPosition}
controlApi={controlsManager.getControlApi(draggingId)}
/>
) : null}
</DragOverlay>
</DndContext>
{!autoApplySelections && (
<EuiFlexItem grow={false}>
<EuiToolTip
content={ControlGroupStrings.management.getApplyButtonTitle(hasUnappliedSelections)}
>
<EuiButtonIcon
size="m"
disabled={!hasUnappliedSelections}
iconSize="m"
display="fill"
color={'success'}
iconType={'check'}
data-test-subj="controlGroup--applyFiltersButton"
aria-label={ControlGroupStrings.management.getApplyButtonTitle(
hasUnappliedSelections
)}
onClick={applySelections}
/>
</EuiToolTip>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiPanel>
);
}

View file

@ -36,6 +36,7 @@ describe('chaining$', () => {
const onFireMock = jest.fn();
const chainingSystem$ = new BehaviorSubject<ControlGroupChainingSystem>('HIERARCHICAL');
const controlsInOrder$ = new BehaviorSubject<Array<{ id: string; type: string }>>([]);
const children$ = new BehaviorSubject<{ [key: string]: unknown }>({});
const alphaControlApi = {
filters$: new BehaviorSubject<Filter[] | undefined>(undefined),
};
@ -49,21 +50,6 @@ describe('chaining$', () => {
const deltaControlApi = {
filters$: new BehaviorSubject<Filter[] | undefined>([FILTER_DELTA]),
};
const getControlApi = (uuid: string) => {
if (uuid === 'alpha') {
return alphaControlApi;
}
if (uuid === 'bravo') {
return bravoControlApi;
}
if (uuid === 'charlie') {
return charlieControlApi;
}
if (uuid === 'delta') {
return deltaControlApi;
}
return undefined;
};
beforeEach(() => {
onFireMock.mockReset();
@ -82,15 +68,51 @@ describe('chaining$', () => {
{ id: 'charlie', type: 'whatever' },
{ id: 'delta', type: 'whatever' },
]);
children$.next({
alpha: alphaControlApi,
bravo: bravoControlApi,
charlie: charlieControlApi,
delta: deltaControlApi,
});
});
describe('hierarchical chaining', () => {
test('should not fire until all chained controls are initialized', async () => {
const childrenValueWithNoControlsInitialized = {};
children$.next(childrenValueWithNoControlsInitialized);
const subscription = chaining$(
'charlie',
chainingSystem$,
controlsInOrder$,
children$
).subscribe(onFireMock);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(onFireMock.mock.calls).toHaveLength(0);
const childrenValueWithAlphaInitialized = {
alpha: alphaControlApi,
};
children$.next(childrenValueWithAlphaInitialized);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(onFireMock.mock.calls).toHaveLength(0);
const childrenValueWithAllControlsInitialized = {
alpha: alphaControlApi,
bravo: bravoControlApi,
};
children$.next(childrenValueWithAllControlsInitialized);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(onFireMock.mock.calls).toHaveLength(1);
subscription.unsubscribe();
});
test('should contain values from controls to the left', async () => {
const subscription = chaining$(
'charlie',
chainingSystem$,
controlsInOrder$,
getControlApi
children$
).subscribe(onFireMock);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(onFireMock.mock.calls).toHaveLength(1);
@ -107,7 +129,7 @@ describe('chaining$', () => {
});
test('should fire on chaining system change', async () => {
const subscription = chaining$('charlie', chainingSystem$, controlsInOrder$, getControlApi)
const subscription = chaining$('charlie', chainingSystem$, controlsInOrder$, children$)
.pipe(skip(1))
.subscribe(onFireMock);
await new Promise((resolve) => setTimeout(resolve, 0));
@ -126,7 +148,7 @@ describe('chaining$', () => {
});
test('should fire when controls are moved', async () => {
const subscription = chaining$('charlie', chainingSystem$, controlsInOrder$, getControlApi)
const subscription = chaining$('charlie', chainingSystem$, controlsInOrder$, children$)
.pipe(skip(1))
.subscribe(onFireMock);
await new Promise((resolve) => setTimeout(resolve, 0));
@ -155,7 +177,7 @@ describe('chaining$', () => {
});
test('should fire when controls are removed', async () => {
const subscription = chaining$('charlie', chainingSystem$, controlsInOrder$, getControlApi)
const subscription = chaining$('charlie', chainingSystem$, controlsInOrder$, children$)
.pipe(skip(1))
.subscribe(onFireMock);
await new Promise((resolve) => setTimeout(resolve, 0));
@ -179,7 +201,7 @@ describe('chaining$', () => {
});
test('should fire when chained filter changes', async () => {
const subscription = chaining$('charlie', chainingSystem$, controlsInOrder$, getControlApi)
const subscription = chaining$('charlie', chainingSystem$, controlsInOrder$, children$)
.pipe(skip(1))
.subscribe(onFireMock);
await new Promise((resolve) => setTimeout(resolve, 0));
@ -208,7 +230,7 @@ describe('chaining$', () => {
});
test('should not fire when unchained filter changes', async () => {
const subscription = chaining$('charlie', chainingSystem$, controlsInOrder$, getControlApi)
const subscription = chaining$('charlie', chainingSystem$, controlsInOrder$, children$)
.pipe(skip(1))
.subscribe(onFireMock);
await new Promise((resolve) => setTimeout(resolve, 0));
@ -228,7 +250,7 @@ describe('chaining$', () => {
});
test('should fire when chained timeslice changes', async () => {
const subscription = chaining$('charlie', chainingSystem$, controlsInOrder$, getControlApi)
const subscription = chaining$('charlie', chainingSystem$, controlsInOrder$, children$)
.pipe(skip(1))
.subscribe(onFireMock);
await new Promise((resolve) => setTimeout(resolve, 0));

View file

@ -13,7 +13,15 @@ import {
apiPublishesTimeslice,
PublishingSubject,
} from '@kbn/presentation-publishing';
import { BehaviorSubject, combineLatest, debounceTime, map, Observable, switchMap } from 'rxjs';
import {
BehaviorSubject,
combineLatest,
debounceTime,
map,
Observable,
skipWhile,
switchMap,
} from 'rxjs';
export interface ChainingContext {
chainingFilters?: Filter[] | undefined;
@ -24,10 +32,29 @@ export function chaining$(
uuid: string,
chainingSystem$: PublishingSubject<ControlGroupChainingSystem>,
controlsInOrder$: PublishingSubject<Array<{ id: string; type: string }>>,
getControlApi: (uuid: string) => undefined | unknown
children$: PublishingSubject<{ [key: string]: unknown }>
) {
return combineLatest([chainingSystem$, controlsInOrder$]).pipe(
switchMap(([chainingSystem, controlsInOrder]) => {
return combineLatest([chainingSystem$, controlsInOrder$, children$]).pipe(
skipWhile(([chainingSystem, controlsInOrder, children]) => {
if (chainingSystem === 'HIERARCHICAL') {
for (let i = 0; i < controlsInOrder.length; i++) {
if (controlsInOrder[i].id === uuid) {
// all controls to the left are initialized
return false;
}
if (!children[controlsInOrder[i].id]) {
// a control to the left is not initialized
// block rxjs pipe flow until its initialized
return true;
}
}
}
// no chaining
return false;
}),
switchMap(([chainingSystem, controlsInOrder, children]) => {
const observables: Array<Observable<unknown>> = [];
if (chainingSystem === 'HIERARCHICAL') {
for (let i = 0; i < controlsInOrder.length; i++) {
@ -35,7 +62,7 @@ export function chaining$(
break;
}
const chainedControlApi = getControlApi(controlsInOrder[i].id);
const chainedControlApi = children[controlsInOrder[i].id];
const chainedControl$ = combineLatest([
apiPublishesFilters(chainedControlApi)

View file

@ -30,12 +30,22 @@ import { ControlStyle, ParentIgnoreSettings } from '@kbn/controls-plugin/public'
import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
import { ControlStateManager } from '../types';
import {
ControlGroupEditorStrings,
CONTROL_LAYOUT_OPTIONS,
} from './control_group_editor_constants';
import { ControlGroupStrings } from './control_group_strings';
import { ControlGroupApi, ControlGroupEditorState } from './types';
const CONTROL_LAYOUT_OPTIONS = [
{
id: `oneLine`,
'data-test-subj': 'control-editor-layout-oneLine',
label: ControlGroupStrings.management.labelPosition.getInlineTitle(),
},
{
id: `twoLine`,
'data-test-subj': 'control-editor-layout-twoLine',
label: ControlGroupStrings.management.labelPosition.getAboveTitle(),
},
];
interface EditControlGroupProps {
onCancel: () => void;
onSave: () => void;
@ -81,20 +91,18 @@ export const ControlGroupEditor = ({
<>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2>{ControlGroupEditorStrings.management.getFlyoutTitle()}</h2>
<h2>{ControlGroupStrings.management.getFlyoutTitle()}</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody data-test-subj="control-group-settings-flyout">
<EuiForm component="form" fullWidth>
<EuiFormRow
label={ControlGroupEditorStrings.management.labelPosition.getLabelPositionTitle()}
>
<EuiFormRow label={ControlGroupStrings.management.labelPosition.getLabelPositionTitle()}>
<EuiButtonGroup
color="primary"
options={CONTROL_LAYOUT_OPTIONS}
data-test-subj="control-group-layout-options"
idSelected={selectedLabelPosition}
legend={ControlGroupEditorStrings.management.labelPosition.getLabelPositionLegend()}
legend={ControlGroupStrings.management.labelPosition.getLabelPositionLegend()}
onChange={(newPosition: string) => {
stateManager.labelPosition.next(newPosition as ControlStyle);
}}
@ -102,13 +110,13 @@ export const ControlGroupEditor = ({
</EuiFormRow>
<EuiFormRow
label={ControlGroupEditorStrings.management.filteringSettings.getFilteringSettingsTitle()}
label={ControlGroupStrings.management.filteringSettings.getFilteringSettingsTitle()}
>
<div>
<EuiSwitch
compressed
data-test-subj="control-group-filter-sync"
label={ControlGroupEditorStrings.management.filteringSettings.getUseGlobalFiltersTitle()}
label={ControlGroupStrings.management.filteringSettings.getUseGlobalFiltersTitle()}
onChange={(e) =>
updateIgnoreSetting({
ignoreFilters: !e.target.checked,
@ -124,7 +132,7 @@ export const ControlGroupEditor = ({
<EuiSwitch
compressed
data-test-subj="control-group-query-sync-time-range"
label={ControlGroupEditorStrings.management.filteringSettings.getUseGlobalTimeRangeTitle()}
label={ControlGroupStrings.management.filteringSettings.getUseGlobalTimeRangeTitle()}
onChange={(e) => updateIgnoreSetting({ ignoreTimerange: !e.target.checked })}
checked={!Boolean(selectedIgnoreParentSettings?.ignoreTimerange)}
/>
@ -132,7 +140,7 @@ export const ControlGroupEditor = ({
</EuiFormRow>
<EuiFormRow
label={ControlGroupEditorStrings.management.selectionSettings.getSelectionSettingsTitle()}
label={ControlGroupStrings.management.selectionSettings.getSelectionSettingsTitle()}
>
<div>
<EuiSwitch
@ -140,8 +148,8 @@ export const ControlGroupEditor = ({
data-test-subj="control-group-validate-selections"
label={
<ControlSettingTooltipLabel
label={ControlGroupEditorStrings.management.selectionSettings.validateSelections.getValidateSelectionsTitle()}
tooltip={ControlGroupEditorStrings.management.selectionSettings.validateSelections.getValidateSelectionsTooltip()}
label={ControlGroupStrings.management.selectionSettings.validateSelections.getValidateSelectionsTitle()}
tooltip={ControlGroupStrings.management.selectionSettings.validateSelections.getValidateSelectionsTooltip()}
/>
}
checked={!Boolean(selectedIgnoreParentSettings?.ignoreValidations)}
@ -153,8 +161,8 @@ export const ControlGroupEditor = ({
data-test-subj="control-group-chaining"
label={
<ControlSettingTooltipLabel
label={ControlGroupEditorStrings.management.selectionSettings.controlChaining.getHierarchyTitle()}
tooltip={ControlGroupEditorStrings.management.selectionSettings.controlChaining.getHierarchyTooltip()}
label={ControlGroupStrings.management.selectionSettings.controlChaining.getHierarchyTitle()}
tooltip={ControlGroupStrings.management.selectionSettings.controlChaining.getHierarchyTooltip()}
/>
}
checked={selectedChainingSystem === 'HIERARCHICAL'}
@ -168,8 +176,8 @@ export const ControlGroupEditor = ({
data-test-subj="control-group-auto-apply-selections"
label={
<ControlSettingTooltipLabel
label={ControlGroupEditorStrings.management.selectionSettings.showApplySelections.getShowApplySelectionsTitle()}
tooltip={ControlGroupEditorStrings.management.selectionSettings.showApplySelections.getShowApplySelectionsTooltip()}
label={ControlGroupStrings.management.selectionSettings.showApplySelections.getShowApplySelectionsTitle()}
tooltip={ControlGroupStrings.management.selectionSettings.showApplySelections.getShowApplySelectionsTooltip()}
/>
}
checked={selectedAutoApplySelections}
@ -191,7 +199,7 @@ export const ControlGroupEditor = ({
flush="left"
size="s"
>
{ControlGroupEditorStrings.management.getDeleteAllButtonTitle()}
{ControlGroupStrings.management.getDeleteAllButtonTitle()}
</EuiButtonEmpty>
</EuiFormRow>
</>
@ -208,7 +216,7 @@ export const ControlGroupEditor = ({
onCancel();
}}
>
{ControlGroupEditorStrings.getCancelTitle()}
{ControlGroupStrings.getCancelTitle()}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
@ -221,7 +229,7 @@ export const ControlGroupEditor = ({
onSave();
}}
>
{ControlGroupEditorStrings.getSaveChangesTitle()}
{ControlGroupStrings.getSaveChangesTitle()}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -8,7 +8,7 @@
import { i18n } from '@kbn/i18n';
export const ControlGroupEditorStrings = {
export const ControlGroupStrings = {
getSaveChangesTitle: () =>
i18n.translate('controls.controlGroup.manageControl.saveChangesTitle', {
defaultMessage: 'Save and close',
@ -18,6 +18,14 @@ export const ControlGroupEditorStrings = {
defaultMessage: 'Cancel',
}),
management: {
getApplyButtonTitle: (hasUnappliedSelections: boolean) =>
hasUnappliedSelections
? i18n.translate('controls.controlGroup.management.applyButtonTooltip.enabled', {
defaultMessage: 'Apply selections',
})
: i18n.translate('controls.controlGroup.management.applyButtonTooltip.disabled', {
defaultMessage: 'No new selections to apply',
}),
getFlyoutTitle: () =>
i18n.translate('controls.controlGroup.management.flyoutTitle', {
defaultMessage: 'Control settings',
@ -98,16 +106,3 @@ export const ControlGroupEditorStrings = {
},
},
};
export const CONTROL_LAYOUT_OPTIONS = [
{
id: `oneLine`,
'data-test-subj': 'control-editor-layout-oneLine',
label: ControlGroupEditorStrings.management.labelPosition.getInlineTitle(),
},
{
id: `twoLine`,
'data-test-subj': 'control-editor-layout-twoLine',
label: ControlGroupEditorStrings.management.labelPosition.getAboveTitle(),
},
];

View file

@ -6,26 +6,8 @@
* Side Public License, v 1.
*/
import React, { useCallback, useEffect, useState } from 'react';
import React, { useEffect } from 'react';
import { BehaviorSubject } from 'rxjs';
import {
DndContext,
DragEndEvent,
DragOverlay,
KeyboardSensor,
MeasuringStrategy,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
rectSortingStrategy,
SortableContext,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable';
import { EuiFlexGroup, EuiPanel } from '@elastic/eui';
import {
ControlGroupChainingSystem,
ControlWidth,
@ -39,20 +21,13 @@ import { CoreStart } from '@kbn/core/public';
import { DataView } from '@kbn/data-views-plugin/common';
import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public';
import { Filter } from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import { combineCompatibleChildrenApis } from '@kbn/presentation-containers';
import {
apiPublishesDataViews,
apiPublishesFilters,
apiPublishesTimeslice,
PublishesDataViews,
PublishesFilters,
PublishesTimeslice,
useBatchedPublishingSubjects,
} from '@kbn/presentation-publishing';
import { ControlRenderer } from '../control_renderer';
import { chaining$, controlFetch$, controlGroupFetch$ } from './control_fetch';
import { initControlsManager } from './init_controls_manager';
import { openEditControlGroupFlyout } from './open_edit_control_group_flyout';
@ -63,7 +38,8 @@ import {
ControlGroupSerializedState,
ControlGroupUnsavedChanges,
} from './types';
import { ControlClone } from '../components/control_clone';
import { ControlGroup } from './components/control_group';
import { initSelectionsManager } from './selections_manager';
export const getControlGroupEmbeddableFactory = (services: {
core: CoreStart;
@ -81,16 +57,18 @@ export const getControlGroupEmbeddableFactory = (services: {
initialChildControlState,
defaultControlGrow,
defaultControlWidth,
labelPosition,
labelPosition: initialLabelPosition,
chainingSystem,
autoApplySelections,
ignoreParentSettings,
} = initialState;
const controlsManager = initControlsManager(initialChildControlState);
const autoApplySelections$ = new BehaviorSubject<boolean>(autoApplySelections);
const timeslice$ = new BehaviorSubject<[number, number] | undefined>(undefined);
const filters$ = new BehaviorSubject<Filter[] | undefined>([]);
const controlsManager = initControlsManager(initialChildControlState);
const selectionsManager = initSelectionsManager({
...controlsManager.api,
autoApplySelections$,
});
const dataViews = new BehaviorSubject<DataView[] | undefined>(undefined);
const chainingSystem$ = new BehaviorSubject<ControlGroupChainingSystem>(chainingSystem);
const ignoreParentSettings$ = new BehaviorSubject<ParentIgnoreSettings | undefined>(
@ -103,7 +81,7 @@ export const getControlGroupEmbeddableFactory = (services: {
defaultControlWidth ?? DEFAULT_CONTROL_WIDTH
);
const labelPosition$ = new BehaviorSubject<ControlStyle>( // TODO: Rename `ControlStyle`
labelPosition ?? DEFAULT_CONTROL_STYLE // TODO: Rename `DEFAULT_CONTROL_STYLE`
initialLabelPosition ?? DEFAULT_CONTROL_STYLE // TODO: Rename `DEFAULT_CONTROL_STYLE`
);
/** TODO: Handle loading; loading should be true if any child is loading */
@ -123,13 +101,14 @@ export const getControlGroupEmbeddableFactory = (services: {
const api = setApi({
...controlsManager.api,
...selectionsManager.api,
controlFetch$: (controlUuid: string) =>
controlFetch$(
chaining$(
controlUuid,
chainingSystem$,
controlsManager.controlsInOrder$,
controlsManager.getControlApi
controlsManager.api.children$
),
controlGroupFetch$(ignoreParentSettings$, parentApi ? parentApi : {})
),
@ -176,50 +155,12 @@ export const getControlGroupEmbeddableFactory = (services: {
},
grow,
width,
filters$,
dataViews,
labelPosition: labelPosition$,
timeslice$,
});
/**
* Subscribe to all children's output filters, combine them, and output them
* TODO: If `autoApplySelections` is false, publish to "unpublishedFilters" instead
* and only output to filters$ when the apply button is clicked.
* OR
* Always publish to "unpublishedFilters" and publish them manually on click
* (when `autoApplySelections` is false) or after a small debounce (when false)
* See: https://github.com/elastic/kibana/pull/182842#discussion_r1624929511
* - Note: Unsaved changes of control group **should** take into consideration the
* output filters, but not the "unpublishedFilters"
*/
const outputFiltersSubscription = combineCompatibleChildrenApis<PublishesFilters, Filter[]>(
api,
'filters$',
apiPublishesFilters,
[]
).subscribe((newFilters) => filters$.next(newFilters));
const childrenTimesliceSubscription = combineCompatibleChildrenApis<
PublishesTimeslice,
[number, number] | undefined
>(
api,
'timeslice$',
apiPublishesTimeslice,
undefined,
// flatten method
(values) => {
// control group should never allow multiple timeslider controls
// returns first timeslider control value
return values.length === 0 ? undefined : values[0];
}
).subscribe((timeslice) => {
timeslice$.next(timeslice);
});
/** Subscribe to all children's output data views, combine them, and output them */
const childDataViewsSubscription = combineCompatibleChildrenApis<
const childrenDataViewsSubscription = combineCompatibleChildrenApis<
PublishesDataViews,
DataView[]
>(api, 'dataViews', apiPublishesDataViews, []).subscribe((newDataViews) =>
@ -229,82 +170,26 @@ export const getControlGroupEmbeddableFactory = (services: {
return {
api,
Component: () => {
const [controlsInOrder, controlStyle] = useBatchedPublishingSubjects(
controlsManager.controlsInOrder$,
const [hasUnappliedSelections, labelPosition] = useBatchedPublishingSubjects(
selectionsManager.hasUnappliedSelections$,
labelPosition$
);
/** Handle drag and drop */
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
const [draggingId, setDraggingId] = useState<string | null>(null);
const onDragEnd = useCallback(
({ over, active }: DragEndEvent) => {
const oldIndex = active?.data.current?.sortable.index;
const newIndex = over?.data.current?.sortable.index;
if (oldIndex !== undefined && newIndex !== undefined && oldIndex !== newIndex) {
controlsManager.controlsInOrder$.next(
arrayMove([...controlsInOrder], oldIndex, newIndex)
);
}
(document.activeElement as HTMLElement)?.blur(); // hide hover actions on drop; otherwise, they get stuck
setDraggingId(null);
},
[controlsInOrder]
);
useEffect(() => {
return () => {
outputFiltersSubscription.unsubscribe();
childDataViewsSubscription.unsubscribe();
childrenTimesliceSubscription.unsubscribe();
selectionsManager.cleanup();
childrenDataViewsSubscription.unsubscribe();
};
}, []);
return (
<EuiPanel
borderRadius="m"
paddingSize="none"
color={draggingId ? 'success' : 'transparent'}
>
<EuiFlexGroup alignItems="center" gutterSize="s" wrap={true}>
<DndContext
onDragStart={({ active }) => setDraggingId(`${active.id}`)}
onDragEnd={onDragEnd}
onDragCancel={() => setDraggingId(null)}
sensors={sensors}
measuring={{
droppable: {
strategy: MeasuringStrategy.BeforeDragging,
},
}}
>
<SortableContext items={controlsInOrder} strategy={rectSortingStrategy}>
{controlsInOrder.map(({ id, type }) => (
<ControlRenderer
key={id}
uuid={id}
type={type}
getParentApi={() => api}
onApiAvailable={(controlApi) => {
controlsManager.setControlApi(id, controlApi);
}}
/>
))}
</SortableContext>
<DragOverlay>
{draggingId ? (
<ControlClone
controlStyle={controlStyle}
controlApi={controlsManager.getControlApi(draggingId)}
/>
) : null}
</DragOverlay>
</DndContext>
</EuiFlexGroup>
</EuiPanel>
<ControlGroup
applySelections={selectionsManager.applySelections}
controlGroupApi={api}
controlsManager={controlsManager}
hasUnappliedSelections={hasUnappliedSelections}
labelPosition={labelPosition}
/>
);
},
};

View file

@ -65,4 +65,44 @@ describe('PresentationContainer api', () => {
'charlie',
]);
});
describe('untilInitialized', () => {
test('should not resolve until all controls are initialized', async () => {
const controlsManager = initControlsManager({
alpha: { type: 'whatever', order: 0 },
bravo: { type: 'whatever', order: 1 },
});
let isDone = false;
controlsManager.api.untilInitialized().then(() => {
isDone = true;
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(isDone).toBe(false);
controlsManager.setControlApi('alpha', {} as unknown as DefaultControlApi);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(isDone).toBe(false);
controlsManager.setControlApi('bravo', {} 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: 'whatever', order: 0 },
bravo: { type: 'whatever', order: 1 },
});
controlsManager.setControlApi('alpha', {} as unknown as DefaultControlApi);
controlsManager.setControlApi('bravo', {} as unknown as DefaultControlApi);
let isDone = false;
controlsManager.api.untilInitialized().then(() => {
isDone = true;
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(isDone).toBe(true);
});
});
});

View file

@ -13,20 +13,21 @@ import {
PresentationContainer,
} from '@kbn/presentation-containers';
import { Reference } from '@kbn/content-management-utils';
import { BehaviorSubject, merge } from 'rxjs';
import { BehaviorSubject, first, merge } from 'rxjs';
import { PublishingSubject } from '@kbn/presentation-publishing';
import { omit } from 'lodash';
import { ControlPanelsState, ControlPanelState } from './types';
import { DefaultControlApi, DefaultControlState } from '../types';
type ControlOrder = Array<{ id: string; type: string }>;
export type ControlsInOrder = Array<{ id: string; type: string }>;
export function initControlsManager(initialControlPanelsState: ControlPanelsState) {
const initialControlIds = Object.keys(initialControlPanelsState);
const children$ = new BehaviorSubject<{ [key: string]: DefaultControlApi }>({});
const controlsPanelState: { [panelId: string]: DefaultControlState } = {
...initialControlPanelsState,
};
const controlsInOrder$ = new BehaviorSubject<ControlOrder>(
const controlsInOrder$ = new BehaviorSubject<ControlsInOrder>(
Object.keys(initialControlPanelsState)
.map((key) => ({
id: key,
@ -156,6 +157,23 @@ export function initControlsManager(initialControlPanelsState: ControlPanelsStat
);
return controlApi ? controlApi.uuid : '';
},
} as PresentationContainer & HasSerializedChildState<ControlPanelState>,
untilInitialized: () => {
return new Promise((resolve) => {
children$
.pipe(
first((children) => {
const atLeastOneControlNotInitialized = initialControlIds.some(
(controlId) => !children[controlId]
);
return !atLeastOneControlNotInitialized;
})
)
.subscribe(() => {
resolve();
});
});
},
} as PresentationContainer &
HasSerializedChildState<ControlPanelState> & { untilInitialized: () => Promise<void> },
};
}

View file

@ -0,0 +1,219 @@
/*
* 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.
*/
import { Filter } from '@kbn/es-query';
import { BehaviorSubject, skip } from 'rxjs';
import { initSelectionsManager } from './selections_manager';
import { ControlGroupApi } from './types';
describe('selections manager', () => {
const control1Api = {
filters$: new BehaviorSubject<Filter[] | undefined>(undefined),
};
const control2Api = {
filters$: new BehaviorSubject<Filter[] | undefined>(undefined),
};
const control3Api = {
timeslice$: new BehaviorSubject<[number, number] | undefined>(undefined),
};
const children$ = new BehaviorSubject<{
[key: string]: {
filters$?: BehaviorSubject<Filter[] | undefined>;
timeslice$?: BehaviorSubject<[number, number] | undefined>;
};
}>({});
const controlGroupApi = {
autoApplySelections$: new BehaviorSubject(false),
children$,
untilInitialized: async () => {
control1Api.filters$.next(undefined);
control2Api.filters$.next([
{
meta: {
alias: 'control2 original filter',
},
},
]);
control3Api.timeslice$.next([
Date.parse('2024-06-09T06:00:00.000Z'),
Date.parse('2024-06-09T12:00:00.000Z'),
]);
controlGroupApi.children$.next({
control1: control1Api,
control2: control2Api,
control3: control3Api,
});
},
};
const onFireMock = jest.fn();
beforeEach(() => {
onFireMock.mockReset();
controlGroupApi.children$.next({});
});
describe('auto apply selections disabled', () => {
beforeEach(() => {
controlGroupApi.autoApplySelections$.next(false);
});
test('should publish initial filters and initial timeslice', async () => {
const selectionsManager = initSelectionsManager(
controlGroupApi as unknown as Pick<
ControlGroupApi,
'autoApplySelections$' | 'children$' | 'untilInitialized'
>
);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(selectionsManager.api.filters$.value).toEqual([
{
meta: {
alias: 'control2 original filter',
},
},
]);
expect(new Date(selectionsManager.api.timeslice$.value![0]).toISOString()).toEqual(
'2024-06-09T06:00:00.000Z'
);
expect(new Date(selectionsManager.api.timeslice$.value![1]).toISOString()).toEqual(
'2024-06-09T12:00:00.000Z'
);
expect(selectionsManager.hasUnappliedSelections$.value).toBe(false);
});
test('should not publish filter changes until applySelections is called', async () => {
const selectionsManager = initSelectionsManager(
controlGroupApi as unknown as Pick<
ControlGroupApi,
'autoApplySelections$' | 'children$' | 'untilInitialized'
>
);
await new Promise((resolve) => setTimeout(resolve, 0));
const subscription = selectionsManager.api.filters$.pipe(skip(1)).subscribe(onFireMock);
// remove filter to trigger changes
control2Api.filters$.next(undefined);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(selectionsManager.hasUnappliedSelections$.value).toBe(true);
expect(onFireMock).toHaveBeenCalledTimes(0);
selectionsManager.applySelections();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(selectionsManager.hasUnappliedSelections$.value).toBe(false);
expect(onFireMock).toHaveBeenCalledTimes(1);
const filters = onFireMock.mock.calls[0][0];
expect(filters).toEqual([]);
subscription.unsubscribe();
});
test('should not publish timeslice changes until applySelections is called', async () => {
const selectionsManager = initSelectionsManager(
controlGroupApi as unknown as Pick<
ControlGroupApi,
'autoApplySelections$' | 'children$' | 'untilInitialized'
>
);
await new Promise((resolve) => setTimeout(resolve, 0));
const subscription = selectionsManager.api.timeslice$.pipe(skip(1)).subscribe(onFireMock);
// remove timeslice to trigger changes
control3Api.timeslice$.next(undefined);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(selectionsManager.hasUnappliedSelections$.value).toBe(true);
expect(onFireMock).toHaveBeenCalledTimes(0);
selectionsManager.applySelections();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(selectionsManager.hasUnappliedSelections$.value).toBe(false);
expect(onFireMock).toHaveBeenCalledTimes(1);
const timeslice = onFireMock.mock.calls[0][0];
expect(timeslice).toBeUndefined();
subscription.unsubscribe();
});
});
describe('auto apply selections enabled', () => {
beforeEach(() => {
controlGroupApi.autoApplySelections$.next(true);
});
test('should publish initial filters and initial timeslice', async () => {
const selectionsManager = initSelectionsManager(
controlGroupApi as unknown as Pick<
ControlGroupApi,
'autoApplySelections$' | 'children$' | 'untilInitialized'
>
);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(selectionsManager.api.filters$.value).toEqual([
{
meta: {
alias: 'control2 original filter',
},
},
]);
expect(new Date(selectionsManager.api.timeslice$.value![0]).toISOString()).toEqual(
'2024-06-09T06:00:00.000Z'
);
expect(new Date(selectionsManager.api.timeslice$.value![1]).toISOString()).toEqual(
'2024-06-09T12:00:00.000Z'
);
expect(selectionsManager.hasUnappliedSelections$.value).toBe(false);
});
test('should publish filter changes', async () => {
const selectionsManager = initSelectionsManager(
controlGroupApi as unknown as Pick<
ControlGroupApi,
'autoApplySelections$' | 'children$' | 'untilInitialized'
>
);
await new Promise((resolve) => setTimeout(resolve, 0));
const subscription = selectionsManager.api.filters$.pipe(skip(1)).subscribe(onFireMock);
// remove filter to trigger changes
control2Api.filters$.next(undefined);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(selectionsManager.hasUnappliedSelections$.value).toBe(false);
expect(onFireMock).toHaveBeenCalledTimes(1);
const filters = onFireMock.mock.calls[0][0];
expect(filters).toEqual([]);
subscription.unsubscribe();
});
test('should publish timeslice changes', async () => {
const selectionsManager = initSelectionsManager(
controlGroupApi as unknown as Pick<
ControlGroupApi,
'autoApplySelections$' | 'children$' | 'untilInitialized'
>
);
await new Promise((resolve) => setTimeout(resolve, 0));
const subscription = selectionsManager.api.timeslice$.pipe(skip(1)).subscribe(onFireMock);
// remove timeslice to trigger changes
control3Api.timeslice$.next(undefined);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(selectionsManager.hasUnappliedSelections$.value).toBe(false);
expect(onFireMock).toHaveBeenCalledTimes(1);
const timeslice = onFireMock.mock.calls[0][0];
expect(timeslice).toBeUndefined();
subscription.unsubscribe();
});
});
});

View file

@ -0,0 +1,120 @@
/*
* 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.
*/
import { BehaviorSubject, combineLatest, Subscription } from 'rxjs';
import deepEqual from 'fast-deep-equal';
import { Filter } from '@kbn/es-query';
import { combineCompatibleChildrenApis } from '@kbn/presentation-containers';
import {
apiPublishesFilters,
apiPublishesTimeslice,
PublishesFilters,
PublishesTimeslice,
} from '@kbn/presentation-publishing';
import { ControlGroupApi } from './types';
export function initSelectionsManager(
controlGroupApi: Pick<ControlGroupApi, 'autoApplySelections$' | 'children$' | 'untilInitialized'>
) {
const filters$ = new BehaviorSubject<Filter[] | undefined>([]);
const unpublishedFilters$ = new BehaviorSubject<Filter[] | undefined>([]);
const timeslice$ = new BehaviorSubject<[number, number] | undefined>(undefined);
const unpublishedTimeslice$ = new BehaviorSubject<[number, number] | undefined>(undefined);
const hasUnappliedSelections$ = new BehaviorSubject(false);
const subscriptions: Subscription[] = [];
controlGroupApi.untilInitialized().then(() => {
const initialFilters: Filter[] = [];
let initialTimeslice: undefined | [number, number];
Object.values(controlGroupApi.children$.value).forEach((controlApi) => {
if (apiPublishesFilters(controlApi) && controlApi.filters$.value) {
initialFilters.push(...controlApi.filters$.value);
}
if (apiPublishesTimeslice(controlApi) && controlApi.timeslice$.value) {
initialTimeslice = controlApi.timeslice$.value;
}
});
if (initialFilters.length) {
filters$.next(initialFilters);
unpublishedFilters$.next(initialFilters);
}
if (initialTimeslice) {
timeslice$.next(initialTimeslice);
unpublishedTimeslice$.next(initialTimeslice);
}
subscriptions.push(
combineCompatibleChildrenApis<PublishesFilters, Filter[]>(
controlGroupApi,
'filters$',
apiPublishesFilters,
[]
).subscribe((newFilters) => unpublishedFilters$.next(newFilters))
);
subscriptions.push(
combineCompatibleChildrenApis<PublishesTimeslice, [number, number] | undefined>(
controlGroupApi,
'timeslice$',
apiPublishesTimeslice,
undefined,
// flatten method
(values) => {
// control group should never allow multiple timeslider controls
// return last timeslider control value
return values.length === 0 ? undefined : values[values.length - 1];
}
).subscribe((newTimeslice) => unpublishedTimeslice$.next(newTimeslice))
);
subscriptions.push(
combineLatest([filters$, unpublishedFilters$, timeslice$, unpublishedTimeslice$]).subscribe(
([filters, unpublishedFilters, timeslice, unpublishedTimeslice]) => {
const next =
!deepEqual(timeslice, unpublishedTimeslice) || !deepEqual(filters, unpublishedFilters);
if (hasUnappliedSelections$.value !== next) {
hasUnappliedSelections$.next(next);
}
}
)
);
subscriptions.push(
combineLatest([
controlGroupApi.autoApplySelections$,
unpublishedFilters$,
unpublishedTimeslice$,
]).subscribe(([autoApplySelections]) => {
if (autoApplySelections) {
applySelections();
}
})
);
});
function applySelections() {
if (!deepEqual(filters$.value, unpublishedFilters$.value)) {
filters$.next(unpublishedFilters$.value);
}
if (!deepEqual(timeslice$.value, unpublishedTimeslice$.value)) {
timeslice$.next(unpublishedTimeslice$.value);
}
}
return {
api: {
filters$,
timeslice$,
},
applySelections,
cleanup: () => {
subscriptions.forEach((subscription) => subscription.unsubscribe());
},
hasUnappliedSelections$,
};
}

View file

@ -58,6 +58,7 @@ export type ControlGroupApi = PresentationContainer &
autoApplySelections$: PublishingSubject<boolean>;
controlFetch$: (controlUuid: string) => Observable<ControlFetchContext>;
ignoreParentSettings$: PublishingSubject<ParentIgnoreSettings | undefined>;
untilInitialized: () => Promise<void>;
};
export interface ControlGroupRuntimeState {

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import React, { useImperativeHandle, useMemo } from 'react';
import React, { useEffect, useImperativeHandle, useState } from 'react';
import { BehaviorSubject } from 'rxjs';
import { StateComparators } from '@kbn/presentation-publishing';
@ -27,23 +27,30 @@ export const ControlRenderer = <
uuid,
getParentApi,
onApiAvailable,
isControlGroupInitialized,
}: {
type: string;
uuid: string;
getParentApi: () => ControlGroupApi;
onApiAvailable?: (api: ApiType) => void;
isControlGroupInitialized: boolean;
}) => {
const component = useMemo(
() =>
(() => {
const [component, setComponent] = useState<undefined | React.FC<{ className: string }>>(
undefined
);
useEffect(
() => {
let ignore = false;
async function buildControl() {
const parentApi = getParentApi();
const factory = getControlFactory<StateType, ApiType>(type);
const buildApi = (
apiRegistration: ControlApiRegistration<ApiType>,
comparators: StateComparators<StateType> // TODO: Use these to calculate unsaved changes
): ApiType => {
const fullApi = {
return {
...apiRegistration,
uuid,
parentApi,
@ -51,34 +58,69 @@ export const ControlRenderer = <
resetUnsavedChanges: () => {},
type: factory.type,
} as unknown as ApiType;
onApiAvailable?.(fullApi);
return fullApi;
};
const { rawState: initialState } = parentApi.getSerializedStateForChild(uuid) ?? {};
const { api, Component } = factory.buildControl(
return await factory.buildControl(
initialState as unknown as StateType,
buildApi,
uuid,
parentApi
);
}
return React.forwardRef<typeof api, { className: string }>((props, ref) => {
// expose the api into the imperative handle
useImperativeHandle(ref, () => api, []);
buildControl()
.then(({ api, Component }) => {
if (ignore) {
return;
}
return <Component {...props} />;
onApiAvailable?.(api);
setComponent(
React.forwardRef<typeof api, { className: string }>((props, ref) => {
// expose the api into the imperative handle
useImperativeHandle(ref, () => api, []);
return <Component {...props} />;
})
);
})
.catch((error) => {
if (ignore) {
return;
}
/**
* critical error encountered when trying to build the control;
* since no API is available, create a dummy API that allows the control to be deleted
* */
const errorApi = {
uuid,
blockingError: new BehaviorSubject(error),
};
setComponent(
React.forwardRef<typeof errorApi, { className: string }>((_, ref) => {
// expose the dummy error api into the imperative handle
useImperativeHandle(ref, () => errorApi, []);
return null;
})
);
});
})(),
return () => {
ignore = true;
};
},
/**
* Disabling exhaustive deps because we do not want to re-fetch the component
* from the embeddable registry unless the type changes.
* unless the type changes.
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
[type]
);
return <ControlPanel<ApiType> Component={component} uuid={uuid} />;
return component && isControlGroupInitialized ? (
// @ts-expect-error
<ControlPanel<ApiType> Component={component} uuid={uuid} />
) : // Control group will not display controls until all controls are initialized
null;
};

View file

@ -7,7 +7,7 @@
*/
import { isEqual } from 'lodash';
import { BehaviorSubject, combineLatest, switchMap } from 'rxjs';
import { BehaviorSubject, combineLatest, first, switchMap } from 'rxjs';
import { CoreStart } from '@kbn/core-lifecycle-browser';
import { DataView, DATA_VIEW_SAVED_OBJECT_TYPE } from '@kbn/data-views-plugin/common';
@ -39,6 +39,7 @@ export const initializeDataControl = <EditorState extends object = {}>(
comparators: StateComparators<DefaultDataControlState>;
stateManager: ControlStateManager<DefaultDataControlState>;
serialize: () => SerializedPanelState<DefaultControlState>;
untilFiltersInitialized: () => Promise<void>;
} => {
const defaultControl = initializeDefaultControlApi(state);
@ -47,7 +48,7 @@ export const initializeDataControl = <EditorState extends object = {}>(
const dataViewId = new BehaviorSubject<string>(state.dataViewId);
const fieldName = new BehaviorSubject<string>(state.fieldName);
const dataViews = new BehaviorSubject<DataView[] | undefined>(undefined);
const filters = new BehaviorSubject<Filter[] | undefined>(undefined);
const filters$ = new BehaviorSubject<Filter[] | undefined>(undefined);
const stateManager: ControlStateManager<DefaultDataControlState> = {
...defaultControl.stateManager,
@ -158,9 +159,9 @@ export const initializeDataControl = <EditorState extends object = {}>(
defaultPanelTitle,
dataViews,
onEdit,
filters$: filters,
filters$,
setOutputFilter: (newFilter: Filter | undefined) => {
filters.next(newFilter ? [newFilter] : undefined);
filters$.next(newFilter ? [newFilter] : undefined);
},
isEditingEnabled: () => true,
};
@ -195,5 +196,19 @@ export const initializeDataControl = <EditorState extends object = {}>(
],
};
},
untilFiltersInitialized: async () => {
return new Promise((resolve) => {
combineLatest([defaultControl.api.blockingError, filters$])
.pipe(
first(
([blockingError, filters]) =>
blockingError !== undefined || (filters?.length ?? 0) > 0
)
)
.subscribe(() => {
resolve();
});
});
},
};
};

View file

@ -7,7 +7,7 @@
*/
import React from 'react';
import { BehaviorSubject, first, of, skip } from 'rxjs';
import { BehaviorSubject, of } from 'rxjs';
import { estypes } from '@elastic/elasticsearch';
import { coreMock } from '@kbn/core/public/mocks';
@ -65,7 +65,7 @@ describe('RangesliderControlApi', () => {
// @ts-ignore
mockDataViews.get = async (id: string): Promise<DataView> => {
if (id !== 'myDataViewId') {
throw new Error(`Simulated error: no data view found for id ${id}`);
throw new Error(`no data view found for id ${id}`);
}
return {
id,
@ -113,9 +113,9 @@ describe('RangesliderControlApi', () => {
};
}
describe('filters$', () => {
test('should not set filters$ when value is not provided', (done) => {
const { api } = factory.buildControl(
describe('on initialize', () => {
test('should not set filters$ when value is not provided', async () => {
const { api } = await factory.buildControl(
{
dataViewId: 'myDataView',
fieldName: 'myFieldName',
@ -124,14 +124,11 @@ describe('RangesliderControlApi', () => {
uuid,
controlGroupApi
);
api.filters$.pipe(skip(1), first()).subscribe((filter) => {
expect(filter).toBeUndefined();
done();
});
expect(api.filters$.value).toBeUndefined();
});
test('should set filters$ when value is provided', (done) => {
const { api } = factory.buildControl(
test('should set filters$ when value is provided', async () => {
const { api } = await factory.buildControl(
{
dataViewId: 'myDataViewId',
fieldName: 'myFieldName',
@ -141,31 +138,45 @@ describe('RangesliderControlApi', () => {
uuid,
controlGroupApi
);
api.filters$.pipe(skip(1), first()).subscribe((filter) => {
expect(filter).toEqual([
{
meta: {
field: 'myFieldName',
index: 'myDataViewId',
key: 'myFieldName',
params: {
expect(api.filters$.value).toEqual([
{
meta: {
field: 'myFieldName',
index: 'myDataViewId',
key: 'myFieldName',
params: {
gte: 5,
lte: 10,
},
type: 'range',
},
query: {
range: {
myFieldName: {
gte: 5,
lte: 10,
},
type: 'range',
},
query: {
range: {
myFieldName: {
gte: 5,
lte: 10,
},
},
},
},
]);
done();
});
},
]);
});
test('should set blocking error when data view is not found', async () => {
const { api } = await factory.buildControl(
{
dataViewId: 'notGonnaFindMeDataView',
fieldName: 'myFieldName',
value: ['5', '10'],
},
buildApiMock,
uuid,
controlGroupApi
);
expect(api.filters$.value).toBeUndefined();
expect(api.blockingError.value?.message).toEqual(
'no data view found for id notGonnaFindMeDataView'
);
});
});
@ -174,7 +185,7 @@ describe('RangesliderControlApi', () => {
totalResults = 0; // simulate no results by returning hits total of zero
min = null; // simulate no results by returning min aggregation value of null
max = null; // simulate no results by returning max aggregation value of null
const { Component } = factory.buildControl(
const { Component } = await factory.buildControl(
{
dataViewId: 'myDataViewId',
fieldName: 'myFieldName',
@ -193,7 +204,7 @@ describe('RangesliderControlApi', () => {
describe('min max', () => {
test('bounds inputs should display min and max placeholders when there is no selected range', async () => {
const { Component } = factory.buildControl(
const { Component } = await factory.buildControl(
{
dataViewId: 'myDataViewId',
fieldName: 'myFieldName',
@ -213,8 +224,8 @@ describe('RangesliderControlApi', () => {
});
describe('step state', () => {
test('default value provided when state.step is undefined', () => {
const { api } = factory.buildControl(
test('default value provided when state.step is undefined', async () => {
const { api } = await factory.buildControl(
{
dataViewId: 'myDataViewId',
fieldName: 'myFieldName',
@ -227,8 +238,8 @@ describe('RangesliderControlApi', () => {
expect(serializedState.rawState.step).toBe(1);
});
test('retains value from initial state', () => {
const { api } = factory.buildControl(
test('retains value from initial state', async () => {
const { api } = await factory.buildControl(
{
dataViewId: 'myDataViewId',
fieldName: 'myFieldName',

View file

@ -7,13 +7,10 @@
*/
import React, { useEffect, useMemo } from 'react';
import deepEqual from 'react-fast-compare';
import { EuiFieldNumber, EuiFormRow } from '@elastic/eui';
import { buildRangeFilter, Filter, RangeFilterParams } from '@kbn/es-query';
import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
import { BehaviorSubject, combineLatest, distinctUntilChanged, map, skip } from 'rxjs';
import { BehaviorSubject, combineLatest, map, skip } from 'rxjs';
import { initializeDataControl } from '../initialize_data_control';
import { DataControlFactory } from '../types';
import { RangeSliderControl } from './components/range_slider_control';
@ -58,7 +55,7 @@ export const getRangesliderControlFactory = (
</>
);
},
buildControl: (initialState, buildApi, uuid, controlGroupApi) => {
buildControl: async (initialState, buildApi, uuid, controlGroupApi) => {
const controlFetch$ = controlGroupApi.controlFetch$(uuid);
const loadingMinMax$ = new BehaviorSubject<boolean>(false);
const loadingHasNoResults$ = new BehaviorSubject<boolean>(false);
@ -125,10 +122,7 @@ export const getRangesliderControlFactory = (
dataControl.stateManager.fieldName,
dataControl.stateManager.dataViewId,
])
.pipe(
distinctUntilChanged(deepEqual),
skip(1) // skip first filter output because it will have been applied in initialize
)
.pipe(skip(1))
.subscribe(() => {
step$.next(1);
value$.next(undefined);
@ -206,6 +200,10 @@ export const getRangesliderControlFactory = (
selectionHasNoResults$.next(hasNoResults);
});
if (initialState.value !== undefined) {
await dataControl.untilFiltersInitialized();
}
return {
api,
Component: ({ className: controlPanelClassName }) => {

View file

@ -8,7 +8,7 @@
import React, { useEffect } from 'react';
import deepEqual from 'react-fast-compare';
import { BehaviorSubject, combineLatest, debounceTime, distinctUntilChanged } from 'rxjs';
import { BehaviorSubject, combineLatest, debounceTime, distinctUntilChanged, skip } from 'rxjs';
import { EuiFieldSearch, EuiFormRow, EuiRadioGroup } from '@elastic/eui';
import { css } from '@emotion/react';
@ -80,7 +80,7 @@ export const getSearchControlFactory = ({
</EuiFormRow>
);
},
buildControl: (initialState, buildApi, uuid, parentApi) => {
buildControl: async (initialState, buildApi, uuid, parentApi) => {
const searchString = new BehaviorSubject<string | undefined>(initialState.searchString);
const searchTechnique = new BehaviorSubject<SearchControlTechniques | undefined>(
initialState.searchTechnique ?? DEFAULT_SEARCH_TECHNIQUE
@ -178,11 +178,15 @@ export const getSearchControlFactory = ({
dataControl.stateManager.fieldName,
dataControl.stateManager.dataViewId,
])
.pipe(distinctUntilChanged(deepEqual))
.pipe(skip(1))
.subscribe(() => {
searchString.next(undefined);
});
if (initialState.searchString?.length) {
await dataControl.untilFiltersInitialized();
}
return {
api,
/**

View file

@ -63,13 +63,13 @@ describe('TimesliderControlApi', () => {
});
});
test('Should set timeslice to undefined when state does not provide percentage of timeRange', () => {
const { api } = factory.buildControl({}, buildApiMock, uuid, controlGroupApi);
test('Should set timeslice to undefined when state does not provide percentage of timeRange', async () => {
const { api } = await factory.buildControl({}, buildApiMock, uuid, controlGroupApi);
expect(api.timeslice$.value).toBe(undefined);
});
test('Should set timeslice to values within time range when state provides percentage of timeRange', () => {
const { api } = factory.buildControl(
test('Should set timeslice to values within time range when state provides percentage of timeRange', async () => {
const { api } = await factory.buildControl(
{
timesliceStartAsPercentageOfTimeRange: 0.25,
timesliceEndAsPercentageOfTimeRange: 0.5,
@ -84,7 +84,7 @@ describe('TimesliderControlApi', () => {
});
test('Should update timeslice when time range changes', async () => {
const { api } = factory.buildControl(
const { api } = await factory.buildControl(
{
timesliceStartAsPercentageOfTimeRange: 0.25,
timesliceEndAsPercentageOfTimeRange: 0.5,
@ -108,7 +108,7 @@ describe('TimesliderControlApi', () => {
});
test('Clicking previous button should advance timeslice backward', async () => {
const { api } = factory.buildControl(
const { api } = await factory.buildControl(
{
timesliceStartAsPercentageOfTimeRange: 0.25,
timesliceEndAsPercentageOfTimeRange: 0.5,
@ -130,7 +130,7 @@ describe('TimesliderControlApi', () => {
});
test('Clicking previous button should wrap when time range start is reached', async () => {
const { api } = factory.buildControl(
const { api } = await factory.buildControl(
{
timesliceStartAsPercentageOfTimeRange: 0.25,
timesliceEndAsPercentageOfTimeRange: 0.5,
@ -153,7 +153,7 @@ describe('TimesliderControlApi', () => {
});
test('Clicking next button should advance timeslice forward', async () => {
const { api } = factory.buildControl(
const { api } = await factory.buildControl(
{
timesliceStartAsPercentageOfTimeRange: 0.25,
timesliceEndAsPercentageOfTimeRange: 0.5,
@ -175,7 +175,7 @@ describe('TimesliderControlApi', () => {
});
test('Clicking next button should wrap when time range end is reached', async () => {
const { api } = factory.buildControl(
const { api } = await factory.buildControl(
{
timesliceStartAsPercentageOfTimeRange: 0.25,
timesliceEndAsPercentageOfTimeRange: 0.5,
@ -199,7 +199,7 @@ describe('TimesliderControlApi', () => {
});
test('Resetting state with comparators should reset timeslice', async () => {
const { api } = factory.buildControl(
const { api } = await factory.buildControl(
{
timesliceStartAsPercentageOfTimeRange: 0.25,
timesliceEndAsPercentageOfTimeRange: 0.5,

View file

@ -49,7 +49,7 @@ export const getTimesliderControlFactory = (
i18n.translate('controlsExamples.timesliderControl.displayName', {
defaultMessage: 'Time slider',
}),
buildControl: (initialState, buildApi, uuid, controlGroupApi) => {
buildControl: async (initialState, buildApi, uuid, controlGroupApi) => {
const { timeRangeMeta$, formatDate, cleanupTimeRangeSubscription } =
initTimeRangeSubscription(controlGroupApi, services);
const timeslice$ = new BehaviorSubject<[number, number] | undefined>(undefined);

View file

@ -85,7 +85,7 @@ export interface ControlFactory<
) => ControlApi,
uuid: string,
parentApi: ControlGroupApi
) => { api: ControlApi; Component: React.FC<{ className: string }> };
) => Promise<{ api: ControlApi; Component: React.FC<{ className: string }> }>;
}
export type ControlStateManager<State extends object = object> = {

View file

@ -35,5 +35,6 @@
"@kbn/presentation-panel-plugin",
"@kbn/datemath",
"@kbn/ui-theme",
"@kbn/react-kibana-context-render",
]
}

View file

@ -53,10 +53,16 @@ export const apiIsPresentationContainer = (api: unknown | null): api is Presenta
typeof (api as PresentationContainer)?.removePanel === 'function' &&
typeof (api as PresentationContainer)?.replacePanel === 'function' &&
typeof (api as PresentationContainer)?.addNewPanel === 'function' &&
(api as PresentationContainer)?.children$
apiPublishesChildren(api)
);
};
export const apiPublishesChildren = (
api: unknown | null
): api is Pick<PresentationContainer, 'children$'> => {
return Boolean((api as PresentationContainer)?.children$);
};
export const getContainerParentFromAPI = (
api: null | unknown
): PresentationContainer | undefined => {
@ -102,7 +108,7 @@ export const combineCompatibleChildrenApis = <ApiType extends unknown, Publishin
emptyState: PublishingSubjectType,
flattenMethod?: (array: PublishingSubjectType[]) => PublishingSubjectType
): Observable<PublishingSubjectType> => {
if (!api || !apiIsPresentationContainer(api)) return of();
if (!api || !apiPublishesChildren(api)) return of();
return api.children$.pipe(
switchMap((children) => {