Skip to content

Commit

Permalink
Reorder challenges/categories + bug fixes
Browse files Browse the repository at this point in the history
  • Loading branch information
ethanlaj committed Apr 14, 2024
1 parent d502ff2 commit 9c967ad
Show file tree
Hide file tree
Showing 13 changed files with 346 additions and 44 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
51 changes: 51 additions & 0 deletions client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
24 changes: 24 additions & 0 deletions client/src/components/SortableItem.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
{children}
</div>
);
}

export default SortableItem;
210 changes: 181 additions & 29 deletions client/src/pages/ChallengesView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,25 @@ 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";
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;
Expand All @@ -17,24 +29,44 @@ interface Category {

function ChallengesView() {
const [isModalOpen, setIsModalOpen] = useState(false);
const userContext = useUser();
const [selectedChallenge, setSelectedChallenge] = useState<Challenge | undefined>();
const [categories, setCategories] = useState<Category[]>([]);
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;
});
}

Expand Down Expand Up @@ -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) => (
<div key={category.name} className="mt-4">
<h2 className="text-left text-xl font-bold">{category.name}</h2>

<div className="flex flex-wrap justify-start">
{category.challenges.map((challenge) => (
<Card
key={challenge.id}
className={getCardColor(challenge)}
extra={challenge.points}
title={challenge.title}
bordered={false}
style={{ width: 300, margin: "15px" }}
onClick={() => showModal(challenge)}
>
{challenge.shortDescription}
</Card>
))}
</div>

{/* Add a divider between categories, except after the last one */}
{index < categories.length - 1 && (
<Divider className="border-2 border-gray-300" />
)}
</div>
))}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleCategoryDragEnd}
>
<SortableContext
disabled={!isAdmin}
items={categories.map((category) => category.name)}
strategy={verticalListSortingStrategy}
>
{categories.map((category, index) => (
<SortableItem key={category.name} id={category.name}>
<div key={category.name} className="mt-4">
<h2 className="text-left text-xl font-bold">{category.name}</h2>

<div className="flex flex-wrap justify-start">
<DndContext
key={category.name + "Context"}
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleChallengeDragEnd}
>
<SortableContext
disabled={!isAdmin}
strategy={verticalListSortingStrategy}
items={category.challenges.map((challenge) =>
challenge.id.toString()
)}
>
{category.challenges.map((challenge) => (
<SortableItem
key={challenge.id}
id={challenge.id.toString()}
>
<Card
key={challenge.id}
className={getCardColor(challenge)}
extra={challenge.points}
title={challenge.title}
bordered={false}
style={{ width: 300, margin: "15px" }}
onClick={(e) => {
e.stopPropagation();
showModal(challenge);
}}
>
{challenge.shortDescription}
</Card>
</SortableItem>
))}
</SortableContext>
</DndContext>
</div>

{/* Add a divider between categories, except after the last one */}
{index < categories.length - 1 && (
<Divider className="border-2 border-gray-300" />
)}
</div>
</SortableItem>
))}
</SortableContext>
</DndContext>
<ChallengeModal
challenge={selectedChallenge}
isOpen={isModalOpen}
Expand Down
2 changes: 1 addition & 1 deletion client/src/pages/admin/Challenges.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const Challenges = () => {

useEffect(() => {
async function getChallenges() {
const data = await ChallengeService.getChallenges(true);
const data = await ChallengeService.getChallengesAsAdmin();
setChallenges(data);
}

Expand Down
14 changes: 12 additions & 2 deletions client/src/services/challengeService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Challenge[]> {
const response = await axios.get(isAdmin ? adminUrl : url);
static async getChallengesAsUser() {
const response = await axios.get<GetChallengesUserResponse>(url);
return response.data;
}

static async getChallengesAsAdmin() {
const response = await axios.get<Challenge[]>(adminUrl);
return response.data;
}

Expand Down
Loading

0 comments on commit 9c967ad

Please sign in to comment.