Skip to content

Commit 3bd40c6

Browse files
feat/FE: assign member into a task (#18)
1 parent 8094040 commit 3bd40c6

File tree

12 files changed

+306
-29
lines changed

12 files changed

+306
-29
lines changed

.github/workflows/frontend-ci.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,8 @@ jobs:
5050
- name: Build & Deploy
5151
run: |
5252
npm run build
53-
# npx vercel --prod --yes --token=${{ secrets.TRELLO_FRONTEND_VERCEL_TOKEN }}
54-
# env:
55-
# VERCEL_ORG_ID: ${{ vars.TRELLO_FRONTEND_VERCEL_ORG_ID }}
56-
# VERCEL_PROJECT_ID: ${{ vars.TRELLO_FRONTEND_VERCEL_PROJECT_ID }}
57-
# VITE_TRELLO_LIKE_API_URL: ${{ vars.VITE_TRELLO_LIKE_API_URL }}
53+
npx vercel --prod --yes --token=${{ secrets.TRELLO_FRONTEND_VERCEL_TOKEN }}
54+
env:
55+
VERCEL_ORG_ID: ${{ vars.TRELLO_FRONTEND_VERCEL_ORG_ID }}
56+
VERCEL_PROJECT_ID: ${{ vars.TRELLO_FRONTEND_VERCEL_PROJECT_ID }}
57+
VITE_TRELLO_LIKE_API_URL: ${{ vars.VITE_TRELLO_LIKE_API_URL }}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package dto.response.task
2+
3+
import play.api.libs.json.{Format, Json}
4+
5+
case class AssignedMemberResponse(
6+
id: Int,
7+
name: String
8+
)
9+
10+
object AssignedMemberResponse {
11+
implicit val format: Format[AssignedMemberResponse] = Json.format[AssignedMemberResponse]
12+
}

backend/app/dto/response/task/TaskDetailResponse.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ case class TaskDetailResponse(id: Int,
1515
columnId: Int,
1616
isCompleted: Boolean,
1717
createdAt: Instant,
18-
updatedAt: Instant
18+
updatedAt: Instant,
19+
assignedMembers: Seq[AssignedMemberResponse] = Seq.empty
1920
)
2021

2122
object TaskDetailResponse {

backend/app/mappers/TaskMapper.scala

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package mappers
22

3-
import dto.response.task.TaskDetailResponse
3+
import dto.response.task.{AssignedMemberResponse, TaskDetailResponse}
44
import models.entities.Task
55

66
object TaskMapper {
@@ -21,5 +21,22 @@ object TaskMapper {
2121
updatedAt = entity.updatedAt
2222
)
2323
}
24+
def toDetailWithAssignMembersResponse(entity: Task,assignedMembers: Seq[AssignedMemberResponse]): TaskDetailResponse = {
25+
TaskDetailResponse(
26+
id = entity.id.getOrElse(0),
27+
name = entity.name,
28+
description = entity.description,
29+
startDate = entity.startDate,
30+
endDate = entity.endDate,
31+
priority = entity.priority.map(_.toString),
32+
status = entity.status.toString,
33+
position = entity.position.getOrElse(0),
34+
columnId = entity.columnId,
35+
isCompleted = entity.isCompleted,
36+
createdAt = entity.createdAt,
37+
updatedAt = entity.updatedAt,
38+
assignedMembers = assignedMembers
39+
)
40+
}
2441

2542
}

backend/app/repositories/TaskRepository.scala

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
package repositories
22

33
import db.MyPostgresProfile.api.{columnStatusTypeMapper, projectStatusTypeMapper, taskStatusTypeMapper}
4-
import dto.response.task.AssignMemberToTaskResponse
5-
import dto.response.task.TaskSummaryResponse
4+
import dto.response.task.{AssignMemberToTaskResponse, AssignedMemberResponse, TaskSummaryResponse}
65
import models.Enums.{ColumnStatus, ProjectStatus, TaskStatus}
76
import models.entities.{Task, UserTask}
87
import play.api.db.slick.{DatabaseConfigProvider, HasDatabaseConfigProvider}
@@ -45,6 +44,15 @@ class TaskRepository@Inject()(
4544
query.result.headOption
4645
}
4746

47+
def findAssignedMembers(taskId: Int): DBIO[Seq[AssignedMemberResponse]] = {
48+
val query = for {
49+
ut <- userTasks if ut.taskId === taskId
50+
u <- users if u.id === ut.assignedTo
51+
} yield (u.id, u.name)
52+
53+
query.result.map(_.map { case (id, name) => AssignedMemberResponse(id, name) })
54+
}
55+
4856
def update(task: Task): DBIO[Int] = {
4957
tasks.filter(_.id === task.id).update(task)
5058
}

backend/app/services/TaskService.scala

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,11 +124,13 @@ class TaskService @Inject()(taskRepository: TaskRepository,
124124

125125
def getTaskDetailById(taskId: Int, userId: Int): Future[Option[TaskDetailResponse]] = {
126126
val action = for {
127-
result <- getTaskAndProjectByTaskId(taskId, userId)
128-
} yield result
127+
(task, _) <- getTaskAndProjectByTaskId(taskId, userId)
128+
assignedMembers <- taskRepository.findAssignedMembers(taskId)
129+
} yield (task, assignedMembers)
129130

130131
db.run(action.transactionally).map {
131-
case (task, _) => Some(TaskMapper.toDetailResponse(task))
132+
case (task, assignedMembers) =>
133+
Some(TaskMapper.toDetailWithAssignMembersResponse(task, assignedMembers))
132134
}
133135
}
134136

frontend/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
</head>
99
<body>
1010
<div id="root"></div>
11+
<div id="portal"></div>
1112
<script type="module" src="/src/main.tsx"></script>
1213
</body>
1314
</html>
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import { fetchBoardMembers } from "@/services/boardService";
2+
import taskService from "@/services/taskService";
3+
import { notify } from "@/services/toastService";
4+
import type { Member } from "@/types";
5+
import React, { useEffect, useRef, useState } from "react";
6+
import { createPortal } from "react-dom";
7+
import { useParams } from "react-router-dom";
8+
9+
interface AssignMembersProps {
10+
assignedMembers?: Member[];
11+
taskId: number;
12+
}
13+
14+
const AssignMembers: React.FC<AssignMembersProps> = (props) => {
15+
const {boardId} = useParams();
16+
const [assignedMembers, setAssignedMembers] = useState<Member[]>(props?.assignedMembers || []);
17+
const [members, setMembers] = useState<Member[]>([])
18+
const [isOpen, setIsOpen] = useState(false);
19+
const [search, setSearch] = useState("");
20+
const buttonRef = useRef<HTMLButtonElement>(null);
21+
const popupRef = useRef<HTMLDivElement>(null);
22+
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null);
23+
24+
const fetchMemebers = async () => {
25+
try {
26+
const membersData = await fetchBoardMembers(Number(boardId));
27+
setMembers(membersData.data);
28+
} catch (error: any) {
29+
notify.error(error.response?.data?.message);
30+
}
31+
}
32+
33+
const toggleAssign = async (member: Member) => {
34+
try {
35+
const isAssigned = assignedMembers.some(am => am.id === member.id);
36+
37+
if (isAssigned) {
38+
// remove
39+
await taskService.removeMember(Number(boardId), props.taskId, member.id);
40+
setAssignedMembers(prev => prev.filter(am => am.id !== member.id));
41+
notify.success(`${member.name} removed`);
42+
} else {
43+
// assign
44+
await taskService.assignMember(Number(boardId), props.taskId, member.id);
45+
setAssignedMembers(prev => [...prev, member]);
46+
notify.success(`${member.name} assigned`);
47+
}
48+
} catch (error: any) {
49+
notify.error(error.response?.data?.message || "Something went wrong");
50+
}
51+
};
52+
53+
useEffect(() => {
54+
fetchMemebers();
55+
}, [boardId]);
56+
57+
const filtered = members.filter((m) =>
58+
m.name.toLowerCase().includes(search.toLowerCase())
59+
);
60+
61+
// Calculate popup position
62+
useEffect(() => {
63+
if (isOpen && buttonRef.current) {
64+
const rect = buttonRef.current.getBoundingClientRect();
65+
setCoords({
66+
top: rect.bottom + window.scrollY,
67+
left: rect.left + window.scrollX,
68+
});
69+
}
70+
}, [isOpen]);
71+
72+
// turn off popup when clicking outside
73+
useEffect(() => {
74+
function handleClickOutside(event: MouseEvent) {
75+
if (
76+
popupRef.current &&
77+
!popupRef.current.contains(event.target as Node) &&
78+
buttonRef.current &&
79+
!buttonRef.current.contains(event.target as Node)
80+
) {
81+
setIsOpen(false);
82+
}
83+
}
84+
85+
if (isOpen) {
86+
document.addEventListener("mousedown", handleClickOutside);
87+
} else {
88+
document.removeEventListener("mousedown", handleClickOutside);
89+
}
90+
91+
return () => {
92+
document.removeEventListener("mousedown", handleClickOutside);
93+
};
94+
}, [isOpen]);
95+
96+
return (
97+
98+
<div className="pr-2 pb-4">
99+
<h3 className="text-sm font-medium text-white mb-2">Members</h3>
100+
<div className="flex items-center gap-1 mb-1">
101+
{
102+
assignedMembers?.map((assignedMember) => (
103+
<div key={assignedMember.id} title={assignedMember.name} className='w-8 h-8 rounded-full bg-[#282e3e] border-1 border-gray-400 shadow-sm flex items-center justify-center'>
104+
<span className='text-xs text-white font-medium'>
105+
{assignedMember.name.charAt(0).toUpperCase()}
106+
</span>
107+
</div>
108+
))
109+
}
110+
111+
<button
112+
ref={buttonRef}
113+
onClick={() => setIsOpen(!isOpen)}
114+
className="w-8 h-8 bg-gray-600 bg-opacity-50 hover:bg-opacity-70
115+
text-gray-300 text-lg rounded-full flex items-center
116+
justify-center leading-none"
117+
>
118+
+
119+
</button>
120+
121+
{/* Popup render outside by Portal */}
122+
{isOpen &&
123+
coords &&
124+
createPortal(
125+
<div
126+
ref={popupRef}
127+
style={{
128+
position: "absolute",
129+
top: coords.top,
130+
left: coords.left,
131+
zIndex: 9999,
132+
}}
133+
className="border border-gray-600 bg-[#282e3e] w-64 rounded-lg shadow-2xl p-4 mt-2"
134+
>
135+
{/* Header */}
136+
<div className="flex justify-between items-center border-b pb-2">
137+
<h2 className="text-lg text-white font-semibold">Assign Members</h2>
138+
<button
139+
onClick={() => setIsOpen(false)}
140+
className="text-gray-400 hover:text-white"
141+
>
142+
143+
</button>
144+
</div>
145+
146+
{/* Search */}
147+
<input
148+
type="text"
149+
placeholder="Search members..."
150+
value={search}
151+
onChange={(e) => setSearch(e.target.value)}
152+
className="w-full text-gray-400 border rounded px-2 py-1 mt-3 mb-3 bg-gray-700 border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
153+
/>
154+
155+
{/* List members */}
156+
<ul className="space-y-2 max-h-60 overflow-y-auto">
157+
{/* Assigned Members */}
158+
{filtered.filter(m => assignedMembers.some(am => am.id === m.id)).length > 0 && (
159+
<>
160+
<li className="text-gray-500 text-xs font-semibold px-2">Assigned</li>
161+
{filtered
162+
.filter(m => assignedMembers.some(am => am.id === m.id))
163+
.map(m => (
164+
<li
165+
key={m.id}
166+
onClick={() => toggleAssign(m)}
167+
className="flex items-center justify-between px-2 py-1 rounded bg-gray-800"
168+
>
169+
<span className="text-white">{m.name}</span>
170+
<span className="text-green-400 text-xs"></span>
171+
</li>
172+
))}
173+
</>
174+
)}
175+
176+
{/* Unassigned Members */}
177+
{filtered.filter(m => !assignedMembers.some(am => am.id === m.id)).length > 0 && (
178+
<>
179+
<li className="text-gray-500 text-xs font-semibold px-2 mt-2">Unassigned</li>
180+
{filtered
181+
.filter(m => !assignedMembers.some(am => am.id === m.id))
182+
.map(m => (
183+
<li
184+
key={m.id}
185+
onClick={() => toggleAssign(m)}
186+
className="flex items-center justify-between px-2 py-1 rounded hover:bg-gray-700 cursor-pointer"
187+
>
188+
<span className="text-gray-300">{m.name}</span>
189+
</li>
190+
))}
191+
</>
192+
)}
193+
194+
{/* Empty state */}
195+
{filtered.length === 0 && (
196+
<li className="text-gray-400 text-sm text-center">
197+
No members found
198+
</li>
199+
)}
200+
</ul>
201+
</div>,
202+
document.getElementById("portal")!
203+
)}
204+
205+
</div>
206+
</div>
207+
208+
);
209+
};
210+
211+
export default AssignMembers;

0 commit comments

Comments
 (0)