Loki: Make label browser accessible in query builder (#58525)

* add label browser button to query editor header

* add dynamic button label text

* add LabelBrowserModal.tsx

* toggle label browser modal on click

* pass required props to LabelBrowserModal

* add placeholder to text input

* render label browser inside of the modal

* change button based on label status

* remove label browser button from code mode

* fix element overlap in label browser

* fix undefined app in feature tracking

* remove all any types

* add tests for label browser button

* update modal component width

* update label loading function

* add tests to LabelBrowserModal

* fix broken mock datasource

* update test names

* use stack component for button spacing

* revert modal width

* update label search placeholder

* remove unused import

* add test assertion for closed modal

* remove redundant if statement

* remove unnecessary code

* update error message and fix position

* fix input placeholder text
This commit is contained in:
Gareth Dawson 2022-11-23 16:48:41 +00:00 committed by GitHub
parent ed72b02b27
commit a098bdef58
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 182 additions and 69 deletions

View File

@ -131,16 +131,11 @@ const getStyles = (theme: GrafanaTheme2) => ({
margin-bottom: ${theme.spacing(1)}; margin-bottom: ${theme.spacing(1)};
`, `,
status: css` status: css`
padding: ${theme.spacing(0.5)}; margin-bottom: ${theme.spacing(1)};
color: ${theme.colors.text.secondary}; color: ${theme.colors.text.secondary};
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
/* using absolute positioning because flex interferes with ellipsis */
position: absolute;
width: 50%;
right: 0;
text-align: right;
transition: opacity 100ms linear; transition: opacity 100ms linear;
opacity: 0; opacity: 0;
`, `,
@ -367,7 +362,7 @@ export class UnthemedLokiLabelBrowser extends React.Component<BrowserProps, Brow
async fetchSeries(selector: string, lastFacetted?: string) { async fetchSeries(selector: string, lastFacetted?: string) {
const { languageProvider } = this.props; const { languageProvider } = this.props;
if (lastFacetted) { if (lastFacetted) {
this.updateLabelState(lastFacetted, { loading: true }, `Facetting labels for ${selector}`); this.updateLabelState(lastFacetted, { loading: true }, `Loading labels for ${selector}`);
} }
try { try {
const possibleLabels = await languageProvider.fetchSeriesLabels(selector, true); const possibleLabels = await languageProvider.fetchSeriesLabels(selector, true);
@ -467,7 +462,12 @@ export class UnthemedLokiLabelBrowser extends React.Component<BrowserProps, Brow
2. Find values for the selected labels 2. Find values for the selected labels
</Label> </Label>
<div> <div>
<Input onChange={this.onChangeSearch} aria-label="Filter expression for values" value={searchTerm} /> <Input
onChange={this.onChangeSearch}
aria-label="Filter expression for values"
value={searchTerm}
placeholder={'Enter a label value'}
/>
</div> </div>
<div className={styles.valueListArea}> <div className={styles.valueListArea}>
{selectedLabels.map((label) => ( {selectedLabels.map((label) => (
@ -520,6 +520,9 @@ export class UnthemedLokiLabelBrowser extends React.Component<BrowserProps, Brow
{selector} {selector}
</div> </div>
{validationStatus && <div className={styles.validationStatus}>{validationStatus}</div>} {validationStatus && <div className={styles.validationStatus}>{validationStatus}</div>}
<div className={cx(styles.status, (status || error) && styles.statusShowing)}>
<span className={error ? styles.error : ''}>{error || status}</span>
</div>
<HorizontalGroup> <HorizontalGroup>
<Button aria-label="Use selector as logs button" disabled={empty} onClick={this.onClickRunLogsQuery}> <Button aria-label="Use selector as logs button" disabled={empty} onClick={this.onClickRunLogsQuery}>
Show logs Show logs
@ -543,9 +546,6 @@ export class UnthemedLokiLabelBrowser extends React.Component<BrowserProps, Brow
<Button aria-label="Selector clear button" variant="secondary" onClick={this.onClickClear}> <Button aria-label="Selector clear button" variant="secondary" onClick={this.onClickClear}>
Clear Clear
</Button> </Button>
<div className={cx(styles.status, (status || error) && styles.statusShowing)}>
<span className={error ? styles.error : ''}>{error || status}</span>
</div>
</HorizontalGroup> </HorizontalGroup>
</div> </div>
</div> </div>

View File

@ -149,6 +149,11 @@ describe('LokiQueryEditorSelector', () => {
expect(screen.getByText('Rate')).toBeInTheDocument(); expect(screen.getByText('Rate')).toBeInTheDocument();
expect(screen.getByText('$__interval')).toBeInTheDocument(); expect(screen.getByText('$__interval')).toBeInTheDocument();
}); });
it('renders the label browser button', async () => {
renderWithMode(QueryEditorMode.Code);
expect(await screen.findByTestId('label-browser-button')).toBeInTheDocument();
});
}); });
function renderWithMode(mode: QueryEditorMode) { function renderWithMode(mode: QueryEditorMode) {

View File

@ -2,7 +2,7 @@ import React, { SyntheticEvent, useCallback, useEffect, useState } from 'react';
import { CoreApp, LoadingState } from '@grafana/data'; import { CoreApp, LoadingState } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { EditorHeader, EditorRows, FlexItem, Space } from '@grafana/experimental'; import { EditorHeader, EditorRows, FlexItem, Space, Stack } from '@grafana/experimental';
import { reportInteraction } from '@grafana/runtime'; import { reportInteraction } from '@grafana/runtime';
import { Button, ConfirmModal } from '@grafana/ui'; import { Button, ConfirmModal } from '@grafana/ui';
import { QueryEditorModeToggle } from 'app/plugins/datasource/prometheus/querybuilder/shared/QueryEditorModeToggle'; import { QueryEditorModeToggle } from 'app/plugins/datasource/prometheus/querybuilder/shared/QueryEditorModeToggle';
@ -10,6 +10,7 @@ import { QueryHeaderSwitch } from 'app/plugins/datasource/prometheus/querybuilde
import { QueryEditorMode } from 'app/plugins/datasource/prometheus/querybuilder/shared/types'; import { QueryEditorMode } from 'app/plugins/datasource/prometheus/querybuilder/shared/types';
import { lokiQueryEditorExplainKey, useFlag } from '../../prometheus/querybuilder/shared/hooks/useFlag'; import { lokiQueryEditorExplainKey, useFlag } from '../../prometheus/querybuilder/shared/hooks/useFlag';
import { LabelBrowserModal } from '../querybuilder/components/LabelBrowserModal';
import { LokiQueryBuilderContainer } from '../querybuilder/components/LokiQueryBuilderContainer'; import { LokiQueryBuilderContainer } from '../querybuilder/components/LokiQueryBuilderContainer';
import { LokiQueryBuilderOptions } from '../querybuilder/components/LokiQueryBuilderOptions'; import { LokiQueryBuilderOptions } from '../querybuilder/components/LokiQueryBuilderOptions';
import { LokiQueryCodeEditor } from '../querybuilder/components/LokiQueryCodeEditor'; import { LokiQueryCodeEditor } from '../querybuilder/components/LokiQueryCodeEditor';
@ -25,10 +26,12 @@ export const testIds = {
}; };
export const LokiQueryEditor = React.memo<LokiQueryEditorProps>((props) => { export const LokiQueryEditor = React.memo<LokiQueryEditorProps>((props) => {
const { onChange, onRunQuery, onAddQuery, data, app, queries } = props; const { onChange, onRunQuery, onAddQuery, data, app, queries, datasource } = props;
const [parseModalOpen, setParseModalOpen] = useState(false); const [parseModalOpen, setParseModalOpen] = useState(false);
const [queryPatternsModalOpen, setQueryPatternsModalOpen] = useState(false); const [queryPatternsModalOpen, setQueryPatternsModalOpen] = useState(false);
const [dataIsStale, setDataIsStale] = useState(false); const [dataIsStale, setDataIsStale] = useState(false);
const [labelBrowserVisible, setLabelBrowserVisible] = useState(false);
const [labelsLoaded, setLabelsLoaded] = useState(false);
const { flag: explain, setFlag: setExplain } = useFlag(lokiQueryEditorExplainKey); const { flag: explain, setFlag: setExplain } = useFlag(lokiQueryEditorExplainKey);
const query = getQueryWithDefaults(props.query); const query = getQueryWithDefaults(props.query);
@ -70,6 +73,30 @@ export const LokiQueryEditor = React.memo<LokiQueryEditorProps>((props) => {
onChange(query); onChange(query);
}; };
const onClickChooserButton = () => {
setLabelBrowserVisible((visible) => !visible);
};
const getChooserText = (logLabelsLoaded: boolean, hasLogLabels: boolean) => {
if (!logLabelsLoaded) {
return 'Loading labels...';
}
if (!hasLogLabels) {
return '(No labels found)';
}
return 'Label browser';
};
useEffect(() => {
datasource.languageProvider.start().then(() => {
setLabelsLoaded(true);
});
}, [datasource]);
const hasLogLabels = datasource.languageProvider.getLabelKeys().length > 0;
const labelBrowserText = getChooserText(labelsLoaded, hasLogLabels);
const buttonDisabled = !(labelsLoaded && hasLogLabels);
return ( return (
<> <>
<ConfirmModal <ConfirmModal
@ -93,25 +120,45 @@ export const LokiQueryEditor = React.memo<LokiQueryEditorProps>((props) => {
onAddQuery={onAddQuery} onAddQuery={onAddQuery}
/> />
<EditorHeader> <EditorHeader>
<Button <LabelBrowserModal
aria-label={selectors.components.QueryBuilder.queryPatterns} isOpen={labelBrowserVisible}
variant="secondary" languageProvider={datasource.languageProvider}
size="sm" query={query}
onClick={() => { app={app}
setQueryPatternsModalOpen((prevValue) => !prevValue); onClose={() => setLabelBrowserVisible(false)}
onChange={onChangeInternal}
onRunQuery={onRunQuery}
/>
<Stack gap={1}>
<Button
aria-label={selectors.components.QueryBuilder.queryPatterns}
variant="secondary"
size="sm"
onClick={() => {
setQueryPatternsModalOpen((prevValue) => !prevValue);
const visualQuery = buildVisualQueryFromString(query.expr || ''); const visualQuery = buildVisualQueryFromString(query.expr || '');
reportInteraction('grafana_loki_query_patterns_opened', { reportInteraction('grafana_loki_query_patterns_opened', {
version: 'v2', version: 'v2',
app: app ?? '', app: app ?? '',
editorMode: query.editorMode, editorMode: query.editorMode,
preSelectedOperationsCount: visualQuery.query.operations.length, preSelectedOperationsCount: visualQuery.query.operations.length,
preSelectedLabelsCount: visualQuery.query.labels.length, preSelectedLabelsCount: visualQuery.query.labels.length,
}); });
}} }}
> >
Kick start your query Kick start your query
</Button> </Button>
<Button
variant="secondary"
size="sm"
onClick={onClickChooserButton}
disabled={buttonDisabled}
data-testid="label-browser-button"
>
{labelBrowserText}
</Button>
</Stack>
<QueryHeaderSwitch label="Explain" value={explain} onChange={onExplainChange} /> <QueryHeaderSwitch label="Explain" value={explain} onChange={onExplainChange} />
<FlexItem grow={1} /> <FlexItem grow={1} />
{app !== CoreApp.Explore && ( {app !== CoreApp.Explore && (

View File

@ -13,7 +13,6 @@ import {
TypeaheadInput, TypeaheadInput,
BracesPlugin, BracesPlugin,
DOMUtil, DOMUtil,
Icon,
} from '@grafana/ui'; } from '@grafana/ui';
import { LocalStorageValueProvider } from 'app/core/components/LocalStorageValueProvider'; import { LocalStorageValueProvider } from 'app/core/components/LocalStorageValueProvider';
@ -22,21 +21,10 @@ import { LokiDatasource } from '../datasource';
import { escapeLabelValueInSelector, shouldRefreshLabels } from '../languageUtils'; import { escapeLabelValueInSelector, shouldRefreshLabels } from '../languageUtils';
import { LokiQuery, LokiOptions } from '../types'; import { LokiQuery, LokiOptions } from '../types';
import { LokiLabelBrowser } from './LokiLabelBrowser';
import { MonacoQueryFieldWrapper } from './monaco-query-field/MonacoQueryFieldWrapper'; import { MonacoQueryFieldWrapper } from './monaco-query-field/MonacoQueryFieldWrapper';
const LAST_USED_LABELS_KEY = 'grafana.datasources.loki.browser.labels'; const LAST_USED_LABELS_KEY = 'grafana.datasources.loki.browser.labels';
function getChooserText(hasSyntax: boolean, hasLogLabels: boolean) {
if (!hasSyntax) {
return 'Loading labels...';
}
if (!hasLogLabels) {
return '(No labels found)';
}
return 'Label browser';
}
function willApplySuggestion(suggestion: string, { typeaheadContext, typeaheadText }: SuggestionsState): string { function willApplySuggestion(suggestion: string, { typeaheadContext, typeaheadText }: SuggestionsState): string {
// Modify suggestion based on context // Modify suggestion based on context
switch (typeaheadContext) { switch (typeaheadContext) {
@ -191,11 +179,6 @@ export class LokiQueryField extends React.PureComponent<LokiQueryFieldProps, Lok
onBlur, onBlur,
} = this.props; } = this.props;
const { labelsLoaded, labelBrowserVisible } = this.state;
const hasLogLabels = datasource.languageProvider.getLabelKeys().length > 0;
const chooserText = getChooserText(labelsLoaded, hasLogLabels);
const buttonDisabled = !(labelsLoaded && hasLogLabels);
return ( return (
<LocalStorageValueProvider<string[]> storageKey={LAST_USED_LABELS_KEY} defaultValue={[]}> <LocalStorageValueProvider<string[]> storageKey={LAST_USED_LABELS_KEY} defaultValue={[]}>
{(lastUsedLabels, onLastUsedLabelsSave, onLastUsedLabelsDelete) => { {(lastUsedLabels, onLastUsedLabelsSave, onLastUsedLabelsDelete) => {
@ -205,14 +188,6 @@ export class LokiQueryField extends React.PureComponent<LokiQueryFieldProps, Lok
className="gf-form-inline gf-form-inline--xs-view-flex-column flex-grow-1" className="gf-form-inline gf-form-inline--xs-view-flex-column flex-grow-1"
data-testid={this.props['data-testid']} data-testid={this.props['data-testid']}
> >
<button
className="gf-form-label query-keyword pointer"
onClick={this.onClickChooserButton}
disabled={buttonDisabled}
>
{chooserText}
<Icon name={labelBrowserVisible ? 'angle-down' : 'angle-right'} />
</button>
<div className="gf-form gf-form--grow flex-shrink-1 min-width-15"> <div className="gf-form gf-form--grow flex-shrink-1 min-width-15">
{config.featureToggles.lokiMonacoEditor ? ( {config.featureToggles.lokiMonacoEditor ? (
<MonacoQueryFieldWrapper <MonacoQueryFieldWrapper
@ -239,19 +214,6 @@ export class LokiQueryField extends React.PureComponent<LokiQueryFieldProps, Lok
)} )}
</div> </div>
</div> </div>
{labelBrowserVisible && (
<div className="gf-form">
<LokiLabelBrowser
languageProvider={datasource.languageProvider}
onChange={this.onChangeLabelBrowser}
lastUsedLabels={lastUsedLabels || []}
storeLastUsedLabels={onLastUsedLabelsSave}
deleteLastUsedLabels={onLastUsedLabelsDelete}
app={app}
/>
</div>
)}
{ExtraFieldElement} {ExtraFieldElement}
</> </>
); );

View File

@ -0,0 +1,42 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { LokiDatasource } from '../../datasource';
import { createLokiDatasource } from '../../mocks';
import { LokiQuery } from '../../types';
import { LabelBrowserModal, Props } from './LabelBrowserModal';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
reportInteraction: jest.fn(),
}));
describe('LabelBrowserModal', () => {
let datasource: LokiDatasource, props: Props;
beforeEach(() => {
datasource = createLokiDatasource();
props = {
isOpen: true,
languageProvider: datasource.languageProvider,
query: {} as LokiQuery,
onClose: jest.fn(),
onChange: jest.fn(),
onRunQuery: jest.fn(),
};
jest.spyOn(datasource, 'metadataRequest').mockResolvedValue({});
});
it('renders the label browser modal when open', () => {
render(<LabelBrowserModal {...props} />);
expect(screen.getByRole('heading', { name: /label browser/i })).toBeInTheDocument();
});
it("doesn't render the label browser modal when closed", () => {
render(<LabelBrowserModal {...props} isOpen={false} />);
expect(screen.queryByRole('heading', { name: /label browser/i })).toBeNull();
});
});

View File

@ -0,0 +1,57 @@
import React from 'react';
import { CoreApp } from '@grafana/data';
import { Modal } from '@grafana/ui';
import { LocalStorageValueProvider } from 'app/core/components/LocalStorageValueProvider';
import LanguageProvider from '../../LanguageProvider';
import { LokiLabelBrowser } from '../../components/LokiLabelBrowser';
import { LokiQuery } from '../../types';
export interface Props {
isOpen: boolean;
languageProvider: LanguageProvider;
query: LokiQuery;
app?: CoreApp;
onClose: () => void;
onChange: (query: LokiQuery) => void;
onRunQuery: () => void;
}
export const LabelBrowserModal = (props: Props) => {
const { isOpen, onClose, languageProvider, app } = props;
const LAST_USED_LABELS_KEY = 'grafana.datasources.loki.browser.labels';
const changeQuery = (value: string) => {
const { query, onChange, onRunQuery } = props;
const nextQuery = { ...query, expr: value };
onChange(nextQuery);
onRunQuery();
};
const onChange = (selector: string) => {
changeQuery(selector);
onClose();
};
return (
<Modal isOpen={isOpen} title="Label browser" onDismiss={onClose}>
<LocalStorageValueProvider<string[]> storageKey={LAST_USED_LABELS_KEY} defaultValue={[]}>
{(lastUsedLabels, onLastUsedLabelsSave, onLastUsedLabelsDelete) => {
return (
<LokiLabelBrowser
languageProvider={languageProvider}
onChange={onChange}
lastUsedLabels={lastUsedLabels}
storeLastUsedLabels={onLastUsedLabelsSave}
deleteLastUsedLabels={onLastUsedLabelsDelete}
app={app}
/>
);
}}
</LocalStorageValueProvider>
</Modal>
);
};