skillbox/client/src/components/content-blocks/assignment/Assignment.vue

491 lines
12 KiB
Vue

<template>
<!-- eslint-disable vue/no-v-html -->
<div
:data-scrollto="value.id"
class="assignment"
ref="assignmentDiv"
>
<div
class="assignment__main-text assignment-text"
data-cy="assignment-main-text"
v-if="assignment.assignment > ''"
v-html="assignmentHtml"
/>
<assignment-solution
:value="solution"
v-if="assignment.solution"
/>
<template v-if="isStudent">
<submission-form
:user-input="submission"
:spellcheck-loading="spellcheckLoading"
:saved="!unsaved"
:spellcheck="true"
:read-only="me.readOnly || me.selectedClass?.readOnly"
placeholder="Ergebnis erfassen"
action="Ergebnis mit Lehrperson teilen"
shared-msg="Das Ergebnis wurde mit der Lehrperson geteilt."
v-if="isStudent"
@turnIn="turnIn"
@saveInput="saveInput"
@reopen="reopen"
@changeDocumentUrl="changeDocumentUrl"
@spellcheck="spellcheck"
/>
<spell-check
:corrections="corrections"
:text="submission.text"
/>
<p
class="assignment__feedback"
v-if="assignment.submission?.submissionFeedback"
v-html="feedbackText"
/>
</template>
<template v-if="!isStudent && assignment.id">
<router-link
:to="{ name: 'submissions', params: { id: assignment.id } }"
class="button button--primary"
>
Zu den Ergebnissen
</router-link>
</template>
</div>
</template>
<script setup lang="ts">
import { defineAsyncComponent, nextTick, onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useMutation, useQuery } from '@vue/apollo-composable';
import { graphql } from '@/__generated__';
import { HighlightNode } from '@/__generated__/graphql';
import { computed } from '@vue/reactivity';
import { markHighlight } from '@/helpers/highlight';
import { sanitize } from '@/helpers/text';
import { matomoTrackEvent } from '@/helpers/matomo-client';
import { getMe } from '@/graphql/queries';
import { PAGE_LOAD_TIMEOUT } from '@/consts/navigation.consts';
import debounce from 'lodash/debounce';
import Mark from 'mark.js';
export interface Props {
value: any;
highlights: HighlightNode[];
}
const SubmissionForm = defineAsyncComponent(() => import('@/components/content-blocks/assignment/SubmissionForm.vue'));
const AssignmentSolution = defineAsyncComponent(() => import('@/components/content-blocks/Solution.vue'));
const SpellCheck = defineAsyncComponent(() => import('@/components/content-blocks/assignment/SpellCheck.vue'));
const route = useRoute();
const assignmentDiv = ref<HTMLElement | null>(null);
const props = defineProps<Props>();
const initialSubmission = {
text: '',
document: '',
final: false,
};
const assignment = ref({ submission: initialSubmission });
const corrections = ref('');
const unsaved = ref(false);
const saving = ref(0);
const spellcheckLoading = ref(false);
graphql(`
fragment SubmissionParts on StudentSubmissionNode {
id
text
final
document
submissionFeedback {
id
text
teacher {
firstName
lastName
}
}
}
`);
const assignmentFragment = graphql(`
fragment AssignmentParts on AssignmentNode {
id
title
assignment
solution
submission {
...SubmissionParts
}
}
`);
const { me } = getMe();
const { result, onResult } = useQuery(
graphql(`
query AssignmentQuery($id: ID!) {
assignment(id: $id) {
...AssignmentParts
}
}
`),
{ id: props.value.id }
);
const { mutate: doUpdateAssignment } = useMutation(
graphql(`
mutation UpdateAssignment($input: UpdateAssignmentInput!) {
updateAssignment(input: $input) {
updatedAssignment {
...AssignmentParts
}
}
}
`)
);
const { mutate: doUpdateAssignmentWithSuccess } = useMutation(
graphql(`
mutation UpdateAssignmentWithSuccess($input: UpdateAssignmentInput!) {
updateAssignment(input: $input) {
successful
updatedAssignment {
...AssignmentParts
}
}
}
`)
);
const { mutate: doSpellCheck } = useMutation(
graphql(`
mutation SpellCheck($input: SpellCheckInput!) {
spellCheck(input: $input) {
correct
results {
sentence
offset
sentenceOffset
length
affected
corrected
}
}
}
`)
);
onMounted(() => {
if (assignmentDiv.value !== null) {
if (route.hash === `#${props.value.id}`) {
setTimeout(() => {
const rect = assignmentDiv.value.getBoundingClientRect();
window.scrollTo({
top: rect.y,
behavior: 'smooth',
});
}, PAGE_LOAD_TIMEOUT);
}
}
});
const submission = computed(() => {
return assignment.value?.submission || {};
});
const isStudent = computed(() => {
return !me.value.isTeacher;
});
const solution = computed(() => {
return {
text: assignment.value.solution,
};
});
const feedbackText = computed(() => {
let feedback = assignment.value.submission.submissionFeedback;
let sanitizedFeedbackText = sanitize(feedback.text);
return `<span class="inline-title">Feedback von ${feedback.teacher.firstName} ${feedback.teacher.lastName}:</span> ${sanitizedFeedbackText}`;
});
const containsParagraphs = (str: string) => {
const parser = new DOMParser();
const doc = parser.parseFromString(str, 'text/html');
return doc.querySelectorAll('p').length > 0;
};
const assignmentHtml = computed(() => {
const assignmentText = assignment.value.assignment;
if (containsParagraphs(assignmentText)) {
return assignmentText;
}
// text needs to be inside a paragraph, otherwise the highlighting will fail
return `<p>${assignmentText}</p>`;
});
const childElements = computed(() => {
// todo: refactor and merge with the one in ContentComponent.vue
if (assignmentDiv.value) {
const highlightParentSelector = '.assignment-text';
const parent = assignmentDiv.value.querySelector(highlightParentSelector);
if (!parent) {
console.warn('Parent does not exist, this should not be possible');
// or can it be, if the children did not load yet, e.g. with a dynamic component?
return [];
}
const elements = Array.from(parent.children).reduce((acc: Element[], current: Element) => {
if (current.tagName.toLowerCase() === 'ul') {
return [...acc, ...current.children];
}
return [...acc, current];
}, []);
return elements as HTMLElement[];
}
return [];
});
const markHighlights = () => {
for (const highlight of props.highlights) {
const element = childElements.value[highlight.paragraphIndex];
markHighlight(highlight, element, assignmentDiv.value as HTMLElement);
}
};
const unmark = () => {
// todo: refactor, this is used in multiple files
for (const paragraph of childElements.value) {
const instance = new Mark(paragraph);
instance.unmark();
}
};
onResult(async () => {
const { assignment: loadedAssignment } = result.value;
assignment.value = {
...loadedAssignment,
};
await nextTick();
assignment.value.submission = Object.assign(initialSubmission, assignment.value.submission);
if (assignmentDiv.value) {
await nextTick();
unmark();
markHighlights();
}
});
watch(
() => props.highlights.map((h) => h.color),
() => {
unmark();
markHighlights();
}
);
const turnIn = () => {
corrections.value = '';
const variables = {
input: {
assignment: {
id: assignment.value.id,
answer: assignment.value.submission.text,
document: assignment.value.submission.document,
final: true,
},
},
};
doUpdateAssignment(variables, {
update: (
_cache,
{
data: {
updateAssignment: {
updatedAssignment: { submission },
},
},
}
) => {
assignment.value.submission = submission;
},
});
matomoTrackEvent('Auftrag', 'Ergebnis mit Lehrperson geteilt', assignment.value.title);
};
const reopen = () => {
const variables = {
input: {
assignment: {
id: assignment.value.id,
answer: assignment.value.submission.text,
document: assignment.value.submission.document,
final: false,
},
},
};
doUpdateAssignment(variables, {
update: (
_cache,
{
data: {
updateAssignment: {
updatedAssignment: { submission },
},
},
}
) => {
assignment.value.submission = submission;
},
});
};
const _save = debounce(function (submission) {
saving.value++;
const variables = {
input: {
assignment: {
id: assignment.value.id,
answer: assignment.value.submission.text,
document: assignment.value.submission.document,
},
},
};
doUpdateAssignmentWithSuccess(variables, {
update(
cache,
{
data: {
updateAssignment: { successful, updatedAssignment },
},
}
) {
try {
if (successful) {
const id = cache.identify({
id: updatedAssignment.id,
__typename: updatedAssignment.__typename,
});
const fragment = assignmentFragment;
const fragmentName = 'AssignmentParts';
cache.writeFragment({
fragment,
fragmentName,
id,
data: {
...updatedAssignment,
submission,
},
});
}
} catch (e) {
console.error(e);
// Query did not exist in the cache, and apollo throws a generic Error. Do nothing
}
},
}).then(() => {
saving.value--;
if (saving.value === 0) {
unsaved.value = false;
}
});
}, 500);
const saveInput = (answer: string) => {
// reset corrections on input
corrections.value = '';
unsaved.value = true;
/*
We update the assignment on this component, so the changes are reflected on it. The server does not return
the updated entity, to prevent the UI to update when the user is entering his input
*/
assignment.value.submission.text = answer;
_save(assignment.value.submission);
if (assignment.value.submission.text.length > 0) {
matomoTrackEvent('Auftrag', 'Text mit Eingabe gespeichert', assignment.value.title);
}
};
const changeDocumentUrl = (documentUrl: string) => {
assignment.value.submission.document = documentUrl;
_save(assignment.value.submission);
};
const spellcheck = () => {
spellcheckLoading.value = true;
const variables = {
input: {
assignment: assignment.value.id,
text: assignment.value.submission.text,
},
};
doSpellCheck(variables, {
update(
_cache,
{
data: {
spellCheck: { results },
},
}
) {
corrections.value = results;
},
}).then(() => {
spellcheckLoading.value = false;
});
matomoTrackEvent('Auftrag', 'Rechtschreibung geprüft', assignment.value.title);
};
</script>
<style scoped lang="scss">
@import '@/styles/_variables.scss';
@import '@/styles/_functions.scss';
@import '@/styles/_mixins.scss';
.assignment {
margin-bottom: 3rem;
position: relative;
&__title {
font-size: toRem(17px);
margin-bottom: 1rem;
}
&__main-text {
:deep(ul) {
@include list-parent;
}
:deep(li) {
@include list-child;
}
}
&__toggle-input-container {
display: flex;
margin-bottom: 15px;
}
&__toggle-input {
border: 0;
font-family: $sans-serif-font-family;
background: transparent;
font-size: toRem(14px);
padding: 5px 0;
margin-right: 15px;
outline: 0;
color: $color-silver-dark;
cursor: pointer;
border-bottom: 2px solid transparent;
&--active {
border-bottom-color: $color-charcoal-dark;
color: $color-charcoal-dark;
}
}
&__feedback {
@include regular-text;
}
}
</style>