Merged in feature/feedback-form-2022-12-29 (pull request #18)

Feature/feedback form 2022 12 29
This commit is contained in:
Ramon Wenger 2023-01-09 15:12:44 +00:00
commit 6b343805a0
58 changed files with 11502 additions and 337 deletions

17
client/codegen.ts Normal file
View File

@ -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;

8462
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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";

View File

@ -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>

View File

@ -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();
}
});

View File

@ -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>

View File

@ -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>

View File

@ -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">

View File

@ -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')"

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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,
},
];

View File

@ -0,0 +1,4 @@
export interface RadioItem<T> {
value: T;
name: string;
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

5
client/src/constants.ts Normal file
View File

@ -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";

View File

@ -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;
}

20
client/src/gql/gql.ts Normal file
View File

@ -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;

1931
client/src/gql/graphql.ts Normal file

File diff suppressed because it is too large Load Diff

2
client/src/gql/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from "./fragment-masking";
export * from "./gql";

View File

@ -18,7 +18,6 @@ function employer() {
onMounted(async () => {
log.debug("DashboardPage mounted");
await courseSessionsStore.loadCourseSessionsData();
});
</script>

View File

@ -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>

View File

@ -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;

View File

@ -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";

View File

@ -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}`;

View File

@ -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;
});

View File

@ -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];

View File

@ -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,
};
});

View File

@ -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();
},
},
});

View File

@ -30,5 +30,5 @@ export function learningContentTypeData(t: LearningContentType): {
return { title: "In Umsetzung", icon: "it-icon-lc-document" };
}
return assertUnreachable(t);
return assertUnreachable();
}

View File

@ -1,3 +1,3 @@
export function assertUnreachable(x: never): never {
export function assertUnreachable(): never {
throw new Error("Didn't expect to get here");
}

View File

@ -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"),
],
};

View File

@ -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

View File

@ -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)

View File

@ -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!

View File

View File

View File

@ -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__"

View File

@ -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()

View File

@ -0,0 +1,8 @@
from graphene_django import DjangoObjectType
from vbv_lernwelt.feedback.models import Feedback
# class FeedbackType(DjangoObjectType):
# class Meta:
# model = Feedback

View File

@ -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",
),
),
],
),
]

View File

@ -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)

View File

@ -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
)

View File

@ -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})

View File

@ -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)

View File

@ -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,
),
),
]

View File

@ -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(

View File

@ -90,3 +90,8 @@ class PlaceholderBlock(blocks.StructBlock):
class Meta:
icon = "media"
class FeedbackBlock(blocks.StructBlock):
class Meta:
icon = "media"

View File

@ -0,0 +1,5 @@
<form action="/your-name/" method="post">
{% csrf_token %}
{{ form }}
<input type="submit" value="submit" />
</form>