diff --git a/apps/dev-playground/client/src/main.tsx b/apps/dev-playground/client/src/main.tsx index 5297b637a..997a14297 100644 --- a/apps/dev-playground/client/src/main.tsx +++ b/apps/dev-playground/client/src/main.tsx @@ -1,3 +1,7 @@ +import { + ResourceStatusIndicator, + ResourceStatusProvider, +} from "@databricks/appkit-ui/react"; import { createRouter, RouterProvider } from "@tanstack/react-router"; import React from "react"; import ReactDOM from "react-dom/client"; @@ -24,6 +28,9 @@ if (!rootElement) { ReactDOM.createRoot(rootElement).render( - + + + + , ); diff --git a/docs/docs/plugins/analytics.md b/docs/docs/plugins/analytics.md index 9fd0b4a30..3198d4340 100644 --- a/docs/docs/plugins/analytics.md +++ b/docs/docs/plugins/analytics.md @@ -100,6 +100,7 @@ const { data, loading, error } = useAnalyticsQuery(queryKey, parameters, options data: T | null; // query result (typed array for JSON, TypedArrowTable for ARROW) loading: boolean; // true while the query is executing error: string | null; // error message, or null on success + warehouseStatus: WarehouseStatus | null; // see "Warehouse readiness" below } ``` @@ -111,6 +112,154 @@ const { data, loading, error } = useAnalyticsQuery(queryKey, parameters, options | `maxParametersSize` | `number` | `102400` | Max serialized parameters size in bytes | | `autoStart` | `boolean` | `true` | Start query on mount | +### Warehouse readiness + +If the configured SQL warehouse is `STOPPED` or `STARTING` when a query is requested, the analytics plugin will: + +1. Auto-start the warehouse (when `STOPPED`). +2. Poll the warehouse state and stream `warehouse_status` events over SSE until it reaches `RUNNING`. +3. Execute the SQL statement. + +This means a cold start no longer freezes the UI on a stalled spinner. Render the new `warehouseStatus` field to give users feedback: + +```tsx +import { useAnalyticsQuery } from "@databricks/appkit-ui/react"; + +function SpendTable() { + const { data, loading, error, warehouseStatus } = + useAnalyticsQuery("spend_summary", params); + + if (warehouseStatus && warehouseStatus.state !== "RUNNING") { + return
Warehouse is {warehouseStatus.state.toLowerCase()}…
; + } + if (loading) return
Loading…
; + if (error) return
{error}
; + return {/* render data */}
; +} +``` + +`warehouseStatus` is `null` until the first status event arrives. After the server has observed the warehouse `RUNNING` once, subsequent requests within ~30s skip the readiness check entirely and `warehouseStatus` stays `null`, so the steady-state hot path isn't taxed any extra round-trips. + +If the warehouse is `DELETED`/`DELETING` or fails to reach `RUNNING` within the configured timeout, the route emits an `error` event (surfaced via the `error` field). + +#### Global readiness indicator + +For dashboards with many charts a per-component spinner isn't enough — wiring the same "warehouse warming up" UI into every skeleton is repetitive. AppKit ships a small generic context (`ResourceStatusProvider`) + drop-in indicator (`ResourceStatusIndicator`) that any plugin can publish into; analytics warehouses are wired up automatically. + +The indicator surfaces the worst pending status as a [sonner](https://sonner.emilkowal.ski/) toast, so it inherits sonner's animations, theming, and stacking. The component mounts its own `` (top-right by default) and forwards its props (`position`, `theme`, `richColors`, …): + +```tsx +import { + ResourceStatusIndicator, + ResourceStatusProvider, +} from "@databricks/appkit-ui/react"; + +export function AppShell({ children }) { + return ( + + + {children} + + ); +} +``` + +`useAnalyticsQuery` registers itself with the nearest provider, so no per-chart wiring is needed. The indicator renders only the `` mount point while every resource is healthy; it pops a single sticky toast — `toast.loading` for cold starts, `toast.error` for unrecoverable states — keyed by the worst kind, and dismisses it when they all settle. Because the same provider is shared across resource kinds (warehouse, lakebase, model serving, …), a single indicator covers every plugin. + +If you already render your own `` for unrelated app toasts, drop the indicator and call `useResourceStatusToaster()` instead so resource-status toasts share that single Toaster: + +```tsx +import { + useResourceStatusToaster, + Toaster, +} from "@databricks/appkit-ui/react"; + +function App() { + useResourceStatusToaster(); + return ( + <> + + + + ); +} +``` + +For a fully custom toast body, pass `render` (rendered through `toast.custom`): + +```tsx + ( +
+ {agg.worst?.kind} {agg.worst?.state.toLowerCase()} ({agg.activeCount} waiting) +
+ )} +/> +``` + +To override copy for a specific kind without rewriting the whole UI, pass `renderers`: + +```tsx + "Spinning up your data", + description: (_s, agg) => + `${agg.affectedLabels.length} chart(s) waiting`, + }, + }} +/> +``` + +Or build your own UI from the aggregate with `useResourceStatus()`: + +```ts +import { useResourceStatus } from "@databricks/appkit-ui/react"; + +// Worst across all kinds +const aggregate = useResourceStatus(); +// Just warehouses +const warehouseOnly = useResourceStatus({ kind: "warehouse" }); +// { worst, byKind, affectedLabels, activeCount, elapsedMs } +``` + +The provider is optional. Apps that don't mount it still get the per-hook `warehouseStatus` field and the hook works exactly as before. + +##### Publishing your own resource status + +Plugins (or your own code) can hook into the same provider for non-analytics resources — e.g. a Lakebase Postgres connection warming up, a model-serving endpoint cold-starting: + +```ts +import { useResourceStatusPublisher } from "@databricks/appkit-ui/react"; +import { useEffect, useId } from "react"; + +function useLakebaseReadiness() { + const id = useId(); + const { publish, unpublish } = useResourceStatusPublisher( + id, + "lakebase", + { kindHint: "lakebase" }, + ); + + useEffect(() => { + publish({ + kind: "lakebase", + state: "STARTING", + severity: "pending", + startedAt: Date.now(), + }); + return () => unpublish(); + }, [publish, unpublish]); +} +``` + +**Server config (in `analytics({...})`):** + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `warehouseStartupTimeoutMs` | `number` | `300000` (5 min) | Maximum time to wait for the warehouse to reach `RUNNING` before failing the request | +| `autoStartWarehouse` | `boolean` | `true` | When `true`, a `STOPPED` warehouse is auto-started on the first request. Set to `false` for cost-controlled deployments where billable warehouse starts must not be triggered by user requests; in that case `STOPPED` surfaces as a `ConfigurationError` | + **Example with loading/error/empty handling:** ```tsx diff --git a/packages/appkit-ui/src/react/charts/index.ts b/packages/appkit-ui/src/react/charts/index.ts index 50b874ea8..0f976f953 100644 --- a/packages/appkit-ui/src/react/charts/index.ts +++ b/packages/appkit-ui/src/react/charts/index.ts @@ -24,6 +24,7 @@ export { } from "../hooks/use-chart-data"; export { BaseChart, type BaseChartProps } from "./base"; export { createChart } from "./create-chart"; +export { LoadingSkeleton, ResourceWaitingPlaceholder } from "./loading"; export { ChartWrapper, type ChartWrapperProps } from "./wrapper"; // ============================================================================ diff --git a/packages/appkit-ui/src/react/charts/loading.tsx b/packages/appkit-ui/src/react/charts/loading.tsx index 3e9846a52..c24e07d31 100644 --- a/packages/appkit-ui/src/react/charts/loading.tsx +++ b/packages/appkit-ui/src/react/charts/loading.tsx @@ -1,3 +1,5 @@ +import { Loader2Icon } from "lucide-react"; + export function LoadingSkeleton({ height = 300, }: { @@ -7,3 +9,30 @@ export function LoadingSkeleton({
); } + +/** + * Non-shimmery placeholder for cold-starting backing resources. Use this + * over {@link LoadingSkeleton} when `useChartData` reports a non-null + * `warehouseStatus` — a shimmer during a 30s–2min wait is misleading. + * The global {@link ResourceStatusIndicator} surfaces the "why". + */ +export function ResourceWaitingPlaceholder({ + height = 300, + message = "Waiting for warehouse…", +}: { + height?: number | string; + message?: string; +}) { + return ( + + + + {message} + + + ); +} diff --git a/packages/appkit-ui/src/react/charts/wrapper.tsx b/packages/appkit-ui/src/react/charts/wrapper.tsx index 2910ff9c8..ed4408add 100644 --- a/packages/appkit-ui/src/react/charts/wrapper.tsx +++ b/packages/appkit-ui/src/react/charts/wrapper.tsx @@ -1,12 +1,30 @@ import type { ReactNode } from "react"; +import type { WarehouseStatus } from "../hooks/types"; import { useChartData } from "../hooks/use-chart-data"; import { ChartErrorBoundary } from "./chart-error-boundary"; import { EmptyState } from "./empty"; import { ErrorState } from "./error"; -import { LoadingSkeleton } from "./loading"; +import { LoadingSkeleton, ResourceWaitingPlaceholder } from "./loading"; import type { ChartData, DataFormat } from "./types"; import { isArrowTable } from "./types"; +const WAREHOUSE_ERROR_STATES = new Set(["DELETED", "DELETING"]); + +function isWarehouseWaiting( + loading: boolean, + data: ChartData | null, + warehouseStatus: WarehouseStatus | null, +): boolean { + if (!loading || data || !warehouseStatus) return false; + if ( + warehouseStatus.state === "RUNNING" || + WAREHOUSE_ERROR_STATES.has(warehouseStatus.state) + ) { + return false; + } + return true; +} + // ============================================================================ // Props Types // ============================================================================ @@ -65,13 +83,16 @@ function QueryModeContent({ testId, children, }: CommonProps & ChartWrapperQueryProps) { - const { data, loading, error, isEmpty } = useChartData({ + const { data, loading, error, isEmpty, warehouseStatus } = useChartData({ queryKey, parameters, format, transformer, }); + if (isWarehouseWaiting(loading, data, warehouseStatus)) { + return ; + } if (loading) return ; if (error) return ; if (isEmpty || !data) return ; diff --git a/packages/appkit-ui/src/react/hooks/__tests__/use-analytics-query.test.ts b/packages/appkit-ui/src/react/hooks/__tests__/use-analytics-query.test.ts index cfe5d6ce9..b2aed2323 100644 --- a/packages/appkit-ui/src/react/hooks/__tests__/use-analytics-query.test.ts +++ b/packages/appkit-ui/src/react/hooks/__tests__/use-analytics-query.test.ts @@ -1,4 +1,4 @@ -import { renderHook } from "@testing-library/react"; +import { act, renderHook, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, test, vi } from "vitest"; // Mock connectSSE so the hook does not attempt a real network request. @@ -90,4 +90,117 @@ describe("useAnalyticsQuery", () => { expect(mockConnectSSE).toHaveBeenCalledTimes(1); }); + + describe("warehouse_status", () => { + test("surfaces warehouseStatus while waiting and clears loading on result", async () => { + // Capture the connectSSE options so we can drive onMessage manually. + let capturedOnMessage: + | ((msg: { id: string; data: string }) => void) + | null = null; + mockConnectSSE.mockImplementationOnce((opts: any) => { + capturedOnMessage = opts.onMessage; + return new Promise(() => {}); + }); + + const { result } = renderHook(() => + // biome-ignore lint/suspicious/noExplicitAny: typed registry not available in tests + useAnalyticsQuery("test_query" as any), + ); + + // Initially: loading, no status, no data. + expect(result.current.loading).toBe(true); + expect(result.current.warehouseStatus).toBeNull(); + expect(result.current.data).toBeNull(); + expect(capturedOnMessage).toBeTruthy(); + + // Server emits a STARTING status — UI should show progress, still loading. + act(() => { + capturedOnMessage?.({ + id: "1", + data: JSON.stringify({ + type: "warehouse_status", + status: { state: "STARTING", elapsedMs: 1200 }, + }), + }); + }); + + await waitFor(() => { + expect(result.current.warehouseStatus).toEqual({ + state: "STARTING", + elapsedMs: 1200, + }); + }); + expect(result.current.loading).toBe(true); + expect(result.current.data).toBeNull(); + + // Then RUNNING. + act(() => { + capturedOnMessage?.({ + id: "2", + data: JSON.stringify({ + type: "warehouse_status", + status: { state: "RUNNING", elapsedMs: 4500 }, + }), + }); + }); + + await waitFor(() => { + expect(result.current.warehouseStatus?.state).toBe("RUNNING"); + }); + + // Finally the SQL result lands. + act(() => { + capturedOnMessage?.({ + id: "3", + data: JSON.stringify({ + type: "result", + data: [{ id: 1, name: "row1" }], + }), + }); + }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + expect(result.current.data).toEqual([{ id: 1, name: "row1" }]); + expect(result.current.error).toBeNull(); + // warehouseStatus is left at its last observed value (RUNNING) so + // consumers that gated on `state !== "RUNNING"` flip back to data. + expect(result.current.warehouseStatus?.state).toBe("RUNNING"); + }); + + test("surfaces an error when a warehouse_status event has no status payload", async () => { + // A malformed frame must terminate the stream so the hook doesn't + // stay stuck in `loading: true` after a clean stream close. + let capturedOnMessage: + | ((msg: { id: string; data: string }) => void) + | null = null; + mockConnectSSE.mockImplementationOnce((opts: any) => { + capturedOnMessage = opts.onMessage; + return new Promise(() => {}); + }); + + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + const { result } = renderHook(() => + // biome-ignore lint/suspicious/noExplicitAny: typed registry not available in tests + useAnalyticsQuery("test_query" as any), + ); + + act(() => { + capturedOnMessage?.({ + id: "1", + data: JSON.stringify({ type: "warehouse_status" }), + }); + }); + + expect(result.current.loading).toBe(false); + expect(result.current.error).toMatch(/Unable to load data/); + expect(result.current.warehouseStatus).toBeNull(); + + consoleError.mockRestore(); + }); + }); }); diff --git a/packages/appkit-ui/src/react/hooks/__tests__/use-analytics-warehouse-status.test.tsx b/packages/appkit-ui/src/react/hooks/__tests__/use-analytics-warehouse-status.test.tsx new file mode 100644 index 000000000..103904423 --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/__tests__/use-analytics-warehouse-status.test.tsx @@ -0,0 +1,440 @@ +import { act, cleanup, render, waitFor } from "@testing-library/react"; +import { afterEach, beforeAll, describe, expect, test, vi } from "vitest"; + +beforeAll(() => { + if (!window.matchMedia) { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + }), + }); + } +}); + +const mockConnectSSE = vi.fn().mockImplementation((_opts: unknown) => { + return new Promise(() => {}); +}); + +vi.mock("@/js", () => ({ + ArrowClient: { + fetchArrow: vi.fn(), + processArrowBuffer: vi.fn(), + }, + connectSSE: (...args: unknown[]) => mockConnectSSE(...args), +})); + +vi.mock("../use-query-hmr", () => ({ + useQueryHMR: () => {}, +})); + +import { ResourceStatusIndicator } from "../../resource-status-indicator"; +import { useAnalyticsQuery } from "../use-analytics-query"; +import { + ResourceStatusProvider, + useResourceStatus, + useResourceStatusPublisher, +} from "../use-resource-status"; + +function queryIndicatorToast(): HTMLElement | null { + return document.querySelector("[data-sonner-toast]"); +} + +/** + * These tests cover the analytics-warehouse adapter end-to-end: the publisher + * inside `useAnalyticsQuery` should map `WarehouseStatus` payloads onto the + * generic resource-status store with the correct kind, severity, and + * `kindHint` registration so kind-filtered consumers see the right shape. + * + * The generic store itself is exercised in `use-resource-status.test.tsx`; + * we don't re-test it here. + */ +describe("useAnalyticsQuery + ResourceStatusProvider integration", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("does not show the indicator toast before a real warehouse status arrives", async () => { + mockConnectSSE.mockImplementationOnce(() => new Promise(() => {})); + + function Chart() { + useAnalyticsQuery("chart_one" as any); + return null; + } + + render( + + + + , + ); + + await waitFor(() => { + expect(queryIndicatorToast()).toBeNull(); + }); + }); + + test("does not show the indicator toast when the first warehouse status is RUNNING", async () => { + let onMessage: ((msg: { id: string; data: string }) => void) | null = null; + mockConnectSSE.mockImplementationOnce((opts: any) => { + onMessage = opts.onMessage; + return new Promise(() => {}); + }); + + function Chart() { + useAnalyticsQuery("chart_one" as any); + return null; + } + + render( + + + + , + ); + + act(() => { + onMessage?.({ + id: "1", + data: JSON.stringify({ + type: "warehouse_status", + status: { state: "RUNNING", elapsedMs: 0 }, + }), + }); + }); + + await waitFor(() => { + expect(queryIndicatorToast()).toBeNull(); + }); + }); + + test("registers a warehouse-kind slot on mount even before any status arrives", async () => { + mockConnectSSE.mockImplementationOnce(() => new Promise(() => {})); + + function Chart() { + useAnalyticsQuery("chart_one" as any); + return null; + } + + function Aggregate() { + const agg = useResourceStatus({ kind: "warehouse" }); + return {agg.activeCount}; + } + + const { getByTestId } = render( + + + + , + ); + + // The hook publishes `null` on mount via the kindHint, so a kind-filtered + // consumer counts the slot before the first SSE event lands. + await waitFor(() => { + expect(getByTestId("active").textContent).toBe("1"); + }); + }); + + test("maps STARTING to a pending warehouse status and surfaces it as worst", async () => { + let onMessage: ((msg: { id: string; data: string }) => void) | null = null; + mockConnectSSE.mockImplementationOnce((opts: any) => { + onMessage = opts.onMessage; + return new Promise(() => {}); + }); + + function Chart() { + useAnalyticsQuery("chart_one" as any); + return null; + } + + function Aggregate() { + const agg = useResourceStatus({ kind: "warehouse" }); + return ( + <> + {agg.worst?.state ?? "null"} + {agg.worst?.severity ?? "null"} + {agg.affectedLabels.join(",")} + + ); + } + + const { getByTestId } = render( + + + + , + ); + + act(() => { + onMessage?.({ + id: "1", + data: JSON.stringify({ + type: "warehouse_status", + status: { state: "STARTING", elapsedMs: 1200 }, + }), + }); + }); + + await waitFor(() => { + expect(getByTestId("state").textContent).toBe("STARTING"); + }); + expect(getByTestId("severity").textContent).toBe("pending"); + expect(getByTestId("labels").textContent).toBe("chart_one"); + }); + + test("maps DELETED to error severity", async () => { + let onMessage: ((msg: { id: string; data: string }) => void) | null = null; + mockConnectSSE.mockImplementationOnce((opts: any) => { + onMessage = opts.onMessage; + return new Promise(() => {}); + }); + + function Chart() { + useAnalyticsQuery("chart_one" as any); + return null; + } + + function Aggregate() { + const agg = useResourceStatus({ kind: "warehouse" }); + return ( + {agg.worst?.severity ?? "null"} + ); + } + + const { getByTestId } = render( + + + + , + ); + + act(() => { + onMessage?.({ + id: "1", + data: JSON.stringify({ + type: "warehouse_status", + status: { state: "DELETED", elapsedMs: 0 }, + }), + }); + }); + + await waitFor(() => { + expect(getByTestId("severity").textContent).toBe("error"); + }); + }); + + test("clears the slot's status when RUNNING arrives but keeps it registered", async () => { + let onMessage: ((msg: { id: string; data: string }) => void) | null = null; + mockConnectSSE.mockImplementationOnce((opts: any) => { + onMessage = opts.onMessage; + return new Promise(() => {}); + }); + + function Chart() { + useAnalyticsQuery("chart_one" as any); + return null; + } + + function Aggregate() { + const agg = useResourceStatus({ kind: "warehouse" }); + return ( + <> + {agg.activeCount} + {agg.worst?.state ?? "null"} + + ); + } + + const { getByTestId } = render( + + + + , + ); + + act(() => { + onMessage?.({ + id: "1", + data: JSON.stringify({ + type: "warehouse_status", + status: { state: "STARTING", elapsedMs: 100 }, + }), + }); + }); + await waitFor(() => { + expect(getByTestId("worst").textContent).toBe("STARTING"); + }); + + act(() => { + onMessage?.({ + id: "2", + data: JSON.stringify({ + type: "warehouse_status", + status: { state: "RUNNING", elapsedMs: 4500 }, + }), + }); + }); + + // RUNNING clears the entry's status (no longer the worst) but the slot + // stays registered until the query completes or unmounts. + await waitFor(() => { + expect(getByTestId("worst").textContent).toBe("null"); + }); + expect(getByTestId("active").textContent).toBe("1"); + }); + + test("anchors startedAt to the first non-null status so elapsed advances monotonically", async () => { + let onMessage: ((msg: { id: string; data: string }) => void) | null = null; + mockConnectSSE.mockImplementationOnce((opts: any) => { + onMessage = opts.onMessage; + return new Promise(() => {}); + }); + + function Chart() { + useAnalyticsQuery("chart_one" as any); + return null; + } + + const seen: number[] = []; + function Aggregate() { + const agg = useResourceStatus({ kind: "warehouse" }); + const startedAt = agg.worst?.startedAt; + if (startedAt !== undefined && seen[seen.length - 1] !== startedAt) { + seen.push(startedAt); + } + return ( + {agg.worst?.startedAt ?? "null"} + ); + } + + render( + + + + , + ); + + act(() => { + onMessage?.({ + id: "1", + data: JSON.stringify({ + type: "warehouse_status", + status: { state: "STARTING", elapsedMs: 1000 }, + }), + }); + }); + await waitFor(() => expect(seen.length).toBe(1)); + + act(() => { + onMessage?.({ + id: "2", + data: JSON.stringify({ + type: "warehouse_status", + status: { state: "STARTING", elapsedMs: 4500 }, + }), + }); + }); + act(() => { + onMessage?.({ + id: "3", + data: JSON.stringify({ + type: "warehouse_status", + status: { state: "STARTING", elapsedMs: 9000 }, + }), + }); + }); + + // All three events share the same anchored startedAt. + expect(seen).toHaveLength(1); + }); + + test("kindHint-only mutation notifies kind-filtered consumers via the version counter", async () => { + // A status-less publish (toggling kindHint) bumps `version` so + // kind-filtered consumers re-derive even when no aggregate field changes. + function Probe() { + const agg = useResourceStatus({ kind: "warehouse" }); + return {agg.version}; + } + + const publisherStore: { + current: ((status: null) => void) | null; + } = { current: null }; + function Publisher() { + const { publish } = useResourceStatusPublisher("p1", "label", { + kindHint: "warehouse", + }); + publisherStore.current = publish as (status: null) => void; + return null; + } + + const { getByTestId } = render( + + + + , + ); + + const v0 = Number(getByTestId("version").textContent); + act(() => publisherStore.current?.(null)); + await waitFor(() => { + expect(Number(getByTestId("version").textContent)).toBeGreaterThan(v0); + }); + }); + + test("unmounts release the slot from the kind-filtered aggregate", async () => { + let onMessage: ((msg: { id: string; data: string }) => void) | null = null; + mockConnectSSE.mockImplementationOnce((opts: any) => { + onMessage = opts.onMessage; + return new Promise(() => {}); + }); + + function Chart() { + useAnalyticsQuery("chart_one" as any); + return null; + } + + function Aggregate() { + const agg = useResourceStatus({ kind: "warehouse" }); + return {agg.activeCount}; + } + + function App({ showChart }: { showChart: boolean }) { + return ( + + {showChart ? : null} + + + ); + } + + const { rerender, getByTestId } = render(); + + act(() => { + onMessage?.({ + id: "1", + data: JSON.stringify({ + type: "warehouse_status", + status: { state: "STARTING", elapsedMs: 100 }, + }), + }); + }); + + await waitFor(() => { + expect(getByTestId("active").textContent).toBe("1"); + }); + + rerender(); + + await waitFor(() => { + expect(getByTestId("active").textContent).toBe("0"); + }); + }); +}); diff --git a/packages/appkit-ui/src/react/hooks/__tests__/use-resource-status.test.tsx b/packages/appkit-ui/src/react/hooks/__tests__/use-resource-status.test.tsx new file mode 100644 index 000000000..a70ad8fa4 --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/__tests__/use-resource-status.test.tsx @@ -0,0 +1,640 @@ +import { + act, + cleanup, + render, + renderHook, + waitFor, +} from "@testing-library/react"; +import { toast } from "sonner"; +import { afterEach, beforeAll, describe, expect, test, vi } from "vitest"; + +import { ResourceStatusIndicator } from "../../resource-status-indicator"; + +// JSDOM doesn't implement window.matchMedia, which sonner reads on mount. +beforeAll(() => { + if (!window.matchMedia) { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + }), + }); + } +}); + +import { + type ResourceStatus, + ResourceStatusProvider, + useResourceStatus, + useResourceStatusPublisher, +} from "../use-resource-status"; + +afterEach(() => { + cleanup(); + // Sonner's toast store is module-level; flush between tests. + toast.dismiss(); +}); + +/** Find the (single) indicator toast in the document, or null. */ +function queryIndicatorToast(): HTMLElement | null { + return document.querySelector("[data-sonner-toast]"); +} + +/** Wait until exactly one indicator toast is mounted and return it. */ +async function findIndicatorToast(): Promise { + return waitFor(() => { + const node = queryIndicatorToast(); + if (!node) throw new Error("indicator toast not mounted yet"); + return node; + }); +} + +function makeStatus( + overrides: Partial & Pick, +): ResourceStatus { + return { + severity: "pending", + startedAt: Date.now(), + ...overrides, + }; +} + +describe("useResourceStatus / useResourceStatusPublisher", () => { + test("returns the empty/idle aggregate when no provider is mounted", () => { + const { result } = renderHook(() => useResourceStatus()); + expect(result.current).toEqual({ + worst: null, + byKind: {}, + affectedLabels: [], + activeCount: 0, + elapsedMs: 0, + version: 0, + }); + }); + + test("aggregates statuses across kinds, picking the worst by severity", async () => { + function Publishers() { + const { publish: pubA } = useResourceStatusPublisher("a", "lakebase-a"); + const { publish: pubB } = useResourceStatusPublisher("b", "warehouse-b"); + const aggregate = useResourceStatus(); + return ( +
+ + {aggregate.worst?.kind ?? "none"} + + + {aggregate.worst?.state ?? "none"} + + {aggregate.activeCount} + {aggregate.affectedLabels.join(",")} +
+ ); + } + + const { getByTestId } = render( + + + , + ); + + // a: lakebase pending + act(() => { + getByTestId("pub-pending-a").click(); + }); + await waitFor(() => { + expect(getByTestId("worst-kind").textContent).toBe("lakebase"); + }); + expect(getByTestId("worst-state").textContent).toBe("STARTING"); + expect(getByTestId("active").textContent).toBe("1"); + expect(getByTestId("labels").textContent).toBe("lakebase-a"); + + // b: warehouse error → outranks pending + act(() => { + getByTestId("pub-error-b").click(); + }); + await waitFor(() => { + expect(getByTestId("worst-kind").textContent).toBe("warehouse"); + }); + expect(getByTestId("worst-state").textContent).toBe("DELETED"); + expect(getByTestId("active").textContent).toBe("2"); + expect(getByTestId("labels").textContent).toBe("lakebase-a,warehouse-b"); + }); + + test("filters the aggregate to a single kind", async () => { + function Publishers() { + const { publish: pubA } = useResourceStatusPublisher("a", "lakebase-a"); + const { publish: pubB } = useResourceStatusPublisher("b", "warehouse-b"); + const warehouseAgg = useResourceStatus({ kind: "warehouse" }); + return ( +
+ + {warehouseAgg.worst?.state ?? "none"} + + {warehouseAgg.activeCount} +
+ ); + } + + const { getByTestId } = render( + + + , + ); + + // Lakebase publishes — warehouse-scoped aggregate stays empty. + act(() => { + getByTestId("pub-lakebase").click(); + }); + await waitFor(() => { + expect(getByTestId("warehouse-state").textContent).toBe("none"); + }); + + // Warehouse publishes — warehouse-scoped aggregate lights up. + act(() => { + getByTestId("pub-warehouse").click(); + }); + await waitFor(() => { + expect(getByTestId("warehouse-state").textContent).toBe("STOPPED"); + }); + expect(getByTestId("warehouse-active").textContent).toBe("1"); + }); + + test("kindHint keeps null-status slots associated with their kind", async () => { + function Publisher() { + const { publish } = useResourceStatusPublisher("a", "x", { + kindHint: "lakebase", + }); + const lakebase = useResourceStatus({ kind: "lakebase" }); + return ( +
+ {lakebase.activeCount} + + {lakebase.worst?.state ?? "none"} + +
+ ); + } + + const { getByTestId } = render( + + + , + ); + + act(() => { + getByTestId("register").click(); + }); + await waitFor(() => { + expect(getByTestId("lakebase-active").textContent).toBe("1"); + expect(getByTestId("lakebase-state").textContent).toBe("none"); + }); + }); + + test("unpublish removes the entry from the aggregate", async () => { + function App() { + const { publish, unpublish } = useResourceStatusPublisher("a", "x"); + const aggregate = useResourceStatus(); + return ( +
+ {aggregate.activeCount} + {aggregate.worst?.state ?? "none"} +
+ ); + } + + const { getByTestId } = render( + + + , + ); + + act(() => { + getByTestId("pub").click(); + }); + await waitFor(() => { + expect(getByTestId("state").textContent).toBe("STOPPED"); + }); + + act(() => { + getByTestId("unpub").click(); + }); + await waitFor(() => { + expect(getByTestId("state").textContent).toBe("none"); + }); + expect(getByTestId("active").textContent).toBe("0"); + }); +}); + +describe("ResourceStatusIndicator", () => { + test("mounts no toast when the aggregate is empty", () => { + render( + + + , + ); + expect(queryIndicatorToast()).toBeNull(); + }); + + test("renders kind-specific copy for known kinds (warehouse)", async () => { + function Trigger() { + const { publish } = useResourceStatusPublisher("a", "my_chart"); + return ( +
+ ); + } + + const { getByTestId } = render( + + + + , + ); + + act(() => { + getByTestId("pub-pending").click(); + }); + const loading = await findIndicatorToast(); + expect(loading.getAttribute("data-type")).toBe("loading"); + + act(() => { + getByTestId("pub-error").click(); + }); + await waitFor(() => { + expect(queryIndicatorToast()?.getAttribute("data-type")).toBe("error"); + }); + expect(queryIndicatorToast()?.textContent).toMatch(/unavailable|Gone/i); + }); + + test("kind prop scopes to a single kind", async () => { + function Trigger() { + const { publish: pubLake } = useResourceStatusPublisher("a", "x"); + const { publish: pubWh } = useResourceStatusPublisher("b", "y"); + return ( +
+
+ ); + } + + const { getByTestId } = render( + + + + , + ); + + // Lakebase pending — warehouse-scoped indicator stays silent. + act(() => { + getByTestId("pub-lake").click(); + }); + expect(queryIndicatorToast()).toBeNull(); + + // Warehouse pending — toast appears. + act(() => { + getByTestId("pub-wh").click(); + }); + const node = await findIndicatorToast(); + expect(node.textContent).toMatch(/warehouse|warming/i); + }); + + test("supports a full custom render override", async () => { + function Trigger() { + const { publish } = useResourceStatusPublisher("a", "x"); + return ( +