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}; +}