Merged in feature/feedback-form-2022-12-29 (pull request #18)
Feature/feedback form 2022 12 29
This commit is contained in:
commit
6b343805a0
|
|
@ -0,0 +1,17 @@
|
|||
import type { CodegenConfig } from "@graphql-codegen/cli";
|
||||
const config: CodegenConfig = {
|
||||
schema: "../server/schema.graphql",
|
||||
documents: ["src/**/*.vue"],
|
||||
ignoreNoDocuments: true,
|
||||
generates: {
|
||||
"./src/gql/": {
|
||||
preset: "client",
|
||||
config: {
|
||||
useTypeImports: true,
|
||||
},
|
||||
plugins: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -5,6 +5,7 @@
|
|||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build && node versionize && cp ./dist/index.html ../server/vbv_lernwelt/templates/vue/index.html && cp -r ./dist/static/vue ../server/vbv_lernwelt/static/",
|
||||
"build:tailwind": "tailwindcss -i tailwind.css -o ../server/vbv_lernwelt/static/css/tailwind.css --minify",
|
||||
"codegen": "graphql-codegen",
|
||||
"test": "vitest run",
|
||||
"coverage": "vitest run --coverage",
|
||||
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json --composite false",
|
||||
|
|
@ -14,6 +15,7 @@
|
|||
"tailwind": "tailwindcss -i tailwind.css -o ../server/vbv_lernwelt/static/css/tailwind.css --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/tailwindcss": "^0.1.2",
|
||||
"@headlessui/vue": "^1.6.7",
|
||||
"@sentry/tracing": "^7.20.0",
|
||||
"@sentry/vue": "^7.20.0",
|
||||
|
|
@ -28,6 +30,8 @@
|
|||
"vue-router": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/cli": "^2.13.12",
|
||||
"@graphql-codegen/client-preset": "^1.1.4",
|
||||
"@rollup/plugin-alias": "^3.1.9",
|
||||
"@rushstack/eslint-patch": "^1.1.4",
|
||||
"@tailwindcss/forms": "^0.5.2",
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as log from "loglevel";
|
||||
import log from "loglevel";
|
||||
|
||||
import AppFooter from "@/components/AppFooter.vue";
|
||||
import MainNavigationBar from "@/components/MainNavigationBar.vue";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,217 @@
|
|||
<template>
|
||||
<div>
|
||||
<p class="text-gray-800 mb-4">Schritt {{ stepNo + 1 }} von {{ MAX_STEPS }}</p>
|
||||
<p class="mb-10 text-xl">
|
||||
Patrizia Huggel, deine Trainerin, bittet dich, ihr Feedback zu geben.
|
||||
<br />
|
||||
Das ist freiwillig, würde ihr aber helfen, dein Lernerlebnis zu verbessern.
|
||||
</p>
|
||||
<ItRadioGroup
|
||||
v-if="stepNo === 0"
|
||||
v-model="wouldRecommend"
|
||||
label="Würden Sie den Kurs weiterempfehlen?"
|
||||
class="mb-8"
|
||||
:items="YES_NO"
|
||||
/>
|
||||
<ItRadioGroup
|
||||
v-if="stepNo === 1"
|
||||
v-model="satisfaction"
|
||||
label="Zufriedenheit insgesamt"
|
||||
class="mb-8"
|
||||
:items="RATINGS"
|
||||
/>
|
||||
<ItRadioGroup
|
||||
v-if="stepNo === 2"
|
||||
v-model="goalAttainment"
|
||||
label="Zielerreichung insgesamt"
|
||||
class="mb-8"
|
||||
:items="RATINGS"
|
||||
/>
|
||||
<ItRadioGroup
|
||||
v-if="stepNo === 3"
|
||||
v-model="proficiency"
|
||||
label="Wie beurteilen Sie Ihre Sicherheit bezüglichen den Themen nach dem Kurs?"
|
||||
class="mb-8"
|
||||
:items="PERCENTAGES"
|
||||
/>
|
||||
<ItRadioGroup
|
||||
v-if="stepNo === 4"
|
||||
v-model="receivedMaterials"
|
||||
label="Haben Sie Vorbereitungsunterlagen (z.B. eLearning) erhalten?"
|
||||
class="mb-8"
|
||||
:items="YES_NO"
|
||||
/>
|
||||
<ItRadioGroup
|
||||
v-if="stepNo === 4 && receivedMaterials"
|
||||
v-model="materialsRating"
|
||||
label="Falls ja: Wie beurteilen Sie die Vorbereitungsunterlagen (z.B.
|
||||
eLearning)?"
|
||||
class="mb-8"
|
||||
:items="RATINGS"
|
||||
/>
|
||||
<ItRadioGroup
|
||||
v-if="stepNo === 5"
|
||||
v-model="instructorCompetence"
|
||||
label="Der Kursleiter war themenstark, fachkompetent."
|
||||
class="mb-8"
|
||||
:items="RATINGS"
|
||||
/>
|
||||
<ItRadioGroup
|
||||
v-if="stepNo === 6"
|
||||
v-model="instructorRespect"
|
||||
class="mb-8"
|
||||
label="Fragen und Anregungen der Kursteilnehmenden wurden ernst
|
||||
genommen u. aufgegriffen."
|
||||
:items="RATINGS"
|
||||
/>
|
||||
<ItTextarea
|
||||
v-if="stepNo === 7"
|
||||
v-model="instructorOpenFeedback"
|
||||
:label="instructorOpenFeedbackLabel"
|
||||
class="mb-8"
|
||||
/>
|
||||
<ItTextarea
|
||||
v-if="stepNo === 8"
|
||||
v-model="courseNegativeFeedback"
|
||||
:label="courseNegativeFeedbackLabel"
|
||||
class="mb-8"
|
||||
/>
|
||||
<ItTextarea
|
||||
v-if="stepNo === 9"
|
||||
v-model="coursePositiveFeedback"
|
||||
:label="coursePositiveFeedbackLabel"
|
||||
class="mb-8"
|
||||
/>
|
||||
<button class="btn-blue" @click="sendFeedback">Senden und abschliessen</button>
|
||||
<button class="btn-blue mr-4" :disabled="stepNo <= 0" @click="previousStep">
|
||||
Zurück
|
||||
</button>
|
||||
<button class="btn-blue" :disabled="stepNo >= MAX_STEPS - 1" @click="nextStep">
|
||||
Weiter
|
||||
</button>
|
||||
<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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
PERCENTAGES,
|
||||
RATINGS,
|
||||
YES_NO,
|
||||
} from "@/components/learningPath/feedback.constants";
|
||||
import ItRadioGroup from "@/components/ui/ItRadioGroup.vue";
|
||||
import ItTextarea from "@/components/ui/ItTextarea.vue";
|
||||
import { graphql } from "@/gql/";
|
||||
import type { SendFeedbackInput } from "@/gql/graphql";
|
||||
import { useCourseSessionsStore } from "@/stores/courseSessions";
|
||||
import type { LearningContent } from "@/types";
|
||||
import { useMutation } from "@urql/vue";
|
||||
import log from "loglevel";
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
|
||||
const props = defineProps<{ page: LearningContent }>();
|
||||
const courseSessionsStore = useCourseSessionsStore();
|
||||
|
||||
onMounted(async () => {
|
||||
log.debug("Feedback mounted");
|
||||
});
|
||||
|
||||
const stepNo = ref(0);
|
||||
const MAX_STEPS = 10;
|
||||
|
||||
const instructorOpenFeedbackLabel = "Was ich dem Kursleiter sonst noch sagen wollte:";
|
||||
const courseNegativeFeedbackLabel = "Wo sehen Sie Verbesserungspotenzial?";
|
||||
const coursePositiveFeedbackLabel = "Was hat Ihnen besonders gut gefallen?";
|
||||
|
||||
const sendFeedbackMutation = graphql(`
|
||||
mutation SendFeedbackMutation($input: SendFeedbackInput!) {
|
||||
sendFeedback(input: $input) {
|
||||
id
|
||||
satisfaction
|
||||
goalAttainment
|
||||
proficiency
|
||||
receivedMaterials
|
||||
materialsRating
|
||||
errors {
|
||||
field
|
||||
messages
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const { executeMutation } = useMutation(sendFeedbackMutation);
|
||||
|
||||
const satisfaction = ref(null);
|
||||
const goalAttainment = ref(null);
|
||||
const wouldRecommend = ref(null);
|
||||
const proficiency = ref(null);
|
||||
const receivedMaterials = ref(null);
|
||||
const materialsRating = ref(null);
|
||||
const instructorCompetence = ref(null);
|
||||
const instructorRespect = ref(null);
|
||||
const coursePositiveFeedback = ref("");
|
||||
const courseNegativeFeedback = ref("");
|
||||
const instructorOpenFeedback = ref("");
|
||||
|
||||
const mutationResult = ref<any>(null);
|
||||
|
||||
const previousStep = () => {
|
||||
if (stepNo.value > 0) {
|
||||
stepNo.value -= 1;
|
||||
}
|
||||
};
|
||||
const nextStep = () => {
|
||||
if (stepNo.value < MAX_STEPS - 1) {
|
||||
stepNo.value += 1;
|
||||
}
|
||||
};
|
||||
|
||||
const sendFeedback = () => {
|
||||
log.info("sending feedback");
|
||||
const courseSession = courseSessionsStore.courseSessionForRoute;
|
||||
if (!courseSession || !courseSession.id) {
|
||||
log.error("no course session set");
|
||||
return;
|
||||
}
|
||||
const input: SendFeedbackInput = reactive({
|
||||
materialsRating,
|
||||
courseNegativeFeedback,
|
||||
coursePositiveFeedback,
|
||||
goalAttainment,
|
||||
instructorCompetence,
|
||||
instructorRespect,
|
||||
instructorOpenFeedback,
|
||||
satisfaction,
|
||||
proficiency,
|
||||
receivedMaterials,
|
||||
wouldRecommend,
|
||||
page: props.page.translation_key,
|
||||
courseSession: courseSession.id,
|
||||
});
|
||||
const variables = reactive({
|
||||
input,
|
||||
});
|
||||
log.debug(variables);
|
||||
executeMutation(variables)
|
||||
.then(({ data, error }) => {
|
||||
log.debug(data);
|
||||
log.error(error);
|
||||
mutationResult.value = data;
|
||||
})
|
||||
.catch((e) => log.error(e));
|
||||
};
|
||||
</script>
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import * as log from "loglevel";
|
||||
import log from "loglevel";
|
||||
|
||||
import IconLogout from "@/components/icons/IconLogout.vue";
|
||||
import IconSettings from "@/components/icons/IconSettings.vue";
|
||||
|
|
@ -78,7 +78,8 @@ function logout() {
|
|||
onMounted(() => {
|
||||
log.debug("MainNavigationBar mounted");
|
||||
if (userStore.loggedIn) {
|
||||
courseSessionsStore.loadCourseSessionsData();
|
||||
// fixme: only when i'm logged in? should this be handled in the store?
|
||||
// courseSessionsStore.loadCourseSessionsData();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -4,12 +4,16 @@ import { useCircleStore } from "@/stores/circle";
|
|||
import type { LearningContent } from "@/types";
|
||||
import * as log from "loglevel";
|
||||
import { computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
import DescriptionBlock from "@/components/learningPath/blocks/DescriptionBlock.vue";
|
||||
import DescriptionTextBlock from "@/components/learningPath/blocks/DescriptionTextBlock.vue";
|
||||
import FeedbackBlock from "@/components/learningPath/blocks/FeedbackBlock.vue";
|
||||
import IframeBlock from "@/components/learningPath/blocks/IframeBlock.vue";
|
||||
import PlaceholderBlock from "@/components/learningPath/blocks/PlaceholderBlock.vue";
|
||||
import VideoBlock from "@/components/learningPath/blocks/VideoBlock.vue";
|
||||
|
||||
log.debug("LearningContent.vue setup");
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const circleStore = useCircleStore();
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
@ -23,71 +27,46 @@ const block = computed(() => {
|
|||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
// can't use the type as component name, as some are reserved HTML components, e.g. video
|
||||
const COMPONENTS: Record<string, any> = {
|
||||
// todo: can we find a better type here than any? ^
|
||||
placeholder: PlaceholderBlock,
|
||||
video: VideoBlock,
|
||||
assignment: DescriptionTextBlock,
|
||||
resource: DescriptionTextBlock,
|
||||
exercise: IframeBlock,
|
||||
test: IframeBlock,
|
||||
learningmodule: IframeBlock,
|
||||
feedback: FeedbackBlock,
|
||||
};
|
||||
const DEFAULT_BLOCK = DescriptionBlock;
|
||||
|
||||
const component = computed(() => {
|
||||
if (block.value) {
|
||||
return COMPONENTS[block.value.type] || DEFAULT_BLOCK;
|
||||
}
|
||||
return DEFAULT_BLOCK;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="block">
|
||||
<LearningContentContainer
|
||||
:title="learningContent.title"
|
||||
:next-button-text="$t('learningContent.completeAndContinue')"
|
||||
:exit-text="$t('general.backToCircle')"
|
||||
@exit="circleStore.closeLearningContent(props.learningContent)"
|
||||
@next="circleStore.continueFromLearningContent(props.learningContent)"
|
||||
>
|
||||
<div class="content">
|
||||
<div
|
||||
v-if="
|
||||
block.type === 'exercise' ||
|
||||
block.type === 'test' ||
|
||||
block.type === 'learningmodule'
|
||||
"
|
||||
class="h-screen"
|
||||
>
|
||||
<iframe width="100%" height="100%" scrolling="no" :src="block.value.url" />
|
||||
</div>
|
||||
|
||||
<div v-else class="container-medium">
|
||||
<div v-if="block.type === 'video'">
|
||||
<iframe
|
||||
class="mt-8 w-full aspect-video"
|
||||
:src="block.value.url"
|
||||
:title="learningContent.title"
|
||||
frameborder="0"
|
||||
allow="accelerometer; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
<div v-else-if="block.type === 'media_library'" class="mt-4 lg:mt-12">
|
||||
<h1>{{ learningContent.title }}</h1>
|
||||
|
||||
<p class="text-large my-4 lg:my-8">{{ block.value.description }}</p>
|
||||
<router-link
|
||||
:to="`${block.value.url}?back=${route.path}`"
|
||||
class="button btn-primary"
|
||||
>
|
||||
Mediathek öffnen
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="block.type === 'resource' || block.type === 'assignment'"
|
||||
class="mt-4 lg:mt-12"
|
||||
>
|
||||
<p class="text-large my-4">{{ block.value.description }}</p>
|
||||
<div class="resource-text" v-html="block.value.text"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="block.type === 'placeholder'" class="mt-4 lg:mt-12">
|
||||
<p class="text-large my-4">{{ block.value.description }}</p>
|
||||
<h1>{{ learningContent.title }}</h1>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-large my-4">{{ block.value.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</LearningContentContainer>
|
||||
</div>
|
||||
<LearningContentContainer
|
||||
v-if="block"
|
||||
:title="learningContent.title"
|
||||
:next-button-text="$t('learningContent.completeAndContinue')"
|
||||
:exit-text="$t('general.backToCircle')"
|
||||
@exit="circleStore.closeLearningContent(props.learningContent)"
|
||||
@next="circleStore.continueFromLearningContent(props.learningContent)"
|
||||
>
|
||||
<div class="content">
|
||||
<component
|
||||
:is="component"
|
||||
:value="block.value"
|
||||
:content="learningContent"
|
||||
></component>
|
||||
</div>
|
||||
</LearningContentContainer>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
|||
|
|
@ -3,14 +3,14 @@ import * as log from "loglevel";
|
|||
|
||||
log.debug("LeariningContentContainer.vue setup");
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
exitText: string;
|
||||
title: string;
|
||||
nextButtonText: string;
|
||||
showBackButton?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["back", "next", "exit"]);
|
||||
defineEmits(["back", "next", "exit"]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
|||
|
|
@ -126,9 +126,9 @@ const learningSequenceBorderClass = computed(() => {
|
|||
</div>
|
||||
<ItCheckbox
|
||||
v-else
|
||||
:model-value="learningContent.completion_status === 'success'"
|
||||
:on-toggle="() => toggleCompleted(learningContent)"
|
||||
:checked="learningContent.completion_status === 'success'"
|
||||
:data-cy="`${learningContent.slug}-checkbox`"
|
||||
@toggle="toggleCompleted(learningContent)"
|
||||
/>
|
||||
<div class="flex-auto pt-1 sm:pt-0">
|
||||
<span class="flex gap-4 items-center xl:h-10">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { LearningUnit } from "@/types";
|
|||
import * as log from "loglevel";
|
||||
|
||||
import LearningContentContainer from "@/components/learningPath/LearningContentContainer.vue";
|
||||
import { COMPLETION_FAILURE, COMPLETION_SUCCESS } from "@/constants";
|
||||
import { computed, reactive } from "vue";
|
||||
|
||||
log.debug("LearningContent.vue setup");
|
||||
|
|
@ -83,7 +84,7 @@ function handleBack() {
|
|||
'border-2': currentQuestion.completion_status === 'success',
|
||||
}"
|
||||
data-cy="success"
|
||||
@click="circleStore.markCompletion(currentQuestion, 'success')"
|
||||
@click="circleStore.markCompletion(currentQuestion, COMPLETION_SUCCESS)"
|
||||
>
|
||||
<it-icon-smiley-happy class="w-16 h-16 mr-4"></it-icon-smiley-happy>
|
||||
<span class="font-bold text-large">{{ $t("selfEvaluation.yes") }}.</span>
|
||||
|
|
@ -91,8 +92,9 @@ function handleBack() {
|
|||
<button
|
||||
class="flex-1 inline-flex items-center text-left p-4 border"
|
||||
:class="{
|
||||
'border-orange-500': currentQuestion.completion_status === 'fail',
|
||||
'border-2': currentQuestion.completion_status === 'fail',
|
||||
'border-orange-500':
|
||||
currentQuestion.completion_status === COMPLETION_FAILURE,
|
||||
'border-2': currentQuestion.completion_status === COMPLETION_FAILURE,
|
||||
}"
|
||||
data-cy="fail"
|
||||
@click="circleStore.markCompletion(currentQuestion, 'fail')"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
<template>
|
||||
<div class="container-medium">
|
||||
<div class="mt-4 lg:mt-12">
|
||||
<p class="text-large my-4">{{ value.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LearningContent } from "@/types";
|
||||
|
||||
interface Value {
|
||||
description: string;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
value: Value;
|
||||
content: LearningContent;
|
||||
}>();
|
||||
</script>
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<template>
|
||||
<div class="container-medium">
|
||||
<div class="mt-4 lg:mt-12">
|
||||
<p class="text-large my-4">{{ value.description }}</p>
|
||||
<div class="resource-text" v-html="value.text"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LearningContent } from "@/types";
|
||||
|
||||
interface Value {
|
||||
description: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
value: Value;
|
||||
content: LearningContent;
|
||||
}>();
|
||||
</script>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<template>
|
||||
<div class="container-medium">
|
||||
<div class="mt-4 lg:mt-12">
|
||||
<FeedbackForm :page="content" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import FeedbackForm from "@/components/FeedbackForm.vue";
|
||||
import type { LearningContent } from "@/types";
|
||||
|
||||
interface Value {
|
||||
description: string;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
value: Value;
|
||||
content: LearningContent;
|
||||
}>();
|
||||
</script>
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<template>
|
||||
<div class="h-screen">
|
||||
<iframe width="100%" height="100%" scrolling="no" :src="value.url" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LearningContent } from "@/types";
|
||||
|
||||
interface Value {
|
||||
url: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
value: Value;
|
||||
content: LearningContent;
|
||||
}>();
|
||||
</script>
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<template>
|
||||
<div class="container-medium">
|
||||
<div class="mt-4 lg:mt-12">
|
||||
<h1>{{ content.title }}</h1>
|
||||
|
||||
<p class="text-large my-4 lg:my-8">{{ value.description }}</p>
|
||||
<router-link :to="`${value.url}?back=${route.path}`" class="button btn-primary">
|
||||
Mediathek öffnen
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LearningContent } from "@/types";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
interface Value {
|
||||
description: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
value: Value;
|
||||
content: LearningContent;
|
||||
}>();
|
||||
</script>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<template>
|
||||
<div class="container-medium">
|
||||
<div class="mt-4 lg:mt-12">
|
||||
<p class="text-large my-4">{{ value.description }}</p>
|
||||
<h1>{{ content.title }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LearningContent } from "@/types";
|
||||
|
||||
interface Value {
|
||||
description: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
value: Value;
|
||||
content: LearningContent;
|
||||
}>();
|
||||
</script>
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<template>
|
||||
<div class="container-medium">
|
||||
<iframe
|
||||
class="mt-8 w-full aspect-video"
|
||||
:src="value.url"
|
||||
:title="content.title"
|
||||
frameborder="0"
|
||||
allow="accelerometer; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LearningContent } from "@/types";
|
||||
|
||||
interface Value {
|
||||
url: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
value: Value;
|
||||
content: LearningContent;
|
||||
}>();
|
||||
</script>
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import type { RadioItem } from "@/components/learningPath/feedback.types";
|
||||
|
||||
export const YES_NO: RadioItem<boolean>[] = [
|
||||
{
|
||||
name: "Ja",
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
name: "Nein",
|
||||
value: false,
|
||||
},
|
||||
];
|
||||
export const RATINGS: RadioItem<number>[] = [
|
||||
{
|
||||
name: "sehr unzufrieden",
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
name: "unzufrieden",
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
name: "zufrieden",
|
||||
value: 3,
|
||||
},
|
||||
{
|
||||
name: "sehr zufrieden",
|
||||
value: 4,
|
||||
},
|
||||
];
|
||||
export const PERCENTAGES: RadioItem<number>[] = [
|
||||
{
|
||||
name: "20%",
|
||||
value: 20,
|
||||
},
|
||||
{
|
||||
name: "40%",
|
||||
value: 40,
|
||||
},
|
||||
{
|
||||
name: "60%",
|
||||
value: 60,
|
||||
},
|
||||
{
|
||||
name: "80%",
|
||||
value: 80,
|
||||
},
|
||||
{
|
||||
name: "100%",
|
||||
value: 100,
|
||||
},
|
||||
];
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export interface RadioItem<T> {
|
||||
value: T;
|
||||
name: string;
|
||||
}
|
||||
|
|
@ -1,19 +1,34 @@
|
|||
<script setup lang="ts">
|
||||
import log from "loglevel";
|
||||
|
||||
interface Props {
|
||||
modelValue?: boolean;
|
||||
checked?: boolean;
|
||||
disabled?: boolean;
|
||||
onToggle?: () => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
checked: false,
|
||||
disabled: false,
|
||||
onToggle: () => {
|
||||
// do nothing
|
||||
},
|
||||
label: undefined,
|
||||
});
|
||||
|
||||
defineEmits(["update:modelValue"]);
|
||||
const emit = defineEmits(["toggle"]);
|
||||
const toggle = () => {
|
||||
emit("toggle");
|
||||
};
|
||||
const keydown = (e: KeyboardEvent) => {
|
||||
log.debug("keydown", e.type, e.key);
|
||||
if (e.key === " " && !props.disabled) {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
};
|
||||
const input = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
log.debug("input", e.type, target.checked, target.value);
|
||||
emit("toggle");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -22,18 +37,32 @@ defineEmits(["update:modelValue"]);
|
|||
'opacity-50': disabled,
|
||||
'cursor-not-allowed': disabled,
|
||||
}"
|
||||
class="w-8 h-8 cursor-pointer"
|
||||
@click="$emit('update:modelValue', !modelValue)"
|
||||
class="cursor-pointer inline-flex"
|
||||
>
|
||||
<button
|
||||
v-if="modelValue"
|
||||
class="w-8 h-8 flex-none bg-contain bg-[url('/static/icons/icon-checkbox-checked.svg')] hover:bg-[url('/static/icons/icon-checkbox-checked-hover.svg')] disabled:opacity-50 cy-checkbox cy-checkbox-checked"
|
||||
@click.stop="props.onToggle()"
|
||||
></button>
|
||||
<button
|
||||
v-else
|
||||
class="w-8 h-8 flex-none bg-contain bg-[url('/static/icons/icon-checkbox-unchecked.svg')] hover:bg-[url('/static/icons/icon-checkbox-unchecked-hover.svg')] cy-checkbox cy-checkbox-unchecked"
|
||||
@click.stop="props.onToggle()"
|
||||
></button>
|
||||
<label
|
||||
class="block bg-contain disabled:opacity-50 cy-checkbox cy-checkbox-checked bg-no-repeat pl-8 h-8 flex items-center"
|
||||
:class="
|
||||
checked
|
||||
? 'bg-[url(/static/icons/icon-checkbox-checked.svg)] hover:bg-[url(/static/icons/icon-checkbox-checked-hover.svg)]'
|
||||
: 'bg-[url(/static/icons/icon-checkbox-unchecked.svg)] hover:bg-[url(/static/icons/icon-checkbox-unchecked-hover.svg)]'
|
||||
"
|
||||
tabindex="0"
|
||||
@keydown.stop="keydown"
|
||||
>
|
||||
<input
|
||||
ref="checkbox"
|
||||
:checked="checked"
|
||||
:value="checked"
|
||||
:disabled="disabled"
|
||||
class="sr-only"
|
||||
type="checkbox"
|
||||
@keydown="keydown"
|
||||
@input="input"
|
||||
/>
|
||||
|
||||
<span v-if="label" class="ml-4">
|
||||
{{ label }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
<template>
|
||||
<div
|
||||
:model-value="modelValue"
|
||||
class="border p-12"
|
||||
@update:modelValue="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<h2 class="text-5xl mb-12 leading-normal font-bold block">{{ label }}</h2>
|
||||
<div class="flex flex-col justify-start align-items-start justify-items-start">
|
||||
<ItCheckbox
|
||||
v-for="item in items"
|
||||
:key="item.value"
|
||||
:label="item.name"
|
||||
class="mb-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { RadioItem } from "@/components/learningPath/feedback.types";
|
||||
|
||||
import ItCheckbox from "@/components/ui/ItCheckbox.vue";
|
||||
|
||||
defineProps<{
|
||||
modelValue: any;
|
||||
items: RadioItem<any>[];
|
||||
label: string;
|
||||
}>();
|
||||
defineEmits(["update:modelValue"]);
|
||||
</script>
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<template>
|
||||
<RadioGroup
|
||||
:model-value="modelValue"
|
||||
class="border p-12"
|
||||
@update:modelValue="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<RadioGroupLabel class="text-5xl mb-12 leading-normal font-bold block">
|
||||
{{ label }}
|
||||
</RadioGroupLabel>
|
||||
<div class="flex justify-between align-items-center justify-items-center space-x-6">
|
||||
<RadioGroupOption
|
||||
v-for="item in items"
|
||||
:key="item.value"
|
||||
as="template"
|
||||
class="flex-1"
|
||||
:value="item.value"
|
||||
>
|
||||
<div
|
||||
class="py-10 text-xl flex-1 text-center cursor-pointer font-bold ui-checked:bg-sky-500 ui-not-checked:border hover:border-gray-500 hover:bg-gray-200"
|
||||
>
|
||||
<RadioGroupLabel as="span">
|
||||
{{ item.name }}
|
||||
</RadioGroupLabel>
|
||||
</div>
|
||||
</RadioGroupOption>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { RadioItem } from "@/components/learningPath/feedback.types";
|
||||
|
||||
import {
|
||||
RadioGroup,
|
||||
// RadioGroupDescription,
|
||||
RadioGroupLabel,
|
||||
RadioGroupOption,
|
||||
} from "@headlessui/vue";
|
||||
|
||||
defineProps<{
|
||||
modelValue: any;
|
||||
items: RadioItem<any>[];
|
||||
label: string;
|
||||
}>();
|
||||
defineEmits(["update:modelValue"]);
|
||||
</script>
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<template>
|
||||
<div class="border p-12">
|
||||
<h2 class="text-5xl mb-12 leading-normal font-bold block">{{ label }}</h2>
|
||||
<textarea
|
||||
:value="modelValue"
|
||||
class="w-full border-gray-500 h-40"
|
||||
@input="onInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
modelValue: string;
|
||||
label: string;
|
||||
}>();
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
emit("update:modelValue", target.value);
|
||||
};
|
||||
</script>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import type { CourseCompletionStatus } from "@/types";
|
||||
|
||||
export const COMPLETION_SUCCESS: CourseCompletionStatus = "success";
|
||||
export const COMPLETION_FAILURE: CourseCompletionStatus = "fail";
|
||||
export const COMPLETION_UNKNOWN: CourseCompletionStatus = "unknown";
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import type { TypedDocumentNode as DocumentNode } from "@graphql-typed-document-node/core";
|
||||
|
||||
export type FragmentType<TDocumentType extends DocumentNode<any, any>> =
|
||||
TDocumentType extends DocumentNode<infer TType, any>
|
||||
? TType extends { " $fragmentName"?: infer TKey }
|
||||
? TKey extends string
|
||||
? { " $fragmentRefs"?: { [key in TKey]: TType } }
|
||||
: never
|
||||
: never
|
||||
: never;
|
||||
|
||||
// return non-nullable if `fragmentType` is non-nullable
|
||||
export function useFragment<TType>(
|
||||
_documentNode: DocumentNode<TType, any>,
|
||||
fragmentType: FragmentType<DocumentNode<TType, any>>
|
||||
): TType;
|
||||
// return nullable if `fragmentType` is nullable
|
||||
export function useFragment<TType>(
|
||||
_documentNode: DocumentNode<TType, any>,
|
||||
fragmentType: FragmentType<DocumentNode<TType, any>> | null | undefined
|
||||
): TType | null | undefined;
|
||||
// return array of non-nullable if `fragmentType` is array of non-nullable
|
||||
export function useFragment<TType>(
|
||||
_documentNode: DocumentNode<TType, any>,
|
||||
fragmentType: ReadonlyArray<FragmentType<DocumentNode<TType, any>>>
|
||||
): ReadonlyArray<TType>;
|
||||
// return array of nullable if `fragmentType` is array of nullable
|
||||
export function useFragment<TType>(
|
||||
_documentNode: DocumentNode<TType, any>,
|
||||
fragmentType: ReadonlyArray<FragmentType<DocumentNode<TType, any>>> | null | undefined
|
||||
): ReadonlyArray<TType> | null | undefined;
|
||||
export function useFragment<TType>(
|
||||
_documentNode: DocumentNode<TType, any>,
|
||||
fragmentType:
|
||||
| FragmentType<DocumentNode<TType, any>>
|
||||
| ReadonlyArray<FragmentType<DocumentNode<TType, any>>>
|
||||
| null
|
||||
| undefined
|
||||
): TType | ReadonlyArray<TType> | null | undefined {
|
||||
return fragmentType as any;
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
/* eslint-disable */
|
||||
import type { TypedDocumentNode as DocumentNode } from "@graphql-typed-document-node/core";
|
||||
import * as types from "./graphql";
|
||||
|
||||
const documents = {
|
||||
"\n mutation SendFeedbackMutation($input: SendFeedbackInput!) {\n sendFeedback(input: $input) {\n id\n satisfaction\n goalAttainment\n proficiency\n receivedMaterials\n materialsRating\n errors {\n field\n messages\n }\n }\n }\n":
|
||||
types.SendFeedbackMutationDocument,
|
||||
};
|
||||
|
||||
export function graphql(
|
||||
source: "\n mutation SendFeedbackMutation($input: SendFeedbackInput!) {\n sendFeedback(input: $input) {\n id\n satisfaction\n goalAttainment\n proficiency\n receivedMaterials\n materialsRating\n errors {\n field\n messages\n }\n }\n }\n"
|
||||
): typeof documents["\n mutation SendFeedbackMutation($input: SendFeedbackInput!) {\n sendFeedback(input: $input) {\n id\n satisfaction\n goalAttainment\n proficiency\n receivedMaterials\n materialsRating\n errors {\n field\n messages\n }\n }\n }\n"];
|
||||
|
||||
export function graphql(source: string): unknown;
|
||||
export function graphql(source: string) {
|
||||
return (documents as any)[source] ?? {};
|
||||
}
|
||||
|
||||
export type DocumentType<TDocumentNode extends DocumentNode<any, any>> =
|
||||
TDocumentNode extends DocumentNode<infer TType, any> ? TType : never;
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./fragment-masking";
|
||||
export * from "./gql";
|
||||
|
|
@ -18,7 +18,6 @@ function employer() {
|
|||
|
||||
onMounted(async () => {
|
||||
log.debug("DashboardPage mounted");
|
||||
await courseSessionsStore.loadCourseSessionsData();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,13 @@
|
|||
import IconLogout from "@/components/icons/IconLogout.vue";
|
||||
import IconSettings from "@/components/icons/IconSettings.vue";
|
||||
import ItCheckbox from "@/components/ui/ItCheckbox.vue";
|
||||
import ItCheckboxGroup from "@/components/ui/ItCheckboxGroup.vue";
|
||||
import ItDropdown from "@/components/ui/ItDropdown.vue";
|
||||
import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue";
|
||||
import { reactive } from "vue";
|
||||
import ItRadioGroup from "@/components/ui/ItRadioGroup.vue";
|
||||
import ItTextarea from "@/components/ui/ItTextarea.vue";
|
||||
import logger from "loglevel";
|
||||
import { reactive, ref } from "vue";
|
||||
|
||||
const state = reactive({
|
||||
checkboxValue: true,
|
||||
|
|
@ -68,8 +72,33 @@ function colorBgClass(color: string, value: number) {
|
|||
return `bg-${color}-${value}`;
|
||||
}
|
||||
|
||||
const satisfactionValues = [
|
||||
{ name: "Sehr unzufrieden", active: false, value: 1 },
|
||||
{ name: "unzufrieden", active: true, value: 2 },
|
||||
{ name: "zufrieden", active: false, value: 3 },
|
||||
{ name: "Sehr zufrieden", active: false, value: 4 },
|
||||
];
|
||||
const satisfaction = ref(satisfactionValues[1].value);
|
||||
const satisfactionText =
|
||||
'Wie zufrieden bist du mit dem Kurs "Überbetriebliche Kurse" allgemein?';
|
||||
|
||||
const sourceLabel = "Wie bist du auf das Kursangebot aufmerksam geworden?";
|
||||
const sourceValues = ref([]);
|
||||
const sourceItems = [
|
||||
{
|
||||
name: "Internet",
|
||||
value: "I",
|
||||
},
|
||||
{
|
||||
name: "TV",
|
||||
value: "T",
|
||||
},
|
||||
];
|
||||
|
||||
const textValue = ref("abc");
|
||||
|
||||
function log(data: any) {
|
||||
console.log(data);
|
||||
logger.info(data);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -361,7 +390,11 @@ function log(data: any) {
|
|||
|
||||
<h2 class="mt-8 mb-8">Checkbox</h2>
|
||||
|
||||
<ItCheckbox v-model="state.checkboxValue" :disabled="false" class="">
|
||||
<ItCheckbox
|
||||
:checked="state.checkboxValue"
|
||||
:disabled="false"
|
||||
@toggle="state.checkboxValue = !state.checkboxValue"
|
||||
>
|
||||
Label
|
||||
</ItCheckbox>
|
||||
|
||||
|
|
@ -379,6 +412,18 @@ function log(data: any) {
|
|||
Click Me
|
||||
</ItDropdown>
|
||||
</div>
|
||||
<ItTextarea v-model="textValue" label="Hallo Velo" class="mb-8" />
|
||||
<ItCheckboxGroup
|
||||
v-model="sourceValues"
|
||||
:label="sourceLabel"
|
||||
:items="sourceItems"
|
||||
class="mb-8"
|
||||
/>
|
||||
<ItRadioGroup
|
||||
v-model="satisfaction"
|
||||
:label="satisfactionText"
|
||||
:items="satisfactionValues"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import type { LearningPath } from "@/services/learningPath";
|
|||
import { useCockpitStore } from "@/stores/cockpit";
|
||||
import { useCompetenceStore } from "@/stores/competence";
|
||||
import { useLearningPathStore } from "@/stores/learningPath";
|
||||
import * as log from "loglevel";
|
||||
import log from "loglevel";
|
||||
|
||||
const props = defineProps<{
|
||||
courseSlug: string;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import CircleOverview from "@/components/learningPath/CircleOverview.vue";
|
|||
import LearningSequence from "@/components/learningPath/LearningSequence.vue";
|
||||
import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue";
|
||||
import ItModal from "@/components/ui/ItModal.vue";
|
||||
import * as log from "loglevel";
|
||||
import log from "loglevel";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
|
||||
import { useAppStore } from "@/stores/app";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useUserStore } from "@/stores/user";
|
||||
import type { NavigationGuardWithThis, RouteLocationNormalized } from "vue-router";
|
||||
|
||||
export const updateLoggedIn: NavigationGuardWithThis<undefined> = async (_to) => {
|
||||
export const updateLoggedIn: NavigationGuardWithThis<undefined> = async () => {
|
||||
const loggedIn = getCookieValue("loginStatus") === "true";
|
||||
const userStore = useUserStore();
|
||||
|
||||
|
|
@ -11,10 +11,7 @@ export const updateLoggedIn: NavigationGuardWithThis<undefined> = async (_to) =>
|
|||
}
|
||||
};
|
||||
|
||||
export const redirectToLoginIfRequired: NavigationGuardWithThis<undefined> = (
|
||||
to,
|
||||
_from
|
||||
) => {
|
||||
export const redirectToLoginIfRequired: NavigationGuardWithThis<undefined> = (to) => {
|
||||
const userStore = useUserStore();
|
||||
if (loginRequired(to) && !userStore.loggedIn) {
|
||||
return `/login?next=${to.fullPath}`;
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ const router = createRouter({
|
|||
router.beforeEach(updateLoggedIn);
|
||||
router.beforeEach(redirectToLoginIfRequired);
|
||||
|
||||
router.afterEach((to, from) => {
|
||||
router.afterEach(() => {
|
||||
const appStore = useAppStore();
|
||||
appStore.routingFinished = true;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,12 +29,15 @@ export const useCockpitStore = defineStore({
|
|||
actions: {
|
||||
async loadCourseSessionUsers(courseSlug: string, reload = false) {
|
||||
log.debug("loadCockpitData called");
|
||||
const data = await itGetCached(`/api/course/sessions/${courseSlug}/users/`, {
|
||||
reload: reload,
|
||||
});
|
||||
const { users, cockpit_user: cockpitUser } = await itGetCached(
|
||||
`/api/course/sessions/${courseSlug}/users/`,
|
||||
{
|
||||
reload: reload,
|
||||
}
|
||||
);
|
||||
|
||||
this.courseSessionUsers = data.users;
|
||||
this.cockpitSessionUser = data.cockpit_user;
|
||||
this.courseSessionUsers = users;
|
||||
this.cockpitSessionUser = cockpitUser;
|
||||
|
||||
if (this.cockpitSessionUser && this.cockpitSessionUser.circles?.length > 0) {
|
||||
this.selectedCircles = [this.cockpitSessionUser.circles[0].translation_key];
|
||||
|
|
|
|||
|
|
@ -4,53 +4,66 @@ import _ from "lodash";
|
|||
import log from "loglevel";
|
||||
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { useUserStore } from "./user";
|
||||
|
||||
export type CourseSessionsStoreState = {
|
||||
courseSessions: CourseSession[] | undefined;
|
||||
};
|
||||
function loadCourseSessionsData(reload = false) {
|
||||
log.debug("loadCourseSessionsData called");
|
||||
const courseSessions = ref<CourseSession[]>([]);
|
||||
|
||||
export const useCourseSessionsStore = defineStore({
|
||||
id: "courseSessions",
|
||||
state: () => {
|
||||
return {
|
||||
courseSessions: undefined,
|
||||
} as CourseSessionsStoreState;
|
||||
},
|
||||
getters: {
|
||||
courseSessionForRoute: (state) => {
|
||||
const route = useRoute();
|
||||
const routePath = decodeURI(route.path);
|
||||
return state.courseSessions?.find((cs) => {
|
||||
return routePath.startsWith(cs.course_url);
|
||||
});
|
||||
},
|
||||
coursesFromCourseSessions: (state) => {
|
||||
if (state.courseSessions) {
|
||||
return _.uniqBy(state.courseSessions, "course.id");
|
||||
}
|
||||
},
|
||||
hasCockpit() {
|
||||
async function loadAndUpdate() {
|
||||
courseSessions.value = await itGetCached(`/api/course/sessions/`, {
|
||||
reload: reload,
|
||||
});
|
||||
if (!courseSessions.value) {
|
||||
throw `No courseSessionData found for user`;
|
||||
}
|
||||
}
|
||||
|
||||
loadAndUpdate(); // this will be called asynchronously, but does not block
|
||||
|
||||
// returns the empty sessions array at first, then after loading populates the ref
|
||||
return { courseSessions };
|
||||
}
|
||||
|
||||
export const useCourseSessionsStore = defineStore("courseSessions", () => {
|
||||
// using setup function seems cleaner, see https://pinia.vuejs.org/core-concepts/#setup-stores
|
||||
|
||||
// this will become a state variable (like each other `ref()`
|
||||
|
||||
// store should do own setup, we don't want to have each component initialize it
|
||||
// that's why we call the load function in here
|
||||
const { courseSessions } = loadCourseSessionsData();
|
||||
// these will become getters
|
||||
const coursesFromCourseSessions = computed(() =>
|
||||
_.uniqBy(courseSessions.value, "course.id")
|
||||
);
|
||||
const courseSessionForRoute = computed(() => {
|
||||
const route = useRoute();
|
||||
const routePath = decodeURI(route.path);
|
||||
|
||||
return courseSessions.value.find((cs) => {
|
||||
return routePath.startsWith(cs.course_url);
|
||||
});
|
||||
});
|
||||
const hasCockpit = computed(() => {
|
||||
if (courseSessionForRoute.value) {
|
||||
const userStore = useUserStore();
|
||||
return (
|
||||
this.courseSessionForRoute &&
|
||||
(this.courseSessionForRoute as unknown as CourseSession).experts.filter(
|
||||
courseSessionForRoute.value.experts.filter(
|
||||
(expert) => expert.user_id === userStore.id
|
||||
).length > 0
|
||||
);
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
async loadCourseSessionsData(reload = false) {
|
||||
log.debug("loadCourseSessionsData called");
|
||||
this.courseSessions = await itGetCached(`/api/course/sessions/`, {
|
||||
reload: reload,
|
||||
});
|
||||
if (!this.courseSessions) {
|
||||
throw `No courseSessionData found for user`;
|
||||
}
|
||||
return this.courseSessions;
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
return {
|
||||
courseSessions,
|
||||
coursesFromCourseSessions,
|
||||
courseSessionForRoute,
|
||||
hasCockpit,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import * as log from "loglevel";
|
||||
import log from "loglevel";
|
||||
|
||||
import { bustItGetCache, itGetCached, itPost } from "@/fetchHelpers";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { useCourseSessionsStore } from "@/stores/courseSessions";
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
const logoutRedirectUrl = import.meta.env.VITE_LOGOUT_REDIRECT;
|
||||
|
|
@ -60,7 +59,7 @@ export const useUserStore = defineStore({
|
|||
handleLogout() {
|
||||
Object.assign(this, initialUserState);
|
||||
|
||||
itPost("/api/core/logout/", {}).then((data) => {
|
||||
itPost("/api/core/logout/", {}).then(() => {
|
||||
let redirectUrl;
|
||||
|
||||
if (logoutRedirectUrl !== "") {
|
||||
|
|
@ -77,8 +76,9 @@ export const useUserStore = defineStore({
|
|||
this.$state = data;
|
||||
this.loggedIn = true;
|
||||
appStore.userLoaded = true;
|
||||
const courseSessionsStore = useCourseSessionsStore();
|
||||
await courseSessionsStore.loadCourseSessionsData();
|
||||
// todo: why?
|
||||
// const courseSessionsStore = useCourseSessionsStore();
|
||||
// await courseSessionsStore.loadCourseSessionsData();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -30,5 +30,5 @@ export function learningContentTypeData(t: LearningContentType): {
|
|||
return { title: "In Umsetzung", icon: "it-icon-lc-document" };
|
||||
}
|
||||
|
||||
return assertUnreachable(t);
|
||||
return assertUnreachable();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
export function assertUnreachable(x: never): never {
|
||||
export function assertUnreachable(): never {
|
||||
throw new Error("Didn't expect to get here");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,5 +49,9 @@ module.exports = {
|
|||
"bg-handlungsfelder-overview",
|
||||
"bg-lernmedien-overview",
|
||||
],
|
||||
plugins: [require("@tailwindcss/typography"), require("@tailwindcss/forms")],
|
||||
plugins: [
|
||||
require("@tailwindcss/typography"),
|
||||
require("@tailwindcss/forms"),
|
||||
require("@headlessui/tailwindcss"),
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ LOCAL_APPS = [
|
|||
"vbv_lernwelt.learnpath",
|
||||
"vbv_lernwelt.competence",
|
||||
"vbv_lernwelt.media_library",
|
||||
"vbv_lernwelt.feedback",
|
||||
]
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
|
||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ from vbv_lernwelt.course.views import (
|
|||
request_course_completion,
|
||||
request_course_completion_for_user,
|
||||
)
|
||||
|
||||
from vbv_lernwelt.feedback.views import get_name
|
||||
from wagtail import urls as wagtail_urls
|
||||
from wagtail.admin import urls as wagtailadmin_urls
|
||||
from wagtail.documents import urls as wagtaildocs_urls
|
||||
|
|
@ -82,6 +84,7 @@ urlpatterns = [
|
|||
raise_example_error), ),
|
||||
path("server/checkratelimit/", check_rate_limit),
|
||||
path("server/", include(grapple_urls)),
|
||||
path(r"your-name/", get_name)
|
||||
]
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
schema {
|
||||
query: Query
|
||||
mutation: Mutation
|
||||
}
|
||||
|
||||
type BlockQuoteBlock implements StreamFieldInterface {
|
||||
|
|
@ -275,6 +276,11 @@ type EmbedBlock implements StreamFieldInterface {
|
|||
rawEmbed: JSONString
|
||||
}
|
||||
|
||||
type ErrorType {
|
||||
field: String!
|
||||
messages: [String!]!
|
||||
}
|
||||
|
||||
type FloatBlock implements StreamFieldInterface {
|
||||
id: String
|
||||
blockType: String!
|
||||
|
|
@ -598,6 +604,10 @@ type MediaLibraryPage implements PageInterface {
|
|||
ancestors(limit: PositiveInt, offset: PositiveInt, order: String, searchQuery: String, id: ID): [PageInterface!]!
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
sendFeedback(input: SendFeedbackInput!): SendFeedbackPayload
|
||||
}
|
||||
|
||||
type Page implements PageInterface {
|
||||
id: ID
|
||||
path: String!
|
||||
|
|
@ -779,6 +789,40 @@ type SecurityRequestResponseLog {
|
|||
id: ID
|
||||
}
|
||||
|
||||
input SendFeedbackInput {
|
||||
id: Int
|
||||
page: String!
|
||||
satisfaction: Int
|
||||
goalAttainment: Int
|
||||
proficiency: Int
|
||||
receivedMaterials: Boolean
|
||||
materialsRating: Int
|
||||
instructorCompetence: Int
|
||||
instructorRespect: Int
|
||||
instructorOpenFeedback: String
|
||||
wouldRecommend: Boolean
|
||||
coursePositiveFeedback: String
|
||||
courseNegativeFeedback: String
|
||||
clientMutationId: String
|
||||
}
|
||||
|
||||
type SendFeedbackPayload {
|
||||
id: Int
|
||||
satisfaction: Int
|
||||
goalAttainment: Int
|
||||
proficiency: Int
|
||||
receivedMaterials: Boolean
|
||||
materialsRating: Int
|
||||
instructorCompetence: Int
|
||||
instructorRespect: Int
|
||||
instructorOpenFeedback: String
|
||||
wouldRecommend: Boolean
|
||||
coursePositiveFeedback: String
|
||||
courseNegativeFeedback: String
|
||||
errors: [ErrorType]
|
||||
clientMutationId: String
|
||||
}
|
||||
|
||||
type SiteObjectType {
|
||||
id: ID!
|
||||
hostname: String!
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
from django import forms
|
||||
|
||||
from vbv_lernwelt.feedback.models import FeedbackResponse
|
||||
|
||||
|
||||
class FeedbackForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = FeedbackResponse
|
||||
fields = "__all__"
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import graphene
|
||||
from graphene_django.rest_framework.mutation import SerializerMutation
|
||||
|
||||
from vbv_lernwelt.feedback.serializers import FeedbackResponseSerializer
|
||||
|
||||
|
||||
class SendFeedback(SerializerMutation):
|
||||
class Meta:
|
||||
serializer_class = FeedbackResponseSerializer
|
||||
model_operations = ["create"]
|
||||
|
||||
|
||||
class Mutation(object):
|
||||
send_feedback = SendFeedback.Field()
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
from graphene_django import DjangoObjectType
|
||||
|
||||
from vbv_lernwelt.feedback.models import Feedback
|
||||
|
||||
|
||||
# class FeedbackType(DjangoObjectType):
|
||||
# class Meta:
|
||||
# model = Feedback
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
# Generated by Django 3.2.13 on 2022-12-27 14:58
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import vbv_lernwelt.feedback.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("learnpath", "0009_alter_learningcontent_contents"),
|
||||
("course", "0007_coursesessionuser_role"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="FeedbackResponse",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"satisfaction",
|
||||
vbv_lernwelt.feedback.models.FeedbackIntegerField(
|
||||
null=True,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(1),
|
||||
django.core.validators.MaxValueValidator(4),
|
||||
django.core.validators.MinValueValidator(1),
|
||||
django.core.validators.MaxValueValidator(4),
|
||||
],
|
||||
),
|
||||
),
|
||||
(
|
||||
"goal_attainment",
|
||||
vbv_lernwelt.feedback.models.FeedbackIntegerField(
|
||||
null=True,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(1),
|
||||
django.core.validators.MaxValueValidator(4),
|
||||
django.core.validators.MinValueValidator(1),
|
||||
django.core.validators.MaxValueValidator(4),
|
||||
],
|
||||
),
|
||||
),
|
||||
("proficiency", models.IntegerField(null=True)),
|
||||
("received_materials", models.BooleanField(null=True)),
|
||||
(
|
||||
"materials_rating",
|
||||
vbv_lernwelt.feedback.models.FeedbackIntegerField(
|
||||
null=True,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(1),
|
||||
django.core.validators.MaxValueValidator(4),
|
||||
django.core.validators.MinValueValidator(1),
|
||||
django.core.validators.MaxValueValidator(4),
|
||||
],
|
||||
),
|
||||
),
|
||||
(
|
||||
"instructor_competence",
|
||||
vbv_lernwelt.feedback.models.FeedbackIntegerField(
|
||||
null=True,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(1),
|
||||
django.core.validators.MaxValueValidator(4),
|
||||
django.core.validators.MinValueValidator(1),
|
||||
django.core.validators.MaxValueValidator(4),
|
||||
],
|
||||
),
|
||||
),
|
||||
(
|
||||
"instructor_respect",
|
||||
vbv_lernwelt.feedback.models.FeedbackIntegerField(
|
||||
null=True,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(1),
|
||||
django.core.validators.MaxValueValidator(4),
|
||||
django.core.validators.MinValueValidator(1),
|
||||
django.core.validators.MaxValueValidator(4),
|
||||
],
|
||||
),
|
||||
),
|
||||
("instructor_open_feedback", models.TextField(blank=True)),
|
||||
("would_recommend", models.BooleanField(null=True)),
|
||||
("course_positive_feedback", models.TextField(blank=True)),
|
||||
("course_negative_feedback", models.TextField(blank=True)),
|
||||
(
|
||||
"circle",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to="learnpath.circle",
|
||||
),
|
||||
),
|
||||
(
|
||||
"course_session",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to="course.coursesession",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class FeedbackIntegerField(models.IntegerField):
|
||||
def __init__(self, **kwargs):
|
||||
validators = kwargs.pop("validators", [])
|
||||
nullable = kwargs.pop("null", True)
|
||||
super().__init__(
|
||||
validators=validators + [MinValueValidator(1), MaxValueValidator(4)],
|
||||
null=nullable,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
|
||||
class FeedbackResponse(models.Model):
|
||||
class DiscoveredChoices(models.TextChoices):
|
||||
INTERNET = "I", _("Internet")
|
||||
LEAFLET = "L", _("Leaflet")
|
||||
NEWSPAPER = "N", _("Newspaper")
|
||||
RECOMMENDATION = "R", _("Personal recommendation")
|
||||
EVENT = "E", _("Public event")
|
||||
OTHER = "O", _("Other")
|
||||
|
||||
class RatingChoices(models.IntegerChoices):
|
||||
ONE = 1, "1"
|
||||
TWO = 2, "2"
|
||||
THREE = 3, "3"
|
||||
FOUR = 4, "4"
|
||||
|
||||
class PercentageChoices(models.IntegerChoices):
|
||||
TWENTY = 20, "20%"
|
||||
FOURTY = 40, "40%"
|
||||
SIXTY = 60, "60%"
|
||||
EIGHTY = 80, "80%"
|
||||
HUNDRED = 100, "100%"
|
||||
|
||||
satisfaction = FeedbackIntegerField()
|
||||
goal_attainment = FeedbackIntegerField()
|
||||
proficiency = models.IntegerField(null=True)
|
||||
received_materials = models.BooleanField(null=True)
|
||||
materials_rating = FeedbackIntegerField()
|
||||
instructor_competence = FeedbackIntegerField()
|
||||
instructor_respect = FeedbackIntegerField()
|
||||
instructor_open_feedback = models.TextField(blank=True)
|
||||
would_recommend = models.BooleanField(null=True)
|
||||
course_positive_feedback = models.TextField(blank=True)
|
||||
course_negative_feedback = models.TextField(blank=True)
|
||||
|
||||
circle = models.ForeignKey("learnpath.Circle", models.PROTECT)
|
||||
course_session = models.ForeignKey("course.CourseSession", models.PROTECT)
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import structlog
|
||||
from rest_framework import serializers
|
||||
from wagtail.models import Page
|
||||
|
||||
from vbv_lernwelt.course.models import CourseSession
|
||||
from vbv_lernwelt.feedback.models import FeedbackResponse
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class FeedbackResponseSerializer(serializers.ModelSerializer):
|
||||
page = serializers.CharField(write_only=True)
|
||||
course_session = serializers.CharField(write_only=True)
|
||||
|
||||
class Meta:
|
||||
model = FeedbackResponse
|
||||
exclude = ["circle"]
|
||||
# extra_kwargs = {"course", {"read_only": True}}
|
||||
|
||||
def create(self, validated_data):
|
||||
logger.info("creating feedback")
|
||||
page_key = validated_data.pop("page")
|
||||
course_session_id = validated_data.pop("course_session")
|
||||
|
||||
learning_content = Page.objects.get(
|
||||
translation_key=page_key, locale__language_code="de-CH"
|
||||
)
|
||||
circle = learning_content.get_parent().specific
|
||||
course_session = CourseSession.objects.get(id=course_session_id)
|
||||
return FeedbackResponse.objects.create(
|
||||
**validated_data, circle=circle, course_session=course_session
|
||||
)
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import render
|
||||
|
||||
from .forms import FeedbackForm
|
||||
|
||||
|
||||
def get_name(request):
|
||||
if request.method == "POST":
|
||||
form = FeedbackForm(request.POST)
|
||||
if form.is_valid():
|
||||
return HttpResponseRedirect("/thanks/")
|
||||
else:
|
||||
form = FeedbackForm()
|
||||
|
||||
return render(request, "feedback/name.html", {"form": form})
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
from wagtail.core import hooks
|
||||
|
||||
from .graphql.mutations import Mutation
|
||||
|
||||
|
||||
@hooks.register("register_schema_mutation")
|
||||
def register_feedback_mutations(mutation_mixins):
|
||||
mutation_mixins.append(Mutation)
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
# Generated by Django 3.2.13 on 2022-12-08 09:55
|
||||
|
||||
import wagtail.blocks
|
||||
import wagtail.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("learnpath", "0008_alter_learningcontent_contents"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="learningcontent",
|
||||
name="contents",
|
||||
field=wagtail.fields.StreamField(
|
||||
[
|
||||
(
|
||||
"video",
|
||||
wagtail.blocks.StructBlock(
|
||||
[
|
||||
("description", wagtail.blocks.TextBlock()),
|
||||
("url", wagtail.blocks.TextBlock()),
|
||||
]
|
||||
),
|
||||
),
|
||||
(
|
||||
"resource",
|
||||
wagtail.blocks.StructBlock(
|
||||
[
|
||||
("description", wagtail.blocks.TextBlock()),
|
||||
("url", wagtail.blocks.TextBlock()),
|
||||
("text", wagtail.blocks.RichTextBlock(required=False)),
|
||||
]
|
||||
),
|
||||
),
|
||||
(
|
||||
"exercise",
|
||||
wagtail.blocks.StructBlock(
|
||||
[
|
||||
("description", wagtail.blocks.TextBlock()),
|
||||
("url", wagtail.blocks.TextBlock()),
|
||||
]
|
||||
),
|
||||
),
|
||||
(
|
||||
"learningmodule",
|
||||
wagtail.blocks.StructBlock(
|
||||
[
|
||||
("description", wagtail.blocks.TextBlock()),
|
||||
("url", wagtail.blocks.TextBlock()),
|
||||
]
|
||||
),
|
||||
),
|
||||
(
|
||||
"online_training",
|
||||
wagtail.blocks.StructBlock(
|
||||
[
|
||||
("description", wagtail.blocks.TextBlock()),
|
||||
("url", wagtail.blocks.TextBlock()),
|
||||
]
|
||||
),
|
||||
),
|
||||
(
|
||||
"media_library",
|
||||
wagtail.blocks.StructBlock(
|
||||
[
|
||||
("description", wagtail.blocks.TextBlock()),
|
||||
("url", wagtail.blocks.TextBlock()),
|
||||
]
|
||||
),
|
||||
),
|
||||
(
|
||||
"document",
|
||||
wagtail.blocks.StructBlock(
|
||||
[
|
||||
("description", wagtail.blocks.TextBlock()),
|
||||
("url", wagtail.blocks.TextBlock()),
|
||||
]
|
||||
),
|
||||
),
|
||||
(
|
||||
"test",
|
||||
wagtail.blocks.StructBlock(
|
||||
[
|
||||
("description", wagtail.blocks.TextBlock()),
|
||||
("url", wagtail.blocks.TextBlock()),
|
||||
]
|
||||
),
|
||||
),
|
||||
(
|
||||
"book",
|
||||
wagtail.blocks.StructBlock(
|
||||
[
|
||||
("description", wagtail.blocks.TextBlock()),
|
||||
("url", wagtail.blocks.TextBlock()),
|
||||
]
|
||||
),
|
||||
),
|
||||
(
|
||||
"assignment",
|
||||
wagtail.blocks.StructBlock(
|
||||
[
|
||||
("description", wagtail.blocks.TextBlock()),
|
||||
("url", wagtail.blocks.TextBlock()),
|
||||
("text", wagtail.blocks.RichTextBlock(required=False)),
|
||||
]
|
||||
),
|
||||
),
|
||||
(
|
||||
"placeholder",
|
||||
wagtail.blocks.StructBlock(
|
||||
[
|
||||
("description", wagtail.blocks.TextBlock()),
|
||||
("url", wagtail.blocks.TextBlock()),
|
||||
]
|
||||
),
|
||||
),
|
||||
("feedback", wagtail.blocks.StructBlock([])),
|
||||
],
|
||||
use_json_field=None,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -14,6 +14,7 @@ from vbv_lernwelt.learnpath.models_learning_unit_content import (
|
|||
BookBlock,
|
||||
DocumentBlock,
|
||||
ExerciseBlock,
|
||||
FeedbackBlock,
|
||||
LearningModuleBlock,
|
||||
MediaLibraryBlock,
|
||||
OnlineTrainingBlock,
|
||||
|
|
@ -267,6 +268,7 @@ class LearningContent(CourseBasePage):
|
|||
("book", BookBlock()),
|
||||
("assignment", AssignmentBlock()),
|
||||
("placeholder", PlaceholderBlock()),
|
||||
("feedback", FeedbackBlock()),
|
||||
]
|
||||
|
||||
contents = StreamField(
|
||||
|
|
|
|||
|
|
@ -90,3 +90,8 @@ class PlaceholderBlock(blocks.StructBlock):
|
|||
|
||||
class Meta:
|
||||
icon = "media"
|
||||
|
||||
|
||||
class FeedbackBlock(blocks.StructBlock):
|
||||
class Meta:
|
||||
icon = "media"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
<form action="/your-name/" method="post">
|
||||
{% csrf_token %}
|
||||
{{ form }}
|
||||
<input type="submit" value="submit" />
|
||||
</form>
|
||||
Loading…
Reference in New Issue