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

@@ -153,6 +153,7 @@
"sharp": "^0.29.0",
"stream-json": "^1.7.1",
"supertest": "^6.1.6",
"tmp-promise": "^3.0.3",
"update-check": "^1.5.4",
"uuid": "^8.3.2",
"uuid-validate": "0.0.3",

View File

@@ -10,7 +10,7 @@ import {
} from '../exceptions';
import collectionExists from '../middleware/collection-exists';
import { respond } from '../middleware/respond';
import { RevisionsService, UtilsService, ImportService } from '../services';
import { RevisionsService, UtilsService, ImportService, ExportService } from '../services';
import asyncHandler from '../utils/async-handler';
import Busboy, { BusboyHeaders } from 'busboy';
import { flushCaches } from '../cache';
@@ -136,6 +136,33 @@ router.post(
})
);
router.post(
'/export/:collection',
collectionExists,
asyncHandler(async (req, res, next) => {
if (!req.body.query) {
throw new InvalidPayloadException(`"query" is required.`);
}
if (!req.body.format) {
throw new InvalidPayloadException(`"format" is required.`);
}
const service = new ExportService({
accountability: req.accountability,
schema: req.schema,
});
// We're not awaiting this, as it's supposed to run async in the background
service.exportToFile(req.params.collection, req.body.query, req.body.format, {
file: req.body.file,
});
return next();
}),
respond
);
router.post(
'/cache/clear',
asyncHandler(async (req, res) => {

View File

@@ -82,6 +82,8 @@ const defaults: Record<string, any> = {
SERVE_APP: true,
RELATIONAL_BATCH_SIZE: 25000,
EXPORT_BATCH_SIZE: 5000,
};
// Allows us to force certain environment variable into a type, instead of relying

View File

@@ -1,14 +1,13 @@
import { RequestHandler } from 'express';
import { Transform, transforms } from 'json2csv';
import ms from 'ms';
import { PassThrough } from 'stream';
import { getCache } from '../cache';
import env from '../env';
import asyncHandler from '../utils/async-handler';
import { getCacheKey } from '../utils/get-cache-key';
import { parse as toXML } from 'js2xmlparser';
import { getCacheControlHeader } from '../utils/get-cache-headers';
import logger from '../logger';
import { ExportService } from '../services';
import { getDateFormatted } from '../utils/get-date-formatted';
export const respond: RequestHandler = asyncHandler(async (req, res) => {
const { cache } = getCache();
@@ -38,6 +37,8 @@ export const respond: RequestHandler = asyncHandler(async (req, res) => {
}
if (req.sanitizedQuery.export) {
const exportService = new ExportService({ accountability: req.accountability, schema: req.schema });
let filename = '';
if (req.collection) {
@@ -51,30 +52,19 @@ export const respond: RequestHandler = asyncHandler(async (req, res) => {
if (req.sanitizedQuery.export === 'json') {
res.attachment(`${filename}.json`);
res.set('Content-Type', 'application/json');
return res.status(200).send(JSON.stringify(res.locals.payload?.data || null, null, '\t'));
return res.status(200).send(exportService.transform(res.locals.payload?.data, 'json'));
}
if (req.sanitizedQuery.export === 'xml') {
res.attachment(`${filename}.xml`);
res.set('Content-Type', 'text/xml');
return res.status(200).send(toXML('data', res.locals.payload?.data));
return res.status(200).send(exportService.transform(res.locals.payload?.data, 'xml'));
}
if (req.sanitizedQuery.export === 'csv') {
res.attachment(`${filename}.csv`);
res.set('Content-Type', 'text/csv');
const stream = new PassThrough();
if (!res.locals.payload?.data || res.locals.payload.data.length === 0) {
stream.end(Buffer.from(''));
return stream.pipe(res);
} else {
stream.end(Buffer.from(JSON.stringify(res.locals.payload.data), 'utf-8'));
const json2csv = new Transform({
transforms: [transforms.flatten({ separator: '.' })],
});
return stream.pipe(json2csv).pipe(res);
}
return res.status(200).send(exportService.transform(res.locals.payload?.data, 'csv'));
}
}
@@ -86,15 +76,3 @@ export const respond: RequestHandler = asyncHandler(async (req, res) => {
return res.status(204).end();
}
});
function getDateFormatted() {
const date = new Date();
let month = String(date.getMonth() + 1);
if (month.length === 1) month = '0' + month;
let day = String(date.getDate());
if (day.length === 1) day = '0' + day;
return `${date.getFullYear()}-${month}-${day} at ${date.getHours()}.${date.getMinutes()}.${date.getSeconds()}`;
}

View File

@@ -0,0 +1,345 @@
import { Knex } from 'knex';
import getDatabase from '../database';
import { AbstractServiceOptions, File } from '../types';
import { Accountability, Query, SchemaOverview } from '@directus/shared/types';
import {
ForbiddenException,
InvalidPayloadException,
ServiceUnavailableException,
UnsupportedMediaTypeException,
} from '../exceptions';
import StreamArray from 'stream-json/streamers/StreamArray';
import { ItemsService } from './items';
import { queue } from 'async';
import destroyStream from 'destroy';
import csv from 'csv-parser';
import { set, transform } from 'lodash';
import { parse as toXML } from 'js2xmlparser';
import { Parser as CSVParser, transforms as CSVTransforms } from 'json2csv';
import { appendFile, createReadStream } from 'fs-extra';
import { file as createTmpFile } from 'tmp-promise';
import env from '../env';
import { FilesService } from './files';
import { getDateFormatted } from '../utils/get-date-formatted';
import { toArray } from '@directus/shared/utils';
import { NotificationsService } from './notifications';
import logger from '../logger';
export class ImportService {
knex: Knex;
accountability: Accountability | null;
schema: SchemaOverview;
constructor(options: AbstractServiceOptions) {
this.knex = options.knex || getDatabase();
this.accountability = options.accountability || null;
this.schema = options.schema;
}
async import(collection: string, mimetype: string, stream: NodeJS.ReadableStream): Promise<void> {
if (collection.startsWith('directus_')) throw new ForbiddenException();
const createPermissions = this.accountability?.permissions?.find(
(permission) => permission.collection === collection && permission.action === 'create'
);
const updatePermissions = this.accountability?.permissions?.find(
(permission) => permission.collection === collection && permission.action === 'update'
);
if (this.accountability?.admin !== true && (!createPermissions || !updatePermissions)) {
throw new ForbiddenException();
}
switch (mimetype) {
case 'application/json':
return await this.importJSON(collection, stream);
case 'text/csv':
case 'application/vnd.ms-excel':
return await this.importCSV(collection, stream);
default:
throw new UnsupportedMediaTypeException(`Can't import files of type "${mimetype}"`);
}
}
importJSON(collection: string, stream: NodeJS.ReadableStream): Promise<void> {
const extractJSON = StreamArray.withParser();
return this.knex.transaction((trx) => {
const service = new ItemsService(collection, {
knex: trx,
schema: this.schema,
accountability: this.accountability,
});
const saveQueue = queue(async (value: Record<string, unknown>) => {
return await service.upsertOne(value);
});
return new Promise<void>((resolve, reject) => {
stream.pipe(extractJSON);
extractJSON.on('data', ({ value }) => {
saveQueue.push(value);
});
extractJSON.on('error', (err) => {
destroyStream(stream);
destroyStream(extractJSON);
reject(new InvalidPayloadException(err.message));
});
saveQueue.error((err) => {
reject(err);
});
extractJSON.on('end', () => {
saveQueue.drain(() => {
return resolve();
});
});
});
});
}
importCSV(collection: string, stream: NodeJS.ReadableStream): Promise<void> {
return this.knex.transaction((trx) => {
const service = new ItemsService(collection, {
knex: trx,
schema: this.schema,
accountability: this.accountability,
});
const saveQueue = queue(async (value: Record<string, unknown>) => {
return await service.upsertOne(value);
});
return new Promise<void>((resolve, reject) => {
stream
.pipe(csv())
.on('data', (value: Record<string, string>) => {
const obj = transform(value, (result: Record<string, string>, value, key) => {
if (value.length === 0) {
delete result[key];
} else {
try {
const parsedJson = JSON.parse(value);
set(result, key, parsedJson);
} catch {
set(result, key, value);
}
}
});
saveQueue.push(obj);
})
.on('error', (err) => {
destroyStream(stream);
reject(new InvalidPayloadException(err.message));
})
.on('end', () => {
saveQueue.drain(() => {
return resolve();
});
});
saveQueue.error((err) => {
reject(err);
});
});
});
}
}
export class ExportService {
knex: Knex;
accountability: Accountability | null;
schema: SchemaOverview;
constructor(options: AbstractServiceOptions) {
this.knex = options.knex || getDatabase();
this.accountability = options.accountability || null;
this.schema = options.schema;
}
/**
* Export the query results as a named file. Will query in batches, and keep appending a tmp file
* until all the data is retrieved. Uploads the result as a new file using the regular
* FilesService upload method.
*/
async exportToFile(
collection: string,
query: Partial<Query>,
format: 'xml' | 'csv' | 'json',
options?: {
file?: Partial<File>;
}
) {
try {
const mimeTypes = {
xml: 'text/xml',
csv: 'text/csv',
json: 'application/json',
};
const database = getDatabase();
const { path, cleanup } = await createTmpFile();
await database.transaction(async (trx) => {
const service = new ItemsService(collection, {
accountability: this.accountability,
schema: this.schema,
knex: trx,
});
const totalCount = await service
.readByQuery({
...query,
aggregate: {
count: ['*'],
},
})
.then((result) => Number(result?.[0]?.count ?? 0));
const count = query.limit ? Math.min(totalCount, query.limit) : totalCount;
const requestedLimit = query.limit ?? -1;
const batchesRequired = Math.ceil(count / env.EXPORT_BATCH_SIZE);
let readCount = 0;
for (let batch = 0; batch <= batchesRequired; batch++) {
let limit = env.EXPORT_BATCH_SIZE;
if (requestedLimit > 0 && env.EXPORT_BATCH_SIZE > requestedLimit - readCount) {
limit = requestedLimit - readCount;
}
const result = await service.readByQuery({
...query,
limit,
page: batch,
});
readCount += result.length;
if (result.length) {
await appendFile(
path,
this.transform(result, format, {
includeHeader: batch === 0,
includeFooter: batch + 1 === batchesRequired,
})
);
}
}
});
const filesService = new FilesService({
accountability: this.accountability,
schema: this.schema,
});
const storage: string = toArray(env.STORAGE_LOCATIONS)[0];
const title = `export-${collection}-${getDateFormatted()}`;
const filename = `${title}.${format}`;
const fileWithDefaults: Partial<File> & { storage: string; filename_download: string } = {
...(options?.file ?? {}),
title: options?.file?.title ?? title,
filename_download: options?.file?.filename_download ?? filename,
storage: options?.file?.storage ?? storage,
type: mimeTypes[format],
};
const savedFile = await filesService.uploadOne(createReadStream(path), fileWithDefaults);
if (this.accountability?.user) {
const notificationsService = new NotificationsService({
accountability: this.accountability,
schema: this.schema,
});
await notificationsService.createOne({
recipient: this.accountability.user,
sender: this.accountability.user,
subject: `Your export of ${collection} is ready`,
collection: `directus_files`,
item: savedFile,
});
}
await cleanup();
} catch (err: any) {
logger.error(err, `Couldn't export ${collection}: ${err.message}`);
if (this.accountability?.user) {
const notificationsService = new NotificationsService({
accountability: this.accountability,
schema: this.schema,
});
await notificationsService.createOne({
recipient: this.accountability.user,
sender: this.accountability.user,
subject: `Your export of ${collection} failed`,
message: `Please contact your system administrator for more information.`,
});
}
}
}
/**
* Transform a given input object / array to the given type
*/
transform(
input: Record<string, any>[],
format: 'xml' | 'csv' | 'json',
options?: {
includeHeader?: boolean;
includeFooter?: boolean;
}
): string {
if (format === 'json') {
let string = JSON.stringify(input || null, null, '\t');
if (options?.includeHeader === false) string = string.split('\n').slice(1).join('\n');
if (options?.includeFooter === false) {
const lines = string.split('\n');
string = lines.slice(0, lines.length - 1).join('\n');
string += ',\n';
}
return string;
}
if (format === 'xml') {
let string = toXML('data', input);
if (options?.includeHeader === false) string = string.split('\n').slice(2).join('\n');
if (options?.includeFooter === false) {
const lines = string.split('\n');
string = lines.slice(0, lines.length - 1).join('\n');
string += '\n';
}
return string;
}
if (format === 'csv') {
const parser = new CSVParser({
transforms: [CSVTransforms.flatten({ separator: '.' })],
header: options?.includeHeader !== false,
});
return parser.parse(input);
}
throw new ServiceUnavailableException(`Illegal export type used: "${format}"`, { service: 'export' });
}
}

View File

@@ -1,138 +0,0 @@
import { Knex } from 'knex';
import getDatabase from '../database';
import { AbstractServiceOptions } from '../types';
import { Accountability, SchemaOverview } from '@directus/shared/types';
import { ForbiddenException, InvalidPayloadException, UnsupportedMediaTypeException } from '../exceptions';
import StreamArray from 'stream-json/streamers/StreamArray';
import { ItemsService } from './items';
import { queue } from 'async';
import destroyStream from 'destroy';
import csv from 'csv-parser';
import { set, transform } from 'lodash';
export class ImportService {
knex: Knex;
accountability: Accountability | null;
schema: SchemaOverview;
constructor(options: AbstractServiceOptions) {
this.knex = options.knex || getDatabase();
this.accountability = options.accountability || null;
this.schema = options.schema;
}
async import(collection: string, mimetype: string, stream: NodeJS.ReadableStream): Promise<void> {
if (collection.startsWith('directus_')) throw new ForbiddenException();
const createPermissions = this.accountability?.permissions?.find(
(permission) => permission.collection === collection && permission.action === 'create'
);
const updatePermissions = this.accountability?.permissions?.find(
(permission) => permission.collection === collection && permission.action === 'update'
);
if (this.accountability?.admin !== true && (!createPermissions || !updatePermissions)) {
throw new ForbiddenException();
}
switch (mimetype) {
case 'application/json':
return await this.importJSON(collection, stream);
case 'text/csv':
case 'application/vnd.ms-excel':
return await this.importCSV(collection, stream);
default:
throw new UnsupportedMediaTypeException(`Can't import files of type "${mimetype}"`);
}
}
importJSON(collection: string, stream: NodeJS.ReadableStream): Promise<void> {
const extractJSON = StreamArray.withParser();
return this.knex.transaction((trx) => {
const service = new ItemsService(collection, {
knex: trx,
schema: this.schema,
accountability: this.accountability,
});
const saveQueue = queue(async (value: Record<string, unknown>) => {
return await service.upsertOne(value);
});
return new Promise<void>((resolve, reject) => {
stream.pipe(extractJSON);
extractJSON.on('data', ({ value }) => {
saveQueue.push(value);
});
extractJSON.on('error', (err) => {
destroyStream(stream);
destroyStream(extractJSON);
reject(new InvalidPayloadException(err.message));
});
saveQueue.error((err) => {
reject(err);
});
extractJSON.on('end', () => {
saveQueue.drain(() => {
return resolve();
});
});
});
});
}
importCSV(collection: string, stream: NodeJS.ReadableStream): Promise<void> {
return this.knex.transaction((trx) => {
const service = new ItemsService(collection, {
knex: trx,
schema: this.schema,
accountability: this.accountability,
});
const saveQueue = queue(async (value: Record<string, unknown>) => {
return await service.upsertOne(value);
});
return new Promise<void>((resolve, reject) => {
stream
.pipe(csv())
.on('data', (value: Record<string, string>) => {
const obj = transform(value, (result: Record<string, string>, value, key) => {
if (value.length === 0) {
delete result[key];
} else {
try {
const parsedJson = JSON.parse(value);
set(result, key, parsedJson);
} catch {
set(result, key, value);
}
}
});
saveQueue.push(obj);
})
.on('error', (err) => {
destroyStream(stream);
reject(new InvalidPayloadException(err.message));
})
.on('end', () => {
saveQueue.drain(() => {
return resolve();
});
});
saveQueue.error((err) => {
reject(err);
});
});
});
}
}

View File

@@ -10,7 +10,7 @@ export * from './fields';
export * from './files';
export * from './folders';
export * from './graphql';
export * from './import';
export * from './import-export';
export * from './mail';
export * from './meta';
export * from './notifications';

View File

@@ -0,0 +1,11 @@
export function getDateFormatted() {
const date = new Date();
let month = String(date.getMonth() + 1);
if (month.length === 1) month = '0' + month;
let day = String(date.getDate());
if (day.length === 1) day = '0' + day;
return `${date.getFullYear()}${month}${day}-${date.getHours()}${date.getMinutes()}${date.getSeconds()}`;
}

View File

@@ -0,0 +1,12 @@
import { defineInterface } from '@directus/shared/utils';
import InterfaceSystemFields from './system-fields.vue';
export default defineInterface({
id: 'system-fields',
name: '$t:interfaces.fields.name',
icon: 'search',
component: InterfaceSystemFields,
types: ['csv', 'json'],
options: [],
system: true,
});

View File

@@ -0,0 +1,87 @@
<template>
<v-list>
<draggable v-model="fields" :force-fallback="true" item-key="key" handle=".drag-handle">
<template #item="{ element: field }">
<v-list-item block>
<v-icon name="drag_handle" class="drag-handle" left />
<div class="name">{{ field.name }}</div>
<div class="spacer" />
<v-icon name="close" clickable @click="removeField(field.key)" />
</v-list-item>
</template>
</draggable>
</v-list>
<v-menu placement="bottom-start" show-arrow>
<template #activator="{ toggle }">
<button class="toggle" @click="toggle">
{{ t('add_field') }}
<v-icon name="expand_more" />
</button>
</template>
<v-field-list :disabled-fields="value" :collection="collectionName" @select-field="addField" />
</v-menu>
</template>
<script lang="ts" setup>
import { useFieldsStore } from '@/stores/fields';
import { Field } from '@directus/shared/types';
import { computed } from 'vue';
import Draggable from 'vuedraggable';
import { useI18n } from 'vue-i18n';
interface Props {
collectionName: string;
value?: string[] | null;
}
const props = withDefaults(defineProps<Props>(), { value: () => null });
const emit = defineEmits(['input']);
const fieldsStore = useFieldsStore();
const fields = computed<(Field & { key: string })[]>({
get() {
return (
props.value?.map((fieldKey) => ({
key: fieldKey,
...fieldsStore.getField(props.collectionName, fieldKey)!,
})) ?? []
);
},
set(updatedFields) {
const newFields = updatedFields.map((field) => field.key);
emit('input', newFields);
},
});
const { t } = useI18n();
function addField(fieldKey: string) {
emit('input', [...(props.value ?? []), fieldKey]);
}
function removeField(fieldKey: string) {
const newArray = props.value?.filter((val) => val !== fieldKey);
if (!newArray || newArray.length === 0) {
emit('input', null);
}
emit('input', newArray);
}
</script>
<style lang="scss" scoped>
.toggle {
color: var(--primary);
font-weight: 600;
margin-left: 10px;
margin-top: 6px;
.v-icon {
position: absolute;
}
}
</style>

View File

@@ -42,6 +42,7 @@ duplicate_field: Duplicate Field
half_width: Half Width
full_width: Full Width
group: Group
export_items: Export Items
and: And
or: Or
fill_width: Fill Width
@@ -344,8 +345,6 @@ label_import: Import
label_export: Export
import_export: Import / Export
format: Format
use_current_filters_settings: Use Current Filters & Settings
export_data_button: Start Export
last_page: Last Page
last_access: Last Access
fill_template: Fill with Template Value
@@ -417,6 +416,14 @@ replace_from_library: Replace File from Library
replace_from_url: Replace File from URL
no_file_selected: No File Selected
download_file: Download File
start_export: Start Export
not_available_for_local_downloads: Not available for local downloads
exporting_all_items_in_collection: Exporting all {total} items within {collection}.
exporting_limited_items_in_collection: Exporting {limit} out of {total} items within {collection}.
exporting_no_items_to_export: No items to export. Adjust the exporting configuration below.
exporting_download_hint: Once completed, this {format} file will automatically be downloaded to your device.
exporting_batch_hint: This export will be processed in batches, and once completed, the {format} file will be saved to the File Library.
exporting_batch_hint_forced: Due to the large number of items, this export must be processed in batches, and once completed, the {format} file will be saved to the File Library.
collection_key: Collection Key
name: Name
primary_key_field: Primary Key Field
@@ -723,6 +730,7 @@ no_data: No Data
create_dashboard: Create Dashboard
dashboard_name: Dashboard Name
full_screen: Full Screen
full_text_search: Full-Text Search
edit_panels: Edit Panels
center_align: Center Align
left_align: Left Align
@@ -1273,6 +1281,9 @@ sign_out: Sign Out
sign_out_confirm: Are you sure you want to sign out?
something_went_wrong: Something went wrong.
sort_direction: Sort Direction
export_location: Export Location
export_started: Export Started
export_started_copy: Your export has started. You'll be notified when it's ready to download.
sort_asc: Sort Ascending
sort_desc: Sort Descending
template: Template
@@ -1300,6 +1311,8 @@ interfaces:
no_rules: No configured rules
change_value: Click to change value
placeholder: Drag rules here
fields:
name: Fields
group-accordion:
name: Accordion
description: Display fields or groups as accordion sections

View File

@@ -248,6 +248,7 @@
:collection="collection"
:filter="mergeFilters(filter, archiveFilter)"
:search="search"
:layout-query="layoutQuery"
@refresh="refresh"
/>
</template>

View File

@@ -118,7 +118,7 @@ import { useI18n } from 'vue-i18n';
import { defineComponent, PropType, ref } from 'vue';
import useFolders, { Folder } from '@/composables/use-folders';
import api from '@/api';
import FolderPicker from './folder-picker.vue';
import FolderPicker from '@/views/private/components/folder-picker/folder-picker.vue';
import { useRouter } from 'vue-router';
import { unexpectedError } from '@/utils/unexpected-error';

View File

@@ -167,6 +167,7 @@
<component :is="`layout-sidebar-${layout}`" v-bind="layoutState" />
<export-sidebar-detail
collection="directus_files"
:layout-query="layoutQuery"
:filter="mergeFilters(filter, folderTypeFilter)"
:search="search"
/>
@@ -191,7 +192,7 @@ import usePreset from '@/composables/use-preset';
import LayoutSidebarDetail from '@/views/private/components/layout-sidebar-detail';
import AddFolder from '../components/add-folder.vue';
import SearchInput from '@/views/private/components/search-input';
import FolderPicker from '../components/folder-picker.vue';
import FolderPicker from '@/views/private/components/folder-picker/folder-picker.vue';
import emitter, { Events } from '@/events';
import { useRouter, onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router';
import { useNotificationsStore, useUserStore, usePermissionsStore } from '@/stores';

View File

@@ -183,7 +183,7 @@ import FilePreview from '@/views/private/components/file-preview';
import ImageEditor from '@/views/private/components/image-editor';
import { Field } from '@directus/shared/types';
import FileInfoSidebarDetail from '../components/file-info-sidebar-detail.vue';
import FolderPicker from '../components/folder-picker.vue';
import FolderPicker from '@/views/private/components/folder-picker/folder-picker.vue';
import api, { addTokenToURL } from '@/api';
import { getRootPath } from '@/utils/get-root-path';
import FilesNotFound from './not-found.vue';

View File

@@ -151,6 +151,7 @@
<component :is="`layout-sidebar-${layout}`" v-bind="layoutState" />
<export-sidebar-detail
collection="directus_users"
:layout-query="layoutQuery"
:filter="mergeFilters(filter, roleFilter)"
:search="search"
/>

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

@@ -151,12 +151,17 @@ export default defineComponent({
</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(--background-normal-alt);
--v-list-item-background-color-active: var(--background-normal-alt);
--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(--background-normal);
background-color: var(--folder-picker-background-color);
border-radius: var(--border-radius);
}
</style>

View File

@@ -799,3 +799,4 @@ Allows you to configure hard technical limits, to prevent abuse and optimize for
| Variable | Description | Default Value |
| ----------------------- | ----------------------------------------------------------------------------------------- | ------------- |
| `RELATIONAL_BATCH_SIZE` | How many rows are read into memory at a time when constructing nested relational datasets | 25000 |
| `EXPORT_BATCH_SIZE` | How many rows are read into memory at a time when constructing exports | 5000 |

View File

@@ -270,6 +270,75 @@ n/a
---
## Export Data to a File
Export a larger data set to a file in the File Library
<div class="two-up">
<div class="left">
### Query Parameters
Doesn't use any query parameters.
### Request Body
<div class="definitions">
`format` **Required**\
What file format to save the export to. One of `csv`, `xml`, `json`.
`query` **Required**\
The query object to use for the export. Supports the [global query parameters](/reference/query).
`file` **File Object**\
Partial file object to tweak where / how the export file is saved.
</div>
### Returns
Empty body
</div>
<div class="right">
### REST API
```
POST /utils/export/:collection
```
##### Example
```
POST /utils/export/articles
```
```json
{
"query": {
"filter": {
"status": {
"_eq": "published"
}
}
},
"file": {
"folder": "34e95c19-cc50-42f2-83c8-b97616ac2390"
}
}
```
### GraphQL
n/a
</div>
</div>
---
## Clear the Internal Cache
Resets both the data and schema cache of Directus. This endpoint is only available to admin users.

144
package-lock.json generated
View File

@@ -54,20 +54,20 @@
},
"api": {
"name": "directus",
"version": "9.5.2",
"version": "9.6.0",
"license": "GPL-3.0-only",
"dependencies": {
"@aws-sdk/client-ses": "^3.40.0",
"@directus/app": "9.5.2",
"@directus/drive": "9.5.2",
"@directus/drive-azure": "9.5.2",
"@directus/drive-gcs": "9.5.2",
"@directus/drive-s3": "9.5.2",
"@directus/extensions-sdk": "9.5.2",
"@directus/format-title": "9.5.2",
"@directus/schema": "9.5.2",
"@directus/shared": "9.5.2",
"@directus/specs": "9.5.2",
"@directus/app": "9.6.0",
"@directus/drive": "9.6.0",
"@directus/drive-azure": "9.6.0",
"@directus/drive-gcs": "9.6.0",
"@directus/drive-s3": "9.6.0",
"@directus/extensions-sdk": "9.6.0",
"@directus/format-title": "9.6.0",
"@directus/schema": "9.6.0",
"@directus/shared": "9.6.0",
"@directus/specs": "9.6.0",
"@godaddy/terminus": "^4.9.0",
"@rollup/plugin-alias": "^3.1.9",
"@rollup/plugin-virtual": "^2.0.3",
@@ -133,6 +133,7 @@
"sharp": "^0.29.0",
"stream-json": "^1.7.1",
"supertest": "^6.1.6",
"tmp-promise": "^3.0.3",
"update-check": "^1.5.4",
"uuid": "^8.3.2",
"uuid-validate": "0.0.3",
@@ -207,12 +208,12 @@
},
"app": {
"name": "@directus/app",
"version": "9.5.2",
"version": "9.6.0",
"devDependencies": {
"@directus/docs": "9.5.2",
"@directus/extensions-sdk": "9.5.2",
"@directus/format-title": "9.5.2",
"@directus/shared": "9.5.2",
"@directus/docs": "9.6.0",
"@directus/extensions-sdk": "9.6.0",
"@directus/format-title": "9.6.0",
"@directus/shared": "9.6.0",
"@fortawesome/fontawesome-svg-core": "1.2.36",
"@fortawesome/free-brands-svg-icons": "5.15.4",
"@fullcalendar/core": "5.10.1",
@@ -351,7 +352,7 @@
},
"docs": {
"name": "@directus/docs",
"version": "9.5.2",
"version": "9.6.0",
"license": "ISC",
"devDependencies": {
"directory-tree": "3.0.1",
@@ -47360,6 +47361,14 @@
"node": ">=8.17.0"
}
},
"node_modules/tmp-promise": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz",
"integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==",
"dependencies": {
"tmp": "^0.2.0"
}
},
"node_modules/tmpl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@@ -52063,11 +52072,11 @@
},
"packages/cli": {
"name": "@directus/cli",
"version": "9.5.2",
"version": "9.6.0",
"license": "MIT",
"dependencies": {
"@directus/format-title": "9.5.2",
"@directus/sdk": "9.5.2",
"@directus/format-title": "9.6.0",
"@directus/sdk": "9.6.0",
"@types/yargs": "^17.0.0",
"app-module-path": "^2.2.0",
"chalk": "^4.1.0",
@@ -52211,11 +52220,11 @@
}
},
"packages/create-directus-extension": {
"version": "9.5.2",
"version": "9.6.0",
"license": "GPL-3.0-only",
"dependencies": {
"@directus/extensions-sdk": "9.5.2",
"@directus/shared": "9.5.2",
"@directus/extensions-sdk": "9.6.0",
"@directus/shared": "9.6.0",
"inquirer": "^8.1.2"
},
"bin": {
@@ -52224,7 +52233,7 @@
}
},
"packages/create-directus-project": {
"version": "9.5.2",
"version": "9.6.0",
"license": "GPL-3.0-only",
"dependencies": {
"chalk": "^4.1.1",
@@ -52241,7 +52250,7 @@
},
"packages/drive": {
"name": "@directus/drive",
"version": "9.5.2",
"version": "9.6.0",
"license": "MIT",
"dependencies": {
"fs-extra": "^10.0.0",
@@ -52260,11 +52269,11 @@
},
"packages/drive-azure": {
"name": "@directus/drive-azure",
"version": "9.5.2",
"version": "9.6.0",
"license": "MIT",
"dependencies": {
"@azure/storage-blob": "^12.6.0",
"@directus/drive": "9.5.2",
"@directus/drive": "9.6.0",
"normalize-path": "^3.0.0"
},
"devDependencies": {
@@ -52386,10 +52395,10 @@
},
"packages/drive-gcs": {
"name": "@directus/drive-gcs",
"version": "9.5.2",
"version": "9.6.0",
"license": "MIT",
"dependencies": {
"@directus/drive": "9.5.2",
"@directus/drive": "9.6.0",
"@google-cloud/storage": "^5.8.5",
"lodash": "4.17.21",
"normalize-path": "^3.0.0"
@@ -52499,10 +52508,10 @@
},
"packages/drive-s3": {
"name": "@directus/drive-s3",
"version": "9.5.2",
"version": "9.6.0",
"license": "MIT",
"dependencies": {
"@directus/drive": "9.5.2",
"@directus/drive": "9.6.0",
"aws-sdk": "^2.928.0",
"normalize-path": "^3.0.0"
},
@@ -52716,9 +52725,9 @@
},
"packages/extensions-sdk": {
"name": "@directus/extensions-sdk",
"version": "9.5.2",
"version": "9.6.0",
"dependencies": {
"@directus/shared": "9.5.2",
"@directus/shared": "9.6.0",
"@rollup/plugin-commonjs": "^21.0.1",
"@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-node-resolve": "^13.1.3",
@@ -52749,7 +52758,7 @@
},
"packages/format-title": {
"name": "@directus/format-title",
"version": "9.5.2",
"version": "9.6.0",
"license": "MIT",
"devDependencies": {
"@rollup/plugin-commonjs": "21.0.1",
@@ -52805,10 +52814,10 @@
},
"packages/gatsby-source-directus": {
"name": "@directus/gatsby-source-directus",
"version": "9.5.2",
"version": "9.6.0",
"license": "MIT",
"dependencies": {
"@directus/sdk": "9.5.2",
"@directus/sdk": "9.6.0",
"gatsby-source-filesystem": "4.2.0",
"gatsby-source-graphql": "4.2.0",
"ms": "2.1.3"
@@ -52816,7 +52825,7 @@
},
"packages/schema": {
"name": "@directus/schema",
"version": "9.5.2",
"version": "9.6.0",
"license": "GPL-3.0",
"dependencies": {
"knex-schema-inspector": "1.7.3",
@@ -52829,7 +52838,7 @@
},
"packages/sdk": {
"name": "@directus/sdk",
"version": "9.5.2",
"version": "9.6.0",
"license": "MIT",
"dependencies": {
"axios": "^0.24.0"
@@ -53006,7 +53015,7 @@
},
"packages/shared": {
"name": "@directus/shared",
"version": "9.5.2",
"version": "9.6.0",
"dependencies": {
"date-fns": "2.24.0",
"fs-extra": "10.0.0",
@@ -53073,7 +53082,7 @@
},
"packages/specs": {
"name": "@directus/specs",
"version": "9.5.2",
"version": "9.6.0",
"license": "GPL-3.0",
"dependencies": {
"openapi3-ts": "^2.0.1"
@@ -55465,10 +55474,10 @@
"@directus/app": {
"version": "file:app",
"requires": {
"@directus/docs": "9.5.2",
"@directus/extensions-sdk": "9.5.2",
"@directus/format-title": "9.5.2",
"@directus/shared": "9.5.2",
"@directus/docs": "9.6.0",
"@directus/extensions-sdk": "9.6.0",
"@directus/format-title": "9.6.0",
"@directus/shared": "9.6.0",
"@fortawesome/fontawesome-svg-core": "1.2.36",
"@fortawesome/free-brands-svg-icons": "5.15.4",
"@fullcalendar/core": "5.10.1",
@@ -55583,8 +55592,8 @@
"@directus/cli": {
"version": "file:packages/cli",
"requires": {
"@directus/format-title": "9.5.2",
"@directus/sdk": "9.5.2",
"@directus/format-title": "9.6.0",
"@directus/sdk": "9.6.0",
"@types/figlet": "1.5.4",
"@types/fs-extra": "9.0.13",
"@types/jest": "27.0.3",
@@ -55772,7 +55781,7 @@
"version": "file:packages/drive-azure",
"requires": {
"@azure/storage-blob": "^12.6.0",
"@directus/drive": "9.5.2",
"@directus/drive": "9.6.0",
"@types/fs-extra": "9.0.13",
"@types/jest": "27.0.3",
"@types/node": "16.11.9",
@@ -55848,7 +55857,7 @@
"@directus/drive-gcs": {
"version": "file:packages/drive-gcs",
"requires": {
"@directus/drive": "9.5.2",
"@directus/drive": "9.6.0",
"@google-cloud/storage": "^5.8.5",
"@lukeed/uuid": "2.0.0",
"@types/fs-extra": "9.0.13",
@@ -55915,7 +55924,7 @@
"@directus/drive-s3": {
"version": "file:packages/drive-s3",
"requires": {
"@directus/drive": "9.5.2",
"@directus/drive": "9.6.0",
"@lukeed/uuid": "2.0.0",
"@types/fs-extra": "9.0.13",
"@types/jest": "27.0.3",
@@ -55993,7 +56002,7 @@
"@directus/extensions-sdk": {
"version": "file:packages/extensions-sdk",
"requires": {
"@directus/shared": "9.5.2",
"@directus/shared": "9.6.0",
"@rollup/plugin-commonjs": "^21.0.1",
"@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-node-resolve": "^13.1.3",
@@ -56058,7 +56067,7 @@
"@directus/gatsby-source-directus": {
"version": "file:packages/gatsby-source-directus",
"requires": {
"@directus/sdk": "9.5.2",
"@directus/sdk": "9.6.0",
"gatsby-source-filesystem": "4.2.0",
"gatsby-source-graphql": "4.2.0",
"ms": "2.1.3"
@@ -70249,8 +70258,8 @@
"create-directus-extension": {
"version": "file:packages/create-directus-extension",
"requires": {
"@directus/extensions-sdk": "9.5.2",
"@directus/shared": "9.5.2",
"@directus/extensions-sdk": "9.6.0",
"@directus/shared": "9.6.0",
"inquirer": "^8.1.2"
}
},
@@ -71471,16 +71480,16 @@
"version": "file:api",
"requires": {
"@aws-sdk/client-ses": "^3.40.0",
"@directus/app": "9.5.2",
"@directus/drive": "9.5.2",
"@directus/drive-azure": "9.5.2",
"@directus/drive-gcs": "9.5.2",
"@directus/drive-s3": "9.5.2",
"@directus/extensions-sdk": "9.5.2",
"@directus/format-title": "9.5.2",
"@directus/schema": "9.5.2",
"@directus/shared": "9.5.2",
"@directus/specs": "9.5.2",
"@directus/app": "9.6.0",
"@directus/drive": "9.6.0",
"@directus/drive-azure": "9.6.0",
"@directus/drive-gcs": "9.6.0",
"@directus/drive-s3": "9.6.0",
"@directus/extensions-sdk": "9.6.0",
"@directus/format-title": "9.6.0",
"@directus/schema": "9.6.0",
"@directus/shared": "9.6.0",
"@directus/specs": "9.6.0",
"@godaddy/terminus": "^4.9.0",
"@keyv/redis": "^2.1.2",
"@rollup/plugin-alias": "^3.1.9",
@@ -71599,6 +71608,7 @@
"stream-json": "^1.7.1",
"supertest": "^6.1.6",
"tedious": "^13.0.0",
"tmp-promise": "*",
"ts-jest": "27.1.3",
"ts-node-dev": "1.1.8",
"typescript": "4.5.2",
@@ -91357,6 +91367,14 @@
"rimraf": "^3.0.0"
}
},
"tmp-promise": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz",
"integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==",
"requires": {
"tmp": "^0.2.0"
}
},
"tmpl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",

View File

@@ -1,9 +1,9 @@
import { useStores } from './use-system';
import { Collection, Field } from '../types';
import { AppCollection, Field } from '../types';
import { computed, ref, Ref, ComputedRef } from 'vue';
type UsableCollection = {
info: ComputedRef<Collection | null>;
info: ComputedRef<AppCollection | null>;
fields: ComputedRef<Field[]>;
defaults: Record<string, any>;
primaryKeyField: ComputedRef<Field | null>;
@@ -22,7 +22,7 @@ export function useCollection(collectionKey: string | Ref<string | null>): Usabl
const info = computed(() => {
return (
(collectionsStore.collections as Collection[]).find(({ collection: key }) => key === collection.value) || null
(collectionsStore.collections as AppCollection[]).find(({ collection: key }) => key === collection.value) || null
);
});

View File

@@ -34,4 +34,11 @@ export interface Collection {
schema: Table | null;
}
export interface AppCollection extends Collection {
name: string;
icon: string;
type: CollectionType;
color?: string | null;
}
export type CollectionType = 'alias' | 'table' | 'unknown';

View File

@@ -1,3 +1,5 @@
import { PrimaryKey } from './items';
export type Notification = {
id: string;
status: string;
@@ -7,5 +9,5 @@ export type Notification = {
subject: string;
message: string | null;
collection: string | null;
item: string | null;
item: PrimaryKey | null;
};