diff --git a/kolibri/plugins/coach/frontend/app.js b/kolibri/plugins/coach/frontend/app.js index 12585943130..1f6059444cd 100644 --- a/kolibri/plugins/coach/frontend/app.js +++ b/kolibri/plugins/coach/frontend/app.js @@ -104,9 +104,11 @@ class CoachToolsModule extends KolibriApp { PageNames.COURSES_ASSIGN, PageNames.COURSES_ASSIGN_INDEX, PageNames.COURSES_ASSIGN_COURSE_DETAILS, + PageNames.COURSES_ASSIGN_COURSE_PREVIEW, PageNames.COURSES_ASSIGN_SELECT_RECIPIENTS, PageNames.COURSE_SUMMARY_ASSIGN, PageNames.COURSE_SUMMARY_ASSIGN_COURSE_DETAILS, + PageNames.COURSE_SUMMARY_ASSIGN_COURSE_PREVIEW, PageNames.COURSE_SUMMARY_ASSIGN_SELECT_RECIPIENTS, PageNames.UNIT_DETAIL, PageNames.UNIT_DETAIL_LESSONS, @@ -180,9 +182,11 @@ class CoachToolsModule extends KolibriApp { PageNames.COURSES_ASSIGN, PageNames.COURSES_ASSIGN_INDEX, PageNames.COURSES_ASSIGN_COURSE_DETAILS, + PageNames.COURSES_ASSIGN_COURSE_PREVIEW, PageNames.COURSES_ASSIGN_SELECT_RECIPIENTS, PageNames.COURSE_SUMMARY_ASSIGN, PageNames.COURSE_SUMMARY_ASSIGN_COURSE_DETAILS, + PageNames.COURSE_SUMMARY_ASSIGN_COURSE_PREVIEW, PageNames.COURSE_SUMMARY_ASSIGN_SELECT_RECIPIENTS, HomeActivityPage.name, ].includes(to.name) diff --git a/kolibri/plugins/coach/frontend/constants/index.js b/kolibri/plugins/coach/frontend/constants/index.js index ee4e2db38f6..7eb42a93e58 100644 --- a/kolibri/plugins/coach/frontend/constants/index.js +++ b/kolibri/plugins/coach/frontend/constants/index.js @@ -83,12 +83,14 @@ export const PageNames = { COURSES_ASSIGN: 'COURSES_ASSIGN', COURSES_ASSIGN_INDEX: 'COURSES_ASSIGN_INDEX', COURSES_ASSIGN_COURSE_DETAILS: 'COURSES_ASSIGN_COURSE_DETAILS', + COURSES_ASSIGN_COURSE_PREVIEW: 'COURSES_ASSIGN_COURSE_PREVIEW', COURSES_ASSIGN_SELECT_RECIPIENTS: 'COURSES_ASSIGN_SELECT_RECIPIENTS', UNIT_DETAIL_LESSONS: 'UNIT_DETAIL_LESSONS', UNIT_DETAIL_OBJECTIVES: 'UNIT_DETAIL_OBJECTIVES', // Side panel routes accessible from the course summary page COURSE_SUMMARY_ASSIGN: 'COURSE_SUMMARY_ASSIGN', COURSE_SUMMARY_ASSIGN_COURSE_DETAILS: 'COURSE_SUMMARY_ASSIGN_COURSE_DETAILS', + COURSE_SUMMARY_ASSIGN_COURSE_PREVIEW: 'COURSE_SUMMARY_ASSIGN_COURSE_PREVIEW', COURSE_SUMMARY_ASSIGN_SELECT_RECIPIENTS: 'COURSE_SUMMARY_ASSIGN_SELECT_RECIPIENTS', /* Attendance */ diff --git a/kolibri/plugins/coach/frontend/routes/coursesRoutes.js b/kolibri/plugins/coach/frontend/routes/coursesRoutes.js index 04f5bf012be..3a9badb43d3 100644 --- a/kolibri/plugins/coach/frontend/routes/coursesRoutes.js +++ b/kolibri/plugins/coach/frontend/routes/coursesRoutes.js @@ -7,6 +7,7 @@ import AssignCourseSidePanel from '../views/courses/sidePanels/AssignCourse/inde import CourseDetailsSubpage from '../views/courses/sidePanels/AssignCourse/subpages/CourseDetails.vue'; import SelectRecipientsSubpage from '../views/courses/sidePanels/AssignCourse/subpages/SelectRecipients.vue'; import AssignCourseIndexSubpage from '../views/courses/sidePanels/AssignCourse/subpages/AssignCourseIndex.vue'; +import CoursePreviewSidePanel from '../views/courses/sidePanels/AssignCourse/subpages/CoursePreviewSidePanel.vue'; import { classIdParamRequiredGuard, RouteSegments, COMPACT_UUID_PATTERN } from './utils'; const { OPTIONAL_CLASS, ALL_COURSES, CLASS, COURSE_SESSION } = RouteSegments; @@ -76,6 +77,11 @@ export default [ path: ':courseId/course-details', component: CourseDetailsSubpage, }, + { + name: PageNames.COURSE_SUMMARY_ASSIGN_COURSE_PREVIEW, + path: ':courseId/preview', + component: CoursePreviewSidePanel, + }, { name: PageNames.COURSE_SUMMARY_ASSIGN_SELECT_RECIPIENTS, path: 'select-recipients', @@ -144,6 +150,11 @@ export default [ path: ':courseId/course-details', component: CourseDetailsSubpage, }, + { + name: PageNames.COURSES_ASSIGN_COURSE_PREVIEW, + path: ':courseId/preview', + component: CoursePreviewSidePanel, + }, { name: PageNames.COURSES_ASSIGN_SELECT_RECIPIENTS, path: 'select-recipients', diff --git a/kolibri/plugins/coach/frontend/views/courses/sidePanels/AssignCourse/subpages/CourseDetails.vue b/kolibri/plugins/coach/frontend/views/courses/sidePanels/AssignCourse/subpages/CourseDetails.vue index 4a6d033cc44..916101b3868 100644 --- a/kolibri/plugins/coach/frontend/views/courses/sidePanels/AssignCourse/subpages/CourseDetails.vue +++ b/kolibri/plugins/coach/frontend/views/courses/sidePanels/AssignCourse/subpages/CourseDetails.vue @@ -29,6 +29,16 @@ {{ course.description }} + + diff --git a/kolibri/plugins/coach/frontend/views/courses/sidePanels/AssignCourse/subpages/CoursePreviewSidePanel.vue b/kolibri/plugins/coach/frontend/views/courses/sidePanels/AssignCourse/subpages/CoursePreviewSidePanel.vue new file mode 100644 index 00000000000..075e14b6e99 --- /dev/null +++ b/kolibri/plugins/coach/frontend/views/courses/sidePanels/AssignCourse/subpages/CoursePreviewSidePanel.vue @@ -0,0 +1,240 @@ + + + + + + + diff --git a/kolibri/plugins/coach/frontend/views/courses/sidePanels/AssignCourse/subpages/CourseResourcePreview.vue b/kolibri/plugins/coach/frontend/views/courses/sidePanels/AssignCourse/subpages/CourseResourcePreview.vue new file mode 100644 index 00000000000..559a65e1196 --- /dev/null +++ b/kolibri/plugins/coach/frontend/views/courses/sidePanels/AssignCourse/subpages/CourseResourcePreview.vue @@ -0,0 +1,103 @@ + + + + + + + diff --git a/kolibri/plugins/coach/frontend/views/courses/sidePanels/AssignCourse/subpages/__tests__/CoursePreviewSidePanel.spec.js b/kolibri/plugins/coach/frontend/views/courses/sidePanels/AssignCourse/subpages/__tests__/CoursePreviewSidePanel.spec.js new file mode 100644 index 00000000000..eed3983c36f --- /dev/null +++ b/kolibri/plugins/coach/frontend/views/courses/sidePanels/AssignCourse/subpages/__tests__/CoursePreviewSidePanel.spec.js @@ -0,0 +1,248 @@ +/* global flushPromises */ +import { reactive } from 'vue'; +import { render, screen, fireEvent } from '@testing-library/vue'; +import { useRoute, useRouter } from 'vue-router/composables'; +import ContentNodeResource from 'kolibri-common/apiResources/ContentNodeResource'; +import { coreStrings } from 'kolibri/uiText/commonCoreStrings'; +import { coursesStrings } from 'kolibri-common/strings/coursesStrings'; +import { PageNames } from '../../../../../../constants'; +import CoursePreviewSidePanel from '../CoursePreviewSidePanel.vue'; + +const { backAction$, saveToBookmarks$ } = coreStrings; +const { selectCourseLabel$ } = coursesStrings; +const TEST_ROUTES = [ + { path: '/', name: PageNames.COURSES_ASSIGN_COURSE_DETAILS }, + { path: '/index', name: PageNames.COURSES_ASSIGN_INDEX }, +]; + +jest.mock('vue-router/composables', () => ({ useRoute: jest.fn(), useRouter: jest.fn() })); +jest.mock('kolibri-common/apiResources/ContentNodeResource'); + +describe('CoursePreviewSidePanel', () => { + let mockRoute; + let push; + + beforeEach(() => { + mockRoute = reactive({ + params: { courseId: 'course-1' }, + query: { previewTopicId: 'topic-B', previewContentId: undefined }, + name: PageNames.COURSES_ASSIGN_COURSE_DETAILS, + }); + push = jest.fn(); + useRoute.mockReturnValue(mockRoute); + useRouter.mockReturnValue({ push }); + }); + + it('renders the topic once its fetch resolves', async () => { + const topicTitle = 'Topic B'; + ContentNodeResource.fetchTree.mockResolvedValue({ + id: 'topic-B', + title: topicTitle, + parent: 'topic-A', + children: { results: [] }, + }); + + render(CoursePreviewSidePanel, { routes: TEST_ROUTES }); + await flushPromises(); + + expect(screen.getByRole('heading', { name: topicTitle, level: 2 })).toBeInTheDocument(); + }); + + it('does not render a bookmark button on resource cards', async () => { + const resourceTitle = 'Resource 1'; + ContentNodeResource.fetchTree.mockResolvedValue({ + id: 'topic-B', + title: 'Topic B', + parent: 'topic-A', + children: { + results: [ + { + id: 'resource-1', + title: resourceTitle, + is_leaf: true, + learning_activities: [], + }, + ], + }, + }); + + render(CoursePreviewSidePanel, { routes: TEST_ROUTES }); + await flushPromises(); + + expect(screen.getByRole('heading', { name: resourceTitle, level: 3 })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: saveToBookmarks$() })).not.toBeInTheDocument(); + }); + + describe('breadcrumb "Courses" crumb', () => { + it('links to the course picker when assigning a new course', async () => { + ContentNodeResource.fetchTree.mockResolvedValue({ + id: 'topic-B', + title: 'Topic B', + parent: 'topic-A', + children: { results: [] }, + }); + + render(CoursePreviewSidePanel, { routes: TEST_ROUTES }); + await flushPromises(); + + // KBreadcrumbs collapses non-last crumbs into an offscreen/dropdown clone whenever + // it can't measure real widths (as in jsdom), so a role/visibility-based query would + // miss it; check that the crumb resolves to the course-picker link. + const matches = screen.getAllByText(selectCourseLabel$()); + const link = matches.map(el => el.closest('a')).find(Boolean); + expect(link).toHaveAttribute('href', expect.stringContaining('/index')); + }); + + it('is omitted when previewing from an already-assigned course summary', async () => { + mockRoute.params = { courseId: 'course-1', courseSessionId: 'session-1' }; + ContentNodeResource.fetchTree.mockResolvedValue({ + id: 'topic-B', + title: 'Topic B', + parent: 'topic-A', + children: { results: [] }, + }); + + render(CoursePreviewSidePanel, { routes: TEST_ROUTES }); + await flushPromises(); + + expect(screen.queryAllByText(selectCourseLabel$())).toHaveLength(0); + }); + }); + + describe('goBack', () => { + it('goes from a resource back to its topic', async () => { + mockRoute.query = { previewTopicId: 'topic-B', previewContentId: 'resource-1' }; + ContentNodeResource.fetchTree.mockResolvedValue({ + id: 'topic-B', + parent: 'topic-A', + children: { results: [] }, + }); + ContentNodeResource.fetchModel.mockResolvedValue({ id: 'resource-1', ancestors: [] }); + + render(CoursePreviewSidePanel, { routes: TEST_ROUTES }); + await flushPromises(); + + await fireEvent.click(screen.getByRole('button', { name: backAction$() })); + + expect(push).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ + previewTopicId: 'topic-B', + previewContentId: undefined, + }), + }), + ); + }); + + it('goes from a topic up to its parent topic', async () => { + ContentNodeResource.fetchTree.mockResolvedValue({ + id: 'topic-B', + parent: 'topic-A', + children: { results: [] }, + }); + + render(CoursePreviewSidePanel, { routes: TEST_ROUTES }); + await flushPromises(); + + await fireEvent.click(screen.getByRole('button', { name: backAction$() })); + + expect(push).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ + previewTopicId: 'topic-A', + previewContentId: undefined, + }), + }), + ); + }); + + it('exits preview when going back from the course root', async () => { + mockRoute.query = { previewTopicId: 'course-1', previewContentId: undefined }; + ContentNodeResource.fetchTree.mockResolvedValue({ + id: 'course-1', + parent: null, + children: { results: [] }, + }); + + render(CoursePreviewSidePanel, { routes: TEST_ROUTES }); + await flushPromises(); + + await fireEvent.click(screen.getByRole('button', { name: backAction$() })); + + expect(push).toHaveBeenCalledWith( + expect.objectContaining({ + name: PageNames.COURSES_ASSIGN_COURSE_DETAILS, + query: expect.objectContaining({ + previewTopicId: undefined, + previewContentId: undefined, + }), + }), + ); + }); + }); + + it('hides the back button rather than navigating to a stale parent while the next topic is still loading', async () => { + let resolveTopicC; + const topicCFetch = new Promise(resolve => { + resolveTopicC = resolve; + }); + + ContentNodeResource.fetchTree.mockImplementation(({ id }) => { + if (id === 'topic-B') { + return Promise.resolve({ id: 'topic-B', parent: 'topic-A', children: { results: [] } }); + } + if (id === 'topic-C') { + return topicCFetch; + } + throw new Error(`unexpected fetchTree id: ${id}`); + }); + + render(CoursePreviewSidePanel, { routes: TEST_ROUTES }); + // let the initial topic-B fetch resolve so topic.value is populated + await flushPromises(); + + // navigate deeper into topic-C; this starts a new (still-pending) fetch + mockRoute.query = { ...mockRoute.query, previewTopicId: 'topic-C' }; + await flushPromises(); + + // topic.value still holds topic-B's stale data while topic-C is in flight, + // so the back button should be hidden rather than clickable + expect(screen.queryByRole('button', { name: backAction$() })).not.toBeInTheDocument(); + + // once the fetch resolves, the back button reappears + resolveTopicC({ id: 'topic-C', parent: 'topic-B', children: { results: [] } }); + await flushPromises(); + + expect(screen.getByRole('button', { name: backAction$() })).toBeInTheDocument(); + }); + + it('hides the back button rather than navigating to a stale parent when the topic fetch fails', async () => { + mockRoute.query = { previewTopicId: 'topic-C', previewContentId: undefined }; + mockRoute.params = { courseId: 'course-1' }; + ContentNodeResource.fetchTree.mockRejectedValue(new Error('network error')); + + render(CoursePreviewSidePanel, { routes: TEST_ROUTES }); + await flushPromises(); + + expect(screen.queryByRole('button', { name: backAction$() })).not.toBeInTheDocument(); + }); + + it('does not crash when previewContentId is already set before the topic fetch resolves', async () => { + mockRoute.query = { previewTopicId: 'topic-B', previewContentId: 'resource-1' }; + ContentNodeResource.fetchModel.mockResolvedValue({ id: 'resource-1', ancestors: [] }); + + let resolveTopicB; + ContentNodeResource.fetchTree.mockImplementation( + () => + new Promise(resolve => { + resolveTopicB = resolve; + }), + ); + + render(CoursePreviewSidePanel, { routes: TEST_ROUTES }); + await flushPromises(); + + resolveTopicB({ id: 'topic-B', parent: 'topic-A', children: { results: [] } }); + await flushPromises(); + }); +}); diff --git a/packages/kolibri-common/components/Cards/AccessibleResourceCard.vue b/packages/kolibri-common/components/Cards/AccessibleResourceCard.vue index 93dbacb23c6..b8784fdfa2f 100644 --- a/packages/kolibri-common/components/Cards/AccessibleResourceCard.vue +++ b/packages/kolibri-common/components/Cards/AccessibleResourceCard.vue @@ -36,6 +36,7 @@