Merged in feature/new-lc-navigation (pull request #60)

Implement new learning content navigation/layout

* Fix first part of cypress tests

* Add event bus type to fix typecheck

* Rework SelfEvaluation to support new layout

* Fix layout

* Hide lang switcher icon in lc footer

Closes https://iterativ.atlassian.net/browse/VBV-319

* Fix cypress tests

* Unregister event bus handler

* Hide ItNavigationProgress on self evaluations with
only a single step

* Last fixes

* Merged develop into feature/new-lc-navigation
This commit is contained in:
Elia Bieri 2023-04-25 10:06:24 +00:00
parent 766af5444a
commit 1d77da83da
24 changed files with 499 additions and 384 deletions

View File

@ -1,126 +1,108 @@
<template> <template>
<div> <LearningContentMultiLayout
<FeedbackIntro :title="title"
v-if="stepNo === 0" subtitle="Feedback"
:title="circleStore.circle?.title" :learning-content-type="'feedback'"
:intro=" :show-start-button="stepNo === 0"
$t('feedback.intro', { :show-next-button="stepNo > 0 && stepNo + 1 < numSteps"
name: `${courseSessionsStore.circleExperts[0].first_name} ${courseSessionsStore.circleExperts[0].last_name}`, :show-previous-button="stepNo > 0"
}) :show-exit-button="stepNo + 1 === numSteps"
" :current-step="stepNo"
@start="stepNo = 1" :steps-count="numSteps"
></FeedbackIntro> :start-badge-text="$t('feedback.introduction')"
<ItRadioGroup :end-badge-text="$t('feedback.submission')"
v-if="stepNo === 1" @previous="previousStep()"
v-model="wouldRecommend" @next="nextStep()"
:label="$t('feedback.recommendLabel')" >
class="mb-8" <div class="container-medium">
:items="YES_NO" <p v-if="stepNo === 0" class="mt-10">
/> {{
<ItRadioGroup $t("feedback.intro", {
v-if="stepNo === 2" name: `${courseSessionsStore.circleExperts[0].first_name} ${courseSessionsStore.circleExperts[0].last_name}`,
v-model="satisfaction" })
:label="$t('feedback.satisfactionLabel')" }}
class="mb-8" </p>
:items="RATINGS" <p v-if="stepNo > 0 && stepNo + 1 < numSteps" class="pb-2">
/> {{ stepLabels[stepNo] }}
<ItRadioGroup </p>
v-if="stepNo === 3" <ItRadioGroup
v-model="goalAttainment" v-if="stepNo === 1"
:label="$t('feedback.goalAttainmentLabel')" v-model="wouldRecommend"
class="mb-8" class="mb-8"
:items="RATINGS" :items="YES_NO"
/> />
<ItRadioGroup <ItRadioGroup
v-if="stepNo === 4" v-if="stepNo === 2"
v-model="proficiency" v-model="satisfaction"
:label="$t('feedback.proficiencyLabel')" class="mb-8"
class="mb-8" :items="RATINGS"
:items="PERCENTAGES" />
/> <ItRadioGroup
<ItRadioGroup v-if="stepNo === 3"
v-if="stepNo === 5" v-model="goalAttainment"
v-model="receivedMaterials" class="mb-8"
:label="$t('feedback.receivedMaterialsLabel')" :items="RATINGS"
class="mb-8" />
:items="YES_NO" <ItRadioGroup
/> v-if="stepNo === 4"
<ItRadioGroup v-model="proficiency"
v-if="stepNo === 5 && receivedMaterials" class="mb-8"
v-model="materialsRating" :items="PERCENTAGES"
:label="$t('feedback.materialsRatingLabel')" />
class="mb-8" <ItRadioGroup
:items="RATINGS" v-if="stepNo === 5"
/> v-model="receivedMaterials"
<ItRadioGroup class="mb-8"
v-if="stepNo === 6" :items="YES_NO"
v-model="instructorCompetence" />
:label="$t('feedback.instructorCompetenceLabel')" <div v-if="stepNo === 5 && receivedMaterials">
class="mb-8" <p class="pb-2">{{ t("feedback.materialsRatingLabel") }}</p>
:items="RATINGS" <ItRadioGroup v-model="materialsRating" class="mb-8" :items="RATINGS" />
/> </div>
<ItRadioGroup <ItRadioGroup
v-if="stepNo === 7" v-if="stepNo === 6"
v-model="instructorRespect" v-model="instructorCompetence"
class="mb-8" class="mb-8"
:label="$t('feedback.instructorRespectLabel')" :items="RATINGS"
:items="RATINGS" />
/> <ItRadioGroup
<ItTextarea v-if="stepNo === 7"
v-if="stepNo === 8" v-model="instructorRespect"
v-model="instructorOpenFeedback" class="mb-8"
:label="$t('feedback.instructorOpenFeedbackLabel')" :items="RATINGS"
class="mb-8" />
/> <ItTextarea v-if="stepNo === 8" v-model="instructorOpenFeedback" class="mb-8" />
<ItTextarea <ItTextarea v-if="stepNo === 9" v-model="courseNegativeFeedback" class="mb-8" />
v-if="stepNo === 9" <ItTextarea v-if="stepNo === 10" v-model="coursePositiveFeedback" class="mb-8" />
v-model="courseNegativeFeedback" <FeedbackCompletition
:label="$t('feedback.courseNegativeFeedbackLabel')" v-if="stepNo === 11"
class="mb-8" :avatar-url="courseSessionsStore.circleExperts[0].avatar_url"
/> :title="
<ItTextarea $t('feedback.completionTitle', {
v-if="stepNo === 10" name: `${courseSessionsStore.circleExperts[0].first_name} ${courseSessionsStore.circleExperts[0].last_name}`,
v-model="coursePositiveFeedback" })
:label="$t('feedback.coursePositiveFeedbackLabel')" "
class="mb-8" :description="$t('feedback.completionDescription')"
/> :feedback-sent="mutationResult != null"
<FeedbackCompletition @send-feedback="sendFeedback"
v-if="stepNo === 11" />
:avatar-url="courseSessionsStore.circleExperts[0].avatar_url" </div>
:title=" </LearningContentMultiLayout>
$t('feedback.completionTitle', { <!--
name: `${courseSessionsStore.circleExperts[0].first_name} ${courseSessionsStore.circleExperts[0].last_name}`, <pre>
}) satisfaction {{ satisfaction }}
" goalAttainment {{ goalAttainment }}
:description="$t('feedback.completionDescription')" proficiency {{ proficiency }}
:feedback-sent="mutationResult != null" receivedMaterials {{ receivedMaterials }}
@send-feedback="sendFeedback" materialsRating {{ materialsRating }}
/> instructorCompetence {{ instructorCompetence }}
instructorRespect {{ instructorRespect }}
<LearningContentNavigation instructorOpenFeedback {{ instructorOpenFeedback }}
:show-back-button="stepNo > 0" wouldRecommend {{ wouldRecommend }}
:show-next-button="stepNo > 0 && stepNo < MAX_STEPS - 1" coursePositiveFeedback {{ coursePositiveFeedback }}
:question-index="stepNo" courseNegativeFeedback {{ courseNegativeFeedback }}
:max-question-index="MAX_STEPS" mutationResult: {{ mutationResult }}
@back="previousStep" </pre> -->
@continue="nextStep"
></LearningContentNavigation>
<!-- <hr class="mb-10 mt-10" />
<pre>
satisfaction {{ satisfaction }}
goalAttainment {{ goalAttainment }}
proficiency {{ proficiency }}
receivedMaterials {{ receivedMaterials }}
materialsRating {{ materialsRating }}
instructorCompetence {{ instructorCompetence }}
instructorRespect {{ instructorRespect }}
instructorOpenFeedback {{ instructorOpenFeedback }}
wouldRecommend {{ wouldRecommend }}
coursePositiveFeedback {{ coursePositiveFeedback }}
courseNegativeFeedback {{ courseNegativeFeedback }}
mutationResult: {{ mutationResult }}
</pre> -->
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -128,31 +110,52 @@ import ItRadioGroup from "@/components/ui/ItRadioGroup.vue";
import ItTextarea from "@/components/ui/ItTextarea.vue"; import ItTextarea from "@/components/ui/ItTextarea.vue";
import { graphql } from "@/gql/"; import { graphql } from "@/gql/";
import type { SendFeedbackInput } from "@/gql/graphql"; import type { SendFeedbackInput } from "@/gql/graphql";
import LearningContentNavigation from "@/pages/learningPath/learningContentPage/LearningContentNavigation.vue";
import FeedbackCompletition from "@/pages/learningPath/learningContentPage/feedback/FeedbackCompletition.vue"; import FeedbackCompletition from "@/pages/learningPath/learningContentPage/feedback/FeedbackCompletition.vue";
import FeedbackIntro from "@/pages/learningPath/learningContentPage/feedback/FeedbackIntro.vue";
import { import {
PERCENTAGES, PERCENTAGES,
RATINGS, RATINGS,
YES_NO, YES_NO,
} from "@/pages/learningPath/learningContentPage/feedback/feedback.constants"; } from "@/pages/learningPath/learningContentPage/feedback/feedback.constants";
import LearningContentMultiLayout from "@/pages/learningPath/learningContentPage/layouts/LearningContentMultiLayout.vue";
import { useCircleStore } from "@/stores/circle"; import { useCircleStore } from "@/stores/circle";
import { useCourseSessionsStore } from "@/stores/courseSessions"; import { useCourseSessionsStore } from "@/stores/courseSessions";
import type { LearningContent } from "@/types"; import type { LearningContent } from "@/types";
import { useMutation } from "@urql/vue"; import { useMutation } from "@urql/vue";
import log from "loglevel"; import log from "loglevel";
import { onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { useI18n } from "vue-i18n";
const props = defineProps<{ page: LearningContent }>(); const props = defineProps<{ page: LearningContent }>();
const courseSessionsStore = useCourseSessionsStore(); const courseSessionsStore = useCourseSessionsStore();
const circleStore = useCircleStore(); const circleStore = useCircleStore();
const { t } = useI18n();
onMounted(async () => { onMounted(async () => {
log.debug("Feedback mounted"); log.debug("Feedback mounted");
}); });
const stepNo = ref(0); const stepNo = ref(0);
const MAX_STEPS = 12;
const title = computed(
() => `«${circleStore.circle?.title}»: ${t("feedback.areYouSatisfied")}`
);
const stepLabels = [
t("feedback.introduction"),
t("feedback.recommendLabel"),
t("feedback.satisfactionLabel"),
t("feedback.goalAttainmentLabel"),
t("feedback.proficiencyLabel"),
t("feedback.receivedMaterialsLabel"),
t("feedback.instructorCompetenceLabel"),
t("feedback.instructorRespectLabel"),
t("feedback.instructorOpenFeedbackLabel"),
t("feedback.courseNegativeFeedbackLabel"),
t("feedback.coursePositiveFeedbackLabel"),
t("feedback.submission"),
];
const numSteps = stepLabels.length;
const sendFeedbackMutation = graphql(` const sendFeedbackMutation = graphql(`
mutation SendFeedbackMutation($input: SendFeedbackInput!) { mutation SendFeedbackMutation($input: SendFeedbackInput!) {
@ -189,9 +192,10 @@ const previousStep = () => {
} }
}; };
const nextStep = () => { const nextStep = () => {
if (stepNo.value < MAX_STEPS - 1) { if (stepNo.value < numSteps) {
stepNo.value += 1; stepNo.value += 1;
} }
log.info(`next step ${stepNo.value} of ${numSteps}`);
}; };
const sendFeedback = () => { const sendFeedback = () => {

View File

@ -20,3 +20,10 @@ export const NavigationProgressPartial: Story = {
endBadgeText: "Abgabe", endBadgeText: "Abgabe",
}, },
}; };
export const NavigationProgressNoStartEndBadge: Story = {
args: {
steps: 5,
currentStep: 3,
},
};

View File

@ -2,16 +2,28 @@
import { computed } from "vue"; import { computed } from "vue";
export interface Props { export interface Props {
// Number of steps including the start and end badge
steps: number; steps: number;
// Current step, starting at 0 for the start badge
currentStep: number; currentStep: number;
startBadgeText: string; startBadgeText?: string;
endBadgeText: string; endBadgeText?: string;
} }
const props = defineProps<Props>(); const props = withDefaults(defineProps<Props>(), {
startBadgeText: undefined,
endBadgeText: undefined,
});
const hasStartBadge = computed(() => typeof props.startBadgeText !== "undefined");
const hasEndBadge = computed(() => typeof props.endBadgeText !== "undefined");
const numNumberSteps = computed(
() => props.steps - Number(hasStartBadge.value) - Number(hasEndBadge.value)
);
function getPillClasses(step: number) { function getPillClasses(step: number) {
if (step == props.currentStep) { if (step + Number(hasStartBadge.value) == props.currentStep) {
return "bg-sky-500 text-bold"; return "bg-sky-500 text-bold";
} else if (step < props.currentStep) { } else if (step < props.currentStep) {
return "bg-green-500"; return "bg-green-500";
@ -20,16 +32,16 @@ function getPillClasses(step: number) {
} }
const startBadgeClasses = computed(() => { const startBadgeClasses = computed(() => {
if (0 == props.currentStep) { if (0 === props.currentStep) {
return "bg-sky-500 text-bold"; return "bg-sky-500 text-bold";
} }
return "bg-green-500"; return "bg-green-500";
}); });
const endBadgeClasses = computed(() => { const endBadgeClasses = computed(() => {
if (props.steps + 1 == props.currentStep) { if (props.steps === props.currentStep + 1) {
return "bg-sky-500 text-bold"; return "bg-sky-500 text-bold";
} else if (props.steps + 2 == props.currentStep) { } else if (props.steps === props.currentStep) {
return "bg-green-500 text-bold"; return "bg-green-500 text-bold";
} }
return "border"; return "border";
@ -39,23 +51,28 @@ const endBadgeClasses = computed(() => {
<template> <template>
<div class="flex flex-row text-sm"> <div class="flex flex-row text-sm">
<div <div
class="inline-flex h-7 items-center justify-center rounded-3xl px-3" v-if="props.startBadgeText"
class="inline-flex h-7 items-center justify-center whitespace-nowrap rounded-3xl px-3 text-sm"
:class="startBadgeClasses" :class="startBadgeClasses"
> >
{{ props.startBadgeText }} {{ props.startBadgeText }}
</div> </div>
<div v-for="step in props.steps" :key="step" class="flex flex-row"> <div v-for="(_, step) in numNumberSteps" :key="step" class="flex flex-row">
<hr class="w-16 self-center border border-[1px] border-gray-400" /> <hr
v-if="hasStartBadge || step !== 0"
class="w-8 self-center border border-gray-400"
/>
<div <div
class="inline-flex h-7 w-7 items-center justify-center rounded-full px-3 py-1" class="inline-flex h-7 w-7 items-center justify-center rounded-full px-3 py-1 text-sm"
:class="getPillClasses(step)" :class="getPillClasses(step)"
> >
{{ step }} {{ step + 1 }}
</div> </div>
</div> </div>
<hr class="w-16 self-center border border-gray-400" /> <hr v-if="hasEndBadge" class="w-8 self-center border border-gray-400" />
<div <div
class="inline-flex h-7 items-center justify-center rounded-3xl px-3" v-if="endBadgeText"
class="inline-flex h-7 items-center justify-center whitespace-nowrap rounded-3xl px-3 text-sm"
:class="endBadgeClasses" :class="endBadgeClasses"
> >
{{ props.endBadgeText }} {{ props.endBadgeText }}

View File

@ -33,10 +33,14 @@ import type { RadioItem } from "@/pages/learningPath/learningContentPage/feedbac
import { RadioGroup, RadioGroupLabel, RadioGroupOption } from "@headlessui/vue"; import { RadioGroup, RadioGroupLabel, RadioGroupOption } from "@headlessui/vue";
defineProps<{ interface Props {
modelValue: any; modelValue: any;
items: RadioItem<any>[]; items: RadioItem<any>[];
label: string; label?: string;
}>(); }
withDefaults(defineProps<Props>(), {
label: undefined,
});
defineEmits(["update:modelValue"]); defineEmits(["update:modelValue"]);
</script> </script>

View File

@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<h2 class="heading-1 mb-8 block">{{ label }}</h2> <h2 v-if="label" class="heading-1 mb-8 block">{{ label }}</h2>
<textarea <textarea
:value="modelValue" :value="modelValue"
class="h-40 w-full border-gray-500" class="h-40 w-full border-gray-500"
@ -10,10 +10,14 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ interface Props {
modelValue: string; modelValue: string;
label: string; label?: string;
}>(); }
withDefaults(defineProps<Props>(), {
label: undefined,
});
const emit = defineEmits(["update:modelValue"]); const emit = defineEmits(["update:modelValue"]);
const onInput = (event: Event) => { const onInput = (event: Event) => {

View File

@ -70,6 +70,7 @@
"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?",
@ -80,6 +81,7 @@
"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"

View File

@ -33,12 +33,7 @@ function close() {
<template> <template>
<div v-if="singleCriteria" class="absolute bottom-0 top-0 w-full bg-white"> <div v-if="singleCriteria" class="absolute bottom-0 top-0 w-full bg-white">
<LearningContentContainer <LearningContentContainer @exit="router.back()">
:title="''"
:next-button-text="$t('general.save')"
@exit="router.back()"
@next="router.back()"
>
<div v-if="singleCriteria" class="container-medium"> <div v-if="singleCriteria" class="container-medium">
<div class="mt-4 border p-6 lg:mt-8 lg:p-12"> <div class="mt-4 border p-6 lg:mt-8 lg:p-12">
<h2 class="heading-2"> <h2 class="heading-2">

View File

@ -4,10 +4,11 @@ import { useCircleStore } from "@/stores/circle";
import type { LearningContent, LearningContentType } from "@/types"; import type { LearningContent, LearningContentType } from "@/types";
import log from "loglevel"; import log from "loglevel";
import type { Component } from "vue"; import type { Component } from "vue";
import { computed } from "vue"; import { computed, onUnmounted } from "vue";
import AssignmentBlock from "@/pages/learningPath/learningContentPage/blocks/AssignmentBlock.vue"; import AssignmentBlock from "@/pages/learningPath/learningContentPage/blocks/AssignmentBlock.vue";
import AttendanceDayBlock from "@/pages/learningPath/learningContentPage/blocks/AttendanceDayBlock.vue"; import AttendanceDayBlock from "@/pages/learningPath/learningContentPage/blocks/AttendanceDayBlock.vue";
import eventBus from "@/utils/eventBus";
import DescriptionBlock from "./blocks/DescriptionBlock.vue"; import DescriptionBlock from "./blocks/DescriptionBlock.vue";
import DescriptionTextBlock from "./blocks/DescriptionTextBlock.vue"; import DescriptionTextBlock from "./blocks/DescriptionTextBlock.vue";
import FeedbackBlock from "./blocks/FeedbackBlock.vue"; import FeedbackBlock from "./blocks/FeedbackBlock.vue";
@ -57,20 +58,21 @@ const component = computed(() => {
return DEFAULT_BLOCK; return DEFAULT_BLOCK;
}); });
const showNavigationBorder = computed(() => { function handleFinishedLearningContent() {
return block.value?.type !== "feedback"; circleStore.continueFromLearningContent(props.learningContent);
}
eventBus.on("finishedLearningContent", handleFinishedLearningContent);
onUnmounted(() => {
eventBus.off("finishedLearningContent", handleFinishedLearningContent);
}); });
</script> </script>
<template> <template>
<LearningContentContainer <LearningContentContainer
v-if="block" v-if="block"
:title="learningContent.title"
:next-button-text="$t('learningContent.completeAndContinue')"
:learning-content-block="learningContent.contents[0]"
:show-border="showNavigationBorder"
@exit="circleStore.closeLearningContent(props.learningContent)" @exit="circleStore.closeLearningContent(props.learningContent)"
@next="circleStore.continueFromLearningContent(props.learningContent)"
> >
<div> <div>
<component <component

View File

@ -1,35 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import LearningContentBadge from "@/pages/learningPath/LearningContentTypeBadge.vue";
import type { LearningContentBlock } from "@/types";
import * as log from "loglevel"; import * as log from "loglevel";
log.debug("LearningContentContainer.vue setup"); log.debug("LearningContentContainer.vue setup");
interface Props { defineEmits(["exit"]);
title: string;
nextButtonText: string;
learningContentBlock: LearningContentBlock | null;
showBorder: boolean;
}
const _props = withDefaults(defineProps<Props>(), {
title: "",
nextButtonText: "",
learningContentBlock: null,
showBorder: true,
});
defineEmits(["next", "exit"]);
</script> </script>
<template> <template>
<div> <div>
<div class="h-full"></div> <div class="h-full"></div>
<!-- just here to not make the footer jump during the transition -->
<div class="absolute bottom-0 top-0 w-full bg-white"> <div class="absolute bottom-0 top-0 w-full bg-white">
<div class="h-content overflow-y-scroll"> <div class="h-content overflow-y-auto">
<header <header
class="relative flex h-12 w-full items-center justify-between bg-white px-4 py-4 lg:h-16 lg:px-8" class="relative flex h-12 w-full items-center justify-between bg-white px-4 lg:h-16 lg:px-8"
> >
<button <button
type="button" type="button"
@ -42,30 +25,6 @@ defineEmits(["next", "exit"]);
</header> </header>
<slot></slot> <slot></slot>
</div> </div>
<nav
class="nav flex items-center justify-between bg-white px-4"
:class="{ 'border-t': showBorder }"
>
<div class="flex justify-between">
<LearningContentBadge
v-if="learningContentBlock && learningContentBlock.type"
:learning-content-type="learningContentBlock.type"
class="mr-2 hidden lg:flex"
/>
<h1 class="hidden text-base font-normal lg:inline-block" data-cy="ln-title">
{{ title }}
</h1>
</div>
<button
type="button"
class="btn-blue btn-large-icon z-10"
data-cy="complete-and-continue"
@click="$emit('next')"
>
{{ nextButtonText }}
<it-icon-check class="ml-2"></it-icon-check>
</button>
</nav>
</div> </div>
</div> </div>
</template> </template>

View File

@ -1,61 +0,0 @@
<script setup lang="ts">
interface Props {
showBackButton: boolean;
showNextButton: boolean;
questionIndex: number;
maxQuestionIndex: number;
}
const _props = withDefaults(defineProps<Props>(), {
showBackButton: true,
showNextButton: true,
questionIndex: 0,
maxQuestionIndex: 0,
});
defineEmits(["back", "continue"]);
</script>
<template>
<div>
<nav class="mb-4 mt-16 flex">
<button
v-if="showBackButton"
class="btn-secondary mr-2 flex items-center"
data-cy="previous-step"
@click="$emit('back')"
>
<it-icon-arrow-left class="mr-2 h-6 w-6"></it-icon-arrow-left>
{{ $t("general.backCapitalized") }}
</button>
<button
v-if="showNextButton"
class="btn-secondary flex items-center"
data-cy="next-step"
@click="$emit('continue')"
>
{{ $t("general.next") }}
<it-icon-arrow-right class="ml-2 h-6 w-6"></it-icon-arrow-right>
</button>
</nav>
<div
role="progressbar"
:aria-valuenow="questionIndex + 1"
:aria-valuemax="maxQuestionIndex"
:aria-valuemin="1"
class="absolute bottom-[86px] left-0 right-0 inline-flex h-1 gap-1 lg:left-4 lg:right-4"
>
<span
v-for="i in maxQuestionIndex"
:key="i"
class="w-full"
:class="{
'bg-sky-500': i <= questionIndex + 1,
'bg-gray-400': i > questionIndex + 1,
}"
></span>
</div>
</div>
</template>
<style scoped></style>

View File

@ -1,5 +1,6 @@
<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,9 +1,5 @@
<template> <template>
<div class="container-medium"> <FeedbackForm :page="content" />
<div class="lg:mt-12">
<FeedbackForm :page="content" />
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -14,7 +10,7 @@ interface Value {
description: string; description: string;
} }
defineProps<{ const props = defineProps<{
value: Value; value: Value;
content: LearningContent; content: LearningContent;
}>(); }>();

View File

@ -1,11 +1,18 @@
<template> <template>
<div class="h-screen"> <LearningContentSimpleLayout
<iframe width="100%" height="100%" scrolling="no" :src="value.url" /> :subtitle="learningContentTypeData('resource').title"
</div> :learning-content-type="'resource'"
>
<div class="h-screen">
<iframe width="100%" height="100%" scrolling="no" :src="value.url" />
</div>
</LearningContentSimpleLayout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import LearningContentSimpleLayout from "@/pages/learningPath/learningContentPage/layouts/LearningContentSimpleLayout.vue";
import type { LearningContent } from "@/types"; import type { LearningContent } from "@/types";
import { learningContentTypeData } from "@/utils/typeMaps";
interface Value { interface Value {
url: string; url: string;

View File

@ -1,18 +1,22 @@
<template> <template>
<div class="container-medium"> <LearningContentSimpleLayout
<div class="lg:mt-8"> :title="content.title"
<h1>{{ content.title }}</h1> :subtitle="learningContentTypeData('media_library').title"
:learning-content-type="'media_library'"
>
<div class="container-medium">
<p class="text-large my-4 lg:my-8">{{ value.description }}</p> <p class="text-large my-4 lg:my-8">{{ value.description }}</p>
<router-link :to="`${value.url}?back=${route.path}`" class="button btn-primary"> <router-link :to="`${value.url}?back=${route.path}`" class="button btn-primary">
Mediathek öffnen Mediathek öffnen
</router-link> </router-link>
</div> </div>
</div> </LearningContentSimpleLayout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import LearningContentSimpleLayout from "@/pages/learningPath/learningContentPage/layouts/LearningContentSimpleLayout.vue";
import type { LearningContent } from "@/types"; import type { LearningContent } from "@/types";
import { learningContentTypeData } from "@/utils/typeMaps";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
const route = useRoute(); const route = useRoute();

View File

@ -1,14 +1,15 @@
<template> <template>
<div class="container-medium"> <LearningContentSimpleLayout
<div class="lg:mt-8"> :title="content.title"
<p class="text-large my-4">{{ value.description }}</p> :subtitle="learningContentTypeData('placeholder').title"
<h1>{{ content.title }}</h1> :learning-content-type="'placeholder'"
</div> ></LearningContentSimpleLayout>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import LearningContentSimpleLayout from "@/pages/learningPath/learningContentPage/layouts/LearningContentSimpleLayout.vue";
import type { LearningContent } from "@/types"; import type { LearningContent } from "@/types";
import { learningContentTypeData } from "@/utils/typeMaps";
interface Value { interface Value {
description: string; description: string;

View File

@ -1,18 +1,26 @@
<template> <template>
<div class="container-medium"> <LearningContentSimpleLayout
<iframe :title="content.title"
class="mt-8 aspect-video w-full" :subtitle="learningContentTypeData('video').title"
:src="value.url" :learning-content-type="'video'"
:title="content.title" >
frameborder="0" <div class="container-medium">
allow="accelerometer; encrypted-media; gyroscope; picture-in-picture" <iframe
allowfullscreen class="mt-8 aspect-video w-full"
></iframe> :src="value.url"
</div> :title="content.title"
frameborder="0"
allow="accelerometer; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
</div>
</LearningContentSimpleLayout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import LearningContentSimpleLayout from "@/pages/learningPath/learningContentPage/layouts/LearningContentSimpleLayout.vue";
import type { LearningContent } from "@/types"; import type { LearningContent } from "@/types";
import { learningContentTypeData } from "@/utils/typeMaps";
interface Value { interface Value {
url: string; url: string;

View File

@ -1,23 +0,0 @@
<template>
<div>
<h1 class="mb-8">«{{ title }}»: {{ $t("feedback.areYouSatisfied") }}</h1>
<p class="mb-8">{{ intro }}</p>
<button class="btn-primary" @click="$emit('start')">
{{ $t("general.start") }}
</button>
</div>
</template>
<script setup lang="ts">
interface Props {
title: string;
intro: string;
}
const _props = withDefaults(defineProps<Props>(), {
title: "",
intro: "",
});
defineEmits(["start"]);
</script>

View File

@ -0,0 +1,66 @@
<script setup lang="ts">
import eventBus from "@/utils/eventBus";
interface Props {
showStartButton?: boolean;
showPreviousButton?: boolean;
showNextButton?: boolean;
showExitButton?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
showStartButton: false,
showPreviousButton: false,
showNextButton: false,
showExitButton: true,
});
defineEmits(["start", "previous", "next"]);
</script>
<template>
<nav class="nav absolute bottom-0 w-full border-t px-6 py-4">
<div class="relative z-10 flex flex-row place-content-end bg-white">
<button
v-if="props.showPreviousButton"
class="btn-secondary mr-2 flex items-center"
data-cy="previous-step"
@click="$emit('previous')"
>
<it-icon-arrow-left class="mr-2 h-6 w-6"></it-icon-arrow-left>
{{ $t("general.backCapitalized") }}
</button>
<button
v-if="props.showNextButton"
class="btn-blue z-10 flex items-center"
data-cy="next-step"
@click="$emit('next')"
>
{{ $t("general.next") }}
<it-icon-arrow-right class="ml-2 h-6 w-6"></it-icon-arrow-right>
</button>
<button
v-if="props.showStartButton"
type="button"
class="btn-blue z-10 flex items-center"
data-cy="start"
@click="$emit('start')"
>
{{ $t("general.start") }}
<it-icon-arrow-right class="ml-2 h-6 w-6"></it-icon-arrow-right>
</button>
<button
v-if="props.showExitButton"
type="button"
class="btn-blue z-10 flex items-center"
data-cy="complete-and-continue"
@click="eventBus.emit('finishedLearningContent', true)"
>
{{ "Als erledigt markieren" }}
<it-icon-check class="ml-2"></it-icon-check>
</button>
</div>
</nav>
</template>
<style scoped></style>

View File

@ -0,0 +1,68 @@
<script setup lang="ts">
// Layout for learning contents with multiple steps
import ItNavigationProgress from "@/components/ui/ItNavigationProgress.vue";
import LearningContentFooter from "@/pages/learningPath/learningContentPage/layouts/LearningContentFooter.vue";
import type { LearningContentType } from "@/types";
import { learningContentTypeData } from "@/utils/typeMaps";
interface Props {
title: string | undefined;
subtitle: string;
learningContentType: LearningContentType;
showStartButton: boolean;
showPreviousButton: boolean;
showNextButton: boolean;
showExitButton: boolean;
currentStep: number;
stepsCount: number;
startBadgeText?: string;
endBadgeText?: string;
}
const props = withDefaults(defineProps<Props>(), {
title: undefined,
startBadgeText: undefined,
endBadgeText: undefined,
});
const emit = defineEmits(["previous", "next", "exit"]);
</script>
<template>
<div class="container-medium">
<div
v-if="props.learningContentType !== 'placeholder'"
class="flex h-min w-min items-center gap-2 rounded-full pb-10"
>
<component
:is="learningContentTypeData(props.learningContentType).icon"
class="h-6 w-6 text-gray-900"
></component>
<p class="whitespace-nowrap text-gray-900">
{{ props.subtitle }}
</p>
</div>
<h2 v-if="props.title" class="pb-6 text-3xl" data-cy="ln-title">
{{ props.title }}
</h2>
<ItNavigationProgress
v-if="props.stepsCount > 1"
:current-step="props.currentStep"
:start-badge-text="props.startBadgeText"
:steps="stepsCount"
:end-badge-text="props.endBadgeText"
class="overflow-hidden pb-12"
></ItNavigationProgress>
<slot></slot>
</div>
<LearningContentFooter
:show-start-button="props.showStartButton"
:show-next-button="props.showNextButton"
:show-previous-button="props.showPreviousButton"
:show-exit-button="props.showExitButton"
@previous="emit('previous')"
@next="emit('next')"
@start="emit('next')"
></LearningContentFooter>
</template>

View File

@ -0,0 +1,37 @@
<script setup lang="ts">
// Basic layout for a learning content that only has a single step
import LearningContentFooter from "@/pages/learningPath/learningContentPage/layouts/LearningContentFooter.vue";
import type { LearningContentType } from "@/types";
import { learningContentTypeData } from "@/utils/typeMaps";
interface Props {
title: string | undefined;
subtitle: string;
learningContentType: LearningContentType;
}
const props = withDefaults(defineProps<Props>(), {
title: undefined,
});
</script>
<template>
<div class="container-medium">
<div
v-if="props.learningContentType !== 'placeholder'"
class="flex h-min w-full items-center gap-2 pb-8"
>
<component
:is="learningContentTypeData(props.learningContentType).icon"
class="h-6 w-6 text-gray-900"
></component>
<p class="whitespace-nowrap text-gray-900">
{{ props.subtitle }}
</p>
</div>
<h2 v-if="props.title" data-cy="ln-title">{{ props.title }}</h2>
</div>
<slot></slot>
<LearningContentFooter></LearningContentFooter>
</template>

View File

@ -5,9 +5,10 @@ import * as log from "loglevel";
import { COMPLETION_FAILURE, COMPLETION_SUCCESS } from "@/constants"; import { COMPLETION_FAILURE, COMPLETION_SUCCESS } from "@/constants";
import LearningContentContainer from "@/pages/learningPath/learningContentPage/LearningContentContainer.vue"; import LearningContentContainer from "@/pages/learningPath/learningContentPage/LearningContentContainer.vue";
import LearningContentMultiLayout from "@/pages/learningPath/learningContentPage/layouts/LearningContentMultiLayout.vue";
import { useCourseSessionsStore } from "@/stores/courseSessions"; import { useCourseSessionsStore } from "@/stores/courseSessions";
import eventBus from "@/utils/eventBus";
import { computed, reactive } from "vue"; import { computed, reactive } from "vue";
import LearningContentNavigation from "../learningContentPage/LearningContentNavigation.vue";
log.debug("LearningContent.vue setup"); log.debug("LearningContent.vue setup");
@ -24,7 +25,14 @@ const props = defineProps<{
const questions = computed(() => props.learningUnit?.children); const questions = computed(() => props.learningUnit?.children);
const currentQuestion = computed(() => questions.value[state.questionIndex]); const currentQuestion = computed(() => questions.value[state.questionIndex]);
const showBackButton = computed(() => state.questionIndex != 0); const showPreviousButton = computed(() => state.questionIndex != 0);
const showNextButton = computed(
() => state.questionIndex + 1 < questions.value?.length && questions.value?.length > 1
);
const showExitButton = computed(
() =>
questions.value?.length === 1 || questions.value?.length === state.questionIndex + 1
);
function handleContinue() { function handleContinue() {
log.debug("handleContinue"); log.debug("handleContinue");
@ -43,74 +51,82 @@ function handleBack() {
state.questionIndex -= 1; state.questionIndex -= 1;
} }
} }
eventBus.on("finishedLearningContent", () => {
circleStore.closeSelfEvaluation(props.learningUnit);
});
</script> </script>
<template> <template>
<div v-if="learningUnit"> <div v-if="learningUnit">
<LearningContentContainer <LearningContentContainer
:title="$t('selfEvaluation.title', { title: learningUnit.title })"
:next-button-text="$t('learningContent.completeAndContinue')"
:show-border="false"
@exit="circleStore.closeSelfEvaluation(props.learningUnit)" @exit="circleStore.closeSelfEvaluation(props.learningUnit)"
@next="circleStore.closeSelfEvaluation(props.learningUnit)"
> >
<div class="container-medium h-full"> <LearningContentMultiLayout
<div class="mt-8 lg:mt-40"> :current-step="state.questionIndex"
<h2 class="heading-2"> :subtitle="$t('selfEvaluation.title')"
{{ currentQuestion.competence_id }} {{ currentQuestion.title }} :title="$t('selfEvaluation.title', { title: learningUnit.title })"
</h2> learning-content-type="learningmodule"
:steps-count="questions.length"
:show-next-button="showNextButton"
:show-exit-button="showExitButton"
:show-start-button="false"
:show-previous-button="showPreviousButton"
@previous="handleBack()"
@next="handleContinue()"
>
<div class="h-full">
<div class="mt-8">
<h3 class="heading-3">
{{ currentQuestion.competence_id }} {{ currentQuestion.title }}
</h3>
<div <div
class="mt-4 flex flex-col justify-between gap-8 lg:mt-8 lg:flex-row lg:gap-12" class="mt-4 flex flex-col justify-between gap-8 lg:mt-8 lg:flex-row lg:gap-12"
>
<button
class="inline-flex flex-1 items-center border p-4 text-left"
:class="{
'border-green-500': currentQuestion.completion_status === 'success',
'border-2': currentQuestion.completion_status === 'success',
}"
data-cy="success"
@click="circleStore.markCompletion(currentQuestion, COMPLETION_SUCCESS)"
> >
<it-icon-smiley-happy class="mr-4 h-16 w-16"></it-icon-smiley-happy> <button
<span class="text-large font-bold">{{ $t("selfEvaluation.yes") }}.</span> class="inline-flex flex-1 items-center border p-4 text-left"
</button> :class="{
<button 'border-green-500': currentQuestion.completion_status === 'success',
class="inline-flex flex-1 items-center border p-4 text-left" 'border-2': currentQuestion.completion_status === 'success',
:class="{ }"
'border-orange-500': data-cy="success"
currentQuestion.completion_status === COMPLETION_FAILURE, @click="circleStore.markCompletion(currentQuestion, COMPLETION_SUCCESS)"
'border-2': currentQuestion.completion_status === COMPLETION_FAILURE, >
}" <it-icon-smiley-happy class="mr-4 h-16 w-16"></it-icon-smiley-happy>
data-cy="fail" <span class="text-large font-bold">
@click="circleStore.markCompletion(currentQuestion, 'fail')" {{ $t("selfEvaluation.yes") }}.
> </span>
<it-icon-smiley-thinking class="mr-4 h-16 w-16"></it-icon-smiley-thinking> </button>
<span class="text-xl font-bold">{{ $t("selfEvaluation.no") }}.</span> <button
</button> class="inline-flex flex-1 items-center border p-4 text-left"
</div> :class="{
'border-orange-500':
currentQuestion.completion_status === COMPLETION_FAILURE,
'border-2': currentQuestion.completion_status === COMPLETION_FAILURE,
}"
data-cy="fail"
@click="circleStore.markCompletion(currentQuestion, 'fail')"
>
<it-icon-smiley-thinking
class="mr-4 h-16 w-16"
></it-icon-smiley-thinking>
<span class="text-xl font-bold">{{ $t("selfEvaluation.no") }}.</span>
</button>
</div>
<div class="mt-6 lg:mt-12"> <div class="mt-6 lg:mt-12">
{{ $t("selfEvaluation.progressText") }} {{ $t("selfEvaluation.progressText") }}
<router-link <router-link
:to="courseSession.currentCourseSession?.competence_url || '/'" :to="courseSession.currentCourseSession?.competence_url || '/'"
class="text-primary-500 underline" class="text-primary-500 underline"
> >
{{ $t("selfEvaluation.progressLink") }} {{ $t("selfEvaluation.progressLink") }}
</router-link> </router-link>
</div>
</div> </div>
</div> </div>
<LearningContentNavigation </LearningContentMultiLayout>
:show-next-button="
questions.length > 1 && state.questionIndex + 1 < questions.length
"
:show-back-button="showBackButton"
:question-index="state.questionIndex"
:max-question-index="questions.length"
@back="handleBack"
@continue="handleContinue"
></LearningContentNavigation>
</div>
</LearningContentContainer> </LearningContentContainer>
</div> </div>
</template> </template>

View File

@ -3,6 +3,7 @@ import mitt from "mitt";
export type MittEvents = { export type MittEvents = {
// FIXME: clean up with VBV-305 // FIXME: clean up with VBV-305
switchedCourseSession: number; switchedCourseSession: number;
finishedLearningContent: boolean;
}; };
const eventBus = mitt<MittEvents>(); const eventBus = mitt<MittEvents>();

View File

@ -38,15 +38,15 @@ describe("circle page", () => {
"contain", "contain",
"Verschaffe dir einen Überblick" "Verschaffe dir einen Überblick"
); );
cy.get('[data-cy="complete-and-continue"]').click(); 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="ln-title"]').should("contain", "Mediathek Fahrzeug");
cy.get('[data-cy="complete-and-continue"]').click(); 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="ln-title"]').should("contain", "Vorbereitungsauftrag");
cy.get('[data-cy="complete-and-continue"]').click(); cy.get('[data-cy="complete-and-continue"]').click({ force: true });
cy.get( cy.get(
'[data-cy="test-lehrgang-lp-circle-fahrzeug-lc-verschaffe-dir-einen-überblick-checkbox"] > .cy-checkbox-checked' '[data-cy="test-lehrgang-lp-circle-fahrzeug-lc-verschaffe-dir-einen-überblick-checkbox"] > .cy-checkbox-checked'
@ -68,7 +68,7 @@ describe("circle page", () => {
"contain", "contain",
"Verschaffe dir einen Überblick" "Verschaffe dir einen Überblick"
); );
cy.get('[data-cy="complete-and-continue"]').click(); cy.get('[data-cy="complete-and-continue"]').click({ force: true });
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();

View File

@ -105,9 +105,9 @@ Cypress.Commands.add('makeSelfEvaluation', (answers) => {
cy.get('[data-cy="fail"]').click(); cy.get('[data-cy="fail"]').click();
} }
if (i < answers.length - 1) { if (i < answers.length - 1) {
cy.get('[data-cy="next-step"]').click(); cy.get('[data-cy="next-step"]').click({ force: true });
} else { } else {
cy.get('[data-cy="complete-and-continue"]').click(); cy.get('[data-cy="complete-and-continue"]').click({ force: true });
} }
} }
}); });