VBV-302: Refactor Header for course session switching

This commit is contained in:
Daniel Egger 2023-03-31 14:23:35 +02:00
parent 724f31f4a8
commit 18f7728793
11 changed files with 382 additions and 333 deletions

View File

@ -14,7 +14,7 @@
import log from "loglevel";
import AppFooter from "@/components/AppFooter.vue";
import MainNavigationBar from "@/components/MainNavigationBar.vue";
import MainNavigationBar from "@/components/header/MainNavigationBar.vue";
import { onMounted } from "vue";
log.debug("App created");

View File

@ -1,324 +0,0 @@
<script setup lang="ts">
import log from "loglevel";
import IconLogout from "@/components/icons/IconLogout.vue";
import IconSettings from "@/components/icons/IconSettings.vue";
import MobileMenu from "@/components/MobileMenu.vue";
import NotificationPopover from "@/components/notifications/NotificationPopover.vue";
import NotificationPopoverContent from "@/components/notifications/NotificationPopoverContent.vue";
import ItDropdown from "@/components/ui/ItDropdown.vue";
import { useAppStore } from "@/stores/app";
import { useCourseSessionsStore } from "@/stores/courseSessions";
import { useNotificationsStore } from "@/stores/notifications";
import { useUserStore } from "@/stores/user";
import type { DropdownListItem } from "@/types";
import type { Component } from "vue";
import { computed, onMounted, reactive } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
type DropdownActions = "logout" | "settings" | "profile";
interface DropdownData {
action: DropdownActions;
}
log.debug("MainNavigationBar created");
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const appStore = useAppStore();
const courseSessionsStore = useCourseSessionsStore();
const notificationsStore = useNotificationsStore();
const { t } = useI18n();
const state = reactive({ showMenu: false });
function toggleNav() {
state.showMenu = !state.showMenu;
}
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 handleDropdownSelect(data: DropdownData) {
switch (data.action) {
case "profile":
router.push("/profile");
break;
case "settings":
router.push("/settings");
break;
case "logout":
userStore.handleLogout();
break;
default:
console.log("No action");
}
}
function logout() {
userStore.handleLogout();
}
const selectedCourseSessionTitle = computed(() => {
return courseSessionsStore.courseSessionForRoute?.title;
});
onMounted(() => {
log.debug("MainNavigationBar mounted");
if (userStore.loggedIn) {
// fixme: only when i'm logged in? should this be handled in the store?
// courseSessionsStore.loadCourseSessionsData();
}
});
const profileDropdownData: DropdownListItem[] = [
{
title: t("mainNavigation.profile"),
icon: IconSettings as Component,
data: {
action: "profile",
},
},
{
title: t("general.settings"),
icon: IconSettings as Component,
data: {
action: "settings",
},
},
{
title: t("mainNavigation.logout"),
icon: IconLogout as Component,
data: {
action: "logout",
},
},
];
</script>
<template>
<div>
<Teleport to="body">
<MobileMenu
v-if="userStore.loggedIn"
:show="state.showMenu"
:course-session="courseSessionsStore.courseSessionForRoute"
:media-url="courseSessionsStore.courseSessionForRoute?.media_library_url"
:user="userStore"
@closemodal="state.showMenu = false"
@logout="userStore.handleLogout()"
/>
</Teleport>
<Transition name="nav">
<div v-if="appStore.showMainNavigationBar" class="navigation bg-blue-900">
<nav class="mx-auto px-8 py-2 lg:flex lg:items-center lg:justify-start lg:py-4">
<div class="flex items-center justify-between">
<div class="flex items-center">
<router-link to="/" class="flex">
<it-icon-vbv class="-ml-3 -mt-6 mr-3 h-8 w-16" />
</router-link>
<router-link to="/" class="flex">
<div class="ml-1 border-l border-white pl-3 pr-10 text-2xl text-white">
{{ $t("general.title") }}
</div>
</router-link>
</div>
<div class="flex items-center lg:hidden">
<div v-if="userStore.loggedIn" class="mr-6 flex flex-row items-center">
<NotificationPopover>
<template #toggleButtonContent>
<div class="nav-item flex">
<it-icon-notification class="h-6 w-6" />
<div
v-if="notificationsStore.hasUnread"
aria-label="unread notifications"
class="mt-1 h-1.5 w-1.5 rounded-full bg-sky-500"
/>
</div>
</template>
<template #popoverContent>
<NotificationPopoverContent />
</template>
</NotificationPopover>
</div>
<router-link
v-if="userStore.loggedIn"
to="/messages"
class="nav-item flex flex-row items-center"
data-cy="messages-link"
>
<it-icon-persons class="mr-6 h-6 w-6" />
</router-link>
<!-- Mobile menu button -->
<div class="flex" @click="toggleNav">
<button
type="button"
class="h-8 w-8 text-white hover:text-sky-500 focus:text-sky-500 focus:outline-none"
>
<it-icon-menu class="h-8 w-8" />
</button>
</div>
</div>
</div>
<!-- Mobile Menu open: "block", Menu closed: "hidden" -->
<div
v-if="appStore.userLoaded && appStore.routingFinished && userStore.loggedIn"
:class="state.showMenu ? 'flex' : 'hidden'"
class="mt-8 flex-auto lg:mt-0 lg:flex lg:flex-row lg:items-center lg:space-x-10 lg:space-y-0"
>
<!-- <router-link-->
<!-- v-if="inCourse() && courseSessionsStore.courseSessionForRoute"-->
<!-- :to="`${courseSessionsStore.courseSessionForRoute.course_url}/cockpit`"-->
<!-- class="nav-item"-->
<!-- :class="{ 'nav-item&#45;&#45;active': inCockpit() }"-->
<!-- >-->
<!-- Cockpit-->
<!-- </router-link>-->
<router-link
v-if="
inCourse() &&
courseSessionsStore.courseSessionForRoute &&
courseSessionsStore.hasCockpit
"
:to="`${courseSessionsStore.courseSessionForRoute.course_url}/cockpit`"
class="nav-item"
:class="{ 'nav-item--active': inCockpit() }"
>
{{ $t("cockpit.title") }}
</router-link>
<router-link
v-if="inCourse() && courseSessionsStore.courseSessionForRoute"
:to="courseSessionsStore.courseSessionForRoute.learning_path_url"
class="nav-item"
:class="{ 'nav-item--active': inLearningPath() }"
>
{{ $t("general.learningPath") }}
</router-link>
<router-link
v-if="inCourse() && courseSessionsStore.courseSessionForRoute"
:to="courseSessionsStore.courseSessionForRoute.competence_url"
class="nav-item"
:class="{ 'nav-item--active': inCompetenceProfile() }"
>
{{ $t("competences.title") }}
</router-link>
<div class="hidden flex-auto lg:block"></div>
<a
class="nav-item"
target="_blank"
href="https://bildung.vbv.ch/ilp/pages/catalogsearch.jsf"
>
{{ $t("general.shop") }}
</a>
<router-link
v-if="courseSessionsStore.courseSessionForRoute"
:to="courseSessionsStore.courseSessionForRoute.media_library_url"
class="nav-item"
:class="{ 'nav-item--active': inMediaLibrary() }"
data-cy="medialibrary-link"
>
{{ $t("mediaLibrary.title") }}
</router-link>
<div v-if="userStore.loggedIn" class="mr-6 flex items-center">
<NotificationPopover>
<template #toggleButtonContent>
<div class="nav-item flex">
<it-icon-notification class="h-6 w-6" />
<div
v-if="notificationsStore.hasUnread"
aria-label="unread notifications"
class="mt-1 h-1.5 w-1.5 rounded-full bg-sky-500"
/>
</div>
</template>
<template #popoverContent>
<NotificationPopoverContent />
</template>
</NotificationPopover>
</div>
<router-link
to="/messages"
class="nav-item flex flex-row items-center"
data-cy="messages-link"
>
<it-icon-persons class="mr-6 h-6 w-6" />
</router-link>
<div v-if="userStore.loggedIn" class="nav-item flex items-center">
<div v-if="selectedCourseSessionTitle">
{{ selectedCourseSessionTitle }}
</div>
<ItDropdown
:button-classes="[]"
:list-items="profileDropdownData"
:align="'right'"
@select="handleDropdownSelect"
>
<div v-if="userStore.avatar_url">
<img
class="inline-block h-8 w-8 rounded-full"
:src="userStore.avatar_url"
alt=""
/>
</div>
<div v-else>
{{ userStore.getFullName }}
</div>
</ItDropdown>
</div>
<div v-else><a class="" href="/login">Login</a></div>
</div>
</nav>
</div>
</Transition>
</div>
</template>
<style lang="postcss" scoped>
.nav-item {
@apply text-2xl font-bold text-white hover:text-sky-500 lg:text-base lg:font-normal;
}
.nav-item--active {
@apply underline decoration-sky-500 decoration-4 underline-offset-[21px];
}
.nav-enter-active,
.nav-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.nav-enter-from,
.nav-leave-to {
opacity: 0;
transform: translateY(-80px);
}
</style>

View File

@ -0,0 +1,45 @@
// @ts-nocheck
import avatar from "../../../.storybook/uk1.patrizia.huggel.jpg";
import AccountMenuContent from "@/components/header/AccountMenuContent.vue";
import type { Meta, StoryObj } from "@storybook/vue3";
// More on how to set up stories at: https://storybook.js.org/docs/7.0/vue/writing-stories/introduction
const meta: Meta<typeof AccountMenuContent> = {
title: "VBV/Header/AccountMenuContent",
component: AccountMenuContent,
tags: ["autodocs"],
argTypes: { onClosemodal: { action: "closeModal" } },
parameters: {
viewport: {
defaultViewport: "mobile1",
},
},
decorators: [() => ({ template: '<div class=""><story /></div>' })],
};
export default meta;
type Story = StoryObj<typeof AccountMenuContent>;
export const DefaultStory: Story = {
args: {
show: true,
courseSessions: [
{
id: 1,
title: "Bern 2023 a",
},
{
id: 2,
title: "Zürich 2023 a",
},
],
user: {
first_name: "Vreni",
last_name: "Schmid",
email: "vreni.schmid@example.com",
avatar_url: avatar,
loggedIn: true,
},
},
};

View File

@ -0,0 +1,61 @@
<script setup lang="ts">
import IconLogout from "@/components/icons/IconLogout.vue";
import type { UserState } from "@/stores/user";
import type { CourseSession } from "@/types";
import { useRouter } from "vue-router";
const router = useRouter();
const props = defineProps<{
courseSessions: CourseSession[];
user: UserState | undefined;
}>();
const emits = defineEmits(["closemodal", "logout"]);
const clickLink = (to: string | undefined) => {
if (to) {
router.push(to);
emits("closemodal");
}
};
</script>
<template>
<div class="text-black">
<div v-if="user?.loggedIn" class="border-b py-4">
<div class="flex justify-start">
<div v-if="user?.avatar_url">
<img
class="inline-block h-20 w-20 rounded-full"
:src="user?.avatar_url"
alt=""
/>
</div>
<div class="ml-6">
<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">
<router-link class="link" to="/profile">Profil anzeigen</router-link>
</div>
</div>
</div>
</div>
<div v-if="props.courseSessions.length" class="border-b py-4">
<div v-for="cs in props.courseSessions" :key="cs.id">
{{ cs.title }}
</div>
</div>
<button
v-if="user?.loggedIn"
type="button"
class="mt-6 flex items-center"
@click="$emit('logout')"
>
<IconLogout class="inline-block" />
<span class="ml-1">{{ $t("mainNavigation.logout") }}</span>
</button>
</div>
</template>

View File

@ -0,0 +1,253 @@
<script setup lang="ts">
import log from "loglevel";
import AccountMenuContent from "@/components/header/AccountMenuContent.vue";
import MobileMenu from "@/components/header/MobileMenu.vue";
import NotificationPopover from "@/components/notifications/NotificationPopover.vue";
import NotificationPopoverContent from "@/components/notifications/NotificationPopoverContent.vue";
import ItFullScreenModal from "@/components/ui/ItFullScreenModal.vue";
import { useCourseSessionsStore } from "@/stores/courseSessions";
import { useNotificationsStore } from "@/stores/notifications";
import { useUserStore } from "@/stores/user";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/vue";
import { breakpointsTailwind, useBreakpoints } from "@vueuse/core";
import { computed, onMounted, reactive } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute } from "vue-router";
log.debug("MainNavigationBar created");
const route = useRoute();
const breakpoints = useBreakpoints(breakpointsTailwind);
const userStore = useUserStore();
const courseSessionsStore = useCourseSessionsStore();
const notificationsStore = useNotificationsStore();
const { t } = useI18n();
const state = reactive({
showMobileNavigationMenu: 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 popoverClick(event) {
if (breakpoints.smaller("lg").value) {
event.preventDefault();
state.showMobileProfileMenu = true;
}
}
const selectedCourseSessionTitle = computed(() => {
return courseSessionsStore.courseSessionForRoute?.title;
});
onMounted(() => {
log.debug("MainNavigationBar mounted");
});
</script>
<template>
<div>
<Teleport to="body">
<MobileMenu
v-if="userStore.loggedIn"
:show="state.showMobileNavigationMenu"
:course-session="courseSessionsStore.courseSessionForRoute"
:media-url="courseSessionsStore.courseSessionForRoute?.media_library_url"
:user="userStore"
@closemodal="state.showMobileNavigationMenu = false"
@logout="userStore.handleLogout()"
/>
</Teleport>
<Teleport to="body">
<ItFullScreenModal
v-if="userStore.loggedIn"
:show="state.showMobileProfileMenu"
@closemodal="state.showMobileProfileMenu = false"
>
<AccountMenuContent
:course-sessions="courseSessionsStore.courseSessionsForRoute"
:user="userStore"
@logout="logout"
/>
</ItFullScreenModal>
</Teleport>
<Transition name="nav">
<nav class="bg-blue-900 text-white">
<div class="mx-auto px-4 lg:px-8">
<div class="relative flex h-16 justify-between">
<div class="absolute inset-y-0 left-0 flex items-center lg:hidden">
<!-- Mobile menu button -->
<div class="flex" @click="state.showMobileNavigationMenu = true">
<button
type="button"
class="h-8 w-8 text-white hover:text-sky-500 focus:text-sky-500 focus:outline-none"
>
<it-icon-menu class="h-8 w-8" />
</button>
</div>
</div>
<div class="flex flex-1 items-stretch justify-start">
<div class="hidden flex-shrink-0 items-center lg:flex">
<div class="flex items-center">
<router-link to="/" class="flex">
<it-icon-vbv class="-ml-3 -mt-6 mr-3 h-8 w-16" />
</router-link>
<router-link to="/" class="flex">
<div
class="ml-1 border-l border-white pl-3 pr-10 text-2xl text-white"
>
{{ $t("general.title") }}
</div>
</router-link>
</div>
</div>
<div class="hidden space-x-8 lg:flex">
<router-link
v-if="
inCourse() &&
courseSessionsStore.courseSessionForRoute &&
courseSessionsStore.hasCockpit
"
:to="`${courseSessionsStore.courseSessionForRoute.course_url}/cockpit`"
class="nav-item"
:class="{ 'nav-item--active': inCockpit() }"
>
{{ $t("cockpit.title") }}
</router-link>
<router-link
v-if="inCourse() && courseSessionsStore.courseSessionForRoute"
:to="courseSessionsStore.courseSessionForRoute.learning_path_url"
class="nav-item"
:class="{ 'nav-item--active': inLearningPath() }"
>
{{ $t("general.learningPath") }}
</router-link>
<router-link
v-if="inCourse() && courseSessionsStore.courseSessionForRoute"
:to="courseSessionsStore.courseSessionForRoute.competence_url"
class="nav-item"
:class="{ 'nav-item--active': inCompetenceProfile() }"
>
{{ $t("competences.title") }}
</router-link>
</div>
</div>
<div class="flex items-stretch justify-start space-x-8">
<div v-if="userStore.loggedIn" class="nav-item">
<NotificationPopover>
<template #toggleButtonContent>
<div class="flex">
<it-icon-notification class="h-6 w-6" />
<div
v-if="notificationsStore.hasUnread"
aria-label="unread notifications"
class="mt-1 h-1.5 w-1.5 rounded-full bg-sky-500"
/>
</div>
</template>
<template #popoverContent>
<NotificationPopoverContent />
</template>
</NotificationPopover>
</div>
<div
v-if="selectedCourseSessionTitle"
class="hidden items-center lg:inline-flex"
>
<div class="">
{{ selectedCourseSessionTitle }}
</div>
</div>
<div class="nav-item">
<div v-if="userStore.loggedIn" class="flex items-center">
<Popover class="relative">
<PopoverButton @click="popoverClick($event)">
<div v-if="userStore.avatar_url">
<img
class="inline-block h-8 w-8 rounded-full"
:src="userStore.avatar_url"
alt=""
/>
</div>
<div v-else>
{{ userStore.getFullName }}
</div>
</PopoverButton>
<PopoverPanel
class="absolute -right-2 top-8 z-50 w-[500px] bg-white shadow-lg"
>
<div class="p-4">
<AccountMenuContent
:course-sessions="courseSessionsStore.courseSessionsForRoute"
:user="userStore"
@logout="logout"
/>
</div>
</PopoverPanel>
</Popover>
</div>
<div v-else><a class="" href="/login">Login</a></div>
</div>
</div>
</div>
</div>
</nav>
</Transition>
</div>
</template>
<style lang="postcss" scoped>
.nav-item {
@apply inline-flex items-center border-b-4 border-transparent px-1 pt-1 text-white hover:text-sky-500;
}
.nav-item--active {
@apply border-sky-500;
}
.nav-enter-active,
.nav-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.nav-enter-from,
.nav-leave-to {
opacity: 0;
transform: translateY(-80px);
}
</style>

View File

@ -1,12 +1,12 @@
// @ts-nocheck
import avatar from "../../.storybook/uk1.patrizia.huggel.jpg";
import avatar from "../../../.storybook/uk1.patrizia.huggel.jpg";
import type { Meta, StoryObj } from "@storybook/vue3";
import MobileMenu from "./MobileMenu.vue";
// More on how to set up stories at: https://storybook.js.org/docs/7.0/vue/writing-stories/introduction
const meta: Meta<typeof MobileMenu> = {
title: "VBV/MobileMenu",
title: "VBV/Header/MobileMenu",
component: MobileMenu,
tags: ["autodocs"],
argTypes: { onClosemodal: { action: "closeModal" } },

View File

@ -1,6 +1,5 @@
<script setup lang="ts">
import IconLogout from "@/components/icons/IconLogout.vue";
import IconSettings from "@/components/icons/IconSettings.vue";
import ItFullScreenModal from "@/components/ui/ItFullScreenModal.vue";
import type { UserState } from "@/stores/user";
import type { CourseSession } from "@/types";
@ -44,7 +43,7 @@ const clickLink = (to: string | undefined) => {
class="mt-2 inline-block flex items-center"
@click="clickLink('/settings')"
>
<IconSettings class="inline-block" />
<it-icon-settings class="inline-block" />
<span class="ml-3">{{ $t("general.settings") }}</span>
</button>
</div>

View File

@ -33,7 +33,7 @@ onMounted(async () => {
<div class="grid auto-rows-fr grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="courseSession in courseSessionsStore.coursesFromCourseSessions"
v-for="courseSession in courseSessionsStore.userCourses"
:key="courseSession.id"
>
<div class="bg-white p-6 md:h-full">

View File

@ -189,6 +189,11 @@ function log(data: any) {
menu
<it-icon-menu />
</div>
<div class="inline-flex flex-col">
settings
<it-icon-settings />
</div>
</div>
<div class="mb-8 mt-8 flex flex-col flex-wrap gap-4 lg:flex-row">

View File

@ -72,7 +72,7 @@ export const useCourseSessionsStore = defineStore("courseSessions", () => {
const { courseSessions } = loadCourseSessionsData();
// these will become getters
const coursesFromCourseSessions = computed(() =>
const userCourses = computed(() =>
// TODO: refactor after implementing of Klassenkonzept
// @ts-ignore
uniqBy(courseSessions.value, "course.id")
@ -129,6 +129,11 @@ export const useCourseSessionsStore = defineStore("courseSessions", () => {
return courseSessionForCourse(courseSlug);
});
const courseSessionsForRoute = computed(() => {
const courseSlug = courseSlugForRoute.value;
return courseSessionsForCourse(courseSlug);
});
const hasCockpit = computed(() => {
if (courseSessionForRoute.value) {
const userStore = useUserStore();
@ -210,8 +215,10 @@ export const useCourseSessionsStore = defineStore("courseSessions", () => {
return {
courseSessions,
coursesFromCourseSessions,
userCourses,
courseSessionForRoute,
courseSessionsForRoute,
courseSessionsForCourse,
hasCockpit,
canUploadCircleDocuments,
circleDocuments,
@ -219,6 +226,5 @@ export const useCourseSessionsStore = defineStore("courseSessions", () => {
addDocument,
startUpload,
removeDocument,
courseSessionsForCourse,
};
});

View File

@ -234,6 +234,10 @@ def create_course_uk_de():
course_session=cs,
user=User.objects.get(username="student-uk1-zurich@eiger-versicherungen.ch"),
)
_csu = CourseSessionUser.objects.create(
course_session=cs,
user=User.objects.get(username="michael.meier@example.com"),
)
def create_course_uk_fr():