diff --git a/services/web/frontend/js/features/chat/components/chat-pane.js b/services/web/frontend/js/features/chat/components/chat-pane.js index a170c5fbc3..215af12117 100644 --- a/services/web/frontend/js/features/chat/components/chat-pane.js +++ b/services/web/frontend/js/features/chat/components/chat-pane.js @@ -8,7 +8,7 @@ import InfiniteScroll from './infinite-scroll' import ChatFallbackError from './chat-fallback-error' import Icon from '../../../shared/components/icon' import { useLayoutContext } from '../../../shared/context/layout-context' -import { useApplicationContext } from '../../../shared/context/application-context' +import { useUserContext } from '../../../shared/context/user-context' import withErrorBoundary from '../../../infrastructure/error-boundary' import { FetchError } from '../../../infrastructure/fetch-json' import { useChatContext } from '../context/chat-context' @@ -17,8 +17,8 @@ const ChatPane = React.memo(function ChatPane() { const { t } = useTranslation() const { chatIsOpen } = useLayoutContext({ chatIsOpen: PropTypes.bool }) - const { user } = useApplicationContext({ - user: PropTypes.shape({ id: PropTypes.string.isRequired }), + const user = useUserContext({ + id: PropTypes.string.isRequired, }) const { diff --git a/services/web/frontend/js/features/chat/context/chat-context.js b/services/web/frontend/js/features/chat/context/chat-context.js index 78f578fe3e..76d178a50a 100644 --- a/services/web/frontend/js/features/chat/context/chat-context.js +++ b/services/web/frontend/js/features/chat/context/chat-context.js @@ -9,7 +9,7 @@ import { import PropTypes from 'prop-types' import { v4 as uuid } from 'uuid' -import { useApplicationContext } from '../../../shared/context/application-context' +import { useUserContext } from '../../../shared/context/user-context' import { useProjectContext } from '../../../shared/context/project-context' import { getJSON, postJSON } from '../../../infrastructure/fetch-json' import { appendMessage, prependMessages } from '../utils/message-list-appender' @@ -117,8 +117,8 @@ ChatContext.Provider.propTypes = { } export function ChatProvider({ children }) { - const { user } = useApplicationContext({ - user: PropTypes.shape({ id: PropTypes.string.isRequired }), + const user = useUserContext({ + id: PropTypes.string.isRequired, }) const { _id: projectId } = useProjectContext({ _id: PropTypes.string.isRequired, diff --git a/services/web/frontend/js/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root.js b/services/web/frontend/js/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root.js index aa91caa5be..fb33ddbf29 100644 --- a/services/web/frontend/js/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root.js +++ b/services/web/frontend/js/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root.js @@ -4,11 +4,11 @@ import ToolbarHeader from './toolbar-header' import { useEditorContext } from '../../../shared/context/editor-context' import { useChatContext } from '../../chat/context/chat-context' import { useLayoutContext } from '../../../shared/context/layout-context' -import { useApplicationContext } from '../../../shared/context/application-context' +import { useUserContext } from '../../../shared/context/user-context' import { useProjectContext } from '../../../shared/context/project-context' -const applicationContextPropTypes = { - user: PropTypes.object, +const userContextPropTypes = { + id: PropTypes.string, } const projectContextPropTypes = { @@ -21,6 +21,7 @@ const editorContextPropTypes = { isRestrictedTokenMember: PropTypes.bool, renameProject: PropTypes.func.isRequired, isProjectOwner: PropTypes.bool, + permissionsLevel: PropTypes.string, } const layoutContextPropTypes = { @@ -45,7 +46,7 @@ const EditorNavigationToolbarRoot = React.memo( openDoc, openShareProjectModal, }) { - const { user } = useApplicationContext(applicationContextPropTypes) + const user = useUserContext(userContextPropTypes) const { name: projectName } = useProjectContext(projectContextPropTypes) @@ -55,6 +56,7 @@ const EditorNavigationToolbarRoot = React.memo( isRestrictedTokenMember, renameProject, isProjectOwner, + permissionsLevel, } = useEditorContext(editorContextPropTypes) const { @@ -110,6 +112,12 @@ const EditorNavigationToolbarRoot = React.memo( [openDoc] ) + // the existing angular implementation prevents collaborators from updating a + // project's name, but the backend allows that with the following logic: + // `const hasRenamePermissions = permissionsLevel === 'owner' || permissionsLevel === 'readAndWrite'` + // See https://github.com/overleaf/issues/issues/4492 + const hasRenamePermissions = permissionsLevel === 'owner' + // using {display: 'none'} as 1:1 migration from Angular's ng-hide. Using // `loading ? null : ` causes UI glitches return ( @@ -130,6 +138,7 @@ const EditorNavigationToolbarRoot = React.memo( isAnonymousUser={user == null} projectName={projectName} renameProject={renameProject} + hasRenamePermissions={hasRenamePermissions} openShareModal={openShareModal} pdfViewIsOpen={view === 'pdf'} pdfButtonIsVisible={pdfLayout === 'flat'} diff --git a/services/web/frontend/js/features/editor-navigation-toolbar/components/project-name-editable-label.js b/services/web/frontend/js/features/editor-navigation-toolbar/components/project-name-editable-label.js index 24f4e60859..54b457830b 100644 --- a/services/web/frontend/js/features/editor-navigation-toolbar/components/project-name-editable-label.js +++ b/services/web/frontend/js/features/editor-navigation-toolbar/components/project-name-editable-label.js @@ -7,7 +7,7 @@ import Icon from '../../../shared/components/icon' function ProjectNameEditableLabel({ projectName, - userIsAdmin, + hasRenamePermissions, onChange, className, }) { @@ -15,7 +15,7 @@ function ProjectNameEditableLabel({ const [isRenaming, setIsRenaming] = useState(false) - const canRename = userIsAdmin && !isRenaming + const canRename = hasRenamePermissions && !isRenaming const [inputContent, setInputContent] = useState(projectName) @@ -28,8 +28,10 @@ function ProjectNameEditableLabel({ }, [isRenaming]) function startRenaming() { - setInputContent(projectName) - setIsRenaming(true) + if (canRename) { + setInputContent(projectName) + setIsRenaming(true) + } } function handleKeyDown(event) { @@ -84,7 +86,7 @@ function ProjectNameEditableLabel({ ProjectNameEditableLabel.propTypes = { projectName: PropTypes.string.isRequired, - userIsAdmin: PropTypes.bool, + hasRenamePermissions: PropTypes.bool, onChange: PropTypes.func.isRequired, className: PropTypes.string, } diff --git a/services/web/frontend/js/features/editor-navigation-toolbar/components/toolbar-header.js b/services/web/frontend/js/features/editor-navigation-toolbar/components/toolbar-header.js index c152a2fd6b..fc6f45c453 100644 --- a/services/web/frontend/js/features/editor-navigation-toolbar/components/toolbar-header.js +++ b/services/web/frontend/js/features/editor-navigation-toolbar/components/toolbar-header.js @@ -31,6 +31,7 @@ const ToolbarHeader = React.memo(function ToolbarHeader({ isAnonymousUser, projectName, renameProject, + hasRenamePermissions, openShareModal, pdfViewIsOpen, pdfButtonIsVisible, @@ -56,7 +57,7 @@ const ToolbarHeader = React.memo(function ToolbarHeader({ @@ -108,6 +109,7 @@ ToolbarHeader.propTypes = { isAnonymousUser: PropTypes.bool, projectName: PropTypes.string.isRequired, renameProject: PropTypes.func.isRequired, + hasRenamePermissions: PropTypes.bool, openShareModal: PropTypes.func.isRequired, pdfViewIsOpen: PropTypes.bool, pdfButtonIsVisible: PropTypes.bool, diff --git a/services/web/frontend/js/features/file-tree/hooks/file-tree-socket-listener.js b/services/web/frontend/js/features/file-tree/hooks/file-tree-socket-listener.js index db57703300..daf335cf4c 100644 --- a/services/web/frontend/js/features/file-tree/hooks/file-tree-socket-listener.js +++ b/services/web/frontend/js/features/file-tree/hooks/file-tree-socket-listener.js @@ -1,14 +1,14 @@ import { useCallback, useEffect } from 'react' import PropTypes from 'prop-types' -import { useApplicationContext } from '../../../shared/context/application-context' +import { useUserContext } from '../../../shared/context/user-context' import { useFileTreeMutable } from '../contexts/file-tree-mutable' import { useFileTreeSelectable } from '../contexts/file-tree-selectable' import { findInTreeOrThrow } from '../util/find-in-tree' export function useFileTreeSocketListener() { - const { user } = useApplicationContext({ - user: PropTypes.shape({ id: PropTypes.string.isRequired }), + const user = useUserContext({ + id: PropTypes.string.isRequired, }) const { dispatchRename, diff --git a/services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade.js b/services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade.js index 3f6858cc5f..cd6a96808b 100644 --- a/services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade.js +++ b/services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade.js @@ -3,7 +3,7 @@ import { Trans, useTranslation } from 'react-i18next' import { Button } from 'react-bootstrap' import PropTypes from 'prop-types' -import { useApplicationContext } from '../../../shared/context/application-context' +import { useUserContext } from '../../../shared/context/user-context' import Icon from '../../../shared/components/icon' import { upgradePlan } from '../../../main/account-upgrade' @@ -11,8 +11,8 @@ import StartFreeTrialButton from '../../../shared/components/start-free-trial-bu export default function AddCollaboratorsUpgrade() { const { t } = useTranslation() - const { user } = useApplicationContext({ - user: PropTypes.shape({ allowedFreeTrial: PropTypes.boolean }), + const user = useUserContext({ + allowedFreeTrial: PropTypes.bool, }) const [startedFreeTrial, setStartedFreeTrial] = useState(false) diff --git a/services/web/frontend/js/shared/context/application-context.js b/services/web/frontend/js/shared/context/application-context.js deleted file mode 100644 index 627f8792d7..0000000000 --- a/services/web/frontend/js/shared/context/application-context.js +++ /dev/null @@ -1,50 +0,0 @@ -import { createContext, useContext, useMemo } from 'react' -import PropTypes from 'prop-types' - -export const ApplicationContext = createContext() - -ApplicationContext.Provider.propTypes = { - value: PropTypes.shape({ - user: PropTypes.shape({ - id: PropTypes.string.isRequired, - firstName: PropTypes.string, - lastName: PropTypes.string, - }), - gitBridgePublicBaseUrl: PropTypes.string.isRequired, - }), -} - -export function ApplicationProvider({ children }) { - const value = useMemo(() => { - const value = { - gitBridgePublicBaseUrl: window.gitBridgePublicBaseUrl, - } - - if (window.user.id) { - value.user = window.user - } - - return value - }, []) - - return ( - - {children} - - ) -} - -ApplicationProvider.propTypes = { - children: PropTypes.any, -} - -export function useApplicationContext(propTypes) { - const data = useContext(ApplicationContext) - PropTypes.checkPropTypes( - propTypes, - data, - 'data', - 'ApplicationContext.Provider' - ) - return data -} diff --git a/services/web/frontend/js/shared/context/editor-context.js b/services/web/frontend/js/shared/context/editor-context.js index c4870806ea..ef11a5a7d6 100644 --- a/services/web/frontend/js/shared/context/editor-context.js +++ b/services/web/frontend/js/shared/context/editor-context.js @@ -34,6 +34,7 @@ EditorContext.Provider.propTypes = { rootFolder: PropTypes.shape({ children: PropTypes.arrayOf(PropTypes.shape({ type: PropTypes.string })), }), + permissionsLevel: PropTypes.oneOf(['readOnly', 'readAndWrite', 'owner']), }), } @@ -70,6 +71,15 @@ export function EditorProvider({ children, settings }) { const [loading] = useScopeValue('state.loading') const [projectName, setProjectName] = useScopeValue('project.name') const [rootFolder] = useScopeValue('rootFolder') + const [permissionsLevel] = useScopeValue('permissionsLevel') + + useEffect(() => { + if (ide?.socket) { + ide.socket.on('projectNameUpdated', setProjectName) + return () => + ide.socket.removeListener('projectNameUpdated', setProjectName) + } + }, [ide?.socket, setProjectName]) const renameProject = useCallback( newName => { @@ -109,6 +119,7 @@ export function EditorProvider({ children, settings }) { hasPremiumCompile: features?.compileGroup === 'priority', loading, renameProject, + permissionsLevel, isProjectOwner: owner?._id === window.user.id, isRestrictedTokenMember: window.isRestrictedTokenMember, rootFolder, @@ -118,6 +129,7 @@ export function EditorProvider({ children, settings }) { features?.compileGroup, loading, renameProject, + permissionsLevel, owner?._id, rootFolder, ] diff --git a/services/web/frontend/js/shared/context/root-context.js b/services/web/frontend/js/shared/context/root-context.js index e72211b8ef..d62ebd182c 100644 --- a/services/web/frontend/js/shared/context/root-context.js +++ b/services/web/frontend/js/shared/context/root-context.js @@ -1,7 +1,7 @@ import PropTypes from 'prop-types' import createSharedContext from 'react2angular-shared-context' -import { ApplicationProvider } from './application-context' +import { UserProvider } from './user-context' import { IdeProvider } from './ide-context' import { EditorProvider } from './editor-context' import { CompileProvider } from './compile-context' @@ -11,8 +11,8 @@ import { ProjectProvider } from './project-context' export function ContextRoot({ children, ide, settings }) { return ( - - + + @@ -22,8 +22,8 @@ export function ContextRoot({ children, ide, settings }) { - - + + ) } diff --git a/services/web/frontend/js/shared/context/user-context.js b/services/web/frontend/js/shared/context/user-context.js new file mode 100644 index 0000000000..a122078216 --- /dev/null +++ b/services/web/frontend/js/shared/context/user-context.js @@ -0,0 +1,32 @@ +import { createContext, useContext } from 'react' +import PropTypes from 'prop-types' +import useScopeValue from './util/scope-value-hook' + +export const UserContext = createContext() + +UserContext.Provider.propTypes = { + value: PropTypes.shape({ + user: PropTypes.shape({ + id: PropTypes.string, + allowedFreeTrial: PropTypes.boolean, + first_name: PropTypes.string, + last_name: PropTypes.string, + }), + }), +} + +export function UserProvider({ children }) { + const [user] = useScopeValue('user', true) + + return {children} +} + +UserProvider.propTypes = { + children: PropTypes.any, +} + +export function useUserContext(propTypes) { + const data = useContext(UserContext) + PropTypes.checkPropTypes(propTypes, data, 'data', 'UserContext.Provider') + return data +} diff --git a/services/web/frontend/stories/fixtures/context.js b/services/web/frontend/stories/fixtures/context.js index abd6c2f494..162599a2c0 100644 --- a/services/web/frontend/stories/fixtures/context.js +++ b/services/web/frontend/stories/fixtures/context.js @@ -10,6 +10,7 @@ export function setupContext() { if (window._ide) { $scope = { ...window._ide.$scope, + user: window.user, project: {}, $watch: () => {}, ui: { diff --git a/services/web/frontend/stories/preview-logs-pane.stories.js b/services/web/frontend/stories/preview-logs-pane.stories.js index 767cc3edff..80551f01d6 100644 --- a/services/web/frontend/stories/preview-logs-pane.stories.js +++ b/services/web/frontend/stories/preview-logs-pane.stories.js @@ -1,6 +1,6 @@ import PreviewLogsPane from '../js/features/preview/components/preview-logs-pane' import { EditorProvider } from '../js/shared/context/editor-context' -import { ApplicationProvider } from '../js/shared/context/application-context' +import { UserProvider } from '../js/shared/context/user-context' import useFetchMock from './hooks/use-fetch-mock' import { IdeProvider } from '../js/shared/context/ide-context' @@ -24,13 +24,13 @@ export const TimedOutError = args => { } return ( - + - + ) } TimedOutError.args = { @@ -59,13 +59,13 @@ export const TimedOutErrorWithPriorityCompile = args => { } return ( - + - + ) } TimedOutErrorWithPriorityCompile.args = { diff --git a/services/web/test/frontend/features/editor-navigation-toolbar/components/project-name-editable-label.test.js b/services/web/test/frontend/features/editor-navigation-toolbar/components/project-name-editable-label.test.js index d86060302b..d172ac56e9 100644 --- a/services/web/test/frontend/features/editor-navigation-toolbar/components/project-name-editable-label.test.js +++ b/services/web/test/frontend/features/editor-navigation-toolbar/components/project-name-editable-label.test.js @@ -13,7 +13,7 @@ describe('', function () { }) describe('when the name is editable', function () { - const editableProps = { ...defaultProps, userIsAdmin: true } + const editableProps = { ...defaultProps, hasRenamePermissions: true } it('displays an editable input when the edit button is clicked', function () { render() @@ -52,11 +52,17 @@ describe('', function () { }) describe('when the name is not editable', function () { - const nonEditableProps = { userIsAdmin: false, ...defaultProps } + const nonEditableProps = { hasRenamePermissions: false, ...defaultProps } it('the edit button is not displayed', function () { render() expect(screen.queryByRole('button')).to.not.exist }) + + it('does not display an editable input when the project name is double clicked', function () { + render() + fireEvent.doubleClick(screen.getByText('test-project')) + expect(screen.queryByRole('textbox')).to.not.exist + }) }) }) diff --git a/services/web/test/frontend/helpers/render-with-context.js b/services/web/test/frontend/helpers/render-with-context.js index 1b04e78285..2e9930be7e 100644 --- a/services/web/test/frontend/helpers/render-with-context.js +++ b/services/web/test/frontend/helpers/render-with-context.js @@ -3,7 +3,7 @@ import { render } from '@testing-library/react' import sinon from 'sinon' -import { ApplicationProvider } from '../../../frontend/js/shared/context/application-context' +import { UserProvider } from '../../../frontend/js/shared/context/user-context' import { EditorProvider } from '../../../frontend/js/shared/context/editor-context' import { LayoutProvider } from '../../../frontend/js/shared/context/layout-context' import { ChatProvider } from '../../../frontend/js/features/chat/context/chat-context' @@ -28,6 +28,7 @@ export function EditorProviders({ window.isRestrictedTokenMember = isRestrictedTokenMember const $scope = { + user: window.user, project: { _id: window.project_id, name: 'project-name', @@ -52,15 +53,15 @@ export function EditorProviders({ window._ide = { $scope, socket } return ( - - + + {children} - - + + ) }