Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions kolibri/plugins/coach/frontend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions kolibri/plugins/coach/frontend/constants/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be a bit redundant? but following the existing pattern for course routing as I suspect there was good reason for it :)

COURSE_SUMMARY_ASSIGN_SELECT_RECIPIENTS: 'COURSE_SUMMARY_ASSIGN_SELECT_RECIPIENTS',

/* Attendance */
Expand Down
11 changes: 11 additions & 0 deletions kolibri/plugins/coach/frontend/routes/coursesRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@
{{ course.description }}
</div>
</section>
<KButton
:primary="false"
appearance="raised-button"
:text="previewAction$()"
:disabled="loading || !course"
class="preview-button"
data-testid="preview-button"
@click="openPreview"
/>

<KButton
v-if="descOverflowing"
class="view-more"
Expand Down Expand Up @@ -202,6 +212,7 @@
selectRecipientsLabel$,
preTestLabel$,
postTestLabel$,
previewAction$,
} = coursesStrings;

const { expandAll$, collapseAll$ } = enhancedQuizManagementStrings;
Expand Down Expand Up @@ -266,6 +277,13 @@
return message;
});

function openPreview() {
const previewRouteName = route.params.courseSessionId
? PageNames.COURSE_SUMMARY_ASSIGN_COURSE_PREVIEW
: PageNames.COURSES_ASSIGN_COURSE_PREVIEW;
router.push(overrideRoute(route, { name: previewRouteName }));
}

// Description expansion
const descExpanded = ref(false);
const courseDescriptionRef = templateRef('courseDescriptionRef');
Expand All @@ -282,6 +300,8 @@
selectRecipients,
numTestQuestions,
isPreviewMode,
openPreview,
previewAction$,

courseSubtitle,
backAction$,
Expand Down Expand Up @@ -373,4 +393,8 @@
text-align: right;
}

.preview-button {
margin-bottom: 16px;
}

</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
<template>

<SidePanelLayout
:title="previewCourseResourcesTitle$()"
:goBack="goBack"
>
<!-- :key remounts on ID change: useFetchContentNode only fetches once in setup(),
so without a fresh instance, switching resources would show stale data
therefore, don't delete :key -->
<CourseResourcePreview
v-if="previewContentId"
:key="previewContentId"
:contentId="previewContentId"
:breadcrumbItems="breadcrumbItems"
/>

<template v-else>
<KCircularLoader v-if="loading" />
<div v-else-if="children">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: Consuming error closed the crash, but nothing renders it: on a rejected first fetchTree, loading is false and children is null, so this branch is false and the body is empty — and the back button is now hidden too, leaving SidePanelModal's close/backdrop as the only exit. CourseUnitView/index.vue:954 has the in-repo one-liner (watch(error, ... handleApiError({ error }))) if you want it. Flagging only because the guard fix makes the blank state slightly more of a dead end than before.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: Still open from the last round, raised for the record. error is no longer destructured from useFetch at all, so a rejected first fetchTree leaves loading false and children null — the panel body renders empty with the back button hidden and no way to retry or understand why. A v-else branch with an error message (and ideally a retry) would close it.

<div
v-if="topic"
class="topic-header"
>
<h2
class="topic-title"
dir="auto"
>
{{ topic.title }}
</h2>
</div>

<KBreadcrumbs
Comment thread
marcellamaki marked this conversation as resolved.
v-if="topic && breadcrumbItems.length > 1"
:items="breadcrumbItems"
/>

<KCardGrid layout="1-1-1">
<component
:is="child.is_leaf ? 'AccessibleResourceCard' : 'AccessibleFolderCard'"
v-for="child in children"
:key="child.id"
:to="child.is_leaf ? getResourceRoute(child.id) : getTopicRoute(child.id)"
:contentNode="child"
:headingLevel="3"
:showBookmarkButton="false"
Comment thread
marcellamaki marked this conversation as resolved.
/>
</KCardGrid>

<KButton
v-if="hasMore && !loadingMore"
:primary="false"
class="load-more-button"
:text="viewMoreAction$()"
@click="fetchMore"
/>
<KCircularLoader
v-if="loadingMore"
:delay="false"
/>
</div>
</template>
</SidePanelLayout>

</template>


<script>

import { ref, computed, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router/composables';
import SidePanelLayout from 'kolibri-common/components/courses/sidePanel/SidePanelLayout';
import useFetch from 'kolibri-common/composables/useFetch';
import ContentNodeResource from 'kolibri-common/apiResources/ContentNodeResource';
import { coreStrings } from 'kolibri/uiText/commonCoreStrings';
import { coursesStrings } from 'kolibri-common/strings/coursesStrings';
import AccessibleFolderCard from 'kolibri-common/components/Cards/AccessibleFolderCard';
import AccessibleResourceCard from 'kolibri-common/components/Cards/AccessibleResourceCard';
import { overrideRoute } from '../../../../../utils';
import { PageNames } from '../../../../../constants';
import CourseResourcePreview from './CourseResourcePreview';

export default {
name: 'CoursePreviewSidePanel',
components: {
SidePanelLayout,
AccessibleFolderCard,
AccessibleResourceCard,
CourseResourcePreview,
},
setup() {
const route = useRoute();
const router = useRouter();
const topic = ref(null);
const { viewMoreAction$ } = coreStrings;
const { previewCourseResourcesTitle$, selectCourseLabel$ } = coursesStrings;

const currentTopicId = computed(() => route.query.previewTopicId ?? route.params.courseId);

const previewContentId = computed(() => route.query.previewContentId ?? null);

// Fetches the topic node and stores it (for title + parent navigation),
// then returns its children for useFetch to manage as the paginated list.
// _topicFetchId guards against stale responses
let _topicFetchId = 0;
async function loadTopicAndChildren(topicId) {
_topicFetchId += 1;
const fetchId = _topicFetchId;
const newTopic = await ContentNodeResource.fetchTree({ id: topicId });
if (fetchId === _topicFetchId) {
topic.value = newTopic;
}
return newTopic.children || { results: [] };
}

const {
data: children,
loading,
hasMore,
fetchMore,
loadingMore,
fetchData,
} = useFetch({
fetchMethod: () => loadTopicAndChildren(currentTopicId.value),
fetchMoreMethod: more =>
ContentNodeResource.fetchTree({ id: currentTopicId.value, params: more.params }).then(
t => t.children || { results: [] },
),
});

watch(currentTopicId, () => fetchData(), { immediate: true });

const breadcrumbItems = computed(() => {
if (!topic.value) {
return [];
}
const ancestors = topic.value.ancestors || [];
const courseIndex = ancestors.findIndex(a => a.id === route.params.courseId);
const fromCourse = courseIndex === -1 ? [] : ancestors.slice(courseIndex);
const items = [...fromCourse, topic.value].map(t => ({
text: t.title,
link: getTopicRoute(t.id),
}));
// The COURSES_ASSIGN_INDEX course-picker list only exists in the "assign a new
// course" flow, not when previewing from an already-assigned course's summary
// page, so this leading crumb is skipped there.
if (!route.params.courseSessionId) {
items.unshift({ text: selectCourseLabel$(), link: getCoursesIndexRoute() });
}
return items;
});

function getTopicRoute(topicId) {
return overrideRoute(route, {
query: { previewTopicId: topicId, previewContentId: undefined },
});
}

function getCoursesIndexRoute() {
return overrideRoute(route, {
name: PageNames.COURSES_ASSIGN_INDEX,
query: { previewTopicId: undefined, previewContentId: undefined },
});
}

function getResourceRoute(resourceId) {
return overrideRoute(route, { query: { previewContentId: resourceId } });
}

function exitPreview() {
const detailsRouteName = route.params.courseSessionId
? PageNames.COURSE_SUMMARY_ASSIGN_COURSE_DETAILS
: PageNames.COURSES_ASSIGN_COURSE_DETAILS;
router.push(
overrideRoute(route, {
name: detailsRouteName,
query: { previewTopicId: undefined, previewContentId: undefined },
}),
);
}

function goBackHandler() {
if (previewContentId.value) {
router.push(overrideRoute(route, { query: { previewContentId: undefined } }));
} else if (currentTopicId.value !== route.params.courseId) {
router.push(getTopicRoute(topic.value.parent));
Comment thread
marcellamaki marked this conversation as resolved.
Comment thread
marcellamaki marked this conversation as resolved.
} else {
exitPreview();
}
}

// topic.value isn't reset when a new topic starts fetching, so while loading,
// or if the fetch fails, it may still hold stale data (or none at all). Hide
// the back button in both cases rather than navigating to a wrong/missing parent.
const goBack = computed(() => {
const isAmbiguousParent = computed(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: The inner computed() should be a plain expression — it buys nothing over the boolean and leaks a watcher per evaluation.

const goBack = computed(() => {
  const isAmbiguousParent =
    !previewContentId.value &&
    currentTopicId.value !== route.params.courseId &&
    topic.value?.id !== currentTopicId.value;
  return isAmbiguousParent ? null : goBackHandler;
});

Vue 2.7.16's computed() constructs new Watcher(vm, getter, noop, { lazy: true }) (vue/dist/vue.runtime.common.dev.js:1312), whose constructor calls recordEffectScope(this, … vm._scope …) (:3352-3360) — pushing the watcher into the component's effect scope, released only on destroy. Computed getters run inside _render where setCurrentInstance(vm) is active (:2600), so every re-evaluation of goBack (each route/topic change) appends another never-collected lazy watcher on the same deps. Bounded by the panel's lifetime and not user-visible, so purely cleanup.

() =>
!previewContentId.value &&
currentTopicId.value !== route.params.courseId &&
topic.value?.id !== currentTopicId.value,
);
return isAmbiguousParent.value ? null : goBackHandler;
});

return {
topic,
children,
loading,
hasMore,
fetchMore,
loadingMore,
previewContentId,
breadcrumbItems,
getTopicRoute,
getResourceRoute,
goBack,
viewMoreAction$,
previewCourseResourcesTitle$,
};
},
};

</script>


<style lang="scss" scoped>

.topic-header {
margin-bottom: 16px;
}

.topic-title {
margin: 0;
font-size: 18px;
}

.load-more-button {
margin-top: 2em;
}

</style>
Loading
Loading