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:
emmanuel
2023-10-21 07:44:57 +00:00
committed by Space Cloud
co-authored by Rotimi Best Rotimi Best
parent b21d5923e6
commit 5c8571e571
15 changed files with 611 additions and 123 deletions
+4
View File
@@ -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
});
}
+1 -1
View File
@@ -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) {
+13
View File
@@ -26,3 +26,16 @@ export const getTextFromHTML = (html: string): string => {
return dummyDiv.textContent?.trim() || '';
};
const tagsToReplace = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;'
};
export function replaceHTMLTag(text: string) {
return text
.split('')
.map((char) => tagsToReplace[char] || char)
.join('');
}