diff --git a/package-lock.json b/package-lock.json index e3a67db83..8448bf711 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@floating-ui/react": "^0.25.1", + "@formkit/auto-animate": "^0.8.2", "@tanstack/react-table": "^8.7.8", "allotment": "^1.19.0", "axios": "^1.7.4", @@ -2646,6 +2647,11 @@ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz", "integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==" }, + "node_modules/@formkit/auto-animate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/@formkit/auto-animate/-/auto-animate-0.8.2.tgz", + "integrity": "sha512-SwPWfeRa5veb1hOIBMdzI+73te5puUBHmqqaF1Bu7FjvxlYSz/kJcZKSa9Cg60zL0uRNeJL2SbRxV6Jp6Q1nFQ==" + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", diff --git a/package.json b/package.json index e4f61729f..29ff8a2b1 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,7 @@ }, "dependencies": { "@floating-ui/react": "^0.25.1", + "@formkit/auto-animate": "^0.8.2", "@tanstack/react-table": "^8.7.8", "allotment": "^1.19.0", "axios": "^1.7.4", diff --git a/src/components/Errors/GlobalErrorsList/index.tsx b/src/components/Errors/GlobalErrorsList/index.tsx index 65b1df20a..583a76b3c 100644 --- a/src/components/Errors/GlobalErrorsList/index.tsx +++ b/src/components/Errors/GlobalErrorsList/index.tsx @@ -1,11 +1,12 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { dispatcher } from "../../../dispatcher"; +import { useAutoAnimate } from "@formkit/auto-animate/react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { getFeatureFlagValue } from "../../../featureFlags"; import { DataFetcherConfiguration, useFetchData } from "../../../hooks/useFetchData"; import { useMount } from "../../../hooks/useMount"; +import { usePrevious } from "../../../hooks/usePrevious"; import { useConfigSelector } from "../../../store/config/useConfigSelector"; import { GLOBAL_ERROR_SORTING_CRITERION, @@ -36,10 +37,25 @@ import { SetGlobalErrorsDataPayload } from "./types"; +const PIN_UNPIN_ANIMATION_DURATION = 250; + export const GlobalErrorsList = () => { const { goTo } = useHistory(); const [isSortingMenuOpen, setIsSortingMenuOpen] = useState(false); const listContainerRef = useRef(null); + const [parent, toggleAnimations] = useAutoAnimate({ + duration: PIN_UNPIN_ANIMATION_DURATION + }); + const [latestPinChangedId, setLatestPinChangedId] = useState(); + + // useAutoAnimate requires to memoize callback + const getListContainerRef = useCallback( + (el: HTMLDivElement | null) => { + listContainerRef.current = el; + parent(el); + }, + [parent, listContainerRef] + ); const { environment, backendInfo } = useConfigSelector(); @@ -53,6 +69,8 @@ export const GlobalErrorsList = () => { globalErrorsSelectedFilters: selectedFilters } = useErrorsSelector(); + const previousList = usePrevious(list); + const { setGlobalErrorsData, setGlobalErrorsSearch, @@ -135,19 +153,9 @@ export const GlobalErrorsList = () => { SetGlobalErrorsDataPayload >(dataFetcherConfiguration, payload); - // Refresh data after pin/unpin actions - useEffect(() => { - dispatcher.addActionListener(actions.SET_PIN_ERROR_RESULT, getData); - dispatcher.addActionListener(actions.SET_UNPIN_ERROR_RESULT, getData); - - return () => { - dispatcher.removeActionListener(actions.SET_PIN_ERROR_RESULT, getData); - dispatcher.removeActionListener(actions.SET_UNPIN_ERROR_RESULT, getData); - }; - }, [getData]); - - // Cleanup errors store slice on unmount useMount(() => { + toggleAnimations(false); + return () => { resetGlobalErrors(); }; @@ -160,6 +168,28 @@ export const GlobalErrorsList = () => { } }, [data, setGlobalErrorsData]); + // Disable animations after pin/unpin actions + useEffect(() => { + if (!previousList || !list || !latestPinChangedId) { + return; + } + + if (previousList !== list) { + const isLatestChangedIdInList = list.some( + (x) => x.id === latestPinChangedId + ); + + if (isLatestChangedIdInList) { + setTimeout(() => { + toggleAnimations(false); + }, PIN_UNPIN_ANIMATION_DURATION); + } else { + toggleAnimations(false); + } + setLatestPinChangedId(undefined); + } + }, [previousList, list, latestPinChangedId, toggleAnimations]); + // Reset page on filters change useEffect(() => { setGlobalErrorsPage(0); @@ -226,6 +256,15 @@ export const GlobalErrorsList = () => { resetGlobalErrorsSelectedFilters(); }; + const handlePinStatusToggle = () => { + toggleAnimations(true); + }; + + const handlePinStatusChange = (errorId: string) => { + setLatestPinChangedId(errorId); + getData(); + }; + const areAnyFiltersApplied = search || [ @@ -267,12 +306,14 @@ export const GlobalErrorsList = () => { {list.length > 0 ? ( <> - + {list.map((x) => ( ))} diff --git a/src/components/Errors/GlobalErrorsList/types.ts b/src/components/Errors/GlobalErrorsList/types.ts index 0c21511c9..11473fa10 100644 --- a/src/components/Errors/GlobalErrorsList/types.ts +++ b/src/components/Errors/GlobalErrorsList/types.ts @@ -48,3 +48,11 @@ export interface SetGlobalErrorsDataPayload { totalCount: number; list: GlobalErrorData[]; } + +export interface SetPinUnpinErrorResultPayload { + id: string; + status: "success" | "failure"; + error?: { + message: string; + }; +} diff --git a/src/components/Errors/GlobalErrorsList/usePinning.ts b/src/components/Errors/GlobalErrorsList/usePinning.ts new file mode 100644 index 000000000..2c240b6e0 --- /dev/null +++ b/src/components/Errors/GlobalErrorsList/usePinning.ts @@ -0,0 +1,86 @@ +import { useCallback, useEffect, useState } from "react"; +import { dispatcher } from "../../../dispatcher"; +import { useConfigSelector } from "../../../store/config/useConfigSelector"; +import { actions } from "../actions"; +import { PinUnpinErrorPayload } from "../NewErrorCard/types"; +import { SetPinUnpinErrorResultPayload } from "./types"; + +export const usePinning = (errorId: string) => { + const { environment } = useConfigSelector(); + const [data, setData] = useState<{ + action: string; + payload: SetPinUnpinErrorResultPayload; + } | null>(null); + const [isOperationInProgress, setIsOperationInProgress] = useState(false); + + useEffect(() => { + const handlePinErrorResult = (payload: unknown) => { + handleResult(actions.PIN_ERROR, payload); + }; + + const handleUnpinErrorResult = (payload: unknown) => { + handleResult(actions.UNPIN_ERROR, payload); + }; + + const handleResult = (action: string, data: unknown) => { + const payload = data as SetPinUnpinErrorResultPayload; + if (errorId === payload.id) { + setData({ action, payload }); + setIsOperationInProgress(false); + } + }; + + dispatcher.addActionListener( + actions.SET_PIN_ERROR_RESULT, + handlePinErrorResult + ); + dispatcher.addActionListener( + actions.SET_UNPIN_ERROR_RESULT, + handleUnpinErrorResult + ); + + return () => { + dispatcher.removeActionListener( + actions.SET_PIN_ERROR_RESULT, + handlePinErrorResult + ); + dispatcher.removeActionListener( + actions.SET_UNPIN_ERROR_RESULT, + handleUnpinErrorResult + ); + }; + }, [errorId]); + + const sendAction = useCallback( + (action: string) => { + if (!environment) { + return; + } + + window.sendMessageToDigma({ + action, + payload: { + id: errorId, + environment: environment?.id + } + }); + setIsOperationInProgress(true); + }, + [errorId, environment] + ); + + const pin = useCallback(() => { + sendAction(actions.PIN_ERROR); + }, [sendAction]); + + const unpin = useCallback(() => { + sendAction(actions.UNPIN_ERROR); + }, [sendAction]); + + return { + pin, + unpin, + data, + isInProgress: isOperationInProgress + }; +}; diff --git a/src/components/Errors/NewErrorCard/NewErrorCard.stories.tsx b/src/components/Errors/NewErrorCard/NewErrorCard.stories.tsx index 29c8fb2f2..50f57f39f 100644 --- a/src/components/Errors/NewErrorCard/NewErrorCard.stories.tsx +++ b/src/components/Errors/NewErrorCard/NewErrorCard.stories.tsx @@ -35,3 +35,12 @@ export const Critical: Story = { } } }; + +export const Pinned: Story = { + args: { + data: { + ...mockedGlobalErrorData, + pinnedAt: "2024-10-06T12:57:46.864939Z" + } + } +}; diff --git a/src/components/Errors/NewErrorCard/index.tsx b/src/components/Errors/NewErrorCard/index.tsx index 840743863..45e402851 100644 --- a/src/components/Errors/NewErrorCard/index.tsx +++ b/src/components/Errors/NewErrorCard/index.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { CSSTransition } from "react-transition-group"; import { getFeatureFlagValue } from "../../../featureFlags"; +import { usePrevious } from "../../../hooks/usePrevious"; import { useConfigSelector } from "../../../store/config/useConfigSelector"; import { FeatureFlag } from "../../../types"; import { changeScope } from "../../../utils/actions/changeScope"; @@ -15,24 +16,26 @@ import { PinFillIcon } from "../../common/icons/16px/PinFillIcon"; import { PinIcon } from "../../common/icons/16px/PinIcon"; import { NewIconButton } from "../../common/v3/NewIconButton"; import { Tooltip } from "../../common/v3/Tooltip"; -import { actions } from "../actions"; import { TimestampKeyValue } from "../ErrorCard/TimestampKeyValue"; +import { usePinning } from "../GlobalErrorsList/usePinning"; import { getTagType, HIGH_SEVERITY_SCORE_THRESHOLD } from "../Score"; import { trackingEvents } from "../tracking"; import { OccurrenceChart } from "./OccurrenceChart"; import * as s from "./styles"; -import { NewErrorCardProps, PinErrorPayload, UnpinErrorPayload } from "./types"; +import { NewErrorCardProps } from "./types"; export const getStatusString = (status: string) => status.toLowerCase().startsWith("recent") ? "Recent" : status; export const NewErrorCard = ({ data, - onSourceLinkClick + onSourceLinkClick, + onPinStatusChange, + onPinStatusToggle }: NewErrorCardProps) => { const [isHistogramVisible, setIsHistogramVisible] = useState(false); const chartContainerRef = useRef(null); - const { environment, backendInfo } = useConfigSelector(); + const { backendInfo } = useConfigSelector(); const isOccurrenceChartEnabled = getFeatureFlagValue( backendInfo, @@ -44,6 +47,14 @@ export const NewErrorCard = ({ FeatureFlag.IS_GLOBAL_ERROR_PIN_ENABLED ); + const { + pin, + unpin, + data: pinUnpinResponse, + isInProgress: isPinUnpinInProgress + } = usePinning(data.id); + const previousPinUnpinResponse = usePrevious(pinUnpinResponse); + const { id, affectedEndpoints, @@ -80,6 +91,15 @@ export const NewErrorCard = ({ } }, [selectorOptions, selectedEndpoint]); + useEffect(() => { + if ( + previousPinUnpinResponse !== pinUnpinResponse && + pinUnpinResponse?.payload.status === "success" + ) { + onPinStatusChange(pinUnpinResponse.payload.id); + } + }, [onPinStatusChange, pinUnpinResponse, previousPinUnpinResponse]); + const handleLinkClick = () => { sendUserActionTrackingEvent(trackingEvents.ERROR_CARD_SOURCE_LINK_CLICKED); onSourceLinkClick(id); @@ -129,27 +149,13 @@ export const NewErrorCard = ({ } ); - if (!environment) { - return; - } - if (value) { - window.sendMessageToDigma({ - action: actions.PIN_ERROR, - payload: { - id, - environment: environment.id - } - }); + pin(); } else { - window.sendMessageToDigma({ - action: actions.UNPIN_ERROR, - payload: { - id, - environment: environment.id - } - }); + unpin(); } + + onPinStatusToggle(); }; const isCritical = score.score > HIGH_SEVERITY_SCORE_THRESHOLD; @@ -168,6 +174,7 @@ export const NewErrorCard = ({ buttonType={"secondaryBorderless"} icon={isPinned ? PinFillIcon : PinIcon} onClick={handlePinUnpinButtonClick} + isDisabled={isPinUnpinInProgress} /> ] : []), @@ -202,17 +209,20 @@ export const NewErrorCard = ({ content={getStatusString(status)} title={ - Score: {score.score} + + Score:{" "} + {score.score} + } type={statusTagType} diff --git a/src/components/Errors/NewErrorCard/styles.ts b/src/components/Errors/NewErrorCard/styles.ts index a74f4eb59..bdb2a8060 100644 --- a/src/components/Errors/NewErrorCard/styles.ts +++ b/src/components/Errors/NewErrorCard/styles.ts @@ -64,6 +64,10 @@ export const StatusTagTooltipContainer = styled.div` flex-direction: column; `; +export const StatusTagTooltipKey = styled.span` + color: ${({ theme }) => theme.colors.v3.text.tertiary}; +`; + export const SourceLink = styled(Link)` max-width: 100%; `; diff --git a/src/components/Errors/NewErrorCard/types.ts b/src/components/Errors/NewErrorCard/types.ts index 957a535e7..292d960a7 100644 --- a/src/components/Errors/NewErrorCard/types.ts +++ b/src/components/Errors/NewErrorCard/types.ts @@ -3,6 +3,8 @@ import { GlobalErrorData } from "../GlobalErrorsList/types"; export interface NewErrorCardProps { data: GlobalErrorData; onSourceLinkClick: (codeObjectId: string) => void; + onPinStatusChange: (errorId: string) => void; + onPinStatusToggle: () => void; } export interface ContainerProps { @@ -15,12 +17,7 @@ export interface OccurrenceChartContainerProps { $transitionClassName: string; } -export interface PinErrorPayload { - id: string; - environment: string; -} - -export interface UnpinErrorPayload { +export interface PinUnpinErrorPayload { id: string; environment: string; } 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 8914ce77d..17531b3ff 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,72 +1,79 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { dispatcher } from "../../../../../../../../dispatcher"; import { actions } from "../../../../../../actions"; -import { - DismissInsightPayload, - UndismissInsightPayload -} from "../../../../../../types"; -import { DismissResponsePayload, UndismissResponsePayload } from "../types"; +import { DismissUndismissInsightPayload } from "../../../../../../types"; +import { DismissUndismissResponsePayload } from "../types"; export const useDismissal = (insightId: string) => { - const [isDismissalChangeInProgress, setIsDismissalChangeInProgress] = - useState(false); + const [data, setData] = useState<{ + action: string; + payload: DismissUndismissResponsePayload; + } | null>(null); + const [isOperationInProgress, setIsOperationInProgress] = useState(false); useEffect(() => { - const handleDismissed = (data: unknown) => { - if (insightId === (data as DismissResponsePayload).insightId) { - setIsDismissalChangeInProgress(false); - } + const handleDismissResponse = (payload: unknown) => { + handleResponse(actions.SET_DISMISS_RESPONSE, payload); }; - dispatcher.addActionListener(actions.SET_DISMISS_RESPONSE, handleDismissed); - - return () => { - dispatcher.removeActionListener( - actions.SET_DISMISS_RESPONSE, - handleDismissed - ); + const handleUndismissResponse = (payload: unknown) => { + handleResponse(actions.SET_UNDISMISS_RESPONSE, payload); }; - }, []); - useEffect(() => { - const handleUndismissed = (data: unknown) => { - if (insightId === (data as UndismissResponsePayload).insightId) { - setIsDismissalChangeInProgress(false); + const handleResponse = (action: string, data: unknown) => { + const payload = data as DismissUndismissResponsePayload; + if (insightId === payload.insightId) { + setData({ action, payload }); + setIsOperationInProgress(false); } }; + dispatcher.addActionListener( + actions.SET_DISMISS_RESPONSE, + handleDismissResponse + ); dispatcher.addActionListener( actions.SET_UNDISMISS_RESPONSE, - handleUndismissed + handleUndismissResponse ); return () => { + dispatcher.removeActionListener( + actions.SET_DISMISS_RESPONSE, + handleDismissResponse + ); dispatcher.removeActionListener( actions.SET_UNDISMISS_RESPONSE, - handleUndismissed + handleUndismissResponse ); }; }, [insightId]); - return { - isDismissalChangeInProgress, - dismiss: () => { - window.sendMessageToDigma({ - action: actions.DISMISS, + const sendAction = useCallback( + (action: string) => { + window.sendMessageToDigma({ + action, payload: { insightId } }); - setIsDismissalChangeInProgress(true); + setIsOperationInProgress(true); }, - show: () => { - window.sendMessageToDigma({ - action: actions.UNDISMISS, - payload: { - insightId: insightId - } - }); - setIsDismissalChangeInProgress(true); - } + [insightId] + ); + + const dismiss = useCallback(() => { + sendAction(actions.DISMISS); + }, [sendAction]); + + const undismiss = useCallback(() => { + sendAction(actions.UNDISMISS); + }, [sendAction]); + + return { + dismiss, + show: undismiss, + data, + isDismissalChangeInProgress: isOperationInProgress }; }; 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 a756601ca..46285d38f 100644 --- a/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/types.ts +++ b/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/types.ts @@ -41,13 +41,7 @@ export interface StyledCardProps extends CardProps { $isReadable?: boolean; } -export interface DismissResponsePayload { - insightId: string; - status: "success" | "failure"; - error?: string; -} - -export interface UndismissResponsePayload { +export interface DismissUndismissResponsePayload { insightId: string; status: "success" | "failure"; error?: string; diff --git a/src/components/Insights/types.ts b/src/components/Insights/types.ts index afd752757..4b22294f8 100644 --- a/src/components/Insights/types.ts +++ b/src/components/Insights/types.ts @@ -679,10 +679,6 @@ export interface ScopedInsightsQuery extends InsightsQuery { export { InsightType }; -export interface DismissInsightPayload { - insightId: string; -} - -export interface UndismissInsightPayload { +export interface DismissUndismissInsightPayload { insightId: string; } diff --git a/src/components/common/FilterPopup/styles.ts b/src/components/common/FilterPopup/styles.ts index fc068f71c..9380f81f8 100644 --- a/src/components/common/FilterPopup/styles.ts +++ b/src/components/common/FilterPopup/styles.ts @@ -15,6 +15,7 @@ export const Container = styled.div` box-shadow: 0 2px 4px 0 rgb(0 0 0 / 29%); font-size: 14px; color: ${({ theme }) => theme.colors.v3.text.tertiary}; + margin: 0 8px; `; export const Header = styled.div` @@ -28,7 +29,7 @@ export const Header = styled.div` export const Footer = styled.div` padding: 8px 0; display: flex; - justify-content: space-between; + gap: 8px; align-items: center; `; diff --git a/src/hooks/useFetchData.test.ts b/src/hooks/useFetchData.test.ts index 14f6c9da5..37836c61e 100644 --- a/src/hooks/useFetchData.test.ts +++ b/src/hooks/useFetchData.test.ts @@ -142,47 +142,51 @@ describe("useFetchData", () => { expect(mockSendMessageToDigma).toHaveBeenCalledTimes(2); }); - it("should fetch data on payload change with interval", () => { - try { - jest.useFakeTimers(); - const { rerender } = setup({ - refreshOnPayloadChange: true, - refreshWithInterval: true, - refreshInterval: 500 - }); - - expect(mockSendMessageToDigma).toHaveBeenCalledTimes(1); - const newPayload = { key: "newValue" }; - rerender({ - config: { - requestAction, - responseAction, - refreshOnPayloadChange: true, - refreshWithInterval: true, - refreshInterval: 500 - }, - payload: newPayload - }); - - expect(mockSendMessageToDigma).toHaveBeenCalledTimes(2); - const handleData = (dispatcher.addActionListener as jest.Mock).mock - .calls[0][1] as ActionListener; // eslint-disable-line @typescript-eslint/no-unsafe-member-access - - act(() => { - handleData({ data: "testData" }, Date.now()); - }); - - act(() => { - jest.advanceTimersByTime(5000); - }); - - expect(mockSendMessageToDigma).toHaveBeenNthCalledWith(3, { - action: requestAction, - payload: newPayload - }); - } finally { - jest.useRealTimers(); - } + it("should fetch data with the new payload on payload change with interval", () => { + jest.useFakeTimers(); + + const refreshInterval = 500; + + const config = { + refreshOnPayloadChange: true, + refreshWithInterval: true, + refreshInterval + }; + + const { rerender } = setup(config); + + expect(mockSendMessageToDigma).toHaveBeenCalledTimes(1); + + const newPayload = { key: "newValue" }; + + rerender({ + config: { + requestAction, + responseAction, + ...config + }, + payload: newPayload + }); + + expect(mockSendMessageToDigma).toHaveBeenCalledTimes(2); + + const handleData = (dispatcher.addActionListener as jest.Mock).mock + .calls[0][1] as ActionListener; // eslint-disable-line @typescript-eslint/no-unsafe-member-access + + act(() => { + handleData({ data: "testData" }, Date.now()); + }); + + act(() => { + jest.advanceTimersByTime(refreshInterval); + }); + + expect(mockSendMessageToDigma).toHaveBeenNthCalledWith(3, { + action: requestAction, + payload: newPayload + }); + + jest.useRealTimers(); }); it("should fetch data on refreshWithInterval change", () => {