diff --git a/kolibri/core/assets/src/api-resources/contentNode.js b/kolibri/core/assets/src/api-resources/contentNode.js index 57cd47e8d7c..db89e06f92d 100644 --- a/kolibri/core/assets/src/api-resources/contentNode.js +++ b/kolibri/core/assets/src/api-resources/contentNode.js @@ -104,8 +104,8 @@ export default new Resource({ fetchRecommendationsFor(id, getParams) { return this.fetchDetailCollection('recommendations_for', id, getParams); }, - fetchResume(getParams) { - return this.fetchDetailCollection('resume', Store.getters.currentUserId, getParams); + fetchResume(getParams, force) { + return this.fetchDetailCollection('resume', Store.getters.currentUserId, getParams, force); }, fetchPopular(getParams) { return this.fetchListCollection('popular', getParams); diff --git a/kolibri/core/assets/src/views/TextTruncatorCss.vue b/kolibri/core/assets/src/views/TextTruncatorCss.vue index ea6fd1920ea..05eb673054c 100644 --- a/kolibri/core/assets/src/views/TextTruncatorCss.vue +++ b/kolibri/core/assets/src/views/TextTruncatorCss.vue @@ -6,9 +6,16 @@ of what truncating technique is used. Otherwise adding padding directly would break when using technique (B) since text that should be truncated would show in padding area. + + Attributes are inherited by the inner `div` to emulate the same behavior + like if only one element would wrap the text to allow attributes be applied + as close as possible to the text element. -->
-
+
{{ text }}
@@ -28,6 +35,7 @@ */ export default { name: 'TextTruncatorCss', + inheritAttrs: false, props: { text: { type: String, @@ -114,7 +122,7 @@ position: 'absolute', right: 0, width: ellipsisWidth, - height: '50%', + height: '100%', background: 'white', }, }; diff --git a/kolibri/plugins/learn/assets/src/composables/__mocks__/useChannels.js b/kolibri/plugins/learn/assets/src/composables/__mocks__/useChannels.js new file mode 100644 index 00000000000..24d5677ce1c --- /dev/null +++ b/kolibri/plugins/learn/assets/src/composables/__mocks__/useChannels.js @@ -0,0 +1,46 @@ +/** + * `useChannels` composable function mock. + * + * If default values are sufficient for tests, + * you only need call `jest.mock('')` + * at the top of a test file. + * + * If you need to override some default values from some tests, + * you can import a helper function `useChannelsMock` that accepts + * an object with values to be overriden and use it together + * with `mockImplementation` as follows: + * + * ``` + * // eslint-disable-next-line import/named + * import useChannels, { useChannelsMock } from ''; + * + * jest.mock('') + * + * it('test', () => { + * useChannels.mockImplementation( + * () => useChannelsMock({ channels: [{ id: 'channel-1' }] }) + * ); + * }) + * ``` + * + * You can reset your mock implementation back to default values + * for other tests by calling the following in `beforeEach`: + * + * ``` + * useChannels.mockImplementation(() => useChannelsMock()) + * ``` + */ + +const MOCK_DEFAULTS = { + channels: [], + fetchChannels: jest.fn(), +}; + +export function useChannelsMock(overrides = {}) { + return { + ...MOCK_DEFAULTS, + ...overrides, + }; +} + +export default jest.fn(() => useChannelsMock()); diff --git a/kolibri/plugins/learn/assets/src/composables/__mocks__/useLearnerResources.js b/kolibri/plugins/learn/assets/src/composables/__mocks__/useLearnerResources.js index 7132a190c09..125251880f7 100644 --- a/kolibri/plugins/learn/assets/src/composables/__mocks__/useLearnerResources.js +++ b/kolibri/plugins/learn/assets/src/composables/__mocks__/useLearnerResources.js @@ -33,14 +33,22 @@ const MOCK_DEFAULTS = { classes: [], + activeClassesLessons: [], + activeClassesQuizzes: [], resumableClassesQuizzes: [], resumableClassesResources: [], resumableNonClassesContentNodes: [], + learnerFinishedAllClasses: false, getClass: jest.fn(), + getClassActiveLessons: jest.fn(), + getClassActiveQuizzes: jest.fn(), getResumableContentNode: jest.fn(), + getResumableContentNodeProgress: jest.fn(), + getClassLessonLink: jest.fn(), getClassQuizLink: jest.fn(), getClassResourceLink: jest.fn(), getTopicContentNodeLink: jest.fn(), + fetchClass: jest.fn(), fetchClasses: jest.fn(), fetchResumableContentNodes: jest.fn(), }; diff --git a/kolibri/plugins/learn/assets/src/composables/__tests__/useLearnerResources.spec.js b/kolibri/plugins/learn/assets/src/composables/__tests__/useLearnerResources.spec.js index a7478dccd3f..a2c72215cc5 100644 --- a/kolibri/plugins/learn/assets/src/composables/__tests__/useLearnerResources.spec.js +++ b/kolibri/plugins/learn/assets/src/composables/__tests__/useLearnerResources.spec.js @@ -1,5 +1,6 @@ -import { ContentNodeResource } from 'kolibri.resources'; +import cloneDeep from 'lodash/cloneDeep'; +import { ContentNodeResource, ContentNodeProgressResource } from 'kolibri.resources'; import { PageNames, ClassesPageNames } from '../../constants'; import { LearnerClassroomResource } from '../../apiResources'; import useLearnerResources from '../useLearnerResources'; @@ -11,8 +12,12 @@ const { resumableClassesQuizzes, resumableClassesResources, resumableNonClassesContentNodes, + learnerFinishedAllClasses, getClass, + getClassActiveLessons, + getClassActiveQuizzes, getResumableContentNode, + getResumableContentNodeProgress, getClassLessonLink, getClassQuizLink, getClassResourceLink, @@ -36,6 +41,37 @@ const TEST_RESUMABLE_CONTENT_NODES = [ { id: 'resource-10-in-progress', title: 'Resource 10 (In Progress)' }, ]; +const TEST_CONTENT_NODES_PROGRESSES = [ + { + id: 'resource-1-in-progress', + progress_fraction: 0.2, + }, + { + id: 'resource-3-in-progress', + progress_fraction: 0.74, + }, + { + id: 'resource-5-in-progress', + progress_fraction: 0.04, + }, + { + id: 'resource-6-in-progress', + progress_fraction: 0.1, + }, + { + id: 'resource-8-in-progress', + progress_fraction: 0.9, + }, + { + id: 'resource-9-in-progress', + progress_fraction: 0.87, + }, + { + id: 'resource-10-in-progress', + progress_fraction: 0.02, + }, +]; + const TEST_CLASSES = [ { id: 'class-1', @@ -84,6 +120,10 @@ const TEST_CLASSES = [ { contentnode_id: 'resource-2' }, { contentnode_id: 'resource-3-in-progress' }, ], + progress: { + resource_progress: 0, + total_resources: 3, + }, }, { id: 'class-1-active-lesson-2', @@ -95,6 +135,10 @@ const TEST_CLASSES = [ { contentnode_id: 'resource-4' }, { contentnode_id: 'resource-5-in-progress' }, ], + progress: { + resource_progress: 1, + total_resources: 3, + }, }, { id: 'class-1-inactive-lesson', @@ -102,6 +146,10 @@ const TEST_CLASSES = [ is_active: false, collection: 'class-1', resources: [{ contentnode_id: 'resource-5-in-progress' }], + progress: { + resource_progress: 0, + total_resources: 1, + }, }, ], }, @@ -143,6 +191,10 @@ const TEST_CLASSES = [ { contentnode_id: 'resource-2' }, { contentnode_id: 'resource-1-in-progress' }, ], + progress: { + resource_progress: 1, + total_resources: 3, + }, }, { id: 'class-2-inactive-lesson', @@ -153,15 +205,52 @@ const TEST_CLASSES = [ { contentnode_id: 'resource-7' }, { contentnode_id: 'resource-8-in-progress' }, ], + progress: { + resource_progress: 0, + total_resources: 2, + }, }, ], }, }, ]; +// A helper that takes an object with test classes +// and returns its copy with all lessons and quizzes +// progress changed to complete +function finishClasses(classes) { + const finishedClasses = cloneDeep(classes); + return finishedClasses.map(c => { + c.assignments.exams.forEach(quiz => { + quiz.progress.closed = true; + }); + c.assignments.lessons.forEach(lesson => { + lesson.progress.resource_progress = lesson.progress.total_resources; + }); + return c; + }); +} + +// A helper that takes an object with test classes +// and returns its copy with all lessons and quizzes +// changed to inactive +function inactivateClasses(classes) { + const inactiveClasses = cloneDeep(classes); + return inactiveClasses.map(c => { + c.assignments.exams.forEach(quiz => { + quiz.active = false; + }); + c.assignments.lessons.forEach(lesson => { + lesson.is_active = false; + }); + return c; + }); +} + describe(`useLearnerResources`, () => { - beforeAll(() => { + beforeEach(() => { ContentNodeResource.fetchResume.mockResolvedValue(TEST_RESUMABLE_CONTENT_NODES); + ContentNodeProgressResource.fetchCollection.mockResolvedValue(TEST_CONTENT_NODES_PROGRESSES); fetchResumableContentNodes(); LearnerClassroomResource.fetchCollection.mockResolvedValue(TEST_CLASSES); @@ -187,6 +276,10 @@ describe(`useLearnerResources`, () => { { contentnode_id: 'resource-2' }, { contentnode_id: 'resource-3-in-progress' }, ], + progress: { + resource_progress: 0, + total_resources: 3, + }, }, { id: 'class-1-active-lesson-2', @@ -198,6 +291,10 @@ describe(`useLearnerResources`, () => { { contentnode_id: 'resource-4' }, { contentnode_id: 'resource-5-in-progress' }, ], + progress: { + resource_progress: 1, + total_resources: 3, + }, }, { id: 'class-2-active-lesson-1', @@ -209,6 +306,10 @@ describe(`useLearnerResources`, () => { { contentnode_id: 'resource-2' }, { contentnode_id: 'resource-1-in-progress' }, ], + progress: { + resource_progress: 1, + total_resources: 3, + }, }, ]); }); @@ -336,12 +437,106 @@ describe(`useLearnerResources`, () => { }); }); + describe(`learnerFinishedAllClasses`, () => { + it(`returns 'true' if a learner has no classes`, () => { + LearnerClassroomResource.fetchCollection.mockResolvedValue([]); + fetchClasses().then(() => { + expect(learnerFinishedAllClasses.value).toBe(true); + }); + }); + + it(`returns 'true' if a learner has no active lessons and quizzes`, () => { + LearnerClassroomResource.fetchCollection.mockResolvedValue(inactivateClasses(TEST_CLASSES)); + fetchClasses().then(() => { + expect(learnerFinishedAllClasses.value).toBe(true); + }); + }); + + it(`returns 'false' if a learner hasn't finished all lessons and quizzes yet`, () => { + LearnerClassroomResource.fetchCollection.mockResolvedValue(TEST_CLASSES); + fetchClasses().then(() => { + expect(learnerFinishedAllClasses.value).toBe(false); + }); + }); + + it(`returns 'true' if a learner finished all lessons and quizzes`, () => { + LearnerClassroomResource.fetchCollection.mockResolvedValue(finishClasses(TEST_CLASSES)); + fetchClasses().then(() => { + expect(learnerFinishedAllClasses.value).toBe(true); + }); + }); + }); + describe(`getClass`, () => { it(`returns a class`, () => { expect(getClass('class-2')).toEqual(TEST_CLASSES[1]); }); }); + describe(`getClassActiveLessons`, () => { + it(`returns all active lessons from a class`, () => { + expect(getClassActiveLessons('class-1')).toEqual([ + { + id: 'class-1-active-lesson-1', + title: 'Class 1 - Active Lesson 1', + is_active: true, + collection: 'class-1', + resources: [ + { contentnode_id: 'resource-1-in-progress' }, + { contentnode_id: 'resource-2' }, + { contentnode_id: 'resource-3-in-progress' }, + ], + progress: { + resource_progress: 0, + total_resources: 3, + }, + }, + { + id: 'class-1-active-lesson-2', + title: 'Class 1 - Active Lesson 2', + is_active: true, + collection: 'class-1', + resources: [ + { contentnode_id: 'resource-1-in-progress' }, + { contentnode_id: 'resource-4' }, + { contentnode_id: 'resource-5-in-progress' }, + ], + progress: { + resource_progress: 1, + total_resources: 3, + }, + }, + ]); + }); + }); + + describe(`getClassActiveQuizzes`, () => { + it(`returns all active quizzes from a class`, () => { + expect(getClassActiveQuizzes('class-1')).toEqual([ + { + id: 'class-1-active-quiz-in-progress', + title: 'Class 1 - Active Quiz In Progress', + active: true, + collection: 'class-1', + progress: { + started: true, + closed: false, + }, + }, + { + id: 'class-1-active-finished-quiz', + title: 'Class 1 - Active Finished Quiz', + active: true, + collection: 'class-1', + progress: { + started: true, + closed: true, + }, + }, + ]); + }); + }); + describe(`getResumableContentNode`, () => { it(`returns a resumable content node object by its ID`, () => { expect(getResumableContentNode('resource-6-in-progress')).toEqual({ @@ -351,6 +546,12 @@ describe(`useLearnerResources`, () => { }); }); + describe(`getResumableContentNodeProgress`, () => { + it(`returns a resumable content node progress`, () => { + expect(getResumableContentNodeProgress('resource-3-in-progress')).toBe(0.74); + }); + }); + describe(`getClassQuizLink`, () => { it(`returns a vue-router link to a quiz report page when the quiz is closed`, () => { expect( diff --git a/kolibri/plugins/learn/assets/src/composables/useChannels.js b/kolibri/plugins/learn/assets/src/composables/useChannels.js new file mode 100644 index 00000000000..12fd182de21 --- /dev/null +++ b/kolibri/plugins/learn/assets/src/composables/useChannels.js @@ -0,0 +1,32 @@ +/** + * A composable function containing logic related to channels + */ + +import { ref } from 'kolibri.lib.vueCompositionApi'; +import { set } from '@vueuse/core'; +import { ChannelResource } from 'kolibri.resources'; + +// The refs are defined in the outer scope so they can be used as a shared store +const channels = ref([]); + +export default function useChannels() { + /** + * Fetches channels and saves data to this composable's store + * + * @param {Boolean} available only get the channels that are "available" + * (i.e. with resources on device) when `true` + * @returns {Promise} + * @public + */ + function fetchChannels({ available = true } = {}) { + return ChannelResource.fetchCollection({ getParams: { available } }).then(collection => { + set(channels, collection); + return collection; + }); + } + + return { + channels, + fetchChannels, + }; +} diff --git a/kolibri/plugins/learn/assets/src/composables/useLearnerResources.js b/kolibri/plugins/learn/assets/src/composables/useLearnerResources.js index 6e866585d7a..9d6aedafeac 100644 --- a/kolibri/plugins/learn/assets/src/composables/useLearnerResources.js +++ b/kolibri/plugins/learn/assets/src/composables/useLearnerResources.js @@ -9,12 +9,13 @@ import { computed, ref } from 'kolibri.lib.vueCompositionApi'; import { get, set } from '@vueuse/core'; import flatMap from 'lodash/flatMap'; -import { ContentNodeResource } from 'kolibri.resources'; +import { ContentNodeResource, ContentNodeProgressResource } from 'kolibri.resources'; import { LearnerClassroomResource } from '../apiResources'; import { PageNames, ClassesPageNames } from '../constants'; -// The refs are defined in the outer scope so they can be use as a store +// The refs are defined in the outer scope so they can be used as a shared store const _resumableContentNodes = ref([]); +const _resumableContentNodesProgresses = ref([]); const classes = ref([]); export default function useLearnerResources() { @@ -103,7 +104,10 @@ export default function useLearnerResources() { if (!lesson) { return undefined; } - return lesson.resources.findIndex(r => r.contentnode_id === resource.contentNodeId); + const lessonResourceIdx = lesson.resources.findIndex( + r => r.contentnode_id === resource.contentNodeId + ); + return lessonResourceIdx === -1 ? undefined : lessonResourceIdx; } /** @@ -153,6 +157,21 @@ export default function useLearnerResources() { ); }); + /** + * @returns {Boolean} - `true` if a learner finished all active + * classes lessons and quizzes (or when there are none) + * @public + */ + const learnerFinishedAllClasses = computed(() => { + const hasUnfinishedLesson = get(activeClassesLessons).some(lesson => { + return lesson.progress.resource_progress < lesson.progress.total_resources; + }); + const hasUnfinishedQuiz = get(activeClassesQuizzes).some(quiz => { + return !quiz.progress.closed; + }); + return !(hasUnfinishedLesson || hasUnfinishedQuiz); + }); + /** * @param {String} classId * @returns {Object} A class @@ -162,6 +181,32 @@ export default function useLearnerResources() { return get(classes).find(c => c.id === classId); } + /** + * @param {String} classId + * @returns {Array} All active lessons of a class + * @public + */ + function getClassActiveLessons(classId) { + const classroom = getClass(classId); + if (!classroom || !classroom.assignments || !classroom.assignments.lessons) { + return []; + } + return classroom.assignments.lessons.filter(lesson => lesson.is_active); + } + + /** + * @param {String} classId + * @returns {Array} All active quizzes of a class + * @public + */ + function getClassActiveQuizzes(classId) { + const classroom = getClass(classId); + if (!classroom || !classroom.assignments || !classroom.assignments.exams) { + return []; + } + return classroom.assignments.exams.filter(exam => exam.active); + } + /** * @param {String} contentNodeId * @returns {Object} @@ -171,6 +216,21 @@ export default function useLearnerResources() { return get(_resumableContentNodes).find(contentNode => contentNode.id === contentNodeId); } + /** + * @param {String} contentNodeId + * @returns {Number} A content node progress as a fraction between 0 and 1 + * @public + */ + function getResumableContentNodeProgress(contentNodeId) { + const progressData = get(_resumableContentNodesProgresses).find( + item => item.id === contentNodeId + ); + if (!progressData) { + return undefined; + } + return progressData.progress_fraction; + } + /** * @param {Object} lesson * @returns {Object} vue-router link to a lesson page @@ -227,7 +287,7 @@ export default function useLearnerResources() { */ function getClassResourceLink(resource) { const lessonResourceIdx = _getLessonResourceIdx(resource); - if (!lessonResourceIdx) { + if (lessonResourceIdx === undefined) { return undefined; } return { @@ -253,22 +313,60 @@ export default function useLearnerResources() { } /** + * Fetches a class by its ID and saves data + * to this composable's store + * + * @param {String} classId + * @param {Boolean} force Cache won't be used when `true` + * @returns {Promise} + * @public + */ + function fetchClass({ classId, force = false }) { + return LearnerClassroomResource.fetchModel({ id: classId, force }).then(classroom => { + const updatedClasses = [...get(classes).filter(c => c.id !== classId), classroom]; + set(classes, updatedClasses); + return classroom; + }); + } + + /** + * Fetches current learner's classes + * and saves data to this composable's store + * + * @param {Boolean} force Cache won't be used when `true` * @returns {Promise} * @public */ - function fetchClasses() { - LearnerClassroomResource.fetchCollection().then(collection => { + function fetchClasses({ force = false } = {}) { + return LearnerClassroomResource.fetchCollection({ force }).then(collection => { set(classes, collection); }); } /** + * Fetches resumable content nodes with their progress data + * and saves data to this composable's store + * + * @param {Boolean} force Cache won't be used when `true` * @returns {Promise} * @public */ - function fetchResumableContentNodes() { - ContentNodeResource.fetchResume().then(collection => { - set(_resumableContentNodes, collection); + function fetchResumableContentNodes({ force = false } = {}) { + return ContentNodeResource.fetchResume({}, force).then(contentNodes => { + if (!contentNodes || !contentNodes.length) { + return []; + } + set(_resumableContentNodes, contentNodes); + const contentNodesIds = contentNodes.map(contentNode => contentNode.id); + return ContentNodeProgressResource.fetchCollection({ + getParams: { ids: contentNodesIds }, + force, + }).then(progresses => { + if (progresses) { + set(_resumableContentNodesProgresses, progresses); + } + return contentNodes; + }); }); } @@ -279,12 +377,17 @@ export default function useLearnerResources() { resumableClassesQuizzes, resumableClassesResources, resumableNonClassesContentNodes, + learnerFinishedAllClasses, getClass, getResumableContentNode, + getResumableContentNodeProgress, + getClassActiveLessons, + getClassActiveQuizzes, getClassLessonLink, getClassQuizLink, getClassResourceLink, getTopicContentNodeLink, + fetchClass, fetchClasses, fetchResumableContentNodes, }; diff --git a/kolibri/plugins/learn/assets/src/modules/classAssignments/handlers.js b/kolibri/plugins/learn/assets/src/modules/classAssignments/handlers.js index dabd307d107..2858deb16ea 100644 --- a/kolibri/plugins/learn/assets/src/modules/classAssignments/handlers.js +++ b/kolibri/plugins/learn/assets/src/modules/classAssignments/handlers.js @@ -1,13 +1,14 @@ -import { LearnerClassroomResource } from '../../apiResources'; +import useLearnerResources from '../../composables/useLearnerResources'; import { ClassesPageNames } from '../../constants'; +const { fetchClass } = useLearnerResources(); + // For a given Classroom, shows a list of all Exams and Lessons assigned to the Learner export function showClassAssignmentsPage(store, classId) { return store.dispatch('loading').then(() => { - return LearnerClassroomResource.fetchModel({ id: classId }) - .then(classroom => { + return fetchClass({ classId }) + .then(() => { store.commit('SET_PAGE_NAME', ClassesPageNames.CLASS_ASSIGNMENTS); - store.commit('classAssignments/SET_CURRENT_CLASSROOM', classroom); store.dispatch('notLoading'); }) .catch(error => { diff --git a/kolibri/plugins/learn/assets/src/routes/index.js b/kolibri/plugins/learn/assets/src/routes/index.js index 0f7396f88f7..6bfef9e778e 100644 --- a/kolibri/plugins/learn/assets/src/routes/index.js +++ b/kolibri/plugins/learn/assets/src/routes/index.js @@ -1,5 +1,9 @@ +import { get } from '@vueuse/core'; import store from 'kolibri.coreVue.vuex.store'; import router from 'kolibri.coreVue.router'; +import useChannels from '../composables/useChannels'; +import useUser from '../composables/useUser'; +import useLearnerResources from '../composables/useLearnerResources'; import { showTopicsTopic, showTopicsChannel, @@ -18,6 +22,10 @@ import HomePage from '../views/HomePage'; import RecommendedSubpage from '../views/RecommendedSubpage'; import classesRoutes from './classesRoutes'; +const { isUserLoggedIn } = useUser(); +const { fetchChannels } = useChannels(); +const { fetchClasses, fetchResumableContentNodes } = useLearnerResources(); + function unassignedContentGuard() { const { canAccessUnassignedContent } = store.getters; if (!canAccessUnassignedContent) { @@ -34,16 +42,7 @@ export default [ name: PageNames.ROOT, path: '/', handler: () => { - const { memberships } = store.state; - const { canAccessUnassignedContent } = store.getters; - - // If a registered user, go to Home Page, else go to Content - return router.replace({ - name: - memberships.length > 0 || !canAccessUnassignedContent - ? PageNames.HOME - : PageNames.TOPICS_ROOT, - }); + return router.replace({ name: PageNames.HOME }); }, }, { @@ -51,8 +50,31 @@ export default [ path: '/home', component: HomePage, handler() { - store.commit('SET_PAGE_NAME', PageNames.HOME); - store.commit('CORE_SET_PAGE_LOADING', false); + let promises = [fetchChannels()]; + // force fetch classes and resumable content nodes to make sure that the home + // page is up-to-date when navigating to other 'Learn' pages and then back + // to the home page + if (get(isUserLoggedIn)) { + promises = [ + ...promises, + fetchClasses({ force: true }), + fetchResumableContentNodes({ force: true }), + ]; + } + return store.dispatch('loading').then(() => { + return Promise.all(promises) + .then(([channels]) => { + if (!channels || !channels.length) { + router.replace({ name: PageNames.CONTENT_UNAVAILABLE }); + return; + } + store.commit('SET_PAGE_NAME', PageNames.HOME); + store.dispatch('notLoading'); + }) + .catch(error => { + return store.dispatch('handleApiError', error); + }); + }); }, }, { diff --git a/kolibri/plugins/learn/assets/src/views/HomePage/ContinueLearning.vue b/kolibri/plugins/learn/assets/src/views/HomePage/ContinueLearning.vue index 70f338f9ea5..da6648f214a 100644 --- a/kolibri/plugins/learn/assets/src/views/HomePage/ContinueLearning.vue +++ b/kolibri/plugins/learn/assets/src/views/HomePage/ContinueLearning.vue @@ -14,6 +14,7 @@ v-for="(resource, idx) in uniqueResumableClassesResources" :key="`resource-${idx}`" :contentNode="getResumableContentNode(resource.contentNodeId)" + :contentNodeProgress="getResumableContentNodeProgress(resource.contentNodeId)" :to="getClassResourceLink(resource)" :collectionTitle="getResourceClassName(resource)" /> @@ -32,7 +33,7 @@ :key="idx" :contentNode="contentNode" :to="getTopicContentNodeLink(contentNode.id)" - :collectionName="getContentNodeTopicName(contentNode)" + :collectionTitle="getContentNodeTopicName(contentNode)" /> @@ -69,6 +70,7 @@ resumableNonClassesContentNodes, getClass, getResumableContentNode, + getResumableContentNodeProgress, getClassQuizLink, getClassResourceLink, getTopicContentNodeLink, @@ -104,6 +106,7 @@ resumableNonClassesContentNodes, uniqueResumableClassesResources, getResumableContentNode, + getResumableContentNodeProgress, getClassQuizLink, getClassResourceLink, getTopicContentNodeLink, diff --git a/kolibri/plugins/learn/assets/src/views/HomePage/ExploreChannels.vue b/kolibri/plugins/learn/assets/src/views/HomePage/ExploreChannels.vue deleted file mode 100644 index 592303275e6..00000000000 --- a/kolibri/plugins/learn/assets/src/views/HomePage/ExploreChannels.vue +++ /dev/null @@ -1,26 +0,0 @@ - - - - diff --git a/kolibri/plugins/learn/assets/src/views/HomePage/ExploreChannels/__tests__/ExploreChannels.spec.js b/kolibri/plugins/learn/assets/src/views/HomePage/ExploreChannels/__tests__/ExploreChannels.spec.js new file mode 100644 index 00000000000..996541e6dc0 --- /dev/null +++ b/kolibri/plugins/learn/assets/src/views/HomePage/ExploreChannels/__tests__/ExploreChannels.spec.js @@ -0,0 +1,122 @@ +import { shallowMount, mount, createLocalVue } from '@vue/test-utils'; +import VueRouter from 'vue-router'; + +import { PageNames } from '../../../../constants'; +import ExploreChannels from '../index'; + +const localVue = createLocalVue(); +localVue.use(VueRouter); + +const TEST_CHANNELS = [ + { + id: 'channel-1', + name: 'Channel 1', + }, + { + id: 'channel-2', + name: 'Channel 2', + }, + { + id: 'channel-3', + name: 'Channel 3', + }, + { + id: 'channel-4', + name: 'Channel 4', + }, +]; + +function getViewAllLink(wrapper) { + return wrapper.find('[data-test="viewAllLink"]'); +} + +function getChannelsLinks(wrapper) { + return wrapper.findAll('[data-test="channelLink"]'); +} + +function makeWrapper(propsData) { + const router = new VueRouter({ + routes: [ + { + name: PageNames.LIBRARY, + path: '/library', + }, + { + name: PageNames.TOPICS_CHANNEL, + path: '/channel', + }, + ], + }); + router.push('/'); + + return mount(ExploreChannels, { + propsData, + localVue, + router, + }); +} + +describe(`ExploreChannels`, () => { + it(`smoke test`, () => { + const wrapper = shallowMount(ExploreChannels); + expect(wrapper.exists()).toBe(true); + }); + + describe(`when 'short' is falsy`, () => { + it(`all channels are displayed`, () => { + const wrapper = makeWrapper({ channels: TEST_CHANNELS }); + const links = getChannelsLinks(wrapper); + expect(links.length).toBe(4); + TEST_CHANNELS.forEach((testChannel, idx) => { + expect(links.at(idx).text()).toBe(testChannel.name); + }); + }); + + it(`'View all' link is not displayed`, () => { + const wrapper = makeWrapper({ channels: TEST_CHANNELS }); + expect(getViewAllLink(wrapper).exists()).toBe(false); + }); + }); + + describe(`when 'short' is truthy`, () => { + it(`only first three channels are displayed`, () => { + const wrapper = makeWrapper({ channels: TEST_CHANNELS, short: true }); + const links = getChannelsLinks(wrapper); + expect(links.length).toBe(3); + TEST_CHANNELS.slice(0, 3).forEach((testChannel, idx) => { + expect( + links + .at(idx) + .find('.title') + .text() + ).toBe(testChannel.name); + }); + }); + + it(`'View all' link is not displayed when there are no more than three channels`, () => { + const wrapper = makeWrapper({ channels: TEST_CHANNELS.slice(0, 3), short: true }); + expect(getViewAllLink(wrapper).exists()).toBe(false); + }); + + it(`'View all' link is displayed when there are more than three channels`, () => { + const wrapper = makeWrapper({ channels: TEST_CHANNELS, short: true }); + expect(getViewAllLink(wrapper).exists()).toBe(true); + }); + + it(`clicking 'View all' link navigates to the library page`, () => { + const wrapper = makeWrapper({ channels: TEST_CHANNELS, short: true }); + getViewAllLink(wrapper).trigger('click'); + expect(wrapper.vm.$route.name).toBe(PageNames.LIBRARY); + }); + }); + + it(`clicking a channel navigates to the channel page`, () => { + const wrapper = makeWrapper({ channels: TEST_CHANNELS }); + expect(wrapper.vm.$route.path).toBe('/'); + getChannelsLinks(wrapper) + .at(0) + .trigger('click'); + expect(wrapper.vm.$route.name).toBe(PageNames.TOPICS_CHANNEL); + expect(wrapper.vm.$route.params).toEqual({ channel_id: 'channel-1' }); + }); +}); diff --git a/kolibri/plugins/learn/assets/src/views/HomePage/ExploreChannels/index.vue b/kolibri/plugins/learn/assets/src/views/HomePage/ExploreChannels/index.vue new file mode 100644 index 00000000000..2725c1d9cbe --- /dev/null +++ b/kolibri/plugins/learn/assets/src/views/HomePage/ExploreChannels/index.vue @@ -0,0 +1,111 @@ + + + + diff --git a/kolibri/plugins/learn/assets/src/views/HomePage/__tests__/HomePage.spec.js b/kolibri/plugins/learn/assets/src/views/HomePage/__tests__/HomePage.spec.js index d1d6b615d20..c26158d264b 100644 --- a/kolibri/plugins/learn/assets/src/views/HomePage/__tests__/HomePage.spec.js +++ b/kolibri/plugins/learn/assets/src/views/HomePage/__tests__/HomePage.spec.js @@ -4,6 +4,7 @@ import VueRouter from 'vue-router'; import { ClassesPageNames } from '../../../constants'; import HomePage from '../index'; /* eslint-disable import/named */ +import useChannels, { useChannelsMock } from '../../../composables/useChannels'; import useUser, { useUserMock } from '../../../composables/useUser'; import useDeviceSettings, { useDeviceSettingsMock } from '../../../composables/useDeviceSettings'; import useLearnerResources, { @@ -11,6 +12,7 @@ import useLearnerResources, { } from '../../../composables/useLearnerResources'; /* eslint-enable import/named */ +jest.mock('../../../composables/useChannels'); jest.mock('../../../composables/useUser'); jest.mock('../../../composables/useDeviceSettings'); jest.mock('../../../composables/useLearnerResources'); @@ -43,8 +45,24 @@ function getClassesSection(wrapper) { return wrapper.find('[data-test="classes"]'); } -function getContinueLearningSection(wrapper) { - return wrapper.find('[data-test="continueLearning"]'); +function getContinueLearningFromClassesSection(wrapper) { + return wrapper.find('[data-test="continueLearningFromClasses"]'); +} + +function getRecentLessonsSection(wrapper) { + return wrapper.find('[data-test="recentLessons"]'); +} + +function getRecentQuizzesSection(wrapper) { + return wrapper.find('[data-test="recentQuizzes"]'); +} + +function getContinueLearningOnYourOwnSection(wrapper) { + return wrapper.find('[data-test="continueLearningOnYourOwn"]'); +} + +function getExploreChannelsSection(wrapper) { + return wrapper.find('[data-test="exploreChannels"]'); } describe(`HomePage`, () => { @@ -61,31 +79,6 @@ describe(`HomePage`, () => { expect(wrapper.exists()).toBe(true); }); - describe(`when loaded`, () => { - it(`fetches learner's classes and resumable resources when a user is signed in`, () => { - const fetchClasses = jest.fn(); - const fetchResumableContentNodes = jest.fn(); - useUser.mockImplementation(() => useUserMock({ isUserLoggedIn: true })); - useLearnerResources.mockImplementation(() => - useLearnerResourcesMock({ fetchClasses, fetchResumableContentNodes }) - ); - makeWrapper(); - expect(fetchClasses).toHaveBeenCalledTimes(1); - expect(fetchResumableContentNodes).toHaveBeenCalledTimes(1); - }); - - it(`doesn't fetch learner's classes and resumable resources when a user is signed out`, () => { - const fetchClasses = jest.fn(); - const fetchResumableContentNodes = jest.fn(); - useLearnerResources.mockImplementation(() => - useLearnerResourcesMock({ fetchClasses, fetchResumableContentNodes }) - ); - makeWrapper(); - expect(fetchClasses).not.toHaveBeenCalled(); - expect(fetchResumableContentNodes).not.toHaveBeenCalled(); - }); - }); - describe(`"Your classes" section`, () => { it(`the section is not displayed for a guest user`, () => { const wrapper = makeWrapper(); @@ -116,20 +109,20 @@ describe(`HomePage`, () => { }); }); - describe(`"Continue learning from classes/on your own" section`, () => { + describe(`"Continue learning from classes" section`, () => { it(`the section is not displayed for a guest user`, () => { const wrapper = makeWrapper(); - expect(getContinueLearningSection(wrapper).exists()).toBe(false); + expect(getContinueLearningFromClassesSection(wrapper).exists()).toBe(false); }); it(`the section is not displayed for a signed in user who has - no resources or quizzes in progress`, () => { + no classes resources or quizzes in progress`, () => { useUser.mockImplementation(() => useUserMock({ isUserLoggedIn: true })); const wrapper = makeWrapper(); - expect(getContinueLearningSection(wrapper).exists()).toBe(false); + expect(getContinueLearningFromClassesSection(wrapper).exists()).toBe(false); }); - describe(`for a signed in user who has some classes resources or quizzes in progress`, () => { + describe(`for a signed in user who has some resources or quizzes in progress`, () => { beforeEach(() => { useUser.mockImplementation(() => useUserMock({ isUserLoggedIn: true })); useLearnerResources.mockImplementation(() => @@ -163,25 +156,9 @@ describe(`HomePage`, () => { ); }); - it(`"Continue learning on your own" section is not displayed`, () => { - const wrapper = makeWrapper(); - expect(wrapper.text()).not.toContain('Continue learning on your own'); - }); - - it(`"Continue learning from your classes" section is displayed`, () => { + it(`the section is displayed and contains classes resources and quizzes in progress`, () => { const wrapper = makeWrapper(); - expect(wrapper.text()).toContain('Continue learning from your classes'); - }); - - it(`resources in progress outside of classes are not displayed`, () => { - const wrapper = makeWrapper(); - expect(getContinueLearningSection(wrapper).text()).not.toContain('Non-class resource 1'); - expect(getContinueLearningSection(wrapper).text()).not.toContain('Non-class resource 2'); - }); - - it(`classes resources and quizzes in progress are displayed`, () => { - const wrapper = makeWrapper(); - const links = getContinueLearningSection(wrapper).findAll('a'); + const links = getContinueLearningFromClassesSection(wrapper).findAll('a'); expect(links.length).toBe(4); expect(links.at(0).text()).toBe('Class resource 1'); expect(links.at(1).text()).toBe('Class resource 2'); @@ -189,10 +166,20 @@ describe(`HomePage`, () => { expect(links.at(3).text()).toBe('Class quiz 2'); }); + it(`non-classes resources in progress are not displayed`, () => { + const wrapper = makeWrapper(); + expect(getContinueLearningFromClassesSection(wrapper).text()).not.toContain( + 'Non-class resource 1' + ); + expect(getContinueLearningFromClassesSection(wrapper).text()).not.toContain( + 'Non-class resource 2' + ); + }); + it(`clicking a resource navigates to the class resource page`, () => { const wrapper = makeWrapper(); expect(wrapper.vm.$route.path).toBe('/'); - const links = getContinueLearningSection(wrapper).findAll('a'); + const links = getContinueLearningFromClassesSection(wrapper).findAll('a'); links.at(0).trigger('click'); expect(wrapper.vm.$route.path).toBe('/class-resource'); }); @@ -200,18 +187,104 @@ describe(`HomePage`, () => { it(`clicking a quiz navigates to the class quiz page`, () => { const wrapper = makeWrapper(); expect(wrapper.vm.$route.path).toBe('/'); - const links = getContinueLearningSection(wrapper).findAll('a'); + const links = getContinueLearningFromClassesSection(wrapper).findAll('a'); links.at(2).trigger('click'); expect(wrapper.vm.$route.path).toBe('/class-quiz'); }); }); + }); + + describe(`"Recent lessons" section`, () => { + it(`the section is not displayed for a guest user`, () => { + const wrapper = makeWrapper(); + expect(getRecentLessonsSection(wrapper).exists()).toBe(false); + }); + + it(`the section is not displayed for a signed in user + who has no active lessons`, () => { + useUser.mockImplementation(() => useUserMock({ isUserLoggedIn: true })); + const wrapper = makeWrapper(); + expect(getRecentLessonsSection(wrapper).exists()).toBe(false); + }); + + it(`active lessons are displayed for a signed in user who has some`, () => { + useUser.mockImplementation(() => useUserMock({ isUserLoggedIn: true })); + useLearnerResources.mockImplementation(() => + useLearnerResourcesMock({ + activeClassesLessons: [ + { id: 'lesson-1', title: 'Lesson 1', is_active: true }, + { id: 'lesson-2', title: 'Lesson 2', is_active: true }, + ], + getClassLessonLink() { + return { path: '/class-lesson' }; + }, + }) + ); + const wrapper = makeWrapper(); + expect(getRecentLessonsSection(wrapper).exists()).toBe(true); + const links = getRecentLessonsSection(wrapper).findAll('a'); + expect(links.length).toBe(2); + expect(links.at(0).text()).toBe('Lesson 1'); + expect(links.at(1).text()).toBe('Lesson 2'); + }); + }); + + describe(`"Recent quizzes" section`, () => { + it(`the section is not displayed for a guest user`, () => { + const wrapper = makeWrapper(); + expect(getRecentQuizzesSection(wrapper).exists()).toBe(false); + }); + + it(`the section is not displayed for a signed in user + who has no active quizzes`, () => { + useUser.mockImplementation(() => useUserMock({ isUserLoggedIn: true })); + const wrapper = makeWrapper(); + expect(getRecentQuizzesSection(wrapper).exists()).toBe(false); + }); - describe(`for a signed in user who doesn't have any classes resources or quizzes in progress - and has some resources in progress outside of classes`, () => { + it(`active quizzes are displayed for a signed in user who has some`, () => { + useUser.mockImplementation(() => useUserMock({ isUserLoggedIn: true })); + useLearnerResources.mockImplementation(() => + useLearnerResourcesMock({ + activeClassesQuizzes: [ + { id: 'quiz-1', title: 'Quiz 1', active: true }, + { id: 'quiz-2', title: 'Quiz 2', active: true }, + ], + getClassQuizLink() { + return { path: '/class-quiz' }; + }, + }) + ); + const wrapper = makeWrapper(); + expect(getRecentQuizzesSection(wrapper).exists()).toBe(true); + const links = getRecentQuizzesSection(wrapper).findAll('a'); + expect(links.length).toBe(2); + expect(links.at(0).text()).toBe('Quiz 1'); + expect(links.at(1).text()).toBe('Quiz 2'); + }); + }); + + describe(`"Continue learning on your own" section`, () => { + it(`the section is not displayed for a guest user`, () => { + const wrapper = makeWrapper(); + expect(getContinueLearningOnYourOwnSection(wrapper).exists()).toBe(false); + }); + + it(`the section is not displayed for a signed in user + who hasn't finished all their classes resources and quizzes yet`, () => { + useUser.mockImplementation(() => useUserMock({ isUserLoggedIn: true })); + const wrapper = makeWrapper(); + expect(getContinueLearningOnYourOwnSection(wrapper).exists()).toBe(false); + }); + + describe(`for a signed in user + who has finished all their classes resources and quizzes + and has some non-classes resources in progress`, () => { beforeEach(() => { useUser.mockImplementation(() => useUserMock({ isUserLoggedIn: true })); useLearnerResources.mockImplementation(() => useLearnerResourcesMock({ + learnerFinishedAllClasses: true, resumableNonClassesContentNodes: [ { id: 'non-class-resource-1', title: 'Non-class resource 1' }, { id: 'non-class-resource-2', title: 'Non-class resource 2' }, @@ -223,15 +296,9 @@ describe(`HomePage`, () => { ); }); - it(`"Continue learning from your classes" section is not displayed`, () => { - const wrapper = makeWrapper(); - expect(wrapper.text()).not.toContain('Continue learning from your classes'); - }); - - it(`"Continue learning on your own" section is not displayed - when access to unassigned content is not allowed`, () => { + it(`the section is not displayed when access to unassigned content is not allowed`, () => { const wrapper = makeWrapper(); - expect(wrapper.text()).not.toContain('Continue learning on your own'); + expect(getContinueLearningOnYourOwnSection(wrapper).exists()).toBe(false); }); describe(`when access to unassigned content is allowed`, () => { @@ -243,14 +310,10 @@ describe(`HomePage`, () => { ); }); - it(`"Continue learning on your own" section is displayed`, () => { + it(`the section is displayed and contains non-classes resources in progress`, () => { const wrapper = makeWrapper(); - expect(wrapper.text()).toContain('Continue learning on your own'); - }); - - it(`resources in progress outside of classes are displayed`, () => { - const wrapper = makeWrapper(); - const links = getContinueLearningSection(wrapper).findAll('a'); + expect(getContinueLearningOnYourOwnSection(wrapper).exists()).toBe(true); + const links = getContinueLearningOnYourOwnSection(wrapper).findAll('a'); expect(links.length).toBe(2); expect(links.at(0).text()).toBe('Non-class resource 1'); expect(links.at(1).text()).toBe('Non-class resource 2'); @@ -259,11 +322,67 @@ describe(`HomePage`, () => { it(`clicking a resource navigates to the topic resource page`, () => { const wrapper = makeWrapper(); expect(wrapper.vm.$route.path).toBe('/'); - const links = getContinueLearningSection(wrapper).findAll('a'); + const links = getContinueLearningOnYourOwnSection(wrapper).findAll('a'); links.at(0).trigger('click'); expect(wrapper.vm.$route.path).toBe('/topic-resource'); }); }); }); }); + + describe(`"Explore channels" section`, () => { + it(`the section is not displayed when there are no channels available`, () => { + const wrapper = makeWrapper(); + expect(getExploreChannelsSection(wrapper).exists()).toBe(false); + }); + + describe(`when there are some channels available`, () => { + beforeEach(() => { + useChannels.mockImplementation(() => useChannelsMock({ channels: [{ id: 'channel-1' }] })); + }); + + it(`the section is not displayed for a signed in user + who hasn't finished all their classes resources and quizzes yet`, () => { + useUser.mockImplementation(() => useUserMock({ isUserLoggedIn: true })); + const wrapper = makeWrapper(); + expect(getExploreChannelsSection(wrapper).exists()).toBe(false); + }); + + it(`the section is not displayed for a signed in user + who has finished all their classes resources and quizzes + when access to unassigned content is not allowed`, () => { + useUser.mockImplementation(() => useUserMock({ isUserLoggedIn: true })); + useLearnerResources.mockImplementation(() => + useLearnerResourcesMock({ + learnerFinishedAllClasses: true, + }) + ); + const wrapper = makeWrapper(); + expect(getExploreChannelsSection(wrapper).exists()).toBe(false); + }); + + it(`the section is displayed for a signed in user + who has finished all their classes resources and quizzes + when access to unassigned content is allowed`, () => { + useUser.mockImplementation(() => useUserMock({ isUserLoggedIn: true })); + useLearnerResources.mockImplementation(() => + useLearnerResourcesMock({ + learnerFinishedAllClasses: true, + }) + ); + useDeviceSettings.mockImplementation(() => + useDeviceSettingsMock({ + canAccessUnassignedContent: true, + }) + ); + const wrapper = makeWrapper(); + expect(getExploreChannelsSection(wrapper).exists()).toBe(true); + }); + + it(`the section is displayed for a guest user`, () => { + const wrapper = makeWrapper(); + expect(getExploreChannelsSection(wrapper).exists()).toBe(true); + }); + }); + }); }); diff --git a/kolibri/plugins/learn/assets/src/views/HomePage/index.vue b/kolibri/plugins/learn/assets/src/views/HomePage/index.vue index eda5251f655..e57adecc763 100644 --- a/kolibri/plugins/learn/assets/src/views/HomePage/index.vue +++ b/kolibri/plugins/learn/assets/src/views/HomePage/index.vue @@ -12,7 +12,9 @@ v-if="continueLearningFromClasses || continueLearningOnYourOwn" class="section" :fromClasses="continueLearningFromClasses" - data-test="continueLearning" + :data-test="continueLearningFromClasses ? + 'continueLearningFromClasses' : + 'continueLearningOnYourOwn'" /> + - -
@@ -44,8 +46,9 @@ + + + diff --git a/kolibri/plugins/learn/assets/src/views/cards/CardGrid.vue b/kolibri/plugins/learn/assets/src/views/cards/CardGrid.vue index 79789d8a228..4995db01217 100644 --- a/kolibri/plugins/learn/assets/src/views/cards/CardGrid.vue +++ b/kolibri/plugins/learn/assets/src/views/cards/CardGrid.vue @@ -11,7 +11,6 @@ import responsiveWindowMixin from 'kolibri-design-system/lib/KResponsiveWindowMixin'; - // Add new enums as the designs call for different grid styles const GRID_TYPE_1 = 1; const GRID_TYPE_2 = 2; @@ -19,10 +18,31 @@ name: 'CardGrid', mixins: [responsiveWindowMixin], props: { + /** + * `1` or `2` + * + * The following number of cards will + * be displayed on one row: + * + * Grid type `1` + * Level 3+: 3 cards + * Level 2: 2 cards + * Level 1: 1 cards + * Level 0: 1 card + * + * Grid type `2` + * Level 3+: 4 cards + * Level 2: 3 cards + * Level 1: 2 cards + * Level 0: 1 card + */ gridType: { type: Number, required: false, default: GRID_TYPE_1, + validator(value) { + return [GRID_TYPE_1, GRID_TYPE_2].includes(value); + }, }, }, computed: { diff --git a/kolibri/plugins/learn/assets/src/views/cards/LessonCard/index.vue b/kolibri/plugins/learn/assets/src/views/cards/LessonCard/index.vue index 48f47f6e410..b218e75d052 100644 --- a/kolibri/plugins/learn/assets/src/views/cards/LessonCard/index.vue +++ b/kolibri/plugins/learn/assets/src/views/cards/LessonCard/index.vue @@ -1,6 +1,9 @@ diff --git a/kolibri/plugins/learn/assets/src/views/cards/QuizCard/index.vue b/kolibri/plugins/learn/assets/src/views/cards/QuizCard/index.vue index ddaad30e31b..fa90199de58 100644 --- a/kolibri/plugins/learn/assets/src/views/cards/QuizCard/index.vue +++ b/kolibri/plugins/learn/assets/src/views/cards/QuizCard/index.vue @@ -1,6 +1,9 @@