[Drilldowns] Fix URL drilldown placeholder text and add placeholder capability to Monaco (#121420)

* added logic for detecting the example URL and created a new content widget for the code editor to act as a placeholder

* added file for placeholder widget

* refactor widget ID

* added jest tests for the CodeEditor changes

* remove data-test-subj

* remove unused let

* added url drilldown test

* minor refactor for code readability and updated test titles

* fix ts issue

* simplify switch statement

* add member accessibility to placeholderwidget methods

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Jean-Louis Leysens 2021-12-17 21:21:48 +01:00 committed by GitHub
parent c4ffd316a4
commit a96a5e29d5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 190 additions and 8 deletions

View file

@ -215,6 +215,7 @@ exports[`<CodeEditor /> is rendered 1`] = `
"
>
<div>
<div />
<textarea
data-test-subj="monacoEditorTextarea"
onKeyDown={[Function]}

View file

@ -12,6 +12,7 @@ function createEditorInstance() {
const keyDownListeners: Array<(e?: unknown) => void> = [];
const didShowListeners: Array<(e?: unknown) => void> = [];
const didHideListeners: Array<(e?: unknown) => void> = [];
let placeholderDiv: undefined | HTMLDivElement;
let areSuggestionsVisible = false;
const editorInstance = {
@ -37,10 +38,20 @@ function createEditorInstance() {
onKeyDown: jest.fn((listener) => {
keyDownListeners.push(listener);
}),
addContentWidget: jest.fn((widget: monaco.editor.IContentWidget) => {
placeholderDiv?.appendChild(widget.getDomNode());
}),
applyFontInfo: jest.fn(),
removeContentWidget: jest.fn((widget: monaco.editor.IContentWidget) => {
placeholderDiv?.removeChild(widget.getDomNode());
}),
getDomNode: jest.fn(),
// Helpers for our tests
__helpers__: {
areSuggestionsVisible: () => areSuggestionsVisible,
getPlaceholderRef: (div: HTMLDivElement) => {
placeholderDiv = div;
},
onTextareaKeyDown: ((e) => {
// Let all our listener know that a key has been pressed on the textarea
keyDownListeners.forEach((listener) => listener(e));
@ -81,6 +92,7 @@ const mockMonacoEditor = ({
return (
<div>
<div ref={mockedEditorInstance?.__helpers__.getPlaceholderRef} />
<textarea
onKeyDown={mockedEditorInstance?.__helpers__.onTextareaKeyDown}
data-test-subj="monacoEditorTextarea"

View file

@ -170,4 +170,43 @@ describe('<CodeEditor />', () => {
expect((getHint().props() as any).className).not.toContain('isInactive');
});
});
/**
* Test whether our custom placeholder widget is being mounted based on our React logic. We cannot do a full
* test with Monaco so the parts handled by Monaco are all mocked out and we just check whether the element is mounted
* in the DOM.
*/
describe('placeholder element', () => {
let component: ReactWrapper;
const getPlaceholderDomElement = (): HTMLElement | null =>
component.getDOMNode().querySelector('.kibanaCodeEditor__placeholderContainer');
beforeEach(() => {
component = mountWithIntl(
<CodeEditor
languageId="loglang"
height={250}
value=""
onChange={() => {}}
placeholder="myplaceholder"
/>
);
});
it('displays placeholder element when placeholder text is provided', () => {
expect(getPlaceholderDomElement()?.innerText).toBe('myplaceholder');
});
it('does not display placeholder element when placeholder text is not provided', () => {
component.setProps({ ...component.props(), placeholder: undefined, value: '' });
component.update();
expect(getPlaceholderDomElement()).toBe(null);
});
it('does not display placeholder element when user input has been provided', () => {
component.setProps({ ...component.props(), value: 'some input' });
component.update();
expect(getPlaceholderDomElement()).toBe(null);
});
});
});

View file

@ -24,6 +24,8 @@ import {
LIGHT_THEME_TRANSPARENT,
} from './editor_theme';
import { PlaceholderWidget } from './placeholder_widget';
import './editor.scss';
export interface Props {
@ -106,6 +108,8 @@ export interface Props {
* Should the editor be rendered using the fullWidth EUI attribute
*/
fullWidth?: boolean;
placeholder?: string;
/**
* Accessible name for the editor. (Defaults to "Code editor")
*/
@ -127,6 +131,7 @@ export const CodeEditor: React.FC<Props> = ({
suggestionProvider,
signatureProvider,
hoverProvider,
placeholder,
languageConfiguration,
'aria-label': ariaLabel = i18n.translate('kibana-react.kibanaCodeEditor.ariaLabel', {
defaultMessage: 'Code Editor',
@ -145,6 +150,7 @@ export const CodeEditor: React.FC<Props> = ({
const isReadOnly = options?.readOnly ?? false;
const _editor = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
const _placeholderWidget = useRef<PlaceholderWidget | null>(null);
const isSuggestionMenuOpen = useRef(false);
const editorHint = useRef<HTMLDivElement>(null);
const textboxMutationObserver = useRef<MutationObserver | null>(null);
@ -377,6 +383,17 @@ export const CodeEditor: React.FC<Props> = ({
};
}, []);
useEffect(() => {
if (placeholder && !value && _editor.current) {
// Mounts editor inside constructor
_placeholderWidget.current = new PlaceholderWidget(placeholder, _editor.current);
}
return () => {
_placeholderWidget.current?.dispose();
_placeholderWidget.current = null;
};
}, [placeholder, value]);
return (
<div className="kibanaCodeEditor">
{renderPrompt()}

View file

@ -6,6 +6,12 @@
position: relative;
height: 100%;
&__placeholderContainer {
color: $euiTextSubduedColor;
width: max-content;
pointer-events: none;
}
&__keyboardHint {
position: absolute;
top: 0;

View file

@ -0,0 +1,49 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { monaco } from '@kbn/monaco';
export class PlaceholderWidget implements monaco.editor.IContentWidget {
constructor(
private readonly placeholderText: string,
private readonly editor: monaco.editor.ICodeEditor
) {
editor.addContentWidget(this);
}
private domNode: undefined | HTMLElement;
public getId(): string {
return 'KBN_CODE_EDITOR_PLACEHOLDER_WIDGET_ID';
}
public getDomNode(): HTMLElement {
if (!this.domNode) {
const domNode = document.createElement('div');
domNode.innerText = this.placeholderText;
domNode.className = 'kibanaCodeEditor__placeholderContainer';
this.editor.applyFontInfo(domNode);
this.domNode = domNode;
}
return this.domNode;
}
public getPosition(): monaco.editor.IContentWidgetPosition | null {
return {
position: {
column: 1,
lineNumber: 1,
},
preference: [monaco.editor.ContentWidgetPositionPreference.EXACT],
};
}
public dispose(): void {
this.editor.removeContentWidget(this);
}
}

View file

@ -26,6 +26,7 @@ export interface UrlTemplateEditorProps {
variables?: UrlTemplateEditorVariable[];
onChange: CodeEditorProps['onChange'];
onEditor?: (editor: monaco.editor.IStandaloneCodeEditor) => void;
placeholder?: string;
Editor?: React.ComponentType<CodeEditorProps>;
}
@ -34,6 +35,7 @@ export const UrlTemplateEditor: React.FC<UrlTemplateEditorProps> = ({
value,
variables,
onChange,
placeholder,
onEditor,
Editor = CodeEditor,
}) => {
@ -129,6 +131,7 @@ export const UrlTemplateEditor: React.FC<UrlTemplateEditorProps> = ({
value={value}
onChange={onChange}
editorDidMount={handleEditor}
placeholder={placeholder}
options={{
fontSize: 14,
highlightActiveIndentGuide: false,

View file

@ -8,7 +8,12 @@
import { IExternalUrl } from 'src/core/public';
import { uiSettingsServiceMock } from 'src/core/public/mocks';
import { UrlDrilldown, ActionContext, Config } from './url_drilldown';
import { IEmbeddable, VALUE_CLICK_TRIGGER } from '../../../../../../src/plugins/embeddable/public';
import {
IEmbeddable,
VALUE_CLICK_TRIGGER,
SELECT_RANGE_TRIGGER,
CONTEXT_MENU_TRIGGER,
} from '../../../../../../src/plugins/embeddable/public';
import { DatatableColumnType } from '../../../../../../src/plugins/expressions/common';
import { of } from '../../../../../../src/plugins/kibana_utils';
import { createPoint, rowClickData, TestEmbeddable } from './test/data';
@ -445,6 +450,34 @@ describe('UrlDrilldown', () => {
});
});
});
describe('example url', () => {
it('provides the expected example urls based on the trigger', () => {
expect(urlDrilldown.getExampleUrl({ triggers: [] })).toMatchInlineSnapshot(
`"https://www.example.com/?{{event.key}}={{event.value}}"`
);
expect(urlDrilldown.getExampleUrl({ triggers: ['unknown'] })).toMatchInlineSnapshot(
`"https://www.example.com/?{{event.key}}={{event.value}}"`
);
expect(urlDrilldown.getExampleUrl({ triggers: [VALUE_CLICK_TRIGGER] })).toMatchInlineSnapshot(
`"https://www.example.com/?{{event.key}}={{event.value}}"`
);
expect(
urlDrilldown.getExampleUrl({ triggers: [SELECT_RANGE_TRIGGER] })
).toMatchInlineSnapshot(`"https://www.example.com/?from={{event.from}}&to={{event.to}}"`);
expect(urlDrilldown.getExampleUrl({ triggers: [ROW_CLICK_TRIGGER] })).toMatchInlineSnapshot(
`"https://www.example.com/keys={{event.keys}}&values={{event.values}}"`
);
expect(
urlDrilldown.getExampleUrl({ triggers: [CONTEXT_MENU_TRIGGER] })
).toMatchInlineSnapshot(`"https://www.example.com/?panel={{context.panel.title}}"`);
});
});
});
describe('encoding', () => {

View file

@ -93,7 +93,10 @@ export class UrlDrilldown implements Drilldown<Config, ActionContext, ActionFact
onConfig,
context,
}) => {
const variables = React.useMemo(() => this.getVariableList(context), [context]);
const [variables, exampleUrl] = React.useMemo(
() => [this.getVariableList(context), this.getExampleUrl(context)],
[context]
);
return (
<KibanaContextProvider
@ -103,6 +106,7 @@ export class UrlDrilldown implements Drilldown<Config, ActionContext, ActionFact
>
<UrlDrilldownCollectConfig
variables={variables}
exampleUrl={exampleUrl}
config={config}
onConfig={onConfig}
syntaxHelpDocsLink={this.deps.getSyntaxHelpDocsLink()}
@ -116,7 +120,7 @@ export class UrlDrilldown implements Drilldown<Config, ActionContext, ActionFact
public readonly createConfig = () => ({
url: {
template: 'https://example.com/?{{event.key}}={{event.value}}',
template: '',
},
openInNewTab: true,
encodeUrl: true,
@ -196,4 +200,18 @@ export class UrlDrilldown implements Drilldown<Config, ActionContext, ActionFact
return [...eventVariables, ...contextVariables, ...globalVariables];
};
public readonly getExampleUrl = (context: ActionFactoryContext): string => {
switch (context.triggers[0]) {
case SELECT_RANGE_TRIGGER:
return 'https://www.example.com/?from={{event.from}}&to={{event.to}}';
case CONTEXT_MENU_TRIGGER:
return 'https://www.example.com/?panel={{context.panel.title}}';
case ROW_CLICK_TRIGGER:
return 'https://www.example.com/keys={{event.keys}}&values={{event.values}}';
case VALUE_CLICK_TRIGGER:
default:
return 'https://www.example.com/?{{event.key}}={{event.value}}';
}
};
}

View file

@ -70,7 +70,7 @@ export interface DrilldownDefinition<
createConfig: ActionFactoryDefinition<Config, ExecutionContext, FactoryContext>['createConfig'];
/**
* `UiComponent` that collections config for this drilldown. You can create
* `UiComponent` that collects config for this drilldown. You can create
* a React component and transform it `UiComponent` using `uiToReactComponent`
* helper from `kibana_utils` plugin.
*

View file

@ -28,7 +28,7 @@ export const txtUrlPreviewHelpText = i18n.translate(
export const txtUrlTemplateLabel = i18n.translate(
'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateLabel',
{
defaultMessage: 'Enter URL:',
defaultMessage: 'Enter URL',
}
);

View file

@ -20,6 +20,7 @@ export const Demo = () => {
<UrlDrilldownCollectConfig
config={config}
onConfig={onConfig}
exampleUrl="https://www.example.com"
variables={[
{
label: 'event.key',

View file

@ -35,6 +35,7 @@ import {
export interface UrlDrilldownCollectConfigProps {
config: UrlDrilldownConfig;
variables: UrlTemplateEditorVariable[];
exampleUrl: string;
onConfig: (newConfig: UrlDrilldownConfig) => void;
syntaxHelpDocsLink?: string;
variablesHelpDocsLink?: string;
@ -43,17 +44,18 @@ export interface UrlDrilldownCollectConfigProps {
export const UrlDrilldownCollectConfig: React.FC<UrlDrilldownCollectConfigProps> = ({
config,
variables,
exampleUrl,
onConfig,
syntaxHelpDocsLink,
variablesHelpDocsLink,
}) => {
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
const [showUrlError, setShowUrlError] = React.useState(false);
const [isPristine, setIsPristine] = React.useState(true);
const urlTemplate = config.url.template ?? '';
function updateUrlTemplate(newUrlTemplate: string) {
if (config.url.template !== newUrlTemplate) {
setShowUrlError(true);
setIsPristine(false);
onConfig({
...config,
url: {
@ -64,7 +66,7 @@ export const UrlDrilldownCollectConfig: React.FC<UrlDrilldownCollectConfigProps>
}
}
const isEmpty = !urlTemplate;
const isInvalid = showUrlError && isEmpty;
const isInvalid = !isPristine && isEmpty;
const variablesDropdown = (
<VariablePopover
variables={variables}
@ -99,6 +101,7 @@ export const UrlDrilldownCollectConfig: React.FC<UrlDrilldownCollectConfigProps>
<UrlTemplateEditor
variables={variables}
value={urlTemplate}
placeholder={exampleUrl}
onChange={(newUrlTemplate) => updateUrlTemplate(newUrlTemplate)}
onEditor={(editor) => {
editorRef.current = editor;