mirror of
https://github.com/directus/directus.git
synced 2026-02-16 14:05:17 -05:00
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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
167
app/src/views/private/components/folder-picker/folder-picker.vue
Normal file
167
app/src/views/private/components/folder-picker/folder-picker.vue
Normal 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>
|
||||
Reference in New Issue
Block a user