diff --git a/apps/frontend/src/components/common/RoleBasedLink.tsx b/apps/frontend/src/components/common/RoleBasedLink.tsx new file mode 100644 index 0000000000..b08a98aece --- /dev/null +++ b/apps/frontend/src/components/common/RoleBasedLink.tsx @@ -0,0 +1,34 @@ +import { Link as MuiLink, LinkProps as MuiLinkProps } from '@mui/material'; +import React, { useContext } from 'react'; +import { Link as RouterLink, To } from 'react-router-dom'; + +import { UserContext } from 'context/UserContextProvider'; +import { UserRole } from 'generated/sdk'; + +export type RoleBasedLinkProps = Omit & { + /** Map of role -> destination. Roles omitted here render children as-is without a link. */ + roleRoutes: Partial>; +}; + +const RoleBasedLink = ({ + roleRoutes, + children, + ...linkProps +}: RoleBasedLinkProps) => { + const { currentRole } = useContext(UserContext); + + const to: To | undefined = currentRole ? roleRoutes[currentRole] : undefined; + + // With a destination for this role, render an MUI-styled link that navigates + // via react-router (client-side); otherwise render children as plain content + // with no link behavior. + return to !== undefined ? ( + + {children} + + ) : ( + <>{children} + ); +}; + +export default RoleBasedLink; diff --git a/apps/frontend/src/components/experiment/ExperimentsTable.tsx b/apps/frontend/src/components/experiment/ExperimentsTable.tsx index 37efceaf96..d5136c3a06 100644 --- a/apps/frontend/src/components/experiment/ExperimentsTable.tsx +++ b/apps/frontend/src/components/experiment/ExperimentsTable.tsx @@ -6,9 +6,15 @@ import MaterialTable, { import { Visibility } from '@mui/icons-material'; import { IconButton, Tooltip, Typography } from '@mui/material'; import React, { useRef, useState } from 'react'; -import { useSearchParams } from 'react-router-dom'; - -import { Experiment, PaginationSortDirection, SettingsId } from 'generated/sdk'; +import { useLocation, useSearchParams } from 'react-router-dom'; + +import RoleBasedLink from 'components/common/RoleBasedLink'; +import { + Experiment, + PaginationSortDirection, + SettingsId, + UserRole, +} from 'generated/sdk'; import { useFormattedDateTime } from 'hooks/admin/useFormattedDateTime'; import { setSortDirectionOnSortField } from 'utils/helperFunctions'; import useDataApiWithFeedback from 'utils/useDataApiWithFeedback'; @@ -52,6 +58,8 @@ export default function ExperimentsTable({ const { api } = useDataApiWithFeedback(); const [searchParams, setSearchParams] = useSearchParams(); + const location = useLocation(); + const from = encodeURIComponent(location.pathname + location.search); const [tableData, setTableData] = useState([]); const [selectedExperiment, setSelectedExperiment] = useState(); @@ -168,6 +176,16 @@ export default function ExperimentsTable({ { title: 'Proposal ID', field: 'proposal.proposalId', + render: (rowData: Experiment) => ( + + {rowData.proposal.proposalId} + + ), }, { title: 'Start', diff --git a/apps/frontend/src/components/proposal/ProposalTableOfficer.tsx b/apps/frontend/src/components/proposal/ProposalTableOfficer.tsx index 4b9eea5135..86ecbd969f 100644 --- a/apps/frontend/src/components/proposal/ProposalTableOfficer.tsx +++ b/apps/frontend/src/components/proposal/ProposalTableOfficer.tsx @@ -29,7 +29,7 @@ import { TFunction } from 'i18next'; import React, { useContext, useEffect, useRef, useState } from 'react'; import isEqual from 'react-fast-compare'; import { useTranslation } from 'react-i18next'; -import { useSearchParams } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import i18n from 'i18n'; @@ -37,6 +37,7 @@ import CopyToClipboard from 'components/common/CopyToClipboard'; import MaterialTable from 'components/common/DenseMaterialTable'; import ListStatusIcon from 'components/common/icons/ListStatusIcon'; import ScienceIcon from 'components/common/icons/ScienceIcon'; +import RoleBasedLink from 'components/common/RoleBasedLink'; import UOLoader from 'components/common/UOLoader'; import AssignProposalsToFaps from 'components/fap/Proposals/ProposalsView/AssignProposalsToFaps'; import AssignProposalsToInstruments from 'components/instrument/AssignProposalsToInstruments'; @@ -68,6 +69,7 @@ import { useDownloadPDFProposal } from 'hooks/proposal/useDownloadPDFProposal'; import { useDownloadProposalAttachment } from 'hooks/proposal/useDownloadProposalAttachment'; import { useDownloadXLSXProposal } from 'hooks/proposal/useDownloadXLSXProposal'; import { ProposalViewData } from 'hooks/proposal/useProposalsCoreData'; +import { fromArrayToCommaSeparatedLinks } from 'utils/fromArrayToCommaSeparatedLinks'; import { addColumns, fromArrayToCommaSeparated, @@ -130,9 +132,17 @@ let columns: Column[] = [ sorting: false, emptyValue: '-', render: (proposalView) => - proposalView.principalInvestigator?.lastname - ? `${proposalView.principalInvestigator.lastname}, ${getPreferredName(proposalView.principalInvestigator)}` - : '', + proposalView.principalInvestigator?.lastname ? ( + + {`${proposalView.principalInvestigator.lastname}, ${getPreferredName(proposalView.principalInvestigator)}`} + + ) : ( + '' + ), }, { title: 'PI Email', @@ -157,6 +167,19 @@ let columns: Column[] = [ { title: 'Call', field: 'callShortCode', + // No per-call route exists, so officers link to the Calls list page. + render: (rowData) => + rowData.callShortCode ? ( + + {rowData.callShortCode} + + ) : ( + '' + ), }, ]; @@ -200,10 +223,19 @@ const instrumentManagementColumns = ( { title: t('instrument'), field: 'instruments.name', + // No per-instrument route exists, so officers link to the Instruments list page. + render: (rowData: ProposalViewData) => - fromArrayToCommaSeparated( - rowData.instruments?.map((instrument) => instrument.name) + fromArrayToCommaSeparatedLinks( + rowData.instruments?.map((instrument) => ({ + key: instrument.id, + label: instrument.name, + roleRoutes: { + [UserRole.USER_OFFICER]: `/Instruments?search=${instrument.name}`, + }, + })) ), + customFilterAndSearch: () => true, }, ]; @@ -223,7 +255,13 @@ const fapReviewColumns = (t: TFunction<'translation', undefined>) => [ title: t('FAP'), field: 'faps.code', render: (rowData: ProposalViewData) => - fromArrayToCommaSeparated(rowData.faps?.map((fap) => fap.code)), + fromArrayToCommaSeparatedLinks( + rowData.faps?.map((fap) => ({ + key: fap.id, + label: fap.code, + roleRoutes: { [UserRole.USER_OFFICER]: `/FapPage/${fap.id}` }, + })) + ), }, ]; const SELECT_ALL_ACTION_TOOLTIP = 'select-all-prefetched-proposals'; @@ -429,6 +467,7 @@ const ProposalTableOfficer = ({ const userContext = useContext(UserContext); const featureContext = useContext(FeatureContext); const [searchParams, setSearchParams] = useSearchParams(); + const navigate = useNavigate(); const [bulkReassignData, setBulkReassignData] = useState([]); const isReadOnly = useCheckAccess([UserRole.PROPOSAL_READER]); @@ -1219,6 +1258,8 @@ const ProposalTableOfficer = ({ title={`View proposal: ${proposalToReview?.title} (${proposalToReview?.proposalId})`} proposalReviewModalOpen={!!proposalToReview} setProposalReviewModalOpen={() => { + const from = searchParams.get('from'); + if (searchParams.get('proposalId')) { setProposalFilter({ ...proposalFilter, @@ -1226,6 +1267,14 @@ const ProposalTableOfficer = ({ }); } + if (from) { + // Return to the page that opened the modal (e.g. Experiments), + // restoring its filters, instead of staying on /Proposals. + navigate(decodeURIComponent(from), { replace: true }); + + return; + } + setSearchParams((searchParams) => { searchParams.delete('reviewModal'); searchParams.delete('proposalId'); diff --git a/apps/frontend/src/graphql/proposal/getProposalsCore.graphql b/apps/frontend/src/graphql/proposal/getProposalsCore.graphql index 1ee80cbb65..0a9ddec435 100644 --- a/apps/frontend/src/graphql/proposal/getProposalsCore.graphql +++ b/apps/frontend/src/graphql/proposal/getProposalsCore.graphql @@ -56,6 +56,7 @@ query getProposalsCore( workflowId allocationTimeUnit principalInvestigator { + id firstname lastname preferredname diff --git a/apps/frontend/src/utils/fromArrayToCommaSeparatedLinks.tsx b/apps/frontend/src/utils/fromArrayToCommaSeparatedLinks.tsx new file mode 100644 index 0000000000..d0cd2813e3 --- /dev/null +++ b/apps/frontend/src/utils/fromArrayToCommaSeparatedLinks.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +import RoleBasedLink, { + RoleBasedLinkProps, +} from 'components/common/RoleBasedLink'; + +type CommaSeparatedLink = { + key: React.Key; + label: React.ReactNode; + roleRoutes: RoleBasedLinkProps['roleRoutes']; +}; + +export function fromArrayToCommaSeparatedLinks( + links: CommaSeparatedLink[] | null | undefined +) { + return ( + <> + {links?.map((link, index) => ( + + {index > 0 && ', '} + + {link.label} + + + ))} + + ); +}