From 72f51c15ffbe4822926646569983773cc317843a Mon Sep 17 00:00:00 2001 From: Chong Jia Chua Date: Fri, 22 May 2026 18:19:11 +0100 Subject: [PATCH 1/2] feat: expand/collapse all functionality for proposals and reviewers assignment pages --- .../ProposalsView/FapLegacyProposalsTable.tsx | 7 ++ .../FapProposalsAndAssignmentsTable.tsx | 7 ++ .../FapReviewersAndAssignmentsTable.tsx | 86 ++++++++++-------- .../src/hooks/fap/useExpandCollapseAll.tsx | 90 +++++++++++++++++++ 4 files changed, 152 insertions(+), 38 deletions(-) create mode 100644 apps/frontend/src/hooks/fap/useExpandCollapseAll.tsx diff --git a/apps/frontend/src/components/fap/Proposals/ProposalsView/FapLegacyProposalsTable.tsx b/apps/frontend/src/components/fap/Proposals/ProposalsView/FapLegacyProposalsTable.tsx index ebeb708d67..876c339253 100644 --- a/apps/frontend/src/components/fap/Proposals/ProposalsView/FapLegacyProposalsTable.tsx +++ b/apps/frontend/src/components/fap/Proposals/ProposalsView/FapLegacyProposalsTable.tsx @@ -15,6 +15,7 @@ import ProposalReviewContent, { import ProposalReviewModal from 'components/review/ProposalReviewModal'; import { Review, SettingsId, Fap } from 'generated/sdk'; import { useFormattedDateTime } from 'hooks/admin/useFormattedDateTime'; +import { useExpandCollapseAll } from 'hooks/fap/useExpandCollapseAll'; import { FapProposalType, FapProposalAssignmentType, @@ -159,6 +160,10 @@ const FapLegacyProposalsTable = ({ settingsFormatToUse: SettingsId.DATE_FORMAT, }); const { t } = useTranslation(); + const { tableRef, expandCollapseAllButton } = useExpandCollapseAll( + '[data-cy="fap-assignments-table"]', + [loadingFapProposals] + ); const translatedColumns = FapProposalColumns.map((column) => column.title === 'Instrument' @@ -360,6 +365,7 @@ const FapLegacyProposalsTable = ({
+ {expandCollapseAllButton} ); }; diff --git a/apps/frontend/src/components/fap/Proposals/ProposalsView/FapProposalsAndAssignmentsTable.tsx b/apps/frontend/src/components/fap/Proposals/ProposalsView/FapProposalsAndAssignmentsTable.tsx index e818435f96..8d9c55caa4 100644 --- a/apps/frontend/src/components/fap/Proposals/ProposalsView/FapProposalsAndAssignmentsTable.tsx +++ b/apps/frontend/src/components/fap/Proposals/ProposalsView/FapProposalsAndAssignmentsTable.tsx @@ -21,6 +21,7 @@ import ProposalReviewModal from 'components/review/ProposalReviewModal'; import { UserRole, Review, SettingsId, Fap } from 'generated/sdk'; import { useFormattedDateTime } from 'hooks/admin/useFormattedDateTime'; import { useCheckAccess } from 'hooks/common/useCheckAccess'; +import { useExpandCollapseAll } from 'hooks/fap/useExpandCollapseAll'; import { FapProposalType, FapProposalAssignmentType, @@ -182,6 +183,10 @@ const FapProposalsAndAssignmentsTable = ({ UserRole.USER_OFFICER, ]); const { t } = useTranslation(); + const { tableRef, expandCollapseAllButton } = useExpandCollapseAll( + '[data-cy="fap-assignments-table"]', + [loadingFapProposals] + ); const translatedColumns = FapProposalColumns.map((column) => column.title === 'Instrument' @@ -431,6 +436,7 @@ const FapProposalsAndAssignmentsTable = ({ />
+ {expandCollapseAllButton} ); }; diff --git a/apps/frontend/src/components/fap/Proposals/ReviewersView/FapReviewersAndAssignmentsTable.tsx b/apps/frontend/src/components/fap/Proposals/ReviewersView/FapReviewersAndAssignmentsTable.tsx index ba4fd1fafb..f04f3caea3 100644 --- a/apps/frontend/src/components/fap/Proposals/ReviewersView/FapReviewersAndAssignmentsTable.tsx +++ b/apps/frontend/src/components/fap/Proposals/ReviewersView/FapReviewersAndAssignmentsTable.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { useSearchParams } from 'react-router-dom'; import { Fap } from 'generated/sdk'; +import { useExpandCollapseAll } from 'hooks/fap/useExpandCollapseAll'; import { FapMember, useFapMembersData } from 'hooks/fap/useFapMembersData'; import { FapProposalAssignmentType, @@ -122,6 +123,11 @@ const FapReviewersAndAssignmentsTable = ({ const page = searchParams.get('page'); const pageSize = searchParams.get('pageSize'); + const { tableRef, expandCollapseAllButton } = useExpandCollapseAll( + '[data-cy="fap-reviewers-assignments-table"]', + [loadingMembers, loadingFapProposals] + ); + const reviewersAndProposals: ReviewerAndProposals[] = FapMembersData.map( (member) => { const assignedProposals = fapProposals.FapProposalsData.filter( @@ -253,46 +259,50 @@ const FapReviewersAndAssignmentsTable = ({ fapProposalsData={FapProposalsData} assignProposalsToReviewer={handleMemberAssignmentToFapProposals} /> - ( - s.id)} - removeAssignedReviewer={removeAssignedReviewer} - updateView={updateFapProposalAssignmentsView} - /> - )} - data={reviewersAndProposals} - isLoading={loadingMembers || loadingFapProposals} - options={{ - pageSize: pageSize ? +pageSize : Math.min(10, maxPageLength), - selection: true, - initialPage: page ? +page : 0, - pageSizeOptions: pageSizeOptions, - }} - localization={{ - toolbar: { - nRowsSelected: (rowCount) => `${rowCount} reviewer(s) selected`, - }, - }} - onSelectionChange={(selectedItems) => { - const selectedReviewers = selectedItems.map( - (item) => item.user.userId - ); - - setSearchParams((searchParams) => { - searchParams.delete('selection'); - selectedReviewers.forEach((pk) => - searchParams.append('selection', pk.toString()) +
+ ( + s.id)} + removeAssignedReviewer={removeAssignedReviewer} + updateView={updateFapProposalAssignmentsView} + /> + )} + data={reviewersAndProposals} + isLoading={loadingMembers || loadingFapProposals} + options={{ + pageSize: pageSize ? +pageSize : Math.min(10, maxPageLength), + selection: true, + initialPage: page ? +page : 0, + pageSizeOptions: pageSizeOptions, + }} + localization={{ + toolbar: { + nRowsSelected: (rowCount) => `${rowCount} reviewer(s) selected`, + }, + }} + onSelectionChange={(selectedItems) => { + const selectedReviewers = selectedItems.map( + (item) => item.user.userId ); - return searchParams; - }); - }} - /> + setSearchParams((searchParams) => { + searchParams.delete('selection'); + selectedReviewers.forEach((pk) => + searchParams.append('selection', pk.toString()) + ); + + return searchParams; + }); + }} + /> +
+ {expandCollapseAllButton} ); }; diff --git a/apps/frontend/src/hooks/fap/useExpandCollapseAll.tsx b/apps/frontend/src/hooks/fap/useExpandCollapseAll.tsx new file mode 100644 index 0000000000..0357c07817 --- /dev/null +++ b/apps/frontend/src/hooks/fap/useExpandCollapseAll.tsx @@ -0,0 +1,90 @@ +import KeyboardDoubleArrowDown from '@mui/icons-material/KeyboardDoubleArrowDown'; +import KeyboardDoubleArrowRight from '@mui/icons-material/KeyboardDoubleArrowRight'; +import { IconButton, Tooltip } from '@mui/material'; +import { + DependencyList, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; +import React from 'react'; +import { createPortal } from 'react-dom'; + +export function useExpandCollapseAll( + tableSelector: string, + dependencies: DependencyList +) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const tableRef = useRef(null); + const [isAllExpanded, setIsAllExpanded] = useState(false); + const [portalTarget, setPortalTarget] = useState(null); + + //find position to render button + useEffect(() => { + const frame = requestAnimationFrame(() => { + const container = document.querySelector(`${tableSelector} thead tr`); + if (!container) return; + + const cells = container.querySelectorAll('th, td'); + const cellArray = Array.from(cells); + const targetCell = cellArray.find( + (cell) => + cell.hasAttribute('data-expand-all-target') || + (cell.childElementCount === 0 && cell.textContent?.trim() === '') + ); + + if (targetCell) { + if (!targetCell.hasAttribute('data-expand-all-target')) { + targetCell.setAttribute('data-expand-all-target', 'true'); + } + setPortalTarget(targetCell as HTMLElement); + } + }); + + return () => cancelAnimationFrame(frame); + }, dependencies); + + //index each row and expand/collapse them + const toggleExpandAll = useCallback(() => { + setIsAllExpanded((prev) => { + const nextExpandedState = !prev; + + const sortedData = tableRef.current?.dataManager?.sortedData || []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sortedData.forEach((row: any, index: number) => { + const isCurrentlyExpanded = !!row.tableData.showDetailPanel; + if (isCurrentlyExpanded !== nextExpandedState) { + const detailPanelProp = tableRef.current?.props.detailPanel; + const detailPanelRenderFunction = Array.isArray(detailPanelProp) + ? detailPanelProp[0].render + : detailPanelProp; + + tableRef.current?.onToggleDetailPanel( + [index], + detailPanelRenderFunction + ); + } + }); + + return nextExpandedState; + }); + }, []); + + const expandCollapseAllButton = portalTarget + ? createPortal( + + + {isAllExpanded ? ( + + ) : ( + + )} + + , + portalTarget + ) + : null; + + return { tableRef, expandCollapseAllButton }; +} From 02f3d4400fa99ef8572f5bc99d17f08ebf29ea5a Mon Sep 17 00:00:00 2001 From: Chong Jia Chua Date: Mon, 22 Jun 2026 16:50:38 +0100 Subject: [PATCH 2/2] fix: modify state updater to be pure --- .../src/hooks/fap/useExpandCollapseAll.tsx | 38 +++++++++---------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/apps/frontend/src/hooks/fap/useExpandCollapseAll.tsx b/apps/frontend/src/hooks/fap/useExpandCollapseAll.tsx index 0357c07817..23cc6dbcdc 100644 --- a/apps/frontend/src/hooks/fap/useExpandCollapseAll.tsx +++ b/apps/frontend/src/hooks/fap/useExpandCollapseAll.tsx @@ -47,29 +47,27 @@ export function useExpandCollapseAll( //index each row and expand/collapse them const toggleExpandAll = useCallback(() => { - setIsAllExpanded((prev) => { - const nextExpandedState = !prev; + const nextExpandedState = !isAllExpanded; - const sortedData = tableRef.current?.dataManager?.sortedData || []; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sortedData.forEach((row: any, index: number) => { - const isCurrentlyExpanded = !!row.tableData.showDetailPanel; - if (isCurrentlyExpanded !== nextExpandedState) { - const detailPanelProp = tableRef.current?.props.detailPanel; - const detailPanelRenderFunction = Array.isArray(detailPanelProp) - ? detailPanelProp[0].render - : detailPanelProp; + const sortedData = tableRef.current?.dataManager?.sortedData || []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sortedData.forEach((row: any, index: number) => { + const isCurrentlyExpanded = !!row.tableData.showDetailPanel; + if (isCurrentlyExpanded !== nextExpandedState) { + const detailPanelProp = tableRef.current?.props.detailPanel; + const detailPanelRenderFunction = Array.isArray(detailPanelProp) + ? detailPanelProp[0].render + : detailPanelProp; - tableRef.current?.onToggleDetailPanel( - [index], - detailPanelRenderFunction - ); - } - }); - - return nextExpandedState; + tableRef.current?.onToggleDetailPanel( + [index], + detailPanelRenderFunction + ); + } }); - }, []); + + setIsAllExpanded(nextExpandedState); + }, [isAllExpanded]); const expandCollapseAllButton = portalTarget ? createPortal(