mirror of
https://github.com/overleaf/overleaf.git
synced 2025-04-26 05:52:11 +00:00
Merge pull request #18861 from overleaf/rh-editor-limit-exceeded
[web]: Handle exceeded editor limit in share modal GitOrigin-RevId: 23a15805ca98327ae4a7fc731bbca3982c90bad5
This commit is contained in:
parent
04432478e1
commit
64d9792fe3
32 changed files with 2135 additions and 70 deletions
|
@ -578,6 +578,12 @@ const _ProjectController = {
|
|||
? 'project/ide-react-detached'
|
||||
: 'project/ide-react'
|
||||
|
||||
const assignLink = await SplitTestHandler.promises.getAssignmentForUser(
|
||||
project.owner_ref,
|
||||
'link-sharing-warning'
|
||||
)
|
||||
const linkSharingWarning = assignLink.variant === 'active'
|
||||
|
||||
res.render(template, {
|
||||
title: project.name,
|
||||
priority_title: true,
|
||||
|
@ -651,6 +657,7 @@ const _ProjectController = {
|
|||
optionalPersonalAccessToken,
|
||||
hasTrackChangesFeature: Features.hasFeature('track-changes'),
|
||||
projectTags,
|
||||
linkSharingWarning,
|
||||
})
|
||||
timer.done()
|
||||
} catch (err) {
|
||||
|
|
|
@ -36,6 +36,7 @@ meta(name="ol-optionalPersonalAccessToken", data-type="boolean" content=optional
|
|||
meta(name="ol-hasTrackChangesFeature", data-type="boolean" content=hasTrackChangesFeature)
|
||||
meta(name="ol-inactiveTutorials", data-type="json" content=user.inactiveTutorials)
|
||||
meta(name="ol-projectTags" data-type="json" content=projectTags)
|
||||
meta(name="ol-linkSharingWarning" data-type="boolean" content=linkSharingWarning)
|
||||
meta(name="ol-loadingText", data-type="string" content=translate("loading"))
|
||||
meta(name="ol-translationLoadErrorMessage", data-type="string" content=translate("could_not_load_translations"))
|
||||
|
||||
|
|
|
@ -51,6 +51,7 @@
|
|||
"add_more_members": "",
|
||||
"add_new_email": "",
|
||||
"add_or_remove_project_from_tag": "",
|
||||
"add_people": "",
|
||||
"add_role_and_department": "",
|
||||
"add_to_tag": "",
|
||||
"add_your_comment_here": "",
|
||||
|
@ -359,8 +360,10 @@
|
|||
"edit_tag": "",
|
||||
"editing": "",
|
||||
"editing_captions": "",
|
||||
"editor": "",
|
||||
"editor_and_pdf": "",
|
||||
"editor_disconected_click_to_reconnect": "",
|
||||
"editor_limit_exceeded_in_this_project": "",
|
||||
"editor_only_hide_pdf": "",
|
||||
"editor_theme": "",
|
||||
"educational_discount_for_groups_of_x_or_more": "",
|
||||
|
@ -647,6 +650,7 @@
|
|||
"invalid_password_contains_email": "",
|
||||
"invalid_password_too_similar": "",
|
||||
"invalid_request": "",
|
||||
"invite": "",
|
||||
"invite_more_collabs": "",
|
||||
"invite_not_accepted": "",
|
||||
"invited_to_group": "",
|
||||
|
@ -703,6 +707,7 @@
|
|||
"library": "",
|
||||
"license_for_educational_purposes": "",
|
||||
"limited_offer": "",
|
||||
"limited_to_n_editors_per_project": "",
|
||||
"line_height": "",
|
||||
"line_width_is_the_width_of_the_line_in_the_current_environment": "",
|
||||
"link": "",
|
||||
|
@ -712,6 +717,7 @@
|
|||
"link_institutional_email_get_started": "",
|
||||
"link_sharing": "",
|
||||
"link_sharing_is_off": "",
|
||||
"link_sharing_is_off_short": "",
|
||||
"link_sharing_is_on": "",
|
||||
"link_to_github": "",
|
||||
"link_to_github_description": "",
|
||||
|
@ -747,6 +753,7 @@
|
|||
"main_file_not_found": "",
|
||||
"make_a_copy": "",
|
||||
"make_email_primary_description": "",
|
||||
"make_owner": "",
|
||||
"make_primary": "",
|
||||
"make_private": "",
|
||||
"manage_beta_program_membership": "",
|
||||
|
@ -1014,6 +1021,7 @@
|
|||
"react_history_tutorial_title": "",
|
||||
"reactivate_subscription": "",
|
||||
"read_lines_from_path": "",
|
||||
"read_more": "",
|
||||
"read_more_about_free_compile_timeouts_servers": "",
|
||||
"read_only": "",
|
||||
"read_only_token": "",
|
||||
|
@ -1053,6 +1061,7 @@
|
|||
"remind_before_trial_ends": "",
|
||||
"remote_service_error": "",
|
||||
"remove": "",
|
||||
"remove_access": "",
|
||||
"remove_collaborator": "",
|
||||
"remove_from_group": "",
|
||||
"remove_link": "",
|
||||
|
@ -1522,6 +1531,7 @@
|
|||
"upgrade_cc_btn": "",
|
||||
"upgrade_for_12x_more_compile_time": "",
|
||||
"upgrade_now": "",
|
||||
"upgrade_to_add_more_editors": "",
|
||||
"upgrade_to_get_feature": "",
|
||||
"upgrade_to_track_changes": "",
|
||||
"upload": "",
|
||||
|
@ -1569,6 +1579,7 @@
|
|||
"view_options": "",
|
||||
"view_pdf": "",
|
||||
"view_your_invoices": "",
|
||||
"viewer": "",
|
||||
"viewing_x": "",
|
||||
"visual_editor": "",
|
||||
"visual_editor_is_only_available_for_tex_files": "",
|
||||
|
@ -1590,6 +1601,7 @@
|
|||
"what_should_we_call_you": "",
|
||||
"when_you_tick_the_include_caption_box": "",
|
||||
"wide": "",
|
||||
"will_lose_edit_access_on_date": "",
|
||||
"with_premium_subscription_you_also_get": "",
|
||||
"word_count": "",
|
||||
"work_offline": "",
|
||||
|
@ -1627,6 +1639,7 @@
|
|||
"you_are_on_x_plan_as_member_of_group_subscription_y_administered_by_z": "",
|
||||
"you_can_now_enable_sso": "",
|
||||
"you_can_now_log_in_sso": "",
|
||||
"you_can_only_add_n_people_to_edit_a_project": "",
|
||||
"you_can_request_a_maximum_of_limit_fixes_per_day": "",
|
||||
"you_cant_add_or_change_password_due_to_sso": "",
|
||||
"you_cant_join_this_group_subscription": "",
|
||||
|
@ -1657,6 +1670,7 @@
|
|||
"your_password_was_detected": "",
|
||||
"your_plan": "",
|
||||
"your_plan_is_changing_at_term_end": "",
|
||||
"your_plan_is_limited_to_n_editors": "",
|
||||
"your_project_exceeded_compile_timeout_limit_on_free_plan": "",
|
||||
"your_project_near_compile_timeout_limit": "",
|
||||
"your_projects": "",
|
||||
|
|
|
@ -3,7 +3,9 @@ import { useOnlineUsersContext } from '@/features/ide-react/context/online-users
|
|||
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
|
||||
import * as eventTracking from '@/infrastructure/event-tracking'
|
||||
import EditorNavigationToolbarRoot from '@/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root'
|
||||
import NewShareProjectModal from '@/features/share-project-modal/components/restricted-link-sharing/share-project-modal'
|
||||
import ShareProjectModal from '@/features/share-project-modal/components/share-project-modal'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
function EditorNavigationToolbar() {
|
||||
const [showShareModal, setShowShareModal] = useState(false)
|
||||
|
@ -19,6 +21,8 @@ function EditorNavigationToolbar() {
|
|||
setShowShareModal(false)
|
||||
}, [])
|
||||
|
||||
const showNewShareModal = getMeta('ol-linkSharingWarning')
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditorNavigationToolbarRoot
|
||||
|
@ -26,10 +30,17 @@ function EditorNavigationToolbar() {
|
|||
openDoc={openDoc}
|
||||
openShareProjectModal={handleOpenShareModal}
|
||||
/>
|
||||
<ShareProjectModal
|
||||
show={showShareModal}
|
||||
handleHide={handleHideShareModal}
|
||||
/>
|
||||
{showNewShareModal ? (
|
||||
<NewShareProjectModal
|
||||
show={showShareModal}
|
||||
handleHide={handleHideShareModal}
|
||||
/>
|
||||
) : (
|
||||
<ShareProjectModal
|
||||
show={showShareModal}
|
||||
handleHide={handleHideShareModal}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import Notification from '@/shared/components/notification'
|
||||
import { upgradePlan } from '../../../../main/account-upgrade'
|
||||
|
||||
export default function AddCollaboratorsUpgrade() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="add-collaborators-upgrade">
|
||||
<Notification
|
||||
type="warning"
|
||||
title={t('editor_limit_exceeded_in_this_project')}
|
||||
content={<p>{t('your_plan_is_limited_to_n_editors')}</p>}
|
||||
action={
|
||||
<div className="upgrade-actions">
|
||||
<Button
|
||||
bsSize="sm"
|
||||
className="btn-secondary"
|
||||
onClick={() => upgradePlan('project-sharing')}
|
||||
>
|
||||
{t('upgrade')}
|
||||
</Button>
|
||||
<a
|
||||
href="https://www.overleaf.com/blog/changes-to-project-sharing"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t('read_more')}
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,174 @@
|
|||
import { useState, useMemo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Col, Form, Button } from 'react-bootstrap'
|
||||
import { useMultipleSelection } from 'downshift'
|
||||
import { useShareProjectContext } from './share-project-modal'
|
||||
import SelectCollaborators from './select-collaborators'
|
||||
import { resendInvite, sendInvite } from '../../utils/api'
|
||||
import { useUserContacts } from '../../hooks/use-user-contacts'
|
||||
import useIsMounted from '@/shared/hooks/use-is-mounted'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import { sendMB } from '@/infrastructure/event-tracking'
|
||||
import ClickableElementEnhancer from '@/shared/components/clickable-element-enhancer'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
export default function AddCollaborators({ readOnly }) {
|
||||
const [privileges, setPrivileges] = useState('readAndWrite')
|
||||
|
||||
const isMounted = useIsMounted()
|
||||
|
||||
const { data: contacts } = useUserContacts()
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { updateProject, setInFlight, setError } = useShareProjectContext()
|
||||
|
||||
const { _id: projectId, members, invites } = useProjectContext()
|
||||
|
||||
const currentMemberEmails = useMemo(
|
||||
() => (members || []).map(member => member.email).sort(),
|
||||
[members]
|
||||
)
|
||||
|
||||
const nonMemberContacts = useMemo(() => {
|
||||
if (!contacts) {
|
||||
return null
|
||||
}
|
||||
|
||||
return contacts.filter(
|
||||
contact => !currentMemberEmails.includes(contact.email)
|
||||
)
|
||||
}, [contacts, currentMemberEmails])
|
||||
|
||||
const multipleSelectionProps = useMultipleSelection({
|
||||
initialActiveIndex: 0,
|
||||
initialSelectedItems: [],
|
||||
})
|
||||
|
||||
const { reset, selectedItems } = multipleSelectionProps
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!selectedItems.length) {
|
||||
return
|
||||
}
|
||||
|
||||
// reset the selected items
|
||||
reset()
|
||||
|
||||
setError(undefined)
|
||||
setInFlight(true)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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,
|
||||
current_invites_amount: invites.length,
|
||||
})
|
||||
} catch (error) {
|
||||
setInFlight(false)
|
||||
setError(
|
||||
error.data?.errorReason ||
|
||||
(error.response?.status === 429
|
||||
? 'too_many_requests'
|
||||
: 'generic_something_went_wrong')
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
if (data.error) {
|
||||
setError(data.error)
|
||||
setInFlight(false)
|
||||
} else if (data.invite) {
|
||||
updateProject({
|
||||
invites: invites.concat(data.invite),
|
||||
})
|
||||
} else if (data.users) {
|
||||
updateProject({
|
||||
members: members.concat(data.users),
|
||||
})
|
||||
} else if (data.user) {
|
||||
updateProject({
|
||||
members: members.concat(data.user),
|
||||
})
|
||||
}
|
||||
|
||||
// wait for a short time, so canAddCollaborators has time to update with new collaborator information
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
}
|
||||
|
||||
setInFlight(false)
|
||||
}, [
|
||||
currentMemberEmails,
|
||||
invites,
|
||||
isMounted,
|
||||
members,
|
||||
privileges,
|
||||
projectId,
|
||||
reset,
|
||||
selectedItems,
|
||||
setError,
|
||||
setInFlight,
|
||||
updateProject,
|
||||
])
|
||||
|
||||
return (
|
||||
<Form className="add-collabs">
|
||||
<Col xs={10}>
|
||||
<SelectCollaborators
|
||||
loading={!nonMemberContacts}
|
||||
options={nonMemberContacts || []}
|
||||
placeholder="Email, comma separated"
|
||||
multipleSelectionProps={multipleSelectionProps}
|
||||
privileges={privileges}
|
||||
setPrivileges={setPrivileges}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col xs={2}>
|
||||
<div>
|
||||
<span> </span>
|
||||
<ClickableElementEnhancer
|
||||
as={Button}
|
||||
onClick={handleSubmit}
|
||||
bsStyle="primary"
|
||||
>
|
||||
{t('invite')}
|
||||
</ClickableElementEnhancer>
|
||||
</div>
|
||||
</Col>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
AddCollaborators.propTypes = {
|
||||
readOnly: PropTypes.bool,
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
import { Button } from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Notification from '@/shared/components/notification'
|
||||
import { upgradePlan } from '@/main/account-upgrade'
|
||||
|
||||
export default function CollaboratorsLimitUpgrade() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Notification
|
||||
type="info"
|
||||
customIcon={<div />}
|
||||
title={t('upgrade_to_add_more_editors')}
|
||||
content={<p>{t('you_can_only_add_n_people_to_edit_a_project')}</p>}
|
||||
action={
|
||||
<Button
|
||||
bsSize="sm"
|
||||
className="btn-secondary"
|
||||
onClick={() => upgradePlan('project-sharing')}
|
||||
>
|
||||
{t('upgrade')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,242 @@
|
|||
import { useState, useEffect, useMemo, MouseEventHandler } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useShareProjectContext } from './share-project-modal'
|
||||
import TransferOwnershipModal from './transfer-ownership-modal'
|
||||
import { removeMemberFromProject, updateMember } from '../../utils/api'
|
||||
import { Button, Col, Form, FormGroup } from 'react-bootstrap'
|
||||
import Icon from '@/shared/components/icon'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import { sendMB } from '@/infrastructure/event-tracking'
|
||||
import { Select } from '@/shared/components/select'
|
||||
import type { ProjectContextMember } from '@/shared/context/types/project-context'
|
||||
import { PermissionsLevel } from '@/features/ide-react/types/permissions'
|
||||
|
||||
type PermissionsOption = PermissionsLevel | 'removeAccess'
|
||||
|
||||
type EditMemberProps = {
|
||||
member: ProjectContextMember
|
||||
hasExceededCollaboratorLimit: boolean
|
||||
canAddCollaborators: boolean
|
||||
}
|
||||
|
||||
type Privilege = {
|
||||
key: PermissionsOption
|
||||
label: string
|
||||
}
|
||||
|
||||
export default function EditMember({
|
||||
member,
|
||||
hasExceededCollaboratorLimit,
|
||||
canAddCollaborators,
|
||||
}: EditMemberProps) {
|
||||
const [privileges, setPrivileges] = useState<PermissionsOption>(
|
||||
member.privileges
|
||||
)
|
||||
const [confirmingOwnershipTransfer, setConfirmingOwnershipTransfer] =
|
||||
useState(false)
|
||||
const [privilegeChangePending, setPrivilegeChangePending] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
// update the local state if the member's privileges change externally
|
||||
useEffect(() => {
|
||||
setPrivileges(member.privileges)
|
||||
}, [member.privileges])
|
||||
|
||||
const { updateProject, monitorRequest } = useShareProjectContext()
|
||||
const { _id: projectId, members, invites } = useProjectContext()
|
||||
|
||||
// Immediately commit this change if it's lower impact (eg. editor > viewer)
|
||||
// but show a confirmation button for removing access
|
||||
function handlePrivilegeChange(newPrivileges: PermissionsOption) {
|
||||
setPrivileges(newPrivileges)
|
||||
if (newPrivileges !== 'removeAccess') {
|
||||
commitPrivilegeChange(newPrivileges)
|
||||
} else {
|
||||
setPrivilegeChangePending(true)
|
||||
}
|
||||
}
|
||||
|
||||
function shouldWarnMember() {
|
||||
return hasExceededCollaboratorLimit && privileges === 'readAndWrite'
|
||||
}
|
||||
|
||||
function commitPrivilegeChange(newPrivileges: PermissionsOption) {
|
||||
setPrivileges(newPrivileges)
|
||||
setPrivilegeChangePending(false)
|
||||
|
||||
if (newPrivileges === 'owner') {
|
||||
setConfirmingOwnershipTransfer(true)
|
||||
} else if (newPrivileges === 'removeAccess') {
|
||||
monitorRequest(() => removeMemberFromProject(projectId, member)).then(
|
||||
() => {
|
||||
const updatedMembers = members.filter(existing => existing !== member)
|
||||
updateProject({
|
||||
members: updatedMembers,
|
||||
})
|
||||
sendMB('collaborator-removed', {
|
||||
project_id: projectId,
|
||||
current_collaborators_amount: updatedMembers.length,
|
||||
current_invites_amount: invites.length,
|
||||
})
|
||||
}
|
||||
)
|
||||
} else if (
|
||||
newPrivileges === 'readAndWrite' ||
|
||||
newPrivileges === 'readOnly'
|
||||
) {
|
||||
monitorRequest(() =>
|
||||
updateMember(projectId, member, {
|
||||
privilegeLevel: newPrivileges,
|
||||
})
|
||||
).then(() => {
|
||||
updateProject({
|
||||
members: members.map(item =>
|
||||
item._id === member._id ? { ...item, newPrivileges } : item
|
||||
),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmingOwnershipTransfer) {
|
||||
return (
|
||||
<TransferOwnershipModal
|
||||
member={member}
|
||||
cancel={() => {
|
||||
setConfirmingOwnershipTransfer(false)
|
||||
setPrivileges(member.privileges)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Form
|
||||
horizontal
|
||||
id="share-project-form"
|
||||
onSubmit={e => {
|
||||
e.preventDefault()
|
||||
commitPrivilegeChange(privileges)
|
||||
}}
|
||||
>
|
||||
<FormGroup className="project-member">
|
||||
<Col xs={7}>
|
||||
<div className="project-member-email-icon">
|
||||
<Icon type={shouldWarnMember() ? 'warning' : 'user'} fw />
|
||||
<div className="email-warning">
|
||||
{member.email}
|
||||
{shouldWarnMember() && (
|
||||
<div className="subtitle">
|
||||
{t('will_lose_edit_access_on_date', {
|
||||
date: '[date]',
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<Col xs={2}>
|
||||
{privileges !== member.privileges && privilegeChangePending && (
|
||||
<ChangePrivilegesActions
|
||||
handleReset={() => setPrivileges(member.privileges)}
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
|
||||
<Col xs={3}>
|
||||
<SelectPrivilege
|
||||
value={privileges}
|
||||
handleChange={value => {
|
||||
value && handlePrivilegeChange(value.key)
|
||||
}}
|
||||
canAddCollaborators={canAddCollaborators}
|
||||
/>
|
||||
</Col>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
EditMember.propTypes = {
|
||||
member: PropTypes.shape({
|
||||
_id: PropTypes.string.isRequired,
|
||||
email: PropTypes.string.isRequired,
|
||||
privileges: PropTypes.string.isRequired,
|
||||
}),
|
||||
hasExceededCollaboratorLimit: PropTypes.bool.isRequired,
|
||||
canAddCollaborators: PropTypes.bool.isRequired,
|
||||
}
|
||||
|
||||
type SelectPrivilegeProps = {
|
||||
value: string
|
||||
handleChange: (item: Privilege | null | undefined) => void
|
||||
canAddCollaborators: boolean
|
||||
}
|
||||
|
||||
function SelectPrivilege({
|
||||
value,
|
||||
handleChange,
|
||||
canAddCollaborators,
|
||||
}: SelectPrivilegeProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const privileges = useMemo(
|
||||
(): Privilege[] => [
|
||||
{ key: 'owner', label: t('make_owner') },
|
||||
{ key: 'readAndWrite', label: t('editor') },
|
||||
{ key: 'readOnly', label: t('viewer') },
|
||||
{ key: 'removeAccess', label: t('remove_access') },
|
||||
],
|
||||
[t]
|
||||
)
|
||||
|
||||
function getPrivilegeSubtitle(privilege: PermissionsOption) {
|
||||
return !canAddCollaborators &&
|
||||
privilege === 'readAndWrite' &&
|
||||
value !== 'readAndWrite'
|
||||
? t('limited_to_n_editors_per_project')
|
||||
: ''
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
items={privileges}
|
||||
itemToKey={item => item.key}
|
||||
itemToString={item => (item ? item.label : '')}
|
||||
itemToSubtitle={item => (item ? getPrivilegeSubtitle(item.key) : '')}
|
||||
itemToDisabled={item =>
|
||||
item ? getPrivilegeSubtitle(item.key) !== '' : false
|
||||
}
|
||||
defaultItem={privileges.find(item => item.key === value)}
|
||||
selected={privileges.find(item => item.key === value)}
|
||||
name="privileges"
|
||||
onSelectedItemChanged={handleChange}
|
||||
selectedIcon
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type ChangePrivilegesActionsProps = {
|
||||
handleReset: MouseEventHandler<Button>
|
||||
}
|
||||
function ChangePrivilegesActions({
|
||||
handleReset,
|
||||
}: ChangePrivilegesActionsProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="text-center">
|
||||
<Button type="submit" bsSize="sm" bsStyle="primary">
|
||||
{t('change_or_cancel-change')}
|
||||
</Button>
|
||||
<div className="text-sm">
|
||||
{t('change_or_cancel-or')}
|
||||
|
||||
<Button type="button" className="btn-inline-link" onClick={handleReset}>
|
||||
{t('change_or_cancel-cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,121 @@
|
|||
import { useCallback } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useShareProjectContext } from './share-project-modal'
|
||||
import Icon from '@/shared/components/icon'
|
||||
import { Button, Col, Row } from 'react-bootstrap'
|
||||
import Tooltip from '@/shared/components/tooltip'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import MemberPrivileges from './member-privileges'
|
||||
import { resendInvite, revokeInvite } from '../../utils/api'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import { sendMB } from '@/infrastructure/event-tracking'
|
||||
|
||||
export default function Invite({ invite, isProjectOwner }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Row className="project-invite">
|
||||
<Col xs={8}>
|
||||
<div>{invite.email}</div>
|
||||
<div className="small">
|
||||
{t('invite_not_accepted')}
|
||||
.
|
||||
{isProjectOwner && <ResendInvite invite={invite} />}
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<Col xs={3} className="text-right">
|
||||
<MemberPrivileges privileges={invite.privileges} />
|
||||
</Col>
|
||||
|
||||
{isProjectOwner && (
|
||||
<Col xs={1} className="text-center">
|
||||
<RevokeInvite invite={invite} />
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
Invite.propTypes = {
|
||||
invite: PropTypes.object.isRequired,
|
||||
isProjectOwner: PropTypes.bool.isRequired,
|
||||
}
|
||||
|
||||
function ResendInvite({ invite }) {
|
||||
const { t } = useTranslation()
|
||||
const { monitorRequest } = useShareProjectContext()
|
||||
const { _id: projectId } = useProjectContext()
|
||||
|
||||
// const buttonRef = useRef(null)
|
||||
//
|
||||
const handleClick = useCallback(
|
||||
() =>
|
||||
monitorRequest(() => resendInvite(projectId, invite)).finally(() => {
|
||||
// NOTE: disabled as react-bootstrap v0.33.1 isn't forwarding the ref to the `button`
|
||||
// if (buttonRef.current) {
|
||||
// buttonRef.current.blur()
|
||||
// }
|
||||
document.activeElement.blur()
|
||||
}),
|
||||
[invite, monitorRequest, projectId]
|
||||
)
|
||||
|
||||
return (
|
||||
<Button
|
||||
bsStyle="link"
|
||||
className="btn-inline-link"
|
||||
onClick={handleClick}
|
||||
// ref={buttonRef}
|
||||
>
|
||||
{t('resend')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
ResendInvite.propTypes = {
|
||||
invite: PropTypes.object.isRequired,
|
||||
}
|
||||
|
||||
function RevokeInvite({ invite }) {
|
||||
const { t } = useTranslation()
|
||||
const { updateProject, monitorRequest } = useShareProjectContext()
|
||||
const { _id: projectId, invites, members } = useProjectContext()
|
||||
|
||||
function handleClick(event) {
|
||||
event.preventDefault()
|
||||
|
||||
monitorRequest(() => revokeInvite(projectId, invite)).then(() => {
|
||||
const updatedInvites = invites.filter(existing => existing !== invite)
|
||||
updateProject({
|
||||
invites: updatedInvites,
|
||||
})
|
||||
sendMB('collaborator-invite-revoked', {
|
||||
project_id: projectId,
|
||||
current_invites_amount: updatedInvites.length,
|
||||
current_collaborators_amount: members.length,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
id="revoke-invite"
|
||||
description={t('revoke_invite')}
|
||||
overlayProps={{ placement: 'bottom' }}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
bsStyle="link"
|
||||
onClick={handleClick}
|
||||
aria-label={t('revoke')}
|
||||
className="btn-inline-link"
|
||||
>
|
||||
<Icon type="times" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
RevokeInvite.propTypes = {
|
||||
invite: PropTypes.object.isRequired,
|
||||
}
|
|
@ -0,0 +1,311 @@
|
|||
import { useCallback, useState, useEffect } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Button, Col, Row } from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tooltip from '@/shared/components/tooltip'
|
||||
import Icon from '@/shared/components/icon'
|
||||
import { useShareProjectContext } from './share-project-modal'
|
||||
import { setProjectAccessLevel } from '../../utils/api'
|
||||
import { CopyToClipboard } from '@/shared/components/copy-to-clipboard'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import * as eventTracking from '../../../../infrastructure/event-tracking'
|
||||
import { useUserContext } from '@/shared/context/user-context'
|
||||
import { sendMB } from '../../../../infrastructure/event-tracking'
|
||||
import { getJSON } from '../../../../infrastructure/fetch-json'
|
||||
import useAbortController from '@/shared/hooks/use-abort-controller'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
|
||||
export default function LinkSharing() {
|
||||
const [inflight, setInflight] = useState(false)
|
||||
const [showLinks, setShowLinks] = useState(false)
|
||||
|
||||
const { monitorRequest } = useShareProjectContext()
|
||||
|
||||
const { _id: projectId, publicAccessLevel } = useProjectContext()
|
||||
|
||||
// set the access level of a project
|
||||
const setAccessLevel = useCallback(
|
||||
newPublicAccessLevel => {
|
||||
setInflight(true)
|
||||
sendMB('link-sharing-click-off', {
|
||||
project_id: projectId,
|
||||
})
|
||||
monitorRequest(() =>
|
||||
setProjectAccessLevel(projectId, newPublicAccessLevel)
|
||||
)
|
||||
.then(() => {
|
||||
// NOTE: not calling `updateProject` here as it receives data via
|
||||
// project:publicAccessLevel:changed over the websocket connection
|
||||
// TODO: eventTracking.sendMB('project-make-token-based') when newPublicAccessLevel is 'tokenBased'
|
||||
})
|
||||
.finally(() => {
|
||||
setInflight(false)
|
||||
})
|
||||
},
|
||||
[monitorRequest, projectId]
|
||||
)
|
||||
|
||||
switch (publicAccessLevel) {
|
||||
// Private (with token-access available)
|
||||
case 'private':
|
||||
return (
|
||||
<PrivateSharing
|
||||
setAccessLevel={setAccessLevel}
|
||||
inflight={inflight}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)
|
||||
|
||||
// Token-based access
|
||||
case 'tokenBased':
|
||||
return (
|
||||
<TokenBasedSharing
|
||||
setAccessLevel={setAccessLevel}
|
||||
inflight={inflight}
|
||||
setShowLinks={setShowLinks}
|
||||
showLinks={showLinks}
|
||||
/>
|
||||
)
|
||||
|
||||
// Legacy public-access
|
||||
case 'readAndWrite':
|
||||
case 'readOnly':
|
||||
return (
|
||||
<LegacySharing
|
||||
setAccessLevel={setAccessLevel}
|
||||
accessLevel={publicAccessLevel}
|
||||
inflight={inflight}
|
||||
/>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function PrivateSharing({ setAccessLevel, inflight, projectId }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Row className="public-access-level">
|
||||
<Col xs={12} className="text-center">
|
||||
<strong>{t('link_sharing_is_off_short')}</strong>
|
||||
<span> </span>
|
||||
<Button
|
||||
type="button"
|
||||
bsStyle="link"
|
||||
className="btn-inline-link"
|
||||
onClick={() => {
|
||||
setAccessLevel('tokenBased')
|
||||
eventTracking.sendMB('link-sharing-click', { projectId })
|
||||
}}
|
||||
disabled={inflight}
|
||||
>
|
||||
{t('turn_on_link_sharing')}
|
||||
</Button>
|
||||
<span> </span>
|
||||
<LinkSharingInfo />
|
||||
</Col>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
PrivateSharing.propTypes = {
|
||||
setAccessLevel: PropTypes.func.isRequired,
|
||||
inflight: PropTypes.bool,
|
||||
projectId: PropTypes.string,
|
||||
}
|
||||
|
||||
function TokenBasedSharing({
|
||||
setAccessLevel,
|
||||
inflight,
|
||||
setShowLinks,
|
||||
showLinks,
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { _id: projectId } = useProjectContext()
|
||||
|
||||
const [tokens, setTokens] = useState(null)
|
||||
|
||||
const { signal } = useAbortController()
|
||||
|
||||
useEffect(() => {
|
||||
getJSON(`/project/${projectId}/tokens`, { signal })
|
||||
.then(data => setTokens(data))
|
||||
.catch(debugConsole.error)
|
||||
}, [projectId, signal])
|
||||
|
||||
return (
|
||||
<Row className="public-access-level">
|
||||
<Col xs={12} className="text-center">
|
||||
<strong>{t('link_sharing_is_on')}</strong>
|
||||
<span> </span>
|
||||
<Button
|
||||
bsStyle="link"
|
||||
className="btn-inline-link"
|
||||
onClick={() => setAccessLevel('private')}
|
||||
disabled={inflight}
|
||||
>
|
||||
{t('turn_off_link_sharing')}
|
||||
</Button>
|
||||
<span> </span>
|
||||
<LinkSharingInfo />
|
||||
<Button
|
||||
bsStyle="link"
|
||||
className="btn-chevron"
|
||||
onClick={() => setShowLinks(!showLinks)}
|
||||
>
|
||||
<Icon type={showLinks ? 'chevron-up' : 'chevron-down'} fw />
|
||||
</Button>
|
||||
</Col>
|
||||
{showLinks && (
|
||||
<>
|
||||
<Col xs={12} className="access-token-display-area">
|
||||
<div className="access-token-wrapper">
|
||||
<strong>{t('anyone_with_link_can_edit')}</strong>
|
||||
<AccessToken
|
||||
token={tokens?.readAndWrite}
|
||||
tokenHashPrefix={tokens?.readAndWriteHashPrefix}
|
||||
path="/"
|
||||
tooltipId="tooltip-copy-link-rw"
|
||||
/>
|
||||
</div>
|
||||
<div className="access-token-wrapper">
|
||||
<strong>{t('anyone_with_link_can_view')}</strong>
|
||||
<AccessToken
|
||||
token={tokens?.readOnly}
|
||||
tokenHashPrefix={tokens?.readOnlyHashPrefix}
|
||||
path="/read/"
|
||||
tooltipId="tooltip-copy-link-ro"
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
TokenBasedSharing.propTypes = {
|
||||
setAccessLevel: PropTypes.func.isRequired,
|
||||
inflight: PropTypes.bool,
|
||||
setShowLinks: PropTypes.func.isRequired,
|
||||
showLinks: PropTypes.bool,
|
||||
}
|
||||
|
||||
function LegacySharing({ accessLevel, setAccessLevel, inflight }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Row className="public-access-level">
|
||||
<Col xs={12} className="text-center">
|
||||
<strong>
|
||||
{accessLevel === 'readAndWrite' && t('this_project_is_public')}
|
||||
{accessLevel === 'readOnly' && t('this_project_is_public_read_only')}
|
||||
</strong>
|
||||
<span> </span>
|
||||
<Button
|
||||
type="button"
|
||||
bsStyle="link"
|
||||
className="btn-inline-link"
|
||||
onClick={() => setAccessLevel('private')}
|
||||
disabled={inflight}
|
||||
>
|
||||
{t('make_private')}
|
||||
</Button>
|
||||
<span> </span>
|
||||
<LinkSharingInfo />
|
||||
</Col>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
LegacySharing.propTypes = {
|
||||
accessLevel: PropTypes.string.isRequired,
|
||||
setAccessLevel: PropTypes.func.isRequired,
|
||||
inflight: PropTypes.bool,
|
||||
}
|
||||
|
||||
export function ReadOnlyTokenLink() {
|
||||
const { t } = useTranslation()
|
||||
const { _id: projectId } = useProjectContext()
|
||||
|
||||
const [tokens, setTokens] = useState(null)
|
||||
|
||||
const { signal } = useAbortController()
|
||||
|
||||
useEffect(() => {
|
||||
getJSON(`/project/${projectId}/tokens`, { signal })
|
||||
.then(data => setTokens(data))
|
||||
.catch(debugConsole.error)
|
||||
}, [projectId, signal])
|
||||
|
||||
return (
|
||||
<Row className="public-access-level">
|
||||
<Col xs={12} className="access-token-display-area">
|
||||
<div className="access-token-wrapper">
|
||||
<strong>{t('anyone_with_link_can_view')}</strong>
|
||||
<AccessToken
|
||||
token={tokens?.readOnly}
|
||||
tokenHashPrefix={tokens?.readOnlyHashPrefix}
|
||||
path="/read/"
|
||||
tooltipId="tooltip-copy-link-ro"
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
function AccessToken({ token, tokenHashPrefix, path, tooltipId }) {
|
||||
const { t } = useTranslation()
|
||||
const { isAdmin } = useUserContext()
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<pre className="access-token">
|
||||
<span>{t('loading')}…</span>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
let origin = window.location.origin
|
||||
if (isAdmin) {
|
||||
origin = window.ExposedSettings.siteUrl
|
||||
}
|
||||
const link = `${origin}${path}${token}${
|
||||
tokenHashPrefix ? `#${tokenHashPrefix}` : ''
|
||||
}`
|
||||
|
||||
return (
|
||||
<div className="access-token">
|
||||
<code>{link}</code>
|
||||
<CopyToClipboard content={link} tooltipId={tooltipId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
AccessToken.propTypes = {
|
||||
token: PropTypes.string,
|
||||
tokenHashPrefix: PropTypes.string,
|
||||
tooltipId: PropTypes.string.isRequired,
|
||||
path: PropTypes.string.isRequired,
|
||||
}
|
||||
|
||||
function LinkSharingInfo() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
id="link-sharing-info"
|
||||
description={t('learn_more_about_link_sharing')}
|
||||
>
|
||||
<a
|
||||
href="/learn/how-to/What_is_Link_Sharing%3F"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<Icon type="question-circle" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
import PropTypes from 'prop-types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function MemberPrivileges({ privileges }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
switch (privileges) {
|
||||
case 'readAndWrite':
|
||||
return t('can_edit')
|
||||
|
||||
case 'readOnly':
|
||||
return t('read_only')
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
MemberPrivileges.propTypes = {
|
||||
privileges: PropTypes.string.isRequired,
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import { Col, Row } from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Icon from '@/shared/components/icon'
|
||||
|
||||
export default function OwnerInfo() {
|
||||
const { t } = useTranslation()
|
||||
const { owner } = useProjectContext()
|
||||
|
||||
return (
|
||||
<Row className="project-member">
|
||||
<Col xs={9}>
|
||||
<div className="project-member-email-icon">
|
||||
<Icon type="user" fw />
|
||||
<div className="email-warning">{owner?.email}</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={3} className="text-left">
|
||||
{t('owner')}
|
||||
</Col>
|
||||
</Row>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,357 @@
|
|||
import { useEffect, useMemo, useState, useRef, useCallback } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { matchSorter } from 'match-sorter'
|
||||
import { useCombobox } from 'downshift'
|
||||
import classnames from 'classnames'
|
||||
import { FormControl } from 'react-bootstrap'
|
||||
|
||||
import Icon from '@/shared/components/icon'
|
||||
|
||||
// Unicode characters in these Unicode groups:
|
||||
// "General Punctuation — Spaces"
|
||||
// "General Punctuation — Format character" (including zero-width spaces)
|
||||
const matchAllSpaces =
|
||||
/[\u061C\u2000-\u200F\u202A-\u202E\u2060\u2066-\u2069\u2028\u2029\u202F]/g
|
||||
|
||||
export default function SelectCollaborators({
|
||||
loading,
|
||||
options,
|
||||
placeholder,
|
||||
multipleSelectionProps,
|
||||
privileges,
|
||||
setPrivileges,
|
||||
readOnly,
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
getSelectedItemProps,
|
||||
getDropdownProps,
|
||||
addSelectedItem,
|
||||
removeSelectedItem,
|
||||
selectedItems,
|
||||
} = multipleSelectionProps
|
||||
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
||||
const selectedEmails = useMemo(
|
||||
() => selectedItems.map(item => item.email),
|
||||
[selectedItems]
|
||||
)
|
||||
|
||||
const unselectedOptions = useMemo(
|
||||
() => options.filter(option => !selectedEmails.includes(option.email)),
|
||||
[options, selectedEmails]
|
||||
)
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (inputValue === '') {
|
||||
return unselectedOptions
|
||||
}
|
||||
|
||||
return matchSorter(unselectedOptions, inputValue, {
|
||||
keys: ['name', 'email'],
|
||||
threshold: matchSorter.rankings.CONTAINS,
|
||||
baseSort: (a, b) => {
|
||||
// Prefer server-side sorting for ties in the match ranking.
|
||||
return a.index - b.index > 0 ? 1 : -1
|
||||
},
|
||||
})
|
||||
}, [unselectedOptions, inputValue])
|
||||
|
||||
const inputRef = useRef(null)
|
||||
|
||||
const focusInput = useCallback(() => {
|
||||
if (inputRef.current) {
|
||||
window.setTimeout(() => {
|
||||
inputRef.current.focus()
|
||||
}, 10)
|
||||
}
|
||||
}, [inputRef])
|
||||
|
||||
const isValidInput = useMemo(() => {
|
||||
if (inputValue.includes('@')) {
|
||||
for (const selectedItem of selectedItems) {
|
||||
if (selectedItem.email === inputValue) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}, [inputValue, selectedItems])
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
getLabelProps,
|
||||
getMenuProps,
|
||||
getInputProps,
|
||||
getComboboxProps,
|
||||
highlightedIndex,
|
||||
getItemProps,
|
||||
reset,
|
||||
} = useCombobox({
|
||||
inputValue,
|
||||
defaultHighlightedIndex: 0,
|
||||
items: filteredOptions,
|
||||
itemToString: item => item && item.name,
|
||||
onStateChange: ({ inputValue, type, selectedItem }) => {
|
||||
switch (type) {
|
||||
// add a selected item on Enter (keypress), click or blur
|
||||
case useCombobox.stateChangeTypes.InputKeyDownEnter:
|
||||
case useCombobox.stateChangeTypes.ItemClick:
|
||||
case useCombobox.stateChangeTypes.InputBlur:
|
||||
if (selectedItem) {
|
||||
setInputValue('')
|
||||
addSelectedItem(selectedItem)
|
||||
}
|
||||
break
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const addNewItem = useCallback(
|
||||
(_email, focus = true) => {
|
||||
const email = _email.replace(matchAllSpaces, '')
|
||||
|
||||
if (
|
||||
isValidInput &&
|
||||
email.includes('@') &&
|
||||
!selectedEmails.includes(email)
|
||||
) {
|
||||
addSelectedItem({
|
||||
email,
|
||||
display: email,
|
||||
type: 'user',
|
||||
})
|
||||
setInputValue('')
|
||||
reset()
|
||||
if (focus) {
|
||||
focusInput()
|
||||
}
|
||||
return true
|
||||
}
|
||||
},
|
||||
[addSelectedItem, selectedEmails, isValidInput, focusInput, reset]
|
||||
)
|
||||
|
||||
// close and reset the menu when there are no matching items
|
||||
useEffect(() => {
|
||||
if (readOnly) {
|
||||
setPrivileges('readOnly')
|
||||
}
|
||||
if (isOpen && filteredOptions.length === 0) {
|
||||
reset()
|
||||
}
|
||||
}, [reset, isOpen, filteredOptions.length, readOnly, setPrivileges])
|
||||
|
||||
return (
|
||||
<div className="tags-input tags-new">
|
||||
{/* eslint-disable-next-line jsx-a11y/label-has-for */}
|
||||
<label className="small" {...getLabelProps()}>
|
||||
<strong>
|
||||
{t('add_people')}
|
||||
|
||||
</strong>
|
||||
{loading && <Icon type="refresh" spin />}
|
||||
</label>
|
||||
|
||||
<div className="host">
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
|
||||
|
||||
<div {...getComboboxProps()} className="tags">
|
||||
<div className="tags-main">
|
||||
{selectedItems.map((selectedItem, index) => (
|
||||
<SelectedItem
|
||||
key={`selected-item-${index}`}
|
||||
removeSelectedItem={removeSelectedItem}
|
||||
selectedItem={selectedItem}
|
||||
focusInput={focusInput}
|
||||
index={index}
|
||||
getSelectedItemProps={getSelectedItemProps}
|
||||
/>
|
||||
))}
|
||||
|
||||
<input
|
||||
{...getInputProps(
|
||||
getDropdownProps({
|
||||
className: classnames({
|
||||
input: true,
|
||||
'invalid-tag': !isValidInput,
|
||||
}),
|
||||
type: 'email',
|
||||
placeholder,
|
||||
size: inputValue.length
|
||||
? inputValue.length + 5
|
||||
: placeholder.length,
|
||||
ref: inputRef,
|
||||
// preventKeyAction: showDropdown,
|
||||
onBlur: () => {
|
||||
addNewItem(inputValue, false)
|
||||
},
|
||||
onChange: e => {
|
||||
setInputValue(e.target.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') ??
|
||||
// IE11
|
||||
window.clipboardData?.getData('text')
|
||||
|
||||
if (data) {
|
||||
const emails = data
|
||||
.split(/[\r\n,; ]+/)
|
||||
.filter(item => item.includes('@'))
|
||||
|
||||
if (emails.length) {
|
||||
// pasted comma-separated email addresses
|
||||
event.preventDefault()
|
||||
|
||||
for (const email of emails) {
|
||||
addNewItem(email)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormControl
|
||||
componentClass="select"
|
||||
className="privileges"
|
||||
bsSize="sm"
|
||||
value={privileges}
|
||||
onChange={event => setPrivileges(event.target.value)}
|
||||
>
|
||||
{!readOnly && <option value="readAndWrite">{t('can_edit')}</option>}
|
||||
<option value="readOnly">{t('read_only')}</option>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<div className={classnames({ autocomplete: isOpen })}>
|
||||
<ul {...getMenuProps()} className="suggestion-list">
|
||||
{isOpen &&
|
||||
filteredOptions.map((item, index) => (
|
||||
<Option
|
||||
key={item.email}
|
||||
index={index}
|
||||
item={item}
|
||||
selected={index === highlightedIndex}
|
||||
getItemProps={getItemProps}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
SelectCollaborators.propTypes = {
|
||||
loading: PropTypes.bool.isRequired,
|
||||
options: PropTypes.array.isRequired,
|
||||
placeholder: PropTypes.string,
|
||||
multipleSelectionProps: PropTypes.shape({
|
||||
getSelectedItemProps: PropTypes.func.isRequired,
|
||||
getDropdownProps: PropTypes.func.isRequired,
|
||||
addSelectedItem: PropTypes.func.isRequired,
|
||||
removeSelectedItem: PropTypes.func.isRequired,
|
||||
selectedItems: PropTypes.array.isRequired,
|
||||
}).isRequired,
|
||||
privileges: PropTypes.string.isRequired,
|
||||
setPrivileges: PropTypes.func.isRequired,
|
||||
readOnly: PropTypes.bool.isRequired,
|
||||
}
|
||||
|
||||
function Option({ selected, item, getItemProps, index }) {
|
||||
return (
|
||||
<li
|
||||
className={classnames('suggestion-item', { selected })}
|
||||
{...getItemProps({ item, index })}
|
||||
>
|
||||
<Icon type="user" fw />
|
||||
|
||||
{item.display}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
Option.propTypes = {
|
||||
selected: PropTypes.bool.isRequired,
|
||||
item: PropTypes.shape({
|
||||
display: PropTypes.string.isRequired,
|
||||
}),
|
||||
index: PropTypes.number.isRequired,
|
||||
getItemProps: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
function SelectedItem({
|
||||
removeSelectedItem,
|
||||
selectedItem,
|
||||
focusInput,
|
||||
getSelectedItemProps,
|
||||
index,
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleClick = useCallback(
|
||||
event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
removeSelectedItem(selectedItem)
|
||||
focusInput()
|
||||
},
|
||||
[focusInput, removeSelectedItem, selectedItem]
|
||||
)
|
||||
|
||||
return (
|
||||
<span
|
||||
className="tag-item"
|
||||
{...getSelectedItemProps({ selectedItem, index })}
|
||||
>
|
||||
<Icon type="user" fw />
|
||||
<span>{selectedItem.display}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="remove-button btn-inline-link"
|
||||
aria-label={t('remove')}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Icon type="close" fw />
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
SelectedItem.propTypes = {
|
||||
focusInput: PropTypes.func.isRequired,
|
||||
removeSelectedItem: PropTypes.func.isRequired,
|
||||
selectedItem: PropTypes.shape({
|
||||
display: PropTypes.string.isRequired,
|
||||
}),
|
||||
getSelectedItemProps: PropTypes.func.isRequired,
|
||||
index: PropTypes.number.isRequired,
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
import { Col, Row } from 'react-bootstrap'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
|
||||
export default function SendInvitesNotice() {
|
||||
const { publicAccessLevel } = useProjectContext()
|
||||
|
||||
return (
|
||||
<Row className="public-access-level public-access-level--notice">
|
||||
<Col xs={12} className="text-center">
|
||||
<AccessLevel level={publicAccessLevel} />
|
||||
</Col>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
function AccessLevel({ level }) {
|
||||
const { t } = useTranslation()
|
||||
switch (level) {
|
||||
case 'private':
|
||||
return t('to_add_more_collaborators')
|
||||
|
||||
case 'tokenBased':
|
||||
return t('to_change_access_permissions')
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
AccessLevel.propTypes = {
|
||||
level: PropTypes.string,
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
import { Row } from 'react-bootstrap'
|
||||
import AddCollaborators from './add-collaborators'
|
||||
import AddCollaboratorsUpgrade from './add-collaborators-upgrade'
|
||||
import CollaboratorsLimitUpgrade from './collaborators-limit-upgrade'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
export default function SendInvites({
|
||||
canAddCollaborators,
|
||||
hasExceededCollaboratorLimit,
|
||||
}) {
|
||||
return (
|
||||
<Row className="invite-controls">
|
||||
{hasExceededCollaboratorLimit && <AddCollaboratorsUpgrade />}
|
||||
{!canAddCollaborators && !hasExceededCollaboratorLimit && (
|
||||
<CollaboratorsLimitUpgrade />
|
||||
)}
|
||||
{!hasExceededCollaboratorLimit && (
|
||||
<AddCollaborators readOnly={!canAddCollaborators} />
|
||||
)}
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
SendInvites.propTypes = {
|
||||
canAddCollaborators: PropTypes.bool,
|
||||
hasExceededCollaboratorLimit: PropTypes.bool,
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
import EditMember from './edit-member'
|
||||
import LinkSharing from './link-sharing'
|
||||
import Invite from './invite'
|
||||
import SendInvites from './send-invites'
|
||||
import ViewMember from './view-member'
|
||||
import OwnerInfo from './owner-info'
|
||||
import SendInvitesNotice from './send-invites-notice'
|
||||
import { useEditorContext } from '@/shared/context/editor-context'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import { useMemo } from 'react'
|
||||
import RecaptchaConditions from '@/shared/components/recaptcha-conditions'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
export default function ShareModalBody() {
|
||||
const { members, invites, features } = useProjectContext()
|
||||
const { isProjectOwner } = useEditorContext()
|
||||
|
||||
// whether the project has not reached the collaborator limit
|
||||
const canAddCollaborators = useMemo(() => {
|
||||
if (!isProjectOwner || !features) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (features.collaborators === -1) {
|
||||
// infinite collaborators
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
members.filter(member => member.privileges === 'readAndWrite').length +
|
||||
invites.length <
|
||||
(features.collaborators ?? 1)
|
||||
)
|
||||
}, [members, invites, features, isProjectOwner])
|
||||
|
||||
const hasExceededCollaboratorLimit = useMemo(() => {
|
||||
if (!isProjectOwner || !features) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (features.collaborators === -1) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
members.filter(member => member.privileges === 'readAndWrite').length >
|
||||
(features.collaborators ?? 1)
|
||||
)
|
||||
}, [features, isProjectOwner, members])
|
||||
|
||||
return (
|
||||
<>
|
||||
{isProjectOwner ? (
|
||||
<SendInvites
|
||||
canAddCollaborators={canAddCollaborators}
|
||||
hasExceededCollaboratorLimit={hasExceededCollaboratorLimit}
|
||||
/>
|
||||
) : (
|
||||
<SendInvitesNotice />
|
||||
)}
|
||||
{isProjectOwner && <LinkSharing />}
|
||||
|
||||
<OwnerInfo />
|
||||
|
||||
{members.map(member =>
|
||||
isProjectOwner ? (
|
||||
<EditMember
|
||||
key={member._id}
|
||||
member={member}
|
||||
hasExceededCollaboratorLimit={hasExceededCollaboratorLimit}
|
||||
canAddCollaborators={canAddCollaborators}
|
||||
/>
|
||||
) : (
|
||||
<ViewMember key={member._id} member={member} />
|
||||
)
|
||||
)}
|
||||
|
||||
{invites.map(invite => (
|
||||
<Invite
|
||||
key={invite._id}
|
||||
invite={invite}
|
||||
isProjectOwner={isProjectOwner}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!getMeta('ol-ExposedSettings').recaptchaDisabled?.invite && (
|
||||
<RecaptchaConditions />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,104 @@
|
|||
import { Button, Modal, Grid } from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Icon from '@/shared/components/icon'
|
||||
import AccessibleModal from '@/shared/components/accessible-modal'
|
||||
import { useEditorContext } from '@/shared/context/editor-context'
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
|
||||
import ClickableElementEnhancer from '@/shared/components/clickable-element-enhancer'
|
||||
|
||||
const ReadOnlyTokenLink = lazy(() =>
|
||||
import('./link-sharing').then(({ ReadOnlyTokenLink }) => ({
|
||||
// re-export as default -- lazy can only handle default exports.
|
||||
default: ReadOnlyTokenLink,
|
||||
}))
|
||||
)
|
||||
|
||||
const ShareModalBody = lazy(() => import('./share-modal-body'))
|
||||
|
||||
type ShareProjectModalContentProps = {
|
||||
cancel: () => void
|
||||
show: boolean
|
||||
animation: boolean
|
||||
inFlight: boolean
|
||||
error: string | undefined
|
||||
}
|
||||
|
||||
export default function ShareProjectModalContent({
|
||||
show,
|
||||
cancel,
|
||||
animation,
|
||||
inFlight,
|
||||
error,
|
||||
}: ShareProjectModalContentProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { isRestrictedTokenMember } = useEditorContext()
|
||||
|
||||
return (
|
||||
<AccessibleModal show={show} onHide={cancel} animation={animation}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>{t('share_project')}</Modal.Title>
|
||||
</Modal.Header>
|
||||
|
||||
<Modal.Body className="modal-body-share">
|
||||
<Grid fluid>
|
||||
<Suspense fallback={<FullSizeLoadingSpinner minHeight="15rem" />}>
|
||||
{isRestrictedTokenMember ? (
|
||||
<ReadOnlyTokenLink />
|
||||
) : (
|
||||
<ShareModalBody />
|
||||
)}
|
||||
</Suspense>
|
||||
</Grid>
|
||||
</Modal.Body>
|
||||
|
||||
<Modal.Footer className="modal-footer-share">
|
||||
<div className="modal-footer-left">
|
||||
{inFlight && <Icon type="refresh" spin />}
|
||||
{error && (
|
||||
<span className="text-danger error">
|
||||
<ErrorMessage error={error} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-footer-right">
|
||||
<ClickableElementEnhancer
|
||||
onClick={cancel}
|
||||
as={Button}
|
||||
type="button"
|
||||
bsStyle={null}
|
||||
className="btn-secondary"
|
||||
disabled={inFlight}
|
||||
>
|
||||
{t('close')}
|
||||
</ClickableElementEnhancer>
|
||||
</div>
|
||||
</Modal.Footer>
|
||||
</AccessibleModal>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorMessage({ error }: Pick<ShareProjectModalContentProps, 'error'>) {
|
||||
const { t } = useTranslation()
|
||||
switch (error) {
|
||||
case 'cannot_invite_non_user':
|
||||
return <>{t('cannot_invite_non_user')}</>
|
||||
|
||||
case 'cannot_verify_user_not_robot':
|
||||
return <>{t('cannot_verify_user_not_robot')}</>
|
||||
|
||||
case 'cannot_invite_self':
|
||||
return <>{t('cannot_invite_self')}</>
|
||||
|
||||
case 'invalid_email':
|
||||
return <>{t('invalid_email')}</>
|
||||
|
||||
case 'too_many_requests':
|
||||
return <>{t('too_many_requests')}</>
|
||||
|
||||
default:
|
||||
return <>{t('generic_something_went_wrong')}</>
|
||||
}
|
||||
}
|
|
@ -0,0 +1,140 @@
|
|||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import ShareProjectModalContent from './share-project-modal-content'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import { useSplitTestContext } from '@/shared/context/split-test-context'
|
||||
import { sendMB } from '@/infrastructure/event-tracking'
|
||||
import { ProjectContextUpdateValue } from '@/shared/context/types/project-context'
|
||||
|
||||
type ShareProjectContextValue = {
|
||||
updateProject: (project: ProjectContextUpdateValue) => void
|
||||
monitorRequest: <T extends Promise<unknown>>(request: () => T) => T
|
||||
inFlight: boolean
|
||||
setInFlight: React.Dispatch<
|
||||
React.SetStateAction<ShareProjectContextValue['inFlight']>
|
||||
>
|
||||
error: string | undefined
|
||||
setError: React.Dispatch<
|
||||
React.SetStateAction<ShareProjectContextValue['error']>
|
||||
>
|
||||
}
|
||||
|
||||
const ShareProjectContext = createContext<ShareProjectContextValue | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
export function useShareProjectContext() {
|
||||
const context = useContext(ShareProjectContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useShareProjectContext is only available inside ShareProjectProvider'
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
type ShareProjectModalProps = {
|
||||
handleHide: () => void
|
||||
show: boolean
|
||||
animation?: boolean
|
||||
}
|
||||
|
||||
const ShareProjectModal = React.memo(function ShareProjectModal({
|
||||
handleHide,
|
||||
show,
|
||||
animation = true,
|
||||
}: ShareProjectModalProps) {
|
||||
const [inFlight, setInFlight] =
|
||||
useState<ShareProjectContextValue['inFlight']>(false)
|
||||
const [error, setError] = useState<ShareProjectContextValue['error']>()
|
||||
|
||||
const project = useProjectContext()
|
||||
|
||||
const { splitTestVariants } = useSplitTestContext()
|
||||
|
||||
// send tracking event when the modal is opened
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
sendMB('share-modal-opened', {
|
||||
splitTestVariant: splitTestVariants['null-test-share-modal'],
|
||||
project_id: project._id,
|
||||
})
|
||||
}
|
||||
}, [splitTestVariants, project._id, show])
|
||||
|
||||
// reset error when the modal is opened
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
setError(undefined)
|
||||
}
|
||||
}, [show])
|
||||
|
||||
// close the modal if not in flight
|
||||
const cancel = useCallback(() => {
|
||||
if (!inFlight) {
|
||||
handleHide()
|
||||
}
|
||||
}, [handleHide, inFlight])
|
||||
|
||||
// update `error` and `inFlight` while sending a request
|
||||
const monitorRequest = useCallback(request => {
|
||||
setError(undefined)
|
||||
setInFlight(true)
|
||||
|
||||
const promise = request()
|
||||
|
||||
promise.catch((error: { data?: Record<string, string> }) => {
|
||||
setError(
|
||||
error.data?.errorReason ||
|
||||
error.data?.error ||
|
||||
'generic_something_went_wrong'
|
||||
)
|
||||
})
|
||||
|
||||
promise.finally(() => {
|
||||
setInFlight(false)
|
||||
})
|
||||
|
||||
return promise
|
||||
}, [])
|
||||
|
||||
// merge the new data with the old project data
|
||||
const updateProject = useCallback(
|
||||
data => Object.assign(project, data),
|
||||
[project]
|
||||
)
|
||||
|
||||
if (!project) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ShareProjectContext.Provider
|
||||
value={{
|
||||
updateProject,
|
||||
monitorRequest,
|
||||
inFlight,
|
||||
setInFlight,
|
||||
error,
|
||||
setError,
|
||||
}}
|
||||
>
|
||||
<ShareProjectModalContent
|
||||
animation={animation}
|
||||
cancel={cancel}
|
||||
error={error}
|
||||
inFlight={inFlight}
|
||||
show={show}
|
||||
/>
|
||||
</ShareProjectContext.Provider>
|
||||
)
|
||||
})
|
||||
|
||||
export default ShareProjectModal
|
|
@ -0,0 +1,86 @@
|
|||
import { useState } from 'react'
|
||||
import { Modal, Button } from 'react-bootstrap'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import PropTypes from 'prop-types'
|
||||
import Icon from '@/shared/components/icon'
|
||||
import { transferProjectOwnership } from '../../utils/api'
|
||||
import AccessibleModal from '@/shared/components/accessible-modal'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import { useLocation } from '@/shared/hooks/use-location'
|
||||
|
||||
export default function TransferOwnershipModal({ member, cancel }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [inflight, setInflight] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
const location = useLocation()
|
||||
|
||||
const { _id: projectId, name: projectName } = useProjectContext()
|
||||
|
||||
function confirm() {
|
||||
setError(false)
|
||||
setInflight(true)
|
||||
|
||||
transferProjectOwnership(projectId, member)
|
||||
.then(() => {
|
||||
location.reload()
|
||||
})
|
||||
.catch(() => {
|
||||
setError(true)
|
||||
setInflight(false)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<AccessibleModal show onHide={cancel}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>{t('change_project_owner')}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="project_ownership_transfer_confirmation_1"
|
||||
values={{ user: member.email, project: projectName }}
|
||||
components={[<strong key="strong-1" />, <strong key="strong-2" />]}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
/>
|
||||
</p>
|
||||
<p>{t('project_ownership_transfer_confirmation_2')}</p>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<div className="modal-footer-left">
|
||||
{inflight && <Icon type="refresh" spin />}
|
||||
{error && (
|
||||
<span className="text-danger">
|
||||
{t('generic_something_went_wrong')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer-right">
|
||||
<Button
|
||||
type="button"
|
||||
bsStyle={null}
|
||||
className="btn-secondary"
|
||||
onClick={cancel}
|
||||
disabled={inflight}
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
bsStyle="primary"
|
||||
onClick={confirm}
|
||||
disabled={inflight}
|
||||
>
|
||||
{t('change_owner')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Footer>
|
||||
</AccessibleModal>
|
||||
)
|
||||
}
|
||||
TransferOwnershipModal.propTypes = {
|
||||
member: PropTypes.object.isRequired,
|
||||
cancel: PropTypes.func.isRequired,
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
import PropTypes from 'prop-types'
|
||||
import { Col, Row } from 'react-bootstrap'
|
||||
import MemberPrivileges from './member-privileges'
|
||||
|
||||
export default function ViewMember({ member }) {
|
||||
return (
|
||||
<Row className="project-member">
|
||||
<Col xs={7}>{member.email}</Col>
|
||||
<Col xs={3}>
|
||||
<MemberPrivileges privileges={member.privileges} />
|
||||
</Col>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
ViewMember.propTypes = {
|
||||
member: PropTypes.shape({
|
||||
_id: PropTypes.string.isRequired,
|
||||
email: PropTypes.string.isRequired,
|
||||
privileges: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
}
|
|
@ -6,6 +6,7 @@ import {
|
|||
KeyboardEventHandler,
|
||||
useCallback,
|
||||
ReactNode,
|
||||
useState,
|
||||
} from 'react'
|
||||
import classNames from 'classnames'
|
||||
import { useSelect } from 'downshift'
|
||||
|
@ -34,12 +35,18 @@ export type SelectProps<T> = {
|
|||
itemToKey: (item: T) => string
|
||||
// Callback invoked after the selected item is updated.
|
||||
onSelectedItemChanged?: (item: T | null | undefined) => void
|
||||
// Optionally directly control the selected item.
|
||||
selected?: T
|
||||
// When `true` item selection is disabled.
|
||||
disabled?: boolean
|
||||
// Determine which items should be disabled
|
||||
itemToDisabled?: (item: T | null | undefined) => boolean
|
||||
// When `true` displays an "Optional" subtext after the `label` caption.
|
||||
optionalLabel?: boolean
|
||||
// When `true` displays a spinner next to the `label` caption.
|
||||
loading?: boolean
|
||||
// Show a checkmark next to the selected item
|
||||
selectedIcon?: boolean
|
||||
}
|
||||
|
||||
export const Select = <T,>({
|
||||
|
@ -52,14 +59,20 @@ export const Select = <T,>({
|
|||
itemToSubtitle,
|
||||
itemToKey,
|
||||
onSelectedItemChanged,
|
||||
selected,
|
||||
disabled = false,
|
||||
itemToDisabled,
|
||||
optionalLabel = false,
|
||||
loading = false,
|
||||
selectedIcon = false,
|
||||
}: SelectProps<T>) => {
|
||||
const [selectedItem, setSelectedItem] = useState<T | undefined | null>(
|
||||
defaultItem
|
||||
)
|
||||
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
isOpen,
|
||||
selectedItem,
|
||||
getToggleButtonProps,
|
||||
getLabelProps,
|
||||
getMenuProps,
|
||||
|
@ -69,13 +82,19 @@ export const Select = <T,>({
|
|||
} = useSelect({
|
||||
items: items ?? [],
|
||||
itemToString,
|
||||
selectedItem: selected || defaultItem,
|
||||
onSelectedItemChange: changes => {
|
||||
if (onSelectedItemChanged) {
|
||||
onSelectedItemChanged(changes.selectedItem)
|
||||
}
|
||||
setSelectedItem(changes.selectedItem)
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedItem(selected)
|
||||
}, [selected])
|
||||
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
useEffect(() => {
|
||||
if (!name || !rootRef.current) return
|
||||
|
@ -153,23 +172,39 @@ export const Select = <T,>({
|
|||
{...getMenuProps({ disabled })}
|
||||
>
|
||||
{isOpen &&
|
||||
items?.map((item, index) => (
|
||||
<li
|
||||
className={classNames({
|
||||
'select-highlighted': highlightedIndex === index,
|
||||
'selected-active': selectedItem === item,
|
||||
})}
|
||||
key={itemToKey(item)}
|
||||
{...getItemProps({ item, index })}
|
||||
>
|
||||
<span className="select-item-title">{itemToString(item)}</span>
|
||||
{itemToSubtitle ? (
|
||||
<span className="text-muted select-item-subtitle">
|
||||
{itemToSubtitle(item)}
|
||||
items?.map((item, index) => {
|
||||
const isDisabled = itemToDisabled && itemToDisabled(item)
|
||||
return (
|
||||
<li
|
||||
className={classNames({
|
||||
'select-highlighted': highlightedIndex === index,
|
||||
'selected-active': selectedItem === item,
|
||||
'select-icon': selectedIcon,
|
||||
'select-disabled': isDisabled,
|
||||
})}
|
||||
key={itemToKey(item)}
|
||||
{...getItemProps({ item, index, disabled: isDisabled })}
|
||||
>
|
||||
<span className="select-item-title">
|
||||
{selectedIcon && (
|
||||
<div className="select-item-icon">
|
||||
{(selectedItem === item ||
|
||||
(!selectedItem && defaultItem === item)) && (
|
||||
<Icon type="check" fw />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{itemToString(item)}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
|
||||
{itemToSubtitle ? (
|
||||
<span className="text-muted select-item-subtitle">
|
||||
{itemToSubtitle(item)}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -1,45 +1,9 @@
|
|||
import { FC, createContext, useContext, useMemo } from 'react'
|
||||
import useScopeValue from '../hooks/use-scope-value'
|
||||
import getMeta from '@/utils/meta'
|
||||
import { UserId } from '../../../../types/user'
|
||||
import { PublicAccessLevel } from '../../../../types/public-access-level'
|
||||
import type * as ReviewPanel from '@/features/source-editor/context/review-panel/types/review-panel-state'
|
||||
import { ProjectContextValue } from './types/project-context'
|
||||
|
||||
const ProjectContext = createContext<
|
||||
| {
|
||||
_id: string
|
||||
name: string
|
||||
rootDocId?: string
|
||||
compiler: string
|
||||
members: { _id: UserId; email: string; privileges: string }[]
|
||||
invites: { _id: UserId }[]
|
||||
features: {
|
||||
collaborators?: number
|
||||
compileGroup?: 'alpha' | 'standard' | 'priority'
|
||||
trackChanges?: boolean
|
||||
trackChangesVisible?: boolean
|
||||
references?: boolean
|
||||
mendeley?: boolean
|
||||
zotero?: boolean
|
||||
versioning?: boolean
|
||||
gitBridge?: boolean
|
||||
referencesSearch?: boolean
|
||||
github?: boolean
|
||||
}
|
||||
publicAccessLevel?: PublicAccessLevel
|
||||
owner: {
|
||||
_id: UserId
|
||||
email: string
|
||||
}
|
||||
tags: {
|
||||
_id: string
|
||||
name: string
|
||||
color?: string
|
||||
}[]
|
||||
trackChangesState: ReviewPanel.Value<'trackChangesState'>
|
||||
}
|
||||
| undefined
|
||||
>(undefined)
|
||||
const ProjectContext = createContext<ProjectContextValue | undefined>(undefined)
|
||||
|
||||
export function useProjectContext() {
|
||||
const context = useContext(ProjectContext)
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
import { UserId } from '../../../../../types/user'
|
||||
import { PublicAccessLevel } from '../../../../../types/public-access-level'
|
||||
import type * as ReviewPanel from '@/features/source-editor/context/review-panel/types/review-panel-state'
|
||||
|
||||
export type ProjectContextMember = {
|
||||
_id: UserId
|
||||
privileges: 'readOnly' | 'readAndWrite'
|
||||
email: string
|
||||
}
|
||||
|
||||
export type ProjectContextValue = {
|
||||
_id: string
|
||||
name: string
|
||||
rootDocId?: string
|
||||
compiler: string
|
||||
members: ProjectContextMember[]
|
||||
invites: { _id: UserId }[]
|
||||
features: {
|
||||
collaborators?: number
|
||||
compileGroup?: 'alpha' | 'standard' | 'priority'
|
||||
trackChanges?: boolean
|
||||
trackChangesVisible?: boolean
|
||||
references?: boolean
|
||||
mendeley?: boolean
|
||||
zotero?: boolean
|
||||
versioning?: boolean
|
||||
gitBridge?: boolean
|
||||
referencesSearch?: boolean
|
||||
github?: boolean
|
||||
}
|
||||
publicAccessLevel?: PublicAccessLevel
|
||||
owner: {
|
||||
_id: UserId
|
||||
email: string
|
||||
}
|
||||
tags: {
|
||||
_id: string
|
||||
name: string
|
||||
color?: string
|
||||
}[]
|
||||
trackChangesState: ReviewPanel.Value<'trackChangesState'>
|
||||
}
|
||||
|
||||
export type ProjectContextUpdateValue = Partial<ProjectContextValue>
|
|
@ -123,6 +123,7 @@ export interface Meta {
|
|||
'ol-languages': SpellCheckLanguage[]
|
||||
'ol-learnedWords': string[]
|
||||
'ol-legacyEditorThemes': string[]
|
||||
'ol-linkSharingWarning': boolean
|
||||
'ol-loadingText': string
|
||||
'ol-managedGroupSubscriptions': ManagedGroupSubscription[]
|
||||
'ol-managedInstitutions': ManagedInstitution[]
|
||||
|
|
|
@ -29,6 +29,32 @@ export const WithSubtitles = () => {
|
|||
)
|
||||
}
|
||||
|
||||
export const WithSelectedIcon = () => {
|
||||
return (
|
||||
<Select
|
||||
items={items}
|
||||
itemToString={x => String(x?.value)}
|
||||
itemToKey={x => String(x.key)}
|
||||
itemToSubtitle={x => x?.group ?? ''}
|
||||
defaultText="Choose an item"
|
||||
selectedIcon
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const WithDisabledItem = () => {
|
||||
return (
|
||||
<Select
|
||||
items={items}
|
||||
itemToString={x => String(x?.value)}
|
||||
itemToKey={x => String(x.key)}
|
||||
itemToDisabled={x => x?.key === 1}
|
||||
itemToSubtitle={x => x?.group ?? ''}
|
||||
defaultText="Choose an item"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default {
|
||||
title: 'Shared / Components / Select',
|
||||
component: Select,
|
||||
|
|
|
@ -18,16 +18,21 @@
|
|||
font-size: inherit;
|
||||
}
|
||||
|
||||
.project-member,
|
||||
.project-member {
|
||||
padding: (@line-height-computed / 2) 0;
|
||||
font-size: 16px;
|
||||
span {
|
||||
padding-right: @line-height-computed / 2;
|
||||
}
|
||||
}
|
||||
|
||||
.project-invite,
|
||||
.public-access-level {
|
||||
padding: (@line-height-computed / 2) 0;
|
||||
border-bottom: 1px solid @gray-lighter;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.public-access-level {
|
||||
padding-top: 0;
|
||||
margin-top: @line-height-computed / 4;
|
||||
font-size: 13px;
|
||||
padding-bottom: @modal-inner-padding;
|
||||
.access-token-display-area {
|
||||
|
@ -45,6 +50,14 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
.fa-chevron-down,
|
||||
.fa-chevron-up {
|
||||
vertical-align: top;
|
||||
color: @neutral-70;
|
||||
}
|
||||
.btn-chevron {
|
||||
padding: 0 (@line-height-computed / 2);
|
||||
}
|
||||
}
|
||||
|
||||
.public-access-level.public-access-level--notice {
|
||||
|
@ -61,14 +74,50 @@
|
|||
}
|
||||
}
|
||||
|
||||
.project-member {
|
||||
.select-trigger {
|
||||
color: @neutral-70;
|
||||
border: none;
|
||||
padding: 0 10px 5px 10px;
|
||||
}
|
||||
|
||||
.select-items {
|
||||
max-height: 300px;
|
||||
width: 250%;
|
||||
li:last-child {
|
||||
color: @brand-danger;
|
||||
}
|
||||
}
|
||||
|
||||
.project-member-email-icon {
|
||||
display: grid;
|
||||
grid-template-columns: 2em auto;
|
||||
align-items: center;
|
||||
padding-bottom: 5px;
|
||||
|
||||
.fa-warning {
|
||||
color: @brand-warning;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: @font-size-small;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.project-member .text-left,
|
||||
.project-invite .text-left {
|
||||
padding-left: 25px;
|
||||
}
|
||||
|
||||
.invite-controls {
|
||||
.small {
|
||||
padding: 2px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
padding: @line-height-computed / 2;
|
||||
background-color: @gray-lightest;
|
||||
margin-top: @line-height-computed / 2;
|
||||
.add-collabs {
|
||||
margin-top: @line-height-computed / 2;
|
||||
}
|
||||
form {
|
||||
.form-group {
|
||||
margin-bottom: @line-height-computed / 2;
|
||||
|
@ -79,14 +128,25 @@
|
|||
.privileges {
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
}
|
||||
.tags-new .privileges {
|
||||
background: transparent;
|
||||
width: auto;
|
||||
height: 30px;
|
||||
font-size: 14px;
|
||||
border: none;
|
||||
border-right: 5px solid transparent;
|
||||
}
|
||||
}
|
||||
.add-collaborators-upgrade {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.upgrade-actions {
|
||||
display: flex;
|
||||
gap: @margin-md;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
.select-highlighted {
|
||||
.select-highlighted:not(.select-disabled) {
|
||||
background-color: @neutral-10;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
@ -23,6 +23,11 @@
|
|||
font-weight: bold;
|
||||
}
|
||||
|
||||
.select-disabled {
|
||||
color: @text-muted;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.select-items {
|
||||
list-style: none;
|
||||
width: 100%;
|
||||
|
@ -46,14 +51,25 @@
|
|||
.select-item-title,
|
||||
.select-item-subtitle {
|
||||
display: block;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
:not(.select-disabled) {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.select-item-icon {
|
||||
width: 30px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.select-item-subtitle {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.select-icon .select-item-subtitle {
|
||||
margin-left: 30px;
|
||||
}
|
||||
|
||||
.select-optional-label {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
|
|
@ -21,15 +21,18 @@
|
|||
|
||||
.tags-input .tags {
|
||||
.form-control;
|
||||
appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
-webkit-appearance: textfield;
|
||||
padding: 0 0 0 5px;
|
||||
overflow: hidden;
|
||||
word-wrap: break-word;
|
||||
cursor: text;
|
||||
background-color: #fff;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
flex-direction: row;
|
||||
}
|
||||
.tags-input .tags:focus-within {
|
||||
&:extend(.input-focus-style);
|
||||
|
|
|
@ -71,6 +71,7 @@
|
|||
"add_more_members": "Add more members",
|
||||
"add_new_email": "Add new email",
|
||||
"add_or_remove_project_from_tag": "Add or remove project from tag __tagName__",
|
||||
"add_people": "Add people",
|
||||
"add_role_and_department": "Add role and department",
|
||||
"add_to_tag": "Add to tag",
|
||||
"add_your_comment_here": "Add your comment here",
|
||||
|
@ -501,8 +502,10 @@
|
|||
"editing": "Editing",
|
||||
"editing_and_collaboration": "Editing and collaboration",
|
||||
"editing_captions": "Editing captions",
|
||||
"editor": "Editor",
|
||||
"editor_and_pdf": "Editor & PDF",
|
||||
"editor_disconected_click_to_reconnect": "Editor disconnected, click anywhere to reconnect.",
|
||||
"editor_limit_exceeded_in_this_project": "Too many editors in this project",
|
||||
"editor_only_hide_pdf": "Editor only <0>(hide PDF)</0>",
|
||||
"editor_resources": "Editor Resources",
|
||||
"editor_theme": "Editor theme",
|
||||
|
@ -939,6 +942,7 @@
|
|||
"invalid_password_too_similar": "Password is too similar to parts of email address",
|
||||
"invalid_request": "Invalid Request. Please correct the data and try again.",
|
||||
"invalid_zip_file": "Invalid zip file",
|
||||
"invite": "Invite",
|
||||
"invite_more_collabs": "Invite more collaborators",
|
||||
"invite_not_accepted": "Invite not yet accepted",
|
||||
"invite_not_valid": "This is not a valid project invite",
|
||||
|
@ -1034,6 +1038,7 @@
|
|||
"license": "License",
|
||||
"license_for_educational_purposes": "This license is for educational purposes (applies to students or faculty using __appName__ for teaching)",
|
||||
"limited_offer": "Limited offer",
|
||||
"limited_to_n_editors_per_project": "Limited to n editors per project",
|
||||
"line_height": "Line Height",
|
||||
"line_width_is_the_width_of_the_line_in_the_current_environment": "Line width is the width of the line in the current environment. e.g. a full page width in single-column layout or half a page width in a two-column layout.",
|
||||
"link": "Link",
|
||||
|
@ -1043,6 +1048,7 @@
|
|||
"link_institutional_email_get_started": "Link an institutional email address to your account to get started.",
|
||||
"link_sharing": "Link sharing",
|
||||
"link_sharing_is_off": "Link sharing is off, only invited users can view this project.",
|
||||
"link_sharing_is_off_short": "Link sharing is off",
|
||||
"link_sharing_is_on": "Link sharing is on",
|
||||
"link_to_github": "Link to your GitHub account",
|
||||
"link_to_github_description": "You need to authorise __appName__ to access your GitHub account to allow us to sync your projects.",
|
||||
|
@ -1108,6 +1114,7 @@
|
|||
"maintenance": "Maintenance",
|
||||
"make_a_copy": "Make a copy",
|
||||
"make_email_primary_description": "Make this the primary email, used to log in",
|
||||
"make_owner": "Make owner",
|
||||
"make_primary": "Make Primary",
|
||||
"make_private": "Make Private",
|
||||
"manage_beta_program_membership": "Manage Beta Program Membership",
|
||||
|
@ -1495,6 +1502,7 @@
|
|||
"react_history_tutorial_title": "History actions have a new home",
|
||||
"reactivate_subscription": "Reactivate your subscription",
|
||||
"read_lines_from_path": "Read lines from __path__",
|
||||
"read_more": "Read more",
|
||||
"read_more_about_free_compile_timeouts_servers": "Read more about changes to free compile timeouts and servers",
|
||||
"read_only": "Read Only",
|
||||
"read_only_token": "Read-Only Token",
|
||||
|
@ -1556,6 +1564,7 @@
|
|||
"remind_before_trial_ends": "We’ll remind you before your trial ends",
|
||||
"remote_service_error": "The remote service produced an error",
|
||||
"remove": "Remove",
|
||||
"remove_access": "Remove access",
|
||||
"remove_collaborator": "Remove collaborator",
|
||||
"remove_from_group": "Remove from group",
|
||||
"remove_link": "Remove link",
|
||||
|
@ -2145,6 +2154,7 @@
|
|||
"upgrade_cc_btn": "Upgrade now, pay after 7 days",
|
||||
"upgrade_for_12x_more_compile_time": "Upgrade to get 12x more compile time",
|
||||
"upgrade_now": "Upgrade Now",
|
||||
"upgrade_to_add_more_editors": "Upgrade to add more editors to your project",
|
||||
"upgrade_to_get_feature": "Upgrade to get __feature__, plus:",
|
||||
"upgrade_to_track_changes": "Upgrade to track changes",
|
||||
"upload": "Upload",
|
||||
|
@ -2202,6 +2212,7 @@
|
|||
"view_pdf": "View PDF",
|
||||
"view_source": "View Source",
|
||||
"view_your_invoices": "View Your Invoices",
|
||||
"viewer": "Viewer",
|
||||
"viewing_x": "Viewing <0>__endTime__</0>",
|
||||
"visual_editor": "Visual Editor",
|
||||
"visual_editor_is_only_available_for_tex_files": "Visual Editor is only available for TeX files",
|
||||
|
@ -2230,6 +2241,7 @@
|
|||
"when_you_tick_the_include_caption_box": "When you tick the box “Include caption” the image will be inserted into your document with a placeholder caption. To edit it, you simply select the placeholder text and type to replace it with your own.",
|
||||
"why_latex": "Why LaTeX?",
|
||||
"wide": "Wide",
|
||||
"will_lose_edit_access_on_date": "Will lose edit access on __date__",
|
||||
"will_need_to_log_out_from_and_in_with": "You will need to <b>log out</b> from your <b>__email1__</b> account and then log in with <b>__email2__</b>.",
|
||||
"with_premium_subscription_you_also_get": "With an Overleaf Premium subscription you also get",
|
||||
"word_count": "Word Count",
|
||||
|
@ -2279,6 +2291,7 @@
|
|||
"you_are_on_x_plan_as_member_of_group_subscription_y_administered_by_z": "You are on our <0>__planName__</0> plan as a <1>member</1> of the group subscription <1>__groupName__</1> administered by <1>__adminEmail__</1>",
|
||||
"you_can_now_enable_sso": "You can now enable SSO on your Group settings page.",
|
||||
"you_can_now_log_in_sso": "You can now log in through your institution and if eligible you will receive <0>__appName__ Professional features</0>.",
|
||||
"you_can_only_add_n_people_to_edit_a_project": "You can only add X people to edit a project with you on your current plan. Upgrade to add more.",
|
||||
"you_can_opt_in_and_out_of_the_program_at_any_time_on_this_page": "You can <0>opt in and out</0> of the program at any time on this page",
|
||||
"you_can_request_a_maximum_of_limit_fixes_per_day": "You can request a maximum of __limit__ fixes per day. Please try again tomorrow.",
|
||||
"you_cant_add_or_change_password_due_to_sso": "You can’t add or change your password because your group or organization uses <0>single sign-on (SSO)</0>.",
|
||||
|
@ -2319,6 +2332,7 @@
|
|||
"your_password_was_detected": "Your password is on a <0>public list of known compromised passwords</0>. Keep your account safe by changing your password now.",
|
||||
"your_plan": "Your plan",
|
||||
"your_plan_is_changing_at_term_end": "Your plan is changing to <0>__pendingPlanName__</0> at the end of the current billing period.",
|
||||
"your_plan_is_limited_to_n_editors": "Your plan allows [n] collaborators with edit access and unlimited viewers. From [date] any additional editors on this project will be made viewers.",
|
||||
"your_project_exceeded_compile_timeout_limit_on_free_plan": "Your project exceeded the compile timeout limit on our free plan.",
|
||||
"your_project_near_compile_timeout_limit": "Your project is near the compile timeout limit for our free plan.",
|
||||
"your_projects": "Your Projects",
|
||||
|
|
|
@ -44,9 +44,12 @@ function render(props: RenderProps) {
|
|||
itemToSubtitle={props.itemToSubtitle}
|
||||
itemToKey={x => String(x.key)}
|
||||
onSelectedItemChanged={props.onSelectedItemChanged}
|
||||
selected={props.selected}
|
||||
disabled={props.disabled}
|
||||
itemToDisabled={props.itemToDisabled}
|
||||
optionalLabel={props.optionalLabel}
|
||||
loading={props.loading}
|
||||
selectedIcon={props.selectedIcon}
|
||||
/>
|
||||
<button type="submit">submit</button>
|
||||
</form>
|
||||
|
@ -275,4 +278,55 @@ describe('<Select />', function () {
|
|||
cy.findByText('Demo item 2').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectedIcon', function () {
|
||||
it('renders a selected icon if the prop is set', function () {
|
||||
render({
|
||||
defaultText: 'Choose an item',
|
||||
selectedIcon: true,
|
||||
})
|
||||
cy.findByText('Choose an item').click()
|
||||
cy.findByText('Demo item 1').click()
|
||||
cy.findByText('Demo item 1').click()
|
||||
|
||||
cy.get('.fa-check').should('exist')
|
||||
})
|
||||
it('renders no selected icon if the prop is not set', function () {
|
||||
render({
|
||||
defaultText: 'Choose an item',
|
||||
selectedIcon: false,
|
||||
})
|
||||
cy.findByText('Choose an item').click()
|
||||
cy.findByText('Demo item 1').click()
|
||||
cy.findByText('Demo item 1').click()
|
||||
|
||||
cy.get('.fa-check').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('itemToDisabled', function () {
|
||||
it('prevents selecting a disabled item', function () {
|
||||
render({
|
||||
defaultText: 'Choose an item',
|
||||
itemToDisabled: x => x?.key === 2,
|
||||
})
|
||||
cy.findByText('Choose an item').click()
|
||||
cy.findByText('Demo item 2').click()
|
||||
// still showing other list items
|
||||
cy.findByText('Demo item 3').should('exist')
|
||||
cy.findByText('Demo item 1').click()
|
||||
// clicking an enabled item dismisses the list
|
||||
cy.findByText('Demo item 3').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('selected', function () {
|
||||
it('shows the item provided in the selected prop', function () {
|
||||
render({
|
||||
defaultText: 'Choose an item',
|
||||
selected: testData[1],
|
||||
})
|
||||
cy.findByText('Demo item 2').should('exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -164,6 +164,7 @@ describe('ProjectController', function () {
|
|||
this.SplitTestHandler = {
|
||||
promises: {
|
||||
getAssignment: sinon.stub().resolves({ variant: 'default' }),
|
||||
getAssignmentForUser: sinon.stub().resolves({ variant: 'default' }),
|
||||
},
|
||||
getAssignment: sinon.stub().yields(null, { variant: 'default' }),
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { MongoUser } from './user'
|
||||
import { Folder } from './folder'
|
||||
|
||||
type ProjectMember = {
|
||||
export type ProjectMember = {
|
||||
_id: string
|
||||
type: 'user'
|
||||
privileges: 'readOnly' | 'readAndWrite'
|
||||
|
|
Loading…
Add table
Reference in a new issue