feat: Test case movement (#290)
This commit is contained in:
36
backend/routes/cases/move.js
Normal file
36
backend/routes/cases/move.js
Normal 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;
|
||||
}
|
||||
@@ -82,6 +82,7 @@ app.use('/folders', foldersDeleteRoute(sequelize));
|
||||
|
||||
// "/cases"
|
||||
import casesDownloadRoute from './routes/cases/download.js';
|
||||
import casesMoveRoute from './routes/cases/move.js';
|
||||
import casesIndexRoute from './routes/cases/index.js';
|
||||
import casesIndexByProjectIdRoute from './routes/cases/indexByProjectId.js';
|
||||
import casesShowRoute from './routes/cases/show.js';
|
||||
@@ -89,6 +90,7 @@ import casesNewRoute from './routes/cases/new.js';
|
||||
import casesEditRoute from './routes/cases/edit.js';
|
||||
import casesDeleteRoute from './routes/cases/delete.js';
|
||||
app.use('/cases', casesDownloadRoute(sequelize));
|
||||
app.use('/cases', casesMoveRoute(sequelize));
|
||||
app.use('/cases', casesIndexRoute(sequelize));
|
||||
app.use('/cases', casesIndexByProjectIdRoute(sequelize));
|
||||
app.use('/cases', casesShowRoute(sequelize));
|
||||
|
||||
@@ -210,7 +210,8 @@
|
||||
"select_priorities": "Select priorities",
|
||||
"selected": "Selected",
|
||||
"type": "Type",
|
||||
"select_types": "Select types"
|
||||
"select_types": "Select types",
|
||||
"cases_moved": "Test cases moved"
|
||||
},
|
||||
"Case": {
|
||||
"back_to_cases": "Back to test cases",
|
||||
|
||||
@@ -211,7 +211,8 @@
|
||||
"select_priorities": "優先度を選択",
|
||||
"selected": "選択済み",
|
||||
"type": "タイプ",
|
||||
"select_types": "タイプを選択"
|
||||
"select_types": "タイプを選択",
|
||||
"cases_moved": "テストケースを移動しました"
|
||||
},
|
||||
"Case": {
|
||||
"back_to_cases": "テストケース一覧に戻る",
|
||||
|
||||
@@ -210,7 +210,8 @@
|
||||
"select_priorities": "Selecionar prioridades",
|
||||
"selected": "Selecionado",
|
||||
"type": "Tipo",
|
||||
"select_types": "Selecionar tipos"
|
||||
"select_types": "Selecionar tipos",
|
||||
"moved": "Casos de teste movidos"
|
||||
},
|
||||
"Case": {
|
||||
"back_to_cases": "Voltar para os casos de teste",
|
||||
|
||||
26
frontend/package-lock.json
generated
26
frontend/package-lock.json
generated
@@ -69,6 +69,25 @@
|
||||
"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": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.3.tgz",
|
||||
@@ -10114,9 +10133,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz",
|
||||
"integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ=="
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.13",
|
||||
|
||||
@@ -13,6 +13,7 @@ import DeleteConfirmDialog from '@/components/DeleteConfirmDialog';
|
||||
import { FolderType, FoldersMessages, TreeNodeData } from '@/types/folder';
|
||||
import { logError } from '@/utils/errorHandler';
|
||||
import { buildFolderTree } from '@/utils/buildFolderTree';
|
||||
import { emitMoveEvent } from '@/utils/testCaseMoveEvent';
|
||||
|
||||
type Props = {
|
||||
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 (
|
||||
<>
|
||||
<div className="w-80 min-h-[calc(100vh-64px)] border-r-1 dark:border-neutral-700">
|
||||
@@ -144,6 +160,7 @@ export default function FoldersPane({ projectId, messages, locale }: Props) {
|
||||
disableDrag={true}
|
||||
>
|
||||
{(props) => (
|
||||
<div onDragOver={handleDragOver} onDrop={(e) => handleDrop(e, props.node.id)}>
|
||||
<FolderItem
|
||||
{...props}
|
||||
projectId={projectId}
|
||||
@@ -154,6 +171,7 @@ export default function FoldersPane({ projectId, messages, locale }: Props) {
|
||||
onEditClick={onEditClick}
|
||||
onDeleteClick={onDeleteClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Tree>
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useContext } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { addToast } from '@heroui/react';
|
||||
import TestCaseTable from './TestCaseTable';
|
||||
import CaseDialog from './CaseDialog';
|
||||
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 DeleteConfirmDialog from '@/components/DeleteConfirmDialog';
|
||||
import { PriorityMessages } from '@/types/priority';
|
||||
@@ -12,6 +13,7 @@ import { TestTypeMessages } from '@/types/testType';
|
||||
import { LocaleCodeType } from '@/types/locale';
|
||||
import { logError } from '@/utils/errorHandler';
|
||||
import { parseQueryParam } from '@/utils/parseQueryParam';
|
||||
import { onMoveEvent } from '@/utils/testCaseMoveEvent';
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
@@ -96,6 +98,25 @@ export default function CasesPane({
|
||||
fetchDataEffect();
|
||||
}, [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 onSubmit = async (title: string, description: string) => {
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { useState, useMemo, useCallback, ReactNode } from 'react';
|
||||
import { useState, useMemo, useCallback, ReactNode, useEffect } from 'react';
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableColumn,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
Button,
|
||||
DropdownTrigger,
|
||||
Dropdown,
|
||||
@@ -17,16 +11,31 @@ import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Checkbox,
|
||||
Card,
|
||||
CardBody,
|
||||
} 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 { Link } from '@/src/i18n/routing';
|
||||
import { Link, NextUiLinkClasses } from '@/src/i18n/routing';
|
||||
import { CaseType, CasesMessages } from '@/types/case';
|
||||
import { PriorityMessages } from '@/types/priority';
|
||||
import { TestTypeMessages } from '@/types/testType';
|
||||
import TestCasePriority from '@/components/TestCasePriority';
|
||||
import { LocaleCodeType } from '@/types/locale';
|
||||
import { highlightSearchTerm } from '@/utils/highlightSearchTerm';
|
||||
import { onMoveEvent } from '@/utils/testCaseMoveEvent';
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
@@ -63,6 +72,11 @@ export default function TestCaseTable({
|
||||
testTypeMessages,
|
||||
locale,
|
||||
}: 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 = [
|
||||
{ name: messages.id, uid: 'id', sortable: true },
|
||||
{ name: messages.title, uid: 'title', sortable: true },
|
||||
@@ -70,30 +84,6 @@ export default function TestCaseTable({
|
||||
{ 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(
|
||||
(testCase: CaseType, columnKey: string): ReactNode => {
|
||||
const cellValue = testCase[columnKey as keyof CaseType];
|
||||
@@ -103,19 +93,17 @@ export default function TestCaseTable({
|
||||
return <span>{cellValue as number}</span>;
|
||||
case 'title':
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
as={Link}
|
||||
<Link
|
||||
href={`/projects/${projectId}/folders/${testCase.folderId}/cases/${testCase.id}`}
|
||||
locale={locale}
|
||||
variant="light"
|
||||
className="data-[hover=true]:bg-transparent gap-0"
|
||||
className={NextUiLinkClasses}
|
||||
draggable="false"
|
||||
>
|
||||
{highlightSearchTerm({
|
||||
text: cellValue as string,
|
||||
searchTerm: activeTitleFilter,
|
||||
})}
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
case 'priority':
|
||||
return <TestCasePriority priorityValue={cellValue as number} priorityMessages={priorityMessages} />;
|
||||
@@ -147,6 +135,82 @@ export default function TestCaseTable({
|
||||
[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 = () => {
|
||||
let deleteCaseIds: number[];
|
||||
if (selectedKeys === 'all') {
|
||||
@@ -158,26 +222,41 @@ export default function TestCaseTable({
|
||||
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(
|
||||
() => ({
|
||||
wrapper: ['max-w-3xl'],
|
||||
th: ['bg-transparent', 'text-default-500', 'border-b', 'border-divider'],
|
||||
td: [
|
||||
// changing the rows border radius
|
||||
// first
|
||||
'group-data-[first=true]:first:before:rounded-none',
|
||||
'group-data-[first=true]:last:before:rounded-none',
|
||||
// middle
|
||||
'group-data-[middle=true]:before:rounded-none',
|
||||
// last
|
||||
'group-data-[last=true]:first:before:rounded-none',
|
||||
'group-data-[last=true]:last:before:rounded-none',
|
||||
],
|
||||
}),
|
||||
[]
|
||||
);
|
||||
let selectedIds: number[];
|
||||
if (selectedKeys === 'all' || (selectedKeys instanceof Set && selectedKeys.has(id) && selectedKeys.size > 1)) {
|
||||
// when multiple row selected
|
||||
selectedIds =
|
||||
selectedKeys === 'all' ? sortedItems.map((item) => item.id) : (Array.from(selectedKeys) as number[]);
|
||||
} else {
|
||||
// when no row selected or only one row selected
|
||||
selectedIds = [id];
|
||||
}
|
||||
setDragCount(selectedIds.length);
|
||||
e.dataTransfer.setData('application/json', JSON.stringify(selectedIds));
|
||||
const img = new window.Image();
|
||||
img.src = 'data:image/svg+xml;base64,';
|
||||
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 (
|
||||
<>
|
||||
@@ -265,36 +344,77 @@ export default function TestCaseTable({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
isCompact
|
||||
removeWrapper
|
||||
aria-label="Test cases table"
|
||||
classNames={classNames}
|
||||
selectedKeys={selectedKeys}
|
||||
selectionMode="multiple"
|
||||
sortDescriptor={sortDescriptor}
|
||||
onSelectionChange={setSelectedKeys}
|
||||
onSortChange={setSortDescriptor}
|
||||
>
|
||||
<TableHeader columns={headerColumns}>
|
||||
{(column) => (
|
||||
<TableColumn
|
||||
<div>
|
||||
<table className={heroUITableClasses.table()}>
|
||||
<thead className={heroUITableClasses.thead()}>
|
||||
<tr className={heroUITableClasses.tr()}>
|
||||
<th className={`${heroUITableClasses.th()} ${thClassNames}`}>
|
||||
<Checkbox isSelected={isSelectedAll()} onChange={handleSelectAll} />
|
||||
</th>
|
||||
{headerColumns.map((column) => (
|
||||
<th
|
||||
key={column.uid}
|
||||
align={column.uid === 'actions' ? 'center' : 'start'}
|
||||
allowsSorting={column.sortable}
|
||||
className={`${heroUITableClasses.th()} ${thClassNames}`}
|
||||
onClick={() => column.sortable && handleSort(column.uid)}
|
||||
style={{ cursor: column.sortable ? 'pointer' : 'default' }}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{column.name}
|
||||
</TableColumn>
|
||||
{column.sortable && sortDescriptor.column === column.uid && (
|
||||
<>
|
||||
{sortDescriptor.direction === 'ascending' ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</>
|
||||
)}
|
||||
</TableHeader>
|
||||
<TableBody emptyContent={messages.noCasesFound} items={sortedItems}>
|
||||
{(item) => (
|
||||
<TableRow key={item.id}>
|
||||
{(columnKey) => <TableCell>{renderCell(item, columnKey as string)}</TableCell>}
|
||||
</TableRow>
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={heroUITableClasses.tbody()}>
|
||||
{sortedItems.map((item) => (
|
||||
<tr
|
||||
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>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ export default function Page({ params }: { params: { projectId: string; folderId
|
||||
selected: t('selected'),
|
||||
type: t('type'),
|
||||
selectTypes: t('select_types'),
|
||||
casesMoved: t('cases_moved'),
|
||||
};
|
||||
|
||||
const priorityTranslation = useTranslations('Priority');
|
||||
|
||||
@@ -84,6 +84,7 @@ type CasesMessages = {
|
||||
selected: string;
|
||||
type: string;
|
||||
selectTypes: string;
|
||||
casesMoved: string;
|
||||
};
|
||||
|
||||
type CaseMessages = {
|
||||
|
||||
@@ -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) {
|
||||
const fetchOptions = {
|
||||
method: 'POST',
|
||||
|
||||
19
frontend/utils/testCaseMoveEvent.ts
Normal file
19
frontend/utils/testCaseMoveEvent.ts
Normal 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);
|
||||
};
|
||||
Reference in New Issue
Block a user