diff --git a/src/components/Assets/AssetList/AssetList.stories.tsx b/src/components/Assets/AssetList/AssetList.stories.tsx index 29691a9ad..a28006c56 100644 --- a/src/components/Assets/AssetList/AssetList.stories.tsx +++ b/src/components/Assets/AssetList/AssetList.stories.tsx @@ -39,7 +39,6 @@ const mockedConfig: ConfigContextData = { export const Default: Story = { args: { - onAssetCountChange: fn(), setRefresher: fn(), assetTypeId: "Endpoint" }, @@ -63,7 +62,6 @@ export const WithPerformanceImpact: Story = { ) ], args: { - onAssetCountChange: fn(), setRefresher: fn(), assetTypeId: "Endpoint" }, diff --git a/src/components/Assets/AssetList/index.tsx b/src/components/Assets/AssetList/index.tsx index a5db4fcb0..d5a9bae19 100644 --- a/src/components/Assets/AssetList/index.tsx +++ b/src/components/Assets/AssetList/index.tsx @@ -1,12 +1,13 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { DefaultTheme, useTheme } from "styled-components"; -import { DigmaMessageError } from "../../../api/types"; -import { dispatcher } from "../../../dispatcher"; -import { usePrevious } from "../../../hooks/usePrevious"; +import { + DataFetcherConfiguration, + useFetchData +} from "../../../hooks/useFetchData"; +import { useMount } from "../../../hooks/useMount"; import { useAssetsSelector } from "../../../store/assets/useAssetsSelector"; import { useConfigSelector } from "../../../store/config/useConfigSelector"; import { useStore } from "../../../store/useStore"; -import { isEnvironment } from "../../../typeGuards/isEnvironment"; import { SCOPE_CHANGE_EVENTS } from "../../../types"; import { changeScope } from "../../../utils/actions/changeScope"; import { sendUserActionTrackingEvent } from "../../../utils/actions/sendUserActionTrackingEvent"; @@ -20,8 +21,6 @@ import { PopoverTrigger } from "../../common/Popover/PopoverTrigger"; import { ChevronIcon } from "../../common/icons/ChevronIcon"; import { SortIcon } from "../../common/icons/SortIcon"; import { Direction } from "../../common/icons/types"; -import { AssetFilterQuery } from "../AssetsFilter/types"; -import { ViewMode } from "../AssetsViewScopeConfiguration/types"; import { actions } from "../actions"; import { trackingEvents } from "../tracking"; import { checkIfAnyFiltersApplied, getAssetTypeInfo } from "../utils"; @@ -31,13 +30,23 @@ import { AssetEntry, AssetListProps, AssetsData, + GetAssetsListDataPayload, SORTING_CRITERION, - SORTING_ORDER, - Sorting + SORTING_ORDER } from "./types"; const PAGE_SIZE = 10; const REFRESH_INTERVAL = 10 * 1000; // in milliseconds +const requestConfig: DataFetcherConfiguration = { + requestAction: actions.GET_DATA, + responseAction: actions.SET_DATA, + refreshOnPayloadChange: true, + refreshInterval: REFRESH_INTERVAL, + refreshWithInterval: true, + debounceDelay: 10, + refreshWithDebounce: true, + fetchOnMount: true +}; const getSortingMenuChevronColor = (theme: DefaultTheme) => { switch (theme.mode) { @@ -90,36 +99,8 @@ const getSortingCriteria = (isImpactHidden: boolean) => (x) => !(isImpactHidden && x === SORTING_CRITERION.PERFORMANCE_IMPACT) ); -const getData = ( - assetTypeId: string, - page: number, - sorting: Sorting, - searchQuery: string, - filters: AssetFilterQuery, - viewMode: ViewMode, - scopeSpanCodeObjectId?: string -) => { - window.sendMessageToDigma({ - action: actions.GET_DATA, - payload: { - query: { - assetType: assetTypeId, - page, - pageSize: PAGE_SIZE, - sortBy: sorting.criterion, - sortOrder: sorting.order, - directOnly: viewMode === "children", - scopedSpanCodeObjectId: scopeSpanCodeObjectId, - ...(searchQuery.length > 0 ? { displayName: searchQuery } : {}), - ...(scopeSpanCodeObjectId ? { ...filters, services: [] } : filters) - } - } - }); -}; - export const AssetList = ({ assetTypeId, - onAssetCountChange, setRefresher, onGoToAllAssets }: AssetListProps) => { @@ -137,9 +118,6 @@ export const AssetList = ({ setAssetsPage: setPage, setShowAssetsHeaderToolBox } = useStore.getState(); - const previousData = usePrevious(data); - const [lastSetDataTimeStamp, setLastSetDataTimeStamp] = useState(); - const previousLastSetDataTimeStamp = usePrevious(lastSetDataTimeStamp); const [isSortingMenuOpen, setIsSortingMenuOpen] = useState(false); const theme = useTheme(); const sortingMenuChevronColor = getSortingMenuChevronColor(theme); @@ -150,45 +128,66 @@ export const AssetList = ({ filteredCount ); const listRef = useRef(null); - const refreshTimerId = useRef(); + const { environment, backendInfo, scope } = useConfigSelector(); - const previousEnvironment = usePrevious(environment); - const previousViewMode = usePrevious(viewMode); const scopeSpanCodeObjectId = scope?.span?.spanCodeObjectId; - const previousScopeSpanCodeObjectId = usePrevious(scopeSpanCodeObjectId); const isServicesFilterEnabled = !scopeSpanCodeObjectId; const isInitialLoading = !data; - const refreshData = useCallback(() => { - getData( - assetTypeId, + const payload = useMemo( + () => ({ + query: { + assetType: assetTypeId, + page, + pageSize: PAGE_SIZE, + sortBy: sorting.criterion, + sortOrder: sorting.order, + scopedSpanCodeObjectId: scopeSpanCodeObjectId, + ...(search.length > 0 ? { displayName: search } : {}), + ...(scopeSpanCodeObjectId ? { ...filters, services: [] } : filters), + directOnly: viewMode === "children" + } + }), + [ page, - sorting, - search, + assetTypeId, filters, viewMode, - scopeSpanCodeObjectId - ); - }, [ - page, - assetTypeId, - filters, - viewMode, - scopeSpanCodeObjectId, - search, - sorting - ]); + scopeSpanCodeObjectId, + search, + sorting + ] + ); - const entries = data?.data ?? []; + const { data: fetchedData, getData: refreshData } = useFetchData< + GetAssetsListDataPayload, + AssetsData + >(requestConfig, payload); - const assetTypeInfo = getAssetTypeInfo(assetTypeId); + useMount(() => { + setShowAssetsHeaderToolBox(true); + }); + + useEffect(() => { + setRefresher(refreshData); + }, [refreshData, setRefresher]); + useEffect(() => { + if (fetchedData) { + setData(fetchedData); + } + }, [fetchedData, setData]); + + const entries = data?.data ?? []; + const assetTypeInfo = getAssetTypeInfo(assetTypeId); const isImpactHidden = useMemo( () => !(backendInfo?.centralize && environment?.type === "Public"), [backendInfo?.centralize, environment?.type] ); - - const sortingCriteria = getSortingCriteria(isImpactHidden); + const sortingCriteria = useMemo( + () => getSortingCriteria(isImpactHidden), + [isImpactHidden] + ); const areAnyFiltersApplied = checkIfAnyFiltersApplied( filters, @@ -196,69 +195,6 @@ export const AssetList = ({ isServicesFilterEnabled ); - useEffect(() => { - refreshData(); - }, [refreshData]); - - useEffect(() => { - const handleAssetsData = ( - data: unknown, - timeStamp: number, - error: DigmaMessageError | undefined - ) => { - if (!error) { - setData(data as AssetsData); - } - setLastSetDataTimeStamp(timeStamp); - }; - - dispatcher.addActionListener(actions.SET_DATA, handleAssetsData); - setShowAssetsHeaderToolBox(true); - - return () => { - dispatcher.removeActionListener(actions.SET_DATA, handleAssetsData); - window.clearTimeout(refreshTimerId.current); - }; - }, [setData, setShowAssetsHeaderToolBox]); - - useEffect(() => { - if (data && previousData?.filteredCount !== data?.filteredCount) { - onAssetCountChange(data.filteredCount); - } - }, [previousData, data, onAssetCountChange]); - - useEffect(() => { - setRefresher(refreshData); - }, [refreshData, setRefresher]); - - useEffect(() => { - if ( - (isEnvironment(previousEnvironment) && - previousEnvironment.id !== environment?.id) || - viewMode !== previousViewMode || - previousScopeSpanCodeObjectId !== scopeSpanCodeObjectId - ) { - refreshData(); - } - }, [ - environment?.id, - previousEnvironment, - previousViewMode, - viewMode, - scopeSpanCodeObjectId, - previousScopeSpanCodeObjectId, - refreshData - ]); - - useEffect(() => { - if (previousLastSetDataTimeStamp !== lastSetDataTimeStamp) { - window.clearTimeout(refreshTimerId.current); - refreshTimerId.current = window.setTimeout(() => { - refreshData(); - }, REFRESH_INTERVAL); - } - }, [lastSetDataTimeStamp, previousLastSetDataTimeStamp, refreshData]); - useEffect(() => { if ( isImpactHidden && diff --git a/src/components/Assets/AssetList/types.ts b/src/components/Assets/AssetList/types.ts index 5e8918742..fc9248402 100644 --- a/src/components/Assets/AssetList/types.ts +++ b/src/components/Assets/AssetList/types.ts @@ -1,10 +1,10 @@ import { Duration } from "../../../globals"; +import { AssetFilterQuery } from "../AssetsFilter/types"; export interface AssetListProps { onGoToAllAssets: () => void; assetTypeId: string; setRefresher: (refresher: () => void) => void; - onAssetCountChange: (count: number) => void; } export enum SORTING_CRITERION { @@ -71,3 +71,15 @@ export interface AssetsData { totalCount: number; filteredCount: number; } + +export interface GetAssetListDataQuery extends AssetFilterQuery { + assetType: string; + page: number; + pageSize: number; + sortBy: SORTING_CRITERION; + sortOrder: SORTING_ORDER; +} + +export interface GetAssetsListDataPayload { + query: GetAssetListDataQuery; +} diff --git a/src/components/Assets/AssetTypeList/AssetTypeList.stories.tsx b/src/components/Assets/AssetTypeList/AssetTypeList.stories.tsx index 5df7b8391..6e6930ccf 100644 --- a/src/components/Assets/AssetTypeList/AssetTypeList.stories.tsx +++ b/src/components/Assets/AssetTypeList/AssetTypeList.stories.tsx @@ -21,7 +21,6 @@ type Story = StoryObj; // More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args export const Default: Story = { args: { - onAssetCountChange: fn(), setRefresher: fn() }, play: () => { @@ -68,7 +67,6 @@ export const Default: Story = { export const Empty: Story = { args: { - onAssetCountChange: fn(), setRefresher: fn() }, play: () => { @@ -86,7 +84,6 @@ export const Empty: Story = { export const EmptyWithParents: Story = { args: { - onAssetCountChange: fn(), setRefresher: fn() }, play: () => { diff --git a/src/components/Assets/AssetTypeList/index.tsx b/src/components/Assets/AssetTypeList/index.tsx index 6d7e529c8..64143134a 100644 --- a/src/components/Assets/AssetTypeList/index.tsx +++ b/src/components/Assets/AssetTypeList/index.tsx @@ -63,12 +63,8 @@ const getData = ( }); }; -const getAssetCount = (assetCategoriesData: AssetCategoriesData) => - assetCategoriesData.assetCategories.reduce((acc, cur) => acc + cur.count, 0); - export const AssetTypeList = ({ setRefresher, - onAssetCountChange, onAssetTypeSelect }: AssetTypeListProps) => { const { @@ -141,7 +137,6 @@ export const AssetTypeList = ({ useEffect(() => { if (data && previousData !== data) { - onAssetCountChange(getAssetCount(data)); const showNoDataWithParents = Boolean( data?.parents && data.parents.length > 0 && @@ -150,7 +145,7 @@ export const AssetTypeList = ({ setShowAssetsHeaderToolBox(!showNoDataWithParents); setShowNoDataWithParents(showNoDataWithParents); } - }, [previousData, data, onAssetCountChange, setShowAssetsHeaderToolBox]); + }, [previousData, data, setShowAssetsHeaderToolBox]); useEffect(() => { if ( diff --git a/src/components/Assets/AssetTypeList/types.ts b/src/components/Assets/AssetTypeList/types.ts index 522b52510..b4f2e3b07 100644 --- a/src/components/Assets/AssetTypeList/types.ts +++ b/src/components/Assets/AssetTypeList/types.ts @@ -5,7 +5,6 @@ import { IconProps } from "../../common/icons/types"; export interface AssetTypeListProps { onAssetTypeSelect: (assetTypeId: string) => void; setRefresher: (refresher: () => void) => void; - onAssetCountChange: (count: number) => void; } export interface AssetCategoriesData { diff --git a/src/components/Assets/index.tsx b/src/components/Assets/index.tsx index 73e3a6e9c..be77b0f3c 100644 --- a/src/components/Assets/index.tsx +++ b/src/components/Assets/index.tsx @@ -14,6 +14,7 @@ import { NewIconButton } from "../common/v3/NewIconButton"; import { Tooltip } from "../common/v3/Tooltip"; import { AssetList } from "./AssetList"; import { AssetTypeList } from "./AssetTypeList"; +import { AssetCategoriesData } from "./AssetTypeList/types"; import { AssetsFilter } from "./AssetsFilter"; import { AssetsViewScopeConfiguration } from "./AssetsViewScopeConfiguration"; import { NoDataMessage } from "./NoDataMessage"; @@ -23,11 +24,19 @@ import { DataRefresher } from "./types"; const SEARCH_INPUT_DEBOUNCE_DELAY = 1000; // in milliseconds +const getAssetCategoryCount = ( + assetCategoriesData: AssetCategoriesData | null +) => + assetCategoriesData?.assetCategories.reduce( + (acc, cur) => acc + cur.count, + 0 + ) ?? 0; + export const Assets = () => { const [assetsCount, setAssetsCount] = useState(); const params = useParams(); const selectedAssetTypeId = useMemo(() => params.typeId, [params]); - const { search, filters } = useAssetsSelector(); + const { search, filters, assets, assetCategoriesData } = useAssetsSelector(); const { setAssetsSearch: setSearch } = useStore.getState(); const [searchInputValue, setSearchInputValue] = useState(search); const debouncedSearchInputValue = useDebounce( @@ -81,9 +90,13 @@ export const Assets = () => { } }; - const handleAssetCountChange = useCallback((count: number) => { - setAssetsCount(count); - }, []); + useEffect(() => { + setAssetsCount( + !selectedAssetTypeId + ? getAssetCategoryCount(assetCategoriesData) + : assets?.filteredCount + ); + }, [assetCategoriesData, assets, selectedAssetTypeId]); const handleAssetTypeListRefresherChange = useCallback( (refresher: () => void) => { @@ -129,7 +142,6 @@ export const Assets = () => { ); } @@ -139,7 +151,6 @@ export const Assets = () => { onGoToAllAssets={handleGoToAllAssets} assetTypeId={selectedAssetTypeId} setRefresher={handleAssetListRefresherChange} - onAssetCountChange={handleAssetCountChange} /> ); }; diff --git a/src/hooks/useFetchData.test.ts b/src/hooks/useFetchData.test.ts index ec6f1d9e8..14f6c9da5 100644 --- a/src/hooks/useFetchData.test.ts +++ b/src/hooks/useFetchData.test.ts @@ -142,6 +142,49 @@ 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 on refreshWithInterval change", () => { const { rerender } = setup({ refreshWithInterval: false }); diff --git a/src/hooks/useFetchData.ts b/src/hooks/useFetchData.ts index dd86035be..96648f969 100644 --- a/src/hooks/useFetchData.ts +++ b/src/hooks/useFetchData.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { dispatcher } from "../dispatcher"; import { useMount } from "./useMount"; import { usePrevious } from "./usePrevious"; @@ -41,32 +41,61 @@ export const useFetchData = ( refreshWithDebounce = false, debounceDelay = 0 }: DataFetcherConfiguration, - payload?: T + query?: T ) => { const [data, setData] = useState(); const [lastSetDataTimeStamp, setLastSetDataTimeStamp] = useState(); const previousLastSetDataTimeStamp = usePrevious(lastSetDataTimeStamp); const refreshTimerId = useRef(); + const debounceTimerId = useRef(); const previousRequestAction = usePrevious(requestAction); const isRefreshWithIntervalEnabled = useMemo( () => refreshWithInterval && refreshInterval > 0, [refreshWithInterval, refreshInterval] ); + const isDebounceEnabled = useMemo( + () => refreshWithDebounce && debounceDelay >= 0, + [refreshWithDebounce, debounceDelay] + ); const isPreviousRefreshWithIntervalEnabled = usePrevious( isRefreshWithIntervalEnabled ); const previousRefreshInterval = usePrevious(refreshInterval); const previousIsEnabled = usePrevious(isEnabled); + const [payload, setPayload] = useState( + !isDebounceEnabled ? query : undefined + ); const previousPayload = usePrevious(payload); const [isMounted, setIsMounted] = useState(false); useMount(() => { if (isEnabled && fetchOnMount) { - sendMessage(requestAction, payload); + sendMessage(requestAction, query); } setIsMounted(true); + + return () => { + window.clearTimeout(refreshTimerId.current); + window.clearTimeout(debounceTimerId.current); + }; }); + useEffect(() => { + if (!isMounted) { + return; + } + + if (!isDebounceEnabled) { + setPayload(query); + return; + } + + window.clearTimeout(debounceTimerId.current); + debounceTimerId.current = window.setTimeout(() => { + setPayload(query); + }, debounceDelay); + }, [debounceDelay, query, isMounted, isDebounceEnabled]); + // Clear timer and get data on request action change useEffect(() => { if (isEnabled && isMounted && previousRequestAction !== requestAction) { @@ -92,14 +121,7 @@ export const useFetchData = ( previousPayload !== payload ) { window.clearTimeout(refreshTimerId.current); - - if (refreshWithDebounce && debounceDelay >= 0) { - refreshTimerId.current = window.setTimeout(() => { - sendMessage(requestAction, payload); - }, debounceDelay); - } else { - sendMessage(requestAction, payload); - } + sendMessage(requestAction, payload); } }, [ previousPayload, @@ -130,7 +152,6 @@ export const useFetchData = ( isPreviousRefreshWithIntervalEnabled !== isRefreshWithIntervalEnabled ) { window.clearTimeout(refreshTimerId.current); - if (isEnabled && isRefreshWithIntervalEnabled) { sendMessage(requestAction, payload); } @@ -207,12 +228,16 @@ export const useFetchData = ( return () => { dispatcher.removeActionListener(responseAction, handleData); - window.clearTimeout(refreshTimerId.current); }; }, [responseAction, isEnabled]); + const getData = useCallback( + () => sendMessage(requestAction, payload), + [requestAction, payload] + ); + return { data, - getData: () => sendMessage(requestAction, payload) + getData }; };