Просмотр исходного кода

Merge pull request #32941 from overleaf/ii-share-modal-manage-access

[web] Share modal redesign: invited people screen

GitOrigin-RevId: bf7f83dccc245e41eca8087fc27c1b411f025b0d
ilkin-overleaf 3 месяцев назад
Родитель
Сommit
6ccbefc3f8

+ 4 - 0
services/web/frontend/extracted-translations.json

@@ -965,7 +965,10 @@
   "invite_more_members": "",
   "invite_not_accepted": "",
   "invite_resend_limit_hit": "",
+  "invite_resent": "",
+  "invite_revoked": "",
   "invite_users": "",
+  "invited_people": "",
   "invited_to_group": "",
   "invited_to_group_have_individual_subcription": "",
   "inviting": "",
@@ -1576,6 +1579,7 @@
   "resend": "",
   "resend_confirmation_code": "",
   "resend_group_invite": "",
+  "resend_invite": "",
   "resend_link_sso": "",
   "resend_managed_user_invite": "",
   "resending_confirmation_code": "",

BIN
services/web/frontend/fonts/material-symbols/MaterialSymbolsRoundedUnfilledPartialSlice.woff2


+ 4 - 0
services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs

@@ -45,6 +45,7 @@ export default /** @type {const} */ ([
   'lightbulb_2',
   'lock',
   'lock_open',
+  'mail',
   'mode_comment',
   'more_vert',
   'neurology',
@@ -52,6 +53,8 @@ export default /** @type {const} */ ([
   'notifications',
   'open_in_new',
   'password',
+  'person',
+  'person_edit',
   'picture_as_pdf',
   'push_pin',
   'rate_review',
@@ -75,6 +78,7 @@ export default /** @type {const} */ ([
   'upload',
   'visibility',
   'visibility_off',
+  'warning',
   'web_asset',
   'whatshot',
   'wifi_off',

+ 214 - 7
services/web/frontend/js/features/share-project-modal/components/edit-member.tsx

@@ -15,6 +15,15 @@ import OLCol from '@/shared/components/ol/ol-col'
 import MaterialIcon from '@/shared/components/material-icon'
 import { useUserContext } from '@/shared/context/user-context'
 import { upgradePlan } from '@/main/account-upgrade'
+import ShareProjectModalRow from '@/features/share-project-modal/components/share-project-modal-row'
+import {
+  Dropdown,
+  DropdownMenu,
+  DropdownToggle,
+} from '@/shared/components/dropdown/dropdown-menu'
+import OLDropdownMenuItem from '@/shared/components/ol/ol-dropdown-menu-item'
+import { useFeatureFlag } from '@/shared/context/split-test-context'
+import classnames from 'classnames'
 
 type PermissionsOption = PermissionsLevel | 'removeAccess' | 'downgraded'
 
@@ -38,6 +47,7 @@ export default function EditMember({
   canAddCollaborators,
   isReviewerOnFreeProject,
 }: EditMemberProps) {
+  const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
   const [privileges, setPrivileges] = useState<PermissionsOption>(
     member.privileges
   )
@@ -51,8 +61,8 @@ export default function EditMember({
     setPrivileges(member.privileges)
   }, [member.privileges])
 
-  const { monitorRequest } = useShareProjectContext()
-  const { projectId, project, updateProject } = useProjectContext()
+  const { monitorRequest, setSuccessActionMessage } = useShareProjectContext()
+  const { projectId, project, updateProject, features } = useProjectContext()
   const { members, invites } = project || {}
   const user = useUserContext()
 
@@ -113,10 +123,14 @@ export default function EditMember({
       ).then(() => {
         updateProject({
           members:
-            members?.map(item =>
-              item._id === member._id ? { ...item, newPrivileges } : item
+            members?.map(
+              (item): ProjectMember =>
+                item._id === member._id
+                  ? { ...item, privileges: newPrivileges }
+                  : item
             ) || [],
         })
+        setSuccessActionMessage(t('access_updated'))
       })
     }
   }
@@ -136,7 +150,195 @@ export default function EditMember({
   const confirmRemoval =
     privileges !== member.privileges && privilegeChangePending
 
-  return (
+  const getDropdownLabel = () => {
+    if (hasBeenDowngraded) {
+      return t('select_access_level')
+    }
+    switch (privileges) {
+      case 'owner':
+        return t('make_owner')
+      case 'readAndWrite':
+        return t('editor')
+      case 'review':
+        return t('reviewer')
+      case 'readOnly':
+        return t('viewer')
+      case 'removeAccess':
+        return t('remove_access')
+      case 'downgraded':
+        return t('select_access_level')
+      default:
+        return t('remove_access')
+    }
+  }
+
+  const getPrivilegeSubtitle = (privilege: PermissionsOption) => {
+    if (!['readAndWrite', 'review'].includes(privilege)) {
+      return
+    }
+
+    if (
+      (hasBeenDowngraded && !confirmRemoval) ||
+      (!canAddCollaborators && !['readAndWrite', 'review'].includes(privileges))
+    ) {
+      return t('limited_to_n_collaborators_per_project', {
+        count: features.collaborators,
+      })
+    }
+  }
+
+  const isPrivilegeDisabled = (privilege: PermissionsOption) => {
+    return (
+      !canAddCollaborators &&
+      ['readAndWrite', 'review'].includes(privilege) &&
+      ((hasBeenDowngraded && !confirmRemoval) ||
+        !['readAndWrite', 'review'].includes(privileges))
+    )
+  }
+
+  return isSharingUpdatesEnabled ? (
+    <ShareProjectModalRow>
+      <div className="d-inline-flex align-items-center h5 m-0 gap-2">
+        <MaterialIcon
+          type={
+            shouldWarnMember() || member.pendingEditor || member.pendingReviewer
+              ? 'warning'
+              : 'person'
+          }
+          className={
+            shouldWarnMember() || member.pendingEditor || member.pendingReviewer
+              ? 'text-warning'
+              : undefined
+          }
+          unfilled
+        />
+        <div className="px-2">
+          {member.email}
+          {member.pendingEditor && (
+            <div className="small fw-normal">{t('view_only_downgraded')}</div>
+          )}
+          {member.pendingReviewer && (
+            <div className="small fw-normal">
+              {t('view_only_reviewer_downgraded')}
+            </div>
+          )}
+          {shouldWarnMember() && (
+            <div className="small fw-normal">
+              {t('will_lose_edit_access_on_date', {
+                date: linkSharingEnforcementDate,
+              })}
+            </div>
+          )}
+          {isReviewerOnFreeProject && (
+            <div className="small fw-normal">
+              <Trans
+                i18nKey="comment_only_upgrade_to_enable_track_changes"
+                components={[
+                  // eslint-disable-next-line react/jsx-key
+                  <OLButton
+                    variant="link"
+                    className="btn-inline-link"
+                    onClick={() => upgradePlan('track-changes')}
+                  />,
+                ]}
+              />
+            </div>
+          )}
+        </div>
+      </div>
+      <div className="text-end">
+        <Dropdown
+          align="end"
+          onSelect={(eventKey: PermissionsOption) => {
+            if (eventKey) {
+              handlePrivilegeChange(eventKey)
+            }
+          }}
+        >
+          <div className="d-flex align-items-center gap-2">
+            {hasBeenDowngraded && !confirmRemoval && (
+              <MaterialIcon type="warning" unfilled className="text-warning" />
+            )}
+            <DropdownToggle
+              variant="ghost"
+              className="d-flex align-items-center gap-2 no-default-caret"
+            >
+              {getDropdownLabel()}
+              <MaterialIcon type="keyboard_arrow_down" />
+            </DropdownToggle>
+          </div>
+          <DropdownMenu>
+            <OLDropdownMenuItem
+              as="button"
+              eventKey="owner"
+              leadingIcon={<MaterialIcon type="person_edit" unfilled />}
+              active={privileges === 'owner'}
+              trailingIcon={privileges === 'owner' ? 'check' : undefined}
+            >
+              {t('make_owner')}
+            </OLDropdownMenuItem>
+            <OLDropdownMenuItem
+              as="button"
+              eventKey="readAndWrite"
+              leadingIcon={<MaterialIcon type="edit" unfilled />}
+              active={privileges === 'readAndWrite'}
+              trailingIcon={privileges === 'readAndWrite' ? 'check' : undefined}
+              disabled={isPrivilegeDisabled('readAndWrite')}
+              description={getPrivilegeSubtitle('readAndWrite')}
+            >
+              {t('editor')}
+            </OLDropdownMenuItem>
+            {features.trackChangesVisible && (
+              <OLDropdownMenuItem
+                as="button"
+                eventKey="review"
+                leadingIcon={<MaterialIcon type="mode_comment" unfilled />}
+                active={privileges === 'review'}
+                trailingIcon={privileges === 'review' ? 'check' : undefined}
+                disabled={isPrivilegeDisabled('review')}
+                description={getPrivilegeSubtitle('review')}
+              >
+                {t('reviewer')}
+              </OLDropdownMenuItem>
+            )}
+            <OLDropdownMenuItem
+              as="button"
+              eventKey="readOnly"
+              leadingIcon={<MaterialIcon type="visibility" unfilled />}
+              active={privileges === 'readOnly'}
+              trailingIcon={privileges === 'readOnly' ? 'check' : undefined}
+            >
+              {t('viewer')}
+            </OLDropdownMenuItem>
+            <OLDropdownMenuItem
+              as="button"
+              eventKey="removeAccess"
+              variant="danger"
+              leadingIcon={<MaterialIcon type="block" unfilled />}
+              active={privileges === 'removeAccess'}
+              trailingIcon={privileges === 'removeAccess' ? 'check' : undefined}
+            >
+              {t('remove_access')}
+            </OLDropdownMenuItem>
+          </DropdownMenu>
+        </Dropdown>
+        {confirmRemoval && (
+          <form
+            onSubmit={e => {
+              e.preventDefault()
+              if (privilegeChangePending) {
+                commitPrivilegeChange(privileges)
+              }
+            }}
+          >
+            <ChangePrivilegesActions
+              handleReset={() => setPrivileges(member.privileges)}
+            />
+          </form>
+        )}
+      </div>
+    </ShareProjectModalRow>
+  ) : (
     <form
       id="share-project-form"
       onSubmit={e => {
@@ -322,12 +524,17 @@ type ChangePrivilegesActionsProps = {
 function ChangePrivilegesActions({
   handleReset,
 }: ChangePrivilegesActionsProps) {
+  const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
   const { t } = useTranslation()
 
   return (
-    <div className="text-center d-flex gap-1 me-3">
+    <div
+      className={classnames('text-center d-flex gap-1 me-3', {
+        'mt-2 justify-content-end': isSharingUpdatesEnabled,
+      })}
+    >
       <OLButton
-        type="submit"
+        type="button"
         size="sm"
         variant="secondary"
         onClick={handleReset}

+ 71 - 6
services/web/frontend/js/features/share-project-modal/components/invite.tsx

@@ -9,8 +9,17 @@ import OLRow from '@/shared/components/ol/ol-row'
 import OLCol from '@/shared/components/ol/ol-col'
 import OLTooltip from '@/shared/components/ol/ol-tooltip'
 import OLButton from '@/shared/components/ol/ol-button'
+import OLBadge from '@/shared/components/ol/ol-badge'
+import OLDropdownMenuItem from '@/shared/components/ol/ol-dropdown-menu-item'
+import {
+  Dropdown,
+  DropdownMenu,
+  DropdownToggle,
+} from '@/shared/components/dropdown/dropdown-menu'
+import ShareProjectModalRow from '@/features/share-project-modal/components/share-project-modal-row'
 import MaterialIcon from '@/shared/components/material-icon'
 import { ProjectMember } from '@/shared/context/types/project-metadata'
+import { useFeatureFlag } from '@/shared/context/split-test-context'
 
 export default function Invite({
   invite,
@@ -19,8 +28,41 @@ export default function Invite({
   invite: ProjectMember
   isProjectOwner: boolean
 }) {
+  const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
   const { t } = useTranslation()
-  return (
+
+  return isSharingUpdatesEnabled ? (
+    <ShareProjectModalRow>
+      <div className="d-inline-flex align-items-center h5 m-0 gap-2">
+        <MaterialIcon type="person" unfilled />
+        <div className="px-2">{invite.email}</div>
+        <OLBadge bg="light" text="dark">
+          {t('pending_invite')}
+        </OLBadge>
+      </div>
+      {isProjectOwner ? (
+        <Dropdown align="end" onSelect={() => {}}>
+          <DropdownToggle
+            variant="ghost"
+            className="d-flex align-items-center gap-2 no-default-caret"
+          >
+            <MemberPrivileges privileges={invite.privileges} />
+            <MaterialIcon type="keyboard_arrow_down" />
+          </DropdownToggle>
+          <DropdownMenu>
+            <ResendInvite invite={invite} />
+            <RevokeInvite invite={invite} />
+          </DropdownMenu>
+        </Dropdown>
+      ) : (
+        <div className="h5 m-0 px-4 fw-semibold">
+          <div className="form-control-plaintext border-0">
+            <MemberPrivileges privileges={invite.privileges} />
+          </div>
+        </div>
+      )}
+    </ShareProjectModalRow>
+  ) : (
     <OLRow className="project-invite">
       <OLCol xs={8}>
         <div>{invite.email}</div>
@@ -45,8 +87,10 @@ export default function Invite({
 }
 
 function ResendInvite({ invite }: { invite: ProjectMember }) {
+  const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
   const { t } = useTranslation()
-  const { monitorRequest, setError, inFlight } = useShareProjectContext()
+  const { monitorRequest, setError, inFlight, setSuccessActionMessage } =
+    useShareProjectContext()
   const { projectId } = useProjectContext()
 
   // const buttonRef = useRef(null)
@@ -54,6 +98,7 @@ function ResendInvite({ invite }: { invite: ProjectMember }) {
   const handleClick = useCallback(
     () =>
       monitorRequest(() => resendInvite(projectId, invite))
+        .then(() => setSuccessActionMessage(t('invite_resent')))
         .catch(error => {
           if (error?.response?.status === 404) {
             setError('invite_expired')
@@ -71,10 +116,19 @@ function ResendInvite({ invite }: { invite: ProjectMember }) {
             ;(document.activeElement as HTMLElement).blur()
           }
         }),
-    [invite, monitorRequest, projectId, setError]
+    [invite, monitorRequest, projectId, setError, setSuccessActionMessage, t]
   )
 
-  return (
+  return isSharingUpdatesEnabled ? (
+    <OLDropdownMenuItem
+      as="button"
+      leadingIcon={<MaterialIcon type="mail" unfilled />}
+      onClick={handleClick}
+      disabled={inFlight}
+    >
+      {t('resend_invite')}
+    </OLDropdownMenuItem>
+  ) : (
     <OLButton
       variant="link"
       className="btn-inline-link"
@@ -88,8 +142,9 @@ function ResendInvite({ invite }: { invite: ProjectMember }) {
 }
 
 function RevokeInvite({ invite }: { invite: ProjectMember }) {
+  const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
   const { t } = useTranslation()
-  const { monitorRequest } = useShareProjectContext()
+  const { monitorRequest, setSuccessActionMessage } = useShareProjectContext()
   const { projectId, project, updateProject } = useProjectContext()
   const { invites, members } = project || {}
 
@@ -107,10 +162,20 @@ function RevokeInvite({ invite }: { invite: ProjectMember }) {
         current_invites_amount: updatedInvites.length,
         current_collaborators_amount: members?.length || 0,
       })
+      setSuccessActionMessage(t('invite_revoked'))
     })
   }
 
-  return (
+  return isSharingUpdatesEnabled ? (
+    <OLDropdownMenuItem
+      as="button"
+      leadingIcon={<MaterialIcon type="block" unfilled />}
+      variant="danger"
+      onClick={handleClick}
+    >
+      {t('revoke_invite')}
+    </OLDropdownMenuItem>
+  ) : (
     <OLTooltip
       id="revoke-invite"
       description={t('revoke_invite')}

+ 57 - 3
services/web/frontend/js/features/share-project-modal/components/invited-people.tsx

@@ -1,7 +1,61 @@
-type InvitedPeopleProps = object
+import { useTranslation } from 'react-i18next'
+import OwnerInfo from '@/features/share-project-modal/components/owner-info'
+import EditMember from '@/features/share-project-modal/components/edit-member'
+import ViewMember from '@/features/share-project-modal/components/view-member'
+import Invite from '@/features/share-project-modal/components/invite'
+import { useEditorContext } from '@/shared/context/editor-context'
+import { ProjectMember } from '@/shared/context/types/project-metadata'
 
-function InvitedPeople(_props: InvitedPeopleProps) {
-  return null
+type InvitedPeopleProps = {
+  hasExceededCollaboratorLimit: boolean
+  hasTrackChangesFeature: boolean
+  canAddCollaborators: boolean
+  sortedMembers: ProjectMember[]
+  invites?: ProjectMember[]
+}
+
+function InvitedPeople({
+  hasExceededCollaboratorLimit,
+  hasTrackChangesFeature,
+  canAddCollaborators,
+  sortedMembers,
+  invites,
+}: InvitedPeopleProps) {
+  const { t } = useTranslation()
+  const { isProjectOwner } = useEditorContext()
+
+  return (
+    <>
+      <h3 className="h4 fw-normal mt-3 mb-2 pt-1">{t('invited_people')}</h3>
+      <OwnerInfo />
+      {sortedMembers.map(member =>
+        isProjectOwner ? (
+          <EditMember
+            key={member._id}
+            member={member}
+            hasExceededCollaboratorLimit={hasExceededCollaboratorLimit}
+            hasBeenDowngraded={Boolean(
+              member.pendingEditor || member.pendingReviewer
+            )}
+            canAddCollaborators={canAddCollaborators}
+            isReviewerOnFreeProject={
+              member.privileges === 'review' && !hasTrackChangesFeature
+            }
+          />
+        ) : (
+          <ViewMember key={member._id} member={member} />
+        )
+      )}
+
+      {(invites || []).map(invite => (
+        <Invite
+          key={invite._id}
+          invite={invite}
+          isProjectOwner={isProjectOwner}
+        />
+      ))}
+    </>
+  )
 }
 
 export default InvitedPeople

+ 14 - 1
services/web/frontend/js/features/share-project-modal/components/owner-info.tsx

@@ -2,13 +2,26 @@ import { useProjectContext } from '@/shared/context/project-context'
 import { useTranslation } from 'react-i18next'
 import OLRow from '@/shared/components/ol/ol-row'
 import OLCol from '@/shared/components/ol/ol-col'
+import ShareProjectModalRow from '@/features/share-project-modal/components/share-project-modal-row'
 import MaterialIcon from '@/shared/components/material-icon'
+import { useFeatureFlag } from '@/shared/context/split-test-context'
 
 export default function OwnerInfo() {
+  const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
   const { t } = useTranslation()
   const { project } = useProjectContext()
 
-  return (
+  return isSharingUpdatesEnabled ? (
+    <ShareProjectModalRow>
+      <div className="d-inline-flex align-items-center h5 m-0 gap-2">
+        <MaterialIcon type="person" unfilled />
+        <div className="px-2">{project?.owner.email}</div>
+      </div>
+      <div className="h5 m-0 px-4 fw-semibold">
+        <div className="form-control-plaintext border-0">{t('owner')}</div>
+      </div>
+    </ShareProjectModalRow>
+  ) : (
     <OLRow className="project-member">
       <OLCol xs={8}>
         <div className="project-member-email-icon">

+ 7 - 1
services/web/frontend/js/features/share-project-modal/components/share-modal-body.tsx

@@ -134,7 +134,13 @@ export default function ShareModalBody({
             />
           )}
           {isInvitedPeopleScreen || !isProjectOwner ? (
-            <InvitedPeople />
+            <InvitedPeople
+              sortedMembers={sortedMembers}
+              invites={invites}
+              hasExceededCollaboratorLimit={hasExceededCollaboratorLimit}
+              hasTrackChangesFeature={Boolean(features.trackChanges)}
+              canAddCollaborators={canAddCollaborators}
+            />
           ) : (
             <ProjectAccess
               setIsInvitedPeopleScreen={setIsInvitedPeopleScreen}

+ 1 - 0
services/web/frontend/js/features/share-project-modal/components/share-project-modal.tsx

@@ -160,6 +160,7 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
   // update `error` and `inFlight` while sending a request
   const monitorRequest = useCallback((request: () => any) => {
     setError(undefined)
+    setSuccessActionMessage(undefined)
     setInFlight(true)
 
     const promise = request()

+ 17 - 1
services/web/frontend/js/features/share-project-modal/components/view-member.tsx

@@ -1,11 +1,27 @@
 import MemberPrivileges from './member-privileges'
 import OLRow from '@/shared/components/ol/ol-row'
 import OLCol from '@/shared/components/ol/ol-col'
+import ShareProjectModalRow from '@/features/share-project-modal/components/share-project-modal-row'
 import MaterialIcon from '@/shared/components/material-icon'
 import { ProjectMember } from '@/shared/context/types/project-metadata'
+import { useFeatureFlag } from '@/shared/context/split-test-context'
 
 export default function ViewMember({ member }: { member: ProjectMember }) {
-  return (
+  const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
+
+  return isSharingUpdatesEnabled ? (
+    <ShareProjectModalRow>
+      <div className="d-inline-flex align-items-center h5 m-0 gap-2">
+        <MaterialIcon type="person" unfilled />
+        <div className="px-2">{member.email}</div>
+      </div>
+      <div className="h5 m-0 px-4 fw-semibold">
+        <div className="form-control-plaintext border-0">
+          <MemberPrivileges privileges={member.privileges} />
+        </div>
+      </div>
+    </ShareProjectModalRow>
+  ) : (
     <OLRow className="project-member">
       <OLCol xs={8}>
         <div className="project-member-email-icon">

+ 4 - 0
services/web/locales/en.json

@@ -1261,7 +1261,10 @@
   "invite_not_valid": "This is not a valid project invite",
   "invite_not_valid_description": "The invite may have expired. Please contact the project owner",
   "invite_resend_limit_hit": "The invite resend limit hit",
+  "invite_resent": "Invite resent.",
+  "invite_revoked": "Invite revoked.",
   "invite_users": "Invite users",
+  "invited_people": "Invited people",
   "invited_to_group": "<0>__inviterName__</0> has invited you to join a group subscription on __appName__",
   "invited_to_group_have_individual_subcription": "__inviterName__ has invited you to join a group __appName__ subscription. If you join this group, you may not need your individual subscription. Would you like to cancel it?",
   "invited_to_group_login": "To accept this invitation you need to log in as __emailAddress__.",
@@ -2094,6 +2097,7 @@
   "resend": "Resend",
   "resend_confirmation_code": "Resend confirmation code",
   "resend_group_invite": "Resend group invite",
+  "resend_invite": "Resend invite",
   "resend_link_sso": "Resend SSO invite",
   "resend_managed_user_invite": "Resend managed user invite",
   "resending_confirmation_code": "Resending confirmation code",