mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[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:
parent
74194fbf7a
commit
45071d1c09
24 changed files with 918 additions and 325 deletions
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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));
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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(),
|
||||
},
|
||||
];
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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> },
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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$,
|
||||
};
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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',
|
||||
|
|
|
@ -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 }) => {
|
||||
|
|
|
@ -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,
|
||||
/**
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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> = {
|
||||
|
|
|
@ -35,5 +35,6 @@
|
|||
"@kbn/presentation-panel-plugin",
|
||||
"@kbn/datemath",
|
||||
"@kbn/ui-theme",
|
||||
"@kbn/react-kibana-context-render",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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) => {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue