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={{
items: $lessons,
flipDurationMs,
dragDisabled: $globalStore.isStudent || !reorder,
dragDisabled: !reorder,
dropTargetStyle: {
border: '2px #1d4ed8 solid',
'border-style': 'dashed'
@@ -115,7 +115,7 @@
<a
href={$globalStore.isStudent && !lesson.is_unlocked
? $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 &&
!lesson.is_unlocked
? 'cursor-not-allowed'
@@ -145,7 +145,6 @@
<RoleBasedSecurity allowedRoles={[1, 2]}>
<OverflowMenu size="xl">
<OverflowMenuItem
disabled={$globalStore.isStudent}
text={lesson.is_unlocked
? $t('course.navItem.lessons.add_lesson.lock')
: $t('course.navItem.lessons.add_lesson.unlock')}
@@ -1,10 +1,17 @@
<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 TextChip from '$lib/components/Chip/Text.svelte';
import IconButton from '$lib/components/IconButton/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
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 { snackbar } from '$lib/components/Snackbar/store';
import {
@@ -14,6 +21,7 @@
handleSaveLessonSection,
handleDeleteSection
} from '$lib/components/Course/components/Lesson/store/lessons';
import { updateLesson, updateLessonSection } from '$lib/utils/services/courses';
import type { Lesson } from '$lib/utils/types';
import DeleteLessonConfirmation from '$lib/components/Course/components/Lesson/DeleteLessonConfirmation.svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
@@ -25,7 +33,7 @@
lessonTitle?: string;
};
// export let reorder = false;
export let reorder = false;
export let lessonEditing: string | undefined;
let prevTitle: string | undefined;
@@ -67,7 +75,7 @@
if (!errors?.title) {
resetEdit();
} else {
snackbar.success('snackbar.success_update');
}
}
@@ -99,111 +107,247 @@
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>
<DeleteLessonConfirmation bind:openDeleteModal {deleteLesson} />
<section class="w-full p-3 lg:w-11/12 lg:px-4">
<div class="max-w-xl mx-auto">
{#each $lessonSections as section (section.id)}
<div class="mb-3 border border-gray-200 rounded-md">
<div
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"
>
{#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}
<section
class="m-auto w-full p-3 lg:w-11/12 lg:px-4"
use:dndzone={{
items: $lessonSections,
flipDurationMs,
type: 'rows',
dropTargetStyle: {
border: '2px #1d4ed8 solid',
'border-style': 'dashed'
},
dragDisabled: !reorder
}}
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]}>
<div class="flex items-center">
{#if lessonEditing === section.id}
<PrimaryButton
label={$t('course.navItem.lessons.add_lesson.cancel')}
variant={VARIANTS.OUTLINED}
onClick={() => {
section.title = prevTitle ?? section.title;
resetEdit();
}}
/>
<PrimaryButton
label={$t('course.navItem.lessons.add_lesson.save')}
onClick={() => onSave({ sectionId: section.id })}
/>
{:else}
<IconButton onClick={() => handleAddLesson(section.id)} disabled={!!lessonEditing}>
<Add size={20} />
</IconButton>
<IconButton
onClick={() => onEdit({ sectionId: section.id, sectionTitle: section.title })}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<div class="flex items-center">
{#if lessonEditing === section.id}
<PrimaryButton
label={$t('course.navItem.lessons.add_lesson.cancel')}
variant={VARIANTS.OUTLINED}
onClick={() => {
section.title = prevTitle ?? section.title;
resetEdit();
}}
/>
<PrimaryButton
label={$t('course.navItem.lessons.add_lesson.save')}
onClick={() => onSave({ sectionId: section.id })}
/>
{:else}
<IconButton
contained
size="small"
onClick={() => handleAddLesson(section.id)}
disabled={!!lessonEditing}
>
<Add size={20} />
</IconButton>
<OverflowMenu size="xl" flipped>
<OverflowMenuItem
text={$t('course.navItem.lessons.add_lesson.edit')}
disabled={!!lessonEditing}
>
<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}
on:click={() => onEdit({ sectionId: section.id, sectionTitle: section.title })}
/>
{:else}
<p class="w-4/6 text-md">{lesson.title}</p>
{/if}
<OverflowMenuItem
danger
text={$t('course.navItem.lessons.add_lesson.delete')}
on:click={() => onDelete({ sectionId: section.id })}
/>
</OverflowMenu>
{/if}
</div>
</RoleBasedSecurity>
</div>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<div class="flex items-center gap-1">
{#if lessonEditing === lesson.id}
<PrimaryButton
label={$t('course.navItem.lessons.add_lesson.cancel')}
variant={VARIANTS.OUTLINED}
onClick={() => {
lesson.title = prevTitle ?? lesson.title;
resetEdit();
<div
class="mx-3 py-1"
use:dndzone={{
items: section.lessons,
flipDurationMs,
dropTargetStyle: {
border: '2px #1d4ed8 solid',
'border-style': 'dashed'
},
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
label={$t('course.navItem.lessons.add_lesson.save')}
onClick={() => onSave({ lessonId: lesson.id }, lesson)}
<OverflowMenuItem
text={$t('course.navItem.lessons.add_lesson.edit')}
on:click={() => onEdit({ lessonId: lesson.id, lessonTitle: lesson.title })}
/>
{:else}
<IconButton
onClick={() => onEdit({ lessonId: lesson.id, lessonTitle: lesson.title })}
disabled={!!lessonEditing}
>
<Edit size={20} />
</IconButton>
<IconButton
onClick={() => onDelete({ lessonId: lesson.id })}
disabled={!!lessonEditing}
>
<TrashCan size={20} />
</IconButton>
{/if}
</div>
</RoleBasedSecurity>
</div>
{:else}
{$t('course.navItem.lessons.no_lesson')}
{/each}
</div>
<OverflowMenuItem
danger
text={$t('course.navItem.lessons.add_lesson.delete')}
on:click={() => onDelete({ lessonId: lesson.id })}
/>
</OverflowMenu>
{/if}
</div>
</RoleBasedSecurity>
</div>
{:else}
{$t('course.navItem.lessons.no_lesson')}
{/each}
</div>
{/each}
</div>
</div>
{/each}
</section>
@@ -160,7 +160,7 @@
},
{
id: NAV_IDS.LESSONS,
label: $t('course.navItems.nav_lessons'),
label: $t('course.navItems.nav_content'),
to: getLessonsRoute($course.id),
hideSortIcon: false,
isPaidFeature: false,
@@ -3,7 +3,7 @@ import type { Writable } from 'svelte/store';
import { lessons, lessonSections } from './components/Lesson/store/lessons';
import { ROLE } from '$lib/utils/constants/roles';
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 = {
id: '',
@@ -20,6 +20,7 @@ export const defaultCourse: Course = {
updated_at: new Date().toDateString(),
attendance: [],
polls: [],
version: COURSE_VERSION.V2,
metadata: {
requirements: '',
description: '',
@@ -120,7 +121,6 @@ export async function setCourse(data: Course, setLesson = true) {
lessons.set(orderedLessons);
if (data.lesson_section) {
console.log('Sections', data.lesson_section);
const sections = data.lesson_section?.map((section) => {
const lessons = (data.lessons || []).filter((lesson) => lesson.section_id === section.id);
section.lessons = sortLesson(lessons);