FN-5886: show empty usage state after empty fetch
Handle empty usage responses without leaving the panel in its loading skeleton. - add a hasFetched flag to useUsageData so the UI can distinguish initial loading from an empty completed response - update UsageIndicator to render the skeleton only before the first fetch completes and show the empty state afterward - refresh dashboard hook and component tests to cover empty fetches, refresh behavior, and the new loading semantics Files changed: .../dashboard/app/components/UsageIndicator.tsx | 19 +- .../components/__tests__/UsageIndicator.test.tsx | 395 +++++++++++---------- .../app/hooks/__tests__/useUsageData.test.ts | 42 ++- packages/dashboard/app/hooks/useUsageData.ts | 5 + 4 files changed, 245 insertions(+), 216 deletions(-) Fusion-Task-Id: FN-5886 Fusion-Task-Lineage: 8fa901a9-3167-4e33-a082-e5de95f692da
This commit is contained in:
@@ -558,7 +558,7 @@ function UsageSkeleton() {
|
||||
* reset timers, and pace indicators.
|
||||
*/
|
||||
export function UsageIndicator({ isOpen, onClose, projectId, anchorRect }: UsageIndicatorProps) {
|
||||
const { providers, loading, error, lastUpdated, refresh } = useUsageData({
|
||||
const { providers, loading, error, lastUpdated, hasFetched, refresh } = useUsageData({
|
||||
autoRefresh: isOpen, // Only poll when modal is open
|
||||
});
|
||||
|
||||
@@ -581,7 +581,6 @@ export function UsageIndicator({ isOpen, onClose, projectId, anchorRect }: Usage
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const wasOpenRef = useRef(isOpen);
|
||||
const hasCompletedInitialFetchRef = useRef(false);
|
||||
const [savedSize, setSavedSize] = useState<ModalSize | null>(() => getSavedModalSize(projectId));
|
||||
|
||||
useEffect(() => {
|
||||
@@ -616,20 +615,6 @@ export function UsageIndicator({ isOpen, onClose, projectId, anchorRect }: Usage
|
||||
};
|
||||
}, [isOpen, isDesktopViewport, projectId]);
|
||||
|
||||
// Reset initial fetch flag when modal closes to show skeleton on next open
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
hasCompletedInitialFetchRef.current = false;
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Track when initial fetch completes (providers are populated)
|
||||
useEffect(() => {
|
||||
if (providers.length > 0) {
|
||||
hasCompletedInitialFetchRef.current = true;
|
||||
}
|
||||
}, [providers.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
@@ -923,7 +908,7 @@ export function UsageIndicator({ isOpen, onClose, projectId, anchorRect }: Usage
|
||||
</div>
|
||||
|
||||
<div className="usage-content" ref={contentRef}>
|
||||
{(loading || (!hasCompletedInitialFetchRef.current && !error)) && providers.length === 0 ? (
|
||||
{(!hasFetched && !error) && providers.length === 0 ? (
|
||||
<UsageSkeleton />
|
||||
) : error && providers.length === 0 ? (
|
||||
<div className="usage-error">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { useUsageData } from "../useUsageData";
|
||||
import * as api from "../../api";
|
||||
|
||||
@@ -7,7 +7,7 @@ describe("useUsageData", () => {
|
||||
const mockFetchUsageData = vi.spyOn(api, "fetchUsageData");
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetchUsageData.mockClear();
|
||||
mockFetchUsageData.mockReset();
|
||||
});
|
||||
|
||||
it("fetches data on initial mount", async () => {
|
||||
@@ -25,16 +25,16 @@ describe("useUsageData", () => {
|
||||
|
||||
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
|
||||
|
||||
// Should be loading initially
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.providers).toEqual([]);
|
||||
expect(result.current.hasFetched).toBe(false);
|
||||
|
||||
// Wait for data to load
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.providers).toEqual(mockData.providers);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.lastUpdated).toBeInstanceOf(Date);
|
||||
expect(result.current.hasFetched).toBe(true);
|
||||
});
|
||||
|
||||
it("handles fetch errors", async () => {
|
||||
@@ -42,10 +42,13 @@ describe("useUsageData", () => {
|
||||
|
||||
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
|
||||
|
||||
expect(result.current.hasFetched).toBe(false);
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.error).toBe("Network error");
|
||||
expect(result.current.providers).toEqual([]);
|
||||
expect(result.current.hasFetched).toBe(true);
|
||||
});
|
||||
|
||||
it("manual refresh fetches new data", async () => {
|
||||
@@ -64,11 +67,14 @@ describe("useUsageData", () => {
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.providers).toEqual(mockData1.providers);
|
||||
expect(result.current.hasFetched).toBe(true);
|
||||
|
||||
// Manual refresh
|
||||
await result.current.refresh();
|
||||
await act(async () => {
|
||||
await result.current.refresh();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.providers).toEqual(mockData2.providers));
|
||||
expect(result.current.hasFetched).toBe(true);
|
||||
});
|
||||
|
||||
it("clears error on successful manual refresh after error", async () => {
|
||||
@@ -82,12 +88,29 @@ describe("useUsageData", () => {
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.error).toBe("Network error");
|
||||
expect(result.current.hasFetched).toBe(true);
|
||||
|
||||
// Manual refresh
|
||||
await result.current.refresh();
|
||||
await act(async () => {
|
||||
await result.current.refresh();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.error).toBeNull());
|
||||
expect(result.current.providers).toHaveLength(1);
|
||||
expect(result.current.hasFetched).toBe(true);
|
||||
});
|
||||
|
||||
it("sets hasFetched to true after a successful empty fetch", async () => {
|
||||
mockFetchUsageData.mockResolvedValue({ providers: [] });
|
||||
|
||||
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
|
||||
|
||||
expect(result.current.hasFetched).toBe(false);
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.providers).toEqual([]);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.hasFetched).toBe(true);
|
||||
});
|
||||
|
||||
it("exports the correct interface", () => {
|
||||
@@ -95,7 +118,7 @@ describe("useUsageData", () => {
|
||||
});
|
||||
|
||||
it("returns expected default values before first fetch", () => {
|
||||
mockFetchUsageData.mockImplementation(() => new Promise(() => {})); // Never resolves
|
||||
mockFetchUsageData.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
|
||||
|
||||
@@ -103,6 +126,7 @@ describe("useUsageData", () => {
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.lastUpdated).toBeNull();
|
||||
expect(result.current.hasFetched).toBe(false);
|
||||
expect(typeof result.current.refresh).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ interface UsageDataState {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
lastUpdated: Date | null;
|
||||
hasFetched: boolean;
|
||||
}
|
||||
|
||||
interface UseUsageDataOptions {
|
||||
@@ -34,6 +35,7 @@ export function useUsageData(options: UseUsageDataOptions = {}) {
|
||||
loading: true,
|
||||
error: null,
|
||||
lastUpdated: null,
|
||||
hasFetched: false,
|
||||
});
|
||||
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
@@ -57,6 +59,7 @@ export function useUsageData(options: UseUsageDataOptions = {}) {
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
hasFetched: true,
|
||||
});
|
||||
} catch (err) {
|
||||
// Don't update state if the request was aborted
|
||||
@@ -66,6 +69,7 @@ export function useUsageData(options: UseUsageDataOptions = {}) {
|
||||
...prev,
|
||||
loading: false,
|
||||
error: getErrorMessage(err) || "Failed to fetch usage data",
|
||||
hasFetched: true,
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
@@ -112,6 +116,7 @@ export function useUsageData(options: UseUsageDataOptions = {}) {
|
||||
loading: state.loading,
|
||||
error: state.error,
|
||||
lastUpdated: state.lastUpdated,
|
||||
hasFetched: state.hasFetched,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user