diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Annotation/index.tsx b/ui/src/Components/Grid/AlertGrid/AlertGroup/Annotation/index.tsx index e2a2d54eb..a7f9f432e 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Annotation/index.tsx +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Annotation/index.tsx @@ -2,8 +2,6 @@ import { FC, useEffect, useRef, useState, memo } from "react"; import Linkify from "react-linkify"; -import { CSSTransition } from "react-transition-group"; - import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons/faExternalLinkAlt"; import { faAngleLeft } from "@fortawesome/free-solid-svg-icons/faAngleLeft"; @@ -31,7 +29,7 @@ const RenderNonLinkAnnotation: FC<{ } }); - const { ref, props } = useFlashTransition(value); + const { ref } = useFlashTransition(value); const className = "mb-1 p-1 bg-light d-inline-block rounded components-grid-annotation text-break mw-100"; @@ -60,16 +58,14 @@ const RenderNonLinkAnnotation: FC<{ rel: "noopener noreferrer", }} > - - {allowHTML ? ( - - ) : ( - {value} - )} - + {allowHTML ? ( + + ) : ( + {value} + )} > ) : ( diff --git a/ui/src/Components/Grid/AlertGrid/Grid.tsx b/ui/src/Components/Grid/AlertGrid/Grid.tsx index b72a462b1..1094c63f7 100644 --- a/ui/src/Components/Grid/AlertGrid/Grid.tsx +++ b/ui/src/Components/Grid/AlertGrid/Grid.tsx @@ -189,7 +189,7 @@ const Grid: FC<{ }, [debouncedRepack, onAlertGridCollapseEvent]); useEffect(() => { - repack(); + debouncedRepack(); }); return ( diff --git a/ui/src/Components/Labels/FilteringCounterBadge/index.tsx b/ui/src/Components/Labels/FilteringCounterBadge/index.tsx index 02b0f3c85..661da3198 100644 --- a/ui/src/Components/Labels/FilteringCounterBadge/index.tsx +++ b/ui/src/Components/Labels/FilteringCounterBadge/index.tsx @@ -2,8 +2,6 @@ import { FC, useCallback, MouseEvent } from "react"; import { observer } from "mobx-react-lite"; -import { CSSTransition } from "react-transition-group"; - import type { AlertStore } from "Stores/AlertStore"; import { QueryOperators, FormatQuery } from "Common/Query"; import { TooltipWrapper } from "Components/TooltipWrapper"; @@ -32,7 +30,7 @@ const FilteringCounterBadge: FC<{ defaultColor = "bg-light", isAppend = true, }) => { - const { ref, props } = useFlashTransition(counter); + const { ref } = useFlashTransition(counter); const handleClick = useCallback( (event: MouseEvent) => { @@ -70,24 +68,22 @@ const FilteringCounterBadge: FC<{ - - - {counter} - - + + {counter} + ); }; diff --git a/ui/src/Components/ManagedSilence/SilenceDetails.tsx b/ui/src/Components/ManagedSilence/SilenceDetails.tsx index 3ebd76f24..a931cf6dc 100644 --- a/ui/src/Components/ManagedSilence/SilenceDetails.tsx +++ b/ui/src/Components/ManagedSilence/SilenceDetails.tsx @@ -6,8 +6,6 @@ import { parseISO } from "date-fns/parseISO"; import copy from "copy-to-clipboard"; -import { CSSTransition } from "react-transition-group"; - import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faEdit } from "@fortawesome/free-solid-svg-icons/faEdit"; import { faCalendarCheck } from "@fortawesome/free-solid-svg-icons/faCalendarCheck"; @@ -30,22 +28,20 @@ const SilenceIDCopyButton: FC<{ id: string; }> = ({ id }) => { const [clickCount, setClickCount] = useState(0); - const { ref, props } = useFlashTransition(clickCount); + const { ref } = useFlashTransition(clickCount); return ( - - { - void copy(id); - setClickCount(clickCount + 1); - }} - > - - - + { + void copy(id); + setClickCount(clickCount + 1); + }} + > + + ); }; diff --git a/ui/src/Components/OverviewModal/index.tsx b/ui/src/Components/OverviewModal/index.tsx index 5eaaa3913..54d74be2f 100644 --- a/ui/src/Components/OverviewModal/index.tsx +++ b/ui/src/Components/OverviewModal/index.tsx @@ -2,8 +2,6 @@ import React, { FC, useState, useCallback } from "react"; import { observer } from "mobx-react-lite"; -import { CSSTransition } from "react-transition-group"; - import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faSpinner } from "@fortawesome/free-solid-svg-icons/faSpinner"; @@ -26,22 +24,20 @@ const OverviewModal: FC<{ const toggle = useCallback(() => setIsVisible(!isVisible), [isVisible]); - const { ref, props } = useFlashTransition(alertStore.info.totalAlerts); + const { ref } = useFlashTransition(alertStore.info.totalAlerts); return ( <> - - - {alertStore.info.totalAlerts} - - + + {alertStore.info.totalAlerts} + @@ -89,7 +87,6 @@ const ShareButton: FC<{ onChange={() => {}} /> { void copy(`${baseURL}?m=${silenceFormStore.data.toBase64}`); @@ -97,14 +94,9 @@ const ShareButton: FC<{ }} > - - } - className="d-inline-block" - > - - - + + + diff --git a/ui/src/Hooks/useFlashTransition.test.tsx b/ui/src/Hooks/useFlashTransition.test.tsx index b1d9e5fdd..f85fcebb3 100644 --- a/ui/src/Hooks/useFlashTransition.test.tsx +++ b/ui/src/Hooks/useFlashTransition.test.tsx @@ -1,4 +1,4 @@ -import { act } from "react"; +import { act, ReactNode } from "react"; import { renderHook } from "@testing-library/react"; @@ -6,7 +6,19 @@ import { useInView } from "react-intersection-observer"; import { mockInViewResponse } from "__fixtures__/InView"; -import { useFlashTransition, defaultProps } from "./useFlashTransition"; +import { ThemeContext, ThemeCtx } from "Components/Theme"; + +import { useFlashTransition } from "./useFlashTransition"; + +const animationsOffCtx: ThemeCtx = { + isDark: false, + reactSelectStyles: {}, + animations: { duration: 0 }, +}; + +const animationsOffWrapper = ({ children }: { children: ReactNode }) => ( + {children} +); describe("useFlashTransition", () => { beforeEach(() => { @@ -20,11 +32,12 @@ describe("useFlashTransition", () => { let value = 0; const { result, rerender } = renderHook(() => useFlashTransition(value)); - expect(result.current.props).toMatchObject(defaultProps); + const node = document.createElement("span"); + act(() => result.current.ref(node)); value = 1; rerender(); - expect(result.current.props).toMatchObject(defaultProps); + expect(node.className).toBe(""); }); it("flashes when value changes and element is in viewport", () => { @@ -34,15 +47,44 @@ describe("useFlashTransition", () => { let value = 2; const { result, rerender } = renderHook(() => useFlashTransition(value)); - expect(result.current.props).toMatchObject(defaultProps); + const node = document.createElement("span"); + act(() => result.current.ref(node)); + expect(node.className).toBe(""); value = 3; rerender(); - expect(result.current.props).toMatchObject({ - ...defaultProps, - in: true, - enter: true, - }); + expect(node.className).toBe("components-animation-flash"); + }); + + it("removes the flash class after the animation duration", () => { + (useInView as jest.MockedFunction).mockReturnValue( + mockInViewResponse(true), + ); + + let value = 2; + const { result, rerender } = renderHook(() => useFlashTransition(value)); + const node = document.createElement("span"); + act(() => result.current.ref(node)); + + value = 3; + rerender(); + expect(node.className).toBe("components-animation-flash"); + + act(() => jest.advanceTimersByTime(800)); + expect(node.className).toBe(""); + }); + + it("does not flash on initial mount", () => { + (useInView as jest.MockedFunction).mockReturnValue( + mockInViewResponse(true), + ); + + const { result, rerender } = renderHook(() => useFlashTransition(1)); + const node = document.createElement("span"); + act(() => result.current.ref(node)); + + rerender(); + expect(node.className).toBe(""); }); it("flashes when value changes and element moves into viewport", () => { @@ -52,42 +94,38 @@ describe("useFlashTransition", () => { let value = 2; const { result, rerender } = renderHook(() => useFlashTransition(value)); + const node = document.createElement("span"); + act(() => result.current.ref(node)); value = 3; rerender(); - expect(result.current.props).toMatchObject(defaultProps); + expect(node.className).toBe(""); - act(() => { - (useInView as jest.MockedFunction).mockReturnValue( - mockInViewResponse(true), - ); - }); + (useInView as jest.MockedFunction).mockReturnValue( + mockInViewResponse(true), + ); rerender(); - expect(result.current.props).toMatchObject({ - ...defaultProps, - in: true, - enter: true, - }); + expect(node.className).toBe("components-animation-flash"); }); - it("stops flashing props.onEntered is called", () => { + it("does not flash when animations are disabled", () => { (useInView as jest.MockedFunction).mockReturnValue( mockInViewResponse(true), ); let value = 2; - const { result, rerender } = renderHook(() => useFlashTransition(value)); + const { result, rerender } = renderHook(() => useFlashTransition(value), { + wrapper: animationsOffWrapper, + }); + const node = document.createElement("span"); + act(() => result.current.ref(node)); value = 3; rerender(); - expect(result.current.props).toMatchObject({ - ...defaultProps, - in: true, - enter: true, - }); + expect(node.className).toBe(""); - act(() => result.current.props.onEntered!({} as HTMLElement, false)); - expect(result.current.props).toMatchObject(defaultProps); + act(() => jest.advanceTimersByTime(800)); + expect(node.className).toBe(""); }); it("unmounts cleanly when not flashing", () => { @@ -101,14 +139,19 @@ describe("useFlashTransition", () => { it("unmounts cleanly when flashing", () => { (useInView as jest.MockedFunction).mockReturnValue( - mockInViewResponse(false), + mockInViewResponse(true), ); let value = 5; - const { rerender, unmount } = renderHook(() => useFlashTransition(value)); + const { result, rerender, unmount } = renderHook(() => + useFlashTransition(value), + ); + const node = document.createElement("span"); + act(() => result.current.ref(node)); value = 6; rerender(); + expect(node.className).toBe("components-animation-flash"); unmount(); }); }); diff --git a/ui/src/Hooks/useFlashTransition.ts b/ui/src/Hooks/useFlashTransition.ts index 6e46d3423..6127f459a 100644 --- a/ui/src/Hooks/useFlashTransition.ts +++ b/ui/src/Hooks/useFlashTransition.ts @@ -1,37 +1,30 @@ import { + use, useState, useEffect, useRef, useCallback, ReactNode, - RefObject, } from "react"; -import type { CSSTransitionProps } from "react-transition-group/CSSTransition"; - import { useInView } from "react-intersection-observer"; -const defaultProps: CSSTransitionProps = { - in: false, - classNames: "components-animation-flash", - timeout: 800, - appear: false, - enter: false, - exit: false, -}; +import { ThemeContext } from "Components/Theme"; + +// must match $duration in Styles/Components/_Flash.scss +const flashDuration = 800; const useFlashTransition = ( flashOn: ReactNode, ): { ref: (node: HTMLElement | null) => void; - props: CSSTransitionProps; - nodeRef: RefObject; } => { + const context = use(ThemeContext); const mountRef = useRef(false); const nodeRef = useRef(null); + const timerRef = useRef | null>(null); const [ref, inView] = useInView(); const [isPending, setIsPending] = useState(false); - const [props, setProps] = useState(defaultProps); useEffect(() => { if (mountRef.current) { @@ -42,15 +35,35 @@ const useFlashTransition = ( }, [flashOn]); useEffect(() => { - setProps({ - ...defaultProps, - in: isPending && inView, - enter: isPending && inView, - onEntered: () => setIsPending(false), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - nodeRef: nodeRef as any, - }); - }, [inView, isPending]); + if (!isPending) { + return; + } + // drop stale flashes rather than play them if animations get re-enabled + if (context.animations.duration === 0) { + setIsPending(false); + return; + } + if (inView && nodeRef.current) { + const node = nodeRef.current; + // adding the class starts the CSS animation, removing it after the + // duration passes allows the next flash to restart it without a reflow + node.classList.add("components-animation-flash"); + timerRef.current = setTimeout( + () => node.classList.remove("components-animation-flash"), + flashDuration, + ); + setIsPending(false); + } + }, [inView, isPending, context.animations.duration]); + + useEffect( + () => () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + }, + [], + ); const combinedRef = useCallback( (node: HTMLElement | null) => { @@ -60,7 +73,7 @@ const useFlashTransition = ( [ref], ); - return { ref: combinedRef, props, nodeRef }; + return { ref: combinedRef }; }; -export { useFlashTransition, defaultProps }; +export { useFlashTransition }; diff --git a/ui/src/Styles/Components/_Flash.scss b/ui/src/Styles/Components/_Flash.scss index 60929a6a0..1fc07838e 100644 --- a/ui/src/Styles/Components/_Flash.scss +++ b/ui/src/Styles/Components/_Flash.scss @@ -1,7 +1,6 @@ $duration: 800ms; -.components-animation-flash-appear, -.components-animation-flash-enter { +.components-animation-flash { animation-name: flash; animation-duration: $duration; animation-timing-function: ease-in-out; diff --git a/ui/src/e2e/snapshots/chromium/NavBar.png b/ui/src/e2e/snapshots/chromium/NavBar.png index 4af9d132f..4bcb42d88 100644 Binary files a/ui/src/e2e/snapshots/chromium/NavBar.png and b/ui/src/e2e/snapshots/chromium/NavBar.png differ