feat: lesson note version history (#374)

* chore: install diff package

* chore: add id to render component

* feat: implement language lesson version history

* chore: fix darkmode colors and use tailwind css

* chore: add screensize conditional

* refactor: list updating after data fetch

* change mode to view to prevent duplicate data being saved

* chore: remove raw text

* chore: db migration

* feat: properly format timestamp

* feat: add snackbar for success

* chore: group by minutes, use icons instead of texts for buttons

* docs: generate translations

* refactor

---------

Co-authored-by: Iniubong Obonguko <[email protected]>
This commit is contained in:
Rotimi Best
2024-06-30 04:47:15 +01:00
committed by GitHub
co-authored by Iniubong Obonguko
parent decea8d1e5
commit 931ccb1a09
15 changed files with 407 additions and 29 deletions
+1
View File
@@ -79,6 +79,7 @@
"babel-jest": "^29.7.0",
"carbon-components-svelte": "^0.79.0",
"carbon-icons-svelte": "^12.1.0",
"diff": "^5.2.0",
"dotenv": "^16.3.1",
"eslint-config-custom": "workspace:*",
"jessy": "^3.1.1",
@@ -0,0 +1,268 @@
<script lang="ts">
import { onMount } from 'svelte';
import { createEventDispatcher } from 'svelte';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
import ArrowLeft from 'carbon-icons-svelte/lib/ArrowLeft.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import CaretRight from 'carbon-icons-svelte/lib/CaretRight.svelte';
import { fetchLesssonLanguageHistory } from '$lib/utils/services/courses';
import { diffLines } from 'diff';
import { lesson, lessons } from '$lib/components/Course/components/Lesson/store/lessons';
import { supabase } from '$lib/utils/functions/supabase';
import { t } from '$lib/utils/functions/translations';
import { LOCALE } from '$lib/utils/types';
import { snackbar } from '$lib/components/Snackbar/store';
interface LessonHistory {
new_content: string;
old_content: string;
timestamp: Date;
locale: LOCALE;
lesson_id: string;
}
export let open = false;
let lessonTitle: string = '';
let lessonId: string = '';
let lessonHistory: LessonHistory[] = [];
let content = '';
let selectedVersion: LessonHistory = {
new_content: '',
old_content: '',
timestamp: new Date(),
locale: LOCALE.EN,
lesson_id: ''
};
let selectedVersionIndex = 0;
let contentRestoreLoading = false;
let versionsToFetch = 9;
let isMoreHistoryLoading = false;
let mounted = false;
const dispatch = createEventDispatcher();
function scrollLock(open) {
if (mounted) {
const body = document.querySelector('body');
if (!body) return;
body.style.overflow = open ? 'hidden' : 'auto';
}
}
function formatTimestamp(timestamp) {
const options: Intl.DateTimeFormatOptions = {
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true
};
const date = new Date(timestamp + 'Z');
return new Intl.DateTimeFormat('en-US', options).format(date);
}
function handleDrawerClose() {
dispatch('close');
}
function getMinutes(time: Date) {
return new Date(time).getMinutes();
}
function removeDuplicate(history: LessonHistory[]) {
return history.filter(
(obj1, i, arr) =>
arr.findIndex((obj2) => getMinutes(obj2.timestamp) === getMinutes(obj1.timestamp)) === i
);
}
async function fetchLessonHistory(lessonId: string, locale: string, endRange: number) {
try {
isMoreHistoryLoading = true;
const { data, error } = await fetchLesssonLanguageHistory(lessonId, locale, endRange);
if (!data) {
throw error;
}
// Filter out duplicates based on timestamp
const existingTimestamps = new Set(
lessonHistory.map((item) => new Date(item.timestamp).getMinutes())
);
const newEntries = data.filter(
(item) => !existingTimestamps.has(new Date(item.timestamp).getMinutes())
);
lessonHistory = removeDuplicate([...lessonHistory, ...newEntries]);
updateContentVersion(lessonHistory[0], 0);
} catch (error) {
console.error(error);
snackbar.error('Failed to fetch history');
} finally {
isMoreHistoryLoading = false;
}
}
onMount(() => {
mounted = true;
scrollLock(open);
});
function updateContentVersion(content: LessonHistory, index: number) {
selectedVersionIndex = index;
selectedVersion = content;
const display = document.getElementById('display');
display!.innerHTML = '';
const diff = diffLines(content.old_content, content.new_content);
const fragment = document.createDocumentFragment();
diff.forEach((part) => {
const span = document.createElement('span');
if (part.added) {
span.classList.add('text-green-500');
} else if (part.removed) {
span.classList.add('text-red-500', 'line-through');
} else {
span.classList.add('text-black', 'dark:text-white');
}
span.innerHTML = part.value;
fragment.appendChild(span);
});
display!.appendChild(fragment);
}
async function restoreSelectedVersion() {
try {
contentRestoreLoading = true;
await supabase
.from('lesson_language')
.update({ content: selectedVersion.new_content })
.eq('lesson_id', selectedVersion.lesson_id)
.eq('locale', selectedVersion.locale);
} catch (error) {
console.error(error);
snackbar.error('Failed to restore');
} finally {
contentRestoreLoading = false;
dispatch('restore');
}
}
function loadMoreHistory() {
versionsToFetch += 10;
}
$: lessonTitle = $lessons.find((les) => les.id === $lesson.id)?.title || '';
$: lessonId = $lesson.id || '';
$: scrollLock(open);
$: fetchLessonHistory(lessonId, $lesson.locale, versionsToFetch);
</script>
<aside class="drawer bg-gray-100 dark:bg-neutral-800" class:open>
<div class="panel bg-white dark:bg-black">
<div class="p-10 w-full pr-80">
<div class="flex items-start gap-x-10">
<PrimaryButton variant={VARIANTS.OUTLINED} onClick={handleDrawerClose}>
<ArrowLeft />
</PrimaryButton>
{#if selectedVersionIndex != 0}
<div class="">
<PrimaryButton isLoading={contentRestoreLoading} onClick={restoreSelectedVersion}
>{$t('course.navItem.lessons.version_history.restore_version')}</PrimaryButton
>
</div>
{/if}
</div>
<div class="w-full h-full flex flex-col items-start">
<HtmlRender className="m-auto text-center mt-6 flex items-center justify-center">
<svelte:fragment slot="content">
<h1 class="text-2xl md:text-4xl mt-0 capitalize">
{lessonTitle}
</h1>
</svelte:fragment>
</HtmlRender>
{#key lessonId}
<HtmlRender id="display" className="m-auto">
<svelte:fragment slot="content">
<div class="amen">
{@html content}
</div>
</svelte:fragment>
</HtmlRender>
{/key}
</div>
</div>
</div>
<div
id="scroll-container"
class="w-80 fixed right-0 top-0 min-h-screen h-full z-10 overflow-x-auto overflow-y-scroll bg-gray-100 dark:bg-neutral-800 py-10 space-y-6"
>
<p class="font-medium text-xl text-left flex items-start justify-start px-10">
{$t('course.navItem.lessons.version_history.title')}
</p>
<div>
{#each lessonHistory as version, index}
<button
on:click={() => updateContentVersion(version, index)}
class="hover:bg-gray-200 dark:hover:bg-neutral-700 cursor-pointer p-4 w-full px-10 flex items-start {index ==
selectedVersionIndex
? 'bg-gray-200 dark:bg-neutral-700'
: ''}"
>
<CaretRight class="mt-1"></CaretRight>
<div>
<span class="inline-block font-medium text-base"
>{formatTimestamp(version.timestamp)}</span
>
{#if index == 0}
<span class="block italic text-xs text-start"
>{$t('course.navItem.lessons.version_history.current_version')}</span
>
{/if}
</div>
</button>
{/each}
<div class="flex items-center justify-start px-10 mt-2 h-10">
<PrimaryButton className="h-full" isLoading={isMoreHistoryLoading} onClick={loadMoreHistory}
>{$t('course.navItem.lessons.version_history.fetch_more_versions')}</PrimaryButton
>
</div>
</div>
</div>
</aside>
<style>
.drawer {
position: fixed;
top: 0;
left: 0;
height: 100%;
width: 100%;
z-index: -1;
}
.drawer.open {
z-index: 99;
}
.panel {
position: fixed;
width: 100%;
height: 100%;
z-index: 3;
overflow: auto;
}
.drawer.open .panel {
transform: translate(0, 0);
}
</style>
@@ -1,9 +1,10 @@
<script>
export let content = '';
export let className = '';
export let id = '';
</script>
<article class="preview prose prose-sm sm:prose {className} dark:text-white">
<article {id} class="preview prose prose-sm sm:prose {className} dark:text-white">
{#if $$slots.content}
<slot name="content" />
{:else}
@@ -185,6 +185,16 @@ export function fetchLesson(lessonId: Lesson['id']) {
.single();
}
export function fetchLesssonLanguageHistory(lessonId: string, locale: string, endRange: number) {
return supabase
.from('lesson_versions')
.select('*')
.range(0, endRange)
.eq('lesson_id', lessonId)
.eq('locale', locale)
.order('timestamp', { ascending: false });
}
export function createLesson(lesson: any) {
return supabase.from('lesson').insert(lesson).select();
}
@@ -47,7 +47,8 @@
},
"success": {
"complete_marked": "Als abgeschlossen markiert",
"complete_download": "Download abgeschlossen"
"complete_download": "Download abgeschlossen",
"version_restored": "Version erfolgreich wiederhergestellt"
}
},
"attendance": {
@@ -750,6 +751,12 @@
"disabled": "Funktion ist deaktiviert",
"next": "Nächste",
"prev": "Vorher",
"version_history": {
"title": "Versionsgeschichte",
"fetch_more_versions": "Weitere Versionen abrufen",
"current_version": "Aktuelle Version",
"restore_version": "Stellen Sie diese Version wieder her"
},
"drag": "Ziehen Sie eine Karte, um sie neu anzuordnen",
"no_lesson": "Noch kein Unterricht",
"share_your_knowledge": "Teilen Sie Ihr Wissen mit der Welt, indem Sie ansprechende Lektionen erstellen. Klicken Sie zunächst auf die Schaltfläche „Hinzufügen“."
@@ -1363,4 +1370,4 @@
"hyphen_rule": "Der Name der Organisation darf nicht mit einem Bindestrich beginnen oder enden"
}
}
}
}
@@ -747,6 +747,12 @@
"option_label": "Option Label",
"cancel": "Cancel",
"poll_question": "Poll Question"
},
"version_history": {
"title": "Version history",
"fetch_more_versions": "Fetch more versions",
"current_version": "Current version",
"restore_version": "Restore this version"
}
},
@@ -1243,7 +1249,8 @@
"lessons": {
"success": {
"complete_marked": "Marked as complete",
"complete_download": "Download Complete"
"complete_download": "Download Complete",
"version_restored": "Version restored sucessfully"
},
"error": {
"try_later": "Something's not right - Please try later"
@@ -47,7 +47,8 @@
},
"success": {
"complete_marked": "Marcado como completo",
"complete_download": "Descarga completa"
"complete_download": "Descarga completa",
"version_restored": "Versión restaurada exitosamente"
}
},
"attendance": {
@@ -750,6 +751,12 @@
"disabled": "La característica está deshabilitada",
"next": "Próximo",
"prev": "Anterior",
"version_history": {
"title": "Historial de versiones",
"fetch_more_versions": "Obtener más versiones",
"current_version": "Versión actual",
"restore_version": "Restaurar esta versión"
},
"drag": "arrastra una tarjeta para reordenar",
"no_lesson": "Aún no hay lecciones",
"share_your_knowledge": "Comparta sus conocimientos con el mundo creando lecciones interesantes. Comience haciendo clic en el botón Agregar."
@@ -1363,4 +1370,4 @@
"hyphen_rule": "El nombre de la organización no puede comenzar ni terminar con un guión."
}
}
}
}
@@ -47,7 +47,8 @@
},
"success": {
"complete_marked": "Marqué comme terminé",
"complete_download": "Téléchargement complet"
"complete_download": "Téléchargement complet",
"version_restored": "Version restaurée avec succès"
}
},
"attendance": {
@@ -750,6 +751,12 @@
"disabled": "La fonctionnalité est désactivée",
"next": "Suivant",
"prev": "Précédent",
"version_history": {
"title": "Historique des versions",
"fetch_more_versions": "Récupérer plus de versions",
"current_version": "Version actuelle",
"restore_version": "Restaurer cette version"
},
"drag": "faites glisser une carte pour réorganiser",
"no_lesson": "Pas encore de cours",
"share_your_knowledge": "Partagez vos connaissances avec le monde en créant des leçons engageantes. Commencez par cliquer sur le bouton Ajouter."
@@ -1363,4 +1370,4 @@
"hyphen_rule": "Le nom de l'organisation ne peut pas commencer ou se terminer par un trait d'union"
}
}
}
}
@@ -47,7 +47,8 @@
},
"success": {
"complete_marked": "पूर्ण के रूप में चिह्नित",
"complete_download": "डाउनलोड पूर्ण"
"complete_download": "डाउनलोड पूर्ण",
"version_restored": "संस्करण सफलतापूर्वक पुनर्स्थापित किया गया"
}
},
"attendance": {
@@ -750,6 +751,12 @@
"disabled": "सुविधा अक्षम है",
"next": "अगला",
"prev": "पिछला",
"version_history": {
"title": "संस्करण इतिहास",
"fetch_more_versions": "अधिक संस्करण प्राप्त करें",
"current_version": "वर्तमान संस्करण",
"restore_version": "इस संस्करण को पुनर्स्थापित करें"
},
"drag": "पुनः व्यवस्थित करने के लिए कार्ड खींचें",
"no_lesson": "अभी तक कोई सबक नहीं",
"share_your_knowledge": "आकर्षक पाठ बनाकर अपना ज्ञान दुनिया के साथ साझा करें। ऐड बटन पर क्लिक करके प्रारंभ करें।"
@@ -1363,4 +1370,4 @@
"hyphen_rule": "संगठन का नाम हाइफ़न से प्रारंभ या समाप्त नहीं हो सकता"
}
}
}
}
@@ -47,7 +47,8 @@
},
"success": {
"complete_marked": "Marcado como concluído",
"complete_download": "Download completo"
"complete_download": "Download completo",
"version_restored": "Versão restaurada com sucesso"
}
},
"attendance": {
@@ -750,6 +751,12 @@
"disabled": "O recurso está desativado",
"next": "Próximo",
"prev": "Anterior",
"version_history": {
"title": "Histórico de versões",
"fetch_more_versions": "Buscar mais versões",
"current_version": "Versão Atual",
"restore_version": "Restaurar esta versão"
},
"drag": "arraste um cartão para reordenar",
"no_lesson": "Ainda não há aulas",
"share_your_knowledge": "Compartilhe seu conhecimento com o mundo criando aulas envolventes. Comece clicando no botão Adicionar."
@@ -1363,4 +1370,4 @@
"hyphen_rule": "O nome da organização não pode começar ou terminar com um hífen"
}
}
}
}
@@ -47,7 +47,8 @@
},
"success": {
"complete_marked": "Помечено как завершенное",
"complete_download": "Загрузка завершена"
"complete_download": "Загрузка завершена",
"version_restored": "Версия успешно восстановлена"
}
},
"attendance": {
@@ -750,6 +751,12 @@
"disabled": "Функция отключена",
"next": "Следующий",
"prev": "Предыдущий",
"version_history": {
"title": "История версий",
"fetch_more_versions": "Получить больше версий",
"current_version": "Текущая версия",
"restore_version": "Восстановить эту версию"
},
"drag": "перетащите карточку, чтобы изменить порядок",
"no_lesson": "Уроков пока нет",
"share_your_knowledge": "Поделитесь своими знаниями со всем миром, создавая увлекательные уроки. Начните с нажатия кнопки «Добавить»."
@@ -1363,4 +1370,4 @@
"hyphen_rule": "Название организации не может начинаться или заканчиваться дефисом."
}
}
}
}
@@ -47,7 +47,8 @@
},
"success": {
"complete_marked": "Được đánh dấu là hoàn thành",
"complete_download": "Hoan thanh tải"
"complete_download": "Hoan thanh tải",
"version_restored": "Đã khôi phục phiên bản thành công"
}
},
"attendance": {
@@ -750,6 +751,12 @@
"disabled": "Tính năng bị vô hiệu hóa",
"next": "Kế tiếp",
"prev": "Trước đó",
"version_history": {
"title": "Lịch sử phiên bản",
"fetch_more_versions": "Tìm nạp thêm phiên bản",
"current_version": "Phiên bản hiện tại",
"restore_version": "Khôi phục phiên bản này"
},
"drag": "kéo thẻ để sắp xếp lại",
"no_lesson": "Chưa có bài học nào",
"share_your_knowledge": "Chia sẻ kiến ​​thức của bạn với thế giới bằng cách tạo ra những bài học hấp dẫn. Bắt đầu bằng cách nhấp vào nút Thêm."
@@ -1363,4 +1370,4 @@
"hyphen_rule": "Tên tổ chức không thể bắt đầu hoặc kết thúc bằng dấu gạch nối"
}
}
}
}
@@ -19,9 +19,11 @@
import PageBody from '$lib/components/PageBody/index.svelte';
import Materials from '$lib/components/Course/components/Lesson/Materials/index.svelte';
import Exercises from '$lib/components/Course/components/Lesson/Exercises/index.svelte';
import LanguageLessonVersionHistory from '$lib/components/Course/components/Lesson/LanguageLessonVersionHistory.svelte';
import MODES from '$lib/utils/constants/mode';
import { course } from '$lib/components/Course/store';
import Download from 'carbon-icons-svelte/lib/Download.svelte';
import ResultOld from 'carbon-icons-svelte/lib/ResultOld.svelte';
import OverflowMenuVertical from 'carbon-icons-svelte/lib/OverflowMenuVertical.svelte';
import { apps } from '$lib/components/Apps/store';
import APPS_CONSTANTS from '$lib/components/Apps/constants';
@@ -39,7 +41,7 @@
import { getIsLessonComplete } from '$lib/components/Course/components/Lesson/functions';
import { t } from '$lib/utils/functions/translations';
import { LANGUAGES } from '$lib/utils/constants/translation';
import { ChevronLeft, ChevronRight } from 'carbon-icons-svelte';
import { ChevronLeft, ChevronRight, Edit, Save } from 'carbon-icons-svelte';
export let data;
@@ -50,6 +52,7 @@
let isLoading = false;
let isSaving = false;
let isLessonComplete = false;
let isVersionDrawerOpen = false;
function getLessonOrder(id: string) {
const index = $lessons.findIndex((lesson) => lesson.id === id);
@@ -268,6 +271,15 @@
goto(path);
};
const refetchDataAfterVersionRestore = () => {
isVersionDrawerOpen = false;
if (data.courseId && browser) {
mode = MODES.view;
fetchReqData(data.lessonId, data.isMaterialsTabActive);
}
snackbar.success('snackbar.lessons.success.version_restored');
};
$: path = $page.url?.pathname?.replace(/\/exercises[\/ 0-9 a-z -]*/, '');
$: if (data.courseId && browser) {
@@ -303,12 +315,16 @@
]}
>
<svelte:fragment slot="widget">
<div class="flex">
<div class="flex items-center gap-1">
<RoleBasedSecurity allowedRoles={[1, 2]}>
{#if data.isMaterialsTabActive}
<div class="mr-5">
<Dropdown items={LANGUAGES} bind:selectedId={$lesson.locale} class="h-full" />
</div>
<!-- Version control -->
{#if mode === MODES.edit && window.innerWidth >= 1024}
<IconButton onClick={() => (isVersionDrawerOpen = true)}>
<ResultOld size={24} />
</IconButton>
{/if}
<div class="tab">
<IconButton onClick={toggleApps} buttonClassName="">
<OverflowMenuVertical size={24} />
@@ -321,19 +337,19 @@
: 'hidden'
} lg:flex items-center`}
>
<PrimaryButton
className="mb-2 lg:mb-0 mr-2"
variant={VARIANTS.OUTLINED}
<IconButton
onClick={() => {
$apps.dropdown = false;
toggleMode();
}}
isDisabled={isSaving}
disabled={isSaving}
>
{mode === MODES.edit
? $t('course.navItem.lessons.done')
: $t('course.navItem.lessons.edit')}
</PrimaryButton>
{#if mode === MODES.edit}
<Save size={24} />
{:else}
<Edit size={24} />
{/if}
</IconButton>
{#if $course.metadata.lessonDownload && !!PUBLIC_SERVER_URL}
<PrimaryButton
@@ -346,6 +362,8 @@
</PrimaryButton>
{/if}
</div>
<Dropdown items={LANGUAGES} bind:selectedId={$lesson.locale} class="h-full" />
{/if}
</RoleBasedSecurity>
</div>
@@ -437,6 +455,15 @@
</button>
</div>
</div>
<!-- Version Control Preview -->
{#if isVersionDrawerOpen && window.innerWidth >= 1024}
<LanguageLessonVersionHistory
open={isVersionDrawerOpen}
on:close={() => (isVersionDrawerOpen = false)}
on:restore={refetchDataAfterVersionRestore}
/>
{/if}
</CourseContainer>
<style>
+8
View File
@@ -334,6 +334,9 @@ importers:
carbon-icons-svelte:
specifier: ^12.1.0
version: 12.1.0
diff:
specifier: ^5.2.0
version: 5.2.0
dotenv:
specifier: ^16.3.1
version: 16.3.1
@@ -8886,6 +8889,11 @@ packages:
engines: {node: '>=0.3.1'}
dev: false
/[email protected]:
resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==}
engines: {node: '>=0.3.1'}
dev: true
/[email protected]:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
dev: true
@@ -0,0 +1,7 @@
create or replace view "public"."lesson_versions" as SELECT llh.old_content,
llh.new_content,
llh."timestamp",
ll.locale,
ll.lesson_id
FROM (lesson_language_history llh
JOIN lesson_language ll ON ((ll.id = llh.lesson_language_id)));