From b87ce0c3bed6f544991f16744e19ad81d3fa5de8 Mon Sep 17 00:00:00 2001
From: kimatata <117462761+kimatata@users.noreply.github.com>
Date: Sun, 5 Oct 2025 16:42:06 +0900
Subject: [PATCH] feat: Test case movement (#290)
---
backend/routes/cases/move.js | 36 +++
backend/server.js | 2 +
frontend/messages/en.json | 3 +-
frontend/messages/ja.json | 3 +-
frontend/messages/pt-BR.json | 3 +-
frontend/package-lock.json | 26 +-
.../[projectId]/folders/FoldersPane.tsx | 38 ++-
.../folders/[folderId]/cases/CasesPane.tsx | 23 +-
.../[folderId]/cases/TestCaseTable.tsx | 296 ++++++++++++------
.../folders/[folderId]/cases/page.tsx | 1 +
frontend/types/case.ts | 1 +
frontend/utils/caseControl.ts | 22 ++
frontend/utils/testCaseMoveEvent.ts | 19 ++
13 files changed, 368 insertions(+), 105 deletions(-)
create mode 100644 backend/routes/cases/move.js
create mode 100644 frontend/utils/testCaseMoveEvent.ts
diff --git a/backend/routes/cases/move.js b/backend/routes/cases/move.js
new file mode 100644
index 0000000..13ecfd4
--- /dev/null
+++ b/backend/routes/cases/move.js
@@ -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;
+}
diff --git a/backend/server.js b/backend/server.js
index 66ded8e..5250b32 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -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));
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 9a43321..337d4fe 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -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",
diff --git a/frontend/messages/ja.json b/frontend/messages/ja.json
index 6084d3f..1ff3c2b 100644
--- a/frontend/messages/ja.json
+++ b/frontend/messages/ja.json
@@ -211,7 +211,8 @@
"select_priorities": "優先度を選択",
"selected": "選択済み",
"type": "タイプ",
- "select_types": "タイプを選択"
+ "select_types": "タイプを選択",
+ "cases_moved": "テストケースを移動しました"
},
"Case": {
"back_to_cases": "テストケース一覧に戻る",
diff --git a/frontend/messages/pt-BR.json b/frontend/messages/pt-BR.json
index 99105af..8f4cbab 100644
--- a/frontend/messages/pt-BR.json
+++ b/frontend/messages/pt-BR.json
@@ -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",
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 10af470..d93f099 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -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",
diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/FoldersPane.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/FoldersPane.tsx
index d78409a..f88cd94 100644
--- a/frontend/src/app/[locale]/projects/[projectId]/folders/FoldersPane.tsx
+++ b/frontend/src/app/[locale]/projects/[projectId]/folders/FoldersPane.tsx
@@ -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 (
<>
@@ -144,16 +160,18 @@ export default function FoldersPane({ projectId, messages, locale }: Props) {
disableDrag={true}
>
{(props) => (
-
+
handleDrop(e, props.node.id)}>
+
+
)}
)}
diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CasesPane.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CasesPane.tsx
index b9e99c5..a20bccc 100644
--- a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CasesPane.tsx
+++ b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CasesPane.tsx
@@ -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) => {
diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/TestCaseTable.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/TestCaseTable.tsx
index bf62b65..49ecd56 100644
--- a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/TestCaseTable.tsx
+++ b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/TestCaseTable.tsx
@@ -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
(new Set([]));
- const [sortDescriptor, setSortDescriptor] = useState({
- 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 {cellValue as number};
case 'title':
return (
-
+
);
case 'priority':
return ;
@@ -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(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({
+ 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(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({
-
-
- {(column) => (
-
- {column.name}
-
- )}
-
-
- {(item) => (
-
- {(columnKey) => {renderCell(item, columnKey as string)}}
-
- )}
-
-
+
+
+
+
+ |
+
+ |
+ {headerColumns.map((column) => (
+ column.sortable && handleSort(column.uid)}
+ style={{ cursor: column.sortable ? 'pointer' : 'default' }}
+ >
+
+ {column.name}
+ {column.sortable && sortDescriptor.column === column.uid && (
+ <>
+ {sortDescriptor.direction === 'ascending' ? : }
+ >
+ )}
+
+ |
+ ))}
+
+
+
+ {sortedItems.map((item) => (
+ handleDragStart(e, item.id)}
+ onDrag={handleDrag}
+ onDragEnd={handleDragEnd}
+ style={{ opacity: dragCount ? 0.5 : 1 }}
+ >
+ |
+ handleSelectRow(item.id)} />
+ |
+ {headerColumns.map((column) => (
+
+ {renderCell(item, column.uid)}
+ |
+ ))}
+
+ ))}
+
+
+
+
+ {sortedItems.length === 0 && (
+
+ )}
+
+ {dragCount !== null && (
+
+
+ {dragCount} cases selected
+
+
+ )}
>
);
}
diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/page.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/page.tsx
index e7778bf..7279361 100644
--- a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/page.tsx
+++ b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/page.tsx
@@ -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');
diff --git a/frontend/types/case.ts b/frontend/types/case.ts
index a90e828..00cd5b5 100644
--- a/frontend/types/case.ts
+++ b/frontend/types/case.ts
@@ -84,6 +84,7 @@ type CasesMessages = {
selected: string;
type: string;
selectTypes: string;
+ casesMoved: string;
};
type CaseMessages = {
diff --git a/frontend/utils/caseControl.ts b/frontend/utils/caseControl.ts
index 0b8dfb7..3af3989 100644
--- a/frontend/utils/caseControl.ts
+++ b/frontend/utils/caseControl.ts
@@ -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',
diff --git a/frontend/utils/testCaseMoveEvent.ts b/frontend/utils/testCaseMoveEvent.ts
new file mode 100644
index 0000000..7490fad
--- /dev/null
+++ b/frontend/utils/testCaseMoveEvent.ts
@@ -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) => void) => {
+ const handler = (e: Event) => listener(e as CustomEvent);
+ eventBus.addEventListener(TEST_CASE_MOVE, handler);
+ return () => eventBus.removeEventListener(TEST_CASE_MOVE, handler);
+};