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

Merge pull request #15319 from overleaf/ii-ide-page-prototype-share-modal

Share modal for React IDE page

GitOrigin-RevId: f72f824abdcb5a135c354e3ccc35912b2097673f
ilkin-overleaf 2 лет назад
Родитель
Сommit
9b6f83dfd4

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

@@ -927,6 +927,7 @@
   "remove_or_replace_figure": "",
   "remove_secondary_email_addresses": "",
   "remove_tag": "",
+  "removed_from_project": "",
   "removing": "",
   "rename": "",
   "rename_project": "",
@@ -1372,6 +1373,7 @@
   "you_have_added_x_of_group_size_y": "",
   "you_have_been_invited_to_transfer_management_of_your_account": "",
   "you_have_been_invited_to_transfer_management_of_your_account_to": "",
+  "you_have_been_removed_from_this_project_and_will_be_redirected_to_project_dashboard": "",
   "you_may_be_able_to_prevent_a_compile_timeout": "",
   "you_need_to_configure_your_sso_settings": "",
   "you_will_be_able_to_reassign_subscription": "",

+ 38 - 0
services/web/frontend/js/features/ide-react/components/editor-navigation-toolbar.tsx

@@ -0,0 +1,38 @@
+import { useState, useCallback } from 'react'
+import { useOnlineUsersContext } from '@/features/ide-react/context/online-users-context'
+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 ShareProjectModal from '@/features/share-project-modal/components/share-project-modal'
+
+function EditorNavigationToolbar() {
+  const [showShareModal, setShowShareModal] = useState(false)
+  const { onlineUsersArray } = useOnlineUsersContext()
+  const { openDoc } = useEditorManagerContext()
+
+  const handleOpenShareModal = () => {
+    eventTracking.sendMBOnce('ide-open-share-modal-once')
+    setShowShareModal(true)
+  }
+
+  const handleHideShareModal = useCallback(() => {
+    setShowShareModal(false)
+  }, [])
+
+  return (
+    <>
+      <EditorNavigationToolbarRoot
+        // @ts-ignore
+        onlineUsersArray={onlineUsersArray}
+        openDoc={openDoc}
+        openShareProjectModal={handleOpenShareModal}
+      />
+      <ShareProjectModal
+        show={showShareModal}
+        handleHide={handleHideShareModal}
+      />
+    </>
+  )
+}
+
+export default EditorNavigationToolbar

+ 0 - 63
services/web/frontend/js/features/ide-react/components/header.tsx

@@ -1,63 +0,0 @@
-import React, { useCallback } from 'react'
-import ChatToggleButton from '@/features/editor-navigation-toolbar/components/chat-toggle-button'
-import HistoryToggleButton from '@/features/editor-navigation-toolbar/components/history-toggle-button'
-import LayoutDropdownButton from '@/features/editor-navigation-toolbar/components/layout-dropdown-button'
-import MenuButton from '@/features/editor-navigation-toolbar/components/menu-button'
-import { useLayoutContext } from '@/shared/context/layout-context'
-import { sendMB } from '@/infrastructure/event-tracking'
-import OnlineUsersWidget from '@/features/editor-navigation-toolbar/components/online-users-widget'
-import { useOnlineUsersContext } from '@/features/ide-react/context/online-users-context'
-
-type HeaderProps = {
-  chatIsOpen: boolean
-  setChatIsOpen: (chatIsOpen: boolean) => void
-  historyIsOpen: boolean
-  setHistoryIsOpen: (historyIsOpen: boolean) => void
-}
-
-export default function Header({
-  chatIsOpen,
-  setChatIsOpen,
-  historyIsOpen,
-  setHistoryIsOpen,
-}: HeaderProps) {
-  const { setLeftMenuShown } = useLayoutContext()
-  const { onlineUsersArray } = useOnlineUsersContext()
-
-  function toggleChatOpen() {
-    setChatIsOpen(!chatIsOpen)
-  }
-
-  function toggleHistoryOpen() {
-    setHistoryIsOpen(!historyIsOpen)
-  }
-
-  const handleShowLeftMenuClick = useCallback(() => {
-    sendMB('navigation-clicked-menu')
-    setLeftMenuShown(value => !value)
-  }, [setLeftMenuShown])
-
-  return (
-    <header className="toolbar toolbar-header">
-      <div className="toolbar-left">
-        <MenuButton onClick={handleShowLeftMenuClick} />
-      </div>
-      <div className="toolbar-right">
-        <OnlineUsersWidget
-          onlineUsers={onlineUsersArray}
-          goToUser={() => alert('Not implemented')}
-        />
-        <LayoutDropdownButton />
-        <HistoryToggleButton
-          historyIsOpen={historyIsOpen}
-          onClick={toggleHistoryOpen}
-        />
-        <ChatToggleButton
-          chatIsOpen={chatIsOpen}
-          onClick={toggleChatOpen}
-          unreadMessageCount={0}
-        />
-      </div>
-    </header>
-  )
-}

+ 6 - 20
services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx

@@ -7,14 +7,16 @@ import PlaceholderChat from '@/features/ide-react/components/layout/placeholder/
 import PlaceholderHistory from '@/features/ide-react/components/layout/placeholder/placeholder-history'
 import MainLayout from '@/features/ide-react/components/layout/main-layout'
 import { EditorAndSidebar } from '@/features/ide-react/components/editor-and-sidebar'
-import Header from '@/features/ide-react/components/header'
 import EditorLeftMenu from '@/features/editor-left-menu/components/editor-left-menu'
+import EditorNavigationToolbar from '@/features/ide-react/components/editor-navigation-toolbar'
 import { useLayoutEventTracking } from '@/features/ide-react/hooks/use-layout-event-tracking'
+import useSocketListeners from '@/features/ide-react/hooks/use-socket-listeners'
 
 // This is filled with placeholder content while the real content is migrated
 // away from Angular
 export default function IdePage() {
   useLayoutEventTracking()
+  useSocketListeners()
 
   const [leftColumnDefaultSize, setLeftColumnDefaultSize] = useState(20)
   const { registerUserActivity } = useConnectionContext()
@@ -32,24 +34,8 @@ export default function IdePage() {
     return () => document.body.removeEventListener('click', listener)
   }, [listener])
 
-  const { chatIsOpen, setChatIsOpen, view, setView } = useLayoutContext()
+  const { chatIsOpen, view } = useLayoutContext()
   const historyIsOpen = view === 'history'
-  const setHistoryIsOpen = useCallback(
-    (historyIsOpen: boolean) => {
-      setView(historyIsOpen ? 'history' : 'editor')
-    },
-    [setView]
-  )
-
-  const headerContent = (
-    <Header
-      chatIsOpen={chatIsOpen}
-      setChatIsOpen={setChatIsOpen}
-      historyIsOpen={historyIsOpen}
-      setHistoryIsOpen={setHistoryIsOpen}
-    />
-  )
-  const chatContent = <PlaceholderChat />
 
   const mainContent = historyIsOpen ? (
     <PlaceholderHistory
@@ -70,8 +56,8 @@ export default function IdePage() {
       <Alerts />
       <EditorLeftMenu />
       <MainLayout
-        headerContent={headerContent}
-        chatContent={chatContent}
+        headerContent={<EditorNavigationToolbar />}
+        chatContent={<PlaceholderChat />}
         mainContent={mainContent}
         chatIsOpen={chatIsOpen}
         shouldPersistLayout

+ 5 - 1
services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-header.tsx

@@ -1,5 +1,6 @@
 import React from 'react'
 import ChatToggleButton from '@/features/editor-navigation-toolbar/components/chat-toggle-button'
+import ShareProjectButton from '@/features/editor-navigation-toolbar/components/share-project-button'
 import HistoryToggleButton from '@/features/editor-navigation-toolbar/components/history-toggle-button'
 import LayoutDropdownButton from '@/features/editor-navigation-toolbar/components/layout-dropdown-button'
 
@@ -16,6 +17,8 @@ export default function PlaceholderHeader({
   historyIsOpen,
   setHistoryIsOpen,
 }: PlaceholderHeaderProps) {
+  function handleOpenShareModal() {}
+
   function toggleChatOpen() {
     setChatIsOpen(!chatIsOpen)
   }
@@ -28,11 +31,12 @@ export default function PlaceholderHeader({
     <header className="toolbar toolbar-header">
       <div className="toolbar-left">Header placeholder</div>
       <div className="toolbar-right">
-        <LayoutDropdownButton />
+        <ShareProjectButton onClick={handleOpenShareModal} />
         <HistoryToggleButton
           historyIsOpen={historyIsOpen}
           onClick={toggleHistoryOpen}
         />
+        <LayoutDropdownButton />
         <ChatToggleButton
           chatIsOpen={chatIsOpen}
           onClick={toggleChatOpen}

+ 37 - 0
services/web/frontend/js/features/ide-react/components/modals/generic-message-modal.tsx

@@ -0,0 +1,37 @@
+import { useTranslation } from 'react-i18next'
+import { Modal } from 'react-bootstrap'
+import AccessibleModal from '@/shared/components/accessible-modal'
+
+export type GenericMessageModalOwnProps = {
+  title: string
+  message: string
+}
+
+type GenericMessageModalProps = React.ComponentProps<typeof AccessibleModal> &
+  GenericMessageModalOwnProps
+
+function GenericMessageModal({
+  title,
+  message,
+  ...modalProps
+}: GenericMessageModalProps) {
+  const { t } = useTranslation()
+
+  return (
+    <AccessibleModal {...modalProps}>
+      <Modal.Header closeButton>
+        <Modal.Title>{title}</Modal.Title>
+      </Modal.Header>
+
+      <Modal.Body className="modal-body-share">{message}</Modal.Body>
+
+      <Modal.Footer>
+        <button className="btn btn-info" onClick={() => modalProps.onHide()}>
+          {t('ok')}
+        </button>
+      </Modal.Footer>
+    </AccessibleModal>
+  )
+}
+
+export default GenericMessageModal

+ 0 - 6
services/web/frontend/js/features/ide-react/context/ide-react-context.tsx

@@ -18,7 +18,6 @@ import { JoinProjectPayload } from '@/features/ide-react/connection/join-project
 import { useConnectionContext } from '@/features/ide-react/context/connection-context'
 import { getMockIde } from '@/shared/context/mock/mock-ide'
 import { populateEditorScope } from '@/features/ide-react/context/editor-manager-context'
-import { debugConsole } from '@/utils/debugging'
 import { postJSON } from '@/infrastructure/fetch-json'
 import { EventLog } from '@/features/ide-react/editor/event-log'
 import { populateSettingsScope } from '@/features/ide-react/scope-adapters/settings-adapter'
@@ -41,10 +40,6 @@ const IdeReactContext = createContext<IdeReactContextValue | undefined>(
   undefined
 )
 
-function showGenericMessageModal(title: string, message: string) {
-  debugConsole.log('*** showGenericMessageModal ***', title, message)
-}
-
 function populateIdeReactScope(store: ReactScopeValueStore) {
   store.set('sync_tex_error', false)
   store.set('settings', window.userSettings)
@@ -156,7 +151,6 @@ export const IdeReactProvider: FC = ({ children }) => {
     return {
       ...getMockIde(),
       socket,
-      showGenericMessageModal,
       reportError,
       // TODO: MIGRATION: Remove this once it's no longer used
       fileTreeManager: {

+ 71 - 0
services/web/frontend/js/features/ide-react/context/modals-context.tsx

@@ -0,0 +1,71 @@
+import {
+  createContext,
+  useContext,
+  FC,
+  useCallback,
+  useMemo,
+  useState,
+} from 'react'
+import GenericMessageModal, {
+  GenericMessageModalOwnProps,
+} from '@/features/ide-react/components/modals/generic-message-modal'
+
+type ModalsContextValue = {
+  showGenericMessageModal: (
+    title: GenericMessageModalOwnProps['title'],
+    message: GenericMessageModalOwnProps['message']
+  ) => void
+}
+
+const ModalsContext = createContext<ModalsContextValue | undefined>(undefined)
+
+export const ModalsContextProvider: FC = ({ children }) => {
+  const [showGenericModal, setShowGenericModal] = useState(false)
+  const [genericMessageModalData, setGenericMessageModalData] =
+    useState<GenericMessageModalOwnProps>({ title: '', message: '' })
+
+  const handleHideGenericModal = useCallback(() => {
+    setShowGenericModal(false)
+  }, [])
+
+  const showGenericMessageModal = useCallback(
+    (
+      title: GenericMessageModalOwnProps['title'],
+      message: GenericMessageModalOwnProps['message']
+    ) => {
+      setGenericMessageModalData({ title, message })
+      setShowGenericModal(true)
+    },
+    []
+  )
+
+  const value = useMemo<ModalsContextValue>(
+    () => ({
+      showGenericMessageModal,
+    }),
+    [showGenericMessageModal]
+  )
+
+  return (
+    <ModalsContext.Provider value={value}>
+      {children}
+      <GenericMessageModal
+        show={showGenericModal}
+        onHide={handleHideGenericModal}
+        {...genericMessageModalData}
+      />
+    </ModalsContext.Provider>
+  )
+}
+
+export function useModalsContext(): ModalsContextValue {
+  const context = useContext(ModalsContext)
+
+  if (!context) {
+    throw new Error(
+      'useModalsContext is only available inside ModalsContextProvider'
+    )
+  }
+
+  return context
+}

+ 4 - 1
services/web/frontend/js/features/ide-react/context/react-context-root.tsx

@@ -16,6 +16,7 @@ import { OnlineUsersProvider } from '@/features/ide-react/context/online-users-c
 import { MetadataProvider } from '@/features/ide-react/context/metadata-context'
 import { ReferencesProvider } from '@/features/ide-react/context/references-context'
 import { SplitTestProvider } from '@/shared/context/split-test-context'
+import { ModalsContextProvider } from '@/features/ide-react/context/modals-context'
 import { FileTreePathProvider } from '@/features/file-tree/contexts/file-tree-path'
 
 export const ReactContextRoot: FC = ({ children }) => {
@@ -38,7 +39,9 @@ export const ReactContextRoot: FC = ({ children }) => {
                                   <EditorManagerProvider>
                                     <OnlineUsersProvider>
                                       <MetadataProvider>
-                                        {children}
+                                        <ModalsContextProvider>
+                                          {children}
+                                        </ModalsContextProvider>
                                       </MetadataProvider>
                                     </OnlineUsersProvider>
                                   </EditorManagerProvider>

+ 64 - 0
services/web/frontend/js/features/ide-react/hooks/use-socket-listeners.ts

@@ -0,0 +1,64 @@
+import { useTranslation } from 'react-i18next'
+import useSocketListener from '@/features/ide-react/hooks/use-socket-listener'
+import {
+  listProjectInvites,
+  listProjectMembers,
+} from '@/features/share-project-modal/utils/api'
+import useScopeValue from '@/shared/hooks/use-scope-value'
+import { useConnectionContext } from '@/features/ide-react/context/connection-context'
+import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
+import { useModalsContext } from '@/features/ide-react/context/modals-context'
+import { debugConsole } from '@/utils/debugging'
+
+function useSocketListeners() {
+  const { t } = useTranslation()
+  const { socket } = useConnectionContext()
+  const { projectId } = useIdeReactContext()
+  const { showGenericMessageModal } = useModalsContext()
+  const [, setPublicAccessLevel] = useScopeValue('project.publicAccesLevel')
+  const [, setProjectMembers] = useScopeValue('project.members')
+  const [, setProjectInvites] = useScopeValue('project.invites')
+
+  useSocketListener(socket, 'project:access:revoked', () => {
+    showGenericMessageModal(
+      t('removed_from_project'),
+      t(
+        'you_have_been_removed_from_this_project_and_will_be_redirected_to_project_dashboard'
+      )
+    )
+  })
+
+  useSocketListener(socket, 'project:publicAccessLevel:changed', data => {
+    if (data.newAccessLevel) {
+      setPublicAccessLevel(data.newAccessLevel)
+    }
+  })
+
+  useSocketListener(socket, 'project:membership:changed', data => {
+    if (data.members) {
+      listProjectMembers(projectId)
+        .then(({ members }) => {
+          if (members) {
+            setProjectMembers(members)
+          }
+        })
+        .catch(err => {
+          debugConsole.error('Error fetching members for project', err)
+        })
+    }
+
+    if (data.invites) {
+      listProjectInvites(projectId)
+        .then(({ invites }) => {
+          if (invites) {
+            setProjectInvites(invites)
+          }
+        })
+        .catch(err => {
+          debugConsole.error('Error fetching invites for project', err)
+        })
+    }
+  })
+}
+
+export default useSocketListeners

+ 17 - 23
services/web/frontend/js/features/share-project-modal/components/share-project-modal-content.jsx → services/web/frontend/js/features/share-project-modal/components/share-project-modal-content.tsx

@@ -2,7 +2,6 @@ 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 PropTypes from 'prop-types'
 import { useEditorContext } from '../../../shared/context/editor-context'
 import { lazy, Suspense } from 'react'
 import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
@@ -16,18 +15,24 @@ const ReadOnlyTokenLink = lazy(() =>
 
 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({
-    isRestrictedTokenMember: PropTypes.bool,
-  })
+  const { isRestrictedTokenMember } = useEditorContext()
 
   return (
     <AccessibleModal show={show} onHide={cancel} animation={animation}>
@@ -72,37 +77,26 @@ export default function ShareProjectModalContent({
     </AccessibleModal>
   )
 }
-ShareProjectModalContent.propTypes = {
-  cancel: PropTypes.func.isRequired,
-  show: PropTypes.bool,
-  animation: PropTypes.bool,
-  inFlight: PropTypes.bool,
-  error: PropTypes.string,
-}
 
-function ErrorMessage({ error }) {
+function ErrorMessage({ error }: Pick<ShareProjectModalContentProps, 'error'>) {
   const { t } = useTranslation()
-
   switch (error) {
     case 'cannot_invite_non_user':
-      return t('cannot_invite_non_user')
+      return <>{t('cannot_invite_non_user')}</>
 
     case 'cannot_verify_user_not_robot':
-      return t('cannot_verify_user_not_robot')
+      return <>{t('cannot_verify_user_not_robot')}</>
 
     case 'cannot_invite_self':
-      return t('cannot_invite_self')
+      return <>{t('cannot_invite_self')}</>
 
     case 'invalid_email':
-      return t('invalid_email')
+      return <>{t('invalid_email')}</>
 
     case 'too_many_requests':
-      return t('too_many_requests')
+      return <>{t('too_many_requests')}</>
 
     default:
-      return t('generic_something_went_wrong')
+      return <>{t('generic_something_went_wrong')}</>
   }
 }
-ErrorMessage.propTypes = {
-  error: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]).isRequired,
-}

+ 26 - 20
services/web/frontend/js/features/share-project-modal/components/share-project-modal.jsx → services/web/frontend/js/features/share-project-modal/components/share-project-modal.tsx

@@ -14,19 +14,23 @@ import {
 import { useSplitTestContext } from '../../../shared/context/split-test-context'
 import { sendMB } from '../../../infrastructure/event-tracking'
 
-const ShareProjectContext = createContext()
-
-ShareProjectContext.Provider.propTypes = {
-  value: PropTypes.shape({
-    updateProject: PropTypes.func.isRequired,
-    monitorRequest: PropTypes.func.isRequired,
-    inFlight: PropTypes.bool,
-    setInFlight: PropTypes.func,
-    error: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]),
-    setError: PropTypes.func,
-  }),
+type ShareProjectContextValue = {
+  updateProject: (data: unknown) => 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)
 
@@ -39,13 +43,20 @@ export function useShareProjectContext() {
   return context
 }
 
+type ShareProjectModalProps = {
+  handleHide: () => void
+  show: boolean
+  animation?: boolean
+}
+
 const ShareProjectModal = React.memo(function ShareProjectModal({
   handleHide,
   show,
   animation = true,
-}) {
-  const [inFlight, setInFlight] = useState(false)
-  const [error, setError] = useState()
+}: ShareProjectModalProps) {
+  const [inFlight, setInFlight] =
+    useState<ShareProjectContextValue['inFlight']>(false)
+  const [error, setError] = useState<ShareProjectContextValue['error']>()
 
   const project = useProjectContext(projectShape)
 
@@ -84,7 +95,7 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
 
     const promise = request()
 
-    promise.catch(error => {
+    promise.catch((error: { data?: Record<string, string> }) => {
       setError(
         error.data?.errorReason ||
           error.data?.error ||
@@ -130,10 +141,5 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
     </ShareProjectContext.Provider>
   )
 })
-ShareProjectModal.propTypes = {
-  animation: PropTypes.bool,
-  handleHide: PropTypes.func.isRequired,
-  show: PropTypes.bool.isRequired,
-}
 
 export default ShareProjectModal

+ 5 - 4
services/web/frontend/js/features/share-project-modal/controllers/react-share-project-modal-controller.js

@@ -8,10 +8,11 @@ import { debugConsole } from '@/utils/debugging'
 
 App.component(
   'shareProjectModal',
-  react2angular(
-    rootContext.use(ShareProjectModal),
-    Object.keys(ShareProjectModal.propTypes)
-  )
+  react2angular(rootContext.use(ShareProjectModal), [
+    'animation',
+    'handleHide',
+    'show',
+  ])
 )
 
 export default App.controller('ReactShareProjectModalController', [

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

@@ -1445,6 +1445,7 @@
   "remove_secondary_email_addresses": "Remove any secondary email addresses associated with your account. <0>Remove them in account settings.</0>",
   "remove_tag": "Remove tag __tagName__",
   "removed": "removed",
+  "removed_from_project": "Removed from project",
   "removing": "Removing",
   "rename": "Rename",
   "rename_project": "Rename Project",
@@ -2052,6 +2053,7 @@
   "you_have_added_x_of_group_size_y": "You have added <0>__addedUsersSize__</0> of <1>__groupSize__</1> available members",
   "you_have_been_invited_to_transfer_management_of_your_account": "You have been invited to transfer management of your account.",
   "you_have_been_invited_to_transfer_management_of_your_account_to": "You have been invited to transfer management of your account to __groupName__.",
+  "you_have_been_removed_from_this_project_and_will_be_redirected_to_project_dashboard": "You have been removed from this project, and will no longer have access to it. You will be redirected to your project dashboard momentarily.",
   "you_introed_high_number": " You’ve introduced <0>__numberOfPeople__</0> people to __appName__. Good job!",
   "you_introed_small_number": " You’ve introduced <0>__numberOfPeople__</0> person to __appName__. Good job, but can you get some more?",
   "you_may_be_able_to_prevent_a_compile_timeout": "You may be able to prevent a compile timeout using the following tips.",