Skip to content
Snippets Groups Projects
Commit 78274808 authored by Daniel Göbel's avatar Daniel Göbel
Browse files

Add pseudo folder structure to bucket view

parent 5283fb65
No related branches found
No related tags found
2 merge requests!22Version 1.0.0,!5Add pseudo folder structure to bucket view
...@@ -34,7 +34,7 @@ const emit = defineEmits<{ ...@@ -34,7 +34,7 @@ const emit = defineEmits<{
:aria-current="props.active" :aria-current="props.active"
:to="{ :to="{
name: 'bucket', name: 'bucket',
params: { bucket_name: bucket.name, sub_folders: [] }, params: { bucketName: bucket.name, subFolders: [] },
}" }"
> >
{{ bucket.name }} {{ bucket.name }}
......
<script setup lang="ts"> <script setup lang="ts">
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { onMounted, reactive, watch, computed } from "vue"; import { onMounted, reactive, watch, computed } from "vue";
import type { ComputedRef } from "vue";
import type { S3ObjectMetaInformation } from "@/client"; import type { S3ObjectMetaInformation } from "@/client";
import { ObjectService } from "@/client"; import { ObjectService } from "@/client";
import BootstrapIcon from "@/components/BootstrapIcon.vue"; import BootstrapIcon from "@/components/BootstrapIcon.vue";
import fileSize from "filesize"; import fileSize from "filesize";
import dayjs from "dayjs"; import dayjs from "dayjs";
// Constants
// -----------------------------------------------------------------------------
const route = useRoute(); const route = useRoute();
// Typescript types
// -----------------------------------------------------------------------------
interface S3ObjectWithFolder extends S3ObjectMetaInformation {
folder: string[];
pseudoFileName: string;
}
type S3PseudoFolder = {
size: number;
parentFolder: string[];
last_modified: string;
name: string;
key: string;
};
type FolderTree = {
subFolders: Record<string, FolderTree>;
files: S3ObjectWithFolder[];
};
// Reactive State
// -----------------------------------------------------------------------------
const objectState = reactive({ const objectState = reactive({
objects: [], objects: [],
visibleObjects: [],
loading: true, loading: true,
bucket_not_found_error: false, bucketNotFoundError: false,
bucket_permission_error: false, bucketPermissionError: false,
} as { } as {
objects: S3ObjectMetaInformation[]; objects: S3ObjectMetaInformation[];
visibleObjects: (S3ObjectWithFolder | S3PseudoFolder)[];
loading: boolean; loading: boolean;
bucket_not_found_error: boolean; bucketNotFoundError: boolean;
bucket_permission_error: boolean; bucketPermissionError: boolean;
}); });
// Watcher
// -----------------------------------------------------------------------------
watch( watch(
() => route.params, () => route.params,
(newRouteParams, oldRouteParams) => { (newRouteParams, oldRouteParams) => {
if ( if (
newRouteParams.bucket_name && newRouteParams.bucketName &&
oldRouteParams.bucket_name !== newRouteParams.bucket_name oldRouteParams.bucketName !== newRouteParams.bucketName
) {
// If bucket is changed, update the objects
updateObjects(newRouteParams.bucketName as string);
} else if (
newRouteParams.subFolders &&
oldRouteParams.subFolders !== newRouteParams.subFolders
) { ) {
update_objects(newRouteParams.bucket_name as string); // If sub folder is changed, update the visible objects
updateVisibleObjects(newRouteParams.subFolders as string[]);
} }
} }
); );
const sub_folder_in_url = computed(() => route.params.sub_folders.length > 0); watch(
const error_loading_objects = computed( () => objectState.objects,
() => () => {
objectState.bucket_permission_error || objectState.bucket_not_found_error updateVisibleObjects(
subFolderInUrl.value ? (route.params.subFolders as string[]) : []
);
}
); );
// Computed Properties
// -----------------------------------------------------------------------------
const folderStructure: ComputedRef<FolderTree> = computed(() => {
/**
* Store the entire folder structure in a bucket in a tree-like data structure
*/
return objectsWithFolders.value.reduce(
// Add one object after another to the folder structure
(fTree, currentObject) => {
// If the object is not in a sub folder, but it in the top level 'folder'
if (currentObject.folder.length === 0) {
fTree.files.push(currentObject);
} else {
// If the object is in a sub folder
let currentFolder: FolderTree = fTree;
// For every sub folder the object is in , navigate into the sub folder
for (const folderName of currentObject.folder) {
// If the sub folder doesn't exist yet, create it
if (
Object.keys(currentFolder.subFolders).find(
(subFolderName) => subFolderName === folderName
) == undefined
) {
currentFolder.subFolders[folderName] = {
subFolders: {},
files: [],
};
}
// navigate into the sub folder
currentFolder = currentFolder.subFolders[folderName] as FolderTree;
}
// Add object to the folder
currentFolder.files.push(currentObject);
}
return fTree;
},
// Empty folder structure as initial value
{
subFolders: {},
files: [],
} as FolderTree
);
});
const objectsWithFolders: ComputedRef<S3ObjectWithFolder[]> = computed(() => {
/**
* Add to the meta information from objects the pseudo filename and their pseudo folder
* This can be inferred from the key of the object where the '/' character is the delimiter, e.g.
* dir1/dir2/text.txt ->
* folder: dir1, dir2
* filename: text.txt
*/
return objectState.objects.map((obj) => {
const splittedKey = obj.key.split("/");
return {
...obj,
pseudoFileName: splittedKey[splittedKey.length - 1],
folder: splittedKey.slice(0, splittedKey.length - 1),
};
});
});
const subFolderInUrl: ComputedRef<boolean> = computed(
() => route.params.subFolders != null && route.params.subFolders.length > 0
);
const errorLoadingObjects: ComputedRef<boolean> = computed(
() => objectState.bucketPermissionError || objectState.bucketNotFoundError
);
// Lifecycle Hooks
// -----------------------------------------------------------------------------
onMounted(() => { onMounted(() => {
update_objects(route.params.bucket_name as string); updateObjects(route.params.bucketName as string);
}); });
function update_objects(bucket_name: string) { // Functions
objectState.bucket_not_found_error = false; // -----------------------------------------------------------------------------
objectState.bucket_permission_error = false; /**
* Calculate recursively the cumulative file size of all o objects in a folder
* @param folder Folder to inspect
* @returns The size of this folder in bytes
*/
function calculateFolderSize(folder: FolderTree): number {
let folderSize = 0;
folderSize += folder.files.reduce((acc, file) => acc + file.size, 0);
for (const subFolderName of Object.keys(folder.subFolders)) {
folderSize += calculateFolderSize(folder.subFolders[subFolderName]);
}
return folderSize;
}
/**
* Calculate recursively when an object in a folder were modified the last time
* @param folder Folder to inspect
* @returns The last modified timestamp as ISO string
*/
function calculateFolderLastModified(folder: FolderTree): string {
let lastModified: dayjs.Dayjs;
lastModified = folder.files
.map((f) => dayjs(f.last_modified))
.reduce(
(acc, fileAccessed) => (fileAccessed.isAfter(acc) ? fileAccessed : acc),
dayjs("2000-01-01")
);
for (const subFolderName of Object.keys(folder.subFolders)) {
const lastModifiedSubFolder = dayjs(
calculateFolderLastModified(folder.subFolders[subFolderName])
);
if (lastModifiedSubFolder.isAfter(lastModified)) {
lastModified = lastModifiedSubFolder;
}
}
return lastModified.toISOString();
}
/**
* Load the meta information about objects from a bucket
* @param bucketName Name of a bucket
*/
function updateObjects(bucketName: string) {
objectState.bucketNotFoundError = false;
objectState.bucketPermissionError = false;
objectState.loading = true; objectState.loading = true;
ObjectService.objectGetBucketObjects(bucket_name) ObjectService.objectGetBucketObjects(bucketName)
.then((objs) => { .then((objs) => {
objectState.objects = objs; objectState.objects = objs;
}) })
.catch((error) => { .catch((error) => {
if (error.status === 404) { if (error.status === 404) {
objectState.bucket_not_found_error = true; objectState.bucketNotFoundError = true;
} else if (error.status == 403) { } else if (error.status == 403) {
objectState.bucket_permission_error = true; objectState.bucketPermissionError = true;
} }
}) })
.finally(() => { .finally(() => {
objectState.loading = false; objectState.loading = false;
}); });
} }
/**
* Update the visible objects based on the current sub folder
* @param subFolders sub folders as ordered array
*/
function updateVisibleObjects(subFolders: string[]) {
objectState.visibleObjects = [];
let currentFolder = folderStructure.value;
for (const subFolder of subFolders) {
if (currentFolder.subFolders[subFolder] == null) {
return;
} else {
currentFolder = currentFolder.subFolders[subFolder];
}
}
const arr = [];
arr.push(...currentFolder.files);
arr.push(
...Object.keys(currentFolder.subFolders).map((subFolderName) => {
const folderSize = calculateFolderSize(
currentFolder.subFolders[subFolderName]
);
const folderLastModified = dayjs(
calculateFolderLastModified(currentFolder.subFolders[subFolderName])
).toISOString();
return {
name: subFolderName,
size: folderSize,
key: subFolderName,
parentFolder: subFolders,
last_modified: folderLastModified,
} as S3PseudoFolder;
})
);
objectState.visibleObjects = arr;
}
function isS3Object(
obj: S3PseudoFolder | S3ObjectWithFolder
): obj is S3ObjectWithFolder {
return (obj as S3ObjectWithFolder).folder !== undefined;
}
</script> </script>
<template> <template>
<!-- Navbar Breadcrumb -->
<nav aria-label="breadcrumb" class="fs-2"> <nav aria-label="breadcrumb" class="fs-2">
<ol class="breadcrumb"> <ol class="breadcrumb">
<li class="breadcrumb-item" :class="{ active: sub_folder_in_url }"> <li class="breadcrumb-item" :class="{ active: subFolderInUrl }">
<router-link <router-link
v-if="sub_folder_in_url" v-if="subFolderInUrl"
:to="{ :to="{
name: 'bucket', name: 'bucket',
params: { bucket_name: route.params.bucket_name, sub_folders: [] }, params: { bucketName: route.params.bucketName, subFolders: [] },
}" }"
>{{ route.params.bucket_name }}</router-link >{{ route.params.bucketName }}
> </router-link>
<span v-else>{{ route.params.bucket_name }}</span> <span v-else>{{ route.params.bucketName }}</span>
</li> </li>
<li <li
class="breadcrumb-item" class="breadcrumb-item"
v-for="(folder, index) in route.params.sub_folders" v-for="(folder, index) in route.params.subFolders"
:key="folder" :key="folder"
:class="{ active: index === route.params.sub_folders.length }" :class="{ active: index === route.params.subFolders.length }"
> >
<router-link <router-link
v-if="index !== route.params.sub_folders.length - 1" v-if="index !== route.params.subFolders.length - 1"
:to="{ :to="{
name: 'bucket', name: 'bucket',
params: { params: {
bucket_name: route.params.bucket_name, bucketName: route.params.bucketName,
sub_folders: route.params.sub_folders.slice(0, index + 1), subFolders: route.params.subFolders.slice(0, index + 1),
}, },
}" }"
>{{ folder }}</router-link >{{ folder }}
> </router-link>
<span v-else>{{ folder }}</span> <span v-else>{{ folder }}</span>
</li> </li>
</ol> </ol>
</nav> </nav>
<!-- Inputs on top -->
<!-- Search bucket text input -->
<div class="input-group mt-2"> <div class="input-group mt-2">
<span class="input-group-text" id="objects-search-wrapping" <span class="input-group-text" id="objects-search-wrapping"
><bootstrap-icon icon="search" :width="16" :height="16" ><bootstrap-icon icon="search" :width="16" :height="16"
...@@ -112,18 +311,20 @@ function update_objects(bucket_name: string) { ...@@ -112,18 +311,20 @@ function update_objects(bucket_name: string) {
disabled disabled
/> />
</div> </div>
<!-- Upload object button -->
<button <button
type="button" type="button"
class="btn btn-secondary m-2" class="btn btn-secondary m-2"
:disabled="error_loading_objects" :disabled="errorLoadingObjects"
> >
<bootstrap-icon icon="upload" :width="16" :height="16" fill="white" /> <bootstrap-icon icon="upload" :width="16" :height="16" fill="white" />
<span class="visually-hidden">Upload Object</span> <span class="visually-hidden">Upload Object</span>
</button> </button>
<!-- Add bucket permission button -->
<button <button
type="button" type="button"
class="btn btn-secondary m-2" class="btn btn-secondary m-2"
:disabled="error_loading_objects" :disabled="errorLoadingObjects"
> >
<bootstrap-icon <bootstrap-icon
icon="person-plus-fill" icon="person-plus-fill"
...@@ -133,30 +334,36 @@ function update_objects(bucket_name: string) { ...@@ -133,30 +334,36 @@ function update_objects(bucket_name: string) {
/> />
<span class="visually-hidden">Add Bucket Permission</span> <span class="visually-hidden">Add Bucket Permission</span>
</button> </button>
<!-- Add folder button -->
<button <button
type="button" type="button"
class="btn btn-secondary m-2" class="btn btn-secondary m-2"
:disabled="error_loading_objects" :disabled="errorLoadingObjects"
> >
<bootstrap-icon icon="plus-lg" :width="16" :height="16" fill="white" /> <bootstrap-icon icon="plus-lg" :width="16" :height="16" fill="white" />
Folder Folder
<span class="visually-hidden">Add Folder</span> <span class="visually-hidden">Add Folder</span>
</button> </button>
<!-- Body -->
<div class="pt-3"> <div class="pt-3">
<div v-if="objectState.bucket_not_found_error"> <!-- If bucket not found -->
<div v-if="objectState.bucketNotFoundError">
<p>Bucket not found</p> <p>Bucket not found</p>
</div> </div>
<div v-else-if="objectState.bucket_permission_error"> <!-- If no permission for bucket -->
<div v-else-if="objectState.bucketPermissionError">
<p>No permission for this bucket</p> <p>No permission for this bucket</p>
</div> </div>
<!-- Show content of bucket -->
<div v-else> <div v-else>
<!-- Table header -->
<table <table
class="table table-dark table-striped table-hover caption-top align-middle" class="table table-dark table-striped table-hover caption-top align-middle"
> >
<caption> <caption>
Displaying Displaying
{{ {{
objectState.loading ? 0 : objectState.objects.length objectState.loading ? 0 : objectState.visibleObjects.length
}} }}
Objects Objects
</caption> </caption>
...@@ -168,6 +375,7 @@ function update_objects(bucket_name: string) { ...@@ -168,6 +375,7 @@ function update_objects(bucket_name: string) {
<th scope="col"></th> <th scope="col"></th>
</tr> </tr>
</thead> </thead>
<!-- Table body when loading the objects -->
<tbody v-if="objectState.loading"> <tbody v-if="objectState.loading">
<tr v-for="n in 5" :key="n" class="placeholder-glow"> <tr v-for="n in 5" :key="n" class="placeholder-glow">
<th scope="row"> <th scope="row">
...@@ -178,22 +386,44 @@ function update_objects(bucket_name: string) { ...@@ -178,22 +386,44 @@ function update_objects(bucket_name: string) {
<td></td> <td></td>
</tr> </tr>
</tbody> </tbody>
<tbody v-else-if="objectState.objects.length === 0"> <!-- Table body when no objects are in the bcuket -->
<tbody v-else-if="objectState.visibleObjects.length === 0">
<tr> <tr>
<td colspan="4" class="text-center text-secondary"> <td colspan="4" class="text-center fst-italic fw-light">
<i>No objects to display</i> No objects to display
</td> </td>
</tr> </tr>
</tbody> </tbody>
<!-- Table body when showing objects -->
<tbody v-else> <tbody v-else>
<tr v-for="obj in objectState.objects" :key="obj.key"> <tr v-for="obj in objectState.visibleObjects" :key="obj.key">
<th scope="row" class="text-truncate">{{ obj.key }}</th> <th scope="row" class="text-truncate">
<!-- Show file name if row is an object -->
<div v-if="isS3Object(obj)">{{ obj.pseudoFileName }}</div>
<!-- Show link to subfolder if row is a folder -->
<div v-else>
<router-link
class="text-decoration-none"
:to="{
name: 'bucket',
params: {
bucketName: route.params.bucketName,
subFolders: obj.parentFolder.concat(obj.name),
},
}"
>{{ obj.name }}
</router-link>
</div>
</th>
<td>{{ dayjs(obj.last_modified).fromNow() }}</td> <td>{{ dayjs(obj.last_modified).fromNow() }}</td>
<td>{{ fileSize(obj.size) }}</td> <td>{{ fileSize(obj.size) }}</td>
<!-- Show buttons with dropdown menu if row is an object -->
<td class="text-end"> <td class="text-end">
<div <div
v-if="isS3Object(obj)"
class="btn-group btn-group-sm dropdown-center dropdown-menu-start" class="btn-group btn-group-sm dropdown-center dropdown-menu-start"
> >
<!-- Download Button -->
<button type="button" class="btn btn-secondary"> <button type="button" class="btn btn-secondary">
Download Download
</button> </button>
...@@ -205,7 +435,7 @@ function update_objects(bucket_name: string) { ...@@ -205,7 +435,7 @@ function update_objects(bucket_name: string) {
> >
<span class="visually-hidden">Toggle Dropdown</span> <span class="visually-hidden">Toggle Dropdown</span>
</button> </button>
<!-- Dropdown menu -->
<ul class="dropdown-menu dropdown-menu-dark"> <ul class="dropdown-menu dropdown-menu-dark">
<li> <li>
<button class="dropdown-item" type="button">Details</button> <button class="dropdown-item" type="button">Details</button>
...@@ -217,7 +447,10 @@ function update_objects(bucket_name: string) { ...@@ -217,7 +447,10 @@ function update_objects(bucket_name: string) {
<button class="dropdown-item" type="button">Copy</button> <button class="dropdown-item" type="button">Copy</button>
</li> </li>
<li> <li>
<button class="dropdown-item text-danger" type="button"> <button
class="dropdown-item text-danger align-middle"
type="button"
>
<bootstrap-icon <bootstrap-icon
icon="trash-fill" icon="trash-fill"
class="text-danger" class="text-danger"
...@@ -230,6 +463,22 @@ function update_objects(bucket_name: string) { ...@@ -230,6 +463,22 @@ function update_objects(bucket_name: string) {
</li> </li>
</ul> </ul>
</div> </div>
<!-- Show delete button when row is a folder -->
<div v-else>
<button
type="button"
class="btn btn-danger btn-sm align-middle"
>
<bootstrap-icon
icon="trash-fill"
class="text-danger me-2"
:width="12"
:height="12"
fill="white"
/>
<span>Delete</span>
</button>
</div>
</td> </td>
</tr> </tr>
</tbody> </tbody>
......
...@@ -3,11 +3,13 @@ import { BucketService } from "@/client"; ...@@ -3,11 +3,13 @@ import { BucketService } from "@/client";
import type { BucketIn } from "@/client"; import type { BucketIn } from "@/client";
import { reactive } from "vue"; import { reactive } from "vue";
import BootstrapModal from "@/components/BootstrapModal.vue"; import BootstrapModal from "@/components/BootstrapModal.vue";
import { useRouter } from "vue-router";
/* /*
import { onMounted } from "vue"; import { onMounted } from "vue";
import { Modal } from "bootstrap"; import { Modal } from "bootstrap";
*/ */
const router = useRouter();
const emit = defineEmits(["bucketCreated"]); const emit = defineEmits(["bucketCreated"]);
const bucket = reactive({ name: "", description: "" } as BucketIn); const bucket = reactive({ name: "", description: "" } as BucketIn);
const formState = reactive({ const formState = reactive({
...@@ -51,6 +53,10 @@ function createBucket() { ...@@ -51,6 +53,10 @@ function createBucket() {
bucket.description = ""; bucket.description = "";
formState.bucketNameTaken = false; formState.bucketNameTaken = false;
formState.validated = false; formState.validated = false;
router.push({
name: "bucket",
params: { bucketName: createdBucket.name, subFolders: [] },
});
}) })
.catch((error) => { .catch((error) => {
if ( if (
...@@ -149,7 +155,12 @@ function modalClosed() { ...@@ -149,7 +155,12 @@ function modalClosed() {
:disabled="formState.loading" :disabled="formState.loading"
@click.prevent="createBucket" @click.prevent="createBucket"
> >
<span v-if="formState.loading" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> <span
v-if="formState.loading"
class="spinner-border spinner-border-sm"
role="status"
aria-hidden="true"
></span>
Save Save
</button> </button>
</template> </template>
......
...@@ -16,7 +16,7 @@ const router = createRouter({ ...@@ -16,7 +16,7 @@ const router = createRouter({
component: () => import("../views/object-storage/BucketsView.vue"), component: () => import("../views/object-storage/BucketsView.vue"),
children: [ children: [
{ {
path: ":bucket_name/:sub_folders*", path: ":bucketName/:subFolders*",
name: "bucket", name: "bucket",
component: () => import("../components/BucketView.vue"), component: () => import("../components/BucketView.vue"),
}, },
......
...@@ -32,11 +32,11 @@ function addBucket(bucket: BucketOut) { ...@@ -32,11 +32,11 @@ function addBucket(bucket: BucketOut) {
bucketsState.buckets.push(bucket); bucketsState.buckets.push(bucket);
} }
function deleteBucket(bucket_name: string) { function deleteBucket(bucketName: string) {
BucketService.bucketDeleteBucket(bucket_name).then(() => { BucketService.bucketDeleteBucket(bucketName).then(() => {
router.push({ name: "buckets" }); router.push({ name: "buckets" });
bucketsState.buckets = bucketsState.buckets.filter( bucketsState.buckets = bucketsState.buckets.filter(
(bucket) => bucket.name !== bucket_name (bucket) => bucket.name !== bucketName
); );
}); });
} }
...@@ -97,8 +97,8 @@ onMounted(() => { ...@@ -97,8 +97,8 @@ onMounted(() => {
v-for="bucket in bucketsState.buckets" v-for="bucket in bucketsState.buckets"
:key="bucket.name" :key="bucket.name"
:active=" :active="
route.params.bucket_name != null && route.params.bucketName != null &&
route.params.bucket_name === bucket.name route.params.bucketName === bucket.name
" "
:bucket="bucket" :bucket="bucket"
:loading="false" :loading="false"
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment