diff --git a/src/components/Errors/GlobalErrorsList/GlobalErrorsList.stories.tsx b/src/components/Errors/GlobalErrorsList/GlobalErrorsList.stories.tsx index 3be8cc78b..36ba46cf5 100644 --- a/src/components/Errors/GlobalErrorsList/GlobalErrorsList.stories.tsx +++ b/src/components/Errors/GlobalErrorsList/GlobalErrorsList.stories.tsx @@ -1,6 +1,10 @@ import { Meta, StoryObj } from "@storybook/react"; import { GlobalErrorsList } from "."; +import { ViewMode } from "../../../store/errors/errorsSlice"; +import { useStore } from "../../../store/useStore"; +import { actions } from "../actions"; +import { DefaultErrorList, DismissedErrorList } from "./mockData"; // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction const meta: Meta = { @@ -17,4 +21,32 @@ export default meta; type Story = StoryObj; // More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args -export const Default: Story = {}; +export const Default: Story = { + play: () => { + const { setEnvironment } = useStore.getState(); + setEnvironment({ id: "test-env-id", name: "test", type: "Public" }); + window.setTimeout(() => { + window.postMessage({ + type: "digma", + action: actions.SET_GLOBAL_ERRORS_DATA, + payload: DefaultErrorList + }); + }, 100); + } +}; + +export const Dismissed: Story = { + play: () => { + const { setEnvironment, setGlobalErrorsViewMode } = useStore.getState(); + setEnvironment({ id: "test-env-id", name: "test", type: "Public" }); + setGlobalErrorsViewMode(ViewMode.OnlyDismissed); + + window.setTimeout(() => { + window.postMessage({ + type: "digma", + action: actions.SET_GLOBAL_ERRORS_DATA, + payload: DismissedErrorList + }); + }, 100); + } +}; diff --git a/src/components/Errors/GlobalErrorsList/index.tsx b/src/components/Errors/GlobalErrorsList/index.tsx index 583a76b3c..86265820f 100644 --- a/src/components/Errors/GlobalErrorsList/index.tsx +++ b/src/components/Errors/GlobalErrorsList/index.tsx @@ -1,5 +1,6 @@ import { useAutoAnimate } from "@formkit/auto-animate/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { dispatcher } from "../../../dispatcher"; import { getFeatureFlagValue } from "../../../featureFlags"; import { DataFetcherConfiguration, @@ -10,14 +11,20 @@ import { usePrevious } from "../../../hooks/usePrevious"; import { useConfigSelector } from "../../../store/config/useConfigSelector"; import { GLOBAL_ERROR_SORTING_CRITERION, - PAGE_SIZE + PAGE_SIZE, + ViewMode } from "../../../store/errors/errorsSlice"; import { useErrorsSelector } from "../../../store/errors/useErrorsSelector"; import { useStore } from "../../../store/useStore"; +import { isNumber } from "../../../typeGuards/isNumber"; +import { isUndefined } from "../../../typeGuards/isUndefined"; import { FeatureFlag } from "../../../types"; import { sendUserActionTrackingEvent } from "../../../utils/actions/sendUserActionTrackingEvent"; +import { formatUnit } from "../../../utils/formatUnit"; import { OppositeArrowsIcon } from "../../common/icons/12px/OppositeArrowsIcon"; +import { ChevronIcon } from "../../common/icons/16px/ChevronIcon"; import { CardsColoredIcon } from "../../common/icons/CardsColoredIcon"; +import { Direction } from "../../common/icons/types"; import { NewPopover } from "../../common/NewPopover"; import { SearchInput } from "../../common/SearchInput"; import { NewButton } from "../../common/v3/NewButton"; @@ -58,8 +65,13 @@ export const GlobalErrorsList = () => { ); const { environment, backendInfo } = useConfigSelector(); + const isDismissEnabled = getFeatureFlagValue( + backendInfo, + FeatureFlag.IS_GLOBAL_ERROR_DISMISS_ENABLED + ); const { + globalErrorsViewMode: mode, globalErrorsSearch: search, globalErrorsSorting: sorting, globalErrorsPage: page, @@ -77,7 +89,8 @@ export const GlobalErrorsList = () => { setGlobalErrorsSorting, setGlobalErrorsPage, resetGlobalErrors, - resetGlobalErrorsSelectedFilters + resetGlobalErrorsSelectedFilters, + setGlobalErrorsViewMode } = useStore.getState(); const areGlobalErrorsFiltersEnabled = getFeatureFlagValue( @@ -119,6 +132,7 @@ export const GlobalErrorsList = () => { sortBy: sorting, page, pageSize: PAGE_SIZE, + dismissed: mode === ViewMode.OnlyDismissed, ...(areGlobalErrorsFiltersEnabled ? { services: selectedFilters.services, @@ -138,13 +152,14 @@ export const GlobalErrorsList = () => { search, sorting, page, + mode, areGlobalErrorsFiltersEnabled, - areGlobalErrorsCriticalityAndUnhandledFiltersEnabled, selectedFilters.services, selectedFilters.endpoints, selectedFilters.errorTypes, selectedFilters.criticalities, - selectedFilters.handlingTypes + selectedFilters.handlingTypes, + areGlobalErrorsCriticalityAndUnhandledFiltersEnabled ] ); @@ -153,6 +168,29 @@ export const GlobalErrorsList = () => { SetGlobalErrorsDataPayload >(dataFetcherConfiguration, payload); + const isDismissalViewModeButtonVisible = + isDismissEnabled && + data && + !isUndefined(data.dismissedCount) && + data.dismissedCount > 0; + + // Refresh data after pin/unpin actions + useEffect(() => { + dispatcher.addActionListener(actions.SET_UNDISMISS_ERROR_RESULT, getData); + dispatcher.addActionListener(actions.SET_DISMISS_ERROR_RESULT, getData); + + return () => { + dispatcher.removeActionListener( + actions.SET_UNDISMISS_ERROR_RESULT, + getData + ); + dispatcher.removeActionListener( + actions.SET_DISMISS_ERROR_RESULT, + getData + ); + }; + }, [getData]); + useMount(() => { toggleAnimations(false); @@ -256,6 +294,16 @@ export const GlobalErrorsList = () => { resetGlobalErrorsSelectedFilters(); }; + const handleDismissalViewModeButtonClick = () => { + const newMode = + mode === ViewMode.All ? ViewMode.OnlyDismissed : ViewMode.All; + setGlobalErrorsViewMode(newMode); + }; + + const handleBackToAllInsightsButtonClick = () => { + setGlobalErrorsViewMode(ViewMode.All); + }; + const handlePinStatusToggle = () => { toggleAnimations(true); }; @@ -265,6 +313,10 @@ export const GlobalErrorsList = () => { getData(); }; + const handleDismissalStatusChange = () => { + getData(); + }; + const areAnyFiltersApplied = search || [ @@ -275,8 +327,40 @@ export const GlobalErrorsList = () => { selectedFilters.handlingTypes ].some((x) => x.length > 0); + const renderDismissBtn = () => ( + ( + + )} + onClick={handleDismissalViewModeButtonClick} + /> + ); + return ( + {mode === ViewMode.OnlyDismissed && isNumber(data?.dismissedCount) && ( + + + + + + Back to All Errors + + + {data?.dismissedCount} + dismissed {formatUnit(data?.dismissedCount ?? 0, "error")} + + + )} {list ? ( <> @@ -314,17 +398,10 @@ export const GlobalErrorsList = () => { onSourceLinkClick={handleErrorSourceLinkClick} onPinStatusChange={handlePinStatusChange} onPinStatusToggle={handlePinStatusToggle} + onDismissStatusChange={handleDismissalStatusChange} /> ))} - ) : areAnyFiltersApplied ? ( { ) : ( )} + {list.length > 0 ? ( + + {isDismissalViewModeButtonVisible && renderDismissBtn()} + + ) : isDismissalViewModeButtonVisible ? ( + renderDismissBtn() + ) : undefined} ) : !environmentId ? ( diff --git a/src/components/Errors/GlobalErrorsList/mockData.ts b/src/components/Errors/GlobalErrorsList/mockData.ts new file mode 100644 index 000000000..31170ccfe --- /dev/null +++ b/src/components/Errors/GlobalErrorsList/mockData.ts @@ -0,0 +1,103 @@ +import { SetGlobalErrorsDataPayload } from "./types"; + +export const DefaultErrorList: SetGlobalErrorsDataPayload = { + totalCount: 2, + list: [ + { + id: "0021a8de-9134-11ef-8040-0242ac130002", + errorType: "java.lang.RuntimeException", + fromDisplayName: "CrashController.triggerException", + fromFullyQualifiedName: + "org.springframework.samples.petclinic.system.CrashController.triggerException", + fromCodeObjectId: + "method:org.springframework.samples.petclinic.system.CrashController$_$triggerException", + status: "Recent, 23 hours ago", + firstDetected: "2024-10-23T11:43:01.798918Z", + lastDetected: "2024-10-23T11:43:03.280678Z", + affectedEndpoints: [ + { + displayName: "HTTP GET /oups", + service: "spring-petclinic", + spanCodeObjectId: "span:io.opentelemetry.tomcat-10.0$_$HTTP GET /oups" + } + ], + score: { + score: 70, + scoreParams: { + Occurrences: 0, + Trend: 0, + Recent: 20, + Unhandled: 50 + } + }, + unhandled: true + }, + { + id: "00219416-9134-11ef-82aa-0242ac130002", + errorType: + "org.springframework.web.servlet.resource.NoResourceFoundException", + fromDisplayName: "ResourceHttpRequestHandler.handleRequest", + fromFullyQualifiedName: + "org.springframework.web.servlet.resource.ResourceHttpRequestHandler.handleRequest", + fromCodeObjectId: + "method:org.springframework.web.servlet.resource.ResourceHttpRequestHandler$_$handleRequest", + status: "High number of errors", + firstDetected: "2024-10-23T11:43:00.505243Z", + lastDetected: "2024-10-23T12:36:16.873716Z", + affectedEndpoints: [ + { + displayName: "HTTP GET /webjars/**", + service: "spring-petclinic", + spanCodeObjectId: + "span:io.opentelemetry.tomcat-10.0$_$HTTP GET /webjars/**" + } + ], + score: { + score: 45, + scoreParams: { + Occurrences: 25, + Trend: 0, + Recent: 20, + Unhandled: 0 + } + }, + unhandled: false + } + ] +}; + +export const DismissedErrorList: SetGlobalErrorsDataPayload = { + totalCount: 2, + dismissedCount: 1, + list: [ + { + id: "0021a8de-9134-11ef-8040-0242ac130002", + errorType: "java.lang.RuntimeException", + fromDisplayName: "CrashController.triggerException", + fromFullyQualifiedName: + "org.springframework.samples.petclinic.system.CrashController.triggerException", + fromCodeObjectId: + "method:org.springframework.samples.petclinic.system.CrashController$_$triggerException", + status: "Recent, 23 hours ago", + firstDetected: "2024-10-23T11:43:01.798918Z", + lastDetected: "2024-10-23T11:43:03.280678Z", + affectedEndpoints: [ + { + displayName: "HTTP GET /oups", + service: "spring-petclinic", + spanCodeObjectId: "span:io.opentelemetry.tomcat-10.0$_$HTTP GET /oups" + } + ], + score: { + score: 70, + scoreParams: { + Occurrences: 0, + Trend: 0, + Recent: 20, + Unhandled: 50 + } + }, + unhandled: true + } + ] +}; diff --git a/src/components/Errors/GlobalErrorsList/styles.ts b/src/components/Errors/GlobalErrorsList/styles.ts index 478925e13..51955fa4e 100644 --- a/src/components/Errors/GlobalErrorsList/styles.ts +++ b/src/components/Errors/GlobalErrorsList/styles.ts @@ -1,5 +1,11 @@ import styled from "styled-components"; -import { footnoteRegularTypography } from "../../common/App/typographies"; +import { + footnoteRegularTypography, + subscriptMediumTypography, + subscriptRegularTypography +} from "../../common/App/typographies"; +import { EyeIcon } from "../../common/icons/16px/EyeIcon"; +import { DismissBtnIconProps } from "./types"; export const Container = styled.div` display: flex; @@ -10,6 +16,49 @@ export const Container = styled.div` box-sizing: border-box; `; +export const Description = styled.div` + ${subscriptMediumTypography} + display: flex; + align-items: center; + color: ${({ theme }) => theme.colors.v3.text.tertiary}; + gap: 5px; +`; + +export const Count = styled.span` + color: ${({ theme }) => theme.colors.v3.text.primary}; +`; + +export const ToolbarRow = styled.div` + display: flex; + gap: 4px; + justify-content: space-between; + align-items: center; +`; + +export const ViewModeToolbarRow = styled(ToolbarRow)` + padding: 4px 0; +`; + +export const BackToAllErrorsButtonIconContainer = styled.div` + display: flex; + color: ${({ theme }) => theme.colors.v3.icon.disabled}; +`; + +export const BackToAllErrorsButton = styled.button` + ${subscriptRegularTypography} + + font-family: inherit; + padding: 0; + margin: 0; + background: none; + border: none; + display: flex; + gap: 4px; + align-items: center; + cursor: pointer; + color: ${({ theme }) => theme.colors.v3.text.primary}; +`; + export const ToolbarContainer = styled.div` display: flex; gap: 4px; @@ -45,3 +94,8 @@ export const EmptyStateContent = styled.div` export const SortButtonIconContainer = styled.div` color: ${({ theme }) => theme.colors.v3.icon.tertiary}; `; + +export const DismissBtnIcon = styled(EyeIcon)` + color: ${({ theme, $isDismissedMode }) => + $isDismissedMode ? theme.colors.v3.icon.brandSecondary : "currentColor"}; +`; diff --git a/src/components/Errors/GlobalErrorsList/types.ts b/src/components/Errors/GlobalErrorsList/types.ts index 11473fa10..d2c8d5067 100644 --- a/src/components/Errors/GlobalErrorsList/types.ts +++ b/src/components/Errors/GlobalErrorsList/types.ts @@ -16,6 +16,7 @@ export interface GetGlobalErrorsDataPayload { errorTypes?: string[]; criticalities?: ErrorCriticality[]; handlingTypes?: ErrorHandlingType[]; + dismissed?: boolean; } export interface GlobalErrorData { @@ -42,10 +43,13 @@ export interface GlobalErrorData { }; }; pinnedAt?: string; + isDismissed?: boolean; + unhandled?: boolean; } export interface SetGlobalErrorsDataPayload { totalCount: number; + dismissedCount?: number; list: GlobalErrorData[]; } @@ -56,3 +60,7 @@ export interface SetPinUnpinErrorResultPayload { message: string; }; } + +export interface DismissBtnIconProps { + $isDismissedMode: boolean; +} diff --git a/src/components/Errors/NewErrorCard/NewErrorCard.stories.tsx b/src/components/Errors/NewErrorCard/NewErrorCard.stories.tsx index 50f57f39f..e4180cc04 100644 --- a/src/components/Errors/NewErrorCard/NewErrorCard.stories.tsx +++ b/src/components/Errors/NewErrorCard/NewErrorCard.stories.tsx @@ -1,5 +1,6 @@ import { Meta, StoryObj } from "@storybook/react"; +import { fn } from "@storybook/test"; import { NewErrorCard } from "."; import { mockedGlobalErrorData } from "./mockData"; @@ -20,12 +21,14 @@ type Story = StoryObj; export const Default: Story = { args: { + onPinStatusChange: fn(), data: mockedGlobalErrorData } }; export const Critical: Story = { args: { + onPinStatusChange: fn(), data: { ...mockedGlobalErrorData, score: { diff --git a/src/components/Errors/NewErrorCard/hooks/types.ts b/src/components/Errors/NewErrorCard/hooks/types.ts new file mode 100644 index 000000000..8b92d0c1a --- /dev/null +++ b/src/components/Errors/NewErrorCard/hooks/types.ts @@ -0,0 +1,15 @@ +export interface DismissPayload { + id: string; + status: "success" | "failure"; + error?: { + message: string; + }; +} + +export interface UndismissPayload { + id: string; + status: "success" | "failure"; + error?: { + message: string; + }; +} diff --git a/src/components/Errors/NewErrorCard/hooks/useDismissal.ts b/src/components/Errors/NewErrorCard/hooks/useDismissal.ts new file mode 100644 index 000000000..e0be9f0e6 --- /dev/null +++ b/src/components/Errors/NewErrorCard/hooks/useDismissal.ts @@ -0,0 +1,50 @@ +import { useEffect, useState } from "react"; +import { useAction } from "../../../../hooks/useAction"; +import { actions } from "../../actions"; +import { DismissPayload, UndismissPayload } from "./types"; + +export const useDismissal = (id: string) => { + const [data, setData] = useState<{ + action: string; + payload: DismissPayload | UndismissPayload; + } | null>(null); + + const { + isOperationInProgress: isDismissInProgress, + execute: dismiss, + data: dismissData + } = useAction<{ id: string }, DismissPayload>( + actions.DISMISS_ERROR, + actions.SET_DISMISS_ERROR_RESULT, + { + id + } + ); + + const { + isOperationInProgress: isUndismissInProgress, + execute: undismiss, + data: undismissData + } = useAction<{ id: string }, UndismissPayload>( + actions.UNDISMISS_ERROR, + actions.SET_UNDISMISS_ERROR_RESULT, + { + id + } + ); + + useEffect(() => { + setData(undismissData); + }, [undismissData]); + + useEffect(() => { + setData(dismissData); + }, [dismissData]); + + return { + dismiss, + show: undismiss, + data, + isDismissalChangeInProgress: isDismissInProgress || isUndismissInProgress + }; +}; diff --git a/src/components/Errors/NewErrorCard/index.tsx b/src/components/Errors/NewErrorCard/index.tsx index 45e402851..1e4bb6e86 100644 --- a/src/components/Errors/NewErrorCard/index.tsx +++ b/src/components/Errors/NewErrorCard/index.tsx @@ -20,6 +20,7 @@ import { TimestampKeyValue } from "../ErrorCard/TimestampKeyValue"; import { usePinning } from "../GlobalErrorsList/usePinning"; import { getTagType, HIGH_SEVERITY_SCORE_THRESHOLD } from "../Score"; import { trackingEvents } from "../tracking"; +import { useDismissal } from "./hooks/useDismissal"; import { OccurrenceChart } from "./OccurrenceChart"; import * as s from "./styles"; import { NewErrorCardProps } from "./types"; @@ -31,6 +32,7 @@ export const NewErrorCard = ({ data, onSourceLinkClick, onPinStatusChange, + onDismissStatusChange, onPinStatusToggle }: NewErrorCardProps) => { const [isHistogramVisible, setIsHistogramVisible] = useState(false); @@ -47,12 +49,18 @@ export const NewErrorCard = ({ FeatureFlag.IS_GLOBAL_ERROR_PIN_ENABLED ); + const isDismissEnabled = getFeatureFlagValue( + backendInfo, + FeatureFlag.IS_GLOBAL_ERROR_DISMISS_ENABLED + ); + const { pin, unpin, data: pinUnpinResponse, isInProgress: isPinUnpinInProgress } = usePinning(data.id); + const previousPinUnpinResponse = usePrevious(pinUnpinResponse); const { @@ -64,9 +72,17 @@ export const NewErrorCard = ({ fromFullyQualifiedName, status, firstDetected, - lastDetected + lastDetected, + isDismissed } = data; const statusTagType = getTagType(score.score); + const { + isDismissalChangeInProgress, + dismiss, + show, + data: dismissalData + } = useDismissal(id); + const previousDismissalData = usePrevious(dismissalData); const selectorOptions: Option[] = useMemo( () => affectedEndpoints.map((x) => ({ @@ -91,6 +107,15 @@ export const NewErrorCard = ({ } }, [selectorOptions, selectedEndpoint]); + useEffect(() => { + if ( + previousDismissalData !== dismissalData && + dismissalData?.payload.status === "success" + ) { + onDismissStatusChange(dismissalData.payload.id); + } + }, [dismissalData, onDismissStatusChange, previousDismissalData]); + useEffect(() => { if ( previousPinUnpinResponse !== pinUnpinResponse && @@ -158,6 +183,20 @@ export const NewErrorCard = ({ onPinStatusToggle(); }; + const handleDismissalButtonClick = () => { + sendUserActionTrackingEvent( + trackingEvents.ERROR_CARD_DISMISS_BUTTON_CLICKED + ); + dismiss(); + }; + + const handleUndismissalButtonClick = () => { + sendUserActionTrackingEvent( + trackingEvents.ERROR_CARD_PIN_UNDISMISS_BUTTON_CLICKED + ); + show(); + }; + const isCritical = score.score > HIGH_SEVERITY_SCORE_THRESHOLD; const isPinned = Boolean(data.pinnedAt); @@ -193,78 +232,100 @@ export const NewErrorCard = ({ return ( - - - - {errorType} - - - - {fromDisplayName} - - - - {status && ( - - - - - Score:{" "} - {score.score} - - - } - type={statusTagType} - /> + + + + + {errorType} + + + + {fromDisplayName} + + + + {status && ( + + + + + Score:{" "} + {score.score} + + + } + type={statusTagType} + /> + )} + + {selectorOptions.length > 0 && ( + + Affected Endpoints ({selectorOptions.length}) + + )} - - {selectorOptions.length > 0 && ( - - Affected Endpoints ({selectorOptions.length}) - - - )} - {isOccurrenceChartEnabled && selectedEndpoint && ( - <> - - + - + + + + + )} + + {(toolbarActions.length > 0 || isDismissEnabled) && ( + + + {isDismissEnabled && ( + - - - + )} + {toolbarActions} + + )} - {toolbarActions.length > 0 && {toolbarActions}} ); }; diff --git a/src/components/Errors/NewErrorCard/styles.ts b/src/components/Errors/NewErrorCard/styles.ts index bdb2a8060..26c2bb05e 100644 --- a/src/components/Errors/NewErrorCard/styles.ts +++ b/src/components/Errors/NewErrorCard/styles.ts @@ -3,6 +3,7 @@ import { bodySemiboldTypography, footnoteRegularTypography } from "../../common/App/typographies"; +import { DismissPanel } from "../../common/DismissPanel"; import { Link } from "../../common/v3/Link"; import { Tag } from "../../common/v3/Tag"; import { HEIGHT } from "./OccurrenceChart/styles"; @@ -14,9 +15,7 @@ export const chartContainerTransitionClassName = "chart-container"; export const Container = styled.div` display: flex; flex-direction: column; - gap: 16px; border-radius: 4px; - padding: 12px; border: 1px solid ${({ theme, $isPinned, $isCritical }) => $isPinned @@ -112,8 +111,30 @@ export const OccurrenceChartContainer = styled.div void; onPinStatusChange: (errorId: string) => void; + onDismissStatusChange: (errorId: string) => void; onPinStatusToggle: () => void; } diff --git a/src/components/Errors/actions.ts b/src/components/Errors/actions.ts index ea8f49364..6772a9ced 100644 --- a/src/components/Errors/actions.ts +++ b/src/components/Errors/actions.ts @@ -20,6 +20,10 @@ export const actions = addPrefix(ACTION_PREFIX, { SET_ERROR_TIME_SERIES_DATA: "SET_ERROR_TIME_SERIES_DATA", PIN_ERROR: "PIN_ERROR", SET_PIN_ERROR_RESULT: "SET_PIN_ERROR_RESULT", + SET_UNPIN_ERROR_RESULT: "SET_UNPIN_ERROR_RESULT", UNPIN_ERROR: "UNPIN_ERROR", - SET_UNPIN_ERROR_RESULT: "SET_UNPIN_ERROR_RESULT" + DISMISS_ERROR: "DISMISS_ERROR", + UNDISMISS_ERROR: "UNDISMISS_ERROR", + SET_DISMISS_ERROR_RESULT: "SET_DISMISS_ERROR_RESULT", + SET_UNDISMISS_ERROR_RESULT: "SET_UNDISMISS_ERROR_RESULT" }); diff --git a/src/components/Errors/tracking.ts b/src/components/Errors/tracking.ts index 69521eeae..aa28fe0e8 100644 --- a/src/components/Errors/tracking.ts +++ b/src/components/Errors/tracking.ts @@ -40,7 +40,10 @@ export const trackingEvents = addPrefix( ERROR_CARD_AFFECTED_ENDPOINT_LINK_CLICKED: "error card affected endpoint link clicked", ERROR_CARD_HISTOGRAM_BUTTON_CLICKED: "error card histogram button clicked", - ERROR_CARD_PIN_UNPIN_BUTTON_CLICKED: "error card pin unpin button clicked" + ERROR_CARD_PIN_UNPIN_BUTTON_CLICKED: "error card pin unpin button clicked", + ERROR_CARD_DISMISS_BUTTON_CLICKED: "error card dismiss button clicked", + ERROR_CARD_PIN_UNDISMISS_BUTTON_CLICKED: + "error card pin undismiss button clicked" }, " " ); diff --git a/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/hooks/useDismissal.ts b/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/hooks/useDismissal.ts index 17531b3ff..d9edc0fd9 100644 --- a/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/hooks/useDismissal.ts +++ b/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/hooks/useDismissal.ts @@ -1,79 +1,35 @@ -import { useCallback, useEffect, useState } from "react"; -import { dispatcher } from "../../../../../../../../dispatcher"; +import { useAction } from "../../../../../../../../hooks/useAction"; import { actions } from "../../../../../../actions"; import { DismissUndismissInsightPayload } from "../../../../../../types"; import { DismissUndismissResponsePayload } from "../types"; -export const useDismissal = (insightId: string) => { - const [data, setData] = useState<{ - action: string; - payload: DismissUndismissResponsePayload; - } | null>(null); - const [isOperationInProgress, setIsOperationInProgress] = useState(false); - - useEffect(() => { - const handleDismissResponse = (payload: unknown) => { - handleResponse(actions.SET_DISMISS_RESPONSE, payload); - }; - - const handleUndismissResponse = (payload: unknown) => { - handleResponse(actions.SET_UNDISMISS_RESPONSE, payload); - }; +const mapId = (response: DismissUndismissResponsePayload) => response.insightId; - const handleResponse = (action: string, data: unknown) => { - const payload = data as DismissUndismissResponsePayload; - if (insightId === payload.insightId) { - setData({ action, payload }); - setIsOperationInProgress(false); - } - }; - - dispatcher.addActionListener( +export const useDismissal = (insightId: string) => { + const { isOperationInProgress: isDismissInProgress, execute: dismiss } = + useAction( + actions.DISMISS, actions.SET_DISMISS_RESPONSE, - handleDismissResponse + { + id: insightId, + insightId + }, + mapId ); - dispatcher.addActionListener( + + const { isOperationInProgress: isUndismissInProgress, execute: undismiss } = + useAction( + actions.UNDISMISS, actions.SET_UNDISMISS_RESPONSE, - handleUndismissResponse + { + id: insightId, + insightId + }, + mapId ); - - return () => { - dispatcher.removeActionListener( - actions.SET_DISMISS_RESPONSE, - handleDismissResponse - ); - dispatcher.removeActionListener( - actions.SET_UNDISMISS_RESPONSE, - handleUndismissResponse - ); - }; - }, [insightId]); - - const sendAction = useCallback( - (action: string) => { - window.sendMessageToDigma({ - action, - payload: { - insightId - } - }); - setIsOperationInProgress(true); - }, - [insightId] - ); - - const dismiss = useCallback(() => { - sendAction(actions.DISMISS); - }, [sendAction]); - - const undismiss = useCallback(() => { - sendAction(actions.UNDISMISS); - }, [sendAction]); - return { dismiss, show: undismiss, - data, - isDismissalChangeInProgress: isOperationInProgress + isDismissalChangeInProgress: isDismissInProgress || isUndismissInProgress }; }; diff --git a/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/index.tsx b/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/index.tsx index e4b5b3431..3f0170742 100644 --- a/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/index.tsx +++ b/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/index.tsx @@ -5,7 +5,7 @@ import { useConfigSelector } from "../../../../../../../store/config/useConfigSe import { isString } from "../../../../../../../typeGuards/isString"; import { sendUserActionTrackingEvent } from "../../../../../../../utils/actions/sendUserActionTrackingEvent"; import { getInsightTypeInfo } from "../../../../../../../utils/getInsightTypeInfo"; -import { Spinner } from "../../../../../../Navigation/CodeButtonMenu/Spinner"; +import { DismissPanel } from "../../../../../../common/DismissPanel"; import { CheckmarkCircleIcon } from "../../../../../../common/icons/12px/CheckmarkCircleIcon"; import { TraceIcon } from "../../../../../../common/icons/12px/TraceIcon"; import { DoubleCircleIcon } from "../../../../../../common/icons/16px/DoubleCircleIcon"; @@ -13,9 +13,7 @@ import { HistogramIcon } from "../../../../../../common/icons/16px/HistogramIcon import { PinIcon } from "../../../../../../common/icons/16px/PinIcon"; import { QuestionMark } from "../../../../../../common/icons/16px/QuestionMark"; import { RecheckIcon } from "../../../../../../common/icons/16px/RecheckIcon"; -import { CrossIcon } from "../../../../../../common/icons/CrossIcon"; import { JiraButton } from "../../../../../../common/v3/JiraButton"; -import { NewButton } from "../../../../../../common/v3/NewButton"; import { Tooltip } from "../../../../../../common/v3/Tooltip"; import { actions } from "../../../../../actions"; import { trackingEvents } from "../../../../../tracking"; @@ -52,8 +50,6 @@ export const InsightCard = ({ viewMode, mainMetric }: InsightCardProps) => { - const [isDismissConfirmationOpened, setDismissConfirmationOpened] = - useState(false); const { isDismissalChangeInProgress, dismiss, show } = useDismissal( insight.id ); @@ -159,7 +155,6 @@ export const InsightCard = ({ } ); dismiss(); - setDismissConfirmationOpened(false); }; const handleShowClick = () => { @@ -437,55 +432,23 @@ export const InsightCard = ({ } footer={ isFooterVisible ? ( - <> - {!isDismissConfirmationOpened ? ( - - {insight.isDismissible && ( - - {insight.isDismissed ? ( - - ) : ( - setDismissConfirmationOpened(true)} - /> - )} - {isDismissalChangeInProgress && } - - )} - {renderActions()} - - ) : ( - - Dismiss insight? - - setDismissConfirmationOpened(false)} - /> - - - + + {insight.isDismissible && ( + )} - + {renderActions()} + ) : undefined } /> diff --git a/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/styles.ts b/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/styles.ts index f6d53e7ef..c02ed232a 100644 --- a/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/styles.ts +++ b/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/styles.ts @@ -7,6 +7,8 @@ import { StyledCardProps } from "./types"; export const InsightFooter = styled.div` display: flex; justify-content: space-between; + position: relative; + min-height: 27px; `; export const Description = styled.div` @@ -59,27 +61,6 @@ export const StyledCard = styled(Card)` : ""} `; -export const DismissDialog = styled.div` - display: flex; - justify-content: space-between; - padding: 7px; - align-items: center; - margin: -7px; - background: ${({ theme }) => theme.colors.v3.surface.sidePanelHeader}; - - ${subscriptRegularTypography} -`; - -export const DismissDialogActions = styled.div` - display: flex; - gap: 8px; -`; - -export const ButtonContainer = styled.div` - display: flex; - align-items: center; -`; - export const InfoActionButton = styled(NewIconButton)` &:hover { color: ${({ theme }) => theme.colors.v3.status.medium}; diff --git a/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/types.ts b/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/types.ts index 46285d38f..993efd77b 100644 --- a/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/types.ts +++ b/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/types.ts @@ -43,6 +43,7 @@ export interface StyledCardProps extends CardProps { export interface DismissUndismissResponsePayload { insightId: string; + id: string; status: "success" | "failure"; error?: string; } diff --git a/src/components/Insights/types.ts b/src/components/Insights/types.ts index 4b22294f8..339022eb6 100644 --- a/src/components/Insights/types.ts +++ b/src/components/Insights/types.ts @@ -681,4 +681,5 @@ export { InsightType }; export interface DismissUndismissInsightPayload { insightId: string; + id: string; } diff --git a/src/components/common/DismissPanel/DismissPanel.stories.tsx b/src/components/common/DismissPanel/DismissPanel.stories.tsx new file mode 100644 index 000000000..cef2b0e44 --- /dev/null +++ b/src/components/common/DismissPanel/DismissPanel.stories.tsx @@ -0,0 +1,25 @@ +import { Meta, StoryObj } from "@storybook/react"; +import { fn } from "@storybook/test"; +import { DismissPanel } from "."; + +// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction +const meta: Meta = { + title: "common/DismissPanel", + component: DismissPanel, + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/react/configure/story-layout + layout: "fullscreen" + } +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + confirmationMessage: "Dismiss insight?", + onDismiss: fn(), + onShow: fn() + } +}; diff --git a/src/components/common/DismissPanel/index.tsx b/src/components/common/DismissPanel/index.tsx new file mode 100644 index 000000000..64b411a47 --- /dev/null +++ b/src/components/common/DismissPanel/index.tsx @@ -0,0 +1,80 @@ +import { forwardRef, useState } from "react"; +import { usePrevious } from "../../../hooks/usePrevious"; +import { CrossIcon } from "../icons/CrossIcon"; +import { NewButton } from "../v3/NewButton"; +import { Spinner } from "../v3/Spinner"; +import * as s from "./styles"; +import { DismissPanelProps } from "./types"; + +const DismissPanelComponent = ({ + state, + onShow, + onDismiss, + confirmationMessage, + className +}: DismissPanelProps) => { + const [isDismissConfirmationOpened, setDismissConfirmationOpened] = + useState(false); + const previousState = usePrevious(state); + + const handleDismissClick = () => { + setDismissConfirmationOpened(true); + }; + + const handleConfirmationAgreed = () => { + setDismissConfirmationOpened(false); + onDismiss(); + }; + + const handleConfirmationDiscard = () => { + setDismissConfirmationOpened(false); + }; + + return ( + <> + {!isDismissConfirmationOpened ? ( + + {state === "dismissed" && ( + + )} + {state === "visible" && ( + + )} + {state === "in-progress" && ( + <> + + + + )} + + ) : ( + + {confirmationMessage} + + + + + + )} + + ); +}; + +export const DismissPanel = forwardRef(DismissPanelComponent); diff --git a/src/components/common/DismissPanel/styles.ts b/src/components/common/DismissPanel/styles.ts new file mode 100644 index 000000000..5e1fc3a49 --- /dev/null +++ b/src/components/common/DismissPanel/styles.ts @@ -0,0 +1,27 @@ +import styled from "styled-components"; +import { subscriptRegularTypography } from "../App/typographies"; + +export const DismissDialog = styled.div` + display: flex; + justify-content: space-between; + padding: 7px; + align-items: center; + margin: -7px; + background: ${({ theme }) => theme.colors.v3.surface.sidePanelHeader}; + position: absolute; + width: 100%; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + + ${subscriptRegularTypography} +`; + +export const DismissDialogActions = styled.div` + display: flex; + gap: 8px; +`; + +export const ButtonContainer = styled.div` + display: flex; + align-items: center; +`; diff --git a/src/components/common/DismissPanel/types.ts b/src/components/common/DismissPanel/types.ts new file mode 100644 index 000000000..ffb828f34 --- /dev/null +++ b/src/components/common/DismissPanel/types.ts @@ -0,0 +1,7 @@ +export interface DismissPanelProps { + state: "dismissed" | "in-progress" | "visible"; + onShow: () => void; + onDismiss: () => void; + confirmationMessage: string; + className?: string; +} diff --git a/src/components/common/icons/16px/EyeIcon.tsx b/src/components/common/icons/16px/EyeIcon.tsx index 7485e7a5f..4dee07e3a 100644 --- a/src/components/common/icons/16px/EyeIcon.tsx +++ b/src/components/common/icons/16px/EyeIcon.tsx @@ -4,6 +4,7 @@ import { IconProps } from "../types"; interface EyeIconProps extends IconProps { crossOut?: boolean; + className?: string; } const EyeIconComponent = (props: EyeIconProps) => { @@ -14,6 +15,7 @@ const EyeIconComponent = (props: EyeIconProps) => { xmlns="http://www.w3.org/2000/svg" width={size} height={size} + className={props.className} viewBox="0 0 16 12" fill="none" > diff --git a/src/components/common/v3/Pagination/index.tsx b/src/components/common/v3/Pagination/index.tsx index aa18cc336..a1102b9be 100644 --- a/src/components/common/v3/Pagination/index.tsx +++ b/src/components/common/v3/Pagination/index.tsx @@ -15,7 +15,8 @@ export const Pagination = ({ onPageChange, extendedNavigation, withDescription, - trackingEventPrefix = "" + trackingEventPrefix = "", + children }: PaginationProps) => { const prefixedGlobalTrackingEvents = addPrefix( trackingEventPrefix, @@ -45,10 +46,13 @@ export const Pagination = ({ {(pageCount > 1 || extendedNavigation) && ( {withDescription && ( - - Showing {pageStartItemNumber} - {pageEndItemNumber} of{" "} - {itemsCount} {formatUnit(itemsCount, "Result")} - + + + Showing {pageStartItemNumber} - {pageEndItemNumber} of{" "} + {itemsCount} {formatUnit(itemsCount, "Result")} + + {children} + )} {extendedNavigation && ( diff --git a/src/components/common/v3/Pagination/styles.ts b/src/components/common/v3/Pagination/styles.ts index 86bb248a8..e093c662a 100644 --- a/src/components/common/v3/Pagination/styles.ts +++ b/src/components/common/v3/Pagination/styles.ts @@ -9,8 +9,14 @@ export const Container = styled.div` gap: 8px; `; -export const Description = styled.span` +export const DescriptionContainer = styled.div` + gap: 8px; + display: flex; margin-right: auto; + align-items: center; +`; + +export const Description = styled.span` color: ${({ theme }) => theme.colors.v3.text.tertiary}; `; diff --git a/src/components/common/v3/Pagination/types.ts b/src/components/common/v3/Pagination/types.ts index bb74b362d..a9e2d6fdc 100644 --- a/src/components/common/v3/Pagination/types.ts +++ b/src/components/common/v3/Pagination/types.ts @@ -1,3 +1,5 @@ +import { ReactNode } from "react"; + export interface PaginationProps { itemsCount: number; page: number; @@ -6,4 +8,5 @@ export interface PaginationProps { extendedNavigation?: boolean; withDescription?: boolean; trackingEventPrefix?: string; + children?: ReactNode; } diff --git a/src/featureFlags.ts b/src/featureFlags.ts index 003b645ff..01c6c444a 100644 --- a/src/featureFlags.ts +++ b/src/featureFlags.ts @@ -25,7 +25,8 @@ export const featureFlagMinBackendVersions: Record = { [FeatureFlag.IS_ERROR_OCCURRENCE_CHART_ENABLED]: "0.3.141-alpha.3", [FeatureFlag.ARE_GLOBAL_ERRORS_CRITICALITY_AND_UNHANDLED_FILTERS_ENABLED]: "0.3.145", - [FeatureFlag.IS_GLOBAL_ERROR_PIN_ENABLED]: "0.3.147" + [FeatureFlag.IS_GLOBAL_ERROR_PIN_ENABLED]: "0.3.147", + [FeatureFlag.IS_GLOBAL_ERROR_DISMISS_ENABLED]: "0.3.148" }; export const getFeatureFlagValue = ( diff --git a/src/hooks/useAction.test.ts b/src/hooks/useAction.test.ts new file mode 100644 index 000000000..7c12f3005 --- /dev/null +++ b/src/hooks/useAction.test.ts @@ -0,0 +1,111 @@ +import { act, renderHook } from "@testing-library/react"; +import { ActionListener } from "../api/types"; +import { dispatcher } from "../dispatcher"; +import { useAction } from "./useAction"; + +jest.mock("../dispatcher", () => ({ + dispatcher: { + addActionListener: jest.fn(), + removeActionListener: jest.fn() + } +})); + +const mockSendMessageToDigma = jest.fn(); +window.sendMessageToDigma = mockSendMessageToDigma; + +describe("useAction Hook", () => { + const requestAction = "testRequestAction"; + const responseAction = "testResponseAction"; + const mockPayload = { id: "123" }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should initialize with no data and operation not in progress", () => { + const { result } = renderHook(() => + useAction(requestAction, responseAction, mockPayload) + ); + expect(result.current.data).toBeNull(); + expect(result.current.isOperationInProgress).toBe(false); + }); + + it("should add and remove action listeners on mount and unmount", () => { + const { unmount } = renderHook(() => + useAction(requestAction, responseAction, mockPayload) + ); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(dispatcher.addActionListener).toHaveBeenCalledWith( + responseAction, + expect.any(Function) + ); + + unmount(); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(dispatcher.removeActionListener).toHaveBeenCalledWith( + responseAction, + expect.any(Function) + ); + }); + + it("should set data and stop operation on valid action response", () => { + const { result } = renderHook(() => + useAction(requestAction, responseAction, mockPayload) + ); + + act(() => { + const handleActionResponse = (dispatcher.addActionListener as jest.Mock) + .mock.calls[0][1] as ActionListener; // eslint-disable-line @typescript-eslint/no-unsafe-member-access + + handleActionResponse({ id: "123" }, Date.now()); + }); + + expect(result.current.data).toEqual({ + action: responseAction, + payload: mockPayload + }); + expect(result.current.isOperationInProgress).toBe(false); + }); + + it("should set data and stop operation on valid action response with customId", () => { + const { result } = renderHook(() => + useAction( + requestAction, + responseAction, + mockPayload, + (result: { errorId: string; id: string }) => result.errorId + ) + ); + + act(() => { + const handleActionResponse = (dispatcher.addActionListener as jest.Mock) + .mock.calls[0][1] as ActionListener; // eslint-disable-line @typescript-eslint/no-unsafe-member-access + + handleActionResponse({ errorId: "123" }, Date.now()); + }); + + expect(result.current.data).toEqual({ + action: responseAction, + payload: { errorId: "123" } + }); + expect(result.current.isOperationInProgress).toBe(false); + }); + + it("should start operation in progress and send message on execute", () => { + const { result } = renderHook(() => + useAction(requestAction, responseAction, mockPayload) + ); + + act(() => { + result.current.execute(); + }); + + expect(result.current.isOperationInProgress).toBe(true); + expect(window.sendMessageToDigma).toHaveBeenCalledWith({ + action: requestAction, + payload: mockPayload + }); + }); +}); diff --git a/src/hooks/useAction.ts b/src/hooks/useAction.ts new file mode 100644 index 000000000..9b62d9a8b --- /dev/null +++ b/src/hooks/useAction.ts @@ -0,0 +1,60 @@ +import { useCallback, useEffect, useState } from "react"; +import { dispatcher } from "../dispatcher"; + +interface Identifier { + id: string; +} + +export const useAction = < + TPayload extends Identifier, + TResponse extends Identifier +>( + requestAction: string, + responseAction: string, + payload: TPayload, + mapId?: (response: TResponse) => string +) => { + const [data, setData] = useState<{ + action: string; + payload: TResponse; + } | null>(null); + const [isOperationInProgress, setIsOperationInProgress] = useState(false); + + useEffect(() => { + const handleActionResponse = (data: unknown) => { + const result = data as TResponse; + const responseId = mapId ? mapId(result) : result.id; + if (payload.id === responseId) { + setData({ action: responseAction, payload: result }); + setIsOperationInProgress(false); + } + }; + + dispatcher.addActionListener(responseAction, handleActionResponse); + + return () => { + dispatcher.removeActionListener(responseAction, handleActionResponse); + }; + }, [mapId, payload, payload.id, responseAction]); + + const sendAction = useCallback( + (action: string) => { + window.sendMessageToDigma({ + action, + payload: payload + }); + setIsOperationInProgress(true); + }, + [payload] + ); + + const execute = useCallback(() => { + sendAction(requestAction); + }, [requestAction, sendAction]); + + return { + execute, + data, + isOperationInProgress + }; +}; diff --git a/src/store/errors/errorsSlice.ts b/src/store/errors/errorsSlice.ts index b1b8ec9ca..1ad9d8961 100644 --- a/src/store/errors/errorsSlice.ts +++ b/src/store/errors/errorsSlice.ts @@ -12,6 +12,11 @@ export enum GLOBAL_ERROR_SORTING_CRITERION { LATEST = "Latest" } +export enum ViewMode { + All = "All", + OnlyDismissed = "OnlyDismissed" +} + export type ErrorFilter = "Services" | "Endpoints" | "ErrorTypes"; export type ErrorHandlingType = "Handled" | "Unhandled"; export type ErrorCriticality = "High" | "Medium" | "Low"; @@ -40,6 +45,7 @@ export interface ErrorsState { globalErrorsSorting: GLOBAL_ERROR_SORTING_CRITERION; globalErrorsFilters: GlobalErrorsFiltersState; globalErrorsSelectedFilters: GlobalErrorsSelectedFiltersState; + globalErrorsViewMode: ViewMode; errorDetailsWorkspaceItemsOnly: boolean; } @@ -64,6 +70,7 @@ export const globalErrorsInitialState = { endpoints: null, errorTypes: null }, + globalErrorsViewMode: ViewMode.All, globalErrorsSelectedFilters: selectedFiltersInitialState }; @@ -105,6 +112,8 @@ export const errorsSlice = createSlice({ setErrorDetailsWorkspaceItemsOnly: ( errorDetailsWorkspaceItemsOnly: boolean ) => set({ errorDetailsWorkspaceItemsOnly }), + setGlobalErrorsViewMode: (mode: ViewMode) => + set({ globalErrorsViewMode: mode }), resetGlobalErrors: () => set({ ...globalErrorsInitialState }) } }); diff --git a/src/types.ts b/src/types.ts index 3cfc5239e..49c5f9b0c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,7 +26,8 @@ export enum FeatureFlag { ARE_GLOBAL_ERRORS_FILTERS_ENABLED, IS_ERROR_OCCURRENCE_CHART_ENABLED, ARE_GLOBAL_ERRORS_CRITICALITY_AND_UNHANDLED_FILTERS_ENABLED, - IS_GLOBAL_ERROR_PIN_ENABLED + IS_GLOBAL_ERROR_PIN_ENABLED, + IS_GLOBAL_ERROR_DISMISS_ENABLED } export enum InsightType {