diff --git a/backend/index.js b/backend/index.js index 5670a27..0bf44a5 100644 --- a/backend/index.js +++ b/backend/index.js @@ -30,9 +30,11 @@ app.use('/', indexRoute); // "users" const usersIndexRoute = require('./routes/users/index')(sequelize); +const usersFindRoute = require('./routes/users/find')(sequelize); const signUpRoute = require('./routes/users/signup')(sequelize); const signInRoute = require('./routes/users/signin')(sequelize); app.use('/users', usersIndexRoute); +app.use('/users', usersFindRoute); app.use('/users', signUpRoute); app.use('/users', signInRoute); diff --git a/backend/routes/members/new.js b/backend/routes/members/new.js index bfdbf7b..b517d51 100644 --- a/backend/routes/members/new.js +++ b/backend/routes/members/new.js @@ -2,6 +2,7 @@ const express = require('express'); const router = express.Router(); const defineMember = require('../../models/members'); const { DataTypes } = require('sequelize'); +const { memberRoles } = require('../../routes/users/authSettings'); module.exports = function (sequelize) { const { verifySignedIn, verifyProjectManager } = require('../../middleware/auth')(sequelize); @@ -24,9 +25,11 @@ module.exports = function (sequelize) { return res.status(400).send('Record already exists'); } + const managerRoleIndex = memberRoles.findIndex((entry) => entry.uid === 'reporter'); const newMember = await Member.create({ userId: userId, projectId: projectId, + role: managerRoleIndex, }); res.json(newMember); diff --git a/backend/routes/users/find.js b/backend/routes/users/find.js new file mode 100644 index 0000000..92ea8b3 --- /dev/null +++ b/backend/routes/users/find.js @@ -0,0 +1,41 @@ +const express = require('express'); +const router = express.Router(); +const defineUser = require('../../models/users'); +const { DataTypes, Op } = require('sequelize'); + +module.exports = function (sequelize) { + const { verifySignedIn } = require('../../middleware/auth')(sequelize); + const User = defineUser(sequelize, DataTypes); + + router.get('/find', verifySignedIn, async (req, res) => { + try { + const { search, excludeIds } = req.query; + if (!search) { + return res.status(400).json({ error: 'search text is required' }); + } + + let where = {}; + if (search) { + where = { + [Op.or]: [{ email: { [Op.like]: `%${search}%` } }, { username: { [Op.like]: `%${search}%` } }], + }; + } + + if (excludeIds) { + const excludeIdArray = excludeIds.split(',').map((id) => parseInt(id, 10)); + where.id = { [Op.notIn]: excludeIdArray }; + } + + const users = await User.findAll({ + where, + limit: 7, + }); + res.json(users); + } catch (error) { + console.error(error); + res.status(500).send('Internal Server Error'); + } + }); + + return router; +}; diff --git a/backend/seeders/seed.js b/backend/seeders/seed.js index 71d6984..62af43e 100644 --- a/backend/seeders/seed.js +++ b/backend/seeders/seed.js @@ -17,6 +17,69 @@ module.exports = { createdAt: new Date(), updatedAt: new Date(), }, + { + email: 'samuel@testplat.com', + password: hashedPassword, + username: 'Samuel Golden', + role: 1, + avatarPath: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + email: 'trey@testplat.com', + password: hashedPassword, + username: 'Trey Fisher', + role: 1, + avatarPath: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + email: 'zoe@testplat.com', + password: hashedPassword, + username: 'Zoe Woodward', + role: 1, + avatarPath: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + email: 'roger@testplat.com', + password: hashedPassword, + username: 'Roger Hess', + role: 1, + avatarPath: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + email: 'jasmine@testplat.com', + password: hashedPassword, + username: 'Jasmine Moody', + role: 1, + avatarPath: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + email: 'tatsuya@testplat.com', + password: hashedPassword, + username: 'Suzuki Tatsuya', + role: 1, + avatarPath: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + email: 'eri@testplat.com', + password: hashedPassword, + username: 'Sato Eri', + role: 1, + avatarPath: null, + createdAt: new Date(), + updatedAt: new Date(), + }, ]); // Add projects table records diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 0f7c569..baf6780 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -267,6 +267,10 @@ "manager": "Manager", "developer": "Developer", "reporter": "Reporter", - "no_members_found": "No members found" + "no_members_found": "No members found", + "add_member": "Add member", + "user_name_or_email": "User name or email", + "close": "Close", + "add": "Add" } } diff --git a/frontend/messages/ja.json b/frontend/messages/ja.json index 7109a8a..6dfef32 100644 --- a/frontend/messages/ja.json +++ b/frontend/messages/ja.json @@ -266,6 +266,10 @@ "manager": "管理者", "developer": "開発者", "reporter": "報告者", - "no_members_found": "メンバーがいません" + "no_members_found": "メンバーがいません", + "add_member": "メンバーの追加", + "user_name_or_email": "ユーザー名またはメールアドレス", + "close": "閉じる", + "add": "追加" } } diff --git a/frontend/src/app/[locale]/projects/[projectId]/settings/AddMemberDialog.tsx b/frontend/src/app/[locale]/projects/[projectId]/settings/AddMemberDialog.tsx new file mode 100644 index 0000000..569f8c9 --- /dev/null +++ b/frontend/src/app/[locale]/projects/[projectId]/settings/AddMemberDialog.tsx @@ -0,0 +1,103 @@ +'use client'; +import React from 'react'; +import { useState, useEffect, useContext } from 'react'; +import { Button, Input, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from '@nextui-org/react'; +import { SettingsMessages } from '@/types/settings'; +import { TokenContext } from '@/utils/TokenProvider'; +import Config from '@/config/config'; +import { MemberType, UserType } from '@/types/user'; +import CandidatesTable from './CandidatesTable'; +const apiServer = Config.apiServer; + +type Props = { + isOpen: boolean; + members: MemberType[]; + onCancel: () => void; + onAddMember: (memberAdded: UserType) => void; + messages: SettingsMessages; +}; + +// User Search by username +async function searchUsers(jwt: string, text: string, excludeIdsParam: string) { + const fetchOptions = { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + }; + + const url = `${apiServer}/users/find?search=${text}&excludeIds=${excludeIdsParam}`; + + 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 fetching data:', error.message); + } +} + +export default function AddMemberDialog({ isOpen, members, onCancel, onAddMember, messages }: Props) { + const context = useContext(TokenContext); + const [searchText, setSearchText] = useState(''); + const [candidates, setCandidates] = useState([]); + + useEffect(() => { + async function fetchDataEffect() { + if (!searchText) { + return; + } + + if (!context.isSignedIn()) { + return; + } + + try { + const excludeIds = members.map((member) => { + return member.id; + }); + const excludeIdsParam = excludeIds.join(','); + const data = await searchUsers(context.token.access_token, searchText, excludeIdsParam); + setCandidates(data); + } catch (error: any) { + console.error('Error in effect:', error.message); + } + } + + fetchDataEffect(); + }, [searchText]); + + return ( + { + onCancel(); + }} + size="2xl" + > + + {messages.addMember} + + { + setSearchText(e.target.value); + }} + /> + + + + + + + + ); +} diff --git a/frontend/src/app/[locale]/projects/[projectId]/settings/CandidatesTable.tsx b/frontend/src/app/[locale]/projects/[projectId]/settings/CandidatesTable.tsx new file mode 100644 index 0000000..0be9dc8 --- /dev/null +++ b/frontend/src/app/[locale]/projects/[projectId]/settings/CandidatesTable.tsx @@ -0,0 +1,90 @@ +import { useMemo, useCallback } from 'react'; +import { Button, Table, TableHeader, TableColumn, TableBody, TableRow, TableCell } from '@nextui-org/react'; +import { UserType } from '@/types/user'; +import { SettingsMessages } from '@/types/settings'; +import Avatar from 'boring-avatars'; + +type Props = { + candidates: UserType[]; + onAddPress: (memberAdded: UserType) => void; + messages: SettingsMessages; +}; + +export default function MembersTable({ candidates, onAddPress, messages }: Props) { + const headerColumns = [ + { name: messages.avatar, uid: 'avatar' }, + { name: messages.email, uid: 'email' }, + { name: messages.username, uid: 'username' }, + { name: messages.add, uid: 'add' }, + ]; + + const renderCell = useCallback((candidate: UserType, columnKey: Key) => { + const cellValue = candidate[columnKey as keyof UserType]; + + switch (columnKey) { + case 'avatar': + return ( + + ); + case 'email': + return cellValue; + case 'username': + return cellValue; + case 'add': + return ( + + ); + 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 ( + <> + + + {(column) => ( + + {column.name} + + )} + + + {(item) => ( + {(columnKey) => {renderCell(item, columnKey)}} + )} + +
+ + ); +} diff --git a/frontend/src/app/[locale]/projects/[projectId]/settings/MembersTable.tsx b/frontend/src/app/[locale]/projects/[projectId]/settings/MembersTable.tsx index 7a5b31a..8f82f4b 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/settings/MembersTable.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/settings/MembersTable.tsx @@ -1,17 +1,16 @@ import { useState, useMemo, useCallback } from 'react'; import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell, SortDescriptor } from '@nextui-org/react'; -import { UserType } from '@/types/user'; +import { MemberType, UserType } from '@/types/user'; import { SettingsMessages } from '@/types/settings'; import { memberRoles } from '@/config/selection'; import Avatar from 'boring-avatars'; type Props = { - members: UserType[]; + members: MemberType[]; messages: SettingsMessages; - locale: string; }; -export default function MembersTable({ members, messages, locale }: Props) { +export default function MembersTable({ members, messages }: Props) { const headerColumns = [ { name: messages.avatar, uid: 'avatar', sortable: false }, { name: messages.email, uid: 'email', sortable: true }, diff --git a/frontend/src/app/[locale]/projects/[projectId]/settings/SettingsPage.tsx b/frontend/src/app/[locale]/projects/[projectId]/settings/SettingsPage.tsx index e78a390..c1a64db 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/settings/SettingsPage.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/settings/SettingsPage.tsx @@ -1,11 +1,14 @@ 'use client'; import React from 'react'; import { useState, useEffect, useContext } from 'react'; -import { UserType } from '@/types/user'; +import { Button } from '@nextui-org/react'; +import { Plus } from 'lucide-react'; +import { MemberType, UserType } from '@/types/user'; import { SettingsMessages } from '@/types/settings'; import { TokenContext } from '@/utils/TokenProvider'; import MembersTable from './MembersTable'; import Config from '@/config/config'; +import AddMemberDialog from './AddMemberDialog'; const apiServer = Config.apiServer; type Props = { @@ -32,24 +35,22 @@ async function fetchProjectMembers(jwt: string, projectId: string) { throw new Error(`HTTP error! Status: ${response.status}`); } const data = await response.json(); - console.log(data); return data; } catch (error: any) { console.error('Error fetching data:', error.message); } } -// User Search by username -async function fetchUsers(jwt: string, text: string) { +async function addMember(jwt: string, userId: string, projectId: string) { const fetchOptions = { - method: 'GET', + method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${jwt}`, }, }; - const url = `${apiServer}/users?text=${text}`; + const url = `${apiServer}/members?userId=${userId}&projectId=${projectId}`; try { const response = await fetch(url, fetchOptions); @@ -65,7 +66,8 @@ async function fetchUsers(jwt: string, text: string) { export default function SettingsPage({ projectId, messages, locale }: Props) { const context = useContext(TokenContext); - const [members, setMembers] = useState([]); + const [members, setMembers] = useState([]); + const [isDialogOpen, setIsDialogOpen] = useState(false); useEffect(() => { async function fetchDataEffect() { @@ -84,13 +86,34 @@ export default function SettingsPage({ projectId, messages, locale }: Props) { fetchDataEffect(); }, [context]); + const handleAddMember = async (memberAdded: UserType) => { + const newMember = await addMember(context.token.access_token, memberAdded.id, projectId); + newMember.User = memberAdded; + const updateMembers = [...members]; + updateMembers.push(newMember); + setMembers(updateMembers); + + setIsDialogOpen(false); + }; + return (

{messages.memberManagement}

+
- + + + setIsDialogOpen(false)} + onAddMember={handleAddMember} + messages={messages} + />
); } diff --git a/frontend/src/app/[locale]/projects/[projectId]/settings/page.tsx b/frontend/src/app/[locale]/projects/[projectId]/settings/page.tsx index f1b5c89..d257a53 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/settings/page.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/settings/page.tsx @@ -13,6 +13,10 @@ export default function Page({ params }: { params: { projectId: string; locale: developer: t('developer'), reporter: t('reporter'), noMembersFound: t('no_members_found'), + addMember: t('add_member'), + userNameOrEmail: t('user_name_or_email'), + close: t('close'), + add: t('add'), }; return ( diff --git a/frontend/types/settings.ts b/frontend/types/settings.ts index a79f38e..712ff67 100644 --- a/frontend/types/settings.ts +++ b/frontend/types/settings.ts @@ -5,4 +5,8 @@ export type SettingsMessages = { username: string; role: string; noMembersFound: string; + addMember: string; + userNameOrEmail: string; + close: string; + add: string; }; diff --git a/frontend/types/user.ts b/frontend/types/user.ts index ec4cb9b..2ca3727 100644 --- a/frontend/types/user.ts +++ b/frontend/types/user.ts @@ -68,3 +68,11 @@ export type AccountDropDownMessages = { signIn: string; signOut: string; }; + +export type MemberType = { + id: number | null; + userId: number; + projectId: number; + role: number; + User: UserType; +};