diff --git a/kolibri/plugins/coach/assets/src/composables/quizCreationSpecs.js b/kolibri/plugins/coach/assets/src/composables/quizCreationSpecs.js index bc19a9224f9..aa32c97c19d 100644 --- a/kolibri/plugins/coach/assets/src/composables/quizCreationSpecs.js +++ b/kolibri/plugins/coach/assets/src/composables/quizCreationSpecs.js @@ -42,9 +42,9 @@ export const QuizExercise = { type: String, default: '', }, - assessment_ids: { - type: Array, - default: () => [], + assessmentmetadata: { + type: Object, + default: () => ({ assessment_item_ids: [] }), }, contentnode: { type: String, diff --git a/kolibri/plugins/coach/assets/src/composables/useQuizCreation.js b/kolibri/plugins/coach/assets/src/composables/useQuizCreation.js index ac6ff58ea5d..7ee8fdd9fae 100644 --- a/kolibri/plugins/coach/assets/src/composables/useQuizCreation.js +++ b/kolibri/plugins/coach/assets/src/composables/useQuizCreation.js @@ -1,6 +1,5 @@ import { v4 } from 'uuid'; import isEqual from 'lodash/isEqual'; -import uniqWith from 'lodash/uniqWith'; import range from 'lodash/range'; import shuffle from 'lodash/shuffle'; import { enhancedQuizManagementStrings } from 'kolibri-common/strings/enhancedQuizManagementStrings'; @@ -9,10 +8,10 @@ import { ContentNodeKinds } from 'kolibri.coreVue.vuex.constants'; import { ChannelResource, ExamResource } from 'kolibri.resources'; import { validateObject, objectWithDefaults } from 'kolibri.utils.objectSpecs'; import { get, set } from '@vueuse/core'; -import { computed, ref, provide, inject } from 'kolibri.lib.vueCompositionApi'; +import { computed, ref, watch, provide, inject } from 'kolibri.lib.vueCompositionApi'; import logging from 'kolibri.lib.logging'; // TODO: Probably move this to this file's local dir -import selectQuestions from '../modules/examCreation/selectQuestions.js'; +import selectQuestions from '../utils/selectQuestions.js'; import { Quiz, QuizSection, QuizQuestion, QuizExercise } from './quizCreationSpecs.js'; const logger = logging.getLogger(__filename); @@ -30,14 +29,6 @@ function validateQuiz(quiz) { return validateObject(quiz, Quiz); } -/** - * @param {QuizExercise} o - The resource to check - * @returns {boolean} - True if the resource is a valid QuizExercise - */ -function isExercise(o) { - return o.kind === ContentNodeKinds.EXERCISE; -} - /** * Composable function presenting primary interface for Quiz Creation */ @@ -46,10 +37,6 @@ export default function useQuizCreation(DEBUG = false) { // Local state // ----------- - /** @type {ComputedRef} Currently selected resource_pool - * from the side_panel*/ - const _working_resource_pool = ref([]); - /** @type {ref} * The "source of truth" quiz object from which all reactive properties should derive */ const _quiz = ref(objectWithDefaults({}, Quiz)); @@ -178,13 +165,8 @@ export default function useQuizCreation(DEBUG = false) { } else if (question_count > (targetSection.question_count || 0)) { // If the question_count is being increased, we need to add new questions to the end of the // questions array - const newQuestions = selectQuestions( - question_count - (targetSection.question_count || 0), - targetSection.resource_pool.map(r => r.content_id), - targetSection.resource_pool.map(r => r.title), - targetSection.resource_pool.map(r => r.questions.map(q => q.question_id)), - get(_quiz).seed - ); + const numQuestionsToAdd = question_count - (targetSection.question_count || 0); + const newQuestions = selectRandomQuestionsFromResources(numQuestionsToAdd); updates.questions = [...targetSection.questions, ...newQuestions]; } } @@ -201,6 +183,23 @@ export default function useQuizCreation(DEBUG = false) { }); } + /** + * @description Selects random questions from the active section's `resource_pool` - no side + * effects + * @param numQuestions + * @returns {QuizQuestion[]} + */ + function selectRandomQuestionsFromResources(numQuestions) { + const pool = get(activeResourcePool); + return selectQuestions( + numQuestions, + pool.map(r => r.content_id), + pool.map(r => r.title), + pool.map(r => r.assessmentmetadata.assessment_item_ids), + get(_quiz).seed + ); + } + /** * @param {QuizQuestion[]} newQuestions * @affects _quiz - Updates the active section's `questions` property @@ -272,12 +271,16 @@ export default function useQuizCreation(DEBUG = false) { setActiveSection(newSection.section_id); } _fetchChannels(); - } - // // Method to initialize the working resource pool - function initializeWorkingResourcePool() { - // Set the value of _working_resource_pool to the resource_pool of the active section - set(_working_resource_pool, get(activeResourcePool)); + // Set watcher once we have a section in place + watch(activeResourcePool, (resourcePool, old) => { + if (!isEqual(resourcePool, old)) { + updateSection({ + section_id: get(_activeSectionId), + questions: selectRandomQuestionsFromResources(get(activeSection).question_count), + }); + } + }); } /** @@ -364,11 +367,6 @@ export default function useQuizCreation(DEBUG = false) { } } - function resetWorkingResourcePool() { - // Set the WorkingResource to empty array again! - set(_working_resource_pool, []); - } - /** * @affects _channels - Fetches all channels with exercises and sets them to _channels */ function _fetchChannels() { @@ -391,21 +389,6 @@ export default function useQuizCreation(DEBUG = false) { } // Utilities - /** - * @params {string} section_id - The section_id whose resource_pool we'll use. - * @returns {QuizQuestion[]} - */ - /* - function _getQuestionsFromSection(section_id) { - const section = get(allSections).find(s => s.section_id === section_id); - if (!section) { - throw new Error(`Section with id ${section_id} not found.`); - } - return get(activeExercisePool).reduce((acc, exercise) => { - return [...acc, ...exercise.questions]; - }, []); - } - */ // Computed properties /** @type {ComputedRef} The value of _quiz */ @@ -422,10 +405,13 @@ export default function useQuizCreation(DEBUG = false) { ); /** @type {ComputedRef} The active section's `resource_pool` */ const activeResourcePool = computed(() => get(activeSection).resource_pool); - /** @type {ComputedRef} The active section's `resource_pool` - that is, - * Exercises from which we will enumerate all - * available questions */ - const activeExercisePool = computed(() => get(activeResourcePool).filter(isExercise)); + /** @type {ComputedRef} The active section's `resource_pool` */ + const activeResourceMap = computed(() => + get(activeResourcePool).reduce((acc, resource) => { + acc[resource.content_id] = resource; + return acc; + }, {}) + ); /** @type {ComputedRef} All questions in the active section's `resource_pool` * exercises */ const activeQuestionsPool = computed(() => []); @@ -440,9 +426,6 @@ export default function useQuizCreation(DEBUG = false) { /** @type {ComputedRef} A list of all channels available which have exercises */ const channels = computed(() => get(_channels)); - // /** @type {ComputedRef} The current value of _working_resource_pool */ - const workingResourcePool = computed(() => get(_working_resource_pool)); - /** Handling the Select All Checkbox * See: remove/toggleQuestionFromSelection() & selectAllQuestions() for more */ @@ -480,45 +463,12 @@ export default function useQuizCreation(DEBUG = false) { } }); - /** - * @param {QuizExercise[]} resources - * @affects _working_resource_pool -- Updates it with the given resources and is ensured to have - * a list of unique resources to avoid unnecessary duplication - */ - function addToWorkingResourcePool(resources = []) { - set(_working_resource_pool, uniqWith([...get(_working_resource_pool), ...resources], isEqual)); - } - - /** - * @param {QuizExercise} content - * @affects _working_resource_pool - Remove given quiz exercise from _working_resource_pool - */ - function removeFromWorkingResourcePool(content) { - set( - _working_resource_pool, - _working_resource_pool.value.filter(obj => obj.id !== content.id) - ); - } - - /** - * @param {QuizExercise} content - * Check if the content is present in working_resource_pool - */ - function contentPresentInWorkingResourcePool(content) { - const workingResourceIds = get(workingResourcePool).map(wr => wr.id); - return workingResourceIds.includes(content.id); - } - /** @type {ComputedRef} Whether the select all checkbox should be indeterminate */ const selectAllIsIndeterminate = computed(() => { return !get(allQuestionsSelected) && !get(noQuestionsSelected); }); provide('saveQuiz', saveQuiz); - provide('initializeWorkingResourcePool', initializeWorkingResourcePool); - provide('addToWorkingResourcePool', addToWorkingResourcePool); - provide('removeFromWorkingResourcePool', removeFromWorkingResourcePool); - provide('contentPresentInWorkingResourcePool', contentPresentInWorkingResourcePool); provide('updateSection', updateSection); provide('replaceSelectedQuestions', replaceSelectedQuestions); provide('addSection', addSection); @@ -528,15 +478,13 @@ export default function useQuizCreation(DEBUG = false) { provide('updateQuiz', updateQuiz); provide('addQuestionToSelection', addQuestionToSelection); provide('removeQuestionFromSelection', removeQuestionFromSelection); - provide('resetWorkingResourcePool', resetWorkingResourcePool); provide('channels', channels); provide('quiz', quiz); provide('allSections', allSections); provide('activeSection', activeSection); provide('inactiveSections', inactiveSections); provide('activeResourcePool', activeResourcePool); - provide('workingResourcePool', workingResourcePool); - provide('activeExercisePool', activeExercisePool); + provide('activeResourceMap', activeResourceMap); provide('activeQuestionsPool', activeQuestionsPool); provide('activeQuestions', activeQuestions); provide('selectedActiveQuestions', selectedActiveQuestions); @@ -548,13 +496,8 @@ export default function useQuizCreation(DEBUG = false) { return { // Methods saveQuiz, - initializeWorkingResourcePool, - removeFromWorkingResourcePool, - addToWorkingResourcePool, - contentPresentInWorkingResourcePool, updateSection, replaceSelectedQuestions, - resetWorkingResourcePool, addSection, removeSection, setActiveSection, @@ -569,9 +512,8 @@ export default function useQuizCreation(DEBUG = false) { allSections, activeSection, inactiveSections, - workingResourcePool, activeResourcePool, - activeExercisePool, + activeResourceMap, activeQuestionsPool, activeQuestions, selectedActiveQuestions, @@ -594,14 +536,9 @@ export default function useQuizCreation(DEBUG = false) { export function injectQuizCreation() { const saveQuiz = inject('saveQuiz'); - const initializeWorkingResourcePool = inject('initializeWorkingResourcePool'); - const removeFromWorkingResourcePool = inject('removeFromWorkingResourcePool'); - const contentPresentInWorkingResourcePool = inject('contentPresentInWorkingResourcePool'); - const addToWorkingResourcePool = inject('addToWorkingResourcePool'); const updateSection = inject('updateSection'); const replaceSelectedQuestions = inject('replaceSelectedQuestions'); const addSection = inject('addSection'); - const resetWorkingResourcePool = inject('resetWorkingResourcePool'); const removeSection = inject('removeSection'); const setActiveSection = inject('setActiveSection'); const initializeQuiz = inject('initializeQuiz'); @@ -614,8 +551,7 @@ export function injectQuizCreation() { const activeSection = inject('activeSection'); const inactiveSections = inject('inactiveSections'); const activeResourcePool = inject('activeResourcePool'); - const workingResourcePool = inject('workingResourcePool'); - const activeExercisePool = inject('activeExercisePool'); + const activeResourceMap = inject('activeResourceMap'); const activeQuestionsPool = inject('activeQuestionsPool'); const activeQuestions = inject('activeQuestions'); const selectedActiveQuestions = inject('selectedActiveQuestions'); @@ -627,14 +563,9 @@ export function injectQuizCreation() { return { // Methods saveQuiz, - initializeWorkingResourcePool, - addToWorkingResourcePool, - contentPresentInWorkingResourcePool, - removeFromWorkingResourcePool, deleteActiveSelectedQuestions, selectAllQuestions, updateSection, - resetWorkingResourcePool, replaceSelectedQuestions, addSection, removeSection, @@ -651,9 +582,8 @@ export function injectQuizCreation() { allSections, activeSection, inactiveSections, - workingResourcePool, activeResourcePool, - activeExercisePool, + activeResourceMap, activeQuestionsPool, activeQuestions, selectedActiveQuestions, diff --git a/kolibri/plugins/coach/assets/src/modules/examCreation/actions.js b/kolibri/plugins/coach/assets/src/modules/examCreation/actions.js index 491fcb23b10..befee315279 100644 --- a/kolibri/plugins/coach/assets/src/modules/examCreation/actions.js +++ b/kolibri/plugins/coach/assets/src/modules/examCreation/actions.js @@ -11,7 +11,6 @@ import { ContentNodeKinds } from 'kolibri.coreVue.vuex.constants'; import { PageNames } from '../../constants'; import { MAX_QUESTIONS } from '../../constants/examConstants'; import { createExam } from '../examShared/exams'; -import selectQuestions from './selectQuestions'; export function resetExamCreationState(store) { store.commit('RESET_STATE'); @@ -216,6 +215,9 @@ export function updateSelectedQuestions(store) { contentNodes.forEach(exercise => { exercises[exercise.id] = exercise; }); + // TODO This file needs to be cleaned up when updates to quiz management are complete -- this + // will be removed altogether so just no-op for now is ok + const doNothing = () => null; const availableExercises = exerciseIds.filter(id => exercises[id]); const exerciseTitles = availableExercises.map(id => exercises[id].title); const questionIdArrays = availableExercises.map( @@ -223,7 +225,7 @@ export function updateSelectedQuestions(store) { ); store.commit( 'SET_SELECTED_QUESTIONS', - selectQuestions( + doNothing( store.state.numberOfQuestions, availableExercises, exerciseTitles, diff --git a/kolibri/plugins/coach/assets/src/modules/examCreation/selectQuestions.js b/kolibri/plugins/coach/assets/src/utils/selectQuestions.js similarity index 82% rename from kolibri/plugins/coach/assets/src/modules/examCreation/selectQuestions.js rename to kolibri/plugins/coach/assets/src/utils/selectQuestions.js index 3ea54c5697e..127cc593c0a 100644 --- a/kolibri/plugins/coach/assets/src/modules/examCreation/selectQuestions.js +++ b/kolibri/plugins/coach/assets/src/utils/selectQuestions.js @@ -11,16 +11,18 @@ const logging = logger.getLogger(__filename); const getTotalOfQuestions = sumBy(qArray => qArray.length); /** - * TODO: Move this into the composables directory, clarify typing expectations below + * Choose a an evenly-distributed random selection of questions from exercises. Note that the order + * of the arrays should correspond to each other, ie, exerciseIds[i] should correspond to + * questionIdArrays[i] should correspond to exerciseTitles[i], etc. * - * Choose a an evenly-distributed random selection of questions from exercises. - * @param {number} numQuestions - target number of questions - * @param {array} exerciseIds - exercise IDs - * @param {array} exerciseTitle - exercise titles - * @param {array} questionIdArrays - arrays of question/assessment IDs - * corresponding to the exercise IDs + * @param {Number} numQuestions - target number of questions + * @param {String[]} exerciseIds - QuizExercise IDs + * @param {String[]} exerciseTitle - QuizExercise titles + * @param {Array[String[]]} questionIdArrays - QuizQuestion (assessment) ID arrays corresponding + * to each exercise by index (ie, questionIdArrays[i] corresponds to exerciseIds[i]) * @param {number} seed - value to seed the random shuffle with - * @return {array} - objects of the form { exercise_id, question_id, title } + * + * @return {QuizQuestion[]} */ export default function selectQuestions( numQuestions, diff --git a/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/CreateQuizSection.vue b/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/CreateQuizSection.vue index 1d9319952dc..f3edc5cbba3 100644 --- a/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/CreateQuizSection.vue +++ b/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/CreateQuizSection.vue @@ -281,16 +281,26 @@ @@ -384,9 +394,9 @@ allSections, activeSection, inactiveSections, - workingResourcePool, - activeExercisePool, activeQuestionsPool, + activeResourceMap, + activeResourcePool, activeQuestions, selectedActiveQuestions, replacementQuestionPool, @@ -434,8 +444,8 @@ allSections, activeSection, inactiveSections, - workingResourcePool, - activeExercisePool, + activeResourceMap, + activeResourcePool, activeQuestionsPool, activeQuestions, selectedActiveQuestions, diff --git a/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/ResourceSelection.vue b/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/ResourceSelection.vue index a6c7c613d76..ea22d166a0f 100644 --- a/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/ResourceSelection.vue +++ b/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/ResourceSelection.vue @@ -64,7 +64,7 @@ :layout8="{ span: 4 }" :layout4="{ span: 2 }" > - {{ numberOfResources$({ count: channels.length }) }} + {{ numberOfResources$({ count: workingResourcePool.length }) }} + import uniqWith from 'lodash/uniqWith'; + import isEqual from 'lodash/isEqual'; import { enhancedQuizManagementStrings } from 'kolibri-common/strings/enhancedQuizManagementStrings'; import { computed, ref, getCurrentInstance, watch } from 'kolibri.lib.vueCompositionApi'; import commonCoreStrings from 'kolibri.coreVue.mixins.commonCoreStrings'; @@ -117,19 +120,8 @@ // or the actual exercises that are bookmarked and can be selected // to be added to Quiz Section. const showBookmarks = computed(() => route.value.query.showBookmarks); - const { - updateSection, - activeSection, - selectAllQuestions, - workingResourcePool, - addToWorkingResourcePool, - removeFromWorkingResourcePool, - resetWorkingResourcePool, - contentPresentInWorkingResourcePool, - initializeWorkingResourcePool, - } = injectQuizCreation(); + const { updateSection, activeResourcePool, selectAllQuestions } = injectQuizCreation(); - initializeWorkingResourcePool(); const { sectionSettings$, selectFromBookmarks$, @@ -150,7 +142,43 @@ const { windowIsSmall } = useKResponsiveWindow(); - //const { channels, loading, bookmarks, contentList } = useExerciseResources(); + /** + * @type {Ref} - The uncommitted version of the section's resource_pool + */ + const workingResourcePool = ref(activeResourcePool.value); + + /** + * @param {QuizExercise[]} resources + * @affects workingResourcePool -- Updates it with the given resources and is ensured to have + * a list of unique resources to avoid unnecessary duplication + */ + function addToWorkingResourcePool(resources = []) { + workingResourcePool.value = uniqWith([...workingResourcePool.value, ...resources], isEqual); + } + + /** + * @param {QuizExercise} content + * @affects workingResourcePool - Remove given quiz exercise from workingResourcePool + */ + function removeFromWorkingResourcePool(content) { + workingResourcePool.value = workingResourcePool.value.filter(obj => obj.id !== content.id); + } + + /** + * @affects workingResourcePool - Resets the workingResourcePool to the previous state + */ + function resetWorkingResourcePool() { + workingResourcePool.value = activeResourcePool.value; + } + + /** + * @param {QuizExercise} content + * Check if the content is present in workingResourcePool + */ + function contentPresentInWorkingResourcePool(content) { + const workingResourceIds = workingResourcePool.value.map(wr => wr.id); + return workingResourceIds.includes(content.id); + } const { hasCheckbox, @@ -261,7 +289,6 @@ channels, viewMoreButtonState, updateSection, - activeSection, selectAllQuestions, workingResourcePool, addToWorkingResourcePool, @@ -484,13 +511,19 @@ } .bottom-navigation { - position: fixed; - bottom: 0; - width: 50%; - padding: 10px; - color: black; + position: absolute; + right: 0; + bottom: 1.5em; + left: 0; + width: 100%; + padding: 1em; text-align: center; background-color: white; + border-top: 1px solid black; + + span { + line-height: 2.5em; + } } diff --git a/kolibri/plugins/coach/assets/test/selectRandomExamQuestions.spec.js b/kolibri/plugins/coach/assets/test/selectRandomExamQuestions.spec.js index 6eb152b6a9b..efb9331c85c 100644 --- a/kolibri/plugins/coach/assets/test/selectRandomExamQuestions.spec.js +++ b/kolibri/plugins/coach/assets/test/selectRandomExamQuestions.spec.js @@ -1,5 +1,5 @@ import filter from 'lodash/filter'; -import selectQuestions from '../src/modules/examCreation/selectQuestions'; +import selectQuestions from '../src/utils/selectQuestions'; jest.mock('kolibri.lib.logging'); diff --git a/kolibri/plugins/coach/assets/test/useQuizCreation.spec.js b/kolibri/plugins/coach/assets/test/useQuizCreation.spec.js index 31defb1526b..d30308dbd09 100644 --- a/kolibri/plugins/coach/assets/test/useQuizCreation.spec.js +++ b/kolibri/plugins/coach/assets/test/useQuizCreation.spec.js @@ -1,3 +1,4 @@ +import Vue from 'vue'; import { get } from '@vueuse/core'; import { ChannelResource, ExamResource } from 'kolibri.resources'; import { objectWithDefaults } from 'kolibri.utils.objectSpecs'; @@ -22,7 +23,6 @@ const { quiz, allSections, activeSection, - // activeExercisePool, activeQuestions, selectedActiveQuestions, // replacementQuestionPool, @@ -50,8 +50,12 @@ function generateQuestions(num = 0) { * A helper function to mock an exercise with a given number of questions (for `resource_pool`) */ function generateExercise(numQuestions) { - const exercise = objectWithDefaults({ resource_id: 'exercise_1' }, QuizExercise); - exercise.questions = generateQuestions(numQuestions); + const assessments = generateQuestions(numQuestions); + const assessmentmetadata = { assessment_item_ids: assessments.map(q => q.question_id) }; + const exercise = objectWithDefaults( + { content_id: 'exercise_1', assessmentmetadata }, + QuizExercise + ); return exercise; } @@ -137,16 +141,15 @@ describe('useQuizCreation', () => { ).toEqual(newTitle); }); - it('Will update `questions` to match `question_count` property when it is changed', () => { + it('Will update `questions` to match `question_count` property when it is changed', async () => { // Setup a mock exercise w/ some questions; update the activeSection with their values - const exercise = generateExercise(10); - const questions = exercise.questions; + const exercise = generateExercise(20); updateSection({ section_id: get(activeSection).section_id, - questions, resource_pool: [exercise], }); - expect(get(activeQuestions)).toHaveLength(questions.length); + await Vue.nextTick(); + expect(get(activeQuestions)).toHaveLength(get(activeSection).question_count); expect(get(activeQuestions).length).not.toEqual(0); expect(get(activeSection).resource_pool).toHaveLength(1); @@ -156,18 +159,16 @@ describe('useQuizCreation', () => { section_id: get(activeSection).section_id, question_count: newQuestionCount, }); + await Vue.nextTick(); // Now questions should only be as long as newQuestionCount expect(get(activeQuestions)).toHaveLength(newQuestionCount); - // And it should have split it into head & tail and kept the head so indexes 0 and 4 ought - // to be the same as the first and last questions in the updated questions array - expect(get(activeQuestions)[0].question_id).toEqual(questions[0].question_id); - expect(get(activeQuestions)[4].question_id).toEqual(questions[4].question_id); const newQuestionCount2 = 10; updateSection({ section_id: get(activeSection).section_id, question_count: newQuestionCount2, }); + await Vue.nextTick(); expect(get(activeQuestions)).toHaveLength(newQuestionCount2); });