web/satellite: update upload modal

The upload modal has been updated to more closely match our designs.
- A button has been added to cancel all in-progress uploads.
- The status text has been fixed to display the proper file count.
- Clicking completed items in the upload modal previews them.

Resolves #5973

Change-Id: Iaee5fe05be14b3a6f2de1a9c807eca5137c7d643
This commit is contained in:
Jeremy Wharton 2023-07-06 19:50:31 -05:00 committed by Storj Robot
parent bd4d57c604
commit 4e876fbdba
4 changed files with 237 additions and 188 deletions

View File

@ -7,10 +7,13 @@
<div class="modal__header__left">
<div class="modal__header__left__info">
<div class="modal__header__left__info__cont">
<CompleteIcon v-if="isClosable" />
<component :is="icon" v-if="icon" :class="{ close: icon === FailedIcon }" />
<p class="modal__header__left__info__cont__title">{{ statusLabel }}</p>
</div>
<p class="modal__header__left__info__remaining">{{ remainingTimeString }}</p>
<div class="modal__header__left__info__right">
<p class="modal__header__left__info__right__remaining">{{ remainingTimeString }}</p>
<p v-if="!isClosable" class="modal__header__left__info__right__cancel" @click.stop="cancelAll">Cancel</p>
</div>
</div>
<div v-if="!isClosable" class="modal__header__left__track">
<div class="modal__header__left__track__fill" :style="progressStyle" />
@ -22,28 +25,39 @@
</div>
</div>
<div v-if="isExpanded" class="modal__items">
<div v-for="item in uploading" :key="item.Key">
<UploadItem :item="item" />
</div>
<UploadItem
v-for="item in uploading"
:key="item.Key"
:class="{ modal__items__completed: item.status == UploadingStatus.Finished }"
:item="item"
@click="() => showFile(item)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { computed, onMounted, ref, watch, Component } from 'vue';
import { UploadingBrowserObject, UploadingStatus, useObjectBrowserStore } from '@/store/modules/objectBrowserStore';
import { useAppStore } from '@/store/modules/appStore';
import { Duration } from '@/utils/time';
import { useNotify } from '@/utils/hooks';
import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames';
import { useConfigStore } from '@/store/modules/configStore';
import { MODALS } from '@/utils/constants/appStatePopUps';
import UploadItem from '@/components/modals/objectUpload/UploadItem.vue';
import ArrowIcon from '@/../static/images/modals/objectUpload/arrow.svg';
import CloseIcon from '@/../static/images/modals/objectUpload/close.svg';
import CompleteIcon from '@/../static/images/modals/objectUpload/complete.svg';
import FailedIcon from '@/../static/images/modals/objectUpload/failed.svg';
const obStore = useObjectBrowserStore();
const appStore = useAppStore();
const notify = useNotify();
const config = useConfigStore();
const isExpanded = ref<boolean>(false);
const startDate = ref<number>(Date.now());
@ -71,35 +85,47 @@ const isClosable = computed((): boolean => {
return !objectsInProgress.value.length;
});
/**
* Returns what icon should be displayed in the header.
*/
const icon = computed((): string => {
if (!isClosable.value) return '';
if (uploading.value.some(f => f.status === UploadingStatus.Finished)) return CompleteIcon;
if (uploading.value.some(f => f.status === UploadingStatus.Failed)) return FailedIcon;
return '';
});
/**
* Returns header's status label.
*/
const statusLabel = computed((): string => {
if (isClosable.value) {
let status = 'Uploading completed';
const failedUploads = uploading.value.filter(f => f.status === UploadingStatus.Failed);
if (failedUploads.length > 0) {
status += ` (${failedUploads.length} failed`;
let inProgress = 0, finished = 0, failed = 0, cancelled = 0;
uploading.value.forEach(u => {
switch (u.status) {
case UploadingStatus.InProgress:
inProgress++;
break;
case UploadingStatus.Failed:
failed++;
break;
case UploadingStatus.Cancelled:
cancelled++;
break;
default:
finished++;
}
});
const cancelledUploads = uploading.value.filter(f => f.status === UploadingStatus.Cancelled);
if (cancelledUploads.length > 0) {
status += `, (${cancelledUploads.length} cancelled`;
}
if (failed === uploading.value.length) return 'Uploading failed';
if (cancelled === uploading.value.length) return 'Uploading cancelled';
if (inProgress) return `Uploading ${inProgress} item${inProgress > 1 ? 's' : ''}`;
if (!failedUploads.length && !cancelledUploads.length) {
return status;
}
const statuses = [
failed ? `${failed} failed` : '',
cancelled ? `${cancelled} cancelled` : '',
].filter(s => s).join(', ');
return `${status})`;
}
if (uploading.value.length === 1) {
return 'Uploading 1 item';
}
return `Uploading ${uploading.value.length} items`;
return `Uploading completed${statuses ? ` (${statuses})` : ''}`;
});
/**
@ -137,6 +163,34 @@ function calculateRemainingTime(): void {
remainingTimeString.value = new Duration(remainingNanoseconds).remainingFormatted;
}
/**
* Cancels all uploads in progress.
*/
function cancelAll(): void {
objectsInProgress.value.forEach(item => {
try {
obStore.cancelUpload(item.Key);
} catch (error) {
notify.error(`Unable to cancel upload for '${item.Key}'. ${error.message}`, AnalyticsErrorEventSource.OBJECTS_UPLOAD_MODAL);
}
});
}
/**
* Opens the object preview.
*/
function showFile(item: UploadingBrowserObject): void {
if (item.status !== UploadingStatus.Finished) return;
obStore.setObjectPathForModal(item.Key);
if (config.state.config.galleryViewEnabled) {
appStore.setGalleryView(true);
} else {
appStore.updateActiveModal(MODALS.objectDetails);
}
}
/**
* Toggles expanded state.
*/
@ -211,10 +265,10 @@ onMounted(() => {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
&__left {
box-sizing: border-box;
margin-right: 24px;
width: 100%;
&__info {
@ -225,24 +279,40 @@ onMounted(() => {
&__cont {
display: flex;
align-items: center;
gap: 11px;
svg {
margin-right: 11px;
width: 24px;
height: 24px;
}
&__title {
font-family: 'font_medium', sans-serif;
font-size: 14px;
line-height: 20px;
color: var(--c-white);
}
}
&__remaining {
&__right {
display: flex;
align-items: center;
gap: 17px;
font-size: 14px;
line-height: 20px;
color: var(--c-white);
opacity: 0.7;
&__remaining {
opacity: 0.7;
text-align: right;
}
&__cancel {
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}
}
@ -289,6 +359,18 @@ onMounted(() => {
border-radius: 0 0 8px 8px;
max-height: 185px;
overflow-y: auto;
&__completed {
cursor: pointer;
&:hover {
background-color: var(--c-grey-1);
}
&:active {
background-color: var(--c-grey-2);
}
}
}
}

View File

@ -94,7 +94,7 @@ const progressStyle = computed((): Record<string, string> => {
*/
async function retryUpload(): Promise<void> {
try {
await obStore.retryUpload(props.item);
await obStore.retryUpload(props.item.Key);
} catch (error) {
notify.error(error.message, AnalyticsErrorEventSource.OBJECTS_UPLOAD_MODAL);
}
@ -157,7 +157,6 @@ function cancelUpload(): void {
}
&__name {
font-family: 'font_medium', sans-serif;
font-size: 14px;
line-height: 20px;
color: var(--c-grey-9);
@ -173,7 +172,8 @@ function cancelUpload(): void {
margin-left: 20px;
svg {
min-width: 20px;
width: 20px;
height: 20px;
}
&__track {
@ -200,14 +200,13 @@ function cancelUpload(): void {
}
&__cancelled {
font-family: 'font_medium', sans-serif;
font-size: 14px;
line-height: 20px;
color: var(--c-grey-5);
text-align: right;
}
&__failed {
font-family: 'font_medium', sans-serif;
font-size: 14px;
line-height: 20px;
color: var(--c-red-4);
@ -215,12 +214,15 @@ function cancelUpload(): void {
}
&__retry {
font-family: 'font_medium', sans-serif;
font-size: 14px;
line-height: 20px;
color: var(--c-blue-3);
margin-left: 18px;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
&__info {

View File

@ -15,7 +15,7 @@ import {
GetObjectCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { Upload } from '@aws-sdk/lib-storage';
import { Progress, Upload } from '@aws-sdk/lib-storage';
import { SignatureV4 } from '@aws-sdk/signature-v4';
import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames';
@ -322,6 +322,7 @@ export const useObjectBrowserStore = defineStore('objectBrowser', () => {
assertIsInitialized(state);
type Item = DataTransferItem | FileSystemEntry;
type TraverseResult = { path: string, file: File };
const items: Item[] = 'dataTransfer' in e && e.dataTransfer
? [...e.dataTransfer.items]
@ -329,7 +330,7 @@ export const useObjectBrowserStore = defineStore('objectBrowser', () => {
? ((e.target as unknown) as { files: FileSystemEntry[] }).files
: [];
async function* traverse(item: Item | Item[], path = '') {
async function* traverse(item: Item | Item[], path = ''): AsyncGenerator<TraverseResult, void, void> {
if ('isFile' in item && item.isFile) {
const file = await new Promise(item.file.bind(item));
yield { path, file };
@ -394,191 +395,159 @@ export const useObjectBrowserStore = defineStore('objectBrowser', () => {
return fileName;
}
const appStore = useAppStore();
const config = useConfigStore();
const { notifyError } = useNotificationsStore();
for await (const { path, file } of traverse(iterator)) {
const directories = path.split('/');
directories[0] = getUniqueFileName(directories[0]);
const fileName = getUniqueFileName(directories.join('/') + file.name);
const key = state.path + fileName;
const params = {
Bucket: state.bucket,
Key: state.path + fileName,
Body: file,
};
if (config.state.config.newUploadModalEnabled) {
if (state.uploading.some(f => f.Key === params.Key && f.status === UploadingStatus.InProgress)) {
notifyError({ message: `${params.Key} is already uploading`, source: AnalyticsErrorEventSource.OBJECT_UPLOAD_ERROR });
continue;
}
}
// If file size exceeds 1 GB, show warning notification
if (file.size > (1024 * 1024 * 1024)) {
appStore.setLargeUploadWarningNotification(true);
}
const upload = new Upload({
client: state.s3,
partSize: 64 * 1024 * 1024,
params,
});
upload.on('httpUploadProgress', async (progress) => {
const file = state.uploading.find(file => file.Key === params.Key);
if (!file) {
throw new Error(`No file found with key ${JSON.stringify(params.Key)}`);
}
let p = 0;
if (progress.loaded && progress.total) {
p = Math.round((progress.loaded / progress.total) * 100);
}
file.progress = p;
});
if (config.state.config.newUploadModalEnabled) {
if (state.uploading.some(f => f.Key === params.Key && f.status === UploadingStatus.Cancelled)) {
state.uploading = state.uploading.filter(f => f.Key !== params.Key);
}
// If file size exceeds 30 GB, abort the upload attempt
if (file.size > (30 * 1024 * 1024 * 1024)) {
state.uploading.push({
...params,
upload,
progress: 0,
Size: 0,
LastModified: new Date(),
Body: file,
status: UploadingStatus.Failed,
failedMessage: FailedUploadMessage.TooBig,
});
appStore.setUploadingModal(true);
continue;
}
}
state.uploading.push({
...params,
upload,
progress: 0,
Size: 0,
LastModified: new Date(),
status: UploadingStatus.InProgress,
});
if (config.state.config.newUploadModalEnabled && !appStore.state.isUploadingModal) {
appStore.setUploadingModal(true);
}
state.uploadChain = state.uploadChain.then(async () => {
const index = state.uploading.findIndex(f => f.Key === params.Key);
if (index === -1) {
// upload cancelled or removed
return;
}
try {
await upload.done();
state.uploading[index].status = UploadingStatus.Finished;
} catch (error) {
handleUploadError(error.message, index);
return;
}
await list();
const uploadedFiles = state.files.filter(f => f.type === 'file');
if (uploadedFiles.length === 1 && !path && state.openModalOnFirstUpload) {
state.objectPathForModal = params.Key;
if (config.state.config.galleryViewEnabled) {
appStore.setGalleryView(true);
} else {
appStore.updateActiveModal(MODALS.objectDetails);
}
}
if (!config.state.config.newUploadModalEnabled) {
state.uploading = state.uploading.filter((file) => file.Key !== params.Key);
}
});
await enqueueUpload(key, file);
}
}
async function retryUpload(item: UploadingBrowserObject): Promise<void> {
async function retryUpload(key: string): Promise<void> {
assertIsInitialized(state);
const index = state.uploading.findIndex(f => f.Key === item.Key);
if (index === -1) {
throw new Error(`No file found with key ${JSON.stringify(item.Key)}`);
const item = state.uploading.find(file => file.Key === key);
if (!item) {
throw new Error(`No uploads found with key '${key}'`);
}
return await enqueueUpload(item.Key, item.Body);
}
async function enqueueUpload(key: string, body: File): Promise<void> {
assertIsInitialized(state);
const appStore = useAppStore();
const config = useConfigStore();
const { notifyError } = useNotificationsStore();
const params = {
Bucket: state.bucket,
Key: item.Key,
Body: item.Body,
Key: key,
Body: body,
};
if (config.state.config.newUploadModalEnabled) {
if (state.uploading.some(f => f.Key === key && f.status === UploadingStatus.InProgress)) {
notifyError({ message: `${key} is already uploading`, source: AnalyticsErrorEventSource.OBJECT_UPLOAD_ERROR });
return;
}
appStore.setUploadingModal(true);
const index = state.uploading.findIndex(file => file.Key === key);
if (index !== -1) {
state.uploading.splice(index, 1);
}
// If file size exceeds 30 GB, abort the upload attempt
if (body.size > (30 * 1024 * 1024 * 1024)) {
state.uploading.push({
...params,
progress: 0,
Size: 0,
LastModified: new Date(),
Body: body,
status: UploadingStatus.Failed,
failedMessage: FailedUploadMessage.TooBig,
});
return;
}
}
// If file size exceeds 1 GB, show warning notification
if (body.size > (1024 * 1024 * 1024)) {
appStore.setLargeUploadWarningNotification(true);
}
const upload = new Upload({
client: state.s3,
partSize: 64 * 1024 * 1024,
params,
});
upload.on('httpUploadProgress', async (progress) => {
const file = state.uploading.find(file => file.Key === params.Key);
if (!file) {
throw new Error(`No file found with key ${JSON.stringify(params.Key)}`);
const progressListener = async (progress: Progress) => {
const item = state.uploading.find(f => f.Key === key);
if (!item) {
upload.off('httpUploadProgress', progressListener);
notifyError({
message: `Error updating progress. No file found with key '${key}'`,
source: AnalyticsErrorEventSource.OBJECT_UPLOAD_ERROR,
});
return;
}
let p = 0;
if (progress.loaded && progress.total) {
p = Math.round((progress.loaded / progress.total) * 100);
}
file.progress = p;
});
item.progress = p;
};
upload.on('httpUploadProgress', progressListener);
state.uploading[index] = {
state.uploading.push({
...params,
upload,
progress: 0,
Size: 0,
LastModified: new Date(),
status: UploadingStatus.InProgress,
};
});
try {
await upload.done();
state.uploading[index].status = UploadingStatus.Finished;
} catch (error) {
handleUploadError(error.message, index);
}
state.uploadChain = state.uploadChain.then(async () => {
const item = state.uploading.find(f => f.Key === key && f.status !== UploadingStatus.Cancelled);
if (!item) return;
await list();
try {
await upload.done();
item.status = UploadingStatus.Finished;
} catch (error) {
handleUploadError(item, error);
return;
} finally {
upload.off('httpUploadProgress', progressListener);
}
await list();
const uploadedFiles = state.files.filter(f => f.type === 'file');
if (uploadedFiles.length === 1 && !key.includes('/') && state.openModalOnFirstUpload) {
state.objectPathForModal = key;
if (config.state.config.galleryViewEnabled) {
appStore.setGalleryView(true);
} else {
appStore.updateActiveModal(MODALS.objectDetails);
}
}
if (!config.state.config.newUploadModalEnabled) {
state.uploading = state.uploading.filter(file => file.Key !== key);
}
});
}
function handleUploadError(message: string, index: number): void {
function handleUploadError(item: UploadingBrowserObject, error: Error): void {
if (error.name === 'AbortError' && item.status === UploadingStatus.Cancelled) return;
const config = useConfigStore();
if (config.state.config.newUploadModalEnabled) {
state.uploading[index].status = UploadingStatus.Failed;
state.uploading[index].failedMessage = FailedUploadMessage.Failed;
item.status = UploadingStatus.Failed;
item.failedMessage = FailedUploadMessage.Failed;
}
const { notifyError } = useNotificationsStore();
const limitExceededError = 'storage limit exceeded';
if (message.includes(limitExceededError)) {
if (error.message.includes(limitExceededError)) {
notifyError({ message: `Error: ${limitExceededError}`, source: AnalyticsErrorEventSource.OBJECT_UPLOAD_ERROR });
} else {
notifyError({ message, source: AnalyticsErrorEventSource.OBJECT_UPLOAD_ERROR });
notifyError({ message: error.message, source: AnalyticsErrorEventSource.OBJECT_UPLOAD_ERROR });
}
}
@ -744,17 +713,13 @@ export const useObjectBrowserStore = defineStore('objectBrowser', () => {
state.openedDropdown = 'FileBrowser';
}
function cancelUpload(key): void {
const index = state.uploading.findIndex(f => f.Key === key);
if (index === -1) {
throw new Error(`File ${JSON.stringify(key)} not found`);
}
const file = state.uploading[index];
if (file.progress !== undefined && file.upload && file.progress > 0) {
file.upload.abort();
state.uploading[index].status = UploadingStatus.Cancelled;
function cancelUpload(key: string): void {
const file = state.uploading.find(f => f.Key === key);
if (!file) {
throw new Error(`File '${key}' not found`);
}
file.upload?.abort();
file.status = UploadingStatus.Cancelled;
}
function sort(headingSorted: string): void {

View File

@ -1,4 +1,4 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="20" height="20" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M20 10C20 15.5228 15.5228 20 10 20C4.47715 20 0 15.5228 0 10C0 4.47715 4.47715 0 10 0C15.5228 0 20 4.47715 20 10ZM11.25 15C11.25 15.6904 10.6904 16.25 10 16.25C9.30964 16.25 8.75 15.6904 8.75 15C8.75 14.3096 9.30964 13.75 10 13.75C10.6904 13.75 11.25 14.3096 11.25 15ZM10 3.75C9.30964 3.75 8.75 4.30964 8.75 5V10C8.75 10.6904 9.30964 11.25 10 11.25C10.6904 11.25 11.25 10.6904 11.25 10V5C11.25 4.30964 10.6904 3.75 10 3.75Z" fill="#BA0000"/>
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path d="m16 8a8 8 0 0 1-8 8 8 8 0 0 1-8-8 8 8 0 0 1 8-8 8 8 0 0 1 8 8z" fill="#ba0000"/>
<path d="m8.002 2.9961a1 1 0 0 0-1.0039 1.0059v4a1 1 0 0 0 1.0039 0.99609 1 1 0 0 0 0.99609-0.99609v-4a1 1 0 0 0-0.99609-1.0059zm-0.0019532 8.0039a1 1 0 0 0-1 1 1 1 0 0 0 1 1 1 1 0 0 0 1-1 1 1 0 0 0-1-1z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 646 B

After

Width:  |  Height:  |  Size: 423 B