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.
|
* reset timers, and pace indicators.
|
||||||
*/
|
*/
|
||||||
export function UsageIndicator({ isOpen, onClose, projectId, anchorRect }: UsageIndicatorProps) {
|
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
|
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 contentRef = useRef<HTMLDivElement>(null);
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
const wasOpenRef = useRef(isOpen);
|
const wasOpenRef = useRef(isOpen);
|
||||||
const hasCompletedInitialFetchRef = useRef(false);
|
|
||||||
const [savedSize, setSavedSize] = useState<ModalSize | null>(() => getSavedModalSize(projectId));
|
const [savedSize, setSavedSize] = useState<ModalSize | null>(() => getSavedModalSize(projectId));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -616,20 +615,6 @@ export function UsageIndicator({ isOpen, onClose, projectId, anchorRect }: Usage
|
|||||||
};
|
};
|
||||||
}, [isOpen, isDesktopViewport, projectId]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return;
|
return;
|
||||||
@@ -923,7 +908,7 @@ export function UsageIndicator({ isOpen, onClose, projectId, anchorRect }: Usage
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="usage-content" ref={contentRef}>
|
<div className="usage-content" ref={contentRef}>
|
||||||
{(loading || (!hasCompletedInitialFetchRef.current && !error)) && providers.length === 0 ? (
|
{(!hasFetched && !error) && providers.length === 0 ? (
|
||||||
<UsageSkeleton />
|
<UsageSkeleton />
|
||||||
) : error && providers.length === 0 ? (
|
) : error && providers.length === 0 ? (
|
||||||
<div className="usage-error">
|
<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 { 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 { useUsageData } from "../useUsageData";
|
||||||
import * as api from "../../api";
|
import * as api from "../../api";
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ describe("useUsageData", () => {
|
|||||||
const mockFetchUsageData = vi.spyOn(api, "fetchUsageData");
|
const mockFetchUsageData = vi.spyOn(api, "fetchUsageData");
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockFetchUsageData.mockClear();
|
mockFetchUsageData.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fetches data on initial mount", async () => {
|
it("fetches data on initial mount", async () => {
|
||||||
@@ -25,16 +25,16 @@ describe("useUsageData", () => {
|
|||||||
|
|
||||||
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
|
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
|
||||||
|
|
||||||
// Should be loading initially
|
|
||||||
expect(result.current.loading).toBe(true);
|
expect(result.current.loading).toBe(true);
|
||||||
expect(result.current.providers).toEqual([]);
|
expect(result.current.providers).toEqual([]);
|
||||||
|
expect(result.current.hasFetched).toBe(false);
|
||||||
|
|
||||||
// Wait for data to load
|
|
||||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
expect(result.current.providers).toEqual(mockData.providers);
|
expect(result.current.providers).toEqual(mockData.providers);
|
||||||
expect(result.current.error).toBeNull();
|
expect(result.current.error).toBeNull();
|
||||||
expect(result.current.lastUpdated).toBeInstanceOf(Date);
|
expect(result.current.lastUpdated).toBeInstanceOf(Date);
|
||||||
|
expect(result.current.hasFetched).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles fetch errors", async () => {
|
it("handles fetch errors", async () => {
|
||||||
@@ -42,10 +42,13 @@ describe("useUsageData", () => {
|
|||||||
|
|
||||||
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
|
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
|
||||||
|
|
||||||
|
expect(result.current.hasFetched).toBe(false);
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
expect(result.current.error).toBe("Network error");
|
expect(result.current.error).toBe("Network error");
|
||||||
expect(result.current.providers).toEqual([]);
|
expect(result.current.providers).toEqual([]);
|
||||||
|
expect(result.current.hasFetched).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("manual refresh fetches new data", async () => {
|
it("manual refresh fetches new data", async () => {
|
||||||
@@ -64,11 +67,14 @@ describe("useUsageData", () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
expect(result.current.providers).toEqual(mockData1.providers);
|
expect(result.current.providers).toEqual(mockData1.providers);
|
||||||
|
expect(result.current.hasFetched).toBe(true);
|
||||||
|
|
||||||
// Manual refresh
|
await act(async () => {
|
||||||
await result.current.refresh();
|
await result.current.refresh();
|
||||||
|
});
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.providers).toEqual(mockData2.providers));
|
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 () => {
|
it("clears error on successful manual refresh after error", async () => {
|
||||||
@@ -82,12 +88,29 @@ describe("useUsageData", () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
expect(result.current.error).toBe("Network error");
|
expect(result.current.error).toBe("Network error");
|
||||||
|
expect(result.current.hasFetched).toBe(true);
|
||||||
|
|
||||||
// Manual refresh
|
await act(async () => {
|
||||||
await result.current.refresh();
|
await result.current.refresh();
|
||||||
|
});
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.error).toBeNull());
|
await waitFor(() => expect(result.current.error).toBeNull());
|
||||||
expect(result.current.providers).toHaveLength(1);
|
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", () => {
|
it("exports the correct interface", () => {
|
||||||
@@ -95,7 +118,7 @@ describe("useUsageData", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns expected default values before first fetch", () => {
|
it("returns expected default values before first fetch", () => {
|
||||||
mockFetchUsageData.mockImplementation(() => new Promise(() => {})); // Never resolves
|
mockFetchUsageData.mockImplementation(() => new Promise(() => {}));
|
||||||
|
|
||||||
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
|
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
|
||||||
|
|
||||||
@@ -103,6 +126,7 @@ describe("useUsageData", () => {
|
|||||||
expect(result.current.loading).toBe(true);
|
expect(result.current.loading).toBe(true);
|
||||||
expect(result.current.error).toBeNull();
|
expect(result.current.error).toBeNull();
|
||||||
expect(result.current.lastUpdated).toBeNull();
|
expect(result.current.lastUpdated).toBeNull();
|
||||||
|
expect(result.current.hasFetched).toBe(false);
|
||||||
expect(typeof result.current.refresh).toBe("function");
|
expect(typeof result.current.refresh).toBe("function");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ interface UsageDataState {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
lastUpdated: Date | null;
|
lastUpdated: Date | null;
|
||||||
|
hasFetched: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseUsageDataOptions {
|
interface UseUsageDataOptions {
|
||||||
@@ -34,6 +35,7 @@ export function useUsageData(options: UseUsageDataOptions = {}) {
|
|||||||
loading: true,
|
loading: true,
|
||||||
error: null,
|
error: null,
|
||||||
lastUpdated: null,
|
lastUpdated: null,
|
||||||
|
hasFetched: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
@@ -57,6 +59,7 @@ export function useUsageData(options: UseUsageDataOptions = {}) {
|
|||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
lastUpdated: new Date(),
|
lastUpdated: new Date(),
|
||||||
|
hasFetched: true,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Don't update state if the request was aborted
|
// Don't update state if the request was aborted
|
||||||
@@ -66,6 +69,7 @@ export function useUsageData(options: UseUsageDataOptions = {}) {
|
|||||||
...prev,
|
...prev,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: getErrorMessage(err) || "Failed to fetch usage data",
|
error: getErrorMessage(err) || "Failed to fetch usage data",
|
||||||
|
hasFetched: true,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@@ -112,6 +116,7 @@ export function useUsageData(options: UseUsageDataOptions = {}) {
|
|||||||
loading: state.loading,
|
loading: state.loading,
|
||||||
error: state.error,
|
error: state.error,
|
||||||
lastUpdated: state.lastUpdated,
|
lastUpdated: state.lastUpdated,
|
||||||
|
hasFetched: state.hasFetched,
|
||||||
refresh,
|
refresh,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user