i18n by next-intl
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Textarea,
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
} from "@nextui-org/react";
|
||||
import { FolderType } from "@/types/folder";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
editingFolder: FolderType;
|
||||
onCancel: () => void;
|
||||
onSubmit: (name: string, detail: string) => void;
|
||||
};
|
||||
|
||||
export default function FolderDialog({
|
||||
isOpen,
|
||||
editingFolder,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const [folderName, setFolderName] = useState({
|
||||
text: editingFolder ? editingFolder.name : "",
|
||||
isValid: false,
|
||||
errorMessage: "",
|
||||
});
|
||||
|
||||
const [folderDetail, setFolderDetail] = useState({
|
||||
text: editingFolder ? editingFolder.detail : "",
|
||||
isValid: false,
|
||||
errorMessage: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editingFolder) {
|
||||
setFolderName({
|
||||
...folderName,
|
||||
text: editingFolder.name,
|
||||
});
|
||||
|
||||
setFolderDetail({
|
||||
...folderDetail,
|
||||
text: editingFolder.detail ? editingFolder.detail : "",
|
||||
});
|
||||
} else {
|
||||
setFolderName({
|
||||
...folderName,
|
||||
text: "",
|
||||
});
|
||||
|
||||
setFolderDetail({
|
||||
...folderDetail,
|
||||
text: "",
|
||||
});
|
||||
}
|
||||
}, [editingFolder]);
|
||||
|
||||
const clear = () => {
|
||||
setFolderName({
|
||||
isValid: false,
|
||||
text: "",
|
||||
errorMessage: "",
|
||||
});
|
||||
setFolderDetail({
|
||||
isValid: false,
|
||||
text: "",
|
||||
errorMessage: "",
|
||||
});
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
if (!folderName.text) {
|
||||
setFolderName({
|
||||
text: "",
|
||||
isValid: false,
|
||||
errorMessage: "Please enter folder name",
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit(folderName.text, folderDetail.text);
|
||||
clear();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={() => {
|
||||
onCancel();
|
||||
}}
|
||||
>
|
||||
<ModalContent>
|
||||
<ModalHeader className="flex flex-col gap-1">Folder</ModalHeader>
|
||||
<ModalBody>
|
||||
<Input
|
||||
type="text"
|
||||
label="Folder Name"
|
||||
value={folderName.text}
|
||||
isInvalid={folderName.isValid}
|
||||
errorMessage={folderName.errorMessage}
|
||||
onChange={(e) => {
|
||||
setFolderName({
|
||||
...folderName,
|
||||
text: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Textarea
|
||||
label="Folder Detail"
|
||||
value={folderDetail.text}
|
||||
isInvalid={folderDetail.isValid}
|
||||
errorMessage={folderDetail.errorMessage}
|
||||
onChange={(e) => {
|
||||
setFolderDetail({
|
||||
...folderDetail,
|
||||
text: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onCancel}>
|
||||
Close
|
||||
</Button>
|
||||
<Button color="primary" onPress={validate}>
|
||||
{editingFolder ? "Update" : "Create"}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
DropdownTrigger,
|
||||
DropdownMenu,
|
||||
DropdownItem,
|
||||
} from "@nextui-org/react";
|
||||
import { MoreVertical } from "lucide-react";
|
||||
|
||||
export default function FolderEditMenu({ folder, onEditClick, onDeleteClick }) {
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<Button isIconOnly size="sm" className="bg-transparent rounded-full">
|
||||
<MoreVertical size={16} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu aria-label="Static Actions">
|
||||
<DropdownItem key="edit" onClick={() => onEditClick(folder)}>
|
||||
Edit folder
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key="delete"
|
||||
className="text-danger"
|
||||
color="danger"
|
||||
onClick={() => onDeleteClick(folder.id)}
|
||||
>
|
||||
Delete folder
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { FolderType } from "@/types/folder";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Listbox, ListboxItem } from "@nextui-org/react";
|
||||
import { Folder, Plus } from "lucide-react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import useGetCurrentIds from "@/utils/useGetCurrentIds";
|
||||
import FolderDialog from "./FolderDialog";
|
||||
import FolderEditMenu from "./FolderEditMenu";
|
||||
|
||||
import {
|
||||
fetchFolders,
|
||||
createFolder,
|
||||
updateFolder,
|
||||
deleteFolder,
|
||||
} from "./foldersControl";
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export default function FoldersPane({ projectId }: Props) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [folders, setFolders] = useState([]);
|
||||
const [selectedFolder, setSelectedFolder] = useState<FolderType>({});
|
||||
|
||||
const { folderId } = useGetCurrentIds();
|
||||
|
||||
const [isFolderDialogOpen, setIsFolderDialogOpen] = useState(false);
|
||||
const [editingFolder, setEditingProject] = useState<FolderType | null>(null);
|
||||
|
||||
const openDialogForCreate = () => {
|
||||
setIsFolderDialogOpen(true);
|
||||
setEditingProject(null);
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setIsFolderDialogOpen(false);
|
||||
setEditingProject(null);
|
||||
};
|
||||
|
||||
const onSubmit = async (name: string, detail: string) => {
|
||||
if (editingFolder) {
|
||||
const updatedProject = await updateFolder(
|
||||
editingFolder.id,
|
||||
name,
|
||||
detail,
|
||||
projectId,
|
||||
null
|
||||
);
|
||||
const updatedProjects = folders.map((project) =>
|
||||
project.id === updatedProject.id ? updatedProject : project
|
||||
);
|
||||
setFolders(updatedProjects);
|
||||
} else {
|
||||
const newProject = await createFolder(name, detail, projectId, null);
|
||||
setFolders([...folders, newProject]);
|
||||
}
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
const onEditClick = (folder: FolderType) => {
|
||||
setEditingProject(folder);
|
||||
setIsFolderDialogOpen(true);
|
||||
};
|
||||
|
||||
const onDeleteClick = async (folderId: number) => {
|
||||
await deleteFolder(folderId);
|
||||
router.push(`/projects/${projectId}/folders`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchDataEffect() {
|
||||
try {
|
||||
const data = await fetchFolders(projectId);
|
||||
setFolders(data);
|
||||
|
||||
const selectedFolderFromUrl = data.find(
|
||||
(folder) => folder.id === folderId
|
||||
);
|
||||
setSelectedFolder(selectedFolderFromUrl);
|
||||
|
||||
// Redirect to the smallest folder ID page if the path is "projects/[projectId]/folders
|
||||
if (pathname === `/projects/${projectId}/folders`) {
|
||||
const smallestFolderId = Math.min(...data.map((folder) => folder.id));
|
||||
router.push(
|
||||
`/projects/${projectId}/folders/${smallestFolderId}/cases`
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error in effect:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
fetchDataEffect();
|
||||
}, [folderId]);
|
||||
|
||||
const baseClass = "";
|
||||
const selectedClass = `${baseClass} bg-neutral-200 dark:bg-neutral-700`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-64 min-h-[calc(100vh-64px)] border-r-1 dark:border-neutral-700">
|
||||
<Button
|
||||
startContent={<Plus size={16} />}
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
className="m-2"
|
||||
onClick={openDialogForCreate}
|
||||
>
|
||||
New Folder
|
||||
</Button>
|
||||
<Listbox aria-label="Listbox Variants" variant="light">
|
||||
{folders.map((folder, index) => (
|
||||
<ListboxItem
|
||||
key={index}
|
||||
onClick={() =>
|
||||
router.push(`/projects/${projectId}/folders/${folder.id}/cases`)
|
||||
}
|
||||
startContent={<Folder size={20} color="#F7C24E" fill="#F7C24E" />}
|
||||
className={
|
||||
selectedFolder && folder.id === selectedFolder.id
|
||||
? selectedClass
|
||||
: baseClass
|
||||
}
|
||||
endContent={
|
||||
<FolderEditMenu
|
||||
folder={folder}
|
||||
onEditClick={onEditClick}
|
||||
onDeleteClick={onDeleteClick}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{folder.name}
|
||||
</ListboxItem>
|
||||
))}
|
||||
</Listbox>
|
||||
</div>
|
||||
|
||||
<FolderDialog
|
||||
isOpen={isFolderDialogOpen}
|
||||
editingFolder={editingFolder}
|
||||
onCancel={closeDialog}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import TestCaseTable from "./TestCaseTable";
|
||||
import { fetchCases, createCase, deleteCase, deleteCases } from "./caseControl";
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
folderId: string;
|
||||
};
|
||||
|
||||
export default function CasesPane({ projectId, folderId }: Props) {
|
||||
const [cases, setCases] = useState([]);
|
||||
useEffect(() => {
|
||||
async function fetchDataEffect() {
|
||||
try {
|
||||
const data = await fetchCases(folderId);
|
||||
setCases(data);
|
||||
} catch (error: any) {
|
||||
console.error("Error in effect:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
fetchDataEffect();
|
||||
}, []);
|
||||
|
||||
const handleCreateCase = async (folderId: string) => {
|
||||
const newCase = await createCase(folderId);
|
||||
const updateCases = [...cases];
|
||||
updateCases.push(newCase);
|
||||
setCases(updateCases);
|
||||
};
|
||||
|
||||
const handleDeleteCase = async (caseId: number) => {
|
||||
await deleteCase(caseId);
|
||||
const data = await fetchCases(folderId);
|
||||
setCases(data);
|
||||
};
|
||||
|
||||
const handleDeleteCases = async (deleteCaseIds: string[]) => {
|
||||
await deleteCases(deleteCaseIds);
|
||||
const data = await fetchCases(folderId);
|
||||
setCases(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TestCaseTable
|
||||
projectId={projectId}
|
||||
cases={cases}
|
||||
onCreateCase={() => handleCreateCase(folderId)}
|
||||
onDeleteCase={handleDeleteCase}
|
||||
onDeleteCases={handleDeleteCases}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableColumn,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
Button,
|
||||
DropdownTrigger,
|
||||
Dropdown,
|
||||
DropdownMenu,
|
||||
DropdownItem,
|
||||
Selection,
|
||||
SortDescriptor,
|
||||
Link,
|
||||
} from "@nextui-org/react";
|
||||
import { Plus, MoreVertical, Trash, Circle } from "lucide-react";
|
||||
|
||||
const headerColumns = [
|
||||
{ name: "ID", uid: "id", sortable: true },
|
||||
{ name: "Title", uid: "title", sortable: true },
|
||||
{ name: "Priority", uid: "priority", sortable: true },
|
||||
{ name: "Actions", uid: "actions" },
|
||||
];
|
||||
|
||||
import { priorities } from "@/config/selection";
|
||||
|
||||
type Case = {
|
||||
id: number;
|
||||
title: string;
|
||||
state: number;
|
||||
priority: number;
|
||||
type: number;
|
||||
automationStatus: number;
|
||||
description: string;
|
||||
template: number;
|
||||
preConditions: string;
|
||||
expectedResults: string;
|
||||
folderId: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
cases: Case[];
|
||||
onCreateCase: () => void;
|
||||
onDeleteCase: (caseId: number) => void;
|
||||
onDeleteCases: (selectedCases: string[]) => void;
|
||||
};
|
||||
|
||||
export default function TestCaseTable({
|
||||
projectId,
|
||||
cases,
|
||||
onCreateCase,
|
||||
onDeleteCase,
|
||||
onDeleteCases,
|
||||
}: Props) {
|
||||
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set([]));
|
||||
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({
|
||||
column: "id",
|
||||
direction: "ascending",
|
||||
});
|
||||
|
||||
const sortedItems = useMemo(() => {
|
||||
return [...cases].sort((a: Case, b: Case) => {
|
||||
const first = a[sortDescriptor.column as keyof Case] as number;
|
||||
const second = b[sortDescriptor.column as keyof Case] as number;
|
||||
const cmp = first < second ? -1 : first > second ? 1 : 0;
|
||||
|
||||
return sortDescriptor.direction === "descending" ? -cmp : cmp;
|
||||
});
|
||||
}, [sortDescriptor, cases]);
|
||||
const renderCell = useCallback((testCase: Case, columnKey: Key) => {
|
||||
const cellValue = testCase[columnKey as keyof Case];
|
||||
|
||||
switch (columnKey) {
|
||||
case "id":
|
||||
return <span>{cellValue}</span>;
|
||||
case "title":
|
||||
return (
|
||||
<Link
|
||||
underline="hover"
|
||||
href={`/projects/${projectId}/folders/${testCase.folderId}/cases/${testCase.id}`}
|
||||
className="dark:text-white"
|
||||
>
|
||||
{cellValue}
|
||||
</Link>
|
||||
);
|
||||
case "priority":
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Circle
|
||||
size={8}
|
||||
color={priorities[cellValue].color}
|
||||
fill={priorities[cellValue].color}
|
||||
/>
|
||||
<div className="ms-3">{priorities[cellValue].name}</div>
|
||||
</div>
|
||||
);
|
||||
case "actions":
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<Button isIconOnly radius="full" size="sm" variant="light">
|
||||
<MoreVertical size={16} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu aria-label="test case actions">
|
||||
<DropdownItem
|
||||
className="text-danger"
|
||||
onClick={() => onDeleteCase(testCase.id)}
|
||||
>
|
||||
Delete test case
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
);
|
||||
default:
|
||||
return cellValue;
|
||||
}
|
||||
}, []);
|
||||
|
||||
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",
|
||||
],
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const onDeleteCasesClick = async () => {
|
||||
if (selectedKeys === "all") {
|
||||
const allKeys = sortedItems.map((item) => item.id);
|
||||
onDeleteCases(allKeys);
|
||||
} else {
|
||||
onDeleteCases([...selectedKeys]);
|
||||
}
|
||||
setSelectedKeys(new Set([]));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border-b-1 dark:border-neutral-700 w-full p-3 flex items-center justify-between">
|
||||
<h3 className="font-bold">Cases</h3>
|
||||
<div>
|
||||
{(selectedKeys.size > 0 || selectedKeys === "all") && (
|
||||
<Button
|
||||
startContent={<Trash size={16} />}
|
||||
size="sm"
|
||||
color="danger"
|
||||
className="me-2"
|
||||
onClick={onDeleteCasesClick}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
startContent={<Plus size={16} />}
|
||||
size="sm"
|
||||
color="primary"
|
||||
onClick={onCreateCase}
|
||||
>
|
||||
New Test Case
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
isCompact
|
||||
removeWrapper
|
||||
aria-label="Tese cases table"
|
||||
classNames={classNames}
|
||||
selectedKeys={selectedKeys}
|
||||
selectionMode="multiple"
|
||||
sortDescriptor={sortDescriptor}
|
||||
onSelectionChange={setSelectedKeys}
|
||||
onSortChange={setSortDescriptor}
|
||||
>
|
||||
<TableHeader columns={headerColumns}>
|
||||
{(column) => (
|
||||
<TableColumn
|
||||
key={column.uid}
|
||||
align={column.uid === "actions" ? "center" : "start"}
|
||||
allowsSorting={column.sortable}
|
||||
>
|
||||
{column.name}
|
||||
</TableColumn>
|
||||
)}
|
||||
</TableHeader>
|
||||
<TableBody emptyContent={"No cases found"} items={sortedItems}>
|
||||
{(item) => (
|
||||
<TableRow key={item.id}>
|
||||
{(columnKey) => (
|
||||
<TableCell>{renderCell(item, columnKey)}</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Image, Button, Tooltip, Card, CardBody } from "@nextui-org/react";
|
||||
import { AttachmentType } from "../../../../../../../../types/case";
|
||||
import { Trash, ArrowDownToLine } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
attachments: AttachmentType[];
|
||||
onAttachmentDownload: (
|
||||
attachmentId: number,
|
||||
downloadFileName: string
|
||||
) => void;
|
||||
onAttachmentDelete: (attachmentId: number) => void;
|
||||
};
|
||||
|
||||
export default function CaseAttachmentsEditor({
|
||||
attachments = [],
|
||||
onAttachmentDownload,
|
||||
onAttachmentDelete,
|
||||
}: Props) {
|
||||
let images = [];
|
||||
let others = [];
|
||||
|
||||
attachments.forEach((attachment) => {
|
||||
let path = attachment.path;
|
||||
let extension = path.substring(path.lastIndexOf(".") + 1).toLowerCase();
|
||||
if (
|
||||
extension === "png" ||
|
||||
extension === "jpg" ||
|
||||
extension === "jpeg" ||
|
||||
extension === "gif" ||
|
||||
extension === "bmp" ||
|
||||
extension === "svg"
|
||||
) {
|
||||
images.push(attachment);
|
||||
} else {
|
||||
others.push(attachment);
|
||||
}
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap mt-3">
|
||||
{images.map((image, index) => (
|
||||
<Card key={index} radius="sm" className="mt-2 me-2 max-w-md">
|
||||
<CardBody>
|
||||
<Image
|
||||
alt={image.title}
|
||||
src={image.path}
|
||||
className="object-cover h-40 w-40"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<p>{image.title}</p>
|
||||
<Tooltip content="Delete">
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
className="bg-transparent rounded-full"
|
||||
onPress={() => onAttachmentDelete(image.id)}
|
||||
>
|
||||
<Trash size={16} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{others.map((file, index) => (
|
||||
<Card key={index} radius="sm" className="mt-2 max-w-md">
|
||||
<CardBody>
|
||||
<div className="flex items-center justify-between">
|
||||
<p>{file.title}</p>
|
||||
<div>
|
||||
<Tooltip content="Download">
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
className="bg-transparent rounded-full"
|
||||
onPress={() => onAttachmentDownload(file.id, file.title)}
|
||||
>
|
||||
<ArrowDownToLine size={16} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Delete">
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
className="bg-transparent rounded-full"
|
||||
onPress={() => onAttachmentDelete(file.id)}
|
||||
>
|
||||
<Trash size={16} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Input,
|
||||
Textarea,
|
||||
Select,
|
||||
SelectItem,
|
||||
Button,
|
||||
Divider,
|
||||
Tooltip,
|
||||
} from "@nextui-org/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Save, Plus, ArrowLeft, ArrowUpFromLine, Circle } from "lucide-react";
|
||||
import { priorities, testTypes, templates } from "@/config/selection";
|
||||
import CaseStepsEditor from "./CaseStepsEditor";
|
||||
import CaseAttachmentsEditor from "./CaseAttachmentsEditor";
|
||||
import { CaseType, AttachmentType } from "@/types/case";
|
||||
import { fetchCase, updateCase } from "../caseControl";
|
||||
import { fetchCreateStep, fetchDeleteStep } from "./stepControl";
|
||||
import {
|
||||
fetchCreateAttachments,
|
||||
fetchDownloadAttachment,
|
||||
fetchDeleteAttachment,
|
||||
} from "./attachmentControl";
|
||||
|
||||
const defaultTestCase = {
|
||||
id: 0,
|
||||
title: "",
|
||||
state: 0,
|
||||
priority: 0,
|
||||
type: 0,
|
||||
automationStatus: 0,
|
||||
description: "",
|
||||
template: 0,
|
||||
preConditions: "",
|
||||
expectedResults: "",
|
||||
folderId: 0,
|
||||
};
|
||||
|
||||
export default function CaseEditor({
|
||||
params,
|
||||
}: {
|
||||
params: { projectId: string; folderId: string; caseId: string };
|
||||
}) {
|
||||
const [testCase, setTestCase] = useState<CaseType>(defaultTestCase);
|
||||
const [isTitleInvalid, setIsTitleInvalid] = useState<boolean>(false);
|
||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||
const router = useRouter();
|
||||
|
||||
const onPlusClick = async (newStepNo: number) => {
|
||||
const newStep = await fetchCreateStep(newStepNo, params.caseId);
|
||||
if (newStep) {
|
||||
newStep.caseSteps = { stepNo: newStepNo };
|
||||
const updatedSteps = testCase.Steps.map((step) => {
|
||||
if (step.caseSteps.stepNo >= newStepNo) {
|
||||
return {
|
||||
...step,
|
||||
caseSteps: {
|
||||
...step.caseSteps,
|
||||
stepNo: step.caseSteps.stepNo + 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
return step;
|
||||
});
|
||||
|
||||
updatedSteps.push(newStep);
|
||||
|
||||
setTestCase({
|
||||
...testCase,
|
||||
Steps: updatedSteps,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteClick = async (stepId: number) => {
|
||||
// find deletedStep's stepNo
|
||||
const deletedStep = testCase.Steps.find((step) => step.id === stepId);
|
||||
if (!deletedStep) {
|
||||
return;
|
||||
}
|
||||
const deletedStepNo = deletedStep.caseSteps.stepNo;
|
||||
|
||||
// delete request
|
||||
await fetchDeleteStep(stepId, params.caseId);
|
||||
|
||||
const updatedSteps = testCase.Steps.map((step) => {
|
||||
if (step.caseSteps.stepNo > deletedStepNo) {
|
||||
return {
|
||||
...step,
|
||||
caseSteps: {
|
||||
...step.caseSteps,
|
||||
stepNo: step.caseSteps.stepNo - 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
return step;
|
||||
}).filter((step) => step.id !== stepId);
|
||||
|
||||
setTestCase({
|
||||
...testCase,
|
||||
Steps: updatedSteps,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDrop = async (event) => {
|
||||
event.preventDefault();
|
||||
handleFetchCreateAttachments(params.caseId, event.dataTransfer.files);
|
||||
};
|
||||
|
||||
const handleInput = (event) => {
|
||||
handleFetchCreateAttachments(params.caseId, event.target.files);
|
||||
};
|
||||
|
||||
const handleFetchCreateAttachments = async (
|
||||
caseId: number,
|
||||
files: File[]
|
||||
) => {
|
||||
const newAttachments = await fetchCreateAttachments(caseId, files);
|
||||
|
||||
if (newAttachments) {
|
||||
const newAttachmentsWithJoinTable = [];
|
||||
newAttachments.forEach((attachment: AttachmentType) => {
|
||||
attachment.caseAttachments = { AttachmentId: attachment.id };
|
||||
newAttachmentsWithJoinTable.push(attachment);
|
||||
});
|
||||
const updatedAttachments = testCase.Attachments;
|
||||
updatedAttachments.push(...newAttachments);
|
||||
|
||||
setTestCase({
|
||||
...testCase,
|
||||
Attachments: updatedAttachments,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onAttachmentDelete = async (attachmentId: number) => {
|
||||
await fetchDeleteAttachment(attachmentId);
|
||||
|
||||
const filteredAttachments = testCase.Attachments.filter(
|
||||
(attachment) => attachment.id !== attachmentId
|
||||
);
|
||||
|
||||
setTestCase({
|
||||
...testCase,
|
||||
Attachments: filteredAttachments,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchDataEffect() {
|
||||
try {
|
||||
const data = await fetchCase(params.caseId);
|
||||
setTestCase(data);
|
||||
} catch (error: any) {
|
||||
console.error("Error in effect:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
fetchDataEffect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border-b-1 dark:border-neutral-700 w-full p-3 flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Tooltip content="Back to cases" placement="left">
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
className="rounded-full bg-neutral-50 dark:bg-neutral-600"
|
||||
onPress={() =>
|
||||
router.push(
|
||||
`/projects/${params.projectId}/folders/${params.folderId}/cases`
|
||||
)
|
||||
}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<h3 className="font-bold ms-2">{testCase.title}</h3>
|
||||
</div>
|
||||
<Button
|
||||
startContent={<Save size={16} />}
|
||||
size="sm"
|
||||
color="primary"
|
||||
isLoading={isUpdating}
|
||||
onPress={async () => {
|
||||
setIsUpdating(true);
|
||||
await updateCase(testCase);
|
||||
setIsUpdating(false);
|
||||
}}
|
||||
>
|
||||
{isUpdating ? "Updating..." : "Update"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
<h6 className="font-bold">Basic</h6>
|
||||
<Input
|
||||
size="sm"
|
||||
type="text"
|
||||
variant="bordered"
|
||||
label="Title"
|
||||
value={testCase.title}
|
||||
isInvalid={isTitleInvalid}
|
||||
errorMessage={isTitleInvalid ? "please enter title" : ""}
|
||||
onChange={(e) => {
|
||||
setTestCase({ ...testCase, title: e.target.value });
|
||||
}}
|
||||
className="mt-3"
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
label="Description"
|
||||
placeholder="Test case description"
|
||||
value={testCase.description}
|
||||
onValueChange={(changeValue) => {
|
||||
setTestCase({ ...testCase, description: changeValue });
|
||||
}}
|
||||
className="mt-3"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Select
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
selectedKeys={[priorities[testCase.priority].uid]}
|
||||
onSelectionChange={(e) => {
|
||||
const selectedUid = e.anchorKey;
|
||||
const index = priorities.findIndex(
|
||||
(priority) => priority.uid === selectedUid
|
||||
);
|
||||
setTestCase({ ...testCase, priority: index });
|
||||
}}
|
||||
startContent={
|
||||
<Circle
|
||||
size={8}
|
||||
color={priorities[testCase.priority].color}
|
||||
fill={priorities[testCase.priority].color}
|
||||
/>
|
||||
}
|
||||
label="Priority"
|
||||
className="mt-3 max-w-xs"
|
||||
>
|
||||
{priorities.map((priority, index) => (
|
||||
<SelectItem key={priority.uid} value={index}>
|
||||
{priority.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Select
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
selectedKeys={[testTypes[testCase.type].uid]}
|
||||
onSelectionChange={(e) => {
|
||||
const selectedUid = e.anchorKey;
|
||||
const index = testTypes.findIndex(
|
||||
(type) => type.uid === selectedUid
|
||||
);
|
||||
setTestCase({ ...testCase, type: index });
|
||||
}}
|
||||
label="type"
|
||||
className="mt-3 max-w-xs"
|
||||
>
|
||||
{testTypes.map((type, index) => (
|
||||
<SelectItem key={type.uid} value={index}>
|
||||
{type.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Select
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
selectedKeys={[templates[testCase.template].uid]}
|
||||
onSelectionChange={(e) => {
|
||||
const selectedUid = e.anchorKey;
|
||||
const index = templates.findIndex(
|
||||
(template) => template.uid === selectedUid
|
||||
);
|
||||
setTestCase({ ...testCase, template: index });
|
||||
}}
|
||||
label="template"
|
||||
className="mt-3 max-w-xs"
|
||||
>
|
||||
{templates.map((template, index) => (
|
||||
<SelectItem key={template.uid} value={index}>
|
||||
{template.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Divider className="my-6" />
|
||||
{templates[testCase.template].name === "Text" ? (
|
||||
<div>
|
||||
<h6 className="font-bold">Test Detail</h6>
|
||||
<div className="flex">
|
||||
<Textarea
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
label="PreConditions"
|
||||
placeholder="PreConditions"
|
||||
value={testCase.preConditions}
|
||||
onValueChange={(changeValue) => {
|
||||
setTestCase({ ...testCase, preConditions: changeValue });
|
||||
}}
|
||||
className="mt-3 pe-1"
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
label="ExpectedResults"
|
||||
placeholder="ExpectedResults"
|
||||
value={testCase.expectedResults}
|
||||
onValueChange={(changeValue) => {
|
||||
setTestCase({ ...testCase, expectedResults: changeValue });
|
||||
}}
|
||||
className="mt-3 ps-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<h6 className="font-bold">Steps</h6>
|
||||
<Button
|
||||
startContent={<Plus size={16} />}
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="ms-3"
|
||||
onPress={() => onPlusClick(1)}
|
||||
>
|
||||
New Step
|
||||
</Button>
|
||||
</div>
|
||||
<CaseStepsEditor
|
||||
steps={testCase.Steps}
|
||||
onStepUpdate={(stepId, changeStep) => {
|
||||
setTestCase({
|
||||
...testCase,
|
||||
Steps: testCase.Steps.map((step) => {
|
||||
if (step.id === stepId) {
|
||||
return changeStep;
|
||||
} else {
|
||||
return step;
|
||||
}
|
||||
}),
|
||||
});
|
||||
}}
|
||||
onStepPlus={onPlusClick}
|
||||
onStepDelete={onDeleteClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider className="my-6" />
|
||||
<h6 className="font-bold">Attachments</h6>
|
||||
<CaseAttachmentsEditor
|
||||
attachments={testCase.Attachments}
|
||||
onAttachmentDownload={(
|
||||
attachmentId: number,
|
||||
downloadFileName: string
|
||||
) => fetchDownloadAttachment(attachmentId, downloadFileName)}
|
||||
onAttachmentDelete={onAttachmentDelete}
|
||||
/>
|
||||
<div
|
||||
className="flex items-center justify-center w-96 mt-3"
|
||||
onDrop={handleDrop}
|
||||
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 cursor-pointer 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"
|
||||
>
|
||||
<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">Click to upload</span> or drag
|
||||
and drop
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Max. file size: 50 MB
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
id="dropzone-file"
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleInput}
|
||||
multiple
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Textarea, Button, Tooltip } from "@nextui-org/react";
|
||||
import { StepType } from "../../../../../../../../types/case";
|
||||
import { Plus, Trash } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
steps: StepType[];
|
||||
onStepUpdate: (stepId: number, step: StepType) => void;
|
||||
onStepPlus: (newStepNo: number) => void;
|
||||
onStepDelete: (stepId: number) => void;
|
||||
};
|
||||
|
||||
export default function StepsEditor({
|
||||
steps,
|
||||
onStepUpdate,
|
||||
onStepPlus,
|
||||
onStepDelete,
|
||||
}: Props) {
|
||||
// sort steps by junction table's column
|
||||
const sortedSteps = steps.slice().sort((a, b) => {
|
||||
const stepNoA = a.caseSteps.stepNo;
|
||||
const stepNoB = b.caseSteps.stepNo;
|
||||
return stepNoA - stepNoB;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{sortedSteps.map((step, index) => (
|
||||
<div key={index} className="flex">
|
||||
<div className="bg-neutral-50 dark:bg-neutral-600 rounded-full flex items-center justify-center min-w-unit-8 w-unit-8 h-unit-8 mt-3 me-2">
|
||||
<div>{step.caseSteps.stepNo}</div>
|
||||
</div>
|
||||
<Textarea
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
label="Details of the step"
|
||||
placeholder="Details of the step"
|
||||
value={step.step}
|
||||
onValueChange={(changeValue) => {
|
||||
onStepUpdate(step.id, { ...step, step: changeValue });
|
||||
}}
|
||||
className="mt-3 me-1"
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
label="Expected Result"
|
||||
placeholder="Expected Result"
|
||||
value={step.result}
|
||||
onValueChange={(changeValue) => {
|
||||
onStepUpdate(step.id, { ...step, result: changeValue });
|
||||
}}
|
||||
className="mt-3 ms-1"
|
||||
/>
|
||||
<div className="mt-3 ms-1">
|
||||
<Tooltip content="Delete this step" placement="left">
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
className="bg-transparent rounded-full"
|
||||
onPress={() => onStepDelete(step.id)}
|
||||
>
|
||||
<Trash size={16} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Insert step" placement="left">
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
className="bg-transparent rounded-full"
|
||||
onPress={() => onStepPlus(step.caseSteps.stepNo + 1)}
|
||||
>
|
||||
<Plus size={16} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import Config from "@/config/config";
|
||||
const apiServer = Config.apiServer;
|
||||
|
||||
async function fetchDownloadAttachment(
|
||||
attachmentId: number,
|
||||
downloadFileName: string
|
||||
) {
|
||||
const fetchOptions = {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
};
|
||||
|
||||
const url = `${apiServer}/attachments/download/${attachmentId}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, fetchOptions);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const downloadUrl = window.URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = downloadUrl;
|
||||
link.download = downloadFileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} catch (error: any) {
|
||||
console.error("Error downloading file:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCreateAttachments(caseId: number, files: File[]) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
formData.append("files", files[i]);
|
||||
}
|
||||
|
||||
const url = `${apiServer}/attachments?parentCaseId=${caseId}`;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
return responseData;
|
||||
} catch (error: any) {
|
||||
console.error("Error uploading files:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDeleteAttachment(attachmentId: number) {
|
||||
const fetchOptions = {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
};
|
||||
|
||||
const url = `${apiServer}/attachments/${attachmentId}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, fetchOptions);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error deleting file:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
fetchDownloadAttachment,
|
||||
fetchCreateAttachments,
|
||||
fetchDeleteAttachment,
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import CaseEditor from "./CaseEditor";
|
||||
|
||||
export default function Page({
|
||||
params,
|
||||
}: {
|
||||
params: { projectId: string; folderId: string; caseId: string };
|
||||
}) {
|
||||
return (
|
||||
<CaseEditor
|
||||
params={{
|
||||
projectId: params.projectId,
|
||||
folderId: params.folderId,
|
||||
caseId: params.caseId,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Config from "@/config/config";
|
||||
const apiServer = Config.apiServer;
|
||||
|
||||
async function fetchCreateStep(newStepNo: number, parentCaseId: number) {
|
||||
const fetchOptions = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
};
|
||||
|
||||
const url = `${apiServer}/steps?newStepNo=${newStepNo}&parentCaseId=${parentCaseId}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, fetchOptions);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error: any) {
|
||||
console.error("Error deleting project:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDeleteStep(stepId: number, parentCaseId: number) {
|
||||
const fetchOptions = {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
};
|
||||
|
||||
const url = `${apiServer}/steps/${stepId}?parentCaseId=${parentCaseId}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, fetchOptions);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error deleting project:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
fetchCreateStep,
|
||||
fetchDeleteStep,
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
import Config from "@/config/config";
|
||||
const apiServer = Config.apiServer;
|
||||
import { CaseType } from "@/types/case";
|
||||
|
||||
async function fetchCase(caseId: number) {
|
||||
const url = `${apiServer}/cases/${caseId}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching data:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCases(folderId: string) {
|
||||
const url = `${apiServer}/cases?folderId=${folderId}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching data:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function createCase(folderId: string) {
|
||||
const newCase = {
|
||||
title: "untitled case",
|
||||
state: 0,
|
||||
priority: 2,
|
||||
type: 0,
|
||||
automationStatus: 0,
|
||||
description: "",
|
||||
template: 0,
|
||||
preConditions: "",
|
||||
expectedResults: "",
|
||||
folderId: folderId,
|
||||
};
|
||||
|
||||
const fetchOptions = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(newCase),
|
||||
};
|
||||
|
||||
const url = `${apiServer}/cases`;
|
||||
|
||||
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: any) {
|
||||
console.error("Error creating case:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateCase(updateCaseData: CaseType) {
|
||||
const fetchOptions = {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(updateCaseData),
|
||||
};
|
||||
|
||||
const url = `${apiServer}/cases/${updateCaseData.id}`;
|
||||
|
||||
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: any) {
|
||||
console.error("Error updating project:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCase(caseId: number) {
|
||||
const fetchOptions = {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
};
|
||||
|
||||
const url = `${apiServer}/cases/${caseId}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, fetchOptions);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error deleting case:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCases(deleteCases: string[]) {
|
||||
const fetchOptions = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ caseIds: deleteCases }),
|
||||
};
|
||||
|
||||
const url = `${apiServer}/cases/bulkdelete`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, fetchOptions);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error deleting cases:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
fetchCase,
|
||||
fetchCases,
|
||||
updateCase,
|
||||
createCase,
|
||||
deleteCase,
|
||||
deleteCases,
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import CasesPane from "./CasesPane";
|
||||
|
||||
export default function Page({
|
||||
params,
|
||||
}: {
|
||||
params: { projectId: string; folderId: string };
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<CasesPane projectId={params.projectId} folderId={params.folderId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import Config from "@/config/config";
|
||||
const apiServer = Config.apiServer;
|
||||
|
||||
/**
|
||||
* fetch folder records
|
||||
*/
|
||||
async function fetchFolders(projectId: string) {
|
||||
try {
|
||||
const url = `${apiServer}/folders?projectId=${projectId}`;
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching data:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create project
|
||||
*/
|
||||
async function createFolder(
|
||||
name: string,
|
||||
detail: string,
|
||||
projectId: strting,
|
||||
parentFolderId: number
|
||||
) {
|
||||
const newFolderData = {
|
||||
name: name,
|
||||
detail: detail,
|
||||
projectId: projectId,
|
||||
parentFolderId: parentFolderId,
|
||||
};
|
||||
|
||||
const fetchOptions = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(newFolderData),
|
||||
};
|
||||
|
||||
const url = `${apiServer}/folders`;
|
||||
|
||||
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: any) {
|
||||
console.error("Error creating new project:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update folder
|
||||
*/
|
||||
async function updateFolder(
|
||||
folderId: number,
|
||||
name: string,
|
||||
detail: string,
|
||||
projectId: string,
|
||||
parentFolderId: number
|
||||
) {
|
||||
const updateFolderData = {
|
||||
name: name,
|
||||
detail: detail,
|
||||
projectId: projectId,
|
||||
parentFolderId: parentFolderId,
|
||||
};
|
||||
|
||||
const fetchOptions = {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(updateFolderData),
|
||||
};
|
||||
|
||||
const url = `${apiServer}/folders/${folderId}`;
|
||||
|
||||
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: any) {
|
||||
console.error("Error updating project:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete folder
|
||||
*/
|
||||
async function deleteFolder(folderId: number) {
|
||||
const fetchOptions = {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
};
|
||||
|
||||
const url = `${apiServer}/folders/${folderId}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, fetchOptions);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error deleting project:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export { fetchFolders, createFolder, updateFolder, deleteFolder };
|
||||
@@ -0,0 +1,16 @@
|
||||
import FoldersPane from "./FoldersPane";
|
||||
|
||||
export default function FoldersLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: { projectId: string };
|
||||
}) {
|
||||
return (
|
||||
<div className="flex w-full">
|
||||
<FoldersPane projectId={params.projectId} />
|
||||
<div className="flex-grow w-full">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
This is folders page.
|
||||
</>
|
||||
);
|
||||
}
|
||||
59
frontend/src/app/[locale]/projects/[projectId]/home/home.tsx
Normal file
59
frontend/src/app/[locale]/projects/[projectId]/home/home.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
import Config from "@/config/config";
|
||||
const apiServer = Config.apiServer;
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
async function fetchProject(url) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching data:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export function Home({ projectId }: Props) {
|
||||
const [project, setProject] = useState({
|
||||
Folders: [],
|
||||
});
|
||||
const url = `${apiServer}/projects/${projectId}`;
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchDataEffect() {
|
||||
try {
|
||||
const data = await fetchProject(url);
|
||||
setProject(data);
|
||||
console.log(data);
|
||||
} catch (error: any) {
|
||||
console.error("Error in effect:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
fetchDataEffect();
|
||||
}, [url]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className="font-bold ms-2">Home</h3>
|
||||
|
||||
<h4 className="font-bold ms-2 mt-5">
|
||||
Folder num: {project.Folders.length}
|
||||
</h4>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Home } from "./home";
|
||||
|
||||
export default function Page({ params }: { params: { projectId: string } }) {
|
||||
return (
|
||||
<>
|
||||
<Home projectId={params.projectId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
18
frontend/src/app/[locale]/projects/[projectId]/layout.tsx
Normal file
18
frontend/src/app/[locale]/projects/[projectId]/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import Sidebar from "./sidebar";
|
||||
|
||||
export default function SidebarLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex border-t-1 dark:border-neutral-700 min-h-[calc(100vh-64px)]">
|
||||
<Sidebar />
|
||||
<div className="flex w-full">
|
||||
<div className="flex-grow">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@nextui-org/react";
|
||||
import { Plus } from "lucide-react";
|
||||
import RunsTable from "./RunsTable";
|
||||
import { fetchRuns, createRun, deleteRun } from "./runsControl";
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export default function RunsPage({ projectId }: Props) {
|
||||
const [runs, setRuns] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchDataEffect() {
|
||||
try {
|
||||
const data = await fetchRuns(projectId);
|
||||
setRuns(data);
|
||||
} catch (error: any) {
|
||||
console.error("Error in effect:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
fetchDataEffect();
|
||||
}, []);
|
||||
|
||||
const onCreateClick = async () => {
|
||||
try {
|
||||
const newRun = await createRun(projectId);
|
||||
const updateRuns = [...runs];
|
||||
updateRuns.push(newRun);
|
||||
setRuns(updateRuns);
|
||||
} catch (error: any) {
|
||||
console.error("Error deleting run:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteClick = async (runId: number) => {
|
||||
try {
|
||||
await deleteRun(runId);
|
||||
const data = await fetchRuns(projectId);
|
||||
setRuns(data);
|
||||
} catch (error: any) {
|
||||
console.error("Error deleting run:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-3xl pt-6 px-6 flex-grow">
|
||||
<div className="w-full p-3 flex items-center justify-between">
|
||||
<h3 className="font-bold">Runs</h3>
|
||||
<div>
|
||||
<Button
|
||||
startContent={<Plus size={16} />}
|
||||
size="sm"
|
||||
color="primary"
|
||||
onClick={onCreateClick}
|
||||
>
|
||||
New Run
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RunsTable
|
||||
projectId={projectId}
|
||||
runs={runs}
|
||||
onDeleteRun={onDeleteClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableColumn,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
Button,
|
||||
DropdownTrigger,
|
||||
Dropdown,
|
||||
DropdownMenu,
|
||||
DropdownItem,
|
||||
SortDescriptor,
|
||||
Link,
|
||||
} from "@nextui-org/react";
|
||||
import { MoreVertical } from "lucide-react";
|
||||
import { RunType } from "@/types/run";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
const headerColumns = [
|
||||
{ name: "ID", uid: "id", sortable: true },
|
||||
{ name: "Name", uid: "name", sortable: true },
|
||||
{ name: "Description", uid: "description", sortable: true },
|
||||
{ name: "Last update", uid: "updatedAt", sortable: true },
|
||||
{ name: "Actions", uid: "actions" },
|
||||
];
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
runs: RunType[];
|
||||
onDeleteRun: (runId: number) => void;
|
||||
};
|
||||
|
||||
export default function RunsTable({ projectId, runs, onDeleteRun }: Props) {
|
||||
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({
|
||||
column: "id",
|
||||
direction: "ascending",
|
||||
});
|
||||
|
||||
const sortedItems = useMemo(() => {
|
||||
return [...runs].sort((a: RunType, b: RunType) => {
|
||||
const first = a[sortDescriptor.column as keyof RunType] as number;
|
||||
const second = b[sortDescriptor.column as keyof RunType] as number;
|
||||
const cmp = first < second ? -1 : first > second ? 1 : 0;
|
||||
|
||||
return sortDescriptor.direction === "descending" ? -cmp : cmp;
|
||||
});
|
||||
}, [sortDescriptor, runs]);
|
||||
|
||||
const truncateText = (text: string, maxLength: number) => {
|
||||
return text.length > maxLength ? text.slice(0, maxLength) + "..." : text;
|
||||
};
|
||||
|
||||
const renderCell = useCallback((run: RunType, columnKey: Key) => {
|
||||
const cellValue = run[columnKey as keyof RunType];
|
||||
|
||||
switch (columnKey) {
|
||||
case "id":
|
||||
return <span>{cellValue}</span>;
|
||||
case "name":
|
||||
return (
|
||||
<Link
|
||||
underline="hover"
|
||||
href={`/projects/${projectId}/runs/${run.id}`}
|
||||
className="text-blue-500"
|
||||
>
|
||||
{cellValue}
|
||||
</Link>
|
||||
);
|
||||
case "detail":
|
||||
const maxLength = 20;
|
||||
const truncatedValue = truncateText(cellValue, maxLength);
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>{truncatedValue}</div>
|
||||
</div>
|
||||
);
|
||||
case "updatedAt":
|
||||
return <span>{dayjs(cellValue).format("YYYY/MM/DD HH:mm")}</span>;
|
||||
case "actions":
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<Button isIconOnly radius="full" size="sm" variant="light">
|
||||
<MoreVertical size={16} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu aria-label="run actions">
|
||||
<DropdownItem
|
||||
className="text-danger"
|
||||
onClick={() => onDeleteRun(run.id)}
|
||||
>
|
||||
Delete run
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
);
|
||||
default:
|
||||
return cellValue;
|
||||
}
|
||||
}, []);
|
||||
|
||||
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",
|
||||
],
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table
|
||||
isCompact
|
||||
aria-label="Runs table"
|
||||
classNames={classNames}
|
||||
sortDescriptor={sortDescriptor}
|
||||
onSortChange={setSortDescriptor}
|
||||
>
|
||||
<TableHeader columns={headerColumns}>
|
||||
{(column) => (
|
||||
<TableColumn
|
||||
key={column.uid}
|
||||
align={column.uid === "actions" ? "center" : "start"}
|
||||
allowsSorting={column.sortable}
|
||||
>
|
||||
{column.name}
|
||||
</TableColumn>
|
||||
)}
|
||||
</TableHeader>
|
||||
<TableBody emptyContent={"No runs found"} items={sortedItems}>
|
||||
{(item) => (
|
||||
<TableRow key={item.id}>
|
||||
{(columnKey) => (
|
||||
<TableCell>{renderCell(item, columnKey)}</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Textarea,
|
||||
Select,
|
||||
SelectItem,
|
||||
Tooltip,
|
||||
Listbox,
|
||||
ListboxItem,
|
||||
Divider,
|
||||
Selection,
|
||||
DropdownTrigger,
|
||||
Dropdown,
|
||||
DropdownMenu,
|
||||
DropdownItem,
|
||||
} from "@nextui-org/react";
|
||||
import {
|
||||
Save,
|
||||
ArrowLeft,
|
||||
Folder,
|
||||
ChevronDown,
|
||||
CopyPlus,
|
||||
CopyMinus,
|
||||
RotateCw,
|
||||
} from "lucide-react";
|
||||
import RunProgressChart from "./RunPregressDonutChart";
|
||||
import TestCaseSelector from "./TestCaseSelector";
|
||||
import { testRunStatus } from "@/config/selection";
|
||||
import {
|
||||
RunType,
|
||||
RunCaseType,
|
||||
RunCaseInfoType,
|
||||
RunStatusCountType,
|
||||
} from "@/types/run";
|
||||
import { CaseType } from "@/types/case";
|
||||
import { FolderType } from "@/types/folder";
|
||||
import {
|
||||
fetchRun,
|
||||
updateRun,
|
||||
fetchRunCases,
|
||||
createRunCase,
|
||||
updateRunCase,
|
||||
bulkCreateRunCases,
|
||||
deleteRunCase,
|
||||
bulkDeleteRunCases,
|
||||
} from "../runsControl";
|
||||
import { fetchFolders } from "../../folders/foldersControl";
|
||||
import { fetchCases } from "../../folders/[folderId]/cases/caseControl";
|
||||
|
||||
const defaultTestRun = {
|
||||
id: 0,
|
||||
name: "",
|
||||
configurations: 0,
|
||||
description: "",
|
||||
state: 0,
|
||||
projectId: 0,
|
||||
};
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
runId: string;
|
||||
};
|
||||
|
||||
export default function RunEditor({ projectId, runId }: Props) {
|
||||
const [testRun, setTestRun] = useState<RunType>(defaultTestRun);
|
||||
const [folders, setFolders] = useState([]);
|
||||
const [runCases, setRunCases] = useState<RunCaseType[]>([]);
|
||||
const [runStatusCounts, setRunStatusCounts] =
|
||||
useState<RunStatusCountType[]>();
|
||||
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set([]));
|
||||
const [selectedFolder, setSelectedFolder] = useState<FolderType>({});
|
||||
const [testcases, setTestCases] = useState<CaseType[]>([]);
|
||||
const [isNameInvalid, setIsNameInvalid] = useState<boolean>(false);
|
||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||
const router = useRouter();
|
||||
|
||||
const fetchRunAndStatusCount = async () => {
|
||||
const { run, statusCounts } = await fetchRun(runId);
|
||||
setTestRun(run);
|
||||
setRunStatusCounts(statusCounts);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchDataEffect() {
|
||||
try {
|
||||
await fetchRunAndStatusCount();
|
||||
const foldersData = await fetchFolders(projectId);
|
||||
setFolders(foldersData);
|
||||
setSelectedFolder(foldersData[0]);
|
||||
} catch (error: any) {
|
||||
console.error("Error in effect:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
fetchDataEffect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchCasesData() {
|
||||
if (selectedFolder && selectedFolder.id) {
|
||||
try {
|
||||
const latestRunCases = await fetchRunCases(runId);
|
||||
setRunCases(latestRunCases);
|
||||
|
||||
const testCasesData = await fetchCases(selectedFolder.id);
|
||||
// Check if each testCase has an association with testRun
|
||||
// and add "isIncluded" property
|
||||
const updatedTestCasesData = testCasesData.map((testCase) => {
|
||||
const runCase = latestRunCases.find(
|
||||
(runCase) => runCase.caseId === testCase.id
|
||||
);
|
||||
|
||||
const isIncluded = runCase ? true : false;
|
||||
const runStatus = runCase ? runCase.status : 0;
|
||||
return {
|
||||
...testCase,
|
||||
isIncluded,
|
||||
runStatus,
|
||||
};
|
||||
});
|
||||
|
||||
setTestCases(updatedTestCasesData);
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching cases data:", error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchCasesData();
|
||||
}, [selectedFolder]);
|
||||
|
||||
const handleChangeStatus = async (changeCaseId: number, status: number) => {
|
||||
await updateRunCase(runId, changeCaseId, status);
|
||||
setTestCases((prevTestCases) => {
|
||||
return prevTestCases.map((testCase) => {
|
||||
if (testCase.id === changeCaseId) {
|
||||
return { ...testCase, runStatus: status };
|
||||
}
|
||||
return testCase;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleIncludeExcludeCase = async (
|
||||
isInclude: boolean,
|
||||
clickedTestCaseId: number
|
||||
) => {
|
||||
if (isInclude) {
|
||||
const createdRunCase = await createRunCase(runId, clickedTestCaseId);
|
||||
setRunCases((prevRunCases) => {
|
||||
return [...prevRunCases, createdRunCase];
|
||||
});
|
||||
} else {
|
||||
await deleteRunCase(runId, clickedTestCaseId);
|
||||
setRunCases((prevRunCases) => {
|
||||
return prevRunCases.filter(
|
||||
(runCase) => runCase.caseId !== clickedTestCaseId
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
setTestCases((prevTestCases) => {
|
||||
return prevTestCases.map((testCase) => {
|
||||
if (testCase.id === clickedTestCaseId) {
|
||||
return { ...testCase, isIncluded: isInclude };
|
||||
}
|
||||
return testCase;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleBulkIncludeExcludeCases = async (isInclude: boolean) => {
|
||||
let keys: number[] = [];
|
||||
if (selectedKeys === "all") {
|
||||
keys = testcases.map((item) => item.id);
|
||||
} else {
|
||||
keys = Array.from(selectedKeys).map(Number);
|
||||
}
|
||||
|
||||
const runCaseInfo: RunCaseInfoType[] = keys.map((caseId) => ({
|
||||
runId: runId,
|
||||
caseId: caseId,
|
||||
}));
|
||||
if (isInclude) {
|
||||
const createdRunCases = await bulkCreateRunCases(runCaseInfo);
|
||||
setRunCases((prevRunCases) => [...prevRunCases, ...createdRunCases]);
|
||||
} else {
|
||||
await bulkDeleteRunCases(runCaseInfo);
|
||||
setRunCases((prevRunCases) => {
|
||||
return prevRunCases.filter((runCase) => {
|
||||
return !runCaseInfo.some((info) => info.caseId === runCase.caseId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const updatedTestCases = testcases.map((testcase) => {
|
||||
if (keys.includes(testcase.id)) {
|
||||
return { ...testcase, isIncluded: isInclude };
|
||||
}
|
||||
return testcase;
|
||||
});
|
||||
setTestCases(updatedTestCases);
|
||||
|
||||
setSelectedKeys(new Set([]));
|
||||
};
|
||||
|
||||
const baseClass = "";
|
||||
const selectedClass = `${baseClass} bg-neutral-200 dark:bg-neutral-700`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border-b-1 dark:border-neutral-700 w-full p-3 flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Tooltip content="Back to runs">
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
className="rounded-full bg-neutral-50 dark:bg-neutral-600"
|
||||
onPress={() => router.push(`/projects/${projectId}/runs`)}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<h3 className="font-bold ms-2">{testRun.name}</h3>
|
||||
</div>
|
||||
<Button
|
||||
startContent={<Save size={16} />}
|
||||
size="sm"
|
||||
color="primary"
|
||||
isLoading={isUpdating}
|
||||
onPress={async () => {
|
||||
setIsUpdating(true);
|
||||
await updateRun(testRun);
|
||||
setIsUpdating(false);
|
||||
}}
|
||||
>
|
||||
{isUpdating ? "Updating..." : "Update"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto max-w-5xl pt-6 px-6 flex-grow">
|
||||
<div className="flex">
|
||||
<div>
|
||||
<div className="w-96 h-72">
|
||||
<div className="flex items-center">
|
||||
<h4 className="font-bold">Progress</h4>
|
||||
<Tooltip content="Refresh">
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
className="rounded-full bg-transparent ms-1"
|
||||
onPress={fetchRunAndStatusCount}
|
||||
>
|
||||
<RotateCw size={16} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<RunProgressChart statusCounts={runStatusCounts} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<Input
|
||||
size="sm"
|
||||
type="text"
|
||||
variant="bordered"
|
||||
label="Name"
|
||||
value={testRun.name}
|
||||
isInvalid={isNameInvalid}
|
||||
errorMessage={isNameInvalid ? "please enter name" : ""}
|
||||
onChange={(e) => {
|
||||
setTestRun({ ...testRun, name: e.target.value });
|
||||
}}
|
||||
className="mt-3"
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
label="Description"
|
||||
placeholder="Test run description"
|
||||
value={testRun.description}
|
||||
onValueChange={(changeValue) => {
|
||||
setTestRun({ ...testRun, description: changeValue });
|
||||
}}
|
||||
className="mt-3"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Select
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
selectedKeys={[testRunStatus[testRun.state].uid]}
|
||||
onSelectionChange={(e) => {
|
||||
const selectedUid = e.anchorKey;
|
||||
const index = testRunStatus.findIndex(
|
||||
(template) => template.uid === selectedUid
|
||||
);
|
||||
setTestRun({ ...testRun, state: index });
|
||||
}}
|
||||
label="status"
|
||||
className="mt-3 max-w-xs"
|
||||
>
|
||||
{testRunStatus.map((state, index) => (
|
||||
<SelectItem key={state.uid} value={index}>
|
||||
{state.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider className="my-6" />
|
||||
<div className="flex items-center justify-between">
|
||||
<h6 className="h-8 font-bold">Select test cases</h6>
|
||||
<div>
|
||||
{(selectedKeys.size > 0 || selectedKeys === "all") && (
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
endContent={<ChevronDown size={16} />}
|
||||
>
|
||||
Test case selection
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu aria-label="test case select actions">
|
||||
<DropdownItem
|
||||
startContent={<CopyPlus size={16} />}
|
||||
onClick={() => handleBulkIncludeExcludeCases(true)}
|
||||
>
|
||||
Include selected cases in run
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
startContent={<CopyMinus size={16} />}
|
||||
onClick={() => handleBulkIncludeExcludeCases(false)}
|
||||
>
|
||||
Exclude selected cases from run
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex rounded-small border-2 dark:border-neutral-700 mb-12">
|
||||
<div className="w-3/12 border-r-1 dark:border-neutral-700">
|
||||
<Listbox aria-label="Listbox Variants" variant="light">
|
||||
{folders.map((folder, index) => (
|
||||
<ListboxItem
|
||||
key={index}
|
||||
onClick={() => setSelectedFolder(folder)}
|
||||
startContent={
|
||||
<Folder size={20} color="#F7C24E" fill="#F7C24E" />
|
||||
}
|
||||
className={
|
||||
selectedFolder && folder.id === selectedFolder.id
|
||||
? selectedClass
|
||||
: baseClass
|
||||
}
|
||||
>
|
||||
{folder.name}
|
||||
</ListboxItem>
|
||||
))}
|
||||
</Listbox>
|
||||
</div>
|
||||
<div className="w-9/12">
|
||||
<TestCaseSelector
|
||||
cases={testcases}
|
||||
selectedKeys={selectedKeys}
|
||||
onSelectionChange={setSelectedKeys}
|
||||
onStatusChange={handleChangeStatus}
|
||||
onIncludeCase={(includeTestId) =>
|
||||
handleIncludeExcludeCase(true, includeTestId)
|
||||
}
|
||||
onExcludeCase={(excludeCaseId) =>
|
||||
handleIncludeExcludeCase(false, excludeCaseId)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { testRunCaseStatus } from "@/config/selection";
|
||||
import { RunStatusCountType } from "@/types/run";
|
||||
const Chart = dynamic(() => import("react-apexcharts"), { ssr: false });
|
||||
|
||||
type Props = {
|
||||
statusCounts: RunStatusCountType[];
|
||||
};
|
||||
|
||||
export default function RunProgressDounut({ statusCounts }: Props) {
|
||||
const [chartData, setChartData] = useState({
|
||||
series: [],
|
||||
options: {
|
||||
labels: [],
|
||||
colors: [],
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const updateChartDate = () => {
|
||||
if (statusCounts) {
|
||||
const series = testRunCaseStatus.map((entry, index) => {
|
||||
const found = statusCounts.find((itr) => itr.status === index);
|
||||
return found ? found.count : 0;
|
||||
});
|
||||
|
||||
const labels = testRunCaseStatus.map((entry) => entry.name);
|
||||
const colors = testRunCaseStatus.map((entry) => entry.chartColor);
|
||||
|
||||
setChartData({
|
||||
series,
|
||||
options: { labels, colors },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
updateChartDate();
|
||||
}, [statusCounts]);
|
||||
|
||||
return (
|
||||
<Chart
|
||||
options={chartData.options}
|
||||
series={chartData.series}
|
||||
type="donut"
|
||||
width={"100%"}
|
||||
height={"100%"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableColumn,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
Button,
|
||||
DropdownTrigger,
|
||||
Dropdown,
|
||||
DropdownMenu,
|
||||
DropdownItem,
|
||||
Selection,
|
||||
SortDescriptor,
|
||||
} from "@nextui-org/react";
|
||||
import {
|
||||
ChevronDown,
|
||||
MoreVertical,
|
||||
CopyPlus,
|
||||
CopyMinus,
|
||||
Circle,
|
||||
CircleCheck,
|
||||
CircleDashed,
|
||||
CircleX,
|
||||
CircleSlash2,
|
||||
} from "lucide-react";
|
||||
import { priorities, testRunCaseStatus } from "@/config/selection";
|
||||
import { CaseType } from "@/types/case";
|
||||
|
||||
const headerColumns = [
|
||||
{ name: "ID", uid: "id", sortable: true },
|
||||
{ name: "Title", uid: "title", sortable: true },
|
||||
{ name: "Priority", uid: "priority", sortable: true },
|
||||
{ name: "Status", uid: "runStatus", sortable: true },
|
||||
{ name: "Actions", uid: "actions" },
|
||||
];
|
||||
|
||||
type Props = {
|
||||
cases: CaseType[];
|
||||
selectedKeys: Selection;
|
||||
onSelectionChange: React.Dispatch<React.SetStateAction<Selection>>;
|
||||
onStatusChange: (changeCaseId: number, status: number) => {};
|
||||
onIncludeCase: (includeCaseId: number) => {};
|
||||
onExcludeCase: (excludeCaseId: number) => {};
|
||||
};
|
||||
|
||||
export default function TestCaseSelector({
|
||||
cases,
|
||||
selectedKeys,
|
||||
onSelectionChange,
|
||||
onStatusChange,
|
||||
onIncludeCase,
|
||||
onExcludeCase,
|
||||
}: Props) {
|
||||
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({
|
||||
column: "id",
|
||||
direction: "ascending",
|
||||
});
|
||||
|
||||
const sortedItems = useMemo(() => {
|
||||
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 notIncludedCaseClass = "text-neutral-200 dark:text-neutral-600";
|
||||
const chipBaseClass = "flex items-center text-default-600";
|
||||
|
||||
const renderStatusIcon = (uid: string) => {
|
||||
if (uid === "untested") {
|
||||
return <Circle size={16} color="#d4d4d8" />;
|
||||
} else if (uid === "passed") {
|
||||
return <CircleCheck size={16} color="#17c964" />;
|
||||
} else if (uid === "retest") {
|
||||
return <CircleDashed size={16} color="#f5a524" />;
|
||||
} else if (uid === "failed") {
|
||||
return <CircleX size={16} color="#f31260" />;
|
||||
} else if (uid === "skipped") {
|
||||
return <CircleSlash2 size={16} color="#52525b" />;
|
||||
}
|
||||
};
|
||||
|
||||
const renderCell = (testCase: CaseType, columnKey: Key) => {
|
||||
const cellValue = testCase[columnKey as keyof CaseType];
|
||||
const isIncluded = testCase.isIncluded;
|
||||
|
||||
switch (columnKey) {
|
||||
case "priority":
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
isIncluded ? chipBaseClass : chipBaseClass + notIncludedCaseClass
|
||||
}
|
||||
>
|
||||
<Circle
|
||||
size={8}
|
||||
color={isIncluded ? priorities[cellValue].color : "#d4d4d8"}
|
||||
fill={isIncluded ? priorities[cellValue].color : "#d4d4d8"}
|
||||
/>
|
||||
<div className="ms-3">{priorities[cellValue].name}</div>
|
||||
</div>
|
||||
);
|
||||
case "runStatus":
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
isDisabled={!isIncluded}
|
||||
startContent={
|
||||
isIncluded &&
|
||||
renderStatusIcon(testRunCaseStatus[cellValue].uid)
|
||||
}
|
||||
endContent={isIncluded && <ChevronDown size={16} />}
|
||||
>
|
||||
<span className="w-12">
|
||||
{isIncluded && testRunCaseStatus[cellValue].name}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu aria-label="test case actions">
|
||||
{testRunCaseStatus.map((runCaseStatus, index) => (
|
||||
<DropdownItem
|
||||
key={index}
|
||||
startContent={renderStatusIcon(runCaseStatus.uid)}
|
||||
onPress={() => onStatusChange(testCase.id, index)}
|
||||
>
|
||||
{runCaseStatus.name}
|
||||
</DropdownItem>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
);
|
||||
case "actions":
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<Button isIconOnly radius="full" size="sm" variant="light">
|
||||
<MoreVertical size={16} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu aria-label="include or exclude actions">
|
||||
<DropdownItem
|
||||
startContent={<CopyPlus size={16} />}
|
||||
isDisabled={testCase.isIncluded}
|
||||
onPress={() => onIncludeCase(testCase.id)}
|
||||
>
|
||||
Include in run
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
startContent={<CopyMinus size={16} />}
|
||||
isDisabled={!testCase.isIncluded}
|
||||
onPress={() => onExcludeCase(testCase.id)}
|
||||
>
|
||||
Exclude from run
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
);
|
||||
default:
|
||||
return cellValue;
|
||||
}
|
||||
};
|
||||
|
||||
const classNames = useMemo(
|
||||
() => ({
|
||||
wrapper: ["min-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",
|
||||
],
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSelectionChange = (keys: Selection) => {
|
||||
onSelectionChange(keys);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table
|
||||
isCompact
|
||||
removeWrapper
|
||||
aria-label="Tese cases table"
|
||||
classNames={classNames}
|
||||
selectedKeys={selectedKeys}
|
||||
selectionMode="multiple"
|
||||
sortDescriptor={sortDescriptor}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onSortChange={setSortDescriptor}
|
||||
>
|
||||
<TableHeader columns={headerColumns}>
|
||||
{(column) => (
|
||||
<TableColumn
|
||||
key={column.uid}
|
||||
align={column.uid === "actions" ? "center" : "start"}
|
||||
allowsSorting={column.sortable}
|
||||
>
|
||||
{column.name}
|
||||
</TableColumn>
|
||||
)}
|
||||
</TableHeader>
|
||||
<TableBody emptyContent={"No cases found"} items={sortedItems}>
|
||||
{(item) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={!item.isIncluded ? notIncludedCaseClass : ""}
|
||||
>
|
||||
{(columnKey) => (
|
||||
<TableCell>{renderCell(item, columnKey)}</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import RunEditor from "./RunEditor";
|
||||
|
||||
export default function Page({
|
||||
params,
|
||||
}: {
|
||||
params: { projectId: string; runId: string };
|
||||
}) {
|
||||
return <RunEditor projectId={params.projectId} runId={params.runId} />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import RunsPage from "./RunsPage";
|
||||
|
||||
export default function Page({ params }: { params: { projectId: string } }) {
|
||||
return (
|
||||
<>
|
||||
<RunsPage projectId={params.projectId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import Config from "@/config/config";
|
||||
const apiServer = Config.apiServer;
|
||||
import { RunType, RunCaseInfoType } from "@/types/run";
|
||||
|
||||
async function fetchRun(runId: string) {
|
||||
const url = `${apiServer}/runs/${runId}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching data:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRuns(projectId: string) {
|
||||
const url = `${apiServer}/runs?projectId=${projectId}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching data:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function createRun(projectId: string) {
|
||||
const newTestRun = {
|
||||
name: "untitled run",
|
||||
configurations: 0,
|
||||
description: "",
|
||||
state: 0,
|
||||
projectId: projectId,
|
||||
};
|
||||
|
||||
const fetchOptions = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(newTestRun),
|
||||
};
|
||||
|
||||
const url = `${apiServer}/runs`;
|
||||
|
||||
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: any) {
|
||||
console.error("Error creating new test run:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateRun(updateTestRun: RunType) {
|
||||
const fetchOptions = {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(updateTestRun),
|
||||
};
|
||||
|
||||
const url = `${apiServer}/runs/${updateTestRun.id}`;
|
||||
|
||||
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: any) {
|
||||
console.error("Error updating run:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRun(runId: number) {
|
||||
const fetchOptions = {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
};
|
||||
|
||||
const url = `${apiServer}/runs/${runId}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, fetchOptions);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error deleting run:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRunCases(runId: string) {
|
||||
const url = `${apiServer}/runcases?runId=${runId}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching data:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function createRunCase(runId: string, caseId: number) {
|
||||
const fetchOptions = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
};
|
||||
|
||||
const url = `${apiServer}/runcases?runId=${runId}&caseId=${caseId}`;
|
||||
|
||||
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: any) {
|
||||
console.error("Error creating new runcase:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateRunCase(runId: string, caseId: number, status: number) {
|
||||
const fetchOptions = {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
};
|
||||
|
||||
const url = `${apiServer}/runcases?runId=${runId}&caseId=${caseId}&status=${status}`;
|
||||
|
||||
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: any) {
|
||||
console.error("Error updating runcase:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkCreateRunCases(runCaseInfo: RunCaseInfoType[]) {
|
||||
const fetchOptions = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(runCaseInfo),
|
||||
};
|
||||
|
||||
const url = `${apiServer}/runcases/bulknew`;
|
||||
|
||||
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: any) {
|
||||
console.error("Error creating new runcase:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRunCase(runId: string, caseId: number) {
|
||||
const fetchOptions = {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
};
|
||||
|
||||
const url = `${apiServer}/runcases?runId=${runId}&caseId=${caseId}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, fetchOptions);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error deleting runcase:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkDeleteRunCases(runCaseInfo: RunCaseInfoType[]) {
|
||||
const fetchOptions = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(runCaseInfo),
|
||||
};
|
||||
|
||||
const url = `${apiServer}/runcases/bulkdelete`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, fetchOptions);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error deleting runcase:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
fetchRun,
|
||||
fetchRuns,
|
||||
createRun,
|
||||
updateRun,
|
||||
deleteRun,
|
||||
fetchRunCases,
|
||||
createRunCase,
|
||||
updateRunCase,
|
||||
bulkCreateRunCases,
|
||||
deleteRunCase,
|
||||
bulkDeleteRunCases,
|
||||
};
|
||||
80
frontend/src/app/[locale]/projects/[projectId]/sidebar.tsx
Normal file
80
frontend/src/app/[locale]/projects/[projectId]/sidebar.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Listbox, ListboxItem } from "@nextui-org/react";
|
||||
import { Home, Files, FlaskConical } from "lucide-react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import useGetCurrentIds from "@/utils/useGetCurrentIds";
|
||||
|
||||
export default function Sidebar() {
|
||||
const { projectId } = useGetCurrentIds();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const [currentKey, setCurrentTab] = useState("home");
|
||||
const baseClass = "p-3 rounded-none";
|
||||
const selectedClass = `${baseClass} bg-neutral-200 dark:bg-neutral-700 border-l-3 border-neutral-800`;
|
||||
|
||||
const handleTabClick = (key: string) => {
|
||||
if (key === "home") {
|
||||
router.push(`/projects/${projectId}/home`);
|
||||
} else if (key === "cases") {
|
||||
router.push(`/projects/${projectId}/folders`);
|
||||
} else if (key === "runs") {
|
||||
router.push(`/projects/${projectId}/runs`);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleRouteChange = (currentPath: string) => {
|
||||
if (currentPath.includes("home")) {
|
||||
setCurrentTab("home");
|
||||
} else if (currentPath.includes("folders")) {
|
||||
setCurrentTab("cases");
|
||||
} else if (currentPath.includes("runs")) {
|
||||
setCurrentTab("runs");
|
||||
}
|
||||
};
|
||||
|
||||
handleRouteChange(pathname);
|
||||
}, [pathname]);
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: "home",
|
||||
text: "Home",
|
||||
startContent: <Home strokeWidth={1} size={28} />,
|
||||
},
|
||||
{
|
||||
key: "cases",
|
||||
text: "Test Cases",
|
||||
startContent: <Files strokeWidth={1} size={28} />,
|
||||
},
|
||||
{
|
||||
key: "runs",
|
||||
text: "Test Runs",
|
||||
startContent: <FlaskConical strokeWidth={1} size={28} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="w-64 border-r-1 dark:border-neutral-700">
|
||||
<Listbox
|
||||
aria-label="Listbox Variants"
|
||||
variant="light"
|
||||
className="p-0"
|
||||
onClick={() => router.push(`/projects/${projectId}/home`)}
|
||||
>
|
||||
{tabItems.map((itr, index) => (
|
||||
<ListboxItem
|
||||
key={itr.key}
|
||||
startContent={itr.startContent}
|
||||
onClick={() => handleTabClick(itr.key)}
|
||||
className={currentKey === itr.key ? selectedClass : baseClass}
|
||||
>
|
||||
{itr.text}
|
||||
</ListboxItem>
|
||||
))}
|
||||
</Listbox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user