feat: name export file with meaningful title instead of id (#379)
This commit is contained in:
16
backend/config/contentDisposition.js
Normal file
16
backend/config/contentDisposition.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export function toSafeFileName(fileName) {
|
||||||
|
const s = String(fileName ?? '')
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
.replace(/[\u0000-\u001F\u007F]/g, '')
|
||||||
|
.replace(/[<>:"/\\|?*]/g, '_')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contentDisposition(filename) {
|
||||||
|
const fallback = filename.replace(/[^\x20-\x7E]/g, '_').replace(/"/g, "'");
|
||||||
|
const encoded = encodeURIComponent(filename);
|
||||||
|
return `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import defineStep from '../../models/steps.js';
|
|||||||
import defineFolder from '../../models/folders.js';
|
import defineFolder from '../../models/folders.js';
|
||||||
import authMiddleware from '../../middleware/auth.js';
|
import authMiddleware from '../../middleware/auth.js';
|
||||||
import visibilityMiddleware from '../../middleware/verifyVisible.js';
|
import visibilityMiddleware from '../../middleware/verifyVisible.js';
|
||||||
|
import { contentDisposition, toSafeFileName } from '../../config/contentDisposition.js';
|
||||||
import { testRunStatus, priorities, testTypes, automationStatus, templates } from '../../config/enums.js';
|
import { testRunStatus, priorities, testTypes, automationStatus, templates } from '../../config/enums.js';
|
||||||
|
|
||||||
export default function (sequelize) {
|
export default function (sequelize) {
|
||||||
@@ -31,6 +32,16 @@ export default function (sequelize) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const folder = await Folder.findByPk(folderId);
|
||||||
|
if (!folder) {
|
||||||
|
return res.status(404).send('Folder not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderName = toSafeFileName(folder.name);
|
||||||
|
const filename = `${folderName}.${type}`;
|
||||||
|
res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition');
|
||||||
|
res.setHeader('Content-Disposition', contentDisposition(filename));
|
||||||
|
|
||||||
const cases = await Case.findAll({
|
const cases = await Case.findAll({
|
||||||
attributes: { exclude: ['createdAt', 'updatedAt', 'caseSteps'] },
|
attributes: { exclude: ['createdAt', 'updatedAt', 'caseSteps'] },
|
||||||
include: [
|
include: [
|
||||||
@@ -74,7 +85,6 @@ export default function (sequelize) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||||
res.setHeader('Content-Disposition', `attachment; filename=cases_folder_${folderId}.csv`);
|
|
||||||
return res.send(csv);
|
return res.send(csv);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,13 @@ vi.mock('../../middleware/verifyVisible.js', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// mock defineCase
|
const mockFolder = {
|
||||||
|
findByPk: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.mock('../../models/folders.js', () => ({
|
||||||
|
default: () => mockFolder,
|
||||||
|
}));
|
||||||
|
|
||||||
const mockCase = {
|
const mockCase = {
|
||||||
findAll: vi.fn(),
|
findAll: vi.fn(),
|
||||||
belongsToMany: vi.fn(),
|
belongsToMany: vi.fn(),
|
||||||
@@ -66,6 +72,8 @@ describe('GET /download with type=csv', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return CSV with human-readable labels for state, priority, type, automationStatus, and template', async () => {
|
it('should return CSV with human-readable labels for state, priority, type, automationStatus, and template', async () => {
|
||||||
|
mockFolder.findByPk.mockResolvedValue({ id: 1, name: 'Test Folder' });
|
||||||
|
|
||||||
mockCase.findAll.mockResolvedValue([
|
mockCase.findAll.mockResolvedValue([
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -112,7 +120,7 @@ describe('GET /download with type=csv', () => {
|
|||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.headers['content-type']).toBe('text/csv; charset=utf-8');
|
expect(response.headers['content-type']).toBe('text/csv; charset=utf-8');
|
||||||
expect(response.headers['content-disposition']).toBe('attachment; filename=cases_folder_1.csv');
|
expect(response.headers['content-disposition']).toContain('attachment; filename="Test Folder.csv"');
|
||||||
|
|
||||||
const csvContent = response.text;
|
const csvContent = response.text;
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import defineFolder from '../../models/folders.js';
|
|||||||
import defineTag from '../../models/tags.js';
|
import defineTag from '../../models/tags.js';
|
||||||
import authMiddleware from '../../middleware/auth.js';
|
import authMiddleware from '../../middleware/auth.js';
|
||||||
import visibilityMiddleware from '../../middleware/verifyVisible.js';
|
import visibilityMiddleware from '../../middleware/verifyVisible.js';
|
||||||
|
import { contentDisposition, toSafeFileName } from '../../config/contentDisposition.js';
|
||||||
import { testRunCaseStatus, testRunStatus, priorities, testTypes, automationStatus } from '../../config/enums.js';
|
import { testRunCaseStatus, testRunStatus, priorities, testTypes, automationStatus } from '../../config/enums.js';
|
||||||
|
|
||||||
export default function (sequelize) {
|
export default function (sequelize) {
|
||||||
@@ -40,6 +41,11 @@ export default function (sequelize) {
|
|||||||
return res.status(404).send('Run not found');
|
return res.status(404).send('Run not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const runName = toSafeFileName(run.name);
|
||||||
|
const filename = `${runName}.${type}`;
|
||||||
|
res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition');
|
||||||
|
res.setHeader('Content-Disposition', contentDisposition(filename));
|
||||||
|
|
||||||
const runCases = await RunCase.findAll({
|
const runCases = await RunCase.findAll({
|
||||||
where: { runId },
|
where: { runId },
|
||||||
include: [
|
include: [
|
||||||
@@ -107,7 +113,6 @@ export default function (sequelize) {
|
|||||||
const xmlString = xml.end({ prettyPrint: true });
|
const xmlString = xml.end({ prettyPrint: true });
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'application/xml');
|
res.setHeader('Content-Type', 'application/xml');
|
||||||
res.setHeader('Content-Disposition', `attachment; filename=run_${runId}.xml`);
|
|
||||||
return res.send(xmlString);
|
return res.send(xmlString);
|
||||||
} else if (type === 'json') {
|
} else if (type === 'json') {
|
||||||
return res.json(runCases);
|
return res.json(runCases);
|
||||||
@@ -129,7 +134,6 @@ export default function (sequelize) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||||
res.setHeader('Content-Disposition', `attachment; filename=run_${runId}.csv`);
|
|
||||||
return res.send(csv);
|
return res.send(csv);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ describe('GET /download/:runId with type=csv', () => {
|
|||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.headers['content-type']).toBe('text/csv; charset=utf-8');
|
expect(response.headers['content-type']).toBe('text/csv; charset=utf-8');
|
||||||
expect(response.headers['content-disposition']).toBe('attachment; filename=run_1.csv');
|
expect(response.headers['content-disposition']).toContain('attachment; filename="Test Run.csv"');
|
||||||
|
|
||||||
const csvContent = response.text;
|
const csvContent = response.text;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getFilenameFromContentDisposition } from '@/utils/request';
|
||||||
import { logError } from '@/utils/errorHandler';
|
import { logError } from '@/utils/errorHandler';
|
||||||
import { CaseType } from '@/types/case';
|
import { CaseType } from '@/types/case';
|
||||||
import { RunType, RunCaseType } from '@/types/run';
|
import { RunType, RunCaseType } from '@/types/run';
|
||||||
@@ -149,11 +150,14 @@ async function exportRun(jwt: string, runId: number, type: string) {
|
|||||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const disposition = response.headers.get('content-disposition');
|
||||||
|
const filename = getFilenameFromContentDisposition(disposition) ?? `cases.${type}`;
|
||||||
|
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const objectUrl = window.URL.createObjectURL(blob);
|
const objectUrl = window.URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = objectUrl;
|
a.href = objectUrl;
|
||||||
a.download = `run_${runId}.${type}`;
|
a.download = filename;
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
a.remove();
|
a.remove();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getFilenameFromContentDisposition } from '@/utils/request';
|
||||||
import { logError } from '@/utils/errorHandler';
|
import { logError } from '@/utils/errorHandler';
|
||||||
import Config from '@/config/config';
|
import Config from '@/config/config';
|
||||||
const apiServer = Config.apiServer;
|
const apiServer = Config.apiServer;
|
||||||
@@ -221,11 +222,14 @@ async function exportCases(jwt: string, folderId: number, type: string) {
|
|||||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const disposition = response.headers.get('content-disposition');
|
||||||
|
const filename = getFilenameFromContentDisposition(disposition) ?? `cases.${type}`;
|
||||||
|
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const objectUrl = window.URL.createObjectURL(blob);
|
const objectUrl = window.URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = objectUrl;
|
a.href = objectUrl;
|
||||||
a.download = `folder_${folderId}.${type}`;
|
a.download = filename;
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
a.remove();
|
a.remove();
|
||||||
|
|||||||
17
frontend/utils/request.ts
Normal file
17
frontend/utils/request.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export function getFilenameFromContentDisposition(disposition: string | null): string | null {
|
||||||
|
if (!disposition) return null;
|
||||||
|
|
||||||
|
const filenameStar = disposition.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
|
||||||
|
if (filenameStar?.[1]) {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(filenameStar[1]);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = disposition.match(/filename\s*=\s*"([^"]+)"/i) || disposition.match(/filename\s*=\s*([^;]+)/i);
|
||||||
|
if (filename?.[1]) return filename[1].trim();
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user