diff --git a/backend/package-lock.json b/backend/package-lock.json index 5523736..e5ae6ac 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/routes/cases/import.js b/backend/routes/cases/import.js index cfd6088..9b07cfd 100644 --- a/backend/routes/cases/import.js +++ b/backend/routes/cases/import.js @@ -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) { diff --git a/frontend/messages/en.json b/frontend/messages/en.json index d94ee65..eee362c 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -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", diff --git a/frontend/messages/ja.json b/frontend/messages/ja.json index 46d64e8..53229ec 100644 --- a/frontend/messages/ja.json +++ b/frontend/messages/ja.json @@ -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": "テストケース一覧に戻る", diff --git a/frontend/messages/pt-BR.json b/frontend/messages/pt-BR.json index 881c5ca..3f3b767 100644 --- a/frontend/messages/pt-BR.json +++ b/frontend/messages/pt-BR.json @@ -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", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d93f099..2b167f8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/public/template/unittcms-import-template-v1.xlsx b/frontend/public/template/unittcms-import-template-v1.xlsx new file mode 100644 index 0000000..9a89fe3 Binary files /dev/null and b/frontend/public/template/unittcms-import-template-v1.xlsx differ diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CaseImportDialog.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CaseImportDialog.tsx new file mode 100644 index 0000000..3a1167e --- /dev/null +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CaseImportDialog.tsx @@ -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(null); + + const handleDrop = (event: DragEvent) => { + 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 ( + { + onCloseDialog(); + }} + > + + {messages.importCases} + +
+
+
{messages.importAvailable}
+ + {messages.downloadTemplate} + +
+
+ {importError && } +
{ + if (isDisabled) { + return; + } + handleDrop(event); + }} + onDragOver={(event) => event.preventDefault()} + > + +
+
+ + {isProcessing ? ( + + ) : ( + <> + + + )} + +
+
+ ); +} 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 fef0bc6..5be48ed 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,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 ( <> 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} /> + setIsImportDialogOpen(false)} + messages={messages} + token={context.token.access_token} + /> + 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({ +