feat: import test cases UI using Excel (#339)

This commit is contained in:
kimatata
2025-11-22 10:32:18 +09:00
committed by GitHub
parent 7db464e28b
commit b986f39c42
13 changed files with 263 additions and 76 deletions

View File

@@ -1010,27 +1010,6 @@
"node": ">= 0.8"
}
},
"node_modules/encoding": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"optional": true,
"dependencies": {
"iconv-lite": "^0.6.2"
}
},
"node_modules/encoding/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"optional": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/end-of-stream": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
@@ -1120,6 +1099,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -4286,26 +4266,6 @@
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="
},
"encoding": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"optional": true,
"requires": {
"iconv-lite": "^0.6.2"
},
"dependencies": {
"iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"optional": true,
"requires": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
}
}
}
},
"end-of-stream": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
@@ -4368,6 +4328,7 @@
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"peer": true,
"requires": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",

View File

@@ -68,7 +68,7 @@ export default function (sequelize) {
let errorMessage = null;
const casesToCreate = [];
const requiredFields = ['title', 'preConditions', 'expectedResults'];
const requiredFields = ['title', 'priority', 'type', 'template'];
for (const [index, row] of jsonData.entries()) {
const rowNumber = index + 2;
for (const field of requiredFields) {

View File

@@ -240,7 +240,15 @@
"cases_moved": "Test cases moved",
"cases_cloned": "Test cases cloned",
"tags": "Tags",
"select_tags": "Select tags"
"select_tags": "Select tags",
"import": "Import",
"import_cases": "Import Test Cases",
"import_available": "You can import test cases from Excel file (xlsx, xls). The Excel file to be imported must follow the specified format.",
"download_template": "Download Template",
"click_to_upload": "Click to upload",
"or_drag_and_drop": " or drag and drop",
"max_file_size": "Max. file size",
"cases_imported": "Test cases imported"
},
"Case": {
"back_to_cases": "Back to test cases",

View File

@@ -240,7 +240,15 @@
"cases_moved": "テストケースを移動しました",
"cases_cloned": "テストケースをクローンしました",
"tags": "タグ",
"select_tags": "タグを選択"
"select_tags": "タグを選択",
"import": "インポート",
"import_cases": "テストケースのインポート",
"import_available": "Excelファイル(xlsx, xls)からテストケースをインポートできます。インポートするExcelファイルは既定のフォーマットに従う必要があります。",
"download_template": "テンプレートをダウンロード",
"click_to_upload": "クリックしてアップロード",
"or_drag_and_drop": "またはドラッグアンドドロップ",
"max_file_size": "最大ファイルサイズ",
"cases_imported": "テストケースがインポートされました"
},
"Case": {
"back_to_cases": "テストケース一覧に戻る",

View File

@@ -240,7 +240,15 @@
"cases_moved": "Casos de teste movidos",
"cases_cloned": "Casos de teste clonados",
"tags": "Tags",
"select_tags": "Selecionar tags"
"select_tags": "Selecionar tags",
"import": "Importar",
"import_cases": "Importar Casos de Teste",
"import_available": "Você pode importar casos de teste a partir de um arquivo Excel (xlsx, xls). O arquivo Excel a ser importado deve seguir o formato especificado.",
"download_template": "Baixar Modelo",
"click_to_upload": "Clique para enviar",
"or_drag_and_drop": " ou arraste e solte",
"max_file_size": "Tamanho máx. do arquivo",
"cases_imported": "Casos de teste importados"
},
"Case": {
"back_to_cases": "Voltar para os casos de teste",

View File

@@ -9988,9 +9988,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001700",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001700.tgz",
"integrity": "sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ==",
"version": "1.0.30001754",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz",
"integrity": "sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==",
"funding": [
{
"type": "opencollective",
@@ -10004,7 +10004,8 @@
"type": "github",
"url": "https://github.com/sponsors/ai"
}
]
],
"license": "CC-BY-4.0"
},
"node_modules/chokidar": {
"version": "3.6.0",

View File

@@ -0,0 +1,125 @@
'use client';
import { useState, ChangeEvent, DragEvent } from 'react';
import { Button, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Spinner, Alert } from '@heroui/react';
import { ArrowUpFromLine } from 'lucide-react';
import { CasesMessages } from '@/types/case';
import { importCases } from '@/utils/caseControl';
type Props = {
isOpen: boolean;
folderId: number;
isDisabled: boolean;
onImport: () => void;
onCancel: () => void;
messages: CasesMessages;
token: string;
};
export default function CaseImportDialog({ isOpen, folderId, isDisabled, onImport, onCancel, messages, token }: Props) {
const [isProcessing, setIsProcessing] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const handleDrop = (event: DragEvent<HTMLElement>) => {
event.preventDefault();
if (event.dataTransfer) {
const filesArray = Array.from(event.dataTransfer.files);
handleFiles(filesArray);
}
};
const handleInput = (event: ChangeEvent) => {
if (event.target) {
const input = event.target as HTMLInputElement;
if (input.files) {
handleFiles(Array.from(input.files));
}
}
};
const handleFiles = async (filesArray: File[]) => {
setIsProcessing(true);
if (filesArray.length !== 1) {
console.error('Error multiple file');
} else {
const ret = await importCases(token, folderId, filesArray[0]);
if (ret.error) {
setImportError(ret.error);
} else {
onImport();
}
}
setIsProcessing(false);
};
const onCloseDialog = () => {
setImportError(null);
onCancel();
};
return (
<Modal
isOpen={isOpen}
onOpenChange={() => {
onCloseDialog();
}}
>
<ModalContent>
<ModalHeader className="flex flex-col gap-1">{messages.importCases}</ModalHeader>
<ModalBody>
<div className="flex items-center justify-center w-full">
<div className={`mt-1 text-neutral-500 dark:text-neutral-400 text-sm rounded`}>
<div>{messages.importAvailable}</div>
<a href="/template/unittcms-import-template-v1.xlsx" download className="text-tiny underline">
{messages.downloadTemplate}
</a>
</div>
</div>
{importError && <Alert color="danger" className="mt-1" title="error" description={importError} />}
<div
className="flex items-center justify-center w-full mt-3"
onDrop={(event) => {
if (isDisabled) {
return;
}
handleDrop(event);
}}
onDragOver={(event) => event.preventDefault()}
>
<label
htmlFor="dropzone-file"
className={`flex flex-col items-center justify-center w-full h-32 border-2 border-neutral-200 border-dashed rounded-lg bg-neutral-50 dark:hover:bg-bray-800 dark:bg-neutral-700 hover:bg-neutral-100 dark:border-neutral-600 dark:hover:border-neutral-500 dark:hover:bg-neutral-600 ${isDisabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
>
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<ArrowUpFromLine />
<p className="mb-2 text-sm text-neutral-500 dark:text-neutral-400">
<span className="font-semibold">{messages.clickToUpload}</span>
<span>{messages.orDragAndDrop}</span>
</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400">{messages.maxFileSize}: 50 MB</p>
</div>
<input
id="dropzone-file"
type="file"
className="hidden"
disabled={isDisabled}
onChange={(e) => handleInput(e)}
/>
</label>
</div>
</ModalBody>
<ModalFooter>
{isProcessing ? (
<Spinner />
) : (
<>
<Button variant="light" size="sm" onPress={onCloseDialog}>
{messages.close}
</Button>
</>
)}
</ModalFooter>
</ModalContent>
</Modal>
);
}

View File

@@ -1,9 +1,11 @@
'use client';
import { useState, useEffect, useContext, useCallback } from 'react';
import { addToast } from '@heroui/react';
import { useRouter, useSearchParams } from 'next/navigation';
import TestCaseTable from './TestCaseTable';
import CaseDialog from './CaseDialog';
import CaseMoveDialog from './CaseMoveDialog';
import CaseImportDialog from './CaseImportDialog';
import DeleteConfirmDialog from '@/components/DeleteConfirmDialog';
import { TokenContext } from '@/utils/TokenProvider';
import { fetchCases, createCase, deleteCases, exportCases } from '@/utils/caseControl';
@@ -76,38 +78,38 @@ export default function CasesPane({
router.push(newUrl, { scroll: false });
};
useEffect(() => {
async function fetchDataEffect() {
if (!context.isSignedIn()) return;
const refreshCases = useCallback(async () => {
if (!context.isSignedIn()) return;
const searchParam = searchParams.get('search') || '';
const priorityParam = parseQueryParam(searchParams.get('priority'));
const typeParam = parseQueryParam(searchParams.get('type'));
const tagParam = parseQueryParam(searchParams.get('tag'));
const searchParam = searchParams.get('search') || '';
const priorityParam = parseQueryParam(searchParams.get('priority'));
const typeParam = parseQueryParam(searchParams.get('type'));
const tagParam = parseQueryParam(searchParams.get('tag'));
setSearchFilter(searchParam);
setPriorityFilter(priorityParam);
setTypeFilter(typeParam);
setTagFilter(tagParam);
setSearchFilter(searchParam);
setPriorityFilter(priorityParam);
setTypeFilter(typeParam);
setTagFilter(tagParam);
try {
const data = await fetchCases(
context.token.access_token,
Number(folderId),
searchParam || undefined,
priorityParam.length > 0 ? priorityParam : undefined,
typeParam.length > 0 ? typeParam : undefined,
tagParam.length > 0 ? tagParam : undefined
);
setCases(data);
} catch (error: unknown) {
logError('Error fetching cases:', error);
}
try {
const data = await fetchCases(
context.token.access_token,
Number(folderId),
searchParam || undefined,
priorityParam.length > 0 ? priorityParam : undefined,
typeParam.length > 0 ? typeParam : undefined,
tagParam.length > 0 ? tagParam : undefined
);
setCases(data);
} catch (error: unknown) {
logError('Error fetching cases:', error);
}
fetchDataEffect();
}, [context, folderId, searchParams]);
useEffect(() => {
refreshCases();
}, [refreshCases]);
const closeDialog = () => setIsCaseDialogOpen(false);
const onSubmit = async (title: string, description: string) => {
@@ -175,6 +177,20 @@ export default function CasesPane({
return unsubscribe;
}, [openMoveDialog]);
// **************************************************************************
// Import cases
// **************************************************************************
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
const handleImport = () => {
refreshCases();
setIsImportDialogOpen(false);
addToast({
title: 'Success',
color: 'success',
description: messages.casesImported,
});
};
return (
<>
<TestCaseTable
@@ -184,6 +200,7 @@ export default function CasesPane({
onCreateCase={() => setIsCaseDialogOpen(true)}
onDeleteCase={onDeleteCase}
onDeleteCases={onDeleteCases}
onShowImportDialog={() => setIsImportDialogOpen(true)}
onExportCases={onExportCases}
onFilterChange={handleFilterChange}
activeSearchFilter={searchFilter}
@@ -210,6 +227,16 @@ export default function CasesPane({
token={context.token.access_token}
/>
<CaseImportDialog
isOpen={isImportDialogOpen}
folderId={Number(folderId)}
isDisabled={!context.isProjectDeveloper(Number(projectId))}
onImport={handleImport}
onCancel={() => setIsImportDialogOpen(false)}
messages={messages}
token={context.token.access_token}
/>
<DeleteConfirmDialog
isOpen={isDeleteConfirmDialogOpen}
onCancel={closeDeleteConfirmDialog}

View File

@@ -20,6 +20,7 @@ import {
Plus,
MoreVertical,
Trash,
FileUp,
FileDown,
ChevronUp,
ChevronDown,
@@ -45,6 +46,7 @@ type Props = {
onCreateCase: () => void;
onDeleteCase: (caseId: number) => void;
onDeleteCases: (caseIds: number[]) => void;
onShowImportDialog: () => void;
onExportCases: (type: string) => void;
onFilterChange: (query: string, priorities: number[], types: number[], tag: number[]) => void;
activeSearchFilter: string;
@@ -64,6 +66,7 @@ export default function TestCaseTable({
onCreateCase,
onDeleteCase,
onDeleteCases,
onShowImportDialog,
onExportCases,
onFilterChange,
activeSearchFilter,
@@ -355,6 +358,15 @@ export default function TestCaseTable({
</DropdownItem>
</DropdownMenu>
</Dropdown>
<Button
startContent={<FileUp size={16} />}
size="sm"
variant="bordered"
className="me-2"
onPress={onShowImportDialog}
>
{messages.import}
</Button>
<Button
startContent={<Plus size={16} />}
size="sm"

View File

@@ -49,6 +49,14 @@ export default function Page({ params }: { params: { projectId: string; folderId
casesCloned: t('cases_cloned'),
tags: t('tags'),
selectTags: t('select_tags'),
import: t('import'),
importCases: t('import_cases'),
importAvailable: t('import_available'),
downloadTemplate: t('download_template'),
clickToUpload: t('click_to_upload'),
orDragAndDrop: t('or_drag_and_drop'),
maxFileSize: t('max_file_size'),
casesImported: t('cases_imported'),
};
const priorityTranslation = useTranslations('Priority');

View File

@@ -97,6 +97,14 @@ type CasesMessages = {
tags: string;
casesCloned: string;
selectTags: string;
import: string;
importCases: string;
importAvailable: string;
downloadTemplate: string;
clickToUpload: string;
orDragAndDrop: string;
maxFileSize: string;
casesImported: string;
};
type CaseMessages = {

View File

@@ -235,4 +235,25 @@ async function exportCases(jwt: string, folderId: number, type: string) {
}
}
export { fetchCase, fetchCases, updateCase, createCase, deleteCases, cloneCases, exportCases };
async function importCases(jwt: string, folderId: number, file: File) {
const url = `${apiServer}/cases/import?folderId=${folderId}`;
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${jwt}`,
},
body: formData,
});
const data = await response.json();
return data;
} catch (error: unknown) {
logError('Error importing data', error);
}
}
export { fetchCase, fetchCases, updateCase, createCase, deleteCases, cloneCases, exportCases, importCases };