mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[Embeddable Rebuild] [Controls] Add control registry + example React control (#182842)
Closes https://github.com/elastic/kibana/issues/184373 ## Summary This PR marks the first step of the control group migration to the new React embeddable system. A few notes about this: - In the new system, each individual control will no longer be an "embeddable" - instead, we are creating a **new** control-specific registry for all controls. This is **modelled** after the embeddable registry, but it is locked down and much more controls-specific. - Most of the work accomplished in this PR is hidden away in the `examples` plugin - that way, user-facing code is not impacted. After some discussion, we decided to do it this way because refactoring the control group to work with both legacy and new controls (like we did for the dashboard container) felt like a very large undertaking for minimal benefit. Instead, all work will be contained in the example plugin (including building out the existing control types with the new framework) and we will do a final "swap" of the legacy control group with the new React control group as part of https://github.com/elastic/kibana/issues/174961 - This PR does **not** contain a fully functional control group embeddable - instead, the main point of this PR is to introduce the control registry and an example control. The current control group embeddable is provided just to give the **bare minimum** of functionality. - In order to find the new Search control example, navigate to Developer Examples > Controls > Register a new React control - The example search control only works on text fields. See https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-match-query.html and https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html for information on the two search techniques. ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
993ef43e4e
commit
36f2ff409f
56 changed files with 3407 additions and 229 deletions
|
@ -12,7 +12,9 @@
|
|||
"developerExamples",
|
||||
"embeddable",
|
||||
"navigation",
|
||||
"presentationUtil"
|
||||
"presentationUtil",
|
||||
"uiActions",
|
||||
"dataViews"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,49 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { EuiSpacer } from '@elastic/eui';
|
||||
|
||||
import { AppMountParameters } from '@kbn/core/public';
|
||||
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
|
||||
import { ControlsExampleStartDeps } from './plugin';
|
||||
import { BasicReduxExample } from './basic_redux_example';
|
||||
import { EditExample } from './edit_example';
|
||||
import { SearchExample } from './search_example';
|
||||
import { AddButtonExample } from './add_button_example';
|
||||
|
||||
export const renderApp = async (
|
||||
{ data, navigation }: ControlsExampleStartDeps,
|
||||
{ element }: AppMountParameters
|
||||
) => {
|
||||
const dataViews = await data.dataViews.find('kibana_sample_data_logs');
|
||||
const examples =
|
||||
dataViews.length > 0 ? (
|
||||
<>
|
||||
<SearchExample dataView={dataViews[0]} navigation={navigation} data={data} />
|
||||
<EuiSpacer size="xl" />
|
||||
<EditExample />
|
||||
<EuiSpacer size="xl" />
|
||||
<BasicReduxExample dataViewId={dataViews[0].id!} />
|
||||
<EuiSpacer size="xl" />
|
||||
<AddButtonExample dataViewId={dataViews[0].id!} />
|
||||
</>
|
||||
) : (
|
||||
<div>{'Install web logs sample data to run controls examples.'}</div>
|
||||
);
|
||||
|
||||
ReactDOM.render(
|
||||
<KibanaPageTemplate>
|
||||
<KibanaPageTemplate.Header pageTitle="Controls as a Building Block" />
|
||||
<KibanaPageTemplate.Section>{examples}</KibanaPageTemplate.Section>
|
||||
</KibanaPageTemplate>,
|
||||
element
|
||||
);
|
||||
return () => ReactDOM.unmountComponentAtNode(element);
|
||||
};
|
90
examples/controls_example/public/app/app.tsx
Normal file
90
examples/controls_example/public/app/app.tsx
Normal file
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
* 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 {
|
||||
EuiPage,
|
||||
EuiPageBody,
|
||||
EuiPageHeader,
|
||||
EuiPageSection,
|
||||
EuiPageTemplate,
|
||||
EuiSpacer,
|
||||
EuiTab,
|
||||
EuiTabs,
|
||||
} from '@elastic/eui';
|
||||
import React, { useState } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import { AppMountParameters, CoreStart } from '@kbn/core/public';
|
||||
import { ControlsExampleStartDeps } from '../plugin';
|
||||
import { ControlGroupRendererExamples } from './control_group_renderer_examples';
|
||||
import { ReactControlExample } from './react_control_example';
|
||||
|
||||
const CONTROLS_AS_A_BUILDING_BLOCK = 'controls_as_a_building_block';
|
||||
const CONTROLS_REFACTOR_TEST = 'controls_refactor_test';
|
||||
|
||||
const App = ({
|
||||
core,
|
||||
data,
|
||||
navigation,
|
||||
}: { core: CoreStart } & Pick<ControlsExampleStartDeps, 'data' | 'navigation'>) => {
|
||||
const [selectedTabId, setSelectedTabId] = useState(CONTROLS_REFACTOR_TEST); // TODO: Make this the first tab
|
||||
|
||||
function onSelectedTabChanged(tabId: string) {
|
||||
setSelectedTabId(tabId);
|
||||
}
|
||||
|
||||
function renderTabContent() {
|
||||
if (selectedTabId === CONTROLS_REFACTOR_TEST) {
|
||||
return <ReactControlExample dataViews={data.dataViews} core={core} />;
|
||||
}
|
||||
|
||||
return <ControlGroupRendererExamples data={data} navigation={navigation} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiPage>
|
||||
<EuiPageBody>
|
||||
<EuiPageSection>
|
||||
<EuiPageHeader pageTitle="Controls" />
|
||||
</EuiPageSection>
|
||||
<EuiPageTemplate.Section>
|
||||
<EuiPageSection>
|
||||
<EuiTabs>
|
||||
<EuiTab
|
||||
onClick={() => onSelectedTabChanged(CONTROLS_REFACTOR_TEST)}
|
||||
isSelected={CONTROLS_REFACTOR_TEST === selectedTabId}
|
||||
>
|
||||
Register a new React control
|
||||
</EuiTab>
|
||||
<EuiTab
|
||||
onClick={() => onSelectedTabChanged(CONTROLS_AS_A_BUILDING_BLOCK)}
|
||||
isSelected={CONTROLS_AS_A_BUILDING_BLOCK === selectedTabId}
|
||||
>
|
||||
Controls as a building block
|
||||
</EuiTab>
|
||||
</EuiTabs>
|
||||
|
||||
<EuiSpacer />
|
||||
|
||||
{renderTabContent()}
|
||||
</EuiPageSection>
|
||||
</EuiPageTemplate.Section>
|
||||
</EuiPageBody>
|
||||
</EuiPage>
|
||||
);
|
||||
};
|
||||
|
||||
export const renderApp = (
|
||||
core: CoreStart,
|
||||
{ data, navigation }: Pick<ControlsExampleStartDeps, 'data' | 'navigation'>,
|
||||
{ element }: AppMountParameters
|
||||
) => {
|
||||
ReactDOM.render(<App core={core} data={data} navigation={navigation} />, element);
|
||||
|
||||
return () => ReactDOM.unmountComponentAtNode(element);
|
||||
};
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* 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 from 'react';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
|
||||
import { EuiLoadingSpinner, EuiSpacer, EuiText } from '@elastic/eui';
|
||||
import { SearchExample } from './control_group_renderer_examples/search_example';
|
||||
import { EditExample } from './control_group_renderer_examples/edit_example';
|
||||
import { BasicReduxExample } from './control_group_renderer_examples/basic_redux_example';
|
||||
import { AddButtonExample } from './control_group_renderer_examples/add_button_example';
|
||||
import { ControlsExampleStartDeps } from '../plugin';
|
||||
|
||||
export const ControlGroupRendererExamples = ({
|
||||
data,
|
||||
navigation,
|
||||
}: Pick<ControlsExampleStartDeps, 'data' | 'navigation'>) => {
|
||||
const {
|
||||
loading,
|
||||
value: dataViews,
|
||||
error,
|
||||
} = useAsync(async () => {
|
||||
return await data.dataViews.find('kibana_sample_data_logs');
|
||||
}, []);
|
||||
|
||||
if (loading) return <EuiLoadingSpinner />;
|
||||
|
||||
return dataViews && dataViews.length > 0 && !error ? (
|
||||
<>
|
||||
<SearchExample dataView={dataViews[0]} navigation={navigation} data={data} />
|
||||
<EuiSpacer size="xl" />
|
||||
<EditExample />
|
||||
<EuiSpacer size="xl" />
|
||||
<BasicReduxExample dataViewId={dataViews[0].id!} />
|
||||
<EuiSpacer size="xl" />
|
||||
<AddButtonExample dataViewId={dataViews[0].id!} />
|
||||
</>
|
||||
) : (
|
||||
<EuiText>{'Install web logs sample data to run controls examples.'}</EuiText>
|
||||
);
|
||||
};
|
|
@ -23,7 +23,7 @@ import {
|
|||
EuiTitle,
|
||||
} from '@elastic/eui';
|
||||
import { AwaitingControlGroupAPI, ControlGroupRenderer } from '@kbn/controls-plugin/public';
|
||||
import { PLUGIN_ID } from './constants';
|
||||
import { PLUGIN_ID } from '../../constants';
|
||||
|
||||
interface Props {
|
||||
data: DataPublicPluginStart;
|
234
examples/controls_example/public/app/react_control_example.tsx
Normal file
234
examples/controls_example/public/app/react_control_example.tsx
Normal file
|
@ -0,0 +1,234 @@
|
|||
/*
|
||||
* 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 {
|
||||
EuiButton,
|
||||
EuiButtonGroup,
|
||||
EuiCodeBlock,
|
||||
EuiEmptyPrompt,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiLoadingSpinner,
|
||||
EuiSpacer,
|
||||
} from '@elastic/eui';
|
||||
import { CONTROL_GROUP_TYPE } from '@kbn/controls-plugin/common';
|
||||
import { CoreStart } from '@kbn/core/public';
|
||||
import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
|
||||
import { ReactEmbeddableRenderer, ViewMode } from '@kbn/embeddable-plugin/public';
|
||||
import { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
|
||||
import { PresentationContainer } from '@kbn/presentation-containers';
|
||||
import {
|
||||
HasUniqueId,
|
||||
PublishesUnifiedSearch,
|
||||
PublishesViewMode,
|
||||
useStateFromPublishingSubject,
|
||||
ViewMode as ViewModeType,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import { toMountPoint } from '@kbn/react-kibana-mount';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
import useMount from 'react-use/lib/useMount';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { ControlGroupApi } from '../react_controls/control_group/types';
|
||||
|
||||
const toggleViewButtons = [
|
||||
{
|
||||
id: `viewModeToggle_edit`,
|
||||
value: ViewMode.EDIT,
|
||||
label: 'Edit mode',
|
||||
},
|
||||
{
|
||||
id: `viewModeToggle_view`,
|
||||
value: ViewMode.VIEW,
|
||||
label: 'View mode',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* I am mocking the dashboard API so that the data table embeddble responds to changes to the
|
||||
* data view publishing subject from the control group
|
||||
*/
|
||||
type MockedDashboardApi = PresentationContainer &
|
||||
PublishesViewMode &
|
||||
PublishesUnifiedSearch & {
|
||||
publishFilters: (newFilters: Filter[] | undefined) => void;
|
||||
setViewMode: (newViewMode: ViewMode) => void;
|
||||
setChild: (child: HasUniqueId) => void;
|
||||
};
|
||||
|
||||
export const ReactControlExample = ({
|
||||
core,
|
||||
dataViews: dataViewsService,
|
||||
}: {
|
||||
core: CoreStart;
|
||||
dataViews: DataViewsPublicPluginStart;
|
||||
}) => {
|
||||
const [dashboardApi, setDashboardApi] = useState<MockedDashboardApi | undefined>(undefined);
|
||||
const [controlGroupApi, setControlGroupApi] = useState<ControlGroupApi | undefined>(undefined);
|
||||
const viewModeSelected = useStateFromPublishingSubject(dashboardApi?.viewMode);
|
||||
|
||||
useMount(() => {
|
||||
const viewMode = new BehaviorSubject<ViewModeType>(ViewMode.EDIT as ViewModeType);
|
||||
const filters$ = new BehaviorSubject<Filter[] | undefined>([]);
|
||||
const query$ = new BehaviorSubject<Query | AggregateQuery | undefined>(undefined);
|
||||
const timeRange$ = new BehaviorSubject<TimeRange | undefined>(undefined);
|
||||
const children$ = new BehaviorSubject<{ [key: string]: unknown }>({});
|
||||
|
||||
setDashboardApi({
|
||||
viewMode,
|
||||
filters$,
|
||||
query$,
|
||||
timeRange$,
|
||||
children$,
|
||||
publishFilters: (newFilters) => filters$.next(newFilters),
|
||||
setViewMode: (newViewMode) => viewMode.next(newViewMode),
|
||||
setChild: (child) => children$.next({ ...children$.getValue(), [child.uuid]: child }),
|
||||
removePanel: () => {},
|
||||
replacePanel: () => {
|
||||
return Promise.resolve('');
|
||||
},
|
||||
getPanelCount: () => {
|
||||
return 2;
|
||||
},
|
||||
addNewPanel: () => {
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: Maybe remove `useAsync` - see https://github.com/elastic/kibana/pull/182842#discussion_r1624909709
|
||||
const {
|
||||
loading,
|
||||
value: dataViews,
|
||||
error,
|
||||
} = useAsync(async () => {
|
||||
return await dataViewsService.find('kibana_sample_data_logs');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!controlGroupApi) return;
|
||||
|
||||
const subscription = controlGroupApi.filters$.subscribe((controlGroupFilters) => {
|
||||
if (dashboardApi) dashboardApi.publishFilters(controlGroupFilters);
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [dashboardApi, controlGroupApi]);
|
||||
|
||||
if (error || (!dataViews?.[0]?.id && !loading))
|
||||
return (
|
||||
<EuiEmptyPrompt
|
||||
iconType="error"
|
||||
color="danger"
|
||||
title={<h2>There was an error!</h2>}
|
||||
body={<p>{error ? error.message : 'Please add at least one data view.'}</p>}
|
||||
/>
|
||||
);
|
||||
|
||||
return loading ? (
|
||||
<EuiLoadingSpinner />
|
||||
) : (
|
||||
<>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
fill
|
||||
onClick={() => {
|
||||
controlGroupApi?.onEdit();
|
||||
}}
|
||||
size="s"
|
||||
>
|
||||
Control group settings
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
onClick={() => {
|
||||
core.overlays.openModal(
|
||||
toMountPoint(
|
||||
<EuiCodeBlock language="json">
|
||||
{JSON.stringify(controlGroupApi?.serializeState(), null, 2)}
|
||||
</EuiCodeBlock>,
|
||||
{
|
||||
theme: core.theme,
|
||||
i18n: core.i18n,
|
||||
}
|
||||
)
|
||||
);
|
||||
}}
|
||||
size="s"
|
||||
>
|
||||
Serialize control group
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonGroup
|
||||
legend="Change the view mode"
|
||||
options={toggleViewButtons}
|
||||
idSelected={`viewModeToggle_${viewModeSelected}`}
|
||||
onChange={(_, value) => {
|
||||
dashboardApi?.setViewMode(value);
|
||||
}}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="m" />
|
||||
<ReactEmbeddableRenderer
|
||||
onApiAvailable={(api) => {
|
||||
dashboardApi?.setChild(api);
|
||||
setControlGroupApi(api as ControlGroupApi);
|
||||
}}
|
||||
hidePanelChrome={true}
|
||||
type={CONTROL_GROUP_TYPE}
|
||||
getParentApi={() => ({
|
||||
...dashboardApi,
|
||||
getSerializedStateForChild: () => ({
|
||||
rawState: {
|
||||
controlStyle: 'oneLine',
|
||||
chainingSystem: 'HIERARCHICAL',
|
||||
showApplySelections: false,
|
||||
panelsJSON:
|
||||
'{"a957862f-beae-4f0c-8a3a-a6ea4c235651":{"type":"searchControl","order":0,"grow":true,"width":"medium","explicitInput":{"id":"a957862f-beae-4f0c-8a3a-a6ea4c235651","fieldName":"message","title":"Message","grow":true,"width":"medium","searchString": "this","enhancements":{}}}}',
|
||||
ignoreParentSettingsJSON:
|
||||
'{"ignoreFilters":false,"ignoreQuery":false,"ignoreTimerange":false,"ignoreValidations":false}',
|
||||
} as object,
|
||||
references: [
|
||||
{
|
||||
name: 'controlGroup_a957862f-beae-4f0c-8a3a-a6ea4c235651:searchControlDataView',
|
||||
type: 'index-pattern',
|
||||
id: dataViews?.[0].id!,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})}
|
||||
key={`control_group`}
|
||||
/>
|
||||
<EuiSpacer size="l" />
|
||||
<div style={{ height: '400px' }}>
|
||||
<ReactEmbeddableRenderer
|
||||
type={'data_table'}
|
||||
getParentApi={() => ({
|
||||
...dashboardApi,
|
||||
getSerializedStateForChild: () => ({
|
||||
rawState: {
|
||||
timeRange: { from: 'now-60d/d', to: 'now+60d/d' },
|
||||
},
|
||||
references: [],
|
||||
}),
|
||||
})}
|
||||
hidePanelChrome={false}
|
||||
onApiAvailable={(api) => {
|
||||
dashboardApi?.setChild(api);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -6,46 +6,85 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { CONTROL_GROUP_TYPE } from '@kbn/controls-plugin/common';
|
||||
import { AppMountParameters, CoreSetup, CoreStart, Plugin } from '@kbn/core/public';
|
||||
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
|
||||
import type { DeveloperExamplesSetup } from '@kbn/developer-examples-plugin/public';
|
||||
import { EmbeddableSetup, PANEL_HOVER_TRIGGER } from '@kbn/embeddable-plugin/public';
|
||||
import type { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public';
|
||||
import img from './control_group_image.png';
|
||||
import { UiActionsStart } from '@kbn/ui-actions-plugin/public';
|
||||
import { PLUGIN_ID } from './constants';
|
||||
import img from './control_group_image.png';
|
||||
import { EditControlAction } from './react_controls/actions/edit_control_action';
|
||||
import { registerControlFactory } from './react_controls/control_factory_registry';
|
||||
import { SEARCH_CONTROL_TYPE } from './react_controls/data_controls/search_control/types';
|
||||
|
||||
interface SetupDeps {
|
||||
developerExamples: DeveloperExamplesSetup;
|
||||
embeddable: EmbeddableSetup;
|
||||
}
|
||||
|
||||
export interface ControlsExampleStartDeps {
|
||||
data: DataPublicPluginStart;
|
||||
navigation: NavigationPublicPluginStart;
|
||||
uiActions: UiActionsStart;
|
||||
}
|
||||
|
||||
export class ControlsExamplePlugin
|
||||
implements Plugin<void, void, SetupDeps, ControlsExampleStartDeps>
|
||||
{
|
||||
public setup(core: CoreSetup<ControlsExampleStartDeps>, { developerExamples }: SetupDeps) {
|
||||
public setup(
|
||||
core: CoreSetup<ControlsExampleStartDeps>,
|
||||
{ developerExamples, embeddable }: SetupDeps
|
||||
) {
|
||||
embeddable.registerReactEmbeddableFactory(CONTROL_GROUP_TYPE, async () => {
|
||||
const [{ getControlGroupEmbeddableFactory }, [coreStart, depsStart]] = await Promise.all([
|
||||
import('./react_controls/control_group/get_control_group_factory'),
|
||||
core.getStartServices(),
|
||||
]);
|
||||
return getControlGroupEmbeddableFactory({
|
||||
core: coreStart,
|
||||
dataViews: depsStart.data.dataViews,
|
||||
});
|
||||
});
|
||||
|
||||
registerControlFactory(SEARCH_CONTROL_TYPE, async () => {
|
||||
const [{ getSearchControlFactory: getSearchEmbeddableFactory }, [coreStart, depsStart]] =
|
||||
await Promise.all([
|
||||
import('./react_controls/data_controls/search_control/get_search_control_factory'),
|
||||
core.getStartServices(),
|
||||
]);
|
||||
|
||||
return getSearchEmbeddableFactory({
|
||||
core: coreStart,
|
||||
dataViewsService: depsStart.data.dataViews,
|
||||
});
|
||||
});
|
||||
|
||||
core.application.register({
|
||||
id: PLUGIN_ID,
|
||||
title: 'Controls examples',
|
||||
visibleIn: [],
|
||||
async mount(params: AppMountParameters) {
|
||||
const [, depsStart] = await core.getStartServices();
|
||||
const { renderApp } = await import('./app');
|
||||
return renderApp(depsStart, params);
|
||||
const [coreStart, depsStart] = await core.getStartServices();
|
||||
const { renderApp } = await import('./app/app');
|
||||
return renderApp(coreStart, depsStart, params);
|
||||
},
|
||||
});
|
||||
|
||||
developerExamples.register({
|
||||
appId: 'controlsExamples',
|
||||
title: 'Controls as a Building Block',
|
||||
description: `Showcases different ways to embed a control group into your app`,
|
||||
title: 'Controls',
|
||||
description: `Learn how to create new control types and use controls in your application`,
|
||||
image: img,
|
||||
});
|
||||
}
|
||||
|
||||
public start(core: CoreStart) {}
|
||||
public start(core: CoreStart, deps: ControlsExampleStartDeps) {
|
||||
const editControlAction = new EditControlAction();
|
||||
deps.uiActions.registerAction(editControlAction);
|
||||
deps.uiActions.attachAction(PANEL_HOVER_TRIGGER, editControlAction.id);
|
||||
}
|
||||
|
||||
public stop() {}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,89 @@
|
|||
/*
|
||||
* 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 from 'react';
|
||||
|
||||
import { EuiButtonIcon, EuiToolTip } from '@elastic/eui';
|
||||
import { CONTROL_GROUP_TYPE } from '@kbn/controls-plugin/common';
|
||||
import { ViewMode } from '@kbn/embeddable-plugin/public';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { apiIsPresentationContainer } from '@kbn/presentation-containers';
|
||||
import {
|
||||
apiCanAccessViewMode,
|
||||
apiHasParentApi,
|
||||
apiHasType,
|
||||
apiHasUniqueId,
|
||||
apiIsOfType,
|
||||
EmbeddableApiContext,
|
||||
getInheritedViewMode,
|
||||
hasEditCapabilities,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
|
||||
|
||||
import { DataControlApi } from '../data_controls/types';
|
||||
|
||||
const isApiCompatible = (api: unknown | null): api is DataControlApi =>
|
||||
Boolean(
|
||||
apiHasType(api) &&
|
||||
apiHasUniqueId(api) &&
|
||||
hasEditCapabilities(api) &&
|
||||
apiHasParentApi(api) &&
|
||||
apiCanAccessViewMode(api.parentApi) &&
|
||||
apiIsOfType(api.parentApi, CONTROL_GROUP_TYPE) &&
|
||||
apiIsPresentationContainer(api.parentApi)
|
||||
);
|
||||
|
||||
const ACTION_EDIT_CONTROL = 'editDataControl';
|
||||
|
||||
export class EditControlAction implements Action<EmbeddableApiContext> {
|
||||
public readonly type = ACTION_EDIT_CONTROL;
|
||||
public readonly id = ACTION_EDIT_CONTROL;
|
||||
public order = 2;
|
||||
|
||||
constructor() {}
|
||||
|
||||
public readonly MenuItem = ({ context }: { context: EmbeddableApiContext }) => {
|
||||
if (!isApiCompatible(context.embeddable)) throw new IncompatibleActionError();
|
||||
return (
|
||||
<EuiToolTip content={this.getDisplayName(context)}>
|
||||
<EuiButtonIcon
|
||||
data-test-subj={`control-action-${context.embeddable.uuid}-edit`}
|
||||
aria-label={this.getDisplayName(context)}
|
||||
iconType={this.getIconType(context)}
|
||||
onClick={() => this.execute(context)}
|
||||
color="text"
|
||||
/>
|
||||
</EuiToolTip>
|
||||
);
|
||||
};
|
||||
|
||||
public getDisplayName({ embeddable }: EmbeddableApiContext) {
|
||||
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
return i18n.translate('controls.controlGroup.floatingActions.editTitle', {
|
||||
defaultMessage: 'Edit',
|
||||
});
|
||||
}
|
||||
|
||||
public getIconType({ embeddable }: EmbeddableApiContext) {
|
||||
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
return 'pencil';
|
||||
}
|
||||
|
||||
public async isCompatible({ embeddable }: EmbeddableApiContext) {
|
||||
return (
|
||||
isApiCompatible(embeddable) &&
|
||||
getInheritedViewMode(embeddable.parentApi) === ViewMode.EDIT &&
|
||||
embeddable.isEditingEnabled()
|
||||
);
|
||||
}
|
||||
|
||||
public async execute({ embeddable }: EmbeddableApiContext) {
|
||||
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
await embeddable.onEdit();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* 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, { useState } from 'react';
|
||||
|
||||
import { EuiButtonEmpty, EuiPopover } from '@elastic/eui';
|
||||
import { FormattedMessage, I18nProvider } from '@kbn/i18n-react';
|
||||
import { Markdown } from '@kbn/shared-ux-markdown';
|
||||
|
||||
/** TODO: This file is duplicated from the controls plugin to avoid exporting it */
|
||||
|
||||
interface ControlErrorProps {
|
||||
error: Error | string;
|
||||
}
|
||||
|
||||
export const ControlError = ({ error }: ControlErrorProps) => {
|
||||
const [isPopoverOpen, setPopoverOpen] = useState(false);
|
||||
const errorMessage = error instanceof Error ? error.message : error;
|
||||
|
||||
const popoverButton = (
|
||||
<EuiButtonEmpty
|
||||
color="danger"
|
||||
iconSize="m"
|
||||
iconType="error"
|
||||
data-test-subj="control-frame-error"
|
||||
onClick={() => setPopoverOpen((open) => !open)}
|
||||
className={'errorEmbeddableCompact__button'}
|
||||
textProps={{ className: 'errorEmbeddableCompact__text' }}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="controls.frame.error.message"
|
||||
defaultMessage="An error occurred. View more"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
);
|
||||
|
||||
return (
|
||||
<I18nProvider>
|
||||
<EuiPopover
|
||||
button={popoverButton}
|
||||
isOpen={isPopoverOpen}
|
||||
className="errorEmbeddableCompact__popover"
|
||||
closePopover={() => setPopoverOpen(false)}
|
||||
>
|
||||
<Markdown data-test-subj="errorMessageMarkdown" readOnly>
|
||||
{errorMessage}
|
||||
</Markdown>
|
||||
</EuiPopover>
|
||||
</I18nProvider>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
import { ControlFactory, DefaultControlApi } from './types';
|
||||
|
||||
const registry: { [key: string]: ControlFactory<any, any> } = {};
|
||||
|
||||
export const registerControlFactory = async <
|
||||
State extends object = object,
|
||||
ApiType extends DefaultControlApi = DefaultControlApi
|
||||
>(
|
||||
type: string,
|
||||
getFactory: () => Promise<ControlFactory<State, ApiType>>
|
||||
) => {
|
||||
if (registry[type] !== undefined)
|
||||
throw new Error(
|
||||
i18n.translate('controlFactoryRegistry.factoryAlreadyExistsError', {
|
||||
defaultMessage: 'A control factory for type: {key} is already registered.',
|
||||
values: { key: type },
|
||||
})
|
||||
);
|
||||
registry[type] = (await getFactory()) as ControlFactory<any, any>;
|
||||
};
|
||||
|
||||
export const getControlFactory = <
|
||||
State extends object = object,
|
||||
ApiType extends DefaultControlApi = DefaultControlApi
|
||||
>(
|
||||
key: string
|
||||
): ControlFactory<State, ApiType> => {
|
||||
if (registry[key] === undefined)
|
||||
throw new Error(
|
||||
i18n.translate('controlFactoryRegistry.factoryNotFoundError', {
|
||||
defaultMessage: 'No control factory found for type: {key}',
|
||||
values: { key },
|
||||
})
|
||||
);
|
||||
return registry[key] as ControlFactory<State, ApiType>;
|
||||
};
|
||||
|
||||
export const getAllControlTypes = () => {
|
||||
return Object.keys(registry);
|
||||
};
|
|
@ -0,0 +1,245 @@
|
|||
/*
|
||||
* 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, useMemo } from 'react';
|
||||
|
||||
import {
|
||||
EuiButton,
|
||||
EuiButtonEmpty,
|
||||
EuiButtonGroup,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiFlyoutBody,
|
||||
EuiFlyoutFooter,
|
||||
EuiFlyoutHeader,
|
||||
EuiForm,
|
||||
EuiFormRow,
|
||||
EuiHorizontalRule,
|
||||
EuiIconTip,
|
||||
EuiSpacer,
|
||||
EuiSwitch,
|
||||
EuiTitle,
|
||||
} from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
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 { ControlGroupApi, ControlGroupEditorState } from './types';
|
||||
|
||||
interface EditControlGroupProps {
|
||||
onCancel: () => void;
|
||||
onSave: () => void;
|
||||
onDeleteAll: () => void;
|
||||
stateManager: ControlStateManager<ControlGroupEditorState>;
|
||||
api: ControlGroupApi; // controls must always have a parent API
|
||||
}
|
||||
|
||||
export const ControlGroupEditor = ({
|
||||
onCancel,
|
||||
onSave,
|
||||
onDeleteAll,
|
||||
stateManager,
|
||||
api,
|
||||
}: EditControlGroupProps) => {
|
||||
const [
|
||||
children,
|
||||
selectedLabelPosition,
|
||||
selectedChainingSystem,
|
||||
selectedShowApplySelections,
|
||||
selectedIgnoreParentSettings,
|
||||
] = useBatchedPublishingSubjects(
|
||||
api.children$,
|
||||
stateManager.labelPosition,
|
||||
stateManager.chainingSystem,
|
||||
stateManager.showApplySelections,
|
||||
stateManager.ignoreParentSettings
|
||||
);
|
||||
|
||||
const controlCount = useMemo(() => Object.keys(children).length, [children]);
|
||||
|
||||
const updateIgnoreSetting = useCallback(
|
||||
(newSettings: Partial<ParentIgnoreSettings>) => {
|
||||
stateManager.ignoreParentSettings.next({
|
||||
...(selectedIgnoreParentSettings ?? {}),
|
||||
...newSettings,
|
||||
});
|
||||
},
|
||||
[stateManager.ignoreParentSettings, selectedIgnoreParentSettings]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFlyoutHeader hasBorder>
|
||||
<EuiTitle size="m">
|
||||
<h2>{ControlGroupEditorStrings.management.getFlyoutTitle()}</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody data-test-subj="control-group-settings-flyout">
|
||||
<EuiForm component="form" fullWidth>
|
||||
<EuiFormRow
|
||||
label={ControlGroupEditorStrings.management.labelPosition.getLabelPositionTitle()}
|
||||
>
|
||||
<EuiButtonGroup
|
||||
color="primary"
|
||||
options={CONTROL_LAYOUT_OPTIONS}
|
||||
data-test-subj="control-group-layout-options"
|
||||
idSelected={selectedLabelPosition}
|
||||
legend={ControlGroupEditorStrings.management.labelPosition.getLabelPositionLegend()}
|
||||
onChange={(newPosition: string) => {
|
||||
stateManager.labelPosition.next(newPosition as ControlStyle);
|
||||
}}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
|
||||
<EuiFormRow
|
||||
label={ControlGroupEditorStrings.management.filteringSettings.getFilteringSettingsTitle()}
|
||||
>
|
||||
<div>
|
||||
<EuiSwitch
|
||||
compressed
|
||||
data-test-subj="control-group-filter-sync"
|
||||
label={ControlGroupEditorStrings.management.filteringSettings.getUseGlobalFiltersTitle()}
|
||||
onChange={(e) =>
|
||||
updateIgnoreSetting({
|
||||
ignoreFilters: !e.target.checked,
|
||||
ignoreQuery: !e.target.checked,
|
||||
})
|
||||
}
|
||||
checked={
|
||||
!Boolean(selectedIgnoreParentSettings?.ignoreFilters) ||
|
||||
!Boolean(selectedIgnoreParentSettings?.ignoreQuery)
|
||||
}
|
||||
/>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiSwitch
|
||||
compressed
|
||||
data-test-subj="control-group-query-sync-time-range"
|
||||
label={ControlGroupEditorStrings.management.filteringSettings.getUseGlobalTimeRangeTitle()}
|
||||
onChange={(e) => updateIgnoreSetting({ ignoreTimerange: !e.target.checked })}
|
||||
checked={!Boolean(selectedIgnoreParentSettings?.ignoreTimerange)}
|
||||
/>
|
||||
</div>
|
||||
</EuiFormRow>
|
||||
|
||||
<EuiFormRow
|
||||
label={ControlGroupEditorStrings.management.selectionSettings.getSelectionSettingsTitle()}
|
||||
>
|
||||
<div>
|
||||
<EuiSwitch
|
||||
compressed
|
||||
data-test-subj="control-group-validate-selections"
|
||||
label={
|
||||
<ControlSettingTooltipLabel
|
||||
label={ControlGroupEditorStrings.management.selectionSettings.validateSelections.getValidateSelectionsTitle()}
|
||||
tooltip={ControlGroupEditorStrings.management.selectionSettings.validateSelections.getValidateSelectionsTooltip()}
|
||||
/>
|
||||
}
|
||||
checked={!Boolean(selectedIgnoreParentSettings?.ignoreValidations)}
|
||||
onChange={(e) => updateIgnoreSetting({ ignoreValidations: !e.target.checked })}
|
||||
/>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiSwitch
|
||||
compressed
|
||||
data-test-subj="control-group-chaining"
|
||||
label={
|
||||
<ControlSettingTooltipLabel
|
||||
label={ControlGroupEditorStrings.management.selectionSettings.controlChaining.getHierarchyTitle()}
|
||||
tooltip={ControlGroupEditorStrings.management.selectionSettings.controlChaining.getHierarchyTooltip()}
|
||||
/>
|
||||
}
|
||||
checked={selectedChainingSystem === 'HIERARCHICAL'}
|
||||
onChange={(e) =>
|
||||
stateManager.chainingSystem.next(e.target.checked ? 'HIERARCHICAL' : 'NONE')
|
||||
}
|
||||
/>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiSwitch
|
||||
compressed
|
||||
data-test-subj="control-group-auto-apply-selections"
|
||||
label={
|
||||
<ControlSettingTooltipLabel
|
||||
label={ControlGroupEditorStrings.management.selectionSettings.showApplySelections.getShowApplySelectionsTitle()}
|
||||
tooltip={ControlGroupEditorStrings.management.selectionSettings.showApplySelections.getShowApplySelectionsTooltip()}
|
||||
/>
|
||||
}
|
||||
checked={!selectedShowApplySelections}
|
||||
onChange={(e) => stateManager.showApplySelections.next(!e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
</EuiFormRow>
|
||||
|
||||
{controlCount > 0 && (
|
||||
<>
|
||||
<EuiHorizontalRule margin="m" />
|
||||
<EuiFormRow>
|
||||
<EuiButtonEmpty
|
||||
onClick={onDeleteAll}
|
||||
data-test-subj="delete-all-controls-button"
|
||||
aria-label={'delete-all'}
|
||||
iconType="trash"
|
||||
color="danger"
|
||||
flush="left"
|
||||
size="s"
|
||||
>
|
||||
{ControlGroupEditorStrings.management.getDeleteAllButtonTitle()}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFormRow>
|
||||
</>
|
||||
)}
|
||||
</EuiForm>
|
||||
</EuiFlyoutBody>
|
||||
<EuiFlyoutFooter>
|
||||
<EuiFlexGroup responsive={false} justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
aria-label={`cancel-editing-group`}
|
||||
iconType="cross"
|
||||
onClick={() => {
|
||||
onCancel();
|
||||
}}
|
||||
>
|
||||
{ControlGroupEditorStrings.getCancelTitle()}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
aria-label={`save-group`}
|
||||
iconType="check"
|
||||
color="primary"
|
||||
data-test-subj="control-group-editor-save"
|
||||
onClick={() => {
|
||||
onSave();
|
||||
}}
|
||||
>
|
||||
{ControlGroupEditorStrings.getSaveChangesTitle()}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutFooter>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ControlSettingTooltipLabel = ({ label, tooltip }: { label: string; tooltip: string }) => (
|
||||
<EuiFlexGroup alignItems="center" gutterSize="xs" responsive={false}>
|
||||
<EuiFlexItem grow={false}>{label}</EuiFlexItem>
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
css={css`
|
||||
margin-top: 0px !important;
|
||||
`}
|
||||
>
|
||||
<EuiIconTip content={tooltip} position="right" />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
|
@ -0,0 +1,113 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
|
||||
export const ControlGroupEditorStrings = {
|
||||
getSaveChangesTitle: () =>
|
||||
i18n.translate('controls.controlGroup.manageControl.saveChangesTitle', {
|
||||
defaultMessage: 'Save and close',
|
||||
}),
|
||||
getCancelTitle: () =>
|
||||
i18n.translate('controls.controlGroup.manageControl.cancelTitle', {
|
||||
defaultMessage: 'Cancel',
|
||||
}),
|
||||
management: {
|
||||
getFlyoutTitle: () =>
|
||||
i18n.translate('controls.controlGroup.management.flyoutTitle', {
|
||||
defaultMessage: 'Control settings',
|
||||
}),
|
||||
getDeleteAllButtonTitle: () =>
|
||||
i18n.translate('controls.controlGroup.management.deleteAll', {
|
||||
defaultMessage: 'Delete all',
|
||||
}),
|
||||
labelPosition: {
|
||||
getLabelPositionTitle: () =>
|
||||
i18n.translate('controls.controlGroup.management.labelPosition.title', {
|
||||
defaultMessage: 'Label position',
|
||||
}),
|
||||
getLabelPositionLegend: () =>
|
||||
i18n.translate('controls.controlGroup.management.labelPosition.designSwitchLegend', {
|
||||
defaultMessage: 'Switch label position between inline and above',
|
||||
}),
|
||||
getInlineTitle: () =>
|
||||
i18n.translate('controls.controlGroup.management.labelPosition.inline', {
|
||||
defaultMessage: 'Inline',
|
||||
}),
|
||||
getAboveTitle: () =>
|
||||
i18n.translate('controls.controlGroup.management.labelPosition.above', {
|
||||
defaultMessage: 'Above',
|
||||
}),
|
||||
},
|
||||
selectionSettings: {
|
||||
getSelectionSettingsTitle: () =>
|
||||
i18n.translate('controls.controlGroup.management.selectionSettings', {
|
||||
defaultMessage: 'Selections',
|
||||
}),
|
||||
validateSelections: {
|
||||
getValidateSelectionsTitle: () =>
|
||||
i18n.translate('controls.controlGroup.management.validate.title', {
|
||||
defaultMessage: 'Validate user selections',
|
||||
}),
|
||||
getValidateSelectionsTooltip: () =>
|
||||
i18n.translate('controls.controlGroup.management.validate.tooltip', {
|
||||
defaultMessage: 'Highlight control selections that result in no data.',
|
||||
}),
|
||||
},
|
||||
controlChaining: {
|
||||
getHierarchyTitle: () =>
|
||||
i18n.translate('controls.controlGroup.management.hierarchy.title', {
|
||||
defaultMessage: 'Chain controls',
|
||||
}),
|
||||
getHierarchyTooltip: () =>
|
||||
i18n.translate('controls.controlGroup.management.hierarchy.tooltip', {
|
||||
defaultMessage:
|
||||
'Selections in one control narrow down available options in the next. Controls are chained from left to right.',
|
||||
}),
|
||||
},
|
||||
showApplySelections: {
|
||||
getShowApplySelectionsTitle: () =>
|
||||
i18n.translate('controls.controlGroup.management.showApplySelections.title', {
|
||||
defaultMessage: 'Apply selections automatically',
|
||||
}),
|
||||
getShowApplySelectionsTooltip: () =>
|
||||
i18n.translate('controls.controlGroup.management.showApplySelections.tooltip', {
|
||||
defaultMessage:
|
||||
'If disabled, control selections will only be applied after clicking apply.',
|
||||
}),
|
||||
},
|
||||
},
|
||||
filteringSettings: {
|
||||
getFilteringSettingsTitle: () =>
|
||||
i18n.translate('controls.controlGroup.management.filteringSettings', {
|
||||
defaultMessage: 'Filtering',
|
||||
}),
|
||||
getUseGlobalFiltersTitle: () =>
|
||||
i18n.translate('controls.controlGroup.management.filtering.useGlobalFilters', {
|
||||
defaultMessage: 'Apply global filters to controls',
|
||||
}),
|
||||
getUseGlobalTimeRangeTitle: () =>
|
||||
i18n.translate('controls.controlGroup.management.filtering.useGlobalTimeRange', {
|
||||
defaultMessage: 'Apply global time range to controls',
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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(),
|
||||
},
|
||||
];
|
|
@ -0,0 +1,241 @@
|
|||
/*
|
||||
* 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, { useEffect } from 'react';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
import {
|
||||
ControlGroupChainingSystem,
|
||||
ControlWidth,
|
||||
CONTROL_GROUP_TYPE,
|
||||
DEFAULT_CONTROL_GROW,
|
||||
DEFAULT_CONTROL_STYLE,
|
||||
DEFAULT_CONTROL_WIDTH,
|
||||
} from '@kbn/controls-plugin/common';
|
||||
import { ControlStyle, ParentIgnoreSettings } from '@kbn/controls-plugin/public';
|
||||
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,
|
||||
PublishesDataViews,
|
||||
PublishesFilters,
|
||||
PublishingSubject,
|
||||
useStateFromPublishingSubject,
|
||||
} from '@kbn/presentation-publishing';
|
||||
|
||||
import { EuiFlexGroup } from '@elastic/eui';
|
||||
import { ControlRenderer } from '../control_renderer';
|
||||
import { DefaultControlApi } from '../types';
|
||||
import { openEditControlGroupFlyout } from './open_edit_control_group_flyout';
|
||||
import { deserializeControlGroup, serializeControlGroup } from './serialization_utils';
|
||||
import {
|
||||
ControlGroupApi,
|
||||
ControlGroupRuntimeState,
|
||||
ControlGroupSerializedState,
|
||||
ControlGroupUnsavedChanges,
|
||||
} from './types';
|
||||
|
||||
export const getControlGroupEmbeddableFactory = (services: {
|
||||
core: CoreStart;
|
||||
dataViews: DataViewsPublicPluginStart;
|
||||
}) => {
|
||||
const controlGroupEmbeddableFactory: ReactEmbeddableFactory<
|
||||
ControlGroupSerializedState,
|
||||
ControlGroupApi,
|
||||
ControlGroupRuntimeState
|
||||
> = {
|
||||
type: CONTROL_GROUP_TYPE,
|
||||
deserializeState: (state) => deserializeControlGroup(state),
|
||||
buildEmbeddable: async (initialState, buildApi, uuid, parentApi, setApi) => {
|
||||
const {
|
||||
initialChildControlState: childControlState,
|
||||
defaultControlGrow,
|
||||
defaultControlWidth,
|
||||
labelPosition,
|
||||
chainingSystem,
|
||||
showApplySelections: initialShowApply,
|
||||
ignoreParentSettings: initialParentSettings,
|
||||
} = initialState;
|
||||
|
||||
const children$ = new BehaviorSubject<{ [key: string]: DefaultControlApi }>({});
|
||||
const filters$ = new BehaviorSubject<Filter[] | undefined>([]);
|
||||
const dataViews = new BehaviorSubject<DataView[] | undefined>(undefined);
|
||||
const chainingSystem$ = new BehaviorSubject<ControlGroupChainingSystem>(chainingSystem);
|
||||
const showApplySelections = new BehaviorSubject<boolean | undefined>(initialShowApply);
|
||||
const ignoreParentSettings = new BehaviorSubject<ParentIgnoreSettings | undefined>(
|
||||
initialParentSettings
|
||||
);
|
||||
const grow = new BehaviorSubject<boolean | undefined>(
|
||||
defaultControlGrow === undefined ? DEFAULT_CONTROL_GROW : defaultControlGrow
|
||||
);
|
||||
const width = new BehaviorSubject<ControlWidth | undefined>(
|
||||
defaultControlWidth ?? DEFAULT_CONTROL_WIDTH
|
||||
);
|
||||
const labelPosition$ = new BehaviorSubject<ControlStyle>( // TODO: Rename `ControlStyle`
|
||||
labelPosition ?? DEFAULT_CONTROL_STYLE // TODO: Rename `DEFAULT_CONTROL_STYLE`
|
||||
);
|
||||
|
||||
/** TODO: Handle loading; loading should be true if any child is loading */
|
||||
const dataLoading$ = new BehaviorSubject<boolean | undefined>(true);
|
||||
|
||||
/** TODO: Handle unsaved changes
|
||||
* - Each child has an unsaved changed behaviour subject it pushes to
|
||||
* - The control group listens to all of them (anyChildHasUnsavedChanges) and publishes its
|
||||
* own unsaved changes if either one of its children has unsaved changes **or** one of
|
||||
* the control group settings changed.
|
||||
* - Children should **not** publish unsaved changes based on their output filters or selections.
|
||||
* Instead, the control group will handle unsaved changes for filters.
|
||||
*/
|
||||
const unsavedChanges = new BehaviorSubject<Partial<ControlGroupUnsavedChanges> | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const controlOrder = new BehaviorSubject<Array<{ id: string; order: number; type: string }>>(
|
||||
Object.keys(childControlState)
|
||||
.map((key) => ({
|
||||
id: key,
|
||||
order: childControlState[key].order,
|
||||
type: childControlState[key].type,
|
||||
}))
|
||||
.sort((a, b) => (a.order > b.order ? 1 : -1))
|
||||
);
|
||||
const api = setApi({
|
||||
unsavedChanges,
|
||||
resetUnsavedChanges: () => {
|
||||
// TODO: Implement this
|
||||
},
|
||||
snapshotRuntimeState: () => {
|
||||
// TODO: Remove this if it ends up being unnecessary
|
||||
return {} as unknown as ControlGroupSerializedState;
|
||||
},
|
||||
dataLoading: dataLoading$,
|
||||
children$: children$ as PublishingSubject<{
|
||||
[key: string]: unknown;
|
||||
}>,
|
||||
onEdit: async () => {
|
||||
openEditControlGroupFlyout(
|
||||
api,
|
||||
{
|
||||
chainingSystem: chainingSystem$,
|
||||
labelPosition: labelPosition$,
|
||||
showApplySelections,
|
||||
ignoreParentSettings,
|
||||
},
|
||||
{ core: services.core }
|
||||
);
|
||||
},
|
||||
isEditingEnabled: () => true,
|
||||
getTypeDisplayName: () =>
|
||||
i18n.translate('controls.controlGroup.displayName', {
|
||||
defaultMessage: 'Controls',
|
||||
}),
|
||||
getSerializedStateForChild: (childId) => {
|
||||
return { rawState: childControlState[childId] };
|
||||
},
|
||||
serializeState: () => {
|
||||
return serializeControlGroup(
|
||||
children$.getValue(),
|
||||
controlOrder.getValue().map(({ id }) => id),
|
||||
{
|
||||
labelPosition: labelPosition$.getValue(),
|
||||
chainingSystem: chainingSystem$.getValue(),
|
||||
showApplySelections: showApplySelections.getValue(),
|
||||
ignoreParentSettings: ignoreParentSettings.getValue(),
|
||||
}
|
||||
);
|
||||
},
|
||||
getPanelCount: () => {
|
||||
return (Object.keys(children$.getValue()) ?? []).length;
|
||||
},
|
||||
addNewPanel: (panel) => {
|
||||
// TODO: Add a new child control
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
removePanel: (panelId) => {
|
||||
// TODO: Remove a child control
|
||||
},
|
||||
replacePanel: async (panelId, newPanel) => {
|
||||
// TODO: Replace a child control
|
||||
return Promise.resolve(panelId);
|
||||
},
|
||||
grow,
|
||||
width,
|
||||
filters$,
|
||||
dataViews,
|
||||
labelPosition: labelPosition$,
|
||||
});
|
||||
|
||||
/**
|
||||
* Subscribe to all children's output filters, combine them, and output them
|
||||
* TODO: If `showApplySelections` is true, 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 `showApplySelections` is true) 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));
|
||||
|
||||
/** Subscribe to all children's output data views, combine them, and output them */
|
||||
const childDataViewsSubscription = combineCompatibleChildrenApis<
|
||||
PublishesDataViews,
|
||||
DataView[]
|
||||
>(api, 'dataViews', apiPublishesDataViews, []).subscribe((newDataViews) =>
|
||||
dataViews.next(newDataViews)
|
||||
);
|
||||
|
||||
return {
|
||||
api,
|
||||
Component: (props, test) => {
|
||||
const controlsInOrder = useStateFromPublishingSubject(controlOrder);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
outputFiltersSubscription.unsubscribe();
|
||||
childDataViewsSubscription.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<EuiFlexGroup className={'controlGroup'} alignItems="center" gutterSize="s" wrap={true}>
|
||||
{controlsInOrder.map(({ id, type }) => (
|
||||
<ControlRenderer
|
||||
key={uuid}
|
||||
maybeId={id}
|
||||
type={type}
|
||||
getParentApi={() => api}
|
||||
onApiAvailable={(controlApi) => {
|
||||
children$.next({
|
||||
...children$.getValue(),
|
||||
[controlApi.uuid]: controlApi,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return controlGroupEmbeddableFactory;
|
||||
};
|
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
* 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 { OverlayRef } from '@kbn/core-mount-utils-browser';
|
||||
import { CoreStart } from '@kbn/core/public';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { tracksOverlays } from '@kbn/presentation-containers';
|
||||
import { apiHasParentApi } from '@kbn/presentation-publishing';
|
||||
import { toMountPoint } from '@kbn/react-kibana-mount';
|
||||
import React from 'react';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
import { ControlStateManager } from '../types';
|
||||
import { ControlGroupEditor } from './control_group_editor';
|
||||
import { ControlGroupApi, ControlGroupEditorState } from './types';
|
||||
|
||||
export const openEditControlGroupFlyout = (
|
||||
controlGroupApi: ControlGroupApi,
|
||||
stateManager: ControlStateManager<ControlGroupEditorState>,
|
||||
services: {
|
||||
core: CoreStart;
|
||||
}
|
||||
) => {
|
||||
/**
|
||||
* Duplicate all state into a new manager because we do not want to actually apply the changes
|
||||
* to the control group until the user hits save.
|
||||
*/
|
||||
const editorStateManager: ControlStateManager<ControlGroupEditorState> = Object.keys(
|
||||
stateManager
|
||||
).reduce((prev, key) => {
|
||||
return {
|
||||
...prev,
|
||||
[key as keyof ControlGroupEditorState]: new BehaviorSubject(
|
||||
stateManager[key as keyof ControlGroupEditorState].getValue()
|
||||
),
|
||||
};
|
||||
}, {} as ControlStateManager<ControlGroupEditorState>);
|
||||
|
||||
const closeOverlay = (overlayRef: OverlayRef) => {
|
||||
if (apiHasParentApi(controlGroupApi) && tracksOverlays(controlGroupApi.parentApi)) {
|
||||
controlGroupApi.parentApi.clearOverlays();
|
||||
}
|
||||
overlayRef.close();
|
||||
};
|
||||
|
||||
const onDeleteAll = (ref: OverlayRef) => {
|
||||
services.core.overlays
|
||||
.openConfirm(
|
||||
i18n.translate('controls.controlGroup.management.delete.sub', {
|
||||
defaultMessage: 'Controls are not recoverable once removed.',
|
||||
}),
|
||||
{
|
||||
confirmButtonText: i18n.translate('controls.controlGroup.management.delete.confirm', {
|
||||
defaultMessage: 'Delete',
|
||||
}),
|
||||
cancelButtonText: i18n.translate('controls.controlGroup.management.delete.cancel', {
|
||||
defaultMessage: 'Cancel',
|
||||
}),
|
||||
title: i18n.translate('controls.controlGroup.management.delete.deleteAllTitle', {
|
||||
defaultMessage: 'Delete all controls?',
|
||||
}),
|
||||
buttonColor: 'danger',
|
||||
}
|
||||
)
|
||||
.then((confirmed) => {
|
||||
if (confirmed)
|
||||
Object.keys(controlGroupApi.children$.getValue()).forEach((childId) => {
|
||||
controlGroupApi.removePanel(childId);
|
||||
});
|
||||
ref.close();
|
||||
});
|
||||
};
|
||||
|
||||
const overlay = services.core.overlays.openFlyout(
|
||||
toMountPoint(
|
||||
<ControlGroupEditor
|
||||
api={controlGroupApi}
|
||||
stateManager={editorStateManager}
|
||||
onSave={() => {
|
||||
Object.keys(stateManager).forEach((key) => {
|
||||
(
|
||||
stateManager[key as keyof ControlGroupEditorState] as BehaviorSubject<
|
||||
ControlGroupEditorState[keyof ControlGroupEditorState]
|
||||
>
|
||||
).next(editorStateManager[key as keyof ControlGroupEditorState].getValue());
|
||||
});
|
||||
closeOverlay(overlay);
|
||||
}}
|
||||
onDeleteAll={() => onDeleteAll(overlay)}
|
||||
onCancel={() => closeOverlay(overlay)}
|
||||
/>,
|
||||
{
|
||||
theme: services.core.theme,
|
||||
i18n: services.core.i18n,
|
||||
}
|
||||
),
|
||||
{
|
||||
'aria-label': i18n.translate('controls.controlGroup.manageControl', {
|
||||
defaultMessage: 'Edit control settings',
|
||||
}),
|
||||
outsideClickCloses: false,
|
||||
onClose: () => closeOverlay(overlay),
|
||||
}
|
||||
);
|
||||
|
||||
if (apiHasParentApi(controlGroupApi) && tracksOverlays(controlGroupApi.parentApi)) {
|
||||
controlGroupApi.parentApi.openOverlay(overlay);
|
||||
}
|
||||
};
|
|
@ -0,0 +1,99 @@
|
|||
/*
|
||||
* 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 { Reference } from '@kbn/content-management-utils';
|
||||
import { DEFAULT_CONTROL_GROW, DEFAULT_CONTROL_WIDTH } from '@kbn/controls-plugin/common';
|
||||
import { SerializedPanelState } from '@kbn/presentation-containers';
|
||||
import { omit } from 'lodash';
|
||||
import { DefaultControlApi, DefaultControlState } from '../types';
|
||||
import { ControlGroupRuntimeState, ControlGroupSerializedState } from './types';
|
||||
|
||||
export const deserializeControlGroup = (
|
||||
state: SerializedPanelState<ControlGroupSerializedState>
|
||||
): ControlGroupRuntimeState => {
|
||||
const panels = JSON.parse(state.rawState.panelsJSON);
|
||||
const ignoreParentSettings = JSON.parse(state.rawState.ignoreParentSettingsJSON);
|
||||
|
||||
/** Inject data view references into each individual control */
|
||||
const references = state.references ?? [];
|
||||
references.forEach((reference) => {
|
||||
const referenceName = reference.name;
|
||||
const panelId = referenceName.substring('controlGroup_'.length, referenceName.lastIndexOf(':'));
|
||||
if (panels[panelId]) {
|
||||
panels[panelId].dataViewId = reference.id;
|
||||
}
|
||||
});
|
||||
|
||||
/** Flatten the state of each panel by removing `explicitInput` */
|
||||
const flattenedPanels = Object.keys(panels).reduce((prev, panelId) => {
|
||||
const currentPanel = panels[panelId];
|
||||
const currentPanelExplicitInput = panels[panelId].explicitInput;
|
||||
return {
|
||||
...prev,
|
||||
[panelId]: { ...omit(currentPanel, 'explicitInput'), ...currentPanelExplicitInput },
|
||||
};
|
||||
}, {});
|
||||
|
||||
return {
|
||||
...omit(state.rawState, ['panelsJSON', 'ignoreParentSettingsJSON']),
|
||||
initialChildControlState: flattenedPanels,
|
||||
ignoreParentSettings,
|
||||
labelPosition: state.rawState.controlStyle, // Rename "controlStyle" to "labelPosition"
|
||||
defaultControlGrow: DEFAULT_CONTROL_GROW,
|
||||
defaultControlWidth: DEFAULT_CONTROL_WIDTH,
|
||||
};
|
||||
};
|
||||
|
||||
export const serializeControlGroup = (
|
||||
children: {
|
||||
[key: string]: DefaultControlApi;
|
||||
},
|
||||
idsInOrder: string[],
|
||||
state: Omit<
|
||||
ControlGroupRuntimeState,
|
||||
| 'anyChildHasUnsavedChanges'
|
||||
| 'defaultControlGrow'
|
||||
| 'defaultControlWidth'
|
||||
| 'initialChildControlState'
|
||||
>
|
||||
): SerializedPanelState<ControlGroupSerializedState> => {
|
||||
let references: Reference[] = [];
|
||||
|
||||
/** Re-add the `explicitInput` layer on serialize so control group saved object retains shape */
|
||||
const explicitInputPanels = Object.keys(children).reduce((prev, panelId) => {
|
||||
const child: DefaultControlApi = children[panelId];
|
||||
const type = child.type;
|
||||
const {
|
||||
rawState: { grow, width, ...rest },
|
||||
references: childReferences,
|
||||
} = (child.serializeState as () => SerializedPanelState<DefaultControlState>)();
|
||||
|
||||
if (childReferences && childReferences.length > 0) {
|
||||
references = [...references, ...childReferences];
|
||||
}
|
||||
|
||||
/**
|
||||
* Note: With legacy control embeddables, `grow` and `width` were duplicated under
|
||||
* explicit input - this is no longer the case.
|
||||
*/
|
||||
return {
|
||||
...prev,
|
||||
[panelId]: { grow, order: idsInOrder.indexOf(panelId), type, width, explicitInput: rest },
|
||||
};
|
||||
}, {});
|
||||
|
||||
return {
|
||||
rawState: {
|
||||
...omit(state, ['ignoreParentSettings', 'labelPosition']),
|
||||
controlStyle: state.labelPosition, // Rename "labelPosition" to "controlStyle"
|
||||
ignoreParentSettingsJSON: JSON.stringify(state.ignoreParentSettings),
|
||||
panelsJSON: JSON.stringify(explicitInputPanels),
|
||||
},
|
||||
references,
|
||||
};
|
||||
};
|
|
@ -0,0 +1,91 @@
|
|||
/*
|
||||
* 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 { ControlGroupChainingSystem } from '@kbn/controls-plugin/common/control_group/types';
|
||||
import { ParentIgnoreSettings } from '@kbn/controls-plugin/public';
|
||||
import { ControlStyle, ControlWidth } from '@kbn/controls-plugin/public/types';
|
||||
import { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public';
|
||||
import { Filter } from '@kbn/es-query';
|
||||
import { HasSerializedChildState, PresentationContainer } from '@kbn/presentation-containers';
|
||||
import {
|
||||
HasEditCapabilities,
|
||||
HasParentApi,
|
||||
PublishesDataLoading,
|
||||
PublishesFilters,
|
||||
PublishesUnifiedSearch,
|
||||
PublishesUnsavedChanges,
|
||||
PublishingSubject,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import { PublishesDataViews } from '@kbn/presentation-publishing/interfaces/publishes_data_views';
|
||||
import { DefaultControlState, PublishesControlDisplaySettings } from '../types';
|
||||
|
||||
/** The control display settings published by the control group are the "default" */
|
||||
type PublishesControlGroupDisplaySettings = PublishesControlDisplaySettings & {
|
||||
labelPosition: PublishingSubject<ControlStyle>;
|
||||
};
|
||||
export interface ControlPanelsState<ControlState extends ControlPanelState = ControlPanelState> {
|
||||
[panelId: string]: ControlState;
|
||||
}
|
||||
|
||||
export type ControlGroupUnsavedChanges = Omit<
|
||||
ControlGroupRuntimeState,
|
||||
'initialChildControlState' | 'defaultControlGrow' | 'defaultControlWidth'
|
||||
> & {
|
||||
filters: Filter[] | undefined;
|
||||
};
|
||||
|
||||
export type ControlPanelState = DefaultControlState & { type: string; order: number };
|
||||
|
||||
export type ControlGroupApi = PresentationContainer &
|
||||
DefaultEmbeddableApi<ControlGroupSerializedState> &
|
||||
PublishesFilters &
|
||||
PublishesDataViews &
|
||||
HasSerializedChildState<ControlPanelState> &
|
||||
HasEditCapabilities &
|
||||
PublishesDataLoading &
|
||||
PublishesUnsavedChanges &
|
||||
PublishesControlGroupDisplaySettings &
|
||||
Partial<HasParentApi<PublishesUnifiedSearch>>;
|
||||
|
||||
export interface ControlGroupRuntimeState {
|
||||
chainingSystem: ControlGroupChainingSystem;
|
||||
defaultControlGrow?: boolean;
|
||||
defaultControlWidth?: ControlWidth;
|
||||
labelPosition: ControlStyle; // TODO: Rename this type to ControlLabelPosition
|
||||
showApplySelections?: boolean;
|
||||
ignoreParentSettings?: ParentIgnoreSettings;
|
||||
|
||||
initialChildControlState: ControlPanelsState<ControlPanelState>;
|
||||
/** TODO: Handle the editor config, which is used with the control group renderer component */
|
||||
editorConfig?: {
|
||||
hideDataViewSelector?: boolean;
|
||||
hideWidthSettings?: boolean;
|
||||
hideAdditionalSettings?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export type ControlGroupEditorState = Pick<
|
||||
ControlGroupRuntimeState,
|
||||
'chainingSystem' | 'labelPosition' | 'showApplySelections' | 'ignoreParentSettings'
|
||||
>;
|
||||
|
||||
export type ControlGroupSerializedState = Omit<
|
||||
ControlGroupRuntimeState,
|
||||
| 'labelPosition'
|
||||
| 'ignoreParentSettings'
|
||||
| 'defaultControlGrow'
|
||||
| 'defaultControlWidth'
|
||||
| 'anyChildHasUnsavedChanges'
|
||||
| 'initialChildControlState'
|
||||
> & {
|
||||
panelsJSON: string;
|
||||
ignoreParentSettingsJSON: string;
|
||||
// In runtime state, we refer to this property as `labelPosition`; however, to avoid migrations, we will
|
||||
// continue to refer to this property as the legacy `controlStyle` in the serialized state
|
||||
controlStyle: ControlStyle;
|
||||
};
|
|
@ -0,0 +1,179 @@
|
|||
/*
|
||||
* 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 classNames from 'classnames';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { EuiFlexItem, EuiFormControlLayout, EuiFormLabel, EuiFormRow, EuiIcon } from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
import { ViewMode } from '@kbn/embeddable-plugin/public';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
apiHasParentApi,
|
||||
apiPublishesViewMode,
|
||||
useBatchedOptionalPublishingSubjects,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import { FloatingActions } from '@kbn/presentation-util-plugin/public';
|
||||
import { euiThemeVars } from '@kbn/ui-theme';
|
||||
|
||||
import { ControlError } from './control_error_component';
|
||||
import { ControlPanelProps, DefaultControlApi } from './types';
|
||||
|
||||
/**
|
||||
* TODO: Handle dragging
|
||||
*/
|
||||
const DragHandle = ({ isEditable, controlTitle }: { isEditable: boolean; controlTitle?: string }) =>
|
||||
isEditable ? (
|
||||
<button
|
||||
aria-label={i18n.translate('controls.controlGroup.ariaActions.moveControlButtonAction', {
|
||||
defaultMessage: 'Move control {controlTitle}',
|
||||
values: { controlTitle: controlTitle ?? '' },
|
||||
})}
|
||||
className="controlFrame__dragHandle"
|
||||
>
|
||||
<EuiIcon type="grabHorizontal" />
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
export const ControlPanel = <ApiType extends DefaultControlApi = DefaultControlApi>({
|
||||
Component,
|
||||
}: ControlPanelProps<ApiType>) => {
|
||||
const [api, setApi] = useState<ApiType | null>(null);
|
||||
|
||||
const viewModeSubject = (() => {
|
||||
if (
|
||||
apiHasParentApi(api) &&
|
||||
apiHasParentApi(api.parentApi) && // api.parentApi => controlGroupApi
|
||||
apiPublishesViewMode(api.parentApi.parentApi) // controlGroupApi.parentApi => dashboardApi
|
||||
)
|
||||
return api.parentApi.parentApi.viewMode; // get view mode from dashboard API
|
||||
})();
|
||||
|
||||
const [
|
||||
dataLoading,
|
||||
blockingError,
|
||||
panelTitle,
|
||||
defaultPanelTitle,
|
||||
grow,
|
||||
width,
|
||||
labelPosition,
|
||||
rawViewMode,
|
||||
] = useBatchedOptionalPublishingSubjects(
|
||||
api?.dataLoading,
|
||||
api?.blockingError,
|
||||
api?.panelTitle,
|
||||
api?.defaultPanelTitle,
|
||||
api?.grow,
|
||||
api?.width,
|
||||
api?.parentApi?.labelPosition,
|
||||
viewModeSubject
|
||||
);
|
||||
const usingTwoLineLayout = labelPosition === 'twoLine';
|
||||
|
||||
const [initialLoadComplete, setInitialLoadComplete] = useState(!dataLoading);
|
||||
if (!initialLoadComplete && (dataLoading === false || (api && !api.dataLoading))) {
|
||||
setInitialLoadComplete(true);
|
||||
}
|
||||
|
||||
const viewMode = (rawViewMode ?? ViewMode.VIEW) as ViewMode;
|
||||
const isEditable = viewMode === ViewMode.EDIT;
|
||||
|
||||
return (
|
||||
<EuiFlexItem
|
||||
grow={grow}
|
||||
data-control-id={api?.uuid}
|
||||
data-test-subj={`control-frame`}
|
||||
data-render-complete="true"
|
||||
className={classNames('controlFrameWrapper', {
|
||||
'controlFrameWrapper--grow': grow,
|
||||
'controlFrameWrapper--small': width === 'small',
|
||||
'controlFrameWrapper--medium': width === 'medium',
|
||||
'controlFrameWrapper--large': width === 'large',
|
||||
// TODO: Add the following classes back once drag and drop logic is added
|
||||
// 'controlFrameWrapper-isDragging': isDragging,
|
||||
// 'controlFrameWrapper--insertBefore': isOver && (index ?? -1) < (draggingIndex ?? -1),
|
||||
// 'controlFrameWrapper--insertAfter': isOver && (index ?? -1) > (draggingIndex ?? -1),
|
||||
})}
|
||||
>
|
||||
<FloatingActions
|
||||
api={api}
|
||||
className={classNames({
|
||||
'controlFrameFloatingActions--twoLine': usingTwoLineLayout,
|
||||
'controlFrameFloatingActions--oneLine': !usingTwoLineLayout,
|
||||
})}
|
||||
viewMode={viewMode}
|
||||
disabledActions={[]}
|
||||
isEnabled={true}
|
||||
>
|
||||
<EuiFormRow
|
||||
data-test-subj="control-frame-title"
|
||||
fullWidth
|
||||
label={usingTwoLineLayout ? panelTitle || defaultPanelTitle || '...' : undefined}
|
||||
>
|
||||
{blockingError ? (
|
||||
<EuiFormControlLayout>
|
||||
<ControlError
|
||||
error={
|
||||
blockingError ??
|
||||
i18n.translate('controls.blockingError', {
|
||||
defaultMessage: 'There was an error loading this control.',
|
||||
})
|
||||
}
|
||||
/>
|
||||
</EuiFormControlLayout>
|
||||
) : (
|
||||
<EuiFormControlLayout
|
||||
fullWidth
|
||||
isLoading={Boolean(dataLoading)}
|
||||
prepend={
|
||||
api?.getCustomPrepend ? (
|
||||
<>{api.getCustomPrepend()}</>
|
||||
) : usingTwoLineLayout ? (
|
||||
<DragHandle
|
||||
isEditable={isEditable}
|
||||
controlTitle={panelTitle || defaultPanelTitle}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<DragHandle
|
||||
isEditable={isEditable}
|
||||
controlTitle={panelTitle || defaultPanelTitle}
|
||||
/>{' '}
|
||||
<EuiFormLabel
|
||||
className="eui-textTruncate"
|
||||
// TODO: Convert this to a class when replacing the legacy control group
|
||||
css={css`
|
||||
background-color: transparent !important;
|
||||
`}
|
||||
>
|
||||
{panelTitle || defaultPanelTitle}
|
||||
</EuiFormLabel>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Component
|
||||
// TODO: Convert this to a class when replacing the legacy control group
|
||||
css={css`
|
||||
height: calc(${euiThemeVars.euiButtonHeight} - 2px);
|
||||
box-shadow: none !important;
|
||||
${!isEditable && usingTwoLineLayout
|
||||
? `border-radius: ${euiThemeVars.euiBorderRadius} !important`
|
||||
: ''};
|
||||
`}
|
||||
ref={(newApi) => {
|
||||
if (newApi && !api) setApi(newApi);
|
||||
}}
|
||||
/>
|
||||
</EuiFormControlLayout>
|
||||
)}
|
||||
</EuiFormRow>
|
||||
</FloatingActions>
|
||||
</EuiFlexItem>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
* 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, { useImperativeHandle, useMemo } from 'react';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { v4 as generateId } from 'uuid';
|
||||
|
||||
import { SerializedStyles } from '@emotion/react';
|
||||
import { StateComparators } from '@kbn/presentation-publishing';
|
||||
|
||||
import { getControlFactory } from './control_factory_registry';
|
||||
import { ControlGroupApi } from './control_group/types';
|
||||
import { ControlPanel } from './control_panel';
|
||||
import { ControlApiRegistration, DefaultControlApi, DefaultControlState } from './types';
|
||||
|
||||
/**
|
||||
* Renders a component from the control registry into a Control Panel
|
||||
*/
|
||||
export const ControlRenderer = <
|
||||
StateType extends DefaultControlState = DefaultControlState,
|
||||
ApiType extends DefaultControlApi = DefaultControlApi
|
||||
>({
|
||||
type,
|
||||
maybeId,
|
||||
getParentApi,
|
||||
onApiAvailable,
|
||||
}: {
|
||||
type: string;
|
||||
maybeId?: string;
|
||||
getParentApi: () => ControlGroupApi;
|
||||
onApiAvailable?: (api: ApiType) => void;
|
||||
}) => {
|
||||
const component = useMemo(
|
||||
() =>
|
||||
(() => {
|
||||
const parentApi = getParentApi();
|
||||
const uuid = maybeId ?? generateId();
|
||||
const factory = getControlFactory<StateType, ApiType>(type);
|
||||
|
||||
const buildApi = (
|
||||
apiRegistration: ControlApiRegistration<ApiType>,
|
||||
comparators: StateComparators<StateType> // TODO: Use these to calculate unsaved changes
|
||||
): ApiType => {
|
||||
const fullApi = {
|
||||
...apiRegistration,
|
||||
uuid,
|
||||
parentApi,
|
||||
unsavedChanges: new BehaviorSubject<Partial<StateType> | undefined>(undefined),
|
||||
resetUnsavedChanges: () => {},
|
||||
type: factory.type,
|
||||
} as unknown as ApiType;
|
||||
|
||||
onApiAvailable?.(fullApi);
|
||||
return fullApi;
|
||||
};
|
||||
|
||||
const { rawState: initialState } = parentApi.getSerializedStateForChild(uuid);
|
||||
|
||||
const { api, Component } = factory.buildControl(
|
||||
initialState as unknown as StateType,
|
||||
buildApi,
|
||||
uuid,
|
||||
parentApi
|
||||
);
|
||||
|
||||
return React.forwardRef<typeof api, { css: SerializedStyles }>((props, ref) => {
|
||||
// expose the api into the imperative handle
|
||||
useImperativeHandle(ref, () => api, []);
|
||||
return <Component {...props} />;
|
||||
});
|
||||
})(),
|
||||
/**
|
||||
* Disabling exhaustive deps because we do not want to re-fetch the component
|
||||
* from the embeddable registry unless the type changes.
|
||||
*/
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[type]
|
||||
);
|
||||
|
||||
return <ControlPanel<ApiType> Component={component} />;
|
||||
};
|
|
@ -0,0 +1,154 @@
|
|||
/*
|
||||
* 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 { RANGE_SLIDER_CONTROL } from '@kbn/controls-plugin/common';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const DataControlEditorStrings = {
|
||||
manageControl: {
|
||||
getFlyoutCreateTitle: () =>
|
||||
i18n.translate('controls.controlGroup.manageControl.createFlyoutTitle', {
|
||||
defaultMessage: 'Create control',
|
||||
}),
|
||||
getFlyoutEditTitle: () =>
|
||||
i18n.translate('controls.controlGroup.manageControl.editFlyoutTitle', {
|
||||
defaultMessage: 'Edit control',
|
||||
}),
|
||||
dataSource: {
|
||||
getFormGroupTitle: () =>
|
||||
i18n.translate('controls.controlGroup.manageControl.dataSource.formGroupTitle', {
|
||||
defaultMessage: 'Data source',
|
||||
}),
|
||||
getFormGroupDescription: () =>
|
||||
i18n.translate('controls.controlGroup.manageControl.dataSource.formGroupDescription', {
|
||||
defaultMessage: 'Select the data view and field that you want to create a control for.',
|
||||
}),
|
||||
getSelectDataViewMessage: () =>
|
||||
i18n.translate('controls.controlGroup.manageControl.dataSource.selectDataViewMessage', {
|
||||
defaultMessage: 'Please select a data view',
|
||||
}),
|
||||
getDataViewTitle: () =>
|
||||
i18n.translate('controls.controlGroup.manageControl.dataSource.dataViewTitle', {
|
||||
defaultMessage: 'Data view',
|
||||
}),
|
||||
getDataViewListErrorTitle: () =>
|
||||
i18n.translate('controls.controlGroup.manageControl.dataSource.dataViewListErrorTitle', {
|
||||
defaultMessage: 'Error loading data views',
|
||||
}),
|
||||
getFieldTitle: () =>
|
||||
i18n.translate('controls.controlGroup.manageControl.dataSource.fieldTitle', {
|
||||
defaultMessage: 'Field',
|
||||
}),
|
||||
getFieldListErrorTitle: () =>
|
||||
i18n.translate('controls.controlGroup.manageControl.dataSource.fieldListErrorTitle', {
|
||||
defaultMessage: 'Error loading the field list',
|
||||
}),
|
||||
getControlTypeTitle: () =>
|
||||
i18n.translate('controls.controlGroup.manageControl.dataSource.controlTypesTitle', {
|
||||
defaultMessage: 'Control type',
|
||||
}),
|
||||
getControlTypeErrorMessage: ({
|
||||
fieldSelected,
|
||||
controlType,
|
||||
}: {
|
||||
fieldSelected?: boolean;
|
||||
controlType?: string;
|
||||
}) => {
|
||||
if (!fieldSelected) {
|
||||
return i18n.translate(
|
||||
'controls.controlGroup.manageControl.dataSource.controlTypErrorMessage.noField',
|
||||
{
|
||||
defaultMessage: 'Select a field first.',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
switch (controlType) {
|
||||
/**
|
||||
* Note that options list controls are currently compatible with every field type; so, there is no
|
||||
* need to have a special error message for these.
|
||||
*/
|
||||
case RANGE_SLIDER_CONTROL: {
|
||||
return i18n.translate(
|
||||
'controls.controlGroup.manageControl.dataSource.controlTypeErrorMessage.rangeSlider',
|
||||
{
|
||||
defaultMessage: 'Range sliders are only compatible with number fields.',
|
||||
}
|
||||
);
|
||||
}
|
||||
default: {
|
||||
/** This shouldn't ever happen - but, adding just in case as a fallback. */
|
||||
return i18n.translate(
|
||||
'controls.controlGroup.manageControl.dataSource.controlTypeErrorMessage.default',
|
||||
{
|
||||
defaultMessage: 'Select a compatible control type.',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
displaySettings: {
|
||||
getFormGroupTitle: () =>
|
||||
i18n.translate('controls.controlGroup.manageControl.displaySettings.formGroupTitle', {
|
||||
defaultMessage: 'Display settings',
|
||||
}),
|
||||
getFormGroupDescription: () =>
|
||||
i18n.translate('controls.controlGroup.manageControl.displaySettings.formGroupDescription', {
|
||||
defaultMessage: 'Change how the control appears on your dashboard.',
|
||||
}),
|
||||
getTitleInputTitle: () =>
|
||||
i18n.translate('controls.controlGroup.manageControl.displaySettings.titleInputTitle', {
|
||||
defaultMessage: 'Label',
|
||||
}),
|
||||
getWidthInputTitle: () =>
|
||||
i18n.translate('controls.controlGroup.manageControl.displaySettings.widthInputTitle', {
|
||||
defaultMessage: 'Minimum width',
|
||||
}),
|
||||
getGrowSwitchTitle: () =>
|
||||
i18n.translate('controls.controlGroup.manageControl.displaySettings.growSwitchTitle', {
|
||||
defaultMessage: 'Expand width to fit available space',
|
||||
}),
|
||||
},
|
||||
controlTypeSettings: {
|
||||
getFormGroupTitle: (type: string) =>
|
||||
i18n.translate('controls.controlGroup.manageControl.controlTypeSettings.formGroupTitle', {
|
||||
defaultMessage: '{controlType} settings',
|
||||
values: { controlType: type },
|
||||
}),
|
||||
getFormGroupDescription: (type: string) =>
|
||||
i18n.translate(
|
||||
'controls.controlGroup.manageControl.controlTypeSettings.formGroupDescription',
|
||||
{
|
||||
defaultMessage: 'Custom settings for your {controlType} control.',
|
||||
values: { controlType: type.toLocaleLowerCase() },
|
||||
}
|
||||
),
|
||||
},
|
||||
getSaveChangesTitle: () =>
|
||||
i18n.translate('controls.controlGroup.manageControl.saveChangesTitle', {
|
||||
defaultMessage: 'Save and close',
|
||||
}),
|
||||
getCancelTitle: () =>
|
||||
i18n.translate('controls.controlGroup.manageControl.cancelTitle', {
|
||||
defaultMessage: 'Cancel',
|
||||
}),
|
||||
getDeleteButtonTitle: () =>
|
||||
i18n.translate('controls.controlGroup.management.delete', {
|
||||
defaultMessage: 'Delete control',
|
||||
}),
|
||||
},
|
||||
management: {
|
||||
controlWidth: {
|
||||
getWidthSwitchLegend: () =>
|
||||
i18n.translate('controls.controlGroup.management.layout.controlWidthLegend', {
|
||||
defaultMessage: 'Change control size',
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
|
@ -0,0 +1,403 @@
|
|||
/* * 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, { useEffect, useMemo, useState } from 'react';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
|
||||
import {
|
||||
EuiButton,
|
||||
EuiButtonEmpty,
|
||||
EuiButtonGroup,
|
||||
EuiCallOut,
|
||||
EuiDescribedFormGroup,
|
||||
EuiFieldText,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiFlyoutBody,
|
||||
EuiFlyoutFooter,
|
||||
EuiFlyoutHeader,
|
||||
EuiForm,
|
||||
EuiFormRow,
|
||||
EuiIcon,
|
||||
EuiKeyPadMenu,
|
||||
EuiKeyPadMenuItem,
|
||||
EuiSpacer,
|
||||
EuiSwitch,
|
||||
EuiTitle,
|
||||
EuiToolTip,
|
||||
} from '@elastic/eui';
|
||||
import { DataViewField } from '@kbn/data-views-plugin/common';
|
||||
import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
|
||||
import {
|
||||
LazyDataViewPicker,
|
||||
LazyFieldPicker,
|
||||
withSuspense,
|
||||
} from '@kbn/presentation-util-plugin/public';
|
||||
|
||||
import {
|
||||
ControlWidth,
|
||||
DEFAULT_CONTROL_GROW,
|
||||
DEFAULT_CONTROL_WIDTH,
|
||||
} from '@kbn/controls-plugin/common';
|
||||
import { CONTROL_WIDTH_OPTIONS } from '@kbn/controls-plugin/public';
|
||||
import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
|
||||
import { getAllControlTypes, getControlFactory } from '../control_factory_registry';
|
||||
import { ControlGroupApi } from '../control_group/types';
|
||||
import { ControlStateManager } from '../types';
|
||||
import { DataControlEditorStrings } from './data_control_constants';
|
||||
import { getDataControlFieldRegistry } from './data_control_editor_utils';
|
||||
import { DataControlFactory, DefaultDataControlState, isDataControlFactory } from './types';
|
||||
|
||||
export interface ControlEditorProps<
|
||||
State extends DefaultDataControlState = DefaultDataControlState
|
||||
> {
|
||||
controlId?: string; // if provided, then editing existing control; otherwise, creating a new control
|
||||
controlType?: string;
|
||||
onCancel: () => void;
|
||||
onSave: (type?: string) => void;
|
||||
stateManager: ControlStateManager<State>;
|
||||
parentApi: ControlGroupApi; // controls must always have a parent API
|
||||
services: {
|
||||
dataViews: DataViewsPublicPluginStart;
|
||||
};
|
||||
}
|
||||
|
||||
const FieldPicker = withSuspense(LazyFieldPicker, null);
|
||||
const DataViewPicker = withSuspense(LazyDataViewPicker, null);
|
||||
|
||||
export const DataControlEditor = ({
|
||||
controlId,
|
||||
controlType,
|
||||
onSave,
|
||||
onCancel,
|
||||
stateManager,
|
||||
parentApi: controlGroup,
|
||||
/** TODO: These should not be props */
|
||||
services: { dataViews: dataViewService },
|
||||
}: ControlEditorProps) => {
|
||||
const [
|
||||
selectedDataViewId,
|
||||
selectedFieldName,
|
||||
currentTitle,
|
||||
selectedGrow,
|
||||
selectedWidth,
|
||||
defaultGrow,
|
||||
defaultWidth,
|
||||
] = useBatchedPublishingSubjects(
|
||||
stateManager.dataViewId,
|
||||
stateManager.fieldName,
|
||||
stateManager.title,
|
||||
stateManager.grow,
|
||||
stateManager.width,
|
||||
controlGroup.grow,
|
||||
controlGroup.width
|
||||
// controlGroup.lastUsedDataViewId, // TODO: Implement last used data view id
|
||||
);
|
||||
|
||||
const [selectedFieldDisplayName, setSelectedFieldDisplayName] = useState(selectedFieldName);
|
||||
const [selectedControlType, setSelectedControlType] = useState<string | undefined>(controlType);
|
||||
const [controlEditorValid, setControlEditorValid] = useState(false);
|
||||
/** TODO: Make `editorConfig` work when refactoring the `ControlGroupRenderer` */
|
||||
// const editorConfig = controlGroup.getEditorConfig();
|
||||
|
||||
// TODO: Maybe remove `useAsync` - see https://github.com/elastic/kibana/pull/182842#discussion_r1624909709
|
||||
const {
|
||||
loading: dataViewListLoading,
|
||||
value: dataViewListItems = [],
|
||||
error: dataViewListError,
|
||||
} = useAsync(() => {
|
||||
return dataViewService.getIdsWithTitle();
|
||||
});
|
||||
|
||||
// TODO: Maybe remove `useAsync` - see https://github.com/elastic/kibana/pull/182842#discussion_r1624909709
|
||||
const {
|
||||
loading: dataViewLoading,
|
||||
value: { selectedDataView, fieldRegistry } = {
|
||||
selectedDataView: undefined,
|
||||
fieldRegistry: undefined,
|
||||
},
|
||||
error: fieldListError,
|
||||
} = useAsync(async () => {
|
||||
if (!selectedDataViewId) {
|
||||
return;
|
||||
}
|
||||
const dataView = await dataViewService.get(selectedDataViewId);
|
||||
const registry = await getDataControlFieldRegistry(dataView);
|
||||
return {
|
||||
selectedDataView: dataView,
|
||||
fieldRegistry: registry,
|
||||
};
|
||||
}, [selectedDataViewId]);
|
||||
|
||||
useEffect(() => {
|
||||
setControlEditorValid(
|
||||
Boolean(selectedFieldName) && Boolean(selectedDataView) && Boolean(selectedControlType)
|
||||
);
|
||||
}, [selectedFieldName, setControlEditorValid, selectedDataView, selectedControlType]);
|
||||
|
||||
const dataControlFactories = useMemo(() => {
|
||||
return getAllControlTypes()
|
||||
.map((type) => getControlFactory(type))
|
||||
.filter((factory) => {
|
||||
return isDataControlFactory(factory);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const CompatibleControlTypesComponent = useMemo(() => {
|
||||
return (
|
||||
<EuiKeyPadMenu data-test-subj={`controlTypeMenu`} aria-label={'type'}>
|
||||
{dataControlFactories.map((factory) => {
|
||||
const disabled =
|
||||
fieldRegistry && selectedFieldName
|
||||
? !fieldRegistry[selectedFieldName]?.compatibleControlTypes.includes(factory.type)
|
||||
: true;
|
||||
const keyPadMenuItem = (
|
||||
<EuiKeyPadMenuItem
|
||||
key={factory.type}
|
||||
id={`create__${factory.type}`}
|
||||
aria-label={factory.getDisplayName()}
|
||||
data-test-subj={`create__${factory.type}`}
|
||||
isSelected={factory.type === selectedControlType}
|
||||
disabled={disabled}
|
||||
onClick={() => setSelectedControlType(factory.type)}
|
||||
label={factory.getDisplayName()}
|
||||
>
|
||||
<EuiIcon type={factory.getIconType()} size="l" />
|
||||
</EuiKeyPadMenuItem>
|
||||
);
|
||||
|
||||
return disabled ? (
|
||||
<EuiToolTip
|
||||
key={`disabled__${controlType}`}
|
||||
content={DataControlEditorStrings.manageControl.dataSource.getControlTypeErrorMessage(
|
||||
{
|
||||
fieldSelected: Boolean(selectedFieldName),
|
||||
controlType,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{keyPadMenuItem}
|
||||
</EuiToolTip>
|
||||
) : (
|
||||
keyPadMenuItem
|
||||
);
|
||||
})}
|
||||
</EuiKeyPadMenu>
|
||||
);
|
||||
}, [selectedFieldName, fieldRegistry, selectedControlType, controlType, dataControlFactories]);
|
||||
|
||||
const CustomSettingsComponent = useMemo(() => {
|
||||
if (!selectedControlType || !selectedFieldName || !fieldRegistry) return;
|
||||
|
||||
const controlFactory = getControlFactory(selectedControlType) as DataControlFactory;
|
||||
const CustomSettings = controlFactory.CustomOptionsComponent;
|
||||
|
||||
if (!CustomSettings) return;
|
||||
|
||||
return (
|
||||
<EuiDescribedFormGroup
|
||||
ratio="third"
|
||||
title={
|
||||
<h2>
|
||||
{DataControlEditorStrings.manageControl.controlTypeSettings.getFormGroupTitle(
|
||||
controlFactory.getDisplayName()
|
||||
)}
|
||||
</h2>
|
||||
}
|
||||
description={DataControlEditorStrings.manageControl.controlTypeSettings.getFormGroupDescription(
|
||||
controlFactory.getDisplayName()
|
||||
)}
|
||||
data-test-subj="control-editor-custom-settings"
|
||||
>
|
||||
<CustomSettings stateManager={stateManager} setControlEditorValid={setControlEditorValid} />
|
||||
</EuiDescribedFormGroup>
|
||||
);
|
||||
}, [fieldRegistry, selectedControlType, selectedFieldName, stateManager]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFlyoutHeader hasBorder>
|
||||
<EuiTitle size="m">
|
||||
<h2>
|
||||
{!controlType
|
||||
? DataControlEditorStrings.manageControl.getFlyoutCreateTitle()
|
||||
: DataControlEditorStrings.manageControl.getFlyoutEditTitle()}
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody data-test-subj="control-editor-flyout">
|
||||
<EuiForm fullWidth>
|
||||
<EuiDescribedFormGroup
|
||||
ratio="third"
|
||||
title={<h2>{DataControlEditorStrings.manageControl.dataSource.getFormGroupTitle()}</h2>}
|
||||
description={DataControlEditorStrings.manageControl.dataSource.getFormGroupDescription()}
|
||||
>
|
||||
{/* {!editorConfig?.hideDataViewSelector && ( */}
|
||||
<EuiFormRow
|
||||
label={DataControlEditorStrings.manageControl.dataSource.getDataViewTitle()}
|
||||
>
|
||||
{dataViewListError ? (
|
||||
<EuiCallOut
|
||||
color="danger"
|
||||
iconType="error"
|
||||
title={DataControlEditorStrings.manageControl.dataSource.getDataViewListErrorTitle()}
|
||||
>
|
||||
<p>{dataViewListError.message}</p>
|
||||
</EuiCallOut>
|
||||
) : (
|
||||
<DataViewPicker
|
||||
dataViews={dataViewListItems}
|
||||
selectedDataViewId={selectedDataViewId}
|
||||
onChangeDataViewId={(newDataViewId) => {
|
||||
stateManager.dataViewId.next(newDataViewId);
|
||||
}}
|
||||
trigger={{
|
||||
label:
|
||||
selectedDataView?.getName() ??
|
||||
DataControlEditorStrings.manageControl.dataSource.getSelectDataViewMessage(),
|
||||
}}
|
||||
selectableProps={{ isLoading: dataViewListLoading }}
|
||||
/>
|
||||
)}
|
||||
</EuiFormRow>
|
||||
{/* )} */}
|
||||
|
||||
<EuiFormRow label={DataControlEditorStrings.manageControl.dataSource.getFieldTitle()}>
|
||||
{fieldListError ? (
|
||||
<EuiCallOut
|
||||
color="danger"
|
||||
iconType="error"
|
||||
title={DataControlEditorStrings.manageControl.dataSource.getFieldListErrorTitle()}
|
||||
>
|
||||
<p>{fieldListError.message}</p>
|
||||
</EuiCallOut>
|
||||
) : (
|
||||
<FieldPicker
|
||||
filterPredicate={(field: DataViewField) => {
|
||||
/** TODO: Make `fieldFilterPredicate` work when refactoring the `ControlGroupRenderer` */
|
||||
// const customPredicate = controlGroup.fieldFilterPredicate?.(field) ?? true;
|
||||
return Boolean(fieldRegistry?.[field.name]);
|
||||
}}
|
||||
selectedFieldName={selectedFieldName}
|
||||
dataView={selectedDataView}
|
||||
onSelectField={(field) => {
|
||||
setSelectedControlType(fieldRegistry?.[field.name]?.compatibleControlTypes[0]);
|
||||
const newDefaultTitle = field.displayName ?? field.name;
|
||||
stateManager.fieldName.next(field.name);
|
||||
setSelectedFieldDisplayName(newDefaultTitle);
|
||||
if (!currentTitle || currentTitle === selectedFieldDisplayName) {
|
||||
stateManager.title.next(newDefaultTitle);
|
||||
}
|
||||
}}
|
||||
selectableProps={{ isLoading: dataViewListLoading || dataViewLoading }}
|
||||
/>
|
||||
)}
|
||||
</EuiFormRow>
|
||||
<EuiFormRow
|
||||
label={DataControlEditorStrings.manageControl.dataSource.getControlTypeTitle()}
|
||||
>
|
||||
{CompatibleControlTypesComponent}
|
||||
</EuiFormRow>
|
||||
</EuiDescribedFormGroup>
|
||||
<EuiDescribedFormGroup
|
||||
ratio="third"
|
||||
title={
|
||||
<h2>{DataControlEditorStrings.manageControl.displaySettings.getFormGroupTitle()}</h2>
|
||||
}
|
||||
description={DataControlEditorStrings.manageControl.displaySettings.getFormGroupDescription()}
|
||||
>
|
||||
<EuiFormRow
|
||||
label={DataControlEditorStrings.manageControl.displaySettings.getTitleInputTitle()}
|
||||
>
|
||||
<EuiFieldText
|
||||
data-test-subj="control-editor-title-input"
|
||||
placeholder={selectedFieldDisplayName ?? selectedFieldName}
|
||||
value={currentTitle}
|
||||
onChange={(e) => stateManager.title.next(e.target.value)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
{/* {!editorConfig?.hideWidthSettings && ( */}
|
||||
<EuiFormRow
|
||||
label={DataControlEditorStrings.manageControl.displaySettings.getWidthInputTitle()}
|
||||
>
|
||||
<div>
|
||||
<EuiButtonGroup
|
||||
color="primary"
|
||||
legend={DataControlEditorStrings.management.controlWidth.getWidthSwitchLegend()}
|
||||
options={CONTROL_WIDTH_OPTIONS}
|
||||
idSelected={selectedWidth ?? defaultWidth ?? DEFAULT_CONTROL_WIDTH}
|
||||
onChange={(newWidth: string) => stateManager.width.next(newWidth as ControlWidth)}
|
||||
/>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiSwitch
|
||||
label={DataControlEditorStrings.manageControl.displaySettings.getGrowSwitchTitle()}
|
||||
color="primary"
|
||||
checked={
|
||||
(selectedGrow === undefined ? defaultGrow : selectedGrow) ??
|
||||
DEFAULT_CONTROL_GROW
|
||||
}
|
||||
onChange={() => stateManager.grow.next(!selectedGrow)}
|
||||
data-test-subj="control-editor-grow-switch"
|
||||
/>
|
||||
</div>
|
||||
</EuiFormRow>
|
||||
{/* )} */}
|
||||
</EuiDescribedFormGroup>
|
||||
{CustomSettingsComponent}
|
||||
{/* {!editorConfig?.hideAdditionalSettings ? CustomSettingsComponent : null} */}
|
||||
{controlId && (
|
||||
<>
|
||||
<EuiSpacer size="l" />
|
||||
<EuiButtonEmpty
|
||||
aria-label={`delete-${currentTitle ?? selectedFieldName}`}
|
||||
iconType="trash"
|
||||
flush="left"
|
||||
color="danger"
|
||||
onClick={() => {
|
||||
onCancel();
|
||||
controlGroup.removePanel(controlId);
|
||||
}}
|
||||
>
|
||||
{DataControlEditorStrings.manageControl.getDeleteButtonTitle()}
|
||||
</EuiButtonEmpty>
|
||||
</>
|
||||
)}
|
||||
</EuiForm>
|
||||
</EuiFlyoutBody>
|
||||
<EuiFlyoutFooter>
|
||||
<EuiFlexGroup responsive={false} justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
aria-label={`cancel-${currentTitle ?? selectedFieldName}`}
|
||||
data-test-subj="control-editor-cancel"
|
||||
iconType="cross"
|
||||
onClick={() => {
|
||||
onCancel();
|
||||
}}
|
||||
>
|
||||
{DataControlEditorStrings.manageControl.getCancelTitle()}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
aria-label={`save-${currentTitle ?? selectedFieldName}`}
|
||||
data-test-subj="control-editor-save"
|
||||
iconType="check"
|
||||
color="primary"
|
||||
disabled={!controlEditorValid}
|
||||
onClick={() => {
|
||||
onSave();
|
||||
}}
|
||||
>
|
||||
{DataControlEditorStrings.manageControl.getSaveChangesTitle()}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutFooter>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* 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 { memoize } from 'lodash';
|
||||
|
||||
import { DataControlFieldRegistry } from '@kbn/controls-plugin/public/types';
|
||||
import { DataView } from '@kbn/data-views-plugin/common';
|
||||
import { getAllControlTypes, getControlFactory } from '../control_factory_registry';
|
||||
import { isDataControlFactory } from './types';
|
||||
|
||||
/** TODO: This funciton is duplicated from the controls plugin to avoid exporting it */
|
||||
export const getDataControlFieldRegistry = memoize(
|
||||
async (dataView: DataView) => {
|
||||
return await loadFieldRegistryFromDataView(dataView);
|
||||
},
|
||||
(dataView: DataView) => [dataView.id, JSON.stringify(dataView.fields.getAll())].join('|')
|
||||
);
|
||||
|
||||
/** TODO: This function is duplicated from the controls plugin to avoid exporting it */
|
||||
const loadFieldRegistryFromDataView = async (
|
||||
dataView: DataView
|
||||
): Promise<DataControlFieldRegistry> => {
|
||||
const controlFactories = getAllControlTypes().map((controlType) =>
|
||||
getControlFactory(controlType)
|
||||
);
|
||||
const fieldRegistry: DataControlFieldRegistry = {};
|
||||
return new Promise<DataControlFieldRegistry>((resolve) => {
|
||||
for (const field of dataView.fields.getAll()) {
|
||||
const compatibleControlTypes = [];
|
||||
for (const factory of controlFactories) {
|
||||
if (isDataControlFactory(factory) && factory.isFieldCompatible(field)) {
|
||||
compatibleControlTypes.push(factory.type);
|
||||
}
|
||||
}
|
||||
if (compatibleControlTypes.length > 0) {
|
||||
fieldRegistry[field.name] = { field, compatibleControlTypes };
|
||||
}
|
||||
}
|
||||
resolve(fieldRegistry);
|
||||
});
|
||||
};
|
|
@ -0,0 +1,137 @@
|
|||
/*
|
||||
* 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, combineLatestWith, switchMap } from 'rxjs';
|
||||
|
||||
import { CoreStart } from '@kbn/core-lifecycle-browser';
|
||||
import { DataView, DATA_VIEW_SAVED_OBJECT_TYPE } from '@kbn/data-views-plugin/common';
|
||||
import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
|
||||
import { Filter } from '@kbn/es-query';
|
||||
import { SerializedPanelState } from '@kbn/presentation-containers';
|
||||
import { StateComparators } from '@kbn/presentation-publishing';
|
||||
|
||||
import { ControlGroupApi } from '../control_group/types';
|
||||
import { initializeDefaultControlApi } from '../initialize_default_control_api';
|
||||
import { ControlApiInitialization, ControlStateManager, DefaultControlState } from '../types';
|
||||
import { openDataControlEditor } from './open_data_control_editor';
|
||||
import { DataControlApi, DefaultDataControlState } from './types';
|
||||
|
||||
export const initializeDataControl = <EditorState extends object = {}>(
|
||||
controlId: string,
|
||||
controlType: string,
|
||||
state: DefaultDataControlState,
|
||||
editorStateManager: ControlStateManager<EditorState>,
|
||||
controlGroup: ControlGroupApi,
|
||||
services: {
|
||||
core: CoreStart;
|
||||
dataViews: DataViewsPublicPluginStart;
|
||||
}
|
||||
): {
|
||||
dataControlApi: ControlApiInitialization<DataControlApi>;
|
||||
dataControlComparators: StateComparators<DefaultDataControlState>;
|
||||
dataControlStateManager: ControlStateManager<DefaultDataControlState>;
|
||||
serializeDataControl: () => SerializedPanelState<DefaultControlState>;
|
||||
} => {
|
||||
const {
|
||||
defaultControlApi,
|
||||
defaultControlComparators,
|
||||
defaultControlStateManager,
|
||||
serializeDefaultControl,
|
||||
} = initializeDefaultControlApi(state);
|
||||
|
||||
const panelTitle = new BehaviorSubject<string | undefined>(state.title);
|
||||
const defaultPanelTitle = new BehaviorSubject<string | undefined>(undefined);
|
||||
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 dataControlComparators: StateComparators<DefaultDataControlState> = {
|
||||
...defaultControlComparators,
|
||||
title: [panelTitle, (value: string | undefined) => panelTitle.next(value)],
|
||||
dataViewId: [dataViewId, (value: string) => dataViewId.next(value)],
|
||||
fieldName: [fieldName, (value: string) => fieldName.next(value)],
|
||||
};
|
||||
|
||||
const stateManager: ControlStateManager<DefaultDataControlState> = {
|
||||
...defaultControlStateManager,
|
||||
dataViewId,
|
||||
fieldName,
|
||||
title: panelTitle,
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch the data view + field whenever the selected data view ID or field name changes; use the
|
||||
* fetched field spec to set the default panel title, which is always equal to either the field
|
||||
* name or the field's display name.
|
||||
*/
|
||||
dataViewId
|
||||
.pipe(
|
||||
combineLatestWith(fieldName),
|
||||
switchMap(async ([currentDataViewId, currentFieldName]) => {
|
||||
defaultControlApi.setDataLoading(true);
|
||||
const dataView = await services.dataViews.get(currentDataViewId);
|
||||
const field = dataView.getFieldByName(currentFieldName);
|
||||
defaultControlApi.setDataLoading(false);
|
||||
return { dataView, field };
|
||||
})
|
||||
)
|
||||
.subscribe(async ({ dataView, field }) => {
|
||||
if (!dataView || !field) return;
|
||||
dataViews.next([dataView]);
|
||||
defaultPanelTitle.next(field.displayName || field.name);
|
||||
});
|
||||
|
||||
const onEdit = async () => {
|
||||
openDataControlEditor<DefaultDataControlState & EditorState>(
|
||||
{ ...stateManager, ...editorStateManager } as ControlStateManager<
|
||||
DefaultDataControlState & EditorState
|
||||
>,
|
||||
controlGroup,
|
||||
services,
|
||||
controlType,
|
||||
controlId
|
||||
);
|
||||
};
|
||||
|
||||
const dataControlApi: ControlApiInitialization<DataControlApi> = {
|
||||
...defaultControlApi,
|
||||
panelTitle,
|
||||
defaultPanelTitle,
|
||||
dataViews,
|
||||
onEdit,
|
||||
filters$: filters,
|
||||
setOutputFilter: (newFilter: Filter | undefined) => {
|
||||
filters.next(newFilter ? [newFilter] : undefined);
|
||||
},
|
||||
isEditingEnabled: () => true,
|
||||
};
|
||||
|
||||
return {
|
||||
dataControlApi,
|
||||
dataControlComparators,
|
||||
dataControlStateManager: stateManager,
|
||||
serializeDataControl: () => {
|
||||
return {
|
||||
rawState: {
|
||||
...serializeDefaultControl().rawState,
|
||||
dataViewId: dataViewId.getValue(),
|
||||
fieldName: fieldName.getValue(),
|
||||
title: panelTitle.getValue(),
|
||||
},
|
||||
references: [
|
||||
{
|
||||
name: `controlGroup_${controlId}:${controlType}DataView`,
|
||||
type: DATA_VIEW_SAVED_OBJECT_TYPE,
|
||||
id: dataViewId.getValue(),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
|
@ -0,0 +1,131 @@
|
|||
/*
|
||||
* 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 from 'react';
|
||||
import deepEqual from 'react-fast-compare';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
import { CoreStart, OverlayRef } from '@kbn/core/public';
|
||||
import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { tracksOverlays } from '@kbn/presentation-containers';
|
||||
import { apiHasParentApi } from '@kbn/presentation-publishing';
|
||||
import { toMountPoint } from '@kbn/react-kibana-mount';
|
||||
|
||||
import { ControlGroupApi } from '../control_group/types';
|
||||
import { DataControlEditor } from './data_control_editor';
|
||||
import { DefaultDataControlState } from './types';
|
||||
import { ControlStateManager } from '../types';
|
||||
|
||||
export const openDataControlEditor = async <
|
||||
State extends DefaultDataControlState = DefaultDataControlState
|
||||
>(
|
||||
stateManager: ControlStateManager<State>,
|
||||
controlGroupApi: ControlGroupApi,
|
||||
services: {
|
||||
core: CoreStart;
|
||||
dataViews: DataViewsPublicPluginStart;
|
||||
},
|
||||
controlType?: string,
|
||||
controlId?: string
|
||||
): Promise<undefined> => {
|
||||
return new Promise((resolve) => {
|
||||
/**
|
||||
* Duplicate all state into a new manager because we do not want to actually apply the changes
|
||||
* to the control until the user hits save.
|
||||
*/
|
||||
const editorStateManager: ControlStateManager<State> = Object.keys(stateManager).reduce(
|
||||
(prev, key) => {
|
||||
return {
|
||||
...prev,
|
||||
[key as keyof State]: new BehaviorSubject(stateManager[key as keyof State].getValue()),
|
||||
};
|
||||
},
|
||||
{} as ControlStateManager<State>
|
||||
);
|
||||
|
||||
const closeOverlay = (overlayRef: OverlayRef) => {
|
||||
if (apiHasParentApi(controlGroupApi) && tracksOverlays(controlGroupApi.parentApi)) {
|
||||
controlGroupApi.parentApi.clearOverlays();
|
||||
}
|
||||
overlayRef.close();
|
||||
};
|
||||
|
||||
const onCancel = (overlay: OverlayRef) => {
|
||||
const initialState = Object.keys(stateManager).map((key) => {
|
||||
return stateManager[key as keyof State].getValue();
|
||||
});
|
||||
const newState = Object.keys(editorStateManager).map((key) => {
|
||||
return editorStateManager[key as keyof State].getValue();
|
||||
});
|
||||
|
||||
if (deepEqual(initialState, newState)) {
|
||||
closeOverlay(overlay);
|
||||
return;
|
||||
}
|
||||
services.core.overlays
|
||||
.openConfirm(
|
||||
i18n.translate('controls.controlGroup.management.discard.sub', {
|
||||
defaultMessage: `Changes that you've made to this control will be discarded, are you sure you want to continue?`,
|
||||
}),
|
||||
{
|
||||
confirmButtonText: i18n.translate('controls.controlGroup.management.discard.confirm', {
|
||||
defaultMessage: 'Discard changes',
|
||||
}),
|
||||
cancelButtonText: i18n.translate('controls.controlGroup.management.discard.cancel', {
|
||||
defaultMessage: 'Cancel',
|
||||
}),
|
||||
title: i18n.translate('controls.controlGroup.management.discard.title', {
|
||||
defaultMessage: 'Discard changes?',
|
||||
}),
|
||||
buttonColor: 'danger',
|
||||
}
|
||||
)
|
||||
.then((confirmed) => {
|
||||
if (confirmed) {
|
||||
closeOverlay(overlay);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const overlay = services.core.overlays.openFlyout(
|
||||
toMountPoint(
|
||||
<DataControlEditor
|
||||
controlId={controlId}
|
||||
controlType={controlType}
|
||||
parentApi={controlGroupApi}
|
||||
onCancel={() => {
|
||||
onCancel(overlay);
|
||||
}}
|
||||
onSave={() => {
|
||||
Object.keys(stateManager).forEach((key) => {
|
||||
stateManager[key as keyof State].next(
|
||||
editorStateManager[key as keyof State].getValue()
|
||||
);
|
||||
});
|
||||
closeOverlay(overlay);
|
||||
resolve(undefined);
|
||||
}}
|
||||
stateManager={editorStateManager}
|
||||
services={{ dataViews: services.dataViews }}
|
||||
/>,
|
||||
{
|
||||
theme: services.core.theme,
|
||||
i18n: services.core.i18n,
|
||||
}
|
||||
),
|
||||
{
|
||||
onClose: () => closeOverlay(overlay),
|
||||
}
|
||||
);
|
||||
|
||||
if (apiHasParentApi(controlGroupApi) && tracksOverlays(controlGroupApi.parentApi)) {
|
||||
controlGroupApi.parentApi.openOverlay(overlay);
|
||||
}
|
||||
});
|
||||
};
|
|
@ -0,0 +1,227 @@
|
|||
/*
|
||||
* 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, { useEffect } from 'react';
|
||||
import deepEqual from 'react-fast-compare';
|
||||
import { BehaviorSubject, combineLatest, debounceTime, distinctUntilChanged } from 'rxjs';
|
||||
|
||||
import { EuiFieldSearch, EuiFormRow, EuiRadioGroup } from '@elastic/eui';
|
||||
import { CoreStart } from '@kbn/core/public';
|
||||
import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { useStateFromPublishingSubject } from '@kbn/presentation-publishing';
|
||||
|
||||
import { initializeDataControl } from '../initialize_data_control';
|
||||
import { DataControlFactory } from '../types';
|
||||
import {
|
||||
SearchControlApi,
|
||||
SearchControlState,
|
||||
SearchControlTechniques,
|
||||
SEARCH_CONTROL_TYPE,
|
||||
} from './types';
|
||||
|
||||
const allSearchOptions = [
|
||||
{
|
||||
id: 'match',
|
||||
label: i18n.translate('controlsExamples.searchControl.searchTechnique.match', {
|
||||
defaultMessage: 'Fuzzy match',
|
||||
}),
|
||||
'data-test-subj': 'searchControl__matchSearchOptionAdditionalSetting',
|
||||
},
|
||||
{
|
||||
id: 'simple_query_string',
|
||||
label: i18n.translate('controlsExamples.searchControl.searchTechnique.simpleQueryString', {
|
||||
defaultMessage: 'Query string',
|
||||
}),
|
||||
'data-test-subj': 'optionsListControl__queryStringSearchOptionAdditionalSetting',
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_SEARCH_TECHNIQUE = 'match';
|
||||
|
||||
export const getSearchControlFactory = ({
|
||||
core,
|
||||
dataViewsService,
|
||||
}: {
|
||||
core: CoreStart;
|
||||
dataViewsService: DataViewsPublicPluginStart;
|
||||
}): DataControlFactory<SearchControlState, SearchControlApi> => {
|
||||
return {
|
||||
type: SEARCH_CONTROL_TYPE,
|
||||
getIconType: () => 'search',
|
||||
getDisplayName: () =>
|
||||
i18n.translate('controlsExamples.searchControl.displayName', { defaultMessage: 'Search' }),
|
||||
isFieldCompatible: (field) => {
|
||||
return (
|
||||
field.searchable &&
|
||||
field.spec.type === 'string' &&
|
||||
(field.spec.esTypes ?? []).includes('text')
|
||||
);
|
||||
},
|
||||
CustomOptionsComponent: ({ stateManager }) => {
|
||||
const searchTechnique = useStateFromPublishingSubject(stateManager.searchTechnique);
|
||||
|
||||
return (
|
||||
<EuiFormRow label={'Searching'} data-test-subj="searchControl__searchOptionsRadioGroup">
|
||||
<EuiRadioGroup
|
||||
options={allSearchOptions}
|
||||
idSelected={searchTechnique ?? DEFAULT_SEARCH_TECHNIQUE}
|
||||
onChange={(id) => {
|
||||
const newSearchTechnique = id as SearchControlTechniques;
|
||||
stateManager.searchTechnique.next(newSearchTechnique);
|
||||
}}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
},
|
||||
buildControl: (initialState, buildApi, uuid, parentApi) => {
|
||||
const searchString = new BehaviorSubject<string | undefined>(initialState.searchString);
|
||||
const searchTechnique = new BehaviorSubject<SearchControlTechniques | undefined>(
|
||||
initialState.searchTechnique ?? DEFAULT_SEARCH_TECHNIQUE
|
||||
);
|
||||
const editorStateManager = { searchTechnique };
|
||||
|
||||
const {
|
||||
dataControlApi,
|
||||
dataControlComparators,
|
||||
dataControlStateManager,
|
||||
serializeDataControl,
|
||||
} = initializeDataControl<Pick<SearchControlState, 'searchTechnique'>>(
|
||||
uuid,
|
||||
SEARCH_CONTROL_TYPE,
|
||||
initialState,
|
||||
editorStateManager,
|
||||
parentApi,
|
||||
{
|
||||
core,
|
||||
dataViews: dataViewsService,
|
||||
}
|
||||
);
|
||||
|
||||
const api = buildApi(
|
||||
{
|
||||
...dataControlApi,
|
||||
getTypeDisplayName: () =>
|
||||
i18n.translate('controlsExamples.searchControl.displayName', {
|
||||
defaultMessage: 'Search',
|
||||
}),
|
||||
serializeState: () => {
|
||||
const { rawState: dataControlState, references } = serializeDataControl();
|
||||
return {
|
||||
rawState: {
|
||||
...dataControlState,
|
||||
searchString: searchString.getValue(),
|
||||
searchTechnique: searchTechnique.getValue(),
|
||||
},
|
||||
references, // does not have any references other than those provided by the data control serializer
|
||||
};
|
||||
},
|
||||
clearSelections: () => {
|
||||
searchString.next(undefined);
|
||||
},
|
||||
},
|
||||
{
|
||||
...dataControlComparators,
|
||||
searchTechnique: [
|
||||
searchTechnique,
|
||||
(newTechnique: SearchControlTechniques | undefined) =>
|
||||
searchTechnique.next(newTechnique),
|
||||
],
|
||||
searchString: [
|
||||
searchString,
|
||||
(newString: string | undefined) =>
|
||||
searchString.next(newString?.length === 0 ? undefined : newString),
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* If either the search string or the search technique changes, recalulate the output filter
|
||||
*/
|
||||
const onSearchStringChanged = combineLatest([searchString, searchTechnique])
|
||||
.pipe(debounceTime(200), distinctUntilChanged(deepEqual))
|
||||
.subscribe(([newSearchString, currentSearchTechnnique]) => {
|
||||
const currentDataView = dataControlApi.dataViews.getValue()?.[0];
|
||||
const currentField = dataControlStateManager.fieldName.getValue();
|
||||
|
||||
if (currentDataView && currentField) {
|
||||
if (newSearchString) {
|
||||
api.setOutputFilter(
|
||||
currentSearchTechnnique === 'match'
|
||||
? {
|
||||
query: { match: { [currentField]: { query: newSearchString } } },
|
||||
meta: { index: currentDataView.id },
|
||||
}
|
||||
: {
|
||||
query: {
|
||||
simple_query_string: {
|
||||
query: newSearchString,
|
||||
fields: [currentField],
|
||||
default_operator: 'and',
|
||||
},
|
||||
},
|
||||
meta: { index: currentDataView.id },
|
||||
}
|
||||
);
|
||||
} else {
|
||||
api.setOutputFilter(undefined);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* When the field changes (which can happen if either the field name or the dataview id changes),
|
||||
* clear the previous search string.
|
||||
*/
|
||||
const onFieldChanged = combineLatest([
|
||||
dataControlStateManager.fieldName,
|
||||
dataControlStateManager.dataViewId,
|
||||
])
|
||||
.pipe(distinctUntilChanged(deepEqual))
|
||||
.subscribe(() => {
|
||||
searchString.next(undefined);
|
||||
});
|
||||
|
||||
return {
|
||||
api,
|
||||
/**
|
||||
* The `conrolStyleProps` prop is necessary because it contains the props from the generic
|
||||
* ControlPanel that are necessary for styling
|
||||
*/
|
||||
Component: (conrolStyleProps) => {
|
||||
const currentSearch = useStateFromPublishingSubject(searchString);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// cleanup on unmount
|
||||
onSearchStringChanged.unsubscribe();
|
||||
onFieldChanged.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<EuiFieldSearch
|
||||
{...conrolStyleProps}
|
||||
incremental={true}
|
||||
isClearable={false} // this will be handled by the clear floating action instead
|
||||
value={currentSearch ?? ''}
|
||||
onChange={(event) => {
|
||||
searchString.next(event.target.value);
|
||||
}}
|
||||
placeholder={i18n.translate('controls.searchControl.placeholder', {
|
||||
defaultMessage: 'Search...',
|
||||
})}
|
||||
id={uuid}
|
||||
fullWidth
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* 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 { DataControlApi, DefaultDataControlState } from '../types';
|
||||
|
||||
export const SEARCH_CONTROL_TYPE = 'searchControl';
|
||||
|
||||
export type SearchControlTechniques = 'match' | 'simple_query_string';
|
||||
|
||||
export interface SearchControlState extends DefaultDataControlState {
|
||||
searchString?: string;
|
||||
searchTechnique?: SearchControlTechniques;
|
||||
}
|
||||
|
||||
export type SearchControlApi = DataControlApi;
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* 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 { CanClearSelections } from '@kbn/controls-plugin/public';
|
||||
import { DataViewField } from '@kbn/data-views-plugin/common';
|
||||
import { Filter } from '@kbn/es-query';
|
||||
import {
|
||||
HasEditCapabilities,
|
||||
PublishesDataViews,
|
||||
PublishesFilters,
|
||||
PublishesPanelTitle,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import {
|
||||
ControlFactory,
|
||||
ControlStateManager,
|
||||
DefaultControlApi,
|
||||
DefaultControlState,
|
||||
} from '../types';
|
||||
|
||||
export type DataControlApi = DefaultControlApi &
|
||||
Omit<PublishesPanelTitle, 'hidePanelTitle'> & // control titles cannot be hidden
|
||||
HasEditCapabilities &
|
||||
CanClearSelections &
|
||||
PublishesDataViews &
|
||||
PublishesFilters & {
|
||||
setOutputFilter: (filter: Filter | undefined) => void; // a control should only ever output a **single** filter
|
||||
};
|
||||
|
||||
export interface DataControlFactory<
|
||||
State extends DefaultDataControlState = DefaultDataControlState,
|
||||
Api extends DataControlApi = DataControlApi
|
||||
> extends ControlFactory<State, Api> {
|
||||
isFieldCompatible: (field: DataViewField) => boolean;
|
||||
CustomOptionsComponent?: React.FC<{
|
||||
stateManager: ControlStateManager<State>;
|
||||
setControlEditorValid: (valid: boolean) => void;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const isDataControlFactory = (
|
||||
factory: ControlFactory<object, any>
|
||||
): factory is DataControlFactory<any, any> => {
|
||||
return typeof (factory as DataControlFactory).isFieldCompatible === 'function';
|
||||
};
|
||||
|
||||
export interface DefaultDataControlState extends DefaultControlState {
|
||||
dataViewId: string;
|
||||
fieldName: string;
|
||||
title?: string; // custom control label
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* 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 } from 'rxjs';
|
||||
|
||||
import { ControlWidth } from '@kbn/controls-plugin/common';
|
||||
import { SerializedPanelState } from '@kbn/presentation-containers';
|
||||
import { StateComparators } from '@kbn/presentation-publishing';
|
||||
|
||||
import {
|
||||
ControlApiInitialization,
|
||||
ControlStateManager,
|
||||
DefaultControlApi,
|
||||
DefaultControlState,
|
||||
} from './types';
|
||||
|
||||
export type ControlApi = ControlApiInitialization<DefaultControlApi>;
|
||||
|
||||
export const initializeDefaultControlApi = (
|
||||
state: DefaultControlState
|
||||
): {
|
||||
defaultControlApi: ControlApi;
|
||||
defaultControlStateManager: ControlStateManager<DefaultControlState>;
|
||||
defaultControlComparators: StateComparators<DefaultControlState>;
|
||||
serializeDefaultControl: () => SerializedPanelState<DefaultControlState>;
|
||||
} => {
|
||||
const dataLoading = new BehaviorSubject<boolean | undefined>(false);
|
||||
const blockingError = new BehaviorSubject<Error | undefined>(undefined);
|
||||
const grow = new BehaviorSubject<boolean | undefined>(state.grow);
|
||||
const width = new BehaviorSubject<ControlWidth | undefined>(state.width);
|
||||
|
||||
const defaultControlApi: ControlApi = {
|
||||
grow,
|
||||
width,
|
||||
dataLoading,
|
||||
blockingError,
|
||||
setBlockingError: (error) => blockingError.next(error),
|
||||
setDataLoading: (loading) => dataLoading.next(loading),
|
||||
};
|
||||
|
||||
const defaultControlStateManager: ControlStateManager<DefaultControlState> = {
|
||||
grow,
|
||||
width,
|
||||
};
|
||||
|
||||
const defaultControlComparators: StateComparators<DefaultControlState> = {
|
||||
grow: [grow, (newGrow: boolean | undefined) => grow.next(newGrow)],
|
||||
width: [width, (newWidth: ControlWidth | undefined) => width.next(newWidth)],
|
||||
};
|
||||
|
||||
return {
|
||||
defaultControlApi,
|
||||
defaultControlComparators,
|
||||
defaultControlStateManager,
|
||||
serializeDefaultControl: () => {
|
||||
return { rawState: { grow: grow.getValue(), width: width.getValue() }, references: [] };
|
||||
},
|
||||
};
|
||||
};
|
96
examples/controls_example/public/react_controls/types.ts
Normal file
96
examples/controls_example/public/react_controls/types.ts
Normal file
|
@ -0,0 +1,96 @@
|
|||
/*
|
||||
* 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 } from 'rxjs';
|
||||
|
||||
import { SerializedStyles } from '@emotion/react';
|
||||
import { ControlWidth } from '@kbn/controls-plugin/public/types';
|
||||
import { HasSerializableState } from '@kbn/presentation-containers';
|
||||
import { PanelCompatibleComponent } from '@kbn/presentation-panel-plugin/public/panel_component/types';
|
||||
import {
|
||||
HasParentApi,
|
||||
HasType,
|
||||
HasUniqueId,
|
||||
PublishesBlockingError,
|
||||
PublishesDataLoading,
|
||||
PublishesDisabledActionIds,
|
||||
PublishesPanelTitle,
|
||||
PublishesUnsavedChanges,
|
||||
PublishingSubject,
|
||||
StateComparators,
|
||||
} from '@kbn/presentation-publishing';
|
||||
|
||||
import { ControlGroupApi } from './control_group/types';
|
||||
|
||||
export interface PublishesControlDisplaySettings {
|
||||
grow: PublishingSubject<boolean | undefined>;
|
||||
width: PublishingSubject<ControlWidth | undefined>;
|
||||
}
|
||||
|
||||
export interface HasCustomPrepend {
|
||||
getCustomPrepend: () => React.FC<{}>;
|
||||
}
|
||||
|
||||
export type DefaultControlApi = PublishesDataLoading &
|
||||
PublishesBlockingError &
|
||||
PublishesUnsavedChanges &
|
||||
PublishesControlDisplaySettings &
|
||||
Partial<PublishesPanelTitle & PublishesDisabledActionIds & HasCustomPrepend> &
|
||||
HasType &
|
||||
HasUniqueId &
|
||||
HasSerializableState &
|
||||
HasParentApi<ControlGroupApi> & {
|
||||
setDataLoading: (loading: boolean) => void;
|
||||
setBlockingError: (error: Error | undefined) => void;
|
||||
};
|
||||
|
||||
export interface DefaultControlState {
|
||||
grow?: boolean;
|
||||
width?: ControlWidth;
|
||||
}
|
||||
|
||||
export type ControlApiRegistration<ControlApi extends DefaultControlApi = DefaultControlApi> = Omit<
|
||||
ControlApi,
|
||||
'uuid' | 'parentApi' | 'type' | 'unsavedChanges' | 'resetUnsavedChanges'
|
||||
>;
|
||||
|
||||
export type ControlApiInitialization<ControlApi extends DefaultControlApi = DefaultControlApi> =
|
||||
Omit<
|
||||
ControlApiRegistration<ControlApi>,
|
||||
'serializeState' | 'getTypeDisplayName' | 'clearSelections'
|
||||
>;
|
||||
|
||||
// TODO: Move this to the Control plugin's setup contract
|
||||
export interface ControlFactory<
|
||||
State extends DefaultControlState = DefaultControlState,
|
||||
ControlApi extends DefaultControlApi = DefaultControlApi
|
||||
> {
|
||||
type: string;
|
||||
getIconType: () => string;
|
||||
getDisplayName: () => string;
|
||||
buildControl: (
|
||||
initialState: State,
|
||||
buildApi: (
|
||||
apiRegistration: ControlApiRegistration<ControlApi>,
|
||||
comparators: StateComparators<State>
|
||||
) => ControlApi,
|
||||
uuid: string,
|
||||
parentApi: ControlGroupApi
|
||||
) => { api: ControlApi; Component: React.FC<{}> };
|
||||
}
|
||||
|
||||
export type ControlStateManager<State extends object = object> = {
|
||||
[key in keyof Required<State>]: BehaviorSubject<State[key]>;
|
||||
};
|
||||
|
||||
export interface ControlPanelProps<
|
||||
ApiType extends DefaultControlApi = DefaultControlApi,
|
||||
PropsType extends {} = { css: SerializedStyles }
|
||||
> {
|
||||
Component: PanelCompatibleComponent<ApiType, PropsType>;
|
||||
}
|
|
@ -18,9 +18,21 @@
|
|||
"@kbn/data-plugin",
|
||||
"@kbn/controls-plugin",
|
||||
"@kbn/navigation-plugin",
|
||||
"@kbn/shared-ux-page-kibana-template",
|
||||
"@kbn/embeddable-plugin",
|
||||
"@kbn/data-views-plugin",
|
||||
"@kbn/es-query",
|
||||
"@kbn/presentation-containers",
|
||||
"@kbn/presentation-publishing",
|
||||
"@kbn/ui-actions-plugin",
|
||||
"@kbn/i18n-react",
|
||||
"@kbn/shared-ux-markdown",
|
||||
"@kbn/i18n",
|
||||
"@kbn/core-mount-utils-browser",
|
||||
"@kbn/react-kibana-mount",
|
||||
"@kbn/content-management-utils",
|
||||
"@kbn/presentation-util-plugin",
|
||||
"@kbn/ui-theme",
|
||||
"@kbn/core-lifecycle-browser",
|
||||
"@kbn/presentation-panel-plugin",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -27,6 +27,7 @@ export {
|
|||
apiIsPresentationContainer,
|
||||
getContainerParentFromAPI,
|
||||
listenForCompatibleApi,
|
||||
combineCompatibleChildrenApis,
|
||||
type PanelPackage,
|
||||
type PresentationContainer,
|
||||
} from './interfaces/presentation_container';
|
||||
|
|
|
@ -6,23 +6,16 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import {
|
||||
apiHasParentApi,
|
||||
apiHasUniqueId,
|
||||
PublishesViewMode,
|
||||
PublishingSubject,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import { apiHasParentApi, apiHasUniqueId, PublishingSubject } from '@kbn/presentation-publishing';
|
||||
import { BehaviorSubject, combineLatest, isObservable, map, Observable, of, switchMap } from 'rxjs';
|
||||
import { apiCanAddNewPanel, CanAddNewPanel } from './can_add_new_panel';
|
||||
import { PublishesSettings } from './publishes_settings';
|
||||
|
||||
export interface PanelPackage<SerializedState extends object = object> {
|
||||
panelType: string;
|
||||
initialState?: SerializedState;
|
||||
}
|
||||
|
||||
export interface PresentationContainer
|
||||
extends Partial<PublishesViewMode & PublishesSettings>,
|
||||
CanAddNewPanel {
|
||||
export interface PresentationContainer extends CanAddNewPanel {
|
||||
/**
|
||||
* Removes a panel from the container.
|
||||
*/
|
||||
|
@ -101,3 +94,34 @@ export const listenForCompatibleApi = <ApiType extends unknown>(
|
|||
lastCleanupFunction?.();
|
||||
};
|
||||
};
|
||||
|
||||
export const combineCompatibleChildrenApis = <ApiType extends unknown, PublishingSubjectType>(
|
||||
api: unknown,
|
||||
observableKey: keyof ApiType,
|
||||
isCompatible: (api: unknown) => api is ApiType,
|
||||
emptyState: PublishingSubjectType,
|
||||
flattenMethod?: (array: PublishingSubjectType[]) => PublishingSubjectType
|
||||
): Observable<PublishingSubjectType> => {
|
||||
if (!api || !apiIsPresentationContainer(api)) return of();
|
||||
|
||||
return api.children$.pipe(
|
||||
switchMap((children) => {
|
||||
const compatibleChildren: Array<Observable<PublishingSubjectType>> = [];
|
||||
for (const child of Object.values(children)) {
|
||||
if (isCompatible(child) && isObservable(child[observableKey]))
|
||||
compatibleChildren.push(child[observableKey] as BehaviorSubject<PublishingSubjectType>);
|
||||
}
|
||||
|
||||
if (compatibleChildren.length === 0) return of(emptyState);
|
||||
|
||||
return combineLatest(compatibleChildren).pipe(
|
||||
map(
|
||||
flattenMethod
|
||||
? flattenMethod
|
||||
: (nextCompatible) =>
|
||||
nextCompatible.flat().filter((value) => Boolean(value)) as PublishingSubjectType
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
|
|
@ -36,13 +36,16 @@ export {
|
|||
} from './interfaces/fetch/initialize_time_range';
|
||||
export {
|
||||
apiPublishesPartialUnifiedSearch,
|
||||
apiPublishesFilters,
|
||||
apiPublishesTimeRange,
|
||||
apiPublishesUnifiedSearch,
|
||||
apiPublishesWritableUnifiedSearch,
|
||||
useSearchApi,
|
||||
type PublishesTimeRange,
|
||||
type PublishesFilters,
|
||||
type PublishesUnifiedSearch,
|
||||
type PublishesWritableUnifiedSearch,
|
||||
type PublishesTimeslice,
|
||||
} from './interfaces/fetch/publishes_unified_search';
|
||||
export {
|
||||
apiHasAppContext,
|
||||
|
|
|
@ -6,26 +6,33 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { TimeRange, Filter, Query, AggregateQuery } from '@kbn/es-query';
|
||||
import { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { PublishingSubject } from '../../publishing_subject';
|
||||
|
||||
export interface PublishesTimeRange {
|
||||
export interface PublishesTimeslice {
|
||||
timeslice$?: PublishingSubject<[number, number] | undefined>;
|
||||
}
|
||||
|
||||
export interface PublishesTimeRange extends PublishesTimeslice {
|
||||
timeRange$: PublishingSubject<TimeRange | undefined>;
|
||||
timeRestore$?: PublishingSubject<boolean | undefined>;
|
||||
timeslice$?: PublishingSubject<[number, number] | undefined>;
|
||||
}
|
||||
|
||||
export type PublishesWritableTimeRange = PublishesTimeRange & {
|
||||
setTimeRange: (timeRange: TimeRange | undefined) => void;
|
||||
};
|
||||
|
||||
export type PublishesUnifiedSearch = PublishesTimeRange & {
|
||||
isCompatibleWithUnifiedSearch?: () => boolean;
|
||||
export interface PublishesFilters {
|
||||
filters$: PublishingSubject<Filter[] | undefined>;
|
||||
query$: PublishingSubject<Query | AggregateQuery | undefined>;
|
||||
};
|
||||
}
|
||||
|
||||
export type PublishesUnifiedSearch = PublishesTimeRange &
|
||||
PublishesFilters & {
|
||||
isCompatibleWithUnifiedSearch?: () => boolean;
|
||||
query$: PublishingSubject<Query | AggregateQuery | undefined>;
|
||||
};
|
||||
|
||||
export type PublishesWritableUnifiedSearch = PublishesUnifiedSearch &
|
||||
PublishesWritableTimeRange & {
|
||||
|
@ -39,6 +46,10 @@ export const apiPublishesTimeRange = (
|
|||
return Boolean(unknownApi && (unknownApi as PublishesTimeRange)?.timeRange$ !== undefined);
|
||||
};
|
||||
|
||||
export const apiPublishesFilters = (unknownApi: unknown): unknownApi is PublishesFilters => {
|
||||
return Boolean(unknownApi && (unknownApi as PublishesFilters)?.filters$ !== undefined);
|
||||
};
|
||||
|
||||
export const apiPublishesUnifiedSearch = (
|
||||
unknownApi: null | unknown
|
||||
): unknownApi is PublishesUnifiedSearch => {
|
||||
|
|
|
@ -18,6 +18,7 @@ export {
|
|||
type RawControlGroupAttributes,
|
||||
type PersistableControlGroupInput,
|
||||
type SerializableControlGroupInput,
|
||||
type ControlGroupChainingSystem,
|
||||
persistableControlGroupInputKeys,
|
||||
} from './control_group/types';
|
||||
export {
|
||||
|
@ -32,6 +33,7 @@ export {
|
|||
} from './control_group/control_group_persistence';
|
||||
|
||||
export {
|
||||
DEFAULT_CONTROL_GROW,
|
||||
DEFAULT_CONTROL_WIDTH,
|
||||
DEFAULT_CONTROL_STYLE,
|
||||
} from './control_group/control_group_constants';
|
||||
|
|
|
@ -9,30 +9,55 @@
|
|||
import React, { SyntheticEvent } from 'react';
|
||||
|
||||
import { EuiButtonIcon, EuiToolTip } from '@elastic/eui';
|
||||
import { isErrorEmbeddable } from '@kbn/embeddable-plugin/public';
|
||||
import { apiIsPresentationContainer, PresentationContainer } from '@kbn/presentation-containers';
|
||||
import {
|
||||
apiCanAccessViewMode,
|
||||
apiHasParentApi,
|
||||
apiHasType,
|
||||
apiHasUniqueId,
|
||||
apiIsOfType,
|
||||
EmbeddableApiContext,
|
||||
HasParentApi,
|
||||
HasType,
|
||||
HasUniqueId,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
|
||||
|
||||
import { ACTION_CLEAR_CONTROL } from '.';
|
||||
import { CanClearSelections, isClearableControl } from '../../types';
|
||||
import { ControlGroupStrings } from '../control_group_strings';
|
||||
import { ControlEmbeddable, DataControlInput, isClearableControl } from '../../types';
|
||||
import { isControlGroup } from '../embeddable/control_group_helpers';
|
||||
import { CONTROL_GROUP_TYPE } from '../types';
|
||||
|
||||
export interface ClearControlActionContext {
|
||||
embeddable: ControlEmbeddable<DataControlInput>;
|
||||
}
|
||||
export type ClearControlActionApi = HasType &
|
||||
HasUniqueId &
|
||||
CanClearSelections &
|
||||
HasParentApi<PresentationContainer & HasType>;
|
||||
|
||||
export class ClearControlAction implements Action<ClearControlActionContext> {
|
||||
const isApiCompatible = (api: unknown | null): api is ClearControlActionApi =>
|
||||
Boolean(
|
||||
apiHasType(api) &&
|
||||
apiHasUniqueId(api) &&
|
||||
isClearableControl(api) &&
|
||||
apiHasParentApi(api) &&
|
||||
apiCanAccessViewMode(api.parentApi) &&
|
||||
apiIsOfType(api.parentApi, CONTROL_GROUP_TYPE) &&
|
||||
apiIsPresentationContainer(api.parentApi)
|
||||
);
|
||||
|
||||
export class ClearControlAction implements Action<EmbeddableApiContext> {
|
||||
public readonly type = ACTION_CLEAR_CONTROL;
|
||||
public readonly id = ACTION_CLEAR_CONTROL;
|
||||
public order = 1;
|
||||
|
||||
constructor() {}
|
||||
|
||||
public readonly MenuItem = ({ context }: { context: ClearControlActionContext }) => {
|
||||
public readonly MenuItem = ({ context }: { context: EmbeddableApiContext }) => {
|
||||
if (!isApiCompatible(context.embeddable)) throw new IncompatibleActionError();
|
||||
|
||||
return (
|
||||
<EuiToolTip content={this.getDisplayName(context)}>
|
||||
<EuiButtonIcon
|
||||
data-test-subj={`control-action-${context.embeddable.id}-erase`}
|
||||
data-test-subj={`control-action-${context.embeddable.uuid}-erase`}
|
||||
aria-label={this.getDisplayName(context)}
|
||||
iconType={this.getIconType(context)}
|
||||
onClick={(event: SyntheticEvent<HTMLButtonElement>) => {
|
||||
|
@ -45,34 +70,22 @@ export class ClearControlAction implements Action<ClearControlActionContext> {
|
|||
);
|
||||
};
|
||||
|
||||
public getDisplayName({ embeddable }: ClearControlActionContext) {
|
||||
if (!embeddable.parent || !isControlGroup(embeddable.parent)) {
|
||||
throw new IncompatibleActionError();
|
||||
}
|
||||
public getDisplayName({ embeddable }: EmbeddableApiContext) {
|
||||
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
return ControlGroupStrings.floatingActions.getClearButtonTitle();
|
||||
}
|
||||
|
||||
public getIconType({ embeddable }: ClearControlActionContext) {
|
||||
if (!embeddable.parent || !isControlGroup(embeddable.parent)) {
|
||||
throw new IncompatibleActionError();
|
||||
}
|
||||
public getIconType({ embeddable }: EmbeddableApiContext) {
|
||||
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
return 'eraser';
|
||||
}
|
||||
|
||||
public async isCompatible({ embeddable }: ClearControlActionContext) {
|
||||
if (isErrorEmbeddable(embeddable)) return false;
|
||||
const controlGroup = embeddable.parent;
|
||||
return Boolean(controlGroup && isControlGroup(controlGroup)) && isClearableControl(embeddable);
|
||||
public async isCompatible({ embeddable }: EmbeddableApiContext) {
|
||||
return isApiCompatible(embeddable);
|
||||
}
|
||||
|
||||
public async execute({ embeddable }: ClearControlActionContext) {
|
||||
if (
|
||||
!embeddable.parent ||
|
||||
!isControlGroup(embeddable.parent) ||
|
||||
!isClearableControl(embeddable)
|
||||
) {
|
||||
throw new IncompatibleActionError();
|
||||
}
|
||||
public async execute({ embeddable }: EmbeddableApiContext) {
|
||||
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
embeddable.clearSelections();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,20 +9,43 @@
|
|||
import React from 'react';
|
||||
|
||||
import { EuiButtonIcon, EuiToolTip } from '@elastic/eui';
|
||||
import { ViewMode, isErrorEmbeddable } from '@kbn/embeddable-plugin/public';
|
||||
import { ViewMode } from '@kbn/embeddable-plugin/public';
|
||||
import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
|
||||
|
||||
import { apiIsPresentationContainer, PresentationContainer } from '@kbn/presentation-containers';
|
||||
import {
|
||||
apiCanAccessViewMode,
|
||||
apiHasParentApi,
|
||||
apiHasType,
|
||||
apiHasUniqueId,
|
||||
apiIsOfType,
|
||||
EmbeddableApiContext,
|
||||
getInheritedViewMode,
|
||||
HasParentApi,
|
||||
HasType,
|
||||
HasUniqueId,
|
||||
PublishesViewMode,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import { ACTION_DELETE_CONTROL } from '.';
|
||||
import { pluginServices } from '../../services';
|
||||
import { ControlGroupStrings } from '../control_group_strings';
|
||||
import { ControlEmbeddable, DataControlInput } from '../../types';
|
||||
import { isControlGroup } from '../embeddable/control_group_helpers';
|
||||
import { CONTROL_GROUP_TYPE } from '../types';
|
||||
|
||||
export interface DeleteControlActionContext {
|
||||
embeddable: ControlEmbeddable<DataControlInput>;
|
||||
}
|
||||
export type DeleteControlActionApi = HasType &
|
||||
HasUniqueId &
|
||||
HasParentApi<PresentationContainer & PublishesViewMode & HasType>;
|
||||
|
||||
export class DeleteControlAction implements Action<DeleteControlActionContext> {
|
||||
const isApiCompatible = (api: unknown | null): api is DeleteControlActionApi =>
|
||||
Boolean(
|
||||
apiHasType(api) &&
|
||||
apiHasUniqueId(api) &&
|
||||
apiHasParentApi(api) &&
|
||||
apiCanAccessViewMode(api.parentApi) &&
|
||||
apiIsOfType(api.parentApi, CONTROL_GROUP_TYPE) &&
|
||||
apiIsPresentationContainer(api.parentApi)
|
||||
);
|
||||
|
||||
export class DeleteControlAction implements Action<EmbeddableApiContext> {
|
||||
public readonly type = ACTION_DELETE_CONTROL;
|
||||
public readonly id = ACTION_DELETE_CONTROL;
|
||||
public order = 100; // should always be last
|
||||
|
@ -35,11 +58,13 @@ export class DeleteControlAction implements Action<DeleteControlActionContext> {
|
|||
} = pluginServices.getServices());
|
||||
}
|
||||
|
||||
public readonly MenuItem = ({ context }: { context: DeleteControlActionContext }) => {
|
||||
public readonly MenuItem = ({ context }: { context: EmbeddableApiContext }) => {
|
||||
if (!isApiCompatible(context.embeddable)) throw new IncompatibleActionError();
|
||||
|
||||
return (
|
||||
<EuiToolTip content={this.getDisplayName(context)}>
|
||||
<EuiButtonIcon
|
||||
data-test-subj={`control-action-${context.embeddable.id}-delete`}
|
||||
data-test-subj={`control-action-${context.embeddable.uuid}-delete`}
|
||||
aria-label={this.getDisplayName(context)}
|
||||
iconType={this.getIconType(context)}
|
||||
onClick={() => this.execute(context)}
|
||||
|
@ -49,34 +74,25 @@ export class DeleteControlAction implements Action<DeleteControlActionContext> {
|
|||
);
|
||||
};
|
||||
|
||||
public getDisplayName({ embeddable }: DeleteControlActionContext) {
|
||||
if (!embeddable.parent || !isControlGroup(embeddable.parent)) {
|
||||
throw new IncompatibleActionError();
|
||||
}
|
||||
public getDisplayName({ embeddable }: EmbeddableApiContext) {
|
||||
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
return ControlGroupStrings.floatingActions.getRemoveButtonTitle();
|
||||
}
|
||||
|
||||
public getIconType({ embeddable }: DeleteControlActionContext) {
|
||||
if (!embeddable.parent || !isControlGroup(embeddable.parent)) {
|
||||
throw new IncompatibleActionError();
|
||||
}
|
||||
public getIconType({ embeddable }: EmbeddableApiContext) {
|
||||
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
return 'trash';
|
||||
}
|
||||
|
||||
public async isCompatible({ embeddable }: DeleteControlActionContext) {
|
||||
if (isErrorEmbeddable(embeddable)) return false;
|
||||
const controlGroup = embeddable.parent;
|
||||
return Boolean(
|
||||
controlGroup &&
|
||||
isControlGroup(controlGroup) &&
|
||||
controlGroup.getInput().viewMode === ViewMode.EDIT
|
||||
public async isCompatible({ embeddable }: EmbeddableApiContext) {
|
||||
return (
|
||||
isApiCompatible(embeddable) && getInheritedViewMode(embeddable.parentApi) === ViewMode.EDIT
|
||||
);
|
||||
}
|
||||
|
||||
public async execute({ embeddable }: DeleteControlActionContext) {
|
||||
if (!embeddable.parent || !isControlGroup(embeddable.parent)) {
|
||||
throw new IncompatibleActionError();
|
||||
}
|
||||
public async execute({ embeddable }: EmbeddableApiContext) {
|
||||
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
|
||||
this.openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), {
|
||||
confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(),
|
||||
cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(),
|
||||
|
@ -84,7 +100,7 @@ export class DeleteControlAction implements Action<DeleteControlActionContext> {
|
|||
buttonColor: 'danger',
|
||||
}).then((confirmed) => {
|
||||
if (confirmed) {
|
||||
embeddable.parent?.removeEmbeddable(embeddable.id);
|
||||
embeddable.parentApi.removePanel(embeddable.uuid);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -129,7 +129,7 @@ export const ControlFrame = ({
|
|||
'controlFrameFloatingActions--oneLine': !usingTwoLineLayout,
|
||||
})}
|
||||
viewMode={viewMode}
|
||||
embeddable={embeddable}
|
||||
api={embeddable}
|
||||
disabledActions={disabledActions}
|
||||
isEnabled={embeddable && enableActions}
|
||||
>
|
||||
|
|
|
@ -6,14 +6,6 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
/*
|
||||
* 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 deepEqual from 'fast-deep-equal';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
|
|
|
@ -11,8 +11,15 @@ import { isEqual, pick } from 'lodash';
|
|||
import React, { createContext, useContext } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { batch, Provider, TypedUseSelectorHook, useSelector } from 'react-redux';
|
||||
import { BehaviorSubject, merge, Subject, Subscription } from 'rxjs';
|
||||
import { debounceTime, distinctUntilChanged, skip } from 'rxjs';
|
||||
import {
|
||||
BehaviorSubject,
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
merge,
|
||||
skip,
|
||||
Subject,
|
||||
Subscription,
|
||||
} from 'rxjs';
|
||||
|
||||
import { OverlayRef } from '@kbn/core/public';
|
||||
import { Container, EmbeddableFactory } from '@kbn/embeddable-plugin/public';
|
||||
|
@ -479,6 +486,11 @@ export class ControlGroupContainer extends Container<
|
|||
};
|
||||
}
|
||||
|
||||
public removePanel(id: string): void {
|
||||
/** TODO: This is a temporary wrapper until the control group refactor is complete */
|
||||
super.removeEmbeddable(id);
|
||||
}
|
||||
|
||||
protected onRemoveEmbeddable(idToRemove: string) {
|
||||
const newPanels = super.onRemoveEmbeddable(idToRemove) as ControlsPanels;
|
||||
const childOrderCache = cachedChildEmbeddableOrder(this.getInput().panels);
|
||||
|
|
|
@ -15,6 +15,7 @@ export type {
|
|||
ControlEditorProps,
|
||||
CommonControlOutput,
|
||||
IEditableControlFactory,
|
||||
CanClearSelections,
|
||||
} from './types';
|
||||
|
||||
export type {
|
||||
|
@ -65,6 +66,9 @@ export {
|
|||
type ControlGroupRendererProps,
|
||||
} from './control_group';
|
||||
|
||||
/** TODO: Remove this once it is no longer needed in the examples plugin */
|
||||
export { CONTROL_WIDTH_OPTIONS } from './control_group/editor/editor_constants';
|
||||
|
||||
export function plugin() {
|
||||
return new ControlsPlugin();
|
||||
}
|
||||
|
|
|
@ -11,8 +11,17 @@ import { isEmpty, isEqual } from 'lodash';
|
|||
import React, { createContext, useContext } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { batch } from 'react-redux';
|
||||
import { merge, Subject, Subscription, switchMap, tap } from 'rxjs';
|
||||
import { debounceTime, distinctUntilChanged, map, skip } from 'rxjs';
|
||||
import {
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
merge,
|
||||
skip,
|
||||
Subject,
|
||||
Subscription,
|
||||
switchMap,
|
||||
tap,
|
||||
} from 'rxjs';
|
||||
|
||||
import { DataView, FieldSpec } from '@kbn/data-views-plugin/public';
|
||||
import { Embeddable, IContainer } from '@kbn/embeddable-plugin/public';
|
||||
|
@ -39,7 +48,7 @@ import { ControlFilterOutput } from '../../control_group/types';
|
|||
import { pluginServices } from '../../services';
|
||||
import { ControlsDataViewsService } from '../../services/data_views/types';
|
||||
import { ControlsOptionsListService } from '../../services/options_list/types';
|
||||
import { IClearableControl } from '../../types';
|
||||
import { CanClearSelections } from '../../types';
|
||||
import { OptionsListControl } from '../components/options_list_control';
|
||||
import { getDefaultComponentState, optionsListReducers } from '../options_list_reducers';
|
||||
import { MIN_OPTIONS_LIST_REQUEST_SIZE, OptionsListReduxState } from '../types';
|
||||
|
@ -81,7 +90,7 @@ type OptionsListReduxEmbeddableTools = ReduxEmbeddableTools<
|
|||
|
||||
export class OptionsListEmbeddable
|
||||
extends Embeddable<OptionsListEmbeddableInput, ControlOutput>
|
||||
implements IClearableControl
|
||||
implements CanClearSelections
|
||||
{
|
||||
public readonly type = OPTIONS_LIST_CONTROL;
|
||||
public deferEmbeddableLoad = true;
|
||||
|
|
|
@ -38,7 +38,7 @@ import { ControlFilterOutput } from '../../control_group/types';
|
|||
import { pluginServices } from '../../services';
|
||||
import { ControlsDataService } from '../../services/data/types';
|
||||
import { ControlsDataViewsService } from '../../services/data_views/types';
|
||||
import { IClearableControl } from '../../types';
|
||||
import { CanClearSelections } from '../../types';
|
||||
import { RangeSliderControl } from '../components/range_slider_control';
|
||||
import { getDefaultComponentState, rangeSliderReducers } from '../range_slider_reducers';
|
||||
import { RangeSliderReduxState } from '../types';
|
||||
|
@ -79,7 +79,7 @@ type RangeSliderReduxEmbeddableTools = ReduxEmbeddableTools<
|
|||
|
||||
export class RangeSliderEmbeddable
|
||||
extends Embeddable<RangeSliderEmbeddableInput, ControlOutput>
|
||||
implements IClearableControl
|
||||
implements CanClearSelections
|
||||
{
|
||||
public readonly type = RANGE_SLIDER_CONTROL;
|
||||
public deferEmbeddableLoad = true;
|
||||
|
|
|
@ -26,7 +26,7 @@ import { ControlTimesliceOutput } from '../../control_group/types';
|
|||
import { pluginServices } from '../../services';
|
||||
import { ControlsDataService } from '../../services/data/types';
|
||||
import { ControlsSettingsService } from '../../services/settings/types';
|
||||
import { ControlOutput, IClearableControl } from '../../types';
|
||||
import { CanClearSelections, ControlOutput } from '../../types';
|
||||
import { TimeSlider, TimeSliderPrepend } from '../components';
|
||||
import { timeSliderReducers } from '../time_slider_reducers';
|
||||
import { getIsAnchored, getRoundedTimeRangeBounds } from '../time_slider_selectors';
|
||||
|
@ -57,7 +57,7 @@ type TimeSliderReduxEmbeddableTools = ReduxEmbeddableTools<
|
|||
|
||||
export class TimeSliderControlEmbeddable
|
||||
extends Embeddable<TimeSliderControlEmbeddableInput, ControlOutput>
|
||||
implements IClearableControl
|
||||
implements CanClearSelections
|
||||
{
|
||||
public readonly type = TIME_SLIDER_CONTROL;
|
||||
public deferEmbeddedLoad = true;
|
||||
|
|
|
@ -36,25 +36,23 @@ export type ControlFactory<T extends ControlInput = ControlInput> = EmbeddableFa
|
|||
ControlEmbeddable
|
||||
>;
|
||||
|
||||
export type ControlEmbeddable<
|
||||
export interface ControlEmbeddable<
|
||||
TControlEmbeddableInput extends ControlInput = ControlInput,
|
||||
TControlEmbeddableOutput extends ControlOutput = ControlOutput
|
||||
> = IEmbeddable<TControlEmbeddableInput, TControlEmbeddableOutput> & {
|
||||
> extends IEmbeddable<TControlEmbeddableInput, TControlEmbeddableOutput> {
|
||||
isChained?: () => boolean;
|
||||
renderPrepend?: () => ReactNode | undefined;
|
||||
selectionsToFilters?: (
|
||||
input: Partial<TControlEmbeddableInput>
|
||||
) => Promise<ControlGroupFilterOutput>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IClearableControl<
|
||||
TClearableControlEmbeddableInput extends ControlInput = ControlInput
|
||||
> extends ControlEmbeddable {
|
||||
export interface CanClearSelections {
|
||||
clearSelections: () => void;
|
||||
}
|
||||
|
||||
export const isClearableControl = (control: ControlEmbeddable): control is IClearableControl => {
|
||||
return Boolean((control as IClearableControl).clearSelections);
|
||||
export const isClearableControl = (control: unknown): control is CanClearSelections => {
|
||||
return typeof (control as CanClearSelections).clearSelections === 'function';
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -39,6 +39,8 @@
|
|||
"@kbn/react-kibana-mount",
|
||||
"@kbn/shared-ux-markdown",
|
||||
"@kbn/react-kibana-context-render",
|
||||
"@kbn/presentation-containers",
|
||||
"@kbn/presentation-publishing",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
|
@ -9,10 +9,11 @@
|
|||
import { ControlGroupInput } from '@kbn/controls-plugin/common';
|
||||
import { ControlGroupContainer } from '@kbn/controls-plugin/public';
|
||||
import { compareFilters, COMPARE_ALL_OPTIONS, type Filter } from '@kbn/es-query';
|
||||
import { apiPublishesDataLoading, PublishingSubject } from '@kbn/presentation-publishing';
|
||||
import { combineCompatibleChildrenApis } from '@kbn/presentation-containers';
|
||||
import { apiPublishesDataLoading, PublishesDataLoading } from '@kbn/presentation-publishing';
|
||||
import deepEqual from 'fast-deep-equal';
|
||||
import { isEqual } from 'lodash';
|
||||
import { combineLatest, distinctUntilChanged, map, Observable, skip, switchMap } from 'rxjs';
|
||||
import { distinctUntilChanged, Observable, skip } from 'rxjs';
|
||||
import { DashboardContainerInput } from '../../../../../common';
|
||||
import { DashboardContainer } from '../../dashboard_container';
|
||||
|
||||
|
@ -96,20 +97,14 @@ export function startSyncingDashboardControlGroup(this: DashboardContainer) {
|
|||
|
||||
// the Control Group needs to know when any dashboard children are loading in order to know when to move on to the next time slice when playing.
|
||||
this.integrationSubscriptions.add(
|
||||
this.children$
|
||||
.pipe(
|
||||
switchMap((children) => {
|
||||
const definedDataLoadingSubjects: Array<PublishingSubject<boolean | undefined>> = [];
|
||||
for (const child of Object.values(children)) {
|
||||
if (apiPublishesDataLoading(child)) {
|
||||
definedDataLoadingSubjects.push(child.dataLoading);
|
||||
}
|
||||
}
|
||||
return combineLatest(definedDataLoadingSubjects).pipe(
|
||||
map((values) => values.some(Boolean))
|
||||
);
|
||||
})
|
||||
)
|
||||
combineCompatibleChildrenApis<PublishesDataLoading, boolean>(
|
||||
this,
|
||||
'dataLoading',
|
||||
apiPublishesDataLoading,
|
||||
false,
|
||||
(childrenLoading) => childrenLoading.some(Boolean)
|
||||
)
|
||||
.pipe(skip(1)) // skip the initial output of "false"
|
||||
.subscribe((anyChildLoading) =>
|
||||
this.controlGroup?.anyControlOutputConsumerLoading$.next(anyChildLoading)
|
||||
)
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
*/
|
||||
|
||||
import { DataView } from '@kbn/data-views-plugin/common';
|
||||
import { combineCompatibleChildrenApis } from '@kbn/presentation-containers';
|
||||
import { apiPublishesDataViews, PublishesDataViews } from '@kbn/presentation-publishing';
|
||||
import { uniqBy } from 'lodash';
|
||||
import { combineLatest, map, Observable, of, switchMap } from 'rxjs';
|
||||
|
@ -32,18 +33,11 @@ export function startSyncingDashboardDataViews(this: DashboardContainer) {
|
|||
)
|
||||
: of([]);
|
||||
|
||||
const childDataViewsPipe: Observable<DataView[]> = this.children$.pipe(
|
||||
switchMap((children) => {
|
||||
const childrenThatPublishDataViews: PublishesDataViews[] = [];
|
||||
for (const child of Object.values(children)) {
|
||||
if (apiPublishesDataViews(child)) childrenThatPublishDataViews.push(child);
|
||||
}
|
||||
if (childrenThatPublishDataViews.length === 0) return of([]);
|
||||
return combineLatest(childrenThatPublishDataViews.map((child) => child.dataViews));
|
||||
}),
|
||||
map(
|
||||
(nextDataViews) => nextDataViews.flat().filter((dataView) => Boolean(dataView)) as DataView[]
|
||||
)
|
||||
const childDataViewsPipe = combineCompatibleChildrenApis<PublishesDataViews, DataView[]>(
|
||||
this,
|
||||
'dataViews',
|
||||
apiPublishesDataViews,
|
||||
[]
|
||||
);
|
||||
|
||||
return combineLatest([controlGroupDataViewsPipe, childDataViewsPipe])
|
||||
|
|
|
@ -15,6 +15,7 @@ import {
|
|||
apiPublishesPanelTitle,
|
||||
apiPublishesUnsavedChanges,
|
||||
getPanelTitle,
|
||||
PublishesViewMode,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import { RefreshInterval } from '@kbn/data-plugin/public';
|
||||
import type { DataView } from '@kbn/data-views-plugin/public';
|
||||
|
@ -52,6 +53,7 @@ import { batch } from 'react-redux';
|
|||
import { BehaviorSubject, Subject, Subscription } from 'rxjs';
|
||||
import { distinctUntilChanged, map } from 'rxjs';
|
||||
import { v4 } from 'uuid';
|
||||
import { PublishesSettings } from '@kbn/presentation-containers/interfaces/publishes_settings';
|
||||
import { apiHasSerializableState } from '@kbn/presentation-containers/interfaces/serialized_state';
|
||||
import { DashboardLocatorParams, DASHBOARD_CONTAINER_TYPE } from '../..';
|
||||
import { DashboardContainerInput, DashboardPanelState } from '../../../common';
|
||||
|
@ -129,7 +131,9 @@ export class DashboardContainer
|
|||
TracksQueryPerformance,
|
||||
HasSaveNotification,
|
||||
HasRuntimeChildState,
|
||||
HasSerializedChildState
|
||||
HasSerializedChildState,
|
||||
PublishesSettings,
|
||||
Partial<PublishesViewMode>
|
||||
{
|
||||
public readonly type = DASHBOARD_CONTAINER_TYPE;
|
||||
|
||||
|
|
|
@ -93,7 +93,8 @@ describe('react embeddable renderer', () => {
|
|||
{ bork: 'blorp?' },
|
||||
expect.any(Function),
|
||||
expect.any(String),
|
||||
expect.any(Object)
|
||||
expect.any(Object),
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -118,7 +119,8 @@ describe('react embeddable renderer', () => {
|
|||
{ bork: 'blorp?' },
|
||||
expect.any(Function),
|
||||
'12345',
|
||||
expect.any(Object)
|
||||
expect.any(Object),
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -139,7 +141,8 @@ describe('react embeddable renderer', () => {
|
|||
{ bork: 'blorp?' },
|
||||
expect.any(Function),
|
||||
expect.any(String),
|
||||
parentApi
|
||||
parentApi,
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -19,7 +19,11 @@ import { BehaviorSubject, combineLatest, debounceTime, skip, Subscription, switc
|
|||
import { v4 as generateId } from 'uuid';
|
||||
import { getReactEmbeddableFactory } from './react_embeddable_registry';
|
||||
import { initializeReactEmbeddableState } from './react_embeddable_state';
|
||||
import { DefaultEmbeddableApi, ReactEmbeddableApiRegistration } from './types';
|
||||
import {
|
||||
DefaultEmbeddableApi,
|
||||
SetReactEmbeddableApiRegistration,
|
||||
BuildReactEmbeddableApiRegistration,
|
||||
} from './types';
|
||||
|
||||
const ON_STATE_CHANGE_DEBOUNCE = 100;
|
||||
|
||||
|
@ -96,8 +100,22 @@ export const ReactEmbeddableRenderer = <
|
|||
RuntimeState
|
||||
>(uuid, factory, parentApi);
|
||||
|
||||
const setApi = (
|
||||
apiRegistration: SetReactEmbeddableApiRegistration<SerializedState, Api>
|
||||
) => {
|
||||
const fullApi = {
|
||||
...apiRegistration,
|
||||
uuid,
|
||||
phase$,
|
||||
parentApi,
|
||||
type: factory.type,
|
||||
} as unknown as Api;
|
||||
onApiAvailable?.(fullApi);
|
||||
return fullApi;
|
||||
};
|
||||
|
||||
const buildApi = (
|
||||
apiRegistration: ReactEmbeddableApiRegistration<SerializedState, Api>,
|
||||
apiRegistration: BuildReactEmbeddableApiRegistration<SerializedState, Api>,
|
||||
comparators: StateComparators<RuntimeState>
|
||||
) => {
|
||||
if (onAnyStateChange) {
|
||||
|
@ -131,21 +149,15 @@ export const ReactEmbeddableRenderer = <
|
|||
|
||||
const { unsavedChanges, resetUnsavedChanges, cleanup, snapshotRuntimeState } =
|
||||
startStateDiffing(comparators);
|
||||
const fullApi = {
|
||||
|
||||
const fullApi = setApi({
|
||||
...apiRegistration,
|
||||
uuid,
|
||||
phase$,
|
||||
parentApi,
|
||||
unsavedChanges,
|
||||
type: factory.type,
|
||||
resetUnsavedChanges,
|
||||
snapshotRuntimeState,
|
||||
} as unknown as Api;
|
||||
cleanupFunction.current = () => {
|
||||
subscriptions.unsubscribe();
|
||||
cleanup();
|
||||
};
|
||||
onApiAvailable?.(fullApi);
|
||||
} as unknown as SetReactEmbeddableApiRegistration<SerializedState, Api>);
|
||||
|
||||
cleanupFunction.current = () => cleanup();
|
||||
return fullApi;
|
||||
};
|
||||
|
||||
|
@ -153,7 +165,8 @@ export const ReactEmbeddableRenderer = <
|
|||
initialState,
|
||||
buildApi,
|
||||
uuid,
|
||||
parentApi
|
||||
parentApi,
|
||||
setApi
|
||||
);
|
||||
|
||||
if (apiPublishesDataLoading(api)) {
|
||||
|
|
|
@ -36,21 +36,24 @@ export interface DefaultEmbeddableApi<
|
|||
HasSnapshottableState<RuntimeState> {}
|
||||
|
||||
/**
|
||||
* A subset of the default embeddable API used in registration to allow implementors to omit aspects
|
||||
* of the API that will be automatically added by the system.
|
||||
* Defines the subset of the default embeddable API that the `setApi` method uses, which allows implementors
|
||||
* to omit aspects of the API that will be automatically added by `setApi`.
|
||||
*/
|
||||
export type ReactEmbeddableApiRegistration<
|
||||
export type SetReactEmbeddableApiRegistration<
|
||||
SerializedState extends object = object,
|
||||
Api extends DefaultEmbeddableApi<SerializedState> = DefaultEmbeddableApi<SerializedState>
|
||||
> = Omit<Api, 'uuid' | 'parent' | 'type' | 'phase$'>;
|
||||
|
||||
/**
|
||||
* Defines the subset of the default embeddable API that the `buildApi` method uses, which allows implementors
|
||||
* to omit aspects of the API that will be automatically added by `buildApi`.
|
||||
*/
|
||||
export type BuildReactEmbeddableApiRegistration<
|
||||
SerializedState extends object = object,
|
||||
Api extends DefaultEmbeddableApi<SerializedState> = DefaultEmbeddableApi<SerializedState>
|
||||
> = Omit<
|
||||
Api,
|
||||
| 'uuid'
|
||||
| 'parent'
|
||||
| 'type'
|
||||
| 'unsavedChanges'
|
||||
| 'resetUnsavedChanges'
|
||||
| 'snapshotRuntimeState'
|
||||
| 'phase$'
|
||||
SetReactEmbeddableApiRegistration<SerializedState, Api>,
|
||||
'unsavedChanges' | 'resetUnsavedChanges' | 'snapshotRuntimeState'
|
||||
>;
|
||||
|
||||
/**
|
||||
|
@ -93,11 +96,17 @@ export interface ReactEmbeddableFactory<
|
|||
*/
|
||||
buildEmbeddable: (
|
||||
initialState: RuntimeState,
|
||||
/**
|
||||
* `buildApi` should be used by most embeddables that are used in dashboards, since it implements the unsaved
|
||||
* changes logic that the dashboard expects using the provided comparators
|
||||
*/
|
||||
buildApi: (
|
||||
apiRegistration: ReactEmbeddableApiRegistration<SerializedState, Api>,
|
||||
apiRegistration: BuildReactEmbeddableApiRegistration<SerializedState, Api>,
|
||||
comparators: StateComparators<RuntimeState>
|
||||
) => Api,
|
||||
uuid: string,
|
||||
parentApi?: unknown
|
||||
parentApi: unknown | undefined,
|
||||
/** `setApi` should be used when the unsaved changes logic in `buildApi` is unnecessary */
|
||||
setApi: (api: SetReactEmbeddableApiRegistration<SerializedState, Api>) => Api
|
||||
) => Promise<{ Component: React.FC<{}>; api: Api }>;
|
||||
}
|
||||
|
|
|
@ -7,14 +7,15 @@
|
|||
*/
|
||||
import classNames from 'classnames';
|
||||
import React, { FC, ReactElement, useEffect, useState } from 'react';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import {
|
||||
panelHoverTrigger,
|
||||
PANEL_HOVER_TRIGGER,
|
||||
type EmbeddableInput,
|
||||
type IEmbeddable,
|
||||
type ViewMode,
|
||||
} from '@kbn/embeddable-plugin/public';
|
||||
import { apiHasUniqueId } from '@kbn/presentation-publishing';
|
||||
import { Action } from '@kbn/ui-actions-plugin/public';
|
||||
|
||||
import { pluginServices } from '../../services';
|
||||
|
@ -25,7 +26,7 @@ export interface FloatingActionsProps {
|
|||
|
||||
className?: string;
|
||||
isEnabled?: boolean;
|
||||
embeddable?: IEmbeddable;
|
||||
api?: unknown;
|
||||
viewMode?: ViewMode;
|
||||
disabledActions?: EmbeddableInput['disabledActions'];
|
||||
}
|
||||
|
@ -34,7 +35,7 @@ export const FloatingActions: FC<FloatingActionsProps> = ({
|
|||
children,
|
||||
viewMode,
|
||||
isEnabled,
|
||||
embeddable,
|
||||
api,
|
||||
className = '',
|
||||
disabledActions,
|
||||
}) => {
|
||||
|
@ -44,12 +45,12 @@ export const FloatingActions: FC<FloatingActionsProps> = ({
|
|||
const [floatingActions, setFloatingActions] = useState<JSX.Element | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!embeddable) return;
|
||||
if (!api) return;
|
||||
|
||||
const getActions = async () => {
|
||||
let mounted = true;
|
||||
const context = {
|
||||
embeddable,
|
||||
embeddable: api,
|
||||
trigger: panelHoverTrigger,
|
||||
};
|
||||
const actions = (await getTriggerCompatibleActions(PANEL_HOVER_TRIGGER, context))
|
||||
|
@ -79,14 +80,16 @@ export const FloatingActions: FC<FloatingActionsProps> = ({
|
|||
};
|
||||
|
||||
getActions();
|
||||
}, [embeddable, getTriggerCompatibleActions, viewMode, disabledActions]);
|
||||
}, [api, getTriggerCompatibleActions, viewMode, disabledActions]);
|
||||
|
||||
return (
|
||||
<div className="presentationUtil__floatingActionsWrapper">
|
||||
{children}
|
||||
{isEnabled && floatingActions && (
|
||||
<div
|
||||
data-test-subj={`presentationUtil__floatingActions__${embeddable?.id}`}
|
||||
data-test-subj={`presentationUtil__floatingActions__${
|
||||
apiHasUniqueId(api) ? api.uuid : v4()
|
||||
}`}
|
||||
className={classNames('presentationUtil__floatingActions', className)}
|
||||
>
|
||||
{floatingActions}
|
||||
|
|
|
@ -35,6 +35,7 @@
|
|||
"@kbn/code-editor",
|
||||
"@kbn/calculate-width-from-char-count",
|
||||
"@kbn/field-utils",
|
||||
"@kbn/presentation-publishing",
|
||||
],
|
||||
"exclude": ["target/**/*"]
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue