From 1d2426f88013e31804a977e57167aed23314dac6 Mon Sep 17 00:00:00 2001 From: Nathan Marrs Date: Thu, 20 Nov 2025 03:14:29 -0800 Subject: [PATCH] Dashboard Favorites: Dashboard star icon stuck in loading state on initial load (#114211) Co-Author: Haris Rozajac --- .../features/stars/StarToolbarButton.test.tsx | 41 +++++++++++++++- .../app/features/stars/StarToolbarButton.tsx | 6 +-- public/app/features/stars/hooks.ts | 49 +++++++++++++------ 3 files changed, 77 insertions(+), 19 deletions(-) diff --git a/public/app/features/stars/StarToolbarButton.test.tsx b/public/app/features/stars/StarToolbarButton.test.tsx index 2c3e2e7468d..d4fde427014 100644 --- a/public/app/features/stars/StarToolbarButton.test.tsx +++ b/public/app/features/stars/StarToolbarButton.test.tsx @@ -1,6 +1,7 @@ -import { render, screen, testWithFeatureToggles } from 'test/test-utils'; +import { render, screen, testWithFeatureToggles, waitFor } from 'test/test-utils'; import { GrafanaConfig, locationUtil } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { config, setBackendSrv } from '@grafana/runtime'; import { setupMockServer } from '@grafana/test-utils/server'; import { getFolderFixtures } from '@grafana/test-utils/unstable'; @@ -110,5 +111,43 @@ describe('StarToolbarButton', () => { expect(screen.queryByTestId(expectedTestId)).not.toBeInTheDocument(); }); + + it('shows spinner initially and transitions to empty star when loading completes with no starred items', async () => { + setup(itemToStar); + const button = screen.getByTestId(selectors.components.NavToolbar.markAsFavorite); + + // Initially, button should be disabled (loading state) + expect(button).toBeDisabled(); + + // Wait for loading to complete + await waitFor( + () => { + expect(button).not.toBeDisabled(); + }, + { timeout: 3000 } + ); + + // After loading, should show "Mark as favorite" (empty star state) + expect(await findStarButton(itemToStar.title, false)).toBeInTheDocument(); + }); + + it('shows spinner initially and transitions to filled star when loading completes with starred item', async () => { + setup(existingStarredItem); + const button = screen.getByTestId(selectors.components.NavToolbar.markAsFavorite); + + // Initially, button should be disabled (loading state) + expect(button).toBeDisabled(); + + // Wait for loading to complete + await waitFor( + () => { + expect(button).not.toBeDisabled(); + }, + { timeout: 3000 } + ); + + // After loading, should show "Unmark as favorite" (filled star state) + expect(await findStarButton(existingStarredItem.title, true)).toBeInTheDocument(); + }); }); }); diff --git a/public/app/features/stars/StarToolbarButton.tsx b/public/app/features/stars/StarToolbarButton.tsx index b6af50ce36c..9843f5904d2 100644 --- a/public/app/features/stars/StarToolbarButton.tsx +++ b/public/app/features/stars/StarToolbarButton.tsx @@ -39,7 +39,7 @@ export function StarToolbarButton({ title, group, kind, id, onStarChange }: Prop onStarChange?.(id, !isStarred); }; - const iconProps = (() => { + const iconProps = useMemo(() => { if (isLoading) { return { name: 'spinner', type: 'default' } as const; } @@ -47,7 +47,7 @@ export function StarToolbarButton({ title, group, kind, id, onStarChange }: Prop return { name: 'favorite', type: 'mono' } as const; } return { name: 'star', type: 'default' } as const; - })(); + }, [isLoading, isStarred]); const tooltipAndLabel = (() => { return isStarred @@ -55,7 +55,7 @@ export function StarToolbarButton({ title, group, kind, id, onStarChange }: Prop : { tooltip: tooltips.star, label: isLoading ? undefined : tooltips.starWithTitle }; })(); - const icon = ; + const icon = ; return ( { const name = `user-${contextSrv.user.uid}`; const appPlatform = config.featureToggles.starsFromAPIServer; const legacyResponse = useLegacyGetStarsQuery(appPlatform ? skipToken : undefined); - const appPlatformResponse = useListStarsQuery(!appPlatform ? skipToken : { fieldSelector: `metadata.name=${name}` }); + const queryArgs = !appPlatform ? skipToken : { fieldSelector: `metadata.name=${name}` }; + const appPlatformResponse = useListStarsQuery(queryArgs); const appPlatformStarredItems = useMemo(() => { - const { data } = appPlatformResponse; - if (data) { - const starredItems = appPlatformResponse.data?.items || []; - if (!starredItems.length) { - return []; - } - return starredItems[0]?.spec.resource.find((info) => info.group === group && info.kind === kind)?.names || []; + const { data, isLoading, isUninitialized } = appPlatformResponse; + + // If query hasn't been initiated yet or is still loading, return undefined to show loading state + if (isUninitialized || isLoading) { + return undefined; } - return undefined; + + // If query completed but no data, return empty array + if (!data) { + return []; + } + + const starredItems = appPlatformResponse.data?.items || []; + if (!starredItems.length) { + return []; + } + + return starredItems[0]?.spec.resource.find((info) => info.group === group && info.kind === kind)?.names || []; }, [appPlatformResponse, group, kind]); - return appPlatform - ? { - ...appPlatformResponse, - data: appPlatformStarredItems, - } - : legacyResponse; + if (appPlatform) { + return { + ...appPlatformResponse, + data: appPlatformStarredItems, + // Ensure isLoading is true when data is undefined (still loading or uninitialized) + isLoading: appPlatformStarredItems === undefined ? true : appPlatformResponse.isLoading, + }; + } + + // For legacy response, ensure isLoading is true when query is uninitialized + // RTK Query sets isLoading: false when uninitialized, but we need it to be true + return { + ...legacyResponse, + isLoading: legacyResponse.isUninitialized || legacyResponse.isLoading, + }; }; /**