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.
-->
-
@@ -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 @@
-
+
-
+
+
+
+
+
@@ -18,6 +25,7 @@
+
+
+