diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/ActivityDuration.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/ActivityDuration.vue new file mode 100644 index 0000000000..debdbeadc3 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/ActivityDuration.vue @@ -0,0 +1,188 @@ + + + + + + {{ defaultUploadTime }} + + + + + + + + + + + {{ $tr('notOptionalLabel') }} + + + {{ $tr('optionalLabel') }} + + + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/CompletionOptions.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/CompletionOptions.vue new file mode 100644 index 0000000000..8a1571b454 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/CompletionOptions.vue @@ -0,0 +1,816 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ $tr('referenceHint') }} + + + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/DetailsTabView.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/DetailsTabView.vue index 3fd74720d6..327d6faec3 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/DetailsTabView.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/DetailsTabView.vue @@ -54,11 +54,7 @@ @focus="trackClick('Description')" /> - + - + - {{ $tr('assessmentHeader') }} + {{ $tr('assessmentOptionsLabel') }} - - - - + + - - + + + + {{ $tr('completionLabel') }} + + + + + + @@ -162,7 +158,7 @@ {{ $tr('thumbnailHeader') }} - + - - author = e.srcElement.value" + @input.native="(e) => (author = e.srcElement.value)" @input="author = $event" @focus="trackClick('Author')" > @@ -274,7 +268,7 @@ autoSelectFirst box :value="provider && provider.toString()" - @input.native="e => provider = e.srcElement.value" + @input.native="(e) => (provider = e.srcElement.value)" @input="provider = $event" @focus="trackClick('Provider')" > @@ -295,7 +289,7 @@ :placeholder="getPlaceholder('aggregator')" box :value="aggregator && aggregator.toString()" - @input.native="e => aggregator = e.srcElement.value" + @input.native="(e) => (aggregator = e.srcElement.value)" @input="aggregator = $event" @focus="trackClick('Aggregator')" > @@ -332,7 +326,7 @@ :readonly="disableAuthEdits" box :value="copyright_holder && copyright_holder.toString()" - @input.native="e => copyright_holder = e.srcElement.value" + @input.native="(e) => (copyright_holder = e.srcElement.value)" @input="copyright_holder = $event" @focus="trackClick('Copyright holder')" /> @@ -375,6 +369,7 @@ import ResourcesNeededOptions from './ResourcesNeededOptions.vue'; import LearningActivityOptions from './LearningActivityOptions.vue'; import CategoryOptions from './CategoryOptions.vue'; + import CompletionOptions from './CompletionOptions.vue'; import { getTitleValidators, @@ -385,11 +380,10 @@ import LanguageDropdown from 'shared/views/LanguageDropdown'; import HelpTooltip from 'shared/views/HelpTooltip'; import LicenseDropdown from 'shared/views/LicenseDropdown'; - import MasteryDropdown from 'shared/views/MasteryDropdown'; import VisibilityDropdown from 'shared/views/VisibilityDropdown'; import Checkbox from 'shared/views/form/Checkbox'; import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; - import { NEW_OBJECT, FeatureFlagKeys, ContentModalities } from 'shared/constants'; + import { NEW_OBJECT, FeatureFlagKeys } from 'shared/constants'; import { validate as validateCompletionCriteria } from 'shared/leUtils/CompletionCriteria'; import { constantsTranslationMixin, metadataTranslationMixin } from 'shared/mixins'; @@ -435,7 +429,9 @@ * - `learner_needs` (resources needed) * - `accessibility_labels` (accessibility options) * - `learning_activities` (learning activities) + * - `categories` (categories) */ + function generateNestedNodesGetterSetter(key) { return { get() { @@ -458,7 +454,6 @@ LanguageDropdown, HelpTooltip, LicenseDropdown, - MasteryDropdown, VisibilityDropdown, FileUpload, SubtitlesList, @@ -469,6 +464,7 @@ ResourcesNeededOptions, LearningActivityOptions, CategoryOptions, + CompletionOptions, }, mixins: [constantsTranslationMixin, metadataTranslationMixin], props: { @@ -552,27 +548,7 @@ resourcesNeeded: generateNestedNodesGetterSetter('learner_needs'), contentLearningActivities: generateNestedNodesGetterSetter('learning_activities'), categories: generateNestedNodesGetterSetter('categories'), - mastery_model() { - return this.getExtraFieldsValueFromNodes('mastery_model'); - }, - m() { - return this.getExtraFieldsValueFromNodes('m'); - }, - n() { - return this.getExtraFieldsValueFromNodes('n'); - }, - masteryModelItem: { - get() { - return { - mastery_model: this.mastery_model, - m: this.m, - n: this.n, - }; - }, - set(value) { - this.updateExtraFields(value); - }, - }, + learnerManaged: generateGetterSetter('learner_managed'), license() { return this.getValueFromNodes('license'); }, @@ -590,9 +566,6 @@ this.update(value); }, }, - extra_fields() { - return this.getValueFromNodes('extra_fields'); - }, thumbnail: { get() { return this.nodeFiles.find(f => f.preset.thumbnail); @@ -602,34 +575,37 @@ }, }, thumbnailEncoding: generateGetterSetter('thumbnail_encoding'), - channelQuiz: { + completionAndDuration: { get() { - const options = this.getExtraFieldsValueFromNodes('options') || {}; - return options.modality === ContentModalities.QUIZ; - }, - set(val) { - const options = { modality: val ? ContentModalities.QUIZ : null }; - this.updateExtraFields({ options }); - }, - }, - // TODO remove eslint disable when `completionCriteria` is utilized - /* eslint-disable-next-line kolibri/vue-no-unused-properties */ - completionCriteria: { - get() { - const options = this.getExtraFieldsValueFromNodes('options') || {}; - return options.completion_criteria || {}; + const { completion_criteria, modality } = + this.getExtraFieldsValueFromNodes('options') || {}; + const suggested_duration_type = this.getExtraFieldsValueFromNodes( + 'suggested_duration_type' + ); + const suggested_duration = this.getValueFromNodes('suggested_duration'); + return { + suggested_duration, + suggested_duration_type, + modality, + ...(completion_criteria || {}), + }; }, - set(completion_criteria) { - // TODO Remove validation if unnecessary after implementing `completionCriteria` - if (validateCompletionCriteria(completion_criteria)) { + set({ completion_criteria, suggested_duration, suggested_duration_type, modality }) { + if (validateCompletionCriteria(completion_criteria, this.firstNode.kind)) { const options = { completion_criteria }; this.updateExtraFields({ options }); } else { console.warn('Invalid completion criteria', [...validateCompletionCriteria.errors]); } + const options = { completion_criteria, modality }; + if (modality) { + options.modality = modality; + } + this.updateExtraFields({ options }); + this.updateExtraFields({ suggested_duration_type }); + this.update({ suggested_duration }); }, }, - /* COMPUTED PROPS */ disableAuthEdits() { return this.nodes.some(node => node.freeze_authoring_data); @@ -665,6 +641,15 @@ nodeFiles() { return (this.firstNode && this.getContentNodeFiles(this.firstNode.id)) || []; }, + fileDuration() { + if (this.firstNode.kind === 'audio' || this.firstNode.kind === 'video') { + return this.nodeFiles.filter( + file => file.file_format === 'mp4' || file.file_format === 'mp3' + )[0].duration; + } else { + return null; + } + }, videoSelected() { return this.oneSelected && this.firstNode.kind === 'video'; }, @@ -674,6 +659,9 @@ allowChannelQuizzes() { return this.$store.getters.hasFeatureEnabled(FeatureFlagKeys.channel_quizzes); }, + isDocument() { + return this.firstNode.kind === 'document'; + }, }, watch: { nodes: { @@ -800,7 +788,6 @@ basicInfoHeader: 'Basic information', audienceHeader: 'Audience', sourceHeader: 'Source', - assessmentHeader: 'Assessment options', thumbnailHeader: 'Thumbnail', titleLabel: 'Title', languageHelpText: 'Leave blank to use the folder language', @@ -819,8 +806,10 @@ descriptionLabel: 'Description', tagsLabel: 'Tags', noTagsFoundText: 'No results found for "{text}". Press \'Enter\' key to create a new tag', + assessmentOptionsLabel: 'Assessment options', randomizeQuestionLabel: 'Randomize question order for learners', - channelQuizzesLabel: 'Allow as a channel quiz', + completionLabel: 'Completion', + learnersCanMarkComplete: 'Allow learners to mark as complete', }, }; diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/MasteryCriteriaGoal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/MasteryCriteriaGoal.vue new file mode 100644 index 0000000000..f56ba17306 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/MasteryCriteriaGoal.vue @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/MasteryDropdown.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/MasteryCriteriaMofNFields.vue similarity index 63% rename from contentcuration/contentcuration/frontend/shared/views/MasteryDropdown.vue rename to contentcuration/contentcuration/frontend/channelEdit/components/edit/MasteryCriteriaMofNFields.vue index 4178418e2d..9ba5a5cf2e 100644 --- a/contentcuration/contentcuration/frontend/shared/views/MasteryDropdown.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/MasteryCriteriaMofNFields.vue @@ -1,36 +1,6 @@ - - - - - {{ $tr('exerciseDescripiton') }} - {{ $tr('masteryDescripiton') }} - - {{ translateConstant(item.value) }} - - - {{ translateConstant(item.value + '_description') }} - - - - - + / - + import { - getMasteryModelValidators, getMasteryModelMValidators, getMasteryModelNValidators, translateValidator, - } from '../utils/validation'; - import MasteryModels, { - MasteryModelsList, - MasteryModelsNames, - } from 'shared/leUtils/MasteryModels'; - import InfoModal from 'shared/views/InfoModal.vue'; + } from '../../../shared/utils/validation'; + import MasteryModels from 'shared/leUtils/MasteryModels'; // MasteryModelsNames, // MasteryModelsList, import { constantsTranslationMixin } from 'shared/mixins'; export default { - name: 'MasteryDropdown', - components: { - InfoModal, - }, + name: 'MasteryCriteriaMofNFields', mixins: [constantsTranslationMixin], props: { value: { @@ -110,18 +70,11 @@ ); }, }, - placeholder: { - type: String, - }, - required: { - type: Boolean, - default: true, - }, - readonly: { + showMofN: { type: Boolean, default: false, }, - disabled: { + readonly: { type: Boolean, default: false, }, @@ -141,14 +94,6 @@ }, }, computed: { - masteryModel: { - get() { - return this.value && this.value.mastery_model; - }, - set(mastery_model) { - this.handleInput({ mastery_model }); - }, - }, mValue: { get() { return this.value && this.value.m; @@ -169,18 +114,6 @@ this.handleInput(value < this.mValue ? { m: value, n: value } : { n: value }); }, }, - masteryCriteria() { - return MasteryModelsList.map(model => ({ - text: this.translateConstant(model), - value: model, - })); - }, - showMofN() { - return this.masteryModel === MasteryModelsNames.M_OF_N; - }, - masteryRules() { - return this.required ? getMasteryModelValidators().map(translateValidator) : []; - }, mRules() { return this.mRequired ? getMasteryModelMValidators(this.nValue).map(translateValidator) @@ -223,12 +156,6 @@ }, }, $trs: { - labelText: 'Mastery criteria', - exerciseHeader: 'About exercises', - exerciseDescripiton: - 'Exercises contain a set of interactive questions that a learner can engage with in Kolibri. Learners receive instant feedback for each answer (correct or incorrect). Kolibri will display available questions in an exercise until the learner achieves mastery.', - masteryDescripiton: - 'Kolibri marks an exercise as "completed" when the mastery criteria is met. Here are the different types of mastery criteria for an exercise:', mHint: 'Correct answers needed', nHint: 'Recent answers', }, diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/actions.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/actions.js index c32ef53fc3..068e809898 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/actions.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/actions.js @@ -181,6 +181,7 @@ export function createContentNode(context, { parent, kind, ...payload }) { learner_needs: {}, learning_activities: {}, categories: {}, + suggested_duration: 0, ...payload, }; @@ -218,6 +219,7 @@ function generateContentNodeData({ learner_needs = NOVALUE, learning_activities = NOVALUE, categories = NOVALUE, + suggested_duration = NOVALUE, } = {}) { const contentNodeData = {}; if (title !== NOVALUE) { @@ -271,7 +273,9 @@ function generateContentNodeData({ if (categories !== NOVALUE) { contentNodeData.categories = categories; } - + if (suggested_duration !== NOVALUE) { + contentNodeData.suggested_duration = suggested_duration; + } if (extra_fields !== NOVALUE) { contentNodeData.extra_fields = contentNodeData.extra_fields || {}; if (extra_fields.mastery_model) { @@ -289,6 +293,9 @@ function generateContentNodeData({ if (extra_fields.options) { contentNodeData.extra_fields.options = extra_fields.options; } + if (extra_fields.suggested_duration_type) { + contentNodeData.extra_fields.suggested_duration_type = extra_fields.suggested_duration_type; + } } if (prerequisite !== NOVALUE) { contentNodeData.prerequisite = prerequisite; diff --git a/contentcuration/contentcuration/frontend/shared/constants.js b/contentcuration/contentcuration/frontend/shared/constants.js index 100c8a579d..fbc0b23b1f 100644 --- a/contentcuration/contentcuration/frontend/shared/constants.js +++ b/contentcuration/contentcuration/frontend/shared/constants.js @@ -1,5 +1,6 @@ import invert from 'lodash/invert'; import Subjects from 'kolibri-constants/labels/Subjects'; +import CompletionCriteria from 'kolibri-constants/CompletionCriteria'; import featureFlagsSchema from 'static/feature_flags.json'; export { default as LearningActivities } from 'kolibri-constants/labels/LearningActivities'; @@ -11,6 +12,7 @@ export { default as ContentLevels } from 'kolibri-constants/labels/Levels'; export { default as ResourcesNeededTypes } from 'kolibri-constants/labels/Needs'; export const CategoriesLookup = invert(Subjects); +export const CompletionCriteriaLookup = invert(CompletionCriteria); export const ContentDefaults = { author: 'author', @@ -166,6 +168,14 @@ export const ValidationErrors = { NO_VALID_PRIMARY_FILES: 'NO_VALID_PRIMARY_FILES', INVALID_COMPLETION_CRITERIA_MODEL: 'INVALID_COMPLETION_CRITERIA_MODEL', LEARNING_ACTIVITY_REQUIRED: 'LEARNING_ACTIVITY_REQUIRED', + DURATION_REQUIRED: 'DURATION_REQUIRED', + ACTIVITY_DURATION_REQUIRED: 'ACTIVITY_DURATION_REQUIRED', + ACTIVITY_DURATION_MIN_FOR_SHORT_ACTIVITY: 'ACTIVITY_DURATION_MIN_FOR_SHORT_ACTIVITY', + ACTIVITY_DURATION_MAX_FOR_SHORT_ACTIVITY: 'ACTIVITY_DURATION_MAX_FOR_SHORT_ACTIVITY', + ACTIVITY_DURATION_MIN_FOR_LONG_ACTIVITY: 'ACTIVITY_DURATION_MIN_FOR_LONG_ACTIVITY', + ACTIVITY_DURATION_MAX_FOR_LONG_ACTIVITY: 'ACTIVITY_DURATION_MAX_FOR_LONG_ACTIVITY', + ACTIVITY_DURATION_MIN_REQUIREMENT: 'ACTIVITY_DURATION_MIN_REQUIREMENT', + ACTIVITY_DURATION_TOO_LONG: 'ACTIVITY_DURATION_TOO_LONG', ...fileErrors, }; @@ -190,3 +200,19 @@ export const AccessibilityCategoriesMap = { exercise: ['ALT_TEXT'], html5: ['ALT_TEXT', 'HIGH_CONTRAST'], }; + +export const CompletionDropdownMap = { + allContent: 'allContent', + completeDuration: 'completeDuration', + determinedByResource: 'determinedByResource', + goal: 'goal', + practiceQuiz: 'practiceQuiz', + reference: 'reference', +}; + +export const DurationDropdownMap = { + EXACT_TIME: 'exactTime', + SHORT_ACTIVITY: 'shortActivity', + LONG_ACTIVITY: 'longActivity', + REFERENCE: 'reference', +}; diff --git a/contentcuration/contentcuration/frontend/shared/translator.js b/contentcuration/contentcuration/frontend/shared/translator.js index 5188bc5f9c..5cf1f5b50f 100644 --- a/contentcuration/contentcuration/frontend/shared/translator.js +++ b/contentcuration/contentcuration/frontend/shared/translator.js @@ -15,6 +15,15 @@ const MESSAGES = { masteryModelNWholeNumber: 'Must be a whole number', confirmLogout: 'Changes you made may not be saved. Are you sure you want to leave this page?', learningActivityRequired: 'Learning activity is required', + durationRequired: 'Duration is required', + activityDurationRequired: 'This field is required', + shortActivityGteOne: 'Short activity must be greater than or equal to 1', + shortActivityLteThirty: 'Short activity must be less than or equal to 30', + longActivityGtThirty: 'Long activity must be greater than 30', + longActivityLteOneTwenty: 'Long activity must be less than or equal to 120', + activityDurationTimeMinRequirement: 'Time must be greater than or equal to 1', + activityDurationTooLongWarning: + 'Please make sure this is the amount of time you want learners to spend on this resource to complete it', }; export default createTranslator('sharedVue', MESSAGES); diff --git a/contentcuration/contentcuration/frontend/shared/utils/validation.js b/contentcuration/contentcuration/frontend/shared/utils/validation.js index 8ae07297e1..d253314a7a 100644 --- a/contentcuration/contentcuration/frontend/shared/utils/validation.js +++ b/contentcuration/contentcuration/frontend/shared/utils/validation.js @@ -113,6 +113,24 @@ function _getErrorMsg(error) { [ValidationErrors.MASTERY_MODEL_N_WHOLE_NUMBER]: translator.$tr('masteryModelNWholeNumber'), [ValidationErrors.MASTERY_MODEL_N_GT_ZERO]: translator.$tr('masteryModelNGtZero'), [ValidationErrors.LEARNING_ACTIVITY_REQUIRED]: translator.$tr('learningActivityRequired'), + [ValidationErrors.DURATION_REQUIRED]: translator.$tr('durationRequired'), + [ValidationErrors.ACTIVITY_DURATION_REQUIRED]: translator.$tr('activityDurationRequired'), + [ValidationErrors.ACTIVITY_DURATION_MIN_FOR_SHORT_ACTIVITY]: translator.$tr( + 'shortActivityGteOne' + ), + [ValidationErrors.ACTIVITY_DURATION_MAX_FOR_SHORT_ACTIVITY]: translator.$tr( + 'shortActivityLteThirty' + ), + [ValidationErrors.ACTIVITY_DURATION_MIN_FOR_LONG_ACTIVITY]: translator.$tr( + 'longActivityGtThirty' + ), + [ValidationErrors.ACTIVITY_DURATION_MAX_FOR_LONG_ACTIVITY]: translator.$tr( + 'longActivityLteOneTwenty' + ), + [ValidationErrors.ACTIVITY_DURATION_MIN_REQUIREMENT]: translator.$tr( + 'activityDurationTimeMinRequirement' + ), + [ValidationErrors.ACTIVITY_DURATION_TOO_LONG]: translator.$tr('activityDurationTooLongWarning'), }; return messages[error]; @@ -143,6 +161,14 @@ export function getLearningActivityValidators() { return [value => Boolean(value.length) || ValidationErrors.LEARNING_ACTIVITY_REQUIRED]; } +export function getCompletionValidators() { + return [value => Boolean(value) || ValidationErrors.COMPLETION_REQUIRED]; +} + +export function getDurationValidators() { + return [value => Boolean(value.length) || ValidationErrors.DURATION_REQUIRED]; +} + export function getLicenseDescriptionValidators() { return [value => Boolean(value && value.trim()) || ValidationErrors.LICENSE_DESCRIPTION_REQUIRED]; } @@ -168,6 +194,30 @@ export function getMasteryModelNValidators() { ]; } +export function getShortActivityDurationValidators() { + return [ + v => v !== '' || ValidationErrors.ACTIVITY_DURATION_REQUIRED, + v => v >= 1 || ValidationErrors.ACTIVITY_DURATION_MIN_FOR_SHORT_ACTIVITY, + v => v <= 30 || ValidationErrors.ACTIVITY_DURATION_MAX_FOR_SHORT_ACTIVITY, + ]; +} + +export function getLongActivityDurationValidators() { + return [ + v => v !== '' || ValidationErrors.ACTIVITY_DURATION_REQUIRED, + v => v > 30 || ValidationErrors.ACTIVITY_DURATION_MIN_FOR_LONG_ACTIVITY, + v => v <= 120 || ValidationErrors.ACTIVITY_DURATION_MAX_FOR_LONG_ACTIVITY, + ]; +} + +export function getActivityDurationValidators() { + return [ + v => v !== '' || ValidationErrors.ACTIVITY_DURATION_REQUIRED, + v => v >= 1 || ValidationErrors.ACTIVITY_DURATION_MIN_REQUIREMENT, + v => v <= 1200 || ValidationErrors.ACTIVITY_DURATION_TOO_LONG, + ]; +} + // Node validation // These functions return an array of error codes export function getNodeTitleErrors(node) { diff --git a/contentcuration/contentcuration/frontend/shared/views/__tests__/masteryDropdown.spec.js b/contentcuration/contentcuration/frontend/shared/views/__tests__/masteryDropdown.spec.js deleted file mode 100644 index 697dbbb90d..0000000000 --- a/contentcuration/contentcuration/frontend/shared/views/__tests__/masteryDropdown.spec.js +++ /dev/null @@ -1,214 +0,0 @@ -import { mount } from '@vue/test-utils'; -import MasteryDropdown from '../MasteryDropdown.vue'; -import TestForm from './TestForm.vue'; -import { constantStrings } from 'shared/mixins'; -import MasteryModels from 'shared/leUtils/MasteryModels'; - -document.body.setAttribute('data-app', true); // Vuetify prints a warning without this - -function makeWrapper() { - return mount(TestForm, { - slots: { - testComponent: MasteryDropdown, - }, - }); -} - -describe('masteryDropdown', () => { - let formWrapper; - let wrapper; - let modelInput; - let mInput; - let nInput; - - beforeEach(() => { - formWrapper = makeWrapper(); - wrapper = formWrapper.find(MasteryDropdown); - wrapper.setProps({ value: { mastery_model: 'm_of_n' } }); - modelInput = wrapper.find({ ref: 'masteryModel' }).find('input'); - wrapper.vm.$nextTick(() => { - mInput = wrapper.find({ ref: 'mValue' }).find('input'); - nInput = wrapper.find({ ref: 'nValue' }).find('input'); - }); - }); - - describe('on load', () => { - MasteryModels.forEach(model => { - it(`${model} mastery option should be an option to select`, () => { - expect(wrapper.find('.v-list').text()).toContain(constantStrings.$tr(model)); - }); - }); - it('should render according to masteryModel prop', () => { - function test(model) { - wrapper.setProps({ value: { mastery_model: model } }); - expect(wrapper.vm.$refs.masteryModel.value).toEqual(model); - expect(wrapper.find({ ref: 'mValue' }).exists()).toBe(model === 'm_of_n'); - expect(wrapper.find({ ref: 'nValue' }).exists()).toBe(model === 'm_of_n'); - } - MasteryModels.forEach(test); - }); - it('should render correct mValue and nValue props', () => { - wrapper.setProps({ value: { mastery_model: 'm_of_n', m: 10, n: 20 } }); - expect(wrapper.vm.$refs.mValue.value).toEqual(10); - expect(wrapper.vm.$refs.nValue.value).toEqual(20); - }); - }); - describe('props', () => { - beforeEach(() => {}); - it('setting readonly should prevent any edits', () => { - wrapper.setProps({ readonly: true }); - wrapper.vm.$nextTick(() => { - expect(modelInput.attributes('readonly')).toEqual('readonly'); - expect(mInput.attributes('readonly')).toEqual('readonly'); - expect(nInput.attributes('readonly')).toEqual('readonly'); - }); - }); - it('setting required to false should make fields not required (required by default)', () => { - expect(modelInput.attributes('required')).toEqual('required'); - expect(mInput.attributes('required')).toEqual('required'); - expect(nInput.attributes('required')).toEqual('required'); - - wrapper.setProps({ required: false, mRequired: false, nRequired: false }); - wrapper.vm.$nextTick(() => { - expect(modelInput.attributes('required')).toBeFalsy(); - expect(mInput.attributes('required')).toBeFalsy(); - expect(nInput.attributes('required')).toBeFalsy(); - }); - }); - it('setting disabled should make fields disabled', () => { - expect(modelInput.attributes('disabled')).toBeFalsy(); - expect(mInput.attributes('disabled')).toBeFalsy(); - expect(nInput.attributes('disabled')).toBeFalsy(); - - wrapper.setProps({ disabled: true }); - wrapper.vm.$nextTick(() => { - expect(modelInput.attributes('disabled')).toEqual('disabled'); - expect(mInput.attributes('disabled')).toEqual('disabled'); - expect(nInput.attributes('disabled')).toEqual('disabled'); - }); - }); - }); - describe('emitted events', () => { - it('input should be emitted when masteryModel is updated', () => { - expect(wrapper.emitted('input')).toBeFalsy(); - modelInput.setValue('do_all'); - expect(wrapper.emitted('input')).toBeTruthy(); - expect(wrapper.emitted('input')[0][0].mastery_model).toEqual('do_all'); - }); - it('input should be emitted when mValue is updated', () => { - expect(wrapper.emitted('input')).toBeFalsy(); - mInput.setValue(10); - expect(wrapper.emitted('input')).toBeTruthy(); - expect(wrapper.emitted('input')[0][0].m).toEqual(10); - }); - it('input should be emitted when mValue is updated', () => { - expect(wrapper.emitted('input')).toBeFalsy(); - nInput.setValue(10); - expect(wrapper.emitted('input')).toBeTruthy(); - expect(wrapper.emitted('input')[0][0].n).toEqual(10); - }); - }); - describe('validation', () => { - it('should flag empty required mastery models', () => { - wrapper.setProps({ value: { mastery_model: null } }); - formWrapper.vm.validate(); - expect( - wrapper - .find({ ref: 'masteryModel' }) - .find('.error--text') - .exists() - ).toBe(true); - wrapper.setProps({ required: false }); - formWrapper.vm.validate(); - expect( - wrapper - .find({ ref: 'masteryModel' }) - .find('.error--text') - .exists() - ).toBe(false); - }); - it('should flag empty n and m values', () => { - formWrapper.vm.validate(); - expect( - wrapper - .find({ ref: 'mValue' }) - .find('.error--text') - .exists() - ).toBe(true); - expect( - wrapper - .find({ ref: 'nValue' }) - .find('.error--text') - .exists() - ).toBe(true); - wrapper.setProps({ mRequired: false, nRequired: false }); - formWrapper.vm.validate(); - expect( - wrapper - .find({ ref: 'mValue' }) - .find('.error--text') - .exists() - ).toBe(false); - expect( - wrapper - .find({ ref: 'nValue' }) - .find('.error--text') - .exists() - ).toBe(false); - }); - it('should flag if m is not a whole number', () => { - wrapper.setProps({ value: { mastery_model: 'm_of_n', m: 0.1231, n: 10 } }); - formWrapper.vm.validate(); - expect( - wrapper - .find({ ref: 'mValue' }) - .find('.error--text') - .exists() - ).toBe(true); - wrapper.setProps({ value: { mastery_model: 'm_of_n', m: 1, n: 10 } }); - formWrapper.vm.validate(); - expect( - wrapper - .find({ ref: 'mValue' }) - .find('.error--text') - .exists() - ).toBe(false); - }); - it('should flag if m < 1', () => { - wrapper.setProps({ value: { mastery_model: 'm_of_n', m: 0, n: 10 } }); - formWrapper.vm.validate(); - expect( - wrapper - .find({ ref: 'mValue' }) - .find('.error--text') - .exists() - ).toBe(true); - wrapper.setProps({ value: { mastery_model: 'm_of_n', m: 1, n: 10 } }); - formWrapper.vm.validate(); - expect( - wrapper - .find({ ref: 'mValue' }) - .find('.error--text') - .exists() - ).toBe(false); - }); - it('should flag if m > n', () => { - wrapper.setProps({ value: { mastery_model: 'm_of_n', m: 2, n: 1 } }); - formWrapper.vm.validate(); - expect( - wrapper - .find({ ref: 'mValue' }) - .find('.error--text') - .exists() - ).toBe(true); - wrapper.setProps({ value: { mastery_model: 'm_of_n', m: 2, n: 2 } }); - formWrapper.vm.validate(); - expect( - wrapper - .find({ ref: 'mValue' }) - .find('.error--text') - .exists() - ).toBe(false); - }); - }); -}); diff --git a/contentcuration/contentcuration/viewsets/contentnode.py b/contentcuration/contentcuration/viewsets/contentnode.py index 5fd3fe3091..761fea1410 100644 --- a/contentcuration/contentcuration/viewsets/contentnode.py +++ b/contentcuration/contentcuration/viewsets/contentnode.py @@ -284,6 +284,7 @@ class ExtraFieldsOptionsSerializer(JSONFieldDictSerializer): class ExtraFieldsSerializer(JSONFieldDictSerializer): randomize = BooleanField() options = ExtraFieldsOptionsSerializer(required=False) + suggested_duration_type = ChoiceField(choices=[completion_criteria.TIME, completion_criteria.APPROX_TIME], allow_null=True, required=False) class TagField(DotPathValueMixin, DictField):
{{ $tr('exerciseDescripiton') }}
{{ $tr('masteryDescripiton') }}