feat(tags): Test case tag editing UI and tag settings UI (#322)

This commit is contained in:
Eliezer Castro
2025-11-02 04:52:15 -03:00
committed by GitHub
parent b71f3e99cc
commit 3fd226bdbd
21 changed files with 896 additions and 143 deletions

View File

@@ -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>

View File

@@ -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"

View File

@@ -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>
);
}

View File

@@ -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');

View File

@@ -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');

View File

@@ -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>
);
}

View File

@@ -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}

View File

@@ -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');