diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index b4bbdb9ab9..1671eddc57 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -576,6 +576,7 @@ "email_does_not_belong_to_university": "", "email_limit_reached": "", "email_link_expired": "", + "email_must_be_a_valid_format": "", "email_must_be_linked_to_institution": "", "email_notifications_are_currently_in_beta": "", "email_or_password_wrong_try_again": "", @@ -596,6 +597,7 @@ "end_time_utc": "", "ensure_recover_account": "", "enter_any_size_including_units_or_valid_latex_command": "", + "enter_emails_separated_by_commas": "", "enter_manually": "", "enter_tax_id_number": "", "enter_the_code": "", @@ -963,6 +965,7 @@ "invalid_upload_request": "", "invert_pdf_preview_colors": "", "invert_pdf_preview_colors_when_in_dark_mode": "", + "invitations_sent": "", "invite": "", "invite_expired": "", "invite_group_members": "", @@ -1275,6 +1278,7 @@ "on": "", "on_free_plan_upgrade_to_access_features": "", "one_step_away_from_professional_features": "", + "only_add_people_who_dont_yet_have_access": "", "only_group_admin_or_managers_can_delete_your_account_1": "", "only_group_admin_or_managers_can_delete_your_account_10": "", "only_group_admin_or_managers_can_delete_your_account_3": "", diff --git a/services/web/frontend/js/features/share-project-modal/components/add-collaborators-select.tsx b/services/web/frontend/js/features/share-project-modal/components/add-collaborators-select.tsx new file mode 100644 index 0000000000..cfe7757e4b --- /dev/null +++ b/services/web/frontend/js/features/share-project-modal/components/add-collaborators-select.tsx @@ -0,0 +1,294 @@ +import { useMemo, useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useMultipleSelection } from 'downshift' +import { Select } from '@/shared/components/select' +import ClickableElementEnhancer from '@/shared/components/clickable-element-enhancer' +import OLButton from '@/shared/components/ol/ol-button' +import { PermissionsLevel } from '@/features/ide-react/types/permissions' +import { useProjectContext } from '@/shared/context/project-context' +import { + resendInvite, + sendInvite, +} from '@/features/share-project-modal/utils/api' +import { ContactItem } from '@/features/share-project-modal/components/select-collaborators' +import { useShareProjectContext } from '@/features/share-project-modal/components/share-project-modal' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useFeatureFlag } from '@/shared/context/split-test-context' +import { sendMB } from '@/infrastructure/event-tracking' +import MaterialIcon, { + AvailableUnfilledIcon, +} from '@/shared/components/material-icon' +import { isValidEmail } from '@/shared/utils/email' + +type AddCollaboratorsSelectProps = { + readOnly?: boolean + multipleSelectionProps: ReturnType> + currentMemberEmails: string[] + inputValue?: string + onInviteSuccess?: () => void + hasErrors?: boolean +} + +function AddCollaboratorsSelect({ + readOnly, + multipleSelectionProps, + currentMemberEmails, + inputValue, + onInviteSuccess, + hasErrors, +}: AddCollaboratorsSelectProps) { + const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates') + const { t } = useTranslation() + const { features } = useProjectContext() + const [privileges, setPrivileges] = useState('readAndWrite') + + const isMounted = useIsMounted() + + const { setInFlight, setError } = useShareProjectContext() + const [isSubmitting, setIsSubmitting] = useState(false) + const { projectId, project, updateProject } = useProjectContext() + const { members, invites } = project || {} + + const privilegeOptions = useMemo(() => { + const options: { + key: PermissionsLevel + label: string + description?: string | null + icon: AvailableUnfilledIcon + }[] = [ + { + key: 'readAndWrite', + label: t('editor'), + icon: 'edit', + }, + ] + + if (features.trackChangesVisible) { + options.push({ + key: 'review', + label: t('reviewer'), + description: !features.trackChanges + ? t('comment_only_upgrade_for_track_changes') + : null, + icon: 'mode_comment', + }) + } + + options.push({ + key: 'readOnly', + label: t('viewer'), + icon: 'visibility', + }) + + return options + }, [features.trackChanges, features.trackChangesVisible, t]) + + const { reset, selectedItems } = multipleSelectionProps + + useEffect(() => { + if (readOnly && privileges !== 'readOnly') { + setPrivileges('readOnly') + } + }, [privileges, readOnly]) + + const handleSubmit = useCallback(async () => { + if (!selectedItems.length) { + return + } + + // reset the selected items + reset() + + setError(undefined) + if (isSharingUpdatesEnabled) { + setIsSubmitting(true) + } else { + setInFlight(true) + } + + let hasError = false + let hasInvited = false + + for (const contact of selectedItems) { + // unmounting means can't add any more collaborators + if (!isMounted.current) { + break + } + + const email = contact.type === 'user' ? contact.email : contact.display + const normalisedEmail = email.toLowerCase() + + if (currentMemberEmails.includes(normalisedEmail)) { + continue + } + + hasInvited = true + + let data + + try { + const invite = invites?.find(invite => invite.email === normalisedEmail) + + if (invite) { + data = await resendInvite(projectId, invite) + } else { + data = await sendInvite(projectId, email, privileges) + } + + const role = data?.invite?.privileges + const membersAndInvites = (members || []).concat(invites || []) + const previousEditorsAmount = membersAndInvites.filter( + member => member.privileges === 'readAndWrite' + ).length + const previousReviewersAmount = membersAndInvites.filter( + member => member.privileges === 'review' + ).length + const previousViewersAmount = membersAndInvites.filter( + member => member.privileges === 'readOnly' + ).length + + sendMB('collaborator-invited', { + project_id: projectId, + // invitation is only populated on successful invite, meaning that for paywall and other cases this will be null + successful_invite: !!data.invite, + users_updated: !!(data.users || data.user), + current_collaborators_amount: members?.length || 0, + current_invites_amount: invites?.length || 0, + role, + previousEditorsAmount, + previousReviewersAmount, + previousViewersAmount, + newEditorsAmount: + role === 'readAndWrite' + ? previousEditorsAmount + 1 + : previousEditorsAmount, + newReviewersAmount: + role === 'review' + ? previousReviewersAmount + 1 + : previousReviewersAmount, + newViewersAmount: + role === 'readOnly' + ? previousViewersAmount + 1 + : previousViewersAmount, + }) + } catch (error: any) { + hasError = true + if (isSharingUpdatesEnabled) { + setIsSubmitting(false) + } else { + setInFlight(false) + } + setError( + error.data?.errorReason || + (error.response?.status === 429 + ? 'too_many_requests' + : 'generic_something_went_wrong') + ) + break + } + + if (data.error) { + hasError = true + setError(data.error) + if (isSharingUpdatesEnabled) { + setIsSubmitting(false) + } else { + setInFlight(false) + } + } else if (data.invite) { + updateProject({ + invites: invites?.concat(data.invite) || [data.invite], + }) + } else if (data.users) { + updateProject({ + members: members?.concat(data.users) || data.users, + }) + } else if (data.user) { + updateProject({ + members: members?.concat(data.user) || [data.user], + }) + } + + // wait for a short time, so canAddCollaborators has time to update with new collaborator information + await new Promise(resolve => setTimeout(resolve, 100)) + } + + if (isSharingUpdatesEnabled) { + setIsSubmitting(false) + } else { + setInFlight(false) + } + + if (!hasError && hasInvited) { + onInviteSuccess?.() + } + }, [ + currentMemberEmails, + invites, + isMounted, + isSharingUpdatesEnabled, + members, + onInviteSuccess, + privileges, + projectId, + reset, + selectedItems, + setError, + setInFlight, + updateProject, + ]) + + const canInvite = + !hasErrors && + ((inputValue && + isValidEmail(inputValue) && + !currentMemberEmails.includes(inputValue.toLowerCase())) || + selectedItems.length > 0) + + const showPermissionsSelect = Boolean(inputValue) || selectedItems.length > 0 + + const permissionComponent = ( + item.key} - itemToString={item => item?.label || ''} - itemToSubtitle={item => item?.description || ''} - itemToDisabled={item => !!(readOnly && item?.key !== 'readOnly')} - selected={privilegeOptions.find( - option => option.key === privileges - )} - onSelectedItemChanged={item => { - if (item) { - setPrivileges(item.key) - } - }} - /> - - {t('invite')} - - - + ) : ( + <> + + + + {t('add_comma_separated_emails_help')} + + + +
+ +
+
+ + )} ) } diff --git a/services/web/frontend/js/features/share-project-modal/components/select-collaborators.tsx b/services/web/frontend/js/features/share-project-modal/components/select-collaborators.tsx index af50df978d..4893997f2b 100644 --- a/services/web/frontend/js/features/share-project-modal/components/select-collaborators.tsx +++ b/services/web/frontend/js/features/share-project-modal/components/select-collaborators.tsx @@ -5,11 +5,17 @@ import { useCombobox, UseMultipleSelectionReturnValue } from 'downshift' import classnames from 'classnames' import MaterialIcon from '@/shared/components/material-icon' -import Tag from '@/shared/components/tag' import { DropdownItem } from '@/shared/components/dropdown/dropdown-menu' import { Contact } from '../utils/types' import OLFormLabel from '@/shared/components/ol/ol-form-label' import OLSpinner from '@/shared/components/ol/ol-spinner' +import OLFormFeedback from '@/shared/components/ol/ol-form-feedback' +import OLRow from '@/shared/components/ol/ol-row' +import OLCol from '@/shared/components/ol/ol-col' +import OLTag from '@/shared/components/ol/ol-tag' +import AddCollaboratorsSelect from '@/features/share-project-modal/components/add-collaborators-select' +import { useFeatureFlag } from '@/shared/context/split-test-context' +import { isValidEmail } from '@/shared/utils/email' export type ContactItem = { email: string @@ -23,15 +29,33 @@ export type ContactItem = { const matchAllSpaces = /[\u061C\u2000-\u200F\u202A-\u202E\u2060\u2066-\u2069\u2028\u2029\u202F]/g +const INPUT_ERRORS = { + ALREADY_MEMBER: 'ALREADY_MEMBER', + INVALID_FORMAT: 'INVALID_FORMAT', +} as const + +type InputError = { + key: `${InputError['email']}:${(typeof INPUT_ERRORS)[keyof typeof INPUT_ERRORS]}` + email: string + message: string +} + export default function SelectCollaborators({ loading, options, multipleSelectionProps, + currentMemberEmails, + readOnly, + size, }: { loading: boolean options: Contact[] multipleSelectionProps: UseMultipleSelectionReturnValue + currentMemberEmails: string[] + readOnly?: boolean + size?: 'lg' }) { + const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates') const { t } = useTranslation() const { getSelectedItemProps, @@ -42,6 +66,8 @@ export default function SelectCollaborators({ } = multipleSelectionProps const [inputValue, setInputValue] = useState('') + const [inviteSent, setInviteSent] = useState(false) + const [inputErrors, setInputErrors] = useState([]) const selectedEmails = useMemo( () => selectedItems.map(item => item.email), @@ -146,13 +172,60 @@ export default function SelectCollaborators({ }) setInputValue('') reset() + + const inputErrorsSetter = ( + inputErrors: InputError[], + errorType: (typeof INPUT_ERRORS)[keyof typeof INPUT_ERRORS], + message: InputError['message'] + ) => { + const key = `${email}:${errorType}` as const + return [ + ...inputErrors.filter(e => !(e.email === email && e.key === key)), + { + key, + email, + message, + }, + ] + } + + // Validate email format + if (!isValidEmail(email)) { + setInputErrors(prev => + inputErrorsSetter( + prev, + INPUT_ERRORS.INVALID_FORMAT, + t('email_must_be_a_valid_format') + ) + ) + } + + // Validate against existing members + if (currentMemberEmails.includes(email.toLowerCase())) { + setInputErrors(prev => + inputErrorsSetter( + prev, + INPUT_ERRORS.ALREADY_MEMBER, + t('only_add_people_who_dont_yet_have_access') + ) + ) + } + if (focus) { focusInput() } return true } }, - [addSelectedItem, selectedEmails, isValidInput, focusInput, reset] + [ + addSelectedItem, + selectedEmails, + isValidInput, + focusInput, + reset, + currentMemberEmails, + t, + ] ) // close and reset the menu when there are no matching items @@ -162,123 +235,197 @@ export default function SelectCollaborators({ } }, [reset, isOpen, filteredOptions.length]) + useEffect(() => { + setInputErrors(prev => prev.filter(e => selectedEmails.includes(e.email))) + }, [selectedEmails]) + return (
- {/* eslint-disable-next-line jsx-a11y/label-has-for */} - - {t('add_email_address')} - {loading && } - + {isSharingUpdatesEnabled ? ( + // eslint-disable-next-line jsx-a11y/label-has-for + + {t('enter_emails_separated_by_commas')} + + ) : ( + // eslint-disable-next-line jsx-a11y/label-has-for + + {t('add_email_address')} + {loading && } + + )} -
- {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */} -
- {selectedItems.map((selectedItem, index) => ( - - ))} - - { - addNewItem(inputValue, false) - }, - onChange: e => { - setInputValue((e.target as HTMLInputElement).value) - }, - onClick: () => focusInput, - onKeyDown: event => { - switch (event.key) { - case 'Enter': - // Enter: always prevent form submission - event.preventDefault() - event.stopPropagation() - break - - case 'Tab': - // Tab: if the dropdown isn't open, try to create a new item using inputValue and prevent blur if successful - if (!isOpen && addNewItem(inputValue)) { - event.preventDefault() - event.stopPropagation() - } - break - - case ',': - // comma: try to create a new item using inputValue - event.preventDefault() - addNewItem(inputValue) - break - } - }, - onPaste: event => { - const data = - // modern browsers - event.clipboardData?.getData('text/plain') ?? - // @ts-ignore IE11 - window.clipboardData?.getData('text') - - if (data) { - const emails = data - .split(/[\r\n,; ]+/) - .filter(item => item.includes('@')) - .map(email => email.replace(matchAllSpaces, '')) - - if (emails.length) { - // pasted comma-separated email addresses - event.preventDefault() - - // dedupe emails in pasted content and previously-entered items - const uniqueEmails = [...new Set(emails)].filter( - email => !selectedEmails.includes(email) - ) - - for (const email of uniqueEmails) { - addNewItem(email) - } - } - } - }, - }) - )} - /> -
- -
-
    - {isOpen && - filteredOptions.map((item, index) => ( -
-
-
+ + { + addNewItem(inputValue, false) + }, + onChange: e => { + setInputValue((e.target as HTMLInputElement).value) + setInviteSent(false) + }, + onClick: () => focusInput, + onKeyDown: event => { + switch (event.key) { + case 'Enter': + // Enter: always prevent form submission + event.preventDefault() + event.stopPropagation() + break + + case 'Tab': + // Tab: if the dropdown isn't open, try to create a new item using inputValue and prevent blur if successful + if (!isOpen && addNewItem(inputValue)) { + event.preventDefault() + event.stopPropagation() + } + break + + case ',': + // comma: try to create a new item using inputValue + event.preventDefault() + addNewItem(inputValue) + break + } + }, + onPaste: event => { + const data = + // modern browsers + event.clipboardData?.getData('text/plain') ?? + // @ts-ignore IE11 + window.clipboardData?.getData('text') + + if (data) { + const emails = data + .split(/[\r\n,; ]+/) + .filter(item => item.includes('@')) + .map(email => email.replace(matchAllSpaces, '')) + + if (emails.length) { + // pasted comma-separated email addresses + event.preventDefault() + + // dedupe emails in pasted content and previously-entered items + const uniqueEmails = [...new Set(emails)].filter( + email => !selectedEmails.includes(email) + ) + + for (const email of uniqueEmails) { + addNewItem(email) + } + } + } + }, + }) + )} + /> +
+ +
+
    + {isOpen && + filteredOptions.map((item, index) => ( +
+
+ + + {isSharingUpdatesEnabled && ( + +
+ setInviteSent(true)} + hasErrors={inputErrors.length > 0} + /> +
+
+ )} + + {isSharingUpdatesEnabled && ( + <> + {inputErrors.length > 0 ? ( + inputErrors.length === 1 ? ( + + {inputErrors[0].message} + + ) : ( +
    + {inputErrors.map(inputError => ( +
  • + {inputError.message} +
  • + ))} +
+ ) + ) : null} + {inputErrors.length === 0 && inviteSent && ( + + {t('invitations_sent')} + + )} + + )} ) } @@ -294,12 +441,19 @@ function Option({ getItemProps: (any: any) => any index: number }) { + const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates') return (
  • + ) : ( + + ) + } className={classnames({ active: selected, })} @@ -316,13 +470,16 @@ function SelectedItem({ focusInput, getSelectedItemProps, index, + hasIssue, }: { removeSelectedItem: (item: ContactItem) => void selectedItem: ContactItem focusInput: () => void getSelectedItemProps: (any: any) => any index: number + hasIssue?: boolean }) { + const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates') const handleClick = useCallback( (event: React.MouseEvent) => { event.preventDefault() @@ -333,15 +490,25 @@ function SelectedItem({ [focusInput, removeSelectedItem, selectedItem] ) + let prepend: React.ReactNode + if (isSharingUpdatesEnabled) { + if (hasIssue) { + prepend = + } + } else { + prepend = + } + return ( - } + {selectedItem.display} - + ) } diff --git a/services/web/frontend/js/features/share-project-modal/components/send-invites-notice.tsx b/services/web/frontend/js/features/share-project-modal/components/send-invites-notice.tsx index 261eccce9d..1d2b015773 100644 --- a/services/web/frontend/js/features/share-project-modal/components/send-invites-notice.tsx +++ b/services/web/frontend/js/features/share-project-modal/components/send-invites-notice.tsx @@ -1,21 +1,29 @@ import { useTranslation } from 'react-i18next' import { useProjectContext } from '@/shared/context/project-context' -import Notification from '@/shared/components/notification' -import { PublicAccessLevel } from '../../../../../types/public-access-level' import { useEditorContext } from '@/shared/context/editor-context' import OLRow from '@/shared/components/ol/ol-row' import OLCol from '@/shared/components/ol/ol-col' +import OLNotification from '@/shared/components/ol/ol-notification' +import { useFeatureFlag } from '@/shared/context/split-test-context' -export default function SendInvitesNotice() { +function SendInvitesNotice() { + const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates') const { project } = useProjectContext() const { publicAccessLevel } = project || {} const { isPendingEditor } = useEditorContext() const { t } = useTranslation() + let accessLevelText = '' + if (publicAccessLevel === 'private') { + accessLevelText = t('to_add_more_collaborators') + } else if (publicAccessLevel === 'tokenBased') { + accessLevelText = t('to_change_access_permissions') + } + return (
    {isPendingEditor && ( - )} - - - - - + {accessLevelText && + (isSharingUpdatesEnabled ? ( + + ) : ( + + + {accessLevelText} + + + ))}
    ) } -type AccessLevelProps = { - level: PublicAccessLevel | undefined -} - -function AccessLevel({ level }: AccessLevelProps) { - const { t } = useTranslation() - switch (level) { - case 'private': - return {t('to_add_more_collaborators')} - - case 'tokenBased': - return {t('to_change_access_permissions')} - - default: - return '' - } -} +export default SendInvitesNotice diff --git a/services/web/frontend/js/features/share-project-modal/components/send-invites.tsx b/services/web/frontend/js/features/share-project-modal/components/send-invites.tsx index 089544ddf0..98ec6620a3 100644 --- a/services/web/frontend/js/features/share-project-modal/components/send-invites.tsx +++ b/services/web/frontend/js/features/share-project-modal/components/send-invites.tsx @@ -19,10 +19,6 @@ export default function SendInvites({ }) { const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates') - if (isSharingUpdatesEnabled) { - return null - } - return ( , 'type' | 'className' | 'children' -> +> & { unfilled?: boolean } -function FormFeedback(props: FormFeedbackProps) { +function FormFeedback({ unfilled, ...props }: FormFeedbackProps) { return ( - + {props.children} diff --git a/services/web/frontend/js/shared/components/form/form-text.tsx b/services/web/frontend/js/shared/components/form/form-text.tsx index 8380b879e7..acc3bb312c 100644 --- a/services/web/frontend/js/shared/components/form/form-text.tsx +++ b/services/web/frontend/js/shared/components/form/form-text.tsx @@ -9,6 +9,7 @@ export type FormTextProps = MergeAndOverride< BS5FormTextProps, { type?: TextType + unfilled?: boolean marginless?: boolean } > @@ -22,16 +23,35 @@ const typeClassMap: Partial> = { export const getFormTextClass = (type?: TextType) => typeClassMap[type || 'default'] -function FormTextIcon({ type }: { type?: TextType }) { +function FormTextIcon({ + type, + unfilled, +}: Pick) { switch (type) { case 'info': - return + return unfilled ? ( + + ) : ( + + ) case 'success': - return + return unfilled ? ( + + ) : ( + + ) case 'warning': - return + return unfilled ? ( + + ) : ( + + ) case 'error': - return + return unfilled ? ( + + ) : ( + + ) default: return null } @@ -42,6 +62,7 @@ function FormText({ marginless, children, className, + unfilled, ...rest }: FormTextProps) { return ( @@ -50,7 +71,7 @@ function FormText({ {...rest} > - + {children} diff --git a/services/web/frontend/js/shared/components/ol/ol-form-feedback.tsx b/services/web/frontend/js/shared/components/ol/ol-form-feedback.tsx index e2fc86260a..8ebecd2803 100644 --- a/services/web/frontend/js/shared/components/ol/ol-form-feedback.tsx +++ b/services/web/frontend/js/shared/components/ol/ol-form-feedback.tsx @@ -1,13 +1,8 @@ -import { Form } from 'react-bootstrap' -import { ComponentProps } from 'react' -import FormFeedback from '@/shared/components/form/form-feedback' +import FormFeedback, { + FormFeedbackProps, +} from '@/shared/components/form/form-feedback' -type OLFormFeedbackProps = Pick< - ComponentProps, - 'type' | 'className' | 'children' -> - -function OLFormFeedback(props: OLFormFeedbackProps) { +function OLFormFeedback(props: FormFeedbackProps) { return } diff --git a/services/web/frontend/js/shared/components/select.tsx b/services/web/frontend/js/shared/components/select.tsx index 34774811fe..3b5f36a77f 100644 --- a/services/web/frontend/js/shared/components/select.tsx +++ b/services/web/frontend/js/shared/components/select.tsx @@ -18,6 +18,7 @@ import OLSpinner from './ol/ol-spinner' import DSFormLabel from '@/shared/components/ds/ds-form-label' import DSFormGroup from '@/shared/components/ds/ds-form-group' import DSFormControl from '@/shared/components/ds/ds-form-control' +import { DropdownItemProps } from '@/shared/components/types/dropdown-menu-props' export type SelectProps = { // The items rendered as dropdown options. @@ -39,6 +40,10 @@ export type SelectProps = { itemToSubtitle?: (item: T | null | undefined) => string // Stringifies an item. The resulting string is rendered as a React `key` for each item. itemToKey: (item: T) => string + // Maps an item to a leading icon. + itemToLeadingIcon?: ( + item: T | null | undefined + ) => DropdownItemProps['leadingIcon'] // Callback invoked after the selected item is updated. onSelectedItemChanged?: (item: T | null | undefined) => void // Optionally directly control the selected item. @@ -57,6 +62,7 @@ export type SelectProps = { dataTestId?: string // CIAM-specific layout isCiam?: boolean + size?: React.ComponentProps['size'] } export const Select = ({ @@ -68,6 +74,7 @@ export const Select = ({ defaultItem, itemToSubtitle, itemToKey, + itemToLeadingIcon, onSelectedItemChanged, selected, disabled = false, @@ -77,6 +84,7 @@ export const Select = ({ selectedIcon = false, dataTestId, isCiam, + size, }: SelectProps) => { const [selectedItem, setSelectedItem] = useState( defaultItem @@ -185,6 +193,9 @@ export const Select = ({ trailingIcon={ selectedIcon && selectedItem === item ? tickIcon() : undefined } + leadingIcon={ + itemToLeadingIcon ? itemToLeadingIcon(item) : undefined + } description={itemToSubtitle ? itemToSubtitle(item) : undefined} {...itemProps} disabled={disabled} @@ -250,6 +261,7 @@ export const Select = ({ className="align-text-bottom" /> } + size={size} /> {dropdown} diff --git a/services/web/frontend/stylesheets/pages/editor/tags-input.scss b/services/web/frontend/stylesheets/pages/editor/tags-input.scss index 6c5e8cd222..48afd0c913 100644 --- a/services/web/frontend/stylesheets/pages/editor/tags-input.scss +++ b/services/web/frontend/stylesheets/pages/editor/tags-input.scss @@ -37,6 +37,21 @@ } } } + + &.form-control-lg { + min-height: initial; + padding-top: calc(#{$input-padding-y-lg} - #{$input-border-width} * 2); + padding-bottom: calc(#{$input-padding-y-lg} - #{$input-border-width} * 2); + } +} + +.modal-redesign { + .tags-input .tags { + .badge-tag { + margin-top: 1px; + margin-bottom: 1px; + } + } } .tags-input .tags:focus-within { diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 63b6bf5d50..9e553a378c 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -755,6 +755,7 @@ "email_does_not_belong_to_university": "We don’t recognize that domain as being affiliated with your university. Please contact us to add the affiliation.", "email_limit_reached": "You can have a maximum of <0>__emailAddressLimit__ email addresses on this account. To add another email address, please delete an existing one.", "email_link_expired": "Email link expired, please request a new one.", + "email_must_be_a_valid_format": "Email addresses must be a valid format.", "email_must_be_linked_to_institution": "As a member of __institutionName__, this email address can only be added via single sign-on on your <0>account settings page. Please add a different recovery email address.", "email_notifications_are_currently_in_beta": "Email notifications are currently in beta.", "email_or_password_wrong_try_again": "Your email or password is incorrect. Please try again.", @@ -781,6 +782,7 @@ "end_time_utc": "End time (UTC)", "ensure_recover_account": "This will ensure that it can be used to recover your __appName__ account in case you lose access to your primary email address.", "enter_any_size_including_units_or_valid_latex_command": "Enter any size (including units) or valid LaTeX command", + "enter_emails_separated_by_commas": "Enter emails separated by commas", "enter_manually": "Enter manually", "enter_tax_id_number": "Enter tax ID number", "enter_the_code": "Enter the 6-digit code sent to __email__.", @@ -1257,6 +1259,7 @@ "invalid_zip_file": "Invalid zip file", "invert_pdf_preview_colors": "Invert PDF preview colors", "invert_pdf_preview_colors_when_in_dark_mode": "Invert PDF preview colors when in dark mode", + "invitations_sent": "Invitation(s) sent.", "invite": "Invite", "invite_expired": "The invite may have expired", "invite_group_members": "Invite group members", @@ -1694,6 +1697,7 @@ "one_user": "1 user", "ongoing_experiments": "Ongoing experiments", "online_latex_editor": "Online LaTeX Editor", + "only_add_people_who_dont_yet_have_access": "Only add people who don’t yet have access.", "only_group_admin_or_managers_can_delete_your_account_1": "By becoming a managed user, your organization will have admin rights over your account and control over your stuff, including the right to close your account and access, delete and share your stuff. As a result:", "only_group_admin_or_managers_can_delete_your_account_10": "If you have an individual subscription, we’ll automatically terminate it and cancel its renewal when your account becomes managed. To request a pro-rata refund for the remainder, please contact Support.", "only_group_admin_or_managers_can_delete_your_account_3": "Your group admin and group managers will be able to reassign ownership of your projects to another group member.", diff --git a/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.tsx b/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.tsx index 8296069b37..a7671307fb 100644 --- a/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.tsx +++ b/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.tsx @@ -1052,4 +1052,127 @@ describe('', function () { ) }) }) + + describe('with "sharing-updates" feature flag', function () { + beforeEach(function () { + window.metaAttributesCache.set('ol-splitTestVariants', { + 'sharing-updates': 'enabled', + }) + }) + + afterEach(function () { + window.metaAttributesCache.set('ol-splitTestVariants', {}) + }) + + it('disables the invite button when no email is entered', async function () { + renderWithEditorContext( + , + createContextProps({ publicAccessLevel: 'tokenBased' }) + ) + + const inviteButton = (await screen.findByRole('button', { + name: /invite/i, + })) as HTMLButtonElement + + expect(inviteButton.disabled).to.be.true + }) + + it('enables the invite button once a valid email is typed', async function () { + renderWithEditorContext( + , + createContextProps({ publicAccessLevel: 'tokenBased' }) + ) + + const inviteButton = (await screen.findByRole('button', { + name: /invite/i, + })) as HTMLButtonElement + + expect(inviteButton.disabled).to.be.true + + const inputElement = await screen.findByTestId('collaborator-email-input') + fireEvent.change(inputElement, { + target: { value: 'new@example.com' }, + }) + + await waitFor(() => expect(inviteButton.disabled).to.be.false) + }) + + it('shows a validation error and disables invite button for an invalid email format', async function () { + renderWithEditorContext( + , + createContextProps({ publicAccessLevel: 'tokenBased' }) + ) + + const inputElement = await screen.findByTestId('collaborator-email-input') + fireEvent.change(inputElement, { target: { value: 'invalid@' } }) + fireEvent.blur(inputElement) + + await screen.findByText('Email addresses must be a valid format.') + + const inviteButton = screen.getByRole('button', { + name: /invite/i, + }) as HTMLButtonElement + expect(inviteButton.disabled).to.be.true + }) + + it('shows a validation error and disables invite button when email already has access', async function () { + const members: ProjectMember[] = [ + { + _id: 'existing-member' as UserId, + email: 'member@example.com', + privileges: 'readAndWrite', + first_name: 'Existing', + last_name: 'Member', + }, + ] + + renderWithEditorContext( + , + createContextProps({ publicAccessLevel: 'tokenBased', members }) + ) + + const inputElement = await screen.findByTestId('collaborator-email-input') + fireEvent.change(inputElement, { + target: { value: 'member@example.com' }, + }) + fireEvent.blur(inputElement) + + await screen.findByText('Only add people who don’t yet have access.') + + const inviteButton = screen.getByRole('button', { + name: /invite/i, + }) as HTMLButtonElement + expect(inviteButton.disabled).to.be.true + }) + + it('shows "invitations sent" message after a successful invite', async function () { + fetchMock.post('express:/project/:projectId/invite', { + status: 200, + body: { + invite: { + _id: 'new-invite', + email: 'new@example.com', + privileges: 'readAndWrite', + }, + }, + }) + + renderWithEditorContext( + , + createContextProps({ publicAccessLevel: 'tokenBased' }) + ) + + const inputElement = await screen.findByTestId('collaborator-email-input') + fireEvent.change(inputElement, { target: { value: 'new@example.com' } }) + fireEvent.blur(inputElement) + + const inviteButton = (await screen.findByRole('button', { + name: /invite/i, + })) as HTMLButtonElement + await waitFor(() => expect(inviteButton.disabled).to.be.false) + await userEvent.click(inviteButton) + + await screen.findByText('Invitation(s) sent.') + }) + }) })