From e7926d5ced07af1cfb71fa58355286855c672ee4 Mon Sep 17 00:00:00 2001 From: MisRob Date: Tue, 18 Apr 2023 15:11:17 +0200 Subject: [PATCH 1/4] Show completion and duration in the side panel --- .../channelEdit/__tests__/utils.spec.js | 223 +++++++++++++++++- .../channelEdit/components/ResourcePanel.vue | 36 ++- .../frontend/channelEdit/utils.js | 156 +++++++++++- .../frontend/shared/constants.js | 3 + 4 files changed, 397 insertions(+), 21 deletions(-) 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 0c505ddbfa..501cc992f4 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,7 +662,6 @@ }, $trs: { questions: 'Questions', - masteryMofN: 'Goal: {m} out of {n}', details: 'Details', completion: 'Completion', showAnswers: 'Show answers', diff --git a/contentcuration/contentcuration/frontend/channelEdit/utils.js b/contentcuration/contentcuration/frontend/channelEdit/utils.js index 4c6b168c45..4cf73cb29d 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/utils.js +++ b/contentcuration/contentcuration/frontend/channelEdit/utils.js @@ -1,6 +1,13 @@ import translator from './translator'; import { RouteNames } from './constants'; -import { AssessmentItemTypes } from 'shared/constants'; +import { createTranslator } from 'shared/i18n'; +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 +157,150 @@ export function assessmentItemKey(assessmentItem) { assessment_id: assessmentItem.assessment_id, }; } + +// TODO: Rename and move to metadata strings translator +const completionStrings = createTranslator('CompletionStrings', { + reference: 'Reference material', + completeDuration: 'When time spent is equal to duration', + allContent: 'Viewed in its entirety', + determinedByResource: 'Determined by the resource', + masteryMofN: 'Goal: {m} out of {n}', +}); + +/** + * 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 = completionStrings.$tr('reference'); + break; + + case CompletionCriteriaModels.TIME: + labels.completion = completionStrings.$tr('completeDuration'); + if (suggestedDuration) { + labels.duration = secondsToHms(suggestedDuration); + } + break; + + case CompletionCriteriaModels.APPROX_TIME: + labels.completion = completionStrings.$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 = completionStrings.$tr('allContent'); + } + break; + + case CompletionCriteriaModels.MASTERY: + if (!masteryModel) { + break; + } + if (masteryModel === MasteryModelsNames.M_OF_N) { + labels.completion = completionStrings.$tr('masteryMofN', { + m: completionThreshold.m, + n: completionThreshold.n, + }); + } else { + labels.completion = constantStrings.$tr(masteryModel); + } + break; + + case CompletionCriteriaModels.DETERMINED_BY_RESOURCE: + labels.completion = completionStrings.$tr('determinedByResource'); + break; + + default: + break; + } + + return labels; +} diff --git a/contentcuration/contentcuration/frontend/shared/constants.js b/contentcuration/contentcuration/frontend/shared/constants.js index 005be9a69c..76995efd1c 100644 --- a/contentcuration/contentcuration/frontend/shared/constants.js +++ b/contentcuration/contentcuration/frontend/shared/constants.js @@ -206,6 +206,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', From 74162ca15e056e878f1afd90079840d526bdb51f Mon Sep 17 00:00:00 2001 From: MisRob Date: Thu, 4 May 2023 14:34:32 +0200 Subject: [PATCH 2/4] Use constant --- .../frontend/channelEdit/components/edit/CompletionOptions.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/CompletionOptions.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/CompletionOptions.vue index 788651098e..66878ba90f 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, From 6a82930f9d2ca1abb540d59af0df7d38e465114f Mon Sep 17 00:00:00 2001 From: MisRob Date: Thu, 4 May 2023 14:51:41 +0200 Subject: [PATCH 3/4] Cleanup strings - move shared strings to metadata strings - remove unused strings --- .../channelEdit/components/ResourcePanel.vue | 3 +- .../components/edit/CompletionOptions.vue | 13 +----- .../frontend/channelEdit/utils.js | 22 +++------- .../contentcuration/frontend/shared/mixins.js | 42 +++++++++++++++++++ 4 files changed, 51 insertions(+), 29 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue b/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue index 501cc992f4..80af74d205 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue @@ -139,7 +139,7 @@ /> - + error {{ $tr('noMasteryModelError') }} @@ -663,7 +663,6 @@ $trs: { questions: 'Questions', details: 'Details', - completion: 'Completion', showAnswers: 'Show answers', questionCount: '{value, number, integer} {value, plural, one {question} other {questions}}', description: 'Description', diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/CompletionOptions.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/CompletionOptions.vue index 66878ba90f..2316ecf92e 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/CompletionOptions.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/CompletionOptions.vue @@ -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 4cf73cb29d..986e21aabe 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/utils.js +++ b/contentcuration/contentcuration/frontend/channelEdit/utils.js @@ -1,6 +1,5 @@ import translator from './translator'; import { RouteNames } from './constants'; -import { createTranslator } from 'shared/i18n'; import { MasteryModelsNames } from 'shared/leUtils/MasteryModels'; import { metadataStrings, constantStrings } from 'shared/mixins'; import { @@ -158,15 +157,6 @@ export function assessmentItemKey(assessmentItem) { }; } -// TODO: Rename and move to metadata strings translator -const completionStrings = createTranslator('CompletionStrings', { - reference: 'Reference material', - completeDuration: 'When time spent is equal to duration', - allContent: 'Viewed in its entirety', - determinedByResource: 'Determined by the resource', - masteryMofN: 'Goal: {m} out of {n}', -}); - /** * 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. @@ -255,18 +245,18 @@ export function getCompletionCriteriaLabels(node) { switch (completionModel) { case CompletionCriteriaModels.REFERENCE: - labels.completion = completionStrings.$tr('reference'); + labels.completion = metadataStrings.$tr('reference'); break; case CompletionCriteriaModels.TIME: - labels.completion = completionStrings.$tr('completeDuration'); + labels.completion = metadataStrings.$tr('completeDuration'); if (suggestedDuration) { labels.duration = secondsToHms(suggestedDuration); } break; case CompletionCriteriaModels.APPROX_TIME: - labels.completion = completionStrings.$tr('completeDuration'); + labels.completion = metadataStrings.$tr('completeDuration'); if (isLongActivity(node)) { labels.duration = metadataStrings.$tr('longActivity'); } else { @@ -276,7 +266,7 @@ export function getCompletionCriteriaLabels(node) { case CompletionCriteriaModels.PAGES: if (completionThreshold === '100%') { - labels.completion = completionStrings.$tr('allContent'); + labels.completion = metadataStrings.$tr('allContent'); } break; @@ -285,7 +275,7 @@ export function getCompletionCriteriaLabels(node) { break; } if (masteryModel === MasteryModelsNames.M_OF_N) { - labels.completion = completionStrings.$tr('masteryMofN', { + labels.completion = metadataStrings.$tr('masteryMofN', { m: completionThreshold.m, n: completionThreshold.n, }); @@ -295,7 +285,7 @@ export function getCompletionCriteriaLabels(node) { break; case CompletionCriteriaModels.DETERMINED_BY_RESOURCE: - labels.completion = completionStrings.$tr('determinedByResource'); + labels.completion = metadataStrings.$tr('determinedByResource'); break; default: diff --git a/contentcuration/contentcuration/frontend/shared/mixins.js b/contentcuration/contentcuration/frontend/shared/mixins.js index 310e69e162..65dbd0603a 100644 --- a/contentcuration/contentcuration/frontend/shared/mixins.js +++ b/contentcuration/contentcuration/frontend/shared/mixins.js @@ -212,6 +212,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', From d5c41c65babeea705c34d5930f6f7e47798067f4 Mon Sep 17 00:00:00 2001 From: MisRob Date: Fri, 5 May 2023 10:02:24 +0200 Subject: [PATCH 4/4] Add strings for error messages --- .../frontend/channelEdit/components/ResourcePanel.vue | 5 +++++ contentcuration/contentcuration/frontend/shared/mixins.js | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue b/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue index 80af74d205..c06f99138d 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue @@ -689,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/shared/mixins.js b/contentcuration/contentcuration/frontend/shared/mixins.js index 65dbd0603a..23fa80954d 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: {