From 369f1c29574d375367b46db169bc7762509d615c Mon Sep 17 00:00:00 2001 From: Aliwoto Date: Sat, 24 Aug 2024 22:52:35 +0330 Subject: [PATCH] Add search exam page, fix dates handling. Signed-off-by: Aliwoto --- src/App.tsx | 3 +- src/api/api.ts | 220 ++++++++++++++- src/apiClient.ts | 17 ++ src/components/date/ModernDatePicker.tsx | 38 ++- src/components/menus/sideMenu.tsx | 270 ++++++++++--------- src/components/rendering/RenderAllFields.tsx | 9 +- src/pages/examInfoPage.tsx | 24 +- src/pages/searchExamPage.tsx | 193 +++++++++++++ src/translations/appTranslation.ts | 5 +- src/translations/faTranslation.ts | 5 +- src/utils/commonUtils.ts | 3 + src/utils/timeUtils.ts | 35 ++- 12 files changed, 661 insertions(+), 161 deletions(-) create mode 100644 src/pages/searchExamPage.tsx diff --git a/src/App.tsx b/src/App.tsx index ac3838a..bbfa240 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ import SearchCoursePage from './pages/searchCoursePage'; import CreateExamPage from './pages/createExamPage'; import { switchAppTranslation } from './translations/translationSwitcher'; import ExamInfoPage from './pages/examInfoPage'; +import SearchExamPage from './pages/searchExamPage'; const App: React.FC = () => { const [isLoggedIn, setIsLoggedIn] = useState(apiClient.isLoggedIn()); @@ -124,7 +125,7 @@ const App: React.FC = () => { /> : } + element={apiClient.canSearchTopics() ? : } /> } /> diff --git a/src/api/api.ts b/src/api/api.ts index 0e0bcd5..a5000b8 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -85,7 +85,8 @@ export const APIErrorCode = { ErrCodeAccountAlreadyConfirmed: 2152, ErrCodeEmailAlreadyExists: 2153, ErrCodeTopicNameExists: 2154, - ErrCodeTopicNotFound: 2155 + ErrCodeTopicNotFound: 2155, + ErrCodeBodyTooLong: 2156 } as const; export type APIErrorCode = typeof APIErrorCode[keyof typeof APIErrorCode]; @@ -630,7 +631,7 @@ export interface CreateExamData { * @type {number} * @memberof CreateExamData */ - 'course_id'?: number; + 'course_id': number; /** * * @type {number} @@ -648,13 +649,13 @@ export interface CreateExamData { * @type {string} * @memberof CreateExamData */ - 'exam_description'?: string; + 'exam_description': string; /** * * @type {string} * @memberof CreateExamData */ - 'exam_title'?: string; + 'exam_title': string; /** * * @type {boolean} @@ -2471,6 +2472,69 @@ export interface SearchCourseV1200Response { */ 'success'?: boolean; } +/** + * + * @export + * @interface SearchExamData + */ +export interface SearchExamData { + /** + * + * @type {number} + * @memberof SearchExamData + */ + 'limit': number; + /** + * + * @type {number} + * @memberof SearchExamData + */ + 'offset': number; + /** + * + * @type {string} + * @memberof SearchExamData + */ + 'search_query': string; +} +/** + * + * @export + * @interface SearchExamResult + */ +export interface SearchExamResult { + /** + * + * @type {Array} + * @memberof SearchExamResult + */ + 'exams'?: Array; +} +/** + * + * @export + * @interface SearchExamV1200Response + */ +export interface SearchExamV1200Response { + /** + * + * @type {EndpointError} + * @memberof SearchExamV1200Response + */ + 'error'?: EndpointError; + /** + * + * @type {SearchExamResult} + * @memberof SearchExamV1200Response + */ + 'result'?: SearchExamResult; + /** + * + * @type {boolean} + * @memberof SearchExamV1200Response + */ + 'success'?: boolean; +} /** * * @export @@ -2628,6 +2692,73 @@ export interface SearchedCourseInfo { */ 'topic_id'?: number; } +/** + * + * @export + * @interface SearchedExamInfo + */ +export interface SearchedExamInfo { + /** + * + * @type {number} + * @memberof SearchedExamInfo + */ + 'course_id'?: number; + /** + * + * @type {string} + * @memberof SearchedExamInfo + */ + 'created_at'?: string; + /** + * + * @type {string} + * @memberof SearchedExamInfo + */ + 'created_by'?: string; + /** + * + * @type {number} + * @memberof SearchedExamInfo + */ + 'duration'?: number; + /** + * + * @type {string} + * @memberof SearchedExamInfo + */ + 'exam_date'?: string; + /** + * + * @type {string} + * @memberof SearchedExamInfo + */ + 'exam_description'?: string; + /** + * + * @type {number} + * @memberof SearchedExamInfo + */ + 'exam_id'?: number; + /** + * + * @type {string} + * @memberof SearchedExamInfo + */ + 'exam_title'?: string; + /** + * + * @type {boolean} + * @memberof SearchedExamInfo + */ + 'is_public'?: boolean; + /** + * + * @type {string} + * @memberof SearchedExamInfo + */ + 'price'?: string; +} /** * * @export @@ -3866,6 +3997,49 @@ export const ExamApiAxiosParamCreator = function (configuration?: Configuration) options: localVarRequestOptions, }; }, + /** + * Allows the user to search exams. + * @summary Search exams + * @param {string} authorization Authorization token + * @param {SearchExamData} data Data needed to search exams + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + searchExamV1: async (authorization: string, data: SearchExamData, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'authorization' is not null or undefined + assertParamExists('searchExamV1', 'authorization', authorization) + // verify required parameter 'data' is not null or undefined + assertParamExists('searchExamV1', 'data', data) + const localVarPath = `/api/v1/exam/search`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + if (authorization != null) { + localVarHeaderParameter['Authorization'] = String(authorization); + } + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(data, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Allows the user to set score for a user in an exam. * @summary Set score for a user in an exam @@ -4031,6 +4205,20 @@ export const ExamApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['ExamApi.getUserOngoingExamsV1']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Allows the user to search exams. + * @summary Search exams + * @param {string} authorization Authorization token + * @param {SearchExamData} data Data needed to search exams + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async searchExamV1(authorization: string, data: SearchExamData, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.searchExamV1(authorization, data, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['ExamApi.searchExamV1']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Allows the user to set score for a user in an exam. * @summary Set score for a user in an exam @@ -4143,6 +4331,17 @@ export const ExamApiFactory = function (configuration?: Configuration, basePath? getUserOngoingExamsV1(authorization: string, targetId?: string, options?: any): AxiosPromise { return localVarFp.getUserOngoingExamsV1(authorization, targetId, options).then((request) => request(axios, basePath)); }, + /** + * Allows the user to search exams. + * @summary Search exams + * @param {string} authorization Authorization token + * @param {SearchExamData} data Data needed to search exams + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + searchExamV1(authorization: string, data: SearchExamData, options?: any): AxiosPromise { + return localVarFp.searchExamV1(authorization, data, options).then((request) => request(axios, basePath)); + }, /** * Allows the user to set score for a user in an exam. * @summary Set score for a user in an exam @@ -4268,6 +4467,19 @@ export class ExamApi extends BaseAPI { return ExamApiFp(this.configuration).getUserOngoingExamsV1(authorization, targetId, options).then((request) => request(this.axios, this.basePath)); } + /** + * Allows the user to search exams. + * @summary Search exams + * @param {string} authorization Authorization token + * @param {SearchExamData} data Data needed to search exams + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ExamApi + */ + public searchExamV1(authorization: string, data: SearchExamData, options?: RawAxiosRequestConfig) { + return ExamApiFp(this.configuration).searchExamV1(authorization, data, options).then((request) => request(this.axios, this.basePath)); + } + /** * Allows the user to set score for a user in an exam. * @summary Set score for a user in an exam diff --git a/src/apiClient.ts b/src/apiClient.ts index b5d3468..2bf1b53 100644 --- a/src/apiClient.ts +++ b/src/apiClient.ts @@ -36,6 +36,8 @@ import { EditExamData, EditExamResult, GetExamInfoResult, + SearchExamData, + SearchExamResult, } from './api'; import { canParseAsNumber } from './utils/textUtils'; import { SupportedTranslations } from './translations/translationSwitcher'; @@ -435,6 +437,21 @@ class ExamSphereAPIClient extends UserApi { return searchCourseResult; } + public async searchExam(searchExamData: SearchExamData): Promise { + if (!this.isLoggedIn()) { + throw new Error("Not logged in"); + } + + let searchExamResult = (await this.examApi.searchExamV1(`Bearer ${this.accessToken}`, searchExamData))?.data.result; + if (!searchExamResult) { + // we shouldn't reach here, because if there is an error somewhere, + // it should have already been thrown by the API client + throw new Error("Failed to search exam"); + } + + return searchExamResult; + } + public async createNewTopic(data: CreateNewTopicData): Promise { if (!this.isLoggedIn()) { throw new Error("Not logged in"); diff --git a/src/components/date/ModernDatePicker.tsx b/src/components/date/ModernDatePicker.tsx index e119201..e45ea1d 100644 --- a/src/components/date/ModernDatePicker.tsx +++ b/src/components/date/ModernDatePicker.tsx @@ -6,6 +6,7 @@ import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns'; import { AdapterMomentJalaali } from '@mui/x-date-pickers/AdapterMomentJalaali'; import { MobileDateTimePicker } from '@mui/x-date-pickers'; import { AppCalendarType } from '../../utils/AppCalendarTypes'; +import { CurrentAppTranslation } from '../../translations/appTranslation'; const theme = createTheme({ palette: { @@ -31,19 +32,25 @@ const ModernDateTimePicker: React.FC = ({ ...props }) if (props.value) { const valueType = typeof props.value; if (valueType === 'number') { - momentValue = moment.unix(props.value as number); + momentValue = moment.utc((props.value as number) * 1000); } else { - momentValue = moment(props.value); + momentValue = moment.utc(props.value); } } const [selectedDateTime, setSelectedDateTime] = React.useState(momentValue); + if (!selectedDateTime && momentValue) { + setSelectedDateTime(momentValue); + } + const adapterType = props.dateType === 'jalali' ? AdapterMomentJalaali : AdapterDateFns; return ( - - + = ({ ...props }) } } label={props.label} - value={selectedDateTime as any} + value={ + (selectedDateTime === null || selectedDateTime === undefined) ? null : + props.dateType === 'gregorian' ? + (selectedDateTime as moment.Moment).local().toDate() : + selectedDateTime?.local() + } onChange={(newValue) => { - setSelectedDateTime(newValue); - props.onChange(newValue); + let utcValue: any = null; + if (newValue instanceof Date) { + // setSelectedDateTime(moment(newValue)); + utcValue = moment(newValue).utc(); + } else if (moment.isMoment(newValue)) { + // setSelectedDateTime(newValue); + utcValue = newValue.clone().utc(); + } + + if (utcValue) { + props.onChange(newValue); + } else { + console.log('newValue is not a Date or Moment object'); + } }} /> diff --git a/src/components/menus/sideMenu.tsx b/src/components/menus/sideMenu.tsx index c74ff88..627ad44 100644 --- a/src/components/menus/sideMenu.tsx +++ b/src/components/menus/sideMenu.tsx @@ -21,166 +21,170 @@ const SideMenuContainer = styled.div<{ $isOpen: boolean }>` `; interface SideMenuProps { - isOpen: boolean; - toggleMenu: () => void; + isOpen: boolean; + toggleMenu: () => void; - children?: React.ReactNode; + children?: React.ReactNode; } const RenderProfileMenu = () => { - return ( - - - - - ) + return ( + + + + + ) }; const RenderManageUserMenu = () => { - return ( - - - - - - - - - ) + return ( + + + + + + + + + ) } const RenderManageTopicsMenu = () => { - return ( - - - - - ) + return ( + + + + + ) }; const RenderManageCoursesMenu = () => { - return ( - - - - - - ) + return ( + + + + + + ) }; const RenderManageExamsMenu = () => { - return ( - - - - - ) + return ( + + + + + + ) }; const RenderCommonMenus = () => { - return ( - <> - - - { - apiClient.logout(); - window.location.href = '/'; - }} - > - - ); + return ( + <> + + + { + apiClient.logout(); + window.location.href = '/'; + }} + > + + ); }; const SideMenu: React.FC = ({ ...props }) => { - if (apiClient.isOwner()) { - return ( - - - {RenderProfileMenu()} - {RenderManageUserMenu()} - {RenderManageTopicsMenu()} - {RenderManageCoursesMenu()} - {RenderManageExamsMenu()} - {RenderCommonMenus()} - - ); - } + if (apiClient.isOwner()) { + return ( + + + {RenderProfileMenu()} + {RenderManageUserMenu()} + {RenderManageTopicsMenu()} + {RenderManageCoursesMenu()} + {RenderManageExamsMenu()} + {RenderCommonMenus()} + + ); + } - if (apiClient.isAdmin()) { - return ( - - - {RenderProfileMenu()} - {RenderManageUserMenu()} - {RenderManageTopicsMenu()} - {RenderManageCoursesMenu()} - {RenderManageExamsMenu()} - {RenderCommonMenus()} - - ); - } + if (apiClient.isAdmin()) { + return ( + + + {RenderProfileMenu()} + {RenderManageUserMenu()} + {RenderManageTopicsMenu()} + {RenderManageCoursesMenu()} + {RenderManageExamsMenu()} + {RenderCommonMenus()} + + ); + } - if (apiClient.isTeacher()) { - return ( - - - {RenderProfileMenu()} - {RenderManageUserMenu()} - {RenderManageCoursesMenu()} - {RenderManageExamsMenu()} - {RenderCommonMenus()} - - ); - } + if (apiClient.isTeacher()) { + return ( + + + {RenderProfileMenu()} + {RenderManageUserMenu()} + {RenderManageCoursesMenu()} + {RenderManageExamsMenu()} + {RenderCommonMenus()} + + ); + } + + if (apiClient.isStudent()) { + return ( + + + {RenderProfileMenu()} + {RenderManageUserMenu()} + {RenderManageExamsMenu()} + {RenderCommonMenus()} + + ); + } - if (apiClient.isStudent()) { return ( - - - {RenderProfileMenu()} - {RenderManageUserMenu()} - {RenderManageExamsMenu()} - {RenderCommonMenus()} - + + + ); - } - - return ( - - - - ); }; export default SideMenu; diff --git a/src/components/rendering/RenderAllFields.tsx b/src/components/rendering/RenderAllFields.tsx index 0c65f6b..ba04881 100644 --- a/src/components/rendering/RenderAllFields.tsx +++ b/src/components/rendering/RenderAllFields.tsx @@ -4,6 +4,7 @@ import { CurrentAppTranslation } from '../../translations/appTranslation'; import { Box, Checkbox, FormControlLabel, Grid, TextField, Typography } from '@mui/material'; import SelectMenu from '../../components/menus/selectMenu'; import ModernDateTimePicker from '../date/ModernDatePicker'; +import { getDateFromServerTimestamp } from '../../utils/timeUtils'; interface RenderAllFieldsProps { data: any; @@ -36,11 +37,13 @@ const RenderAllFields = (props: RenderAllFieldsProps) => { {`${CurrentAppTranslation[field as keyof (typeof CurrentAppTranslation)]}: `} - {new Date((data[field] * 1000) as number).toLocaleDateString('en-US', { + {getDateFromServerTimestamp(data[field])?.toLocaleDateString('en-US', { weekday: 'long', // "Monday" year: 'numeric', // "2003" month: 'long', // "July" - day: 'numeric' // "26" + day: 'numeric', // "26", + hour: 'numeric', // "11 AM" + minute: 'numeric', // "30" })} @@ -50,7 +53,7 @@ const RenderAllFields = (props: RenderAllFieldsProps) => { { handleInputChange({ diff --git a/src/pages/examInfoPage.tsx b/src/pages/examInfoPage.tsx index a7f00aa..58e27d6 100644 --- a/src/pages/examInfoPage.tsx +++ b/src/pages/examInfoPage.tsx @@ -6,8 +6,8 @@ import {DashboardContainer} from '../components/containers/dashboardContainer'; import { CurrentAppTranslation } from '../translations/appTranslation'; import useAppSnackbar from '../components/snackbars/useAppSnackbars'; import { extractErrorDetails } from '../utils/errorUtils'; -import { getFieldOf } from '../utils/commonUtils'; -import { getUTCUnixTimestamp } from '../utils/timeUtils'; +import { autoSetWindowTitle, getFieldOf } from '../utils/commonUtils'; +import { getDateFromServerTimestamp, getUTCUnixTimestamp } from '../utils/timeUtils'; import RenderAllFields from '../components/rendering/RenderAllFields'; export var forceUpdateExamInfoPage = () => {}; @@ -53,12 +53,12 @@ const ExamInfoPage = () => { exam_description: result.exam_description, price: result.price, duration: result.duration, - exam_date: getUTCUnixTimestamp(new Date(result.exam_date!)), + exam_date: getUTCUnixTimestamp(getDateFromServerTimestamp(result.exam_date)!), is_public: result.is_public, }); } catch (error: any) { const [errCode, errMessage] = extractErrorDetails(error); - snackbar.error(`Failed to get user info (${errCode}): ${errMessage}`); + snackbar.error(`Failed to get exam info (${errCode}): ${errMessage}`); setIsUserNotFound(true); return; } @@ -66,6 +66,8 @@ const ExamInfoPage = () => { useEffect(() => { fetchExamInfo(); + + autoSetWindowTitle(); }, []); // eslint-disable-line react-hooks/exhaustive-deps const handleEdit = () => { @@ -109,6 +111,16 @@ const ExamInfoPage = () => { setExamData(updatedUserData); setIsEditing(false); + + window.history.pushState( + `examInfo_examId_${examData.exam_id}`, + "Exam Info", + `${window.location.pathname}?examId=${ + encodeURIComponent(examData.exam_id!) + }&edit=${ + isEditing ? '0' : '1' + }`, + ); } catch (error: any) { const [errCode, errMessage] = extractErrorDetails(error); snackbar.error(`Failed (${errCode}) - ${errMessage}`); @@ -129,7 +141,7 @@ const ExamInfoPage = () => { if (isUserNotFound) { return ( - {CurrentAppTranslation.UserNotFoundText} + {CurrentAppTranslation.ExamNotFoundText} ); } @@ -139,7 +151,7 @@ const ExamInfoPage = () => { - {CurrentAppTranslation.UserInformationText} + {CurrentAppTranslation.ExamInformationText} diff --git a/src/pages/searchExamPage.tsx b/src/pages/searchExamPage.tsx new file mode 100644 index 0000000..91b9bfa --- /dev/null +++ b/src/pages/searchExamPage.tsx @@ -0,0 +1,193 @@ +import { useEffect, useReducer, useState } from 'react'; +import { + TextField, + List, + ListItem, + CircularProgress, + Paper, + Grid, + Typography, +} from '@mui/material'; +import { Box } from '@mui/material'; +import SearchIcon from '@mui/icons-material/Search'; +import Pagination from '@mui/material/Pagination'; +import IconButton from '@mui/material/IconButton'; +import { SearchedExamInfo } from '../api'; +import apiClient from '../apiClient'; +import { DashboardContainer } from '../components/containers/dashboardContainer'; +import { timeAgo } from '../utils/timeUtils'; +import { CurrentAppTranslation } from '../translations/appTranslation'; +import { autoSetWindowTitle } from '../utils/commonUtils'; +import { extractErrorDetails } from '../utils/errorUtils'; +import useAppSnackbar from '../components/snackbars/useAppSnackbars'; + +export var forceUpdateSearchExamPage = () => {}; + +const PageLimit = 10; + +const RenderExamsList = (courses: SearchedExamInfo[] | undefined, forEdit: boolean = false) => { + if (!courses || courses.length === 0) { + return ( + + {forEdit ? CurrentAppTranslation.EnterSearchForEdit : + CurrentAppTranslation.NoResultsFoundText} + + ); + } + + return ( + + {courses.map((course) => ( + + { + // Redirect to course info page, make sure to query encode it + window.location.href = `/examInfo?examId=${ + encodeURIComponent(course.exam_id!) + }`; + } + }> + + + + {`${CurrentAppTranslation.exam_id}: ${course.exam_id}`} + + + {`${CurrentAppTranslation.exam_title}: ${course.exam_title}`} + + + {`${CurrentAppTranslation.course_id}: ${course.course_id}`} + + + + + {`${CurrentAppTranslation.exam_description}: ${course.exam_description}`} + + + {`${CurrentAppTranslation.created_at}: ${timeAgo(course.created_at!)}`} + + + + + + ))} + + ) +} + +const SearchExamPage = () => { + const urlSearch = new URLSearchParams(window.location.search); + const providedQuery = urlSearch.get('query'); + const providedPage = urlSearch.get('page'); + const forEdit = (urlSearch.get('edit') ?? "false") === "true"; + + const [query, setQuery] = useState(providedQuery ?? ''); + const [courses, setCourses] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [, setForceUpdate] = useReducer(x => x + 1, 0); + const [page, setPage] = useState(providedPage ? parseInt(providedPage) - 1 : 0); + const [totalPages, setTotalPages] = useState(page + 1); + const snackbar = useAppSnackbar(); + + forceUpdateSearchExamPage = () => setForceUpdate(); + + const handleSearch = async (newPage = 0) => { + window.history.pushState( + `searchExam_query_${query}`, + "Search Exam", + `${window.location.pathname}?query=${ + encodeURIComponent(query) + }&page=${newPage + 1}`, + ); + + setIsLoading(true); + try { + const results = await apiClient.searchExam({ + search_query: query, + offset: newPage * PageLimit, + limit: PageLimit, + }) + + if (!results || !results.exams) { + setIsLoading(false); + return; + } + + // we need to do setTotalPages dynamically, e.g. if the limit is reached, + // we should add one more page. if the amount of results returned is less than + // the limit, we shouldn't increment the total pages. + const newTotalPages = results.exams.length < PageLimit ? (newPage + 1) : newPage + 2; + setTotalPages(newTotalPages); + + setPage(newPage); + setCourses(results.exams!); + setIsLoading(false); + } catch (error: any) { + const [errCode, errMessage] = extractErrorDetails(error); + snackbar.error(`Failed (${errCode}): ${errMessage}`); + setIsLoading(false); + return; + } + }; + + useEffect(() => { + // if at first the query is not null (e.g. the providedQuery exists), + // do the search. + if (query) { + handleSearch(page); + } + + autoSetWindowTitle(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + + + + setQuery(e.target.value)} + label={CurrentAppTranslation.SearchCoursesText} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + InputProps={{ + endAdornment: ( + handleSearch()} disabled={isLoading}> + + + ), + }} + /> + {isLoading ? ( + + + + ) : RenderExamsList(courses, forEdit)} + + + handleSearch(newPage - 1)} /> + + + + ); +}; + +export default SearchExamPage; \ No newline at end of file diff --git a/src/translations/appTranslation.ts b/src/translations/appTranslation.ts index c3ed626..346c11b 100644 --- a/src/translations/appTranslation.ts +++ b/src/translations/appTranslation.ts @@ -44,6 +44,7 @@ export class AppTranslationBase { AddCourseText: string = "Add Course"; SearchCoursesText: string = "Search Course"; SearchCourseText: string = "Search Courses"; + SearchExamText: string = "Search Exams"; EditCourseText: string = "Edit Course"; ManageExamsText: string = "Manage Exams"; AddExamText: string = "Add Exam"; @@ -59,6 +60,7 @@ export class AppTranslationBase { EditText: string = "Edit"; UserInformationText: string = "User Information"; CourseInformationText: string = "Course Information"; + ExamInformationText: string = "Exam Information"; ExamInfoText: string = "Exam Info"; UserInfoText: string = "User Info"; ConfirmYourAccountText: string = "Confirm Your Account"; @@ -83,6 +85,7 @@ export class AppTranslationBase { PasswordsDoNotMatchText: string = "Passwords do not match!"; FailedToConfirmAccountCreationText: string = "Failed to confirm account creation"; UserNotFoundText: string = "This user doesn't seem to exist..."; + ExamNotFoundText: string = "This exam doesn't seem to exist..."; CourseNotFoundText: string = "This course doesn't seem to exist..."; UserCreatedSuccessfullyText: string = "User created successfully"; TopicCreatedSuccessfullyText: string = "Topic created successfully"; @@ -90,7 +93,7 @@ export class AppTranslationBase { CourseCreatedSuccessfullyText: string = "Course created successfully"; NoResultsFoundText: string = "No results found, try changing your search query"; SearchSomethingForTopicsText: string = "Search a query or enter empty to list all topics"; - EnterSearchForEdit: string = "Enter search query to edit the user"; + EnterSearchForEdit: string = "Enter search query to edit"; CopyrightText: string = "ALiwoto. All rights reserved."; //#endregion diff --git a/src/translations/faTranslation.ts b/src/translations/faTranslation.ts index 08eece1..02a8e42 100644 --- a/src/translations/faTranslation.ts +++ b/src/translations/faTranslation.ts @@ -42,6 +42,7 @@ class FaTranslation extends AppTranslationBase { AddCourseText: string = "افزودن دوره"; SearchCoursesText: string = "جستجوی دوره ها"; SearchCourseText: string = "جستجوی دوره"; + SearchExamText: string = "جستجوی آزمون ها"; EditCourseText: string = "ویرایش دوره"; ManageExamsText: string = "مدیریت آزمون ها"; AddExamText: string = "افزودن آزمون"; @@ -56,6 +57,7 @@ class FaTranslation extends AppTranslationBase { EditText: string = "ویرایش"; UserInformationText: string = "اطلاعات کاربر"; CourseInformationText: string = "اطلاعات دوره"; + ExamInformationText: string = "اطلاعات آزمون"; ExamInfoText: string = "اطلاعات آزمون"; UserInfoText: string = "اطلاعات کاربر"; ConfirmYourAccountText: string = "حساب کاربری خود را تایید کنید"; @@ -81,6 +83,7 @@ class FaTranslation extends AppTranslationBase { PasswordsDoNotMatchText: string = "رمز عبور ها یکسان نیستند!"; FailedToConfirmAccountCreationText: string = "تایید ایجاد حساب کاربری ناموفق بود"; UserNotFoundText: string = "این کاربر وجود ندارد..."; + ExamNotFoundText: string = "این آزمون وجود ندارد..."; CourseNotFoundText: string = "این دوره وجود ندارد..."; UserCreatedSuccessfullyText: string = "کاربر با موفقیت ایجاد شد"; TopicCreatedSuccessfullyText: string = "موضوع با موفقیت ایجاد شد"; @@ -88,7 +91,7 @@ class FaTranslation extends AppTranslationBase { CourseCreatedSuccessfullyText: string = "دوره با موفقیت ایجاد شد"; NoResultsFoundText: string = "نتیجه ای یافت نشد، تلاش کنید تا جستجوی خود را تغییر دهید"; SearchSomethingForTopicsText: string = "برای جستجو یک کلمه وارد کنید یا خالی بگذارید تا همه موضوعات لیست شوند"; - EnterSearchForEdit: string = "برای ویرایش کاربر جستجو کنید"; + EnterSearchForEdit: string = "برای ویرایش، ابتدا جستجو کنید"; CopyrightText: string = "ALiwoto. تمامی حقوق محفوظ است."; //#endregion diff --git a/src/utils/commonUtils.ts b/src/utils/commonUtils.ts index 8852f30..b927c7a 100644 --- a/src/utils/commonUtils.ts +++ b/src/utils/commonUtils.ts @@ -31,6 +31,9 @@ export function autoSetWindowTitle(): void { case '/searchUser': document.title = CurrentAppTranslation.SearchUserText; break; + case '/searchExam': + document.title = CurrentAppTranslation.SearchExamText; + break; case '/examInfo': document.title = CurrentAppTranslation.ExamInfoText; break; diff --git a/src/utils/timeUtils.ts b/src/utils/timeUtils.ts index 720ff8c..9dc11ee 100644 --- a/src/utils/timeUtils.ts +++ b/src/utils/timeUtils.ts @@ -21,20 +21,45 @@ export const calculateTimeDifference = (time: number) => { }; export const timeAgo = (date: string | number | Date): string => { - const time = Math.floor((new Date().valueOf() - new Date(date).valueOf()) / 1000); + const now = new Date().valueOf(); + const targetDate = new Date(date).valueOf(); + const time = Math.floor(Math.abs(now - targetDate) / 1000); + const isFuture = targetDate > now; const { interval, unit } = calculateTimeDifference(time); const suffix = interval === 1 ? '' : 's'; - return `${interval} ${unit}${suffix} ago`; + + return isFuture + ? `${interval} ${unit}${suffix} in the future` + : `${interval} ${unit}${suffix} ago`; }; export const getUTCUnixTimestamp = (date: Date): number => { + if (date === undefined || date === null) { + return 0; + } + // Create a new Date object set to the UTC time const utcDate = new Date(date.toUTCString()); - + // Get the UNIX timestamp (in milliseconds) const unixTimestamp = utcDate.getTime(); - + // Return the UNIX timestamp in seconds return Math.floor(unixTimestamp / 1000); - } +} + +export const getDateFromServerTimestamp = (serverData: any): Date | null => { + if (typeof serverData == 'number') { + return new Date(serverData * 1000); + } else if (typeof serverData == 'string') { + return new Date(serverData); + } else if (serverData instanceof Date) { + return serverData; + } else if (serverData === undefined || serverData === null) { + return null; + } + + console.log(`Invalid server data: ${serverData}`); + throw new Error('Invalid server data'); +}