kibana/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx
Marco Liberati bd908c6ba3
[Lens] Integrate searchSessionId into Lens app (#86297)
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
2020-12-29 14:18:30 +01:00

494 lines
15 KiB
TypeScript

/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import './suggestion_panel.scss';
import _, { camelCase } from 'lodash';
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiIcon,
EuiTitle,
EuiPanel,
EuiIconTip,
EuiToolTip,
EuiFlexGroup,
EuiFlexItem,
EuiButtonEmpty,
} from '@elastic/eui';
import { IconType } from '@elastic/eui/src/components/icon/icon';
import { Ast, toExpression } from '@kbn/interpreter/common';
import { i18n } from '@kbn/i18n';
import classNames from 'classnames';
import { DataPublicPluginStart, ExecutionContextSearch } from 'src/plugins/data/public';
import { Action, PreviewState } from './state_management';
import { Datasource, Visualization, FramePublicAPI, DatasourcePublicAPI } from '../../types';
import { getSuggestions, switchToSuggestion } from './suggestion_helpers';
import {
ReactExpressionRendererProps,
ReactExpressionRendererType,
} from '../../../../../../src/plugins/expressions/public';
import { prependDatasourceExpression } from './expression_helpers';
import { trackUiEvent, trackSuggestionEvent } from '../../lens_ui_telemetry';
import { validateDatasourceAndVisualization } from './state_helpers';
const MAX_SUGGESTIONS_DISPLAYED = 5;
export interface SuggestionPanelProps {
activeDatasourceId: string | null;
datasourceMap: Record<string, Datasource>;
datasourceStates: Record<
string,
{
isLoading: boolean;
state: unknown;
}
>;
activeVisualizationId: string | null;
visualizationMap: Record<string, Visualization>;
visualizationState: unknown;
dispatch: (action: Action) => void;
ExpressionRenderer: ReactExpressionRendererType;
frame: FramePublicAPI;
stagedPreview?: PreviewState;
plugins: { data: DataPublicPluginStart };
}
const PreviewRenderer = ({
withLabel,
ExpressionRendererComponent,
expression,
hasError,
}: {
withLabel: boolean;
expression: string | null | undefined;
ExpressionRendererComponent: ReactExpressionRendererType;
hasError: boolean;
}) => {
const onErrorMessage = (
<div className="lnsSuggestionPanel__suggestionIcon">
<EuiIconTip
size="xl"
color="danger"
type="alert"
aria-label={i18n.translate('xpack.lens.editorFrame.previewErrorLabel', {
defaultMessage: 'Preview rendering failed',
})}
content={i18n.translate('xpack.lens.editorFrame.previewErrorLabel', {
defaultMessage: 'Preview rendering failed',
})}
/>
</div>
);
return (
<div
className={classNames('lnsSuggestionPanel__chartWrapper', {
// eslint-disable-next-line @typescript-eslint/naming-convention
'lnsSuggestionPanel__chartWrapper--withLabel': withLabel,
})}
>
{!expression || hasError ? (
onErrorMessage
) : (
<ExpressionRendererComponent
className="lnsSuggestionPanel__expressionRenderer"
padding="s"
expression={expression}
debounce={2000}
renderError={() => {
return onErrorMessage;
}}
/>
)}
</div>
);
};
const SuggestionPreview = ({
preview,
ExpressionRenderer: ExpressionRendererComponent,
selected,
onSelect,
showTitleAsLabel,
}: {
onSelect: () => void;
preview: {
expression?: Ast | null;
icon: IconType;
title: string;
error?: boolean;
};
ExpressionRenderer: ReactExpressionRendererType;
selected: boolean;
showTitleAsLabel?: boolean;
}) => {
return (
<EuiToolTip content={preview.title}>
<div data-test-subj={`lnsSuggestion-${camelCase(preview.title)}`}>
<EuiPanel
className={classNames('lnsSuggestionPanel__button', {
// eslint-disable-next-line @typescript-eslint/naming-convention
'lnsSuggestionPanel__button-isSelected': selected,
})}
paddingSize="none"
data-test-subj="lnsSuggestion"
onClick={onSelect}
aria-current={!!selected}
aria-label={preview.title}
>
{preview.expression || preview.error ? (
<PreviewRenderer
ExpressionRendererComponent={ExpressionRendererComponent}
expression={preview.expression && toExpression(preview.expression)}
withLabel={Boolean(showTitleAsLabel)}
hasError={Boolean(preview.error)}
/>
) : (
<span className="lnsSuggestionPanel__suggestionIcon">
<EuiIcon size="xxl" type={preview.icon} />
</span>
)}
{showTitleAsLabel && (
<span className="lnsSuggestionPanel__buttonLabel">{preview.title}</span>
)}
</EuiPanel>
</div>
</EuiToolTip>
);
};
export function SuggestionPanel({
activeDatasourceId,
datasourceMap,
datasourceStates,
activeVisualizationId,
visualizationMap,
visualizationState,
dispatch,
frame,
ExpressionRenderer: ExpressionRendererComponent,
stagedPreview,
plugins,
}: SuggestionPanelProps) {
const currentDatasourceStates = stagedPreview ? stagedPreview.datasourceStates : datasourceStates;
const currentVisualizationState = stagedPreview
? stagedPreview.visualization.state
: visualizationState;
const currentVisualizationId = stagedPreview
? stagedPreview.visualization.activeId
: activeVisualizationId;
const { suggestions, currentStateExpression, currentStateError } = useMemo(
() => {
const newSuggestions = getSuggestions({
datasourceMap,
datasourceStates: currentDatasourceStates,
visualizationMap,
activeVisualizationId: currentVisualizationId,
visualizationState: currentVisualizationState,
activeData: frame.activeData,
})
.filter((suggestion) => !suggestion.hide)
.filter(
({
visualizationId,
visualizationState: suggestionVisualizationState,
datasourceState: suggestionDatasourceState,
datasourceId: suggetionDatasourceId,
}) => {
return (
validateDatasourceAndVisualization(
suggetionDatasourceId ? datasourceMap[suggetionDatasourceId] : null,
suggestionDatasourceState,
visualizationMap[visualizationId],
suggestionVisualizationState,
frame
) == null
);
}
)
.slice(0, MAX_SUGGESTIONS_DISPLAYED)
.map((suggestion) => ({
...suggestion,
previewExpression: preparePreviewExpression(
suggestion,
visualizationMap[suggestion.visualizationId],
datasourceMap,
currentDatasourceStates,
frame
),
}));
const validationErrors = validateDatasourceAndVisualization(
activeDatasourceId ? datasourceMap[activeDatasourceId] : null,
activeDatasourceId && currentDatasourceStates[activeDatasourceId]?.state,
currentVisualizationId ? visualizationMap[currentVisualizationId] : null,
currentVisualizationState,
frame
);
const newStateExpression =
currentVisualizationState && currentVisualizationId && !validationErrors
? preparePreviewExpression(
{ visualizationState: currentVisualizationState },
visualizationMap[currentVisualizationId],
datasourceMap,
currentDatasourceStates,
frame
)
: undefined;
return {
suggestions: newSuggestions,
currentStateExpression: newStateExpression,
currentStateError: validationErrors,
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
currentDatasourceStates,
currentVisualizationState,
currentVisualizationId,
activeDatasourceId,
datasourceMap,
visualizationMap,
]
);
const context: ExecutionContextSearch = useMemo(
() => ({
query: frame.query,
timeRange: {
from: frame.dateRange.fromDate,
to: frame.dateRange.toDate,
},
filters: frame.filters,
}),
[frame.query, frame.dateRange.fromDate, frame.dateRange.toDate, frame.filters]
);
const contextRef = useRef<ExecutionContextSearch>(context);
contextRef.current = context;
const sessionIdRef = useRef<string>(frame.searchSessionId);
sessionIdRef.current = frame.searchSessionId;
const AutoRefreshExpressionRenderer = useMemo(() => {
return (props: ReactExpressionRendererProps) => (
<ExpressionRendererComponent
{...props}
searchContext={contextRef.current}
searchSessionId={sessionIdRef.current}
/>
);
}, [ExpressionRendererComponent]);
const [lastSelectedSuggestion, setLastSelectedSuggestion] = useState<number>(-1);
useEffect(() => {
// if the staged preview is overwritten by a suggestion,
// reset the selected index to "current visualization" because
// we are not in transient suggestion state anymore
if (!stagedPreview && lastSelectedSuggestion !== -1) {
setLastSelectedSuggestion(-1);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stagedPreview]);
if (!activeDatasourceId) {
return null;
}
if (suggestions.length === 0) {
return null;
}
function rollbackToCurrentVisualization() {
if (lastSelectedSuggestion !== -1) {
trackSuggestionEvent('back_to_current');
setLastSelectedSuggestion(-1);
dispatch({
type: 'ROLLBACK_SUGGESTION',
});
}
}
return (
<div className="lnsSuggestionPanel">
<EuiFlexGroup alignItems="center">
<EuiFlexItem>
<EuiTitle className="lnsSuggestionPanel__title" size="xxs">
<h3>
<FormattedMessage
id="xpack.lens.editorFrame.suggestionPanelTitle"
defaultMessage="Suggestions"
/>
</h3>
</EuiTitle>
</EuiFlexItem>
{stagedPreview && (
<EuiFlexItem grow={false}>
<EuiToolTip
content={i18n.translate('xpack.lens.suggestion.refreshSuggestionTooltip', {
defaultMessage: 'Refresh the suggestions based on the selected visualization.',
})}
>
<EuiButtonEmpty
data-test-subj="lensSubmitSuggestion"
size="xs"
iconType="refresh"
onClick={() => {
trackUiEvent('suggestion_confirmed');
dispatch({
type: 'SUBMIT_SUGGESTION',
});
}}
>
{i18n.translate('xpack.lens.sugegstion.refreshSuggestionLabel', {
defaultMessage: 'Refresh',
})}
</EuiButtonEmpty>
</EuiToolTip>
</EuiFlexItem>
)}
</EuiFlexGroup>
<div className="lnsSuggestionPanel__suggestions">
{currentVisualizationId && (
<SuggestionPreview
preview={{
error: currentStateError != null,
expression: currentStateExpression,
icon:
visualizationMap[currentVisualizationId].getDescription(currentVisualizationState)
.icon || 'empty',
title: i18n.translate('xpack.lens.suggestions.currentVisLabel', {
defaultMessage: 'Current visualization',
}),
}}
ExpressionRenderer={AutoRefreshExpressionRenderer}
onSelect={rollbackToCurrentVisualization}
selected={lastSelectedSuggestion === -1}
showTitleAsLabel
/>
)}
{suggestions.map((suggestion, index) => {
return (
<SuggestionPreview
preview={{
expression: suggestion.previewExpression,
icon: suggestion.previewIcon,
title: suggestion.title,
}}
ExpressionRenderer={AutoRefreshExpressionRenderer}
key={index}
onSelect={() => {
trackUiEvent('suggestion_clicked');
if (lastSelectedSuggestion === index) {
rollbackToCurrentVisualization();
} else {
setLastSelectedSuggestion(index);
switchToSuggestion(dispatch, suggestion);
}
}}
selected={index === lastSelectedSuggestion}
/>
);
})}
</div>
</div>
);
}
interface VisualizableState {
visualizationState: unknown;
datasourceState?: unknown;
datasourceId?: string;
keptLayerIds?: string[];
}
function getPreviewExpression(
visualizableState: VisualizableState,
visualization: Visualization,
datasources: Record<string, Datasource>,
frame: FramePublicAPI
) {
if (!visualization.toPreviewExpression) {
return null;
}
const suggestionFrameApi: FramePublicAPI = {
...frame,
datasourceLayers: { ...frame.datasourceLayers },
};
// use current frame api and patch apis for changed datasource layers
if (
visualizableState.keptLayerIds &&
visualizableState.datasourceId &&
visualizableState.datasourceState
) {
const datasource = datasources[visualizableState.datasourceId];
const datasourceState = visualizableState.datasourceState;
const updatedLayerApis: Record<string, DatasourcePublicAPI> = _.pick(
frame.datasourceLayers,
visualizableState.keptLayerIds
);
const changedLayers = datasource.getLayers(visualizableState.datasourceState);
changedLayers.forEach((layerId) => {
if (updatedLayerApis[layerId]) {
updatedLayerApis[layerId] = datasource.getPublicAPI({
layerId,
state: datasourceState,
});
}
});
}
return visualization.toPreviewExpression(
visualizableState.visualizationState,
suggestionFrameApi.datasourceLayers
);
}
function preparePreviewExpression(
visualizableState: VisualizableState,
visualization: Visualization,
datasourceMap: Record<string, Datasource<unknown, unknown>>,
datasourceStates: Record<string, { isLoading: boolean; state: unknown }>,
framePublicAPI: FramePublicAPI
) {
const suggestionDatasourceId = visualizableState.datasourceId;
const suggestionDatasourceState = visualizableState.datasourceState;
const expression = getPreviewExpression(
visualizableState,
visualization,
datasourceMap,
framePublicAPI
);
if (!expression) {
return;
}
const expressionWithDatasource = prependDatasourceExpression(
expression,
datasourceMap,
suggestionDatasourceId
? {
...datasourceStates,
[suggestionDatasourceId]: {
isLoading: false,
state: suggestionDatasourceState,
},
}
: datasourceStates
);
return expressionWithDatasource;
}