Merge pull request #3642 from overleaf/msm-history-track-changes-toggles

[ReactNavToolbar] Track changes and History toggle buttons.

GitOrigin-RevId: a67a9a488c0960dba3f3d374cde4db0080ed2952
This commit is contained in:
Miguel Serrano 2021-02-23 11:17:41 +01:00 committed by Copybot
parent e5c49ea19a
commit d55e46d3c0
16 changed files with 312 additions and 150 deletions

View file

@ -8,16 +8,7 @@ block vars
block content
.editor(ng-controller="IdeController").full-size
//- required by react2angular-shared-context, must be rendered as a top level component
div(
ng-controller="ReactRootContextController"
)
shared-context-react(
editor-loading="editorLoading"
chat-is-open-angular="chatIsOpenAngular"
set-chat-is-open-angular="setChatIsOpenAngular"
open-doc="openDoc"
online-users-array="onlineUsersArray"
)
shared-context-react()
.loading-screen(ng-if="state.loading")
.loading-screen-brand-container
.loading-screen-brand(
@ -123,7 +114,7 @@ block content
aside.chat(
ng-controller="ReactChatController"
)
chat(reset-unread-messages="resetUnreadMessages" chat-is-open="chatIsOpen")
chat()
script(type="text/ng-template", id="genericMessageModalTemplate")
.modal-header

View file

@ -1,3 +1,6 @@
div(ng-controller="EditorNavigationToolbarController")
editor-navigation-toolbar-root(on-show-left-menu-click="onShowLeftMenuClick")
editor-navigation-toolbar-root(
open-doc="openDoc"
online-users-array="onlineUsersArray"
)

View file

@ -54,6 +54,7 @@
"go_to_error_location": "",
"headers": "",
"hide_outline": "",
"history": "",
"hotkeys": "",
"ignore_validation_errors": "",
"invalid_file_name": "",

View file

@ -1,19 +1,18 @@
import React, { useEffect, useState } from 'react'
import PropTypes from 'prop-types'
import MessageList from './message-list'
import MessageInput from './message-input'
import InfiniteScroll from './infinite-scroll'
import Icon from '../../../shared/components/icon'
import { useTranslation } from 'react-i18next'
import { useEditorContext } from '../../../shared/context/editor-context'
import { useLayoutContext } from '../../../shared/context/layout-context'
import withErrorBoundary from '../../../infrastructure/error-boundary'
import { useChatContext } from '../context/chat-context'
function ChatPane() {
const { t } = useTranslation()
const {
ui: { chatIsOpen }
} = useEditorContext()
const { chatIsOpen } = useLayoutContext({ chatIsOpen: PropTypes.bool })
const {
userId,

View file

@ -10,20 +10,37 @@ import { useApplicationContext } from '../../../shared/context/application-conte
import { useEditorContext } from '../../../shared/context/editor-context'
import { ChatStore } from '../store/chat-store'
import useBrowserWindow from '../../../infrastructure/browser-window-hook'
import { useLayoutContext } from '../../../shared/context/layout-context'
export const ChatContext = createContext()
ChatContext.Provider.propTypes = {
value: PropTypes.shape({
userId: PropTypes.string.isRequired,
atEnd: PropTypes.bool,
loading: PropTypes.bool,
messages: PropTypes.array.isRequired,
unreadMessageCount: PropTypes.number.isRequired,
resetUnreadMessageCount: PropTypes.func.isRequired,
loadMoreMessages: PropTypes.func.isRequired,
sendMessage: PropTypes.func.isRequired
}).isRequired
}
export function ChatProvider({ children }) {
const {
hasFocus: windowHasFocus,
flashTitle,
stopFlashingTitle
} = useBrowserWindow()
const { user } = useApplicationContext()
const {
projectId,
ui: { chatIsOpen }
} = useEditorContext()
const { user } = useApplicationContext({
user: PropTypes.shape({ id: PropTypes.string.isRequired }.isRequired)
})
const { projectId } = useEditorContext({
projectId: PropTypes.string.isRequired
})
const { chatIsOpen } = useLayoutContext({ chatIsOpen: PropTypes.bool })
const [unreadMessageCount, setUnreadMessageCount] = useState(0)
function resetUnreadMessageCount() {

View file

@ -3,23 +3,61 @@ import PropTypes from 'prop-types'
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'
const editorContextPropTypes = {
cobranding: PropTypes.object,
loading: PropTypes.bool,
isRestrictedTokenMember: PropTypes.bool
}
const layoutContextPropTypes = {
chatIsOpen: PropTypes.bool,
setChatIsOpen: PropTypes.func.isRequired,
reviewPanelOpen: PropTypes.bool,
setReviewPanelOpen: PropTypes.func.isRequired,
view: PropTypes.string,
setView: PropTypes.func.isRequired,
setLeftMenuShown: PropTypes.func.isRequired
}
function EditorNavigationToolbarRoot({ onlineUsersArray, openDoc }) {
const { cobranding, loading, isRestrictedTokenMember } = useEditorContext(
editorContextPropTypes
)
function EditorNavigationToolbarRoot({ onShowLeftMenuClick }) {
const {
cobranding,
loading,
ui,
onlineUsersArray,
openDoc
} = useEditorContext()
chatIsOpen,
setChatIsOpen,
reviewPanelOpen,
setReviewPanelOpen,
view,
setView,
setLeftMenuShown
} = useLayoutContext(layoutContextPropTypes)
const { resetUnreadMessageCount, unreadMessageCount } = useChatContext()
const toggleChatOpen = useCallback(() => {
if (!ui.chatIsOpen) {
if (!chatIsOpen) {
resetUnreadMessageCount()
}
ui.toggleChatOpen()
}, [ui, resetUnreadMessageCount])
setChatIsOpen(value => !value)
}, [chatIsOpen, setChatIsOpen, resetUnreadMessageCount])
const toggleReviewPanelOpen = useCallback(
() => setReviewPanelOpen(value => !value),
[setReviewPanelOpen]
)
const toggleHistoryOpen = useCallback(() => {
setView(view === 'history' ? 'editor' : 'history')
}, [view, setView])
const onShowLeftMenuClick = useCallback(
() => setLeftMenuShown(value => !value),
[setLeftMenuShown]
)
function goToUser(user) {
if (user.doc && typeof user.row === 'number') {
@ -34,16 +72,23 @@ function EditorNavigationToolbarRoot({ onShowLeftMenuClick }) {
style={loading ? { display: 'none' } : {}}
cobranding={cobranding}
onShowLeftMenuClick={onShowLeftMenuClick}
chatIsOpen={ui.chatIsOpen}
chatIsOpen={chatIsOpen}
unreadMessageCount={unreadMessageCount}
toggleChatOpen={toggleChatOpen}
reviewPanelOpen={reviewPanelOpen}
toggleReviewPanelOpen={toggleReviewPanelOpen}
historyIsOpen={view === 'history'}
toggleHistoryOpen={toggleHistoryOpen}
onlineUsers={onlineUsersArray}
goToUser={goToUser}
isRestrictedTokenMember={isRestrictedTokenMember}
/>
)
}
EditorNavigationToolbarRoot.propTypes = {
onShowLeftMenuClick: PropTypes.func.isRequired
onlineUsersArray: PropTypes.array.isRequired,
openDoc: PropTypes.func.isRequired
}
export default EditorNavigationToolbarRoot

View file

@ -0,0 +1,28 @@
import React from 'react'
import PropTypes from 'prop-types'
import classNames from 'classnames'
import { useTranslation } from 'react-i18next'
import Icon from '../../../shared/components/icon'
function HistoryToggleButton({ historyIsOpen, onClick }) {
const { t } = useTranslation()
const classes = classNames('btn', 'btn-full-height', {
active: historyIsOpen
})
return (
// eslint-disable-next-line jsx-a11y/anchor-is-valid
<a role="button" className={classes} href="#" onClick={onClick}>
<Icon type="fw" modifier="history" />
<p className="toolbar-label">{t('history')}</p>
</a>
)
}
HistoryToggleButton.propTypes = {
historyIsOpen: PropTypes.bool,
onClick: PropTypes.func.isRequired
}
export default HistoryToggleButton

View file

@ -5,15 +5,22 @@ 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 TrackChangesToggleButton from './track-changes-toggle-button'
import HistoryToggleButton from './history-toggle-button'
function ToolbarHeader({
cobranding,
onShowLeftMenuClick,
chatIsOpen,
toggleChatOpen,
reviewPanelOpen,
toggleReviewPanelOpen,
historyIsOpen,
toggleHistoryOpen,
unreadMessageCount,
onlineUsers,
goToUser
goToUser,
isRestrictedTokenMember
}) {
return (
<header className="toolbar toolbar-header toolbar-with-labels">
@ -24,11 +31,24 @@ function ToolbarHeader({
</div>
<div className="toolbar-right">
<OnlineUsersWidget onlineUsers={onlineUsers} goToUser={goToUser} />
<ChatToggleButton
chatIsOpen={chatIsOpen}
onClick={toggleChatOpen}
unreadMessageCount={unreadMessageCount}
/>
{!isRestrictedTokenMember && (
<>
<TrackChangesToggleButton
onClick={toggleReviewPanelOpen}
disabled={historyIsOpen}
trackChangesIsOpen={reviewPanelOpen}
/>
<HistoryToggleButton
historyIsOpen={historyIsOpen}
onClick={toggleHistoryOpen}
/>
<ChatToggleButton
chatIsOpen={chatIsOpen}
onClick={toggleChatOpen}
unreadMessageCount={unreadMessageCount}
/>
</>
)}
</div>
</header>
)
@ -39,9 +59,14 @@ ToolbarHeader.propTypes = {
cobranding: PropTypes.object,
chatIsOpen: PropTypes.bool,
toggleChatOpen: PropTypes.func.isRequired,
reviewPanelOpen: PropTypes.bool,
toggleReviewPanelOpen: PropTypes.func.isRequired,
historyIsOpen: PropTypes.bool,
toggleHistoryOpen: PropTypes.func.isRequired,
unreadMessageCount: PropTypes.number.isRequired,
onlineUsers: PropTypes.array.isRequired,
goToUser: PropTypes.func.isRequired
goToUser: PropTypes.func.isRequired,
isRestrictedTokenMember: PropTypes.bool
}
export default ToolbarHeader

View file

@ -0,0 +1,34 @@
import React from 'react'
import PropTypes from 'prop-types'
import classNames from 'classnames'
import { useTranslation } from 'react-i18next'
function TrackChangesToggleButton({ trackChangesIsOpen, disabled, onClick }) {
const { t } = useTranslation()
const classes = classNames('btn', 'btn-full-height', {
active: trackChangesIsOpen && !disabled,
disabled: disabled
})
return (
// eslint-disable-next-line jsx-a11y/anchor-is-valid
<a
role="button"
disabled={disabled}
className={classes}
href="#"
onClick={onClick}
>
<i className="review-icon" />
<p className="toolbar-label">{t('review')}</p>
</a>
)
}
TrackChangesToggleButton.propTypes = {
trackChangesIsOpen: PropTypes.bool,
disabled: PropTypes.bool,
onClick: PropTypes.func.isRequired
}
export default TrackChangesToggleButton

View file

@ -4,15 +4,18 @@ import EditorNavigationToolbarRoot from '../components/editor-navigation-toolbar
import { rootContext } from '../../../shared/context/root-context'
App.controller('EditorNavigationToolbarController', function($scope, ide) {
$scope.onShowLeftMenuClick = () =>
ide.$scope.$applyAsync(() => {
ide.$scope.ui.leftMenuShown = !ide.$scope.ui.leftMenuShown
})
// wrapper is required to avoid scope problems with `this` inside `EditorManager`
$scope.openDoc = (doc, args) => ide.editorManager.openDoc(doc, args)
})
App.component(
'editorNavigationToolbarRoot',
react2angular(rootContext.use(EditorNavigationToolbarRoot), [
'onShowLeftMenuClick'
'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

@ -2,42 +2,7 @@ import App from '../../../base'
import { react2angular } from 'react2angular'
import { rootContext } from '../root-context'
App.controller('ReactRootContextController', function($scope, ide) {
$scope.editorLoading = !!ide.$scope.state.loading
ide.$scope.$watch('state.loading', editorLoading => {
$scope.editorLoading = editorLoading
})
$scope.setChatIsOpenAngular = value => {
ide.$scope.$applyAsync(() => {
ide.$scope.ui.chatOpen = value
})
}
// we need to pass `$scope.ui.chatOpen` to Angular while both React and Angular
// Navigation Toolbars exist in the codebase. Once the Angular version is removed,
// the React Navigation Toolbar will be the only source of truth for the open/closed state,
// but `setChatIsOpenAngular` will still need to exist since Angular is responsible of the
// global layout
$scope.chatIsOpenAngular = ide.$scope.ui.chatOpen
ide.$scope.$watch('ui.chatOpen', 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(
'sharedContextReact',
react2angular(rootContext.component, [
'editorLoading',
'setChatIsOpenAngular',
'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'
])
react2angular(rootContext.component, [], ['ide'])
)

View file

@ -1,6 +1,6 @@
import React, { createContext, useCallback, useContext, useEffect } from 'react'
import React, { createContext, useContext } from 'react'
import PropTypes from 'prop-types'
import usePersistedState from '../../infrastructure/persisted-state-hook'
import useScopeValue from './util/scope-value-hook'
export const EditorContext = createContext()
@ -13,18 +13,12 @@ EditorContext.Provider.propTypes = {
}),
loading: PropTypes.bool,
projectId: PropTypes.string.isRequired,
isProjectOwner: PropTypes.bool
isProjectOwner: PropTypes.bool,
isRestrictedTokenMember: PropTypes.bool
})
}
export function EditorProvider({
children,
loading,
chatIsOpenAngular,
setChatIsOpenAngular,
openDoc,
onlineUsersArray
}) {
export function EditorProvider({ children, $scope }) {
const cobranding = window.brandVariation
? {
logoImgUrl: window.brandVariation.logo_url,
@ -38,39 +32,14 @@ export function EditorProvider({
? window._ide.$scope.project.owner._id
: null
const [chatIsOpen, setChatIsOpen] = usePersistedState(
'editor.ui.chat.open',
false
)
const toggleChatOpen = useCallback(() => {
setChatIsOpen(!chatIsOpen)
setChatIsOpenAngular(!chatIsOpen)
}, [chatIsOpen, setChatIsOpenAngular, setChatIsOpen])
// updates React's `chatIsOpen` state when the chat is opened by Angular.
// In order to prevent race conditions with `toggleChatOpen` it's not a 1:1 binding:
// Angular forces the React state to `true`, but can only set it to `false` when
// the React state is explicitly `true`.
useEffect(() => {
if (chatIsOpenAngular) {
setChatIsOpen(true)
} else if (chatIsOpen) {
setChatIsOpen(false)
}
}, [chatIsOpenAngular, chatIsOpen, setChatIsOpen])
const [loading] = useScopeValue('state.loading', $scope)
const editorContextValue = {
cobranding,
loading,
projectId: window.project_id,
isProjectOwner: ownerId === window.user.id,
openDoc,
onlineUsersArray,
ui: {
chatIsOpen,
toggleChatOpen
}
isRestrictedTokenMember: window.isRestrictedTokenMember
}
return (
@ -82,11 +51,7 @@ export function EditorProvider({
EditorProvider.propTypes = {
children: PropTypes.any,
loading: PropTypes.bool,
chatIsOpenAngular: PropTypes.bool,
setChatIsOpenAngular: PropTypes.func.isRequired,
openDoc: PropTypes.func.isRequired,
onlineUsersArray: PropTypes.array.isRequired
$scope: PropTypes.any.isRequired
}
export function useEditorContext(propTypes) {

View file

@ -0,0 +1,59 @@
import React, { createContext, useContext } from 'react'
import PropTypes from 'prop-types'
import useScopeValue from './util/scope-value-hook'
export const LayoutContext = createContext()
LayoutContext.Provider.propTypes = {
value: PropTypes.shape({
view: PropTypes.string,
setView: PropTypes.func.isRequired,
chatIsOpen: PropTypes.bool,
setChatIsOpen: PropTypes.func.isRequired,
reviewPanelOpen: PropTypes.bool,
setReviewPanelOpen: PropTypes.func.isRequired,
leftMenuShown: PropTypes.bool,
setLeftMenuShown: PropTypes.func.isRequired
}).isRequired
}
export function LayoutProvider({ children, $scope }) {
const [view, setView] = useScopeValue('ui.view', $scope)
const [chatIsOpen, setChatIsOpen] = useScopeValue('ui.chatOpen', $scope)
const [reviewPanelOpen, setReviewPanelOpen] = useScopeValue(
'ui.reviewPanelOpen',
$scope
)
const [leftMenuShown, setLeftMenuShown] = useScopeValue(
'ui.leftMenuShown',
$scope
)
const layoutContextValue = {
view,
setView,
chatIsOpen,
setChatIsOpen,
reviewPanelOpen,
setReviewPanelOpen,
leftMenuShown,
setLeftMenuShown
}
return (
<LayoutContext.Provider value={layoutContextValue}>
{children}
</LayoutContext.Provider>
)
}
LayoutProvider.propTypes = {
children: PropTypes.any,
$scope: PropTypes.any.isRequired
}
export function useLayoutContext(propTypes) {
const data = useContext(LayoutContext)
PropTypes.checkPropTypes(propTypes, data, 'data', 'LayoutContext.Provider')
return data
}

View file

@ -4,25 +4,15 @@ import { ApplicationProvider } from './application-context'
import { EditorProvider } from './editor-context'
import createSharedContext from 'react2angular-shared-context'
import { ChatProvider } from '../../features/chat/context/chat-context'
import { LayoutProvider } from './layout-context'
export function ContextRoot({
children,
editorLoading,
chatIsOpenAngular,
setChatIsOpenAngular,
openDoc,
onlineUsersArray
}) {
export function ContextRoot({ children, ide }) {
return (
<ApplicationProvider>
<EditorProvider
loading={editorLoading}
chatIsOpenAngular={chatIsOpenAngular}
setChatIsOpenAngular={setChatIsOpenAngular}
openDoc={openDoc}
onlineUsersArray={onlineUsersArray}
>
<ChatProvider>{children}</ChatProvider>
<EditorProvider $scope={ide.$scope}>
<LayoutProvider $scope={ide.$scope}>
<ChatProvider>{children}</ChatProvider>
</LayoutProvider>
</EditorProvider>
</ApplicationProvider>
)
@ -30,11 +20,7 @@ export function ContextRoot({
ContextRoot.propTypes = {
children: PropTypes.any,
editorLoading: PropTypes.bool,
chatIsOpenAngular: PropTypes.bool,
setChatIsOpenAngular: PropTypes.func.isRequired,
openDoc: PropTypes.func.isRequired,
onlineUsersArray: PropTypes.array.isRequired
ide: PropTypes.any.isRequired
}
export const rootContext = createSharedContext(ContextRoot)

View file

@ -0,0 +1,38 @@
import { useCallback, useEffect, useState } from 'react'
import _ from 'lodash'
/**
* Binds a property in an Angular scope making it accessible in a React
* component. The interface is compatible with React.useState(), including
* the option of passing a function to the setter.
*
* @param {string} path - dot '.' path of a property in `sourceScope`.
* @param {object} $scope - Angular $scope containing the value to bind.
* @returns {[any, function]} - Binded value and setter function tuple.
*/
export default function useScopeValue(path, $scope, deep = false) {
const [value, setValue] = useState(() => _.get($scope, path))
useEffect(() => {
return $scope.$watch(
path,
newValue => {
setValue(newValue)
},
deep
)
}, [path, $scope, deep])
const scopeSetter = useCallback(
newValue => {
setValue(val => {
const actualNewValue = _.isFunction(newValue) ? newValue(val) : newValue
$scope.$applyAsync(() => _.set($scope, path, actualNewValue))
return actualNewValue
})
},
[path, $scope]
)
return [value, scopeSetter]
}

View file

@ -4,6 +4,7 @@ import { ApplicationProvider } from '../../../frontend/js/shared/context/applica
import { EditorProvider } from '../../../frontend/js/shared/context/editor-context'
import sinon from 'sinon'
import { ChatProvider } from '../../../frontend/js/features/chat/context/chat-context'
import { LayoutProvider } from '../../../frontend/js/shared/context/layout-context'
export function renderWithEditorContext(
children,
@ -17,7 +18,11 @@ export function renderWithEditorContext(
owner: {
_id: '124abd'
}
}
},
ui: {
chatOpen: true
},
$watch: () => {}
},
socket: {
on: sinon.stub(),
@ -27,19 +32,17 @@ export function renderWithEditorContext(
return render(
<ApplicationProvider>
<EditorProvider
setChatIsOpen={() => {}}
setChatIsOpenAngular={() => {}}
openDoc={() => {}}
onlineUsersArray={[]}
$scope={window._ide.$scope}
>
{children}
<LayoutProvider $scope={window._ide.$scope}>{children}</LayoutProvider>
</EditorProvider>
</ApplicationProvider>
)
}
export function renderWithChatContext(children, { user, projectId } = {}) {
global.localStorage.setItem('editor.ui.chat.open', true)
return renderWithEditorContext(<ChatProvider>{children}</ChatProvider>, {
user,
projectId