Merge pull request #291 from kimatata/develop

This commit is contained in:
kimatata
2025-10-05 16:58:29 +09:00
committed by GitHub
14 changed files with 372 additions and 107 deletions

View File

@@ -0,0 +1,36 @@
import express from 'express';
const router = express.Router();
import { DataTypes } from 'sequelize';
import defineCase from '../../models/cases.js';
import authMiddleware from '../../middleware/auth.js';
import editableMiddleware from '../../middleware/verifyEditable.js';
export default function (sequelize) {
const { verifySignedIn } = authMiddleware(sequelize);
const { verifyProjectDeveloperFromProjectId } = editableMiddleware(sequelize);
const Case = defineCase(sequelize, DataTypes);
router.put('/move', verifySignedIn, verifyProjectDeveloperFromProjectId, async (req, res) => {
const { caseIds, targetFolderId } = req.body;
if (!Array.isArray(caseIds) || caseIds.length === 0 || !targetFolderId) {
return res.status(400).json({ error: 'caseIds(array) and targetFolderId are required' });
}
try {
const cases = await Case.findAll({ where: { id: caseIds } });
if (cases.length !== caseIds.length) {
return res.status(404).json({ error: 'Some cases not found' });
}
await Case.update({ folderId: targetFolderId }, { where: { id: caseIds } });
res.status(200).json({ message: 'Cases moved successfully', movedCaseIds: caseIds, targetFolderId });
} catch (error) {
console.error('Error moving cases:', error);
res.status(500).send('Internal Server Error');
}
});
return router;
}

View File

@@ -1,9 +1,11 @@
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url';
import express from 'express'; import express from 'express';
import cors from 'cors'; import cors from 'cors';
import RateLimit from 'express-rate-limit'; import RateLimit from 'express-rate-limit';
import { Sequelize } from 'sequelize'; import { Sequelize } from 'sequelize';
const __dirname = path.dirname(new URL(import.meta.url).pathname); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express(); const app = express();
// enable frontend access // enable frontend access
@@ -82,6 +84,7 @@ app.use('/folders', foldersDeleteRoute(sequelize));
// "/cases" // "/cases"
import casesDownloadRoute from './routes/cases/download.js'; import casesDownloadRoute from './routes/cases/download.js';
import casesMoveRoute from './routes/cases/move.js';
import casesIndexRoute from './routes/cases/index.js'; import casesIndexRoute from './routes/cases/index.js';
import casesIndexByProjectIdRoute from './routes/cases/indexByProjectId.js'; import casesIndexByProjectIdRoute from './routes/cases/indexByProjectId.js';
import casesShowRoute from './routes/cases/show.js'; import casesShowRoute from './routes/cases/show.js';
@@ -89,6 +92,7 @@ import casesNewRoute from './routes/cases/new.js';
import casesEditRoute from './routes/cases/edit.js'; import casesEditRoute from './routes/cases/edit.js';
import casesDeleteRoute from './routes/cases/delete.js'; import casesDeleteRoute from './routes/cases/delete.js';
app.use('/cases', casesDownloadRoute(sequelize)); app.use('/cases', casesDownloadRoute(sequelize));
app.use('/cases', casesMoveRoute(sequelize));
app.use('/cases', casesIndexRoute(sequelize)); app.use('/cases', casesIndexRoute(sequelize));
app.use('/cases', casesIndexByProjectIdRoute(sequelize)); app.use('/cases', casesIndexByProjectIdRoute(sequelize));
app.use('/cases', casesShowRoute(sequelize)); app.use('/cases', casesShowRoute(sequelize));

View File

@@ -210,7 +210,8 @@
"select_priorities": "Select priorities", "select_priorities": "Select priorities",
"selected": "Selected", "selected": "Selected",
"type": "Type", "type": "Type",
"select_types": "Select types" "select_types": "Select types",
"cases_moved": "Test cases moved"
}, },
"Case": { "Case": {
"back_to_cases": "Back to test cases", "back_to_cases": "Back to test cases",

View File

@@ -211,7 +211,8 @@
"select_priorities": "優先度を選択", "select_priorities": "優先度を選択",
"selected": "選択済み", "selected": "選択済み",
"type": "タイプ", "type": "タイプ",
"select_types": "タイプを選択" "select_types": "タイプを選択",
"cases_moved": "テストケースを移動しました"
}, },
"Case": { "Case": {
"back_to_cases": "テストケース一覧に戻る", "back_to_cases": "テストケース一覧に戻る",

View File

@@ -210,7 +210,8 @@
"select_priorities": "Selecionar prioridades", "select_priorities": "Selecionar prioridades",
"selected": "Selecionado", "selected": "Selecionado",
"type": "Tipo", "type": "Tipo",
"select_types": "Selecionar tipos" "select_types": "Selecionar tipos",
"moved": "Casos de teste movidos"
}, },
"Case": { "Case": {
"back_to_cases": "Voltar para os casos de teste", "back_to_cases": "Voltar para os casos de teste",

View File

@@ -69,6 +69,25 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@emotion/is-prop-valid": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.2.tgz",
"integrity": "sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emotion/memoize": "^0.8.1"
}
},
"node_modules/@emotion/memoize": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz",
"integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==",
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/@formatjs/ecma402-abstract": { "node_modules/@formatjs/ecma402-abstract": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.3.tgz", "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.3.tgz",
@@ -10114,9 +10133,10 @@
} }
}, },
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.1.2", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
}, },
"node_modules/dayjs": { "node_modules/dayjs": {
"version": "1.11.13", "version": "1.11.13",

View File

@@ -62,7 +62,7 @@ export default function HealthPage({ messages, locale }: Props) {
<TableBody> <TableBody>
<TableRow key="1"> <TableRow key="1">
<TableCell>{messages.unittcms_version}</TableCell> <TableCell>{messages.unittcms_version}</TableCell>
<TableCell>1.0.0-beta.19</TableCell> <TableCell>1.0.0-beta.20</TableCell>
</TableRow> </TableRow>
<TableRow key="2"> <TableRow key="2">
<TableCell>{messages.api_server}</TableCell> <TableCell>{messages.api_server}</TableCell>

View File

@@ -13,6 +13,7 @@ import DeleteConfirmDialog from '@/components/DeleteConfirmDialog';
import { FolderType, FoldersMessages, TreeNodeData } from '@/types/folder'; import { FolderType, FoldersMessages, TreeNodeData } from '@/types/folder';
import { logError } from '@/utils/errorHandler'; import { logError } from '@/utils/errorHandler';
import { buildFolderTree } from '@/utils/buildFolderTree'; import { buildFolderTree } from '@/utils/buildFolderTree';
import { emitMoveEvent } from '@/utils/testCaseMoveEvent';
type Props = { type Props = {
projectId: string; projectId: string;
@@ -114,6 +115,21 @@ export default function FoldersPane({ projectId, messages, locale }: Props) {
} }
}; };
// **************************************************************************
// move test case
// **************************************************************************
const handleDragOver = (e: React.DragEvent) => {
e.stopPropagation();
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
};
const handleDrop = (e: React.DragEvent, dropFolderId: string) => {
e.stopPropagation();
const ids = JSON.parse(e.dataTransfer.getData('application/json'));
emitMoveEvent(ids, Number(dropFolderId));
};
return ( return (
<> <>
<div className="w-80 min-h-[calc(100vh-64px)] border-r-1 dark:border-neutral-700"> <div className="w-80 min-h-[calc(100vh-64px)] border-r-1 dark:border-neutral-700">
@@ -144,16 +160,18 @@ export default function FoldersPane({ projectId, messages, locale }: Props) {
disableDrag={true} disableDrag={true}
> >
{(props) => ( {(props) => (
<FolderItem <div onDragOver={handleDragOver} onDrop={(e) => handleDrop(e, props.node.id)}>
{...props} <FolderItem
projectId={projectId} {...props}
selectedFolder={selectedFolder} projectId={projectId}
locale={locale} selectedFolder={selectedFolder}
messages={messages} locale={locale}
openDialogForCreate={openDialogForCreate} messages={messages}
onEditClick={onEditClick} openDialogForCreate={openDialogForCreate}
onDeleteClick={onDeleteClick} onEditClick={onEditClick}
/> onDeleteClick={onDeleteClick}
/>
</div>
)} )}
</Tree> </Tree>
)} )}

View File

@@ -1,10 +1,11 @@
'use client'; 'use client';
import { useState, useEffect, useContext } from 'react'; import { useState, useEffect, useContext } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { addToast } from '@heroui/react';
import TestCaseTable from './TestCaseTable'; import TestCaseTable from './TestCaseTable';
import CaseDialog from './CaseDialog'; import CaseDialog from './CaseDialog';
import { TokenContext } from '@/utils/TokenProvider'; import { TokenContext } from '@/utils/TokenProvider';
import { fetchCases, createCase, deleteCases, exportCases } from '@/utils/caseControl'; import { fetchCases, createCase, deleteCases, exportCases, moveCases } from '@/utils/caseControl';
import { CaseType, CasesMessages } from '@/types/case'; import { CaseType, CasesMessages } from '@/types/case';
import DeleteConfirmDialog from '@/components/DeleteConfirmDialog'; import DeleteConfirmDialog from '@/components/DeleteConfirmDialog';
import { PriorityMessages } from '@/types/priority'; import { PriorityMessages } from '@/types/priority';
@@ -12,6 +13,7 @@ import { TestTypeMessages } from '@/types/testType';
import { LocaleCodeType } from '@/types/locale'; import { LocaleCodeType } from '@/types/locale';
import { logError } from '@/utils/errorHandler'; import { logError } from '@/utils/errorHandler';
import { parseQueryParam } from '@/utils/parseQueryParam'; import { parseQueryParam } from '@/utils/parseQueryParam';
import { onMoveEvent } from '@/utils/testCaseMoveEvent';
type Props = { type Props = {
projectId: string; projectId: string;
@@ -96,6 +98,25 @@ export default function CasesPane({
fetchDataEffect(); fetchDataEffect();
}, [context, folderId, searchParams]); }, [context, folderId, searchParams]);
useEffect(() => {
const unsubscribe = onMoveEvent(async (e) => {
const { testCaseIds, targetFolderId } = e.detail;
const moveRet = await moveCases(context.token.access_token, testCaseIds, targetFolderId, Number(projectId));
if (!moveRet) {
console.error('Error moving cases');
return;
}
setCases(cases.filter((entry) => !testCaseIds.includes(entry.id)));
addToast({
title: 'Success',
color: 'success',
description: messages.casesMoved,
});
});
return unsubscribe;
}, [cases, context.token.access_token, messages.casesMoved, projectId]);
const closeDialog = () => setIsCaseDialogOpen(false); const closeDialog = () => setIsCaseDialogOpen(false);
const onSubmit = async (title: string, description: string) => { const onSubmit = async (title: string, description: string) => {

View File

@@ -1,11 +1,5 @@
import { useState, useMemo, useCallback, ReactNode } from 'react'; import { useState, useMemo, useCallback, ReactNode, useEffect } from 'react';
import { import {
Table,
TableHeader,
TableColumn,
TableBody,
TableRow,
TableCell,
Button, Button,
DropdownTrigger, DropdownTrigger,
Dropdown, Dropdown,
@@ -17,16 +11,31 @@ import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
Checkbox,
Card,
CardBody,
} from '@heroui/react'; } from '@heroui/react';
import { Plus, MoreVertical, Trash, FileDown, ChevronDown, Filter, FileJson, FileSpreadsheet } from 'lucide-react'; import {
Plus,
MoreVertical,
Trash,
FileDown,
ChevronUp,
ChevronDown,
Filter,
FileJson,
FileSpreadsheet,
} from 'lucide-react';
import { table } from '@heroui/theme';
import TestCaseFilter from './TestCaseFilter'; import TestCaseFilter from './TestCaseFilter';
import { Link } from '@/src/i18n/routing'; import { Link, NextUiLinkClasses } from '@/src/i18n/routing';
import { CaseType, CasesMessages } from '@/types/case'; import { CaseType, CasesMessages } from '@/types/case';
import { PriorityMessages } from '@/types/priority'; import { PriorityMessages } from '@/types/priority';
import { TestTypeMessages } from '@/types/testType'; import { TestTypeMessages } from '@/types/testType';
import TestCasePriority from '@/components/TestCasePriority'; import TestCasePriority from '@/components/TestCasePriority';
import { LocaleCodeType } from '@/types/locale'; import { LocaleCodeType } from '@/types/locale';
import { highlightSearchTerm } from '@/utils/highlightSearchTerm'; import { highlightSearchTerm } from '@/utils/highlightSearchTerm';
import { onMoveEvent } from '@/utils/testCaseMoveEvent';
type Props = { type Props = {
projectId: string; projectId: string;
@@ -63,6 +72,11 @@ export default function TestCaseTable({
testTypeMessages, testTypeMessages,
locale, locale,
}: Props) { }: Props) {
const heroUITableClasses = table();
const thClassNames = 'bg-transparent text-default-500 border-b border-divider';
const tdClassNames =
'!py-1 group-data-[first=true]:first:before:rounded-none group-data-[first=true]:last:before:rounded-none group-data-[middle=true]:before:rounded-none group-data-[last=true]:first:before:rounded-none group-data-[last=true]:last:before:rounded-none';
const headerColumns = [ const headerColumns = [
{ name: messages.id, uid: 'id', sortable: true }, { name: messages.id, uid: 'id', sortable: true },
{ name: messages.title, uid: 'title', sortable: true }, { name: messages.title, uid: 'title', sortable: true },
@@ -70,30 +84,6 @@ export default function TestCaseTable({
{ name: messages.actions, uid: 'actions' }, { name: messages.actions, uid: 'actions' },
]; ];
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set([]));
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({
column: 'id',
direction: 'ascending',
});
const [showFilter, setShowFilter] = useState(false);
const sortedItems = useMemo(() => {
if (cases.length === 0) {
return [];
}
return [...cases].sort((a: CaseType, b: CaseType) => {
const first = a[sortDescriptor.column as keyof CaseType] as number;
const second = b[sortDescriptor.column as keyof CaseType] as number;
const cmp = first < second ? -1 : first > second ? 1 : 0;
return sortDescriptor.direction === 'descending' ? -cmp : cmp;
});
}, [sortDescriptor, cases]);
const handleDeleteCase = (deleteCaseId: number) => {
onDeleteCase(deleteCaseId);
};
const renderCell = useCallback( const renderCell = useCallback(
(testCase: CaseType, columnKey: string): ReactNode => { (testCase: CaseType, columnKey: string): ReactNode => {
const cellValue = testCase[columnKey as keyof CaseType]; const cellValue = testCase[columnKey as keyof CaseType];
@@ -103,19 +93,17 @@ export default function TestCaseTable({
return <span>{cellValue as number}</span>; return <span>{cellValue as number}</span>;
case 'title': case 'title':
return ( return (
<Button <Link
size="sm"
as={Link}
href={`/projects/${projectId}/folders/${testCase.folderId}/cases/${testCase.id}`} href={`/projects/${projectId}/folders/${testCase.folderId}/cases/${testCase.id}`}
locale={locale} locale={locale}
variant="light" className={NextUiLinkClasses}
className="data-[hover=true]:bg-transparent gap-0" draggable="false"
> >
{highlightSearchTerm({ {highlightSearchTerm({
text: cellValue as string, text: cellValue as string,
searchTerm: activeTitleFilter, searchTerm: activeTitleFilter,
})} })}
</Button> </Link>
); );
case 'priority': case 'priority':
return <TestCasePriority priorityValue={cellValue as number} priorityMessages={priorityMessages} />; return <TestCasePriority priorityValue={cellValue as number} priorityMessages={priorityMessages} />;
@@ -147,6 +135,82 @@ export default function TestCaseTable({
[activeTitleFilter] [activeTitleFilter]
); );
// **************************************************************************
// filter test case
// **************************************************************************
const [showFilter, setShowFilter] = useState(false);
const activeFilterNum = (activeTitleFilter ? 1 : 0) + activePriorityFilters.length + activeTypeFilters.length;
// **************************************************************************
// select test case
// **************************************************************************
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set([]));
const handleSelectRow = (id: number) => {
setSelectedKeys((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
};
const handleSelectAll = () => {
if (selectedKeys !== 'all' && selectedKeys instanceof Set && selectedKeys.size === sortedItems.length) {
setSelectedKeys(new Set());
} else {
setSelectedKeys(new Set(sortedItems.map((item) => item.id)));
}
};
const isSelected = (id: number) => {
return selectedKeys === 'all' || (selectedKeys instanceof Set && selectedKeys.has(id));
};
const isSelectedAll = () => {
return (
(selectedKeys === 'all' && sortedItems.length > 0) ||
(selectedKeys instanceof Set && selectedKeys.size === sortedItems.length && sortedItems.length > 0)
);
};
// **************************************************************************
// sort test case
// **************************************************************************
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({
column: 'id',
direction: 'ascending',
});
const sortedItems = useMemo(() => {
if (cases.length === 0) {
return [];
}
return [...cases].sort((a: CaseType, b: CaseType) => {
const first = a[sortDescriptor.column as keyof CaseType] as number;
const second = b[sortDescriptor.column as keyof CaseType] as number;
const cmp = first < second ? -1 : first > second ? 1 : 0;
return sortDescriptor.direction === 'descending' ? -cmp : cmp;
});
}, [sortDescriptor, cases]);
const handleSort = (columnUid: string) => {
setSortDescriptor((prev) => {
if (prev.column === columnUid) {
return {
column: columnUid,
direction: prev.direction === 'ascending' ? 'descending' : 'ascending',
};
}
return { column: columnUid, direction: 'ascending' };
});
};
const handleDeleteCase = (deleteCaseId: number) => {
onDeleteCase(deleteCaseId);
};
const handleDeleteCases = () => { const handleDeleteCases = () => {
let deleteCaseIds: number[]; let deleteCaseIds: number[];
if (selectedKeys === 'all') { if (selectedKeys === 'all') {
@@ -158,26 +222,41 @@ export default function TestCaseTable({
setSelectedKeys(new Set([])); setSelectedKeys(new Set([]));
}; };
const activeFilterNum = (activeTitleFilter ? 1 : 0) + activePriorityFilters.length + activeTypeFilters.length; // **************************************************************************
// move test case
// **************************************************************************
const [dragCount, setDragCount] = useState<number | null>(null);
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
const handleDragStart = (e: React.DragEvent, id: number) => {
e.stopPropagation();
const classNames = useMemo( let selectedIds: number[];
() => ({ if (selectedKeys === 'all' || (selectedKeys instanceof Set && selectedKeys.has(id) && selectedKeys.size > 1)) {
wrapper: ['max-w-3xl'], // when multiple row selected
th: ['bg-transparent', 'text-default-500', 'border-b', 'border-divider'], selectedIds =
td: [ selectedKeys === 'all' ? sortedItems.map((item) => item.id) : (Array.from(selectedKeys) as number[]);
// changing the rows border radius } else {
// first // when no row selected or only one row selected
'group-data-[first=true]:first:before:rounded-none', selectedIds = [id];
'group-data-[first=true]:last:before:rounded-none', }
// middle setDragCount(selectedIds.length);
'group-data-[middle=true]:before:rounded-none', e.dataTransfer.setData('application/json', JSON.stringify(selectedIds));
// last const img = new window.Image();
'group-data-[last=true]:first:before:rounded-none', img.src = 'data:image/svg+xml;base64,';
'group-data-[last=true]:last:before:rounded-none', e.dataTransfer.setDragImage(img, 0, 0);
], };
}), const handleDrag = (e: React.DragEvent) => {
[] setMousePos({ x: e.clientX, y: e.clientY });
); };
const handleDragEnd = () => {
setDragCount(null);
};
useEffect(() => {
const unsubscribe = onMoveEvent(() => {
handleDragEnd();
});
return unsubscribe;
}, []);
return ( return (
<> <>
@@ -265,36 +344,77 @@ export default function TestCaseTable({
</div> </div>
</div> </div>
<Table <div>
isCompact <table className={heroUITableClasses.table()}>
removeWrapper <thead className={heroUITableClasses.thead()}>
aria-label="Test cases table" <tr className={heroUITableClasses.tr()}>
classNames={classNames} <th className={`${heroUITableClasses.th()} ${thClassNames}`}>
selectedKeys={selectedKeys} <Checkbox isSelected={isSelectedAll()} onChange={handleSelectAll} />
selectionMode="multiple" </th>
sortDescriptor={sortDescriptor} {headerColumns.map((column) => (
onSelectionChange={setSelectedKeys} <th
onSortChange={setSortDescriptor} key={column.uid}
> className={`${heroUITableClasses.th()} ${thClassNames}`}
<TableHeader columns={headerColumns}> onClick={() => column.sortable && handleSort(column.uid)}
{(column) => ( style={{ cursor: column.sortable ? 'pointer' : 'default' }}
<TableColumn >
key={column.uid} <div className="flex items-center gap-1">
align={column.uid === 'actions' ? 'center' : 'start'} {column.name}
allowsSorting={column.sortable} {column.sortable && sortDescriptor.column === column.uid && (
> <>
{column.name} {sortDescriptor.direction === 'ascending' ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</TableColumn> </>
)} )}
</TableHeader> </div>
<TableBody emptyContent={messages.noCasesFound} items={sortedItems}> </th>
{(item) => ( ))}
<TableRow key={item.id}> </tr>
{(columnKey) => <TableCell>{renderCell(item, columnKey as string)}</TableCell>} </thead>
</TableRow> <tbody className={heroUITableClasses.tbody()}>
)} {sortedItems.map((item) => (
</TableBody> <tr
</Table> draggable
className={`${heroUITableClasses.tr()} cursor-pointer`}
key={item.id}
onDragStart={(e) => handleDragStart(e, item.id)}
onDrag={handleDrag}
onDragEnd={handleDragEnd}
style={{ opacity: dragCount ? 0.5 : 1 }}
>
<td className={`${heroUITableClasses.td()} ${tdClassNames}`}>
<Checkbox isSelected={isSelected(item.id)} onChange={() => handleSelectRow(item.id)} />
</td>
{headerColumns.map((column) => (
<td key={column.uid} className={`${heroUITableClasses.td()} ${tdClassNames}`}>
{renderCell(item, column.uid)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{sortedItems.length === 0 && (
<div className="flex justify-center items-center w-full h-48 text-neutral-500">
<div>No test case</div>
</div>
)}
{dragCount !== null && (
<Card
className="absolute"
style={{
left: mousePos.x,
top: mousePos.y,
pointerEvents: 'none',
}}
>
<CardBody>
<p>{dragCount} cases selected</p>
</CardBody>
</Card>
)}
</> </>
); );
} }

View File

@@ -40,6 +40,7 @@ export default function Page({ params }: { params: { projectId: string; folderId
selected: t('selected'), selected: t('selected'),
type: t('type'), type: t('type'),
selectTypes: t('select_types'), selectTypes: t('select_types'),
casesMoved: t('cases_moved'),
}; };
const priorityTranslation = useTranslations('Priority'); const priorityTranslation = useTranslations('Priority');

View File

@@ -84,6 +84,7 @@ type CasesMessages = {
selected: string; selected: string;
type: string; type: string;
selectTypes: string; selectTypes: string;
casesMoved: string;
}; };
type CaseMessages = { type CaseMessages = {

View File

@@ -125,6 +125,28 @@ async function updateCase(jwt: string, updateCaseData: CaseType) {
} }
} }
export async function moveCases(jwt: string, moveCaseIds: number[], targetFolderId: number, projectId: number) {
const fetchOptions = {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwt}`,
},
body: JSON.stringify({ caseIds: moveCaseIds, targetFolderId }),
};
const url = `${apiServer}/cases/move?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 project', error);
}
}
async function deleteCases(jwt: string, deleteCaseIds: number[], projectId: number) { async function deleteCases(jwt: string, deleteCaseIds: number[], projectId: number) {
const fetchOptions = { const fetchOptions = {
method: 'POST', method: 'POST',

View File

@@ -0,0 +1,19 @@
const eventBus = new EventTarget();
const TEST_CASE_MOVE = 'testCasesMove';
type MoveEventDetail = {
testCaseIds: number[];
targetFolderId: number;
};
export const emitMoveEvent = (ids: number[], targetFolderId: number) => {
eventBus.dispatchEvent(
new CustomEvent(TEST_CASE_MOVE, { detail: { testCaseIds: ids, targetFolderId: targetFolderId } })
);
};
export const onMoveEvent = (listener: (event: CustomEvent<MoveEventDetail>) => void) => {
const handler = (e: Event) => listener(e as CustomEvent);
eventBus.addEventListener(TEST_CASE_MOVE, handler);
return () => eventBus.removeEventListener(TEST_CASE_MOVE, handler);
};