From 476eb510ce57713af266a9ea2b32b67345388ebe Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Tue, 11 Jun 2024 23:06:41 -0700 Subject: [PATCH] Update select quiz workflow to use new components and composable. --- .../assets/src/composables/useQuizCreation.js | 133 ++++++++----- .../src/composables/useQuizResources.js | 9 +- .../coach/assets/src/constants/index.js | 1 + .../coach/assets/src/routes/planExamRoutes.js | 8 + .../src/views/plan/CoachExamsPage/index.vue | 10 +- .../plan/CreateExamPage/ResourceSelection.vue | 182 ++++++++++++------ .../ContentCardList.vue | 30 ++- .../coach/assets/test/useQuizCreation.spec.js | 3 +- .../strings/enhancedQuizManagementStrings.js | 12 +- 9 files changed, 258 insertions(+), 130 deletions(-) diff --git a/kolibri/plugins/coach/assets/src/composables/useQuizCreation.js b/kolibri/plugins/coach/assets/src/composables/useQuizCreation.js index f88b9bb5be3..54811e8ab73 100644 --- a/kolibri/plugins/coach/assets/src/composables/useQuizCreation.js +++ b/kolibri/plugins/coach/assets/src/composables/useQuizCreation.js @@ -74,8 +74,62 @@ export default function useQuizCreation() { question_count: originalQuestionCount, } = targetSection; - const { resource_pool, question_count } = updates; + const { question_count, questions } = updates; + if (questions && question_count) { + throw new TypeError( + 'Cannot update both `questions` and `question_count` at the same time; use one or the other.' + ); + } + + if (question_count) { + // The question_count is so we need to update the selected resources + if (question_count > originalQuestionCount) { + updates.questions = [ + ...originalQuestions, + ...selectRandomQuestionsFromResources( + question_count - originalQuestionCount, + originalResourcePool + ), + ]; + } else if (question_count < originalQuestionCount) { + updates.questions = originalQuestions.slice(0, question_count); + } + } + + if (questions) { + // The questions are being updated + // Set question_count to the length of the questions array + updates.question_count = questions.length; + } + + const _allSections = get(allSections); + + set(_quiz, { + ...get(quiz), + // Update matching QuizSections with the updates object + question_sources: [ + ..._allSections.slice(0, sectionIndex), + { ...targetSection, ...updates }, + ..._allSections.slice(sectionIndex + 1), + ], + }); + } + + function updateSectionResourcePool({ sectionIndex, resource_pool }) { + const targetSection = get(allSections)[sectionIndex]; + if (!targetSection) { + throw new TypeError(`Section with id ${sectionIndex} not found; cannot be updated.`); + } + + // original variables are the original values of the properties we're updating + const { + resource_pool: originalResourcePool, + questions: originalQuestions, + question_count: originalQuestionCount, + } = targetSection; + + const updates = { resource_pool }; if (resource_pool?.length === 0) { // The user has removed all resources from the section, so we can clear all questions too updates.questions = []; @@ -95,65 +149,36 @@ export default function useQuizCreation() { // if there weren't resources in the originalResourcePool before. // *** updates.questions = selectRandomQuestionsFromResources( - question_count || originalQuestionCount || 0, + originalQuestionCount || 0, resource_pool ); } else { - // We're updating the resource_pool of a section that already had resources - if (question_count === 0) { - updates.questions = []; - } else { - // In this case, we already had resources in the section, so we need to handle the - // case where a resource has been removed so that we remove & replace the questions - const removedResourceQuestionIds = originalResourcePool.reduce( - (questionIds, originalResource) => { - if (!resource_pool.map(r => r.id).includes(originalResource.id)) { - // If the resource_pool doesn't have the originalResource, we're removing it - questionIds = [...questionIds, ...originalResource.unique_question_ids]; - return questionIds; - } + // In this case, we already had resources in the section, so we need to handle the + // case where a resource has been removed so that we remove & replace the questions + const removedResourceQuestionIds = originalResourcePool.reduce( + (questionIds, originalResource) => { + if (!resource_pool.map(r => r.id).includes(originalResource.id)) { + // If the resource_pool doesn't have the originalResource, we're removing it + questionIds = [...questionIds, ...originalResource.unique_question_ids]; return questionIds; - }, - [] + } + return questionIds; + }, + [] + ); + if (removedResourceQuestionIds.length !== 0) { + const questionsToKeep = originalQuestions.filter( + q => !removedResourceQuestionIds.includes(q.item) ); - if (removedResourceQuestionIds.length !== 0) { - const questionsToKeep = originalQuestions.filter( - q => !removedResourceQuestionIds.includes(q.item) - ); - const numReplacementsNeeded = - (question_count || originalQuestionCount) - questionsToKeep.length; - updates.questions = [ - ...questionsToKeep, - ...selectRandomQuestionsFromResources(numReplacementsNeeded, resource_pool), - ]; - } + const numReplacementsNeeded = originalQuestionCount - questionsToKeep.length; + updates.questions = [ + ...questionsToKeep, + ...selectRandomQuestionsFromResources(numReplacementsNeeded, resource_pool), + ]; } } } - // The resource pool isn't being updated but the question_count is so we need to update them - if (question_count > originalQuestionCount) { - updates.questions = [ - ...originalQuestions, - ...selectRandomQuestionsFromResources( - question_count - originalQuestionCount, - originalResourcePool - ), - ]; - } else if (question_count < originalQuestionCount) { - updates.questions = originalQuestions.slice(0, question_count); - } - - const _allSections = get(allSections); - - set(_quiz, { - ...get(quiz), - // Update matching QuizSections with the updates object - question_sources: [ - ..._allSections.slice(0, sectionIndex), - { ...targetSection, ...updates }, - ..._allSections.slice(sectionIndex + 1), - ], - }); + updateSection({ sectionIndex, ...updates }); } function handleReplacement() { @@ -485,6 +510,7 @@ export default function useQuizCreation() { provide('allQuestionsInQuiz', allQuestionsInQuiz); provide('updateSection', updateSection); + provide('updateSectionResourcePool', updateSectionResourcePool); provide('handleReplacement', handleReplacement); provide('replaceSelectedQuestions', replaceSelectedQuestions); provide('addSection', addSection); @@ -515,6 +541,7 @@ export default function useQuizCreation() { // Methods saveQuiz, updateSection, + updateSectionResourcePool, handleReplacement, replaceSelectedQuestions, addSection, @@ -550,6 +577,7 @@ export default function useQuizCreation() { export function injectQuizCreation() { const allQuestionsInQuiz = inject('allQuestionsInQuiz'); const updateSection = inject('updateSection'); + const updateSectionResourcePool = inject('updateSectionResourcePool'); const handleReplacement = inject('handleReplacement'); const replaceSelectedQuestions = inject('replaceSelectedQuestions'); const addSection = inject('addSection'); @@ -580,6 +608,7 @@ export function injectQuizCreation() { deleteActiveSelectedQuestions, selectAllQuestions, updateSection, + updateSectionResourcePool, handleReplacement, replaceSelectedQuestions, addSection, diff --git a/kolibri/plugins/coach/assets/src/composables/useQuizResources.js b/kolibri/plugins/coach/assets/src/composables/useQuizResources.js index b243a8ca4ed..6108d02a226 100644 --- a/kolibri/plugins/coach/assets/src/composables/useQuizResources.js +++ b/kolibri/plugins/coach/assets/src/composables/useQuizResources.js @@ -19,12 +19,16 @@ const _loadingMore = ref(false); * @module useQuizResources * @param {QuizResourcesConfig} config */ -export default function useQuizResources({ topicId } = {}) { +export default function useQuizResources({ topicId, practiceQuiz = false } = {}) { const params = { kind_in: [ContentNodeKinds.EXERCISE, ContentNodeKinds.TOPIC], include_coach_content: true, }; + if (practiceQuiz) { + params.contains_quiz = true; + } + // Initialize useFetchTree methods with the given topicId computed property and params const { topic, fetchTree, fetchMore, hasMore, loading: treeLoading } = useFetchTree({ topicId, @@ -135,7 +139,8 @@ export default function useQuizResources({ topicId } = {}) { return ( node.kind === ContentNodeKinds.EXERCISE || // Has children, no more to load, and no children are topics - (node.children && + (!practiceQuiz && + node.children && !node.children.more && !node.children.results.some(c => c.kind === ContentNodeKinds.TOPIC)) ); diff --git a/kolibri/plugins/coach/assets/src/constants/index.js b/kolibri/plugins/coach/assets/src/constants/index.js index da4723aec6c..6515529c713 100644 --- a/kolibri/plugins/coach/assets/src/constants/index.js +++ b/kolibri/plugins/coach/assets/src/constants/index.js @@ -16,6 +16,7 @@ export const PageNames = { QUIZ_SECTION_EDITOR: 'QUIZ_SECTION_EDITOR', QUIZ_REPLACE_QUESTIONS: 'QUIZ_REPLACE_QUESTIONS', QUIZ_SELECT_RESOURCES: 'QUIZ_SELECT_RESOURCES', + QUIZ_SELECT_PRACTICE_QUIZ: 'QUIZ_SELECT_PRACTICE_QUIZ', BOOK_MARKED_RESOURCES: 'BOOK_MARKED_RESOURCES', /** TODO Remove unused */ diff --git a/kolibri/plugins/coach/assets/src/routes/planExamRoutes.js b/kolibri/plugins/coach/assets/src/routes/planExamRoutes.js index 086ea6a2ac0..ca05380ff3a 100644 --- a/kolibri/plugins/coach/assets/src/routes/planExamRoutes.js +++ b/kolibri/plugins/coach/assets/src/routes/planExamRoutes.js @@ -51,6 +51,14 @@ export default [ path: 'select-resources/:topic_id?', component: ResourceSelection, }, + { + name: PageNames.QUIZ_SELECT_PRACTICE_QUIZ, + path: 'select-quiz/:topic_id?', + component: ResourceSelection, + props: { + selectPracticeQuiz: true, + }, + }, ], }, { diff --git a/kolibri/plugins/coach/assets/src/views/plan/CoachExamsPage/index.vue b/kolibri/plugins/coach/assets/src/views/plan/CoachExamsPage/index.vue index 440622ca919..d77170ec17f 100644 --- a/kolibri/plugins/coach/assets/src/views/plan/CoachExamsPage/index.vue +++ b/kolibri/plugins/coach/assets/src/views/plan/CoachExamsPage/index.vue @@ -398,12 +398,12 @@ handleSelect({ value }) { const nextRouteName = { MAKE_NEW_QUIZ: PageNames.EXAM_CREATION_ROOT, - SELECT_QUIZ: PageNames.EXAM_CREATION_PRACTICE_QUIZ, + SELECT_QUIZ: PageNames.QUIZ_SELECT_PRACTICE_QUIZ, }[value]; - const nextRoute = { name: nextRouteName, params: { ...this.$route.params } }; - if (value === 'MAKE_NEW_QUIZ') { - nextRoute.params.quizId = 'new'; - } + const nextRoute = { + name: nextRouteName, + params: { ...this.$route.params, quizId: 'new', sectionIndex: 0 }, + }; this.$router.push(nextRoute); }, }, 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 e5edaa4cf56..c077a232a97 100644 --- a/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/ResourceSelection.vue +++ b/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/ResourceSelection.vue @@ -6,7 +6,7 @@
- {{ selectResourcesDescription$() }} + {{ selectPracticeQuiz ? selectPracticeQuizLabel$() : selectResourcesDescription$() }}
@@ -65,6 +65,7 @@ :contentCardMessage="selectionMetadata" :contentCardLink="contentLink" :loadingMoreState="loadingMore" + :showRadioButtons="selectPracticeQuiz" @changeselectall="handleSelectAll" @change_content_card="toggleSelected" @moreresults="fetchMoreResources" @@ -81,7 +82,9 @@ :layout8="{ span: 4 }" :layout4="{ span: 2 }" > - {{ numberOfResourcesSelected$({ count: workingResourcePool.length }) }} + + {{ numberOfResourcesSelected$({ count: workingResourcePool.length }) }} + + import get from 'lodash/get'; import uniqWith from 'lodash/uniqWith'; import isEqual from 'lodash/isEqual'; import { enhancedQuizManagementStrings } from 'kolibri-common/strings/enhancedQuizManagementStrings'; @@ -141,7 +145,7 @@ ResourceSelectionBreadcrumbs, }, mixins: [commonCoreStrings], - setup(_, context) { + setup(props, context) { const store = getCurrentInstance().proxy.$store; const route = computed(() => store.state.route); const topicId = computed(() => route.value.params.topic_id); @@ -153,27 +157,34 @@ const { activeSectionIndex, updateSection, + updateSectionResourcePool, activeResourcePool, selectAllQuestions, allQuestionsInQuiz, } = injectQuizCreation(); const showCloseConfirmation = ref(false); - const prevRoute = ref({ name: PageNames.EXAM_CREATION_ROOT }); + const prevRoute = ref({ + name: PageNames.EXAM_CREATION_ROOT, + sectionId: activeSectionIndex.value, + }); + + const selectPracticeQuiz = computed(() => props.selectPracticeQuiz); const { sectionSettings$, selectFromBookmarks$, numberOfSelectedBookmarks$, selectResourcesDescription$, - numberOfSelectedResources$, numberOfResourcesSelected$, changesSavedSuccessfully$, - selectedResourcesInformation$, + selectedQuestionsInformation$, cannotSelectSomeTopicWarning$, closeConfirmationMessage$, closeConfirmationTitle$, questionsUnusedInSection$, + selectQuiz$, + selectPracticeQuizLabel$, } = enhancedQuizManagementStrings; // TODO let's not use text for this @@ -256,6 +267,9 @@ keywords: searchQuery.value, kind: ContentNodeKinds.EXERCISE, }; + if (selectPracticeQuiz.value) { + getParams.contains_quiz = true; + } return ContentNodeResource.fetchCollection({ getParams }).then(response => { searchResults.value = response.results; moreSearchResults.value = response.more; @@ -287,7 +301,9 @@ }); const showSelectAll = computed(() => { - return contentList.value.every(content => hasCheckbox(content)); + return ( + !selectPracticeQuiz.value && contentList.value.every(content => hasCheckbox(content)) + ); }); function handleSelectAll(isChecked) { @@ -320,6 +336,9 @@ function toggleSelected({ content, checked }) { content = content.kind === ContentNodeKinds.TOPIC ? content.children.results : [content]; if (checked) { + if (this.selectPracticeQuiz) { + this.resetWorkingResourcePool(); + } this.addToWorkingResourcePool(content); } else { content.forEach(c => { @@ -339,7 +358,7 @@ annotateTopicsWithDescendantCounts, setResources, loadingMore, - } = useQuizResources({ topicId }); + } = useQuizResources({ topicId, practiceQuiz: selectPracticeQuiz.value }); const _loading = ref(true); @@ -352,12 +371,13 @@ if (!topicId.value) { const channelBookmarkPromises = [ - ContentNodeResource.fetchBookmarks({ params: { limit: 25, available: true } }).then( - data => { - const isExercise = item => item.kind === ContentNodeKinds.EXERCISE; - bookmarks.value = data.results ? data.results.filter(isExercise) : []; - } - ), + ContentNodeResource.fetchBookmarks({ + params: { limit: 25, available: true, kind: ContentNodeKinds.EXERCISE }, + }).then(data => { + const isPracticeQuiz = item => + !selectPracticeQuiz.value || get(item, ['options', 'modality'], false) === 'QUIZ'; + bookmarks.value = data.results ? data.results.filter(isPracticeQuiz) : []; + }), ]; if (searchQuery.value) { @@ -365,7 +385,11 @@ } else { channelBookmarkPromises.push( ChannelResource.fetchCollection({ - params: { has_exercises: true, available: true }, + getParams: { + contains_exercise: true, + available: true, + contains_quiz: selectPracticeQuiz.value ? true : null, + }, }).then(response => { setResources( response.map(chnl => { @@ -495,22 +519,30 @@ numberOfSelectedBookmarks$, questionsUnusedInSection$, selectResourcesDescription$, - numberOfSelectedResources$, numberOfResourcesSelected$, windowIsSmall, bookmarks, channels, viewMoreButtonState, updateSection, + updateSectionResourcePool, selectAllQuestions, workingResourcePool, activeResourcePool, addToWorkingResourcePool, removeFromWorkingResourcePool, showBookmarks, - selectedResourcesInformation$, + selectedQuestionsInformation$, + selectQuiz$, + selectPracticeQuizLabel$, }; }, + props: { + selectPracticeQuiz: { + type: Boolean, + default: false, + }, + }, computed: { isTopicIdSet() { return this.$route.params.topic_id; @@ -521,12 +553,18 @@ // the resourceSelection component now renderes only the // the exercises that are bookmarked for the Quiz selection. return { - name: PageNames.QUIZ_SELECT_RESOURCES, + ...this.$route, query: { showBookmarks: true }, }; }, channelsLink() { - return this.$router.getRoute(PageNames.QUIZ_SELECT_RESOURCES, { topic_id: null }); + return { + name: this.$route.name, + params: { + ...this.$route.params, + topic_id: null, + }, + }; }, /* selectAllIsVisible() { @@ -578,7 +616,11 @@ } }, showTopicSizeWarningCard(content) { - return !this.hasCheckbox(content) && content.kind === ContentNodeKinds.TOPIC; + return ( + !this.selectPracticeQuiz && + !this.hasCheckbox(content) && + content.kind === ContentNodeKinds.TOPIC + ); }, showTopicSizeWarning() { return this.contentList.some(this.showTopicSizeWarningCard); @@ -588,70 +630,88 @@ this.$refs.textbox.focus(); }, contentLink(content) { - /* The click handler for the content card, no-op for non-folder cards */ - if (this.showBookmarks) { - // If we're showing bookmarks, we don't want to link to anything - const { name, params, query } = this.$route; - return { name, params, query }; - } else if (!content.is_leaf) { + if (!content.is_leaf) { + const { name, params } = this.$route; // Link folders to their page return { - name: PageNames.QUIZ_SELECT_RESOURCES, + name, params: { + ...params, topic_id: content.id, - classId: this.$route.params.classId, - sectionIndex: this.activeSectionIndex, }, }; } return {}; // Or this could be how we handle leaf nodes if we wanted them to link somewhere }, topicsLink(topic_id) { - return this.$router.getRoute(PageNames.QUIZ_SELECT_RESOURCES, { topic_id }); + return this.contentLink({ id: topic_id }); }, saveSelectedResource() { - this.updateSection({ - sectionIndex: this.activeSectionIndex, - resource_pool: this.workingResourcePool.map(resource => { - // Add the unique_question_ids to the resource - const unique_question_ids = resource.assessmentmetadata.assessment_item_ids.map( - question_id => { - return `${resource.id}:${question_id}`; - } - ); + const resource_pool = this.workingResourcePool.map(resource => { + // Add the unique_question_ids to the resource + const unique_question_ids = resource.assessmentmetadata.assessment_item_ids.map( + question_id => { + return `${resource.id}:${question_id}`; + } + ); + return { + ...resource, + unique_question_ids, + }; + }); + if (this.selectPracticeQuiz) { + if (this.workingResourcePool.length !== 1) { + throw new Error('Only one resource can be selected for a practice quiz'); + } + const quiz = this.workingResourcePool[0]; + const questions = quiz.assessmentmetadata.assessment_item_ids.map((question_id, i) => { return { - ...resource, - unique_question_ids, + exercise_id: quiz.id, + question_id, + counter_in_exercise: i + 1, + title: '', + item: `${quiz.id}:${question_id}`, }; - }), - }); + }); + this.updateSection({ + sectionIndex: this.activeSectionIndex, + questions, + resource_pool, + }); + } else { + this.updateSectionResourcePool({ + sectionIndex: this.activeSectionIndex, + resource_pool, + }); + } this.resetWorkingResourcePool(); - + const route = this.selectPracticeQuiz + ? { name: PageNames.EXAM_CREATION_ROOT, sectionId: this.activeSectionIndex } + : this.prevRoute; this.$router.replace({ - ...this.prevRoute, + ...route, }); this.$store.dispatch('createSnackbar', this.changesSavedSuccessfully$()); }, // The message put onto the content's card when listed selectionMetadata(content) { - if (content.kind === ContentNodeKinds.TOPIC) { - const total = content.num_exercises; - const numberOfresourcesSelected = this.workingResourcePool.reduce((acc, wr) => { - if (wr.ancestors.map(ancestor => ancestor.id).includes(content.id)) { - return acc + 1; - } - return acc; - }, 0); - - return this.selectedResourcesInformation$({ - count: numberOfresourcesSelected, - total: total, - }); - } else { - // content is an exercise + if (this.selectPracticeQuiz || content.kind !== ContentNodeKinds.TOPIC) { + return; } + const total = content.num_exercises; + const numberOfresourcesSelected = this.workingResourcePool.reduce((acc, wr) => { + if (wr.ancestors.map(ancestor => ancestor.id).includes(content.id)) { + return acc + wr.assessmentmetadata.assessment_item_ids.length; + } + return acc; + }, 0); + + return this.selectedQuestionsInformation$({ + count: numberOfresourcesSelected, + total: total, + }); }, handleSearchTermChange(searchTerm) { const query = { diff --git a/kolibri/plugins/coach/assets/src/views/plan/LessonResourceSelectionPage/ContentCardList.vue b/kolibri/plugins/coach/assets/src/views/plan/LessonResourceSelectionPage/ContentCardList.vue index add1a52b31d..74e7166b92a 100644 --- a/kolibri/plugins/coach/assets/src/views/plan/LessonResourceSelectionPage/ContentCardList.vue +++ b/kolibri/plugins/coach/assets/src/views/plan/LessonResourceSelectionPage/ContentCardList.vue @@ -16,7 +16,7 @@ :aria-selected="contentIsChecked(content)" > +