Merge branch 'develop' into feature/VBV-234-bugfix-create-new-page-in-wagtail

This commit is contained in:
Daniel Egger 2023-04-13 19:23:24 +02:00
commit cb37c55732
39 changed files with 1863 additions and 428 deletions

10
client/cypress.config.ts Normal file
View File

@ -0,0 +1,10 @@
import { defineConfig } from "cypress";
export default defineConfig({
component: {
devServer: {
framework: "vue",
bundler: "vite",
},
},
});

View File

@ -1,3 +0,0 @@
{
"baseUrl": "http://localhost:5050"
}

View File

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

View File

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

View File

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

View File

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

View File

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

1582
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -15,7 +15,8 @@
"prettier:check": "prettier . --check", "prettier:check": "prettier . --check",
"tailwind": "tailwindcss -i tailwind.css -o ../server/vbv_lernwelt/static/css/tailwind.css --watch", "tailwind": "tailwindcss -i tailwind.css -o ../server/vbv_lernwelt/static/css/tailwind.css --watch",
"storybook": "storybook dev -p 6006", "storybook": "storybook dev -p 6006",
"build-storybook": "rm -rf ../server/vbv_lernwelt/static/storybook/* && storybook build -o ../server/vbv_lernwelt/static/storybook" "build-storybook": "rm -rf ../server/vbv_lernwelt/static/storybook/* && storybook build -o ../server/vbv_lernwelt/static/storybook",
"cypress:open": "cypress open"
}, },
"dependencies": { "dependencies": {
"@headlessui/tailwindcss": "^0.1.2", "@headlessui/tailwindcss": "^0.1.2",
@ -24,6 +25,7 @@
"@sentry/vue": "^7.20.0", "@sentry/vue": "^7.20.0",
"@urql/vue": "^1.0.2", "@urql/vue": "^1.0.2",
"@vueuse/core": "^9.13.0", "@vueuse/core": "^9.13.0",
"cypress": "^12.9.0",
"d3": "^7.6.1", "d3": "^7.6.1",
"dayjs": "^1.11.7", "dayjs": "^1.11.7",
"graphql": "^16.6.0", "graphql": "^16.6.0",

View File

@ -0,0 +1,26 @@
<template>
<AccountMenuContent
:course-sessions="courseSessionsStore.allCurrentCourseSessions"
:selected-course-session="courseSessionsStore.currentCourseSession?.id"
:user="userStore"
@logout="logout"
@select-course-session="selectCourseSession"
/>
</template>
<script setup lang="ts">
import AccountMenuContent from "@/components/header/AccountMenuContent.vue";
import { useCourseSessionsStore } from "@/stores/courseSessions";
import { useUserStore } from "@/stores/user";
import type { CourseSession } from "@/types";
const logout = () => {
userStore.handleLogout();
};
const selectCourseSession = (courseSession: CourseSession) => {
courseSessionsStore.switchCourseSession(courseSession);
};
const courseSessionsStore = useCourseSessionsStore();
const userStore = useUserStore();
</script>

View File

@ -0,0 +1,41 @@
import avatar from "../../../.storybook/uk1.patrizia.huggel.jpg";
import AccountMenuContent from "./AccountMenuContent.vue";
const bern = {
id: 1,
title: "Bern 2023 a",
};
let selectedSession = bern;
const courseSessions = [
bern,
{
id: 2,
title: "Zürich 2023 a",
},
];
const user = {
first_name: "Vreni",
last_name: "Schmid",
email: "vreni.schmid@example.com",
avatar_url: avatar,
loggedIn: true,
};
const selectCourseSession = (session) => {
selectedSession = session;
console.log("session");
};
describe("<AccountMenuContent />", () => {
it("renders", () => {
// see: https://on.cypress.io/mounting-vue
cy.mount(AccountMenuContent, {
props: {
user,
allCourseSessions: courseSessions,
selectedCourseSession: selectedSession,
onSelectCourseSession: selectCourseSession,
},
});
});
});

View File

@ -21,25 +21,37 @@ const meta: Meta<typeof AccountMenuContent> = {
export default meta; export default meta;
type Story = StoryObj<typeof AccountMenuContent>; type Story = StoryObj<typeof AccountMenuContent>;
export const DefaultStory: Story = { const courseSessions = [
{
id: 1,
title: "Bern 2023 a",
},
{
id: 2,
title: "Zürich 2023 a",
},
];
const loggedInUser = {
first_name: "Vreni",
last_name: "Schmid",
email: "vreni.schmid@example.com",
avatar_url: avatar,
loggedIn: true,
};
export const LoggedInUser: Story = {
args: { args: {
show: true, allCourseSessions: courseSessions,
courseSessions: [ user: loggedInUser,
{ },
id: 1, };
title: "Bern 2023 a",
}, export const NoUser: Story = {
{ args: {
id: 2, allCourseSessions: courseSessions,
title: "Zürich 2023 a",
},
],
user: { user: {
first_name: "Vreni", loggedIn: false,
last_name: "Schmid",
email: "vreni.schmid@example.com",
avatar_url: avatar,
loggedIn: true,
}, },
}, },
}; };

View File

@ -1,29 +1,31 @@
<script setup lang="ts"> <script setup lang="ts">
import CourseSessionsMenu from "@/components/header/CourseSessionsMenu.vue";
import type { UserState } from "@/stores/user"; import type { UserState } from "@/stores/user";
import type { CourseSession } from "@/types"; import type { CourseSession } from "@/types";
const props = defineProps<{ const props = defineProps<{
courseSessions: CourseSession[]; courseSessions: CourseSession[];
user: UserState | undefined; user: UserState;
selectedCourseSession?: number;
}>(); }>();
const emits = defineEmits(["selectCourseSession", "logout"]); const emit = defineEmits(["selectCourseSession", "logout"]);
</script> </script>
<template> <template>
<div class="text-black"> <div class="text-black">
<div v-if="user?.loggedIn" class="border-b py-4"> <div class="border-b py-4">
<div class="flex justify-start"> <div class="flex justify-start">
<div v-if="user?.avatar_url"> <div v-if="user.avatar_url">
<img <img
class="inline-block h-20 w-20 rounded-full" class="inline-block h-20 w-20 rounded-full"
:src="user?.avatar_url" :src="user.avatar_url"
alt="" alt=""
/> />
</div> </div>
<div class="ml-6"> <div class="ml-6">
<h3>{{ user?.first_name }} {{ user?.last_name }}</h3> <h3>{{ user.first_name }} {{ user.last_name }}</h3>
<div class="text-sm text-gray-800">{{ user?.email }}</div> <div class="text-sm text-gray-800">{{ user.email }}</div>
<div class="text-sm text-gray-800"> <div class="text-sm text-gray-800">
<router-link class="link" to="/profile">Profil anzeigen</router-link> <router-link class="link" to="/profile">Profil anzeigen</router-link>
</div> </div>
@ -32,17 +34,14 @@ const emits = defineEmits(["selectCourseSession", "logout"]);
</div> </div>
<div v-if="props.courseSessions.length" class="border-b py-4"> <div v-if="props.courseSessions.length" class="border-b py-4">
<div v-for="cs in props.courseSessions" :key="cs.id"> <CourseSessionsMenu
<button @click="$emit('selectCourseSession', cs)">{{ cs.title }}</button> :items="courseSessions"
</div> :selected="selectedCourseSession"
@select="emit('selectCourseSession', $event)"
/>
</div> </div>
<button <button type="button" class="mt-6 flex items-center" @click="emit('logout')">
v-if="user?.loggedIn"
type="button"
class="mt-6 flex items-center"
@click="$emit('logout')"
>
<it-icon-logout class="inline-block" /> <it-icon-logout class="inline-block" />
<span class="ml-1">{{ $t("mainNavigation.logout") }}</span> <span class="ml-1">{{ $t("mainNavigation.logout") }}</span>
</button> </button>

View File

@ -0,0 +1,36 @@
import type { Meta, StoryObj } from "@storybook/vue3";
import type { Item } from "./CourseSessionsMenu.vue";
import CourseSessionsMenu from "./CourseSessionsMenu.vue";
const meta: Meta<typeof CourseSessionsMenu> = {
title: "VBV/CourseSessionsMenu",
component: CourseSessionsMenu,
};
export default meta;
type Story = StoryObj<typeof CourseSessionsMenu>;
const items: Item[] = [
{
id: "1",
title: "Bern 2021 a",
},
{
id: "2",
title: "Bern 2021 b",
},
{
id: "3",
title: "Bern 2022 a",
},
{
id: "4",
title: "Bern 2022 b",
},
];
export const CourseSessionsMenuStory: Story = {
args: {
items,
},
};

View File

@ -0,0 +1,44 @@
<template>
<fieldset>
<div>
<div
v-for="item in props.items"
:key="item.id"
class="mb-4 flex items-center last:mb-0"
>
<input
:id="String(item.id)"
name="coursesessions"
type="radio"
:value="item.id"
:checked="selected === item.id"
class="focus:ring-indigo-900 h-4 w-4 border-gray-300 text-blue-900"
@change="emit('select', item)"
/>
<label
:for="String(item.id)"
class="ml-3 block text-sm font-medium leading-6 text-gray-900"
>
{{ item.title }}
</label>
</div>
</div>
</fieldset>
</template>
<script setup lang="ts">
import type { CourseSession } from "@/types";
export interface Item {
id: string;
title: string;
}
export interface Props {
items: CourseSession[];
selected?: number;
}
const props = defineProps<Props>();
const emit = defineEmits(["select"]);
</script>

View File

@ -0,0 +1,22 @@
import router from "@/router";
import { createPinia } from "pinia";
import { markRaw } from "vue";
import MainNavigationBar from "./MainNavigationBar.vue";
describe("<MainNavigationBar />", () => {
it("renders", async () => {
const pinia = createPinia();
pinia.use(({ store }) => {
store.router = markRaw(router);
});
router.push("/");
await router.isReady();
// see: https://on.cypress.io/mounting-vue
cy.mount(MainNavigationBar, {
router: router,
global: {
plugins: [pinia],
},
});
});
});

View File

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import log from "loglevel"; import log from "loglevel";
import AccountMenuContent from "@/components/header/AccountMenuContent.vue"; import AccountMenu from "@/components/header/AccountMenu.vue";
import MobileMenu from "@/components/header/MobileMenu.vue"; import MobileMenu from "@/components/header/MobileMenu.vue";
import NotificationPopover from "@/components/notifications/NotificationPopover.vue"; import NotificationPopover from "@/components/notifications/NotificationPopover.vue";
import NotificationPopoverContent from "@/components/notifications/NotificationPopoverContent.vue"; import NotificationPopoverContent from "@/components/notifications/NotificationPopoverContent.vue";
@ -9,20 +9,19 @@ import ItFullScreenModal from "@/components/ui/ItFullScreenModal.vue";
import { useCourseSessionsStore } from "@/stores/courseSessions"; import { useCourseSessionsStore } from "@/stores/courseSessions";
import { useNotificationsStore } from "@/stores/notifications"; import { useNotificationsStore } from "@/stores/notifications";
import { useUserStore } from "@/stores/user"; import { useUserStore } from "@/stores/user";
import type { CourseSession } from "@/types"; import { useRouteLookups } from "@/utils/route";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/vue"; import { Popover, PopoverButton, PopoverPanel } from "@headlessui/vue";
import { breakpointsTailwind, useBreakpoints } from "@vueuse/core"; import { breakpointsTailwind, useBreakpoints } from "@vueuse/core";
import { computed, onMounted, reactive } from "vue"; import { computed, onMounted, reactive } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useRoute } from "vue-router";
log.debug("MainNavigationBar created"); log.debug("MainNavigationBar created");
const route = useRoute();
const breakpoints = useBreakpoints(breakpointsTailwind); const breakpoints = useBreakpoints(breakpointsTailwind);
const userStore = useUserStore(); const userStore = useUserStore();
const courseSessionsStore = useCourseSessionsStore(); const courseSessionsStore = useCourseSessionsStore();
const notificationsStore = useNotificationsStore(); const notificationsStore = useNotificationsStore();
const { inCockpit, inCompetenceProfile, inCourse, inLearningPath } = useRouteLookups();
const { t } = useI18n(); const { t } = useI18n();
const state = reactive({ const state = reactive({
@ -30,37 +29,6 @@ const state = reactive({
showMobileProfileMenu: false, showMobileProfileMenu: false,
}); });
function inCourse() {
return route.path.startsWith("/course/");
}
function inCockpit() {
const regex = new RegExp("/course/[^/]+/cockpit");
return regex.test(route.path);
}
function inLearningPath() {
const regex = new RegExp("/course/[^/]+/learn");
return regex.test(route.path);
}
function inCompetenceProfile() {
const regex = new RegExp("/course/[^/]+/competence");
return regex.test(route.path);
}
function inMediaLibrary() {
return route.path.startsWith("/media/");
}
function logout() {
userStore.handleLogout();
}
function selectCourseSession(courseSession: CourseSession) {
courseSessionsStore.switchCourseSession(courseSession);
}
function popoverClick(event: Event) { function popoverClick(event: Event) {
if (breakpoints.smaller("lg").value) { if (breakpoints.smaller("lg").value) {
event.preventDefault(); event.preventDefault();
@ -96,11 +64,7 @@ onMounted(() => {
:show="state.showMobileProfileMenu" :show="state.showMobileProfileMenu"
@closemodal="state.showMobileProfileMenu = false" @closemodal="state.showMobileProfileMenu = false"
> >
<AccountMenuContent <AccountMenu />
:course-sessions="courseSessionsStore.currentCourseSessions"
:user="userStore"
@logout="logout"
/>
</ItFullScreenModal> </ItFullScreenModal>
</Teleport> </Teleport>
<Transition name="nav"> <Transition name="nav">
@ -129,13 +93,14 @@ onMounted(() => {
<div <div
class="ml-1 border-l border-white pl-3 pr-10 text-2xl text-white" class="ml-1 border-l border-white pl-3 pr-10 text-2xl text-white"
> >
{{ $t("general.title") }} {{ t("general.title") }}
</div> </div>
</router-link> </router-link>
</div> </div>
</div> </div>
<div class="hidden space-x-8 lg:flex"> <div class="hidden space-x-8 lg:flex">
<!-- Navigation Links Desktop -->
<router-link <router-link
v-if=" v-if="
inCourse() && inCourse() &&
@ -146,7 +111,7 @@ onMounted(() => {
class="nav-item" class="nav-item"
:class="{ 'nav-item--active': inCockpit() }" :class="{ 'nav-item--active': inCockpit() }"
> >
{{ $t("cockpit.title") }} {{ t("cockpit.title") }}
</router-link> </router-link>
<router-link <router-link
@ -155,7 +120,7 @@ onMounted(() => {
class="nav-item" class="nav-item"
:class="{ 'nav-item--active': inLearningPath() }" :class="{ 'nav-item--active': inLearningPath() }"
> >
{{ $t("general.learningPath") }} {{ t("general.learningPath") }}
</router-link> </router-link>
<router-link <router-link
@ -164,12 +129,13 @@ onMounted(() => {
class="nav-item" class="nav-item"
:class="{ 'nav-item--active': inCompetenceProfile() }" :class="{ 'nav-item--active': inCompetenceProfile() }"
> >
{{ $t("competences.title") }} {{ t("competences.title") }}
</router-link> </router-link>
</div> </div>
</div> </div>
<div class="flex items-stretch justify-start space-x-8"> <div class="flex items-stretch justify-start space-x-8">
<!-- Notification Bell & Menu -->
<div v-if="userStore.loggedIn" class="nav-item"> <div v-if="userStore.loggedIn" class="nav-item">
<NotificationPopover> <NotificationPopover>
<template #toggleButtonContent> <template #toggleButtonContent>
@ -217,12 +183,7 @@ onMounted(() => {
class="absolute -right-2 top-8 z-50 w-[500px] bg-white shadow-lg" class="absolute -right-2 top-8 z-50 w-[500px] bg-white shadow-lg"
> >
<div class="p-4"> <div class="p-4">
<AccountMenuContent <AccountMenu />
:course-sessions="courseSessionsStore.currentCourseSessions"
:user="userStore"
@logout="logout"
@select-course-session="selectCourseSession"
/>
</div> </div>
</PopoverPanel> </PopoverPanel>
</Popover> </Popover>

View File

@ -3,7 +3,7 @@ import type { CheckboxItem } from "@/components/ui/checkbox.types";
import log from "loglevel"; import log from "loglevel";
const props = defineProps<{ const props = defineProps<{
checkbox_item: CheckboxItem<any>; checkboxItem: CheckboxItem<any>;
disabled?: boolean; disabled?: boolean;
}>(); }>();
@ -36,7 +36,7 @@ const input = (e: Event) => {
<label <label
class="cy-checkbox cy-checkbox-checked block flex h-8 items-center bg-contain bg-no-repeat pl-8 disabled:opacity-50" class="cy-checkbox cy-checkbox-checked block flex h-8 items-center bg-contain bg-no-repeat pl-8 disabled:opacity-50"
:class=" :class="
checkbox_item.checked checkboxItem.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-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)]' : 'bg-[url(/static/icons/icon-checkbox-unchecked.svg)] hover:bg-[url(/static/icons/icon-checkbox-unchecked-hover.svg)]'
" "
@ -45,21 +45,21 @@ const input = (e: Event) => {
> >
<input <input
ref="checkbox" ref="checkbox"
:checked="checkbox_item.checked" :checked="checkboxItem.checked"
:value="checkbox_item.value" :value="checkboxItem.value"
:disabled="disabled" :disabled="disabled"
:data-cy="`it-checkbox-${checkbox_item.value}`" :data-cy="`it-checkbox-${checkboxItem.value}`"
class="sr-only" class="sr-only"
type="checkbox" type="checkbox"
@keydown="keydown" @keydown="keydown"
@input="input" @input="input"
/> />
<div class="ml-4 flex-col"> <div class="ml-4 flex-col">
<div v-if="checkbox_item.label"> <div v-if="checkboxItem.label">
{{ checkbox_item.label }} {{ checkboxItem.label }}
</div> </div>
<div v-if="checkbox_item.subtitle" class="text-gray-900"> <div v-if="checkboxItem.subtitle" class="text-gray-900">
{{ checkbox_item.subtitle }} {{ checkboxItem.subtitle }}
</div> </div>
</div> </div>
</label> </label>

View File

@ -25,7 +25,7 @@ function updateItems(itemValue: string) {
<ItCheckbox <ItCheckbox
v-for="item in items" v-for="item in items"
:key="item.value" :key="item.value"
:checkbox_item="item" :checkbox-item="item"
:class="item.subtitle ? 'mb-6' : 'mb-4'" :class="item.subtitle ? 'mb-6' : 'mb-4'"
@toggle="updateItems(item.value)" @toggle="updateItems(item.value)"
/> />

View File

@ -0,0 +1,35 @@
import type { RadioItem } from "@/pages/learningPath/learningContentPage/feedback/feedback.types";
import type { Meta, StoryObj } from "@storybook/vue3";
import ItRadioGroup from "./ItRadioGroup.vue";
const meta: Meta<typeof ItRadioGroup> = {
title: "VBV/RadioGroup",
component: ItRadioGroup,
argTypes: { "onUpdate:modelValue": { action: "updated" } },
};
export default meta;
type Story = StoryObj<typeof ItRadioGroup>;
let modelValue = "a";
const items: RadioItem<string>[] = [
{
value: "a",
name: "A",
},
{
value: "b",
name: "B",
},
];
export const RadioGroup: Story = {
args: {
modelValue: modelValue,
items: items,
label: "Radiogroup",
"onUpdate:modelValue": (newValue: any) => {
modelValue = newValue;
},
},
};

View File

@ -1,6 +1,13 @@
export interface CheckboxItem<T> { export interface BaseItem<T> {
value: T; value: T;
label?: string; label?: string;
checked: boolean;
subtitle?: string; subtitle?: string;
} }
export interface CheckboxItem<T> extends BaseItem<T> {
checked: boolean;
}
export interface RadioItem<T> extends BaseItem<T> {
selected: boolean;
}

View File

@ -418,7 +418,7 @@ function log(data: any) {
<h2 class="mb-8 mt-8">Checkbox</h2> <h2 class="mb-8 mt-8">Checkbox</h2>
<ItCheckbox <ItCheckbox
:checkbox_item="{ :checkbox-item="{
subtitle: 'Subtitle', subtitle: 'Subtitle',
label: 'Label', label: 'Label',
value: 'value', value: 'value',
@ -433,7 +433,7 @@ function log(data: any) {
<ItCheckbox <ItCheckbox
:disabled="true" :disabled="true"
:checkbox_item="{ :checkbox-item="{
subtitle: 'checked disabled', subtitle: 'checked disabled',
label: 'Label', label: 'Label',
value: 'value', value: 'value',

View File

@ -33,8 +33,7 @@ function userCountStatusForCircle(userId: number, translationKey: string) {
); );
const grouped = groupBy(criteria, "circle.translation_key"); const grouped = groupBy(criteria, "circle.translation_key");
// @ts-ignore return competenceStore.calcStatusCount(grouped[translationKey] as []);
return competenceStore.calcStatusCount(grouped[translationKey]);
} }
const circles = computed(() => { const circles = computed(() => {

View File

@ -127,7 +127,7 @@ const learningSequenceBorderClass = computed(() => {
</div> </div>
<ItCheckbox <ItCheckbox
v-else v-else
:checkbox_item="{ :checkbox-item="{
value: learningContent.completion_status, value: learningContent.completion_status,
checked: learningContent.completion_status === 'success', checked: learningContent.completion_status === 'success',
}" }"

View File

@ -24,7 +24,7 @@ const attendanceDay = computed(() => {
<div class="lg:mt-8"> <div class="lg:mt-8">
<div class="text-large my-4"> <div class="text-large my-4">
<div v-if="attendanceDay">{{ attendanceDay }}</div> <div v-if="attendanceDay">{{ attendanceDay }}</div>
<div v-else>Keine Präsenztagdaten erfasst für diese Durchführung</div> <div v-else>Für diese Durchführung existieren noch keine Details</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -171,9 +171,9 @@ router.beforeEach(redirectToLoginIfRequired);
router.beforeEach((to) => { router.beforeEach((to) => {
const courseSessionStore = useCourseSessionsStore(); const courseSessionStore = useCourseSessionsStore();
if (to.params.courseSlug) { if (to.params.courseSlug) {
courseSessionStore.currentCourseSlug = to.params.courseSlug as string; courseSessionStore._currentCourseSlug = to.params.courseSlug as string;
} else { } else {
courseSessionStore.currentCourseSlug = ""; courseSessionStore._currentCourseSlug = "";
} }
}); });

View File

@ -108,7 +108,7 @@ export class LearningPath implements WagtailLearningPath {
public async reloadCompletionData() { public async reloadCompletionData() {
const learningPathStore = useLearningPathStore(); const learningPathStore = useLearningPathStore();
const completionData = await learningPathStore.loadCompletionData( const completionData = await learningPathStore.loadCourseSessionCompletionData(
this.course.slug, this.course.slug,
this.userId this.userId
); );

View File

@ -63,8 +63,8 @@ describe("CourseSession Store", () => {
userStore.$patch(user); userStore.$patch(user);
const courseSessionsStore = useCourseSessionsStore(); const courseSessionsStore = useCourseSessionsStore();
courseSessionsStore.currentCourseSlug = "test-course"; courseSessionsStore._currentCourseSlug = "test-course";
courseSessionsStore.courseSessions = courseSessions; courseSessionsStore.allCourseSessions = courseSessions;
expect(courseSessionsStore.hasCockpit).toBeFalsy(); expect(courseSessionsStore.hasCockpit).toBeFalsy();
}); });
@ -74,8 +74,8 @@ describe("CourseSession Store", () => {
userStore.$patch(Object.assign(user, { is_superuser: true })); userStore.$patch(Object.assign(user, { is_superuser: true }));
const courseSessionsStore = useCourseSessionsStore(); const courseSessionsStore = useCourseSessionsStore();
courseSessionsStore.currentCourseSlug = "test-course"; courseSessionsStore._currentCourseSlug = "test-course";
courseSessionsStore.courseSessions = courseSessions; courseSessionsStore.allCourseSessions = courseSessions;
expect(courseSessionsStore.hasCockpit).toBeTruthy(); expect(courseSessionsStore.hasCockpit).toBeTruthy();
}); });
@ -87,8 +87,8 @@ describe("CourseSession Store", () => {
); );
const courseSessionsStore = useCourseSessionsStore(); const courseSessionsStore = useCourseSessionsStore();
courseSessionsStore.currentCourseSlug = "test-course"; courseSessionsStore._currentCourseSlug = "test-course";
courseSessionsStore.courseSessions = courseSessions; courseSessionsStore.allCourseSessions = courseSessions;
expect(courseSessionsStore.hasCockpit).toBeTruthy(); expect(courseSessionsStore.hasCockpit).toBeTruthy();
}); });

View File

@ -169,7 +169,7 @@ export const useCompetenceStore = defineStore({
const courseSessionsStore = useCourseSessionsStore(); const courseSessionsStore = useCourseSessionsStore();
const courseSession = courseSessionsStore.currentCourseSession; const courseSession = courseSessionsStore.currentCourseSession;
if (courseSession) { if (courseSession) {
const completionData = await completionStore.loadCompletionData( const completionData = await completionStore.loadCourseSessionCompletionData(
courseSession.id, courseSession.id,
userId userId
); );

View File

@ -11,7 +11,11 @@ export const useCompletionStore = defineStore({
}, },
getters: {}, getters: {},
actions: { actions: {
async loadCompletionData(courseSessionId: number, userId: number, reload = false) { async loadCourseSessionCompletionData(
courseSessionId: number,
userId: number,
reload = false
) {
const userCompletionData = (await itGetCached( const userCompletionData = (await itGetCached(
`/api/course/completion/${courseSessionId}/${userId}/`, `/api/course/completion/${courseSessionId}/${userId}/`,
{ {

View File

@ -58,7 +58,7 @@ function loadCourseSessionsData(reload = false) {
loadAndUpdate(); // this will be called asynchronously, but does not block loadAndUpdate(); // this will be called asynchronously, but does not block
// returns the empty sessions array at first, then after loading populates the ref // returns the empty sessions array at first, then after loading populates the ref
return { courseSessions }; return { allCourseSessions: courseSessions };
} }
export const useCourseSessionsStore = defineStore("courseSessions", () => { export const useCourseSessionsStore = defineStore("courseSessions", () => {
@ -68,26 +68,33 @@ export const useCourseSessionsStore = defineStore("courseSessions", () => {
// store should do own setup, we don't want to have each component initialize it // 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 // that's why we call the load function in here
const { courseSessions } = loadCourseSessionsData(); const { allCourseSessions } = loadCourseSessionsData();
const selectedCourseSessionMap = useLocalStorage( const selectedCourseSessionMap = useLocalStorage(
SELECTED_COURSE_SESSIONS_KEY, SELECTED_COURSE_SESSIONS_KEY,
new Map<string, number>() new Map<string, number>()
); );
const currentCourseSlug = ref(""); const _currentCourseSlug = ref("");
// these will become getters
const uniqueCourseSessionsByCourse = computed(() => const uniqueCourseSessionsByCourse = computed(() =>
// TODO: refactor after implementing of Klassenkonzept // Im Dashboard wird aktuell ein Widget pro Kurs dargestellt
// mit dem Fortschritt jeweils einer Durchführung.
// Dieser Getter wird nur dort benutzt.
// TODO: Das Dashboard verschwindet evtl. in Zukunft, dann kann dieser Getter weg.
// @ts-ignore // @ts-ignore
uniqBy(courseSessions.value, "course.id") uniqBy(allCourseSessions.value, "course.id")
); );
function selectedCourseSessionForCourse(courseSlug: string) { function selectedCourseSessionForCourse(courseSlug: string) {
// Wir wollen pro Kurs wissen, welche Durchführung der User zuletzt ausgewählt hat.
// Die letzte Durchführung wird im localStorage via `selectedCoruseSessionMap`
// gespeichert und hier geladen.
// Wenn noch keine Durchführung ausgewählt wurde, wird die erste Durchführung
// in `courseSessionForCourse` zurückgegeben.
try { try {
const courseSessionId = selectedCourseSessionMap.value.get(courseSlug); const courseSessionId = selectedCourseSessionMap.value.get(courseSlug);
if (courseSessionId) { if (courseSessionId) {
return courseSessions.value.find((cs) => { return allCourseSessions.value.find((cs) => {
return cs.id === courseSessionId; return cs.id === courseSessionId;
}); });
} }
@ -95,6 +102,7 @@ export const useCourseSessionsStore = defineStore("courseSessions", () => {
log.error("Error while parsing courseSessions from localStorage", e); log.error("Error while parsing courseSessions from localStorage", e);
} }
// Keine Durchführung ausgewählt im localStorage
return undefined; return undefined;
} }
@ -112,7 +120,7 @@ export const useCourseSessionsStore = defineStore("courseSessions", () => {
return courseSession; return courseSession;
} else { } else {
// return first if there is no selected courseSession // return first if there is no selected courseSession
return courseSessions.value.find((cs) => { return allCourseSessions.value.find((cs) => {
return cs.course.slug === courseSlug; return cs.course.slug === courseSlug;
}); });
} }
@ -121,18 +129,18 @@ export const useCourseSessionsStore = defineStore("courseSessions", () => {
return undefined; return undefined;
} }
function courseSessionsForCourse(courseSlug: string) { function allCourseSessionsForCourse(courseSlug: string) {
return courseSessions.value.filter((cs) => { return allCourseSessions.value.filter((cs) => {
return cs.course.slug === courseSlug; return cs.course.slug === courseSlug;
}); });
} }
const currentCourseSession = computed(() => { const currentCourseSession = computed(() => {
return courseSessionForCourse(currentCourseSlug.value); return courseSessionForCourse(_currentCourseSlug.value);
}); });
const currentCourseSessions = computed(() => { const allCurrentCourseSessions = computed(() => {
return courseSessionsForCourse(currentCourseSlug.value); return allCourseSessionsForCourse(_currentCourseSlug.value);
}); });
const hasCockpit = computed(() => { const hasCockpit = computed(() => {
@ -197,7 +205,7 @@ export const useCourseSessionsStore = defineStore("courseSessions", () => {
async function startUpload() { async function startUpload() {
log.debug("loadCourseSessionsData called"); log.debug("loadCourseSessionsData called");
courseSessions.value = await itPost(`/api/core/file/start`, { allCourseSessions.value = await itPost(`/api/core/file/start`, {
file_type: "image/png", file_type: "image/png",
file_name: "test.png", file_name: "test.png",
}); });
@ -238,7 +246,7 @@ export const useCourseSessionsStore = defineStore("courseSessions", () => {
return { return {
uniqueCourseSessionsByCourse, uniqueCourseSessionsByCourse,
currentCourseSession, currentCourseSession,
currentCourseSessions, allCurrentCourseSessions,
courseSessionForCourse, courseSessionForCourse,
switchCourseSession, switchCourseSession,
hasCockpit, hasCockpit,
@ -251,10 +259,10 @@ export const useCourseSessionsStore = defineStore("courseSessions", () => {
findAttendanceDay, findAttendanceDay,
findAssignmentDetails, findAssignmentDetails,
// TODO: only used to be changed by router.afterEach // only used so that `router.afterEach` can switch it
currentCourseSlug, _currentCourseSlug,
// TODO: only used for unit testing // only used for unit testing
courseSessions, allCourseSessions,
}; };
}); });

View File

@ -41,10 +41,11 @@ export const useLearningPathStore = defineStore("learningPath", () => {
}; };
}); });
async function loadCompletionData( async function loadCourseSessionCompletionData(
courseSlug: string, courseSlug: string,
userId: number | undefined = undefined userId: number | undefined = undefined
) { ) {
// FIXME: should not be here anymore with VBV-305
const completionStore = useCompletionStore(); const completionStore = useCompletionStore();
let completionData: CourseCompletion[] = []; let completionData: CourseCompletion[] = [];
@ -52,7 +53,7 @@ export const useLearningPathStore = defineStore("learningPath", () => {
const courseSessionsStore = useCourseSessionsStore(); const courseSessionsStore = useCourseSessionsStore();
const courseSession = courseSessionsStore.courseSessionForCourse(courseSlug); const courseSession = courseSessionsStore.courseSessionForCourse(courseSlug);
if (courseSession) { if (courseSession) {
completionData = await completionStore.loadCompletionData( completionData = await completionStore.loadCourseSessionCompletionData(
courseSession.id, courseSession.id,
userId userId
); );
@ -85,7 +86,7 @@ export const useLearningPathStore = defineStore("learningPath", () => {
throw `No learning path found with: ${slug}`; throw `No learning path found with: ${slug}`;
} }
const completionData = await loadCompletionData( const completionData = await loadCourseSessionCompletionData(
learningPathData.course.slug, learningPathData.course.slug,
userId userId
); );
@ -99,19 +100,22 @@ export const useLearningPathStore = defineStore("learningPath", () => {
return learningPath; return learningPath;
} }
function reloadCompletionData() { eventBus.on("switchedCourseSession", (courseSession) => {
log.debug("handle switchedCourseSession", courseSession);
// FIXME: clean up with VBV-305
// Die Completion Daten werden nur für die aktuelle Durchführung geladen.
// Deshalb müssen die hier neu geladen werden...
state.learningPaths.forEach((lp) => { state.learningPaths.forEach((lp) => {
if (lp.userId) { if (lp.userId) {
lp.reloadCompletionData(); lp.reloadCompletionData();
} }
}); });
}
eventBus.on("switchedCourseSession", (courseSession) => {
log.debug("handle switchedCourseSession", courseSession);
// FIXME: clean up with VBV-305
reloadCompletionData();
}); });
return { state, learningPathForUser, loadCompletionData, loadLearningPath }; return {
state,
learningPathForUser,
loadCourseSessionCompletionData,
loadLearningPath,
};
}); });

30
client/src/utils/route.ts Normal file
View File

@ -0,0 +1,30 @@
import { useRoute } from "vue-router";
export function useRouteLookups() {
const route = useRoute();
function inCourse() {
return route.path.startsWith("/course/");
}
function inCockpit() {
const regex = new RegExp("/course/[^/]+/cockpit");
return regex.test(route.path);
}
function inLearningPath() {
const regex = new RegExp("/course/[^/]+/learn");
return regex.test(route.path);
}
function inCompetenceProfile() {
const regex = new RegExp("/course/[^/]+/competence");
return regex.test(route.path);
}
function inMediaLibrary() {
return route.path.startsWith("/media/");
}
return { inMediaLibrary, inCockpit, inLearningPath, inCompetenceProfile, inCourse };
}

View File

@ -9,7 +9,10 @@ module.exports = {
], ],
theme: { theme: {
fontFamily: { fontFamily: {
sans: ["Buenos Aires", "sans-serif"], sans: [
"Buenos Aires, sans-serif",
{ fontFeatureSettings: '"salt"', fontVariationSettings: '"normal"' },
],
}, },
backgroundSize: { backgroundSize: {
auto: "auto", auto: "auto",

View File

@ -10,7 +10,7 @@
}, },
"strict": true "strict": true
}, },
"exclude": ["src/**/__tests__/*"], "exclude": ["src/**/__tests__/*", "src/**/*.cy.ts"],
"extends": "@vue/tsconfig/tsconfig.web.json", "extends": "@vue/tsconfig/tsconfig.web.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"] "include": ["env.d.ts", "src/**/*", "src/**/*.vue"]
} }

View File

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

View File

@ -9,6 +9,9 @@
}, },
{ {
"path": "./tsconfig.vitest.json" "path": "./tsconfig.vitest.json"
},
{
"path": "./tsconfig.cypress.json"
} }
] ]
} }

View File

@ -5,49 +5,38 @@ describe("Competence", () => {
cy.manageCommand("cypress_reset"); cy.manageCommand("cypress_reset");
login("admin", "test"); login("admin", "test");
cy.visit("/course/versicherungsvermittler-in/learn/fahrzeug");
// test-lehrgang-lp-circle-analyse-lu-fahrzeug ist eine Selbstevaluation
// mit mehreren Schritten
cy.visit("/course/test-lehrgang/learn/analyse");
}); });
it("self evaluation should be neutral", () => { it("self evaluation should be neutral", () => {
cy.get( cy.get('[data-cy="test-lehrgang-lp-circle-analyse-lu-fahrzeug"]')
'[data-cy="versicherungsvermittler-in-lp-circle-fahrzeug-lu-fahrzeug-1"]'
)
.find('[data-cy="unknown"]') .find('[data-cy="unknown"]')
.should("exist"); .should("exist");
}); });
it("should be able to make a happy self evaluation", () => { it("should be able to make a happy self evaluation", () => {
cy.get( cy.get('[data-cy="test-lehrgang-lp-circle-analyse-lu-fahrzeug"]').click();
'[data-cy="versicherungsvermittler-in-lp-circle-fahrzeug-lu-fahrzeug-1"]' cy.makeSelfEvaluation([true, true]);
).click(); cy.get('[data-cy="test-lehrgang-lp-circle-analyse-lu-fahrzeug"]')
cy.makeSelfEvaluation([true]);
cy.get(
'[data-cy="versicherungsvermittler-in-lp-circle-fahrzeug-lu-fahrzeug-1"]'
)
.find('[data-cy="success"]') .find('[data-cy="success"]')
.should("exist"); .should("exist");
}); });
it("should be able to make a fail self evaluation", () => { it("should be able to make a fail self evaluation", () => {
cy.get( cy.get('[data-cy="test-lehrgang-lp-circle-analyse-lu-fahrzeug"]').click();
'[data-cy="versicherungsvermittler-in-lp-circle-fahrzeug-lu-fahrzeug-1"]' cy.makeSelfEvaluation([false, false]);
).click(); cy.get('[data-cy="test-lehrgang-lp-circle-analyse-lu-fahrzeug"]')
cy.makeSelfEvaluation([false]);
cy.get(
'[data-cy="versicherungsvermittler-in-lp-circle-fahrzeug-lu-fahrzeug-1"]'
)
.find('[data-cy="fail"]') .find('[data-cy="fail"]')
.should("exist"); .should("exist");
}); });
it.skip("should be able to make a mixed self evaluation", () => { it("should be able to make a mixed self evaluation", () => {
cy.get( cy.get('[data-cy="test-lehrgang-lp-circle-analyse-lu-fahrzeug"]').click();
'[data-cy="versicherungsvermittler-in-alt-lp-circle-analyse-lu-fahrzeug"]' cy.makeSelfEvaluation([false, true]);
).click(); cy.get('[data-cy="test-lehrgang-lp-circle-analyse-lu-fahrzeug"]')
cy.makeSelfEvaluation([false, true, true]);
cy.get(
'[data-cy="versicherungsvermittler-in-alt-lp-circle-analyse-lu-fahrzeug"]'
)
.find('[data-cy="fail"]') .find('[data-cy="fail"]')
.should("exist"); .should("exist");
}); });

View File

@ -7,8 +7,9 @@ describe("MediaLibrary", () => {
login("admin", "test"); login("admin", "test");
}); });
// skipped as long the MainNavigation is not finished
it.skip("should be accessible via link in header", () => { it.skip("should be accessible via link in header", () => {
// der Link zur Mediathek fehlt aktuell im Header (auch im Design)
// das ganze Konzept der Mediathek wird noch überdacht -> skip
cy.visit("/course/test-lehrgang"); cy.visit("/course/test-lehrgang");
cy.get('[data-cy="medialibrary-link"]').click(); cy.get('[data-cy="medialibrary-link"]').click();
cy.get('[data-cy="Handlungsfelder-link"]').click(); cy.get('[data-cy="Handlungsfelder-link"]').click();