feat: add project settings page

This commit is contained in:
Takeshi Kimata
2024-06-02 21:22:33 +09:00
parent 016a8d07c5
commit 24fe7bebb3
21 changed files with 471 additions and 91 deletions

View File

@@ -9,7 +9,14 @@ type Props = {
deleteText: string; deleteText: string;
}; };
export default function ProjectDialog({ isOpen, onCancel, onConfirm, closeText, confirmText, deleteText }: Props) { export default function DeleteConfirmDialog({
isOpen,
onCancel,
onConfirm,
closeText,
confirmText,
deleteText,
}: Props) {
return ( return (
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}

View File

@@ -95,6 +95,7 @@
"home": "Home", "home": "Home",
"test_cases": "Test Cases", "test_cases": "Test Cases",
"test_runs": "Test Runs", "test_runs": "Test Runs",
"members": "Members",
"settings": "Settings" "settings": "Settings"
}, },
"Home": { "Home": {
@@ -260,7 +261,7 @@
"exclude_from_run": "Exclude from run", "exclude_from_run": "Exclude from run",
"no_cases_found": "No cases found" "no_cases_found": "No cases found"
}, },
"Settings": { "Members": {
"member_management": "Member Management", "member_management": "Member Management",
"avatar": "Avatar", "avatar": "Avatar",
"email": "Email", "email": "Email",
@@ -276,5 +277,21 @@
"user_name_or_email": "User name or email", "user_name_or_email": "User name or email",
"close": "Close", "close": "Close",
"add": "Add" "add": "Add"
},
"Settings": {
"project_management": "Project Management",
"project_name": "Project Name",
"project_detail": "Project Detail",
"edit_project": "Edit Project",
"project": "Project",
"publicity": "Publicity",
"public": "Public",
"private": "Private",
"if_you_make_public": "Making a project public makes it visible to users who are not project members.",
"update": "Update",
"delete_project": "Delete Project",
"delete": "Delete",
"close": "Close",
"are_you_sure": "Are you sure you want to delete the project?"
} }
} }

View File

@@ -94,6 +94,7 @@
"home": "ホーム", "home": "ホーム",
"test_cases": "テストケース", "test_cases": "テストケース",
"test_runs": "テストラン", "test_runs": "テストラン",
"members": "メンバー",
"settings": "設定" "settings": "設定"
}, },
"Home": { "Home": {
@@ -259,7 +260,7 @@
"exclude_from_run": "テストランから除外する", "exclude_from_run": "テストランから除外する",
"no_cases_found": "テストケースが見つかりません" "no_cases_found": "テストケースが見つかりません"
}, },
"Settings": { "Members": {
"member_management": "メンバー管理", "member_management": "メンバー管理",
"avatar": "アバター", "avatar": "アバター",
"email": "メールアドレス", "email": "メールアドレス",
@@ -275,5 +276,21 @@
"user_name_or_email": "ユーザー名またはメールアドレス", "user_name_or_email": "ユーザー名またはメールアドレス",
"close": "閉じる", "close": "閉じる",
"add": "追加" "add": "追加"
},
"Settings": {
"project_management": "プロジェクト管理",
"project_name": "プロジェクト名",
"project_detail": "プロジェクト詳細",
"edit_project": "プロジェクトの編集",
"project": "プロジェクト",
"publicity": "公開",
"public": "パブリック",
"private": "プライベート",
"if_you_make_public": "プロジェクトをパブリックにすると、プロジェクトメンバーではないユーザーからも見えるようになります。",
"update": "更新",
"delete_project": "プロジェクトの削除",
"delete": "削除",
"close": "閉じる",
"are_you_sure": "プロジェクトを削除してもよろしいですか?"
} }
} }

View File

@@ -5,8 +5,8 @@ import { Plus } from 'lucide-react';
import { TokenContext } from '@/utils/TokenProvider'; import { TokenContext } from '@/utils/TokenProvider';
import { ProjectType, ProjectsMessages } from '@/types/project'; import { ProjectType, ProjectsMessages } from '@/types/project';
import ProjectsTable from './ProjectsTable'; import ProjectsTable from './ProjectsTable';
import ProjectDialog from './ProjectDialog'; import ProjectDialog from '@/components/ProjectDialog';
import { fetchProjects, createProject, updateProject, deleteProject } from './projectsControl'; import { fetchProjects, createProject, updateProject, deleteProject } from '@/utils/projectsControl';
import DeleteConfirmDialog from '@/components/DeleteConfirmDialog'; import DeleteConfirmDialog from '@/components/DeleteConfirmDialog';
export type Props = { export type Props = {

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Listbox, ListboxItem } from '@nextui-org/react'; import { Listbox, ListboxItem } from '@nextui-org/react';
import { Home, Files, FlaskConical, Settings } from 'lucide-react'; import { Home, Files, FlaskConical, Users, Settings } from 'lucide-react';
import { usePathname, useRouter } from '@/src/navigation'; import { usePathname, useRouter } from '@/src/navigation';
import useGetCurrentIds from '@/utils/useGetCurrentIds'; import useGetCurrentIds from '@/utils/useGetCurrentIds';
import { ProjectMessages } from '@/types/project'; import { ProjectMessages } from '@/types/project';
@@ -27,6 +27,8 @@ export default function Sidebar({ messages, locale }: Props) {
router.push(`/projects/${projectId}/folders`, { locale: locale }); router.push(`/projects/${projectId}/folders`, { locale: locale });
} else if (key === 'runs') { } else if (key === 'runs') {
router.push(`/projects/${projectId}/runs`, { locale: locale }); router.push(`/projects/${projectId}/runs`, { locale: locale });
} else if (key === 'members') {
router.push(`/projects/${projectId}/members`, { locale: locale });
} else if (key === 'settings') { } else if (key === 'settings') {
router.push(`/projects/${projectId}/settings`, { locale: locale }); router.push(`/projects/${projectId}/settings`, { locale: locale });
} }
@@ -40,6 +42,8 @@ export default function Sidebar({ messages, locale }: Props) {
setCurrentTab('cases'); setCurrentTab('cases');
} else if (currentPath.includes('runs')) { } else if (currentPath.includes('runs')) {
setCurrentTab('runs'); setCurrentTab('runs');
} else if (currentPath.includes('members')) {
setCurrentTab('members');
} else if (currentPath.includes('settings')) { } else if (currentPath.includes('settings')) {
setCurrentTab('settings'); setCurrentTab('settings');
} }
@@ -64,6 +68,11 @@ export default function Sidebar({ messages, locale }: Props) {
text: messages.testRuns, text: messages.testRuns,
startContent: <FlaskConical strokeWidth={1} size={20} />, startContent: <FlaskConical strokeWidth={1} size={20} />,
}, },
{
key: 'members',
text: messages.members,
startContent: <Users strokeWidth={1} size={20} />,
},
{ {
key: 'settings', key: 'settings',
text: messages.settings, text: messages.settings,

View File

@@ -43,7 +43,7 @@ type Props = {
messages: HomeMessages; messages: HomeMessages;
}; };
export function Home({ projectId, messages }: Props) { export function ProjectHome({ projectId, messages }: Props) {
const context = useContext(TokenContext); const context = useContext(TokenContext);
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const [project, setProject] = useState({ const [project, setProject] = useState({

View File

@@ -1,4 +1,4 @@
import { Home } from './home'; import { ProjectHome } from './ProjectHome';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
export type HomeMessages = { export type HomeMessages = {
@@ -69,7 +69,7 @@ export default function Page({ params }: { params: { projectId: string } }) {
}; };
return ( return (
<> <>
<Home projectId={params.projectId} messages={messages} /> <ProjectHome projectId={params.projectId} messages={messages} />
</> </>
); );
} }

View File

@@ -13,6 +13,7 @@ export default function SidebarLayout({
home: t('home'), home: t('home'),
testCases: t('test_cases'), testCases: t('test_cases'),
testRuns: t('test_runs'), testRuns: t('test_runs'),
members: t('members'),
settings: t('settings'), settings: t('settings'),
}; };

View File

@@ -0,0 +1,100 @@
'use client';
import React from 'react';
import { useState, useEffect, useContext } from 'react';
import { Button } from '@nextui-org/react';
import { Plus } from 'lucide-react';
import { MemberType, UserType } from '@/types/user';
import { MembersMessages } from '@/types/members';
import { TokenContext } from '@/utils/TokenProvider';
import MembersTable from './MembersTable';
import AddMemberDialog from './AddMemberDialog';
import { fetchProjectMembers, addMember, deleteMember, updateMember } from './membersControl';
type Props = {
projectId: string;
messages: MembersMessages;
locale: string;
};
export default function MembersPage({ projectId, messages, locale }: Props) {
const context = useContext(TokenContext);
const [members, setMembers] = useState<MemberType[]>([]);
const [isDialogOpen, setIsDialogOpen] = useState(false);
useEffect(() => {
async function fetchDataEffect() {
if (!context.isSignedIn()) {
return;
}
try {
const data = await fetchProjectMembers(context.token.access_token, projectId);
setMembers(data);
} catch (error: any) {
console.error('Error in effect:', error.message);
}
}
fetchDataEffect();
}, [context]);
const handleAddMember = async (userAdded: UserType) => {
const newMember = await addMember(context.token.access_token, userAdded.id, projectId);
newMember.User = userAdded;
const updateMembers = [...members];
updateMembers.push(newMember);
setMembers(updateMembers);
setIsDialogOpen(false);
};
const handleDeleteMember = async (userDeleted: UserType) => {
await deleteMember(context.token.access_token, userDeleted.id, projectId);
setMembers(members.filter((member) => member.User.id !== userDeleted.id));
};
const handleChangeRole = async (userEdit: UserType, role: number) => {
await updateMember(context.token.access_token, userEdit.id, projectId, role);
setMembers((prevMembers) => {
return prevMembers.map((member) => {
if (member.User.id === userEdit.id) {
return { ...member, role: role };
}
return member;
});
});
};
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">{messages.memberManagement}</h3>
<Button
startContent={<Plus size={16} />}
size="sm"
color="primary"
isDisabled={!context.isProjectManager(Number(projectId))}
onClick={() => setIsDialogOpen(true)}
>
{messages.addMember}
</Button>
</div>
<MembersTable
members={members}
isDisabled={!context.isProjectManager(Number(projectId))}
onChangeRole={handleChangeRole}
onDeleteMember={handleDeleteMember}
messages={messages}
/>
<AddMemberDialog
isOpen={isDialogOpen}
projectId={projectId}
onCancel={() => setIsDialogOpen(false)}
onAddMember={handleAddMember}
messages={messages}
/>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import MembersPage from './MembersPage';
import { useTranslations } from 'next-intl';
export default function Page({ params }: { params: { projectId: string; locale: string } }) {
const t = useTranslations('Members');
const messages = {
memberManagement: t('member_management'),
avatar: t('avatar'),
email: t('email'),
username: t('username'),
role: t('role'),
manager: t('manager'),
developer: t('developer'),
reporter: t('reporter'),
delete: t('delete'),
deleteMember: t('deleteMember'),
noMembersFound: t('no_members_found'),
addMember: t('add_member'),
userNameOrEmail: t('user_name_or_email'),
close: t('close'),
add: t('add'),
};
return (
<>
<MembersPage projectId={params.projectId} messages={messages} locale={params.locale} />
</>
);
}

View File

@@ -1,14 +1,15 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { useState, useEffect, useContext } from 'react'; import { useState, useEffect, useContext } from 'react';
import { Button } from '@nextui-org/react'; import { Button, Table, TableHeader, TableColumn, TableBody, TableRow, TableCell } from '@nextui-org/react';
import { Plus } from 'lucide-react'; import { Pencil, Trash } from 'lucide-react';
import { MemberType, UserType } from '@/types/user';
import { SettingsMessages } from '@/types/settings'; import { SettingsMessages } from '@/types/settings';
import { TokenContext } from '@/utils/TokenProvider'; import { TokenContext } from '@/utils/TokenProvider';
import MembersTable from './MembersTable'; import { deleteProject, fetchProject, updateProject } from '@/utils/projectsControl';
import AddMemberDialog from './AddMemberDialog'; import { ProjectType } from '@/types/project';
import { fetchProjectMembers, addMember, deleteMember, updateMember } from './membersControl'; import DeleteConfirmDialog from '@/components/DeleteConfirmDialog';
import { useRouter } from '@/src/navigation';
import ProjectDialog from '@/components/ProjectDialog';
type Props = { type Props = {
projectId: string; projectId: string;
@@ -18,8 +19,18 @@ type Props = {
export default function SettingsPage({ projectId, messages, locale }: Props) { export default function SettingsPage({ projectId, messages, locale }: Props) {
const context = useContext(TokenContext); const context = useContext(TokenContext);
const [members, setMembers] = useState<MemberType[]>([]); const router = useRouter();
const [isDialogOpen, setIsDialogOpen] = useState(false); const [project, setProject] = useState<ProjectType>({
id: 0,
name: '',
detail: '',
isPublic: false,
userId: 0,
createdAt: '',
updatedAt: '',
Folders: [],
Runs: [],
});
useEffect(() => { useEffect(() => {
async function fetchDataEffect() { async function fetchDataEffect() {
@@ -28,8 +39,8 @@ export default function SettingsPage({ projectId, messages, locale }: Props) {
} }
try { try {
const data = await fetchProjectMembers(context.token.access_token, projectId); const data = await fetchProject(context.token.access_token, Number(projectId));
setMembers(data); setProject(data);
} catch (error: any) { } catch (error: any) {
console.error('Error in effect:', error.message); console.error('Error in effect:', error.message);
} }
@@ -38,62 +49,87 @@ export default function SettingsPage({ projectId, messages, locale }: Props) {
fetchDataEffect(); fetchDataEffect();
}, [context]); }, [context]);
const handleAddMember = async (userAdded: UserType) => { // project dialog
const newMember = await addMember(context.token.access_token, userAdded.id, projectId); const [isProjectDialogOpen, setIsProjectDialogOpen] = useState(false);
newMember.User = userAdded; const onSubmit = async (name: string, detail: string, isPublic: boolean) => {
const updateMembers = [...members]; const updatedProject = await updateProject(context.token.access_token, project.id, name, detail, isPublic);
updateMembers.push(newMember); setProject(updatedProject);
setMembers(updateMembers); setIsProjectDialogOpen(false);
setIsDialogOpen(false);
}; };
const handleDeleteMember = async (userDeleted: UserType) => { // delete confirm dialog
await deleteMember(context.token.access_token, userDeleted.id, projectId); const [isDeleteConfirmDialogOpen, setIsDeleteConfirmDialogOpen] = useState(false);
setMembers(members.filter((member) => member.User.id !== userDeleted.id)); const onConfirm = async () => {
}; await deleteProject(context.token.access_token, Number(projectId));
setIsDeleteConfirmDialogOpen(false);
const handleChangeRole = async (userEdit: UserType, role: number) => { router.push(`/projects/`, { locale: locale });
await updateMember(context.token.access_token, userEdit.id, projectId, role);
setMembers((prevMembers) => {
return prevMembers.map((member) => {
if (member.User.id === userEdit.id) {
return { ...member, role: role };
}
return member;
});
});
}; };
return ( return (
<div className="container mx-auto max-w-3xl pt-16 px-6 flex-grow"> <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"> <div className="w-full p-3 flex items-center justify-between">
<h3 className="font-bold">{messages.memberManagement}</h3> <h3 className="font-bold">{messages.projectManagement}</h3>
<Button <div>
startContent={<Plus size={16} />} <Button
size="sm" startContent={<Trash size={16} />}
color="primary" size="sm"
isDisabled={!context.isProjectManager(Number(projectId))} color="danger"
onClick={() => setIsDialogOpen(true)} isDisabled={!context.isProjectManager(Number(projectId))}
> onClick={() => setIsDeleteConfirmDialogOpen(true)}
{messages.addMember} >
</Button> {messages.deleteProject}
</Button>
<Button
startContent={<Pencil size={16} />}
size="sm"
color="primary"
isDisabled={!context.isProjectManager(Number(projectId))}
onClick={() => setIsProjectDialogOpen(true)}
className="ms-2"
>
{messages.editProject}
</Button>
</div>
</div> </div>
<MembersTable <div className="w-full p-3">
members={members} <Table hideHeader aria-label="Example static collection table">
isDisabled={!context.isProjectManager(Number(projectId))} <TableHeader>
onChangeRole={handleChangeRole} <TableColumn>dummy</TableColumn>
onDeleteMember={handleDeleteMember} <TableColumn>dummy</TableColumn>
</TableHeader>
<TableBody>
<TableRow key="1">
<TableCell>{messages.projectName}</TableCell>
<TableCell>{project.name}</TableCell>
</TableRow>
<TableRow key="2">
<TableCell>{messages.projectDetail}</TableCell>
<TableCell>{project.detail}</TableCell>
</TableRow>
<TableRow key="3">
<TableCell>{messages.publicity}</TableCell>
<TableCell>{project.isPublic ? messages.public : messages.private}</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<ProjectDialog
isOpen={isProjectDialogOpen}
editingProject={project}
onCancel={() => setIsProjectDialogOpen(false)}
onSubmit={onSubmit}
messages={messages} messages={messages}
/> />
<AddMemberDialog <DeleteConfirmDialog
isOpen={isDialogOpen} isOpen={isDeleteConfirmDialogOpen}
projectId={projectId} onCancel={() => setIsDeleteConfirmDialogOpen(false)}
onCancel={() => setIsDialogOpen(false)} onConfirm={onConfirm}
onAddMember={handleAddMember} closeText={messages.close}
messages={messages} confirmText={messages.areYouSure}
deleteText={messages.delete}
/> />
</div> </div>
); );

View File

@@ -4,21 +4,20 @@ import { useTranslations } from 'next-intl';
export default function Page({ params }: { params: { projectId: string; locale: string } }) { export default function Page({ params }: { params: { projectId: string; locale: string } }) {
const t = useTranslations('Settings'); const t = useTranslations('Settings');
const messages = { const messages = {
memberManagement: t('member_management'), projectManagement: t('project_management'),
avatar: t('avatar'), projectName: t('project_name'),
email: t('email'), projectDetail: t('project_detail'),
username: t('username'), editProject: t('edit_project'),
role: t('role'), project: t('project'),
manager: t('manager'), ifYouMakePublic: t('if_you_make_public'),
developer: t('developer'), public: t('public'),
reporter: t('reporter'), publicity: t('publicity'),
private: t('private'),
update: t('update'),
deleteProject: t('delete_project'),
delete: t('delete'), delete: t('delete'),
deleteMember: t('deleteMember'),
noMembersFound: t('no_members_found'),
addMember: t('add_member'),
userNameOrEmail: t('user_name_or_email'),
close: t('close'), close: t('close'),
add: t('add'), areYouSure: t('are_you_sure'),
}; };
return ( return (

17
frontend/types/member.ts Normal file
View File

@@ -0,0 +1,17 @@
export type MembersMessages = {
memberManagement: string;
avatar: string;
email: string;
username: string;
role: string;
manager: string;
developer: string;
reporter: string;
delete: string;
deleteMember: string;
noMembersFound: string;
addMember: string;
userNameOrEmail: string;
close: string;
add: string;
};

View File

@@ -5,7 +5,7 @@ export type ProjectType = {
id: number; id: number;
name: string; name: string;
detail: string; detail: string;
isPublic: string; isPublic: boolean;
userId: number; userId: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;

View File

@@ -1,17 +1,16 @@
export type SettingsMessages = { export type SettingsMessages = {
memberManagement: string; projectManagement: string;
avatar: string; projectName: string;
email: string; projectDetail: string;
username: string; editProject: string;
role: string; project: string;
manager: string; publicity: string;
developer: string; public: string;
reporter: string; private: string;
ifYouMakePublic: string;
update: string;
deleteProject: string;
delete: string; delete: string;
deleteMember: string;
noMembersFound: string;
addMember: string;
userNameOrEmail: string;
close: string; close: string;
add: string; areYouSure: string;
}; };

View File

@@ -0,0 +1,149 @@
import Config from '@/config/config';
const apiServer = Config.apiServer;
/**
* fetch project
*/
async function fetchProject(jwt: string, projectId: number) {
const url = `${apiServer}/projects/${projectId}`;
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwt}`,
},
});
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);
}
}
/**
* fetch project records
*/
async function fetchProjects(jwt: string) {
const url = `${apiServer}/projects`;
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwt}`,
},
});
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(jwt: string, name: string, detail: string, isPublic: boolean) {
const newProjectData = {
name,
detail,
isPublic,
};
const fetchOptions = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwt}`,
},
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(jwt: string, projectId: number, name: string, detail: string, isPublic: boolean) {
const updatedProjectData = {
name,
detail,
isPublic,
};
const fetchOptions = {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwt}`,
},
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(jwt: string, projectId: number) {
const fetchOptions = {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwt}`,
},
};
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 { fetchProject, fetchProjects, createProject, updateProject, deleteProject };