From 2e9cca53d0a5e34d0edbc049adaf60b87ff79af6 Mon Sep 17 00:00:00 2001 From: Shaheer Kochai Date: Tue, 3 Sep 2024 20:36:31 +0430 Subject: [PATCH] feat: alert history dropdown functionality (#5833) * feat: alert history dropdown actions * chore: use query keys from react query key constant * fix: properly handle error states for alert rule APIs * fix: handle dropdown state using onOpenChange to fix clicking delete not closing the dropdown --- frontend/src/api/alerts/create.ts | 24 ++-- frontend/src/api/alerts/delete.ts | 20 +-- frontend/src/api/alerts/get.ts | 25 +--- frontend/src/api/alerts/patch.ts | 28 ++-- frontend/src/api/alerts/put.ts | 24 ++-- frontend/src/constants/reactQueryKeys.ts | 4 + .../src/pages/AlertDetails/AlertDetails.tsx | 13 +- .../ActionButtons/ActionButtons.tsx | 80 ++++++++--- .../AlertDetails/AlertHeader/AlertHeader.tsx | 8 +- frontend/src/pages/AlertDetails/hooks.tsx | 134 ++++++++++++++++-- 10 files changed, 244 insertions(+), 116 deletions(-) diff --git a/frontend/src/api/alerts/create.ts b/frontend/src/api/alerts/create.ts index cad7917815..744183fa4b 100644 --- a/frontend/src/api/alerts/create.ts +++ b/frontend/src/api/alerts/create.ts @@ -1,26 +1,20 @@ import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { PayloadProps, Props } from 'types/api/alerts/create'; const create = async ( props: Props, ): Promise | ErrorResponse> => { - try { - const response = await axios.post('/rules', { - ...props.data, - }); + const response = await axios.post('/rules', { + ...props.data, + }); - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; }; export default create; diff --git a/frontend/src/api/alerts/delete.ts b/frontend/src/api/alerts/delete.ts index 278e3e2935..56407f3c40 100644 --- a/frontend/src/api/alerts/delete.ts +++ b/frontend/src/api/alerts/delete.ts @@ -1,24 +1,18 @@ import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { PayloadProps, Props } from 'types/api/alerts/delete'; const deleteAlerts = async ( props: Props, ): Promise | ErrorResponse> => { - try { - const response = await axios.delete(`/rules/${props.id}`); + const response = await axios.delete(`/rules/${props.id}`); - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data.rules, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data.rules, + }; }; export default deleteAlerts; diff --git a/frontend/src/api/alerts/get.ts b/frontend/src/api/alerts/get.ts index 05403d36e8..15a741287e 100644 --- a/frontend/src/api/alerts/get.ts +++ b/frontend/src/api/alerts/get.ts @@ -1,27 +1,16 @@ import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { PayloadProps, Props } from 'types/api/alerts/get'; const get = async ( props: Props, ): Promise | ErrorResponse> => { - try { - const response = await axios.get(`/rules/${props.id}`); - - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data, - }; - } catch (error) { - if (window.location.href.includes('alerts/history')) { - throw error as AxiosError; - } - return ErrorResponseHandler(error as AxiosError); - } + const response = await axios.get(`/rules/${props.id}`); + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; }; - export default get; diff --git a/frontend/src/api/alerts/patch.ts b/frontend/src/api/alerts/patch.ts index 0f422cd66c..cb64a1046f 100644 --- a/frontend/src/api/alerts/patch.ts +++ b/frontend/src/api/alerts/patch.ts @@ -1,30 +1,20 @@ import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { PayloadProps, Props } from 'types/api/alerts/patch'; const patch = async ( props: Props, ): Promise | ErrorResponse> => { - try { - const response = await axios.patch(`/rules/${props.id}`, { - ...props.data, - }); + const response = await axios.patch(`/rules/${props.id}`, { + ...props.data, + }); - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data, - }; - } catch (error) { - if (window.location.href.includes('alerts/history')) { - throw error as AxiosError; - } - - return ErrorResponseHandler(error as AxiosError); - } + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; }; export default patch; diff --git a/frontend/src/api/alerts/put.ts b/frontend/src/api/alerts/put.ts index b8c34e96bd..77d98d3c49 100644 --- a/frontend/src/api/alerts/put.ts +++ b/frontend/src/api/alerts/put.ts @@ -1,26 +1,20 @@ import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { PayloadProps, Props } from 'types/api/alerts/save'; const put = async ( props: Props, ): Promise | ErrorResponse> => { - try { - const response = await axios.put(`/rules/${props.id}`, { - ...props.data, - }); + const response = await axios.put(`/rules/${props.id}`, { + ...props.data, + }); - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; }; export default put; diff --git a/frontend/src/constants/reactQueryKeys.ts b/frontend/src/constants/reactQueryKeys.ts index f135da9cf3..ec2353abbf 100644 --- a/frontend/src/constants/reactQueryKeys.ts +++ b/frontend/src/constants/reactQueryKeys.ts @@ -14,4 +14,8 @@ export const REACT_QUERY_KEY = { ALERT_RULE_TIMELINE_TABLE: 'ALERT_RULE_TIMELINE_TABLE', ALERT_RULE_TIMELINE_GRAPH: 'ALERT_RULE_TIMELINE_GRAPH', GET_CONSUMER_LAG_DETAILS: 'GET_CONSUMER_LAG_DETAILS', + TOGGLE_ALERT_STATE: 'TOGGLE_ALERT_STATE', + GET_ALL_ALLERTS: 'GET_ALL_ALLERTS', + REMOVE_ALERT_RULE: 'REMOVE_ALERT_RULE', + DUPLICATE_ALERT_RULE: 'DUPLICATE_ALERT_RULE', }; diff --git a/frontend/src/pages/AlertDetails/AlertDetails.tsx b/frontend/src/pages/AlertDetails/AlertDetails.tsx index 4d5cada592..c79478fb77 100644 --- a/frontend/src/pages/AlertDetails/AlertDetails.tsx +++ b/frontend/src/pages/AlertDetails/AlertDetails.tsx @@ -71,12 +71,19 @@ function AlertDetails(): JSX.Element { const { routes } = useRouteTabUtils(); const { - data: { isLoading, data, isRefetching, isError }, + isLoading, + isRefetching, + isError, ruleId, isValidRuleId, + alertDetailsResponse, } = useGetAlertRuleDetails(); - if (isError || !isValidRuleId) { + if ( + isError || + !isValidRuleId || + (alertDetailsResponse && alertDetailsResponse.statusCode !== 200) + ) { return ; } @@ -98,7 +105,7 @@ function AlertDetails(): JSX.Element {
diff --git a/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/ActionButtons.tsx b/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/ActionButtons.tsx index b961f989e6..bf4ac62446 100644 --- a/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/ActionButtons.tsx +++ b/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/ActionButtons.tsx @@ -2,26 +2,22 @@ import './ActionButtons.styles.scss'; import { Color } from '@signozhq/design-tokens'; import { Button, Divider, Dropdown, MenuProps, Switch, Tooltip } from 'antd'; +import { QueryParams } from 'constants/query'; +import ROUTES from 'constants/routes'; import { useIsDarkMode } from 'hooks/useDarkMode'; +import useUrlQuery from 'hooks/useUrlQuery'; +import history from 'lib/history'; import { Copy, Ellipsis, PenLine, Trash2 } from 'lucide-react'; -import { useAlertRuleStatusToggle } from 'pages/AlertDetails/hooks'; +import { + useAlertRuleDelete, + useAlertRuleDuplicate, + useAlertRuleStatusToggle, +} from 'pages/AlertDetails/hooks'; import CopyToClipboard from 'periscope/components/CopyToClipboard'; -import React from 'react'; +import React, { useCallback, useState } from 'react'; +import { AlertDef } from 'types/api/alerts/def'; -const menu: MenuProps['items'] = [ - { - key: 'rename-rule', - label: 'Rename', - icon: , - onClick: (): void => {}, - }, - { - key: 'duplicate-rule', - label: 'Duplicate', - icon: , - onClick: (): void => {}, - }, -]; +import { AlertHeaderProps } from '../AlertHeader'; const menuStyle: React.CSSProperties = { padding: 0, @@ -29,7 +25,10 @@ const menuStyle: React.CSSProperties = { fontSize: 14, }; -function DropdownMenuRenderer(menu: React.ReactNode): React.ReactNode { +function DropdownMenuRenderer( + menu: React.ReactNode, + handleDelete: () => void, +): React.ReactNode { return (
{React.cloneElement(menu as React.ReactElement, { @@ -40,6 +39,7 @@ function DropdownMenuRenderer(menu: React.ReactNode): React.ReactNode { type="default" icon={} className="delete-button" + onClick={handleDelete} > Delete @@ -50,15 +50,55 @@ function DropdownMenuRenderer(menu: React.ReactNode): React.ReactNode { function AlertActionButtons({ ruleId, state, + alertDetails, }: { ruleId: string; state: string; + alertDetails: AlertHeaderProps['alertDetails']; }): JSX.Element { + const [dropdownOpen, setDropdownOpen] = useState(false); + const { handleAlertStateToggle, isAlertRuleEnabled, } = useAlertRuleStatusToggle({ ruleId, state }); + + const { handleAlertDuplicate } = useAlertRuleDuplicate({ + alertDetails: (alertDetails as unknown) as AlertDef, + }); + const { handleAlertDelete } = useAlertRuleDelete({ ruleId: Number(ruleId) }); + + const handleDeleteWithClose = useCallback(() => { + handleAlertDelete(); + setDropdownOpen(false); + }, [handleAlertDelete]); + + const params = useUrlQuery(); + + const handleRename = React.useCallback(() => { + params.set(QueryParams.ruleId, String(ruleId)); + history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`); + }, [params, ruleId]); + + const menu: MenuProps['items'] = React.useMemo( + () => [ + { + key: 'rename-rule', + label: 'Rename', + icon: , + onClick: (): void => handleRename(), + }, + { + key: 'duplicate-rule', + label: 'Duplicate', + icon: , + onClick: (): void => handleAlertDuplicate(), + }, + ], + [handleAlertDuplicate, handleRename], + ); const isDarkMode = useIsDarkMode(); + return (
+ DropdownMenuRenderer(menu, handleDeleteWithClose) + } >
- +
); diff --git a/frontend/src/pages/AlertDetails/hooks.tsx b/frontend/src/pages/AlertDetails/hooks.tsx index 478eb219cd..78d9bd0038 100644 --- a/frontend/src/pages/AlertDetails/hooks.tsx +++ b/frontend/src/pages/AlertDetails/hooks.tsx @@ -1,8 +1,11 @@ import { FilterValue, SorterResult } from 'antd/es/table/interface'; import { TablePaginationConfig, TableProps } from 'antd/lib'; +import deleteAlerts from 'api/alerts/delete'; import get from 'api/alerts/get'; +import getAll from 'api/alerts/getAll'; import patchAlert from 'api/alerts/patch'; import ruleStats from 'api/alerts/ruleStats'; +import save from 'api/alerts/save'; import timelineGraph from 'api/alerts/timelineGraph'; import timelineTable from 'api/alerts/timelineTable'; import topContributors from 'api/alerts/topContributors'; @@ -14,6 +17,7 @@ import AlertHistory from 'container/AlertHistory'; import { TIMELINE_TABLE_PAGE_SIZE } from 'container/AlertHistory/constants'; import { AlertDetailsTab, TimelineFilter } from 'container/AlertHistory/types'; import { urlKey } from 'container/AllError/utils'; +import useAxiosError from 'hooks/useAxiosError'; import { useNotifications } from 'hooks/useNotifications'; import useUrlQuery from 'hooks/useUrlQuery'; import createQueryParams from 'lib/createQueryParams'; @@ -24,18 +28,20 @@ import EditRules from 'pages/EditRules'; import { OrderPreferenceItems } from 'pages/Logs/config'; import PaginationInfoText from 'periscope/components/PaginationInfoText/PaginationInfoText'; import { useCallback, useMemo, useState } from 'react'; -import { useMutation, useQuery, UseQueryResult } from 'react-query'; +import { useMutation, useQuery } from 'react-query'; import { useSelector } from 'react-redux'; import { generatePath, useLocation } from 'react-router-dom'; import { AppState } from 'store/reducers'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { + AlertDef, AlertRuleStatsPayload, AlertRuleTimelineGraphResponsePayload, AlertRuleTimelineTableResponse, AlertRuleTimelineTableResponsePayload, AlertRuleTopContributorsPayload, } from 'types/api/alerts/def'; +import { PayloadProps } from 'types/api/alerts/get'; import { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; import { GlobalReducer } from 'types/reducer/globalTime'; import { nanoToMilli } from 'utils/timeUtils'; @@ -128,17 +134,29 @@ export const useRouteTabUtils = (): { routes: TabRoutes[] } => { return { routes }; }; - -export const useGetAlertRuleDetails = (): { +type Props = { ruleId: string | null; - data: UseQueryResult; isValidRuleId: boolean; -} => { + alertDetailsResponse: + | SuccessResponse + | ErrorResponse + | undefined; + isLoading: boolean; + isRefetching: boolean; + isError: boolean; +}; + +export const useGetAlertRuleDetails = (): Props => { const { ruleId } = useAlertHistoryQueryParams(); const isValidRuleId = ruleId !== null && String(ruleId).length !== 0; - const data = useQuery([REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId], { + const { + isLoading, + data: alertDetailsResponse, + isRefetching, + isError, + } = useQuery([REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId], { queryFn: () => get({ id: parseInt(ruleId || '', 10), @@ -148,7 +166,14 @@ export const useGetAlertRuleDetails = (): { refetchOnWindowFocus: false, }); - return { ruleId, data, isValidRuleId }; + return { + ruleId, + isLoading, + alertDetailsResponse, + isRefetching, + isError, + isValidRuleId, + }; }; type GetAlertRuleDetailsApiProps = { @@ -355,14 +380,14 @@ export const useAlertRuleStatusToggle = ({ isAlertRuleEnabled: boolean; } => { const { notifications } = useNotifications(); - const defaultErrorMessage = 'Something went wrong'; const isAlertRuleInitiallyEnabled = state !== 'disabled'; const [isAlertRuleEnabled, setIsAlertRuleEnabled] = useState( isAlertRuleInitiallyEnabled, ); + const handleError = useAxiosError(); const { mutate: toggleAlertState } = useMutation( - ['toggle-alert-state', ruleId], + [REACT_QUERY_KEY.TOGGLE_ALERT_STATE, ruleId], patchAlert, { onMutate: () => { @@ -373,11 +398,9 @@ export const useAlertRuleStatusToggle = ({ message: `Alert has been turned ${!isAlertRuleEnabled ? 'on' : 'off'}.`, }); }, - onError: () => { + onError: (error) => { setIsAlertRuleEnabled(isAlertRuleInitiallyEnabled); - notifications.error({ - message: defaultErrorMessage, - }); + handleError(error); }, }, ); @@ -390,6 +413,91 @@ export const useAlertRuleStatusToggle = ({ return { handleAlertStateToggle, isAlertRuleEnabled }; }; +export const useAlertRuleDuplicate = ({ + alertDetails, +}: { + alertDetails: AlertDef; +}): { + handleAlertDuplicate: () => void; +} => { + const { notifications } = useNotifications(); + + const params = useUrlQuery(); + + const { refetch } = useQuery(REACT_QUERY_KEY.GET_ALL_ALLERTS, { + queryFn: getAll, + cacheTime: 0, + }); + const handleError = useAxiosError(); + const { mutate: duplicateAlert } = useMutation( + [REACT_QUERY_KEY.DUPLICATE_ALERT_RULE], + save, + { + onSuccess: async () => { + notifications.success({ + message: `Success`, + }); + + const { data: allAlertsData } = await refetch(); + + if ( + allAlertsData && + allAlertsData.payload && + allAlertsData.payload.length > 0 + ) { + const clonedAlert = + allAlertsData.payload[allAlertsData.payload.length - 1]; + params.set(QueryParams.ruleId, String(clonedAlert.id)); + history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`); + } + }, + onError: handleError, + }, + ); + + const handleAlertDuplicate = (): void => { + const args = { + data: { ...alertDetails, alert: alertDetails.alert?.concat(' - Copy') }, + }; + duplicateAlert(args); + }; + + return { handleAlertDuplicate }; +}; + +export const useAlertRuleDelete = ({ + ruleId, +}: { + ruleId: number; +}): { + handleAlertDelete: () => void; +} => { + const { notifications } = useNotifications(); + const handleError = useAxiosError(); + + const { mutate: deleteAlert } = useMutation( + [REACT_QUERY_KEY.REMOVE_ALERT_RULE, ruleId], + deleteAlerts, + { + onSuccess: async () => { + notifications.success({ + message: `Success`, + }); + + history.push(ROUTES.LIST_ALL_ALERT); + }, + onError: handleError, + }, + ); + + const handleAlertDelete = (): void => { + const args = { id: ruleId }; + deleteAlert(args); + }; + + return { handleAlertDelete }; +}; + type GetAlertRuleDetailsTimelineGraphProps = GetAlertRuleDetailsApiProps & { data: | SuccessResponse