Merge pull request #3445 from overleaf/ae-wordcount-modal

Migrate word count modal to React

GitOrigin-RevId: cfb5beceec7ba3a992a808b9e7417170d580b392
This commit is contained in:
Alf Eaton 2021-01-06 10:38:25 +00:00 committed by Copybot
parent 0135236df8
commit 669c72ed2c
10 changed files with 398 additions and 75 deletions

View file

@ -30,7 +30,7 @@ aside#left-menu.full-size(
i.fa.fa-file-pdf-o.fa-2x
br
| PDF
span(ng-show="!anonymous")
h4 #{translate("actions")}
ul.list-unstyled.nav
@ -50,14 +50,21 @@ aside#left-menu.full-size(
)
!= moduleIncludes("editorLeftMenu:actions", locals)
li(ng-controller="WordCountController")
a(href, ng-if="pdf.url" ,ng-click="openWordCountModal()")
li(ng-controller="WordCountModalController")
a(href, ng-if="pdf.url", ng-click="openWordCountModal()")
i.fa.fa-fw.fa-eye
span    #{translate("word_count")}
a.link-disabled(href, ng-if="!pdf.url" , tooltip=translate('please_compile_pdf_before_word_count'))
a.link-disabled(href, ng-if="!pdf.url", tooltip=translate('please_compile_pdf_before_word_count'))
i.fa.fa-fw.fa-eye
span.link-disabled    #{translate("word_count")}
word-count-modal(
clsi-server-id="clsiServerId"
handle-hide="handleHide"
project-id="projectId"
show="show"
)
if (moduleIncludesAvailable("editorLeftMenu:sync"))
div(ng-show="!anonymous")
h4() #{translate("sync")}
@ -234,14 +241,43 @@ aside#left-menu.full-size(
ng-cloak
)
script(type='text/ng-template', id='cloneProjectModalTemplate')
.modal-header
h3 #{translate("copy_project")}
.modal-body
.alert.alert-danger(ng-show="state.error.message") {{ state.error.message}}
.alert.alert-danger(ng-show="state.error && !state.error.message") #{translate("generic_something_went_wrong")}
form(name="cloneProjectForm", novalidate)
.form-group
label #{translate("new_name")}
input.form-control(
type="text",
placeholder="New Project Name",
required,
ng-model="inputs.projectName",
on-enter="clone()",
focus-on="open"
)
.modal-footer
button.btn.btn-default(
ng-disabled="state.inflight"
ng-click="cancel()"
) #{translate("cancel")}
button.btn.btn-primary(
ng-disabled="cloneProjectForm.$invalid || state.inflight"
ng-click="clone()"
)
span(ng-hide="state.inflight") #{translate("copy")}
span(ng-show="state.inflight") #{translate("copying")}…
script(type='text/ng-template', id='wordCountModalTemplate')
.modal-header
h3 #{translate("word_count")}
.modal-body
div(ng-if="status.loading")
.loading(ng-show="!status.error && status.loading")
i.fa.fa-refresh.fa-spin.fa-fw
span   #{translate("loading")}…
i.fa.fa-refresh.fa-spin.fa-fw
span   #{translate("loading")}…
div.pdf-disabled(
ng-if="!pdf.url"
tooltip=translate('please_compile_pdf_before_word_count')
@ -257,16 +293,16 @@ script(type='text/ng-template', id='wordCountModalTemplate')
.col-xs-4
.pull-right #{translate("total_words")} :
.col-xs-6 {{data.textWords}}
.row
.row
.col-xs-4
.pull-right #{translate("headers")} :
.col-xs-6 {{data.headers}}
.row
.col-xs-4
.row
.col-xs-4
.pull-right #{translate("math_inline")} :
.col-xs-6 {{data.mathInline}}
.row
.col-xs-4
.row
.col-xs-4
.pull-right #{translate("math_display")} :
.col-xs-6 {{data.mathDisplay}}
.modal-footer

View file

@ -112,5 +112,12 @@
"duplicate_file",
"error",
"invalid_file_name",
"ok"
"ok",
"refresh",
"word_count",
"total_words",
"headers",
"math_inline",
"math_display",
"done"
]

View file

@ -0,0 +1,94 @@
import React from 'react'
import { Row, Col, Modal, Grid, Alert, Button } from 'react-bootstrap'
import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next'
import Icon from '../../../shared/components/icon'
function WordCountModalContent({ data, error, handleHide, loading }) {
const { t } = useTranslation()
return (
<>
<Modal.Header closeButton>
<Modal.Title>{t('word_count')}</Modal.Title>
</Modal.Header>
<Modal.Body>
{loading && !error && (
<div className="loading">
<Loading /> &nbsp; {t('loading')}
</div>
)}
{error && (
<Alert bsStyle="danger">{t('generic_something_went_wrong')}</Alert>
)}
{data && (
<Grid fluid>
{data.messages && (
<Row>
<Col xs={12}>
<Alert bsStyle="danger">
<p style={{ whiteSpace: 'pre-wrap' }}>{data.messages}</p>
</Alert>
</Col>
</Row>
)}
<Row>
<Col xs={4}>
<div className="pull-right">{t('total_words')}:</div>
</Col>
<Col xs={6}>{data.textWords}</Col>
</Row>
<Row>
<Col xs={4}>
<div className="pull-right">{t('headers')}:</div>
</Col>
<Col xs={6}>{data.headers}</Col>
</Row>
<Row>
<Col xs={4}>
<div className="pull-right">{t('math_inline')}:</div>
</Col>
<Col xs={6}>{data.mathInline}</Col>
</Row>
<Row>
<Col xs={4}>
<div className="pull-right">{t('math_display')}:</div>
</Col>
<Col xs={6}>{data.mathDisplay}</Col>
</Row>
</Grid>
)}
</Modal.Body>
<Modal.Footer>
<Button onClick={handleHide}>{t('done')}</Button>
</Modal.Footer>
</>
)
}
WordCountModalContent.propTypes = {
handleHide: PropTypes.func.isRequired,
loading: PropTypes.bool.isRequired,
error: PropTypes.bool,
data: PropTypes.shape({
messages: PropTypes.string,
headers: PropTypes.number,
mathDisplay: PropTypes.number,
mathInline: PropTypes.number,
textWords: PropTypes.number
})
}
function Loading() {
return <Icon type="refresh" spin modifier="fw" accessibilityLabel="Loading" />
}
export default WordCountModalContent

View file

@ -0,0 +1,70 @@
import React, { useCallback, useEffect, useState } from 'react'
import { Modal } from 'react-bootstrap'
import PropTypes from 'prop-types'
import WordCountModalContent from './word-count-modal-content'
function WordCountModal({ handleHide, show, projectId }) {
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const [data, setData] = useState()
const [abortController, setAbortController] = useState(new AbortController())
useEffect(() => {
if (!show) {
return
}
setData(undefined)
setError(false)
setLoading(true)
const _abortController = new AbortController()
setAbortController(_abortController)
fetch(`/project/${projectId}/wordcount`, {
signal: _abortController.signal
})
.then(async response => {
if (response.ok) {
const { texcount } = await response.json()
setData(texcount)
} else {
setError(true)
}
})
.catch(() => {
setError(true)
})
.finally(() => {
setLoading(false)
})
return () => {
_abortController.abort()
}
}, [show, projectId])
const abortAndHide = useCallback(() => {
abortController.abort()
handleHide()
}, [abortController, handleHide])
return (
<Modal show={show} onHide={abortAndHide}>
<WordCountModalContent
data={data}
error={error}
handleHide={abortAndHide}
loading={loading}
/>
</Modal>
)
}
WordCountModal.propTypes = {
handleHide: PropTypes.func.isRequired,
projectId: PropTypes.string.isRequired,
show: PropTypes.bool.isRequired
}
export default WordCountModal

View file

@ -0,0 +1,27 @@
import App from '../../../base'
import { react2angular } from 'react2angular'
import WordCountModal from '../components/word-count-modal'
App.component('wordCountModal', react2angular(WordCountModal))
export default App.controller('WordCountModalController', function(
$scope,
ide
) {
$scope.show = false
$scope.projectId = ide.project_id
$scope.handleHide = () => {
$scope.$applyAsync(() => {
$scope.show = false
})
}
$scope.openWordCountModal = () => {
$scope.$applyAsync(() => {
$scope.projectId = ide.project_id
$scope.show = true
})
}
})

View file

@ -1,21 +0,0 @@
/* eslint-disable
no-return-assign,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import App from '../../../base'
export default App.controller(
'WordCountController',
($scope, $modal) =>
($scope.openWordCountModal = () =>
$modal.open({
templateUrl: 'wordCountModalTemplate',
controller: 'WordCountModalController'
}))
)

View file

@ -1,38 +0,0 @@
/* eslint-disable
max-len,
no-return-assign,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import App from '../../../base'
export default App.controller('WordCountModalController', function(
$scope,
$modalInstance,
ide,
$http
) {
$scope.status = { loading: true }
const opts = {
url: `/project/${ide.project_id}/wordcount`,
method: 'GET',
params: {
clsiserverid: ide.clsiServerId
}
}
$http(opts)
.then(function(response) {
const { data } = response
$scope.status.loading = false
return ($scope.data = data.texcount)
})
.catch(() => ($scope.status.error = true))
return ($scope.cancel = () => $modalInstance.dismiss('cancel'))
})

View file

@ -1,4 +1 @@
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
import './controllers/WordCountController'
import './controllers/WordCountModalController'
import '../../features/word-count-modal/controllers/word-count-modal-controller'

View file

@ -0,0 +1,77 @@
import React from 'react'
import WordCountModalContent from '../js/features/word-count-modal/components/word-count-modal-content'
// NOTE: WordCountModalContent is wrapped in modal classes, without modal behaviours
export const Loading = args => (
<div className="modal-dialog">
<div className="modal-content">
<WordCountModalContent {...args} />
</div>
</div>
)
Loading.args = {
loading: true,
error: false
}
export const LoadingError = args => (
<div className="modal-dialog">
<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 {
title: 'Word Count Modal',
component: WordCountModalContent,
argTypes: {
handleHide: { action: 'handleHide' }
}
}

View file

@ -0,0 +1,74 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import WordCountModalContent from '../../../../../frontend/js/features/word-count-modal/components/word-count-modal-content'
import { expect } from 'chai'
const handleHide = () => {
// closed
}
describe('<WordCountModalContent />', function() {
it('renders the translated modal title', async function() {
render(<WordCountModalContent handleHide={handleHide} loading={false} />)
await screen.findByText('Word Count')
expect(screen.queryByText(/Loading/)).to.not.exist
})
it('renders a loading message when loading', async function() {
render(<WordCountModalContent handleHide={handleHide} loading />)
await screen.findByText('Loading')
})
it('renders an error message and hides loading message on error', async function() {
render(<WordCountModalContent handleHide={handleHide} loading error />)
await screen.findByText('Sorry, something went wrong')
expect(screen.queryByText(/Loading/)).to.not.exist
})
it('displays messages', async function() {
render(
<WordCountModalContent
handleHide={handleHide}
loading={false}
data={{
messages: 'This is a test'
}}
/>
)
await screen.findByText('This is a test')
})
it('displays counts data', async function() {
render(
<WordCountModalContent
handleHide={handleHide}
loading={false}
data={{
textWords: 100,
mathDisplay: 200,
mathInline: 300,
headers: 400
}}
/>
)
await screen.findByText((content, element) =>
element.textContent.trim().match(/^Total Words\s*:\s*100$/)
)
await screen.findByText((content, element) =>
element.textContent.trim().match(/^Math Display\s*:\s*200$/)
)
await screen.findByText((content, element) =>
element.textContent.trim().match(/^Math Inline\s*:\s*300$/)
)
await screen.findByText((content, element) =>
element.textContent.trim().match(/^Headers\s*:\s*400$/)
)
})
})