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": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.386.0",
|
"@aws-sdk/client-s3": "^3.386.0",
|
||||||
|
"@carbon/charts-svelte": "^1.13.6",
|
||||||
"@sendgrid/mail": "^7.7.0",
|
"@sendgrid/mail": "^7.7.0",
|
||||||
"@sentry/browser": "^7.61.0",
|
"@sentry/browser": "^7.61.0",
|
||||||
"@sentry/integrations": "^7.61.0",
|
"@sentry/integrations": "^7.61.0",
|
||||||
@@ -32,6 +33,9 @@
|
|||||||
"canvas-confetti": "^1.6.0",
|
"canvas-confetti": "^1.6.0",
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"copy-to-clipboard": "^3.3.3",
|
"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",
|
"form-data": "^4.0.0",
|
||||||
"hotkeys-js": "^3.11.2",
|
"hotkeys-js": "^3.11.2",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
let comments: LessonComment[] = [];
|
let comments: LessonComment[] = [];
|
||||||
let groupmember: GroupPerson | undefined;
|
let groupmember: GroupPerson | undefined;
|
||||||
let isSaving: boolean = false;
|
let isSaving: boolean = false;
|
||||||
|
let isFetched = false;
|
||||||
|
|
||||||
interface FetchComments {
|
interface FetchComments {
|
||||||
comment: string;
|
comment: string;
|
||||||
@@ -155,6 +156,7 @@
|
|||||||
console.error('error');
|
console.error('error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
console.log('fetch comments', data);
|
||||||
|
|
||||||
comments = data.map((lessonComment) => {
|
comments = data.map((lessonComment) => {
|
||||||
return {
|
return {
|
||||||
@@ -167,6 +169,7 @@
|
|||||||
: lessonComment.groupmember.profile.fullname
|
: lessonComment.groupmember.profile.fullname
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
isFetched = true;
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +188,7 @@
|
|||||||
supabase.removeChannel(lessonCommentChannel);
|
supabase.removeChannel(lessonCommentChannel);
|
||||||
});
|
});
|
||||||
|
|
||||||
$: fetchComments($group.people, $lesson.id);
|
$: !isFetched && fetchComments($group.people, $lesson.id);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageNav title="Comments" overidableStyle="padding: 0 10px">
|
<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}
|
{#if !!$apps.selectedApp}
|
||||||
<div
|
<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}
|
bind:this={appContentRef}
|
||||||
>
|
>
|
||||||
{#key $apps.selectedApp}
|
{#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,
|
reset,
|
||||||
questionnaireOrder
|
questionnaireOrder
|
||||||
} from '../store/exercise';
|
} from '../store/exercise';
|
||||||
|
import {
|
||||||
|
ContentSwitcher,
|
||||||
|
Switch,
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
OverflowMenu,
|
||||||
|
OverflowMenuItem
|
||||||
|
} from 'carbon-components-svelte';
|
||||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||||
import PageBody from '$lib/components/PageBody/index.svelte';
|
import PageBody from '$lib/components/PageBody/index.svelte';
|
||||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||||
@@ -20,11 +28,10 @@
|
|||||||
import { upsertExercise } from '$lib/utils/services/courses';
|
import { upsertExercise } from '$lib/utils/services/courses';
|
||||||
import UpdateDescription from './UpdateDescription.svelte';
|
import UpdateDescription from './UpdateDescription.svelte';
|
||||||
import { snackbar } from '$lib/components/Snackbar/store';
|
import { snackbar } from '$lib/components/Snackbar/store';
|
||||||
import { OverflowMenu, OverflowMenuItem } from 'carbon-components-svelte';
|
import Analytics from './Submissions/index.svelte';
|
||||||
import Analytics from './Analytics/index.svelte';
|
|
||||||
import { ContentSwitcher, Switch } from 'carbon-components-svelte';
|
|
||||||
|
|
||||||
export let exerciseId = '';
|
export let exerciseId = '';
|
||||||
|
export let path = '';
|
||||||
export let goBack = () => {};
|
export let goBack = () => {};
|
||||||
export let isStudent = true;
|
export let isStudent = true;
|
||||||
|
|
||||||
@@ -65,60 +72,71 @@
|
|||||||
reset();
|
reset();
|
||||||
});
|
});
|
||||||
|
|
||||||
$: $questionnaire.questions.length < 1 && handleAddQuestion();
|
$: $questionnaire?.questions?.length < 1 && handleAddQuestion();
|
||||||
|
console.log('question', $questionnaire.questions);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageBody bind:isPageNavHidden={isStudent} padding="px-4 overflow-x-hidden">
|
<PageBody bind:isPageNavHidden={isStudent} padding="px-4 overflow-x-hidden">
|
||||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
<div class="bg-gray-100 dark:bg-neutral-800 top-0 z-10 sticky p-2 mb-3">
|
||||||
<ContentSwitcher bind:selectedIndex class="mb-2">
|
<Breadcrumb noTrailingSlash class="mb-5">
|
||||||
<Switch text="Questions" />
|
<BreadcrumbItem href={path}>All Exercises</BreadcrumbItem>
|
||||||
<Switch text="Analytics" />
|
<BreadcrumbItem href={`${path}${exerciseId}`} isCurrentPage>
|
||||||
</ContentSwitcher>
|
{$questionnaire.title}
|
||||||
</RoleBasedSecurity>
|
</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}
|
{#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]}>
|
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||||
<UpdateDescription {preview} />
|
<UpdateDescription {preview} />
|
||||||
</RoleBasedSecurity>
|
</RoleBasedSecurity>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import Box from '$lib/components/Box/index.svelte';
|
import Box from '$lib/components/Box/index.svelte';
|
||||||
|
import { Breadcrumb, BreadcrumbItem } from 'carbon-components-svelte';
|
||||||
import { Moon } from 'svelte-loading-spinners';
|
import { Moon } from 'svelte-loading-spinners';
|
||||||
import Backdrop from '$lib/components/Backdrop/index.svelte';
|
import Backdrop from '$lib/components/Backdrop/index.svelte';
|
||||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||||
@@ -89,6 +90,7 @@
|
|||||||
.select(
|
.select(
|
||||||
`
|
`
|
||||||
id, title, description, due_by,
|
id, title, description, due_by,
|
||||||
|
totalSubmissions:submission(count),
|
||||||
questions:question(
|
questions:question(
|
||||||
*,
|
*,
|
||||||
options:option(*),
|
options:option(*),
|
||||||
@@ -114,15 +116,16 @@
|
|||||||
data.questions = data.questions.sort((a, b) => a.order - b.order);
|
data.questions = data.questions.sort((a, b) => a.order - b.order);
|
||||||
} else if (data) {
|
} else if (data) {
|
||||||
data.questions = [];
|
data.questions = [];
|
||||||
} else {
|
|
||||||
data = {};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data.is_title_dirty = false;
|
questionnaire.set({
|
||||||
data.is_description_dirty = false;
|
...(data || {}),
|
||||||
data.is_due_by_dirty = false;
|
is_title_dirty: false,
|
||||||
|
is_description_dirty: false,
|
||||||
questionnaire.set(data);
|
is_due_by_dirty: false,
|
||||||
|
questions: Array.isArray(data.questions) ? data.questions : [],
|
||||||
|
totalSubmissions: data?.totalSubmissions?.[0]?.count || 0
|
||||||
|
});
|
||||||
|
|
||||||
isQuestionnaireFetching.update(() => false);
|
isQuestionnaireFetching.update(() => false);
|
||||||
isFetching = false;
|
isFetching = false;
|
||||||
@@ -146,7 +149,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if exerciseId}
|
{#if exerciseId}
|
||||||
<Exercise {exerciseId} {goBack} bind:isStudent />
|
<Exercise {exerciseId} {goBack} bind:path bind:isStudent />
|
||||||
{:else}
|
{:else}
|
||||||
<NewExerciseModal
|
<NewExerciseModal
|
||||||
bind:open
|
bind:open
|
||||||
@@ -158,12 +161,15 @@
|
|||||||
|
|
||||||
<PageBody bind:isPageNavHidden={isStudent}>
|
<PageBody bind:isPageNavHidden={isStudent}>
|
||||||
<slot:fragment slot="header">
|
<slot:fragment slot="header">
|
||||||
|
<Breadcrumb class="my-2">
|
||||||
|
<BreadcrumbItem href={path}>All Exercises</BreadcrumbItem>
|
||||||
|
</Breadcrumb>
|
||||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
<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>
|
</RoleBasedSecurity>
|
||||||
</slot:fragment>
|
</slot:fragment>
|
||||||
|
|
||||||
<div class="flex flex-wrap">
|
<div class="flex flex-wrap mt-5">
|
||||||
{#each $lesson.exercises as exercise}
|
{#each $lesson.exercises as exercise}
|
||||||
<a
|
<a
|
||||||
class="w-52 bg-gray-100 dark:bg-neutral-800 px-4 py-7 mr-4 mb-4 rounded-lg"
|
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 { writable } from 'svelte/store';
|
||||||
import {
|
import { QUESTION_TEMPLATE, QUESTION_TYPE } from '$lib/components/Question/constants';
|
||||||
QUESTION_TEMPLATE,
|
|
||||||
QUESTION_TYPE,
|
|
||||||
} from '$lib/components/Question/constants';
|
|
||||||
import { questionnaireMetaData } from './answers';
|
import { questionnaireMetaData } from './answers';
|
||||||
import { isUUID } from '$lib/utils/functions/isUUID';
|
import { isUUID } from '$lib/utils/functions/isUUID';
|
||||||
|
|
||||||
@@ -21,6 +18,7 @@ export const questionnaire = writable({
|
|||||||
description: '',
|
description: '',
|
||||||
is_description_dirty: false,
|
is_description_dirty: false,
|
||||||
questions: [],
|
questions: [],
|
||||||
|
totalSubmissions: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
export function reset() {
|
export function reset() {
|
||||||
@@ -36,10 +34,7 @@ export function validateQuestionnaire(questions) {
|
|||||||
const errors = {};
|
const errors = {};
|
||||||
|
|
||||||
for (const question of questions) {
|
for (const question of questions) {
|
||||||
if (
|
if (question.question_type.id === QUESTION_TYPE.TEXTAREA || question.deleted_at) {
|
||||||
question.question_type.id === QUESTION_TYPE.TEXTAREA ||
|
|
||||||
question.deleted_at
|
|
||||||
) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const qErrors = errors[question.id] || {};
|
const qErrors = errors[question.id] || {};
|
||||||
@@ -74,18 +69,18 @@ export function handleAddQuestion() {
|
|||||||
points: 0,
|
points: 0,
|
||||||
order: questions.length,
|
order: questions.length,
|
||||||
question_type: {
|
question_type: {
|
||||||
id: QUESTION_TYPE.RADIO,
|
id: QUESTION_TYPE.RADIO
|
||||||
},
|
},
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
id: '1-form',
|
id: '1-form',
|
||||||
label: '',
|
label: '',
|
||||||
value: null,
|
value: null,
|
||||||
is_correct: false,
|
is_correct: false
|
||||||
},
|
}
|
||||||
],
|
]
|
||||||
},
|
}
|
||||||
],
|
]
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -106,14 +101,14 @@ export function handleAddOption(questionId) {
|
|||||||
id: `${question.options.length + 1}-form`,
|
id: `${question.options.length + 1}-form`,
|
||||||
label: '',
|
label: '',
|
||||||
value: '',
|
value: '',
|
||||||
is_correct: false,
|
is_correct: false
|
||||||
},
|
}
|
||||||
],
|
]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return question;
|
return question;
|
||||||
}),
|
})
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -135,12 +130,12 @@ export function handleRemoveOption(questionId, optionId) {
|
|||||||
option.deleted_at = new Date();
|
option.deleted_at = new Date();
|
||||||
}
|
}
|
||||||
return option;
|
return option;
|
||||||
}),
|
})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return question;
|
return question;
|
||||||
}),
|
})
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -158,7 +153,7 @@ export function handleRemoveQuestion(questionId) {
|
|||||||
q.deleted_at = new Date();
|
q.deleted_at = new Date();
|
||||||
}
|
}
|
||||||
return q;
|
return q;
|
||||||
}),
|
})
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -174,12 +169,12 @@ export function handleCode(questionId, shouldAdd = true) {
|
|||||||
if (question.id === questionId) {
|
if (question.id === questionId) {
|
||||||
return {
|
return {
|
||||||
...question,
|
...question,
|
||||||
code: shouldAdd ? question.code || '' : undefined,
|
code: shouldAdd ? question.code || '' : undefined
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return question;
|
return question;
|
||||||
}),
|
})
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -201,12 +196,12 @@ export function handleAnswerSelect(questionId, optionId) {
|
|||||||
option.is_dirty = true;
|
option.is_dirty = true;
|
||||||
}
|
}
|
||||||
return option;
|
return option;
|
||||||
}),
|
})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return question;
|
return question;
|
||||||
}),
|
})
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -236,12 +231,12 @@ export function addDynamicValue(questionId, optionId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return option;
|
return option;
|
||||||
}),
|
})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return question;
|
return question;
|
||||||
}),
|
})
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,7 +25,8 @@
|
|||||||
|
|
||||||
const updates = {
|
const updates = {
|
||||||
fullname: $profile.fullname,
|
fullname: $profile.fullname,
|
||||||
username: $profile.username
|
username: $profile.username,
|
||||||
|
email: $profile.email
|
||||||
};
|
};
|
||||||
|
|
||||||
if (avatar) {
|
if (avatar) {
|
||||||
|
|||||||
@@ -26,3 +26,16 @@ export const getTextFromHTML = (html: string): string => {
|
|||||||
|
|
||||||
return dummyDiv.textContent?.trim() || '';
|
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