diff --git a/backend/index.js b/backend/index.js index e05e2de..86d08bc 100644 --- a/backend/index.js +++ b/backend/index.js @@ -78,12 +78,14 @@ app.use('/folders', foldersEditRoute); app.use('/folders', foldersDeleteRoute); // "/cases" +const casesDownloadRoute = require('./routes/cases/download')(sequelize); const casesIndexRoute = require('./routes/cases/index')(sequelize); const casesIndexByProjectIdRoute = require('./routes/cases/indexByProjectId')(sequelize); const casesShowRoute = require('./routes/cases/show')(sequelize); const casesNewRoute = require('./routes/cases/new')(sequelize); const casesEditRoute = require('./routes/cases/edit')(sequelize); const casesDeleteRoute = require('./routes/cases/delete')(sequelize); +app.use('/cases', casesDownloadRoute); app.use('/cases', casesIndexRoute); app.use('/cases', casesIndexByProjectIdRoute); app.use('/cases', casesShowRoute); diff --git a/backend/package-lock.json b/backend/package-lock.json index 64215a5..930489a 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -15,6 +15,7 @@ "express-rate-limit": "^7.4.1", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", + "papaparse": "^5.5.2", "sequelize": "^6.37.5", "sequelize-cli": "^6.6.2", "sqlite3": "^5.1.7" @@ -2358,6 +2359,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/papaparse": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.2.tgz", + "integrity": "sha512-PZXg8UuAc4PcVwLosEEDYjPyfWnTEhOrUfdv+3Bx+NuAb+5NhDmXzg5fHWmdCh1mP5p7JAZfFr3IMQfcntNAdA==" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -5265,6 +5271,11 @@ "aggregate-error": "^3.0.0" } }, + "papaparse": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.2.tgz", + "integrity": "sha512-PZXg8UuAc4PcVwLosEEDYjPyfWnTEhOrUfdv+3Bx+NuAb+5NhDmXzg5fHWmdCh1mP5p7JAZfFr3IMQfcntNAdA==" + }, "parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", diff --git a/backend/package.json b/backend/package.json index b374248..c61a9cd 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,6 +18,7 @@ "express-rate-limit": "^7.4.1", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", + "papaparse": "^5.5.2", "sequelize": "^6.37.5", "sequelize-cli": "^6.6.2", "sqlite3": "^5.1.7" diff --git a/backend/routes/cases/download.js b/backend/routes/cases/download.js new file mode 100644 index 0000000..9764ee8 --- /dev/null +++ b/backend/routes/cases/download.js @@ -0,0 +1,48 @@ +const express = require('express'); +const router = express.Router(); +const { DataTypes } = require('sequelize'); +const Papa = require('papaparse'); +const defineCase = require('../../models/cases'); + +module.exports = function (sequelize) { + const Case = defineCase(sequelize, DataTypes); + const { verifySignedIn } = require('../../middleware/auth')(sequelize); + const { verifyProjectVisibleFromFolderId } = require('../../middleware/verifyVisible')(sequelize); + + router.get('/download', verifySignedIn, verifyProjectVisibleFromFolderId, async (req, res) => { + const { folderId, type } = req.query; + + if (!folderId) { + return res.status(400).json({ error: 'folderId is required' }); + } + + if (!type) { + return res.status(400).json({ error: 'download type is required' }); + } + + try { + const cases = await Case.findAll({ + where: { folderId }, + raw: true, + }); + + if (cases.length === 0) { + return res.status(404).send('No cases found'); + } + + const csv = Papa.unparse(cases, { + quotes: true, + skipEmptyLines: true, + }); + + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename=cases_folder_${folderId}.csv`); + res.send(csv); + } catch (error) { + console.error(error); + res.status(500).send('Internal Server Error'); + } + }); + + return router; +}; diff --git a/frontend/messages/en.json b/frontend/messages/en.json index d0021d3..eace95a 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -188,6 +188,7 @@ "close": "Close", "are_you_sure": "Are you sure you want to delete test cases?", "new_test_case": "New Test Case", + "download_csv": "Download CSV", "status": "Status", "no_cases_found": "No test cases found", "case_title": "Test Case Title", diff --git a/frontend/messages/ja.json b/frontend/messages/ja.json index dabd405..664ac9b 100644 --- a/frontend/messages/ja.json +++ b/frontend/messages/ja.json @@ -189,6 +189,7 @@ "close": "閉じる", "are_you_sure": "テストケースを削除してもよろしいですか?", "new_test_case": "新規テストケース", + "download_csv": "CSVダウンロード", "status": "ステータス", "no_cases_found": "テストケースがありません", "case_title": "テストケースタイトル", diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CasesPane.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CasesPane.tsx index c0ea790..28b355e 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CasesPane.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CasesPane.tsx @@ -1,11 +1,11 @@ 'use client'; import { useState, useEffect, useContext } from 'react'; -import { TokenContext } from '@/utils/TokenProvider'; import TestCaseTable from './TestCaseTable'; -import { fetchCases, createCase, deleteCases } from '@/utils/caseControl'; +import CaseDialog from './CaseDialog'; +import { TokenContext } from '@/utils/TokenProvider'; +import { fetchCases, createCase, deleteCases, csvDownload } from '@/utils/caseControl'; import { CaseType, CasesMessages } from '@/types/case'; import DeleteConfirmDialog from '@/components/DeleteConfirmDialog'; -import CaseDialog from './CaseDialog'; import { PriorityMessages } from '@/types/priority'; import { LocaleCodeType } from '@/types/locale'; @@ -74,6 +74,10 @@ export default function CasesPane({ projectId, folderId, messages, priorityMessa } }; + const onCsvDownload = async () => { + await csvDownload(context.token.access_token, Number(folderId)); + }; + return ( <> setIsCaseDialogOpen(true)} onDeleteCase={onDeleteCase} onDeleteCases={onDeleteCases} + onCsvDownload={onCsvDownload} messages={messages} priorityMessages={priorityMessages} locale={locale} diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/TestCaseTable.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/TestCaseTable.tsx index d776422..0d63500 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/TestCaseTable.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/TestCaseTable.tsx @@ -14,7 +14,7 @@ import { Selection, SortDescriptor, } from '@heroui/react'; -import { Plus, MoreVertical, Trash } from 'lucide-react'; +import { Plus, MoreVertical, Trash, Download } from 'lucide-react'; import { Link } from '@/src/i18n/routing'; import { CaseType, CasesMessages } from '@/types/case'; import { PriorityMessages } from '@/types/priority'; @@ -28,6 +28,7 @@ type Props = { onCreateCase: () => void; onDeleteCase: (caseId: number) => void; onDeleteCases: (caseIds: number[]) => void; + onCsvDownload: () => void; messages: CasesMessages; priorityMessages: PriorityMessages; locale: LocaleCodeType; @@ -40,6 +41,7 @@ export default function TestCaseTable({ onCreateCase, onDeleteCase, onDeleteCases, + onCsvDownload, messages, priorityMessages, locale, @@ -154,6 +156,7 @@ export default function TestCaseTable({ <>

{messages.testCaseList}

+
{((selectedKeys !== 'all' && selectedKeys.size > 0) || selectedKeys === 'all') && (