mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #3707 from overleaf/ae-refactor-word-count-modal
Refactor "Word Count" modal GitOrigin-RevId: 00561b5b3f8f161238321c440ecde67cd42ece1c
This commit is contained in:
parent
1707a2555b
commit
c8f139cced
7 changed files with 207 additions and 131 deletions
|
@ -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 /> {t('loading')}…
|
<Icon type="refresh" spin modifier="fw" /> {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
|
|
||||||
|
|
|
@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
|
@ -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' }
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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$/)
|
||||||
|
|
Loading…
Reference in a new issue