Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Upcoming Features
---

Add virtualization using react-window for large datasets in dropdown for CloudPulseResources Select in `CloudPulse Metrics` ([#13575](https://github.com/linode/manager/pull/13575))
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Upcoming Features
---

Add a delay loading indicator with message in `CloudPulse Metrics and Alerts` ([#13575](https://github.com/linode/manager/pull/13575))
1 change: 1 addition & 0 deletions packages/manager/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
"react-redux": "~7.1.3",
"react-vnc": "^3.0.7",
"react-waypoint": "^10.3.0",
"react-window": "^2.2.7",
"recharts": "^2.14.1",
"redux": "^4.0.4",
"redux-thunk": "^2.3.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import EntityIcon from 'src/assets/icons/entityIcons/alertsresources.svg';
import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField';
import { useResourcesQuery } from 'src/queries/cloudpulse/resources';

import { DelayedLoadingMessage } from '../../shared/DelayedLoadingMessage';
import { LOADING_DELAYS } from '../../Utils/constants';
import { useDelayedLoadingIndicator } from '../../Utils/useDelayedLoadingIndicator';
import { StyledPlaceholder } from '../AlertsDetail/AlertDetail';
import { MULTILINE_ERROR_SEPARATOR } from '../constants';
import { AlertListNoticeMessages } from '../Utils/AlertListNoticeMessages';
Expand Down Expand Up @@ -369,6 +372,14 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => {
!isDataLoadingError && !isSelectionsNeeded && alertResourceIds.length === 0;
const showEditInformation = isSelectionsNeeded && alertType === 'system';

const isLoading = isRegionsLoading || isResourcesLoading;

// Show loading indicator only if loading continues for more than 10 seconds
const showLoadingIndicator = useDelayedLoadingIndicator(
isLoading,
LOADING_DELAYS.LARGE_DATASET
);

if (isNoResources) {
return (
<Stack gap={2}>
Expand Down Expand Up @@ -406,11 +417,14 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => {
maxSelectionCount && selectedResources
? Math.max(0, maxSelectionCount - selectedResources.length)
: undefined;

const isLoading = isRegionsLoading || isResourcesLoading;
return (
<Stack gap={2}>
{isLoading && <CircleProgress />}
{isLoading && (
<Stack alignItems="center" gap={2}>
<CircleProgress />
{showLoadingIndicator && <DelayedLoadingMessage />}
</Stack>
)}
{!hideLabel && (
<Typography
display={isLoading ? 'none' : 'block'}
Expand Down
24 changes: 24 additions & 0 deletions packages/manager/src/features/CloudPulse/Utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,3 +168,27 @@ export const ASSOCIATED_ENTITY_METRIC_MAP: Record<
linode: 'Linode',
nodebalancer: 'Node Balancer',
};

/**
* Configuration constants for virtualized list rendering
*/
export const VIRTUALIZATION_CONFIG = {
/** Height of each item in the virtualized list (px) */
ITEM_HEIGHT: 36,
/** Maximum visible height of the virtualized list (px) - shows ~8 items */
MAX_VISIBLE_HEIGHT: 389,
/** Minimum number of items before virtualization is enabled */
THRESHOLD: 100,
/** Maximum number of filtered results to show when searching */
FILTER_LIMIT: 1300,
};

/**
* Delay times for loading indicators (milliseconds)
*/
export const LOADING_DELAYS = {
/** Default delay before showing loading indicator */
DEFAULT: 5000,
/** Extended delay for large dataset operations */
LARGE_DATASET: 10000,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { useDelayedLoadingIndicator } from './useDelayedLoadingIndicator';

describe('useDelayedLoadingIndicator', () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});

it('should return false initially when not loading', () => {
const { result } = renderHook(() => useDelayedLoadingIndicator(false));

expect(result.current).toBe(false);
});

it('should return false initially even when loading starts', () => {
const { result } = renderHook(() => useDelayedLoadingIndicator(true));

expect(result.current).toBe(false);
});

it('should return true after default delay (5000ms) when loading', () => {
const { result } = renderHook(() => useDelayedLoadingIndicator(true));

expect(result.current).toBe(false);

act(() => {
vi.advanceTimersByTime(4999);
});

expect(result.current).toBe(false);

act(() => {
vi.advanceTimersByTime(1);
});

expect(result.current).toBe(true);
});

it('should return true after custom delay when provided', () => {
const customDelay = 3000;
const { result } = renderHook(() =>
useDelayedLoadingIndicator(true, customDelay)
);

expect(result.current).toBe(false);

act(() => {
vi.advanceTimersByTime(2999);
});

expect(result.current).toBe(false);

act(() => {
vi.advanceTimersByTime(1);
});

expect(result.current).toBe(true);
});

it('should reset to false immediately when loading completes before delay', () => {
const { result, rerender } = renderHook(
({ isLoading }) => useDelayedLoadingIndicator(isLoading),
{ initialProps: { isLoading: true } }
);

expect(result.current).toBe(false);

act(() => {
vi.advanceTimersByTime(3000);
});

expect(result.current).toBe(false);

// Loading completes before delay
rerender({ isLoading: false });

expect(result.current).toBe(false);
});

it('should reset to false immediately when loading completes after delay', () => {
const { result, rerender } = renderHook(
({ isLoading }) => useDelayedLoadingIndicator(isLoading),
{ initialProps: { isLoading: true } }
);

// Wait for delay to pass
act(() => {
vi.advanceTimersByTime(5000);
});

expect(result.current).toBe(true);

// Loading completes
rerender({ isLoading: false });

expect(result.current).toBe(false);
});

it('should handle rapid loading state changes correctly', () => {
const { result, rerender } = renderHook(
({ isLoading }) => useDelayedLoadingIndicator(isLoading),
{ initialProps: { isLoading: true } }
);

expect(result.current).toBe(false);

// Stop loading before delay
act(() => {
vi.advanceTimersByTime(2000);
});
rerender({ isLoading: false });

expect(result.current).toBe(false);

// Start loading again
rerender({ isLoading: true });

expect(result.current).toBe(false);

// Wait for new delay
act(() => {
vi.advanceTimersByTime(5000);
});

expect(result.current).toBe(true);
});

it('should clear timeout on unmount', () => {
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');

const { unmount } = renderHook(() => useDelayedLoadingIndicator(true));

unmount();

expect(clearTimeoutSpy).toHaveBeenCalled();
});

it('should clear timeout when isLoading changes from true to false', () => {
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');

const { rerender } = renderHook(
({ isLoading }) => useDelayedLoadingIndicator(isLoading),
{ initialProps: { isLoading: true } }
);

rerender({ isLoading: false });

expect(clearTimeoutSpy).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { useEffect, useState } from 'react';

/**
* Custom hook to show a loading indicator only after a specified delay.
* Useful for preventing loading flashes for quick operations while still
* providing feedback for longer-running tasks.
*
* @param isLoading - The loading state to monitor
* @param delay - Delay in milliseconds before showing the indicator (default: 5000)
* @returns boolean indicating whether to show the delayed loading indicator
*
* @example
* ```tsx
* const showLoadingIndicator = useDelayedLoadingIndicator(isLoading);
*
* return (
* <>
* {isLoading && <CircleProgress />}
* {showLoadingIndicator && (
* <Typography>Taking longer than expected...</Typography>
* )}
* </>
* );
* ```
*/
export const useDelayedLoadingIndicator = (
isLoading: boolean,
delay: number = 5000
): boolean => {
const [showLoadingIndicator, setShowLoadingIndicator] =
useState<boolean>(false);

useEffect(() => {
if (!isLoading) {
// Reset the indicator immediately when loading completes
setShowLoadingIndicator(false);
return;
}

// Set a timer to show loading indicator after the specified delay
const timer = setTimeout(() => {
setShowLoadingIndicator(true);
}, delay);

// Clean up timer on unmount or when dependencies change
return () => {
clearTimeout(timer);
};
}, [isLoading, delay]);

return showLoadingIndicator;
};
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
ENDPOINT,
FIREWALL,
INTERFACE_ID,
LOADING_DELAYS,
NODE_TYPE,
NODEBALANCER_ID,
PARENT_ENTITY_REGION,
Expand All @@ -36,7 +37,9 @@ import {
} from '../Utils/FilterBuilder';
import { FILTER_CONFIG } from '../Utils/FilterConfig';
import { type CloudPulseServiceTypeFilters } from '../Utils/models';
import { useDelayedLoadingIndicator } from '../Utils/useDelayedLoadingIndicator';
import { clearChildPreferences } from '../Utils/UserPreference';
import { DelayedLoadingMessage } from './DelayedLoadingMessage';

import type {
CloudPulseMetricsFilter,
Expand Down Expand Up @@ -115,6 +118,12 @@ export const CloudPulseDashboardFilterBuilder = React.memo(
const dependentFilterReference: React.MutableRefObject<CloudPulseMetricsFilter> =
React.useRef({});

// Show loading indicator only if loading continues for more than 10 seconds
const showLoadingIndicator = useDelayedLoadingIndicator(
isLoading,
LOADING_DELAYS.LARGE_DATASET
);

const checkAndUpdateDependentFilters = React.useCallback(
(filterKey: string, value: FilterValueType) => {
if (dashboard && dashboard.service_type) {
Expand Down Expand Up @@ -549,10 +558,12 @@ export const CloudPulseDashboardFilterBuilder = React.memo(
<GridLegacy
alignItems="center"
container
direction="column"
display="flex"
justifyContent="center"
>
<CircleProgress size="md" />
{showLoadingIndicator && <DelayedLoadingMessage />}
</GridLegacy>
) : (
<GridLegacy
Expand Down
Loading
Loading