feat(slo): SLO selector component (#147010)

This commit is contained in:
Kevin Delemme 2022-12-07 08:49:53 -05:00 committed by GitHub
parent 925666e04b
commit f179279e5c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 339 additions and 20 deletions

View file

@ -7,12 +7,16 @@
*/
import * as path from 'path';
import fs from 'fs';
import type { StorybookConfig } from '@storybook/core-common';
import { Configuration } from 'webpack';
import webpack, { Configuration } from 'webpack';
import webpackMerge from 'webpack-merge';
import { REPO_ROOT } from './constants';
import { default as WebpackConfig } from '../webpack.config';
const MOCKS_DIRECTORY = '__storybook_mocks__';
const EXTENSIONS = ['.ts', '.js'];
export type { StorybookConfig };
const toPath = (_path: string) => path.join(REPO_ROOT, _path);
@ -52,6 +56,48 @@ export const defaultConfig: StorybookConfig = {
config.cache = true;
}
// This will go over every component which is imported and check its import statements.
// For every import which starts with ./ it will do a check to see if a file with the same name
// exists in the __storybook_mocks__ folder. If it does, use that import instead.
// This allows you to mock hooks and functions when rendering components in Storybook.
// It is akin to Jest's manual mocks (__mocks__).
config.plugins?.push(
new webpack.NormalModuleReplacementPlugin(/^\.\//, async (resource: any) => {
if (!resource.contextInfo.issuer?.includes('node_modules')) {
const mockedPath = path.resolve(resource.context, MOCKS_DIRECTORY, resource.request);
EXTENSIONS.forEach((ext) => {
const isReplacementPathExists = fs.existsSync(mockedPath + ext);
if (isReplacementPathExists) {
const newImportPath = './' + path.join(MOCKS_DIRECTORY, resource.request);
resource.request = newImportPath;
}
});
}
})
);
// Same, but for imports statements which import modules outside of the directory (../)
config.plugins?.push(
new webpack.NormalModuleReplacementPlugin(/^\.\.\//, async (resource: any) => {
if (!resource.contextInfo.issuer?.includes('node_modules')) {
const prs = path.parse(resource.request);
const mockedPath = path.resolve(resource.context, prs.dir, MOCKS_DIRECTORY, prs.base);
EXTENSIONS.forEach((ext) => {
const isReplacementPathExists = fs.existsSync(mockedPath + ext);
if (isReplacementPathExists) {
const newImportPath = prs.dir + '/' + path.join(MOCKS_DIRECTORY, prs.base);
resource.request = newImportPath;
}
});
}
})
);
config.node = { fs: 'empty' };
config.watch = true;
config.watchOptions = {

View file

@ -0,0 +1,45 @@
/*
* 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 type { SLOList } from '../../public';
export const emptySloList: SLOList = {
results: [],
page: 1,
perPage: 25,
total: 0,
};
export const sloList: SLOList = {
results: [
{
id: '1f1c6ee7-433f-4b56-b727-5682262e0d7d',
name: 'latency',
objective: { target: 0.98 },
summary: {
sliValue: 0.99872,
errorBudget: {
remaining: 0.936,
},
},
},
{
id: 'c0f8d669-9177-4706-9098-f397a88173a6',
name: 'availability',
objective: { target: 0.98 },
summary: {
sliValue: 0.97,
errorBudget: {
remaining: 0,
},
},
},
],
page: 1,
perPage: 25,
total: 2,
};

View file

@ -0,0 +1,25 @@
/*
* 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 { SLO } from '../../../../typings';
import { SloSelector as Component } from './slo_selector';
export default {
component: Component,
title: 'app/SLOs/Shared/SloSelector',
};
const Template: ComponentStory<typeof Component> = () => (
<Component onSelected={(slo: SLO) => console.log(slo)} />
);
const defaultProps = {};
export const Default = Template.bind({});
Default.args = defaultProps;

View file

@ -0,0 +1,46 @@
/*
* 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 { act, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../../../../utils/test_helper';
import { SloSelector } from './slo_selector';
import { useFetchSloList } from '../../../../hooks/slo/use_fetch_slo_list';
import { emptySloList } from '../../../../pages/slos/mocks/slo_list';
import { wait } from '@testing-library/user-event/dist/utils';
jest.mock('../../../../hooks/slo/use_fetch_slo_list');
const useFetchSloListMock = useFetchSloList as jest.Mock;
describe('SLO Selector', () => {
const onSelectedSpy = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
useFetchSloListMock.mockReturnValue({ loading: true, sloList: emptySloList });
});
it('fetches SLOs asynchronously', async () => {
render(<SloSelector onSelected={onSelectedSpy} />);
expect(screen.getByTestId('sloSelector')).toBeTruthy();
expect(useFetchSloListMock).toHaveBeenCalledWith('');
});
it('searches SLOs when typing', async () => {
render(<SloSelector onSelected={onSelectedSpy} />);
const input = screen.getByTestId('comboBoxInput');
await act(async () => {
await userEvent.type(input, 'latency', { delay: 1 });
await wait(310); // debounce delay
});
expect(useFetchSloListMock).toHaveBeenCalledWith('latency');
});
});

View file

@ -0,0 +1,68 @@
/*
* 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 { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { debounce } from 'lodash';
import React, { useEffect, useMemo, useState } from 'react';
import { SLO } from '../../../../typings';
import { useFetchSloList } from '../../../../hooks/slo/use_fetch_slo_list';
interface Props {
onSelected: (slo: SLO) => void;
}
function SloSelector({ onSelected }: Props) {
const [options, setOptions] = useState<Array<EuiComboBoxOptionOption<string>>>([]);
const [selectedOptions, setSelected] = useState<Array<EuiComboBoxOptionOption<string>>>();
const [searchValue, setSearchValue] = useState<string>('');
const { loading, sloList } = useFetchSloList(searchValue);
useEffect(() => {
const isLoadedWithData = !loading && sloList !== undefined;
const opts: Array<EuiComboBoxOptionOption<string>> = isLoadedWithData
? sloList.results.map((slo) => ({ value: slo.id, label: slo.name }))
: [];
setOptions(opts);
}, [loading, sloList]);
const onChange = (opts: Array<EuiComboBoxOptionOption<string>>) => {
setSelected(opts);
if (opts.length === 1) {
const sloId = opts[0].value;
const selectedSlo = sloList.results.find((slo) => slo.id === sloId);
if (selectedSlo !== undefined) {
onSelected(selectedSlo);
}
}
};
const onSearchChange = useMemo(() => debounce((value: string) => setSearchValue(value), 300), []);
return (
<EuiComboBox
aria-label={i18n.translate('xpack.observability.slo.sloSelector.ariaLabel', {
defaultMessage: 'SLO Selector',
})}
placeholder={i18n.translate('xpack.observability.slo.sloSelector.placeholder', {
defaultMessage: 'Select a SLO',
})}
data-test-subj="sloSelector"
singleSelection={{ asPlainText: true }}
options={options}
selectedOptions={selectedOptions}
async
isLoading={loading}
onChange={onChange}
onSearchChange={onSearchChange}
/>
);
}
export { SloSelector };
export type { Props as SloSelectorProps };

View file

@ -0,0 +1,16 @@
/*
* 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 { sloList } from '../../../../common/data/sli_list';
import { UseFetchSloListResponse } from '../use_fetch_slo_list';
export const useFetchSloList = (name?: string): UseFetchSloListResponse => {
return {
loading: false,
sloList,
};
};

View file

@ -8,8 +8,8 @@
import { useCallback, useMemo } from 'react';
import { HttpSetup } from '@kbn/core/public';
import type { SLO, SLOList } from '../../../typings/slo';
import { useDataFetcher } from '../../../hooks/use_data_fetcher';
import type { SLO, SLOList } from '../../typings/slo';
import { useDataFetcher } from '../use_data_fetcher';
const EMPTY_LIST = {
results: [],
@ -18,28 +18,42 @@ const EMPTY_LIST = {
perPage: 0,
};
export const useFetchSloList = (): [boolean, SLOList] => {
const params = useMemo(() => ({}), []);
const shouldExecuteApiCall = useCallback((apiCallParams: {}) => true, []);
interface SLOListParams {
name?: string;
}
const { loading, data: sloList } = useDataFetcher<{}, SLOList>({
interface UseFetchSloListResponse {
loading: boolean;
sloList: SLOList;
}
const useFetchSloList = (name?: string): UseFetchSloListResponse => {
const params: SLOListParams = useMemo(() => ({ name }), [name]);
const shouldExecuteApiCall = useCallback(
(apiCallParams: SLOListParams) => apiCallParams.name === params.name,
[params]
);
const { loading, data: sloList } = useDataFetcher<SLOListParams, SLOList>({
paramsForApiCall: params,
initialDataState: EMPTY_LIST,
executeApiCall: fetchSloList,
shouldExecuteApiCall,
});
return [loading, sloList];
return { loading, sloList };
};
const fetchSloList = async (
params: {},
params: SLOListParams,
abortController: AbortController,
http: HttpSetup
): Promise<SLOList> => {
try {
const response = await http.get<Record<string, unknown>>(`/api/observability/slos`, {
query: {},
query: {
...(params.name && { name: params.name }),
},
signal: abortController.signal,
});
if (response !== undefined) {
@ -78,3 +92,6 @@ function toSLO(result: any): SLO {
},
};
}
export { useFetchSloList };
export type { UseFetchSloListResponse };

View file

@ -8,14 +8,14 @@
import React from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { useFetchSloList } from '../hooks/use_fetch_slo_list';
import { useFetchSloList } from '../../../hooks/slo/use_fetch_slo_list';
export function SloList() {
const [isLoading, sloList] = useFetchSloList();
const { loading, sloList } = useFetchSloList();
return (
<EuiFlexGroup direction="column" gutterSize="s" data-test-subj="sloList">
<EuiFlexItem>{!isLoading && <pre>{JSON.stringify(sloList, null, 2)}</pre>}</EuiFlexItem>
<EuiFlexItem>{!loading && <pre>{JSON.stringify(sloList, null, 2)}</pre>}</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -14,14 +14,14 @@ import { useKibana } from '../../utils/kibana_react';
import { kibanaStartMock } from '../../utils/kibana_react.mock';
import { render } from '../../utils/test_helper';
import { SlosPage } from '.';
import { useFetchSloList } from './hooks/use_fetch_slo_list';
import { useFetchSloList } from '../../hooks/slo/use_fetch_slo_list';
import { emptySloList } from './mocks/slo_list';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: jest.fn(),
}));
jest.mock('./hooks/use_fetch_slo_list');
jest.mock('../../hooks/slo/use_fetch_slo_list');
jest.mock('../../utils/kibana_react');
jest.mock('../../hooks/use_breadcrumbs');
@ -50,7 +50,7 @@ describe('SLOs Page', () => {
beforeEach(() => {
jest.clearAllMocks();
mockKibana();
useFetchSloListMock.mockReturnValue([false, emptySloList]);
useFetchSloListMock.mockReturnValue({ loading: false, sloList: emptySloList });
});
it('renders the not found page when the feature flag is not enabled', async () => {

View file

@ -13,3 +13,33 @@ export const emptySloList: SLOList = {
perPage: 25,
total: 0,
};
export const sloList: SLOList = {
results: [
{
id: '1f1c6ee7-433f-4b56-b727-5682262e0d7d',
name: 'latency',
objective: { target: 0.98 },
summary: {
sliValue: 0.99872,
errorBudget: {
remaining: 0.936,
},
},
},
{
id: 'c0f8d669-9177-4706-9098-f397a88173a6',
name: 'availability',
objective: { target: 0.98 },
summary: {
sliValue: 0.97,
errorBudget: {
remaining: 0,
},
},
},
],
page: 1,
perPage: 25,
total: 2,
};

View file

@ -101,11 +101,11 @@ describe('KibanaSavedObjectsSLORepository', () => {
describe('find', () => {
const DEFAULT_PAGINATION = { page: 1, perPage: 25 };
it('includes the filter on name when provided', async () => {
it('includes the filter on name with wildcard when provided', async () => {
const repository = new KibanaSavedObjectsSLORepository(soClientMock);
soClientMock.find.mockResolvedValueOnce(aFindResponse(SOME_SLO));
const result = await repository.find({ name: 'availability' }, DEFAULT_PAGINATION);
const result = await repository.find({ name: 'availability*' }, DEFAULT_PAGINATION);
expect(result).toEqual({
page: 1,
@ -117,7 +117,27 @@ describe('KibanaSavedObjectsSLORepository', () => {
type: SO_SLO_TYPE,
page: 1,
perPage: 25,
filter: `slo.attributes.name: availability`,
filter: `slo.attributes.name: availability*`,
});
});
it('includes the filter on name with added wildcard when not provided', async () => {
const repository = new KibanaSavedObjectsSLORepository(soClientMock);
soClientMock.find.mockResolvedValueOnce(aFindResponse(SOME_SLO));
const result = await repository.find({ name: 'availa' }, DEFAULT_PAGINATION);
expect(result).toEqual({
page: 1,
perPage: 25,
total: 1,
results: [SOME_SLO],
});
expect(soClientMock.find).toHaveBeenCalledWith({
type: SO_SLO_TYPE,
page: 1,
perPage: 25,
filter: `slo.attributes.name: availa*`,
});
});

View file

@ -96,7 +96,7 @@ export class KibanaSavedObjectsSLORepository implements SLORepository {
function buildFilterKuery(criteria: Criteria): string | undefined {
const filters: string[] = [];
if (!!criteria.name) {
filters.push(`slo.attributes.name: ${criteria.name}`);
filters.push(`slo.attributes.name: ${addWildcardIfAbsent(criteria.name)}`);
}
return filters.length > 0 ? filters.join(' and ') : undefined;
}
@ -113,3 +113,9 @@ function toSLO(storedSLO: StoredSLO): SLO {
}, t.identity)
);
}
const WILDCARD_CHAR = '*';
function addWildcardIfAbsent(value: string): string {
if (value.substring(value.length - 1) === WILDCARD_CHAR) return value;
return `${value}${WILDCARD_CHAR}`;
}