[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:
Hannah Mudge 2024-06-05 08:51:37 -06:00 committed by GitHub
parent 993ef43e4e
commit 36f2ff409f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 3407 additions and 229 deletions

View file

@ -12,7 +12,9 @@
"developerExamples",
"embeddable",
"navigation",
"presentationUtil"
"presentationUtil",
"uiActions",
"dataViews"
]
}
}

View file

@ -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);
};

View 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);
};

View file

@ -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>
);
};

View file

@ -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;

View 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>
</>
);
};

View file

@ -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() {}
}

View file

@ -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();
}
}

View file

@ -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>
);
};

View file

@ -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);
};

View file

@ -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>
);

View file

@ -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(),
},
];

View file

@ -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;
};

View file

@ -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);
}
};

View file

@ -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,
};
};

View file

@ -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;
};

View file

@ -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>
);
};

View file

@ -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} />;
};

View file

@ -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',
}),
},
},
};

View file

@ -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>
</>
);
};

View file

@ -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);
});
};

View file

@ -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(),
},
],
};
},
};
};

View file

@ -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);
}
});
};

View file

@ -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
/>
);
},
};
},
};
};

View file

@ -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;

View file

@ -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
}

View file

@ -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: [] };
},
};
};

View 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>;
}

View file

@ -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",
]
}

View file

@ -27,6 +27,7 @@ export {
apiIsPresentationContainer,
getContainerParentFromAPI,
listenForCompatibleApi,
combineCompatibleChildrenApis,
type PanelPackage,
type PresentationContainer,
} from './interfaces/presentation_container';

View file

@ -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
)
);
})
);
};

View file

@ -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,

View file

@ -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 => {

View file

@ -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';

View file

@ -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();
}
}

View file

@ -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);
}
});
}

View file

@ -129,7 +129,7 @@ export const ControlFrame = ({
'controlFrameFloatingActions--oneLine': !usingTwoLineLayout,
})}
viewMode={viewMode}
embeddable={embeddable}
api={embeddable}
disabledActions={disabledActions}
isEnabled={embeddable && enableActions}
>

View file

@ -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';

View file

@ -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);

View file

@ -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();
}

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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';
};
/**

View file

@ -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/**/*",

View file

@ -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)
)

View file

@ -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])

View file

@ -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;

View file

@ -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)
);
});
});

View file

@ -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)) {

View file

@ -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 }>;
}

View file

@ -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}

View file

@ -35,6 +35,7 @@
"@kbn/code-editor",
"@kbn/calculate-width-from-char-count",
"@kbn/field-utils",
"@kbn/presentation-publishing",
],
"exclude": ["target/**/*"]
}