Skip to content

Commit

Permalink
[Embeddables as Building Blocks] Controls API (#140739)
Browse files Browse the repository at this point in the history
Added control group API and renderer component
  • Loading branch information
ThomThomson authored Oct 19, 2022
1 parent fcea2e7 commit c8a0376
Show file tree
Hide file tree
Showing 15 changed files with 274 additions and 54 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*
* 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 uuid from 'uuid';
import useLifecycles from 'react-use/lib/useLifecycles';
import React, { useEffect, useMemo, useRef, useState } from 'react';

import { IEmbeddable } from '@kbn/embeddable-plugin/public';

import { pluginServices } from '../services';
import { getDefaultControlGroupInput } from '../../common';
import { ControlGroupInput, ControlGroupOutput, CONTROL_GROUP_TYPE } from './types';
import { ControlGroupContainer } from './embeddable/control_group_container';

export interface ControlGroupRendererProps {
input?: Partial<Pick<ControlGroupInput, 'viewMode' | 'executionContext'>>;
onEmbeddableLoad: (controlGroupContainer: ControlGroupContainer) => void;
}

export const ControlGroupRenderer = ({ input, onEmbeddableLoad }: ControlGroupRendererProps) => {
const controlsRoot = useRef(null);
const [controlGroupContainer, setControlGroupContainer] = useState<ControlGroupContainer>();

const id = useMemo(() => uuid.v4(), []);

/**
* Use Lifecycles to load initial control group container
*/
useLifecycles(
() => {
const { embeddable } = pluginServices.getServices();

(async () => {
const container = (await embeddable
.getEmbeddableFactory<
ControlGroupInput,
ControlGroupOutput,
IEmbeddable<ControlGroupInput, ControlGroupOutput>
>(CONTROL_GROUP_TYPE)
?.create({ id, ...getDefaultControlGroupInput(), ...input })) as ControlGroupContainer;

if (controlsRoot.current) {
container.render(controlsRoot.current);
}
setControlGroupContainer(container);
onEmbeddableLoad(container);
})();
},
() => {
controlGroupContainer?.destroy();
}
);

/**
* Update embeddable input when props input changes
*/
useEffect(() => {
let updateCanceled = false;
(async () => {
// check if applying input from props would result in any changes to the embeddable input
const isInputEqual = await controlGroupContainer?.getExplicitInputIsEqual({
...controlGroupContainer?.getInput(),
...input,
});
if (!controlGroupContainer || isInputEqual || updateCanceled) return;
controlGroupContainer.updateInput({ ...input });
})();

return () => {
updateCanceled = true;
};
}, [controlGroupContainer, input]);

return <div ref={controlsRoot} />;
};

// required for dynamic import using React.lazy()
// eslint-disable-next-line import/no-default-export
export default ControlGroupRenderer;
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,6 @@
import { i18n } from '@kbn/i18n';

export const ControlGroupStrings = {
getEmbeddableTitle: () =>
i18n.translate('controls.controlGroup.title', {
defaultMessage: 'Control group',
}),
getControlButtonTitle: () =>
i18n.translate('controls.controlGroup.toolbarButtonTitle', {
defaultMessage: 'Controls',
Expand Down
55 changes: 10 additions & 45 deletions src/plugins/controls/public/control_group/editor/control_editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* Side Public License, v 1.
*/

import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useState } from 'react';
import useMount from 'react-use/lib/useMount';

import {
Expand All @@ -36,7 +36,6 @@ import {
EuiTextColor,
} from '@elastic/eui';
import { DataViewListItem, DataView, DataViewField } from '@kbn/data-views-plugin/common';
import { IFieldSubTypeMulti } from '@kbn/es-query';
import {
LazyDataViewPicker,
LazyFieldPicker,
Expand All @@ -53,6 +52,7 @@ import {
} from '../../types';
import { CONTROL_WIDTH_OPTIONS } from './editor_constants';
import { pluginServices } from '../../services';
import { loadFieldRegistryFromDataViewId } from './data_control_editor_tools';
interface EditControlProps {
embeddable?: ControlEmbeddable<DataControlInput>;
isCreate: boolean;
Expand Down Expand Up @@ -97,7 +97,7 @@ export const ControlEditor = ({
}: EditControlProps) => {
const {
dataViews: { getIdsWithTitle, getDefaultId, get },
controls: { getControlTypes, getControlFactory },
controls: { getControlFactory },
} = pluginServices.getServices();
const [state, setState] = useState<ControlEditorState>({
dataViewListItems: [],
Expand All @@ -112,49 +112,14 @@ export const ControlEditor = ({
embeddable ? embeddable.getInput().fieldName : undefined
);

const doubleLinkFields = (dataView: DataView) => {
// double link the parent-child relationship specifically for case-sensitivity support for options lists
const fieldRegistry: DataControlFieldRegistry = {};

for (const field of dataView.fields.getAll()) {
if (!fieldRegistry[field.name]) {
fieldRegistry[field.name] = { field, compatibleControlTypes: [] };
}
const parentFieldName = (field.subType as IFieldSubTypeMulti)?.multi?.parent;
if (parentFieldName) {
fieldRegistry[field.name].parentFieldName = parentFieldName;

const parentField = dataView.getFieldByName(parentFieldName);
if (!fieldRegistry[parentFieldName] && parentField) {
fieldRegistry[parentFieldName] = { field: parentField, compatibleControlTypes: [] };
}
fieldRegistry[parentFieldName].childFieldName = field.name;
}
}
return fieldRegistry;
};

const fieldRegistry = useMemo(() => {
if (!state.selectedDataView) return;
const newFieldRegistry: DataControlFieldRegistry = doubleLinkFields(state.selectedDataView);

const controlFactories = getControlTypes().map(
(controlType) => getControlFactory(controlType) as IEditableControlFactory
);
state.selectedDataView.fields.map((dataViewField) => {
for (const factory of controlFactories) {
if (factory.isFieldCompatible) {
factory.isFieldCompatible(newFieldRegistry[dataViewField.name]);
}
}

if (newFieldRegistry[dataViewField.name]?.compatibleControlTypes.length === 0) {
delete newFieldRegistry[dataViewField.name];
const [fieldRegistry, setFieldRegistry] = useState<DataControlFieldRegistry>();
useEffect(() => {
(async () => {
if (state.selectedDataView?.id) {
setFieldRegistry(await loadFieldRegistryFromDataViewId(state.selectedDataView.id));
}
});

return newFieldRegistry;
}, [state.selectedDataView, getControlFactory, getControlTypes]);
})();
}, [state.selectedDataView]);

useMount(() => {
let mounted = true;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* 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 { IFieldSubTypeMulti } from '@kbn/es-query';
import { DataView } from '@kbn/data-views-plugin/common';

import { pluginServices } from '../../services';
import { DataControlFieldRegistry, IEditableControlFactory } from '../../types';

const dataControlFieldRegistryCache: { [key: string]: DataControlFieldRegistry } = {};

const doubleLinkFields = (dataView: DataView) => {
// double link the parent-child relationship specifically for case-sensitivity support for options lists
const fieldRegistry: DataControlFieldRegistry = {};

for (const field of dataView.fields.getAll()) {
if (!fieldRegistry[field.name]) {
fieldRegistry[field.name] = { field, compatibleControlTypes: [] };
}
const parentFieldName = (field.subType as IFieldSubTypeMulti)?.multi?.parent;
if (parentFieldName) {
fieldRegistry[field.name].parentFieldName = parentFieldName;

const parentField = dataView.getFieldByName(parentFieldName);
if (!fieldRegistry[parentFieldName] && parentField) {
fieldRegistry[parentFieldName] = { field: parentField, compatibleControlTypes: [] };
}
fieldRegistry[parentFieldName].childFieldName = field.name;
}
}
return fieldRegistry;
};

export const loadFieldRegistryFromDataViewId = async (
dataViewId: string
): Promise<DataControlFieldRegistry> => {
if (dataControlFieldRegistryCache[dataViewId]) {
return dataControlFieldRegistryCache[dataViewId];
}
const {
dataViews,
controls: { getControlTypes, getControlFactory },
} = pluginServices.getServices();
const dataView = await dataViews.get(dataViewId);

const newFieldRegistry: DataControlFieldRegistry = doubleLinkFields(dataView);

const controlFactories = getControlTypes().map(
(controlType) => getControlFactory(controlType) as IEditableControlFactory
);
dataView.fields.map((dataViewField) => {
for (const factory of controlFactories) {
if (factory.isFieldCompatible) {
factory.isFieldCompatible(newFieldRegistry[dataViewField.name]);
}
}

if (newFieldRegistry[dataViewField.name]?.compatibleControlTypes.length === 0) {
delete newFieldRegistry[dataViewField.name];
}
});
dataControlFieldRegistryCache[dataViewId] = newFieldRegistry;

return newFieldRegistry;
};
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import { skip, debounceTime, distinctUntilChanged } from 'rxjs/operators';
import React from 'react';
import ReactDOM from 'react-dom';
import { Filter, uniqFilters } from '@kbn/es-query';
import { compareFilters, COMPARE_ALL_OPTIONS, Filter, uniqFilters } from '@kbn/es-query';
import { BehaviorSubject, merge, Subject, Subscription } from 'rxjs';
import { EuiContextMenuPanel } from '@elastic/eui';

Expand Down Expand Up @@ -39,10 +39,11 @@ import { ControlGroupStrings } from '../control_group_strings';
import { EditControlGroup } from '../editor/edit_control_group';
import { ControlGroup } from '../component/control_group_component';
import { controlGroupReducers } from '../state/control_group_reducers';
import { ControlEmbeddable, ControlInput, ControlOutput } from '../../types';
import { ControlEmbeddable, ControlInput, ControlOutput, DataControlInput } from '../../types';
import { CreateControlButton, CreateControlButtonTypes } from '../editor/create_control';
import { CreateTimeSliderControlButton } from '../editor/create_time_slider_control';
import { TIME_SLIDER_CONTROL } from '../../time_slider';
import { loadFieldRegistryFromDataViewId } from '../editor/data_control_editor_tools';

let flyoutRef: OverlayRef | undefined;
export const setFlyoutRef = (newRef: OverlayRef | undefined) => {
Expand Down Expand Up @@ -70,6 +71,9 @@ export class ControlGroupContainer extends Container<
typeof controlGroupReducers
>;

public onFiltersPublished$: Subject<Filter[]>;
public onControlRemoved$: Subject<string>;

public setLastUsedDataViewId = (lastUsedDataViewId: string) => {
this.lastUsedDataViewId = lastUsedDataViewId;
};
Expand All @@ -87,6 +91,27 @@ export class ControlGroupContainer extends Container<
flyoutRef = undefined;
}

public async addDataControlFromField({
uuid,
dataViewId,
fieldName,
title,
}: {
uuid?: string;
dataViewId: string;
fieldName: string;
title?: string;
}) {
const fieldRegistry = await loadFieldRegistryFromDataViewId(dataViewId);
const field = fieldRegistry[fieldName];
return this.addNewEmbeddable(field.compatibleControlTypes[0], {
id: uuid,
dataViewId,
fieldName,
title: title ?? fieldName,
} as DataControlInput);
}

/**
* Returns a button that allows controls to be created externally using the embeddable
* @param buttonType Controls the button styling
Expand Down Expand Up @@ -185,6 +210,8 @@ export class ControlGroupContainer extends Container<
);

this.recalculateFilters$ = new Subject();
this.onFiltersPublished$ = new Subject<Filter[]>();
this.onControlRemoved$ = new Subject<string>();

// build redux embeddable tools
this.reduxEmbeddableTools = reduxEmbeddablePackage.createTools<
Expand Down Expand Up @@ -249,6 +276,10 @@ export class ControlGroupContainer extends Container<
return Object.keys(this.getInput().panels).length;
};

public updateFilterContext = (filters: Filter[]) => {
this.updateInput({ filters });
};

private recalculateFilters = () => {
const allFilters: Filter[] = [];
let timeslice;
Expand All @@ -259,7 +290,11 @@ export class ControlGroupContainer extends Container<
timeslice = childOutput.timeslice;
}
});
this.updateOutput({ filters: uniqFilters(allFilters), timeslice });
// if filters are different, publish them
if (!compareFilters(this.output.filters ?? [], allFilters ?? [], COMPARE_ALL_OPTIONS)) {
this.updateOutput({ filters: uniqFilters(allFilters), timeslice });
this.onFiltersPublished$.next(allFilters);
}
};

private recalculateDataViews = () => {
Expand Down Expand Up @@ -304,6 +339,7 @@ export class ControlGroupContainer extends Container<
order: currentOrder - 1,
};
}
this.onControlRemoved$.next(idToRemove);
return newPanels;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@
* Side Public License, v 1.
*/

import { i18n } from '@kbn/i18n';
import { Container, EmbeddableFactoryDefinition } from '@kbn/embeddable-plugin/public';
import { lazyLoadReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public';
import { EmbeddablePersistableStateService } from '@kbn/embeddable-plugin/common';

import { ControlGroupInput, CONTROL_GROUP_TYPE } from '../types';
import { ControlGroupStrings } from '../control_group_strings';
import {
createControlGroupExtract,
createControlGroupInject,
Expand All @@ -40,7 +40,9 @@ export class ControlGroupContainerFactory implements EmbeddableFactoryDefinition
public isEditable = async () => false;

public readonly getDisplayName = () => {
return ControlGroupStrings.getEmbeddableTitle();
return i18n.translate('controls.controlGroup.title', {
defaultMessage: 'Control group',
});
};

public getDefaultInput(): Partial<ControlGroupInput> {
Expand Down
5 changes: 5 additions & 0 deletions src/plugins/controls/public/control_group/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@
* Side Public License, v 1.
*/

import React from 'react';

export type { ControlGroupContainer } from './embeddable/control_group_container';
export type { ControlGroupInput, ControlGroupOutput } from './types';

export { CONTROL_GROUP_TYPE } from './types';
export { ControlGroupContainerFactory } from './embeddable/control_group_container_factory';

export type { ControlGroupRendererProps } from './control_group_renderer';
export const LazyControlGroupRenderer = React.lazy(() => import('./control_group_renderer'));
Loading

0 comments on commit c8a0376

Please sign in to comment.