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:
+63
@@ -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')}
|
||||
|
||||
+242
-98
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user