From 28edee590c5fd7e4daa0a3be350876872e20f8f6 Mon Sep 17 00:00:00 2001 From: Matheus Santana <83383321+matheusfsantana@users.noreply.github.com> Date: Mon, 10 Nov 2025 23:00:54 -0300 Subject: [PATCH 1/5] feat: import test cases from excel files (xlsx, xls) (#334) --- backend/package-lock.json | 89 ++++++++++++-------- backend/package.json | 1 + backend/routes/cases/import.js | 145 +++++++++++++++++++++++++++++++++ backend/server.js | 2 + 4 files changed, 205 insertions(+), 32 deletions(-) create mode 100644 backend/routes/cases/import.js diff --git a/backend/package-lock.json b/backend/package-lock.json index 9c8eee9..5523736 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -19,6 +19,7 @@ "sequelize": "^6.37.7", "sequelize-cli": "^6.6.3", "sqlite3": "^5.1.7", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "xmlbuilder2": "^3.1.1" } }, @@ -585,9 +586,10 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -1114,9 +1116,10 @@ } }, "node_modules/express": { - "version": "4.21.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", - "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -1137,7 +1140,7 @@ "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.10", + "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", @@ -1152,6 +1155,10 @@ }, "engines": { "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/express-rate-limit": { @@ -2427,9 +2434,10 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", - "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" }, "node_modules/pg-connection-string": { "version": "2.9.1", @@ -3182,9 +3190,10 @@ } }, "node_modules/tar-fs": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", - "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", @@ -3356,9 +3365,9 @@ } }, "node_modules/validator": { - "version": "13.15.15", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz", - "integrity": "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==", + "version": "13.15.20", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.20.tgz", + "integrity": "sha512-KxPOq3V2LmfQPP4eqf3Mq/zrT0Dqp2Vmx2Bn285LwVahLc+CsxOM0crBHczm8ijlcjZ0Q5Xd6LW3z3odTPnlrw==", "license": "MIT", "engines": { "node": ">= 0.10" @@ -3457,6 +3466,18 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, + "node_modules/xlsx": { + "version": "0.20.3", + "resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", + "integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==", + "license": "Apache-2.0", + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/xmlbuilder2": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-3.1.1.tgz", @@ -3953,9 +3974,9 @@ } }, "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -4344,9 +4365,9 @@ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==" }, "express": { - "version": "4.21.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", - "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "requires": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -4367,7 +4388,7 @@ "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.10", + "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", @@ -5292,9 +5313,9 @@ } }, "path-to-regexp": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", - "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" }, "pg-connection-string": { "version": "2.9.1", @@ -5807,9 +5828,9 @@ } }, "tar-fs": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", - "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", "requires": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", @@ -5942,9 +5963,9 @@ "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" }, "validator": { - "version": "13.15.15", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz", - "integrity": "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==" + "version": "13.15.20", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.20.tgz", + "integrity": "sha512-KxPOq3V2LmfQPP4eqf3Mq/zrT0Dqp2Vmx2Bn285LwVahLc+CsxOM0crBHczm8ijlcjZ0Q5Xd6LW3z3odTPnlrw==" }, "vary": { "version": "1.1.2", @@ -6014,6 +6035,10 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, + "xlsx": { + "version": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", + "integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==" + }, "xmlbuilder2": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-3.1.1.tgz", diff --git a/backend/package.json b/backend/package.json index 0a22155..07f13f7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -23,6 +23,7 @@ "sequelize": "^6.37.7", "sequelize-cli": "^6.6.3", "sqlite3": "^5.1.7", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "xmlbuilder2": "^3.1.1" } } diff --git a/backend/routes/cases/import.js b/backend/routes/cases/import.js new file mode 100644 index 0000000..cfd6088 --- /dev/null +++ b/backend/routes/cases/import.js @@ -0,0 +1,145 @@ +import path from 'path'; +import express from 'express'; +const router = express.Router(); +import multer from 'multer'; +import XLSX from 'xlsx'; +import { DataTypes } from 'sequelize'; +import defineCase from '../../models/cases.js'; +import authMiddleware from '../../middleware/auth.js'; +import editableMiddleware from '../../middleware/verifyEditable.js'; +import { priorities, testTypes, automationStatus, templates } from '../../config/enums.js'; + +const fileFilter = (req, file, cb) => { + const allowedFileTypes = ['.xlsx', '.xls']; + const allowedMimeTypes = [ + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-excel', + ]; + const extname = allowedFileTypes.includes(path.extname(file.originalname).toLowerCase()); + const mimetype = allowedMimeTypes.includes(file.mimetype); + + if (extname && mimetype) { + return cb(null, true); + } else { + cb(new Error('Only Excel files (.xlsx, .xls) are allowed!')); + } +}; + +const storage = multer.memoryStorage(); +const upload = multer({ + storage: storage, + fileFilter, + limits: { fileSize: 50 * 1024 * 1024 }, // 50 MB limit +}); + +export default function (sequelize) { + const Case = defineCase(sequelize, DataTypes); + const { verifySignedIn } = authMiddleware(sequelize); + const { verifyProjectDeveloperFromFolderId } = editableMiddleware(sequelize); + + router.post( + '/import', + (req, res, next) => { + upload.single('file')(req, res, function (err) { + if (err) { + return res.status(400).json({ error: err.message }); + } + next(); + }); + }, + verifySignedIn, + verifyProjectDeveloperFromFolderId, + async (req, res) => { + const { folderId } = req.query; + + if (!req.file || !req.file.buffer) { + return res.status(400).json({ error: 'No file uploaded' }); + } + + if (!folderId) { + return res.status(400).json({ error: 'folderId is required' }); + } + + try { + const workbook = XLSX.read(req.file.buffer, { type: 'buffer' }); + const sheetName = workbook.SheetNames[0]; + const worksheet = workbook.Sheets[sheetName]; + const jsonData = XLSX.utils.sheet_to_json(worksheet); + + let errorMessage = null; + const casesToCreate = []; + const requiredFields = ['title', 'preConditions', 'expectedResults']; + for (const [index, row] of jsonData.entries()) { + const rowNumber = index + 2; + for (const field of requiredFields) { + if (!row[field]) { + errorMessage = `Row ${rowNumber} is missing required field: ${field}`; + console.log(`Error found for field: ${errorMessage}`); + } + } + + // Validate priority if provided + let priorityIndex = priorities.indexOf('medium'); // default to 'medium' + if (row['priority']) { + priorityIndex = priorities.indexOf(row['priority'].toLowerCase()); + if (priorityIndex === -1) { + errorMessage = `Row ${rowNumber} has invalid priority: ${row['priority']}`; + } + } + + // Validate type if provided + let typeIndex = testTypes.indexOf('other'); // default to 'other' + if (row['type']) { + typeIndex = testTypes.indexOf(row['type'].toLowerCase()); + if (typeIndex === -1) { + errorMessage = `Row ${rowNumber} has invalid type: ${row['type']}`; + } + } + + // Validate automationStatus if provided + let automationStatusIndex = automationStatus.indexOf('automation-not-required'); // default to 'automation-not-required' + if (row['automationStatus']) { + automationStatusIndex = automationStatus.indexOf(row['automationStatus'].toLowerCase()); + if (automationStatusIndex === -1) { + errorMessage = `Row ${rowNumber} has invalid automationStatus: ${row['automationStatus']}`; + } + } + + // Validate template if provided + let templateIndex = templates.indexOf('text'); // default to 'text' + if (row['template']) { + templateIndex = templates.indexOf(row['template'].toLowerCase()); + if (templateIndex === -1) { + errorMessage = `Row ${rowNumber} has invalid template: ${row['template']}`; + } + } + + if (errorMessage) { + return res.status(400).json({ error: errorMessage }); + } + + casesToCreate.push({ + folderId: folderId, + title: row['title'], + description: row['description'] || '', + state: 0, // default state + priority: priorityIndex, + type: typeIndex, + preConditions: row['preConditions'], + expectedResults: row['expectedResults'], + automationStatus: automationStatusIndex, + template: templateIndex, + }); + } + + const createdCases = await Case.bulkCreate(casesToCreate); + res.json(createdCases); + } catch (error) { + console.error(error); + res.status(500).send('Internal Server Error'); + } + } + ); + + return router; +} diff --git a/backend/server.js b/backend/server.js index ea60146..db432b8 100644 --- a/backend/server.js +++ b/backend/server.js @@ -100,6 +100,7 @@ import casesNewRoute from './routes/cases/new.js'; import casesEditRoute from './routes/cases/edit.js'; import casesDeleteRoute from './routes/cases/delete.js'; import casesCloneRoute from './routes/cases/clone.js'; +import casesImportRoute from './routes/cases/import.js'; app.use('/cases', casesDownloadRoute(sequelize)); app.use('/cases', casesMoveRoute(sequelize)); app.use('/cases', casesIndexRoute(sequelize)); @@ -109,6 +110,7 @@ app.use('/cases', casesNewRoute(sequelize)); app.use('/cases', casesEditRoute(sequelize)); app.use('/cases', casesDeleteRoute(sequelize)); app.use('/cases', casesCloneRoute(sequelize)); +app.use('/cases', casesImportRoute(sequelize)); // "/steps" import stepsEditRoute from './routes/steps/edit.js'; From 068e0778055677c1d7625c3e8a42558d06f22b83 Mon Sep 17 00:00:00 2001 From: Matheus Santana <83383321+matheusfsantana@users.noreply.github.com> Date: Sat, 15 Nov 2025 06:10:03 -0300 Subject: [PATCH 2/5] feat: include steps on json and csv exports (#336) --- backend/routes/cases/download.js | 93 +++++++++++++++++++++++---- backend/routes/cases/download.test.js | 8 +++ 2 files changed, 88 insertions(+), 13 deletions(-) diff --git a/backend/routes/cases/download.js b/backend/routes/cases/download.js index ecaf162..ab2071d 100644 --- a/backend/routes/cases/download.js +++ b/backend/routes/cases/download.js @@ -3,12 +3,16 @@ const router = express.Router(); import { DataTypes } from 'sequelize'; import Papa from 'papaparse'; import defineCase from '../../models/cases.js'; +import defineStep from '../../models/steps.js'; import authMiddleware from '../../middleware/auth.js'; import visibilityMiddleware from '../../middleware/verifyVisible.js'; import { testRunStatus, priorities, testTypes, automationStatus, templates } from '../../config/enums.js'; export default function (sequelize) { const Case = defineCase(sequelize, DataTypes); + const Step = defineStep(sequelize, DataTypes); + Case.belongsToMany(Step, { through: 'caseSteps' }); + Step.belongsToMany(Case, { through: 'caseSteps' }); const { verifySignedIn } = authMiddleware(sequelize); const { verifyProjectVisibleFromFolderId } = visibilityMiddleware(sequelize); @@ -25,6 +29,15 @@ export default function (sequelize) { try { const cases = await Case.findAll({ + attributes: { exclude: ['createdAt', 'updatedAt', 'caseSteps'] }, + include: [ + { + model: Step, + through: { attributes: [] }, + order: [['stepNo', 'ASC']], + attributes: { exclude: ['createdAt', 'updatedAt'] }, + }, + ], where: { folderId }, raw: true, }); @@ -33,20 +46,22 @@ export default function (sequelize) { return res.status(404).send('No cases found'); } - if (type === 'json') { - return res.json(cases); - } else if (type === 'csv') { - // Convert numeric values to human-readable labels - const casesWithLabels = cases.map((c) => ({ - ...c, - state: testRunStatus[c.state] || c.state, - priority: priorities[c.priority] || c.priority, - type: testTypes[c.type] || c.type, - automationStatus: automationStatus[c.automationStatus] || c.automationStatus, - template: templates[c.template] || c.template, - })); + // Convert numeric values to human-readable labels + const casesWithLabels = cases.map((c) => ({ + ...c, + state: testRunStatus[c.state] || c.state, + priority: priorities[c.priority] || c.priority, + type: testTypes[c.type] || c.type, + automationStatus: automationStatus[c.automationStatus] || c.automationStatus, + template: templates[c.template] || c.template, + })); - const csv = Papa.unparse(casesWithLabels, { + if (type === 'json') { + const formattedJsonCases = _formatRawCasesToJson(casesWithLabels); + return res.json(formattedJsonCases); + } else if (type === 'csv') { + const formattedCsvCases = _formatRawCasesToCsv(casesWithLabels); + const csv = Papa.unparse(formattedCsvCases, { quotes: true, skipEmptyLines: true, }); @@ -65,3 +80,55 @@ export default function (sequelize) { return router; } + +// Group cases by caseId and format steps to Steps array to better visualization +const _formatRawCasesToJson = (cases) => { + const casesObject = {}; + + cases.forEach((c) => { + if (!casesObject[c.id]) { + casesObject[c.id] = { + id: c.id, + folderId: c.folderId, + title: c.title, + state: c.state, + priority: c.priority, + type: c.type, + automationStatus: c.automationStatus, + description: c.description, + template: c.template, + preConditions: c.preConditions, + expectedResults: c.expectedResults, + Steps: [], + }; + } + + if (c['Steps.id']) { + casesObject[c.id].Steps.push({ + step: c['Steps.step'], + expectedStepResult: c['Steps.result'], + }); + } + }); + + return casesObject; +}; + +// Rename fields to better CSV headers +const _formatRawCasesToCsv = (cases) => { + return cases.map((c) => ({ + id: c.id, + folderId: c.folderId, + title: c.title, + state: c.state, + priority: c.priority, + type: c.type, + automationStatus: c.automationStatus, + description: c.description, + template: c.template, + preConditions: c.preConditions, + expectedResults: c.expectedResults, + step: c['Steps.step'], + expectedStepResult: c['Steps.result'], + })); +}; diff --git a/backend/routes/cases/download.test.js b/backend/routes/cases/download.test.js index 38e36c3..06e60c2 100644 --- a/backend/routes/cases/download.test.js +++ b/backend/routes/cases/download.test.js @@ -37,11 +37,19 @@ vi.mock('../../middleware/verifyVisible.js', () => ({ // mock defineCase const mockCase = { findAll: vi.fn(), + belongsToMany: vi.fn(), }; vi.mock('../../models/cases.js', () => ({ default: () => mockCase, })); +const mockStep = { + belongsToMany: vi.fn(), +}; +vi.mock('../../models/steps.js', () => ({ + default: () => mockStep, +})); + describe('GET /download with type=csv', () => { let app; const sequelize = new Sequelize({ From 7db464e28b5d15f83525b43350cb04e9e95d6925 Mon Sep 17 00:00:00 2001 From: Matheus Santana <83383321+matheusfsantana@users.noreply.github.com> Date: Sat, 15 Nov 2025 06:21:30 -0300 Subject: [PATCH 3/5] feat: added logic to search test cases by title and description (#337) --- backend/routes/cases/index.js | 13 ++++++---- frontend/messages/en.json | 1 + frontend/messages/ja.json | 1 + frontend/messages/pt-BR.json | 1 + .../folders/[folderId]/cases/CasesPane.tsx | 24 +++++++++---------- .../[folderId]/cases/TestCaseFilter.tsx | 20 ++++++++-------- .../[folderId]/cases/TestCaseTable.tsx | 12 +++++----- .../folders/[folderId]/cases/page.tsx | 1 + frontend/types/case.ts | 1 + frontend/utils/caseControl.ts | 6 ++--- 10 files changed, 44 insertions(+), 36 deletions(-) diff --git a/backend/routes/cases/index.js b/backend/routes/cases/index.js index 6139440..9a87169 100644 --- a/backend/routes/cases/index.js +++ b/backend/routes/cases/index.js @@ -17,7 +17,7 @@ export default function (sequelize) { Tags.belongsToMany(Case, { through: 'caseTags', foreignKey: 'tagId', otherKey: 'caseId' }); router.get('/', verifySignedIn, verifyProjectVisibleFromFolderId, async (req, res) => { - const { folderId, title, priority, type, tag } = req.query; + const { folderId, search, priority, type, tag } = req.query; if (!folderId) { return res.status(400).json({ error: 'folderId is required' }); @@ -28,15 +28,18 @@ export default function (sequelize) { folderId: folderId, }; - if (title) { - const searchTerm = title.trim(); + if (search) { + const searchTerm = search.trim(); if (searchTerm.length > 100) { - return res.status(400).json({ error: 'too long title param' }); + return res.status(400).json({ error: 'too long search param' }); } if (searchTerm.length >= 1) { - whereClause[Op.or] = [{ title: { [Op.like]: `%${searchTerm}%` } }]; + whereClause[Op.or] = [ + { title: { [Op.like]: `%${searchTerm}%` } }, + { description: { [Op.like]: `%${searchTerm}%` } }, + ]; } } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index fc4cbac..d94ee65 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -223,6 +223,7 @@ "no_cases_found": "No test cases found", "case_title": "Test Case Title", "case_description": "Test Case Description", + "case_title_or_description": "Test case title or description", "create": "Create", "please_enter": "Please enter test case title", "filter": "Filter", diff --git a/frontend/messages/ja.json b/frontend/messages/ja.json index 5b961d6..46d64e8 100644 --- a/frontend/messages/ja.json +++ b/frontend/messages/ja.json @@ -223,6 +223,7 @@ "no_cases_found": "テストケースがありません", "case_title": "テストケースタイトル", "case_description": "テストケース詳細", + "case_title_or_description": "テストケースのタイトルまたは説明", "create": "作成", "please_enter": "テストケースタイトルを入力してください", "filter": "フィルター", diff --git a/frontend/messages/pt-BR.json b/frontend/messages/pt-BR.json index 784f172..881c5ca 100644 --- a/frontend/messages/pt-BR.json +++ b/frontend/messages/pt-BR.json @@ -223,6 +223,7 @@ "no_cases_found": "Nenhum caso de teste encontrado", "case_title": "Título do Caso de Teste", "case_description": "Descrição do Caso de Teste", + "case_title_or_description": "Título ou descrição do caso de teste", "create": "Criar", "please_enter": "Por favor, insira o título do caso de teste", "filter": "Filtrar", 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 3a28d36..fef0bc6 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 @@ -34,7 +34,7 @@ export default function CasesPane({ }: Props) { const [cases, setCases] = useState([]); const [isCaseDialogOpen, setIsCaseDialogOpen] = useState(false); - const [titleFilter, setTitleFilter] = useState(''); + const [searchFilter, setSearchFilter] = useState(''); const [priorityFilter, setPriorityFilter] = useState([]); const [typeFilter, setTypeFilter] = useState([]); const [tagFilter, setTagFilter] = useState([]); @@ -45,13 +45,13 @@ export default function CasesPane({ const router = useRouter(); const searchParams = useSearchParams(); - const updateUrlParams = (updates: { title?: string; priority?: number[]; type?: number[]; tag?: number[] }) => { + const updateUrlParams = (updates: { search?: string; priority?: number[]; type?: number[]; tag?: number[] }) => { const currentParams = new URLSearchParams(searchParams.toString()); - if (updates.title) { - currentParams.set('title', updates.title); + if (updates.search) { + currentParams.set('search', updates.search); } else { - currentParams.delete('title'); + currentParams.delete('search'); } if (updates.priority && updates.priority.length > 0) { @@ -80,12 +80,12 @@ export default function CasesPane({ async function fetchDataEffect() { if (!context.isSignedIn()) return; - const titleParam = searchParams.get('title') || ''; + const searchParam = searchParams.get('search') || ''; const priorityParam = parseQueryParam(searchParams.get('priority')); const typeParam = parseQueryParam(searchParams.get('type')); const tagParam = parseQueryParam(searchParams.get('tag')); - setTitleFilter(titleParam); + setSearchFilter(searchParam); setPriorityFilter(priorityParam); setTypeFilter(typeParam); setTagFilter(tagParam); @@ -94,7 +94,7 @@ export default function CasesPane({ const data = await fetchCases( context.token.access_token, Number(folderId), - titleParam || undefined, + searchParam || undefined, priorityParam.length > 0 ? priorityParam : undefined, typeParam.length > 0 ? typeParam : undefined, tagParam.length > 0 ? tagParam : undefined @@ -143,12 +143,12 @@ export default function CasesPane({ await exportCases(context.token.access_token, Number(folderId), type); }; - const handleFilterChange = (title: string, priorities: number[], types: number[], tag: number[]) => { - setTitleFilter(title); + const handleFilterChange = (search: string, priorities: number[], types: number[], tag: number[]) => { + setSearchFilter(search); setPriorityFilter(priorities); setTypeFilter(types); setTagFilter(tag); - updateUrlParams({ title: title, priority: priorities, type: types, tag: tag }); + updateUrlParams({ search: search, priority: priorities, type: types, tag: tag }); }; // ************************************************************************** @@ -186,7 +186,7 @@ export default function CasesPane({ onDeleteCases={onDeleteCases} onExportCases={onExportCases} onFilterChange={handleFilterChange} - activeTitleFilter={titleFilter} + activeSearchFilter={searchFilter} activePriorityFilters={priorityFilter} activeTypeFilters={typeFilter} activeTagFilters={tagFilter} diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/TestCaseFilter.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/TestCaseFilter.tsx index d7c04e3..5c12f3b 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/TestCaseFilter.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/TestCaseFilter.tsx @@ -23,12 +23,12 @@ type TestCaseFilterProps = { messages: CasesMessages; priorityMessages: PriorityMessages; testTypeMessages: TestTypeMessages; - activeTitleFilter: string; + activeSearchFilter: string; activePriorityFilters: number[]; activeTypeFilters: number[]; activeTagFilters: number[]; projectId: string; - onFilterChange: (title: string, priorities: number[], types: number[], tags: number[]) => void; + onFilterChange: (search: string, priorities: number[], types: number[], tags: number[]) => void; }; type Tag = Pick; @@ -37,7 +37,7 @@ export default function TestCaseFilter({ messages, priorityMessages, testTypeMessages, - activeTitleFilter, + activeSearchFilter, activePriorityFilters, activeTypeFilters, activeTagFilters, @@ -45,7 +45,7 @@ export default function TestCaseFilter({ projectId, }: TestCaseFilterProps) { const tokenContext = useContext(TokenContext); - const [title, setTitle] = useState(''); + const [search, setSearch] = useState(''); const [selectedPriorities, setSelectedPriorities] = useState(new Set([])); const [selectedTypes, setSelectedTypes] = useState(new Set([])); const [selectedTags, setSelectedTags] = useState(new Set([])); @@ -74,8 +74,8 @@ export default function TestCaseFilter({ }, [activeTagFilters]); useEffect(() => { - setTitle(activeTitleFilter); - }, [activeTitleFilter]); + setSearch(activeSearchFilter); + }, [activeSearchFilter]); useEffect(() => { if (activePriorityFilters.length > 0) { @@ -125,7 +125,7 @@ export default function TestCaseFilter({ .filter((id) => !isNaN(id)); } - onFilterChange(title, priorityIndices, typeIndices, tagIds); + onFilterChange(search, priorityIndices, typeIndices, tagIds); }; const handleClearFilter = () => { @@ -137,7 +137,7 @@ export default function TestCaseFilter({ return (
-

{messages.caseTitle}

+

{messages.caseTitleOrDescription}

} type="search" - value={title} - onValueChange={setTitle} + value={search} + onValueChange={setSearch} maxLength={100} />
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 35f4b44..62e2a88 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 @@ -47,7 +47,7 @@ type Props = { onDeleteCases: (caseIds: number[]) => void; onExportCases: (type: string) => void; onFilterChange: (query: string, priorities: number[], types: number[], tag: number[]) => void; - activeTitleFilter: string; + activeSearchFilter: string; activePriorityFilters: number[]; activeTypeFilters: number[]; activeTagFilters: number[]; @@ -66,7 +66,7 @@ export default function TestCaseTable({ onDeleteCases, onExportCases, onFilterChange, - activeTitleFilter, + activeSearchFilter, activePriorityFilters, activeTypeFilters, activeTagFilters, @@ -105,7 +105,7 @@ export default function TestCaseTable({ > {highlightSearchTerm({ text: cellValue as string, - searchTerm: activeTitleFilter, + searchTerm: activeSearchFilter, })} ); @@ -147,7 +147,7 @@ export default function TestCaseTable({ } }, // eslint-disable-next-line react-hooks/exhaustive-deps - [activeTitleFilter] + [activeSearchFilter] ); // ************************************************************************** @@ -155,7 +155,7 @@ export default function TestCaseTable({ // ************************************************************************** const [showFilter, setShowFilter] = useState(false); const activeFilterNum = - (activeTitleFilter ? 1 : 0) + activePriorityFilters.length + activeTypeFilters.length + activeTagFilters.length; + (activeSearchFilter ? 1 : 0) + activePriorityFilters.length + activeTypeFilters.length + activeTagFilters.length; // ************************************************************************** // select test case @@ -318,7 +318,7 @@ export default function TestCaseTable({ messages={messages} priorityMessages={priorityMessages} testTypeMessages={testTypeMessages} - activeTitleFilter={activeTitleFilter} + activeSearchFilter={activeSearchFilter} activePriorityFilters={activePriorityFilters} activeTypeFilters={activeTypeFilters} activeTagFilters={activeTagFilters} diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/page.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/page.tsx index 4f87d89..e67e174 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/page.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/page.tsx @@ -31,6 +31,7 @@ export default function Page({ params }: { params: { projectId: string; folderId noCasesFound: t('no_cases_found'), caseTitle: t('case_title'), caseDescription: t('case_description'), + caseTitleOrDescription: t('case_title_or_description'), create: t('create'), pleaseEnter: t('please_enter'), apply: t('apply'), diff --git a/frontend/types/case.ts b/frontend/types/case.ts index 64cccad..b647534 100644 --- a/frontend/types/case.ts +++ b/frontend/types/case.ts @@ -79,6 +79,7 @@ type CasesMessages = { noCasesFound: string; caseTitle: string; caseDescription: string; + caseTitleOrDescription: string; create: string; pleaseEnter: string; filter: string; diff --git a/frontend/utils/caseControl.ts b/frontend/utils/caseControl.ts index 69b518f..a6b454d 100644 --- a/frontend/utils/caseControl.ts +++ b/frontend/utils/caseControl.ts @@ -29,15 +29,15 @@ async function fetchCase(jwt: string, caseId: number) { async function fetchCases( jwt: string, folderId: number, - title?: string, + search?: string, priority?: number[], type?: number[], tag?: number[] ) { const queryParams = [`folderId=${folderId}`]; - if (title) { - queryParams.push(`title=${title}`); + if (search) { + queryParams.push(`search=${search}`); } if (priority && priority.length > 0) { From b986f39c420db79ee680cc0e4f1887809f294416 Mon Sep 17 00:00:00 2001 From: kimatata <117462761+kimatata@users.noreply.github.com> Date: Sat, 22 Nov 2025 10:32:18 +0900 Subject: [PATCH 4/5] feat: import test cases UI using Excel (#339) --- backend/package-lock.json | 43 +----- backend/routes/cases/import.js | 2 +- frontend/messages/en.json | 10 +- frontend/messages/ja.json | 10 +- frontend/messages/pt-BR.json | 10 +- frontend/package-lock.json | 9 +- .../template/unittcms-import-template-v1.xlsx | Bin 0 -> 17586 bytes .../[folderId]/cases/CaseImportDialog.tsx | 125 ++++++++++++++++++ .../folders/[folderId]/cases/CasesPane.tsx | 79 +++++++---- .../[folderId]/cases/TestCaseTable.tsx | 12 ++ .../folders/[folderId]/cases/page.tsx | 8 ++ frontend/types/case.ts | 8 ++ frontend/utils/caseControl.ts | 23 +++- 13 files changed, 263 insertions(+), 76 deletions(-) create mode 100644 frontend/public/template/unittcms-import-template-v1.xlsx create mode 100644 frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CaseImportDialog.tsx diff --git a/backend/package-lock.json b/backend/package-lock.json index 5523736..e5ae6ac 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1010,27 +1010,6 @@ "node": ">= 0.8" } }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -1120,6 +1099,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -4286,26 +4266,6 @@ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==" }, - "encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "optional": true, - "requires": { - "iconv-lite": "^0.6.2" - }, - "dependencies": { - "iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "optional": true, - "requires": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - } - } - } - }, "end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -4368,6 +4328,7 @@ "version": "4.21.2", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "peer": true, "requires": { "accepts": "~1.3.8", "array-flatten": "1.1.1", diff --git a/backend/routes/cases/import.js b/backend/routes/cases/import.js index cfd6088..9b07cfd 100644 --- a/backend/routes/cases/import.js +++ b/backend/routes/cases/import.js @@ -68,7 +68,7 @@ export default function (sequelize) { let errorMessage = null; const casesToCreate = []; - const requiredFields = ['title', 'preConditions', 'expectedResults']; + const requiredFields = ['title', 'priority', 'type', 'template']; for (const [index, row] of jsonData.entries()) { const rowNumber = index + 2; for (const field of requiredFields) { diff --git a/frontend/messages/en.json b/frontend/messages/en.json index d94ee65..eee362c 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -240,7 +240,15 @@ "cases_moved": "Test cases moved", "cases_cloned": "Test cases cloned", "tags": "Tags", - "select_tags": "Select tags" + "select_tags": "Select tags", + "import": "Import", + "import_cases": "Import Test Cases", + "import_available": "You can import test cases from Excel file (xlsx, xls). The Excel file to be imported must follow the specified format.", + "download_template": "Download Template", + "click_to_upload": "Click to upload", + "or_drag_and_drop": " or drag and drop", + "max_file_size": "Max. file size", + "cases_imported": "Test cases imported" }, "Case": { "back_to_cases": "Back to test cases", diff --git a/frontend/messages/ja.json b/frontend/messages/ja.json index 46d64e8..53229ec 100644 --- a/frontend/messages/ja.json +++ b/frontend/messages/ja.json @@ -240,7 +240,15 @@ "cases_moved": "テストケースを移動しました", "cases_cloned": "テストケースをクローンしました", "tags": "タグ", - "select_tags": "タグを選択" + "select_tags": "タグを選択", + "import": "インポート", + "import_cases": "テストケースのインポート", + "import_available": "Excelファイル(xlsx, xls)からテストケースをインポートできます。インポートするExcelファイルは既定のフォーマットに従う必要があります。", + "download_template": "テンプレートをダウンロード", + "click_to_upload": "クリックしてアップロード", + "or_drag_and_drop": "またはドラッグアンドドロップ", + "max_file_size": "最大ファイルサイズ", + "cases_imported": "テストケースがインポートされました" }, "Case": { "back_to_cases": "テストケース一覧に戻る", diff --git a/frontend/messages/pt-BR.json b/frontend/messages/pt-BR.json index 881c5ca..3f3b767 100644 --- a/frontend/messages/pt-BR.json +++ b/frontend/messages/pt-BR.json @@ -240,7 +240,15 @@ "cases_moved": "Casos de teste movidos", "cases_cloned": "Casos de teste clonados", "tags": "Tags", - "select_tags": "Selecionar tags" + "select_tags": "Selecionar tags", + "import": "Importar", + "import_cases": "Importar Casos de Teste", + "import_available": "Você pode importar casos de teste a partir de um arquivo Excel (xlsx, xls). O arquivo Excel a ser importado deve seguir o formato especificado.", + "download_template": "Baixar Modelo", + "click_to_upload": "Clique para enviar", + "or_drag_and_drop": " ou arraste e solte", + "max_file_size": "Tamanho máx. do arquivo", + "cases_imported": "Casos de teste importados" }, "Case": { "back_to_cases": "Voltar para os casos de teste", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d93f099..2b167f8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9988,9 +9988,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001700", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001700.tgz", - "integrity": "sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ==", + "version": "1.0.30001754", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz", + "integrity": "sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==", "funding": [ { "type": "opencollective", @@ -10004,7 +10004,8 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/chokidar": { "version": "3.6.0", diff --git a/frontend/public/template/unittcms-import-template-v1.xlsx b/frontend/public/template/unittcms-import-template-v1.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..9a89fe33ac17f49f0a71c7b5f5db14a77e86991b GIT binary patch literal 17586 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 j*XTd8H}n31{U0l~oFwFrSQ`Wl`1c3_0+P=6 void; + onCancel: () => void; + messages: CasesMessages; + token: string; +}; + +export default function CaseImportDialog({ isOpen, folderId, isDisabled, onImport, onCancel, messages, token }: Props) { + const [isProcessing, setIsProcessing] = useState(false); + const [importError, setImportError] = useState(null); + + const handleDrop = (event: DragEvent) => { + event.preventDefault(); + if (event.dataTransfer) { + const filesArray = Array.from(event.dataTransfer.files); + handleFiles(filesArray); + } + }; + + const handleInput = (event: ChangeEvent) => { + if (event.target) { + const input = event.target as HTMLInputElement; + if (input.files) { + handleFiles(Array.from(input.files)); + } + } + }; + + const handleFiles = async (filesArray: File[]) => { + setIsProcessing(true); + + if (filesArray.length !== 1) { + console.error('Error multiple file'); + } else { + const ret = await importCases(token, folderId, filesArray[0]); + if (ret.error) { + setImportError(ret.error); + } else { + onImport(); + } + } + setIsProcessing(false); + }; + + const onCloseDialog = () => { + setImportError(null); + onCancel(); + }; + + return ( + { + onCloseDialog(); + }} + > + + {messages.importCases} + +
+
+
{messages.importAvailable}
+ + {messages.downloadTemplate} + +
+
+ {importError && } +
{ + if (isDisabled) { + return; + } + handleDrop(event); + }} + onDragOver={(event) => event.preventDefault()} + > + +
+
+ + {isProcessing ? ( + + ) : ( + <> + + + )} + +
+
+ ); +} 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 fef0bc6..5be48ed 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,9 +1,11 @@ 'use client'; import { useState, useEffect, useContext, useCallback } from 'react'; +import { addToast } from '@heroui/react'; import { useRouter, useSearchParams } from 'next/navigation'; import TestCaseTable from './TestCaseTable'; import CaseDialog from './CaseDialog'; import CaseMoveDialog from './CaseMoveDialog'; +import CaseImportDialog from './CaseImportDialog'; import DeleteConfirmDialog from '@/components/DeleteConfirmDialog'; import { TokenContext } from '@/utils/TokenProvider'; import { fetchCases, createCase, deleteCases, exportCases } from '@/utils/caseControl'; @@ -76,38 +78,38 @@ export default function CasesPane({ router.push(newUrl, { scroll: false }); }; - useEffect(() => { - async function fetchDataEffect() { - if (!context.isSignedIn()) return; + const refreshCases = useCallback(async () => { + if (!context.isSignedIn()) return; - const searchParam = searchParams.get('search') || ''; - const priorityParam = parseQueryParam(searchParams.get('priority')); - const typeParam = parseQueryParam(searchParams.get('type')); - const tagParam = parseQueryParam(searchParams.get('tag')); + const searchParam = searchParams.get('search') || ''; + const priorityParam = parseQueryParam(searchParams.get('priority')); + const typeParam = parseQueryParam(searchParams.get('type')); + const tagParam = parseQueryParam(searchParams.get('tag')); - setSearchFilter(searchParam); - setPriorityFilter(priorityParam); - setTypeFilter(typeParam); - setTagFilter(tagParam); + setSearchFilter(searchParam); + setPriorityFilter(priorityParam); + setTypeFilter(typeParam); + setTagFilter(tagParam); - try { - const data = await fetchCases( - context.token.access_token, - Number(folderId), - searchParam || undefined, - priorityParam.length > 0 ? priorityParam : undefined, - typeParam.length > 0 ? typeParam : undefined, - tagParam.length > 0 ? tagParam : undefined - ); - setCases(data); - } catch (error: unknown) { - logError('Error fetching cases:', error); - } + try { + const data = await fetchCases( + context.token.access_token, + Number(folderId), + searchParam || undefined, + priorityParam.length > 0 ? priorityParam : undefined, + typeParam.length > 0 ? typeParam : undefined, + tagParam.length > 0 ? tagParam : undefined + ); + setCases(data); + } catch (error: unknown) { + logError('Error fetching cases:', error); } - - fetchDataEffect(); }, [context, folderId, searchParams]); + useEffect(() => { + refreshCases(); + }, [refreshCases]); + const closeDialog = () => setIsCaseDialogOpen(false); const onSubmit = async (title: string, description: string) => { @@ -175,6 +177,20 @@ export default function CasesPane({ return unsubscribe; }, [openMoveDialog]); + // ************************************************************************** + // Import cases + // ************************************************************************** + const [isImportDialogOpen, setIsImportDialogOpen] = useState(false); + const handleImport = () => { + refreshCases(); + setIsImportDialogOpen(false); + addToast({ + title: 'Success', + color: 'success', + description: messages.casesImported, + }); + }; + return ( <> setIsCaseDialogOpen(true)} onDeleteCase={onDeleteCase} onDeleteCases={onDeleteCases} + onShowImportDialog={() => setIsImportDialogOpen(true)} onExportCases={onExportCases} onFilterChange={handleFilterChange} activeSearchFilter={searchFilter} @@ -210,6 +227,16 @@ export default function CasesPane({ token={context.token.access_token} /> + setIsImportDialogOpen(false)} + messages={messages} + token={context.token.access_token} + /> + void; onDeleteCase: (caseId: number) => void; onDeleteCases: (caseIds: number[]) => void; + onShowImportDialog: () => void; onExportCases: (type: string) => void; onFilterChange: (query: string, priorities: number[], types: number[], tag: number[]) => void; activeSearchFilter: string; @@ -64,6 +66,7 @@ export default function TestCaseTable({ onCreateCase, onDeleteCase, onDeleteCases, + onShowImportDialog, onExportCases, onFilterChange, activeSearchFilter, @@ -355,6 +358,15 @@ export default function TestCaseTable({ +