add loader
This commit is contained in:
parent
91917289e2
commit
abce2bb6ef
10 changed files with 538 additions and 49 deletions
|
|
@ -1,3 +1,5 @@
|
|||
import time
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity, create_access_token, create_refresh_token
|
||||
from app import db
|
||||
|
|
@ -76,7 +78,12 @@ def get_current_user():
|
|||
@api_bp.route("/products", methods=["GET"])
|
||||
def get_products():
|
||||
"""Get all products"""
|
||||
|
||||
|
||||
# time.sleep(5) # This adds a 5 second delay
|
||||
|
||||
products = Product.query.filter_by(is_active=True).all()
|
||||
|
||||
return jsonify([product.to_dict() for product in products]), 200
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -54,6 +54,145 @@ function Products() {
|
|||
}
|
||||
```
|
||||
|
||||
### API Integration with Loaders and Toasts
|
||||
- **ALWAYS** create custom hooks for API operations that integrate loader and toast logic
|
||||
- **NEVER** handle errors in components when the hook already shows toasts
|
||||
- Custom hooks should use `useLoader` and `useToast` for consistent UX
|
||||
- Hooks should return data, refetch function, and optionally error state for debugging
|
||||
- Components should NOT display error UI when toasts are already shown
|
||||
|
||||
```jsx
|
||||
// ✅ CORRECT - Custom hook with loader and toast integration
|
||||
import { useState, useEffect } from "react"
|
||||
import useApi from "./useApi"
|
||||
import { useLoader } from "../context/loaders/useLoader"
|
||||
import { useToast } from "../context/toasts/useToast"
|
||||
|
||||
function useProducts() {
|
||||
const [products, setProducts] = useState([])
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const { getProducts } = useApi()
|
||||
const { withLoader } = useLoader()
|
||||
const { addNotification } = useToast()
|
||||
|
||||
const fetchProducts = async () => {
|
||||
try {
|
||||
setError(null)
|
||||
|
||||
// Use withLoader to show loading state
|
||||
const data = await withLoader(
|
||||
() => getProducts(),
|
||||
'Loading products...'
|
||||
)
|
||||
|
||||
setProducts(data)
|
||||
|
||||
// Show success toast
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Products Loaded',
|
||||
message: `Successfully loaded ${data.length} products.`,
|
||||
duration: 3000,
|
||||
})
|
||||
|
||||
return data
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to load products'
|
||||
setError(errorMessage)
|
||||
|
||||
// Show error toast
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Error Loading Products',
|
||||
message: errorMessage,
|
||||
duration: 5000,
|
||||
})
|
||||
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchProducts()
|
||||
}, [])
|
||||
|
||||
return {
|
||||
products,
|
||||
error, // For debugging, not for UI display
|
||||
loading: false, // Loading is handled by global loader
|
||||
refetch: fetchProducts,
|
||||
}
|
||||
}
|
||||
|
||||
export default useProducts
|
||||
```
|
||||
|
||||
```jsx
|
||||
// ✅ CORRECT - Component using the custom hook
|
||||
function Products() {
|
||||
const { products, refetch } = useProducts()
|
||||
|
||||
// Note: No error UI here - toast is already shown by the hook
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Products</h1>
|
||||
<button onClick={() => refetch()}>Refresh</button>
|
||||
{products.map(product => (
|
||||
<ProductCard key={product.id} product={product} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
```jsx
|
||||
// ❌ WRONG - Handling errors in component when toast already shown
|
||||
function Products() {
|
||||
const { products, error, refetch } = useProducts()
|
||||
|
||||
// BAD PRACTICE: This duplicates the error message already shown in toast
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-400 mb-4">Failed to load products</p>
|
||||
<button onClick={() => refetch()}>Retry</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{products.map(product => (
|
||||
<ProductCard key={product.id} product={product} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Why this is bad practice:**
|
||||
1. **Duplicate UI**: Users see the same error message twice (toast + component error UI)
|
||||
2. **Inconsistent UX**: Some operations show toasts, others show component error messages
|
||||
3. **Confusing Experience**: Users don't know which error message to act on
|
||||
4. **Redundant Code**: You're writing error handling twice (in hook and component)
|
||||
5. **Maintenance Issue**: If you change error handling in hook, component UI doesn't update
|
||||
|
||||
**The error state in hooks is for:**
|
||||
- Debugging purposes
|
||||
- Conditional rendering based on error type (rare cases)
|
||||
- Logging or analytics
|
||||
- NOT for displaying error UI to users
|
||||
|
||||
**Benefits of this pattern:**
|
||||
1. **Consistent UX**: All errors shown via toasts in the same place (top-right)
|
||||
2. **Separation of Concerns**: Hook handles data fetching, component handles rendering
|
||||
3. **Global Loading**: Loader covers entire app, preventing double-submissions
|
||||
4. **Auto-Cleanup**: `withLoader` ensures loader is hidden even on errors
|
||||
5. **Clean Components**: Components focus on displaying data, not handling errors
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
### Formatting
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { ModalProvider } from './context/modals/useModal'
|
|||
import { ModalRoot } from './context/modals/ModalRoot'
|
||||
import { ToastProvider } from './context/toasts/useToast'
|
||||
import { ToastRoot } from './context/toasts/ToastRoot'
|
||||
import { LoaderProvider } from './context/loaders/useLoader'
|
||||
import { LoaderRoot } from './context/loaders/LoaderRoot'
|
||||
import Cart from './pages/Cart'
|
||||
import { Navbar } from './components/Navbar'
|
||||
import { Home } from './pages/Home'
|
||||
|
|
@ -13,6 +15,7 @@ import { Orders } from './pages/Orders'
|
|||
|
||||
const App = () => {
|
||||
return (
|
||||
<LoaderProvider>
|
||||
<ToastProvider>
|
||||
<ModalProvider>
|
||||
<div className="min-h-screen bg-gray-900 text-gray-100">
|
||||
|
|
@ -27,11 +30,14 @@ const App = () => {
|
|||
<Route path="/orders" element={<Orders />} />
|
||||
</Routes>
|
||||
</main>
|
||||
{/* Order matters for Z-Index: Loader (70) > Toast (60) > Modal (50) */}
|
||||
<LoaderRoot />
|
||||
<ToastRoot />
|
||||
<ModalRoot />
|
||||
</div>
|
||||
</ModalProvider>
|
||||
</ToastProvider>
|
||||
</LoaderProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
192
frontend/src/context/loaders/LoaderExample.tsx
Normal file
192
frontend/src/context/loaders/LoaderExample.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import { useLoader } from './useLoader';
|
||||
import { useToast } from '../toasts/useToast';
|
||||
|
||||
export const LoaderExample = () => {
|
||||
const { showLoader, hideLoader, withLoader } = useLoader();
|
||||
const { addNotification } = useToast();
|
||||
|
||||
// Pattern A: Manual Control
|
||||
const handleManualLoad = async () => {
|
||||
showLoader("Processing manual task...");
|
||||
|
||||
try {
|
||||
// Simulate an async operation
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Manual Task Complete',
|
||||
message: 'The manual loading task finished successfully.',
|
||||
duration: 3000,
|
||||
});
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Manual Task Failed',
|
||||
message: 'Something went wrong during manual loading.',
|
||||
duration: 5000,
|
||||
});
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
};
|
||||
|
||||
// Pattern B: withLoader Helper (Cleanest)
|
||||
const handleWithLoader = async () => {
|
||||
try {
|
||||
await withLoader(
|
||||
async () => {
|
||||
// Simulate an async operation
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
return 'Success!';
|
||||
},
|
||||
"Processing with withLoader..."
|
||||
);
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'withLoader Task Complete',
|
||||
message: 'The task wrapped in withLoader finished successfully.',
|
||||
duration: 3000,
|
||||
});
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'withLoader Task Failed',
|
||||
message: 'Something went wrong during withLoader.',
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Pattern C: Long-running task
|
||||
const handleLongLoad = async () => {
|
||||
showLoader("Processing long-running task...");
|
||||
|
||||
try {
|
||||
// Simulate a longer async operation
|
||||
await new Promise(resolve => setTimeout(resolve, 4000));
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Long Task Complete',
|
||||
message: 'The long-running task finished successfully.',
|
||||
duration: 3000,
|
||||
});
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Long Task Failed',
|
||||
message: 'Something went wrong during long loading.',
|
||||
duration: 5000,
|
||||
});
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
};
|
||||
|
||||
// Pattern D: Error simulation
|
||||
const handleError = async () => {
|
||||
showLoader("Processing task that will fail...");
|
||||
|
||||
try {
|
||||
// Simulate an error
|
||||
await new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Simulated error')), 1500)
|
||||
);
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Task Complete',
|
||||
message: 'This should not appear.',
|
||||
duration: 3000,
|
||||
});
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Task Failed',
|
||||
message: 'Simulated error occurred as expected.',
|
||||
duration: 5000,
|
||||
});
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
};
|
||||
|
||||
// Pattern E: Without message
|
||||
const handleNoMessage = async () => {
|
||||
showLoader();
|
||||
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'No Message Task Complete',
|
||||
message: 'Loader shown without custom message.',
|
||||
duration: 3000,
|
||||
});
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Task Failed',
|
||||
message: 'Something went wrong.',
|
||||
duration: 5000,
|
||||
});
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold text-white mb-4">Loader System Examples</h3>
|
||||
<p className="text-gray-400 mb-6">
|
||||
Click the buttons below to see different loader patterns in action. The loader uses React Context for state management.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
onClick={handleManualLoad}
|
||||
className="px-4 py-2 text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Manual Control (2s)
|
||||
</button>
|
||||
<button
|
||||
onClick={handleWithLoader}
|
||||
className="px-4 py-2 text-white bg-green-600 rounded hover:bg-green-700 transition-colors"
|
||||
>
|
||||
withLoader Helper (1.5s)
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLongLoad}
|
||||
className="px-4 py-2 text-white bg-purple-600 rounded hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Long Task (4s)
|
||||
</button>
|
||||
<button
|
||||
onClick={handleError}
|
||||
className="px-4 py-2 text-white bg-red-600 rounded hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Error Simulation
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNoMessage}
|
||||
className="px-4 py-2 text-white bg-gray-600 rounded hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
No Message (2s)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-gray-700 rounded-lg border border-gray-600">
|
||||
<h4 className="text-white font-semibold mb-2">Usage Tips:</h4>
|
||||
<ul className="text-gray-300 text-sm space-y-1 list-disc list-inside">
|
||||
<li><strong>Manual Control:</strong> Use showLoader/hideLoader when you need fine-grained control</li>
|
||||
<li><strong>withLoader:</strong> Use the helper for automatic cleanup (recommended)</li>
|
||||
<li><strong>Message:</strong> Optional - provides context about what's happening</li>
|
||||
<li><strong>Z-Index:</strong> Loader (70) sits above Toasts (60) and Modals (50)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
33
frontend/src/context/loaders/LoaderRoot.tsx
Normal file
33
frontend/src/context/loaders/LoaderRoot.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { createPortal } from 'react-dom';
|
||||
import { useLoader } from './useLoader';
|
||||
|
||||
export const LoaderRoot = () => {
|
||||
const { isLoading, message } = useLoader();
|
||||
|
||||
if (!isLoading) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex flex-col items-center justify-center bg-black/60 backdrop-blur-sm transition-opacity duration-200"
|
||||
role="alert"
|
||||
aria-busy="true"
|
||||
aria-label={message || "Loading"}
|
||||
>
|
||||
{/* Custom CSS Spinner (No external libs) */}
|
||||
<div className="relative">
|
||||
<div className="w-16 h-16 border-4 border-white/30 border-t-white rounded-full animate-spin"></div>
|
||||
{/* Optional inner circle for style */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-4 border-white/50 border-b-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<p className="mt-4 text-white font-medium text-lg animate-pulse">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
2
frontend/src/context/loaders/index.ts
Normal file
2
frontend/src/context/loaders/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { LoaderProvider, useLoader } from './useLoader';
|
||||
export { LoaderRoot } from './LoaderRoot';
|
||||
56
frontend/src/context/loaders/useLoader.tsx
Normal file
56
frontend/src/context/loaders/useLoader.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { createContext, useContext, useState, useCallback, ReactNode, FC } from 'react';
|
||||
|
||||
interface LoaderState {
|
||||
isLoading: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface LoaderContextType extends LoaderState {
|
||||
showLoader: (message?: string) => void;
|
||||
hideLoader: () => void;
|
||||
// Optional: Helper to wrap async functions automatically
|
||||
withLoader: <T>(fn: () => Promise<T>, message?: string) => Promise<T>;
|
||||
}
|
||||
|
||||
const LoaderContext = createContext<LoaderContextType | undefined>(undefined);
|
||||
|
||||
interface LoaderProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const LoaderProvider: FC<LoaderProviderProps> = ({ children }) => {
|
||||
const [state, setState] = useState<LoaderState>({ isLoading: false });
|
||||
|
||||
const showLoader = useCallback((message?: string) => {
|
||||
setState({ isLoading: true, message });
|
||||
}, []);
|
||||
|
||||
const hideLoader = useCallback(() => {
|
||||
setState({ isLoading: false, message: undefined });
|
||||
}, []);
|
||||
|
||||
// Helper to avoid try/finally blocks everywhere
|
||||
const withLoader = useCallback(async <T,>(
|
||||
fn: () => Promise<T>,
|
||||
message?: string
|
||||
): Promise<T> => {
|
||||
showLoader(message);
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}, [showLoader, hideLoader]);
|
||||
|
||||
return (
|
||||
<LoaderContext.Provider value={{ ...state, showLoader, hideLoader, withLoader }}>
|
||||
{children}
|
||||
</LoaderContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useLoader = () => {
|
||||
const context = useContext(LoaderContext);
|
||||
if (!context) throw new Error('useLoader must be used within a LoaderProvider');
|
||||
return context;
|
||||
};
|
||||
63
frontend/src/hooks/useProducts.ts
Normal file
63
frontend/src/hooks/useProducts.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useApi } from './useApi';
|
||||
import { useLoader } from '../context/loaders/useLoader';
|
||||
import { useToast } from '../context/toasts/useToast';
|
||||
import { ProductData } from '../types';
|
||||
|
||||
export function useProducts() {
|
||||
const [products, setProducts] = useState<ProductData[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { getProducts } = useApi();
|
||||
const { withLoader } = useLoader();
|
||||
const { addNotification } = useToast();
|
||||
|
||||
const fetchProducts = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
// Use withLoader to show loading state and handle errors
|
||||
const data = await withLoader(
|
||||
() => getProducts(),
|
||||
'Loading products...'
|
||||
);
|
||||
|
||||
setProducts(data);
|
||||
|
||||
// // Show success toast
|
||||
// addNotification({
|
||||
// type: 'success',
|
||||
// title: 'Products Loaded',
|
||||
// message: `Successfully loaded ${data.length} products.`,
|
||||
// duration: 3000,
|
||||
// });
|
||||
|
||||
return data;
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to load products';
|
||||
setError(errorMessage);
|
||||
|
||||
// Show error toast
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Error Loading Products',
|
||||
message: errorMessage,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Optionally auto-fetch on mount
|
||||
useEffect(() => {
|
||||
fetchProducts();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
products,
|
||||
error,
|
||||
loading: false, // Loading is handled by the global loader
|
||||
refetch: fetchProducts,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
import { ModalExample } from '../context/modals/ModalExample'
|
||||
import { ToastExample } from '../context/toasts/ToastExample'
|
||||
import { LoaderExample } from '../context/loaders/LoaderExample'
|
||||
|
||||
export function Home() {
|
||||
return (
|
||||
|
|
@ -50,6 +51,14 @@ export function Home() {
|
|||
</p>
|
||||
<ToastExample />
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<h2 className="text-2xl font-semibold text-white mb-4">Loader System Demo</h2>
|
||||
<p className="text-gray-400 mb-6">
|
||||
Test our global loading system with this interactive example. The loader uses React Context for state management.
|
||||
</p>
|
||||
<LoaderExample />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,40 +1,22 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useApp } from '../context/AppContext'
|
||||
import { useApi } from '../hooks/useApi'
|
||||
import { ProductData, CartItem } from '../types'
|
||||
import { useProducts } from '../hooks/useProducts'
|
||||
import { CartItem } from '../types'
|
||||
|
||||
export function Products() {
|
||||
const [products, setProducts] = useState<ProductData[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const { products, refetch } = useProducts()
|
||||
const { addToCart } = useApp()
|
||||
const { getProducts } = useApi()
|
||||
|
||||
useEffect(() => {
|
||||
fetchProducts()
|
||||
}, [])
|
||||
|
||||
const fetchProducts = async () => {
|
||||
try {
|
||||
const data = await getProducts()
|
||||
setProducts(data)
|
||||
} catch (error) {
|
||||
console.error('Error fetching products:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-400">Loading products...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white mb-8">Products</h1>
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-white">Products</h1>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="bg-gray-700 hover:bg-gray-600 text-white px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{products.map((product) => (
|
||||
<div
|
||||
|
|
|
|||
Loading…
Reference in a new issue