Add previous-/next- attributes to LearningContents

This commit is contained in:
Daniel Egger 2022-06-21 17:15:35 +02:00
parent b893dcbcc8
commit 2c36ea9242
9 changed files with 164 additions and 62 deletions

View File

@ -1,11 +1,25 @@
<script setup lang="ts"> <script setup lang="ts">
import * as log from 'loglevel';
import type {Circle, LearningContent} from '@/types'; import type {Circle, LearningContent} from '@/types';
import {computed} from 'vue';
import {useCircleStore} from '@/stores/circle';
log.debug('LearningContent.vue setup');
const props = defineProps<{ const props = defineProps<{
circleData: Circle, circleData: Circle,
learningContent: LearningContent, learningContent: LearningContent,
}>() }>()
const block = computed(() => {
if(props.learningContent) {
return props.learningContent.contents[0];
}
})
const circleStore = useCircleStore();
</script> </script>
<template> <template>
@ -21,7 +35,7 @@ const props = defineProps<{
<button <button
type="button" type="button"
class="btn-text inline-flex items-center px-3 py-2 font-normal" class="btn-text inline-flex items-center px-3 py-2 font-normal"
@click="$emit('close')" @click="circleStore.closeLearningContent()"
> >
<it-icon-arrow-left class="-ml-1 mr-1 h-5 w-5"></it-icon-arrow-left> <it-icon-arrow-left class="-ml-1 mr-1 h-5 w-5"></it-icon-arrow-left>
<span class="">zurück zum Circle</span> <span class="">zurück zum Circle</span>
@ -29,21 +43,36 @@ const props = defineProps<{
<h1 class="text-xl">{{ learningContent.title }}</h1> <h1 class="text-xl">{{ learningContent.title }}</h1>
<button type="button" class="btn-blue">Abschliessen und weiter</button> <button
type="button"
class="btn-blue"
@click="circleStore.continueToNextLearningContent()"
>
Abschliessen und weiter
</button>
</nav> </nav>
<div class="mx-auto max-w-5xl px-8 py-4"> <div class="mx-auto max-w-5xl px-8 py-4">
<p>{{ learningContent.contents[0].value.description }}</p> <p>{{ block.value.description }}</p>
<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-if="block.type === 'podcast'"
>
<iframe width="100%" height="300" scrolling="no" frameborder="no" allow="" :src="block.value.url"></iframe>
</div>
<iframe
class="mt-8 w-full aspect-video"
:src="learningContent.contents[0].value.url"
:title="learningContent.title"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen>
</iframe>
</div> </div>
</div> </div>

View File

@ -2,6 +2,7 @@
import ItCheckbox from '@/components/ui/ItCheckbox.vue'; import ItCheckbox from '@/components/ui/ItCheckbox.vue';
import type {LearningContent, LearningSequence} from '@/types'; import type {LearningContent, LearningSequence} from '@/types';
import {useCircleStore} from '@/stores/circle'; import {useCircleStore} from '@/stores/circle';
import {computed} from 'vue';
const props = defineProps<{ const props = defineProps<{
learningSequence: LearningSequence learningSequence: LearningSequence
@ -17,6 +18,16 @@ function toggleCompleted(learningContent: LearningContent) {
circleStore.toggleCompleted(learningContent, !learningContent.completed); circleStore.toggleCompleted(learningContent, !learningContent.completed);
} }
const someFinished = computed(() => {
if (props.learningSequence) {
return circleStore.flatLearningContents.filter((lc) => {
return lc.completed && lc.parentLearningSequence?.translation_key === props.learningSequence.translation_key;
}).length > 0;
}
return false;
})
</script> </script>
<template> <template>
@ -29,7 +40,13 @@ function toggleCompleted(learningContent: LearningContent) {
<div>{{ learningSequence.minutes }} Minuten</div> <div>{{ learningSequence.minutes }} Minuten</div>
</div> </div>
<div class="bg-white px-4 border border-gray-500 border-l-4 border-l-sky-500"> <div
class="bg-white px-4 border border-gray-500 border-l-4"
:class="{
'border-l-sky-500': someFinished,
'border-l-gray-500': !someFinished,
}"
>
<div <div
v-for="learningUnit in learningSequence.learningUnits" v-for="learningUnit in learningSequence.learningUnits"
class="py-3" class="py-3"
@ -47,7 +64,7 @@ function toggleCompleted(learningContent: LearningContent) {
:modelValue="learningContent.completed" :modelValue="learningContent.completed"
@click="toggleCompleted(learningContent)" @click="toggleCompleted(learningContent)"
> >
<span @click.stop="$emit('clickContent', learningContent)">{{ learningContent.contents[0].type }}: {{ learningContent.title }}</span> <span @click.stop="circleStore.openLearningContent(learningContent)">{{ learningContent.contents[0].type }}: {{ learningContent.title }}</span>
</ItCheckbox> </ItCheckbox>
</div> </div>

View File

@ -120,7 +120,11 @@ describe('circleService.parseLearningSequences', () => {
} as WagtailCircle; } as WagtailCircle;
const learningSequences = parseLearningSequences(input.children); const learningSequences = parseLearningSequences(input.children);
expect(learningSequences.length).toBe(1);
console.log(learningSequences[0].learningUnits[0].learningContents[0]);
expect(learningSequences.length).toBe(4); expect(
learningSequences[0].learningUnits[1].learningContents[0].previousLearningContent.translation_key
).toEqual(learningSequences[0].learningUnits[0].learningContents[1].translation_key);
}) })
}) })

View File

@ -1,9 +1,9 @@
import type {CircleChild, LearningSequence, LearningUnit} from '@/types'; import type {CircleChild, LearningContent, LearningSequence, LearningUnit} from '@/types';
function createEmptyLearningUnit(): LearningUnit { function createEmptyLearningUnit(): LearningUnit {
return { return {
id: -1, id: 0,
title: '', title: '',
slug: '', slug: '',
translation_key: '', translation_key: '',
@ -15,12 +15,13 @@ function createEmptyLearningUnit(): LearningUnit {
} }
export function parseLearningSequences (children: CircleChild[]): LearningSequence[] { export function parseLearningSequences (children: CircleChild[]): LearningSequence[] {
let learningSequence:LearningSequence|null = null; let learningSequence:LearningSequence | null = null;
let learningUnit:LearningUnit|null = null; let learningUnit:LearningUnit | null = null;
let learningContent:LearningContent | null = null;
let previousLearningContent: LearningContent | null = null;
const result:LearningSequence[] = []; const result:LearningSequence[] = [];
children.forEach((child) => { children.forEach((child) => {
// FIXME add error detection if the data does not conform to expectations
if (child.type === 'learnpath.LearningSequence') { if (child.type === 'learnpath.LearningSequence') {
if (learningSequence) { if (learningSequence) {
if (learningUnit) { if (learningUnit) {
@ -46,6 +47,17 @@ export function parseLearningSequences (children: CircleChild[]): LearningSequen
if (learningUnit === null) { if (learningUnit === null) {
throw new Error('LearningContent found before LearningUnit'); throw new Error('LearningContent found before LearningUnit');
} }
previousLearningContent = learningContent;
learningContent = Object.assign(child, {
parentLearningSequence: learningSequence,
parentLearningUnit: learningUnit,
previousLearningContent: previousLearningContent,
});
if (previousLearningContent) {
previousLearningContent.nextLearningContent = learningContent;
}
learningUnit.learningContents.push(child); learningUnit.learningContents.push(child);
} }

View File

@ -1,3 +1,5 @@
import * as log from 'loglevel';
import {defineStore} from 'pinia' import {defineStore} from 'pinia'
import type {Circle, LearningContent} from '@/types' import type {Circle, LearningContent} from '@/types'
@ -8,6 +10,7 @@ export type CircleStoreState = {
circleData: Circle; circleData: Circle;
completionData: any; completionData: any;
currentLearningContent: LearningContent | undefined; currentLearningContent: LearningContent | undefined;
page: 'INDEX' | 'OVERVIEW' | 'LEARNING_CONTENT',
} }
export const useCircleStore = defineStore({ export const useCircleStore = defineStore({
@ -17,9 +20,22 @@ export const useCircleStore = defineStore({
circleData: {}, circleData: {},
completionData: {}, completionData: {},
currentLearningContent: undefined, currentLearningContent: undefined,
page: 'INDEX',
} as CircleStoreState; } as CircleStoreState;
}, },
getters: {}, getters: {
flatLearningContents: (state) => {
const result:LearningContent[] = [];
state.circleData.learningSequences.forEach((learningSequence) => {
learningSequence.learningUnits.forEach((learningUnit) => {
learningUnit.learningContents.forEach((learningContent) => {
result.push(learningContent);
});
});
});
return result;
},
},
actions: { actions: {
async loadCircle(slug: string) { async loadCircle(slug: string) {
try { try {
@ -28,11 +44,8 @@ export const useCircleStore = defineStore({
this.circleData.learningSequences = parseLearningSequences(this.circleData.children); this.circleData.learningSequences = parseLearningSequences(this.circleData.children);
this.completionData = await itGet(`/api/completion/circle/${this.circleData.translation_key}/`); this.completionData = await itGet(`/api/completion/circle/${this.circleData.translation_key}/`);
this.parseCompletionData(); this.parseCompletionData();
// TODO set currentLearningContent for testing
this.setCurrentLearningContent(this.circleData.learningSequences[0].learningUnits[0].learningContents[0]);
} catch (error) { } catch (error) {
console.warn(error); log.error(error);
return error return error
} }
}, },
@ -45,23 +58,34 @@ export const useCircleStore = defineStore({
}); });
this.parseCompletionData(); this.parseCompletionData();
} catch (error) { } catch (error) {
console.warn(error); log.error(error);
return error return error
} }
}, },
parseCompletionData() { parseCompletionData() {
this.circleData.learningSequences.forEach((learningSequence) => { this.flatLearningContents.forEach((learningContent) => {
learningSequence.learningUnits.forEach((learningUnit) => { learningContent.completed = this.completionData.completed_learning_contents.findIndex((e) => {
learningUnit.learningContents.forEach((learningContent) => { return e.learning_content_key === learningContent.translation_key && e.completed;
learningContent.completed = this.completionData.completed_learning_contents.findIndex((e) => { }) >= 0;
return e.learning_content_key === learningContent.translation_key && e.completed;
}) >= 0;
});
});
}); });
}, },
setCurrentLearningContent(learningContent: LearningContent) { openLearningContent(learningContent: LearningContent) {
this.currentLearningContent = learningContent; this.currentLearningContent = learningContent;
this.page = 'LEARNING_CONTENT';
},
closeLearningContent() {
this.currentLearningContent = undefined;
this.page = 'INDEX';
},
continueToNextLearningContent() {
if (this.currentLearningContent) {
this.toggleCompleted(this.currentLearningContent, true);
if (this.currentLearningContent.nextLearningContent) {
this.openLearningContent(this.currentLearningContent.nextLearningContent);
}
} else {
log.error('currentLearningContent is undefined');
}
} }
} }
}) })

View File

@ -1,8 +1,34 @@
export interface LearningContentBlock { export interface LearningContentBlock {
type: 'video' | 'web-based-training' | 'podcast' | 'competence' | 'exercise' | 'document' | 'knowledge'; type: 'web-based-training' | 'competence' | 'exercise' | 'knowledge';
value: { value: {
description: string; description: string;
url?: string; },
id: string;
}
export interface VideoBlock {
type: 'video';
value: {
description: string;
url: string;
},
id: string;
}
export interface PodcastBlock {
type: 'podcast';
value: {
description: string;
url: string;
},
id: string;
}
export interface DocumentBlock {
type: 'document';
value: {
description: string;
url: string;
}, },
id: string; id: string;
} }
@ -29,8 +55,12 @@ export interface LearningWagtailPage {
export interface LearningContent extends LearningWagtailPage { export interface LearningContent extends LearningWagtailPage {
type: 'learnpath.LearningContent'; type: 'learnpath.LearningContent';
minutes: number; minutes: number;
contents: LearningContentBlock[]; contents: (LearningContentBlock | VideoBlock | PodcastBlock | DocumentBlock)[];
completed?: boolean; completed?: boolean;
parentLearningSequence?: LearningSequence;
parentLearningUnit?: LearningUnit;
nextLearningContent?: LearningContent;
previousLearningContent?: LearningContent;
} }
export interface LearningUnit extends LearningWagtailPage { export interface LearningUnit extends LearningWagtailPage {

View File

@ -6,48 +6,32 @@ import LearningSequence from '@/components/circle/LearningSequence.vue';
import CircleOverview from '@/components/circle/CircleOverview.vue'; import CircleOverview from '@/components/circle/CircleOverview.vue';
import LearningContent from '@/components/circle/LearningContent.vue'; import LearningContent from '@/components/circle/LearningContent.vue';
import {onMounted, reactive} from 'vue' import {onMounted} from 'vue'
import {useCircleStore} from '@/stores/circle'; import {useCircleStore} from '@/stores/circle';
const props = defineProps<{ const props = defineProps<{
circleSlug: string circleSlug: string
}>() }>()
interface State {
page: 'INDEX' | 'OVERVIEW' | 'LEARNING_CONTENT',
}
const circleStore = useCircleStore(); const circleStore = useCircleStore();
const state:State = reactive({
page: 'LEARNING_CONTENT',
});
onMounted(async () => { onMounted(async () => {
log.info('CircleView.vue mounted'); log.info('CircleView.vue mounted');
await circleStore.loadCircle(props.circleSlug); await circleStore.loadCircle(props.circleSlug);
}); });
function openLearningContent(learningContent: LearningContent) {
log.info('openLearningContent', learningContent);
circleStore.setCurrentLearningContent(learningContent);
state.page = 'LEARNING_CONTENT';
}
</script> </script>
<template> <template>
<Transition> <Transition>
<div v-if="state.page === 'OVERVIEW'"> <div v-if="circleStore.page === 'OVERVIEW'">
<CircleOverview :circle-data="circleStore.circleData" @close="state.page = 'INDEX'"/> <CircleOverview :circle-data="circleStore.circleData" @close="circleStore.page = 'INDEX'"/>
</div> </div>
<div v-else-if="state.page === 'LEARNING_CONTENT'"> <div v-else-if="circleStore.page === 'LEARNING_CONTENT'">
<LearningContent <LearningContent
:circle-data="circleStore.circleData" :circle-data="circleStore.circleData"
:learning-content="circleStore.currentLearningContent" :learning-content="circleStore.currentLearningContent"
@close="state.page = 'INDEX'" :key="circleStore.currentLearningContent.translation_key"
/> />
</div> </div>
<div v-else> <div v-else>
@ -70,7 +54,7 @@ function openLearningContent(learningContent: LearningContent) {
{{ circleStore.circleData.description }} {{ circleStore.circleData.description }}
</div> </div>
<button class="btn-primary mt-4" @click="state.showOverview = true">Erfahre mehr dazu</button> <button class="btn-primary mt-4" @click="circleStore.page = 'OVERVIEW'">Erfahre mehr dazu</button>
</div> </div>
<div class="expert border border-gray-500 mt-8 p-6"> <div class="expert border border-gray-500 mt-8 p-6">
@ -90,7 +74,6 @@ function openLearningContent(learningContent: LearningContent) {
<LearningSequence <LearningSequence
:learning-sequence="learningSequence" :learning-sequence="learningSequence"
:completion-data="circleStore.completionData" :completion-data="circleStore.completionData"
@click-content="openLearningContent"
></LearningSequence> ></LearningSequence>
</div> </div>

View File

@ -85,7 +85,7 @@ class Circle(Page):
], use_json_field=True) ], use_json_field=True)
parent_page_types = ['learnpath.LearningPath'] parent_page_types = ['learnpath.LearningPath']
subpage_types = ['learnpath.LearningSequence', 'learnpath.LearningUnit'] subpage_types = ['learnpath.LearningSequence', 'learnpath.LearningUnit', 'learnpath.LearningContent']
content_panels = Page.content_panels + [ content_panels = Page.content_panels + [
FieldPanel('description'), FieldPanel('description'),

View File

@ -111,7 +111,10 @@ Fachspezialisten bei.
title='Ermittlung des Kundenbedarfs', title='Ermittlung des Kundenbedarfs',
parent=circe_analyse, parent=circe_analyse,
minutes=30, minutes=30,
contents=[('podcast', PodcastBlockFactory())] contents=[('podcast', PodcastBlockFactory(
description='Die Ermittlung des Kundenbedarfs muss in einem eingehenden Gespräch herausgefunden werden. Höre dazu auch diesen Podcast an.',
url='https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/325190984&color=%23ff5500&auto_play=false&hide_related=false&show_comments=true&show_user=true&show_reposts=false&show_teaser=true&visual=true',
))]
) )
LearningContentFactory( LearningContentFactory(
title='Kundenbedürfnisse erkennen', title='Kundenbedürfnisse erkennen',