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 { fileURLToPath } from 'url';
import express from 'express';
import cors from 'cors';
import RateLimit from 'express-rate-limit';
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();
// enable frontend access
@@ -82,6 +84,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 +92,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));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,16 +160,18 @@ export default function FoldersPane({ projectId, messages, locale }: Props) {
disableDrag={true}
>
{(props) => (
<FolderItem
{...props}
projectId={projectId}
selectedFolder={selectedFolder}
locale={locale}
messages={messages}
openDialogForCreate={openDialogForCreate}
onEditClick={onEditClick}
onDeleteClick={onDeleteClick}
/>
<div onDragOver={handleDragOver} onDrop={(e) => handleDrop(e, props.node.id)}>
<FolderItem
{...props}
projectId={projectId}
selectedFolder={selectedFolder}
locale={locale}
messages={messages}
openDialogForCreate={openDialogForCreate}
onEditClick={onEditClick}
onDeleteClick={onDeleteClick}
/>
</div>
)}
</Tree>
)}

View File

@@ -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) => {

View File

@@ -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
key={column.uid}
align={column.uid === 'actions' ? 'center' : 'start'}
allowsSorting={column.sortable}
>
{column.name}
</TableColumn>
)}
</TableHeader>
<TableBody emptyContent={messages.noCasesFound} items={sortedItems}>
{(item) => (
<TableRow key={item.id}>
{(columnKey) => <TableCell>{renderCell(item, columnKey as string)}</TableCell>}
</TableRow>
)}
</TableBody>
</Table>
<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}
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}
{column.sortable && sortDescriptor.column === column.uid && (
<>
{sortDescriptor.direction === 'ascending' ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</>
)}
</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>
)}
</>
);
}

View File

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

View File

@@ -84,6 +84,7 @@ type CasesMessages = {
selected: string;
type: string;
selectTypes: string;
casesMoved: string;
};
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) {
const fetchOptions = {
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);
};