di usage in website
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { EmptyStateProps } from '../types/state.types';
|
||||
import { EmptyStateProps } from './types';
|
||||
import Button from '@/components/ui/Button';
|
||||
|
||||
// Illustration components (simple SVG representations)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AlertTriangle, Wifi, RefreshCw, ArrowLeft, Home, X, Info } from 'lucide-react';
|
||||
import { ErrorDisplayProps } from '../types/state.types';
|
||||
import { ErrorDisplayProps } from './types';
|
||||
import Button from '@/components/ui/Button';
|
||||
|
||||
/**
|
||||
@@ -70,12 +70,6 @@ export function ErrorDisplay({
|
||||
// Icon based on error type
|
||||
const ErrorIcon = isConnectivity ? Wifi : AlertTriangle;
|
||||
|
||||
// Common button styles
|
||||
const buttonBase = 'flex items-center justify-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors disabled:opacity-50';
|
||||
const primaryButton = `${buttonBase} bg-red-500 hover:bg-red-600 text-white`;
|
||||
const secondaryButton = `${buttonBase} bg-iron-gray hover:bg-charcoal-outline text-gray-300 border border-charcoal-outline`;
|
||||
const ghostButton = `${buttonBase} hover:bg-iron-gray/50 text-gray-300`;
|
||||
|
||||
// Render different variants
|
||||
switch (variant) {
|
||||
case 'full-screen':
|
||||
@@ -125,11 +119,11 @@ export function ErrorDisplay({
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col gap-2 pt-2">
|
||||
{isRetryable && onRetry && (
|
||||
<button
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleRetry}
|
||||
disabled={isRetrying}
|
||||
className={primaryButton}
|
||||
aria-label={isRetrying ? 'Retrying...' : 'Try again'}
|
||||
className="w-full"
|
||||
>
|
||||
{isRetrying ? (
|
||||
<>
|
||||
@@ -142,55 +136,46 @@ export function ErrorDisplay({
|
||||
Try Again
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showNavigation && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleGoBack}
|
||||
className={`${secondaryButton} flex-1`}
|
||||
aria-label="Go back to previous page"
|
||||
className="flex-1"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Go Back
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
<button
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleGoHome}
|
||||
className={`${secondaryButton} flex-1`}
|
||||
aria-label="Go to home page"
|
||||
className="flex-1"
|
||||
>
|
||||
<Home className="w-4 h-4" />
|
||||
Home
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom Actions */}
|
||||
{actions.length > 0 && (
|
||||
<div className="flex flex-col gap-2 pt-2 border-t border-charcoal-outline/50">
|
||||
{actions.map((action, index) => {
|
||||
const variantClasses = {
|
||||
primary: 'bg-primary-blue hover:bg-blue-600 text-white',
|
||||
secondary: 'bg-iron-gray hover:bg-charcoal-outline text-gray-300 border border-charcoal-outline',
|
||||
danger: 'bg-red-600 hover:bg-red-700 text-white',
|
||||
ghost: 'hover:bg-iron-gray/50 text-gray-300',
|
||||
}[action.variant || 'secondary'];
|
||||
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
className={`${buttonBase} ${variantClasses} ${action.disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
aria-label={action.label}
|
||||
>
|
||||
{action.icon && <action.icon className="w-4 h-4" />}
|
||||
{action.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{actions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant={action.variant || 'secondary'}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
className="w-full"
|
||||
>
|
||||
{action.icon && <action.icon className="w-4 h-4" />}
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { LoadingWrapperProps } from '../types/state.types';
|
||||
import { LoadingWrapperProps } from './types';
|
||||
|
||||
/**
|
||||
* LoadingWrapper Component
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React, { ReactNode } from 'react';
|
||||
import { StateContainerProps, StateContainerConfig } from '../types/state.types';
|
||||
import { StateContainerProps, StateContainerConfig } from './types';
|
||||
import { LoadingWrapper } from './LoadingWrapper';
|
||||
import { ErrorDisplay } from './ErrorDisplay';
|
||||
import { EmptyState } from './EmptyState';
|
||||
@@ -52,7 +52,7 @@ export function StateContainer<T>({
|
||||
isEmpty,
|
||||
}: StateContainerProps<T>) {
|
||||
// Determine if data is empty
|
||||
const isDataEmpty = (data: T | null): boolean => {
|
||||
const isDataEmpty = (data: T | null | undefined): boolean => {
|
||||
if (data === null || data === undefined) return true;
|
||||
if (isEmpty) return isEmpty(data);
|
||||
|
||||
@@ -156,7 +156,7 @@ export function StateContainer<T>({
|
||||
);
|
||||
}
|
||||
|
||||
// At this point, data is guaranteed to be non-null
|
||||
// At this point, data is guaranteed to be non-null and non-undefined
|
||||
return <>{children(data as T)}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* Basic test file to verify state components are properly exported and typed
|
||||
*/
|
||||
|
||||
import { LoadingWrapper } from '../LoadingWrapper';
|
||||
import { ErrorDisplay } from '../ErrorDisplay';
|
||||
import { EmptyState } from '../EmptyState';
|
||||
import { StateContainer } from '../StateContainer';
|
||||
import { useDataFetching } from '../../hooks/useDataFetching';
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
|
||||
// This file just verifies that all components can be imported and are properly typed
|
||||
// Full testing would be done in separate test files
|
||||
|
||||
describe('State Components - Basic Type Checking', () => {
|
||||
it('should export all components', () => {
|
||||
expect(LoadingWrapper).toBeDefined();
|
||||
expect(ErrorDisplay).toBeDefined();
|
||||
expect(EmptyState).toBeDefined();
|
||||
expect(StateContainer).toBeDefined();
|
||||
expect(useDataFetching).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have proper component signatures', () => {
|
||||
// LoadingWrapper accepts props
|
||||
const loadingProps = {
|
||||
variant: 'spinner' as const,
|
||||
message: 'Loading...',
|
||||
size: 'md' as const,
|
||||
};
|
||||
expect(loadingProps).toBeDefined();
|
||||
|
||||
// ErrorDisplay accepts ApiError
|
||||
const mockError = new ApiError(
|
||||
'Test error',
|
||||
'NETWORK_ERROR',
|
||||
{ timestamp: new Date().toISOString() }
|
||||
);
|
||||
expect(mockError).toBeDefined();
|
||||
expect(mockError.isRetryable()).toBe(true);
|
||||
|
||||
// EmptyState accepts icon and title
|
||||
const emptyProps = {
|
||||
icon: require('lucide-react').Activity,
|
||||
title: 'No data',
|
||||
};
|
||||
expect(emptyProps).toBeDefined();
|
||||
|
||||
// StateContainer accepts data and state
|
||||
const stateProps = {
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
retry: async () => {},
|
||||
children: (data: any) => <div>{JSON.stringify(data)}</div>,
|
||||
};
|
||||
expect(stateProps).toBeDefined();
|
||||
});
|
||||
});
|
||||
116
apps/website/components/shared/state/types.ts
Normal file
116
apps/website/components/shared/state/types.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
'use client';
|
||||
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { ReactNode } from 'react';
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
|
||||
// ==================== EMPTY STATE TYPES ====================
|
||||
|
||||
export interface EmptyStateAction {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
icon?: LucideIcon;
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'race-performance' | 'race-final';
|
||||
}
|
||||
|
||||
export interface EmptyStateProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: EmptyStateAction;
|
||||
variant?: 'default' | 'minimal' | 'full-page';
|
||||
className?: string;
|
||||
illustration?: 'racing' | 'league' | 'team' | 'sponsor' | 'driver';
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
// ==================== LOADING STATE TYPES ====================
|
||||
|
||||
export interface LoadingCardConfig {
|
||||
count?: number;
|
||||
height?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface LoadingWrapperProps {
|
||||
variant?: 'spinner' | 'skeleton' | 'full-screen' | 'inline' | 'card';
|
||||
message?: string;
|
||||
className?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
skeletonCount?: number;
|
||||
cardConfig?: LoadingCardConfig;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
// ==================== ERROR STATE TYPES ====================
|
||||
|
||||
export interface ErrorAction {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'race-performance' | 'race-final';
|
||||
icon?: LucideIcon;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ErrorDisplayProps {
|
||||
error: ApiError;
|
||||
onRetry?: () => void;
|
||||
variant?: 'full-screen' | 'inline' | 'card' | 'toast';
|
||||
showRetry?: boolean;
|
||||
showNavigation?: boolean;
|
||||
actions?: ErrorAction[];
|
||||
className?: string;
|
||||
hideTechnicalDetails?: boolean;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
// ==================== STATE CONTAINER TYPES ====================
|
||||
|
||||
export interface StateContainerConfig<T> {
|
||||
loading?: {
|
||||
variant?: LoadingWrapperProps['variant'];
|
||||
message?: string;
|
||||
size?: LoadingWrapperProps['size'];
|
||||
skeletonCount?: number;
|
||||
};
|
||||
error?: {
|
||||
variant?: ErrorDisplayProps['variant'];
|
||||
showRetry?: boolean;
|
||||
showNavigation?: boolean;
|
||||
hideTechnicalDetails?: boolean;
|
||||
actions?: ErrorAction[];
|
||||
};
|
||||
empty?: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: EmptyStateAction;
|
||||
illustration?: EmptyStateProps['illustration'];
|
||||
};
|
||||
customRender?: {
|
||||
loading?: () => ReactNode;
|
||||
error?: (error: ApiError) => ReactNode;
|
||||
empty?: () => ReactNode;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StateContainerProps<T> {
|
||||
data: T | null | undefined;
|
||||
isLoading: boolean;
|
||||
error: ApiError | null;
|
||||
retry: () => void;
|
||||
children: (data: T) => ReactNode;
|
||||
config?: StateContainerConfig<T>;
|
||||
className?: string;
|
||||
showEmpty?: boolean;
|
||||
isEmpty?: (data: T) => boolean;
|
||||
}
|
||||
|
||||
// ==================== CONVENIENCE PROP TYPES ====================
|
||||
|
||||
// For components that only need specific subsets of props
|
||||
export type MinimalEmptyStateProps = Omit<EmptyStateProps, 'variant'>;
|
||||
export type MinimalLoadingProps = Pick<LoadingWrapperProps, 'message' | 'className'>;
|
||||
export type InlineLoadingProps = Pick<LoadingWrapperProps, 'message' | 'size' | 'className'>;
|
||||
export type SkeletonLoadingProps = Pick<LoadingWrapperProps, 'skeletonCount' | 'className'>;
|
||||
export type CardLoadingProps = Pick<LoadingWrapperProps, 'cardConfig' | 'className'>;
|
||||
Reference in New Issue
Block a user