diff --git a/.eslintrc.js b/.eslintrc.js index af6684f5fa25103b99509cad538e95995403cf41..bee267378c3cc4b67bf8cd3dc6bfd97578fa52ca 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -25,6 +25,7 @@ module.exports = { { "allowWholeFile": true } ], "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }], // will only ignore variables that start with an underscore _ + "@typescript-eslint/no-inferrable-types": "off", // this enforces the removal of the type declarations for variables that are initialized with a value (e.g. let foo: string = "foo") "vue/multi-word-component-names": "off" }, } diff --git a/package.json b/package.json index ddcaf4e0098a630619d85b05900143449f6ed996..ed5252c419779c681b4c794139f8709b04c48646 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "coverage": "vitest run --coverage" }, "dependencies": { - "@coscine/api-client": "^3.0.0", + "@coscine/api-client": "^3.1.0", "@coscine/form-generator": "^3.2.2", "@dynamic-mapper/mapper": "^1.10.2", "@pinia/testing": "^0.1.2", @@ -92,6 +92,7 @@ "vite": "^4.3.9", "vite-aliases": "^0.11.2", "vite-plugin-node-polyfills": "^0.9.0", + "vite-plugin-rewrite-all": "^1.0.1", "vite-plugin-windicss": "^1.9.0", "vitest": "^0.32.2", "vue-template-compiler": "^2.7.14" diff --git a/src/components/coscine/CoscineFormGroup.vue b/src/components/coscine/CoscineFormGroup.vue index 5e636e6fe129ae7b387c8e39cef5feadaf5ad8ad..43feda82f887ce82304fd2d9bf6b8a59722a1b8f 100644 --- a/src/components/coscine/CoscineFormGroup.vue +++ b/src/components/coscine/CoscineFormGroup.vue @@ -1,45 +1,55 @@ <template> - <b-form-group - :class="mandatory === true ? 'mandatory' : ''" - :label-for="labelFor" - :label-cols-sm="labelColsSm" - :label-align-sm="labelAlignSm" - > - <!-- Loading Skeleton --> - <b-skeleton-wrapper :loading="isLoading"> - <template #loading> - <b-skeleton :type="type" animation="fade"></b-skeleton> - </template> - <slot /> - </b-skeleton-wrapper> + <b-container class="mb-3 p-0 w-100"> + <b-row no-gutters> + <b-form-group + class="w-100 m-0" + :class="mandatory === true ? 'mandatory' : ''" + :label-for="labelFor" + :label-cols-sm="labelColsSm" + :label-align-sm="labelAlignSm" + > + <!-- Loading Skeleton --> + <b-skeleton-wrapper :loading="isLoading"> + <template #loading> + <b-skeleton :type="type" animation="fade"></b-skeleton> + </template> - <!-- Label Template --> - <template #label> - <!-- Label --> - <span id="label" class="text-break">{{ label }}</span> + <div class="flex-center"> + <slot /> + </div> + </b-skeleton-wrapper> - <div v-if="info" class="d-inline ml-1"> - <!-- Information Circle Icon --> - <b-icon :id="`${labelFor}Info`" icon="info-circle" /> + <!-- Label Template --> + <template #label> + <!-- Label --> + <span id="label" class="text-break">{{ label }}</span> - <!-- Popover --> - <b-popover - over - :target="`${labelFor}Info`" - triggers="hover focus" - placement="bottom" - > - <!-- Popover Contents --> - <slot name="popover" /> - </b-popover> - </div> - </template> + <div v-if="info" class="d-inline ml-1"> + <!-- Information Circle Icon --> + <b-icon :id="`${labelFor}Info`" icon="info-circle" /> - <!-- Hint Text --> - <div id="hint" class="small text-muted"> - <slot name="hint" /> - </div> - </b-form-group> + <!-- Popover --> + <b-popover + over + :target="`${labelFor}Info`" + triggers="hover focus" + placement="bottom" + > + <!-- Popover Contents --> + <slot name="popover" /> + </b-popover> + </div> + </template> + </b-form-group> + </b-row> + <b-row no-gutters> + <b-col :sm="labelColsSm" /> + <!-- Hint Text --> + <b-col id="hint" class="small text-muted pl-1" align-self="end"> + <slot name="hint" /> + </b-col> + </b-row> + </b-container> </template> <script lang="ts"> @@ -90,4 +100,18 @@ export default defineComponent({ content: " *"; color: #a70619; } +.flex-center { + /* Needed to center the content vertically */ + display: flex; + align-items: center; + height: 100%; + width: 100%; +} +#hint { + position: relative; +} +.form-row :deep(.col) { + /* Removes the right padding from the inner col, making it go until the end */ + padding: 0 0 0 5px; +} </style> diff --git a/src/components/elements/BreadCrumbs.vue b/src/components/elements/BreadCrumbs.vue index de4f63aadb9bac5afaa16d8a8a9412f06dea5f6e..b99f12c01a0dd3d13dff34382a3dd1c460989073 100644 --- a/src/components/elements/BreadCrumbs.vue +++ b/src/components/elements/BreadCrumbs.vue @@ -1,10 +1,13 @@ <template> <b-breadcrumb class="breadcrumbs"> + <!-- Home --> <b-breadcrumb-item :to="{ name: 'home' }" :active="crumbs.length === 0"> <b-icon v-if="crumbs.length === 0" icon="house-fill" aria-hidden="true" /> <b-icon v-else icon="house" aria-hidden="true" /> {{ $t(`breadcrumbs.home`) }} </b-breadcrumb-item> + + <!-- Rest --> <b-breadcrumb-item v-for="(crumb, id) in crumbs" :key="id" @@ -29,6 +32,7 @@ import type { ProjectDto, ResourceDto, } from "@coscine/api-client/dist/types/Coscine.Api/api"; +import type { RouteRecord } from "vue-router"; interface RouteLink { to: RawLocation; @@ -46,72 +50,14 @@ export default defineComponent({ computed: { crumbs(): RouteLink[] { - let pathArray = this.$route.path.split("/"); - pathArray = pathArray.filter( - (path) => - path.trim() !== "" && path.trim() !== "p" && path.trim() !== "r" - ); - // Deal with Root Path inclusion - if (pathArray.length === 1) { - pathArray.unshift(""); - } - const breadcrumbs = pathArray.reduce( - (breadcrumbArray: RouteLink[], path, idx) => { - // Deal with Root Path inclusion - if (path === "") { - return breadcrumbArray; - } - breadcrumbArray.push({ - to: { - name: this.$route.matched[idx].name - ? this.$route.matched[idx].name - : this.$route.matched[idx].meta.default, - }, - text: - this.$route.matched[idx] && - this.$route.matched[idx].meta && - this.$route.matched[idx].meta.breadCrumb - ? this.$t( - `breadcrumbs.${this.$route.matched[idx].meta.breadCrumb}`, - { - path: path, - projectName: this.project - ? this.project.displayName - : path, - resourceName: - this.resource && this.resource.type - ? `${this.$t( - "resourceTypes." + - this.resource.type.specificType + - ".displayName" - )}: ${this.resource.displayName}` - : path, - } - ).toString() - : path, - }); - return breadcrumbArray; - }, - [] - ); - if (this.project && this.parentProjects) { - const parentBreadCrumbs = this.parentProjects.map((parentProject) => { - return { - to: { - name: "project-page", - params: { slug: parentProject.slug }, - }, - text: this.$t(`breadcrumbs.project.page`, { - projectName: parentProject.displayName, - }).toString(), - } as RouteLink; - }); - breadcrumbs.unshift(...parentBreadCrumbs); - } - if (breadcrumbs.length > 0) { - breadcrumbs[breadcrumbs.length - 1].active = true; - } - return breadcrumbs; + // Get the relevant path from the route (ignores everything after "/-") + const relevantPath = this.$route.path.split("/-")[0]; + // Filter out empty paths and the project and resource path + let pathArray = this.filterPaths(relevantPath.split("/")); + pathArray = this.includeRootPath(pathArray); + let breadcrumbs = this.generateRouteLinks(pathArray); + breadcrumbs = this.addParentProjects(breadcrumbs); + return this.markLastActive(breadcrumbs); }, parentProjects(): ProjectDto[] | null { @@ -155,7 +101,11 @@ export default defineComponent({ }, methods: { - setDocumentTitle(title = "") { + /** + * Set the document title. + * @param {string} [title=""] - The title to set. If empty, the default title will be used. + */ + setDocumentTitle(title: string = "") { if (title.trim() !== "") { document.title = this.$t("title.modified", { title: title, @@ -164,6 +114,126 @@ export default defineComponent({ document.title = this.$t("title.default").toString(); } }, + + /** + * Filters out unwanted paths from the given array. + * @param {string[]} paths - The array of paths. + * @returns {string[]} - The filtered array of paths. + */ + filterPaths(paths: string[]): string[] { + return paths.filter( + (path) => + path.trim() !== "" && + path.trim() !== "p" && + path.trim() !== "r" && + path.trim() !== "-" + ); + }, + + /** + * Adds a root path if the path array contains only one element. + * @param {string[]} pathArray - The array of paths. + * @returns {string[]} - The array of paths possibly including the root. + */ + includeRootPath(pathArray: string[]): string[] { + if (pathArray.length === 1) { + pathArray.unshift(""); + } + return pathArray; + }, + + /** + * Generates RouteLink objects based on the path array. + * @param {string[]} pathArray - The array of paths. + * @returns {RouteLink[]} - The array of RouteLink objects. + */ + generateRouteLinks(pathArray: string[]): RouteLink[] { + return pathArray.reduce((breadcrumbArray: RouteLink[], path, idx) => { + if (path === "") return breadcrumbArray; + const routeMatched = this.$route.matched[idx]; + if (routeMatched) { + breadcrumbArray.push(this.generateRouteLink(routeMatched, path)); + } + return breadcrumbArray; + }, []); + }, + + /** + * Generates a single RouteLink object. + * @param {RouteRecord} routeMatched - The matched route record. + * @param {string} path - The path segment for this breadcrumb. + * @returns {RouteLink} - The RouteLink object. + */ + generateRouteLink(routeMatched: RouteRecord, path: string): RouteLink { + return { + to: { + name: routeMatched.name + ? routeMatched.name + : routeMatched.meta.default, + }, + text: routeMatched?.meta?.breadCrumb + ? this.$t(`breadcrumbs.${routeMatched.meta.breadCrumb}`, { + path: path, + projectName: this.project ? this.project.displayName : path, + resourceName: this.resource ? this.resourceDisplayName() : path, + }).toString() + : path, + } as RouteLink; + }, + + /** + * Retrieves the display name of the resource if available. + * @returns {string} - The display name of the resource or an empty string. + */ + resourceDisplayName(): string { + if (this.resource?.displayName) { + if (this.resource.type?.specificType) { + // e.g. "RWTH-RDS-S3: My Resource Name" + return `${this.$t( + "resourceTypes." + this.resource.type.specificType + ".displayName" + )}: ${this.resource.displayName}`; + } + return this.resource.displayName; + } + return ""; + }, + + /** + * Adds parent projects to the breadcrumb array. + * @param {RouteLink[]} breadcrumbs - The existing array of RouteLink objects. + * @returns {RouteLink[]} - The updated array of RouteLink objects. + */ + addParentProjects(breadcrumbs: RouteLink[]): RouteLink[] { + if (this.project && this.parentProjects) { + const parentBreadCrumbs: RouteLink[] = this.parentProjects.map( + (parentProject) => { + return { + to: { + name: "project-page", + params: { slug: parentProject.slug }, + }, + text: this.$t(`breadcrumbs.project.page`, { + projectName: parentProject.displayName, + }).toString(), + } as RouteLink; + } + ); + breadcrumbs.unshift(...parentBreadCrumbs); + } + return breadcrumbs; + }, + + /** + * Marks the last breadcrumb as active. + * @param {RouteLink[]} breadcrumbs - The existing array of RouteLink objects. + * @returns {RouteLink[]} - The updated array of RouteLink objects. + */ + markLastActive(breadcrumbs: RouteLink[]): RouteLink[] { + if (breadcrumbs.length > 0) { + breadcrumbs[breadcrumbs.length - 1].active = true; + } + return breadcrumbs; + }, }, }); </script> diff --git a/src/components/elements/SidebarMenu.vue b/src/components/elements/SidebarMenu.vue index b84af98d74df1b0421903347b1ac437135292da7..4986a4773503d1a9e12ad83b623ee5369816346f 100644 --- a/src/components/elements/SidebarMenu.vue +++ b/src/components/elements/SidebarMenu.vue @@ -166,6 +166,7 @@ export default defineComponent({ params: { slug: this.project ? this.project.slug : undefined, guid: resource.id, + dirTrail: "", }, }, icon: "bi bi-archive", diff --git a/src/data/mockup/metadata/applicationProfile.ts b/src/data/mockup/metadata/applicationProfile.ts index 9d58c11d07010a592d9f6eb3a0b67a7994305cf7..7a12b125de9b1c1b5c2ec9704c4731c857f0d8f9 100644 --- a/src/data/mockup/metadata/applicationProfile.ts +++ b/src/data/mockup/metadata/applicationProfile.ts @@ -1 +1,2 @@ -export const radarApplicationProfile = `[{"@id":"https://purl.org/coscine/ap/radar/","@graph":[{"@id":"https://purl.org/coscine/ap/radar#subject","http://www.w3.org/ns/shacl#path":[{"@id":"http://purl.org/dc/terms/subject"}],"http://www.w3.org/ns/shacl#order":[{"@value":"3","@type":"http://www.w3.org/2001/XMLSchema#integer"}],"http://www.w3.org/ns/shacl#maxCount":[{"@value":"1","@type":"http://www.w3.org/2001/XMLSchema#integer"}],"http://www.w3.org/ns/shacl#class":[{"@id":"http://www.dfg.de/dfg_profil/gremien/fachkollegien/faecher/"}],"http://www.w3.org/ns/shacl#name":[{"@language":"de","@value":"Fachrichtung"},{"@language":"en","@value":"Subject Area"}]},{"@id":"https://purl.org/coscine/ap/radar#created","http://www.w3.org/ns/shacl#path":[{"@id":"http://purl.org/dc/terms/created"}],"http://www.w3.org/ns/shacl#order":[{"@value":"2","@type":"http://www.w3.org/2001/XMLSchema#integer"}],"http://www.w3.org/ns/shacl#minCount":[{"@value":"1","@type":"http://www.w3.org/2001/XMLSchema#integer"}],"http://www.w3.org/ns/shacl#maxCount":[{"@value":"1","@type":"http://www.w3.org/2001/XMLSchema#integer"}],"http://www.w3.org/ns/shacl#name":[{"@language":"en","@value":"Production Date"},{"@language":"de","@value":"Erstelldatum"}],"http://www.w3.org/ns/shacl#datatype":[{"@id":"http://www.w3.org/2001/XMLSchema#date"}],"http://www.w3.org/ns/shacl#defaultValue":[{"@value":"{TODAY}"}]},{"@id":"https://purl.org/coscine/ap/radar#creator","http://www.w3.org/ns/shacl#path":[{"@id":"http://purl.org/dc/terms/creator"}],"http://www.w3.org/ns/shacl#order":[{"@value":"0","@type":"http://www.w3.org/2001/XMLSchema#integer"}],"http://www.w3.org/ns/shacl#minCount":[{"@value":"1","@type":"http://www.w3.org/2001/XMLSchema#integer"}],"http://www.w3.org/ns/shacl#maxCount":[{"@value":"1","@type":"http://www.w3.org/2001/XMLSchema#integer"}],"http://www.w3.org/ns/shacl#name":[{"@language":"de","@value":"Ersteller"},{"@language":"en","@value":"Creator"}],"http://www.w3.org/ns/shacl#minLength":[{"@value":"1","@type":"http://www.w3.org/2001/XMLSchema#integer"}],"http://www.w3.org/ns/shacl#datatype":[{"@id":"http://www.w3.org/2001/XMLSchema#string"}],"http://www.w3.org/ns/shacl#defaultValue":[{"@value":"{ME}"}]},{"@id":"https://purl.org/coscine/ap/radar#rights","http://www.w3.org/ns/shacl#path":[{"@id":"http://purl.org/dc/terms/rights"}],"http://www.w3.org/ns/shacl#order":[{"@value":"5","@type":"http://www.w3.org/2001/XMLSchema#integer"}],"http://www.w3.org/ns/shacl#maxCount":[{"@value":"1","@type":"http://www.w3.org/2001/XMLSchema#integer"}],"http://www.w3.org/ns/shacl#name":[{"@language":"en","@value":"Rights"},{"@language":"de","@value":"Berechtigung"}],"http://www.w3.org/ns/shacl#datatype":[{"@id":"http://www.w3.org/2001/XMLSchema#string"}]},{"@id":"https://purl.org/coscine/ap/radar#rightsHolder","http://www.w3.org/ns/shacl#path":[{"@id":"http://purl.org/dc/terms/rightsHolder"}],"http://www.w3.org/ns/shacl#order":[{"@value":"6","@type":"http://www.w3.org/2001/XMLSchema#integer"}],"http://www.w3.org/ns/shacl#maxCount":[{"@value":"1","@type":"http://www.w3.org/2001/XMLSchema#integer"}],"http://www.w3.org/ns/shacl#name":[{"@language":"en","@value":"Rightsholder"},{"@language":"de","@value":"Rechteinhaber"}],"http://www.w3.org/ns/shacl#datatype":[{"@id":"http://www.w3.org/2001/XMLSchema#string"}]},{"@id":"https://purl.org/coscine/ap/radar#title","http://www.w3.org/ns/shacl#path":[{"@id":"http://purl.org/dc/terms/title"}],"http://www.w3.org/ns/shacl#order":[{"@value":"1","@type":"http://www.w3.org/2001/XMLSchema#integer"}],"http://www.w3.org/ns/shacl#minCount":[{"@value":"1","@type":"http://www.w3.org/2001/XMLSchema#integer"}],"http://www.w3.org/ns/shacl#maxCount":[{"@value":"1","@type":"http://www.w3.org/2001/XMLSchema#integer"}],"http://www.w3.org/ns/shacl#name":[{"@language":"de","@value":"Titel"},{"@language":"en","@value":"Title"}],"http://www.w3.org/ns/shacl#minLength":[{"@value":"1","@type":"http://www.w3.org/2001/XMLSchema#integer"}],"http://www.w3.org/ns/shacl#datatype":[{"@id":"http://www.w3.org/2001/XMLSchema#string"}]},{"@id":"https://purl.org/coscine/ap/radar#type","http://www.w3.org/ns/shacl#path":[{"@id":"http://purl.org/dc/terms/type"}],"http://www.w3.org/ns/shacl#order":[{"@value":"4","@type":"http://www.w3.org/2001/XMLSchema#integer"}],"http://www.w3.org/ns/shacl#maxCount":[{"@value":"1","@type":"http://www.w3.org/2001/XMLSchema#integer"}],"http://www.w3.org/ns/shacl#class":[{"@id":"http://purl.org/dc/dcmitype/"}],"http://www.w3.org/ns/shacl#name":[{"@language":"en","@value":"Resource"},{"@language":"de","@value":"Ressource"}]},{"@id":"https://purl.org/coscine/ap/radar/","@type":["http://www.w3.org/ns/shacl#NodeShape"],"http://purl.org/dc/terms/license":[{"@id":"http://spdx.org/licenses/MIT"}],"http://purl.org/dc/terms/rights":[{"@value":"Copyright © 2020 IT Center, RWTH Aachen University"}],"http://www.w3.org/ns/shacl#targetClass":[{"@id":"https://purl.org/coscine/ap/radar/"}],"http://www.w3.org/ns/shacl#closed":[{"@value":"true","@type":"http://www.w3.org/2001/XMLSchema#boolean"}],"http://www.w3.org/ns/shacl#property":[{"@id":"https://purl.org/coscine/ap/radar#creator"},{"@id":"https://purl.org/coscine/ap/radar#rights"},{"@id":"https://purl.org/coscine/ap/radar#subject"},{"@id":"https://purl.org/coscine/ap/radar#created"},{"@id":"https://purl.org/coscine/ap/radar#type"},{"@id":"_:b8922734"},{"@id":"https://purl.org/coscine/ap/radar#rightsHolder"},{"@id":"https://purl.org/coscine/ap/radar#title"}],"http://purl.org/dc/terms/title":[{"@language":"en","@value":"radar application profile"}],"http://purl.org/dc/terms/publisher":[{"@id":"https://itc.rwth-aachen.de/"}],"http://purl.org/dc/terms/description":[{"@value":"The RADAR metadata schema v09 was created by the 2018 FIZ Karlsruhe - Leibniz-Institut fuer Informationsinfrastruktur GmbH and has been published under CC BY 4.0."}]},{"@id":"_:b8922734","http://www.w3.org/ns/shacl#path":[{"@id":"http://www.w3.org/1999/02/22-rdf-syntax-ns#type"}]}]}]`; +export const baseApplicationProfile = `[{"@id":"_:b6482494","http://www.w3.org/ns/shacl#datatype":[{"@id":"http://www.w3.org/2001/XMLSchema#string"}],"http://www.w3.org/ns/shacl#minCount":[{"@value":"1","@type":"http://www.w3.org/2001/XMLSchema#integer"}],"http://www.w3.org/ns/shacl#order":[{"@value":"1","@type":"http://www.w3.org/2001/XMLSchema#integer"}],"http://www.w3.org/ns/shacl#name":[{"@language":"en","@value":"Creator"},{"@language":"de","@value":"Ersteller"}],"http://www.w3.org/ns/shacl#description":[{"@language":"de","@value":"Wer ist der Ersteller der Daten?"},{"@language":"en","@value":"Who is the creator of the data?"}],"http://www.w3.org/ns/shacl#defaultValue":[{"@value":"{ME}"}],"http://www.w3.org/ns/shacl#minLength":[{"@value":"1","@type":"http://www.w3.org/2001/XMLSchema#integer"}],"http://www.w3.org/ns/shacl#path":[{"@id":"http://purl.org/dc/terms/creator"}]},{"@id":"_:b6482495","http://www.w3.org/ns/shacl#description":[{"@language":"de","@value":"Von welchem Datentyp sind Ihre Daten?"},{"@language":"en","@value":"What type of data is your data?"}],"http://www.w3.org/ns/shacl#order":[{"@value":"4","@type":"http://www.w3.org/2001/XMLSchema#integer"}],"http://www.w3.org/ns/shacl#class":[{"@id":"http://purl.org/dc/dcmitype/"}],"http://www.w3.org/ns/shacl#path":[{"@id":"http://purl.org/dc/terms/type"}],"http://www.w3.org/ns/shacl#name":[{"@language":"de","@value":"Typ"},{"@language":"en","@value":"Type"}]},{"@id":"https://purl.org/coscine/ap/base/","http://purl.org/dc/terms/title":[{"@language":"en","@value":"Base Profile"},{"@language":"de","@value":"Basisprofil"}],"http://purl.org/dc/terms/creator":[{"@value":"Hanna Führ"}],"http://www.w3.org/ns/shacl#closed":[{"@value":"false","@type":"http://www.w3.org/2001/XMLSchema#boolean"}],"@type":["http://www.w3.org/ns/shacl#NodeShape"],"http://www.w3.org/ns/shacl#targetClass":[{"@id":"https://purl.org/coscine/ap/base/"}],"http://www.w3.org/ns/shacl#property":[{"@id":"_:b6482493"},{"@id":"_:b6482496"},{"@id":"_:b6482494"},{"@id":"_:b6482497"},{"@id":"_:b6482495"}],"http://purl.org/dc/terms/description":[{"@language":"de","@value":"Basisprofil, das Grundbausteine enthält"},{"@language":"en","@value":"Base application profile, which contains the basic building blocks"}],"http://purl.org/dc/terms/created":[{"@value":"2023-02-06","@type":"http://www.w3.org/2001/XMLSchema#date"}],"http://purl.org/dc/terms/license":[{"@value":"https://spdx.org/licenses/CC-BY-4.0.html"}]},{"@id":"_:b6482497","http://www.w3.org/ns/shacl#name":[{"@language":"en","@value":"Creation Date"},{"@language":"de","@value":"Erstellungsdatum"}],"http://www.w3.org/ns/shacl#path":[{"@id":"http://purl.org/dc/terms/created"}],"http://www.w3.org/ns/shacl#defaultValue":[{"@value":"{TODAY}"}],"http://www.w3.org/ns/shacl#minCount":[{"@value":"1","@type":"http://www.w3.org/2001/XMLSchema#integer"}],"http://www.w3.org/ns/shacl#datatype":[{"@id":"http://www.w3.org/2001/XMLSchema#date"}],"http://www.w3.org/ns/shacl#description":[{"@language":"en","@value":"When was the data created?"},{"@language":"de","@value":"Wann wurden die Daten erstellt?"}],"http://www.w3.org/ns/shacl#order":[{"@value":"2","@type":"http://www.w3.org/2001/XMLSchema#integer"}],"http://www.w3.org/ns/shacl#maxCount":[{"@value":"1","@type":"http://www.w3.org/2001/XMLSchema#integer"}]},{"@id":"_:b6482493","http://www.w3.org/ns/shacl#description":[{"@language":"en","@value":"Which subject does your data belong to?"},{"@language":"de","@value":"Welcher Fachrichtung gehören Ihre Daten an?"}],"http://www.w3.org/ns/shacl#class":[{"@id":"http://www.dfg.de/dfg_profil/gremien/fachkollegien/faecher/"}],"http://www.w3.org/ns/shacl#order":[{"@value":"3","@type":"http://www.w3.org/2001/XMLSchema#integer"}],"http://www.w3.org/ns/shacl#name":[{"@language":"de","@value":"Fachrichtung"},{"@language":"en","@value":"Subject Area"}],"http://www.w3.org/ns/shacl#path":[{"@id":"http://purl.org/dc/terms/subject"}]},{"@id":"_:b6482496","http://www.w3.org/ns/shacl#name":[{"@language":"de","@value":"Titel"},{"@language":"en","@value":"Title"}],"http://www.w3.org/ns/shacl#datatype":[{"@id":"http://www.w3.org/2001/XMLSchema#string"}],"http://www.w3.org/ns/shacl#minLength":[{"@value":"1","@type":"http://www.w3.org/2001/XMLSchema#integer"}],"http://www.w3.org/ns/shacl#order":[{"@value":"0","@type":"http://www.w3.org/2001/XMLSchema#integer"}],"http://www.w3.org/ns/shacl#minCount":[{"@value":"1","@type":"http://www.w3.org/2001/XMLSchema#integer"}],"http://www.w3.org/ns/shacl#description":[{"@language":"en","@value":"Enter a title to describe your data."},{"@language":"de","@value":"Geben Sie einen Titel an, um Ihre Daten zu beschreiben."}],"http://www.w3.org/ns/shacl#path":[{"@id":"http://purl.org/dc/terms/title"}]}]`; +export const baseApplicationProfileFormat = "application/ld+json"; diff --git a/src/data/mockup/responses/getMetadata.ts b/src/data/mockup/responses/getMetadata.ts index f0ef8041e2f60f1c027d3244166278b50b1c29d2..e9d45b925640bf05a92e87cd738328926207ec5c 100644 --- a/src/data/mockup/responses/getMetadata.ts +++ b/src/data/mockup/responses/getMetadata.ts @@ -1,104 +1,80 @@ -export const getMetadataResponse = { - data: { - metadataStorage: [ - { - "https://hdl.handle.net/21.11102/eeb8d803-46a1-49ba-a47c-81cd4f49cd65@path=%2FTesta": - '<https://hdl.handle.net/21.11102/eeb8d803-46a1-49ba-a47c-81cd4f49cd65@path=%2FTesta> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <https://purl.org/coscine/ap/radar/> .\r\n<https://hdl.handle.net/21.11102/eeb8d803-46a1-49ba-a47c-81cd4f49cd65@path=%2FTesta> <http://purl.org/dc/terms/created> "2022-02-25"^^<http://www.w3.org/2001/XMLSchema#date> .\r\n<https://hdl.handle.net/21.11102/eeb8d803-46a1-49ba-a47c-81cd4f49cd65@path=%2FTesta> <http://purl.org/dc/terms/creator> "Benedikt Heinrichs"^^<http://www.w3.org/2001/XMLSchema#string> .\r\n<https://hdl.handle.net/21.11102/eeb8d803-46a1-49ba-a47c-81cd4f49cd65@path=%2FTesta> <http://purl.org/dc/terms/title> "Test"^^<http://www.w3.org/2001/XMLSchema#string> .\r\n', - }, - { - "https://hdl.handle.net/21.11102/eeb8d803-46a1-49ba-a47c-81cd4f49cd65@path=%2FTest": - '<https://hdl.handle.net/21.11102/eeb8d803-46a1-49ba-a47c-81cd4f49cd65@path=%2FTest> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <https://purl.org/coscine/ap/radar/> .\r\n<https://hdl.handle.net/21.11102/eeb8d803-46a1-49ba-a47c-81cd4f49cd65@path=%2FTest> <http://purl.org/dc/terms/created> "2021-12-09"^^<http://www.w3.org/2001/XMLSchema#date> .\r\n<https://hdl.handle.net/21.11102/eeb8d803-46a1-49ba-a47c-81cd4f49cd65@path=%2FTest> <http://purl.org/dc/terms/creator> "Benedikt Heinrichs"^^<http://www.w3.org/2001/XMLSchema#string> .\r\n<https://hdl.handle.net/21.11102/eeb8d803-46a1-49ba-a47c-81cd4f49cd65@path=%2FTest> <http://purl.org/dc/terms/title> "Test"^^<http://www.w3.org/2001/XMLSchema#string> .\r\n', - }, - { - "https://hdl.handle.net/21.11102/eeb8d803-46a1-49ba-a47c-81cd4f49cd65@path=%2FcoolerEintrag": - '<https://hdl.handle.net/21.11102/eeb8d803-46a1-49ba-a47c-81cd4f49cd65@path=%2FcoolerEintrag> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <https://purl.org/coscine/ap/radar/> .\r\n<https://hdl.handle.net/21.11102/eeb8d803-46a1-49ba-a47c-81cd4f49cd65@path=%2FcoolerEintrag> <http://purl.org/dc/terms/created> "2022-03-07"^^<http://www.w3.org/2001/XMLSchema#date> .\r\n<https://hdl.handle.net/21.11102/eeb8d803-46a1-49ba-a47c-81cd4f49cd65@path=%2FcoolerEintrag> <http://purl.org/dc/terms/creator> "Benedikt Heinrichs"^^<http://www.w3.org/2001/XMLSchema#string> .\r\n<https://hdl.handle.net/21.11102/eeb8d803-46a1-49ba-a47c-81cd4f49cd65@path=%2FcoolerEintrag> <http://purl.org/dc/terms/title> "test"^^<http://www.w3.org/2001/XMLSchema#string> .\r\n', - }, - { - "https://hdl.handle.net/21.11102/eeb8d803-46a1-49ba-a47c-81cd4f49cd65@path=%2Freview": - '<https://hdl.handle.net/21.11102/eeb8d803-46a1-49ba-a47c-81cd4f49cd65@path=%2Freview> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <https://purl.org/coscine/ap/radar/> .\r\n<https://hdl.handle.net/21.11102/eeb8d803-46a1-49ba-a47c-81cd4f49cd65@path=%2Freview> <http://purl.org/dc/terms/created> "2022-03-10"^^<http://www.w3.org/2001/XMLSchema#date> .\r\n<https://hdl.handle.net/21.11102/eeb8d803-46a1-49ba-a47c-81cd4f49cd65@path=%2Freview> <http://purl.org/dc/terms/creator> "Benedikt Heinrichs"^^<http://www.w3.org/2001/XMLSchema#string> .\r\n<https://hdl.handle.net/21.11102/eeb8d803-46a1-49ba-a47c-81cd4f49cd65@path=%2Freview> <http://purl.org/dc/terms/title> "MyTest2"^^<http://www.w3.org/2001/XMLSchema#string> .\r\n', - }, - { - "https://hdl.handle.net/21.11102/eeb8d803-46a1-49ba-a47c-81cd4f49cd65@path=%2Feawfaewf": - '<https://hdl.handle.net/21.11102/eeb8d803-46a1-49ba-a47c-81cd4f49cd65@path=%2Feawfaewf> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <https://purl.org/coscine/ap/radar/> .\r\n<https://hdl.handle.net/21.11102/eeb8d803-46a1-49ba-a47c-81cd4f49cd65@path=%2Feawfaewf> <http://purl.org/dc/terms/created> "2022-03-10"^^<http://www.w3.org/2001/XMLSchema#date> .\r\n<https://hdl.handle.net/21.11102/eeb8d803-46a1-49ba-a47c-81cd4f49cd65@path=%2Feawfaewf> <http://purl.org/dc/terms/creator> "Benedikt Heinrichs"^^<http://www.w3.org/2001/XMLSchema#string> .\r\n<https://hdl.handle.net/21.11102/eeb8d803-46a1-49ba-a47c-81cd4f49cd65@path=%2Feawfaewf> <http://purl.org/dc/terms/rights> "awer"^^<http://www.w3.org/2001/XMLSchema#string> .\r\n<https://hdl.handle.net/21.11102/eeb8d803-46a1-49ba-a47c-81cd4f49cd65@path=%2Feawfaewf> <http://purl.org/dc/terms/title> "awerawe"^^<http://www.w3.org/2001/XMLSchema#string> .\r\n', - }, - { - "https://hdl.handle.net/21.11102/eeb8d803-46a1-49ba-a47c-81cd4f49cd65@path=%2Fw": - '<https://hdl.handle.net/21.11102/eeb8d803-46a1-49ba-a47c-81cd4f49cd65@path=%2Fw> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <https://purl.org/coscine/ap/radar/> .\r\n<https://hdl.handle.net/21.11102/eeb8d803-46a1-49ba-a47c-81cd4f49cd65@path=%2Fw> <http://purl.org/dc/terms/created> "2022-04-04"^^<http://www.w3.org/2001/XMLSchema#date> .\r\n<https://hdl.handle.net/21.11102/eeb8d803-46a1-49ba-a47c-81cd4f49cd65@path=%2Fw> <http://purl.org/dc/terms/creator> "Benedikt Heinrichs"^^<http://www.w3.org/2001/XMLSchema#string> .\r\n<https://hdl.handle.net/21.11102/eeb8d803-46a1-49ba-a47c-81cd4f49cd65@path=%2Fw> <http://purl.org/dc/terms/title> "a"^^<http://www.w3.org/2001/XMLSchema#string> .\r\n', - }, - ], - fileStorage: [ - { - Name: "Testa", - Path: "/Testa", - Size: 7, - Kind: "file", - Modified: null, - Created: null, - Provider: "linked", - IsFolder: false, - IsFile: true, - Action: {}, - }, - { - Name: "Test", - Path: "/Test", - Size: 18, - Kind: "file", - Modified: null, - Created: null, - Provider: "linked", - IsFolder: false, - IsFile: true, - Action: {}, - }, - { - Name: "coolerEintrag", - Path: "/coolerEintrag", - Size: 14, - Kind: "file", - Modified: null, - Created: null, - Provider: "linked", - IsFolder: false, - IsFile: true, - Action: {}, - }, - { - Name: "review", - Path: "/review", - Size: 14, - Kind: "file", - Modified: null, - Created: null, - Provider: "linked", - IsFolder: false, - IsFile: true, - Action: {}, - }, - { - Name: "eawfaewf", - Path: "/eawfaewf", - Size: 14, - Kind: "file", - Modified: null, - Created: null, - Provider: "linked", - IsFolder: false, - IsFile: true, - Action: {}, - }, - { - Name: "w", - Path: "/w", - Size: 9, - Kind: "file", - Modified: null, - Created: null, - Provider: "linked", - IsFolder: false, - IsFile: true, - Action: {}, - }, +import type { + FileDto, + MetadataDto, + TreeDataType, +} from "@coscine/api-client/dist/types/Coscine.Api"; + +export const getMetadataTreeResponse: MetadataDto[] = [ + { + version: "1693212042", + availableVersions: ["1693212042"], + definition: + '@base <https://purl.org/coscine/resources/4103cbea-ffa3-40a5-9e5c-b99cc16f0007/folder_1/folder_2/A.txt/@type=metadata&version=1693212042>.\r\n\r\n@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>.\r\n@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#>.\r\n@prefix xsd: <http://www.w3.org/2001/XMLSchema#>.\r\n@prefix ns2: <http://purl.org/dc/terms/>.\r\n\r\n_:b8477997 ns2:created "2023-08-15"^^xsd:date;\r\n ns2:creator "Petar Hristov";\r\n ns2:title "Title inside Form Generator";\r\n a <https://purl.org/coscine/ap/base/>.\r\n', + format: "text/turtle", + path: "folder_1/folder_2/A.txt", + type: "Leaf" as TreeDataType.Leaf, + }, + { + version: "1692777419", + availableVersions: ["1692777419"], + definition: + '@base <https://purl.org/coscine/resources/4103cbea-ffa3-40a5-9e5c-b99cc16f0007/file_1.txt/@type=metadata&version=1692777419>.\r\n\r\n@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>.\r\n@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#>.\r\n@prefix xsd: <http://www.w3.org/2001/XMLSchema#>.\r\n@prefix ns2: <http://purl.org/dc/terms/>.\r\n\r\n_:b8425359 ns2:created "2023-08-18"^^xsd:date;\r\n ns2:creator "Petar Hristov";\r\n ns2:title "file_1";\r\n a <https://purl.org/coscine/ap/base/>.\r\n', + format: "text/turtle", + path: "file_1.txt", + type: "Leaf" as TreeDataType.Leaf, + }, + { + version: "1692779210", + availableVersions: [ + "1692340745", + "1692340906", + "1692340959", + "1692341031", + "1692779210", ], + definition: + '@base <https://purl.org/coscine/resources/4103cbea-ffa3-40a5-9e5c-b99cc16f0007/file_0.txt/@type=metadata&version=1692779210>.\r\n\r\n@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>.\r\n@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#>.\r\n@prefix xsd: <http://www.w3.org/2001/XMLSchema#>.\r\n@prefix ns2: <http://purl.org/dc/terms/>.\r\n\r\n_:b8425361 ns2:created "2023-08-18"^^xsd:date;\r\n ns2:creator "Petar Hristov";\r\n ns2:title "Revised";\r\n a <https://purl.org/coscine/ap/base/>.\r\n', + format: "text/turtle", + path: "file_0.txt", + type: "Leaf" as TreeDataType.Leaf, + }, + { + version: "1693209938", + availableVersions: ["1693209938"], + definition: + '@base <https://purl.org/coscine/resources/4103cbea-ffa3-40a5-9e5c-b99cc16f0007/my_folder/file_of_folder.txt/@type=metadata&version=1693209938>.\r\n\r\n@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>.\r\n@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#>.\r\n@prefix xsd: <http://www.w3.org/2001/XMLSchema#>.\r\n@prefix ns2: <http://purl.org/dc/terms/>.\r\n\r\n_:b8477996 ns2:created "2023-08-15"^^xsd:date;\r\n ns2:creator "Petar Hristov";\r\n ns2:title "From Insomnia";\r\n a <https://purl.org/coscine/ap/base/>.\r\n', + format: "text/turtle", + path: "my_folder/file_of_folder.txt", + type: "Leaf" as TreeDataType.Leaf, + }, +]; +export const getFileTreeResponse: FileDto[] = [ + { + parentDirectory: "", + name: "folder_1", + size: 0, + creationDate: "2023-09-01T16:57:48.2525218+02:00", + changeDate: "2023-09-01T16:57:48.2525225+02:00", + path: "folder_1/", + type: "Tree" as TreeDataType.Tree, + }, + { + parentDirectory: "", + name: "my_folder", + size: 0, + creationDate: "2023-09-01T16:57:48.2525245+02:00", + changeDate: "2023-09-01T16:57:48.2525249+02:00", + path: "my_folder/", + type: "Tree" as TreeDataType.Tree, + }, + { + parentDirectory: "", + name: "file_0.txt", + extension: "txt", + size: 2513352, + creationDate: "2023-08-23T10:27:20.062+02:00", + changeDate: "2023-08-23T10:27:20.062+02:00", + path: "file_0.txt", + type: "Leaf" as TreeDataType.Leaf, }, -}; +]; diff --git a/src/data/mockup/testResource.ts b/src/data/mockup/testResource.ts index 6fb94c13387499313e48b1fc0f8cbca4d02b1f75..9cc50478575fb27873203d20e06e60f8575068c6 100644 --- a/src/data/mockup/testResource.ts +++ b/src/data/mockup/testResource.ts @@ -3,7 +3,10 @@ import type { ResourceState, } from "@/modules/resource/types"; import { parseRDFDefinition } from "@/modules/resource/utils/linkedData"; -import { radarApplicationProfile } from "./metadata/applicationProfile"; +import { + baseApplicationProfile, + baseApplicationProfileFormat, +} from "./metadata/applicationProfile"; import { radarFixedValues } from "./metadata/fixedValues"; import { testDiscipline, getTestUser } from "./testUser"; import type { @@ -40,10 +43,10 @@ export const testResourceType: ResourceTypeInformationDto = { export const getTestResource: () => Promise<VisitedResourceObject> = async () => { - const apUrl = "https://purl.org/coscine/ap/radar/"; + const apUrl = "https://purl.org/coscine/ap/base/"; const ap = await parseRDFDefinition( - radarApplicationProfile, - "application/ld+json", + baseApplicationProfile, + baseApplicationProfileFormat, apUrl ); const resourceObject: VisitedResourceObject = { diff --git a/src/mapping/tree.ts b/src/mapping/tree.ts new file mode 100644 index 0000000000000000000000000000000000000000..15084f64009a31a773e370b456f3be6c3f89aa30 --- /dev/null +++ b/src/mapping/tree.ts @@ -0,0 +1,51 @@ +import { MapperConfiguration, MappingPair } from "@dynamic-mapper/mapper"; +import { v4 as uuidv4 } from "uuid"; + +import type { + TreeDataType, + FileDto, +} from "@coscine/api-client/dist/types/Coscine.Api"; +import type { + FileInformation, + FolderInformation, +} from "@/modules/resource/types"; + +export const TreeDto2FileInformation = new MappingPair< + FileDto, + FileInformation +>(); + +export const TreeDto2FolderInformation = new MappingPair< + FileDto, + FolderInformation +>(); + +const configuration = new MapperConfiguration((cfg) => { + cfg.createMap(TreeDto2FileInformation, { + id: (opt) => opt.mapFrom((_) => uuidv4()), + name: (opt) => opt.mapFrom((dto) => dto.name ?? ""), + parentDirectory: (opt) => opt.mapFrom((dto) => dto.parentDirectory ?? ""), + path: (opt) => opt.mapFrom((dto) => dto.path ?? ""), + type: (opt) => opt.mapFrom((_) => "Leaf" as TreeDataType.Leaf), + isFolder: (opt) => opt.mapFrom((_) => false), + createdAt: (opt) => opt.mapFrom((dto) => dto.creationDate ?? undefined), + lastModified: (opt) => opt.mapFrom((dto) => dto.changeDate), + size: (opt) => opt.mapFrom((dto) => dto.size ?? 0), + version: (opt) => opt.mapFrom((_) => `${+new Date()}`), + metadata: (opt) => opt.mapFrom((_) => null), // Set outside of the mapper + }); + + cfg.createMap(TreeDto2FolderInformation, { + id: (opt) => opt.mapFrom((_) => uuidv4()), + name: (opt) => opt.mapFrom((dto) => dto.name ?? ""), + parentDirectory: (opt) => opt.mapFrom((dto) => dto.parentDirectory ?? ""), + path: (opt) => opt.mapFrom((dto) => dto.path ?? ""), + type: (opt) => opt.mapFrom((_) => "Tree" as TreeDataType.Tree), + isFolder: (opt) => opt.mapFrom((_) => true), + createdAt: (opt) => opt.mapFrom((dto) => dto.creationDate ?? undefined), + lastModified: (opt) => opt.mapFrom((dto) => dto.changeDate), + metadata: (opt) => opt.mapFrom((_) => null), // Set outside of the mapper + }); +}); + +export const treeMapper = configuration.createMapper(); diff --git a/src/modules/project/pages/ProjectPage.vue b/src/modules/project/pages/ProjectPage.vue index 0314a132d84eb0d3c4c250dab8a5560f725074a4..9a024eafc5606f50026535391edfe92a344b7e41 100644 --- a/src/modules/project/pages/ProjectPage.vue +++ b/src/modules/project/pages/ProjectPage.vue @@ -173,7 +173,7 @@ export default defineComponent({ toResource(resource: ResourceDto): RawLocation { const route = { name: "resource-page", - params: { guid: resource.id }, + params: { guid: resource.id, dirTrail: "" }, } as RawLocation; return route; }, diff --git a/src/modules/resource/ResourceModule.vue b/src/modules/resource/ResourceModule.vue index 4aa1641a87670ce05d5d0c527f241c2f4daaa797..268736e2aaa7f070076dc096870e5ec969b03c20 100644 --- a/src/modules/resource/ResourceModule.vue +++ b/src/modules/resource/ResourceModule.vue @@ -70,6 +70,11 @@ export default defineComponent({ this.initialize(); }, + beforeDestroy() { + // Set current resource ID to null + this.resourceStore.currentId = null; + }, + methods: { async initialize() { await this.apiFetch(this.$router.currentRoute); diff --git a/src/modules/resource/components/create-resource/ApplicationProfile.vue b/src/modules/resource/components/create-resource/ApplicationProfile.vue index 7c6a5d44a84f94e51b897def5fb3bf788f19091e..afe80d1af34215a440d0a94291b27ae70cf39f53 100644 --- a/src/modules/resource/components/create-resource/ApplicationProfile.vue +++ b/src/modules/resource/components/create-resource/ApplicationProfile.vue @@ -28,7 +28,7 @@ </template> <!-- Dropdown --> - <div class="d-flex align-items-center gap-2"> + <div class="d-inline-flex align-items-center gap-2 w-100"> <multiselect id="applicationProfiles" v-model="selectedApplicationProfile" @@ -69,7 +69,7 @@ <b-button variant="outline-primary" name="createAP" - class="d-flex justify-content-between align-items-center" + class="d-flex gap-2 justify-content-between align-items-center w-fit" @click="$bvModal.show('createAPModal')" > <b-icon icon="file-earmark-plus" aria-hidden="true" /> diff --git a/src/modules/resource/components/create-resource/ConfigurationSizeSlider.vue b/src/modules/resource/components/create-resource/ConfigurationSizeSlider.vue index 3232083c3110a81b315b5f9c6d0e9e34e1b9a128..2828efead43ee7580fdf3e26332ac02e162f76b2 100644 --- a/src/modules/resource/components/create-resource/ConfigurationSizeSlider.vue +++ b/src/modules/resource/components/create-resource/ConfigurationSizeSlider.vue @@ -4,23 +4,25 @@ :mandatory="true" :label="$t('page.createResource.configuration.labels.size')" > - <!-- Slider --> - <b-form-input - id="slider" - :disabled="!sliderValue || max < min" - type="range" - :min="min" - :max="max" - :value="sliderValue" - @change="update" - /> - <div> - {{ sliderText }} + <div class="w-100"> + <!-- Slider --> + <b-form-input + id="slider" + :disabled="!sliderValue || max < min" + type="range" + :min="min" + :max="max" + :value="sliderValue" + @change="update" + /> + <div> + {{ sliderText }} - <!-- Router Link --> - <router-link v-if="isOwner" :to="{ name: 'project-quota' }"> - {{ $t("page.createResource.configuration.needMore") }} - </router-link> + <!-- Router Link --> + <router-link v-if="isOwner" :to="{ name: 'project-quota' }"> + {{ $t("page.createResource.configuration.needMore") }} + </router-link> + </div> </div> </CoscineFormGroup> </div> diff --git a/src/modules/resource/components/resource-page/FilesView.spec.ts b/src/modules/resource/components/resource-page/FilesView.spec.ts index 7da372077001ea1f89b7e7c7ade42860600ccf84..66a621a117744d64d173583c11c5b0520973c7f2 100644 --- a/src/modules/resource/components/resource-page/FilesView.spec.ts +++ b/src/modules/resource/components/resource-page/FilesView.spec.ts @@ -1,5 +1,5 @@ /* Testing imports */ -import { createLocalVue, mount } from "@vue/test-utils"; +import { type Wrapper, createLocalVue, mount } from "@vue/test-utils"; import { createTestingPinia } from "@pinia/testing"; /* Vue i18n */ @@ -15,6 +15,8 @@ import { PiniaVuePlugin } from "pinia"; /* Tested Component */ import FilesView from "./FilesView.vue"; +import VueRouter from "vue-router"; +import { routes } from "@/router"; import type Vue from "vue"; @@ -22,7 +24,10 @@ import { getTestUserState } from "@/data/mockup/testUser"; import { getTestResourceState } from "@/data/mockup/testResource"; import { testProjectState } from "@/data/mockup/testProject"; import useResourceStore from "../../store"; -import { getMetadataResponse } from "@/data/mockup/responses/getMetadata"; +import { + getFileTreeResponse, + getMetadataTreeResponse, +} from "@/data/mockup/responses/getMetadata"; function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -31,58 +36,82 @@ function sleep(ms: number) { /* Create a local Vue instance */ const localVue = createLocalVue(); localVue.use(PiniaVuePlugin); +localVue.use(VueRouter); +const router = new VueRouter({ routes: routes }); -describe("FilesView.vue", () => { - /* Checks for local storage setting of columns */ - test("testLocalStorageSetting", async () => { - const testingPinia = createTestingPinia({ - createSpy: vitest.fn, - initialState: { - project: testProjectState, - resource: await getTestResourceState(), - user: getTestUserState(), - }, - }); - - const resourceStore = useResourceStore(testingPinia); - vi.mocked(resourceStore.getMetadata).mockReturnValue( - Promise.resolve(getMetadataResponse) - ); - vi.mocked(resourceStore.getVocabularyInstances).mockReturnValue( - Promise.resolve({ en: [], de: [] }) - ); - - const wrapper = mount(FilesView as unknown as typeof Vue, { +// Define the Vue instance type (computed properties) +interface ResourcePageComponent extends Vue { + dirTrail: string; +} + +describe("FilesView.vue", async () => { + let wrapper: Wrapper<ResourcePageComponent>; + + // Create a mocked pinia instance with initial state + const testingPinia = createTestingPinia({ + createSpy: vitest.fn, + initialState: { + project: testProjectState, + resource: await getTestResourceState(), + user: getTestUserState(), + }, + }); + + // Mock the API calls + const resourceStore = useResourceStore(testingPinia); + vi.mocked(resourceStore.getMetadataTree).mockReturnValue( + Promise.resolve(getMetadataTreeResponse) + ); + vi.mocked(resourceStore.getFileTree).mockReturnValue( + Promise.resolve(getFileTreeResponse) + ); + vi.mocked(resourceStore.getVocabularyInstances).mockReturnValue( + Promise.resolve({ en: [], de: [] }) + ); + + beforeEach(() => { + // shallowMount does not work here! + wrapper = mount(FilesView as unknown as typeof Vue, { pinia: testingPinia, + router, i18n, localVue, - }); + }) as Wrapper<ResourcePageComponent>; + }); - await wrapper.vm.$nextTick(); + test( + "Should toggle column visibility and persist to local storage", + async () => { + await wrapper.vm.$nextTick(); - // Wait for 1 second until everything is set up - await sleep(1000); + // Wait for 1 second until everything is set up + await sleep(1000); // Don't remove! - expect(resourceStore.setStoredColumns).toBeCalledTimes(1); + expect(resourceStore.setStoredColumns).toBeCalledTimes(1); - const selectButton = wrapper.find("#addColumnDropDown__BV_toggle_"); - await selectButton.trigger("click"); + const selectButton = wrapper.find("#addColumnDropDown__BV_toggle_"); + await selectButton.trigger("click"); - const checkBox = wrapper.find("#addColumnDropDown .custom-control-input"); - await checkBox.trigger("click"); + const checkBox = wrapper.find("#addColumnDropDown .custom-control-input"); + await checkBox.trigger("click"); - // The previous clicks should have done that, workaround for now - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (wrapper.vm as any).columns[0].active = true; + // The previous clicks should have done that, workaround for now + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (wrapper.vm as any).columns[0].active = true; - await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); - // Wait for 1 second until everything is set up - await sleep(1000); + // Wait for 1 second until everything is set up + await sleep(1000); // Don't remove! - const additionalColumnHeader = wrapper.find(".additionalColumnHeader"); - expect(additionalColumnHeader.exists()).toBeTruthy(); + const additionalColumnHeader = wrapper.find(".additionalColumnHeader"); + expect(additionalColumnHeader.exists()).toBeTruthy(); - expect(resourceStore.setStoredColumns).toBeCalledTimes(2); - }); + expect(resourceStore.setStoredColumns).toBeCalledTimes(2); + }, + { + // Override the maximum run time for this test (10 sec), due to the sleep() calls + timeout: 10000, + } + ); }); diff --git a/src/modules/resource/components/resource-page/FilesView.vue b/src/modules/resource/components/resource-page/FilesView.vue index 2da6291fe6acfe1acf86bb348d686b77b1a5be33..ace291b8ef67e76d3df33bfcf28397071d7041ae 100644 --- a/src/modules/resource/components/resource-page/FilesView.vue +++ b/src/modules/resource/components/resource-page/FilesView.vue @@ -1,11 +1,14 @@ <template> <div class="DataSource"> + <!-- Files View Header --> <FilesViewHeader v-model="filter" :is-uploading="isUploading" @clickFileSelect="clickFileSelect" /> + <b-row> + <!-- Files View Table --> <b-table id="resourceViewTable" ref="adaptTable" @@ -31,25 +34,31 @@ :empty-filtered-text="$t('page.resource.emptyFilterText')" @row-selected="onRowSelected" > + <!-- Loading Spinner for Busy State --> <div slot="table-busy" class="text-center text-danger my-2"> - <b-spinner class="align-middle"></b-spinner> - <strong style="margin-left: 1%">{{ - $t("page.resource.loading") - }}</strong> + <b-spinner class="align-middle" /> + <strong style="margin-left: 1%"> + {{ $t("page.resource.loading") }} + </strong> </div> + + <!-- Column "Name" --> <template #head(name)="row"> - <b-form-checkbox - v-model="selectAll" - @change="allSelect(row)" - ></b-form-checkbox> + <b-form-checkbox v-model="selectAll" @change="allSelect(row)" /> <span>{{ $t("page.resource.fileName") }}</span> </template> + + <!-- Column "Last Modified" --> <template #head(lastModified)="row"> <span>{{ row.label }}</span> </template> + + <!-- Column "Size" --> <template #head(size)="row"> <span>{{ row.label }}</span> </template> + + <!-- --> <template #head()="row"> <template v-if="visibleColumns.includes(row.field.key)"> <span>{{ row.label }}</span> @@ -61,11 +70,16 @@ </span> </template> </template> + + <!-- Column "Add Column"/Filter --> <template #head(addColumn)> <b-dropdown id="addColumnDropDown" size="sm" right :no-caret="true"> + <!-- Button Template + Icon --> <template #button-content> - <b-icon icon="arrow-down" /> + <b-icon icon="funnel" /> </template> + + <!-- Checkbox Options --> <b-form-checkbox v-for="column in columns" :key="column.key" @@ -76,56 +90,72 @@ </b-form-checkbox> </b-dropdown> </template> + + <!-- Row "Name" --> <template #cell(name)="row"> - <span class="checkFile"> - <b-form-checkbox - v-model="row.rowSelected" - class="tableCheck" - @change="select(row)" - /> - <b-icon - :icon="row.item.isFolder ? 'folder' : 'file-earmark'" - ></b-icon> - </span> - <a - v-if="!editableDataUrl" - class="dataSourceItem" - :href="'#' + row.item.absolutePath" - @click=" - row.item.isFolder ? openFolder(row.item) : openFile(row.item) - " - >{{ row.item.name }}</a - > - <a v-else>{{ row.item.name }}</a> - <b-dropdown - class="dotMenu" - left - variant="link" - toggle-class="text-decoration-none" - size="sm" - :no-caret="true" - :disabled="editableDataUrl && resource && resource.archived" - > - <template #button-content> ... </template> - <b-dropdown-item - v-if="!editableDataUrl" - @click=" - row.item.isFolder ? openFolder(row.item) : openFile(row.item) - " - > - {{ $t("page.resource.metadataManagerBtnDownload") }} - </b-dropdown-item> - <b-dropdown-item - v-if="!isGuest" - :disabled=" - (resource && resource.archived) || - (resourceTypeInformation && !resourceTypeInformation.canDelete) - " - @click="showModalDeleteFile(row.item)" + <span class="d-flex justify-between fileViewEntryWrapper"> + <span class="d-inline-flex gap-2"> + <!-- File Icon and Checkbox --> + <span class="checkFile"> + <!-- Show a checkbox for elements that are not read-only --> + <b-form-checkbox + v-if="!row.item.readOnly" + v-model="row.rowSelected" + class="tableCheck" + @change="select(row)" + /> + <!-- Icon --> + <b-icon + :icon="row.item.isFolder ? 'folder' : 'file-earmark'" + class="ml-1" + /> + </span> + <a + v-if="!editableDataUrl" + class="fileViewEntry" + @click="triggerNavigation(row.item)" + > + {{ row.item.name }} + </a> + <span v-else class="fileViewEntry"> {{ row.item.name }} </span> + </span> + <!-- Row File "..." Menu --> + <b-dropdown + class="dotMenu" + left + variant="link" + toggle-class="text-decoration-none" + size="sm" + :no-caret="true" + :disabled="editableDataUrl && resource && resource.archived" > - {{ $t("buttons.delete") }} - </b-dropdown-item> - </b-dropdown> + <!-- Row File "..." Button Template + Icon --> + <template #button-content> + <b-icon icon="three-dots-vertical" /> + </template> + + <!-- Option "Download" --> + <b-dropdown-item + v-if="!editableDataUrl && !row.item.isFolder" + @click="openFile(row.item)" + > + {{ $t("page.resource.metadataManagerBtnDownload") }} + </b-dropdown-item> + + <!-- Option "Delete" --> + <b-dropdown-item + v-if="!isGuest" + :disabled=" + (resource && resource.archived) || + (resourceTypeInformation && + !resourceTypeInformation.canDelete) + " + @click="showModalDeleteFile(row.item)" + > + {{ $t("buttons.delete") }} + </b-dropdown-item> + </b-dropdown> + </span> </template> </b-table> </b-row> @@ -134,42 +164,45 @@ <script lang="ts"> import { defineComponent, type PropType, reactive } from "vue"; - // import the store for current module import useResourceStore from "../../store"; import useProjectStore from "@/modules/project/store"; - import FilesViewHeader from "./FilesViewHeader.vue"; - -import type { - FileInformation, - FolderContent, - FolderInformation, - ReadOnlyFolderInformation, -} from "../../utils/EntryDefinition"; - import MetadataManagerUtil from "../../utils/MetadataManagerUtil"; - import fileSaver from "file-saver"; - +import { FileUtil } from "../../utils/FileUtil"; +import { v4 as uuidv4 } from "uuid"; +import { parseRDFDefinition } from "../../utils/linkedData"; +import factory from "rdf-ext"; +import { + TreeDto2FileInformation, + TreeDto2FolderInformation, + treeMapper, +} from "@/mapping/tree"; +import type { BFormRow, BTable } from "bootstrap-vue"; +import type { Dataset, Literal, Quad } from "@rdfjs/types"; +import type { + TreeDataType, + ProjectDto, + ResourceTypeInformationDto, + FileDto, + MetadataDto, +} from "@coscine/api-client/dist/types/Coscine.Api"; import type { BilingualLabels, CustomTableField, + FileInformation, + FolderContent, + FolderInformation, + ReadOnlyFolderInformation, VisitedResourceObject, } from "../../types"; -import type { BFormRow, BTable } from "bootstrap-vue"; -import { FileUtil } from "../../utils/FileUtil"; - -import { v4 as uuidv4 } from "uuid"; -import { parseRDFDefinition } from "../../utils/linkedData"; -import factory from "rdf-ext"; -import type { Dataset, Literal } from "@rdfjs/types"; -import type { ResourceTypeInformationDto } from "@coscine/api-client/dist/types/Coscine.Api"; export default defineComponent({ components: { FilesViewHeader, }, + props: { folderContents: { default: () => { @@ -177,10 +210,6 @@ export default defineComponent({ }, type: Array as PropType<FolderContent[]>, }, - currentFolder: { - default: "/", - type: String, - }, fileListEdit: { default: () => { return []; @@ -191,21 +220,37 @@ export default defineComponent({ default: false, type: Boolean, }, + dirTrail: { + required: true, + type: String, + }, + dirCrumbs: { + required: true, + type: Array as PropType<string[]>, + }, + }, + + emits: { + clickFileSelect: () => true, + fileListEdit: (_: FolderContent[]) => true, + folderContents: (_: FolderContent[]) => true, + showDetail: (_: boolean) => true, + showModalDelete: (_: FolderContent[]) => true, }, + setup() { const resourceStore = useResourceStore(); const projectStore = useProjectStore(); return { resourceStore, projectStore }; }, + data() { return { columns: [] as CustomTableField[], isBusy: true, selectAll: false, filter: "", - folderPath: [] as string[], - selectableFiles: [] as FolderContent[], sortBy: "", sortDesc: false, }; @@ -218,10 +263,12 @@ export default defineComponent({ applicationProfile(): Dataset | null { return this.resourceStore.currentFullApplicationProfile; }, + project(): null | ProjectDto { + return this.projectStore.currentProject; + }, resource(): null | VisitedResourceObject { return this.resourceStore.currentResource; }, - classes(): { [className: string]: BilingualLabels } { return this.resourceStore.classes; }, @@ -237,7 +284,7 @@ export default defineComponent({ ); }, locale(): string { - //Define as computed property to translate RCV Table + // Define as computed property to translate RCV Table return this.$root.$i18n.locale; }, defaultHeaders(): Array<CustomTableField> { @@ -332,8 +379,8 @@ export default defineComponent({ locale() { this.getColumns(); }, - resource() { - this.getData(); + async resource() { + await this.navigateTree(); }, sortBy() { this.saveInLocalStorage(); @@ -341,153 +388,256 @@ export default defineComponent({ sortDesc() { this.saveInLocalStorage(); }, + async dirTrail() { + // Reacts to route changes from 'triggerNavigation()' + await this.navigateTree(); + }, }, - created() { + + async created() { this.isBusy = true; this.getColumns(); if (this.resource) { - this.getData(); + await this.navigateTree(); } + this.isBusy = false; }, + methods: { + /** + * Generates the table columns based on the application profile. + * This method reads paths from the application profile and checks the local storage to see if + * certain columns should be active or not. The resulting columns are reactive objects. + */ getColumns() { this.columns.length = 0; + + if (!this.applicationProfile) return; + // keys a, b, name, active - if (this.applicationProfile) { - const oldColumns = this.loadFromLocalStorage(); - const paths = this.applicationProfile.match( - undefined, - factory.namedNode("http://www.w3.org/ns/shacl#path") - ); - for (const path of paths) { - // read the active value from the localStorage - const identifier = path.object.value; - const names = this.applicationProfile.match( - path.subject, - factory.namedNode("http://www.w3.org/ns/shacl#name") + const oldColumns = this.loadFromLocalStorage(); + const shaclPath = factory.namedNode("http://www.w3.org/ns/shacl#path"); + const shaclName = factory.namedNode("http://www.w3.org/ns/shacl#name"); + + const paths = this.applicationProfile.match(undefined, shaclPath); + for (const path of paths) { + // Read the active value from the localStorage + const identifier = path.object.value; + + // Find the name of the column in the preferred language or a default if not available. + const names = this.applicationProfile.match(path.subject, shaclName); + const name = this.getColumnName(names); + + // Determine if the column should be active based on stored settings. + const activeColumn = this.isColumnActive(identifier, oldColumns); + + if (name) { + this.columns.push( + this.createColumn(name, identifier, path, activeColumn) ); - let name: string | null = null; - for (const nameEntry of names) { - name = nameEntry.object.value; - if ( - (nameEntry.object as Literal).language === this.$root.$i18n.locale - ) { - break; - } + } + } + }, + + /** + * Retrieves the column name from the provided names list. + * It prioritizes names in the current locale. + * + * @param {Dataset<Quad, Quad>} names - The list of potential names. + * @returns {string | null} - The retrieved name or null. + */ + getColumnName(names: Dataset<Quad, Quad>): string | null { + let name: string | null = null; + for (const nameEntry of names) { + name = nameEntry.object.value; + if ( + (nameEntry.object as Literal).language === this.$root.$i18n.locale + ) { + break; + } + } + return name; + }, + + /** + * Determines if a column should be active based on the provided identifier and the stored columns. + * @param {string} identifier - The identifier of the column to check. + * @param {CustomTableField[]} oldColumns - The collection of stored old columns. + * @returns {boolean} - True if the column should be active, false otherwise. + */ + isColumnActive( + identifier: string, + oldColumns: CustomTableField[] + ): boolean { + for (const oldColumn of oldColumns) { + if (oldColumn.key === identifier) { + return oldColumn.active; + } + } + return false; + }, + + /** + * Creates a column based on the provided name, identifier and path. + * @param {string} name - The name of the column. + * @param {string} identifier - The identifier of the column. + * @param {Quad} path - The path of the column. + * @param {boolean} activeColumn - True if the column should be active, false otherwise. + * @returns {CustomTableField} - The created column. + */ + createColumn( + name: string, + identifier: string, + path: Quad, + activeColumn: boolean + ): CustomTableField { + return reactive({ + label: name, + key: identifier, + sortable: true, + active: activeColumn, + formatter: (value, key, item: FolderContent) => { + return this.formatColumnValue(item.metadata, path); + }, + sortByFormatted: true, + filterByFormatted: true, + }); + }, + + /** + * Formats the column value based on the provided metadata and path. + * @param {Dataset<Quad, Quad>} metadata - The metadata containing information to format. + * @param {Quad} path - The RDF quad defining the property path for the column. + * @returns {string} - The formatted column value, where multiple values are separated by a line break ("<br>"). + */ + formatColumnValue( + metadata: Dataset<Quad, Quad> | null, + path: Quad + ): string { + // Extract all matching entries from the metadata using the specified path. + const entries = metadata?.match(undefined, path.object); + + // If there are no matching entries, return an empty string. + if (!entries?.size) return ""; + + const returnList: string[] = []; + for (const entry of entries) { + const entryObject = entry.object; + let entryValue = entryObject.value; + + // If the entry object is a literal, further formatting might be needed. + if (entryObject.termType === "Literal") { + // If the datatype of the literal indicates a date, format it accordingly. + if (entryObject.datatype.value.endsWith("date")) { + const date = entryObject.value; + const dateObject = new Date(date); + entryValue = dateObject.toLocaleDateString(this.$i18n.locale); } + } + // If the entry object is a named node, it might need to be translated into a more readable form. + else if (entryObject.termType === "NamedNode") { + // Loop through all available classes to find a match for the named node's value. + for (const classValue of Object.values(this.classes)) { + // Depending on the user's locale, choose the appropriate language list. + const langClassList = + this.$i18n.locale === "de" ? classValue.de : classValue.en; + + // If a language-specific list is available, search for the named node's value in it. + if (langClassList) { + const foundEntry = langClassList.find( + (langClassEntry) => langClassEntry.value === entryValue + ); - let activeColumn = false; - for (const oldColumn of oldColumns) { - if (oldColumn.key === identifier) { - activeColumn = oldColumn.active; + // If a match is found, replace the entryValue with the more readable name. + if (foundEntry && foundEntry.name) { + entryValue = foundEntry.name; + break; // Exit the loop once a match is found. + } } } - if (name) { - this.columns.push( - reactive({ - label: name, - key: identifier, - sortable: true, - active: activeColumn, - formatter: (value, key, item: FolderContent) => { - const metadata = item.metadata; - const entries = metadata.match(undefined, path.object); - if (entries.size) { - const returnList: string[] = []; - for (const entry of entries) { - const entryObject = entry.object; - let entryValue = entryObject.value; - if (entryObject.termType === "Literal") { - if (entryObject.datatype.value.endsWith("date")) { - const date = entryObject.value; - const dateObject = new Date(date); - entryValue = dateObject.toLocaleDateString( - this.$i18n.locale - ); - } - } else if (entryObject.termType === "NamedNode") { - for (const classValue of Object.values(this.classes)) { - const langClassList = - this.$i18n.locale === "de" - ? classValue.de - : classValue.en; - if (langClassList) { - const foundEntry = langClassList.find( - (langClassEntry) => - langClassEntry.value === entryValue - ); - if (foundEntry && foundEntry.name) { - entryValue = foundEntry.name; - break; - } - } - } - } - returnList.push(entryValue); - } - return returnList.join("<br>"); - } - return ""; - }, - sortByFormatted: true, - filterByFormatted: true, - }) - ); - } } + + // Add the processed entry value to the list of results. + returnList.push(entryValue); } + + // Join the list of results with line breaks and return the final formatted value. + return returnList.join("<br>"); }, - getData() { - // Doesn't end with '/' => Probably a file - let currentFolder = this.currentFolder; - if (!currentFolder.endsWith("/") && currentFolder !== "") { + + /** + * Navigate the folder structure based on the current folder's path. + * This method serves to identify whether the current folder is actually a file, + * opens it accordingly, and then navigates the directory structure. + */ + async navigateTree() { + // Start with the current folder as the reference + let currentFolder = this.dirTrail; + + // Check if the current folder might actually be a file and handle it + if (this.isProbablyFile(currentFolder)) { // TODO: Change to open modal - this.openFile({ - id: uuidv4(), - isFolder: false, + await this.openFile({ name: currentFolder.substring(currentFolder.lastIndexOf("/") + 1), path: currentFolder, - absolutePath: currentFolder, - version: `${+new Date()}`, - size: 0, - metadata: factory.dataset() as unknown as Dataset, - }); - currentFolder = currentFolder.substring( - 0, - currentFolder.lastIndexOf("/") + 1 - ); - } - if (currentFolder !== "/") { - let combine = ""; - const folderSplit = currentFolder.split("/"); - for (const folderKey in folderSplit) { - if (Object.prototype.hasOwnProperty.call(folderSplit, folderKey)) { - const folder = folderSplit[folderKey]; - if (combine !== "" && folder === "") { - continue; - } - combine += folder + "/"; - this.folderPath.push(combine); - } - } - this.folderPath.pop(); - if (this.folderPath.length > 0) { - this.$emit("currentFolder", this.folderPath.pop()); - } else { - this.$emit("currentFolder", ""); - } - } else { - this.$emit("currentFolder", ""); + } as FileInformation); + // Remove the file name from the path to get the folder path, then navigate to it + currentFolder = this.dirCrumbs.join(""); + this.triggerNavigation({ path: currentFolder } as FolderInformation); } - this.openFolder({ - id: uuidv4(), - isFolder: true, - name: currentFolder.substring(currentFolder.lastIndexOf("/") + 1), - path: currentFolder, - absolutePath: currentFolder, - metadata: factory.dataset() as unknown as Dataset, + + // Finally, open the current folder to display its contents + await this.openFolder({ path: currentFolder } as FolderInformation); + }, + + /** + * Navigates to the specified folder or resource based on the given `entry`. + * If the provided `entry.path` is different from the current `dirTrail`, + * a new route will be pushed to the Vue router. + * + * @param {FolderContent} entry - The folder or file information object to navigate to. + */ + triggerNavigation(entry: FolderContent) { + this.$router.push({ + name: "resource-page", + params: { + dirTrail: entry.path, + }, }); }, + + /** + * Determines if a given path represents a file based on its structure. + * Files typically do not end with a slash `/`. + * + * @param {string} path - The path to evaluate. + * @returns {boolean} - True if the path might represent a file, false otherwise. + */ + isProbablyFile(path: string | undefined): boolean | undefined { + return path !== "" && !path?.endsWith("/"); + }, + + /** + * Splits a path into its constituent directories. + * + * @param {string} path - The path to split. + * @returns {string[]} - An array representing the folder's hierarchical structure. + */ + splitPathIntoDirectories(path: string): string[] { + const directories = []; + let combinedPath = ""; + const splitFolders = path.split("/"); + for (const folder of splitFolders) { + if (combinedPath && !folder) continue; + combinedPath += folder + "/"; + directories.push(combinedPath); + } + return directories; + }, + + /** + * Saves table preferences in local storage. + */ async saveInLocalStorage() { await this.resourceStore.setStoredColumns({ columns: this.columns, @@ -496,20 +646,34 @@ export default defineComponent({ sortDesc: this.sortDesc, }); }, + + /** + * Loads table preferences from local storage. + * @returns {Array<CustomTableField>} - Array of table fields. + */ loadFromLocalStorage(): Array<CustomTableField> { - let element: Array<CustomTableField> = []; - if (this.resourceStore.currentStoredColumns) { - // Deal with old values - if (Array.isArray(this.resourceStore.currentStoredColumns)) { - return this.resourceStore.currentStoredColumns; + let storedColumns: Array<CustomTableField> = []; + const currentStoredColumns = this.resourceStore.currentStoredColumns; + + if (currentStoredColumns) { + // If the stored columns are just an array (legacy format) + if (Array.isArray(currentStoredColumns)) { + return currentStoredColumns; } - element = this.resourceStore.currentStoredColumns.columns; - this.filter = this.resourceStore.currentStoredColumns.filter; - this.sortBy = this.resourceStore.currentStoredColumns.sortBy; - this.sortDesc = this.resourceStore.currentStoredColumns.sortDesc; + + // Retrieve and set stored preferences + storedColumns = currentStoredColumns.columns; + this.filter = currentStoredColumns.filter; + this.sortBy = currentStoredColumns.sortBy; + this.sortDesc = currentStoredColumns.sortDesc; } - return element; + return storedColumns; }, + + /** + * Deactivates a column based on the provided row. + * @param {BFormRow} row - Row which represents a column. + */ removeColumn(row: BFormRow) { for (const column of this.columns) { if (column.key === row.column) { @@ -517,109 +681,192 @@ export default defineComponent({ } } }, + + /** + * Emits an event to show the delete modal for a specific file. + * @param {FolderContent} file - File to delete. + */ showModalDeleteFile(file: FolderContent) { this.$emit("showModalDelete", [file]); }, - async openFolder(folder: FolderInformation) { - if (this.resource?.id) { - this.isBusy = true; + /** + * Asynchronously opens the specified file, fetching its blob content and triggering a download. + * @async + * @param {FileInformation} file - Information about the file to be opened. + */ + async openFile(file: FileInformation) { + if (this.project?.id && this.resource?.id) { + // Download the file as a blob (binary data) + const asBlob = true; + + // Fetch the blob content of the file + const response = await this.resourceStore.getBlob( + this.project.id, + this.resource.id, + file.path, + asBlob + ); - // Empty file list, since the context changes - // (Other workaround would be to keep the editing files open) - this.$emit("fileListEdit", []); + if (response) { + // Trigger file download + fileSaver.saveAs( + new Blob([response.data], { + type: response.headers["content-type"], + }), + file.name + ); - const absolutePath = - folder.absolutePath !== "" ? folder.absolutePath : "/"; + // TODO: Consider adding a visual indicator that the file is being downloaded + } + } + }, + + /** + * Asynchronously opens the given folder, fetching its file tree and metadata tree. + * @async + * @param {FolderInformation} folder - Information about the folder to be opened. + */ + async openFolder(folder: FolderInformation) { + if (!this.project?.id || !this.resource?.id) return; - // Show location change - window.location.hash = absolutePath; + this.isBusy = true; - const response = await this.resourceStore.getMetadata( + // Empty file list, since the context changes + // (Other workaround would be to keep the editing files open) + this.emitEmptyFileList(); + + // Determine the path of the folder and fetch its file and metadata trees + const path = this.determinePath(folder); + const [fileTree, metadataTree] = await Promise.all([ + this.resourceStore.getFileTree(this.project.id, this.resource.id, path), + this.resourceStore.getMetadataTree( + this.project.id, this.resource.id, - absolutePath - ); + path + ), + ]); - const tmpFolder: FolderContent[] = []; - if (folder.name === "..") { - this.$emit("currentFolder", this.folderPath.pop()); - } else if (this.currentFolder !== "") { - this.folderPath.push(this.currentFolder); - this.$emit("currentFolder", absolutePath); - } else { - this.$emit("currentFolder", absolutePath); - } + // Create a temporary folder to store the entries + const folderContents = await this.constructFolderContents( + fileTree, + metadataTree + ); - if (this.folderPath.length > 0) { - const path = this.folderPath[this.folderPath.length - 1]; - const navigateUpFolder: ReadOnlyFolderInformation = { - id: uuidv4(), - isFolder: true, - name: "..", - absolutePath: path, - path: path, - readOnly: true, - metadata: factory.dataset() as unknown as Dataset, - }; - tmpFolder.push(navigateUpFolder); - } + this.isBusy = false; + this.$emit("folderContents", folderContents); + }, - for (const obj of response.data.fileStorage) { - const newEntry: FileInformation = { - id: uuidv4(), - isFolder: obj.IsFolder, - name: obj.Name, - path: obj.Path, - absolutePath: obj.Path, - lastModified: obj.Modified, - created: obj.Created, - size: obj.Size, - version: `${+new Date()}`, - metadata: factory.dataset() as unknown as Dataset, - }; - - const resultArray = MetadataManagerUtil.filterMetadataStorage( - response.data.metadataStorage, - newEntry.absolutePath - ); + /** + * Emits an event to notify of an empty file list. + */ + emitEmptyFileList() { + this.$emit("fileListEdit", []); + }, - if (resultArray.length > 0) { - const result = resultArray[0][Object.keys(resultArray[0])[0]]; - if (typeof result === "string") { - newEntry.metadata = await parseRDFDefinition( - result, - "application/n-triples" - ); - } - } + /** + * Determines the correct path for the folder. + * @param {FolderInformation} folder - Information about the folder for which path needs to be determined. + * @returns {string} The determined path. + */ + determinePath(folder: FolderInformation): string { + return folder.path; // !== "" ? folder.path : "/"; + }, - tmpFolder.push(newEntry); + /** + * Constructs the folder content based on the provided file and metadata trees. + * @async + * @param {FileDto[] | null | undefined} fileTree - File tree of the folder. + * @param {MetadataDto[] | null | undefined} metadataTree - Metadata tree of the folder. + * @returns {FolderContent[]} An array containing folder content. + */ + async constructFolderContents( + fileTree: FileDto[] | null | undefined, + metadataTree: MetadataDto[] | null | undefined + ): Promise<FolderContent[]> { + const folderContents = []; + + if (this.dirCrumbs.length > 0) { + // Add navigate up folder entry (..) + folderContents.push(this.createNavigateUpFolder()); + } + + // Iterate over the file tree and map the entries for the files view table + for (const fileEntry of fileTree || []) { + const content = this.mapFileEntryToContent(fileEntry); + if (content) { + const metadataEntry = metadataTree?.find( + (md) => md.path === fileEntry.path + ); + if (metadataEntry?.definition && metadataEntry?.format) { + content.metadata = await parseRDFDefinition( + metadataEntry.definition, + metadataEntry.format + ); + } + folderContents.push(content); } - this.isBusy = false; - this.$emit("folderContents", tmpFolder); } + return folderContents; }, - async openFile(file: FileInformation) { - if (this.resource?.id) { - const response = await this.resourceStore.getFile( - this.resource.id, - file.absolutePath, - true - ); - if (response !== null) { - fileSaver.saveAs(response, file.name); - } - window.location.hash = this.currentFolder; + /** + * Creates a representation for the "navigate up" folder. + * @returns {FolderContent} A representation of the "navigate up" folder. + */ + createNavigateUpFolder(): FolderContent { + // Make a shallow copy of the array and copy all but the last element as it is the current folder itself + const currentDirCrumbs = this.dirCrumbs.slice(0, -1); + const path = currentDirCrumbs.join(""); + return { + id: uuidv4(), + isFolder: true, + name: "..", + path: path, + readOnly: true, + type: "Tree" as TreeDataType.Tree, + parentDirectory: "", + metadata: factory.dataset() as unknown as Dataset, + } as ReadOnlyFolderInformation; + }, + + /** + * Maps a file entry to its corresponding folder content. + * @param {FileDto} fileEntry - The file data transfer object to map. + * @returns {FolderContent | undefined} The corresponding folder content or undefined if the type is unknown. + */ + mapFileEntryToContent(fileEntry: FileDto): FolderContent | undefined { + let content: FolderContent | undefined = undefined; + switch (fileEntry.type) { + case "Tree" as TreeDataType.Tree: + content = treeMapper.map(TreeDto2FolderInformation, fileEntry); + break; + case "Leaf" as TreeDataType.Leaf: + content = treeMapper.map(TreeDto2FileInformation, fileEntry); + break; + default: + console.error("Unknown content type: ", fileEntry.type); + break; } + return content; }, + + /** + * Handles when a row is selected, updating the UI and emitting necessary events. + * @param {FolderContent[]} items - Selected items. + */ onRowSelected(items: FolderContent[]) { this.$emit("showDetail", items.length > 0); this.selectAll = items.length === this.folderContents.length; - // Filter out folders like ".." - this.selectableFiles = items.filter((item) => !item.readOnly); - this.updateFileListEdit(); + // Filter out read-only entires like the navigation up folder ".." + const selectedFiles = items.filter((item) => !item.readOnly); + this.updateFileListEdit(selectedFiles); }, + + /** + * Selects or deselects all rows. + * @param {BTable} row - Reference to the table. + */ allSelect(row: BTable) { if (this.selectAll) { row.selectAllRows(); @@ -627,77 +874,84 @@ export default defineComponent({ row.clearSelected(); } }, + + /** + * Toggles the selection of a specific row. + * @param {{ rowSelected: boolean; index: number }} row - Info about the row's status and its index. + */ select(row: { rowSelected: boolean; index: number }) { - if (row.rowSelected) { - (this.$refs.adaptTable as BTable).selectRow(row.index); - } else { - (this.$refs.adaptTable as BTable).unselectRow(row.index); - } + const tableRef = this.$refs.adaptTable as BTable; + row.rowSelected + ? tableRef.selectRow(row.index) + : tableRef.unselectRow(row.index); }, + + /** + * Emits an event for file selection. + */ clickFileSelect() { this.$emit("clickFileSelect"); }, - async updateFileListEdit() { - const newFileListEdit = [] as FolderContent[]; - for (const currentFileEdit of this.fileListEdit) { - const selectedFile = this.selectableFiles.find( - (f) => - this.currentFolder === currentFileEdit.path && - f.name === currentFileEdit.name - ); - if (selectedFile) { - newFileListEdit.push(currentFileEdit); - } - } - for (const currentSelectableFile of this.selectableFiles) { - const selectableFile = currentSelectableFile; - const fileEdit = newFileListEdit.find( - (f) => f.path === this.currentFolder && f.name === selectableFile.name - ); - if (!fileEdit) { - const loadedMetadata = await MetadataManagerUtil.loadMetadata( - selectableFile, - this.resource + + /** + * Updates the file list for editing based on current selections. + */ + async updateFileListEdit(selectedFiles: FolderContent[]) { + const existingFilesSet = new Set(this.fileListEdit.map((f) => f.name)); + const newFileListEdit: FileInformation[] = []; + + for (const file of selectedFiles) { + // Check if file already exists in the list + if (existingFilesSet.has(file.name) && this.dirTrail === file.path) { + const existingFile = this.fileListEdit.find( + (f) => f.name === file.name ); - newFileListEdit.push({ - id: uuidv4(), - lastModified: selectableFile.lastModified, - created: selectableFile.created, - path: this.currentFolder, - version: `${+new Date()}`, - uploading: false, - name: selectableFile.name, - metadata: loadedMetadata, - isFolder: selectableFile.isFolder, - absolutePath: selectableFile.absolutePath, - info: - selectableFile.isFolder === false - ? selectableFile.info - : undefined, - size: selectableFile.isFolder === false ? selectableFile.size : 0, - }); + if (existingFile) { + newFileListEdit.push(existingFile as FileInformation); + } + continue; } + + // If file doesn't exist, fetch metadata and add + const loadedMetadata = await MetadataManagerUtil.loadMetadataForFile( + file, + this.project, + this.resource + ); + + newFileListEdit.push({ + ...file, + id: uuidv4(), + version: `${+new Date()}`, + uploading: false, + metadata: loadedMetadata, + info: file.isFolder ? undefined : file.info, + size: file.isFolder ? 0 : file.size, + } as FileInformation); } + this.$emit("fileListEdit", newFileListEdit); - if (this.selectableFiles.length > 0) { - this.$emit("showDetail", true); - } else { - this.$emit("showDetail", false); - } + this.$emit("showDetail", selectedFiles.length > 0); }, + + /** + * Converts last modified date into a locale string format. + * @param {FolderContent} item - Item to retrieve the date from. + * @returns {string} - Formatted date string. + */ renderDate(item: FolderContent): string { - if (!item.lastModified) { - return ""; - } - const date = item.lastModified; - const dateObject = new Date(date); - return dateObject.toLocaleDateString(this.$i18n.locale); + return item.lastModified + ? new Date(item.lastModified).toLocaleDateString(this.$i18n.locale) + : ""; }, + + /** + * Converts size into a readable string format. + * @param {FolderContent} item - Item to retrieve the size from. + * @returns {string} - Formatted size string. + */ renderSize(item: FolderContent): string { - if (!item.isFolder) { - return FileUtil.formatBytes(item.size); - } - return ""; + return !item.isFolder ? FileUtil.formatBytes(item.size) : ""; }, }, }); @@ -763,22 +1017,6 @@ export default defineComponent({ #resourceViewTable div.tableCheck.custom-checkbox label, .checkFile { vertical-align: top; - cursor: pointer; -} - -#resourceViewTable tr:hover .dotMenu button { - font-weight: bold; - margin: 0; -} - -#resourceViewTable .dotMenu { - position: absolute; - right: 0px; - margin-top: -5px; -} - -#resourceViewTable .dotMenu .dropdown-item { - margin: 1px; } </style> @@ -810,10 +1048,6 @@ export default defineComponent({ margin-right: 20px; } -#resourceViewTable th .additionalColumnHeader svg { - cursor: pointer; -} - .adaptTable { overflow: auto; position: absolute; @@ -824,12 +1058,16 @@ export default defineComponent({ width: auto; } -.rightFloating { - float: right; +.fileViewEntryWrapper { + align-items: center; } -.dataSourceItem { - vertical-align: sub; +.fileViewEntry { + padding: 0.05rem 0.5rem; +} + +a.fileViewEntry { + cursor: pointer; } .DataSource { diff --git a/src/modules/resource/components/resource-page/FilesViewHeader.vue b/src/modules/resource/components/resource-page/FilesViewHeader.vue index e2d36dbb4400d20774623397d965ac41e0d07200..6c2e4079924d7701fc041ea4d691efaabd3604c7 100644 --- a/src/modules/resource/components/resource-page/FilesViewHeader.vue +++ b/src/modules/resource/components/resource-page/FilesViewHeader.vue @@ -34,6 +34,7 @@ :disabled="isUploading || readOnly || (resource && resource.archived)" @click="upload" > + <!-- TODO: Add GitLab branch disabled logic as in MetadataManagerHeader --> <b-icon icon="plus" :title="$t('page.resource.upload')" /> </b-button> diff --git a/src/modules/resource/components/resource-page/MetadataManager.vue b/src/modules/resource/components/resource-page/MetadataManager.vue index 1570c7c88ff78f376aebf9dc2064d9deef19d67f..7e8a614c9b389df6890a7f4ce23ee21066cd566c 100644 --- a/src/modules/resource/components/resource-page/MetadataManager.vue +++ b/src/modules/resource/components/resource-page/MetadataManager.vue @@ -1,5 +1,6 @@ <template> <b-container id="detail-view"> + <!-- Header Buttons --> <MetadataManagerHeader v-if="resource" :editable-data-url="editableDataUrl" @@ -12,6 +13,8 @@ @selectFiles="selectFiles" @showModalDeleteFolderContents="showModalDeleteFolderContents" /> + + <!-- Selected Files Table --> <MetadataManagerTable :current-file-id="currentFileId" :current-folder-content="currentFolderContent" @@ -23,8 +26,11 @@ @loadAllFilesTab="changeMetadata(-1)" @removeElement="removeElement" /> + + <!-- Metadata Section --> <b-row id="metadataManagerMetadataSection"> <b-col> + <!-- File Information --> <MetadataManagerFileInformation v-if=" showDetail && @@ -35,6 +41,7 @@ :current-file-id="currentFileId" :file-list-edit="fileListEdit" /> + <!-- Special Properties (see Linked Data) --> <MetadataManagerSpecialProperties v-if=" shownFiles.length > 0 && @@ -51,6 +58,7 @@ @updateAbsolutePath="updateAbsolutePath" @updateDataUrl="updateDataUrl" /> + <!-- Form Generator --> <span v-if=" resource && resource.applicationProfile !== '' && applicationProfile @@ -77,6 +85,7 @@ </b-col> </b-row> + <!-- Footer --> <MetadataManagerFooter :is-uploading="isUploading" :number-of-currently-processed-files="numberOfCurrentlyProcessedFiles" @@ -89,11 +98,15 @@ @update="update" @uploadPreparation="uploadPreparation" /> + + <!-- Validation Popover --> <ValidationPopover v-if="validationResults[currentFileId + 1]" :valid="valid" :validation-results="validationResults[currentFileId + 1].results" /> + + <!-- Duplicate Files Modal --> <SaveDuplicateFilesModal :visible="saveDuplicateFilesModalVisible" :upload-file-list-replace-files="uploadFileListReplaceFiles" @@ -111,40 +124,36 @@ import { defineComponent, reactive, type PropType } from "vue"; import useResourceStore from "../../store"; import useProjectStore from "@/modules/project/store"; import useUserStore from "@/modules/user/store"; - +import useNotificationStore from "@/store/notification"; import fileSaver from "file-saver"; - import MetadataManagerUtil from "../../utils/MetadataManagerUtil"; - import MetadataManagerHeader from "./metadata/MetadataManagerHeader.vue"; import MetadataManagerTable from "./metadata/MetadataManagerTable.vue"; import MetadataManagerFileInformation from "./metadata/MetadataManagerFileInformation.vue"; import MetadataManagerSpecialProperties from "./metadata/MetadataManagerSpecialProperties.vue"; import MetadataManagerFooter from "./metadata/MetadataManagerFooter.vue"; - import SaveDuplicateFilesModal from "./modals/SaveDuplicateFilesModal.vue"; import ValidationPopover from "./popovers/ValidationPopover.vue"; - -import type { - FileInformation, - FolderContent, -} from "../../utils/EntryDefinition"; - import "@/plugins/form-generator"; - -import type { Dataset } from "@rdfjs/types"; import factory from "rdf-ext"; - -import type ValidationReport from "rdf-validate-shacl/src/validation-report"; - import { v4 as uuidv4 } from "uuid"; -import useNotificationStore from "@/store/notification"; +import { serializeRDFDefinition } from "../../utils/linkedData"; +import type ValidationReport from "rdf-validate-shacl/src/validation-report"; +import type { Dataset } from "@rdfjs/types"; import type { + MetadataTreeForCreationDto, + MetadataTreeForUpdateDto, + ProjectDto, + RdfFormat, ResourceDto, ResourceTypeInformationDto, UserDto, } from "@coscine/api-client/dist/types/Coscine.Api"; -import type { BilingualLabels } from "../../types"; +import type { + BilingualLabels, + FileInformation, + FolderContent, +} from "../../types"; export default defineComponent({ components: { @@ -157,6 +166,7 @@ export default defineComponent({ SaveDuplicateFilesModal, ValidationPopover, }, + props: { showDetail: { default: false, @@ -184,11 +194,26 @@ export default defineComponent({ }, type: Array as PropType<FolderContent[]>, }, - currentFolder: { - default: "/", + dirTrail: { + required: true, type: String, }, + dirCrumbs: { + required: true, + type: Array as PropType<string[]>, + }, + }, + + emits: { + clickFileSelect: () => true, + emptyFileLists: (_: boolean) => true, + isUploading: (_: boolean) => true, + navigateTree: () => true, + removeElement: (_index: number, _count: number) => true, + removeSelection: (_index: number, _count: number) => true, + showModalDelete: (_: FolderContent[]) => true, }, + setup() { const resourceStore = useResourceStore(); const projectStore = useProjectStore(); @@ -202,9 +227,10 @@ export default defineComponent({ notificationStore, }; }, + data() { return { - currentUsedMetadata: factory.dataset() as unknown as Dataset, + currentUsedMetadata: factory.dataset() as unknown as Dataset | null, numberOfCurrentlyProcessedFiles: 0, totalNumberOfCurrentlyProcessedFiles: 0, uploadDuplicates: true, @@ -227,6 +253,7 @@ export default defineComponent({ saveDuplicateFilesModalVisible: false, }; }, + computed: { isGuest(): boolean | undefined { return this.projectStore.currentUserRoleIsGuest; @@ -234,6 +261,9 @@ export default defineComponent({ applicationProfile(): Dataset | null { return this.resourceStore.currentFullApplicationProfile; }, + project(): null | ProjectDto { + return this.projectStore.currentProject; + }, resource(): null | ResourceDto { return this.resourceStore.currentResource; }, @@ -264,7 +294,8 @@ export default defineComponent({ currentFolderContent(): FolderContent | undefined { return this.shownFiles[this.currentFileId]; }, - currentMetadata(): Dataset { + + currentMetadata(): Dataset | null { if ( this.currentFileId >= 0 && this.currentFileId < this.shownFiles.length @@ -350,8 +381,11 @@ export default defineComponent({ fileListUpload() { this.getOptions(); }, - fileListEdit() { - this.getOptions(); + fileListEdit(newVal, oldVal) { + // Trigger only the value really changed. Otherwise double API calls are triggered. + if (newVal !== oldVal) { + this.getOptions(); + } }, isValidating() { if (!this.isValidating && this.storedSwitch !== null) { @@ -370,15 +404,28 @@ export default defineComponent({ ); }, }, + created() { this.getOptions(); }, + methods: { + /** + * Fetches vocabulary instances for a given class name from the store. + * + * @async + * @param {string} className - The class name for which vocabulary instances need to be fetched. + * @returns {Promise<BilingualLabels>} Returns a promise resolving to bilingual labels. + */ async getVocabularyInstances(className: string): Promise<BilingualLabels> { return await this.resourceStore.getVocabularyInstances(className); }, + + /** + * Applies metadata templates to the shown files. + */ applyMetadataTemplate() { - if (this.currentUsedMetadata.size > 0) { + if (this.currentUsedMetadata && this.currentUsedMetadata.size > 0) { for (const currentFile of this.shownFiles) { currentFile.metadata = factory.dataset( Array.from(this.currentUsedMetadata) @@ -386,6 +433,13 @@ export default defineComponent({ } } }, + + /** + * Validates a file based on provided validation results. + * + * @param {ValidationReport} valid - The validation report. + * @param {string} fileString - The file string which will be converted to a number to identify the file. + */ isValid(valid: ValidationReport, fileString: string) { let fileId = Number(fileString); if (isNaN(fileId)) { @@ -404,9 +458,22 @@ export default defineComponent({ ); } }, + + /** + * Updates the validation status. + * + * @param {boolean} isValidating - The current validation status. + * @param {string} _ - Placeholder for an unused parameter. + */ isValidatingChange(isValidating: boolean, _: string) { this.isValidating = isValidating; }, + + /** + * Changes the currently viewed metadata based on the provided index. + * + * @param {number} index - The index of the file whose metadata is to be viewed. + */ changeMetadata(index: number) { if (this.isValidating) { this.storedSwitch = index; @@ -414,6 +481,13 @@ export default defineComponent({ this.currentFileId = index; } }, + + /** + * Removes elements from the list based on the provided index and count. + * + * @param {number} index - The starting index for the removal. + * @param {number} count - The number of elements to be removed. + */ removeElement(index: number, count = 1) { if (this.showDetail) { this.$emit("removeSelection", index, count); @@ -421,6 +495,14 @@ export default defineComponent({ this.$emit("removeElement", index, count); } }, + + /** + * Adjusts the status of processed files. Sends notifications if necessary. + * + * @param {FolderContent} file - The file whose status needs adjustment. + * @param {boolean} error - A flag indicating if there was an error processing the file. + * @param {boolean} reload - A flag indicating if the page should reload. + */ adjustRemainingFiles(file: FolderContent, error = false, reload = false) { this.numberOfCurrentlyProcessedFiles -= 1; this.progressStatus = 0; @@ -480,6 +562,12 @@ export default defineComponent({ } } }, + + /** + * Initializes the count of remaining files to be processed. + * + * @param {number} numberOfFiles - The initial number of files to be processed. + */ initRemainingFiles(numberOfFiles: number) { this.fileListError = []; this.numberOfCurrentlyProcessedFiles = numberOfFiles; @@ -498,12 +586,24 @@ export default defineComponent({ this.$emit("isUploading", true); } }, + + /** + * Updates the metadata for the current folder content and sets the current metadata. + * + * @param {Dataset} metadata - The metadata to be set. + */ inputMetadata(metadata: Dataset) { if (this.currentFolderContent) { this.currentFolderContent.metadata = metadata; } this.currentUsedMetadata = metadata; }, + + /** + * Prepares for file uploads by segregating new files and replacement files. + * + * @async + */ async uploadPreparation() { this.uploadFileListNewFiles = []; this.uploadFileListReplaceFiles = []; @@ -523,6 +623,12 @@ export default defineComponent({ await this.upload(); } }, + + /** + * Starts the file upload process. + * + * @async + */ async upload() { if (this.currentFileId === -1) { this.applyMetadataTemplate(); @@ -539,26 +645,66 @@ export default defineComponent({ } } }, + + /** + * Asynchronously uploads a file and its metadata. + * It first constructs and serializes a metadata tree DTO for the file, and then + * attempts to create this metadata tree. If successful, the function uploads the file's content. + * + * @param {FileInformation} file - The file information object to be uploaded. + * @async + */ async uploadFile(file: FileInformation) { - if (this.resource?.id) { - const result = await this.resourceStore.storeMetadata( - this.resource.id, - file.absolutePath, - file.metadata - ); - if (result) { - if (!this.editableDataUrl && file.info) { - await this.handleUploadContent(file.info, file); - } else if (file.dataUrl !== undefined) { - this.fillData(file); - const blob = new Blob([file.dataUrl], { type: "plain/text" }); - await this.handleUploadContent(blob, file); - } - } else { - this.adjustRemainingFiles(file, true); + if (!this.project?.id || !this.resource?.id) { + return; + } + + // Specify the target content type + const contentType = "text/turtle"; + + // Create the metadata tree DTO and serialize the definition + const metadataTreeForCreationDto: MetadataTreeForCreationDto = { + path: file.path, + definition: await serializeRDFDefinition(file.metadata, contentType), + format: contentType as RdfFormat, + }; + + // Trigger metadata tree creation. Metadata might be already present, so we try updating it if true. + const success = await this.resourceStore.addOrUpdateMetadataTree( + this.project.id, + this.resource.id, + metadataTreeForCreationDto + ); + + if (success) { + // Check if the file is not editable via data URL and has info, and if so, handle its content upload + if (!this.editableDataUrl && file.info) { + await this.handleUploadContent(file.info, file); } + // Else, if the file has a data URL, convert it to a blob and handle its content upload + else if (file.dataUrl !== undefined) { + this.fillData(file); + const blob = new Blob([file.dataUrl], { type: "plain/text" }); + await this.handleUploadContent(blob, file); + } + } else { + // On Failure, post a generic warning notification + this.notificationStore.postNotification({ + title: this.$t("toast.apiError.failure.title").toString(), + body: this.$t("toast.apiError.failure.message").toString(), + variant: "warning", + }); + // If metadata tree creation was not successful, adjust the count of files remaining + this.adjustRemainingFiles(file, true); } }, + + /** + * Converts a FileInformation's dataUrl into a File object. + * If no dataUrl exists, initializes it as an empty string. + * + * @param {FileInformation} file - The file information to process. + */ fillData(file: FileInformation) { if (!file.dataUrl) { file.dataUrl = ""; @@ -567,109 +713,188 @@ export default defineComponent({ type: "text/plain", }); }, + + /** + * Handles the uploading of file contents. + * Depending on whether the file already exists, either updates or creates a new blob. + * Emits updated folder contents if necessary. + * + * @param {Blob} contents - The file contents to upload. + * @param {FileInformation} file - The file information. + * @async + */ async handleUploadContent(contents: Blob, file: FileInformation) { - if (this.resource?.id) { - this.progressStatus = 0; - const result = await this.resourceStore.storeFile( - this.resource.id, - file.absolutePath, - [contents], - { - onUploadProgress: (progressEvent: ProgressEvent) => { - this.progressStatus = Math.round( - (progressEvent.loaded * 100) / progressEvent.total - ); - }, - } + if (this.project?.id && this.resource?.id) { + const fileExists = this.folderContents.some( + (x) => x.name === file.name ); - if (!result) { + this.progressStatus = 0; + + // Update progress as the file is uploaded + const onUploadProgress = (progressEvent: ProgressEvent) => { + this.progressStatus = Math.round( + (progressEvent.loaded * 100) / progressEvent.total + ); + }; + + // Depending on the file's existence, either update or create the blob + const uploadResult = fileExists + ? await this.resourceStore.updateBlob( + this.project.id, + this.resource.id, + file.path, + contents, + { onUploadProgress } + ) + : await this.resourceStore.createBlob( + this.project.id, + this.resource.id, + file.path, + contents, + { onUploadProgress } + ); + + if (!uploadResult) { this.adjustRemainingFiles(file, true); } + } - const entry = this.folderContents.find((x) => x.name === file.name); - if (entry === undefined) { - const newRow: FileInformation = { - id: uuidv4(), - isFolder: false, - name: file.name, - absolutePath: file.absolutePath, - lastModified: new Date().toString(), - created: new Date().toString(), - size: file.size, - path: file.path, - info: file.info, - metadata: factory.dataset() as unknown as Dataset, - version: `${+new Date()}`, - }; - MetadataManagerUtil.copyMetadata(file.metadata, newRow); - this.$emit("folderContents", [...this.folderContents, newRow]); - } else { - entry.lastModified = new Date().toString(); - entry.created = new Date().toString(); - if (!entry.isFolder) { - entry.size = file.size; - } - MetadataManagerUtil.copyMetadata(file.metadata, entry); + // Update or emit the updated folder contents + const entry = this.folderContents.find((x) => x.name === file.name); + if (entry === undefined) { + const newRow: FileInformation = { + id: uuidv4(), + isFolder: false, + name: file.name, + type: file.type, + parentDirectory: file.parentDirectory, + lastModified: new Date().toString(), + createdAt: new Date().toString(), + size: file.size, + path: file.path, + info: file.info, + metadata: factory.dataset() as unknown as Dataset, + version: `${+new Date()}`, + }; + MetadataManagerUtil.copyMetadata(file.metadata, newRow); + this.$emit("navigateTree"); + } else { + entry.lastModified = new Date().toString(); + entry.createdAt = new Date().toString(); + if (!entry.isFolder) { + entry.size = file.size; } - this.adjustRemainingFiles(file); + MetadataManagerUtil.copyMetadata(file.metadata, entry); } + this.adjustRemainingFiles(file); }, + + /** + * Downloads selected files. + * If the detail view is active, attempts to download each shown file. + * + * @async + */ async download() { - if (this.showDetail && this.resource?.id) { + if (this.showDetail && this.project?.id && this.resource?.id) { for (const editableFile of this.shownFiles) { - const response = await this.resourceStore.getFile( + const response = await this.resourceStore.getBlob( + this.project.id, this.resource.id, - editableFile.absolutePath, - true + editableFile.path ); - if (response) { - fileSaver.saveAs(response, editableFile.name); + if (response !== null) { + fileSaver.saveAs( + new Blob([response.data], { + type: response.headers["content-type"], + }), + editableFile.name + ); } } } }, + + /** + * Emits an event signaling the selection of files. + */ selectFiles() { this.$emit("clickFileSelect"); }, + + /** + * Asynchronously updates the resource's metadata based on the files in the edit list. + * It applies metadata templates, initializes remaining files, and then iterates over + * the edit list to update each file's metadata. It also handles file uploads when + * necessary. + * + * @async + */ async update() { - if (this.resource?.id) { - if (this.currentFileId === -1) { - this.applyMetadataTemplate(); - } - this.initRemainingFiles(this.fileListEdit.length); + if (!this.project?.id || !this.resource?.id) { + return; + } - for (const editableFile of this.fileListEdit) { - const result = await this.resourceStore.storeMetadata( - this.resource.id, - editableFile.absolutePath, - editableFile.metadata - ); - if (result) { - const tmp = this.folderContents.find( - (x) => x.name === editableFile.name - ); - if ( - this.editableDataUrl && - !editableFile.isFolder && - editableFile.dataUrl !== undefined - ) { - this.fillData(editableFile); - const blob = new Blob([editableFile.dataUrl], { - type: "plain/text", - }); - await this.handleUploadContent(blob, editableFile); - } else { - MetadataManagerUtil.copyMetadata(editableFile.metadata, tmp); - this.adjustRemainingFiles(editableFile); - } + // Specify the target content type + const contentType = "text/turtle"; + + // If this is the initial file, apply the metadata template + if (this.currentFileId === -1) { + this.applyMetadataTemplate(); + } + + // Initialize files yet to be processed + this.initRemainingFiles(this.fileListEdit.length); + + for (const file of this.fileListEdit) { + // Create the metadata tree DTO and serialize the definition + const metadataTreeForUpdateDto: MetadataTreeForUpdateDto = { + path: file.path, + definition: await serializeRDFDefinition(file.metadata, contentType), + format: contentType as RdfFormat, + }; + + const success = await this.resourceStore.updateOrAddMetadataTree( + this.project.id, + this.resource.id, + metadataTreeForUpdateDto + ); + + if (success) { + // Find the corresponding file entry from folder contents + const tmp = this.folderContents.find((x) => x.name === file.name); + + // If the file has a data URL and is editable, handle its content upload + if ( + this.editableDataUrl && + !file.isFolder && + file.dataUrl !== undefined + ) { + this.fillData(file); + const blob = new Blob([file.dataUrl], { + type: "plain/text", + }); + await this.handleUploadContent(blob, file); } else { - this.adjustRemainingFiles(editableFile, true); + // If not a file for upload, just copy the metadata + MetadataManagerUtil.copyMetadata(file.metadata, tmp); + this.adjustRemainingFiles(file); } + } else { + // If the update was not successful, adjust the remaining files count + this.adjustRemainingFiles(file, true); } } }, + + /** + * Retrieves blob options for each shown file, if applicable. + * Updates the data URL for each file from the store. + * + * @async + */ async getOptions() { - if (this.resource?.id) { + if (this.project?.id && this.resource?.id) { if (this.shownFiles.length === 1) { this.currentFileId = 0; } @@ -681,51 +906,89 @@ export default defineComponent({ return; } element.requesting = true; - const response = await this.resourceStore.getFile( + const response = await this.resourceStore.getBlob( + this.project.id, this.resource.id, - element.absolutePath + element.path ); - this.$set(element, "dataUrl", response); + if (response !== null) { + this.$set(element, "dataUrl", response.data); + } element.requesting = false; } } } } }, + + /** + * Updates the absolute path of the current folder content based on a new value. + * + * @param {string} value - The new value for the folder content's name. + */ updateAbsolutePath(value: string) { if (this.currentFolderContent) { this.currentFolderContent.name = value; + const currentFolder = this.dirCrumbs.join(""); this.currentFolderContent.path = - this.currentFolderContent.path !== "" - ? this.currentFolderContent.path - : "/"; - this.currentFolderContent.absolutePath = - this.currentFolderContent.path + this.currentFolderContent.name; + currentFolder + this.currentFolderContent.name; } }, + + /** + * Updates the data URL and size of the current folder content based on a new value. + * + * @param {string} value - The new value for the data URL. + */ updateDataUrl(value: string) { if (this.currentFolderContent && !this.currentFolderContent.isFolder) { this.currentFolderContent.dataUrl = value; this.currentFolderContent.size = value.length; } }, + + /** + * Emits an event to clean up file lists. + */ cleanup() { - this.$emit("emptyFileLists"); + this.$emit("emptyFileLists", true); }, + + /** + * Emits an event to display the modal for deleting folder contents. + */ showModalDeleteFolderContents() { this.$emit("showModalDelete", this.fileListEdit); }, + + /** + * Displays the modal for saving duplicate files. + */ showModalSaveDuplicateFiles() { this.saveDuplicateFilesModalVisible = true; }, + + /** + * Hides the modal for saving duplicate files. + */ hideModalSaveDuplicateFiles() { this.saveDuplicateFilesModalVisible = false; }, + + /** + * Handles the overwrite action in the modal for saving duplicate files. + * Initiates the upload process afterward. + */ overwriteModalSaveDuplicateFiles() { this.uploadDuplicates = true; this.saveDuplicateFilesModalVisible = false; this.upload(); }, + + /** + * Handles the skip action in the modal for saving duplicate files. + * Initiates the upload process afterward. + */ skipModalSaveDuplicateFiles() { this.uploadDuplicates = false; this.saveDuplicateFilesModalVisible = false; diff --git a/src/modules/resource/components/resource-page/metadata/MetadataManagerFileInformation.vue b/src/modules/resource/components/resource-page/metadata/MetadataManagerFileInformation.vue index 020d30858ebe1a47ba9d8bee67e30b2ddd9c8c37..0dedfe575534029fcd1a909948eec1b3063dc6e5 100644 --- a/src/modules/resource/components/resource-page/metadata/MetadataManagerFileInformation.vue +++ b/src/modules/resource/components/resource-page/metadata/MetadataManagerFileInformation.vue @@ -1,9 +1,9 @@ <template> <span> + <!-- File Name --> <CoscineFormGroup :label="$t('page.resource.infoFileName')"> <div - id="fileInfoFieldID" - class="fileInfoField" + id="fileInfofileName" data-toggle="tooltip" :title="currentFile.name" > @@ -14,42 +14,39 @@ }} </div> </CoscineFormGroup> + + <!-- Last Modified --> <CoscineFormGroup :label="$t('page.resource.infoFileLastModified')"> - <div class="fileInfoField"> - {{ - !currentFile.lastModified - ? $t("page.resource.infoFileNoInformation") - : new Date(currentFile.lastModified).toLocaleDateString( - $i18n.locale - ) - }} - </div> + {{ + !currentFile.lastModified + ? $t("page.resource.infoFileNoInformation") + : new Date(currentFile.lastModified).toLocaleDateString($i18n.locale) + }} </CoscineFormGroup> + + <!-- Uploaded --> <CoscineFormGroup :label="$t('page.resource.infoFileCreated')"> - <div class="fileInfoField"> - {{ - !currentFile.created - ? $t("page.resource.infoFileNoInformation") - : new Date(currentFile.created).toLocaleDateString($i18n.locale) - }} - </div> + {{ + !currentFile.createdAt + ? $t("page.resource.infoFileNoInformation") + : new Date(currentFile.createdAt).toLocaleDateString($i18n.locale) + }} </CoscineFormGroup> + + <!-- File Size --> <CoscineFormGroup :label="$t('page.resource.infoFileSize')"> - <div class="fileInfoField"> - {{ - currentFile.isFolder === false - ? currentFile.size + " Bytes" - : $t("page.resource.infoFileNoInformation") - }} - </div> + {{ + currentFile.isFolder === false + ? currentFile.size + " Bytes" + : $t("page.resource.infoFileNoInformation") + }} </CoscineFormGroup> </span> </template> <script lang="ts"> import { defineComponent, type PropType } from "vue"; - -import type { FolderContent } from "../../../utils/EntryDefinition"; +import type { FolderContent } from "@/modules/resource/types"; export default defineComponent({ props: { @@ -71,12 +68,7 @@ export default defineComponent({ </script> <style scoped> -.fileInfoField { - padding-top: calc(0.375rem + 1px); - padding-bottom: calc(0.375rem + 1px); -} -#fileInfoFieldID { - margin-left: auto; +#fileInfofileName { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; diff --git a/src/modules/resource/components/resource-page/metadata/MetadataManagerHeader.vue b/src/modules/resource/components/resource-page/metadata/MetadataManagerHeader.vue index 5af166d194c077d716201ec5d54a5bc115bc343c..f3923c7d8ef1885bace2c50b6fc70110b124818d 100644 --- a/src/modules/resource/components/resource-page/metadata/MetadataManagerHeader.vue +++ b/src/modules/resource/components/resource-page/metadata/MetadataManagerHeader.vue @@ -65,20 +65,20 @@ <script lang="ts"> import { defineComponent, type PropType } from "vue"; - import useProjectStore from "@/modules/project/store"; // import the store for current module import useResourceStore from "../../../store"; - -import type { FolderContent } from "../../../utils/EntryDefinition"; - import type { TranslateResult } from "vue-i18n"; import type { GitlabBranchDto, ResourceDto, ResourceTypeInformationDto, } from "@coscine/api-client/dist/types/Coscine.Api"; -import { CoscineResourceTypes } from "@/modules/resource/types"; +import { + CoscineResourceTypes, + type FolderContent, +} from "@/modules/resource/types"; + export default defineComponent({ props: { editableDataUrl: { @@ -168,7 +168,6 @@ export default defineComponent({ const accessToken = this.resource.type?.options?.gitLabOptions?.accessToken; const currentBranch = this.resource.type?.options?.gitLabOptions?.branch; - if (projectId && domain && accessToken && currentBranch) { const gitlabBranches = await this.resourceStore.getGitlabBranchesForProject( diff --git a/src/modules/resource/components/resource-page/metadata/MetadataManagerSpecialProperties.vue b/src/modules/resource/components/resource-page/metadata/MetadataManagerSpecialProperties.vue index 4120042ad9e9f0da9e11abce47116bcbefad57d6..d0aaa67efe3f64a9ce11dcb3d469967b86230b66 100644 --- a/src/modules/resource/components/resource-page/metadata/MetadataManagerSpecialProperties.vue +++ b/src/modules/resource/components/resource-page/metadata/MetadataManagerSpecialProperties.vue @@ -1,5 +1,6 @@ <template> <div> + <!-- Data URL --> <span v-if="editableDataUrl && !currentFolderContent.isFolder"> <CoscineFormGroup :mandatory="true" @@ -21,6 +22,8 @@ </b-input-group> </CoscineFormGroup> </span> + + <!-- Entry Name - Editable Key --> <span v-if="editableKey"> <CoscineFormGroup :mandatory="true" @@ -43,7 +46,7 @@ import { defineComponent, type PropType } from "vue"; import useProjectStore from "@/modules/project/store"; -import type { FolderContent } from "../../../utils/EntryDefinition"; +import type { FolderContent } from "@/modules/resource/types"; import type { ResourceDto } from "@coscine/api-client/dist/types/Coscine.Api"; export default defineComponent({ diff --git a/src/modules/resource/components/resource-page/metadata/MetadataManagerTable.vue b/src/modules/resource/components/resource-page/metadata/MetadataManagerTable.vue index e6ddadc099f1937f75bcbaaafd379c2070354081..ea85cea51ed93a0d8c33722703fec796bb00f30f 100644 --- a/src/modules/resource/components/resource-page/metadata/MetadataManagerTable.vue +++ b/src/modules/resource/components/resource-page/metadata/MetadataManagerTable.vue @@ -72,9 +72,9 @@ {{ currentFolderContent.name }} </span> </span> - <span v-else-if="shownFiles.length > 0">{{ - $t("page.resource.allFiles") - }}</span> + <span v-else-if="shownFiles.length > 0"> + {{ $t("page.resource.allFiles") }} + </span> </b-col> </b-row> </div> @@ -83,7 +83,7 @@ <script lang="ts"> import { defineComponent, type PropType } from "vue"; -import type { FolderContent } from "../../../utils/EntryDefinition"; +import type { FolderContent } from "@/modules/resource/types"; export default defineComponent({ props: { diff --git a/src/modules/resource/components/resource-page/modals/DeleteFolderContentsModal.vue b/src/modules/resource/components/resource-page/modals/DeleteFolderContentsModal.vue index c79ea2e4094d4f36beef6a9d1e32151c07caeeff..5b5ba6513cd68a93428a63966027e08fd49ada87 100644 --- a/src/modules/resource/components/resource-page/modals/DeleteFolderContentsModal.vue +++ b/src/modules/resource/components/resource-page/modals/DeleteFolderContentsModal.vue @@ -39,7 +39,7 @@ <script lang="ts"> import { defineComponent, type PropType } from "vue"; -import type { FolderContent } from "../../../utils/EntryDefinition"; +import type { FolderContent } from "@/modules/resource/types"; export default defineComponent({ props: { diff --git a/src/modules/resource/components/resource-page/modals/SaveDuplicateFilesModal.vue b/src/modules/resource/components/resource-page/modals/SaveDuplicateFilesModal.vue index 33527fd30354940dc50114f3c1ea388d7fa6caca..cca04be3826359119450ceefa7c080ed51dba42a 100644 --- a/src/modules/resource/components/resource-page/modals/SaveDuplicateFilesModal.vue +++ b/src/modules/resource/components/resource-page/modals/SaveDuplicateFilesModal.vue @@ -56,8 +56,8 @@ import { defineComponent, type PropType } from "vue"; import useResourceStore from "../../../store"; import useProjectStore from "@/modules/project/store"; -import type { VisitedResourceObject } from "../../../types"; -import type { FileInformation } from "@/modules/resource/utils/EntryDefinition"; +import type { FileInformation, VisitedResourceObject } from "../../../types"; + import type { ResourceTypeInformationDto } from "@coscine/api-client/dist/types/Coscine.Api"; export default defineComponent({ diff --git a/src/modules/resource/pages/ResourcePage.spec.ts b/src/modules/resource/pages/ResourcePage.spec.ts index 846ba7429e01d24ffd580a4bcca2934160d25930..8b4ceb1a4693bf23f6e8b7cddd938c2a95a075c1 100644 --- a/src/modules/resource/pages/ResourcePage.spec.ts +++ b/src/modules/resource/pages/ResourcePage.spec.ts @@ -1,5 +1,5 @@ /* Testing imports */ -import { createLocalVue, mount } from "@vue/test-utils"; +import { type Wrapper, createLocalVue, mount } from "@vue/test-utils"; import { createTestingPinia } from "@pinia/testing"; /* Vue i18n */ @@ -15,14 +15,19 @@ import { PiniaVuePlugin } from "pinia"; /* Tested Component */ import ResourcePage from "./ResourcePage.vue"; +import VueRouter from "vue-router"; +import { routes } from "@/router"; -import type Vue from "vue"; +import Vue from "vue"; import { getTestUserState } from "@/data/mockup/testUser"; import { getTestResourceState } from "@/data/mockup/testResource"; import { testProjectState } from "@/data/mockup/testProject"; import useResourceStore from "../store"; -import { getMetadataResponse } from "@/data/mockup/responses/getMetadata"; +import { + getFileTreeResponse, + getMetadataTreeResponse, +} from "@/data/mockup/responses/getMetadata"; function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -31,61 +36,87 @@ function sleep(ms: number) { /* Create a local Vue instance */ const localVue = createLocalVue(); localVue.use(PiniaVuePlugin); +localVue.use(VueRouter); +const router = new VueRouter({ routes: routes }); -describe("ResourcePage.vue", () => { - /* Checks for correct default handling of RCV page */ - test( - "defaultHandling", - async () => { - const testingPinia = createTestingPinia({ - createSpy: vitest.fn, - initialState: { - project: testProjectState, - resource: await getTestResourceState(), - user: getTestUserState(), - }, - }); +// Define the Vue instance type (computed properties) +interface ResourcePageComponent extends Vue { + dirTrail: string; +} - const resourceStore = useResourceStore(testingPinia); - vi.mocked(resourceStore.getMetadata).mockReturnValue( - Promise.resolve(getMetadataResponse) - ); - vi.mocked(resourceStore.getVocabularyInstances).mockReturnValue( - Promise.resolve({ en: [], de: [] }) - ); +describe("ResourcePage.vue", async () => { + let wrapper: Wrapper<ResourcePageComponent>; - const wrapper = mount(ResourcePage as unknown as typeof Vue, { - pinia: testingPinia, - i18n, - localVue, - }); + // Create a mocked pinia instance with initial state + const testingPinia = createTestingPinia({ + createSpy: vitest.fn, + initialState: { + project: testProjectState, + resource: await getTestResourceState(), + user: getTestUserState(), + }, + }); + + // Mock the API calls + const resourceStore = useResourceStore(testingPinia); + vi.mocked(resourceStore.getMetadataTree).mockReturnValue( + Promise.resolve(getMetadataTreeResponse) + ); + vi.mocked(resourceStore.getFileTree).mockReturnValue( + Promise.resolve(getFileTreeResponse) + ); + vi.mocked(resourceStore.getVocabularyInstances).mockReturnValue( + Promise.resolve({ en: [], de: [] }) + ); + beforeEach(() => { + // shallowMount does not work here! + wrapper = mount(ResourcePage as unknown as typeof Vue, { + pinia: testingPinia, + router, + i18n, + localVue, + }) as Wrapper<ResourcePageComponent>; + }); + + test( + "Should render form fields and file entries and reflect metadata upon selection", + async () => { await wrapper.vm.$nextTick(); // Wait for 1 second until everything is set up - await sleep(1000); + await sleep(1000); // Don't remove! // Form-Generator rendered - let textFields = wrapper.findAllComponents({ name: "InputTextField" }); - expect(textFields.length).toBeGreaterThanOrEqual(4); + // The test resource uses the Base Application profile, + // which has has 5 fields (2x text, 1x date, 2x combo box) + let wrapperInputFields = wrapper.findAllComponents({ + name: "WrapperInput", + }); + expect(wrapperInputFields.length).toBe(5); - // Entries in table rendered - const tableFields = wrapper.findAll(".dataSourceItem"); - expect(tableFields.length).toBeGreaterThanOrEqual(6); + // File-View rendered with as many entries as the test resource has files/folders + const tableFields = wrapper.findAll(".fileViewEntry"); + expect(tableFields.length).toBeGreaterThanOrEqual(3); - // Find first entry checkbox + // Find the first entry in the table and select it const firstFound = wrapper.find(".tableCheck .custom-control-input"); await firstFound.setChecked(true); // Wait for 1 second until everything is updated - await sleep(1000); + await sleep(1000); // Don't remove! // After selecting an entry in the table, its metadata should be rendered in the metadata manager - textFields = wrapper.findAllComponents({ name: "InputTextField" }); - const textInput = textFields.wrappers[1]; - expect((textInput.element as HTMLInputElement).value).toBe("Test"); + wrapperInputFields = wrapper.findAllComponents({ + name: "InputTextField", + }); + const textInput = wrapperInputFields.wrappers[0]; // For the used AP, the first InputTextField is "Title" + expect((textInput.element as HTMLInputElement).value).toBe( + "Title inside Form Generator" + ); }, { + // Override the maximum run time for this test (10 sec), due to the sleep() calls timeout: 10000, } ); diff --git a/src/modules/resource/pages/ResourcePage.vue b/src/modules/resource/pages/ResourcePage.vue index c894c2999ffcddfa54715042bac1460c77f7ac36..327c5ae62de21e7ee723ba5422cb9c1ca6a98b0d 100644 --- a/src/modules/resource/pages/ResourcePage.vue +++ b/src/modules/resource/pages/ResourcePage.vue @@ -7,33 +7,46 @@ @dragover.prevent="" @drop.prevent="uploadDrop" > + <!-- Drop File to Upload --> <div v-if="showDroppable && fileAddable" class="droppable"> - <p class="droppableText">{{ $t("page.resource.canDropFile") }}</p> + <p class="droppableText"> + {{ $t("page.resource.canDropFile") }} + </p> </div> + + <!-- Headline --> <coscine-headline - v-show="!isFullscreen" + v-show="!isFullscreenActive" :headline="$t('page.resource.resources')" /> + + <!-- Form File Window --> <b-form-file ref="fileTrigger" multiple class="mt-3" plain @input="fileListUploadSelected" - ></b-form-file> + /> + + <!-- Files View Column --> <span id="filesViewSpan"> - <div id="filesViewCard" :class="isFullscreen == false ? 'card' : ''"> - <div :class="isFullscreen == false ? 'card-body' : ''"> + <div + id="filesViewCard" + :class="isFullscreenActive == false ? 'card' : ''" + > + <!-- Files View --> + <div :class="isFullscreenActive == false ? 'card-body' : ''"> <FilesView ref="filesView" :folder-contents="folderContents" - :current-folder="currentFolder" :is-uploading="isUploading" :file-list-edit="fileListEdit" + :dir-trail="dirTrail" + :dir-crumbs="dirCrumbs" @showDetail="setShowDetail" - @currentFolder="setCurrentFolder" - @folderContents="setFolder" - @fileListEdit="setFileListEdit" + @folderContents="updateFolderContent" + @fileListEdit="updateEditFileList" @clickFileSelect="clickFileSelect" @showModalDelete="showModalDelete" @waitingForResponse="setWaitingForResponse" @@ -41,17 +54,25 @@ </div> </div> </span> + + <!-- Metadata Manager Column --> <div id="metadataManagerDiv" - :class="isMetadataManagerHidden == true ? 'hiddenMetadataManager' : ''" + :class="{ hiddenMetadataManager: isMetadataManagerHidden }" > + <!-- Vertical Button Bar for Fullscreen --> <b-button - v-show="isFullscreen" + v-show="isFullscreenActive" id="metadataManagerToggleFullscreen" squared - @click="toggleMenu()" - ><span>{{ $t("page.resource.metadataManager") }}</span></b-button + @click="toggleMetadataPanel()" > + <span> + {{ $t("page.resource.metadataManager") }} + </span> + </b-button> + + <!-- Metadata Manager --> <div class="card"> <div class="card-body"> <MetadataManager @@ -59,44 +80,37 @@ :file-list-edit="fileListEdit" :file-list-upload="fileListUpload" :folder-contents="folderContents" - :current-folder="currentFolder" + :dir-trail="dirTrail" + :dir-crumbs="dirCrumbs" :is-uploading="isUploading" @emptyFileLists="emptyFileLists" - @folderContents="setFolder" - @removeElement="removeElement" + @navigateTree="navigateTreeInFilesView" + @removeElement="deleteFromUploadList" @removeSelection="removeSelection" @isUploading="setIsUploading" @showModalDelete="showModalDelete" @clickFileSelect="clickFileSelect" /> + + <!-- Toggle Fullscreen Button --> <div id="toggleFullscreenButton" - :class=" - isMetadataManagerHidden == true ? '' : 'hiddenMetadataManager' - " + :class="{ hiddenMetadataManager: !isMetadataManagerHidden }" > - <button - v-if="isFullscreen" - class="btn btn-secondary" - type="button" - @click="toggleFullscreen" - > - <b-icon icon="fullscreen-exit" /> - </button> - <button - v-else - type="button" - class="btn btn-secondary" - @click="toggleFullscreen" - > - <b-icon icon="fullscreen" /> + <button class="btn btn-secondary" @click="toggleViewMode"> + <b-icon + :icon="isFullscreenActive ? 'fullscreen-exit' : 'fullscreen'" + /> </button> </div> </div> </div> </div> + + <!-- Page Loading Spinner --> <LoadingSpinner :is-waiting-for-response="isWaitingForResponse" /> - <!-- Delete RCV File Modal --> + + <!-- Delete File Modal --> <DeleteFolderContentsModal :visible="deleteModalVisible" :shown-files="filesToBeDeleted" @@ -109,33 +123,25 @@ <script lang="ts"> import { defineComponent } from "vue"; import type Vue from "vue"; - // import the store for current module import useResourceStore from "../store"; // import the main store import useMainStore from "@/store/index"; import useProjectStore from "@/modules/project/store"; - import FilesView from "../components/resource-page/FilesView.vue"; import MetadataManager from "../components/resource-page/MetadataManager.vue"; - import DeleteFolderContentsModal from "../components/resource-page/modals/DeleteFolderContentsModal.vue"; - -import type { - FileInformation, - FolderContent, - FolderInformation, -} from "../utils/EntryDefinition"; - import { v4 as uuidv4 } from "uuid"; import factory from "rdf-ext"; - import type { BFormFile, BTable } from "bootstrap-vue"; import type { Dataset } from "@rdfjs/types"; import type { + ProjectDto, ResourceDto, ResourceTypeInformationDto, + TreeDataType, } from "@coscine/api-client/dist/types/Coscine.Api"; +import type { FolderContent, FileInformation } from "../types"; export default defineComponent({ components: { @@ -143,10 +149,12 @@ export default defineComponent({ MetadataManager, DeleteFolderContentsModal, }, + beforeRouteLeave(to, from, next) { - this.setFullscreen(false); + this.updateViewMode(false); next(); }, + setup() { const mainStore = useMainStore(); const projectStore = useProjectStore(); @@ -158,18 +166,14 @@ export default defineComponent({ data() { return { isWaitingForResponse: false, - fileListEdit: [] as FolderContent[], showDetail: false, + fileListEdit: [] as FolderContent[], fileListUpload: [] as FileInformation[], - folderContents: [] as FolderContent[], filesToBeDeleted: [] as FolderContent[], - currentFolder: - window.location.hash.indexOf("#") !== -1 - ? window.location.hash.substring(1) - : "/", + folderContents: [] as FolderContent[], dragCounter: 0, - isFullscreen: false, + isFullscreenActive: false, isMetadataManagerHidden: false, isUploading: false, @@ -181,6 +185,9 @@ export default defineComponent({ resource(): null | ResourceDto { return this.resourceStore.currentResource; }, + project(): null | ProjectDto { + return this.projectStore.currentProject; + }, resourceTypeInformation(): ResourceTypeInformationDto | undefined { return this.resourceStore.enabledResourceTypes?.find( (resourceType) => resourceType.id === this.resource?.type?.id @@ -200,7 +207,22 @@ export default defineComponent({ isGuest(): boolean | undefined { return this.projectStore.currentUserRoleIsGuest; }, + dirTrail(): string { + return this.$route.params.dirTrail ?? ""; + }, + dirCrumbs(): string[] { + const trail = this.dirTrail; + const pathArray = trail + .substring(0, trail.lastIndexOf("/") + 1) + .split("/") + .filter((n) => n); + + return pathArray.map((f) => { + return f ? `${f}/` : ""; + }); + }, }, + watch: { fileListEdit() { if (this.fileListEdit.length > 0) { @@ -211,23 +233,30 @@ export default defineComponent({ this.emptyFileLists(); }, }, + mounted() { this.$nextTick(() => { - window.addEventListener("resize", this.getWindowWidth); + window.addEventListener("resize", this.adjustViewForScreenWidth); - this.getWindowWidth(); + this.adjustViewForScreenWidth(); }); this.emptyFileLists(); }, + methods: { + /** + * Trigger file selection, if not editable data URL is provided. + */ clickFileSelect() { this.showDetail = false; + // Check if editableDataUrl is present if ( this.resourceTypeInformation?.resourceContent?.metadataView ?.editableDataUrl ) { this.emptyFileLists(); } else { + // Trigger a click event on fileTrigger element (this.$refs.fileTrigger as BFormFile).$el.dispatchEvent( new MouseEvent("click", { view: window, @@ -237,37 +266,75 @@ export default defineComponent({ ); } }, + + /** + * Display the modal for file deletion and set the files to be deleted. + * @param {FolderContent[]} files - Files to be deleted. + */ showModalDelete(files: FolderContent[]) { - //open modal + // Open modal this.deleteModalVisible = true; - //pass files from emit + // Pass files from emit this.filesToBeDeleted = files; }, + + /** + * Close the delete modal and reset the filesToBeDeleted list. + */ + closeModalDeleteFile() { + this.deleteModalVisible = false; + this.filesToBeDeleted = []; + }, + + /** + * Trigger a navigation in the child component to update the view. + */ + async navigateTreeInFilesView() { + await ( + this.$refs.filesView as unknown as typeof FilesView + ).navigateTree(); + }, + + /** + * Delete selected files. + */ async deleteFiles() { - if (this.resource?.id) { + if (this.resource?.id && this.project?.id) { for (const fileToDelete of this.filesToBeDeleted) { this.$emit("waitingForResponse", true); - await this.resourceStore.deleteFile( + + await this.resourceStore.deleteBlob( + this.project.id, this.resource.id, - fileToDelete.absolutePath + fileToDelete.path ); - this.$emit("waitingForResponse", false); } - location.reload(); + this.closeModalDeleteFile(); + // Trigger a navigation in the child component to update the view after deletion + await this.navigateTreeInFilesView(); + this.$emit("waitingForResponse", false); } }, - closeModalDeleteFile() { - this.deleteModalVisible = false; - this.filesToBeDeleted = []; - }, + /** + * Handle drag enter event. Increase drag counter on dragEnter. + */ dragEnter() { this.dragCounter++; }, + + /** + * Handle drag leave event. Decrease drag counter on dragLeave. + */ dragLeave() { this.dragCounter--; }, - emptyFileLists(emptyUpload = true) { + + /** + * Clear the file lists. + * @param {boolean} emptyUpload - Indicates whether to clear upload list as well. + */ + emptyFileLists(emptyUpload: boolean = true) { this.removeSelection(0, this.fileListEdit.length); this.fileListEdit.length = 0; if (emptyUpload) { @@ -276,31 +343,43 @@ export default defineComponent({ this.showDetail = false; this.initializeForResourceType(); }, + + /** + * Add selected files to the fileListUpload list. + * @param {File[] | File} selectedFiles - Selected files. + */ fileListUploadSelected(selectedFiles: File[] | File) { if (!Array.isArray(selectedFiles)) { selectedFiles = [selectedFiles]; } this.emptyFileLists(false); + + // Pushing selected files into the upload list for (const file of selectedFiles) { this.fileListUpload.push({ id: uuidv4(), - path: this.currentFolder, + path: this.dirTrail + file.name, + parentDirectory: this.dirTrail, + type: file.type, version: `${+new Date()}`, uploading: false, info: file, name: file.name, size: file.size, - lastModified: "" + file.lastModified, + lastModified: `${file.lastModified}`, metadata: factory.dataset() as unknown as Dataset, - isFolder: false, - absolutePath: this.currentFolder + file.name, - }); + } as FileInformation); } + // Clear selection in files view table ( (this.$refs.filesView as Vue).$refs.adaptTable as BTable ).clearSelected(); this.showDetail = false; }, + + /** + * Initialize the file based on resource type. + */ initializeForResourceType() { if ( this.resourceTypeInformation?.resourceContent?.metadataView @@ -313,16 +392,24 @@ export default defineComponent({ uploading: false, name: "", isFolder: false, - absolutePath: this.currentFolder, + parentDirectory: this.dirTrail, + type: "Leaf" as TreeDataType.Leaf, size: 0, metadata: factory.dataset() as unknown as Dataset, dataUrl: "", - }); + } as FileInformation); } }, + + /** + * Handle file drop action for upload. + * @param {DragEvent} ev - Drag event. + */ uploadDrop(ev: DragEvent) { if (this.fileAddable) { this.dragCounter = 0; + + // Handling file drops if (ev?.dataTransfer?.items) { for (const item of ev.dataTransfer.items) { if (item.kind === "file") { @@ -335,33 +422,75 @@ export default defineComponent({ } } }, - setShowDetail(newShowDetail: boolean) { - this.showDetail = newShowDetail; - }, - setWaitingForResponse(newIsWaitingForResponse: boolean) { - this.isWaitingForResponse = newIsWaitingForResponse; + + /** + * Update the detail visibility. + * + * @param {boolean} visibility - New visibility state. + */ + setShowDetail(visibility: boolean) { + this.showDetail = visibility; }, - setIsUploading(newIsUploading: boolean) { - this.isUploading = newIsUploading; + + /** + * Update the state indicating if the system is waiting for a response. + * + * @param {boolean} waiting - Whether the system is waiting for a response or not. + */ + setWaitingForResponse(waiting: boolean) { + this.isWaitingForResponse = waiting; }, - setCurrentFolder(newCurrentFolder: string) { - this.currentFolder = newCurrentFolder; + + /** + * Update the uploading state. + * + * @param {boolean} uploading - Indicates if a file is currently uploading. + */ + setIsUploading(uploading: boolean) { + this.isUploading = uploading; }, - setFolder(newFolder: FolderInformation[]) { - this.folderContents = newFolder; + + /** + * Updates the current folder's content. + * + * @param {FolderContent[]} folderContent - Information about the items in the folder. + */ + updateFolderContent(folderContent: FolderContent[]) { + this.folderContents = folderContent; }, - setFileListEdit(newFileListEdit: FileInformation[]) { - this.fileListEdit = newFileListEdit; + + /** + * Updates the list of files being edited. + * + * @param {FolderContent[]} fileList - New list of files for editing. + */ + updateEditFileList(fileList: FolderContent[]) { + this.fileListEdit = fileList; }, - removeElement(index: number, count: number) { - this.fileListUpload.splice(index, count); + + /** + * Deletes files from the upload list. + * + * @param {number} startIndex - Where to start the deletion in the list. + * @param {number} numberOfFiles - How many files to delete from the list. + */ + deleteFromUploadList(startIndex: number, numberOfFiles: number) { + this.fileListUpload.splice(startIndex, numberOfFiles); }, + + /** + * Remove selected items from the list. + * @param {number} index - Start index. + * @param {number} count - Number of items to remove. + */ removeSelection(index: number, count: number) { const selectionRemovable: FolderContent[] = this.fileListEdit.splice( index, count ); const table = (this.$refs.filesView as Vue).$refs.adaptTable as BTable; + + // Unselecting rows in table for (let i = 0; i < table.items.length; i++) { const item = (table.items as FolderContent[])[i]; if ( @@ -372,28 +501,43 @@ export default defineComponent({ } } }, - setFullscreen(newIsFullscreen: boolean) { - this.isFullscreen = newIsFullscreen; - if (newIsFullscreen) { - document.body.classList.add("fullscreen"); + + /** + * Update the view mode based on the given fullscreen state. + * + * @param {boolean} activateFullscreen - Whether to activate the fullscreen mode. + */ + updateViewMode(activateFullscreen: boolean) { + this.isFullscreenActive = activateFullscreen; + + if (activateFullscreen) { + document.body.classList.add("fullscreen-mode"); this.mainStore.sidebarActive = false; } else { - document.body.classList.remove("fullscreen"); - this.isMetadataManagerHidden = false; + document.body.classList.remove("fullscreen-mode"); + this.isMetadataManagerHidden = true; } }, - toggleFullscreen() { - this.setFullscreen(!this.isFullscreen); + + /** + * Toggle between fullscreen and regular mode. + */ + toggleViewMode() { + this.updateViewMode(!this.isFullscreenActive); }, - toggleMenu() { + + /** + * Toggle the visibility of the metadata panel. + */ + toggleMetadataPanel() { this.isMetadataManagerHidden = !this.isMetadataManagerHidden; }, - getWindowWidth() { - if (document.documentElement.clientWidth < 1250) { - this.setFullscreen(true); - } else { - this.setFullscreen(false); - } + + /** + * Adjust view mode based on window width. + */ + adjustViewForScreenWidth() { + this.updateViewMode(document.documentElement.clientWidth < 1250); }, }, }); diff --git a/src/modules/resource/routes.ts b/src/modules/resource/routes.ts index 6d776872c7635efa64e33e9ad67ad4bff113a3ef..55772fb005b81cc25a22556a838a9a9e43dc5d16 100644 --- a/src/modules/resource/routes.ts +++ b/src/modules/resource/routes.ts @@ -41,10 +41,27 @@ export const ResourceRoutes: RouteConfig[] = [ children: [ { path: "/", + redirect: (to) => { + return { + name: "resource-page", + params: { + slug: to.params.slug, + guid: to.params.guid, + dirTrail: "", + }, + meta: { + breadCrumb: "resource.page", + requiresAuth: true, + }, + }; + }, + }, + { + path: "-/:dirTrail(.*)*", name: "resource-page", + pathToRegexpOptions: { strict: true, sensitive: true }, component: ResourcePage, meta: { - breadCrumb: "resource.page", requiresAuth: true, }, }, diff --git a/src/modules/resource/store.ts b/src/modules/resource/store.ts index 4f1ef88aca75f05e65761f38a7b4cdbc3b42c1e6..2e8398d8ad83708d7b557186f5ec20187df13542 100644 --- a/src/modules/resource/store.ts +++ b/src/modules/resource/store.ts @@ -19,13 +19,9 @@ import { VocabularyApi, } from "@coscine/api-client"; import type { Route } from "vue-router/types/router"; -import type { AxiosError } from "axios"; +import axios, { AxiosError } from "axios"; import useNotificationStore from "@/store/notification"; -import { - parseRDFDefinition, - resolveImports, - serializeRDFDefinition, -} from "./utils/linkedData"; +import { parseRDFDefinition, resolveImports } from "./utils/linkedData"; import factory from "rdf-ext"; import { useLocalStorage } from "@vueuse/core"; import type { @@ -33,12 +29,16 @@ import type { ApplicationProfileDto, GitlabBranchDto, GitlabProjectDto, + MetadataTreeForCreationDto, + MetadataTreeForUpdateDto, ProjectDto, ResourceDto, ResourceForCreationDto, ResourceForUpdateDto, ResourceQuotaDto, AcceptedLanguage, + FileDto, + MetadataDto, } from "@coscine/api-client/dist/types/Coscine.Api/api"; import { wrapListRequest } from "@/util/wrapListRequest"; /* @@ -159,10 +159,14 @@ export const useResourceStore = defineStore({ "JsonLd" as RdfFormat ); const returnedData = apiResponse.data.data; - if (returnedData?.definition && returnedData?.baseUri) { + if ( + returnedData?.definition && + returnedData?.format && + returnedData?.baseUri + ) { resource.rawApplicationProfile = await parseRDFDefinition( returnedData.definition, - "application/ld+json", + returnedData.format, returnedData.baseUri ); resource.fullApplicationProfile = await resolveImports( @@ -192,10 +196,14 @@ export const useResourceStore = defineStore({ "JsonLd" as RdfFormat ); const returnedData = apiResponse.data.data; - if (returnedData?.definition && returnedData?.baseUri) { + if ( + returnedData?.definition && + returnedData?.format && + returnedData?.baseUri + ) { let returnApplicationProfile = await parseRDFDefinition( returnedData.definition, - "application/ld+json", + returnedData.format, returnedData.baseUri ); if (doResolveImports) { @@ -409,18 +417,6 @@ export const useResourceStore = defineStore({ } }, - async deleteFile(resourceId: string, absoluteFilePath: string) { - const notificationStore = useNotificationStore(); - try { - await BlobApi.blobDeleteFileWithParameter(resourceId, absoluteFilePath); - return true; - } catch (error) { - // Handle other Status Codes - notificationStore.postApiErrorNotification(error as AxiosError); - return false; - } - }, - async getVocabularyInstances(className: string): Promise<BilingualLabels> { const notificationStore = useNotificationStore(); try { @@ -431,7 +427,7 @@ export const useResourceStore = defineStore({ undefined, "en" as AcceptedLanguage.En, pageNumber, - 50 + 150 ) ); const deInstances = await wrapListRequest((pageNumber: number) => @@ -440,7 +436,7 @@ export const useResourceStore = defineStore({ undefined, "de" as AcceptedLanguage.De, pageNumber, - 50 + 150 ) ); @@ -448,13 +444,13 @@ export const useResourceStore = defineStore({ de: deInstances.map((instance) => { return { name: instance.displayName, - value: instance.graphUri, + value: instance.instanceUri, }; }), en: enInstances.map((instance) => { return { name: instance.displayName, - value: instance.graphUri, + value: instance.instanceUri, }; }), }; @@ -469,23 +465,206 @@ export const useResourceStore = defineStore({ } }, - async getFile( + async createMetadataTree( + projectId: string, + resourceId: string, + metadataTreeForCreationDto: MetadataTreeForCreationDto + ): Promise<boolean> { + const notificationStore = useNotificationStore(); + try { + await TreeApi.createMetadataTree( + projectId, + resourceId, + metadataTreeForCreationDto + ); + return true; + } catch (error) { + // Handle other Status Codes + notificationStore.postApiErrorNotification(error as AxiosError); + return false; + } + }, + + async updateMetadataTree( + projectId: string, + resourceId: string, + metadataTreeForUpdateDto: MetadataTreeForUpdateDto + ): Promise<boolean> { + const notificationStore = useNotificationStore(); + try { + await TreeApi.updateMetadataTree( + projectId, + resourceId, + metadataTreeForUpdateDto + ); + return true; + } catch (error) { + // Handle other Status Codes + notificationStore.postApiErrorNotification(error as AxiosError); + return false; + } + }, + + /** + * Attempts to add a new metadata tree. If a conflict (409) error occurs, it tries to update the existing metadata tree. + * + * @param {string} projectId - The ID of the project. + * @param {string} resourceId - The ID of the resource. + * @param {MetadataTreeForCreationDto | MetadataTreeForUpdateDto} metadataTreeDto - The data transfer object containing metadata tree details. Can be for creation or update. + * @returns {Promise<boolean>} Returns true if the operation is successful, false otherwise. + * @throws {AxiosError} Throws an AxiosError if the API call fails. + */ + async addOrUpdateMetadataTree( + projectId: string, + resourceId: string, + metadataTreeDto: MetadataTreeForCreationDto | MetadataTreeForUpdateDto + ): Promise<boolean> { + const notificationStore = useNotificationStore(); + + try { + // Attempt to create metadata tree + await TreeApi.createMetadataTree( + projectId, + resourceId, + metadataTreeDto + ); + return true; + } catch (error: unknown) { + if (axios.isAxiosError(error)) { + if (error.response?.status === 409) { + // Metadata tree already exists, attempt to update + try { + await TreeApi.updateMetadataTree( + projectId, + resourceId, + metadataTreeDto // NOTE: Potential issue here, as the DTO might not be the same for creation and update if signature changes + ); + return true; + } catch (updateError) { + // Handle other Status Codes + notificationStore.postApiErrorNotification( + updateError as AxiosError + ); + return false; + } + } + } + } + // Handle notification externally + return false; + }, + + /** + * Attempts to update a new metadata tree. If a not found (404) error occurs, it tries to create a new metadata tree. + * + * @param {string} projectId - The ID of the project. + * @param {string} resourceId - The ID of the resource. + * @param {MetadataTreeForCreationDto | MetadataTreeForUpdateDto} metadataTreeDto - The data transfer object containing metadata tree details. Can be for creation or update. + * @returns {Promise<boolean>} Returns true if the operation is successful, false otherwise. + * @throws {AxiosError} Throws an AxiosError if the API call fails. + */ + async updateOrAddMetadataTree( + projectId: string, + resourceId: string, + metadataTreeDto: MetadataTreeForCreationDto | MetadataTreeForUpdateDto + ): Promise<boolean> { + const notificationStore = useNotificationStore(); + + try { + // Attempt to update metadata tree + await TreeApi.updateMetadataTree( + projectId, + resourceId, + metadataTreeDto // NOTE: Potential issue here, as the DTO might not be the same for creation and update if signature changes + ); + return true; + } catch (error: unknown) { + if (axios.isAxiosError(error)) { + if (error.response?.status === 404) { + // Metadata tree already exists, attempt to update + try { + await TreeApi.createMetadataTree( + projectId, + resourceId, + metadataTreeDto // NOTE: Potential issue here, as the DTO might not be the same for creation and update if signature changes + ); + return true; + } catch (updateError) { + // Handle other Status Codes + notificationStore.postApiErrorNotification( + updateError as AxiosError + ); + return false; + } + } + } + } + // Handle notification externally + return false; + }, + + async getMetadataTree( + projectId: string, + resourceId: string, + filePath: string, + format?: RdfFormat + ): Promise<MetadataDto[] | null | undefined> { + const notificationStore = useNotificationStore(); + try { + const response = await TreeApi.getMetadataTree( + projectId, + resourceId, + filePath, + format + ); + return response.data.data; + } catch (error) { + // Handle other Status Codes + notificationStore.postApiErrorNotification(error as AxiosError); + return null; + } + }, + + async getFileTree( + projectId: string, + resourceId: string, + filePath: string + ): Promise<FileDto[] | null | undefined> { + const notificationStore = useNotificationStore(); + try { + const response = await TreeApi.getFileTree( + projectId, + resourceId, + filePath + ); + return response.data.data; + } catch (error) { + // Handle other Status Codes + notificationStore.postApiErrorNotification(error as AxiosError); + return null; + } + }, + + async getBlob( + projectId: string, resourceId: string, absoluteFilePath: string, asBlob = false ) { const notificationStore = useNotificationStore(); try { - const response = await BlobApi.blobGetFileWithParameter( + const response = await BlobApi.getBlob( + projectId, resourceId, absoluteFilePath, + /* the following is an axios option */ asBlob ? { responseType: "blob", } : undefined ); - return response.data; + return response; } catch (error) { // Handle other Status Codes notificationStore.postApiErrorNotification(error as AxiosError); @@ -493,37 +672,47 @@ export const useResourceStore = defineStore({ } }, - async getMetadata(resourceId: string, absoluteFilePath: string) { + async createBlob( + projectId: string, + resourceId: string, + absoluteFilePath: string, + file: Blob, + options?: unknown + ) { const notificationStore = useNotificationStore(); try { - const response = await TreeApi.treeGetMetadataWithParameter( + const response = await BlobApi.createBlob( + projectId, resourceId, absoluteFilePath, - "application/n-triples" + file, + options ); - return response.data; + return response.status >= 200 && response.status < 300; } catch (error) { // Handle other Status Codes notificationStore.postApiErrorNotification(error as AxiosError); - return null; + return false; } }, - async storeFile( + async updateBlob( + projectId: string, resourceId: string, absoluteFilePath: string, - files: Blob[], + file: Blob, options?: unknown ) { const notificationStore = useNotificationStore(); try { - await BlobApi.blobUploadFileWithParameter( + const response = await BlobApi.updateBlob( + projectId, resourceId, absoluteFilePath, - files, + file, options ); - return true; + return response.status >= 200 && response.status < 300; } catch (error) { // Handle other Status Codes notificationStore.postApiErrorNotification(error as AxiosError); @@ -531,19 +720,14 @@ export const useResourceStore = defineStore({ } }, - async storeMetadata( + async deleteBlob( + projectId: string, resourceId: string, - absoluteFilePath: string, - body: Dataset + absoluteFilePath: string ) { const notificationStore = useNotificationStore(); try { - await TreeApi.treeStoreMetadataForFileWithParameter( - resourceId, - absoluteFilePath, - "text/turtle", - { data: { metadata: await serializeRDFDefinition(body) } } - ); + await BlobApi.deleteBlob(projectId, resourceId, absoluteFilePath); return true; } catch (error) { // Handle other Status Codes diff --git a/src/modules/resource/types.ts b/src/modules/resource/types.ts index d783bcb1b3695d6b5a3aede4f5ed2d1c021d5b3c..2d640c054fd518beec87f858edde52e304bac50b 100644 --- a/src/modules/resource/types.ts +++ b/src/modules/resource/types.ts @@ -8,6 +8,7 @@ import type { GitlabProjectDto, ResourceDto, ResourceTypeInformationDto, + TreeDataType, } from "@coscine/api-client/dist/types/Coscine.Api/api"; import type { Dataset } from "@rdfjs/types"; @@ -185,3 +186,38 @@ export interface Label { name?: string | null; value?: string | null; } + +export interface GeneralInformation { + id: string; + name: string; + path: string; + parentDirectory: string; + type: TreeDataType; + isFolder: boolean; + lastModified?: string | null; + readOnly?: boolean; + createdAt?: string; + metadata: Dataset | null; +} + +export interface FileInformation extends GeneralInformation { + type: TreeDataType.Leaf; + isFolder: false; + version: string; + size: number; + dataUrl?: string; + info?: File; + uploading?: boolean; + requesting?: boolean; +} + +export interface FolderInformation extends GeneralInformation { + type: TreeDataType.Tree; + isFolder: true; +} + +export interface ReadOnlyFolderInformation extends FolderInformation { + readOnly: true; +} + +export type FolderContent = FileInformation | FolderInformation; diff --git a/src/modules/resource/utils/EntryDefinition.ts b/src/modules/resource/utils/EntryDefinition.ts deleted file mode 100644 index 34309e24e83c113d219687c1ea6dcd5ef712461e..0000000000000000000000000000000000000000 --- a/src/modules/resource/utils/EntryDefinition.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { Dataset } from "@rdfjs/types"; - -export interface GeneralInformation { - id: string; - path: string; - absolutePath: string; - name: string; - isFolder: boolean; - lastModified?: string; - readOnly?: boolean; - created?: string; - metadata: Dataset; -} - -export interface FileInformation extends GeneralInformation { - isFolder: false; - version: string; - size: number; - dataUrl?: string; - info?: File; - uploading?: boolean; - requesting?: boolean; -} - -export interface FolderInformation extends GeneralInformation { - isFolder: true; -} - -export interface ReadOnlyFolderInformation extends FolderInformation { - readOnly: true; -} - -export type FolderContent = FileInformation | FolderInformation; diff --git a/src/modules/resource/utils/MetadataManagerUtil.ts b/src/modules/resource/utils/MetadataManagerUtil.ts index d935173a6ca547ae33317628400fe9cdba5b006b..c49ef45e1c2a5c8eb2f4cc6345a1e6b067c94176 100644 --- a/src/modules/resource/utils/MetadataManagerUtil.ts +++ b/src/modules/resource/utils/MetadataManagerUtil.ts @@ -1,110 +1,66 @@ import useResourceStore from "../store"; -import type { FolderContent } from "./EntryDefinition"; import type { Dataset } from "@rdfjs/types"; import factory from "rdf-ext"; import { parseRDFDefinition } from "./linkedData"; -import type { Metadata } from "../types"; -import type { ResourceDto } from "@coscine/api-client/dist/types/Coscine.Api"; +import type { FolderContent } from "../types"; +import type { + ProjectDto, + ResourceDto, +} from "@coscine/api-client/dist/types/Coscine.Api"; export default { - filterMetadataStorage( - metadataStorage: Metadata[], - filterPath: string - ): Metadata[] { - return metadataStorage.filter((x) => { - if (Object.keys(x).length > 0) { - const graphName = Object.keys(x)[0]; - const pathQueryString = "path="; - if (graphName.indexOf(pathQueryString) !== -1) { - let path = graphName.substring( - graphName.indexOf(pathQueryString) + pathQueryString.length - ); - if (path.indexOf("&") !== -1) { - path = path.substring(0, path.indexOf("&")); - } - if ( - decodeURIComponent(path) === filterPath || - decodeURIComponent(path) === "/" + filterPath - ) { - return true; - } - } - const typeQueryString = "/@type=metadata"; - if (graphName.indexOf(typeQueryString) !== -1) { - const urlEnd = graphName.substring( - 0, - graphName.indexOf(typeQueryString) - ); - if ( - urlEnd.endsWith(filterPath) || - decodeURIComponent(urlEnd).endsWith(filterPath) - ) { - return true; - } - } - } - return false; - }); - }, - async loadMetadata( + /** + * Loads metadata for a specified file. If no metadata is found, it returns an empty dataset. + * + * @param {FolderContent} fileInfo - Information about the file for which metadata is to be loaded. + * @param {ProjectDto | null} project - Parent project of the file. + * @param {ResourceDto | null} resource - Parent resource of the file. + * @returns {Promise<Dataset>} A promise that resolves to the metadata for the file or an empty dataset if none is found. + */ + async loadMetadataForFile( fileInfo: FolderContent, + project: ProjectDto | null, resource: ResourceDto | null - ): Promise<Dataset> { - if ( - fileInfo !== undefined && - fileInfo.absolutePath !== undefined && - resource?.id - ) { - if (fileInfo.metadata) { - if (fileInfo.metadata.size > 0) { - return fileInfo.metadata; - } - } - - const response = await useResourceStore().getMetadata( - resource.id, - fileInfo.absolutePath - ); + ): Promise<Dataset | null> { + // Return an empty dataset if no file info is available + if (!fileInfo?.path || !project?.id || !resource?.id) { + return factory.dataset() as unknown as Dataset; + } - const metadataStorage = response.data.metadataStorage; + // Return existing metadata if available + if (fileInfo.metadata && fileInfo.metadata.size > 0) { + return fileInfo.metadata; + } - if (metadataStorage.length === 0) { - return factory.dataset() as unknown as Dataset; - } + // Fetch metadata from the API + const metadataTree = await useResourceStore().getMetadataTree( + project.id, + resource.id, + fileInfo.path + ); - const resultArray = this.filterMetadataStorage( - metadataStorage, - fileInfo.absolutePath + // If the metadata exists and has a definition, parse it + if (metadataTree?.[0]?.definition && metadataTree[0].format) { + return await parseRDFDefinition( + metadataTree[0].definition, + metadataTree[0].format ); - - if (resultArray.length === 0) { - return factory.dataset() as unknown as Dataset; - } - - const result = resultArray[0]; - - const objectKeys = Object.keys(result); - if ( - response.data.metadataStorage !== undefined && - objectKeys.length === 1 - ) { - const entry = result[objectKeys[0]]; - if (typeof entry === "string") { - return await parseRDFDefinition(entry, "application/n-triples"); - } - } else if ( - response.data.metadataStorage !== undefined && - objectKeys.length > 1 - ) { - const entry = result; - if (typeof entry === "string") { - return await parseRDFDefinition(entry, "application/n-triples"); - } - } } + + // Return an empty dataset if no metadata was found return factory.dataset() as unknown as Dataset; }, - copyMetadata(source: Dataset | undefined, target: FolderContent | undefined) { + + /** + * Copies metadata from the source to the target. + * + * @param {Dataset | null | undefined} source - The source dataset from which to copy metadata. + * @param {FolderContent | undefined} target - The target where the metadata should be copied to. + */ + copyMetadata( + source: Dataset | null | undefined, + target: FolderContent | undefined + ) { if (source && target) { target.metadata = factory.dataset( Array.from(source) diff --git a/src/modules/resource/utils/linkedData.ts b/src/modules/resource/utils/linkedData.ts index 8da4b03e6809814dad6c398b16dd6814ea981f45..c0632b8abfb61ecb0871340f3e31aa496472d81b 100644 --- a/src/modules/resource/utils/linkedData.ts +++ b/src/modules/resource/utils/linkedData.ts @@ -30,18 +30,25 @@ export async function parseRDFDefinition( return dataset as unknown as Dataset; } +/** + * Serializes an RDF dataset into a specified format. + * + * @param {Dataset | null} dataset - The RDF dataset to be serialized. + * @param {string} [contentType="text/turtle"] - The MIME type for the serialization format. + * @returns {Promise<string>} A promise that resolves with the serialized dataset as a string. + * + * @example + * const turtleStr = await serializeRDFDefinition(myDataset, "text/turtle"); + */ export async function serializeRDFDefinition( - dataset: Dataset, - contentType = "text/turtle" + dataset: Dataset | null, + contentType: string = "text/turtle" ): Promise<string> { - const canonical = dataset.toCanonical(); - if (!canonical) { - return ""; - } + if (!dataset || !dataset.toCanonical()) return ""; + const output = formats.serializers.import(contentType, dataset.toStream()); - if (output === null) { - return ""; - } + if (!output) return ""; + return await stringifyStream(output as NodeJS.ReadableStream); } @@ -80,10 +87,10 @@ export async function resolveImports( "JsonLd" as RdfFormat ); const apResponse = importedApiResponse.data.data; - if (apResponse?.definition) { + if (apResponse?.definition && apResponse?.format) { const importedApplicationProfile = await parseRDFDefinition( apResponse.definition, - "application/ld+json", + apResponse.format, importedAP.value ); fullApplicationProfile = ( diff --git a/src/modules/search/pages/components/MetadataResult.vue b/src/modules/search/pages/components/MetadataResult.vue index 318a75e2cb0d8e32d812705a1d7ed5d4bc069969..8b442b9a31a8fd310c1be65fe11737b0fe394e05 100644 --- a/src/modules/search/pages/components/MetadataResult.vue +++ b/src/modules/search/pages/components/MetadataResult.vue @@ -73,8 +73,8 @@ v-html=" $options.filters ? $options.filters.highlight( - resourceType.displayName - ? resourceTypeName(resourceType.displayName) + resourceType.specificType + ? resourceTypeName(resourceType.specificType) : null, query ) @@ -113,8 +113,10 @@ import { defineComponent, type PropType } from "vue"; import useResourceStore from "@/modules/resource/store"; import type { ItemSearchResult } from "@coscine/api-client/dist/types/Coscine.Api.Search"; -import type { ResourceTypeObject } from "@coscine/api-client/dist/types/Coscine.Api.Resources"; -import type { ProjectDto } from "@coscine/api-client/dist/types/Coscine.Api/api"; +import type { + ProjectDto, + ResourceTypeInformationDto, +} from "@coscine/api-client/dist/types/Coscine.Api/api"; export default defineComponent({ name: "MetadataResult", @@ -208,7 +210,7 @@ export default defineComponent({ } return null; }, - resourceType(): ResourceTypeObject | undefined | null { + resourceType(): ResourceTypeInformationDto | undefined | null { if ( this.resourceTypes && this.result.type === "Resource" && @@ -223,7 +225,7 @@ export default defineComponent({ } return null; }, - resourceTypes(): ResourceTypeObject[] | null { + resourceTypes(): ResourceTypeInformationDto[] | undefined | null { return this.resourceStore.enabledResourceTypes; }, fields(): Record<string, string> { @@ -285,6 +287,7 @@ export default defineComponent({ params: { guid: this.resourceId, slug: this.project.slug, + dirTrail: "", }, }).href; } else if ( @@ -298,6 +301,7 @@ export default defineComponent({ params: { guid: this.resourceId, slug: this.project.slug, + dirTrail: "", }, }).href; } else { diff --git a/src/router/index.ts b/src/router/index.ts index 78d6cd9396904e41e607a49a1a530cda2786b235..0802fb472e2f82d4bf0f5d976be82bda72789992 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -42,7 +42,12 @@ VueRouter.prototype.push = async function (location: RawLocation) { ); } catch (err) { if (err instanceof Error) { - if (err.name !== "NavigationDuplicated") { + if ( + // Ignore errors from navigating to the same route + err.name !== "NavigationDuplicated" && + // Ignore errors from redirecting enforced by a navigation guard + !err.message.includes("via a navigation guard") + ) { throw err; } } @@ -115,6 +120,11 @@ router.beforeEach((to, _, next) => { else if (to.name === "login" && loginStore.isLoggedIn) { next({ name: "home" }); } + // Navigation Guard - Set directory trail for resource page when no dirTrail is provided + else if (to.name === "resource-page" && to.params.dirTrail === undefined) { + to.params.dirTrail = ""; + next({ name: "resource-page", params: { ...to.params } }); + } // Continue navigation else { next(); diff --git a/vite.config.js b/vite.config.js index e637d1fe22f67ac0ddbcc414a2ad032352d05ed9..e45ad2fbb79162c1f6a5b8429ea0b15a3656abdc 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,6 +1,7 @@ import path from "node:path"; import { defineConfig } from "vite"; import vue from "@vitejs/plugin-vue2"; +import pluginRewriteAll from 'vite-plugin-rewrite-all'; import { nodePolyfills } from 'vite-plugin-node-polyfills'; @@ -39,6 +40,9 @@ const config = defineConfig({ plugins: [ nodePolyfills(), vue(), + // This plugin fixes a vite bug, that breaks SPA-fallback with a dot (.) in the URL (e.g. /file_0.txt). + // TODO: Check if the issue is resolved after upgrading to vite 5.0.0 + pluginRewriteAll(), WindiCSS(), Components({ dts: 'src/components.d.ts', @@ -46,6 +50,8 @@ const config = defineConfig({ exclude: [/[\\/]node_modules[\\/]/, /[\\/]\.git[\\/]/, /[\\/]\.nuxt[\\/]/], }), ], + + appType: "spa", server: { host: true, diff --git a/yarn.lock b/yarn.lock index cb6a1e3c4fb57d507ded47ef4a242ea057196ef1..f5c5881e591fe78d8dd258d833e461a49fe18c6d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -428,12 +428,12 @@ __metadata: languageName: node linkType: hard -"@coscine/api-client@npm:^3.0.0": - version: 3.0.0 - resolution: "@coscine/api-client@npm:3.0.0" +"@coscine/api-client@npm:^3.1.0": + version: 3.1.0 + resolution: "@coscine/api-client@npm:3.1.0" dependencies: axios: ^0.21.1 - checksum: b4722c0428ca3851f47f21bd606617d8386efc1c547ab68d55cbd8038496182684465b2fa6f27e37e9bc780160206eb3d5a9ecc26790bc4bf9df2d8c1e5304e6 + checksum: 71892411a48930421486b299915a0bd7b3b3e6209953f2304cc22ab2cc3705b56d4c702b8ea4d6e7dd438cd67fa348bb86219df5e7b128ab0ae4e356501e6e82 languageName: node linkType: hard @@ -4243,6 +4243,13 @@ __metadata: languageName: node linkType: hard +"connect-history-api-fallback@npm:^1.6.0": + version: 1.6.0 + resolution: "connect-history-api-fallback@npm:1.6.0" + checksum: 804ca2be28c999032ecd37a9f71405e5d7b7a4b3defcebbe41077bb8c5a0a150d7b59f51dcc33b2de30bc7e217a31d10f8cfad27e8e74c2fc7655eeba82d6e7e + languageName: node + linkType: hard + "consola@npm:^2.15.0": version: 2.15.3 resolution: "consola@npm:2.15.3" @@ -11895,7 +11902,7 @@ __metadata: version: 0.0.0-use.local resolution: "ui@workspace:." dependencies: - "@coscine/api-client": ^3.0.0 + "@coscine/api-client": ^3.1.0 "@coscine/form-generator": ^3.2.2 "@dynamic-mapper/mapper": ^1.10.2 "@pinia/testing": ^0.1.2 @@ -11965,6 +11972,7 @@ __metadata: vite: ^4.3.9 vite-aliases: ^0.11.2 vite-plugin-node-polyfills: ^0.9.0 + vite-plugin-rewrite-all: ^1.0.1 vite-plugin-windicss: ^1.9.0 vitest: ^0.32.2 vue: ^2.7.14 @@ -12263,6 +12271,17 @@ __metadata: languageName: node linkType: hard +"vite-plugin-rewrite-all@npm:^1.0.1": + version: 1.0.1 + resolution: "vite-plugin-rewrite-all@npm:1.0.1" + dependencies: + connect-history-api-fallback: ^1.6.0 + peerDependencies: + vite: ^2.0.0 || ^3.0.0 || ^4.0.0 + checksum: 9a22e51e80fc14d58d32556208c26740baf3f02eae2d0a5f356ddc1ea478bd2f56803cc17e77261ffad33d27301017ba03736143446685c03bb0f076eef2fdd0 + languageName: node + linkType: hard + "vite-plugin-windicss@npm:^1.9.0": version: 1.9.0 resolution: "vite-plugin-windicss@npm:1.9.0"