diff --git a/contentcuration/contentcuration/frontend/channelEdit/__tests__/utils.spec.js b/contentcuration/contentcuration/frontend/channelEdit/__tests__/utils.spec.js index 9c2683d3e3..1ea5215eb8 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/__tests__/utils.spec.js +++ b/contentcuration/contentcuration/frontend/channelEdit/__tests__/utils.spec.js @@ -1,3 +1,4 @@ +import each from 'jest-each'; import { floatOrIntRegex, getCorrectAnswersIndices, @@ -5,10 +6,13 @@ import { updateAnswersToQuestionType, isImportedContent, importedChannelLink, + secondsToHms, + getCompletionCriteriaLabels, } from '../utils'; import router from '../router'; import { RouteNames } from '../constants'; -import { AssessmentItemTypes } from 'shared/constants'; +import { MasteryModelsNames } from 'shared/leUtils/MasteryModels'; +import { AssessmentItemTypes, CompletionCriteriaModels } from 'shared/constants'; describe('channelEdit utils', () => { describe('imported content', () => { @@ -451,4 +455,221 @@ describe('channelEdit utils', () => { ].forEach(v => expect(floatOrIntRegex.test(v)).toBe(false)); }); }); + + describe(`secondsToHms`, () => { + it(`converts 0 seconds to '00:00'`, () => { + expect(secondsToHms(0)).toBe('00:00'); + }); + + it(`converts seconds to 'mm:ss' when it's less than one hour`, () => { + expect(secondsToHms(3599)).toBe('59:59'); + }); + + it(`converts seconds to 'hh:mm:ss' when it's exactly one hour`, () => { + expect(secondsToHms(3600)).toBe('01:00:00'); + }); + + it(`converts seconds to 'hh:mm:ss' when it's more than one hour`, () => { + expect(secondsToHms(7323)).toBe('02:02:03'); + }); + }); + + describe(`getCompletionCriteriaLabels`, () => { + describe(`for 'reference' completion criteria`, () => { + it(`returns 'Reference material' completion label and empty duration label`, () => { + expect( + getCompletionCriteriaLabels({ + extra_fields: { + options: { + completion_criteria: { + model: CompletionCriteriaModels.REFERENCE, + }, + }, + }, + }) + ).toEqual({ + completion: 'Reference material', + duration: '-', + }); + }); + }); + + describe(`for 'time' completion criteria`, () => { + it(`returns 'When time spent is equal to duration' completion label and human-readable duration label`, () => { + expect( + getCompletionCriteriaLabels({ + extra_fields: { + options: { + completion_criteria: { + model: CompletionCriteriaModels.TIME, + }, + }, + }, + suggested_duration: 3820, + }) + ).toEqual({ + completion: 'When time spent is equal to duration', + duration: '01:03:40', + }); + }); + }); + + describe(`for 'approximate time' completion criteria`, () => { + it(`returns 'When time spent is equal to duration' completion label`, () => { + expect( + getCompletionCriteriaLabels({ + extra_fields: { + options: { + completion_criteria: { + model: CompletionCriteriaModels.APPROX_TIME, + }, + }, + }, + suggested_duration: 1859, + }).completion + ).toBe('When time spent is equal to duration'); + }); + + it(`returns 'Short activity' duration label for a short activity`, () => { + expect( + getCompletionCriteriaLabels({ + extra_fields: { + options: { + completion_criteria: { + model: CompletionCriteriaModels.APPROX_TIME, + }, + }, + }, + suggested_duration: 1860, + }).duration + ).toBe('Short activity'); + }); + + it(`returns 'Long activity' duration label for a long activity`, () => { + expect( + getCompletionCriteriaLabels({ + extra_fields: { + options: { + completion_criteria: { + model: CompletionCriteriaModels.APPROX_TIME, + }, + }, + }, + suggested_duration: 1861, + }).duration + ).toBe('Long activity'); + }); + }); + + describe(`for 'pages' completion criteria`, () => { + it(`returns 'Viewed in its entirety' completion label and empty duration label`, () => { + expect( + getCompletionCriteriaLabels({ + extra_fields: { + options: { + completion_criteria: { + model: CompletionCriteriaModels.PAGES, + threshold: '100%', + }, + }, + }, + }) + ).toEqual({ + completion: 'Viewed in its entirety', + duration: '-', + }); + }); + }); + + describe(`for 'determined by resource' completion criteria`, () => { + it(`returns 'Determined by the resource' completion label and empty duration label`, () => { + expect( + getCompletionCriteriaLabels({ + extra_fields: { + options: { + completion_criteria: { + model: CompletionCriteriaModels.DETERMINED_BY_RESOURCE, + }, + }, + }, + }) + ).toEqual({ + completion: 'Determined by the resource', + duration: '-', + }); + }); + }); + + describe(`for 'mastery' completion criteria`, () => { + it(`returns 'Goal: m out of n' completion label and empty duration label for 'm of n' mastery`, () => { + expect( + getCompletionCriteriaLabels({ + extra_fields: { + options: { + completion_criteria: { + model: CompletionCriteriaModels.MASTERY, + threshold: { + mastery_model: MasteryModelsNames.M_OF_N, + m: 4, + n: 5, + }, + }, + }, + }, + }) + ).toEqual({ + completion: 'Goal: 4 out of 5', + duration: '-', + }); + }); + + it(`returns 'Goal: 100% correct' completion label and empty duration label for 'do all' mastery`, () => { + expect( + getCompletionCriteriaLabels({ + extra_fields: { + options: { + completion_criteria: { + model: CompletionCriteriaModels.MASTERY, + threshold: { + mastery_model: MasteryModelsNames.DO_ALL, + }, + }, + }, + }, + }) + ).toEqual({ + completion: 'Goal: 100% correct', + duration: '-', + }); + }); + + each([ + [2, MasteryModelsNames.NUM_CORRECT_IN_A_ROW_2], + [3, MasteryModelsNames.NUM_CORRECT_IN_A_ROW_3], + [5, MasteryModelsNames.NUM_CORRECT_IN_A_ROW_5], + [10, MasteryModelsNames.NUM_CORRECT_IN_A_ROW_10], + ]).it( + `returns 'Goal: %s in a row' completion label and empty duration label for '%s' mastery`, + (num, mastery_model) => { + expect( + getCompletionCriteriaLabels({ + extra_fields: { + options: { + completion_criteria: { + model: CompletionCriteriaModels.MASTERY, + threshold: { + mastery_model, + }, + }, + }, + }, + }) + ).toEqual({ + completion: `Goal: ${num} in a row`, + duration: '-', + }); + } + ); + }); + }); }); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue b/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue index 07846be710..0139f85181 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue @@ -138,15 +138,21 @@ showEachActivityIcon /> - - + + + error {{ $tr('noMasteryModelError') }} - {{ masteryCriteria }} + {{ completion }} + + + {{ duration }} + + f.preset.order); }, @@ -459,19 +471,6 @@ importedChannelName() { return this.node.original_channel_name; }, - masteryCriteria() { - if (!this.isExercise) { - return ''; - } - - const masteryModel = this.node.extra_fields.mastery_model; - if (!masteryModel) { - return this.defaultText; - } else if (masteryModel === MasteryModelsNames.M_OF_N) { - return this.$tr('masteryMofN', this.node.extra_fields); - } - return this.translateConstant(masteryModel); - }, sortedTags() { return orderBy(this.node.tags, ['count'], ['desc']); }, @@ -663,9 +662,7 @@ }, $trs: { questions: 'Questions', - masteryMofN: 'Goal: {m} out of {n}', details: 'Details', - completion: 'Completion', showAnswers: 'Show answers', questionCount: '{value, number, integer} {value, plural, one {question} other {questions}}', description: 'Description', @@ -692,6 +689,11 @@ fileSize: 'Size', // Validation strings + /* eslint-disable kolibri/vue-no-unused-translations */ + noLearningActivityError: 'Missing learning activity', + noCompletionCriteriaError: 'Missing completion criteria', + noDurationError: 'Missing duration', + /* eslint-enable kolibri/vue-no-unused-translations */ noLicenseError: 'Missing license', noCopyrightHolderError: 'Missing copyright holder', noLicenseDescriptionError: 'Missing license description', diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/CompletionOptions.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/CompletionOptions.vue index 446fd3ac8b..6840c106bc 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/CompletionOptions.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/CompletionOptions.vue @@ -109,6 +109,7 @@ CompletionDropdownMap, DurationDropdownMap, nonUniqueValue, + SHORT_LONG_ACTIVITY_MIDPOINT, } from 'shared/constants'; import Checkbox from 'shared/views/form/Checkbox'; import { MasteryModelsNames } from 'shared/leUtils/MasteryModels'; @@ -123,7 +124,6 @@ const DEFAULT_SHORT_ACTIVITY = 600; const DEFAULT_LONG_ACTIVITY = 3000; - const SHORT_LONG_ACTIVITY_MIDPOINT = 1860; const defaultCompletionCriteriaModels = { [ContentKindsNames.VIDEO]: CompletionCriteriaModels.TIME, @@ -393,7 +393,7 @@ showCorrectCompletionOptions() { if (this.kind) { return CompletionOptionsDropdownMap[this.kind].map(model => ({ - text: this.$tr(model), + text: this.translateMetadataString(model), value: CompletionDropdownMap[model], })); } @@ -402,7 +402,7 @@ selectableDurationOptions() { return [ { - text: this.$tr(DurationDropdownMap.EXACT_TIME), + text: this.translateMetadataString(DurationDropdownMap.EXACT_TIME), value: 'exactTime', }, { @@ -514,15 +514,6 @@ }, }, $trs: { - /* eslint-disable kolibri/vue-no-unused-translations */ - allContent: 'Viewed in its entirety', - completeDuration: 'When time spent is equal to duration', - determinedByResource: 'Determined by the resource', - goal: 'When goal is met', - practiceQuiz: 'Practice quiz', - reference: 'Reference material', - /* eslint-enable */ - exactTime: 'Time to complete', referenceHint: 'Progress will not be tracked on reference material unless learners mark it as complete', learnersCanMarkComplete: 'Allow learners to mark as complete', diff --git a/contentcuration/contentcuration/frontend/channelEdit/utils.js b/contentcuration/contentcuration/frontend/channelEdit/utils.js index 52e7426975..a475d4e67f 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/utils.js +++ b/contentcuration/contentcuration/frontend/channelEdit/utils.js @@ -1,6 +1,12 @@ import translator from './translator'; import { RouteNames } from './constants'; -import { AssessmentItemTypes } from 'shared/constants'; +import { MasteryModelsNames } from 'shared/leUtils/MasteryModels'; +import { metadataStrings, constantStrings } from 'shared/mixins'; +import { + AssessmentItemTypes, + CompletionCriteriaModels, + SHORT_LONG_ACTIVITY_MIDPOINT, +} from 'shared/constants'; /** * Get correct answer index/indices out of an array of answer objects. @@ -150,3 +156,141 @@ export function assessmentItemKey(assessmentItem) { assessment_id: assessmentItem.assessment_id, }; } + +/** + * Converts a value in seconds to a human-readable format. + * If the value is greater than or equal to one hour, the format will be hh:mm:ss. + * If the value is less than one hour, the format will be mm:ss. + * + * @param {Number} seconds - The value in seconds to be converted. + * @returns {String} The value in human-readable format. + */ +export function secondsToHms(seconds) { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds - hours * 3600) / 60); + const remainingSeconds = seconds - hours * 3600 - minutes * 60; + + let hms = ''; + if (hours > 0) { + hms += hours.toString().padStart(2, '0') + ':'; + } + hms += minutes.toString().padStart(2, '0') + ':' + remainingSeconds.toString().padStart(2, '0'); + return hms; +} + +/** + * Gathers data from a content node related to its completion. + * + * @param {Object} node + * @returns {Object} { completionModel, completionThreshold, masteryModel, + * suggestedDurationType, suggestedDuration} + */ +export function getCompletionDataFromNode(node) { + if (!node) { + return; + } + + const { model, threshold } = node.extra_fields?.options?.completion_criteria || {}; + const masteryModel = threshold?.mastery_model; + const suggestedDurationType = node.extra_fields?.suggested_duration_type; + const suggestedDuration = node.suggested_duration; + + return { + completionModel: model, + completionThreshold: threshold, + masteryModel, + suggestedDurationType, + suggestedDuration, + }; +} + +/** + * @param {Object} node + * @returns {Boolean, undefined} + */ +export function isLongActivity(node) { + if (!node) { + return; + } + const { completionModel, suggestedDuration } = getCompletionDataFromNode(node); + return ( + completionModel === CompletionCriteriaModels.APPROX_TIME && + suggestedDuration > SHORT_LONG_ACTIVITY_MIDPOINT + ); +} + +/** + * Determines completion and duration labels from completion + * criteria and related of a content node. + * + * @param {Object} node + * @returns {Object} { completion, duration } + */ +export function getCompletionCriteriaLabels(node) { + if (!node) { + return; + } + + const { + completionModel, + completionThreshold, + masteryModel, + suggestedDuration, + } = getCompletionDataFromNode(node); + + const labels = { + completion: '-', + duration: '-', + }; + + switch (completionModel) { + case CompletionCriteriaModels.REFERENCE: + labels.completion = metadataStrings.$tr('reference'); + break; + + case CompletionCriteriaModels.TIME: + labels.completion = metadataStrings.$tr('completeDuration'); + if (suggestedDuration) { + labels.duration = secondsToHms(suggestedDuration); + } + break; + + case CompletionCriteriaModels.APPROX_TIME: + labels.completion = metadataStrings.$tr('completeDuration'); + if (isLongActivity(node)) { + labels.duration = metadataStrings.$tr('longActivity'); + } else { + labels.duration = metadataStrings.$tr('shortActivity'); + } + break; + + case CompletionCriteriaModels.PAGES: + if (completionThreshold === '100%') { + labels.completion = metadataStrings.$tr('allContent'); + } + break; + + case CompletionCriteriaModels.MASTERY: + if (!masteryModel) { + break; + } + if (masteryModel === MasteryModelsNames.M_OF_N) { + labels.completion = metadataStrings.$tr('masteryMofN', { + m: completionThreshold.m, + n: completionThreshold.n, + }); + } else { + labels.completion = constantStrings.$tr(masteryModel); + } + break; + + case CompletionCriteriaModels.DETERMINED_BY_RESOURCE: + labels.completion = metadataStrings.$tr('determinedByResource'); + break; + + default: + break; + } + + return labels; +} diff --git a/contentcuration/contentcuration/frontend/shared/constants.js b/contentcuration/contentcuration/frontend/shared/constants.js index 65c941c7e5..fb802c47b3 100644 --- a/contentcuration/contentcuration/frontend/shared/constants.js +++ b/contentcuration/contentcuration/frontend/shared/constants.js @@ -207,6 +207,9 @@ export const AccessibilityCategoriesMap = { audio: ['CAPTIONS_SUBTITLES'], }; +// an activity with duration longer than this value is considered long, otherwise short +export const SHORT_LONG_ACTIVITY_MIDPOINT = 1860; + export const CompletionDropdownMap = { allContent: 'allContent', completeDuration: 'completeDuration', diff --git a/contentcuration/contentcuration/frontend/shared/mixins.js b/contentcuration/contentcuration/frontend/shared/mixins.js index 86b217fc28..48c114b8bf 100644 --- a/contentcuration/contentcuration/frontend/shared/mixins.js +++ b/contentcuration/contentcuration/frontend/shared/mixins.js @@ -52,6 +52,12 @@ const statusStrings = createTranslator('StatusStrings', { noStorageError: 'Not enough space', }); +export const validationStrings = createTranslator('ValidationStrings', { + message: 'Missing required information', + context: + 'An error message displayed when some information required before publishing a channel is missing, for example when a resource has no license set.', +}); + export const fileStatusMixin = { mixins: [fileSizeMixin], computed: { @@ -212,6 +218,48 @@ export const metadataStrings = createTranslator('CommonMetadataStrings', { message: 'Category', context: 'A title for the metadata that explains the subject matter of an activity', }, + // Completion criteria types + reference: { + message: 'Reference material', + context: + 'One of the completion criteria types. Progress made on a resource with this criteria is not tracked.', + }, + completeDuration: { + message: 'When time spent is equal to duration', + context: + 'One of the completion criteria types. A resource with this criteria is considered complete when learners spent given time studying it.', + }, + exactTime: { + message: 'Time to complete', + context: + 'One of the completion criteria types. A subset of "When time spent is equal to duration". For example, for an audio resource with this criteria, learnes need to hear the whole length of audio for the resource to be considered complete.', + }, + allContent: { + message: 'Viewed in its entirety', + context: + 'One of the completion criteria types. A resource with this criteria is considered complete when learners studied it all, for example they saw all pages of a document.', + }, + determinedByResource: { + message: 'Determined by the resource', + context: + 'One of the completion criteria types. Typically used for embedded html5/h5p resources that contain their own completion criteria, for example reaching a score in an educational game.', + }, + masteryMofN: { + message: 'Goal: {m} out of {n}', + context: + 'One of the completion criteria types specific to exercises. An exercise with this criteria is considered complete when learners answered m questions out of n correctly.', + }, + goal: { + message: 'When goal is met', + context: + 'One of the completion criteria types specific to exercises. An exercise with this criteria is considered complete when learners reached a given goal, for example 100% correct.', + }, + practiceQuiz: { + message: 'Practice quiz', + context: + 'One of the completion criteria types specific to exercises. An exercise with this criteria represents a quiz.', + }, + // Learning Activities all: { message: 'All',