390 lines
9.3 KiB
TypeScript
390 lines
9.3 KiB
TypeScript
'use client';
|
|
|
|
import React, { ReactNode } from 'react';
|
|
import { StateContainerProps, StateContainerConfig } from './types';
|
|
import { LoadingWrapper } from './LoadingWrapper';
|
|
import { ErrorDisplay } from './ErrorDisplay';
|
|
import { EmptyState } from './EmptyState';
|
|
import { Inbox, AlertCircle, Grid, List } 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,
|
|
className = '',
|
|
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 (
|
|
<div className={className}>
|
|
<LoadingWrapper
|
|
variant={loadingConfig.variant || 'spinner'}
|
|
message={loadingConfig.message || 'Loading...'}
|
|
size={loadingConfig.size || 'md'}
|
|
skeletonCount={loadingConfig.skeletonCount}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
const errorConfig = config?.error || {};
|
|
|
|
// Custom render
|
|
if (config?.customRender?.error) {
|
|
return <>{config.customRender.error(error)}</>;
|
|
}
|
|
|
|
return (
|
|
<div className={className}>
|
|
<ErrorDisplay
|
|
error={error}
|
|
onRetry={retry}
|
|
variant={errorConfig.variant || 'full-screen'}
|
|
actions={errorConfig.actions}
|
|
showRetry={errorConfig.showRetry}
|
|
showNavigation={errorConfig.showNavigation}
|
|
hideTechnicalDetails={errorConfig.hideTechnicalDetails}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className={className}>
|
|
<EmptyState
|
|
icon={Inbox}
|
|
title="No data available"
|
|
description="There is nothing to display here"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={className}>
|
|
<EmptyState
|
|
icon={emptyConfig.icon}
|
|
title={emptyConfig.title || 'No data available'}
|
|
description={emptyConfig.description}
|
|
action={emptyConfig.action}
|
|
variant="default"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<div className={className}>
|
|
<EmptyState
|
|
icon={AlertCircle}
|
|
title="Unexpected state"
|
|
description="No data available but no error or loading state"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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,
|
|
className = '',
|
|
emptyConfig,
|
|
}: StateContainerProps<T[]> & {
|
|
emptyConfig?: {
|
|
icon: any;
|
|
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}
|
|
className={className}
|
|
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,
|
|
className = '',
|
|
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}
|
|
className={className}
|
|
>
|
|
{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 (
|
|
<div className="min-h-screen bg-deep-graphite py-12">
|
|
<div className="max-w-4xl mx-auto px-4">
|
|
{title && (
|
|
<div className="mb-8">
|
|
<h1 className="text-3xl font-bold text-white mb-2">{title}</h1>
|
|
{description && (
|
|
<p className="text-gray-400">{description}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
<StateContainer data={data} isLoading={isLoading} error={error} retry={retry} config={pageConfig}>
|
|
{children}
|
|
</StateContainer>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-deep-graphite py-8">
|
|
<div className="max-w-4xl mx-auto px-4">
|
|
{title && (
|
|
<div className="mb-6">
|
|
<h1 className="text-3xl font-bold text-white mb-2">{title}</h1>
|
|
{description && (
|
|
<p className="text-gray-400">{description}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
<StateContainer data={data} isLoading={isLoading} error={error} retry={retry} config={pageConfig}>
|
|
{children}
|
|
</StateContainer>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* GridStateContainer - Specialized for grid layouts
|
|
* Handles card-based empty states
|
|
*/
|
|
export function GridStateContainer<T>({
|
|
data,
|
|
isLoading,
|
|
error,
|
|
retry,
|
|
children,
|
|
config,
|
|
className = '',
|
|
emptyConfig,
|
|
}: StateContainerProps<T[]> & {
|
|
emptyConfig?: {
|
|
icon: any;
|
|
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}
|
|
className={className}
|
|
isEmpty={(arr) => !arr || arr.length === 0}
|
|
>
|
|
{children}
|
|
</StateContainer>
|
|
);
|
|
} |