From 913072e16d9b308de73d2a30d9f47877d0be5928 Mon Sep 17 00:00:00 2001 From: Yuki Ito <37338201+ykit00@users.noreply.github.com> Date: Wed, 14 Feb 2024 17:06:50 +0900 Subject: [PATCH] feat: progress on problems (#36) * style: run prisma format * feat: Add UserSolvedProblem table and relation to User model * fix: Programming language name (#31) * feat: Add checkpoint problem (#30) * feat: add create userSolvedProblem action * refactor: fix Addressability directory * feat: handle complete problem * style: display progress * feat: display progress and complete status * style: fix import and tidy up * refactor: renaming function * refactor: use server component and Improved performance --------- Co-authored-by: tatehito <48908346+Tatehito@users.noreply.github.com> --- .../migration.sql | 11 ++ prisma/schema.prisma | 17 ++- public/crown.png | Bin 0 -> 3740 bytes .../(withAuth)/courses/[courseId]/Course.tsx | 135 ++++++++++++++++++ .../(withAuth)/courses/[courseId]/page.tsx | 102 +++---------- .../[programId]/CheckpointProblem.tsx | 8 +- .../[programId]/ExecutionResultProblem.tsx | 11 +- .../programs/[programId]/StepProblem.tsx | 9 +- .../[courseId]}/programs/[programId]/page.tsx | 21 ++- src/app/lib/actions.ts | 46 ++++++ 10 files changed, 262 insertions(+), 98 deletions(-) create mode 100644 prisma/migrations/20240209123948_create_user_solved_problem_table/migration.sql create mode 100644 public/crown.png create mode 100644 src/app/(withAuth)/courses/[courseId]/Course.tsx rename src/app/(withAuth)/{ => courses/[courseId]}/programs/[programId]/CheckpointProblem.tsx (91%) rename src/app/(withAuth)/{ => courses/[courseId]}/programs/[programId]/ExecutionResultProblem.tsx (81%) rename src/app/(withAuth)/{ => courses/[courseId]}/programs/[programId]/StepProblem.tsx (89%) rename src/app/(withAuth)/{ => courses/[courseId]}/programs/[programId]/page.tsx (74%) create mode 100644 src/app/lib/actions.ts diff --git a/prisma/migrations/20240209123948_create_user_solved_problem_table/migration.sql b/prisma/migrations/20240209123948_create_user_solved_problem_table/migration.sql new file mode 100644 index 00000000..23be143f --- /dev/null +++ b/prisma/migrations/20240209123948_create_user_solved_problem_table/migration.sql @@ -0,0 +1,11 @@ +-- CreateTable +CREATE TABLE "UserSolvedProblem" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + "userId" TEXT NOT NULL, + "courseId" TEXT NOT NULL, + "programId" TEXT NOT NULL, + "languageId" TEXT NOT NULL, + CONSTRAINT "UserSolvedProblem_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3ebb8699..8ed807e7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,9 +13,22 @@ datasource db { // ----------------------------------------------------------------------------- model User { - id String @id + id String @id createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - displayName String @unique + displayName String @unique + userSolvedAnswers UserSolvedProblem[] +} + +model UserSolvedProblem { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id]) + userId String + courseId String + programId String + languageId String } diff --git a/public/crown.png b/public/crown.png new file mode 100644 index 0000000000000000000000000000000000000000..efa99119e2d1bef20cb90591f81ae527671e2182 GIT binary patch literal 3740 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 literal 0 HcmV?d00001 diff --git a/src/app/(withAuth)/courses/[courseId]/Course.tsx b/src/app/(withAuth)/courses/[courseId]/Course.tsx new file mode 100644 index 00000000..7c9d0023 --- /dev/null +++ b/src/app/(withAuth)/courses/[courseId]/Course.tsx @@ -0,0 +1,135 @@ +'use client'; + +import { + Box, + Heading, + VStack, + Accordion, + AccordionItem, + AccordionButton, + AccordionIcon, + AccordionPanel, + Select, + Table, + TableContainer, + Thead, + Tbody, + Tr, + Td, + Th, + Flex, +} from '@chakra-ui/react'; +import Image from 'next/image'; +import NextLink from 'next/link'; +import React, { useEffect, useState } from 'react'; + +import { + courseIdToProgramIdLists, + languageIdToName, + languageIds, + programIdToName, +} from '../../../../problems/problemData'; +import { getLanguageIdFromSessionStorage, setLanguageIdToSessionStorage } from '../../../lib/SessionStorage'; + +export const Course: React.FC<{ + courseId: string; + userSolvedProblems: { programId: string; languageId: string }[]; +}> = ({ courseId, userSolvedProblems }) => { + const [selectedLanguageId, setSelectedLanguageId] = useState(''); + + const SPECIFIED_COMPLETION_COUNT = 2; + + useEffect(() => { + setSelectedLanguageId(getLanguageIdFromSessionStorage()); + }, []); + + const handleSelectLanguage = (event: React.ChangeEvent): void => { + const inputValue = event.target.value; + setLanguageIdToSessionStorage(inputValue); + setSelectedLanguageId(inputValue); + }; + + const countUserSolvedProblems = (programId: string, languageId: string): number => { + return userSolvedProblems.filter( + (userSolvedProblem) => userSolvedProblem.programId === programId && userSolvedProblem.languageId === languageId + ).length; + }; + + return ( +
+ + Lessons + + + + {courseIdToProgramIdLists[courseId].map((programIds, iLesson) => ( + + + + + + 第{iLesson + 1}回 + + + + + + + + + + + + + + {programIds.map((programId) => ( + + + + + ))} + +
+ プログラム + + 進捗 +
+ + {programIdToName[programId]} + + + +

+ {countUserSolvedProblems(programId, selectedLanguageId)} /{' '} + {SPECIFIED_COMPLETION_COUNT} +

+ {countUserSolvedProblems(programId, selectedLanguageId) >= + SPECIFIED_COMPLETION_COUNT && ( + + 完了の王冠 + + )} +
+
+
+
+
+
+
+ ))} +
+
+ ); +}; diff --git a/src/app/(withAuth)/courses/[courseId]/page.tsx b/src/app/(withAuth)/courses/[courseId]/page.tsx index f4971160..0c31682e 100644 --- a/src/app/(withAuth)/courses/[courseId]/page.tsx +++ b/src/app/(withAuth)/courses/[courseId]/page.tsx @@ -1,90 +1,30 @@ -'use client'; - -import { - Box, - Heading, - OrderedList, - ListItem, - VStack, - Accordion, - AccordionItem, - AccordionButton, - AccordionIcon, - AccordionPanel, - Select, -} from '@chakra-ui/react'; import type { NextPage } from 'next'; -import NextLink from 'next/link'; -import React, { useEffect, useState } from 'react'; +import { redirect } from 'next/navigation'; + +import { prisma } from '../../../../infrastructures/prisma'; +import { getNullableSessionOnServer } from '../../../../utils/session'; +import { fetchUserSolvedProblems } from '../../../lib/actions'; -import { - courseIdToProgramIdLists, - languageIdToName, - languageIds, - programIdToName, -} from '../../../../problems/problemData'; -import { getLanguageIdFromSessionStorage, setLanguageIdToSessionStorage } from '../../../lib/SessionStorage'; +import { Course } from './Course'; -const CoursePage: NextPage<{ params: { courseId: string } }> = ({ params }) => { - const [selectedLanguageId, setSelectedLanguageId] = useState(''); +const CoursePage: NextPage<{ params: { courseId: string } }> = async ({ params }) => { + const { session } = await getNullableSessionOnServer(); + const user = + session && + (await prisma.user.findUnique({ + where: { + id: session.getUserId(), + }, + })); - useEffect(() => { - setSelectedLanguageId(getLanguageIdFromSessionStorage()); - }, []); + if (!user) { + return redirect('/auth'); + } - const handleSelectLanguage = (event: React.ChangeEvent): void => { - const inputValue = event.target.value; - setLanguageIdToSessionStorage(inputValue); - setSelectedLanguageId(inputValue); - }; + const courseId = params.courseId; + const userSolvedProblems = await fetchUserSolvedProblems(user.id, courseId); - return ( -
- - Lessons - - - - {courseIdToProgramIdLists[params.courseId].map((programIds, iLesson) => ( - - - - - - 第{iLesson + 1}回 - - - - - - {programIds.map((programId) => ( - - - {programIdToName[programId]} - - - ))} - - - - - - ))} - -
- ); + return ; }; export default CoursePage; diff --git a/src/app/(withAuth)/programs/[programId]/CheckpointProblem.tsx b/src/app/(withAuth)/courses/[courseId]/programs/[programId]/CheckpointProblem.tsx similarity index 91% rename from src/app/(withAuth)/programs/[programId]/CheckpointProblem.tsx rename to src/app/(withAuth)/courses/[courseId]/programs/[programId]/CheckpointProblem.tsx index 52e0b7a8..332e9001 100644 --- a/src/app/(withAuth)/programs/[programId]/CheckpointProblem.tsx +++ b/src/app/(withAuth)/courses/[courseId]/programs/[programId]/CheckpointProblem.tsx @@ -3,10 +3,10 @@ import { Box, Button, Flex, HStack, VStack } from '@chakra-ui/react'; import { useRef } from 'react'; -import { SyntaxHighlighter } from '../../../../components/organisms/SyntaxHighlighter'; -import type { TurtleGraphicsHandle } from '../../../../components/organisms/TurtleGraphics'; -import { TurtleGraphics } from '../../../../components/organisms/TurtleGraphics'; -import type { ProblemType } from '../../../../types'; +import { SyntaxHighlighter } from '../../../../../../components/organisms/SyntaxHighlighter'; +import type { TurtleGraphicsHandle } from '../../../../../../components/organisms/TurtleGraphics'; +import { TurtleGraphics } from '../../../../../../components/organisms/TurtleGraphics'; +import type { ProblemType } from '../../../../../../types'; interface CheckpointProblemProps { problemProgram: string; diff --git a/src/app/(withAuth)/programs/[programId]/ExecutionResultProblem.tsx b/src/app/(withAuth)/courses/[courseId]/programs/[programId]/ExecutionResultProblem.tsx similarity index 81% rename from src/app/(withAuth)/programs/[programId]/ExecutionResultProblem.tsx rename to src/app/(withAuth)/courses/[courseId]/programs/[programId]/ExecutionResultProblem.tsx index 45fe0166..9ee57985 100644 --- a/src/app/(withAuth)/programs/[programId]/ExecutionResultProblem.tsx +++ b/src/app/(withAuth)/courses/[courseId]/programs/[programId]/ExecutionResultProblem.tsx @@ -3,18 +3,20 @@ import { Box, Button, Flex, HStack, VStack } from '@chakra-ui/react'; import { useRef } from 'react'; -import { SyntaxHighlighter } from '../../../../components/organisms/SyntaxHighlighter'; -import type { TurtleGraphicsHandle } from '../../../../components/organisms/TurtleGraphics'; -import { TurtleGraphics } from '../../../../components/organisms/TurtleGraphics'; -import type { ProblemType } from '../../../../types'; +import { SyntaxHighlighter } from '../../../../../../components/organisms/SyntaxHighlighter'; +import type { TurtleGraphicsHandle } from '../../../../../../components/organisms/TurtleGraphics'; +import { TurtleGraphics } from '../../../../../../components/organisms/TurtleGraphics'; +import type { ProblemType } from '../../../../../../types'; interface ExecutionResultProblemProps { problemProgram: string; selectedLanguageId: string; setStep: (step: ProblemType) => void; + handleComplete: () => void; } export const ExecutionResultProblem: React.FC = ({ + handleComplete, problemProgram, selectedLanguageId, setStep, @@ -31,6 +33,7 @@ export const ExecutionResultProblem: React.FC = ({ // TODO: 一旦アラートで表示 if (isCorrect) { alert('正解です。この問題は終了です'); + handleComplete(); } else { alert('不正解です。チェックポイントごとに回答してください'); setStep('checkpoint'); diff --git a/src/app/(withAuth)/programs/[programId]/StepProblem.tsx b/src/app/(withAuth)/courses/[courseId]/programs/[programId]/StepProblem.tsx similarity index 89% rename from src/app/(withAuth)/programs/[programId]/StepProblem.tsx rename to src/app/(withAuth)/courses/[courseId]/programs/[programId]/StepProblem.tsx index c061135e..00f8a9bd 100644 --- a/src/app/(withAuth)/programs/[programId]/StepProblem.tsx +++ b/src/app/(withAuth)/courses/[courseId]/programs/[programId]/StepProblem.tsx @@ -3,9 +3,9 @@ import { Box, Button, Flex, HStack, VStack } from '@chakra-ui/react'; import { useRef } from 'react'; -import { SyntaxHighlighter } from '../../../../components/organisms/SyntaxHighlighter'; -import type { TurtleGraphicsHandle } from '../../../../components/organisms/TurtleGraphics'; -import { TurtleGraphics } from '../../../../components/organisms/TurtleGraphics'; +import { SyntaxHighlighter } from '../../../../../../components/organisms/SyntaxHighlighter'; +import type { TurtleGraphicsHandle } from '../../../../../../components/organisms/TurtleGraphics'; +import { TurtleGraphics } from '../../../../../../components/organisms/TurtleGraphics'; interface StepProblemProps { beforeCheckPointLine: number; @@ -14,11 +14,13 @@ interface StepProblemProps { selectedLanguageId: string; setBeforeCheckPointLine: (line: number) => void; setCurrentCheckPointLine: (line: number) => void; + handleComplete: () => void; } export const StepProblem: React.FC = ({ beforeCheckPointLine, currentCheckPointLine, + handleComplete, problemProgram, selectedLanguageId, setBeforeCheckPointLine, @@ -39,6 +41,7 @@ export const StepProblem: React.FC = ({ if (currentCheckPointLine === problemProgramLines) { alert('正解です。この問題は終了です'); + handleComplete(); } else { alert('正解です。次の行に進みます'); setBeforeCheckPointLine(currentCheckPointLine); diff --git a/src/app/(withAuth)/programs/[programId]/page.tsx b/src/app/(withAuth)/courses/[courseId]/programs/[programId]/page.tsx similarity index 74% rename from src/app/(withAuth)/programs/[programId]/page.tsx rename to src/app/(withAuth)/courses/[courseId]/programs/[programId]/page.tsx index e26f94f7..488ca42c 100644 --- a/src/app/(withAuth)/programs/[programId]/page.tsx +++ b/src/app/(withAuth)/courses/[courseId]/programs/[programId]/page.tsx @@ -3,16 +3,21 @@ import { Heading, VStack } from '@chakra-ui/react'; import type { NextPage } from 'next'; import { useEffect, useState } from 'react'; +import { useSessionContext } from 'supertokens-auth-react/recipe/session'; -import { generateProgram, programIdToName } from '../../../../problems/problemData'; -import type { ProblemType } from '../../../../types'; -import { getLanguageIdFromSessionStorage } from '../../../lib/SessionStorage'; +import { generateProgram, programIdToName } from '../../../../../../problems/problemData'; +import type { ProblemType } from '../../../../../../types'; +import { getLanguageIdFromSessionStorage } from '../../../../../lib/SessionStorage'; +import { createUserSolvedProblem } from '../../../../../lib/actions'; import { CheckpointProblem } from './CheckpointProblem'; import { ExecutionResultProblem } from './ExecutionResultProblem'; import { StepProblem } from './StepProblem'; -const ProblemPage: NextPage<{ params: { programId: string } }> = ({ params }) => { +const ProblemPage: NextPage<{ params: { courseId: string; programId: string } }> = ({ params }) => { + const session = useSessionContext(); + const userId = session.loading ? '' : session.userId; + const courseId = params.courseId; const programId = params.programId; // TODO: チェックポイントを取得する処理が実装できたら置き換える const checkPointLines = [1, 4]; @@ -31,11 +36,18 @@ const ProblemPage: NextPage<{ params: { programId: string } }> = ({ params }) => setProblemProgram(generateProgram(programId, selectedLanguageId)); }, [programId, selectedLanguageId]); + const handleSolveProblem = async (): Promise => { + if (userId) { + await createUserSolvedProblem(userId, courseId, programId, selectedLanguageId); + } + }; + const ProblemComponent: React.FC = () => { switch (step) { case 'normal': { return ( = ({ params }) => { + try { + await prisma.userSolvedProblem.create({ + data: { + userId, + courseId, + programId, + languageId, + }, + }); + } catch (error) { + console.error(error); + } +} + +export async function fetchUserSolvedProblems( + userId: string, + courseId: string +): Promise> { + try { + const userSolvedProblems = await prisma.userSolvedProblem.findMany({ + where: { + userId, + courseId, + }, + select: { + programId: true, + languageId: true, + }, + }); + return userSolvedProblems; + } catch (error) { + console.error(error); + return []; + } +}