mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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:
parent
c4ffd316a4
commit
a96a5e29d5
13 changed files with 190 additions and 8 deletions
|
@ -215,6 +215,7 @@ exports[`<CodeEditor /> is rendered 1`] = `
|
|||
"
|
||||
>
|
||||
<div>
|
||||
<div />
|
||||
<textarea
|
||||
data-test-subj="monacoEditorTextarea"
|
||||
onKeyDown={[Function]}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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()}
|
||||
|
|
|
@ -6,6 +6,12 @@
|
|||
position: relative;
|
||||
height: 100%;
|
||||
|
||||
&__placeholderContainer {
|
||||
color: $euiTextSubduedColor;
|
||||
width: max-content;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__keyboardHint {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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}}';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
@ -20,6 +20,7 @@ export const Demo = () => {
|
|||
<UrlDrilldownCollectConfig
|
||||
config={config}
|
||||
onConfig={onConfig}
|
||||
exampleUrl="https://www.example.com"
|
||||
variables={[
|
||||
{
|
||||
label: 'event.key',
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue