From 300d4763f59c0d54371bf13bbe37472102bc2551 Mon Sep 17 00:00:00 2001 From: shancds Date: Wed, 23 Jul 2025 10:49:00 +0530 Subject: [PATCH] Enhance project member management with search functionality and localization updates - Implemented search functionality for project members in the backend, allowing users to filter members by name or email. - Updated frontend components to include a search input for members, improving user experience. - Added localization strings for the search placeholder in multiple languages (Albanian, German, English, Spanish, Portuguese). - Refactored SQL queries for better performance and clarity in fetching project members. --- .../src/controllers/projects-controller.ts | 109 ++++++++---------- .../locales/alb/project-view-members.json | 3 +- .../locales/de/project-view-members.json | 3 +- .../locales/en/project-view-members.json | 3 +- .../locales/es/project-view-members.json | 3 +- .../locales/pt/project-view-members.json | 3 +- .../members/project-view-members.tsx | 68 +++++++---- 7 files changed, 106 insertions(+), 86 deletions(-) diff --git a/worklenz-backend/src/controllers/projects-controller.ts b/worklenz-backend/src/controllers/projects-controller.ts index a350675e..da204832 100644 --- a/worklenz-backend/src/controllers/projects-controller.ts +++ b/worklenz-backend/src/controllers/projects-controller.ts @@ -71,7 +71,7 @@ export default class ProjectsController extends WorklenzControllerBase { return res.status(200).send(new ServerResponse(false, [], `Sorry, the free plan cannot have more than ${projectsLimit} projects.`)); } } - + const q = `SELECT create_project($1) AS project`; req.body.team_id = req.user?.team_id || null; @@ -317,65 +317,58 @@ export default class ProjectsController extends WorklenzControllerBase { @HandleExceptions() public static async getMembersByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const {sortField, sortOrder, size, offset} = this.toPaginationOptions(req.query, "name"); + const search = (req.query.search || "").toString().trim(); + + let searchFilter = ""; + const params = [req.params.id, req.user?.team_id ?? null, size, offset]; + if (search) { + searchFilter = ` + AND ( + (SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) ILIKE '%' || $5 || '%' + OR (SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) ILIKE '%' || $5 || '%' + ) + `; + params.push(search); + } const q = ` - SELECT ROW_TO_JSON(rec) AS members - FROM (SELECT COUNT(*) AS total, - (SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON) - FROM (SELECT project_members.id, - team_member_id, - (SELECT name - FROM team_member_info_view - WHERE team_member_info_view.team_member_id = tm.id), - (SELECT email - FROM team_member_info_view - WHERE team_member_info_view.team_member_id = tm.id) AS email, - u.avatar_url, - (SELECT COUNT(*) - FROM tasks - WHERE archived IS FALSE - AND project_id = project_members.project_id - AND id IN (SELECT task_id - FROM tasks_assignees - WHERE tasks_assignees.project_member_id = project_members.id)) AS all_tasks_count, - (SELECT COUNT(*) - FROM tasks - WHERE archived IS FALSE - AND project_id = project_members.project_id - AND id IN (SELECT task_id - FROM tasks_assignees - WHERE tasks_assignees.project_member_id = project_members.id) - AND status_id IN (SELECT id - FROM task_statuses - WHERE category_id = (SELECT id - FROM sys_task_status_categories - WHERE is_done IS TRUE))) AS completed_tasks_count, - EXISTS(SELECT email - FROM email_invitations - WHERE team_member_id = project_members.team_member_id - AND email_invitations.team_id = $2) AS pending_invitation, - (SELECT project_access_levels.name - FROM project_access_levels - WHERE project_access_levels.id = project_members.project_access_level_id) AS access, - (SELECT name FROM job_titles WHERE id = tm.job_title_id) AS job_title - FROM project_members - INNER JOIN team_members tm ON project_members.team_member_id = tm.id - LEFT JOIN users u ON tm.user_id = u.id - WHERE project_id = $1 - ORDER BY ${sortField} ${sortOrder} - LIMIT $3 OFFSET $4) t) AS data - FROM project_members - WHERE project_id = $1) rec; + WITH filtered_members AS ( + SELECT project_members.id, + team_member_id, + (SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) AS name, + (SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) AS email, + u.avatar_url, + (SELECT COUNT(*) FROM tasks WHERE archived IS FALSE AND project_id = project_members.project_id AND id IN (SELECT task_id FROM tasks_assignees WHERE tasks_assignees.project_member_id = project_members.id)) AS all_tasks_count, + (SELECT COUNT(*) FROM tasks WHERE archived IS FALSE AND project_id = project_members.project_id AND id IN (SELECT task_id FROM tasks_assignees WHERE tasks_assignees.project_member_id = project_members.id) AND status_id IN (SELECT id FROM task_statuses WHERE category_id = (SELECT id FROM sys_task_status_categories WHERE is_done IS TRUE))) AS completed_tasks_count, + EXISTS(SELECT email FROM email_invitations WHERE team_member_id = project_members.team_member_id AND email_invitations.team_id = $2) AS pending_invitation, + (SELECT project_access_levels.name FROM project_access_levels WHERE project_access_levels.id = project_members.project_access_level_id) AS access, + (SELECT name FROM job_titles WHERE id = tm.job_title_id) AS job_title + FROM project_members + INNER JOIN team_members tm ON project_members.team_member_id = tm.id + LEFT JOIN users u ON tm.user_id = u.id + WHERE project_id = $1 + ${search ? searchFilter : ""} + ) + SELECT + (SELECT COUNT(*) FROM filtered_members) AS total, + (SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON) + FROM ( + SELECT * FROM filtered_members + ORDER BY ${sortField} ${sortOrder} + LIMIT $3 OFFSET $4 + ) t + ) AS data `; - const result = await db.query(q, [req.params.id, req.user?.team_id ?? null, size, offset]); + + const result = await db.query(q, params); const [data] = result.rows; - for (const member of data?.members.data || []) { + for (const member of data?.data || []) { member.progress = member.all_tasks_count > 0 ? ((member.completed_tasks_count / member.all_tasks_count) * 100).toFixed(0) : 0; } - return res.status(200).send(new ServerResponse(true, data?.members || this.paginatedDatasetDefaultStruct)); + return res.status(200).send(new ServerResponse(true, data || this.paginatedDatasetDefaultStruct)); } @HandleExceptions() @@ -779,7 +772,7 @@ export default class ProjectsController extends WorklenzControllerBase { let groupJoin = ""; let groupByFields = ""; let groupOrderBy = ""; - + switch (groupBy) { case "client": groupField = "COALESCE(projects.client_id::text, 'no-client')"; @@ -888,13 +881,13 @@ export default class ProjectsController extends WorklenzControllerBase { ELSE p2.updated_at END) AS updated_at FROM projects p2 ${groupJoin.replace("projects.", "p2.")} - WHERE p2.team_id = $1 + WHERE p2.team_id = $1 AND ${groupField.replace("projects.", "p2.")} = ${groupField} - ${categories.replace("projects.", "p2.")} - ${statuses.replace("projects.", "p2.")} - ${isArchived.replace("projects.", "p2.")} - ${isFavorites.replace("projects.", "p2.")} - ${filterByMember.replace("projects.", "p2.")} + ${categories.replace("projects.", "p2.")} + ${statuses.replace("projects.", "p2.")} + ${isArchived.replace("projects.", "p2.")} + ${isFavorites.replace("projects.", "p2.")} + ${filterByMember.replace("projects.", "p2.")} ${searchQuery.replace("projects.", "p2.")} ORDER BY ${innerSortField} ${sortOrder} ) project_data diff --git a/worklenz-frontend/public/locales/alb/project-view-members.json b/worklenz-frontend/public/locales/alb/project-view-members.json index 239b77e9..b6406344 100644 --- a/worklenz-frontend/public/locales/alb/project-view-members.json +++ b/worklenz-frontend/public/locales/alb/project-view-members.json @@ -13,5 +13,6 @@ "deleteButtonTooltip": "Hiq nga projekti", "memberCount": "Anëtar", "membersCountPlural": "Anëtarë", - "emptyText": "Nuk ka bashkëngjitje në projekt." + "emptyText": "Nuk ka bashkëngjitje në projekt.", + "searchPlaceholder": "Kërko anëtarë" } diff --git a/worklenz-frontend/public/locales/de/project-view-members.json b/worklenz-frontend/public/locales/de/project-view-members.json index eee5d0a1..4e40f757 100644 --- a/worklenz-frontend/public/locales/de/project-view-members.json +++ b/worklenz-frontend/public/locales/de/project-view-members.json @@ -13,5 +13,6 @@ "deleteButtonTooltip": "Aus Projekt entfernen", "memberCount": "Mitglied", "membersCountPlural": "Mitglieder", - "emptyText": "Es gibt keine Anhänge in diesem Projekt." + "emptyText": "Es gibt keine Anhänge in diesem Projekt.", + "searchPlaceholder": "Mitglieder suchen" } diff --git a/worklenz-frontend/public/locales/en/project-view-members.json b/worklenz-frontend/public/locales/en/project-view-members.json index 6ed8ddf0..fd15ca71 100644 --- a/worklenz-frontend/public/locales/en/project-view-members.json +++ b/worklenz-frontend/public/locales/en/project-view-members.json @@ -13,5 +13,6 @@ "deleteButtonTooltip": "Remove from project", "memberCount": "Member", "membersCountPlural": "Members", - "emptyText": "There are no attachments in the project." + "emptyText": "There are no attachments in the project.", + "searchPlaceholder": "Search members" } diff --git a/worklenz-frontend/public/locales/es/project-view-members.json b/worklenz-frontend/public/locales/es/project-view-members.json index 95a8d943..46f26b3f 100644 --- a/worklenz-frontend/public/locales/es/project-view-members.json +++ b/worklenz-frontend/public/locales/es/project-view-members.json @@ -13,5 +13,6 @@ "deleteButtonTooltip": "Eliminar del proyecto", "memberCount": "Miembro", "membersCountPlural": "Miembros", - "emptyText": "No hay archivos adjuntos en el proyecto." + "emptyText": "No hay archivos adjuntos en el proyecto.", + "searchPlaceholder": "Buscar miembros" } diff --git a/worklenz-frontend/public/locales/pt/project-view-members.json b/worklenz-frontend/public/locales/pt/project-view-members.json index 72524807..df6eded0 100644 --- a/worklenz-frontend/public/locales/pt/project-view-members.json +++ b/worklenz-frontend/public/locales/pt/project-view-members.json @@ -13,5 +13,6 @@ "deleteButtonTooltip": "Remover do projeto", "memberCount": "Membro", "membersCountPlural": "Membros", - "emptyText": "Não há anexos no projeto." + "emptyText": "Não há anexos no projeto.", + "searchPlaceholder": "Pesquisar membros" } diff --git a/worklenz-frontend/src/pages/projects/projectView/members/project-view-members.tsx b/worklenz-frontend/src/pages/projects/projectView/members/project-view-members.tsx index 5393ea95..0e2f8d54 100644 --- a/worklenz-frontend/src/pages/projects/projectView/members/project-view-members.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/members/project-view-members.tsx @@ -11,6 +11,7 @@ import { TableProps, Tooltip, Typography, + Input, // <-- Add this import } from 'antd'; // Icons @@ -70,26 +71,29 @@ const ProjectViewMembers = () => { field: 'name', order: 'ascend', total: 0, - pageSizeOptions: ['5', '10', '15', '20', '50', '100'], + pageSizeOptions: ['10', '20', '50', '100'], size: 'small', }); + const [searchQuery, setSearchQuery] = useState(''); // <-- Add search state // API Functions - const getProjectMembers = async () => { + const getProjectMembers = async (search: string = searchQuery) => { if (!projectId) return; setIsLoading(true); try { + const offset = (pagination.current - 1) * pagination.pageSize; const res = await projectsApiService.getMembers( projectId, - pagination.current, - pagination.pageSize, + pagination.current, // index + pagination.pageSize, // size // offset pagination.field, pagination.order, - null + search ); if (res.done) { setMembers(res.body); + setPagination(p => ({ ...p, total: res.body.total ?? 0 })); // update total from backend, default to 0 } } catch (error) { logger.error('Error fetching members:', error); @@ -123,16 +127,14 @@ const ProjectViewMembers = () => { return Math.floor((completed / total) * 100); }; - const handleTableChange = (pagination: any, filters: any, sorter: any) => { - setPagination({ - current: pagination.current, - pageSize: pagination.pageSize, - field: sorter.field || pagination.field, - order: sorter.order || pagination.order, - total: pagination.total, - pageSizeOptions: pagination.pageSizeOptions, - size: pagination.size, - }); + const handleTableChange = (tablePagination: any, filters: any, sorter: any) => { + setPagination(prev => ({ + ...prev, + current: tablePagination.current, + pageSize: tablePagination.pageSize, + field: sorter.field || prev.field, + order: sorter.order || prev.order, + })); }; // Effects @@ -145,6 +147,7 @@ const ProjectViewMembers = () => { pagination.pageSize, pagination.field, pagination.order, + // searchQuery, // <-- Do NOT include here, search is triggered manually ]); useEffect(() => { @@ -269,18 +272,33 @@ const ProjectViewMembers = () => { + {members?.total} {members?.total !== 1 ? t('membersCountPlural') : t('memberCount')} - -