diff --git a/backend/graphql/RequiredForCompletion.ts b/backend/graphql/RequiredForCompletion.ts new file mode 100644 index 000000000..b08c58427 --- /dev/null +++ b/backend/graphql/RequiredForCompletion.ts @@ -0,0 +1,34 @@ +import { enumType, objectType } from "nexus" + +import { RequiredForCompletionEnum } from "../typeDefs" + +function convertTsEnum(toConvert: any) { + const converted: { [key: string]: number } = {} + + Object.keys(toConvert).forEach((key) => { + if (/^[_a-zA-Z][_a-zA-Z0-9]*$/.test(key)) { + converted[key] = toConvert[key] + } + }) + + return converted +} + +export const RequiredForCompletionType = enumType({ + name: "RequiredForCompletionType", + members: convertTsEnum(RequiredForCompletionEnum), +}) + +export const RequiredForCompletion = objectType({ + name: "RequiredForCompletion", + definition(t) { + t.nonNull.field("type", { + type: "RequiredForCompletionType", + }) + t.int("current_amount") + t.int("required_amount") + t.list.field("required_actions", { + type: "ExerciseCompletionRequiredAction", + }) + }, +}) diff --git a/backend/graphql/User/model.ts b/backend/graphql/User/model.ts index 11589156a..048cc0fbd 100644 --- a/backend/graphql/User/model.ts +++ b/backend/graphql/User/model.ts @@ -249,16 +249,28 @@ export const User = objectType({ t.list.field("user_course_summary", { type: "UserCourseSummary", - resolve: async (parent, _, ctx) => { + args: { + include_hidden_exercises: booleanArg({ default: false }), + }, + resolve: async (parent, { include_hidden_exercises }, ctx) => { // not very optimal, as the exercise completions will be queried twice if that field is selected const exerciseCompletionCourses = await ctx.prisma.user .findUnique({ where: { id: parent.id }, }) .exercise_completions({ - select: { + where: { + ...(!include_hidden_exercises + ? { + exercise: { + deleted: { not: true }, + }, + } + : {}), + }, + include: { exercise: { - select: { + include: { course: { select: { id: true, diff --git a/backend/graphql/UserCourseSummary.ts b/backend/graphql/UserCourseSummary.ts index 5c03b983b..cd6e9858d 100644 --- a/backend/graphql/UserCourseSummary.ts +++ b/backend/graphql/UserCourseSummary.ts @@ -1,6 +1,10 @@ import { UserInputError } from "apollo-server-express" +import { uniqBy } from "lodash" import { objectType } from "nexus" +import { RequiredForCompletionEnum } from "../typeDefs" +import { RequiredForCompletion } from "./RequiredForCompletion" + export const UserCourseSummary = objectType({ name: "UserCourseSummary", definition(t) { @@ -9,6 +13,101 @@ export const UserCourseSummary = objectType({ t.id("inherit_settings_from_id") t.id("completions_handled_by_id") + t.nonNull.list.nonNull.field("required_for_completion", { + type: RequiredForCompletion, + resolve: async ({ course_id, user_id }, _, ctx) => { + if (!course_id || !user_id) { + throw new UserInputError("must provide course_id and user_id") + } + + const completion = await ctx.prisma.completion.findMany({ + where: { + user_id, + course_id, + }, + }) + + if (completion.length) { + return [] + } + + const result = [] + + const { points_needed, exercise_completions_needed } = + (await ctx.prisma.course.findUnique({ + where: { + id: course_id, + }, + })) ?? {} + + const { user_course_progresses, exercise_completions } = + (await ctx.prisma.user.findUnique({ + where: { + id: user_id, + }, + include: { + user_course_progresses: { + where: { + course_id, + }, + select: { + n_points: true, + }, + orderBy: { + created_at: "asc", + }, + take: 1, + }, + exercise_completions: { + where: { + exercise: { + course_id, + deleted: { not: true }, + }, + }, + include: { + exercise_completion_required_actions: true, + }, + }, + }, + })) ?? {} + + const n_points = user_course_progresses?.[0]?.n_points ?? 0 + const uniqueExerciseCompletions = uniqBy(exercise_completions, "id") + const requiredActions = uniqueExerciseCompletions.flatMap( + (ec) => ec.exercise_completion_required_actions, + ) + + if (points_needed && n_points < points_needed) { + result.push({ + type: RequiredForCompletionEnum.NOT_ENOUGH_POINTS, + current_amount: n_points, + needed_amount: points_needed, + }) + } + + if ( + exercise_completions_needed && + uniqueExerciseCompletions.length < exercise_completions_needed + ) { + result.push({ + type: RequiredForCompletionEnum.NOT_ENOUGH_EXERCISE_COMPLETIONS, + current_amount: uniqueExerciseCompletions.length, + required_amount: exercise_completions_needed, + }) + } + + if (requiredActions.length) { + result.push({ + type: RequiredForCompletionEnum.REQUIRED_ACTIONS, + required_actions: requiredActions, + }) + } + + return result + }, + }) + t.field("course", { type: "Course", resolve: async ({ course_id }, _, ctx) => { diff --git a/backend/graphql/index.ts b/backend/graphql/index.ts index 000ff1567..3de36f9f5 100644 --- a/backend/graphql/index.ts +++ b/backend/graphql/index.ts @@ -23,6 +23,7 @@ export * from "./Organization" export * from "./OrganizationTranslation" export * from "./PointsByGroup" export * from "./Progress" +export * from "./RequiredForCompletion" export * from "./Service" export * from "./StoredData" export * from "./StudyModule" diff --git a/backend/schema.ts b/backend/schema.ts index 37cc1f533..fe5ae0bec 100644 --- a/backend/schema.ts +++ b/backend/schema.ts @@ -68,6 +68,10 @@ export default makeSchema({ module: require.resolve(".prisma/client/index.d.ts"), alias: "prisma", }, + { + module: path.join(__dirname, "typeDefs.ts"), + alias: "t", + }, ], }, plugins: createPlugins(), @@ -80,4 +84,5 @@ export default makeSchema({ }, shouldGenerateArtifacts: true, shouldExitAfterGenerateArtifacts: Boolean(NEXUS_REFLECTION), + prettierConfig: require.resolve("../.prettierrc.yaml"), }) diff --git a/backend/typeDefs.ts b/backend/typeDefs.ts new file mode 100644 index 000000000..f294fd2d0 --- /dev/null +++ b/backend/typeDefs.ts @@ -0,0 +1,17 @@ +import { ExerciseCompletionRequiredAction } from "@prisma/client" + +export enum RequiredForCompletionEnum { + NOT_ENOUGH_POINTS = "NOT_ENOUGH_POINTS", + NOT_ENOUGH_EXERCISE_COMPLETIONS = "NOT_ENOUGH_EXERCISE_COMPLETIONS", + REQUIRED_ACTIONS = "REQUIRED_ACTIONS", +} + +export type RequiredForCompletion = { + type: + | RequiredForCompletionEnum.NOT_ENOUGH_POINTS + | RequiredForCompletionEnum.NOT_ENOUGH_EXERCISE_COMPLETIONS + | RequiredForCompletionEnum.REQUIRED_ACTIONS + current_amount?: number + required_amount?: number + required_actions?: ExerciseCompletionRequiredAction[] +} diff --git a/frontend/components/Dashboard/PointsListItemCard.tsx b/frontend/components/Dashboard/PointsListItemCard.tsx index ed20ee905..ad73bad33 100644 --- a/frontend/components/Dashboard/PointsListItemCard.tsx +++ b/frontend/components/Dashboard/PointsListItemCard.tsx @@ -1,19 +1,22 @@ import { useState } from "react" -import { Grid } from "@mui/material" -import PointsItemTable from "./PointsItemTable" -import styled from "@emotion/styled" -import { gql } from "@apollo/client" -import formatPointsData, { - formattedGroupPointsDictionary, -} from "/util/formatPointsData" -import { CardTitle, CardSubtitle } from "/components/Text/headers" + import { FormSubmitButton } from "/components/Buttons/FormSubmitButton" import PointsProgress from "/components/Dashboard/PointsProgress" +import { CardSubtitle, CardTitle } from "/components/Text/headers" import { ProgressUserCourseProgressFragment } from "/graphql/fragments/userCourseProgress" import { ProgressUserCourseServiceProgressFragment } from "/graphql/fragments/userCourseServiceProgress" import { UserCourseProgressFragment } from "/static/types/generated/UserCourseProgressFragment" import { UserCourseServiceProgressFragment } from "/static/types/generated/UserCourseServiceProgressFragment" import { UserPoints_currentUser_progresses_course } from "/static/types/generated/UserPoints" +import formatPointsData, { + formattedGroupPointsDictionary, +} from "/util/formatPointsData" + +import { gql } from "@apollo/client" +import styled from "@emotion/styled" +import { Grid } from "@mui/material" + +import PointsItemTable from "./PointsItemTable" const UserFragment = gql` fragment UserPointsFragment on User { @@ -104,11 +107,11 @@ function PointsListItemCard(props: Props) { {showProgress ? ( <>
diff --git a/frontend/components/Dashboard/PointsProgress.tsx b/frontend/components/Dashboard/PointsProgress.tsx index ca44c7b07..7f957d684 100644 --- a/frontend/components/Dashboard/PointsProgress.tsx +++ b/frontend/components/Dashboard/PointsProgress.tsx @@ -1,6 +1,8 @@ +import { CardSubtitle } from "/components/Text/headers" +import notEmpty from "/util/notEmpty" + import styled from "@emotion/styled" import { LinearProgress } from "@mui/material" -import { CardSubtitle } from "/components/Text/headers" const ColoredProgressBar = styled(({ ...props }) => ( @@ -24,7 +26,18 @@ const ChartContainer = styled.div` margin-bottom: 1rem; ` -const PointsProgress = ({ total, title }: { total: number; title: string }) => ( +interface PointsProgressProps { + amount?: number | null + required?: number | null + percentage: number + title: string +} +const PointsProgress = ({ + amount, + required, + percentage, + title, +}: PointsProgressProps) => ( <> ( {title} - {total.toFixed(0)}% + {percentage.toFixed(0)}% + {notEmpty(amount) && notEmpty(required) && required > 0 ? ( + + {amount} out of {required} required + + ) : null} ) diff --git a/frontend/components/Dashboard/Users/Summary/CourseEntry.tsx b/frontend/components/Dashboard/Users/Summary/CourseEntry.tsx index 9b0e87d2b..5a96868d6 100644 --- a/frontend/components/Dashboard/Users/Summary/CourseEntry.tsx +++ b/frontend/components/Dashboard/Users/Summary/CourseEntry.tsx @@ -101,6 +101,7 @@ function CourseEntry({ data }: CourseEntryProps) { userCourseServiceProgresses={data.user_course_service_progresses?.filter( notEmpty, )} + exerciseCompletions={data.exercise_completions?.filter(notEmpty)} /> @@ -45,7 +56,9 @@ export default function ProgressEntry({ {t("progress")} (UserOverViewQuery) + console.log("data", data) useBreadcrumbs([ { translation: "profile", diff --git a/frontend/pages/users/[id]/summary.tsx b/frontend/pages/users/[id]/summary.tsx index 08f39ca39..e635df246 100644 --- a/frontend/pages/users/[id]/summary.tsx +++ b/frontend/pages/users/[id]/summary.tsx @@ -32,6 +32,8 @@ const UserSummaryQuery = gql` name slug has_certificate + exercise_completions_needed + points_needed photo { id uncompressed @@ -79,6 +81,16 @@ const UserSummaryQuery = gql` email ...CompletionsRegisteredFragment } + required_for_completion { + type + current_amount + required_amount + required_actions { + id + exercise_completion_id + value + } + } } } } @@ -134,6 +146,7 @@ function UserSummaryView() { ) } + console.log("data", data) return (