diff --git a/app/controllers/components/course/gradebook_component.rb b/app/controllers/components/course/gradebook_component.rb new file mode 100644 index 00000000000..e5770eeb72d --- /dev/null +++ b/app/controllers/components/course/gradebook_component.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true +class Course::GradebookComponent < SimpleDelegator + include Course::ControllerComponentHost::Component + + def self.display_name + 'Gradebook' + end + + def sidebar_items + return [] unless can?(:read_gradebook, current_course) + + [ + { + key: self.class.key, + icon: :gradebook, + title: I18n.t('course.gradebook.component.sidebar_title'), + type: :admin, + weight: 4, + path: course_gradebook_path(current_course) + } + ] + end +end diff --git a/app/controllers/course/admin/gradebook_settings_controller.rb b/app/controllers/course/admin/gradebook_settings_controller.rb new file mode 100644 index 00000000000..6e6f3313ed5 --- /dev/null +++ b/app/controllers/course/admin/gradebook_settings_controller.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +class Course::Admin::GradebookSettingsController < Course::Admin::Controller + def edit + respond_to(&:json) + end + + def update + if @settings.update(gradebook_settings_params) && current_course.save + render 'edit' + else + render json: { errors: @settings.errors }, status: :bad_request + end + end + + private + + def gradebook_settings_params + params.require(:settings_gradebook_component).permit(:weighted_view_enabled) + end + + def component + current_component_host[:course_gradebook_component] + end + + def authorize_admin + authorize! :manage_gradebook_settings, current_course + end +end diff --git a/app/controllers/course/gradebook_controller.rb b/app/controllers/course/gradebook_controller.rb new file mode 100644 index 00000000000..198be93d613 --- /dev/null +++ b/app/controllers/course/gradebook_controller.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true +class Course::GradebookController < Course::ComponentController + before_action :authorize_read_gradebook! + + def index + respond_to do |format| + format.json do + @weighted_view_enabled = @settings.weighted_view_enabled + @published_assessments = fetch_published_assessments + @categories, @tabs = fetch_categories_and_tabs + @students = fetch_students + assessment_ids = @published_assessments.pluck(:id) + @assessment_max_grades = Course::Assessment.max_grades(assessment_ids) + @submissions = Course::Assessment::Submission.grade_summary( + student_ids: @students.map(&:user_id), + assessment_ids: assessment_ids + ) + end + end + end + + def update_weights + authorize! :manage_gradebook_weights, current_course + updates = update_weights_params[:weights].map do |entry| + { tab_id: entry[:tabId].to_i, weight: entry[:weight].to_i } + end + Course::Assessment::Tab.update_gradebook_weights(course: current_course, updates: updates) + render json: { weights: updates.map { |u| { tabId: u[:tab_id], weight: u[:weight] } } } + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotFound => e + render json: { errors: { base: e.message } }, status: :unprocessable_entity + end + + private + + def authorize_read_gradebook! + authorize! :read_gradebook, current_course + end + + def update_weights_params + params.permit(weights: [:tabId, :weight]) + end + + def component + current_component_host[:course_gradebook_component] + end + + def fetch_categories_and_tabs + tabs = @published_assessments.map(&:tab).uniq(&:id) + [tabs.map(&:category).uniq(&:id), tabs] + end + + def fetch_students + current_course.levels.to_a + current_course.course_users.students.without_phantom_users. + calculated(:experience_points).includes(:user).to_a + end + + def fetch_published_assessments + current_course.assessments. + published. + includes(tab: :category). + joins(tab: :category). + reorder('course_assessment_categories.weight, course_assessment_tabs.weight, course_assessments.id') + end +end diff --git a/app/models/components/course/gradebook_ability_component.rb b/app/models/components/course/gradebook_ability_component.rb new file mode 100644 index 00000000000..d54a56cca62 --- /dev/null +++ b/app/models/components/course/gradebook_ability_component.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true +module Course::GradebookAbilityComponent + include AbilityHost::Component + + def define_permissions + can :read_gradebook, Course, id: course.id if course_user&.staff? + can :manage_gradebook_weights, Course, id: course.id if course_user&.manager_or_owner? + can :manage_gradebook_settings, Course, id: course.id if course_user&.manager_or_owner? + super + end +end diff --git a/app/models/course/assessment.rb b/app/models/course/assessment.rb index 3128ed42528..aec93f9de08 100644 --- a/app/models/course/assessment.rb +++ b/app/models/course/assessment.rb @@ -160,6 +160,22 @@ def self.use_relative_model_naming? true end + # Returns a hash of assessment_id => max_grade (sum of question maximum_grades). + def self.max_grades(assessment_ids) + return {} if assessment_ids.empty? + + rows = find_by_sql( + sanitize_sql_array([<<-SQL.squish, assessment_ids]) + SELECT cqa.assessment_id, COALESCE(SUM(caq.maximum_grade), 0) AS max_grade + FROM course_question_assessments cqa + JOIN course_assessment_questions caq ON caq.id = cqa.question_id + WHERE cqa.assessment_id IN (?) + GROUP BY cqa.assessment_id + SQL + ) + rows.to_h { |row| [row.assessment_id, row.max_grade.to_f] } + end + def to_partial_path 'course/assessment/assessments/assessment' end diff --git a/app/models/course/assessment/submission.rb b/app/models/course/assessment/submission.rb index c4919d6ec14..ec9a2d0de48 100644 --- a/app/models/course/assessment/submission.rb +++ b/app/models/course/assessment/submission.rb @@ -323,6 +323,27 @@ def self.on_dependent_status_change(answer) answer.submission.last_graded_time = Time.now end + # Returns an array of submission rows for the given students and assessments. + # Each row has: student_id (creator_id), assessment_id, grade (float). + # Only graded/published submissions are included. + def self.grade_summary(student_ids:, assessment_ids:) + return [] if student_ids.empty? || assessment_ids.empty? + + find_by_sql( + sanitize_sql_array([<<-SQL.squish, student_ids, assessment_ids]) + SELECT cas.creator_id AS student_id, cas.assessment_id, + SUM(caa.grade) AS grade + FROM course_assessment_submissions cas + JOIN course_assessment_answers caa ON caa.submission_id = cas.id + WHERE cas.creator_id IN (?) + AND cas.assessment_id IN (?) + AND cas.workflow_state IN ('graded', 'published') + AND caa.current_answer = TRUE + GROUP BY cas.creator_id, cas.assessment_id + SQL + ) + end + private # Queues the submission for auto grading, after the submission has changed to the submitted state. diff --git a/app/models/course/assessment/tab.rb b/app/models/course/assessment/tab.rb index bb88b5287f2..4013558f340 100644 --- a/app/models/course/assessment/tab.rb +++ b/app/models/course/assessment/tab.rb @@ -2,6 +2,11 @@ class Course::Assessment::Tab < ApplicationRecord validates :title, length: { maximum: 255 }, presence: true validates :weight, numericality: { only_integer: true }, presence: true + validates :gradebook_weight, + numericality: { only_integer: true, + greater_than_or_equal_to: 0, + less_than_or_equal_to: 100 }, + presence: true validates :creator, presence: true validates :updater, presence: true validates :category, presence: true @@ -24,6 +29,30 @@ class Course::Assessment::Tab < ApplicationRecord select('(array_agg(title))[0:3]') end) + # Bulk-updates the gradebook_weight for a set of tabs belonging to the given course. + # Raises ActiveRecord::RecordNotFound if any tab_id does not belong to the course. + # Raises ActiveRecord::RecordInvalid if any weight fails validation; the transaction is rolled back. + # + # @param course [Course] + # @param updates [Array] array of { tab_id: Integer, weight: Integer } + def self.update_gradebook_weights(course:, updates:) + course_tab_ids = course.assessment_tabs.pluck(:id).to_set + tab_ids_to_update = updates.map { |e| e[:tab_id] } + + tab_ids_to_update.each do |tab_id| + raise ActiveRecord::RecordNotFound unless course_tab_ids.include?(tab_id) + end + + tabs_by_id = where(id: tab_ids_to_update).index_by(&:id) + + transaction do + updates.each do |entry| + tab = tabs_by_id[entry[:tab_id]] + tab.update!(gradebook_weight: entry[:weight]) + end + end + end + # Returns a boolean value indicating if there are other tabs # besides this one remaining in its category. # diff --git a/app/models/course/settings/gradebook_component.rb b/app/models/course/settings/gradebook_component.rb new file mode 100644 index 00000000000..0f788086061 --- /dev/null +++ b/app/models/course/settings/gradebook_component.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +class Course::Settings::GradebookComponent < Course::Settings::Component + # Returns whether weighted view is enabled (disabled by default). + # + # @return [Boolean] Setting on whether weighted view is enabled. + def weighted_view_enabled + ActiveRecord::Type::Boolean.new.cast(settings.weighted_view_enabled) || false + end + + # Enable or disable the weighted view. + # + # @param [Boolean|Integer|String] value Setting on whether weighted view is enabled. + def weighted_view_enabled=(value) + settings.weighted_view_enabled = ActiveRecord::Type::Boolean.new.cast(value) + end +end diff --git a/app/views/course/admin/gradebook_settings/edit.json.jbuilder b/app/views/course/admin/gradebook_settings/edit.json.jbuilder new file mode 100644 index 00000000000..24c730f6bcb --- /dev/null +++ b/app/views/course/admin/gradebook_settings/edit.json.jbuilder @@ -0,0 +1,2 @@ +# frozen_string_literal: true +json.weightedViewEnabled @settings.weighted_view_enabled diff --git a/app/views/course/gradebook/index.json.jbuilder b/app/views/course/gradebook/index.json.jbuilder new file mode 100644 index 00000000000..edd6daa4d14 --- /dev/null +++ b/app/views/course/gradebook/index.json.jbuilder @@ -0,0 +1,38 @@ +# frozen_string_literal: true +json.weightedViewEnabled @weighted_view_enabled +json.canManageWeights can?(:manage_gradebook_weights, current_course) + +json.categories @categories do |cat| + json.id cat.id + json.title cat.title +end + +json.tabs @tabs do |tab| + json.id tab.id + json.title tab.title + json.categoryId tab.category_id + json.gradebookWeight tab.gradebook_weight if @weighted_view_enabled +end + +json.assessments @published_assessments do |assessment| + json.id assessment.id + json.title assessment.title + json.tabId assessment.tab_id + json.maxGrade @assessment_max_grades[assessment.id] || 0 +end + +json.students @students do |course_user| + json.id course_user.user_id + json.name course_user.name + json.email course_user.user.email + json.level course_user.level_number + json.totalXp course_user.experience_points +end + +json.submissions @submissions do |sub| + json.studentId sub.student_id + json.assessmentId sub.assessment_id + json.grade sub.grade&.to_f +end + +json.gamificationEnabled current_course.gamified? diff --git a/client/app/api/course/Admin/Gradebook.ts b/client/app/api/course/Admin/Gradebook.ts new file mode 100644 index 00000000000..287e4b0c79d --- /dev/null +++ b/client/app/api/course/Admin/Gradebook.ts @@ -0,0 +1,23 @@ +import { AxiosResponse } from 'axios'; +import { + GradebookSettingsData, + GradebookSettingsPostData, +} from 'types/course/admin/gradebook'; + +import BaseAdminAPI from './Base'; + +export default class GradebookAdminAPI extends BaseAdminAPI { + override get urlPrefix(): string { + return `${super.urlPrefix}/gradebook`; + } + + index(): Promise> { + return this.client.get(this.urlPrefix); + } + + update( + data: GradebookSettingsPostData, + ): Promise> { + return this.client.patch(this.urlPrefix, data); + } +} diff --git a/client/app/api/course/Admin/index.ts b/client/app/api/course/Admin/index.ts index 966a1d3b05f..fcd4097b26d 100644 --- a/client/app/api/course/Admin/index.ts +++ b/client/app/api/course/Admin/index.ts @@ -6,6 +6,7 @@ import CommentsAdminAPI from './Comments'; import ComponentsAdminAPI from './Components'; import CourseAdminAPI from './Course'; import ForumsAdminAPI from './Forums'; +import GradebookAdminAPI from './Gradebook'; import LeaderboardAdminAPI from './Leaderboard'; import LessonPlanSettingsAPI from './LessonPlan'; import MaterialsAdminAPI from './Materials'; @@ -28,6 +29,7 @@ const AdminAPI = { lessonPlan: new LessonPlanSettingsAPI(), materials: new MaterialsAdminAPI(), forums: new ForumsAdminAPI(), + gradebook: new GradebookAdminAPI(), videos: new VideosAdminAPI(), notifications: new NotificationsSettingsAPI(), codaveri: new CodaveriAdminAPI(), diff --git a/client/app/api/course/Gradebook.ts b/client/app/api/course/Gradebook.ts new file mode 100644 index 00000000000..7603f1f2a1b --- /dev/null +++ b/client/app/api/course/Gradebook.ts @@ -0,0 +1,21 @@ +import { GradebookData, UpdateWeightsPayload } from 'types/course/gradebook'; + +import { APIResponse } from 'api/types'; + +import BaseCourseAPI from './Base'; + +export default class GradebookAPI extends BaseCourseAPI { + get #urlPrefix(): string { + return `/courses/${this.courseId}/gradebook`; + } + + index(): APIResponse { + return this.client.get(this.#urlPrefix); + } + + updateWeights( + payload: UpdateWeightsPayload, + ): APIResponse { + return this.client.patch(`${this.#urlPrefix}/weights`, payload); + } +} diff --git a/client/app/api/course/index.js b/client/app/api/course/index.js index 8f5df6176fe..355a5878c53 100644 --- a/client/app/api/course/index.js +++ b/client/app/api/course/index.js @@ -12,6 +12,7 @@ import DuplicationAPI from './Duplication'; import EnrolRequestsAPI from './EnrolRequests'; import ExperiencePointsRecordAPI from './ExperiencePointsRecord'; import ForumAPI from './Forum'; +import GradebookAPI from './Gradebook'; import GroupsAPI from './Groups'; import LeaderboardAPI from './Leaderboard'; import LearningMapAPI from './LearningMap'; @@ -48,6 +49,7 @@ const CourseAPI = { experiencePointsRecord: new ExperiencePointsRecordAPI(), folders: new FoldersAPI(), forum: ForumAPI, + gradebook: new GradebookAPI(), groups: new GroupsAPI(), leaderboard: new LeaderboardAPI(), learningMap: new LearningMapAPI(), diff --git a/client/app/bundles/course/admin/pages/GradebookSettings/GradebookSettingsForm.tsx b/client/app/bundles/course/admin/pages/GradebookSettings/GradebookSettingsForm.tsx new file mode 100644 index 00000000000..2a0c5363c40 --- /dev/null +++ b/client/app/bundles/course/admin/pages/GradebookSettings/GradebookSettingsForm.tsx @@ -0,0 +1,59 @@ +import { forwardRef } from 'react'; +import { Controller } from 'react-hook-form'; +import { Typography } from '@mui/material'; +import { GradebookSettingsData } from 'types/course/admin/gradebook'; + +import Section from 'lib/components/core/layouts/Section'; +import FormCheckboxField from 'lib/components/form/fields/CheckboxField'; +import Form, { FormRef } from 'lib/components/form/Form'; +import useTranslation from 'lib/hooks/useTranslation'; + +import translations from './translations'; + +interface GradebookSettingsFormProps { + data: GradebookSettingsData; + onSubmit: (data: GradebookSettingsData) => void; + disabled?: boolean; +} + +const GradebookSettingsForm = forwardRef< + FormRef, + GradebookSettingsFormProps +>((props, ref): JSX.Element => { + const { t } = useTranslation(); + + return ( +
+ {(control): JSX.Element => ( +
+ ( + + )} + /> + + + {t(translations.weightedViewEnabledHint)} + +
+ )} +
+ ); +}); + +GradebookSettingsForm.displayName = 'GradebookSettingsForm'; + +export default GradebookSettingsForm; diff --git a/client/app/bundles/course/admin/pages/GradebookSettings/__tests__/GradebookSettings.test.tsx b/client/app/bundles/course/admin/pages/GradebookSettings/__tests__/GradebookSettings.test.tsx new file mode 100644 index 00000000000..f38c567ae32 --- /dev/null +++ b/client/app/bundles/course/admin/pages/GradebookSettings/__tests__/GradebookSettings.test.tsx @@ -0,0 +1,48 @@ +import { createMockAdapter } from 'mocks/axiosMock'; +import { fireEvent, render, screen, waitFor } from 'test-utils'; + +import CourseAPI from 'api/course'; + +import GradebookSettings from '../index'; + +const mock = createMockAdapter(CourseAPI.admin.gradebook.client); + +describe('', () => { + it('renders the toggle unchecked when weightedViewEnabled is false', async () => { + mock + .onGet(`/courses/${global.courseId}/admin/gradebook`) + .reply(200, { weightedViewEnabled: false }); + + render(); + + const checkbox = await screen.findByRole('checkbox', { + name: /enable weighted grade view/i, + }); + expect(checkbox).not.toBeChecked(); + }); + + it('PATCHes when toggle is checked and form submitted', async () => { + mock + .onGet(`/courses/${global.courseId}/admin/gradebook`) + .reply(200, { weightedViewEnabled: false }); + mock + .onPatch(`/courses/${global.courseId}/admin/gradebook`) + .reply(200, { weightedViewEnabled: true }); + + const spy = jest.spyOn(CourseAPI.admin.gradebook, 'update'); + + render(); + + const checkbox = await screen.findByRole('checkbox', { + name: /enable weighted grade view/i, + }); + fireEvent.click(checkbox); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + + await waitFor(() => { + expect(spy).toHaveBeenCalledWith({ + settings_gradebook_component: { weighted_view_enabled: true }, + }); + }); + }); +}); diff --git a/client/app/bundles/course/admin/pages/GradebookSettings/index.tsx b/client/app/bundles/course/admin/pages/GradebookSettings/index.tsx new file mode 100644 index 00000000000..122c063b90b --- /dev/null +++ b/client/app/bundles/course/admin/pages/GradebookSettings/index.tsx @@ -0,0 +1,49 @@ +import { ComponentRef, useRef, useState } from 'react'; +import { GradebookSettingsData } from 'types/course/admin/gradebook'; + +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import Preload from 'lib/components/wrappers/Preload'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; +import translations from 'lib/translations/form'; + +import { useItemsReloader } from '../../components/SettingsNavigation'; + +import GradebookSettingsForm from './GradebookSettingsForm'; +import { fetchGradebookSettings, updateGradebookSettings } from './operations'; + +const GradebookSettings = (): JSX.Element => { + const reloadItems = useItemsReloader(); + const { t } = useTranslation(); + const formRef = useRef>(null); + const [submitting, setSubmitting] = useState(false); + + const handleSubmit = (data: GradebookSettingsData): void => { + setSubmitting(true); + + updateGradebookSettings(data) + .then((newData) => { + if (!newData) return; + formRef.current?.resetTo?.(newData); + reloadItems(); + toast.success(t(translations.changesSaved)); + }) + .catch(formRef.current?.receiveErrors) + .finally(() => setSubmitting(false)); + }; + + return ( + } while={fetchGradebookSettings}> + {(data): JSX.Element => ( + + )} + + ); +}; + +export default GradebookSettings; diff --git a/client/app/bundles/course/admin/pages/GradebookSettings/operations.ts b/client/app/bundles/course/admin/pages/GradebookSettings/operations.ts new file mode 100644 index 00000000000..0d19aebc9da --- /dev/null +++ b/client/app/bundles/course/admin/pages/GradebookSettings/operations.ts @@ -0,0 +1,32 @@ +import { AxiosError } from 'axios'; +import { + GradebookSettingsData, + GradebookSettingsPostData, +} from 'types/course/admin/gradebook'; + +import CourseAPI from 'api/course'; + +type Data = Promise; + +export const fetchGradebookSettings = async (): Data => { + const response = await CourseAPI.admin.gradebook.index(); + return response.data; +}; + +export const updateGradebookSettings = async ( + data: GradebookSettingsData, +): Data => { + const adaptedData: GradebookSettingsPostData = { + settings_gradebook_component: { + weighted_view_enabled: data.weightedViewEnabled, + }, + }; + + try { + const response = await CourseAPI.admin.gradebook.update(adaptedData); + return response.data; + } catch (error) { + if (error instanceof AxiosError) throw error.response?.data?.errors; + throw error; + } +}; diff --git a/client/app/bundles/course/admin/pages/GradebookSettings/translations.ts b/client/app/bundles/course/admin/pages/GradebookSettings/translations.ts new file mode 100644 index 00000000000..1179259e7a5 --- /dev/null +++ b/client/app/bundles/course/admin/pages/GradebookSettings/translations.ts @@ -0,0 +1,17 @@ +import { defineMessages } from 'react-intl'; + +export default defineMessages({ + gradebookSettings: { + id: 'course.admin.GradebookSettings.gradebookSettings', + defaultMessage: 'Gradebook settings', + }, + weightedViewEnabled: { + id: 'course.admin.GradebookSettings.weightedViewEnabled', + defaultMessage: 'Enable weighted grade view', + }, + weightedViewEnabledHint: { + id: 'course.admin.GradebookSettings.weightedViewEnabledHint', + defaultMessage: + 'Enables a "By weight" view in the gradebook where staff can configure per-tab weights and see a weighted Total column.', + }, +}); diff --git a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/AssessmentsListing.tsx b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/AssessmentsListing.tsx index 12f47b012e3..4799c28d3b3 100644 --- a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/AssessmentsListing.tsx +++ b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/AssessmentsListing.tsx @@ -2,7 +2,6 @@ import { FC } from 'react'; import { defineMessages } from 'react-intl'; import { Card, CardContent, ListSubheader } from '@mui/material'; -import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; import TypeBadge from 'course/duplication/components/TypeBadge'; import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; import { selectDuplicationStore } from 'course/duplication/selectors'; @@ -12,6 +11,7 @@ import { DuplicationTabData, } from 'course/duplication/types'; import componentTranslations from 'course/translations'; +import IndentedCheckbox from 'lib/components/core/IndentedCheckbox'; import { useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; diff --git a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/MaterialsListing.tsx b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/MaterialsListing.tsx index f2beac495af..8f44a3496ae 100644 --- a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/MaterialsListing.tsx +++ b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/MaterialsListing.tsx @@ -2,7 +2,6 @@ import { FC } from 'react'; import { defineMessages } from 'react-intl'; import { Card, CardContent, ListSubheader } from '@mui/material'; -import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; import TypeBadge from 'course/duplication/components/TypeBadge'; import { selectDuplicationStore } from 'course/duplication/selectors'; import { @@ -10,6 +9,7 @@ import { DuplicationMaterialData, } from 'course/duplication/types'; import componentTranslations from 'course/translations'; +import IndentedCheckbox from 'lib/components/core/IndentedCheckbox'; import { useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; diff --git a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/VideosListing.tsx b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/VideosListing.tsx index 55610147699..3425d9bf1cb 100644 --- a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/VideosListing.tsx +++ b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/VideosListing.tsx @@ -2,7 +2,6 @@ import { FC } from 'react'; import { defineMessages } from 'react-intl'; import { Card, CardContent, ListSubheader } from '@mui/material'; -import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; import TypeBadge from 'course/duplication/components/TypeBadge'; import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; import { selectDuplicationStore } from 'course/duplication/selectors'; @@ -11,6 +10,7 @@ import { DuplicationVideoTabData, } from 'course/duplication/types'; import componentTranslations from 'course/translations'; +import IndentedCheckbox from 'lib/components/core/IndentedCheckbox'; import { useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; diff --git a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AchievementsSelector.tsx b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AchievementsSelector.tsx index 03a1b32fb3a..84a7e486043 100644 --- a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AchievementsSelector.tsx +++ b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AchievementsSelector.tsx @@ -3,7 +3,6 @@ import { defineMessages } from 'react-intl'; import { ListSubheader, Typography } from '@mui/material'; import BulkSelectors from 'course/duplication/components/BulkSelectors'; -import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; import TypeBadge from 'course/duplication/components/TypeBadge'; import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; import { selectDuplicationStore } from 'course/duplication/selectors'; @@ -11,6 +10,7 @@ import { actions } from 'course/duplication/store'; import { DuplicationAchievementData } from 'course/duplication/types'; import { getAchievementBadgeUrl } from 'course/helper/achievements'; import componentTranslations from 'course/translations'; +import IndentedCheckbox from 'lib/components/core/IndentedCheckbox'; import Thumbnail from 'lib/components/core/Thumbnail'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; diff --git a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AssessmentsSelector.tsx b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AssessmentsSelector.tsx index 3b4817e9590..0f0d78708b5 100644 --- a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AssessmentsSelector.tsx +++ b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AssessmentsSelector.tsx @@ -3,7 +3,6 @@ import { defineMessages } from 'react-intl'; import { ListSubheader, Typography } from '@mui/material'; import BulkSelectors from 'course/duplication/components/BulkSelectors'; -import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; import TypeBadge from 'course/duplication/components/TypeBadge'; import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; import { @@ -17,6 +16,7 @@ import { DuplicationTabData, } from 'course/duplication/types'; import componentTranslations from 'course/translations'; +import IndentedCheckbox from 'lib/components/core/IndentedCheckbox'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; diff --git a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/MaterialsSelector.tsx b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/MaterialsSelector.tsx index 9d6c22a01ad..fd1d09852d8 100644 --- a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/MaterialsSelector.tsx +++ b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/MaterialsSelector.tsx @@ -3,7 +3,6 @@ import { defineMessages } from 'react-intl'; import { ListSubheader, Typography } from '@mui/material'; import BulkSelectors from 'course/duplication/components/BulkSelectors'; -import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; import TypeBadge from 'course/duplication/components/TypeBadge'; import { selectDuplicationStore } from 'course/duplication/selectors'; import { actions } from 'course/duplication/store'; @@ -12,6 +11,7 @@ import { DuplicationMaterialData, } from 'course/duplication/types'; import componentTranslations from 'course/translations'; +import IndentedCheckbox from 'lib/components/core/IndentedCheckbox'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; diff --git a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/SurveysSelector.tsx b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/SurveysSelector.tsx index 1c1a6a77068..fbd3cdebd03 100644 --- a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/SurveysSelector.tsx +++ b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/SurveysSelector.tsx @@ -3,13 +3,13 @@ import { defineMessages } from 'react-intl'; import { ListSubheader, Typography } from '@mui/material'; import BulkSelectors from 'course/duplication/components/BulkSelectors'; -import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; import TypeBadge from 'course/duplication/components/TypeBadge'; import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; import { selectDuplicationStore } from 'course/duplication/selectors'; import { actions } from 'course/duplication/store'; import { DuplicationSurveyData } from 'course/duplication/types'; import componentTranslations from 'course/translations'; +import IndentedCheckbox from 'lib/components/core/IndentedCheckbox'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; diff --git a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/VideosSelector.tsx b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/VideosSelector.tsx index 25ab8fa9505..fb010970d96 100644 --- a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/VideosSelector.tsx +++ b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/VideosSelector.tsx @@ -3,7 +3,6 @@ import { defineMessages } from 'react-intl'; import { ListSubheader, Typography } from '@mui/material'; import BulkSelectors from 'course/duplication/components/BulkSelectors'; -import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; import TypeBadge from 'course/duplication/components/TypeBadge'; import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; import { selectDuplicationStore } from 'course/duplication/selectors'; @@ -13,6 +12,7 @@ import { DuplicationVideoTabData, } from 'course/duplication/types'; import componentTranslations from 'course/translations'; +import IndentedCheckbox from 'lib/components/core/IndentedCheckbox'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; diff --git a/client/app/bundles/course/gradebook/__tests__/ConfigureWeightsDialog.test.tsx b/client/app/bundles/course/gradebook/__tests__/ConfigureWeightsDialog.test.tsx new file mode 100644 index 00000000000..4ec677ae6fc --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/ConfigureWeightsDialog.test.tsx @@ -0,0 +1,97 @@ +import { fireEvent, render, screen, waitFor } from 'test-utils'; + +import * as operations from '../operations'; +import ConfigureWeightsDialog from '../components/ConfigureWeightsDialog'; + +jest + .spyOn(operations, 'updateGradebookWeights') + .mockReturnValue(() => Promise.resolve()); + +const categories = [{ id: 1, title: 'Missions' }]; +const tabs = [ + { id: 10, title: 'Assignments', categoryId: 1, gradebookWeight: 50 }, + { id: 11, title: 'Optional', categoryId: 1, gradebookWeight: 50 }, +]; + +const setup = (overrides = {}) => + render( + , + ); + +describe('', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders one input per tab grouped by category', async () => { + setup(); + expect(await screen.findByText('Missions')).toBeInTheDocument(); + expect(screen.getByLabelText('Assignments')).toHaveValue(50); + expect(screen.getByLabelText('Optional')).toHaveValue(50); + }); + + it('shows Total: 100% with no warning when sum = 100', async () => { + setup(); + expect(await screen.findByText(/Total:\s*100%/)).toBeInTheDocument(); + expect(screen.queryByText(/do not sum to 100/i)).not.toBeInTheDocument(); + }); + + it('shows warning when sum != 100', async () => { + setup(); + await screen.findByText('Missions'); + fireEvent.change(screen.getByLabelText('Optional'), { + target: { value: '30' }, + }); + expect(screen.getByText(/Total:\s*80%/)).toBeInTheDocument(); + expect(screen.getByText(/do not sum to 100/i)).toBeInTheDocument(); + }); + + it('shows inline error for >100', async () => { + setup(); + await screen.findByText('Missions'); + fireEvent.change(screen.getByLabelText('Assignments'), { + target: { value: '101' }, + }); + expect(screen.getByText(/must be at most 100/i)).toBeInTheDocument(); + }); + + it('shows inline error for negative', async () => { + setup(); + await screen.findByText('Missions'); + fireEvent.change(screen.getByLabelText('Optional'), { + target: { value: '-1' }, + }); + expect(screen.getByText(/must be at least 0/i)).toBeInTheDocument(); + }); + + it('Save dispatches updateGradebookWeights with current values', async () => { + setup(); + await screen.findByText('Missions'); + fireEvent.change(screen.getByLabelText('Optional'), { + target: { value: '40' }, + }); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + await waitFor(() => { + expect(operations.updateGradebookWeights).toHaveBeenCalledWith([ + { tabId: 10, weight: 50 }, + { tabId: 11, weight: 40 }, + ]); + }); + }); + + it('Cancel does not dispatch', async () => { + setup(); + await screen.findByText('Missions'); + fireEvent.change(screen.getByLabelText('Optional'), { + target: { value: '40' }, + }); + fireEvent.click(screen.getByRole('button', { name: /cancel/i })); + expect(operations.updateGradebookWeights).not.toHaveBeenCalled(); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/GradebookColumnTree.test.tsx b/client/app/bundles/course/gradebook/__tests__/GradebookColumnTree.test.tsx new file mode 100644 index 00000000000..261abaa3cdf --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/GradebookColumnTree.test.tsx @@ -0,0 +1,265 @@ +import { IntlProvider } from 'react-intl'; +import { fireEvent, render, screen } from '@testing-library/react'; + +import { buildAssessmentColumnId } from '../components/buildAssessmentColumnIds'; +import GradebookColumnTree from '../components/GradebookColumnTree'; +import type { AssessmentData, CategoryData, TabData } from '../types'; + +const categories: CategoryData[] = [{ id: 1, title: 'Cat A' }]; +const tabs: TabData[] = [{ id: 10, title: 'Tab 1', categoryId: 1 }]; +const assessments: AssessmentData[] = [ + { id: 100, title: 'Quiz 1', tabId: 10, maxGrade: 10 }, + { id: 101, title: 'Quiz 2', tabId: 10, maxGrade: 10 }, +]; + +const asnId100 = buildAssessmentColumnId(100); +const asnId101 = buildAssessmentColumnId(101); +const allIds = ['name', 'email', 'level', asnId100, asnId101]; + +const wrap = (node: JSX.Element): JSX.Element => ( + + {node} + +); + +describe('GradebookColumnTree', () => { + it('renders Student info and Grades branch labels', () => { + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + expect(screen.getByText('Student info')).toBeInTheDocument(); + expect(screen.getByText('Grades')).toBeInTheDocument(); + }); + + it('renders Gamification branch when gamificationEnabled', () => { + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + expect(screen.getByText('Gamification')).toBeInTheDocument(); + expect( + screen.getByRole('checkbox', { name: /^level$/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole('checkbox', { name: /^total xp$/i }), + ).toBeInTheDocument(); + }); + + it('hides Gamification branch when gamificationEnabled is false', () => { + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + expect(screen.queryByText('Gamification')).not.toBeInTheDocument(); + expect( + screen.queryByRole('checkbox', { name: /^level$/i }), + ).not.toBeInTheDocument(); + }); + + it('name checkbox is disabled and always checked', () => { + const visibility: Record = { + name: false, + email: true, + [asnId100]: true, + [asnId101]: true, + }; + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + const nameCheckbox = screen.getByRole('checkbox', { name: /^name/i }); + expect(nameCheckbox).toBeDisabled(); + expect(nameCheckbox).toBeChecked(); + }); + + it('non-name student info checkboxes are enabled and reflect visibility state', () => { + const visibility: Record = { + name: true, + email: false, + [asnId100]: true, + [asnId101]: true, + }; + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + const emailCheckbox = screen.getByRole('checkbox', { name: /^email$/i }); + expect(emailCheckbox).not.toBeDisabled(); + expect(emailCheckbox).not.toBeChecked(); + }); + + it('clicking a student info checkbox calls setVisible with its column id', () => { + const setVisible = jest.fn(); + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={setVisible} + tabs={tabs} + />, + ), + ); + fireEvent.click(screen.getByRole('checkbox', { name: /^email$/i })); + expect(setVisible).toHaveBeenCalledWith('email', expect.any(Boolean)); + }); + + it('renders Category, Tab, and assessment checkboxes', () => { + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + expect(screen.getByText('Cat A')).toBeInTheDocument(); + expect(screen.getByText('Tab 1')).toBeInTheDocument(); + expect( + screen.getByRole('checkbox', { name: /quiz 1/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole('checkbox', { name: /quiz 2/i }), + ).toBeInTheDocument(); + }); + + it('clicking an assessment checkbox calls setVisible with the single column id', () => { + const setVisible = jest.fn(); + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={setVisible} + tabs={tabs} + />, + ), + ); + fireEvent.click(screen.getByRole('checkbox', { name: /quiz 1/i })); + expect(setVisible).toHaveBeenCalledWith(asnId100, expect.any(Boolean)); + }); + + it('renders "Always included" chip next to the Name row', () => { + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + expect(screen.getByText('Always included')).toBeInTheDocument(); + }); + + it('does not render "Always included" chip next to email row', () => { + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + expect(screen.getAllByText('Always included')).toHaveLength(1); + }); + + it('Student info branch is indeterminate when some but not all student cols are visible', () => { + const visibility: Record = { + name: true, + email: false, + [asnId100]: true, + [asnId101]: true, + }; + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + expect( + screen.getByRole('checkbox', { name: /student info/i }), + ).toHaveAttribute('data-indeterminate', 'true'); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx b/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx new file mode 100644 index 00000000000..74a2a302622 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx @@ -0,0 +1,192 @@ +import { fireEvent, render, screen, waitFor } from 'test-utils'; + +import toast from 'lib/hooks/toast'; + +import fetchGradebook from '../operations'; +import GradebookIndex from '../pages/GradebookIndex'; + +jest.mock('../../container/CourseLoader', () => ({ + useCourseContext: (): { courseTitle: string; id: number } => ({ + courseTitle: 'Test Course', + id: 1, + }), +})); + +jest.mock('lib/hooks/toast', () => ({ + __esModule: true, + default: { error: jest.fn(), success: jest.fn() }, +})); + +jest.mock('../operations', () => ({ + __esModule: true, + default: jest.fn(() => (): Promise => Promise.resolve()), +})); + +const mockFetchGradebook = fetchGradebook as jest.Mock; + +const emptyState = { + gradebook: { + categories: [], + tabs: [], + assessments: [], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: false, + canManageWeights: false, + }, +}; + +const noStudentsState = { + gradebook: { + categories: [{ id: 1, title: 'Cat A' }], + tabs: [{ id: 10, title: 'Tab 1', categoryId: 1 }], + assessments: [{ id: 100, title: 'Quiz 1', tabId: 10, maxGrade: 10 }], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: false, + canManageWeights: false, + }, +}; + +const populatedState = { + gradebook: { + categories: [{ id: 1, title: 'Cat A' }], + tabs: [{ id: 10, title: 'Tab 1', categoryId: 1 }], + assessments: [{ id: 100, title: 'Quiz 1', tabId: 10, maxGrade: 10 }], + students: [ + { + id: 1, + name: 'Alice', + email: 'alice@example.com', + level: 3, + totalXp: 150, + }, + ], + submissions: [{ studentId: 1, assessmentId: 100, grade: 8 }], + gamificationEnabled: false, + weightedViewEnabled: false, + canManageWeights: false, + }, +}; + +const populatedStateWithGamification = { + gradebook: { + ...populatedState.gradebook, + gamificationEnabled: true, + }, +}; + +beforeEach(() => { + jest.clearAllMocks(); + mockFetchGradebook.mockReturnValue((): Promise => Promise.resolve()); +}); + +describe('GradebookIndex', () => { + it('shows loading indicator initially', () => { + render(, { state: emptyState }); + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('shows the gradebook table after data loads', async () => { + render(, { state: populatedState }); + expect( + await screen.findByRole('button', { name: /export/i }), + ).toBeInTheDocument(); + }); + + it('shows the page title', async () => { + render(, { state: populatedState }); + expect(await screen.findByText('Gradebook')).toBeInTheDocument(); + }); + + it('shows empty students message when there are no students', async () => { + render(, { state: noStudentsState }); + expect( + await screen.findByText('No students enrolled yet'), + ).toBeInTheDocument(); + }); + + it('shows empty students message when both assessments and students are absent', async () => { + render(, { state: emptyState }); + expect( + await screen.findByText('No students enrolled yet'), + ).toBeInTheDocument(); + }); + + it('shows error toast when fetch fails', async () => { + mockFetchGradebook.mockReturnValueOnce( + (): Promise => Promise.reject(new Error('Network error')), + ); + render(, { state: emptyState }); + await waitFor(() => expect(toast.error).toHaveBeenCalled()); + }); + + it('shows grade-only hint in column picker when gamification is disabled and no data cols selected', async () => { + render(, { state: populatedState }); + fireEvent.click( + await screen.findByRole('button', { name: /select columns/i }), + ); + expect( + await screen.findByText( + 'No grade columns selected - export will include student info only.', + ), + ).toBeInTheDocument(); + }); + + it('shows grade-and-gamification hint in column picker when gamification is enabled and no data cols selected', async () => { + render(, { state: populatedStateWithGamification }); + fireEvent.click( + await screen.findByRole('button', { name: /select columns/i }), + ); + fireEvent.click( + await screen.findByRole('checkbox', { name: /gamification/i }), + ); + expect( + await screen.findByText( + 'No grade or gamification columns selected - export will include student info only.', + ), + ).toBeInTheDocument(); + }); + + it('does not render view toggle when weightedViewEnabled is false', async () => { + render(, { state: populatedState }); + await screen.findByText('Gradebook'); + expect( + screen.queryByRole('button', { name: /by weight/i }), + ).not.toBeInTheDocument(); + }); + + it('renders view toggle when weightedViewEnabled is true', async () => { + render(, { + state: { + gradebook: { + ...populatedState.gradebook, + weightedViewEnabled: true, + canManageWeights: false, + }, + }, + }); + expect( + await screen.findByRole('button', { name: /all assessments/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /by weight/i }), + ).toBeInTheDocument(); + }); + + it('switches to By weight view on toggle click', async () => { + render(, { + state: { + gradebook: { + ...populatedState.gradebook, + weightedViewEnabled: true, + canManageWeights: false, + }, + }, + }); + fireEvent.click(await screen.findByRole('button', { name: /by weight/i })); + expect(screen.getByTestId('gradebook-weighted-table')).toBeInTheDocument(); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx b/client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx new file mode 100644 index 00000000000..94c3d90563d --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx @@ -0,0 +1,408 @@ +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor, within } from 'test-utils'; + +import GradebookTable from '../components/GradebookTable'; +import type { + AssessmentData, + CategoryData, + StudentData, + SubmissionData, + TabData, +} from '../types'; + +const categories: CategoryData[] = [{ id: 1, title: 'Cat A' }]; +const tabs: TabData[] = [{ id: 10, title: 'Tab 1', categoryId: 1 }]; +const assessments: AssessmentData[] = [ + { id: 100, title: 'Quiz 1', tabId: 10, maxGrade: 10 }, +]; +const students: StudentData[] = [ + { + id: 1, + name: 'Alice', + email: 'alice@example.com', + level: 3, + totalXp: 150, + }, + { + id: 2, + name: 'Bob', + email: 'bob@example.com', + level: 5, + totalXp: 300, + }, +]; +const submissions: SubmissionData[] = [ + { studentId: 1, assessmentId: 100, grade: 8 }, +]; + +const makeStudents = (n: number): StudentData[] => + Array.from({ length: n }, (_, i) => ({ + id: i + 1, + name: `Student ${i + 1}`, + email: `student${i + 1}@example.com`, + level: 1, + totalXp: 0, + })); + +const STORAGE_KEY = 'gradebook_columns_1'; + +interface RenderOptions { + gamificationEnabled?: boolean; +} + +const renderTable = ({ + gamificationEnabled = true, +}: RenderOptions = {}): void => { + render( + , + ); +}; + +const renderTableWithAssessmentVisible = ( + options: RenderOptions = {}, +): void => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + name: true, + email: true, + 'asn-100': true, + }), + ); + renderTable(options); +}; + +describe('GradebookTable', () => { + beforeEach(() => localStorage.clear()); + + it('renders both student names', async () => { + renderTableWithAssessmentVisible(); + expect(await screen.findByText('Alice')).toBeInTheDocument(); + expect(await screen.findByText('Bob')).toBeInTheDocument(); + }); + + it('renders two header rows (column titles and max marks)', async () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + name: true, + email: true, + + 'asn-100': true, + }), + ); + const { container } = render( + , + ); + await screen.findByText('Alice'); + expect(container.querySelectorAll('thead tr')).toHaveLength(2); + }); + + it('shows Select Columns button and Export button', async () => { + renderTableWithAssessmentVisible(); + await screen.findByText('Alice'); + expect( + screen.getByRole('button', { name: /select columns/i }), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /export/i })).toBeInTheDocument(); + }); + + describe('export button label reflects selection', () => { + it('shows "Export all rows" when no rows are selected', async () => { + renderTableWithAssessmentVisible(); + await screen.findByText('Alice'); + expect( + screen.getByRole('button', { name: /export all rows/i }), + ).toBeInTheDocument(); + }); + + it('shows tooltip "all rows will be exported" when no rows are selected', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + const exportBtn = await screen.findByRole('button', { + name: /export all rows/i, + }); + await user.hover(exportBtn); + expect( + await screen.findByText(/all rows will be exported/i), + ).toBeInTheDocument(); + }); + + it('hides the tooltip when a row is selected', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + const checkboxes = await screen.findAllByRole('checkbox'); + await user.click(checkboxes[1]); + const exportBtn = await screen.findByRole('button', { + name: /export 1 row/i, + }); + await user.hover(exportBtn); + expect( + screen.queryByText(/all rows will be exported/i), + ).not.toBeInTheDocument(); + }); + + it('shows "Export 1 row" when one row is selected', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + const checkboxes = await screen.findAllByRole('checkbox'); + await user.click(checkboxes[1]); + await waitFor(() => + expect( + screen.getByRole('button', { name: /export 1 row/i }), + ).toBeInTheDocument(), + ); + }); + + it('shows "Export all rows" when all rows are selected via the corner checkbox', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + const checkboxes = await screen.findAllByRole('checkbox'); + await user.click(checkboxes[0]); + await waitFor(() => + expect( + screen.getByRole('button', { name: /export all rows/i }), + ).toBeInTheDocument(), + ); + expect( + screen.queryByRole('button', { name: /export \d+ row/i }), + ).not.toBeInTheDocument(); + }); + }); + + it('shows the Max Marks header row', async () => { + renderTableWithAssessmentVisible(); + expect(await screen.findByText('Max Marks')).toBeInTheDocument(); + }); + + it('renders row selection checkboxes', async () => { + renderTableWithAssessmentVisible(); + await screen.findByText('Alice'); + expect(screen.getAllByRole('checkbox').length).toBeGreaterThanOrEqual(2); + }); + + describe('row selection', () => { + it('keeps search input visible after selecting a row', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + const checkboxes = await screen.findAllByRole('checkbox'); + await user.click(checkboxes[1]); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('keeps Export button visible after selecting a row', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + const checkboxes = await screen.findAllByRole('checkbox'); + await user.click(checkboxes[1]); + expect( + screen.getByRole('button', { name: /export/i }), + ).toBeInTheDocument(); + }); + }); + + it('does not show assessment columns in the table by default', async () => { + renderTable(); + await screen.findByText('Alice'); + expect(screen.queryByText('Quiz 1')).not.toBeInTheDocument(); + }); + + it('shows gamification columns by default when gamification is enabled', async () => { + renderTable({ gamificationEnabled: true }); + expect(await screen.findByText('Level')).toBeInTheDocument(); + expect(screen.getByText('Total XP')).toBeInTheDocument(); + }); + + describe('gamification columns', () => { + it('shows level and totalXp in the column picker when gamification is enabled', async () => { + const user = userEvent.setup(); + renderTable({ gamificationEnabled: true }); + const selectColumnsBtn = await screen.findByRole('button', { + name: /select columns/i, + }); + await user.click(selectColumnsBtn); + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText('Level')).toBeInTheDocument(); + expect(within(dialog).getByText('Total XP')).toBeInTheDocument(); + }); + }); + + describe('locked name column', () => { + it('name is always visible even when localStorage sets it to false', async () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + name: false, + email: true, + + 'asn-100': true, + }), + ); + renderTable(); + await waitFor(() => + expect(screen.getByText('Alice')).toBeInTheDocument(), + ); + }); + }); + + describe('gamification disabled', () => { + it('level and totalXp absent from table headers when gamification is disabled', async () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + name: true, + email: true, + + level: true, + totalXp: true, + 'asn-100': true, + }), + ); + renderTable({ gamificationEnabled: false }); + await screen.findByText('Alice'); + expect(screen.queryByText('Level')).not.toBeInTheDocument(); + expect(screen.queryByText('Total XP')).not.toBeInTheDocument(); + }); + }); + + it('shows the table when gamification columns are visible and assessments are deselected', async () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ 'asn-100': false })); + renderTable({ gamificationEnabled: true }); + expect(await screen.findByText('Alice')).toBeInTheDocument(); + expect(screen.getByRole('table')).toBeInTheDocument(); + }); + + it('export button is always enabled regardless of which columns are selected', async () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ 'asn-100': false })); + renderTable({ gamificationEnabled: false }); + await screen.findByText('Alice'); + expect(screen.getByRole('button', { name: /export/i })).not.toBeDisabled(); + }); + + it('shows the table (not an empty state) when all assessments are deselected', async () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ 'asn-100': false })); + renderTable({ gamificationEnabled: false }); + expect(await screen.findByRole('table')).toBeInTheDocument(); + expect(await screen.findByText('Alice')).toBeInTheDocument(); + }); + + it('shows the table when all optional columns are deselected with gamification', async () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ 'asn-100': false, level: false, totalXp: false }), + ); + renderTable({ gamificationEnabled: true }); + expect(await screen.findByRole('table')).toBeInTheDocument(); + expect(await screen.findByText('Alice')).toBeInTheDocument(); + }); + + it('shows pagination when all assessments are deselected', async () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ 'asn-100': false })); + renderTable({ gamificationEnabled: false }); + await screen.findByText('Alice'); + expect(screen.getByText(/rows per page/i)).toBeInTheDocument(); + }); + + it('shows the table with assessment columns when restored from localStorage', async () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + name: true, + email: true, + + 'asn-100': true, + }), + ); + renderTable(); + expect(await screen.findByText('Quiz 1')).toBeInTheDocument(); + }); + + describe('search', () => { + it('filters by name', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + const input = await screen.findByRole('textbox'); + await user.type(input, 'Alice'); + await waitFor(() => + expect(screen.queryByText('Bob')).not.toBeInTheDocument(), + ); + expect(screen.getByText('Alice')).toBeInTheDocument(); + }); + + it('filters by email', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + const input = await screen.findByRole('textbox'); + await user.type(input, 'bob@example.com'); + await waitFor(() => + expect(screen.queryByText('Alice')).not.toBeInTheDocument(), + ); + expect(screen.getByText('Bob')).toBeInTheDocument(); + }); + }); + + describe('cross-page selection', () => { + it('export label reflects selection count across pages', async () => { + const user = userEvent.setup(); + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + name: true, + email: true, + + 'asn-100': true, + }), + ); + render( + , + ); + + const checkboxes = await screen.findAllByRole('checkbox'); + await user.click(checkboxes[1]); + await waitFor(() => + expect( + screen.getByRole('button', { name: /export 1 row/i }), + ).toBeInTheDocument(), + ); + + await user.click( + screen.getByRole('button', { name: /go to next page/i }), + ); + await waitFor(() => + expect(screen.getByText('Student 11')).toBeInTheDocument(), + ); + expect(screen.queryByText('Student 1')).not.toBeInTheDocument(); + + expect( + screen.getByRole('button', { name: /export 1 row/i }), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/GradebookWeightedTable.test.tsx b/client/app/bundles/course/gradebook/__tests__/GradebookWeightedTable.test.tsx new file mode 100644 index 00000000000..c1d34bccb85 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/GradebookWeightedTable.test.tsx @@ -0,0 +1,146 @@ +import userEvent from '@testing-library/user-event'; +import { render, screen, within } from 'test-utils'; + +import type { + AssessmentData, + CategoryData, + StudentData, + SubmissionData, + TabData, +} from '../types'; +import GradebookWeightedTable from '../components/GradebookWeightedTable'; + +// Suppress MUI dialog rendering noise in jsdom +jest.mock('../components/ConfigureWeightsDialog', () => ({ + __esModule: true, + default: () => null, +})); + +const categories: CategoryData[] = [{ id: 1, title: 'Missions' }]; +const tabs: TabData[] = [ + { id: 10, title: 'Assignments', categoryId: 1, gradebookWeight: 60 }, + { id: 11, title: 'Optional', categoryId: 1, gradebookWeight: 40 }, +]; +const assessments: AssessmentData[] = [ + { id: 100, title: 'Quiz 1', tabId: 10, maxGrade: 100 }, + { id: 101, title: 'Quiz 2', tabId: 11, maxGrade: 50 }, +]; +const students: StudentData[] = [ + { id: 1, name: 'Alice', email: 'alice@example.com', level: 3, totalXp: 150 }, +]; +const submissions: SubmissionData[] = [ + { studentId: 1, assessmentId: 100, grade: 80 }, + { studentId: 1, assessmentId: 101, grade: 40 }, +]; + +const defaultProps = { + categories, + tabs, + assessments, + students, + submissions, + canManageWeights: true, +}; + +const renderTable = (props = {}) => + render(); + +describe('', () => { + it('renders category header in row 1', async () => { + renderTable(); + expect(await screen.findByText('Missions')).toBeInTheDocument(); + }); + + it('renders tab titles in row 2', async () => { + renderTable(); + await screen.findByText('Missions'); + expect(screen.getByText('Assignments')).toBeInTheDocument(); + expect(screen.getByText('Optional')).toBeInTheDocument(); + }); + + it('renders "X% of grade" subheaders in row 3', async () => { + renderTable(); + await screen.findByText('Missions'); + expect(screen.getByText('60% of grade')).toBeInTheDocument(); + expect(screen.getByText('40% of grade')).toBeInTheDocument(); + }); + + it('shows "100% total" in Total subheader when weights sum to 100', async () => { + renderTable(); + await screen.findByText('Missions'); + expect(screen.getByText('100% total')).toBeInTheDocument(); + }); + + it('shows warning text in Total subheader when weights do not sum to 100', async () => { + const tabsUnbalanced: TabData[] = [ + { id: 10, title: 'Assignments', categoryId: 1, gradebookWeight: 60 }, + { id: 11, title: 'Optional', categoryId: 1, gradebookWeight: 30 }, + ]; + renderTable({ tabs: tabsUnbalanced }); + await screen.findByText('Missions'); + // subheader should show 90% total with a warning indicator + expect(screen.getByText(/90%\s*total/)).toBeInTheDocument(); + }); + + it('computes and displays tab subtotals for a student', async () => { + renderTable(); + await screen.findByText('Alice'); + // tab 10: 80/100 = 80.00%, tab 11: 40/50 = 80.00% — both show the same value + const cells = screen.getAllByText('80.00%'); + expect(cells.length).toBeGreaterThanOrEqual(2); + }); + + it('computes and displays weighted total for a student', async () => { + renderTable(); + await screen.findByText('Alice'); + // total = (60 * 0.8 + 40 * 0.8) / 100 = 0.8 = 80.00% + const allCells = screen.getAllByText('80.00%'); + expect(allCells.length).toBeGreaterThanOrEqual(2); + }); + + it('shows — for student with no graded submissions in a tab', async () => { + renderTable({ submissions: [] }); + await screen.findByText('Alice'); + // No submissions → all dashes + expect(screen.getAllByText('—').length).toBeGreaterThanOrEqual(1); + }); + + it('recomputes when Treat Ungraded as 0 is toggled', async () => { + const user = userEvent.setup(); + renderTable({ + submissions: [{ studentId: 1, assessmentId: 100, grade: 80 }], + }); + await screen.findByText('Alice'); + // Before toggle: tab 11 ungraded → dash + expect(screen.getAllByText('—').length).toBeGreaterThanOrEqual(1); + // Toggle on + const toggle = screen.getByRole('checkbox', { name: /treat ungraded as 0/i }); + await user.click(toggle); + // After toggle: tab 11 = 0/50 = 0.00% + expect(screen.getByText('0.00%')).toBeInTheDocument(); + }); + + it('shows empty state banner when all weights are 0', async () => { + const zeroTabs: TabData[] = [ + { id: 10, title: 'Assignments', categoryId: 1, gradebookWeight: 0 }, + ]; + renderTable({ tabs: zeroTabs }); + await screen.findByText(/no tab weights configured/i); + }); + + it('shows Configure Weights button when canManageWeights = true', async () => { + renderTable({ canManageWeights: true }); + await screen.findByText('Missions'); + expect( + screen.getByRole('button', { name: /configure weights/i }), + ).toBeInTheDocument(); + }); + + it('hides Configure Weights button when canManageWeights = false', async () => { + renderTable({ canManageWeights: false }); + await screen.findByText('Missions'); + expect( + screen.queryByRole('button', { name: /configure weights/i }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/computeWeighted.test.ts b/client/app/bundles/course/gradebook/__tests__/computeWeighted.test.ts new file mode 100644 index 00000000000..093ca6599a9 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/computeWeighted.test.ts @@ -0,0 +1,154 @@ +import { computeTabSubtotal, computeStudentTotal, sumWeights } from '../computeWeighted'; + +const assessments = [ + { id: 1, tabId: 10, maxGrade: 100, title: 'A' }, + { id: 2, tabId: 10, maxGrade: 50, title: 'B' }, + { id: 3, tabId: 20, maxGrade: 100, title: 'C' }, +]; + +const subs = ( + entries: { studentId: number; assessmentId: number; grade: number | null }[], +) => entries; + +describe('computeTabSubtotal', () => { + it('returns null when tab has no assessments', () => { + expect( + computeTabSubtotal({ + studentId: 1, + tab: { id: 999, title: 'X', categoryId: 0 }, + assessments, + submissions: [], + treatUngradedAsZero: false, + }), + ).toBeNull(); + }); + + it('returns null when student has no graded submissions and toggle off', () => { + expect( + computeTabSubtotal({ + studentId: 1, + tab: { id: 10, title: 'M', categoryId: 0 }, + assessments, + submissions: [], + treatUngradedAsZero: false, + }), + ).toBeNull(); + }); + + it('sum-of-points across graded only when toggle off', () => { + expect( + computeTabSubtotal({ + studentId: 1, + tab: { id: 10, title: 'M', categoryId: 0 }, + assessments, + submissions: subs([ + { studentId: 1, assessmentId: 1, grade: 80 }, + // assessment 2 ungraded + ]), + treatUngradedAsZero: false, + }), + ).toBeCloseTo(0.8); + }); + + it('includes ungraded as zero when toggle on', () => { + expect( + computeTabSubtotal({ + studentId: 1, + tab: { id: 10, title: 'M', categoryId: 0 }, + assessments, + submissions: subs([{ studentId: 1, assessmentId: 1, grade: 80 }]), + treatUngradedAsZero: true, + }), + ).toBeCloseTo(80 / 150); + }); +}); + +describe('computeStudentTotal', () => { + const tabs = [ + { id: 10, title: 'M', categoryId: 0, gradebookWeight: 60 }, + { id: 20, title: 'T', categoryId: 0, gradebookWeight: 40 }, + ]; + + it('weighted average over weighted tabs', () => { + const total = computeStudentTotal({ + studentId: 1, + tabs, + assessments, + submissions: subs([ + { studentId: 1, assessmentId: 1, grade: 80 }, + { studentId: 1, assessmentId: 2, grade: 50 }, + { studentId: 1, assessmentId: 3, grade: 90 }, + ]), + treatUngradedAsZero: false, + }); + // tab 10 subtotal = 130/150; tab 20 subtotal = 90/100 + // total = (60*(130/150) + 40*0.9) / 100 + expect(total).toBeCloseTo((60 * (130 / 150) + 40 * 0.9) / 100); + }); + + it('excludes tabs with weight 0', () => { + const total = computeStudentTotal({ + studentId: 1, + tabs: [ + { id: 10, title: 'M', categoryId: 0, gradebookWeight: 100 }, + { id: 20, title: 'T', categoryId: 0, gradebookWeight: 0 }, + ], + assessments, + submissions: subs([ + { studentId: 1, assessmentId: 1, grade: 80 }, + { studentId: 1, assessmentId: 2, grade: 50 }, + ]), + treatUngradedAsZero: false, + }); + expect(total).toBeCloseTo(130 / 150); + }); + + it('returns null when no weighted tab contributes', () => { + expect( + computeStudentTotal({ + studentId: 1, + tabs: [{ id: 10, title: 'M', categoryId: 0, gradebookWeight: 0 }], + assessments, + submissions: [], + treatUngradedAsZero: false, + }), + ).toBeNull(); + }); + + it('normalizes when weights do not sum to 100', () => { + const total = computeStudentTotal({ + studentId: 1, + tabs: [ + { id: 10, title: 'M', categoryId: 0, gradebookWeight: 60 }, + { id: 20, title: 'T', categoryId: 0, gradebookWeight: 30 }, + ], + assessments, + submissions: subs([ + { studentId: 1, assessmentId: 1, grade: 80 }, + { studentId: 1, assessmentId: 2, grade: 50 }, + { studentId: 1, assessmentId: 3, grade: 90 }, + ]), + treatUngradedAsZero: false, + }); + // weightSum = 90; total = (60*(130/150) + 30*0.9)/90 + expect(total).toBeCloseTo((60 * (130 / 150) + 30 * 0.9) / 90); + }); +}); + +describe('sumWeights', () => { + it('sums gradebookWeight across tabs', () => { + const tabs = [ + { id: 10, title: 'M', categoryId: 0, gradebookWeight: 60 }, + { id: 20, title: 'T', categoryId: 0, gradebookWeight: 40 }, + ]; + expect(sumWeights(tabs)).toBe(100); + }); + + it('treats undefined gradebookWeight as 0', () => { + const tabs = [ + { id: 10, title: 'M', categoryId: 0 }, + { id: 20, title: 'T', categoryId: 0, gradebookWeight: 50 }, + ]; + expect(sumWeights(tabs)).toBe(50); + }); +}); diff --git a/client/app/bundles/course/gradebook/components/ConfigureWeightsDialog.tsx b/client/app/bundles/course/gradebook/components/ConfigureWeightsDialog.tsx new file mode 100644 index 00000000000..1d78030aca3 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/ConfigureWeightsDialog.tsx @@ -0,0 +1,193 @@ +import { FC, useEffect, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { + Alert, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Stack, + TextField, + Typography, +} from '@mui/material'; +import type { CategoryData, TabData } from 'types/course/gradebook'; + +import { useAppDispatch } from 'lib/hooks/store'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { updateGradebookWeights } from '../operations'; + +interface Props { + open: boolean; + onClose: () => void; + categories: CategoryData[]; + tabs: TabData[]; +} + +const translations = defineMessages({ + title: { + id: 'course.gradebook.ConfigureWeightsDialog.title', + defaultMessage: 'Configure tab weights', + }, + description: { + id: 'course.gradebook.ConfigureWeightsDialog.description', + defaultMessage: + 'Set how much each tab contributes to the total grade. Weights should sum to 100.', + }, + totalLabel: { + id: 'course.gradebook.ConfigureWeightsDialog.totalLabel', + defaultMessage: 'Total: {sum}%', + }, + sumWarning: { + id: 'course.gradebook.ConfigureWeightsDialog.sumWarning', + defaultMessage: + 'Weights do not sum to 100. Saving is allowed; Total may be inaccurate.', + }, + cancel: { + id: 'course.gradebook.ConfigureWeightsDialog.cancel', + defaultMessage: 'Cancel', + }, + save: { + id: 'course.gradebook.ConfigureWeightsDialog.save', + defaultMessage: 'Save', + }, + saveSuccess: { + id: 'course.gradebook.ConfigureWeightsDialog.saveSuccess', + defaultMessage: 'Weights saved.', + }, + saveFailure: { + id: 'course.gradebook.ConfigureWeightsDialog.saveFailure', + defaultMessage: 'Failed to save weights — try again.', + }, + errorMin: { + id: 'course.gradebook.ConfigureWeightsDialog.errorMin', + defaultMessage: 'Value must be at least 0', + }, + errorMax: { + id: 'course.gradebook.ConfigureWeightsDialog.errorMax', + defaultMessage: 'Value must be at most 100', + }, + errorInteger: { + id: 'course.gradebook.ConfigureWeightsDialog.errorInteger', + defaultMessage: 'Value must be a whole number', + }, +}); + +const validate = (value: number): keyof typeof translations | null => { + if (!Number.isInteger(value)) return 'errorInteger'; + if (value < 0) return 'errorMin'; + if (value > 100) return 'errorMax'; + return null; +}; + +const ConfigureWeightsDialog: FC = ({ + open, + onClose, + categories, + tabs, +}) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const [weights, setWeights] = useState>(() => + Object.fromEntries(tabs.map((tb) => [tb.id, tb.gradebookWeight ?? 0])), + ); + const [submitting, setSubmitting] = useState(false); + + // Re-sync from store whenever dialog opens (handles stale state after external updates) + useEffect(() => { + if (open) { + setWeights( + Object.fromEntries(tabs.map((tb) => [tb.id, tb.gradebookWeight ?? 0])), + ); + } + }, [open, tabs]); + + const sum = Object.values(weights).reduce((acc, w) => acc + w, 0); + const hasInvalid = Object.values(weights).some((w) => validate(w) !== null); + + const handleChange = (tabId: number, raw: string): void => { + const parsed = raw === '' ? 0 : Number(raw); + setWeights((prev) => ({ ...prev, [tabId]: parsed })); + }; + + const handleSave = async (): Promise => { + if (hasInvalid) return; + setSubmitting(true); + try { + await dispatch( + updateGradebookWeights( + tabs.map((tb) => ({ tabId: tb.id, weight: weights[tb.id] ?? 0 })), + ), + ); + toast.success(t(translations.saveSuccess)); + onClose(); + } catch { + toast.error(t(translations.saveFailure)); + } finally { + setSubmitting(false); + } + }; + + return ( + + {t(translations.title)} + + + {t(translations.description)} + + + {categories.map((cat) => ( +
+ {cat.title} + + {tabs + .filter((tb) => tb.categoryId === cat.id) + .map((tb) => { + const value = weights[tb.id] ?? 0; + const errKey = validate(value); + return ( + handleChange(tb.id, e.target.value)} + size="small" + type="number" + value={value} + /> + ); + })} + +
+ ))} +
+ + {t(translations.totalLabel, { sum })} + + {sum !== 100 && ( + + {t(translations.sumWarning)} + + )} +
+ + + + +
+ ); +}; + +export default ConfigureWeightsDialog; diff --git a/client/app/bundles/course/gradebook/components/GradebookColumnTree.tsx b/client/app/bundles/course/gradebook/components/GradebookColumnTree.tsx new file mode 100644 index 00000000000..35621ae3421 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/GradebookColumnTree.tsx @@ -0,0 +1,231 @@ +import { useMemo } from 'react'; +import { defineMessages } from 'react-intl'; +import { Chip } from '@mui/material'; + +import IndentedCheckbox from 'lib/components/core/IndentedCheckbox'; +import { + ColumnPickerRenderCtx, + ColumnPickerTreeGroup, +} from 'lib/components/table'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { + GAMIFICATION_COL_IDS, + type GamificationColId, + STUDENT_INFO_COL_IDS, + type StudentInfoColId, +} from '../constants'; +import type { AssessmentData, CategoryData, TabData } from '../types'; + +import { + buildAssessmentColumnId, + parseAssessmentColumnId, +} from './buildAssessmentColumnIds'; + +const translations = defineMessages({ + studentInfo: { + id: 'course.gradebook.GradebookColumnTree.studentInfo', + defaultMessage: 'Student info', + }, + name: { + id: 'course.gradebook.GradebookColumnTree.name', + defaultMessage: 'Name', + }, + email: { + id: 'course.gradebook.GradebookColumnTree.email', + defaultMessage: 'Email', + }, + level: { + id: 'course.gradebook.GradebookColumnTree.level', + defaultMessage: 'Level', + }, + totalXp: { + id: 'course.gradebook.GradebookColumnTree.totalXp', + defaultMessage: 'Total XP', + }, + gamification: { + id: 'course.gradebook.GradebookColumnTree.gamification', + defaultMessage: 'Gamification', + }, + grades: { + id: 'course.gradebook.GradebookColumnTree.grades', + defaultMessage: 'Grades', + }, + alwaysIncluded: { + id: 'course.gradebook.GradebookColumnTree.alwaysIncluded', + defaultMessage: 'Always included', + }, +}); + +interface GradebookColumnTreeProps extends ColumnPickerRenderCtx { + categories: CategoryData[]; + tabs: TabData[]; + assessments: AssessmentData[]; + gamificationEnabled: boolean; +} + +const STUDENT_ALL_IDS = [...STUDENT_INFO_COL_IDS]; +const GAMIFICATION_ALL_IDS = [...GAMIFICATION_COL_IDS]; + +const GradebookColumnTree = ({ + isVisible, + setVisible, + setManyVisible, + categories, + tabs, + assessments, + gamificationEnabled, +}: GradebookColumnTreeProps): JSX.Element => { + const { t } = useTranslation(); + const ctx: ColumnPickerRenderCtx = { isVisible, setVisible, setManyVisible }; + + const asnIds = useMemo( + () => assessments.map((a) => buildAssessmentColumnId(a.id)), + [assessments], + ); + + const tabAsnIds = useMemo(() => { + const map = new Map(); + assessments.forEach((a) => { + const existing = map.get(a.tabId) ?? []; + map.set(a.tabId, [...existing, buildAssessmentColumnId(a.id)]); + }); + return map; + }, [assessments]); + + const catTabs = useMemo(() => { + const map = new Map(); + tabs.forEach((tab) => { + const existing = map.get(tab.categoryId) ?? []; + map.set(tab.categoryId, [...existing, tab]); + }); + return map; + }, [tabs]); + + const asnById = useMemo( + () => new Map(assessments.map((a) => [a.id, a])), + [assessments], + ); + + const catAsnIds = useMemo(() => { + const map = new Map(); + tabs.forEach((tab) => { + const tabIds = tabAsnIds.get(tab.id) ?? []; + const existing = map.get(tab.categoryId) ?? []; + map.set(tab.categoryId, [...existing, ...tabIds]); + }); + return map; + }, [tabs, tabAsnIds]); + + return ( +
+ + {STUDENT_INFO_COL_IDS.map((id: StudentInfoColId) => + id === 'name' ? ( + + {t(translations[id])} + + + } + /> + ) : ( + setVisible(id, e.target.checked)} + /> + ), + )} + + + {gamificationEnabled && ( + + {GAMIFICATION_COL_IDS.map((id: GamificationColId) => ( + setVisible(id, e.target.checked)} + /> + ))} + + )} + + + {categories.map((cat) => { + const catIds = catAsnIds.get(cat.id) ?? []; + const thisCatTabs = catTabs.get(cat.id) ?? []; + return ( + + {thisCatTabs.map((tab) => { + const tabIds = tabAsnIds.get(tab.id) ?? []; + return ( + + {tabIds.map((id) => { + const asnId = parseAssessmentColumnId(id); + const asn = + asnId !== null ? asnById.get(asnId) : undefined; + if (!asn) return null; + return ( + setVisible(id, e.target.checked)} + /> + ); + })} + + ); + })} + + ); + })} + +
+ ); +}; + +export default GradebookColumnTree; diff --git a/client/app/bundles/course/gradebook/components/GradebookTable.tsx b/client/app/bundles/course/gradebook/components/GradebookTable.tsx new file mode 100644 index 00000000000..e62e7da1882 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/GradebookTable.tsx @@ -0,0 +1,642 @@ +import { + forwardRef, + useCallback, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { defineMessages } from 'react-intl'; +import { + Checkbox, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tooltip, +} from '@mui/material'; +import { flexRender } from '@tanstack/react-table'; + +import type { + ColumnPickerRenderCtx, + ColumnTemplate, +} from 'lib/components/table/builder'; +import MuiTablePagination from 'lib/components/table/MuiTableAdapter/MuiTablePagination'; +import MuiTableToolbar from 'lib/components/table/MuiTableAdapter/MuiTableToolbar'; +import useTanStackTableBuilder from 'lib/components/table/TanStackTableBuilder'; +import { + DEFAULT_MINI_TABLE_ROWS_PER_PAGE, + DEFAULT_TABLE_ROWS_PER_PAGE, +} from 'lib/constants/sharedConstants'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { GAMIFICATION_COL_IDS } from '../constants'; +import type { + AssessmentData, + CategoryData, + StudentData, + SubmissionData, + TabData, +} from '../types'; + +import { + buildAssessmentColumnId, + parseAssessmentColumnId, +} from './buildAssessmentColumnIds'; +import GradebookColumnTree from './GradebookColumnTree'; + +const COL_WIDTHS = { + name: 160, + email: 220, + level: 70, + totalXp: 70, + assessment: 150, +} as const; + +const CHECKBOX_WIDTH = 56; + +const getColWidth = (id: string): number => + COL_WIDTHS[id as keyof typeof COL_WIDTHS] ?? COL_WIDTHS.assessment; + +const isLeftAligned = (id: string): boolean => id === 'name' || id === 'email'; + +const translations = defineMessages({ + searchStudents: { + id: 'course.gradebook.GradebookIndex.searchStudents', + defaultMessage: 'Search by name or email', + }, + exportButton: { + id: 'course.gradebook.GradebookIndex.exportButton', + defaultMessage: 'Export all rows', + }, + exportRows: { + id: 'course.gradebook.GradebookIndex.exportRows', + defaultMessage: 'Export {count, plural, one {# row} other {# rows}}', + }, + exportAllTooltip: { + id: 'course.gradebook.GradebookIndex.exportAllTooltip', + defaultMessage: 'No rows selected - all rows will be exported.', + }, + selectColumns: { + id: 'course.gradebook.GradebookIndex.selectColumns', + defaultMessage: 'Select Columns', + }, + dialogTitle: { + id: 'course.gradebook.GradebookIndex.dialogTitle', + defaultMessage: 'Select columns', + }, + applyAndExport: { + id: 'course.gradebook.GradebookIndex.applyAndExport', + defaultMessage: 'Apply and Export', + }, + name: { + id: 'course.gradebook.GradebookColumnTree.name', + defaultMessage: 'Name', + }, + email: { + id: 'course.gradebook.GradebookColumnTree.email', + defaultMessage: 'Email', + }, + level: { + id: 'course.gradebook.GradebookColumnTree.level', + defaultMessage: 'Level', + }, + totalXp: { + id: 'course.gradebook.GradebookColumnTree.totalXp', + defaultMessage: 'Total XP', + }, + maxMarks: { + id: 'course.gradebook.GradebookTable.maxMarks', + defaultMessage: 'Max Marks', + }, + noDataColumnsHint: { + id: 'course.gradebook.GradebookTable.noDataColumnsHint', + defaultMessage: + 'No grade columns selected - export will include student info only.', + }, + noDataColumnsHintWithGamification: { + id: 'course.gradebook.GradebookTable.noDataColumnsHintWithGamification', + defaultMessage: + 'No grade or gamification columns selected - export will include student info only.', + }, +}); + +const HeaderLabel = forwardRef< + HTMLSpanElement, + { text: string; onSingleLine: (fits: boolean) => void } +>(({ text, onSingleLine }, forwardedRef): JSX.Element => { + const innerRef = useRef(null); + const [display, setDisplay] = useState(text); + + useLayoutEffect(() => { + const el = innerRef.current; + if (!el) return; + + const lh = parseFloat(getComputedStyle(el).lineHeight) || 20; + const oneLineH = lh + 1; + const twoLineH = lh * 2 + 1; + + el.textContent = text; + + if (el.scrollHeight <= oneLineH) { + onSingleLine(true); + setDisplay(text); + return; + } + + onSingleLine(false); + + if (el.scrollHeight <= twoLineH) { + setDisplay(text); + return; + } + + let lo = 1; + let hi = text.length; + let best = `${text[0]}…`; + while (lo <= hi) { + const mid = Math.floor((lo + hi) / 2); + const candidate = `${text.slice(0, mid)}…`; + el.textContent = candidate; + if (el.scrollHeight <= twoLineH) { + best = candidate; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + // Ensure DOM reflects `best` before React reconciles — the loop's last + // el.textContent assignment may be a too-long candidate, not `best`. + el.textContent = best; + setDisplay(best); + }, [text, onSingleLine]); + + return ( + { + innerRef.current = node; + if (typeof forwardedRef === 'function') forwardedRef(node); + else if (forwardedRef) forwardedRef.current = node; + }} + style={{ display: 'block' }} + > + {display} + + ); +}); +HeaderLabel.displayName = 'HeaderLabel'; + +interface GradebookRow { + studentId: number; + name: string; + email: string; + level: number; + totalXp: number; + grades: Partial>; +} + +interface GradebookTableProps { + categories: CategoryData[]; + tabs: TabData[]; + assessments: AssessmentData[]; + students: StudentData[]; + submissions: SubmissionData[]; + courseTitle: string; + courseId: number; + gamificationEnabled: boolean; +} + +const GradebookTable = ({ + categories, + tabs, + assessments, + students, + submissions, + courseTitle, + courseId, + gamificationEnabled, +}: GradebookTableProps): JSX.Element => { + const { t } = useTranslation(); + + const submissionsByStudent = useMemo(() => { + const map = new Map(); + submissions.forEach((s) => { + const existing = map.get(s.studentId); + if (existing) { + existing.push(s); + } else { + map.set(s.studentId, [s]); + } + }); + return map; + }, [submissions]); + + const rows = useMemo( + () => + students.map((student) => { + const subs = submissionsByStudent.get(student.id) ?? []; + const grades: Partial> = {}; + assessments.forEach((a) => { + const sub = subs.find((s) => s.assessmentId === a.id); + if (sub != null) grades[a.id] = sub.grade; + }); + return { + studentId: student.id, + name: student.name, + email: student.email, + level: student.level, + totalXp: student.totalXp, + grades, + }; + }), + [students, assessments, submissionsByStudent], + ); + + const columns = useMemo[]>(() => { + const cols: ColumnTemplate[] = [ + { + id: 'name', + title: t(translations.name), + of: 'name', + cell: (row) => row.name, + csvDownloadable: true, + searchable: true, + searchProps: { getValue: (row) => row.name }, + }, + { + id: 'email', + title: t(translations.email), + of: 'email', + cell: (row) => row.email, + csvDownloadable: true, + searchable: true, + }, + ]; + + if (gamificationEnabled) { + cols.push({ + id: 'level', + title: t(translations.level), + of: 'level', + cell: (row) => row.level, + csvDownloadable: true, + }); + cols.push({ + id: 'totalXp', + title: t(translations.totalXp), + of: 'totalXp', + cell: (row) => row.totalXp, + csvDownloadable: true, + }); + } + + assessments.forEach((asn) => { + const colId = buildAssessmentColumnId(asn.id); + cols.push({ + id: colId, + title: asn.title, + accessorFn: (row) => row.grades[asn.id], + cell: (row) => { + const grade = row.grades[asn.id]; + if (grade === undefined) return '—'; + if (grade === null) return ''; + return grade; + }, + csvDownloadable: true, + defaultVisible: false, + }); + }); + return cols; + }, [assessments, gamificationEnabled, t]); + + const assessmentMaxGrades = useMemo( + () => new Map(assessments.map((a) => [a.id, a.maxGrade])), + [assessments], + ); + + const dataColumnIds = useMemo( + () => [ + ...assessments.map((a) => buildAssessmentColumnId(a.id)), + ...GAMIFICATION_COL_IDS, + ], + [assessments], + ); + + const columnPicker = useMemo( + () => ({ + renderTree: (ctx: ColumnPickerRenderCtx) => ( + + ), + locked: ['name'], + triggerLabel: t(translations.selectColumns), + dialogTitle: t(translations.dialogTitle), + exportLabel: t(translations.applyAndExport), + onExport: 'csv' as const, + getExtraHeaderRows: (colIds): string[][] => { + const hasAssessments = colIds.some( + (id) => parseAssessmentColumnId(id) !== null, + ); + if (!hasAssessments) return []; + return [ + colIds.map((id) => { + if (id === 'name') return t(translations.maxMarks); + const asnId = parseAssessmentColumnId(id); + if (asnId !== null) + return String(assessmentMaxGrades.get(asnId) ?? ''); + return ''; + }), + ]; + }, + storageKey: `gradebook_columns_${courseId}`, + dataColumnIds, + noDataColumnsHint: gamificationEnabled + ? t(translations.noDataColumnsHintWithGamification) + : t(translations.noDataColumnsHint), + }), + [ + assessments, + categories, + gamificationEnabled, + tabs, + t, + assessmentMaxGrades, + courseId, + dataColumnIds, + ], + ); + + const { toolbar, body, pagination } = useTanStackTableBuilder({ + data: rows, + columns, + getRowId: (row) => row.studentId.toString(), + getRowEqualityData: (row) => row, + indexing: { rowSelectable: true }, + pagination: { + rowsPerPage: [ + DEFAULT_MINI_TABLE_ROWS_PER_PAGE, + 25, + 50, + DEFAULT_TABLE_ROWS_PER_PAGE, + ], + showAllRows: true, + }, + search: { searchPlaceholder: t(translations.searchStudents) }, + toolbar: { show: true, keepNative: true }, + csvDownload: { + filename: `${courseTitle}_gradebook`, + showDownloadButton: false, + }, + columnPicker, + }); + + const visibility = toolbar?.getColumnVisibility?.() ?? {}; + const isColVisible = (id: string): boolean => visibility[id] ?? true; + const visibleCols = columns.filter((c) => + isColVisible(c.id ?? (c.of as string)), + ); + + const selectedCount = body.selectedCount ?? 0; + + const directExportLabel = useMemo((): string => { + const isPartialSelection = selectedCount > 0 && selectedCount < rows.length; + if (isPartialSelection) + return t(translations.exportRows, { count: selectedCount }); + return t(translations.exportButton); + }, [selectedCount, rows.length, t]); + + const toolbarWithLabel = toolbar?.columnPicker + ? { + ...toolbar, + columnPicker: { + ...toolbar.columnPicker, + directExportLabel, + directExportTooltip: + selectedCount === 0 ? t(translations.exportAllTooltip) : undefined, + }, + } + : toolbar; + + const totalWidth = useMemo( + () => + CHECKBOX_WIDTH + + visibleCols.reduce((sum, c) => { + const id = c.id ?? (c.of as string); + return sum + getColWidth(id); + }, 0), + [visibleCols], + ); + + const allRowsSelected = body.allFilteredSelected ?? false; + const someRowsSelected = body.someFilteredSelected ?? false; + const toggleAllRows = (): void => body.toggleAllFiltered?.(); + + const hasVisibleAssessments = useMemo( + () => + visibleCols.some( + (c) => parseAssessmentColumnId(c.id ?? (c.of as string)) !== null, + ), + [visibleCols], + ); + + const row1Ref = useRef(null); + const [row2Top, setRow2Top] = useState(0); + useLayoutEffect(() => { + setRow2Top(row1Ref.current?.offsetHeight ?? 0); + }, [visibleCols]); + + const headerFitsRef = useRef>({}); + const [headerFits, setHeaderFits] = useState>({}); + const onSingleLine = useCallback((id: string, fits: boolean): void => { + if (headerFitsRef.current[id] !== fits) { + headerFitsRef.current[id] = fits; + setHeaderFits((prev) => ({ ...prev, [id]: fits })); + } + }, []); + const singleLineCallbacks = useMemo( + () => + new Map( + visibleCols.map((c) => { + const id = c.id ?? (c.of as string); + return [id, (f: boolean): void => onSingleLine(id, f)]; + }), + ), + [visibleCols, onSingleLine], + ); + + return ( +
+ +
+ + + ({ + tableLayout: 'fixed', + borderCollapse: 'separate', + borderSpacing: 0, + + '& th, & td': { + boxSizing: 'border-box', + border: 0, + + // Draws the cell grid without relying on collapsed borders. + borderBottom: `0.5px solid ${theme.palette.grey[200]}`, + }, + })} + > + + + {visibleCols.map((c) => { + const id = c.id ?? (c.of as string); + return ; + })} + + + + + + + {visibleCols.map((c) => { + const id = c.id ?? (c.of as string); + const label = typeof c.title === 'string' ? c.title : id; + const isLeft = isLeftAligned(id); + const fits = headerFits[id] ?? false; + return ( + + + + + + ); + })} + + {hasVisibleAssessments && ( + + + {visibleCols.map((c) => { + const id = c.id ?? (c.of as string); + const asnId = parseAssessmentColumnId(id); + let cellContent: string | number = ''; + if (id === 'name') cellContent = t(translations.maxMarks); + else if (asnId !== null) + cellContent = assessmentMaxGrades.get(asnId) ?? ''; + return ( + + {cellContent} + + ); + })} + + )} + + + {body.rows.map((row, idx) => { + const rowProps = body.forEachRow(row, idx); + return ( + + + + + {row + .getVisibleCells() + .filter((cell) => cell.column.id !== 'rowSelector') + .map((cell) => { + return ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ); + })} + + ); + })} + +
+
+ {pagination && } +
+
+
+ ); +}; + +export default GradebookTable; diff --git a/client/app/bundles/course/gradebook/components/GradebookWeightedTable.tsx b/client/app/bundles/course/gradebook/components/GradebookWeightedTable.tsx new file mode 100644 index 00000000000..50d0dce48ee --- /dev/null +++ b/client/app/bundles/course/gradebook/components/GradebookWeightedTable.tsx @@ -0,0 +1,300 @@ +import { FC, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import WarningAmberIcon from '@mui/icons-material/WarningAmber'; +import { + Button, + FormControlLabel, + Paper, + Switch, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tooltip, + Typography, +} from '@mui/material'; +import type { + AssessmentData, + CategoryData, + StudentData, + SubmissionData, + TabData, +} from 'types/course/gradebook'; + +import useTranslation from 'lib/hooks/useTranslation'; + +import { + computeStudentTotal, + computeTabSubtotal, + sumWeights, +} from '../computeWeighted'; + +import ConfigureWeightsDialog from './ConfigureWeightsDialog'; + +interface Props { + categories: CategoryData[]; + tabs: TabData[]; + assessments: AssessmentData[]; + students: StudentData[]; + submissions: SubmissionData[]; + canManageWeights: boolean; +} + +const translations = defineMessages({ + configureWeights: { + id: 'course.gradebook.GradebookWeightedTable.configure', + defaultMessage: 'Configure Weights', + }, + treatUngradedAsZero: { + id: 'course.gradebook.GradebookWeightedTable.treatUngradedAsZero', + defaultMessage: 'Treat Ungraded as 0', + }, + total: { + id: 'course.gradebook.GradebookWeightedTable.total', + defaultMessage: 'Total', + }, + totalSubheader: { + id: 'course.gradebook.GradebookWeightedTable.totalSubheader', + defaultMessage: '{sum}% total', + }, + sumWarningTooltip: { + id: 'course.gradebook.GradebookWeightedTable.sumWarningTooltip', + defaultMessage: 'Tab weights sum to {sum}%. Configure Weights to fix.', + }, + weightSubheader: { + id: 'course.gradebook.GradebookWeightedTable.weightSubheader', + defaultMessage: '{weight}% of grade', + }, + emptyStateTitle: { + id: 'course.gradebook.GradebookWeightedTable.emptyStateTitle', + defaultMessage: 'No tab weights configured.', + }, + emptyStateBody: { + id: 'course.gradebook.GradebookWeightedTable.emptyStateBody', + defaultMessage: 'Click Configure Weights to start.', + }, + name: { + id: 'course.gradebook.GradebookWeightedTable.name', + defaultMessage: 'Name', + }, +}); + +const formatPct = (v: number | null): string => + v == null ? '—' : `${(v * 100).toFixed(2)}%`; + +const GradebookWeightedTable: FC = ({ + categories, + tabs, + assessments, + students, + submissions, + canManageWeights, +}) => { + const { t } = useTranslation(); + const [treatUngradedAsZero, setTreatUngradedAsZero] = useState(false); + const [dialogOpen, setDialogOpen] = useState(false); + + // 3-row sticky header measurement + const row1Ref = useRef(null); + const row2Ref = useRef(null); + const [row2Top, setRow2Top] = useState(0); + const [row3Top, setRow3Top] = useState(0); + useLayoutEffect(() => { + const h1 = row1Ref.current?.offsetHeight ?? 0; + const h2 = row2Ref.current?.offsetHeight ?? 0; + setRow2Top(h1); + setRow3Top(h1 + h2); + }, [tabs, categories]); + + const rows = useMemo( + () => + students.map((stu) => { + const subtotalsByTabId: Record = + Object.fromEntries( + tabs.map((tab) => [ + tab.id, + computeTabSubtotal({ + studentId: stu.id, + tab, + assessments, + submissions, + treatUngradedAsZero, + }), + ]), + ); + const total = computeStudentTotal({ + studentId: stu.id, + tabs, + assessments, + submissions, + treatUngradedAsZero, + }); + return { + studentId: stu.id, + name: stu.name, + subtotalsByTabId, + total, + }; + }), + [students, tabs, assessments, submissions, treatUngradedAsZero], + ); + + const weightSum = sumWeights(tabs); + const allWeightsZero = weightSum === 0; + + // Category colSpan map + const tabsByCategory = useMemo( + () => + tabs.reduce((map, tab) => { + const existing = map.get(tab.categoryId) ?? []; + return map.set(tab.categoryId, [...existing, tab]); + }, new Map()), + [tabs], + ); + + return ( +
+ {/* Toolbar */} +
+ {canManageWeights && ( + + )} + setTreatUngradedAsZero(e.target.checked)} + /> + } + label={t(translations.treatUngradedAsZero)} + /> +
+ + {/* Empty state */} + {allWeightsZero && ( + + + {t(translations.emptyStateTitle)} + + + {t(translations.emptyStateBody)} + + + )} + + {/* Table */} + + + + {/* Row 1: categories + Total */} + + + {t(translations.name)} + + {categories.map((cat) => { + const catTabs = tabsByCategory.get(cat.id) ?? []; + return ( + + {cat.title} + + ); + })} + + {t(translations.total)} + + + + {/* Row 2: tab titles */} + + + {tabs.map((tab) => ( + + {tab.title} + + ))} + + + + {/* Row 3: weight subheaders */} + + + {tabs.map((tab) => ( + + {t(translations.weightSubheader, { + weight: tab.gradebookWeight ?? 0, + })} + + ))} + + {weightSum !== 100 ? ( + + + {t(translations.totalSubheader, { sum: weightSum })} +   + + + + ) : ( + t(translations.totalSubheader, { sum: weightSum }) + )} + + + + + + {rows.map((row) => ( + + {row.name} + {tabs.map((tab) => ( + + {formatPct(row.subtotalsByTabId[tab.id])} + + ))} + {formatPct(row.total)} + + ))} + +
+
+ + setDialogOpen(false)} + open={dialogOpen} + tabs={tabs} + /> +
+ ); +}; + +export default GradebookWeightedTable; diff --git a/client/app/bundles/course/gradebook/components/buildAssessmentColumnIds.ts b/client/app/bundles/course/gradebook/components/buildAssessmentColumnIds.ts new file mode 100644 index 00000000000..d12a4bd26a7 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/buildAssessmentColumnIds.ts @@ -0,0 +1,7 @@ +export const buildAssessmentColumnId = (asnId: number): string => + `asn-${asnId}`; + +export const parseAssessmentColumnId = (colId: string): number | null => { + const match = colId.match(/^asn-(\d+)$/); + return match ? Number(match[1]) : null; +}; diff --git a/client/app/bundles/course/gradebook/computeWeighted.ts b/client/app/bundles/course/gradebook/computeWeighted.ts new file mode 100644 index 00000000000..714a9f961cc --- /dev/null +++ b/client/app/bundles/course/gradebook/computeWeighted.ts @@ -0,0 +1,96 @@ +import type { + AssessmentData, + SubmissionData, + TabData, +} from 'types/course/gradebook'; + +interface SubtotalArgs { + studentId: number; + tab: TabData; + assessments: AssessmentData[]; + submissions: SubmissionData[]; + treatUngradedAsZero: boolean; +} + +export const computeTabSubtotal = ({ + studentId, + tab, + assessments, + submissions, + treatUngradedAsZero, +}: SubtotalArgs): number | null => { + const tabAssessments = assessments.filter((a) => a.tabId === tab.id); + if (tabAssessments.length === 0) return null; + + let numerator = 0; + let denominator = 0; + tabAssessments.forEach((a) => { + const grade = submissions.find( + (s) => s.studentId === studentId && s.assessmentId === a.id, + )?.grade; + if (grade != null) { + numerator += grade; + denominator += a.maxGrade; + } else if (treatUngradedAsZero) { + denominator += a.maxGrade; + } + }); + return denominator > 0 ? numerator / denominator : null; +}; + +interface TotalArgs { + studentId: number; + tabs: TabData[]; + assessments: AssessmentData[]; + submissions: SubmissionData[]; + treatUngradedAsZero: boolean; +} + +export const computeStudentTotal = ({ + studentId, + tabs, + assessments, + submissions, + treatUngradedAsZero, +}: TotalArgs): number | null => { + // Build per-student O(1) lookup map to avoid O(n) scans per assessment + const gradeByAssessmentId = new Map(); + submissions.forEach((s) => { + if (s.studentId === studentId) + gradeByAssessmentId.set(s.assessmentId, s.grade); + }); + + const { weightedSum, weightSum } = tabs.reduce( + (acc, tab) => { + const weight = tab.gradebookWeight ?? 0; + if (weight <= 0) return acc; + + const tabAssessments = assessments.filter((a) => a.tabId === tab.id); + if (tabAssessments.length === 0) return acc; + + let numerator = 0; + let denominator = 0; + tabAssessments.forEach((a) => { + const grade = gradeByAssessmentId.get(a.id); + if (grade != null) { + numerator += grade; + denominator += a.maxGrade; + } else if (treatUngradedAsZero) { + denominator += a.maxGrade; + } + }); + + const sub = denominator > 0 ? numerator / denominator : null; + if (sub == null) return acc; + return { + weightedSum: acc.weightedSum + weight * sub, + weightSum: acc.weightSum + weight, + }; + }, + { weightedSum: 0, weightSum: 0 }, + ); + return weightSum > 0 ? weightedSum / weightSum : null; +}; + +export const sumWeights = (tabs: TabData[]): number => + tabs.reduce((acc, t) => acc + (t.gradebookWeight ?? 0), 0); diff --git a/client/app/bundles/course/gradebook/constants.ts b/client/app/bundles/course/gradebook/constants.ts new file mode 100644 index 00000000000..87a49f50a7c --- /dev/null +++ b/client/app/bundles/course/gradebook/constants.ts @@ -0,0 +1,5 @@ +export const STUDENT_INFO_COL_IDS = ['name', 'email'] as const; +export type StudentInfoColId = (typeof STUDENT_INFO_COL_IDS)[number]; + +export const GAMIFICATION_COL_IDS = ['level', 'totalXp'] as const; +export type GamificationColId = (typeof GAMIFICATION_COL_IDS)[number]; diff --git a/client/app/bundles/course/gradebook/handles.ts b/client/app/bundles/course/gradebook/handles.ts new file mode 100644 index 00000000000..0022bfbd02c --- /dev/null +++ b/client/app/bundles/course/gradebook/handles.ts @@ -0,0 +1,21 @@ +import { defineMessages } from 'react-intl'; + +import type { CrumbPath, DataHandle } from 'lib/hooks/router/dynamicNest'; + +const translations = defineMessages({ + header: { + id: 'course.gradebook.GradebookIndex.gradebook', + defaultMessage: 'Gradebook', + }, +}); + +export const gradebookHandle: DataHandle = (match) => { + const courseId = match.params.courseId; + + return { + getData: async (): Promise => ({ + activePath: `/courses/${courseId}/gradebook`, + content: { title: translations.header }, + }), + }; +}; diff --git a/client/app/bundles/course/gradebook/operations.ts b/client/app/bundles/course/gradebook/operations.ts new file mode 100644 index 00000000000..6bf1426ad92 --- /dev/null +++ b/client/app/bundles/course/gradebook/operations.ts @@ -0,0 +1,20 @@ +import type { Operation } from 'store'; +import type { UpdateWeightsPayload } from 'types/course/gradebook'; + +import CourseAPI from 'api/course'; + +import { actions } from './store'; + +const fetchGradebook = (): Operation => async (dispatch) => { + const response = await CourseAPI.gradebook.index(); + dispatch(actions.saveGradebook(response.data)); +}; + +export const updateGradebookWeights = + (weights: UpdateWeightsPayload['weights']): Operation => + async (dispatch) => { + const response = await CourseAPI.gradebook.updateWeights({ weights }); + dispatch(actions.updateTabWeights(response.data?.weights ?? weights)); + }; + +export default fetchGradebook; diff --git a/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx b/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx new file mode 100644 index 00000000000..d8bf4698343 --- /dev/null +++ b/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx @@ -0,0 +1,159 @@ +import { FC, useEffect, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { useParams } from 'react-router-dom'; +import { PeopleAlt } from '@mui/icons-material'; +import { Typography } from '@mui/material'; +import ToggleButton from '@mui/material/ToggleButton'; +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; + +import Page from 'lib/components/core/layouts/Page'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { useCourseContext } from '../../../container/CourseLoader'; +import GradebookTable from '../../components/GradebookTable'; +import GradebookWeightedTable from '../../components/GradebookWeightedTable'; +import fetchGradebook from '../../operations'; +import { + getAssessments, + getCanManageWeights, + getCategories, + getGamificationEnabled, + getStudents, + getSubmissions, + getTabs, + getWeightedViewEnabled, +} from '../../selectors'; + +const translations = defineMessages({ + gradebook: { + id: 'course.gradebook.GradebookIndex.gradebook', + defaultMessage: 'Gradebook', + }, + fetchFailure: { + id: 'course.gradebook.GradebookIndex.fetchFailure', + defaultMessage: 'Failed to retrieve Gradebook.', + }, + noStudents: { + id: 'course.gradebook.GradebookIndex.noStudents', + defaultMessage: 'No students enrolled yet', + }, + noStudentsHint: { + id: 'course.gradebook.GradebookIndex.noStudentsHint', + defaultMessage: 'Grades will appear here once students join the course.', + }, + allAssessments: { + id: 'course.gradebook.GradebookIndex.allAssessments', + defaultMessage: 'All assessments', + }, + byWeight: { + id: 'course.gradebook.GradebookIndex.byWeight', + defaultMessage: 'By weight', + }, +}); + +const GradebookIndex: FC = () => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const { courseTitle } = useCourseContext(); + const { courseId: courseIdParam } = useParams(); + const courseId = parseInt(courseIdParam!, 10); + const [isLoading, setIsLoading] = useState(true); + const [viewMode, setViewMode] = useState<'all' | 'weighted'>('all'); + + const assessments = useAppSelector(getAssessments); + const categories = useAppSelector(getCategories); + const tabs = useAppSelector(getTabs); + const students = useAppSelector(getStudents); + const submissions = useAppSelector(getSubmissions); + const gamificationEnabled = useAppSelector(getGamificationEnabled); + const weightedViewEnabled = useAppSelector(getWeightedViewEnabled); + const canManageWeights = useAppSelector(getCanManageWeights); + + useEffect(() => { + dispatch(fetchGradebook()) + .finally(() => setIsLoading(false)) + .catch(() => toast.error(t(translations.fetchFailure))); + }, [dispatch]); + + useEffect(() => { + if (!weightedViewEnabled) setViewMode('all'); + }, [weightedViewEnabled]); + + let content: JSX.Element; + if (isLoading) { + content = ; + } else if (students.length === 0) { + content = ( +
+ + + {t(translations.noStudents)} + + + {t(translations.noStudentsHint)} + +
+ ); + } else { + const viewToggle = weightedViewEnabled ? ( +
+ { + if (v) setViewMode(v); + }} + size="small" + value={viewMode} + > + + {t(translations.allAssessments)} + + + {t(translations.byWeight)} + + +
+ ) : null; + + const tableView = + viewMode === 'weighted' && weightedViewEnabled ? ( + + ) : ( + + ); + + content = ( + <> + {viewToggle} + {tableView} + + ); + } + + return ( + + {content} + + ); +}; + +export default GradebookIndex; diff --git a/client/app/bundles/course/gradebook/selectors.ts b/client/app/bundles/course/gradebook/selectors.ts new file mode 100644 index 00000000000..8541e471fd5 --- /dev/null +++ b/client/app/bundles/course/gradebook/selectors.ts @@ -0,0 +1,30 @@ +import type { AppState } from 'store'; + +type GradebookState = AppState['gradebook']; + +function getLocalState(state: AppState): GradebookState { + return state.gradebook; +} + +export const getCategories = (state: AppState): GradebookState['categories'] => + getLocalState(state).categories; +export const getTabs = (state: AppState): GradebookState['tabs'] => + getLocalState(state).tabs; +export const getAssessments = ( + state: AppState, +): GradebookState['assessments'] => getLocalState(state).assessments; +export const getStudents = (state: AppState): GradebookState['students'] => + getLocalState(state).students; +export const getSubmissions = ( + state: AppState, +): GradebookState['submissions'] => getLocalState(state).submissions; +export const getGamificationEnabled = ( + state: AppState, +): GradebookState['gamificationEnabled'] => + getLocalState(state).gamificationEnabled; + +export const getWeightedViewEnabled = (state: AppState): boolean => + getLocalState(state).weightedViewEnabled; + +export const getCanManageWeights = (state: AppState): boolean => + getLocalState(state).canManageWeights; diff --git a/client/app/bundles/course/gradebook/store.ts b/client/app/bundles/course/gradebook/store.ts new file mode 100644 index 00000000000..8289312b4f1 --- /dev/null +++ b/client/app/bundles/course/gradebook/store.ts @@ -0,0 +1,98 @@ +import { produce } from 'immer'; +import type { + GradebookData, + UpdateWeightsPayload, +} from 'types/course/gradebook'; + +import type { + AssessmentData, + CategoryData, + StudentData, + SubmissionData, + TabData, +} from './types'; + +const SAVE_GRADEBOOK = 'course/gradebook/SAVE_GRADEBOOK'; +const UPDATE_TAB_WEIGHTS = 'course/gradebook/UPDATE_TAB_WEIGHTS'; + +interface GradebookState { + categories: CategoryData[]; + tabs: TabData[]; + assessments: AssessmentData[]; + students: StudentData[]; + submissions: SubmissionData[]; + gamificationEnabled: boolean; + weightedViewEnabled: boolean; + canManageWeights: boolean; +} + +interface SaveGradebookAction { + type: typeof SAVE_GRADEBOOK; + payload: GradebookData; +} + +interface UpdateTabWeightsAction { + type: typeof UPDATE_TAB_WEIGHTS; + payload: UpdateWeightsPayload['weights']; +} + +const initialState: GradebookState = { + categories: [], + tabs: [], + assessments: [], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: false, + canManageWeights: false, +}; + +const reducer = produce( + ( + draft: GradebookState, + action: SaveGradebookAction | UpdateTabWeightsAction, + ) => { + switch (action.type) { + case SAVE_GRADEBOOK: { + const payload = (action as SaveGradebookAction).payload; + draft.categories = payload.categories; + draft.tabs = payload.tabs; + draft.assessments = payload.assessments; + draft.students = payload.students; + draft.submissions = payload.submissions; + draft.gamificationEnabled = payload.gamificationEnabled; + draft.weightedViewEnabled = payload.weightedViewEnabled; + draft.canManageWeights = payload.canManageWeights; + break; + } + case UPDATE_TAB_WEIGHTS: { + const weights = (action as UpdateTabWeightsAction).payload; + weights.forEach(({ tabId, weight }) => { + const tab = draft.tabs.find((t) => t.id === tabId); + if (tab) { + tab.gradebookWeight = weight; + } + }); + break; + } + default: + break; + } + }, + initialState, +); + +export const actions = { + saveGradebook: (data: GradebookData): SaveGradebookAction => ({ + type: SAVE_GRADEBOOK, + payload: data, + }), + updateTabWeights: ( + weights: UpdateWeightsPayload['weights'], + ): UpdateTabWeightsAction => ({ + type: UPDATE_TAB_WEIGHTS, + payload: weights, + }), +}; + +export default reducer; diff --git a/client/app/bundles/course/gradebook/types.ts b/client/app/bundles/course/gradebook/types.ts new file mode 100644 index 00000000000..b91689df872 --- /dev/null +++ b/client/app/bundles/course/gradebook/types.ts @@ -0,0 +1,9 @@ +export type { + AssessmentData, + CategoryData, + GradebookData, + StudentData, + SubmissionData, + TabData, + UpdateWeightsPayload, +} from 'types/course/gradebook'; diff --git a/client/app/bundles/course/translations.ts b/client/app/bundles/course/translations.ts index b92b165a744..c52ce359071 100644 --- a/client/app/bundles/course/translations.ts +++ b/client/app/bundles/course/translations.ts @@ -75,6 +75,10 @@ const translations = defineMessages({ id: 'course.componentTitles.course_forums_component', defaultMessage: 'Forums', }, + course_gradebook_component: { + id: 'course.componentTitles.course_gradebook_component', + defaultMessage: 'Gradebook', + }, course_groups_component: { id: 'course.componentTitles.course_groups_component', defaultMessage: 'Groups', diff --git a/client/app/bundles/course/duplication/components/IndentedCheckbox.tsx b/client/app/lib/components/core/IndentedCheckbox.tsx similarity index 100% rename from client/app/bundles/course/duplication/components/IndentedCheckbox.tsx rename to client/app/lib/components/core/IndentedCheckbox.tsx diff --git a/client/app/lib/components/table/MuiTableAdapter/ColumnPickerTreeGroup.tsx b/client/app/lib/components/table/MuiTableAdapter/ColumnPickerTreeGroup.tsx new file mode 100644 index 00000000000..852685225ff --- /dev/null +++ b/client/app/lib/components/table/MuiTableAdapter/ColumnPickerTreeGroup.tsx @@ -0,0 +1,52 @@ +import { FC, ReactNode } from 'react'; + +import IndentedCheckbox from 'lib/components/core/IndentedCheckbox'; + +import { ColumnPickerRenderCtx } from '../builder'; + +interface ColumnPickerTreeGroupProps { + label: string; + /** All leaf column ids that belong to this group (used to derive parent state). */ + childIds: string[]; + ctx: ColumnPickerRenderCtx; + /** Ids that are locked visible — parent checkbox is disabled when all children are locked. */ + locked?: string[]; + indentLevel?: number; + children: ReactNode; +} + +/** + * Renders a parent checkbox whose checked/indeterminate state mirrors its children's + * visibility, and whose onChange bulk-toggles all children via ctx.setManyVisible. + * Children are rendered below (not inline), giving a vertical tree layout. + */ +const ColumnPickerTreeGroup: FC = ({ + label, + childIds, + ctx, + locked = [], + indentLevel = 0, + children, +}) => { + const visibleCount = childIds.filter((id) => ctx.isVisible(id)).length; + const allVisible = childIds.length > 0 && visibleCount === childIds.length; + const someVisible = visibleCount > 0 && !allVisible; + const allLocked = + childIds.length > 0 && childIds.every((id) => locked.includes(id)); + + return ( +
+ ctx.setManyVisible(childIds, e.target.checked)} + /> +
{children}
+
+ ); +}; + +export default ColumnPickerTreeGroup; diff --git a/client/app/lib/components/table/MuiTableAdapter/MuiColumnPickerDialog.tsx b/client/app/lib/components/table/MuiTableAdapter/MuiColumnPickerDialog.tsx new file mode 100644 index 00000000000..388eed3b935 --- /dev/null +++ b/client/app/lib/components/table/MuiTableAdapter/MuiColumnPickerDialog.tsx @@ -0,0 +1,152 @@ +import { useEffect, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { + Alert, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, +} from '@mui/material'; + +import useTranslation from 'lib/hooks/useTranslation'; + +import { ColumnPickerTemplate } from '../builder'; + +const translations = defineMessages({ + defaultTitle: { + id: 'lib.components.table.MuiColumnPickerDialog.defaultTitle', + defaultMessage: 'Select columns', + }, + apply: { + id: 'lib.components.table.MuiColumnPickerDialog.apply', + defaultMessage: 'Apply to view', + }, + cancel: { + id: 'lib.components.table.MuiColumnPickerDialog.cancel', + defaultMessage: 'Cancel', + }, + defaultExport: { + id: 'lib.components.table.MuiColumnPickerDialog.export', + defaultMessage: 'Apply and Export', + }, +}); + +interface MuiColumnPickerDialogProps { + open: boolean; + onClose: () => void; + initialVisibility: Record; + locked?: string[]; + columnPicker: ColumnPickerTemplate; + commitColumnVisibility: (next: Record) => void; + onExportFromPicker?: (visibility: Record) => void; +} + +const enforceLockedLocal = ( + next: Record, + locked: string[] | undefined, +): Record => { + if (!locked || locked.length === 0) return next; + const enforced = { ...next }; + locked.forEach((id) => { + enforced[id] = true; + }); + return enforced; +}; + +const MuiColumnPickerDialog = ({ + open, + onClose, + initialVisibility, + locked, + columnPicker, + commitColumnVisibility, + onExportFromPicker, +}: MuiColumnPickerDialogProps): JSX.Element => { + const { t } = useTranslation(); + const [staged, setStaged] = useState>(() => + enforceLockedLocal({ ...initialVisibility }, locked), + ); + + const dataColumnIds = columnPicker.dataColumnIds; + const hasDataColumns = + !dataColumnIds || + dataColumnIds.length === 0 || + dataColumnIds.some((id) => staged[id]); + + useEffect(() => { + if (open) { + setStaged(enforceLockedLocal({ ...initialVisibility }, locked)); + } + }, [open, initialVisibility, locked]); + + const ctx = { + isVisible: (id: string): boolean => staged[id] ?? false, + setVisible: (id: string, v: boolean): void => { + if (locked?.includes(id)) return; + setStaged((prev) => + Object.hasOwn(prev, id) ? { ...prev, [id]: v } : prev, + ); + }, + setManyVisible: (ids: string[], v: boolean): void => { + setStaged((prev) => { + const next = { ...prev }; + let changed = false; + ids.forEach((id) => { + if (!Object.hasOwn(next, id)) return; + if (locked?.includes(id)) return; + if (next[id] !== v) { + next[id] = v; + changed = true; + } + }); + return changed ? next : prev; + }); + }, + }; + + const commitAndClose = (): void => { + commitColumnVisibility(enforceLockedLocal(staged, locked)); + onClose(); + }; + + const cancelAndClose = (): void => { + onClose(); + }; + + const exportAndClose = (): void => { + const enforced = enforceLockedLocal(staged, locked); + commitColumnVisibility(enforced); + onExportFromPicker?.(enforced); + onClose(); + }; + + return ( + + + {columnPicker.dialogTitle ?? t(translations.defaultTitle)} + + {columnPicker.renderTree(ctx)} + {!hasDataColumns && columnPicker.noDataColumnsHint && ( + + {columnPicker.noDataColumnsHint} + + )} + + + + + + + ); +}; + +export default MuiColumnPickerDialog; diff --git a/client/app/lib/components/table/MuiTableAdapter/MuiTableToolbar.tsx b/client/app/lib/components/table/MuiTableAdapter/MuiTableToolbar.tsx index 174ad2de405..40e20b3c385 100644 --- a/client/app/lib/components/table/MuiTableAdapter/MuiTableToolbar.tsx +++ b/client/app/lib/components/table/MuiTableAdapter/MuiTableToolbar.tsx @@ -1,23 +1,38 @@ +import { useState } from 'react'; +import { defineMessages } from 'react-intl'; import { Download } from '@mui/icons-material'; -import { IconButton, Tooltip } from '@mui/material'; +import { Button, IconButton, Tooltip } from '@mui/material'; import SearchField from 'lib/components/core/fields/SearchField'; import useTranslation from 'lib/hooks/useTranslation'; import { ToolbarProps } from '../adapters'; +import MuiColumnPickerDialog from './MuiColumnPickerDialog'; import translations from './translations'; interface ToolbarContainerProps { children: React.ReactNode; } +const localTranslations = defineMessages({ + defaultPickerTrigger: { + id: 'lib.components.table.MuiTableToolbar.exportTrigger', + defaultMessage: 'Export…', + }, + defaultDirectExport: { + id: 'lib.components.table.MuiTableToolbar.directExport', + defaultMessage: 'Export', + }, +}); + const ToolbarContainer = ({ children }: ToolbarContainerProps): JSX.Element => (
{children}
); const MuiTableToolbar = (props: ToolbarProps): JSX.Element | null => { const { t } = useTranslation(); + const [pickerOpen, setPickerOpen] = useState(false); const renderAlternative = props.alternative?.when(); const renderNative = renderAlternative @@ -26,33 +41,77 @@ const MuiTableToolbar = (props: ToolbarProps): JSX.Element | null => { if (!renderAlternative && !renderNative) return null; + const triggerLabel = + props.columnPicker?.triggerLabel ?? + t(localTranslations.defaultPickerTrigger); + + const directExportLabel = + props.columnPicker?.directExportLabel ?? + t(localTranslations.defaultDirectExport); + return ( -
+
{renderNative && ( )} -
- {renderAlternative && props.alternative?.render()} - {renderNative && !renderAlternative && props.buttons} - - {renderNative && props.onDownloadCsv && ( - - - - - - )} -
+ {renderAlternative && props.alternative?.render()} + {renderNative && !renderAlternative && props.buttons} + + {renderNative && props.columnPicker && ( + + )} + + {renderNative && props.columnPicker && props.onDirectExport && ( + + + + + + )} + + {renderNative && props.onDownloadCsv && ( + + + + + + )}
+ + {props.columnPicker && props.commitColumnVisibility && ( + setPickerOpen(false)} + onExportFromPicker={props.onExportFromPicker} + open={pickerOpen} + /> + )} ); }; diff --git a/client/app/lib/components/table/TanStackTableBuilder/columnsBuilder.ts b/client/app/lib/components/table/TanStackTableBuilder/columnsBuilder.ts index 5fdf50fd16a..8ebb3127667 100644 --- a/client/app/lib/components/table/TanStackTableBuilder/columnsBuilder.ts +++ b/client/app/lib/components/table/TanStackTableBuilder/columnsBuilder.ts @@ -45,8 +45,12 @@ const buildTanStackColumns = ( (column) => ({ id: column.id, - accessorKey: column.of, - accessorFn: column.searchProps?.getValue, + ...(column.accessorFn !== undefined + ? { accessorFn: column.accessorFn } + : { + accessorKey: column.of, + accessorFn: column.searchProps?.getValue, + }), header: column.title, cell: ({ row: { original: datum } }) => column.cell(datum), enableSorting: Boolean(column.sortable), diff --git a/client/app/lib/components/table/TanStackTableBuilder/csvGenerator.ts b/client/app/lib/components/table/TanStackTableBuilder/csvGenerator.ts index 965a2c684e4..420122e9f2c 100644 --- a/client/app/lib/components/table/TanStackTableBuilder/csvGenerator.ts +++ b/client/app/lib/components/table/TanStackTableBuilder/csvGenerator.ts @@ -1,34 +1,59 @@ import { ReactNode } from 'react'; -import { Row } from '@tanstack/react-table'; +import { Column, Table } from '@tanstack/react-table'; import { unparse } from 'papaparse'; import { ColumnTemplate, Data } from '../builder'; interface CsvGenerator { - headers: string[]; - rows: () => Row[]; - getRealColumn: (index: number) => ColumnTemplate | undefined; + table: Table; + getRealColumn: (id: string) => ColumnTemplate | undefined; + visibilityOverride?: Record; + getExtraHeaderRows?: (columnIds: string[]) => string[][]; + onlySelected?: boolean; } +const extractHeader = ( + col: Column, + realColumn: ColumnTemplate | undefined, +): string => { + const title = realColumn?.title; + if (typeof title === 'string') return title; + return realColumn?.id ?? col.id; +}; + const generateCsv = ( options: CsvGenerator, ): Promise => new Promise((resolve) => { - const rows = [options.headers]; - - options.rows().forEach((row) => { - const rowData = row - .getAllCells() - .reduce((cells, cell, index) => { - const realColumn = options.getRealColumn(index); - const csvDownloadable = realColumn?.csvDownloadable; - if (!csvDownloadable) return cells; - - const value = cell.getValue() as ReactNode; - cells.push(realColumn.csvValue?.(value) ?? value?.toString() ?? ''); - return cells; - }, []); - + // Keep ONLY columns where the consumer explicitly set csvDownloadable === true. + // Columns with `csvDownloadable: undefined` or `false` are excluded (matches the + // original behaviour where `csvDownloadable ?? false` gated headers). + const leafColumns = options.visibilityOverride + ? options.table + .getAllLeafColumns() + .filter((col) => options.visibilityOverride?.[col.id] !== false) + : options.table.getVisibleLeafColumns(); + const exportColumns = leafColumns.filter( + (col) => options.getRealColumn(col.id)?.csvDownloadable === true, + ); + + const headers = exportColumns.map((col) => + extractHeader(col, options.getRealColumn(col.id)), + ); + + const colIds = exportColumns.map((col) => col.id); + const extraHeaderRows = options.getExtraHeaderRows?.(colIds) ?? []; + const rows: string[][] = [headers, ...extraHeaderRows]; + + const exportRows = options.onlySelected + ? options.table.getSelectedRowModel().rows + : options.table.getCoreRowModel().rows; + exportRows.forEach((row) => { + const rowData = exportColumns.map((col) => { + const realColumn = options.getRealColumn(col.id); + const value = row.getValue(col.id) as ReactNode; + return realColumn?.csvValue?.(value) ?? value?.toString() ?? ''; + }); rows.push(rowData); }); diff --git a/client/app/lib/components/table/TanStackTableBuilder/useTanStackTableBuilder.tsx b/client/app/lib/components/table/TanStackTableBuilder/useTanStackTableBuilder.tsx index 16806f3445e..bc917108808 100644 --- a/client/app/lib/components/table/TanStackTableBuilder/useTanStackTableBuilder.tsx +++ b/client/app/lib/components/table/TanStackTableBuilder/useTanStackTableBuilder.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Cell, ColumnFiltersState, @@ -9,12 +9,14 @@ import { getSortedRowModel, Header, Row, + Updater, useReactTable, + VisibilityState, } from '@tanstack/react-table'; import isEmpty from 'lodash-es/isEmpty'; import { RowEqualityData, TableProps } from '../adapters'; -import { TableTemplate } from '../builder'; +import { ColumnTemplate, TableTemplate } from '../builder'; import { downloadCsv } from '../utils'; import buildTanStackColumns from './columnsBuilder'; @@ -47,6 +49,90 @@ const useTanStackTableBuilder = ( pageIndex: props.pagination?.initialPageIndex ?? 0, }); + const initialVisibility = useMemo(() => { + const storageKey = props.columnPicker?.storageKey; + let stored: VisibilityState | null = null; + if (storageKey) { + try { + const raw = localStorage.getItem(storageKey); + stored = raw ? (JSON.parse(raw) as VisibilityState) : null; + } catch { + stored = null; + } + } + return Object.fromEntries( + props.columns.map((c) => { + const id = c.id ?? (c.of as string); + const storedValue = stored?.[id]; + return [ + id, + storedValue !== undefined ? storedValue : c.defaultVisible ?? true, + ]; + }), + ); + }, []); + const [columnVisibility, setColumnVisibility] = + useState(initialVisibility); + + // Ref-based so enforceLocked is stable and never a changing useEffect dep. + const lockedRef = useRef(props.columnPicker?.locked); + lockedRef.current = props.columnPicker?.locked; + + const enforceLocked = useCallback( + (next: VisibilityState): VisibilityState => { + const locked = lockedRef.current; + if (!locked || locked.length === 0) return next; + const enforced = { ...next }; + locked.forEach((id) => { + enforced[id] = true; + }); + return enforced; + }, + [], + ); + + const safeSetVisibility = (updater: Updater): void => { + setColumnVisibility((prev) => { + const next = typeof updater === 'function' ? updater(prev) : updater; + return enforceLocked(next); + }); + }; + + useEffect(() => { + const key = props.columnPicker?.storageKey; + if (!key) return; + try { + localStorage.setItem(key, JSON.stringify(columnVisibility)); + } catch { + // setItem throws QuotaExceededError (storage full) or SecurityError (private + // browsing on some browsers). Persistence is best-effort; the current session + // is unaffected if it fails. + } + }, [columnVisibility, props.columnPicker?.storageKey]); + + // Reconcile when columns change (e.g. async-loaded gradebook assessments). + useEffect(() => { + setColumnVisibility((prev) => { + const currentIds = props.columns.map((c) => c.id ?? (c.of as string)); + const colMap = new Map( + props.columns.map((c) => [c.id ?? (c.of as string), c]), + ); + const next: VisibilityState = {}; + currentIds.forEach((id) => { + next[id] = Object.hasOwn(prev, id) + ? prev[id] + : colMap.get(id)?.defaultVisible ?? true; + }); + const enforced = enforceLocked(next); + // Return prev reference when nothing changed — prevents infinite re-render + // loop when columns/locked arrays are new references on every render. + const changed = + Object.keys(enforced).length !== Object.keys(prev).length || + Object.keys(enforced).some((k) => enforced[k] !== prev[k]); + return changed ? enforced : prev; + }); + }, [props.columns, enforceLocked]); + const resetPagination = (): void => setPagination((current) => ({ ...current, pageIndex: 0 })); @@ -83,7 +169,9 @@ const useTanStackTableBuilder = ( columnFilters, globalFilter: searchKeyword.trim(), pagination, + columnVisibility, }, + onColumnVisibilityChange: safeSetVisibility, initialState: { sorting: props.sort?.initially && [ { @@ -94,24 +182,39 @@ const useTanStackTableBuilder = ( }, }); - const generateAndDownloadCsv = async (): Promise => { - const headers = table.options.columns.reduce( - (acc, column, index) => { - const header = column.header || column.id; - if (header && (getRealColumn(index)?.csvDownloadable ?? false)) { - acc.push(header as string); - } - return acc; - }, - [], - ); + const getRealColumnById = (id: string): ColumnTemplate | undefined => { + // Use the position within getAllLeafColumns() as the index into getRealColumn. + // We cannot search table.options.columns by c.id (undefined for accessorKey-based columns), + // and we cannot use col.columnDef reference equality because TanStack's createColumn spreads + // the def ({ ...defaultColumn, ...columnDef }), so col.columnDef is never === the original. + // + // Why getAllLeafColumns() index === getRealColumn() index: + // table.options.columns (ColumnDef[]) + // → _getColumnDefs() returns it directly + // → getAllColumns() maps each def → Column, preserving order + // → getAllLeafColumns() flatMaps + applies _getOrderColumnsFn + // (identity when columnOrder state is empty — we never set it) + // NOTE: if user-reorderable columns are added, columnOrder state will be set and + // getAllLeafColumns() will no longer match getRealColumn() by position. At that point + // getRealColumnById must be rewritten to look up by id rather than position. + // getRealColumn is built by buildColumns, which maps built-array position → ColumnTemplate + // using the same table.options.columns as input in the same order. + // Both arrays share the same positional index, so getRealColumn(i) matches getAllLeafColumns()[i]. + const index = table.getAllLeafColumns().findIndex((c) => c.id === id); + if (index === -1) return undefined; + return getRealColumn(index); + }; + const generateAndDownloadCsv = async ( + visibilityOverride?: Record, + ): Promise => { const csvData = await generateCsv({ - headers, - rows: () => table.getCoreRowModel().rows, - getRealColumn, + table, + getRealColumn: getRealColumnById, + visibilityOverride, + getExtraHeaderRows: props.columnPicker?.getExtraHeaderRows, + onlySelected: !isEmpty(rowSelection), }); - downloadCsv(csvData, props.csvDownload?.filename); }; @@ -178,6 +281,18 @@ const useTanStackTableBuilder = ( selected: rowSelection[row.id], })), }), + selectedCount: table.getSelectedRowModel().rows.length, + allFilteredSelected: + table.getFilteredRowModel().rows.length > 0 && + table.getFilteredRowModel().rows.every((r) => r.getIsSelected()), + someFilteredSelected: table + .getFilteredRowModel() + .rows.some((r) => r.getIsSelected()), + toggleAllFiltered: (): void => { + const filteredRows = table.getFilteredRowModel().rows; + const allSelected = filteredRows.every((r) => r.getIsSelected()); + filteredRows.forEach((r) => r.toggleSelected(!allSelected)); + }, }, handles: { getPaginationState: () => pagination, @@ -208,10 +323,23 @@ const useTanStackTableBuilder = ( }, searchKeyword, onSearchKeywordChange: setSearchKeyword, - onDownloadCsv: props.csvDownload && generateAndDownloadCsv, + onDownloadCsv: + props.csvDownload && (props.csvDownload.showDownloadButton ?? true) + ? generateAndDownloadCsv + : undefined, csvDownloadLabel: props.csvDownload?.downloadButtonLabel, searchPlaceholder: props.search?.searchPlaceholder, buttons: props.toolbar?.buttons, + columnPicker: props.columnPicker, + getColumnVisibility: () => columnVisibility, + commitColumnVisibility: (next) => safeSetVisibility(() => next), + onExportFromPicker: + props.columnPicker && + ((visibility: Record): Promise => + generateAndDownloadCsv(visibility)), + onDirectExport: props.columnPicker + ? (): Promise => generateAndDownloadCsv() + : undefined, }, }; }; diff --git a/client/app/lib/components/table/__tests__/ColumnPickerTreeGroup.test.tsx b/client/app/lib/components/table/__tests__/ColumnPickerTreeGroup.test.tsx new file mode 100644 index 00000000000..c70d672997c --- /dev/null +++ b/client/app/lib/components/table/__tests__/ColumnPickerTreeGroup.test.tsx @@ -0,0 +1,141 @@ +import { fireEvent, render, screen } from '@testing-library/react'; + +import { ColumnPickerRenderCtx } from '../builder'; +import ColumnPickerTreeGroup from '../MuiTableAdapter/ColumnPickerTreeGroup'; + +const makeCtx = ( + visible: Record, +): ColumnPickerRenderCtx & { setManyVisible: jest.Mock } => ({ + isVisible: (id) => visible[id] ?? false, + setVisible: jest.fn(), + setManyVisible: jest.fn(), +}); + +describe('ColumnPickerTreeGroup', () => { + describe('parent checkbox state mirrors children visibility', () => { + it('is checked when all children are visible', () => { + const ctx = makeCtx({ a: true, b: true }); + render( + + + , + ); + expect(screen.getByRole('checkbox', { name: 'Group' })).toBeChecked(); + }); + + it('is unchecked and not indeterminate when no children are visible', () => { + const ctx = makeCtx({ a: false, b: false }); + render( + + + , + ); + const checkbox = screen.getByRole('checkbox', { name: 'Group' }); + expect(checkbox).not.toBeChecked(); + expect(checkbox.getAttribute('data-indeterminate')).toBe('false'); + }); + + it('is indeterminate and not checked when some but not all children are visible', () => { + const ctx = makeCtx({ a: true, b: false }); + render( + + + , + ); + const checkbox = screen.getByRole('checkbox', { name: 'Group' }); + expect(checkbox).not.toBeChecked(); + expect(checkbox.getAttribute('data-indeterminate')).toBe('true'); + }); + }); + + describe('cascading toggle', () => { + it('calls setManyVisible(childIds, true) when parent is clicked while unchecked', () => { + const ctx = makeCtx({ a: false, b: false }); + render( + + + , + ); + fireEvent.click(screen.getByRole('checkbox', { name: 'Group' })); + expect(ctx.setManyVisible).toHaveBeenCalledWith(['a', 'b'], true); + }); + + it('calls setManyVisible(childIds, false) when parent is clicked while checked', () => { + const ctx = makeCtx({ a: true, b: true }); + render( + + + , + ); + fireEvent.click(screen.getByRole('checkbox', { name: 'Group' })); + expect(ctx.setManyVisible).toHaveBeenCalledWith(['a', 'b'], false); + }); + + it('calls setManyVisible(childIds, true) when parent is clicked while indeterminate', () => { + const ctx = makeCtx({ a: true, b: false }); + render( + + + , + ); + fireEvent.click(screen.getByRole('checkbox', { name: 'Group' })); + expect(ctx.setManyVisible).toHaveBeenCalledWith(['a', 'b'], true); + }); + }); + + describe('locked behavior', () => { + it('disables the parent checkbox when all children are locked', () => { + const ctx = makeCtx({ a: true, b: true }); + render( + + + , + ); + expect(screen.getByRole('checkbox', { name: 'Group' })).toBeDisabled(); + }); + + it('does not disable the parent when only some children are locked', () => { + const ctx = makeCtx({ a: true, b: true }); + render( + + + , + ); + expect( + screen.getByRole('checkbox', { name: 'Group' }), + ).not.toBeDisabled(); + }); + + it('does not disable the parent when no children are locked', () => { + const ctx = makeCtx({ a: true, b: true }); + render( + + + , + ); + expect( + screen.getByRole('checkbox', { name: 'Group' }), + ).not.toBeDisabled(); + }); + }); + + it('renders children below the parent checkbox', () => { + const ctx = makeCtx({}); + render( + + Child + , + ); + expect(screen.getByTestId('child-node')).toBeInTheDocument(); + }); +}); diff --git a/client/app/lib/components/table/__tests__/MuiColumnPickerDialog.test.tsx b/client/app/lib/components/table/__tests__/MuiColumnPickerDialog.test.tsx new file mode 100644 index 00000000000..f1cac338422 --- /dev/null +++ b/client/app/lib/components/table/__tests__/MuiColumnPickerDialog.test.tsx @@ -0,0 +1,269 @@ +import { IntlProvider } from 'react-intl'; +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { ColumnPickerRenderCtx } from '../builder'; +import MuiColumnPickerDialog from '../MuiTableAdapter/MuiColumnPickerDialog'; + +const DIALOG_TITLE = 'Select columns'; + +const wrap = (node: JSX.Element): JSX.Element => ( + + {node} + +); + +const makeRenderTree = (ids: readonly string[]): jest.Mock => + jest.fn((ctx: ColumnPickerRenderCtx) => ( + <> + {ids.map((id) => ( + + ))} + + )); + +const setup = ( + overrides: Partial> = {}, +): ReturnType & { + commitColumnVisibility: jest.Mock; + onExportFromPicker: jest.Mock; + renderTree: jest.Mock; + props: React.ComponentProps; +} => { + const commitColumnVisibility = jest.fn(); + const onExportFromPicker = jest.fn(); + const renderTree = makeRenderTree(['name', 'email']); + const props = { + open: true, + onClose: jest.fn(), + initialVisibility: { name: true, email: true }, + locked: ['name'], + columnPicker: { + renderTree, + dialogTitle: DIALOG_TITLE, + exportLabel: 'Export CSV', + onExport: 'csv' as const, + }, + commitColumnVisibility, + onExportFromPicker, + ...overrides, + }; + return { + ...render(wrap()), + commitColumnVisibility, + onExportFromPicker, + renderTree, + props, + }; +}; + +describe('MuiColumnPickerDialog', () => { + it('renders the dialog title', () => { + setup(); + expect(screen.getByText(DIALOG_TITLE)).toBeInTheDocument(); + }); + + it('Apply commits staged changes and closes', async () => { + const user = userEvent.setup(); + const { commitColumnVisibility, props } = setup(); + await user.click(screen.getByLabelText('email')); + await user.click(screen.getByRole('button', { name: /apply/i })); + + expect(commitColumnVisibility).toHaveBeenCalledWith({ + name: true, + email: false, + }); + expect(props.onClose).toHaveBeenCalled(); + }); + + it('Cancel discards staged and closes without commit', async () => { + const user = userEvent.setup(); + const { commitColumnVisibility, props } = setup(); + await user.click(screen.getByLabelText('email')); + await user.click(screen.getByRole('button', { name: /cancel/i })); + + expect(commitColumnVisibility).not.toHaveBeenCalled(); + expect(props.onClose).toHaveBeenCalled(); + }); + + it('Export CSV commits + invokes onExportFromPicker + closes', async () => { + const user = userEvent.setup(); + const { commitColumnVisibility, onExportFromPicker, props } = setup(); + await user.click(screen.getByLabelText('email')); + await user.click(screen.getByRole('button', { name: /export csv/i })); + + expect(commitColumnVisibility).toHaveBeenCalledWith({ + name: true, + email: false, + }); + expect(onExportFromPicker).toHaveBeenCalledWith({ + name: true, + email: false, + }); + expect(props.onClose).toHaveBeenCalled(); + }); + + it('locked id forcibly restored to true on commit even if staged false', async () => { + const user = userEvent.setup(); + const { commitColumnVisibility } = setup({ + initialVisibility: { name: false, email: true }, // malformed input + }); + + await user.click(screen.getByRole('button', { name: /apply/i })); + + expect(commitColumnVisibility).toHaveBeenCalledWith({ + name: true, + email: true, + }); + }); + + describe('locked column behavior', () => { + const makeGroupRenderTree = (ids: readonly string[]): jest.Mock => + jest.fn( + (ctx: ColumnPickerRenderCtx): JSX.Element => ( + <> + + + + ), + ); + + it('deselect-all leaves the locked column checked', async () => { + const user = userEvent.setup(); + const commitColumnVisibility = jest.fn(); + render( + wrap( + , + ), + ); + await user.click(screen.getByRole('button', { name: 'Deselect all' })); + await user.click(screen.getByRole('button', { name: /apply/i })); + expect(commitColumnVisibility).toHaveBeenCalledWith({ + name: true, + email: false, + }); + }); + + it('select-all from indeterminate state selects non-locked column', async () => { + const user = userEvent.setup(); + const commitColumnVisibility = jest.fn(); + render( + wrap( + , + ), + ); + await user.click(screen.getByRole('button', { name: 'Select all' })); + await user.click(screen.getByRole('button', { name: /apply/i })); + expect(commitColumnVisibility).toHaveBeenCalledWith({ + name: true, + email: true, + }); + }); + + it('clicking a locked column checkbox has no effect on its visibility', async () => { + const user = userEvent.setup(); + const { commitColumnVisibility } = setup(); + await user.click(screen.getByLabelText('name')); + await user.click(screen.getByRole('button', { name: /apply/i })); + expect(commitColumnVisibility).toHaveBeenCalledWith({ + name: true, + email: true, + }); + }); + }); + + it('Esc key dismisses without committing', () => { + const { commitColumnVisibility, props } = setup(); + fireEvent.keyDown(screen.getByRole('dialog'), { + key: 'Escape', + code: 'Escape', + }); + expect(props.onClose).toHaveBeenCalled(); + expect(commitColumnVisibility).not.toHaveBeenCalled(); + }); + + describe('noDataColumnsHint', () => { + const dataSetup = ( + dataColumnIds: string[], + initialVisibility: Record, + ): ReturnType => + setup({ + initialVisibility, + columnPicker: { + renderTree: makeRenderTree(['name', 'grade']), + dialogTitle: DIALOG_TITLE, + exportLabel: 'Export CSV', + onExport: 'csv' as const, + dataColumnIds, + noDataColumnsHint: 'No grade columns selected.', + }, + }); + + it('shows hint when no data columns are selected', () => { + dataSetup(['grade'], { name: true, grade: false }); + expect( + screen.getByText('No grade columns selected.'), + ).toBeInTheDocument(); + }); + + it('hides hint when at least one data column is selected', () => { + dataSetup(['grade'], { name: true, grade: true }); + expect( + screen.queryByText('No grade columns selected.'), + ).not.toBeInTheDocument(); + }); + + it('Export button is enabled even when no data columns are selected', () => { + dataSetup(['grade'], { name: true, grade: false }); + expect( + screen.getByRole('button', { name: /export csv/i }), + ).not.toBeDisabled(); + }); + + it('Apply button is enabled even when no data columns are selected', () => { + dataSetup(['grade'], { name: true, grade: false }); + expect(screen.getByRole('button', { name: /apply/i })).not.toBeDisabled(); + }); + }); +}); diff --git a/client/app/lib/components/table/__tests__/MuiTableToolbar.test.tsx b/client/app/lib/components/table/__tests__/MuiTableToolbar.test.tsx new file mode 100644 index 00000000000..76870f763c6 --- /dev/null +++ b/client/app/lib/components/table/__tests__/MuiTableToolbar.test.tsx @@ -0,0 +1,64 @@ +import { IntlProvider } from 'react-intl'; +import { render, screen } from '@testing-library/react'; + +import { ToolbarProps } from '../adapters'; +import MuiTableToolbar from '../MuiTableAdapter/MuiTableToolbar'; + +const baseToolbar: ToolbarProps = { + renderNative: true, + searchKeyword: '', + onSearchKeywordChange: () => {}, +}; + +const wrap = (node: JSX.Element): JSX.Element => ( + + {node} + +); + +describe('MuiTableToolbar columnPicker trigger', () => { + it('does not render Export… button when columnPicker is unset', () => { + render(wrap()); + expect( + screen.queryByRole('button', { name: /export/i }), + ).not.toBeInTheDocument(); + }); + + it('renders Export… button when columnPicker is set', () => { + const props: ToolbarProps = { + ...baseToolbar, + columnPicker: { + renderTree: () => null, + triggerLabel: 'Export…', + onExport: 'csv' as const, + }, + getColumnVisibility: () => ({}), + commitColumnVisibility: () => {}, + }; + render(wrap()); + expect( + screen.getByRole('button', { name: /export…/i }), + ).toBeInTheDocument(); + }); +}); + +describe('MuiTableToolbar direct export button', () => { + const directExportProps: ToolbarProps = { + ...baseToolbar, + columnPicker: { + renderTree: () => null, + directExportLabel: 'Export all rows', + onExport: 'csv' as const, + }, + getColumnVisibility: () => ({}), + commitColumnVisibility: () => {}, + onDirectExport: async () => {}, + }; + + it('direct export button is enabled by default', () => { + render(wrap()); + expect( + screen.getByRole('button', { name: /export all rows/i }), + ).not.toBeDisabled(); + }); +}); diff --git a/client/app/lib/components/table/__tests__/csvGenerator.test.ts b/client/app/lib/components/table/__tests__/csvGenerator.test.ts new file mode 100644 index 00000000000..e4defeb62c2 --- /dev/null +++ b/client/app/lib/components/table/__tests__/csvGenerator.test.ts @@ -0,0 +1,222 @@ +import { + ColumnDef, + getCoreRowModel, + Table, + useReactTable, +} from '@tanstack/react-table'; +import { renderHook } from '@testing-library/react'; + +import { ColumnTemplate } from '../builder'; +import generateCsv from '../TanStackTableBuilder/csvGenerator'; + +interface Row { + id: number; + name: string; + email: string; + score: number; +} + +const fixture: Row[] = [ + { id: 1, name: 'Alice', email: 'alice@example.com', score: 90 }, + { id: 2, name: 'Bob', email: 'bob@example.com', score: 80 }, +]; + +const buildHarness = ( + visibility: Record, + selectedRowIds?: Record, +): { + table: Table; + getRealColumn: (id: string) => ColumnTemplate | undefined; +} => { + const templates: Record> = { + name: { + id: 'name', + title: 'Name', + cell: (r) => r.name, + csvDownloadable: true, + }, + email: { + id: 'email', + title: 'Email', + cell: (r) => r.email, + csvDownloadable: true, + }, + score: { + id: 'score', + title: 'Score', + cell: (r) => r.score, + csvDownloadable: false, + }, + }; + + const columnDefs: ColumnDef[] = Object.values(templates).map( + (tpl) => ({ + id: tpl.id, + header: tpl.title as string, + accessorFn: (row) => + (row as unknown as Record)[tpl.id as string], + cell: ({ row: { original } }) => tpl.cell(original), + }), + ); + + const { result } = renderHook(() => + useReactTable({ + data: fixture, + columns: columnDefs, + getCoreRowModel: getCoreRowModel(), + enableRowSelection: true, + state: { + columnVisibility: visibility, + rowSelection: selectedRowIds ?? {}, + }, + onColumnVisibilityChange: () => {}, + onRowSelectionChange: () => {}, + }), + ); + + return { + table: result.current, + getRealColumn: (id: string) => templates[id], + }; +}; + +describe('csvGenerator', () => { + it('emits headers and rows ordered by visible csv-downloadable columns', async () => { + const { table, getRealColumn } = buildHarness({ + name: true, + email: true, + score: true, + }); + + const csv = await generateCsv({ + table, + getRealColumn, + }); + + const lines = csv.trim().split(/\r?\n/); + expect(lines[0]).toBe('Name,Email'); // score has csvDownloadable: false + expect(lines[1]).toBe('Alice,alice@example.com'); + expect(lines[2]).toBe('Bob,bob@example.com'); + }); + + it('excludes hidden columns from output', async () => { + const { table, getRealColumn } = buildHarness({ + name: true, + email: false, + score: true, + }); + + const csv = await generateCsv({ + table, + getRealColumn, + }); + + const lines = csv.trim().split(/\r?\n/); + expect(lines[0]).toBe('Name'); + expect(lines[1]).toBe('Alice'); + expect(lines[2]).toBe('Bob'); + }); + + it('row cell count always equals header count', async () => { + const { table, getRealColumn } = buildHarness({ + name: true, + email: false, + score: true, + }); + + const csv = await generateCsv({ table, getRealColumn }); + + const lines = csv.trim().split(/\r?\n/); + const headerCount = lines[0].split(',').length; + lines + .slice(1) + .forEach((row) => expect(row.split(',')).toHaveLength(headerCount)); + }); + + describe('getExtraHeaderRows', () => { + it('inserts extra rows between the header row and data rows', async () => { + const { table, getRealColumn } = buildHarness({ + name: true, + email: true, + score: true, + }); + + const csv = await generateCsv({ + table, + getRealColumn, + getExtraHeaderRows: () => [['Extra A', 'Extra B']], + }); + + const lines = csv.trim().split(/\r?\n/); + expect(lines[0]).toBe('Name,Email'); + expect(lines[1]).toBe('Extra A,Extra B'); + expect(lines[2]).toBe('Alice,alice@example.com'); + }); + + it('is called with the visible csvDownloadable column ids', async () => { + const { table, getRealColumn } = buildHarness({ + name: true, + email: false, + score: true, + }); + const getExtraHeaderRows = jest.fn(() => []); + + await generateCsv({ table, getRealColumn, getExtraHeaderRows }); + + // email is hidden; score has csvDownloadable: false — only 'name' remains + expect(getExtraHeaderRows).toHaveBeenCalledWith(['name']); + }); + + it('supports multiple extra rows', async () => { + const { table, getRealColumn } = buildHarness({ + name: true, + email: true, + score: true, + }); + + const csv = await generateCsv({ + table, + getRealColumn, + getExtraHeaderRows: () => [ + ['Row1A', 'Row1B'], + ['Row2A', 'Row2B'], + ], + }); + + const lines = csv.trim().split(/\r?\n/); + expect(lines[0]).toBe('Name,Email'); + expect(lines[1]).toBe('Row1A,Row1B'); + expect(lines[2]).toBe('Row2A,Row2B'); + expect(lines[3]).toBe('Alice,alice@example.com'); + }); + }); + + it('exports only selected rows when onlySelected is true', async () => { + // TanStack uses row index as id by default: '0' = Alice, '1' = Bob + const { table, getRealColumn } = buildHarness( + { name: true, email: true, score: true }, + { '1': true }, // Bob selected + ); + + const csv = await generateCsv({ table, getRealColumn, onlySelected: true }); + + const lines = csv.trim().split(/\r?\n/); + expect(lines).toHaveLength(2); // header + Bob only + expect(lines[1]).toBe('Bob,bob@example.com'); + }); + + it('respects csvValue override', async () => { + const { getRealColumn: baseGet, table } = buildHarness({ + name: true, + email: true, + score: true, + }); + const wrapped = (id: string): ColumnTemplate | undefined => + id === 'name' + ? { ...baseGet('name')!, csvValue: (v: unknown) => `<<${String(v)}>>` } + : baseGet(id); + + const csv = await generateCsv({ table, getRealColumn: wrapped }); + expect(csv).toContain('<>'); + }); +}); diff --git a/client/app/lib/components/table/__tests__/useTanStackTableBuilder.test.tsx b/client/app/lib/components/table/__tests__/useTanStackTableBuilder.test.tsx new file mode 100644 index 00000000000..74ed0c12820 --- /dev/null +++ b/client/app/lib/components/table/__tests__/useTanStackTableBuilder.test.tsx @@ -0,0 +1,558 @@ +import { act, renderHook } from '@testing-library/react'; +import { downloadFile } from 'utilities/downloadFile'; + +import { ColumnTemplate, TableTemplate } from '../builder'; +import useTanStackTableBuilder from '../TanStackTableBuilder'; + +jest.mock('utilities/downloadFile', () => ({ + downloadFile: jest.fn(), +})); + +const mockedDownloadFile = jest.mocked(downloadFile); + +interface Row { + id: number; + name: string; + email: string; +} + +const baseColumns: ColumnTemplate[] = [ + { id: 'name', title: 'Name', cell: (r) => r.name, csvDownloadable: true }, + { id: 'email', title: 'Email', cell: (r) => r.email, csvDownloadable: true }, +]; + +const baseProps = ( + overrides: Partial> = {}, +): TableTemplate => ({ + data: [{ id: 1, name: 'Alice', email: 'alice@example.com' }], + columns: baseColumns, + getRowId: (r) => r.id.toString(), + ...overrides, +}); + +describe('useTanStackTableBuilder columnPicker state', () => { + it('initial visibility marks every column visible', () => { + const { result } = renderHook(() => + useTanStackTableBuilder( + baseProps({ + columnPicker: { + renderTree: () => null, + onExport: 'csv' as const, + }, + }), + ), + ); + + expect(result.current.toolbar!.getColumnVisibility?.()).toEqual({ + name: true, + email: true, + }); + }); + + it('locked id cannot be set to false via setVisible', () => { + const { result } = renderHook(() => + useTanStackTableBuilder( + baseProps({ + columnPicker: { + renderTree: () => null, + locked: ['name'], + onExport: 'csv' as const, + }, + }), + ), + ); + + const commit = result.current.toolbar!.commitColumnVisibility!; + act(() => commit({ name: false, email: true })); + + expect(result.current.toolbar!.getColumnVisibility?.()).toEqual({ + name: true, // forced back to true + email: true, + }); + }); + + it('setManyVisible toggles only unlocked descendants', () => { + // This test exercises the contract used by BulkSelectors in PR2 callers: + // when a branch deselects, locked descendants must remain visible. + const { result } = renderHook(() => + useTanStackTableBuilder( + baseProps({ + columnPicker: { + renderTree: () => null, + locked: ['name'], + onExport: 'csv' as const, + }, + }), + ), + ); + + act(() => + result.current.toolbar!.commitColumnVisibility!({ + name: false, + email: false, + }), + ); + expect(result.current.toolbar!.getColumnVisibility?.()).toEqual({ + name: true, + email: false, + }); + }); + + it('dynamic columns: adding a new column with defaultVisible: false defaults it hidden', () => { + const { result, rerender } = renderHook( + ({ extra }: { extra: boolean }) => + useTanStackTableBuilder( + baseProps({ + columns: extra + ? [ + ...baseColumns, + { + id: 'phone', + title: 'Phone', + cell: (): string => '', + csvDownloadable: true, + defaultVisible: false, + }, + ] + : baseColumns, + columnPicker: { + renderTree: () => null, + onExport: 'csv' as const, + }, + }), + ), + { initialProps: { extra: false } }, + ); + + rerender({ extra: true }); + + expect(result.current.toolbar!.getColumnVisibility?.()).toEqual({ + name: true, + email: true, + phone: false, + }); + }); + + it('dynamic columns: adding a new column id after mount defaults it visible', () => { + const { result, rerender } = renderHook( + ({ extra }: { extra: boolean }) => + useTanStackTableBuilder( + baseProps({ + columns: extra + ? [ + ...baseColumns, + { + id: 'phone', + title: 'Phone', + cell: (): string => '', + csvDownloadable: true, + }, + ] + : baseColumns, + columnPicker: { renderTree: () => null, onExport: 'csv' as const }, + }), + ), + { initialProps: { extra: false } }, + ); + + expect( + Object.keys(result.current.toolbar!.getColumnVisibility?.() ?? {}), + ).toEqual(['name', 'email']); + + rerender({ extra: true }); + + expect(result.current.toolbar!.getColumnVisibility?.()).toEqual({ + name: true, + email: true, + phone: true, // new column defaults visible + }); + }); +}); + +// CSV tests use `of:` (accessorKey) so TanStack can extract values via row.getValue(). +// The student statistics table uses the same `of:` pattern. +const csvColumns: ColumnTemplate[] = [ + { of: 'name', title: 'Name', cell: (r) => r.name, csvDownloadable: true }, + { of: 'email', title: 'Email', cell: (r) => r.email, csvDownloadable: true }, +]; + +describe('useTanStackTableBuilder CSV download', () => { + beforeEach(() => { + mockedDownloadFile.mockClear(); + }); + + it('CSV contains headers and rows for all csvDownloadable columns', async () => { + const { result } = renderHook(() => + useTanStackTableBuilder( + baseProps({ + columns: csvColumns, + csvDownload: { filename: 'test' }, + }), + ), + ); + + await act(async () => { + await result.current.toolbar!.onDownloadCsv?.(); + }); + + expect(mockedDownloadFile).toHaveBeenCalledTimes(1); + const csv: string = mockedDownloadFile.mock.calls[0][1]; + const lines = csv.trim().split(/\r?\n/); + expect(lines[0]).toBe('Name,Email'); + expect(lines[1]).toContain('Alice'); + }); + + it('CSV with indices: true still maps columns correctly (student statistics pattern)', async () => { + // Student statistics sets indexing.indices: true, which prepends an index column + // at position 0 in getAllLeafColumns(). getRealColumnById must offset correctly. + const { result } = renderHook(() => + useTanStackTableBuilder( + baseProps({ + columns: csvColumns, + csvDownload: { filename: 'test' }, + indexing: { indices: true }, + }), + ), + ); + + await act(async () => { + await result.current.toolbar!.onDownloadCsv?.(); + }); + + expect(mockedDownloadFile).toHaveBeenCalledTimes(1); + const csv: string = mockedDownloadFile.mock.calls[0][1]; + const lines = csv.trim().split(/\r?\n/); + // Headers must be Name and Email (not blank or offset titles from wrong template lookup) + expect(lines[0]).toBe('Name,Email'); + expect(lines[1]).toContain('Alice'); + }); + + it('CSV columns using accessorFn (not of) emit correct values', async () => { + // Regression: assessment columns have no `of` key — they use accessorFn to + // expose the grade value. row.getValue() must return the fn result, not undefined. + interface ScoreRow { + id: number; + name: string; + grades: Record; + } + const scoreData: ScoreRow[] = [ + { id: 1, name: 'Alice', grades: { 42: 9 } }, + { id: 2, name: 'Bob', grades: { 42: null } }, + ]; + const scoreColumns: ColumnTemplate[] = [ + { of: 'name', title: 'Name', cell: (r) => r.name, csvDownloadable: true }, + { + id: 'asn_42', + title: 'Quiz', + accessorFn: (r) => r.grades[42], + cell: (r) => r.grades[42] ?? '—', + csvDownloadable: true, + }, + ]; + const { result } = renderHook(() => + useTanStackTableBuilder({ + data: scoreData, + columns: scoreColumns, + getRowId: (r) => r.id.toString(), + csvDownload: { filename: 'test' }, + }), + ); + + await act(async () => { + await result.current.toolbar!.onDownloadCsv?.(); + }); + + const csv: string = mockedDownloadFile.mock.calls[0][1]; + const lines = csv.trim().split(/\r?\n/); + expect(lines[0]).toBe('Name,Quiz'); + expect(lines[1]).toBe('Alice,9'); + expect(lines[2]).toBe('Bob,'); + }); + + it('columnPicker getExtraHeaderRows inserts extra rows after the header', async () => { + const { result } = renderHook(() => + useTanStackTableBuilder( + baseProps({ + columns: csvColumns, + columnPicker: { + renderTree: () => null, + onExport: 'csv' as const, + getExtraHeaderRows: (colIds) => [colIds.map(() => 'max')], + }, + }), + ), + ); + + await act(async () => { + await result.current.toolbar!.onExportFromPicker?.({ + name: true, + email: true, + }); + }); + + expect(mockedDownloadFile).toHaveBeenCalledTimes(1); + const csv: string = mockedDownloadFile.mock.calls[0][1]; + const lines = csv.trim().split(/\r?\n/); + expect(lines[0]).toBe('Name,Email'); + expect(lines[1]).toBe('max,max'); + expect(lines[2]).toContain('Alice'); + }); + + it('exports only selected rows when rows are selected', async () => { + const twoRowData = [ + { id: 1, name: 'Alice', email: 'alice@example.com' }, + { id: 2, name: 'Bob', email: 'bob@example.com' }, + ]; + const { result } = renderHook(() => + useTanStackTableBuilder({ + data: twoRowData, + columns: csvColumns, + getRowId: (r) => r.id.toString(), + indexing: { rowSelectable: true }, + columnPicker: { + renderTree: () => null, + onExport: 'csv' as const, + }, + }), + ); + + // Select only Alice (row index 0) + act(() => result.current.body.rows[0].toggleSelected()); + + await act(async () => { + await result.current.toolbar!.onExportFromPicker?.({ + name: true, + email: true, + }); + }); + + expect(mockedDownloadFile).toHaveBeenCalledTimes(1); + const csv: string = mockedDownloadFile.mock.calls[0][1]; + const lines = csv.trim().split(/\r?\n/); + expect(lines).toHaveLength(2); // header + Alice only + expect(lines[1]).toContain('Alice'); + expect(csv).not.toContain('Bob'); + }); + + it('CSV excludes columns where csvDownloadable is false', async () => { + const columns: ColumnTemplate[] = [ + { of: 'name', title: 'Name', cell: (r) => r.name, csvDownloadable: true }, + { + of: 'email', + title: 'Email', + cell: (r) => r.email, + csvDownloadable: false, + }, + ]; + const { result } = renderHook(() => + useTanStackTableBuilder( + baseProps({ columns, csvDownload: { filename: 'test' } }), + ), + ); + + await act(async () => { + await result.current.toolbar!.onDownloadCsv?.(); + }); + + const csv: string = mockedDownloadFile.mock.calls[0][1]; + const lines = csv.trim().split(/\r?\n/); + expect(lines[0]).toBe('Name'); + expect(lines[0]).not.toContain('Email'); + }); +}); + +// ---------- cross-page row selection ---------- + +const threeRowData: Row[] = [ + { id: 1, name: 'Alice', email: 'alice@example.com' }, + { id: 2, name: 'Bob', email: 'bob@example.com' }, + { id: 3, name: 'Carol', email: 'carol@example.com' }, +]; + +describe('cross-page row selection', () => { + const selectionProps = (): TableTemplate => + baseProps({ + data: threeRowData, + indexing: { rowSelectable: true }, + pagination: { rowsPerPage: [2] }, + }); + + it('body.selectedCount is 0 when nothing is selected', () => { + const { result } = renderHook(() => + useTanStackTableBuilder(selectionProps()), + ); + expect(result.current.body.selectedCount).toBe(0); + }); + + it('body.selectedCount increments when a row on the current page is selected', () => { + const { result } = renderHook(() => + useTanStackTableBuilder(selectionProps()), + ); + act(() => result.current.body.rows[0].toggleSelected()); + expect(result.current.body.selectedCount).toBe(1); + }); + + it('body.selectedCount persists after navigating away from the page where the selection was made', () => { + const { result } = renderHook(() => + useTanStackTableBuilder(selectionProps()), + ); + // Page 1: Alice (id 1) and Bob (id 2) + act(() => result.current.body.rows[0].toggleSelected()); // select Alice + expect(result.current.body.selectedCount).toBe(1); + + // Navigate to page 2: Carol (id 3) only + act(() => result.current.pagination!.onPageChange?.(1)); + expect(result.current.body.rows).toHaveLength(1); // only Carol visible + expect(result.current.body.selectedCount).toBe(1); // Alice still counted + }); + + it('toggleAllFiltered selects all rows across all pages', () => { + const { result } = renderHook(() => + useTanStackTableBuilder(selectionProps()), + ); + act(() => result.current.body.toggleAllFiltered?.()); + expect(result.current.body.selectedCount).toBe(3); + expect(result.current.body.allFilteredSelected).toBe(true); + }); + + it('someFilteredSelected is true when only some rows are selected', () => { + const { result } = renderHook(() => + useTanStackTableBuilder(selectionProps()), + ); + act(() => result.current.body.rows[0].toggleSelected()); // Alice only + expect(result.current.body.someFilteredSelected).toBe(true); + expect(result.current.body.allFilteredSelected).toBe(false); + }); + + it('toggleAllFiltered twice deselects all rows', () => { + const { result } = renderHook(() => + useTanStackTableBuilder(selectionProps()), + ); + act(() => result.current.body.toggleAllFiltered?.()); // select all + act(() => result.current.body.toggleAllFiltered?.()); // deselect all + expect(result.current.body.selectedCount).toBe(0); + expect(result.current.body.allFilteredSelected).toBe(false); + }); +}); + +describe('localStorage persistence', () => { + beforeEach(() => localStorage.clear()); + + it('reads initial visibility from localStorage when storageKey is provided', () => { + localStorage.setItem( + 'test_key', + JSON.stringify({ name: false, email: true }), + ); + const { result } = renderHook(() => + useTanStackTableBuilder( + baseProps({ + columnPicker: { + renderTree: () => null, + onExport: 'csv' as const, + storageKey: 'test_key', + }, + }), + ), + ); + expect(result.current.toolbar!.getColumnVisibility?.()).toEqual({ + name: false, + email: true, + }); + }); + + it('writes visibility to localStorage on change', async () => { + const { result } = renderHook(() => + useTanStackTableBuilder( + baseProps({ + columnPicker: { + renderTree: () => null, + onExport: 'csv' as const, + storageKey: 'test_key', + }, + }), + ), + ); + act(() => + result.current.toolbar!.commitColumnVisibility!({ + name: false, + email: true, + }), + ); + expect(JSON.parse(localStorage.getItem('test_key')!)).toMatchObject({ + name: false, + email: true, + }); + }); + + it('falls back to defaultVisible when storageKey has no entry', () => { + const { result } = renderHook(() => + useTanStackTableBuilder( + baseProps({ + columns: [ + baseColumns[0], + { ...baseColumns[1], defaultVisible: false }, + ], + columnPicker: { + renderTree: () => null, + onExport: 'csv' as const, + storageKey: 'missing_key', + }, + }), + ), + ); + expect(result.current.toolbar!.getColumnVisibility?.()).toEqual({ + name: true, + email: false, + }); + }); +}); + +describe('useTanStackTableBuilder onDirectExport', () => { + beforeEach(() => { + mockedDownloadFile.mockClear(); + }); + + it('toolbar.onDirectExport is defined when columnPicker is provided', () => { + const { result } = renderHook(() => + useTanStackTableBuilder( + baseProps({ + columnPicker: { + renderTree: () => null, + onExport: 'csv' as const, + }, + }), + ), + ); + expect(result.current.toolbar!.onDirectExport).toBeDefined(); + }); + + it('toolbar.onDirectExport is undefined when no columnPicker is provided', () => { + const { result } = renderHook(() => useTanStackTableBuilder(baseProps())); + expect(result.current.toolbar!.onDirectExport).toBeUndefined(); + }); + + it('toolbar.onDirectExport downloads CSV using committed column visibility', async () => { + const { result } = renderHook(() => + useTanStackTableBuilder( + baseProps({ + columns: csvColumns, + csvDownload: { filename: 'my_gradebook' }, + columnPicker: { + renderTree: () => null, + onExport: 'csv' as const, + }, + }), + ), + ); + + await act(async () => { + await result.current.toolbar!.onDirectExport?.(); + }); + + expect(mockedDownloadFile).toHaveBeenCalledTimes(1); + const csv: string = mockedDownloadFile.mock.calls[0][1]; + const lines = csv.trim().split(/\r?\n/); + expect(lines[0]).toBe('Name,Email'); + expect(lines[1]).toContain('Alice'); + }); +}); diff --git a/client/app/lib/components/table/__tests__/utils.test.ts b/client/app/lib/components/table/__tests__/utils.test.ts new file mode 100644 index 00000000000..439b4f45576 --- /dev/null +++ b/client/app/lib/components/table/__tests__/utils.test.ts @@ -0,0 +1,33 @@ +import { downloadFile } from 'utilities/downloadFile'; + +import { downloadCsv } from '../utils'; + +jest.mock('utilities/downloadFile', () => ({ downloadFile: jest.fn() })); + +const mockDownloadFile = downloadFile as jest.Mock; + +describe('downloadCsv', () => { + beforeEach(() => mockDownloadFile.mockClear()); + + it('prepends a UTF-8 BOM so Excel detects UTF-8 encoding', () => { + downloadCsv('a,b\n1,2'); + const content: string = mockDownloadFile.mock.calls[0][1]; + expect(content.charCodeAt(0)).toBe(0xfeff); + }); + + it('preserves em dash characters after the BOM', () => { + downloadCsv('Name,Score\nAlice — Test,10'); + const content: string = mockDownloadFile.mock.calls[0][1]; + expect(content).toContain('—'); + }); + + it('uses the provided filename with .csv extension', () => { + downloadCsv('a,b', 'my-file'); + expect(mockDownloadFile.mock.calls[0][2]).toBe('my-file.csv'); + }); + + it('defaults to data.csv when no filename is given', () => { + downloadCsv('a,b'); + expect(mockDownloadFile.mock.calls[0][2]).toBe('data.csv'); + }); +}); diff --git a/client/app/lib/components/table/adapters/Body.ts b/client/app/lib/components/table/adapters/Body.ts index 4230762eb4f..955602d0dd9 100644 --- a/client/app/lib/components/table/adapters/Body.ts +++ b/client/app/lib/components/table/adapters/Body.ts @@ -26,6 +26,10 @@ interface BodyProps { getCells: (row: B) => C[]; forEachCell: (cell: C, row: B, index: number) => CellRender; forEachRow: (row: B, index: number) => RowRender; + selectedCount?: number; + allFilteredSelected?: boolean; + someFilteredSelected?: boolean; + toggleAllFiltered?: () => void; } export default BodyProps; diff --git a/client/app/lib/components/table/adapters/Toolbar.ts b/client/app/lib/components/table/adapters/Toolbar.ts index fe8fad0e38a..519aa9804a5 100644 --- a/client/app/lib/components/table/adapters/Toolbar.ts +++ b/client/app/lib/components/table/adapters/Toolbar.ts @@ -1,5 +1,7 @@ import { ReactNode } from 'react'; +import { ColumnPickerTemplate } from '../builder'; + interface ToolbarProps { renderNative?: boolean; alternative?: { @@ -13,6 +15,17 @@ interface ToolbarProps { csvDownloadLabel?: string; searchPlaceholder?: string; buttons?: ReactNode[]; + + /** Set when consumer passes `columnPicker` on TableTemplate. Drives "Select Columns" button + dialog. */ + columnPicker?: ColumnPickerTemplate; + /** Read-side accessor — called by the dialog to seed staged state. */ + getColumnVisibility?: () => Record; + /** Commit-side updater — called by the dialog on Apply / Export. */ + commitColumnVisibility?: (next: Record) => void; + /** Called when the user clicks "Apply and Export" in the dialog. Pre-bound to the CSV pipeline. */ + onExportFromPicker?: (visibility: Record) => void; + /** Called when the user clicks the direct Export button in the toolbar. Uses committed visibility. */ + onDirectExport?: () => Promise; } export default ToolbarProps; diff --git a/client/app/lib/components/table/builder/ColumnPickerTemplate.ts b/client/app/lib/components/table/builder/ColumnPickerTemplate.ts new file mode 100644 index 00000000000..15339f19393 --- /dev/null +++ b/client/app/lib/components/table/builder/ColumnPickerTemplate.ts @@ -0,0 +1,60 @@ +import { ReactNode } from 'react'; + +export interface ColumnPickerRenderCtx { + isVisible: (columnId: string) => boolean; + setVisible: (columnId: string, value: boolean) => void; + setManyVisible: (columnIds: string[], value: boolean) => void; +} + +interface ColumnPickerTemplate { + /** Caller renders its own tree using the provided ctx helpers. */ + renderTree: (ctx: ColumnPickerRenderCtx) => ReactNode; + + /** Column ids that render disabled-checked. Forcibly kept visible on every commit. */ + locked?: string[]; + + /** Toolbar trigger button text, default "Export…". Opens the picker dialog. */ + triggerLabel?: string; + + /** Label for the direct-export button rendered next to the trigger in the toolbar. */ + directExportLabel?: string; + + /** Tooltip shown on the direct-export button. */ + directExportTooltip?: string; + + /** Modal title, default "Select columns to export". */ + dialogTitle?: string; + + /** Reuses the table's client-side CSV pipeline for the Export CSV button. */ + onExport: 'csv'; + + /** CTA text inside the dialog, default "Apply and Export". */ + exportLabel?: string; + + /** + * Called at CSV export time with the ordered visible column IDs. + * Return one array per extra row to insert after the header row. + */ + getExtraHeaderRows?: (columnIds: string[]) => string[][]; + + /** + * localStorage key for persisting column visibility across page loads. + * When set, visibility is read from storage on mount and written on every change. + */ + storageKey?: string; + + /** + * Column ids that count as "data" columns (e.g. grade/gamification columns). + * When provided and none of these ids are visible in the staged selection, + * `noDataColumnsHint` is shown above the dialog actions. + */ + dataColumnIds?: string[]; + + /** + * Hint shown above the dialog actions when no `dataColumnIds` are selected. + * Has no effect if `dataColumnIds` is not provided. + */ + noDataColumnsHint?: string; +} + +export default ColumnPickerTemplate; diff --git a/client/app/lib/components/table/builder/ColumnTemplate.ts b/client/app/lib/components/table/builder/ColumnTemplate.ts index eda11a70c9d..cbfe6132ac7 100644 --- a/client/app/lib/components/table/builder/ColumnTemplate.ts +++ b/client/app/lib/components/table/builder/ColumnTemplate.ts @@ -23,6 +23,7 @@ interface ColumnTemplate { title: StringOrTemplateHeader; cell: (datum: D) => ReactNode; of?: keyof D; + accessorFn?: (datum: D) => unknown; id?: string; unless?: boolean; sortable?: boolean; @@ -36,6 +37,7 @@ interface ColumnTemplate { className?: string; colSpan?: (datum: D) => number; cellUnless?: (datum: D) => boolean; + defaultVisible?: boolean; } export default ColumnTemplate; diff --git a/client/app/lib/components/table/builder/TableTemplate.ts b/client/app/lib/components/table/builder/TableTemplate.ts index c6de07ae6d9..d6bba03f117 100644 --- a/client/app/lib/components/table/builder/TableTemplate.ts +++ b/client/app/lib/components/table/builder/TableTemplate.ts @@ -1,3 +1,4 @@ +import ColumnPickerTemplate from './ColumnPickerTemplate'; import ColumnTemplate, { Data } from './ColumnTemplate'; import { CsvDownloadTemplate, @@ -23,6 +24,7 @@ interface TableTemplate { filter?: FilterTemplate; toolbar?: ToolbarTemplate; sort?: SortTemplate; + columnPicker?: ColumnPickerTemplate; } export default TableTemplate; diff --git a/client/app/lib/components/table/builder/featureTemplates.ts b/client/app/lib/components/table/builder/featureTemplates.ts index 2e767fcc33f..7e3e57c3d9c 100644 --- a/client/app/lib/components/table/builder/featureTemplates.ts +++ b/client/app/lib/components/table/builder/featureTemplates.ts @@ -22,6 +22,7 @@ interface SearchProps { export interface CsvDownloadTemplate { filename?: string; downloadButtonLabel?: string; + showDownloadButton?: boolean; } export interface SearchTemplate { diff --git a/client/app/lib/components/table/builder/index.ts b/client/app/lib/components/table/builder/index.ts index 869466251d4..42675138667 100644 --- a/client/app/lib/components/table/builder/index.ts +++ b/client/app/lib/components/table/builder/index.ts @@ -1,4 +1,8 @@ export type { BuiltColumns } from './buildColumns'; export { buildColumns } from './buildColumns'; +export type { + ColumnPickerRenderCtx, + default as ColumnPickerTemplate, +} from './ColumnPickerTemplate'; export type { default as ColumnTemplate, Data } from './ColumnTemplate'; export type { default as TableTemplate } from './TableTemplate'; diff --git a/client/app/lib/components/table/index.tsx b/client/app/lib/components/table/index.tsx index 1161e5689ca..d6baf00a17e 100644 --- a/client/app/lib/components/table/index.tsx +++ b/client/app/lib/components/table/index.tsx @@ -1,2 +1,7 @@ -export type { ColumnTemplate } from './builder'; +export type { + ColumnPickerRenderCtx, + ColumnPickerTemplate, + ColumnTemplate, +} from './builder'; +export { default as ColumnPickerTreeGroup } from './MuiTableAdapter/ColumnPickerTreeGroup'; export { default } from './Table'; diff --git a/client/app/lib/components/table/utils.ts b/client/app/lib/components/table/utils.ts index 6b84e7a373c..916dfdba696 100644 --- a/client/app/lib/components/table/utils.ts +++ b/client/app/lib/components/table/utils.ts @@ -2,10 +2,13 @@ import { downloadFile } from 'utilities/downloadFile'; const DEFAULT_CSV_FILENAME = 'data' as const; +// Prepend UTF-8 BOM so Excel on macOS/Windows detects UTF-8 encoding instead +// of falling back to a legacy code page (Mac Roman / Windows-1252), which +// misreads multibyte characters like em dash (U+2014) as mojibake (e.g. "‚Äî"). export const downloadCsv = (csvData: string, filename?: string): void => { downloadFile( 'text/csv;charset=utf-8', - csvData, + `\uFEFF${csvData}`, `${filename ?? DEFAULT_CSV_FILENAME}.csv`, ); }; diff --git a/client/app/lib/constants/icons.ts b/client/app/lib/constants/icons.ts index e73e495c3f3..9c1d328e12a 100644 --- a/client/app/lib/constants/icons.ts +++ b/client/app/lib/constants/icons.ts @@ -50,6 +50,8 @@ import { Star, StarOutline, SvgIconComponent, + TableChart, + TableChartOutlined, Upload, UploadOutlined, Videocam, @@ -65,6 +67,7 @@ interface IconTuple { export const COURSE_COMPONENT_ICONS = { achievement: { outlined: EmojiEventsOutlined, filled: EmojiEvents }, + gradebook: { outlined: TableChartOutlined, filled: TableChart }, assessment: { outlined: SendOutlined, filled: Send }, material: { outlined: FolderOutlined, filled: Folder }, survey: { outlined: PieChartOutlined, filled: PieChart }, diff --git a/client/app/routers/course/admin.tsx b/client/app/routers/course/admin.tsx index f9e1e1028b1..a157415aaf6 100644 --- a/client/app/routers/course/admin.tsx +++ b/client/app/routers/course/admin.tsx @@ -119,6 +119,17 @@ const adminRouter: Translated = (_) => ({ ).default, }), }, + { + path: 'gradebook', + lazy: async (): Promise> => ({ + Component: ( + await import( + /* webpackChunkName: 'GradebookSettings' */ + 'course/admin/pages/GradebookSettings' + ) + ).default, + }), + }, { path: 'comments', lazy: async (): Promise> => ({ diff --git a/client/app/routers/course/gradebook.tsx b/client/app/routers/course/gradebook.tsx new file mode 100644 index 00000000000..3b366cfa106 --- /dev/null +++ b/client/app/routers/course/gradebook.tsx @@ -0,0 +1,23 @@ +import { RouteObject } from 'react-router-dom'; +import { WithRequired } from 'types'; + +import { Translated } from 'lib/hooks/useTranslation'; + +const gradebookRouter: Translated = (_t) => ({ + path: 'gradebook', + lazy: async (): Promise> => { + const [{ gradebookHandle }, GradebookIndex] = await Promise.all([ + import( + /* webpackChunkName: 'gradebookHandle' */ + 'course/gradebook/handles' + ), + import( + /* webpackChunkName: 'GradebookIndex' */ + 'course/gradebook/pages/GradebookIndex' + ).then((m) => m.default), + ]); + return { Component: GradebookIndex, handle: gradebookHandle }; + }, +}); + +export default gradebookRouter; diff --git a/client/app/routers/course/index.tsx b/client/app/routers/course/index.tsx index 047262ac54e..13bc90b1aca 100644 --- a/client/app/routers/course/index.tsx +++ b/client/app/routers/course/index.tsx @@ -7,6 +7,7 @@ import achievementsRouter from './achievements'; import adminRouter from './admin'; import assessmentsRouter from './assessments'; import forumsRouter from './forums'; +import gradebookRouter from './gradebook'; import groupsRouter from './groups'; import lessonPlanRouter from './lessonPlan'; import materialsRouter from './materials'; @@ -41,6 +42,7 @@ const courseRouter: Translated = (t) => ({ adminRouter(t), assessmentsRouter(t), forumsRouter(t), + gradebookRouter(t), groupsRouter(t), lessonPlanRouter(t), materialsRouter(t), diff --git a/client/app/store.ts b/client/app/store.ts index 456ac7acbc0..3117dabc973 100644 --- a/client/app/store.ts +++ b/client/app/store.ts @@ -28,6 +28,7 @@ import enrolRequestsReducer from './bundles/course/enrol-requests/store'; import disbursementReducer from './bundles/course/experience-points/disbursement/store'; import experiencePointsReducer from './bundles/course/experience-points/store'; import forumsReducer from './bundles/course/forum/store'; +import gradebookReducer from './bundles/course/gradebook/store'; import groupsReducer from './bundles/course/group/store'; import leaderboardReducer from './bundles/course/leaderboard/store'; import learningMapReducer from './bundles/course/learning-map/store'; @@ -64,6 +65,7 @@ const rootReducer = combineReducers({ enrolRequests: enrolRequestsReducer, folders: foldersReducer, forums: forumsReducer, + gradebook: gradebookReducer, groups: groupsReducer, invitations: invitationsReducer, leaderboard: leaderboardReducer, diff --git a/client/app/types/course/admin/gradebook.ts b/client/app/types/course/admin/gradebook.ts new file mode 100644 index 00000000000..13301111fa0 --- /dev/null +++ b/client/app/types/course/admin/gradebook.ts @@ -0,0 +1,9 @@ +export interface GradebookSettingsData { + weightedViewEnabled: boolean; +} + +export interface GradebookSettingsPostData { + settings_gradebook_component: { + weighted_view_enabled: GradebookSettingsData['weightedViewEnabled']; + }; +} diff --git a/client/app/types/course/gradebook.ts b/client/app/types/course/gradebook.ts new file mode 100644 index 00000000000..d041add93b8 --- /dev/null +++ b/client/app/types/course/gradebook.ts @@ -0,0 +1,47 @@ +export interface CategoryData { + id: number; + title: string; +} + +export interface TabData { + id: number; + title: string; + categoryId: number; + gradebookWeight?: number; +} + +export interface AssessmentData { + id: number; + title: string; + tabId: number; + maxGrade: number; +} + +export interface StudentData { + id: number; + name: string; + email: string; + level: number; + totalXp: number; +} + +export interface SubmissionData { + studentId: number; + assessmentId: number; + grade: number | null; +} + +export interface GradebookData { + categories: CategoryData[]; + tabs: TabData[]; + assessments: AssessmentData[]; + students: StudentData[]; + submissions: SubmissionData[]; + gamificationEnabled: boolean; + weightedViewEnabled: boolean; + canManageWeights: boolean; +} + +export interface UpdateWeightsPayload { + weights: { tabId: number; weight: number }[]; +} diff --git a/client/jest.config.js b/client/jest.config.js index abcae92775b..947fb088890 100644 --- a/client/jest.config.js +++ b/client/jest.config.js @@ -25,6 +25,7 @@ const config = { '^store(.*)$': '/app/store$1', '^lodash-es(.*)$': 'lodash$1', }, + testPathIgnorePatterns: ['/node_modules/', '/dist/'], coveragePathIgnorePatterns: ['/node_modules/', '/__test__/'], }; diff --git a/client/locales/en.json b/client/locales/en.json index 66413747666..54c1aae06b0 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -1,4 +1,7 @@ { + "2kgbTg": { + "defaultMessage": "Role-Playing Assessment" + }, "announcements.GlobalAnnouncementIndex.fetchAnnouncementsFailure": { "defaultMessage": "Unable to fetch announcements" }, @@ -29,6 +32,9 @@ "app.DashboardPage.yourCourses": { "defaultMessage": "Your Courses" }, + "app.ErrorPage.courseSuspended": { + "defaultMessage": "This course is suspended." + }, "app.ErrorPage.error": { "defaultMessage": "KABOOM, a meteor has just crashed." }, @@ -59,14 +65,11 @@ "app.ErrorPage.notFoundSubtitle": { "defaultMessage": "Check if you've typed the correct address, try again later, or go back home." }, - "app.ErrorPage.userSuspended": { - "defaultMessage": "Your access to this course has been suspended." - }, "app.ErrorPage.suspendedSubtitle": { "defaultMessage": "Please contact your instructors or the course staff." }, - "app.ErrorPage.courseSuspended": { - "defaultMessage": "This course is suspended." + "app.ErrorPage.userSuspended": { + "defaultMessage": "Your access to this course has been suspended." }, "app.Footer.contactUs": { "defaultMessage": "Contact Us" @@ -80,12 +83,12 @@ "app.Footer.instructorsGuide": { "defaultMessage": "Instructors' Guide" }, - "app.Footer.reportIssue": { - "defaultMessage": "Report an Issue" - }, "app.Footer.privacyPolicy": { "defaultMessage": "Privacy Policy" }, + "app.Footer.reportIssue": { + "defaultMessage": "Report an Issue" + }, "app.Footer.termsOfService": { "defaultMessage": "Terms of Service" }, @@ -101,6 +104,9 @@ "assessment.attemptLoader.errorAttemptingAssessment": { "defaultMessage": "An error occurred while attempting this assessment. Try again later." }, + "bW7B87": { + "defaultMessage": "New" + }, "client.video.attemptLoader.errorWatchVideo": { "defaultMessage": "An error occurred while attempting to watch this video. Try again later." }, @@ -263,23 +269,23 @@ "course.achievement.AchievementAward.AchievementAwardManager.saveChanges": { "defaultMessage": "Save Changes" }, + "course.achievement.AchievementAward.AchievementAwardSummary.awardedStudents": { + "defaultMessage": "Awarded Students" + }, "course.achievement.AchievementAward.AchievementAwardSummary.name": { "defaultMessage": "Name" }, - "course.achievement.AchievementAward.AchievementAwardSummary.userType": { - "defaultMessage": "User Type" + "course.achievement.AchievementAward.AchievementAwardSummary.normalStudent": { + "defaultMessage": "Normal Student" }, - "course.achievement.AchievementAward.AchievementAwardSummary.awardedStudents": { - "defaultMessage": "Awarded Students" + "course.achievement.AchievementAward.AchievementAwardSummary.phantomStudent": { + "defaultMessage": "Phantom Student" }, "course.achievement.AchievementAward.AchievementAwardSummary.revokedStudents": { "defaultMessage": "Revoked Students" }, - "course.achievement.AchievementAward.AchievementAwardSummary.phantomStudent": { - "defaultMessage": "Phantom Student" - }, - "course.achievement.AchievementAward.AchievementAwardSummary.normalStudent": { - "defaultMessage": "Normal Student" + "course.achievement.AchievementAward.AchievementAwardSummary.userType": { + "defaultMessage": "User Type" }, "course.achievement.AchievementAward.awardAchievement": { "defaultMessage": "Award Achievement" @@ -353,26 +359,26 @@ "course.achievement.AchievementShow.studentsWithAchievement": { "defaultMessage": "Students with this achievement" }, - "course.achievement.AchievementTable.noAchievement": { - "defaultMessage": "No achievement" + "course.achievement.AchievementTable.actions": { + "defaultMessage": "Actions" }, "course.achievement.AchievementTable.badge": { "defaultMessage": "Badge" }, - "course.achievement.AchievementTable.title": { - "defaultMessage": "Title" - }, "course.achievement.AchievementTable.description": { "defaultMessage": "Description" }, - "course.achievement.AchievementTable.requirements": { - "defaultMessage": "Requirements" + "course.achievement.AchievementTable.noAchievement": { + "defaultMessage": "No achievement" }, "course.achievement.AchievementTable.published": { "defaultMessage": "Published" }, - "course.achievement.AchievementTable.actions": { - "defaultMessage": "Actions" + "course.achievement.AchievementTable.requirements": { + "defaultMessage": "Requirements" + }, + "course.achievement.AchievementTable.title": { + "defaultMessage": "Title" }, "course.achievement.AchievementsIndex.achievements": { "defaultMessage": "Achievements" @@ -515,46 +521,6 @@ "course.admin.AssessmentSettings.toTab": { "defaultMessage": "to {tab}" }, - "course.admin.CodaveriSettings.codaveriModel": { - "defaultMessage": "Model" - }, - "course.admin.CodaveriSettings.codaveriModelDescription": { - "defaultMessage": "The AI model used by Codaveri to generate help conversations with students for programming questions." - }, - "course.admin.CodaveriSettings.codaveriSystemPromptDescription": { - "defaultMessage": - "You may customize the behavior of the Codaveri model by providing instructions here. {br} When assisting students, these instructions will be followed in addition to any you have set on the question itself.{br}To reference question-specific details, you may use the following variables within the prompt, writing them with brackets as shown below:" - }, - "course.admin.CodaveriSettings.codaveriSystemPromptProblemDescriptionLine": { - "defaultMessage": "{problemDescriptionVar} : The full description of the coding problem." - }, - "course.admin.CodaveriSettings.codaveriSystemPromptStudentFilePathsLine": { - "defaultMessage": "{studentFilePathsVar} : A comma-separated list of file paths the student is working on." - }, - "course.admin.CodaveriSettings.codaveriSettings": { - "defaultMessage": "Codaveri settings" - }, - "course.admin.CodaveriSettings.codaveriSettingsSubtitle": { - "defaultMessage": "This is currently an experimental feature. Codaveri provides code evaluation and automated code feedback services for students' codes." - }, - "course.admin.CodaveriSettings.feedbackWorkflow": { - "defaultMessage": "Automatic Post-Submission Comments" - }, - "course.admin.CodaveriSettings.feedbackWorkflowDescription": { - "defaultMessage": "When a submission with programming question is finalised," - }, - "course.admin.CodaveriSettings.feedbackWorkflowNone": { - "defaultMessage": "Generate no feedback" - }, - "course.admin.CodaveriSettings.feedbackWorkflowDraft": { - "defaultMessage": "Generate feedback as a draft requiring approval from staff" - }, - "course.admin.CodaveriSettings.feedbackWorkflowPublish": { - "defaultMessage": "Publish feedback directly to student" - }, - "course.admin.CodaveriSettings.error": { - "defaultMessage": "An error occurred while updating the codaveri setting." - }, "course.admin.CodaveriSettings.Some": { "defaultMessage": "Some" }, @@ -570,32 +536,80 @@ "course.admin.CodaveriSettings.codaveriEngineDescription": { "defaultMessage": "Type of codaveri engine used to generate programming code feedback" }, + "course.admin.CodaveriSettings.codaveriEvaluatorSettings": { + "defaultMessage": "Codaveri Evaluator" + }, + "course.admin.CodaveriSettings.codaveriModel": { + "defaultMessage": "Model" + }, + "course.admin.CodaveriSettings.codaveriModelDescription": { + "defaultMessage": "The AI model used by Codaveri to generate help conversations with students for programming questions." + }, "course.admin.CodaveriSettings.codaveriOverrideSystemPrompt": { "defaultMessage": "Use a custom system prompt" }, "course.admin.CodaveriSettings.codaveriOverrideSystemPromptDescription": { "defaultMessage": "When assisting students, these instructions will be followed in addition to any you have set on the question itself. To reference question-specific details, you may use these variables within the prompt, writing them with brackets as shown below:" }, + "course.admin.CodaveriSettings.codaveriSettings": { + "defaultMessage": "Codaveri settings" + }, + "course.admin.CodaveriSettings.codaveriSettingsSubtitle": { + "defaultMessage": "This is currently an experimental feature. Codaveri provides code evaluation and automated code feedback services for students' codes." + }, "course.admin.CodaveriSettings.codaveriSystemPrompt": { "defaultMessage": "System Prompt" }, + "course.admin.CodaveriSettings.codaveriSystemPromptDescription": { + "defaultMessage": "The Codaveri system prompt controls AI behavior when interacting with students." + }, + "course.admin.CodaveriSettings.codaveriSystemPromptProblemDescriptionLine": { + "defaultMessage": "{problemDescriptionVar} : The full description of the coding problem." + }, + "course.admin.CodaveriSettings.codaveriSystemPromptStudentFilePathsLine": { + "defaultMessage": "{studentFilePathsVar} : A comma-separated list of file paths the student is working on." + }, "course.admin.CodaveriSettings.codaveriUseDefaultSystemPrompt": { "defaultMessage": "Use the default system prompt" }, + "course.admin.CodaveriSettings.enableDisableButton": { + "defaultMessage": "{enabled, select, true {Enable} other {Disable}}" + }, + "course.admin.CodaveriSettings.enableDisableEvaluator": { + "defaultMessage": "{enabled, select, true {Enable } other {Disable }} Codaveri Evaluator for {questionCount} programming questions in {title}?" + }, + "course.admin.CodaveriSettings.enableDisableEvaluatorDescription": { + "defaultMessage": "{questionCount} programming questions in this {type} will use {enabled, select, true {Codaveri } other {Default }} evaluator" + }, + "course.admin.CodaveriSettings.enableDisableLiveFeedback": { + "defaultMessage": "{enabled, select, true {Enable } other {Disable }} Get Help for {questionCount} programming questions in {title}?" + }, + "course.admin.CodaveriSettings.errorOccurredWhenUpdatingCodaveriEvaluatorSettings": { + "defaultMessage": "An error occurred while updating the codaveri evaluator settings." + }, + "course.admin.CodaveriSettings.errorOccurredWhenUpdatingLiveFeedbackSettings": { + "defaultMessage": "An error occurred while updating the Get Help settings." + }, "course.admin.CodaveriSettings.evaluatorUpdateSuccess": { "defaultMessage": "{question} is now using {evaluator} evaluator" }, "course.admin.CodaveriSettings.expandAll": { "defaultMessage": "Expand All Questions" }, - "course.admin.CodaveriSettings.programmingQuestionSettings": { - "defaultMessage": "Programming Question Settings" + "course.admin.CodaveriSettings.feedbackWorkflow": { + "defaultMessage": "Automatic Post-Submission Comments" }, - "course.admin.CodaveriSettings.programmingQuestionSettingsSubtitle": { - "defaultMessage": "Enable/disable Codaveri as evaluator for programming questions in various assessments." + "course.admin.CodaveriSettings.feedbackWorkflowDescription": { + "defaultMessage": "When a submission with programming question is finalised," }, - "course.admin.CodaveriSettings.succesfulUpdateAllEvaluator": { - "defaultMessage": "Successfully updated all questions to use {evaluator} evaluator" + "course.admin.CodaveriSettings.feedbackWorkflowDraft": { + "defaultMessage": "Generate feedback as a draft requiring approval from staff" + }, + "course.admin.CodaveriSettings.feedbackWorkflowNone": { + "defaultMessage": "Generate no feedback" + }, + "course.admin.CodaveriSettings.feedbackWorkflowPublish": { + "defaultMessage": "Publish feedback directly to student" }, "course.admin.CodaveriSettings.getHelpUsageLimit": { "defaultMessage": "Limit Get Help messages per student" @@ -603,35 +617,23 @@ "course.admin.CodaveriSettings.getHelpUsageLimitDescription": { "defaultMessage": "If enabled, students will only be able to send a limited number of messages per question. Students will be able to see this limit and how many messages they have left." }, - "course.admin.CodaveriSettings.maxGetHelpUserMessages": { - "defaultMessage": "Maximum messages per question" - }, - "course.admin.CodaveriSettings.errorOccurredWhenUpdatingCodaveriEvaluatorSettings": { - "defaultMessage": "An error occurred while updating the codaveri evaluator settings." - }, - "course.admin.CodaveriSettings.codaveriEvaluatorSettings": { - "defaultMessage": "Codaveri Evaluator" + "course.admin.CodaveriSettings.liveFeedbackEnabledUpdateSuccess": { + "defaultMessage": "Get Help for {question} is now {liveFeedbackEnabled, select, true {enabled} other {disabled}}" }, "course.admin.CodaveriSettings.liveFeedbackSettings": { "defaultMessage": "Get Help" }, - "course.admin.CodaveriSettings.errorOccurredWhenUpdatingLiveFeedbackSettings": { - "defaultMessage": "An error occurred while updating the Get Help settings." - }, - "course.admin.CodaveriSettings.enableDisableButton": { - "defaultMessage": "{enabled, select, true {Enable} other {Disable}}" - }, - "course.admin.CodaveriSettings.enableDisableEvaluator": { - "defaultMessage": "{enabled, select, true {Enable } other {Disable }} Codaveri Evaluator for {questionCount} programming questions in {title}?" + "course.admin.CodaveriSettings.maxGetHelpUserMessages": { + "defaultMessage": "Maximum messages per question" }, - "course.admin.CodaveriSettings.enableDisableLiveFeedback": { - "defaultMessage": "{enabled, select, true {Enable } other {Disable }} Get Help for {questionCount} programming questions in {title}?" + "course.admin.CodaveriSettings.programmingQuestionSettings": { + "defaultMessage": "Programming Question Settings" }, - "course.admin.CodaveriSettings.enableDisableEvaluatorDescription": { - "defaultMessage": "{questionCount} programming questions in this {type} will use {enabled, select, true {Codaveri } other {Default }} evaluator" + "course.admin.CodaveriSettings.programmingQuestionSettingsSubtitle": { + "defaultMessage": "Enable/disable Codaveri as evaluator for programming questions in various assessments." }, - "course.admin.CodaveriSettings.liveFeedbackEnabledUpdateSuccess": { - "defaultMessage": "Get Help for {question} is now {liveFeedbackEnabled, select, true {enabled} other {disabled}}" + "course.admin.CodaveriSettings.succesfulUpdateAllEvaluator": { + "defaultMessage": "Successfully updated all questions to use {evaluator} evaluator" }, "course.admin.CodaveriSettings.successfulUpdateAllLiveFeedbackEnabled": { "defaultMessage": "Successfully {liveFeedbackEnabled, select, true {enabled} other {disabled}} Get Help for all questions" @@ -648,9 +650,15 @@ "course.admin.ComponentSettings.errorOccurredWhenUpdatingComponents": { "defaultMessage": "An error occurred while updating the component settings." }, + "course.admin.ComponentSettings.settingUpComponent": { + "defaultMessage": "Setting up component for this course" + }, "course.admin.CourseSettings.allowUsersToSendEnrolmentRequests": { "defaultMessage": "Allow users to send enrolment requests" }, + "course.admin.CourseSettings.autoApproveEnrolmentRequests": { + "defaultMessage": "Automatically approve enrolment requests" + }, "course.admin.CourseSettings.clearChanges": { "defaultMessage": "Clear changes" }, @@ -678,6 +686,12 @@ "course.admin.CourseSettings.courseSettings": { "defaultMessage": "Course settings" }, + "course.admin.CourseSettings.courseSuspensionMessage": { + "defaultMessage": "Course suspension message" + }, + "course.admin.CourseSettings.courseSuspensionMessageDescription": { + "defaultMessage": "This message will be shown to users while this course is suspended. Leave blank to show a default message." + }, "course.admin.CourseSettings.daysInAdvance": { "defaultMessage": "Days in advance" }, @@ -765,23 +779,14 @@ "course.admin.CourseSettings.stragglersDescription": { "defaultMessage": "Leave no one behind; subsequent closing reference timings will be pushed back if students complete their assessments late." }, - "course.admin.CourseSettings.suspension": { - "defaultMessage": "Access suspension" - }, "course.admin.CourseSettings.suspendCourse": { "defaultMessage": "Suspend course" }, "course.admin.CourseSettings.suspendCourseDescription": { "defaultMessage": "A suspended course is inaccessible to all students. Instructors can still access the course and all student data will be retained." }, - "course.admin.CourseSettings.unsuspendCourse": { - "defaultMessage": "Unsuspend course" - }, - "course.admin.CourseSettings.courseSuspensionMessage": { - "defaultMessage": "Course suspension message" - }, - "course.admin.CourseSettings.courseSuspensionMessageDescription": { - "defaultMessage": "This message will be shown to users while this course is suspended. Leave blank to show a default message." + "course.admin.CourseSettings.suspendCourseFailure": { + "defaultMessage": "An error occurred while suspending this course." }, "course.admin.CourseSettings.suspendCoursePromptText": { "defaultMessage": "Are you sure you want to suspend this course? All students will not be able to access it until it is unsuspended." @@ -789,20 +794,8 @@ "course.admin.CourseSettings.suspendCourseSuccess": { "defaultMessage": "This course has been suspended." }, - "course.admin.CourseSettings.suspendCourseFailure": { - "defaultMessage": "An error occurred while suspending this course." - }, - "course.admin.CourseSettings.unsuspendCourseSuccess": { - "defaultMessage": "This course has been unsuspended." - }, - "course.admin.CourseSettings.unsuspendCourseFailure": { - "defaultMessage": "An error occurred while unsuspending this course." - }, - "course.admin.CourseSettings.userSuspensionMessage": { - "defaultMessage": "User suspension message" - }, - "course.admin.CourseSettings.userSuspensionMessageDescription": { - "defaultMessage": "This message will be shown to individual users whose access to this course has been suspended. Leave blank to show a default message." + "course.admin.CourseSettings.suspension": { + "defaultMessage": "Access suspension" }, "course.admin.CourseSettings.timeSettings": { "defaultMessage": "Time settings" @@ -813,12 +806,27 @@ "course.admin.CourseSettings.titleRequired": { "defaultMessage": "Course name is required." }, + "course.admin.CourseSettings.unsuspendCourse": { + "defaultMessage": "Unsuspend course" + }, + "course.admin.CourseSettings.unsuspendCourseFailure": { + "defaultMessage": "An error occurred while unsuspending this course." + }, + "course.admin.CourseSettings.unsuspendCourseSuccess": { + "defaultMessage": "This course has been unsuspended." + }, "course.admin.CourseSettings.uploadANewImage": { "defaultMessage": "Choose a new image" }, "course.admin.CourseSettings.uploadingLogo": { "defaultMessage": "Uploading your new logo..." }, + "course.admin.CourseSettings.userSuspensionMessage": { + "defaultMessage": "User suspension message" + }, + "course.admin.CourseSettings.userSuspensionMessageDescription": { + "defaultMessage": "This message will be shown to individual users whose access to this course has been suspended. Leave blank to show a default message." + }, "course.admin.CourseSettingst.confirmDeletePlaceholder": { "defaultMessage": "This is your last chance to go back!" }, @@ -873,6 +881,15 @@ "course.admin.ForumsSettings.markPostAsAnswerSetting": { "defaultMessage": "User who can mark a post as answer" }, + "course.admin.GradebookSettings.gradebookSettings": { + "defaultMessage": "Gradebook settings" + }, + "course.admin.GradebookSettings.weightedViewEnabled": { + "defaultMessage": "Enable weighted grade view" + }, + "course.admin.GradebookSettings.weightedViewEnabledHint": { + "defaultMessage": "Enables a \"By weight\" view in the gradebook where staff can configure per-tab weights and see a weighted Total column." + }, "course.admin.LeaderboardSettings.displayUserCount": { "defaultMessage": "Display user count" }, @@ -925,7 +942,10 @@ "defaultMessage": "Component Item Settings" }, "course.admin.LessonPlanSettings.lessonPlanItemSettings": { - "defaultMessage": "Lesson Plan Item Settings" + "defaultMessage": "Item Settings" + }, + "course.admin.LessonPlanSettings.lessonPlanSettings": { + "defaultMessage": "Lesson Plan Settings" }, "course.admin.LessonPlanSettings.noLessonPlanItems": { "defaultMessage": "There are no lesson plan items to configure for lesson plan display." @@ -942,11 +962,14 @@ "course.admin.MaterialSettings.materialsSettings": { "defaultMessage": "Materials settings" }, + "course.admin.NotificationSettings.component": { + "defaultMessage": "Component" + }, "course.admin.NotificationSettings.description": { "defaultMessage": "Description" }, "course.admin.NotificationSettings.emailSettings": { - "defaultMessage": "Email Settings" + "defaultMessage": "Email settings" }, "course.admin.NotificationSettings.noEmailSettings": { "defaultMessage": "None of the enabled components have email settings." @@ -1059,28 +1082,148 @@ "course.admin.NotificationSettings.updateSuccess": { "defaultMessage": "The email setting \"{setting}\" for {user} users has been {action}." }, - "course.admin.SidebarSettings.errorOccurredWhenUpdatingSidebar": { - "defaultMessage": "An error occurred while updating the sidebar ordering." + "course.admin.RagWiseSettings.ForumKnowledgeBaseSwitch.addFailure": { + "defaultMessage": "{forum} could not be added to knowledge base." }, - "course.admin.SidebarSettings.sidebarSettings": { - "defaultMessage": "Student's sidebar ordering" + "course.admin.RagWiseSettings.ForumKnowledgeBaseSwitch.addSuccess": { + "defaultMessage": "{forum} {n, plural, one {has} other {have}} been added to knowledge base." }, - "course.admin.SidebarSettings.sidebarSettingsSubtitle": { - "defaultMessage": "Drag and drop the sidebar items to rearrange." + "course.admin.RagWiseSettings.ForumKnowledgeBaseSwitch.pendingImport": { + "defaultMessage": "Please wait as your request to import forums into knowledge base is being processed. You may close this window while importing is in progress." }, - "course.admin.SidebarSettings.sidebarSettingsUpdated": { - "defaultMessage": "The new sidebar ordering has been applied. Refresh to see the latest changes." + "course.admin.RagWiseSettings.ForumKnowledgeBaseSwitch.removeFailure": { + "defaultMessage": "{forum} could not be removed from knowledge base." }, - "course.admin.VideosSettings.addATab": { - "defaultMessage": "Add a tab" + "course.admin.RagWiseSettings.ForumKnowledgeBaseSwitch.removeSuccess": { + "defaultMessage": "{forum} {n, plural, one {has} other {have}} been removed from knowledge base." }, - "course.admin.VideosSettings.deleteTabPromptAction": { - "defaultMessage": "Delete {title} tab" + "course.admin.RagWiseSettings.KnowledgeBaseSwitch.addFailure": { + "defaultMessage": "{material} could not be added to knowledge base." }, - "course.admin.VideosSettings.deleteTabPromptMessage": { - "defaultMessage": "Deleting this tab will delete all its associated videos and statistics. This action is irreversible." + "course.admin.RagWiseSettings.KnowledgeBaseSwitch.addSuccess": { + "defaultMessage": "{material} {n, plural, one {has} other {have}} been added to knowledge base." }, - "course.admin.VideosSettings.deleteTabPromptTitle": { + "course.admin.RagWiseSettings.KnowledgeBaseSwitch.pendingAdd": { + "defaultMessage": "Please wait as your request to add materials into knowledge base is being processed. You may close this window while adding is in progress." + }, + "course.admin.RagWiseSettings.KnowledgeBaseSwitch.removeFailure": { + "defaultMessage": "{material} could not be removed from knowledge base." + }, + "course.admin.RagWiseSettings.KnowledgeBaseSwitch.removeSuccess": { + "defaultMessage": "{material} {n, plural, one {has} other {have}} been removed from knowledge base." + }, + "course.admin.RagWiseSettings.expandAll": { + "defaultMessage": "Expand all {object}" + }, + "course.admin.RagWiseSettings.forumSectionSubtitle": { + "defaultMessage": "Manage the inclusion or exclusion of forum data from related courses in the knowledge base, allowing users to control its availability to the LLM for generating responses." + }, + "course.admin.RagWiseSettings.forumSectionTitle": { + "defaultMessage": "No related courses found." + }, + "course.admin.RagWiseSettings.knowledgeBaseStatusSettings": { + "defaultMessage": "Knowledge Base" + }, + "course.admin.RagWiseSettings.materialsSectionSubtitle": { + "defaultMessage": "Add/remove pdf/docx/ipynb/txt files in knowledge base, allowing users to control its availability to the LLM for generating responses." + }, + "course.admin.RagWiseSettings.materialsSectionTitle": { + "defaultMessage": "Materials" + }, + "course.admin.RagWiseSettings.ragWiseSettings": { + "defaultMessage": "RagWise settings" + }, + "course.admin.RagWiseSettings.ragWiseSettingsSubtitle": { + "defaultMessage": "This is currently an experimental feature. RagWise uses Retrieval-Augmented Generation to generate contextually aware responses to student's query on forum." + }, + "course.admin.RagWiseSettings.responseWorkflowAuto": { + "defaultMessage": "Automatically respond" + }, + "course.admin.RagWiseSettings.responseWorkflowDescription": { + "defaultMessage": "When students post a question on forum," + }, + "course.admin.RagWiseSettings.responseWorkflowDraft": { + "defaultMessage": "Always draft" + }, + "course.admin.RagWiseSettings.responseWorkflowDraftDescription": { + "defaultMessage": "Generated response will be drafted." + }, + "course.admin.RagWiseSettings.responseWorkflowHighTrust": { + "defaultMessage": "High trust" + }, + "course.admin.RagWiseSettings.responseWorkflowLowTrust": { + "defaultMessage": "Low trust" + }, + "course.admin.RagWiseSettings.responseWorkflowLowTrustDescription": { + "defaultMessage": "Generated response will be conditionally published with {trust}% trust." + }, + "course.admin.RagWiseSettings.responseWorkflowNoAuto": { + "defaultMessage": "Do not automatically respond" + }, + "course.admin.RagWiseSettings.responseWorkflowPublish": { + "defaultMessage": "Always publish" + }, + "course.admin.RagWiseSettings.responseWorkflowPublishDescription": { + "defaultMessage": "Generated response will be immediately published." + }, + "course.admin.RagWiseSettings.responseWorkflowTitle": { + "defaultMessage": "Automatic Forum Response" + }, + "course.admin.RagWiseSettings.roleplayCharacter": { + "defaultMessage": "Specified Character Prompt" + }, + "course.admin.RagWiseSettings.roleplayCharacterLabel": { + "defaultMessage": "Character prompt (Max 200 Characters)" + }, + "course.admin.RagWiseSettings.roleplayDeadpool": { + "defaultMessage": "You must always impersonate Deadpool character in all your responses." + }, + "course.admin.RagWiseSettings.roleplayDeadpoolLabel": { + "defaultMessage": "Deadpool" + }, + "course.admin.RagWiseSettings.roleplayDescription": { + "defaultMessage": "Customise character prompt to change how LLM response" + }, + "course.admin.RagWiseSettings.roleplayNormal": { + "defaultMessage": "" + }, + "course.admin.RagWiseSettings.roleplayNormalLabel": { + "defaultMessage": "No roleplay" + }, + "course.admin.RagWiseSettings.roleplaySubtitle": { + "defaultMessage": "Character that LLM will roleplay as in responses." + }, + "course.admin.RagWiseSettings.roleplayTitle": { + "defaultMessage": "Response Roleplay" + }, + "course.admin.RagWiseSettings.roleplayYoda": { + "defaultMessage": "You must always impersonate Master Yoda character in all your responses." + }, + "course.admin.RagWiseSettings.roleplayYodaLabel": { + "defaultMessage": "Master Yoda" + }, + "course.admin.SidebarSettings.errorOccurredWhenUpdatingSidebar": { + "defaultMessage": "An error occurred while updating the sidebar ordering." + }, + "course.admin.SidebarSettings.sidebarSettings": { + "defaultMessage": "Student's sidebar ordering" + }, + "course.admin.SidebarSettings.sidebarSettingsSubtitle": { + "defaultMessage": "Drag and drop the sidebar items to rearrange." + }, + "course.admin.SidebarSettings.sidebarSettingsUpdated": { + "defaultMessage": "The new sidebar ordering has been applied. Refresh to see the latest changes." + }, + "course.admin.VideosSettings.addATab": { + "defaultMessage": "Add a tab" + }, + "course.admin.VideosSettings.deleteTabPromptAction": { + "defaultMessage": "Delete {title} tab" + }, + "course.admin.VideosSettings.deleteTabPromptMessage": { + "defaultMessage": "Deleting this tab will delete all its associated videos and statistics. This action is irreversible." + }, + "course.admin.VideosSettings.deleteTabPromptTitle": { "defaultMessage": "Delete {title} tab?" }, "course.admin.VideosSettings.errorOccurredWhenCreatingTab": { @@ -1122,6 +1265,51 @@ "course.admin.courseSettings": { "defaultMessage": "Course Settings" }, + "course.admin.storiesSettings.autoCreateAccounts": { + "defaultMessage": "User accounts and chat rooms on Cikgo will automatically be created if they don't yet exist. Information shared with Cikgo is governed by our Privacy Policy and Cikgo's Privacy Policy." + }, + "course.admin.storiesSettings.integrationHint": { + "defaultMessage": "To integrate your course on Cikgo with this course, enter its integration key here. Here's what's going to happen once this course is integrated with Cikgo." + }, + "course.admin.storiesSettings.integrationSettings": { + "defaultMessage": "Integration settings" + }, + "course.admin.storiesSettings.learnTitle": { + "defaultMessage": "Learn page title" + }, + "course.admin.storiesSettings.leaveEmptyToUseDefaultTitle": { + "defaultMessage": "Leave empty to use the default \"Learn\" title." + }, + "course.admin.storiesSettings.onlyOwnersCanManage": { + "defaultMessage": "Only you, Owners, and Managers can configure the integration of this course with Cikgo." + }, + "course.admin.storiesSettings.pingError": { + "defaultMessage": "There was a problem connecting to Cikgo. You may try again at a later time." + }, + "course.admin.storiesSettings.publishTaskCompletions": { + "defaultMessage": "Student's submission statuses will be reflected in their chat rooms in Cikgo." + }, + "course.admin.storiesSettings.pushKey": { + "defaultMessage": "Integration key" + }, + "course.admin.storiesSettings.pushKeyError": { + "defaultMessage": "This integration key doesn't point to a valid course on Cikgo. Please check your settings on Cikgo and try again." + }, + "course.admin.storiesSettings.pushKeyHint": { + "defaultMessage": "Integration keys aren't strictly secretive, but should be handled in confidence." + }, + "course.admin.storiesSettings.pushKeyPointsToCourse": { + "defaultMessage": "This integration key points to {course} on Cikgo." + }, + "course.admin.storiesSettings.redirects": { + "defaultMessage": "When students access this course's root URL, they'll be redirected to the Learn page. The home page is still accessible from the sidebar." + }, + "course.admin.storiesSettings.storiesSettings": { + "defaultMessage": "Stories settings" + }, + "course.admin.storiesSettings.syncs": { + "defaultMessage": "Published assessments, videos, and surveys in this course will be available in and kept in sync with Cikgo as resources." + }, "course.announcement.AnnouncementsDisplay.searchBarPlaceholder": { "defaultMessage": "Search by title or content" }, @@ -1233,6 +1421,12 @@ "course.assessment.AssessmentForm.blockStudentViewingAfterSubmittedHint": { "defaultMessage": "Students will only be able to view their submissions after their grades have been published." }, + "course.assessment.AssessmentForm.blocksAccessesFromInvalidSUS": { + "defaultMessage": "Block accesses from browsers with invalid UA" + }, + "course.assessment.AssessmentForm.blocksAccessesFromInvalidSUSHint": { + "defaultMessage": "If enabled, examinees using browsers with invalid UA (does not contain the specified SUS below) will be blocked from accessing this assessment. Instructors can override access with the session unlock password. Heartbeats from an overridden browser session will be flagged as valid in the PulseGrid." + }, "course.assessment.AssessmentForm.bonusEndAt": { "defaultMessage": "Bonus ends at" }, @@ -1245,9 +1439,6 @@ "course.assessment.AssessmentForm.delayedGradePublicationHint": { "defaultMessage": "If enabled, gradings will not be immediately shown to students. To publish all gradings, you may click Publish Grades in the Submissions page." }, - "course.assessment.AssessmentForm.canEnableCodaveriInComponents": { - "defaultMessage": "Contact the course manager or owner to enable this feature in Components in the Course Settings." - }, "course.assessment.AssessmentForm.description": { "defaultMessage": "Description" }, @@ -1302,12 +1493,24 @@ "course.assessment.AssessmentForm.hasPersonalTimesHint": { "defaultMessage": "Timings for this item will be automatically adjusted for users based on learning rate." }, + "course.assessment.AssessmentForm.hasTimeLimit": { + "defaultMessage": "Automatically submit when timer ends" + }, + "course.assessment.AssessmentForm.hasTimeLimitHint": { + "defaultMessage": "When enabled, each submission will have its own timer and will automatically be finalised when its timer ends." + }, "course.assessment.AssessmentForm.hasToBeMoreThanMinInterval": { "defaultMessage": "Has to be greater than the minimum value." }, "course.assessment.AssessmentForm.hasToBeMoreThanValueMs": { "defaultMessage": "Has to be at least 3000 ms." }, + "course.assessment.AssessmentForm.hasToBeNumber": { + "defaultMessage": "Has to be valid number." + }, + "course.assessment.AssessmentForm.hasToBePositive": { + "defaultMessage": "Has to be positive." + }, "course.assessment.AssessmentForm.hasToBePositiveInteger": { "defaultMessage": "Has to be a positive integer less than 86,400,000 ms" }, @@ -1320,6 +1523,12 @@ "course.assessment.AssessmentForm.intervalHint": { "defaultMessage": "Controls how frequent heartbeats are sent from the students' browsers. Intervals are randomised between these two ranges." }, + "course.assessment.AssessmentForm.koditsuDisabledInCourse": { + "defaultMessage": "Please contact the Course Administrator to enable Koditsu Exam in Course Settings." + }, + "course.assessment.AssessmentForm.liveFeedback": { + "defaultMessage": "Get Help" + }, "course.assessment.AssessmentForm.maxInterval": { "defaultMessage": "Max interval" }, @@ -1329,9 +1538,18 @@ "course.assessment.AssessmentForm.minInterval": { "defaultMessage": "Min interval" }, + "course.assessment.AssessmentForm.minutes": { + "defaultMessage": "minute(s)" + }, "course.assessment.AssessmentForm.modeSwitchingHint": { "defaultMessage": "You can no longer change the grading mode because there are already submissions for this assessment." }, + "course.assessment.AssessmentForm.needSUSAndSessionUnlockPassword": { + "defaultMessage": "You need to specify a SUS and session unlock password to enable this." + }, + "course.assessment.AssessmentForm.noProgrammingQuestion": { + "defaultMessage": "You need to add at least one programming question that can be supported by Codaveri to allow enabling Get Help for this Assessment" + }, "course.assessment.AssessmentForm.noTestCaseChosenError": { "defaultMessage": "Select at least one type of test case" }, @@ -1356,29 +1574,32 @@ "course.assessment.AssessmentForm.personalisedTimelines": { "defaultMessage": "Personalised timelines" }, + "course.assessment.AssessmentForm.proctorWithKoditsu": { + "defaultMessage": "Proctor Exam using Koditsu" + }, "course.assessment.AssessmentForm.published": { "defaultMessage": "Published" }, "course.assessment.AssessmentForm.publishedHint": { "defaultMessage": "Everyone can see this assessment." }, + "course.assessment.AssessmentForm.questionsIncompatibleWithKoditsu": { + "defaultMessage": "Please make sure that all questions in this assessment is compatible with Koditsu before activating proctoring in Koditsu" + }, "course.assessment.AssessmentForm.secret": { "defaultMessage": "Secret UA Substring (SUS)" }, "course.assessment.AssessmentForm.secretHint": { - "defaultMessage": "If provided, Coursemology can automatically flag a connection as valid in PulseGrid if the examinee's User Agent (UA) contains this secret. Otherwise, connections will be flagged only by heartbeat intervals." + "defaultMessage": "If provided, the PulseGrid automatically checks if the examinee's browser's User Agent (UA) contains this secret, and marks connections that do not as invalid. This string is case-sensitive." }, "course.assessment.AssessmentForm.sessionPassword": { "defaultMessage": "Session unlock password" }, - "course.assessment.AssessmentForm.sessionPasswordHint": { - "defaultMessage": "Ideally, do NOT give this password to students." - }, "course.assessment.AssessmentForm.sessionProtection": { "defaultMessage": "Enable session protection" }, "course.assessment.AssessmentForm.sessionProtectionHint": { - "defaultMessage": "If enabled, students can only access their attempt once. Further access will require the session unlock password." + "defaultMessage": "If enabled, students can only access their attempt once. Further access will require the session unlock password. Ideally, do NOT give this password to students." }, "course.assessment.AssessmentForm.showEvaluation": { "defaultMessage": "Show evaluation test cases" @@ -1392,12 +1613,12 @@ "course.assessment.AssessmentForm.showMcqMrqSolution": { "defaultMessage": "Show MCQ/MRQ solution(s)" }, - "course.assessment.AssessmentForm.showRubricToStudents": { - "defaultMessage": "Show rubric breakdown to students" - }, "course.assessment.AssessmentForm.showPrivate": { "defaultMessage": "Show private test cases" }, + "course.assessment.AssessmentForm.showRubricToStudents": { + "defaultMessage": "Show rubric breakdown to students" + }, "course.assessment.AssessmentForm.singlePage": { "defaultMessage": "Single Page" }, @@ -1422,9 +1643,15 @@ "course.assessment.AssessmentForm.timeBonusExp": { "defaultMessage": "Time Bonus EXP" }, + "course.assessment.AssessmentForm.timeLimit": { + "defaultMessage": "Time Limit" + }, "course.assessment.AssessmentForm.title": { "defaultMessage": "Title" }, + "course.assessment.AssessmentForm.toggleLiveFeedbackDescription": { + "defaultMessage": "Enable Get Help feature for all programming questions" + }, "course.assessment.AssessmentForm.unavailableInAutograded": { "defaultMessage": "Unavailable in autograded assessments." }, @@ -1455,12 +1682,6 @@ "course.assessment.AssessmentForm.visibility": { "defaultMessage": "Visibility" }, - "course.assessment.AssessmentForm.toggleLiveFeedbackDescription": { - "defaultMessage": "{enabled, select, true {Enable} other {Disable}} Get Help feature for all programming questions" - }, - "course.assessment.AssessmentForm.noProgrammingQuestion": { - "defaultMessage": "You need to add at least one programming question that can be supported by Codaveri to allow enabling Get Help for this Assessment" - }, "course.assessment.FileManager.addFiles": { "defaultMessage": "Add Files" }, @@ -1506,9 +1727,24 @@ "course.assessment.edit.update": { "defaultMessage": "Save" }, + "course.assessment.generation.allFieldsLocked": { + "defaultMessage": "All fields are locked, so nothing can be generated." + }, "course.assessment.generation.confirmDeleteConversation": { "defaultMessage": "Are you sure you want to delete \"{title}\" and all its history items? THIS ACTION IS IRREVERSIBLE!" }, + "course.assessment.generation.createMode": { + "defaultMessage": "Create New" + }, + "course.assessment.generation.createModeTooltip": { + "defaultMessage": "Generate fresh questions from scratch" + }, + "course.assessment.generation.enhanceMode": { + "defaultMessage": "Enhance" + }, + "course.assessment.generation.enhanceModeTooltip": { + "defaultMessage": "Build upon your current question" + }, "course.assessment.generation.exportAction": { "defaultMessage": "Export" }, @@ -1518,140 +1754,212 @@ "course.assessment.generation.exportError": { "defaultMessage": "An error occurred in exporting this question: {error}" }, - "course.assessment.generation.lockTooltip": { - "defaultMessage": "Lock to prevent changes to this section" - }, - "course.assessment.generation.newTab": { - "defaultMessage": "New" - }, - "course.assessment.generation.openExportDialog": { - "defaultMessage": "Export" + "course.assessment.generation.generateError": { + "defaultMessage": "An error occurred generating question \"{title}\"." }, - "course.assessment.generation.resetConversation": { - "defaultMessage": "Reset" + "course.assessment.generation.generateMcqPage": { + "defaultMessage": "Generate Multiple Choice Question" }, - "course.assessment.generation.unlockTooltip": { - "defaultMessage": "Unlock to continue editing this section" + "course.assessment.generation.generateMrqPage": { + "defaultMessage": "Generate Multiple Response Question" }, - "course.assessment.generation.mrq.numberOfQuestionsField": { - "defaultMessage": "Number of Questions" + "course.assessment.generation.generateMultipleSuccess": { + "defaultMessage": "Successfully generated {count} questions!" }, - "course.assessment.generation.promptPlaceholder": { - "defaultMessage": "Type something here..." + "course.assessment.generation.generatePage": { + "defaultMessage": "Generate Programming Question" }, "course.assessment.generation.generateQuestion": { "defaultMessage": "Generate" }, - "course.assessment.generation.showInactive": { - "defaultMessage": "Show inactive items" - }, - "course.assessment.generation.mrq.numberOfQuestionsRange": { - "defaultMessage": "Please enter a number from {min} to {max}" + "course.assessment.generation.generateSuccess": { + "defaultMessage": "Generation for \"{title}\" successful." }, - "course.assessment.generation.enhanceMode": { - "defaultMessage": "Enhance" + "course.assessment.generation.languageField": { + "defaultMessage": "Language" }, - "course.assessment.generation.createMode": { - "defaultMessage": "Create New" + "course.assessment.generation.loadingSourceError": { + "defaultMessage": "Unable to load source question data." }, - "course.assessment.generation.enhanceModeTooltip": { - "defaultMessage": "Build upon your current question" + "course.assessment.generation.lockTooltip": { + "defaultMessage": "Lock to prevent changes to this section" }, - "course.assessment.generation.createModeTooltip": { - "defaultMessage": "Generate fresh questions from scratch" + "course.assessment.generation.mrq.exportAction": { + "defaultMessage": "Export" }, "course.assessment.generation.mrq.exportDialogHeader": { "defaultMessage": "Export Questions ({exportCount} selected)" }, - "course.assessment.generation.requireNonEmptyOptionError": { - "defaultMessage": "Question must have at least one non-empty option" + "course.assessment.generation.mrq.numberOfQuestionsField": { + "defaultMessage": "Number of Questions" }, - "course.assessment.generation.untitledQuestion": { - "defaultMessage": "Untitled Question" + "course.assessment.generation.mrq.numberOfQuestionsRange": { + "defaultMessage": "Please enter a number from {min} to {max}" }, - "course.assessment.question.multipleResponses.showOptions": { - "defaultMessage": "Show Options" + "course.assessment.generation.newTab": { + "defaultMessage": "New" }, - "course.assessment.question.multipleResponses.hideOptions": { - "defaultMessage": "Hide Options" + "course.assessment.generation.openExportDialog": { + "defaultMessage": "Export" }, - "course.assessment.question.multipleResponses.noOptions": { - "defaultMessage": "No options" + "course.assessment.generation.promptPlaceholder": { + "defaultMessage": "Type something here..." }, - "course.assessment.question.multipleResponses.title": { - "defaultMessage": "Title" + "course.assessment.generation.requireNonEmptyOptionError": { + "defaultMessage": "Question must have at least one non-empty option" }, - "course.assessment.generation.generateMrqPage": { - "defaultMessage": "Generate Multiple Response Question" + "course.assessment.generation.resetConversation": { + "defaultMessage": "Reset" }, - "course.assessment.generation.generateMcqPage": { - "defaultMessage": "Generate Multiple Choice Question" + "course.assessment.generation.showInactive": { + "defaultMessage": "Show inactive items" }, - "course.assessment.generation.generateMultipleSuccess": { - "defaultMessage": "Successfully generated {count} questions!" + "course.assessment.generation.sourceLanguageNotSupported": { + "defaultMessage": "Source question language not supported by the generation tool." }, - "course.assessment.generation.generateSuccess": { - "defaultMessage": "Generation for {title} successful." + "course.assessment.generation.unlockTooltip": { + "defaultMessage": "Unlock to continue editing this section" }, - "course.assessment.generation.generateError": { - "defaultMessage": "An error occurred generating question {title}." + "course.assessment.generation.untitledQuestion": { + "defaultMessage": "Untitled Question" }, - "course.assessment.generation.loadingSourceError": { - "defaultMessage": "Unable to load source question data." + "course.assessment.liveFeedback.comments": { + "defaultMessage": "Comments" }, - "course.assessment.generation.allFieldsLocked": { - "defaultMessage": "All fields are locked, so nothing can be generated." + "course.assessment.liveFeedback.lineHeader": { + "defaultMessage": "Line {lineNumber}" + }, + "course.assessment.liveFeedback.messageTimingTitle": { + "defaultMessage": "Generated at: {usedAt}" + }, + "course.assessment.liveFeedback.questionTitle": { + "defaultMessage": "Question {index}" + }, + "course.assessment.monitoring.accessGrantedForThisSessionOnly": { + "defaultMessage": "Access will be granted only for this browser session." }, "course.assessment.monitoring.alivePresenceHint": { "defaultMessage": "Last heartbeat was received in time." }, "course.assessment.monitoring.alivePresenceHintSUSMatches": { - "defaultMessage": "Last heartbeat was received in time and the SUS matches." + "defaultMessage": "Last heartbeat was received in time and came from an authorised browser, if browser authorisation is enabled." }, "course.assessment.monitoring.blankField": { "defaultMessage": "(blank)" }, + "course.assessment.monitoring.blocksAccessesFromInvalidSUS": { + "defaultMessage": "Block accesses from unauthorised browsers" + }, + "course.assessment.monitoring.blocksAccessesFromInvalidSUSHint": { + "defaultMessage": "If enabled, examinees using unauthorised browsers can't access this assessment. Instructors can override access with the session unlock password. Heartbeats from overridden browser sessions will always be valid (green) in the PulseGrid." + }, + "course.assessment.monitoring.browserAuthorizationMethod": { + "defaultMessage": "Browser authorisation method" + }, + "course.assessment.monitoring.browserAuthorizationMethodHint": { + "defaultMessage": "Choose how sessions are authorised as valid or invalid. Changes apply to all sessions and heartbeats immediately and updates live in PulseGrid." + }, "course.assessment.monitoring.cannotConnectToLiveMonitoringChannel": { "defaultMessage": "Oops, an error occurred when connecting to the live monitoring channel." }, "course.assessment.monitoring.connected": { "defaultMessage": "Connected" }, - "course.assessment.monitoring.connectedToLiveMonitoringChannel": { - "defaultMessage": "Connected to the live monitoring channel" + "course.assessment.monitoring.connecting": { + "defaultMessage": "Connecting" + }, + "course.assessment.monitoring.deltaFromPreviousHeartbeat": { + "defaultMessage": "{ms} ms from previous heartbeat" }, "course.assessment.monitoring.detailsOfNHeartbeats": { - "defaultMessage": "Details of the last {n} heartbeats" + "defaultMessage": "Last {n} heartbeats" }, "course.assessment.monitoring.disconnected": { "defaultMessage": "Disconnected" }, - "course.assessment.monitoring.disconnectedFromLiveMonitoringChannel": { - "defaultMessage": "Disconnected from the live monitoring channel" + "course.assessment.monitoring.enableBrowserAuthorization": { + "defaultMessage": "Authorise browsers that access this assessment" + }, + "course.assessment.monitoring.enableBrowserAuthorizationHint": { + "defaultMessage": "If enabled, PulseGrid will additionally check if an examinee is accessing this assessment from an authorised browser, based on the authorisation method you choose." + }, + "course.assessment.monitoring.examMonitoring": { + "defaultMessage": "Enable exam monitoring" + }, + "course.assessment.monitoring.examMonitoringHint": { + "defaultMessage": "If enabled, examinees' sessions will be monitored in real time from when they attempt the exam until they finalise it or the first 24 hours since their attempt, whichever is earlier. Instructors can monitor these sessions in PulseGrid." + }, + "course.assessment.monitoring.expiredSession": { + "defaultMessage": "Expired session. It has been at least 24 hours since the submission was made." }, "course.assessment.monitoring.filterByGroup": { "defaultMessage": "Filter by Group" }, + "course.assessment.monitoring.firstReceivedHeartbeat": { + "defaultMessage": "First received heartbeat" + }, "course.assessment.monitoring.generatedAt": { "defaultMessage": "Generated at" }, + "course.assessment.monitoring.intervalHint": { + "defaultMessage": "Controls how frequent heartbeats are sent from the examinees' browsers. Intervals are randomised between these two ranges." + }, + "course.assessment.monitoring.invalidBrowser": { + "defaultMessage": "Invalid browser configuration" + }, + "course.assessment.monitoring.invalidBrowserSubtitle": { + "defaultMessage": "Access to this assessment is not allowed with your current browser and/or its configuration. Contact your instructor for assistance." + }, + "course.assessment.monitoring.invalidHeartbeat": { + "defaultMessage": "Invalid" + }, "course.assessment.monitoring.ipAddress": { "defaultMessage": "IP Address" }, - "course.assessment.monitoring.lastHeartbeat": { - "defaultMessage": "Last heartbeat" - }, "course.assessment.monitoring.latePresenceHint": { "defaultMessage": "Next heartbeat hasn't been received in time, but still within the configured inter-heartbeats interval." }, "course.assessment.monitoring.live": { "defaultMessage": "Live" }, + "course.assessment.monitoring.liveHint": { + "defaultMessage": "This heartbeat was immediately received by the server." + }, + "course.assessment.monitoring.liveness": { + "defaultMessage": "Liveness" + }, + "course.assessment.monitoring.loadAllHeartbeats": { + "defaultMessage": "Load all" + }, + "course.assessment.monitoring.maxInterval": { + "defaultMessage": "Max interval" + }, + "course.assessment.monitoring.milliseconds": { + "defaultMessage": "ms" + }, + "course.assessment.monitoring.minInterval": { + "defaultMessage": "Min interval" + }, "course.assessment.monitoring.missingPresenceHint": { - "defaultMessage": "Next heartbeat hasn't been received in time." + "defaultMessage": "Next heartbeat hasn't been received in time, or the last heartbeat came from an unauthorised browser, if browser authorisation is enabled." + }, + "course.assessment.monitoring.needSUSAndSessionUnlockPassword": { + "defaultMessage": "You must enable browser authorisation and set a session unlock password to enable this." }, "course.assessment.monitoring.noActiveSessions": { - "defaultMessage": "No active sessions." + "defaultMessage": "No active sessions. No attempts have been made." + }, + "course.assessment.monitoring.offset": { + "defaultMessage": "Inter-heartbeat offset" + }, + "course.assessment.monitoring.offsetHint": { + "defaultMessage": "Controls how long PulseGrid should wait after the frequency interval before flagging a session as late." + }, + "course.assessment.monitoring.openSubmissionInNewTab": { + "defaultMessage": "Open submission in new tab" + }, + "course.assessment.monitoring.overrideAccess": { + "defaultMessage": "Override access" }, "course.assessment.monitoring.pulsegrid": { "defaultMessage": "PulseGrid" @@ -1662,9 +1970,42 @@ "course.assessment.monitoring.recentActivitiesHint": { "defaultMessage": "These logs will disappear if you close this tab!" }, + "course.assessment.monitoring.resetZoom": { + "defaultMessage": "Reset zoom" + }, + "course.assessment.monitoring.sebConfigKey": { + "defaultMessage": "Safe Exam Browser (SEB) Config Key" + }, + "course.assessment.monitoring.sebConfigKeyFieldHint": { + "defaultMessage": "Your SEB Config Key, not the Browser Exam Key, is generated from your specific SEB configuration. It stays the same across operating systems and SEB versions. Ensure this field is updated if you change your SEB configuration." + }, + "course.assessment.monitoring.sebConfigKeyFieldLabel": { + "defaultMessage": "SEB Config Key" + }, + "course.assessment.monitoring.sebConfigKeyHint": { + "defaultMessage": "Flags a session as valid if the examinee is using Safe Exam Browser (SEB) with a valid configuration. SEB generates a unique Config Key for a specific configuration. This method requires SEB 3.4 for Windows and SEB 3.0 for iOS and macOS, or later." + }, + "course.assessment.monitoring.sebPayload": { + "defaultMessage": "Safe Exam Browser (SEB) Config Key Hash & URL" + }, + "course.assessment.monitoring.secret": { + "defaultMessage": "Secret UA Substring (SUS)" + }, + "course.assessment.monitoring.secretHint": { + "defaultMessage": "If an examinee's browser's User Agent (UA) contains this case-sensitive secret, PulseGrid will flag that session as valid, and invalid otherwise. If you leave this blank, all sessions will be flagged as valid." + }, + "course.assessment.monitoring.sessionUnlockPassword": { + "defaultMessage": "Session unlock password" + }, "course.assessment.monitoring.stale": { "defaultMessage": "Stale" }, + "course.assessment.monitoring.staleHint": { + "defaultMessage": "This heartbeat wasn't immediately received by the server because the examinee's browser was temporarily unreachable. It was cached in the browser, and sent to the server when the browser was reachable again." + }, + "course.assessment.monitoring.stoppedSession": { + "defaultMessage": "Stopped session. Student may have finalised their submission." + }, "course.assessment.monitoring.summaryCorrectAsAt": { "defaultMessage": "Summary correct as at {time}" }, @@ -1672,7 +2013,10 @@ "defaultMessage": "Type" }, "course.assessment.monitoring.userAgent": { - "defaultMessage": "User Agent" + "defaultMessage": "User Agent (UA)" + }, + "course.assessment.monitoring.userAgentHint": { + "defaultMessage": "Flags a session as valid if the examinee's browser's User Agent (UA) contains a secret substring." }, "course.assessment.monitoring.userHeartbeatContinuedStreaming": { "defaultMessage": "{name}'s heartbeat just continued streaming." @@ -1680,9 +2024,66 @@ "course.assessment.monitoring.userHeartbeatNotReceivedInTime": { "defaultMessage": "{name}'s heartbeat wasn't received in time." }, + "course.assessment.monitoring.validHeartbeat": { + "defaultMessage": "Valid" + }, + "course.assessment.monitoring.zoomPanHint": { + "defaultMessage": "Pinch or scroll to zoom. Drag to pan." + }, "course.assessment.newAssessment": { "defaultMessage": "New Assessment" }, + "course.assessment.plagiarism.actions": { + "defaultMessage": "Actions" + }, + "course.assessment.plagiarism.baseSubmission": { + "defaultMessage": "Base Submission" + }, + "course.assessment.plagiarism.cannotManageSubmission": { + "defaultMessage": "You do not have permission to manage this submission." + }, + "course.assessment.plagiarism.comparedSubmission": { + "defaultMessage": "Compared Submission" + }, + "course.assessment.plagiarism.confirmStartMessage": { + "defaultMessage": "Running a new plagiarism check will remove the previous results." + }, + "course.assessment.plagiarism.confirmStartTitle": { + "defaultMessage": "Confirm Plagiarism Check?" + }, + "course.assessment.plagiarism.downloadPdf": { + "defaultMessage": "Download PDF" + }, + "course.assessment.plagiarism.lastRunTime": { + "defaultMessage": "Last run at: {date}" + }, + "course.assessment.plagiarism.notStarted": { + "defaultMessage": "No plagiarism check has been run" + }, + "course.assessment.plagiarism.plagiarism": { + "defaultMessage": "Plagiarism Results" + }, + "course.assessment.plagiarism.results": { + "defaultMessage": "Plagiarism Results (similarity between submissions)" + }, + "course.assessment.plagiarism.searchByStudentName": { + "defaultMessage": "Search by Student Name" + }, + "course.assessment.plagiarism.showSelfPlagiarism": { + "defaultMessage": "Include self-plagiarism comparisons (same student, different courses)" + }, + "course.assessment.plagiarism.similarityScore": { + "defaultMessage": "Similarity Score" + }, + "course.assessment.plagiarism.start": { + "defaultMessage": "New Plagiarism Check" + }, + "course.assessment.plagiarism.status": { + "defaultMessage": "Plagiarism Check Status" + }, + "course.assessment.plagiarism.viewReport": { + "defaultMessage": "View Report" + }, "course.assessment.question.forumPostResponses.enableTextResponse": { "defaultMessage": "Include a text field for students to provide further inputs" }, @@ -1755,6 +2156,9 @@ "course.assessment.question.multipleResponses.grading": { "defaultMessage": "Grading" }, + "course.assessment.question.multipleResponses.hideOptions": { + "defaultMessage": "Hide Options" + }, "course.assessment.question.multipleResponses.ignoresRandomization": { "defaultMessage": "Ignores randomization" }, @@ -1767,18 +2171,39 @@ "course.assessment.question.multipleResponses.maximumGrade": { "defaultMessage": "Maximum grade" }, + "course.assessment.question.multipleResponses.mustBeLessThanMaxAttachmentSize": { + "defaultMessage": "Must be at most {defaultMax}MB." + }, + "course.assessment.question.multipleResponses.mustBeLessThanMaxAttachments": { + "defaultMessage": "Must be at most {defaultMax}." + }, "course.assessment.question.multipleResponses.mustBeLessThanMaxMaximumGrade": { "defaultMessage": "Must be less than 1000." }, + "course.assessment.question.multipleResponses.mustHaveAtLeastOneResponse": { + "defaultMessage": "You must specify at least one response." + }, "course.assessment.question.multipleResponses.mustSpecifyAtLeastOneCorrectChoice": { "defaultMessage": "You must specify at least one correct choice." }, "course.assessment.question.multipleResponses.mustSpecifyChoice": { "defaultMessage": "You must specify a valid choice title." }, + "course.assessment.question.multipleResponses.mustSpecifyMaxAttachment": { + "defaultMessage": "You must specify a valid, positive maximum attachment number." + }, + "course.assessment.question.multipleResponses.mustSpecifyMaxAttachmentSize": { + "defaultMessage": "You must specify a valid, positive maximum attachment size." + }, "course.assessment.question.multipleResponses.mustSpecifyMaximumGrade": { "defaultMessage": "You must specify a valid, non-negative maximum grade to award." }, + "course.assessment.question.multipleResponses.mustSpecifyPositiveMaxAttachment": { + "defaultMessage": "Max Number of Attachments has to be at least 2." + }, + "course.assessment.question.multipleResponses.mustSpecifyPositiveMaxAttachmentSize": { + "defaultMessage": "Max Size has to be positive." + }, "course.assessment.question.multipleResponses.mustSpecifyPositiveMaximumGrade": { "defaultMessage": "Maximum grade has to be non-negative." }, @@ -1791,6 +2216,9 @@ "course.assessment.question.multipleResponses.newResponseCannotUndo": { "defaultMessage": "This is a new response. It will immediately disappear if you delete before saving it." }, + "course.assessment.question.multipleResponses.noOptions": { + "defaultMessage": "No options" + }, "course.assessment.question.multipleResponses.noSkillsCanCreateSkills": { "defaultMessage": "There are no skills in this course yet. You can create new skills at the Skills page." }, @@ -1827,6 +2255,9 @@ "course.assessment.question.multipleResponses.saveChangesFirstBeforeConvertingMcqMrq": { "defaultMessage": "Please save your changes before attempting to convert this question." }, + "course.assessment.question.multipleResponses.showOptions": { + "defaultMessage": "Show Options" + }, "course.assessment.question.multipleResponses.skills": { "defaultMessage": "Skills" }, @@ -1839,6 +2270,9 @@ "course.assessment.question.multipleResponses.staffOnlyCommentsHint": { "defaultMessage": "Useful for internal notes or documentations. Students will never see this." }, + "course.assessment.question.multipleResponses.title": { + "defaultMessage": "Title" + }, "course.assessment.question.multipleResponses.undoDeleteChoice": { "defaultMessage": "Undo delete choice" }, @@ -1893,6 +2327,9 @@ "course.assessment.question.programming.codaveriEvaluatorHint": { "defaultMessage": "On top of the default evaluation, this evaluator will provide automated code feedback powered by Codaveri when the submission is finalised. They will appear as draft comments for the instructors to review, edit, and publish." }, + "course.assessment.question.programming.codaveriEvaluatorNotSupported": { + "defaultMessage": "{languageName} is not supported by the Codaveri evaluator." + }, "course.assessment.question.programming.codeInserts": { "defaultMessage": "Code inserts" }, @@ -1914,15 +2351,18 @@ "course.assessment.question.programming.defaultEvaluator": { "defaultMessage": "Default" }, - "course.assessment.question.programming.defaultEvaluatorDependencyTitle": { - "defaultMessage": "{name}: Installed Dependencies" - }, "course.assessment.question.programming.defaultEvaluatorDependencyDescription": { "defaultMessage": "Submitted code is run in a containerized environment with the following dependencies installed locally.{br}If your programming question requires a dependency not listed below, contact us and we will consider adding it." }, + "course.assessment.question.programming.defaultEvaluatorDependencyTitle": { + "defaultMessage": "{name}: Installed Dependencies" + }, "course.assessment.question.programming.defaultEvaluatorHint": { "defaultMessage": "No fuss; just run the code according to the evaluation package below and report the test results." }, + "course.assessment.question.programming.defaultEvaluatorNotSupported": { + "defaultMessage": "{languageName} is not supported by the default evaluator." + }, "course.assessment.question.programming.dependencySearchText": { "defaultMessage": "Search dependencies by name" }, @@ -1954,7 +2394,7 @@ "defaultMessage": "Hold tight, evaluating all submissions with the new package..." }, "course.assessment.question.programming.evaluationLimits": { - "defaultMessage": "Evaluationlimits" + "defaultMessage": "Evaluation limits" }, "course.assessment.question.programming.evaluationTestCases": { "defaultMessage": "Evaluation test cases" @@ -1971,6 +2411,9 @@ "course.assessment.question.programming.expected": { "defaultMessage": "Expected" }, + "course.assessment.question.programming.expectedOutput": { + "defaultMessage": "Expected Output" + }, "course.assessment.question.programming.expression": { "defaultMessage": "Expression" }, @@ -2001,6 +2444,9 @@ "course.assessment.question.programming.inlineCode": { "defaultMessage": "Inline code" }, + "course.assessment.question.programming.input": { + "defaultMessage": "Input" + }, "course.assessment.question.programming.javaTestCasesHint": { "defaultMessage": "Expressions will be evaluated in the context of the submitted code. Their return values will be compared against the Expected expectations using the expectEquals(expression, expected) void. Its simplified definition is as follows, where Object has been overloaded for all Java primitives." }, @@ -2016,6 +2462,9 @@ "course.assessment.question.programming.languageAndEvaluation": { "defaultMessage": "Language and evaluation" }, + "course.assessment.question.programming.languageDeprecatedWarning": { + "defaultMessage": "Your selected language is deprecated. Please change it to another language." + }, "course.assessment.question.programming.lastUpdated": { "defaultMessage": "Last updated by {by} on {on}." }, @@ -2025,6 +2474,9 @@ "course.assessment.question.programming.liveFeedbackCustomPromptDescription": { "defaultMessage": "Add instructions to guide the generation of Get Help feedback here. If unsure, just leave this blank." }, + "course.assessment.question.programming.liveFeedbackNotSupported": { + "defaultMessage": "Get Help is not supported for {languageName}." + }, "course.assessment.question.programming.lowestGradingPriority": { "defaultMessage": "Lowest grading priority" }, @@ -2052,24 +2504,24 @@ "course.assessment.question.programming.packageCreationModeHint": { "defaultMessage": "You cannot change this mode once this question is successfully created. Choose wisely!" }, - "course.assessment.question.programming.packageImportSuccess": { - "defaultMessage": "The package was successfully imported." + "course.assessment.question.programming.packageImportEvaluationError": { + "defaultMessage": "An error occurred evaluating your solution against its test cases. Please double-check them and try again." + }, + "course.assessment.question.programming.packageImportEvaluationTimeout": { + "defaultMessage": "No response was received from an evaluator within the required time. This may indicate all our evaluators are busy right now, please try again later." + }, + "course.assessment.question.programming.packageImportGenericError": { + "defaultMessage": "The package could not be imported: {error}" }, "course.assessment.question.programming.packageImportInvalidPackage": { "defaultMessage": "The package could not be imported: the uploaded package does not have a valid structure." }, - "course.assessment.question.programming.packageImportEvaluationTimeout": { - "defaultMessage": "No response was received from an evaluator within the required time. This may indicate all our evaluators are busy right now, please try again later." + "course.assessment.question.programming.packageImportSuccess": { + "defaultMessage": "The package was successfully imported." }, "course.assessment.question.programming.packageImportTimeLimitExceeded": { "defaultMessage": "The solution did not finish evaluating the test cases in the specified time limit." }, - "course.assessment.question.programming.packageImportEvaluationError": { - "defaultMessage": "An error occurred evaluating your solution against its test cases. Please double-check them and try again." - }, - "course.assessment.question.programming.packageImportGenericError": { - "defaultMessage": "The package could not be imported: {error}" - }, "course.assessment.question.programming.packageInfoOnline": { "defaultMessage": "Generated evaluation package" }, @@ -2130,6 +2582,9 @@ "course.assessment.question.programming.standardError": { "defaultMessage": "Standard error" }, + "course.assessment.question.programming.standardInputOutputTestCasesHint": { + "defaultMessage": "Each test case launches a separate {language} console environment and provides input via standard input. The environment will combine the Prepend, student submission, and Append scripts into a single program and run it. The standard output of the program will be compared (as a string) to the expected output of the test case. We recommend handling input parsing and function calls in one of these scripts." + }, "course.assessment.question.programming.standardOutput": { "defaultMessage": "Standard output" }, @@ -2157,6 +2612,9 @@ "course.assessment.question.programming.timeLimit": { "defaultMessage": "Time limit" }, + "course.assessment.question.programming.timeLimitDetail": { + "defaultMessage": "{timeLimit, plural, one {# minute} other {# minutes}}" + }, "course.assessment.question.programming.uploadNewPackage": { "defaultMessage": "Upload a new package" }, @@ -2166,11 +2624,182 @@ "course.assessment.question.programming.uploadPackage": { "defaultMessage": "Manually create/edit offline and upload" }, - "course.assessment.question.programming.uploadPackageHint": { - "defaultMessage": "Pack the package as a ZIP file, then upload it here. Useful for complex test cases or if you host your course's evaluation packages in some version control system (e.g., Git, Mercurial, etc.)." + "course.assessment.question.programming.uploadPackageHint": { + "defaultMessage": "Pack the package as a ZIP file, then upload it here. Useful for complex test cases or if you host your course's evaluation packages in some version control system (e.g., Git, Mercurial, etc.)." + }, + "course.assessment.question.programminquestion.questionSavedRedirecting": { + "defaultMessage": "Question saved." + }, + "course.assessment.question.rubricBasedResponses.addNewCategory": { + "defaultMessage": "Add new category" + }, + "course.assessment.question.rubricBasedResponses.addNewLevel": { + "defaultMessage": "Add new grade" + }, + "course.assessment.question.rubricBasedResponses.aiGrading": { + "defaultMessage": "AI Grading" + }, + "course.assessment.question.rubricBasedResponses.aiGradingCustomPrompt": { + "defaultMessage": "Custom Prompt" + }, + "course.assessment.question.rubricBasedResponses.aiGradingCustomPromptDescription": { + "defaultMessage": "Add grading instructions (e.g. question context, model answer, feedback tone). Leave blank if unsure." + }, + "course.assessment.question.rubricBasedResponses.aiGradingModelAnswer": { + "defaultMessage": "Model Answer" + }, + "course.assessment.question.rubricBasedResponses.aiGradingModelAnswerDescription": { + "defaultMessage": "Add an example answer that would get the maximum grades in each rubric category. Leave blank if unsure." + }, + "course.assessment.question.rubricBasedResponses.bonusReservedNames": { + "defaultMessage": "After finalization, a special category named 'Moderation' will be added automatically. It allows graders to award bonus or penalty points at their discretion." + }, + "course.assessment.question.rubricBasedResponses.categoryGrade": { + "defaultMessage": "Grade" + }, + "course.assessment.question.rubricBasedResponses.categoryGradeExplanation": { + "defaultMessage": "Explanation" + }, + "course.assessment.question.rubricBasedResponses.categoryMaximumGrade": { + "defaultMessage": "Max" + }, + "course.assessment.question.rubricBasedResponses.categoryName": { + "defaultMessage": "Category Name" + }, + "course.assessment.question.rubricBasedResponses.enableAiGrading": { + "defaultMessage": "Enable AI to auto-grade submissions" + }, + "course.assessment.question.rubricBasedResponses.enableAiGradingDescription": { + "defaultMessage": "AI will assign rubric scores and draft feedback for you to review and publish." + }, + "course.assessment.question.rubricBasedResponses.rubric": { + "defaultMessage": "Rubric" + }, + "course.assessment.question.rubricBasedResponses.rubricHint": { + "defaultMessage": "Rubric is used to grade the student's submission." + }, + "course.assessment.question.rubricPlayground.addAnswersPromptAction": { + "defaultMessage": "Add" + }, + "course.assessment.question.rubricPlayground.addAnswersTitle": { + "defaultMessage": "Add Sample Answers" + }, + "course.assessment.question.rubricPlayground.addExistingAnswers": { + "defaultMessage": "Add existing answers" + }, + "course.assessment.question.rubricPlayground.addRandomStudentAnswers": { + "defaultMessage": "Add {inputComponent} random student answer(s)" + }, + "course.assessment.question.rubricPlayground.addSampleAnswers": { + "defaultMessage": "Add Sample Answers" + }, + "course.assessment.question.rubricPlayground.answer": { + "defaultMessage": "Answer" + }, + "course.assessment.question.rubricPlayground.apply": { + "defaultMessage": "Apply" + }, + "course.assessment.question.rubricPlayground.applyFailure": { + "defaultMessage": "Failed to apply grading results" + }, + "course.assessment.question.rubricPlayground.applySuccess": { + "defaultMessage": "Grading rubric, prompt, and results successfully applied." + }, + "course.assessment.question.rubricPlayground.applyWillGradeAllAnswers": { + "defaultMessage": "Applying this rubric will assign grades to all student answers, including the ones not yet evaluated on this page." + }, + "course.assessment.question.rubricPlayground.applyingRubricGradingData": { + "defaultMessage": "Applying rubric grading data..." + }, + "course.assessment.question.rubricPlayground.categoryHeading": { + "defaultMessage": "C{index}" + }, + "course.assessment.question.rubricPlayground.compare": { + "defaultMessage": "Compare" + }, + "course.assessment.question.rubricPlayground.comparingRevisions": { + "defaultMessage": "Comparing {count} revisions" + }, + "course.assessment.question.rubricPlayground.confirmAIGradingApplication": { + "defaultMessage": "Confirm AI Grading Application" + }, + "course.assessment.question.rubricPlayground.confirmProceed": { + "defaultMessage": "Are you sure you wish to proceed?" + }, + "course.assessment.question.rubricPlayground.dismiss": { + "defaultMessage": "Dismiss" + }, + "course.assessment.question.rubricPlayground.evaluate": { + "defaultMessage": "Evaluate" + }, + "course.assessment.question.rubricPlayground.evaluateAll": { + "defaultMessage": "Evaluate All ({count})" + }, + "course.assessment.question.rubricPlayground.evaluateRemaining": { + "defaultMessage": "Evaluate Remaining ({count})" + }, + "course.assessment.question.rubricPlayground.evaluating": { + "defaultMessage": "Evaluating" + }, + "course.assessment.question.rubricPlayground.feedback": { + "defaultMessage": "Feedback" + }, + "course.assessment.question.rubricPlayground.gradingCategories": { + "defaultMessage": "Grading Categories" + }, + "course.assessment.question.rubricPlayground.gradingPrompt": { + "defaultMessage": "Grading Prompt" + }, + "course.assessment.question.rubricPlayground.gradingPromptDescription": { + "defaultMessage": "Instructions to guide the AI in grading and giving feedback." + }, + "course.assessment.question.rubricPlayground.modelAnswer": { + "defaultMessage": "Model Answer" + }, + "course.assessment.question.rubricPlayground.modelAnswerDescription": { + "defaultMessage": "An example that scores the maximum for each category." + }, + "course.assessment.question.rubricPlayground.noAnswers": { + "defaultMessage": "No sample answers have been added. Add some to get started." + }, + "course.assessment.question.rubricPlayground.notLatestRevisionWarning": { + "defaultMessage": "You have selected to apply a rubric which is not the latest revision saved on this page." + }, + "course.assessment.question.rubricPlayground.questionGrade": { + "defaultMessage": "Grade" + }, + "course.assessment.question.rubricPlayground.reevaluate": { + "defaultMessage": "Re-evaluate" + }, + "course.assessment.question.rubricPlayground.reevaluateAll": { + "defaultMessage": "Re-evaluate All ({count})" + }, + "course.assessment.question.rubricPlayground.rubricPlayground": { + "defaultMessage": "Rubric Playground" + }, + "course.assessment.question.rubricPlayground.sampleAnswerEvaluations": { + "defaultMessage": "Sample Answer Evaluations" + }, + "course.assessment.question.rubricPlayground.savedRubric": { + "defaultMessage": "Saved Rubric, {date}" + }, + "course.assessment.question.rubricPlayground.searchAnswersPlaceholder": { + "defaultMessage": "Search answers by student name or grade" + }, + "course.assessment.question.rubricPlayground.student": { + "defaultMessage": "Student" + }, + "course.assessment.question.rubricPlayground.totalGrade": { + "defaultMessage": "Total" + }, + "course.assessment.question.rubricPlayground.viewEditRubric": { + "defaultMessage": "View / Edit Rubric" + }, + "course.assessment.question.rubricPlayground.writeAnswerPlaceholder": { + "defaultMessage": "Write the answer here" }, - "course.assessment.question.programminquestion.questionSavedRedirecting": { - "defaultMessage": "Question saved." + "course.assessment.question.rubricPlayground.writeCustomAnswer": { + "defaultMessage": "Write a custom answer" }, "course.assessment.question.scribing.ScribingQuestionForm.cannotBeBlankValidationError": { "defaultMessage": "Cannot be blank." @@ -2241,8 +2870,14 @@ "course.assessment.question.textResponses.addSolution": { "defaultMessage": "Add a new solution" }, - "course.assessment.question.textResponses.allowFileUpload": { - "defaultMessage": "Allow file upload in the answer" + "course.assessment.question.textResponses.attachmentSettingRequired": { + "defaultMessage": "Attachment Setting should be defined in this question" + }, + "course.assessment.question.textResponses.attachmentSettings": { + "defaultMessage": "Attachment Settings" + }, + "course.assessment.question.textResponses.attachmentSettingsDescription": { + "defaultMessage": "When students are attempting this question," }, "course.assessment.question.textResponses.deleteSolution": { "defaultMessage": "Delete solution" @@ -2256,9 +2891,24 @@ "course.assessment.question.textResponses.grade": { "defaultMessage": "Grade" }, + "course.assessment.question.textResponses.isAttachmentRequired": { + "defaultMessage": "Require file upload for this question" + }, "course.assessment.question.textResponses.keyword": { "defaultMessage": "Keyword" }, + "course.assessment.question.textResponses.maxAttachmentSize": { + "defaultMessage": "Max Size per Attachment" + }, + "course.assessment.question.textResponses.maxAttachments": { + "defaultMessage": "Max Number of Attachments" + }, + "course.assessment.question.textResponses.multipleAttachments": { + "defaultMessage": "Multiple Attachments" + }, + "course.assessment.question.textResponses.multipleFileAttachmentDescription": { + "defaultMessage": "They can upload several attachments." + }, "course.assessment.question.textResponses.mustSpecifyGrade": { "defaultMessage": "You must specify a valid number for grade." }, @@ -2271,6 +2921,18 @@ "course.assessment.question.textResponses.newSolutionCannotUndo": { "defaultMessage": "This is a new solution. It will immediately disappear if you delete before saving it." }, + "course.assessment.question.textResponses.noAttachment": { + "defaultMessage": "No Attachment" + }, + "course.assessment.question.textResponses.noAttachmentDescription": { + "defaultMessage": "They will not be able to upload any attachment." + }, + "course.assessment.question.textResponses.singleFileAttachment": { + "defaultMessage": "Single Attachment" + }, + "course.assessment.question.textResponses.singleFileAttachmentDescription": { + "defaultMessage": "They can only upload one attachment." + }, "course.assessment.question.textResponses.solution": { "defaultMessage": "Solution" }, @@ -2289,155 +2951,23 @@ "course.assessment.question.textResponses.solutionsHint": { "defaultMessage": "Adding solutions allows the answer to be autograded. Students can only input plain text." }, - "course.assessment.question.textResponses.textResponseNote": { - "defaultMessage": "Note: If no solutions are provided, the autograder will always award the maximum grade." - }, - "course.assessment.question.textResponses.undoDeleteSolution": { - "defaultMessage": "Undo delete solution" - }, - "course.assessment.question.textResponses.zeroGrade": { - "defaultMessage": "0.0" - }, "course.assessment.question.textResponses.templateText": { "defaultMessage": "Template" }, "course.assessment.question.textResponses.templateTextDescription": { "defaultMessage": "Text that appears in the answer area when students attempt this question for the first time." }, - "course.assessment.question.rubricPlayground.rubricPlayground": { - "defaultMessage": "Rubric Playground" - }, - "course.assessment.question.rubricPlayground.savedRubric": { - "defaultMessage": "Saved Rubric, {date}" - }, - "course.assessment.question.rubricPlayground.viewEditRubric": { - "defaultMessage": "View / Edit Rubric" - }, - "course.assessment.question.rubricPlayground.evaluate": { - "defaultMessage": "Evaluate" - }, - "course.assessment.question.rubricPlayground.compare": { - "defaultMessage": "Compare" - }, - "course.assessment.question.rubricPlayground.apply": { - "defaultMessage": "Apply" - }, - "course.assessment.question.rubricPlayground.confirmAIGradingApplication": { - "defaultMessage": "Confirm AI Grading Application" - }, - "course.assessment.question.rubricPlayground.applyingRubricGradingData": { - "defaultMessage": "Applying rubric grading data..." - }, - "course.assessment.question.rubricPlayground.applySuccess": { - "defaultMessage": "Grading rubric, prompt, and results successfully applied." - }, - "course.assessment.question.rubricPlayground.applyFailure": { - "defaultMessage": "Failed to apply grading results" - }, - "course.assessment.question.rubricPlayground.notLatestRevisionWarning": { - "defaultMessage": "You have selected to apply a rubric which is not the latest revision saved on this page." - }, - "course.assessment.question.rubricPlayground.applyWillGradeAllAnswers": { - "defaultMessage": "Applying this rubric will assign grades to all student answers, including the ones not yet evaluated on this page." - }, - "course.assessment.question.rubricPlayground.confirmProceed": { - "defaultMessage": "Are you sure you wish to proceed?" - }, - "course.assessment.question.rubricPlayground.sampleAnswerEvaluations": { - "defaultMessage": "Sample Answer Evaluations" - }, - "course.assessment.question.rubricPlayground.addSampleAnswers": { - "defaultMessage": "Add Sample Answers" - }, - "course.assessment.question.rubricPlayground.evaluateAll": { - "defaultMessage": "Evaluate All ({count})" - }, - "course.assessment.question.rubricPlayground.reevaluateAll": { - "defaultMessage": "Re-evaluate All ({count})" - }, - "course.assessment.question.rubricPlayground.evaluateRemaining": { - "defaultMessage": "Evaluate Remaining ({count})" - }, - "course.assessment.question.rubricPlayground.comparingRevisions": { - "defaultMessage": "Comparing {count} revisions" - }, - "course.assessment.question.rubricPlayground.addSampleAnswersTitle": { - "defaultMessage": "Add Sample Answers" - }, - "course.assessment.question.rubricPlayground.add": { - "defaultMessage": "Add" - }, - "course.assessment.question.rubricPlayground.addExistingAnswers": { - "defaultMessage": "Add existing answers" - }, - "course.assessment.question.rubricPlayground.student": { - "defaultMessage": "Student" - }, - "course.assessment.question.rubricPlayground.questionGrade": { - "defaultMessage": "Grade" - }, - "course.assessment.question.rubricPlayground.categoryHeading": { - "defaultMessage": "C{index}" - }, - "course.assessment.question.rubricPlayground.answer": { - "defaultMessage": "Answer" - }, - "course.assessment.question.rubricPlayground.searchAnswersPlaceholder": { - "defaultMessage": "Search answers by student name or grade" - }, - "course.assessment.question.rubricPlayground.addRandomStudentAnswers": { - "defaultMessage": "Add {inputComponent} random student answer(s)" - }, - "course.assessment.question.rubricPlayground.writeCustomAnswer": { - "defaultMessage": "Write a custom answer" - }, - "course.assessment.question.rubricPlayground.writeAnswerPlaceholder": { - "defaultMessage": "Write the answer here" - }, - "course.assessment.question.rubricPlayground.dismiss": { - "defaultMessage": "Dismiss" - }, - "course.assessment.question.rubricPlayground.noAnswers": { - "defaultMessage": "No sample answers have been added. Add some to get started." - }, - "course.assessment.question.rubricPlayground.reevaluate": { - "defaultMessage": "Re-evaluate" - }, - "course.assessment.question.rubricPlayground.totalGrade": { - "defaultMessage": "Total" - }, - "course.assessment.question.rubricPlayground.feedback": { - "defaultMessage": "Feedback" - }, - "course.assessment.question.rubricPlayground.evaluating": { - "defaultMessage": "Evaluating" - }, - "course.assessment.question.rubricPlayground.gradingPrompt": { - "defaultMessage": "Grading Prompt" - }, - "course.assessment.question.rubricPlayground.gradingPromptDescription": { - "defaultMessage": "Instructions to guide the AI in grading and giving feedback." - }, - "course.assessment.question.rubricPlayground.modelAnswer": { - "defaultMessage": "Model Answer" - }, - "course.assessment.question.rubricPlayground.modelAnswerDescription": { - "defaultMessage": "An example that scores the maximum for each category." - }, - "course.assessment.question.rubricPlayground.gradingCategories": { - "defaultMessage": "Grading Categories" - }, - "course.assessment.question.rubricPlayground.addNewCategory": { - "defaultMessage": "Add New Category" + "course.assessment.question.textResponses.textResponseNote": { + "defaultMessage": "Note: If no solutions are provided, the autograder will always award the maximum grade." }, - "course.assessment.question.rubricPlayground.categoryName": { - "defaultMessage": "Category Name" + "course.assessment.question.textResponses.undoDeleteSolution": { + "defaultMessage": "Undo delete solution" }, - "course.assessment.question.rubricPlayground.max": { - "defaultMessage": "Max" + "course.assessment.question.textResponses.validAttachmentSettingValues": { + "defaultMessage": "Attachment Settings should be either no attachment, single file attachment, or multiple file attachment" }, - "course.assessment.question.rubricPlayground.addNewGrade": { - "defaultMessage": "Add New Grade" + "course.assessment.question.textResponses.zeroGrade": { + "defaultMessage": "0.0" }, "course.assessment.session.assessmentNotStarted": { "defaultMessage": "The assessment has not started yet. Please come back after {startDate}." @@ -2463,9 +2993,6 @@ "course.assessment.show.assessmentOnlyAvailableFrom": { "defaultMessage": "This assessment will only be available from" }, - "course.assessment.show.audioResponse": { - "defaultMessage": "Audio Response" - }, "course.assessment.show.baseExp": { "defaultMessage": "Base EXP" }, @@ -2499,6 +3026,9 @@ "course.assessment.show.chooseAssessmentToDuplicateInto": { "defaultMessage": "Choose an assessment to duplicate into" }, + "course.assessment.show.comprehension": { + "defaultMessage": "Comprehension" + }, "course.assessment.show.delete": { "defaultMessage": "Delete" }, @@ -2556,9 +3086,15 @@ "course.assessment.show.errorMovingQuestion": { "defaultMessage": "An error occurred while moving the question." }, + "course.assessment.show.failedSyncingWithKoditsu": { + "defaultMessage": "Not Synced with Koditsu" + }, "course.assessment.show.fileUpload": { "defaultMessage": "File Upload" }, + "course.assessment.show.fileUploadDescription": { + "defaultMessage": "Settings for the number of attachments allowed (none, one, or multiple)" + }, "course.assessment.show.files": { "defaultMessage": "Files" }, @@ -2571,20 +3107,20 @@ "course.assessment.show.forumPostResponse": { "defaultMessage": "Forum Post Response" }, - "course.assessment.show.gradedTestCases": { - "defaultMessage": "Graded test cases" - }, "course.assessment.show.generate": { "defaultMessage": "Generate Questions" }, - "course.assessment.show.generateTooltip": { - "defaultMessage": "Collaborate with Codaveri AI to create questions" + "course.assessment.show.generateFromProgrammingQuestion": { + "defaultMessage": "Generate a similar question with Codaveri AI" }, "course.assessment.show.generateFromQuestion": { "defaultMessage": "Generate a similar question with AI" }, - "course.assessment.show.generateFromProgrammingQuestion": { - "defaultMessage": "Generate a similar question with Codaveri AI" + "course.assessment.show.generateTooltip": { + "defaultMessage": "Collaborate with Codaveri AI to create questions" + }, + "course.assessment.show.gradedTestCases": { + "defaultMessage": "Graded test cases" }, "course.assessment.show.gradingMode": { "defaultMessage": "Grading mode" @@ -2601,6 +3137,9 @@ "course.assessment.show.hideOptions": { "defaultMessage": "Hide options" }, + "course.assessment.show.koditsuMode": { + "defaultMessage": "Koditsu" + }, "course.assessment.show.manageComponents": { "defaultMessage": "Manage Components in Course Settings" }, @@ -2649,6 +3188,9 @@ "course.assessment.show.newQuestion": { "defaultMessage": "New Question" }, + "course.assessment.show.newRubricBasedResponse": { + "defaultMessage": "New Rubric Based Response Question" + }, "course.assessment.show.newScribing": { "defaultMessage": "New Scribing Question" }, @@ -2706,6 +3248,9 @@ "course.assessment.show.requirementsHint": { "defaultMessage": "The following items must be fulfilled to unlock this assessment." }, + "course.assessment.show.rubricBasedResponse": { + "defaultMessage": "Rubric-Based Response" + }, "course.assessment.show.scribing": { "defaultMessage": "Scribing" }, @@ -2715,15 +3260,15 @@ "course.assessment.show.showMcqMrqSolution": { "defaultMessage": "Show MCQ/MRQ solutions" }, - "course.assessment.show.showRubricToStudents": { - "defaultMessage": "Show rubric breakdown to students" - }, "course.assessment.show.showMcqSubmitResult": { "defaultMessage": "Show MCQ submit result" }, "course.assessment.show.showOptions": { "defaultMessage": "Show options" }, + "course.assessment.show.showRubricToStudents": { + "defaultMessage": "Show rubric breakdown to students" + }, "course.assessment.show.sureChangingQuestionType": { "defaultMessage": "Sure you're changing this question type?" }, @@ -2733,6 +3278,12 @@ "course.assessment.show.sureDeletingQuestion": { "defaultMessage": "Sure you're deleting this question?" }, + "course.assessment.show.syncedWithKoditsu": { + "defaultMessage": "Synced with Koditsu" + }, + "course.assessment.show.syncingWithKoditsu": { + "defaultMessage": "Syncing with Koditsu" + }, "course.assessment.show.textResponse": { "defaultMessage": "Text Response" }, @@ -2748,6 +3299,9 @@ "course.assessment.show.unsubmittingAndChangingQuestionType": { "defaultMessage": "Unsubmitting submissions and changing your question type..." }, + "course.assessment.show.voiceResponse": { + "defaultMessage": "Audio Response" + }, "course.assessment.show.whileHoldingToCancelMoving": { "defaultMessage": "while holding to cancel moving." }, @@ -2844,99 +3398,36 @@ "course.assessment.skills.SkillsTable.noSkill": { "defaultMessage": "Sorry, no skill found under this skill branch." }, - "course.assessment.skills.SkillsTable.skills": { - "defaultMessage": "Skills" - }, - "course.assessment.skills.SkillsTable.uncategorised": { - "defaultMessage": "Uncategorised Skills" - }, - "course.assessment.liveFeedback.questionTitle": { - "defaultMessage": "Question {index}" - }, - "course.assessment.liveFeedback.messageTimingTitle": { - "defaultMessage": "Generated at: {usedAt}" - }, - "course.assessment.liveFeedback.liveFeedbackName": { - "defaultMessage": "Get Help" - }, - "course.assessment.liveFeedback.comments": { - "defaultMessage": "Comments" - }, - "course.assessment.liveFeedback.lineHeader": { - "defaultMessage": "Line {lineNumber}" - }, - "course.assessment.submission.GetHelpChatPage.chatInputText": { - "defaultMessage": "How can we help you?" - }, - "course.assessment.submission.GetHelpChatPage.chatMessagesRemaining": { - "defaultMessage": "{numMessages} / {maxMessages} {numMessages, plural, one {message} other {messages}} remaining" - }, - "course.assessment.submission.GetHelpChatPage.noChatMessagesRemaining": { - "defaultMessage": "You have reached the message limit for this question." - }, - "course.assessment.submission.GetHelpChatPage.codeUpdated": { - "defaultMessage": "Code Updated" - }, - "course.assessment.submission.GetHelpChatPage.ConversationArea.lineNumber": { - "defaultMessage": "Line {lineNumber}" - }, - "course.assessment.submission.GetHelpChatPage.ConversationArea.fileNameAndLineNumber": { - "defaultMessage": "{filename}:{lineNumber}" - }, - "course.assessment.submission.GetHelpChatPage.ConversationArea.threadExpired": { - "defaultMessage": "The chat above has ended. Start a new chat?" - }, - "course.assessment.plagiarism.plagiarism": { - "defaultMessage": "Plagiarism Results" - }, - "course.assessment.plagiarism.status": { - "defaultMessage": "Plagiarism Check Status" - }, - "course.assessment.plagiarism.lastRunTime": { - "defaultMessage": "Last run at: {date}" - }, - "course.assessment.plagiarism.start": { - "defaultMessage": "New Plagiarism Check" - }, - "course.assessment.plagiarism.notStarted": { - "defaultMessage": "No plagiarism check has been run" - }, - "course.assessment.plagiarism.confirmStartTitle": { - "defaultMessage": "Confirm Plagiarism Check?" - }, - "course.assessment.plagiarism.confirmStartMessage": { - "defaultMessage": "Running a new plagiarism check will remove the previous results." - }, - "course.assessment.plagiarism.results": { - "defaultMessage": "Plagiarism Results (similarity between submissions)" - }, - "course.assessment.plagiarism.baseSubmission": { - "defaultMessage": "Base Submission" + "course.assessment.skills.SkillsTable.skills": { + "defaultMessage": "Skills" }, - "course.assessment.plagiarism.comparedSubmission": { - "defaultMessage": "Compared Submission" + "course.assessment.skills.SkillsTable.uncategorised": { + "defaultMessage": "Uncategorised Skills" }, - "course.assessment.plagiarism.similarityScore": { - "defaultMessage": "Similarity Score" + "course.assessment.statistics.ancestorFail": { + "defaultMessage": "Failed to fetch past iterations of this assessment." }, - "course.assessment.plagiarism.actions": { - "defaultMessage": "Actions" + "course.assessment.statistics.ancestorSelect.current": { + "defaultMessage": "Current" }, - "course.assessment.plagiarism.viewReport": { - "defaultMessage": "View Report" + "course.assessment.statistics.ancestorSelect.fromCourse": { + "defaultMessage": "From {courseTitle}" }, - "course.assessment.plagiarism.downloadPdf": { - "defaultMessage": "Download PDF" + "course.assessment.statistics.ancestorSelect.subtitle": { + "defaultMessage": "Compare against past versions of this assessment:" }, - "course.assessment.plagiarism.searchByStudentName": { - "defaultMessage": "Search by Student Name" + "course.assessment.statistics.ancestorSelect.title": { + "defaultMessage": "Duplication History" }, - "course.assessment.plagiarism.showSelfPlagiarism": { - "defaultMessage": "Include self-plagiarism comparisons (same student, different courses)" + "course.assessment.statistics.ancestorStatisticsFail": { + "defaultMessage": "Failed to fetch ancestor's statistics." }, "course.assessment.statistics.answers": { "defaultMessage": "Answers" }, + "course.assessment.statistics.attemptCount": { + "defaultMessage": "Attempt Count" + }, "course.assessment.statistics.attempts.filename": { "defaultMessage": "Question-level Attempt Statistics for {assessment}" }, @@ -2949,21 +3440,78 @@ "course.assessment.statistics.closePrompt": { "defaultMessage": "Close" }, + "course.assessment.statistics.comments": { + "defaultMessage": "Comments" + }, + "course.assessment.statistics.duplicationHistory": { + "defaultMessage": "Duplication History" + }, + "course.assessment.statistics.email": { + "defaultMessage": "Email" + }, + "course.assessment.statistics.fail": { + "defaultMessage": "Failed to fetch statistics." + }, + "course.assessment.statistics.gradeDisplay": { + "defaultMessage": "Grade: {grade} / {maxGrade}" + }, + "course.assessment.statistics.gradeDistribution": { + "defaultMessage": "Grade Distribution" + }, + "course.assessment.statistics.gradeDistribution.datasetLabel": { + "defaultMessage": "Distribution" + }, + "course.assessment.statistics.gradeDistribution.xAxisLabel": { + "defaultMessage": "Grades" + }, + "course.assessment.statistics.gradeDistribution.yAxisLabel": { + "defaultMessage": "Submissions" + }, "course.assessment.statistics.grader": { "defaultMessage": "Grader" }, + "course.assessment.statistics.gradesPerQuestion": { + "defaultMessage": "Grades Per Question" + }, "course.assessment.statistics.grayCellLegend": { "defaultMessage": "Undecided (question is Non-autogradable)" }, "course.assessment.statistics.group": { "defaultMessage": "Group" }, - "course.assessment.statistics.legendHigherusage": { - "defaultMessage": "Higher Usage" + "course.assessment.statistics.header": { + "defaultMessage": "Statistics for {title}" + }, + "course.assessment.statistics.includePhantom": { + "defaultMessage": "Include Phantom Student" }, - "course.assessment.statistics.legendLowerUsage": { + "course.assessment.statistics.legendHigherLabelGrade": { + "defaultMessage": "Higher Grade" + }, + "course.assessment.statistics.legendHigherLabelGradeDiff": { + "defaultMessage": "More Improvement" + }, + "course.assessment.statistics.legendLowerLabelGrade": { + "defaultMessage": "Lower Grade" + }, + "course.assessment.statistics.legendLowerLabelGradeDiff": { + "defaultMessage": "Less Improvement" + }, + "course.assessment.statistics.legendLowerLabelMessagesSent": { "defaultMessage": "Lower Usage" }, + "course.assessment.statistics.legendLowerLabelWordCount": { + "defaultMessage": "Lower Word Count" + }, + "course.assessment.statistics.legendUpperLabelMessagesSent": { + "defaultMessage": "Higher Usage" + }, + "course.assessment.statistics.legendUpperLabelWordCount": { + "defaultMessage": "Higher Word Count" + }, + "course.assessment.statistics.liveFeedback": { + "defaultMessage": "Get Help" + }, "course.assessment.statistics.liveFeedback.filename": { "defaultMessage": "Question-level Get Help Statistics for {assessment}" }, @@ -2988,78 +3536,30 @@ "course.assessment.statistics.nameGroupsSearchText": { "defaultMessage": "Search by Name or Groups" }, + "course.assessment.statistics.noIncludePhantom": { + "defaultMessage": "*All statistics in this duplicated assessments does not include Phantom Students" + }, "course.assessment.statistics.noSubmission": { "defaultMessage": "No submission yet" }, "course.assessment.statistics.onlyForAutogradableAssessment": { "defaultMessage": "This table is only displayed for Assessment with at least one Autograded Questions" }, + "course.assessment.statistics.pastAnswerTitle": { + "defaultMessage": "Submitted At: {submittedAt}" + }, "course.assessment.statistics.questionDisplayTitle": { "defaultMessage": "Q{index} for {student}" }, "course.assessment.statistics.questionIndex": { "defaultMessage": "Q{index}" }, - "course.assessment.statistics.total": { - "defaultMessage": "Total" - }, - "course.assessment.statistics.workflowState": { - "defaultMessage": "Status" - }, - "course.assessment.statistics.ancestorFail": { - "defaultMessage": "Failed to fetch past iterations of this assessment." - }, - "course.assessment.statistics.ancestorStatisticsFail": { - "defaultMessage": "Failed to fetch ancestor's statistics." - }, - "course.assessment.statistics.fail": { - "defaultMessage": "Failed to fetch statistics." - }, - "course.assessment.statistics.gradeDistribution": { - "defaultMessage": "Grade Distribution" - }, - "course.assessment.statistics.gradeViolin.datasetLabel": { - "defaultMessage": "Distribution" - }, - "course.assessment.statistics.gradeViolin.xAxisLabel": { - "defaultMessage": "Grades" - }, - "course.assessment.statistics.gradeViolin.yAxisLabel": { - "defaultMessage": "Submissions" - }, - "course.assessment.statistics.ancestorSelect.current": { - "defaultMessage": "Current" - }, - "course.assessment.statistics.ancestorSelect.fromCourse": { - "defaultMessage": "From {courseTitle}" - }, - "course.assessment.statistics.ancestorSelect.subtitle": { - "defaultMessage": "Compare against past versions of this assessment:" - }, - "course.assessment.statistics.ancestorSelect.title": { - "defaultMessage": "Duplication History" - }, - "course.assessment.statistics.attemptCount": { - "defaultMessage": "Attempt Count" - }, - "course.assessment.statistics.duplicationHistory": { - "defaultMessage": "Duplication History" - }, - "course.assessment.statistics.gradesPerQuestion": { - "defaultMessage": "Grades Per Question" - }, - "course.assessment.statistics.includePhantom": { - "defaultMessage": "Include Phantom Student" - }, - "course.assessment.statistics.liveFeedback": { - "defaultMessage": "Get Help" - }, - "course.assessment.statistics.header": { - "defaultMessage": "Statistics for {title}" - }, "course.assessment.statistics.statistics": { "defaultMessage": "Statistics" }, + "course.assessment.statistics.submissionPage": { + "defaultMessage": "Go to Answer Page" + }, "course.assessment.statistics.submissionStatuses": { "defaultMessage": "Submission Statuses" }, @@ -3078,9 +3578,18 @@ "course.assessment.statistics.submissionTimeGradeChart.xAxisLabel.withoutDeadline": { "defaultMessage": "Submission Date" }, + "course.assessment.statistics.total": { + "defaultMessage": "Total" + }, + "course.assessment.statistics.workflowState": { + "defaultMessage": "Status" + }, "course.assessment.submission.Annotations.comment": { "defaultMessage": "Add Comment" }, + "course.assessment.submission.Answer.rendererNotImplemented": { + "defaultMessage": "The display for this question type has not been implemented yet." + }, "course.assessment.submission.CodaveriFeedbackStatus.codaveriFeedbackStatus": { "defaultMessage": "Codaveri Feedback Status" }, @@ -3099,14 +3608,77 @@ "course.assessment.submission.EvaluatorErrorPanel.emailSubject": { "defaultMessage": "[Bug Report] Evaluator Error" }, + "course.assessment.submission.FileInput.exactlyOneFileUploadAllowed": { + "defaultMessage": "*You must upload EXACTLY 1 file for this question" + }, + "course.assessment.submission.FileInput.fileName": { + "defaultMessage": "{index}. {name}" + }, + "course.assessment.submission.FileInput.fileTooLargeErrorMessage": { + "defaultMessage": "The following files have size larger than allowed ({maxAttachmentSize} MB)" + }, + "course.assessment.submission.FileInput.fileUploadErrorTitle": { + "defaultMessage": "Error in Uploading Files" + }, + "course.assessment.submission.FileInput.onlyOneFileUploadAllowed": { + "defaultMessage": "*You can only upload AT MOST {maxAttachments} file for this question" + }, + "course.assessment.submission.FileInput.requiredUploadLimitedNumberOfFiles": { + "defaultMessage": "*You can upload AT LEAST 1 and AT MOST {maxAttachments} files for this question" + }, + "course.assessment.submission.FileInput.tooManyFilesErrorMessage": { + "defaultMessage": "You have attempted to upload {numFiles} files, but ONLY {maxAttachmentsAllowed} {maxAttachmentsAllowed, plural, one {file} other {files}} can be uploaded {numAttachments, plural, =0 {} one {since 1 file has been uploaded before} other {since {numAttachments} files has been uploaded before}}" + }, "course.assessment.submission.FileInput.uploadDisabled": { "defaultMessage": "File upload disabled" }, "course.assessment.submission.FileInput.uploadLabel": { "defaultMessage": "Drag and drop or click to upload files" }, + "course.assessment.submission.GetHelpChatPage": { + "defaultMessage": "Get Help" + }, + "course.assessment.submission.GetHelpChatPage.ConversationArea.fileNameAndLineNumber": { + "defaultMessage": "{filename}:{lineNumber}" + }, + "course.assessment.submission.GetHelpChatPage.ConversationArea.lineNumber": { + "defaultMessage": "Line {lineNumber}" + }, + "course.assessment.submission.GetHelpChatPage.ConversationArea.threadExpired": { + "defaultMessage": "The chat above has ended. Start a new chat?" + }, + "course.assessment.submission.GetHelpChatPage.chatInputText": { + "defaultMessage": "How can we help you?" + }, + "course.assessment.submission.GetHelpChatPage.chatMessagesRemaining": { + "defaultMessage": "{numMessages} / {maxMessages} {numMessages, plural, one {message} other {messages}} remaining" + }, + "course.assessment.submission.GetHelpChatPage.codeUpdated": { + "defaultMessage": "Code Updated" + }, + "course.assessment.submission.GetHelpChatPage.endOfConversation": { + "defaultMessage": "View code after conversation" + }, + "course.assessment.submission.GetHelpChatPage.failedSyncingWithCodaveri": { + "defaultMessage": "Unavailable" + }, + "course.assessment.submission.GetHelpChatPage.noChatMessagesRemaining": { + "defaultMessage": "You have reached the message limit for this question." + }, + "course.assessment.submission.GetHelpChatPage.syncedWithCodaveri": { + "defaultMessage": "Ready" + }, + "course.assessment.submission.GetHelpChatPage.syncingWithCodaveri": { + "defaultMessage": "Preparing" + }, + "course.assessment.submission.ImportedFileView.delete": { + "defaultMessage": "Delete" + }, "course.assessment.submission.ImportedFileView.deleteConfirmation": { - "defaultMessage": "Are you sure you want to delete this file?" + "defaultMessage": "Are you sure you want to delete \"{fileName}\"?" + }, + "course.assessment.submission.ImportedFileView.deleteTitle": { + "defaultMessage": "Delete File" }, "course.assessment.submission.ImportedFileView.noFiles": { "defaultMessage": "No files uploaded." @@ -3114,17 +3686,11 @@ "course.assessment.submission.ImportedFileView.uploadedFiles": { "defaultMessage": "Uploaded Files:" }, - "course.assessment.submission.Answer.missingAnswer": { - "defaultMessage": "There is no answer submitted for this question - this might be caused by the addition of this question after the submission is submitted." - }, - "course.assessment.submission.answers.AnswerHeader.noPastAnswers": { - "defaultMessage": "No past answers." - }, - "course.assessment.submission.Answer.rendererNotImplemented": { - "defaultMessage": "The display for this question type has not been implemented yet." + "course.assessment.submission.SubmissionEditIndex.TimeLimitBanner.hoursMinutesSeconds": { + "defaultMessage": "{hrs, plural, one {# hour} other {# hours}} {mins, plural, =0 {} one {# minute} other {# minutes}} {secs, plural, =0 {} one {# second} other {# seconds}}" }, - "course.assessment.submission.SubmissionAnswer.viewPastAnswers": { - "defaultMessage": "Past Answers" + "course.assessment.submission.SubmissionEditIndex.TimeLimitBanner.minutesSeconds": { + "defaultMessage": "{secs, plural, one {# second} other {# seconds}}" }, "course.assessment.submission.SubmissionsIndex.accessLogs": { "defaultMessage": "Access Logs" @@ -3159,6 +3725,9 @@ "course.assessment.submission.SubmissionsIndex.experiencePoints": { "defaultMessage": "EXP Awarded" }, + "course.assessment.submission.SubmissionsIndex.fetchFromKoditsu": { + "defaultMessage": "Fetch Submissions from Koditsu" + }, "course.assessment.submission.SubmissionsIndex.forceSubmit": { "defaultMessage": "Force Submit Remaining" }, @@ -3171,12 +3740,12 @@ "course.assessment.submission.SubmissionsIndex.includePhantoms": { "defaultMessage": "Include phantom users" }, - "lib.translations.myStudents": { - "defaultMessage": "My Students" - }, "course.assessment.submission.SubmissionsIndex.phantom": { "defaultMessage": "Phantom User" }, + "course.assessment.submission.SubmissionsIndex.publishAutoFeedback": { + "defaultMessage": "Publish Automated Programming Feedback ({count})" + }, "course.assessment.submission.SubmissionsIndex.publishGrades": { "defaultMessage": "Publish Grades" }, @@ -3186,21 +3755,6 @@ "course.assessment.submission.SubmissionsIndex.remind": { "defaultMessage": "Send Reminder Emails" }, - "lib.translations.staff": { - "defaultMessage": "Staff" - }, - "lib.translations.students": { - "defaultMessage": "Students" - }, - "lib.translations.myStudentsIncludingPhantoms": { - "defaultMessage": "My Students (Including Phantoms)" - }, - "lib.translations.studentsIncludingPhantoms": { - "defaultMessage": "Students (Including Phantoms)" - }, - "lib.translations.staffIncludingPhantoms": { - "defaultMessage": "Staff (Including Phantoms)" - }, "course.assessment.submission.SubmissionsIndex.submissionStatus": { "defaultMessage": "Status" }, @@ -3216,6 +3770,9 @@ "course.assessment.submission.SubmissionsIndex.userName": { "defaultMessage": "Name" }, + "course.assessment.submission.TestCaseView.allFailed": { + "defaultMessage": "All failed" + }, "course.assessment.submission.TestCaseView.allPassed": { "defaultMessage": "All passed" }, @@ -3255,8 +3812,17 @@ "course.assessment.submission.TestCaseView.standardOutput": { "defaultMessage": "Standard Output" }, + "course.assessment.submission.TestCaseView.testCasesPassed": { + "defaultMessage": "{numPassed}/{numTestCases} passed" + }, "course.assessment.submission.UploadedFileView.deleteConfirmation": { - "defaultMessage": "Are you sure you want to delete this attachment?" + "defaultMessage": "Are you sure you want to delete {fileName}?" + }, + "course.assessment.submission.UploadedFileView.deleteTitle": { + "defaultMessage": "Delete File" + }, + "course.assessment.submission.UploadedFileView.deleting": { + "defaultMessage": "Delete" }, "course.assessment.submission.UploadedFileView.noFiles": { "defaultMessage": "No files uploaded." @@ -3265,7 +3831,7 @@ "defaultMessage": "Uploaded Files" }, "course.assessment.submission.VoiceResponseAnswer.chooseVoiceFileExplain": { - "defaultMessage": "Drag your audio file here, or click to select an audio file. Only wav and mp3 formats are supported. Alternatively, you may use the recorder below to record your response" + "defaultMessage": "Drag and drop or click to upload your WAV / MP3 files. Alternatively, use the recorder below to record your response" }, "course.assessment.submission.VoiceResponseAnswer.pleaseRecordYourVoice": { "defaultMessage": "Please record your voice" @@ -3390,6 +3956,24 @@ "course.assessment.submission.answerSubmitted": { "defaultMessage": "Answer Submitted" }, + "course.assessment.submission.answerTooLarge": { + "defaultMessage": "Answer Too Large" + }, + "course.assessment.submission.answerTooLargeError": { + "defaultMessage": "Your answer must be less than 2 MB." + }, + "course.assessment.submission.answers.AnswerHeader.noPastAnswers": { + "defaultMessage": "No past answers." + }, + "course.assessment.submission.answers.AnswerHeader.viewAllAnswers": { + "defaultMessage": "All Answers ({count})" + }, + "course.assessment.submission.answers.AnswerHeader.viewGetHelpHistory": { + "defaultMessage": "Get Help History ({count})" + }, + "course.assessment.submission.answers.AnswerHeader.viewPastAnswers": { + "defaultMessage": "Past Answers ({count})" + }, "course.assessment.submission.answers.ForumPostResponse.ForumCard.forumCardTitleTypeNoneSelected": { "defaultMessage": "Forum" }, @@ -3462,17 +4046,11 @@ "course.assessment.submission.answers.ForumPostResponse.TopicCard.viewTopicInNewTab": { "defaultMessage": "View Topic" }, - "course.assessment.submission.answers.Programming.ProgrammingFile.downloadFile": { - "defaultMessage": "Download File" - }, "course.assessment.submission.answers.Programming.ProgrammingFile.sizeTooBig": { "defaultMessage": "The file is too big and cannot be displayed." }, - "course.assessment.submission.answerTooLarge": { - "defaultMessage": "Answer Too Large" - }, - "course.assessment.submission.answerTooLargeError": { - "defaultMessage": "Your answer must be less than 2 MB." + "course.assessment.submission.attachmentRequired": { + "defaultMessage": "*please upload AT LEAST 1 file for this question" }, "course.assessment.submission.attemptedAt": { "defaultMessage": "Attempted At" @@ -3495,14 +4073,17 @@ "course.assessment.submission.bonusEndAt": { "defaultMessage": "Bonus End At" }, - "course.assessment.submission.codaveriAutogradeFailure": { - "defaultMessage": "There is an error while evaluating your code in Codaveri. Try submitting your code again in a couple of minutes or check the error message in the network response." + "course.assessment.submission.category": { + "defaultMessage": "Category" }, - "course.assessment.submission.liveFeedbackNoneGenerated": { - "defaultMessage": "Question {questionIndex}: No feedback generated." + "course.assessment.submission.checkAnswer": { + "defaultMessage": "Check Answer" }, - "course.assessment.submission.liveFeedbackSuccess": { - "defaultMessage": "Question {questionIndex}: Feedback successfully generated." + "course.assessment.submission.checkAnswerWithLimit": { + "defaultMessage": "Check Answer ({attemptsLeft, plural, one {# attempt} other {# attempts}} left)" + }, + "course.assessment.submission.codaveriAutogradeFailure": { + "defaultMessage": "There is an error while evaluating your code in Codaveri. Try submitting your code again in a couple of minutes or check the error message in the network response." }, "course.assessment.submission.comment.CodaveriCommentCard.finalise": { "defaultMessage": "Finalise and Post Feedback" @@ -3528,14 +4109,14 @@ "course.assessment.submission.comment.CommentCard.deleteConfirmation": { "defaultMessage": "Are you sure you want to delete this comment?" }, - "course.assessment.submission.comment.CommentCard.save": { - "defaultMessage": "Save" + "course.assessment.submission.comment.CommentCard.isAiGenerated": { + "defaultMessage": "AI Generated Comment" }, "course.assessment.submission.comment.CommentCard.publish": { "defaultMessage": "Publish" }, - "course.assessment.submission.comment.CommentCard.isAiGenerated": { - "defaultMessage": "AI Generated Comment" + "course.assessment.submission.comment.CommentCard.save": { + "defaultMessage": "Save" }, "course.assessment.submission.comment.CommentField.comment": { "defaultMessage": "Comment" @@ -3549,20 +4130,8 @@ "course.assessment.submission.comment.CommentField.prompt": { "defaultMessage": "Add a new comment here..." }, - "course.assessment.submission.comments": { - "defaultMessage": "Comments" - }, - "course.assessment.submission.answers.Programming.ProgrammingFiles.liveFeedbackItemDelete": { - "defaultMessage": "Dismiss" - }, - "course.assessment.submission.answers.Programming.ProgrammingFiles.liveFeedbackItemDislike": { - "defaultMessage": "Dislike" - }, - "course.assessment.submission.answers.Programming.ProgrammingFiles.liveFeedbackItemLike": { - "defaultMessage": "Like" - }, - "course.assessment.submission.answers.Programming.ProgrammingFiles.liveFeedbackItemLineHeading": { - "defaultMessage": "Line {linenum}" + "course.assessment.submission.comments": { + "defaultMessage": "Comments" }, "course.assessment.submission.continue": { "defaultMessage": "Continue" @@ -3609,6 +4178,9 @@ "course.assessment.submission.emptyAssessment": { "defaultMessage": "This assessment currently has no questions." }, + "course.assessment.submission.errorUnknown": { + "defaultMessage": "Error is Unknown" + }, "course.assessment.submission.examDialogMessage": { "defaultMessage": "Please do not sign out or close the browser, otherwise you may have trouble continuing the exam." }, @@ -3618,6 +4190,18 @@ "course.assessment.submission.expAwarded": { "defaultMessage": "EXP Awarded" }, + "course.assessment.submission.explanation": { + "defaultMessage": "Explanation" + }, + "course.assessment.submission.fetchSubmissionsFromKoditsuConfirmation": { + "defaultMessage": "Are you sure you want to fetch all submissions from Koditsu? all the existing answers here will be overwritten by the newer one. NOTE THAT THIS ACTION IS IRREVERSIBLE!" + }, + "course.assessment.submission.fetchSubmissionsFromKoditsuPending": { + "defaultMessage": "Please wait as the submissions are currently being fetched from Koditsu." + }, + "course.assessment.submission.fetchSubmissionsFromKoditsuSuccess": { + "defaultMessage": "All submissions have been fetched successfully from Koditsu" + }, "course.assessment.submission.finalise": { "defaultMessage": "Finalise all answers" }, @@ -3648,6 +4232,9 @@ "course.assessment.submission.grade": { "defaultMessage": "Grade" }, + "course.assessment.submission.gradeDisplay": { + "defaultMessage": "Grade: {grade}" + }, "course.assessment.submission.gradePrefilled": { "defaultMessage": "Pre-filled" }, @@ -3657,9 +4244,6 @@ "course.assessment.submission.gradeSummary": { "defaultMessage": "Grade Summary" }, - "course.assessment.submission.gradeUnsaved": { - "defaultMessage": "Unsaved" - }, "course.assessment.submission.gradeUnsavedHint": { "defaultMessage": "This grade is not yet saved. Click Save Grade at the end of the page to save all grade changes." }, @@ -3675,6 +4259,12 @@ "course.assessment.submission.group": { "defaultMessage": "Group" }, + "course.assessment.submission.history.questionTitle": { + "defaultMessage": "Question Details" + }, + "course.assessment.submission.history.title": { + "defaultMessage": "Submission by {studentName}, Question {number}" + }, "course.assessment.submission.importFilesFailure": { "defaultMessage": "File uploads failed: {errors}" }, @@ -3684,9 +4274,24 @@ "course.assessment.submission.invalidFileUpload": { "defaultMessage": "File uploads failed: Only java files can be uploaded" }, + "course.assessment.submission.isSaved": { + "defaultMessage": "Saved" + }, + "course.assessment.submission.isSaving": { + "defaultMessage": "Saving" + }, + "course.assessment.submission.isUnsaved": { + "defaultMessage": "Unsaved" + }, "course.assessment.submission.lateSubmission": { "defaultMessage": "This submission is LATE! You may want to penalize the student for late submission." }, + "course.assessment.submission.liveFeedbackHistory.codeHistory": { + "defaultMessage": "Code History" + }, + "course.assessment.submission.liveFeedbackNoneGenerated": { + "defaultMessage": "No feedback generated." + }, "course.assessment.submission.loadingComment": { "defaultMessage": "Loading comment field..." }, @@ -3723,6 +4328,9 @@ "course.assessment.submission.mark": { "defaultMessage": "Submit for Publishing" }, + "course.assessment.submission.max": { + "defaultMessage": "Max" + }, "course.assessment.submission.maximumGroupGrade": { "defaultMessage": "Maximum Grade for this Group" }, @@ -3735,6 +4343,9 @@ "course.assessment.submission.ok": { "defaultMessage": "OK" }, + "course.assessment.submission.onlyOneAttachmentAllowed": { + "defaultMessage": "*ONLY 1 file is allowed for this question" + }, "course.assessment.submission.pastAnswers": { "defaultMessage": "Past Answers" }, @@ -3777,14 +4388,20 @@ "course.assessment.submission.question": { "defaultMessage": "Question" }, - "course.assessment.submission.questionNumber": { - "defaultMessage": "Q{number}" + "course.assessment.submission.questionAnswer": { + "defaultMessage": "Answer" }, "course.assessment.submission.questionDescription": { "defaultMessage": "Description" }, - "course.assessment.submission.questionAnswer": { - "defaultMessage": "Answer" + "course.assessment.submission.questionHeading": { + "defaultMessage": "Question {number}" + }, + "course.assessment.submission.questionHeadingWithTitle": { + "defaultMessage": "Question {number}: {title}" + }, + "course.assessment.submission.questionNumber": { + "defaultMessage": "Q{number}" }, "course.assessment.submission.readOnlyEditor.expandComments": { "defaultMessage": "Expand all comments" @@ -3795,6 +4412,12 @@ "course.assessment.submission.reevaluate": { "defaultMessage": "Re-evaluate Answer" }, + "course.assessment.submission.remainingBufferTime": { + "defaultMessage": "Finalising in: {timeLimit}" + }, + "course.assessment.submission.remainingTime": { + "defaultMessage": "Time Remaining: {timeLimit}" + }, "course.assessment.submission.rendererNotImplemented": { "defaultMessage": "The display for this question type has not been implemented yet." }, @@ -3807,14 +4430,8 @@ "course.assessment.submission.resetConfirmation": { "defaultMessage": "Are you sure you want to reset your answer? This action is irreversible and you will lose all your current work for this question." }, - "course.assessment.submission.checkAnswer": { - "defaultMessage": "Check Answer" - }, - "course.assessment.submission.checkAnswerWithLimit": { - "defaultMessage": "Check Answer ({attemptsLeft, plural, one {# attempt} other {# attempts}} left)" - }, - "course.assessment.submission.submitWithLimit": { - "defaultMessage": "Submit ({attemptsLeft, plural, one {# attempt} other {# attempts}} left)" + "course.assessment.submission.rubricScores": { + "defaultMessage": "Rubric Grades" }, "course.assessment.submission.saveDraft": { "defaultMessage": "Save Draft" @@ -3822,6 +4439,15 @@ "course.assessment.submission.saveGrade": { "defaultMessage": "Save Grade" }, + "course.assessment.submission.saved": { + "defaultMessage": "Saved" + }, + "course.assessment.submission.saving": { + "defaultMessage": "Saving" + }, + "course.assessment.submission.savingFailed": { + "defaultMessage": "Saving Failed" + }, "course.assessment.submission.sendReminderEmailConfirmation": { "defaultMessage": "Send reminder emails to {unattempted} unattempted and {attempting} attempting user(s) ({selectedUsers}) who have not completed the assessment?" }, @@ -3858,6 +4484,9 @@ "course.assessment.submission.submissionBy": { "defaultMessage": "Submission by {name}" }, + "course.assessment.submission.submissionError": { + "defaultMessage": "There is a problem in submitting question for {questions}" + }, "course.assessment.submission.submissionsHeader": { "defaultMessage": "Submissions: {assessment}" }, @@ -3870,14 +4499,47 @@ "course.assessment.submission.submitShortcut": { "defaultMessage": "(Ctrl+Enter) or (⌘+Enter)" }, + "course.assessment.submission.submitWithLimit": { + "defaultMessage": "Submit ({attemptsLeft, plural, one {# attempt} other {# attempts}} left)" + }, "course.assessment.submission.submitted": { "defaultMessage": "Submitted" }, "course.assessment.submission.submittedAt": { "defaultMessage": "Submitted At" }, - "course.assessment.submission.unknown": { - "defaultMessage": "Unknown status, please contact administrator" + "course.assessment.submission.suggestions.howDoIFixThis": { + "defaultMessage": "How do I fix this?" + }, + "course.assessment.submission.suggestions.iAmStuck": { + "defaultMessage": "I am stuck" + }, + "course.assessment.submission.suggestions.looksWrong": { + "defaultMessage": "This looks wrong" + }, + "course.assessment.submission.suggestions.optimizeThisCode": { + "defaultMessage": "Review my code" + }, + "course.assessment.submission.suggestions.questionUnclear": { + "defaultMessage": "Explain the question" + }, + "course.assessment.submission.suggestions.whereAmIWrong": { + "defaultMessage": "Where am I wrong?" + }, + "course.assessment.submission.timeIsUp": { + "defaultMessage": "Time is Up!" + }, + "course.assessment.submission.timedAssessmentDialogMessage": { + "defaultMessage": "{stillSomeTimeRemaining, select, true {Once the time is up, the assessment will be automatically finalised.} other {Finalising the submission now!}}" + }, + "course.assessment.submission.timedAssessmentDialogTitle": { + "defaultMessage": "{stillSomeTimeRemaining, select, true {{remainingTime} {isNewSubmission, select, true {} other {remaining}} to complete this assessment.} other {The assessment has ended!}}" + }, + "course.assessment.submission.timedExamDialogMessage": { + "defaultMessage": "{stillSomeTimeRemaining, select, true {Please do not sign out or close the browser while attempting this exam. Once the time is up, the assessment will be automatically finalised.} other {Finalising the submission now!}}" + }, + "course.assessment.submission.timedExamDialogTitle": { + "defaultMessage": "{stillSomeTimeRemaining, select, true {{remainingTime} {isNewSubmission, select, true {} other {remaining}} to complete this exam.} other {The exam has ended!}}" }, "course.assessment.submission.totalGrade": { "defaultMessage": "Total Grade" @@ -3885,6 +4547,9 @@ "course.assessment.submission.type": { "defaultMessage": "Type" }, + "course.assessment.submission.unknown": { + "defaultMessage": "Unknown status, please contact administrator" + }, "course.assessment.submission.unmark": { "defaultMessage": "Revert to Submitted" }, @@ -3898,7 +4563,7 @@ "defaultMessage": "Unsubmit Submission" }, "course.assessment.submission.unsubmitAllConfirmation": { - "defaultMessage": "Are you sure you want to UNSUBMIT the submissions for all {users}? All submissions will be unsubmitted and this will reset the submission time and permit the users to change their answers. NOTE THAT THIS ACTION IS IRREVERSIBLE" + "defaultMessage": "Are you sure you want to UNSUBMIT the submissions for all {users}? All submissions will be unsubmitted and this will reset the submission time and permit the users to change their submissions. NOTE THAT THIS ACTION IS IRREVERSIBLE" }, "course.assessment.submission.unsubmitAllSubmissionsJobPending": { "defaultMessage": "Please wait as the submissions are currently being unsubmitted." @@ -3915,6 +4580,9 @@ "course.assessment.submission.updateFailure": { "defaultMessage": "Submission update failed: {errors}" }, + "course.assessment.submission.updateIndividualSuccess": { + "defaultMessage": "Submission for {errors} updated successfully" + }, "course.assessment.submission.updateSuccess": { "defaultMessage": "Submission updated successfully." }, @@ -3951,9 +4619,6 @@ "course.assessment.submissions.SubmissionsIndex.header": { "defaultMessage": "Submissions" }, - "course.assessment.submission.SubmissionsIndex.publishAutoFeedback": { - "defaultMessage": "Publish Automated Programming Feedback ({count})" - }, "course.assessment.submissions.SubmissionsTable.gradeTooltip": { "defaultMessage": "These grades can't be seen by the student until they are published" }, @@ -3976,7 +4641,7 @@ "defaultMessage": "Submitted At" }, "course.assessment.submissions.SubmissionsTable.tableHeaderTitle": { - "defaultMessage": "Assessment" + "defaultMessage": "Title" }, "course.assessment.submissions.SubmissionsTable.tableHeaderTotalGrade": { "defaultMessage": "Grade" @@ -4035,6 +4700,18 @@ "course.assessments.index.hasTodo": { "defaultMessage": "Has TODO" }, + "course.assessments.index.inviteToKoditsu": { + "defaultMessage": "Invite users to Koditsu Exam" + }, + "course.assessments.index.invitingUserToKoditsu": { + "defaultMessage": "Inviting users to Koditsu Exam" + }, + "course.assessments.index.invitingUserToKoditsuFailure": { + "defaultMessage": "There is a problem in inviting users to Koditsu. Please try again later" + }, + "course.assessments.index.invitingUserToKoditsuSuccess": { + "defaultMessage": "Successful in inviting users to Koditsu Exam" + }, "course.assessments.index.neededFor": { "defaultMessage": "Needed for" }, @@ -4065,6 +4742,9 @@ "course.assessments.index.submittedCount": { "defaultMessage": "Submissions" }, + "course.assessments.index.timeLimitIcon": { + "defaultMessage": "Time Limit: {timeLimit, plural, one {# minute} other {# minutes}}" + }, "course.assessments.index.title": { "defaultMessage": "Title" }, @@ -4083,9 +4763,6 @@ "course.asssessment.submission.submitNoQuestionExplain": { "defaultMessage": "Mark as completed?" }, - "course.admin.NotificationSettings.component": { - "defaultMessage": "Component" - }, "course.componentTitles.course_achievements_component": { "defaultMessage": "Achievements" }, @@ -4110,6 +4787,9 @@ "course.componentTitles.course_forums_component": { "defaultMessage": "Forums" }, + "course.componentTitles.course_gradebook_component": { + "defaultMessage": "Gradebook" + }, "course.componentTitles.course_groups_component": { "defaultMessage": "Groups" }, @@ -4167,15 +4847,6 @@ "course.courses.CourseAnnouncements.announcementHeader": { "defaultMessage": "Latest announcements" }, - "course.courses.CourseSuspendedAlert.header": { - "defaultMessage": "This course is suspended. Instructors can still access it, but students cannot." - }, - "course.courses.CourseSuspendedAlert.canSuspendMessage": { - "defaultMessage": "You can unsuspend it from the {link} page." - }, - "course.courses.CourseSuspendedAlert.cannotSuspendMessage": { - "defaultMessage": "If you believe this is a mistake, contact a course manager or owner to have them unsuspend the course." - }, "course.courses.CourseDisplay.noCourse": { "defaultMessage": "There is no course yet..." }, @@ -4221,6 +4892,15 @@ "course.courses.CourseShow.instructorsHeader": { "defaultMessage": "Instructors" }, + "course.courses.CourseSuspendedAlert.canSuspendMessage": { + "defaultMessage": "You can unsuspend it from the {link} page." + }, + "course.courses.CourseSuspendedAlert.cannotSuspendMessage": { + "defaultMessage": "If you believe this is a mistake, contact a course manager or owner to have them unsuspend the course." + }, + "course.courses.CourseSuspendedAlert.header": { + "defaultMessage": "This course is suspended. Instructors can still access it, but students cannot." + }, "course.courses.CourseUserItem.differentCourseNameHint": { "defaultMessage": "You're seeing a name different from your account name because this course's manager invited you with this name." }, @@ -4336,7 +5016,7 @@ "defaultMessage": "Starts at" }, "course.courses.PendingTodosTable.tableHeaderTitle": { - "defaultMessage": "Title" + "defaultMessage": "Assessment" }, "course.courses.PendingTodosTable.tableSeeMore": { "defaultMessage": "See {n} more" @@ -4344,6 +5024,9 @@ "course.courses.Sidebar.administration": { "defaultMessage": "Administration" }, + "course.courses.Sidebar.joinCoursemologyMessage": { + "defaultMessage": "Create a Coursemology account or sign up to join this course." + }, "course.courses.SidebarItem.admin.duplication": { "defaultMessage": "Duplicate Data" }, @@ -4389,15 +5072,15 @@ "course.courses.SidebarItem.home": { "defaultMessage": "Home" }, + "course.courses.SidebarItem.scholaistic.assessments": { + "defaultMessage": "Role-Playing Assessments" + }, "course.courses.SidebarItem.stories.learn": { "defaultMessage": "Learn" }, "course.courses.SidebarItem.stories.missionControl": { "defaultMessage": "Mission Control" }, - "course.courses.SidebarItem.scholaistic.assessments": { - "defaultMessage": "Role-Playing Assessments" - }, "course.courses.TodoIgnoreButton.ignore.ignoreButtonText": { "defaultMessage": "Ignore" }, @@ -4440,6 +5123,12 @@ "course.discussion.topics.CommentCard.deleteSuccess": { "defaultMessage": "Successfully deleted comment." }, + "course.discussion.topics.CommentCard.isAiGenerated": { + "defaultMessage": "AI Generated Comment" + }, + "course.discussion.topics.CommentCard.publish": { + "defaultMessage": "Publish" + }, "course.discussion.topics.CommentCard.publishFailure": { "defaultMessage": "Failed to publish feedback." }, @@ -4461,12 +5150,6 @@ "course.discussion.topics.CommentCard.updateSuccess": { "defaultMessage": "Successfully updated comment." }, - "course.discussion.topics.CommentCard.publish": { - "defaultMessage": "Publish" - }, - "course.discussion.topics.CommentCard.isAiGenerated": { - "defaultMessage": "AI Generated Comment" - }, "course.discussion.topics.CommentField.comment": { "defaultMessage": "Comment" }, @@ -4534,7 +5217,7 @@ "defaultMessage": "Select current instance" }, "course.duplication.Duplication.DestinationCourseSelector.InstanceDropdown.destinationInstance": { - "defaultMessage": "Destination Instance" + "defaultMessage": "Destination instance" }, "course.duplication.Duplication.DestinationCourseSelector.NewCourseForm.newStartAt": { "defaultMessage": "New Start Date *" @@ -4638,15 +5321,15 @@ "course.duplication.Duplication.duplicateData": { "defaultMessage": "Duplicate Data" }, - "course.duplication.Duplication.fromCourse": { - "defaultMessage": "Duplicate Data from {courseTitle}" - }, "course.duplication.Duplication.duplicationDisabled": { "defaultMessage": "Duplication is disabled for this course." }, "course.duplication.Duplication.existingCourse": { "defaultMessage": "Existing Course" }, + "course.duplication.Duplication.fromCourse": { + "defaultMessage": "Duplicate data from {courseTitle}" + }, "course.duplication.Duplication.items": { "defaultMessage": "Selected Items" }, @@ -4740,21 +5423,6 @@ "course.enrolRequests.UserRequests.rejected": { "defaultMessage": "Rejected Enrolment Requests" }, - "course.experiencePoints.downloadCsvButton": { - "defaultMessage": "Download CSV" - }, - "course.experiencePoints.downloadFailure": { - "defaultMessage": "An error occurred while doing your request for download." - }, - "course.experiencePoints.downloadPending": { - "defaultMessage": "Please wait as your request to download is being processed." - }, - "course.experiencePoints.downloadRequestSuccess": { - "defaultMessage": "Your request to download is successful" - }, - "course.experiencePoints.filterByNameButton": { - "defaultMessage": "Filter by Name" - }, "course.experiencePoints.disbursement.DisbursementForm.createDisbursementFailure": { "defaultMessage": "Failed to award experience points." }, @@ -4786,7 +5454,7 @@ "defaultMessage": "Disburse Points" }, "course.experiencePoints.disbursement.DisbursementIndex.disbursements": { - "defaultMessage": "Disbursed Experience Points" + "defaultMessage": "Experience Points" }, "course.experiencePoints.disbursement.DisbursementIndex.experienceTab": { "defaultMessage": "History" @@ -4824,6 +5492,15 @@ "course.experiencePoints.disbursement.FilterForm.weeklyCap": { "defaultMessage": "Weekly Cap" }, + "course.experiencePoints.disbursement.ForumDisbursement.fetchDisbursementFailure": { + "defaultMessage": "Failed to retrieve data." + }, + "course.experiencePoints.disbursement.ForumDisbursement.fetchForumPostsFailure": { + "defaultMessage": "Failed to fetch forum posts." + }, + "course.experiencePoints.disbursement.ForumDisbursement.postListDialogHeader": { + "defaultMessage": "Posts created between {startDate} and {endDate} by" + }, "course.experiencePoints.disbursement.ForumDisbursementForm.createDisbursementFailure": { "defaultMessage": "Failed to award experience points." }, @@ -4833,9 +5510,6 @@ "course.experiencePoints.disbursement.ForumDisbursementForm.fetchForumPostsFailure": { "defaultMessage": "Failed to fetch forum posts." }, - "course.experiencePoints.disbursement.ForumDisbursementForm.postListDialogHeader": { - "defaultMessage": "Posts created between {startDate} and {endDate} by" - }, "course.experiencePoints.disbursement.ForumDisbursementForm.reason": { "defaultMessage": "Reason For Disbursement" }, @@ -4875,6 +5549,27 @@ "course.experiencePoints.disbursement.ForumPostTable.voteTally": { "defaultMessage": "Vote Tally" }, + "course.experiencePoints.disbursement.GeneralDisbursement.fetchDisbursementFailure": { + "defaultMessage": "Failed to retrieve data." + }, + "course.experiencePoints.downloadCsvButton": { + "defaultMessage": "Download CSV" + }, + "course.experiencePoints.downloadFailure": { + "defaultMessage": "An error occurred while doing your request for download." + }, + "course.experiencePoints.downloadPending": { + "defaultMessage": "Please wait as your request to download is being processed." + }, + "course.experiencePoints.downloadRequestSuccess": { + "defaultMessage": "Your request to download is successful" + }, + "course.experiencePoints.fetchRecordsFailure": { + "defaultMessage": "Failed to fetch records" + }, + "course.experiencePoints.filterByNameButton": { + "defaultMessage": "Filter by Name" + }, "course.forum.FormShow.fetchTopicsFailure": { "defaultMessage": "Failed to retrieve forum topic data." }, @@ -5112,48 +5807,57 @@ "course.forum.ForumsIndex.newForum": { "defaultMessage": "New Forum" }, + "course.forum.GenerateReplyButton.generateReply": { + "defaultMessage": "Generate reply" + }, + "course.forum.GenerateReplyButton.generateReplySuccess": { + "defaultMessage": "A reply has been successfully generated." + }, + "course.forum.GenerateReplyButton.generatingReply": { + "defaultMessage": "Generating reply" + }, "course.forum.HideButton.hide": { "defaultMessage": "Hide" }, - "course.forum.HideButton.hideTooltip": { - "defaultMessage": "Hide topic from students" - }, "course.forum.HideButton.hideFailure": { "defaultMessage": "Failed to hide the topic \"{title}\" - {error}" }, "course.forum.HideButton.hideSuccess": { "defaultMessage": "The topic \"{title}\" has successfully been hidden." }, + "course.forum.HideButton.hideTooltip": { + "defaultMessage": "Hide topic from students" + }, "course.forum.HideButton.unhide": { "defaultMessage": "Unhide" }, - "course.forum.HideButton.unhideTooltip": { - "defaultMessage": "Show topic to students" - }, "course.forum.HideButton.unhideFailure": { "defaultMessage": "Failed to unhide the topic \"{title}\" - {error}" }, "course.forum.HideButton.unhideSuccess": { "defaultMessage": "The topic \"{title}\" has successfully been unhidden." }, - "course.forum.LockButton.locked": { - "defaultMessage": "Lock" + "course.forum.HideButton.unhideTooltip": { + "defaultMessage": "Show topic to students" }, "course.forum.LockButton.lockTooltip": { "defaultMessage": "Lock to stop students from posting in this topic" }, + "course.forum.LockButton.locked": { + "defaultMessage": "Lock" + }, "course.forum.LockButton.lockedFailure": { "defaultMessage": "Failed to locked the topic \"{title}\" - {error}" }, "course.forum.LockButton.lockedSuccess": { "defaultMessage": "The topic \"{title}\" has successfully been locked." }, - "course.forum.LockButton.unlocked": { - "defaultMessage": "Unlock" - }, "course.forum.LockButton.unlockTooltip": { "defaultMessage": "Unlock to allow students to post within this topic" }, + "course.forum.LockButton.unlocked": { + "defaultMessage": "Unlock" + }, "course.forum.LockButton.unlockedFailure": { "defaultMessage": "Failed to unlocked the topic \"{title}\" - {error}" }, @@ -5172,6 +5876,12 @@ "course.forum.MarkAnswerButton.markAsAnswer": { "defaultMessage": "Mark as answer" }, + "course.forum.MarkAnswerButton.markAsAnswerAndPublish": { + "defaultMessage": "Mark as answer and publish" + }, + "course.forum.MarkAnswerButton.markAsAnswerAndPublishTooltip": { + "defaultMessage": "Mark as answer and publish for students to view" + }, "course.forum.MarkAnswerButton.markedAsAnswer": { "defaultMessage": "Marked as answer" }, @@ -5271,6 +5981,159 @@ "course.forum.forum.markAllAsReadFailed": { "defaultMessage": "Failed to mark all topics in this forum as read. Please try again later." }, + "course.forum.publishButton.generateReplyDisabledTooltip": { + "defaultMessage": "Disabled for generated reply" + }, + "course.forum.publishButton.generateReplySuccess": { + "defaultMessage": "Failed to generate a reply." + }, + "course.forum.publishButton.generateReplyTooltip": { + "defaultMessage": "Generate a draft reply using AI" + }, + "course.forum.publishButton.publish": { + "defaultMessage": "Publish" + }, + "course.forum.publishButton.publishFailure": { + "defaultMessage": "Failed to publish the post." + }, + "course.forum.publishButton.publishSuccess": { + "defaultMessage": "The post has succesfully been published." + }, + "course.forum.publishButton.publishTooltip": { + "defaultMessage": "Pusblish post to students" + }, + "course.gradebook.ConfigureWeightsDialog.cancel": { + "defaultMessage": "Cancel" + }, + "course.gradebook.ConfigureWeightsDialog.description": { + "defaultMessage": "Set how much each tab contributes to the total grade. Weights should sum to 100." + }, + "course.gradebook.ConfigureWeightsDialog.errorInteger": { + "defaultMessage": "Value must be a whole number" + }, + "course.gradebook.ConfigureWeightsDialog.errorMax": { + "defaultMessage": "Value must be at most 100" + }, + "course.gradebook.ConfigureWeightsDialog.errorMin": { + "defaultMessage": "Value must be at least 0" + }, + "course.gradebook.ConfigureWeightsDialog.save": { + "defaultMessage": "Save" + }, + "course.gradebook.ConfigureWeightsDialog.saveFailure": { + "defaultMessage": "Failed to save weights — try again." + }, + "course.gradebook.ConfigureWeightsDialog.saveSuccess": { + "defaultMessage": "Weights saved." + }, + "course.gradebook.ConfigureWeightsDialog.sumWarning": { + "defaultMessage": "Weights do not sum to 100. Saving is allowed; Total may be inaccurate." + }, + "course.gradebook.ConfigureWeightsDialog.title": { + "defaultMessage": "Configure tab weights" + }, + "course.gradebook.ConfigureWeightsDialog.totalLabel": { + "defaultMessage": "Total: {sum}%" + }, + "course.gradebook.GradebookColumnTree.alwaysIncluded": { + "defaultMessage": "Always included" + }, + "course.gradebook.GradebookColumnTree.email": { + "defaultMessage": "Email" + }, + "course.gradebook.GradebookColumnTree.gamification": { + "defaultMessage": "Gamification" + }, + "course.gradebook.GradebookColumnTree.grades": { + "defaultMessage": "Grades" + }, + "course.gradebook.GradebookColumnTree.level": { + "defaultMessage": "Level" + }, + "course.gradebook.GradebookColumnTree.name": { + "defaultMessage": "Name" + }, + "course.gradebook.GradebookColumnTree.studentInfo": { + "defaultMessage": "Student info" + }, + "course.gradebook.GradebookColumnTree.totalXp": { + "defaultMessage": "Total XP" + }, + "course.gradebook.GradebookIndex.allAssessments": { + "defaultMessage": "All assessments" + }, + "course.gradebook.GradebookIndex.applyAndExport": { + "defaultMessage": "Apply and Export" + }, + "course.gradebook.GradebookIndex.byWeight": { + "defaultMessage": "By weight" + }, + "course.gradebook.GradebookIndex.dialogTitle": { + "defaultMessage": "Select columns" + }, + "course.gradebook.GradebookIndex.exportAllTooltip": { + "defaultMessage": "No rows selected - all rows will be exported." + }, + "course.gradebook.GradebookIndex.exportButton": { + "defaultMessage": "Export all rows" + }, + "course.gradebook.GradebookIndex.exportRows": { + "defaultMessage": "Export {count, plural, one {# row} other {# rows}}" + }, + "course.gradebook.GradebookIndex.fetchFailure": { + "defaultMessage": "Failed to retrieve Gradebook." + }, + "course.gradebook.GradebookIndex.gradebook": { + "defaultMessage": "Gradebook" + }, + "course.gradebook.GradebookIndex.noStudents": { + "defaultMessage": "No students enrolled yet" + }, + "course.gradebook.GradebookIndex.noStudentsHint": { + "defaultMessage": "Grades will appear here once students join the course." + }, + "course.gradebook.GradebookIndex.searchStudents": { + "defaultMessage": "Search by name or email" + }, + "course.gradebook.GradebookIndex.selectColumns": { + "defaultMessage": "Select Columns" + }, + "course.gradebook.GradebookTable.maxMarks": { + "defaultMessage": "Max Marks" + }, + "course.gradebook.GradebookTable.noDataColumnsHint": { + "defaultMessage": "No grade columns selected - export will include student info only." + }, + "course.gradebook.GradebookTable.noDataColumnsHintWithGamification": { + "defaultMessage": "No grade or gamification columns selected - export will include student info only." + }, + "course.gradebook.GradebookWeightedTable.configure": { + "defaultMessage": "Configure Weights" + }, + "course.gradebook.GradebookWeightedTable.emptyStateBody": { + "defaultMessage": "Click Configure Weights to start." + }, + "course.gradebook.GradebookWeightedTable.emptyStateTitle": { + "defaultMessage": "No tab weights configured." + }, + "course.gradebook.GradebookWeightedTable.name": { + "defaultMessage": "Name" + }, + "course.gradebook.GradebookWeightedTable.sumWarningTooltip": { + "defaultMessage": "Tab weights sum to {sum}%. Configure Weights to fix." + }, + "course.gradebook.GradebookWeightedTable.total": { + "defaultMessage": "Total" + }, + "course.gradebook.GradebookWeightedTable.totalSubheader": { + "defaultMessage": "{sum}% total" + }, + "course.gradebook.GradebookWeightedTable.treatUngradedAsZero": { + "defaultMessage": "Treat Ungraded as 0" + }, + "course.gradebook.GradebookWeightedTable.weightSubheader": { + "defaultMessage": "{weight}% of grade" + }, "course.group.GroupCreationForm.description": { "defaultMessage": "Description (Optional)" }, @@ -5547,27 +6410,27 @@ "course.leaderboard.LeaderboardTable.average": { "defaultMessage": "Average" }, - "course.leaderboard.LeaderboardTable.experience": { - "defaultMessage": "Experience" + "course.leaderboard.LeaderboardTable.averageAchievements": { + "defaultMessage": "Average Achievements" }, - "course.leaderboard.LeaderboardTable.rank": { - "defaultMessage": "Rank" + "course.leaderboard.LeaderboardTable.averageExperience": { + "defaultMessage": "Average Experience" }, - "course.leaderboard.LeaderboardTable.name": { - "defaultMessage": "Name" + "course.leaderboard.LeaderboardTable.experience": { + "defaultMessage": "Experience" }, "course.leaderboard.LeaderboardTable.level": { "defaultMessage": "Level" }, - "course.leaderboard.LeaderboardTable.averageExperience": { - "defaultMessage": "Average Experience" - }, - "course.leaderboard.LeaderboardTable.averageAchievements": { - "defaultMessage": "Average Achievements" - }, "course.leaderboard.LeaderboardTable.members": { "defaultMessage": "Members" }, + "course.leaderboard.LeaderboardTable.name": { + "defaultMessage": "Name" + }, + "course.leaderboard.LeaderboardTable.rank": { + "defaultMessage": "Rank" + }, "course.leaderboard.LeaderboardTable.titleAchievements": { "defaultMessage": "By Achievements" }, @@ -5736,20 +6599,56 @@ "course.level.Level.levelHeader": { "defaultMessage": "Levels" }, - "course.level.Level.saveFailure": { - "defaultMessage": "Level saving failed, please try again." + "course.level.Level.orderedIncorrectly": { + "defaultMessage": "Levels will be sorted automatically when saved regardless of their order here." + }, + "course.level.Level.placeholder": { + "defaultMessage": "0" + }, + "course.level.Level.reset": { + "defaultMessage": "Reset" + }, + "course.level.Level.resetTooltip": { + "defaultMessage": "Reset changes" + }, + "course.level.Level.saveChanges": { + "defaultMessage": "Save" }, - "course.level.Level.saveLevels": { - "defaultMessage": "Save Levels" + "course.level.Level.saveFailure": { + "defaultMessage": "Failed to save levels" }, "course.level.Level.saveSuccess": { "defaultMessage": "Levels Saved" }, "course.level.Level.thresholdHeader": { - "defaultMessage": "Threshold" + "defaultMessage": "EXP Threshold" + }, + "course.level.Level.unsavedChanges": { + "defaultMessage": "You have unsaved changes" + }, + "course.material.files.DownloadingFilePage.clickToDownloadFile": { + "defaultMessage": "Download {name}" + }, + "course.material.files.DownloadingFilePage.clickToDownloadFileDescription": { + "defaultMessage": "Something happened when initiating an automatic download. Click the link below to immediately download the file." + }, + "course.material.files.DownloadingFilePage.downloading": { + "defaultMessage": "Downloading {name}" + }, + "course.material.files.DownloadingFilePage.downloadingDescription": { + "defaultMessage": "This file should start downloading automatically now. If it doesn't, you can try again by clicking the link below or refreshing this page." + }, + "course.material.files.DownloadingFilePage.tryDownloadingAgain": { + "defaultMessage": "Try downloading again" + }, + "course.material.files.ErrorRetrievingFilePage.goToTheWorkbin": { + "defaultMessage": "Go to the Workbin" }, - "course.level.LevelRow.zeroThresholdError": { - "defaultMessage": "Experience points threshold cannot be 0" + "course.material.files.ErrorRetrievingFilePage.problemRetrievingFile": { + "defaultMessage": "Problem retrieving file" + }, + "course.material.files.ErrorRetrievingFilePage.problemRetrievingFileDescription": { + "defaultMessage": "Either it no longer exists, you don't have the permission to access it, or something unexpected happened when we were trying to retrieve it." }, "course.material.folders.DownloadFolderButton.downloadFolderErrorMessage": { "defaultMessage": "Download has failed. Please try again later." @@ -5760,6 +6659,15 @@ "course.material.folders.DownloadFolderButton.downloading": { "defaultMessage": "Downloading..." }, + "course.material.folders.ErrorRetrievingFolderPage.goToMainFolder": { + "defaultMessage": "Go to the main folder" + }, + "course.material.folders.ErrorRetrievingFolderPage.problemRetrievingFolder": { + "defaultMessage": "Problem retrieving folder" + }, + "course.material.folders.ErrorRetrievingFolderPage.problemRetrievingFolderDescription": { + "defaultMessage": "Either it no longer exists, you don't have the permission to access it, or something unexpected happened when we were trying to retrieve it." + }, "course.material.folders.FolderEdit.editSubfolderTitle": { "defaultMessage": "Edit Folder" }, @@ -5799,6 +6707,12 @@ "course.material.folders.FolderShow.defaultHeader": { "defaultMessage": "Materials" }, + "course.material.folders.FolderShow.error": { + "defaultMessage": "(Error)" + }, + "course.material.folders.FolderShow.folderNotFound": { + "defaultMessage": "Folder not found" + }, "course.material.folders.MaterialEdit.editMaterialTitle": { "defaultMessage": "Edit Material" }, @@ -5844,41 +6758,71 @@ "course.material.folders.UploadFilesButton.uploadFilesTooltip": { "defaultMessage": "Upload" }, + "course.material.folders.WorkbinTable.lastModified": { + "defaultMessage": "Last Modified" + }, + "course.material.folders.WorkbinTable.name": { + "defaultMessage": "Name" + }, + "course.material.folders.WorkbinTable.startAt": { + "defaultMessage": "Start At" + }, "course.material.folders.WorkbinTableButtons.DeletionFailure": { "defaultMessage": "could not be deleted" }, + "course.material.folders.WorkbinTableButtons.addFailure": { + "defaultMessage": "{material} could not be added to knowledge base" + }, "course.material.folders.WorkbinTableButtons.deleteConfirmation": { "defaultMessage": "Are you sure you want to delete" }, "course.material.folders.WorkbinTableButtons.deletionSuccess": { "defaultMessage": "has been deleted" }, + "course.material.folders.WorkbinTableButtons.removeFailure": { + "defaultMessage": "{material} could not be removed from knowledge base" + }, + "course.material.folders.WorkbinTableButtons.removeSuccess": { + "defaultMessage": "{material} has been removed from knowledge base" + }, "course.material.folders.WorkbinTableButtons.tableButtonDeleteTooltip": { "defaultMessage": "Delete" }, - "course.material.folders.WorkbinTable.name": { - "defaultMessage": "Name" + "course.plagiarism.PlagiarismIndex.assessments.AssessmentLinkDialog.linkAssessments": { + "defaultMessage": "Link Assessments" }, - "course.material.folders.WorkbinTable.lastModified": { - "defaultMessage": "Last Modified" + "course.plagiarism.PlagiarismIndex.assessments.AssessmentLinkDialog.linkedAssessments": { + "defaultMessage": "Linked Assessments" }, - "course.material.folders.WorkbinTable.startAt": { - "defaultMessage": "Start At" + "course.plagiarism.PlagiarismIndex.assessments.AssessmentLinkDialog.searchPlaceholder": { + "defaultMessage": "Search by Assessment Title" }, - "course.plagiarism.PlagiarismIndex.header.plagiarism": { - "defaultMessage": "Plagiarism Check" + "course.plagiarism.PlagiarismIndex.assessments.AssessmentLinkDialog.unlinkedAssessments": { + "defaultMessage": "Available Assessments" }, - "course.plagiarism.PlagiarismIndex.assessments.assessment": { - "defaultMessage": "Assessments" + "course.plagiarism.PlagiarismIndex.assessments.AssessmentLinkDialog.updateLinksFailure": { + "defaultMessage": "Failed to update assessment links" }, - "course.plagiarism.PlagiarismIndex.assessments.numSubmitted": { - "defaultMessage": "# Submissions" + "course.plagiarism.PlagiarismIndex.assessments.AssessmentLinkDialog.updateLinksSuccess": { + "defaultMessage": "Assessment links updated successfully" }, - "course.plagiarism.PlagiarismIndex.assessments.numCheckableQuestions": { - "defaultMessage": "# Checkable Questions" + "course.plagiarism.PlagiarismIndex.assessments.AssessmentLinkList.cannotManage": { + "defaultMessage": "You do not have permission to manage this assessment." }, - "course.plagiarism.PlagiarismIndex.assessments.lastSubmittedAt": { - "defaultMessage": "Last Submission At" + "course.plagiarism.PlagiarismIndex.assessments.AssessmentLinkList.noAssessmentsFound": { + "defaultMessage": "No assessments found" + }, + "course.plagiarism.PlagiarismIndex.assessments.actions": { + "defaultMessage": "Actions" + }, + "course.plagiarism.PlagiarismIndex.assessments.assessment": { + "defaultMessage": "Assessment" + }, + "course.plagiarism.PlagiarismIndex.assessments.confirmRerunMessage": { + "defaultMessage": "Some of the selected assessments already have completed plagiarism checks. Running a new plagiarism check will remove the previous results." + }, + "course.plagiarism.PlagiarismIndex.assessments.confirmRerunTitle": { + "defaultMessage": "Confirm Plagiarism Check?" }, "course.plagiarism.PlagiarismIndex.assessments.lastRunStatus": { "defaultMessage": "Status" @@ -5886,71 +6830,125 @@ "course.plagiarism.PlagiarismIndex.assessments.lastRunTime": { "defaultMessage": "Last Run At" }, - "course.plagiarism.PlagiarismIndex.assessments.statusNotStarted": { - "defaultMessage": "Not Started" + "course.plagiarism.PlagiarismIndex.assessments.lastSubmittedAt": { + "defaultMessage": "Last Submission At" }, - "course.plagiarism.PlagiarismIndex.assessments.statusRunning": { - "defaultMessage": "Running" + "course.plagiarism.PlagiarismIndex.assessments.linkAssessments": { + "defaultMessage": "Link Assessments" }, - "course.plagiarism.PlagiarismIndex.assessments.statusCompleted": { - "defaultMessage": "Completed" + "course.plagiarism.PlagiarismIndex.assessments.newSubmissionsWarning": { + "defaultMessage": "New submissions detected since last plagiarism run" }, - "course.plagiarism.PlagiarismIndex.assessments.statusFailed": { - "defaultMessage": "Failed" + "course.plagiarism.PlagiarismIndex.assessments.noNewSubmissionsWarning": { + "defaultMessage": "No new submissions since last plagiarism run" }, "course.plagiarism.PlagiarismIndex.assessments.noPlagiarismCheckableQuestions": { "defaultMessage": "No checkable questions" }, - "course.plagiarism.PlagiarismIndex.assessments.notEnoughSubmissions": { + "course.plagiarism.PlagiarismIndex.assessments.notEnoughSubmissions": { "defaultMessage": "Not enough submissions" }, + "course.plagiarism.PlagiarismIndex.assessments.numCheckableQuestions": { + "defaultMessage": "# Checkable Questions" + }, + "course.plagiarism.PlagiarismIndex.assessments.numSubmitted": { + "defaultMessage": "# Submissions" + }, "course.plagiarism.PlagiarismIndex.assessments.runAssessmentsPlagiarism": { "defaultMessage": "New Plagiarism Check ({count})" }, - "course.plagiarism.PlagiarismIndex.assessments.runPlagiarismCheckSuccess": { - "defaultMessage": "Started plagiarism check for {count, plural, =1 {# assessment} other {# assessments}}" + "course.plagiarism.PlagiarismIndex.assessments.runPlagiarismCheck": { + "defaultMessage": "Run Plagiarism Check" }, "course.plagiarism.PlagiarismIndex.assessments.runPlagiarismCheckError": { "defaultMessage": "Failed to start plagiarism checks for some assessments" }, + "course.plagiarism.PlagiarismIndex.assessments.runPlagiarismCheckSuccess": { + "defaultMessage": "Started plagiarism check for {count, plural, =1 {# assessment} other {# assessments}}" + }, "course.plagiarism.PlagiarismIndex.assessments.searchByAssessmentTitle": { "defaultMessage": "Search by Assessment Title" }, - "course.plagiarism.PlagiarismIndex.assessments.actions": { - "defaultMessage": "Actions" + "course.plagiarism.PlagiarismIndex.assessments.statusCompleted": { + "defaultMessage": "Completed" }, - "course.plagiarism.PlagiarismIndex.assessments.runPlagiarismCheck": { - "defaultMessage": "Run Plagiarism Check" + "course.plagiarism.PlagiarismIndex.assessments.statusFailed": { + "defaultMessage": "Failed" + }, + "course.plagiarism.PlagiarismIndex.assessments.statusNotStarted": { + "defaultMessage": "Not Started" + }, + "course.plagiarism.PlagiarismIndex.assessments.statusRunning": { + "defaultMessage": "Running" + }, + "course.plagiarism.PlagiarismIndex.assessments.statusStarting": { + "defaultMessage": "Starting" }, "course.plagiarism.PlagiarismIndex.assessments.viewResults": { "defaultMessage": "View Results" }, - "course.plagiarism.PlagiarismIndex.assessments.newSubmissionsWarning": { - "defaultMessage": "New submissions detected since last plagiarism run" + "course.plagiarism.PlagiarismIndex.header.plagiarism": { + "defaultMessage": "Plagiarism Check" }, - "course.plagiarism.PlagiarismIndex.assessments.noNewSubmissionsWarning": { - "defaultMessage": "No new submissions since last plagiarism run" + "course.statistics.StatisticsIndex.assessments.averageGrade": { + "defaultMessage": "Avg Grade" }, - "course.plagiarism.PlagiarismIndex.assessments.confirmRerunTitle": { - "defaultMessage": "Confirm Plagiarism Check?" + "course.statistics.StatisticsIndex.assessments.averageTimeTaken": { + "defaultMessage": "Avg Time" }, - "course.plagiarism.PlagiarismIndex.assessments.confirmRerunMessage": { - "defaultMessage": "Some of the selected assessments already have completed plagiarism checks. Running a new plagiarism check will remove the previous results." + "course.statistics.StatisticsIndex.assessments.category": { + "defaultMessage": "Category" }, - "course.statistics.StatisticsIndex.course.StudentPerformanceTable.achievementCount": { - "defaultMessage": "No. of Achievements (Total: {courseAchievementCount})" + "course.statistics.StatisticsIndex.assessments.csvFileTitle": { + "defaultMessage": "Assessments Statistics" }, - "course.statistics.StatisticsIndex.course.StudentPerformanceTable.ascending": { - "defaultMessage": "Ascending" + "course.statistics.StatisticsIndex.assessments.downloadCsv": { + "defaultMessage": "Download Score Summary for the following Assessments?" }, - "course.statistics.StatisticsIndex.course.StudentPerformanceTable.correctness": { - "defaultMessage": "Correctness" + "course.statistics.StatisticsIndex.assessments.downloadScoreSummaryFailure": { + "defaultMessage": "An error occurred while downloading score summary" + }, + "course.statistics.StatisticsIndex.assessments.downloadScoreSummaryPending": { + "defaultMessage": "Please wait as your request to download is being processed" + }, + "course.statistics.StatisticsIndex.assessments.downloadScoreSummarySuccess": { + "defaultMessage": "Successfully downloaded score summary" + }, + "course.statistics.StatisticsIndex.assessments.numLateStudents": { + "defaultMessage": "# Late" + }, + "course.statistics.StatisticsIndex.assessments.numSubmittedStudents": { + "defaultMessage": "# Attempted" + }, + "course.statistics.StatisticsIndex.assessments.searchBar": { + "defaultMessage": "Search by Assessment Title, Tab, or Category" + }, + "course.statistics.StatisticsIndex.assessments.selectedNUsers": { + "defaultMessage": "Download Score Summary ({n, plural, =1 {# assessment} other {# assessments}})" + }, + "course.statistics.StatisticsIndex.assessments.startAt": { + "defaultMessage": "Starts At" + }, + "course.statistics.StatisticsIndex.assessments.stdevGrade": { + "defaultMessage": "Stdev Grade" }, - "course.statistics.StatisticsIndex.course.StudentPerformanceTable.correctnessHint": { - "defaultMessage": "Correctness is the average grade percentage of all graded assessments by a student." + "course.statistics.StatisticsIndex.assessments.stdevTimeTaken": { + "defaultMessage": "Stdev Time" + }, + "course.statistics.StatisticsIndex.assessments.tab": { + "defaultMessage": "Tab" + }, + "course.statistics.StatisticsIndex.assessments.tableTitle": { + "defaultMessage": "Assessments Statistics ({numStudents} students)" }, - "course.statistics.StatisticsIndex.course.StudentPerformanceTable.descending": { - "defaultMessage": "Descending" + "course.statistics.StatisticsIndex.assessments.title": { + "defaultMessage": "Title" + }, + "course.statistics.StatisticsIndex.course.StudentPerformanceTable.achievementCountDetails": { + "defaultMessage": "No. of Achievements (Total: {courseAchievementCount})" + }, + "course.statistics.StatisticsIndex.course.StudentPerformanceTable.correctness": { + "defaultMessage": "Correctness" }, "course.statistics.StatisticsIndex.course.StudentPerformanceTable.experiencePoints": { "defaultMessage": "Experience Points" @@ -5959,32 +6957,23 @@ "defaultMessage": "Tutors" }, "course.statistics.StatisticsIndex.course.StudentPerformanceTable.highlight": { - "defaultMessage": "Highlight top and bottom {percent}%" + "defaultMessage": "Highlight top and bottom {percent}% based on {criteria}" }, "course.statistics.StatisticsIndex.course.StudentPerformanceTable.learningRate": { "defaultMessage": "Learning Rate" }, - "course.statistics.StatisticsIndex.course.StudentPerformanceTable.learningRateHint": { - "defaultMessage": "A learning rate of 200% means that they can complete the course in half the time." - }, - "course.statistics.StatisticsIndex.course.StudentPerformanceTable.level": { + "course.statistics.StatisticsIndex.course.StudentPerformanceTable.levelInfo": { "defaultMessage": "Level (Max: {maxLevel})" }, - "course.statistics.StatisticsIndex.course.StudentPerformanceTable.levelFilter": { - "defaultMessage": "Level: {name}" - }, "course.statistics.StatisticsIndex.course.StudentPerformanceTable.name": { "defaultMessage": "Name" }, "course.statistics.StatisticsIndex.course.StudentPerformanceTable.noData": { "defaultMessage": "No Data" }, - "course.statistics.StatisticsIndex.course.StudentPerformanceTable.numSubmissions": { + "course.statistics.StatisticsIndex.course.StudentPerformanceTable.numSubmissionsDetails": { "defaultMessage": "No. of Submissions (Total: {courseAssessmentCount})" }, - "course.statistics.StatisticsIndex.course.StudentPerformanceTable.phantom": { - "defaultMessage": "Include phantom users" - }, "course.statistics.StatisticsIndex.course.StudentPerformanceTable.studentType": { "defaultMessage": "Student Type" }, @@ -5994,27 +6983,15 @@ "course.statistics.StatisticsIndex.course.StudentPerformanceTable.studentType.phantom": { "defaultMessage": "Phantom" }, - "course.statistics.StatisticsIndex.course.StudentPerformanceTable.tableTitle": { - "defaultMessage": "Students Sorted in {direction} {column}" - }, "course.statistics.StatisticsIndex.course.StudentPerformanceTable.title": { "defaultMessage": "Student Performance" }, - "course.statistics.StatisticsIndex.course.StudentPerformanceTable.tutorFilter": { - "defaultMessage": "Tutor: {name}" - }, - "course.statistics.StatisticsIndex.course.StudentPerformanceTable.videoPercentWatched": { - "defaultMessage": "Video % Count" - }, "course.statistics.StatisticsIndex.course.StudentPerformanceTable.videoPercentWatchedHeader": { "defaultMessage": "Average Video % Watched" }, "course.statistics.StatisticsIndex.course.StudentPerformanceTable.videoSubmissionCountHeader": { "defaultMessage": "Videos Watched (Total: {courseVideoCount})" }, - "course.statistics.StatisticsIndex.course.searchBar": { - "defaultMessage": "Search by Student Name" - }, "course.statistics.StatisticsIndex.course.StudentProgressionChart.deadlines": { "defaultMessage": "Deadlines" }, @@ -6042,86 +7019,23 @@ "course.statistics.StatisticsIndex.course.StudentProgressionChart.xAxisLabel": { "defaultMessage": "Date" }, - "course.statistics.StatisticsIndex.course.StudentProgressionChart.yAxisLabel": { - "defaultMessage": "Assessment (Sorted by Deadline)" - }, - "course.statistics.StatisticsIndex.course.error": { - "defaultMessage": "Something went wrong when fetching course statistics! Please refresh to try again." - }, - "course.statistics.StatisticsIndex.course.performanceError": { - "defaultMessage": "Something went wrong when fetching course performance statistics! Please refresh to try again." - }, - "course.statistics.StatisticsIndex.course.progressionError": { - "defaultMessage": "Something went wrong when fetching course progression statistics! Please refresh to try again." - }, - "course.statistics.StatisticsIndex.header.statistics": { - "defaultMessage": "Statistics" - }, - "course.statistics.StatisticsIndex.assessments.title": { - "defaultMessage": "Title" - }, - "course.statistics.StatisticsIndex.assessments.startAt": { - "defaultMessage": "Starts At" - }, - "course.statistics.StatisticsIndex.assessments.tab": { - "defaultMessage": "Tab" - }, - "course.statistics.StatisticsIndex.assessments.category": { - "defaultMessage": "Category" - }, - "course.statistics.StatisticsIndex.assessments.numSubmittedStudents": { - "defaultMessage": "# Submitted" - }, - "course.statistics.StatisticsIndex.assessments.numAttemptedStudents": { - "defaultMessage": "# Attempted" - }, - "course.statistics.StatisticsIndex.assessments.numLateStudents": { - "defaultMessage": "# Late" - }, - "course.statistics.StatisticsIndex.assessments.averageGrade": { - "defaultMessage": "Avg Grade" - }, - "course.statistics.StatisticsIndex.assessments.stdevGrade": { - "defaultMessage": "Stdev Grade" - }, - "course.statistics.StatisticsIndex.assessments.averageTimeTaken": { - "defaultMessage": "Avg Time" - }, - "course.statistics.StatisticsIndex.assessments.stdevTimeTaken": { - "defaultMessage": "Stdev Time" - }, - "course.statistics.StatisticsIndex.assessments.tableTitle": { - "defaultMessage": "Assessments Statistics ({numStudents} students)" - }, - "course.statistics.StatisticsIndex.assessments.csvFileTitle": { - "defaultMessage": "Assessments Statistics" - }, - "course.statistics.StatisticsIndex.assessments.searchBar": { - "defaultMessage": "Search by Assessment Title, Tab, or Category" - }, - "course.statistics.StatisticsIndex.assessments.selectedNUsers": { - "defaultMessage": "Download Score Summary for {numUsers} students?" - }, - "course.statistics.StatisticsIndex.assessments.downloadCsv": { - "defaultMessage": "Download" - }, - "course.statistics.StatisticsIndex.assessments.downloadScoreSummary": { - "defaultMessage": "Download Score Summary for the following Assessments?" + "course.statistics.StatisticsIndex.course.StudentProgressionChart.yAxisLabel": { + "defaultMessage": "Assessment (Sorted by Deadline)" }, - "course.statistics.StatisticsIndex.assessments.downloadScoreSummarySuccess": { - "defaultMessage": "Successfully downloaded score summary" + "course.statistics.StatisticsIndex.course.csvFileTitle": { + "defaultMessage": "Student Performance Statistics" }, - "course.statistics.StatisticsIndex.assessments.downloadScoreSummaryFailure": { - "defaultMessage": "An error occurred while downloading score summary" + "course.statistics.StatisticsIndex.course.searchBar": { + "defaultMessage": "Search by Student Name" }, - "course.statistics.StatisticsIndex.assessments.downloadScoreSummaryPending": { - "defaultMessage": "Your download is being processed. Please wait." + "course.statistics.StatisticsIndex.header.statistics": { + "defaultMessage": "Statistics" }, "course.statistics.StatisticsIndex.staff.averageMarkingTime": { "defaultMessage": "Avg Time / Assessment" }, - "course.statistics.StatisticsIndex.staff.error": { - "defaultMessage": "Something went wrong when fetching staff statistics! Please refresh to try again." + "course.statistics.StatisticsIndex.staff.csvFileTitle": { + "defaultMessage": "Staff Statistics" }, "course.statistics.StatisticsIndex.staff.name": { "defaultMessage": "Name" @@ -6132,23 +7046,20 @@ "course.statistics.StatisticsIndex.staff.numStudents": { "defaultMessage": "# Students" }, + "course.statistics.StatisticsIndex.staff.searchBar": { + "defaultMessage": "Search by Staff Name" + }, "course.statistics.StatisticsIndex.staff.stddev": { - "defaultMessage": "Standard Deviation" + "defaultMessage": "Stdev Time / Assessment" }, "course.statistics.StatisticsIndex.staff.tableTitle": { "defaultMessage": "Staff Statistics" }, - "course.statistics.StatisticsIndex.staff.csvFileTitle": { - "defaultMessage": "Staff Statistics" - }, - "course.statistics.StatisticsIndex.staff.searchBar": { - "defaultMessage": "Search by Staff Name" - }, - "course.statistics.StatisticsIndex.staffFailure": { - "defaultMessage": "Failed to fetch staff data!" + "course.statistics.StatisticsIndex.students.csvFileTitle": { + "defaultMessage": "Student Statistics" }, - "course.statistics.StatisticsIndex.students.error": { - "defaultMessage": "Something went wrong when fetching student statistics! Please refresh to try again." + "course.statistics.StatisticsIndex.students.email": { + "defaultMessage": "Email" }, "course.statistics.StatisticsIndex.students.experiencePoints": { "defaultMessage": "Experience Points" @@ -6162,14 +7073,8 @@ "course.statistics.StatisticsIndex.students.name": { "defaultMessage": "Name" }, - "course.statistics.StatisticsIndex.students.email": { - "defaultMessage": "Email" - }, - "course.statistics.StatisticsIndex.students.noStudents": { - "defaultMessage": "There is no student in this course, yet..." - }, - "course.statistics.StatisticsIndex.students.showMyStudentsOnly": { - "defaultMessage": "Show My Students Only" + "course.statistics.StatisticsIndex.students.searchBar": { + "defaultMessage": "Search by Student Name or Student Type" }, "course.statistics.StatisticsIndex.students.studentsType": { "defaultMessage": "Student Type" @@ -6177,21 +7082,12 @@ "course.statistics.StatisticsIndex.students.tableTitle": { "defaultMessage": "Student Statistics ({numStudents} students, {numPhantom} phantom)" }, - "course.statistics.StatisticsIndex.students.tutorFilter": { - "defaultMessage": "Tutor: {name}" - }, "course.statistics.StatisticsIndex.students.videoPercentWatched": { "defaultMessage": "Average % Watched" }, "course.statistics.StatisticsIndex.students.videoSubmissionCount": { "defaultMessage": "Videos Watched (Total: {courseVideoCount})" }, - "course.statistics.StatisticsIndex.students.csvFileTitle": { - "defaultMessage": "Student Statistics" - }, - "course.statistics.StatisticsIndex.students.searchBar": { - "defaultMessage": "Search by Student Name or Student Type" - }, "course.statistics.StatisticsIndex.studentsFailure": { "defaultMessage": "Failed to fetch student data!" }, @@ -6216,12 +7112,6 @@ "course.statistics.course.studentProgressionChart.startAt": { "defaultMessage": "Starts at: {startAt}" }, - "course.statistics.failures.coursePerformance": { - "defaultMessage": "Failed to fetch course performance data!" - }, - "course.statistics.failures.courseProgression": { - "defaultMessage": "Failed to fetch course progression data!" - }, "course.statistics.tabs.course": { "defaultMessage": "Course" }, @@ -6231,6 +7121,15 @@ "course.statistics.tabs.courseProgression": { "defaultMessage": "Course Progression" }, + "course.stories.CikgoErrorPage.errorFetching": { + "defaultMessage": "Either it's supposed to be naught, or something went wrong." + }, + "course.stories.CikgoErrorPage.errorFetchingDescription": { + "defaultMessage": "Cikgo is our partner that powers this experience. They were contactable, but did not give us any resources for this request just now. Please try again later, and if this persists, contact us." + }, + "course.stories.pages.MissionControlPage": { + "defaultMessage": "Mission Control" + }, "course.survey.DeleteSectionButton.deleteSection": { "defaultMessage": "Delete Section" }, @@ -6718,7 +7617,7 @@ "defaultMessage": "Revert and delete timeline and its times" }, "course.timelines.defaultTimeline": { - "defaultMessage": "Default" + "defaultMessage": "Default Timeline" }, "course.timelines.deleteTime": { "defaultMessage": "Delete time" @@ -6876,6 +7775,9 @@ "course.userInvitation.InviteUsersRegistrationCode.registrationCodeNote": { "defaultMessage": "Users who have been invited and use this invitation code to register for the course would not have the proper status reflected in the Invitations page." }, + "course.userInvitations.IndividualInvitations.addRowsByEmail": { + "defaultMessage": "Add Rows by Email" + }, "course.userInvitations.IndividualInvitations.appendNewRow": { "defaultMessage": "Add Row" }, @@ -6885,12 +7787,39 @@ "course.userInvitations.IndividualInvitations.invite": { "defaultMessage": "Invite All Users" }, + "course.userInvitations.IndividualInvitations.malformedEmail": { + "defaultMessage": "{n, plural, one {This email is } other {These emails are }} wrongly formatted: {emails}" + }, + "course.userInvitations.IndividualInvitations.nameEmailInput": { + "defaultMessage": "John Doe '; \"Doe, Jane\" '; ..." + }, "course.userInvitations.IndividualInvitations.namePlaceholder": { "defaultMessage": "Awesome User" }, "course.userInvitations.IndividualInvitations.removeInvitation": { "defaultMessage": "Remove Invitation" }, + "course.userInvitations.InvitationActionButtons.deletionConfirm": { + "defaultMessage": "Are you sure you wish to delete invitation to {name} ({email})?" + }, + "course.userInvitations.InvitationActionButtons.deletionFailure": { + "defaultMessage": "Failed to delete user - {error}" + }, + "course.userInvitations.InvitationActionButtons.deletionSuccess": { + "defaultMessage": "Invitation for {name} was deleted." + }, + "course.userInvitations.InvitationActionButtons.deletionTooltip": { + "defaultMessage": "Delete Invitation" + }, + "course.userInvitations.InvitationActionButtons.resendFailure": { + "defaultMessage": "Failed to resend invitation - {error}" + }, + "course.userInvitations.InvitationActionButtons.resendSuccess": { + "defaultMessage": "Resent email invitation to {email}!" + }, + "course.userInvitations.InvitationActionButtons.resendTooltip": { + "defaultMessage": "Resend Invitation" + }, "course.userInvitations.InvitationResultDialog.body": { "defaultMessage": "{newInvitationsCount, plural, =0 {No new users were} one {# new user has been} other {# new users have been}} invited to Coursemology. {newCourseUsersCount, plural, =0 {No user with Coursemology account has been} one {# new user with existing Coursemology account has been} other {# new users with existing Coursemology accounts have been}} added to this course." }, @@ -6924,9 +7853,6 @@ "course.userInvitations.InvitationResultDialog.newInvitations": { "defaultMessage": "New Invitations ({count})" }, - "course.userInvitations.InvitationsBarChart.accepted": { - "defaultMessage": "Accepted Invitations" - }, "course.userInvitations.InvitationsIndex.failure": { "defaultMessage": "Failed to fetch all invitations" }, @@ -6984,27 +7910,6 @@ "course.userInvitations.InviteUsersfileUploadForm.invite": { "defaultMessage": "Invite Users from File" }, - "course.userInvitations.InvitationActionButtons.deletionConfirm": { - "defaultMessage": "Are you sure you wish to delete invitation to {name} ({email})?" - }, - "course.userInvitations.InvitationActionButtons.deletionFailure": { - "defaultMessage": "Failed to delete user - {error}" - }, - "course.userInvitations.InvitationActionButtons.deletionSuccess": { - "defaultMessage": "Invitation for {name} was deleted." - }, - "course.userInvitations.InvitationActionButtons.deletionTooltip": { - "defaultMessage": "Delete Invitation" - }, - "course.userInvitations.InvitationActionButtons.resendFailure": { - "defaultMessage": "Failed to resend invitation - {error}" - }, - "course.userInvitations.InvitationActionButtons.resendSuccess": { - "defaultMessage": "Resent email invitation to {email}!" - }, - "course.userInvitations.InvitationActionButtons.resendTooltip": { - "defaultMessage": "Resend Invitation" - }, "course.userInvitations.RegistrationCodeButton.registrationCode": { "defaultMessage": "Registration Code" }, @@ -7023,6 +7928,9 @@ "course.userInvitations.UserInvitationsTable.accepted": { "defaultMessage": "Accepted" }, + "course.userInvitations.UserInvitationsTable.confirmedTooltip": { + "defaultMessage": "Accepted {confirmedAt}" + }, "course.userInvitations.UserInvitationsTable.failed": { "defaultMessage": "Failed" }, @@ -7035,9 +7943,6 @@ "course.userInvitations.UserInvitationsTable.sentTooltip": { "defaultMessage": "Sent {sentAt}" }, - "course.userInvitations.UserInvitationsTable.confirmedTooltip": { - "defaultMessage": "Accepted {confirmedAt}" - }, "course.userNotification.AchievementGainedPopup.unlocked": { "defaultMessage": "Achievement Unlocked!" }, @@ -7056,12 +7961,6 @@ "course.users.ExperiencePointsRecords.experiencePointsHistoryHeader": { "defaultMessage": "Experience Points History: {for}" }, - "course.users.ExperiencePointsRecords.fetchUsersFailure": { - "defaultMessage": "Failed to fetch records" - }, - "course.users.ExperiencePointsTable.fetchRecordsFailure": { - "defaultMessage": "Failed to fetch records" - }, "course.users.ManageStaff.noStaff": { "defaultMessage": "No staff in course." }, @@ -7119,6 +8018,15 @@ "course.users.ManageUsersTable.defaultTimeline": { "defaultMessage": "Default" }, + "course.users.ManageUsersTable.deletionConfirm": { + "defaultMessage": "Are you sure you wish to delete {role} {name} ({email})?" + }, + "course.users.ManageUsersTable.deletionFailure": { + "defaultMessage": "Failed to delete {role} {name} ({email})." + }, + "course.users.ManageUsersTable.deletionScheduled": { + "defaultMessage": "{role} {name} ({email}) has been scheduled for deletion." + }, "course.users.ManageUsersTable.group": { "defaultMessage": "Group: {name}" }, @@ -7134,6 +8042,24 @@ "course.users.ManageUsersTable.selectedNUsers": { "defaultMessage": "Selected {n, plural, =1 {# user} other {# users}}" }, + "course.users.ManageUsersTable.suspend": { + "defaultMessage": "Suspend" + }, + "course.users.ManageUsersTable.suspendFailure": { + "defaultMessage": "Failed to suspend {name}." + }, + "course.users.ManageUsersTable.suspendSuccess": { + "defaultMessage": "{name} is now suspended. They cannot access this course until they are unsuspended." + }, + "course.users.ManageUsersTable.unsuspend": { + "defaultMessage": "Unsuspend" + }, + "course.users.ManageUsersTable.unsuspendFailure": { + "defaultMessage": "Failed to unsuspend {name}." + }, + "course.users.ManageUsersTable.unsuspendSuccess": { + "defaultMessage": "{name} is no longer suspended. They can now access the course." + }, "course.users.ManageUsersTable.updateFailure": { "defaultMessage": "Failed to update user - {error}" }, @@ -7242,36 +8168,6 @@ "course.users.UpgradeToStaff.upgradeSuccess": { "defaultMessage": "{count, plural, =0 {No users were} one {# new user has} other {# new users have}} been upgraded to {role}" }, - "course.users.ManageUsersTable.deletionConfirm": { - "defaultMessage": "Are you sure you wish to delete {role} {name} ({email})?" - }, - "course.users.ManageUsersTable.deletionFailure": { - "defaultMessage": "Failed to delete user." - }, - "course.users.ManageUsersTable.deletionScheduled": { - "defaultMessage": "{role} {name} ({email}) has been scheduled for deletion." - }, - "course.users.ManageUsersTable.deletionSuccess": { - "defaultMessage": "User was deleted." - }, - "course.users.ManageUsersTable.suspend": { - "defaultMessage": "Suspend" - }, - "course.users.ManageUsersTable.suspendFailure": { - "defaultMessage": "Failed to suspend {name}." - }, - "course.users.ManageUsersTable.suspendSuccess": { - "defaultMessage": "{name} is now suspended. They cannot access this course until they are unsuspended." - }, - "course.users.ManageUsersTable.unsuspend": { - "defaultMessage": "Unsuspend" - }, - "course.users.ManageUsersTable.unsuspendFailure": { - "defaultMessage": "Failed to unsuspend {name}." - }, - "course.users.ManageUsersTable.unsuspendSuccess": { - "defaultMessage": "{name} is no longer suspended. They can now access the course." - }, "course.users.UserManagementTabs.enrolRequestsTitle": { "defaultMessage": "Enrol Requests" }, @@ -7410,27 +8306,27 @@ "course.video.VideoShow.videoTitle": { "defaultMessage": "Video - {title}" }, + "course.video.VideoTable.actions": { + "defaultMessage": "Actions" + }, + "course.video.VideoTable.averageWatched": { + "defaultMessage": "Average % Watched" + }, "course.video.VideoTable.noVideo": { "defaultMessage": "No Video" }, - "course.video.VideoTable.title": { - "defaultMessage": "Title" + "course.video.VideoTable.published": { + "defaultMessage": "Published" }, "course.video.VideoTable.startAt": { "defaultMessage": "Start At" }, + "course.video.VideoTable.title": { + "defaultMessage": "Title" + }, "course.video.VideoTable.watchCount": { "defaultMessage": "Watch Count" }, - "course.video.VideoTable.averageWatched": { - "defaultMessage": "Average % Watched" - }, - "course.video.VideoTable.published": { - "defaultMessage": "Published" - }, - "course.video.VideoTable.actions": { - "defaultMessage": "Actions" - }, "course.video.VideosIndex.fetchVideosFailure": { "defaultMessage": "Failed to retrieve videos." }, @@ -7578,9 +8474,33 @@ "course.videoSubmissions.UserVideoSubmissionsIndex.videoSubmissionsHeader": { "defaultMessage": "Video Watch History" }, + "d6avGo": { + "defaultMessage": "Submissions" + }, + "f9aTl7": { + "defaultMessage": "Role-Playing Assessments" + }, + "jvrMfo": { + "defaultMessage": "Assistants" + }, "landing_page.create_an_account": { "defaultMessage": "Create an account" }, + "landing_page.iconEngaging": { + "defaultMessage": "Engaging" + }, + "landing_page.iconEngagingSubtitle": { + "defaultMessage": "It is built for all teachers. You do not need to have any programming knowledge to master the platform. Coursemology is easy and intuitive to use for both teachers and students." + }, + "landing_page.iconGeneral": { + "defaultMessage": "General" + }, + "landing_page.iconGeneralSubtitle": { + "defaultMessage": "It is built for all subjects. The gamification system of Coursemology does not make any assumptions on the subject. Through Coursemology, any teacher who teaches any subject can turn his course exercises into an online game." + }, + "landing_page.iconSimple": { + "defaultMessage": "Simple" + }, "landing_page.new_to_coursemology": { "defaultMessage": "New to Coursemology?" }, @@ -7617,12 +8537,12 @@ "lib.components.core.Expandable.showMore": { "defaultMessage": "Show more" }, - "lib.components.core.Note.noteHeader": { - "defaultMessage": "Note" - }, "lib.components.core.Note.errorHeader": { "defaultMessage": "Error" }, + "lib.components.core.Note.noteHeader": { + "defaultMessage": "Note" + }, "lib.components.core.banners.ServerUnreachableBanner.refreshPage": { "defaultMessage": "Refresh page" }, @@ -7731,26 +8651,89 @@ "lib.components.extensions.conditions.scoringAtLeast": { "defaultMessage": "scoring at least" }, - "lib.components.extensions.conditions.specifyLevel": { - "defaultMessage": "Specify a minimum level" + "lib.components.extensions.conditions.specifyLevel": { + "defaultMessage": "Specify a minimum level" + }, + "lib.components.extensions.conditions.survey": { + "defaultMessage": "Survey" + }, + "lib.components.extensions.conditions.type": { + "defaultMessage": "Type" + }, + "lib.components.extensions.conditions.updateCondition": { + "defaultMessage": "Update condition" + }, + "lib.components.form.fields.DateTimePickerField.invalidDateTime": { + "defaultMessage": "Invalid Date and/or Time" + }, + "lib.components.form.fields.SingleFileInput.dropzone": { + "defaultMessage": "Drag your file here, or click to select file" + }, + "lib.components.form.fields.SingleFileInput.removeFile": { + "defaultMessage": "Remove File" + }, + "lib.components.getHelp.filter.filterAssessmentLabel": { + "defaultMessage": "Filter by Assessment" + }, + "lib.components.getHelp.filter.filterCourseLabel": { + "defaultMessage": "Filter by Course" + }, + "lib.components.getHelp.filter.filterEndDateLabel": { + "defaultMessage": "End Date" + }, + "lib.components.getHelp.filter.filterStartDateLabel": { + "defaultMessage": "Start Date" + }, + "lib.components.getHelp.filter.filterStudentLabel": { + "defaultMessage": "Filter by Student" + }, + "lib.components.getHelp.filter.lastFourteenDays": { + "defaultMessage": "Last 14 Days" + }, + "lib.components.getHelp.filter.lastSevenDays": { + "defaultMessage": "Last 7 Days" + }, + "lib.components.getHelp.filter.lastSixMonths": { + "defaultMessage": "Last 6 Months" + }, + "lib.components.getHelp.filter.lastThirtyDays": { + "defaultMessage": "Last 30 Days" + }, + "lib.components.getHelp.filter.lastTwelveMonths": { + "defaultMessage": "Last 12 Months" + }, + "lib.components.getHelp.header": { + "defaultMessage": "Recent Get Help Activity ({total, plural, one {# Conversation} other {# Conversations}})" + }, + "lib.components.getHelp.table.assessmentTitle": { + "defaultMessage": "Assessment" + }, + "lib.components.getHelp.table.courseTitle": { + "defaultMessage": "Course" + }, + "lib.components.getHelp.table.createdAt": { + "defaultMessage": "Last Message At" }, - "lib.components.extensions.conditions.survey": { - "defaultMessage": "Survey" + "lib.components.getHelp.table.instanceTitle": { + "defaultMessage": "Instance" }, - "lib.components.extensions.conditions.type": { - "defaultMessage": "Type" + "lib.components.getHelp.table.lastMessage": { + "defaultMessage": "Last Message" }, - "lib.components.extensions.conditions.updateCondition": { - "defaultMessage": "Update condition" + "lib.components.getHelp.table.messageCount": { + "defaultMessage": "# Msgs" }, - "lib.components.form.fields.DateTimePickerField.invalidDateTime": { - "defaultMessage": "Invalid Date and/or Time" + "lib.components.getHelp.table.questionNumber": { + "defaultMessage": "Question" }, - "lib.components.form.fields.SingleFileInput.dropzone": { - "defaultMessage": "Drag your file here, or click to select file" + "lib.components.getHelp.table.studentName": { + "defaultMessage": "Name" }, - "lib.components.form.fields.SingleFileInput.removeFile": { - "defaultMessage": "Remove File" + "lib.components.getHelp.validation.exceedDateRange": { + "defaultMessage": "Date range cannot exceed 365 days" + }, + "lib.components.getHelp.validation.invalidDateSelection": { + "defaultMessage": "End Date must be after or equal to Start Date" }, "lib.components.navigation.AdminPopupMenuList.adminPanel": { "defaultMessage": "System Admin Panel" @@ -7788,6 +8771,24 @@ "lib.components.navigation.CourseSwitcherPopupMenu.thisCourse": { "defaultMessage": "This course" }, + "lib.components.table.MuiColumnPickerDialog.apply": { + "defaultMessage": "Apply to view" + }, + "lib.components.table.MuiColumnPickerDialog.cancel": { + "defaultMessage": "Cancel" + }, + "lib.components.table.MuiColumnPickerDialog.defaultTitle": { + "defaultMessage": "Select columns" + }, + "lib.components.table.MuiColumnPickerDialog.export": { + "defaultMessage": "Apply and Export" + }, + "lib.components.table.MuiTableToolbar.directExport": { + "defaultMessage": "Export" + }, + "lib.components.table.MuiTableToolbar.exportTrigger": { + "defaultMessage": "Export…" + }, "lib.hooks.router.usePrompt.sureYouWantToLeave": { "defaultMessage": "Are you sure you want to leave this page? You will lose unsaved changes." }, @@ -7815,90 +8816,27 @@ "lib.translations.beta": { "defaultMessage": "Beta" }, - "lib.components.getHelp.header": { - "defaultMessage": "Recent Get Help Activity ({total, plural, one {# Conversation} other {# Conversations}})" - }, - "lib.components.getHelp.filter.filterCourseLabel": { - "defaultMessage": "Filter by Course" - }, - "lib.components.getHelp.filter.filterAssessmentLabel": { - "defaultMessage": "Filter by Assessment" - }, - "lib.components.getHelp.filter.filterStudentLabel": { - "defaultMessage": "Filter by Student" - }, - "lib.components.getHelp.filter.filterStartDateLabel": { - "defaultMessage": "Start Date" - }, - "lib.components.getHelp.filter.filterEndDateLabel": { - "defaultMessage": "End Date" - }, - "lib.components.getHelp.filter.lastSevenDays": { - "defaultMessage": "Last 7 Days" - }, - "lib.components.getHelp.filter.lastFourteenDays": { - "defaultMessage": "Last 14 Days" - }, - "lib.components.getHelp.filter.lastThirtyDays": { - "defaultMessage": "Last 30 Days" - }, - "lib.components.getHelp.filter.lastSixMonths": { - "defaultMessage": "Last 6 Months" - }, - "lib.components.getHelp.filter.lastTwelveMonths": { - "defaultMessage": "Last 12 Months" - }, - "lib.components.getHelp.table.studentName": { - "defaultMessage": "Name" - }, - "lib.components.getHelp.table.messageCount": { - "defaultMessage": "# Msgs" - }, - "lib.components.getHelp.table.lastMessage": { - "defaultMessage": "Last Message" - }, - "lib.components.getHelp.table.questionNumber": { - "defaultMessage": "Question" - }, - "lib.components.getHelp.table.assessmentTitle": { - "defaultMessage": "Assessment" - }, - "lib.components.getHelp.table.createdAt": { - "defaultMessage": "Last Message At" - }, - "lib.components.getHelp.table.courseTitle": { - "defaultMessage": "Course" - }, - "lib.components.getHelp.table.instanceTitle": { - "defaultMessage": "Instance" - }, - "lib.components.getHelp.validation.invalidDateSelection": { - "defaultMessage": "End Date must be after or equal to Start Date" - }, - "lib.components.getHelp.validation.exceedDateRange": { - "defaultMessage": "Date range cannot exceed 365 days" - }, "lib.translations.course.users.fetchUsersFailure": { "defaultMessage": "Failed to fetch users." }, "lib.translations.course.users.manageUsersHeader": { "defaultMessage": "Manage Users" }, - "lib.translations.course.users.roles.student": { - "defaultMessage": "Student" - }, - "lib.translations.course.users.roles.teachingAssistant": { - "defaultMessage": "Teaching Assistant" + "lib.translations.course.users.roles.manager": { + "defaultMessage": "Manager" }, "lib.translations.course.users.roles.observer": { "defaultMessage": "Observer" }, - "lib.translations.course.users.roles.manager": { - "defaultMessage": "Manager" - }, "lib.translations.course.users.roles.owner": { "defaultMessage": "Owner" }, + "lib.translations.course.users.roles.student": { + "defaultMessage": "Student" + }, + "lib.translations.course.users.roles.teachingAssistant": { + "defaultMessage": "Teaching Assistant" + }, "lib.translations.experimental": { "defaultMessage": "Experimental" }, @@ -8010,14 +8948,17 @@ "lib.translations.form.validation.startEndDateValidationError": { "defaultMessage": "Must be after Start Date" }, - "lib.translations.instance.users.roles.normal": { - "defaultMessage": "Normal" + "lib.translations.instance.users.roles.administrator": { + "defaultMessage": "Administrator" }, "lib.translations.instance.users.roles.instructor": { "defaultMessage": "Instructor" }, - "lib.translations.instance.users.roles.administrator": { - "defaultMessage": "Administrator" + "lib.translations.instance.users.roles.normal": { + "defaultMessage": "Normal" + }, + "lib.translations.messages.featureUnavailable": { + "defaultMessage": "This feature is currently unavailable." }, "lib.translations.messages.fetchingError": { "defaultMessage": "An error occurred when loading your data. Please reload and try again." @@ -8028,9 +8969,27 @@ "lib.translations.messages.loadImageError": { "defaultMessage": "An error occurred when loading your image. Please try selecting another one." }, + "lib.translations.myStudents": { + "defaultMessage": "My Students" + }, + "lib.translations.myStudentsIncludingPhantoms": { + "defaultMessage": "My Students (Including Phantoms)" + }, "lib.translations.no": { "defaultMessage": "No" }, + "lib.translations.staff": { + "defaultMessage": "Staff" + }, + "lib.translations.staffIncludingPhantoms": { + "defaultMessage": "Staff (Including Phantoms)" + }, + "lib.translations.students": { + "defaultMessage": "Students" + }, + "lib.translations.studentsIncludingPhantoms": { + "defaultMessage": "Students (Including Phantoms)" + }, "lib.translations.summary": { "defaultMessage": "Summary" }, @@ -8196,33 +9155,15 @@ "lib.translations.yes": { "defaultMessage": "Yes" }, - "material.attemptLoader.errorAccessingMaterial": { - "defaultMessage": "An error occurred while accessing this material. Try again later." - }, - "system.admin.instance.instance.InstanceAdminNavigator.announcements": { - "defaultMessage": "Announcements" - }, - "system.admin.instance.instance.InstanceAdminNavigator.components": { - "defaultMessage": "Components" - }, - "system.admin.instance.instance.InstanceAdminNavigator.courses": { - "defaultMessage": "Courses" - }, - "system.admin.instance.instance.InstanceAdminNavigator.roleRequests": { - "defaultMessage": "Role Requests" - }, - "system.admin.instance.instance.InstanceAdminNavigator.users": { - "defaultMessage": "Users" - }, - "system.admin.instance.instance.InstanceAdminNavigator.getHelp": { - "defaultMessage": "Get Help" - }, "system.admin.admin.AdminNavigator.announcements": { "defaultMessage": "System Announcements" }, "system.admin.admin.AdminNavigator.courses": { "defaultMessage": "Courses" }, + "system.admin.admin.AdminNavigator.getHelp": { + "defaultMessage": "Get Help" + }, "system.admin.admin.AdminNavigator.instances": { "defaultMessage": "Instances" }, @@ -8232,9 +9173,6 @@ "system.admin.admin.AdminNavigator.users": { "defaultMessage": "Users" }, - "system.admin.admin.AdminNavigator.getHelp": { - "defaultMessage": "Get Help" - }, "system.admin.admin.AnnouncementsIndex.fetchAnnouncementsFailure": { "defaultMessage": "Unable to fetch announcements" }, @@ -8319,21 +9257,24 @@ "system.admin.admin.InstancesTable.updateSuccess": { "defaultMessage": "Renamed {field} from {prevValue} to {newValue}" }, + "system.admin.admin.UsersButton.associatedCourses": { + "defaultMessage": "{courseName} ({instanceName})" + }, "system.admin.admin.UsersButton.deleteTooltip": { "defaultMessage": "Delete User" }, "system.admin.admin.UsersButton.deletionConfirm": { - "defaultMessage": "Are you sure you wish to delete {role} {name} ({email})?" + "defaultMessage": "Are you sure you wish to proceed?" }, "system.admin.admin.UsersButton.deletionConfirmTitle": { "defaultMessage": "Deleting {role} User {name} ({email})" }, - "system.admin.admin.UsersButton.deletionPromptContent": { - "defaultMessage": "Deleting this user will PERMANENTLY delete associated data in the following {count, plural, one {course} other {courses}}:" - }, "system.admin.admin.UsersButton.deletionFailure": { "defaultMessage": "Failed to delete user - {error}" }, + "system.admin.admin.UsersButton.deletionPromptContent": { + "defaultMessage": "Deleting this user will PERMANENTLY delete associated data in the following {count, plural, one {course} other {courses}}:" + }, "system.admin.admin.UsersButton.deletionSuccess": { "defaultMessage": "User was deleted." }, @@ -8379,6 +9320,24 @@ "system.admin.instance.instance.IndividualInvitations.invite": { "defaultMessage": "Invite All Users" }, + "system.admin.instance.instance.InstanceAdminNavigator.announcements": { + "defaultMessage": "Announcements" + }, + "system.admin.instance.instance.InstanceAdminNavigator.components": { + "defaultMessage": "Components" + }, + "system.admin.instance.instance.InstanceAdminNavigator.courses": { + "defaultMessage": "Courses" + }, + "system.admin.instance.instance.InstanceAdminNavigator.getHelp": { + "defaultMessage": "Get Help" + }, + "system.admin.instance.instance.InstanceAdminNavigator.roleRequests": { + "defaultMessage": "Role Requests" + }, + "system.admin.instance.instance.InstanceAdminNavigator.users": { + "defaultMessage": "Users" + }, "system.admin.instance.instance.InstanceAnnouncementsIndex.fetchAnnouncementsFailure": { "defaultMessage": "Unable to fetch announcements" }, @@ -8472,21 +9431,12 @@ "system.admin.instance.instance.InstanceUsersIndex.totalUsers": { "defaultMessage": "Total Users: {allCount} ({adminCount} Administrators, {instructorCount} Instructors, {normalCount} Normal)" }, - "system.admin.instance.instance.InstanceUsersInvitations.accepted": { - "defaultMessage": "Accepted Invitations" - }, - "system.admin.instance.instance.InstanceUsersInvitations.failed": { - "defaultMessage": "Failed Invitations" - }, "system.admin.instance.instance.InstanceUsersInvitations.fetch.failure": { "defaultMessage": "Failed to fetch invitations." }, "system.admin.instance.instance.InstanceUsersInvitations.header": { "defaultMessage": "Invitations" }, - "system.admin.instance.instance.InstanceUsersInvitations.pending": { - "defaultMessage": "Pending Invitations" - }, "system.admin.instance.instance.InstanceUsersInvitations.title": { "defaultMessage": "Users" }, @@ -8499,6 +9449,27 @@ "system.admin.instance.instance.InstanceUsersTabs.usersTab": { "defaultMessage": "Users" }, + "system.admin.instance.instance.InvitationActionButtons.deletionConfirm": { + "defaultMessage": "Are you sure you wish to delete invitation to {name} ({email})?" + }, + "system.admin.instance.instance.InvitationActionButtons.deletionFailure": { + "defaultMessage": "Failed to delete user - {error}" + }, + "system.admin.instance.instance.InvitationActionButtons.deletionSuccess": { + "defaultMessage": "Invitation for {name} was deleted." + }, + "system.admin.instance.instance.InvitationActionButtons.deletionTooltip": { + "defaultMessage": "Delete Invitation" + }, + "system.admin.instance.instance.InvitationActionButtons.resendFailure": { + "defaultMessage": "Failed to resend invitation - {error}" + }, + "system.admin.instance.instance.InvitationActionButtons.resendSuccess": { + "defaultMessage": "Resent email invitation to {email}!" + }, + "system.admin.instance.instance.InvitationActionButtons.resendTooltip": { + "defaultMessage": "Resend Invitation" + }, "system.admin.instance.instance.InvitationResultDialog.close": { "defaultMessage": "Close" }, @@ -8529,27 +9500,6 @@ "system.admin.instance.instance.InvitationResultDialog.newInvitations": { "defaultMessage": "New Invitations ({count})" }, - "system.admin.instance.instance.InvitationActionButtons.deletionConfirm": { - "defaultMessage": "Are you sure you wish to delete invitation to {name} ({email})?" - }, - "system.admin.instance.instance.InvitationActionButtons.deletionFailure": { - "defaultMessage": "Failed to delete user - {error}" - }, - "system.admin.instance.instance.InvitationActionButtons.deletionSuccess": { - "defaultMessage": "Invitation for {name} was deleted." - }, - "system.admin.instance.instance.InvitationActionButtons.deletionTooltip": { - "defaultMessage": "Delete Invitation" - }, - "system.admin.instance.instance.InvitationActionButtons.resendFailure": { - "defaultMessage": "Failed to resend invitation - {error}" - }, - "system.admin.instance.instance.InvitationActionButtons.resendSuccess": { - "defaultMessage": "Resent email invitation to {email}!" - }, - "system.admin.instance.instance.InvitationActionButtons.resendTooltip": { - "defaultMessage": "Resend Invitation" - }, "system.admin.instance.instance.PendingRoleRequestsButton.approveFailure": { "defaultMessage": "Failed to approve role request - {error}" }, @@ -8595,6 +9545,9 @@ "system.admin.instance.instance.UserInvitationsTable.accepted": { "defaultMessage": "Accepted" }, + "system.admin.instance.instance.UserInvitationsTable.confirmedTooltip": { + "defaultMessage": "Accepted {confirmedAt}" + }, "system.admin.instance.instance.UserInvitationsTable.failed": { "defaultMessage": "Failed" }, @@ -8607,9 +9560,6 @@ "system.admin.instance.instance.UserInvitationsTable.sentTooltip": { "defaultMessage": "Sent {sentAt}" }, - "system.admin.instance.instance.UserInvitationsTable.confirmedTooltip": { - "defaultMessage": "Accepted {confirmedAt}" - }, "system.admin.instance.instance.UsersButton.deleteTooltip": { "defaultMessage": "Remove User" }, @@ -8619,12 +9569,12 @@ "system.admin.instance.instance.UsersButton.deletionConfirmTitle": { "defaultMessage": "Removing {role} User {name} ({email})" }, - "system.admin.instance.instance.UsersButton.deletionPromptContent": { - "defaultMessage": "Removing this user may cause errors in the following {count, plural, one {course} other {courses}}:" - }, "system.admin.instance.instance.UsersButton.deletionFailure": { "defaultMessage": "Failed to remove user - {error}" }, + "system.admin.instance.instance.UsersButton.deletionPromptContent": { + "defaultMessage": "Removing this user may cause errors in the following {count, plural, one {course} other {courses}}:" + }, "system.admin.instance.instance.UsersButton.deletionSuccess": { "defaultMessage": "User was removed from this instance." }, @@ -8954,5 +9904,8 @@ }, "users.troubleSigningIn": { "defaultMessage": "Trouble signing in?" + }, + "wEQDC6": { + "defaultMessage": "Edit" } } diff --git a/client/locales/ko.json b/client/locales/ko.json index 2592efdaf4a..5f237eb5be8 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -4091,6 +4091,9 @@ "course.componentTitles.course_forums_component": { "defaultMessage": "포럼" }, + "course.componentTitles.course_gradebook_component": { + "defaultMessage": "성적부" + }, "course.componentTitles.course_groups_component": { "defaultMessage": "그룹" }, @@ -5252,6 +5255,63 @@ "course.forum.forum.markAllAsReadFailed": { "defaultMessage": "이 포럼의 모든 주제를 읽음으로 표시하는 데 실패했습니다. 나중에 다시 시도하세요." }, + "course.gradebook.GradebookColumnTree.grades": { + "defaultMessage": "성적" + }, + "course.gradebook.GradebookColumnTree.email": { + "defaultMessage": "이메일" + }, + "course.gradebook.GradebookColumnTree.level": { + "defaultMessage": "레벨" + }, + "course.gradebook.GradebookColumnTree.name": { + "defaultMessage": "이름" + }, + "course.gradebook.GradebookColumnTree.studentInfo": { + "defaultMessage": "학생 정보" + }, + "course.gradebook.GradebookColumnTree.totalXp": { + "defaultMessage": "총 경험치" + }, + "course.gradebook.GradebookColumnTree.gamification": { + "defaultMessage": "게임화" + }, + "course.gradebook.GradebookIndex.dialogTitle": { + "defaultMessage": "열 선택" + }, + "course.gradebook.GradebookIndex.exportAllTooltip": { + "defaultMessage": "선택한 행 없음 - 모든 행이 내보내집니다." + }, + "course.gradebook.GradebookIndex.exportButton": { + "defaultMessage": "전체 행 내보내기" + }, + "course.gradebook.GradebookIndex.exportRows": { + "defaultMessage": "{count, plural, other {# 행}} 내보내기" + }, + "course.gradebook.GradebookIndex.selectColumns": { + "defaultMessage": "열 선택" + }, + "course.gradebook.GradebookIndex.applyAndExport": { + "defaultMessage": "적용 및 내보내기" + }, + "course.gradebook.GradebookIndex.fetchFailure": { + "defaultMessage": "성적부를 불러오지 못했습니다." + }, + "course.gradebook.GradebookIndex.gradebook": { + "defaultMessage": "성적부" + }, + "course.gradebook.GradebookIndex.searchStudents": { + "defaultMessage": "이름 또는 이메일로 검색" + }, + "course.gradebook.GradebookIndex.noStudents": { + "defaultMessage": "학생 없음" + }, + "course.gradebook.GradebookTable.maxMarks": { + "defaultMessage": "최고 점수" + }, + "course.gradebook.GradebookTable.noDataColumnsHint": { + "defaultMessage": "성적 열이 선택되지 않았습니다 — 학생 정보만 내보내집니다." + }, "course.group.GroupCreationForm.description": { "defaultMessage": "설명 (선택사항)" }, @@ -7868,6 +7928,24 @@ "lib.components.getHelp.validation.exceedDateRange": { "defaultMessage": "날짜 범위는 365일을 초과할 수 없습니다" }, + "lib.components.table.MuiColumnPickerDialog.apply": { + "defaultMessage": "뷰에 적용" + }, + "lib.components.table.MuiColumnPickerDialog.cancel": { + "defaultMessage": "취소" + }, + "lib.components.table.MuiColumnPickerDialog.defaultTitle": { + "defaultMessage": "열 선택" + }, + "lib.components.table.MuiColumnPickerDialog.export": { + "defaultMessage": "적용 및 내보내기" + }, + "lib.components.table.MuiTableToolbar.directExport": { + "defaultMessage": "내보내기" + }, + "lib.components.table.MuiTableToolbar.exportTrigger": { + "defaultMessage": "내보내기…" + }, "lib.translations.course.users.fetchUsersFailure": { "defaultMessage": "사용자를 가져오는 데 실패했습니다." }, diff --git a/client/locales/zh.json b/client/locales/zh.json index 93d955f80be..351f02ee55c 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -4085,6 +4085,9 @@ "course.componentTitles.course_forums_component": { "defaultMessage": "论坛" }, + "course.componentTitles.course_gradebook_component": { + "defaultMessage": "成绩册" + }, "course.componentTitles.course_groups_component": { "defaultMessage": "组" }, @@ -5246,6 +5249,63 @@ "course.forum.forum.markAllAsReadFailed": { "defaultMessage": "未能将此论坛中的所有主题标记为已读。请稍后再试。" }, + "course.gradebook.GradebookColumnTree.grades": { + "defaultMessage": "成绩" + }, + "course.gradebook.GradebookColumnTree.email": { + "defaultMessage": "电子邮件" + }, + "course.gradebook.GradebookColumnTree.level": { + "defaultMessage": "等级" + }, + "course.gradebook.GradebookColumnTree.name": { + "defaultMessage": "姓名" + }, + "course.gradebook.GradebookColumnTree.studentInfo": { + "defaultMessage": "学生信息" + }, + "course.gradebook.GradebookColumnTree.totalXp": { + "defaultMessage": "总经验值" + }, + "course.gradebook.GradebookColumnTree.gamification": { + "defaultMessage": "游戏化" + }, + "course.gradebook.GradebookIndex.dialogTitle": { + "defaultMessage": "选择列" + }, + "course.gradebook.GradebookIndex.exportAllTooltip": { + "defaultMessage": "未选择行 - 将导出所有行。" + }, + "course.gradebook.GradebookIndex.exportButton": { + "defaultMessage": "导出全部行" + }, + "course.gradebook.GradebookIndex.exportRows": { + "defaultMessage": "导出 {count, plural, other {# 行}}" + }, + "course.gradebook.GradebookIndex.selectColumns": { + "defaultMessage": "选择列" + }, + "course.gradebook.GradebookIndex.applyAndExport": { + "defaultMessage": "应用并导出" + }, + "course.gradebook.GradebookIndex.fetchFailure": { + "defaultMessage": "无法获取成绩册。" + }, + "course.gradebook.GradebookIndex.gradebook": { + "defaultMessage": "成绩册" + }, + "course.gradebook.GradebookIndex.searchStudents": { + "defaultMessage": "按姓名或电子邮件搜索" + }, + "course.gradebook.GradebookIndex.noStudents": { + "defaultMessage": "无学生" + }, + "course.gradebook.GradebookTable.maxMarks": { + "defaultMessage": "最高分" + }, + "course.gradebook.GradebookTable.noDataColumnsHint": { + "defaultMessage": "未选择成绩列——导出内容将仅包含学生信息。" + }, "course.group.GroupCreationForm.description": { "defaultMessage": "说明(可选)" }, @@ -7862,6 +7922,24 @@ "lib.components.getHelp.validation.exceedDateRange": { "defaultMessage": "日期范围不能超过365天" }, + "lib.components.table.MuiColumnPickerDialog.apply": { + "defaultMessage": "应用至视图" + }, + "lib.components.table.MuiColumnPickerDialog.cancel": { + "defaultMessage": "取消" + }, + "lib.components.table.MuiColumnPickerDialog.defaultTitle": { + "defaultMessage": "选择列" + }, + "lib.components.table.MuiColumnPickerDialog.export": { + "defaultMessage": "应用并导出" + }, + "lib.components.table.MuiTableToolbar.directExport": { + "defaultMessage": "导出" + }, + "lib.components.table.MuiTableToolbar.exportTrigger": { + "defaultMessage": "导出…" + }, "lib.translations.course.users.fetchUsersFailure": { "defaultMessage": "无法获取用户。" }, diff --git a/client/package.json b/client/package.json index 2dd501cdee5..3dedf38918d 100644 --- a/client/package.json +++ b/client/package.json @@ -22,7 +22,7 @@ "lint": "yarn run lint-src && yarn run lint-tests && prettier --check \"**/*.{js,jsx,ts,tsx}\"", "lint-fix": "yarn run lint-src --fix && yarn run lint-tests --fix && prettier --write \"**/*.{js,jsx,ts,tsx}\"", "postinstall": "cd vendor/recorderjs && NODE_ENV=development yarn install --force --frozen-lockfile", - "check-types": "tsc" + "check-types": "tsc --noEmit" }, "cacheDirectories": [ "node_modules", diff --git a/config/locales/en/course/gradebook.yml b/config/locales/en/course/gradebook.yml new file mode 100644 index 00000000000..d28d02726ed --- /dev/null +++ b/config/locales/en/course/gradebook.yml @@ -0,0 +1,5 @@ +en: + course: + gradebook: + component: + sidebar_title: 'Gradebook' diff --git a/config/locales/ko/course/gradebook.yml b/config/locales/ko/course/gradebook.yml new file mode 100644 index 00000000000..ddd9d30ee8b --- /dev/null +++ b/config/locales/ko/course/gradebook.yml @@ -0,0 +1,5 @@ +ko: + course: + gradebook: + component: + sidebar_title: '성적부' diff --git a/config/locales/zh/course/gradebook.yml b/config/locales/zh/course/gradebook.yml new file mode 100644 index 00000000000..a07bbfddd02 --- /dev/null +++ b/config/locales/zh/course/gradebook.yml @@ -0,0 +1,5 @@ +zh: + course: + gradebook: + component: + sidebar_title: '成绩册' diff --git a/config/routes.rb b/config/routes.rb index 921ce2fb71d..6838d77f241 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -196,6 +196,9 @@ get 'leaderboard' => 'leaderboard_settings#edit' patch 'leaderboard' => 'leaderboard_settings#update' + get 'gradebook' => 'gradebook_settings#edit' + patch 'gradebook' => 'gradebook_settings#update' + get 'comments' => 'discussion/topic_settings#edit', as: 'topics' patch 'comments' => 'discussion/topic_settings#update' @@ -496,6 +499,11 @@ get 'groups', as: :group end + resource :gradebook, only: [] do + get '/' => 'gradebook#index' + patch '/weights' => 'gradebook#update_weights' + end + scope module: :discussion do resources :topics, path: 'comments', only: [:index] do get 'pending', on: :collection diff --git a/db/migrate/20260528084849_add_gradebook_weight_to_course_assessment_tabs.rb b/db/migrate/20260528084849_add_gradebook_weight_to_course_assessment_tabs.rb new file mode 100644 index 00000000000..14673cf68f0 --- /dev/null +++ b/db/migrate/20260528084849_add_gradebook_weight_to_course_assessment_tabs.rb @@ -0,0 +1,5 @@ +class AddGradebookWeightToCourseAssessmentTabs < ActiveRecord::Migration[7.2] + def change + add_column :course_assessment_tabs, :gradebook_weight, :integer, null: false, default: 0 + end +end diff --git a/db/schema.rb b/db/schema.rb index 696d5837a38..8eecfe871e7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_04_06_122130) do +ActiveRecord::Schema[7.2].define(version: 2026_05_28_084849) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" enable_extension "uuid-ossp" @@ -552,6 +552,7 @@ t.integer "updater_id", null: false t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false + t.integer "gradebook_weight", default: 0, null: false t.index ["category_id"], name: "fk__course_assessment_tabs_category_id" t.index ["creator_id"], name: "fk__course_assessment_tabs_creator_id" t.index ["updater_id"], name: "fk__course_assessment_tabs_updater_id" @@ -1333,9 +1334,11 @@ t.boolean "phantom", default: false, null: false t.integer "timeline_algorithm" t.boolean "is_retryable", default: true, null: false + t.string "external_id" t.index "lower((email)::text)", name: "index_course_user_invitations_on_email" t.index ["confirmer_id"], name: "fk__course_user_invitations_confirmer_id" t.index ["course_id", "email"], name: "index_course_user_invitations_on_course_id_and_email", unique: true + t.index ["course_id", "external_id"], name: "index_course_user_invitations_on_course_id_and_external_id", unique: true, where: "(external_id IS NOT NULL)" t.index ["course_id"], name: "fk__course_user_invitations_course_id" t.index ["creator_id"], name: "fk__course_user_invitations_creator_id" t.index ["invitation_key"], name: "index_course_user_invitations_on_invitation_key", unique: true @@ -1357,6 +1360,8 @@ t.integer "timeline_algorithm", default: 0, null: false t.datetime "deleted_at" t.boolean "is_suspended", default: false, null: false + t.string "external_id" + t.index ["course_id", "external_id"], name: "index_course_users_on_course_id_and_external_id", unique: true, where: "(external_id IS NOT NULL)" t.index ["course_id", "user_id"], name: "index_course_users_on_course_id_and_user_id", unique: true t.index ["course_id"], name: "fk__course_users_course_id" t.index ["creator_id"], name: "fk__course_users_creator_id" diff --git a/lib/tasks/coursemology/seed_600_gradebook.rake b/lib/tasks/coursemology/seed_600_gradebook.rake new file mode 100644 index 00000000000..0c4e559e721 --- /dev/null +++ b/lib/tasks/coursemology/seed_600_gradebook.rake @@ -0,0 +1,239 @@ +# frozen_string_literal: true + +# Usage: bundle exec rails coursemology:seed_600_gradebook +# Creates a demo course with 3 categories, multiple assessments, and 600 students +# with varied grades for demonstrating the gradebook. + +namespace :coursemology do + task seed_600_gradebook: 'db:seed' do + require 'factory_bot_rails' + + ActsAsTenant.with_tenant(Instance.default) do + admin = User::Email.find_by_email('test@example.org').user + User.stamper = admin + + course = Course.create!( + title: 'Gradebook 600 Demo Course', + published: true, + enrollable: false, + creator: admin, + updater: admin + ) + + puts "Created course: #{course.title} (id=#{course.id})" + + # ── Categories, tabs, assessments ────────────────────────────────────── + + structure = [ + { + title: 'Missions', + tabs: [ + { + title: 'Assignments', + assessments: [ + { title: 'Mission 1 — Variables & Control Flow', max: 20 }, + { title: 'Mission 2 — Functions & Recursion', max: 20 }, + { title: 'Mission 3 — Data Structures', max: 25 }, + { title: 'Mission 4 — Sorting Algorithms', max: 25 } + ] + }, + { + title: 'Optional Missions', + assessments: [ + { title: 'Optional Mission A — Regex', max: 10 }, + { title: 'Optional Mission B — Concurrency', max: 10 } + ] + } + ] + }, + { + title: 'Tutorials', + tabs: [ + { + title: 'Problem Sets', + assessments: [ + { title: 'Problem Set 1', max: 10 }, + { title: 'Problem Set 2', max: 10 }, + { title: 'Problem Set 3', max: 10 }, + { title: 'Problem Set 4', max: 10 }, + { title: 'Problem Set 5', max: 10 } + ] + }, + { + title: 'Recitation Quizzes', + assessments: [ + { title: 'Recitation Quiz 1', max: 5 }, + { title: 'Recitation Quiz 2', max: 5 }, + { title: 'Recitation Quiz 3', max: 5 } + ] + } + ] + }, + { + title: 'Projects', + tabs: [ + { + title: 'Milestones', + assessments: [ + { title: 'Project Milestone 1 — Proposal', max: 15 }, + { title: 'Project Milestone 2 — Prototype', max: 20 }, + { title: 'Project Milestone 3 — Final Report', max: 30 } + ] + } + ] + } + ] + + all_assessments = [] + start_at = 1.month.ago + default_category = course.assessment_categories.first + + structure.each_with_index do |cat_def, cat_i| + category = if cat_i == 0 + default_category.update!(title: cat_def[:title], weight: 1) + default_category + else + course.assessment_categories.create!( + title: cat_def[:title], + weight: cat_i + 1, + creator: admin, + updater: admin + ) + end + + existing_tab = cat_i == 0 ? category.tabs.first : nil + + cat_def[:tabs].each_with_index do |tab_def, tab_i| + tab = if existing_tab && tab_i == 0 + existing_tab.update!(title: tab_def[:title], weight: 1) + existing_tab + else + category.tabs.create!( + title: tab_def[:title], + weight: tab_i + 1, + creator: admin, + updater: admin + ) + end + + tab_def[:assessments].each_with_index do |a_def, a_i| + assessment = Course::Assessment.new( + course: course, + tab: tab, + title: a_def[:title], + description: '', + base_exp: 0, + autograded: false, + start_at: start_at + (((cat_i * 10) + (tab_i * 4) + a_i) * 3).days, + creator: admin, + updater: admin + ) + assessment.lesson_plan_item.published = true + + # Build one MCQ question with the desired max grade. + question = FactoryBot.build( + :course_assessment_question_multiple_response, + :multiple_choice, + maximum_grade: a_def[:max] + ) + assessment.question_assessments.build( + question: question.acting_as, + weight: a_i + 1 + ) + assessment.save! + all_assessments << { record: assessment, max: a_def[:max] } + print '.' + end + end + end + puts "\nCreated #{all_assessments.size} assessments across 3 categories." + + # ── Students ─────────────────────────────────────────────────────────── + + # 600 users: student1000 .. student1599 + student_profiles = (1000..1599).map do |n| + name = "student#{n}" + + # Deterministic but mixed distribution across tiers. + tier = + case n % 10 + when 0, 1, 2 then :high + when 3, 4, 5, 6 then :mid + when 7, 8 then :low + else :sparse + end + + [name, tier] + end + + rng = Random.new(42) # fixed seed for reproducible grades + + student_profiles.each do |name, tier| + email = "#{name}@gradebook.demo" + user = User::Email.find_by_email(email)&.user + unless user + user = User.new(name: name, email: email, password: 'Coursemology!') + user.skip_confirmation! + user.save! + end + + course_user = CourseUser.find_or_create_by!(course: course, user: user) do |cu| + cu.role = :student + cu.name = name + cu.creator = admin + cu.updater = admin + end + + tier_params = { + high: [0.95, (0.78..1.00)], + mid: [0.85, (0.50..0.80)], + low: [0.70, (0.20..0.55)], + sparse: [0.40, (0.30..0.70)] + } + submission_probability, grade_fraction_range = tier_params[tier] + + all_assessments.each do |a| + next if rng.rand > submission_probability + + fraction = rng.rand(grade_fraction_range) + earned = (fraction * a[:max]).round.clamp(0, a[:max]) + + submission = Course::Assessment::Submission.new( + assessment: a[:record], + creator: user, + updater: user + ) + submission.experience_points_record.course_user = course_user + submission.experience_points_record.creator = user + submission.experience_points_record.updater = user + + answers = a[:record].questions.attempt(submission) + answers.each { |ans| ans.current_answer = true } + submission.answers = answers + submission.save! + + submission.finalise! + submission.save! + + submission.answers.reload.each do |answer| + answer.grade = earned + answer.grader = admin + answer.graded_at = Time.zone.now + answer.save! + end + + submission.mark! + submission.save! + submission.publish! + submission.save! + end + + print '.' + end + + puts "\nCreated #{student_profiles.size} students with submissions." + puts "\nDone! Log in as test@example.org and visit:" + puts " http://localhost:3000/courses/#{course.id}/gradebook" + end + end +end diff --git a/lib/tasks/coursemology/seed_gradebook.rake b/lib/tasks/coursemology/seed_gradebook.rake new file mode 100644 index 00000000000..ef7ffa60fc0 --- /dev/null +++ b/lib/tasks/coursemology/seed_gradebook.rake @@ -0,0 +1,246 @@ +# frozen_string_literal: true + +# Usage: bundle exec rails coursemology:seed_gradebook +# Creates a demo course with 3 categories, multiple assessments, and 20 students +# with varied grades for demonstrating the gradebook. + +namespace :coursemology do + task seed_gradebook: 'db:seed' do + require 'factory_bot_rails' + + ActsAsTenant.with_tenant(Instance.default) do + admin = User::Email.find_by_email('test@example.org').user + User.stamper = admin + + course = Course.create!( + title: 'Gradebook Demo Course', + published: true, + enrollable: false, + creator: admin, + updater: admin + ) + + puts "Created course: #{course.title} (id=#{course.id})" + + # ── Categories, tabs, assessments ────────────────────────────────────── + + structure = [ + { + title: 'Missions', + tabs: [ + { + title: 'Assignments', + assessments: [ + { title: 'Mission 1 — Variables & Control Flow', max: 20 }, + { title: 'Mission 2 — Functions & Recursion', max: 20 }, + { title: 'Mission 3 — Data Structures', max: 25 }, + { title: 'Mission 4 — Sorting Algorithms', max: 25 } + ] + }, + { + title: 'Optional Missions', + assessments: [ + { title: 'Optional Mission A — Regex', max: 10 }, + { title: 'Optional Mission B — Concurrency', max: 10 } + ] + } + ] + }, + { + title: 'Tutorials', + tabs: [ + { + title: 'Problem Sets', + assessments: [ + { title: 'Problem Set 1', max: 10 }, + { title: 'Problem Set 2', max: 10 }, + { title: 'Problem Set 3', max: 10 }, + { title: 'Problem Set 4', max: 10 }, + { title: 'Problem Set 5', max: 10 } + ] + }, + { + title: 'Recitation Quizzes', + assessments: [ + { title: 'Recitation Quiz 1', max: 5 }, + { title: 'Recitation Quiz 2', max: 5 }, + { title: 'Recitation Quiz 3', max: 5 } + ] + } + ] + }, + { + title: 'Projects', + tabs: [ + { + title: 'Milestones', + assessments: [ + { title: 'Project Milestone 1 — Proposal', max: 15 }, + { title: 'Project Milestone 2 — Prototype', max: 20 }, + { title: 'Project Milestone 3 — Final Report', max: 30 } + ] + } + ] + } + ] + + all_assessments = [] + start_at = 1.month.ago + default_category = course.assessment_categories.first + + structure.each_with_index do |cat_def, cat_i| + category = if cat_i == 0 + default_category.update!(title: cat_def[:title], weight: 1) + default_category + else + course.assessment_categories.create!( + title: cat_def[:title], + weight: cat_i + 1, + creator: admin, + updater: admin + ) + end + + existing_tab = cat_i == 0 ? category.tabs.first : nil + + cat_def[:tabs].each_with_index do |tab_def, tab_i| + tab = if existing_tab && tab_i == 0 + existing_tab.update!(title: tab_def[:title], weight: 1) + existing_tab + else + category.tabs.create!( + title: tab_def[:title], + weight: tab_i + 1, + creator: admin, + updater: admin + ) + end + + tab_def[:assessments].each_with_index do |a_def, a_i| + assessment = Course::Assessment.new( + course: course, + tab: tab, + title: a_def[:title], + description: '', + base_exp: 0, + autograded: false, + start_at: start_at + (((cat_i * 10) + (tab_i * 4) + a_i) * 3).days, + creator: admin, + updater: admin + ) + assessment.lesson_plan_item.published = true + + # Build one MCQ question with the desired max grade. + question = FactoryBot.build( + :course_assessment_question_multiple_response, + :multiple_choice, + maximum_grade: a_def[:max] + ) + assessment.question_assessments.build( + question: question.acting_as, + weight: a_i + 1 + ) + assessment.save! + all_assessments << { record: assessment, max: a_def[:max] } + print '.' + end + end + end + puts "\nCreated #{all_assessments.size} assessments across 3 categories." + + # ── Students ─────────────────────────────────────────────────────────── + + student_profiles = [ + # [name, grade_tier] tier: :high | :mid | :low | :sparse + ['Alice Tan', :high], + ['Bob Lim', :high], + ['Carol Chen', :high], + ['David Ng', :high], + ['Eve Zhang', :high], + ['Frank Liu', :mid], + ['Grace Wang', :mid], + ['Henry Kim', :mid], + ['Irene Pham', :mid], + ['James Ho', :mid], + ['Karen Soh', :mid], + ['Leo Bui', :mid], + ['Mia Yeo', :mid], + ['Nathan Koh', :low], + ['Olivia Tan', :low], + ['Paul Wu', :low], + ['Quinn Lee', :low], + ['Rachel Goh', :sparse], + ['Samuel Ong', :sparse], + ['Tina Chan', :sparse] + ] + + rng = Random.new(42) # fixed seed for reproducible grades + + student_profiles.each do |name, tier| + email = "#{name.downcase.gsub(' ', '.')}@gradebook.demo" + user = User::Email.find_by_email(email)&.user + unless user + user = User.new(name: name, email: email, password: 'Coursemology!') + user.skip_confirmation! + user.save! + end + + course_user = CourseUser.find_or_create_by!(course: course, user: user) do |cu| + cu.role = :student + cu.name = name + cu.creator = admin + cu.updater = admin + end + + tier_params = { + high: [0.95, (0.78..1.00)], + mid: [0.85, (0.50..0.80)], + low: [0.70, (0.20..0.55)], + sparse: [0.40, (0.30..0.70)] + } + submission_probability, grade_fraction_range = tier_params[tier] + + all_assessments.each do |a| + next if rng.rand > submission_probability + + fraction = rng.rand(grade_fraction_range) + earned = (fraction * a[:max]).round.clamp(0, a[:max]) + + submission = Course::Assessment::Submission.new( + assessment: a[:record], + creator: user, + updater: user + ) + submission.experience_points_record.course_user = course_user + submission.experience_points_record.creator = user + submission.experience_points_record.updater = user + answers = a[:record].questions.attempt(submission) + answers.each { |ans| ans.current_answer = true } + submission.answers = answers + submission.save! + + submission.finalise! + submission.save! + + submission.answers.reload.each do |answer| + answer.grade = earned + answer.grader = admin + answer.graded_at = Time.zone.now + answer.save! + end + + submission.mark! + submission.save! + submission.publish! + submission.save! + end + + print '.' + end + + puts "\nCreated #{student_profiles.size} students with submissions." + puts "\nDone! Log in as test@example.org and visit:" + puts " http://localhost:3000/courses/#{course.id}/gradebook" + end + end +end diff --git a/spec/controllers/course/admin/gradebook_settings_controller_spec.rb b/spec/controllers/course/admin/gradebook_settings_controller_spec.rb new file mode 100644 index 00000000000..dd4567ceb2b --- /dev/null +++ b/spec/controllers/course/admin/gradebook_settings_controller_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Admin::GradebookSettingsController, type: :controller do + let(:instance) { Instance.default } + with_tenant(:instance) do + let(:course) { create(:course) } + let(:manager) { create(:course_manager, course: course) } + let(:teaching_assistant) { create(:course_teaching_assistant, course: course) } + + describe '#edit' do + context 'as manager' do + render_views + before { controller_sign_in(controller, manager.user) } + + it 'returns settings JSON' do + get :edit, params: { course_id: course.id }, format: :json + expect(response).to have_http_status(:ok) + body = JSON.parse(response.body) + expect(body).to include('weightedViewEnabled' => false) + end + end + + context 'as teaching assistant' do + before { controller_sign_in(controller, teaching_assistant.user) } + + it 'is denied' do + expect do + get :edit, params: { course_id: course.id }, format: :json + end.to raise_error(CanCan::AccessDenied) + end + end + end + + describe '#update' do + context 'as manager' do + render_views + before { controller_sign_in(controller, manager.user) } + + it 'updates weighted_view_enabled and returns 200' do + patch :update, + params: { course_id: course.id, + settings_gradebook_component: { weighted_view_enabled: true } }, + format: :json + expect(response).to have_http_status(:ok) + body = JSON.parse(response.body) + expect(body).to include('weightedViewEnabled' => true) + end + + it 'preserves existing tab gradebook_weights when toggling setting' do + category = create(:course_assessment_category, course: course) + tab = create(:course_assessment_tab, category: category) + tab.update!(gradebook_weight: 50) + + patch :update, + params: { course_id: course.id, + settings_gradebook_component: { weighted_view_enabled: true } }, + format: :json + expect(tab.reload.gradebook_weight).to eq(50) + + patch :update, + params: { course_id: course.id, + settings_gradebook_component: { weighted_view_enabled: false } }, + format: :json + expect(tab.reload.gradebook_weight).to eq(50) + end + end + + context 'as teaching assistant' do + before { controller_sign_in(controller, teaching_assistant.user) } + + it 'is denied' do + expect do + patch :update, + params: { course_id: course.id, + settings_gradebook_component: { weighted_view_enabled: true } }, + format: :json + end.to raise_error(CanCan::AccessDenied) + end + end + end + end +end diff --git a/spec/controllers/course/gradebook_controller_spec.rb b/spec/controllers/course/gradebook_controller_spec.rb new file mode 100644 index 00000000000..01fc03c37d0 --- /dev/null +++ b/spec/controllers/course/gradebook_controller_spec.rb @@ -0,0 +1,332 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::GradebookController, type: :controller do + let(:instance) { Instance.default } + + with_tenant(:instance) do + let(:course) { create(:course) } + let(:student) { create(:course_user, :student, course: course) } + let(:staff) { create(:course_user, :teaching_assistant, course: course) } + + describe '#index' do + render_views + subject { get :index, params: { course_id: course.id }, format: :json } + + context 'when the gradebook component is disabled' do + let(:ta) { create(:course_teaching_assistant, course: course) } + + before do + controller_sign_in(controller, ta.user) + allow(controller).to receive_message_chain('current_component_host.[]').and_return(nil) + end + + it 'raises a component not found error' do + expect { subject }.to raise_error(ComponentNotFoundError) + end + end + + context 'when a student visits the page' do + let(:student) { create(:course_student, course: course) } + before { controller_sign_in(controller, student.user) } + + it { expect { subject }.to raise_error(CanCan::AccessDenied) } + end + + context 'when a teaching assistant visits the page' do + let(:ta) { create(:course_teaching_assistant, course: course) } + before { controller_sign_in(controller, ta.user) } + + it { expect(subject).to be_successful } + + it 'returns all required top-level keys' do + subject + data = JSON.parse(response.body) + %w[categories tabs assessments students submissions].each do |key| + expect(data).to have_key(key), "expected response to have key '#{key}'" + end + end + end + + context 'when a manager visits the page' do + let(:manager) { create(:course_manager, course: course) } + before { controller_sign_in(controller, manager.user) } + + it { expect(subject).to be_successful } + end + + context 'when an observer visits the page' do + let(:observer) { create(:course_observer, course: course) } + before { controller_sign_in(controller, observer.user) } + + it { expect(subject).to be_successful } + end + + context 'with a published assessment and a graded submission' do + let(:ta) { create(:course_teaching_assistant, course: course) } + let(:tab) { course.assessment_categories.first.tabs.first } + let!(:assessment) do + create(:course_assessment_assessment, :published_with_mcq_question, + course: course, tab: tab) + end + let!(:student) { create(:course_student, course: course) } + let!(:submission) do + create(:course_assessment_submission, :graded, + assessment: assessment, creator: student.user) + end + + before do + submission.answers.update_all(grade: 5.0, current_answer: true) + controller_sign_in(controller, ta.user) + end + + it 'includes the assessment in the assessments array' do + subject + data = JSON.parse(response.body) + expect(data['assessments'].map { |a| a['id'] }).to include(assessment.id) + end + + it 'includes the tab in the tabs array' do + subject + data = JSON.parse(response.body) + expect(data['tabs'].map { |t| t['id'] }).to include(tab.id) + end + + it 'includes the category in the categories array' do + subject + data = JSON.parse(response.body) + expect(data['categories'].map { |c| c['id'] }).to include(tab.category.id) + end + + it 'includes the student with email and level in the students array' do + subject + data = JSON.parse(response.body) + student_data = data['students'].find { |s| s['id'] == student.user_id } + expect(student_data).not_to be_nil + expect(student_data).to have_key('email') + expect(student_data).not_to have_key('externalId') + expect(student_data).to have_key('level') + expect(student_data['level']).to be_a(Integer) + end + + it 'returns the correct grade in the submissions array' do + subject + data = JSON.parse(response.body) + sub = data['submissions'].find do |s| + s['studentId'] == student.user_id && s['assessmentId'] == assessment.id + end + expect(sub).not_to be_nil + expect(sub['grade'].to_f).to eq(5.0) + end + + it 'returns a positive maxGrade for the assessment' do + subject + data = JSON.parse(response.body) + assessment_data = data['assessments'].find { |a| a['id'] == assessment.id } + expect(assessment_data['maxGrade'].to_f).to be > 0 + end + end + + context 'with a graded submission where the answer grade is exactly 0' do + let(:ta) { create(:course_teaching_assistant, course: course) } + let(:tab) { course.assessment_categories.first.tabs.first } + let!(:assessment) do + create(:course_assessment_assessment, :published_with_mcq_question, + course: course, tab: tab) + end + let!(:student) { create(:course_student, course: course) } + let!(:submission) do + create(:course_assessment_submission, :graded, + assessment: assessment, creator: student.user) + end + + before do + submission.answers.update_all(grade: 0.0, current_answer: true) + controller_sign_in(controller, ta.user) + end + + it 'returns grade 0 (not null) in the submissions array' do + subject + data = JSON.parse(response.body) + sub = data['submissions'].find do |s| + s['studentId'] == student.user_id && s['assessmentId'] == assessment.id + end + expect(sub).not_to be_nil + expect(sub['grade']).to eq(0.0) + end + end + + context 'with a graded submission where answer grades are null (blank)' do + let(:ta) { create(:course_teaching_assistant, course: course) } + let(:tab) { course.assessment_categories.first.tabs.first } + let!(:assessment) do + create(:course_assessment_assessment, :published_with_mcq_question, + course: course, tab: tab) + end + let!(:student) { create(:course_student, course: course) } + let!(:submission) do + create(:course_assessment_submission, :graded, + assessment: assessment, creator: student.user) + end + + before do + submission.answers.update_all(grade: nil, current_answer: true) + controller_sign_in(controller, ta.user) + end + + it 'returns null grade (not 0) in the submissions array' do + subject + data = JSON.parse(response.body) + sub = data['submissions'].find do |s| + s['studentId'] == student.user_id && s['assessmentId'] == assessment.id + end + expect(sub).not_to be_nil + expect(sub['grade']).to be_nil + end + end + end + + describe 'PATCH update_weights' do + let(:manager) { create(:course_manager, course: course) } + let(:ta) { create(:course_teaching_assistant, course: course) } + let(:student) { create(:course_student, course: course) } + let(:category) { create(:course_assessment_category, course: course) } + let!(:tab1) { create(:course_assessment_tab, category: category) } + let!(:tab2) { create(:course_assessment_tab, category: category) } + + let(:valid_payload) do + { weights: [{ tabId: tab1.id, weight: 60 }, { tabId: tab2.id, weight: 40 }] } + end + + context 'as manager' do + before { controller_sign_in(controller, manager.user) } + + it 'updates and returns 200' do + patch :update_weights, params: { course_id: course.id, **valid_payload }, format: :json + expect(response).to have_http_status(:ok) + expect(tab1.reload.gradebook_weight).to eq(60) + expect(tab2.reload.gradebook_weight).to eq(40) + end + + it 'accepts sum < 100' do + patch :update_weights, + params: { course_id: course.id, weights: [tabId: tab1.id, weight: 30] }, + format: :json + expect(response).to have_http_status(:ok) + end + + it 'accepts sum > 100' do + patch :update_weights, + params: { course_id: course.id, + weights: [{ tabId: tab1.id, weight: 70 }, { tabId: tab2.id, weight: 70 }] }, + format: :json + expect(response).to have_http_status(:ok) + end + + it 'rejects negative with 422 and no partial write' do + tab1.update!(gradebook_weight: 10) + patch :update_weights, + params: { course_id: course.id, + weights: [{ tabId: tab1.id, weight: 50 }, { tabId: tab2.id, weight: -1 }] }, + format: :json + expect(response).to have_http_status(:unprocessable_entity) + expect(tab1.reload.gradebook_weight).to eq(10) + end + + it 'rejects >100 with 422' do + patch :update_weights, + params: { course_id: course.id, weights: [tabId: tab1.id, weight: 101] }, + format: :json + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'rejects foreign tab id with 422' do + other_course = create(:course) + other_tab = create(:course_assessment_tab, + category: create(:course_assessment_category, course: other_course)) + patch :update_weights, + params: { course_id: course.id, weights: [tabId: other_tab.id, weight: 50] }, + format: :json + expect(response).to have_http_status(:unprocessable_entity) + end + end + + context 'as TA' do + before { controller_sign_in(controller, ta.user) } + it 'is denied' do + expect do + patch :update_weights, params: { course_id: course.id, **valid_payload }, format: :json + end.to raise_error(CanCan::AccessDenied) + end + end + + context 'as student' do + before { controller_sign_in(controller, student.user) } + it 'is denied' do + expect do + patch :update_weights, params: { course_id: course.id, **valid_payload }, format: :json + end.to raise_error(CanCan::AccessDenied) + end + end + + context 'when setting is disabled' do + before { controller_sign_in(controller, manager.user) } + + it 'still allows update (storage independent of display)' do + patch :update_weights, params: { course_id: course.id, **valid_payload }, format: :json + expect(response).to have_http_status(:ok) + expect(tab1.reload.gradebook_weight).to eq(60) + end + end + end + + describe 'GET index — weighted view fields' do + render_views + let(:manager) { create(:course_manager, course: course) } + let(:ta) { create(:course_teaching_assistant, course: course) } + let(:category) { create(:course_assessment_category, course: course) } + let!(:tab) { create(:course_assessment_tab, category: category, gradebook_weight: 30) } + let!(:assessment) do + create(:course_assessment_assessment, :published_with_mcq_question, + course: course, tab: tab) + end + + context 'when setting is disabled (default)' do + before { controller_sign_in(controller, manager.user) } + + it 'returns weightedViewEnabled false and omits gradebookWeight per tab' do + get :index, params: { course_id: course.id }, format: :json + body = JSON.parse(response.body) + expect(body['weightedViewEnabled']).to eq(false) + tab_json = body['tabs'].find { |t| t['id'] == tab.id } + expect(tab_json).not_to have_key('gradebookWeight') + end + end + + context 'when setting is enabled' do + before do + ctx = Struct.new(:current_course, :key).new(course, Course::GradebookComponent.key) + Course::Settings::GradebookComponent.new(ctx).weighted_view_enabled = true + course.save! + end + + it 'includes weightedViewEnabled true and gradebookWeight per tab for manager' do + controller_sign_in(controller, manager.user) + get :index, params: { course_id: course.id }, format: :json + body = JSON.parse(response.body) + expect(body['weightedViewEnabled']).to eq(true) + expect(body['canManageWeights']).to eq(true) + tab_json = body['tabs'].find { |t| t['id'] == tab.id } + expect(tab_json['gradebookWeight']).to eq(30) + end + + it 'returns canManageWeights false for TA' do + controller_sign_in(controller, ta.user) + get :index, params: { course_id: course.id }, format: :json + body = JSON.parse(response.body) + expect(body['canManageWeights']).to eq(false) + end + end + end + end +end diff --git a/spec/models/course/assessment/submission_spec.rb b/spec/models/course/assessment/submission_spec.rb index 77936019685..7da9fe68604 100644 --- a/spec/models/course/assessment/submission_spec.rb +++ b/spec/models/course/assessment/submission_spec.rb @@ -873,4 +873,70 @@ def unsubmit_and_save_subject end end end + + describe '.grade_summary' do + let(:instance) { Instance.default } + with_tenant(:instance) do + let(:course) { create(:course) } + let(:student) { create(:course_student, course: course) } + let(:graded_assessment) { create(:assessment, :with_mcq_question, course: course) } + + it 'returns empty array for empty student_ids' do + result = Course::Assessment::Submission.grade_summary( + student_ids: [], + assessment_ids: [graded_assessment.id] + ) + expect(result).to eq([]) + end + + it 'returns empty array for empty assessment_ids' do + result = Course::Assessment::Submission.grade_summary( + student_ids: [student.user_id], + assessment_ids: [] + ) + expect(result).to eq([]) + end + + it 'returns grade data for graded submissions' do + submission = create(:course_assessment_submission, :graded, + assessment: graded_assessment, creator: student.user) + submission.answers.update_all(grade: 5.0, current_answer: true) + + results = Course::Assessment::Submission.grade_summary( + student_ids: [student.user_id], + assessment_ids: [graded_assessment.id] + ) + + expect(results.size).to eq(1) + expect(results.first.student_id).to eq(student.user_id) + expect(results.first.assessment_id).to eq(graded_assessment.id) + expect(results.first.grade.to_f).to eq(5.0) + end + + it 'excludes attempting submissions' do + create(:course_assessment_submission, :attempting, + assessment: graded_assessment, creator: student.user) + + results = Course::Assessment::Submission.grade_summary( + student_ids: [student.user_id], + assessment_ids: [graded_assessment.id] + ) + expect(results).to be_empty + end + + it 'only sums answers where current_answer is true' do + submission = create(:course_assessment_submission, :graded, + assessment: graded_assessment, creator: student.user) + submission.answers.update_all(grade: 3.0, current_answer: true) + # Mark all answers as non-current — grade_summary must return nothing + submission.answers.update_all(current_answer: false) + + results = Course::Assessment::Submission.grade_summary( + student_ids: [student.user_id], + assessment_ids: [graded_assessment.id] + ) + expect(results).to be_empty + end + end + end end diff --git a/spec/models/course/assessment/tab_spec.rb b/spec/models/course/assessment/tab_spec.rb index b2454314dc9..b546671032f 100644 --- a/spec/models/course/assessment/tab_spec.rb +++ b/spec/models/course/assessment/tab_spec.rb @@ -17,5 +17,82 @@ expect(weights.each_cons(2).all? { |a, b| a <= b }).to be_truthy end end + + describe 'gradebook_weight validation' do + let(:tab) { create(:course_assessment_tab) } + + it 'defaults to 0' do + expect(tab.gradebook_weight).to eq(0) + end + + it 'accepts 0..100 integers' do + [0, 50, 100].each do |w| + tab.gradebook_weight = w + expect(tab).to be_valid + end + end + + it 'rejects negative' do + tab.gradebook_weight = -1 + expect(tab).not_to be_valid + expect(tab.errors[:gradebook_weight]).to be_present + end + + it 'rejects >100' do + tab.gradebook_weight = 101 + expect(tab).not_to be_valid + end + + it 'rejects non-integer' do + tab.gradebook_weight = 50.5 + expect(tab).not_to be_valid + end + + it 'rejects nil' do + tab.gradebook_weight = nil + expect(tab).not_to be_valid + end + end + + describe '.update_gradebook_weights' do + let(:course) { create(:course) } + let(:category) { create(:course_assessment_category, course: course) } + let(:tab1) { create(:course_assessment_tab, category: category) } + let(:tab2) { create(:course_assessment_tab, category: category) } + + it 'updates given tabs' do + described_class.update_gradebook_weights( + course: course, + updates: [{ tab_id: tab1.id, weight: 60 }, { tab_id: tab2.id, weight: 40 }] + ) + expect(tab1.reload.gradebook_weight).to eq(60) + expect(tab2.reload.gradebook_weight).to eq(40) + end + + it 'is transactional — invalid value rolls back everything' do + tab1.update!(gradebook_weight: 10) + tab2.update!(gradebook_weight: 20) + expect do + described_class.update_gradebook_weights( + course: course, + updates: [{ tab_id: tab1.id, weight: 50 }, { tab_id: tab2.id, weight: 999 }] + ) + end.to raise_error(ActiveRecord::RecordInvalid) + expect(tab1.reload.gradebook_weight).to eq(10) + expect(tab2.reload.gradebook_weight).to eq(20) + end + + it 'rejects foreign tab_id' do + other_course = create(:course) + other_tab = create(:course_assessment_tab, + category: create(:course_assessment_category, course: other_course)) + expect do + described_class.update_gradebook_weights( + course: course, + updates: [tab_id: other_tab.id, weight: 50] + ) + end.to raise_error(ActiveRecord::RecordNotFound) + end + end end end diff --git a/spec/models/course/assessment_spec.rb b/spec/models/course/assessment_spec.rb index d02e8ec4c49..6d8b480816a 100644 --- a/spec/models/course/assessment_spec.rb +++ b/spec/models/course/assessment_spec.rb @@ -407,5 +407,34 @@ expect(autograded_assessment.skippable).to be_truthy end end + + describe '.max_grades' do + let(:assessment_with_question) do + create(:assessment, :with_mcq_question, course: course) + end + + it 'returns empty hash for empty assessment_ids' do + expect(Course::Assessment.max_grades([])).to eq({}) + end + + it 'returns the sum of maximum_grades for each assessment' do + assessment_with_question + result = Course::Assessment.max_grades([assessment_with_question.id]) + expected = assessment_with_question.questions.sum(:maximum_grade).to_f + expect(result[assessment_with_question.id]).to eq(expected) + end + + it 'excludes assessments not in the given ids' do + other = create(:assessment, :with_mcq_question, course: course) + result = Course::Assessment.max_grades([assessment_with_question.id]) + expect(result.keys).not_to include(other.id) + end + + it 'excludes assessments with no questions from the result' do + empty_assessment = create(:assessment, course: course) + result = Course::Assessment.max_grades([empty_assessment.id]) + expect(result).not_to have_key(empty_assessment.id) + end + end end end diff --git a/spec/models/course/gradebook_ability_spec.rb b/spec/models/course/gradebook_ability_spec.rb new file mode 100644 index 00000000000..79f74f80cc2 --- /dev/null +++ b/spec/models/course/gradebook_ability_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::GradebookAbilityComponent do + let!(:instance) { Instance.default } + with_tenant(:instance) do + subject { Ability.new(user, course, course_user) } + let(:course) { create(:course) } + + context 'when the user is a Course Manager' do + let(:course_user) { create(:course_manager, course: course) } + let(:user) { course_user.user } + it { is_expected.to be_able_to(:read_gradebook, course) } + it { is_expected.to be_able_to(:manage_gradebook_weights, course) } + it { is_expected.to be_able_to(:manage_gradebook_settings, course) } + end + + context 'when the user is a Course Owner' do + let(:course_user) { create(:course_owner, course: course) } + let(:user) { course_user.user } + it { is_expected.to be_able_to(:manage_gradebook_weights, course) } + it { is_expected.to be_able_to(:manage_gradebook_settings, course) } + end + + context 'when the user is a Teaching Assistant' do + let(:course_user) { create(:course_teaching_assistant, course: course) } + let(:user) { course_user.user } + it { is_expected.to be_able_to(:read_gradebook, course) } + it { is_expected.not_to be_able_to(:manage_gradebook_weights, course) } + it { is_expected.not_to be_able_to(:manage_gradebook_settings, course) } + end + + context 'when the user is a Course Student' do + let(:course_user) { create(:course_student, course: course) } + let(:user) { course_user.user } + it { is_expected.not_to be_able_to(:read_gradebook, course) } + it { is_expected.not_to be_able_to(:manage_gradebook_weights, course) } + it { is_expected.not_to be_able_to(:manage_gradebook_settings, course) } + end + end +end diff --git a/spec/models/course/settings/gradebook_component_spec.rb b/spec/models/course/settings/gradebook_component_spec.rb new file mode 100644 index 00000000000..51e602521c5 --- /dev/null +++ b/spec/models/course/settings/gradebook_component_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Settings::GradebookComponent do + let!(:instance) { Instance.default } + with_tenant(:instance) do + let(:course) { create(:course) } + let(:settings) do + context = OpenStruct.new(current_course: course, key: Course::GradebookComponent.key) + Course::Settings::GradebookComponent.new(context) + end + + describe '#weighted_view_enabled' do + it 'returns false by default' do + expect(settings.weighted_view_enabled).to eq(false) + end + end + + describe '#weighted_view_enabled=' do + it 'persists true when set to true' do + settings.weighted_view_enabled = true + course.save! + expect(settings.weighted_view_enabled).to eq(true) + end + + it 'persists false when set to false after being true' do + settings.weighted_view_enabled = true + course.save! + settings.weighted_view_enabled = false + course.save! + expect(settings.weighted_view_enabled).to eq(false) + end + + it 'handles string "1" as truthy' do + settings.weighted_view_enabled = '1' + expect(settings.weighted_view_enabled).to eq(true) + end + + it 'handles string "0" as falsy' do + settings.weighted_view_enabled = '0' + expect(settings.weighted_view_enabled).to eq(false) + end + end + end +end diff --git a/spec/models/instance_spec.rb b/spec/models/instance_spec.rb index ad5f4724f52..970a21e3812 100644 --- a/spec/models/instance_spec.rb +++ b/spec/models/instance_spec.rb @@ -210,6 +210,16 @@ end end + describe '#host' do + context 'when a non-default instance has a nil host attribute' do + it 'raises NoMethodError (nil host has no fallback; RAILS_HOSTNAME is required)' do + instance = build(:instance) + allow(instance).to receive(:read_attribute).with(:host).and_return(nil) + expect { instance.host }.to raise_error(NoMethodError) + end + end + end + let(:instance) { create(:instance) } with_tenant(:instance) do describe '.active_course_count' do