feat(tags): Test case tag editing UI and tag settings UI (#322)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -237,7 +237,8 @@
|
||||
"move": "Move",
|
||||
"clone": "Clone",
|
||||
"cases_moved": "Test cases moved",
|
||||
"cases_cloned": "Test cases cloned"
|
||||
"cases_cloned": "Test cases cloned",
|
||||
"tags": "Tags"
|
||||
},
|
||||
"Case": {
|
||||
"back_to_cases": "Back to test cases",
|
||||
@@ -269,7 +270,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",
|
||||
@@ -351,6 +361,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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,7 +238,8 @@
|
||||
"move": "移動",
|
||||
"clone": "クローン",
|
||||
"cases_moved": "テストケースを移動しました",
|
||||
"cases_cloned": "テストケースをクローンしました"
|
||||
"cases_cloned": "テストケースをクローンしました",
|
||||
"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": "テストラン一覧",
|
||||
@@ -352,6 +362,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": "タグの削除に失敗しました。もう一度お試しください。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,7 +237,8 @@
|
||||
"move": "Mover",
|
||||
"clone": "Clonar",
|
||||
"cases_moved": "Casos de teste movidos",
|
||||
"cases_cloned": "Casos de teste clonados"
|
||||
"cases_cloned": "Casos de teste clonados",
|
||||
"tags": "Tags"
|
||||
},
|
||||
"Case": {
|
||||
"back_to_cases": "Voltar para os casos de teste",
|
||||
@@ -269,7 +270,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",
|
||||
@@ -351,6 +361,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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Checkbox,
|
||||
Card,
|
||||
CardBody,
|
||||
Chip,
|
||||
} from '@heroui/react';
|
||||
import {
|
||||
Plus,
|
||||
@@ -81,6 +82,7 @@ export default function TestCaseTable({
|
||||
{ name: messages.id, uid: 'id', sortable: true },
|
||||
{ name: messages.title, uid: 'title', sortable: true },
|
||||
{ name: messages.priority, uid: 'priority', sortable: true },
|
||||
{ name: messages.tags, uid: 'tags' },
|
||||
{ name: messages.actions, uid: 'actions' },
|
||||
];
|
||||
|
||||
@@ -107,6 +109,17 @@ export default function TestCaseTable({
|
||||
);
|
||||
case 'priority':
|
||||
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':
|
||||
return (
|
||||
<Dropdown>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
'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 { Save, Plus, ArrowLeft, Circle } from 'lucide-react';
|
||||
import CaseStepsEditor from './CaseStepsEditor';
|
||||
import CaseAttachmentsEditor from './CaseAttachmentsEditor';
|
||||
import { updateSteps } from './stepControl';
|
||||
import { fetchCreateAttachments, fetchDownloadAttachment, fetchDeleteAttachment } from './attachmentControl';
|
||||
import CaseTagsEditor from './CaseTagsEditor';
|
||||
import { fetchCase, updateCase } from '@/utils/caseControl';
|
||||
import { priorities, testTypes, templates } from '@/config/selection';
|
||||
import { useRouter } from '@/src/i18n/routing';
|
||||
@@ -15,6 +16,7 @@ import { CaseType, AttachmentType, CaseMessages, StepType } from '@/types/case';
|
||||
import { PriorityMessages } from '@/types/priority';
|
||||
import { TestTypeMessages } from '@/types/testType';
|
||||
import { logError } from '@/utils/errorHandler';
|
||||
import { updateCaseTags } from '@/utils/caseTagsControls';
|
||||
|
||||
const defaultTestCase = {
|
||||
id: 0,
|
||||
@@ -32,6 +34,7 @@ const defaultTestCase = {
|
||||
Attachments: [],
|
||||
isIncluded: false,
|
||||
runStatus: 0,
|
||||
Tags: [],
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -59,6 +62,8 @@ export default function CaseEditor({
|
||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||
const [plusCount, setPlusCount] = useState<number>(0);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [selectedTags, setSelectedTags] = useState<{ id: number; name: string }[]>([]);
|
||||
|
||||
const router = useRouter();
|
||||
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(() => {
|
||||
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();
|
||||
}, [caseId, tokenContext, fetchAndSetCase]);
|
||||
}, [tokenContext, caseId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -258,18 +263,30 @@ export default function CaseEditor({
|
||||
isLoading={isUpdating}
|
||||
onPress={async () => {
|
||||
setIsUpdating(true);
|
||||
await updateCase(tokenContext.token.access_token, testCase);
|
||||
if (testCase.Steps) {
|
||||
await updateSteps(tokenContext.token.access_token, Number(caseId), testCase.Steps);
|
||||
}
|
||||
await fetchAndSetCase();
|
||||
try {
|
||||
await updateCase(tokenContext.token.access_token, testCase);
|
||||
if (testCase.Steps) {
|
||||
await updateSteps(tokenContext.token.access_token, Number(caseId), testCase.Steps);
|
||||
}
|
||||
|
||||
addToast({
|
||||
title: 'Info',
|
||||
description: messages.updatedTestCase,
|
||||
});
|
||||
setIsUpdating(false);
|
||||
setIsDirty(false);
|
||||
const tagIds = selectedTags.map((tag) => tag.id);
|
||||
await updateCaseTags(tokenContext.token.access_token, Number(caseId), tagIds, projectId);
|
||||
|
||||
addToast({
|
||||
title: 'Info',
|
||||
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}
|
||||
@@ -305,6 +322,16 @@ export default function CaseEditor({
|
||||
className="mt-3"
|
||||
/>
|
||||
|
||||
<CaseTagsEditor
|
||||
projectId={projectId}
|
||||
selectedTags={selectedTags}
|
||||
onChange={(tags) => {
|
||||
setSelectedTags(tags);
|
||||
setIsDirty(true);
|
||||
}}
|
||||
messages={messages}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Select
|
||||
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'),
|
||||
maxFileSize: t('max_file_size'),
|
||||
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');
|
||||
|
||||
@@ -46,6 +46,7 @@ export default function Page({ params }: { params: { projectId: string; folderId
|
||||
clone: t('clone'),
|
||||
casesMoved: t('cases_moved'),
|
||||
casesCloned: t('cases_cloned'),
|
||||
tags: t('tags'),
|
||||
};
|
||||
|
||||
const priorityTranslation = useTranslations('Priority');
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
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',
|
||||
description: messages.tagCreated,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : messages.tagErrorCreate;
|
||||
addToast({
|
||||
title: 'Error',
|
||||
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',
|
||||
description: messages.tagUpdated,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : messages.tagErrorUpdate;
|
||||
addToast({
|
||||
title: 'Error',
|
||||
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',
|
||||
description: messages.tagDeleted,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : messages.tagErrorDelete;
|
||||
addToast({
|
||||
title: 'Error',
|
||||
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 { 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
|
||||
</Table>
|
||||
</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
|
||||
isOpen={isProjectDialogOpen}
|
||||
editingProject={project}
|
||||
|
||||
@@ -28,6 +28,21 @@ export default function Page({ params }: { params: { projectId: string; locale:
|
||||
delete: t('delete'),
|
||||
close: t('close'),
|
||||
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');
|
||||
|
||||
@@ -13,6 +13,10 @@ type CaseType = {
|
||||
Steps?: StepType[];
|
||||
RunCases?: RunCaseType[];
|
||||
Attachments?: AttachmentType[];
|
||||
Tags?: {
|
||||
id: number;
|
||||
name: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
type CaseStepType = {
|
||||
@@ -89,6 +93,7 @@ type CasesMessages = {
|
||||
move: string;
|
||||
clone: string;
|
||||
casesMoved: string;
|
||||
tags: string;
|
||||
casesCloned: string;
|
||||
};
|
||||
|
||||
@@ -123,6 +128,15 @@ type CaseMessages = {
|
||||
orDragAndDrop: string;
|
||||
maxFileSize: 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 };
|
||||
|
||||
@@ -11,4 +11,19 @@ export type SettingsMessages = {
|
||||
delete: string;
|
||||
close: 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 };
|
||||
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