[ReactNavToolbar] Project name + pdf and share project buttons (#3709)

* Added project name, pdf toggle and share project buttons to navigation toolbar

* Added PropTypes check to `useChatContext()`

* React context updates for project name/rename, pdf view and share moda

* Hide PDF button when pdfLayout != 'flat'

GitOrigin-RevId: 3f4a1b072259df7148d3417cd22116702bdd79ac
This commit is contained in:
Miguel Serrano 2021-03-10 13:19:54 +01:00 committed by Copybot
parent c3a6ac320b
commit a555f0d309
16 changed files with 328 additions and 47 deletions

View file

@ -1,6 +1,7 @@
div(ng-controller="EditorNavigationToolbarController")
editor-navigation-toolbar-root(
open-doc="openDoc"
online-users-array="onlineUsersArray"
)
div(ng-controller="ShareController")
div(ng-controller="EditorNavigationToolbarController")
editor-navigation-toolbar-root(
open-doc="openDoc"
online-users-array="onlineUsersArray"
open-share-project-modal="openShareProjectModal"
)

View file

@ -109,6 +109,7 @@
"run_syntax_check_now": "",
"send_first_message": "",
"server_error": "",
"share": "",
"show_outline": "",
"something_went_wrong_rendering_pdf": "",
"somthing_went_wrong_compiling": "",

View file

@ -113,6 +113,8 @@ ChatProvider.propTypes = {
children: PropTypes.any
}
export function useChatContext() {
return useContext(ChatContext)
export function useChatContext(propTypes) {
const data = useContext(ChatContext)
PropTypes.checkPropTypes(propTypes, data, 'data', 'ChatContext.Provider')
return data
}

View file

@ -8,7 +8,10 @@ import { useLayoutContext } from '../../../shared/context/layout-context'
const editorContextPropTypes = {
cobranding: PropTypes.object,
loading: PropTypes.bool,
isRestrictedTokenMember: PropTypes.bool
isRestrictedTokenMember: PropTypes.bool,
projectName: PropTypes.string.isRequired,
renameProject: PropTypes.func.isRequired,
isProjectOwner: PropTypes.bool
}
const layoutContextPropTypes = {
@ -18,13 +21,28 @@ const layoutContextPropTypes = {
setReviewPanelOpen: PropTypes.func.isRequired,
view: PropTypes.string,
setView: PropTypes.func.isRequired,
setLeftMenuShown: PropTypes.func.isRequired
setLeftMenuShown: PropTypes.func.isRequired,
pdfLayout: PropTypes.string.isRequired
}
function EditorNavigationToolbarRoot({ onlineUsersArray, openDoc }) {
const { cobranding, loading, isRestrictedTokenMember } = useEditorContext(
editorContextPropTypes
)
const chatContextPropTypes = {
resetUnreadMessageCount: PropTypes.func.isRequired,
unreadMessageCount: PropTypes.number.isRequired
}
function EditorNavigationToolbarRoot({
onlineUsersArray,
openDoc,
openShareProjectModal
}) {
const {
cobranding,
loading,
isRestrictedTokenMember,
projectName,
renameProject,
isProjectOwner
} = useEditorContext(editorContextPropTypes)
const {
chatIsOpen,
@ -33,10 +51,13 @@ function EditorNavigationToolbarRoot({ onlineUsersArray, openDoc }) {
setReviewPanelOpen,
view,
setView,
setLeftMenuShown
setLeftMenuShown,
pdfLayout
} = useLayoutContext(layoutContextPropTypes)
const { resetUnreadMessageCount, unreadMessageCount } = useChatContext()
const { resetUnreadMessageCount, unreadMessageCount } = useChatContext(
chatContextPropTypes
)
const toggleChatOpen = useCallback(() => {
if (!chatIsOpen) {
@ -54,6 +75,14 @@ function EditorNavigationToolbarRoot({ onlineUsersArray, openDoc }) {
setView(view === 'history' ? 'editor' : 'history')
}, [view, setView])
const togglePdfView = useCallback(() => {
setView(view === 'pdf' ? 'editor' : 'pdf')
}, [view, setView])
const openShareModal = useCallback(() => {
openShareProjectModal(isProjectOwner)
}, [openShareProjectModal, isProjectOwner])
const onShowLeftMenuClick = useCallback(
() => setLeftMenuShown(value => !value),
[setLeftMenuShown]
@ -82,13 +111,20 @@ function EditorNavigationToolbarRoot({ onlineUsersArray, openDoc }) {
onlineUsers={onlineUsersArray}
goToUser={goToUser}
isRestrictedTokenMember={isRestrictedTokenMember}
projectName={projectName}
renameProject={renameProject}
openShareModal={openShareModal}
pdfViewIsOpen={view === 'pdf'}
pdfButtonIsVisible={pdfLayout === 'flat'}
togglePdfView={togglePdfView}
/>
)
}
EditorNavigationToolbarRoot.propTypes = {
onlineUsersArray: PropTypes.array.isRequired,
openDoc: PropTypes.func.isRequired
openDoc: PropTypes.func.isRequired,
openShareProjectModal: PropTypes.func.isRequired
}
export default EditorNavigationToolbarRoot

View file

@ -0,0 +1,36 @@
import React from 'react'
import PropTypes from 'prop-types'
import { OverlayTrigger, Tooltip } from 'react-bootstrap'
import classNames from 'classnames'
import Icon from '../../../shared/components/icon'
function PdfToggleButton({ onClick, pdfViewIsOpen }) {
const classes = classNames(
'btn',
'btn-full-height',
'btn-full-height-no-border',
{
active: pdfViewIsOpen
}
)
return (
<OverlayTrigger
placement="bottom"
trigger={['hover', 'focus']}
overlay={<Tooltip id="tooltip-online-user">PDF</Tooltip>}
>
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid,jsx-a11y/click-events-have-key-events,jsx-a11y/interactive-supports-focus */}
<a role="button" className={classes} onClick={onClick}>
<Icon type="file-pdf-o" modifier="fw" />
</a>
</OverlayTrigger>
)
}
PdfToggleButton.propTypes = {
onClick: PropTypes.func.isRequired,
pdfViewIsOpen: PropTypes.bool
}
export default PdfToggleButton

View file

@ -0,0 +1,82 @@
import React, { useEffect, useState, useRef } from 'react'
import PropTypes from 'prop-types'
import classNames from 'classnames'
import Icon from '../../../shared/components/icon'
function ProjectNameEditableLabel({
projectName,
userIsAdmin,
onChange,
className
}) {
const [isRenaming, setIsRenaming] = useState(false)
const canRename = userIsAdmin && !isRenaming
const [inputContent, setInputContent] = useState(projectName)
const inputRef = useRef(null)
useEffect(() => {
if (isRenaming) {
inputRef.current.select()
}
}, [isRenaming])
function startRenaming() {
setInputContent(projectName)
setIsRenaming(true)
}
function handleKeyDown(event) {
if (event.key === 'Enter') {
event.preventDefault()
setIsRenaming(false)
onChange(event.target.value)
}
}
function handleOnChange(event) {
setInputContent(event.target.value)
}
function handleBlur() {
setIsRenaming(false)
}
return (
<div className={classNames('project-name', className)}>
{!isRenaming && (
<span className="name" onDoubleClick={startRenaming}>
{projectName}
</span>
)}
{isRenaming && (
<input
ref={inputRef}
type="text"
className="form-control"
onKeyDown={handleKeyDown}
onChange={handleOnChange}
onBlur={handleBlur}
value={inputContent}
/>
)}
{canRename && (
// eslint-disable-next-line jsx-a11y/anchor-is-valid, jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus
<a className="rename" role="button" onClick={startRenaming}>
<Icon type="pencil" modifier="fw" />
</a>
)}
</div>
)
}
ProjectNameEditableLabel.propTypes = {
projectName: PropTypes.string.isRequired,
userIsAdmin: PropTypes.bool,
onChange: PropTypes.func.isRequired,
className: PropTypes.string
}
export default ProjectNameEditableLabel

View file

@ -0,0 +1,22 @@
import React from 'react'
import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next'
import Icon from '../../../shared/components/icon'
function ShareProjectButton({ onClick }) {
const { t } = useTranslation()
return (
// eslint-disable-next-line jsx-a11y/anchor-is-valid,jsx-a11y/click-events-have-key-events,jsx-a11y/interactive-supports-focus
<a role="button" className="btn btn-full-height" onClick={onClick}>
<Icon type="fw" modifier="group" />
<p className="toolbar-label">{t('share')}</p>
</a>
)
}
ShareProjectButton.propTypes = {
onClick: PropTypes.func.isRequired
}
export default ShareProjectButton

View file

@ -5,8 +5,11 @@ import CobrandingLogo from './cobranding-logo'
import BackToProjectsButton from './back-to-projects-button'
import ChatToggleButton from './chat-toggle-button'
import OnlineUsersWidget from './online-users-widget'
import ProjectNameEditableLabel from './project-name-editable-label'
import TrackChangesToggleButton from './track-changes-toggle-button'
import HistoryToggleButton from './history-toggle-button'
import ShareProjectButton from './share-project-button'
import PdfToggleButton from './pdf-toggle-button'
function ToolbarHeader({
cobranding,
@ -20,7 +23,13 @@ function ToolbarHeader({
unreadMessageCount,
onlineUsers,
goToUser,
isRestrictedTokenMember
isRestrictedTokenMember,
projectName,
renameProject,
openShareModal,
pdfViewIsOpen,
pdfButtonIsVisible,
togglePdfView
}) {
return (
<header className="toolbar toolbar-header toolbar-with-labels">
@ -29,8 +38,22 @@ function ToolbarHeader({
{cobranding ? <CobrandingLogo {...cobranding} /> : null}
<BackToProjectsButton />
</div>
{pdfButtonIsVisible && (
<PdfToggleButton
onClick={togglePdfView}
pdfViewIsOpen={pdfViewIsOpen}
/>
)}
<ProjectNameEditableLabel
className="toolbar-center"
projectName={projectName}
userIsAdmin
onChange={renameProject}
/>
<div className="toolbar-right">
<OnlineUsersWidget onlineUsers={onlineUsers} goToUser={goToUser} />
<ShareProjectButton onClick={openShareModal} />
{!isRestrictedTokenMember && (
<>
<TrackChangesToggleButton
@ -66,7 +89,13 @@ ToolbarHeader.propTypes = {
unreadMessageCount: PropTypes.number.isRequired,
onlineUsers: PropTypes.array.isRequired,
goToUser: PropTypes.func.isRequired,
isRestrictedTokenMember: PropTypes.bool
isRestrictedTokenMember: PropTypes.bool,
projectName: PropTypes.string.isRequired,
renameProject: PropTypes.func.isRequired,
openShareModal: PropTypes.func.isRequired,
pdfViewIsOpen: PropTypes.bool,
pdfButtonIsVisible: PropTypes.bool,
togglePdfView: PropTypes.func.isRequired
}
export default ToolbarHeader

View file

@ -16,6 +16,11 @@ App.component(
// `$scope.onlineUsersArray` is already populated by `OnlineUsersManager`, which also creates
// a new array instance every time the list of online users change (which should refresh the
// value passed to React as a prop, triggering a re-render)
'onlineUsersArray'
'onlineUsersArray',
// We're still including ShareController as part fo the React navigation toolbar. The reason is
// the coupling between ShareController's $scope and Angular's ShareProjectModal. Once ShareProjectModal
// is fully ported to React we should be able to repli
'openShareProjectModal'
])
)

View file

@ -4,7 +4,7 @@ let titleIsFlashing = false
let originalTitle
let flashIntervalHandle
export function flashTitle(message) {
function flashTitle(message) {
if (document.hasFocus() || titleIsFlashing) {
return
}
@ -23,7 +23,7 @@ export function flashTitle(message) {
flashIntervalHandle = setInterval(swapTitle, 800)
}
export function stopFlashingTitle() {
function stopFlashingTitle() {
if (!titleIsFlashing) {
return
}
@ -34,6 +34,14 @@ export function stopFlashingTitle() {
titleIsFlashing = false
}
function setTitle(title) {
if (titleIsFlashing) {
originalTitle = title
} else {
window.document.title = title
}
}
function useBrowserWindow() {
const [hasFocus, setHasFocus] = useState(document.hasFocus())
@ -54,7 +62,7 @@ function useBrowserWindow() {
}
}, [])
return { hasFocus, flashTitle, stopFlashingTitle }
return { hasFocus, flashTitle, stopFlashingTitle, setTitle }
}
export default useBrowserWindow

View file

@ -4,5 +4,5 @@ import { rootContext } from '../root-context'
App.component(
'sharedContextReact',
react2angular(rootContext.component, [], ['ide'])
react2angular(rootContext.component, [], ['ide', 'settings'])
)

View file

@ -1,6 +1,8 @@
import React, { createContext, useContext } from 'react'
import React, { createContext, useCallback, useContext } from 'react'
import PropTypes from 'prop-types'
import useScopeValue from './util/scope-value-hook'
import { useApplicationContext } from './application-context'
import useBrowserWindow from '../../infrastructure/browser-window-hook'
export const EditorContext = createContext()
@ -13,12 +15,21 @@ EditorContext.Provider.propTypes = {
}),
loading: PropTypes.bool,
projectId: PropTypes.string.isRequired,
projectName: PropTypes.string.isRequired,
renameProject: PropTypes.func.isRequired,
isProjectOwner: PropTypes.bool,
isRestrictedTokenMember: PropTypes.bool
})
}
export function EditorProvider({ children, $scope }) {
export function EditorProvider({ children, ide, settings }) {
const {
exposedSettings: { appName }
} = useApplicationContext({
exposedSettings: PropTypes.shape({ appName: PropTypes.string.isRequired })
.isRequired
})
const cobranding = window.brandVariation
? {
logoImgUrl: window.brandVariation.logo_url,
@ -28,30 +39,68 @@ export function EditorProvider({ children, $scope }) {
: undefined
const ownerId =
window._ide.$scope.project && window._ide.$scope.project.owner
? window._ide.$scope.project.owner._id
ide.$scope.project && ide.$scope.project.owner
? ide.$scope.project.owner._id
: null
const [loading] = useScopeValue('state.loading', $scope)
const [loading] = useScopeValue('state.loading', ide.$scope)
const [projectName, setProjectName] = useScopeValue(
'project.name',
ide.$scope
)
const renameProject = useCallback(
newName => {
setProjectName(oldName => {
if (oldName !== newName) {
settings.saveProjectSettings({ name: newName }).catch(response => {
setProjectName(oldName)
const { data, status } = response
if (status === 400) {
return ide.showGenericMessageModal('Error renaming project', data)
} else {
return ide.showGenericMessageModal(
'Error renaming project',
'Please try again in a moment'
)
}
})
}
return newName
})
},
[settings, ide, setProjectName]
)
const { setTitle } = useBrowserWindow()
setTitle(
`${projectName ? projectName + ' - ' : ''}Online LaTeX Editor ${appName}`
)
const editorContextValue = {
cobranding,
loading,
projectId: window.project_id,
projectName: projectName || '', // initially might be empty in Angular
renameProject,
isProjectOwner: ownerId === window.user.id,
isRestrictedTokenMember: window.isRestrictedTokenMember
}
return (
<EditorContext.Provider value={editorContextValue}>
{children}
</EditorContext.Provider>
<>
<EditorContext.Provider value={editorContextValue}>
{children}
</EditorContext.Provider>
</>
)
}
EditorProvider.propTypes = {
children: PropTypes.any,
$scope: PropTypes.any.isRequired
ide: PropTypes.any.isRequired,
settings: PropTypes.any.isRequired
}
export function useEditorContext(propTypes) {

View file

@ -13,7 +13,8 @@ LayoutContext.Provider.propTypes = {
reviewPanelOpen: PropTypes.bool,
setReviewPanelOpen: PropTypes.func.isRequired,
leftMenuShown: PropTypes.bool,
setLeftMenuShown: PropTypes.func.isRequired
setLeftMenuShown: PropTypes.func.isRequired,
pdfLayout: PropTypes.oneOf(['sideBySide', 'flat', 'split']).isRequired
}).isRequired
}
@ -29,6 +30,8 @@ export function LayoutProvider({ children, $scope }) {
$scope
)
const [pdfLayout] = useScopeValue('ui.pdfLayout', $scope)
const layoutContextValue = {
view,
setView,
@ -37,7 +40,8 @@ export function LayoutProvider({ children, $scope }) {
reviewPanelOpen,
setReviewPanelOpen,
leftMenuShown,
setLeftMenuShown
setLeftMenuShown,
pdfLayout
}
return (

View file

@ -6,10 +6,10 @@ import createSharedContext from 'react2angular-shared-context'
import { ChatProvider } from '../../features/chat/context/chat-context'
import { LayoutProvider } from './layout-context'
export function ContextRoot({ children, ide }) {
export function ContextRoot({ children, ide, settings }) {
return (
<ApplicationProvider>
<EditorProvider $scope={ide.$scope}>
<EditorProvider ide={ide} settings={settings}>
<LayoutProvider $scope={ide.$scope}>
<ChatProvider>{children}</ChatProvider>
</LayoutProvider>
@ -20,7 +20,8 @@ export function ContextRoot({ children, ide }) {
ContextRoot.propTypes = {
children: PropTypes.any,
ide: PropTypes.any.isRequired
ide: PropTypes.any.isRequired,
settings: PropTypes.any.isRequired
}
export const rootContext = createSharedContext(ContextRoot)

View file

@ -29,11 +29,18 @@ export default {
title: 'EditorNavigationToolbar',
component: ToolbarHeader,
argTypes: {
goToUser: { action: 'goToUser' }
goToUser: { action: 'goToUser' },
renameProject: { action: 'renameProject' },
toggleHistoryOpen: { action: 'toggleHistoryOpen' },
toggleReviewPanelOpen: { action: 'toggleReviewPanelOpen' },
toggleChatOpen: { action: 'toggleChatOpen' },
togglePdfView: { action: 'togglePdfView' },
openShareModal: { action: 'openShareModal' },
onShowLeftMenuClick: { action: 'onShowLeftMenuClick' }
},
args: {
projectName: 'Overleaf Project',
onlineUsers: [{ user_id: 'abc', name: 'overleaf' }],
goToUser: () => {},
onShowLeftMenuClick: () => {}
unreadMessageCount: 0
}
}

View file

@ -11,6 +11,7 @@ export function renderWithEditorContext(
{ user = { id: '123abd' }, projectId = 'project123' } = {}
) {
window.user = user || window.user
window.ExposedSettings.appName = 'test'
window.project_id = projectId != null ? projectId : window.project_id
window._ide = {
$scope: {
@ -20,7 +21,8 @@ export function renderWithEditorContext(
}
},
ui: {
chatOpen: true
chatOpen: true,
pdfLayout: 'flat'
},
$watch: () => {}
},
@ -31,11 +33,7 @@ export function renderWithEditorContext(
}
return render(
<ApplicationProvider>
<EditorProvider
openDoc={() => {}}
onlineUsersArray={[]}
$scope={window._ide.$scope}
>
<EditorProvider ide={window._ide} settings={{}}>
<LayoutProvider $scope={window._ide.$scope}>{children}</LayoutProvider>
</EditorProvider>
</ApplicationProvider>