diff --git a/.circleci/config.yml b/.circleci/config.yml index 5f0a1f080..66755e637 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -171,7 +171,7 @@ jobs: root: ~/project paths: - build - - backend/sourcemap + - backend/sourcemp push_backend: executor: backend_build diff --git a/backend/bin/kafkaConsumer/common/createKafkaConsumer.ts b/backend/bin/kafkaConsumer/common/createKafkaConsumer.ts index 3ea336ad1..29639376b 100644 --- a/backend/bin/kafkaConsumer/common/createKafkaConsumer.ts +++ b/backend/bin/kafkaConsumer/common/createKafkaConsumer.ts @@ -1,3 +1,4 @@ +import checkConnectionInInterval from "/bin/kafkaConsumer/common/connectedChecker" import * as Kafka from "node-rdkafka" import { ConsumerGlobalConfig } from "node-rdkafka" import { v4 } from "uuid" @@ -5,6 +6,7 @@ import winston from "winston" import type { PrismaClient } from "@prisma/client" +import { KafkaError } from "../../../bin/lib/errors" import { KAFKA_CONSUMER_GROUP, KAFKA_DEBUG_CONTEXTS, @@ -12,8 +14,6 @@ import { KAFKA_TOP_OF_THE_QUEUE, } from "../../../config" import { attachPrismaEvents } from "../../../util/prismaLogger" -import { KafkaError } from "../../lib/errors" -import checkConnectionInInterval from "./connectedChecker" const logCommit = (logger: winston.Logger) => (err: any, topicPartitions: any) => { diff --git a/backend/bin/seedExercises.ts b/backend/bin/seedExercises.ts index ea8c69c17..80ddf85fa 100644 --- a/backend/bin/seedExercises.ts +++ b/backend/bin/seedExercises.ts @@ -1,6 +1,8 @@ import * as faker from "faker" -import { Exercise } from "@prisma/client" import { sample } from "lodash" + +import { Exercise } from "@prisma/client" + import prisma from "../prisma" const createExercise = () => ({ @@ -9,7 +11,7 @@ const createExercise = () => ({ part: Math.round(Math.random() * 3 + 1), section: Math.round(Math.random() * 3 + 1), timestamp: new Date(), - custom_id: faker.random.uuid(), + custom_id: faker.datatype.uuid(), }) const createExerciseCompletion = (exercise: Exercise) => ({ diff --git a/backend/graphql/Completion/mutations.ts b/backend/graphql/Completion/mutations.ts index 665afa503..90a36e465 100644 --- a/backend/graphql/Completion/mutations.ts +++ b/backend/graphql/Completion/mutations.ts @@ -243,11 +243,9 @@ export const CompletionMutations = extendType({ } catch (e: unknown) { const message = e instanceof Error ? `${e.message}, ${e.stack}` : e ctx.logger.error(`error processing after ${processed}: ${message}`) - return `error processing after ${processed}: ${message}` } } - return `${users.length} users rechecked` }, }) diff --git a/backend/graphql/Course/__test__/Course.test.ts b/backend/graphql/Course/__test__/Course.test.ts index b9e83b270..c970a4790 100644 --- a/backend/graphql/Course/__test__/Course.test.ts +++ b/backend/graphql/Course/__test__/Course.test.ts @@ -1,7 +1,7 @@ import { createReadStream } from "fs" import { gql } from "graphql-request" -import { omit, orderBy } from "lodash" import { mocked } from "jest-mock" +import { omit, orderBy } from "lodash" import { Course } from "@prisma/client" diff --git a/backend/graphql/Course/model.ts b/backend/graphql/Course/model.ts index ec78fe326..60bc1d26a 100644 --- a/backend/graphql/Course/model.ts +++ b/backend/graphql/Course/model.ts @@ -110,5 +110,11 @@ export const Course = objectType({ }) }, }) + + t.field("course_statistics", { + type: "CourseStatistics", + authorize: isAdmin, + resolve: ({ id }) => ({ course_id: id }), + }) }, }) diff --git a/backend/graphql/CourseStatistics.ts b/backend/graphql/CourseStatistics.ts new file mode 100644 index 000000000..dc77c0208 --- /dev/null +++ b/backend/graphql/CourseStatistics.ts @@ -0,0 +1,440 @@ +import { arg, enumType, intArg, nonNull, objectType } from "nexus" + +import { Sql } from "@prisma/client/runtime" + +import { Context } from "../context" +import { redisify } from "../services/redis" + +export const CourseStatisticsValue = objectType({ + name: "CourseStatisticsValue", + definition(t) { + t.int("value") + t.nullable.field("date", { type: "DateTime" }) + t.nullable.int("unit_value") + }, +}) + +export const CourseStatisticsEntry = objectType({ + name: "CourseStatisticsEntry", + definition(t) { + t.field("updated_at", { type: "DateTime" }) + t.nullable.field("unit", { type: "TimeGroupUnit" }) + t.nonNull.list.nonNull.field("data", { type: "CourseStatisticsValue" }) + }, +}) + +export const IntervalUnit = enumType({ + name: "IntervalUnit", + members: ["day", "month", "week", "year"], +}) + +type IntervalUnit = "day" | "month" | "week" | "year" + +export const TimeGroupUnit = enumType({ + name: "TimeGroupUnit", + members: [ + "century", + "day", + "decade", + "dow", + "doy", + "epoch", + "hour", + "isodow", + "isoyear", + "microseconds", + "millennium", + "milliseconds", + "minute", + "month", + "quarter", + "second", + "timezone", + "timezone_hour", + "timezone_minute", + "week", + "year", + ], +}) + +type TimeGroupUnit = + | "century" + | "day" + | "decade" + | "dow" + | "doy" + | "epoch" + | "hour" + | "isodow" + | "isoyear" + | "microseconds" + | "millennium" + | "milliseconds" + | "minute" + | "month" + | "quarter" + | "second" + | "timezone" + | "timezone_hour" + | "timezone_minute" + | "week" + | "year" + +interface CourseStatisticsValue { + value?: number + date?: number + unit?: TimeGroupUnit + unitValue?: number +} + +interface CreateStatisticQuery { + path: string + ctx: Context + query: string | TemplateStringsArray | Sql + values?: any[] + expireTime?: number + unit?: TimeGroupUnit + keyFn?: (...values: any[]) => string + disableCaching?: boolean +} + +/*const permittedUnits = "day|week|month|year" +const intervalRegex = new RegExp( + `^(\\d\\s+(${permittedUnits})$|(\\d{2,}\\s+(${permittedUnits})s$))`, + "i", +) +const validateInterval = (interval: string | null) => { + if (!interval || !intervalRegex.test(interval.trim())) { + throw new UserInputError(`invalid interval: ${interval}`) + } +}*/ + +const getIntervalString = (number: number, unit: IntervalUnit) => { + return `${number} ${unit}${number > 1 ? "s" : ""}` +} + +const createStatisticsQuery = async ({ + path, + ctx, + query, + values = [], + expireTime = 1, + unit, + keyFn = (..._values) => _values.map((v) => v.toString()).join("-"), + disableCaching, +}: CreateStatisticQuery): Promise<{ + data: any[] + updated_at: number +}> => { + const queryFn = async () => { + const data = (await ctx.prisma.$queryRaw(query, ...values)) ?? [] + + return { + updated_at: Date.now(), + unit, + data, + } + } + + if (disableCaching) return await queryFn() + + return await redisify(queryFn, { + prefix: `coursestatistics_${path}`, + expireTime, + key: keyFn(values), + }) +} + +export const CourseStatistics = objectType({ + name: "CourseStatistics", + definition(t) { + t.id("course_id") + /*, { + resolve: ({ course_id }) => course_id + })*/ + t.field("started", { + type: "CourseStatisticsEntry", + resolve: async ({ course_id }, __, ctx) => { + return createStatisticsQuery({ + path: "started", + ctx, + query: ` + SELECT COUNT(DISTINCT user_id) as value, now() as date + FROM user_course_setting + WHERE course_id = $1; + `, + values: [course_id], + disableCaching: true, + }) + }, + }) + + t.field("started_by_unit", { + type: "CourseStatisticsEntry", + args: { + unit: nonNull(arg({ type: "TimeGroupUnit", default: "dow" })), + }, + resolve: async ({ course_id }, { unit }, ctx) => { + return createStatisticsQuery({ + path: "started_by_unit", + ctx, + query: ` + select floor(extract(${unit} from created_at)) as unit_value, count(distinct ucs.user_id) as value + from user_course_setting ucs + where course_id = $1 + group by floor(extract(${unit} from created_at)); + `, + values: [course_id], + unit, + disableCaching: true, + }) + }, + }) + + t.field("started_by_interval", { + type: "CourseStatisticsEntry", + args: { + number: nonNull(intArg({ default: 1 })), + unit: nonNull(arg({ type: "IntervalUnit", default: "day" })), + }, + resolve: async ({ course_id }, { number, unit }, ctx) => { + const interval = getIntervalString(number, unit) + + return createStatisticsQuery({ + path: "started_by_interval", + ctx, + query: ` + select distinct date, count(distinct ucs.user_id) as value + from ( + select generate_series(min(date_trunc('${unit}', created_at)), now(), '${interval}') as date + from user_course_setting + where course_id = $1 + ) series + left join + user_course_setting ucs + on date_trunc('${unit}', created_at) > series.date - interval '${interval}' + and date_trunc('${unit}', created_at) <= series.date + and course_id = $1 + group by date + order by date + `, + values: [course_id], + keyFn: (...values) => `${values[0]}-${interval.replaceAll(" ", "-")}`, + disableCaching: true, + }) + }, + }) + + t.field("started_cumulative", { + type: "CourseStatisticsEntry", + args: { + number: nonNull(intArg({ default: 1 })), + unit: nonNull(arg({ type: "IntervalUnit", default: "day" })), + }, + resolve: async ({ course_id }, { number, unit }, ctx) => { + const interval = getIntervalString(number, unit) + + return createStatisticsQuery({ + path: "started_cumulative", + ctx, + query: ` + select distinct date, count(distinct ucs.user_id) as value + from ( + select generate_series(min(date_trunc('${unit}', created_at)), now(), '${interval}') as date + from user_course_setting + where course_id = $1 + union + select date_trunc('${unit}', now()) as date + ) series + left join + user_course_setting ucs + on date_trunc('${unit}', ucs.created_at) <= series.date + where course_id = $1 + group by date + order by date; + `, + values: [course_id], + keyFn: (...values) => `${values[0]}-${interval.replaceAll(" ", "-")}`, + disableCaching: true, + }) + }, + }) + + t.field("completed", { + type: "CourseStatisticsEntry", + resolve: async ({ course_id }, __, ctx) => { + return createStatisticsQuery({ + path: "completed", + ctx, + query: ` + SELECT COUNT(DISTINCT user_id) as value, now() as date + FROM completion + WHERE course_id = $1; + `, + values: [course_id], + disableCaching: true, + }) + }, + }) + + t.field("completed_by_interval", { + type: "CourseStatisticsEntry", + args: { + number: nonNull(intArg({ default: 1 })), + unit: nonNull(arg({ type: "IntervalUnit", default: "day" })), + }, + resolve: async ({ course_id }, { number, unit }, ctx) => { + const interval = getIntervalString(number, unit) + + return createStatisticsQuery({ + path: "completed_by_interval", + ctx, + query: ` + select distinct date, count(distinct co.user_id) as value + from ( + select generate_series(min(date_trunc('${unit}', created_at)), now(), '${interval}') as date + from completion + where course_id = $1 + ) series + left join + completion co + on date_trunc('${unit}', created_at) > series.date - interval '${interval}' + and date_trunc('${unit}', created_at) <= series.date + and course_id = $1 + group by date + order by date + `, + values: [course_id], + disableCaching: true, + }) + }, + }) + + t.field("completed_cumulative", { + type: "CourseStatisticsEntry", + args: { + number: nonNull(intArg({ default: 1 })), + unit: nonNull(arg({ type: "IntervalUnit", default: "day" })), + }, + resolve: async ({ course_id }, { number, unit }, ctx) => { + const interval = getIntervalString(number, unit) + + return createStatisticsQuery({ + path: "completed_cumulative", + ctx, + query: ` + select distinct date, count(distinct co.user_id) as value + from ( + select generate_series(min(date_trunc('${unit}', created_at)), now(), '${interval}') as date + from completion + where course_id = $1 + union + select date_trunc('${unit}', now()) as date + ) series + left join + completion co + on date_trunc('${unit}', co.created_at) <= series.date + where course_id = $1 + group by date + order by date; + `, + values: [course_id], + disableCaching: true, + }) + }, + }) + + t.field("at_least_one_exercise", { + type: "CourseStatisticsEntry", + resolve: async ({ course_id }, __, ctx) => { + return createStatisticsQuery({ + path: "at_least_one_exercise", + ctx, + query: ` + SELECT COUNT(DISTINCT user_id) as value, now() as date + FROM exercise_completion ec + LEFT JOIN exercise e ON ec.exercise_id = e.id + WHERE course_id = $1; + `, + values: [course_id], + disableCaching: true, + }) + }, + }) + + t.field("at_least_one_exercise_by_interval", { + type: "CourseStatisticsEntry", + args: { + number: nonNull(intArg({ default: 1 })), + unit: nonNull(arg({ type: "IntervalUnit", default: "day" })), + }, + resolve: async ({ course_id }, { number, unit }, ctx) => { + const interval = getIntervalString(number, unit) + + return createStatisticsQuery({ + path: "at_least_one_exercise_by_interval", + ctx, + query: ` + select distinct date, count(distinct ec.user_id) as value + from ( + select generate_series(min(date_trunc('${unit}', ec2.created_at)), now(), '${interval}') as date + from exercise_completion ec2 + left join exercise e2 on ec2.exercise_id = e2.id + where course_id = $1 + ) series + left join + exercise_completion ec + on date_trunc('${unit}', ec.created_at) > series.date - interval '${interval}' + and date_trunc('${unit}', ec.created_at) <= series.date + left join + exercise e + on ec.exercise_id = e.id + and e.course_id = $1 + group by date + order by date; + `, + values: [course_id], + disableCaching: true, + }) + }, + }) + + t.field("at_least_one_exercise_cumulative", { + type: "CourseStatisticsEntry", + args: { + number: nonNull(intArg({ default: 1 })), + unit: nonNull(arg({ type: "IntervalUnit", default: "day" })), + }, + resolve: async ({ course_id }, { number, unit }, ctx) => { + const interval = getIntervalString(number, unit) + + return createStatisticsQuery({ + path: "at_least_one_exercise_cumulative", + ctx, + query: ` + select distinct date, count(distinct ec.user_id) as value + from ( + select generate_series(min(date_trunc('${unit}', ec2.created_at)), now(), '${interval}') as date + from exercise_completion ec2 + join exercise e2 on ec2.exercise_id = e2.id + where course_id = $1 + union + select date_trunc('${unit}', now()) as date + ) series + left join + exercise_completion ec + on date_trunc('${unit}', ec.created_at) <= series.date + left join + exercise e + on ec.exercise_id = e.id + where e.course_id = $1 + group by date + order by date; + `, + values: [course_id], + disableCaching: true, + }) + }, + }) + }, +}) diff --git a/backend/graphql/Progress.ts b/backend/graphql/Progress.ts index 4d34f8a50..c1702a034 100644 --- a/backend/graphql/Progress.ts +++ b/backend/graphql/Progress.ts @@ -8,9 +8,9 @@ export const Progress = objectType({ t.nullable.field("user_course_progress", { type: "UserCourseProgress", - resolve: async (parent, _, ctx) => { - const course_id = parent.course?.id - const user_id = parent.user?.id + resolve: async ({ course, user }, _, ctx) => { + const course_id = course?.id + const user_id = user?.id const progresses = await ctx.prisma.course .findUnique({ @@ -24,6 +24,7 @@ export const Progress = objectType({ return progresses?.[0] ?? null }, }) + t.list.field("user_course_service_progresses", { type: "UserCourseServiceProgress", resolve: async (parent, _, ctx) => { diff --git a/backend/graphql/User/queries.ts b/backend/graphql/User/queries.ts index 6b5905451..a64ff7529 100644 --- a/backend/graphql/User/queries.ts +++ b/backend/graphql/User/queries.ts @@ -1,4 +1,4 @@ -import { ForbiddenError, UserInputError } from "apollo-server-express" +import { ForbiddenError, UserInputError } from "apollo-server-core" import { extendType, idArg, intArg, stringArg } from "nexus" import { isAdmin } from "../../accessControl" diff --git a/backend/graphql/UserCourseSetting.ts b/backend/graphql/UserCourseSetting.ts index 654e78dfd..7e8e2dcea 100644 --- a/backend/graphql/UserCourseSetting.ts +++ b/backend/graphql/UserCourseSetting.ts @@ -1,4 +1,4 @@ -import { ForbiddenError, UserInputError } from "apollo-server-express" +import { ForbiddenError, UserInputError } from "apollo-server-core" import { extendType, idArg, diff --git a/backend/graphql/UserCourseSummary.ts b/backend/graphql/UserCourseSummary.ts index 5c03b983b..a0fde7ef8 100644 --- a/backend/graphql/UserCourseSummary.ts +++ b/backend/graphql/UserCourseSummary.ts @@ -21,6 +21,7 @@ export const UserCourseSummary = objectType({ }) }, }) + t.field("completion", { type: "Completion", resolve: async ( @@ -31,6 +32,7 @@ export const UserCourseSummary = objectType({ if (!user_id || !course_id) { throw new UserInputError("need to specify user_id and course_id") } + const completions = await ctx.prisma.course .findUnique({ where: { id: completions_handled_by_id ?? course_id }, @@ -46,6 +48,7 @@ export const UserCourseSummary = objectType({ return completions?.[0] }, }) + t.field("user_course_progress", { type: "UserCourseProgress", resolve: async ({ user_id, course_id }, _, ctx) => { diff --git a/backend/graphql/index.ts b/backend/graphql/index.ts index 000ff1567..6cc8bb112 100644 --- a/backend/graphql/index.ts +++ b/backend/graphql/index.ts @@ -9,6 +9,7 @@ export * from "./CourseAlias" export * from "./CourseOrganization" export * from "./CourseOwnership" export * from "./CourseStatsSubscription" +export * from "./CourseStatistics" export * from "./CourseTranslation" export * from "./CourseVariant" export * from "./EmailDelivery" diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index a54d9f3c9..a626b6fc0 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -560,6 +560,42 @@ model CourseStatsSubscription { @@unique([user_id, email_template_id]) @@map("course_stats_subscription") } +// model Report { +// id String @id @default(uuid()) +// created_at DateTime? @default(now()) +// updated_at DateTime? @updatedAt +// name String? +// user_id String +// user User @relation(fields: [user_id], references: [id]) +// result Json +// params Json +// report_job ReportJob? +// +// @@map("report") +// } + +// - user selects what they want to query, params and all +// - report job is created with the params +// - job runs the queries in sequence and stores the results in report +// - when it's finished, it's finished -- some mechanism to inform user +// - old reports from user show up in the frontend, with the option to refresh them +// ie. create new job with same parameters +// - frontend just visualizes the report data then + +// model ReportJob { +// id String @id @default(uuid()) +// created_at DateTime? @default(now()) +// updated_at DateTime? @updatedAt +// name String? +// user_id String +// user User @relation(fields: [user_id], references: [id]) +// params Json +// report_id String? +// report Report? @relation(fields: [report_id], references: [id]) +// finished Boolean +// +// @@map("report_job") +// } enum CourseStatus { Active diff --git a/backend/schema.ts b/backend/schema.ts index 37cc1f533..91792058b 100644 --- a/backend/schema.ts +++ b/backend/schema.ts @@ -2,6 +2,7 @@ import { DateTimeResolver, JSONObjectResolver } from "graphql-scalars" import { GraphQLScalarType } from "graphql/type" import { connectionPlugin, fieldAuthorizePlugin, makeSchema } from "nexus" import { nexusPrisma } from "nexus-plugin-prisma" +// import { GraphQLScalarType } from "graphql/type" import * as path from "path" import { join } from "path" @@ -38,6 +39,7 @@ const createPlugins = () => { }), }, }), + connectionPlugin({ nexusFieldName: "connection", includeNodesField: true, diff --git a/backend/tests/__helpers.ts b/backend/tests/__helpers.ts index 1b8b6a823..076d9634b 100644 --- a/backend/tests/__helpers.ts +++ b/backend/tests/__helpers.ts @@ -115,6 +115,7 @@ function createTestContext(testContext: TestContext) { serverInstance = app.listen(port).on("error", (err) => { throw err }) + DEBUG && console.log(`got port ${port}`) return { @@ -243,7 +244,9 @@ export function fakeTMCSpecific(users: Record) { .get(`/api/v8/users/${user_id}?show_user_fields=1&extra_fields=1`) .reply(function () { if (!Array.isArray(reply)) { - throw new Error(`Invalid fakeTMCSpecific entry ${reply}`) + throw new Error( + `Invalid fakeTMCSpecific entry ${reply} - provide an array`, + ) } return reply }) diff --git a/backend/tests/data/index.ts b/backend/tests/data/index.ts index 74ffc2ded..1b5b7573d 100644 --- a/backend/tests/data/index.ts +++ b/backend/tests/data/index.ts @@ -558,49 +558,6 @@ export const userCourseProgresses: Prisma.UserCourseProgressCreateInput[] = [ }, ] -export const userCourseServiceProgresses: Prisma.UserCourseServiceProgressCreateInput[] = - [ - { - course: { connect: { id: "00000000000000000000000000000002" } }, - user: { connect: { id: "20000000000000000000000000000104" } }, - service: { - connect: { id: "40000000-0000-0000-0000-000000000102" }, - }, - progress: [{ group: "week1", max_points: 3, n_points: 3 }], - }, - ] - -export const emailTemplateThresholds: Prisma.EmailTemplateCreateInput[] = [ - { - id: "00000000000000000000000000000012", - template_type: "threshold", - txt_body: "Awesome feature", - created_at: "1901-01-01T10:00:00.00+02:00", - updated_at: "1901-01-01T10:00:00.00+02:00", - title: "Win", - points_threshold: 2, - name: "value", - triggered_automatically_by_course: { - connect: { id: "00000000000000000000000000000667" }, - }, - exercise_completions_threshold: 2, - }, - { - id: "00000000000000000000000000000013", - template_type: "threshold", - txt_body: "Another", - created_at: "1901-01-01T10:00:00.00+02:00", - updated_at: "1901-01-01T10:00:00.00+02:00", - title: "Win", - points_threshold: 60, - name: "value", - triggered_automatically_by_course: { - connect: { id: "00000000000000000000000000000667" }, - }, - exercise_completions_threshold: 100, - }, -] - export const completionsRegistered: Prisma.CompletionRegisteredCreateInput[] = [ { id: "66000000-0000-0000-0000-000000000102", @@ -613,24 +570,6 @@ export const completionsRegistered: Prisma.CompletionRegisteredCreateInput[] = [ }, ] -export const courseAliases: Prisma.CourseAliasCreateInput[] = [ - { - id: "67000000-0000-0000-0000-000000000001", - course: { connect: { id: "00000000000000000000000000000002" } }, - course_code: "alias", - }, - { - id: "67000000-0000-0000-0000-000000000002", - course: { connect: { id: "00000000000000000000000000000001" } }, - course_code: "alias2", - }, - { - id: "67000000-0000-0000-0000-000000000003", - course: { connect: { id: "00000000000000000000000000000666" } }, - course_code: "alias3", - }, -] - export const openUniversityRegistrationLink: Prisma.OpenUniversityRegistrationLinkCreateInput[] = [ { @@ -678,6 +617,67 @@ export const openUniversityRegistrationLink: Prisma.OpenUniversityRegistrationLi }, ] +export const courseAliases: Prisma.CourseAliasCreateInput[] = [ + { + id: "67000000-0000-0000-0000-000000000001", + course: { connect: { id: "00000000000000000000000000000002" } }, + course_code: "alias", + }, + { + id: "67000000-0000-0000-0000-000000000002", + course: { connect: { id: "00000000000000000000000000000001" } }, + course_code: "alias2", + }, + { + id: "67000000-0000-0000-0000-000000000003", + course: { connect: { id: "00000000000000000000000000000666" } }, + course_code: "alias3", + }, +] + +export const userCourseServiceProgresses: Prisma.UserCourseServiceProgressCreateInput[] = + [ + { + course: { connect: { id: "00000000000000000000000000000002" } }, + user: { connect: { id: "20000000000000000000000000000104" } }, + service: { + connect: { id: "40000000-0000-0000-0000-000000000102" }, + }, + progress: [{ group: "week1", max_points: 3, n_points: 3 }], + }, + ] + +export const emailTemplateThresholds: Prisma.EmailTemplateCreateInput[] = [ + { + id: "00000000000000000000000000000012", + template_type: "threshold", + txt_body: "Awesome feature", + created_at: "1901-01-01T10:00:00.00+02:00", + updated_at: "1901-01-01T10:00:00.00+02:00", + title: "Win", + points_threshold: 2, + name: "value", + triggered_automatically_by_course: { + connect: { id: "00000000000000000000000000000667" }, + }, + exercise_completions_threshold: 2, + }, + { + id: "00000000000000000000000000000013", + template_type: "threshold", + txt_body: "Another", + created_at: "1901-01-01T10:00:00.00+02:00", + updated_at: "1901-01-01T10:00:00.00+02:00", + title: "Win", + points_threshold: 60, + name: "value", + triggered_automatically_by_course: { + connect: { id: "00000000000000000000000000000667" }, + }, + exercise_completions_threshold: 100, + }, +] + export const storedData: Prisma.StoredDataCreateInput[] = [ { // user1, course2 diff --git a/backend/tests/data/seed.ts b/backend/tests/data/seed.ts index d2edc8be5..24e81268d 100644 --- a/backend/tests/data/seed.ts +++ b/backend/tests/data/seed.ts @@ -48,8 +48,6 @@ export const seed = async (prisma: PrismaClient) => { "userCourseSetting", userCourseSettings, ) - const seededAbStudies = await create("abStudy", abStudies) - const seededAbEnrollments = await create("abEnrollment", abEnrollments) const seededExercises = await create("exercise", exercises) const seededExerciseCompletions = await create( "exerciseCompletion", @@ -67,6 +65,8 @@ export const seed = async (prisma: PrismaClient) => { "emailTemplate", emailTemplateThresholds, ) + const seededAbStudies = await create("abStudy", abStudies) + const seededAbEnrollments = await create("abEnrollment", abEnrollments) const seededCompletionsRegistered = await create( "completionRegistered", completionsRegistered, diff --git a/backend/util/server-functions.ts b/backend/util/server-functions.ts index 1fe5ef534..b3459cb6c 100644 --- a/backend/util/server-functions.ts +++ b/backend/util/server-functions.ts @@ -13,6 +13,24 @@ interface GetUserReturn { details: UserInfo } +export function requireAdmin(knex: Knex) { + return async function ( + req: Request, + res: Response, + ): Promise | boolean> { + const getUserResult = await getUser(knex)(req, res) + + if (getUserResult.isOk() && getUserResult.value.details.administrator) { + return true + } + if (getUserResult.isErr()) { + return getUserResult.error + } + + return res.status(401).json({ message: "unauthorized" }) + } +} + export function requireCourseOwnership({ course_id, knex, @@ -50,24 +68,6 @@ export function requireCourseOwnership({ } } -export function requireAdmin(knex: Knex) { - return async function ( - req: Request, - res: Response, - ): Promise | boolean> { - const getUserResult = await getUser(knex)(req, res) - - if (getUserResult.isOk() && getUserResult.value.details.administrator) { - return true - } - if (getUserResult.isErr()) { - return getUserResult.error - } - - return res.status(401).json({ message: "unauthorized" }) - } -} - export function getUser(knex: Knex) { return async function ( req: any, diff --git a/frontend/components/Breadcrumbs.tsx b/frontend/components/Breadcrumbs.tsx index ef12f31d8..60b560684 100644 --- a/frontend/components/Breadcrumbs.tsx +++ b/frontend/components/Breadcrumbs.tsx @@ -1,12 +1,13 @@ -import { Skeleton } from "@mui/material" import LangLink from "/components/LangLink" -import styled from "@emotion/styled" import { Breadcrumb, useBreadcrumbContext } from "/contexts/BreadcrumbContext" -import { useTranslator } from "/util/useTranslator" -import BreadcrumbsTranslations from "/translations/breadcrumbs" import { isTranslationKey } from "/translations" +import BreadcrumbsTranslations from "/translations/breadcrumbs" +import { useTranslator } from "/util/useTranslator" import { memoize } from "lodash" +import styled from "@emotion/styled" +import { Skeleton } from "@mui/material" + const BreadcrumbList = styled.ul` list-style: none; overflow: hidden; diff --git a/frontend/components/Buttons/CollapseButton.tsx b/frontend/components/Buttons/CollapseButton.tsx index fedc9e07d..ebf24dca5 100644 --- a/frontend/components/Buttons/CollapseButton.tsx +++ b/frontend/components/Buttons/CollapseButton.tsx @@ -1,6 +1,6 @@ -import { IconButton, Typography } from "@mui/material" import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown" import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp" +import { IconButton, Typography } from "@mui/material" interface CollapseButtonProps { open: boolean diff --git a/frontend/components/Dashboard/Courses/Statistics/Charts.tsx b/frontend/components/Dashboard/Courses/Statistics/Charts.tsx new file mode 100644 index 000000000..0f247663f --- /dev/null +++ b/frontend/components/Dashboard/Courses/Statistics/Charts.tsx @@ -0,0 +1,181 @@ +import { useCallback, useMemo } from "react" + +import { Series } from "/components/Dashboard/Courses/Statistics/types" +import { useLanguageContext } from "/contexts/LanguageContext" +import { DateTime } from "luxon" +import dynamic from "next/dynamic" + +import { ApolloError } from "@apollo/client" +import { Alert, AlertTitle, Skeleton } from "@mui/material" + +const Chart = dynamic(() => import("react-apexcharts"), { ssr: false }) + +const getChartName = (s: Series) => + Array.isArray(s) ? s.map((s2) => s2.name).join("-") : s.name + +const colors = [ + "rgba(0, 143, 251, 0.85)", + "rgba(0, 27,150,0.85)", + "rgba(254,176,25,0.85)", + "#F44336", + "#E91E63", + "#9C27B0", +] + +interface ChartsProps { + series: Series[] + error?: ApolloError + loading?: boolean + separate: boolean + seriesLoading: boolean[] +} + +function ChartSkeleton() { + return ( + <> +
+ + +
+ + + ) +} + +function Charts({ + loading, + error, + separate, + series, + seriesLoading, +}: ChartsProps) { + const { language } = useLanguageContext() + + const options: ApexCharts.ApexOptions = useMemo( + () => ({ + stroke: { + curve: "smooth", + }, + yaxis: { + title: { + text: "users", + }, + labels: { + minWidth: 10, + }, + }, + xaxis: { + type: "datetime", + labels: { + rotate: -90, + rotateAlways: true, + hideOverlappingLabels: false, + formatter: (_value, timestamp, _opts) => { + return DateTime.fromMillis(Number(timestamp) ?? 0).toLocaleString( + {}, + { + locale: language ?? "en", + }, + ) + }, + }, + tickPlacement: "on", + }, + chart: { + animations: { + enabled: false, + }, + group: "group", + }, + grid: { + show: true, + position: "back", + borderColor: "#e7e7e7", + row: { + colors: ["#f3f3f3", "transparent"], + opacity: 0.5, + }, + xaxis: { + lines: { + show: true, + }, + }, + }, + legend: { + position: "top", + showForSingleSeries: true, + showForNullSeries: false, + }, + }), + [language], + ) + + const chartOptions = useCallback( + () => + series.map((_, index) => ({ + ...options, + colors: colors + .slice(index % colors.length) + .concat(colors.slice(0, index % colors.length)), + chart: { + ...options.chart, + id: `chart-${index}`, + }, + })), + [series], + ) + + if (loading) { + return ( + <> + + + + + ) + } + + if (error) { + return ( + + Error loading chart +
{JSON.stringify(error, undefined, 2)}
+
+ ) + } + + if (separate) { + return ( + <> + {series.map((s, index) => ( +
+ {seriesLoading[index] ? ( + + ) : ( + //
+ + )} +
+ ))} + + ) + } + + return ( + //
+ + ) +} + +export default Charts diff --git a/frontend/components/Dashboard/Courses/Statistics/CourseStatisticsEntry.tsx b/frontend/components/Dashboard/Courses/Statistics/CourseStatisticsEntry.tsx new file mode 100644 index 000000000..987d87e9f --- /dev/null +++ b/frontend/components/Dashboard/Courses/Statistics/CourseStatisticsEntry.tsx @@ -0,0 +1,79 @@ +import { DatedInt } from "/components/Dashboard/Courses/Statistics/types" +import { useLanguageContext } from "/contexts/LanguageContext" +import CoursesTranslations from "/translations/courses" +import { useTranslator } from "/util/useTranslator" +import { DateTime } from "luxon" + +import styled from "@emotion/styled" +import { Skeleton, Typography } from "@mui/material" + +interface CourseStatisticsEntryProps { + name: string + label: string + value?: { + updated_at: string + data: DatedInt[] | null + } | null + loading: boolean + error: boolean +} + +const StatisticsEntryWrapper: any = styled.div` + display: flex; + justify-content: space-between; + margin-bottom: 0.5rem; + padding: 0.5rem; + & + & { + border-top: 1px ridge; + } +` + +const LabelWrapper = styled.div` + display: flex; + flex-direction: column; +` + +function CourseStatisticsEntry({ + name, + label, + value, + loading, + error, +}: CourseStatisticsEntryProps) { + const t = useTranslator(CoursesTranslations) + const { language } = useLanguageContext() + + if (loading) { + return + } + + if (error) { + return
Error loading {name}
+ } + + const updated_at = DateTime.fromISO(value?.updated_at ?? "") + .setLocale(language ?? "en") + .toLocaleString(DateTime.DATETIME_FULL) + + return ( + + {loading ? ( + + ) : ( + <> + + {label} + + {t("updated")} {updated_at} + + + + {value?.data?.[0].value ?? "---"} + + + )} + + ) +} + +export default CourseStatisticsEntry diff --git a/frontend/components/Dashboard/Courses/Statistics/Graph.tsx b/frontend/components/Dashboard/Courses/Statistics/Graph.tsx new file mode 100644 index 000000000..21fe2eb75 --- /dev/null +++ b/frontend/components/Dashboard/Courses/Statistics/Graph.tsx @@ -0,0 +1,258 @@ +import React, { useEffect, useState } from "react" + +import CollapseButton from "/components/Buttons/CollapseButton" +// import Charts from "/components/Dashboard/Courses/Statistics/Charts" +import { + DatedInt, + Filter, + Series, + SeriesEntry, +} from "/components/Dashboard/Courses/Statistics/types" +import { useLanguageContext } from "/contexts/LanguageContext" +import CoursesTranslations from "/translations/courses" +import notEmpty from "/util/notEmpty" +import { useTranslator } from "/util/useTranslator" +import { flatten } from "lodash" +import { DateTime } from "luxon" +import dynamic from "next/dynamic" + +// import dynamic from "next/dynamic" +import { + ApolloError, + OperationVariables, + QueryLazyOptions, +} from "@apollo/client" +import styled, { StyledComponent } from "@emotion/styled" +import { + Button, + Checkbox, + Collapse, + FormControl, + FormControlLabel, + Paper, + Typography, +} from "@mui/material" + +const Charts = dynamic(() => import("./Charts"), { ssr: false }) + +const ChartWrapper: StyledComponent = styled((props: any[]) => ( + +))` + padding: 0.5rem; + row-gap: 1em; +` + +const ChartHeader = styled.div` + display: flex; + justify-content: space-between; +` + +const FilterMenu = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; +` + +const FilterColumn: any = styled(FormControl)`` +/*& + ${() => FilterColumn} { + border-left: 1px ridge grey; + }*/ + +interface GraphEntry { + value?: { + updated_at: string + data: DatedInt[] | null + } | null + name: string + label: string + loading: boolean + fetch?: (_?: QueryLazyOptions) => void + error?: ApolloError +} + +type GraphValues = GraphEntry | GraphEntry[] + +interface GraphProps { + values: GraphValues[] + loading?: boolean + error?: ApolloError + label?: string + updated_at?: string +} + +function useGraphFilter(values: GraphValues[]) { + const getFilters = (_values: GraphValues[]) => + flatten( + _values.map((v) => { + if (Array.isArray(v)) { + return v.map((v2) => ({ name: v2.name, label: v2.label })) + } + return { name: v.name, label: v.label } + }), + ) + + const mapGraphEntry = (value: GraphEntry): SeriesEntry => ({ + name: value.label, + data: (value?.value?.data ?? []).map((e) => ({ + x: new Date(DateTime.fromISO(e.date).toJSDate()).getTime(), + y: e.value, + })), + }) + + console.log(values) + const calculateSeries = (_values: GraphValues[]): Series[] => + _values.map((value) => { + if (Array.isArray(value)) return value.map(mapGraphEntry) + + return mapGraphEntry(value) + }) + + const getLoading = (_values: GraphValues[]) => + _values.map((v) => { + if (Array.isArray(v)) + return v.reduce((acc, curr) => acc || (curr.loading ?? false), false) + + return v.loading ?? false + }) + + const onFilterChange = (filterValue: Filter) => (_: any, checked: boolean) => + checked + ? setFilter((prev) => prev.concat(filterValue)) + : setFilter((prev) => prev.filter((f) => f.name !== filterValue.name)) + + const isChecked = (filterValue: Filter) => + filter.map((f) => f.name).includes(filterValue.name) + + const filterValues = getFilters(values) + const [filter, setFilter] = useState(filterValues) + const [series, setSeries] = useState(calculateSeries(values)) + const [loading, setLoading] = useState(getLoading(values)) + + useEffect(() => { + setSeries(calculateSeries(values)) + setLoading(getLoading(values)) + }, [values]) + + useEffect(() => { + const filterNames = filter.map((f) => f.name) + console.log(filterNames) + const tmp = values + .map((v) => { + if (Array.isArray(v)) { + const arr = v.filter((v2) => filterNames.includes(v2.name)) + return arr.length > 0 ? arr : undefined + } + + return filterNames.includes(v.name) ? v : undefined + }) + .filter(notEmpty) + + console.log("temp", tmp) + setSeries(calculateSeries(tmp)) + }, [filter]) + + return { + filterValues, + filter, + setFilter, + series, + loading, + isChecked, + onFilterChange, + } +} + +function Graph({ values, loading, error, label, updated_at }: GraphProps) { + const t = useTranslator(CoursesTranslations) + const { language } = useLanguageContext() + const [isFilterVisible, setIsFilterVisible] = useState(false) + const [separate, setSeparate] = useState(true) + + const { + filterValues, + series, + loading: seriesLoading, + isChecked, + onFilterChange, + } = useGraphFilter(values) + + console.log("series", series) + + const updatedAtFormatted = updated_at + ? DateTime.fromISO(updated_at) + .setLocale(language ?? "en") + .toLocaleString(DateTime.DATETIME_FULL) + : undefined + + const refreshSeries = () => { + // todo: only fetch selected + values.forEach((v) => { + if (Array.isArray(v)) { + return v.forEach((v2) => v2.fetch?.()) + } + v.fetch?.() + }) + } + + return ( + + +
+ {label && {label}} + {updatedAtFormatted && ( + + {t("updated")} {updatedAtFormatted} + + )} +
+ setIsFilterVisible((v) => !v)} + label="Options" + /> +
+ + + + {filterValues.map((filterValue, index) => ( + + } + /> + ))} + + + setSeparate(checked)} + /> + } + /> + + + + + +
+ ) +} + +export default Graph diff --git a/frontend/components/Dashboard/Courses/Statistics/types.ts b/frontend/components/Dashboard/Courses/Statistics/types.ts new file mode 100644 index 000000000..3e823aad2 --- /dev/null +++ b/frontend/components/Dashboard/Courses/Statistics/types.ts @@ -0,0 +1,20 @@ +export type DatedInt = { + value: number | null + date: string +} + +export interface Filter { + name: string + label: string +} + +export interface SeriesEntry { + name: string + data: Array<{ x: number; y: number | null }> +} +/*interface SeriesEntry { + name: string + [key: string]: any +}*/ + +export type Series = SeriesEntry | SeriesEntry[] diff --git a/frontend/components/Dashboard/DashboardTabBar.tsx b/frontend/components/Dashboard/DashboardTabBar.tsx index 8e75a9146..9ececfbcf 100644 --- a/frontend/components/Dashboard/DashboardTabBar.tsx +++ b/frontend/components/Dashboard/DashboardTabBar.tsx @@ -1,14 +1,17 @@ -import { useContext, useState, ChangeEvent } from "react" -import AppBar from "@mui/material/AppBar" -import Tabs from "@mui/material/Tabs" -import Tab from "@mui/material/Tab" +import { ChangeEvent, useContext, useState } from "react" + +import LanguageContext from "/contexts/LanguageContext" +import { useRouter } from "next/router" + import styled from "@emotion/styled" -import ViewListIcon from "@mui/icons-material/ViewList" -import ScatterplotIcon from "@mui/icons-material/ScatterPlot" import DashboardIcon from "@mui/icons-material/Dashboard" import EditIcon from "@mui/icons-material/Edit" -import LanguageContext from "/contexts/LanguageContext" -import { useRouter } from "next/router" +import EqualizerIcon from "@mui/icons-material/Equalizer" +import ScatterplotIcon from "@mui/icons-material/ScatterPlot" +import ViewListIcon from "@mui/icons-material/ViewList" +import AppBar from "@mui/material/AppBar" +import Tab from "@mui/material/Tab" +import Tabs from "@mui/material/Tabs" const TabBarContainer = styled.div` flex-grow: 1; @@ -59,6 +62,11 @@ const routes: Route[] = [ icon: , path: "/points", }, + { + label: "Statistics", + icon: , + path: "/statistics", + }, { label: "Edit", icon: , diff --git a/frontend/components/Dashboard/Editor/common.tsx b/frontend/components/Dashboard/Editor/common.tsx index 60566f41d..13053e3ff 100644 --- a/frontend/components/Dashboard/Editor/common.tsx +++ b/frontend/components/Dashboard/Editor/common.tsx @@ -44,7 +44,7 @@ export const OutlinedFormGroup = styled(FormGroup)<{ error?: boolean }>` } &:focus { - bordercolor: "#3f51b5"; + border-color: "#3f51b5"; } @media (hover: none) { diff --git a/frontend/components/Dashboard/Editor2/Common/Fields/ControlledCheckbox.tsx b/frontend/components/Dashboard/Editor2/Common/Fields/ControlledCheckbox.tsx index 116690e5d..2621e7af2 100644 --- a/frontend/components/Dashboard/Editor2/Common/Fields/ControlledCheckbox.tsx +++ b/frontend/components/Dashboard/Editor2/Common/Fields/ControlledCheckbox.tsx @@ -1,3 +1,7 @@ +import { + ControlledFieldProps, + FieldController, +} from "/components/Dashboard/Editor2/Common/Fields" import { FieldValues, Path, @@ -5,12 +9,9 @@ import { UnpackNestedValue, useFormContext, } from "react-hook-form" -import { FormControlLabel, Checkbox, Tooltip } from "@mui/material" -import { - ControlledFieldProps, - FieldController, -} from "/components/Dashboard/Editor2/Common/Fields" + import HelpIcon from "@mui/icons-material/Help" +import { Checkbox, FormControlLabel, Tooltip } from "@mui/material" export function ControlledCheckbox(props: ControlledFieldProps) { const { name, label, tip } = props diff --git a/frontend/components/Dashboard/Editor2/Common/Fields/ControlledFieldArrayList.tsx b/frontend/components/Dashboard/Editor2/Common/Fields/ControlledFieldArrayList.tsx index 77f709046..815df5d3a 100644 --- a/frontend/components/Dashboard/Editor2/Common/Fields/ControlledFieldArrayList.tsx +++ b/frontend/components/Dashboard/Editor2/Common/Fields/ControlledFieldArrayList.tsx @@ -1,20 +1,21 @@ +import { ButtonWithPaddingAndMargin as StyledButton } from "/components/Buttons/ButtonWithPaddingAndMargin" +import { ButtonWithWhiteText } from "/components/Dashboard/Editor2/Common" +import { ControlledFieldProps } from "/components/Dashboard/Editor2/Common/Fields" +import CoursesTranslations from "/translations/courses" +import { useTranslator } from "/util/useTranslator" +import { useConfirm } from "material-ui-confirm" import { - useFormContext, - useFieldArray, FieldArray, FieldArrayWithId, Path, + useFieldArray, + useFormContext, } from "react-hook-form" -import { FormGroup, Typography } from "@mui/material" -import { useTranslator } from "/util/useTranslator" -import CoursesTranslations from "/translations/courses" -import { useConfirm } from "material-ui-confirm" -import { ButtonWithPaddingAndMargin as StyledButton } from "/components/Buttons/ButtonWithPaddingAndMargin" + +import styled from "@emotion/styled" import AddIcon from "@mui/icons-material/Add" import RemoveIcon from "@mui/icons-material/Remove" -import { ControlledFieldProps } from "/components/Dashboard/Editor2/Common/Fields" -import styled from "@emotion/styled" -import { ButtonWithWhiteText } from "/components/Dashboard/Editor2/Common" +import { FormGroup, Typography } from "@mui/material" export const ArrayList = styled.ul` list-style: none; diff --git a/frontend/components/Dashboard/Editor2/Common/Fields/ControlledRadioGroup.tsx b/frontend/components/Dashboard/Editor2/Common/Fields/ControlledRadioGroup.tsx index 9c5b8aee3..541006ae2 100644 --- a/frontend/components/Dashboard/Editor2/Common/Fields/ControlledRadioGroup.tsx +++ b/frontend/components/Dashboard/Editor2/Common/Fields/ControlledRadioGroup.tsx @@ -8,7 +8,8 @@ import { UnpackNestedValue, useFormContext, } from "react-hook-form" -import { Radio, RadioGroup, FormControlLabel } from "@mui/material" + +import { FormControlLabel, Radio, RadioGroup } from "@mui/material" interface ControlledRadioGroupProps extends ControlledFieldProps { options: Array<{ value: string; label: string }> diff --git a/frontend/components/Dashboard/Editor2/Common/Fields/ControlledTextField.tsx b/frontend/components/Dashboard/Editor2/Common/Fields/ControlledTextField.tsx index 933659b56..6111a1ae4 100644 --- a/frontend/components/Dashboard/Editor2/Common/Fields/ControlledTextField.tsx +++ b/frontend/components/Dashboard/Editor2/Common/Fields/ControlledTextField.tsx @@ -1,12 +1,5 @@ -import { omit } from "lodash" import { useEffect, useState } from "react" -import { - FieldValues, - Path, - PathValue, - UnpackNestedValue, - useFormContext, -} from "react-hook-form" + import { ControlledFieldProps, FieldController, @@ -15,10 +8,19 @@ import { useEditorContext } from "/components/Dashboard/Editor2/EditorContext" import CommonTranslations from "/translations/common" import flattenKeys from "/util/flattenKeys" import { useTranslator } from "/util/useTranslator" -import { TextField, Tooltip, IconButton } from "@mui/material" -import HistoryIcon from "@mui/icons-material/History" +import { get, omit, set } from "lodash" +import { + FieldValues, + Path, + PathValue, + UnpackNestedValue, + useFormContext, +} from "react-hook-form" + import HelpIcon from "@mui/icons-material/Help" -import { get, set } from "lodash" +import HistoryIcon from "@mui/icons-material/History" +import { IconButton, TextField, Tooltip } from "@mui/material" + export interface ControlledTextFieldProps extends ControlledFieldProps { type?: string disabled?: boolean diff --git a/frontend/components/Dashboard/Editor2/Course/CourseTranslationForm.tsx b/frontend/components/Dashboard/Editor2/Course/CourseTranslationForm.tsx index 19ad03d2c..ee1b00056 100644 --- a/frontend/components/Dashboard/Editor2/Course/CourseTranslationForm.tsx +++ b/frontend/components/Dashboard/Editor2/Course/CourseTranslationForm.tsx @@ -1,15 +1,16 @@ -import { useFormContext } from "react-hook-form" -import { Typography } from "@mui/material" import { - ControlledTextField, ControlledHiddenField, + ControlledTextField, } from "/components/Dashboard/Editor2/Common/Fields" -import { useTranslator } from "/util/useTranslator" -import CoursesTranslations from "/translations/courses" -import styled from "@emotion/styled" import { CourseTranslationFormValues } from "/components/Dashboard/Editor2/Course/types" import { mapLangToLanguage } from "/components/DataFormatFunctions" import { EntryContainer } from "/components/Surfaces/EntryContainer" +import CoursesTranslations from "/translations/courses" +import { useTranslator } from "/util/useTranslator" +import { useFormContext } from "react-hook-form" + +import styled from "@emotion/styled" +import { Typography } from "@mui/material" const LanguageVersionTitle = styled(Typography)` margin-bottom: 1.5rem; diff --git a/frontend/components/Dashboard/PaginatedPointsList.tsx b/frontend/components/Dashboard/PaginatedPointsList.tsx index 2f34d24e7..95583ab42 100644 --- a/frontend/components/Dashboard/PaginatedPointsList.tsx +++ b/frontend/components/Dashboard/PaginatedPointsList.tsx @@ -1,5 +1,6 @@ import { ChangeEvent, useEffect, useState } from "react" +import PointsList from "/components/Dashboard/DashboardPointsList" import ErrorBoundary from "/components/ErrorBoundary" import { ProgressUserCourseProgressFragment } from "/graphql/fragments/userCourseProgress" import { ProgressUserCourseServiceProgressFragment } from "/graphql/fragments/userCourseServiceProgress" @@ -15,8 +16,6 @@ import { gql, useLazyQuery } from "@apollo/client" import styled from "@emotion/styled" import { Button, Grid, Skeleton, Slider, TextField } from "@mui/material" -import PointsList from "./DashboardPointsList" - export const StudentProgresses = gql` query UserCourseSettings($course_id: ID!, $skip: Int, $search: String) { userCourseSettings( diff --git a/frontend/components/Dashboard/PointsListItemCard.tsx b/frontend/components/Dashboard/PointsListItemCard.tsx index ed20ee905..bb3ecf888 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 { diff --git a/frontend/components/Dashboard/StudyModules/ModuleCard.tsx b/frontend/components/Dashboard/StudyModules/ModuleCard.tsx index 81e617a9e..0c90f27e4 100644 --- a/frontend/components/Dashboard/StudyModules/ModuleCard.tsx +++ b/frontend/components/Dashboard/StudyModules/ModuleCard.tsx @@ -1,13 +1,14 @@ -import { Grid, Typography, Skeleton } from "@mui/material" -import EditIcon from "@mui/icons-material/Edit" -import AddIcon from "@mui/icons-material/Add" -import AddCircleIcon from "@mui/icons-material/AddCircle" -import styled from "@emotion/styled" -import { mime } from "/util/imageUtils" +import { ButtonWithPaddingAndMargin } from "/components/Buttons/ButtonWithPaddingAndMargin" import LangLink from "/components/LangLink" -import { AllEditorModulesWithTranslations_study_modules } from "/static/types/generated/AllEditorModulesWithTranslations" import { ClickableDiv } from "/components/Surfaces/ClickableCard" -import { ButtonWithPaddingAndMargin } from "/components/Buttons/ButtonWithPaddingAndMargin" +import { AllEditorModulesWithTranslations_study_modules } from "/static/types/generated/AllEditorModulesWithTranslations" +import { mime } from "/util/imageUtils" + +import styled from "@emotion/styled" +import AddIcon from "@mui/icons-material/Add" +import AddCircleIcon from "@mui/icons-material/AddCircle" +import EditIcon from "@mui/icons-material/Edit" +import { Grid, Skeleton, Typography } from "@mui/material" const Base = styled(ClickableDiv)` width: 100%; diff --git a/frontend/components/Dashboard/Users/Summary/CollapseContext.tsx b/frontend/components/Dashboard/Users/Summary/CollapseContext.tsx index fb5bc869c..6db8641dd 100644 --- a/frontend/components/Dashboard/Users/Summary/CollapseContext.tsx +++ b/frontend/components/Dashboard/Users/Summary/CollapseContext.tsx @@ -1,6 +1,7 @@ import { createContext, Dispatch, useContext } from "react" -import { produce } from "immer" + import { UserSummary_user_user_course_summary } from "/static/types/generated/UserSummary" +import { produce } from "immer" export type ExerciseState = Record export type CourseState = { diff --git a/frontend/components/Dashboard/Users/Summary/Completion.tsx b/frontend/components/Dashboard/Users/Summary/Completion.tsx index aee1ce4d7..c1a9bab24 100644 --- a/frontend/components/Dashboard/Users/Summary/Completion.tsx +++ b/frontend/components/Dashboard/Users/Summary/Completion.tsx @@ -1,3 +1,15 @@ +import React from "react" + +import CollapseButton from "/components/Buttons/CollapseButton" +import { formatDateTime } from "/components/DataFormatFunctions" +import { CompletionListItem } from "/components/Home/Completions" +import { + UserSummary_user_user_course_summary_completion, + UserSummary_user_user_course_summary_course, +} from "/static/types/generated/UserSummary" +import ProfileTranslations from "/translations/profile" +import { useTranslator } from "/util/useTranslator" + import { Collapse, Paper, @@ -7,21 +19,12 @@ import { TableContainer, TableRow, } from "@mui/material" -import React from "react" -import CollapseButton from "/components/Buttons/CollapseButton" -import { CompletionListItem } from "/components/Home/Completions" -import { formatDateTime } from "/components/DataFormatFunctions" + import { ActionType, CollapsablePart, useCollapseContext, } from "./CollapseContext" -import { - UserSummary_user_user_course_summary_completion, - UserSummary_user_user_course_summary_course, -} from "/static/types/generated/UserSummary" -import ProfileTranslations from "/translations/profile" -import { useTranslator } from "/util/useTranslator" interface CompletionProps { completion?: UserSummary_user_user_course_summary_completion diff --git a/frontend/components/Dashboard/Users/Summary/ExerciseEntry.tsx b/frontend/components/Dashboard/Users/Summary/ExerciseEntry.tsx index a92ea0222..6ad162bb3 100644 --- a/frontend/components/Dashboard/Users/Summary/ExerciseEntry.tsx +++ b/frontend/components/Dashboard/Users/Summary/ExerciseEntry.tsx @@ -38,7 +38,7 @@ export default function ExerciseEntry({ exercise }: ExerciseEntryProps) { {exercise.name} - {round(exerciseCompletion?.n_points ?? 0)}/{exercise?.max_points ?? 0} + {round(exerciseCompletion?.n_points ?? 0)}/{exercise.max_points ?? 0} {exerciseCompletion?.completed ? t("yes") : t("no")} @@ -70,7 +70,6 @@ export default function ExerciseEntry({ exercise }: ExerciseEntryProps) { /> */} - {/* TODO/FIXME: not shown ever since collapse is disabled */} {JSON.stringify(exerciseCompletion)} diff --git a/frontend/components/Dashboard/Users/Summary/ProgressEntry.tsx b/frontend/components/Dashboard/Users/Summary/ProgressEntry.tsx index d2bb45824..9daae8912 100644 --- a/frontend/components/Dashboard/Users/Summary/ProgressEntry.tsx +++ b/frontend/components/Dashboard/Users/Summary/ProgressEntry.tsx @@ -1,3 +1,14 @@ +import React from "react" + +import CollapseButton from "/components/Buttons/CollapseButton" +import PointsListItemCard from "/components/Dashboard/PointsListItemCard" +import PointsProgress from "/components/Dashboard/PointsProgress" +import { UserCourseProgressFragment } from "/static/types/generated/UserCourseProgressFragment" +import { UserCourseServiceProgressFragment } from "/static/types/generated/UserCourseServiceProgressFragment" +import { UserSummary_user_user_course_summary_course } from "/static/types/generated/UserSummary" +import ProfileTranslations from "/translations/profile" +import { useTranslator } from "/util/useTranslator" + import { Collapse, Paper, @@ -7,20 +18,12 @@ import { TableContainer, TableRow, } from "@mui/material" -import React from "react" -import CollapseButton from "/components/Buttons/CollapseButton" -import PointsListItemCard from "/components/Dashboard/PointsListItemCard" -import PointsProgress from "/components/Dashboard/PointsProgress" + import { ActionType, CollapsablePart, useCollapseContext, } from "./CollapseContext" -import { UserSummary_user_user_course_summary_course } from "/static/types/generated/UserSummary" -import { UserCourseProgressFragment } from "/static/types/generated/UserCourseProgressFragment" -import { UserCourseServiceProgressFragment } from "/static/types/generated/UserCourseServiceProgressFragment" -import ProfileTranslations from "/translations/profile" -import { useTranslator } from "/util/useTranslator" interface ProgressEntryProps { userCourseProgress?: UserCourseProgressFragment | null diff --git a/frontend/components/Dashboard/Users/Summary/UserPointsSummary.tsx b/frontend/components/Dashboard/Users/Summary/UserPointsSummary.tsx index 339b06f7a..02ba16ba7 100644 --- a/frontend/components/Dashboard/Users/Summary/UserPointsSummary.tsx +++ b/frontend/components/Dashboard/Users/Summary/UserPointsSummary.tsx @@ -1,18 +1,22 @@ -import CourseEntry from "./CourseEntry" -import { sortBy } from "lodash" -import { UserSummary_user_user_course_summary } from "/static/types/generated/UserSummary" +import { useState } from "react" + +import CollapseButton from "/components/Buttons/CollapseButton" import { ActionType, CollapsablePart, useCollapseContext, } from "/components/Dashboard/Users/Summary/CollapseContext" -import { Paper, Button, Dialog } from "@mui/material" -import CollapseButton from "/components/Buttons/CollapseButton" -import { useTranslator } from "/util/useTranslator" +import RawView from "/components/Dashboard/Users/Summary/RawView" +import { UserSummary_user_user_course_summary } from "/static/types/generated/UserSummary" import CommonTranslations from "/translations/common" +import { useTranslator } from "/util/useTranslator" +import { sortBy } from "lodash" + import BuildIcon from "@mui/icons-material/Build" -import RawView from "/components/Dashboard/Users/Summary/RawView" -import { useState } from "react" +import { Button, Dialog, Paper } from "@mui/material" + +import CourseEntry from "./CourseEntry" + interface UserPointsSummaryProps { data?: UserSummary_user_user_course_summary[] search?: string diff --git a/frontend/components/Dashboard/Users/WideGrid.tsx b/frontend/components/Dashboard/Users/WideGrid.tsx index f66f5d26f..8bfd60185 100644 --- a/frontend/components/Dashboard/Users/WideGrid.tsx +++ b/frontend/components/Dashboard/Users/WideGrid.tsx @@ -1,23 +1,25 @@ import { useCallback, useContext } from "react" + +import Pagination from "/components/Dashboard/Users/Pagination" +import LangLink from "/components/LangLink" +import UserSearchContext from "/contexts/UserSearchContext" +import UsersTranslations from "/translations/users" +import notEmpty from "/util/notEmpty" +import { useTranslator } from "/util/useTranslator" +import range from "lodash/range" + +import styled from "@emotion/styled" import { Button, Paper, + Skeleton, Table, TableBody, TableCell, TableFooter, - TableRow, TableHead, - Skeleton, + TableRow, } from "@mui/material" -import styled from "@emotion/styled" -import range from "lodash/range" -import LangLink from "/components/LangLink" -import Pagination from "/components/Dashboard/Users/Pagination" -import UsersTranslations from "/translations/users" -import UserSearchContext from "/contexts/UserSearchContext" -import { useTranslator } from "/util/useTranslator" -import notEmpty from "/util/notEmpty" const TableWrapper = styled.div` overflow-x: auto; @@ -76,7 +78,7 @@ const WideGrid = () => { {/* {t("summary")} - {("completions")} + {t("completions")} */} diff --git a/frontend/components/FilterMenu.tsx b/frontend/components/FilterMenu.tsx index 7ce545548..0cc692c2d 100644 --- a/frontend/components/FilterMenu.tsx +++ b/frontend/components/FilterMenu.tsx @@ -216,8 +216,8 @@ export default function FilterMenu({