Files
gridpilot.gg/apps/website/components/shared/state/StateContainer.tsx
2026-01-15 19:55:46 +01:00

392 lines
9.3 KiB
TypeScript

'use client';
import React from 'react';
import { StateContainerProps, StateContainerConfig } from '@/ui/state-types';
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
import { ErrorDisplay } from '@/components/shared/state/ErrorDisplay';
import { EmptyState } from '@/components/shared/state/EmptyState';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Inbox, AlertCircle, Grid, List, LucideIcon } from 'lucide-react';
/**
* StateContainer Component
*
* Combined wrapper that automatically handles all states (loading, error, empty, success)
* based on the provided data and state values.
*
* Features:
* - Automatic state detection and rendering
* - Customizable configuration for each state
* - Custom render functions for advanced use cases
* - Consistent behavior across all pages
*
* Usage Example:
* ```typescript
* <StateContainer
* data={data}
* isLoading={isLoading}
* error={error}
* retry={retry}
* config={{
* loading: { variant: 'skeleton', message: 'Loading...' },
* error: { variant: 'full-screen' },
* empty: {
* icon: Trophy,
* title: 'No data found',
* description: 'Try refreshing the page',
* action: { label: 'Refresh', onClick: retry }
* }
* }}
* >
* {(content) => <MyContent data={content} />}
* </StateContainer>
* ```
*/
export function StateContainer<T>({
data,
isLoading,
error,
retry,
children,
config,
showEmpty = true,
isEmpty,
}: StateContainerProps<T>) {
// Determine if data is empty
const isDataEmpty = (data: T | null | undefined): boolean => {
if (data === null || data === undefined) return true;
if (isEmpty) return isEmpty(data);
// Default empty checks
if (Array.isArray(data)) return data.length === 0;
if (typeof data === 'object' && data !== null) {
return Object.keys(data).length === 0;
}
return false;
};
// Priority order: Loading > Error > Empty > Success
if (isLoading) {
const loadingConfig = config?.loading || {};
// Custom render
if (config?.customRender?.loading) {
return <>{config.customRender.loading()}</>;
}
return (
<Box>
<LoadingWrapper
variant={loadingConfig.variant || 'spinner'}
message={loadingConfig.message || 'Loading...'}
size={loadingConfig.size || 'md'}
skeletonCount={loadingConfig.skeletonCount}
/>
</Box>
);
}
if (error) {
const errorConfig = config?.error || {};
// Custom render
if (config?.customRender?.error) {
return <>{config.customRender.error(error)}</>;
}
return (
<Box>
<ErrorDisplay
error={error}
onRetry={retry}
variant={errorConfig.variant || 'full-screen'}
actions={errorConfig.actions}
showRetry={errorConfig.showRetry}
showNavigation={errorConfig.showNavigation}
hideTechnicalDetails={errorConfig.hideTechnicalDetails}
/>
</Box>
);
}
if (showEmpty && isDataEmpty(data)) {
const emptyConfig = config?.empty;
// Custom render
if (config?.customRender?.empty) {
return <>{config.customRender.empty()}</>;
}
// If no empty config provided, show nothing (or could show default empty state)
if (!emptyConfig) {
return (
<Box>
<EmptyState
icon={Inbox}
title="No data available"
description="There is nothing to display here"
/>
</Box>
);
}
return (
<Box>
<EmptyState
icon={emptyConfig.icon}
title={emptyConfig.title || 'No data available'}
description={emptyConfig.description}
action={emptyConfig.action}
variant="default"
/>
</Box>
);
}
// Success state - render children with data
if (data === null || data === undefined) {
// This shouldn't happen if we've handled all cases above, but as a fallback
return (
<Box>
<EmptyState
icon={AlertCircle}
title="Unexpected state"
description="No data available but no error or loading state"
/>
</Box>
);
}
// Custom success render
if (config?.customRender?.success) {
return <>{config.customRender.success(data as T)}</>;
}
// At this point, data is guaranteed to be non-null and non-undefined
return <>{children(data as T)}</>;
}
/**
* ListStateContainer - Specialized for list data
* Automatically handles empty arrays with appropriate messaging
*/
export function ListStateContainer<T>({
data,
isLoading,
error,
retry,
children,
config,
emptyConfig,
}: StateContainerProps<T[]> & {
emptyConfig?: {
icon: LucideIcon;
title: string;
description?: string;
action?: {
label: string;
onClick: () => void;
};
};
}) {
const listConfig: StateContainerConfig<T[]> = {
...config,
empty: emptyConfig || {
icon: List,
title: 'No items found',
description: 'This list is currently empty',
},
};
return (
<StateContainer
data={data}
isLoading={isLoading}
error={error}
retry={retry}
config={listConfig}
isEmpty={(arr) => !arr || arr.length === 0}
>
{children}
</StateContainer>
);
}
/**
* DetailStateContainer - Specialized for detail pages
* Includes back/refresh functionality
*/
export function DetailStateContainer<T>({
data,
isLoading,
error,
retry,
children,
config,
onBack,
onRefresh,
}: StateContainerProps<T> & {
onBack?: () => void;
onRefresh?: () => void;
}) {
const detailConfig: StateContainerConfig<T> = {
...config,
error: {
...config?.error,
actions: [
...(config?.error?.actions || []),
...(onBack ? [{ label: 'Go Back', onClick: onBack, variant: 'secondary' as const }] : []),
...(onRefresh ? [{ label: 'Refresh', onClick: onRefresh, variant: 'primary' as const }] : []),
],
showNavigation: config?.error?.showNavigation ?? true,
},
};
return (
<StateContainer
data={data}
isLoading={isLoading}
error={error}
retry={retry}
config={detailConfig}
>
{children}
</StateContainer>
);
}
/**
* PageStateContainer - Full page state management
* Wraps content in proper page structure
*/
export function PageStateContainer<T>({
data,
isLoading,
error,
retry,
children,
config,
title,
description,
}: StateContainerProps<T> & {
title?: string;
description?: string;
}) {
const pageConfig: StateContainerConfig<T> = {
loading: {
variant: 'full-screen',
message: title ? `Loading ${title}...` : 'Loading...',
...config?.loading,
},
error: {
variant: 'full-screen',
...config?.error,
},
empty: config?.empty,
};
if (isLoading) {
return <StateContainer data={data} isLoading={isLoading} error={error} retry={retry} config={pageConfig}>
{children}
</StateContainer>;
}
if (error) {
return <StateContainer data={data} isLoading={isLoading} error={error} retry={retry} config={pageConfig}>
{children}
</StateContainer>;
}
if (!data || (Array.isArray(data) && data.length === 0)) {
if (config?.empty) {
return (
<Box bg="bg-deep-graphite" py={12} minHeight="100vh">
<Box maxWidth="4xl" mx="auto" px={4}>
{title && (
<Box mb={8}>
<Heading level={1}>{title}</Heading>
{description && (
<Text color="text-gray-400">{description}</Text>
)}
</Box>
)}
<StateContainer data={data} isLoading={isLoading} error={error} retry={retry} config={pageConfig}>
{children}
</StateContainer>
</Box>
</Box>
);
}
}
return (
<Box bg="bg-deep-graphite" py={8} minHeight="100vh">
<Box maxWidth="4xl" mx="auto" px={4}>
{title && (
<Box mb={6}>
<Heading level={1}>{title}</Heading>
{description && (
<Text color="text-gray-400">{description}</Text>
)}
</Box>
)}
<StateContainer data={data} isLoading={isLoading} error={error} retry={retry} config={pageConfig}>
{children}
</StateContainer>
</Box>
</Box>
);
}
/**
* GridStateContainer - Specialized for grid layouts
* Handles card-based empty states
*/
export function GridStateContainer<T>({
data,
isLoading,
error,
retry,
children,
config,
emptyConfig,
}: StateContainerProps<T[]> & {
emptyConfig?: {
icon: LucideIcon;
title: string;
description?: string;
action?: {
label: string;
onClick: () => void;
};
};
}) {
const gridConfig: StateContainerConfig<T[]> = {
loading: {
variant: 'card',
...config?.loading,
},
...config,
empty: emptyConfig || {
icon: Grid,
title: 'No items to display',
description: 'Try adjusting your filters or search',
},
};
return (
<StateContainer
data={data}
isLoading={isLoading}
error={error}
retry={retry}
config={gridConfig}
isEmpty={(arr) => !arr || arr.length === 0}
>
{children}
</StateContainer>
);
}