mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-29 21:23:45 -05:00
Merge pull request #3445 from overleaf/ae-wordcount-modal
Migrate word count modal to React GitOrigin-RevId: cfb5beceec7ba3a992a808b9e7417170d580b392
This commit is contained in:
parent
0135236df8
commit
669c72ed2c
10 changed files with 398 additions and 75 deletions
|
@ -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,6 +241,35 @@ 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")}
|
||||
|
|
|
@ -112,5 +112,12 @@
|
|||
"duplicate_file",
|
||||
"error",
|
||||
"invalid_file_name",
|
||||
"ok"
|
||||
"ok",
|
||||
"refresh",
|
||||
"word_count",
|
||||
"total_words",
|
||||
"headers",
|
||||
"math_inline",
|
||||
"math_display",
|
||||
"done"
|
||||
]
|
||||
|
|
|
@ -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 /> {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
|
|
@ -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
|
|
@ -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
|
||||
})
|
||||
}
|
||||
})
|
|
@ -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'
|
||||
}))
|
||||
)
|
|
@ -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'))
|
||||
})
|
|
@ -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'
|
||||
|
|
77
services/web/frontend/stories/word-count-modal.stories.js
Normal file
77
services/web/frontend/stories/word-count-modal.stories.js
Normal 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' }
|
||||
}
|
||||
}
|
|
@ -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$/)
|
||||
)
|
||||
})
|
||||
})
|
Loading…
Reference in a new issue