diff --git a/package.json b/package.json index d1926b7f10..90e1f27df7 100644 --- a/package.json +++ b/package.json @@ -48,8 +48,8 @@ "@tremor/react": "^1.8.2", "ace-builds": "^1.42.1", "acorn": "^8.9.0", - "ag-grid-community": "^32.3.1", - "ag-grid-react": "^32.3.1", + "ag-grid-community": "^34.1.1", + "ag-grid-react": "^34.1.1", "array-move": "^4.0.0", "browserfs": "^1.4.3", "classnames": "^2.3.2", diff --git a/src/bootstrap/agGrid.ts b/src/bootstrap/agGrid.ts new file mode 100644 index 0000000000..6c83b1153f --- /dev/null +++ b/src/bootstrap/agGrid.ts @@ -0,0 +1,41 @@ +import { + CellStyleModule, + ClientSideRowModelModule, + ColumnApiModule, + ColumnAutoSizeModule, + CsvExportModule, + DateFilterModule, + InfiniteRowModelModule, + type Module, + ModuleRegistry, + PaginationModule, + RowDragModule, + TextEditorModule, + TextFilterModule, + ValidationModule +} from 'ag-grid-community'; + +const productionModules: readonly Module[] = [ + CellStyleModule, + ClientSideRowModelModule, + ColumnApiModule, + ColumnAutoSizeModule, + CsvExportModule, + DateFilterModule, + InfiniteRowModelModule, + PaginationModule, + RowDragModule, + TextEditorModule, + TextFilterModule, + ValidationModule +]; + +export const initializeAgGridModules = () => { + const modulesToLoad = [...productionModules]; + + // Load helpful warnings in development mode + if (process.env.NODE_ENV === 'development') { + modulesToLoad.push(ValidationModule); + } + ModuleRegistry.registerModules(modulesToLoad); +}; diff --git a/src/bootstrap/sentry.ts b/src/bootstrap/sentry.ts new file mode 100644 index 0000000000..692de6ff69 --- /dev/null +++ b/src/bootstrap/sentry.ts @@ -0,0 +1,33 @@ +import * as Sentry from '@sentry/react'; +import { useEffect } from 'react'; +import { + createRoutesFromChildren, + matchRoutes, + useLocation, + useNavigationType +} from 'react-router'; +import Constants from 'src/commons/utils/Constants'; +import { store } from 'src/pages/createStore'; + +export const initializeSentryLogging = () => { + if (!Constants.sentryDsn) { + return; + } + Sentry.init({ + dsn: Constants.sentryDsn, + environment: Constants.sourceAcademyEnvironment, + release: `cadet-frontend@${Constants.sourceAcademyVersion}`, + integrations: [ + Sentry.reactRouterV7BrowserTracingIntegration({ + useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes + }), + Sentry.replayIntegration() + ] + }); + const userId = store.getState().session.userId; + Sentry.setUser(typeof userId !== 'undefined' ? { id: userId.toString() } : null); +}; diff --git a/src/commons/sourceRecorder/SourceRecorderTable.tsx b/src/commons/sourceRecorder/SourceRecorderTable.tsx index 404a231d97..7c98c08ddc 100644 --- a/src/commons/sourceRecorder/SourceRecorderTable.tsx +++ b/src/commons/sourceRecorder/SourceRecorderTable.tsx @@ -1,5 +1,3 @@ -import 'ag-grid-community/styles/ag-grid.css'; - import { Divider, FormGroup, @@ -9,6 +7,7 @@ import { SpinnerSize } from '@blueprintjs/core'; import { ColDef, GridApi, GridReadyEvent } from 'ag-grid-community'; +import { themeBalham } from 'ag-grid-community'; import { AgGridReact } from 'ag-grid-react'; import { sortBy } from 'lodash'; import React from 'react'; @@ -62,7 +61,7 @@ class SourcecastTable extends React.Component { }, minWidth: 200, suppressMovable: true, - suppressMenu: true, + suppressHeaderMenuButton: true, cellStyle: { 'text-align': 'left' }, @@ -73,7 +72,7 @@ class SourcecastTable extends React.Component { field: 'title', minWidth: 100, suppressMovable: true, - suppressMenu: true, + suppressHeaderMenuButton: true, hide: !!this.props.handleSetSourcecastData }, { @@ -81,7 +80,7 @@ class SourcecastTable extends React.Component { field: 'uploader.name', minWidth: 150, suppressMovable: true, - suppressMenu: true, + suppressHeaderMenuButton: true, cellStyle: { 'text-align': 'center' } @@ -91,7 +90,7 @@ class SourcecastTable extends React.Component { valueGetter: params => getStandardDate(params.data.inserted_at), minWidth: 150, suppressMovable: true, - suppressMenu: true + suppressHeaderMenuButton: true }, { headerName: 'Share', @@ -102,7 +101,7 @@ class SourcecastTable extends React.Component { }, minWidth: 80, suppressMovable: true, - suppressMenu: true + suppressHeaderMenuButton: true }, { headerName: 'Delete', @@ -115,7 +114,7 @@ class SourcecastTable extends React.Component { maxWidth: 100, sortable: false, suppressMovable: true, - suppressMenu: true, + suppressHeaderMenuButton: true, cellStyle: { 'text-align': 'center' }, @@ -169,6 +168,7 @@ class SourcecastTable extends React.Component {
import('../../commons/assessment/Assessment'); const Game = () => import('./game/Game'); const Sourcecast = () => import('../sourcecast/Sourcecast'); const Achievement = () => import('../achievement/Achievement'); -const Leaderboard = () => import('../leaderboard/Leaderboard'); -const OverallLeaderboardWrapper = () => - import('../leaderboard/subcomponents/OverallLeaderboardWrapper'); +const OverallLeaderboard = () => import('../leaderboard/subcomponents/OverallLeaderboard'); const ContestLeaderboardWrapper = () => import('../leaderboard/subcomponents/ContestLeaderboardWrapper'); const NotFound = () => import('../notFound/NotFound'); @@ -78,9 +80,18 @@ const getCommonAcademyRoutes = (): RouteObject[] => { }, { path: 'sourcecast/:sourcecastId?', lazy: Sourcecast }, { path: 'achievements/*', lazy: Achievement }, - { path: 'leaderboard/overall', lazy: OverallLeaderboardWrapper }, - { path: 'leaderboard/contests/*', lazy: ContestLeaderboardWrapper }, - { path: 'leaderboard/*', lazy: Leaderboard }, + { + path: 'leaderboard', + loader: leaderboardLoader, + children: [ + { path: 'overall', lazy: OverallLeaderboard }, + { + path: 'contests/:contestId?/:leaderboardType', + loader: contestLeaderboardLoader, + lazy: ContestLeaderboardWrapper + } + ] + }, { path: '*', lazy: NotFound } ]; }; diff --git a/src/pages/academy/adminPanel/AdminPanel.tsx b/src/pages/academy/adminPanel/AdminPanel.tsx index 23277bc0b0..2170f773b3 100644 --- a/src/pages/academy/adminPanel/AdminPanel.tsx +++ b/src/pages/academy/adminPanel/AdminPanel.tsx @@ -1,6 +1,3 @@ -import 'ag-grid-community/styles/ag-grid.css'; -import 'ag-grid-community/styles/ag-theme-balham.css'; - import { Button, Divider, H1, Intent, Tab, Tabs } from '@blueprintjs/core'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useDispatch } from 'react-redux'; diff --git a/src/pages/academy/adminPanel/subcomponents/AddStoriesUserPanel.tsx b/src/pages/academy/adminPanel/subcomponents/AddStoriesUserPanel.tsx index c7c56f953c..e558e2084c 100644 --- a/src/pages/academy/adminPanel/subcomponents/AddStoriesUserPanel.tsx +++ b/src/pages/academy/adminPanel/subcomponents/AddStoriesUserPanel.tsx @@ -12,7 +12,7 @@ import { Position } from '@blueprintjs/core'; import { IconNames } from '@blueprintjs/icons'; -import { ColDef } from 'ag-grid-community'; +import { type ColDef, themeBalham } from 'ag-grid-community'; import { AgGridReact } from 'ag-grid-react'; import { uniqBy } from 'lodash'; import React from 'react'; @@ -50,8 +50,9 @@ const AddStoriesUserPanel: React.FC = props => { const { CSVReader } = useCSVReader(); const grid = ( -
+
= props => { const { CSVReader } = useCSVReader(); const grid = ( -
+
+
= props => { }; const grid = ( -
+
= props => { }; const grid = ( -
+
{ const content = (
-
+
= ({ /> -
+
= ({ rowHeight={tableProperties.rowHeight} suppressMenuHide={tableProperties.suppressMenuHide} suppressPaginationPanel={tableProperties.suppressPaginationPanel} - suppressRowClickSelection={tableProperties.suppressRowClickSelection} + rowSelection={{ + mode: 'singleRow', + enableClickSelection: !tableProperties.suppressRowClickSelection + }} domLayout="autoHeight" onFilterChanged={e => { if (!e.afterFloatingFilter) { diff --git a/src/pages/academy/groundControl/GroundControl.tsx b/src/pages/academy/groundControl/GroundControl.tsx index dd86979203..546473b09c 100644 --- a/src/pages/academy/groundControl/GroundControl.tsx +++ b/src/pages/academy/groundControl/GroundControl.tsx @@ -1,6 +1,3 @@ -import 'ag-grid-community/styles/ag-grid.css'; -import 'ag-grid-community/styles/ag-theme-balham.css'; - import { Button, Collapse, @@ -11,7 +8,7 @@ import { SpinnerSize } from '@blueprintjs/core'; import { IconNames } from '@blueprintjs/icons'; -import { ColDef, GridApi, GridReadyEvent } from 'ag-grid-community'; +import { type ColDef, type GridApi, type GridReadyEvent, themeBalham } from 'ag-grid-community'; import { AgGridReact } from 'ag-grid-react'; import React, { useState } from 'react'; import { useSession } from 'src/commons/utils/Hooks'; @@ -199,8 +196,9 @@ const GroundControl: React.FC = props => { ); const grid = ( -
+
{ - const enableOverallLeaderboard = useTypedSelector( - store => store.session.enableOverallLeaderboard - ); - const enableContestLeaderboard = useTypedSelector( - store => store.session.enableContestLeaderboard - ); - const contestAssessments = useTypedSelector(store => store.session.assessmentOverviews); - const defaultContest = - contestAssessments?.find( - assessment => assessment.type == 'Contests' && assessment.isPublished - ) ?? null; - - const courseId = useTypedSelector(store => store.session.courseId); - const baseLink = `/courses/${courseId}/leaderboard`; - - return ( - - - ) : enableContestLeaderboard && defaultContest != null ? ( - - ) : ( - - ) - } - > - - - ) : ( - - ) - } - > - - ); -}; - -// react-router lazy loading -// https://reactrouter.com/en/main/route/lazy -export const Component = Leaderboard; -Component.displayName = 'Leaderboard'; - -export default Leaderboard; diff --git a/src/pages/leaderboard/subcomponents/ContestLeaderboard.tsx b/src/pages/leaderboard/subcomponents/ContestLeaderboard.tsx index d27d903c81..999080f233 100644 --- a/src/pages/leaderboard/subcomponents/ContestLeaderboard.tsx +++ b/src/pages/leaderboard/subcomponents/ContestLeaderboard.tsx @@ -1,8 +1,6 @@ -import 'ag-grid-community/styles/ag-grid.css'; -import 'ag-grid-community/styles/ag-theme-alpine.css'; import 'src/styles/Leaderboard.scss'; -import { ColDef } from 'ag-grid-community'; +import { type ColDef, themeAlpine } from 'ag-grid-community'; import { AgGridReact } from 'ag-grid-react'; import React, { useEffect, useMemo } from 'react'; import { useDispatch } from 'react-redux'; @@ -19,13 +17,17 @@ import leaderboard_background from '../../../assets/leaderboard_background.jpg'; import LeaderboardDropdown from './LeaderboardDropdown'; import LeaderboardExportButton from './LeaderboardExportButton'; import LeaderboardPodium from './LeaderboardPodium'; +import { convertToRandomNumber } from './OverallLeaderboard'; type Props = { - type: string; - contestID: number; + type: 'score' | 'popularvote'; + contest: LeaderboardContestDetails; }; -const ContestLeaderboard: React.FC = ({ type, contestID }) => { +const ContestLeaderboard: React.FC = ({ + type, + contest: { contest_id: contestId, title: contestName } +}) => { const courseID = useTypedSelector(store => store.session.courseId); const visibleEntries = useTypedSelector( store => store.session?.topContestLeaderboardDisplay ?? 10 @@ -39,17 +41,11 @@ const ContestLeaderboard: React.FC = ({ type, contestID }) => { useEffect(() => { if (type === 'score') { - dispatch(LeaderboardActions.getAllContestScores(contestID, visibleEntries)); + dispatch(LeaderboardActions.getAllContestScores(contestId, visibleEntries)); } else { - dispatch(LeaderboardActions.getAllContestPopularVotes(contestID, visibleEntries)); + dispatch(LeaderboardActions.getAllContestPopularVotes(contestId, visibleEntries)); } - }, [dispatch, contestID, type]); - - // Retrieve contests (For dropdown) - const contestDetails: LeaderboardContestDetails[] = useTypedSelector( - store => store.leaderboard.contests - ); - const contestName = contestDetails.find(contest => contest.contest_id === contestID)?.title; + }, [dispatch, contestId, type]); useEffect(() => { dispatch(LeaderboardActions.getContests()); @@ -71,17 +67,6 @@ const ContestLeaderboard: React.FC = ({ type, contestID }) => { .filter(row => row.rank <= Number(visibleEntries)) .slice(top3.length); - // Set sample profile pictures (Seeded random) - function convertToRandomNumber(id: string): number { - const str = id.slice(1); - let hash = 0; - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - hash = (hash << 5) - hash + char; - } - return (Math.abs(hash) % 7) + 1; - } - rankedLeaderboard.map((row: ContestLeaderboardRow) => { row.avatar = `/assets/Sample_Profile_${convertToRandomNumber(row.username)}.jpg`; }); @@ -98,9 +83,8 @@ const ContestLeaderboard: React.FC = ({ type, contestID }) => { () => [ { field: 'rank', - suppressMovable: true, headerName: 'Rank', - width: 84, + flex: 84, sortable: true, cellRenderer: (params: any) => { const rank = params.value; @@ -110,9 +94,8 @@ const ContestLeaderboard: React.FC = ({ type, contestID }) => { }, { field: 'avatar', - suppressMovable: true, headerName: 'Avatar', - width: 180, + flex: 180, sortable: false, cellRenderer: (params: any) => ( = ({ type, contestID }) => { /> ) }, - { field: 'name', suppressMovable: true, headerName: 'Name', width: 520, sortable: true }, + { field: 'name', headerName: 'Name', flex: 520, sortable: true }, { field: 'score', - suppressMovable: true, headerName: 'Score', - width: 154, + flex: 154, sortable: true, valueFormatter: params => params.value?.toFixed(2) }, { field: 'code', - suppressMovable: true, headerName: 'Code', - width: 260, + flex: 260, sortable: false, cellRenderer: (params: any) => ( = ({ type, contestID }) => { ) } ], + // eslint-disable-next-line react-hooks/exhaustive-deps [] ); @@ -163,16 +145,16 @@ const ContestLeaderboard: React.FC = ({ type, contestID }) => {
{/* Leaderboard Options Dropdown */} - - + {/* Export Button */} - +
{/* Leaderboard Table (Top 3) */} -
+

Contest Winners

= ({ type, contestID }) => {
{/* Honourable Mentions */} -
+

Honourable Mentions

= ({ type, contestID }) => { ); }; -// react-router lazy loading -// https://reactrouter.com/en/main/route/lazy -export const Component = ContestLeaderboard; -Component.displayName = 'ContestLeaderboard'; - export default ContestLeaderboard; diff --git a/src/pages/leaderboard/subcomponents/ContestLeaderboardWrapper.tsx b/src/pages/leaderboard/subcomponents/ContestLeaderboardWrapper.tsx index 599c06d4ff..eadd733bda 100644 --- a/src/pages/leaderboard/subcomponents/ContestLeaderboardWrapper.tsx +++ b/src/pages/leaderboard/subcomponents/ContestLeaderboardWrapper.tsx @@ -1,107 +1,18 @@ -import React, { useEffect } from 'react'; -import { useDispatch } from 'react-redux'; -import { Navigate, Route, useLocation, useParams } from 'react-router'; +import React from 'react'; +import { useParams } from 'react-router'; import { useTypedSelector } from 'src/commons/utils/Hooks'; -import LeaderboardActions from 'src/features/leaderboard/LeaderboardActions'; -import type { LeaderboardContestDetails } from 'src/features/leaderboard/LeaderboardTypes'; -import { SentryRoutes } from 'src/routes/routerConfig'; import NotFound from '../../notFound/NotFound'; import ContestLeaderboard from './ContestLeaderboard'; const ContestLeaderboardWrapper: React.FC = () => { - const enableContestLeaderboard = useTypedSelector( - store => store.session.enableContestLeaderboard - ); - - const dispatch = useDispatch(); - const contestAssessments: LeaderboardContestDetails[] = useTypedSelector( - store => store.leaderboard.contests - ); - - useEffect(() => { - dispatch(LeaderboardActions.getContests()); - }, [dispatch]); - - const defaultContest = contestAssessments?.find(assessment => assessment.published) ?? null; - - const courseId = useTypedSelector(store => store.session.courseId); - const baseLink = `/courses/${courseId}/leaderboard`; - - return ( - - - ) : ( - - ) - } - > - - - ) : ( - - ) - } - > - - - ) : ( - - ) - } - > - - }> - - ); -}; - -const ContestLeaderboardWrapperHelper: React.FC<{ - baseLink: string; - contestDetails: LeaderboardContestDetails[]; - type: 'score' | 'popularvote'; -}> = ({ baseLink, contestDetails, type }) => { - let { id } = useParams<{ id: string }>(); - const location = useLocation(); - - if (!id) { - const match = location.pathname.match(/\/contests\/(\d+)\//); - if (match) { - id = match[1]; - } - } - - if (!contestDetails.length) { - // Wait for contestDetails to load - return null; - } - const contest = contestDetails.find(contest => contest.contest_id === parseInt(id ?? '', 10)); - - return contest !== undefined && contest.published ? ( - - ) : ( - - ); + const { contestId, leaderboardType: type } = useParams<{ + contestId: string; + leaderboardType: 'score' | 'popularvote'; + }>(); + const contests = useTypedSelector(state => state.leaderboard.contests); + const contest = contests.find(d => d.contest_id === parseInt(contestId!, 10)); + return contest ? : ; }; // react-router lazy loading diff --git a/src/pages/leaderboard/subcomponents/LeaderboardDropdown.tsx b/src/pages/leaderboard/subcomponents/LeaderboardDropdown.tsx index 6e73234818..b56ddd68f3 100644 --- a/src/pages/leaderboard/subcomponents/LeaderboardDropdown.tsx +++ b/src/pages/leaderboard/subcomponents/LeaderboardDropdown.tsx @@ -1,15 +1,10 @@ import 'src/styles/Leaderboard.scss'; -import React from 'react'; +import React, { Fragment } from 'react'; import { useLocation, useNavigate } from 'react-router'; import { useTypedSelector } from 'src/commons/utils/Hooks'; -import { LeaderboardContestDetails } from 'src/features/leaderboard/LeaderboardTypes'; -type Props = { - contests: LeaderboardContestDetails[]; -}; - -const LeaderboardDropdown: React.FC = ({ contests }) => { +const LeaderboardDropdown: React.FC = () => { const enableOverallLeaderboard = useTypedSelector( store => store.session.enableOverallLeaderboard ); @@ -28,49 +23,23 @@ const LeaderboardDropdown: React.FC = ({ contests }) => { }; const currentPath = location.pathname; + const contests = useTypedSelector(state => state.leaderboard.contests); const publishedContests = enableContestLeaderboard ? contests.filter(contest => contest.published) : []; return ( - <> - {/* Leaderboard Options Dropdown */} - - + ); }; -// react-router lazy loading -// https://reactrouter.com/en/main/route/lazy -export const Component = LeaderboardDropdown; -Component.displayName = 'LeaderboardDropdown'; - export default LeaderboardDropdown; diff --git a/src/pages/leaderboard/subcomponents/LeaderboardExportButton.tsx b/src/pages/leaderboard/subcomponents/LeaderboardExportButton.tsx index 0181b67b69..85493b4057 100644 --- a/src/pages/leaderboard/subcomponents/LeaderboardExportButton.tsx +++ b/src/pages/leaderboard/subcomponents/LeaderboardExportButton.tsx @@ -107,9 +107,4 @@ const LeaderboardExportButton: React.FC = ({ type, contest, contestID }) ); }; -// react-router lazy loading -// https://reactrouter.com/en/main/route/lazy -export const Component = LeaderboardExportButton; -Component.displayName = 'LeaderboardExportButton'; - export default LeaderboardExportButton; diff --git a/src/pages/leaderboard/subcomponents/LeaderboardPodium.tsx b/src/pages/leaderboard/subcomponents/LeaderboardPodium.tsx index 36ca6ef495..9908705841 100644 --- a/src/pages/leaderboard/subcomponents/LeaderboardPodium.tsx +++ b/src/pages/leaderboard/subcomponents/LeaderboardPodium.tsx @@ -37,9 +37,4 @@ const LeaderboardPodium: React.FC = ({ type, data, outputType }) => { ); }; -// react-router lazy loading -// https://reactrouter.com/en/main/route/lazy -export const Component = LeaderboardPodium; -Component.displayName = 'LeaderboardPodium'; - export default LeaderboardPodium; diff --git a/src/pages/leaderboard/subcomponents/OverallLeaderboard.tsx b/src/pages/leaderboard/subcomponents/OverallLeaderboard.tsx index f96fd1e0e1..4f8fd089ee 100644 --- a/src/pages/leaderboard/subcomponents/OverallLeaderboard.tsx +++ b/src/pages/leaderboard/subcomponents/OverallLeaderboard.tsx @@ -1,32 +1,64 @@ -import 'ag-grid-community/styles/ag-grid.css'; -import 'ag-grid-community/styles/ag-theme-alpine.css'; import 'src/styles/Leaderboard.scss'; -import { ColDef, IDatasource } from 'ag-grid-community'; +import { ColDef, IDatasource, themeAlpine } from 'ag-grid-community'; import { AgGridReact } from 'ag-grid-react'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { useDispatch } from 'react-redux'; import default_avatar from 'src/assets/default-avatar.jpg'; import { useTypedSelector } from 'src/commons/utils/Hooks'; import LeaderboardActions from 'src/features/leaderboard/LeaderboardActions'; -import { - LeaderboardContestDetails, - LeaderboardRow -} from 'src/features/leaderboard/LeaderboardTypes'; +import { LeaderboardRow } from 'src/features/leaderboard/LeaderboardTypes'; -import leaderboard_background from '../../../assets/leaderboard_background.jpg'; +import leaderboardBackground from '../../../assets/leaderboard_background.jpg'; import LeaderboardDropdown from './LeaderboardDropdown'; import LeaderboardExportButton from './LeaderboardExportButton'; import LeaderboardPodium from './LeaderboardPodium'; +// Set sample profile pictures (Seeded random) +export function convertToRandomNumber(id: string): number { + const str = id.slice(1); + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + } + return (Math.abs(hash) % 7) + 1; +} + +const columnDefs: ColDef[] = [ + { + field: 'rank', + headerName: 'Rank', + flex: 84, + sortable: true, + cellRenderer: (params: any) => { + const rank = params.value; + const medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : ''; + return `${rank} ${medal}`; + } + }, + { + field: 'avatar', + headerName: 'Avatar', + flex: 180, + sortable: false, + cellRenderer: (params: any) => ( + avatar (e.currentTarget.src = default_avatar)} + style={{ flex: '40px', height: '40px', borderRadius: '50%' }} + /> + ) + }, + { field: 'name', headerName: 'Name', flex: 520, sortable: true }, + { field: 'xp', headerName: 'XP', flex: 414, sortable: true } +]; + const OverallLeaderboard: React.FC = () => { const dispatch = useDispatch(); - // Retrieve contests (For dropdown) - const contestDetails: LeaderboardContestDetails[] = useTypedSelector( - store => store.leaderboard.contests - ); - useEffect(() => { dispatch(LeaderboardActions.getContests()); }, [dispatch]); @@ -34,56 +66,13 @@ const OverallLeaderboard: React.FC = () => { // Temporary loading of leaderboard background useEffect(() => { const originalBackground = document.body.style.background; - document.body.style.background = `url(${leaderboard_background}) center/cover no-repeat fixed`; + document.body.style.background = `url(${leaderboardBackground}) center/cover no-repeat fixed`; return () => { // Cleanup document.body.style.background = originalBackground; }; }, []); - // Define column definitions for ag-Grid - const columnDefs: ColDef[] = useMemo( - () => [ - { - field: 'rank', - suppressMovable: true, - headerName: 'Rank', - width: 84, - sortable: true, - cellRenderer: (params: any) => { - const rank = params.value; - const medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : ''; - return `${rank} ${medal}`; - } - }, - { - field: 'avatar', - suppressMovable: true, - headerName: 'Avatar', - width: 180, - sortable: false, - cellRenderer: (params: any) => ( - avatar (e.currentTarget.src = default_avatar)} - style={{ width: '40px', height: '40px', borderRadius: '50%' }} - /> - ) - }, - { field: 'name', suppressMovable: true, headerName: 'Name', width: 520, sortable: true }, - { - field: 'xp', - suppressMovable: true, - headerName: 'XP', - width: 414 /*154*/, - sortable: true - } - ], - [] - ); - const paginatedLeaderboard: { rows: LeaderboardRow[]; userCount: number } = useTypedSelector( store => store.leaderboard.paginatedUserXp ); @@ -129,17 +118,6 @@ const OverallLeaderboard: React.FC = () => { } }, [paginatedLeaderboard]); - // Set sample profile pictures (Seeded random) - function convertToRandomNumber(id: string): number { - const str = id.slice(1); - let hash = 0; - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - hash = (hash << 5) - hash + char; - } - return (Math.abs(hash) % 7) + 1; - } - paginatedLeaderboard.rows.map((row: LeaderboardRow) => { row.avatar = `/assets/Sample_Profile_${convertToRandomNumber(row.username)}.jpg`; }); @@ -151,16 +129,19 @@ const OverallLeaderboard: React.FC = () => {
{/* Leaderboard Options Dropdown */} - + {/* Export Button */}
{/* Leaderboard Table (Replaced with ag-Grid) */} -
+
{ - const enableOverallLeaderboard = useTypedSelector( - store => store.session.enableOverallLeaderboard - ); - - const [isReady, setIsReady] = useState(false); - - useEffect(() => { - // Wait for enableOverallLeaderboard to be resolved or loaded - if (enableOverallLeaderboard !== undefined) { - setIsReady(true); - } - }, [enableOverallLeaderboard]); - - if (!isReady || !enableOverallLeaderboard) { - return ; - } - - if (!enableOverallLeaderboard) { - return ; - } else { - return ; - } -}; - -// react-router lazy loading -// https://reactrouter.com/en/main/route/lazy -export const Component = OverallLeaderboardWrapper; -Component.displayName = 'OverallLeaderboardWrapper'; - -export default OverallLeaderboardWrapper; diff --git a/src/pages/leaderboard/subcomponents/leaderboardUtils.ts b/src/pages/leaderboard/subcomponents/leaderboardUtils.ts new file mode 100644 index 0000000000..3914685d03 --- /dev/null +++ b/src/pages/leaderboard/subcomponents/leaderboardUtils.ts @@ -0,0 +1,46 @@ +import { LoaderFunction, redirect, replace } from 'react-router'; +import { store } from 'src/pages/createStore'; + +export const leaderboardLoader: LoaderFunction = ({ request, params }) => { + const baseUrl = `/courses/${params.courseId}`; + const { enableOverallLeaderboard, enableContestLeaderboard } = store.getState().session; + if (!enableOverallLeaderboard && !enableContestLeaderboard) { + return redirect(baseUrl + '/not_found'); + } + // If if it a sub-path, pass through transparently + const path = new URL(request.url).pathname; + const rootRegex = new RegExp(`^${baseUrl}/leaderboard/?$`); + if (!rootRegex.test(path)) { + // Don't try to redirect subpaths, pass through and proceed + return null; + } + // Prefer showing overall + return enableOverallLeaderboard + ? replace(baseUrl + '/leaderboard/overall') + : replace(baseUrl + '/leaderboard/contests'); +}; + +export const contestLeaderboardLoader: LoaderFunction = ({ request, params }) => { + const baseUrl = `/courses/${params.courseId}`; + const { enableContestLeaderboard } = store.getState().session; + if (!enableContestLeaderboard) { + return redirect(baseUrl + '/not_found'); + } + const contests = store.getState().leaderboard.contests; + const fallback = contests.find(c => c.published); + if (!fallback) { + // No contests are ready to show scores yet + return redirect(baseUrl + '/not_found'); + } + if (!params.contestId) { + // Fallback to default contest ID + // Prefer score leaderboard + return replace(`${baseUrl}/leaderboard/contests/${fallback.contest_id}/score`); + } + const leaderboardType = params.leaderboardType!; + if (!['score', 'popularvote'].includes(leaderboardType)) { + return redirect(baseUrl + '/not_found'); + } + // Pass through and proceed + return null; +}; diff --git a/src/pages/stories/StoriesTable.tsx b/src/pages/stories/StoriesTable.tsx index d66d73379a..2753e5bf74 100644 --- a/src/pages/stories/StoriesTable.tsx +++ b/src/pages/stories/StoriesTable.tsx @@ -1,9 +1,6 @@ -import 'ag-grid-community/styles/ag-grid.css'; -import 'ag-grid-community/styles/ag-theme-quartz.css'; - import { Icon } from '@blueprintjs/core'; import { IconNames } from '@blueprintjs/icons'; -import { ColDef } from 'ag-grid-community'; +import { ColDef, themeQuartz } from 'ag-grid-community'; import { AgGridReact, CustomCellRendererProps } from 'ag-grid-react'; import React, { useMemo } from 'react'; import GradingFlex from 'src/commons/grading/GradingFlex'; @@ -65,8 +62,9 @@ const StoriesTable: React.FC = ({ headers, stories, storyActions }) => { ); return ( -
+
{ if (!isLoggedIn) { - return redirect('/login', { status: 401 }); + return redirect('/login'); } if (courseId === null) { return redirect('/welcome'); diff --git a/src/styles/Leaderboard.scss b/src/styles/Leaderboard.scss index 7292a78883..d0d27e2867 100644 --- a/src/styles/Leaderboard.scss +++ b/src/styles/Leaderboard.scss @@ -172,110 +172,114 @@ $xp-color: rgb(31, 147, 255); outline: none; } -/* General ag-Grid customization */ -.ag-theme-alpine .ag-header-cell[col-id='name'] .ag-header-cell-label { - text-align: left !important; - border-left: none !important; - justify-content: flex-start !important; - align-items: flex-start !important; -} +.leaderboard-table-container { + width: min(1200px, 100%); + + /* General ag-Grid customization */ + .ag-header-cell[col-id='name'] .ag-header-cell-label { + text-align: left !important; + border-left: none !important; + justify-content: flex-start !important; + align-items: flex-start !important; + } -.ag-theme-alpine .ag-header-cell:last-child .ag-header-cell-resize { - display: none; /* Hide the resize handle after the last column */ -} + .ag-header-cell:last-child .ag-header-cell-resize { + display: none; /* Hide the resize handle after the last column */ + } -.ag-theme-alpine .ag-root-wrapper { - width: 1200px; - background-color: transparent; - border-radius: 3px; - overflow: hidden; - color: white; -} + .ag-root-wrapper { + background-color: transparent; + border-radius: 3px; + overflow: hidden; + color: white; + } -.ag-theme-alpine .ag-header { - background-color: $highlight-color; - border-bottom: 1px solid $border-color; - font-size: 15px; -} + .ag-header { + background-color: $highlight-color; + border-bottom: 1px solid $border-color; + font-size: 15px; + } -.ag-theme-alpine .ag-header-cell { - padding: 10px; - font-weight: bold; - color: white; - text-align: center; -} + .ag-header-cell { + padding: 10px; + font-weight: bold; + color: white; + text-align: center; + } -.ag-theme-alpine .ag-header-cell-label { - text-align: center; - justify-content: center; -} + .ag-header-cell-label { + text-align: center; + justify-content: center; + } -.ag-theme-alpine .ag-row { - background-color: rgba(255, 255, 255, 0.05); - color: white; - font-size: 15px; - text-shadow: 1px 1px #000000; -} + .ag-row { + background-color: rgba(255, 255, 255, 0.05); + color: white; + font-size: 15px; + text-shadow: 1px 1px #000000; + } -.ag-theme-alpine .ag-cell { - padding: 10px; - text-align: center; - border: 1px solid $border-color; - background-color: rgba(0, 0, 0, 0.55); -} + .ag-cell { + padding: 10px; + text-align: center; + border: 1px solid $border-color; + background-color: rgba(0, 0, 0, 0.55); -.ag-theme-alpine .ag-cell:nth-child(2) { - border-right: none; -} + &:nth-child(2) { + border-right: none; + } -.ag-theme-alpine .ag-cell:nth-child(3) { - text-align: left; - border-left: none; -} + &:nth-child(3) { + text-align: left; + border-left: none; + } + } -/* Avatar styling */ -.avatar { - width: 40px; - height: 40px; - border-radius: 50%; - object-fit: cover; -} + /* Avatar styling */ + .avatar { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; + } -/* Pagination styling */ -.ag-theme-alpine .ag-paging-panel .ag-picker-field-display { - color: black; -} -.ag-theme-alpine .ag-paging-panel { - display: flex; - gap: 10px; - margin-top: 20px; - color: white; - background-color: rgba(255, 255, 255, 0.05); -} + /* Pagination styling */ + .ag-paging-panel { + display: flex; + gap: 10px; + margin-top: 20px; + color: white; + background-color: rgba(255, 255, 255, 0.05); -.ag-theme-alpine .ag-paging-panel .ag-paging-button { - padding: 8px 12px; - border: none; - background: rgba(255, 255, 255, 1); - color: white; - border-radius: 5px; - cursor: pointer; - transition: 0.3s; -} + .ag-picker-field-display { + color: black; + } -.ag-theme-alpine .ag-paging-panel .ag-paging-button:hover { - background: rgba(255, 255, 255, 0.4); -} + .ag-paging-button { + padding: 8px 12px; + border: none; + background: rgba(255, 255, 255, 1); + color: white; + border-radius: 5px; + cursor: pointer; + transition: 0.3s; + } -.ag-theme-alpine .ag-paging-panel .ag-paging-button:disabled { - opacity: 0.5; - cursor: not-allowed; -} + .ag-paging-button:hover { + background: rgba(255, 255, 255, 0.4); + } -.ag-theme-alpine .ag-paging-panel .ag-paging-button.ag-active { - background: white; - color: black; - font-weight: bold; + .ag-paging-button:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .ag-paging-button.ag-active { + background: white; + color: black; + font-weight: bold; + } + } } .table-gap { @@ -305,8 +309,4 @@ h2 { width: 80%; max-width: 300px; } - - .leaderboard-table { - width: 100%; - } } diff --git a/yarn.lock b/yarn.lock index e4aa806f63..665d93974f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4630,32 +4630,32 @@ __metadata: languageName: node linkType: hard -"ag-charts-types@npm:10.3.9": - version: 10.3.9 - resolution: "ag-charts-types@npm:10.3.9" - checksum: 10c0/09edd315982e86b73c15a0d33ca852059d07ebe0928b5628ad34cf8d65ff1025f3d8f9f5393304d6ad7dd0780f5759c3f70f45df35a438ef691ff89d0c82bb64 +"ag-charts-types@npm:12.1.1": + version: 12.1.1 + resolution: "ag-charts-types@npm:12.1.1" + checksum: 10c0/8d49a1598cbb4bcc756bd85a622bc692d525c079751cf8fbcd94b39aab8b0c0297176de062bf846540e5d5598e9cb1786609f019682619999424d64ade70d3c8 languageName: node linkType: hard -"ag-grid-community@npm:32.3.9, ag-grid-community@npm:^32.3.1": - version: 32.3.9 - resolution: "ag-grid-community@npm:32.3.9" +"ag-grid-community@npm:34.1.1, ag-grid-community@npm:^34.1.1": + version: 34.1.1 + resolution: "ag-grid-community@npm:34.1.1" dependencies: - ag-charts-types: "npm:10.3.9" - checksum: 10c0/7b7bd1e6f653296fd4e9db533bbf0632fd1aa5fb517fd0b37d33db770b3d7b0451baae115ee8cb77a5a1b81cb696c61d8b8f8508373e64693a2e1927c109478d + ag-charts-types: "npm:12.1.1" + checksum: 10c0/7558385f74cd6ccb7738afc61720fdb2742d7b689d3d65fdc60cb3818243444109b65845d26b5005c48194b15f0f2fdfa0a38d10617c49364027f87cdc3b2234 languageName: node linkType: hard -"ag-grid-react@npm:^32.3.1": - version: 32.3.9 - resolution: "ag-grid-react@npm:32.3.9" +"ag-grid-react@npm:^34.1.1": + version: 34.1.1 + resolution: "ag-grid-react@npm:34.1.1" dependencies: - ag-grid-community: "npm:32.3.9" + ag-grid-community: "npm:34.1.1" prop-types: "npm:^15.8.1" peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - checksum: 10c0/356a88358f3986487549283f807d4c6e1230d1223a9acc37ab44700b85a9dc869a4e46c90fb43fe63b23ed2b8cca679ba5f8d8f207322057edf52c6f77b9099f + checksum: 10c0/8df25755eb97f8f6c647f750ea0f7aaf8e360ae00a9d105e90284303b9c5620d1b391e34fcda3faa046d76714ee49b4ce184e8c03e1aa4d9c3c28878e6c76659 languageName: node linkType: hard @@ -7592,8 +7592,8 @@ __metadata: "@vitest/ui": "npm:^3.2.4" ace-builds: "npm:^1.42.1" acorn: "npm:^8.9.0" - ag-grid-community: "npm:^32.3.1" - ag-grid-react: "npm:^32.3.1" + ag-grid-community: "npm:^34.1.1" + ag-grid-react: "npm:^34.1.1" array-move: "npm:^4.0.0" browserfs: "npm:^1.4.3" buffer: "npm:^6.0.3"