Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/frontend-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ jobs:
- name: Build & Deploy
run: |
npm run build
# npx vercel --prod --yes --token=${{ secrets.TRELLO_FRONTEND_VERCEL_TOKEN }}
# env:
# VERCEL_ORG_ID: ${{ vars.TRELLO_FRONTEND_VERCEL_ORG_ID }}
# VERCEL_PROJECT_ID: ${{ vars.TRELLO_FRONTEND_VERCEL_PROJECT_ID }}
# VITE_TRELLO_LIKE_API_URL: ${{ vars.VITE_TRELLO_LIKE_API_URL }}
npx vercel --prod --yes --token=${{ secrets.TRELLO_FRONTEND_VERCEL_TOKEN }}
env:
VERCEL_ORG_ID: ${{ vars.TRELLO_FRONTEND_VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ vars.TRELLO_FRONTEND_VERCEL_PROJECT_ID }}
VITE_TRELLO_LIKE_API_URL: ${{ vars.VITE_TRELLO_LIKE_API_URL }}
12 changes: 12 additions & 0 deletions backend/app/dto/response/task/AssignedMemberResponse.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package dto.response.task

import play.api.libs.json.{Format, Json}

case class AssignedMemberResponse(
id: Int,
name: String
)

object AssignedMemberResponse {
implicit val format: Format[AssignedMemberResponse] = Json.format[AssignedMemberResponse]
}
3 changes: 2 additions & 1 deletion backend/app/dto/response/task/TaskDetailResponse.scala
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ case class TaskDetailResponse(id: Int,
columnId: Int,
isCompleted: Boolean,
createdAt: Instant,
updatedAt: Instant
updatedAt: Instant,
assignedMembers: Seq[AssignedMemberResponse] = Seq.empty
)

object TaskDetailResponse {
Expand Down
19 changes: 18 additions & 1 deletion backend/app/mappers/TaskMapper.scala
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package mappers

import dto.response.task.TaskDetailResponse
import dto.response.task.{AssignedMemberResponse, TaskDetailResponse}
import models.entities.Task

object TaskMapper {
Expand All @@ -21,5 +21,22 @@ object TaskMapper {
updatedAt = entity.updatedAt
)
}
def toDetailWithAssignMembersResponse(entity: Task,assignedMembers: Seq[AssignedMemberResponse]): TaskDetailResponse = {
TaskDetailResponse(
id = entity.id.getOrElse(0),
name = entity.name,
description = entity.description,
startDate = entity.startDate,
endDate = entity.endDate,
priority = entity.priority.map(_.toString),
status = entity.status.toString,
position = entity.position.getOrElse(0),
columnId = entity.columnId,
isCompleted = entity.isCompleted,
createdAt = entity.createdAt,
updatedAt = entity.updatedAt,
assignedMembers = assignedMembers
)
}

}
12 changes: 10 additions & 2 deletions backend/app/repositories/TaskRepository.scala
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
package repositories

import db.MyPostgresProfile.api.{columnStatusTypeMapper, projectStatusTypeMapper, taskStatusTypeMapper}
import dto.response.task.AssignMemberToTaskResponse
import dto.response.task.TaskSummaryResponse
import dto.response.task.{AssignMemberToTaskResponse, AssignedMemberResponse, TaskSummaryResponse}
import models.Enums.{ColumnStatus, ProjectStatus, TaskStatus}
import models.entities.{Task, UserTask}
import play.api.db.slick.{DatabaseConfigProvider, HasDatabaseConfigProvider}
Expand Down Expand Up @@ -45,6 +44,15 @@ class TaskRepository@Inject()(
query.result.headOption
}

def findAssignedMembers(taskId: Int): DBIO[Seq[AssignedMemberResponse]] = {
val query = for {
ut <- userTasks if ut.taskId === taskId
u <- users if u.id === ut.assignedTo
} yield (u.id, u.name)

query.result.map(_.map { case (id, name) => AssignedMemberResponse(id, name) })
}

def update(task: Task): DBIO[Int] = {
tasks.filter(_.id === task.id).update(task)
}
Expand Down
8 changes: 5 additions & 3 deletions backend/app/services/TaskService.scala
Original file line number Diff line number Diff line change
Expand Up @@ -124,11 +124,13 @@ class TaskService @Inject()(taskRepository: TaskRepository,

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

db.run(action.transactionally).map {
case (task, _) => Some(TaskMapper.toDetailResponse(task))
case (task, assignedMembers) =>
Some(TaskMapper.toDetailWithAssignMembersResponse(task, assignedMembers))
}
}

Expand Down
1 change: 1 addition & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
</head>
<body>
<div id="root"></div>
<div id="portal"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
211 changes: 211 additions & 0 deletions frontend/src/components/board/AssignMembers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import { fetchBoardMembers } from "@/services/boardService";
import taskService from "@/services/taskService";
import { notify } from "@/services/toastService";
import type { Member } from "@/types";
import React, { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useParams } from "react-router-dom";

interface AssignMembersProps {
assignedMembers?: Member[];
taskId: number;
}

const AssignMembers: React.FC<AssignMembersProps> = (props) => {
const {boardId} = useParams();
const [assignedMembers, setAssignedMembers] = useState<Member[]>(props?.assignedMembers || []);
const [members, setMembers] = useState<Member[]>([])
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState("");
const buttonRef = useRef<HTMLButtonElement>(null);
const popupRef = useRef<HTMLDivElement>(null);
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null);

const fetchMemebers = async () => {
try {
const membersData = await fetchBoardMembers(Number(boardId));
setMembers(membersData.data);
} catch (error: any) {
notify.error(error.response?.data?.message);
}
}

const toggleAssign = async (member: Member) => {
try {
const isAssigned = assignedMembers.some(am => am.id === member.id);

if (isAssigned) {
// remove
await taskService.removeMember(Number(boardId), props.taskId, member.id);
setAssignedMembers(prev => prev.filter(am => am.id !== member.id));
notify.success(`${member.name} removed`);
} else {
// assign
await taskService.assignMember(Number(boardId), props.taskId, member.id);
setAssignedMembers(prev => [...prev, member]);
notify.success(`${member.name} assigned`);
}
} catch (error: any) {
notify.error(error.response?.data?.message || "Something went wrong");
}
};

useEffect(() => {
fetchMemebers();
}, [boardId]);

Check warning on line 55 in frontend/src/components/board/AssignMembers.tsx

View workflow job for this annotation

GitHub Actions / build-and-deploy

React Hook useEffect has a missing dependency: 'fetchMemebers'. Either include it or remove the dependency array

const filtered = members.filter((m) =>
m.name.toLowerCase().includes(search.toLowerCase())
);

// Calculate popup position
useEffect(() => {
if (isOpen && buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
setCoords({
top: rect.bottom + window.scrollY,
left: rect.left + window.scrollX,
});
}
}, [isOpen]);

// turn off popup when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
popupRef.current &&
!popupRef.current.contains(event.target as Node) &&
buttonRef.current &&
!buttonRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
}

if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
} else {
document.removeEventListener("mousedown", handleClickOutside);
}

return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isOpen]);

return (

<div className="pr-2 pb-4">
<h3 className="text-sm font-medium text-white mb-2">Members</h3>
<div className="flex items-center gap-1 mb-1">
{
assignedMembers?.map((assignedMember) => (
<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'>
<span className='text-xs text-white font-medium'>
{assignedMember.name.charAt(0).toUpperCase()}
</span>
</div>
))
}

<button
ref={buttonRef}
onClick={() => setIsOpen(!isOpen)}
className="w-8 h-8 bg-gray-600 bg-opacity-50 hover:bg-opacity-70
text-gray-300 text-lg rounded-full flex items-center
justify-center leading-none"
>
+
</button>

{/* Popup render outside by Portal */}
{isOpen &&
coords &&
createPortal(
<div
ref={popupRef}
style={{
position: "absolute",
top: coords.top,
left: coords.left,
zIndex: 9999,
}}
className="border border-gray-600 bg-[#282e3e] w-64 rounded-lg shadow-2xl p-4 mt-2"
>
{/* Header */}
<div className="flex justify-between items-center border-b pb-2">
<h2 className="text-lg text-white font-semibold">Assign Members</h2>
<button
onClick={() => setIsOpen(false)}
className="text-gray-400 hover:text-white"
>
</button>
</div>

{/* Search */}
<input
type="text"
placeholder="Search members..."
value={search}
onChange={(e) => setSearch(e.target.value)}
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"
/>

{/* List members */}
<ul className="space-y-2 max-h-60 overflow-y-auto">
{/* Assigned Members */}
{filtered.filter(m => assignedMembers.some(am => am.id === m.id)).length > 0 && (
<>
<li className="text-gray-500 text-xs font-semibold px-2">Assigned</li>
{filtered
.filter(m => assignedMembers.some(am => am.id === m.id))
.map(m => (
<li
key={m.id}
onClick={() => toggleAssign(m)}
className="flex items-center justify-between px-2 py-1 rounded bg-gray-800"
>
<span className="text-white">{m.name}</span>
<span className="text-green-400 text-xs">✓</span>
</li>
))}
</>
)}

{/* Unassigned Members */}
{filtered.filter(m => !assignedMembers.some(am => am.id === m.id)).length > 0 && (
<>
<li className="text-gray-500 text-xs font-semibold px-2 mt-2">Unassigned</li>
{filtered
.filter(m => !assignedMembers.some(am => am.id === m.id))
.map(m => (
<li
key={m.id}
onClick={() => toggleAssign(m)}
className="flex items-center justify-between px-2 py-1 rounded hover:bg-gray-700 cursor-pointer"
>
<span className="text-gray-300">{m.name}</span>
</li>
))}
</>
)}

{/* Empty state */}
{filtered.length === 0 && (
<li className="text-gray-400 text-sm text-center">
No members found
</li>
)}
</ul>
</div>,
document.getElementById("portal")!
)}

</div>
</div>

);
};

export default AssignMembers;
Loading
Loading