diff --git a/README.md b/README.md index 7681815..41d1859 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ UnitTCMS currently supports the following languages: - English (en) - Portuguese (pt-BR) - Chinese (zh-CN) +- Traditional Chinese (zh-TW) - Japanese (ja) If you would like to add support for another language, feel free to submit a pull request. For reference, you can see how Portuguese was added in [PR #260](https://github.com/kimatata/unittcms/pull/260). diff --git a/backend/config/locale.js b/backend/config/locale.js index e661d34..a5c5236 100644 --- a/backend/config/locale.js +++ b/backend/config/locale.js @@ -1 +1 @@ -export const SUPPORTED_LOCALES = ['de', 'en', 'pt-BR', 'zh-CN', 'ja']; +export const SUPPORTED_LOCALES = ['de', 'en', 'pt-BR', 'zh-CN', 'zh-TW', 'ja']; diff --git a/backend/routes/cases/import.js b/backend/routes/cases/import.js index 894b859..00a9ba9 100644 --- a/backend/routes/cases/import.js +++ b/backend/routes/cases/import.js @@ -12,18 +12,15 @@ import editableMiddleware from '../../middleware/verifyEditable.js'; import { priorities, testTypes, automationStatus, templates } from '../../config/enums.js'; const fileFilter = (req, file, cb) => { - const allowedFileTypes = ['.xlsx', '.xls']; - const allowedMimeTypes = [ - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'application/vnd.ms-excel', - ]; + // Accept Excel and JSON by extension. JSON mime types vary across browsers + // (application/json, text/plain, application/octet-stream), so rely on the extension. + const allowedFileTypes = ['.xlsx', '.xls', '.json']; const extname = allowedFileTypes.includes(path.extname(file.originalname).toLowerCase()); - const mimetype = allowedMimeTypes.includes(file.mimetype); - if (extname && mimetype) { + if (extname) { return cb(null, true); } else { - cb(new Error('Only Excel files (.xlsx, .xls) are allowed!')); + cb(new Error('Only Excel (.xlsx, .xls) or JSON (.json) files are allowed!')); } }; @@ -66,61 +63,23 @@ export default function (sequelize) { return res.status(400).json({ error: 'folderId is required' }); } + // Parse and validate the file before opening a transaction. + // Both parsers return the same { casesToCreate, stepsToCreate } shape, + // or throw an Error with a user-facing validation message. + let casesToCreate; + let stepsToCreate; + try { + const ext = path.extname(req.file.originalname).toLowerCase(); + const parsed = + ext === '.json' ? _parseJsonBuffer(req.file.buffer, folderId) : _parseExcelBuffer(req.file.buffer, folderId); + casesToCreate = parsed.casesToCreate; + stepsToCreate = parsed.stepsToCreate; + } catch (error) { + return res.status(400).json({ error: error.message }); + } + const t = await sequelize.transaction(); try { - const workbook = XLSX.read(req.file.buffer, { type: 'buffer' }); - const sheetName = workbook.SheetNames[0]; - const worksheet = workbook.Sheets[sheetName]; - const jsonData = XLSX.utils.sheet_to_json(worksheet); - - let currentTitle = null; - let previousTitle = null; - let stepNo = 1; - const casesToCreate = []; - const stepsToCreate = []; - for (const [index, row] of jsonData.entries()) { - const errorMessage = _getRowValidationError(row, index); - if (errorMessage) { - return res.status(400).json({ error: errorMessage }); - } - - // Add step to the same case if the current row title is equal to previous row title - // This handle cases with multiple steps :) - currentTitle = row['title'].trim(); - previousTitle = casesToCreate[casesToCreate.length - 1]?.title.trim(); - if (casesToCreate.length > 0 && previousTitle === currentTitle) { - stepNo += 1; - stepsToCreate.push({ - caseIndex: casesToCreate.length - 1, - stepNo: stepNo, - step: row['step'] || '', - result: row['expectedStepResult'] || '', - }); - } else { - stepNo = 1; - casesToCreate.push({ - folderId: folderId, - title: currentTitle, - description: row['description'] || '', - state: 0, // default state - priority: row['priority'] ? priorities.indexOf(row['priority']) : priorities.indexOf('medium'), - type: row['type'] ? testTypes.indexOf(row['type']) : testTypes.indexOf('other'), - preConditions: row['preConditions'], - expectedResults: row['expectedResults'], - automationStatus: row['automationStatus'] - ? automationStatus.indexOf(row['automationStatus']) - : automationStatus.indexOf('automation-not-required'), - template: row['template'] ? templates.indexOf(row['template']) : templates.indexOf('text'), - }); - stepsToCreate.push({ - caseIndex: casesToCreate.length - 1, - stepNo: stepNo, - step: row['step'] || '', - result: row['expectedStepResult'] || '', - }); - } - } - // 'Manually' create cases, steps and caseStep association. const createdCases = await Case.bulkCreate(casesToCreate, { transaction: t }); for (const stepData of stepsToCreate) { @@ -147,6 +106,9 @@ export default function (sequelize) { } catch (error) { await t.rollback(); console.error(error); + if (!res.headersSent) { + res.status(500).json({ error: 'Internal Server Error' }); + } } } ); @@ -154,6 +116,120 @@ export default function (sequelize) { return router; } +// Parse an Excel (.xlsx, .xls) buffer into { casesToCreate, stepsToCreate }. +// Rows sharing the same title are grouped into a single case with multiple steps. +function _parseExcelBuffer(buffer, folderId) { + const workbook = XLSX.read(buffer, { type: 'buffer' }); + const sheetName = workbook.SheetNames[0]; + const worksheet = workbook.Sheets[sheetName]; + const jsonData = XLSX.utils.sheet_to_json(worksheet); + + let currentTitle = null; + let previousTitle = null; + let stepNo = 1; + const casesToCreate = []; + const stepsToCreate = []; + for (const [index, row] of jsonData.entries()) { + const errorMessage = _getRowValidationError(row, index); + if (errorMessage) { + throw new Error(errorMessage); + } + + // Add step to the same case if the current row title is equal to previous row title + // This handle cases with multiple steps :) + currentTitle = row['title'].trim(); + previousTitle = casesToCreate[casesToCreate.length - 1]?.title.trim(); + if (casesToCreate.length > 0 && previousTitle === currentTitle) { + stepNo += 1; + stepsToCreate.push({ + caseIndex: casesToCreate.length - 1, + stepNo: stepNo, + step: row['step'] || '', + result: row['expectedStepResult'] || '', + }); + } else { + stepNo = 1; + casesToCreate.push({ + folderId: folderId, + title: currentTitle, + description: row['description'] || '', + state: 0, // default state + priority: row['priority'] ? priorities.indexOf(row['priority']) : priorities.indexOf('medium'), + type: row['type'] ? testTypes.indexOf(row['type']) : testTypes.indexOf('other'), + preConditions: row['preConditions'], + expectedResults: row['expectedResults'], + automationStatus: row['automationStatus'] + ? automationStatus.indexOf(row['automationStatus']) + : automationStatus.indexOf('automation-not-required'), + template: row['template'] ? templates.indexOf(row['template']) : templates.indexOf('text'), + }); + stepsToCreate.push({ + caseIndex: casesToCreate.length - 1, + stepNo: stepNo, + step: row['step'] || '', + result: row['expectedStepResult'] || '', + }); + } + } + + return { casesToCreate, stepsToCreate }; +} + +// Parse a JSON buffer into { casesToCreate, stepsToCreate }. +// Accepts the same shape produced by the JSON export (`/cases/download?type=json`): +// either an array of case objects, or an object map keyed by id. +// `id`, `folderId`, `folder` and `state` are ignored; cases are created in the +// target folder with the default `new` state. +function _parseJsonBuffer(buffer, folderId) { + let parsed; + try { + parsed = JSON.parse(buffer.toString('utf-8')); + } catch { + throw new Error('Invalid JSON file'); + } + + const rawCases = Array.isArray(parsed) ? parsed : parsed && typeof parsed === 'object' ? Object.values(parsed) : null; + if (!rawCases || rawCases.length === 0) { + throw new Error('No cases found in JSON file'); + } + + const casesToCreate = []; + const stepsToCreate = []; + for (const [index, c] of rawCases.entries()) { + const errorMessage = _getJsonCaseValidationError(c, index); + if (errorMessage) { + throw new Error(errorMessage); + } + + casesToCreate.push({ + folderId: folderId, + title: String(c.title).trim(), + description: c.description || '', + state: 0, // default state + priority: priorities.indexOf(c.priority), + type: testTypes.indexOf(c.type), + preConditions: c.preConditions || '', + expectedResults: c.expectedResults || '', + automationStatus: c.automationStatus + ? automationStatus.indexOf(c.automationStatus) + : automationStatus.indexOf('automation-not-required'), + template: templates.indexOf(c.template), + }); + + const steps = Array.isArray(c.Steps) ? c.Steps : []; + steps.forEach((s, stepIndex) => { + stepsToCreate.push({ + caseIndex: casesToCreate.length - 1, + stepNo: stepIndex + 1, + step: s?.step || '', + result: s?.expectedStepResult || s?.result || '', + }); + }); + } + + return { casesToCreate, stepsToCreate }; +} + function _getRowValidationError(row, index) { const requiredFields = ['title', 'priority', 'type', 'template']; const rowNumber = index + 2; @@ -198,3 +274,37 @@ function _getRowValidationError(row, index) { return null; } + +function _getJsonCaseValidationError(c, index) { + const caseNumber = index + 1; + + if (!c || typeof c !== 'object') { + return `Case ${caseNumber} is not a valid object`; + } + + const requiredFields = ['title', 'priority', 'type', 'template']; + for (const field of requiredFields) { + if (!c[field]) { + return `Case ${caseNumber} is missing required field: ${field}`; + } + } + + // Values are human-readable labels, matching the JSON export format. + if (priorities.indexOf(c.priority) === -1) { + return `Case ${caseNumber} has invalid priority: ${c.priority}`; + } + + if (testTypes.indexOf(c.type) === -1) { + return `Case ${caseNumber} has invalid type: ${c.type}`; + } + + if (templates.indexOf(c.template) === -1) { + return `Case ${caseNumber} has invalid template: ${c.template}`; + } + + if (c.automationStatus && automationStatus.indexOf(c.automationStatus) === -1) { + return `Case ${caseNumber} has invalid automationStatus: ${c.automationStatus}`; + } + + return null; +} diff --git a/backend/routes/cases/import.test.js b/backend/routes/cases/import.test.js new file mode 100644 index 0000000..2b39f62 --- /dev/null +++ b/backend/routes/cases/import.test.js @@ -0,0 +1,163 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import request from 'supertest'; +import express from 'express'; +import { Sequelize } from 'sequelize'; +import casesImportRoute from './import.js'; + +// mock of authentication / permission middleware +vi.mock('../../middleware/auth.js', () => ({ + default: () => ({ + verifySignedIn: vi.fn((req, res, next) => { + req.userId = 1; + next(); + }), + }), +})); +vi.mock('../../middleware/verifyEditable.js', () => ({ + default: () => ({ + verifyProjectDeveloperFromFolderId: vi.fn((req, res, next) => { + next(); + }), + }), +})); + +const mockCase = { + bulkCreate: vi.fn(), + belongsToMany: vi.fn(), +}; +vi.mock('../../models/cases.js', () => ({ + default: () => mockCase, +})); + +const mockStep = { + create: vi.fn(), + belongsToMany: vi.fn(), +}; +vi.mock('../../models/steps.js', () => ({ + default: () => mockStep, +})); + +const mockCaseStep = { + create: vi.fn(), +}; +vi.mock('../../models/caseSteps.js', () => ({ + default: () => mockCaseStep, +})); + +describe('POST /import with JSON file', () => { + let app; + const sequelize = new Sequelize({ dialect: 'sqlite', logging: false }); + + beforeEach(() => { + app = express(); + app.use(express.json()); + app.use('/', casesImportRoute(sequelize)); + vi.clearAllMocks(); + vi.spyOn(sequelize, 'transaction').mockResolvedValue({ + commit: vi.fn(), + rollback: vi.fn(), + }); + }); + + const attachJson = (data) => + request(app) + .post('/import?folderId=1') + .attach('file', Buffer.from(JSON.stringify(data)), 'cases.json'); + + it('creates cases from a JSON array, mapping labels to enum indexes', async () => { + mockCase.bulkCreate.mockResolvedValue([{ id: 101 }, { id: 102 }]); + mockStep.create.mockResolvedValue({ id: 201 }); + mockCaseStep.create.mockResolvedValue({}); + + const response = await attachJson([ + { + title: 'Text case', + priority: 'high', + type: 'functional', + template: 'text', + description: 'desc', + Steps: [], + }, + { + title: 'Step case', + priority: 'medium', + type: 'acceptance', + template: 'step', + automationStatus: 'cannot-be-automated', + Steps: [ + { step: 'do A', expectedStepResult: 'A done' }, + { step: 'do B', expectedStepResult: 'B done' }, + ], + }, + ]); + + expect(response.status).toBe(200); + + const created = mockCase.bulkCreate.mock.calls[0][0]; + expect(created).toHaveLength(2); + expect(created[0]).toMatchObject({ + folderId: '1', + title: 'Text case', + state: 0, + priority: 1, // high + type: 4, // functional + template: 0, // text + automationStatus: 1, // default automation-not-required + }); + expect(created[1]).toMatchObject({ + title: 'Step case', + priority: 2, // medium + type: 5, // acceptance + template: 1, // step + automationStatus: 2, // cannot-be-automated + }); + + // Only the second case has steps -> 2 steps created + expect(mockStep.create).toHaveBeenCalledTimes(2); + expect(mockCaseStep.create).toHaveBeenCalledTimes(2); + }); + + it('accepts the object-map shape produced by the JSON export', async () => { + mockCase.bulkCreate.mockResolvedValue([{ id: 101 }]); + + const response = await attachJson({ + 1: { title: 'Mapped case', priority: 'low', type: 'other', template: 'text', Steps: [] }, + }); + + expect(response.status).toBe(200); + expect(mockCase.bulkCreate.mock.calls[0][0]).toHaveLength(1); + }); + + it('returns 400 when a required field is missing', async () => { + const response = await attachJson([{ priority: 'high', type: 'functional', template: 'text' }]); + + expect(response.status).toBe(400); + expect(response.body.error).toContain('missing required field: title'); + expect(mockCase.bulkCreate).not.toHaveBeenCalled(); + }); + + it('returns 400 for an invalid priority label', async () => { + const response = await attachJson([ + { title: 'Bad priority', priority: 'urgent', type: 'functional', template: 'text' }, + ]); + + expect(response.status).toBe(400); + expect(response.body.error).toContain('invalid priority'); + }); + + it('returns 400 for malformed JSON', async () => { + const response = await request(app) + .post('/import?folderId=1') + .attach('file', Buffer.from('{ not json'), 'cases.json'); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Invalid JSON file'); + }); + + it('rejects unsupported file extensions', async () => { + const response = await request(app).post('/import?folderId=1').attach('file', Buffer.from('hello'), 'cases.txt'); + + expect(response.status).toBe(400); + expect(response.body.error).toContain('Only Excel'); + }); +}); diff --git a/frontend/config/selection.ts b/frontend/config/selection.ts index 8b11215..1e686e4 100644 --- a/frontend/config/selection.ts +++ b/frontend/config/selection.ts @@ -19,6 +19,7 @@ const locales: LocaleType[] = [ { code: 'en', name: 'English' }, { code: 'pt-BR', name: 'Português' }, { code: 'zh-CN', name: '简体中文' }, + { code: 'zh-TW', name: '繁體中文' }, { code: 'ja', name: '日本語' }, ]; diff --git a/frontend/messages/de.json b/frontend/messages/de.json index d92a8aa..c4fe4a7 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -251,8 +251,9 @@ "select_tags": "Tags auswählen", "import": "Importieren", "import_cases": "Testfälle importieren", - "import_available": "Du kannst Testfälle aus einer Excel-Datei (xlsx, xls) importieren. Die Datei muss dem vorgegebenen Format entsprechen.", + "import_available": "Du kannst Testfälle aus einer Excel-Datei (xlsx, xls) oder einer JSON-Datei (json) importieren. Die Datei muss dem vorgegebenen Format entsprechen.", "download_template": "Vorlage herunterladen", + "download_json_sample": "JSON-Beispiel herunterladen", "click_to_upload": "Zum Hochladen klicken", "or_drag_and_drop": " oder per Drag & Drop", "max_file_size": "Max. Dateigröße", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index c3f244e..b50cd96 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -251,8 +251,9 @@ "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.", + "import_available": "You can import test cases from an Excel file (xlsx, xls) or a JSON file (json). The file to be imported must follow the specified format.", "download_template": "Download Template", + "download_json_sample": "Download JSON Sample", "click_to_upload": "Click to upload", "or_drag_and_drop": " or drag and drop", "max_file_size": "Max. file size", diff --git a/frontend/messages/ja.json b/frontend/messages/ja.json index 26e0f01..117ba17 100644 --- a/frontend/messages/ja.json +++ b/frontend/messages/ja.json @@ -251,8 +251,9 @@ "select_tags": "タグを選択", "import": "インポート", "import_cases": "テストケースのインポート", - "import_available": "Excelファイル(xlsx, xls)からテストケースをインポートできます。インポートするExcelファイルは既定のフォーマットに従う必要があります。", + "import_available": "Excelファイル(xlsx, xls)またはJSONファイル(json)からテストケースをインポートできます。インポートするファイルは既定のフォーマットに従う必要があります。", "download_template": "テンプレートをダウンロード", + "download_json_sample": "JSONサンプルをダウンロード", "click_to_upload": "クリックしてアップロード", "or_drag_and_drop": "またはドラッグアンドドロップ", "max_file_size": "最大ファイルサイズ", diff --git a/frontend/messages/pt-BR.json b/frontend/messages/pt-BR.json index eed385f..1a0c1fe 100644 --- a/frontend/messages/pt-BR.json +++ b/frontend/messages/pt-BR.json @@ -251,8 +251,9 @@ "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.", + "import_available": "Você pode importar casos de teste a partir de um arquivo Excel (xlsx, xls) ou um arquivo JSON (json). O arquivo a ser importado deve seguir o formato especificado.", "download_template": "Baixar Modelo", + "download_json_sample": "Baixar Exemplo JSON", "click_to_upload": "Clique para enviar", "or_drag_and_drop": " ou arraste e solte", "max_file_size": "Tamanho máx. do arquivo", diff --git a/frontend/messages/zh-CN.json b/frontend/messages/zh-CN.json index 7fa89e6..b49e329 100644 --- a/frontend/messages/zh-CN.json +++ b/frontend/messages/zh-CN.json @@ -251,8 +251,9 @@ "select_tags": "选择标签", "import": "导入", "import_cases": "导入测试用例", - "import_available": "您可以从 Excel 文件 (xlsx, xls) 导入测试用例。导入的 Excel 文件必须遵循指定格式。", + "import_available": "您可以从 Excel 文件 (xlsx, xls) 或 JSON 文件 (json) 导入测试用例。导入的文件必须遵循指定格式。", "download_template": "下载模板", + "download_json_sample": "下载 JSON 示例", "click_to_upload": "点击上传", "or_drag_and_drop": " 或拖拽文件到此处", "max_file_size": "最大文件大小", diff --git a/frontend/messages/zh-TW.json b/frontend/messages/zh-TW.json new file mode 100644 index 0000000..7cc841a --- /dev/null +++ b/frontend/messages/zh-TW.json @@ -0,0 +1,432 @@ +{ + "RunStatus": { + "new": "新建", + "inProgress": "進行中", + "underReview": "審核中", + "rejected": "已拒絕", + "done": "完成", + "closed": "已關閉" + }, + "RunCaseStatus": { + "untested": "未測試", + "passed": "通過", + "failed": "失敗", + "retest": "重測", + "skipped": "略過" + }, + "Priority": { + "critical": "嚴重", + "high": "高", + "medium": "中", + "low": "低" + }, + "Type": { + "other": "其他", + "security": "安全", + "performance": "效能", + "accessibility": "無障礙", + "functional": "功能", + "acceptance": "驗收", + "usability": "易用性", + "smoke_sanity": "冒煙與健全性", + "compatibility": "相容性", + "destructive": "破壞性", + "regression": "迴歸", + "automated": "自動化", + "manual": "手動" + }, + "ProjectDialog": { + "project": "專案", + "project_name": "專案名稱", + "project_detail": "專案詳情", + "public": "公開", + "private": "私有", + "if_you_make_public": "將專案設為公開將使其對非專案成員的使用者可見。", + "close": "關閉", + "create": "建立", + "update": "更新", + "please_enter": "請輸入專案名稱" + }, + "Index": { + "get_started": "開始使用", + "demo": "示範", + "oss_tcms": "開源測試案例管理系統", + "integrate_and_manage": "整合並管理您所有的軟體測試。", + "oss_title": "開源", + "oss_detail": "UnitTCMS 是免費且開源的。應用程式支援自我託管,可部署於具有嚴格安全要求的環境中。", + "organize_title": "組織測試案例", + "organize_detail": "可以透過專案與資料夾來組織測試案例。現代化的 UI 框架使其運行迅速。", + "usability_title": "易用性", + "usability_detail": "定義好的測試案例可以在測試執行中反覆使用。測試執行與專案的狀態可以透過圖表直覺地檢視。", + "universal_title": "通用性", + "universal_detail": "多語言支援與深色模式讓任何人都能無障礙地使用此系統。", + "project_title": "以專案為基礎", + "project_subtitle": "以專案為單位管理測試案例與測試執行。我們的儀表板提供了每個專案中測試案例類型及其進度的概覽。這使您能夠即時監控專案狀態並進行高效率的管理。", + "case_management_title": "測試案例管理", + "case_management_subtitle": "在專案中建立資料夾,利用我們現代化且直覺的 UI 輕鬆定義測試案例。支援附件上傳,可對測試案例進行詳細說明,便於在整個團隊中共享資訊。", + "run_management_title": "測試執行管理", + "run_management_subtitle": "定義好的測試案例可以在測試執行中多次重複使用,實現高效率的測試循環。此外,您還可以視覺化地監控測試執行與專案的狀態。", + "member_management_title": "成員管理", + "member_management_subtitle": "透過在專案中新增或移除成員來支援團隊開發。您可以為每個成員指派角色並詳細設定權限。我們提供三種主要角色:「管理者」管理整個專案,「開發者」設計測試,「報告者」執行測試。" + }, + "Header": { + "title": "開源測試案例管理系統", + "description": "整合並管理您所有的軟體測試", + "docs": "文件", + "roadmap": "路線圖", + "projects": "專案", + "admin": "管理後台", + "account": "帳戶", + "profile_settings": "個人設定", + "signup": "註冊", + "signin": "登入", + "signout": "登出", + "links": "連結", + "languages": "語言" + }, + "Toast": { + "need_signed_in": "您需要先登入", + "session_expired": "工作階段已過期" + }, + "Auth": { + "account": "帳戶", + "signup": "註冊", + "signin": "登入", + "or_signup": "還沒有帳號?前往註冊", + "or_signin": "已有帳號?前往登入", + "signin_as_guest": "或以訪客身分登入", + "email": "電子郵件", + "username": "使用者名稱", + "password": "密碼", + "confirm_password": "確認密碼", + "invalid_email": "無效的電子郵件地址", + "invalid_password": "密碼必須至少包含 8 個字元", + "invalid_locale": "無效的語言", + "username_empty": "使用者名稱不能為空", + "password_empty": "密碼不能為空", + "password_not_match": "密碼不一致", + "email_already_exist": "電子郵件已被註冊", + "email_not_exist": "找不到該電子郵件地址", + "signup_error": "註冊失敗", + "signin_error": "登入失敗", + "demo_page_warning": "這是一個示範環境,您輸入的任何資料都將被定期重置。", + "your_projects": "您的專案", + "public": "公開", + "private": "私有", + "not_own_any_projects": "您沒有任何專案。", + "find_projects": "探索專案", + "profile_settings": "個人設定", + "change_username": "變更使用者名稱", + "new_username": "新使用者名稱", + "update_username": "更新使用者名稱", + "username_updated": "使用者名稱更新成功", + "change_password": "變更密碼", + "current_password": "目前密碼", + "new_password": "新密碼", + "confirm_new_password": "確認新密碼", + "update_password": "更新密碼", + "password_updated": "密碼更新成功", + "change_locale": "變更語言", + "update_locale": "更新語言", + "locale_updated": "語言更新成功", + "change_avatar": "變更頭像", + "upload_avatar": "上傳頭像", + "remove_avatar": "移除頭像", + "avatar_updated": "頭像更新成功", + "avatar_removed": "頭像移除成功", + "max_file_size_5mb": "檔案大小上限:5MB", + "only_images_allowed": "僅允許上傳圖片檔案", + "current_password_incorrect": "目前密碼不正確", + "update_error": "更新失敗" + }, + "Health": { + "health_check": "健康檢查", + "status": "狀態", + "ok": "正常", + "error": "錯誤", + "api_server": "API 伺服器", + "unittcms_version": "UnitTCMS 版本" + }, + "Admin": { + "user_management": "使用者管理", + "avatar": "頭像", + "id": "ID", + "email": "電子郵件", + "username": "使用者名稱", + "role": "角色", + "administrator": "管理員", + "user": "使用者", + "no_users_found": "找不到使用者", + "quit_admin": "放棄管理員權限", + "close": "關閉", + "quit": "放棄", + "quit_confirm": "如果您放棄管理員權限,將無法再存取管理頁面。", + "role_changed": "角色已變更", + "lost_admin_auth": "已失去管理員權限", + "at_least": "至少需要保留一名管理員", + "reset_password": "重設密碼", + "reset": "重設", + "invalid_password": "密碼必須至少包含 8 個字元", + "password_not_match": "密碼不一致" + }, + "Projects": { + "project_list": "專案列表", + "new_project": "新增專案", + "id": "ID", + "publicity": "公開性", + "public": "公開", + "private": "私有", + "name": "名稱", + "detail": "詳情", + "last_update": "最後更新", + "no_projects_found": "找不到專案" + }, + "Project": { + "project": "專案", + "toggle_sidebar": "切換側邊欄", + "home": "首頁", + "test_cases": "測試案例", + "test_runs": "測試執行", + "members": "成員", + "settings": "設定" + }, + "Home": { + "home": "首頁", + "Folders": "資料夾", + "test_cases": "測試案例", + "test_runs": "測試執行", + "progress": "進度", + "test_classification": "測試分類", + "by_type": "依測試類型", + "by_priority": "依測試優先級" + }, + "Folders": { + "folder": "資料夾", + "new_folder": "新增資料夾", + "edit_folder": "編輯資料夾", + "delete_folder": "刪除資料夾", + "folder_name": "資料夾名稱", + "folder_detail": "資料夾詳情", + "close": "關閉", + "create": "建立", + "update": "更新", + "please_enter": "請輸入資料夾名稱", + "delete": "刪除", + "are_you_sure": "您確定要刪除此資料夾嗎?", + "no_folders_found": "找不到資料夾" + }, + "Cases": { + "test_case_list": "測試案例列表", + "id": "ID", + "title": "標題", + "priority": "優先級", + "actions": "操作", + "delete_case": "刪除測試案例", + "delete": "刪除", + "close": "關閉", + "are_you_sure": "您確定要刪除測試案例嗎?", + "new_test_case": "新增", + "export": "匯出", + "status": "狀態", + "no_cases_found": "找不到測試案例", + "case_title": "測試案例標題", + "case_description": "測試案例描述", + "case_title_or_description": "測試案例標題或描述", + "create": "建立", + "please_enter": "請輸入測試案例標題", + "filter": "篩選", + "clear_all": "清除全部", + "apply": "套用", + "select_priorities": "選擇優先級", + "selected": "已選擇", + "type": "類型", + "select_types": "選擇類型", + "cases_selected": "個案例已選擇", + "select_action": "選擇操作", + "move": "移動", + "clone": "複製", + "cases_moved": "測試案例已移動", + "cases_cloned": "測試案例已複製", + "tags": "標籤", + "select_tags": "選擇標籤", + "import": "匯入", + "import_cases": "匯入測試案例", + "import_available": "您可以從 Excel 檔案 (xlsx, xls) 或 JSON 檔案 (json) 匯入測試案例。匯入的檔案必須遵循指定格式。", + "download_template": "下載範本", + "download_json_sample": "下載 JSON 範例", + "click_to_upload": "點擊上傳", + "or_drag_and_drop": " 或將檔案拖曳至此處", + "max_file_size": "檔案大小上限", + "cases_imported": "測試案例已匯入", + "create_more": "繼續建立" + }, + "Case": { + "back_to_cases": "返回測試案例列表", + "updating": "更新中...", + "update": "更新", + "updated_test_case": "測試案例已更新", + "basic": "基本資訊", + "title": "標題", + "please_enter_title": "請輸入標題", + "description": "描述", + "test_case_description": "測試案例描述", + "priority": "優先級", + "type": "類型", + "template": "範本", + "test_detail": "測試詳情", + "preconditions": "前置條件", + "expected_result": "預期結果", + "step": "步驟", + "text": "文字", + "steps": "步驟", + "new_step": "新增步驟", + "details_of_the_step": "步驟詳情", + "delete_this_step": "刪除此步驟", + "insert_step": "插入步驟", + "attachments": "附件", + "delete": "刪除", + "download": "下載", + "delete_file": "刪除檔案", + "click_to_upload": "點擊上傳", + "or_drag_and_drop": " 或將檔案拖曳至此處", + "max_file_size": "檔案大小上限", + "are_you_sure_leave": "您確定要離開此頁面嗎?", + "tags": "標籤", + "create_tag": "建立標籤", + "max_tags_limit": "標籤數量上限", + "tag_already_exists": "標籤已存在", + "tag_created_and_added": "標籤已建立並新增", + "error_creating_tag": "建立標籤失敗", + "error_updating_test_case": "更新測試案例失敗", + "search_or_create_tag": "搜尋或建立標籤", + "no_tags_selected": "未選擇標籤" + }, + "Runs": { + "run_list": "測試執行列表", + "run": "執行", + "new_run": "新增執行", + "edit_run": "編輯執行", + "delete_run": "刪除執行", + "id": "ID", + "name": "名稱", + "description": "描述", + "last_update": "最後更新", + "actions": "操作", + "run_name": "執行名稱", + "run_description": "執行描述", + "no_runs_found": "找不到測試執行", + "close": "關閉", + "create": "建立", + "update": "更新", + "please_enter": "請輸入執行名稱", + "are_you_sure": "您確定要刪除此測試執行嗎?", + "delete": "刪除" + }, + "Run": { + "back_to_runs": "返回測試執行列表", + "updating": "更新中...", + "update": "更新", + "updated_test_run": "測試執行已更新", + "export": "匯出", + "progress": "進度", + "refresh": "重新整理", + "id": "ID", + "title": "標題", + "please_enter": "請輸入執行名稱", + "description": "描述", + "priority": "優先級", + "status": "狀態", + "actions": "操作", + "select_test_case": "選擇測試案例", + "test_case_selection": "測試案例選擇", + "include_in_run": "納入執行", + "exclude_from_run": "從執行中排除", + "no_cases_found": "找不到案例", + "are_you_sure_leave": "您確定要離開此頁面嗎?", + "type": "類型", + "test_detail": "測試詳情", + "steps": "步驟", + "preconditions": "前置條件", + "expected_result": "預期結果", + "details_of_the_step": "步驟詳情", + "close": "關閉", + "filter": "篩選", + "clear_all": "清除全部", + "apply": "套用", + "select_status": "選擇狀態", + "please_save": "請儲存變更", + "case_title_or_description": "測試案例標題或描述", + "selected": "已選擇", + "tags": "標籤", + "select_tags": "選擇標籤", + "no_case_selected": "未選擇測試案例", + "case_detail": "測試案例詳情", + "comments": "留言", + "history": "歷史" + }, + "Comments": { + "comments": "留言", + "no_comments": "尚無留言", + "add_comment": "新增留言", + "save": "儲存", + "cancel": "取消", + "placeholder": "輸入留言...", + "not_included_in_run": "無法對未納入測試執行的測試案例進行留言", + "comment_added": "留言已新增", + "failed_to_add_comment": "新增留言失敗", + "comment_updated": "留言已更新", + "failed_to_update_comment": "更新留言失敗", + "comment_deleted": "留言已刪除", + "failed_to_delete_comment": "刪除留言失敗" + }, + "Members": { + "member_management": "成員管理", + "avatar": "頭像", + "email": "電子郵件", + "username": "使用者名稱", + "role": "角色", + "manager": "管理者", + "developer": "開發者", + "reporter": "報告者", + "delete": "刪除", + "deleteMember": "刪除成員", + "no_members_found": "找不到成員", + "add_member": "新增成員", + "user_name_or_email": "使用者名稱或電子郵件", + "close": "關閉", + "add": "新增", + "are_you_sure": "您確定要刪除此成員嗎?", + "member_added": "成員已新增", + "role_changed": "成員角色已變更", + "member_deleted": "成員已刪除" + }, + "Settings": { + "project_management": "專案管理", + "project_name": "專案名稱", + "project_detail": "專案詳情", + "project_owner": "專案擁有者", + "edit_project": "編輯專案", + "publicity": "公開性", + "public": "公開", + "private": "私有", + "delete_project": "刪除專案", + "delete": "刪除", + "close": "關閉", + "are_you_sure": "您確定要刪除此專案嗎?", + "tag_management": "標籤管理", + "tag_name": "標籤名稱", + "add_tag": "新增標籤", + "no_tags_available": "尚無標籤", + "delete_tag": "刪除標籤", + "are_you_sure_delete_tag": "您確定要刪除此標籤嗎?", + "tag_created": "標籤建立成功。", + "tag_updated": "標籤更新成功。", + "tag_deleted": "標籤刪除成功。", + "tag_error_empty": "標籤名稱不能為空。", + "tag_error_min_length": "標籤名稱長度至少為 3 個字元。", + "tag_error_max_length": "標籤名稱不能超過 20 個字元。", + "tag_error_create": "標籤建立失敗,請重試。", + "tag_error_update": "標籤更新失敗,請重試。", + "tag_error_delete": "標籤刪除失敗,請重試。" + } +} diff --git a/frontend/public/template/unittcms-import-sample.json b/frontend/public/template/unittcms-import-sample.json new file mode 100644 index 0000000..2f1cc06 --- /dev/null +++ b/frontend/public/template/unittcms-import-sample.json @@ -0,0 +1,33 @@ +[ + { + "title": "User can sign in with valid credentials", + "priority": "high", + "type": "functional", + "template": "text", + "automationStatus": "automation-not-required", + "description": "Verify that a registered user can sign in.", + "preConditions": "A user account already exists.", + "expectedResults": "The user is redirected to the projects page after signing in.", + "Steps": [] + }, + { + "title": "Create a new project", + "priority": "medium", + "type": "acceptance", + "template": "step", + "automationStatus": "cannot-be-automated", + "description": "Verify that a signed-in user can create a project.", + "preConditions": "The user is signed in.", + "expectedResults": "The new project appears in the project list.", + "Steps": [ + { + "step": "Click the \"New project\" button.", + "expectedStepResult": "The project creation dialog opens." + }, + { + "step": "Enter a project name and submit.", + "expectedStepResult": "The dialog closes and the project is created." + } + ] + } +] 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 index d771130..a929541 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CaseImportDialog.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CaseImportDialog.tsx @@ -73,6 +73,10 @@ export default function CaseImportDialog({ isOpen, folderId, isDisabled, onImpor {messages.downloadTemplate} + | + + {messages.downloadJsonSample} + {importError && } @@ -101,6 +105,7 @@ export default function CaseImportDialog({ isOpen, folderId, isDisabled, onImpor handleInput(e)} 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 1e275d7..f922e15 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 @@ -53,6 +53,7 @@ export default function Page({ params }: { params: { projectId: string; folderId importCases: t('import_cases'), importAvailable: t('import_available'), downloadTemplate: t('download_template'), + downloadJsonSample: t('download_json_sample'), clickToUpload: t('click_to_upload'), orDragAndDrop: t('or_drag_and_drop'), maxFileSize: t('max_file_size'), diff --git a/frontend/src/i18n/routing.ts b/frontend/src/i18n/routing.ts index c09ac0e..cfeb721 100644 --- a/frontend/src/i18n/routing.ts +++ b/frontend/src/i18n/routing.ts @@ -2,7 +2,7 @@ import { createNavigation } from 'next-intl/navigation'; import { defineRouting } from 'next-intl/routing'; export const routing = defineRouting({ - locales: ['de', 'en', 'pt-BR', 'zh-CN', 'ja'], + locales: ['de', 'en', 'pt-BR', 'zh-CN', 'zh-TW', 'ja'], defaultLocale: 'en', localePrefix: { mode: 'always', diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index fa9af8c..a701392 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/middleware.ts @@ -4,5 +4,5 @@ import { routing } from './i18n/routing'; export default createMiddleware(routing); export const config = { - matcher: ['/', '/(de|en|pt-BR|zh-CN|ja)/:path*'], + matcher: ['/', '/(de|en|pt-BR|zh-CN|zh-TW|ja)/:path*'], }; diff --git a/frontend/types/case.ts b/frontend/types/case.ts index 58f7f83..61ab313 100644 --- a/frontend/types/case.ts +++ b/frontend/types/case.ts @@ -102,6 +102,7 @@ type CasesMessages = { importCases: string; importAvailable: string; downloadTemplate: string; + downloadJsonSample: string; clickToUpload: string; orDragAndDrop: string; maxFileSize: string; diff --git a/frontend/types/locale.ts b/frontend/types/locale.ts index e1d2270..eb8af98 100644 --- a/frontend/types/locale.ts +++ b/frontend/types/locale.ts @@ -1,4 +1,4 @@ -export type LocaleCodeType = 'de' | 'en' | 'pt-BR' | 'zh-CN' | 'ja'; +export type LocaleCodeType = 'de' | 'en' | 'pt-BR' | 'zh-CN' | 'zh-TW' | 'ja'; export type LocaleType = { code: LocaleCodeType;