feat(frontend): basisimplementatie leerobject upload-UI
This commit is contained in:
parent
6600441b08
commit
be1091544c
11 changed files with 311 additions and 40 deletions
|
@ -2,7 +2,6 @@ import { DwengoEntityRepository } from '../dwengo-entity-repository.js';
|
|||
import { LearningObject } from '../../entities/content/learning-object.entity.js';
|
||||
import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js';
|
||||
import { Language } from '@dwengo-1/common/util/language';
|
||||
import { Teacher } from '../../entities/users/teacher.entity.js';
|
||||
|
||||
export class LearningObjectRepository extends DwengoEntityRepository<LearningObject> {
|
||||
public async findByIdentifier(identifier: LearningObjectIdentifier): Promise<LearningObject | null> {
|
||||
|
@ -35,7 +34,11 @@ export class LearningObjectRepository extends DwengoEntityRepository<LearningObj
|
|||
|
||||
public async findAllByAdmin(adminUsername: string): Promise<LearningObject[]> {
|
||||
return this.find(
|
||||
{ admins: { $contains: adminUsername } },
|
||||
{
|
||||
admins: {
|
||||
username: adminUsername
|
||||
}
|
||||
},
|
||||
{ populate: ['admins'] } // Make sure to load admin relations
|
||||
);
|
||||
}
|
||||
|
|
|
@ -9,7 +9,6 @@ import {
|
|||
} from '@dwengo-1/common/interfaces/learning-content';
|
||||
import {getLearningObjectRepository, getTeacherRepository} from "../../data/repositories";
|
||||
import {processLearningObjectZip} from "./learning-object-zip-processing-service";
|
||||
import {BadRequestException} from "../../exceptions/bad-request-exception";
|
||||
import {LearningObject} from "../../entities/content/learning-object.entity";
|
||||
|
||||
function getProvider(id: LearningObjectIdentifierDTO): LearningObjectProvider {
|
||||
|
@ -67,7 +66,6 @@ const learningObjectService = {
|
|||
const learningObjectRepository = getLearningObjectRepository();
|
||||
const learningObject = await processLearningObjectZip(learningObjectPath);
|
||||
|
||||
console.log(learningObject);
|
||||
if (!learningObject.hruid.startsWith(getEnvVar(envVars.UserContentPrefix))) {
|
||||
learningObject.hruid = getEnvVar(envVars.UserContentPrefix) + learningObject.hruid;
|
||||
}
|
||||
|
@ -75,10 +73,10 @@ const learningObjectService = {
|
|||
// Lookup the admin teachers based on their usernames and add them to the admins of the learning object.
|
||||
const teacherRepo = getTeacherRepository();
|
||||
const adminTeachers = await Promise.all(
|
||||
admins.map(it => teacherRepo.findByUsername(it))
|
||||
admins.map(async it => teacherRepo.findByUsername(it))
|
||||
);
|
||||
adminTeachers.forEach(it => {
|
||||
if (it != null) {
|
||||
if (it !== null) {
|
||||
learningObject.admins.add(it);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -4,6 +4,7 @@ import {LearningObject} from "../../entities/content/learning-object.entity";
|
|||
import {getAttachmentRepository, getLearningObjectRepository} from "../../data/repositories";
|
||||
import {BadRequestException} from "../../exceptions/bad-request-exception";
|
||||
import {LearningObjectMetadata} from "@dwengo-1/common/dist/interfaces/learning-content";
|
||||
import { ReturnValue } from '../../entities/content/return-value.entity';
|
||||
|
||||
const METADATA_PATH_REGEX = /.*[/^]metadata\.json$/;
|
||||
const CONTENT_PATH_REGEX = /.*[/^]content\.[a-zA-Z]*$/;
|
||||
|
@ -13,33 +14,38 @@ const CONTENT_PATH_REGEX = /.*[/^]content\.[a-zA-Z]*$/;
|
|||
* @param filePath Path of the zip file to process.
|
||||
*/
|
||||
export async function processLearningObjectZip(filePath: string): Promise<LearningObject> {
|
||||
const learningObjectRepo = getLearningObjectRepository();
|
||||
const attachmentRepo = getAttachmentRepository();
|
||||
let zip: unzipper.CentralDirectory;
|
||||
try {
|
||||
zip = await unzipper.Open.file(filePath);
|
||||
} catch(_: unknown) {
|
||||
throw new BadRequestException("invalid_zip");
|
||||
}
|
||||
|
||||
const zip = await unzipper.Open.file(filePath);
|
||||
|
||||
let metadata: LearningObjectMetadata | undefined = undefined;
|
||||
const attachments: {name: string, content: Buffer}[] = [];
|
||||
let content: Buffer | undefined = undefined;
|
||||
|
||||
if (zip.files.length == 0) {
|
||||
if (zip.files.length === 0) {
|
||||
throw new BadRequestException("empty_zip")
|
||||
}
|
||||
|
||||
for (const file of zip.files) {
|
||||
if (file.type !== "Directory") {
|
||||
if (METADATA_PATH_REGEX.test(file.path)) {
|
||||
metadata = await processMetadataJson(file);
|
||||
} else if (CONTENT_PATH_REGEX.test(file.path)) {
|
||||
content = await processFile(file);
|
||||
} else {
|
||||
attachments.push({
|
||||
name: file.path,
|
||||
content: await processFile(file)
|
||||
});
|
||||
await Promise.all(
|
||||
zip.files.map(async file => {
|
||||
if (file.type !== "Directory") {
|
||||
if (METADATA_PATH_REGEX.test(file.path)) {
|
||||
metadata = await processMetadataJson(file);
|
||||
} else if (CONTENT_PATH_REGEX.test(file.path)) {
|
||||
content = await processFile(file);
|
||||
} else {
|
||||
attachments.push({
|
||||
name: file.path,
|
||||
content: await processFile(file)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (!metadata) {
|
||||
throw new BadRequestException("missing_metadata");
|
||||
|
@ -49,20 +55,30 @@ export async function processLearningObjectZip(filePath: string): Promise<Learni
|
|||
}
|
||||
|
||||
|
||||
const learningObject = createLearningObject(metadata, content, attachments);
|
||||
|
||||
return learningObject;
|
||||
}
|
||||
|
||||
function createLearningObject(
|
||||
metadata: LearningObjectMetadata, content: Buffer, attachments: { name: string; content: Buffer; }[]
|
||||
): LearningObject {
|
||||
const learningObjectRepo = getLearningObjectRepository();
|
||||
const attachmentRepo = getAttachmentRepository();
|
||||
const learningObject = learningObjectRepo.create({
|
||||
admins: [],
|
||||
available: metadata.available ?? true,
|
||||
content: content,
|
||||
contentType: metadata.content_type,
|
||||
copyright: metadata.copyright,
|
||||
description: metadata.description,
|
||||
educationalGoals: metadata.educational_goals,
|
||||
copyright: metadata.copyright ?? "",
|
||||
description: metadata.description ?? "",
|
||||
educationalGoals: metadata.educational_goals ?? [],
|
||||
hruid: metadata.hruid,
|
||||
keywords: metadata.keywords,
|
||||
language: metadata.language,
|
||||
license: "",
|
||||
returnValue: metadata.return_value,
|
||||
skosConcepts: metadata.skos_concepts,
|
||||
license: metadata.license ?? "",
|
||||
returnValue: metadata.return_value ?? new ReturnValue(),
|
||||
skosConcepts: metadata.skos_concepts ?? [],
|
||||
teacherExclusive: metadata.teacher_exclusive,
|
||||
title: metadata.title,
|
||||
version: metadata.version
|
||||
|
@ -72,9 +88,8 @@ export async function processLearningObjectZip(filePath: string): Promise<Learni
|
|||
content: it.content,
|
||||
mimeType: mime.lookup(it.name) || "text/plain",
|
||||
learningObject
|
||||
}))
|
||||
attachmentEntities.forEach(it => learningObject.attachments.add(it));
|
||||
|
||||
}));
|
||||
attachmentEntities.forEach(it => { learningObject.attachments.add(it); });
|
||||
return learningObject;
|
||||
}
|
||||
|
||||
|
|
|
@ -37,6 +37,28 @@ export abstract class BaseController {
|
|||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a POST-request with a form-data body with the given file.
|
||||
*
|
||||
* @param path Relative path in the api to send the request to.
|
||||
* @param formFieldName The name of the form field in which the file should be.
|
||||
* @param file The file to upload.
|
||||
* @param queryParams The query parameters.
|
||||
* @returns The response the POST request generated.
|
||||
*/
|
||||
protected async postFile<T>(path: string, formFieldName: string, file: File, queryParams?: QueryParams): Promise<T> {
|
||||
const formData = new FormData();
|
||||
formData.append(formFieldName, file);
|
||||
const response = await apiClient.post<T>(this.absolutePathFor(path), formData, {
|
||||
params: queryParams,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
BaseController.assertSuccessResponse(response)
|
||||
return response.data;
|
||||
}
|
||||
|
||||
protected async delete<T>(path: string, queryParams?: QueryParams): Promise<T> {
|
||||
const response = await apiClient.delete<T>(this.absolutePathFor(path), { params: queryParams });
|
||||
BaseController.assertSuccessResponse(response);
|
||||
|
|
|
@ -14,4 +14,12 @@ export class LearningObjectController extends BaseController {
|
|||
async getHTML(hruid: string, language: Language, version: number): Promise<Document> {
|
||||
return this.get<Document>(`/${hruid}/html`, { language, version }, "document");
|
||||
}
|
||||
|
||||
async getAllAdministratedBy(admin: string): Promise<LearningObject[]> {
|
||||
return this.get<LearningObject[]>("/", { admin });
|
||||
}
|
||||
|
||||
async upload(learningObjectZip: File): Promise<LearningObject> {
|
||||
return this.postFile<LearningObject>("/", "learningObject", learningObjectZip);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import { type MaybeRefOrGetter, toValue } from "vue";
|
||||
import type { Language } from "@/data-objects/language.ts";
|
||||
import { useQuery, type UseQueryReturnType } from "@tanstack/vue-query";
|
||||
import { useMutation, useQuery, useQueryClient, type UseMutationReturnType, type UseQueryReturnType } from "@tanstack/vue-query";
|
||||
import { getLearningObjectController } from "@/controllers/controllers.ts";
|
||||
import type { LearningObject } from "@/data-objects/learning-objects/learning-object.ts";
|
||||
import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts";
|
||||
import type { AxiosError } from "axios";
|
||||
|
||||
export const LEARNING_OBJECT_KEY = "learningObject";
|
||||
const learningObjectController = getLearningObjectController();
|
||||
|
@ -24,15 +25,15 @@ export function useLearningObjectMetadataQuery(
|
|||
}
|
||||
|
||||
export function useLearningObjectHTMLQuery(
|
||||
hruid: MaybeRefOrGetter<string>,
|
||||
language: MaybeRefOrGetter<Language>,
|
||||
version: MaybeRefOrGetter<number>,
|
||||
hruid: MaybeRefOrGetter<string | undefined>,
|
||||
language: MaybeRefOrGetter<Language | undefined>,
|
||||
version: MaybeRefOrGetter<number | undefined>,
|
||||
): UseQueryReturnType<Document, Error> {
|
||||
return useQuery({
|
||||
queryKey: [LEARNING_OBJECT_KEY, "html", hruid, language, version],
|
||||
queryFn: async () => {
|
||||
const [hruidVal, languageVal, versionVal] = [toValue(hruid), toValue(language), toValue(version)];
|
||||
return learningObjectController.getHTML(hruidVal, languageVal, versionVal);
|
||||
return learningObjectController.getHTML(hruidVal!, languageVal!, versionVal!);
|
||||
},
|
||||
enabled: () => Boolean(toValue(hruid)) && Boolean(toValue(language)) && Boolean(toValue(version)),
|
||||
});
|
||||
|
@ -55,3 +56,25 @@ export function useLearningObjectListForPathQuery(
|
|||
enabled: () => Boolean(toValue(learningPath)),
|
||||
});
|
||||
}
|
||||
|
||||
export function useLearningObjectListForAdminQuery(
|
||||
admin: MaybeRefOrGetter<string | undefined>
|
||||
): UseQueryReturnType<LearningObject[], Error> {
|
||||
return useQuery({
|
||||
queryKey: [LEARNING_OBJECT_KEY, "forAdmin", admin],
|
||||
queryFn: async () => {
|
||||
const adminVal = toValue(admin);
|
||||
return await learningObjectController.getAllAdministratedBy(adminVal!);
|
||||
},
|
||||
enabled: () => toValue(admin) !== undefined
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadLearningObjectMutation(): UseMutationReturnType<LearningObject, AxiosError, {learningObjectZip: File}, unknown> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ learningObjectZip }) => await learningObjectController.upload(learningObjectZip),
|
||||
onSuccess: async () => { await queryClient.invalidateQueries({queryKey: [LEARNING_OBJECT_KEY, "forAdmin"]}); }
|
||||
});
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ import UserHomePage from "@/views/homepage/UserHomePage.vue";
|
|||
import SingleTheme from "@/views/SingleTheme.vue";
|
||||
import LearningObjectView from "@/views/learning-paths/learning-object/LearningObjectView.vue";
|
||||
import authService from "@/services/auth/auth-service";
|
||||
import OwnLearningContentPage from "@/views/own-learning-content/OwnLearningContentPage.vue";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
|
@ -114,6 +115,12 @@ const router = createRouter({
|
|||
component: LearningPathSearchPage,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "my",
|
||||
name: "OwnLearningContentPage",
|
||||
component: OwnLearningContentPage,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: ":hruid/:language/:learningObjectHruid",
|
||||
name: "LearningPath",
|
||||
|
|
|
@ -0,0 +1,76 @@
|
|||
<script setup lang="ts">
|
||||
import { useUploadLearningObjectMutation } from '@/queries/learning-objects';
|
||||
import { ref, watch, type Ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { VFileUpload } from 'vuetify/labs/VFileUpload';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const dialogOpen = ref(false);
|
||||
|
||||
interface ContainsErrorString {
|
||||
error: string;
|
||||
}
|
||||
|
||||
const fileToUpload: Ref<File | undefined> = ref(undefined);
|
||||
|
||||
const { isPending, error, isError, isSuccess, mutate } = useUploadLearningObjectMutation();
|
||||
|
||||
watch(isSuccess, (newIsSuccess) => {
|
||||
if (newIsSuccess) {
|
||||
dialogOpen.value = false;
|
||||
fileToUpload.value = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
function uploadFile() {
|
||||
if (fileToUpload.value) {
|
||||
mutate({learningObjectZip: fileToUpload.value});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<v-dialog max-width="500" v-model="dialogOpen">
|
||||
<template v-slot:activator="{ props: activatorProps }">
|
||||
<v-fab icon="mdi mdi-plus" v-bind="activatorProps"></v-fab>
|
||||
</template>
|
||||
|
||||
<template v-slot:default="{ isActive }">
|
||||
<v-card :title="t('learning_object_upload_title')">
|
||||
<v-card-text>
|
||||
<v-file-upload
|
||||
:browse-text="t('upload_browse')"
|
||||
:divider-text="t('upload_divider')"
|
||||
icon="mdi-upload"
|
||||
:title="t('upload_drag_and_drop')"
|
||||
v-model="fileToUpload"
|
||||
:disabled="isPending"
|
||||
></v-file-upload>
|
||||
<v-alert
|
||||
v-if="error"
|
||||
icon="mdi mdi-alert-circle"
|
||||
type="error"
|
||||
:title="t('upload_failed')"
|
||||
:text="t((error.response?.data as ContainsErrorString).error ?? error.message)"
|
||||
></v-alert>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
:text="t('cancel')"
|
||||
@click="isActive.value = false"
|
||||
></v-btn>
|
||||
<v-btn
|
||||
:text="t('upload')"
|
||||
@click="uploadFile()"
|
||||
:loading="isPending"
|
||||
:disabled="!fileToUpload"
|
||||
></v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
</v-dialog>
|
||||
</template>
|
||||
<style scoped>
|
||||
</style>
|
|
@ -1,11 +1,52 @@
|
|||
<script setup lang="ts">
|
||||
import {useLearningObjectListForAdminQuery} from "@/queries/learning-objects.ts";
|
||||
import OwnLearningObjectsView from "@/views/own-learning-content/OwnLearningObjectsView.vue"
|
||||
import OwnLearningPathsView from "@/views/own-learning-content/OwnLearningPathsView.vue"
|
||||
import authService from "@/services/auth/auth-service.ts";
|
||||
import UsingQueryResult from "@/components/UsingQueryResult.vue";
|
||||
import type { LearningObject } from "@/data-objects/learning-objects/learning-object";
|
||||
import { ref, type Ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const learningObjectsQuery =
|
||||
useLearningObjectListForAdminQuery(authService.authState.user?.profile.preferred_username);
|
||||
|
||||
type Tab = "learningObjects" | "learningPaths";
|
||||
const tab: Ref<Tab> = ref("learningObjects");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tab-pane-container">
|
||||
<v-tabs v-model="tab">
|
||||
<v-tab value="learningObjects">{{ t('learningObjects') }}</v-tab>
|
||||
<v-tab value="learningPaths">{{ t('learningPaths') }}</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<v-tabs-window v-model="tab" class="main-content">
|
||||
<v-tabs-window-item value="learningObjects" class="main-content">
|
||||
<using-query-result
|
||||
:query-result="learningObjectsQuery"
|
||||
v-slot="response: { data: LearningObject[] }"
|
||||
>
|
||||
<own-learning-objects-view :learningObjects="response.data"></own-learning-objects-view>
|
||||
</using-query-result>
|
||||
</v-tabs-window-item>
|
||||
<v-tabs-window-item value="learningPaths">
|
||||
<own-learning-paths-view/>
|
||||
</v-tabs-window-item>
|
||||
</v-tabs-window>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.tab-pane-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
.main-content {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,11 +1,80 @@
|
|||
<script setup lang="ts">
|
||||
import type { LearningObject } from '@/data-objects/learning-objects/learning-object';
|
||||
import LearningObjectUploadButton from '@/views/own-learning-content/LearningObjectUploadButton.vue'
|
||||
import LearningObjectContentView from '../learning-paths/learning-object/content/LearningObjectContentView.vue';
|
||||
import { computed, ref, type Ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useLearningObjectHTMLQuery } from '@/queries/learning-objects';
|
||||
import UsingQueryResult from '@/components/UsingQueryResult.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const props = defineProps<{
|
||||
learningObjects: LearningObject[]
|
||||
}>();
|
||||
|
||||
const tableHeaders = [
|
||||
{ title: t("hruid"), width: "250px", key: "key" },
|
||||
{ title: t("language"), width: "50px", key: "language" },
|
||||
{ title: t("version"), width: "50px", key: "version" },
|
||||
{ title: t("title"), key: "title" }
|
||||
];
|
||||
|
||||
const selectedLearningObjects: Ref<LearningObject[]> = ref([])
|
||||
|
||||
const selectedLearningObject = computed(() =>
|
||||
selectedLearningObjects.value ? selectedLearningObjects.value[0] : undefined
|
||||
)
|
||||
|
||||
const learningObjectQueryResult = useLearningObjectHTMLQuery(
|
||||
() => selectedLearningObject.value?.key,
|
||||
() => selectedLearningObject.value?.language,
|
||||
() => selectedLearningObject.value?.version
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div class="root">
|
||||
<v-data-table
|
||||
class="table"
|
||||
v-model="selectedLearningObjects"
|
||||
:items="props.learningObjects"
|
||||
:headers="tableHeaders"
|
||||
select-strategy="single"
|
||||
show-select
|
||||
return-object
|
||||
/>
|
||||
<v-card
|
||||
class="preview"
|
||||
v-if="selectedLearningObjects.length > 0"
|
||||
:title="t('preview_for') + selectedLearningObjects[0].title"
|
||||
>
|
||||
<template v-slot:text>
|
||||
<using-query-result :query-result="learningObjectQueryResult" v-slot="response: { data: Document }">
|
||||
<learning-object-content-view :learning-object-content="response.data"></learning-object-content-view>
|
||||
</using-query-result>
|
||||
</template>
|
||||
</v-card>
|
||||
</div>
|
||||
<div class="fab">
|
||||
<learning-object-upload-button/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.fab {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
}
|
||||
.root {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
}
|
||||
.preview {
|
||||
flex: 1;
|
||||
}
|
||||
.table {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
<script lang="ts">
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p>Own learning paths</p>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
</style>
|
Loading…
Add table
Add a link
Reference in a new issue