Merge pull request #4087 from overleaf/hb-chat-error-boundary

Chat error boundary

GitOrigin-RevId: 19bc6ee243d9b30510f2164462760bad04516ec3
This commit is contained in:
Alf Eaton 2021-06-07 10:51:27 +01:00 committed by Copybot
parent c8c310e5c0
commit f5a9b80052
8 changed files with 151 additions and 8 deletions

View file

@ -32,6 +32,7 @@
"change_owner": "", "change_owner": "",
"change_project_owner": "", "change_project_owner": "",
"chat": "", "chat": "",
"chat_error": "",
"checking_dropbox_status": "", "checking_dropbox_status": "",
"checking_project_github_status": "", "checking_project_github_status": "",
"clear_cached_files": "", "clear_cached_files": "",
@ -241,6 +242,7 @@
"recent_commits_in_github": "", "recent_commits_in_github": "",
"recompile": "", "recompile": "",
"recompile_from_scratch": "", "recompile_from_scratch": "",
"reconnect": "",
"reference_error_relink_hint": "", "reference_error_relink_hint": "",
"refresh": "", "refresh": "",
"refresh_page_after_linking_dropbox": "", "refresh_page_after_linking_dropbox": "",

View file

@ -0,0 +1,29 @@
import React from 'react'
import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next'
import { Button, Alert } from 'react-bootstrap'
function ChatFallbackError({ reconnect }) {
const { t } = useTranslation()
return (
<aside className="chat">
<div className="chat-error">
<Alert bsStyle="danger">{t('chat_error')}</Alert>
{reconnect && (
<p className="text-center">
<Button bsStyle="info" type="button" onClick={reconnect}>
{t('reconnect')}
</Button>
</p>
)}
</div>
</aside>
)
}
ChatFallbackError.propTypes = {
reconnect: PropTypes.any,
}
export default ChatFallbackError

View file

@ -5,10 +5,12 @@ import { useTranslation } from 'react-i18next'
import MessageList from './message-list' import MessageList from './message-list'
import MessageInput from './message-input' import MessageInput from './message-input'
import InfiniteScroll from './infinite-scroll' import InfiniteScroll from './infinite-scroll'
import ChatFallbackError from './chat-fallback-error'
import Icon from '../../../shared/components/icon' import Icon from '../../../shared/components/icon'
import { useLayoutContext } from '../../../shared/context/layout-context' import { useLayoutContext } from '../../../shared/context/layout-context'
import { useApplicationContext } from '../../../shared/context/application-context' import { useApplicationContext } from '../../../shared/context/application-context'
import withErrorBoundary from '../../../infrastructure/error-boundary' import withErrorBoundary from '../../../infrastructure/error-boundary'
import { FetchError } from '../../../infrastructure/fetch-json'
import { useChatContext } from '../context/chat-context' import { useChatContext } from '../context/chat-context'
function ChatPane() { function ChatPane() {
@ -26,8 +28,10 @@ function ChatPane() {
atEnd, atEnd,
loadInitialMessages, loadInitialMessages,
loadMoreMessages, loadMoreMessages,
reset,
sendMessage, sendMessage,
markMessagesAsRead, markMessagesAsRead,
error,
} = useChatContext() } = useChatContext()
useEffect(() => { useEffect(() => {
@ -43,6 +47,14 @@ function ChatPane() {
0 0
) )
if (error) {
// let user try recover from fetch errors
if (error instanceof FetchError) {
return <ChatFallbackError reconnect={reset} />
}
throw error
}
return ( return (
<aside className="chat"> <aside className="chat">
<InfiniteScroll <InfiniteScroll
@ -95,4 +107,4 @@ function Placeholder() {
) )
} }
export default withErrorBoundary(ChatPane) export default withErrorBoundary(ChatPane, ChatFallbackError)

View file

@ -72,6 +72,9 @@ export function chatReducer(state, action) {
unreadMessageCount: 0, unreadMessageCount: 0,
} }
case 'CLEAR':
return { ...initialState }
case 'ERROR': case 'ERROR':
return { return {
...state, ...state,
@ -108,6 +111,8 @@ ChatContext.Provider.propTypes = {
loadMoreMessages: PropTypes.func.isRequired, loadMoreMessages: PropTypes.func.isRequired,
sendMessage: PropTypes.func.isRequired, sendMessage: PropTypes.func.isRequired,
markMessagesAsRead: PropTypes.func.isRequired, markMessagesAsRead: PropTypes.func.isRequired,
reset: PropTypes.func.isRequired,
error: PropTypes.object,
}).isRequired, }).isRequired,
} }
@ -129,7 +134,7 @@ export function ChatProvider({ children }) {
const [state, dispatch] = useReducer(chatReducer, initialState) const [state, dispatch] = useReducer(chatReducer, initialState)
const { loadInitialMessages, loadMoreMessages } = useMemo(() => { const { loadInitialMessages, loadMoreMessages, reset } = useMemo(() => {
function fetchMessages() { function fetchMessages() {
if (state.atEnd) return if (state.atEnd) return
@ -142,12 +147,19 @@ export function ChatProvider({ children }) {
const queryString = new URLSearchParams(query) const queryString = new URLSearchParams(query)
const url = `/project/${projectId}/messages?${queryString.toString()}` const url = `/project/${projectId}/messages?${queryString.toString()}`
getJSON(url).then((messages = []) => { getJSON(url)
dispatch({ .then((messages = []) => {
type: 'FETCH_MESSAGES_SUCCESS', dispatch({
messages: messages.reverse(), type: 'FETCH_MESSAGES_SUCCESS',
messages: messages.reverse(),
})
})
.catch(error => {
dispatch({
type: 'ERROR',
error: error,
})
}) })
})
} }
function loadInitialMessages() { function loadInitialMessages() {
@ -162,9 +174,15 @@ export function ChatProvider({ children }) {
fetchMessages() fetchMessages()
} }
function reset() {
dispatch({ type: 'CLEAR' })
fetchMessages()
}
return { return {
loadInitialMessages, loadInitialMessages,
loadMoreMessages, loadMoreMessages,
reset,
} }
}, [projectId, state.atEnd, state.initialMessagesLoaded, state.lastTimestamp]) }, [projectId, state.atEnd, state.initialMessagesLoaded, state.lastTimestamp])
@ -181,6 +199,11 @@ export function ChatProvider({ children }) {
const url = `/project/${projectId}/messages` const url = `/project/${projectId}/messages`
postJSON(url, { postJSON(url, {
body: { content }, body: { content },
}).catch(error => {
dispatch({
type: 'ERROR',
error: error,
})
}) })
}, },
[projectId, user] [projectId, user]
@ -249,8 +272,10 @@ export function ChatProvider({ children }) {
unreadMessageCount: state.unreadMessageCount, unreadMessageCount: state.unreadMessageCount,
loadInitialMessages, loadInitialMessages,
loadMoreMessages, loadMoreMessages,
reset,
sendMessage, sendMessage,
markMessagesAsRead, markMessagesAsRead,
error: state.error,
} }
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider> return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>

View file

@ -26,6 +26,15 @@
color: @chat-instructions-color; color: @chat-instructions-color;
} }
.chat-error {
position: absolute;
top: 0;
bottom: 0;
background-color: @chat-bg;
padding: @line-height-computed / 2;
text-align: center;
}
.messages { .messages {
position: absolute; position: absolute;
top: 0; top: 0;

View file

@ -1,4 +1,6 @@
{ {
"chat_error": "Could not load chat messages, please try again.",
"reconnect": "Try again",
"logs_pane_info_message": "We are testing a new logs pane", "logs_pane_info_message": "We are testing a new logs pane",
"logs_pane_info_message_popup": "We are testing a new logs pane. Click here to give feedback.", "logs_pane_info_message_popup": "We are testing a new logs pane. Click here to give feedback.",
"github_symlink_error": "Your Github repository contains symbolic link files, which are not currently supported by Overleaf. Please remove these and try again.", "github_symlink_error": "Your Github repository contains symbolic link files, which are not currently supported by Overleaf. Please remove these and try again.",

View file

@ -1,6 +1,10 @@
import React from 'react' import React from 'react'
import { expect } from 'chai' import { expect } from 'chai'
import { screen, waitForElementToBeRemoved } from '@testing-library/react' import {
fireEvent,
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'
@ -52,6 +56,28 @@ describe('<ChatPane />', function () {
await screen.findByText('another message') await screen.findByText('another message')
}) })
it('provides error message with reload button on FetchError', async function () {
fetchMock.get(/messages/, 500)
renderWithChatContext(<ChatPane />, { user })
// should have hit a FetchError and will prompt user to reconnect
await screen.findByText('Try again')
// bring chat back up
fetchMock.reset()
fetchMock.get(/messages/, [])
const reconnectButton = screen.getByRole('button', {
name: 'Try again',
})
expect(reconnectButton).to.exist
// should now reconnect with placeholder message
fireEvent.click(reconnectButton)
await screen.findByText('Send your first message to your collaborators')
})
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/, [])

View file

@ -208,6 +208,18 @@ describe('ChatContext', function () {
result.current.loadInitialMessages() result.current.loadInitialMessages()
expect(fetchMock.calls()).to.have.lengthOf(1) expect(fetchMock.calls()).to.have.lengthOf(1)
}) })
it('provides an error on failure', async function () {
fetchMock.reset()
fetchMock.get('express:/project/:projectId/messages', 500)
const { result, waitForNextUpdate } = renderChatContextHook({ user })
result.current.loadInitialMessages()
await waitForNextUpdate()
expect(result.current.error).to.exist
expect(result.current.status).to.equal('error')
})
}) })
describe('loadMoreMessages', function () { describe('loadMoreMessages', function () {
@ -352,6 +364,18 @@ describe('ChatContext', function () {
'socket message', 'socket message',
]) ])
}) })
it('provides an error on failures', async function () {
fetchMock.reset()
fetchMock.get('express:/project/:projectId/messages', 500)
const { result, waitForNextUpdate } = renderChatContextHook({ user })
result.current.loadMoreMessages()
await waitForNextUpdate()
expect(result.current.error).to.exist
expect(result.current.status).to.equal('error')
})
}) })
describe('sendMessage', function () { describe('sendMessage', function () {
@ -397,6 +421,20 @@ describe('ChatContext', function () {
}) })
).to.be.false ).to.be.false
}) })
it('provides an error on failure', async function () {
fetchMock.reset()
fetchMock
.get('express:/project/:projectId/messages', [])
.postOnce('express:/project/:projectId/messages', 500)
const { result, waitForNextUpdate } = renderChatContextHook({ user })
result.current.sendMessage('sent message')
await waitForNextUpdate()
expect(result.current.error).to.exist
expect(result.current.status).to.equal('error')
})
}) })
describe('unread messages', function () { describe('unread messages', function () {