Interface single file (#605)

* Start on file interface

* Add preview icon

* Add readable mimetypes to extension

* Add file interface strings

* Fix type errors

* Add file select / deselect

* Add new file upload support

* Add import from url

* Fix loading state of url import

* Add filename in download attribute

* Replace option names with replace when file exists

* Remove / fix outdated tests
This commit is contained in:
Rijk van Zanten
2020-05-21 16:47:01 -04:00
committed by GitHub
parent 7b2cb9d329
commit 0347426e12
11 changed files with 441 additions and 110 deletions

View File

@@ -15,6 +15,8 @@
disabled,
dashed,
}"
:href="href"
:download="download"
v-on="disabled === false && $listeners"
>
<slot></slot>
@@ -39,6 +41,10 @@ export default defineComponent({
type: [String, Object] as PropType<string | Location>,
default: null,
},
href: {
type: String,
default: null,
},
disabled: {
type: Boolean,
default: false,
@@ -55,10 +61,22 @@ export default defineComponent({
type: Boolean,
default: false,
},
download: {
type: String,
default: null,
},
},
setup(props, { listeners }) {
const component = computed<string>(() => (props.to ? 'router-link' : 'li'));
const isClickable = computed(() => Boolean(props.to || listeners.click !== undefined));
const component = computed<string>(() => {
if (props.to) return 'router-link';
if (props.href) return 'a';
return 'li';
});
const isClickable = computed(() =>
Boolean(props.to || props.href || listeners.click !== undefined)
);
return { component, isClickable };
},
});

View File

@@ -0,0 +1,310 @@
<template>
<div class="file">
<v-menu attached close-on-content-click :disabled="disabled || loading">
<template #activator="{ toggle }">
<div>
<v-skeleton-loader type="input" v-if="loading" />
<v-input
v-else
@click="toggle"
readonly
:placeholder="$t('no_file_selected')"
:disabled="disabled"
:value="file && file.title"
>
<template #prepend>
<div class="preview" :class="{ 'has-file': file }">
<img
v-if="imageThumbnail"
:src="imageThumbnail"
:alt="file.title"
/>
<span class="extension" v-else-if="fileExtension">
{{ fileExtension }}
</span>
<v-icon v-else name="folder_open" />
</div>
</template>
<template #append>
<v-icon
class="deselect"
name="close"
v-if="file"
@click.stop="$emit('input', null)"
/>
<v-icon v-else name="attach_file" />
</template>
</v-input>
</div>
</template>
<v-list dense>
<template v-if="file">
<v-list-item :download="file.filename_download" :href="file.data.asset_url">
<v-list-item-icon><v-icon name="file_download" /></v-list-item-icon>
<v-list-item-content>{{ $t('download_file') }}</v-list-item-content>
</v-list-item>
<v-divider />
</template>
<v-list-item @click="activeDialog = 'upload'">
<v-list-item-icon><v-icon name="phonelink" /></v-list-item-icon>
<v-list-item-content>
{{ $t(file ? 'replace_from_device' : 'upload_from_device') }}
</v-list-item-content>
</v-list-item>
<v-list-item @click="activeDialog = 'choose'">
<v-list-item-icon><v-icon name="folder_open" /></v-list-item-icon>
<v-list-item-content>
{{ $t(file ? 'replace_from_library' : 'choose_from_library') }}
</v-list-item-content>
</v-list-item>
<v-list-item @click="activeDialog = 'url'">
<v-list-item-icon><v-icon name="link" /></v-list-item-icon>
<v-list-item-content>
{{ $t(file ? 'replace_from_url' : 'import_from_url') }}
</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
<v-dialog :active="activeDialog === 'upload'" @toggle="activeDialog = null">
<v-card>
<v-card-title>{{ $t('upload_from_device') }}</v-card-title>
<v-card-text>
<v-upload @upload="onUpload" />
</v-card-text>
<v-card-actions>
<v-button @click="activeDialog = null" secondary>{{ $t('cancel') }}</v-button>
</v-card-actions>
</v-card>
</v-dialog>
<modal-browse
collection="directus_files"
:active="activeDialog === 'choose'"
@update:active="activeDialog = null"
@input="setSelection"
/>
<v-dialog
:active="activeDialog === 'url'"
@toggle="activeDialog = null"
:persistent="urlLoading"
>
<v-card>
<v-card-title>{{ $t('import_from_url') }}</v-card-title>
<v-card-text>
<v-input :placeholder="$t('url')" v-model="url" :disabled="urlLoading" />
</v-card-text>
<v-card-actions>
<v-button :disabled="urlLoading" @click="activeDialog = null" secondary>
{{ $t('cancel') }}
</v-button>
<v-button
:loading="urlLoading"
@click="importFromURL"
:disabled="isValidURL === false"
>
{{ $t('import') }}
</v-button>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, watch, computed } from '@vue/composition-api';
import ModalBrowse from '@/views/private/components/modal-browse';
import useProjectsStore from '@/stores/projects/';
import api from '@/api';
import readableMimeType from '@/utils/readable-mime-type';
type FileInfo = {
title: string;
type: string;
data: {
thumbnails?: {
key: string;
url: string;
}[];
};
};
export default defineComponent({
components: { ModalBrowse },
props: {
value: {
type: Number,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
},
setup(props, { emit }) {
const projectsStore = useProjectsStore();
const activeDialog = ref<'upload' | 'choose' | 'url'>(null);
const { loading, error, file, fetchFile } = useFile();
watch(() => props.value, fetchFile);
const fileExtension = computed(() => {
if (file.value === null) return null;
return readableMimeType(file.value.type, true);
});
const imageThumbnail = computed(() => {
if (file.value === null) return null;
if (file.value.type.includes('image') === false) return null;
return file.value.data.thumbnails?.find((thumb) => thumb.key === 'directus-small-crop')
?.url;
});
const {
url,
isValidURL,
loading: urlLoading,
error: urlError,
importFromURL,
} = useURLImport();
return {
activeDialog,
setSelection,
loading,
error,
file,
fileExtension,
imageThumbnail,
onUpload,
url,
urlLoading,
urlError,
importFromURL,
isValidURL,
};
function useFile() {
const loading = ref(false);
const error = ref(null);
const file = ref<FileInfo>(null);
return { loading, error, file, fetchFile };
async function fetchFile() {
if (props.value === null) {
file.value = null;
loading.value = false;
error.value = null;
return;
}
loading.value = true;
const { currentProjectKey } = projectsStore.state;
try {
const response = await api.get(`/${currentProjectKey}/files/${props.value}`, {
params: {
fields: ['title', 'data', 'type', 'filename_download'],
},
});
file.value = response.data.data;
} catch (err) {
error.value = err;
} finally {
loading.value = false;
}
}
}
function setSelection(selection: number[]) {
if (selection[0]) {
emit('input', selection[0]);
} else {
emit('input', null);
}
}
function onUpload(fileInfo: FileInfo) {
file.value = fileInfo;
activeDialog.value = null;
}
function useURLImport() {
const url = ref('');
const loading = ref(false);
const error = ref(null);
const isValidURL = computed(() => {
try {
new URL(url.value);
return true;
} catch {
return false;
}
});
return { url, loading, error, isValidURL, importFromURL };
async function importFromURL() {
loading.value = true;
const { currentProjectKey } = projectsStore.state;
try {
const response = await api.post(`/${currentProjectKey}/files`, {
data: url.value,
});
file.value = response.data.data;
activeDialog.value = null;
url.value = '';
} catch (err) {
error.value = err;
} finally {
loading.value = false;
}
}
}
},
});
</script>
<style lang="scss" scoped>
.preview {
--v-icon-color: var(--foreground-subdued);
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
margin-left: -8px;
overflow: hidden;
background-color: var(--background-normal);
border-radius: var(--border-radius);
&.has-file {
background-color: var(--primary-alt);
}
}
.extension {
color: var(--foreground-subdued);
color: var(--primary);
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
}
.deselect:hover {
--v-icon-color: var(--danger);
}
</style>

View File

@@ -0,0 +1,10 @@
import { defineInterface } from '../define';
import InterfaceFile from './file.vue';
export default defineInterface(({ i18n }) => ({
id: 'file',
name: i18n.t('file'),
icon: 'note_add',
component: InterfaceFile,
options: [],
}));

View File

@@ -22,6 +22,7 @@ import InterfaceSlug from './slug';
import InterfaceUser from './user';
import InterfaceTags from './tags';
import InterfaceRepeater from './repeater';
import InterfaceFile from './file';
export const interfaces = [
InterfaceTextInput,
@@ -48,6 +49,7 @@ export const interfaces = [
InterfaceUser,
InterfaceTags,
InterfaceRepeater,
InterfaceFile,
];
export default interfaces;

View File

@@ -139,6 +139,8 @@
"one_to_many": "One to Many (O2M)",
"many_to_one": "Many to One (M2O)",
"original": "Original",
"url": "URL",
"import": "Import",
"file_details": "File Details",
"dimensions": "Dimensions",
@@ -155,6 +157,15 @@
"download": "Download",
"open": "Open",
"upload_from_device": "Upload File from Device",
"choose_from_library": "Choose File from Library",
"import_from_url": "Import File from URL",
"no_file_selected": "No File Selected",
"download_file": "Download File",
"replace_from_device": "Replace File from Device",
"replace_from_library": "Replace File from Library",
"replace_from_url": "Replace File from URL",
"name": "Name",
"primary_key_field": "Primary Key Field",
"type": "Type",

View File

@@ -13,14 +13,17 @@ export interface Project {
project_color: string;
project_logo: {
full_url: string;
asset_url: string;
url: string;
} | null;
project_foreground: {
full_url: string;
asset_url: string;
url: string;
} | null;
project_background: {
full_url: string;
asset_url: string;
url: string;
} | null;
project_public_note: string | null;

View File

@@ -0,0 +1,73 @@
{
"audio/aac": "aac",
"application/x-abiword": "abw",
"application/x-freearc": "arc",
"video/x-msvideo": "avi",
"application/vnd.amazon.ebook": "azw",
"application/octet-stream": "bin",
"image/bmp": "bmp",
"application/x-bzip": "bz",
"application/x-bzip2": "bz2",
"application/x-csh": "csh",
"text/css": "css",
"text/csv": "csv",
"application/msword": "doc",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
"application/vnd.ms-fontobject": "eot",
"application/epub+zip": "epub",
"application/gzip": "gz",
"image/gif": "gif",
"text/html": "html",
"image/vnd.microsoft.icon": "ico",
"text/calendar": "ics",
"application/java-archive": "jar",
"image/jpeg": "jpg",
"text/javascript": "js",
"application/json": "json",
"application/ld+json": "jsonld",
"audio/midi audio/x-midi": "midi",
"audio/mpeg": "mp3",
"video/mpeg": "mpeg",
"application/vnd.apple.installer+xml": "mpkg",
"application/vnd.oasis.opendocument.presentation": "odp",
"application/vnd.oasis.opendocument.spreadsheet": "ods",
"application/vnd.oasis.opendocument.text": "odt",
"audio/ogg": "oga",
"video/ogg": "ogv",
"application/ogg": "ogx",
"audio/opus": "opus",
"font/otf": "otf",
"image/png": "png",
"application/pdf": "pdf",
"application/x-httpd-php": "php",
"application/vnd.ms-powerpoint": "ppt",
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
"application/vnd.rar": "rar",
"application/rtf": "rtf",
"application/x-sh": "sh",
"image/svg+xml": "svg",
"application/x-shockwave-flash": "swf",
"application/x-tar": "tar",
"image/tiff": "tiff",
"video/mp2t": "ts",
"font/ttf": "ttf",
"text/plain": "txt",
"application/vnd.visio": "vsd",
"audio/wav": "wav",
"audio/webm": "weba",
"video/webm": "webm",
"image/webp": "webp",
"font/woff": "woff",
"font/woff2": "woff2",
"application/xhtml+xml": "xhtml",
"application/vnd.ms-excel": "xls",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
"application/xml": "xml",
"application/vnd.mozilla.xul+xml": "xul",
"application/zip": "zip",
"video/3gpp": "3gp",
"video/3gpp2": "3g2",
"audio/3gpp": "3gp",
"audio/3gpp2": "3g2",
"application/x-7z-compressed": "7z"
}

View File

@@ -1,5 +1,10 @@
import types from './types.json';
import extensions from './extensions.json';
export default function readableMimeType(type: string, extension = false) {
if (extension) {
return (extensions as any)[type] || null;
}
export default function readableMimeType(type: string) {
return (types as any)[type] || null;
}

View File

@@ -58,6 +58,8 @@ export const withCustomLogo = () =>
'https://demo.directus.io/uploads/thumper/originals/19acff06-4969-5c75-9cd5-dc3f27506de2.svg',
url:
'/uploads/thumper/originals/19acff06-4969-5c75-9cd5-dc3f27506de2.svg',
asset_url:
'/uploads/thumper/originals/19acff06-4969-5c75-9cd5-dc3f27506de2.svg',
},
project_color: '#4CAF50',
project_foreground: null,

View File

@@ -1,106 +0,0 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueCompositionAPI from '@vue/composition-api';
import ModuleBarLogo from './module-bar-logo.vue';
import { useRequestsStore } from '@/stores/requests';
import { useProjectsStore } from '@/stores/projects';
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
describe('Views / Private / Module Bar / Logo', () => {
it('Renders the default rabbit when we are not in a project', () => {
const component = shallowMount(ModuleBarLogo, { localVue });
expect((component.vm as any).customLogoPath).toBe(null);
});
it('Renders the default rabbit when the current project errored out', () => {
const projectsStore = useProjectsStore({});
projectsStore.currentProject = {
value: {
key: 'my-project',
status: 500,
error: {
code: 400,
message: 'Could not connect to the database',
},
},
};
const component = shallowMount(ModuleBarLogo, { localVue });
expect((component.vm as any).customLogoPath).toBe(null);
});
it('Renders the default rabbit when the current project does not have a custom logo', () => {
const projectsStore = useProjectsStore({});
projectsStore.currentProject = {
value: {
key: 'my-project',
api: {
requires2FA: false,
project_foreground: null,
project_background: null,
project_color: '#abcdef',
project_public_note: '',
default_locale: 'en-US',
telemetry: false,
project_name: 'test',
project_logo: null,
},
},
};
const component = shallowMount(ModuleBarLogo, { localVue });
expect((component.vm as any).customLogoPath).toBe(null);
});
it('Renders the custom logo if set', () => {
const projectsStore = useProjectsStore({});
projectsStore.currentProject = {
value: {
key: 'my-project',
api: {
requires2FA: false,
project_foreground: null,
project_background: null,
project_color: '#abcdef',
project_public_note: '',
default_locale: 'en-US',
telemetry: false,
project_name: 'test',
project_logo: {
full_url: 'abc',
url: 'abc',
},
},
},
};
const component = shallowMount(ModuleBarLogo, { localVue });
expect((component.vm as any).customLogoPath).toBe('abc');
expect(component.find('img').attributes().src).toBe('abc');
});
it('Only stops running if the queue is empty', () => {
const requestsStore = useRequestsStore({});
requestsStore.queueHasItems = { value: false };
let component = shallowMount(ModuleBarLogo, { localVue });
(component.vm as any).isRunning = true;
(component.vm as any).stopRunningIfQueueIsEmpty();
expect((component.vm as any).isRunning).toBe(false);
requestsStore.queueHasItems = { value: true };
component = shallowMount(ModuleBarLogo, { localVue });
expect((component.vm as any).isRunning).toBe(true);
(component.vm as any).stopRunningIfQueueIsEmpty();
expect((component.vm as any).isRunning).toBe(true);
requestsStore.queueHasItems = { value: false };
component = shallowMount(ModuleBarLogo, { localVue });
(component.vm as any).stopRunningIfQueueIsEmpty();
expect((component.vm as any).isRunning).toBe(false);
});
});

View File

@@ -33,17 +33,20 @@ const mockProject: ProjectWithKey = {
full_url:
'http://localhost:8080/uploads/my-project/originals/19acff06-4969-5c75-9cd5-dc3f27506de2.svg',
url: '/uploads/my-project/originals/19acff06-4969-5c75-9cd5-dc3f27506de2.svg',
asset_url: '/uploads/my-project/assets/abc',
},
project_color: '#4CAF50',
project_foreground: {
full_url:
'http://localhost:8080/uploads/my-project/originals/f28c49b0-2b4f-571e-bf62-593107cbf2ec.svg',
url: '/uploads/my-project/originals/f28c49b0-2b4f-571e-bf62-593107cbf2ec.svg',
asset_url: '/uploads/my-project/assets/abc',
},
project_background: {
full_url:
'http://localhost:8080/uploads/my-project/originals/03a06753-6794-4b9a-803b-3e1cd15e0742.jpg',
url: '/uploads/my-project/originals/03a06753-6794-4b9a-803b-3e1cd15e0742.jpg',
asset_url: '/uploads/my-project/assets/abc',
},
telemetry: true,
default_locale: 'en-US',
@@ -125,7 +128,7 @@ describe('Views / Public', () => {
store.state.projects = [mockProject];
store.state.currentProjectKey = 'my-project';
expect((component.vm as any).artStyles).toEqual({
background: `url(${mockProject.api?.project_background?.full_url})`,
background: `url(${mockProject.api?.project_background?.asset_url})`,
backgroundPosition: 'center center',
backgroundSize: 'cover',
});