Merge pull request #3707 from overleaf/ae-refactor-word-count-modal

Refactor "Word Count" modal

GitOrigin-RevId: 00561b5b3f8f161238321c440ecde67cd42ece1c
This commit is contained in:
Alf Eaton 2021-03-05 13:00:21 +00:00 committed by Copybot
parent 1707a2555b
commit c8f139cced
7 changed files with 207 additions and 131 deletions

View file

@ -3,12 +3,25 @@ import { Row, Col, Modal, Grid, Alert, Button } from 'react-bootstrap'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Icon from '../../../shared/components/icon' import Icon from '../../../shared/components/icon'
import AccessibleModal from '../../../shared/components/accessible-modal'
function WordCountModalContent({ data, error, handleHide, loading }) { export default function WordCountModalContent({
animation = true,
show,
data,
error,
handleHide,
loading
}) {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<> <AccessibleModal
animation={animation}
show={show}
onHide={handleHide}
id="clone-project-modal"
>
<Modal.Header closeButton> <Modal.Header closeButton>
<Modal.Title>{t('word_count')}</Modal.Title> <Modal.Title>{t('word_count')}</Modal.Title>
</Modal.Header> </Modal.Header>
@ -16,7 +29,7 @@ function WordCountModalContent({ data, error, handleHide, loading }) {
<Modal.Body> <Modal.Body>
{loading && !error && ( {loading && !error && (
<div className="loading"> <div className="loading">
<Loading /> &nbsp; {t('loading')} <Icon type="refresh" spin modifier="fw" /> &nbsp; {t('loading')}
</div> </div>
)} )}
@ -70,11 +83,13 @@ function WordCountModalContent({ data, error, handleHide, loading }) {
<Modal.Footer> <Modal.Footer>
<Button onClick={handleHide}>{t('done')}</Button> <Button onClick={handleHide}>{t('done')}</Button>
</Modal.Footer> </Modal.Footer>
</> </AccessibleModal>
) )
} }
WordCountModalContent.propTypes = { WordCountModalContent.propTypes = {
animation: PropTypes.bool,
show: PropTypes.bool.isRequired,
handleHide: PropTypes.func.isRequired, handleHide: PropTypes.func.isRequired,
loading: PropTypes.bool.isRequired, loading: PropTypes.bool.isRequired,
error: PropTypes.bool, error: PropTypes.bool,
@ -86,9 +101,3 @@ WordCountModalContent.propTypes = {
textWords: PropTypes.number textWords: PropTypes.number
}) })
} }
function Loading() {
return <Icon type="refresh" spin modifier="fw" accessibilityLabel="Loading" />
}
export default WordCountModalContent

View file

@ -1,7 +1,8 @@
import React, { useCallback, useEffect, useState } from 'react' import React, { useCallback, useEffect, useState } from 'react'
import { Modal } from 'react-bootstrap'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import AbortController from 'abort-controller'
import WordCountModalContent from './word-count-modal-content' import WordCountModalContent from './word-count-modal-content'
import { fetchWordCount } from '../utils/api'
function WordCountModal({ clsiServerId, handleHide, projectId, show }) { function WordCountModal({ clsiServerId, handleHide, projectId, show }) {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -21,25 +22,17 @@ function WordCountModal({ clsiServerId, handleHide, projectId, show }) {
const _abortController = new AbortController() const _abortController = new AbortController()
setAbortController(_abortController) setAbortController(_abortController)
let query = '' fetchWordCount(projectId, clsiServerId, {
if (clsiServerId) {
query = `?clsiserverid=${clsiServerId}`
}
fetch(`/project/${projectId}/wordcount${query}`, {
signal: _abortController.signal signal: _abortController.signal
}) })
.then(async response => { .then(data => {
if (response.ok) { setData(data.texcount)
const { texcount } = await response.json() })
setData(texcount) .catch(error => {
} else { if (error.cause?.name !== 'AbortError') {
setError(true) setError(true)
} }
}) })
.catch(() => {
setError(true)
})
.finally(() => { .finally(() => {
setLoading(false) setLoading(false)
}) })
@ -55,14 +48,13 @@ function WordCountModal({ clsiServerId, handleHide, projectId, show }) {
}, [abortController, handleHide]) }, [abortController, handleHide])
return ( return (
<Modal show={show} onHide={abortAndHide}> <WordCountModalContent
<WordCountModalContent data={data}
data={data} error={error}
error={error} show={show}
handleHide={abortAndHide} handleHide={abortAndHide}
loading={loading} loading={loading}
/> />
</Modal>
) )
} }

View file

@ -0,0 +1,10 @@
import { getJSON } from '../../../infrastructure/fetch-json'
export function fetchWordCount(projectId, clsiServerId, options) {
let query = ''
if (clsiServerId) {
query = `?clsiserverid=${clsiServerId}`
}
return getJSON(`/project/${projectId}/wordcount${query}`, options)
}

View file

@ -0,0 +1,45 @@
import React from 'react'
import WordCountModalContent from '../js/features/word-count-modal/components/word-count-modal-content'
export const Basic = args => {
const data = {
headers: 4,
mathDisplay: 40,
mathInline: 400,
textWords: 4000
}
return <WordCountModalContent {...args} data={data} />
}
export const Loading = args => {
return <WordCountModalContent {...args} loading />
}
export const LoadingError = args => {
return <WordCountModalContent {...args} error />
}
export const Messages = args => {
const messages = [
'Lorem ipsum dolor sit amet.',
'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.'
].join('\n')
return <WordCountModalContent {...args} data={{ messages }} />
}
export default {
title: 'Word Count Modal / Content',
component: WordCountModalContent,
args: {
animation: false,
show: true,
error: false,
loading: false
},
argTypes: {
handleHide: { action: 'hide' }
}
}

View file

@ -1,77 +1,76 @@
import React from 'react' import React from 'react'
import fetchMock from 'fetch-mock'
import PropTypes from 'prop-types'
import WordCountModalContent from '../js/features/word-count-modal/components/word-count-modal-content' import WordCountModal from '../js/features/word-count-modal/components/word-count-modal'
// NOTE: WordCountModalContent is wrapped in modal classes, without modal behaviours export const Interactive = ({
mockResponse = 200,
mockResponseDelay = 500,
...args
}) => {
fetchMock.restore().get(
'express:/project/:projectId/wordcount',
() => {
switch (mockResponse) {
case 400:
return { status: 400, body: 'The project id is not valid' }
export const Loading = args => ( case 200:
<div className="modal-dialog"> return {
<div className="modal-content"> texcount: {
<WordCountModalContent {...args} /> headers: 4,
</div> mathDisplay: 40,
</div> mathInline: 400,
) textWords: 4000
Loading.args = { }
loading: true, }
error: false
default:
return mockResponse
}
},
{ delay: mockResponseDelay }
)
return <WordCountModal {...args} />
} }
Interactive.propTypes = {
export const LoadingError = args => ( mockResponse: PropTypes.number,
<div className="modal-dialog"> mockResponseDelay: PropTypes.number
<div className="modal-content">
<WordCountModalContent {...args} />
</div>
</div>
)
LoadingError.args = {
loading: false,
error: true
}
export const Loaded = args => (
<div className="modal-dialog">
<div className="modal-content">
<WordCountModalContent {...args} />
</div>
</div>
)
Loaded.args = {
loading: false,
error: false,
data: {
headers: 4,
mathDisplay: 40,
mathInline: 400,
textWords: 4000
}
}
export const Messages = args => (
<div className="modal-dialog">
<div className="modal-content">
<WordCountModalContent {...args} />
</div>
</div>
)
Messages.args = {
loading: false,
error: false,
data: {
messages: [
'Lorem ipsum dolor sit amet.',
'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.'
].join('\n'),
headers: 4,
mathDisplay: 40,
mathInline: 400,
textWords: 4000
}
} }
export default { export default {
title: 'Word Count Modal', title: 'Word Count Modal',
component: WordCountModalContent, component: WordCountModal,
args: {
clsiServerId: 'server-id',
projectId: 'project-id',
show: true
},
argTypes: { argTypes: {
handleHide: { action: 'handleHide' } handleHide: { action: 'handleHide' },
mockResponse: {
name: 'Mock Response Status',
type: { name: 'number', required: false },
description: 'The status code that should be returned by the mock server',
defaultValue: 200,
control: {
type: 'radio',
options: [200, 500, 400]
}
},
mockResponseDelay: {
name: 'Mock Response Delay',
type: { name: 'number', required: false },
description: 'The delay before returning a response from the mock server',
defaultValue: 500,
control: {
type: 'range',
min: 0,
max: 2500,
step: 250
}
}
} }
} }

View file

@ -165,6 +165,7 @@
"@storybook/react": "^6.1.10", "@storybook/react": "^6.1.10",
"@testing-library/dom": "^7.29.4", "@testing-library/dom": "^7.29.4",
"@testing-library/react": "^11.2.3", "@testing-library/react": "^11.2.3",
"abort-controller": "^3.0.0",
"acorn": "^7.1.1", "acorn": "^7.1.1",
"acorn-walk": "^7.1.1", "acorn-walk": "^7.1.1",
"angular-mocks": "~1.8.0", "angular-mocks": "~1.8.0",

View file

@ -1,29 +1,43 @@
import React from 'react' import React from 'react'
import { render, screen } from '@testing-library/react' import { render, screen, cleanup } from '@testing-library/react'
import WordCountModalContent from '../../../../../frontend/js/features/word-count-modal/components/word-count-modal-content' import WordCountModal from '../../../../../frontend/js/features/word-count-modal/components/word-count-modal'
import { expect } from 'chai' import { expect } from 'chai'
import sinon from 'sinon'
import fetchMock from 'fetch-mock'
const handleHide = () => { describe('<WordCountModal />', function() {
// closed afterEach(function() {
} fetchMock.reset()
cleanup()
})
const modalProps = {
projectId: 'project-1',
clsiServerId: 'clsi-server-1',
show: true,
handleHide: sinon.stub()
}
describe('<WordCountModalContent />', function() {
it('renders the translated modal title', async function() { it('renders the translated modal title', async function() {
render(<WordCountModalContent handleHide={handleHide} loading={false} />) render(<WordCountModal {...modalProps} />)
await screen.findByText('Word Count') await screen.findByText('Word Count')
expect(screen.queryByText(/Loading/)).to.not.exist
}) })
it('renders a loading message when loading', async function() { it('renders a loading message when loading', async function() {
render(<WordCountModalContent handleHide={handleHide} loading />) fetchMock.get('express:/project/:projectId/wordcount', () => {
return { status: 200, body: { texcount: {} } }
})
await screen.findByText('Loading') render(<WordCountModal {...modalProps} />)
await screen.findByText('Loading…')
}) })
it('renders an error message and hides loading message on error', async function() { it('renders an error message and hides loading message on error', async function() {
render(<WordCountModalContent handleHide={handleHide} loading error />) fetchMock.get('express:/project/:projectId/wordcount', 500)
render(<WordCountModal {...modalProps} />)
await screen.findByText('Sorry, something went wrong') await screen.findByText('Sorry, something went wrong')
@ -31,32 +45,38 @@ describe('<WordCountModalContent />', function() {
}) })
it('displays messages', async function() { it('displays messages', async function() {
render( fetchMock.get('express:/project/:projectId/wordcount', () => {
<WordCountModalContent return {
handleHide={handleHide} status: 200,
loading={false} body: {
data={{ texcount: {
messages: 'This is a test' messages: 'This is a test'
}} }
/> }
) }
})
render(<WordCountModal {...modalProps} />)
await screen.findByText('This is a test') await screen.findByText('This is a test')
}) })
it('displays counts data', async function() { it('displays counts data', async function() {
render( fetchMock.get('express:/project/:projectId/wordcount', () => {
<WordCountModalContent return {
handleHide={handleHide} status: 200,
loading={false} body: {
data={{ texcount: {
textWords: 100, textWords: 100,
mathDisplay: 200, mathDisplay: 200,
mathInline: 300, mathInline: 300,
headers: 400 headers: 400
}} }
/> }
) }
})
render(<WordCountModal {...modalProps} />)
await screen.findByText((content, element) => await screen.findByText((content, element) =>
element.textContent.trim().match(/^Total Words\s*:\s*100$/) element.textContent.trim().match(/^Total Words\s*:\s*100$/)