feat: export test run result (#220)
This commit is contained in:
@@ -3,7 +3,7 @@ import { useState, useEffect, useContext } from 'react';
|
||||
import TestCaseTable from './TestCaseTable';
|
||||
import CaseDialog from './CaseDialog';
|
||||
import { TokenContext } from '@/utils/TokenProvider';
|
||||
import { fetchCases, createCase, deleteCases, csvDownload } from '@/utils/caseControl';
|
||||
import { fetchCases, createCase, deleteCases, exportCases } from '@/utils/caseControl';
|
||||
import { CaseType, CasesMessages } from '@/types/case';
|
||||
import DeleteConfirmDialog from '@/components/DeleteConfirmDialog';
|
||||
import { PriorityMessages } from '@/types/priority';
|
||||
@@ -74,8 +74,8 @@ export default function CasesPane({ projectId, folderId, messages, priorityMessa
|
||||
}
|
||||
};
|
||||
|
||||
const onCsvDownload = async () => {
|
||||
await csvDownload(context.token.access_token, Number(folderId));
|
||||
const onExportCases = async (type: string) => {
|
||||
await exportCases(context.token.access_token, Number(folderId), type);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -87,7 +87,7 @@ export default function CasesPane({ projectId, folderId, messages, priorityMessa
|
||||
onCreateCase={() => setIsCaseDialogOpen(true)}
|
||||
onDeleteCase={onDeleteCase}
|
||||
onDeleteCases={onDeleteCases}
|
||||
onCsvDownload={onCsvDownload}
|
||||
onExportCases={onExportCases}
|
||||
messages={messages}
|
||||
priorityMessages={priorityMessages}
|
||||
locale={locale}
|
||||
|
||||
@@ -13,8 +13,9 @@ import {
|
||||
DropdownItem,
|
||||
Selection,
|
||||
SortDescriptor,
|
||||
ButtonGroup,
|
||||
} from '@heroui/react';
|
||||
import { Plus, MoreVertical, Trash, Download } from 'lucide-react';
|
||||
import { Plus, MoreVertical, Trash, Download, FileDown, ChevronDown, FileJson, FileSpreadsheet } from 'lucide-react';
|
||||
import { Link } from '@/src/i18n/routing';
|
||||
import { CaseType, CasesMessages } from '@/types/case';
|
||||
import { PriorityMessages } from '@/types/priority';
|
||||
@@ -28,7 +29,7 @@ type Props = {
|
||||
onCreateCase: () => void;
|
||||
onDeleteCase: (caseId: number) => void;
|
||||
onDeleteCases: (caseIds: number[]) => void;
|
||||
onCsvDownload: () => void;
|
||||
onExportCases: (type: string) => void;
|
||||
messages: CasesMessages;
|
||||
priorityMessages: PriorityMessages;
|
||||
locale: LocaleCodeType;
|
||||
@@ -41,7 +42,7 @@ export default function TestCaseTable({
|
||||
onCreateCase,
|
||||
onDeleteCase,
|
||||
onDeleteCases,
|
||||
onCsvDownload,
|
||||
onExportCases,
|
||||
messages,
|
||||
priorityMessages,
|
||||
locale,
|
||||
@@ -58,6 +59,7 @@ export default function TestCaseTable({
|
||||
column: 'id',
|
||||
direction: 'ascending',
|
||||
});
|
||||
const [exportType, setExportType] = useState(new Set(['json']));
|
||||
|
||||
const sortedItems = useMemo(() => {
|
||||
if (cases.length === 0) {
|
||||
@@ -133,6 +135,10 @@ export default function TestCaseTable({
|
||||
setSelectedKeys(new Set([]));
|
||||
};
|
||||
|
||||
const handleExportTypeChange = (keys: Selection) => {
|
||||
setExportType(new Set(Array.from(keys as Set<string>)));
|
||||
};
|
||||
|
||||
const classNames = useMemo(
|
||||
() => ({
|
||||
wrapper: ['max-w-3xl'],
|
||||
@@ -170,16 +176,38 @@ export default function TestCaseTable({
|
||||
{messages.delete}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
startContent={<Download size={16} />}
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
isDisabled={isDisabled}
|
||||
className="me-2"
|
||||
onPress={onCsvDownload}
|
||||
>
|
||||
{messages.downloadCsv}
|
||||
</Button>
|
||||
<ButtonGroup className="me-2">
|
||||
<Button
|
||||
startContent={<FileDown size={16} />}
|
||||
size="sm"
|
||||
isDisabled={isDisabled}
|
||||
onPress={() => onExportCases(Array.from(exportType)[0])}
|
||||
>
|
||||
{messages.export} {exportType}
|
||||
</Button>
|
||||
<Dropdown placement="bottom-end">
|
||||
<DropdownTrigger>
|
||||
<Button isIconOnly size="sm">
|
||||
<ChevronDown size={16} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
disallowEmptySelection
|
||||
aria-label="Export options"
|
||||
className="max-w-[300px]"
|
||||
selectedKeys={exportType}
|
||||
selectionMode="single"
|
||||
onSelectionChange={handleExportTypeChange}
|
||||
>
|
||||
<DropdownItem key="json" startContent={<FileJson size={16} />}>
|
||||
json
|
||||
</DropdownItem>
|
||||
<DropdownItem key="csv" startContent={<FileSpreadsheet size={16} />}>
|
||||
csv
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</ButtonGroup>
|
||||
<Button
|
||||
startContent={<Plus size={16} />}
|
||||
size="sm"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useContext, ChangeEvent, DragEvent } from 'react';
|
||||
import { Input, Textarea, Select, SelectItem, Button, Divider, Tooltip, addToast } from '@heroui/react';
|
||||
import { Input, Textarea, Select, SelectItem, Button, Divider, Tooltip, addToast, Badge } from '@heroui/react';
|
||||
import { useRouter } from '@/src/i18n/routing';
|
||||
import { Save, Plus, ArrowLeft, Circle } from 'lucide-react';
|
||||
import { priorities, testTypes, templates } from '@/config/selection';
|
||||
@@ -54,7 +54,7 @@ export default function CaseEditor({
|
||||
}: Props) {
|
||||
const tokenContext = useContext(TokenContext);
|
||||
const [testCase, setTestCase] = useState<CaseType>(defaultTestCase);
|
||||
const [isTitleInvalid, setIsTitleInvalid] = useState<boolean>(false);
|
||||
const [isTitleInvalid] = useState<boolean>(false);
|
||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||
const [plusCount, setPlusCount] = useState<number>(0);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
@@ -245,9 +245,12 @@ export default function CaseEditor({
|
||||
<h3 className="font-bold ms-2">{testCase.title}</h3>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{isDirty && <Circle size={8} color="#525252" fill="#525252" className="me-1" />}
|
||||
<Button
|
||||
startContent={<Save size={16} />}
|
||||
startContent={
|
||||
<Badge isInvisible={!isDirty} color="danger" size="sm" content="" shape="circle">
|
||||
<Save size={16} />
|
||||
</Badge>
|
||||
}
|
||||
size="sm"
|
||||
isDisabled={!tokenContext.isProjectDeveloper(Number(projectId))}
|
||||
color="primary"
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function Page({ params }: { params: { projectId: string; folderId
|
||||
close: t('close'),
|
||||
areYouSure: t('are_you_sure'),
|
||||
newTestCase: t('new_test_case'),
|
||||
downloadCsv: t('download_csv'),
|
||||
export: t('export'),
|
||||
status: t('status'),
|
||||
noCasesFound: t('no_cases_found'),
|
||||
caseTitle: t('case_title'),
|
||||
|
||||
@@ -3,9 +3,9 @@ import { useEffect, useState, useContext } from 'react';
|
||||
import { Button } from '@heroui/react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import RunsTable from './RunsTable';
|
||||
import RunDialog from './RunDialog';
|
||||
import { fetchRuns, createRun, updateRun, deleteRun } from './runsControl';
|
||||
import { RunType, RunsMessages } from '@/types/run';
|
||||
import RunDialog from './RunDialog';
|
||||
import DeleteConfirmDialog from '@/components/DeleteConfirmDialog';
|
||||
import { TokenContext } from '@/utils/TokenProvider';
|
||||
import { LocaleCodeType } from '@/types/locale';
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import { useState, useEffect, useContext } from 'react';
|
||||
import { useRouter } from '@/src/i18n/routing';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
@@ -18,14 +16,23 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownItem,
|
||||
addToast,
|
||||
ButtonGroup,
|
||||
Badge,
|
||||
} from '@heroui/react';
|
||||
import { Save, Circle, ArrowLeft, Folder, ChevronDown, CopyPlus, CopyMinus, RotateCw } from 'lucide-react';
|
||||
import RunProgressChart from './RunPregressDonutChart';
|
||||
import TestCaseSelector from './TestCaseSelector';
|
||||
import { testRunStatus } from '@/config/selection';
|
||||
import { RunType, RunStatusCountType, RunMessages } from '@/types/run';
|
||||
import { CaseType } from '@/types/case';
|
||||
import { FolderType } from '@/types/folder';
|
||||
import {
|
||||
Save,
|
||||
ArrowLeft,
|
||||
Folder,
|
||||
ChevronDown,
|
||||
CopyPlus,
|
||||
CopyMinus,
|
||||
RotateCw,
|
||||
FileDown,
|
||||
FileSpreadsheet,
|
||||
FileCode,
|
||||
FileJson,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import {
|
||||
fetchRun,
|
||||
updateRun,
|
||||
@@ -33,10 +40,17 @@ import {
|
||||
fetchProjectCases,
|
||||
includeExcludeTestCases,
|
||||
changeStatus,
|
||||
exportRun,
|
||||
} from '../runsControl';
|
||||
import { fetchFolders } from '../../folders/foldersControl';
|
||||
import RunProgressChart from './RunPregressDonutChart';
|
||||
import TestCaseSelector from './TestCaseSelector';
|
||||
import { useRouter } from '@/src/i18n/routing';
|
||||
import { testRunStatus } from '@/config/selection';
|
||||
import { RunType, RunStatusCountType, RunMessages } from '@/types/run';
|
||||
import { CaseType } from '@/types/case';
|
||||
import { FolderType } from '@/types/folder';
|
||||
import { TokenContext } from '@/utils/TokenProvider';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useFormGuard } from '@/utils/formGuard';
|
||||
import { PriorityMessages } from '@/types/priority';
|
||||
import { RunStatusMessages, TestRunCaseStatusMessages } from '@/types/status';
|
||||
@@ -75,7 +89,7 @@ export default function RunEditor({
|
||||
locale,
|
||||
}: Props) {
|
||||
const tokenContext = useContext(TokenContext);
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { theme } = useTheme();
|
||||
const [testRun, setTestRun] = useState<RunType>(defaultTestRun);
|
||||
const [folders, setFolders] = useState<FolderType[]>([]);
|
||||
const [runStatusCounts, setRunStatusCounts] = useState<RunStatusCountType[]>([]);
|
||||
@@ -83,9 +97,10 @@ export default function RunEditor({
|
||||
const [selectedFolder, setSelectedFolder] = useState<FolderType | null>(null);
|
||||
const [testCases, setTestCases] = useState<CaseType[]>([]);
|
||||
const [filteredTestCases, setFilteredTestCases] = useState<CaseType[]>([]);
|
||||
const [isNameInvalid, setIsNameInvalid] = useState<boolean>(false);
|
||||
const [isNameInvalid] = useState<boolean>(false);
|
||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [exportType, setExportType] = useState(new Set(['xml']));
|
||||
const router = useRouter();
|
||||
useFormGuard(isDirty, messages.areYouSureLeave);
|
||||
|
||||
@@ -184,6 +199,10 @@ export default function RunEditor({
|
||||
const baseClass = '';
|
||||
const selectedClass = `${baseClass} bg-neutral-200 dark:bg-neutral-700`;
|
||||
|
||||
const handleExportTypeChange = (keys: Selection) => {
|
||||
setExportType(new Set(Array.from(keys as Set<string>)));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border-b-1 dark:border-neutral-700 w-full p-3 flex items-center justify-between">
|
||||
@@ -201,9 +220,47 @@ export default function RunEditor({
|
||||
<h3 className="font-bold ms-2">{testRun.name}</h3>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{isDirty && <Circle size={8} color="#525252" fill="#525252" className="me-1" />}
|
||||
<ButtonGroup className="me-2">
|
||||
<Button
|
||||
startContent={<FileDown size={16} />}
|
||||
size="sm"
|
||||
isDisabled={!tokenContext.isProjectReporter(Number(projectId))}
|
||||
onPress={() => exportRun(tokenContext.token.access_token, Number(testRun.id), Array.from(exportType)[0])}
|
||||
>
|
||||
{messages.export} {exportType}
|
||||
</Button>
|
||||
<Dropdown placement="bottom-end">
|
||||
<DropdownTrigger>
|
||||
<Button isIconOnly size="sm">
|
||||
<ChevronDown size={16} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
disallowEmptySelection
|
||||
aria-label="Export options"
|
||||
className="max-w-[300px]"
|
||||
selectedKeys={exportType}
|
||||
selectionMode="single"
|
||||
onSelectionChange={handleExportTypeChange}
|
||||
>
|
||||
<DropdownItem key="xml" startContent={<FileCode size={16} />}>
|
||||
xml
|
||||
</DropdownItem>
|
||||
<DropdownItem key="json" startContent={<FileJson size={16} />}>
|
||||
json
|
||||
</DropdownItem>
|
||||
<DropdownItem key="csv" startContent={<FileSpreadsheet size={16} />}>
|
||||
csv
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</ButtonGroup>
|
||||
<Button
|
||||
startContent={<Save size={16} />}
|
||||
startContent={
|
||||
<Badge isInvisible={!isDirty} color="danger" size="sm" content="" shape="circle">
|
||||
<Save size={16} />
|
||||
</Badge>
|
||||
}
|
||||
size="sm"
|
||||
isDisabled={!tokenContext.isProjectReporter(Number(projectId))}
|
||||
color="primary"
|
||||
|
||||
@@ -12,6 +12,7 @@ export default function Page({ params }: { params: { projectId: string; runId: s
|
||||
updating: t('updating'),
|
||||
update: t('update'),
|
||||
updatedTestRun: t('updated_test_run'),
|
||||
export: t('export'),
|
||||
progress: t('progress'),
|
||||
refresh: t('refresh'),
|
||||
id: t('id'),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { LocaleCodeType } from '@/types/locale';
|
||||
import RunsPage from './RunsPage';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import RunsPage from './RunsPage';
|
||||
import { LocaleCodeType } from '@/types/locale';
|
||||
|
||||
export async function generateMetadata({ params: { locale } }: { params: { locale: LocaleCodeType } }) {
|
||||
const t = await getTranslations({ locale, namespace: 'Runs' });
|
||||
|
||||
@@ -129,6 +129,39 @@ async function deleteRun(jwt: string, runId: number) {
|
||||
}
|
||||
}
|
||||
|
||||
async function exportRun(jwt: string, runId: number, type: string) {
|
||||
if (type !== 'xml' && type !== 'json' && type !== 'csv') {
|
||||
console.error('export type error. type:', type);
|
||||
return;
|
||||
}
|
||||
const url = `${apiServer}/runs/download/${runId}?type=${type}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const objectUrl = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = objectUrl;
|
||||
a.download = `run_${runId}.${type}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.URL.revokeObjectURL(objectUrl);
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching data:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRunCases(jwt: string, runId: number) {
|
||||
const url = `${apiServer}/runcases?runId=${runId}`;
|
||||
|
||||
@@ -310,6 +343,7 @@ export {
|
||||
createRun,
|
||||
updateRun,
|
||||
deleteRun,
|
||||
exportRun,
|
||||
fetchRunCases,
|
||||
changeStatus,
|
||||
includeExcludeTestCases,
|
||||
|
||||
Reference in New Issue
Block a user