Merge pull request #343 from kimatata/develop

This commit is contained in:
kimatata
2025-11-22 10:46:40 +09:00
committed by GitHub
20 changed files with 597 additions and 154 deletions

View File

@@ -19,6 +19,7 @@
"sequelize": "^6.37.7", "sequelize": "^6.37.7",
"sequelize-cli": "^6.6.3", "sequelize-cli": "^6.6.3",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"xmlbuilder2": "^3.1.1" "xmlbuilder2": "^3.1.1"
} }
}, },
@@ -585,9 +586,10 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0", "balanced-match": "^1.0.0",
"concat-map": "0.0.1" "concat-map": "0.0.1"
@@ -1008,27 +1010,6 @@
"node": ">= 0.8" "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": { "node_modules/end-of-stream": {
"version": "1.4.4", "version": "1.4.4",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
@@ -1114,9 +1095,11 @@
} }
}, },
"node_modules/express": { "node_modules/express": {
"version": "4.21.1", "version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"accepts": "~1.3.8", "accepts": "~1.3.8",
"array-flatten": "1.1.1", "array-flatten": "1.1.1",
@@ -1137,7 +1120,7 @@
"methods": "~1.1.2", "methods": "~1.1.2",
"on-finished": "2.4.1", "on-finished": "2.4.1",
"parseurl": "~1.3.3", "parseurl": "~1.3.3",
"path-to-regexp": "0.1.10", "path-to-regexp": "0.1.12",
"proxy-addr": "~2.0.7", "proxy-addr": "~2.0.7",
"qs": "6.13.0", "qs": "6.13.0",
"range-parser": "~1.2.1", "range-parser": "~1.2.1",
@@ -1152,6 +1135,10 @@
}, },
"engines": { "engines": {
"node": ">= 0.10.0" "node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/express-rate-limit": { "node_modules/express-rate-limit": {
@@ -2427,9 +2414,10 @@
} }
}, },
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {
"version": "0.1.10", "version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
}, },
"node_modules/pg-connection-string": { "node_modules/pg-connection-string": {
"version": "2.9.1", "version": "2.9.1",
@@ -3182,9 +3170,10 @@
} }
}, },
"node_modules/tar-fs": { "node_modules/tar-fs": {
"version": "2.1.1", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"chownr": "^1.1.1", "chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2", "mkdirp-classic": "^0.5.2",
@@ -3356,9 +3345,9 @@
} }
}, },
"node_modules/validator": { "node_modules/validator": {
"version": "13.15.15", "version": "13.15.20",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz", "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.20.tgz",
"integrity": "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==", "integrity": "sha512-KxPOq3V2LmfQPP4eqf3Mq/zrT0Dqp2Vmx2Bn285LwVahLc+CsxOM0crBHczm8ijlcjZ0Q5Xd6LW3z3odTPnlrw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.10" "node": ">= 0.10"
@@ -3457,6 +3446,18 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" "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": { "node_modules/xmlbuilder2": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-3.1.1.tgz", "resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-3.1.1.tgz",
@@ -3953,9 +3954,9 @@
} }
}, },
"brace-expansion": { "brace-expansion": {
"version": "1.1.11", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"requires": { "requires": {
"balanced-match": "^1.0.0", "balanced-match": "^1.0.0",
"concat-map": "0.0.1" "concat-map": "0.0.1"
@@ -4265,26 +4266,6 @@
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==" "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": { "end-of-stream": {
"version": "1.4.4", "version": "1.4.4",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
@@ -4344,9 +4325,10 @@
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==" "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="
}, },
"express": { "express": {
"version": "4.21.1", "version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"peer": true,
"requires": { "requires": {
"accepts": "~1.3.8", "accepts": "~1.3.8",
"array-flatten": "1.1.1", "array-flatten": "1.1.1",
@@ -4367,7 +4349,7 @@
"methods": "~1.1.2", "methods": "~1.1.2",
"on-finished": "2.4.1", "on-finished": "2.4.1",
"parseurl": "~1.3.3", "parseurl": "~1.3.3",
"path-to-regexp": "0.1.10", "path-to-regexp": "0.1.12",
"proxy-addr": "~2.0.7", "proxy-addr": "~2.0.7",
"qs": "6.13.0", "qs": "6.13.0",
"range-parser": "~1.2.1", "range-parser": "~1.2.1",
@@ -5292,9 +5274,9 @@
} }
}, },
"path-to-regexp": { "path-to-regexp": {
"version": "0.1.10", "version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
}, },
"pg-connection-string": { "pg-connection-string": {
"version": "2.9.1", "version": "2.9.1",
@@ -5807,9 +5789,9 @@
} }
}, },
"tar-fs": { "tar-fs": {
"version": "2.1.1", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"requires": { "requires": {
"chownr": "^1.1.1", "chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2", "mkdirp-classic": "^0.5.2",
@@ -5942,9 +5924,9 @@
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
}, },
"validator": { "validator": {
"version": "13.15.15", "version": "13.15.20",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz", "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.20.tgz",
"integrity": "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==" "integrity": "sha512-KxPOq3V2LmfQPP4eqf3Mq/zrT0Dqp2Vmx2Bn285LwVahLc+CsxOM0crBHczm8ijlcjZ0Q5Xd6LW3z3odTPnlrw=="
}, },
"vary": { "vary": {
"version": "1.1.2", "version": "1.1.2",
@@ -6014,6 +5996,10 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" "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": { "xmlbuilder2": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-3.1.1.tgz", "resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-3.1.1.tgz",

View File

@@ -23,6 +23,7 @@
"sequelize": "^6.37.7", "sequelize": "^6.37.7",
"sequelize-cli": "^6.6.3", "sequelize-cli": "^6.6.3",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"xmlbuilder2": "^3.1.1" "xmlbuilder2": "^3.1.1"
} }
} }

View File

@@ -3,12 +3,16 @@ const router = express.Router();
import { DataTypes } from 'sequelize'; import { DataTypes } from 'sequelize';
import Papa from 'papaparse'; import Papa from 'papaparse';
import defineCase from '../../models/cases.js'; import defineCase from '../../models/cases.js';
import defineStep from '../../models/steps.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 { 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) {
const Case = defineCase(sequelize, DataTypes); 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 { verifySignedIn } = authMiddleware(sequelize);
const { verifyProjectVisibleFromFolderId } = visibilityMiddleware(sequelize); const { verifyProjectVisibleFromFolderId } = visibilityMiddleware(sequelize);
@@ -25,6 +29,15 @@ export default function (sequelize) {
try { try {
const cases = await Case.findAll({ const cases = await Case.findAll({
attributes: { exclude: ['createdAt', 'updatedAt', 'caseSteps'] },
include: [
{
model: Step,
through: { attributes: [] },
order: [['stepNo', 'ASC']],
attributes: { exclude: ['createdAt', 'updatedAt'] },
},
],
where: { folderId }, where: { folderId },
raw: true, raw: true,
}); });
@@ -33,9 +46,6 @@ export default function (sequelize) {
return res.status(404).send('No cases found'); 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 // Convert numeric values to human-readable labels
const casesWithLabels = cases.map((c) => ({ const casesWithLabels = cases.map((c) => ({
...c, ...c,
@@ -46,7 +56,12 @@ export default function (sequelize) {
template: templates[c.template] || c.template, 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, quotes: true,
skipEmptyLines: true, skipEmptyLines: true,
}); });
@@ -65,3 +80,55 @@ export default function (sequelize) {
return router; 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'],
}));
};

View File

@@ -37,11 +37,19 @@ vi.mock('../../middleware/verifyVisible.js', () => ({
// mock defineCase // mock defineCase
const mockCase = { const mockCase = {
findAll: vi.fn(), findAll: vi.fn(),
belongsToMany: vi.fn(),
}; };
vi.mock('../../models/cases.js', () => ({ vi.mock('../../models/cases.js', () => ({
default: () => mockCase, default: () => mockCase,
})); }));
const mockStep = {
belongsToMany: vi.fn(),
};
vi.mock('../../models/steps.js', () => ({
default: () => mockStep,
}));
describe('GET /download with type=csv', () => { describe('GET /download with type=csv', () => {
let app; let app;
const sequelize = new Sequelize({ const sequelize = new Sequelize({

View File

@@ -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', 'priority', 'type', 'template'];
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;
}

View File

@@ -17,7 +17,7 @@ export default function (sequelize) {
Tags.belongsToMany(Case, { through: 'caseTags', foreignKey: 'tagId', otherKey: 'caseId' }); Tags.belongsToMany(Case, { through: 'caseTags', foreignKey: 'tagId', otherKey: 'caseId' });
router.get('/', verifySignedIn, verifyProjectVisibleFromFolderId, async (req, res) => { 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) { if (!folderId) {
return res.status(400).json({ error: 'folderId is required' }); return res.status(400).json({ error: 'folderId is required' });
@@ -28,15 +28,18 @@ export default function (sequelize) {
folderId: folderId, folderId: folderId,
}; };
if (title) { if (search) {
const searchTerm = title.trim(); const searchTerm = search.trim();
if (searchTerm.length > 100) { 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) { if (searchTerm.length >= 1) {
whereClause[Op.or] = [{ title: { [Op.like]: `%${searchTerm}%` } }]; whereClause[Op.or] = [
{ title: { [Op.like]: `%${searchTerm}%` } },
{ description: { [Op.like]: `%${searchTerm}%` } },
];
} }
} }

View File

@@ -100,6 +100,7 @@ import casesNewRoute from './routes/cases/new.js';
import casesEditRoute from './routes/cases/edit.js'; import casesEditRoute from './routes/cases/edit.js';
import casesDeleteRoute from './routes/cases/delete.js'; import casesDeleteRoute from './routes/cases/delete.js';
import casesCloneRoute from './routes/cases/clone.js'; import casesCloneRoute from './routes/cases/clone.js';
import casesImportRoute from './routes/cases/import.js';
app.use('/cases', casesDownloadRoute(sequelize)); app.use('/cases', casesDownloadRoute(sequelize));
app.use('/cases', casesMoveRoute(sequelize)); app.use('/cases', casesMoveRoute(sequelize));
app.use('/cases', casesIndexRoute(sequelize)); app.use('/cases', casesIndexRoute(sequelize));
@@ -109,6 +110,7 @@ app.use('/cases', casesNewRoute(sequelize));
app.use('/cases', casesEditRoute(sequelize)); app.use('/cases', casesEditRoute(sequelize));
app.use('/cases', casesDeleteRoute(sequelize)); app.use('/cases', casesDeleteRoute(sequelize));
app.use('/cases', casesCloneRoute(sequelize)); app.use('/cases', casesCloneRoute(sequelize));
app.use('/cases', casesImportRoute(sequelize));
// "/steps" // "/steps"
import stepsEditRoute from './routes/steps/edit.js'; import stepsEditRoute from './routes/steps/edit.js';

View File

@@ -223,6 +223,7 @@
"no_cases_found": "No test cases found", "no_cases_found": "No test cases found",
"case_title": "Test Case Title", "case_title": "Test Case Title",
"case_description": "Test Case Description", "case_description": "Test Case Description",
"case_title_or_description": "Test case title or description",
"create": "Create", "create": "Create",
"please_enter": "Please enter test case title", "please_enter": "Please enter test case title",
"filter": "Filter", "filter": "Filter",
@@ -239,7 +240,15 @@
"cases_moved": "Test cases moved", "cases_moved": "Test cases moved",
"cases_cloned": "Test cases cloned", "cases_cloned": "Test cases cloned",
"tags": "Tags", "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": { "Case": {
"back_to_cases": "Back to test cases", "back_to_cases": "Back to test cases",

View File

@@ -223,6 +223,7 @@
"no_cases_found": "テストケースがありません", "no_cases_found": "テストケースがありません",
"case_title": "テストケースタイトル", "case_title": "テストケースタイトル",
"case_description": "テストケース詳細", "case_description": "テストケース詳細",
"case_title_or_description": "テストケースのタイトルまたは説明",
"create": "作成", "create": "作成",
"please_enter": "テストケースタイトルを入力してください", "please_enter": "テストケースタイトルを入力してください",
"filter": "フィルター", "filter": "フィルター",
@@ -239,7 +240,15 @@
"cases_moved": "テストケースを移動しました", "cases_moved": "テストケースを移動しました",
"cases_cloned": "テストケースをクローンしました", "cases_cloned": "テストケースをクローンしました",
"tags": "タグ", "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": { "Case": {
"back_to_cases": "テストケース一覧に戻る", "back_to_cases": "テストケース一覧に戻る",

View File

@@ -223,6 +223,7 @@
"no_cases_found": "Nenhum caso de teste encontrado", "no_cases_found": "Nenhum caso de teste encontrado",
"case_title": "Título do Caso de Teste", "case_title": "Título do Caso de Teste",
"case_description": "Descrição 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", "create": "Criar",
"please_enter": "Por favor, insira o título do caso de teste", "please_enter": "Por favor, insira o título do caso de teste",
"filter": "Filtrar", "filter": "Filtrar",
@@ -239,7 +240,15 @@
"cases_moved": "Casos de teste movidos", "cases_moved": "Casos de teste movidos",
"cases_cloned": "Casos de teste clonados", "cases_cloned": "Casos de teste clonados",
"tags": "Tags", "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": { "Case": {
"back_to_cases": "Voltar para os casos de teste", "back_to_cases": "Voltar para os casos de teste",

View File

@@ -9988,9 +9988,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001700", "version": "1.0.30001754",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001700.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz",
"integrity": "sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ==", "integrity": "sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -10004,7 +10004,8 @@
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
] ],
"license": "CC-BY-4.0"
}, },
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "3.6.0", "version": "3.6.0",

View File

@@ -62,7 +62,7 @@ export default function HealthPage({ messages, locale }: Props) {
<TableBody> <TableBody>
<TableRow key="1"> <TableRow key="1">
<TableCell>{messages.unittcms_version}</TableCell> <TableCell>{messages.unittcms_version}</TableCell>
<TableCell>1.0.0-beta.23</TableCell> <TableCell>1.0.0-beta.24</TableCell>
</TableRow> </TableRow>
<TableRow key="2"> <TableRow key="2">
<TableCell>{messages.api_server}</TableCell> <TableCell>{messages.api_server}</TableCell>

View File

@@ -0,0 +1,125 @@
'use client';
import { useState, ChangeEvent, DragEvent } from 'react';
import { Button, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Spinner, Alert } from '@heroui/react';
import { ArrowUpFromLine } from 'lucide-react';
import { CasesMessages } from '@/types/case';
import { importCases } from '@/utils/caseControl';
type Props = {
isOpen: boolean;
folderId: number;
isDisabled: boolean;
onImport: () => 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<string | null>(null);
const handleDrop = (event: DragEvent<HTMLElement>) => {
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 (
<Modal
isOpen={isOpen}
onOpenChange={() => {
onCloseDialog();
}}
>
<ModalContent>
<ModalHeader className="flex flex-col gap-1">{messages.importCases}</ModalHeader>
<ModalBody>
<div className="flex items-center justify-center w-full">
<div className={`mt-1 text-neutral-500 dark:text-neutral-400 text-sm rounded`}>
<div>{messages.importAvailable}</div>
<a href="/template/unittcms-import-template-v1.xlsx" download className="text-tiny underline">
{messages.downloadTemplate}
</a>
</div>
</div>
{importError && <Alert color="danger" className="mt-1" title="error" description={importError} />}
<div
className="flex items-center justify-center w-full mt-3"
onDrop={(event) => {
if (isDisabled) {
return;
}
handleDrop(event);
}}
onDragOver={(event) => event.preventDefault()}
>
<label
htmlFor="dropzone-file"
className={`flex flex-col items-center justify-center w-full h-32 border-2 border-neutral-200 border-dashed rounded-lg bg-neutral-50 dark:hover:bg-bray-800 dark:bg-neutral-700 hover:bg-neutral-100 dark:border-neutral-600 dark:hover:border-neutral-500 dark:hover:bg-neutral-600 ${isDisabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
>
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<ArrowUpFromLine />
<p className="mb-2 text-sm text-neutral-500 dark:text-neutral-400">
<span className="font-semibold">{messages.clickToUpload}</span>
<span>{messages.orDragAndDrop}</span>
</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400">{messages.maxFileSize}: 50 MB</p>
</div>
<input
id="dropzone-file"
type="file"
className="hidden"
disabled={isDisabled}
onChange={(e) => handleInput(e)}
/>
</label>
</div>
</ModalBody>
<ModalFooter>
{isProcessing ? (
<Spinner />
) : (
<>
<Button variant="light" size="sm" onPress={onCloseDialog}>
{messages.close}
</Button>
</>
)}
</ModalFooter>
</ModalContent>
</Modal>
);
}

View File

@@ -1,9 +1,11 @@
'use client'; 'use client';
import { useState, useEffect, useContext, useCallback } from 'react'; import { useState, useEffect, useContext, useCallback } from 'react';
import { addToast } from '@heroui/react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import TestCaseTable from './TestCaseTable'; import TestCaseTable from './TestCaseTable';
import CaseDialog from './CaseDialog'; import CaseDialog from './CaseDialog';
import CaseMoveDialog from './CaseMoveDialog'; import CaseMoveDialog from './CaseMoveDialog';
import CaseImportDialog from './CaseImportDialog';
import DeleteConfirmDialog from '@/components/DeleteConfirmDialog'; import DeleteConfirmDialog from '@/components/DeleteConfirmDialog';
import { TokenContext } from '@/utils/TokenProvider'; import { TokenContext } from '@/utils/TokenProvider';
import { fetchCases, createCase, deleteCases, exportCases } from '@/utils/caseControl'; import { fetchCases, createCase, deleteCases, exportCases } from '@/utils/caseControl';
@@ -34,7 +36,7 @@ export default function CasesPane({
}: Props) { }: Props) {
const [cases, setCases] = useState<CaseType[]>([]); const [cases, setCases] = useState<CaseType[]>([]);
const [isCaseDialogOpen, setIsCaseDialogOpen] = useState(false); const [isCaseDialogOpen, setIsCaseDialogOpen] = useState(false);
const [titleFilter, setTitleFilter] = useState(''); const [searchFilter, setSearchFilter] = useState('');
const [priorityFilter, setPriorityFilter] = useState<number[]>([]); const [priorityFilter, setPriorityFilter] = useState<number[]>([]);
const [typeFilter, setTypeFilter] = useState<number[]>([]); const [typeFilter, setTypeFilter] = useState<number[]>([]);
const [tagFilter, setTagFilter] = useState<number[]>([]); const [tagFilter, setTagFilter] = useState<number[]>([]);
@@ -45,13 +47,13 @@ export default function CasesPane({
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); 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()); const currentParams = new URLSearchParams(searchParams.toString());
if (updates.title) { if (updates.search) {
currentParams.set('title', updates.title); currentParams.set('search', updates.search);
} else { } else {
currentParams.delete('title'); currentParams.delete('search');
} }
if (updates.priority && updates.priority.length > 0) { if (updates.priority && updates.priority.length > 0) {
@@ -76,16 +78,15 @@ export default function CasesPane({
router.push(newUrl, { scroll: false }); router.push(newUrl, { scroll: false });
}; };
useEffect(() => { const refreshCases = useCallback(async () => {
async function fetchDataEffect() {
if (!context.isSignedIn()) return; if (!context.isSignedIn()) return;
const titleParam = searchParams.get('title') || ''; const searchParam = searchParams.get('search') || '';
const priorityParam = parseQueryParam(searchParams.get('priority')); const priorityParam = parseQueryParam(searchParams.get('priority'));
const typeParam = parseQueryParam(searchParams.get('type')); const typeParam = parseQueryParam(searchParams.get('type'));
const tagParam = parseQueryParam(searchParams.get('tag')); const tagParam = parseQueryParam(searchParams.get('tag'));
setTitleFilter(titleParam); setSearchFilter(searchParam);
setPriorityFilter(priorityParam); setPriorityFilter(priorityParam);
setTypeFilter(typeParam); setTypeFilter(typeParam);
setTagFilter(tagParam); setTagFilter(tagParam);
@@ -94,7 +95,7 @@ export default function CasesPane({
const data = await fetchCases( const data = await fetchCases(
context.token.access_token, context.token.access_token,
Number(folderId), Number(folderId),
titleParam || undefined, searchParam || undefined,
priorityParam.length > 0 ? priorityParam : undefined, priorityParam.length > 0 ? priorityParam : undefined,
typeParam.length > 0 ? typeParam : undefined, typeParam.length > 0 ? typeParam : undefined,
tagParam.length > 0 ? tagParam : undefined tagParam.length > 0 ? tagParam : undefined
@@ -103,11 +104,12 @@ export default function CasesPane({
} catch (error: unknown) { } catch (error: unknown) {
logError('Error fetching cases:', error); logError('Error fetching cases:', error);
} }
}
fetchDataEffect();
}, [context, folderId, searchParams]); }, [context, folderId, searchParams]);
useEffect(() => {
refreshCases();
}, [refreshCases]);
const closeDialog = () => setIsCaseDialogOpen(false); const closeDialog = () => setIsCaseDialogOpen(false);
const onSubmit = async (title: string, description: string) => { const onSubmit = async (title: string, description: string) => {
@@ -143,12 +145,12 @@ export default function CasesPane({
await exportCases(context.token.access_token, Number(folderId), type); await exportCases(context.token.access_token, Number(folderId), type);
}; };
const handleFilterChange = (title: string, priorities: number[], types: number[], tag: number[]) => { const handleFilterChange = (search: string, priorities: number[], types: number[], tag: number[]) => {
setTitleFilter(title); setSearchFilter(search);
setPriorityFilter(priorities); setPriorityFilter(priorities);
setTypeFilter(types); setTypeFilter(types);
setTagFilter(tag); setTagFilter(tag);
updateUrlParams({ title: title, priority: priorities, type: types, tag: tag }); updateUrlParams({ search: search, priority: priorities, type: types, tag: tag });
}; };
// ************************************************************************** // **************************************************************************
@@ -175,6 +177,20 @@ export default function CasesPane({
return unsubscribe; return unsubscribe;
}, [openMoveDialog]); }, [openMoveDialog]);
// **************************************************************************
// Import cases
// **************************************************************************
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
const handleImport = () => {
refreshCases();
setIsImportDialogOpen(false);
addToast({
title: 'Success',
color: 'success',
description: messages.casesImported,
});
};
return ( return (
<> <>
<TestCaseTable <TestCaseTable
@@ -184,9 +200,10 @@ export default function CasesPane({
onCreateCase={() => setIsCaseDialogOpen(true)} onCreateCase={() => setIsCaseDialogOpen(true)}
onDeleteCase={onDeleteCase} onDeleteCase={onDeleteCase}
onDeleteCases={onDeleteCases} onDeleteCases={onDeleteCases}
onShowImportDialog={() => setIsImportDialogOpen(true)}
onExportCases={onExportCases} onExportCases={onExportCases}
onFilterChange={handleFilterChange} onFilterChange={handleFilterChange}
activeTitleFilter={titleFilter} activeSearchFilter={searchFilter}
activePriorityFilters={priorityFilter} activePriorityFilters={priorityFilter}
activeTypeFilters={typeFilter} activeTypeFilters={typeFilter}
activeTagFilters={tagFilter} activeTagFilters={tagFilter}
@@ -210,6 +227,16 @@ export default function CasesPane({
token={context.token.access_token} token={context.token.access_token}
/> />
<CaseImportDialog
isOpen={isImportDialogOpen}
folderId={Number(folderId)}
isDisabled={!context.isProjectDeveloper(Number(projectId))}
onImport={handleImport}
onCancel={() => setIsImportDialogOpen(false)}
messages={messages}
token={context.token.access_token}
/>
<DeleteConfirmDialog <DeleteConfirmDialog
isOpen={isDeleteConfirmDialogOpen} isOpen={isDeleteConfirmDialogOpen}
onCancel={closeDeleteConfirmDialog} onCancel={closeDeleteConfirmDialog}

View File

@@ -23,12 +23,12 @@ type TestCaseFilterProps = {
messages: CasesMessages; messages: CasesMessages;
priorityMessages: PriorityMessages; priorityMessages: PriorityMessages;
testTypeMessages: TestTypeMessages; testTypeMessages: TestTypeMessages;
activeTitleFilter: string; activeSearchFilter: string;
activePriorityFilters: number[]; activePriorityFilters: number[];
activeTypeFilters: number[]; activeTypeFilters: number[];
activeTagFilters: number[]; activeTagFilters: number[];
projectId: string; 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<TagType, 'id' | 'name'>; type Tag = Pick<TagType, 'id' | 'name'>;
@@ -37,7 +37,7 @@ export default function TestCaseFilter({
messages, messages,
priorityMessages, priorityMessages,
testTypeMessages, testTypeMessages,
activeTitleFilter, activeSearchFilter,
activePriorityFilters, activePriorityFilters,
activeTypeFilters, activeTypeFilters,
activeTagFilters, activeTagFilters,
@@ -45,7 +45,7 @@ export default function TestCaseFilter({
projectId, projectId,
}: TestCaseFilterProps) { }: TestCaseFilterProps) {
const tokenContext = useContext(TokenContext); const tokenContext = useContext(TokenContext);
const [title, setTitle] = useState<string>(''); const [search, setSearch] = useState<string>('');
const [selectedPriorities, setSelectedPriorities] = useState<Selection>(new Set([])); const [selectedPriorities, setSelectedPriorities] = useState<Selection>(new Set([]));
const [selectedTypes, setSelectedTypes] = useState<Selection>(new Set([])); const [selectedTypes, setSelectedTypes] = useState<Selection>(new Set([]));
const [selectedTags, setSelectedTags] = useState<Selection>(new Set([])); const [selectedTags, setSelectedTags] = useState<Selection>(new Set([]));
@@ -74,8 +74,8 @@ export default function TestCaseFilter({
}, [activeTagFilters]); }, [activeTagFilters]);
useEffect(() => { useEffect(() => {
setTitle(activeTitleFilter); setSearch(activeSearchFilter);
}, [activeTitleFilter]); }, [activeSearchFilter]);
useEffect(() => { useEffect(() => {
if (activePriorityFilters.length > 0) { if (activePriorityFilters.length > 0) {
@@ -125,7 +125,7 @@ export default function TestCaseFilter({
.filter((id) => !isNaN(id)); .filter((id) => !isNaN(id));
} }
onFilterChange(title, priorityIndices, typeIndices, tagIds); onFilterChange(search, priorityIndices, typeIndices, tagIds);
}; };
const handleClearFilter = () => { const handleClearFilter = () => {
@@ -137,7 +137,7 @@ export default function TestCaseFilter({
return ( return (
<div className="p-3"> <div className="p-3">
<div className="mb-3 space-y-1"> <div className="mb-3 space-y-1">
<h3 className="text-default-500 text-small">{messages.caseTitle}</h3> <h3 className="text-default-500 text-small">{messages.caseTitleOrDescription}</h3>
<Input <Input
variant="bordered" variant="bordered"
classNames={{ classNames={{
@@ -148,8 +148,8 @@ export default function TestCaseFilter({
size="sm" size="sm"
startContent={<SearchIcon size={18} />} startContent={<SearchIcon size={18} />}
type="search" type="search"
value={title} value={search}
onValueChange={setTitle} onValueChange={setSearch}
maxLength={100} maxLength={100}
/> />
</div> </div>

View File

@@ -20,6 +20,7 @@ import {
Plus, Plus,
MoreVertical, MoreVertical,
Trash, Trash,
FileUp,
FileDown, FileDown,
ChevronUp, ChevronUp,
ChevronDown, ChevronDown,
@@ -45,9 +46,10 @@ type Props = {
onCreateCase: () => void; onCreateCase: () => void;
onDeleteCase: (caseId: number) => void; onDeleteCase: (caseId: number) => void;
onDeleteCases: (caseIds: number[]) => void; onDeleteCases: (caseIds: number[]) => void;
onShowImportDialog: () => void;
onExportCases: (type: string) => void; onExportCases: (type: string) => void;
onFilterChange: (query: string, priorities: number[], types: number[], tag: number[]) => void; onFilterChange: (query: string, priorities: number[], types: number[], tag: number[]) => void;
activeTitleFilter: string; activeSearchFilter: string;
activePriorityFilters: number[]; activePriorityFilters: number[];
activeTypeFilters: number[]; activeTypeFilters: number[];
activeTagFilters: number[]; activeTagFilters: number[];
@@ -64,9 +66,10 @@ export default function TestCaseTable({
onCreateCase, onCreateCase,
onDeleteCase, onDeleteCase,
onDeleteCases, onDeleteCases,
onShowImportDialog,
onExportCases, onExportCases,
onFilterChange, onFilterChange,
activeTitleFilter, activeSearchFilter,
activePriorityFilters, activePriorityFilters,
activeTypeFilters, activeTypeFilters,
activeTagFilters, activeTagFilters,
@@ -105,7 +108,7 @@ export default function TestCaseTable({
> >
{highlightSearchTerm({ {highlightSearchTerm({
text: cellValue as string, text: cellValue as string,
searchTerm: activeTitleFilter, searchTerm: activeSearchFilter,
})} })}
</Link> </Link>
); );
@@ -147,7 +150,7 @@ export default function TestCaseTable({
} }
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[activeTitleFilter] [activeSearchFilter]
); );
// ************************************************************************** // **************************************************************************
@@ -155,7 +158,7 @@ export default function TestCaseTable({
// ************************************************************************** // **************************************************************************
const [showFilter, setShowFilter] = useState(false); const [showFilter, setShowFilter] = useState(false);
const activeFilterNum = const activeFilterNum =
(activeTitleFilter ? 1 : 0) + activePriorityFilters.length + activeTypeFilters.length + activeTagFilters.length; (activeSearchFilter ? 1 : 0) + activePriorityFilters.length + activeTypeFilters.length + activeTagFilters.length;
// ************************************************************************** // **************************************************************************
// select test case // select test case
@@ -318,7 +321,7 @@ export default function TestCaseTable({
messages={messages} messages={messages}
priorityMessages={priorityMessages} priorityMessages={priorityMessages}
testTypeMessages={testTypeMessages} testTypeMessages={testTypeMessages}
activeTitleFilter={activeTitleFilter} activeSearchFilter={activeSearchFilter}
activePriorityFilters={activePriorityFilters} activePriorityFilters={activePriorityFilters}
activeTypeFilters={activeTypeFilters} activeTypeFilters={activeTypeFilters}
activeTagFilters={activeTagFilters} activeTagFilters={activeTagFilters}
@@ -355,6 +358,15 @@ export default function TestCaseTable({
</DropdownItem> </DropdownItem>
</DropdownMenu> </DropdownMenu>
</Dropdown> </Dropdown>
<Button
startContent={<FileUp size={16} />}
size="sm"
variant="bordered"
className="me-2"
onPress={onShowImportDialog}
>
{messages.import}
</Button>
<Button <Button
startContent={<Plus size={16} />} startContent={<Plus size={16} />}
size="sm" size="sm"

View File

@@ -31,6 +31,7 @@ export default function Page({ params }: { params: { projectId: string; folderId
noCasesFound: t('no_cases_found'), noCasesFound: t('no_cases_found'),
caseTitle: t('case_title'), caseTitle: t('case_title'),
caseDescription: t('case_description'), caseDescription: t('case_description'),
caseTitleOrDescription: t('case_title_or_description'),
create: t('create'), create: t('create'),
pleaseEnter: t('please_enter'), pleaseEnter: t('please_enter'),
apply: t('apply'), apply: t('apply'),
@@ -48,6 +49,14 @@ export default function Page({ params }: { params: { projectId: string; folderId
casesCloned: t('cases_cloned'), casesCloned: t('cases_cloned'),
tags: t('tags'), tags: t('tags'),
selectTags: t('select_tags'), selectTags: t('select_tags'),
import: t('import'),
importCases: t('import_cases'),
importAvailable: t('import_available'),
downloadTemplate: t('download_template'),
clickToUpload: t('click_to_upload'),
orDragAndDrop: t('or_drag_and_drop'),
maxFileSize: t('max_file_size'),
casesImported: t('cases_imported'),
}; };
const priorityTranslation = useTranslations('Priority'); const priorityTranslation = useTranslations('Priority');

View File

@@ -79,6 +79,7 @@ type CasesMessages = {
noCasesFound: string; noCasesFound: string;
caseTitle: string; caseTitle: string;
caseDescription: string; caseDescription: string;
caseTitleOrDescription: string;
create: string; create: string;
pleaseEnter: string; pleaseEnter: string;
filter: string; filter: string;
@@ -96,6 +97,14 @@ type CasesMessages = {
tags: string; tags: string;
casesCloned: string; casesCloned: string;
selectTags: string; selectTags: string;
import: string;
importCases: string;
importAvailable: string;
downloadTemplate: string;
clickToUpload: string;
orDragAndDrop: string;
maxFileSize: string;
casesImported: string;
}; };
type CaseMessages = { type CaseMessages = {

View File

@@ -29,15 +29,15 @@ async function fetchCase(jwt: string, caseId: number) {
async function fetchCases( async function fetchCases(
jwt: string, jwt: string,
folderId: number, folderId: number,
title?: string, search?: string,
priority?: number[], priority?: number[],
type?: number[], type?: number[],
tag?: number[] tag?: number[]
) { ) {
const queryParams = [`folderId=${folderId}`]; const queryParams = [`folderId=${folderId}`];
if (title) { if (search) {
queryParams.push(`title=${title}`); queryParams.push(`search=${search}`);
} }
if (priority && priority.length > 0) { if (priority && priority.length > 0) {
@@ -235,4 +235,25 @@ async function exportCases(jwt: string, folderId: number, type: string) {
} }
} }
export { fetchCase, fetchCases, updateCase, createCase, deleteCases, cloneCases, exportCases }; async function importCases(jwt: string, folderId: number, file: File) {
const url = `${apiServer}/cases/import?folderId=${folderId}`;
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${jwt}`,
},
body: formData,
});
const data = await response.json();
return data;
} catch (error: unknown) {
logError('Error importing data', error);
}
}
export { fetchCase, fetchCases, updateCase, createCase, deleteCases, cloneCases, exportCases, importCases };