diff --git a/kolibri/plugins/coach/assets/src/composables/useQuizCreation.js b/kolibri/plugins/coach/assets/src/composables/useQuizCreation.js index 22485c6c14e..ac6ff58ea5d 100644 --- a/kolibri/plugins/coach/assets/src/composables/useQuizCreation.js +++ b/kolibri/plugins/coach/assets/src/composables/useQuizCreation.js @@ -1,5 +1,6 @@ -import { v4 as uuidv4 } from 'uuid'; +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'; @@ -16,6 +17,10 @@ import { Quiz, QuizSection, QuizQuestion, QuizExercise } from './quizCreationSpe const logger = logging.getLogger(__filename); +function uuidv4() { + return v4().replace(/-/g, ''); +} + /** Validators **/ /* objectSpecs expects every property to be available -- but we don't want to have to make an * object with every property just to validate it. So we use these functions to validate subsets @@ -41,6 +46,10 @@ 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)); @@ -265,6 +274,12 @@ export default function useQuizCreation(DEBUG = false) { _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)); + } + /** * @returns {Promise} * @throws {Error} if quiz is not valid @@ -282,7 +297,19 @@ export default function useQuizCreation(DEBUG = false) { if (!validateQuiz(get(_quiz))) { throw new Error(`Quiz is not valid: ${JSON.stringify(get(_quiz))}`); } - return ExamResource.saveModel({ data: get(_quiz) }); + + // Here we update each section's `resource_pool` to only be the IDs of the resources + const sectionsWithResourcePoolAsIDs = get(allSections).map(section => { + const resourcePoolAsIds = get(section).resource_pool.map(content => content.id); + section.resource_pool = resourcePoolAsIds; + return section; + }); + + const finalQuiz = get(_quiz); + + finalQuiz.question_sources = sectionsWithResourcePoolAsIDs; + + return ExamResource.saveModel({ data: finalQuiz }); } /** @@ -337,6 +364,11 @@ 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() { @@ -408,6 +440,9 @@ 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 */ @@ -445,12 +480,45 @@ 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); @@ -460,11 +528,14 @@ 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('activeQuestionsPool', activeQuestionsPool); provide('activeQuestions', activeQuestions); @@ -477,8 +548,13 @@ export default function useQuizCreation(DEBUG = false) { return { // Methods saveQuiz, + initializeWorkingResourcePool, + removeFromWorkingResourcePool, + addToWorkingResourcePool, + contentPresentInWorkingResourcePool, updateSection, replaceSelectedQuestions, + resetWorkingResourcePool, addSection, removeSection, setActiveSection, @@ -493,6 +569,8 @@ export default function useQuizCreation(DEBUG = false) { allSections, activeSection, inactiveSections, + workingResourcePool, + activeResourcePool, activeExercisePool, activeQuestionsPool, activeQuestions, @@ -516,9 +594,14 @@ 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'); @@ -530,6 +613,8 @@ export function injectQuizCreation() { const allSections = inject('allSections'); const activeSection = inject('activeSection'); const inactiveSections = inject('inactiveSections'); + const activeResourcePool = inject('activeResourcePool'); + const workingResourcePool = inject('workingResourcePool'); const activeExercisePool = inject('activeExercisePool'); const activeQuestionsPool = inject('activeQuestionsPool'); const activeQuestions = inject('activeQuestions'); @@ -542,9 +627,14 @@ export function injectQuizCreation() { return { // Methods saveQuiz, + initializeWorkingResourcePool, + addToWorkingResourcePool, + contentPresentInWorkingResourcePool, + removeFromWorkingResourcePool, deleteActiveSelectedQuestions, selectAllQuestions, updateSection, + resetWorkingResourcePool, replaceSelectedQuestions, addSection, removeSection, @@ -561,6 +651,8 @@ export function injectQuizCreation() { allSections, activeSection, inactiveSections, + workingResourcePool, + activeResourcePool, activeExercisePool, activeQuestionsPool, activeQuestions, diff --git a/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/ConfirmCancellationModal.vue b/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/ConfirmCancellationModal.vue new file mode 100644 index 00000000000..38497c9b061 --- /dev/null +++ b/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/ConfirmCancellationModal.vue @@ -0,0 +1,68 @@ + + + + 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 f00f37f897f..1d9319952dc 100644 --- a/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/CreateQuizSection.vue +++ b/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/CreateQuizSection.vue @@ -304,6 +304,8 @@ + + @@ -382,6 +384,7 @@ allSections, activeSection, inactiveSections, + workingResourcePool, activeExercisePool, activeQuestionsPool, activeQuestions, @@ -431,6 +434,7 @@ allSections, activeSection, inactiveSections, + workingResourcePool, activeExercisePool, activeQuestionsPool, activeQuestions, @@ -515,6 +519,7 @@ focusActiveSectionTab() { const label = this.tabRefLabel(this.activeSection.section_id); const tabRef = this.$refs[label]; + // TODO Consider the "Delete section" button on the side panel; maybe we need to await // nextTick if we're getting the error if (tabRef) { 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 5d67bbe716a..582d485e8df 100644 --- a/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/ResourceSelection.vue +++ b/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/ResourceSelection.vue @@ -8,13 +8,6 @@
- - - {{ /* selectFoldersOrExercises$() */ }}
@@ -53,11 +46,12 @@ :contentList="contentList" :showSelectAll="true" :viewMoreButtonState="viewMoreButtonState" - :selectAllChecked="false" - :contentIsChecked="() => false" + :selectAllChecked="isSelectAllChecked" + :contentIsChecked="contentPresentInWorkingResourcePool" :contentHasCheckbox="hasCheckbox" :contentCardMessage="selectionMetadata" :contentCardLink="contentLink" + :selectAllIndeterminate="selectAllIndeterminate" @changeselectall="toggleTopicInWorkingResources" @change_content_card="toggleSelected" @moreresults="fetchMoreQuizResources" @@ -78,9 +72,10 @@ :layout4="{ span: 2 }" > @@ -102,7 +97,7 @@ import { PageNames } from '../../../constants'; import BookmarkIcon from '../LessonResourceSelectionPage/LessonContentCard/BookmarkIcon.vue'; import useQuizResources from '../../../composables/useQuizResources'; - //import { injectQuizCreation } from '../../../composables/useQuizCreation'; + import { injectQuizCreation } from '../../../composables/useQuizCreation'; import ContentCardList from './../LessonResourceSelectionPage/ContentCardList.vue'; import ResourceSelectionBreadcrumbs from './../LessonResourceSelectionPage/SearchTools/ResourceSelectionBreadcrumbs.vue'; @@ -118,7 +113,19 @@ const store = getCurrentInstance().proxy.$store; const route = computed(() => store.state.route); const topicId = computed(() => route.value.params.topic_id); - + const { + updateSection, + activeSection, + selectAllQuestions, + workingResourcePool, + addToWorkingResourcePool, + removeFromWorkingResourcePool, + resetWorkingResourcePool, + contentPresentInWorkingResourcePool, + initializeWorkingResourcePool, + } = injectQuizCreation(); + + initializeWorkingResourcePool(); const { sectionSettings$, selectFromBookmarks$, @@ -230,6 +237,8 @@ loading, hasMore, fetchMoreQuizResources, + resetWorkingResourcePool, + contentPresentInWorkingResourcePool, //contentList, sectionSettings$, selectFromBookmarks$, @@ -241,12 +250,38 @@ bookmarks, channels, viewMoreButtonState, + updateSection, + activeSection, + selectAllQuestions, + workingResourcePool, + addToWorkingResourcePool, + removeFromWorkingResourcePool, }; }, + props: { + closePanelRoute: { + type: Object, + required: true, + }, + }, computed: { isTopicIdSet() { return this.$route.params.topic_id; }, + isSelectAllChecked() { + // Returns true if all the resources in the topic are in the working resource pool + const workingResourceIds = this.workingResourcePool.map(wr => wr.id); + return this.contentList.every(content => workingResourceIds.includes(content.id)); + }, + selectAllIndeterminate() { + // Returns true if some, but not all, of the resources in the topic are in the working + // resource + const workingResourceIds = this.workingResourcePool.map(wr => wr.id); + return ( + !this.isSelectAllChecked && + this.contentList.some(content => workingResourceIds.includes(content.id)) + ); + }, selectionMetadata(/*content*/) { // TODO This should return a function that returns a string telling us how many of this // topic's descendants are selected out of its total descendants -- basically answering @@ -270,12 +305,7 @@ // console.log('Dynamic function called'); // }; }, - goBack() { - // TODO This should only be shown w/ the back arrow KRouterLink when we've gone past the - // initial screen w/ the channels - // See https://github.com/learningequality/kolibri/issues/11733 - return {}; // This will need to be gleaned in a nav guard - }, + getBookmarksLink() { return { name: PageNames.BOOK_MARKED_RESOURCES, @@ -323,19 +353,19 @@ if (checked) { this.addToSelectedResources(content); } else { - this.removeFromSelectedResources([content]); + this.removeFromWorkingResourcePool(content); } }, + addToSelectedResources(content) { + this.addToWorkingResourcePool([content]); + }, toggleTopicInWorkingResources(isChecked) { if (isChecked) { - this.addableContent.forEach(resource => { - this.addToResourceCache({ - node: { ...resource }, - }); - }); - this.addToWorkingResources(this.addableContent); + this.isSelectAllChecked = true; + this.addToWorkingResourcePool(this.contentList); } else { - this.removeFromSelectedResources(this.channels.value); + this.isSelectAllChecked = false; + this.resetWorkingResourcePool(); } }, topicListingLink({ topicId }) { @@ -348,6 +378,33 @@ topicsLink(topicId) { return this.topicListingLink({ ...this.$route.params, topicId }); }, + hasTopicId() { + return Boolean(this.$route.params.topic_id); + }, + saveSelectedResource() { + this.updateSection({ + section_id: this.$route.params.section_id, + resource_pool: this.workingResourcePool, + }); + + //Also reset workingResourcePool + this.resetWorkingResourcePool(); + + this.$router.replace(this.closePanelRoute); + }, + // selectionMetadata(content) { + // if (content.kind === ContentNodeKinds.TOPIC) { + // const count = content.exercises.filter(exercise => + // Boolean(this.selectedExercises[exercise.id]) + // ).length; + // if (count === 0) { + // return ''; + // } + // const total = content.exercises.length; + // return this.$tr('total_number', { count, total }); + // } + // return ''; + // }, }, }; diff --git a/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/SectionSidePanel.vue b/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/SectionSidePanel.vue index 20b31d9f655..53bc6e0202d 100644 --- a/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/SectionSidePanel.vue +++ b/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/SectionSidePanel.vue @@ -1,16 +1,23 @@ @@ -20,9 +27,11 @@ import SidePanelModal from 'kolibri-common/components/SidePanelModal'; import { PageNames } from '../../../constants'; import ResourceSelectionBreadcrumbs from '../../plan/LessonResourceSelectionPage/SearchTools/ResourceSelectionBreadcrumbs'; + import { injectQuizCreation } from '../../../composables/useQuizCreation'; import SectionEditor from './SectionEditor'; import ReplaceQuestions from './ReplaceQuestions'; import ResourceSelection from './ResourceSelection'; + import ConfirmCancellationModal from './ConfirmCancellationModal.vue'; //import ShowBookMarkedResources from './ShowBookMarkedResources.vue'; // import SelectedChannel from './SelectedChannel.vue'; @@ -42,10 +51,25 @@ ResourceSelection, // SelectedChannel, ResourceSelectionBreadcrumbs, - //ShowBookMarkedResources, + ConfirmCancellationModal, + }, + setup() { + const { + //Computed + activeResourcePool, + workingResourcePool, + resetWorkingResourcePool, + } = injectQuizCreation(); + + return { + activeResourcePool, + workingResourcePool, + resetWorkingResourcePool, + }; }, data() { return { + showConfirmationModal: false, prevRoute: { name: PageNames.EXAM_CREATION_ROOT }, }; }, @@ -81,8 +105,12 @@ }, methods: { handleClosePanel() { + if (this.workingResourcePool.length > this.activeResourcePool.length) { + this.showConfirmationModal = true; + } else { + this.$router.replace(this.closePanelRoute); + } this.$emit('closePanel'); - this.$router.replace(this.closePanelRoute); }, /** * Calls the currently displayed ref's focusFirstEl method.