i18n by next-intl

This commit is contained in:
Takeshi Kimata
2024-05-03 11:44:02 +09:00
parent 7af70399a1
commit 1e01535134
51 changed files with 194 additions and 87 deletions

View File

@@ -0,0 +1,13 @@
export default function AboutLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<section className="flex flex-col items-center justify-center gap-4 py-8 md:py-10">
<div className="inline-block max-w-lg text-center justify-center">
{children}
</div>
</section>
);
}

View File

@@ -0,0 +1,9 @@
import { title } from "@/components/primitives";
export default function AboutPage() {
return (
<div>
<h1 className={title()}>About</h1>
</div>
);
}

View File

@@ -0,0 +1,30 @@
"use client";
import { useEffect } from "react";
export default function Error({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
useEffect(() => {
// Log the error to an error reporting service
console.error(error);
}, [error]);
return (
<div>
<h2>Something went wrong!</h2>
<button
onClick={
// Attempt to recover by trying to re-render the segment
() => reset()
}
>
Try again
</button>
</div>
);
}

View File

@@ -0,0 +1,62 @@
import "@/styles/globals.css";
import { Metadata } from "next";
import { siteConfig } from "@/config/site";
import { fontSans } from "@/config/fonts";
import { Providers } from "./providers";
import { Navbar } from "@/components/navbar";
import clsx from "clsx";
export const metadata: Metadata = {
title: {
default: siteConfig.name,
template: `%s - ${siteConfig.name}`,
},
description: siteConfig.description,
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "white" },
{ media: "(prefers-color-scheme: dark)", color: "black" },
],
icons: {
icon: "/favicon/favicon.ico",
shortcut: "/favicon/favicon-16x16.png",
apple: "/favicon/apple-touch-icon.png",
},
};
export default function RootLayout({
children,
params: {locale}
}: {
children: React.ReactNode;
params: {locale: string};
}) {
return (
<html lang={locale} suppressHydrationWarning>
<head />
<body
className={clsx(
"min-h-[calc(100vh-64px)] bg-background font-sans antialiased",
fontSans.variable
)}
>
<Providers themeProps={{ attribute: "class", defaultTheme: "dark" }}>
<div className="relative flex flex-col min-h-screen light:bg-neutral-50 dark:bg-neutral-800">
<Navbar />
<main>{children}</main>
{/* <footer className="w-full flex items-center justify-center py-3">
<Link
isExternal
className="flex items-center gap-1 text-current"
href="https://nextui-docs-v2.vercel.app?utm_source=next-app-template"
title="nextui.org homepage"
>
<span className="text-default-600">Powered by</span>
<p className="text-primary">NextUI</p>
</Link>
</footer> */}
</div>
</Providers>
</body>
</html>
);
}

View File

@@ -0,0 +1,36 @@
import { button as buttonStyles } from "@nextui-org/react";
import { title, subtitle } from "@/components/primitives";
import { useTranslations } from "next-intl";
import { Link } from "@/src/navigation";
export default function Home() {
const t = useTranslations("Index");
return (
<section className="flex flex-col items-center justify-center gap-4 py-8 md:py-10">
<div className="inline-block max-w-lg text-center justify-center">
<h1 className={title({ color: "pink" })}>Open Source&nbsp;</h1>
<h1 className={title()}>Test Case Management Tool&nbsp;</h1>
<br />
<h2 className={subtitle({ class: "text-inherit mt-4" })}>
Integrate and manage all your software testing.
</h2>
</div>
<h1>{t("title")}</h1>
<div className="flex gap-3">
<Link
href="/projects"
className={buttonStyles({
color: "primary",
radius: "full",
variant: "shadow",
})}
>
Create first test project
</Link>
</div>
</section>
);
}

View File

@@ -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 { ProjectType } from "@/types/project";
type Props = {
isOpen: boolean;
editingProject: ProjectType;
onCancel: () => void;
onSubmit: (name: string, detail: string) => void;
};
export default function ProjectDialog({
isOpen,
editingProject,
onCancel,
onSubmit,
}: Props) {
const [projectName, setProjectName] = useState({
text: editingProject ? editingProject.name : "",
isValid: false,
errorMessage: "",
});
const [projectDetail, setProjectDetail] = useState({
text: editingProject ? editingProject.detail : "",
isValid: false,
errorMessage: "",
});
useEffect(() => {
if (editingProject) {
setProjectName({
...projectName,
text: editingProject.name,
});
setProjectDetail({
...projectDetail,
text: editingProject.detail ? editingProject.detail : "",
});
} else {
setProjectName({
...projectName,
text: "",
});
setProjectDetail({
...projectDetail,
text: "",
});
}
}, [editingProject]);
const clear = () => {
setProjectName({
isValid: false,
text: "",
errorMessage: "",
});
setProjectDetail({
isValid: false,
text: "",
errorMessage: "",
});
};
const validate = () => {
if (!projectName.text) {
setProjectName({
text: "",
isValid: false,
errorMessage: "Please enter project name",
});
return;
}
onSubmit(projectName.text, projectDetail.text);
clear();
};
return (
<Modal
isOpen={isOpen}
onOpenChange={() => {
onCancel();
}}
>
<ModalContent>
<ModalHeader className="flex flex-col gap-1">Project</ModalHeader>
<ModalBody>
<Input
type="text"
label="Project Name"
value={projectName.text}
isInvalid={projectName.isValid}
errorMessage={projectName.errorMessage}
onChange={(e) => {
setProjectName({
...projectName,
text: e.target.value,
});
}}
/>
<Textarea
label="Project Detail"
value={projectDetail.text}
isInvalid={projectDetail.isValid}
errorMessage={projectDetail.errorMessage}
onChange={(e) => {
setProjectDetail({
...projectDetail,
text: e.target.value,
});
}}
/>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onCancel}>
Close
</Button>
<Button color="primary" onPress={validate}>
{editingProject ? "Update" : "Create"}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}

View File

@@ -0,0 +1,110 @@
// "use client";
import { useEffect, useState } from "react";
import { Button } from "@nextui-org/react";
import { Plus } from "lucide-react";
import { ProjectType } from "@/types/project";
import ProjectsTable from "./ProjectsTable";
import ProjectDialog from "./ProjectDialog";
import {
fetchProjects,
createProject,
updateProject,
deleteProject,
} from "./projectsControl";
import { useTranslations } from "next-intl";
export default function ProjectsPage() {
const t = useTranslations("ProjectsPage");
const [projects, setProjects] = useState([]);
useEffect(() => {
async function fetchDataEffect() {
try {
const data = await fetchProjects();
setProjects(data);
} catch (error: any) {
console.error("Error in effect:", error.message);
}
}
fetchDataEffect();
}, []);
// dialog
const [isProjectDialogOpen, setIsProjectDialogOpen] = useState(false);
const [editingProject, setEditingProject] = useState<ProjectType | null>(
null
);
const openDialogForCreate = () => {
setIsProjectDialogOpen(true);
setEditingProject(null);
};
const closeDialog = () => {
setIsProjectDialogOpen(false);
setEditingProject(null);
};
const onSubmit = async (name: string, detail: string) => {
if (editingProject) {
const updatedProject = await updateProject(
editingProject.id,
name,
detail
);
const updatedProjects = projects.map((project) =>
project.id === updatedProject.id ? updatedProject : project
);
setProjects(updatedProjects);
} else {
const newProject = await createProject(name, detail);
setProjects([...projects, newProject]);
}
closeDialog();
};
const onEditClick = (project: ProjectType) => {
setEditingProject(project);
setIsProjectDialogOpen(true);
};
const onDeleteClick = async (projectId: number) => {
try {
await deleteProject(projectId);
setProjects(projects.filter((project) => project.id !== projectId));
} catch (error: any) {
console.error("Error deleting project:", error);
}
};
return (
<div className="container mx-auto max-w-3xl pt-16 px-6 flex-grow">
<div className="w-full p-3 flex items-center justify-between">
<h3 className="font-bold">{t("project.project")}</h3>
<div>
<Button
startContent={<Plus size={16} />}
size="sm"
color="primary"
onClick={openDialogForCreate}
>
{t("newProject")}
</Button>
</div>
</div>
<ProjectsTable
projects={projects}
onEditProject={onEditClick}
onDeleteProject={onDeleteClick}
/>
<ProjectDialog
isOpen={isProjectDialogOpen}
editingProject={editingProject}
onCancel={closeDialog}
onSubmit={onSubmit}
/>
</div>
);
}

View File

@@ -0,0 +1,162 @@
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 { ProjectType } from "@/types/project";
import dayjs from "dayjs";
const headerColumns = [
{ name: "ID", uid: "id", sortable: true },
{ name: "Name", uid: "name", sortable: true },
{ name: "Detail", uid: "detail", sortable: true },
{ name: "Last update", uid: "updatedAt", sortable: true },
{ name: "Actions", uid: "actions" },
];
type Props = {
projects: ProjectType[];
onEditProject: (project: ProjectType) => void;
onDeleteProject: (projectId: number) => void;
};
export default function ProjectsTable({
projects,
onEditProject,
onDeleteProject,
}: Props) {
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({
column: "id",
direction: "ascending",
});
const sortedItems = useMemo(() => {
return [...projects].sort((a: ProjectType, b: ProjectType) => {
const first = a[sortDescriptor.column as keyof ProjectType] as number;
const second = b[sortDescriptor.column as keyof ProjectType] as number;
const cmp = first < second ? -1 : first > second ? 1 : 0;
return sortDescriptor.direction === "descending" ? -cmp : cmp;
});
}, [sortDescriptor, projects]);
const truncateText = (text: string, maxLength: number) => {
return text.length > maxLength ? text.slice(0, maxLength) + "..." : text;
};
const renderCell = useCallback((project: ProjectType, columnKey: Key) => {
const cellValue = project[columnKey as keyof ProjectType];
switch (columnKey) {
case "id":
return <span>{cellValue}</span>;
case "name":
return (
<Link
underline="hover"
href={`/projects/${project.id}/home`}
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="project actions">
<DropdownItem onClick={() => onEditProject(project)}>
Edit project
</DropdownItem>
<DropdownItem
className="text-danger"
onClick={() => onDeleteProject(project.id)}
>
Delete project
</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="Projects 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 cases found"} items={sortedItems}>
{(item) => (
<TableRow key={item.id}>
{(columnKey) => (
<TableCell>{renderCell(item, columnKey)}</TableCell>
)}
</TableRow>
)}
</TableBody>
</Table>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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}
/>
</>
);
}

View File

@@ -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}
/>
</>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
))}
</>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
))}
</>
);
}

View File

@@ -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,
};

View File

@@ -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,
}}
/>
);
}

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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} />
</>
);
}

View File

@@ -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 };

View File

@@ -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>
);
}

View File

@@ -0,0 +1,7 @@
export default function Page() {
return (
<>
This is folders page.
</>
);
}

View 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>
</>
);
}

View File

@@ -0,0 +1,9 @@
import { Home } from "./home";
export default function Page({ params }: { params: { projectId: string } }) {
return (
<>
<Home projectId={params.projectId} />
</>
);
}

View 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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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%"}
/>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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} />;
}

View File

@@ -0,0 +1,9 @@
import RunsPage from "./RunsPage";
export default function Page({ params }: { params: { projectId: string } }) {
return (
<>
<RunsPage projectId={params.projectId} />
</>
);
}

View File

@@ -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,
};

View 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>
);
}

View File

@@ -0,0 +1,7 @@
export default function ProjectsLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}

View File

@@ -0,0 +1,9 @@
import ProjectsPage from "./ProjectsPage";
export default function Page() {
return (
<>
<ProjectsPage />
</>
);
}

View File

@@ -0,0 +1,117 @@
import Config from "@/config/config";
const apiServer = Config.apiServer;
/**
* fetch project records
*/
async function fetchProjects() {
const url = `${apiServer}/projects`;
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);
}
}
/**
* Create project
*/
async function createProject(name: string, detail: string) {
const newProjectData = {
name: name,
detail: detail,
};
const fetchOptions = {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(newProjectData),
};
const url = `${apiServer}/projects`;
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 project
*/
async function updateProject(projectId: number, name: string, detail: string) {
const updatedProjectData = {
name: name,
detail: detail,
};
const fetchOptions = {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(updatedProjectData),
};
const url = `${apiServer}/projects/${projectId}`;
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 project
*/
async function deleteProject(projectId: number) {
const fetchOptions = {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
};
const url = `${apiServer}/projects/${projectId}`;
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 { fetchProjects, createProject, updateProject, deleteProject };

View File

@@ -0,0 +1,22 @@
"use client";
import * as React from "react";
import { NextUIProvider } from "@nextui-org/react";
import { useRouter } from "next/navigation";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { ThemeProviderProps } from "next-themes/dist/types";
export interface ProvidersProps {
children: React.ReactNode;
themeProps?: ThemeProviderProps;
}
export function Providers({ children, themeProps }: ProvidersProps) {
const router = useRouter();
return (
<NextUIProvider navigate={router.push}>
<NextThemesProvider {...themeProps}>{children}</NextThemesProvider>
</NextUIProvider>
);
}

12
frontend/src/i18n.ts Normal file
View File

@@ -0,0 +1,12 @@
import { notFound } from "next/navigation";
import { getRequestConfig } from "next-intl/server";
const locales = ["en", "ja"];
export default getRequestConfig(async ({ locale }) => {
if (!locales.includes(locale as any)) notFound();
return {
messages: (await import(`../messages/${locale}.json`)).default,
};
});

View File

@@ -0,0 +1,12 @@
import createMiddleware from "next-intl/middleware";
import { locales, localePrefix } from "./navigation";
export default createMiddleware({
defaultLocale: "en",
localePrefix,
locales,
});
export const config = {
matcher: ["/", "/(ja|en)/:path*"],
};

View File

@@ -0,0 +1,7 @@
import { createSharedPathnamesNavigation } from "next-intl/navigation";
export const locales = ["en", "ja"] as const;
export const localePrefix = "always";
export const { Link, redirect, usePathname, useRouter } =
createSharedPathnamesNavigation({ locales, localePrefix });