Merged in feature/VBV-310-VBV-324-document-improvements (pull request #66)

Feature/VBV-310 VBV 324 document improvements

Approved-by: Elia Bieri
This commit is contained in:
Ramon Wenger 2023-05-08 08:42:33 +00:00
commit a11c0d481f
12 changed files with 320 additions and 141 deletions

View File

@ -0,0 +1,35 @@
<template>
<Dialog :open="true" class="relative z-50" @close="close">
<div class="fixed inset-0 bg-black/30" aria-hidden="true"></div>
<div class="fixed inset-0 flex items-center justify-center p-4">
<DialogPanel class="w-full max-w-2xl bg-white pt-8">
<div class="px-8 pb-4">
<DialogTitle class="relative mb-8 flex flex-row">{{ title }}</DialogTitle>
<p v-html="content" />
</div>
<div class="flex flex-row-reverse gap-x-4 border-t border-t-gray-500 p-4">
<button class="btn-primary" @click="confirm">Löschen</button>
<button class="btn-secondary" @click="close">Abbrechen</button>
</div>
</DialogPanel>
</div>
</Dialog>
</template>
<script lang="ts" setup>
import { Dialog, DialogPanel, DialogTitle } from "@headlessui/vue";
defineProps<{
isOpen: boolean;
title: string;
content: string;
}>();
const emit = defineEmits(["confirm", "close"]);
const close = () => {
emit("close");
};
const confirm = () => {
emit("confirm");
};
</script>

View File

@ -1,11 +1,11 @@
<script setup lang="ts">
import { Dialog, DialogDescription, DialogPanel, DialogTitle } from "@headlessui/vue";
import { Dialog, DialogPanel, DialogTitle } from "@headlessui/vue";
interface Props {
export interface Props {
modelValue: boolean;
}
const props = withDefaults(defineProps<Props>(), {
withDefaults(defineProps<Props>(), {
modelValue: false,
});
@ -20,20 +20,22 @@ function setIsOpen(value: boolean) {
<Dialog :open="modelValue" class="relative z-50" @close="setIsOpen">
<div class="fixed inset-0 bg-black/30" aria-hidden="true"></div>
<div class="fixed inset-0 flex items-center justify-center p-4">
<DialogPanel class="w-full max-w-2xl bg-white px-8 pb-4 pt-8">
<DialogTitle class="relative mb-8 flex flex-row">
<slot name="title"></slot>
<button
type="button"
class="absolute right-4 h-4 w-4 cursor-pointer"
@click="setIsOpen(false)"
>
<it-icon-close></it-icon-close>
</button>
</DialogTitle>
<DialogDescription></DialogDescription>
<DialogPanel class="w-full max-w-2xl bg-white pt-8">
<div class="px-8 pb-4">
<DialogTitle class="relative mb-8 flex flex-row">
<slot name="title"></slot>
<button
type="button"
class="absolute right-4 h-4 w-4 cursor-pointer"
@click="setIsOpen(false)"
>
<it-icon-close></it-icon-close>
</button>
</DialogTitle>
<slot name="body"></slot>
<slot name="body"></slot>
</div>
<slot name="footer" />
</DialogPanel>
</div>
</Dialog>

View File

@ -30,6 +30,8 @@
"chooseLearningSequence": "Bitte wähle eine Lernsequenz aus",
"chooseName": "Bitte wähle einen Namen",
"chooseSequence": "Wähle eine Lernsequenz aus",
"deleteModalTitle": "Unterlage löschen",
"deleteModalWarning": "Willst du die Unterlage <strong>\"{title}\"</strong> löschen?<br> Diese Aktion ist nicht umkehrbar.",
"expertDescription": "Stelle deinen Lernenden zusätzliche Inhalte zur Verfügung.",
"fileLabel": "Datei",
"maxFileSize": "Maximale Dateigrösse: 20 MB",
@ -38,6 +40,10 @@
"modalNameInformation": "Max. 70 Zeichen",
"selectFile": "Bitte wähle eine Datei aus",
"title": "Unterlagen",
"trainerDescription": "Finde auf Teams zusätzliche Inhalte für deinen Unterricht.",
"trainerLinkSrc": "https://teams.microsoft.com",
"trainerLinkText": "Inhalte auf Teams anschauen",
"trainerTitle": "Begleitung für Trainer",
"uploadErrorMessage": "Beim Hochladen ist ein Fehler aufgetreten. Bitte versuche es erneut.",
"userDescription": "Hier findest du die Unterlagen, die dir die Fachexpertin zur Verfügung gestellt hat."
},

View File

@ -1,31 +1,29 @@
<script setup lang="ts">
import ItModal from "@/components/ui/ItModal.vue";
import * as log from "loglevel";
import { computed, onMounted, ref, watch } from "vue";
import log from "loglevel";
import { computed, onMounted } from "vue";
import CircleDiagram from "./CircleDiagram.vue";
import CircleOverview from "./CircleOverview.vue";
import DocumentUploadForm from "./DocumentUploadForm.vue";
import DocumentSection from "./DocumentSection.vue";
import LearningSequence from "./LearningSequence.vue";
import { uploadCircleDocument } from "@/services/files";
import { useAppStore } from "@/stores/app";
import { useCircleStore } from "@/stores/circle";
import { useCourseSessionsStore } from "@/stores/courseSessions";
import type { CourseSessionUser, DocumentUploadData } from "@/types";
import type { CourseSessionUser } from "@/types";
import { humanizeDuration } from "@/utils/humanizeDuration";
import sumBy from "lodash/sumBy";
import { useRoute } from "vue-router";
const route = useRoute();
const courseSessionsStore = useCourseSessionsStore();
interface Props {
export interface Props {
courseSlug: string;
circleSlug: string;
profileUser?: CourseSessionUser;
readonly?: boolean;
}
const route = useRoute();
const courseSessionsStore = useCourseSessionsStore();
const props = withDefaults(defineProps<Props>(), {
readonly: false,
profileUser: undefined,
@ -33,10 +31,6 @@ const props = withDefaults(defineProps<Props>(), {
log.debug("CirclePage created", props.readonly, props.profileUser);
const showUploadModal = ref(false);
const showUploadErrorMessage = ref(false);
const isUploading = ref(false);
const appStore = useAppStore();
appStore.showMainNavigationBar = true;
@ -51,15 +45,6 @@ const duration = computed(() => {
return "";
});
const dropdownLearningSequences = computed(() =>
circleStore.circle?.learningSequences.map((sequence) => ({
id: sequence.id,
name: sequence.title,
}))
);
watch(showUploadModal, (_v) => (showUploadErrorMessage.value = false));
onMounted(async () => {
log.debug(
"CirclePage mounted",
@ -107,28 +92,6 @@ onMounted(async () => {
log.error(error);
}
});
async function uploadDocument(data: DocumentUploadData) {
isUploading.value = true;
showUploadErrorMessage.value = false;
try {
if (!courseSessionsStore.currentCourseSession) {
throw new Error("No course session found");
}
const newDocument = await uploadCircleDocument(
data,
courseSessionsStore.currentCourseSession.id
);
const courseSessionStore = useCourseSessionsStore();
courseSessionStore.addDocument(newDocument);
showUploadModal.value = false;
isUploading.value = false;
} catch (error) {
log.error(error);
showUploadErrorMessage.value = true;
isUploading.value = false;
}
}
</script>
<template>
@ -213,60 +176,7 @@ async function uploadDocument(data: DocumentUploadData) {
{{ $t("circlePage.learnMore") }}
</button>
</div>
<div v-if="!props.readonly" class="mt-8 block border p-6">
<h3 class="text-blue-dark">
{{ $t("circlePage.documents.title") }}
</h3>
<div v-if="!courseSessionsStore.canUploadCircleDocuments">
<div class="mt-4 leading-relaxed">
{{ $t("circlePage.documents.userDescription") }}
</div>
</div>
<ol
v-if="
courseSessionsStore &&
courseSessionsStore.circleDocuments &&
courseSessionsStore.circleDocuments.length > 0
"
>
<li
v-for="learningSequence of courseSessionsStore.circleDocuments"
:key="learningSequence.id"
>
<h4 class="text-bold mt-4">{{ learningSequence.title }}</h4>
<ul>
<li
v-for="document of learningSequence.documents"
:key="document.url"
>
<a :href="document.url" download>
<span>{{ document.name }}</span>
</a>
<button
v-if="courseSessionsStore.canUploadCircleDocuments"
type="button"
class="relative top-[1px] ml-2 inline-block h-3 w-3 cursor-pointer leading-6"
@click="courseSessionsStore.removeDocument(document.id)"
>
<it-icon-close class="h-3 w-3"></it-icon-close>
</button>
</li>
</ul>
</li>
</ol>
<div v-if="courseSessionsStore.canUploadCircleDocuments">
<div class="mt-4 leading-relaxed">
{{ $t("circlePage.documents.expertDescription") }}
</div>
<button
class="btn-primary mt-4 text-xl"
@click="showUploadModal = true"
>
{{ $t("circlePage.documents.action") }}
</button>
</div>
</div>
<DocumentSection v-if="!readonly" />
<div v-if="!props.readonly" class="expert mt-8 border p-6">
<h3 class="text-blue-dark">{{ $t("circlePage.gotQuestions") }}</h3>
<div class="mt-4 leading-relaxed">
@ -312,17 +222,6 @@ async function uploadDocument(data: DocumentUploadData) {
</div>
</div>
</div>
<ItModal v-model="showUploadModal">
<template #title>{{ $t("circlePage.documents.action") }}</template>
<template #body>
<DocumentUploadForm
:learning-sequences="dropdownLearningSequences"
:show-upload-error-message="showUploadErrorMessage"
:is-uploading="isUploading"
@form-submit="uploadDocument"
/>
</template>
</ItModal>
</div>
</Transition>
</div>

View File

@ -0,0 +1,33 @@
<template>
<li
:key="doc.url"
class="grid grid-cols-document-list-item border-b border-b-gray-500 py-3 grid-areas-document-list-item"
>
<h3 class="grid-in-title">
{{ doc.name }}
</h3>
<h4 class="grid-in-subtitle">{{ subtitle }}</h4>
<div class="flex items-center justify-end gap-x-4 grid-in-icons">
<a v-if="canDelete" class="flex cursor-pointer" @click="emit('delete')">
<it-icon-delete class="h-8 w-8" />
</a>
<a download :href="doc.url" class="flex">
<it-icon-download class="h-8 w-8" />
</a>
</div>
</li>
</template>
<script setup lang="ts">
defineProps<{
doc: {
url: string;
name: string;
};
canDelete: boolean;
subtitle: string;
}>();
const emit = defineEmits(["delete"]);
</script>

View File

@ -0,0 +1,128 @@
<template>
<div class="mt-8 block border p-6">
<h3 class="text-blue-dark">
{{ $t("circlePage.documents.title") }}
</h3>
<div>
<div class="mt-4 leading-relaxed">
<template v-if="!courseSessionsStore.canUploadCircleDocuments">
{{ $t("circlePage.documents.userDescription") }}
</template>
<template v-else>
{{ $t("circlePage.documents.expertDescription") }}
</template>
</div>
</div>
<ul
v-if="courseSessionsStore.circleDocuments.length"
class="mt-8 border-t border-t-gray-500"
>
<template
v-for="learningSequence of courseSessionsStore.circleDocuments"
:key="learningSequence.id"
>
<DocumentListItem
v-for="doc of learningSequence.documents"
:key="doc.url"
:subtitle="learningSequence.title"
:can-delete="courseSessionsStore.canUploadCircleDocuments"
:doc="doc"
@delete="deleteDocument(doc)"
/>
</template>
</ul>
<div v-if="courseSessionsStore.canUploadCircleDocuments">
<button class="btn-primary mt-8 text-xl" @click="showUploadModal = true">
{{ $t("circlePage.documents.action") }}
</button>
</div>
</div>
<ItModal v-model="showUploadModal">
<template #title>{{ $t("circlePage.documents.action") }}</template>
<template #body>
<DocumentUploadForm
:learning-sequences="dropdownLearningSequences"
:show-upload-error-message="showUploadErrorMessage"
:is-uploading="isUploading"
@form-submit="uploadDocument"
/>
</template>
</ItModal>
<div
v-if="courseSessionsStore.canUploadCircleDocuments"
class="mt-8 flex flex-col gap-y-4 border p-6"
>
<h3 class="text-blue-dark">
{{ $t("circlePage.documents.trainerTitle") }}
</h3>
<div class="leading-relaxed">
{{ $t("circlePage.documents.trainerDescription") }}
</div>
<a target="_blank" class="link" :href="$t('circlePage.documents.trainerLinkSrc')">
{{ $t("circlePage.documents.trainerLinkText") }}
</a>
</div>
</template>
<script setup lang="ts">
import ItModal from "@/components/ui/ItModal.vue";
import { uploadCircleDocument } from "@/services/files";
import { useCircleStore } from "@/stores/circle";
import { useCourseSessionsStore } from "@/stores/courseSessions";
import type { CircleDocument, DocumentUploadData } from "@/types";
import dialog from "@/utils/confirm-dialog";
import log from "loglevel";
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import DocumentListItem from "./DocumentListItem.vue";
import DocumentUploadForm from "./DocumentUploadForm.vue";
const courseSessionsStore = useCourseSessionsStore();
const circleStore = useCircleStore();
const showUploadModal = ref(false);
const showUploadErrorMessage = ref(false);
const isUploading = ref(false);
const dropdownLearningSequences = computed(() =>
circleStore.circle?.learningSequences.map((sequence) => ({
id: sequence.id,
name: sequence.title,
}))
);
// confirm dialog
const { t } = useI18n();
const deleteDocument = async (doc: CircleDocument) => {
const options = {
title: t("circlePage.documents.deleteModalTitle"),
content: t("circlePage.documents.deleteModalWarning", { title: doc.name }),
};
try {
await dialog.confirm(options);
courseSessionsStore.removeDocument(doc.id);
} catch (e) {
log.debug("rejected");
}
};
watch(showUploadModal, () => (showUploadErrorMessage.value = false));
async function uploadDocument(data: DocumentUploadData) {
isUploading.value = true;
showUploadErrorMessage.value = false;
try {
if (!courseSessionsStore.currentCourseSession) {
throw new Error("No course session found");
}
const newDocument = await uploadCircleDocument(
data,
courseSessionsStore.currentCourseSession.id
);
const courseSessionStore = useCourseSessionsStore();
courseSessionStore.addDocument(newDocument);
showUploadModal.value = false;
isUploading.value = false;
} catch (error) {
log.error(error);
showUploadErrorMessage.value = true;
isUploading.value = false;
}
}
</script>

View File

@ -4,7 +4,7 @@ import type { DocumentUploadData, DropdownSelectable } from "@/types";
import { reactive } from "vue";
import { useI18n } from "vue-i18n";
interface Props {
export interface Props {
learningSequences: DropdownSelectable[];
showUploadErrorMessage: boolean;
isUploading: boolean;

View File

@ -182,21 +182,23 @@ export const useCourseSessionsStore = defineStore("courseSessions", () => {
const circleDocuments = computed(() => {
const circleStore = useCircleStore();
return circleStore.circle?.learningSequences
.map((ls) => ({ id: ls.id, title: ls.title, documents: [] }))
.map((ls: { id: number; title: string; documents: CircleDocument[] }) => {
if (currentCourseSession.value === undefined) {
return ls;
}
for (const document of currentCourseSession.value.documents) {
if (document.learning_sequence === ls.id) {
ls.documents.push(document);
return (
circleStore.circle?.learningSequences
.map((ls) => ({ id: ls.id, title: ls.title, documents: [] }))
.map((ls: { id: number; title: string; documents: CircleDocument[] }) => {
if (currentCourseSession.value === undefined) {
return ls;
}
}
return ls;
})
.filter((ls) => ls.documents.length > 0);
for (const document of currentCourseSession.value.documents) {
if (document.learning_sequence === ls.id) {
ls.documents.push(document);
}
}
return ls;
})
.filter((ls) => ls.documents.length > 0) || []
);
});
function addDocument(document: CircleDocument) {

View File

@ -0,0 +1,67 @@
import ConfirmDialog from "@/components/ui/ConfirmDialog.vue";
import { createApp } from "vue";
interface ConfirmDialogOptions {
title: string;
content: string;
}
type ResolveReject = (v?: unknown) => void;
// inspired by https://stackoverflow.com/a/69773076/6071058
/*
* We need a separate service for the ConfirmDialog, because we don't want the
* boilerplate of including the component and the handling of the promises inside
* of every component where we need a confirm dialog.
*
* With this service, one can simply import the dialog and use it, e.g.
*
import dialog from "@/utils/confirm-dialog";
const someMethodToConfirm = async () => {
const options = {
title: 'Dialog Title',
content: 'Do you really wanna?'
};
try {
await dialog.confirm(options);
doSomethingWhenConfirmed();
} catch (e) {
log.debug("rejected");
doSomethingWhenRejected()
}
};
*/
export default {
confirm(options: ConfirmDialogOptions) {
const mountEl = document.createElement("div");
document.body.appendChild(mountEl);
let _resolve: ResolveReject, _reject: ResolveReject;
const promise = new Promise((resolve, reject) => {
_resolve = resolve;
_reject = reject;
});
const cleanUp = () => {
mountEl?.parentNode?.removeChild(mountEl);
dialog.unmount();
};
const dialog = createApp(ConfirmDialog, {
isOpen: true,
title: options.title,
content: options.content,
onClose() {
cleanUp();
_reject();
},
onConfirm() {
cleanUp();
_resolve();
},
});
dialog.mount(mountEl);
return promise;
},
};

View File

@ -51,11 +51,13 @@ module.exports = {
],
"rating-scale-slim": ["bar bar bar", "fst mid fth"],
"icon-card": ["icon title", "icon value"],
"document-list-item": ["title icons", "subtitle icons"],
},
gridTemplateColumns: {
"horizontal-bar-chart": "50px 1fr 300px 4fr 300px 1fr",
"horizontal-bar-chart-slim": "50px 1fr 78px 4fr 78px 1fr",
"icon-card": "60px auto",
"document-list-item": "1fr 100px",
},
gridTemplateRows: {
"horizontal-bar-chart": "200px 40px",

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Outline" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30">
<path d="m25.63,7.06h-6.08v-1.05c0-1.18-.96-2.14-2.14-2.14h-4.83c-1.18,0-2.14.96-2.14,2.14v1.05h-6.08v1.5h2.61l.82,15.54c.06,1.14,1,2.03,2.14,2.03h10.14c1.14,0,2.08-.89,2.14-2.03l.82-15.54h2.61v-1.5Zm-13.69-1.05c0-.35.29-.64.64-.64h4.83c.35,0,.64.29.64.64v1.05h-6.11v-1.05Zm8.76,18.02c-.02.34-.3.61-.64.61h-10.14c-.34,0-.62-.27-.64-.61l-.81-15.46h13.04l-.81,15.46Z"/>
</svg>

After

Width:  |  Height:  |  Size: 489 B

View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30"><path d="M15,22.31c.19,0,.38-.07,.53-.22l7.45-7.45-1.06-1.06-6.17,6.17V3.83h-1.5v15.92l-6.17-6.17-1.06,1.06,7.45,7.45c.15,.15,.34,.22,.53,.22Z"/><rect x="4.94" y="24.84" width="20.11" height="1.5"/></svg>

After

Width:  |  Height:  |  Size: 309 B