web/satellite/vuetify-poc: unmock file browser table entries

This change shows real file entries in the file browser table,
replacing the mock data. Sorting, searching, and folder navigation have
been implemented.

Resolves #6199

Change-Id: I7360879d2e26605489c20f9d094c3f231fee49cd
This commit is contained in:
Jeremy Wharton 2023-08-28 19:45:58 -05:00 committed by Storj Robot
parent ebfbbca1be
commit 0c9c37875d
8 changed files with 425 additions and 154 deletions

View File

@ -53,6 +53,11 @@ module.exports = {
'pattern': '@/../static/**',
'position': 'after',
},
{
'group': 'internal',
'pattern': '@poc/assets/**',
'position': 'after',
},
],
'newlines-between': 'always',
}],

View File

@ -46,6 +46,7 @@ export class BucketsState {
public passphrase = '';
public promptForPassphrase = true;
public fileComponentBucketName = '';
public fileComponentPath = '';
public leaveRoute = '';
public enterPassphraseCallback: (() => void) | null = null;
}
@ -210,6 +211,10 @@ export const useBucketsStore = defineStore('buckets', () => {
state.fileComponentBucketName = bucketName;
}
function setFileComponentPath(path: string): void {
state.fileComponentPath = path;
}
function setEnterPassphraseCallback(fn: (() => void) | null): void {
state.enterPassphraseCallback = fn;
}
@ -304,6 +309,7 @@ export const useBucketsStore = defineStore('buckets', () => {
setPassphrase,
setApiKey,
setFileComponentBucketName,
setFileComponentPath,
setEnterPassphraseCallback,
createBucket,
createBucketWithNoPassphrase,

View File

@ -2,7 +2,7 @@
// See LICENSE for copying information.
<template>
<v-breadcrumbs :items="['Buckets', 'Demo']" active-class="font-weight-bold" class="pa-0">
<v-breadcrumbs :items="items" active-class="font-weight-bold" class="pa-0">
<template #divider>
<img src="@poc/assets/icon-right.svg" alt="Breadcrumbs separator" width="10">
</template>
@ -10,5 +10,52 @@
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { VBreadcrumbs } from 'vuetify/components';
import { useBucketsStore } from '@/store/modules/bucketsStore';
import { useProjectsStore } from '@/store/modules/projectsStore';
const projectsStore = useProjectsStore();
const bucketsStore = useBucketsStore();
/**
* Returns ID of selected project from store.
*/
const projectId = computed<string>(() => projectsStore.state.selectedProject.id);
/**
* Returns the name of the selected bucket.
*/
const bucketName = computed<string>(() => bucketsStore.state.fileComponentBucketName);
/**
* Returns the name of the current path within the selected bucket.
*/
const filePath = computed<string>(() => bucketsStore.state.fileComponentPath);
type BreadcrumbItem = {
title: string;
to: string;
}
/**
* Returns breadcrumb items corresponding to parts in the file browser path.
*/
const items = computed<BreadcrumbItem[]>(() => {
const bucketsURL = `/projects/${projectId.value}/buckets`;
const pathParts = [bucketName.value];
if (filePath.value) pathParts.push(...filePath.value.split('/'));
return [
{ title: 'Buckets', to: bucketsURL },
...pathParts.map<BreadcrumbItem>((part, index) => {
const suffix = pathParts.slice(0, index + 1)
.map(part => encodeURIComponent(part))
.join('/');
return { title: part, to: `${bucketsURL}/${suffix}` };
}),
];
});
</script>

View File

@ -17,30 +17,45 @@
class="mx-2 mt-2"
/>
<v-data-table
<v-data-table-server
v-model="selected"
v-model:options="options"
:sort-by="sortBy"
:headers="headers"
:items="files"
:items="tableFiles"
:search="search"
class="elevation-1"
item-key="path"
:item-value="(item: BrowserObjectWrapper) => item.browserObject.Key"
show-select
hover
must-sort
:loading="isFetching || loading"
:items-length="allFiles.length"
>
<template #item.name="{ item }">
<div>
<template #item.name="{ item }: ItemSlotProps">
<v-btn
class="rounded-lg w-100 pl-1 pr-4 justify-start font-weight-bold"
variant="text"
height="40"
color="default"
@click="previewFile"
block
@click="onFileClick(item.raw.browserObject)"
>
<img :src="icons.get(item.raw.icon) || fileIcon" alt="Item icon" class="mr-3">
{{ item.raw.name }}
<img :src="item.raw.typeInfo.icon" :alt="item.raw.typeInfo.title + 'icon'" class="mr-3">
{{ item.raw.browserObject.Key }}
</v-btn>
</div>
</template>
<template #item.type="{ item }: ItemSlotProps">
{{ item.raw.typeInfo.title }}
</template>
<template #item.size="{ item }: ItemSlotProps">
{{ getFormattedSize(item.raw.browserObject) }}
</template>
<template #item.date="{ item }: ItemSlotProps">
{{ getFormattedDate(item.raw.browserObject) }}
</template>
<template #item.actions>
@ -70,17 +85,17 @@
</v-btn>
</div>
</template>
</v-data-table>
</v-data-table-server>
<v-dialog v-model="previewDialog" transition="fade-transition" class="preview-dialog" fullscreen theme="dark">
<v-card class="preview-card">
<v-carousel hide-delimiters show-arrows="hover" height="100vh">
<template #prev="{ props }">
<template #prev="{ props: slotProps }">
<v-btn
color="default"
class="rounded-circle"
icon
@click="props.onClick"
@click="slotProps.onClick"
>
<svg width="10" height="17" viewBox="0 0 10 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_24843_332342)">
@ -94,12 +109,12 @@
</svg>
</v-btn>
</template>
<template #next="{ props }">
<template #next="{ props: slotProps }">
<v-btn
color="default"
class="rounded-circle"
icon
@click="props.onClick"
@click="slotProps.onClick"
>
<svg width="10" height="17" viewBox="0 0 10 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_24843_332338)">
@ -182,7 +197,8 @@
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ref, computed, watch } from 'vue';
import { useRouter } from 'vue-router';
import {
VCard,
VTextField,
@ -195,7 +211,19 @@ import {
VCarouselItem,
VIcon,
} from 'vuetify/components';
import { VDataTable } from 'vuetify/labs/components';
import { VDataTableServer } from 'vuetify/labs/components';
import { BrowserObject, useObjectBrowserStore } from '@/store/modules/objectBrowserStore';
import { useProjectsStore } from '@/store/modules/projectsStore';
import { useNotify } from '@/utils/hooks';
import { SHORT_MONTHS_NAMES } from '@/utils/constants/date';
import { Size } from '@/utils/bytesSize';
import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames';
import { useBucketsStore } from '@/store/modules/bucketsStore';
import IconShare from '@poc/components/icons/IconShare.vue';
import IconDownload from '@poc/components/icons/IconDownload.vue';
import BrowserActionsMenu from '@poc/components/BrowserActionsMenu.vue';
import folderIcon from '@poc/assets/icon-folder-tonal.svg';
import pdfIcon from '@poc/assets/icon-pdf-tonal.svg';
@ -207,25 +235,51 @@ import zipIcon from '@poc/assets/icon-zip-tonal.svg';
import spreadsheetIcon from '@poc/assets/icon-spreadsheet-tonal.svg';
import fileIcon from '@poc/assets/icon-file-tonal.svg';
import IconShare from '@poc/components/icons/IconShare.vue';
import IconDownload from '@poc/components/icons/IconDownload.vue';
import BrowserActionsMenu from '@poc/components/BrowserActionsMenu.vue';
type SortKey = 'name' | 'type' | 'size' | 'date';
type TableOptions = {
page: number;
itemsPerPage: number;
sortBy: {
key: SortKey;
order: 'asc' | 'desc';
}[];
};
type BrowserObjectTypeInfo = {
title: string;
icon: string;
};
/**
* Contains extra information to aid in the display, filtering, and sorting of browser objects.
*/
type BrowserObjectWrapper = {
browserObject: BrowserObject;
typeInfo: BrowserObjectTypeInfo;
lowerName: string;
ext: string;
};
type ItemSlotProps = { item: { raw: BrowserObjectWrapper } };
const props = defineProps<{
forceEmpty?: boolean;
loading?: boolean;
}>();
const obStore = useObjectBrowserStore();
const projectsStore = useProjectsStore();
const bucketsStore = useBucketsStore();
const notify = useNotify();
const router = useRouter();
const isFetching = ref<boolean>(false);
const search = ref<string>('');
const selected = ref([]);
const previewDialog = ref<boolean>(false);
const icons = new Map<string, string>([
['folder', folderIcon],
['pdf', pdfIcon],
['image', imageIcon],
['video', videoIcon],
['audio', audioIcon],
['text', textIcon],
['zip', zipIcon],
['spreadsheet', spreadsheetIcon],
['file', fileIcon],
]);
const options = ref<TableOptions>();
const items = [
{ src: 'https://cdn.vuetifyjs.com/images/carousel/squirrel.jpg' },
@ -240,97 +294,174 @@ const headers = [
align: 'start',
key: 'name',
},
{ title: 'Type', key:'type' },
{ title: 'Type', key: 'type' },
{ title: 'Size', key: 'size' },
{ title: 'Date', key: 'date' },
{ title: '', key: 'actions', sortable: false, width: 0 },
];
const files = [
{
name: 'Be The Cloud',
path: 'folder-1',
type: 'Folder',
size: '2 GB',
date: '02 Mar 2023',
icon: 'folder',
},
{
name: 'Folder',
path: 'folder-2',
type: 'Folder',
size: '458 MB',
date: '21 Apr 2023',
icon: 'folder',
},
{
name: 'Presentation.pdf',
path: 'Presentation.pdf',
type: 'PDF',
size: '150 KB',
date: '24 Mar 2023',
icon: 'pdf',
},
{
name: 'Image.jpg',
path: 'image.jpg',
type: 'JPG',
size: '500 KB',
date: '12 Mar 2023',
icon: 'image',
},
{
name: 'Video.mp4',
path: 'video.mp4',
type: 'MP4',
size: '3 MB',
date: '01 Apr 2023',
icon: 'video',
},
{
name: 'Song.mp3',
path: 'Song.mp3',
type: 'MP3',
size: '8 MB',
date: '22 May 2023',
icon: 'audio',
},
{
name: 'Text.txt',
path: 'text.txt',
type: 'TXT',
size: '2 KB',
date: '21 May 2023',
icon: 'text',
},
{
name: 'NewArchive.zip',
path: 'newarchive.zip',
type: 'ZIP',
size: '21 GB',
date: '20 May 2023',
icon: 'zip',
},
{
name: 'Table.csv',
path: 'table.csv',
type: 'CSV',
size: '3 MB',
date: '20 May 2023',
icon: 'spreadsheet',
},
{
name: 'Map-export.json',
path: 'map-export.json',
type: 'JSON',
size: '1 MB',
date: '23 May 2023',
icon: 'file',
},
];
const collator = new Intl.Collator('en', { sensitivity: 'case' });
const extensionInfos: Map<string[], BrowserObjectTypeInfo> = new Map([
[['jpg', 'jpeg', 'png', 'gif'], { title: 'Image', icon: imageIcon }],
[['mp4', 'mkv', 'mov'], { title: 'Video', icon: videoIcon }],
[['mp3', 'aac', 'wav', 'm4a'], { title: 'Audio', icon: audioIcon }],
[['txt', 'docx', 'doc', 'pages'], { title: 'Text', icon: textIcon }],
[['pdf'], { title: 'PDF', icon: pdfIcon }],
[['zip'], { title: 'ZIP', icon: zipIcon }],
[['xls', 'numbers', 'csv', 'xlsx', 'tsv'], { title: 'Spreadsheet', icon: spreadsheetIcon }],
]);
const folderInfo: BrowserObjectTypeInfo = { title: 'Folder', icon: folderIcon };
const fileInfo: BrowserObjectTypeInfo = { title: 'File', icon: fileIcon };
/**
* Returns the name of the selected bucket.
*/
const bucketName = computed<string>(() => bucketsStore.state.fileComponentBucketName);
/**
* Returns the name of the current path within the selected bucket.
*/
const filePath = computed<string>(() => bucketsStore.state.fileComponentPath);
/**
* Returns every file under the current path.
*/
const allFiles = computed<BrowserObjectWrapper[]>(() => {
if (props.forceEmpty) return [];
return obStore.state.files.map<BrowserObjectWrapper>(file => {
const lowerName = file.Key.toLowerCase();
const dotIdx = lowerName.indexOf('.');
const ext = dotIdx === -1 ? '' : file.Key.slice(dotIdx + 1);
return {
browserObject: file,
typeInfo: getFileTypeInfo(ext, file.type),
lowerName,
ext,
};
});
});
/**
* Returns every file under the current path that matchs the search query.
*/
const filteredFiles = computed<BrowserObjectWrapper[]>(() => {
if (!search.value) return allFiles.value;
const searchLower = search.value.toLowerCase();
return allFiles.value.filter(file => file.lowerName.includes(searchLower));
});
/**
* Returns the files to be displayed in the table.
*/
const tableFiles = computed<BrowserObjectWrapper[]>(() => {
const opts = options.value;
if (!opts) return [];
const files = [...filteredFiles.value];
if (opts.sortBy.length) {
const sortBy = opts.sortBy[0];
type CompareFunc = (a: BrowserObjectWrapper, b: BrowserObjectWrapper) => number;
const compareFuncs: Record<SortKey, CompareFunc> = {
name: (a, b) => collator.compare(a.browserObject.Key, b.browserObject.Key),
type: (a, b) => collator.compare(a.typeInfo.title, b.typeInfo.title) || collator.compare(a.ext, b.ext),
size: (a, b) => a.browserObject.Size - b.browserObject.Size,
date: (a, b) => a.browserObject.LastModified.getTime() - b.browserObject.LastModified.getTime(),
};
files.sort((a, b) => {
const objA = a.browserObject, objB = b.browserObject;
if (sortBy.key !== 'type') {
if (objA.type === 'folder') {
if (objB.type !== 'folder') return -1;
if (sortBy.key === 'size' || sortBy.key === 'date') return 0;
} else if (objB.type === 'folder') {
return 1;
}
}
const cmp = compareFuncs[sortBy.key](a, b);
return sortBy.order === 'asc' ? cmp : -cmp;
});
}
if (opts.itemsPerPage === -1) return files;
return files.slice((opts.page - 1) * opts.itemsPerPage, opts.page * opts.itemsPerPage);
});
/**
* Returns the string form of the file's last modified date.
*/
function getFormattedDate(file: BrowserObject): string {
if (file.type === 'folder') return '';
const date = file.LastModified;
return `${date.getDate()} ${SHORT_MONTHS_NAMES[date.getMonth()]} ${date.getFullYear()}`;
}
/**
* Returns the string form of the file's size.
*/
function getFormattedSize(file: BrowserObject): string {
if (file.type === 'folder') return '';
const size = new Size(file.Size);
return `${size.formattedBytes} ${size.label}`;
}
/**
* Returns the title and icon representing a file's type.
*/
function getFileTypeInfo(ext: string, type: BrowserObject['type']): BrowserObjectTypeInfo {
if (!type) return fileInfo;
if (type === 'folder') return folderInfo;
ext = ext.toLowerCase();
for (const [exts, info] of extensionInfos.entries()) {
if (exts.indexOf(ext) !== -1) return info;
}
return fileInfo;
}
/**
* Handles file click.
*/
function onFileClick(file: BrowserObject): void {
if (!file.type) return;
if (file.type === 'folder') {
const uriParts = [file.Key];
if (filePath.value) {
uriParts.unshift(...filePath.value.split('/'));
}
const pathAndKey = uriParts.map(part => encodeURIComponent(part)).join('/');
router.push(`/projects/${projectsStore.state.selectedProject.id}/buckets/${bucketName.value}/${pathAndKey}`);
return;
}
function previewFile(): void {
// Implement logic to fetch the file content for preview or generate a URL for preview
// Then, open the preview dialog
previewDialog.value = true;
}
/**
* Fetches all files in the current directory.
*/
async function fetchFiles(): Promise<void> {
if (isFetching.value || props.forceEmpty) return;
isFetching.value = true;
try {
await obStore.list(filePath.value ? filePath.value + '/' : '');
selected.value = [];
} catch (err) {
err.message = `Error fetching files. ${err.message}`;
notify.notifyError(err, AnalyticsErrorEventSource.FILE_BROWSER_LIST_CALL);
}
isFetching.value = false;
}
watch(filePath, fetchFiles, { immediate: true });
watch(() => props.forceEmpty, v => !v && fetchFiles());
</script>

View File

@ -56,8 +56,6 @@ const isLoading = ref<boolean>(true);
* all projects dashboard if no such project exists.
*/
async function selectProject(projectId: string): Promise<void> {
isLoading.value = true;
let projects: Project[];
try {
projects = await projectsStore.getProjects();
@ -74,17 +72,22 @@ async function selectProject(projectId: string): Promise<void> {
return;
}
projectsStore.selectProject(projectId);
isLoading.value = false;
}
watch(() => route.params.projectId, async newId => selectProject(newId as string));
watch(() => route.params.projectId, async newId => {
if (newId === undefined) return;
isLoading.value = true;
await selectProject(newId as string);
isLoading.value = false;
});
/**
* Lifecycle hook after initial render.
* Pre-fetches user`s and project information.
*/
onBeforeMount(async () => {
isLoading.value = true;
try {
await Promise.all([
usersStore.getUser(),
@ -112,8 +115,10 @@ onBeforeMount(async () => {
notify.notifyError(error, AnalyticsErrorEventSource.OVERALL_APP_WRAPPER_ERROR);
}
selectProject(route.params.projectId as string);
await selectProject(route.params.projectId as string);
if (!agStore.state.accessGrantsWebWorker) await agStore.startWorker();
isLoading.value = false;
});
</script>

View File

@ -80,7 +80,7 @@ export default createVuetify({
VBtn: {
density: 'default',
rounded: 'lg',
class: 'text-capitalize font-weight-bold',
class: 'text-none font-weight-bold',
style: 'letter-spacing:0;',
},
VTooltip: {

View File

@ -58,7 +58,7 @@ const routes: RouteRecordRaw[] = [
component: () => import(/* webpackChunkName: "Buckets" */ '@poc/views/Buckets.vue'),
},
{
path: 'buckets/:bucketName',
path: 'buckets/:browserPath+',
name: 'Bucket',
component: () => import(/* webpackChunkName: "Bucket" */ '@poc/views/Bucket.vue'),
},

View File

@ -2,19 +2,20 @@
// See LICENSE for copying information.
<template>
<v-container v-if="hasObjects">
<PageTitleComponent title="Browse Files" />
<v-container>
<page-title-component title="Browse Files" />
<BrowserBreadcrumbsComponent />
<browser-breadcrumbs-component />
<v-col>
<v-row class="mt-2 mb-4">
<v-btn
color="primary"
min-width="120"
:disabled="isContentDisabled"
@click="snackbar = true"
>
<BrowserSnackbarComponent :on-cancel="() => { snackbar = false }" />
<browser-snackbar-component :on-cancel="() => { snackbar = false }" />
<IconUpload />
Upload
</v-btn>
@ -23,26 +24,36 @@
variant="outlined"
color="default"
class="mx-4"
:disabled="isContentDisabled"
>
<IconFolder />
<icon-folder />
New Folder
<BrowserNewFolderDialog />
<browser-new-folder-dialog />
</v-btn>
</v-row>
</v-col>
<BrowserTableComponent />
<browser-table-component :loading="isLoading" :force-empty="isContentDisabled" />
</v-container>
<EnterBucketPassphraseDialog v-model="isBucketPassphraseDialogOpen" @passphraseEntered="getObjects" />
<enter-bucket-passphrase-dialog
v-if="!isLoading"
v-model="isBucketPassphraseDialogOpen"
@passphrase-entered="initObjectStore"
/>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { VContainer, VCol, VRow, VBtn } from 'vuetify/components';
import { useRoute } from 'vue-router';
import { useBucketsStore } from '@/store/modules/bucketsStore';
import { useObjectBrowserStore } from '@/store/modules/objectBrowserStore';
import { useProjectsStore } from '@/store/modules/projectsStore';
import { EdgeCredentials } from '@/types/accessGrants';
import { useNotify } from '@/utils/hooks';
import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames';
import PageTitleComponent from '@poc/components/PageTitleComponent.vue';
import BrowserBreadcrumbsComponent from '@poc/components/BrowserBreadcrumbsComponent.vue';
@ -54,27 +65,93 @@ import IconFolder from '@poc/components/icons/IconFolder.vue';
import EnterBucketPassphraseDialog from '@poc/components/dialogs/EnterBucketPassphraseDialog.vue';
const bucketsStore = useBucketsStore();
const obStore = useObjectBrowserStore();
const projectsStore = useProjectsStore();
const router = useRouter();
const route = useRoute();
const notify = useNotify();
const isLoading = ref<boolean>(true);
const isContentDisabled = ref<boolean>(true);
const snackbar = ref<boolean>(false);
const isBucketPassphraseDialogOpen = ref(false);
const hasObjects = ref(false);
const isBucketPassphraseDialogOpen = ref<boolean>(bucketsStore.state.promptForPassphrase);
const bucketName = computed(() => {
return bucketsStore.state.fileComponentBucketName;
});
/**
* Returns the name of the selected bucket.
*/
const bucketName = computed<string>(() => bucketsStore.state.fileComponentBucketName);
function getObjects() {
hasObjects.value = true;
/**
* Returns edge credentials from store.
*/
const edgeCredentials = computed((): EdgeCredentials => bucketsStore.state.edgeCredentials);
/**
* Returns ID of selected project from store.
*/
const projectId = computed<string>(() => projectsStore.state.selectedProject.id);
/**
* Returns whether the user should be prompted to enter the passphrase.
*/
const isPromptForPassphrase = computed<boolean>(() => bucketsStore.state.promptForPassphrase);
/**
* Initializes object browser store.
*/
function initObjectStore(): void {
obStore.init({
endpoint: edgeCredentials.value.endpoint,
accessKey: edgeCredentials.value.accessKeyId,
secretKey: edgeCredentials.value.secretKey,
bucket: bucketName.value,
browserRoot: '', // unused
});
isContentDisabled.value = false;
}
onMounted(() => {
if (!bucketName.value) {
// navigated here via direct link or reloaded this page
bucketsStore.setFileComponentBucketName(route.params['bucketName'] as string);
isBucketPassphraseDialogOpen.value = true;
watch(isBucketPassphraseDialogOpen, isOpen => {
if (isOpen || !isPromptForPassphrase.value) return;
router.push(`/projects/${projectId.value}/dashboard`);
});
watch(() => route.params.browserPath, browserPath => {
if (browserPath === undefined) return;
let bucketName = '', filePath = '';
if (typeof browserPath === 'string') {
bucketName = browserPath;
} else {
bucketName = browserPath[0];
filePath = browserPath.slice(1).join('/');
}
bucketsStore.setFileComponentBucketName(bucketName);
bucketsStore.setFileComponentPath(filePath);
}, { immediate: true });
/**
* Initializes file browser.
*/
onMounted(async () => {
const dashboardURL = `/projects/${projectId.value}/dashboard`;
try {
await bucketsStore.getAllBucketsNames(projectId.value);
} catch (error) {
error.message = `Error fetching bucket names. ${error.message}`;
notify.notifyError(error, AnalyticsErrorEventSource.UPLOAD_FILE_VIEW);
return;
}
getObjects();
if (bucketsStore.state.allBucketNames.indexOf(bucketName.value) === -1) {
router.push(dashboardURL);
return;
}
if (!isPromptForPassphrase.value) initObjectStore();
isLoading.value = false;
});
</script>