feat: course section reordering & migration to course v2 (#437)

* feat: reorder sections and lessons gracefully

* convert course into version 2

* feat: get next and prev working
This commit is contained in:
Rotimi Best
2024-08-17 17:10:15 +01:00
committed by GitHub
parent e18b4ca1b1
commit 1c9dc004a0
17 changed files with 425 additions and 143 deletions
@@ -0,0 +1,63 @@
<script lang="ts">
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import Modal from '$lib/components/Modal/index.svelte';
import { t } from '$lib/utils/functions/translations';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { supabase } from '$lib/utils/functions/supabase';
import { course } from '../../store';
import { snackbar } from '$lib/components/Snackbar/store';
export let open = false;
let isActivating = false;
const activate = async () => {
isActivating = true;
const { error } = await supabase.rpc('convert_course_to_v2', {
course_id_arg: $course.id
});
if (error) {
snackbar.error('snackbar.something');
return;
}
window.location.reload();
};
function handleClose() {
open = false;
}
</script>
<Modal
onClose={handleClose}
bind:open
width="w-[80%] md:w-[65%]"
maxWidth="max-w-xl"
modalHeading={$t(`course.navItem.lessons.section_prompt.header`)}
>
<div class="flex flex-col w-full items-center">
<div class="mb-8">
<h3 class="text-center text-2xl">
{$t('course.navItem.lessons.section_prompt.title')}
</h3>
<p class="text-center max-w-md">
{$t('course.navItem.lessons.section_prompt.description')}
</p>
</div>
<div class="flex gap-2">
<PrimaryButton
variant={VARIANTS.OUTLINED}
label={$t('course.navItem.lessons.section_prompt.cancel')}
onClick={handleClose}
/>
<PrimaryButton
label={$t('course.navItem.lessons.section_prompt.activate')}
onClick={activate}
isLoading={isActivating}
/>
</div>
</div>
</Modal>
@@ -75,7 +75,7 @@
use:dndzone={{ use:dndzone={{
items: $lessons, items: $lessons,
flipDurationMs, flipDurationMs,
dragDisabled: $globalStore.isStudent || !reorder, dragDisabled: !reorder,
dropTargetStyle: { dropTargetStyle: {
border: '2px #1d4ed8 solid', border: '2px #1d4ed8 solid',
'border-style': 'dashed' 'border-style': 'dashed'
@@ -115,7 +115,7 @@
<a <a
href={$globalStore.isStudent && !lesson.is_unlocked href={$globalStore.isStudent && !lesson.is_unlocked
? $page.url.pathname ? $page.url.pathname
: '/courses/' + $course.id + '/lessons/' + lesson.id} : `/courses/${$course.id}/lessons/${lesson.id}`}
class="font-medium text-black no-underline hover:underline dark:text-white {$globalStore.isStudent && class="font-medium text-black no-underline hover:underline dark:text-white {$globalStore.isStudent &&
!lesson.is_unlocked !lesson.is_unlocked
? 'cursor-not-allowed' ? 'cursor-not-allowed'
@@ -145,7 +145,6 @@
<RoleBasedSecurity allowedRoles={[1, 2]}> <RoleBasedSecurity allowedRoles={[1, 2]}>
<OverflowMenu size="xl"> <OverflowMenu size="xl">
<OverflowMenuItem <OverflowMenuItem
disabled={$globalStore.isStudent}
text={lesson.is_unlocked text={lesson.is_unlocked
? $t('course.navItem.lessons.add_lesson.lock') ? $t('course.navItem.lessons.add_lesson.lock')
: $t('course.navItem.lessons.add_lesson.unlock')} : $t('course.navItem.lessons.add_lesson.unlock')}
@@ -1,10 +1,17 @@
<script lang="ts"> <script lang="ts">
import { Edit, TrashCan, Add } from 'carbon-icons-svelte'; import { page } from '$app/stores';
import { flip } from 'svelte/animate';
import { dndzone } from 'svelte-dnd-action';
import { Add, ScreenMap } from 'carbon-icons-svelte';
import { OverflowMenu, OverflowMenuItem } from 'carbon-components-svelte';
import TextField from '$lib/components/Form/TextField.svelte'; import TextField from '$lib/components/Form/TextField.svelte';
import TextChip from '$lib/components/Chip/Text.svelte';
import IconButton from '$lib/components/IconButton/index.svelte'; import IconButton from '$lib/components/IconButton/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte'; import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants'; import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { globalStore } from '$lib/utils/store/app';
import { course } from '$lib/components/Course/store';
import { handleAddLessonWidget } from '$lib/components/Course/components/Lesson/store'; import { handleAddLessonWidget } from '$lib/components/Course/components/Lesson/store';
import { snackbar } from '$lib/components/Snackbar/store'; import { snackbar } from '$lib/components/Snackbar/store';
import { import {
@@ -14,6 +21,7 @@
handleSaveLessonSection, handleSaveLessonSection,
handleDeleteSection handleDeleteSection
} from '$lib/components/Course/components/Lesson/store/lessons'; } from '$lib/components/Course/components/Lesson/store/lessons';
import { updateLesson, updateLessonSection } from '$lib/utils/services/courses';
import type { Lesson } from '$lib/utils/types'; import type { Lesson } from '$lib/utils/types';
import DeleteLessonConfirmation from '$lib/components/Course/components/Lesson/DeleteLessonConfirmation.svelte'; import DeleteLessonConfirmation from '$lib/components/Course/components/Lesson/DeleteLessonConfirmation.svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte'; import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
@@ -25,7 +33,7 @@
lessonTitle?: string; lessonTitle?: string;
}; };
// export let reorder = false; export let reorder = false;
export let lessonEditing: string | undefined; export let lessonEditing: string | undefined;
let prevTitle: string | undefined; let prevTitle: string | undefined;
@@ -67,7 +75,7 @@
if (!errors?.title) { if (!errors?.title) {
resetEdit(); resetEdit();
} else {
snackbar.success('snackbar.success_update'); snackbar.success('snackbar.success_update');
} }
} }
@@ -99,111 +107,247 @@
id id
})); }));
} }
/**
* Drag functionality
*/
const flipDurationMs = 200;
function handleDndConsiderColumns(e) {
$lessonSections = e.detail.items;
}
function handleDndFinalizeColumns(e) {
const prevSectionsOrder = $lessonSections.reduce((acc, l) => ({ ...acc, [l.id]: l.order }), {});
$lessonSections = e.detail.items;
e.detail.items.forEach((section, index) => {
const order = index + 1;
if (order !== prevSectionsOrder[section.id]) {
$lessonSections[index].order = order;
updateLessonSection({ order }, section.id).then((update) =>
console.log(`updated section order`, update)
);
}
});
}
function handleDndConsiderCards(cid, e) {
const colIdx = $lessonSections.findIndex((c) => c.id === cid);
$lessonSections[colIdx].lessons = e.detail.items;
$lessonSections = [...$lessonSections];
}
function handleDndFinalizeCards(cid, e) {
const colIdx = $lessonSections.findIndex((c) => c.id === cid);
const section = $lessonSections[colIdx];
const prevLessonOrder = section.lessons.reduce((acc, l) => ({ ...acc, [l.id]: l.order }), {});
$lessonSections[colIdx].lessons = e.detail.items;
$lessonSections = [...$lessonSections];
e.detail.items.forEach((lesson: Lesson, index: number) => {
const order = index + 1;
if (order !== prevLessonOrder[lesson.id] || lesson.section_id !== section.id) {
section.lessons[index].order = order;
const lessonUpdate = {
order,
section_id: $lessonSections[colIdx].id
};
updateLesson(lessonUpdate, lesson.id).then((update) =>
console.log(`updated lesson order`, update)
);
}
});
}
function getLessonOrder(lessons, id) {
const index = lessons.findIndex((lesson) => lesson.id === id);
if (index < 9) {
return '0' + (index + 1);
} else {
return index + 1;
}
}
</script> </script>
<DeleteLessonConfirmation bind:openDeleteModal {deleteLesson} /> <DeleteLessonConfirmation bind:openDeleteModal {deleteLesson} />
<section class="w-full p-3 lg:w-11/12 lg:px-4"> <section
<div class="max-w-xl mx-auto"> class="m-auto w-full p-3 lg:w-11/12 lg:px-4"
{#each $lessonSections as section (section.id)} use:dndzone={{
<div class="mb-3 border border-gray-200 rounded-md"> items: $lessonSections,
<div flipDurationMs,
class="mb-2 px-3 py-1 min-h-[50px] border-b bg-gray-50 rounded-tl-md rounded-tr-md flex justify-between items-center" type: 'rows',
> dropTargetStyle: {
{#if lessonEditing === section.id} border: '2px #1d4ed8 solid',
<TextField className="w-4/6" bind:value={section.title} errorMessage={errors?.title} /> 'border-style': 'dashed'
{:else} },
<p class="w-4/6 font-semibold">{section.title}</p> dragDisabled: !reorder
{/if} }}
on:consider={handleDndConsiderColumns}
on:finalize={handleDndFinalizeColumns}
>
{#each $lessonSections as section (section.id)}
<div
class="mb-3 m-auto max-w-xl border-2 {reorder
? 'border-primary-400'
: 'border-gray-200'} dark:border-neutral-600 dark:bg-neutral-800 rounded-md"
>
<div
class="mb-2 px-3 py-1 min-h-[50px] border-b bg-gray-50 dark:bg-neutral-700 rounded-tl-md rounded-tr-md flex justify-between items-center"
>
{#if lessonEditing === section.id}
<TextField className="w-4/6" bind:value={section.title} errorMessage={errors?.title} />
{:else}
<p class="w-4/6 font-semibold">{section.title}</p>
{/if}
<RoleBasedSecurity allowedRoles={[1, 2]}> <RoleBasedSecurity allowedRoles={[1, 2]}>
<div class="flex items-center"> <div class="flex items-center">
{#if lessonEditing === section.id} {#if lessonEditing === section.id}
<PrimaryButton <PrimaryButton
label={$t('course.navItem.lessons.add_lesson.cancel')} label={$t('course.navItem.lessons.add_lesson.cancel')}
variant={VARIANTS.OUTLINED} variant={VARIANTS.OUTLINED}
onClick={() => { onClick={() => {
section.title = prevTitle ?? section.title; section.title = prevTitle ?? section.title;
resetEdit(); resetEdit();
}} }}
/> />
<PrimaryButton <PrimaryButton
label={$t('course.navItem.lessons.add_lesson.save')} label={$t('course.navItem.lessons.add_lesson.save')}
onClick={() => onSave({ sectionId: section.id })} onClick={() => onSave({ sectionId: section.id })}
/> />
{:else} {:else}
<IconButton onClick={() => handleAddLesson(section.id)} disabled={!!lessonEditing}> <IconButton
<Add size={20} /> contained
</IconButton> size="small"
<IconButton onClick={() => handleAddLesson(section.id)}
onClick={() => onEdit({ sectionId: section.id, sectionTitle: section.title })} disabled={!!lessonEditing}
>
<Add size={20} />
</IconButton>
<OverflowMenu size="xl" flipped>
<OverflowMenuItem
text={$t('course.navItem.lessons.add_lesson.edit')}
disabled={!!lessonEditing} disabled={!!lessonEditing}
> on:click={() => onEdit({ sectionId: section.id, sectionTitle: section.title })}
<Edit size={20} />
</IconButton>
<IconButton
onClick={() => onDelete({ sectionId: section.id })}
disabled={!!lessonEditing}
>
<TrashCan size={20} />
</IconButton>
{/if}
</div>
</RoleBasedSecurity>
</div>
<div class="mx-3 py-1">
{#each section.lessons as lesson (lesson.id)}
<div
class="border max-w-xl border-gray-200 px-3 py-1 min-h-[50px] rounded-md mb-2 flex items-center justify-between"
>
{#if lessonEditing === lesson.id}
<TextField
className="w-4/6"
bind:value={lesson.title}
errorMessage={errors?.title}
/> />
{:else} <OverflowMenuItem
<p class="w-4/6 text-md">{lesson.title}</p> danger
{/if} text={$t('course.navItem.lessons.add_lesson.delete')}
on:click={() => onDelete({ sectionId: section.id })}
/>
</OverflowMenu>
{/if}
</div>
</RoleBasedSecurity>
</div>
<RoleBasedSecurity allowedRoles={[1, 2]}> <div
<div class="flex items-center gap-1"> class="mx-3 py-1"
{#if lessonEditing === lesson.id} use:dndzone={{
<PrimaryButton items: section.lessons,
label={$t('course.navItem.lessons.add_lesson.cancel')} flipDurationMs,
variant={VARIANTS.OUTLINED} dropTargetStyle: {
onClick={() => { border: '2px #1d4ed8 solid',
lesson.title = prevTitle ?? lesson.title; 'border-style': 'dashed'
resetEdit(); },
dragDisabled: !reorder
}}
on:consider={(e) => handleDndConsiderCards(section.id, e)}
on:finalize={(e) => handleDndFinalizeCards(section.id, e)}
>
{#each section.lessons as lesson (lesson.id)}
<div
class="border max-w-xl {reorder
? 'border-primary-400'
: 'border-gray-200'} px-3 py-1 min-h-[50px] rounded-md mb-2 flex items-center justify-between"
animate:flip={{ duration: flipDurationMs }}
>
{#if lessonEditing === lesson.id}
<TextField className="w-4/6" bind:value={lesson.title} errorMessage={errors?.title} />
{:else}
<div class="w-4/5 flex items-center gap-2">
<TextChip
value={getLessonOrder(section.lessons, lesson.id)}
size="sm"
shape="rounded-full"
className="bg-primary-200 text-primary-600 text-xs"
/>
<div>
<a
href={$globalStore.isStudent && !lesson.is_unlocked
? $page.url.pathname
: `/courses/${$course.id}/lessons/${lesson.id}`}
class=" text-black dark:text-white {$globalStore.isStudent &&
!lesson.is_unlocked
? 'cursor-not-allowed'
: ''}"
data-sveltekit-preload-data="off"
>
{lesson.title}
</a>
<div class="mt-1 mb-3 flex items-center lg:mb-0">
<ScreenMap size={16} class="carbon-icon dark:text-white" />
<p class="ml-2 text-xs text-gray-500 dark:text-white">
{lesson?.totalExercises ? lesson?.totalExercises?.map((c) => c.count) : 0}
{$t('exercises.heading')}
</p>
</div>
</div>
</div>
{/if}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<div class="flex items-center gap-1">
<!-- IS EDITING -->
{#if lessonEditing === lesson.id}
<PrimaryButton
label={$t('course.navItem.lessons.add_lesson.cancel')}
variant={VARIANTS.OUTLINED}
onClick={() => {
lesson.title = prevTitle ?? lesson.title;
resetEdit();
}}
/>
<PrimaryButton
label={$t('course.navItem.lessons.add_lesson.save')}
onClick={() => onSave({ lessonId: lesson.id }, lesson)}
/>
{:else}
<OverflowMenu size="sm" flipped>
<OverflowMenuItem
text={lesson.is_unlocked
? $t('course.navItem.lessons.add_lesson.lock')
: $t('course.navItem.lessons.add_lesson.unlock')}
on:click={() => {
lesson.is_unlocked = !lesson.is_unlocked;
handleSaveLesson(lesson, $course.id);
}} }}
/> />
<PrimaryButton <OverflowMenuItem
label={$t('course.navItem.lessons.add_lesson.save')} text={$t('course.navItem.lessons.add_lesson.edit')}
onClick={() => onSave({ lessonId: lesson.id }, lesson)} on:click={() => onEdit({ lessonId: lesson.id, lessonTitle: lesson.title })}
/> />
{:else} <OverflowMenuItem
<IconButton danger
onClick={() => onEdit({ lessonId: lesson.id, lessonTitle: lesson.title })} text={$t('course.navItem.lessons.add_lesson.delete')}
disabled={!!lessonEditing} on:click={() => onDelete({ lessonId: lesson.id })}
> />
<Edit size={20} /> </OverflowMenu>
</IconButton> {/if}
<IconButton </div>
onClick={() => onDelete({ lessonId: lesson.id })} </RoleBasedSecurity>
disabled={!!lessonEditing} </div>
> {:else}
<TrashCan size={20} /> {$t('course.navItem.lessons.no_lesson')}
</IconButton> {/each}
{/if}
</div>
</RoleBasedSecurity>
</div>
{:else}
{$t('course.navItem.lessons.no_lesson')}
{/each}
</div>
</div> </div>
{/each} </div>
</div> {/each}
</section> </section>
@@ -160,7 +160,7 @@
}, },
{ {
id: NAV_IDS.LESSONS, id: NAV_IDS.LESSONS,
label: $t('course.navItems.nav_lessons'), label: $t('course.navItems.nav_content'),
to: getLessonsRoute($course.id), to: getLessonsRoute($course.id),
hideSortIcon: false, hideSortIcon: false,
isPaidFeature: false, isPaidFeature: false,
@@ -3,7 +3,7 @@ import type { Writable } from 'svelte/store';
import { lessons, lessonSections } from './components/Lesson/store/lessons'; import { lessons, lessonSections } from './components/Lesson/store/lessons';
import { ROLE } from '$lib/utils/constants/roles'; import { ROLE } from '$lib/utils/constants/roles';
import type { Course, GroupPerson, Lesson, LessonSection } from '$lib/utils/types'; import type { Course, GroupPerson, Lesson, LessonSection } from '$lib/utils/types';
import { COURSE_TYPE } from '$lib/utils/types'; import { COURSE_TYPE, COURSE_VERSION } from '$lib/utils/types';
export const defaultCourse: Course = { export const defaultCourse: Course = {
id: '', id: '',
@@ -20,6 +20,7 @@ export const defaultCourse: Course = {
updated_at: new Date().toDateString(), updated_at: new Date().toDateString(),
attendance: [], attendance: [],
polls: [], polls: [],
version: COURSE_VERSION.V2,
metadata: { metadata: {
requirements: '', requirements: '',
description: '', description: '',
@@ -120,7 +121,6 @@ export async function setCourse(data: Course, setLesson = true) {
lessons.set(orderedLessons); lessons.set(orderedLessons);
if (data.lesson_section) { if (data.lesson_section) {
console.log('Sections', data.lesson_section);
const sections = data.lesson_section?.map((section) => { const sections = data.lesson_section?.map((section) => {
const lessons = (data.lessons || []).filter((lesson) => lesson.section_id === section.id); const lessons = (data.lessons || []).filter((lesson) => lesson.section_id === section.id);
section.lessons = sortLesson(lessons); section.lessons = sortLesson(lessons);
@@ -287,7 +287,7 @@ export function updateLessonSection(section: any, sectionId: LessonSection['id']
.match({ id: sectionId }); .match({ id: sectionId });
} }
export function updateLesson(lesson: any, lessonId: Lesson['id']) { export async function updateLesson(lesson: any, lessonId: Lesson['id']) {
return supabase return supabase
.from('lesson') .from('lesson')
.update({ ...lesson, id: undefined }) .update({ ...lesson, id: undefined })
@@ -307,6 +307,14 @@ export function updateLessonCompletion(completion: LessonCompletion, shouldUpdat
} }
} }
export const upsertLessons = (data: { id: string; order: number }[]) => {
return supabase.from('lesson').upsert(data);
};
export const upsertLessonSections = (data: { id: string; order: number }[]) => {
return supabase.from('lesson_section').upsert(data);
};
export function deleteLesson(lessonId: Lesson['id']) { export function deleteLesson(lessonId: Lesson['id']) {
// Need to implement soft delete // Need to implement soft delete
return supabase.from('lesson').delete().match({ id: lessonId }); return supabase.from('lesson').delete().match({ id: lessonId });
@@ -801,14 +801,14 @@
}, },
"navItems": { "navItems": {
"nav_news_feed": "Neuigkeiten", "nav_news_feed": "Neuigkeiten",
"nav_lessons": "Unterricht",
"nav_attendance": "Teilnahme", "nav_attendance": "Teilnahme",
"nav_submissions": "Einsendungen", "nav_submissions": "Einsendungen",
"nav_marks": "Markierungen", "nav_marks": "Markierungen",
"nav_people": "Menschen", "nav_people": "Menschen",
"nav_certificates": "Zertifikate", "nav_certificates": "Zertifikate",
"nav_landing_page": "Landingpage", "nav_landing_page": "Landingpage",
"nav_settings": "Einstellungen" "nav_settings": "Einstellungen",
"nav_content": "Inhalt"
}, },
"not_permitted": { "not_permitted": {
"header": "Keine Erlaubnis", "header": "Keine Erlaubnis",
@@ -464,7 +464,7 @@
"course": { "course": {
"navItems": { "navItems": {
"nav_news_feed": "News Feed", "nav_news_feed": "News Feed",
"nav_lessons": "Lessons", "nav_content": "Content",
"nav_attendance": "Attendance", "nav_attendance": "Attendance",
"nav_submissions": "Submissions", "nav_submissions": "Submissions",
"nav_marks": "Marks", "nav_marks": "Marks",
@@ -504,9 +504,18 @@
"lessons": { "lessons": {
"heading": "Lessons", "heading": "Lessons",
"heading_v2": "Content",
"drag": "drag a card to reorder", "drag": "drag a card to reorder",
"no_lesson": "No lessons yet", "no_lesson": "No lessons yet",
"share_your_knowledge": "Share your knowledge with the world by creating engaging lessons. Start by clicking on the Add button.", "share_your_knowledge": "Share your knowledge with the world by creating engaging lessons. Start by clicking on the Add button.",
"section_prompt": {
"cta": "Enable Sections",
"header": "Enable Sections",
"title": "We Launched Course Sections",
"description": "You can now group lessons into sections to organize your content and make learning smoother for your students.",
"activate": "Activate",
"cancel": "Cancel"
},
"add_lesson": { "add_lesson": {
"button_title": "Add", "button_title": "Add",
"start_reorder": "Reorder", "start_reorder": "Reorder",
@@ -801,14 +801,14 @@
}, },
"navItems": { "navItems": {
"nav_news_feed": "Noticias", "nav_news_feed": "Noticias",
"nav_lessons": "Lecciones",
"nav_attendance": "Asistencia", "nav_attendance": "Asistencia",
"nav_submissions": "Envíos", "nav_submissions": "Envíos",
"nav_marks": "Marcas", "nav_marks": "Marcas",
"nav_people": "Gente", "nav_people": "Gente",
"nav_certificates": "Certificados", "nav_certificates": "Certificados",
"nav_landing_page": "Página de destino", "nav_landing_page": "Página de destino",
"nav_settings": "Ajustes" "nav_settings": "Ajustes",
"nav_content": "Contenido"
}, },
"not_permitted": { "not_permitted": {
"header": "Sin autorización", "header": "Sin autorización",
@@ -801,14 +801,14 @@
}, },
"navItems": { "navItems": {
"nav_news_feed": "Fil d'actualité", "nav_news_feed": "Fil d'actualité",
"nav_lessons": "Cours",
"nav_attendance": "Présence", "nav_attendance": "Présence",
"nav_submissions": "Soumissions", "nav_submissions": "Soumissions",
"nav_marks": "Des marques", "nav_marks": "Des marques",
"nav_people": "Personnes", "nav_people": "Personnes",
"nav_certificates": "Certificats", "nav_certificates": "Certificats",
"nav_landing_page": "Page de destination", "nav_landing_page": "Page de destination",
"nav_settings": "Paramètres" "nav_settings": "Paramètres",
"nav_content": "Contenu"
}, },
"not_permitted": { "not_permitted": {
"header": "Aucune autorisation", "header": "Aucune autorisation",
@@ -801,14 +801,14 @@
}, },
"navItems": { "navItems": {
"nav_news_feed": "समाचार फ़ीड", "nav_news_feed": "समाचार फ़ीड",
"nav_lessons": "पाठ",
"nav_attendance": "उपस्थिति", "nav_attendance": "उपस्थिति",
"nav_submissions": "प्रविष्टियों", "nav_submissions": "प्रविष्टियों",
"nav_marks": "निशान", "nav_marks": "निशान",
"nav_people": "लोग", "nav_people": "लोग",
"nav_certificates": "प्रमाण पत्र", "nav_certificates": "प्रमाण पत्र",
"nav_landing_page": "लैंडिंग पृष्ठ", "nav_landing_page": "लैंडिंग पृष्ठ",
"nav_settings": "समायोजन" "nav_settings": "समायोजन",
"nav_content": "सामग्री"
}, },
"not_permitted": { "not_permitted": {
"header": "अनुमति नहीं", "header": "अनुमति नहीं",
@@ -801,14 +801,14 @@
}, },
"navItems": { "navItems": {
"nav_news_feed": "Notícias", "nav_news_feed": "Notícias",
"nav_lessons": "Lições",
"nav_attendance": "Comparecimento", "nav_attendance": "Comparecimento",
"nav_submissions": "Envios", "nav_submissions": "Envios",
"nav_marks": "Marcas", "nav_marks": "Marcas",
"nav_people": "Pessoas", "nav_people": "Pessoas",
"nav_certificates": "Certificados", "nav_certificates": "Certificados",
"nav_landing_page": "Página inicial", "nav_landing_page": "Página inicial",
"nav_settings": "Configurações" "nav_settings": "Configurações",
"nav_content": "Contente"
}, },
"not_permitted": { "not_permitted": {
"header": "Sem permissão", "header": "Sem permissão",
@@ -801,14 +801,14 @@
}, },
"navItems": { "navItems": {
"nav_news_feed": "Новостная лента", "nav_news_feed": "Новостная лента",
"nav_lessons": "Уроки",
"nav_attendance": "посещаемость", "nav_attendance": "посещаемость",
"nav_submissions": "Материалы", "nav_submissions": "Материалы",
"nav_marks": "Метки", "nav_marks": "Метки",
"nav_people": "Люди", "nav_people": "Люди",
"nav_certificates": "Сертификаты", "nav_certificates": "Сертификаты",
"nav_landing_page": "Целевая страница", "nav_landing_page": "Целевая страница",
"nav_settings": "Настройки" "nav_settings": "Настройки",
"nav_content": "Содержание"
}, },
"not_permitted": { "not_permitted": {
"header": "Нет разрешения", "header": "Нет разрешения",
@@ -801,14 +801,14 @@
}, },
"navItems": { "navItems": {
"nav_news_feed": "Nguồn cấp tin tức", "nav_news_feed": "Nguồn cấp tin tức",
"nav_lessons": "Những bài học",
"nav_attendance": "Sự tham dự", "nav_attendance": "Sự tham dự",
"nav_submissions": "bài nộp", "nav_submissions": "bài nộp",
"nav_marks": "Điểm", "nav_marks": "Điểm",
"nav_people": "Mọi người", "nav_people": "Mọi người",
"nav_certificates": "Chứng chỉ", "nav_certificates": "Chứng chỉ",
"nav_landing_page": "Trang đích", "nav_landing_page": "Trang đích",
"nav_settings": "Cài đặt" "nav_settings": "Cài đặt",
"nav_content": "Nội dung"
}, },
"not_permitted": { "not_permitted": {
"header": "Không cho phép", "header": "Không cho phép",
@@ -4,6 +4,7 @@
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte'; import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import Box from '$lib/components/Box/index.svelte'; import Box from '$lib/components/Box/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte'; import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import CourseContainer from '$lib/components/CourseContainer/index.svelte'; import CourseContainer from '$lib/components/CourseContainer/index.svelte';
import { import {
lessons, lessons,
@@ -18,11 +19,11 @@
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { COURSE_VERSION } from '$lib/utils/types'; import { COURSE_VERSION } from '$lib/utils/types';
import { VARIANTS } from '$lib/components/PrimaryButton/constants.js'; import { profile } from '$lib/utils/store/user';
import { profile } from '$lib/utils/store/user.js';
import LessonList from '$lib/components/Course/components/Lesson/LessonList.svelte'; import LessonList from '$lib/components/Course/components/Lesson/LessonList.svelte';
import LessonSectionList from '$lib/components/Course/components/Lesson/LessonSectionList.svelte'; import LessonSectionList from '$lib/components/Course/components/Lesson/LessonSectionList.svelte';
import NewLessonModal from '$lib/components/Course/components/Lesson/NewLessonModal.svelte'; import NewLessonModal from '$lib/components/Course/components/Lesson/NewLessonModal.svelte';
import ActivateSectionsModal from '$lib/components/Course/components/Lesson/ActivateSectionsModal.svelte';
export let data; export let data;
@@ -33,6 +34,7 @@
let openDeleteModal: boolean = false; let openDeleteModal: boolean = false;
let isFetching: boolean = false; let isFetching: boolean = false;
let reorder = false; let reorder = false;
let activateSections = false;
function addLesson() { function addLesson() {
$handleAddLessonWidget.open = true; $handleAddLessonWidget.open = true;
@@ -44,8 +46,22 @@
return completion?.find((c) => c.profile_id === $profile.id); return completion?.find((c) => c.profile_id === $profile.id);
} }
const getLessons = () => {
if ($course.version === COURSE_VERSION.V1) {
return $lessons;
} else {
const _lessons: Lesson[] = [];
$lessonSections.forEach((section) => {
_lessons.push(...section.lessons);
});
return _lessons;
}
};
function findFirstIncompleteLesson() { function findFirstIncompleteLesson() {
return $lessons.find( return getLessons().find(
(lesson) => !hasUserCompletedLesson(lesson.lesson_completion) && lesson.is_unlocked === true (lesson) => !hasUserCompletedLesson(lesson.lesson_completion) && lesson.is_unlocked === true
); );
} }
@@ -70,30 +86,38 @@
<NewLessonModal /> <NewLessonModal />
<ActivateSectionsModal bind:open={activateSections} />
<DeleteLessonConfirmation <DeleteLessonConfirmation
bind:openDeleteModal bind:openDeleteModal
deleteLesson={() => handleDelete(lessonToDelete?.id)} deleteLesson={() => handleDelete(lessonToDelete?.id)}
/> />
<CourseContainer bind:isFetching bind:courseId={data.courseId}> <CourseContainer bind:isFetching bind:courseId={data.courseId}>
<PageNav title={$t('course.navItem.lessons.heading')}> <PageNav title={$t('course.navItem.lessons.heading_v2')}>
<div slot="widget" class="flex w-full justify-end gap-2"> <div slot="widget" class="flex w-full justify-end gap-2">
<RoleBasedSecurity allowedRoles={[1, 2]}> <RoleBasedSecurity allowedRoles={[1, 2]}>
{#if $course.version === COURSE_VERSION.V1}
<PrimaryButton
label={$t(`course.navItem.lessons.section_prompt.cta`)}
variant={VARIANTS.OUTLINED}
onClick={() => (activateSections = !activateSections)}
isDisabled={!!lessonEditing}
/>
{/if}
<PrimaryButton
label={$t(
`course.navItem.lessons.add_lesson.${reorder ? 'end_reorder' : 'start_reorder'}`
)}
variant={VARIANTS.OUTLINED}
onClick={() => (reorder = !reorder)}
isDisabled={!!lessonEditing}
/>
<PrimaryButton <PrimaryButton
label={$t('course.navItem.lessons.add_lesson.button_title')} label={$t('course.navItem.lessons.add_lesson.button_title')}
onClick={addLesson} onClick={addLesson}
isDisabled={!!lessonEditing} isDisabled={!!lessonEditing}
/> />
{#if $course.version === COURSE_VERSION.V1}
<PrimaryButton
label={$t(
`course.navItem.lessons.add_lesson.${reorder ? 'end_reorder' : 'start_reorder'}`
)}
variant={VARIANTS.OUTLINED}
onClick={() => (reorder = !reorder)}
isDisabled={!!lessonEditing}
/>
{/if}
</RoleBasedSecurity> </RoleBasedSecurity>
</div> </div>
</PageNav> </PageNav>
@@ -119,7 +143,7 @@
{#if $course.version === COURSE_VERSION.V1} {#if $course.version === COURSE_VERSION.V1}
<LessonList {reorder} {lessonEditing} bind:lessonToDelete bind:openDeleteModal /> <LessonList {reorder} {lessonEditing} bind:lessonToDelete bind:openDeleteModal />
{:else if $course.version === COURSE_VERSION.V2} {:else if $course.version === COURSE_VERSION.V2}
<LessonSectionList {lessonEditing} /> <LessonSectionList {reorder} {lessonEditing} />
{/if} {/if}
{:else} {:else}
<Box className="w-full lg:w-11/12 lg:px-4 m-auto"> <Box className="w-full lg:w-11/12 lg:px-4 m-auto">
@@ -31,12 +31,13 @@
import { import {
lesson, lesson,
lessons, lessons,
lessonByTranslation lessonByTranslation,
lessonSections
} from '$lib/components/Course/components/Lesson/store/lessons'; } from '$lib/components/Course/components/Lesson/store/lessons';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { currentOrg } from '$lib/utils/store/org'; import { currentOrg } from '$lib/utils/store/org';
import { snackbar } from '$lib/components/Snackbar/store'; import { snackbar } from '$lib/components/Snackbar/store';
import type { LessonCompletion } from '$lib/utils/types'; import { COURSE_VERSION, type LessonCompletion, type Lesson } from '$lib/utils/types';
import { profile } from '$lib/utils/store/user'; import { profile } from '$lib/utils/store/user';
import { getIsLessonComplete } from '$lib/components/Course/components/Lesson/functions'; import { getIsLessonComplete } from '$lib/components/Course/components/Lesson/functions';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
@@ -249,20 +250,36 @@
$apps.open = true; $apps.open = true;
} }
const isNextOrPrevDisabled = (lessonId: string, isPrev: boolean) => { const getLessons = () => {
const index = $lessons.findIndex((lesson) => lesson.id === lessonId); if ($course.version === COURSE_VERSION.V1) {
return $lessons;
} else {
const _lessons: Lesson[] = [];
return isPrev ? !$lessons[index - 1] : !$lessons[index + 1]; $lessonSections.forEach((section) => {
_lessons.push(...section.lessons);
});
return _lessons;
}
};
const isNextOrPrevDisabled = (lessonId: string, isPrev: boolean) => {
const _lessons = getLessons();
const index = _lessons.findIndex((lesson) => lesson.id === lessonId);
return isPrev ? !_lessons[index - 1] : !_lessons[index + 1];
}; };
const goToNextOrPrevLesson = (lessonId: string, isPrev: boolean) => { const goToNextOrPrevLesson = (lessonId: string, isPrev: boolean) => {
const _lessons = getLessons();
const isDisabled = isNextOrPrevDisabled(lessonId, isPrev); const isDisabled = isNextOrPrevDisabled(lessonId, isPrev);
// Always use early return // Always use early return
if (isDisabled) return; if (isDisabled) return;
const index = $lessons.findIndex((lesson) => lesson.id === lessonId); const index = _lessons.findIndex((lesson) => lesson.id === lessonId);
const nextOrPrevLesson = isPrev ? $lessons[index - 1] : $lessons[index + 1]; const nextOrPrevLesson = isPrev ? _lessons[index - 1] : _lessons[index + 1];
const isLocked = $globalStore.isStudent && !nextOrPrevLesson.is_unlocked; const isLocked = $globalStore.isStudent && !nextOrPrevLesson.is_unlocked;
@@ -124,4 +124,22 @@ using (is_user_in_group_with_role(( SELECT course.group_id
with check (is_user_in_group_with_role(( SELECT course.group_id with check (is_user_in_group_with_role(( SELECT course.group_id
FROM course FROM course
WHERE (course.id = lesson_section.course_id) WHERE (course.id = lesson_section.course_id)
LIMIT 1))); LIMIT 1)));
CREATE
OR REPLACE FUNCTION convert_course_to_v2 (course_id uuid) RETURNS void AS $$
DECLARE
new_section_id bigint;
BEGIN
UPDATE course
SET version = 'V2'
WHERE id = course_id;
INSERT INTO lesson_section (title, course_id) VALUES ('First Section [edit me]', course_id) RETURNING id INTO new_section_id;
UPDATE lesson
SET section_id = new_section_id
WHERE course_id = course_id;
END;
$$ LANGUAGE plpgsql;