diff --git a/README.md b/README.md index f7a4a9a..42f6940 100644 --- a/README.md +++ b/README.md @@ -27,3 +27,4 @@ For Milestone 3, the plan is to create an event/challenge exporter and uploader. - [3/29/24] - The UI for starting and stopping containers has been created on the user side. Admins can view all containers and the status of them on the admin/containers view. - [4/7/24] - Settings page has been created for admins. Admins can now edit the start and end time of the game, auto-generate badges, export challenges and related data, and import back in the challenges and related data. On the user side, the start and end time of the game is displayed at the top. +- [4/14/24] - Added the ability for admins to reorder challenges/categories. Various bug fixes and improvements have been made to the platform. diff --git a/client/package-lock.json b/client/package-lock.json index 9ff2019..11cb5f9 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -10,6 +10,8 @@ "dependencies": { "@ant-design/icons": "^5.3.5", "@ant-design/plots": "^1.2.6", + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/sortable": "^8.0.0", "@react-hook/window-size": "^3.1.1", "antd": "^5.12.1", "axios": "^1.6.5", @@ -810,6 +812,55 @@ "node": ">=10" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz", + "integrity": "sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.1.0.tgz", + "integrity": "sha512-J3cQBClB4TVxwGo3KEjssGEXNJqGVWx17aRTZ1ob0FliR5IjYgTxl5YJbKTzA6IzrtelotH19v6y7uoIRUZPSg==", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.0", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz", + "integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.1.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emotion/hash": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", diff --git a/client/package.json b/client/package.json index d3558f1..cc69a6d 100644 --- a/client/package.json +++ b/client/package.json @@ -12,6 +12,8 @@ "dependencies": { "@ant-design/icons": "^5.3.5", "@ant-design/plots": "^1.2.6", + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/sortable": "^8.0.0", "@react-hook/window-size": "^3.1.1", "antd": "^5.12.1", "axios": "^1.6.5", diff --git a/client/src/components/SortableItem.tsx b/client/src/components/SortableItem.tsx new file mode 100644 index 0000000..695af64 --- /dev/null +++ b/client/src/components/SortableItem.tsx @@ -0,0 +1,24 @@ +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; + +interface SortableItemProps { + children: React.ReactNode; + id: string; +} + +function SortableItem({ children, id }: SortableItemProps) { + const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+ {children} +
+ ); +} + +export default SortableItem; diff --git a/client/src/pages/ChallengesView.tsx b/client/src/pages/ChallengesView.tsx index 58d749f..95e9ce0 100644 --- a/client/src/pages/ChallengesView.tsx +++ b/client/src/pages/ChallengesView.tsx @@ -2,6 +2,15 @@ import { useEffect, useState } from "react"; import { useWindowSize } from "@react-hook/window-size"; import { Button, Card, Divider, notification } from "antd"; import ChallengeModal from "../components/ChallengeModal/ChallengeModal"; +import { + DndContext, + closestCenter, + MouseSensor, + useSensor, + useSensors, + DragOverEvent, +} from "@dnd-kit/core"; +import { SortableContext, arrayMove, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { Challenge } from "../types/Challenge"; import { ChallengeService } from "@/services/challengeService"; import _ from "lodash"; @@ -9,6 +18,9 @@ import { ClientError } from "@/types/ClientError"; import Confetti from "react-confetti"; import { SubmitAttemptResponse } from "@/services/attemptService"; import { useNavigate } from "react-router-dom"; +import SortableItem from "@/components/SortableItem"; +import { useUser } from "@/contexts/UserContext"; +import { ConfigurationService } from "@/services/configurationService"; interface Category { name: string; @@ -17,24 +29,44 @@ interface Category { function ChallengesView() { const [isModalOpen, setIsModalOpen] = useState(false); + const userContext = useUser(); const [selectedChallenge, setSelectedChallenge] = useState(); const [categories, setCategories] = useState([]); const [isConfettiActive, setIsConfettiActive] = useState(false); const [width, height] = useWindowSize(); const navigate = useNavigate(); + const sensors = useSensors( + useSensor(MouseSensor, { + activationConstraint: { + delay: 100, + tolerance: 5, + }, + }) + ); + + const isAdmin = userContext?.user?.isAdmin || false; useEffect(() => { async function getChallenges() { try { - const response = await ChallengeService.getChallenges(false); - const categories = _.groupBy(response, "category"); + const response = await ChallengeService.getChallengesAsUser(); + const categories = _.groupBy(response.challenges, "category"); const categoriesArray: Category[] = []; for (const category in categories) { const challenges = categories[category]; categoriesArray.push({ name: category, - challenges: challenges, + challenges: challenges.sort((a, b) => a.order - b.order), + }); + } + + const categoryOrder = response.categoryOrder; + if (categoryOrder) { + categoriesArray.sort((a, b) => { + const aIndex = categoryOrder.indexOf(a.name); + const bIndex = categoryOrder.indexOf(b.name); + return aIndex - bIndex; }); } @@ -114,34 +146,154 @@ function ChallengesView() { setIsModalOpen(false); }; + const handleCategoryDragEnd = async (event: DragOverEvent) => { + const { active, over } = event; + + if (!over || active.id === over.id) { + return; + } + + if (active.id !== over.id) { + const oldCategories = categories; + + try { + const newCategories = arrayMove( + categories, + categories.findIndex((category) => category.name === active.id), + categories.findIndex((category) => category.name === over.id) + ); + + setCategories(newCategories); + + await ConfigurationService.reorderCategories(newCategories.map((c) => c.name)); + } catch (error) { + setCategories(oldCategories); + console.log(error); + new ClientError(error).toast(); + } + } + }; + + const handleChallengeDragEnd = async (event: DragOverEvent) => { + const { active, over } = event; + + if (!over || active.id === over.id) { + return; + } + + const category = categories.find((category) => { + return category.challenges.find((challenge) => challenge.id === Number(active.id)); + }); + + const activeChallengeIndex = category?.challenges.findIndex( + (challenge) => challenge.id === Number(active.id) + ); + const overChallengeIndex = category?.challenges.findIndex( + (challenge) => challenge.id === Number(over.id) + ); + + if (!category || activeChallengeIndex === undefined || overChallengeIndex === undefined) { + return; + } + + try { + const newChallenges = arrayMove( + category.challenges, + activeChallengeIndex, + overChallengeIndex + ); + + await ConfigurationService.reorderChallenges( + category.name, + newChallenges.map((c) => c.id) + ); + + // Same category, reorder challenges + setCategories((categories) => { + const updatedCategories = categories.map((categoryToMap) => { + if (categoryToMap.name === category.name) { + return { + ...category, + challenges: newChallenges, + }; + } + + return categoryToMap; + }); + + return updatedCategories; + }); + } catch (error) { + console.log(error); + new ClientError(error).toast(); + } + }; + return ( <> - {categories.map((category, index) => ( -
-

{category.name}

- -
- {category.challenges.map((challenge) => ( - showModal(challenge)} - > - {challenge.shortDescription} - - ))} -
- - {/* Add a divider between categories, except after the last one */} - {index < categories.length - 1 && ( - - )} -
- ))} + + category.name)} + strategy={verticalListSortingStrategy} + > + {categories.map((category, index) => ( + +
+

{category.name}

+ +
+ + + challenge.id.toString() + )} + > + {category.challenges.map((challenge) => ( + + { + e.stopPropagation(); + showModal(challenge); + }} + > + {challenge.shortDescription} + + + ))} + + +
+ + {/* Add a divider between categories, except after the last one */} + {index < categories.length - 1 && ( + + )} +
+
+ ))} +
+
{ useEffect(() => { async function getChallenges() { - const data = await ChallengeService.getChallenges(true); + const data = await ChallengeService.getChallengesAsAdmin(); setChallenges(data); } diff --git a/client/src/services/challengeService.ts b/client/src/services/challengeService.ts index 5c1e0ba..4b4539d 100644 --- a/client/src/services/challengeService.ts +++ b/client/src/services/challengeService.ts @@ -6,9 +6,19 @@ import { UserContainer, UserContainerWithK8Data } from '@/types/UserContainer'; const url = baseUrl + '/challenges'; const adminUrl = url + '/admin'; +interface GetChallengesUserResponse { + challenges: Challenge[]; + categoryOrder: string[]; +} + export class ChallengeService { - static async getChallenges(isAdmin: boolean): Promise { - const response = await axios.get(isAdmin ? adminUrl : url); + static async getChallengesAsUser() { + const response = await axios.get(url); + return response.data; + } + + static async getChallengesAsAdmin() { + const response = await axios.get(adminUrl); return response.data; } diff --git a/client/src/services/configurationService.ts b/client/src/services/configurationService.ts index af73290..28ef953 100644 --- a/client/src/services/configurationService.ts +++ b/client/src/services/configurationService.ts @@ -12,4 +12,12 @@ export class ConfigurationService { const response = await axios.put(baseUrl + '/configuration', configuration); return response.data; } + + static async reorderCategories(categories: string[]) { + await axios.post(baseUrl + '/configuration/reorder/categories', { categories }); + } + + static async reorderChallenges(category: string, challenges: number[]) { + await axios.post(baseUrl + '/configuration/reorder/category/' + category, { challenges }); + } } \ No newline at end of file diff --git a/client/src/types/Challenge.ts b/client/src/types/Challenge.ts index cbce40c..4491801 100644 --- a/client/src/types/Challenge.ts +++ b/client/src/types/Challenge.ts @@ -40,6 +40,7 @@ interface BaseChallenge { containerPorts: number[]; containerInstructions: string; containerImage: string; + order: number; } export interface MultipleChoiceOption { diff --git a/server/__tests__/controllers/challenges.test.ts b/server/__tests__/controllers/challenges.test.ts index 7ec4086..4e5a9f0 100644 --- a/server/__tests__/controllers/challenges.test.ts +++ b/server/__tests__/controllers/challenges.test.ts @@ -2,7 +2,7 @@ import request from 'supertest'; import app from '../../src/app'; import { verifyAccess } from '../../src/middleware/verifyAccess'; import { verifyIsAdmin } from '../../src/middleware/verifyIsAdmin'; -import { Challenge, ChallengeFile, Container } from '../../src/database/models'; +import { Challenge, ChallengeFile, Configuration, Container } from '../../src/database/models'; import path from 'path'; // Mock the verifyAccess middleware @@ -58,6 +58,9 @@ jest.mock('../../src/database/models', () => { findByPk: jest.fn().mockReturnValue({}), bulkCreate: jest.fn(), destroy: jest.fn(), + }, + Configuration: { + findOne: jest.fn(), } } }); diff --git a/server/src/controllers/challenges.ts b/server/src/controllers/challenges.ts index d802ac0..fa8774f 100644 --- a/server/src/controllers/challenges.ts +++ b/server/src/controllers/challenges.ts @@ -1,7 +1,7 @@ import { Request, Response, Router } from "express"; import { Transaction } from "sequelize"; import errorHandler from "../middleware/errorHandler"; -import { Challenge, ChallengeFile, Container, MultipleChoiceOption, ShortAnswerOption, User } from "../database/models"; +import { Challenge, ChallengeFile, Configuration, Container, MultipleChoiceOption, ShortAnswerOption, User } from "../database/models"; import { ChallengeService } from "../services/challengeService"; import { verifyAccess } from "../middleware/verifyAccess"; import { verifyIsAdmin } from "../middleware/verifyIsAdmin"; @@ -19,13 +19,15 @@ router.get("/", verifyAccess, errorHandler(async (req: Request, res: Response) = .scope({ method: ['withUserAttempts', req.payload!.userId] }) .findAll(); + const config = await Configuration.findOne({ attributes: ["categoryOrder"] }); + for (let challenge of challenges) { if (!challenge.isSolvedOrExhausted) { ChallengeService.removeAnswers(challenge); } } - return res.json(challenges); + return res.json({ challenges, categoryOrder: config?.categoryOrder }); })); router.get("/admin/:id", verifyIsAdmin, errorHandler(async (req: Request, res: Response) => { diff --git a/server/src/controllers/configuration.ts b/server/src/controllers/configuration.ts index bfe2e5a..f5c2dbd 100644 --- a/server/src/controllers/configuration.ts +++ b/server/src/controllers/configuration.ts @@ -1,19 +1,22 @@ -import { Router } from "express"; -import { Configuration, User } from "../database/models"; +import { Router, Request, Response } from "express"; +import { Challenge, Configuration } from "../database/models"; import { verifyIsAdmin } from "../middleware/verifyIsAdmin"; +import errorHandler from "../middleware/errorHandler"; const router = Router(); -router.get("/", async (_req, res) => { - const configuration = await Configuration.findOne(); +router.get("/", errorHandler(async (_req: Request, res: Response) => { + const configuration = await Configuration.findOne({ + attributes: ["startTime", "endTime"], + }); if (!configuration) { return res.json({}); } return res.json(configuration); -}); +})); -router.put("/", verifyIsAdmin, async (req, res) => { +router.put("/", verifyIsAdmin, errorHandler(async (req: Request, res: Response) => { const { startTime, endTime } = req.body; const [configuration, isCreated] = await Configuration.findOrCreate( @@ -27,6 +30,47 @@ router.put("/", verifyIsAdmin, async (req, res) => { } return res.json(configuration); -}); +})); + +router.post("/reorder/categories", verifyIsAdmin, errorHandler(async (req: Request, res: Response) => { + if (!req.body.categories) { + return res.status(400).json({ error: "Categories not provided" }); + } + + const categories = req.body.categories as string[]; + + const [configuration, isCreated] = await Configuration.findOrCreate( + { where: {}, defaults: { categoryOrder: categories } } + ); + + if (!isCreated) { + configuration.categoryOrder = categories; + await configuration.save(); + } + + return res.json({ success: true }); +})); + +router.post("/reorder/category/:category", verifyIsAdmin, errorHandler(async (req: Request, res: Response) => { + const { category } = req.params; + const challenges = req.body.challenges as number[]; + + if (!category || !challenges) { + return res.status(400).json({ error: "Category or challenges not provided" }); + } + + const challengesInCategory = await Challenge.findAll({ where: { category } }); + // Match order of challenges + const orderedChallenges = challengesInCategory.map((challenge) => { + const order = challenges.indexOf(challenge.id); + challenge.order = order; + + return challenge; + }); + + await Promise.all(orderedChallenges.map((challenge) => challenge.save())); + + return res.json({ success: true }); +})); export default router; \ No newline at end of file diff --git a/server/src/database/models/configuration.ts b/server/src/database/models/configuration.ts index a2a3169..73a5cbf 100644 --- a/server/src/database/models/configuration.ts +++ b/server/src/database/models/configuration.ts @@ -2,13 +2,17 @@ import { Table, Column, Model, DataType, AllowNull } from 'sequelize-typescript' @Table({ tableName: 'Configuration', timestamps: false }) class Configuration extends Model { - @AllowNull(false) + @AllowNull(true) @Column(DataType.DATE) startTime!: Date; - @AllowNull(false) + @AllowNull(true) @Column(DataType.DATE) endTime!: Date; + + @AllowNull(true) + @Column(DataType.JSON) + categoryOrder!: string[]; } export { Configuration as _Configuration }; \ No newline at end of file