491 lines
12 KiB
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>
|