mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #4087 from overleaf/hb-chat-error-boundary
Chat error boundary GitOrigin-RevId: 19bc6ee243d9b30510f2164462760bad04516ec3
This commit is contained in:
parent
c8c310e5c0
commit
f5a9b80052
8 changed files with 151 additions and 8 deletions
|
@ -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": "",
|
||||||
|
|
|
@ -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
|
|
@ -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)
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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.",
|
||||||
|
|
|
@ -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/, [])
|
||||||
|
|
||||||
|
|
|
@ -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 () {
|
||||||
|
|
Loading…
Reference in a new issue