add loader

This commit is contained in:
david 2026-02-24 14:03:23 +03:00
parent 91917289e2
commit abce2bb6ef
10 changed files with 538 additions and 49 deletions

View file

@ -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

View file

@ -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

View file

@ -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,25 +15,29 @@ import { Orders } from './pages/Orders'
const App = () => {
return (
<ToastProvider>
<ModalProvider>
<div className="min-h-screen bg-gray-900 text-gray-100">
<Navbar />
<main className="flex-1 p-8 max-w-7xl mx-auto w-full">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/products" element={<Products />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/cart" element={<Cart />} />
<Route path="/orders" element={<Orders />} />
</Routes>
</main>
<ToastRoot />
<ModalRoot />
</div>
</ModalProvider>
</ToastProvider>
<LoaderProvider>
<ToastProvider>
<ModalProvider>
<div className="min-h-screen bg-gray-900 text-gray-100">
<Navbar />
<main className="flex-1 p-8 max-w-7xl mx-auto w-full">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/products" element={<Products />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/cart" element={<Cart />} />
<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>
)
}

View 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>
);
};

View 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
);
};

View file

@ -0,0 +1,2 @@
export { LoaderProvider, useLoader } from './useLoader';
export { LoaderRoot } from './LoaderRoot';

View 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;
};

View 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,
};
}

View file

@ -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>
)
}

View file

@ -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