mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 01:13:23 -04:00
[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:
parent
3ec310b519
commit
b55dae32f6
57 changed files with 2404 additions and 569 deletions
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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%',
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
@ -68,6 +68,7 @@ export function ChartConfigPanel({
|
|||
updatePanelState={updateSuggestion}
|
||||
lensAdapters={lensAdapters}
|
||||
output$={lensEmbeddableOutput$}
|
||||
displayFlyoutHeader
|
||||
closeFlyout={() => {
|
||||
setIsFlyoutVisible(false);
|
||||
}}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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' };
|
||||
|
|
|
@ -56,6 +56,7 @@
|
|||
"embeddable",
|
||||
"fieldFormats",
|
||||
"charts",
|
||||
"textBasedLanguages",
|
||||
],
|
||||
"extraPublicDirs": [
|
||||
"common/constants"
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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', () => {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -13,6 +13,7 @@ export interface TextBasedLayerColumn {
|
|||
columnId: string;
|
||||
fieldName: string;
|
||||
meta?: DatatableColumn['meta'];
|
||||
inMetricDimension?: boolean;
|
||||
}
|
||||
|
||||
export interface TextBasedField {
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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')
|
||||
);
|
||||
}
|
||||
|
|
|
@ -370,7 +370,7 @@ export function LayerPanels(
|
|||
}
|
||||
},
|
||||
registerLibraryAnnotationGroup: registerLibraryAnnotationGroupFunction,
|
||||
isInlineEditing: Boolean(props?.setIsInlineFlyoutFooterVisible),
|
||||
isInlineEditing: Boolean(props?.setIsInlineFlyoutVisible),
|
||||
})}
|
||||
</EuiForm>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -29,7 +29,7 @@ export interface ConfigPanelWrapperProps {
|
|||
uiActions: UiActionsStart;
|
||||
getUserMessages?: UserMessagesGetter;
|
||||
hideLayerHeader?: boolean;
|
||||
setIsInlineFlyoutFooterVisible?: (status: boolean) => void;
|
||||
setIsInlineFlyoutVisible?: (status: boolean) => void;
|
||||
}
|
||||
|
||||
export interface LayerPanelProps {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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%;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -149,3 +149,10 @@
|
|||
75% { transform: translateY(15%); }
|
||||
100% { transform: translateY(10%); }
|
||||
}
|
||||
|
||||
.lnsVisualizationToolbar--fixed {
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
background-color: $euiColorLightestShade;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
56
x-pack/plugins/lens/public/mocks/dataview_mock.ts
Normal file
56
x-pack/plugins/lens/public/mocks/dataview_mock.ts
Normal 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',
|
||||
});
|
|
@ -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>;
|
||||
|
||||
|
|
291
x-pack/plugins/lens/public/mocks/suggestions_mock.ts
Normal file
291
x-pack/plugins/lens/public/mocks/suggestions_mock.ts
Normal 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,
|
||||
];
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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!,
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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)];
|
||||
}
|
||||
|
||||
|
|
|
@ -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/**/*",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "テクニカルプレビュー",
|
||||
|
|
|
@ -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": "技术预览",
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue