import { Component, useCallback, type ReactNode } from 'react' import { Alert, AlertTitle, Box, Typography } from '@mui/material' import { setCaptureEl, useWidgetId, useWidgetShallow } from '../stores' import { styles } from './style' import { DEFAULT_STATE_LABELS, type StateLabels } from './labels' export interface StateProps { skeleton: ReactNode children: ReactNode /** Override the empty-state predicate. Receives `rawData` (pre-pipeline). */ isEmpty?: (rawData: unknown) => boolean /** Render an alternative error UI. When omitted, the default ErrorView is used. */ fallback?: (error: Error) => ReactNode labels?: Partial } interface StateSlice { isLoading: boolean isFetching: boolean error: unknown rawData: unknown } const sliceSelector = (s: { isLoading: boolean isFetching: boolean error: unknown rawData: unknown }): StateSlice => ({ isLoading: s.isLoading, isFetching: s.isFetching, error: s.error, rawData: s.rawData, }) /** * Priority chain: loading → error → empty → content. Both consumer-supplied * errors (set via Provider's `error` prop) and transform-throw errors (set by * the pipeline middleware) flow through the same `error` field. Render-time * errors thrown by `children` are caught by the inlined error boundary and * routed through the same path. */ export function State({ skeleton, children, isEmpty = defaultIsEmpty, fallback, labels, }: StateProps) { const id = useWidgetId() const slice = useWidgetShallow(id, sliceSelector) const _labels = { ...DEFAULT_STATE_LABELS, ...labels } // Loading wins over everything else (matches v1 — hides errors during fetch). if (slice.isLoading) return <>{skeleton} // Store-side error (consumer-supplied or transform-throw via middleware). if (slice.error !== undefined && slice.error !== null) { const err = toError(slice.error) return fallback ? ( <>{fallback(err)} ) : ( ) } // Empty (uses pre-pipeline rawData so a transform that filters everything // doesn't get misclassified as "no data from upstream"). if (isEmpty(slice.rawData)) { return } return ( {children} ) } /** * Wraps the success-path children in a stable flex column whose DOM ref is * registered with the per-widget capture map. Per-widget download configs * feed the wrapper element to `html2canvas` for PNG export. The callback * ref auto-clears (React calls it with `null`) when Widget.State leaves * the success branch or unmounts, so loading / error / empty states * correctly report "no capture target" via `getCaptureEl`. * * Composition convention: render interactive controls that should *not* * appear in the PNG (e.g. ``) as siblings of * `` rather than children, so they sit outside this box. */ function CaptureBox({ id, children }: { id: string; children: ReactNode }) { const setRef = useCallback( (el: HTMLDivElement | null) => setCaptureEl(id, el), [id], ) return ( {children} ) } function ErrorView({ error, labels }: { error: Error; labels: StateLabels }) { return ( {labels.errorTitle} {labels.errorMessage(error)} ) } function EmptyView({ labels }: { labels: StateLabels }) { return ( {labels.emptyTitle} {labels.emptyDescription} ) } interface BoundaryProps { fallback?: (error: Error) => ReactNode labels: StateLabels children: ReactNode } interface BoundaryState { error: Error | null } class RenderErrorBoundary extends Component { override state: BoundaryState = { error: null } static getDerivedStateFromError(error: Error): BoundaryState { return { error } } override componentDidCatch(error: Error): void { // eslint-disable-next-line no-console console.error('[widgets-v2] Render error in child:', error) } override render(): ReactNode { const { error } = this.state if (error !== null) { const { fallback, labels } = this.props return fallback ? ( fallback(error) ) : ( ) } return this.props.children } } function defaultIsEmpty(rawData: unknown): boolean { if (rawData == null) return true if (Array.isArray(rawData)) { if (rawData.length === 0) return true if (rawData.every((item) => Array.isArray(item) && item.length === 0)) { return true } return false } if (typeof rawData === 'object' && Object.keys(rawData).length === 0) { return true } return false } function toError(value: unknown): Error { if (value instanceof Error) return value if (typeof value === 'string') return new Error(value) if (value && typeof value === 'object' && 'message' in value) { return new Error(String((value as { message: unknown }).message)) } return new Error('Unknown error') }