Add new export experience (#12201)

* Use script setup

* Start on export dialog

* Use new system field interface, replace limit with numeric input

* Set placeholder

* Add sort config

* Use folder picker, correct layoutQuery use

* Add local download button

* Allow writing exports to file

* Add notification after export

* Fix sort config, use new export endpoint

* Setup notification hints

* Add information notice

* Fix local limit, cancel button

* Add (basic) docs for export functionality

* Fix json export file format

* Implement xml batch stitching

* Resolve review points
This commit is contained in:
Rijk van Zanten
2022-03-17 15:43:45 -04:00
committed by GitHub
parent aca9ff9709
commit 1c3e94d830
25 changed files with 1095 additions and 416 deletions

View File

@@ -51,45 +51,177 @@
<v-divider />
<div class="field full">
<p class="type-label">{{ t('label_export') }}</p>
<v-select
v-model="format"
:items="[
{
text: t('csv'),
value: 'csv',
},
{
text: t('json'),
value: 'json',
},
{
text: t('xml'),
value: 'xml',
},
]"
/>
<v-checkbox v-model="useFilters" :label="t('use_current_filters_settings')" />
</div>
<div class="field full">
<v-button small full-width @click="exportData">
{{ t('export_data_button') }}
<v-button small full-width @click="exportDialogActive = true">
{{ t('export_items') }}
</v-button>
</div>
</div>
<v-drawer
v-model="exportDialogActive"
:title="t('export_items')"
icon="import_export"
persistent
@esc="exportDialogActive = false"
@cancel="exportDialogActive = false"
>
<template #actions>
<v-button
v-tooltip.bottom="location === 'download' ? t('download_file') : t('start_export')"
rounded
icon
:loading="exporting"
@click="startExport"
>
<v-icon :name="location === 'download' ? 'download' : 'start'" />
</v-button>
</template>
<div class="export-fields">
<div class="field half-left">
<p class="type-label">{{ t('format') }}</p>
<v-select
v-model="format"
:items="[
{
text: t('csv'),
value: 'csv',
},
{
text: t('json'),
value: 'json',
},
{
text: t('xml'),
value: 'xml',
},
]"
/>
</div>
<div class="field half-right">
<p class="type-label">{{ t('limit') }}</p>
<v-input v-model="exportSettings.limit" type="number" :placeholder="t('unlimited')" />
</div>
<div class="field half-left">
<p class="type-label">{{ t('export_location') }}</p>
<v-select
v-model="location"
:disabled="lockedToFiles"
:items="[
{ value: 'download', text: t('download_file') },
{ value: 'files', text: t('file_library') },
]"
/>
</div>
<div class="field half-right">
<p class="type-label">{{ t('folder') }}</p>
<folder-picker v-if="location === 'files'" v-model="folder" />
<v-notice v-else>{{ t('not_available_for_local_downloads') }}</v-notice>
</div>
<v-notice class="full" :type="lockedToFiles ? 'warning' : 'normal'">
<div>
<p>
<template v-if="itemCount === 0">{{ t('exporting_no_items_to_export') }}</template>
<template v-else-if="!exportSettings.limit || (itemCount && exportSettings.limit > itemCount)">
{{
t('exporting_all_items_in_collection', {
total: itemCount ? n(itemCount) : '??',
collection: collectionInfo?.name,
})
}}
</template>
<template v-else-if="itemCount && itemCount > exportSettings.limit">
{{
t('exporting_limited_items_in_collection', {
limit: exportSettings.limit ? n(exportSettings.limit) : '??',
total: itemCount ? n(itemCount) : '??',
collection: collectionInfo?.name,
})
}}
</template>
</p>
<p>
<template v-if="lockedToFiles">
{{ t('exporting_batch_hint_forced', { format }) }}
</template>
<template v-else-if="location === 'files'">
{{ t('exporting_batch_hint', { format }) }}
</template>
<template v-else>
{{ t('exporting_download_hint', { format }) }}
</template>
</p>
</div>
</v-notice>
<v-divider />
<div class="field half-left">
<p class="type-label">{{ t('sort_field') }}</p>
<interface-system-field
:value="sortField"
:collection="collection"
allow-primary-key
@input="sortField = $event"
/>
</div>
<div class="field half-right">
<p class="type-label">{{ t('sort_direction') }}</p>
<v-select
v-model="sortDirection"
:items="[
{ value: 'ASC', text: t('sort_asc') },
{ value: 'DESC', text: t('sort_desc') },
]"
/>
</div>
<div class="field full">
<p class="type-label">{{ t('full_text_search') }}</p>
<v-input v-model="exportSettings.search" :placeholder="t('search')" />
</div>
<div class="field full">
<p class="type-label">{{ t('filter') }}</p>
<interface-system-filter
:value="exportSettings.filter"
:collection-name="collection"
@input="exportSettings.filter = $event"
/>
</div>
<div class="field full">
<p class="type-label">{{ t('field', 2) }}</p>
<interface-system-fields
:value="exportSettings.fields"
:collection-name="collection"
@input="exportSettings.fields = $event"
/>
</div>
</div>
</v-drawer>
</sidebar-detail>
</template>
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { defineComponent, ref, PropType, computed } from 'vue';
import { Filter } from '@directus/shared/types';
<script lang="ts" setup>
import api from '@/api';
import { getRootPath } from '@/utils/get-root-path';
import { useCollectionsStore } from '@/stores/';
import readableMimeType from '@/utils/readable-mime-type';
import { notify } from '@/utils/notify';
import readableMimeType from '@/utils/readable-mime-type';
import { Filter } from '@directus/shared/types';
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useCollection } from '@directus/shared/composables';
import FolderPicker from '@/views/private/components/folder-picker/folder-picker.vue';
import { unexpectedError } from '@/utils/unexpected-error';
import { debounce } from 'lodash';
import { getEndpoint } from '@/utils/get-endpoint';
type LayoutQuery = {
fields?: string[];
@@ -97,177 +229,281 @@ type LayoutQuery = {
limit?: number;
};
export default defineComponent({
props: {
layoutQuery: {
type: Object as PropType<LayoutQuery>,
default: (): LayoutQuery => ({}),
},
filter: {
type: Object as PropType<Filter>,
default: null,
},
search: {
type: String as PropType<string | null>,
default: null,
},
collection: {
type: String,
required: true,
},
},
emits: ['refresh'],
setup(props, { emit }) {
const { t } = useI18n();
interface Props {
collection: string;
layoutQuery?: LayoutQuery;
filter?: Filter;
search?: string;
}
const format = ref('csv');
const useFilters = ref(true);
const collectionsStore = useCollectionsStore();
const collectionName = computed(() => collectionsStore.getCollection(props.collection)?.name);
const props = withDefaults(defineProps<Props>(), {
layoutQuery: undefined,
filter: undefined,
search: undefined,
});
const fileInput = ref<HTMLInputElement | null>(null);
const emit = defineEmits(['refresh']);
const file = ref<File | null>(null);
const { uploading, progress, importing, uploadFile } = useUpload();
const { t, n } = useI18n();
const fileExtension = computed(() => {
if (file.value === null) return null;
return readableMimeType(file.value.type, true);
});
const fileInput = ref<HTMLInputElement | null>(null);
return {
t,
fileInput,
file,
fileExtension,
onChange,
clearFileInput,
importData,
uploading,
progress,
importing,
format,
useFilters,
exportData,
collectionName,
};
const file = ref<File | null>(null);
const { uploading, progress, importing, uploadFile } = useUpload();
function onChange(event: Event) {
const files = (event.target as HTMLInputElement)?.files;
const exportDialogActive = ref(false);
if (files && files.length > 0) {
file.value = files.item(0)!;
}
const fileExtension = computed(() => {
if (file.value === null) return null;
return readableMimeType(file.value.type, true);
});
const { primaryKeyField, fields, info: collectionInfo } = useCollection(props.collection);
const exportSettings = reactive({
limit: props.layoutQuery?.limit ?? 25,
filter: props.filter,
search: props.search,
fields: props.layoutQuery?.fields ?? fields.value?.map((field) => field.field),
sort: props.layoutQuery?.sort?.[0] ?? `${primaryKeyField.value!.field}`,
});
const format = ref('csv');
const location = ref('download');
const folder = ref<string>();
const lockedToFiles = computed(() => {
const toBeDownloaded = exportSettings.limit ?? itemCount.value;
return toBeDownloaded >= 2500;
});
watch(
() => exportSettings.limit,
() => {
if (lockedToFiles.value) {
location.value = 'files';
}
}
);
function clearFileInput() {
if (fileInput.value) fileInput.value.value = '';
file.value = null;
}
const itemCount = ref<number>();
const itemCountLoading = ref(false);
function importData() {
uploadFile(file.value!);
}
const getItemCount = debounce(async () => {
itemCountLoading.value = true;
function useUpload() {
const uploading = ref(false);
const importing = ref(false);
const progress = ref(0);
return { uploading, progress, importing, uploadFile };
async function uploadFile(file: File) {
uploading.value = true;
importing.value = false;
progress.value = 0;
const formData = new FormData();
formData.append('file', file);
try {
await api.post(`/utils/import/${props.collection}`, formData, {
onUploadProgress: (progressEvent: ProgressEvent) => {
const percentCompleted = Math.floor((progressEvent.loaded * 100) / progressEvent.total);
progress.value = percentCompleted;
importing.value = percentCompleted === 100 ? true : false;
},
});
clearFileInput();
emit('refresh');
notify({
title: t('import_data_success', { filename: file.name }),
});
} catch (err: any) {
notify({
title: t('import_data_error'),
type: 'error',
});
} finally {
uploading.value = false;
importing.value = false;
progress.value = 0;
try {
const count = await api
.get(getEndpoint(props.collection), {
params: {
...exportSettings,
aggregate: {
count: ['*'],
},
},
})
.then((response) => {
if (response.data.data?.[0]?.count) {
return Number(response.data.data[0].count);
}
}
}
function exportData() {
const endpoint = props.collection.startsWith('directus_')
? `${props.collection.substring(9)}`
: `items/${props.collection}`;
const url = getRootPath() + endpoint;
let params: Record<string, unknown> = {
access_token: api.defaults.headers.common['Authorization'].substring(7),
export: format.value || 'json',
};
if (useFilters.value === true) {
if (props.layoutQuery?.sort) params.sort = props.layoutQuery.sort;
if (props.layoutQuery?.fields) params.fields = props.layoutQuery.fields;
if (props.layoutQuery?.limit) params.limit = props.layoutQuery.limit;
if (props.search) params.search = props.search;
if (props.filter) {
params.filter = props.filter;
}
if (props.search) {
params.search = props.search;
}
}
const exportUrl = api.getUri({
url,
params,
});
window.open(exportUrl);
itemCount.value = count;
} finally {
itemCountLoading.value = false;
}
}, 250);
getItemCount();
watch(exportSettings, () => {
getItemCount();
});
const sortDirection = computed({
get() {
return exportSettings.sort.startsWith('-') ? 'DESC' : 'ASC';
},
set(newDirection: 'ASC' | 'DESC') {
if (newDirection === 'ASC') {
if (exportSettings.sort.startsWith('-')) {
exportSettings.sort = exportSettings.sort.substring(1);
}
} else {
if (exportSettings.sort.startsWith('-') === false) {
exportSettings.sort = `-${exportSettings.sort}`;
}
}
},
});
const sortField = computed({
get() {
if (exportSettings.sort.startsWith('-')) return exportSettings.sort.substring(1);
return exportSettings.sort;
},
set(newSortField: string) {
exportSettings.sort = newSortField;
},
});
const exporting = ref(false);
function onChange(event: Event) {
const files = (event.target as HTMLInputElement)?.files;
if (files && files.length > 0) {
file.value = files.item(0)!;
}
}
function clearFileInput() {
if (fileInput.value) fileInput.value.value = '';
file.value = null;
}
function importData() {
uploadFile(file.value!);
}
function useUpload() {
const uploading = ref(false);
const importing = ref(false);
const progress = ref(0);
return { uploading, progress, importing, uploadFile };
async function uploadFile(file: File) {
uploading.value = true;
importing.value = false;
progress.value = 0;
const formData = new FormData();
formData.append('file', file);
try {
await api.post(`/utils/import/${props.collection}`, formData, {
onUploadProgress: (progressEvent: ProgressEvent) => {
const percentCompleted = Math.floor((progressEvent.loaded * 100) / progressEvent.total);
progress.value = percentCompleted;
importing.value = percentCompleted === 100 ? true : false;
},
});
clearFileInput();
emit('refresh');
notify({
title: t('import_data_success', { filename: file.name }),
});
} catch (err: any) {
notify({
title: t('import_data_error'),
type: 'error',
});
} finally {
uploading.value = false;
importing.value = false;
progress.value = 0;
}
}
}
function startExport() {
if (location.value === 'download') {
exportDataLocal();
} else {
exportDataFiles();
}
}
function exportDataLocal() {
const endpoint = props.collection.startsWith('directus_')
? `${props.collection.substring(9)}`
: `items/${props.collection}`;
const url = getRootPath() + endpoint;
let params: Record<string, unknown> = {
access_token: api.defaults.headers.common['Authorization'].substring(7),
export: format.value,
};
if (exportSettings.sort) params.sort = exportSettings.sort;
if (exportSettings.fields) params.fields = exportSettings.fields;
if (exportSettings.limit) params.limit = exportSettings.limit;
if (exportSettings.search) params.search = exportSettings.search;
if (exportSettings.filter) params.filter = exportSettings.filter;
if (exportSettings.search) params.search = exportSettings.search;
const exportUrl = api.getUri({
url,
params,
});
window.open(exportUrl);
}
async function exportDataFiles() {
exporting.value = true;
try {
await api.post(`/utils/export/${props.collection}`, {
query: {
...exportSettings,
sort: [exportSettings.sort],
},
format: format.value,
file: {
folder: folder.value,
},
});
exportDialogActive.value = false;
notify({
title: t('export_started'),
text: t('export_started_copy'),
type: 'success',
icon: 'file_download',
});
} catch (err: any) {
unexpectedError(err);
} finally {
exporting.value = false;
}
}
</script>
<style lang="scss" scoped>
@import '@/styles/mixins/form-grid';
.fields {
--form-vertical-gap: 24px;
.fields,
.export-fields {
@include form-grid;
.type-label {
font-size: 1rem;
}
.v-divider {
grid-column: 1 / span 2;
}
}
.fields {
--form-vertical-gap: 24px;
.type-label {
font-size: 1rem;
}
}
.export-fields {
--folder-picker-background-color: var(--background-subdued);
--folder-picker-color: var(--background-normal);
margin-top: 24px;
padding: var(--content-padding);
}
.v-checkbox {
width: 100%;
margin-top: 8px;

View File

@@ -0,0 +1,73 @@
<template>
<div class="folder-picker-list-item">
<v-list-item
v-if="folder.children.length === 0"
clickable
:active="currentFolder === folder.id"
:disabled="disabled"
@click="clickHandler(folder.id)"
>
<v-list-item-icon><v-icon :name="currentFolder === folder.id ? 'folder_open' : 'folder'" /></v-list-item-icon>
<v-list-item-content>{{ folder.name }}</v-list-item-content>
</v-list-item>
<v-list-group
v-else
clickable
:active="currentFolder === folder.id"
:disabled="disabled"
@click="clickHandler(folder.id)"
>
<template #activator>
<v-list-item-icon>
<v-icon :name="currentFolder === folder.id ? 'folder_open' : 'folder'" />
</v-list-item-icon>
<v-list-item-content>{{ folder.name }}</v-list-item-content>
</template>
<folder-picker-list-item
v-for="childFolder in folder.children"
:key="childFolder.id"
:folder="childFolder"
:current-folder="currentFolder"
:click-handler="clickHandler"
:disabled="disabledFolders.includes(childFolder.id)"
:disabled-folders="disabledFolders"
/>
</v-list-group>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue';
type Folder = {
id: string;
name: string;
children: Folder[];
};
export default defineComponent({
name: 'FolderPickerListItem',
props: {
folder: {
type: Object as PropType<Folder>,
required: true,
},
currentFolder: {
type: String,
default: null,
},
clickHandler: {
type: Function,
default: () => undefined,
},
disabled: {
type: Boolean,
default: false,
},
disabledFolders: {
type: Array as PropType<string[]>,
default: () => [],
},
},
});
</script>

View File

@@ -0,0 +1,167 @@
<template>
<v-skeleton-loader v-if="loading" />
<div v-else class="folder-picker">
<v-list>
<v-item-group v-model="openFolders" scope="folder-picker" multiple>
<v-list-group
disable-groupable-parent
clickable
:active="modelValue === null"
scope="folder-picker"
value="root"
@click="$emit('update:modelValue', null)"
>
<template #activator>
<v-list-item-icon>
<v-icon name="folder_special" outline />
</v-list-item-icon>
<v-list-item-content>{{ t('file_library') }}</v-list-item-content>
</template>
<folder-picker-list-item
v-for="folder in tree"
:key="folder.id"
:folder="folder"
:current-folder="modelValue"
:click-handler="(id) => $emit('update:modelValue', id)"
:disabled="disabledFolders.includes(folder.id)"
:disabled-folders="disabledFolders"
/>
</v-list-group>
</v-item-group>
</v-list>
</div>
</template>
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { defineComponent, ref, computed, PropType } from 'vue';
import api from '@/api';
import FolderPickerListItem from './folder-picker-list-item.vue';
import { unexpectedError } from '@/utils/unexpected-error';
type FolderRaw = {
id: string;
name: string;
parent: null | string;
};
type Folder = {
id: string;
name: string;
children: Folder[];
};
export default defineComponent({
components: { FolderPickerListItem },
props: {
disabledFolders: {
type: Array as PropType<string[]>,
default: () => [],
},
modelValue: {
type: String,
default: null,
},
},
emits: ['update:modelValue'],
setup(props) {
const { t } = useI18n();
const loading = ref(false);
const folders = ref<FolderRaw[]>([]);
const tree = computed<Folder[]>(() => {
return folders.value
.filter((folder) => folder.parent === null)
.map((folder) => {
return {
...folder,
children: getChildFolders(folder),
};
});
function getChildFolders(folder: FolderRaw): Folder[] {
return folders.value
.filter((childFolder) => {
return childFolder.parent === folder.id;
})
.map((childFolder) => {
return {
...childFolder,
children: getChildFolders(childFolder),
};
});
}
});
const shouldBeOpen: string[] = [];
const folder = folders.value.find((folder) => folder.id === props.modelValue);
if (folder && folder.parent) parseFolder(folder.parent);
const startOpenFolders = ['root'];
for (const folderID of shouldBeOpen) {
if (startOpenFolders.includes(folderID) === false) {
startOpenFolders.push(folderID);
}
}
const selectedFolder = computed(() => {
return folders.value.find((folder) => folder.id === props.modelValue) || {};
});
const openFolders = ref(startOpenFolders);
fetchFolders();
return { t, loading, folders, tree, selectedFolder, openFolders };
async function fetchFolders() {
if (folders.value.length > 0) return;
loading.value = true;
try {
const response = await api.get(`/folders`, {
params: {
limit: -1,
sort: 'name',
},
});
folders.value = response.data.data;
} catch (err: any) {
unexpectedError(err);
} finally {
loading.value = false;
}
}
function parseFolder(id: string) {
if (!folders.value) return;
shouldBeOpen.push(id);
const folder = folders.value.find((folder) => folder.id === id);
if (folder && folder.parent) {
parseFolder(folder.parent);
}
}
},
});
</script>
<style lang="scss" scoped>
:global(body) {
--folder-picker-background-color: var(--background-normal);
--folder-picker-color: var(--background-normal-alt);
}
.folder-picker {
--v-list-item-background-color-hover: var(--folder-picker-color);
--v-list-item-background-color-active: var(--folder-picker-color);
padding: 12px;
background-color: var(--folder-picker-background-color);
border-radius: var(--border-radius);
}
</style>