เพิ่ม Toast Component
This commit is contained in:
parent
693ca097f4
commit
5670ef88e1
14
web/package-lock.json
generated
14
web/package-lock.json
generated
@ -23,6 +23,7 @@
|
|||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
"react-router-dom": "^7.9.5",
|
"react-router-dom": "^7.9.5",
|
||||||
"tailwindcss": "^4.1.16",
|
"tailwindcss": "^4.1.16",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
"yup": "^1.7.1"
|
"yup": "^1.7.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -3834,6 +3835,19 @@
|
|||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uuid": {
|
||||||
|
"version": "13.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||||
|
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist-node/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.1.12",
|
"version": "7.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz",
|
||||||
|
|||||||
@ -25,6 +25,7 @@
|
|||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
"react-router-dom": "^7.9.5",
|
"react-router-dom": "^7.9.5",
|
||||||
"tailwindcss": "^4.1.16",
|
"tailwindcss": "^4.1.16",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
"yup": "^1.7.1"
|
"yup": "^1.7.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -2,12 +2,13 @@ import { BrowserRouter, Route, Routes } from "react-router-dom";
|
|||||||
|
|
||||||
import AuthRoutes from "./routes/AuthRoutes.jsx";
|
import AuthRoutes from "./routes/AuthRoutes.jsx";
|
||||||
import PrivateRoutes from "./routes/PrivateRoutes.jsx";
|
import PrivateRoutes from "./routes/PrivateRoutes.jsx";
|
||||||
|
import ToastNotification from './components/ToastNotification.jsx';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
// ต้องห่อหุ้มด้วย Provider ของ Redux และ QueryClient ใน main.jsx
|
// ต้องห่อหุ้มด้วย Provider ของ Redux และ QueryClient ใน main.jsx
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<ToastNotification />
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Private Routes: ตรวจสอบล็อกอินก่อนเข้า /dashboard/* */}
|
{/* Private Routes: ตรวจสอบล็อกอินก่อนเข้า /dashboard/* */}
|
||||||
<Route path="/dashboard/*" element={<PrivateRoutes/>}/>
|
<Route path="/dashboard/*" element={<PrivateRoutes/>}/>
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import { configureStore } from '@reduxjs/toolkit';
|
import { configureStore } from '@reduxjs/toolkit';
|
||||||
import authReducer from '../features/auth/authSlice';
|
import authReducer from '../features/auth/authSlice';
|
||||||
|
import toastReducer from '../features/toast/toastSlice';
|
||||||
|
|
||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
// กำหนด Reducer หลัก
|
// กำหนด Reducer หลัก
|
||||||
auth: authReducer,
|
auth: authReducer,
|
||||||
|
toast: toastReducer,
|
||||||
// [เพิ่ม Reducer อื่น ๆ ที่นี่]
|
// [เพิ่ม Reducer อื่น ๆ ที่นี่]
|
||||||
},
|
},
|
||||||
// ปิด serializableCheck ใน Production เพื่อประสิทธิภาพ
|
// ปิด serializableCheck ใน Production เพื่อประสิทธิภาพ
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTestConnection } from '../../services/modelRegistryApi'; // Hook สำหรับ Test Connection
|
import { useTestConnection } from '../../services/modelRegistryApi'; // Hook สำหรับ Test Connection
|
||||||
import { FaCheckCircle, FaExclamationTriangle, FaTrash, FaEdit } from 'react-icons/fa';
|
import { FaCheckCircle, FaExclamationTriangle, FaTrash, FaEdit } from 'react-icons/fa';
|
||||||
|
import { useDispatch } from 'react-redux';
|
||||||
|
import { addToast } from '../../features/toast/toastSlice';
|
||||||
|
|
||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
// Helper Function: แสดง Badge สถานะ
|
// Helper Function: แสดง Badge สถานะ
|
||||||
@ -17,7 +18,10 @@ const getStatusBadge = (status) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function ModelTable({ models, handleOpenEdit, handleDelete, deleteLoading }) {
|
function ModelTable({ models, handleOpenEdit, handleDelete, deleteLoading }) {
|
||||||
// 1. Hook สำหรับทดสอบการเชื่อมต่อ (Test Connection)
|
// ประกาศ useDispatch Hook
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
// Hook สำหรับทดสอบการเชื่อมต่อ (Test Connection)
|
||||||
// ใช้ Hook ตรงนี้ เพราะเป็น Logic ที่เกี่ยวข้องกับ Action ในตารางโดยตรง
|
// ใช้ Hook ตรงนี้ เพราะเป็น Logic ที่เกี่ยวข้องกับ Action ในตารางโดยตรง
|
||||||
const testConnectionMutation = useTestConnection();
|
const testConnectionMutation = useTestConnection();
|
||||||
|
|
||||||
@ -26,10 +30,18 @@ function ModelTable({ models, handleOpenEdit, handleDelete, deleteLoading }) {
|
|||||||
testConnectionMutation.mutate(modelId, {
|
testConnectionMutation.mutate(modelId, {
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
const status = result.status === 'success' ? 'SUCCESS' : 'FAILED';
|
const status = result.status === 'success' ? 'SUCCESS' : 'FAILED';
|
||||||
alert(`Test Result: ${status}\nMessage: ${result.message || JSON.stringify(result.response_data)}`);
|
const message = `Test Result: ${status}. Service responded successfully.`;
|
||||||
|
dispatch(addToast({
|
||||||
|
message: message,
|
||||||
|
type: status === 'SUCCESS' ? 'success' : 'error'
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
alert(`Test Failed! Error: ${error.message || 'Check network or base URL'}`);
|
const errorMessage = error.message || 'Check network or base URL';
|
||||||
|
dispatch(addToast({
|
||||||
|
message: `Test Failed: ${errorMessage}`,
|
||||||
|
type: 'error'
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
137
web/src/components/ToastNotification.jsx
Normal file
137
web/src/components/ToastNotification.jsx
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useSelector, useDispatch } from 'react-redux';
|
||||||
|
import { removeToast } from '../features/toast/toastSlice';
|
||||||
|
|
||||||
|
const TOAST_DURATION = 3000;
|
||||||
|
|
||||||
|
// ----------------------------------------------------
|
||||||
|
// Helper Functions
|
||||||
|
// ----------------------------------------------------
|
||||||
|
|
||||||
|
const getColors = (type) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'success':
|
||||||
|
return 'alert-success';
|
||||||
|
case 'error':
|
||||||
|
return 'alert-error';
|
||||||
|
case 'warning':
|
||||||
|
return 'alert-warning';
|
||||||
|
case 'info':
|
||||||
|
return 'alert-info';
|
||||||
|
default:
|
||||||
|
return 'alert-info';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIcon = (type) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'success':
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="stroke-current shrink-0 h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'error':
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="stroke-current shrink-0 h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'warning':
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="stroke-current shrink-0 h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667
|
||||||
|
1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464
|
||||||
|
0L3.332 16c-.77 1.333.192 3 1.732 3z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'info':
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="stroke-current shrink-0 w-6 h-6"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M13 16h-1v-4h-1m1-4h.01M21
|
||||||
|
12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ----------------------------------------------------
|
||||||
|
// ToastNotification Component
|
||||||
|
// ----------------------------------------------------
|
||||||
|
|
||||||
|
export default function ToastNotification() {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const queue = useSelector((state) => state.toast.queue);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (queue.length > 0) {
|
||||||
|
queue.forEach((toast) => {
|
||||||
|
if (!toast.timerId) {
|
||||||
|
setTimeout(() => {
|
||||||
|
dispatch(removeToast({ id: toast.id }));
|
||||||
|
}, TOAST_DURATION);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [queue, dispatch]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed top-0 right-0 z-[100] p-4 space-y-3 pointer-events-none">
|
||||||
|
{queue.map((toast) => (
|
||||||
|
<div
|
||||||
|
key={toast.id}
|
||||||
|
className={`alert ${getColors(toast.type)} shadow-xl transition-all duration-500 ease-out transform translate-x-0 opacity-100 max-w-sm`}
|
||||||
|
style={{ pointerEvents: 'auto' }}
|
||||||
|
onClick={() => dispatch(removeToast({ id: toast.id }))}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2 text-white">
|
||||||
|
{getIcon(toast.type)}
|
||||||
|
<span>{toast.message}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
web/src/features/toast/toastSlice.js
Normal file
31
web/src/features/toast/toastSlice.js
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { createSlice } from '@reduxjs/toolkit';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
const TOAST_DURATION = 3000; // 3 วินาที
|
||||||
|
|
||||||
|
const toastSlice = createSlice({
|
||||||
|
name: 'toast',
|
||||||
|
initialState: {
|
||||||
|
queue: [], // สถานะเก็บรายการ Toast ทั้งหมดที่ต้องแสดง
|
||||||
|
},
|
||||||
|
reducers: {
|
||||||
|
// Action สำหรับเพิ่ม Toast เข้าคิว
|
||||||
|
addToast: (state, action) => {
|
||||||
|
const newToast = {
|
||||||
|
id: uuidv4(),
|
||||||
|
message: action.payload.message,
|
||||||
|
type: action.payload.type || 'info',
|
||||||
|
};
|
||||||
|
state.queue.push(newToast);
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
// Action สำหรับลบ Toast ออกจากคิว
|
||||||
|
removeToast: (state, action) => {
|
||||||
|
state.queue = state.queue.filter(toast => toast.id !== action.payload.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { addToast, removeToast } = toastSlice.actions;
|
||||||
|
export default toastSlice.reducer;
|
||||||
@ -1,5 +1,7 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import axiosClient from './axiosClient'; // Axios Client ที่มี JWT Interceptor
|
import axiosClient from './axiosClient'; // Axios Client ที่มี JWT Interceptor
|
||||||
|
import { useDispatch } from 'react-redux';
|
||||||
|
import { addToast } from '../features/toast/toastSlice';
|
||||||
|
|
||||||
const STALE_TIME = 60 * 1000; // 1 นาที
|
const STALE_TIME = 60 * 1000; // 1 นาที
|
||||||
|
|
||||||
@ -32,6 +34,7 @@ export const useModelList = () => {
|
|||||||
*/
|
*/
|
||||||
export const useCreateModel = () => {
|
export const useCreateModel = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const dispatch = useDispatch();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (modelData) => {
|
mutationFn: async (modelData) => {
|
||||||
// modelData: { name, model_version, base_url, inference_path, ... }
|
// modelData: { name, model_version, base_url, inference_path, ... }
|
||||||
@ -39,11 +42,12 @@ export const useCreateModel = () => {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
alert('Model ถูกลงทะเบียนสำเร็จแล้ว!');
|
dispatch(addToast({ message: 'Model ถูกลงทะเบียนสำเร็จแล้ว!', type: 'success' }));
|
||||||
queryClient.invalidateQueries({ queryKey: ['modelList'] });
|
queryClient.invalidateQueries({ queryKey: ['modelList'] });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
alert(`การลงทะเบียนล้มเหลว: ${error.response?.data?.detail || 'โปรดตรวจสอบข้อมูล'}`);
|
const errorMessage = error.response?.data?.detail || 'โปรดตรวจสอบข้อมูล';
|
||||||
|
dispatch(addToast({ message: `การลงทะเบียนล้มเหลว: ${errorMessage}`, type: 'error' }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -54,17 +58,19 @@ export const useCreateModel = () => {
|
|||||||
*/
|
*/
|
||||||
export const useDeleteModel = () => {
|
export const useDeleteModel = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const dispatch = useDispatch();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (modelId) => {
|
mutationFn: async (modelId) => {
|
||||||
const response = await axiosClient.delete(`/api/v1/models/${modelId}/`);
|
const response = await axiosClient.delete(`/api/v1/models/${modelId}/`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
alert('Model ถูกลบสำเร็จแล้ว!');
|
dispatch(addToast({ message: 'Model ถูกลบสำเร็จแล้ว!', type: 'success' }));
|
||||||
queryClient.invalidateQueries({ queryKey: ['modelList'] });
|
queryClient.invalidateQueries({ queryKey: ['modelList'] });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
alert(`การลบล้มเหลว: ${error.response?.data?.detail || 'คุณอาจไม่มีสิทธิ์'}`);
|
const errorMessage = error.response?.data?.detail || 'คุณอาจไม่มีสิทธิ์';
|
||||||
|
dispatch(addToast({ message: `การลบล้มเหลว: ${errorMessage}`, type: 'error' }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -89,6 +95,7 @@ export const useTestConnection = () => {
|
|||||||
*/
|
*/
|
||||||
export const useUpdateModel = () => {
|
export const useUpdateModel = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const dispatch = useDispatch();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
// mutationFn รับ object ที่มี { id: number, data: object }
|
// mutationFn รับ object ที่มี { id: number, data: object }
|
||||||
mutationFn: async ({ id, data }) => {
|
mutationFn: async ({ id, data }) => {
|
||||||
@ -97,12 +104,13 @@ export const useUpdateModel = () => {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
alert('Model ถูกแก้ไขสำเร็จแล้ว!');
|
dispatch(addToast({ message: 'Model ถูกแก้ไขสำเร็จแล้ว!', type: 'success' }));
|
||||||
// Invalidate query list เพื่อบังคับให้ตารางอัปเดตข้อมูล
|
// Invalidate query list เพื่อบังคับให้ตารางอัปเดตข้อมูล
|
||||||
queryClient.invalidateQueries({ queryKey: ['modelList'] });
|
queryClient.invalidateQueries({ queryKey: ['modelList'] });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
alert(`การแก้ไขล้มเหลว: ${error.response?.data?.detail || 'โปรดตรวจสอบข้อมูล'}`);
|
const errorMessage = error.response?.data?.detail || 'โปรดตรวจสอบข้อมูล';
|
||||||
|
dispatch(addToast({ message: `การแก้ไขล้มเหลว: ${errorMessage}`, type: 'error' }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user