[ES|QL] Edits query in the dashboard (#169911)

## Summary

Part of https://github.com/elastic/kibana/issues/165928
Closes https://github.com/elastic/kibana/issues/144498

Allows the user to edit the ES|QL query from the dashboard. Also allows
the user to select one of the suggestions.

<img width="1886" alt="image"
src="9961c154-e414-4ce1-bff5-33ec5c30db69">
<img width="1883" alt="image"
src="6e8971d3-4a35-466f-804a-b8df58b09394">

### Testing
Navigate to Discover ES|QL mode and save a Lens chart to a dashboard.
Click the edit Visualization.

### Important notes
- We can very easily enable suggestions for the dataview panels but I am
going to do it on a follow up PR to keep this PR clean
- Creation is going to be added on a follow up PR
- Warnings are not rendered in the editor because I am using the limit 0
for performance reasons. We need to find another way to depict them via
the embeddable or store. It will be on a follow up PR.
- Errors are being displayed though. The user is not allowed to apply
the changes when an error occurs.
- Creating ES|QL charts from dashboard will happen to a follow up PR

### Running queries which don't return numeric fields
In these cases (i.e. `from logstash-* | keep clientip` we are returning
a table. I had to change the datatable logic for text based datasource
to not depend to isBucketed flag. This is something we had foreseen from
the [beginning of text based
languages](https://github.com/elastic/kibana/issues/144498)

<img width="1879" alt="image"
src="ca4b66fd-560d-4c1e-881d-b173458a06ae">

### Running queries which return a lot of fields
For queries with many fields Lens is going to suggest a huge table
trying to add the fields to the different dimensions. This is not
something we want:
- not performant
- user possibly will start removing fields from the dimensions
- this table is unreadable

For this reason we decided to select the first 5 fields and then the
user can easily adjust the dimensions with the fields they want.

<img width="1215" alt="image"
src="07d7ee78-0085-41b1-98a0-a77eefbc0dcd">


### 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)
- [ ] [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>
This commit is contained in:
Stratoula Kalafateli 2023-11-23 11:26:40 +01:00 committed by GitHub
parent 3ec310b519
commit b55dae32f6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
57 changed files with 2404 additions and 569 deletions

View file

@ -19,6 +19,8 @@ import {
EuiPopoverTitle,
EuiDescriptionList,
EuiDescriptionListDescription,
EuiButton,
useEuiTheme,
} from '@elastic/eui';
import { Interpolation, Theme, css } from '@emotion/react';
import { css as classNameCss } from '@emotion/css';
@ -60,12 +62,14 @@ export function ErrorsWarningsPopover({
type,
setIsPopoverOpen,
onErrorClick,
isSpaceReduced,
}: {
isPopoverOpen: boolean;
items: MonacoError[];
type: 'error' | 'warning';
setIsPopoverOpen: (flag: boolean) => void;
onErrorClick: (error: MonacoError) => void;
isSpaceReduced?: boolean;
}) {
const strings = getConstsByType(type, items.length);
return (
@ -90,7 +94,7 @@ export function ErrorsWarningsPopover({
setIsPopoverOpen(!isPopoverOpen);
}}
>
<p>{strings.message}</p>
<p>{isSpaceReduced ? items.length : strings.message}</p>
</EuiText>
}
ownFocus={false}
@ -151,8 +155,11 @@ interface EditorFooterProps {
warning?: MonacoError[];
detectTimestamp: boolean;
onErrorClick: (error: MonacoError) => void;
refreshErrors: () => void;
runQuery: () => void;
hideRunQueryText?: boolean;
disableSubmitAction?: boolean;
editorIsInline?: boolean;
isSpaceReduced?: boolean;
}
export const EditorFooter = memo(function EditorFooter({
@ -162,10 +169,15 @@ export const EditorFooter = memo(function EditorFooter({
warning,
detectTimestamp,
onErrorClick,
refreshErrors,
runQuery,
hideRunQueryText,
disableSubmitAction,
editorIsInline,
isSpaceReduced,
}: EditorFooterProps) {
const { euiTheme } = useEuiTheme();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
return (
<EuiFlexGroup
gutterSize="s"
@ -176,24 +188,6 @@ export const EditorFooter = memo(function EditorFooter({
>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="s" responsive={false} alignItems="center">
{errors && errors.length > 0 && (
<ErrorsWarningsPopover
isPopoverOpen={isPopoverOpen}
items={errors}
type="error"
setIsPopoverOpen={setIsPopoverOpen}
onErrorClick={onErrorClick}
/>
)}
{warning && warning.length > 0 && (
<ErrorsWarningsPopover
isPopoverOpen={isPopoverOpen}
items={warning}
type="warning"
setIsPopoverOpen={setIsPopoverOpen}
onErrorClick={onErrorClick}
/>
)}
<EuiFlexItem grow={false} style={{ marginRight: '8px' }}>
<EuiText size="xs" color="subdued" data-test-subj="TextBasedLangEditor-footer-lines">
<p>
@ -206,23 +200,22 @@ export const EditorFooter = memo(function EditorFooter({
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ marginRight: '16px' }}>
<EuiFlexGroup gutterSize="xs" responsive={false} alignItems="center">
<EuiFlexItem grow={false}>
<EuiIcon type="calendar" color="subdued" size="s" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="xs" color="subdued" data-test-subj="TextBasedLangEditor-date-info">
<p>
{detectTimestamp
{isSpaceReduced
? '@timestamp'
: detectTimestamp
? i18n.translate(
'textBasedEditor.query.textBasedLanguagesEditor.timestampDetected',
{
defaultMessage: '@timestamp detected',
defaultMessage: '@timestamp found',
}
)
: i18n.translate(
'textBasedEditor.query.textBasedLanguagesEditor.timestampNotDetected',
{
defaultMessage: '@timestamp not detected',
defaultMessage: '@timestamp not found',
}
)}
</p>
@ -230,6 +223,26 @@ export const EditorFooter = memo(function EditorFooter({
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
{errors && errors.length > 0 && (
<ErrorsWarningsPopover
isPopoverOpen={isPopoverOpen}
items={errors}
type="error"
setIsPopoverOpen={setIsPopoverOpen}
onErrorClick={onErrorClick}
isSpaceReduced={isSpaceReduced}
/>
)}
{warning && warning.length > 0 && (
<ErrorsWarningsPopover
isPopoverOpen={isPopoverOpen}
items={warning}
type="warning"
setIsPopoverOpen={setIsPopoverOpen}
onErrorClick={onErrorClick}
isSpaceReduced={isSpaceReduced}
/>
)}
</EuiFlexGroup>
</EuiFlexItem>
{!hideRunQueryText && (
@ -255,6 +268,53 @@ export const EditorFooter = memo(function EditorFooter({
</EuiFlexGroup>
</EuiFlexItem>
)}
{Boolean(editorIsInline) && (
<EuiFlexItem grow={false}>
<EuiButton
color="text"
size="s"
fill
onClick={runQuery}
isDisabled={Boolean(disableSubmitAction)}
data-test-subj="TextBasedLangEditor-run-query-button"
minWidth={isSpaceReduced ? false : undefined}
>
<EuiFlexGroup
gutterSize="xs"
responsive={false}
alignItems="center"
justifyContent="spaceBetween"
>
<EuiFlexItem grow={false}>
{isSpaceReduced
? i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.run', {
defaultMessage: 'Run',
})
: i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.runQuery', {
defaultMessage: 'Run query',
})}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText
size="xs"
css={css`
border: 1px solid
${Boolean(disableSubmitAction)
? euiTheme.colors.disabled
: euiTheme.colors.emptyShade};
padding: 0 ${euiTheme.size.xs};
font-size: ${euiTheme.size.s};
margin-left: ${euiTheme.size.xs};
border-radius: ${euiTheme.size.xs};
`}
>
{COMMAND_KEY}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiButton>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
});

View file

@ -13,11 +13,13 @@ import { css } from '@emotion/react';
export function ResizableButton({
onMouseDownResizeHandler,
onKeyDownResizeHandler,
editorIsInline,
}: {
onMouseDownResizeHandler: (
mouseDownEvent: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.TouchEvent
) => void;
onKeyDownResizeHandler: (keyDownEvernt: React.KeyboardEvent) => void;
editorIsInline?: boolean;
}) {
return (
<EuiResizableButton
@ -26,7 +28,7 @@ export function ResizableButton({
onKeyDown={onKeyDownResizeHandler}
onTouchStart={onMouseDownResizeHandler}
css={css`
position: absolute;
position: ${editorIsInline ? 'relative' : 'absolute'};
bottom: 0;
left: 0;
right: 0;

View file

@ -20,7 +20,8 @@ export const textBasedLanguagedEditorStyles = (
hasErrors: boolean,
hasWarning: boolean,
isCodeEditorExpandedFocused: boolean,
hasReference: boolean
hasReference: boolean,
editorIsInline: boolean
) => {
let position = isCompactFocused ? ('absolute' as 'absolute') : ('relative' as 'relative'); // cast string to type 'relative' | 'absolute'
if (isCodeEditorExpanded) {
@ -33,7 +34,9 @@ export const textBasedLanguagedEditorStyles = (
zIndex: isCompactFocused ? 4 : 0,
height: `${editorHeight}px`,
border: isCompactFocused ? euiTheme.border.thin : 'none',
borderTopLeftRadius: isCodeEditorExpanded ? 0 : '6px',
borderLeft: editorIsInline || !isCompactFocused ? 'none' : euiTheme.border.thin,
borderRight: editorIsInline || !isCompactFocused ? 'none' : euiTheme.border.thin,
borderTopLeftRadius: isCodeEditorExpanded ? 0 : euiTheme.border.radius.medium,
borderBottom: isCodeEditorExpanded
? 'none'
: isCompactFocused
@ -45,8 +48,8 @@ export const textBasedLanguagedEditorStyles = (
width: isCodeEditorExpanded ? '100%' : `calc(100% - ${hasReference ? 80 : 40}px)`,
alignItems: isCompactFocused ? 'flex-start' : 'center',
border: !isCompactFocused ? euiTheme.border.thin : 'none',
borderTopLeftRadius: '6px',
borderBottomLeftRadius: '6px',
borderTopLeftRadius: euiTheme.border.radius.medium,
borderBottomLeftRadius: euiTheme.border.radius.medium,
borderBottomWidth: hasErrors ? '2px' : '1px',
borderBottomColor: hasErrors ? euiTheme.colors.danger : euiTheme.colors.lightShade,
},
@ -66,6 +69,8 @@ export const textBasedLanguagedEditorStyles = (
},
bottomContainer: {
border: euiTheme.border.thin,
borderLeft: editorIsInline ? 'none' : euiTheme.border.thin,
borderRight: editorIsInline ? 'none' : euiTheme.border.thin,
borderTop:
isCodeEditorExpanded && !isCodeEditorExpandedFocused
? hasErrors
@ -75,29 +80,29 @@ export const textBasedLanguagedEditorStyles = (
backgroundColor: euiTheme.colors.lightestShade,
paddingLeft: euiTheme.size.base,
paddingRight: euiTheme.size.base,
paddingTop: euiTheme.size.xs,
paddingBottom: euiTheme.size.xs,
paddingTop: editorIsInline ? euiTheme.size.s : euiTheme.size.xs,
paddingBottom: editorIsInline ? euiTheme.size.s : euiTheme.size.xs,
width: 'calc(100% + 2px)',
position: 'relative' as 'relative', // cast string to type 'relative',
marginTop: 0,
marginLeft: 0,
marginBottom: 0,
borderBottomLeftRadius: '6px',
borderBottomRightRadius: '6px',
borderBottomLeftRadius: editorIsInline ? 0 : euiTheme.border.radius.medium,
borderBottomRightRadius: editorIsInline ? 0 : euiTheme.border.radius.medium,
},
topContainer: {
border: euiTheme.border.thin,
borderTopLeftRadius: '6px',
borderTopRightRadius: '6px',
border: editorIsInline ? 'none' : euiTheme.border.thin,
borderTopLeftRadius: editorIsInline ? 0 : euiTheme.border.radius.medium,
borderTopRightRadius: editorIsInline ? 0 : euiTheme.border.radius.medium,
backgroundColor: euiTheme.colors.lightestShade,
paddingLeft: euiTheme.size.base,
paddingRight: euiTheme.size.base,
paddingTop: euiTheme.size.xs,
paddingBottom: euiTheme.size.xs,
paddingTop: editorIsInline ? euiTheme.size.s : euiTheme.size.xs,
paddingBottom: editorIsInline ? euiTheme.size.s : euiTheme.size.xs,
width: 'calc(100% + 2px)',
position: 'relative' as 'relative', // cast string to type 'relative',
marginLeft: 0,
marginTop: euiTheme.size.s,
marginTop: editorIsInline ? 0 : euiTheme.size.s,
},
dragResizeContainer: {
width: '100%',

View file

@ -84,7 +84,7 @@ describe('TextBasedLanguagesEditor', () => {
});
});
it('should render the date info with no @timestamp detected', async () => {
it('should render the date info with no @timestamp found', async () => {
const newProps = {
...props,
isCodeEditorExpanded: true,
@ -93,11 +93,11 @@ describe('TextBasedLanguagesEditor', () => {
const component = mount(renderTextBasedLanguagesEditorComponent({ ...newProps }));
expect(
component.find('[data-test-subj="TextBasedLangEditor-date-info"]').at(0).text()
).toStrictEqual('@timestamp not detected');
).toStrictEqual('@timestamp not found');
});
});
it('should render the date info with @timestamp detected if detectTimestamp is true', async () => {
it('should render the date info with @timestamp found if detectTimestamp is true', async () => {
const newProps = {
...props,
isCodeEditorExpanded: true,
@ -107,7 +107,7 @@ describe('TextBasedLanguagesEditor', () => {
const component = mount(renderTextBasedLanguagesEditorComponent({ ...newProps }));
expect(
component.find('[data-test-subj="TextBasedLangEditor-date-info"]').at(0).text()
).toStrictEqual('@timestamp detected');
).toStrictEqual('@timestamp found');
});
});
@ -265,4 +265,24 @@ describe('TextBasedLanguagesEditor', () => {
expect(component.find('[data-test-subj="TextBasedLangEditor-run-query"]').length).toBe(0);
});
});
it('should render correctly if editorIsInline prop is set to true', async () => {
const onTextLangQuerySubmit = jest.fn();
const newProps = {
...props,
isCodeEditorExpanded: true,
hideRunQueryText: true,
editorIsInline: true,
onTextLangQuerySubmit,
};
await act(async () => {
const component = mount(renderTextBasedLanguagesEditorComponent({ ...newProps }));
expect(component.find('[data-test-subj="TextBasedLangEditor-run-query"]').length).toBe(0);
expect(
component.find('[data-test-subj="TextBasedLangEditor-run-query-button"]').length
).not.toBe(1);
findTestSubject(component, 'TextBasedLangEditor-run-query-button').simulate('click');
expect(onTextLangQuerySubmit).toHaveBeenCalled();
});
});
});

View file

@ -65,19 +65,43 @@ import { fetchFieldsFromESQL } from './fetch_fields_from_esql';
import './overwrite.scss';
export interface TextBasedLanguagesEditorProps {
/** The aggregate type query */
query: AggregateQuery;
/** Callback running everytime the query changes */
onTextLangQueryChange: (query: AggregateQuery) => void;
onTextLangQuerySubmit: () => void;
/** Callback running when the user submits the query */
onTextLangQuerySubmit: (query?: AggregateQuery) => void;
/** Can be used to expand/minimize the editor */
expandCodeEditor: (status: boolean) => void;
/** If it is true, the editor initializes with height EDITOR_INITIAL_HEIGHT_EXPANDED */
isCodeEditorExpanded: boolean;
/** If it is true, the editor displays the message @timestamp found
* The text based queries are relying on adhoc dataviews which
* can have an @timestamp timefield or nothing
*/
detectTimestamp?: boolean;
/** Array of errors */
errors?: Error[];
/** Warning string as it comes from ES */
warning?: string;
/** Disables the editor */
isDisabled?: boolean;
/** Indicator if the editor is on dark mode */
isDarkMode?: boolean;
dataTestSubj?: string;
/** If true it hides the minimize button and the user can't return to the minimized version
* Useful when the application doesn't want to give this capability
*/
hideMinimizeButton?: boolean;
/** Hide the Run query information which appears on the footer*/
hideRunQueryText?: boolean;
/** This is used for applications (such as the inline editing flyout in dashboards)
* which want to add the editor without being part of the Unified search component
* It renders a submit query button inside the editor
*/
editorIsInline?: boolean;
/** Disables the submit query action*/
disableSubmitAction?: boolean;
}
interface TextBasedEditorDeps {
@ -94,6 +118,9 @@ const EDITOR_ONE_LINER_UNUSED_SPACE_WITH_ERRORS = 220;
const KEYCODE_ARROW_UP = 38;
const KEYCODE_ARROW_DOWN = 40;
// for editor width smaller than this value we want to start hiding some text
const BREAKPOINT_WIDTH = 410;
const languageId = (language: string) => {
switch (language) {
case 'esql': {
@ -125,6 +152,8 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({
isDarkMode,
hideMinimizeButton,
hideRunQueryText,
editorIsInline,
disableSubmitAction,
dataTestSubj,
}: TextBasedLanguagesEditorProps) {
const { euiTheme } = useEuiTheme();
@ -137,6 +166,7 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({
const [editorHeight, setEditorHeight] = useState(
isCodeEditorExpanded ? EDITOR_INITIAL_HEIGHT_EXPANDED : EDITOR_INITIAL_HEIGHT
);
const [isSpaceReduced, setIsSpaceReduced] = useState(false);
const [showLineNumbers, setShowLineNumbers] = useState(isCodeEditorExpanded);
const [isCompactFocused, setIsCompactFocused] = useState(isCodeEditorExpanded);
const [isCodeEditorExpandedFocused, setIsCodeEditorExpandedFocused] = useState(false);
@ -166,7 +196,8 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({
Boolean(errors?.length),
Boolean(warning),
isCodeEditorExpandedFocused,
Boolean(documentationSections)
Boolean(documentationSections),
Boolean(editorIsInline)
);
const isDark = isDarkMode;
const editorModel = useRef<monaco.editor.ITextModel>();
@ -216,6 +247,11 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({
[editorHeight]
);
const onQuerySubmit = useCallback(() => {
const currentValue = editor1.current?.getValue();
onTextLangQuerySubmit({ [language]: currentValue } as AggregateQuery);
}, [language, onTextLangQuerySubmit]);
const restoreInitialMode = () => {
setIsCodeEditorExpandedFocused(false);
if (isCodeEditorExpanded) return;
@ -355,6 +391,7 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({
}, [code, isCodeEditorExpanded, isWordWrapped]);
const onResize = ({ width }: { width: number }) => {
setIsSpaceReduced(Boolean(editorIsInline && width < BREAKPOINT_WIDTH));
calculateVisibleCode(width);
if (editor1.current) {
editor1.current.layout({ width, height: editorHeight });
@ -514,6 +551,7 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({
<EuiButtonIcon
iconType={isWordWrapped ? 'wordWrap' : 'wordWrapDisabled'}
color="text"
size="s"
data-test-subj="TextBasedLangEditor-toggleWordWrap"
aria-label={
isWordWrapped
@ -568,6 +606,7 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({
}
)}
data-test-subj="TextBasedLangEditor-minimize"
size="s"
onClick={() => {
expandCodeEditor(false);
updateLinesFromModel = false;
@ -584,6 +623,7 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({
sections={documentationSections}
buttonProps={{
color: 'text',
size: 's',
'data-test-subj': 'TextBasedLangEditor-documentation',
'aria-label': i18n.translate(
'textBasedEditor.query.textBasedLanguagesEditor.documentationLabel',
@ -712,7 +752,7 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({
// eslint-disable-next-line no-bitwise
monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter,
function () {
onTextLangQuerySubmit();
onQuerySubmit();
}
);
if (!isCodeEditorExpanded) {
@ -729,9 +769,12 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({
errors={editorErrors}
warning={editorWarning}
onErrorClick={onErrorClick}
refreshErrors={onTextLangQuerySubmit}
runQuery={onQuerySubmit}
detectTimestamp={detectTimestamp}
editorIsInline={editorIsInline}
disableSubmitAction={disableSubmitAction}
hideRunQueryText={hideRunQueryText}
isSpaceReduced={isSpaceReduced}
/>
)}
</div>
@ -816,15 +859,19 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({
errors={editorErrors}
warning={editorWarning}
onErrorClick={onErrorClick}
refreshErrors={onTextLangQuerySubmit}
runQuery={onQuerySubmit}
detectTimestamp={detectTimestamp}
hideRunQueryText={hideRunQueryText}
editorIsInline={editorIsInline}
disableSubmitAction={disableSubmitAction}
isSpaceReduced={isSpaceReduced}
/>
)}
{isCodeEditorExpanded && (
<ResizableButton
onMouseDownResizeHandler={onMouseDownResizeHandler}
onKeyDownResizeHandler={onKeyDownResizeHandler}
editorIsInline={editorIsInline}
/>
)}
</>

View file

@ -76,6 +76,7 @@ export interface ChartProps {
lensAdapters?: UnifiedHistogramChartLoadEvent['adapters'];
lensEmbeddableOutput$?: Observable<LensEmbeddableOutput>;
isOnHistogramMode?: boolean;
histogramQuery?: AggregateQuery;
isChartLoading?: boolean;
onResetChartHeight?: () => void;
onChartHiddenChange?: (chartHidden: boolean) => void;
@ -115,6 +116,7 @@ export function Chart({
lensAdapters,
lensEmbeddableOutput$,
isOnHistogramMode,
histogramQuery,
isChartLoading,
onResetChartHeight,
onChartHiddenChange,
@ -216,7 +218,7 @@ export function Chart({
getLensAttributes({
title: chart?.title,
filters,
query,
query: histogramQuery ?? query,
dataView,
timeInterval: chart?.timeInterval,
breakdownField: breakdown?.field,
@ -230,6 +232,7 @@ export function Chart({
dataView,
filters,
query,
histogramQuery,
]
);

View file

@ -68,6 +68,7 @@ export function ChartConfigPanel({
updatePanelState={updateSuggestion}
lensAdapters={lensAdapters}
output$={lensEmbeddableOutput$}
displayFlyoutHeader
closeFlyout={() => {
setIsFlyoutVisible(false);
}}

View file

@ -38,6 +38,7 @@ describe('useLensSuggestions', () => {
allSuggestions: [],
currentSuggestion: undefined,
isOnHistogramMode: false,
histogramQuery: undefined,
suggestionUnsupported: false,
});
});
@ -66,6 +67,7 @@ describe('useLensSuggestions', () => {
allSuggestions: allSuggestionsMock,
currentSuggestion: allSuggestionsMock[0],
isOnHistogramMode: false,
histogramQuery: undefined,
suggestionUnsupported: false,
});
});
@ -94,6 +96,7 @@ describe('useLensSuggestions', () => {
allSuggestions: [],
currentSuggestion: undefined,
isOnHistogramMode: false,
histogramQuery: undefined,
suggestionUnsupported: true,
});
});
@ -133,6 +136,9 @@ describe('useLensSuggestions', () => {
allSuggestions: [],
currentSuggestion: allSuggestionsMock[0],
isOnHistogramMode: true,
histogramQuery: {
esql: 'from the-data-view | limit 100 | EVAL timestamp=DATE_TRUNC(30 minute, @timestamp) | stats rows = count(*) by timestamp | rename timestamp as `@timestamp every 30 minute`',
},
suggestionUnsupported: false,
});
});
@ -172,6 +178,7 @@ describe('useLensSuggestions', () => {
allSuggestions: [],
currentSuggestion: undefined,
isOnHistogramMode: false,
histogramQuery: undefined,
suggestionUnsupported: true,
});
});

View file

@ -61,7 +61,7 @@ export const useLensSuggestions = ({
const [allSuggestions, setAllSuggestions] = useState(suggestions.allSuggestions);
const currentSuggestion = originalSuggestion ?? suggestions.firstSuggestion;
const suggestionDeps = useRef(getSuggestionDeps({ dataView, query, columns }));
const histogramQuery = useRef<AggregateQuery | undefined>();
const histogramSuggestion = useMemo(() => {
if (
!currentSuggestion &&
@ -85,8 +85,7 @@ export const useLensSuggestions = ({
const interval = computeInterval(timeRange, data);
const language = getAggregateQueryMode(query);
const histogramQuery = `${query[language]}
| EVAL timestamp=DATE_TRUNC(${interval}, ${dataView.timeFieldName}) | stats rows = count(*) by timestamp | rename timestamp as \`${dataView.timeFieldName} every ${interval}\``;
const esqlQuery = `${query[language]} | EVAL timestamp=DATE_TRUNC(${interval}, ${dataView.timeFieldName}) | stats rows = count(*) by timestamp | rename timestamp as \`${dataView.timeFieldName} every ${interval}\``;
const context = {
dataViewSpec: dataView?.toSpec(),
fieldName: '',
@ -107,15 +106,16 @@ export const useLensSuggestions = ({
},
] as DatatableColumn[],
query: {
esql: histogramQuery,
esql: esqlQuery,
},
};
const sug = lensSuggestionsApi(context, dataView, ['lnsDatatable']) ?? [];
if (sug.length) {
histogramQuery.current = { esql: esqlQuery };
return sug[0];
}
return undefined;
}
histogramQuery.current = undefined;
return undefined;
}, [currentSuggestion, dataView, query, timeRange, data, lensSuggestionsApi]);
@ -142,6 +142,7 @@ export const useLensSuggestions = ({
currentSuggestion: histogramSuggestion ?? currentSuggestion,
suggestionUnsupported: !currentSuggestion && !histogramSuggestion && isPlainRecord,
isOnHistogramMode: Boolean(histogramSuggestion),
histogramQuery: histogramQuery.current ? histogramQuery.current : undefined,
};
};

View file

@ -215,18 +215,23 @@ export const UnifiedHistogramLayout = ({
children,
withDefaultActions,
}: UnifiedHistogramLayoutProps) => {
const { allSuggestions, currentSuggestion, suggestionUnsupported, isOnHistogramMode } =
useLensSuggestions({
dataView,
query,
originalSuggestion,
isPlainRecord,
columns,
timeRange,
data: services.data,
lensSuggestionsApi,
onSuggestionChange,
});
const {
allSuggestions,
currentSuggestion,
suggestionUnsupported,
isOnHistogramMode,
histogramQuery,
} = useLensSuggestions({
dataView,
query,
originalSuggestion,
isPlainRecord,
columns,
timeRange,
data: services.data,
lensSuggestionsApi,
onSuggestionChange,
});
const chart = suggestionUnsupported ? undefined : originalChart;
const [topPanelNode] = useState(() =>
@ -302,6 +307,7 @@ export const UnifiedHistogramLayout = ({
lensAdapters={lensAdapters}
lensEmbeddableOutput$={lensEmbeddableOutput$}
isOnHistogramMode={isOnHistogramMode}
histogramQuery={histogramQuery}
withDefaultActions={withDefaultActions}
/>
</InPortal>

View file

@ -45,6 +45,7 @@ export interface ColumnState {
summaryRow?: 'none' | 'sum' | 'avg' | 'count' | 'min' | 'max';
summaryLabel?: string;
collapseFn?: CollapseFunction;
isMetric?: boolean;
}
export type DatatableColumnResult = ColumnState & { type: 'lens_datatable_column' };

View file

@ -56,6 +56,7 @@
"embeddable",
"fieldFormats",
"charts",
"textBasedLanguages",
],
"extraPublicDirs": [
"common/constants"

View file

@ -0,0 +1,157 @@
/*
* 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 {
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiTitle,
EuiToolTip,
EuiButton,
EuiLink,
EuiBetaBadge,
} from '@elastic/eui';
import { euiThemeVars } from '@kbn/ui-theme';
import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import type { FlyoutWrapperProps } from './types';
export const FlyoutWrapper = ({
children,
isInlineFlyoutVisible,
isScrollable,
displayFlyoutHeader,
language,
attributesChanged,
onCancel,
navigateToLensEditor,
onApply,
}: FlyoutWrapperProps) => {
return (
<>
{isInlineFlyoutVisible && displayFlyoutHeader && (
<EuiFlyoutHeader
hasBorder
css={css`
pointer-events: auto;
background-color: ${euiThemeVars.euiColorEmptyShade};
`}
data-test-subj="editFlyoutHeader"
>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center" responsive={false}>
<EuiFlexItem grow={false}>
<EuiTitle size="xs">
<h2>
{i18n.translate('xpack.lens.config.editVisualizationLabel', {
defaultMessage: 'Edit {lang} visualization',
values: { lang: language },
})}
<EuiToolTip
content={i18n.translate('xpack.lens.config.experimentalLabel', {
defaultMessage:
'Technical preview, ES|QL currently offers limited configuration options',
})}
>
<EuiBetaBadge
label="Lab"
iconType="beaker"
size="s"
css={css`
margin-left: ${euiThemeVars.euiSizeXS};
`}
/>
</EuiToolTip>
</h2>
</EuiTitle>
</EuiFlexItem>
{navigateToLensEditor && (
<EuiFlexItem grow={false}>
<EuiLink onClick={navigateToLensEditor} data-test-subj="navigateToLensEditorLink">
{i18n.translate('xpack.lens.config.editLinkLabel', {
defaultMessage: 'Edit in Lens',
})}
</EuiLink>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlyoutHeader>
)}
<EuiFlyoutBody
className="lnsEditFlyoutBody"
css={css`
// styles needed to display extra drop targets that are outside of the config panel main area
overflow-y: auto;
padding-left: ${euiThemeVars.euiFormMaxWidth};
margin-left: -${euiThemeVars.euiFormMaxWidth};
pointer-events: none;
.euiFlyoutBody__overflow {
-webkit-mask-image: none;
padding-left: inherit;
margin-left: inherit;
${!isScrollable &&
`
overflow-y: hidden;
`}
> * {
pointer-events: auto;
}
}
.euiFlyoutBody__overflowContent {
padding: 0;
block-size: 100%;
}
`}
>
{children}
</EuiFlyoutBody>
{isInlineFlyoutVisible && (
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
onClick={onCancel}
flush="left"
aria-label={i18n.translate('xpack.lens.config.cancelFlyoutAriaLabel', {
defaultMessage: 'Cancel applied changes',
})}
data-test-subj="cancelFlyoutButton"
>
<FormattedMessage
id="xpack.lens.config.cancelFlyoutLabel"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
onClick={onApply}
fill
aria-label={i18n.translate('xpack.lens.config.applyFlyoutAriaLabel', {
defaultMessage: 'Apply changes',
})}
disabled={!attributesChanged}
iconType="check"
data-test-subj="applyFlyoutButton"
>
<FormattedMessage
id="xpack.lens.config.applyFlyoutLabel"
defaultMessage="Apply and close"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
)}
</>
);
};

View file

@ -39,11 +39,11 @@ describe('Lens flyout', () => {
newDatasourceState: 'newDatasourceState',
})
);
expect(updaterFn).toHaveBeenCalledWith('newDatasourceState', null);
expect(updaterFn).toHaveBeenCalledWith('newDatasourceState', null, 'testVis');
store.dispatch(
updateVisualizationState({ visualizationId: 'testVis', newState: 'newVisState' })
);
expect(updaterFn).toHaveBeenCalledWith('newDatasourceState', 'newVisState');
expect(updaterFn).toHaveBeenCalledWith('newDatasourceState', 'newVisState', 'testVis');
});
test('updater is not run if it does not modify visualization or datasource state', () => {

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useCallback } from 'react';
import React, { useCallback, useState } from 'react';
import { EuiFlyout, EuiLoadingSpinner, EuiOverlayMask } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Provider } from 'react-redux';
@ -25,16 +25,21 @@ import {
} from '../../../state_management';
import { generateId } from '../../../id_generator';
import type { DatasourceMap, VisualizationMap } from '../../../types';
import {
LensEditConfigurationFlyout,
type EditConfigPanelProps,
} from './lens_configuration_flyout';
import { LensEditConfigurationFlyout } from './lens_configuration_flyout';
import type { EditConfigPanelProps } from './types';
import { SavedObjectIndexStore, type Document } from '../../../persistence';
import type { TypedLensByValueInput } from '../../../embeddable/embeddable_component';
import { DOC_TYPE } from '../../../../common/constants';
export type EditLensConfigurationProps = Omit<
EditConfigPanelProps,
'startDependencies' | 'coreStart' | 'visualizationMap' | 'datasourceMap' | 'saveByRef'
| 'startDependencies'
| 'coreStart'
| 'visualizationMap'
| 'datasourceMap'
| 'saveByRef'
| 'setCurrentAttributes'
| 'previousAttributes'
>;
function LoadingSpinnerWithOverlay() {
return (
@ -44,7 +49,11 @@ function LoadingSpinnerWithOverlay() {
);
}
type UpdaterType = (datasourceState: unknown, visualizationState: unknown) => void;
type UpdaterType = (
datasourceState: unknown,
visualizationState: unknown,
visualizationType?: string
) => void;
// exported for testing
export const updatingMiddleware =
@ -68,7 +77,12 @@ export const updatingMiddleware =
if (initExisting.match(action) || initEmpty.match(action)) {
return;
}
updater(datasourceStates[activeDatasourceId].state, visualization.state);
updater(
datasourceStates[activeDatasourceId].state,
visualization.state,
visualization.activeId
);
}
};
@ -88,6 +102,7 @@ export async function getEditLensConfiguration(
return ({
attributes,
updatePanelState,
updateSuggestion,
closeFlyout,
wrapInFlyout,
datasourceId,
@ -98,10 +113,13 @@ export async function getEditLensConfiguration(
updateByRefInput,
navigateToLensEditor,
displayFlyoutHeader,
canEditTextBasedQuery,
}: EditLensConfigurationProps) => {
if (!lensServices || !datasourceMap || !visualizationMap) {
return <LoadingSpinnerWithOverlay />;
}
const [currentAttributes, setCurrentAttributes] =
useState<TypedLensByValueInput['attributes']>(attributes);
/**
* During inline editing of a by reference panel, the panel is converted to a by value one.
* When the user applies the changes we save them to the Lens SO
@ -117,7 +135,7 @@ export async function getEditLensConfiguration(
},
[savedObjectId]
);
const datasourceState = attributes.state.datasourceStates[datasourceId];
const datasourceState = currentAttributes.state.datasourceStates[datasourceId];
const storeDeps = {
lensServices,
datasourceMap,
@ -135,7 +153,7 @@ export async function getEditLensConfiguration(
lensStore.dispatch(
loadInitial({
initialInput: {
attributes,
attributes: currentAttributes,
id: panelId ?? generateId(),
},
inlineEditing: true,
@ -148,6 +166,7 @@ export async function getEditLensConfiguration(
<EuiFlyout
type="push"
ownFocus
paddingSize="m"
onClose={() => {
closeFlyout?.();
}}
@ -169,8 +188,9 @@ export async function getEditLensConfiguration(
};
const configPanelProps = {
attributes,
attributes: currentAttributes,
updatePanelState,
updateSuggestion,
closeFlyout,
datasourceId,
coreStart,
@ -184,6 +204,8 @@ export async function getEditLensConfiguration(
updateByRefInput,
navigateToLensEditor,
displayFlyoutHeader,
canEditTextBasedQuery,
setCurrentAttributes,
};
return getWrapper(

View file

@ -0,0 +1,127 @@
/*
* 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 { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import type { LensPluginStartDependencies } from '../../../plugin';
import { createMockStartDependencies } from '../../../editor_frame_service/mocks';
import {
mockVisualizationMap,
mockDatasourceMap,
mockDataViewWithTimefield,
mockAllSuggestions,
} from '../../../mocks';
import { suggestionsApi } from '../../../lens_suggestions_api';
import { fetchDataFromAggregateQuery } from '../../../datasources/text_based/fetch_data_from_aggregate_query';
import { getSuggestions } from './helpers';
const mockSuggestionApi = suggestionsApi as jest.Mock;
const mockFetchData = fetchDataFromAggregateQuery as jest.Mock;
jest.mock('../../../lens_suggestions_api', () => ({
suggestionsApi: jest.fn(() => mockAllSuggestions),
}));
jest.mock('../../../datasources/text_based/fetch_data_from_aggregate_query', () => ({
fetchDataFromAggregateQuery: jest.fn(() => {
return {
columns: [
{
name: '@timestamp',
id: '@timestamp',
meta: {
type: 'date',
},
},
{
name: 'bytes',
id: 'bytes',
meta: {
type: 'number',
},
},
{
name: 'memory',
id: 'memory',
meta: {
type: 'number',
},
},
],
};
}),
}));
describe('getSuggestions', () => {
const query = {
esql: 'from index1 | limit 10 | stats average = avg(bytes',
};
const mockStartDependencies =
createMockStartDependencies() as unknown as LensPluginStartDependencies;
const dataViews = dataViewPluginMocks.createStartContract();
dataViews.create.mockResolvedValue(mockDataViewWithTimefield);
const dataviewSpecArr = [
{
id: 'd2588ae7-9ea0-4439-9f5b-f808754a3b97',
title: 'index1',
timeFieldName: '@timestamp',
sourceFilters: [],
fieldFormats: {},
runtimeFieldMap: {},
fieldAttrs: {},
allowNoIndex: false,
name: 'index1',
},
];
const startDependencies = {
...mockStartDependencies,
dataViews,
};
it('returns the suggestions attributes correctly', async () => {
const suggestionsAttributes = await getSuggestions(
query,
startDependencies,
mockDatasourceMap(),
mockVisualizationMap(),
dataviewSpecArr,
jest.fn()
);
expect(suggestionsAttributes?.visualizationType).toBe(mockAllSuggestions[0].visualizationId);
expect(suggestionsAttributes?.state.visualization).toStrictEqual(
mockAllSuggestions[0].visualizationState
);
});
it('returns undefined if no suggestions are computed', async () => {
mockSuggestionApi.mockResolvedValueOnce([]);
const suggestionsAttributes = await getSuggestions(
query,
startDependencies,
mockDatasourceMap(),
mockVisualizationMap(),
dataviewSpecArr,
jest.fn()
);
expect(suggestionsAttributes).toBeUndefined();
});
it('returns an error if fetching the data fails', async () => {
mockFetchData.mockImplementation(() => {
throw new Error('sorry!');
});
const setErrorsSpy = jest.fn();
const suggestionsAttributes = await getSuggestions(
query,
startDependencies,
mockDatasourceMap(),
mockVisualizationMap(),
dataviewSpecArr,
setErrorsSpy
);
expect(suggestionsAttributes).toBeUndefined();
expect(setErrorsSpy).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,148 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { getIndexPatternFromSQLQuery, getIndexPatternFromESQLQuery } from '@kbn/es-query';
import type { AggregateQuery, Query, Filter } from '@kbn/es-query';
import type { DataView, DataViewSpec } from '@kbn/data-views-plugin/public';
import type { Suggestion } from '../../../types';
import type { TypedLensByValueInput } from '../../../embeddable/embeddable_component';
import type { LensPluginStartDependencies } from '../../../plugin';
import type { DatasourceMap, VisualizationMap } from '../../../types';
import { fetchDataFromAggregateQuery } from '../../../datasources/text_based/fetch_data_from_aggregate_query';
import { suggestionsApi } from '../../../lens_suggestions_api';
export const getQueryColumns = async (
query: AggregateQuery,
dataView: DataView,
deps: LensPluginStartDependencies
) => {
// Fetching only columns for ES|QL for performance reasons with limit 0
// Important note: ES doesnt return the warnings for 0 limit,
// I am skipping them in favor of performance now
// but we should think another way to get them (from Lens embeddable or store)
const performantQuery = { ...query };
if ('esql' in performantQuery && performantQuery.esql) {
performantQuery.esql = `${performantQuery.esql} | limit 0`;
}
const table = await fetchDataFromAggregateQuery(
performantQuery,
dataView,
deps.data,
deps.expressions
);
return table?.columns;
};
export const getSuggestions = async (
query: AggregateQuery,
deps: LensPluginStartDependencies,
datasourceMap: DatasourceMap,
visualizationMap: VisualizationMap,
adHocDataViews: DataViewSpec[],
setErrors: (errors: Error[]) => void
) => {
try {
let indexPattern = '';
if ('sql' in query) {
indexPattern = getIndexPatternFromSQLQuery(query.sql);
}
if ('esql' in query) {
indexPattern = getIndexPatternFromESQLQuery(query.esql);
}
const dataViewSpec = adHocDataViews.find((adHoc) => {
return adHoc.name === indexPattern;
});
const dataView = await deps.dataViews.create(
dataViewSpec ?? {
title: indexPattern,
}
);
if (dataView.fields.getByName('@timestamp')?.type === 'date' && !dataViewSpec) {
dataView.timeFieldName = '@timestamp';
}
const columns = await getQueryColumns(query, dataView, deps);
const context = {
dataViewSpec: dataView?.toSpec(),
fieldName: '',
textBasedColumns: columns,
query,
};
const allSuggestions =
suggestionsApi({ context, dataView, datasourceMap, visualizationMap }) ?? [];
// Lens might not return suggestions for some cases, i.e. in case of errors
if (!allSuggestions.length) return undefined;
const firstSuggestion = allSuggestions[0];
const attrs = getLensAttributes({
filters: [],
query,
suggestion: firstSuggestion,
dataView,
});
return attrs;
} catch (e) {
setErrors([e]);
}
return undefined;
};
export const getLensAttributes = ({
filters,
query,
suggestion,
dataView,
}: {
filters: Filter[];
query: Query | AggregateQuery;
suggestion: Suggestion | undefined;
dataView?: DataView;
}) => {
const suggestionDatasourceState = Object.assign({}, suggestion?.datasourceState);
const suggestionVisualizationState = Object.assign({}, suggestion?.visualizationState);
const datasourceStates =
suggestion && suggestion.datasourceState
? {
[suggestion.datasourceId!]: {
...suggestionDatasourceState,
},
}
: {
formBased: {},
};
const visualization = suggestionVisualizationState;
const attributes = {
title: suggestion
? suggestion.title
: i18n.translate('xpack.lens.config.suggestion.title', {
defaultMessage: 'New suggestion',
}),
references: [
{
id: dataView?.id ?? '',
name: `textBasedLanguages-datasource-layer-suggestion`,
type: 'index-pattern',
},
],
state: {
datasourceStates,
filters,
query,
visualization,
...(dataView &&
dataView.id &&
!dataView.isPersisted() && {
adHocDataViews: { [dataView.id]: dataView.toSpec(false) },
}),
},
visualizationType: suggestion ? suggestion.visualizationId : 'lnsXY',
} as TypedLensByValueInput['attributes'];
return attributes;
};

View file

@ -0,0 +1,76 @@
/*
* 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 { EuiSpacer, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import { VisualizationToolbar } from '../../../editor_frame_service/editor_frame/workspace_panel';
import { ConfigPanelWrapper } from '../../../editor_frame_service/editor_frame/config_panel/config_panel';
import { createIndexPatternService } from '../../../data_views_service/service';
import { useLensDispatch, updateIndexPatterns } from '../../../state_management';
import { replaceIndexpattern } from '../../../state_management/lens_slice';
import type { LayerConfigurationProps } from './types';
import { useLensSelector } from '../../../state_management';
export function LayerConfiguration({
attributes,
coreStart,
startDependencies,
visualizationMap,
datasourceMap,
datasourceId,
framePublicAPI,
hasPadding,
setIsInlineFlyoutVisible,
}: LayerConfigurationProps) {
const dispatch = useLensDispatch();
const { euiTheme } = useEuiTheme();
const { visualization } = useLensSelector((state) => state.lens);
const activeVisualization =
visualizationMap[visualization.activeId ?? attributes.visualizationType];
const indexPatternService = useMemo(
() =>
createIndexPatternService({
dataViews: startDependencies.dataViews,
uiActions: startDependencies.uiActions,
core: coreStart,
updateIndexPatterns: (newIndexPatternsState, options) => {
dispatch(updateIndexPatterns(newIndexPatternsState));
},
replaceIndexPattern: (newIndexPattern, oldId, options) => {
dispatch(replaceIndexpattern({ newIndexPattern, oldId }));
},
}),
[coreStart, dispatch, startDependencies.dataViews, startDependencies.uiActions]
);
const layerPanelsProps = {
framePublicAPI,
datasourceMap,
visualizationMap,
core: coreStart,
dataViews: startDependencies.dataViews,
uiActions: startDependencies.uiActions,
hideLayerHeader: datasourceId === 'textBased',
indexPatternService,
setIsInlineFlyoutVisible,
};
return (
<div
css={css`
padding: ${hasPadding ? euiTheme.size.s : 0};
`}
>
<VisualizationToolbar
activeVisualization={activeVisualization}
framePublicAPI={framePublicAPI}
/>
<EuiSpacer size="m" />
<ConfigPanelWrapper {...layerPanelsProps} />
</div>
);
}

View file

@ -14,10 +14,8 @@ import { mockVisualizationMap, mockDatasourceMap, mockDataPlugin } from '../../.
import type { LensPluginStartDependencies } from '../../../plugin';
import { createMockStartDependencies } from '../../../editor_frame_service/mocks';
import type { TypedLensByValueInput } from '../../../embeddable/embeddable_component';
import {
LensEditConfigurationFlyout,
type EditConfigPanelProps,
} from './lens_configuration_flyout';
import { LensEditConfigurationFlyout } from './lens_configuration_flyout';
import type { EditConfigPanelProps } from './types';
const lensAttributes = {
title: 'test',
@ -29,14 +27,12 @@ const lensAttributes = {
visualization: {},
filters: [],
query: {
language: 'lucene',
query: '',
esql: 'from index1 | limit 10',
},
},
filters: [],
query: {
language: 'lucene',
query: '',
esql: 'from index1 | limit 10',
},
references: [],
} as unknown as TypedLensByValueInput['attributes'];
@ -109,6 +105,16 @@ describe('LensEditConfigurationFlyout', () => {
expect(closeFlyoutSpy).toHaveBeenCalled();
});
it('should call the updatePanelState callback if cancel button is clicked', async () => {
const updatePanelStateSpy = jest.fn();
renderConfigFlyout({
updatePanelState: updatePanelStateSpy,
});
expect(screen.getByTestId('lns-layerPanel-0')).toBeInTheDocument();
userEvent.click(screen.getByTestId('cancelFlyoutButton'));
expect(updatePanelStateSpy).toHaveBeenCalled();
});
it('should call the updateByRefInput callback if cancel button is clicked and savedObjectId exists', async () => {
const updateByRefInputSpy = jest.fn();
@ -135,4 +141,41 @@ describe('LensEditConfigurationFlyout', () => {
expect(updateByRefInputSpy).toHaveBeenCalled();
expect(saveByRefSpy).toHaveBeenCalled();
});
it('should not display the editor if canEditTextBasedQuery prop is false', async () => {
renderConfigFlyout({
canEditTextBasedQuery: false,
});
expect(screen.queryByTestId('TextBasedLangEditor')).toBeNull();
});
it('should not display the editor if canEditTextBasedQuery prop is true but the query is not text based', async () => {
renderConfigFlyout({
canEditTextBasedQuery: true,
attributes: {
...lensAttributes,
state: {
...lensAttributes.state,
query: {
type: 'kql',
query: '',
} as unknown as Query,
},
},
});
expect(screen.queryByTestId('TextBasedLangEditor')).toBeNull();
});
it('should display the suggestions if canEditTextBasedQuery prop is true', async () => {
renderConfigFlyout(
{
canEditTextBasedQuery: true,
},
{
esql: 'from index1 | limit 10',
}
);
expect(screen.getByTestId('InlineEditingESQLEditor')).toBeInTheDocument();
expect(screen.getByTestId('InlineEditingSuggestions')).toBeInTheDocument();
});
});

View file

@ -6,85 +6,34 @@
*/
import React, { useMemo, useCallback, useRef, useEffect, useState } from 'react';
import { isEqual } from 'lodash';
import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n';
import {
EuiButtonEmpty,
EuiButton,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiTitle,
EuiLink,
EuiIcon,
EuiToolTip,
EuiSpacer,
EuiAccordion,
useEuiTheme,
EuiFlexGroup,
EuiFlexItem,
useEuiTheme,
EuiCallOut,
euiScrollBarStyles,
} from '@elastic/eui';
import { isEqual } from 'lodash';
import type { Observable } from 'rxjs';
import { euiThemeVars } from '@kbn/ui-theme';
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 { LensPluginStartDependencies } from '../../../plugin';
import {
useLensSelector,
selectFramePublicAPI,
useLensDispatch,
updateIndexPatterns,
} from '../../../state_management';
import { replaceIndexpattern } from '../../../state_management/lens_slice';
import { VisualizationToolbar } from '../../../editor_frame_service/editor_frame/workspace_panel';
import type { DatasourceMap, VisualizationMap } from '../../../types';
getAggregateQueryMode,
isOfAggregateQueryType,
getLanguageDisplayName,
} from '@kbn/es-query';
import type { AggregateQuery, Query } from '@kbn/es-query';
import { TextBasedLangEditor } from '@kbn/text-based-languages/public';
import { useLensSelector, selectFramePublicAPI } from '../../../state_management';
import type { TypedLensByValueInput } from '../../../embeddable/embeddable_component';
import type { LensEmbeddableOutput } from '../../../embeddable';
import type { LensInspector } from '../../../lens_inspector_service';
import { ConfigPanelWrapper } from '../../../editor_frame_service/editor_frame/config_panel/config_panel';
import { extractReferencesFromState } from '../../../utils';
import type { Document } from '../../../persistence';
import { createIndexPatternService } from '../../../data_views_service/service';
export interface EditConfigPanelProps {
coreStart: CoreStart;
startDependencies: LensPluginStartDependencies;
visualizationMap: VisualizationMap;
datasourceMap: DatasourceMap;
/** The attributes of the Lens embeddable */
attributes: TypedLensByValueInput['attributes'];
/** Callback for updating the visualization and datasources state */
updatePanelState: (datasourceState: unknown, visualizationState: unknown) => void;
/** Lens visualizations can be either created from ESQL (textBased) or from dataviews (formBased) */
datasourceId: 'formBased' | 'textBased';
/** Embeddable output observable, useful for dashboard flyout */
output$?: Observable<LensEmbeddableOutput>;
/** Contains the active data, necessary for some panel configuration such as coloring */
lensAdapters?: LensInspector['adapters'];
/** Optional callback called when updating the by reference embeddable */
updateByRefInput?: (soId: string) => void;
/** Callback for closing the edit flyout */
closeFlyout?: () => void;
/** Boolean used for adding a flyout wrapper */
wrapInFlyout?: boolean;
/** Optional parameter for panel identification
* If not given, Lens generates a new one
*/
panelId?: string;
/** Optional parameter for saved object id
* Should be given if the lens embeddable is a by reference one
* (saved in the library)
*/
savedObjectId?: string;
/** Callback for saving the embeddable as a SO */
saveByRef?: (attrs: Document) => void;
/** Optional callback for navigation from the header of the flyout */
navigateToLensEditor?: () => void;
/** If set to true it displays a header on the flyout */
displayFlyoutHeader?: boolean;
}
import { LayerConfiguration } from './layer_configuration_section';
import type { EditConfigPanelProps } from './types';
import { FlyoutWrapper } from './flyout_wrapper';
import { getSuggestions } from './helpers';
import { SuggestionPanel } from '../../../editor_frame_service/editor_frame/suggestion_panel';
export function LensEditConfigurationFlyout({
attributes,
@ -94,6 +43,8 @@ export function LensEditConfigurationFlyout({
datasourceMap,
datasourceId,
updatePanelState,
updateSuggestion,
setCurrentAttributes,
closeFlyout,
saveByRef,
savedObjectId,
@ -102,15 +53,21 @@ export function LensEditConfigurationFlyout({
lensAdapters,
navigateToLensEditor,
displayFlyoutHeader,
canEditTextBasedQuery,
}: EditConfigPanelProps) {
const euiTheme = useEuiTheme();
const previousAttributes = useRef<TypedLensByValueInput['attributes']>(attributes);
const prevQuery = useRef<AggregateQuery | Query>(attributes.state.query);
const [query, setQuery] = useState<AggregateQuery | Query>(attributes.state.query);
const [errors, setErrors] = useState<Error[] | undefined>();
const [isInlineFlyoutVisible, setIsInlineFlyoutVisible] = useState(true);
const [isLayerAccordionOpen, setIsLayerAccordionOpen] = useState(true);
const [isSuggestionsAccordionOpen, setIsSuggestionsAccordionOpen] = useState(false);
const datasourceState = attributes.state.datasourceStates[datasourceId];
const activeVisualization = visualizationMap[attributes.visualizationType];
const activeDatasource = datasourceMap[datasourceId];
const [isInlineFooterVisible, setIsInlineFlyoutFooterVisible] = useState(true);
const { euiTheme } = useEuiTheme();
const { datasourceStates, visualization, isLoading } = useLensSelector((state) => state.lens);
const dispatch = useLensDispatch();
const suggestsLimitedColumns = activeDatasource?.suggestsLimitedColumns?.(datasourceState);
const activeData: Record<string, Datatable> = useMemo(() => {
return {};
}, []);
@ -149,28 +106,34 @@ export function LensEditConfigurationFlyout({
const onCancel = useCallback(() => {
const previousAttrs = previousAttributes.current;
if (attributesChanged) {
const currentDatasourceState = datasourceMap[datasourceId].injectReferencesToLayers
? datasourceMap[datasourceId]?.injectReferencesToLayers?.(
previousAttrs.state.datasourceStates[datasourceId],
previousAttrs.references
)
: previousAttrs.state.datasourceStates[datasourceId];
updatePanelState?.(currentDatasourceState, previousAttrs.state.visualization);
if (previousAttrs.visualizationType === visualization.activeId) {
const currentDatasourceState = datasourceMap[datasourceId].injectReferencesToLayers
? datasourceMap[datasourceId]?.injectReferencesToLayers?.(
previousAttrs.state.datasourceStates[datasourceId],
previousAttrs.references
)
: previousAttrs.state.datasourceStates[datasourceId];
updatePanelState?.(currentDatasourceState, previousAttrs.state.visualization);
} else {
updateSuggestion?.(previousAttrs);
}
if (savedObjectId) {
updateByRefInput?.(savedObjectId);
}
}
closeFlyout?.();
}, [
previousAttributes,
attributesChanged,
savedObjectId,
closeFlyout,
datasourceMap,
datasourceId,
updatePanelState,
updateSuggestion,
savedObjectId,
updateByRefInput,
visualization,
]);
const onApply = useCallback(() => {
@ -218,20 +181,33 @@ export function LensEditConfigurationFlyout({
datasourceMap,
]);
const indexPatternService = useMemo(
() =>
createIndexPatternService({
dataViews: startDependencies.dataViews,
uiActions: startDependencies.uiActions,
core: coreStart,
updateIndexPatterns: (newIndexPatternsState, options) => {
dispatch(updateIndexPatterns(newIndexPatternsState));
},
replaceIndexPattern: (newIndexPattern, oldId, options) => {
dispatch(replaceIndexpattern({ newIndexPattern, oldId }));
},
}),
[coreStart, dispatch, startDependencies.dataViews, startDependencies.uiActions]
// needed for text based languages mode which works ONLY with adHoc dataviews
const adHocDataViews = Object.values(attributes.state.adHocDataViews ?? {});
const runQuery = useCallback(
async (q) => {
const attrs = await getSuggestions(
q,
startDependencies,
datasourceMap,
visualizationMap,
adHocDataViews,
setErrors
);
if (attrs) {
setCurrentAttributes?.(attrs);
setErrors([]);
updateSuggestion?.(attrs);
}
},
[
startDependencies,
datasourceMap,
visualizationMap,
adHocDataViews,
setCurrentAttributes,
updateSuggestion,
]
);
const framePublicAPI = useLensSelector((state) => {
@ -244,155 +220,197 @@ export function LensEditConfigurationFlyout({
};
return selectFramePublicAPI(newState, datasourceMap);
});
if (isLoading) return null;
const layerPanelsProps = {
framePublicAPI,
datasourceMap,
visualizationMap,
core: coreStart,
dataViews: startDependencies.dataViews,
uiActions: startDependencies.uiActions,
hideLayerHeader: datasourceId === 'textBased',
indexPatternService,
setIsInlineFlyoutFooterVisible,
};
const textBasedMode = isOfAggregateQueryType(query) ? getAggregateQueryMode(query) : undefined;
if (isLoading) return null;
// Example is the Discover editing where we dont want to render the text based editor on the panel
if (!canEditTextBasedQuery) {
return (
<FlyoutWrapper
isInlineFlyoutVisible={isInlineFlyoutVisible}
displayFlyoutHeader={displayFlyoutHeader}
onCancel={onCancel}
navigateToLensEditor={navigateToLensEditor}
onApply={onApply}
isScrollable={true}
attributesChanged={attributesChanged}
>
<LayerConfiguration
attributes={attributes}
coreStart={coreStart}
startDependencies={startDependencies}
visualizationMap={visualizationMap}
datasourceMap={datasourceMap}
datasourceId={datasourceId}
hasPadding={true}
framePublicAPI={framePublicAPI}
setIsInlineFlyoutVisible={setIsInlineFlyoutVisible}
/>
</FlyoutWrapper>
);
}
return (
<>
<EuiFlyoutBody
className="lnsEditFlyoutBody"
css={css`
// styles needed to display extra drop targets that are outside of the config panel main area
overflow-y: auto;
padding-left: ${euiThemeVars.euiFormMaxWidth};
margin-left: -${euiThemeVars.euiFormMaxWidth};
pointer-events: none;
.euiFlyoutBody__overflow {
padding-left: inherit;
margin-left: inherit;
> * {
pointer-events: auto;
}
}
.euiFlyoutBody__overflowContent {
padding: 0;
}
`}
<FlyoutWrapper
isInlineFlyoutVisible={isInlineFlyoutVisible}
displayFlyoutHeader={displayFlyoutHeader}
onCancel={onCancel}
navigateToLensEditor={navigateToLensEditor}
onApply={onApply}
attributesChanged={attributesChanged}
language={getLanguageDisplayName(textBasedMode)}
isScrollable={false}
>
<EuiFlexGroup gutterSize="s" direction="column">
{displayFlyoutHeader && (
<EuiFlexItem
data-test-subj="editFlyoutHeader"
css={css`
padding: ${euiThemeVars.euiSizeL};
border-block-end: 1px solid ${euiThemeVars.euiBorderColor};
`}
>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center" responsive={false}>
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center" gutterSize="xs">
<EuiFlexItem grow={false}>
<EuiTitle size="xs">
<h2 id="Edit visualization">
{i18n.translate('xpack.lens.config.editVisualizationLabel', {
defaultMessage: 'Edit visualization',
})}
</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip
content={i18n.translate('xpack.lens.config.experimentalLabel', {
defaultMessage: 'Technical preview',
})}
>
<EuiIcon type="beaker" size="m" />
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
{navigateToLensEditor && (
<EuiFlexItem grow={false}>
<EuiLink
onClick={navigateToLensEditor}
data-test-subj="navigateToLensEditorLink"
>
{i18n.translate('xpack.lens.config.editLinkLabel', {
defaultMessage: 'Edit in Lens',
})}
</EuiLink>
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiFlexGroup
css={css`
block-size: 100%;
.euiFlexItem,
.euiAccordion,
.euiAccordion__triggerWrapper,
.euiAccordion__childWrapper {
min-block-size: 0;
}
.euiAccordion {
display: flex;
flex: 1;
flex-direction: column;
}
.euiAccordion__childWrapper {
overflow-y: auto !important;
${euiScrollBarStyles(euiTheme)}
padding-left: ${euiThemeVars.euiFormMaxWidth};
margin-left: -${euiThemeVars.euiFormMaxWidth};
.euiAccordion-isOpen & {
block-size: auto !important;
flex: 1;
}
}
`}
direction="column"
gutterSize="none"
>
{isOfAggregateQueryType(query) && (
<EuiFlexItem grow={false} data-test-subj="InlineEditingESQLEditor">
<TextBasedLangEditor
query={query}
onTextLangQueryChange={(q) => {
setQuery(q);
prevQuery.current = q;
}}
expandCodeEditor={(status: boolean) => {}}
isCodeEditorExpanded
detectTimestamp={Boolean(adHocDataViews?.[0]?.timeFieldName)}
errors={errors}
warning={
suggestsLimitedColumns
? i18n.translate('xpack.lens.config.configFlyoutCallout', {
defaultMessage:
'Displaying a limited portion of the available fields. Add more from the configuration panel.',
})
: undefined
}
hideMinimizeButton
editorIsInline
hideRunQueryText
disableSubmitAction={isEqual(query, prevQuery.current)}
onTextLangQuerySubmit={(q) => {
if (q) {
runQuery(q);
}
}}
isDisabled={false}
/>
</EuiFlexItem>
)}
<EuiFlexItem
grow={isLayerAccordionOpen ? 1 : false}
css={css`
padding: ${euiTheme.size.s};
padding-left: ${euiThemeVars.euiSize};
padding-right: ${euiThemeVars.euiSize};
.euiAccordion__childWrapper {
flex: ${isLayerAccordionOpen ? 1 : 'none'}
}
}
`}
>
{datasourceId === 'textBased' && (
<EuiCallOut
size="s"
title={i18n.translate('xpack.lens.config.configFlyoutCallout', {
defaultMessage: 'ES|QL currently offers limited configuration options',
})}
iconType="iInCircle"
<EuiAccordion
id="layer-configuration"
buttonContent={
<EuiTitle
size="xxs"
css={css`
padding: 2px;
}
`}
>
<h5>
{i18n.translate('xpack.lens.config.layerConfigurationLabel', {
defaultMessage: 'Layer configuration',
})}
</h5>
</EuiTitle>
}
buttonProps={{
paddingSize: 'm',
}}
initialIsOpen={isLayerAccordionOpen}
forceState={isLayerAccordionOpen ? 'open' : 'closed'}
onToggle={(status) => {
if (status && isSuggestionsAccordionOpen) {
setIsSuggestionsAccordionOpen(!status);
}
setIsLayerAccordionOpen(!isLayerAccordionOpen);
}}
>
<LayerConfiguration
attributes={attributes}
coreStart={coreStart}
startDependencies={startDependencies}
visualizationMap={visualizationMap}
datasourceMap={datasourceMap}
datasourceId={datasourceId}
framePublicAPI={framePublicAPI}
setIsInlineFlyoutVisible={setIsInlineFlyoutVisible}
/>
)}
<EuiSpacer size="m" />
<VisualizationToolbar
activeVisualization={activeVisualization}
framePublicAPI={framePublicAPI}
/>
<EuiSpacer size="m" />
<ConfigPanelWrapper
{...layerPanelsProps}
css={css`
padding: ${euiTheme.size.s};
`}
</EuiAccordion>
</EuiFlexItem>
<EuiFlexItem
grow={isSuggestionsAccordionOpen ? 1 : false}
data-test-subj="InlineEditingSuggestions"
css={css`
border-top: ${euiThemeVars.euiBorderThin};
border-bottom: ${euiThemeVars.euiBorderThin};
padding-left: ${euiThemeVars.euiSize};
padding-right: ${euiThemeVars.euiSize};
.euiAccordion__childWrapper {
flex: ${isSuggestionsAccordionOpen ? 1 : 'none'}
}
}
`}
>
<SuggestionPanel
ExpressionRenderer={startDependencies.expressions.ReactExpressionRenderer}
datasourceMap={datasourceMap}
visualizationMap={visualizationMap}
frame={framePublicAPI}
core={coreStart}
nowProvider={startDependencies.data.nowProvider}
showOnlyIcons
wrapSuggestions
isAccordionOpen={isSuggestionsAccordionOpen}
toggleAccordionCb={(status) => {
if (!status && isLayerAccordionOpen) {
setIsLayerAccordionOpen(status);
}
setIsSuggestionsAccordionOpen(!isSuggestionsAccordionOpen);
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutBody>
{isInlineFooterVisible && (
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
onClick={onCancel}
flush="left"
aria-label={i18n.translate('xpack.lens.config.cancelFlyoutAriaLabel', {
defaultMessage: 'Cancel applied changes',
})}
data-test-subj="cancelFlyoutButton"
>
<FormattedMessage
id="xpack.lens.config.cancelFlyoutLabel"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
onClick={onApply}
fill
aria-label={i18n.translate('xpack.lens.config.applyFlyoutAriaLabel', {
defaultMessage: 'Apply changes',
})}
iconType="check"
isDisabled={!attributesChanged}
data-test-subj="applyFlyoutButton"
>
<FormattedMessage
id="xpack.lens.config.applyFlyoutLabel"
defaultMessage="Apply and close"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
)}
</FlyoutWrapper>
</>
);
}

View file

@ -0,0 +1,85 @@
/*
* 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 type { Observable } from 'rxjs';
import type { CoreStart } from '@kbn/core/public';
import type { TypedLensByValueInput } from '../../../embeddable/embeddable_component';
import type { LensPluginStartDependencies } from '../../../plugin';
import type { DatasourceMap, VisualizationMap, FramePublicAPI } from '../../../types';
import type { LensEmbeddableOutput } from '../../../embeddable';
import type { LensInspector } from '../../../lens_inspector_service';
import type { Document } from '../../../persistence';
export interface FlyoutWrapperProps {
children: JSX.Element;
isInlineFlyoutVisible: boolean;
isScrollable: boolean;
displayFlyoutHeader?: boolean;
language?: string;
attributesChanged?: boolean;
onCancel?: () => void;
onApply?: () => void;
navigateToLensEditor?: () => void;
}
export interface EditConfigPanelProps {
coreStart: CoreStart;
startDependencies: LensPluginStartDependencies;
visualizationMap: VisualizationMap;
datasourceMap: DatasourceMap;
/** The attributes of the Lens embeddable */
attributes: TypedLensByValueInput['attributes'];
/** Callback for updating the visualization and datasources state.*/
updatePanelState: (
datasourceState: unknown,
visualizationState: unknown,
visualizationType?: string
) => void;
updateSuggestion?: (attrs: TypedLensByValueInput['attributes']) => void;
/** Set the attributes state */
setCurrentAttributes?: (attrs: TypedLensByValueInput['attributes']) => void;
/** Lens visualizations can be either created from ESQL (textBased) or from dataviews (formBased) */
datasourceId: 'formBased' | 'textBased';
/** Embeddable output observable, useful for dashboard flyout */
output$?: Observable<LensEmbeddableOutput>;
/** Contains the active data, necessary for some panel configuration such as coloring */
lensAdapters?: LensInspector['adapters'];
/** Optional callback called when updating the by reference embeddable */
updateByRefInput?: (soId: string) => void;
/** Callback for closing the edit flyout */
closeFlyout?: () => void;
/** Boolean used for adding a flyout wrapper */
wrapInFlyout?: boolean;
/** Optional parameter for panel identification
* If not given, Lens generates a new one
*/
panelId?: string;
/** Optional parameter for saved object id
* Should be given if the lens embeddable is a by reference one
* (saved in the library)
*/
savedObjectId?: string;
/** Callback for saving the embeddable as a SO */
saveByRef?: (attrs: Document) => void;
/** Optional callback for navigation from the header of the flyout */
navigateToLensEditor?: () => void;
/** If set to true it displays a header on the flyout */
displayFlyoutHeader?: boolean;
/** If set to true the layout changes to accordion and the text based query (i.e. ES|QL) can be edited */
canEditTextBasedQuery?: boolean;
}
export interface LayerConfigurationProps {
attributes: TypedLensByValueInput['attributes'];
coreStart: CoreStart;
startDependencies: LensPluginStartDependencies;
visualizationMap: VisualizationMap;
datasourceMap: DatasourceMap;
datasourceId: 'formBased' | 'textBased';
framePublicAPI: FramePublicAPI;
hasPadding?: boolean;
setIsInlineFlyoutVisible: (flag: boolean) => void;
}

View file

@ -13,6 +13,7 @@ import {
column3,
numericDraggedColumn,
fieldList,
fieldListNonNumericOnly,
notNumericDraggedField,
numericDraggedField,
} from './mocks';
@ -74,6 +75,23 @@ describe('Text-based: getDropProps', () => {
} as unknown as DatasourceDimensionDropHandlerProps<TextBasedPrivateState>;
expect(getDropProps(props)).toBeUndefined();
});
it('should not return undefined if source is a non-numeric field, target is a metric dimension but datatable doesnt have numeric fields', () => {
const props = {
...defaultProps,
state: {
...defaultProps.state,
layers: {
first: {
columns: [column1, column2, column3],
allColumns: [...fieldListNonNumericOnly, column1, column2, column3],
},
},
fieldList: fieldListNonNumericOnly,
},
source: notNumericDraggedField,
} as unknown as DatasourceDimensionDropHandlerProps<TextBasedPrivateState>;
expect(getDropProps(props)).toEqual({ dropTypes: ['field_replace'], nextLabel: 'category' });
});
it('should return reorder if source and target are operations from the same group', () => {
const props = {
...defaultProps,

View file

@ -10,6 +10,7 @@ import { isOperation } from '../../../types';
import type { TextBasedPrivateState } from '../types';
import type { GetDropPropsArgs } from '../../../types';
import { isDraggedField, isOperationFromTheSameGroup } from '../../../utils';
import { canColumnBeDroppedInMetricDimension } from '../utils';
export const getDropProps = (
props: GetDropPropsArgs<TextBasedPrivateState>
@ -44,14 +45,24 @@ export const getDropProps = (
return { dropTypes: ['reorder'], nextLabel };
}
const sourceFieldCanMoveToMetricDimension = canColumnBeDroppedInMetricDimension(
layer.allColumns,
sourceField?.meta?.type
);
const targetFieldCanMoveToMetricDimension = canColumnBeDroppedInMetricDimension(
layer.allColumns,
targetField?.meta?.type
);
const isMoveable =
!target?.isMetricDimension ||
(target.isMetricDimension && sourceField?.meta?.type === 'number');
(target.isMetricDimension && sourceFieldCanMoveToMetricDimension);
if (targetColumn) {
const isSwappable =
(isMoveable && !source?.isMetricDimension) ||
(source.isMetricDimension && targetField?.meta?.type === 'number');
(source.isMetricDimension && targetFieldCanMoveToMetricDimension);
if (isMoveable) {
if (isSwappable) {
return {

View file

@ -83,6 +83,37 @@ export const numericDraggedField = {
},
};
export const fieldListNonNumericOnly = [
{
columnId: 'category',
fieldName: 'category',
meta: {
type: 'string',
},
},
{
columnId: 'currency',
fieldName: 'currency',
meta: {
type: 'string',
},
},
{
columnId: 'products.sold_date',
fieldName: 'products.sold_date',
meta: {
type: 'date',
},
},
{
columnId: 'products.buyer',
fieldName: 'products.buyer',
meta: {
type: 'string',
},
},
];
export const fieldList = [
{
columnId: 'category',

View file

@ -1,86 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import type { DatatableColumn } from '@kbn/expressions-plugin/public';
import { TextBasedPrivateState } from './types';
import type { DataViewsState } from '../../state_management/types';
import { TextBasedLayerPanelProps, LayerPanel } from './layerpanel';
import { shallowWithIntl as shallow } from '@kbn/test-jest-helpers';
import { ChangeIndexPattern } from '../../shared_components/dataview_picker/dataview_picker';
const fields = [
{
name: 'timestamp',
id: 'timestamp',
meta: {
type: 'date',
},
},
{
name: 'bytes',
id: 'bytes',
meta: {
type: 'number',
},
},
{
name: 'memory',
id: 'memory',
meta: {
type: 'number',
},
},
] as DatatableColumn[];
const initialState: TextBasedPrivateState = {
layers: {
first: {
index: '1',
columns: [],
allColumns: [],
query: { sql: 'SELECT * FROM foo' },
},
},
indexPatternRefs: [
{ id: '1', title: 'my-fake-index-pattern' },
{ id: '2', title: 'my-fake-restricted-pattern' },
{ id: '3', title: 'my-compatible-pattern' },
],
fieldList: fields,
};
describe('Layer Data Panel', () => {
let defaultProps: TextBasedLayerPanelProps;
beforeEach(() => {
defaultProps = {
layerId: 'first',
state: initialState,
onChangeIndexPattern: jest.fn(),
dataViews: {
indexPatternRefs: [
{ id: '1', title: 'my-fake-index-pattern', name: 'My fake index pattern' },
{ id: '2', title: 'my-fake-restricted-pattern', name: 'my-fake-restricted-pattern' },
{ id: '3', title: 'my-compatible-pattern', name: 'my-compatible-pattern' },
],
indexPatterns: {},
} as DataViewsState,
};
});
it('should display the selected dataview but disabled', () => {
const instance = shallow(<LayerPanel {...defaultProps} />);
expect(instance.find(ChangeIndexPattern).prop('trigger')).toStrictEqual({
fontWeight: 'normal',
isDisabled: true,
label: 'my-fake-index-pattern',
size: 's',
title: 'my-fake-index-pattern',
});
});
});

View file

@ -1,41 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { DatasourceLayerPanelProps } from '../../types';
import { TextBasedPrivateState } from './types';
import { ChangeIndexPattern } from '../../shared_components/dataview_picker/dataview_picker';
export interface TextBasedLayerPanelProps extends DatasourceLayerPanelProps<TextBasedPrivateState> {
state: TextBasedPrivateState;
}
export function LayerPanel({ state, layerId, dataViews }: TextBasedLayerPanelProps) {
const layer = state.layers[layerId];
const dataView = state.indexPatternRefs.find((ref) => ref.id === layer.index);
const notFoundTitleLabel = i18n.translate('xpack.lens.layerPanel.missingDataView', {
defaultMessage: 'Data view not found',
});
return (
<ChangeIndexPattern
data-test-subj="textBasedLanguages-switcher"
trigger={{
label: dataView?.name || dataView?.title || notFoundTitleLabel,
title: dataView?.title || notFoundTitleLabel,
size: 's',
fontWeight: 'normal',
isDisabled: true,
}}
indexPatternId={layer.index}
indexPatternRefs={dataViews.indexPatternRefs}
isMissingCurrent={!dataView}
onChangeIndexPattern={() => {}}
/>
);
}

View file

@ -116,7 +116,7 @@ describe('Textbased Data Source', () => {
},
],
index: 'foo',
query: { sql: 'SELECT * FROM foo' },
query: { esql: 'FROM foo' },
},
},
fieldList: [
@ -226,7 +226,7 @@ describe('Textbased Data Source', () => {
},
},
],
query: { sql: 'SELECT * FROM foo' },
query: { esql: 'FROM foo' },
index: 'foo',
},
},
@ -261,7 +261,7 @@ describe('Textbased Data Source', () => {
...baseState.layers,
newLayer: {
index: 'foo',
query: { sql: 'SELECT * FROM foo' },
query: { esql: 'FROM foo' },
allColumns: [
{
columnId: 'col1',
@ -296,7 +296,7 @@ describe('Textbased Data Source', () => {
},
},
],
query: { sql: 'SELECT * FROM foo' },
query: { esql: 'FROM foo' },
index: 'foo',
},
},
@ -353,7 +353,7 @@ describe('Textbased Data Source', () => {
},
},
],
query: { sql: 'SELECT * FROM foo' },
query: { esql: 'FROM foo' },
index: 'foo',
},
},
@ -402,12 +402,20 @@ describe('Textbased Data Source', () => {
expect(suggestions[0].state).toEqual({
...state,
fieldList: textBasedQueryColumns,
indexPatternRefs: [
{
id: '1',
timeField: undefined,
title: 'foo',
},
],
layers: {
newid: {
allColumns: [
{
columnId: 'bytes',
fieldName: 'bytes',
inMetricDimension: true,
meta: {
type: 'number',
},
@ -424,6 +432,7 @@ describe('Textbased Data Source', () => {
{
columnId: 'bytes',
fieldName: 'bytes',
inMetricDimension: true,
meta: {
type: 'number',
},
@ -466,6 +475,7 @@ describe('Textbased Data Source', () => {
],
isMultiRow: false,
layerId: 'newid',
notAssignedMetrics: false,
});
});
@ -504,6 +514,175 @@ describe('Textbased Data Source', () => {
);
expect(suggestions).toEqual([]);
});
it('should return the correct suggestions if non numeric columns are given', () => {
const textBasedQueryColumns = [
{
id: '@timestamp',
name: '@timestamp',
meta: {
type: 'date',
},
},
{
id: 'dest',
name: 'dest',
meta: {
type: 'string',
},
},
];
const state = {
layers: {},
initialContext: {
textBasedColumns: textBasedQueryColumns,
query: { esql: 'from foo' },
dataViewSpec: {
title: 'foo',
id: '1',
name: 'Foo',
},
},
} as unknown as TextBasedPrivateState;
const suggestions = TextBasedDatasource.getDatasourceSuggestionsForVisualizeField(
state,
'1',
'',
indexPatterns
);
expect(suggestions[0].state).toEqual({
...state,
fieldList: textBasedQueryColumns,
indexPatternRefs: [
{
id: '1',
timeField: undefined,
title: 'foo',
},
],
layers: {
newid: {
allColumns: [
{
columnId: '@timestamp',
fieldName: '@timestamp',
inMetricDimension: true,
meta: {
type: 'date',
},
},
{
columnId: 'dest',
fieldName: 'dest',
inMetricDimension: true,
meta: {
type: 'string',
},
},
],
columns: [
{
columnId: '@timestamp',
fieldName: '@timestamp',
inMetricDimension: true,
meta: {
type: 'date',
},
},
{
columnId: 'dest',
fieldName: 'dest',
inMetricDimension: true,
meta: {
type: 'string',
},
},
],
index: '1',
query: {
esql: 'from foo',
},
},
},
});
expect(suggestions[0].table).toEqual({
changeType: 'initial',
columns: [
{
columnId: '@timestamp',
operation: {
dataType: 'date',
isBucketed: true,
label: '@timestamp',
},
},
{
columnId: 'dest',
operation: {
dataType: 'string',
isBucketed: true,
label: 'dest',
},
},
],
isMultiRow: false,
layerId: 'newid',
notAssignedMetrics: true,
});
});
});
describe('#suggestsLimitedColumns', () => {
it('should return true if query returns big number of columns', () => {
const fieldList = [
{
id: 'a',
name: 'Test 1',
meta: {
type: 'number',
},
},
{
id: 'b',
name: 'Test 2',
meta: {
type: 'number',
},
},
{
id: 'c',
name: 'Test 3',
meta: {
type: 'date',
},
},
{
id: 'd',
name: 'Test 4',
meta: {
type: 'string',
},
},
{
id: 'e',
name: 'Test 5',
meta: {
type: 'string',
},
},
];
const state = {
fieldList,
layers: {
a: {
query: { esql: 'from foo' },
index: 'foo',
},
},
} as unknown as TextBasedPrivateState;
expect(TextBasedDatasource?.suggestsLimitedColumns?.(state)).toBeTruthy();
});
});
describe('#getUserMessages', () => {
@ -544,7 +723,7 @@ describe('Textbased Data Source', () => {
},
],
errors: [new Error('error 1'), new Error('error 2')],
query: { sql: 'SELECT * FROM foo' },
query: { esql: 'FROM foo' },
index: 'foo',
},
},
@ -626,7 +805,7 @@ describe('Textbased Data Source', () => {
},
},
],
query: { sql: 'SELECT * FROM foo' },
query: { esql: 'FROM foo' },
index: '1',
},
},
@ -673,7 +852,7 @@ describe('Textbased Data Source', () => {
},
},
],
query: { sql: 'SELECT * FROM foo' },
query: { esql: 'FROM foo' },
index: '1',
},
},
@ -731,7 +910,7 @@ describe('Textbased Data Source', () => {
},
},
],
query: { sql: 'SELECT * FROM foo' },
query: { esql: 'FROM foo' },
index: '1',
},
},
@ -759,11 +938,14 @@ describe('Textbased Data Source', () => {
},
Object {
"arguments": Object {
"locale": Array [
"en",
],
"query": Array [
"SELECT * FROM foo",
"FROM foo",
],
},
"function": "essql",
"function": "esql",
"type": "function",
},
Object {

View file

@ -42,10 +42,10 @@ import type {
} from './types';
import { FieldSelect } from './field_select';
import type { Datasource } from '../../types';
import { LayerPanel } from './layerpanel';
import { getUniqueLabelGenerator, nonNullable } from '../../utils';
import { onDrop, getDropProps } from './dnd';
import { removeColumn } from './remove_column';
import { canColumnBeUsedBeInMetricDimension, MAX_NUM_OF_COLUMNS } from './utils';
function getLayerReferenceName(layerId: string) {
return `textBasedLanguages-datasource-layer-${layerId}`;
@ -88,12 +88,20 @@ export function getTextBasedDatasource({
layerId: id,
columns:
layer.columns?.map((f) => {
const inMetricDimension = canColumnBeUsedBeInMetricDimension(
layer.allColumns,
f?.meta?.type
);
return {
columnId: f.columnId,
operation: {
dataType: f?.meta?.type as DataType,
label: f.fieldName,
isBucketed: Boolean(f?.meta?.type !== 'number'),
// makes non-number fields to act as metrics, used for datatable suggestions
...(inMetricDimension && {
inMetricDimension,
}),
},
};
}) ?? [],
@ -113,11 +121,23 @@ export function getTextBasedDatasource({
if (context && 'dataViewSpec' in context && context.dataViewSpec.title && context.query) {
const newLayerId = generateId();
const textBasedQueryColumns = context.textBasedColumns ?? [];
// Number fields are assigned automatically as metrics (!isBucketed). There are cases where the query
// will not return number fields. In these cases we want to suggest a datatable
// Datatable works differently in this case. On the metrics dimension can be all type of fields
const hasNumberTypeColumns = textBasedQueryColumns?.some((c) => c?.meta?.type === 'number');
const newColumns = textBasedQueryColumns.map((c) => {
const inMetricDimension = canColumnBeUsedBeInMetricDimension(
textBasedQueryColumns,
c?.meta?.type
);
return {
columnId: c.id,
fieldName: c.name,
meta: c.meta,
// makes non-number fields to act as metrics, used for datatable suggestions
...(inMetricDimension && {
inMetricDimension,
}),
};
});
@ -126,12 +146,23 @@ export function getTextBasedDatasource({
const updatedState = {
...state,
fieldList: textBasedQueryColumns,
...(context.dataViewSpec.id
? {
indexPatternRefs: [
{
id: context.dataViewSpec.id,
title: context.dataViewSpec.title,
timeField: context.dataViewSpec.timeFieldName,
},
],
}
: {}),
layers: {
...state.layers,
[newLayerId]: {
index,
query,
columns: newColumns ?? [],
columns: newColumns.slice(0, MAX_NUM_OF_COLUMNS) ?? [],
allColumns: newColumns ?? [],
timeField: context.dataViewSpec.timeFieldName,
},
@ -146,9 +177,10 @@ export function getTextBasedDatasource({
table: {
changeType: 'initial' as TableChangeType,
isMultiRow: false,
notAssignedMetrics: !hasNumberTypeColumns,
layerId: newLayerId,
columns:
newColumns?.map((f) => {
newColumns?.slice(0, MAX_NUM_OF_COLUMNS)?.map((f) => {
return {
columnId: f.columnId,
operation: {
@ -304,6 +336,13 @@ export function getTextBasedDatasource({
getLayers(state: TextBasedPrivateState) {
return state && state.layers ? Object.keys(state?.layers) : [];
},
// there are cases where a query can return a big amount of columns
// at this case we don't suggest all columns in a table but the first
// MAX_NUM_OF_COLUMNS
suggestsLimitedColumns(state: TextBasedPrivateState) {
const fieldsList = state?.fieldList ?? [];
return fieldsList.length >= MAX_NUM_OF_COLUMNS;
},
isTimeBased: (state, indexPatterns) => {
if (!state) return false;
const { layers } = state;
@ -382,20 +421,21 @@ export function getTextBasedDatasource({
DimensionEditorComponent: (props: DatasourceDimensionEditorProps<TextBasedPrivateState>) => {
const fields = props.state.fieldList;
const selectedField = props.state.layers[props.layerId]?.allColumns?.find(
(column) => column.columnId === props.columnId
);
const allColumns = props.state.layers[props.layerId]?.allColumns;
const selectedField = allColumns?.find((column) => column.columnId === props.columnId);
const hasNumberTypeColumns = allColumns?.some((c) => c?.meta?.type === 'number');
const updatedFields = fields?.map((f) => {
return {
...f,
compatible: props.isMetricDimension
? props.filterOperations({
dataType: f.meta.type as DataType,
isBucketed: Boolean(f?.meta?.type !== 'number'),
scale: 'ordinal',
})
: true,
compatible:
props.isMetricDimension && hasNumberTypeColumns
? props.filterOperations({
dataType: f.meta.type as DataType,
isBucketed: Boolean(f?.meta?.type !== 'number'),
scale: 'ordinal',
})
: true,
};
});
return (
@ -472,7 +512,7 @@ export function getTextBasedDatasource({
},
LayerPanelComponent: (props: DatasourceLayerPanelProps<TextBasedPrivateState>) => {
return <LayerPanel {...props} />;
return null;
},
uniqueLabels(state: TextBasedPrivateState) {
@ -519,6 +559,7 @@ export function getTextBasedDatasource({
dataType: column?.meta?.type as DataType,
label: columnLabelMap[columnId] ?? column?.fieldName,
isBucketed: Boolean(column?.meta?.type !== 'number'),
inMetricDimension: column.inMetricDimension,
hasTimeShift: false,
hasReducedTimeRange: false,
};

View file

@ -13,6 +13,7 @@ export interface TextBasedLayerColumn {
columnId: string;
fieldName: string;
meta?: DatatableColumn['meta'];
inMetricDimension?: boolean;
}
export interface TextBasedField {

View file

@ -15,6 +15,7 @@ import {
loadIndexPatternRefs,
getStateFromAggregateQuery,
getAllColumns,
canColumnBeUsedBeInMetricDimension,
} from './utils';
import type { TextBasedLayerColumn } from './types';
import { type AggregateQuery } from '@kbn/es-query';
@ -485,4 +486,111 @@ describe('Text based languages utils', () => {
});
});
});
describe('canColumnBeUsedBeInMetricDimension', () => {
it('should return true if there are non numeric field', async () => {
const fieldList = [
{
id: 'a',
name: 'Test 1',
meta: {
type: 'string',
},
},
{
id: 'b',
name: 'Test 2',
meta: {
type: 'string',
},
},
] as DatatableColumn[];
const flag = canColumnBeUsedBeInMetricDimension(fieldList, 'string');
expect(flag).toBeTruthy();
});
it('should return true if there are numeric field and the selected type is number', async () => {
const fieldList = [
{
id: 'a',
name: 'Test 1',
meta: {
type: 'number',
},
},
{
id: 'b',
name: 'Test 2',
meta: {
type: 'string',
},
},
] as DatatableColumn[];
const flag = canColumnBeUsedBeInMetricDimension(fieldList, 'number');
expect(flag).toBeTruthy();
});
it('should return false if there are non numeric fields and the selected type is non numeric', async () => {
const fieldList = [
{
id: 'a',
name: 'Test 1',
meta: {
type: 'number',
},
},
{
id: 'b',
name: 'Test 2',
meta: {
type: 'string',
},
},
] as DatatableColumn[];
const flag = canColumnBeUsedBeInMetricDimension(fieldList, 'date');
expect(flag).toBeFalsy();
});
it('should return true if there are many columns regardless the types', async () => {
const fieldList = [
{
id: 'a',
name: 'Test 1',
meta: {
type: 'number',
},
},
{
id: 'b',
name: 'Test 2',
meta: {
type: 'number',
},
},
{
id: 'c',
name: 'Test 3',
meta: {
type: 'date',
},
},
{
id: 'd',
name: 'Test 4',
meta: {
type: 'string',
},
},
{
id: 'e',
name: 'Test 5',
meta: {
type: 'string',
},
},
] as DatatableColumn[];
const flag = canColumnBeUsedBeInMetricDimension(fieldList, 'date');
expect(flag).toBeTruthy();
});
});
});

View file

@ -20,6 +20,8 @@ import { fetchDataFromAggregateQuery } from './fetch_data_from_aggregate_query';
import type { IndexPatternRef, TextBasedPrivateState, TextBasedLayerColumn } from './types';
import type { DataViewsState } from '../../state_management';
export const MAX_NUM_OF_COLUMNS = 5;
export async function loadIndexPatternRefs(
indexPatternsService: DataViewsPublicPluginStart
): Promise<IndexPatternRef[]> {
@ -146,3 +148,25 @@ export function getIndexPatternFromTextBasedQuery(query: AggregateQuery): string
return indexPattern;
}
export function canColumnBeDroppedInMetricDimension(
columns: TextBasedLayerColumn[] | DatatableColumn[],
selectedColumnType?: string
): boolean {
// check if at least one numeric field exists
const hasNumberTypeColumns = columns?.some((c) => c?.meta?.type === 'number');
return !hasNumberTypeColumns || (hasNumberTypeColumns && selectedColumnType === 'number');
}
export function canColumnBeUsedBeInMetricDimension(
columns: TextBasedLayerColumn[] | DatatableColumn[],
selectedColumnType?: string
): boolean {
// check if at least one numeric field exists
const hasNumberTypeColumns = columns?.some((c) => c?.meta?.type === 'number');
return (
!hasNumberTypeColumns ||
columns.length >= MAX_NUM_OF_COLUMNS ||
(hasNumberTypeColumns && selectedColumnType === 'number')
);
}

View file

@ -370,7 +370,7 @@ export function LayerPanels(
}
},
registerLibraryAnnotationGroup: registerLibraryAnnotationGroupFunction,
isInlineEditing: Boolean(props?.setIsInlineFlyoutFooterVisible),
isInlineEditing: Boolean(props?.setIsInlineFlyoutVisible),
})}
</EuiForm>
);

View file

@ -95,7 +95,7 @@ export function LayerPanel(
indexPatternService?: IndexPatternServiceAPI;
getUserMessages?: UserMessagesGetter;
displayLayerSettings: boolean;
setIsInlineFlyoutFooterVisible?: (status: boolean) => void;
setIsInlineFlyoutVisible?: (status: boolean) => void;
}
) {
const [activeDimension, setActiveDimension] = useState<ActiveDimensionState>(
@ -139,7 +139,7 @@ export function LayerPanel(
useEffect(() => {
// is undefined when the dimension panel is closed
const activeDimensionId = activeDimension.activeId;
props?.setIsInlineFlyoutFooterVisible?.(!Boolean(activeDimensionId));
props?.setIsInlineFlyoutVisible?.(!Boolean(activeDimensionId));
}, [activeDimension.activeId, activeVisualization.id, props]);
const panelRef = useRef<HTMLDivElement | null>(null);
@ -394,6 +394,7 @@ export function LayerPanel(
)}
</EuiFlexGroup>
{props.indexPatternService &&
!isTextBasedLanguage &&
(layerDatasource || activeVisualization.LayerPanelComponent) && (
<EuiSpacer size="s" />
)}
@ -680,7 +681,7 @@ export function LayerPanel(
setPanelSettingsOpen(false);
return true;
}}
isInlineEditing={Boolean(props?.setIsInlineFlyoutFooterVisible)}
isInlineEditing={Boolean(props?.setIsInlineFlyoutVisible)}
>
<div id={layerId}>
<div className="lnsIndexPatternDimensionEditor--padded">
@ -749,7 +750,7 @@ export function LayerPanel(
isOpen={isDimensionPanelOpen}
isFullscreen={isFullscreen}
groupLabel={activeGroup?.dimensionEditorGroupLabel ?? (activeGroup?.groupLabel || '')}
isInlineEditing={Boolean(props?.setIsInlineFlyoutFooterVisible)}
isInlineEditing={Boolean(props?.setIsInlineFlyoutVisible)}
handleClose={() => {
if (layerDatasource) {
if (layerDatasource.updateStateOnCloseDimension) {
@ -828,7 +829,7 @@ export function LayerPanel(
addLayer: props.addLayer,
removeLayer: props.onRemoveLayer,
panelRef,
isInlineEditing: Boolean(props?.setIsInlineFlyoutFooterVisible),
isInlineEditing: Boolean(props?.setIsInlineFlyoutVisible),
}}
/>
</div>

View file

@ -29,7 +29,7 @@ export interface ConfigPanelWrapperProps {
uiActions: UiActionsStart;
getUserMessages?: UserMessagesGetter;
hideLayerHeader?: boolean;
setIsInlineFlyoutFooterVisible?: (status: boolean) => void;
setIsInlineFlyoutVisible?: (status: boolean) => void;
}
export interface LayerPanelProps {

View file

@ -150,6 +150,7 @@ export function getSuggestions({
return filteredCount || filteredCount === datasourceSuggestion.keptLayerIds.length;
})
.flatMap((datasourceSuggestion) => {
const datasourceId = datasourceSuggestion.datasourceId;
const table = datasourceSuggestion.table;
const currentVisualizationState =
visualizationId === activeVisualization?.id ? visualizationState : undefined;
@ -170,7 +171,8 @@ export function getSuggestions({
palette,
visualizeTriggerFieldContext && 'isVisualizeAction' in visualizeTriggerFieldContext,
activeData,
allowMixed
allowMixed,
datasourceId
);
});
})
@ -240,7 +242,8 @@ function getVisualizationSuggestions(
mainPalette?: SuggestionRequest['mainPalette'],
isFromContext?: boolean,
activeData?: Record<string, Datatable>,
allowMixed?: boolean
allowMixed?: boolean,
datasourceId?: string
) {
try {
return visualization
@ -253,6 +256,7 @@ function getVisualizationSuggestions(
isFromContext,
activeData,
allowMixed,
datasourceId,
})
.map(({ state, ...visualizationSuggestion }) => ({
...visualizationSuggestion,

View file

@ -1,6 +1,10 @@
@import '../../mixins';
@import '../../variables';
.lnsSuggestionPanel .euiAccordion__buttonContent {
width: 100%;
}
.lnsSuggestionPanel__suggestions {
@include euiScrollBar;
@include lnsOverflowShadowHorizontal;
@ -16,14 +20,9 @@
margin-right: -$euiSizeXS;
}
.lnsSuggestionPanel {
padding-bottom: $euiSizeS;
}
.lnsSuggestionPanel__button {
position: relative; // Let the expression progress indicator position itself against the button
flex: 0 0 auto;
width: $lnsSuggestionWidth !important; // sass-lint:disable-line no-important
height: $lnsSuggestionHeight;
margin-right: $euiSizeS;
margin-left: $euiSizeXS / 2;
@ -58,6 +57,10 @@
}
}
.lnsSuggestionPanel__button-fixedWidth {
width: $lnsSuggestionWidth !important; // sass-lint:disable-line no-important
}
.lnsSuggestionPanel__suggestionIcon {
color: $euiColorDarkShade;
width: 100%;

View file

@ -10,6 +10,7 @@ import './suggestion_panel.scss';
import { camelCase, pick } from 'lodash';
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { css } from '@emotion/react';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import {
EuiIcon,
@ -20,7 +21,9 @@ import {
EuiButtonEmpty,
EuiAccordion,
EuiText,
EuiNotificationBadge,
} from '@elastic/eui';
import { euiThemeVars } from '@kbn/ui-theme';
import { IconType } from '@elastic/eui/src/components/icon/icon';
import { Ast, fromExpression, toExpression } from '@kbn/interpreter';
import { i18n } from '@kbn/i18n';
@ -65,6 +68,7 @@ import {
selectFramePublicAPI,
} from '../../state_management';
import { filterAndSortUserMessages } from '../../app_plugin/get_application_user_messages';
const MAX_SUGGESTIONS_DISPLAYED = 5;
const LOCAL_STORAGE_SUGGESTIONS_PANEL = 'LENS_SUGGESTIONS_PANEL_HIDDEN';
@ -99,10 +103,13 @@ export interface SuggestionPanelProps {
visualizationMap: VisualizationMap;
ExpressionRenderer: ReactExpressionRendererType;
frame: FramePublicAPI;
getUserMessages: UserMessagesGetter;
getUserMessages?: UserMessagesGetter;
nowProvider: DataPublicPluginStart['nowProvider'];
core: CoreStart;
showOnlyIcons?: boolean;
wrapSuggestions?: boolean;
isAccordionOpen?: boolean;
toggleAccordionCb?: (flag: boolean) => void;
}
const PreviewRenderer = ({
@ -165,6 +172,7 @@ const SuggestionPreview = ({
onSelect,
showTitleAsLabel,
onRender,
wrapSuggestions,
}: {
onSelect: () => void;
preview: {
@ -177,15 +185,30 @@ const SuggestionPreview = ({
selected: boolean;
showTitleAsLabel?: boolean;
onRender: () => void;
wrapSuggestions?: boolean;
}) => {
return (
<EuiToolTip content={preview.title}>
<EuiToolTip
content={preview.title}
anchorProps={
wrapSuggestions
? {
css: css`
display: flex;
flex-direction: column;
flex-basis: calc(50% - 9px);
`,
}
: undefined
}
>
<div data-test-subj={`lnsSuggestion-${camelCase(preview.title)}`}>
<EuiPanel
hasBorder={true}
hasShadow={false}
className={classNames('lnsSuggestionPanel__button', {
'lnsSuggestionPanel__button-isSelected': selected,
'lnsSuggestionPanel__button-fixedWidth': !wrapSuggestions,
})}
paddingSize="none"
data-test-subj="lnsSuggestion"
@ -231,6 +254,9 @@ export function SuggestionPanel({
nowProvider,
core,
showOnlyIcons,
wrapSuggestions,
toggleAccordionCb,
isAccordionOpen,
}: SuggestionPanelProps) {
const dispatchLens = useLensDispatch();
const activeDatasourceId = useLensSelector(selectActiveDatasourceId);
@ -243,14 +269,22 @@ export function SuggestionPanel({
const framePublicAPI = useLensSelector((state) => selectFramePublicAPI(state, datasourceMap));
const changesApplied = useLensSelector(selectChangesApplied);
// get user's selection from localStorage, this key defines if the suggestions panel will be hidden or not
const initialAccordionStatusValue =
typeof isAccordionOpen !== 'undefined' ? !Boolean(isAccordionOpen) : false;
const [hideSuggestions, setHideSuggestions] = useLocalStorage(
LOCAL_STORAGE_SUGGESTIONS_PANEL,
false
initialAccordionStatusValue
);
useEffect(() => {
if (typeof isAccordionOpen !== 'undefined') {
setHideSuggestions(!Boolean(isAccordionOpen));
}
}, [isAccordionOpen, setHideSuggestions]);
const toggleSuggestions = useCallback(() => {
setHideSuggestions(!hideSuggestions);
}, [setHideSuggestions, hideSuggestions]);
toggleAccordionCb?.(!hideSuggestions);
}, [setHideSuggestions, hideSuggestions, toggleAccordionCb]);
const missingIndexPatterns = getMissingIndexPattern(
activeDatasourceId ? datasourceMap[activeDatasourceId] : null,
@ -304,8 +338,10 @@ export function SuggestionPanel({
),
}));
const hasErrors =
getUserMessages(['visualization', 'visualizationInEditor'], { severity: 'error' }).length > 0;
const hasErrors = getUserMessages
? getUserMessages(['visualization', 'visualizationInEditor'], { severity: 'error' }).length >
0
: false;
const newStateExpression =
currentVisualization.state && currentVisualization.activeId && !hasErrors
@ -450,6 +486,7 @@ export function SuggestionPanel({
selected={lastSelectedSuggestion === -1}
showTitleAsLabel
onRender={() => onSuggestionRender(0)}
wrapSuggestions={wrapSuggestions}
/>
)}
{!hideSuggestions &&
@ -474,64 +511,88 @@ export function SuggestionPanel({
selected={index === lastSelectedSuggestion}
onRender={() => onSuggestionRender(index + 1)}
showTitleAsLabel={showOnlyIcons}
wrapSuggestions={wrapSuggestions}
/>
);
})}
</>
);
};
const title = (
<EuiTitle
size="xxs"
css={css`
padding: 2px;
}
`}
>
<h3>
<FormattedMessage
id="xpack.lens.editorFrame.suggestionPanelTitle"
defaultMessage="Suggestions"
/>
</h3>
</EuiTitle>
);
return (
<div className="lnsSuggestionPanel">
<EuiAccordion
id="lensSuggestionsPanel"
buttonProps={{ 'data-test-subj': 'lensSuggestionsPanelToggleButton' }}
buttonContent={
<EuiTitle size="xxs">
<h3>
<FormattedMessage
id="xpack.lens.editorFrame.suggestionPanelTitle"
defaultMessage="Suggestions"
/>
</h3>
</EuiTitle>
}
forceState={hideSuggestions ? 'closed' : 'open'}
onToggle={toggleSuggestions}
extraAction={
existsStagedPreview &&
!hideSuggestions && (
<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={() => {
dispatchLens(submitSuggestion());
}}
>
{i18n.translate('xpack.lens.sugegstion.refreshSuggestionLabel', {
defaultMessage: 'Refresh',
<EuiAccordion
id="lensSuggestionsPanel"
buttonProps={{
'data-test-subj': 'lensSuggestionsPanelToggleButton',
paddingSize: wrapSuggestions ? 'm' : 's',
}}
className="lnsSuggestionPanel"
css={css`
padding-bottom: ${wrapSuggestions ? 0 : euiThemeVars.euiSizeS};
`}
buttonContent={title}
forceState={hideSuggestions ? 'closed' : 'open'}
onToggle={toggleSuggestions}
extraAction={
!hideSuggestions && (
<>
{existsStagedPreview && (
<EuiToolTip
content={i18n.translate('xpack.lens.suggestion.refreshSuggestionTooltip', {
defaultMessage: 'Refresh the suggestions based on the selected visualization.',
})}
</EuiButtonEmpty>
</EuiToolTip>
)
}
>
<EuiButtonEmpty
data-test-subj="lensSubmitSuggestion"
size="xs"
iconType="refresh"
onClick={() => {
dispatchLens(submitSuggestion());
}}
>
{i18n.translate('xpack.lens.sugegstion.refreshSuggestionLabel', {
defaultMessage: 'Refresh',
})}
</EuiButtonEmpty>
</EuiToolTip>
)}
{wrapSuggestions && (
<EuiNotificationBadge size="m" color="subdued">
{suggestions.length + 1}
</EuiNotificationBadge>
)}
</>
)
}
>
<div
className="lnsSuggestionPanel__suggestions"
data-test-subj="lnsSuggestionsPanel"
role="list"
tabIndex={0}
css={css`
flex-wrap: ${wrapSuggestions ? 'wrap' : 'nowrap'};
gap: ${wrapSuggestions ? euiThemeVars.euiSize : 0};
`}
>
<div
className="lnsSuggestionPanel__suggestions"
data-test-subj="lnsSuggestionsPanel"
role="list"
tabIndex={0}
>
{changesApplied ? renderSuggestionsUI() : renderApplyChangesPrompt()}
</div>
</EuiAccordion>
</div>
{changesApplied ? renderSuggestionsUI() : renderApplyChangesPrompt()}
</div>
</EuiAccordion>
);
}

View file

@ -149,3 +149,10 @@
75% { transform: translateY(15%); }
100% { transform: translateY(10%); }
}
.lnsVisualizationToolbar--fixed {
position: fixed;
width: 100%;
z-index: 1;
background-color: $euiColorLightestShade;
}

View file

@ -53,10 +53,11 @@ export interface WorkspacePanelWrapperProps {
export function VisualizationToolbar(props: {
activeVisualization: Visualization | null;
framePublicAPI: FramePublicAPI;
isFixedPosition?: boolean;
}) {
const dispatchLens = useLensDispatch();
const visualization = useLensSelector(selectVisualizationState);
const { activeVisualization } = props;
const { activeVisualization, isFixedPosition } = props;
const setVisualizationState = useCallback(
(newState: unknown) => {
if (!activeVisualization) {
@ -77,7 +78,12 @@ export function VisualizationToolbar(props: {
return (
<>
{ToolbarComponent && (
<EuiFlexItem grow={false}>
<EuiFlexItem
grow={false}
className={classNames({
'lnsVisualizationToolbar--fixed': isFixedPosition,
})}
>
{ToolbarComponent({
frame: props.framePublicAPI,
state: visualization.state,

View file

@ -747,7 +747,11 @@ export class Embeddable
* Gets the Lens embeddable's datasource and visualization states
* updates the embeddable input
*/
async updateVisualization(datasourceState: unknown, visualizationState: unknown) {
async updateVisualization(
datasourceState: unknown,
visualizationState: unknown,
visualizationType?: string
) {
const viz = this.savedVis;
const activeDatasourceId = (this.activeDatasourceId ??
'formBased') as EditLensConfigurationProps['datasourceId'];
@ -769,7 +773,7 @@ export class Embeddable
),
visualizationState,
activeVisualization: this.activeVisualizationId
? this.deps.visualizationMap[this.activeVisualizationId]
? this.deps.visualizationMap[visualizationType ?? this.activeVisualizationId]
: undefined,
});
const attrs = {
@ -780,6 +784,7 @@ export class Embeddable
datasourceStates,
},
references,
visualizationType: visualizationType ?? viz.visualizationType,
};
/**
@ -795,6 +800,15 @@ export class Embeddable
}
}
async updateSuggestion(attrs: LensSavedObjectAttributes) {
const viz = this.savedVis;
const newViz = {
...viz,
...attrs,
};
this.updateInput({ attributes: newViz });
}
/**
* Callback which allows the navigation to the editor.
* Used for the Edit in Lens link inside the inline editing flyout.
@ -848,6 +862,7 @@ export class Embeddable
<Component
attributes={attributes}
updatePanelState={this.updateVisualization.bind(this)}
updateSuggestion={this.updateSuggestion.bind(this)}
datasourceId={datasourceId}
lensAdapters={this.lensInspector.adapters}
output$={this.getOutput$()}
@ -858,6 +873,7 @@ export class Embeddable
!this.isTextBasedLanguage() ? this.navigateToLensEditor.bind(this) : undefined
}
displayFlyoutHeader={true}
canEditTextBasedQuery={this.isTextBasedLanguage()}
/>
);
}

View file

@ -0,0 +1,56 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { DataView } from '@kbn/data-views-plugin/public';
import { buildDataViewMock } from '@kbn/discover-utils/src/__mocks__';
const fields = [
{
name: '_index',
type: 'string',
scripted: false,
filterable: true,
},
{
name: '@timestamp',
displayName: 'timestamp',
type: 'date',
scripted: false,
filterable: true,
aggregatable: true,
sortable: true,
},
{
name: 'message',
displayName: 'message',
type: 'string',
scripted: false,
filterable: false,
},
{
name: 'extension',
displayName: 'extension',
type: 'string',
scripted: false,
filterable: true,
aggregatable: true,
},
{
name: 'bytes',
displayName: 'bytes',
type: 'number',
scripted: false,
filterable: true,
aggregatable: true,
},
] as DataView['fields'];
export const mockDataViewWithTimefield = buildDataViewMock({
name: 'index-pattern-with-timefield',
fields,
timeFieldName: '%timestamp',
});

View file

@ -29,6 +29,8 @@ export {
renderWithReduxStore,
} from './store_mocks';
export { lensPluginMock } from './lens_plugin_mock';
export { mockDataViewWithTimefield } from './dataview_mock';
export { mockAllSuggestions } from './suggestions_mock';
export type FrameMock = jest.Mocked<FramePublicAPI>;

View file

@ -0,0 +1,291 @@
/*
* 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 type { Suggestion } from '../types';
export const currentSuggestionMock = {
title: 'Heat map',
hide: false,
score: 0.6,
previewIcon: 'heatmap',
visualizationId: 'lnsHeatmap',
visualizationState: {
shape: 'heatmap',
layerId: '46aa21fa-b747-4543-bf90-0b40007c546d',
layerType: 'data',
legend: {
isVisible: true,
position: 'right',
type: 'heatmap_legend',
},
gridConfig: {
type: 'heatmap_grid',
isCellLabelVisible: false,
isYAxisLabelVisible: true,
isXAxisLabelVisible: true,
isYAxisTitleVisible: false,
isXAxisTitleVisible: false,
},
valueAccessor: '5b9b8b76-0836-4a12-b9c0-980c9900502f',
xAccessor: '81e332d6-ee37-42a8-a646-cea4fc75d2d3',
},
keptLayerIds: ['46aa21fa-b747-4543-bf90-0b40007c546d'],
datasourceState: {
layers: {
'46aa21fa-b747-4543-bf90-0b40007c546d': {
index: 'd3d7af60-4c81-11e8-b3d7-01146121b73d',
query: {
esql: 'FROM kibana_sample_data_flights | keep Dest, AvgTicketPrice',
},
columns: [
{
columnId: '81e332d6-ee37-42a8-a646-cea4fc75d2d3',
fieldName: 'Dest',
meta: {
type: 'string',
},
},
{
columnId: '5b9b8b76-0836-4a12-b9c0-980c9900502f',
fieldName: 'AvgTicketPrice',
meta: {
type: 'number',
},
},
],
allColumns: [
{
columnId: '81e332d6-ee37-42a8-a646-cea4fc75d2d3',
fieldName: 'Dest',
meta: {
type: 'string',
},
},
{
columnId: '5b9b8b76-0836-4a12-b9c0-980c9900502f',
fieldName: 'AvgTicketPrice',
meta: {
type: 'number',
},
},
],
timeField: 'timestamp',
},
},
fieldList: [],
indexPatternRefs: [],
initialContext: {
dataViewSpec: {
id: 'd3d7af60-4c81-11e8-b3d7-01146121b73d',
version: 'WzM1ODA3LDFd',
title: 'kibana_sample_data_flights',
timeFieldName: 'timestamp',
sourceFilters: [],
fields: {
AvgTicketPrice: {
count: 0,
name: 'AvgTicketPrice',
type: 'number',
esTypes: ['float'],
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
format: {
id: 'number',
params: {
pattern: '$0,0.[00]',
},
},
shortDotsEnable: false,
isMapped: true,
},
Dest: {
count: 0,
name: 'Dest',
type: 'string',
esTypes: ['keyword'],
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
format: {
id: 'string',
},
shortDotsEnable: false,
isMapped: true,
},
timestamp: {
count: 0,
name: 'timestamp',
type: 'date',
esTypes: ['date'],
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
format: {
id: 'date',
},
shortDotsEnable: false,
isMapped: true,
},
},
allowNoIndex: false,
name: 'Kibana Sample Data Flights',
},
fieldName: '',
contextualFields: ['Dest', 'AvgTicketPrice'],
query: {
esql: 'FROM "kibana_sample_data_flights"',
},
},
},
datasourceId: 'textBased',
columns: 2,
changeType: 'initial',
} as Suggestion;
export const mockAllSuggestions = [
currentSuggestionMock,
{
title: 'Donut',
score: 0.46,
visualizationId: 'lnsPie',
previewIcon: 'pie',
visualizationState: {
shape: 'donut',
layers: [
{
layerId: '2513a3d4-ad9d-48ea-bd58-8b6419ab97e6',
primaryGroups: ['923f0681-3fe1-4987-aa27-d9c91fb95fa6'],
metrics: ['b5f41c04-4bca-4abe-ae5c-b1d4d6fb00e0'],
numberDisplay: 'percent',
categoryDisplay: 'default',
legendDisplay: 'default',
nestedLegend: false,
layerType: 'data',
},
],
},
keptLayerIds: ['2513a3d4-ad9d-48ea-bd58-8b6419ab97e6'],
datasourceState: {
layers: {
'2513a3d4-ad9d-48ea-bd58-8b6419ab97e6': {
index: 'd3d7af60-4c81-11e8-b3d7-01146121b73d',
query: {
esql: 'FROM "kibana_sample_data_flights"',
},
columns: [
{
columnId: '923f0681-3fe1-4987-aa27-d9c91fb95fa6',
fieldName: 'Dest',
meta: {
type: 'string',
},
},
{
columnId: 'b5f41c04-4bca-4abe-ae5c-b1d4d6fb00e0',
fieldName: 'AvgTicketPrice',
meta: {
type: 'number',
},
},
],
allColumns: [
{
columnId: '923f0681-3fe1-4987-aa27-d9c91fb95fa6',
fieldName: 'Dest',
meta: {
type: 'string',
},
},
{
columnId: 'b5f41c04-4bca-4abe-ae5c-b1d4d6fb00e0',
fieldName: 'AvgTicketPrice',
meta: {
type: 'number',
},
},
],
timeField: 'timestamp',
},
},
fieldList: [],
indexPatternRefs: [],
initialContext: {
dataViewSpec: {
id: 'd3d7af60-4c81-11e8-b3d7-01146121b73d',
version: 'WzM1ODA3LDFd',
title: 'kibana_sample_data_flights',
timeFieldName: 'timestamp',
sourceFilters: [],
fields: {
AvgTicketPrice: {
count: 0,
name: 'AvgTicketPrice',
type: 'number',
esTypes: ['float'],
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
format: {
id: 'number',
params: {
pattern: '$0,0.[00]',
},
},
shortDotsEnable: false,
isMapped: true,
},
Dest: {
count: 0,
name: 'Dest',
type: 'string',
esTypes: ['keyword'],
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
format: {
id: 'string',
},
shortDotsEnable: false,
isMapped: true,
},
timestamp: {
count: 0,
name: 'timestamp',
type: 'date',
esTypes: ['date'],
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
format: {
id: 'date',
},
shortDotsEnable: false,
isMapped: true,
},
},
typeMeta: {},
allowNoIndex: false,
name: 'Kibana Sample Data Flights',
},
fieldName: '',
contextualFields: ['Dest', 'AvgTicketPrice'],
query: {
esql: 'FROM "kibana_sample_data_flights"',
},
},
},
datasourceId: 'textBased',
columns: 2,
changeType: 'unchanged',
} as Suggestion,
];

View file

@ -1,6 +1,7 @@
// styles needed to display extra drop targets that are outside of the config panel main area while also allowing to scroll vertically
.lnsConfigPanel__overlay {
clip-path: polygon(-100% 0, 100% 0, 100% 100%, -100% 100%);
background: $euiColorLightestShade;
.kbnOverlayMountWrapper {
padding-left: $euiFormMaxWidth;
margin-left: -$euiFormMaxWidth;

View file

@ -52,6 +52,7 @@ export async function executeAction({ embeddable, startDependencies, overlays, t
size: 's',
'data-test-subj': 'customizeLens',
type: 'push',
paddingSize: 'm',
hideCloseButton: true,
onClose: (overlayRef) => {
if (overlayTracker) overlayTracker.clearOverlays();

View file

@ -198,6 +198,8 @@ export interface TableSuggestion {
* The change type indicates what was changed in this table compared to the currently active table of this layer.
*/
changeType: TableChangeType;
notAssignedMetrics?: boolean;
}
/**
@ -509,6 +511,8 @@ export interface Datasource<T = unknown, P = unknown> {
) => Promise<DataSourceInfo[]>;
injectReferencesToLayers?: (state: T, references?: SavedObjectReference[]) => T;
suggestsLimitedColumns?: (state: T) => boolean;
}
export interface DatasourceFixAction<T> {
@ -746,6 +750,7 @@ export interface OperationMetadata {
export interface OperationDescriptor extends Operation {
hasTimeShift: boolean;
hasReducedTimeRange: boolean;
inMetricDimension?: boolean;
}
export interface VisualizationConfigProps<T = unknown> {
@ -882,6 +887,7 @@ export interface SuggestionRequest<T = unknown> {
subVisualizationId?: string;
activeData?: Record<string, Datatable>;
allowMixed?: boolean;
datasourceId?: string;
}
/**

View file

@ -138,6 +138,26 @@ describe('Datatable Visualization', () => {
expect(suggestions.length).toBeGreaterThan(0);
});
it('should force table as suggestion when there are no number fields', () => {
const suggestions = datatableVisualization.getSuggestions({
state: {
layerId: 'first',
layerType: LayerTypes.DATA,
columns: [{ columnId: 'col1' }],
},
table: {
isMultiRow: true,
layerId: 'first',
changeType: 'initial',
columns: [strCol('col1'), strCol('col2')],
notAssignedMetrics: true,
},
keptLayerIds: [],
});
expect(suggestions.length).toBeGreaterThan(0);
});
it('should reject suggestion with static value', () => {
function staticValueCol(columnId: string): TableSuggestionColumn {
return {
@ -387,6 +407,48 @@ describe('Datatable Visualization', () => {
}).groups[2].accessors
).toEqual([{ columnId: 'c' }, { columnId: 'b' }]);
});
it('should compute the groups correctly for text based languages', () => {
const datasource = createMockDatasource('textBased', {
isTextBasedLanguage: jest.fn(() => true),
});
datasource.publicAPIMock.getTableSpec.mockReturnValue([
{ columnId: 'c', fields: [] },
{ columnId: 'b', fields: [] },
]);
const frame = mockFrame();
frame.datasourceLayers = { first: datasource.publicAPIMock };
const groups = datatableVisualization.getConfiguration({
layerId: 'first',
state: {
layerId: 'first',
layerType: LayerTypes.DATA,
columns: [{ columnId: 'b', isMetric: true }, { columnId: 'c' }],
},
frame,
}).groups;
// rows
expect(groups[0].accessors).toEqual([
{
columnId: 'c',
triggerIconType: undefined,
},
]);
// columns
expect(groups[1].accessors).toEqual([]);
// metrics
expect(groups[2].accessors).toEqual([
{
columnId: 'b',
triggerIconType: undefined,
palette: undefined,
},
]);
});
});
describe('#removeDimension', () => {
@ -462,7 +524,11 @@ describe('Datatable Visualization', () => {
).toEqual({
layerId: 'layer1',
layerType: LayerTypes.DATA,
columns: [{ columnId: 'b' }, { columnId: 'c' }, { columnId: 'd', isTransposed: false }],
columns: [
{ columnId: 'b' },
{ columnId: 'c' },
{ columnId: 'd', isTransposed: false, isMetric: false },
],
});
});
@ -482,7 +548,7 @@ describe('Datatable Visualization', () => {
).toEqual({
layerId: 'layer1',
layerType: LayerTypes.DATA,
columns: [{ columnId: 'b', isTransposed: false }, { columnId: 'c' }],
columns: [{ columnId: 'b', isTransposed: false, isMetric: false }, { columnId: 'c' }],
});
});
});

View file

@ -165,11 +165,14 @@ export const getDatatableVisualization = ({
? 0.5
: 1;
// forcing datatable as a suggestion when there are no metrics (number fields)
const forceSuggestion = Boolean(table?.notAssignedMetrics);
return [
{
title,
// table with >= 10 columns will have a score of 0.4, fewer columns reduce score
score: (Math.min(table.columns.length, 10) / 10) * 0.4 * changeFactor,
score: forceSuggestion ? 1 : (Math.min(table.columns.length, 10) / 10) * 0.4 * changeFactor,
state: {
...(state || {}),
layerId: table.layerId,
@ -187,6 +190,13 @@ export const getDatatableVisualization = ({
];
},
/*
Datatable works differently on text based datasource and form based
- Form based: It relies on the isBucketed flag to identify groups. It allows only numeric fields
on the Metrics dimension
- Text based: It relies on the isMetric flag to identify groups. It allows all type of fields
on the Metric dimension in cases where there are no numeric columns
**/
getConfiguration({ state, frame, layerId }) {
const { sortedColumns, datasource } =
getDataSourceAndSortedColumns(state, frame.datasourceLayers, layerId) || {};
@ -199,9 +209,11 @@ export const getDatatableVisualization = ({
if (!sortedColumns) {
return { groups: [] };
}
const isTextBasedLanguage = datasource?.isTextBasedLanguage();
return {
groups: [
// In this group we get columns that are not transposed and are not on the metric dimension
{
groupId: 'rows',
groupLabel: i18n.translate('xpack.lens.datatable.breakdownRows', {
@ -216,11 +228,17 @@ export const getDatatableVisualization = ({
}),
layerId: state.layerId,
accessors: sortedColumns
.filter(
(c) =>
datasource!.getOperationForColumnId(c)?.isBucketed &&
!state.columns.find((col) => col.columnId === c)?.isTransposed
)
.filter((c) => {
const column = state.columns.find((col) => col.columnId === c);
if (isTextBasedLanguage) {
return (
!datasource!.getOperationForColumnId(c)?.inMetricDimension &&
!column?.isMetric &&
!column?.isTransposed
);
}
return datasource!.getOperationForColumnId(c)?.isBucketed && !column?.isTransposed;
})
.map((accessor) => ({
columnId: accessor,
triggerIconType: columnMap[accessor].hidden
@ -236,6 +254,7 @@ export const getDatatableVisualization = ({
hideGrouping: true,
nestingOrder: 1,
},
// In this group we get columns that are transposed and are not on the metric dimension
{
groupId: 'columns',
groupLabel: i18n.translate('xpack.lens.datatable.breakdownColumns', {
@ -250,11 +269,15 @@ export const getDatatableVisualization = ({
}),
layerId: state.layerId,
accessors: sortedColumns
.filter(
(c) =>
.filter((c) => {
if (isTextBasedLanguage) {
return state.columns.find((col) => col.columnId === c)?.isTransposed;
}
return (
datasource!.getOperationForColumnId(c)?.isBucketed &&
state.columns.find((col) => col.columnId === c)?.isTransposed
)
);
})
.map((accessor) => ({ columnId: accessor })),
supportsMoreColumns: true,
filterOperations: (op) => op.isBucketed,
@ -263,6 +286,7 @@ export const getDatatableVisualization = ({
hideGrouping: true,
nestingOrder: 0,
},
// In this group we get columns are on the metric dimension
{
groupId: 'metrics',
groupLabel: i18n.translate('xpack.lens.datatable.metrics', {
@ -278,7 +302,16 @@ export const getDatatableVisualization = ({
},
layerId: state.layerId,
accessors: sortedColumns
.filter((c) => !datasource!.getOperationForColumnId(c)?.isBucketed)
.filter((c) => {
const operation = datasource!.getOperationForColumnId(c);
if (isTextBasedLanguage) {
return (
operation?.inMetricDimension ||
state.columns.find((col) => col.columnId === c)?.isMetric
);
}
return !operation?.isBucketed;
})
.map((accessor) => {
const columnConfig = columnMap[accessor];
const stops = columnConfig?.palette?.params?.stops;
@ -316,7 +349,12 @@ export const getDatatableVisualization = ({
...prevState,
columns: prevState.columns.map((column) => {
if (column.columnId === columnId || column.columnId === previousColumn) {
return { ...column, columnId, isTransposed: groupId === 'columns' };
return {
...column,
columnId,
isTransposed: groupId === 'columns',
isMetric: groupId === 'metrics',
};
}
return column;
}),
@ -324,7 +362,10 @@ export const getDatatableVisualization = ({
}
return {
...prevState,
columns: [...prevState.columns, { columnId, isTransposed: groupId === 'columns' }],
columns: [
...prevState.columns,
{ columnId, isTransposed: groupId === 'columns', isMetric: groupId === 'metrics' },
],
};
},
removeDimension({ prevState, columnId }) {
@ -371,9 +412,11 @@ export const getDatatableVisualization = ({
): Ast | null {
const { sortedColumns, datasource } =
getDataSourceAndSortedColumns(state, datasourceLayers, state.layerId) || {};
const isTextBasedLanguage = datasource?.isTextBasedLanguage();
if (
sortedColumns?.length &&
!isTextBasedLanguage &&
sortedColumns.filter((c) => !datasource!.getOperationForColumnId(c)?.isBucketed).length === 0
) {
return null;
@ -435,6 +478,15 @@ export const getDatatableVisualization = ({
const canColor =
datasource!.getOperationForColumnId(column.columnId)?.dataType === 'number';
let isTransposable =
!isTextBasedLanguage &&
!datasource!.getOperationForColumnId(column.columnId)?.isBucketed;
if (isTextBasedLanguage) {
const operation = datasource!.getOperationForColumnId(column.columnId);
isTransposable = Boolean(column?.isMetric || operation?.inMetricDimension);
}
const datatableColumnFn = buildExpressionFunction<DatatableColumnFunction>(
'lens_datatable_column',
{
@ -443,8 +495,7 @@ export const getDatatableVisualization = ({
oneClickFilter: column.oneClickFilter,
width: column.width,
isTransposed: column.isTransposed,
transposable: !datasource!.getOperationForColumnId(column.columnId)?.isBucketed,
alignment: column.alignment,
transposable: isTransposable,
colorMode: canColor && column.colorMode ? column.colorMode : 'none',
palette: paletteService.get(CUSTOM_PALETTE).toExpression(paletteParams),
summaryRow: hasNoSummaryRow ? undefined : column.summaryRow!,

View file

@ -305,6 +305,70 @@ describe('heatmap suggestions', () => {
},
]);
});
test('when no metric dimension but groups', () => {
expect(
getSuggestions({
table: {
layerId: 'first',
isMultiRow: true,
columns: [
{
columnId: 'date-column',
operation: {
isBucketed: true,
dataType: 'date',
scale: 'interval',
label: 'Date',
},
},
{
columnId: 'string-column-01',
operation: {
isBucketed: true,
dataType: 'string',
label: 'Bucket 1',
},
},
],
changeType: 'initial',
},
state: {
layerId: 'first',
layerType: LayerTypes.DATA,
} as HeatmapVisualizationState,
keptLayerIds: ['first'],
})
).toEqual([
{
state: {
layerId: 'first',
layerType: LayerTypes.DATA,
shape: 'heatmap',
xAccessor: 'date-column',
yAccessor: 'string-column-01',
gridConfig: {
type: HEATMAP_GRID_FUNCTION,
isCellLabelVisible: false,
isYAxisLabelVisible: true,
isXAxisLabelVisible: true,
isYAxisTitleVisible: false,
isXAxisTitleVisible: false,
},
legend: {
isVisible: true,
position: Position.Right,
type: LEGEND_FUNCTION,
},
},
title: 'Heat map',
hide: true,
incomplete: true,
previewIcon: IconChartHeatmap,
score: 0,
},
]);
});
test('for tables with a single bucket dimension', () => {
expect(
getSuggestions({

View file

@ -61,6 +61,7 @@ export const getSuggestions: Visualization<HeatmapVisualizationState>['getSugges
const isSingleBucketDimension = groups.length === 1 && metrics.length === 0;
const isOnlyMetricDimension = groups.length === 0 && metrics.length === 1;
const isOnlyBucketDimension = groups.length > 0 && metrics.length === 0;
/**
* Hide for:
@ -77,6 +78,7 @@ export const getSuggestions: Visualization<HeatmapVisualizationState>['getSugges
table.changeType === 'reorder' ||
isSingleBucketDimension ||
hasOnlyDatehistogramBuckets ||
isOnlyBucketDimension ||
isOnlyMetricDimension;
const newState: HeatmapVisualizationState = {
@ -130,7 +132,7 @@ export const getSuggestions: Visualization<HeatmapVisualizationState>['getSugges
hide,
previewIcon: IconChartHeatmap,
score: Number(score.toFixed(1)),
incomplete: isSingleBucketDimension || isOnlyMetricDimension,
incomplete: isSingleBucketDimension || isOnlyMetricDimension || isOnlyBucketDimension,
},
];
};

View file

@ -98,6 +98,7 @@ describe('metric_suggestions', () => {
).map((table) => expect(getSuggestions({ table, keptLayerIds: ['l1'] })).toEqual([]))
);
});
test('does not suggest for a static value', () => {
const suggestion = getSuggestions({
table: {
@ -133,6 +134,29 @@ describe('metric_suggestions', () => {
expect(suggestion).toHaveLength(0);
});
test('does not suggest for text based languages', () => {
const col = {
columnId: 'id',
operation: {
dataType: 'number',
label: `Top values`,
isBucketed: false,
},
} as const;
const suggestion = getSuggestions({
table: {
columns: [col],
isMultiRow: false,
layerId: 'l1',
changeType: 'unchanged',
},
keptLayerIds: [],
datasourceId: 'textBased',
});
expect(suggestion).toHaveLength(0);
});
test('suggests a basic metric chart', () => {
const [suggestion, ...rest] = getSuggestions({
table: {

View file

@ -20,6 +20,7 @@ export function getSuggestions({
table,
state,
keptLayerIds,
datasourceId,
}: SuggestionRequest<LegacyMetricState>): Array<VisualizationSuggestion<LegacyMetricState>> {
// We only render metric charts for single-row queries. We require a single, numeric column.
if (
@ -39,6 +40,11 @@ export function getSuggestions({
return [];
}
// do not return the legacy metric vis for the textbased mode (i.e. ES|QL)
if (datasourceId === 'textBased') {
return [];
}
return [getSuggestion(table)];
}

View file

@ -90,9 +90,11 @@
"@kbn/search-response-warnings",
"@kbn/logging",
"@kbn/core-plugins-server",
"@kbn/text-based-languages",
"@kbn/field-utils",
"@kbn/shared-ux-button-toolbar",
"@kbn/cell-actions"
"@kbn/discover-utils",
"@kbn/cell-actions",
"@kbn/shared-ux-button-toolbar"
],
"exclude": [
"target/**/*",

View file

@ -22333,7 +22333,6 @@
"xpack.lens.config.configFlyoutCallout": "ES|QL propose actuellement des options de configuration limitées",
"xpack.lens.config.editLabel": "Modifier la configuration",
"xpack.lens.config.editLinkLabel": "Modifier dans Lens",
"xpack.lens.config.editVisualizationLabel": "Modifier la visualisation",
"xpack.lens.config.experimentalLabel": "Version d'évaluation technique",
"xpack.lens.configPanel.addLayerButton": "Ajouter un calque",
"xpack.lens.configPanel.experimentalLabel": "Version d'évaluation technique",

View file

@ -22347,7 +22347,6 @@
"xpack.lens.config.configFlyoutCallout": "現在、ES|QLでは、構成オプションは限られています。",
"xpack.lens.config.editLabel": "構成の編集",
"xpack.lens.config.editLinkLabel": "Lensで編集",
"xpack.lens.config.editVisualizationLabel": "ビジュアライゼーションを編集",
"xpack.lens.config.experimentalLabel": "テクニカルプレビュー",
"xpack.lens.configPanel.addLayerButton": "レイヤーを追加",
"xpack.lens.configPanel.experimentalLabel": "テクニカルプレビュー",

View file

@ -22347,7 +22347,6 @@
"xpack.lens.config.configFlyoutCallout": "ES|QL 当前提供的配置选项数量有限",
"xpack.lens.config.editLabel": "编辑配置",
"xpack.lens.config.editLinkLabel": "在 Lens 中编辑",
"xpack.lens.config.editVisualizationLabel": "编辑可视化",
"xpack.lens.config.experimentalLabel": "技术预览",
"xpack.lens.configPanel.addLayerButton": "添加图层",
"xpack.lens.configPanel.experimentalLabel": "技术预览",

View file

@ -255,5 +255,54 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
const data = await PageObjects.lens.getCurrentChartDebugStateForVizType('xyVisChart');
assertMatchesExpectedData(data!);
});
it('should allow editing the query in the dashboard', async () => {
await PageObjects.discover.selectTextBaseLang();
await PageObjects.header.waitUntilLoadingHasFinished();
await monacoEditor.setCodeEditorValue('from logstash-* | limit 10');
await testSubjects.click('querySubmitButton');
await PageObjects.header.waitUntilLoadingHasFinished();
await testSubjects.click('TextBasedLangEditor-expand');
// save the visualization
await testSubjects.click('unifiedHistogramSaveVisualization');
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.lens.saveModal('TextBasedChart1', false, false, false, 'new');
await testSubjects.existOrFail('embeddablePanelHeading-TextBasedChart1');
await elasticChart.setNewChartUiDebugFlag(true);
await PageObjects.header.waitUntilLoadingHasFinished();
// open the inline editing flyout
await testSubjects.click('embeddablePanelToggleMenuIcon');
await testSubjects.click('embeddablePanelAction-ACTION_CONFIGURE_IN_LENS');
await PageObjects.header.waitUntilLoadingHasFinished();
// change the query
await monacoEditor.setCodeEditorValue('from logstash-* | stats maxB = max(bytes)');
await testSubjects.click('TextBasedLangEditor-run-query-button');
await PageObjects.header.waitUntilLoadingHasFinished();
expect((await PageObjects.lens.getMetricVisualizationData()).length).to.be.equal(1);
// change the query to display a datatabler
await monacoEditor.setCodeEditorValue('from logstash-* | limit 10');
await testSubjects.click('TextBasedLangEditor-run-query-button');
await PageObjects.lens.waitForVisualization();
expect(await testSubjects.exists('lnsDataTable')).to.be(true);
await PageObjects.lens.removeDimension('lnsDatatable_metrics');
await PageObjects.lens.removeDimension('lnsDatatable_metrics');
await PageObjects.lens.removeDimension('lnsDatatable_metrics');
await PageObjects.lens.removeDimension('lnsDatatable_metrics');
await PageObjects.lens.configureTextBasedLanguagesDimension({
dimension: 'lnsDatatable_metrics > lns-empty-dimension',
field: 'bytes',
keepOpen: true,
});
await testSubjects.click('lns-indexPattern-dimensionContainerBack');
// click donut from suggestions
await testSubjects.click('lensSuggestionsPanelToggleButton');
await testSubjects.click('lnsSuggestion-donut');
expect(await testSubjects.exists('partitionVisChart')).to.be(true);
});
});
}