Merge pull request #291 from kimatata/develop
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;
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "テストケース一覧に戻る",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
26
frontend/package-lock.json
generated
26
frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,6 +160,7 @@ export default function FoldersPane({ projectId, messages, locale }: Props) {
|
|||||||
disableDrag={true}
|
disableDrag={true}
|
||||||
>
|
>
|
||||||
{(props) => (
|
{(props) => (
|
||||||
|
<div onDragOver={handleDragOver} onDrop={(e) => handleDrop(e, props.node.id)}>
|
||||||
<FolderItem
|
<FolderItem
|
||||||
{...props}
|
{...props}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
@@ -154,6 +171,7 @@ export default function FoldersPane({ projectId, messages, locale }: Props) {
|
|||||||
onEditClick={onEditClick}
|
onEditClick={onEditClick}
|
||||||
onDeleteClick={onDeleteClick}
|
onDeleteClick={onDeleteClick}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Tree>
|
</Tree>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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}
|
|
||||||
>
|
|
||||||
<TableHeader columns={headerColumns}>
|
|
||||||
{(column) => (
|
|
||||||
<TableColumn
|
|
||||||
key={column.uid}
|
key={column.uid}
|
||||||
align={column.uid === 'actions' ? 'center' : 'start'}
|
className={`${heroUITableClasses.th()} ${thClassNames}`}
|
||||||
allowsSorting={column.sortable}
|
onClick={() => column.sortable && handleSort(column.uid)}
|
||||||
|
style={{ cursor: column.sortable ? 'pointer' : 'default' }}
|
||||||
>
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
{column.name}
|
{column.name}
|
||||||
</TableColumn>
|
{column.sortable && sortDescriptor.column === column.uid && (
|
||||||
|
<>
|
||||||
|
{sortDescriptor.direction === 'ascending' ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</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) => (
|
||||||
|
<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'),
|
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');
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ type CasesMessages = {
|
|||||||
selected: string;
|
selected: string;
|
||||||
type: string;
|
type: string;
|
||||||
selectTypes: string;
|
selectTypes: string;
|
||||||
|
casesMoved: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CaseMessages = {
|
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) {
|
async function deleteCases(jwt: string, deleteCaseIds: number[], projectId: number) {
|
||||||
const fetchOptions = {
|
const fetchOptions = {
|
||||||
method: 'POST',
|
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