mirror of
https://github.com/directus/directus.git
synced 2026-01-14 17:07:56 -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:
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
|
||||
345
api/src/services/import-export.ts
Normal file
345
api/src/services/import-export.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
11
api/src/utils/get-date-formatted.ts
Normal file
11
api/src/utils/get-date-formatted.ts
Normal 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()}`;
|
||||
}
|
||||
12
app/src/interfaces/_system/system-fields/index.ts
Normal file
12
app/src/interfaces/_system/system-fields/index.ts
Normal 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,
|
||||
});
|
||||
87
app/src/interfaces/_system/system-fields/system-fields.vue
Normal file
87
app/src/interfaces/_system/system-fields/system-fields.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -248,6 +248,7 @@
|
||||
:collection="collection"
|
||||
:filter="mergeFilters(filter, archiveFilter)"
|
||||
:search="search"
|
||||
:layout-query="layoutQuery"
|
||||
@refresh="refresh"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
144
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user