feat: clone course (#582)

* feat: clone course work

* feat: clone and delete a course

* feat: clone and delete a course

* feat: clean up

* feat: attending to reviews

* feat: attended to revies
This commit is contained in:
Nwosu Emmanuel
2025-10-29 09:57:29 +01:00
committed by GitHub
parent f8271d850f
commit 8efffcb7f6
27 changed files with 900 additions and 382 deletions
@@ -16,7 +16,7 @@
import GrowthIcon from 'carbon-icons-svelte/lib/Growth.svelte';
import RadioButtonChecked from 'carbon-icons-svelte/lib/RadioButtonChecked.svelte';
import UserProfileIcon from 'carbon-icons-svelte/lib/UserProfile.svelte';
import { copyCourseModal, deleteCourseModal } from '$lib/components/Courses/store';
export let bannerImage: string | undefined;
export let id = '';
export let slug = '';
@@ -43,8 +43,10 @@
$: formatter = getCurrencyFormatter(currency);
function handleCloneCourse() {
// TODO: Clone course functionality
alert('WIP: Clone course');
$copyCourseModal.open = true;
$copyCourseModal.id = id;
$copyCourseModal.title = title;
$copyCourseModal.description = description;
}
function handleShareCourse() {
@@ -56,7 +58,9 @@
}
function handleDeleteCourse() {
goto(`/courses/${id}/settings#delete`);
$deleteCourseModal.open = true;
$deleteCourseModal.id = id;
$deleteCourseModal.title = title;
}
const COURSE_TAG: Record<
@@ -122,7 +126,10 @@
>
<OverflowMenuItem
text={$t('courses.course_card.context_menu.clone')}
on:click={handleCloneCourse}
on:click={(e) => {
e.stopPropagation();
handleCloneCourse();
}}
/>
<OverflowMenuItem
text={$t('courses.course_card.context_menu.share')}
@@ -135,7 +142,10 @@
<OverflowMenuItem
danger
text={$t('courses.course_card.context_menu.delete')}
on:click={handleDeleteCourse}
on:click={(e) => {
e.stopPropagation();
handleDeleteCourse();
}}
/>
</OverflowMenu>
{/if}
@@ -1,23 +1,56 @@
<script>
<script lang="ts">
import { goto } from '$app/navigation';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import Modal from '$lib/components/Modal/index.svelte';
import { snackbar } from '$lib/components/Snackbar/store';
import TextField from '$lib/components/Form/TextField.svelte';
import TextArea from '$lib/components/Form/TextArea.svelte';
import { copyCourseModalInitialState, copyCourseModal } from '$lib/components/Courses/store';
import { cloneCourse } from '$lib/utils/services/courses/clone';
import { classroomio } from '$lib/utils/services/api';
import generateSlug from '$lib/utils/functions/generateSlug';
import { currentOrg } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
interface CloneCourseResponse {
success: boolean;
course: { id: string };
error?: string;
}
// clone, show spinner and redirect to new course
async function createCourse() {
// Prevent double submission (Option 1: Early Return Guard)
if ($copyCourseModal.isSaving) return;
console.log('Create course');
$copyCourseModal.isSaving = true;
try {
const newCourse = await cloneCourse($copyCourseModal.id, $copyCourseModal.title);
// Generate unique slug from the title
const slug = generateSlug($copyCourseModal.title);
goto(`/courses/${newCourse.id}`);
const response = await classroomio.course.clone.$post({
json: {
id: $copyCourseModal.id,
title: $copyCourseModal.title,
description: $copyCourseModal.description,
slug,
organizationId: $currentOrg.id
}
});
const result = (await response.json()) as CloneCourseResponse;
if (!response.ok) {
throw new Error(result.error || $t('courses.copy_course.error.failed_to_clone'));
}
// Option 3: Close modal and reset state before navigation
copyCourseModal.set(copyCourseModalInitialState);
// Navigate to the new course
goto(`/courses/${result.course.id}`);
$copyCourseModal.open = false;
} catch (error) {
console.error(error);
@@ -36,25 +69,34 @@
onClose={() => ($copyCourseModal.open = false)}
bind:open={$copyCourseModal.open}
width="w-96"
modalHeading="Create a Course"
modalHeading={$t('courses.copy_course.title')}
>
<form on:submit|preventDefault={createCourse}>
<TextField
label="Course name"
label={$t('courses.copy_course.course_name_label')}
bind:value={$copyCourseModal.title}
autoFocus={true}
placeholder="Your course name"
placeholder={$t('courses.copy_course.course_name_placeholder')}
className="mb-4"
isRequired={true}
autoComplete={false}
/>
<div class="mt-5 flex items-center flex-row-reverse">
<TextArea
label={$t('courses.copy_course.course_description_label')}
bind:value={$copyCourseModal.description}
placeholder={$t('courses.copy_course.course_description_placeholder')}
className="mb-4"
rows={4}
/>
<div class="mt-5 flex flex-row-reverse items-center">
<PrimaryButton
className="px-6 py-3"
label="Create"
label={$t('courses.copy_course.create_button')}
type="submit"
isLoading={$copyCourseModal.isSaving}
isDisabled={$copyCourseModal.isSaving}
/>
</div>
</form>
@@ -15,7 +15,12 @@
StructuredListBody
} from 'carbon-components-svelte';
import { t } from '$lib/utils/functions/translations';
import CopyCourseModal from './components/CopyCourseModal/index.svelte';
import DeleteModal from '$lib/components/Modal/DeleteModal.svelte';
import { deleteCourse } from '$lib/utils/services/courses';
import { snackbar } from '$lib/components/Snackbar/store';
import { deleteCourseModal, deleteCourseModalInitialState, courses as coursesStore } from './store';
export let courses: Course[] = [];
export let emptyTitle = $t('courses.course_card.empty_title');
export let emptyDescription = $t('courses.course_card.empty_description');
@@ -28,9 +33,39 @@
return Math.round((progressRate / totalLessons) * 100);
}
async function handleDeleteCourse() {
if (!$deleteCourseModal.id) return;
$deleteCourseModal.isDeleting = true;
try {
await deleteCourse($deleteCourseModal.id);
// Remove the course from the courses store
$coursesStore = $coursesStore.filter(course => course.id !== $deleteCourseModal.id);
// Show success message
snackbar.success('snackbar.course_deleted');
// Close modal and reset state
deleteCourseModal.set(deleteCourseModalInitialState);
} catch (error) {
console.error('Error deleting course:', error);
snackbar.error('snackbar.course_settings.error.went_wrong');
// Stop deleting state on error
$deleteCourseModal.isDeleting = false;
}
}
</script>
<!-- <CopyCourseModal /> -->
<CopyCourseModal />
<DeleteModal
onDelete={handleDeleteCourse}
bind:open={$deleteCourseModal.open}
isLoading={$deleteCourseModal.isDeleting}
/>
<div class="mx-auto my-4 w-full">
{#if $courseMetaDeta.isLoading}
@@ -40,8 +40,18 @@ export const copyCourseModalInitialState = {
courseIdToCopy: null,
id: '',
title: '',
description: '',
isSaving: false,
error: null
};
export const copyCourseModal = writable({ ...copyCourseModalInitialState });
export const deleteCourseModalInitialState = {
open: false,
id: '',
title: '',
isDeleting: false
};
export const deleteCourseModal = writable({ ...deleteCourseModalInitialState });
@@ -6,15 +6,11 @@
export let open = false;
export let onDelete = () => {};
function handleDelete() {
onDelete();
open = false;
}
export let isLoading = false;
</script>
<Modal
onClose={() => (open = false)}
onClose={() => !isLoading && (open = false)}
bind:open
width="w-96"
containerClass="p-6"
@@ -32,12 +28,14 @@
variant={VARIANTS.OUTLINED}
label={$t('delete_modal.no')}
onClick={() => (open = false)}
{isLoading}
/>
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.CONTAINED_DANGER}
label={$t('delete_modal.yes')}
onClick={handleDelete}
onClick={onDelete}
{isLoading}
/>
</div>
</div>