CLASSROOMIO-T-49

merge

CLASSROOMIO-T-49: Quiz CRUD

CLASSROOMIO-T-49: Validate quiz question

CLASSROOMIO-T-49: Begin work on quiz preview

CLASSROOMIO-T-49: Complete quiz page

CLASSROOMIO-T-49: Init quiz work

CLASSROOMIO-T-49: Quiz CRUD

CLASSROOMIO-T-49: Validate quiz question

CLASSROOMIO-T-49: Begin work on quiz preview

CLASSROOMIO-T-49: Complete quiz page

CLASSROOMIO-T-49: Init quiz work


Co-authored-by: Rotimi Best <[email protected]>

Merge-request: CLASSROOMIO-MR-14
Merged-by: Best Admin <[email protected]>
This commit is contained in:
Rotimi Best
2023-06-17 19:11:29 +00:00
committed by Space Cloud
co-authored by Rotimi Best
parent b532ea6d29
commit 1e71a91a54
70 changed files with 10601 additions and 497 deletions
+31 -4
View File
@@ -9,6 +9,7 @@
import { derived } from 'svelte/store';
import { stores, goto } from '@sapper/app';
import isEmpty from 'lodash/isEmpty';
import mixpanel from 'mixpanel-browser';
import * as Sentry from '@sentry/browser';
import { Theme } from 'carbon-components-svelte';
import { Moon } from 'svelte-loading-spinners';
@@ -24,8 +25,14 @@
// import SideBar from "../components/SideBar/index.svelte";
// import Footer from '../components/Footer/index.svelte';
import Apps from '../components/Apps/index.svelte';
import PlayQuiz from '../components/Org/Quiz/Play/index.svelte';
import { handleAuthChange } from '../utils/functions/api';
import { isCoursesPage, isOrgPage, isLMSPage } from '../utils/functions/app';
import {
isCoursesPage,
isOrgPage,
isLMSPage,
isQuizPage,
} from '../utils/functions/app';
import showAppsSideBar from '../utils/functions/showAppsSideBar';
import { user, profile } from '../utils/store/user';
import { getSupabase } from '../utils/functions/supabase';
@@ -39,11 +46,15 @@
export let segment;
export let config;
// MIXPANEL
mixpanel.init(config.mixpanelToken, { debug: !config.isProd });
let { page, preloading } = stores();
let supabase = getSupabase(config);
let path = $page.path.replace('/', '');
let theme = 'white';
let skipAuth = false;
const delayedPreloading = derived(preloading, (currentPreloading, set) => {
setTimeout(() => set(currentPreloading), 250);
@@ -89,6 +100,8 @@
// Set user in sentry
Sentry.setUser($profile);
// Set user in Mixpanel
mixpanel.identify($profile.email);
if ($appStore.isStudentDomain) {
const { data, error } = await supabase
@@ -127,6 +140,9 @@
// Set user in sentry
Sentry.setUser($profile);
// Set user in Mixpanel
mixpanel.identify($profile.email);
const orgRes = await getOrganizations($profile.id);
// student redirect
@@ -161,6 +177,8 @@
$appStore.isStudentDomain = debug || answer;
$appStore.siteNameFromDomain = debug ? 'codingdojo' : subdomain;
skipAuth = debug || subdomain === 'play';
}
}
@@ -187,6 +205,7 @@
if (config.isProd) {
handleAuthChange(event, session);
}
// Log key events
console.log(`event`, event);
if (event == 'PASSWORD_RECOVERY') {
console.log('PASSWORD RESET');
@@ -197,6 +216,10 @@
return;
}
// Skip Authentication
if (skipAuth) return;
// Authentication Steps
if (event === 'SIGNED_IN') {
$user.fetchingUser = true;
getProfile();
@@ -236,7 +259,9 @@
<Snackbar />
{#if $appStore.isStudentDomain && !path}
{#if skipAuth}
<PlayQuiz />
{:else if $appStore.isStudentDomain && !path}
<OrgLandingPage {segment} />
{:else}
<main class="dark:bg-gray-800">
@@ -262,7 +287,9 @@
<div class="flex justify-between">
{#if isOrgPage($page.path)}
<div class="org-root w-full flex items-center justify-between">
<OrgSideBar />
{#if !isQuizPage($page.path)}
<OrgSideBar />
{/if}
<div class="org-slot bg-white dark:bg-gray-800">
<slot />
</div>
@@ -299,7 +326,7 @@
height: calc(100vh - 44px);
}
.org-slot {
width: calc(100vw - 250px);
min-width: calc(100vw - 250px);
height: calc(100vh - 44px);
overflow-y: auto;
}
+1 -1
View File
@@ -182,7 +182,7 @@
{:else}
<Box>
<AudioConsole32 class="carbon-icon w-80" />
<h3 class="text-3xl text-gray-500 dark:text-gray-200">
<h3 class="text-3xl text-gray-500 dark:text-white">
No Student Added
</h3>
</Box>
+2 -2
View File
@@ -152,7 +152,7 @@
{#each $lessons as lesson (lesson.id)}
<div
bind:this={ref}
class="group relative m-auto rounded-md border-2 border-gray-100 py-3 px-5 mb-4 flex items-center hover:shadow-2xl shadow-md transition delay-150 duration-300 ease-in-out"
class="group relative m-auto rounded-md border-2 border-gray-100 py-3 px-5 mb-4 flex items-center hover:shadow-md shadow-xl transition delay-150 duration-300 ease-in-out"
>
<!-- Complete or Not complete icon -->
<div class="absolute -left-6 -top-6 success">
@@ -303,7 +303,7 @@
</div>
{:else}
<Box>
<h3 class="text-3xl text-gray-500 dark:text-gray-200">
<h3 class="text-3xl text-gray-500 dark:text-white">
No lesson added
</h3>
<img alt="No lesson added" src="/notfound2.gif" class="w-80" />
+1 -1
View File
@@ -181,7 +181,7 @@
{:else}
<Box>
<AudioConsole32 class="carbon-icon w-80" />
<h3 class="text-3xl text-gray-500 dark:text-gray-200">
<h3 class="text-3xl text-gray-500 dark:text-white">
No Student Added
</h3>
</Box>
+1 -2
View File
@@ -25,7 +25,6 @@
import { formatAnswers } from '../../../components/Course/function';
import { snackbarStore } from '../../../components/Snackbar/store';
import { SNACKBAR_SEVERITY } from '../../../components/Snackbar/constants';
import Date from '../../../components/Form/Date.svelte';
import isSubmissionEarly from '../../../utils/functions/isSubmissionEarly';
import formatDate from '../../../utils/functions/formatDate';
@@ -332,7 +331,7 @@
</p>
</a>
<p
class="dark:text-white text-gray-500 dark:text-gray-200 text-xs"
class="dark:text-white text-gray-500 dark:text-white text-xs"
>
{item.submittedAt}
</p>
+4
View File
@@ -5,6 +5,7 @@
</script>
<script>
import mixpanel from 'mixpanel-browser';
import TextField from '../components/Form/TextField.svelte';
import PrimaryButton from '../components/PrimaryButton/index.svelte';
import { getSupabase } from '../utils/functions/supabase';
@@ -36,6 +37,9 @@
email: fields.email,
password: fields.password,
});
mixpanel.track('Log In', {
'Login Type': 'Referral',
});
console.log('data', data);
if (error) throw error;
} catch (error) {
@@ -287,7 +287,7 @@
{:else}
<div class="py-10 px-5">
<a
class="text-gray-500 dark:text-gray-200 text-md flex items-center"
class="text-gray-500 dark:text-white text-md flex items-center"
href={`${$currentOrgPath}/community`}
>
<ArrowLeft24 class="carbon-icon" /> Go Back
+1 -1
View File
@@ -57,7 +57,7 @@
<section class="w-full max-w-3xl mx-auto">
<div class="p-5">
<a
class="text-gray-500 dark:text-gray-200 text-md flex items-center"
class="text-gray-500 dark:text-white text-md flex items-center"
href={`${$currentOrgPath}/community`}
>
<ArrowLeft24 class="carbon-icon" /> Go Back
+414
View File
@@ -0,0 +1,414 @@
<script context="module">
export async function preload({ params }) {
return {
quizId: params.slug,
};
}
</script>
<script>
import { onMount } from 'svelte';
import { goto } from '@sapper/app';
import cloneDeep from 'lodash/cloneDeep';
import isBoolean from 'lodash/isBoolean';
import ArrowLeft24 from 'carbon-icons-svelte/lib/ArrowLeft24';
import CheckmarkFilled24 from 'carbon-icons-svelte/lib/CheckmarkFilled24';
import WarningFilled20 from 'carbon-icons-svelte/lib/WarningFilled20';
import { Select, SelectItem } from 'carbon-components-svelte';
import { currentOrgPath, deleteModal } from '../../../../utils/store/org';
import PrimaryButton from '../../../../components/PrimaryButton/index.svelte';
import { VARIANTS } from '../../../../components/PrimaryButton/constants';
import {
themeImages,
allOptions,
booleanOptions,
allThemes,
} from '../../../../utils/constants/quiz';
import { quizStore, quizesStore } from '../../../../utils/store/org';
import DeleteModal from '../../../../components/Org/Quiz/DeleteModal.svelte';
import Preview from '../../../../components/Org/Quiz/Play/Preview.svelte';
import QuizQuestion from '../../../../components/Org/Quiz/QuizQuestion.svelte';
import { snackbarStore } from '../../../../components/Snackbar/store';
import { SNACKBAR_SEVERITY } from '../../../../components/Snackbar/constants';
import { supabase } from '../../../../utils/functions/supabase';
export let quizId;
// Questionnaire State
let currentQuestion = $quizStore.questions[0] || {
options: [],
};
// Behavioural State
let openPreview = false;
let type = 'multichoice';
let errors = [];
let currentError = {};
let isFocused = false;
let selectEl = null;
function activeClass(q, cq) {
if (q.id === cq.id) {
return 'bg-gray-200 dark:bg-gray-800';
}
return '';
}
function addQuestion() {
$quizStore.questions = [
...$quizStore.questions,
{
id: new Date().getTime(),
label: '',
type: 'multichoice',
options: [],
},
];
type = 'multichoice';
}
function addOption() {
const cOptIds = currentQuestion.options.map((o) => o.id);
const nextOption = cloneDeep(allOptions).find(
(o) => !cOptIds.includes(o.id)
);
if (!nextOption) return;
currentQuestion.options = [...currentQuestion.options, nextOption];
}
function deleteOption() {
const opt = cloneDeep(currentQuestion.options);
opt.pop();
currentQuestion.options = opt;
}
function handleQuestionTypeChange(type) {
const opt =
type === 'multichoice'
? cloneDeep(allOptions).filter((o, i) => i < 2)
: cloneDeep(booleanOptions);
currentQuestion.type = type;
currentQuestion.options = opt;
// $quizStore.questions = $quizStore.questions.map((q) =>
// q.id === currentQuestion.id ? cloneDeep(currentQuestion) : q
// );
}
function deleteQuestion() {
// Only leave one question
if ($quizStore.questions.length === 1) return;
$quizStore.questions = $quizStore.questions.filter(
(q) => q.id !== currentQuestion.id
);
currentQuestion = $quizStore.questions[0];
}
function previewQuiz() {
openPreview = !openPreview;
}
function validateQuiz() {
const _errors = [];
$quizStore.questions.forEach((q) => {
const labelError = !!((q.label?.length || 0) < 3);
const options = [];
let hasOneAnswer = false;
q.options.forEach((o) => {
const valLabelError = !!(o.label.length < 3);
if (valLabelError) {
options.push({
id: o.id,
error: true,
});
}
if (o.isCorrect) {
hasOneAnswer = true;
}
});
_errors.push({
isLabelEmpty: labelError,
hasOneAnswer,
id: q.id,
options,
});
});
errors = _errors;
currentError = errors.find((e) => e.id === currentQuestion.id) || {};
return errors;
}
async function saveQuiz() {
const _errors = validateQuiz();
if (Array.isArray(_errors) && _errors.length && qHasError(null, _errors)) {
console.error('Please fix all errors', errors);
return;
}
const { data, error } = await supabase
.from('quiz')
.update(
{
...$quizStore,
updated_at: new Date(),
},
{
returning: 'minimal',
}
)
.match({ id: quizId });
console.log('data', data);
console.log('error', error);
if (error) {
$snackbarStore.message = "Something's not right - Please try later";
$snackbarStore.severity = SNACKBAR_SEVERITY.ERROR;
$snackbarStore.open = true;
return;
} else {
$snackbarStore.message = 'Saved Successfully';
$snackbarStore.severity = SNACKBAR_SEVERITY.SUCCESS;
$snackbarStore.open = true;
}
}
function qHasError(qId, _errs) {
return _errs.some((qe) => {
return (
(qId ? qe.id === qId : true) &&
(qe.isLabelEmpty || !qe.hasOneAnswer || !!qe.options.length)
);
});
}
function optionHasError(eId, _errs) {
if (Array.isArray(_errs) && _errs.length) {
return _errs.some((e) => e.id === eId && e.error);
}
return false;
}
onMount(() => {
const quiz = $quizesStore.find((q) => q.id === quizId);
if (!quiz) {
goto(`${$currentOrgPath}/quiz`);
return;
}
quizStore.set(quiz);
currentQuestion = quiz.questions[0];
});
</script>
<svelte:head>
<title>Quiz</title>
</svelte:head>
<DeleteModal onDelete={deleteQuestion} />
{#if openPreview}
<Preview exitPreview={previewQuiz} />
{/if}
<section class="w-screen h-full flex justify-between">
<!-- Questions list -->
<aside class="root w-1/5 p-4 bg-gray-100 dark:bg-gray-700 h-full">
<div class="h-full flex flex-col">
<a
class="text-gray-500 dark:text-white text-md flex items-center"
href={`${$currentOrgPath}/quiz`}
>
<ArrowLeft24 class="carbon-icon" /> Back to Quizzes
</a>
<h3 class="my-3">Quiz</h3>
<div class="mb-3">
{#each $quizStore.questions as question, i}
<button
class="w-full rounded p-3 mb-3 font-bold text-left text-gray-500 dark:text-white flex justify-between {activeClass(
question,
currentQuestion
)}"
on:click={() => {
currentQuestion = question;
type = question.type;
}}
>
Question {i + 1}
{#if qHasError(question.id, errors)}
<WarningFilled20 class="carbon-icon error" />
{/if}
</button>
{/each}
</div>
<div class="w-full flex justify-end">
<PrimaryButton
label="Add Question"
variant={VARIANTS.CONTAINED}
onClick={addQuestion}
/>
</div>
</div>
</aside>
<div class="container w-3/6 h-full">
<div
class="p-5 h-full"
style="background: url({themeImages[$quizStore.theme]
?.editor}) no-repeat center center fixed; -webkit-background-size: cover;-moz-background-size: cover;-o-background-size: cover;background-size: cover;"
>
<div class="content m-auto">
<h1 class="text-white font-bold my-5">{$quizStore.title}</h1>
<QuizQuestion {currentQuestion} {optionHasError} {currentError} />
{#if isBoolean(currentError.hasOneAnswer) && !currentError.hasOneAnswer}
<div class="mb-5">
<p class="text-red-500">
Please select at least one correct answer
</p>
</div>
{/if}
{#if currentQuestion.type !== 'boolean'}
<div class="w-full flex justify-center mb-4">
{#if currentQuestion.options.length < allOptions.length}
<PrimaryButton
label="+ Add more answers"
variant={VARIANTS.CONTAINED_WHITE}
onClick={addOption}
className="mr-5"
/>
{/if}
{#if currentQuestion.options.length > 0}
<PrimaryButton
label="- Remove last answer"
variant={VARIANTS.CONTAINED_WHITE}
onClick={deleteOption}
/>
{/if}
</div>
{/if}
</div>
</div>
</div>
<!-- Quiz Settings -->
<aside class="settings w-1/5 p-4 bg-gray-100 dark:bg-gray-700 h-full">
<div class="py-5">
<h5>Quiz settings</h5>
<PrimaryButton
label="Save Changes"
variant={VARIANTS.CONTAINED}
onClick={saveQuiz}
className="my-3"
/>
<PrimaryButton
label="Preview Quiz"
variant={VARIANTS.OUTLINED}
onClick={previewQuiz}
className="my-3"
/>
<PrimaryButton
label="Delete question"
variant={VARIANTS.TEXT}
onClick={() => {
if ($quizStore.questions.length === 1) return;
$deleteModal.open = true;
$deleteModal.isQuestion = true;
}}
className="my-3"
/>
</div>
<div class="flex flex-col justify-evenly">
<div class="my-3">
<!-- Question type -->
<Select
labelText="Question type"
bind:this={selectEl}
bind:selected={type}
class="flex items-center mb-3"
on:focus={() => (isFocused = true)}
on:blur={() => (isFocused = false)}
on:change={() => {
if (!isFocused) return;
// Blur after change
const onBlur = selectEl?.$$?.callbacks?.blur?.[0];
const onFocus = selectEl?.$$?.callbacks?.focus?.[0];
setTimeout(() => {
onBlur();
onFocus();
}, 1000);
handleQuestionTypeChange(type);
}}
>
<SelectItem value="multichoice" text="Multi-choice answers" />
<SelectItem value="boolean" text="True or False" />
</Select>
<!-- -->
<Select
labelText="Time limit"
bind:selected={$quizStore.timelimit}
class="flex items-center mb-3"
>
<SelectItem value="10 seconds" text="10s" />
<SelectItem value="20 seconds" text="20s" />
<SelectItem value="30 seconds" text="30s" />
<SelectItem value="1 minute" text="1m" />
<SelectItem value="2 minute" text="2m" />
<SelectItem value="3 minute" text="3m" />
</Select>
</div>
<!-- Theme settings -->
<div class="my-3">
<p class="mb-2">Choose a theme</p>
{#each allThemes as _theme}
<div
class="theme rounded-md w-full border cursor-pointer mb-5 relative {$quizStore.theme ===
_theme.id && 'border-blue-700'}"
>
{#if $quizStore.theme === _theme.id}
<CheckmarkFilled24
class="carbon-icon absolute top-4 right-4"
style="fill:white;"
/>
{/if}
<div
class="w-full rounded-md h-full border flex flex-col-reverse"
style="background: url({themeImages[_theme.id]?.card});"
on:click={() => ($quizStore.theme = _theme.id)}
>
<p class="ml-3 mb-3 text-white">{_theme.label}</p>
</div>
</div>
{/each}
</div>
</div>
</aside>
</section>
<style>
.container {
min-width: 696px;
}
.content {
max-width: 550px;
}
.theme {
height: 120px;
max-width: 260px;
}
</style>
+28
View File
@@ -0,0 +1,28 @@
<script>
import Quizzes from '../../../../components/Org/Quiz/Quizzes.svelte';
import NewQuizModal from '../../../../components/Org/Quiz/NewQuizModal.svelte';
import DeleteModal from '../../../../components/Org/Quiz/DeleteModal.svelte';
import PrimaryButton from '../../../../components/PrimaryButton/index.svelte';
import { createQuizModal } from '../../../../utils/store/org';
</script>
<svelte:head>
<title>Interactive Quizzes - ClassroomIO</title>
</svelte:head>
<section class="w-full max-w-6xl mx-auto">
<div class="py-10 px-5">
<div class="flex items-center justify-between mb-10">
<h1 class="dark:text-white text-3xl font-bold">Interactive Quizzes</h1>
<PrimaryButton
label="Create Quiz"
onClick={() => ($createQuizModal.open = true)}
/>
</div>
<NewQuizModal />
<DeleteModal />
<Quizzes />
</div>
</section>
@@ -10,7 +10,7 @@
<section class="w-full max-w-6xl mx-auto">
<div class="py-10 px-5">
<a
class="text-gray-500 dark:text-gray-200 text-md"
class="text-gray-500 dark:text-white text-md"
href={`${$currentOrgPath}/settings`}>Back to Settings</a
>
<div class="flex items-center justify-between mb-10">
+1 -1
View File
@@ -10,7 +10,7 @@
<section class="w-full max-w-6xl mx-auto">
<div class="py-10 px-5">
<a
class="text-gray-500 dark:text-gray-200 text-md"
class="text-gray-500 dark:text-white text-md"
href={`${$currentOrgPath}/settings`}>Back to Settings</a
>
<div class="flex items-center justify-between mb-10">
+4
View File
@@ -6,6 +6,7 @@
<script>
import { goto } from '@sapper/app';
import mixpanel from 'mixpanel-browser';
import TextField from '../components/Form/TextField.svelte';
import PrimaryButton from '../components/PrimaryButton/index.svelte';
import { getSupabase } from '../utils/functions/supabase';
@@ -46,6 +47,9 @@
console.log('data', data);
if (error) throw error;
mixpanel.track('Signed Up', {
'Signup Type': 'Referral',
});
formRef?.reset();
return goto('/login');