Merge pull request #3436 from overleaf/msm-react-shared-context

React shared context

GitOrigin-RevId: ebc6fa90dd8c65ddf803fd457c99a30f0e8e3c9c
This commit is contained in:
Miguel Serrano 2020-12-14 12:44:10 +01:00 committed by Copybot
parent c446f7712c
commit 1fcf94c3b9
18 changed files with 232 additions and 65 deletions

View file

@ -7,6 +7,8 @@ block vars
block content block content
.editor(ng-controller="IdeController").full-size .editor(ng-controller="IdeController").full-size
//- required by react2angular-shared-context, must be rendered as a top level component
shared-context-react()
.loading-screen(ng-if="state.loading") .loading-screen(ng-if="state.loading")
.loading-screen-brand-container .loading-screen-brand-container
.loading-screen-brand( .loading-screen-brand(

View file

@ -1,5 +1,6 @@
import App from '../../../base' import App from '../../../base'
import { react2angular } from 'react2angular' import { react2angular } from 'react2angular'
import { rootContext } from '../../../shared/context/root-context'
import ChatPane from '../components/chat-pane' import ChatPane from '../components/chat-pane'
@ -8,4 +9,7 @@ App.controller('ReactChatController', function($scope, ide) {
ide.$scope.$broadcast('chat:resetUnreadMessages') ide.$scope.$broadcast('chat:resetUnreadMessages')
}) })
App.component('chat', react2angular(ChatPane)) App.component(
'chat',
react2angular(rootContext.use(ChatPane), ['resetUnreadMessages'])
)

View file

@ -1,37 +1,38 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useRef } from 'react'
import { ChatStore } from './chat-store' import { ChatStore } from './chat-store'
import { useApplicationContext } from '../../../shared/context/application-context'
let chatStore import { useEditorContext } from '../../../shared/context/editor-context'
export function resetChatStore() {
chatStore = undefined
}
export function useChatStore() { export function useChatStore() {
if (!chatStore) { const { user } = useApplicationContext()
chatStore = new ChatStore() const { projectId } = useEditorContext()
const chatStoreRef = useRef(new ChatStore(user, projectId))
const [atEnd, setAtEnd] = useState(chatStoreRef.current.atEnd)
const [loading, setLoading] = useState(chatStoreRef.current.loading)
const [messages, setMessages] = useState(chatStoreRef.current.messages)
useEffect(
() => {
const chatStore = chatStoreRef.current
function handleStoreUpdated() {
setAtEnd(chatStore.atEnd)
setLoading(chatStore.loading)
setMessages(chatStore.messages)
}
chatStore.on('updated', handleStoreUpdated)
return () => chatStore.destroy()
},
[chatStoreRef]
)
return {
userId: user.id,
atEnd,
loading,
messages,
loadMoreMessages: () => chatStoreRef.current.loadMoreMessages(),
sendMessage: message => chatStoreRef.current.sendMessage(message)
} }
function getStateFromStore() {
return {
userId: window.user.id,
atEnd: chatStore.atEnd,
loading: chatStore.loading,
messages: chatStore.messages,
loadMoreMessages: () => chatStore.loadMoreMessages(),
sendMessage: message => chatStore.sendMessage(message)
}
}
const [storeState, setStoreState] = useState(getStateFromStore())
useEffect(() => {
function handleStoreUpdated() {
setStoreState(getStateFromStore())
}
chatStore.on('updated', handleStoreUpdated)
return () => chatStore.off('updated', handleStoreUpdated)
}, [])
return storeState
} }

View file

@ -5,19 +5,21 @@ import { getJSON, postJSON } from '../../../infrastructure/fetch-json'
export const MESSAGE_LIMIT = 50 export const MESSAGE_LIMIT = 50
export class ChatStore { export class ChatStore {
constructor() { constructor(user, projectId) {
this.messages = [] this.messages = []
this.loading = false this.loading = false
this.atEnd = false this.atEnd = false
this._user = user
this._projectId = projectId
this._nextBeforeTimestamp = null this._nextBeforeTimestamp = null
this._justSent = false this._justSent = false
this._emitter = new EventEmitter() this._emitter = new EventEmitter()
window._ide.socket.on('new-chat-message', message => { this._onNewChatMessage = message => {
const messageIsFromSelf = const messageIsFromSelf =
message && message.user && message.user.id === window.user.id message && message.user && message.user.id === this._user.id
if (!messageIsFromSelf || !this._justSent) { if (!messageIsFromSelf || !this._justSent) {
this.messages = appendMessage(this.messages, message) this.messages = appendMessage(this.messages, message)
this._emitter.emit('updated') this._emitter.emit('updated')
@ -27,7 +29,14 @@ export class ChatStore {
) )
} }
this._justSent = false this._justSent = false
}) }
window._ide.socket.on('new-chat-message', this._onNewChatMessage)
}
destroy() {
window._ide.socket.off('new-chat-message', this._onNewChatMessage)
this._emitter.off() // removes all listeners
} }
on(event, fn) { on(event, fn) {
@ -76,11 +85,11 @@ export class ChatStore {
} }
this._justSent = true this._justSent = true
this.messages = appendMessage(this.messages, { this.messages = appendMessage(this.messages, {
user: window.user, user: this._user,
content: message, content: message,
timestamp: Date.now() timestamp: Date.now()
}) })
const url = `/project/${window.project_id}/messages` const url = `/project/${this._projectId}/messages`
this._emitter.emit('updated') this._emitter.emit('updated')
return postJSON(url, { body }) return postJSON(url, { body })
} }

View file

@ -7,11 +7,11 @@ import OutlineRoot from './outline-root'
import Icon from '../../../shared/components/icon' import Icon from '../../../shared/components/icon'
import localStorage from '../../../infrastructure/local-storage' import localStorage from '../../../infrastructure/local-storage'
import withErrorBoundary from '../../../infrastructure/error-boundary' import withErrorBoundary from '../../../infrastructure/error-boundary'
import { useEditorContext } from '../../../shared/context/editor-context'
function OutlinePane({ function OutlinePane({
isTexFile, isTexFile,
outline, outline,
projectId,
jumpToLine, jumpToLine,
onToggle, onToggle,
eventTracking, eventTracking,
@ -19,6 +19,8 @@ function OutlinePane({
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { projectId } = useEditorContext()
const storageKey = `file_outline.expanded.${projectId}` const storageKey = `file_outline.expanded.${projectId}`
const [expanded, setExpanded] = useState(() => { const [expanded, setExpanded] = useState(() => {
const storedExpandedState = localStorage.getItem(storageKey) !== false const storedExpandedState = localStorage.getItem(storageKey) !== false
@ -77,7 +79,6 @@ function OutlinePane({
OutlinePane.propTypes = { OutlinePane.propTypes = {
isTexFile: PropTypes.bool.isRequired, isTexFile: PropTypes.bool.isRequired,
outline: PropTypes.array.isRequired, outline: PropTypes.array.isRequired,
projectId: PropTypes.string.isRequired,
jumpToLine: PropTypes.func.isRequired, jumpToLine: PropTypes.func.isRequired,
onToggle: PropTypes.func.isRequired, onToggle: PropTypes.func.isRequired,
eventTracking: PropTypes.object.isRequired, eventTracking: PropTypes.object.isRequired,

View file

@ -1,6 +1,7 @@
import App from '../../../base' import App from '../../../base'
import OutlinePane from '../components/outline-pane' import OutlinePane from '../components/outline-pane'
import { react2angular } from 'react2angular' import { react2angular } from 'react2angular'
import { rootContext } from '../../../shared/context/root-context'
App.controller('OutlineController', function($scope, ide, eventTracking) { App.controller('OutlineController', function($scope, ide, eventTracking) {
$scope.isTexFile = false $scope.isTexFile = false
@ -30,4 +31,14 @@ App.controller('OutlineController', function($scope, ide, eventTracking) {
}) })
// Wrap React component as Angular component. Only needed for "top-level" component // Wrap React component as Angular component. Only needed for "top-level" component
App.component('outlinePane', react2angular(OutlinePane)) App.component(
'outlinePane',
react2angular(rootContext.use(OutlinePane), [
'outline',
'jumpToLine',
'highlightedLine',
'eventTracking',
'onToggle',
'isTexFile'
])
)

View file

@ -61,6 +61,10 @@ import './main/account-upgrade'
import './main/exposed-settings' import './main/exposed-settings'
import './main/system-messages' import './main/system-messages'
import '../../modules/modules-ide.js' import '../../modules/modules-ide.js'
import { react2angular } from 'react2angular'
import { rootContext } from './shared/context/root-context'
App.controller('IdeController', function( App.controller('IdeController', function(
$scope, $scope,
$timeout, $timeout,
@ -349,6 +353,10 @@ If the project has been renamed please look in your project list for a new proje
}) })
}) })
// required by react2angular-shared-context, maps the shared context instance to an angular component
// that must be rendered in the app
App.component('sharedContextReact', react2angular(rootContext.component))
export default angular.bootstrap(document.body, ['SharelatexApp']) export default angular.bootstrap(document.body, ['SharelatexApp'])
function __guard__(value, transform) { function __guard__(value, transform) {

View file

@ -0,0 +1,27 @@
import React, { createContext, useContext } from 'react'
import PropTypes from 'prop-types'
export const ApplicationContext = createContext()
export function ApplicationProvider({ children }) {
return (
<ApplicationContext.Provider
value={{
user: window.user
}}
>
{children}
</ApplicationContext.Provider>
)
}
ApplicationProvider.propTypes = {
children: PropTypes.any
}
export function useApplicationContext() {
const { user } = useContext(ApplicationContext)
return {
user
}
}

View file

@ -0,0 +1,27 @@
import React, { createContext, useContext } from 'react'
import PropTypes from 'prop-types'
export const EditorContext = createContext()
export function EditorProvider({ children }) {
return (
<EditorContext.Provider
value={{
projectId: window.project_id
}}
>
{children}
</EditorContext.Provider>
)
}
EditorProvider.propTypes = {
children: PropTypes.any
}
export function useEditorContext() {
const { projectId } = useContext(EditorContext)
return {
projectId
}
}

View file

@ -0,0 +1,15 @@
import React from 'react'
import { ApplicationProvider } from './application-context'
import { EditorProvider } from './editor-context'
import createSharedContext from 'react2angular-shared-context'
// eslint-disable-next-line react/prop-types
export function ContextRoot({ children }) {
return (
<ApplicationProvider>
<EditorProvider>{children}</EditorProvider>
</ApplicationProvider>
)
}
export const rootContext = createSharedContext(ContextRoot)

View file

@ -1,6 +1,9 @@
import React from 'react' import React from 'react'
import OutlinePane from '../js/features/outline/components/outline-pane' import OutlinePane from '../js/features/outline/components/outline-pane'
import { ContextRoot } from '../js/shared/context/root-context'
window.project_id = '1234'
export const Basic = args => <OutlinePane {...args} /> export const Basic = args => <OutlinePane {...args} />
Basic.args = { Basic.args = {
@ -47,11 +50,17 @@ export default {
jumpToLine: { action: 'jumpToLine' } jumpToLine: { action: 'jumpToLine' }
}, },
args: { args: {
projectId: '1234',
eventTracking: { sendMB: () => {} }, eventTracking: { sendMB: () => {} },
isTexFile: true, isTexFile: true,
outline: [], outline: [],
jumpToLine: () => {}, jumpToLine: () => {},
onToggle: () => {} onToggle: () => {}
} },
decorators: [
Story => (
<ContextRoot>
<Story />
</ContextRoot>
)
]
} }

View file

@ -23924,7 +23924,7 @@
}, },
"mkdirp": { "mkdirp": {
"version": "0.5.1", "version": "0.5.1",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", "resolved": false,
"integrity": "sha512-SknJC52obPfGQPnjIkXbmA6+5H15E+fR+E4iR2oQ3zzCLbd7/ONua69R/Gw7AgkTLsRG+r5fzksYwWe1AgTyWA==", "integrity": "sha512-SknJC52obPfGQPnjIkXbmA6+5H15E+fR+E4iR2oQ3zzCLbd7/ONua69R/Gw7AgkTLsRG+r5fzksYwWe1AgTyWA==",
"requires": { "requires": {
"minimist": "0.0.8" "minimist": "0.0.8"
@ -30003,6 +30003,21 @@
"ngcomponent": "^4.1.0" "ngcomponent": "^4.1.0"
} }
}, },
"react2angular-shared-context": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/react2angular-shared-context/-/react2angular-shared-context-1.1.0.tgz",
"integrity": "sha512-8es9/tSf0XxbJ6f/58OPVrNUKjzY370WJXhMo5e4klxaEW0obLK2MRidIT8aY1ZZAf/x9EAGlBFJlvrxhu0H0Q==",
"requires": {
"uuid": "^7.0.3"
},
"dependencies": {
"uuid": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz",
"integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg=="
}
}
},
"reactcss": { "reactcss": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz", "resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz",

View file

@ -133,6 +133,7 @@
"react-i18next": "^11.7.1", "react-i18next": "^11.7.1",
"react-linkify": "^1.0.0-alpha", "react-linkify": "^1.0.0-alpha",
"react2angular": "^4.0.6", "react2angular": "^4.0.6",
"react2angular-shared-context": "^1.1.0",
"request": "^2.88.2", "request": "^2.88.2",
"request-promise-native": "^1.0.8", "request-promise-native": "^1.0.8",
"requestretry": "^1.13.0", "requestretry": "^1.13.0",

View file

@ -1,13 +1,10 @@
import React from 'react' import React from 'react'
import { expect } from 'chai' import { expect } from 'chai'
import { import { screen, waitForElementToBeRemoved } from '@testing-library/react'
render,
screen,
waitForElementToBeRemoved
} from '@testing-library/react'
import fetchMock from 'fetch-mock' import fetchMock from 'fetch-mock'
import ChatPane from '../../../../../frontend/js/features/chat/components/chat-pane' import ChatPane from '../../../../../frontend/js/features/chat/components/chat-pane'
import { renderWithEditorContext } from '../../../helpers/render-with-context'
import { import {
stubChatStore, stubChatStore,
stubMathJax, stubMathJax,
@ -53,31 +50,44 @@ describe('<ChatPane />', function() {
it('renders multiple messages', async function() { it('renders multiple messages', async function() {
fetchMock.get(/messages/, testMessages) fetchMock.get(/messages/, testMessages)
render(<ChatPane resetUnreadMessages={() => {}} />) // unmounting before `beforeEach` block is executed is required to prevent cleanup errors
const { unmount } = renderWithEditorContext(
<ChatPane resetUnreadMessages={() => {}} />
)
await screen.findByText('a message') await screen.findByText('a message')
await screen.findByText('another message') await screen.findByText('another message')
unmount()
}) })
it('A loading spinner is rendered while the messages are loading, then disappears', async function() { it('A loading spinner is rendered while the messages are loading, then disappears', async function() {
fetchMock.get(/messages/, []) fetchMock.get(/messages/, [])
render(<ChatPane resetUnreadMessages={() => {}} />) const { unmount } = renderWithEditorContext(
<ChatPane resetUnreadMessages={() => {}} />
)
await waitForElementToBeRemoved(() => screen.getByText('Loading…')) await waitForElementToBeRemoved(() => screen.getByText('Loading…'))
unmount()
}) })
describe('"send your first message" placeholder', function() { describe('"send your first message" placeholder', function() {
it('is rendered when there are no messages ', async function() { it('is rendered when there are no messages ', async function() {
fetchMock.get(/messages/, []) fetchMock.get(/messages/, [])
render(<ChatPane resetUnreadMessages={() => {}} />) const { unmount } = renderWithEditorContext(
<ChatPane resetUnreadMessages={() => {}} />
)
await screen.findByText('Send your first message to your collaborators') await screen.findByText('Send your first message to your collaborators')
unmount()
}) })
it('is not rendered when messages are displayed', function() { it('is not rendered when messages are displayed', function() {
fetchMock.get(/messages/, testMessages) fetchMock.get(/messages/, testMessages)
render(<ChatPane resetUnreadMessages={() => {}} />) const { unmount } = renderWithEditorContext(
<ChatPane resetUnreadMessages={() => {}} />
)
expect( expect(
screen.queryByText('Send your first message to your collaborators') screen.queryByText('Send your first message to your collaborators')
).to.not.exist ).to.not.exist
unmount()
}) })
}) })
}) })

View file

@ -1,5 +1,4 @@
import sinon from 'sinon' import sinon from 'sinon'
import { resetChatStore } from '../../../../../frontend/js/features/chat/store/chat-store-effect'
export function stubUIConfig() { export function stubUIConfig() {
window.uiConfig = { window.uiConfig = {
@ -30,7 +29,6 @@ export function tearDownMathJaxStubs() {
export function stubChatStore({ user }) { export function stubChatStore({ user }) {
window._ide = { socket: { on: sinon.stub(), off: sinon.stub() } } window._ide = { socket: { on: sinon.stub(), off: sinon.stub() } }
window.user = user window.user = user
resetChatStore()
} }
export function tearDownChatStore() { export function tearDownChatStore() {

View file

@ -13,6 +13,8 @@ describe('ChatStore', function() {
id: '123abc' id: '123abc'
} }
const testProjectId = 'project-123'
const testMessage = { const testMessage = {
content: 'hello', content: 'hello',
timestamp: new Date().getTime(), timestamp: new Date().getTime(),
@ -22,15 +24,13 @@ describe('ChatStore', function() {
beforeEach(function() { beforeEach(function() {
fetchMock.reset() fetchMock.reset()
window.user = user
window.project_id = 'project-123'
window.csrfToken = 'csrf_tok' window.csrfToken = 'csrf_tok'
socket = { on: sinon.stub() } socket = { on: sinon.stub(), off: sinon.stub() }
window._ide = { socket } window._ide = { socket }
mockSocketMessage = message => socket.on.getCall(0).args[1](message) mockSocketMessage = message => socket.on.getCall(0).args[1](message)
store = new ChatStore() store = new ChatStore(user, testProjectId)
}) })
afterEach(function() { afterEach(function() {
@ -216,4 +216,18 @@ describe('ChatStore', function() {
expect(subscriber).not.to.be.called expect(subscriber).not.to.be.called
}) })
}) })
describe('destroy', function() {
beforeEach(function() {
fetchMock.post(/messages/, 204)
})
it('removes event listeners', async function() {
const subscriber = sinon.stub()
store.on('updated', subscriber)
store.destroy()
await store.sendMessage('a message')
expect(subscriber).not.to.be.called
})
})
}) })

View file

@ -1,16 +1,20 @@
import { expect } from 'chai' import { expect } from 'chai'
import React from 'react' import React from 'react'
import sinon from 'sinon' import sinon from 'sinon'
import { screen, render, fireEvent } from '@testing-library/react' import { screen, fireEvent } from '@testing-library/react'
import OutlinePane from '../../../../../frontend/js/features/outline/components/outline-pane' import OutlinePane from '../../../../../frontend/js/features/outline/components/outline-pane'
import { renderWithEditorContext } from '../../../helpers/render-with-context'
describe('<OutlinePane />', function() { describe('<OutlinePane />', function() {
const jumpToLine = () => {} const jumpToLine = () => {}
const projectId = '123abc'
const onToggle = sinon.stub() const onToggle = sinon.stub()
const eventTracking = { sendMB: sinon.stub() } const eventTracking = { sendMB: sinon.stub() }
function render(children) {
renderWithEditorContext(children, { projectId: '123abc' })
}
before(function() { before(function() {
global.localStorage = { global.localStorage = {
getItem: sinon.stub().returns(null), getItem: sinon.stub().returns(null),
@ -41,7 +45,6 @@ describe('<OutlinePane />', function() {
<OutlinePane <OutlinePane
isTexFile isTexFile
outline={outline} outline={outline}
projectId={projectId}
jumpToLine={jumpToLine} jumpToLine={jumpToLine}
onToggle={onToggle} onToggle={onToggle}
eventTracking={eventTracking} eventTracking={eventTracking}
@ -57,7 +60,6 @@ describe('<OutlinePane />', function() {
<OutlinePane <OutlinePane
isTexFile={false} isTexFile={false}
outline={outline} outline={outline}
projectId={projectId}
jumpToLine={jumpToLine} jumpToLine={jumpToLine}
onToggle={onToggle} onToggle={onToggle}
eventTracking={eventTracking} eventTracking={eventTracking}
@ -80,7 +82,6 @@ describe('<OutlinePane />', function() {
<OutlinePane <OutlinePane
isTexFile isTexFile
outline={outline} outline={outline}
projectId={projectId}
jumpToLine={jumpToLine} jumpToLine={jumpToLine}
onToggle={onToggle} onToggle={onToggle}
eventTracking={eventTracking} eventTracking={eventTracking}

View file

@ -0,0 +1,14 @@
import React from 'react'
import { render } from '@testing-library/react'
import { ApplicationProvider } from '../../../frontend/js/shared/context/application-context'
import { EditorProvider } from '../../../frontend/js/shared/context/editor-context'
export function renderWithEditorContext(children, { user, projectId } = {}) {
window.user = user || window.user
window.project_id = projectId != null ? projectId : window.project_id
return render(
<ApplicationProvider>
<EditorProvider>{children}</EditorProvider>
</ApplicationProvider>
)
}