di usage in website
This commit is contained in:
@@ -1,8 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { useCapability } from '@/hooks/useCapability';
|
||||
|
||||
type CapabilityGateProps = {
|
||||
capabilityKey: string;
|
||||
@@ -17,26 +16,17 @@ export function CapabilityGate({
|
||||
fallback = null,
|
||||
comingSoon = null,
|
||||
}: CapabilityGateProps) {
|
||||
const { policyService } = useServices();
|
||||
const { isLoading, isError, capabilityState } = useCapability(capabilityKey);
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['policySnapshot'],
|
||||
queryFn: () => policyService.getSnapshot(),
|
||||
staleTime: 60_000,
|
||||
gcTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
if (isLoading || isError || !data) {
|
||||
if (isLoading || isError || !capabilityState) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
const state = policyService.getCapabilityState(data, capabilityKey);
|
||||
|
||||
if (state === 'enabled') {
|
||||
if (capabilityState === 'enabled') {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (state === 'coming_soon') {
|
||||
if (capabilityState === 'coming_soon') {
|
||||
return <>{comingSoon ?? fallback}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import React from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import Button from '@/components/ui/Button';
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const EmptyState = ({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className = ''
|
||||
}: EmptyStateProps) => (
|
||||
<div className={`text-center py-12 ${className}`}>
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-2xl bg-iron-gray/60 border border-charcoal-outline/50 mb-6">
|
||||
<Icon className="w-8 h-8 text-gray-500" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-white mb-3">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-gray-400 mb-8">{description}</p>
|
||||
)}
|
||||
{action && (
|
||||
<Button variant="primary" onClick={action.onClick} className="mx-auto">
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1,15 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface LoadingStateProps {
|
||||
message?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const LoadingState = ({ message = 'Loading...', className = '' }: LoadingStateProps) => (
|
||||
<div className={`flex items-center justify-center min-h-[200px] ${className}`}>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-10 h-10 border-2 border-primary-blue border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-gray-400">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1,374 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
import { UseDataFetchingOptions, UseDataFetchingResult } from '../types/state.types';
|
||||
import { delay, retryWithBackoff } from '@/lib/utils/errorUtils';
|
||||
|
||||
/**
|
||||
* useDataFetching Hook
|
||||
*
|
||||
* Unified data fetching hook with built-in state management, error handling,
|
||||
* retry logic, and caching support.
|
||||
*
|
||||
* Features:
|
||||
* - Automatic loading state management
|
||||
* - Error classification and handling
|
||||
* - Built-in retry with exponential backoff
|
||||
* - Cache and stale time support
|
||||
* - Refetch capability
|
||||
* - Success/error callbacks
|
||||
* - Auto-retry on mount for recoverable errors
|
||||
*
|
||||
* Usage Example:
|
||||
* ```typescript
|
||||
* const { data, isLoading, error, retry, refetch } = useDataFetching({
|
||||
* queryKey: ['dashboardOverview'],
|
||||
* queryFn: () => dashboardService.getDashboardOverview(),
|
||||
* retryOnMount: true,
|
||||
* cacheTime: 5 * 60 * 1000,
|
||||
* onSuccess: (data) => console.log('Loaded:', data),
|
||||
* onError: (error) => console.error('Error:', error),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function useDataFetching<T>(
|
||||
options: UseDataFetchingOptions<T>
|
||||
): UseDataFetchingResult<T> {
|
||||
const {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled = true,
|
||||
retryOnMount = false,
|
||||
cacheTime = 5 * 60 * 1000, // 5 minutes
|
||||
staleTime = 1 * 60 * 1000, // 1 minute
|
||||
maxRetries = 3,
|
||||
retryDelay = 1000,
|
||||
onSuccess,
|
||||
onError,
|
||||
} = options;
|
||||
|
||||
// State management
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [isFetching, setIsFetching] = useState<boolean>(false);
|
||||
const [error, setError] = useState<ApiError | null>(null);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
const [isStale, setIsStale] = useState<boolean>(true);
|
||||
|
||||
// Refs for caching and retry logic
|
||||
const cacheRef = useRef<{
|
||||
data: T | null;
|
||||
timestamp: number;
|
||||
isStale: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const retryCountRef = useRef<number>(0);
|
||||
const isMountedRef = useRef<boolean>(true);
|
||||
|
||||
// Check if cache is valid
|
||||
const isCacheValid = useCallback((): boolean => {
|
||||
if (!cacheRef.current) return false;
|
||||
|
||||
const now = Date.now();
|
||||
const age = now - cacheRef.current.timestamp;
|
||||
|
||||
// Cache is valid if within cacheTime and not stale
|
||||
return age < cacheTime && !cacheRef.current.isStale;
|
||||
}, [cacheTime]);
|
||||
|
||||
// Update cache
|
||||
const updateCache = useCallback((newData: T | null, isStale: boolean = false) => {
|
||||
cacheRef.current = {
|
||||
data: newData,
|
||||
timestamp: Date.now(),
|
||||
isStale,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Main fetch function
|
||||
const fetch = useCallback(async (isRetry: boolean = false): Promise<T | null> => {
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
if (!isRetry && isCacheValid() && cacheRef.current && cacheRef.current.data !== null) {
|
||||
setData(cacheRef.current.data);
|
||||
setIsLoading(false);
|
||||
setIsFetching(false);
|
||||
setError(null);
|
||||
setLastUpdated(new Date(cacheRef.current.timestamp));
|
||||
setIsStale(false);
|
||||
return cacheRef.current.data;
|
||||
}
|
||||
|
||||
setIsFetching(true);
|
||||
if (!isRetry) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Execute the fetch with retry logic
|
||||
const result = await retryWithBackoff(
|
||||
async () => {
|
||||
retryCountRef.current++;
|
||||
return await queryFn();
|
||||
},
|
||||
maxRetries,
|
||||
retryDelay
|
||||
);
|
||||
|
||||
if (!isMountedRef.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Success - update state and cache
|
||||
setData(result);
|
||||
setLastUpdated(new Date());
|
||||
setIsStale(false);
|
||||
updateCache(result, false);
|
||||
retryCountRef.current = 0; // Reset retry count on success
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess(result);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (!isMountedRef.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert to ApiError if needed
|
||||
const apiError = err instanceof ApiError ? err : new ApiError(
|
||||
err instanceof Error ? err.message : 'An unexpected error occurred',
|
||||
'UNKNOWN_ERROR',
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
retryCount: retryCountRef.current,
|
||||
wasRetry: isRetry,
|
||||
},
|
||||
err instanceof Error ? err : undefined
|
||||
);
|
||||
|
||||
setError(apiError);
|
||||
|
||||
if (onError) {
|
||||
onError(apiError);
|
||||
}
|
||||
|
||||
// Mark cache as stale on error
|
||||
if (cacheRef.current) {
|
||||
cacheRef.current.isStale = true;
|
||||
setIsStale(true);
|
||||
}
|
||||
|
||||
throw apiError;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsFetching(false);
|
||||
}
|
||||
}, [enabled, isCacheValid, queryFn, maxRetries, retryDelay, updateCache, onSuccess, onError]);
|
||||
|
||||
// Retry function
|
||||
const retry = useCallback(async () => {
|
||||
return await fetch(true);
|
||||
}, [fetch]);
|
||||
|
||||
// Refetch function
|
||||
const refetch = useCallback(async () => {
|
||||
// Force bypass cache
|
||||
cacheRef.current = null;
|
||||
return await fetch(false);
|
||||
}, [fetch]);
|
||||
|
||||
// Initial fetch and auto-retry on mount
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
const initialize = async () => {
|
||||
if (!enabled) return;
|
||||
|
||||
// Check if we should auto-retry on mount
|
||||
const shouldRetryOnMount = retryOnMount && error && error.isRetryable();
|
||||
|
||||
if (shouldRetryOnMount) {
|
||||
try {
|
||||
await retry();
|
||||
} catch (err) {
|
||||
// Error already set by retry
|
||||
}
|
||||
} else if (!data && !error) {
|
||||
// Initial fetch
|
||||
try {
|
||||
await fetch(false);
|
||||
} catch (err) {
|
||||
// Error already set by fetch
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initialize();
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, [enabled, retryOnMount]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Effect to check staleness
|
||||
useEffect(() => {
|
||||
if (!lastUpdated) return;
|
||||
|
||||
const checkStale = () => {
|
||||
if (!lastUpdated) return;
|
||||
|
||||
const now = Date.now();
|
||||
const age = now - lastUpdated.getTime();
|
||||
|
||||
if (age > staleTime) {
|
||||
setIsStale(true);
|
||||
if (cacheRef.current) {
|
||||
cacheRef.current.isStale = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const interval = setInterval(checkStale, 30000); // Check every 30 seconds
|
||||
return () => clearInterval(interval);
|
||||
}, [lastUpdated, staleTime]);
|
||||
|
||||
// Effect to update cache staleness
|
||||
useEffect(() => {
|
||||
if (isStale && cacheRef.current) {
|
||||
cacheRef.current.isStale = true;
|
||||
}
|
||||
}, [isStale]);
|
||||
|
||||
// Clear cache function (useful for manual cache invalidation)
|
||||
const clearCache = useCallback(() => {
|
||||
cacheRef.current = null;
|
||||
setIsStale(true);
|
||||
}, []);
|
||||
|
||||
// Reset function (clears everything)
|
||||
const reset = useCallback(() => {
|
||||
setData(null);
|
||||
setIsLoading(false);
|
||||
setIsFetching(false);
|
||||
setError(null);
|
||||
setLastUpdated(null);
|
||||
setIsStale(true);
|
||||
cacheRef.current = null;
|
||||
retryCountRef.current = 0;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
retry,
|
||||
refetch,
|
||||
lastUpdated,
|
||||
isStale,
|
||||
// Additional utility functions (not part of standard interface but useful)
|
||||
_clearCache: clearCache,
|
||||
_reset: reset,
|
||||
} as UseDataFetchingResult<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* useDataFetchingWithPagination Hook
|
||||
*
|
||||
* Extension of useDataFetching for paginated data
|
||||
*/
|
||||
export function useDataFetchingWithPagination<T>(
|
||||
options: UseDataFetchingOptions<T[]> & {
|
||||
initialPage?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
) {
|
||||
const {
|
||||
initialPage = 1,
|
||||
pageSize = 10,
|
||||
queryFn,
|
||||
...restOptions
|
||||
} = options;
|
||||
|
||||
const [page, setPage] = useState<number>(initialPage);
|
||||
const [hasMore, setHasMore] = useState<boolean>(true);
|
||||
|
||||
const paginatedQueryFn = useCallback(async () => {
|
||||
const result = await queryFn();
|
||||
|
||||
// Check if there's more data
|
||||
if (Array.isArray(result)) {
|
||||
setHasMore(result.length === pageSize);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [queryFn, pageSize]);
|
||||
|
||||
const result = useDataFetching<T[]>({
|
||||
...restOptions,
|
||||
queryFn: paginatedQueryFn,
|
||||
});
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!hasMore) return;
|
||||
|
||||
const nextPage = page + 1;
|
||||
setPage(nextPage);
|
||||
|
||||
// This would need to be integrated with the actual API
|
||||
// For now, we'll just refetch which may not be ideal
|
||||
await result.refetch();
|
||||
}, [page, hasMore, result]);
|
||||
|
||||
const resetPagination = useCallback(() => {
|
||||
setPage(initialPage);
|
||||
setHasMore(true);
|
||||
if (result._reset) {
|
||||
result._reset();
|
||||
}
|
||||
}, [initialPage, result]);
|
||||
|
||||
return {
|
||||
...result,
|
||||
page,
|
||||
hasMore,
|
||||
loadMore,
|
||||
resetPagination,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* useDataFetchingWithRefresh Hook
|
||||
*
|
||||
* Extension with automatic refresh capability
|
||||
*/
|
||||
export function useDataFetchingWithRefresh<T>(
|
||||
options: UseDataFetchingOptions<T> & {
|
||||
refreshInterval?: number; // milliseconds
|
||||
}
|
||||
) {
|
||||
const { refreshInterval, ...restOptions } = options;
|
||||
const result = useDataFetching<T>(restOptions);
|
||||
|
||||
useEffect(() => {
|
||||
if (!refreshInterval) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (!result.isLoading && !result.isFetching) {
|
||||
result.refetch();
|
||||
}
|
||||
}, refreshInterval);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [refreshInterval, result]);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -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'>;
|
||||
@@ -1,386 +0,0 @@
|
||||
/**
|
||||
* TypeScript Interfaces for State Management Components
|
||||
*
|
||||
* Provides comprehensive type definitions for loading, error, and empty states
|
||||
* across the GridPilot website application.
|
||||
*/
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
|
||||
// ============================================================================
|
||||
// Core State Interfaces
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Basic state for any data fetching operation
|
||||
*/
|
||||
export interface PageState<T> {
|
||||
data: T | null;
|
||||
isLoading: boolean;
|
||||
error: ApiError | null;
|
||||
retry: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended state with metadata for advanced use cases
|
||||
*/
|
||||
export interface PageStateWithMeta<T> extends PageState<T> {
|
||||
isFetching: boolean;
|
||||
refetch: () => Promise<void>;
|
||||
lastUpdated: Date | null;
|
||||
isStale: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook Interfaces
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Options for useDataFetching hook
|
||||
*/
|
||||
export interface UseDataFetchingOptions<T> {
|
||||
/** Unique key for caching and invalidation */
|
||||
queryKey: string[];
|
||||
/** Function to fetch data */
|
||||
queryFn: () => Promise<T>;
|
||||
/** Enable/disable the query */
|
||||
enabled?: boolean;
|
||||
/** Auto-retry on mount for recoverable errors */
|
||||
retryOnMount?: boolean;
|
||||
/** Cache time in milliseconds */
|
||||
cacheTime?: number;
|
||||
/** Stale time in milliseconds */
|
||||
staleTime?: number;
|
||||
/** Maximum retry attempts */
|
||||
maxRetries?: number;
|
||||
/** Delay between retries in milliseconds */
|
||||
retryDelay?: number;
|
||||
/** Success callback */
|
||||
onSuccess?: (data: T) => void;
|
||||
/** Error callback */
|
||||
onError?: (error: ApiError) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from useDataFetching hook
|
||||
*/
|
||||
export interface UseDataFetchingResult<T> {
|
||||
data: T | null;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
error: ApiError | null;
|
||||
retry: () => Promise<void>;
|
||||
refetch: () => Promise<void>;
|
||||
lastUpdated: Date | null;
|
||||
isStale: boolean;
|
||||
// Internal methods (not part of public API but needed for extensions)
|
||||
_clearCache?: () => void;
|
||||
_reset?: () => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LoadingWrapper Component
|
||||
// ============================================================================
|
||||
|
||||
export type LoadingVariant = 'spinner' | 'skeleton' | 'full-screen' | 'inline' | 'card';
|
||||
export type LoadingSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
export interface LoadingWrapperProps {
|
||||
/** Visual variant of loading state */
|
||||
variant?: LoadingVariant;
|
||||
/** Custom message to display */
|
||||
message?: string;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
/** Size of loading indicator */
|
||||
size?: LoadingSize;
|
||||
/** For skeleton variant - number of skeleton items to show */
|
||||
skeletonCount?: number;
|
||||
/** For card variant - card layout configuration */
|
||||
cardConfig?: {
|
||||
height?: number;
|
||||
count?: number;
|
||||
className?: string;
|
||||
};
|
||||
/** ARIA label for accessibility */
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ErrorDisplay Component
|
||||
// ============================================================================
|
||||
|
||||
export type ErrorVariant = 'full-screen' | 'inline' | 'card' | 'toast';
|
||||
|
||||
export interface ErrorAction {
|
||||
/** Button label */
|
||||
label: string;
|
||||
/** Click handler */
|
||||
onClick: () => void;
|
||||
/** Visual variant */
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
/** Optional icon */
|
||||
icon?: LucideIcon;
|
||||
/** Disabled state */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ErrorDisplayProps {
|
||||
/** The error to display */
|
||||
error: ApiError;
|
||||
/** Retry callback */
|
||||
onRetry?: () => void;
|
||||
/** Visual variant */
|
||||
variant?: ErrorVariant;
|
||||
/** Show retry button (auto-detected from error.isRetryable()) */
|
||||
showRetry?: boolean;
|
||||
/** Show navigation buttons */
|
||||
showNavigation?: boolean;
|
||||
/** Additional custom actions */
|
||||
actions?: ErrorAction[];
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
/** Hide technical details in production */
|
||||
hideTechnicalDetails?: boolean;
|
||||
/** ARIA label for accessibility */
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EmptyState Component
|
||||
// ============================================================================
|
||||
|
||||
export type EmptyVariant = 'default' | 'minimal' | 'full-page';
|
||||
export type EmptyIllustration = 'racing' | 'league' | 'team' | 'sponsor' | 'driver';
|
||||
|
||||
export interface EmptyStateProps {
|
||||
/** Icon to display */
|
||||
icon: LucideIcon;
|
||||
/** Title text */
|
||||
title: string;
|
||||
/** Description text */
|
||||
description?: string;
|
||||
/** Primary action */
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
icon?: LucideIcon;
|
||||
variant?: 'primary' | 'secondary';
|
||||
};
|
||||
/** Visual variant */
|
||||
variant?: EmptyVariant;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
/** Illustration instead of icon */
|
||||
illustration?: EmptyIllustration;
|
||||
/** ARIA label for accessibility */
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// StateContainer Component
|
||||
// ============================================================================
|
||||
|
||||
export interface StateContainerConfig<T> {
|
||||
/** Loading state configuration */
|
||||
loading?: {
|
||||
variant?: LoadingVariant;
|
||||
message?: string;
|
||||
size?: LoadingSize;
|
||||
skeletonCount?: number;
|
||||
};
|
||||
/** Error state configuration */
|
||||
error?: {
|
||||
variant?: ErrorVariant;
|
||||
actions?: ErrorAction[];
|
||||
showRetry?: boolean;
|
||||
showNavigation?: boolean;
|
||||
hideTechnicalDetails?: boolean;
|
||||
};
|
||||
/** Empty state configuration */
|
||||
empty?: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
};
|
||||
/** Custom render functions for advanced use cases */
|
||||
customRender?: {
|
||||
loading?: () => ReactNode;
|
||||
error?: (error: ApiError) => ReactNode;
|
||||
empty?: () => ReactNode;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StateContainerProps<T> {
|
||||
/** Current data */
|
||||
data: T | null;
|
||||
/** Loading state */
|
||||
isLoading: boolean;
|
||||
/** Error state */
|
||||
error: ApiError | null;
|
||||
/** Retry function */
|
||||
retry: () => Promise<void>;
|
||||
/** Child render function */
|
||||
children: (data: T) => ReactNode;
|
||||
/** Configuration for all states */
|
||||
config?: StateContainerConfig<T>;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
/** Whether to show empty state (default: true) */
|
||||
showEmpty?: boolean;
|
||||
/** Custom function to determine if data is empty */
|
||||
isEmpty?: (data: T) => boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Retry Configuration
|
||||
// ============================================================================
|
||||
|
||||
export interface RetryConfig {
|
||||
/** Maximum retry attempts */
|
||||
maxAttempts?: number;
|
||||
/** Base delay in milliseconds */
|
||||
baseDelay?: number;
|
||||
/** Backoff multiplier */
|
||||
backoffMultiplier?: number;
|
||||
/** Auto-retry on mount */
|
||||
retryOnMount?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Notification Configuration
|
||||
// ============================================================================
|
||||
|
||||
export interface NotificationConfig {
|
||||
/** Show toast on success */
|
||||
showToastOnSuccess?: boolean;
|
||||
/** Show toast on error */
|
||||
showToastOnError?: boolean;
|
||||
/** Custom success message */
|
||||
successMessage?: string;
|
||||
/** Custom error message */
|
||||
errorMessage?: string;
|
||||
/** Auto-dismiss delay in milliseconds */
|
||||
autoDismissDelay?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Analytics Configuration
|
||||
// ============================================================================
|
||||
|
||||
export interface StateAnalytics {
|
||||
/** Called when state changes */
|
||||
onStateChange?: (from: string, to: string, data?: unknown) => void;
|
||||
/** Called on error */
|
||||
onError?: (error: ApiError, context: string) => void;
|
||||
/** Called on retry */
|
||||
onRetry?: (attempt: number, maxAttempts: number) => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Performance Metrics
|
||||
// ============================================================================
|
||||
|
||||
export interface PerformanceMetrics {
|
||||
/** Time to first render in milliseconds */
|
||||
timeToFirstRender?: number;
|
||||
/** Time to data load in milliseconds */
|
||||
timeToDataLoad?: number;
|
||||
/** Number of retry attempts */
|
||||
retryCount?: number;
|
||||
/** Whether cache was hit */
|
||||
cacheHit?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Advanced Configuration
|
||||
// ============================================================================
|
||||
|
||||
export interface AdvancedStateConfig<T> extends StateContainerConfig<T> {
|
||||
retry?: RetryConfig;
|
||||
notifications?: NotificationConfig;
|
||||
analytics?: StateAnalytics;
|
||||
performance?: PerformanceMetrics;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Page Template Interfaces
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generic page template props
|
||||
*/
|
||||
export interface PageTemplateProps<T> {
|
||||
data: T | null;
|
||||
isLoading: boolean;
|
||||
error: ApiError | null;
|
||||
retry: () => Promise<void>;
|
||||
refetch: () => Promise<void>;
|
||||
title?: string;
|
||||
description?: string;
|
||||
children: (data: T) => ReactNode;
|
||||
config?: StateContainerConfig<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* List page template props
|
||||
*/
|
||||
export interface ListPageTemplateProps<T> extends PageTemplateProps<T[]> {
|
||||
emptyConfig?: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
};
|
||||
showSkeleton?: boolean;
|
||||
skeletonCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail page template props
|
||||
*/
|
||||
export interface DetailPageTemplateProps<T> extends PageTemplateProps<T> {
|
||||
onBack?: () => void;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Default Configuration
|
||||
// ============================================================================
|
||||
|
||||
export const DEFAULT_CONFIG = {
|
||||
loading: {
|
||||
variant: 'spinner' as LoadingVariant,
|
||||
message: 'Loading...',
|
||||
size: 'md' as LoadingSize,
|
||||
},
|
||||
error: {
|
||||
variant: 'full-screen' as ErrorVariant,
|
||||
showRetry: true,
|
||||
showNavigation: true,
|
||||
},
|
||||
empty: {
|
||||
title: 'No data available',
|
||||
description: 'There is nothing to display here',
|
||||
},
|
||||
retry: {
|
||||
maxAttempts: 3,
|
||||
baseDelay: 1000,
|
||||
backoffMultiplier: 2,
|
||||
retryOnMount: true,
|
||||
},
|
||||
notifications: {
|
||||
showToastOnSuccess: false,
|
||||
showToastOnError: true,
|
||||
autoDismissDelay: 5000,
|
||||
},
|
||||
} as const;
|
||||
Reference in New Issue
Block a user