Merged in feature/VBV-291-kn-frontend-teilnehmer (pull request #56)

VBV-291 Auftrag Frontend

* Fixes in SubmissionView

* Change closing button tag

* Delete client cypress folder

* Add eslint cypress plugin

* Add Cypress tests

* Reformat de.json

* Fix type errors

* Fix cypress tests

* Add cypress commands

* Disable assignment task inputs after submission
This commit is contained in:
Elia Bieri 2023-05-03 14:53:21 +00:00
parent f1ab753515
commit e4b8d7c301
27 changed files with 767 additions and 173 deletions

View File

@ -1,5 +0,0 @@
{
"body": "Fixtures are a great way to mock data for responses to routes",
"email": "hello@cypress.io",
"name": "Using fixtures to represent data"
}

View File

@ -1,37 +0,0 @@
/// <reference types="cypress" />
// ***********************************************
// This example commands.ts shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
//
// declare global {
// namespace Cypress {
// interface Chainable {
// login(email: string, password: string): Chainable<void>
// drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
// }
// }
// }

View File

@ -1,13 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>Components App</title>
</head>
<body>
<div data-cy-root></div>
</body>
</html>

View File

@ -1,55 +0,0 @@
// ***********************************************************
// This example support/component.ts is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import "../../tailwind.css";
import "./commands";
// Alternatively you can use CommonJS syntax:
// require('./commands')
import { setupI18n } from "@/i18n.ts";
import router from "@/router";
import { mount } from "cypress/vue";
import { createPinia } from "pinia";
// Augment the Cypress namespace to include type definitions for
// your custom command.
// Alternatively, can be defined in cypress/support/component.d.ts
// with a <reference path="./component" /> at the top of your spec.
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
mount: typeof mount;
}
}
}
Cypress.Commands.add("mount", (component, options = {}) => {
options.global = options.global || {};
options.global.plugins = options.global.plugins || [];
options.global.plugins.push(setupI18n());
options.global.plugins.push(createPinia());
if (!options.router) {
options.router = router;
}
return mount(component, options);
});
// Example use:
// cy.mount(MyComponent)

View File

@ -1,8 +0,0 @@
{
"compilerOptions": {
"lib": ["es5", "dom"],
"target": "es5",
"types": ["cypress", "node"]
},
"include": ["**/*.ts"]
}

View File

@ -60,6 +60,7 @@
"autoprefixer": "^10.4.8", "autoprefixer": "^10.4.8",
"eslint": "8.37", "eslint": "8.37",
"eslint-config-prettier": "^8.5.0", "eslint-config-prettier": "^8.5.0",
"eslint-plugin-cypress": "^2.13.3",
"eslint-plugin-storybook": "^0.6.11", "eslint-plugin-storybook": "^0.6.11",
"eslint-plugin-vue": "^9.4.0", "eslint-plugin-vue": "^9.4.0",
"jsdom": "^21.1.1", "jsdom": "^21.1.1",
@ -10824,6 +10825,18 @@
"eslint": ">=7.0.0" "eslint": ">=7.0.0"
} }
}, },
"node_modules/eslint-plugin-cypress": {
"version": "2.13.3",
"resolved": "https://registry.npmjs.org/eslint-plugin-cypress/-/eslint-plugin-cypress-2.13.3.tgz",
"integrity": "sha512-nAPjZE5WopCsgJwl3vHm5iafpV+ZRO76Z9hMyRygWhmg5ODXDPd+9MaPl7kdJ2azj+sO87H3P1PRnggIrz848g==",
"dev": true,
"dependencies": {
"globals": "^11.12.0"
},
"peerDependencies": {
"eslint": ">= 3.2.1"
}
},
"node_modules/eslint-plugin-prettier": { "node_modules/eslint-plugin-prettier": {
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz",
@ -27863,6 +27876,15 @@
"integrity": "sha512-wLbQiFre3tdGgpDv67NQKnJuTlcUVYHas3k+DZCc2U2BadthoEY4B7hLPvAxaqdyOGCzuLfii2fqGph10va7oA==", "integrity": "sha512-wLbQiFre3tdGgpDv67NQKnJuTlcUVYHas3k+DZCc2U2BadthoEY4B7hLPvAxaqdyOGCzuLfii2fqGph10va7oA==",
"dev": true "dev": true
}, },
"eslint-plugin-cypress": {
"version": "2.13.3",
"resolved": "https://registry.npmjs.org/eslint-plugin-cypress/-/eslint-plugin-cypress-2.13.3.tgz",
"integrity": "sha512-nAPjZE5WopCsgJwl3vHm5iafpV+ZRO76Z9hMyRygWhmg5ODXDPd+9MaPl7kdJ2azj+sO87H3P1PRnggIrz848g==",
"dev": true,
"requires": {
"globals": "^11.12.0"
}
},
"eslint-plugin-prettier": { "eslint-plugin-prettier": {
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz",

View File

@ -71,6 +71,7 @@
"autoprefixer": "^10.4.8", "autoprefixer": "^10.4.8",
"eslint": "8.37", "eslint": "8.37",
"eslint-config-prettier": "^8.5.0", "eslint-config-prettier": "^8.5.0",
"eslint-plugin-cypress": "^2.13.3",
"eslint-plugin-storybook": "^0.6.11", "eslint-plugin-storybook": "^0.6.11",
"eslint-plugin-vue": "^9.4.0", "eslint-plugin-vue": "^9.4.0",
"jsdom": "^21.1.1", "jsdom": "^21.1.1",

View File

@ -9,8 +9,8 @@
:show-exit-button="stepNo + 1 === numSteps" :show-exit-button="stepNo + 1 === numSteps"
:current-step="stepNo" :current-step="stepNo"
:steps-count="numSteps" :steps-count="numSteps"
:start-badge-text="$t('feedback.introduction')" :start-badge-text="$t('general.introduction')"
:end-badge-text="$t('feedback.submission')" :end-badge-text="$t('general.submission')"
@previous="previousStep()" @previous="previousStep()"
@next="nextStep()" @next="nextStep()"
> >
@ -141,7 +141,7 @@ const title = computed(
); );
const stepLabels = [ const stepLabels = [
t("feedback.introduction"), t("general.introduction"),
t("feedback.recommendLabel"), t("feedback.recommendLabel"),
t("feedback.satisfactionLabel"), t("feedback.satisfactionLabel"),
t("feedback.goalAttainmentLabel"), t("feedback.goalAttainmentLabel"),
@ -152,7 +152,7 @@ const stepLabels = [
t("feedback.instructorOpenFeedbackLabel"), t("feedback.instructorOpenFeedbackLabel"),
t("feedback.courseNegativeFeedbackLabel"), t("feedback.courseNegativeFeedbackLabel"),
t("feedback.coursePositiveFeedbackLabel"), t("feedback.coursePositiveFeedbackLabel"),
t("feedback.submission"), t("general.submission"),
]; ];
const numSteps = stepLabels.length; const numSteps = stepLabels.length;

View File

@ -36,7 +36,7 @@ import { RadioGroup, RadioGroupLabel, RadioGroupOption } from "@headlessui/vue";
interface Props { interface Props {
modelValue: any; modelValue: any;
items: RadioItem<any>[]; items: RadioItem<any>[];
label?: string; label: string | undefined;
} }
withDefaults(defineProps<Props>(), { withDefaults(defineProps<Props>(), {

View File

@ -0,0 +1,19 @@
import ItSuccessAlert from "@/components/ui/ItSuccessAlert.vue";
import type { Meta, StoryObj } from "@storybook/vue3";
// More on how to set up stories at: https://storybook.js.org/docs/7.0/vue/writing-stories/introduction
const meta: Meta<typeof ItSuccessAlert> = {
title: "VBV/SuccessAlert",
component: ItSuccessAlert,
// This component will have an automatically generated docsPage entry: https://storybook.js.org/docs/7.0/vue/writing-docs/docs-page
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof ItSuccessAlert>;
export const Default: Story = {
args: {
text: "Deiner Praxisauftrag wurde abgegeben",
},
};

View File

@ -0,0 +1,12 @@
<script setup lang="ts">
defineProps<{
text: string;
}>();
</script>
<template>
<div class="flex flex-row items-center space-x-2 bg-green-200 px-6">
<it-icon-check class="it-icon"></it-icon-check>
<p class="text-large py-4">{{ text }}</p>
</div>
</template>

View File

@ -4,6 +4,8 @@
<textarea <textarea
:value="modelValue" :value="modelValue"
class="h-40 w-full border-gray-500" class="h-40 w-full border-gray-500"
:data-cy="`it-textarea-${cyKey}`"
:disabled="disabled"
@input="onInput" @input="onInput"
/> />
</div> </div>
@ -12,11 +14,14 @@
<script setup lang="ts"> <script setup lang="ts">
interface Props { interface Props {
modelValue: string; modelValue: string;
label?: string; label: string | undefined;
cyKey?: string;
disabled?: boolean;
} }
withDefaults(defineProps<Props>(), { withDefaults(defineProps<Props>(), {
label: undefined, label: undefined,
cyKey: "",
}); });
const emit = defineEmits(["update:modelValue"]); const emit = defineEmits(["update:modelValue"]);

View File

@ -1,4 +1,26 @@
{ {
"assignment": {
"acceptConditionsDisclaimer": "Bedingungen akzeptieren und Ergebnisse abgeben",
"assessmentDocumentDisclaimer": "Diese geleitete Fallarbeit wird auf Grund des folgenden Beurteilungsinstrument bewertet:",
"assessmentTitle": "Bewertung",
"assignmentSubmitted": "Du hast deine Ergebnisse erfolgreich abgegeben.",
"confirmSubmitPerson": "Hiermit bestätige ich, dass die folgende Person meine Ergebnisse bewerten soll.",
"confirmSubmitResults": "Hiermit bestätige ich, dass ich die Zusammenfassung meiner Ergebnisse überprüft habe und so abgeben will.",
"dueDateIntroduction": "Reiche deine Ergebnisse pünktlich ein bis am {date} um {time} Uhr ein.",
"dueDateNotSet": "Keine Abgabedaten wurden erfasst für diese Durchführung",
"dueDateSubmission": "Einreichungstermin: {date}",
"dueDateTitle": "Abgabetermin",
"edit": "Bearbeiten",
"effortTitle": "Zeitaufwand",
"initialSituationTitle": "Ausgangslage",
"lastChangesNotSaved": "Die letzte Änderung konnte nicht gespeichert werden.",
"performanceObjectivesTitle": "Leistungsziele",
"showAssessmentDocument": "Bewertungsinstrument anzeigen",
"submissionNotificationDisclaimer": "{name} wird deine Ergebnisse bewerten. Du wirst per Benachrichtigung informiert, sobald die Bewertung für dich freigegeben wurde.",
"submitAssignment": "Ergebnisse abgeben",
"taskDefinition": "Bearbeite die Teilaufgaben und dokumentiere deine Ergebnisse.",
"taskDefinitionTitle": "Aufgabenstellung"
},
"circlePage": { "circlePage": {
"circleContentBoxTitle": "Das lernst du in diesem Circle.", "circleContentBoxTitle": "Das lernst du in diesem Circle.",
"contactExpertButton": "Fachexpertin kontaktieren", "contactExpertButton": "Fachexpertin kontaktieren",
@ -70,7 +92,6 @@
"instructorOpenFeedbackLabel": "Was ich dem Kursleiter sonst noch sagen wollte:", "instructorOpenFeedbackLabel": "Was ich dem Kursleiter sonst noch sagen wollte:",
"instructorRespectLabel": "Fragen und Anregungen der Kursteilnehmenden wurden ernst genommen und aufgegriffen.", "instructorRespectLabel": "Fragen und Anregungen der Kursteilnehmenden wurden ernst genommen und aufgegriffen.",
"intro": "{name}, dein/e Trainer/-in, bittet dich, ihm/ihr Feedback zu geben. Das ist freiwillig, würde aber ihm/ihr helfen, deine Lernerlebniss zu verbessern.", "intro": "{name}, dein/e Trainer/-in, bittet dich, ihm/ihr Feedback zu geben. Das ist freiwillig, würde aber ihm/ihr helfen, deine Lernerlebniss zu verbessern.",
"introduction": "Einleitung",
"materialsRatingLabel": "Falls ja: Wie beurteilen Sie die Vorbereitungsunterlagen (z.B. eLearning)?", "materialsRatingLabel": "Falls ja: Wie beurteilen Sie die Vorbereitungsunterlagen (z.B. eLearning)?",
"noFeedbacks": "Es wurden noch keine Feedbacks abgegeben", "noFeedbacks": "Es wurden noch keine Feedbacks abgegeben",
"proficiencyLabel": "Wie beurteilen Sie Ihre Sicherheit bezüglichen den Themen nach dem Kurs?", "proficiencyLabel": "Wie beurteilen Sie Ihre Sicherheit bezüglichen den Themen nach dem Kurs?",
@ -81,7 +102,6 @@
"sendFeedback": "Feedback abschicken", "sendFeedback": "Feedback abschicken",
"sentByUsers": "Von {count} Teilnehmern ausgefüllt", "sentByUsers": "Von {count} Teilnehmern ausgefüllt",
"showDetails": "Details anzeigen", "showDetails": "Details anzeigen",
"submission": "Abgabe",
"unhappy": "Unzufrieden", "unhappy": "Unzufrieden",
"veryHappy": "Sehr zufrieden", "veryHappy": "Sehr zufrieden",
"veryUnhappy": "Sehr unzufrieden" "veryUnhappy": "Sehr unzufrieden"
@ -99,9 +119,11 @@
"backToLearningPath": "zurück zum Lernpfad", "backToLearningPath": "zurück zum Lernpfad",
"certificate": "Zertifikat | Zertifikate", "certificate": "Zertifikat | Zertifikate",
"circles": "Circles", "circles": "Circles",
"close": "Schliessen",
"exam": "Prüfung | Prüfungen", "exam": "Prüfung | Prüfungen",
"examResult": "Prüfungsresultat | Prüfungsresultate", "examResult": "Prüfungsresultat | Prüfungsresultate",
"feedback": "Feedback | Feedbacks", "feedback": "Feedback | Feedbacks",
"introduction": "Einleitung",
"learningPath": "Lernpfad", "learningPath": "Lernpfad",
"learningSequence": "Lernsequenz", "learningSequence": "Lernsequenz",
"learningUnit": "Lerneinheit", "learningUnit": "Lerneinheit",
@ -117,6 +139,7 @@
"show": "Anschauen", "show": "Anschauen",
"showAll": "Alle anschauen", "showAll": "Alle anschauen",
"start": "Los geht's", "start": "Los geht's",
"submission": "Abgabe",
"title": "myVBV", "title": "myVBV",
"transferTask": "Transferauftrag | Transferaufträge", "transferTask": "Transferauftrag | Transferaufträge",
"yes": "Ja" "yes": "Ja"
@ -126,7 +149,7 @@
"fr": "Französisch" "fr": "Französisch"
}, },
"learningContent": { "learningContent": {
"completeAndContinue": "Als erledigt markieren" "markAsDone": "Als erledigt markieren"
}, },
"learningPathPage": { "learningPathPage": {
"currentCircle": "Aktueller Circle", "currentCircle": "Aktueller Circle",

View File

@ -0,0 +1,62 @@
<script setup lang="ts">
import type { Assignment } from "@/types";
import type { Dayjs } from "dayjs";
interface Props {
assignment: Assignment;
dueDate?: Dayjs;
}
const props = withDefaults(defineProps<Props>(), {
dueDate: undefined,
});
</script>
<template>
<h3 class="mt-8">{{ $t("assignment.initialSituationTitle") }}</h3>
<p class="text-large">{{ props.assignment.starting_position }}</p>
<h3 class="mt-8">{{ $t("assignment.taskDefinitionTitle") }}</h3>
<p class="text-large">
{{ $t("assignment.taskDefinition") }}
</p>
<ul>
<li v-for="task in props.assignment.tasks" :key="task.id">
-
<span class="text-large underline">{{ task.value.title }}</span>
</li>
</ul>
<h3 class="mt-8">{{ $t("assignment.dueDateTitle") }}</h3>
<p v-if="props.dueDate" class="text-large">
{{
$t("assignment.dueDateIntroduction", {
date: dueDate!.format("DD.MM.YYYY"),
time: dueDate!.format("HH:mm"),
})
}}
</p>
<p v-else class="text-large">
{{ $t("assignment.dueDateNotSet") }}
</p>
<h3 class="mt-8">{{ $t("assignment.effortTitle") }}</h3>
<p class="text-large">{{ props.assignment.effort_required }}</p>
<h3 class="mt-8 border-b border-gray-500 pb-2">
{{ $t("assignment.performanceObjectivesTitle") }}
</h3>
<p
v-for="performance_objective in props.assignment.performance_objectives"
:key="performance_objective.id"
class="text-large border-b border-gray-500 py-4"
>
{{ performance_objective.value.text }}
</p>
<h3 class="mt-8">{{ $t("assignment.assessmentTitle") }}</h3>
<p class="text-large">{{ props.assignment.assessment_description }}</p>
<a :href="props.assignment.assessment_document_url" class="text-large underline">
{{ $t("assignment.showAssessmentDocument") }}
</a>
</template>

View File

@ -0,0 +1,37 @@
<script setup lang="ts">
import type { UserDataText } from "@/stores/assignmentStore";
import { useAssignmentStore } from "@/stores/assignmentStore";
import type { AssignmentTask } from "@/types";
import { computed } from "vue";
const emit = defineEmits<{
(e: "editTask", task: AssignmentTask): void;
}>();
const assignmentStore = useAssignmentStore();
const completionData = computed(() => assignmentStore.completion_data);
</script>
<template>
<div
v-for="task in assignmentStore.assignment?.tasks ?? []"
:key="task.id"
class="mb-6 border-t border-gray-400"
>
<div class="flex flex-row justify-between pt-8">
<p class="text-sm text-gray-900">{{ task.value.title }}</p>
<button
class="link whitespace-nowrap pl-2 text-sm"
@click="emit('editTask', task)"
>
{{ $t("assignment.edit") }}
</button>
</div>
<div v-for="taskBlock in task.value.content" :key="taskBlock.id">
<p class="pt-6 text-base font-bold">{{ taskBlock.value.text }}</p>
<p v-if="completionData && taskBlock.id in completionData" class="font-normal">
{{ (completionData[taskBlock.id].user_data as UserDataText).text }}
</p>
</div>
</div>
</template>

View File

@ -0,0 +1,131 @@
<script setup lang="ts">
import ItButton from "@/components/ui/ItButton.vue";
import ItCheckbox from "@/components/ui/ItCheckbox.vue";
import ItSuccessAlert from "@/components/ui/ItSuccessAlert.vue";
import AssignmentSubmissionResponses from "@/pages/learningPath/learningContentPage/assignment/AssignmentSubmissionResponses.vue";
import { useAssignmentStore } from "@/stores/assignmentStore";
import { useCourseSessionsStore } from "@/stores/courseSessions";
import type { Assignment, AssignmentTask } from "@/types";
import type { Dayjs } from "dayjs";
import log from "loglevel";
import { computed, reactive } from "vue";
import { useI18n } from "vue-i18n";
const props = defineProps<{
assignment: Assignment;
courseSessionId: number;
dueDate: Dayjs;
}>();
const emit = defineEmits<{
(e: "editTask", task: AssignmentTask): void;
}>();
const assignmentStore = useAssignmentStore();
const courseSessionStore = useCourseSessionsStore();
const { t } = useI18n();
const state = reactive({
confirmInput: false,
confirmPerson: false,
});
const circleExpert = computed(() => {
return courseSessionStore.circleExperts[0];
});
const circleExpertName = computed(() => {
return `${circleExpert.value?.first_name} ${circleExpert.value?.last_name}`;
});
const onEditTask = (task: AssignmentTask) => {
emit("editTask", task);
};
const onSubmit = async () => {
try {
const courseSessionId = courseSessionStore.currentCourseSession?.id;
if (!courseSessionId) {
log.error("Invalid courseSessionId");
return;
}
await assignmentStore.upsertAssignmentCompletion(
props.assignment.id,
{},
courseSessionId,
true
);
} catch (error) {
log.error("Could not submit assignment", error);
}
};
</script>
<template>
<div class="w-full border border-gray-400 p-8">
<h3 class="heading-3 border-b border-gray-400 pb-6">
{{ $t("assignment.acceptConditionsDisclaimer") }}
</h3>
<div v-if="!assignmentStore.submitted">
<ItCheckbox
class="w-full border-b border-gray-400 py-6"
:checkbox-item="{
label: $t('assignment.confirmSubmitResults'),
value: 'value',
checked: state.confirmInput,
}"
@toggle="state.confirmInput = !state.confirmInput"
></ItCheckbox>
<div class="w-full border-b border-gray-400 py-6">
<ItCheckbox
:checkbox-item="{
label: $t('assignment.confirmSubmitPerson'),
value: 'value',
checked: state.confirmPerson,
}"
@toggle="state.confirmPerson = !state.confirmPerson"
></ItCheckbox>
<div class="flex flex-row items-center pl-[49px] pt-3">
<img
alt="Notification icon"
class="mr-2 h-[45px] min-w-[45px] rounded-full"
:src="circleExpert.avatar_url"
/>
<p class="text-base font-bold">
{{ circleExpertName }}
</p>
</div>
<!-- TODO: find way to find user that will do the corrections -->
</div>
<div class="flex flex-col space-x-2 pt-6 text-base sm:flex-row">
<p>{{ $t("assignment.assessmentDocumentDisclaimer") }}</p>
<a :href="props.assignment.assessment_document_url" class="underline">
{{ $t("assignment.showAssessmentDocument") }}
</a>
</div>
<p class="pt-6">
{{ $t("assignment.dueDateSubmission", { date: dueDate.format("DD.MM.YYYY") }) }}
</p>
<ItButton
class="mt-6"
variant="primary"
size="large"
:disabled="!state.confirmInput || !state.confirmPerson"
@click="onSubmit"
>
<p>{{ $t("assignment.submitAssignment") }}</p>
</ItButton>
</div>
<div v-else class="pt-6">
<ItSuccessAlert :text="t('assignment.assignmentSubmitted')"></ItSuccessAlert>
<p class="pt-6">
{{
$t("assignment.submissionNotificationDisclaimer", { name: circleExpertName })
}}
</p>
</div>
</div>
<AssignmentSubmissionResponses
@edit-task="onEditTask"
></AssignmentSubmissionResponses>
</template>

View File

@ -0,0 +1,134 @@
<script setup lang="ts">
import ItCheckbox from "@/components/ui/ItCheckbox.vue";
import ItTextarea from "@/components/ui/ItTextarea.vue";
import type {
AssignmentCompletionData,
BlockId,
UserDataConfirmation,
UserDataText,
} from "@/stores/assignmentStore";
import { useAssignmentStore } from "@/stores/assignmentStore";
import { useCourseSessionsStore } from "@/stores/courseSessions";
import type { AssignmentTask } from "@/types";
import { useDebounceFn } from "@vueuse/core";
import dayjs from "dayjs";
import { reactive, ref } from "vue";
const props = defineProps<{
assignmentId: number;
task: AssignmentTask;
}>();
const lastSaved = ref(dayjs());
const lastSaveUnsuccessful = ref(false);
const checkboxState = reactive({} as Record<BlockId, boolean>);
const courseSessionStore = useCourseSessionsStore();
const assignmentStore = useAssignmentStore();
async function upsertAssignmentCompletion(completion_data: AssignmentCompletionData) {
try {
const courseSessionId = courseSessionStore.currentCourseSession?.id;
if (!courseSessionId) {
console.error("Invalid courseSessionId");
return;
}
await assignmentStore.upsertAssignmentCompletion(
props.assignmentId,
completion_data,
courseSessionId,
false
);
lastSaved.value = dayjs();
lastSaveUnsuccessful.value = false;
console.debug("Saved user input");
} catch (error) {
lastSaveUnsuccessful.value = true;
console.error("Could not save user input", error);
}
}
const upsertAssignmentCompletionDebounced = useDebounceFn(
upsertAssignmentCompletion,
500
);
const onUpdateText = (id: BlockId, value: string) => {
const data: AssignmentCompletionData = {};
data[id] = {
user_data: {
text: value,
} as UserDataText,
};
upsertAssignmentCompletionDebounced(data);
};
const onUpdateConfirmation = (id: BlockId, value: boolean) => {
const data: AssignmentCompletionData = {};
data[id] = {
user_data: {
confirmation: value,
} as UserDataConfirmation,
};
upsertAssignmentCompletion(data);
};
const getBlockData = (id: BlockId) => {
const userData = assignmentStore.getCompletionDataForUserInput(id)?.user_data;
if (userData && "text" in userData) {
return userData.text;
} else if (userData && "confirmation" in userData) {
return userData.confirmation;
}
return null;
};
const onToggleCheckbox = (id: BlockId) => {
checkboxState[id] = !checkboxState[id];
onUpdateConfirmation(id, checkboxState[id]);
};
</script>
<template>
<div class="flex flex-col space-y-10">
<div v-for="(block, index) in props.task.value.content" :key="block.id">
<div v-if="block.type === 'explanation'">
<p class="text-large">{{ block.value.text }}</p>
</div>
<div v-if="block.type === 'user_confirmation'">
<ItCheckbox
:checkbox-item="{
label: block.value.text,
value: `confirmation-${index}`,
checked: getBlockData(block.id) as boolean,
}"
:disabled="assignmentStore.submitted"
@toggle="onToggleCheckbox(block.id)"
></ItCheckbox>
</div>
<div v-if="block.type === 'user_text_input'">
<p class="text-large pb-4">{{ block.value.text }}</p>
<ItTextarea
:model-value="(getBlockData(block.id) as string) ?? ''"
:cy-key="`user-text-input-${index}`"
:disabled="assignmentStore.submitted"
label=""
@update:model-value="onUpdateText(block.id, $event)"
></ItTextarea>
</div>
</div>
<div v-if="lastSaveUnsuccessful" class="text-red-600">
{{ $t("assignment.lastChangesNotSaved") }}
</div>
</div>
<div v-if="props.task.value.file_submission_required">
<p class="text-large">Datei hochladen</p>
<p class="text-sm text-gray-900">
Mögliche Formate: .JPG, .PNG, .PDF, .DOC, .MOV, .PPT
</p>
</div>
</template>

View File

@ -1,24 +1,39 @@
<script setup lang="ts"> <script setup lang="ts">
import AssignmentIntroductionView from "@/pages/learningPath/learningContentPage/assignment/AssignmentIntroductionView.vue";
import AssignmentSubmissionView from "@/pages/learningPath/learningContentPage/assignment/AssignmentSubmissionView.vue";
import AssignmentTaskView from "@/pages/learningPath/learningContentPage/assignment/AssignmentTaskView.vue";
import LearningContentMultiLayout from "@/pages/learningPath/learningContentPage/layouts/LearningContentMultiLayout.vue";
import type { AssignmentCompletionData } from "@/stores/assignmentStore";
import { useAssignmentStore } from "@/stores/assignmentStore"; import { useAssignmentStore } from "@/stores/assignmentStore";
import { useCourseSessionsStore } from "@/stores/courseSessions"; import { useCourseSessionsStore } from "@/stores/courseSessions";
import type { import type {
Assignment, Assignment,
AssignmentTask,
CourseSessionAssignmentDetails, CourseSessionAssignmentDetails,
LearningContent, LearningContent,
} from "@/types"; } from "@/types";
import dayjs from "dayjs";
import * as log from "loglevel"; import * as log from "loglevel";
import { onMounted, reactive } from "vue"; import { computed, onMounted, reactive } from "vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const courseSessionStore = useCourseSessionsStore();
const assignmentStore = useAssignmentStore(); const assignmentStore = useAssignmentStore();
interface State { interface State {
assignment: Assignment | undefined; assignment: Assignment | undefined;
courseSessionAssignmentDetails: CourseSessionAssignmentDetails | undefined; courseSessionAssignmentDetails: CourseSessionAssignmentDetails | undefined;
assignmentCompletionData: AssignmentCompletionData | undefined;
pageIndex: number;
} }
const state: State = reactive({ const state: State = reactive({
assignment: undefined, assignment: undefined,
courseSessionAssignmentDetails: undefined, courseSessionAssignmentDetails: undefined,
assignmentCompletionData: undefined,
// 0 = introduction, 1 - n = tasks, n+1 = submission
pageIndex: 0,
}); });
const props = defineProps<{ const props = defineProps<{
@ -36,29 +51,104 @@ onMounted(async () => {
state.courseSessionAssignmentDetails = courseSessionsStore.findAssignmentDetails( state.courseSessionAssignmentDetails = courseSessionsStore.findAssignmentDetails(
props.learningContent.id props.learningContent.id
); );
state.assignmentCompletionData = await assignmentStore.loadAssignmentCompletion(
props.assignmentId,
courseSessionId.value
);
log.debug(state.assignment, state.courseSessionAssignmentDetails);
} catch (error) { } catch (error) {
log.error(error); log.error(error);
} }
}); });
const numTasks = computed(() => state.assignment?.tasks?.length ?? 0);
const numPages = computed(() => numTasks.value + 2);
const showPreviousButton = computed(() => state.pageIndex != 0);
const showNextButton = computed(() => state.pageIndex + 1 < numPages.value);
const showExitButton = computed(() => numPages.value === state.pageIndex + 1);
const dueDate = computed(() =>
dayjs(state.courseSessionAssignmentDetails?.deadlineDateTimeUtc)
);
const courseSessionId = computed(
() => courseSessionStore.currentCourseSession?.id ?? 0
);
const currentTask = computed(() => {
if (state.pageIndex > 0 && state.pageIndex <= numTasks.value) {
return state.assignment?.tasks[state.pageIndex - 1];
}
return undefined;
});
const handleBack = () => {
log.debug("handleBack");
if (state.pageIndex > 0) {
state.pageIndex -= 1;
}
log.debug(`pageIndex: ${state.pageIndex}`);
};
const handleContinue = () => {
log.debug("handleContinue");
if (state.pageIndex + 1 < numPages.value) {
state.pageIndex += 1;
}
log.debug(`pageIndex: ${state.pageIndex}`);
};
const jumpToTask = (task: AssignmentTask) => {
log.debug("jumpToTask", task);
const index = state.assignment?.tasks.findIndex((t) => t.id === task.id);
if (index && index >= 0) {
state.pageIndex = index + 1;
}
log.debug(`pageIndex: ${state.pageIndex}`);
};
const getTitle = () => {
if (0 === state.pageIndex) {
return t("general.introduction");
} else if (state.pageIndex === numPages.value - 1) {
return t("general.submission");
}
return currentTask?.value?.value.title ?? "Unknown";
};
</script> </script>
<template> <template>
<div class="container-medium"> <LearningContentMultiLayout
<div v-if="state.assignment" class="lg:mt-12"> :current-step="state.pageIndex"
<h2>Einleitung</h2> :subtitle="state.assignment?.title ?? ''"
:title="getTitle()"
<h3 class="mt-8">Ausgangslage</h3> learning-content-type="assignment"
<p>{{ state.assignment.starting_position }}</p> :steps-count="numPages"
:show-next-button="showNextButton"
<h3 class="mt-8">Abgabetermin</h3> :show-exit-button="showExitButton"
<p v-if="state.courseSessionAssignmentDetails"> :show-start-button="false"
{{ state.courseSessionAssignmentDetails }} :show-previous-button="showPreviousButton"
</p> start-badge-text="Einleitung"
<p v-else>Keine Abgabedaten erfasst für diese Durchführung</p> end-badge-text="Abgabe"
close-button-variant="close"
<pre class="mt-16"> @previous="handleBack()"
{{ state.assignment }} @next="handleContinue()"
</pre> >
</div> <div>
<AssignmentIntroductionView
v-if="state.pageIndex === 0 && state.assignment"
:due-date="dueDate"
:assignment="state.assignment!"
></AssignmentIntroductionView>
<AssignmentTaskView
v-if="currentTask"
:task="currentTask"
:assignment-id="props.assignmentId"
></AssignmentTaskView>
<AssignmentSubmissionView
v-if="state.pageIndex + 1 === numPages && state.assignment && courseSessionId"
:due-date="dueDate"
:assignment="state.assignment!"
:course-session-id="courseSessionId!"
@edit-task="jumpToTask($event)"
></AssignmentSubmissionView>
</div> </div>
</LearningContentMultiLayout>
</template> </template>

View File

@ -1,6 +1,5 @@
<template> <template>
<AssignmentView <AssignmentView
class="container-medium"
:assignment-id="props.value.assignment" :assignment-id="props.value.assignment"
:learning-content="props.content" :learning-content="props.content"
/> />

View File

@ -1,18 +1,28 @@
<script setup lang="ts"> <script setup lang="ts">
import eventBus from "@/utils/eventBus"; import eventBus from "@/utils/eventBus";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
interface Props { export type ClosingButtonVariant = "close" | "mark_as_done";
showStartButton?: boolean;
showPreviousButton?: boolean; const props = defineProps<{
showNextButton?: boolean; showStartButton: boolean;
showExitButton?: boolean; showPreviousButton: boolean;
showNextButton: boolean;
showExitButton: boolean;
closingButtonVariant: ClosingButtonVariant;
}>();
const { t } = useI18n();
// eslint-disable-next-line vue/return-in-computed-property
const closingButtonText = computed(() => {
switch (props.closingButtonVariant) {
case "close":
return t("general.close");
case "mark_as_done":
return t("learningContent.markAsDone");
} }
const props = withDefaults(defineProps<Props>(), {
showStartButton: false,
showPreviousButton: false,
showNextButton: false,
showExitButton: true,
}); });
defineEmits(["start", "previous", "next"]); defineEmits(["start", "previous", "next"]);
@ -56,8 +66,11 @@ defineEmits(["start", "previous", "next"]);
data-cy="complete-and-continue" data-cy="complete-and-continue"
@click="eventBus.emit('finishedLearningContent', true)" @click="eventBus.emit('finishedLearningContent', true)"
> >
{{ "Als erledigt markieren" }} {{ closingButtonText }}
<it-icon-check class="ml-2"></it-icon-check> <it-icon-check
v-if="props.closingButtonVariant == 'mark_as_done'"
class="ml-2"
></it-icon-check>
</button> </button>
</div> </div>
</nav> </nav>

View File

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
// Layout for learning contents with multiple steps // Layout for learning contents with multiple steps
import ItNavigationProgress from "@/components/ui/ItNavigationProgress.vue"; import ItNavigationProgress from "@/components/ui/ItNavigationProgress.vue";
import type { ClosingButtonVariant } from "@/pages/learningPath/learningContentPage/layouts/LearningContentFooter.vue";
import LearningContentFooter from "@/pages/learningPath/learningContentPage/layouts/LearningContentFooter.vue"; import LearningContentFooter from "@/pages/learningPath/learningContentPage/layouts/LearningContentFooter.vue";
import type { LearningContentType } from "@/types"; import type { LearningContentType } from "@/types";
import { learningContentTypeData } from "@/utils/typeMaps"; import { learningContentTypeData } from "@/utils/typeMaps";
@ -17,19 +18,21 @@ interface Props {
stepsCount: number; stepsCount: number;
startBadgeText?: string; startBadgeText?: string;
endBadgeText?: string; endBadgeText?: string;
closeButtonVariant?: ClosingButtonVariant;
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
title: undefined, title: undefined,
startBadgeText: undefined, startBadgeText: undefined,
endBadgeText: undefined, endBadgeText: undefined,
closeButtonVariant: "mark_as_done",
}); });
const emit = defineEmits(["previous", "next", "exit"]); const emit = defineEmits(["previous", "next", "exit"]);
</script> </script>
<template> <template>
<div class="container-medium"> <div class="container-large">
<div <div
v-if="props.learningContentType !== 'placeholder'" v-if="props.learningContentType !== 'placeholder'"
class="flex h-min w-min items-center gap-2 rounded-full pb-10" class="flex h-min w-min items-center gap-2 rounded-full pb-10"
@ -38,12 +41,12 @@ const emit = defineEmits(["previous", "next", "exit"]);
:is="learningContentTypeData(props.learningContentType).icon" :is="learningContentTypeData(props.learningContentType).icon"
class="h-6 w-6 text-gray-900" class="h-6 w-6 text-gray-900"
></component> ></component>
<p class="whitespace-nowrap text-gray-900"> <p class="whitespace-nowrap text-gray-900" data-cy="lc-subtitle">
{{ props.subtitle }} {{ props.subtitle }}
</p> </p>
</div> </div>
<h2 v-if="props.title" class="pb-6 text-3xl" data-cy="ln-title"> <h2 v-if="props.title" class="pb-6 text-3xl" data-cy="lc-title">
{{ props.title }} {{ props.title }}
</h2> </h2>
<ItNavigationProgress <ItNavigationProgress
@ -61,6 +64,7 @@ const emit = defineEmits(["previous", "next", "exit"]);
:show-next-button="props.showNextButton" :show-next-button="props.showNextButton"
:show-previous-button="props.showPreviousButton" :show-previous-button="props.showPreviousButton"
:show-exit-button="props.showExitButton" :show-exit-button="props.showExitButton"
:closing-button-variant="props.closeButtonVariant"
@previous="emit('previous')" @previous="emit('previous')"
@next="emit('next')" @next="emit('next')"
@start="emit('next')" @start="emit('next')"

View File

@ -23,13 +23,19 @@ const type = learningContentTypeData(props.learningContentType);
class="flex h-min w-full items-center gap-2 pb-8" class="flex h-min w-full items-center gap-2 pb-8"
> >
<component :is="type.icon" class="h-6 w-6 text-gray-900"></component> <component :is="type.icon" class="h-6 w-6 text-gray-900"></component>
<p class="whitespace-nowrap text-gray-900"> <p class="whitespace-nowrap text-gray-900" data-cy="lc-subtitle">
{{ type.title }} {{ type.title }}
</p> </p>
</div> </div>
<h2 v-if="props.title" data-cy="ln-title">{{ props.title }}</h2> <h2 v-if="props.title" data-cy="lc-title">{{ props.title }}</h2>
</div> </div>
<slot></slot> <slot></slot>
<LearningContentFooter></LearningContentFooter> <LearningContentFooter
:show-next-button="false"
:show-previous-button="false"
:show-exit-button="true"
:show-start-button="false"
:closing-button-variant="'close'"
></LearningContentFooter>
</template> </template>

View File

@ -1,17 +1,39 @@
import { itGet } from "@/fetchHelpers"; import { itGet, itPost } from "@/fetchHelpers";
import type { Assignment } from "@/types"; import type { Assignment } from "@/types";
import log from "loglevel"; import log from "loglevel";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
export type AssignmentStoreState = { export type AssignmentStoreState = {
assignment: Assignment | undefined; assignment: Assignment | undefined;
completion_data: AssignmentCompletionData;
submitted: boolean;
}; };
export type BlockId = string;
export interface UserDataText {
text: string;
}
export interface UserDataConfirmation {
confirmation: boolean;
}
export interface AssignmentCompletionData {
// {
// "<user_text_input:uuid>": {"user_data": {"text": "some text from user"}},
// "<user_confirmation:uuid>": {"user_data": {"confirmation": true}},
// }
[key: BlockId]: { user_data: UserDataText | UserDataConfirmation };
}
export const useAssignmentStore = defineStore({ export const useAssignmentStore = defineStore({
id: "assignmentStore", id: "assignmentStore",
state: () => { state: () => {
return { return {
assignment: undefined, assignment: undefined,
completion_data: {},
submitted: false,
} as AssignmentStoreState; } as AssignmentStoreState;
}, },
getters: {}, getters: {},
@ -23,9 +45,42 @@ export const useAssignmentStore = defineStore({
if (!assignmentData) { if (!assignmentData) {
throw `No assignment found with: ${assignmentId}`; throw `No assignment found with: ${assignmentId}`;
} }
this.assignment = assignmentData; this.assignment = assignmentData;
return this.assignment; return this.assignment;
}, },
async loadAssignmentCompletion(assignmentId: number, courseSessionId: number) {
log.debug("load assignment completion", assignmentId, courseSessionId);
try {
const data = await itGet(`/api/assignment/${assignmentId}/${courseSessionId}/`);
this.completion_data = data.completion_data;
this.submitted = data.completion_status === "submitted";
} catch (e) {
log.debug("no completion data found ", e);
return undefined;
}
return this.completion_data;
},
getCompletionDataForUserInput(id: BlockId) {
return this.completion_data[id];
},
async upsertAssignmentCompletion(
assignmentId: number,
completion_data: AssignmentCompletionData,
courseSessionId: number,
submit: boolean
) {
const data = {
assignment_id: assignmentId,
completion_status: submit ? "submitted" : "in_progress",
course_session_id: courseSessionId,
completion_data: completion_data,
};
const responseData = await itPost(`/api/assignment/upsert/`, data);
if (responseData) {
this.completion_data = responseData.completion_data;
this.submitted = responseData.completion_status === "submitted";
}
return responseData;
},
}, },
}); });

5
cypress/.eslintrc.json Normal file
View File

@ -0,0 +1,5 @@
{
"extends": [
"plugin:cypress/recommended"
]
}

View File

@ -0,0 +1,78 @@
import { login } from "./helpers";
const navigateToAssignment = () => {
cy.visit(
"/course/überbetriebliche-kurse/learn/fahrzeug/überprüfen-einer-motorfahrzeug-versicherungspolice"
);
};
describe("assignment completion", () => {
beforeEach(() => {
cy.manageCommand("cypress_reset");
login("admin", "test");
navigateToAssignment();
});
it("can open assignment", () => {
cy.testLearningContentTitle("Einleitung");
cy.testLearningContentSubtitle(
"Überprüfen einer Motorfahrzeugs-Versicherungspolice"
);
});
it("can navigate through assignment", () => {
// 1 Step forward
cy.learningContentMultiLayoutNextStep();
cy.testLearningContentTitle(
"Teilaufgabe 1: Beispiel einer Versicherungspolice finden"
);
// 2 Steps forward, 1 step backwards
for (let i = 0; i !== 2; i++) {
cy.learningContentMultiLayoutNextStep();
}
cy.learningContentMultiLayoutPreviousStep();
cy.testLearningContentTitle(
"Teilaufgabe 2: Kundensituation und Ausgangslage"
);
});
it("can save confirmation", () => {
// 1 Step forward
cy.learningContentMultiLayoutNextStep();
cy.testLearningContentTitle(
"Teilaufgabe 1: Beispiel einer Versicherungspolice finden"
);
// Click confirmation
cy.get('[data-cy="it-checkbox-confirmation-1"]').click({ force: true });
cy.wait(250);
cy.reload();
// 1 Step forward
cy.learningContentMultiLayoutNextStep();
cy.get('[data-cy="it-checkbox-confirmation-1"]').should("be.checked");
});
it("can save text", () => {
// 2 Steps forward
cy.learningContentMultiLayoutNextStep();
cy.learningContentMultiLayoutNextStep();
cy.testLearningContentTitle(
"Teilaufgabe 2: Kundensituation und Ausgangslage"
);
// Enter text
cy.get('[data-cy="it-textarea-user-text-input-1"]')
.clear()
.type("Hallovelo");
cy.wait(550);
cy.reload();
// 2 Step forward
cy.learningContentMultiLayoutNextStep();
cy.learningContentMultiLayoutNextStep();
cy.get('[data-cy="it-textarea-user-text-input-1"]').should(
"have.value",
"Hallovelo"
);
});
});

View File

@ -34,18 +34,18 @@ describe("circle page", () => {
cy.get( cy.get(
'[data-cy="test-lehrgang-lp-circle-fahrzeug-lc-verschaffe-dir-einen-überblick"]' '[data-cy="test-lehrgang-lp-circle-fahrzeug-lc-verschaffe-dir-einen-überblick"]'
).click(); ).click();
cy.get('[data-cy="ln-title"]').should( cy.get('[data-cy="lc-title"]').should(
"contain", "contain",
"Verschaffe dir einen Überblick" "Verschaffe dir einen Überblick"
); );
cy.get('[data-cy="complete-and-continue"]').click({ force: true }); cy.get('[data-cy="complete-and-continue"]').click({ force: true });
cy.get('[data-cy="ls-continue-button"]').click(); cy.get('[data-cy="ls-continue-button"]').click();
cy.get('[data-cy="ln-title"]').should("contain", "Mediathek Fahrzeug"); cy.get('[data-cy="lc-title"]').should("contain", "Mediathek Fahrzeug");
cy.get('[data-cy="complete-and-continue"]').click({ force: true }); cy.get('[data-cy="complete-and-continue"]').click({ force: true });
cy.get('[data-cy="ls-continue-button"]').click(); cy.get('[data-cy="ls-continue-button"]').click();
cy.get('[data-cy="ln-title"]').should("contain", "Vorbereitungsauftrag"); cy.get('[data-cy="lc-title"]').should("contain", "Vorbereitungsauftrag");
cy.get('[data-cy="complete-and-continue"]').click({ force: true }); cy.get('[data-cy="complete-and-continue"]').click({ force: true });
cy.get( cy.get(
@ -64,7 +64,7 @@ describe("circle page", () => {
cy.get('[data-cy="ls-continue-button"]').should("contain", "Los geht's"); cy.get('[data-cy="ls-continue-button"]').should("contain", "Los geht's");
cy.get('[data-cy="ls-continue-button"]').click(); cy.get('[data-cy="ls-continue-button"]').click();
cy.get('[data-cy="ln-title"]').should( cy.get('[data-cy="lc-title"]').should(
"contain", "contain",
"Verschaffe dir einen Überblick" "Verschaffe dir einen Überblick"
); );
@ -72,12 +72,12 @@ describe("circle page", () => {
cy.get('[data-cy="ls-continue-button"]').should("contain", "Weiter geht's"); cy.get('[data-cy="ls-continue-button"]').should("contain", "Weiter geht's");
cy.get('[data-cy="ls-continue-button"]').click(); cy.get('[data-cy="ls-continue-button"]').click();
cy.get('[data-cy="ln-title"]').should("contain", "Mediathek Fahrzeug"); cy.get('[data-cy="lc-title"]').should("contain", "Mediathek Fahrzeug");
}); });
it("can open learning content by url", () => { it("can open learning content by url", () => {
cy.visit("/course/test-lehrgang/learn/fahrzeug/mediathek-fahrzeug"); cy.visit("/course/test-lehrgang/learn/fahrzeug/mediathek-fahrzeug");
cy.get('[data-cy="ln-title"]').should("contain", "Mediathek Fahrzeug"); cy.get('[data-cy="lc-title"]').should("contain", "Mediathek Fahrzeug");
cy.get('[data-cy="close-learning-content"]').click(); cy.get('[data-cy="close-learning-content"]').click();
cy.get('[data-cy="circle-title"]').should("contain", "Fahrzeug"); cy.get('[data-cy="circle-title"]').should("contain", "Fahrzeug");

View File

@ -121,3 +121,19 @@ Cypress.Commands.add('makeSelfEvaluation', (answers) => {
// 'myservice.apps.apiclient.serializers.ApiClientRequestResponseLogSerializer' // 'myservice.apps.apiclient.serializers.ApiClientRequestResponseLogSerializer'
// ); // );
// }); // });
Cypress.Commands.add("learningContentMultiLayoutNextStep", () => {
return cy.get('[data-cy="next-step"]').click({ force: true });
});
Cypress.Commands.add("learningContentMultiLayoutPreviousStep", () => {
return cy.get('[data-cy="previous-step"]').click({ force: true });
});
Cypress.Commands.add("testLearningContentTitle", (title) => {
return cy.get('[data-cy="lc-title"]').should("contain", title);
});
Cypress.Commands.add("testLearningContentSubtitle", (subtitle) => {
return cy.get('[data-cy="lc-subtitle"]').should("contain", subtitle);
});