Merge pull request #3632 from overleaf/msm-navbar-collaborator-widget

[ReactNavToolbar] Collaborators widget

GitOrigin-RevId: 65f2484962591103f02eb7624a974d0806b1abf0
This commit is contained in:
Miguel Serrano 2021-02-10 11:57:25 +01:00 committed by Copybot
parent 77c35e3715
commit d78644e02c
10 changed files with 208 additions and 12 deletions

View file

@ -15,6 +15,8 @@ block content
editor-loading="editorLoading" editor-loading="editorLoading"
chat-is-open-angular="chatIsOpenAngular" chat-is-open-angular="chatIsOpenAngular"
set-chat-is-open-angular="setChatIsOpenAngular" set-chat-is-open-angular="setChatIsOpenAngular"
open-doc="openDoc"
online-users-array="onlineUsersArray"
) )
.loading-screen(ng-if="state.loading") .loading-screen(ng-if="state.loading")
.loading-screen-brand-container .loading-screen-brand-container

View file

@ -21,6 +21,7 @@
"compile_mode": "", "compile_mode": "",
"compile_terminated_by_user": "", "compile_terminated_by_user": "",
"compiling": "", "compiling": "",
"connected_users": "",
"conflicting_paths_found": "", "conflicting_paths_found": "",
"copy": "", "copy": "",
"copy_project": "", "copy_project": "",

View file

@ -5,7 +5,13 @@ import { useEditorContext } from '../../../shared/context/editor-context'
import { useChatContext } from '../../chat/context/chat-context' import { useChatContext } from '../../chat/context/chat-context'
function EditorNavigationToolbarRoot({ onShowLeftMenuClick }) { function EditorNavigationToolbarRoot({ onShowLeftMenuClick }) {
const { cobranding, loading, ui } = useEditorContext() const {
cobranding,
loading,
ui,
onlineUsersArray,
openDoc
} = useEditorContext()
const { resetUnreadMessageCount, unreadMessageCount } = useChatContext() const { resetUnreadMessageCount, unreadMessageCount } = useChatContext()
const toggleChatOpen = useCallback(() => { const toggleChatOpen = useCallback(() => {
@ -15,6 +21,12 @@ function EditorNavigationToolbarRoot({ onShowLeftMenuClick }) {
ui.toggleChatOpen() ui.toggleChatOpen()
}, [ui, resetUnreadMessageCount]) }, [ui, resetUnreadMessageCount])
function goToUser(user) {
if (user.doc && typeof user.row === 'number') {
openDoc(user.doc, { gotoLine: user.row + 1 })
}
}
// using {display: 'none'} as 1:1 migration from Angular's ng-hide. Using // using {display: 'none'} as 1:1 migration from Angular's ng-hide. Using
// `loading ? null : <ToolbarHeader/>` causes UI glitches // `loading ? null : <ToolbarHeader/>` causes UI glitches
return ( return (
@ -25,6 +37,8 @@ function EditorNavigationToolbarRoot({ onShowLeftMenuClick }) {
chatIsOpen={ui.chatIsOpen} chatIsOpen={ui.chatIsOpen}
unreadMessageCount={unreadMessageCount} unreadMessageCount={unreadMessageCount}
toggleChatOpen={toggleChatOpen} toggleChatOpen={toggleChatOpen}
onlineUsers={onlineUsersArray}
goToUser={goToUser}
/> />
) )
} }

View file

@ -0,0 +1,120 @@
import React from 'react'
import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next'
import { Dropdown, MenuItem, OverlayTrigger, Tooltip } from 'react-bootstrap'
import Icon from '../../../shared/components/icon'
import ColorManager from '../../../ide/colors/ColorManager'
function OnlineUsersWidget({ onlineUsers, goToUser }) {
const { t } = useTranslation()
const shouldDisplayDropdown = onlineUsers.length >= 4
if (shouldDisplayDropdown) {
return (
<Dropdown id="online-users" className="online-users" pullRight>
<DropDownToggleButton
bsRole="toggle"
onlineUserCount={onlineUsers.length}
/>
<Dropdown.Menu>
<MenuItem header>{t('connected_users')}</MenuItem>
{onlineUsers.map(user => (
<MenuItem
as="button"
key={user.user_id}
eventKey={user}
onSelect={goToUser}
>
<UserIcon user={user} onClick={goToUser} showName />
</MenuItem>
))}
</Dropdown.Menu>
</Dropdown>
)
} else {
return (
<div className="online-users">
{onlineUsers.map(user => (
<OverlayTrigger
key={user.user_id}
placement="bottom"
trigger={['hover', 'focus']}
overlay={<Tooltip id="tooltip-online-user">{user.name}</Tooltip>}
>
<span>
{/* OverlayTrigger won't fire unless UserIcon is wrapped in a span */}
<UserIcon user={user} onClick={goToUser} />
</span>
</OverlayTrigger>
))}
</div>
)
}
}
OnlineUsersWidget.propTypes = {
onlineUsers: PropTypes.arrayOf(
PropTypes.shape({
user_id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired
})
).isRequired,
goToUser: PropTypes.func.isRequired
}
function UserIcon({ user, showName, onClick }) {
const backgroundColor = `hsl(${ColorManager.getHueForUserId(
user.user_id
)}, 70%, 50%)`
function handleOnClick() {
onClick(user)
}
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<span onClick={handleOnClick}>
<span className="online-user" style={{ backgroundColor }}>
{user.name.slice(0, 1)}
</span>
{showName && user.name}
</span>
)
}
UserIcon.propTypes = {
user: PropTypes.shape({
user_id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired
}),
showName: PropTypes.bool,
onClick: PropTypes.func.isRequired
}
const DropDownToggleButton = React.forwardRef((props, ref) => {
const { t } = useTranslation()
return (
<OverlayTrigger
placement="left"
overlay={
<Tooltip id="tooltip-connected-users">{t('connected_users')}</Tooltip>
}
>
<button
className="btn online-user online-user-multi"
onClick={props.onClick} // required by Bootstrap Dropdown to trigger an opening
>
<strong>{props.onlineUserCount}</strong>
<Icon type="users" modifier="fw" />
</button>
</OverlayTrigger>
)
})
DropDownToggleButton.propTypes = {
onlineUserCount: PropTypes.number.isRequired,
onClick: PropTypes.func
}
export default OnlineUsersWidget

View file

@ -4,13 +4,16 @@ import MenuButton from './menu-button'
import CobrandingLogo from './cobranding-logo' import CobrandingLogo from './cobranding-logo'
import BackToProjectsButton from './back-to-projects-button' import BackToProjectsButton from './back-to-projects-button'
import ChatToggleButton from './chat-toggle-button' import ChatToggleButton from './chat-toggle-button'
import OnlineUsersWidget from './online-users-widget'
function ToolbarHeader({ function ToolbarHeader({
cobranding, cobranding,
onShowLeftMenuClick, onShowLeftMenuClick,
chatIsOpen, chatIsOpen,
toggleChatOpen, toggleChatOpen,
unreadMessageCount unreadMessageCount,
onlineUsers,
goToUser
}) { }) {
return ( return (
<header className="toolbar toolbar-header toolbar-with-labels"> <header className="toolbar toolbar-header toolbar-with-labels">
@ -20,6 +23,7 @@ function ToolbarHeader({
<BackToProjectsButton /> <BackToProjectsButton />
</div> </div>
<div className="toolbar-right"> <div className="toolbar-right">
<OnlineUsersWidget onlineUsers={onlineUsers} goToUser={goToUser} />
<ChatToggleButton <ChatToggleButton
chatIsOpen={chatIsOpen} chatIsOpen={chatIsOpen}
onClick={toggleChatOpen} onClick={toggleChatOpen}
@ -35,7 +39,9 @@ ToolbarHeader.propTypes = {
cobranding: PropTypes.object, cobranding: PropTypes.object,
chatIsOpen: PropTypes.bool, chatIsOpen: PropTypes.bool,
toggleChatOpen: PropTypes.func.isRequired, toggleChatOpen: PropTypes.func.isRequired,
unreadMessageCount: PropTypes.number.isRequired unreadMessageCount: PropTypes.number.isRequired,
onlineUsers: PropTypes.array.isRequired,
goToUser: PropTypes.func.isRequired
} }
export default ToolbarHeader export default ToolbarHeader

View file

@ -23,6 +23,9 @@ App.controller('ReactRootContextController', function($scope, ide) {
ide.$scope.$watch('ui.chatOpen', value => { ide.$scope.$watch('ui.chatOpen', value => {
$scope.chatIsOpenAngular = value $scope.chatIsOpenAngular = value
}) })
// wrapper is required to avoid scope problems with `this` inside `EditorManager`
$scope.openDoc = (doc, args) => ide.editorManager.openDoc(doc, args)
}) })
App.component( App.component(
@ -30,6 +33,11 @@ App.component(
react2angular(rootContext.component, [ react2angular(rootContext.component, [
'editorLoading', 'editorLoading',
'setChatIsOpenAngular', 'setChatIsOpenAngular',
'chatIsOpenAngular' 'chatIsOpenAngular',
'openDoc',
// `$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'
]) ])
) )

View file

@ -21,7 +21,9 @@ export function EditorProvider({
children, children,
loading, loading,
chatIsOpenAngular, chatIsOpenAngular,
setChatIsOpenAngular setChatIsOpenAngular,
openDoc,
onlineUsersArray
}) { }) {
const cobranding = window.brandVariation const cobranding = window.brandVariation
? { ? {
@ -63,6 +65,8 @@ export function EditorProvider({
loading, loading,
projectId: window.project_id, projectId: window.project_id,
isProjectOwner: ownerId === window.user.id, isProjectOwner: ownerId === window.user.id,
openDoc,
onlineUsersArray,
ui: { ui: {
chatIsOpen, chatIsOpen,
toggleChatOpen toggleChatOpen
@ -80,7 +84,9 @@ EditorProvider.propTypes = {
children: PropTypes.any, children: PropTypes.any,
loading: PropTypes.bool, loading: PropTypes.bool,
chatIsOpenAngular: PropTypes.bool, chatIsOpenAngular: PropTypes.bool,
setChatIsOpenAngular: PropTypes.func.isRequired setChatIsOpenAngular: PropTypes.func.isRequired,
openDoc: PropTypes.func.isRequired,
onlineUsersArray: PropTypes.array.isRequired
} }
export function useEditorContext(propTypes) { export function useEditorContext(propTypes) {

View file

@ -9,7 +9,9 @@ export function ContextRoot({
children, children,
editorLoading, editorLoading,
chatIsOpenAngular, chatIsOpenAngular,
setChatIsOpenAngular setChatIsOpenAngular,
openDoc,
onlineUsersArray
}) { }) {
return ( return (
<ApplicationProvider> <ApplicationProvider>
@ -17,6 +19,8 @@ export function ContextRoot({
loading={editorLoading} loading={editorLoading}
chatIsOpenAngular={chatIsOpenAngular} chatIsOpenAngular={chatIsOpenAngular}
setChatIsOpenAngular={setChatIsOpenAngular} setChatIsOpenAngular={setChatIsOpenAngular}
openDoc={openDoc}
onlineUsersArray={onlineUsersArray}
> >
<ChatProvider>{children}</ChatProvider> <ChatProvider>{children}</ChatProvider>
</EditorProvider> </EditorProvider>
@ -28,7 +32,9 @@ ContextRoot.propTypes = {
children: PropTypes.any, children: PropTypes.any,
editorLoading: PropTypes.bool, editorLoading: PropTypes.bool,
chatIsOpenAngular: PropTypes.bool, chatIsOpenAngular: PropTypes.bool,
setChatIsOpenAngular: PropTypes.func.isRequired setChatIsOpenAngular: PropTypes.func.isRequired,
openDoc: PropTypes.func.isRequired,
onlineUsersArray: PropTypes.array.isRequired
} }
export const rootContext = createSharedContext(ContextRoot) export const rootContext = createSharedContext(ContextRoot)

View file

@ -1,10 +1,38 @@
import React from 'react' import React from 'react'
import ToolbarHeader from '../js/features/editor-navigation-toolbar/components/toolbar-header' import ToolbarHeader from '../js/features/editor-navigation-toolbar/components/toolbar-header'
export const Default = () => { // required by ColorManager
return <ToolbarHeader /> window.user = { id: 42 }
export const UpToThreeConnectedUsers = args => {
return <ToolbarHeader {...args} />
}
UpToThreeConnectedUsers.args = {
onlineUsers: ['a', 'c', 'd'].map(c => ({
user_id: c,
name: `${c}_user name`
}))
}
export const ManyConnectedUsers = args => {
return <ToolbarHeader {...args} />
}
ManyConnectedUsers.args = {
onlineUsers: ['a', 'c', 'd', 'e', 'f'].map(c => ({
user_id: c,
name: `${c}_user name`
}))
} }
export default { export default {
title: 'EditorNavigationToolbar' title: 'EditorNavigationToolbar',
component: ToolbarHeader,
argTypes: {
goToUser: { action: 'goToUser' }
},
args: {
onlineUsers: [{ user_id: 'abc', name: 'overleaf' }],
goToUser: () => {},
onShowLeftMenuClick: () => {}
}
} }

View file

@ -26,7 +26,12 @@ export function renderWithEditorContext(
} }
return render( return render(
<ApplicationProvider> <ApplicationProvider>
<EditorProvider setChatIsOpen={() => {}} setChatIsOpenAngular={() => {}}> <EditorProvider
setChatIsOpen={() => {}}
setChatIsOpenAngular={() => {}}
openDoc={() => {}}
onlineUsersArray={[]}
>
{children} {children}
</EditorProvider> </EditorProvider>
</ApplicationProvider> </ApplicationProvider>