[Text based] Configure Lens suggestion on the fly from Discover (#159559)

## Summary

Part of https://github.com/elastic/kibana/issues/158802

This PR removes the navigation from Discover to Lens and renders a push
flyout instead.


![textbased](92c6f290-6cf9-4daa-920e-f1409595d765)


Next tasks (follow-up PRs):

- [ ] Remove the text based support from Lens dataview picker. The FTs
should be removed from there and possibly moved to discover FTs
- [ ] Apply the same flyout in dashboard for text based panels
- [ ] Allow drag and drop between dimensions
- [ ] Investigate why the Field select doesnt close when you click
outside the dropdown

### Checklist

- [ ] 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)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [ ] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [ ] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [ ] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Andrea Del Rio <delrio.andre@gmail.com>
This commit is contained in:
Stratoula Kalafateli 2023-07-03 15:18:05 +03:00 committed by GitHub
parent 90b3e712cb
commit e8b2303875
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 1477 additions and 92 deletions

View file

@ -0,0 +1,40 @@
/*
* 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 type { Datatable } from '@kbn/expressions-plugin/common';
export const lensTablesAdapterMock: Record<string, Datatable> = {
default: {
columns: [
{
id: 'col-0-1',
meta: {
dimensionName: 'Slice size',
type: 'number',
},
name: 'Field 1',
},
{
id: 'col-0-2',
meta: {
dimensionName: 'Slice',
type: 'number',
},
name: 'Field 2',
},
],
rows: [
{
'col-0-1': 0,
'col-0-2': 0,
'col-0-3': 0,
'col-0-4': 0,
},
],
type: 'datatable',
},
};

View file

@ -5,7 +5,7 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { expressionsPluginMock } from '@kbn/expressions-plugin/public/mocks';
import { fieldFormatsMock } from '@kbn/field-formats-plugin/common/mocks';
@ -33,6 +33,7 @@ export const unifiedHistogramServicesMock = {
suggestions: jest.fn(() => allSuggestionsMock),
};
}),
EditLensConfigPanelApi: jest.fn().mockResolvedValue(<span>Lens Config Panel Component</span>),
},
storage: {
get: jest.fn(),

View file

@ -233,6 +233,17 @@ describe('Chart', () => {
expect(component.find(SuggestionSelector).exists()).toBeTruthy();
});
it('should render the edit on the fly button when chart is visible and suggestions exist', async () => {
const component = await mountComponent({
currentSuggestion: currentSuggestionMock,
allSuggestions: allSuggestionsMock,
isPlainRecord: true,
});
expect(
component.find('[data-test-subj="unifiedHistogramEditFlyoutVisualization"]').exists()
).toBeTruthy();
});
it('should render the save button when chart is visible and suggestions exist', async () => {
const component = await mountComponent({
currentSuggestion: currentSuggestionMock,

View file

@ -6,8 +6,7 @@
* Side Public License, v 1.
*/
import { ReactElement, useMemo, useState } from 'react';
import React, { memo } from 'react';
import React, { ReactElement, useMemo, useState, useEffect, useCallback, memo } from 'react';
import {
EuiButtonIcon,
EuiContextMenu,
@ -18,6 +17,7 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { Suggestion } from '@kbn/lens-plugin/public';
import type { Datatable } from '@kbn/expressions-plugin/common';
import { DataView, DataViewField, DataViewType } from '@kbn/data-views-plugin/public';
import type { LensEmbeddableInput } from '@kbn/lens-plugin/public';
import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
@ -42,6 +42,7 @@ import { useTotalHits } from './hooks/use_total_hits';
import { useRequestParams } from './hooks/use_request_params';
import { useChartStyles } from './hooks/use_chart_styles';
import { useChartActions } from './hooks/use_chart_actions';
import { useChartConfigPanel } from './hooks/use_chart_config_panel';
import { getLensAttributes } from './utils/get_lens_attributes';
import { useRefetch } from './hooks/use_refetch';
import { useEditVisualization } from './hooks/use_edit_visualization';
@ -67,6 +68,7 @@ export interface ChartProps {
disableTriggers?: LensEmbeddableInput['disableTriggers'];
disabledActions?: LensEmbeddableInput['disabledActions'];
input$?: UnifiedHistogramInput$;
lensTablesAdapter?: Record<string, Datatable>;
onResetChartHeight?: () => void;
onChartHiddenChange?: (chartHidden: boolean) => void;
onTimeIntervalChange?: (timeInterval: string) => void;
@ -101,6 +103,7 @@ export function Chart({
disableTriggers,
disabledActions,
input$: originalInput$,
lensTablesAdapter,
onResetChartHeight,
onChartHiddenChange,
onTimeIntervalChange,
@ -112,6 +115,7 @@ export function Chart({
onBrushEnd,
}: ChartProps) {
const [isSaveModalVisible, setIsSaveModalVisible] = useState(false);
const [isFlyoutVisible, setIsFlyoutVisible] = useState(false);
const {
showChartOptionsPopover,
chartRef,
@ -190,6 +194,7 @@ export function Chart({
histogramCss,
breakdownFieldSelectorGroupCss,
breakdownFieldSelectorItemCss,
suggestionsSelectorItemCss,
chartToolButtonCss,
} = useChartStyles(chartVisible);
@ -215,6 +220,34 @@ export function Chart({
]
);
const ChartConfigPanel = useChartConfigPanel({
services,
lensAttributesContext,
dataView,
lensTablesAdapter,
currentSuggestion,
isFlyoutVisible,
setIsFlyoutVisible,
isPlainRecord,
query: originalQuery,
onSuggestionChange,
});
const onSuggestionSelectorChange = useCallback(
(s: Suggestion | undefined) => {
onSuggestionChange?.(s);
},
[onSuggestionChange]
);
useEffect(() => {
// close the flyout for dataview mode
// or if no chart is visible
if (!chartVisible && isFlyoutVisible) {
setIsFlyoutVisible(false);
}
}, [chartVisible, isFlyoutVisible]);
const onEditVisualization = useEditVisualization({
services,
dataView,
@ -226,6 +259,24 @@ export function Chart({
const canSaveVisualization =
chartVisible && currentSuggestion && services.capabilities.dashboard?.showWriteControls;
const renderEditButton = useMemo(
() => (
<EuiButtonIcon
size="xs"
iconType="pencil"
onClick={() => setIsFlyoutVisible(true)}
data-test-subj="unifiedHistogramEditFlyoutVisualization"
aria-label={i18n.translate('unifiedHistogram.editVisualizationButton', {
defaultMessage: 'Edit visualization',
})}
disabled={isFlyoutVisible}
/>
),
[isFlyoutVisible]
);
const canEditVisualizationOnTheFly = isPlainRecord && chartVisible;
return (
<EuiFlexGroup
className={className}
@ -237,6 +288,7 @@ export function Chart({
<EuiFlexItem grow={false} css={resultCountCss}>
<EuiFlexGroup
justifyContent="spaceBetween"
alignItems="center"
gutterSize="none"
responsive={false}
css={resultCountInnerCss}
@ -267,11 +319,11 @@ export function Chart({
</EuiFlexItem>
)}
{chartVisible && currentSuggestion && allSuggestions && allSuggestions?.length > 1 && (
<EuiFlexItem css={breakdownFieldSelectorItemCss}>
<EuiFlexItem css={suggestionsSelectorItemCss}>
<SuggestionSelector
suggestions={allSuggestions}
activeSuggestion={currentSuggestion}
onSuggestionChange={onSuggestionChange}
onSuggestionChange={onSuggestionSelectorChange}
/>
</EuiFlexItem>
)}
@ -296,6 +348,21 @@ export function Chart({
</EuiFlexItem>
</>
)}
{canEditVisualizationOnTheFly && (
<EuiFlexItem grow={false} css={chartToolButtonCss}>
{!isFlyoutVisible ? (
<EuiToolTip
content={i18n.translate('unifiedHistogram.editVisualizationButton', {
defaultMessage: 'Edit visualization',
})}
>
{renderEditButton}
</EuiToolTip>
) : (
renderEditButton
)}
</EuiFlexItem>
)}
{onEditVisualization && (
<EuiFlexItem grow={false} css={chartToolButtonCss}>
<EuiToolTip
@ -387,6 +454,7 @@ export function Chart({
isSaveable={false}
/>
)}
{isFlyoutVisible && ChartConfigPanel}
</EuiFlexGroup>
);
}

View file

@ -146,6 +146,7 @@ export function Histogram({
const chartCss = css`
position: relative;
flex-grow: 1;
margin-block: ${euiTheme.size.xs};
& > div {
height: 100%;

View file

@ -0,0 +1,66 @@
/*
* 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 type { TypedLensByValueInput } from '@kbn/lens-plugin/public';
import { renderHook } from '@testing-library/react-hooks';
import { act } from 'react-test-renderer';
import { setTimeout } from 'timers/promises';
import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield';
import { unifiedHistogramServicesMock } from '../../__mocks__/services';
import { lensTablesAdapterMock } from '../../__mocks__/lens_table_adapter';
import { useChartConfigPanel } from './use_chart_config_panel';
import type { LensAttributesContext } from '../utils/get_lens_attributes';
describe('useChartConfigPanel', () => {
it('should return a jsx element to edit the visualization', async () => {
const lensAttributes = {
visualizationType: 'lnsXY',
title: 'test',
} as TypedLensByValueInput['attributes'];
const hook = renderHook(() =>
useChartConfigPanel({
services: unifiedHistogramServicesMock,
dataView: dataViewWithTimefieldMock,
lensAttributesContext: {
attributes: lensAttributes,
} as unknown as LensAttributesContext,
isFlyoutVisible: true,
setIsFlyoutVisible: jest.fn(),
isPlainRecord: true,
lensTablesAdapter: lensTablesAdapterMock,
query: {
sql: 'Select * from test',
},
})
);
await act(() => setTimeout(0));
expect(hook.result.current).toBeDefined();
expect(hook.result.current).not.toBeNull();
});
it('should return null if not in text based mode', async () => {
const lensAttributes = {
visualizationType: 'lnsXY',
title: 'test',
} as TypedLensByValueInput['attributes'];
const hook = renderHook(() =>
useChartConfigPanel({
services: unifiedHistogramServicesMock,
dataView: dataViewWithTimefieldMock,
lensAttributesContext: {
attributes: lensAttributes,
} as unknown as LensAttributesContext,
isFlyoutVisible: true,
setIsFlyoutVisible: jest.fn(),
isPlainRecord: false,
})
);
await act(() => setTimeout(0));
expect(hook.result.current).toBeNull();
});
});

View file

@ -0,0 +1,101 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type { AggregateQuery, Query } from '@kbn/es-query';
import { isEqual } from 'lodash';
import type { Suggestion } from '@kbn/lens-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
import type { Datatable } from '@kbn/expressions-plugin/common';
import type { UnifiedHistogramServices } from '../../types';
import type { LensAttributesContext } from '../utils/get_lens_attributes';
export function useChartConfigPanel({
services,
lensAttributesContext,
dataView,
lensTablesAdapter,
currentSuggestion,
isFlyoutVisible,
setIsFlyoutVisible,
isPlainRecord,
query,
onSuggestionChange,
}: {
services: UnifiedHistogramServices;
lensAttributesContext: LensAttributesContext;
dataView: DataView;
isFlyoutVisible: boolean;
setIsFlyoutVisible: (flag: boolean) => void;
lensTablesAdapter?: Record<string, Datatable>;
currentSuggestion?: Suggestion;
isPlainRecord?: boolean;
query?: Query | AggregateQuery;
onSuggestionChange?: (suggestion: Suggestion | undefined) => void;
}) {
const [editLensConfigPanel, setEditLensConfigPanel] = useState<JSX.Element | null>(null);
const previousSuggestion = useRef<Suggestion | undefined>(undefined);
const previousAdapters = useRef<Record<string, Datatable> | undefined>(undefined);
const previousQuery = useRef<Query | AggregateQuery | undefined>(undefined);
const updateSuggestion = useCallback(
(datasourceState, visualizationState) => {
const updatedSuggestion = {
...currentSuggestion,
...(datasourceState && { datasourceState }),
...(visualizationState && { visualizationState }),
} as Suggestion;
onSuggestionChange?.(updatedSuggestion);
},
[currentSuggestion, onSuggestionChange]
);
useEffect(() => {
const dataHasChanged =
Boolean(lensTablesAdapter) &&
!isEqual(previousAdapters.current, lensTablesAdapter) &&
query !== previousQuery?.current;
async function fetchLensConfigComponent() {
const Component = await services.lens.EditLensConfigPanelApi();
const panel = (
<Component
attributes={lensAttributesContext.attributes}
dataView={dataView}
adaptersTables={lensTablesAdapter}
updateAll={updateSuggestion}
setIsFlyoutVisible={setIsFlyoutVisible}
datasourceId="textBased"
/>
);
setEditLensConfigPanel(panel);
previousSuggestion.current = currentSuggestion;
previousAdapters.current = lensTablesAdapter;
if (dataHasChanged) {
previousQuery.current = query;
}
}
const suggestionHasChanged = currentSuggestion?.title !== previousSuggestion?.current?.title;
// rerender the component if the data has changed or the suggestion
// as I can have different suggestions for the same data
if (isPlainRecord && (dataHasChanged || suggestionHasChanged || !isFlyoutVisible)) {
fetchLensConfigComponent();
}
}, [
lensAttributesContext.attributes,
services.lens,
dataView,
updateSuggestion,
isPlainRecord,
currentSuggestion,
query,
isFlyoutVisible,
lensTablesAdapter,
setIsFlyoutVisible,
]);
return isPlainRecord ? editLensConfigPanel : null;
}

View file

@ -56,6 +56,11 @@ export const useChartStyles = (chartVisible: boolean) => {
align-items: flex-end;
padding-left: ${euiTheme.size.s};
`;
const suggestionsSelectorItemCss = css`
min-width: 0;
align-items: flex-start;
padding-left: ${euiTheme.size.s};
`;
const chartToolButtonCss = css`
display: flex;
justify-content: center;
@ -70,6 +75,7 @@ export const useChartStyles = (chartVisible: boolean) => {
histogramCss,
breakdownFieldSelectorGroupCss,
breakdownFieldSelectorItemCss,
suggestionsSelectorItemCss,
chartToolButtonCss,
};
};

View file

@ -81,6 +81,21 @@ describe('useEditVisualization', () => {
expect(hook.result.current).toBeUndefined();
});
it('should return undefined if is on text based mode', async () => {
getTriggerCompatibleActions.mockReturnValue(Promise.resolve([{ id: 'test' }]));
const hook = renderHook(() =>
useEditVisualization({
services: unifiedHistogramServicesMock,
dataView: dataViewWithTimefieldMock,
relativeTimeRange: { from: 'now-15m', to: 'now' },
lensAttributes: {} as unknown as TypedLensByValueInput['attributes'],
isPlainRecord: true,
})
);
await act(() => setTimeout(0));
expect(hook.result.current).toBeUndefined();
});
it('should return undefined if the time field is not visualizable', async () => {
getTriggerCompatibleActions.mockReturnValue(Promise.resolve([{ id: 'test' }]));
const dataView = {

View file

@ -32,10 +32,10 @@ export const useEditVisualization = ({
const [canVisualize, setCanVisualize] = useState(false);
const checkCanVisualize = useCallback(async () => {
if (!dataView.id) {
if (!dataView.id || isPlainRecord) {
return false;
}
if (!isPlainRecord && (!dataView.isTimeBased() || !dataView.getTimeField().visualizable)) {
if (!dataView.isTimeBased() || !dataView.getTimeField().visualizable) {
return false;
}

View file

@ -67,7 +67,7 @@ export const SuggestionSelector = ({
const { euiTheme } = useEuiTheme();
const suggestionComboCss = css`
width: 100%;
max-width: ${euiTheme.base * 22}px;
max-width: ${euiTheme.base * 15}px;
`;
return (
@ -78,9 +78,7 @@ export const SuggestionSelector = ({
>
<EuiComboBox
data-test-subj="unifiedHistogramSuggestionSelector"
prepend={i18n.translate('unifiedHistogram.suggestionSelectorLabel', {
defaultMessage: 'Visualization',
})}
prepend={<EuiIcon type={activeSuggestion?.previewIcon ?? 'empty'} />}
placeholder={i18n.translate('unifiedHistogram.suggestionSelectorPlaceholder', {
defaultMessage: 'Select visualization',
})}
@ -88,9 +86,9 @@ export const SuggestionSelector = ({
options={suggestionOptions}
selectedOptions={selectedSuggestion}
onChange={onSelectionChange}
compressed
fullWidth={true}
isClearable={false}
compressed
onFocus={disableFieldPopover}
onBlur={enableFieldPopover}
renderOption={(option) => {

View file

@ -15,6 +15,7 @@ import { UnifiedHistogramFetchStatus } from '../../types';
import { dataViewMock } from '../../__mocks__/data_view';
import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield';
import { currentSuggestionMock } from '../../__mocks__/suggestions';
import { lensTablesAdapterMock } from '../../__mocks__/lens_table_adapter';
import { unifiedHistogramServicesMock } from '../../__mocks__/services';
import {
createStateService,
@ -28,6 +29,7 @@ describe('useStateProps', () => {
breakdownField: 'bytes',
chartHidden: false,
lensRequestAdapter: new RequestAdapter(),
lensTablesAdapter: lensTablesAdapterMock,
timeInterval: 'auto',
topPanelHeight: 100,
totalHitsStatus: UnifiedHistogramFetchStatus.uninitialized,
@ -82,6 +84,37 @@ describe('useStateProps', () => {
"total": undefined,
},
"isPlainRecord": false,
"lensTablesAdapter": Object {
"default": Object {
"columns": Array [
Object {
"id": "col-0-1",
"meta": Object {
"dimensionName": "Slice size",
"type": "number",
},
"name": "Field 1",
},
Object {
"id": "col-0-2",
"meta": Object {
"dimensionName": "Slice",
"type": "number",
},
"name": "Field 2",
},
],
"rows": Array [
Object {
"col-0-1": 0,
"col-0-2": 0,
"col-0-3": 0,
"col-0-4": 0,
},
],
"type": "datatable",
},
},
"onBreakdownFieldChange": [Function],
"onChartHiddenChange": [Function],
"onChartLoad": [Function],
@ -126,6 +159,37 @@ describe('useStateProps', () => {
"total": undefined,
},
"isPlainRecord": true,
"lensTablesAdapter": Object {
"default": Object {
"columns": Array [
Object {
"id": "col-0-1",
"meta": Object {
"dimensionName": "Slice size",
"type": "number",
},
"name": "Field 1",
},
Object {
"id": "col-0-2",
"meta": Object {
"dimensionName": "Slice",
"type": "number",
},
"name": "Field 2",
},
],
"rows": Array [
Object {
"col-0-1": 0,
"col-0-2": 0,
"col-0-3": 0,
"col-0-4": 0,
},
],
"type": "datatable",
},
},
"onBreakdownFieldChange": [Function],
"onChartHiddenChange": [Function],
"onChartLoad": [Function],
@ -191,6 +255,37 @@ describe('useStateProps', () => {
"total": undefined,
},
"isPlainRecord": false,
"lensTablesAdapter": Object {
"default": Object {
"columns": Array [
Object {
"id": "col-0-1",
"meta": Object {
"dimensionName": "Slice size",
"type": "number",
},
"name": "Field 1",
},
Object {
"id": "col-0-2",
"meta": Object {
"dimensionName": "Slice",
"type": "number",
},
"name": "Field 2",
},
],
"rows": Array [
Object {
"col-0-1": 0,
"col-0-2": 0,
"col-0-3": 0,
"col-0-4": 0,
},
],
"type": "datatable",
},
},
"onBreakdownFieldChange": [Function],
"onChartHiddenChange": [Function],
"onChartLoad": [Function],
@ -232,6 +327,37 @@ describe('useStateProps', () => {
"total": undefined,
},
"isPlainRecord": false,
"lensTablesAdapter": Object {
"default": Object {
"columns": Array [
Object {
"id": "col-0-1",
"meta": Object {
"dimensionName": "Slice size",
"type": "number",
},
"name": "Field 1",
},
Object {
"id": "col-0-2",
"meta": Object {
"dimensionName": "Slice",
"type": "number",
},
"name": "Field 2",
},
],
"rows": Array [
Object {
"col-0-1": 0,
"col-0-2": 0,
"col-0-3": 0,
"col-0-4": 0,
},
],
"type": "datatable",
},
},
"onBreakdownFieldChange": [Function],
"onChartHiddenChange": [Function],
"onChartLoad": [Function],

View file

@ -23,6 +23,7 @@ import {
timeIntervalSelector,
totalHitsResultSelector,
totalHitsStatusSelector,
lensTablesAdapterSelector,
} from '../utils/state_selectors';
import { useStateSelector } from '../utils/use_state_selector';
@ -44,7 +45,7 @@ export const useStateProps = ({
const timeInterval = useStateSelector(stateService?.state$, timeIntervalSelector);
const totalHitsResult = useStateSelector(stateService?.state$, totalHitsResultSelector);
const totalHitsStatus = useStateSelector(stateService?.state$, totalHitsStatusSelector);
const lensTablesAdapter = useStateSelector(stateService?.state$, lensTablesAdapterSelector);
/**
* Contexts
*/
@ -139,6 +140,7 @@ export const useStateProps = ({
(event: UnifiedHistogramChartLoadEvent) => {
// We need to store the Lens request adapter in order to inspect its requests
stateService?.setLensRequestAdapter(event.adapters.requests);
stateService?.setLensTablesAdapter(event.adapters.tables?.tables);
},
[stateService]
);
@ -174,6 +176,7 @@ export const useStateProps = ({
breakdown,
request,
isPlainRecord,
lensTablesAdapter,
onTopPanelHeightChange,
onTimeIntervalChange,
onTotalHitsChange,

View file

@ -9,6 +9,7 @@
import { RequestAdapter } from '@kbn/inspector-plugin/common';
import { UnifiedHistogramFetchStatus } from '../..';
import { unifiedHistogramServicesMock } from '../../__mocks__/services';
import { lensTablesAdapterMock } from '../../__mocks__/lens_table_adapter';
import {
getChartHidden,
getTopPanelHeight,
@ -46,6 +47,7 @@ describe('UnifiedHistogramStateService', () => {
breakdownField: 'bytes',
chartHidden: false,
lensRequestAdapter: new RequestAdapter(),
lensTablesAdapter: lensTablesAdapterMock,
timeInterval: 'auto',
topPanelHeight: 100,
totalHitsStatus: UnifiedHistogramFetchStatus.uninitialized,
@ -134,6 +136,8 @@ describe('UnifiedHistogramStateService', () => {
expect(state).toEqual(newState);
stateService.setLensRequestAdapter(undefined);
newState = { ...newState, lensRequestAdapter: undefined };
stateService.setLensTablesAdapter(undefined);
newState = { ...newState, lensTablesAdapter: undefined };
expect(state).toEqual(newState);
stateService.setTotalHits({
totalHitsStatus: UnifiedHistogramFetchStatus.complete,

View file

@ -8,6 +8,7 @@
import type { RequestAdapter } from '@kbn/inspector-plugin/common';
import type { Suggestion } from '@kbn/lens-plugin/public';
import type { Datatable } from '@kbn/expressions-plugin/common';
import { BehaviorSubject, Observable } from 'rxjs';
import { UnifiedHistogramFetchStatus } from '../..';
import type { UnifiedHistogramServices } from '../../types';
@ -40,6 +41,10 @@ export interface UnifiedHistogramState {
* The current Lens request adapter
*/
lensRequestAdapter: RequestAdapter | undefined;
/**
* The current Lens request table
*/
lensTablesAdapter?: Record<string, Datatable>;
/**
* The current time interval of the chart
*/
@ -108,6 +113,10 @@ export interface UnifiedHistogramStateService {
* Sets the current Lens request adapter
*/
setLensRequestAdapter: (lensRequestAdapter: RequestAdapter | undefined) => void;
/**
* Sets the current Lens tables
*/
setLensTablesAdapter: (lensTablesAdapter: Record<string, Datatable> | undefined) => void;
/**
* Sets the current total hits status and result
*/
@ -190,6 +199,10 @@ export const createStateService = (
updateState({ lensRequestAdapter });
},
setLensTablesAdapter: (lensTablesAdapter: Record<string, Datatable> | undefined) => {
updateState({ lensTablesAdapter });
},
setTotalHits: (totalHits: {
totalHitsStatus: UnifiedHistogramFetchStatus;
totalHitsResult: number | Error | undefined;

View file

@ -15,3 +15,4 @@ export const topPanelHeightSelector = (state: UnifiedHistogramState) => state.to
export const totalHitsResultSelector = (state: UnifiedHistogramState) => state.totalHitsResult;
export const totalHitsStatusSelector = (state: UnifiedHistogramState) => state.totalHitsStatus;
export const currentSuggestionSelector = (state: UnifiedHistogramState) => state.currentSuggestion;
export const lensTablesAdapterSelector = (state: UnifiedHistogramState) => state.lensTablesAdapter;

View file

@ -11,6 +11,7 @@ import { PropsWithChildren, ReactElement, RefObject } from 'react';
import React, { useMemo } from 'react';
import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal';
import { css } from '@emotion/css';
import type { Datatable } from '@kbn/expressions-plugin/common';
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
import type { LensEmbeddableInput, LensSuggestionsApi, Suggestion } from '@kbn/lens-plugin/public';
import { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
@ -77,6 +78,7 @@ export interface UnifiedHistogramLayoutProps extends PropsWithChildren<unknown>
* Context object for the hits count -- leave undefined to hide the hits count
*/
hits?: UnifiedHistogramHitsContext;
lensTablesAdapter?: Record<string, Datatable>;
/**
* Context object for the chart -- leave undefined to hide the chart
*/
@ -169,6 +171,7 @@ export const UnifiedHistogramLayout = ({
columns,
request,
hits,
lensTablesAdapter,
chart: originalChart,
breakdown,
resizeRef,
@ -273,6 +276,7 @@ export const UnifiedHistogramLayout = ({
onChartLoad={onChartLoad}
onFilter={onFilter}
onBrushEnd={onBrushEnd}
lensTablesAdapter={lensTablesAdapter}
/>
</InPortal>
<InPortal node={mainPanelNode}>{children}</InPortal>

View file

@ -0,0 +1,129 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect, useState } from 'react';
import { EuiFlyout, EuiLoadingSpinner, EuiOverlayMask } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Provider } from 'react-redux';
import { PreloadedState } from '@reduxjs/toolkit';
import { css } from '@emotion/react';
import type { CoreStart } from '@kbn/core/public';
import type { LensPluginStartDependencies } from '../../../plugin';
import {
makeConfigureStore,
LensRootStore,
LensAppState,
LensState,
} from '../../../state_management';
import { getPreloadedState } from '../../../state_management/lens_slice';
import type { DatasourceMap, VisualizationMap } from '../../../types';
import {
LensEditConfigurationFlyout,
type EditConfigPanelProps,
} from './lens_configuration_flyout';
import type { LensAppServices } from '../../types';
export type EditLensConfigurationProps = Omit<
EditConfigPanelProps,
'startDependencies' | 'coreStart' | 'visualizationMap' | 'datasourceMap'
>;
function LoadingSpinnerWithOverlay() {
return (
<EuiOverlayMask>
<EuiLoadingSpinner />
</EuiOverlayMask>
);
}
export function getEditLensConfiguration(
coreStart: CoreStart,
startDependencies: LensPluginStartDependencies,
visualizationMap?: VisualizationMap,
datasourceMap?: DatasourceMap
) {
return ({
attributes,
dataView,
updateAll,
setIsFlyoutVisible,
datasourceId,
adaptersTables,
}: EditLensConfigurationProps) => {
const [lensServices, setLensServices] = useState<LensAppServices>();
useEffect(() => {
async function loadLensService() {
const { getLensServices, getLensAttributeService } = await import(
'../../../async_services'
);
const lensServicesT = await getLensServices(
coreStart,
startDependencies,
getLensAttributeService(coreStart, startDependencies)
);
setLensServices(lensServicesT);
}
loadLensService();
}, []);
if (!lensServices || !datasourceMap || !visualizationMap || !dataView.id) {
return <LoadingSpinnerWithOverlay />;
}
const datasourceState = attributes.state.datasourceStates[datasourceId];
const storeDeps = {
lensServices,
datasourceMap,
visualizationMap,
initialContext:
datasourceState && 'initialContext' in datasourceState
? datasourceState.initialContext
: undefined,
};
const lensStore: LensRootStore = makeConfigureStore(storeDeps, {
lens: getPreloadedState(storeDeps) as LensAppState,
} as unknown as PreloadedState<LensState>);
const closeFlyout = () => {
setIsFlyoutVisible?.(false);
};
const configPanelProps = {
attributes,
dataView,
updateAll,
setIsFlyoutVisible,
datasourceId,
adaptersTables,
coreStart,
startDependencies,
visualizationMap,
datasourceMap,
};
return (
<EuiFlyout
type="push"
ownFocus
onClose={closeFlyout}
aria-labelledby={i18n.translate('xpack.lens.config.editLabel', {
defaultMessage: 'Edit configuration',
})}
size="s"
className="lnsEditConfigurationFlyout"
css={css`
background: none;
`}
hideCloseButton
>
<Provider store={lensStore}>
<LensEditConfigurationFlyout {...configPanelProps} />
</Provider>
</EuiFlyout>
);
};
}

View file

@ -0,0 +1,433 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiFlyoutBody } from '@elastic/eui';
import { mountWithProvider } from '../../../mocks';
import type { Query, AggregateQuery } from '@kbn/es-query';
import type { DataView } from '@kbn/data-views-plugin/public';
import { coreMock } from '@kbn/core/public/mocks';
import {
mockVisualizationMap,
mockDatasourceMap,
mockStoreDeps,
mockDataPlugin,
} from '../../../mocks';
import type { LensPluginStartDependencies } from '../../../plugin';
import { createMockStartDependencies } from '../../../editor_frame_service/mocks';
import type { TypedLensByValueInput } from '../../../embeddable/embeddable_component';
import { VisualizationToolbar } from '../../../editor_frame_service/editor_frame/workspace_panel';
import { ConfigPanelWrapper } from '../../../editor_frame_service/editor_frame/config_panel/config_panel';
import {
LensEditConfigurationFlyout,
type EditConfigPanelProps,
} from './lens_configuration_flyout';
let container: HTMLDivElement | undefined;
beforeEach(() => {
container = document.createElement('div');
container.id = 'lensContainer';
document.body.appendChild(container);
});
afterEach(() => {
if (container && container.parentNode) {
container.parentNode.removeChild(container);
}
container = undefined;
});
describe('LensEditConfigurationFlyout', () => {
const mockStartDependencies =
createMockStartDependencies() as unknown as LensPluginStartDependencies;
const data = mockDataPlugin();
(data.query.timefilter.timefilter.getTime as jest.Mock).mockReturnValue({
from: 'now-2m',
to: 'now',
});
const startDependencies = {
...mockStartDependencies,
data,
};
function prepareAndMountComponent(
props: ReturnType<typeof getDefaultProps>,
query?: Query | AggregateQuery
) {
return mountWithProvider(
<LensEditConfigurationFlyout {...props} />,
{
preloadedState: {
datasourceStates: {
testDatasource: {
isLoading: false,
state: 'state',
},
},
activeDatasourceId: 'testDatasource',
query: query as Query,
},
storeDeps: mockStoreDeps({
datasourceMap: props.datasourceMap,
visualizationMap: props.visualizationMap,
}),
},
{
attachTo: container,
}
);
}
function getDefaultProps(
{ datasourceMap = mockDatasourceMap(), visualizationMap = mockVisualizationMap() } = {
datasourceMap: mockDatasourceMap(),
visualizationMap: mockVisualizationMap(),
}
) {
const lensAttributes = {
title: 'test',
visualizationType: 'testVis',
state: {
datasourceStates: {
testDatasource: {},
},
visualization: {},
filters: [],
query: {
language: 'lucene',
query: '',
},
},
filters: [],
query: {
language: 'lucene',
query: '',
},
references: [],
} as unknown as TypedLensByValueInput['attributes'];
const dataView = { id: 'index1', isPersisted: () => true } as unknown as DataView;
return {
attributes: lensAttributes,
dataView,
updateAll: jest.fn(),
coreStart: coreMock.createStart(),
startDependencies,
visualizationMap,
datasourceMap,
setIsFlyoutVisible: jest.fn(),
datasourceId: 'testDatasource',
} as unknown as EditConfigPanelProps;
}
it('should call the setIsFlyout callback if collapse button is clicked', async () => {
const setIsFlyoutVisibleSpy = jest.fn();
const props = getDefaultProps();
const newProps = {
...props,
setIsFlyoutVisible: setIsFlyoutVisibleSpy,
};
const { instance } = await prepareAndMountComponent(newProps);
expect(instance.find(EuiFlyoutBody).exists()).toBe(true);
instance.find('[data-test-subj="collapseFlyoutButton"]').at(1).simulate('click');
expect(setIsFlyoutVisibleSpy).toHaveBeenCalled();
});
it('should compute the frame public api correctly', async () => {
const props = getDefaultProps();
const { instance } = await prepareAndMountComponent(props);
expect(instance.find(ConfigPanelWrapper).exists()).toBe(true);
expect(instance.find(VisualizationToolbar).exists()).toBe(true);
expect(instance.find(VisualizationToolbar).prop('framePublicAPI')).toMatchInlineSnapshot(`
Object {
"activeData": Object {},
"dataViews": Object {
"indexPatternRefs": Array [],
"indexPatterns": Object {
"index1": Object {
"id": "index1",
"isPersisted": [Function],
},
},
},
"datasourceLayers": Object {
"a": Object {
"datasourceId": "testDatasource",
"getFilters": [MockFunction],
"getMaxPossibleNumValues": [MockFunction],
"getOperationForColumnId": [MockFunction],
"getSourceId": [MockFunction],
"getTableSpec": [MockFunction],
"getVisualDefaults": [MockFunction],
"hasDefaultTimeField": [MockFunction],
"isTextBasedLanguage": [MockFunction] {
"calls": Array [
Array [],
Array [],
],
"results": Array [
Object {
"type": "return",
"value": false,
},
Object {
"type": "return",
"value": false,
},
],
},
},
},
"dateRange": Object {
"fromDate": "2021-01-10T04:00:00.000Z",
"toDate": "2021-01-10T08:00:00.000Z",
},
}
`);
});
it('should compute the activeVisualization correctly', async () => {
const props = getDefaultProps();
const { instance } = await prepareAndMountComponent(props);
expect(instance.find(VisualizationToolbar).prop('activeVisualization')).toMatchInlineSnapshot(`
Object {
"appendLayer": [MockFunction],
"clearLayer": [MockFunction],
"getConfiguration": [MockFunction] {
"calls": Array [
Array [
Object {
"frame": Object {
"activeData": Object {},
"dataViews": Object {
"indexPatternRefs": Array [],
"indexPatterns": Object {
"index1": Object {
"id": "index1",
"isPersisted": [Function],
},
},
},
"datasourceLayers": Object {
"a": Object {
"datasourceId": "testDatasource",
"getFilters": [MockFunction],
"getMaxPossibleNumValues": [MockFunction],
"getOperationForColumnId": [MockFunction],
"getSourceId": [MockFunction],
"getTableSpec": [MockFunction],
"getVisualDefaults": [MockFunction],
"hasDefaultTimeField": [MockFunction],
"isTextBasedLanguage": [MockFunction] {
"calls": Array [
Array [],
Array [],
],
"results": Array [
Object {
"type": "return",
"value": false,
},
Object {
"type": "return",
"value": false,
},
],
},
},
},
"dateRange": Object {
"fromDate": "2021-01-10T04:00:00.000Z",
"toDate": "2021-01-10T08:00:00.000Z",
},
},
"layerId": "layer1",
"state": Object {},
},
],
Array [
Object {
"frame": Object {
"activeData": Object {},
"dataViews": Object {
"indexPatternRefs": Array [],
"indexPatterns": Object {
"index1": Object {
"id": "index1",
"isPersisted": [Function],
},
},
},
"datasourceLayers": Object {
"a": Object {
"datasourceId": "testDatasource",
"getFilters": [MockFunction],
"getMaxPossibleNumValues": [MockFunction],
"getOperationForColumnId": [MockFunction],
"getSourceId": [MockFunction],
"getTableSpec": [MockFunction],
"getVisualDefaults": [MockFunction],
"hasDefaultTimeField": [MockFunction],
"isTextBasedLanguage": [MockFunction] {
"calls": Array [
Array [],
Array [],
],
"results": Array [
Object {
"type": "return",
"value": false,
},
Object {
"type": "return",
"value": false,
},
],
},
},
},
"dateRange": Object {
"fromDate": "2021-01-10T04:00:00.000Z",
"toDate": "2021-01-10T08:00:00.000Z",
},
},
"layerId": "layer1",
"state": Object {},
},
],
],
"results": Array [
Object {
"type": "return",
"value": Object {
"groups": Array [
Object {
"accessors": Array [],
"dataTestSubj": "mockVisA",
"filterOperations": [MockFunction],
"groupId": "a",
"groupLabel": "a",
"layerId": "layer1",
"supportsMoreColumns": true,
},
],
},
},
Object {
"type": "return",
"value": Object {
"groups": Array [
Object {
"accessors": Array [],
"dataTestSubj": "mockVisA",
"filterOperations": [MockFunction],
"groupId": "a",
"groupLabel": "a",
"layerId": "layer1",
"supportsMoreColumns": true,
},
],
},
},
],
},
"getDescription": [MockFunction] {
"calls": Array [
Array [
Object {},
],
Array [
Object {},
],
],
"results": Array [
Object {
"type": "return",
"value": Object {
"label": "",
},
},
Object {
"type": "return",
"value": Object {
"label": "",
},
},
],
},
"getLayerIds": [MockFunction] {
"calls": Array [
Array [
Object {},
],
Array [
Object {},
],
],
"results": Array [
Object {
"type": "return",
"value": Array [
"layer1",
],
},
Object {
"type": "return",
"value": Array [
"layer1",
],
},
],
},
"getLayerType": [MockFunction] {
"calls": Array [
Array [
"layer1",
Object {},
],
Array [
"layer1",
Object {},
],
],
"results": Array [
Object {
"type": "return",
"value": "data",
},
Object {
"type": "return",
"value": "data",
},
],
},
"getRenderEventCounters": [MockFunction],
"getSuggestions": [MockFunction],
"getSupportedLayers": [MockFunction],
"getVisualizationTypeId": [MockFunction],
"id": "testVis",
"initialize": [MockFunction],
"removeDimension": [MockFunction],
"removeLayer": [MockFunction],
"renderDimensionEditor": [MockFunction],
"setDimension": [MockFunction],
"switchVisualizationType": [MockFunction],
"toExpression": [MockFunction],
"toPreviewExpression": [MockFunction],
"visualizationTypes": Array [
Object {
"groupLabel": "testVisGroup",
"icon": "empty",
"id": "testVis",
"label": "TEST",
},
],
}
`);
});
});

View file

@ -0,0 +1,174 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo } from 'react';
import {
EuiButtonEmpty,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiSpacer,
EuiFlexGroup,
EuiFlexItem,
useEuiTheme,
EuiCallOut,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { css } from '@emotion/react';
import type { CoreStart } from '@kbn/core/public';
import type { Datatable } from '@kbn/expressions-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
import { getResolvedDateRange } from '../../../utils';
import type { LensPluginStartDependencies } from '../../../plugin';
import {
DataViewsState,
useLensDispatch,
updateStateFromSuggestion,
} from '../../../state_management';
import { VisualizationToolbar } from '../../../editor_frame_service/editor_frame/workspace_panel';
import type { DatasourceMap, VisualizationMap, DatasourceLayers } from '../../../types';
import type { TypedLensByValueInput } from '../../../embeddable/embeddable_component';
import { ConfigPanelWrapper } from '../../../editor_frame_service/editor_frame/config_panel/config_panel';
export interface EditConfigPanelProps {
attributes: TypedLensByValueInput['attributes'];
dataView: DataView;
updateAll: (datasourceState: unknown, visualizationState: unknown) => void;
coreStart: CoreStart;
startDependencies: LensPluginStartDependencies;
visualizationMap: VisualizationMap;
datasourceMap: DatasourceMap;
setIsFlyoutVisible?: (flag: boolean) => void;
datasourceId: 'formBased' | 'textBased';
adaptersTables?: Record<string, Datatable>;
}
export function LensEditConfigurationFlyout({
attributes,
dataView,
coreStart,
startDependencies,
visualizationMap,
datasourceMap,
datasourceId,
updateAll,
setIsFlyoutVisible,
adaptersTables,
}: EditConfigPanelProps) {
const currentDataViewId = dataView.id ?? '';
const datasourceState = attributes.state.datasourceStates[datasourceId];
const activeVisualization = visualizationMap[attributes.visualizationType];
const activeDatasource = datasourceMap[datasourceId];
const dispatchLens = useLensDispatch();
const { euiTheme } = useEuiTheme();
const dataViews = useMemo(() => {
return {
indexPatterns: {
[currentDataViewId]: dataView,
},
indexPatternRefs: [],
} as unknown as DataViewsState;
}, [currentDataViewId, dataView]);
dispatchLens(
updateStateFromSuggestion({
newDatasourceId: datasourceId,
visualizationId: activeVisualization.id,
visualizationState: attributes.state.visualization,
datasourceState,
dataViews,
})
);
const datasourceLayers: DatasourceLayers = useMemo(() => {
return {};
}, []);
const activeData: Record<string, Datatable> = useMemo(() => {
return {};
}, []);
const layers = activeDatasource.getLayers(datasourceState);
layers.forEach((layer) => {
datasourceLayers[layer] = datasourceMap[datasourceId].getPublicAPI({
state: datasourceState,
layerId: layer,
indexPatterns: dataViews.indexPatterns,
});
if (adaptersTables) {
activeData[layer] = Object.values(adaptersTables)[0];
}
});
const dateRange = getResolvedDateRange(startDependencies.data.query.timefilter.timefilter);
const framePublicAPI = useMemo(() => {
return {
activeData,
dataViews,
datasourceLayers,
dateRange,
};
}, [activeData, dataViews, datasourceLayers, dateRange]);
const closeFlyout = () => {
setIsFlyoutVisible?.(false);
};
const layerPanelsProps = {
framePublicAPI,
datasourceMap,
visualizationMap,
core: coreStart,
dataViews: startDependencies.dataViews,
uiActions: startDependencies.uiActions,
hideLayerHeader: true,
onUpdateStateCb: updateAll,
};
return (
<>
<EuiFlyoutBody
className="lnsEditFlyoutBody"
css={css`
.euiFlyoutBody__overflowContent {
padding: ${euiTheme.size.s};
}
`}
>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem>
<EuiCallOut
size="s"
title={i18n.translate('xpack.lens.config.configFlyoutCallout', {
defaultMessage: 'SQL currently offers limited configuration options',
})}
iconType="iInCircle"
/>
<EuiSpacer size="m" />
<VisualizationToolbar
activeVisualization={activeVisualization}
framePublicAPI={framePublicAPI}
onUpdateStateCb={updateAll}
/>
<EuiSpacer size="m" />
<ConfigPanelWrapper {...layerPanelsProps} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiButtonEmpty
onClick={closeFlyout}
data-test-subj="collapseFlyoutButton"
aria-controls="lens-config-close-button"
aria-expanded="true"
aria-label={i18n.translate('xpack.lens.config.closeFlyoutAriaLabel', {
defaultMessage: 'Close flyout',
})}
>
<FormattedMessage id="xpack.lens.config.closeFlyoutLabel" defaultMessage="Close" />
</EuiButtonEmpty>
</EuiFlyoutFooter>
</>
);
}

View file

@ -30,6 +30,7 @@ export * from './visualizations/gauge/gauge_visualization';
export * from './visualizations/gauge';
export * from './visualizations/tagcloud/tagcloud_visualization';
export * from './visualizations/tagcloud';
export { getEditLensConfiguration } from './app_plugin/shared/edit_on_the_fly/get_edit_lens_configuration';
export * from './datasources/form_based/form_based';
export { getTextBasedDatasource } from './datasources/text_based/text_based_languages';

View file

@ -410,6 +410,22 @@ describe('Textbased Data Source', () => {
);
expect(suggestions[0].state).toEqual({
...state,
fieldList: [
{
id: 'newid',
meta: {
type: 'number',
},
name: 'bytes',
},
{
id: 'newid',
meta: {
type: 'string',
},
name: 'dest',
},
],
layers: {
newid: {
allColumns: [

View file

@ -122,6 +122,14 @@ export function getTextBasedDatasource({
const query = context.query;
const updatedState = {
...state,
fieldList:
newColumns?.map((c) => {
return {
id: c.columnId,
name: c.fieldName,
meta: c.meta,
};
}) ?? [],
layers: {
...state.layers,
[newLayerId]: {

View file

@ -31,12 +31,12 @@ export interface TextBasedLayer {
export interface TextBasedPersistedState {
layers: Record<string, TextBasedLayer>;
initialContext?: VisualizeFieldContext | VisualizeEditorContext;
}
export type TextBasedPrivateState = TextBasedPersistedState & {
indexPatternRefs: IndexPatternRef[];
fieldList: DatatableColumn[];
initialContext?: VisualizeFieldContext | VisualizeEditorContext;
};
export interface IndexPatternRef {

View file

@ -170,13 +170,19 @@ describe('ConfigPanel', () => {
it('allow datasources and visualizations to use setters', async () => {
const props = getDefaultProps();
const { instance, lensStore } = await prepareAndMountComponent(props);
const onUpdateCbSpy = jest.fn();
const newProps = {
...props,
onUpdateStateCb: onUpdateCbSpy,
};
const { instance, lensStore } = await prepareAndMountComponent(newProps);
const { updateDatasource, updateAll } = instance.find(LayerPanel).props();
const updater = () => 'updated';
updateDatasource('testDatasource', updater);
await waitMs(0);
expect(lensStore.dispatch).toHaveBeenCalledTimes(1);
expect(onUpdateCbSpy).toHaveBeenCalled();
expect(
(lensStore.dispatch as jest.Mock).mock.calls[0][0].payload.updater(
props.datasourceStates.testDatasource.state
@ -184,6 +190,7 @@ describe('ConfigPanel', () => {
).toEqual('updated');
updateAll('testDatasource', updater, props.visualizationState);
expect(onUpdateCbSpy).toHaveBeenCalled();
// wait for one tick so async updater has a chance to trigger
await waitMs(0);
expect(lensStore.dispatch).toHaveBeenCalledTimes(2);

View file

@ -6,6 +6,7 @@
*/
import React, { useMemo, memo, useCallback } from 'react';
import { useStore } from 'react-redux';
import { EuiForm } from '@elastic/eui';
import { ActionExecutionContext } from '@kbn/ui-actions-plugin/public';
import { isOfAggregateQueryType } from '@kbn/es-query';
@ -52,7 +53,8 @@ export function LayerPanels(
activeVisualization: Visualization;
}
) {
const { activeVisualization, datasourceMap, indexPatternService } = props;
const lensStore = useStore();
const { activeVisualization, datasourceMap, indexPatternService, onUpdateStateCb } = props;
const { activeDatasourceId, visualization, datasourceStates, query } = useLensSelector(
(state) => state.lens
);
@ -74,8 +76,12 @@ export function LayerPanels(
newState,
})
);
if (onUpdateStateCb && activeDatasourceId) {
const dsState = datasourceStates[activeDatasourceId].state;
onUpdateStateCb?.(dsState, newState);
}
},
[activeVisualization, dispatchLens]
[activeDatasourceId, activeVisualization.id, datasourceStates, dispatchLens, onUpdateStateCb]
);
const updateDatasource = useMemo(
() =>
@ -90,9 +96,10 @@ export function LayerPanels(
dontSyncLinkedDimensions,
})
);
onUpdateStateCb?.(newState, visualization.state);
}
},
[dispatchLens]
[dispatchLens, onUpdateStateCb, visualization.state]
);
const updateDatasourceAsync = useMemo(
() => (datasourceId: string | undefined, newState: unknown) => {
@ -147,9 +154,10 @@ export function LayerPanels(
},
})
);
onUpdateStateCb?.(newDatasourceState, newVisualizationState);
}, 0);
},
[dispatchLens]
[dispatchLens, onUpdateStateCb]
);
const toggleFullscreen = useMemo(
@ -213,20 +221,21 @@ export function LayerPanels(
visualizationId?: string;
layerId?: string;
}) => {
const indexPatterns = await props.indexPatternService.ensureIndexPattern({
const indexPatterns = await props.indexPatternService?.ensureIndexPattern({
id: indexPatternId,
cache: props.framePublicAPI.dataViews.indexPatterns,
});
dispatchLens(
changeIndexPattern({
indexPatternId,
datasourceIds: datasourceId ? [datasourceId] : [],
visualizationIds: visualizationId ? [visualizationId] : [],
layerId,
dataViews: { indexPatterns },
})
);
if (indexPatterns) {
dispatchLens(
changeIndexPattern({
indexPatternId,
datasourceIds: datasourceId ? [datasourceId] : [],
visualizationIds: visualizationId ? [visualizationId] : [],
layerId,
dataViews: { indexPatterns },
})
);
}
},
[dispatchLens, props.framePublicAPI.dataViews.indexPatterns, props.indexPatternService]
);
@ -262,6 +271,7 @@ export function LayerPanels(
updateVisualization={setVisualizationState}
updateDatasource={updateDatasource}
updateDatasourceAsync={updateDatasourceAsync}
displayLayerSettings={!props.hideLayerHeader}
onChangeIndexPattern={(args) => {
onChangeIndexPattern(args);
const layersToRemove =
@ -307,6 +317,13 @@ export function LayerPanels(
const datasourcePublicAPI = props.framePublicAPI.datasourceLayers?.[layerId];
const datasourceId = datasourcePublicAPI?.datasourceId;
dispatchLens(removeDimension({ ...dimensionProps, datasourceId }));
if (datasourceId && onUpdateStateCb) {
const newState = lensStore.getState().lens;
onUpdateStateCb(
newState.datasourceStates[datasourceId].state,
newState.visualization.state
);
}
}}
toggleFullscreen={toggleFullscreen}
indexPatternService={indexPatternService}
@ -336,19 +353,21 @@ export function LayerPanels(
indexPatternId = dataView.id;
}
const newIndexPatterns = await indexPatternService.ensureIndexPattern({
const newIndexPatterns = await indexPatternService?.ensureIndexPattern({
id: indexPatternId,
cache: props.framePublicAPI.dataViews.indexPatterns,
});
dispatchLens(
changeIndexPattern({
dataViews: { indexPatterns: newIndexPatterns },
datasourceIds: Object.keys(datasourceStates),
visualizationIds: visualization.activeId ? [visualization.activeId] : [],
indexPatternId,
})
);
if (newIndexPatterns) {
dispatchLens(
changeIndexPattern({
dataViews: { indexPatterns: newIndexPatterns },
datasourceIds: Object.keys(datasourceStates),
visualizationIds: visualization.activeId ? [visualization.activeId] : [],
indexPatternId,
})
);
}
},
registerLibraryAnnotationGroup: (groupInfo) =>
dispatchLens(registerLibraryAnnotationGroup(groupInfo)),

View file

@ -11,6 +11,7 @@ import { EuiFormRow } from '@elastic/eui';
import { ChildDragDropProvider, DragDrop } from '@kbn/dom-drag-drop';
import { FramePublicAPI, Visualization, VisualizationConfigProps } from '../../../types';
import { LayerPanel } from './layer_panel';
import { LayerActions } from './layer_actions';
import { coreMock } from '@kbn/core/public/mocks';
import { generateId } from '../../../id_generator';
import {
@ -116,6 +117,7 @@ describe('LayerPanel', () => {
onChangeIndexPattern: jest.fn(),
indexPatternService: createIndexPatternServiceMock(),
getUserMessages: () => [],
displayLayerSettings: true,
};
}
@ -203,6 +205,13 @@ describe('LayerPanel', () => {
expect(optionalLabel.text()).toEqual('Optional');
});
it('should hide the layer actions if displayLayerSettings is set to false', async () => {
const { instance } = await mountWithProvider(
<LayerPanel {...getDefaultProps()} displayLayerSettings={false} />
);
expect(instance.find(LayerActions).exists()).toBe(false);
});
it('should render the group with a way to add a new column', async () => {
mockVisualization.getConfiguration.mockReturnValue({
groups: [

View file

@ -87,8 +87,9 @@ export function LayerPanel(
datasourceId?: string;
visualizationId?: string;
}) => void;
indexPatternService: IndexPatternServiceAPI;
getUserMessages: UserMessagesGetter;
indexPatternService?: IndexPatternServiceAPI;
getUserMessages?: UserMessagesGetter;
displayLayerSettings: boolean;
}
) {
const [activeDimension, setActiveDimension] = useState<ActiveDimensionState>(
@ -418,17 +419,20 @@ export function LayerPanel(
activeVisualization={activeVisualization}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<LayerActions
actions={compatibleActions}
layerIndex={layerIndex}
mountingPoint={layerActionsFlyoutRef.current}
/>
<div ref={layerActionsFlyoutRef} />
</EuiFlexItem>
{props.displayLayerSettings && (
<EuiFlexItem grow={false}>
<LayerActions
actions={compatibleActions}
layerIndex={layerIndex}
mountingPoint={layerActionsFlyoutRef.current}
/>
<div ref={layerActionsFlyoutRef} />
</EuiFlexItem>
)}
</EuiFlexGroup>
{(layerDatasource || activeVisualization.renderLayerPanel) && <EuiSpacer size="s" />}
{layerDatasource && (
{props.indexPatternService &&
(layerDatasource || activeVisualization.renderLayerPanel) && <EuiSpacer size="s" />}
{layerDatasource && props.indexPatternService && (
<NativeRenderer
render={layerDatasource.renderLayerPanel}
nativeProps={{
@ -544,9 +548,10 @@ export function LayerPanel(
{group.accessors.map((accessorConfig, accessorIndex) => {
const { columnId } = accessorConfig;
const messages = props.getUserMessages('dimensionButton', {
dimensionId: columnId,
});
const messages =
props?.getUserMessages?.('dimensionButton', {
dimensionId: columnId,
}) ?? [];
return (
<DraggableDimensionButton

View file

@ -25,9 +25,11 @@ export interface ConfigPanelWrapperProps {
visualizationMap: VisualizationMap;
core: DatasourceDimensionEditorProps['core'];
dataViews: DataViewsPublicPluginStart;
indexPatternService: IndexPatternServiceAPI;
indexPatternService?: IndexPatternServiceAPI;
uiActions: UiActionsStart;
getUserMessages: UserMessagesGetter;
getUserMessages?: UserMessagesGetter;
hideLayerHeader?: boolean;
onUpdateStateCb?: (datasourceState: unknown, visualizationState: unknown) => void;
}
export interface LayerPanelProps {

View file

@ -6,3 +6,4 @@
*/
export { WorkspacePanel } from './workspace_panel';
export { VisualizationToolbar } from './workspace_panel_wrapper';

View file

@ -65,7 +65,13 @@ describe('workspace_panel_wrapper', () => {
isFullscreen={false}
lensInspector={{} as unknown as LensInspector}
getUserMessages={() => []}
/>
/>,
{
preloadedState: {
visualization: { activeId: 'myVis', state: visState },
datasourceStates: {},
},
}
);
expect(renderToolbarMock).toHaveBeenCalledWith(expect.any(Element), {

View file

@ -16,6 +16,7 @@ import {
FramePublicAPI,
UserMessagesGetter,
VisualizationMap,
Visualization,
} from '../../../types';
import { DONT_CLOSE_DIMENSION_CONTAINER_ON_CLICK_CLASS } from '../../../utils';
import { NativeRenderer } from '../../../native_renderer';
@ -49,6 +50,52 @@ export interface WorkspacePanelWrapperProps {
getUserMessages: UserMessagesGetter;
}
export function VisualizationToolbar(props: {
activeVisualization: Visualization | null;
framePublicAPI: FramePublicAPI;
onUpdateStateCb?: (datasourceState: unknown, visualizationState: unknown) => void;
}) {
const dispatchLens = useLensDispatch();
const { activeDatasourceId, visualization, datasourceStates } = useLensSelector(
(state) => state.lens
);
const setVisualizationState = useCallback(
(newState: unknown) => {
if (!props.activeVisualization) {
return;
}
dispatchLens(
updateVisualizationState({
visualizationId: props.activeVisualization.id,
newState,
})
);
if (activeDatasourceId && props.onUpdateStateCb) {
const dsState = datasourceStates[activeDatasourceId].state;
props.onUpdateStateCb?.(dsState, newState);
}
},
[activeDatasourceId, datasourceStates, dispatchLens, props]
);
return (
<>
{props.activeVisualization && props.activeVisualization.renderToolbar && (
<EuiFlexItem grow={false}>
<NativeRenderer
render={props.activeVisualization.renderToolbar}
nativeProps={{
frame: props.framePublicAPI,
state: visualization.state,
setState: setVisualizationState,
}}
/>
</EuiFlexItem>
)}
</>
);
}
export function WorkspacePanelWrapper({
children,
framePublicAPI,
@ -65,21 +112,6 @@ export function WorkspacePanelWrapper({
const autoApplyEnabled = useLensSelector(selectAutoApplyEnabled);
const activeVisualization = visualizationId ? visualizationMap[visualizationId] : null;
const setVisualizationState = useCallback(
(newState: unknown) => {
if (!activeVisualization) {
return;
}
dispatchLens(
updateVisualizationState({
visualizationId: activeVisualization.id,
newState,
})
);
},
[dispatchLens, activeVisualization]
);
const userMessages = getUserMessages('toolbar');
return (
@ -116,19 +148,10 @@ export function WorkspacePanelWrapper({
framePublicAPI={framePublicAPI}
/>
</EuiFlexItem>
{activeVisualization && activeVisualization.renderToolbar && (
<EuiFlexItem grow={false}>
<NativeRenderer
render={activeVisualization.renderToolbar}
nativeProps={{
frame: framePublicAPI,
state: visualizationState,
setState: setVisualizationState,
}}
/>
</EuiFlexItem>
)}
<VisualizationToolbar
activeVisualization={activeVisualization}
framePublicAPI={framePublicAPI}
/>
</EuiFlexGroup>
</EuiFlexItem>
)}

View file

@ -10,6 +10,8 @@ import { ExpressionsSetup, ExpressionsStart } from '@kbn/expressions-plugin/publ
import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks';
import { expressionsPluginMock } from '@kbn/expressions-plugin/public/mocks';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks';
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
import { EditorFrameSetupPlugins, EditorFrameStartPlugins } from './service';
@ -57,5 +59,7 @@ export function createMockStartDependencies() {
embeddable: embeddablePluginMock.createStartContract(),
expressions: expressionsPluginMock.createStartContract(),
charts: chartPluginMock.createStartContract(),
uiActions: uiActionsPluginMock.createStartContract(),
dataViews: dataViewPluginMocks.createStartContract(),
} as unknown as MockedStartDependencies;
}

View file

@ -23,6 +23,7 @@ import {
import type { LensByReferenceInput, LensByValueInput } from './embeddable';
import type { Document } from '../persistence';
import type { FormBasedPersistedState } from '../datasources/form_based/types';
import type { TextBasedPersistedState } from '../datasources/text_based/types';
import type { XYState } from '../visualizations/xy/types';
import type {
PieVisualizationState,
@ -45,6 +46,7 @@ type LensAttributes<TVisType, TVisState> = Omit<
state: Omit<Document['state'], 'datasourceStates' | 'visualization'> & {
datasourceStates: {
formBased: FormBasedPersistedState;
textBased?: TextBasedPersistedState;
};
visualization: TVisState;
};

View file

@ -21,6 +21,7 @@ export const lensPluginMock = {
SaveModalComponent: jest.fn(() => {
return <span>Lens Save Modal Component</span>;
}),
EditLensConfigPanelApi: jest.fn().mockResolvedValue(<span>Lens Config Panel Component</span>),
canUseEditor: jest.fn(() => true),
navigateToPrefilledEditor: jest.fn(),
getXyVisTypes: jest

View file

@ -126,6 +126,7 @@ import { type LensAppLocator, LensAppLocatorDefinition } from '../common/locator
import { downloadCsvShareProvider } from './app_plugin/csv_download_provider/csv_download_provider';
import { CONTENT_ID, LATEST_VERSION } from '../common/content_management';
import type { EditLensConfigurationProps } from './app_plugin/shared/edit_on_the_fly/get_edit_lens_configuration';
export interface LensPluginSetupDependencies {
urlForwarding: UrlForwardingSetup;
@ -215,6 +216,14 @@ export interface LensPublicStart {
* @experimental
*/
SaveModalComponent: React.ComponentType<Omit<SaveModalContainerProps, 'lensServices'>>;
/**
* React component which can be used to embed a Lens Visualization Config Panel Component.
*
* This API might undergo breaking changes even in minor versions.
*
* @experimental
*/
EditLensConfigPanelApi: () => Promise<EditLensConfigPanelComponent>;
/**
* Method which navigates to the Lens editor, loading the state specified by the `input` parameter.
* See `x-pack/examples/embedded_lens_example` for exemplary usage.
@ -252,6 +261,8 @@ export interface LensPublicStart {
}>;
}
export type EditLensConfigPanelComponent = React.ComponentType<EditLensConfigurationProps>;
export type LensSuggestionsApi = (
context: VisualizeFieldContext | VisualizeEditorContext,
dataViews: DataView,
@ -649,6 +660,17 @@ export class LensPlugin {
},
};
},
EditLensConfigPanelApi: async () => {
const { getEditLensConfiguration } = await import('./async_services');
if (!this.editorFrameService) {
this.initDependenciesForApi();
}
const [visualizationMap, datasourceMap] = await Promise.all([
this.editorFrameService!.loadVisualizations(),
this.editorFrameService!.loadDatasources(),
]);
return getEditLensConfiguration(core, startDependencies, visualizationMap, datasourceMap);
},
};
}

View file

@ -35,6 +35,7 @@ export const {
submitSuggestion,
switchDatasource,
switchAndCleanDatasource,
updateStateFromSuggestion,
updateIndexPatterns,
setToggleFullscreen,
initEmpty,

View file

@ -10,6 +10,7 @@ import type { Query } from '@kbn/es-query';
import {
switchDatasource,
switchAndCleanDatasource,
updateStateFromSuggestion,
switchVisualization,
setState,
updateState,
@ -271,6 +272,28 @@ describe('lensSlice', () => {
});
});
describe('update the state from the suggestion', () => {
it('should switch active datasource and initialize new state', () => {
store.dispatch(
updateStateFromSuggestion({
newDatasourceId: 'testDatasource2',
visualizationId: 'testVis',
visualizationState: ['col1', 'col2'],
datasourceState: {},
dataViews: { indexPatterns: {} } as DataViewsState,
})
);
expect(store.getState().lens.activeDatasourceId).toEqual('testDatasource2');
expect(store.getState().lens.datasourceStates.testDatasource2.isLoading).toEqual(false);
expect(store.getState().lens.datasourceStates.testDatasource2.state).toStrictEqual({});
expect(store.getState().lens.visualization).toStrictEqual({
activeId: 'testVis',
state: ['col1', 'col2'],
});
expect(store.getState().lens.dataViews).toEqual({ indexPatterns: {} });
});
});
describe('adding or removing layer', () => {
const testDatasource = (datasourceId: string) => {
return {

View file

@ -173,6 +173,13 @@ export const switchAndCleanDatasource = createAction<{
visualizationId: string | null;
currentIndexPatternId?: string;
}>('lens/switchAndCleanDatasource');
export const updateStateFromSuggestion = createAction<{
newDatasourceId: string;
visualizationId: string | null;
visualizationState: unknown;
datasourceState: unknown;
dataViews: DataViewsState;
}>('lens/updateStateFromSuggestion');
export const navigateAway = createAction<void>('lens/navigateAway');
export const loadInitial = createAction<{
initialInput?: LensEmbeddableInput;
@ -267,6 +274,7 @@ export const lensActions = {
submitSuggestion,
switchDatasource,
switchAndCleanDatasource,
updateStateFromSuggestion,
navigateAway,
loadInitial,
initEmpty,
@ -848,6 +856,42 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => {
},
};
},
[updateStateFromSuggestion.type]: (
state,
{
payload,
}: {
payload: {
newDatasourceId: string;
visualizationId: string;
visualizationState: unknown;
datasourceState: unknown;
dataViews: DataViewsState;
};
}
) => {
const visualization = {
activeId: payload.visualizationId,
state: payload.visualizationState,
};
const datasourceState = payload.datasourceState;
return {
...state,
datasourceStates: {
[payload.newDatasourceId]: {
state: datasourceState,
isLoading: false,
},
},
activeDatasourceId: payload.newDatasourceId,
visualization: {
...visualization,
},
dataViews: payload.dataViews,
};
},
[navigateAway.type]: (state) => state,
[loadInitial.type]: (
state,

View file

@ -5673,7 +5673,6 @@
"unifiedHistogram.lensTitle": "Modifier la visualisation",
"unifiedHistogram.resetChartHeight": "Réinitialiser à la hauteur par défaut",
"unifiedHistogram.showChart": "Afficher le graphique",
"unifiedHistogram.suggestionSelectorLabel": "Visualisation",
"unifiedHistogram.suggestionSelectorPlaceholder": "Sélectionner la visualisation",
"unifiedHistogram.timeIntervals": "Intervalles de temps",
"unifiedHistogram.timeIntervalWithValueWarning": "Avertissement",

View file

@ -5674,7 +5674,6 @@
"unifiedHistogram.lensTitle": "ビジュアライゼーションを編集",
"unifiedHistogram.resetChartHeight": "デフォルトの高さにリセット",
"unifiedHistogram.showChart": "グラフを表示",
"unifiedHistogram.suggestionSelectorLabel": "ビジュアライゼーション",
"unifiedHistogram.suggestionSelectorPlaceholder": "ビジュアライゼーションを選択",
"unifiedHistogram.timeIntervals": "時間間隔",
"unifiedHistogram.timeIntervalWithValueWarning": "警告",

View file

@ -5673,7 +5673,6 @@
"unifiedHistogram.lensTitle": "编辑可视化",
"unifiedHistogram.resetChartHeight": "重置为默认高度",
"unifiedHistogram.showChart": "显示图表",
"unifiedHistogram.suggestionSelectorLabel": "可视化",
"unifiedHistogram.suggestionSelectorPlaceholder": "选择可视化",
"unifiedHistogram.timeIntervals": "时间间隔",
"unifiedHistogram.timeIntervalWithValueWarning": "警告",

View file

@ -156,11 +156,11 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
await testSubjects.click('querySubmitButton');
await PageObjects.header.waitUntilLoadingHasFinished();
await testSubjects.click('TextBasedLangEditor-expand');
await testSubjects.click('unifiedHistogramEditVisualization');
await testSubjects.click('unifiedHistogramEditFlyoutVisualization');
await PageObjects.header.waitUntilLoadingHasFinished();
await retry.waitFor('lens visualization', async () => {
await retry.waitFor('lens flyout', async () => {
const dimensions = await testSubjects.findAll('lns-dimensionTrigger-textBased');
return dimensions.length === 2 && (await dimensions[1].getVisibleText()) === 'average';
});
@ -175,11 +175,11 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
await testSubjects.click('querySubmitButton');
await PageObjects.header.waitUntilLoadingHasFinished();
await testSubjects.click('TextBasedLangEditor-expand');
await testSubjects.click('unifiedHistogramEditVisualization');
await testSubjects.click('unifiedHistogramEditFlyoutVisualization');
await PageObjects.header.waitUntilLoadingHasFinished();
await retry.waitFor('lens visualization', async () => {
await retry.waitFor('lens flyout', async () => {
const dimensions = await testSubjects.findAll('lns-dimensionTrigger-textBased');
return dimensions.length === 2 && (await dimensions[1].getVisibleText()) === 'average';
});