diff --git a/apps/backend/db_patches/0200_Update_email_search_description.sql b/apps/backend/db_patches/0200_Update_email_search_description.sql new file mode 100644 index 0000000000..b1caa98620 --- /dev/null +++ b/apps/backend/db_patches/0200_Update_email_search_description.sql @@ -0,0 +1,13 @@ +DO +$$ +BEGIN + IF register_patch('Update_email_search_description.sql', 'jekabskarklins', 'Updates email search description to make it more clear.', '2025-09-09') THEN + BEGIN + UPDATE features + SET description = 'By setting this feature ON, users can be searched *only* by their exact email addresses.' + WHERE feature_id = 'EMAIL_SEARCH'; + END; + END IF; +END; +$$ +LANGUAGE plpgsql; diff --git a/apps/frontend/src/components/common/UserManagementTable.tsx b/apps/frontend/src/components/common/UserManagementTable.tsx index 0eb7cac25b..b8fb64e5ef 100644 --- a/apps/frontend/src/components/common/UserManagementTable.tsx +++ b/apps/frontend/src/components/common/UserManagementTable.tsx @@ -1,7 +1,7 @@ import { ScheduleSend } from '@mui/icons-material'; import PersonAddIcon from '@mui/icons-material/PersonAdd'; import SendIcon from '@mui/icons-material/Send'; -import { Chip } from '@mui/material'; +import { Chip, Tooltip } from '@mui/material'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import FormControl from '@mui/material/FormControl'; @@ -15,20 +15,21 @@ import { FeatureContext } from 'context/FeatureContextProvider'; import { UserContext } from 'context/UserContextProvider'; import { BasicUserDetails, FeatureId, Invite, UserRole } from 'generated/sdk'; -import InviteUser from '../proposal/InviteUser'; import ParticipantModal from '../proposal/ParticipantModal'; +import ParticipantSelector from '../proposal/ParticipantSelector'; export type UserManagementTableProps = { /** Basic user details array to be shown in the table. */ users: BasicUserDetails[]; /** Function for setting up the users. */ setUsers: (users: BasicUserDetails[]) => void; - invites: Invite[]; - setInvites: (invites: Invite[]) => void; + invites?: Invite[]; + setInvites?: (invites: Invite[]) => void; sx?: SxProps; title: string; preserveSelf?: boolean; addButtonLabel?: string; + addButtonTooltip?: string; /** Disable the add button */ disabled?: boolean; /** Custom actions to be passed to PeopleTable */ @@ -41,12 +42,13 @@ export type UserManagementTableProps = { const UserManagementTable = ({ users, setUsers, - invites, + invites = [], setInvites, sx, title, preserveSelf, addButtonLabel = 'Add', + addButtonTooltip = 'Add a participant', disabled = false, onUserAction, excludeUserIds = [], @@ -73,7 +75,7 @@ const UserManagementTable = ({ invites: Invite[]; }) => { setUsers([...users, ...props.users]); - setInvites([...invites, ...props.invites]); + setInvites?.([...invites, ...props.invites]); setOpen(false); }; @@ -85,12 +87,13 @@ const UserManagementTable = ({ }; const handleDeleteInvite = (invite: Invite) => { - setInvites(invites.filter((i) => i.email !== invite.email)); + setInvites?.(invites.filter((i) => i.email !== invite.email)); }; const InviteComponent = ( - setOpen(false)} onAddParticipants={handleAddParticipants} excludeUserIds={[...users.map((user) => user.id), ...excludeUserIds]} @@ -197,16 +200,18 @@ const UserManagementTable = ({ marginTop: theme.spacing(1), })} > - + + + diff --git a/apps/frontend/src/components/proposal/CoProposers.tsx b/apps/frontend/src/components/proposal/CoProposers.tsx new file mode 100644 index 0000000000..881b9336fa --- /dev/null +++ b/apps/frontend/src/components/proposal/CoProposers.tsx @@ -0,0 +1,53 @@ +import React, { useContext } from 'react'; + +import UserManagementTable, { + UserManagementTableProps, +} from 'components/common/UserManagementTable'; +import { + createMissingContextErrorMessage, + QuestionaryContext, +} from 'components/questionary/QuestionaryContext'; +import { BasicUserDetails } from 'generated/sdk'; + +import { ProposalContextType } from './ProposalContainer'; + +type CoProposersProps = Pick< + UserManagementTableProps, + 'setInvites' | 'setUsers' | 'disabled' +> & { + setPrincipalInvestigator?: (user: BasicUserDetails) => void; +}; + +const CoProposers = ({ + setPrincipalInvestigator, + ...props +}: CoProposersProps) => { + const handleUserAction = (action: string, user: BasicUserDetails) => { + if (action === 'setPrincipalInvestigator' && setPrincipalInvestigator) { + setPrincipalInvestigator(user); + } + }; + + const { state } = useContext(QuestionaryContext) as ProposalContextType; + if (!state) { + throw new Error(createMissingContextErrorMessage()); + } + const { proposer, users, coProposerInvites } = state.proposal; + + return ( + + ); +}; + +export default CoProposers; diff --git a/apps/frontend/src/components/proposal/NoOptionsText.tsx b/apps/frontend/src/components/proposal/NoOptionsText.tsx index 4a73fcbb58..b238d70090 100644 --- a/apps/frontend/src/components/proposal/NoOptionsText.tsx +++ b/apps/frontend/src/components/proposal/NoOptionsText.tsx @@ -1,7 +1,8 @@ import { MenuItem, Typography } from '@mui/material'; -import React from 'react'; +import React, { useContext } from 'react'; -import { BasicUserDetails } from 'generated/sdk'; +import { FeatureContext } from 'context/FeatureContextProvider'; +import { BasicUserDetails, FeatureId } from 'generated/sdk'; import { isValidEmail } from 'utils/net'; import { getFullUserNameWithInstitution } from 'utils/user'; @@ -12,6 +13,9 @@ interface NoOptionsTextProps { exactEmailMatch?: BasicUserDetails; excludeEmails?: string[]; minSearchLength?: number; + /* If true, if no user with email is found show invite option */ + allowEmailInvites?: boolean; + /* If true, only allow searching users by entering their email */ isEmailSearchOnly: boolean; allowInviteByEmail?: boolean; } @@ -26,7 +30,14 @@ function NoOptionsText({ isEmailSearchOnly, allowInviteByEmail = false, }: NoOptionsTextProps) { - const isEmailInviteEnabled = allowInviteByEmail; + const featureContext = useContext(FeatureContext); + const isEmailInviteFeatureEnabled = !!featureContext.featuresMap.get( + FeatureId.EMAIL_INVITE + )?.isEnabled; + + // email invite will be enabled if both the prop and the feature flag are true + const isEmailInviteEnabled = + allowInviteByEmail && isEmailInviteFeatureEnabled; if (exactEmailMatch) { return ( diff --git a/apps/frontend/src/components/proposal/InviteUser.tsx b/apps/frontend/src/components/proposal/ParticipantSelector.tsx similarity index 80% rename from apps/frontend/src/components/proposal/InviteUser.tsx rename to apps/frontend/src/components/proposal/ParticipantSelector.tsx index f259386ed5..d19dbc0587 100644 --- a/apps/frontend/src/components/proposal/InviteUser.tsx +++ b/apps/frontend/src/components/proposal/ParticipantSelector.tsx @@ -31,8 +31,15 @@ import NoOptionsText from './NoOptionsText'; type UserOrEmail = BasicUserDetails | ValidEmailAddress; -interface InviteUserProps { +const keyOf = (u: UserOrEmail) => + typeof u === 'string' ? `email:${u.toLowerCase()}` : `id:${String(u.id)}`; + +const isSameParticipants = (a: UserOrEmail[], b: UserOrEmail[]): boolean => + a.length === b.length && a.every((v, i) => keyOf(v) === keyOf(b[i])); + +interface ParticipantSelectorProps { modalOpen: boolean; + title?: string; onClose?: () => void; onAddParticipants?: (data: { users: BasicUserDetails[]; @@ -40,6 +47,8 @@ interface InviteUserProps { }) => void; excludeUserIds?: number[]; excludeEmails?: string[]; + preset?: UserOrEmail[]; + multiple?: boolean; allowInviteByEmail?: boolean; } @@ -54,15 +63,19 @@ const categorizeSelectedItems = (items: UserOrEmail[]) => ({ const MIN_SEARCH_LENGTH = 3; -function InviteUser({ +function ParticipantSelector({ modalOpen, + title, onClose, onAddParticipants, excludeUserIds, excludeEmails, - allowInviteByEmail, + allowInviteByEmail = false, confirm, -}: InviteUserProps & WithConfirmProps) { + preset = [], + multiple = true, +}: ParticipantSelectorProps & WithConfirmProps) { + // multiple selection enabled by default const api = useDataApi(); const [query, setQuery] = useState(''); const [loading, setLoading] = useState(false); @@ -70,7 +83,7 @@ function InviteUser({ const [exactEmailMatch, setExactEmailMatch] = useState< BasicUserDetails | undefined >(); - const [selectedItems, setSelectedItems] = useState([]); + const [selectedItems, setSelectedItems] = useState(preset); const isPendingSearch = useRef(false); const featureContext = useContext(FeatureContext); const isEmailSearchOnly = !!featureContext.featuresMap.get( @@ -166,7 +179,7 @@ function InviteUser({ }, [query, fetchUserSearchResults]); const addToSelectedItems = (user: UserOrEmail) => { - setSelectedItems((prev) => [...prev, user]); + setSelectedItems((prev) => (multiple ? [...prev, user] : [user])); setExactEmailMatch(undefined); setOptions([]); @@ -217,7 +230,7 @@ function InviteUser({ }; const handleClose = () => { - if (selectedItems.length > 0) { + if (isSameParticipants(selectedItems, preset || []) === false) { confirm( async () => { onClose?.(); @@ -227,7 +240,7 @@ function InviteUser({ { title: 'Please confirm', description: - 'User(s) have not yet been added to the proposal. Are you sure you want to close the dialog?', + 'Are you sure you want to close the dialog? Your changes will be lost.', } )(); @@ -240,6 +253,13 @@ function InviteUser({ }; const handleKeyDown = (event: React.KeyboardEvent) => { + if (!multiple && selectedItems.length === 1) { + // Prevent further input when single selection is made + event.preventDefault(); + + return; + } + if (event.key === 'Enter' || event.key === ' ' || event.key === ',') { event.preventDefault(); @@ -249,12 +269,11 @@ function InviteUser({ if (exactEmailMatch) { addToSelectedItems(exactEmailMatch); - } else { - if (options.length === 1) { - addToSelectedItems(options[0]); - } else if (isValidEmail(query) && !isEmailSearchOnly) { - addValidEmailToSelection(query); - } + setExactEmailMatch(undefined); + } else if (options.length === 1) { + addToSelectedItems(options[0]); + } else if (isValidEmail(query) && !isEmailSearchOnly) { + addValidEmailToSelection(query); } } }; @@ -275,7 +294,7 @@ function InviteUser({ fullWidth maxWidth="md" onClose={handleClose} - title="Add co-proposers to proposal" + title={title || (multiple ? 'Add Participant(s)' : 'Select Participant')} tooltip={ isEmailSearchOnly && ( setSelectedItems(newValue)} + value={ + multiple + ? selectedItems + : selectedItems.length + ? selectedItems[0] + : undefined + } + onChange={(_, newValue) => + setSelectedItems( + newValue + ? multiple + ? (newValue as UserOrEmail[]) + : [newValue as UserOrEmail] + : [] + ) + } filterSelectedOptions onInputChange={(_, newValue) => setQuery(newValue)} onKeyDown={handleKeyDown} @@ -342,10 +375,16 @@ function InviteUser({ !isLoading ? ( addValidEmailToSelection(email)} + onAddEmail={(email) => + allowInviteByEmail + ? addValidEmailToSelection(email) + : undefined + } exactEmailMatch={exactEmailMatch} onAddUser={(user) => { - addToSelectedItems(user); + multiple + ? addToSelectedItems(user) + : setSelectedItems([user]); }} excludeEmails={ excludeEmails?.concat( @@ -364,14 +403,17 @@ function InviteUser({ onClick={handleSubmit} sx={{ margin: '16px 0 8px 0' }} startIcon={} - disabled={!selectedItems.length} + disabled={ + !selectedItems.length || + isSameParticipants(selectedItems, preset || []) + } data-cy="invite-user-submit-button" > - Add + {multiple ? 'Add' : 'Select'} ); } -export default withConfirm(InviteUser); +export default withConfirm(ParticipantSelector); diff --git a/apps/frontend/src/components/proposal/PrincipalInvestigator.tsx b/apps/frontend/src/components/proposal/PrincipalInvestigator.tsx new file mode 100644 index 0000000000..8a6922f76e --- /dev/null +++ b/apps/frontend/src/components/proposal/PrincipalInvestigator.tsx @@ -0,0 +1,90 @@ +import EditIcon from '@mui/icons-material/Edit'; +import Box from '@mui/material/Box'; +import FormControl from '@mui/material/FormControl'; +import IconButton from '@mui/material/IconButton'; +import TextField from '@mui/material/TextField'; +import Tooltip from '@mui/material/Tooltip'; +import React, { useContext, useState } from 'react'; + +import { + QuestionaryContext, + createMissingContextErrorMessage, +} from 'components/questionary/QuestionaryContext'; +import { BasicUserDetails } from 'generated/sdk'; +import { getFullUserNameWithInstitution } from 'utils/user'; + +import ParticipantSelector from './ParticipantSelector'; +import { ProposalContextType } from './ProposalContainer'; + +interface PrincipalInvestigatorProps { + setPrincipalInvestigator: (user: BasicUserDetails) => void; + disabled?: boolean; +} + +export default function PrincipalInvestigator( + props: PrincipalInvestigatorProps +) { + const [isPickerOpen, setIsPickerOpen] = useState(false); + + const { state } = useContext(QuestionaryContext) as ProposalContextType; + if (!state) { + throw new Error(createMissingContextErrorMessage()); + } + const { proposer, users } = state.proposal; + + return ( + + {isPickerOpen && ( + setIsPickerOpen(false)} + onAddParticipants={(participants) => { + props.setPrincipalInvestigator(participants.users[0]); + setIsPickerOpen(false); + }} + excludeUserIds={[users.map((u) => u.id)].flat()} + preset={proposer ? [proposer] : []} + multiple={false} + allowInviteByEmail={false} + /> + )} + + + + + + + setIsPickerOpen(true)} + sx={(theme) => ({ + padding: '5px', + marginLeft: theme.spacing(1), + })} + disabled={props.disabled} + > + + + + + + + ); +} diff --git a/apps/frontend/src/components/proposal/ProposalParticipant.tsx b/apps/frontend/src/components/proposal/ProposalParticipantLegacy.tsx similarity index 97% rename from apps/frontend/src/components/proposal/ProposalParticipant.tsx rename to apps/frontend/src/components/proposal/ProposalParticipantLegacy.tsx index 206a0169ed..7cb8832af1 100644 --- a/apps/frontend/src/components/proposal/ProposalParticipant.tsx +++ b/apps/frontend/src/components/proposal/ProposalParticipantLegacy.tsx @@ -13,7 +13,7 @@ import { getFullUserNameWithInstitution } from 'utils/user'; import ParticipantModal from './ParticipantModal'; -export default function ProposalParticipant(props: { +export default function ProposalParticipantLegacy(props: { principalInvestigator: BasicUserData | null | undefined; setPrincipalInvestigator: (user: BasicUserDetails) => void; sx?: SxProps; diff --git a/apps/frontend/src/components/proposal/ProposalParticipants.tsx b/apps/frontend/src/components/proposal/ProposalParticipants.tsx deleted file mode 100644 index c472d7fc64..0000000000 --- a/apps/frontend/src/components/proposal/ProposalParticipants.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import React, { useContext } from 'react'; - -import UserManagementTable, { - UserManagementTableProps, -} from 'components/common/UserManagementTable'; -import { FeatureContext } from 'context/FeatureContextProvider'; -import { BasicUserDetails, FeatureId } from 'generated/sdk'; -import { BasicUserData } from 'hooks/user/useUserData'; - -type ProposalParticipantsProps = Omit< - UserManagementTableProps, - 'onUserAction' | 'excludeUserIds' | 'disabled' -> & { - principalInvestigator?: BasicUserData | null; - setPrincipalInvestigator?: (user: BasicUserDetails) => void; - loadingPrincipalInvestigator?: boolean; -}; - -const ProposalParticipants = ({ - principalInvestigator, - setPrincipalInvestigator, - loadingPrincipalInvestigator, - ...props -}: ProposalParticipantsProps) => { - const handleUserAction = (action: string, user: BasicUserDetails) => { - if (action === 'setPrincipalInvestigator' && setPrincipalInvestigator) { - setPrincipalInvestigator(user); - } - }; - - const featureContext = useContext(FeatureContext); - const allowInviteByEmail = !!featureContext.featuresMap.get( - FeatureId.EMAIL_INVITE - )?.isEnabled; - - return ( - - ); -}; - -export default ProposalParticipants; diff --git a/apps/frontend/src/components/questionary/questionaryComponents/ProposalBasis/QuestionaryComponentProposalBasis.tsx b/apps/frontend/src/components/questionary/questionaryComponents/ProposalBasis/QuestionaryComponentProposalBasis.tsx index e25c7141e7..1d6e98c378 100644 --- a/apps/frontend/src/components/questionary/questionaryComponents/ProposalBasis/QuestionaryComponentProposalBasis.tsx +++ b/apps/frontend/src/components/questionary/questionaryComponents/ProposalBasis/QuestionaryComponentProposalBasis.tsx @@ -7,15 +7,17 @@ import React, { ChangeEvent, useContext, useEffect, useState } from 'react'; import ErrorMessage from 'components/common/ErrorMessage'; import TextField from 'components/common/FormikUITextField'; import withPreventSubmit from 'components/common/withPreventSubmit'; +import CoProposers from 'components/proposal/CoProposers'; import { BasicComponentProps } from 'components/proposal/IBasicComponentProps'; +import PrincipalInvestigator from 'components/proposal/PrincipalInvestigator'; import { ProposalContextType } from 'components/proposal/ProposalContainer'; -import ProposalParticipant from 'components/proposal/ProposalParticipant'; -import Participants from 'components/proposal/ProposalParticipants'; +import ProposalParticipantLegacy from 'components/proposal/ProposalParticipantLegacy'; import { createMissingContextErrorMessage, QuestionaryContext, } from 'components/questionary/QuestionaryContext'; -import { BasicUserDetails, Invite } from 'generated/sdk'; +import { FeatureContext } from 'context/FeatureContextProvider'; +import { BasicUserDetails, FeatureId, Invite } from 'generated/sdk'; import { SubmitActionDependencyContainer } from 'hooks/questionary/useSubmitActions'; import { useBasicUserData } from 'hooks/user/useUserData'; import { ProposalSubmissionState } from 'models/questionary/proposal/ProposalSubmissionState'; @@ -38,14 +40,17 @@ function QuestionaryComponentProposalBasis(props: BasicComponentProps) { const [localTitle, setLocalTitle] = useState(state?.proposal.title); const [localAbstract, setLocalAbstract] = useState(state?.proposal.abstract); const [hasInvalidChars, setHasInvalidChars] = useState(false); - const [textLen, setTextLen] = useState( - state?.proposal.abstract ? (state?.proposal.abstract as string).length : 0 - ); + const [textLen, setTextLen] = useState(state?.proposal.abstract ?? 0); + const { featuresMap } = useContext(FeatureContext); + const isLegacyInviteFlow = featuresMap.get( + FeatureId.EMAIL_INVITE_LEGACY + )?.isEnabled; + if (!state || !dispatch) { throw new Error(createMissingContextErrorMessage()); } - const { proposer, users, coProposerInvites } = state.proposal; + const { proposer, users } = state.proposal; const { loading, userData } = useBasicUserData(state?.proposal.proposer?.id); const [piData, setPIData] = useState(null); @@ -55,7 +60,7 @@ function QuestionaryComponentProposalBasis(props: BasicComponentProps) { } }, [userData]); - const coInvestigatorChanged = (users: BasicUserDetails[]) => { + const coProposersChanged = (users: BasicUserDetails[]) => { formikProps.setFieldValue( `${id}.users`, users.map((user) => user.id) @@ -82,8 +87,10 @@ function QuestionaryComponentProposalBasis(props: BasicComponentProps) { proposer: user, }, }); + setPIData(user); - coInvestigatorChanged( + // Remove the new PI from the co-proposers list (if present) and add the old PI (if present) to the co-proposers list + coProposersChanged( users .filter((coInvestigator) => coInvestigator.id !== user.id) .concat(proposer as BasicUserDetails) @@ -166,25 +173,23 @@ function QuestionaryComponentProposalBasis(props: BasicComponentProps) { > {counter} - - + ) : ( + + )} + +