Merge pull request #332 from kimatata/develop
This commit is contained in:
36
backend/config/enums.js
Normal file
36
backend/config/enums.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// Enum mappings for database numeric values to human-readable labels
|
||||||
|
// These correspond to the frontend config/selection.ts configurations
|
||||||
|
|
||||||
|
// The status of each test case in test run
|
||||||
|
const testRunCaseStatus = ['untested', 'passed', 'failed', 'retest', 'skipped'];
|
||||||
|
|
||||||
|
// The status of each test run
|
||||||
|
const testRunStatus = ['new', 'inProgress', 'underReview', 'rejected', 'done', 'closed'];
|
||||||
|
|
||||||
|
// Priority levels
|
||||||
|
const priorities = ['critical', 'high', 'medium', 'low'];
|
||||||
|
|
||||||
|
// Test types
|
||||||
|
const testTypes = [
|
||||||
|
'other',
|
||||||
|
'security',
|
||||||
|
'performance',
|
||||||
|
'accessibility',
|
||||||
|
'functional',
|
||||||
|
'acceptance',
|
||||||
|
'usability',
|
||||||
|
'smokeSanity',
|
||||||
|
'compatibility',
|
||||||
|
'destructive',
|
||||||
|
'regression',
|
||||||
|
'automated',
|
||||||
|
'manual',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Automation status
|
||||||
|
const automationStatus = ['automated', 'automation-not-required', 'cannot-be-automated', 'obsolete'];
|
||||||
|
|
||||||
|
// Templates
|
||||||
|
const templates = ['text', 'step'];
|
||||||
|
|
||||||
|
export { testRunCaseStatus, testRunStatus, priorities, testTypes, automationStatus, templates };
|
||||||
@@ -5,6 +5,7 @@ import Papa from 'papaparse';
|
|||||||
import defineCase from '../../models/cases.js';
|
import defineCase from '../../models/cases.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';
|
||||||
|
|
||||||
export default function (sequelize) {
|
export default function (sequelize) {
|
||||||
const Case = defineCase(sequelize, DataTypes);
|
const Case = defineCase(sequelize, DataTypes);
|
||||||
@@ -35,7 +36,17 @@ export default function (sequelize) {
|
|||||||
if (type === 'json') {
|
if (type === 'json') {
|
||||||
return res.json(cases);
|
return res.json(cases);
|
||||||
} else if (type === 'csv') {
|
} else if (type === 'csv') {
|
||||||
const csv = Papa.unparse(cases, {
|
// 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, {
|
||||||
quotes: true,
|
quotes: true,
|
||||||
skipEmptyLines: true,
|
skipEmptyLines: true,
|
||||||
});
|
});
|
||||||
|
|||||||
149
backend/routes/cases/download.test.js
Normal file
149
backend/routes/cases/download.test.js
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import express from 'express';
|
||||||
|
import { Sequelize } from 'sequelize';
|
||||||
|
import casesDownloadRoute from './download.js';
|
||||||
|
|
||||||
|
// mock papaparse
|
||||||
|
vi.mock('papaparse', () => ({
|
||||||
|
default: {
|
||||||
|
unparse: vi.fn((data) => {
|
||||||
|
// Simple CSV generation for testing
|
||||||
|
if (data.length === 0) return '';
|
||||||
|
const headers = Object.keys(data[0]);
|
||||||
|
const rows = data.map((row) => headers.map((h) => `"${row[h]}"`).join(','));
|
||||||
|
return [headers.join(','), ...rows].join('\n');
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// mock of authentication middleware
|
||||||
|
vi.mock('../../middleware/auth.js', () => ({
|
||||||
|
default: () => ({
|
||||||
|
verifySignedIn: vi.fn((req, res, next) => {
|
||||||
|
req.userId = 1;
|
||||||
|
next();
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock('../../middleware/verifyVisible.js', () => ({
|
||||||
|
default: () => ({
|
||||||
|
verifyProjectVisibleFromFolderId: vi.fn((req, res, next) => {
|
||||||
|
next();
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// mock defineCase
|
||||||
|
const mockCase = {
|
||||||
|
findAll: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.mock('../../models/cases.js', () => ({
|
||||||
|
default: () => mockCase,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('GET /download with type=csv', () => {
|
||||||
|
let app;
|
||||||
|
const sequelize = new Sequelize({
|
||||||
|
dialect: 'sqlite',
|
||||||
|
logging: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/', casesDownloadRoute(sequelize));
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return CSV with human-readable labels for state, priority, type, automationStatus, and template', async () => {
|
||||||
|
mockCase.findAll.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'Test Case 1',
|
||||||
|
state: 0, // new
|
||||||
|
priority: 0, // critical
|
||||||
|
type: 4, // functional
|
||||||
|
automationStatus: 0, // automated
|
||||||
|
template: 1, // step
|
||||||
|
description: 'Test description',
|
||||||
|
preConditions: 'Test preconditions',
|
||||||
|
expectedResults: 'Test expected results',
|
||||||
|
folderId: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: 'Test Case 2',
|
||||||
|
state: 1, // inProgress
|
||||||
|
priority: 1, // high
|
||||||
|
type: 1, // security
|
||||||
|
automationStatus: 1, // automation-not-required
|
||||||
|
template: 0, // text
|
||||||
|
description: 'Test description 2',
|
||||||
|
preConditions: 'Test preconditions 2',
|
||||||
|
expectedResults: 'Test expected results 2',
|
||||||
|
folderId: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: 'Test Case 3',
|
||||||
|
state: 2, // underReview
|
||||||
|
priority: 2, // medium
|
||||||
|
type: 2, // performance
|
||||||
|
automationStatus: 2, // cannot-be-automated
|
||||||
|
template: 1, // step
|
||||||
|
description: 'Test description 3',
|
||||||
|
preConditions: 'Test preconditions 3',
|
||||||
|
expectedResults: 'Test expected results 3',
|
||||||
|
folderId: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const response = await request(app).get('/download?folderId=1&type=csv');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers['content-type']).toBe('text/csv; charset=utf-8');
|
||||||
|
expect(response.headers['content-disposition']).toBe('attachment; filename=cases_folder_1.csv');
|
||||||
|
|
||||||
|
const csvContent = response.text;
|
||||||
|
|
||||||
|
// Check that the CSV contains human-readable labels instead of numeric values
|
||||||
|
expect(csvContent).toContain('critical');
|
||||||
|
expect(csvContent).toContain('high');
|
||||||
|
expect(csvContent).toContain('medium');
|
||||||
|
expect(csvContent).toContain('functional');
|
||||||
|
expect(csvContent).toContain('security');
|
||||||
|
expect(csvContent).toContain('performance');
|
||||||
|
expect(csvContent).toContain('automated');
|
||||||
|
expect(csvContent).toContain('automation-not-required');
|
||||||
|
expect(csvContent).toContain('cannot-be-automated');
|
||||||
|
expect(csvContent).toContain('new');
|
||||||
|
expect(csvContent).toContain('inProgress');
|
||||||
|
expect(csvContent).toContain('underReview');
|
||||||
|
expect(csvContent).toContain('text');
|
||||||
|
expect(csvContent).toContain('step');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 if no cases found', async () => {
|
||||||
|
mockCase.findAll.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const response = await request(app).get('/download?folderId=999&type=csv');
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.text).toBe('No cases found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 if folderId is missing', async () => {
|
||||||
|
const response = await request(app).get('/download?type=csv');
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error).toBe('folderId is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 if type is missing', async () => {
|
||||||
|
const response = await request(app).get('/download?folderId=1');
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error).toBe('download type is required');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 } = req.query;
|
const { folderId, title, 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' });
|
||||||
@@ -60,15 +60,27 @@ export default function (sequelize) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tagInclude = {
|
||||||
|
model: Tags,
|
||||||
|
attributes: ['id', 'name'],
|
||||||
|
through: { attributes: [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tag) {
|
||||||
|
const tagIds = tag
|
||||||
|
.split(',')
|
||||||
|
.map((t) => parseInt(t.trim(), 10))
|
||||||
|
.filter((t) => !isNaN(t));
|
||||||
|
|
||||||
|
if (tagIds.length > 0) {
|
||||||
|
tagInclude.where = { id: { [Op.in]: tagIds } };
|
||||||
|
tagInclude.required = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const cases = await Case.findAll({
|
const cases = await Case.findAll({
|
||||||
where: whereClause,
|
where: whereClause,
|
||||||
include: [
|
include: [tagInclude],
|
||||||
{
|
|
||||||
model: Tags,
|
|
||||||
attributes: ['id', 'name'],
|
|
||||||
through: { attributes: [] },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
res.json(cases);
|
res.json(cases);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -25,41 +25,56 @@ export default function (sequelize) {
|
|||||||
RunCase.belongsTo(Case, { foreignKey: 'caseId' });
|
RunCase.belongsTo(Case, { foreignKey: 'caseId' });
|
||||||
const { verifySignedIn } = authMiddleware(sequelize);
|
const { verifySignedIn } = authMiddleware(sequelize);
|
||||||
const { verifyProjectVisibleFromProjectId } = visibilityMiddleware(sequelize);
|
const { verifyProjectVisibleFromProjectId } = visibilityMiddleware(sequelize);
|
||||||
|
const { verifyProjectVisibleFromRunId } = visibilityMiddleware(sequelize);
|
||||||
|
|
||||||
router.get('/byproject', verifySignedIn, verifyProjectVisibleFromProjectId, async (req, res) => {
|
router.get(
|
||||||
const { projectId } = req.query;
|
'/byproject',
|
||||||
|
verifySignedIn,
|
||||||
|
verifyProjectVisibleFromProjectId,
|
||||||
|
verifyProjectVisibleFromRunId,
|
||||||
|
async (req, res) => {
|
||||||
|
const { projectId, runId } = req.query;
|
||||||
|
|
||||||
if (!projectId) {
|
if (!projectId) {
|
||||||
return res.status(400).json({ error: 'projectId is required' });
|
return res.status(400).json({ error: 'projectId is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (!runId) {
|
||||||
const cases = await Case.findAll({
|
return res.status(400).json({ error: 'runId is required' });
|
||||||
include: [
|
}
|
||||||
{
|
|
||||||
model: Folder,
|
try {
|
||||||
where: {
|
const cases = await Case.findAll({
|
||||||
projectId: projectId,
|
include: [
|
||||||
|
{
|
||||||
|
model: Folder,
|
||||||
|
where: {
|
||||||
|
projectId: projectId,
|
||||||
|
},
|
||||||
|
attributes: [],
|
||||||
},
|
},
|
||||||
attributes: [],
|
{
|
||||||
},
|
model: RunCase,
|
||||||
{
|
attributes: ['id', 'runId', 'status'],
|
||||||
model: RunCase,
|
required: false,
|
||||||
attributes: ['id', 'runId', 'status'],
|
where: {
|
||||||
},
|
runId: runId,
|
||||||
{
|
},
|
||||||
model: Tags,
|
},
|
||||||
attributes: ['id', 'name'],
|
{
|
||||||
through: { attributes: [] },
|
model: Tags,
|
||||||
},
|
attributes: ['id', 'name'],
|
||||||
],
|
through: { attributes: [] },
|
||||||
});
|
},
|
||||||
res.json(cases);
|
],
|
||||||
} catch (error) {
|
});
|
||||||
console.error(error);
|
res.json(cases);
|
||||||
res.status(500).send('Internal Server Error');
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).send('Internal Server Error');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
import express from 'express';
|
|
||||||
const router = express.Router();
|
|
||||||
import { DataTypes } from 'sequelize';
|
|
||||||
import authMiddleware from '../../middleware/auth.js';
|
|
||||||
import editableMiddleware from '../../middleware/verifyEditable.js';
|
|
||||||
import definecaseTags from '../../models/caseTags.js';
|
|
||||||
|
|
||||||
export default function (sequelize) {
|
|
||||||
const { verifySignedIn } = authMiddleware(sequelize);
|
|
||||||
const { verifyProjectDeveloperFromCaseId } = editableMiddleware(sequelize);
|
|
||||||
const CaseTag = definecaseTags(sequelize, DataTypes);
|
|
||||||
|
|
||||||
router.delete('/:id', verifySignedIn, verifyProjectDeveloperFromCaseId, async (req, res) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
if (!id) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'id is required',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const deletedCaseTag = await CaseTag.destroy({
|
|
||||||
where: {
|
|
||||||
id: id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!deletedCaseTag) {
|
|
||||||
return res.status(404).json({ error: 'Case-tag association not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(204).send();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting case-tag association:', error);
|
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
64
backend/routes/casetags/edit.js
Normal file
64
backend/routes/casetags/edit.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import express from 'express';
|
||||||
|
const router = express.Router();
|
||||||
|
import { DataTypes } from 'sequelize';
|
||||||
|
import authMiddleware from '../../middleware/auth.js';
|
||||||
|
import editableMiddleware from '../../middleware/verifyEditable.js';
|
||||||
|
import defineCaseTag from '../../models/caseTags.js';
|
||||||
|
import defineCase from '../../models/cases.js';
|
||||||
|
import defineTag from '../../models/tags.js';
|
||||||
|
|
||||||
|
export default function (sequelize) {
|
||||||
|
const { verifySignedIn } = authMiddleware(sequelize);
|
||||||
|
const { verifyProjectDeveloperFromCaseId } = editableMiddleware(sequelize);
|
||||||
|
const CaseTag = defineCaseTag(sequelize, DataTypes);
|
||||||
|
const Case = defineCase(sequelize, DataTypes);
|
||||||
|
const Tag = defineTag(sequelize, DataTypes);
|
||||||
|
|
||||||
|
router.post('/update', verifySignedIn, verifyProjectDeveloperFromCaseId, async (req, res) => {
|
||||||
|
const { tagIds } = req.body;
|
||||||
|
const caseId = req.query.caseId;
|
||||||
|
|
||||||
|
if (!caseId || !Array.isArray(tagIds)) {
|
||||||
|
return res.status(400).json({ error: 'caseId and tagIds[] are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tagIds.length > 5) {
|
||||||
|
return res.status(400).json({ error: 'Maximum of 5 tags allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const testCase = await Case.findByPk(caseId);
|
||||||
|
if (!testCase) {
|
||||||
|
return res.status(404).json({ error: 'Case not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentAssociations = await CaseTag.findAll({
|
||||||
|
where: { caseId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentTagIds = currentAssociations.map((ct) => ct.tagId);
|
||||||
|
|
||||||
|
const tagsToAdd = tagIds.filter((id) => !currentTagIds.includes(id));
|
||||||
|
const tagsToRemove = currentTagIds.filter((id) => !tagIds.includes(id));
|
||||||
|
|
||||||
|
if (tagsToAdd.length > 0) {
|
||||||
|
const validTags = await Tag.findAll({ where: { id: tagsToAdd } });
|
||||||
|
const newLinks = validTags.map((tag) => ({ caseId, tagId: tag.id }));
|
||||||
|
await CaseTag.bulkCreate(newLinks);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tagsToRemove.length > 0) {
|
||||||
|
await CaseTag.destroy({
|
||||||
|
where: { caseId, tagId: tagsToRemove },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({ message: 'Tags updated successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating case tags:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import express from 'express';
|
|
||||||
const router = express.Router();
|
|
||||||
import { DataTypes } from 'sequelize';
|
|
||||||
import authMiddleware from '../../middleware/auth.js';
|
|
||||||
import editableMiddleware from '../../middleware/verifyEditable.js';
|
|
||||||
import definecaseTags from '../../models/caseTags.js';
|
|
||||||
import defineCase from '../../models/cases.js';
|
|
||||||
import defineTag from '../../models/tags.js';
|
|
||||||
|
|
||||||
export default function (sequelize) {
|
|
||||||
const { verifySignedIn } = authMiddleware(sequelize);
|
|
||||||
const { verifyProjectDeveloperFromCaseId } = editableMiddleware(sequelize);
|
|
||||||
const CaseTag = definecaseTags(sequelize, DataTypes);
|
|
||||||
const Case = defineCase(sequelize, DataTypes);
|
|
||||||
const Tags = defineTag(sequelize, DataTypes);
|
|
||||||
|
|
||||||
router.post('/', verifySignedIn, verifyProjectDeveloperFromCaseId, async (req, res) => {
|
|
||||||
const { caseId, tagId } = req.body;
|
|
||||||
|
|
||||||
if (!caseId || !tagId) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'caseId and tagId are required',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const caseExists = await Case.findByPk(caseId);
|
|
||||||
if (!caseExists) {
|
|
||||||
return res.status(404).json({ error: 'Case not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const tagExists = await Tags.findByPk(tagId);
|
|
||||||
if (!tagExists) {
|
|
||||||
return res.status(404).json({ error: 'Tag not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingAssociation = await CaseTag.findOne({
|
|
||||||
where: { caseId, tagId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingAssociation) {
|
|
||||||
return res.status(409).json({ error: 'Tag is already associated with this case' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const newCaseTag = await CaseTag.create({
|
|
||||||
caseId,
|
|
||||||
tagId,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.status(201).json(newCaseTag);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating case-tag association:', error);
|
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
@@ -9,6 +9,7 @@ import defineCase from '../../models/cases.js';
|
|||||||
import defineFolder from '../../models/folders.js';
|
import defineFolder from '../../models/folders.js';
|
||||||
import authMiddleware from '../../middleware/auth.js';
|
import authMiddleware from '../../middleware/auth.js';
|
||||||
import visibilityMiddleware from '../../middleware/verifyVisible.js';
|
import visibilityMiddleware from '../../middleware/verifyVisible.js';
|
||||||
|
import { testRunCaseStatus, testRunStatus, priorities, testTypes, automationStatus } from '../../config/enums.js';
|
||||||
|
|
||||||
export default function (sequelize) {
|
export default function (sequelize) {
|
||||||
const { verifySignedIn } = authMiddleware(sequelize);
|
const { verifySignedIn } = authMiddleware(sequelize);
|
||||||
@@ -99,11 +100,11 @@ export default function (sequelize) {
|
|||||||
const records = runCases.map((rc) => ({
|
const records = runCases.map((rc) => ({
|
||||||
id: rc.Case.id,
|
id: rc.Case.id,
|
||||||
title: rc.Case.title,
|
title: rc.Case.title,
|
||||||
state: rc.Case.state,
|
state: testRunStatus[rc.Case.state] || rc.Case.state,
|
||||||
priority: rc.Case.priority,
|
priority: priorities[rc.Case.priority] || rc.Case.priority,
|
||||||
type: rc.Case.type,
|
type: testTypes[rc.Case.type] || rc.Case.type,
|
||||||
automationStatus: rc.Case.automationStatus,
|
automationStatus: automationStatus[rc.Case.automationStatus] || rc.Case.automationStatus,
|
||||||
status: rc.status,
|
status: testRunCaseStatus[rc.status] || rc.status,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const csv = Papa.unparse(records, {
|
const csv = Papa.unparse(records, {
|
||||||
@@ -111,7 +112,7 @@ export default function (sequelize) {
|
|||||||
skipEmptyLines: true,
|
skipEmptyLines: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/csv');
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||||
res.setHeader('Content-Disposition', `attachment; filename=run_${runId}.csv`);
|
res.setHeader('Content-Disposition', `attachment; filename=run_${runId}.csv`);
|
||||||
return res.send(csv);
|
return res.send(csv);
|
||||||
}
|
}
|
||||||
|
|||||||
193
backend/routes/runs/download.test.js
Normal file
193
backend/routes/runs/download.test.js
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import express from 'express';
|
||||||
|
import { Sequelize } from 'sequelize';
|
||||||
|
import runsDownloadRoute from './download.js';
|
||||||
|
|
||||||
|
// mock papaparse
|
||||||
|
vi.mock('papaparse', () => ({
|
||||||
|
default: {
|
||||||
|
unparse: vi.fn((data) => {
|
||||||
|
// Simple CSV generation for testing
|
||||||
|
if (data.length === 0) return '';
|
||||||
|
const headers = Object.keys(data[0]);
|
||||||
|
const rows = data.map((row) => headers.map((h) => `"${row[h]}"`).join(','));
|
||||||
|
return [headers.join(','), ...rows].join('\n');
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// mock xmlbuilder2
|
||||||
|
vi.mock('xmlbuilder2', () => ({
|
||||||
|
create: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// mock of authentication middleware
|
||||||
|
vi.mock('../../middleware/auth.js', () => ({
|
||||||
|
default: () => ({
|
||||||
|
verifySignedIn: vi.fn((req, res, next) => {
|
||||||
|
req.userId = 1;
|
||||||
|
next();
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock('../../middleware/verifyVisible.js', () => ({
|
||||||
|
default: () => ({
|
||||||
|
verifyProjectVisibleFromRunId: vi.fn((req, res, next) => {
|
||||||
|
next();
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// mock defineRun
|
||||||
|
const mockRun = {
|
||||||
|
findByPk: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.mock('../../models/runs.js', () => ({
|
||||||
|
default: () => mockRun,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// mock defineRunCase
|
||||||
|
const mockRunCase = {
|
||||||
|
findAll: vi.fn(),
|
||||||
|
belongsTo: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.mock('../../models/runCases.js', () => ({
|
||||||
|
default: () => mockRunCase,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// mock defineCase
|
||||||
|
const mockCase = {
|
||||||
|
belongsTo: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.mock('../../models/cases.js', () => ({
|
||||||
|
default: () => mockCase,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// mock defineFolder
|
||||||
|
const mockFolder = {
|
||||||
|
findByPk: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.mock('../../models/folders.js', () => ({
|
||||||
|
default: () => mockFolder,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('GET /download/:runId with type=csv', () => {
|
||||||
|
let app;
|
||||||
|
const sequelize = new Sequelize({
|
||||||
|
dialect: 'sqlite',
|
||||||
|
logging: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/', runsDownloadRoute(sequelize));
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return CSV with human-readable labels for status, priority, type, and automationStatus', async () => {
|
||||||
|
mockRun.findByPk.mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
name: 'Test Run',
|
||||||
|
});
|
||||||
|
|
||||||
|
mockRunCase.findAll.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
runId: 1,
|
||||||
|
caseId: 1,
|
||||||
|
status: 1, // passed
|
||||||
|
Case: {
|
||||||
|
id: 1,
|
||||||
|
title: 'Test Case 1',
|
||||||
|
state: 0, // new
|
||||||
|
priority: 0, // critical
|
||||||
|
type: 4, // functional
|
||||||
|
automationStatus: 0, // automated
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
runId: 1,
|
||||||
|
caseId: 2,
|
||||||
|
status: 2, // failed
|
||||||
|
Case: {
|
||||||
|
id: 2,
|
||||||
|
title: 'Test Case 2',
|
||||||
|
state: 1, // inProgress
|
||||||
|
priority: 1, // high
|
||||||
|
type: 1, // security
|
||||||
|
automationStatus: 1, // automation-not-required
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
runId: 1,
|
||||||
|
caseId: 3,
|
||||||
|
status: 0, // untested
|
||||||
|
Case: {
|
||||||
|
id: 3,
|
||||||
|
title: 'Test Case 3',
|
||||||
|
state: 2, // underReview
|
||||||
|
priority: 2, // medium
|
||||||
|
type: 2, // performance
|
||||||
|
automationStatus: 2, // cannot-be-automated
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const response = await request(app).get('/download/1?type=csv');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers['content-type']).toBe('text/csv; charset=utf-8');
|
||||||
|
expect(response.headers['content-disposition']).toBe('attachment; filename=run_1.csv');
|
||||||
|
|
||||||
|
const csvContent = response.text;
|
||||||
|
|
||||||
|
// Check that the CSV contains human-readable labels instead of numeric values
|
||||||
|
expect(csvContent).toContain('passed');
|
||||||
|
expect(csvContent).toContain('failed');
|
||||||
|
expect(csvContent).toContain('untested');
|
||||||
|
expect(csvContent).toContain('critical');
|
||||||
|
expect(csvContent).toContain('high');
|
||||||
|
expect(csvContent).toContain('medium');
|
||||||
|
expect(csvContent).toContain('functional');
|
||||||
|
expect(csvContent).toContain('security');
|
||||||
|
expect(csvContent).toContain('performance');
|
||||||
|
expect(csvContent).toContain('automated');
|
||||||
|
expect(csvContent).toContain('automation-not-required');
|
||||||
|
expect(csvContent).toContain('cannot-be-automated');
|
||||||
|
expect(csvContent).toContain('new');
|
||||||
|
expect(csvContent).toContain('inProgress');
|
||||||
|
expect(csvContent).toContain('underReview');
|
||||||
|
|
||||||
|
// Ensure numeric values are not present (except for id which should be numeric)
|
||||||
|
const lines = csvContent.split('\n');
|
||||||
|
const dataLines = lines.slice(1).filter((line) => line.trim()); // Skip header
|
||||||
|
|
||||||
|
// Parse CSV rows to verify values
|
||||||
|
dataLines.forEach((line) => {
|
||||||
|
const values = line.split(',').map((v) => v.replace(/"/g, '').trim());
|
||||||
|
// Skip the id column (first column) and title column (second column)
|
||||||
|
const nonIdTitleValues = values.slice(2);
|
||||||
|
|
||||||
|
// Check that state, priority, type, automationStatus, status are not just single digits
|
||||||
|
nonIdTitleValues.forEach((value) => {
|
||||||
|
if (value && !isNaN(value) && value.length === 1) {
|
||||||
|
// This would indicate a numeric value wasn't converted
|
||||||
|
// But we allow it if it could be valid (this is a weak check, mainly for demonstration)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 if run not found', async () => {
|
||||||
|
mockRun.findByPk.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const response = await request(app).get('/download/999?type=csv');
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.text).toBe('Run not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,6 @@ const router = express.Router();
|
|||||||
import { DataTypes, Op } from 'sequelize';
|
import { DataTypes, Op } from 'sequelize';
|
||||||
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 defineTag from '../../models/tags.js';
|
import defineTag from '../../models/tags.js';
|
||||||
|
|
||||||
export default function (sequelize) {
|
export default function (sequelize) {
|
||||||
@@ -48,9 +47,19 @@ export default function (sequelize) {
|
|||||||
return res.status(409).json({ error: 'Tag name must be unique' });
|
return res.status(409).json({ error: 'Tag name must be unique' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const [updated, [updatedTag]] = await Tags.update({ name: trimmedName }, { where: { id: tagId, projectId } });
|
const [updated] = await Tags.update({ name: trimmedName }, { where: { id: tagId, projectId } });
|
||||||
|
|
||||||
if (updated === 0) return res.status(404).json({ error: 'Tag not found' });
|
if (updated === 0) {
|
||||||
|
return res.status(404).json({ error: 'Tag not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedTag = await Tags.findOne({
|
||||||
|
where: { id: tagId, projectId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updatedTag) {
|
||||||
|
return res.status(404).json({ error: 'Tag not found after update' });
|
||||||
|
}
|
||||||
|
|
||||||
res.status(200).json(updatedTag);
|
res.status(200).json(updatedTag);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -168,10 +168,8 @@ app.use('/tags', tagsDeleteRoute(sequelize));
|
|||||||
app.use('/tags', tagsEditRoute(sequelize));
|
app.use('/tags', tagsEditRoute(sequelize));
|
||||||
|
|
||||||
// "/casetags"
|
// "/casetags"
|
||||||
import caseTagsNewRoute from './routes/casetags/new.js';
|
import caseTagsEditRoute from './routes/casetags/edit.js';
|
||||||
import caseTagsDeleteRoute from './routes/casetags/delete.js';
|
app.use('/casetags', caseTagsEditRoute(sequelize));
|
||||||
app.use('/casetags', caseTagsNewRoute(sequelize));
|
|
||||||
app.use('/casetags', caseTagsDeleteRoute(sequelize));
|
|
||||||
|
|
||||||
// "/home"
|
// "/home"
|
||||||
import homeIndexRoute from './routes/home/index.js';
|
import homeIndexRoute from './routes/home/index.js';
|
||||||
|
|||||||
@@ -237,7 +237,9 @@
|
|||||||
"move": "Move",
|
"move": "Move",
|
||||||
"clone": "Clone",
|
"clone": "Clone",
|
||||||
"cases_moved": "Test cases moved",
|
"cases_moved": "Test cases moved",
|
||||||
"cases_cloned": "Test cases cloned"
|
"cases_cloned": "Test cases cloned",
|
||||||
|
"tags": "Tags",
|
||||||
|
"select_tags": "Select tags"
|
||||||
},
|
},
|
||||||
"Case": {
|
"Case": {
|
||||||
"back_to_cases": "Back to test cases",
|
"back_to_cases": "Back to test cases",
|
||||||
@@ -269,7 +271,16 @@
|
|||||||
"click_to_upload": "Click to upload",
|
"click_to_upload": "Click to upload",
|
||||||
"or_drag_and_drop": " or drag and drop",
|
"or_drag_and_drop": " or drag and drop",
|
||||||
"max_file_size": "Max. file size",
|
"max_file_size": "Max. file size",
|
||||||
"are_you_sure_leave": "Are you sure you want to leave the page?"
|
"are_you_sure_leave": "Are you sure you want to leave the page?",
|
||||||
|
"tags": "Tags",
|
||||||
|
"create_tag": "Create tag",
|
||||||
|
"max_tags_limit": "Max. tags limit",
|
||||||
|
"tag_already_exists": "Tag already exists",
|
||||||
|
"tag_created_and_added": "Tag created and added",
|
||||||
|
"error_creating_tag": "Error creating tag",
|
||||||
|
"error_updating_test_case": "Error updating test case",
|
||||||
|
"search_or_create_tag": "Search or create tag",
|
||||||
|
"no_tags_selected": "No tags selected"
|
||||||
},
|
},
|
||||||
"Runs": {
|
"Runs": {
|
||||||
"run_list": "Test Run List",
|
"run_list": "Test Run List",
|
||||||
@@ -337,7 +348,10 @@
|
|||||||
"user_name_or_email": "User name or email",
|
"user_name_or_email": "User name or email",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
"are_you_sure": "Are you sure you want to delete member?"
|
"are_you_sure": "Are you sure you want to delete member?",
|
||||||
|
"member_added": "Member added",
|
||||||
|
"role_changed": "Member role changed",
|
||||||
|
"member_deleted": "Member deleted"
|
||||||
},
|
},
|
||||||
"Settings": {
|
"Settings": {
|
||||||
"project_management": "Project Management",
|
"project_management": "Project Management",
|
||||||
@@ -351,6 +365,21 @@
|
|||||||
"delete_project": "Delete Project",
|
"delete_project": "Delete Project",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"are_you_sure": "Are you sure you want to delete the project?"
|
"are_you_sure": "Are you sure you want to delete the project?",
|
||||||
|
"tag_management": "Tag Management",
|
||||||
|
"tag_name": "Tag Name",
|
||||||
|
"add_tag": "Add Tag",
|
||||||
|
"no_tags_available": "No tags available",
|
||||||
|
"delete_tag": "Delete Tag",
|
||||||
|
"are_you_sure_delete_tag": "Are you sure you want to delete this tag?",
|
||||||
|
"tag_created": "Tag created successfully.",
|
||||||
|
"tag_updated": "Tag updated successfully.",
|
||||||
|
"tag_deleted": "Tag deleted successfully.",
|
||||||
|
"tag_error_empty": "Tag name cannot be empty.",
|
||||||
|
"tag_error_min_length": "Tag name must be at least 3 characters long.",
|
||||||
|
"tag_error_max_length": "Tag name cannot exceed 20 characters.",
|
||||||
|
"tag_error_create": "Failed to create tag. Please try again.",
|
||||||
|
"tag_error_update": "Failed to update tag. Please try again.",
|
||||||
|
"tag_error_delete": "Failed to delete tag. Please try again."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
"skipped": "スキップ"
|
"skipped": "スキップ"
|
||||||
},
|
},
|
||||||
"Priority": {
|
"Priority": {
|
||||||
"priority": "優先度",
|
|
||||||
"critical": "致",
|
"critical": "致",
|
||||||
"high": "高",
|
"high": "高",
|
||||||
"medium": "中",
|
"medium": "中",
|
||||||
@@ -136,6 +135,14 @@
|
|||||||
"current_password_incorrect": "現在のパスワードが正しくありません",
|
"current_password_incorrect": "現在のパスワードが正しくありません",
|
||||||
"update_error": "更新に失敗しました"
|
"update_error": "更新に失敗しました"
|
||||||
},
|
},
|
||||||
|
"Health": {
|
||||||
|
"health_check": "ヘルスチェック",
|
||||||
|
"status": "ステータス",
|
||||||
|
"ok": "正常",
|
||||||
|
"error": "エラー",
|
||||||
|
"api_server": "APIサーバー",
|
||||||
|
"unittcms_version": "UnitTCMS バージョン"
|
||||||
|
},
|
||||||
"Admin": {
|
"Admin": {
|
||||||
"user_management": "ユーザー管理",
|
"user_management": "ユーザー管理",
|
||||||
"avatar": "アバター",
|
"avatar": "アバター",
|
||||||
@@ -154,14 +161,6 @@
|
|||||||
"lost_admin_auth": "管理者権限を失いました",
|
"lost_admin_auth": "管理者権限を失いました",
|
||||||
"at_least": "最低1人以上の管理者が必要です。"
|
"at_least": "最低1人以上の管理者が必要です。"
|
||||||
},
|
},
|
||||||
"Health": {
|
|
||||||
"health_check": "ヘルスチェック",
|
|
||||||
"status": "ステータス",
|
|
||||||
"ok": "正常",
|
|
||||||
"error": "エラー",
|
|
||||||
"api_server": "APIサーバー",
|
|
||||||
"unittcms_version": "UnitTCMS バージョン"
|
|
||||||
},
|
|
||||||
"Projects": {
|
"Projects": {
|
||||||
"project_list": "プロジェクト一覧",
|
"project_list": "プロジェクト一覧",
|
||||||
"new_project": "新規プロジェクト",
|
"new_project": "新規プロジェクト",
|
||||||
@@ -238,7 +237,9 @@
|
|||||||
"move": "移動",
|
"move": "移動",
|
||||||
"clone": "クローン",
|
"clone": "クローン",
|
||||||
"cases_moved": "テストケースを移動しました",
|
"cases_moved": "テストケースを移動しました",
|
||||||
"cases_cloned": "テストケースをクローンしました"
|
"cases_cloned": "テストケースをクローンしました",
|
||||||
|
"tags": "タグ",
|
||||||
|
"select_tags": "タグを選択"
|
||||||
},
|
},
|
||||||
"Case": {
|
"Case": {
|
||||||
"back_to_cases": "テストケース一覧に戻る",
|
"back_to_cases": "テストケース一覧に戻る",
|
||||||
@@ -270,7 +271,16 @@
|
|||||||
"click_to_upload": "クリックしてアップロード",
|
"click_to_upload": "クリックしてアップロード",
|
||||||
"or_drag_and_drop": "またはドラッグアンドドロップ",
|
"or_drag_and_drop": "またはドラッグアンドドロップ",
|
||||||
"max_file_size": "最大ファイルサイズ",
|
"max_file_size": "最大ファイルサイズ",
|
||||||
"are_you_sure_leave": "ページを離れてもよろしいですか?"
|
"are_you_sure_leave": "ページを離れてもよろしいですか?",
|
||||||
|
"tags": "タグ",
|
||||||
|
"create_tag": "タグを作成",
|
||||||
|
"max_tags_limit": "最大タグ数",
|
||||||
|
"tag_already_exists": "タグは既に存在します",
|
||||||
|
"tag_created_and_added": "タグが作成されて追加されました",
|
||||||
|
"error_creating_tag": "タグの作成中にエラーが発生しました",
|
||||||
|
"error_updating_test_case": "テストケースの更新中にエラーが発生しました",
|
||||||
|
"search_or_create_tag": "タグを検索または作成",
|
||||||
|
"no_tags_selected": "タグが選択されていません"
|
||||||
},
|
},
|
||||||
"Runs": {
|
"Runs": {
|
||||||
"run_list": "テストラン一覧",
|
"run_list": "テストラン一覧",
|
||||||
@@ -303,8 +313,8 @@
|
|||||||
"refresh": "再読み込み",
|
"refresh": "再読み込み",
|
||||||
"id": "ID",
|
"id": "ID",
|
||||||
"title": "タイトル",
|
"title": "タイトル",
|
||||||
"description": "詳細",
|
|
||||||
"please_enter": "タイトルを入力してください",
|
"please_enter": "タイトルを入力してください",
|
||||||
|
"description": "詳細",
|
||||||
"priority": "優先度",
|
"priority": "優先度",
|
||||||
"status": "ステータス",
|
"status": "ステータス",
|
||||||
"actions": "アクション",
|
"actions": "アクション",
|
||||||
@@ -338,7 +348,10 @@
|
|||||||
"user_name_or_email": "ユーザー名またはメールアドレス",
|
"user_name_or_email": "ユーザー名またはメールアドレス",
|
||||||
"close": "閉じる",
|
"close": "閉じる",
|
||||||
"add": "追加",
|
"add": "追加",
|
||||||
"are_you_sure": "メンバーを削除してもよろしいですか?"
|
"are_you_sure": "メンバーを削除してもよろしいですか?",
|
||||||
|
"member_added": "メンバーが追加されました",
|
||||||
|
"role_changed": "メンバーのロールを更新しました",
|
||||||
|
"member_deleted": "メンバーを削除しました"
|
||||||
},
|
},
|
||||||
"Settings": {
|
"Settings": {
|
||||||
"project_management": "プロジェクト管理",
|
"project_management": "プロジェクト管理",
|
||||||
@@ -352,6 +365,21 @@
|
|||||||
"delete_project": "プロジェクトの削除",
|
"delete_project": "プロジェクトの削除",
|
||||||
"delete": "削除",
|
"delete": "削除",
|
||||||
"close": "閉じる",
|
"close": "閉じる",
|
||||||
"are_you_sure": "プロジェクトを削除してもよろしいですか?"
|
"are_you_sure": "プロジェクトを削除してもよろしいですか?",
|
||||||
|
"tag_management": "タグ管理",
|
||||||
|
"tag_name": "タグ名",
|
||||||
|
"add_tag": "タグを追加",
|
||||||
|
"no_tags_available": "タグがありません",
|
||||||
|
"delete_tag": "タグを削除",
|
||||||
|
"are_you_sure_delete_tag": "このタグを削除してもよろしいですか?",
|
||||||
|
"tag_created": "タグが正常に作成されました。",
|
||||||
|
"tag_updated": "タグが正常に更新されました。",
|
||||||
|
"tag_deleted": "タグが正常に削除されました。",
|
||||||
|
"tag_error_empty": "タグ名を入力してください。",
|
||||||
|
"tag_error_min_length": "タグ名は3文字以上である必要があります。",
|
||||||
|
"tag_error_max_length": "タグ名は20文字を超えることはできません。",
|
||||||
|
"tag_error_create": "タグの作成に失敗しました。もう一度お試しください。",
|
||||||
|
"tag_error_update": "タグの更新に失敗しました。もう一度お試しください。",
|
||||||
|
"tag_error_delete": "タグの削除に失敗しました。もう一度お試しください。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,7 +237,9 @@
|
|||||||
"move": "Mover",
|
"move": "Mover",
|
||||||
"clone": "Clonar",
|
"clone": "Clonar",
|
||||||
"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",
|
||||||
|
"select_tags": "Selecionar tags"
|
||||||
},
|
},
|
||||||
"Case": {
|
"Case": {
|
||||||
"back_to_cases": "Voltar para os casos de teste",
|
"back_to_cases": "Voltar para os casos de teste",
|
||||||
@@ -269,7 +271,16 @@
|
|||||||
"click_to_upload": "Clique para enviar",
|
"click_to_upload": "Clique para enviar",
|
||||||
"or_drag_and_drop": " ou arraste e solte",
|
"or_drag_and_drop": " ou arraste e solte",
|
||||||
"max_file_size": "Tamanho máx. do arquivo",
|
"max_file_size": "Tamanho máx. do arquivo",
|
||||||
"are_you_sure_leave": "Tem certeza de que deseja sair da página?"
|
"are_you_sure_leave": "Tem certeza de que deseja sair da página?",
|
||||||
|
"tags": "Tags",
|
||||||
|
"create_tag": "Criar tag",
|
||||||
|
"max_tags_limit": "Limite máximo de tags",
|
||||||
|
"tag_already_exists": "Tag já existe",
|
||||||
|
"tag_created_and_added": "Tag criada e adicionada",
|
||||||
|
"error_creating_tag": "Erro ao criar tag",
|
||||||
|
"error_updating_test_case": "Erro ao atualizar caso de teste",
|
||||||
|
"search_or_create_tag": "Buscar ou criar tag",
|
||||||
|
"no_tags_selected": "Nenhuma tag selecionada"
|
||||||
},
|
},
|
||||||
"Runs": {
|
"Runs": {
|
||||||
"run_list": "Lista de Execuções de Teste",
|
"run_list": "Lista de Execuções de Teste",
|
||||||
@@ -337,7 +348,10 @@
|
|||||||
"user_name_or_email": "Nome de usuário ou e-mail",
|
"user_name_or_email": "Nome de usuário ou e-mail",
|
||||||
"close": "Fechar",
|
"close": "Fechar",
|
||||||
"add": "Adicionar",
|
"add": "Adicionar",
|
||||||
"are_you_sure": "Tem certeza de que deseja excluir o membro?"
|
"are_you_sure": "Tem certeza de que deseja excluir o membro?",
|
||||||
|
"member_added": "Membro adicionado",
|
||||||
|
"role_changed": "Função do membro alterada",
|
||||||
|
"member_deleted": "Membro excluído"
|
||||||
},
|
},
|
||||||
"Settings": {
|
"Settings": {
|
||||||
"project_management": "Gerenciamento de Projetos",
|
"project_management": "Gerenciamento de Projetos",
|
||||||
@@ -351,6 +365,21 @@
|
|||||||
"delete_project": "Excluir Projeto",
|
"delete_project": "Excluir Projeto",
|
||||||
"delete": "Excluir",
|
"delete": "Excluir",
|
||||||
"close": "Fechar",
|
"close": "Fechar",
|
||||||
"are_you_sure": "Tem certeza de que deseja excluir o projeto?"
|
"are_you_sure": "Tem certeza de que deseja excluir o projeto?",
|
||||||
|
"tag_management": "Gerenciamento de Tags",
|
||||||
|
"tag_name": "Nome da Tag",
|
||||||
|
"add_tag": "Adicionar Tag",
|
||||||
|
"no_tags_available": "Nenhuma tag disponível",
|
||||||
|
"delete_tag": "Excluir Tag",
|
||||||
|
"are_you_sure_delete_tag": "Tem certeza de que deseja excluir esta tag?",
|
||||||
|
"tag_created": "Tag criada com sucesso.",
|
||||||
|
"tag_updated": "Tag atualizada com sucesso.",
|
||||||
|
"tag_deleted": "Tag excluída com sucesso.",
|
||||||
|
"tag_error_empty": "O nome da tag não pode estar vazio.",
|
||||||
|
"tag_error_min_length": "O nome da tag deve ter pelo menos 3 caracteres.",
|
||||||
|
"tag_error_max_length": "O nome da tag não pode exceder 20 caracteres.",
|
||||||
|
"tag_error_create": "Falha ao criar tag. Por favor, tente novamente.",
|
||||||
|
"tag_error_update": "Falha ao atualizar tag. Por favor, tente novamente.",
|
||||||
|
"tag_error_delete": "Falha ao excluir tag. Por favor, tente novamente."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
33
frontend/messages/strings.test.ts
Normal file
33
frontend/messages/strings.test.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import en from './en.json';
|
||||||
|
import ja from './ja.json';
|
||||||
|
import ptBR from './pt-BR.json';
|
||||||
|
|
||||||
|
function getAllKeys(obj: unknown, prefix = ''): string[] {
|
||||||
|
if (typeof obj !== 'object' || obj === null) return [];
|
||||||
|
return Object.entries(obj as Record<string, unknown>).flatMap(([key, value]) => {
|
||||||
|
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||||
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
return getAllKeys(value, fullKey);
|
||||||
|
}
|
||||||
|
return [fullKey];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Locale message keys consistency', () => {
|
||||||
|
const locales = [
|
||||||
|
{ name: 'en', data: en },
|
||||||
|
{ name: 'ja', data: ja },
|
||||||
|
{ name: 'pt-BR', data: ptBR },
|
||||||
|
];
|
||||||
|
|
||||||
|
const base = locales[0];
|
||||||
|
const baseKeys = getAllKeys(base.data);
|
||||||
|
|
||||||
|
for (const locale of locales.slice(1)) {
|
||||||
|
it(`should have the same keys as ${base.name} in ${locale.name}`, () => {
|
||||||
|
const localeKeys = getAllKeys(locale.data);
|
||||||
|
expect(localeKeys).toEqual(baseKeys);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -84,7 +84,8 @@ export default function AdminPage({ messages, locale }: Props) {
|
|||||||
const data = await updateUserRole(tokenContext.token.access_token, userEdit.id, role);
|
const data = await updateUserRole(tokenContext.token.access_token, userEdit.id, role);
|
||||||
if (data.user) {
|
if (data.user) {
|
||||||
addToast({
|
addToast({
|
||||||
title: 'Info',
|
title: 'Success',
|
||||||
|
color: 'success',
|
||||||
description: messages.roleChanged,
|
description: messages.roleChanged,
|
||||||
});
|
});
|
||||||
setUsers((prevUsers) => {
|
setUsers((prevUsers) => {
|
||||||
@@ -106,13 +107,16 @@ export default function AdminPage({ messages, locale }: Props) {
|
|||||||
|
|
||||||
if (data && data.user) {
|
if (data && data.user) {
|
||||||
addToast({
|
addToast({
|
||||||
title: 'Info',
|
title: 'Success',
|
||||||
|
color: 'success',
|
||||||
description: messages.lostAdminAuth,
|
description: messages.lostAdminAuth,
|
||||||
});
|
});
|
||||||
router.push(`/`, { locale: locale });
|
router.push(`/`, { locale: locale });
|
||||||
} else {
|
} else {
|
||||||
|
setIsConfirmDialogOpen(false);
|
||||||
addToast({
|
addToast({
|
||||||
title: 'Info',
|
title: 'Error',
|
||||||
|
color: 'danger',
|
||||||
description: messages.atLeast,
|
description: messages.atLeast,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.22</TableCell>
|
<TableCell>1.0.0-beta.23</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
<TableRow key="2">
|
<TableRow key="2">
|
||||||
<TableCell>{messages.api_server}</TableCell>
|
<TableCell>{messages.api_server}</TableCell>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export default function CasesPane({
|
|||||||
const [titleFilter, setTitleFilter] = useState('');
|
const [titleFilter, setTitleFilter] = 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 [isDeleteConfirmDialogOpen, setIsDeleteConfirmDialogOpen] = useState(false);
|
const [isDeleteConfirmDialogOpen, setIsDeleteConfirmDialogOpen] = useState(false);
|
||||||
const [deleteCaseIds, setDeleteCaseIds] = useState<number[]>([]);
|
const [deleteCaseIds, setDeleteCaseIds] = useState<number[]>([]);
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ 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[] }) => {
|
const updateUrlParams = (updates: { title?: string; priority?: number[]; type?: number[]; tag?: number[] }) => {
|
||||||
const currentParams = new URLSearchParams(searchParams.toString());
|
const currentParams = new URLSearchParams(searchParams.toString());
|
||||||
|
|
||||||
if (updates.title) {
|
if (updates.title) {
|
||||||
@@ -65,6 +66,12 @@ export default function CasesPane({
|
|||||||
currentParams.delete('type');
|
currentParams.delete('type');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (updates.tag && updates.tag.length > 0) {
|
||||||
|
currentParams.set('tag', updates.tag.join(','));
|
||||||
|
} else {
|
||||||
|
currentParams.delete('tag');
|
||||||
|
}
|
||||||
|
|
||||||
const newUrl = `${window.location.pathname}?${currentParams.toString()}`;
|
const newUrl = `${window.location.pathname}?${currentParams.toString()}`;
|
||||||
router.push(newUrl, { scroll: false });
|
router.push(newUrl, { scroll: false });
|
||||||
};
|
};
|
||||||
@@ -76,10 +83,12 @@ export default function CasesPane({
|
|||||||
const titleParam = searchParams.get('title') || '';
|
const titleParam = searchParams.get('title') || '';
|
||||||
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'));
|
||||||
|
|
||||||
setTitleFilter(titleParam);
|
setTitleFilter(titleParam);
|
||||||
setPriorityFilter(priorityParam);
|
setPriorityFilter(priorityParam);
|
||||||
setTypeFilter(typeParam);
|
setTypeFilter(typeParam);
|
||||||
|
setTagFilter(tagParam);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await fetchCases(
|
const data = await fetchCases(
|
||||||
@@ -87,7 +96,8 @@ export default function CasesPane({
|
|||||||
Number(folderId),
|
Number(folderId),
|
||||||
titleParam || undefined,
|
titleParam || 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
|
||||||
);
|
);
|
||||||
setCases(data);
|
setCases(data);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -133,11 +143,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[]) => {
|
const handleFilterChange = (title: string, priorities: number[], types: number[], tag: number[]) => {
|
||||||
setTitleFilter(title);
|
setTitleFilter(title);
|
||||||
setPriorityFilter(priorities);
|
setPriorityFilter(priorities);
|
||||||
setTypeFilter(types);
|
setTypeFilter(types);
|
||||||
updateUrlParams({ title: title, priority: priorities, type: types });
|
setTagFilter(tag);
|
||||||
|
updateUrlParams({ title: title, priority: priorities, type: types, tag: tag });
|
||||||
};
|
};
|
||||||
|
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
@@ -178,6 +189,7 @@ export default function CasesPane({
|
|||||||
activeTitleFilter={titleFilter}
|
activeTitleFilter={titleFilter}
|
||||||
activePriorityFilters={priorityFilter}
|
activePriorityFilters={priorityFilter}
|
||||||
activeTypeFilters={typeFilter}
|
activeTypeFilters={typeFilter}
|
||||||
|
activeTagFilters={tagFilter}
|
||||||
messages={messages}
|
messages={messages}
|
||||||
priorityMessages={priorityMessages}
|
priorityMessages={priorityMessages}
|
||||||
testTypeMessages={testTypeMessages}
|
testTypeMessages={testTypeMessages}
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useContext } from 'react';
|
||||||
import { Button, Dropdown, DropdownTrigger, DropdownMenu, DropdownItem, Selection, Input } from '@heroui/react';
|
import {
|
||||||
|
Button,
|
||||||
|
Dropdown,
|
||||||
|
DropdownTrigger,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownItem,
|
||||||
|
Selection,
|
||||||
|
Input,
|
||||||
|
addToast,
|
||||||
|
} from '@heroui/react';
|
||||||
import { SearchIcon, ChevronDown, Circle } from 'lucide-react';
|
import { SearchIcon, ChevronDown, Circle } from 'lucide-react';
|
||||||
import { PriorityMessages } from '@/types/priority';
|
import { PriorityMessages } from '@/types/priority';
|
||||||
import { TestTypeMessages } from '@/types/testType';
|
import { TestTypeMessages } from '@/types/testType';
|
||||||
import { CasesMessages } from '@/types/case';
|
import { CasesMessages } from '@/types/case';
|
||||||
import { priorities, testTypes } from '@/config/selection';
|
import { priorities, testTypes } from '@/config/selection';
|
||||||
|
import { TagType } from '@/types/tag';
|
||||||
|
import { fetchTags } from '@/utils/tagsControls';
|
||||||
|
import { TokenContext } from '@/utils/TokenProvider';
|
||||||
|
import { logError } from '@/utils/errorHandler';
|
||||||
|
|
||||||
type TestCaseFilterProps = {
|
type TestCaseFilterProps = {
|
||||||
messages: CasesMessages;
|
messages: CasesMessages;
|
||||||
@@ -13,9 +26,13 @@ type TestCaseFilterProps = {
|
|||||||
activeTitleFilter: string;
|
activeTitleFilter: string;
|
||||||
activePriorityFilters: number[];
|
activePriorityFilters: number[];
|
||||||
activeTypeFilters: number[];
|
activeTypeFilters: number[];
|
||||||
onFilterChange: (title: string, priorities: number[], types: number[]) => void;
|
activeTagFilters: number[];
|
||||||
|
projectId: string;
|
||||||
|
onFilterChange: (title: string, priorities: number[], types: number[], tags: number[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type Tag = Pick<TagType, 'id' | 'name'>;
|
||||||
|
|
||||||
export default function TestCaseFilter({
|
export default function TestCaseFilter({
|
||||||
messages,
|
messages,
|
||||||
priorityMessages,
|
priorityMessages,
|
||||||
@@ -23,11 +40,38 @@ export default function TestCaseFilter({
|
|||||||
activeTitleFilter,
|
activeTitleFilter,
|
||||||
activePriorityFilters,
|
activePriorityFilters,
|
||||||
activeTypeFilters,
|
activeTypeFilters,
|
||||||
|
activeTagFilters,
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
|
projectId,
|
||||||
}: TestCaseFilterProps) {
|
}: TestCaseFilterProps) {
|
||||||
|
const tokenContext = useContext(TokenContext);
|
||||||
const [title, setTitle] = useState<string>('');
|
const [title, setTitle] = 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 [tags, setTags] = useState<Tag[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchDataEffect = async () => {
|
||||||
|
try {
|
||||||
|
const tagsResponse = (await fetchTags(tokenContext.token.access_token, projectId)) || [];
|
||||||
|
setTags(tagsResponse);
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error fetching case tags', error);
|
||||||
|
addToast({ title: 'Error', description: 'Error fetching tags', color: 'danger' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchDataEffect();
|
||||||
|
}, [projectId, tokenContext.token.access_token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTagFilters.length > 0) {
|
||||||
|
const activeKeys = activeTagFilters.map((id) => id.toString());
|
||||||
|
setSelectedTags(new Set(activeKeys));
|
||||||
|
} else {
|
||||||
|
setSelectedTags(new Set([]));
|
||||||
|
}
|
||||||
|
}, [activeTagFilters]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTitle(activeTitleFilter);
|
setTitle(activeTitleFilter);
|
||||||
@@ -74,13 +118,20 @@ export default function TestCaseFilter({
|
|||||||
.filter((index) => index !== -1);
|
.filter((index) => index !== -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
onFilterChange(title, priorityIndices, typeIndices);
|
let tagIds: number[] = [];
|
||||||
|
if (selectedTags !== 'all' && selectedTags.size > 0) {
|
||||||
|
tagIds = Array.from(selectedTags)
|
||||||
|
.map((key) => parseInt(key as string))
|
||||||
|
.filter((id) => !isNaN(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
onFilterChange(title, priorityIndices, typeIndices, tagIds);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearFilter = () => {
|
const handleClearFilter = () => {
|
||||||
setSelectedPriorities(new Set([]));
|
setSelectedPriorities(new Set([]));
|
||||||
setSelectedTypes(new Set([]));
|
setSelectedTypes(new Set([]));
|
||||||
onFilterChange('', [], []);
|
onFilterChange('', [], [], []);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -159,6 +210,35 @@ export default function TestCaseFilter({
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex-col space-y-1">
|
||||||
|
<h3 className="text-default-500 text-small"></h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3 space-y-1">
|
||||||
|
<h3 className="text-default-500 text-small">{messages.tags}</h3>
|
||||||
|
<Dropdown>
|
||||||
|
<DropdownTrigger>
|
||||||
|
<Button size="sm" variant="bordered" className="w-32" endContent={<ChevronDown size={16} />}>
|
||||||
|
{selectedTags === 'all' || selectedTags.size === 0
|
||||||
|
? messages.selectTags
|
||||||
|
: `${selectedTags.size} ${messages.selected || 'selected'}`}
|
||||||
|
</Button>
|
||||||
|
</DropdownTrigger>
|
||||||
|
<DropdownMenu
|
||||||
|
className="max-h-[50vh] overflow-y-auto"
|
||||||
|
aria-label="Tag filter"
|
||||||
|
selectionMode="multiple"
|
||||||
|
selectedKeys={selectedTags}
|
||||||
|
onSelectionChange={setSelectedTags}
|
||||||
|
>
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<DropdownItem key={tag.id.toString()} textValue={tag.name}>
|
||||||
|
<span className="text-sm">{tag.name}</span>
|
||||||
|
</DropdownItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenu>
|
||||||
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button className="me-2" size="sm" variant="light" onPress={handleClearFilter}>
|
<Button className="me-2" size="sm" variant="light" onPress={handleClearFilter}>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
Checkbox,
|
Checkbox,
|
||||||
Card,
|
Card,
|
||||||
CardBody,
|
CardBody,
|
||||||
|
Chip,
|
||||||
} from '@heroui/react';
|
} from '@heroui/react';
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
@@ -45,10 +46,11 @@ type Props = {
|
|||||||
onDeleteCase: (caseId: number) => void;
|
onDeleteCase: (caseId: number) => void;
|
||||||
onDeleteCases: (caseIds: number[]) => void;
|
onDeleteCases: (caseIds: number[]) => void;
|
||||||
onExportCases: (type: string) => void;
|
onExportCases: (type: string) => void;
|
||||||
onFilterChange: (query: string, priorities: number[], types: number[]) => void;
|
onFilterChange: (query: string, priorities: number[], types: number[], tag: number[]) => void;
|
||||||
activeTitleFilter: string;
|
activeTitleFilter: string;
|
||||||
activePriorityFilters: number[];
|
activePriorityFilters: number[];
|
||||||
activeTypeFilters: number[];
|
activeTypeFilters: number[];
|
||||||
|
activeTagFilters: number[];
|
||||||
messages: CasesMessages;
|
messages: CasesMessages;
|
||||||
priorityMessages: PriorityMessages;
|
priorityMessages: PriorityMessages;
|
||||||
testTypeMessages: TestTypeMessages;
|
testTypeMessages: TestTypeMessages;
|
||||||
@@ -67,6 +69,7 @@ export default function TestCaseTable({
|
|||||||
activeTitleFilter,
|
activeTitleFilter,
|
||||||
activePriorityFilters,
|
activePriorityFilters,
|
||||||
activeTypeFilters,
|
activeTypeFilters,
|
||||||
|
activeTagFilters,
|
||||||
messages,
|
messages,
|
||||||
priorityMessages,
|
priorityMessages,
|
||||||
testTypeMessages,
|
testTypeMessages,
|
||||||
@@ -81,6 +84,7 @@ export default function TestCaseTable({
|
|||||||
{ name: messages.id, uid: 'id', sortable: true },
|
{ name: messages.id, uid: 'id', sortable: true },
|
||||||
{ name: messages.title, uid: 'title', sortable: true },
|
{ name: messages.title, uid: 'title', sortable: true },
|
||||||
{ name: messages.priority, uid: 'priority', sortable: true },
|
{ name: messages.priority, uid: 'priority', sortable: true },
|
||||||
|
{ name: messages.tags, uid: 'tags' },
|
||||||
{ name: messages.actions, uid: 'actions' },
|
{ name: messages.actions, uid: 'actions' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -107,6 +111,17 @@ export default function TestCaseTable({
|
|||||||
);
|
);
|
||||||
case 'priority':
|
case 'priority':
|
||||||
return <TestCasePriority priorityValue={cellValue as number} priorityMessages={priorityMessages} />;
|
return <TestCasePriority priorityValue={cellValue as number} priorityMessages={priorityMessages} />;
|
||||||
|
|
||||||
|
case 'tags':
|
||||||
|
return (
|
||||||
|
<div className="space-x-2">
|
||||||
|
{testCase.Tags?.map((tag) => (
|
||||||
|
<Chip size="sm" key={tag.id}>
|
||||||
|
{tag.name}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
case 'actions':
|
case 'actions':
|
||||||
return (
|
return (
|
||||||
<Dropdown>
|
<Dropdown>
|
||||||
@@ -139,7 +154,8 @@ export default function TestCaseTable({
|
|||||||
// filter test case
|
// filter test case
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
const [showFilter, setShowFilter] = useState(false);
|
const [showFilter, setShowFilter] = useState(false);
|
||||||
const activeFilterNum = (activeTitleFilter ? 1 : 0) + activePriorityFilters.length + activeTypeFilters.length;
|
const activeFilterNum =
|
||||||
|
(activeTitleFilter ? 1 : 0) + activePriorityFilters.length + activeTypeFilters.length + activeTagFilters.length;
|
||||||
|
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
// select test case
|
// select test case
|
||||||
@@ -305,9 +321,11 @@ export default function TestCaseTable({
|
|||||||
activeTitleFilter={activeTitleFilter}
|
activeTitleFilter={activeTitleFilter}
|
||||||
activePriorityFilters={activePriorityFilters}
|
activePriorityFilters={activePriorityFilters}
|
||||||
activeTypeFilters={activeTypeFilters}
|
activeTypeFilters={activeTypeFilters}
|
||||||
onFilterChange={(newTitleFilter, newPriorityFilters, newTypeFilters) => {
|
activeTagFilters={activeTagFilters}
|
||||||
|
projectId={projectId}
|
||||||
|
onFilterChange={(newTitleFilter, newPriorityFilters, newTypeFilters, newTagFilters) => {
|
||||||
setShowFilter(false);
|
setShowFilter(false);
|
||||||
onFilterChange(newTitleFilter, newPriorityFilters, newTypeFilters);
|
onFilterChange(newTitleFilter, newPriorityFilters, newTypeFilters, newTagFilters);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useState, useEffect, useContext, useCallback, ChangeEvent, DragEvent } from 'react';
|
import { useState, useEffect, useContext, ChangeEvent, DragEvent } from 'react';
|
||||||
import { Input, Textarea, Select, SelectItem, Button, Divider, Tooltip, addToast, Badge } from '@heroui/react';
|
import { Input, Textarea, Select, SelectItem, Button, Divider, Tooltip, addToast, Badge } from '@heroui/react';
|
||||||
import { Save, Plus, ArrowLeft, Circle } from 'lucide-react';
|
import { Save, Plus, ArrowLeft, Circle } from 'lucide-react';
|
||||||
import CaseStepsEditor from './CaseStepsEditor';
|
import CaseStepsEditor from './CaseStepsEditor';
|
||||||
import CaseAttachmentsEditor from './CaseAttachmentsEditor';
|
import CaseAttachmentsEditor from './CaseAttachmentsEditor';
|
||||||
import { updateSteps } from './stepControl';
|
import { updateSteps } from './stepControl';
|
||||||
import { fetchCreateAttachments, fetchDownloadAttachment, fetchDeleteAttachment } from './attachmentControl';
|
import { fetchCreateAttachments, fetchDownloadAttachment, fetchDeleteAttachment } from './attachmentControl';
|
||||||
|
import CaseTagsEditor from './CaseTagsEditor';
|
||||||
import { fetchCase, updateCase } from '@/utils/caseControl';
|
import { fetchCase, updateCase } from '@/utils/caseControl';
|
||||||
import { priorities, testTypes, templates } from '@/config/selection';
|
import { priorities, testTypes, templates } from '@/config/selection';
|
||||||
import { useRouter } from '@/src/i18n/routing';
|
import { useRouter } from '@/src/i18n/routing';
|
||||||
@@ -15,6 +16,7 @@ import { CaseType, AttachmentType, CaseMessages, StepType } from '@/types/case';
|
|||||||
import { PriorityMessages } from '@/types/priority';
|
import { PriorityMessages } from '@/types/priority';
|
||||||
import { TestTypeMessages } from '@/types/testType';
|
import { TestTypeMessages } from '@/types/testType';
|
||||||
import { logError } from '@/utils/errorHandler';
|
import { logError } from '@/utils/errorHandler';
|
||||||
|
import { updateCaseTags } from '@/utils/caseTagsControls';
|
||||||
|
|
||||||
const defaultTestCase = {
|
const defaultTestCase = {
|
||||||
id: 0,
|
id: 0,
|
||||||
@@ -32,6 +34,7 @@ const defaultTestCase = {
|
|||||||
Attachments: [],
|
Attachments: [],
|
||||||
isIncluded: false,
|
isIncluded: false,
|
||||||
runStatus: 0,
|
runStatus: 0,
|
||||||
|
Tags: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -59,6 +62,8 @@ export default function CaseEditor({
|
|||||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||||
const [plusCount, setPlusCount] = useState<number>(0);
|
const [plusCount, setPlusCount] = useState<number>(0);
|
||||||
const [isDirty, setIsDirty] = useState(false);
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
|
const [selectedTags, setSelectedTags] = useState<{ id: number; name: string }[]>([]);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
useFormGuard(isDirty, messages.areYouSureLeave);
|
useFormGuard(isDirty, messages.areYouSureLeave);
|
||||||
|
|
||||||
@@ -210,24 +215,24 @@ export default function CaseEditor({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchAndSetCase = useCallback(async () => {
|
|
||||||
if (!tokenContext.isSignedIn()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const data = await fetchCase(tokenContext.token.access_token, Number(caseId));
|
|
||||||
data.Steps.forEach((step: StepType) => {
|
|
||||||
step.editState = 'notChanged';
|
|
||||||
});
|
|
||||||
setTestCase(data);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
logError('Error fetching case data', error);
|
|
||||||
}
|
|
||||||
}, [tokenContext, caseId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const fetchAndSetCase = async () => {
|
||||||
|
if (!tokenContext.isSignedIn()) return;
|
||||||
|
try {
|
||||||
|
const data = await fetchCase(tokenContext.token.access_token, Number(caseId));
|
||||||
|
data.Steps.forEach((step: StepType) => {
|
||||||
|
step.editState = 'notChanged';
|
||||||
|
});
|
||||||
|
setTestCase(data);
|
||||||
|
if (data.Tags) {
|
||||||
|
setSelectedTags(Array.isArray(data.Tags) ? data.Tags : []);
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logError('Error fetching case data', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
fetchAndSetCase();
|
fetchAndSetCase();
|
||||||
}, [caseId, tokenContext, fetchAndSetCase]);
|
}, [tokenContext, caseId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -258,18 +263,31 @@ export default function CaseEditor({
|
|||||||
isLoading={isUpdating}
|
isLoading={isUpdating}
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
setIsUpdating(true);
|
setIsUpdating(true);
|
||||||
await updateCase(tokenContext.token.access_token, testCase);
|
try {
|
||||||
if (testCase.Steps) {
|
await updateCase(tokenContext.token.access_token, testCase);
|
||||||
await updateSteps(tokenContext.token.access_token, Number(caseId), testCase.Steps);
|
if (testCase.Steps) {
|
||||||
}
|
await updateSteps(tokenContext.token.access_token, Number(caseId), testCase.Steps);
|
||||||
await fetchAndSetCase();
|
}
|
||||||
|
|
||||||
addToast({
|
const tagIds = selectedTags.map((tag) => tag.id);
|
||||||
title: 'Info',
|
await updateCaseTags(tokenContext.token.access_token, Number(caseId), tagIds, projectId);
|
||||||
description: messages.updatedTestCase,
|
|
||||||
});
|
addToast({
|
||||||
setIsUpdating(false);
|
title: 'Success',
|
||||||
setIsDirty(false);
|
color: 'success',
|
||||||
|
description: messages.updatedTestCase,
|
||||||
|
});
|
||||||
|
setIsDirty(false);
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error updating test case', error);
|
||||||
|
addToast({
|
||||||
|
title: 'Error',
|
||||||
|
description: messages.errorUpdatingTestCase,
|
||||||
|
color: 'danger',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsUpdating(false);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isUpdating ? messages.updating : messages.update}
|
{isUpdating ? messages.updating : messages.update}
|
||||||
@@ -305,6 +323,16 @@ export default function CaseEditor({
|
|||||||
className="mt-3"
|
className="mt-3"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CaseTagsEditor
|
||||||
|
projectId={projectId}
|
||||||
|
selectedTags={selectedTags}
|
||||||
|
onChange={(tags) => {
|
||||||
|
setSelectedTags(tags);
|
||||||
|
setIsDirty(true);
|
||||||
|
}}
|
||||||
|
messages={messages}
|
||||||
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Select
|
<Select
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
'use client';
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Autocomplete, AutocompleteItem, Chip, addToast } from '@heroui/react';
|
||||||
|
import { useContext } from 'react';
|
||||||
|
import { createTag, fetchTags } from '@/utils/tagsControls';
|
||||||
|
import { logError } from '@/utils/errorHandler';
|
||||||
|
import { TokenContext } from '@/utils/TokenProvider';
|
||||||
|
import { CaseMessages } from '@/types/case';
|
||||||
|
import { TagType } from '@/types/tag';
|
||||||
|
|
||||||
|
type Tag = Pick<TagType, 'id' | 'name'>;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
projectId: string;
|
||||||
|
selectedTags: Tag[];
|
||||||
|
onChange: (tags: Tag[]) => void;
|
||||||
|
messages: CaseMessages;
|
||||||
|
maxTags?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CaseTagsEditor({ projectId, selectedTags, onChange, messages, maxTags = 5 }: Props) {
|
||||||
|
const tokenContext = useContext(TokenContext);
|
||||||
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
const autocompleteRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const isProjectDeveloper = tokenContext.isProjectDeveloper(Number(projectId));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchDataEffect = async () => {
|
||||||
|
try {
|
||||||
|
const tagsResponse = (await fetchTags(tokenContext.token.access_token, projectId)) || [];
|
||||||
|
setTags(tagsResponse);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logError('Error fetching case tags', error);
|
||||||
|
addToast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Error fetching tags',
|
||||||
|
color: 'danger',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchDataEffect();
|
||||||
|
}, [projectId, tokenContext.token.access_token]);
|
||||||
|
|
||||||
|
const availableTags = useMemo(() => {
|
||||||
|
return tags.filter((t) => !selectedTags.some((s) => s.id === t.id));
|
||||||
|
}, [tags, selectedTags]);
|
||||||
|
|
||||||
|
const handleTagRemove = (tagId: number) => {
|
||||||
|
onChange(selectedTags.filter((tag) => tag.id !== tagId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTagAdd = (tag: Tag) => {
|
||||||
|
if (selectedTags.length >= maxTags) {
|
||||||
|
addToast({
|
||||||
|
title: 'Warning',
|
||||||
|
description: messages.maxTagsLimit,
|
||||||
|
color: 'warning',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedTags.some((t) => t.id === tag.id)) return;
|
||||||
|
onChange([...selectedTags, tag]);
|
||||||
|
setInputValue('');
|
||||||
|
autocompleteRef.current?.blur();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateTag = async (name: string) => {
|
||||||
|
if (selectedTags.length >= maxTags) {
|
||||||
|
addToast({
|
||||||
|
title: 'Warning',
|
||||||
|
description: messages.maxTagsLimit,
|
||||||
|
color: 'warning',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const normalizedName = name.trim().toLowerCase();
|
||||||
|
if (
|
||||||
|
tags.some((tag) => tag.name.toLowerCase() === normalizedName) ||
|
||||||
|
selectedTags.some((tag) => tag.name.toLowerCase() === normalizedName)
|
||||||
|
) {
|
||||||
|
addToast({
|
||||||
|
title: 'Warning',
|
||||||
|
description: messages.tagAlreadyExists,
|
||||||
|
color: 'warning',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tag = await createTag(tokenContext.token.access_token, projectId, name);
|
||||||
|
setTags((prev) => [...prev, tag]);
|
||||||
|
onChange([...selectedTags, tag]);
|
||||||
|
setInputValue('');
|
||||||
|
autocompleteRef.current?.blur();
|
||||||
|
addToast({
|
||||||
|
title: 'Success',
|
||||||
|
description: messages.tagCreatedAndAdded,
|
||||||
|
color: 'success',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error creating tag', error);
|
||||||
|
addToast({
|
||||||
|
title: 'Error',
|
||||||
|
description: messages.errorCreatingTag,
|
||||||
|
color: 'danger',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Autocomplete
|
||||||
|
className="max-w-xs mt-2"
|
||||||
|
size="sm"
|
||||||
|
variant="bordered"
|
||||||
|
inputValue={inputValue}
|
||||||
|
label={messages.tags}
|
||||||
|
placeholder={selectedTags.length >= maxTags ? messages.maxTagsLimit : messages.searchOrCreateTag}
|
||||||
|
isDisabled={selectedTags.length >= maxTags}
|
||||||
|
onInputChange={setInputValue}
|
||||||
|
ref={autocompleteRef}
|
||||||
|
onOpenChange={(isOpen) => !isOpen && setInputValue('')}
|
||||||
|
>
|
||||||
|
{inputValue.trim() &&
|
||||||
|
availableTags.filter((tag) => tag.name.toLowerCase().includes(inputValue.trim().toLowerCase())).length === 0 ? (
|
||||||
|
<AutocompleteItem
|
||||||
|
key="create-tag"
|
||||||
|
textValue={inputValue.trim()}
|
||||||
|
onPress={() => handleCreateTag(inputValue.trim())}
|
||||||
|
className="text-primary"
|
||||||
|
>
|
||||||
|
{`${messages.createTag} "${inputValue.trim()}"`}
|
||||||
|
</AutocompleteItem>
|
||||||
|
) : (
|
||||||
|
availableTags
|
||||||
|
.filter((tag) => tag.name.toLowerCase().includes(inputValue.trim().toLowerCase()))
|
||||||
|
.map((tag) => (
|
||||||
|
<AutocompleteItem
|
||||||
|
key={tag.id}
|
||||||
|
textValue={tag.name}
|
||||||
|
isReadOnly={!isProjectDeveloper}
|
||||||
|
onPress={() => handleTagAdd(tag)}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</AutocompleteItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Autocomplete>
|
||||||
|
|
||||||
|
<div className="gap-2 flex items-center mt-3">
|
||||||
|
<div className="flex justify-start align-center gap-1.5 flex-wrap">
|
||||||
|
{selectedTags.length === 0 && <p className="text-foreground-500 text-xs mb-1.5">{messages.noTagsSelected}</p>}
|
||||||
|
{selectedTags.map((tag) => (
|
||||||
|
<Chip
|
||||||
|
key={tag.id}
|
||||||
|
size="md"
|
||||||
|
onClose={!isProjectDeveloper ? undefined : () => handleTagRemove(tag.id)}
|
||||||
|
isDisabled={!isProjectDeveloper}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -45,6 +45,15 @@ export default function Page({
|
|||||||
orDragAndDrop: t('or_drag_and_drop'),
|
orDragAndDrop: t('or_drag_and_drop'),
|
||||||
maxFileSize: t('max_file_size'),
|
maxFileSize: t('max_file_size'),
|
||||||
areYouSureLeave: t('are_you_sure_leave'),
|
areYouSureLeave: t('are_you_sure_leave'),
|
||||||
|
tags: t('tags'),
|
||||||
|
createTag: t('create_tag'),
|
||||||
|
maxTagsLimit: t('max_tags_limit'),
|
||||||
|
tagAlreadyExists: t('tag_already_exists'),
|
||||||
|
tagCreatedAndAdded: t('tag_created_and_added'),
|
||||||
|
errorCreatingTag: t('error_creating_tag'),
|
||||||
|
errorUpdatingTestCase: t('error_updating_test_case'),
|
||||||
|
searchOrCreateTag: t('search_or_create_tag'),
|
||||||
|
noTagsSelected: t('no_tags_selected'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const tt = useTranslations('Type');
|
const tt = useTranslations('Type');
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export default function Page({ params }: { params: { projectId: string; folderId
|
|||||||
clone: t('clone'),
|
clone: t('clone'),
|
||||||
casesMoved: t('cases_moved'),
|
casesMoved: t('cases_moved'),
|
||||||
casesCloned: t('cases_cloned'),
|
casesCloned: t('cases_cloned'),
|
||||||
|
tags: t('tags'),
|
||||||
|
selectTags: t('select_tags'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const priorityTranslation = useTranslations('Priority');
|
const priorityTranslation = useTranslations('Priority');
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useState, useEffect, useContext } from 'react';
|
import { useState, useEffect, useContext } from 'react';
|
||||||
import { Button } from '@heroui/react';
|
import { addToast, Button } from '@heroui/react';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import MembersTable from './MembersTable';
|
import MembersTable from './MembersTable';
|
||||||
import AddMemberDialog from './AddMemberDialog';
|
import AddMemberDialog from './AddMemberDialog';
|
||||||
@@ -45,6 +45,11 @@ export default function MembersPage({ projectId, messages }: Props) {
|
|||||||
const updateMembers = [...members];
|
const updateMembers = [...members];
|
||||||
updateMembers.push(newMember);
|
updateMembers.push(newMember);
|
||||||
setMembers(updateMembers);
|
setMembers(updateMembers);
|
||||||
|
addToast({
|
||||||
|
title: 'Success',
|
||||||
|
color: 'success',
|
||||||
|
description: messages.memberAdded,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsDialogOpen(false);
|
setIsDialogOpen(false);
|
||||||
@@ -68,12 +73,22 @@ export default function MembersPage({ projectId, messages }: Props) {
|
|||||||
await deleteMember(context.token.access_token, deleteMemberId, Number(projectId));
|
await deleteMember(context.token.access_token, deleteMemberId, Number(projectId));
|
||||||
setMembers(members.filter((member) => member.User.id !== deleteMemberId));
|
setMembers(members.filter((member) => member.User.id !== deleteMemberId));
|
||||||
closeDeleteConfirmDialog();
|
closeDeleteConfirmDialog();
|
||||||
|
addToast({
|
||||||
|
title: 'Success',
|
||||||
|
color: 'success',
|
||||||
|
description: messages.memberDeleted,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChangeRole = async (userEdit: UserType, role: number) => {
|
const handleChangeRole = async (userEdit: UserType, role: number) => {
|
||||||
if (userEdit.id) {
|
if (userEdit.id) {
|
||||||
await updateMember(context.token.access_token, userEdit.id, Number(projectId), role);
|
await updateMember(context.token.access_token, userEdit.id, Number(projectId), role);
|
||||||
|
addToast({
|
||||||
|
title: 'Success',
|
||||||
|
color: 'success',
|
||||||
|
description: messages.roleChanged,
|
||||||
|
});
|
||||||
setMembers((prevMembers) => {
|
setMembers((prevMembers) => {
|
||||||
return prevMembers.map((member) => {
|
return prevMembers.map((member) => {
|
||||||
if (member.User.id === userEdit.id) {
|
if (member.User.id === userEdit.id) {
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ export default function Page({ params }: { params: { projectId: string; locale:
|
|||||||
close: t('close'),
|
close: t('close'),
|
||||||
add: t('add'),
|
add: t('add'),
|
||||||
areYouSure: t('are_you_sure'),
|
areYouSure: t('are_you_sure'),
|
||||||
|
memberAdded: t('member_added'),
|
||||||
|
roleChanged: t('role_changed'),
|
||||||
|
memberDeleted: t('member_deleted'),
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export default function RunEditor({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const initTestCases = async () => {
|
const initTestCases = async () => {
|
||||||
const casesData = await fetchProjectCases(tokenContext.token.access_token, Number(projectId));
|
const casesData = await fetchProjectCases(tokenContext.token.access_token, Number(projectId), Number(runId));
|
||||||
casesData.forEach((testCase: CaseType) => {
|
casesData.forEach((testCase: CaseType) => {
|
||||||
if (testCase.RunCases && testCase.RunCases.length > 0) {
|
if (testCase.RunCases && testCase.RunCases.length > 0) {
|
||||||
testCase.RunCases[0].editState = 'notChanged';
|
testCase.RunCases[0].editState = 'notChanged';
|
||||||
@@ -192,7 +192,8 @@ export default function RunEditor({
|
|||||||
await initTestCases();
|
await initTestCases();
|
||||||
|
|
||||||
addToast({
|
addToast({
|
||||||
title: 'Info',
|
title: 'Success',
|
||||||
|
color: 'success',
|
||||||
description: messages.updatedTestRun,
|
description: messages.updatedTestRun,
|
||||||
});
|
});
|
||||||
setIsUpdating(false);
|
setIsUpdating(false);
|
||||||
|
|||||||
@@ -315,8 +315,8 @@ async function updateRunCases(jwt: string, runId: number, testCases: CaseType[])
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchProjectCases(jwt: string, projectId: number) {
|
async function fetchProjectCases(jwt: string, projectId: number, runId: number) {
|
||||||
const url = `${apiServer}/cases/byproject?projectId=${projectId}`;
|
const url = `${apiServer}/cases/byproject?projectId=${projectId}&runId=${runId}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import { useContext, useEffect, useState } from 'react';
|
||||||
|
import { addToast, Button, Card, CardBody, Input, Popover, PopoverContent, PopoverTrigger } from '@heroui/react';
|
||||||
|
import { Check, Pencil, Plus, Trash2, X } from 'lucide-react';
|
||||||
|
import { SettingsMessages } from '@/types/settings';
|
||||||
|
import { TagType } from '@/types/tag';
|
||||||
|
import { TokenContext } from '@/utils/TokenProvider';
|
||||||
|
import { createTag, deleteTag, fetchTags, updateTag } from '@/utils/tagsControls';
|
||||||
|
import { logError } from '@/utils/errorHandler';
|
||||||
|
|
||||||
|
type ProjectTagsManagerProps = {
|
||||||
|
projectId: string;
|
||||||
|
messages: SettingsMessages;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProjectTagsManager({ projectId, messages }: ProjectTagsManagerProps) {
|
||||||
|
const context = useContext(TokenContext);
|
||||||
|
const [tags, setTags] = useState<TagType[]>([]);
|
||||||
|
const [tagName, setTagName] = useState('');
|
||||||
|
const [isValidTag, setIsValidTag] = useState(true);
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
const [editingTag, setEditingTag] = useState<number | null>(null);
|
||||||
|
const [editedTagName, setEditedTagName] = useState('');
|
||||||
|
const [isValidEditTag, setIsValidEditTag] = useState(true);
|
||||||
|
const [editErrorMessage, setEditErrorMessage] = useState('');
|
||||||
|
const [openPopoverTagId, setOpenPopoverTagId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const isProjectDeveloper = context.isProjectDeveloper(Number(projectId));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchDataEffect() {
|
||||||
|
if (!context.isSignedIn()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const caseTags = (await fetchTags(context.token.access_token, projectId)) || [];
|
||||||
|
setTags(caseTags);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logError('Error fetching project data:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchDataEffect();
|
||||||
|
}, [context, projectId]);
|
||||||
|
|
||||||
|
const validateName = (name: string, messages: SettingsMessages) => {
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
|
||||||
|
if (!trimmedName) {
|
||||||
|
return { isValid: false, errorMessage: messages.tagErrorEmpty };
|
||||||
|
}
|
||||||
|
if (trimmedName.length < 3) {
|
||||||
|
return { isValid: false, errorMessage: messages.tagErrorMinLength };
|
||||||
|
}
|
||||||
|
if (trimmedName.length > 20) {
|
||||||
|
return { isValid: false, errorMessage: messages.tagErrorMaxLength };
|
||||||
|
}
|
||||||
|
return { isValid: true, errorMessage: '' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCreateTag = async () => {
|
||||||
|
const { isValid, errorMessage } = validateName(tagName, messages);
|
||||||
|
setIsValidTag(isValid);
|
||||||
|
setErrorMessage(errorMessage);
|
||||||
|
if (!isValid) return;
|
||||||
|
try {
|
||||||
|
const newTag = await createTag(context.token.access_token, projectId, tagName);
|
||||||
|
setTags((prev) => [...prev, newTag]);
|
||||||
|
setIsValidTag(true);
|
||||||
|
setErrorMessage('');
|
||||||
|
setTagName('');
|
||||||
|
addToast({
|
||||||
|
title: 'Success',
|
||||||
|
color: 'success',
|
||||||
|
description: messages.tagCreated,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : messages.tagErrorCreate;
|
||||||
|
addToast({
|
||||||
|
title: 'Error',
|
||||||
|
color: 'danger',
|
||||||
|
description: errorMessage,
|
||||||
|
});
|
||||||
|
logError('Error creating tag:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUpdateTag = async (tagId: number) => {
|
||||||
|
const { isValid, errorMessage } = validateName(editedTagName, messages);
|
||||||
|
setIsValidEditTag(isValid);
|
||||||
|
setEditErrorMessage(errorMessage);
|
||||||
|
if (!isValid) return;
|
||||||
|
try {
|
||||||
|
await updateTag(context.token.access_token, projectId, tagId, editedTagName);
|
||||||
|
setTags(tags.map((tag) => (tag.id === tagId ? { ...tag, name: editedTagName } : tag)));
|
||||||
|
setEditingTag(null);
|
||||||
|
setEditedTagName('');
|
||||||
|
setIsValidEditTag(true);
|
||||||
|
setEditErrorMessage('');
|
||||||
|
addToast({
|
||||||
|
title: 'Success',
|
||||||
|
color: 'success',
|
||||||
|
description: messages.tagUpdated,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : messages.tagErrorUpdate;
|
||||||
|
addToast({
|
||||||
|
title: 'Error',
|
||||||
|
color: 'danger',
|
||||||
|
description: errorMessage,
|
||||||
|
});
|
||||||
|
logError('Error updating tag:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDeleteTag = async (tagId: number) => {
|
||||||
|
try {
|
||||||
|
await deleteTag(context.token.access_token, projectId, tagId);
|
||||||
|
setTags(tags.filter((tag) => tag.id !== tagId));
|
||||||
|
setOpenPopoverTagId(null);
|
||||||
|
addToast({
|
||||||
|
title: 'Success',
|
||||||
|
color: 'success',
|
||||||
|
description: messages.tagDeleted,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : messages.tagErrorDelete;
|
||||||
|
addToast({
|
||||||
|
title: 'Error',
|
||||||
|
color: 'danger',
|
||||||
|
description: errorMessage,
|
||||||
|
});
|
||||||
|
logError('Error deleting tag:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<div className="mb-6 flex items-baseline gap-3">
|
||||||
|
<Input
|
||||||
|
size="sm"
|
||||||
|
type="text"
|
||||||
|
placeholder={messages.tagName}
|
||||||
|
variant="bordered"
|
||||||
|
isInvalid={!isValidTag}
|
||||||
|
errorMessage={errorMessage}
|
||||||
|
value={tagName}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTagName(e.target.value);
|
||||||
|
const { isValid, errorMessage } = validateName(e.target.value, messages);
|
||||||
|
setIsValidTag(isValid);
|
||||||
|
setErrorMessage(errorMessage);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
startContent={<Plus className="w-4 h-4" />}
|
||||||
|
color="primary"
|
||||||
|
size="sm"
|
||||||
|
isDisabled={!isProjectDeveloper || !isValidTag}
|
||||||
|
onPress={() => {
|
||||||
|
onCreateTag();
|
||||||
|
setTagName('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{messages.addTag}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{tags.length === 0 && <div className="text-center text-gray-500 mb-3">{messages.noTagsAvailable}</div>}
|
||||||
|
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<div
|
||||||
|
key={tag.id}
|
||||||
|
className="flex items-center justify-between p-2 hover:bg-gray-100 hover:dark:bg-[#2a2a2a] transition-colors rounded-lg"
|
||||||
|
>
|
||||||
|
{editingTag === tag.id ? (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-1 items-start gap-3">
|
||||||
|
<Input
|
||||||
|
size="sm"
|
||||||
|
type="text"
|
||||||
|
variant="bordered"
|
||||||
|
value={editedTagName}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEditedTagName(e.target.value);
|
||||||
|
const { isValid, errorMessage } = validateName(e.target.value, messages);
|
||||||
|
setIsValidEditTag(isValid);
|
||||||
|
setEditErrorMessage(errorMessage);
|
||||||
|
}}
|
||||||
|
isInvalid={!isValidEditTag}
|
||||||
|
errorMessage={editErrorMessage}
|
||||||
|
classNames={{
|
||||||
|
inputWrapper: 'h-7 flex',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="primary"
|
||||||
|
isIconOnly
|
||||||
|
isDisabled={!isValidEditTag}
|
||||||
|
onPress={() => onUpdateTag(tag.id)}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 "
|
||||||
|
isIconOnly
|
||||||
|
onPress={() => {
|
||||||
|
setEditingTag(null);
|
||||||
|
setEditedTagName('');
|
||||||
|
setIsValidEditTag(true);
|
||||||
|
setEditErrorMessage('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="font-medium">{tag.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 transition-opacity group-hover:opacity-100">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8"
|
||||||
|
isIconOnly
|
||||||
|
isDisabled={!isProjectDeveloper}
|
||||||
|
onPress={() => {
|
||||||
|
setEditingTag(tag.id);
|
||||||
|
setEditedTagName(tag.name);
|
||||||
|
setIsValidEditTag(true);
|
||||||
|
setEditErrorMessage('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Popover
|
||||||
|
placement="top"
|
||||||
|
isOpen={openPopoverTagId === tag.id}
|
||||||
|
onOpenChange={(open) => setOpenPopoverTagId(open ? tag.id : null)}
|
||||||
|
>
|
||||||
|
<PopoverTrigger>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
color="danger"
|
||||||
|
className="h-8 "
|
||||||
|
isIconOnly
|
||||||
|
isDisabled={!isProjectDeveloper}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent>
|
||||||
|
<div className="px-1 py-2">
|
||||||
|
<div className="text-small font-bold">{messages.deleteTag}</div>
|
||||||
|
<div className="text-tiny">{messages.areYouSureDeleteTag}</div>
|
||||||
|
<div className="flex justify-end gap-2 mt-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8"
|
||||||
|
isIconOnly
|
||||||
|
onPress={() => setOpenPopoverTagId(null)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
color="danger"
|
||||||
|
isIconOnly
|
||||||
|
onPress={() => onDeleteTag(tag.id)}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { useState, useEffect, useContext } from 'react';
|
import { useState, useEffect, useContext } from 'react';
|
||||||
import { Button, Table, TableHeader, TableColumn, TableBody, TableRow, TableCell } from '@heroui/react';
|
import { Button, Table, TableHeader, TableColumn, TableBody, TableRow, TableCell } from '@heroui/react';
|
||||||
import { Pencil, Trash } from 'lucide-react';
|
import { Pencil, Trash } from 'lucide-react';
|
||||||
|
import ProjectTagsManager from './ProjectTagsManager';
|
||||||
import { SettingsMessages } from '@/types/settings';
|
import { SettingsMessages } from '@/types/settings';
|
||||||
import { TokenContext } from '@/utils/TokenProvider';
|
import { TokenContext } from '@/utils/TokenProvider';
|
||||||
import { deleteProject, fetchProject, updateProject } from '@/utils/projectsControl';
|
import { deleteProject, fetchProject, updateProject } from '@/utils/projectsControl';
|
||||||
@@ -143,6 +144,14 @@ export default function SettingsPage({ projectId, messages, projectDialogMessage
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full p-3 flex items-center justify-between">
|
||||||
|
<h3 className="font-bold">{messages.tagManagement}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full p-3">
|
||||||
|
<ProjectTagsManager projectId={projectId} messages={messages} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<ProjectDialog
|
<ProjectDialog
|
||||||
isOpen={isProjectDialogOpen}
|
isOpen={isProjectDialogOpen}
|
||||||
editingProject={project}
|
editingProject={project}
|
||||||
|
|||||||
@@ -28,6 +28,21 @@ export default function Page({ params }: { params: { projectId: string; locale:
|
|||||||
delete: t('delete'),
|
delete: t('delete'),
|
||||||
close: t('close'),
|
close: t('close'),
|
||||||
areYouSure: t('are_you_sure'),
|
areYouSure: t('are_you_sure'),
|
||||||
|
tagManagement: t('tag_management'),
|
||||||
|
tagName: t('tag_name'),
|
||||||
|
addTag: t('add_tag'),
|
||||||
|
noTagsAvailable: t('no_tags_available'),
|
||||||
|
deleteTag: t('delete_tag'),
|
||||||
|
areYouSureDeleteTag: t('are_you_sure_delete_tag'),
|
||||||
|
tagCreated: t('tag_created'),
|
||||||
|
tagUpdated: t('tag_updated'),
|
||||||
|
tagDeleted: t('tag_deleted'),
|
||||||
|
tagErrorEmpty: t('tag_error_empty'),
|
||||||
|
tagErrorMinLength: t('tag_error_min_length'),
|
||||||
|
tagErrorMaxLength: t('tag_error_max_length'),
|
||||||
|
tagErrorCreate: t('tag_error_create'),
|
||||||
|
tagErrorUpdate: t('tag_error_update'),
|
||||||
|
tagErrorDelete: t('tag_error_delete'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const pt = useTranslations('ProjectDialog');
|
const pt = useTranslations('ProjectDialog');
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ type CaseType = {
|
|||||||
Steps?: StepType[];
|
Steps?: StepType[];
|
||||||
RunCases?: RunCaseType[];
|
RunCases?: RunCaseType[];
|
||||||
Attachments?: AttachmentType[];
|
Attachments?: AttachmentType[];
|
||||||
|
Tags?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type CaseStepType = {
|
type CaseStepType = {
|
||||||
@@ -89,7 +93,9 @@ type CasesMessages = {
|
|||||||
move: string;
|
move: string;
|
||||||
clone: string;
|
clone: string;
|
||||||
casesMoved: string;
|
casesMoved: string;
|
||||||
|
tags: string;
|
||||||
casesCloned: string;
|
casesCloned: string;
|
||||||
|
selectTags: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CaseMessages = {
|
type CaseMessages = {
|
||||||
@@ -123,6 +129,15 @@ type CaseMessages = {
|
|||||||
orDragAndDrop: string;
|
orDragAndDrop: string;
|
||||||
maxFileSize: string;
|
maxFileSize: string;
|
||||||
areYouSureLeave: string;
|
areYouSureLeave: string;
|
||||||
|
tags: string;
|
||||||
|
createTag: string;
|
||||||
|
maxTagsLimit: string;
|
||||||
|
tagAlreadyExists: string;
|
||||||
|
tagCreatedAndAdded: string;
|
||||||
|
errorCreatingTag: string;
|
||||||
|
errorUpdatingTestCase: string;
|
||||||
|
searchOrCreateTag: string;
|
||||||
|
noTagsSelected: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type { CaseType, StepType, AttachmentType, CasesMessages, CaseMessages };
|
export type { CaseType, StepType, AttachmentType, CasesMessages, CaseMessages };
|
||||||
|
|||||||
@@ -15,4 +15,7 @@ export type MembersMessages = {
|
|||||||
close: string;
|
close: string;
|
||||||
add: string;
|
add: string;
|
||||||
areYouSure: string;
|
areYouSure: string;
|
||||||
|
memberAdded: string;
|
||||||
|
roleChanged: string;
|
||||||
|
memberDeleted: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,4 +11,19 @@ export type SettingsMessages = {
|
|||||||
delete: string;
|
delete: string;
|
||||||
close: string;
|
close: string;
|
||||||
areYouSure: string;
|
areYouSure: string;
|
||||||
|
tagManagement: string;
|
||||||
|
tagName: string;
|
||||||
|
addTag: string;
|
||||||
|
noTagsAvailable: string;
|
||||||
|
deleteTag: string;
|
||||||
|
areYouSureDeleteTag: string;
|
||||||
|
tagCreated: string;
|
||||||
|
tagUpdated: string;
|
||||||
|
tagDeleted: string;
|
||||||
|
tagErrorEmpty: string;
|
||||||
|
tagErrorMinLength: string;
|
||||||
|
tagErrorMaxLength: string;
|
||||||
|
tagErrorCreate: string;
|
||||||
|
tagErrorUpdate: string;
|
||||||
|
tagErrorDelete: string;
|
||||||
};
|
};
|
||||||
|
|||||||
7
frontend/types/tag.ts
Normal file
7
frontend/types/tag.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
type TagType = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
projectId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type { TagType };
|
||||||
@@ -139,7 +139,7 @@ const TokenProvider = ({ toastMessages, locale, children }: TokenProps) => {
|
|||||||
if (ret.reason === 'notoken') {
|
if (ret.reason === 'notoken') {
|
||||||
if (toastMessages) {
|
if (toastMessages) {
|
||||||
addToast({
|
addToast({
|
||||||
title: 'Info',
|
title: 'Error',
|
||||||
description: toastMessages.needSignedIn,
|
description: toastMessages.needSignedIn,
|
||||||
color: 'danger',
|
color: 'danger',
|
||||||
});
|
});
|
||||||
@@ -147,7 +147,7 @@ const TokenProvider = ({ toastMessages, locale, children }: TokenProps) => {
|
|||||||
} else if (ret.reason === 'expired') {
|
} else if (ret.reason === 'expired') {
|
||||||
if (toastMessages) {
|
if (toastMessages) {
|
||||||
addToast({
|
addToast({
|
||||||
title: 'Info',
|
title: 'Error',
|
||||||
description: toastMessages.sessionExpired,
|
description: toastMessages.sessionExpired,
|
||||||
color: 'danger',
|
color: 'danger',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,7 +26,14 @@ async function fetchCase(jwt: string, caseId: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchCases(jwt: string, folderId: number, title?: string, priority?: number[], type?: number[]) {
|
async function fetchCases(
|
||||||
|
jwt: string,
|
||||||
|
folderId: number,
|
||||||
|
title?: string,
|
||||||
|
priority?: number[],
|
||||||
|
type?: number[],
|
||||||
|
tag?: number[]
|
||||||
|
) {
|
||||||
const queryParams = [`folderId=${folderId}`];
|
const queryParams = [`folderId=${folderId}`];
|
||||||
|
|
||||||
if (title) {
|
if (title) {
|
||||||
@@ -41,6 +48,10 @@ async function fetchCases(jwt: string, folderId: number, title?: string, priorit
|
|||||||
queryParams.push(`type=${type.join(',')}`);
|
queryParams.push(`type=${type.join(',')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tag && tag.length > 0) {
|
||||||
|
queryParams.push(`tag=${tag.join(',')}`);
|
||||||
|
}
|
||||||
|
|
||||||
const query = queryParams.length > 0 ? `?${queryParams.join('&')}` : '';
|
const query = queryParams.length > 0 ? `?${queryParams.join('&')}` : '';
|
||||||
|
|
||||||
const url = `${apiServer}/cases${query}`;
|
const url = `${apiServer}/cases${query}`;
|
||||||
|
|||||||
28
frontend/utils/caseTagsControls.ts
Normal file
28
frontend/utils/caseTagsControls.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { logError } from './errorHandler';
|
||||||
|
import Config from '@/config/config';
|
||||||
|
const apiServer = Config.apiServer;
|
||||||
|
|
||||||
|
export async function updateCaseTags(jwt: string, caseId: number, tagIds: number[], projectId: string) {
|
||||||
|
const fetchOptions = {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${jwt}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ tagIds }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = `${apiServer}/casetags/update?projectId=${projectId}?&caseId=${caseId}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, fetchOptions);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
return data || [];
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logError('Error updating case tags:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
104
frontend/utils/tagsControls.ts
Normal file
104
frontend/utils/tagsControls.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { logError } from './errorHandler';
|
||||||
|
import Config from '@/config/config';
|
||||||
|
const apiServer = Config.apiServer;
|
||||||
|
|
||||||
|
async function fetchTags(jwt: string, projectId: string) {
|
||||||
|
const fetchOptions = {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${jwt}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = `${apiServer}/tags?projectId=${projectId}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, fetchOptions);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
return data || [];
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logError('Error fetching case tags', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTag(jwt: string, projectId: string, tagName: string) {
|
||||||
|
const fetchOptions = {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${jwt}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name: tagName }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = `${apiServer}/tags?projectId=${projectId}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, fetchOptions);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
return data;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logError('Error creating case tag', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateTag(jwt: string, projectId: string, tagId: number, tagName: string) {
|
||||||
|
const fetchOptions = {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${jwt}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name: tagName }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = `${apiServer}/tags/${tagId}?projectId=${projectId}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, fetchOptions);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
return data;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logError('Error updating case tag', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTag(jwt: string, projectId: string, tagId: number) {
|
||||||
|
const fetchOptions = {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${jwt}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = `${apiServer}/tags/${tagId}?projectId=${projectId}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, fetchOptions);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logError('Error deleting case tag', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { fetchTags, createTag, updateTag, deleteTag };
|
||||||
Reference in New Issue
Block a user