CLASSROOMIO-T-227: Exercise Statistics
CLASSROOMIO-T-227: 🔥 🔥 🔥 🔥 Improvements CLASSROOMIO-T-227:Worked on the Exercise Statistics CLASSROOMIO-T-227:Worked on the Exercise Statistics CLASSROOMIO-T-227:Worked on the Exercise Statistics Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> Co-authored-by: Ifeanyi Nwosu <ifeanyi@digdippa.com> Co-authored-by: Rotimi Best <rotimiibitoyeemma@gmail.com> Merge-request: CLASSROOMIO-MR-111 Merged-by: Best Admin <best@digdippa.com>
This commit is contained in:
committed by
Space Cloud
co-authored by
Rotimi Best
Rotimi Best
parent
b21d5923e6
commit
5c8571e571
@@ -14,6 +14,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.386.0",
|
||||
"@carbon/charts-svelte": "^1.13.6",
|
||||
"@sendgrid/mail": "^7.7.0",
|
||||
"@sentry/browser": "^7.61.0",
|
||||
"@sentry/integrations": "^7.61.0",
|
||||
@@ -32,6 +33,9 @@
|
||||
"canvas-confetti": "^1.6.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"d3": "^7.8.5",
|
||||
"d3-cloud": "^1.2.7",
|
||||
"d3-sankey": "^0.12.3",
|
||||
"form-data": "^4.0.0",
|
||||
"hotkeys-js": "^3.11.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
let comments: LessonComment[] = [];
|
||||
let groupmember: GroupPerson | undefined;
|
||||
let isSaving: boolean = false;
|
||||
let isFetched = false;
|
||||
|
||||
interface FetchComments {
|
||||
comment: string;
|
||||
@@ -155,6 +156,7 @@
|
||||
console.error('error');
|
||||
return;
|
||||
}
|
||||
console.log('fetch comments', data);
|
||||
|
||||
comments = data.map((lessonComment) => {
|
||||
return {
|
||||
@@ -167,6 +169,7 @@
|
||||
: lessonComment.groupmember.profile.fullname
|
||||
};
|
||||
});
|
||||
isFetched = true;
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
@@ -185,7 +188,7 @@
|
||||
supabase.removeChannel(lessonCommentChannel);
|
||||
});
|
||||
|
||||
$: fetchComments($group.people, $lesson.id);
|
||||
$: !isFetched && fetchComments($group.people, $lesson.id);
|
||||
</script>
|
||||
|
||||
<PageNav title="Comments" overidableStyle="padding: 0 10px">
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import type { PollType } from './types';
|
||||
import { getSupabase } from '$lib/utils/functions/supabase';
|
||||
import type { GroupPerson } from '$lib/utils/types';
|
||||
|
||||
const supabase = getSupabase();
|
||||
|
||||
export async function createPoll(poll: PollType, groupmember: GroupPerson, courseId: string) {
|
||||
if (!groupmember) return;
|
||||
|
||||
const { data: newPolls, error } = await supabase
|
||||
.from('apps_poll')
|
||||
.insert({
|
||||
question: poll.question,
|
||||
authorId: groupmember.id,
|
||||
isPublic: poll.isPublic,
|
||||
status: poll.question,
|
||||
expiration: poll.expiration,
|
||||
courseId
|
||||
})
|
||||
.select();
|
||||
|
||||
if (!newPolls || error) {
|
||||
snackbar.error('Error creating poll');
|
||||
console.error(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const [newPoll] = newPolls;
|
||||
|
||||
if (!newPoll.id) return;
|
||||
poll = {
|
||||
poll,
|
||||
...newPoll
|
||||
};
|
||||
|
||||
poll.options = await Promise.all(
|
||||
poll.options.map(async (option) => {
|
||||
const { data: newPollOptions, error: newPollOptionsError } = await supabase
|
||||
.from('apps_poll_option')
|
||||
.insert({
|
||||
poll_id: newPoll.id,
|
||||
label: option.label
|
||||
})
|
||||
.select();
|
||||
|
||||
if (!newPollOptions || newPollOptionsError) {
|
||||
return option;
|
||||
}
|
||||
|
||||
const [newPollOption] = newPollOptions;
|
||||
option.id = newPollOption.id;
|
||||
|
||||
return option;
|
||||
})
|
||||
);
|
||||
|
||||
return poll;
|
||||
}
|
||||
|
||||
export async function fetchPolls(courseId: string) {
|
||||
const {} = await supabase
|
||||
.from('app_poll')
|
||||
.select(
|
||||
`
|
||||
id,
|
||||
question,
|
||||
isPublic,
|
||||
status,
|
||||
expiration
|
||||
groupmember:authorId(
|
||||
profile!inner(
|
||||
id,
|
||||
fullname,
|
||||
avatar_url
|
||||
)
|
||||
)
|
||||
apps_poll_option!inner(
|
||||
id,
|
||||
label,
|
||||
)
|
||||
`
|
||||
)
|
||||
.match({
|
||||
courseId
|
||||
});
|
||||
}
|
||||
@@ -145,7 +145,7 @@
|
||||
|
||||
{#if !!$apps.selectedApp}
|
||||
<div
|
||||
class={`app cursor-auto lg:cursor-ew-resize max-w-[350px] transition ease-in-out delay-150 duration-100`}
|
||||
class="app max-w-[350px] transition ease-in-out delay-150 duration-100"
|
||||
bind:this={appContentRef}
|
||||
>
|
||||
{#key $apps.selectedApp}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { fetchSubmission } from '$lib/utils/services/submissions';
|
||||
import type { ExerciseSubmissions } from '$lib/utils/types';
|
||||
|
||||
export let exerciseId: string;
|
||||
|
||||
let isLoading = false;
|
||||
let analyticsData: ExerciseSubmissions[] = [];
|
||||
|
||||
async function fetchAnalyticsData(id: string | undefined) {
|
||||
console.log('id', id);
|
||||
if (!id) return;
|
||||
|
||||
const { data } = await fetchSubmission({
|
||||
exerciseId: id
|
||||
});
|
||||
|
||||
if (!data) return;
|
||||
|
||||
// This is the data you need, look at the typescript interface
|
||||
analyticsData = data;
|
||||
}
|
||||
|
||||
$: fetchAnalyticsData(exerciseId);
|
||||
</script>
|
||||
|
||||
{#if isLoading}
|
||||
Loading data
|
||||
{:else}
|
||||
Analytics data
|
||||
{/if}
|
||||
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import { questionnaire } from '../../store/exercise';
|
||||
import { submissions } from './store';
|
||||
|
||||
export let isLoading = false;
|
||||
|
||||
const defaultImg =
|
||||
'https://images.unsplash.com/photo-1599566150163-29194dcaad36?auto=format&fit=crop&q=60&w=800&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8NHx8YXZhdGFyc3xlbnwwfHwwfHx8Mg%3D%3D';
|
||||
|
||||
let studentSelected = 0;
|
||||
|
||||
const isSelected = (option: { value: string[] }): boolean => {
|
||||
if ($submissions) {
|
||||
let submittedAnswer = $submissions[studentSelected]?.answers.map((answer) => answer.answers);
|
||||
return !!submittedAnswer.find((opt) => opt == option.value);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if isLoading}
|
||||
Loading data
|
||||
{:else if $submissions?.length}
|
||||
<div class="flex gap-1 w-full overflow-auto mt-2 mb-5">
|
||||
{#each $submissions as student, i}
|
||||
<button on:click={() => (studentSelected = i)} class="flex flex-col items-center w-20">
|
||||
<div
|
||||
class={`flex items-center justify-center w-12 h-12 rounded-full ${
|
||||
studentSelected == i ? 'border-[3px] border-primary-700' : ''
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={student.submitted_by.profile.avatar_url
|
||||
? student.submitted_by.profile.avatar_url
|
||||
: defaultImg}
|
||||
alt="student"
|
||||
class={`w-10 h-10 rounded-full bg-white m-1`}
|
||||
/>
|
||||
</div>
|
||||
<p class="line-clamp-2 w-20 whitespace-pre-wrap leading-4">
|
||||
{student.submitted_by.profile.fullname}
|
||||
</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class="font-medium mb-2">
|
||||
{$submissions[studentSelected].submitted_by.profile.fullname}'s Individual Answers
|
||||
</p>
|
||||
{#if $submissions[studentSelected]}
|
||||
{#if $questionnaire.questions}
|
||||
{#each $questionnaire.questions as q, i}
|
||||
<div class="pb-4">
|
||||
<p>{i + 1}. {q.title}</p>
|
||||
{#if q.question_type_id !== 3}
|
||||
{#each q.options as option, i}
|
||||
<div
|
||||
class="flex gap-2 max-w-[80%] items-center my-1 border border-gray-600 dark:border-gray-100 rounded-md p-3"
|
||||
>
|
||||
<input type="checkbox" name="" id="" checked={isSelected(option)} />
|
||||
<p>{option.label}</p>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if q.question_type_id === 3}
|
||||
<div
|
||||
class="border border-gray-300 rounded-md max-w-[80%] bg-slate-200 dark:bg-slate-500 p-2 my-1"
|
||||
>
|
||||
{#if $submissions[studentSelected].answers.map((ans) => ans.open_answer)}
|
||||
<p>
|
||||
{$submissions[studentSelected].answers.map((ans) => ans.open_answer)}
|
||||
</p>
|
||||
{:else}
|
||||
<p>No answer</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -0,0 +1,170 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import type { ExerciseSubmissions } from '$lib/utils/types';
|
||||
import { questionnaire } from '../../store/exercise';
|
||||
import { replaceHTMLTag } from '$lib/utils/functions/course';
|
||||
import type {
|
||||
BarChartOptions,
|
||||
PieChartOptions,
|
||||
PieChart,
|
||||
BarChartSimple
|
||||
} from '@carbon/charts-svelte';
|
||||
import { getChartOptions } from './functions';
|
||||
import { submissions } from './store';
|
||||
import '@carbon/charts-svelte/styles.css';
|
||||
|
||||
export let isLoading = true;
|
||||
|
||||
type TranformedQuestionChartData = {
|
||||
group: string;
|
||||
value: number;
|
||||
isCorrect?: boolean;
|
||||
};
|
||||
interface TranformedQuestion {
|
||||
title: string;
|
||||
type: number;
|
||||
chartData: TranformedQuestionChartData[];
|
||||
}
|
||||
|
||||
let transformedQuestions: TranformedQuestion[] = [];
|
||||
let barChart: typeof BarChartSimple;
|
||||
let pieChart: typeof PieChart;
|
||||
let pieOptions: PieChartOptions;
|
||||
let barOptions: BarChartOptions;
|
||||
|
||||
function getAnswerToQuestionOfStudent(
|
||||
questionId: number,
|
||||
isTextArea: boolean,
|
||||
submission: ExerciseSubmissions
|
||||
): string[] {
|
||||
let questionSubmission: string[] = [];
|
||||
|
||||
// find the answer by this student to this question (questionId)
|
||||
const studentAnswer = submission.answers.find((a) => a.question_id === questionId);
|
||||
|
||||
if (studentAnswer) {
|
||||
const { answers, open_answer } = studentAnswer;
|
||||
if (isTextArea) {
|
||||
questionSubmission = [open_answer];
|
||||
} else {
|
||||
questionSubmission = answers;
|
||||
}
|
||||
}
|
||||
|
||||
return questionSubmission;
|
||||
}
|
||||
|
||||
const getTransformedData = ($submissions: ExerciseSubmissions[]): void => {
|
||||
const _transformedQuestions: TranformedQuestion[] = [];
|
||||
|
||||
$questionnaire.questions.forEach(
|
||||
(question: {
|
||||
id: number;
|
||||
title: string;
|
||||
question_type_id: number;
|
||||
options: { value: string; is_correct: boolean; label: string }[];
|
||||
}) => {
|
||||
const transformedQuestion: {
|
||||
title: string;
|
||||
type: number;
|
||||
chartData: TranformedQuestionChartData[];
|
||||
} = {
|
||||
title: question.title,
|
||||
type: question.question_type_id,
|
||||
chartData: []
|
||||
};
|
||||
|
||||
// If textarea don't calculate the value just get the student's answer
|
||||
if (transformedQuestion.type === 3) {
|
||||
$submissions.forEach((submission) => {
|
||||
const chartData: TranformedQuestionChartData = {
|
||||
group: getAnswerToQuestionOfStudent(question.id, true, submission)[0],
|
||||
value: 0
|
||||
};
|
||||
|
||||
// Update the transformed question chartData
|
||||
transformedQuestion.chartData.push(chartData);
|
||||
});
|
||||
} else {
|
||||
// radio or checkbox
|
||||
question.options.forEach((option) => {
|
||||
const { value, is_correct, label } = option;
|
||||
const chartData: TranformedQuestionChartData = {
|
||||
group: replaceHTMLTag(label),
|
||||
value: 0,
|
||||
isCorrect: is_correct
|
||||
};
|
||||
|
||||
$submissions.forEach((studentSubmission) => {
|
||||
const studentAnswer = getAnswerToQuestionOfStudent(
|
||||
question.id,
|
||||
false,
|
||||
studentSubmission
|
||||
);
|
||||
|
||||
if (studentAnswer.includes(value)) {
|
||||
chartData.value += 1;
|
||||
}
|
||||
});
|
||||
transformedQuestion.chartData.push(chartData);
|
||||
});
|
||||
}
|
||||
|
||||
_transformedQuestions.push(transformedQuestion);
|
||||
}
|
||||
);
|
||||
|
||||
transformedQuestions = [..._transformedQuestions];
|
||||
|
||||
console.log({ transformedQuestions });
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
const charts = await import('@carbon/charts-svelte');
|
||||
barChart = charts.BarChartSimple;
|
||||
pieChart = charts.PieChart;
|
||||
});
|
||||
|
||||
$: browser && $submissions?.length && getTransformedData($submissions);
|
||||
|
||||
$: {
|
||||
let chartOptions = getChartOptions(isLoading);
|
||||
barOptions = chartOptions.barOptions;
|
||||
pieOptions = chartOptions.pieOptions;
|
||||
|
||||
console.log({ chartOptions });
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isLoading}
|
||||
Loading data
|
||||
{:else if browser}
|
||||
<div>
|
||||
<h1>Question Charts</h1>
|
||||
{#each transformedQuestions as q}
|
||||
<div>
|
||||
<h3>{q.title}</h3>
|
||||
{#if q.type === 1}
|
||||
<svelte:component this={pieChart} data={q.chartData} options={pieOptions} />
|
||||
{:else if q.type === 2}
|
||||
<svelte:component this={barChart} data={q.chartData} options={barOptions} />
|
||||
{:else}
|
||||
<div class="max-h-[200px] overflow-auto">
|
||||
<ul>
|
||||
{#each q.chartData as answer (answer)}
|
||||
{#if answer.group}
|
||||
<div
|
||||
class="border border-gray-300 rounded-md w-[80%] bg-slate-200 dark:bg-slate-500 p-2 my-1"
|
||||
>
|
||||
<li>{answer.group}</li>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { ScaleTypes, Alignments } from '@carbon/charts-svelte';
|
||||
import type { BarChartOptions, PieChartOptions } from '@carbon/charts-svelte';
|
||||
|
||||
export const pieOptions: PieChartOptions = {
|
||||
resizable: true,
|
||||
legend: {
|
||||
alignment: Alignments.CENTER
|
||||
},
|
||||
pie: {
|
||||
alignment: Alignments.CENTER
|
||||
},
|
||||
height: '400px'
|
||||
};
|
||||
|
||||
export const barOptions: BarChartOptions = {
|
||||
axes: {
|
||||
left: {
|
||||
mapsTo: 'value'
|
||||
},
|
||||
bottom: {
|
||||
mapsTo: 'group',
|
||||
scaleType: ScaleTypes['LABELS']
|
||||
}
|
||||
},
|
||||
height: '400px'
|
||||
};
|
||||
|
||||
export function getChartOptions(isLoading: boolean) {
|
||||
const barOptions: BarChartOptions = {
|
||||
axes: {
|
||||
left: {
|
||||
mapsTo: 'value'
|
||||
},
|
||||
bottom: {
|
||||
mapsTo: 'group',
|
||||
scaleType: ScaleTypes['LABELS']
|
||||
}
|
||||
},
|
||||
height: '400px'
|
||||
};
|
||||
|
||||
const pieOptions: PieChartOptions = {
|
||||
resizable: true,
|
||||
legend: {
|
||||
alignment: Alignments.CENTER
|
||||
},
|
||||
pie: {
|
||||
alignment: Alignments.CENTER
|
||||
},
|
||||
height: '400px'
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
barOptions.data = {
|
||||
loading: true
|
||||
};
|
||||
pieOptions.data = {
|
||||
loading: true
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
barOptions,
|
||||
pieOptions
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { fetchSubmission } from '$lib/utils/services/submissions';
|
||||
import { browser } from '$app/environment';
|
||||
import Summary from './Summary.svelte';
|
||||
import Individual from './Individual.svelte';
|
||||
import { submissions } from './store';
|
||||
|
||||
export let exerciseId: string;
|
||||
|
||||
const tabs = [
|
||||
{ label: 'Summary', value: 'summary' },
|
||||
{ label: 'Individual', value: 'individual' }
|
||||
];
|
||||
let currentTab = tabs[0].value;
|
||||
|
||||
let isLoading = true;
|
||||
|
||||
const onChange = (tabValue: string) => () => (currentTab = tabValue);
|
||||
|
||||
async function fetchSubmissions(id: string | undefined) {
|
||||
if (!id) return;
|
||||
isLoading = true;
|
||||
|
||||
const { data } = await fetchSubmission({
|
||||
exerciseId: id
|
||||
});
|
||||
|
||||
if (!data) return;
|
||||
|
||||
submissions.set(data);
|
||||
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
$: browser && fetchSubmissions(exerciseId);
|
||||
</script>
|
||||
|
||||
<div class="w-full flex flex-col">
|
||||
<div class="border rounded-md mb-2 px-4">
|
||||
<div class="mt-4">
|
||||
<p class="text-lg">{$submissions.length} Submissions</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-center overflow-x-auto">
|
||||
{#each tabs as tab}
|
||||
<button
|
||||
class="relative {currentTab === tab.value
|
||||
? 'text-primary-700'
|
||||
: 'dark:bg-gray-500 dark:text-white'} dark:bg-transparent font-semibold focus:outline-none w-fit mr-4 text-center py-3 px-2"
|
||||
on:click={onChange(tab.value)}
|
||||
>
|
||||
<div class="flex items-center justify-center w-full text-center">
|
||||
{tab.label}
|
||||
</div>
|
||||
<span
|
||||
class="absolute bottom-0 left-0 h-[2px] bg-primary-700 transition-all ease-in-out duration-500 {currentTab ===
|
||||
tab.value
|
||||
? 'w-full'
|
||||
: 'w-0'}"
|
||||
/>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{#if currentTab === tabs[0].value}
|
||||
<Summary bind:isLoading />
|
||||
{:else}
|
||||
<Individual bind:isLoading />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,4 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type { ExerciseSubmissions } from '$lib/utils/types';
|
||||
|
||||
export const submissions = writable<ExerciseSubmissions[]>([]);
|
||||
@@ -10,6 +10,14 @@
|
||||
reset,
|
||||
questionnaireOrder
|
||||
} from '../store/exercise';
|
||||
import {
|
||||
ContentSwitcher,
|
||||
Switch,
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
OverflowMenu,
|
||||
OverflowMenuItem
|
||||
} from 'carbon-components-svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import PageBody from '$lib/components/PageBody/index.svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
@@ -20,11 +28,10 @@
|
||||
import { upsertExercise } from '$lib/utils/services/courses';
|
||||
import UpdateDescription from './UpdateDescription.svelte';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { OverflowMenu, OverflowMenuItem } from 'carbon-components-svelte';
|
||||
import Analytics from './Analytics/index.svelte';
|
||||
import { ContentSwitcher, Switch } from 'carbon-components-svelte';
|
||||
import Analytics from './Submissions/index.svelte';
|
||||
|
||||
export let exerciseId = '';
|
||||
export let path = '';
|
||||
export let goBack = () => {};
|
||||
export let isStudent = true;
|
||||
|
||||
@@ -65,60 +72,71 @@
|
||||
reset();
|
||||
});
|
||||
|
||||
$: $questionnaire.questions.length < 1 && handleAddQuestion();
|
||||
$: $questionnaire?.questions?.length < 1 && handleAddQuestion();
|
||||
console.log('question', $questionnaire.questions);
|
||||
</script>
|
||||
|
||||
<PageBody bind:isPageNavHidden={isStudent} padding="px-4 overflow-x-hidden">
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<ContentSwitcher bind:selectedIndex class="mb-2">
|
||||
<Switch text="Questions" />
|
||||
<Switch text="Analytics" />
|
||||
</ContentSwitcher>
|
||||
</RoleBasedSecurity>
|
||||
<div class="bg-gray-100 dark:bg-neutral-800 top-0 z-10 sticky p-2 mb-3">
|
||||
<Breadcrumb noTrailingSlash class="mb-5">
|
||||
<BreadcrumbItem href={path}>All Exercises</BreadcrumbItem>
|
||||
<BreadcrumbItem href={`${path}${exerciseId}`} isCurrentPage>
|
||||
{$questionnaire.title}
|
||||
</BreadcrumbItem>
|
||||
</Breadcrumb>
|
||||
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<ContentSwitcher bind:selectedIndex class="mb-2">
|
||||
<Switch text="Questions ({$questionnaire.questions.length})" />
|
||||
<Switch text="Submissions ({$questionnaire.totalSubmissions})" />
|
||||
</ContentSwitcher>
|
||||
|
||||
{#if selectedIndex === 0}
|
||||
<div class="flex items-center justify-end right-0 w-full">
|
||||
<div class="flex items-center">
|
||||
<PrimaryButton
|
||||
className="mr-2"
|
||||
variant={VARIANTS.CONTAINED}
|
||||
label="Save"
|
||||
onClick={handleSave}
|
||||
isLoading={isSaving}
|
||||
/>
|
||||
<IconButton
|
||||
onClick={() => (preview = !preview)}
|
||||
contained={preview}
|
||||
toolTipProps={{
|
||||
title: 'Preview',
|
||||
direction: 'bottom',
|
||||
hotkeys: []
|
||||
}}
|
||||
>
|
||||
{#if preview}
|
||||
<ViewFilledIcon size={24} class="carbon-icon dark:text-white" />
|
||||
{:else}
|
||||
<ViewIcon size={24} class="carbon-icon dark:text-white" />
|
||||
{/if}
|
||||
</IconButton>
|
||||
<IconButton onClick={() => handleAddQuestion()} size="small">
|
||||
<AddAltIcon size={24} class="carbon-icon dark:text-white" />
|
||||
</IconButton>
|
||||
<OverflowMenu flipped>
|
||||
<OverflowMenuItem
|
||||
text="Reorder Questions"
|
||||
on:click={() => ($questionnaireOrder.open = true)}
|
||||
/>
|
||||
<OverflowMenuItem
|
||||
danger
|
||||
text="Delete Exercise"
|
||||
on:click={() => (shouldDeleteExercise = true)}
|
||||
/>
|
||||
</OverflowMenu>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</RoleBasedSecurity>
|
||||
</div>
|
||||
|
||||
{#if selectedIndex === 0}
|
||||
<div class="top-0 z-10 mb-3 flex items-center justify-end sticky right-0 w-full">
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<div class="flex items-center bg-gray-100 dark:bg-neutral-800 rounded-md pl-3">
|
||||
<PrimaryButton
|
||||
className="mr-2"
|
||||
variant={VARIANTS.CONTAINED}
|
||||
label="Save"
|
||||
onClick={handleSave}
|
||||
isLoading={isSaving}
|
||||
/>
|
||||
<IconButton
|
||||
onClick={() => (preview = !preview)}
|
||||
contained={preview}
|
||||
toolTipProps={{
|
||||
title: 'Preview',
|
||||
direction: 'bottom',
|
||||
hotkeys: []
|
||||
}}
|
||||
>
|
||||
{#if preview}
|
||||
<ViewFilledIcon size={24} class="carbon-icon dark:text-white" />
|
||||
{:else}
|
||||
<ViewIcon size={24} class="carbon-icon dark:text-white" />
|
||||
{/if}
|
||||
</IconButton>
|
||||
<IconButton onClick={() => handleAddQuestion()} size="small">
|
||||
<AddAltIcon size={24} class="carbon-icon dark:text-white" />
|
||||
</IconButton>
|
||||
<OverflowMenu flipped>
|
||||
<OverflowMenuItem
|
||||
text="Reorder Questions"
|
||||
on:click={() => ($questionnaireOrder.open = true)}
|
||||
/>
|
||||
<OverflowMenuItem
|
||||
danger
|
||||
text="Delete Exercise"
|
||||
on:click={() => (shouldDeleteExercise = true)}
|
||||
/>
|
||||
</OverflowMenu>
|
||||
</div>
|
||||
</RoleBasedSecurity>
|
||||
</div>
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<UpdateDescription {preview} />
|
||||
</RoleBasedSecurity>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import Box from '$lib/components/Box/index.svelte';
|
||||
import { Breadcrumb, BreadcrumbItem } from 'carbon-components-svelte';
|
||||
import { Moon } from 'svelte-loading-spinners';
|
||||
import Backdrop from '$lib/components/Backdrop/index.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
@@ -89,6 +90,7 @@
|
||||
.select(
|
||||
`
|
||||
id, title, description, due_by,
|
||||
totalSubmissions:submission(count),
|
||||
questions:question(
|
||||
*,
|
||||
options:option(*),
|
||||
@@ -114,15 +116,16 @@
|
||||
data.questions = data.questions.sort((a, b) => a.order - b.order);
|
||||
} else if (data) {
|
||||
data.questions = [];
|
||||
} else {
|
||||
data = {};
|
||||
}
|
||||
|
||||
data.is_title_dirty = false;
|
||||
data.is_description_dirty = false;
|
||||
data.is_due_by_dirty = false;
|
||||
|
||||
questionnaire.set(data);
|
||||
questionnaire.set({
|
||||
...(data || {}),
|
||||
is_title_dirty: false,
|
||||
is_description_dirty: false,
|
||||
is_due_by_dirty: false,
|
||||
questions: Array.isArray(data.questions) ? data.questions : [],
|
||||
totalSubmissions: data?.totalSubmissions?.[0]?.count || 0
|
||||
});
|
||||
|
||||
isQuestionnaireFetching.update(() => false);
|
||||
isFetching = false;
|
||||
@@ -146,7 +149,7 @@
|
||||
{/if}
|
||||
|
||||
{#if exerciseId}
|
||||
<Exercise {exerciseId} {goBack} bind:isStudent />
|
||||
<Exercise {exerciseId} {goBack} bind:path bind:isStudent />
|
||||
{:else}
|
||||
<NewExerciseModal
|
||||
bind:open
|
||||
@@ -158,12 +161,15 @@
|
||||
|
||||
<PageBody bind:isPageNavHidden={isStudent}>
|
||||
<slot:fragment slot="header">
|
||||
<Breadcrumb class="my-2">
|
||||
<BreadcrumbItem href={path}>All Exercises</BreadcrumbItem>
|
||||
</Breadcrumb>
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<PrimaryButton className="mr-2 mb-2" label="Add" onClick={() => (open = !open)} />
|
||||
<PrimaryButton className="mr-2 my-2" label="Add" onClick={() => (open = !open)} />
|
||||
</RoleBasedSecurity>
|
||||
</slot:fragment>
|
||||
|
||||
<div class="flex flex-wrap">
|
||||
<div class="flex flex-wrap mt-5">
|
||||
{#each $lesson.exercises as exercise}
|
||||
<a
|
||||
class="w-52 bg-gray-100 dark:bg-neutral-800 px-4 py-7 mr-4 mb-4 rounded-lg"
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import {
|
||||
QUESTION_TEMPLATE,
|
||||
QUESTION_TYPE,
|
||||
} from '$lib/components/Question/constants';
|
||||
import { QUESTION_TEMPLATE, QUESTION_TYPE } from '$lib/components/Question/constants';
|
||||
import { questionnaireMetaData } from './answers';
|
||||
import { isUUID } from '$lib/utils/functions/isUUID';
|
||||
|
||||
@@ -21,6 +18,7 @@ export const questionnaire = writable({
|
||||
description: '',
|
||||
is_description_dirty: false,
|
||||
questions: [],
|
||||
totalSubmissions: 0
|
||||
});
|
||||
|
||||
export function reset() {
|
||||
@@ -36,10 +34,7 @@ export function validateQuestionnaire(questions) {
|
||||
const errors = {};
|
||||
|
||||
for (const question of questions) {
|
||||
if (
|
||||
question.question_type.id === QUESTION_TYPE.TEXTAREA ||
|
||||
question.deleted_at
|
||||
) {
|
||||
if (question.question_type.id === QUESTION_TYPE.TEXTAREA || question.deleted_at) {
|
||||
continue;
|
||||
}
|
||||
const qErrors = errors[question.id] || {};
|
||||
@@ -74,18 +69,18 @@ export function handleAddQuestion() {
|
||||
points: 0,
|
||||
order: questions.length,
|
||||
question_type: {
|
||||
id: QUESTION_TYPE.RADIO,
|
||||
id: QUESTION_TYPE.RADIO
|
||||
},
|
||||
options: [
|
||||
{
|
||||
id: '1-form',
|
||||
label: '',
|
||||
value: null,
|
||||
is_correct: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
is_correct: false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -106,14 +101,14 @@ export function handleAddOption(questionId) {
|
||||
id: `${question.options.length + 1}-form`,
|
||||
label: '',
|
||||
value: '',
|
||||
is_correct: false,
|
||||
},
|
||||
],
|
||||
is_correct: false
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
return question;
|
||||
}),
|
||||
})
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -135,12 +130,12 @@ export function handleRemoveOption(questionId, optionId) {
|
||||
option.deleted_at = new Date();
|
||||
}
|
||||
return option;
|
||||
}),
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
return question;
|
||||
}),
|
||||
})
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -158,7 +153,7 @@ export function handleRemoveQuestion(questionId) {
|
||||
q.deleted_at = new Date();
|
||||
}
|
||||
return q;
|
||||
}),
|
||||
})
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -174,12 +169,12 @@ export function handleCode(questionId, shouldAdd = true) {
|
||||
if (question.id === questionId) {
|
||||
return {
|
||||
...question,
|
||||
code: shouldAdd ? question.code || '' : undefined,
|
||||
code: shouldAdd ? question.code || '' : undefined
|
||||
};
|
||||
}
|
||||
|
||||
return question;
|
||||
}),
|
||||
})
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -201,12 +196,12 @@ export function handleAnswerSelect(questionId, optionId) {
|
||||
option.is_dirty = true;
|
||||
}
|
||||
return option;
|
||||
}),
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
return question;
|
||||
}),
|
||||
})
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -236,12 +231,12 @@ export function addDynamicValue(questionId, optionId) {
|
||||
}
|
||||
|
||||
return option;
|
||||
}),
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
return question;
|
||||
}),
|
||||
})
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
|
||||
const updates = {
|
||||
fullname: $profile.fullname,
|
||||
username: $profile.username
|
||||
username: $profile.username,
|
||||
email: $profile.email
|
||||
};
|
||||
|
||||
if (avatar) {
|
||||
|
||||
@@ -26,3 +26,16 @@ export const getTextFromHTML = (html: string): string => {
|
||||
|
||||
return dummyDiv.textContent?.trim() || '';
|
||||
};
|
||||
|
||||
const tagsToReplace = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>'
|
||||
};
|
||||
|
||||
export function replaceHTMLTag(text: string) {
|
||||
return text
|
||||
.split('')
|
||||
.map((char) => tagsToReplace[char] || char)
|
||||
.join('');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user