diff --git a/.github/workflows/deploy-master.yml b/.github/workflows/deploy-master.yml index 952887a..09e0653 100644 --- a/.github/workflows/deploy-master.yml +++ b/.github/workflows/deploy-master.yml @@ -1,33 +1,37 @@ name: Build and Deploy on: - push: - branches: [ "master" ] - pull_request: - branches: [ "master" ] + push: + branches: ["master"] + pull_request: + branches: ["master"] jobs: - build: - runs-on: ubuntu-latest + build: + runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 + steps: + - uses: actions/checkout@v4 - - name: Use Node.js 22.x - uses: actions/setup-node@v4 - with: - node-version: 22.x + - name: Use Node.js 22.x + uses: actions/setup-node@v4 + with: + node-version: 22.x - - name: Build - run: | - npm install - npm run build + - name: Build + run: | + npm install + npm run build - - name: Get commit short hash - id: vars - run: | - echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT - - name: Post request - run: | - curl -s -X POST -k ${{ secrets.WEBHOOK_URL }}?TAG=${{ steps.vars.outputs.sha_short }} \ - -H 'sudo-token: ${{ secrets.API_SUDO_TOKEN }}' + - name: Get commit short hash + id: vars + run: | + echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + + - name: Post request + env: + API_WEBHOOK_URL: ${{ secrets.WEBHOOK_URL }} + if: ${{ env.API_WEBHOOK_URL != '' && env.API_WEBHOOK_URL != null }} + run: | + curl -s -X POST -k ${{ secrets.WEBHOOK_URL }}?TAG=${{ steps.vars.outputs.sha_short }} \ + -H 'sudo-token: ${{ secrets.API_SUDO_TOKEN }}' diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a4531e8 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "cSpell.words": [ + "snackbars" + ] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 44318bc..5bf47a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "axios": "^1.7.4", + "notistack": "^3.0.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.26.0", @@ -30,6 +31,7 @@ "web-vitals": "^4.2.3" }, "devDependencies": { + "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@babel/plugin-transform-private-property-in-object": "^7.24.7" } }, @@ -752,10 +754,17 @@ } }, "node_modules/@babel/plugin-proposal-private-property-in-object": { - "version": "7.21.0-placeholder-for-preset-env.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", - "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", - "license": "MIT", + "version": "7.21.11", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.11.tgz", + "integrity": "sha512-0QZ8qP/3RLDVBwBFoWAwCtgcDZJVwA5LUJRZU8x2YFfKNuFq161wK3cuGrALu5yiPu+vzwTAg/sMWVNeWeNyaw==", + "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-private-property-in-object instead.", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-create-class-features-plugin": "^7.21.0", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5" + }, "engines": { "node": ">=6.9.0" }, @@ -2096,6 +2105,17 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/preset-env/node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/preset-env/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -9977,6 +9997,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/goober": { + "version": "2.1.14", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.14.tgz", + "integrity": "sha512-4UpC0NdGyAFqLNPnhCT2iHpza2q+RAY3GV85a/mRPdzyPQMsj0KmMMuetdIkzWRbJ+Hgau1EZztq8ImmiMGhsg==", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -13077,6 +13105,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/notistack": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/notistack/-/notistack-3.0.1.tgz", + "integrity": "sha512-ntVZXXgSQH5WYfyU+3HfcXuKaapzAJ8fBLQ/G618rn3yvSzEbnOB8ZSOwhX+dAORy/lw+GC2N061JA0+gYWTVA==", + "dependencies": { + "clsx": "^1.1.0", + "goober": "^2.0.33" + }, + "engines": { + "node": ">=12.0.0", + "npm": ">=6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/notistack" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/notistack/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "engines": { + "node": ">=6" + } + }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", diff --git a/package.json b/package.json index 9655d59..15d0455 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "axios": "^1.7.4", + "notistack": "^3.0.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.26.0", @@ -55,6 +56,7 @@ ] }, "devDependencies": { - "@babel/plugin-transform-private-property-in-object": "^7.24.7" + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-proposal-private-property-in-object": "^7.21.11" } } diff --git a/src/App.tsx b/src/App.tsx index 0a7c46d..b3c2941 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import CreateUserPage from './pages/createUserPage'; import Dashboard from './pages/dashboardPage'; import SearchUserPage from './pages/searchUserPage'; import UserInfoPage from './pages/userInfoPage'; +import { Box, CircularProgress, Typography } from '@mui/material'; const App: React.FC = () => { const [isLoggedIn, setIsLoggedIn] = useState(apiClient.isLoggedIn()); @@ -32,7 +33,28 @@ const App: React.FC = () => { }, []); if (isLoading) { - return
{CurrentAppTranslation.LoadingText}
; + return ( + + + + {CurrentAppTranslation.LoadingText} + + + Please wait while we load the content for you. + + + ); } return ( diff --git a/src/components/menus/selectMenu.tsx b/src/components/menus/selectMenu.tsx index a49c39f..a8169a9 100644 --- a/src/components/menus/selectMenu.tsx +++ b/src/components/menus/selectMenu.tsx @@ -4,7 +4,7 @@ import Box from '@mui/material/Box'; import InputLabel from '@mui/material/InputLabel'; import MenuItem from '@mui/material/MenuItem'; import FormControl from '@mui/material/FormControl'; -import Select, { SelectChangeEvent } from '@mui/material/Select'; +import Select from '@mui/material/Select'; interface SelectMenuProps { diff --git a/src/components/snackbars/AppSnackBarProvider.tsx b/src/components/snackbars/AppSnackBarProvider.tsx new file mode 100644 index 0000000..84f37e0 --- /dev/null +++ b/src/components/snackbars/AppSnackBarProvider.tsx @@ -0,0 +1,18 @@ +"use client"; + +import React from "react"; +import { SnackbarProvider } from "notistack"; + +export default function AppSnackBarProvider(props: { children: JSX.Element }) { + return ( + + {props.children} + + ); +} diff --git a/src/components/snackbars/SnackbarButtons.css b/src/components/snackbars/SnackbarButtons.css new file mode 100644 index 0000000..ea3ffa6 --- /dev/null +++ b/src/components/snackbars/SnackbarButtons.css @@ -0,0 +1,8 @@ +.go703367398 { + padding: 0 !important; + margin: 0 !important; +} + +.notistack-MuiContent { + justify-content: space-between; +} \ No newline at end of file diff --git a/src/components/snackbars/SnackbarButtons.tsx b/src/components/snackbars/SnackbarButtons.tsx new file mode 100644 index 0000000..722a2be --- /dev/null +++ b/src/components/snackbars/SnackbarButtons.tsx @@ -0,0 +1,28 @@ +import { Close } from "@mui/icons-material"; +import { IconButton } from "@mui/material"; +import { closeSnackbar } from "notistack"; +import React from "react"; +import "./SnackbarButtons.css"; + +interface SnackbarButtonsProps { + snackbarId: string | number; +} + +export default function SnackbarButtons(props: SnackbarButtonsProps) { + const { snackbarId } = props; + return ( + closeSnackbar(snackbarId)} + sx={{ + color: "white", + opacity: "0.4", + "&:hover": { + bgcolor: "unset", + opacity: "1", + }, + }} + > + + + ); +} diff --git a/src/components/snackbars/useAppSnackbars.tsx b/src/components/snackbars/useAppSnackbars.tsx new file mode 100644 index 0000000..91f74fb --- /dev/null +++ b/src/components/snackbars/useAppSnackbars.tsx @@ -0,0 +1,70 @@ +import { SnackbarAction, SnackbarKey, useSnackbar } from "notistack"; +import SnackbarButtons from "./SnackbarButtons"; + +interface AppSnackbarProps { + action?: SnackbarAction; + key?: SnackbarKey; + hideDuration?: number; + preventAutoHide?: boolean; + preventAction?: boolean; +} + +const useAppSnackbar = () => { + const { enqueueSnackbar } = useSnackbar(); + + const success = (message: string, props?: AppSnackbarProps) => { + enqueueSnackbar(message, { + variant: "success", + key: props?.key, + autoHideDuration: props?.preventAutoHide + ? null + : props?.hideDuration ?? 5 * 1000, + action: (key) => + props?.preventAction + ? undefined + : SnackbarButtons({ + snackbarId: key, + }), + }); + }; + + const error = (message: string, props?: AppSnackbarProps) => { + enqueueSnackbar(message, { + key: props?.key, + autoHideDuration: props?.preventAutoHide + ? null + : props?.hideDuration ?? 20 * 1000, + variant: "error", + action: (key) => + props?.preventAction + ? undefined + : SnackbarButtons({ + snackbarId: key, + }), + }); + }; + + const warning = (message: string, props?: AppSnackbarProps) => { + enqueueSnackbar(message, { + variant: "warning", + key: props?.key, + autoHideDuration: props?.preventAutoHide + ? null + : props?.hideDuration ?? 20 * 1000, + action: (key) => + props?.preventAction + ? undefined + : SnackbarButtons({ + snackbarId: key, + }), + }); + }; + + return { + success, + error, + warning, + }; +}; + +export default useAppSnackbar; diff --git a/src/index.tsx b/src/index.tsx index 032464f..9b482a5 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,13 +3,16 @@ import ReactDOM from 'react-dom/client'; import './index.css'; import App from './App'; import reportWebVitals from './reportWebVitals'; +import AppSnackBarProvider from './components/snackbars/AppSnackBarProvider'; const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement ); root.render( - + + + ); diff --git a/src/pages/createUserPage.tsx b/src/pages/createUserPage.tsx index 95084ea..6c5b766 100644 --- a/src/pages/createUserPage.tsx +++ b/src/pages/createUserPage.tsx @@ -1,4 +1,4 @@ -import React, { ChangeEventHandler, FormEvent, useState } from 'react'; +import React, { useState } from 'react'; import SubmitButton from '../components/buttons/submitButton'; import DashboardContainer from '../components/containers/dashboardContainer'; import TitleLabel from '../components/labels/titleLabel'; @@ -8,105 +8,99 @@ import SelectMenu from '../components/menus/selectMenu'; import apiClient from '../apiClient'; import { CreateUserData, UserRole } from '../api'; import { CurrentAppTranslation } from '../translations/appTranslation'; -import ErrorLabel from '../components/labels/errorLabel'; -import SuccessLabel from '../components/labels/successLabel'; import { TextField } from '@mui/material'; +import useAppSnackbar from '../components/snackbars/useAppSnackbars'; +import { extractErrorDetails } from '../utils/errorUtils'; const CreateUserPage: React.FC = () => { - const [userInfo, setUserInfo] = useState({ - user_id: '', - email: '', - password: '', - role: UserRole.UserRoleStudent, - }); - const [errText, setErrText] = useState(''); - const [successText, setSuccessText] = useState(''); + const [userInfo, setUserInfo] = useState({ + user_id: '', + email: '', + password: '', + role: UserRole.UserRoleStudent, + }); + const snackbar = useAppSnackbar(); - const handleInputChange = (e: React.ChangeEvent) => { - setUserInfo({ ...userInfo, [e.target.name]: e.target.value }); - setSuccessText(''); - setErrText(''); - }; + const handleInputChange = (e: React.ChangeEvent) => { + setUserInfo({ ...userInfo, [e.target.name]: e.target.value }); + }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - setSuccessText(''); - setErrText(''); try { await apiClient.createNewUser(userInfo); - setSuccessText('User created successfully'); + snackbar.success(CurrentAppTranslation.UserCreatedSuccessfullyText); } catch (error: any) { - setErrText('Failed to create user: ' + error?.response?.data?.error?.message ?? 'Unknown error'); + const [errCode, errMessage] = extractErrorDetails(error); + snackbar.error(`Failed to create new user (${errCode}): ${errMessage}`); } }; - return ( - - - - Create New User - { errText && {errText} } - { successText && {successText} } - {handleInputChange(e as any)}} - required /> - {handleInputChange(e as any)}} - required /> - {handleInputChange(e as any)}} - required /> - {handleInputChange(e as any)}} - required /> + return ( + + + + {CurrentAppTranslation.CreateNewUserText} + { handleInputChange(e as any) }} + required /> + { handleInputChange(e as any) }} + required /> + { handleInputChange(e as any) }} + required /> + { handleInputChange(e as any) }} + required /> apiClient.canCreateTargetRole(role))} /> - {CurrentAppTranslation.CreateUserButtonText} - - - - ); + {CurrentAppTranslation.CreateUserButtonText} + + + + ); }; export default CreateUserPage; \ No newline at end of file diff --git a/src/pages/dashboardPage.tsx b/src/pages/dashboardPage.tsx index 831dec4..22c8647 100644 --- a/src/pages/dashboardPage.tsx +++ b/src/pages/dashboardPage.tsx @@ -1,6 +1,5 @@ import React, { useEffect } from 'react'; import styled from 'styled-components'; -import { useNavigate } from 'react-router-dom'; import apiClient from '../apiClient'; import DashboardContainer from '../components/containers/dashboardContainer'; @@ -33,27 +32,14 @@ const ListItem = styled.li` margin-bottom: 5px; `; -const Button = styled.button` - background-color: #007bff; - color: white; - border: none; - padding: 10px 15px; - border-radius: 4px; - cursor: pointer; - margin-right: 10px; -`; - const Dashboard: React.FC = () => { - const navigate = useNavigate(); - const fetchUserInfo = async () => { try { await apiClient.getCurrentUserInfo(); } catch (error) { console.error(`Failed to get user info: ${error}`); apiClient.clearTokens(); - navigate('/login'); - window.location.reload(); + window.location.href = '/login'; } }; @@ -88,12 +74,6 @@ const Dashboard: React.FC = () => { - {(apiClient.isAdmin() || apiClient.isTeacher()) && ( -
- - -
- )} ) }; diff --git a/src/pages/loginPage.tsx b/src/pages/loginPage.tsx index 5215c08..938287f 100644 --- a/src/pages/loginPage.tsx +++ b/src/pages/loginPage.tsx @@ -1,7 +1,6 @@ import React, { useState, useEffect } from 'react'; import styled from 'styled-components'; import { Navigate } from 'react-router-dom'; -import { useNavigate } from 'react-router-dom'; import apiClient from '../apiClient'; import { APIErrorCode } from '../api'; @@ -11,9 +10,11 @@ import ReloadButton from '../components/buttons/reloadButton'; import LoginInput from '../components/inputs/loginInput'; import CaptchaImage from '../components/images/captchaImage'; import SubmitButton from '../components/buttons/submitButton'; -import ErrorLabel from '../components/labels/errorLabel'; import CaptchaInput from '../components/inputs/captchaInput'; import LoginForm from '../components/forms/loginForm'; +import { extractErrorDetails } from '../utils/errorUtils'; +import useAppSnackbar from '../components/snackbars/useAppSnackbars'; +import { CurrentAppTranslation } from '../translations/appTranslation'; /********************************************/ const CaptchaContainer = styled.div` @@ -24,114 +25,111 @@ const CaptchaContainer = styled.div` const Login = () => { - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - const [loginError, setLoginError] = useState(''); - const [captchaAnswer, setCaptchaAnswer] = useState(''); - const [captchaImage, setCaptchaImage] = useState(''); - const [isCaptchaIncorrect, setIsCaptchaIncorrect] = useState(false); - const [isLoggedIn, setIsLoggedIn] = useState(false); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [captchaAnswer, setCaptchaAnswer] = useState(''); + const [captchaImage, setCaptchaImage] = useState(''); + const [isCaptchaIncorrect, setIsCaptchaIncorrect] = useState(false); + const [isLoggedIn, setIsLoggedIn] = useState(false); + const snackbar = useAppSnackbar(); - const navigate = useNavigate(); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - try { - const result = await apiClient.loginWithPass({ - user_id: username, - password: password, - captcha_id: apiClient.lastCaptchaId, - captcha_answer: captchaAnswer, - client_rid: apiClient.clientRId, - }) - - console.log(`logged in as ${result.user_id} | ${result.full_name}`); - setIsLoggedIn(true); - navigate('/dashboard'); - window.location.reload(); - } catch (error: any) { - let errorCode = error.response?.data?.error.code; - if (!errorCode) { - setLoginError('Unknown error occurred. Please try again later.'); - // this might also be a network failure...hence why it's better we don't - // try to reload the captcha or other things here *automatically*. - return; - } + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + try { + const result = await apiClient.loginWithPass({ + user_id: username, + password: password, + captcha_id: apiClient.lastCaptchaId, + captcha_answer: captchaAnswer, + client_rid: apiClient.clientRId, + }) - switch (error.response?.data?.error.code) { - case APIErrorCode.ErrCodeInvalidCaptcha: - setLoginError('Invalid CAPTCHA. Please try again.'); - reloadCaptcha(); - setIsCaptchaIncorrect(true); - break; - case APIErrorCode.ErrCodeInvalidUsernamePass: - setLoginError('Invalid username or password.'); - reloadCaptcha(); - break; - default: - setLoginError(`An error occurred (${error.response?.data?.error.code}). Please try again later.`); - break; - } - } - }; + console.log(`logged in as ${result.user_id} | ${result.full_name}`); + setIsLoggedIn(true); + window.location.href = '/dashboard'; + } catch (error: any) { + const [errCode, errMessage] = extractErrorDetails(error); + if (!errCode) { + snackbar.error(`An unknown error occurred. Please try again later.`); + // this might also be a network failure...hence why it's better we don't + // try to reload the captcha or other things here *automatically*. + return; + } + + switch (error.response?.data?.error.code) { + case APIErrorCode.ErrCodeInvalidCaptcha: + snackbar.error(`Invalid CAPTCHA. Please try again.`); + reloadCaptcha(); + setIsCaptchaIncorrect(true); + break; + case APIErrorCode.ErrCodeInvalidUsernamePass: + snackbar.error(`Invalid username or password. Please try again.`); + reloadCaptcha(); + break; + default: + snackbar.error(`Failed to login (${errCode}): ${errMessage}`); + break; + } + } + }; - const reloadCaptcha = async () => { - setCaptchaImage(await apiClient.getCaptchaImage()); - }; + const reloadCaptcha = async () => { + setCaptchaImage(await apiClient.getCaptchaImage()); + }; - useEffect(() => { - apiClient.getCaptchaImage().then((value) => { - setCaptchaImage(value); - }); - }, []); + useEffect(() => { + apiClient.getCaptchaImage().then((value) => { + setCaptchaImage(value); + }); + }, []); - if (isLoggedIn) { - return ; - } + if (isLoggedIn) { + return ; + } - return ( - - -
-

Welcome to ExamSphere!

-
- {loginError && {loginError}} - setUsername(e.target.value)} - required - /> - setPassword(e.target.value)} - required - /> - - - - { - setCaptchaAnswer(e.target.value); - setIsCaptchaIncorrect(false); // Reset error state - }} - required - /> - - Log In -
-
- ); + return ( + + +
+

{CurrentAppTranslation.WelcomeToPlatformText}

+
+ setUsername(e.target.value)} + required + /> + setPassword(e.target.value)} + required + /> + + + + { + setCaptchaAnswer(e.target.value); + setIsCaptchaIncorrect(false); // Reset error state + }} + required + /> + + Log In +
+
+ ); }; export default Login; \ No newline at end of file diff --git a/src/pages/searchUserPage.tsx b/src/pages/searchUserPage.tsx index 15a1218..668a8de 100644 --- a/src/pages/searchUserPage.tsx +++ b/src/pages/searchUserPage.tsx @@ -1,10 +1,8 @@ -import React, { useState } from 'react'; -import { - TextField, - Button, - List, - ListItem, - ListItemText, +import { useEffect, useState } from 'react'; +import { + TextField, + List, + ListItem, CircularProgress, Paper, Grid, @@ -18,21 +16,71 @@ import { SearchedUserInfo } from '../api'; import apiClient from '../apiClient'; import DashboardContainer from '../components/containers/dashboardContainer'; import { timeAgo } from '../utils/timeUtils'; +import { CurrentAppTranslation } from '../translations/appTranslation'; + +const RenderUsersList = (users: SearchedUserInfo[] | undefined) => { + if (!users || users.length === 0) { + return ( + + {CurrentAppTranslation.NoResultsFoundText} + + ); + } + + return ( + + {users.map((user) => ( + + { + // Redirect to user info page, make sure to query encode it + window.location.href = `/userInfo?userId=${encodeURIComponent(user.user_id!)}`; + } + }> + + + {`${CurrentAppTranslation.user_id}: ${user.user_id}`} + {`${CurrentAppTranslation.email}: ${user.email}`} + + + {`${CurrentAppTranslation.full_name}: ${user.full_name}`} + {`${CurrentAppTranslation.created_at}: ${timeAgo(user.created_at!)}`} + + + + + ))} + + ) +} const SearchUserPage = () => { - const [query, setQuery] = useState(''); + const urlSearch = new URLSearchParams(window.location.search); + const providedQuery = urlSearch.get('query'); + const providedPage = urlSearch.get('page'); + + const [query, setQuery] = useState(providedQuery ?? ''); const [users, setUsers] = useState([]); - const [loading, setLoading] = useState(false); - const [page, setPage] = useState(0); - const [totalPages, setTotalPages] = useState(1); + const [isLoading, setIsLoading] = useState(false); + const [page, setPage] = useState(providedPage ? parseInt(providedPage) - 1 : 0); + const [totalPages, setTotalPages] = useState(page + 1); const limit = 10; const handleSearch = async (newPage = 0) => { + window.history.pushState( + `searchUser_query_${query}`, + "Search User", + `/searchUser?query=${encodeURIComponent(query)}&page=${newPage + 1}`, + ); + if (query === '') { return; } - setLoading(true); + setIsLoading(true); const results = await apiClient.searchUser({ query: query, offset: newPage * limit, @@ -40,7 +88,7 @@ const SearchUserPage = () => { }) if (!results || !results.users) { - setLoading(false); + setIsLoading(false); return; } @@ -52,61 +100,62 @@ const SearchUserPage = () => { setPage(newPage); setUsers(results.users!); - setLoading(false); + setIsLoading(false); }; + useEffect(() => { + // if at first the query is not null (e.g. the providedQuery exists), + // do the search. + if (query) { + handleSearch(page); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + return ( - - setQuery(e.target.value)} - placeholder="Search users" - InputProps={{ - endAdornment: ( - handleSearch()} disabled={loading}> - - - ), - }} - /> - {loading ? ( - - ) : ( - - {users.map((user) => ( - - - - - ID: {user.user_id} - Email: {user.email} - - - Name: {user.full_name} - Created: {timeAgo(user.created_at!)} - - - - - ))} - - )} + onChange={(e) => setQuery(e.target.value)} + label="Search users" + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + InputProps={{ + endAdornment: ( + handleSearch()} disabled={isLoading}> + + + ), + }} + /> + {isLoading ? ( + + + + ) : ( + + {RenderUsersList(users)} + + )} - handleSearch(newPage - 1)} /> + handleSearch(newPage - 1)} /> diff --git a/src/pages/userInfoPage.tsx b/src/pages/userInfoPage.tsx index 318bcbe..10dd642 100644 --- a/src/pages/userInfoPage.tsx +++ b/src/pages/userInfoPage.tsx @@ -4,6 +4,8 @@ import apiClient from '../apiClient'; import { EditUserData } from '../api'; import DashboardContainer from '../components/containers/dashboardContainer'; import { CurrentAppTranslation } from '../translations/appTranslation'; +import useAppSnackbar from '../components/snackbars/useAppSnackbars'; +import { extractErrorDetails } from '../utils/errorUtils'; const UserInfoPage = () => { const [userData, setUserData] = useState({ @@ -13,15 +15,16 @@ const UserInfoPage = () => { }); const [isEditing, setIsEditing] = useState(false); const [isUserNotFound, setIsUserNotFound] = useState(false); - const [serverError, setServerError] = useState(''); + const snackbar = useAppSnackbar(); useEffect(() => { fetchUserInfo(); - }, []); + }, []); // eslint-disable-line react-hooks/exhaustive-deps const fetchUserInfo = async () => { // the user id is passed like /userInfo?userId=123 - const targetUserId = new URLSearchParams(window.location.search).get('userId'); + const urlSearch = new URLSearchParams(window.location.search); + const targetUserId = urlSearch.get('userId'); if (!targetUserId) { window.location.href = '/searchUser'; return; @@ -35,7 +38,9 @@ const UserInfoPage = () => { email: result.email, }); } catch (error: any) { - setServerError('Failed to get user information'); + const [errCode, errMessage] = extractErrorDetails(error); + snackbar.error(`Failed to get user info (${errCode}): ${errMessage}`); + setIsUserNotFound(true); return; } }; @@ -57,10 +62,11 @@ const UserInfoPage = () => { }); setUserData(updatedUserData); + setIsEditing(false); } catch (error: any) { const errCode = error.response?.data?.error?.code; const errMessage = error.response?.data?.error?.message; - setServerError(`Failed (${errCode}) - ${errMessage}`); + snackbar.error(`Failed (${errCode}) - ${errMessage}`); return; } @@ -68,12 +74,23 @@ const UserInfoPage = () => { if (!userData) { // maybe return better stuff here in future? - return ; + return ( + + + + ); + } + + if (isUserNotFound) { + return ( + + {CurrentAppTranslation.UserNotFoundText} + + ); } return ( - {serverError && {serverError}} diff --git a/src/translations/appTranslation.ts b/src/translations/appTranslation.ts index c6cba98..45e13c3 100644 --- a/src/translations/appTranslation.ts +++ b/src/translations/appTranslation.ts @@ -4,6 +4,7 @@ export class AppTranslationBase { //#region common UI translations ExamSphereTitleText: string = "---ExamSphere---"; + WelcomeToPlatformText: string = "Welcome to ExamSphere!"; LoginText: string = "Login"; LoadingText: string = "Loading..."; ProfileText: string = "Profile"; @@ -23,6 +24,10 @@ export class AppTranslationBase { SaveText: string = "Save"; EditText: string = "Edit"; UserInformationText: string = "User Information"; + UserNotFoundText: string = "This user doesn't seem to exist..."; + CreateNewUserText: string = "Create New User"; + UserCreatedSuccessfullyText: string = "User created successfully"; + NoResultsFoundText: string = "No results found"; //#endregion diff --git a/src/translations/faTranslation.ts b/src/translations/faTranslation.ts index e56f61c..d36e577 100644 --- a/src/translations/faTranslation.ts +++ b/src/translations/faTranslation.ts @@ -6,6 +6,7 @@ class FaTranslation extends AppTranslationBase { //#region common UI translations ExamSphereTitleText: string = "---کره ی آزمون---"; + WelcomeToPlatformText: string = "به کره ی آزمون خوش آمدید!"; LoginText: string = "ورود"; LoadingText: string = "در حال بارگذاری..."; ProfileText: string = "پروفایل"; @@ -25,6 +26,10 @@ class FaTranslation extends AppTranslationBase { SaveText: string = "ذخیره"; EditText: string = "ویرایش"; UserInformationText: string = "اطلاعات کاربر"; + UserNotFoundText: string = "این کاربر وجود ندارد..."; + CreateNewUserText: string = "ایجاد کاربر جدید"; + UserCreatedSuccessfullyText: string = "کاربر با موفقیت ایجاد شد"; + NoResultsFoundText: string = "نتیجه ای یافت نشد"; //#endregion diff --git a/src/utils/errorUtils.ts b/src/utils/errorUtils.ts new file mode 100644 index 0000000..bc03874 --- /dev/null +++ b/src/utils/errorUtils.ts @@ -0,0 +1,11 @@ + + +// extractErrorDetails function takes an error object and returns the error code and +// error message from the error object. +// In future, this function may use a translation function to translate the error code +// and error message to a user-friendly message. +export function extractErrorDetails(error: any): [number, string] { + const errCode = error.response?.data?.error?.code ?? error.response?.status; + const errMessage = error.response?.data?.error?.message ?? error.response?.statusText; + return [(errCode as number), (errMessage as string)]; +}