From 2c3fa22017fb494a21954198697c23acf6fdbf31 Mon Sep 17 00:00:00 2001 From: galuis116 Date: Thu, 16 Jul 2026 08:22:47 -0400 Subject: [PATCH] feat(ui-kit): port state-views.tsx primitives into @loopover/ui-kit Spinner, LoadingState, EmptyState, StateActionButton, ErrorState, and StateBoundary move to packages/loopover-ui-kit/src/components/state-views.tsx so apps/loopover-miner-ui (and the future chat message list, per #6244's audit) can consume them directly instead of reaching into apps/loopover-ui. apps/loopover-ui's own state-views.tsx becomes a back-compat wrapper: Spinner/LoadingState/EmptyState/StateActionButton/ErrorState re-export unchanged, StateBoundary forwards every prop and defaults onFailureNotify to the app's real notifyApiFailure, and usePreviewDataState (unrelated to this move, zero call sites) stays put. Every existing call site's runtime behavior is unchanged -- confirmed by the app's own state-views.test.tsx (14/14 passing with zero edits) and the broader site/routes suite (230/230 passing). The ui-kit copy can't import from apps/loopover-ui, so it defines its own ApiFailureKind (structurally identical to request.ts's) and replaces the hardcoded notifyApiFailure call with an optional onFailureNotify prop that no-ops when the caller doesn't supply one -- the wrapper passes it as a lazily-dereferenced closure (not a direct reference) so tests that partially mock @/lib/api/request without notifyApiFailure, and never exercise the error path, keep working exactly as before. Closes #6506 --- .../src/components/site/state-views.tsx | 338 ++---------------- .../src/components/state-views.tsx | 330 +++++++++++++++++ 2 files changed, 352 insertions(+), 316 deletions(-) create mode 100644 packages/loopover-ui-kit/src/components/state-views.tsx diff --git a/apps/loopover-ui/src/components/site/state-views.tsx b/apps/loopover-ui/src/components/site/state-views.tsx index c860be86ff..c142ba9c9c 100644 --- a/apps/loopover-ui/src/components/site/state-views.tsx +++ b/apps/loopover-ui/src/components/site/state-views.tsx @@ -1,209 +1,27 @@ -import { Loader2, Inbox, AlertTriangle, RefreshCw, WifiOff } from "lucide-react"; -import { useCallback, useEffect, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useState, type ComponentProps } from "react"; import { toast } from "sonner"; -import { cn } from "@/lib/utils"; -import { notifyApiFailure, type ApiFailureKind } from "@/lib/api/request"; - -/** `errorKind`s that mean "we never reached the server" as opposed to "the server answered with an - * error" — StateBoundary/ErrorState use this to show connectivity-specific copy and iconography (#793). */ -const NETWORK_ERROR_KINDS: ReadonlySet = new Set(["network", "timeout"]); - -/** - * Shared state primitives so every panel/route renders a consistent - * loading / empty / error surface. All animations respect prefers-reduced-motion. - */ - -export function Spinner({ className }: { className?: string }) { - return ( - - ); -} - -function Shell({ - icon, - title, - description, - action, - secondaryAction, - className, - role, -}: { - icon: ReactNode; - title: string; - description?: ReactNode; - action?: ReactNode; - secondaryAction?: ReactNode; - className?: string; - role?: "status" | "alert"; -}) { - return ( -
-
{icon}
-
{title}
- {description && ( -
{description}
- )} - {(action || secondaryAction) && ( -
- {action} - {secondaryAction} -
- )} -
- ); -} - -export function LoadingState({ - title = "Loading…", - description, - className, -}: { - title?: string; - description?: ReactNode; - className?: string; -}) { - return ( - } - title={title} - description={description} - className={className} - /> - ); -} - -export function EmptyState({ - title = "Nothing here yet", - description, - action, - secondaryAction, - className, -}: { - title?: string; - description?: ReactNode; - action?: ReactNode; - secondaryAction?: ReactNode; - className?: string; -}) { - return ( - } - title={title} - description={description} - action={action} - secondaryAction={secondaryAction} - className={className} - /> - ); -} - -export function StateActionButton({ - children, - onClick, - disabled, - icon, - variant = "outline", -}: { - children: ReactNode; - onClick?: () => void; - disabled?: boolean; - icon?: ReactNode; - variant?: "outline" | "primary"; -}) { - return ( - - ); -} - -export function ErrorState({ - title, - description, - errorKind, - onRetry, - retryLabel = "Try again", - secondaryAction, - toastOnRetry = true, - className, -}: { - title?: string; - description?: ReactNode; - /** Distinguishes "couldn't reach the server at all" from "the server answered with an error" (#793). - * Only changes the default `title`/`description` — an explicit `title`/`description` always wins. */ - errorKind?: ApiFailureKind; - onRetry?: () => void; - retryLabel?: string; - secondaryAction?: ReactNode; - toastOnRetry?: boolean; - className?: string; -}) { - const isNetworkIssue = errorKind !== undefined && NETWORK_ERROR_KINDS.has(errorKind); - const resolvedTitle = title ?? (isNetworkIssue ? "Can't reach the server" : "Couldn't load this"); - const resolvedDescription = - description ?? - (isNetworkIssue - ? "We couldn't reach the API — check your connection and retry. This is a connectivity issue, not a problem with the data itself." - : "Something went wrong fetching this data. You can retry, or come back in a moment. If this keeps happening, check status or try again shortly."); - return ( - - ) : ( - - ) - } - title={resolvedTitle} - description={resolvedDescription} - action={ - onRetry && ( - { - if (toastOnRetry) { - toast("Retrying…", { - description: "We’ll request the data again and keep this view in place.", - }); - } - onRetry(); - }} - icon={} - > - {retryLabel} - - ) - } - secondaryAction={secondaryAction} - className={className} - /> - ); +import { notifyApiFailure } from "@/lib/api/request"; +import { + Spinner, + LoadingState, + EmptyState, + StateActionButton, + ErrorState, + StateBoundary as UiKitStateBoundary, +} from "@loopover/ui-kit/components/state-views"; + +export { Spinner, LoadingState, EmptyState, StateActionButton, ErrorState }; + +/** Forwards every prop to the ui-kit primitive, defaulting `onFailureNotify` to this app's real + * `notifyApiFailure` singleton so every existing call site's runtime behavior stays byte-identical + * to before the state-views port (#6506) -- a caller can still override it explicitly. Wrapped in an + * arrow function (not passed directly) so `notifyApiFailure` is only dereferenced when the ui-kit + * boundary actually calls it (isError && errorLabel), matching the original's lazy reference -- + * several existing tests partially mock `@/lib/api/request` without `notifyApiFailure` and never + * exercise the error path, so an eager reference here would break them. */ +export function StateBoundary(props: ComponentProps) { + return notifyApiFailure(args)} {...props} />; } export function usePreviewDataState(label: string, delay = 220) { @@ -232,115 +50,3 @@ export function usePreviewDataState(label: string, delay = 220) { return { isLoading, refresh, retry }; } - -export function StateBoundary({ - isLoading, - isError, - isEmpty, - loadingTitle = "Loading data…", - loadingDescription = "Fetching the latest available signals for this view.", - loadingSkeleton, - emptyTitle = "No data available yet", - emptyDescription = "This view has no records to show. Refresh when the source data is available.", - errorTitle, - errorDescription, - errorKind, - onRetry, - onRefresh, - errorLabel, - children, -}: { - isLoading?: boolean; - isError?: boolean; - isEmpty?: boolean; - loadingTitle?: string; - loadingDescription?: ReactNode; - /** Content-shaped placeholder (build with `@/components/ui/skeleton`) shown instead of the generic - * spinner while loading, so the layout doesn't jump once data arrives (#793). */ - loadingSkeleton?: ReactNode; - emptyTitle?: string; - emptyDescription?: ReactNode; - errorTitle?: string; - errorDescription?: ReactNode; - /** Distinguishes "couldn't reach the server at all" from "the server answered with an error" (#793). */ - errorKind?: ApiFailureKind; - onRetry?: () => void; - onRefresh?: () => void; - /** When provided, surfaces a global API-failure toast with a Retry action. */ - errorLabel?: string; - children: ReactNode; -}) { - const isNetworkIssue = errorKind !== undefined && NETWORK_ERROR_KINDS.has(errorKind); - // Preserve the pre-#793 defaults exactly when no errorKind is given; when one is given and the caller - // didn't override the copy, fall through to ErrorState's own network-aware defaults instead. - const resolvedErrorTitle = errorTitle ?? (isNetworkIssue ? undefined : "Couldn't load data"); - const resolvedErrorDescription = - errorDescription ?? - (isNetworkIssue - ? undefined - : "The data source did not respond. Retry the request, or check back once the service has recovered."); - - // When this boundary flips into the error state, surface a toast with Retry. - useEffect(() => { - if (isError && errorLabel) { - notifyApiFailure({ - label: errorLabel, - kind: errorKind ?? "network", - message: - typeof resolvedErrorDescription === "string" - ? resolvedErrorDescription - : "Data source did not respond.", - retry: onRetry, - }); - } - }, [isError, errorLabel, errorKind, resolvedErrorDescription, onRetry]); - - if (isLoading) { - return ( - loadingSkeleton ?? - ); - } - - if (isError) { - return ( - } - > - Refresh - - ) : undefined - } - /> - ); - } - - if (isEmpty) { - return ( - } - > - Refresh - - ) : undefined - } - /> - ); - } - - return <>{children}; -} diff --git a/packages/loopover-ui-kit/src/components/state-views.tsx b/packages/loopover-ui-kit/src/components/state-views.tsx new file mode 100644 index 0000000000..9af81ab03e --- /dev/null +++ b/packages/loopover-ui-kit/src/components/state-views.tsx @@ -0,0 +1,330 @@ +import { Loader2, Inbox, AlertTriangle, RefreshCw, WifiOff } from "lucide-react"; +import { useEffect, type ReactNode } from "react"; +import { toast } from "sonner"; + +import { cn } from "../utils"; + +export type ApiFailureKind = "timeout" | "network" | "http"; + +/** `errorKind`s that mean "we never reached the server" as opposed to "the server answered with an + * error" — StateBoundary/ErrorState use this to show connectivity-specific copy and iconography (#793). */ +const NETWORK_ERROR_KINDS: ReadonlySet = new Set(["network", "timeout"]); + +/** + * Shared state primitives so every panel/route renders a consistent + * loading / empty / error surface. All animations respect prefers-reduced-motion. + */ + +export function Spinner({ className }: { className?: string }) { + return ( + + ); +} + +function Shell({ + icon, + title, + description, + action, + secondaryAction, + className, + role, +}: { + icon: ReactNode; + title: string; + description?: ReactNode; + action?: ReactNode; + secondaryAction?: ReactNode; + className?: string; + role?: "status" | "alert"; +}) { + return ( +
+
{icon}
+
{title}
+ {description && ( +
{description}
+ )} + {(action || secondaryAction) && ( +
+ {action} + {secondaryAction} +
+ )} +
+ ); +} + +export function LoadingState({ + title = "Loading…", + description, + className, +}: { + title?: string; + description?: ReactNode; + className?: string; +}) { + return ( + } + title={title} + description={description} + className={className} + /> + ); +} + +export function EmptyState({ + title = "Nothing here yet", + description, + action, + secondaryAction, + className, +}: { + title?: string; + description?: ReactNode; + action?: ReactNode; + secondaryAction?: ReactNode; + className?: string; +}) { + return ( + } + title={title} + description={description} + action={action} + secondaryAction={secondaryAction} + className={className} + /> + ); +} + +export function StateActionButton({ + children, + onClick, + disabled, + icon, + variant = "outline", +}: { + children: ReactNode; + onClick?: () => void; + disabled?: boolean; + icon?: ReactNode; + variant?: "outline" | "primary"; +}) { + return ( + + ); +} + +export function ErrorState({ + title, + description, + errorKind, + onRetry, + retryLabel = "Try again", + secondaryAction, + toastOnRetry = true, + className, +}: { + title?: string; + description?: ReactNode; + /** Distinguishes "couldn't reach the server at all" from "the server answered with an error" (#793). + * Only changes the default `title`/`description` — an explicit `title`/`description` always wins. */ + errorKind?: ApiFailureKind; + onRetry?: () => void; + retryLabel?: string; + secondaryAction?: ReactNode; + toastOnRetry?: boolean; + className?: string; +}) { + const isNetworkIssue = errorKind !== undefined && NETWORK_ERROR_KINDS.has(errorKind); + const resolvedTitle = title ?? (isNetworkIssue ? "Can't reach the server" : "Couldn't load this"); + const resolvedDescription = + description ?? + (isNetworkIssue + ? "We couldn't reach the API — check your connection and retry. This is a connectivity issue, not a problem with the data itself." + : "Something went wrong fetching this data. You can retry, or come back in a moment. If this keeps happening, check status or try again shortly."); + return ( + + ) : ( + + ) + } + title={resolvedTitle} + description={resolvedDescription} + action={ + onRetry && ( + { + if (toastOnRetry) { + toast("Retrying…", { + description: "We’ll request the data again and keep this view in place.", + }); + } + onRetry(); + }} + icon={} + > + {retryLabel} + + ) + } + secondaryAction={secondaryAction} + className={className} + /> + ); +} + +export function StateBoundary({ + isLoading, + isError, + isEmpty, + loadingTitle = "Loading data…", + loadingDescription = "Fetching the latest available signals for this view.", + loadingSkeleton, + emptyTitle = "No data available yet", + emptyDescription = "This view has no records to show. Refresh when the source data is available.", + errorTitle, + errorDescription, + errorKind, + onRetry, + onRefresh, + errorLabel, + onFailureNotify, + children, +}: { + isLoading?: boolean; + isError?: boolean; + isEmpty?: boolean; + loadingTitle?: string; + loadingDescription?: ReactNode; + /** Content-shaped placeholder (build with `@/components/ui/skeleton`) shown instead of the generic + * spinner while loading, so the layout doesn't jump once data arrives (#793). */ + loadingSkeleton?: ReactNode; + emptyTitle?: string; + emptyDescription?: ReactNode; + errorTitle?: string; + errorDescription?: ReactNode; + /** Distinguishes "couldn't reach the server at all" from "the server answered with an error" (#793). */ + errorKind?: ApiFailureKind; + onRetry?: () => void; + onRefresh?: () => void; + /** When provided, surfaces a global API-failure toast with a Retry action. */ + errorLabel?: string; + /** Invoked when `isError && errorLabel` -- callers wire this to their own API-status singleton (e.g. + * a `notifyApiFailure`); no-ops when omitted. Kept optional and generic so this package never + * depends on an app-local module. */ + onFailureNotify?: (args: { + label: string; + kind: ApiFailureKind; + message: string; + retry?: () => void; + }) => void; + children: ReactNode; +}) { + const isNetworkIssue = errorKind !== undefined && NETWORK_ERROR_KINDS.has(errorKind); + // Preserve the pre-#793 defaults exactly when no errorKind is given; when one is given and the caller + // didn't override the copy, fall through to ErrorState's own network-aware defaults instead. + const resolvedErrorTitle = errorTitle ?? (isNetworkIssue ? undefined : "Couldn't load data"); + const resolvedErrorDescription = + errorDescription ?? + (isNetworkIssue + ? undefined + : "The data source did not respond. Retry the request, or check back once the service has recovered."); + + // When this boundary flips into the error state, surface a toast with Retry. + useEffect(() => { + if (isError && errorLabel) { + onFailureNotify?.({ + label: errorLabel, + kind: errorKind ?? "network", + message: + typeof resolvedErrorDescription === "string" + ? resolvedErrorDescription + : "Data source did not respond.", + retry: onRetry, + }); + } + }, [isError, errorLabel, errorKind, resolvedErrorDescription, onRetry, onFailureNotify]); + + if (isLoading) { + return ( + loadingSkeleton ?? + ); + } + + if (isError) { + return ( + } + > + Refresh + + ) : undefined + } + /> + ); + } + + if (isEmpty) { + return ( + } + > + Refresh + + ) : undefined + } + /> + ); + } + + return <>{children}; +}