[8.8] Fix React Errors in SLOs (#155953) (#156148)

# Backport

This will backport the following commits from `main` to `8.8`:
- [Fix React Errors in SLOs
(#155953)](https://github.com/elastic/kibana/pull/155953)

<!--- Backport version: 8.9.7 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Coen
Warmer","email":"coen.warmer@gmail.com"},"sourceCommit":{"committedDate":"2023-04-27T17:25:54Z","message":"Fix
React Errors in SLOs
(#155953)","sha":"6c5aafba04767ea6411b5b2cd613f1ab29ecfd75","branchLabelMapping":{"^v8.9.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["backport","release_note:skip","Team:
Actionable
Observability","v8.8.0","v8.9.0"],"number":155953,"url":"https://github.com/elastic/kibana/pull/155953","mergeCommit":{"message":"Fix
React Errors in SLOs
(#155953)","sha":"6c5aafba04767ea6411b5b2cd613f1ab29ecfd75"}},"sourceBranch":"main","suggestedTargetBranches":["8.8"],"targetPullRequestStates":[{"branch":"8.8","label":"v8.8.0","labelRegex":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"main","label":"v8.9.0","labelRegex":"^v8.9.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/155953","number":155953,"mergeCommit":{"message":"Fix
React Errors in SLOs
(#155953)","sha":"6c5aafba04767ea6411b5b2cd613f1ab29ecfd75"}}]}]
BACKPORT-->
This commit is contained in:
Coen Warmer 2023-04-28 14:26:22 +02:00 committed by GitHub
parent f21d997505
commit db4634bd03
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 516 additions and 327 deletions

View file

@ -18,6 +18,7 @@ export const paths = {
ruleDetails: (ruleId?: string | null) =>
ruleId ? `${RULES_PAGE_LINK}/${encodeURI(ruleId)}` : RULES_PAGE_LINK,
slos: SLOS_PAGE_LINK,
slosWelcome: `${SLOS_PAGE_LINK}/welcome`,
sloCreate: `${SLOS_PAGE_LINK}/create`,
sloEdit: (sloId: string) => `${SLOS_PAGE_LINK}/edit/${encodeURI(sloId)}`,
sloDetails: (sloId: string) => `${SLOS_PAGE_LINK}/${encodeURI(sloId)}`,

View file

@ -12,6 +12,7 @@ export const useFetchSloList = (): UseFetchSloListResponse => {
return {
isInitialLoading: false,
isLoading: false,
isRefetching: false,
isError: false,
isSuccess: true,
sloList,

View file

@ -7,15 +7,19 @@
import { v1 as uuidv1 } from 'uuid';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { i18n } from '@kbn/i18n';
import type { CreateSLOInput, CreateSLOResponse, FindSLOResponse } from '@kbn/slo-schema';
import { useKibana } from '../../utils/kibana_react';
export function useCloneSlo() {
const { http } = useKibana().services;
const {
http,
notifications: { toasts },
} = useKibana().services;
const queryClient = useQueryClient();
const cloneSlo = useMutation<
return useMutation<
CreateSLOResponse,
string,
{ slo: CreateSLOInput; idToCopyFrom?: string },
@ -41,7 +45,10 @@ export function useCloneSlo() {
const optimisticUpdate = {
...data,
results: [...(data?.results || []), { ...sloUsedToClone, name: slo.name, id: uuidv1() }],
results: [
...(data?.results || []),
{ ...sloUsedToClone, name: slo.name, id: uuidv1(), summary: undefined },
],
total: data?.total && data.total + 1,
};
@ -50,20 +57,31 @@ export function useCloneSlo() {
queryClient.setQueryData(queryKey, optimisticUpdate);
}
toasts.addSuccess(
i18n.translate('xpack.observability.slo.clone.successNotification', {
defaultMessage: 'Successfully created {name}',
values: { name: slo.name },
})
);
// Return a context object with the snapshotted value
return { previousSloList: data };
},
// If the mutation fails, use the context returned from onMutate to roll back
onError: (_err, _slo, context) => {
onError: (_err, { slo }, context) => {
if (context?.previousSloList) {
queryClient.setQueryData(['fetchSloList'], context.previousSloList);
}
toasts.addDanger(
i18n.translate('xpack.observability.slo.clone.errorNotification', {
defaultMessage: 'Failed to clone {name}',
values: { name: slo.name },
})
);
},
onSuccess: () => {
queryClient.invalidateQueries(['fetchSloList']);
},
}
);
return cloneSlo;
}

View file

@ -5,27 +5,70 @@
* 2.0.
*/
import { v1 as uuidv1 } from 'uuid';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { CreateSLOInput, CreateSLOResponse } from '@kbn/slo-schema';
import { i18n } from '@kbn/i18n';
import type { CreateSLOInput, CreateSLOResponse, FindSLOResponse } from '@kbn/slo-schema';
import { useKibana } from '../../utils/kibana_react';
export function useCreateSlo() {
const { http } = useKibana().services;
const {
http,
notifications: { toasts },
} = useKibana().services;
const queryClient = useQueryClient();
const createSlo = useMutation(
return useMutation(
({ slo }: { slo: CreateSLOInput }) => {
const body = JSON.stringify(slo);
return http.post<CreateSLOResponse>(`/api/observability/slos`, { body });
},
{
mutationKey: ['createSlo'],
onSuccess: () => {
onSuccess: (_data, { slo: { name } }) => {
toasts.addSuccess(
i18n.translate('xpack.observability.slo.create.successNotification', {
defaultMessage: 'Successfully created {name}',
values: { name },
})
);
queryClient.invalidateQueries(['fetchSloList']);
},
onError: (error, { slo: { name } }) => {
toasts.addError(new Error(String(error)), {
title: i18n.translate('xpack.observability.slo.create.errorNotification', {
defaultMessage: 'Something went wrong while creating {name}',
values: { name },
}),
});
},
onMutate: async ({ slo }) => {
// Cancel any outgoing refetches (so they don't overwrite our optimistic update)
await queryClient.cancelQueries(['fetchSloList']);
const latestFetchSloListRequest = (
queryClient.getQueriesData<FindSLOResponse>(['fetchSloList']) || []
).at(0);
const [queryKey, data] = latestFetchSloListRequest || [];
const newItem = { ...slo, id: uuidv1() };
const optimisticUpdate = {
...data,
results: [...(data?.results || []), { ...newItem }],
total: data?.total ? data.total + 1 : 1,
};
// Optimistically update to the new value
if (queryKey) {
queryClient.setQueryData(queryKey, optimisticUpdate);
}
// Return a context object with the snapshotted value
return { previousSloList: data };
},
}
);
return createSlo;
}

View file

@ -6,21 +6,24 @@
*/
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { i18n } from '@kbn/i18n';
import { FindSLOResponse } from '@kbn/slo-schema';
import { useKibana } from '../../utils/kibana_react';
export function useDeleteSlo(sloId: string) {
const { http } = useKibana().services;
export function useDeleteSlo() {
const {
http,
notifications: { toasts },
} = useKibana().services;
const queryClient = useQueryClient();
const deleteSlo = useMutation<
string,
string,
{ id: string },
{ id: string; name: string },
{ previousSloList: FindSLOResponse | undefined }
>(
['deleteSlo', sloId],
['deleteSlo'],
({ id }) => {
try {
return http.delete<string>(`/api/observability/slos/${id}`);
@ -50,16 +53,30 @@ export function useDeleteSlo(sloId: string) {
queryClient.setQueryData(queryKey, optimisticUpdate);
}
toasts.addSuccess(
i18n.translate('xpack.observability.slo.slo.delete.successNotification', {
defaultMessage: 'Deleted {name}',
values: { name: slo.name },
})
);
// Return a context object with the snapshotted value
return { previousSloList: data };
},
// If the mutation fails, use the context returned from onMutate to roll back
onError: (_err, _slo, context) => {
onError: (_err, slo, context) => {
if (context?.previousSloList) {
queryClient.setQueryData(['fetchSloList'], context.previousSloList);
}
toasts.addDanger(
i18n.translate('xpack.observability.slo.slo.delete.errorNotification', {
defaultMessage: 'Failed to delete {name}',
values: { name: slo.name },
})
);
},
onSuccess: () => {
onSuccess: (_success, slo) => {
queryClient.invalidateQueries(['fetchSloList']);
},
}

View file

@ -77,8 +77,9 @@ export function useFetchRulesForSlo({ sloIds }: Params): UseFetchRulesForSloResp
// ignore error for retrieving slos
}
},
enabled: Boolean(sloIds),
enabled: Boolean(sloIds?.length),
refetchOnWindowFocus: false,
keepPreviousData: true,
}
);

View file

@ -28,6 +28,7 @@ interface SLOListParams {
export interface UseFetchSloListResponse {
isInitialLoading: boolean;
isLoading: boolean;
isRefetching: boolean;
isSuccess: boolean;
isError: boolean;
sloList: FindSLOResponse | undefined;
@ -96,14 +97,19 @@ export function useFetchSloList({
queryClient.invalidateQueries(['fetchActiveAlerts'], {
exact: false,
});
queryClient.invalidateQueries(['fetchRulesForSlo'], {
exact: false,
});
},
}
);
return {
sloList: data,
isLoading: isLoading || isRefetching,
isInitialLoading,
isLoading,
isRefetching,
isSuccess,
isError,
refetch,

View file

@ -6,27 +6,43 @@
*/
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { i18n } from '@kbn/i18n';
import type { UpdateSLOInput, UpdateSLOResponse } from '@kbn/slo-schema';
import { useKibana } from '../../utils/kibana_react';
export function useUpdateSlo() {
const { http } = useKibana().services;
const {
http,
notifications: { toasts },
} = useKibana().services;
const queryClient = useQueryClient();
const updateSlo = useMutation(
return useMutation(
({ sloId, slo }: { sloId: string; slo: UpdateSLOInput }) => {
const body = JSON.stringify(slo);
return http.put<UpdateSLOResponse>(`/api/observability/slos/${sloId}`, { body });
},
{
mutationKey: ['updateSlo'],
onSuccess: () => {
onSuccess: (_data, { slo: { name } }) => {
toasts.addSuccess(
i18n.translate('xpack.observability.slo.update.successNotification', {
defaultMessage: 'Successfully updated {name}',
values: { name },
})
);
queryClient.invalidateQueries(['fetchSloList']);
queryClient.invalidateQueries(['fetchHistoricalSummary']);
},
onError: (error, { slo: { name } }) => {
toasts.addError(new Error(String(error)), {
title: i18n.translate('xpack.observability.slo.update.errorNotification', {
defaultMessage: 'Something went wrong when updating {name}',
values: { name },
}),
});
},
}
);
return updateSlo;
}

View file

@ -5,21 +5,20 @@
* 2.0.
*/
import React, { useState } from 'react';
import { useIsMutating } from '@tanstack/react-query';
import React, { useCallback, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButton, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import { useCapabilities } from '../../../hooks/slo/use_capabilities';
import { useKibana } from '../../../utils/kibana_react';
import { useCloneSlo } from '../../../hooks/slo/use_clone_slo';
import { useDeleteSlo } from '../../../hooks/slo/use_delete_slo';
import { isApmIndicatorType } from '../../../utils/slo/indicator';
import { convertSliApmParamsToApmAppDeeplinkUrl } from '../../../utils/slo/convert_sli_apm_params_to_apm_app_deeplink_url';
import { SLO_BURN_RATE_RULE_ID } from '../../../../common/constants';
import { sloFeatureId } from '../../../../common';
import { paths } from '../../../config/paths';
import { useKibana } from '../../../utils/kibana_react';
import { ObservabilityAppServices } from '../../../application/types';
import { useCapabilities } from '../../../hooks/slo/use_capabilities';
import { useCloneSlo } from '../../../hooks/slo/use_clone_slo';
import {
transformSloResponseToCreateSloInput,
transformValuesToCreateSLOInput,
@ -35,24 +34,23 @@ export function HeaderControl({ isLoading, slo }: Props) {
const {
application: { navigateToUrl },
http: { basePath },
notifications: { toasts },
triggersActionsUi: { getAddRuleFlyout: AddRuleFlyout },
} = useKibana<ObservabilityAppServices>().services;
} = useKibana().services;
const { hasWriteCapabilities } = useCapabilities();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [isRuleFlyoutVisible, setRuleFlyoutVisibility] = useState<boolean>(false);
const [isDeleteConfirmationModalOpen, setDeleteConfirmationModalOpen] = useState(false);
const { mutateAsync: cloneSlo } = useCloneSlo();
const isDeleting = Boolean(useIsMutating(['deleteSlo', slo?.id]));
const { mutate: cloneSlo } = useCloneSlo();
const { mutate: deleteSlo } = useDeleteSlo();
const handleActionsClick = () => setIsPopoverOpen((value) => !value);
const closePopover = () => setIsPopoverOpen(false);
const handleEdit = () => {
if (slo) {
navigateToUrl(basePath.prepend(paths.observability.sloEdit(slo.id)));
navigate(basePath.prepend(paths.observability.sloEdit(slo.id)));
}
};
@ -94,7 +92,7 @@ export function HeaderControl({ isLoading, slo }: Props) {
transactionType,
});
navigateToUrl(basePath.prepend(url));
navigate(basePath.prepend(url));
}
};
@ -106,16 +104,9 @@ export function HeaderControl({ isLoading, slo }: Props) {
transformSloResponseToCreateSloInput({ ...slo, name: `[Copy] ${slo.name}` })!
);
await cloneSlo({ slo: newSlo, idToCopyFrom: slo.id });
cloneSlo({ slo: newSlo, idToCopyFrom: slo.id });
toasts.addSuccess(
i18n.translate('xpack.observability.slo.sloDetails.headerControl.cloneSuccess', {
defaultMessage: 'Successfully created {name}',
values: { name: newSlo.name },
})
);
navigateToUrl(basePath.prepend(paths.observability.slos));
navigate(basePath.prepend(paths.observability.slos));
}
};
@ -128,10 +119,18 @@ export function HeaderControl({ isLoading, slo }: Props) {
setDeleteConfirmationModalOpen(false);
};
const handleDeleteSuccess = () => {
navigateToUrl(basePath.prepend(paths.observability.slos));
const handleDeleteConfirm = async () => {
if (slo) {
deleteSlo({ id: slo.id, name: slo.name });
navigate(basePath.prepend(paths.observability.slos));
}
};
const navigate = useCallback(
(url: string) => setTimeout(() => navigateToUrl(url)),
[navigateToUrl]
);
return (
<>
<EuiPopover
@ -144,7 +143,7 @@ export function HeaderControl({ isLoading, slo }: Props) {
iconType="arrowDown"
iconSize="s"
onClick={handleActionsClick}
disabled={isLoading || isDeleting || !slo}
disabled={isLoading || !slo}
>
{i18n.translate('xpack.observability.slo.sloDetails.headerControl.actions', {
defaultMessage: 'Actions',
@ -254,7 +253,7 @@ export function HeaderControl({ isLoading, slo }: Props) {
<SloDeleteConfirmationModal
slo={slo}
onCancel={handleDeleteCancel}
onSuccess={handleDeleteSuccess}
onConfirm={handleDeleteConfirm}
/>
) : null}
</>

View file

@ -6,19 +6,21 @@
*/
import React from 'react';
import { fireEvent, screen } from '@testing-library/react';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { useKibana } from '../../utils/kibana_react';
import { useParams } from 'react-router-dom';
import { useLicense } from '../../hooks/use_license';
import { useCapabilities } from '../../hooks/slo/use_capabilities';
import { useFetchSloDetails } from '../../hooks/slo/use_fetch_slo_details';
import { useFetchHistoricalSummary } from '../../hooks/slo/use_fetch_historical_summary';
import { useFetchActiveAlerts } from '../../hooks/slo/use_fetch_active_alerts';
import { useCloneSlo } from '../../hooks/slo/use_clone_slo';
import { useDeleteSlo } from '../../hooks/slo/use_delete_slo';
import { render } from '../../utils/test_helper';
import { SloDetailsPage } from './slo_details';
import { buildSlo } from '../../data/slo/slo';
import { paths } from '../../config/paths';
import { useFetchHistoricalSummary } from '../../hooks/slo/use_fetch_historical_summary';
import { useCapabilities } from '../../hooks/slo/use_capabilities';
import { useFetchActiveAlerts } from '../../hooks/slo/use_fetch_active_alerts';
import {
HEALTHY_STEP_DOWN_ROLLING_SLO,
historicalSummaryData,
@ -34,21 +36,26 @@ jest.mock('react-router-dom', () => ({
jest.mock('../../utils/kibana_react');
jest.mock('../../hooks/use_breadcrumbs');
jest.mock('../../hooks/use_license');
jest.mock('../../hooks/slo/use_capabilities');
jest.mock('../../hooks/slo/use_fetch_active_alerts');
jest.mock('../../hooks/slo/use_fetch_slo_details');
jest.mock('../../hooks/slo/use_fetch_historical_summary');
jest.mock('../../hooks/slo/use_capabilities');
jest.mock('../../hooks/slo/use_clone_slo');
jest.mock('../../hooks/slo/use_delete_slo');
const useKibanaMock = useKibana as jest.Mock;
const useParamsMock = useParams as jest.Mock;
const useLicenseMock = useLicense as jest.Mock;
const useCapabilitiesMock = useCapabilities as jest.Mock;
const useFetchActiveAlertsMock = useFetchActiveAlerts as jest.Mock;
const useFetchSloDetailsMock = useFetchSloDetails as jest.Mock;
const useFetchHistoricalSummaryMock = useFetchHistoricalSummary as jest.Mock;
const useCapabilitiesMock = useCapabilities as jest.Mock;
const useCloneSloMock = useCloneSlo as jest.Mock;
const useDeleteSloMock = useDeleteSlo as jest.Mock;
const mockNavigate = jest.fn();
const mockBasePathPrepend = jest.fn();
const mockClone = jest.fn();
const mockDelete = jest.fn();
const mockKibana = () => {
useKibanaMock.mockReturnValue({
@ -57,12 +64,13 @@ const mockKibana = () => {
charts: chartPluginMock.createStartContract(),
http: {
basePath: {
prepend: mockBasePathPrepend,
prepend: (url: string) => url,
},
},
notifications: {
toasts: {
addSuccess: jest.fn(),
addDanger: jest.fn(),
addError: jest.fn(),
},
},
@ -92,6 +100,8 @@ describe('SLO Details Page', () => {
sloHistoricalSummaryResponse: historicalSummaryData,
});
useFetchActiveAlertsMock.mockReturnValue({ isLoading: false, data: {} });
useCloneSloMock.mockReturnValue({ mutate: mockClone });
useDeleteSloMock.mockReturnValue({ mutate: mockDelete });
});
describe('when the incorrect license is found', () => {
@ -103,7 +113,7 @@ describe('SLO Details Page', () => {
render(<SloDetailsPage />);
expect(mockNavigate).toBeCalledWith(mockBasePathPrepend(paths.observability.slos));
expect(mockNavigate).toBeCalledWith(paths.observability.slos);
});
});
@ -209,7 +219,26 @@ describe('SLO Details Page', () => {
render(<SloDetailsPage />);
fireEvent.click(screen.getByTestId('o11yHeaderControlActionsButton'));
expect(screen.queryByTestId('sloDetailsHeaderControlPopoverClone')).toBeTruthy();
const button = screen.queryByTestId('sloDetailsHeaderControlPopoverClone');
expect(button).toBeTruthy();
fireEvent.click(button!);
const { id, createdAt, enabled, revision, summary, updatedAt, ...newSlo } = slo;
expect(mockClone).toBeCalledWith({
idToCopyFrom: slo.id,
slo: {
...newSlo,
name: `[Copy] ${newSlo.name}`,
},
});
await waitFor(() => {
expect(mockNavigate).toBeCalledWith(paths.observability.slos);
});
});
it("renders a 'Delete' button under actions menu", async () => {
@ -221,10 +250,25 @@ describe('SLO Details Page', () => {
render(<SloDetailsPage />);
fireEvent.click(screen.getByTestId('o11yHeaderControlActionsButton'));
expect(screen.queryByTestId('sloDetailsHeaderControlPopoverDelete')).toBeTruthy();
const manageRulesButton = screen.queryByTestId('sloDetailsHeaderControlPopoverManageRules');
expect(manageRulesButton).toBeTruthy();
const button = screen.queryByTestId('sloDetailsHeaderControlPopoverDelete');
expect(button).toBeTruthy();
fireEvent.click(button!);
const deleteModalConfirmButton = screen.queryByTestId('confirmModalConfirmButton');
fireEvent.click(deleteModalConfirmButton!);
expect(mockDelete).toBeCalledWith({
id: slo.id,
name: slo.name,
});
await waitFor(() => {
expect(mockNavigate).toBeCalledWith(paths.observability.slos);
});
});
it('renders the Overview tab by default', async () => {

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useEffect, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { useLocation, useHistory } from 'react-router-dom';
import {
@ -51,7 +51,6 @@ export function SloEditForm({ slo }: Props) {
const {
application: { navigateToUrl },
http: { basePath },
notifications: { toasts },
triggersActionsUi: { getAddRuleFlyout: AddRuleFlyout },
} = useKibana().services;
@ -121,64 +120,39 @@ export function SloEditForm({ slo }: Props) {
const values = getValues();
if (isEditMode) {
try {
const processedValues = transformValuesToUpdateSLOInput(values);
const processedValues = transformValuesToUpdateSLOInput(values);
if (isCreateRuleCheckboxChecked) {
await updateSlo({ sloId: slo.id, slo: processedValues });
toasts.addSuccess(
i18n.translate('xpack.observability.slo.sloEdit.update.success', {
defaultMessage: 'Successfully updated {name}',
values: { name: getValues().name },
})
navigate(
basePath.prepend(
`${paths.observability.sloEdit(slo.id)}?${CREATE_RULE_SEARCH_PARAM}=true`
)
);
if (isCreateRuleCheckboxChecked) {
navigateToUrl(
basePath.prepend(
`${paths.observability.sloEdit(slo.id)}?${CREATE_RULE_SEARCH_PARAM}=true`
)
);
} else {
navigateToUrl(basePath.prepend(paths.observability.slos));
}
} catch (error) {
toasts.addError(new Error(error), {
title: i18n.translate('xpack.observability.slo.sloEdit.creation.error', {
defaultMessage: 'Something went wrong',
}),
});
} else {
updateSlo({ sloId: slo.id, slo: processedValues });
navigate(basePath.prepend(paths.observability.slos));
}
} else {
try {
const processedValues = transformValuesToCreateSLOInput(values);
const processedValues = transformValuesToCreateSLOInput(values);
if (isCreateRuleCheckboxChecked) {
const { id } = await createSlo({ slo: processedValues });
toasts.addSuccess(
i18n.translate('xpack.observability.slo.sloEdit.creation.success', {
defaultMessage: 'Successfully created {name}',
values: { name: getValues().name },
})
navigate(
basePath.prepend(`${paths.observability.sloEdit(id)}?${CREATE_RULE_SEARCH_PARAM}=true`)
);
if (isCreateRuleCheckboxChecked) {
navigateToUrl(
basePath.prepend(`${paths.observability.sloEdit(id)}?${CREATE_RULE_SEARCH_PARAM}=true`)
);
} else {
navigateToUrl(basePath.prepend(paths.observability.slos));
}
} catch (error) {
toasts.addError(new Error(error), {
title: i18n.translate('xpack.observability.slo.sloEdit.creation.error', {
defaultMessage: 'Something went wrong',
}),
});
} else {
createSlo({ slo: processedValues });
navigate(basePath.prepend(paths.observability.slos));
}
}
};
const navigate = useCallback(
(url: string) => setTimeout(() => navigateToUrl(url)),
[navigateToUrl]
);
const handleChangeCheckbox = () => {
setIsCreateRuleCheckboxChecked(!isCreateRuleCheckboxChecked);
};

View file

@ -539,46 +539,6 @@ describe('SLO Edit Page', () => {
});
describe('when submitting has completed successfully', () => {
it('renders a success toast', async () => {
const slo = buildSlo();
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
jest
.spyOn(Router, 'useLocation')
.mockReturnValue({ pathname: 'foo', search: '', state: '', hash: '' });
useFetchSloMock.mockReturnValue({ isLoading: false, slo });
useFetchIndicesMock.mockReturnValue({
isLoading: false,
indices: [{ name: 'some-index' }],
});
useCreateSloMock.mockReturnValue({
mutateAsync: jest.fn().mockResolvedValue('success'),
isLoading: false,
isSuccess: false,
isError: false,
});
useUpdateSloMock.mockReturnValue({
mutateAsync: jest.fn().mockResolvedValue('success'),
isLoading: false,
isSuccess: false,
isError: false,
});
render(<SloEditPage />);
expect(screen.queryByTestId('sloFormSubmitButton')).toBeEnabled();
await waitFor(() => {
fireEvent.click(screen.getByTestId('sloFormSubmitButton'));
});
expect(mockAddSuccess).toBeCalled();
});
it('navigates to the SLO List page when checkbox to create new rule is not checked', async () => {
const slo = buildSlo();
@ -615,8 +575,9 @@ describe('SLO Edit Page', () => {
await waitFor(() => {
fireEvent.click(screen.getByTestId('sloFormSubmitButton'));
});
expect(mockNavigate).toBeCalledWith(mockBasePathPrepend(paths.observability.slos));
await waitFor(() => {
expect(mockNavigate).toBeCalledWith(mockBasePathPrepend(paths.observability.slos));
});
});
it('navigates to the SLO Edit page when checkbox to create new rule is checked', async () => {
@ -657,9 +618,11 @@ describe('SLO Edit Page', () => {
fireEvent.click(screen.getByTestId('sloFormSubmitButton'));
});
expect(mockNavigate).toBeCalledWith(
mockBasePathPrepend(`${paths.observability.sloEdit(slo.id)}?create-rule=true`)
);
await waitFor(() => {
expect(mockNavigate).toBeCalledWith(
mockBasePathPrepend(`${paths.observability.sloEdit(slo.id)}?create-rule=true`)
);
});
});
it('opens the Add Rule Flyout when visiting an existing SLO with search params set', async () => {
@ -698,47 +661,5 @@ describe('SLO Edit Page', () => {
});
});
});
describe('when submitting has not completed successfully', () => {
it('renders an error toast', async () => {
const slo = buildSlo();
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
jest
.spyOn(Router, 'useLocation')
.mockReturnValue({ pathname: 'foo', search: '', state: '', hash: '' });
useFetchSloMock.mockReturnValue({ isLoading: false, slo });
useFetchIndicesMock.mockReturnValue({
isLoading: false,
indices: [{ name: 'some-index' }],
});
useCreateSloMock.mockReturnValue({
mutateAsync: jest.fn().mockRejectedValue('argh, I died'),
isLoading: false,
isSuccess: false,
isError: false,
});
useUpdateSloMock.mockReturnValue({
mutateAsync: jest.fn().mockRejectedValue('argh, I died'),
isLoading: false,
isSuccess: false,
isError: false,
});
render(<SloEditPage />);
expect(screen.queryByTestId('sloFormSubmitButton')).toBeEnabled();
await waitFor(() => {
fireEvent.click(screen.getByTestId('sloFormSubmitButton'));
});
expect(mockAddError).toBeCalled();
});
});
});
});

View file

@ -6,7 +6,7 @@
*/
import React from 'react';
import { EuiFlexGroup } from '@elastic/eui';
import { EuiFlexGroup, EuiSkeletonRectangle } from '@elastic/eui';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import { Rule } from '@kbn/triggers-actions-ui-plugin/public';
@ -14,25 +14,54 @@ import { SloIndicatorTypeBadge } from './slo_indicator_type_badge';
import { SloStatusBadge } from '../../../../components/slo/slo_status_badge';
import { SloActiveAlertsBadge } from '../../../../components/slo/slo_status_badge/slo_active_alerts_badge';
import { SloTimeWindowBadge } from './slo_time_window_badge';
import { SloRulesBadge } from './slo_rules_badge';
import type { ActiveAlerts } from '../../../../hooks/slo/use_fetch_active_alerts';
import type { SloRule } from '../../../../hooks/slo/use_fetch_rules_for_slo';
import { SloRulesBadge } from './slo_rules_badge';
export interface Props {
activeAlerts?: ActiveAlerts;
isLoading: boolean;
rules: Array<Rule<SloRule>> | undefined;
slo: SLOWithSummaryResponse;
onClickRuleBadge: () => void;
}
export function SloBadges({ activeAlerts, rules, slo, onClickRuleBadge }: Props) {
export function SloBadges({ activeAlerts, isLoading, rules, slo, onClickRuleBadge }: Props) {
return (
<EuiFlexGroup direction="row" responsive={false} gutterSize="s" alignItems="center">
<SloStatusBadge slo={slo} />
<SloIndicatorTypeBadge slo={slo} />
<SloTimeWindowBadge slo={slo} />
<SloActiveAlertsBadge slo={slo} activeAlerts={activeAlerts} />
<SloRulesBadge rules={rules} onClick={onClickRuleBadge} />
{isLoading ? (
<>
<EuiSkeletonRectangle
isLoading
contentAriaLabel="Loading"
width="54.16px"
height="20px"
borderRadius="s"
/>
<EuiSkeletonRectangle
isLoading
contentAriaLabel="Loading"
width="54.16px"
height="20px"
borderRadius="s"
/>
<EuiSkeletonRectangle
isLoading
contentAriaLabel="Loading"
width="54.16px"
height="20px"
borderRadius="s"
/>
</>
) : (
<>
<SloStatusBadge slo={slo} />
<SloIndicatorTypeBadge slo={slo} />
<SloTimeWindowBadge slo={slo} />
<SloActiveAlertsBadge slo={slo} activeAlerts={activeAlerts} />
<SloRulesBadge rules={rules} onClick={onClickRuleBadge} />
</>
)}
</EuiFlexGroup>
);
}

View file

@ -5,94 +5,49 @@
* 2.0.
*/
import React from 'react';
import { EuiConfirmModal } from '@elastic/eui';
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import type { SLOWithSummaryResponse } from '@kbn/slo-schema';
import { useKibana } from '../../../utils/kibana_react';
import { useDeleteSlo } from '../../../hooks/slo/use_delete_slo';
export interface SloDeleteConfirmationModalProps {
slo: SLOWithSummaryResponse;
onCancel: () => void;
onSuccess?: () => void;
onConfirm: () => void;
}
export function SloDeleteConfirmationModal({
slo: { id, name },
slo: { name },
onCancel,
onSuccess,
onConfirm,
}: SloDeleteConfirmationModalProps) {
const {
notifications: { toasts },
} = useKibana().services;
const [isVisible, setIsVisible] = useState(true);
const { mutate: deleteSlo, isSuccess, isError } = useDeleteSlo(id);
if (isSuccess) {
toasts.addSuccess(getDeleteSuccesfulMessage(name));
onSuccess?.();
}
if (isError) {
toasts.addDanger(getDeleteFailMessage(name));
}
const handleConfirm = () => {
setIsVisible(false);
deleteSlo({ id });
};
return isVisible ? (
return (
<EuiConfirmModal
buttonColor="danger"
data-test-subj="sloDeleteConfirmationModal"
title={getTitle()}
cancelButtonText={getCancelButtonText()}
confirmButtonText={getConfirmButtonText(name)}
title={i18n.translate('xpack.observability.slo.slo.deleteConfirmationModal.title', {
defaultMessage: 'Are you sure?',
})}
cancelButtonText={i18n.translate(
'xpack.observability.slo.slo.deleteConfirmationModal.cancelButtonLabel',
{
defaultMessage: 'Cancel',
}
)}
confirmButtonText={i18n.translate(
'xpack.observability.slo.slo.deleteConfirmationModal.deleteButtonLabel',
{
defaultMessage: 'Delete {name}',
values: { name },
}
)}
onCancel={onCancel}
onConfirm={handleConfirm}
onConfirm={onConfirm}
>
{i18n.translate('xpack.observability.slo.slo.deleteConfirmationModal.descriptionText', {
defaultMessage: "You can't recover {name} after deleting.",
values: { name },
})}
</EuiConfirmModal>
) : null;
);
}
const getTitle = () =>
i18n.translate('xpack.observability.slo.slo.deleteConfirmationModal.title', {
defaultMessage: 'Are you sure?',
});
const getCancelButtonText = () =>
i18n.translate('xpack.observability.slo.slo.deleteConfirmationModal.cancelButtonLabel', {
defaultMessage: 'Cancel',
});
const getConfirmButtonText = (name: string) =>
i18n.translate('xpack.observability.slo.slo.deleteConfirmationModal.deleteButtonLabel', {
defaultMessage: 'Delete {name}',
values: { name },
});
const getDeleteSuccesfulMessage = (name: string) =>
i18n.translate(
'xpack.observability.slo.slo.deleteConfirmationModal.successNotification.descriptionText',
{
defaultMessage: 'Deleted {name}',
values: { name },
}
);
const getDeleteFailMessage = (name: string) =>
i18n.translate(
'xpack.observability.slo.slo.deleteConfirmationModal.errorNotification.descriptionText',
{
defaultMessage: 'Failed to delete {name}',
values: { name },
}
);

View file

@ -29,7 +29,7 @@ export function SloList({ autoRefresh }: Props) {
const [sort, setSort] = useState<SortType>('creationTime');
const [indicatorTypeFilter, setIndicatorTypeFilter] = useState<FilterType[]>([]);
const { isLoading, isError, sloList, refetch } = useFetchSloList({
const { isInitialLoading, isLoading, isRefetching, isError, sloList, refetch } = useFetchSloList({
page: activePage + 1,
name: query,
sortBy: sort,
@ -69,7 +69,15 @@ export function SloList({ autoRefresh }: Props) {
<EuiFlexGroup direction="column" gutterSize="m" data-test-subj="sloList">
<EuiFlexItem grow>
<SloListSearchFilterSortBar
loading={isLoading || isCreatingSlo || isCloningSlo || isUpdatingSlo || isDeletingSlo}
loading={
isInitialLoading ||
isLoading ||
isRefetching ||
isCreatingSlo ||
isCloningSlo ||
isUpdatingSlo ||
isDeletingSlo
}
onChangeQuery={handleChangeQuery}
onChangeSort={handleChangeSort}
onChangeIndicatorTypeFilter={handleChangeIndicatorTypeFilter}
@ -77,7 +85,7 @@ export function SloList({ autoRefresh }: Props) {
</EuiFlexItem>
<EuiFlexItem>
<SloListItems sloList={results} loading={isLoading} error={isError} />
<SloListItems sloList={results} loading={isLoading || isRefetching} error={isError} />
</EuiFlexItem>
{results.length ? (

View file

@ -6,7 +6,7 @@
*/
import React, { useState } from 'react';
import { useIsMutating, useQueryClient } from '@tanstack/react-query';
import { useQueryClient } from '@tanstack/react-query';
import {
EuiButtonIcon,
EuiContextMenuItem,
@ -45,6 +45,7 @@ export interface SloListItemProps {
historicalSummary?: HistoricalSummaryResponse[];
historicalSummaryLoading: boolean;
activeAlerts?: ActiveAlerts;
onConfirmDelete: (slo: SLOWithSummaryResponse) => void;
}
export function SloListItem({
@ -53,6 +54,7 @@ export function SloListItem({
historicalSummary = [],
historicalSummaryLoading,
activeAlerts,
onConfirmDelete,
}: SloListItemProps) {
const {
application: { navigateToUrl },
@ -65,7 +67,6 @@ export function SloListItem({
const filteredRuleTypes = useGetFilteredRuleTypes();
const { mutate: cloneSlo } = useCloneSlo();
const isDeletingSlo = Boolean(useIsMutating(['deleteSlo', slo.id]));
const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false);
const [isAddRuleFlyoutOpen, setIsAddRuleFlyoutOpen] = useState(false);
@ -114,21 +115,17 @@ export function SloListItem({
setIsActionsPopoverOpen(false);
};
const handleDeleteConfirm = () => {
setDeleteConfirmationModalOpen(false);
onConfirmDelete(slo);
};
const handleDeleteCancel = () => {
setDeleteConfirmationModalOpen(false);
};
return (
<EuiPanel
data-test-subj="sloItem"
color={isDeletingSlo ? 'subdued' : undefined}
hasBorder
hasShadow={false}
style={{
opacity: isDeletingSlo ? 0.3 : 1,
transition: 'opacity 0.1s ease-in',
}}
>
<EuiPanel data-test-subj="sloItem" hasBorder hasShadow={false}>
<EuiFlexGroup responsive={false} alignItems="center">
{/* CONTENT */}
<EuiFlexItem grow>
@ -137,13 +134,18 @@ export function SloListItem({
<EuiFlexGroup direction="column" gutterSize="m">
<EuiFlexItem>
<EuiText size="s">
<EuiLink data-test-subj="o11ySloListItemLink" onClick={handleViewDetails}>
{slo.name}
</EuiLink>
{slo.summary ? (
<EuiLink data-test-subj="o11ySloListItemLink" onClick={handleViewDetails}>
{slo.name}
</EuiLink>
) : (
<span>{slo.name}</span>
)}
</EuiText>
</EuiFlexItem>
<SloBadges
activeAlerts={activeAlerts}
isLoading={!slo.summary}
rules={rules}
slo={slo}
onClickRuleBadge={handleCreateRule}
@ -152,11 +154,13 @@ export function SloListItem({
</EuiFlexItem>
<EuiFlexItem grow={false}>
<SloSummary
slo={slo}
historicalSummary={historicalSummary}
historicalSummaryLoading={historicalSummaryLoading}
/>
{slo.summary ? (
<SloSummary
slo={slo}
historicalSummary={historicalSummary}
historicalSummaryLoading={historicalSummaryLoading}
/>
) : null}
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
@ -267,7 +271,11 @@ export function SloListItem({
) : null}
{isDeleteConfirmationModalOpen ? (
<SloDeleteConfirmationModal slo={slo} onCancel={handleDeleteCancel} />
<SloDeleteConfirmationModal
slo={slo}
onCancel={handleDeleteCancel}
onConfirm={handleDeleteConfirm}
/>
) : null}
</EuiPanel>
);

View file

@ -11,6 +11,7 @@ import type { SLOWithSummaryResponse } from '@kbn/slo-schema';
import { useFetchActiveAlerts } from '../../../hooks/slo/use_fetch_active_alerts';
import { useFetchRulesForSlo } from '../../../hooks/slo/use_fetch_rules_for_slo';
import { useFetchHistoricalSummary } from '../../../hooks/slo/use_fetch_historical_summary';
import { useDeleteSlo } from '../../../hooks/slo/use_delete_slo';
import { SloListItem } from './slo_list_item';
import { SloListEmpty } from './slo_list_empty';
import { SloListError } from './slo_list_error';
@ -29,6 +30,8 @@ export function SloListItems({ sloList, loading, error }: Props) {
const { isLoading: historicalSummaryLoading, sloHistoricalSummaryResponse } =
useFetchHistoricalSummary({ sloIds });
const { mutate: deleteSlo } = useDeleteSlo();
if (!loading && !error && sloList.length === 0) {
return <SloListEmpty />;
}
@ -36,6 +39,10 @@ export function SloListItems({ sloList, loading, error }: Props) {
return <SloListError />;
}
const handleDelete = (slo: SLOWithSummaryResponse) => {
deleteSlo({ id: slo.id, name: slo.name });
};
return (
<EuiFlexGroup direction="column" gutterSize="s">
{sloList.map((slo) => (
@ -46,6 +53,7 @@ export function SloListItems({ sloList, loading, error }: Props) {
historicalSummary={sloHistoricalSummaryResponse?.[slo.id]}
historicalSummaryLoading={historicalSummaryLoading}
slo={slo}
onConfirmDelete={handleDelete}
/>
</EuiFlexItem>
))}

View file

@ -6,7 +6,7 @@
*/
import React from 'react';
import { screen, act } from '@testing-library/react';
import { screen, act, waitFor } from '@testing-library/react';
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl';
@ -23,6 +23,7 @@ import { SlosPage } from './slos';
import { emptySloList, sloList } from '../../data/slo/slo';
import { historicalSummaryData } from '../../data/slo/historical_summary_data';
import { useCapabilities } from '../../hooks/slo/use_capabilities';
import { paths } from '../../config/paths';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
@ -69,7 +70,7 @@ const mockKibana = () => {
charts: chartPluginMock.createSetupContract(),
http: {
basePath: {
prepend: jest.fn(),
prepend: (url: string) => url,
},
},
notifications: {
@ -98,17 +99,22 @@ describe('SLOs Page', () => {
});
describe('when the incorrect license is found', () => {
it('renders the welcome prompt with subscription buttons', async () => {
beforeEach(() => {
useFetchSloListMock.mockReturnValue({ isLoading: false, sloList: emptySloList });
useLicenseMock.mockReturnValue({ hasAtLeast: () => false });
useFetchHistoricalSummaryMock.mockReturnValue({
isLoading: false,
sloHistoricalSummaryResponse: {},
});
});
it('navigates to the SLOs Welcome Page', async () => {
await act(async () => {
render(<SlosPage />);
});
expect(screen.queryByTestId('slosPageWelcomePrompt')).toBeTruthy();
expect(screen.queryByTestId('slosPageWelcomePromptSignupForCloudButton')).toBeTruthy();
expect(screen.queryByTestId('slosPageWelcomePromptSignupForLicenseButton')).toBeTruthy();
await waitFor(() => {
expect(mockNavigate).toBeCalledWith(paths.observability.slosWelcome);
});
});
});
@ -117,14 +123,20 @@ describe('SLOs Page', () => {
useLicenseMock.mockReturnValue({ hasAtLeast: () => true });
});
it('renders the SLOs Welcome Prompt when the API has finished loading and there are no results', async () => {
it('navigates to the SLOs Welcome Page when the API has finished loading and there are no results', async () => {
useFetchSloListMock.mockReturnValue({ isLoading: false, sloList: emptySloList });
useFetchHistoricalSummaryMock.mockReturnValue({
isLoading: false,
sloHistoricalSummaryResponse: {},
});
await act(async () => {
render(<SlosPage />);
});
expect(screen.queryByTestId('slosPageWelcomePrompt')).toBeTruthy();
await waitFor(() => {
expect(mockNavigate).toBeCalledWith(paths.observability.slosWelcome);
});
});
it('should have a create new SLO button', async () => {
@ -198,7 +210,9 @@ describe('SLOs Page', () => {
button.click();
expect(mockNavigate).toBeCalled();
expect(mockNavigate).toBeCalledWith(
`${paths.observability.sloEdit(sloList.results.at(0)?.id || '')}`
);
});
it('allows creating a new rule for an SLO', async () => {
@ -250,7 +264,10 @@ describe('SLOs Page', () => {
screen.getByTestId('confirmModalConfirmButton').click();
expect(mockDeleteSlo).toBeCalledWith({ id: sloList.results.at(0)?.id });
expect(mockDeleteSlo).toBeCalledWith({
id: sloList.results.at(0)?.id,
name: sloList.results.at(0)?.name,
});
});
it('allows cloning an SLO', async () => {

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { EuiButton } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
@ -16,7 +16,6 @@ import { useBreadcrumbs } from '../../hooks/use_breadcrumbs';
import { useCapabilities } from '../../hooks/slo/use_capabilities';
import { useFetchSloList } from '../../hooks/slo/use_fetch_slo_list';
import { SloList } from './components/slo_list';
import { SloListWelcomePrompt } from './components/slo_list_welcome_prompt';
import { AutoRefreshButton } from './components/auto_refresh_button';
import { HeaderTitle } from './components/header_title';
import { FeedbackButton } from '../../components/slo/feedback_button/feedback_button';
@ -55,14 +54,16 @@ export function SlosPage() {
setIsAutoRefreshing(!isAutoRefreshing);
};
useEffect(() => {
if ((!isLoading && total === 0) || hasAtLeast('platinum') === false) {
navigateToUrl(basePath.prepend(paths.observability.slosWelcome));
}
}, [basePath, hasAtLeast, isLoading, navigateToUrl, total]);
if (isInitialLoading) {
return null;
}
if ((!isLoading && total === 0) || !hasAtLeast('platinum')) {
return <SloListWelcomePrompt />;
}
return (
<ObservabilityPageTemplate
pageHeader={{

View file

@ -8,12 +8,12 @@
import React from 'react';
import { ComponentStory } from '@storybook/react';
import { KibanaReactStorybookDecorator } from '../../../utils/kibana_react.storybook_decorator';
import { SloListWelcomePrompt as Component } from './slo_list_welcome_prompt';
import { KibanaReactStorybookDecorator } from '../../utils/kibana_react.storybook_decorator';
import { SlosWelcomePage as Component } from './slos_welcome';
export default {
component: Component,
title: 'app/SLO/ListPage/SloListWelcomePrompt',
title: 'app/SLO/SlosWelcomePage',
decorators: [KibanaReactStorybookDecorator],
};

View file

@ -0,0 +1,103 @@
/*
* 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 { screen, waitFor } from '@testing-library/react';
import { render } from '../../utils/test_helper';
import { useKibana } from '../../utils/kibana_react';
import { useFetchSloList } from '../../hooks/slo/use_fetch_slo_list';
import { useLicense } from '../../hooks/use_license';
import { SlosWelcomePage } from './slos_welcome';
import { emptySloList, sloList } from '../../data/slo/slo';
import { useCapabilities } from '../../hooks/slo/use_capabilities';
import { paths } from '../../config/paths';
jest.mock('../../utils/kibana_react');
jest.mock('../../hooks/use_breadcrumbs');
jest.mock('../../hooks/use_license');
jest.mock('../../hooks/slo/use_fetch_slo_list');
jest.mock('../../hooks/slo/use_capabilities');
const useKibanaMock = useKibana as jest.Mock;
const useLicenseMock = useLicense as jest.Mock;
const useFetchSloListMock = useFetchSloList as jest.Mock;
const useCapabilitiesMock = useCapabilities as jest.Mock;
const mockNavigate = jest.fn();
const mockKibana = () => {
useKibanaMock.mockReturnValue({
services: {
application: { navigateToUrl: mockNavigate },
http: {
basePath: {
prepend: (url: string) => url,
},
},
},
});
};
describe('SLOs Welcome Page', () => {
beforeEach(() => {
jest.clearAllMocks();
mockKibana();
useCapabilitiesMock.mockReturnValue({ hasWriteCapabilities: true, hasReadCapabilities: true });
});
describe('when the incorrect license is found', () => {
it('renders the welcome message with subscription buttons', async () => {
useFetchSloListMock.mockReturnValue({ isLoading: false, sloList: emptySloList });
useLicenseMock.mockReturnValue({ hasAtLeast: () => false });
render(<SlosWelcomePage />);
expect(screen.queryByTestId('slosPageWelcomePrompt')).toBeTruthy();
expect(screen.queryByTestId('slosPageWelcomePromptSignupForCloudButton')).toBeTruthy();
expect(screen.queryByTestId('slosPageWelcomePromptSignupForLicenseButton')).toBeTruthy();
});
});
describe('when the correct license is found', () => {
beforeEach(() => {
useLicenseMock.mockReturnValue({ hasAtLeast: () => true });
});
describe('when loading is done and no results are found', () => {
beforeEach(() => {
useFetchSloListMock.mockReturnValue({ isLoading: false, emptySloList });
});
it('should display the welcome message with a Create new SLO button which should navigate to the SLO Creation page', async () => {
render(<SlosWelcomePage />);
expect(screen.queryByTestId('slosPageWelcomePrompt')).toBeTruthy();
const createNewSloButton = screen.queryByTestId('o11ySloListWelcomePromptCreateSloButton');
expect(createNewSloButton).toBeTruthy();
createNewSloButton?.click();
await waitFor(() => {
expect(mockNavigate).toBeCalledWith(paths.observability.sloCreate);
});
});
});
describe('when loading is done and results are found', () => {
beforeEach(() => {
useFetchSloListMock.mockReturnValue({ isLoading: false, sloList });
});
it('should navigate to the SLO List page', async () => {
render(<SlosWelcomePage />);
await waitFor(() => {
expect(mockNavigate).toBeCalledWith(paths.observability.slos);
});
});
});
});
});

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React from 'react';
import React, { useEffect } from 'react';
import {
EuiPageTemplate,
EuiButton,
@ -18,13 +18,14 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useKibana } from '../../../utils/kibana_react';
import { useLicense } from '../../../hooks/use_license';
import { usePluginContext } from '../../../hooks/use_plugin_context';
import { paths } from '../../../config/paths';
import { useKibana } from '../../utils/kibana_react';
import { useLicense } from '../../hooks/use_license';
import { usePluginContext } from '../../hooks/use_plugin_context';
import { useFetchSloList } from '../../hooks/slo/use_fetch_slo_list';
import { paths } from '../../config/paths';
import illustration from './assets/illustration.svg';
export function SloListWelcomePrompt() {
export function SlosWelcomePage() {
const {
application: { navigateToUrl },
http: { basePath },
@ -32,14 +33,24 @@ export function SloListWelcomePrompt() {
const { ObservabilityPageTemplate } = usePluginContext();
const { hasAtLeast } = useLicense();
const hasRightLicense = hasAtLeast('platinum');
const { isLoading, sloList } = useFetchSloList();
const { total } = sloList || { total: 0 };
const handleClickCreateSlo = () => {
navigateToUrl(basePath.prepend(paths.observability.sloCreate));
};
return (
const hasSlosAndHasPermissions = total > 0 && hasAtLeast('platinum') === true;
useEffect(() => {
if (hasSlosAndHasPermissions) {
navigateToUrl(basePath.prepend(paths.observability.slos));
}
}, [basePath, hasSlosAndHasPermissions, navigateToUrl]);
return hasSlosAndHasPermissions || isLoading ? null : (
<ObservabilityPageTemplate data-test-subj="slosPageWelcomePrompt">
<EuiPageTemplate.EmptyPrompt
title={

View file

@ -17,6 +17,7 @@ import { OverviewPage } from '../pages/overview/overview';
import { RulesPage } from '../pages/rules/rules';
import { RuleDetailsPage } from '../pages/rule_details';
import { SlosPage } from '../pages/slos/slos';
import { SlosWelcomePage } from '../pages/slos_welcome/slos_welcome';
import { SloDetailsPage } from '../pages/slo_details/slo_details';
import { SloEditPage } from '../pages/slo_edit/slo_edit';
import { casesPath } from '../../common';
@ -135,6 +136,13 @@ export const routes = {
params: {},
exact: true,
},
'/slos/welcome': {
handler: () => {
return <SlosWelcomePage />;
},
params: {},
exact: true,
},
'/slos/edit/:sloId': {
handler: () => {
return <SloEditPage />;