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

Share modal for React IDE page

GitOrigin-RevId: f72f824abdcb5a135c354e3ccc35912b2097673f
This commit is contained in:
ilkin-overleaf 2023-10-27 11:43:50 +03:00 committed by Copybot
parent 541e7e315c
commit 9b6f83dfd4
14 changed files with 277 additions and 138 deletions

View file

@ -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": "",

View file

@ -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

View file

@ -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>
)
}

View file

@ -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

View file

@ -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}

View file

@ -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

View file

@ -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: {

View file

@ -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
}

View file

@ -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>

View file

@ -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

View file

@ -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,
}

View file

@ -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

View file

@ -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', [

View file

@ -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": " Youve introduced <0>__numberOfPeople__</0> people to __appName__. Good job!",
"you_introed_small_number": " Youve 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.",