diff --git a/packages/api-v4/.changeset/pr-11286-changed-1732032917339.md b/packages/api-v4/.changeset/pr-11286-changed-1732032917339.md new file mode 100644 index 00000000000..46648acec35 --- /dev/null +++ b/packages/api-v4/.changeset/pr-11286-changed-1732032917339.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Added +--- + +service_type as parameter for the Create Alert POST request ([#11286](https://github.com/linode/manager/pull/11286)) diff --git a/packages/api-v4/src/cloudpulse/alerts.ts b/packages/api-v4/src/cloudpulse/alerts.ts index 84847f4a027..f7c4b732043 100644 --- a/packages/api-v4/src/cloudpulse/alerts.ts +++ b/packages/api-v4/src/cloudpulse/alerts.ts @@ -1,11 +1,18 @@ import { createAlertDefinitionSchema } from '@linode/validation'; import Request, { setURL, setMethod, setData } from '../request'; -import { Alert, CreateAlertDefinitionPayload } from './types'; +import { Alert, AlertServiceType, CreateAlertDefinitionPayload } from './types'; import { BETA_API_ROOT as API_ROOT } from 'src/constants'; -export const createAlertDefinition = (data: CreateAlertDefinitionPayload) => +export const createAlertDefinition = ( + data: CreateAlertDefinitionPayload, + service_type: AlertServiceType +) => Request( - setURL(`${API_ROOT}/monitor/alert-definitions`), + setURL( + `${API_ROOT}/monitor/services/${encodeURIComponent( + service_type! + )}/alert-definitions` + ), setMethod('POST'), setData(data, createAlertDefinitionSchema) ); diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index b763ff44721..6a863076a73 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -1,14 +1,10 @@ -export type AlertSeverityType = 0 | 1 | 2 | 3 | null; -type MetricAggregationType = 'avg' | 'sum' | 'min' | 'max' | 'count' | null; -type MetricOperatorType = 'eq' | 'gt' | 'lt' | 'gte' | 'lte' | null; -type DimensionFilterOperatorType = - | 'eq' - | 'neq' - | 'startswith' - | 'endswith' - | null; -type AlertDefinitionType = 'default' | 'custom'; -type AlertStatusType = 'enabled' | 'disabled'; +export type AlertSeverityType = 0 | 1 | 2 | 3; +export type MetricAggregationType = 'avg' | 'sum' | 'min' | 'max' | 'count'; +export type MetricOperatorType = 'eq' | 'gt' | 'lt' | 'gte' | 'lte'; +export type AlertServiceType = 'linode' | 'dbaas'; +type DimensionFilterOperatorType = 'eq' | 'neq' | 'startswith' | 'endswith'; +export type AlertDefinitionType = 'default' | 'custom'; +export type AlertStatusType = 'enabled' | 'disabled'; export interface Dashboard { id: number; label: string; @@ -155,12 +151,6 @@ export interface CreateAlertDefinitionPayload { triggerCondition: TriggerCondition; channel_ids: number[]; } -export interface CreateAlertDefinitionForm - extends CreateAlertDefinitionPayload { - region: string; - service_type: string; - engine_type: string; -} export interface MetricCriteria { metric: string; aggregation_type: MetricAggregationType; @@ -187,7 +177,7 @@ export interface Alert { status: AlertStatusType; type: AlertDefinitionType; severity: AlertSeverityType; - service_type: string; + service_type: AlertServiceType; resource_ids: string[]; rule_criteria: { rules: MetricCriteria[]; diff --git a/packages/manager/.changeset/pr-11286-added-1732032870588.md b/packages/manager/.changeset/pr-11286-added-1732032870588.md new file mode 100644 index 00000000000..d8a2d1f91b7 --- /dev/null +++ b/packages/manager/.changeset/pr-11286-added-1732032870588.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Service, Engine Option, Region components to the Create Alert form ([#11286](https://github.com/linode/manager/pull/11286)) diff --git a/packages/manager/src/factories/cloudpulse/alerts.ts b/packages/manager/src/factories/cloudpulse/alerts.ts new file mode 100644 index 00000000000..4fbf4e0e222 --- /dev/null +++ b/packages/manager/src/factories/cloudpulse/alerts.ts @@ -0,0 +1,27 @@ +import Factory from 'src/factories/factoryProxy'; + +import type { Alert } from '@linode/api-v4'; + +export const alertFactory = Factory.Sync.makeFactory({ + channels: [], + created: new Date().toISOString(), + created_by: 'user1', + description: '', + id: Factory.each((i) => i), + label: Factory.each((id) => `Alert-${id}`), + resource_ids: ['0', '1', '2', '3'], + rule_criteria: { + rules: [], + }, + service_type: 'linode', + severity: 0, + status: 'enabled', + triggerCondition: { + evaluation_period_seconds: 0, + polling_interval_seconds: 0, + trigger_occurrences: 0, + }, + type: 'default', + updated: new Date().toISOString(), + updated_by: 'user1', +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx index 4a3bb4a0098..35d4e4ad328 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen, within } from '@testing-library/react'; +import { screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; @@ -6,18 +6,18 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { CreateAlertDefinition } from './CreateAlertDefinition'; describe('AlertDefinition Create', () => { - it('should render input components', () => { + it('should render input components', async () => { const { getByLabelText } = renderWithTheme(); expect(getByLabelText('Name')).toBeVisible(); expect(getByLabelText('Description (optional)')).toBeVisible(); expect(getByLabelText('Severity')).toBeVisible(); }); - it('should be able to enter a value in the textbox', () => { + it('should be able to enter a value in the textbox', async () => { const { getByLabelText } = renderWithTheme(); const input = getByLabelText('Name'); - fireEvent.change(input, { target: { value: 'text' } }); + await userEvent.type(input, 'text'); const specificInput = within(screen.getByTestId('alert-name')).getByTestId( 'textfield-input' ); @@ -30,7 +30,9 @@ describe('AlertDefinition Create', () => { await userEvent.click(submitButton!); - expect(getByText('Name is required')).toBeVisible(); - expect(getByText('Severity is required')).toBeVisible(); + expect(getByText('Name is required.')).toBeVisible(); + expect(getByText('Severity is required.')).toBeVisible(); + expect(getByText('Service is required.')).toBeVisible(); + expect(getByText('Region is required.')).toBeVisible(); }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx index 3dde1a7d2c9..4b7cb07bd0f 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx @@ -1,6 +1,5 @@ import { yupResolver } from '@hookform/resolvers/yup'; import { Paper, TextField, Typography } from '@linode/ui'; -import { createAlertDefinitionSchema } from '@linode/validation'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import { Controller, FormProvider, useForm } from 'react-hook-form'; @@ -11,36 +10,37 @@ import { Breadcrumb } from 'src/components/Breadcrumb/Breadcrumb'; import { useCreateAlertDefinition } from 'src/queries/cloudpulse/alerts'; import { CloudPulseAlertSeveritySelect } from './GeneralInformation/AlertSeveritySelect'; +import { EngineOption } from './GeneralInformation/EngineOption'; +import { CloudPulseRegionSelect } from './GeneralInformation/RegionSelect'; +import { CloudPulseServiceSelect } from './GeneralInformation/ServiceTypeSelect'; +import { CreateAlertDefinitionFormSchema } from './schemas'; +import { filterFormValues, filterMetricCriteriaFormValues } from './utilities'; -import type { - CreateAlertDefinitionForm, - CreateAlertDefinitionPayload, - MetricCriteria, - TriggerCondition, -} from '@linode/api-v4/lib/cloudpulse/types'; +import type { CreateAlertDefinitionForm, MetricCriteriaForm } from './types'; +import type { TriggerCondition } from '@linode/api-v4/lib/cloudpulse/types'; const triggerConditionInitialValues: TriggerCondition = { evaluation_period_seconds: 0, polling_interval_seconds: 0, trigger_occurrences: 0, }; -const criteriaInitialValues: MetricCriteria[] = [ - { - aggregation_type: null, - dimension_filters: [], - metric: '', - operator: null, - value: 0, - }, -]; +const criteriaInitialValues: MetricCriteriaForm = { + aggregation_type: null, + dimension_filters: [], + metric: '', + operator: null, + value: 0, +}; const initialValues: CreateAlertDefinitionForm = { channel_ids: [], - engine_type: '', + engine_type: null, label: '', region: '', resource_ids: [], - rule_criteria: { rules: criteriaInitialValues }, - service_type: '', + rule_criteria: { + rules: filterMetricCriteriaFormValues(criteriaInitialValues), + }, + service_type: null, severity: null, triggerCondition: triggerConditionInitialValues, }; @@ -62,19 +62,29 @@ export const CreateAlertDefinition = () => { const alertCreateExit = () => history.push('/monitor/cloudpulse/alerts/definitions'); - const formMethods = useForm({ + const formMethods = useForm({ defaultValues: initialValues, mode: 'onBlur', - resolver: yupResolver(createAlertDefinitionSchema), + resolver: yupResolver(CreateAlertDefinitionFormSchema), }); - const { control, formState, handleSubmit, setError } = formMethods; + const { + control, + formState, + getValues, + handleSubmit, + setError, + watch, + } = formMethods; const { enqueueSnackbar } = useSnackbar(); - const { mutateAsync: createAlert } = useCreateAlertDefinition(); + const { mutateAsync: createAlert } = useCreateAlertDefinition( + getValues('service_type')! + ); + const serviceWatcher = watch('service_type'); const onSubmit = handleSubmit(async (values) => { try { - await createAlert(values); + await createAlert(filterFormValues(values)); enqueueSnackbar('Alert successfully created', { variant: 'success', }); @@ -130,6 +140,9 @@ export const CreateAlertDefinition = () => { control={control} name="description" /> + + {serviceWatcher === 'dbaas' && } + { expect(getByLabelText('Severity')).toBeInTheDocument(); expect(getByTestId('severity')).toBeInTheDocument(); }); - it('should render the options happy path', () => { + it('should render the options happy path', async () => { renderWithThemeAndHookFormContext({ component: , }); - fireEvent.click(screen.getByRole('button', { name: 'Open' })); - expect(screen.getByRole('option', { name: 'Info' })); + userEvent.click(screen.getByRole('button', { name: 'Open' })); + expect(await screen.findByRole('option', { name: 'Info' })); expect(screen.getByRole('option', { name: 'Low' })); }); - it('should be able to select an option', () => { + it('should be able to select an option', async () => { renderWithThemeAndHookFormContext({ component: , }); - fireEvent.click(screen.getByRole('button', { name: 'Open' })); - fireEvent.click(screen.getByRole('option', { name: 'Medium' })); + userEvent.click(screen.getByRole('button', { name: 'Open' })); + await userEvent.click( + await screen.findByRole('option', { name: 'Medium' }) + ); expect(screen.getByRole('combobox')).toHaveAttribute('value', 'Medium'); }); it('should render the tooltip text', () => { @@ -35,12 +38,12 @@ describe('EngineOption component tests', () => { }); const severityContainer = container.getByTestId('severity'); - fireEvent.click(severityContainer); + userEvent.click(severityContainer); expect( screen.getByRole('button', { name: - 'Define a severity level associated with the alert to help you prioritize and manage alerts in the Recent activity tab', + 'Define a severity level associated with the alert to help you prioritize and manage alerts in the Recent activity tab.', }) ).toBeVisible(); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertSeveritySelect.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertSeveritySelect.tsx index 76f1afca123..5bb3ecbfac3 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertSeveritySelect.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertSeveritySelect.tsx @@ -5,16 +5,14 @@ import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { alertSeverityOptions } from '../../constants'; -import type { - AlertSeverityType, - CreateAlertDefinitionForm, -} from '@linode/api-v4'; +import type { CreateAlertDefinitionForm } from '../types'; +import type { AlertSeverityType } from '@linode/api-v4'; import type { FieldPathByValue } from 'react-hook-form'; export interface CloudPulseAlertSeveritySelectProps { /** * name used for the component in the form */ - name: FieldPathByValue; + name: FieldPathByValue; } export const CloudPulseAlertSeveritySelect = ( @@ -41,7 +39,7 @@ export const CloudPulseAlertSeveritySelect = ( }} textFieldProps={{ labelTooltipText: - 'Define a severity level associated with the alert to help you prioritize and manage alerts in the Recent activity tab', + 'Define a severity level associated with the alert to help you prioritize and manage alerts in the Recent activity tab.', }} value={ field.value !== null diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/EngineOption.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/EngineOption.test.tsx new file mode 100644 index 00000000000..d13978262fb --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/EngineOption.test.tsx @@ -0,0 +1,35 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { EngineOption } from './EngineOption'; + +describe('EngineOption component tests', () => { + it('should render the component when resource type is dbaas', () => { + const { getByLabelText, getByTestId } = renderWithThemeAndHookFormContext({ + component: , + }); + expect(getByLabelText('Engine Option')).toBeInTheDocument(); + expect(getByTestId('engine-option')).toBeInTheDocument(); + }); + it('should render the options happy path', async () => { + const user = userEvent.setup(); + renderWithThemeAndHookFormContext({ + component: , + }); + user.click(screen.getByRole('button', { name: 'Open' })); + expect(await screen.findByRole('option', { name: 'MySQL' })); + expect(screen.getByRole('option', { name: 'PostgreSQL' })); + }); + it('should be able to select an option', async () => { + const user = userEvent.setup(); + renderWithThemeAndHookFormContext({ + component: , + }); + user.click(screen.getByRole('button', { name: 'Open' })); + await user.click(await screen.findByRole('option', { name: 'MySQL' })); + expect(screen.getByRole('combobox')).toHaveAttribute('value', 'MySQL'); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/EngineOption.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/EngineOption.tsx new file mode 100644 index 00000000000..2c6b0406739 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/EngineOption.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; + +import { engineTypeOptions } from '../../constants'; + +import type { CreateAlertDefinitionForm } from '../types'; +import type { FieldPathByValue } from 'react-hook-form'; + +interface EngineOptionProps { + /** + * name used for the component to set in the form + */ + name: FieldPathByValue; +} +export const EngineOption = (props: EngineOptionProps) => { + const { name } = props; + const { control } = useFormContext(); + return ( + ( + { + if (reason === 'selectOption') { + field.onChange(selected.value); + } + if (reason === 'clear') { + field.onChange(null); + } + }} + value={ + field.value !== null + ? engineTypeOptions.find((option) => option.value === field.value) + : null + } + data-testid="engine-option" + errorText={fieldState.error?.message} + label="Engine Option" + onBlur={field.onBlur} + options={engineTypeOptions} + placeholder="Select an Engine" + /> + )} + control={control} + name={name} + /> + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/RegionSelect.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/RegionSelect.test.tsx new file mode 100644 index 00000000000..805b134507b --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/RegionSelect.test.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; + +import * as regions from 'src/queries/regions/regions'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { CloudPulseRegionSelect } from './RegionSelect'; + +import type { Region } from '@linode/api-v4'; + +describe('RegionSelect', () => { + vi.spyOn(regions, 'useRegionsQuery').mockReturnValue({ + data: Array(), + } as ReturnType); + + it('should render a RegionSelect component', () => { + const { getByTestId } = renderWithThemeAndHookFormContext({ + component: , + }); + expect(getByTestId('region-select')).toBeInTheDocument(); + }); + it('should render a Region Select component with proper error message on api call failure', () => { + vi.spyOn(regions, 'useRegionsQuery').mockReturnValue({ + data: undefined, + isError: true, + isLoading: false, + } as ReturnType); + const { getByText } = renderWithThemeAndHookFormContext({ + component: , + }); + + expect(getByText('Failed to fetch Region.')); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/RegionSelect.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/RegionSelect.tsx new file mode 100644 index 00000000000..fba152700a6 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/RegionSelect.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; +import { useRegionsQuery } from 'src/queries/regions/regions'; + +import type { CreateAlertDefinitionForm } from '../types'; +import type { FieldPathByValue } from 'react-hook-form'; + +export interface CloudViewRegionSelectProps { + /** + * name used for the component to set in the form + */ + name: FieldPathByValue; +} + +export const CloudPulseRegionSelect = (props: CloudViewRegionSelectProps) => { + const { name } = props; + const { data: regions, isError, isLoading } = useRegionsQuery(); + const { control } = useFormContext(); + return ( + ( + { + field.onChange(value?.id); + }} + currentCapability={undefined} + fullWidth + label="Region" + loading={isLoading} + placeholder="Select a Region" + regions={regions ?? []} + textFieldProps={{ onBlur: field.onBlur }} + value={field.value} + /> + )} + control={control} + name={name} + /> + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ServiceTypeSelect.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ServiceTypeSelect.test.tsx new file mode 100644 index 00000000000..5e14b886375 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ServiceTypeSelect.test.tsx @@ -0,0 +1,90 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { serviceTypesFactory } from 'src/factories'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { CloudPulseServiceSelect } from './ServiceTypeSelect'; + +const queryMocks = vi.hoisted(() => ({ + useCloudPulseServiceTypes: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/cloudpulse/services', async () => { + const actual = await vi.importActual('src/queries/cloudpulse/services'); + return { + ...actual, + useCloudPulseServiceTypes: queryMocks.useCloudPulseServiceTypes, + }; +}); + +const mockResponse = { + data: [ + serviceTypesFactory.build({ + label: 'Linode', + service_type: 'linode', + }), + serviceTypesFactory.build({ + label: 'Databases', + service_type: 'dbaas', + }), + ], +}; + +queryMocks.useCloudPulseServiceTypes.mockReturnValue({ + data: mockResponse, + isError: true, + isLoading: false, + status: 'success', +}); +describe('ServiceTypeSelect component tests', () => { + it('should render the Autocomplete component', () => { + const { getAllByText, getByTestId } = renderWithThemeAndHookFormContext({ + component: , + }); + expect(getByTestId('servicetype-select')).toBeInTheDocument(); + getAllByText('Service'); + }); + + it('should render service types happy path', async () => { + renderWithThemeAndHookFormContext({ + component: , + }); + userEvent.click(screen.getByRole('button', { name: 'Open' })); + expect( + await screen.findByRole('option', { + name: 'Linode', + }) + ).toBeInTheDocument(); + expect( + screen.getByRole('option', { + name: 'Databases', + }) + ).toBeInTheDocument(); + }); + + it('should be able to select a service type', async () => { + renderWithThemeAndHookFormContext({ + component: , + }); + userEvent.click(screen.getByRole('button', { name: 'Open' })); + await userEvent.click( + await screen.findByRole('option', { name: 'Linode' }) + ); + expect(screen.getByRole('combobox')).toHaveAttribute('value', 'Linode'); + }); + it('should render error messages when there is an API call failure', () => { + queryMocks.useCloudPulseServiceTypes.mockReturnValue({ + data: undefined, + error: 'an error happened', + isLoading: false, + }); + renderWithThemeAndHookFormContext({ + component: , + }); + expect( + screen.getByText('Failed to fetch the service types.') + ).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ServiceTypeSelect.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ServiceTypeSelect.tsx new file mode 100644 index 00000000000..8ea0d4e1218 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ServiceTypeSelect.tsx @@ -0,0 +1,81 @@ +import * as React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { useCloudPulseServiceTypes } from 'src/queries/cloudpulse/services'; + +import type { Item } from '../../constants'; +import type { CreateAlertDefinitionForm } from '../types'; +import type { AlertServiceType } from '@linode/api-v4'; +import type { FieldPathByValue } from 'react-hook-form'; + +interface CloudPulseServiceSelectProps { + /** + * name used for the component in the form + */ + name: FieldPathByValue; +} + +export const CloudPulseServiceSelect = ( + props: CloudPulseServiceSelectProps +) => { + const { name } = props; + const { + data: serviceOptions, + error: serviceTypesError, + isLoading: serviceTypesLoading, + } = useCloudPulseServiceTypes(true); + const { control } = useFormContext(); + + const getServicesList = React.useMemo((): Item< + string, + AlertServiceType + >[] => { + return serviceOptions && serviceOptions.data.length > 0 + ? serviceOptions.data.map((service) => ({ + label: service.label, + value: service.service_type as AlertServiceType, + })) + : []; + }, [serviceOptions]); + + return ( + ( + { + if (selected) { + field.onChange(selected.value); + } + if (reason === 'clear') { + field.onChange(null); + } + }} + value={ + field.value !== null + ? getServicesList.find((option) => option.value === field.value) + : null + } + data-testid="servicetype-select" + fullWidth + label="Service" + loading={serviceTypesLoading && !serviceTypesError} + onBlur={field.onBlur} + options={getServicesList} + placeholder="Select a Service" + sx={{ marginTop: '5px' }} + /> + )} + control={control} + name={name} + /> + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/schemas.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/schemas.ts new file mode 100644 index 00000000000..2e8fee3e200 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/schemas.ts @@ -0,0 +1,15 @@ +import { createAlertDefinitionSchema } from '@linode/validation'; +import { object, string } from 'yup'; + +const engineOptionValidation = string().when('service_type', { + is: 'dbaas', + otherwise: string().notRequired().nullable(), + then: string().required('Engine type is required.').nullable(), +}); +export const CreateAlertDefinitionFormSchema = createAlertDefinitionSchema.concat( + object({ + engine_type: engineOptionValidation, + region: string().required('Region is required.'), + service_type: string().required('Service is required.').nullable(), + }) +); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts new file mode 100644 index 00000000000..c7c58fc1dcb --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts @@ -0,0 +1,22 @@ +import type { + AlertServiceType, + AlertSeverityType, + CreateAlertDefinitionPayload, + MetricAggregationType, + MetricCriteria, + MetricOperatorType, +} from '@linode/api-v4'; + +export interface CreateAlertDefinitionForm + extends Omit { + engine_type: null | string; + region: string; + service_type: AlertServiceType | null; + severity: AlertSeverityType | null; +} + +export interface MetricCriteriaForm + extends Omit { + aggregation_type: MetricAggregationType | null; + operator: MetricOperatorType | null; +} diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts new file mode 100644 index 00000000000..3db313f7b1f --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts @@ -0,0 +1,31 @@ +import { omitProps } from '@linode/ui'; + +import type { CreateAlertDefinitionForm, MetricCriteriaForm } from './types'; +import type { + CreateAlertDefinitionPayload, + MetricCriteria, +} from '@linode/api-v4'; + +// filtering out the form properties which are not part of the payload +export const filterFormValues = ( + formValues: CreateAlertDefinitionForm +): CreateAlertDefinitionPayload => { + const values = omitProps(formValues, [ + 'service_type', + 'region', + 'engine_type', + 'severity', + ]); + // severity has a need for null in the form for edge-cases, so null-checking and returning it as an appropriate type + const severity = formValues.severity!; + return { ...values, severity }; +}; + +export const filterMetricCriteriaFormValues = ( + formValues: MetricCriteriaForm +): MetricCriteria[] => { + const aggregation_type = formValues.aggregation_type!; + const operator = formValues.operator!; + const values = omitProps(formValues, ['aggregation_type', 'operator']); + return [{ ...values, aggregation_type, operator }]; +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/constants.ts index 070f84dfbe5..08f6223e263 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/constants.ts @@ -10,3 +10,14 @@ export const alertSeverityOptions: Item[] = [ { label: 'Medium', value: 1 }, { label: 'Severe', value: 0 }, ]; + +export const engineTypeOptions: Item[] = [ + { + label: 'MySQL', + value: 'mysql', + }, + { + label: 'PostgreSQL', + value: 'postgresql', + }, +]; diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 85950a1b9e4..fecb595aa70 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -106,10 +106,16 @@ import { getStorage } from 'src/utilities/storage'; const getRandomWholeNumber = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1) + min); +import { alertFactory } from 'src/factories/cloudpulse/alerts'; import { pickRandom } from 'src/utilities/random'; import type { AccountMaintenance, + AlertDefinitionType, + AlertServiceType, + AlertSeverityType, + AlertStatusType, + CreateAlertDefinitionPayload, CreateObjectStorageKeyPayload, Dashboard, FirewallStatus, @@ -2333,28 +2339,28 @@ export const handlers = [ return HttpResponse.json(response); }), - http.post('*/monitor/alert-definitions', async ({ request }) => { - const reqBody = await request.json(); - const response = { - data: [ - { - created: '2021-10-16T04:00:00', - created_by: 'user1', - id: '35892357', - notifications: [ - { - notification_id: '42804', - template_name: 'notification', - }, - ], - reqBody, - updated: '2021-10-16T04:00:00', - updated_by: 'user2', - }, - ], - }; - return HttpResponse.json(response); - }), + http.post( + '*/monitor/services/:service_type/alert-definitions', + async ({ request }) => { + const types: AlertDefinitionType[] = ['custom', 'default']; + const status: AlertStatusType[] = ['enabled', 'disabled']; + const severity: AlertSeverityType[] = [0, 1, 2, 3]; + const users = ['user1', 'user2', 'user3']; + const serviceTypes: AlertServiceType[] = ['linode', 'dbaas']; + + const reqBody = await request.json(); + const response = alertFactory.build({ + ...(reqBody as CreateAlertDefinitionPayload), + created_by: pickRandom(users), + service_type: pickRandom(serviceTypes), + severity: pickRandom(severity), + status: pickRandom(status), + type: pickRandom(types), + updated_by: pickRandom(users), + }); + return HttpResponse.json(response); + } + ), http.get('*/monitor/services', () => { const response: ServiceTypesList = { data: [ diff --git a/packages/manager/src/queries/cloudpulse/alerts.ts b/packages/manager/src/queries/cloudpulse/alerts.ts index 69605058dea..4406a90e0c2 100644 --- a/packages/manager/src/queries/cloudpulse/alerts.ts +++ b/packages/manager/src/queries/cloudpulse/alerts.ts @@ -5,14 +5,15 @@ import { queryFactory } from './queries'; import type { Alert, + AlertServiceType, CreateAlertDefinitionPayload, } from '@linode/api-v4/lib/cloudpulse'; import type { APIError } from '@linode/api-v4/lib/types'; -export const useCreateAlertDefinition = () => { +export const useCreateAlertDefinition = (service_type: AlertServiceType) => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (data) => createAlertDefinition(data), + mutationFn: (data) => createAlertDefinition(data, service_type), onSuccess() { queryClient.invalidateQueries(queryFactory.alerts); }, diff --git a/packages/validation/.changeset/pr-11286-added-1732032964808.md b/packages/validation/.changeset/pr-11286-added-1732032964808.md new file mode 100644 index 00000000000..7f87d4de4fe --- /dev/null +++ b/packages/validation/.changeset/pr-11286-added-1732032964808.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Added +--- + +Punctuation for the error messages ([#11286](https://github.com/linode/manager/pull/11286)) diff --git a/packages/validation/src/cloudpulse.schema.ts b/packages/validation/src/cloudpulse.schema.ts index ecd7ded1d5e..e8b7e63c414 100644 --- a/packages/validation/src/cloudpulse.schema.ts +++ b/packages/validation/src/cloudpulse.schema.ts @@ -1,45 +1,39 @@ import { array, number, object, string } from 'yup'; -const engineOptionValidation = string().when('service_type', { - is: 'dbaas', - then: string().required(), - otherwise: string().notRequired(), -}); const dimensionFilters = object({ - dimension_label: string().required('Label is required for the filter'), - operator: string().required('Operator is required'), - value: string().required('Value is required'), + dimension_label: string().required('Label is required for the filter.'), + operator: string().required('Operator is required.'), + value: string().required('Value is required.'), }); const metricCriteria = object({ - metric: string().required('Metric Data Field is required'), - aggregation_type: string().required('Aggregation type is required'), - operator: string().required('Criteria Operator is required'), - value: number() - .required('Threshold value is required') - .min(0, 'Threshold value cannot be negative'), + metric: string().required('Metric Data Field is required.'), + aggregation_type: string().required('Aggregation type is required.'), + operator: string().required('Criteria Operator is required.'), + threshold: number() + .required('Threshold value is required.') + .min(0, 'Threshold value cannot be negative.'), dimension_filters: array().of(dimensionFilters).notRequired(), }); const triggerCondition = object({ - criteria_condition: string().required('Criteria condition is required'), - polling_interval_seconds: string().required('Polling Interval is required'), - evaluation_period_seconds: string().required('Evaluation Period is required'), + criteria_condition: string().required('Criteria condition is required.'), + polling_interval_seconds: string().required('Polling Interval is required.'), + evaluation_period_seconds: string().required( + 'Evaluation Period is required.' + ), trigger_occurrences: number() - .required('Trigger Occurrences is required') - .positive('Number of occurrences must be greater than zero'), + .required('Trigger Occurrences is required.') + .positive('Number of occurrences must be greater than zero.'), }); -export const createAlertDefinitionSchema = object().shape({ - label: string().required('Name is required'), +export const createAlertDefinitionSchema = object({ + label: string().required('Name is required.'), description: string().optional(), - region: string().required('Region is required'), - engineOption: engineOptionValidation, - service_type: string().required('Service type is required'), - resource_ids: array().of(string()).min(1, 'At least one resource is needed'), - severity: string().required('Severity is required').nullable(), + resource_ids: array().of(string()).min(1, 'At least one resource is needed.'), + severity: string().required('Severity is required.').nullable(), criteria: array() .of(metricCriteria) - .min(1, 'At least one metric criteria is needed'), + .min(1, 'At least one metric criteria is needed.'), triggerCondition, });