feat: add folder on export and handle steps on import (#352)
This commit is contained in:
@@ -4,6 +4,7 @@ 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 defineStep from '../../models/steps.js';
|
||||||
|
import defineFolder from '../../models/folders.js';
|
||||||
import authMiddleware from '../../middleware/auth.js';
|
import authMiddleware from '../../middleware/auth.js';
|
||||||
import visibilityMiddleware from '../../middleware/verifyVisible.js';
|
import visibilityMiddleware from '../../middleware/verifyVisible.js';
|
||||||
import { testRunStatus, priorities, testTypes, automationStatus, templates } from '../../config/enums.js';
|
import { testRunStatus, priorities, testTypes, automationStatus, templates } from '../../config/enums.js';
|
||||||
@@ -11,6 +12,8 @@ import { testRunStatus, priorities, testTypes, automationStatus, templates } fro
|
|||||||
export default function (sequelize) {
|
export default function (sequelize) {
|
||||||
const Case = defineCase(sequelize, DataTypes);
|
const Case = defineCase(sequelize, DataTypes);
|
||||||
const Step = defineStep(sequelize, DataTypes);
|
const Step = defineStep(sequelize, DataTypes);
|
||||||
|
const Folder = defineFolder(sequelize, DataTypes);
|
||||||
|
Case.belongsTo(Folder);
|
||||||
Case.belongsToMany(Step, { through: 'caseSteps' });
|
Case.belongsToMany(Step, { through: 'caseSteps' });
|
||||||
Step.belongsToMany(Case, { through: 'caseSteps' });
|
Step.belongsToMany(Case, { through: 'caseSteps' });
|
||||||
const { verifySignedIn } = authMiddleware(sequelize);
|
const { verifySignedIn } = authMiddleware(sequelize);
|
||||||
@@ -37,6 +40,10 @@ export default function (sequelize) {
|
|||||||
order: [['stepNo', 'ASC']],
|
order: [['stepNo', 'ASC']],
|
||||||
attributes: { exclude: ['createdAt', 'updatedAt'] },
|
attributes: { exclude: ['createdAt', 'updatedAt'] },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
model: Folder,
|
||||||
|
attributes: ['name'],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
where: { folderId },
|
where: { folderId },
|
||||||
raw: true,
|
raw: true,
|
||||||
@@ -90,6 +97,7 @@ const _formatRawCasesToJson = (cases) => {
|
|||||||
casesObject[c.id] = {
|
casesObject[c.id] = {
|
||||||
id: c.id,
|
id: c.id,
|
||||||
folderId: c.folderId,
|
folderId: c.folderId,
|
||||||
|
folder: c['Folder.name'],
|
||||||
title: c.title,
|
title: c.title,
|
||||||
state: c.state,
|
state: c.state,
|
||||||
priority: c.priority,
|
priority: c.priority,
|
||||||
@@ -119,6 +127,7 @@ const _formatRawCasesToCsv = (cases) => {
|
|||||||
return cases.map((c) => ({
|
return cases.map((c) => ({
|
||||||
id: c.id,
|
id: c.id,
|
||||||
folderId: c.folderId,
|
folderId: c.folderId,
|
||||||
|
folder: c['Folder.name'],
|
||||||
title: c.title,
|
title: c.title,
|
||||||
state: c.state,
|
state: c.state,
|
||||||
priority: c.priority,
|
priority: c.priority,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ vi.mock('../../middleware/verifyVisible.js', () => ({
|
|||||||
const mockCase = {
|
const mockCase = {
|
||||||
findAll: vi.fn(),
|
findAll: vi.fn(),
|
||||||
belongsToMany: vi.fn(),
|
belongsToMany: vi.fn(),
|
||||||
|
belongsTo: vi.fn(),
|
||||||
};
|
};
|
||||||
vi.mock('../../models/cases.js', () => ({
|
vi.mock('../../models/cases.js', () => ({
|
||||||
default: () => mockCase,
|
default: () => mockCase,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import multer from 'multer';
|
|||||||
import XLSX from 'xlsx';
|
import XLSX from 'xlsx';
|
||||||
import { DataTypes } from 'sequelize';
|
import { DataTypes } from 'sequelize';
|
||||||
import defineCase from '../../models/cases.js';
|
import defineCase from '../../models/cases.js';
|
||||||
|
import defineStep from '../../models/steps.js';
|
||||||
|
import defineCaseStep from '../../models/caseSteps.js';
|
||||||
import authMiddleware from '../../middleware/auth.js';
|
import authMiddleware from '../../middleware/auth.js';
|
||||||
import editableMiddleware from '../../middleware/verifyEditable.js';
|
import editableMiddleware from '../../middleware/verifyEditable.js';
|
||||||
import { priorities, testTypes, automationStatus, templates } from '../../config/enums.js';
|
import { priorities, testTypes, automationStatus, templates } from '../../config/enums.js';
|
||||||
@@ -34,6 +36,10 @@ const upload = multer({
|
|||||||
|
|
||||||
export default function (sequelize) {
|
export default function (sequelize) {
|
||||||
const Case = defineCase(sequelize, DataTypes);
|
const Case = defineCase(sequelize, DataTypes);
|
||||||
|
const Step = defineStep(sequelize, DataTypes);
|
||||||
|
const CaseStep = defineCaseStep(sequelize, DataTypes);
|
||||||
|
Case.belongsToMany(Step, { through: CaseStep });
|
||||||
|
Step.belongsToMany(Case, { through: CaseStep });
|
||||||
const { verifySignedIn } = authMiddleware(sequelize);
|
const { verifySignedIn } = authMiddleware(sequelize);
|
||||||
const { verifyProjectDeveloperFromFolderId } = editableMiddleware(sequelize);
|
const { verifyProjectDeveloperFromFolderId } = editableMiddleware(sequelize);
|
||||||
|
|
||||||
@@ -60,86 +66,135 @@ export default function (sequelize) {
|
|||||||
return res.status(400).json({ error: 'folderId is required' });
|
return res.status(400).json({ error: 'folderId is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const t = await sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
const workbook = XLSX.read(req.file.buffer, { type: 'buffer' });
|
const workbook = XLSX.read(req.file.buffer, { type: 'buffer' });
|
||||||
const sheetName = workbook.SheetNames[0];
|
const sheetName = workbook.SheetNames[0];
|
||||||
const worksheet = workbook.Sheets[sheetName];
|
const worksheet = workbook.Sheets[sheetName];
|
||||||
const jsonData = XLSX.utils.sheet_to_json(worksheet);
|
const jsonData = XLSX.utils.sheet_to_json(worksheet);
|
||||||
|
|
||||||
let errorMessage = null;
|
let currentTitle = null;
|
||||||
|
let previousTitle = null;
|
||||||
|
let stepNo = 1;
|
||||||
const casesToCreate = [];
|
const casesToCreate = [];
|
||||||
const requiredFields = ['title', 'priority', 'type', 'template'];
|
const stepsToCreate = [];
|
||||||
for (const [index, row] of jsonData.entries()) {
|
for (const [index, row] of jsonData.entries()) {
|
||||||
const rowNumber = index + 2;
|
const errorMessage = _getRowValidationError(row, index);
|
||||||
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) {
|
if (errorMessage) {
|
||||||
return res.status(400).json({ error: errorMessage });
|
return res.status(400).json({ error: errorMessage });
|
||||||
}
|
}
|
||||||
|
|
||||||
casesToCreate.push({
|
// Add step to the same case if the current row title is equal to previous row title
|
||||||
folderId: folderId,
|
// This handle cases with multiple steps :)
|
||||||
title: row['title'],
|
currentTitle = row['title'].trim();
|
||||||
description: row['description'] || '',
|
previousTitle = casesToCreate[casesToCreate.length - 1]?.title.trim();
|
||||||
state: 0, // default state
|
if (casesToCreate.length > 0 && previousTitle === currentTitle) {
|
||||||
priority: priorityIndex,
|
stepNo += 1;
|
||||||
type: typeIndex,
|
stepsToCreate.push({
|
||||||
preConditions: row['preConditions'],
|
caseIndex: casesToCreate.length - 1,
|
||||||
expectedResults: row['expectedResults'],
|
stepNo: stepNo,
|
||||||
automationStatus: automationStatusIndex,
|
step: row['step'] || '',
|
||||||
template: templateIndex,
|
result: row['expectedStepResult'] || '',
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
stepNo = 1;
|
||||||
|
casesToCreate.push({
|
||||||
|
folderId: folderId,
|
||||||
|
title: currentTitle,
|
||||||
|
description: row['description'] || '',
|
||||||
|
state: 0, // default state
|
||||||
|
priority: row['priority'] ? priorities.indexOf(row['priority']) : priorities.indexOf('medium'),
|
||||||
|
type: row['type'] ? testTypes.indexOf(row['type']) : testTypes.indexOf('other'),
|
||||||
|
preConditions: row['preConditions'],
|
||||||
|
expectedResults: row['expectedResults'],
|
||||||
|
automationStatus: row['automationStatus']
|
||||||
|
? automationStatus.indexOf(row['automationStatus'])
|
||||||
|
: automationStatus.indexOf('automation-not-required'),
|
||||||
|
template: row['template'] ? templates.indexOf(row['template']) : templates.indexOf('text'),
|
||||||
|
});
|
||||||
|
stepsToCreate.push({
|
||||||
|
caseIndex: casesToCreate.length - 1,
|
||||||
|
stepNo: stepNo,
|
||||||
|
step: row['step'] || '',
|
||||||
|
result: row['expectedStepResult'] || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdCases = await Case.bulkCreate(casesToCreate);
|
// 'Manually' create cases, steps and caseStep association.
|
||||||
|
const createdCases = await Case.bulkCreate(casesToCreate, { transaction: t });
|
||||||
|
for (const stepData of stepsToCreate) {
|
||||||
|
const createdCase = createdCases[stepData.caseIndex];
|
||||||
|
const createdStep = await Step.create(
|
||||||
|
{
|
||||||
|
step: stepData.step,
|
||||||
|
result: stepData.result,
|
||||||
|
},
|
||||||
|
{ transaction: t }
|
||||||
|
);
|
||||||
|
await CaseStep.create(
|
||||||
|
{
|
||||||
|
caseId: createdCase.id,
|
||||||
|
stepId: createdStep.id,
|
||||||
|
stepNo: stepData.stepNo,
|
||||||
|
},
|
||||||
|
{ transaction: t }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await t.commit();
|
||||||
res.json(createdCases);
|
res.json(createdCases);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
await t.rollback();
|
||||||
console.error(error);
|
console.error(error);
|
||||||
res.status(500).send('Internal Server Error');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _getRowValidationError(row, index) {
|
||||||
|
const requiredFields = ['title', 'priority', 'type', 'template'];
|
||||||
|
const rowNumber = index + 2;
|
||||||
|
|
||||||
|
for (const field of requiredFields) {
|
||||||
|
if (!row[field]) {
|
||||||
|
return `Row ${rowNumber} is missing required field: ${field}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate priority if provided
|
||||||
|
if (row['priority']) {
|
||||||
|
const priorityIndex = priorities.indexOf(row['priority']?.toLowerCase());
|
||||||
|
if (priorityIndex === -1) {
|
||||||
|
return `Row ${rowNumber} has invalid priority: ${row['priority']}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate type if provided
|
||||||
|
if (row['type']) {
|
||||||
|
const typeIndex = testTypes.indexOf(row['type']?.toLowerCase());
|
||||||
|
if (typeIndex === -1) {
|
||||||
|
return `Row ${rowNumber} has invalid type: ${row['type']}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate automationStatus if provided
|
||||||
|
if (row['automationStatus']) {
|
||||||
|
const automationStatusIndex = automationStatus.indexOf(row['automationStatus']?.toLowerCase());
|
||||||
|
if (automationStatusIndex === -1) {
|
||||||
|
return `Row ${rowNumber} has invalid automationStatus: ${row['automationStatus']}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate template if provided
|
||||||
|
if (row['template']) {
|
||||||
|
const templateIndex = templates.indexOf(row['template']?.toLowerCase());
|
||||||
|
if (templateIndex === -1) {
|
||||||
|
return `Row ${rowNumber} has invalid template: ${row['template']}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
BIN
frontend/public/template/unittcms-import-template-v1.1.xlsx
Normal file
BIN
frontend/public/template/unittcms-import-template-v1.1.xlsx
Normal file
Binary file not shown.
@@ -70,7 +70,7 @@ export default function CaseImportDialog({ isOpen, folderId, isDisabled, onImpor
|
|||||||
<div className="flex items-center justify-center w-full">
|
<div className="flex items-center justify-center w-full">
|
||||||
<div className={`mt-1 text-neutral-500 dark:text-neutral-400 text-sm rounded`}>
|
<div className={`mt-1 text-neutral-500 dark:text-neutral-400 text-sm rounded`}>
|
||||||
<div>{messages.importAvailable}</div>
|
<div>{messages.importAvailable}</div>
|
||||||
<a href="/template/unittcms-import-template-v1.xlsx" download className="text-tiny underline">
|
<a href="/template/unittcms-import-template-v1.1.xlsx" download className="text-tiny underline">
|
||||||
{messages.downloadTemplate}
|
{messages.downloadTemplate}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user