diff --git a/packages/manager/.changeset/pr-13575-upcoming-features-1775829047144.md b/packages/manager/.changeset/pr-13575-upcoming-features-1775829047144.md new file mode 100644 index 00000000000..27579c3e595 --- /dev/null +++ b/packages/manager/.changeset/pr-13575-upcoming-features-1775829047144.md @@ -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)) diff --git a/packages/manager/.changeset/pr-13575-upcoming-features-1775829210091.md b/packages/manager/.changeset/pr-13575-upcoming-features-1775829210091.md new file mode 100644 index 00000000000..719e677282c --- /dev/null +++ b/packages/manager/.changeset/pr-13575-upcoming-features-1775829210091.md @@ -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)) diff --git a/packages/manager/package.json b/packages/manager/package.json index e87d106b1bc..6ef000ab630 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -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", diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx index f2a7443d523..1af7cd2259d 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx @@ -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'; @@ -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 ( @@ -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 ( - {isLoading && } + {isLoading && ( + + + {showLoadingIndicator && } + + )} {!hideLabel && ( { + 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(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Utils/useDelayedLoadingIndicator.ts b/packages/manager/src/features/CloudPulse/Utils/useDelayedLoadingIndicator.ts new file mode 100644 index 00000000000..5d6977e40af --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Utils/useDelayedLoadingIndicator.ts @@ -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 && } + * {showLoadingIndicator && ( + * Taking longer than expected... + * )} + * + * ); + * ``` + */ +export const useDelayedLoadingIndicator = ( + isLoading: boolean, + delay: number = 5000 +): boolean => { + const [showLoadingIndicator, setShowLoadingIndicator] = + useState(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; +}; diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx index cfb02502aa5..d463ab873db 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx @@ -13,6 +13,7 @@ import { ENDPOINT, FIREWALL, INTERFACE_ID, + LOADING_DELAYS, NODE_TYPE, NODEBALANCER_ID, PARENT_ENTITY_REGION, @@ -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, @@ -115,6 +118,12 @@ export const CloudPulseDashboardFilterBuilder = React.memo( const dependentFilterReference: React.MutableRefObject = 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) { @@ -549,10 +558,12 @@ export const CloudPulseDashboardFilterBuilder = React.memo( + {showLoadingIndicator && } ) : ( { + if (getResourcesList.length <= VIRTUALIZATION_CONFIG.THRESHOLD) { + return undefined; + } + return React.forwardRef< + HTMLDivElement, + React.HTMLAttributes + >((props, ref) => { + // Extract children and forward to VirtualizedListbox + const { children, ...otherProps } = props; + return ( +
+ {children} +
+ ); + }); + }, [getResourcesList.length]); + return ( ); }} + slotProps={{ + listbox: { + component: ListboxWrapper, + }, + }} textFieldProps={{ ...CLOUD_PULSE_TEXT_FIELD_PROPS, labelTooltipText: tooltipText, diff --git a/packages/manager/src/features/CloudPulse/shared/DelayedLoadingMessage.tsx b/packages/manager/src/features/CloudPulse/shared/DelayedLoadingMessage.tsx new file mode 100644 index 00000000000..fa7f97df854 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/DelayedLoadingMessage.tsx @@ -0,0 +1,24 @@ +import { Typography } from '@linode/ui'; +import React from 'react'; + +export interface DelayedLoadingMessageProps { + /** + * Optional custom message to display. Defaults to standard large dataset message. + */ + message?: string; +} + +/** + * Displays a message to users informing them that loading is taking longer than expected. + * Typically used in conjunction with useDelayedLoadingIndicator hook. + */ +export const DelayedLoadingMessage = React.memo( + ({ message }: DelayedLoadingMessageProps) => { + return ( + + {message || + 'This is taking a bit longer than usual. Loading a large number of entities can take additional time.'} + + ); + } +); diff --git a/packages/manager/src/features/CloudPulse/shared/VirtualizedListBox.tsx b/packages/manager/src/features/CloudPulse/shared/VirtualizedListBox.tsx new file mode 100644 index 00000000000..e8c3f3e8b2b --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/VirtualizedListBox.tsx @@ -0,0 +1,68 @@ +import * as React from 'react'; +import { List, type RowComponentProps } from 'react-window'; + +import { VIRTUALIZATION_CONFIG } from '../Utils/constants'; + +export interface VirtualizedListboxProps { + /** + * The children of the VirtualizedListbox component, which are expected to be the options to be rendered in the list. + */ + children: React.ReactNode; +} + +/** + * A virtualized listbox component that efficiently renders large lists by only + * rendering visible items. Uses react-window for virtualization. + */ +export const VirtualizedListbox = React.memo( + (props: VirtualizedListboxProps) => { + const { children } = props; + + const itemData = React.Children.toArray(children); + const itemCount = itemData.length; + + const calculatedHeight = React.useMemo( + () => + Math.min( + VIRTUALIZATION_CONFIG.MAX_VISIBLE_HEIGHT, + itemCount * VIRTUALIZATION_CONFIG.ITEM_HEIGHT + ), + [itemCount] + ); + + const RowComponent = React.useCallback( + ({ + index, + items, + style, + }: RowComponentProps<{ + items: React.ReactNode[]; + }>) => { + return ( +
+ {items[index]} +
+ ); + }, + [] + ); + + if (itemCount === 0) { + return
    {children}
; + } + + return ( + + ); + } +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11bea13f63e..078e80c26ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -318,6 +318,9 @@ importers: react-waypoint: specifier: ^10.3.0 version: 10.3.0(react@19.1.0) + react-window: + specifier: ^2.2.7 + version: 2.2.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0) recharts: specifier: ^2.14.1 version: 2.15.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -5815,6 +5818,12 @@ packages: peerDependencies: react: ^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + react-window@2.2.7: + resolution: {integrity: sha512-SH5nvfUQwGHYyriDUAOt7wfPsfG9Qxd6OdzQxl5oQ4dsSsUicqQvjV7dR+NqZ4coY0fUn3w1jnC5PwzIUWEg5w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + react@19.1.0: resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} engines: {node: '>=0.10.0'} @@ -12206,6 +12215,11 @@ snapshots: react: 19.1.0 react-is: 18.3.1 + react-window@2.2.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react@19.1.0: {} readdirp@3.6.0: