Global file upload (#422)

* Add hover style for file drop in private view

* Add uploadFile / uploadFiles utils

* Show drag notification on file dragover

* Finish global file uploads

* Fix warning in drawer detail test

* Use correct terminology

* Ignore drop for non file drops
This commit is contained in:
Rijk van Zanten
2020-04-16 15:58:00 -04:00
committed by GitHub
parent 9139637ee3
commit adc3461de5
11 changed files with 234 additions and 5 deletions

View File

@@ -218,6 +218,11 @@
"has": "Contains some of these keys"
},
"drop_to_upload": "Drop to Upload",
"upload_file_indeterminate": "Uploading File | Uploading files {done}/{total}",
"upload_file_success": "File Uploaded | {count} Files Uploaded",
"upload_file_failed": "Couldn't Upload File | Couldn't Upload Files",
"about_directus": "About Directus",
"activity_log": "Activity Log",
"add_field_filter": "Add a field filter",

View File

@@ -39,6 +39,20 @@ export const useNotificationsStore = createStore({
this.state.queue = this.state.queue.filter((n) => n.id !== id);
this.state.previous = [...this.state.previous, toBeRemoved];
},
update(id: string, updates: Partial<Notification>) {
this.state.queue = this.state.queue.map(updateIfNeeded);
this.state.previous = this.state.queue.map(updateIfNeeded);
function updateIfNeeded(notification: Notification) {
if (notification.id === id) {
return {
...notification,
...updates,
};
}
return notification;
}
}
},
getters: {
lastFour(state) {

View File

@@ -7,6 +7,9 @@ export interface NotificationRaw {
text?: string | VueI18n.TranslateResult;
type?: 'info' | 'success' | 'warning' | 'error';
icon?: string | null;
closeable?: boolean;
progress?: number;
loading?: boolean;
}
export interface Notification extends NotificationRaw {

View File

@@ -0,0 +1,4 @@
import uploadFile from './upload-file';
export { uploadFile };
export default uploadFile;

View File

@@ -0,0 +1,37 @@
import api from '@/api';
import useProjectsStore from '@/stores/projects';
import notify from '@/utils/notify';
import i18n from '@/lang';
export default async function uploadFile(
file: File,
onProgressChange?: (percentage: number) => void,
showNotifications = true
) {
const progressHandler = onProgressChange || (() => undefined);
const currentProjectKey = useProjectsStore().state.currentProjectKey;
const formData = new FormData();
formData.append('file', file);
try {
await api.post(`/${currentProjectKey}/files`, formData, { onUploadProgress });
if (showNotifications) {
notify({
title: i18n.tc('upload_file_success', 0),
type: 'success',
});
}
} catch (error) {
if (showNotifications) {
notify({
title: i18n.tc('upload_file_failed', 0),
});
}
}
function onUploadProgress(progressEvent: { loaded: number; total: number }) {
const percentCompleted = Math.floor((progressEvent.loaded * 100) / progressEvent.total);
progressHandler(percentCompleted);
}
}

View File

@@ -0,0 +1,4 @@
import uploadFiles from './upload-files';
export { uploadFiles };
export default uploadFiles;

View File

@@ -0,0 +1,36 @@
import uploadFile from '@/utils/upload-file';
import notify from '@/utils/notify';
import i18n from '@/lang';
export default async function uploadFiles(
files: File[],
onProgressChange?: (percentages: number[]) => void
) {
const progressHandler = onProgressChange || (() => undefined);
const progressForFiles = files.map(() => 0);
try {
await Promise.all(
files.map((file, index) =>
uploadFile(
file,
(percentage: number) => {
progressForFiles[index] = percentage;
progressHandler(progressForFiles);
},
false
)
)
);
notify({
title: i18n.tc('upload_file_success', files.length, { count: files.length }),
type: 'success',
});
} catch (error) {
notify({
title: i18n.tc('upload_file_failed', files.length, { count: files.length }),
type: 'error',
});
}
}

View File

@@ -5,12 +5,14 @@ import * as GroupableComposition from '@/compositions/groupable/groupable';
import VIcon from '@/components/v-icon';
import VDivider from '@/components/v-divider';
import TransitionExpand from '@/components/transition/expand';
import VBadge from '@/components/v-badge/';
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
localVue.component('v-icon', VIcon);
localVue.component('transition-expand', TransitionExpand);
localVue.component('v-divider', VDivider);
localVue.component('v-badge', VBadge);
describe('Drawer Detail', () => {
it('Uses the useGroupable composition', () => {

View File

@@ -1,7 +1,9 @@
<template>
<div class="notification-item" :class="[type, { tail, dense }]" @click="close">
<div class="icon" v-if="icon">
<v-icon :name="icon" />
<div class="icon" v-if="loading || progress || icon">
<v-progress-circular indeterminate small v-if="loading" />
<v-progress-circular small v-else-if="progress" :value="progress" />
<v-icon v-else :name="icon" />
</div>
<div class="content">
@@ -52,6 +54,14 @@ export default defineComponent({
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: false,
},
progress: {
type: Number,
default: undefined,
},
},
setup(props) {
const notificationsStore = useNotificationsStore();
@@ -185,4 +195,9 @@ export default defineComponent({
}
}
}
.v-progress-circular {
--v-progress-circular-color: var(--foreground-inverted);
--v-progress-circular-background-color: transparent;
}
</style>

View File

@@ -1,12 +1,13 @@
<template>
<transition-group class="notifications-group" name="slide-fade" tag="div">
<slot />
<notification-item
v-for="(notification, index) in queue"
:key="notification.id"
v-bind="notification"
:tail="index === queue.length - 1"
dense
:show-close="notification.persist === true"
:show-close="notification.persist === true && notification.closeable !== false"
/>
</transition-group>
</template>
@@ -39,7 +40,8 @@ export default defineComponent({
width: 280px;
direction: rtl;
> * {
> *,
::v-deep > * {
direction: ltr;
}

View File

@@ -1,5 +1,12 @@
<template>
<div class="private-view" :class="theme">
<div
class="private-view"
:class="{ theme, 'drop-effect': showDropEffect }"
@dragenter.prevent="onDragEnter"
@dragover.prevent
@dragleave.prevent="onDragLeave"
@drop.prevent="onDrop"
>
<aside
role="navigation"
aria-label="Module Navigation"
@@ -74,6 +81,10 @@ import DrawerButton from './components/drawer-button/';
import NotificationsGroup from './components/notifications-group/';
import NotificationsPreview from './components/notifications-preview/';
import useUserStore from '@/stores/user';
import NotificationItem from './components/notification-item';
import useNotificationsStore from '@/stores/notifications';
import uploadFiles from '@/utils/upload-files';
import i18n from '@/lang';
export default defineComponent({
components: {
@@ -84,6 +95,7 @@ export default defineComponent({
DrawerButton,
NotificationsGroup,
NotificationsPreview,
NotificationItem,
},
props: {
title: {
@@ -97,6 +109,7 @@ export default defineComponent({
const contentEl = ref<Element>();
const navigationsInline = ref(false);
const userStore = useUserStore();
const notificationsStore = useNotificationsStore();
const theme = computed(() => {
return userStore.state.currentUser?.theme || 'auto';
@@ -111,13 +124,96 @@ export default defineComponent({
provide('drawer-open', drawerOpen);
provide('main-element', contentEl);
const { onDragEnter, onDragLeave, onDrop, showDropEffect } = useFileUpload();
return {
navOpen,
drawerOpen,
contentEl,
navigationsInline,
theme,
onDragEnter,
onDragLeave,
showDropEffect,
onDrop,
};
function useFileUpload() {
const showDropEffect = ref(false);
let dragNotificationID: string;
let fileUploadNotificationID: string;
return { onDragEnter, onDragLeave, onDrop, showDropEffect };
function onDragEnter(event: DragEvent) {
if (
event.dataTransfer?.types.indexOf('Files') !== -1 &&
showDropEffect.value === false
) {
showDropEffect.value = true;
dragNotificationID = notificationsStore.add({
title: i18n.t('drop_to_upload'),
icon: 'cloud_upload',
type: 'info',
persist: true,
closeable: false,
});
}
}
function onDragLeave() {
showDropEffect.value = false;
if (dragNotificationID) {
notificationsStore.remove(dragNotificationID);
}
}
async function onDrop(event: DragEvent) {
showDropEffect.value = false;
if (!event.dataTransfer) return;
if (event.dataTransfer?.types.indexOf('Files') === -1) return;
if (dragNotificationID) {
notificationsStore.remove(dragNotificationID);
}
const files = [...event.dataTransfer.files];
fileUploadNotificationID = notificationsStore.add({
title: i18n.tc('upload_file_indeterminate', files.length, {
done: 0,
total: files.length,
}),
type: 'info',
persist: true,
closeable: false,
loading: true,
});
await uploadFiles(files, (progress) => {
const percentageDone =
progress.reduce((val, cur) => (val += cur)) / progress.length;
const total = files.length;
const done = progress.filter((p) => p === 100).length;
notificationsStore.update(fileUploadNotificationID, {
title: i18n.tc('upload_file_indeterminate', files.length, {
done,
total,
}),
loading: false,
progress: percentageDone,
});
});
notificationsStore.remove(fileUploadNotificationID);
}
}
},
});
</script>
@@ -243,6 +339,17 @@ export default defineComponent({
}
}
&.drop-effect::after {
position: fixed;
top: 0;
left: 0;
z-index: 500;
width: calc(100% - 8px);
height: calc(100% - 8px);
border: 4px solid var(--primary);
content: '';
}
@include breakpoint(small) {
--content-padding: 32px 32px 160px 32px;
}