diff --git a/backend/config/enums.js b/backend/config/enums.js new file mode 100644 index 0000000..6c292cc --- /dev/null +++ b/backend/config/enums.js @@ -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 }; diff --git a/backend/routes/cases/download.js b/backend/routes/cases/download.js index 52169a9..ecaf162 100644 --- a/backend/routes/cases/download.js +++ b/backend/routes/cases/download.js @@ -5,6 +5,7 @@ import Papa from 'papaparse'; import defineCase from '../../models/cases.js'; import authMiddleware from '../../middleware/auth.js'; import visibilityMiddleware from '../../middleware/verifyVisible.js'; +import { testRunStatus, priorities, testTypes, automationStatus, templates } from '../../config/enums.js'; export default function (sequelize) { const Case = defineCase(sequelize, DataTypes); @@ -35,7 +36,17 @@ export default function (sequelize) { if (type === 'json') { return res.json(cases); } 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, skipEmptyLines: true, }); diff --git a/backend/routes/cases/download.test.js b/backend/routes/cases/download.test.js new file mode 100644 index 0000000..38e36c3 --- /dev/null +++ b/backend/routes/cases/download.test.js @@ -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'); + }); +}); diff --git a/backend/routes/cases/index.js b/backend/routes/cases/index.js index b64e51a..6139440 100644 --- a/backend/routes/cases/index.js +++ b/backend/routes/cases/index.js @@ -17,7 +17,7 @@ export default function (sequelize) { Tags.belongsToMany(Case, { through: 'caseTags', foreignKey: 'tagId', otherKey: 'caseId' }); router.get('/', verifySignedIn, verifyProjectVisibleFromFolderId, async (req, res) => { - const { folderId, title, priority, type } = req.query; + const { folderId, title, priority, type, tag } = req.query; if (!folderId) { 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({ where: whereClause, - include: [ - { - model: Tags, - attributes: ['id', 'name'], - through: { attributes: [] }, - }, - ], + include: [tagInclude], }); res.json(cases); } catch (error) { diff --git a/backend/routes/cases/indexByProjectId.js b/backend/routes/cases/indexByProjectId.js index 4bc883c..c630441 100644 --- a/backend/routes/cases/indexByProjectId.js +++ b/backend/routes/cases/indexByProjectId.js @@ -25,41 +25,56 @@ export default function (sequelize) { RunCase.belongsTo(Case, { foreignKey: 'caseId' }); const { verifySignedIn } = authMiddleware(sequelize); const { verifyProjectVisibleFromProjectId } = visibilityMiddleware(sequelize); + const { verifyProjectVisibleFromRunId } = visibilityMiddleware(sequelize); - router.get('/byproject', verifySignedIn, verifyProjectVisibleFromProjectId, async (req, res) => { - const { projectId } = req.query; + router.get( + '/byproject', + verifySignedIn, + verifyProjectVisibleFromProjectId, + verifyProjectVisibleFromRunId, + async (req, res) => { + const { projectId, runId } = req.query; - if (!projectId) { - return res.status(400).json({ error: 'projectId is required' }); - } + if (!projectId) { + return res.status(400).json({ error: 'projectId is required' }); + } - try { - const cases = await Case.findAll({ - include: [ - { - model: Folder, - where: { - projectId: projectId, + if (!runId) { + return res.status(400).json({ error: 'runId is required' }); + } + + try { + const cases = await Case.findAll({ + include: [ + { + model: Folder, + where: { + projectId: projectId, + }, + attributes: [], }, - attributes: [], - }, - { - model: RunCase, - attributes: ['id', 'runId', 'status'], - }, - { - model: Tags, - attributes: ['id', 'name'], - through: { attributes: [] }, - }, - ], - }); - res.json(cases); - } catch (error) { - console.error(error); - res.status(500).send('Internal Server Error'); + { + model: RunCase, + attributes: ['id', 'runId', 'status'], + required: false, + where: { + runId: runId, + }, + }, + { + model: Tags, + attributes: ['id', 'name'], + through: { attributes: [] }, + }, + ], + }); + res.json(cases); + } catch (error) { + console.error(error); + res.status(500).send('Internal Server Error'); + } } - }); + ); return router; } diff --git a/backend/routes/casetags/delete.js b/backend/routes/casetags/delete.js deleted file mode 100644 index 18caac6..0000000 --- a/backend/routes/casetags/delete.js +++ /dev/null @@ -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; -} diff --git a/backend/routes/casetags/edit.js b/backend/routes/casetags/edit.js new file mode 100644 index 0000000..d29e3ef --- /dev/null +++ b/backend/routes/casetags/edit.js @@ -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; +} diff --git a/backend/routes/casetags/new.js b/backend/routes/casetags/new.js deleted file mode 100644 index 19b0898..0000000 --- a/backend/routes/casetags/new.js +++ /dev/null @@ -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; -} diff --git a/backend/routes/runs/download.js b/backend/routes/runs/download.js index 27301d9..15a6b58 100644 --- a/backend/routes/runs/download.js +++ b/backend/routes/runs/download.js @@ -9,6 +9,7 @@ import defineCase from '../../models/cases.js'; import defineFolder from '../../models/folders.js'; import authMiddleware from '../../middleware/auth.js'; import visibilityMiddleware from '../../middleware/verifyVisible.js'; +import { testRunCaseStatus, testRunStatus, priorities, testTypes, automationStatus } from '../../config/enums.js'; export default function (sequelize) { const { verifySignedIn } = authMiddleware(sequelize); @@ -99,11 +100,11 @@ export default function (sequelize) { const records = runCases.map((rc) => ({ id: rc.Case.id, title: rc.Case.title, - state: rc.Case.state, - priority: rc.Case.priority, - type: rc.Case.type, - automationStatus: rc.Case.automationStatus, - status: rc.status, + state: testRunStatus[rc.Case.state] || rc.Case.state, + priority: priorities[rc.Case.priority] || rc.Case.priority, + type: testTypes[rc.Case.type] || rc.Case.type, + automationStatus: automationStatus[rc.Case.automationStatus] || rc.Case.automationStatus, + status: testRunCaseStatus[rc.status] || rc.status, })); const csv = Papa.unparse(records, { @@ -111,7 +112,7 @@ export default function (sequelize) { 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`); return res.send(csv); } diff --git a/backend/routes/runs/download.test.js b/backend/routes/runs/download.test.js new file mode 100644 index 0000000..714e1c7 --- /dev/null +++ b/backend/routes/runs/download.test.js @@ -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'); + }); +}); diff --git a/backend/routes/tags/edit.js b/backend/routes/tags/edit.js index cccaeee..b0a51a3 100644 --- a/backend/routes/tags/edit.js +++ b/backend/routes/tags/edit.js @@ -3,7 +3,6 @@ const router = express.Router(); import { DataTypes, Op } from 'sequelize'; import authMiddleware from '../../middleware/auth.js'; import editableMiddleware from '../../middleware/verifyEditable.js'; - import defineTag from '../../models/tags.js'; export default function (sequelize) { @@ -48,9 +47,19 @@ export default function (sequelize) { 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); } catch (error) { diff --git a/backend/server.js b/backend/server.js index a074eb3..ea60146 100644 --- a/backend/server.js +++ b/backend/server.js @@ -168,10 +168,8 @@ app.use('/tags', tagsDeleteRoute(sequelize)); app.use('/tags', tagsEditRoute(sequelize)); // "/casetags" -import caseTagsNewRoute from './routes/casetags/new.js'; -import caseTagsDeleteRoute from './routes/casetags/delete.js'; -app.use('/casetags', caseTagsNewRoute(sequelize)); -app.use('/casetags', caseTagsDeleteRoute(sequelize)); +import caseTagsEditRoute from './routes/casetags/edit.js'; +app.use('/casetags', caseTagsEditRoute(sequelize)); // "/home" import homeIndexRoute from './routes/home/index.js'; diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 1036c3b..fc4cbac 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -237,7 +237,9 @@ "move": "Move", "clone": "Clone", "cases_moved": "Test cases moved", - "cases_cloned": "Test cases cloned" + "cases_cloned": "Test cases cloned", + "tags": "Tags", + "select_tags": "Select tags" }, "Case": { "back_to_cases": "Back to test cases", @@ -269,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 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": { "run_list": "Test Run List", @@ -337,7 +348,10 @@ "user_name_or_email": "User name or email", "close": "Close", "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": { "project_management": "Project Management", @@ -351,6 +365,21 @@ "delete_project": "Delete Project", "delete": "Delete", "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." } } diff --git a/frontend/messages/ja.json b/frontend/messages/ja.json index 5bf5bb2..5b961d6 100644 --- a/frontend/messages/ja.json +++ b/frontend/messages/ja.json @@ -15,7 +15,6 @@ "skipped": "スキップ" }, "Priority": { - "priority": "優先度", "critical": "致", "high": "高", "medium": "中", @@ -136,6 +135,14 @@ "current_password_incorrect": "現在のパスワードが正しくありません", "update_error": "更新に失敗しました" }, + "Health": { + "health_check": "ヘルスチェック", + "status": "ステータス", + "ok": "正常", + "error": "エラー", + "api_server": "APIサーバー", + "unittcms_version": "UnitTCMS バージョン" + }, "Admin": { "user_management": "ユーザー管理", "avatar": "アバター", @@ -154,14 +161,6 @@ "lost_admin_auth": "管理者権限を失いました", "at_least": "最低1人以上の管理者が必要です。" }, - "Health": { - "health_check": "ヘルスチェック", - "status": "ステータス", - "ok": "正常", - "error": "エラー", - "api_server": "APIサーバー", - "unittcms_version": "UnitTCMS バージョン" - }, "Projects": { "project_list": "プロジェクト一覧", "new_project": "新規プロジェクト", @@ -238,7 +237,9 @@ "move": "移動", "clone": "クローン", "cases_moved": "テストケースを移動しました", - "cases_cloned": "テストケースをクローンしました" + "cases_cloned": "テストケースをクローンしました", + "tags": "タグ", + "select_tags": "タグを選択" }, "Case": { "back_to_cases": "テストケース一覧に戻る", @@ -270,7 +271,16 @@ "click_to_upload": "クリックしてアップロード", "or_drag_and_drop": "またはドラッグアンドドロップ", "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": { "run_list": "テストラン一覧", @@ -303,8 +313,8 @@ "refresh": "再読み込み", "id": "ID", "title": "タイトル", - "description": "詳細", "please_enter": "タイトルを入力してください", + "description": "詳細", "priority": "優先度", "status": "ステータス", "actions": "アクション", @@ -338,7 +348,10 @@ "user_name_or_email": "ユーザー名またはメールアドレス", "close": "閉じる", "add": "追加", - "are_you_sure": "メンバーを削除してもよろしいですか?" + "are_you_sure": "メンバーを削除してもよろしいですか?", + "member_added": "メンバーが追加されました", + "role_changed": "メンバーのロールを更新しました", + "member_deleted": "メンバーを削除しました" }, "Settings": { "project_management": "プロジェクト管理", @@ -352,6 +365,21 @@ "delete_project": "プロジェクトの削除", "delete": "削除", "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": "タグの削除に失敗しました。もう一度お試しください。" } } diff --git a/frontend/messages/pt-BR.json b/frontend/messages/pt-BR.json index 3621182..784f172 100644 --- a/frontend/messages/pt-BR.json +++ b/frontend/messages/pt-BR.json @@ -237,7 +237,9 @@ "move": "Mover", "clone": "Clonar", "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": { "back_to_cases": "Voltar para os casos de teste", @@ -269,7 +271,16 @@ "click_to_upload": "Clique para enviar", "or_drag_and_drop": " ou arraste e solte", "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": { "run_list": "Lista de Execuções de Teste", @@ -337,7 +348,10 @@ "user_name_or_email": "Nome de usuário ou e-mail", "close": "Fechar", "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": { "project_management": "Gerenciamento de Projetos", @@ -351,6 +365,21 @@ "delete_project": "Excluir Projeto", "delete": "Excluir", "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." } } diff --git a/frontend/messages/strings.test.ts b/frontend/messages/strings.test.ts new file mode 100644 index 0000000..45c2809 --- /dev/null +++ b/frontend/messages/strings.test.ts @@ -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).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); + }); + } +}); diff --git a/frontend/src/app/[locale]/admin/AdminPage.tsx b/frontend/src/app/[locale]/admin/AdminPage.tsx index 1f04373..37f8303 100644 --- a/frontend/src/app/[locale]/admin/AdminPage.tsx +++ b/frontend/src/app/[locale]/admin/AdminPage.tsx @@ -84,7 +84,8 @@ export default function AdminPage({ messages, locale }: Props) { const data = await updateUserRole(tokenContext.token.access_token, userEdit.id, role); if (data.user) { addToast({ - title: 'Info', + title: 'Success', + color: 'success', description: messages.roleChanged, }); setUsers((prevUsers) => { @@ -106,13 +107,16 @@ export default function AdminPage({ messages, locale }: Props) { if (data && data.user) { addToast({ - title: 'Info', + title: 'Success', + color: 'success', description: messages.lostAdminAuth, }); router.push(`/`, { locale: locale }); } else { + setIsConfirmDialogOpen(false); addToast({ - title: 'Info', + title: 'Error', + color: 'danger', description: messages.atLeast, }); } diff --git a/frontend/src/app/[locale]/health/HealthPage.tsx b/frontend/src/app/[locale]/health/HealthPage.tsx index 82ec700..03f17a0 100644 --- a/frontend/src/app/[locale]/health/HealthPage.tsx +++ b/frontend/src/app/[locale]/health/HealthPage.tsx @@ -62,7 +62,7 @@ export default function HealthPage({ messages, locale }: Props) { {messages.unittcms_version} - 1.0.0-beta.22 + 1.0.0-beta.23 {messages.api_server} diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CasesPane.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CasesPane.tsx index b851eb0..3a28d36 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CasesPane.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CasesPane.tsx @@ -37,6 +37,7 @@ export default function CasesPane({ const [titleFilter, setTitleFilter] = useState(''); const [priorityFilter, setPriorityFilter] = useState([]); const [typeFilter, setTypeFilter] = useState([]); + const [tagFilter, setTagFilter] = useState([]); const [isDeleteConfirmDialogOpen, setIsDeleteConfirmDialogOpen] = useState(false); const [deleteCaseIds, setDeleteCaseIds] = useState([]); @@ -44,7 +45,7 @@ export default function CasesPane({ const router = useRouter(); 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()); if (updates.title) { @@ -65,6 +66,12 @@ export default function CasesPane({ 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()}`; router.push(newUrl, { scroll: false }); }; @@ -76,10 +83,12 @@ export default function CasesPane({ const titleParam = searchParams.get('title') || ''; const priorityParam = parseQueryParam(searchParams.get('priority')); const typeParam = parseQueryParam(searchParams.get('type')); + const tagParam = parseQueryParam(searchParams.get('tag')); setTitleFilter(titleParam); setPriorityFilter(priorityParam); setTypeFilter(typeParam); + setTagFilter(tagParam); try { const data = await fetchCases( @@ -87,7 +96,8 @@ export default function CasesPane({ Number(folderId), titleParam || undefined, priorityParam.length > 0 ? priorityParam : undefined, - typeParam.length > 0 ? typeParam : undefined + typeParam.length > 0 ? typeParam : undefined, + tagParam.length > 0 ? tagParam : undefined ); setCases(data); } catch (error: unknown) { @@ -133,11 +143,12 @@ export default function CasesPane({ 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); setPriorityFilter(priorities); 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} activePriorityFilters={priorityFilter} activeTypeFilters={typeFilter} + activeTagFilters={tagFilter} messages={messages} priorityMessages={priorityMessages} testTypeMessages={testTypeMessages} diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/TestCaseFilter.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/TestCaseFilter.tsx index de6a17a..d7c04e3 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/TestCaseFilter.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/TestCaseFilter.tsx @@ -1,10 +1,23 @@ -import { useState, useEffect } from 'react'; -import { Button, Dropdown, DropdownTrigger, DropdownMenu, DropdownItem, Selection, Input } from '@heroui/react'; +import { useState, useEffect, useContext } from 'react'; +import { + Button, + Dropdown, + DropdownTrigger, + DropdownMenu, + DropdownItem, + Selection, + Input, + addToast, +} from '@heroui/react'; import { SearchIcon, ChevronDown, Circle } from 'lucide-react'; import { PriorityMessages } from '@/types/priority'; import { TestTypeMessages } from '@/types/testType'; import { CasesMessages } from '@/types/case'; 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 = { messages: CasesMessages; @@ -13,9 +26,13 @@ type TestCaseFilterProps = { activeTitleFilter: string; activePriorityFilters: 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; + export default function TestCaseFilter({ messages, priorityMessages, @@ -23,11 +40,38 @@ export default function TestCaseFilter({ activeTitleFilter, activePriorityFilters, activeTypeFilters, + activeTagFilters, onFilterChange, + projectId, }: TestCaseFilterProps) { + const tokenContext = useContext(TokenContext); const [title, setTitle] = useState(''); const [selectedPriorities, setSelectedPriorities] = useState(new Set([])); const [selectedTypes, setSelectedTypes] = useState(new Set([])); + const [selectedTags, setSelectedTags] = useState(new Set([])); + const [tags, setTags] = useState([]); + + 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(() => { setTitle(activeTitleFilter); @@ -74,13 +118,20 @@ export default function TestCaseFilter({ .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 = () => { setSelectedPriorities(new Set([])); setSelectedTypes(new Set([])); - onFilterChange('', [], []); + onFilterChange('', [], [], []); }; return ( @@ -159,6 +210,35 @@ export default function TestCaseFilter({ +
+

+
+ + +
+

{messages.tags}

+ + + + + + {tags.map((tag) => ( + + {tag.name} + + ))} + +
+
+ +
+ {tags.length === 0 &&
{messages.noTagsAvailable}
} + + {tags.map((tag) => ( +
+ {editingTag === tag.id ? ( + <> +
+ { + 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', + }} + /> +
+ + +
+
+ + ) : ( + <> +
+ {tag.name} +
+
+ + setOpenPopoverTagId(open ? tag.id : null)} + > + + + + +
+
{messages.deleteTag}
+
{messages.areYouSureDeleteTag}
+
+ + +
+
+
+
+
+ + )} +
+ ))} +
+ + + ); +} diff --git a/frontend/src/app/[locale]/projects/[projectId]/settings/SettingsPage.tsx b/frontend/src/app/[locale]/projects/[projectId]/settings/SettingsPage.tsx index 8881564..0a24be2 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/settings/SettingsPage.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/settings/SettingsPage.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useContext } from 'react'; import { Button, Table, TableHeader, TableColumn, TableBody, TableRow, TableCell } from '@heroui/react'; import { Pencil, Trash } from 'lucide-react'; +import ProjectTagsManager from './ProjectTagsManager'; import { SettingsMessages } from '@/types/settings'; import { TokenContext } from '@/utils/TokenProvider'; import { deleteProject, fetchProject, updateProject } from '@/utils/projectsControl'; @@ -143,6 +144,14 @@ export default function SettingsPage({ projectId, messages, projectDialogMessage +
+

{messages.tagManagement}

+
+ +
+ +
+ { if (ret.reason === 'notoken') { if (toastMessages) { addToast({ - title: 'Info', + title: 'Error', description: toastMessages.needSignedIn, color: 'danger', }); @@ -147,7 +147,7 @@ const TokenProvider = ({ toastMessages, locale, children }: TokenProps) => { } else if (ret.reason === 'expired') { if (toastMessages) { addToast({ - title: 'Info', + title: 'Error', description: toastMessages.sessionExpired, color: 'danger', }); diff --git a/frontend/utils/caseControl.ts b/frontend/utils/caseControl.ts index 7960ff0..69b518f 100644 --- a/frontend/utils/caseControl.ts +++ b/frontend/utils/caseControl.ts @@ -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}`]; if (title) { @@ -41,6 +48,10 @@ async function fetchCases(jwt: string, folderId: number, title?: string, priorit queryParams.push(`type=${type.join(',')}`); } + if (tag && tag.length > 0) { + queryParams.push(`tag=${tag.join(',')}`); + } + const query = queryParams.length > 0 ? `?${queryParams.join('&')}` : ''; const url = `${apiServer}/cases${query}`; diff --git a/frontend/utils/caseTagsControls.ts b/frontend/utils/caseTagsControls.ts new file mode 100644 index 0000000..cf6da0c --- /dev/null +++ b/frontend/utils/caseTagsControls.ts @@ -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); + } +} diff --git a/frontend/utils/tagsControls.ts b/frontend/utils/tagsControls.ts new file mode 100644 index 0000000..6b54ddc --- /dev/null +++ b/frontend/utils/tagsControls.ts @@ -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 };