diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 93aad89bdb..2d871c4830 100644 --- a/services/web/frontend/extracted-translations.json +++ b/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": "", diff --git a/services/web/frontend/js/features/ide-react/components/editor-navigation-toolbar.tsx b/services/web/frontend/js/features/ide-react/components/editor-navigation-toolbar.tsx new file mode 100644 index 0000000000..684a804b10 --- /dev/null +++ b/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 ( + <> + + + + ) +} + +export default EditorNavigationToolbar diff --git a/services/web/frontend/js/features/ide-react/components/header.tsx b/services/web/frontend/js/features/ide-react/components/header.tsx deleted file mode 100644 index 11fd158705..0000000000 --- a/services/web/frontend/js/features/ide-react/components/header.tsx +++ /dev/null @@ -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 ( -
-
- -
-
- alert('Not implemented')} - /> - - - -
-
- ) -} diff --git a/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx b/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx index 3f2643b863..f1c9f10595 100644 --- a/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx +++ b/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 = ( -
- ) - const chatContent = const mainContent = historyIsOpen ? ( } + chatContent={} mainContent={mainContent} chatIsOpen={chatIsOpen} shouldPersistLayout diff --git a/services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-header.tsx b/services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-header.tsx index 2e1190bf95..c3f8d67013 100644 --- a/services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-header.tsx +++ b/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 placeholder
- + + & + GenericMessageModalOwnProps + +function GenericMessageModal({ + title, + message, + ...modalProps +}: GenericMessageModalProps) { + const { t } = useTranslation() + + return ( + + + {title} + + + {message} + + + + + + ) +} + +export default GenericMessageModal diff --git a/services/web/frontend/js/features/ide-react/context/ide-react-context.tsx b/services/web/frontend/js/features/ide-react/context/ide-react-context.tsx index d8c263b95b..b80a696efe 100644 --- a/services/web/frontend/js/features/ide-react/context/ide-react-context.tsx +++ b/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( 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: { diff --git a/services/web/frontend/js/features/ide-react/context/modals-context.tsx b/services/web/frontend/js/features/ide-react/context/modals-context.tsx new file mode 100644 index 0000000000..71abdb82c7 --- /dev/null +++ b/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(undefined) + +export const ModalsContextProvider: FC = ({ children }) => { + const [showGenericModal, setShowGenericModal] = useState(false) + const [genericMessageModalData, setGenericMessageModalData] = + useState({ title: '', message: '' }) + + const handleHideGenericModal = useCallback(() => { + setShowGenericModal(false) + }, []) + + const showGenericMessageModal = useCallback( + ( + title: GenericMessageModalOwnProps['title'], + message: GenericMessageModalOwnProps['message'] + ) => { + setGenericMessageModalData({ title, message }) + setShowGenericModal(true) + }, + [] + ) + + const value = useMemo( + () => ({ + showGenericMessageModal, + }), + [showGenericMessageModal] + ) + + return ( + + {children} + + + ) +} + +export function useModalsContext(): ModalsContextValue { + const context = useContext(ModalsContext) + + if (!context) { + throw new Error( + 'useModalsContext is only available inside ModalsContextProvider' + ) + } + + return context +} diff --git a/services/web/frontend/js/features/ide-react/context/react-context-root.tsx b/services/web/frontend/js/features/ide-react/context/react-context-root.tsx index 4e21cffd8a..106667a5b1 100644 --- a/services/web/frontend/js/features/ide-react/context/react-context-root.tsx +++ b/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 }) => { - {children} + + {children} + diff --git a/services/web/frontend/js/features/ide-react/hooks/use-socket-listeners.ts b/services/web/frontend/js/features/ide-react/hooks/use-socket-listeners.ts new file mode 100644 index 0000000000..4b593f769e --- /dev/null +++ b/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 diff --git a/services/web/frontend/js/features/share-project-modal/components/share-project-modal-content.jsx b/services/web/frontend/js/features/share-project-modal/components/share-project-modal-content.tsx similarity index 76% rename from services/web/frontend/js/features/share-project-modal/components/share-project-modal-content.jsx rename to services/web/frontend/js/features/share-project-modal/components/share-project-modal-content.tsx index 118ddbd884..77d173b317 100644 --- a/services/web/frontend/js/features/share-project-modal/components/share-project-modal-content.jsx +++ b/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 ( @@ -72,37 +77,26 @@ export default function ShareProjectModalContent({ ) } -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) { 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, -} diff --git a/services/web/frontend/js/features/share-project-modal/components/share-project-modal.jsx b/services/web/frontend/js/features/share-project-modal/components/share-project-modal.tsx similarity index 75% rename from services/web/frontend/js/features/share-project-modal/components/share-project-modal.jsx rename to services/web/frontend/js/features/share-project-modal/components/share-project-modal.tsx index c11ed8785d..96e4d0887a 100644 --- a/services/web/frontend/js/features/share-project-modal/components/share-project-modal.jsx +++ b/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: >(request: () => T) => T + inFlight: boolean + setInFlight: React.Dispatch< + React.SetStateAction + > + error: string | undefined + setError: React.Dispatch< + React.SetStateAction + > } +const ShareProjectContext = createContext( + 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(false) + const [error, setError] = useState() 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 }) => { setError( error.data?.errorReason || error.data?.error || @@ -130,10 +141,5 @@ const ShareProjectModal = React.memo(function ShareProjectModal({ ) }) -ShareProjectModal.propTypes = { - animation: PropTypes.bool, - handleHide: PropTypes.func.isRequired, - show: PropTypes.bool.isRequired, -} export default ShareProjectModal diff --git a/services/web/frontend/js/features/share-project-modal/controllers/react-share-project-modal-controller.js b/services/web/frontend/js/features/share-project-modal/controllers/react-share-project-modal-controller.js index c546c6169d..cda460db1b 100644 --- a/services/web/frontend/js/features/share-project-modal/controllers/react-share-project-modal-controller.js +++ b/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', [ diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 6d0320c943..7f5ab1eb2c 100644 --- a/services/web/locales/en.json +++ b/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.", "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__ of <1>__groupSize__ 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__ people to __appName__. Good job!", "you_introed_small_number": " You’ve introduced <0>__numberOfPeople__ 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.",