mirror of https://github.com/grafana/grafana
feat(Explore): make sure Loki labels are up to date (#16131)
* Migrated loki syntax and labels logic to useLokiSyntax hook * Enable loki labels refresh after specified interval has passed * Enable periodic loki labels refresh when labels selector is opened * Fix prettier * Add react-hooks-testing-library and disable lib check on typecheck * Add tests for loki syntax/label hooks * Move tsc's skipLibCheck option to tsconfig for webpack to pick it up * Set log labels refresh marker variable when log labels fetch start * Fix prettier issues * Fix type on activeOption in useLokiLabel hook * Typo fixes and types in useLokiSyntax hook test fixes * Make sure effect's setState is not performed on unmounted component * Extract logic for checking if is component mounted to a separate hookpull/16200/head
parent
cf7a5b552b
commit
c2e9daad1e
@ -0,0 +1,12 @@ |
|||||||
|
import { useRef, useEffect } from 'react'; |
||||||
|
|
||||||
|
export const useRefMounted = () => { |
||||||
|
const refMounted = useRef(false); |
||||||
|
useEffect(() => { |
||||||
|
refMounted.current = true; |
||||||
|
return () => { |
||||||
|
refMounted.current = false; |
||||||
|
}; |
||||||
|
}); |
||||||
|
return refMounted; |
||||||
|
}; |
@ -1,266 +1,26 @@ |
|||||||
// Libraries
|
import React, { FunctionComponent } from 'react'; |
||||||
import React from 'react'; |
import { LokiQueryFieldForm, LokiQueryFieldFormProps } from './LokiQueryFieldForm'; |
||||||
import Cascader from 'rc-cascader'; |
import { useLokiSyntax } from './useLokiSyntax'; |
||||||
import PluginPrism from 'slate-prism'; |
|
||||||
import Prism from 'prismjs'; |
|
||||||
|
|
||||||
// Components
|
const LokiQueryField: FunctionComponent<LokiQueryFieldFormProps> = ({ datasource, ...otherProps }) => { |
||||||
import QueryField, { TypeaheadInput, QueryFieldState } from 'app/features/explore/QueryField'; |
const { isSyntaxReady, setActiveOption, refreshLabels, ...syntaxProps } = useLokiSyntax(datasource.languageProvider); |
||||||
|
|
||||||
// Utils & Services
|
|
||||||
// dom also includes Element polyfills
|
|
||||||
import { getNextCharacter, getPreviousCousin } from 'app/features/explore/utils/dom'; |
|
||||||
import BracesPlugin from 'app/features/explore/slate-plugins/braces'; |
|
||||||
import RunnerPlugin from 'app/features/explore/slate-plugins/runner'; |
|
||||||
|
|
||||||
// Types
|
|
||||||
import { LokiQuery } from '../types'; |
|
||||||
import { TypeaheadOutput, HistoryItem } from 'app/types/explore'; |
|
||||||
import { makePromiseCancelable, CancelablePromise } from 'app/core/utils/CancelablePromise'; |
|
||||||
import { ExploreDataSourceApi, ExploreQueryFieldProps } from '@grafana/ui'; |
|
||||||
|
|
||||||
const PRISM_SYNTAX = 'promql'; |
|
||||||
|
|
||||||
function getChooserText(hasSytax, hasLogLabels) { |
|
||||||
if (!hasSytax) { |
|
||||||
return 'Loading labels...'; |
|
||||||
} |
|
||||||
if (!hasLogLabels) { |
|
||||||
return '(No labels found)'; |
|
||||||
} |
|
||||||
return 'Log labels'; |
|
||||||
} |
|
||||||
|
|
||||||
export function willApplySuggestion(suggestion: string, { typeaheadContext, typeaheadText }: QueryFieldState): string { |
|
||||||
// Modify suggestion based on context
|
|
||||||
switch (typeaheadContext) { |
|
||||||
case 'context-labels': { |
|
||||||
const nextChar = getNextCharacter(); |
|
||||||
if (!nextChar || nextChar === '}' || nextChar === ',') { |
|
||||||
suggestion += '='; |
|
||||||
} |
|
||||||
break; |
|
||||||
} |
|
||||||
|
|
||||||
case 'context-label-values': { |
|
||||||
// Always add quotes and remove existing ones instead
|
|
||||||
if (!typeaheadText.match(/^(!?=~?"|")/)) { |
|
||||||
suggestion = `"${suggestion}`; |
|
||||||
} |
|
||||||
if (getNextCharacter() !== '"') { |
|
||||||
suggestion = `${suggestion}"`; |
|
||||||
} |
|
||||||
break; |
|
||||||
} |
|
||||||
|
|
||||||
default: |
|
||||||
} |
|
||||||
return suggestion; |
|
||||||
} |
|
||||||
|
|
||||||
interface CascaderOption { |
|
||||||
label: string; |
|
||||||
value: string; |
|
||||||
children?: CascaderOption[]; |
|
||||||
disabled?: boolean; |
|
||||||
} |
|
||||||
|
|
||||||
interface LokiQueryFieldProps extends ExploreQueryFieldProps<ExploreDataSourceApi, LokiQuery> { |
|
||||||
history: HistoryItem[]; |
|
||||||
} |
|
||||||
|
|
||||||
interface LokiQueryFieldState { |
|
||||||
logLabelOptions: any[]; |
|
||||||
syntaxLoaded: boolean; |
|
||||||
} |
|
||||||
|
|
||||||
export class LokiQueryField extends React.PureComponent<LokiQueryFieldProps, LokiQueryFieldState> { |
|
||||||
plugins: any[]; |
|
||||||
pluginsSearch: any[]; |
|
||||||
languageProvider: any; |
|
||||||
modifiedSearch: string; |
|
||||||
modifiedQuery: string; |
|
||||||
languageProviderInitializationPromise: CancelablePromise<any>; |
|
||||||
|
|
||||||
constructor(props: LokiQueryFieldProps, context) { |
|
||||||
super(props, context); |
|
||||||
|
|
||||||
if (props.datasource.languageProvider) { |
|
||||||
this.languageProvider = props.datasource.languageProvider; |
|
||||||
} |
|
||||||
|
|
||||||
this.plugins = [ |
|
||||||
BracesPlugin(), |
|
||||||
RunnerPlugin({ handler: props.onExecuteQuery }), |
|
||||||
PluginPrism({ |
|
||||||
onlyIn: node => node.type === 'code_block', |
|
||||||
getSyntax: node => 'promql', |
|
||||||
}), |
|
||||||
]; |
|
||||||
|
|
||||||
this.pluginsSearch = [RunnerPlugin({ handler: props.onExecuteQuery })]; |
|
||||||
|
|
||||||
this.state = { |
|
||||||
logLabelOptions: [], |
|
||||||
syntaxLoaded: false, |
|
||||||
}; |
|
||||||
} |
|
||||||
|
|
||||||
componentDidMount() { |
|
||||||
if (this.languageProvider) { |
|
||||||
this.languageProviderInitializationPromise = makePromiseCancelable(this.languageProvider.start()); |
|
||||||
|
|
||||||
this.languageProviderInitializationPromise.promise |
|
||||||
.then(remaining => { |
|
||||||
remaining.map(task => task.then(this.onUpdateLanguage).catch(() => {})); |
|
||||||
}) |
|
||||||
.then(() => this.onUpdateLanguage()) |
|
||||||
.catch(({ isCanceled }) => { |
|
||||||
if (isCanceled) { |
|
||||||
console.warn('LokiQueryField has unmounted, language provider intialization was canceled'); |
|
||||||
} |
|
||||||
}); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
componentWillUnmount() { |
|
||||||
if (this.languageProviderInitializationPromise) { |
|
||||||
this.languageProviderInitializationPromise.cancel(); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
loadOptions = (selectedOptions: CascaderOption[]) => { |
|
||||||
const targetOption = selectedOptions[selectedOptions.length - 1]; |
|
||||||
|
|
||||||
this.setState(state => { |
|
||||||
const nextOptions = state.logLabelOptions.map(option => { |
|
||||||
if (option.value === targetOption.value) { |
|
||||||
return { |
|
||||||
...option, |
|
||||||
loading: true, |
|
||||||
}; |
|
||||||
} |
|
||||||
return option; |
|
||||||
}); |
|
||||||
return { logLabelOptions: nextOptions }; |
|
||||||
}); |
|
||||||
|
|
||||||
this.languageProvider |
|
||||||
.fetchLabelValues(targetOption.value) |
|
||||||
.then(this.onUpdateLanguage) |
|
||||||
.catch(() => {}); |
|
||||||
}; |
|
||||||
|
|
||||||
onChangeLogLabels = (values: string[], selectedOptions: CascaderOption[]) => { |
|
||||||
if (selectedOptions.length === 2) { |
|
||||||
const key = selectedOptions[0].value; |
|
||||||
const value = selectedOptions[1].value; |
|
||||||
const query = `{${key}="${value}"}`; |
|
||||||
this.onChangeQuery(query, true); |
|
||||||
} |
|
||||||
}; |
|
||||||
|
|
||||||
onChangeQuery = (value: string, override?: boolean) => { |
|
||||||
// Send text change to parent
|
|
||||||
const { query, onQueryChange, onExecuteQuery } = this.props; |
|
||||||
if (onQueryChange) { |
|
||||||
const nextQuery = { ...query, expr: value }; |
|
||||||
onQueryChange(nextQuery); |
|
||||||
|
|
||||||
if (override && onExecuteQuery) { |
|
||||||
onExecuteQuery(); |
|
||||||
} |
|
||||||
} |
|
||||||
}; |
|
||||||
|
|
||||||
onClickHintFix = () => { |
|
||||||
const { hint, onExecuteHint } = this.props; |
|
||||||
if (onExecuteHint && hint && hint.fix) { |
|
||||||
onExecuteHint(hint.fix.action); |
|
||||||
} |
|
||||||
}; |
|
||||||
|
|
||||||
onUpdateLanguage = () => { |
|
||||||
Prism.languages[PRISM_SYNTAX] = this.languageProvider.getSyntax(); |
|
||||||
const { logLabelOptions } = this.languageProvider; |
|
||||||
this.setState({ |
|
||||||
logLabelOptions, |
|
||||||
syntaxLoaded: true, |
|
||||||
}); |
|
||||||
}; |
|
||||||
|
|
||||||
onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => { |
|
||||||
if (!this.languageProvider) { |
|
||||||
return { suggestions: [] }; |
|
||||||
} |
|
||||||
|
|
||||||
const { history } = this.props; |
|
||||||
const { prefix, text, value, wrapperNode } = typeahead; |
|
||||||
|
|
||||||
// Get DOM-dependent context
|
|
||||||
const wrapperClasses = Array.from(wrapperNode.classList); |
|
||||||
const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name'); |
|
||||||
const labelKey = labelKeyNode && labelKeyNode.textContent; |
|
||||||
const nextChar = getNextCharacter(); |
|
||||||
|
|
||||||
const result = this.languageProvider.provideCompletionItems( |
|
||||||
{ text, value, prefix, wrapperClasses, labelKey }, |
|
||||||
{ history } |
|
||||||
); |
|
||||||
|
|
||||||
console.log('handleTypeahead', wrapperClasses, text, prefix, nextChar, labelKey, result.context); |
|
||||||
|
|
||||||
return result; |
|
||||||
}; |
|
||||||
|
|
||||||
render() { |
|
||||||
const { error, hint, query } = this.props; |
|
||||||
const { logLabelOptions, syntaxLoaded } = this.state; |
|
||||||
const cleanText = this.languageProvider ? this.languageProvider.cleanText : undefined; |
|
||||||
const hasLogLabels = logLabelOptions && logLabelOptions.length > 0; |
|
||||||
const chooserText = getChooserText(syntaxLoaded, hasLogLabels); |
|
||||||
|
|
||||||
return ( |
return ( |
||||||
<> |
<LokiQueryFieldForm |
||||||
<div className="gf-form-inline"> |
datasource={datasource} |
||||||
<div className="gf-form"> |
syntaxLoaded={isSyntaxReady} |
||||||
<Cascader options={logLabelOptions} onChange={this.onChangeLogLabels} loadData={this.loadOptions}> |
/** |
||||||
<button className="gf-form-label gf-form-label--btn" disabled={!syntaxLoaded}> |
* setActiveOption name is intentional. Because of the way rc-cascader requests additional data |
||||||
{chooserText} <i className="fa fa-caret-down" /> |
* https://github.com/react-component/cascader/blob/master/src/Cascader.jsx#L165
|
||||||
</button> |
* we are notyfing useLokiSyntax hook, what the active option is, and then it's up to the hook logic |
||||||
</Cascader> |
* to fetch data of options that aren't fetched yet |
||||||
</div> |
*/ |
||||||
<div className="gf-form gf-form--grow"> |
onLoadOptions={setActiveOption} |
||||||
<QueryField |
onLabelsRefresh={refreshLabels} |
||||||
additionalPlugins={this.plugins} |
{...syntaxProps} |
||||||
cleanText={cleanText} |
{...otherProps} |
||||||
initialQuery={query.expr} |
|
||||||
onTypeahead={this.onTypeahead} |
|
||||||
onWillApplySuggestion={willApplySuggestion} |
|
||||||
onQueryChange={this.onChangeQuery} |
|
||||||
onExecuteQuery={this.props.onExecuteQuery} |
|
||||||
placeholder="Enter a Loki query" |
|
||||||
portalOrigin="loki" |
|
||||||
syntaxLoaded={syntaxLoaded} |
|
||||||
/> |
/> |
||||||
</div> |
|
||||||
</div> |
|
||||||
<div> |
|
||||||
{error ? <div className="prom-query-field-info text-error">{error}</div> : null} |
|
||||||
{hint ? ( |
|
||||||
<div className="prom-query-field-info text-warning"> |
|
||||||
{hint.label}{' '} |
|
||||||
{hint.fix ? ( |
|
||||||
<a className="text-link muted" onClick={this.onClickHintFix}> |
|
||||||
{hint.fix.label} |
|
||||||
</a> |
|
||||||
) : null} |
|
||||||
</div> |
|
||||||
) : null} |
|
||||||
</div> |
|
||||||
</> |
|
||||||
); |
); |
||||||
} |
}; |
||||||
} |
|
||||||
|
|
||||||
export default LokiQueryField; |
export default LokiQueryField; |
||||||
|
@ -0,0 +1,217 @@ |
|||||||
|
// Libraries
|
||||||
|
import React from 'react'; |
||||||
|
import Cascader from 'rc-cascader'; |
||||||
|
import PluginPrism from 'slate-prism'; |
||||||
|
|
||||||
|
// Components
|
||||||
|
import QueryField, { TypeaheadInput, QueryFieldState } from 'app/features/explore/QueryField'; |
||||||
|
|
||||||
|
// Utils & Services
|
||||||
|
// dom also includes Element polyfills
|
||||||
|
import { getNextCharacter, getPreviousCousin } from 'app/features/explore/utils/dom'; |
||||||
|
import BracesPlugin from 'app/features/explore/slate-plugins/braces'; |
||||||
|
import RunnerPlugin from 'app/features/explore/slate-plugins/runner'; |
||||||
|
|
||||||
|
// Types
|
||||||
|
import { LokiQuery } from '../types'; |
||||||
|
import { TypeaheadOutput, HistoryItem } from 'app/types/explore'; |
||||||
|
import { ExploreDataSourceApi, ExploreQueryFieldProps } from '@grafana/ui'; |
||||||
|
|
||||||
|
function getChooserText(hasSytax, hasLogLabels) { |
||||||
|
if (!hasSytax) { |
||||||
|
return 'Loading labels...'; |
||||||
|
} |
||||||
|
if (!hasLogLabels) { |
||||||
|
return '(No labels found)'; |
||||||
|
} |
||||||
|
return 'Log labels'; |
||||||
|
} |
||||||
|
|
||||||
|
function willApplySuggestion(suggestion: string, { typeaheadContext, typeaheadText }: QueryFieldState): string { |
||||||
|
// Modify suggestion based on context
|
||||||
|
switch (typeaheadContext) { |
||||||
|
case 'context-labels': { |
||||||
|
const nextChar = getNextCharacter(); |
||||||
|
if (!nextChar || nextChar === '}' || nextChar === ',') { |
||||||
|
suggestion += '='; |
||||||
|
} |
||||||
|
break; |
||||||
|
} |
||||||
|
|
||||||
|
case 'context-label-values': { |
||||||
|
// Always add quotes and remove existing ones instead
|
||||||
|
if (!typeaheadText.match(/^(!?=~?"|")/)) { |
||||||
|
suggestion = `"${suggestion}`; |
||||||
|
} |
||||||
|
if (getNextCharacter() !== '"') { |
||||||
|
suggestion = `${suggestion}"`; |
||||||
|
} |
||||||
|
break; |
||||||
|
} |
||||||
|
|
||||||
|
default: |
||||||
|
} |
||||||
|
return suggestion; |
||||||
|
} |
||||||
|
|
||||||
|
export interface CascaderOption { |
||||||
|
label: string; |
||||||
|
value: string; |
||||||
|
children?: CascaderOption[]; |
||||||
|
disabled?: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
export interface LokiQueryFieldFormProps extends ExploreQueryFieldProps<ExploreDataSourceApi, LokiQuery> { |
||||||
|
history: HistoryItem[]; |
||||||
|
syntax: any; |
||||||
|
logLabelOptions: any[]; |
||||||
|
syntaxLoaded: any; |
||||||
|
onLoadOptions: (selectedOptions: CascaderOption[]) => void; |
||||||
|
onLabelsRefresh?: () => void; |
||||||
|
} |
||||||
|
|
||||||
|
export class LokiQueryFieldForm extends React.PureComponent<LokiQueryFieldFormProps> { |
||||||
|
plugins: any[]; |
||||||
|
pluginsSearch: any[]; |
||||||
|
modifiedSearch: string; |
||||||
|
modifiedQuery: string; |
||||||
|
|
||||||
|
constructor(props: LokiQueryFieldFormProps, context) { |
||||||
|
super(props, context); |
||||||
|
|
||||||
|
this.plugins = [ |
||||||
|
BracesPlugin(), |
||||||
|
RunnerPlugin({ handler: props.onExecuteQuery }), |
||||||
|
PluginPrism({ |
||||||
|
onlyIn: node => node.type === 'code_block', |
||||||
|
getSyntax: node => 'promql', |
||||||
|
}), |
||||||
|
]; |
||||||
|
|
||||||
|
this.pluginsSearch = [RunnerPlugin({ handler: props.onExecuteQuery })]; |
||||||
|
} |
||||||
|
|
||||||
|
loadOptions = (selectedOptions: CascaderOption[]) => { |
||||||
|
this.props.onLoadOptions(selectedOptions); |
||||||
|
}; |
||||||
|
|
||||||
|
onChangeLogLabels = (values: string[], selectedOptions: CascaderOption[]) => { |
||||||
|
if (selectedOptions.length === 2) { |
||||||
|
const key = selectedOptions[0].value; |
||||||
|
const value = selectedOptions[1].value; |
||||||
|
const query = `{${key}="${value}"}`; |
||||||
|
this.onChangeQuery(query, true); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
onChangeQuery = (value: string, override?: boolean) => { |
||||||
|
// Send text change to parent
|
||||||
|
const { query, onQueryChange, onExecuteQuery } = this.props; |
||||||
|
if (onQueryChange) { |
||||||
|
const nextQuery = { ...query, expr: value }; |
||||||
|
onQueryChange(nextQuery); |
||||||
|
|
||||||
|
if (override && onExecuteQuery) { |
||||||
|
onExecuteQuery(); |
||||||
|
} |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
onClickHintFix = () => { |
||||||
|
const { hint, onExecuteHint } = this.props; |
||||||
|
if (onExecuteHint && hint && hint.fix) { |
||||||
|
onExecuteHint(hint.fix.action); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => { |
||||||
|
const { datasource } = this.props; |
||||||
|
if (!datasource.languageProvider) { |
||||||
|
return { suggestions: [] }; |
||||||
|
} |
||||||
|
|
||||||
|
const { history } = this.props; |
||||||
|
const { prefix, text, value, wrapperNode } = typeahead; |
||||||
|
|
||||||
|
// Get DOM-dependent context
|
||||||
|
const wrapperClasses = Array.from(wrapperNode.classList); |
||||||
|
const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name'); |
||||||
|
const labelKey = labelKeyNode && labelKeyNode.textContent; |
||||||
|
const nextChar = getNextCharacter(); |
||||||
|
|
||||||
|
const result = datasource.languageProvider.provideCompletionItems( |
||||||
|
{ text, value, prefix, wrapperClasses, labelKey }, |
||||||
|
{ history } |
||||||
|
); |
||||||
|
|
||||||
|
console.log('handleTypeahead', wrapperClasses, text, prefix, nextChar, labelKey, result.context); |
||||||
|
|
||||||
|
return result; |
||||||
|
}; |
||||||
|
|
||||||
|
render() { |
||||||
|
const { |
||||||
|
error, |
||||||
|
hint, |
||||||
|
query, |
||||||
|
syntaxLoaded, |
||||||
|
logLabelOptions, |
||||||
|
onLoadOptions, |
||||||
|
onLabelsRefresh, |
||||||
|
datasource, |
||||||
|
} = this.props; |
||||||
|
const cleanText = datasource.languageProvider ? datasource.languageProvider.cleanText : undefined; |
||||||
|
const hasLogLabels = logLabelOptions && logLabelOptions.length > 0; |
||||||
|
const chooserText = getChooserText(syntaxLoaded, hasLogLabels); |
||||||
|
|
||||||
|
return ( |
||||||
|
<> |
||||||
|
<div className="gf-form-inline"> |
||||||
|
<div className="gf-form"> |
||||||
|
<Cascader |
||||||
|
options={logLabelOptions} |
||||||
|
onChange={this.onChangeLogLabels} |
||||||
|
loadData={onLoadOptions} |
||||||
|
onPopupVisibleChange={isVisible => { |
||||||
|
if (isVisible && onLabelsRefresh) { |
||||||
|
onLabelsRefresh(); |
||||||
|
} |
||||||
|
}} |
||||||
|
> |
||||||
|
<button className="gf-form-label gf-form-label--btn" disabled={!syntaxLoaded}> |
||||||
|
{chooserText} <i className="fa fa-caret-down" /> |
||||||
|
</button> |
||||||
|
</Cascader> |
||||||
|
</div> |
||||||
|
<div className="gf-form gf-form--grow"> |
||||||
|
<QueryField |
||||||
|
additionalPlugins={this.plugins} |
||||||
|
cleanText={cleanText} |
||||||
|
initialQuery={query.expr} |
||||||
|
onTypeahead={this.onTypeahead} |
||||||
|
onWillApplySuggestion={willApplySuggestion} |
||||||
|
onQueryChange={this.onChangeQuery} |
||||||
|
onExecuteQuery={this.props.onExecuteQuery} |
||||||
|
placeholder="Enter a Loki query" |
||||||
|
portalOrigin="loki" |
||||||
|
syntaxLoaded={syntaxLoaded} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
{error ? <div className="prom-query-field-info text-error">{error}</div> : null} |
||||||
|
{hint ? ( |
||||||
|
<div className="prom-query-field-info text-warning"> |
||||||
|
{hint.label}{' '} |
||||||
|
{hint.fix ? ( |
||||||
|
<a className="text-link muted" onClick={this.onClickHintFix}> |
||||||
|
{hint.fix.label} |
||||||
|
</a> |
||||||
|
) : null} |
||||||
|
</div> |
||||||
|
) : null} |
||||||
|
</div> |
||||||
|
</> |
||||||
|
); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,24 @@ |
|||||||
|
import { renderHook, act } from 'react-hooks-testing-library'; |
||||||
|
import LanguageProvider from 'app/plugins/datasource/loki/language_provider'; |
||||||
|
import { useLokiLabels } from './useLokiLabels'; |
||||||
|
|
||||||
|
describe('useLokiLabels hook', () => { |
||||||
|
const datasource = { |
||||||
|
metadataRequest: () => ({ data: { data: [] } }), |
||||||
|
}; |
||||||
|
const languageProvider = new LanguageProvider(datasource); |
||||||
|
const logLabelOptionsMock = ['Holy mock!']; |
||||||
|
|
||||||
|
languageProvider.refreshLogLabels = () => { |
||||||
|
languageProvider.logLabelOptions = logLabelOptionsMock; |
||||||
|
return Promise.resolve(); |
||||||
|
}; |
||||||
|
|
||||||
|
it('should refresh labels', async () => { |
||||||
|
const { result, waitForNextUpdate } = renderHook(() => useLokiLabels(languageProvider, true, [])); |
||||||
|
act(() => result.current.refreshLabels()); |
||||||
|
expect(result.current.logLabelOptions).toEqual([]); |
||||||
|
await waitForNextUpdate(); |
||||||
|
expect(result.current.logLabelOptions).toEqual(logLabelOptionsMock); |
||||||
|
}); |
||||||
|
}); |
@ -0,0 +1,79 @@ |
|||||||
|
import { useState, useEffect } from 'react'; |
||||||
|
import LokiLanguageProvider from 'app/plugins/datasource/loki/language_provider'; |
||||||
|
import { CascaderOption } from 'app/plugins/datasource/loki/components/LokiQueryFieldForm'; |
||||||
|
import { useRefMounted } from 'app/core/hooks/useRefMounted'; |
||||||
|
|
||||||
|
/** |
||||||
|
* |
||||||
|
* @param languageProvider |
||||||
|
* @param languageProviderInitialised |
||||||
|
* @param activeOption rc-cascader provided option used to fetch option's values that hasn't been loaded yet |
||||||
|
* |
||||||
|
* @description Fetches missing labels and enables labels refresh |
||||||
|
*/ |
||||||
|
export const useLokiLabels = ( |
||||||
|
languageProvider: LokiLanguageProvider, |
||||||
|
languageProviderInitialised: boolean, |
||||||
|
activeOption: CascaderOption[] |
||||||
|
) => { |
||||||
|
const mounted = useRefMounted(); |
||||||
|
|
||||||
|
// State
|
||||||
|
const [logLabelOptions, setLogLabelOptions] = useState([]); |
||||||
|
const [shouldTryRefreshLabels, setRefreshLabels] = useState(false); |
||||||
|
|
||||||
|
// Async
|
||||||
|
const fetchOptionValues = async option => { |
||||||
|
await languageProvider.fetchLabelValues(option); |
||||||
|
if (mounted.current) { |
||||||
|
setLogLabelOptions(languageProvider.logLabelOptions); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
const tryLabelsRefresh = async () => { |
||||||
|
await languageProvider.refreshLogLabels(); |
||||||
|
if (mounted.current) { |
||||||
|
setRefreshLabels(false); |
||||||
|
setLogLabelOptions(languageProvider.logLabelOptions); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
// Effects
|
||||||
|
|
||||||
|
// This effect performs loading of options that hasn't been loaded yet
|
||||||
|
// It's a subject of activeOption state change only. This is because of specific behavior or rc-cascader
|
||||||
|
// https://github.com/react-component/cascader/blob/master/src/Cascader.jsx#L165
|
||||||
|
useEffect(() => { |
||||||
|
if (languageProviderInitialised) { |
||||||
|
const targetOption = activeOption[activeOption.length - 1]; |
||||||
|
if (targetOption) { |
||||||
|
const nextOptions = logLabelOptions.map(option => { |
||||||
|
if (option.value === targetOption.value) { |
||||||
|
return { |
||||||
|
...option, |
||||||
|
loading: true, |
||||||
|
}; |
||||||
|
} |
||||||
|
return option; |
||||||
|
}); |
||||||
|
setLogLabelOptions(nextOptions); // to set loading
|
||||||
|
fetchOptionValues(targetOption.value); |
||||||
|
} |
||||||
|
} |
||||||
|
}, [activeOption]); |
||||||
|
|
||||||
|
// This effect is performed on shouldTryRefreshLabels state change only.
|
||||||
|
// Since shouldTryRefreshLabels is reset AFTER the labels are refreshed we are secured in case of trying to refresh
|
||||||
|
// when previous refresh hasn't finished yet
|
||||||
|
useEffect(() => { |
||||||
|
if (shouldTryRefreshLabels) { |
||||||
|
tryLabelsRefresh(); |
||||||
|
} |
||||||
|
}, [shouldTryRefreshLabels]); |
||||||
|
|
||||||
|
return { |
||||||
|
logLabelOptions, |
||||||
|
setLogLabelOptions, |
||||||
|
refreshLabels: () => setRefreshLabels(true), |
||||||
|
}; |
||||||
|
}; |
@ -0,0 +1,66 @@ |
|||||||
|
import { renderHook, act } from 'react-hooks-testing-library'; |
||||||
|
import LanguageProvider from 'app/plugins/datasource/loki/language_provider'; |
||||||
|
import { useLokiSyntax } from './useLokiSyntax'; |
||||||
|
import { CascaderOption } from 'app/plugins/datasource/loki/components/LokiQueryFieldForm'; |
||||||
|
|
||||||
|
describe('useLokiSyntax hook', () => { |
||||||
|
const datasource = { |
||||||
|
metadataRequest: () => ({ data: { data: [] } }), |
||||||
|
}; |
||||||
|
const languageProvider = new LanguageProvider(datasource); |
||||||
|
const logLabelOptionsMock = ['Holy mock!']; |
||||||
|
const logLabelOptionsMock2 = ['Mock the hell?!']; |
||||||
|
const logLabelOptionsMock3 = ['Oh my mock!']; |
||||||
|
|
||||||
|
languageProvider.refreshLogLabels = () => { |
||||||
|
languageProvider.logLabelOptions = logLabelOptionsMock; |
||||||
|
return Promise.resolve(); |
||||||
|
}; |
||||||
|
|
||||||
|
languageProvider.fetchLogLabels = () => { |
||||||
|
languageProvider.logLabelOptions = logLabelOptionsMock2; |
||||||
|
return Promise.resolve([]); |
||||||
|
}; |
||||||
|
|
||||||
|
const activeOptionMock: CascaderOption = { |
||||||
|
label: '', |
||||||
|
value: '', |
||||||
|
}; |
||||||
|
|
||||||
|
it('should provide Loki syntax when used', async () => { |
||||||
|
const { result, waitForNextUpdate } = renderHook(() => useLokiSyntax(languageProvider)); |
||||||
|
expect(result.current.syntax).toEqual(null); |
||||||
|
|
||||||
|
await waitForNextUpdate(); |
||||||
|
|
||||||
|
expect(result.current.syntax).toEqual(languageProvider.getSyntax()); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should fetch labels on first call', async () => { |
||||||
|
const { result, waitForNextUpdate } = renderHook(() => useLokiSyntax(languageProvider)); |
||||||
|
expect(result.current.isSyntaxReady).toBeFalsy(); |
||||||
|
expect(result.current.logLabelOptions).toEqual([]); |
||||||
|
|
||||||
|
await waitForNextUpdate(); |
||||||
|
|
||||||
|
expect(result.current.isSyntaxReady).toBeTruthy(); |
||||||
|
expect(result.current.logLabelOptions).toEqual(logLabelOptionsMock2); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should try to fetch missing options when active option changes', async () => { |
||||||
|
const { result, waitForNextUpdate } = renderHook(() => useLokiSyntax(languageProvider)); |
||||||
|
await waitForNextUpdate(); |
||||||
|
expect(result.current.logLabelOptions).toEqual(logLabelOptionsMock2); |
||||||
|
|
||||||
|
languageProvider.fetchLabelValues = (key: string) => { |
||||||
|
languageProvider.logLabelOptions = logLabelOptionsMock3; |
||||||
|
return Promise.resolve(); |
||||||
|
}; |
||||||
|
|
||||||
|
act(() => result.current.setActiveOption([activeOptionMock])); |
||||||
|
|
||||||
|
await waitForNextUpdate(); |
||||||
|
|
||||||
|
expect(result.current.logLabelOptions).toEqual(logLabelOptionsMock3); |
||||||
|
}); |
||||||
|
}); |
@ -0,0 +1,57 @@ |
|||||||
|
import { useState, useEffect } from 'react'; |
||||||
|
import LokiLanguageProvider from 'app/plugins/datasource/loki/language_provider'; |
||||||
|
import Prism from 'prismjs'; |
||||||
|
import { useLokiLabels } from 'app/plugins/datasource/loki/components/useLokiLabels'; |
||||||
|
import { CascaderOption } from 'app/plugins/datasource/loki/components/LokiQueryFieldForm'; |
||||||
|
import { useRefMounted } from 'app/core/hooks/useRefMounted'; |
||||||
|
|
||||||
|
const PRISM_SYNTAX = 'promql'; |
||||||
|
|
||||||
|
/** |
||||||
|
* |
||||||
|
* @param languageProvider |
||||||
|
* @description Initializes given language provider, exposes Loki syntax and enables loading label option values |
||||||
|
*/ |
||||||
|
export const useLokiSyntax = (languageProvider: LokiLanguageProvider) => { |
||||||
|
const mounted = useRefMounted(); |
||||||
|
// State
|
||||||
|
const [languageProviderInitialized, setLanguageProviderInitilized] = useState(false); |
||||||
|
const [syntax, setSyntax] = useState(null); |
||||||
|
|
||||||
|
/** |
||||||
|
* Holds information about currently selected option from rc-cascader to perform effect |
||||||
|
* that loads option values not fetched yet. Based on that useLokiLabels hook decides whether or not |
||||||
|
* the option requires additional data fetching |
||||||
|
*/ |
||||||
|
const [activeOption, setActiveOption] = useState<CascaderOption[]>(); |
||||||
|
|
||||||
|
const { logLabelOptions, setLogLabelOptions, refreshLabels } = useLokiLabels( |
||||||
|
languageProvider, |
||||||
|
languageProviderInitialized, |
||||||
|
activeOption |
||||||
|
); |
||||||
|
|
||||||
|
// Async
|
||||||
|
const initializeLanguageProvider = async () => { |
||||||
|
await languageProvider.start(); |
||||||
|
Prism.languages[PRISM_SYNTAX] = languageProvider.getSyntax(); |
||||||
|
if (mounted.current) { |
||||||
|
setLogLabelOptions(languageProvider.logLabelOptions); |
||||||
|
setSyntax(languageProvider.getSyntax()); |
||||||
|
setLanguageProviderInitilized(true); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
// Effects
|
||||||
|
useEffect(() => { |
||||||
|
initializeLanguageProvider(); |
||||||
|
}, []); |
||||||
|
|
||||||
|
return { |
||||||
|
isSyntaxReady: languageProviderInitialized, |
||||||
|
syntax, |
||||||
|
logLabelOptions, |
||||||
|
setActiveOption, |
||||||
|
refreshLabels, |
||||||
|
}; |
||||||
|
}; |
Loading…
Reference in new issue