feat(slo): improve index selection input (#149786)

This commit is contained in:
Kevin Delemme 2023-01-30 10:58:35 -05:00 committed by GitHub
parent 15ddb87cdf
commit c4ea96e5ce
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 195 additions and 83 deletions

View file

@ -11,8 +11,17 @@ export const useFetchIndices = (): UseFetchIndicesResponse => {
return { return {
loading: false, loading: false,
error: false, error: false,
indices: Array.from({ length: 5 }, (_, i) => ({ indices: [
name: `.index${i}`, ...Array(10)
})) as Index[], .fill(0)
.map((_, i) => ({
name: `.index-${i}`,
})),
...Array(10)
.fill(0)
.map((_, i) => ({
name: `.some-other-index-${i}`,
})),
] as Index[],
}; };
}; };

View file

@ -0,0 +1,34 @@
/*
* 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 { ComponentStory } from '@storybook/react';
import { FormProvider, useForm } from 'react-hook-form';
import { KibanaReactStorybookDecorator } from '../../../../utils/kibana_react.storybook_decorator';
import { IndexSelection as Component, Props } from './index_selection';
import { SLO_EDIT_FORM_DEFAULT_VALUES } from '../../constants';
export default {
component: Component,
title: 'app/SLO/EditPage/CustomKQL/IndexSelection',
decorators: [KibanaReactStorybookDecorator],
};
const Template: ComponentStory<typeof Component> = (props: Props) => {
const methods = useForm({ defaultValues: SLO_EDIT_FORM_DEFAULT_VALUES });
return (
<FormProvider {...methods}>
<Component {...props} control={methods.control} />
</FormProvider>
);
};
const defaultProps = {};
export const IndexSelection = Template.bind({});
IndexSelection.args = defaultProps;

View file

@ -0,0 +1,135 @@
/*
* 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, { useEffect, useState } from 'react';
import { Control, Controller } from 'react-hook-form';
import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { CreateSLOInput } from '@kbn/slo-schema';
import { useFetchIndices, Index } from '../../../../hooks/use_fetch_indices';
export interface Props {
control: Control<CreateSLOInput>;
}
interface Option {
label: string;
options: Array<{ value: string; label: string }>;
}
export function IndexSelection({ control }: Props) {
const { loading, indices = [] } = useFetchIndices();
const [indexOptions, setIndexOptions] = useState<Option[]>([]);
useEffect(() => {
setIndexOptions([createIndexOptions(indices)]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [indices.length]);
const onSearchChange = (search: string) => {
const options: Option[] = [];
if (!search) {
return setIndexOptions([createIndexOptions(indices)]);
}
const searchPattern = search.endsWith('*') ? search.substring(0, search.length - 1) : search;
const matchingIndices = indices.filter(({ name }) => name.startsWith(searchPattern));
if (matchingIndices.length === 0) {
return setIndexOptions([]);
}
options.push(createIndexOptions(matchingIndices));
const searchWithStarSuffix = search.endsWith('*') ? search : `${search}*`;
options.push({
label: i18n.translate(
'xpack.observability.slos.sloEdit.customKql.indexSelection.indexPatternLabel',
{ defaultMessage: 'Use an index pattern' }
),
options: [{ value: searchWithStarSuffix, label: searchWithStarSuffix }],
});
setIndexOptions(options);
};
return (
<EuiFormRow
label={i18n.translate('xpack.observability.slos.sloEdit.customKql.indexSelection.label', {
defaultMessage: 'Index',
})}
helpText={i18n.translate(
'xpack.observability.slos.sloEdit.customKql.indexSelection.helpText',
{
defaultMessage: 'Use * to broaden your query.',
}
)}
>
<Controller
name="indicator.params.index"
control={control}
rules={{ required: true }}
render={({ field, fieldState }) => (
<EuiComboBox
{...field}
aria-label={i18n.translate(
'xpack.observability.slos.sloEdit.customKql.indexSelection.placeholder',
{
defaultMessage: 'Select an index or index pattern',
}
)}
async
data-test-subj="indexSelection"
isClearable={true}
isInvalid={!!fieldState.error}
isLoading={loading}
onChange={(selected: EuiComboBoxOptionOption[]) => {
if (selected.length) {
return field.onChange(selected[0].value);
}
field.onChange('');
}}
onSearchChange={onSearchChange}
options={indexOptions}
placeholder={i18n.translate(
'xpack.observability.slos.sloEdit.customKql.indexSelection.placeholder',
{
defaultMessage: 'Select an index or index pattern',
}
)}
selectedOptions={
!!field.value
? [
{
value: field.value,
label: field.value,
'data-test-subj': 'indexSelectionSelectedValue',
},
]
: []
}
singleSelection
/>
)}
/>
</EuiFormRow>
);
}
function createIndexOptions(indices: Index[]): Option {
return {
label: i18n.translate(
'xpack.observability.slos.sloEdit.customKql.indexSelection.indexOptionsLabel',
{ defaultMessage: 'Select an existing index' }
),
options: indices
.map(({ name }) => ({ label: name, value: name }))
.sort((a, b) => String(a.label).localeCompare(b.label)),
};
}

View file

@ -50,7 +50,7 @@ export function SloEditForm({ slo }: Props) {
notifications: { toasts }, notifications: { toasts },
} = useKibana().services; } = useKibana().services;
const { control, watch, getFieldState, getValues, formState, trigger } = useForm({ const { control, watch, getFieldState, getValues, formState } = useForm({
defaultValues: SLO_EDIT_FORM_DEFAULT_VALUES, defaultValues: SLO_EDIT_FORM_DEFAULT_VALUES,
values: transformSloResponseToCreateSloInput(slo), values: transformSloResponseToCreateSloInput(slo),
mode: 'all', mode: 'all',
@ -144,7 +144,7 @@ export function SloEditForm({ slo }: Props) {
<EuiSpacer size="xxl" /> <EuiSpacer size="xxl" />
{watch('indicator.type') === 'sli.kql.custom' ? ( {watch('indicator.type') === 'sli.kql.custom' ? (
<SloEditFormDefinitionCustomKql control={control} trigger={trigger} /> <SloEditFormDefinitionCustomKql control={control} />
) : null} ) : null}
<EuiSpacer size="m" /> <EuiSpacer size="m" />

View file

@ -26,7 +26,7 @@ const Template: ComponentStory<typeof Component> = (props: Props) => {
const methods = useForm({ defaultValues: SLO_EDIT_FORM_DEFAULT_VALUES }); const methods = useForm({ defaultValues: SLO_EDIT_FORM_DEFAULT_VALUES });
return ( return (
<FormProvider {...methods}> <FormProvider {...methods}>
<Component {...props} control={methods.control} trigger={methods.trigger} /> <Component {...props} control={methods.control} />
</FormProvider> </FormProvider>
); );
}; };

View file

@ -5,89 +5,23 @@
* 2.0. * 2.0.
*/ */
import React, { useEffect } from 'react'; import React from 'react';
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiFormLabel, EuiSuggest } from '@elastic/eui'; import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiFormLabel, EuiSuggest } from '@elastic/eui';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { Control, Controller, UseFormTrigger } from 'react-hook-form'; import { Control, Controller } from 'react-hook-form';
import type { CreateSLOInput } from '@kbn/slo-schema'; import type { CreateSLOInput } from '@kbn/slo-schema';
import { useFetchIndices } from '../../../hooks/use_fetch_indices'; import { IndexSelection } from './custom_kql/index_selection';
export interface Props { export interface Props {
control: Control<CreateSLOInput>; control: Control<CreateSLOInput>;
trigger: UseFormTrigger<CreateSLOInput>;
} }
export function SloEditFormDefinitionCustomKql({ control, trigger }: Props) { export function SloEditFormDefinitionCustomKql({ control }: Props) {
const { loading, indices = [] } = useFetchIndices();
const indicesNames = indices.map(({ name }) => ({
type: { iconType: '', color: '' },
label: name,
description: '',
}));
// Indices are loading in asynchrously, so trigger field validation
// once results are returned from API
useEffect(() => {
if (!loading && indices.length) {
trigger();
}
}, [indices.length, loading, trigger]);
function valueMatchIndex(value: string | undefined, index: string): boolean {
if (value === undefined) {
return false;
}
if (value.length > 0 && value.substring(value.length - 1) === '*') {
return index.indexOf(value.substring(0, value.length - 1), 0) > -1;
}
return index === value;
}
return ( return (
<EuiFlexGroup direction="column" gutterSize="l"> <EuiFlexGroup direction="column" gutterSize="l">
<EuiFlexItem> <EuiFlexItem>
<EuiFormLabel> <IndexSelection control={control} />
{i18n.translate('xpack.observability.slos.sloEdit.sloDefinition.customKql.index', {
defaultMessage: 'Index',
})}
</EuiFormLabel>
<Controller
name="indicator.params.index"
control={control}
rules={{
required: true,
validate: (value) => indices.some((index) => valueMatchIndex(value, index.name)),
}}
render={({ field, fieldState }) => (
<EuiSuggest
fullWidth
isClearable
aria-label="Indices"
data-test-subj="sloFormCustomKqlIndexInput"
status={loading ? 'loading' : field.value ? 'unchanged' : 'unchanged'}
onItemClick={({ label }) => {
field.onChange(label);
}}
isInvalid={
fieldState.isDirty &&
!indicesNames.some((index) => valueMatchIndex(field.value, index.label))
}
placeholder={i18n.translate(
'xpack.observability.slos.sloEdit.sloDefinition.customKql.index.selectIndex',
{
defaultMessage: 'Select an index',
}
)}
suggestions={indicesNames}
{...field}
/>
)}
/>
</EuiFlexItem> </EuiFlexItem>
<EuiFlexItem> <EuiFlexItem>

View file

@ -166,9 +166,8 @@ describe('SLO Edit Page', () => {
SLO_EDIT_FORM_DEFAULT_VALUES.indicator.type SLO_EDIT_FORM_DEFAULT_VALUES.indicator.type
); );
expect(screen.queryByTestId('sloFormCustomKqlIndexInput')).toHaveValue( expect(screen.queryByTestId('indexSelectionSelectedValue')).toBeNull();
SLO_EDIT_FORM_DEFAULT_VALUES.indicator.params.index
);
expect(screen.queryByTestId('sloFormCustomKqlFilterQueryInput')).toHaveValue( expect(screen.queryByTestId('sloFormCustomKqlFilterQueryInput')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.indicator.type === 'sli.kql.custom' SLO_EDIT_FORM_DEFAULT_VALUES.indicator.type === 'sli.kql.custom'
? SLO_EDIT_FORM_DEFAULT_VALUES.indicator.params.filter ? SLO_EDIT_FORM_DEFAULT_VALUES.indicator.params.filter
@ -222,7 +221,7 @@ describe('SLO Edit Page', () => {
render(<SloEditPage />, config); render(<SloEditPage />, config);
userEvent.type(screen.getByTestId('sloFormCustomKqlIndexInput'), 'some-index'); userEvent.type(screen.getByTestId('indexSelection'), 'some-index');
userEvent.type(screen.getByTestId('sloFormCustomKqlFilterQueryInput'), 'irrelevant'); userEvent.type(screen.getByTestId('sloFormCustomKqlFilterQueryInput'), 'irrelevant');
userEvent.type(screen.getByTestId('sloFormCustomKqlGoodQueryInput'), 'irrelevant'); userEvent.type(screen.getByTestId('sloFormCustomKqlGoodQueryInput'), 'irrelevant');
userEvent.type(screen.getByTestId('sloFormCustomKqlTotalQueryInput'), 'irrelevant'); userEvent.type(screen.getByTestId('sloFormCustomKqlTotalQueryInput'), 'irrelevant');
@ -307,9 +306,10 @@ describe('SLO Edit Page', () => {
slo.indicator.type slo.indicator.type
); );
expect(screen.queryByTestId('sloFormCustomKqlIndexInput')).toHaveValue( expect(screen.queryByTestId('indexSelectionSelectedValue')).toHaveTextContent(
slo.indicator.params.index slo.indicator.params.index!
); );
expect(screen.queryByTestId('sloFormCustomKqlFilterQueryInput')).toHaveValue( expect(screen.queryByTestId('sloFormCustomKqlFilterQueryInput')).toHaveValue(
slo.indicator.type === 'sli.kql.custom' ? slo.indicator.params.filter : '' slo.indicator.type === 'sli.kql.custom' ? slo.indicator.params.filter : ''
); );