diff --git a/services/web/app/views/project/editor/left-menu.pug b/services/web/app/views/project/editor/left-menu.pug
index 8abffea540..628b26b279 100644
--- a/services/web/app/views/project/editor/left-menu.pug
+++ b/services/web/app/views/project/editor/left-menu.pug
@@ -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
diff --git a/services/web/frontend/extracted-translation-keys.json b/services/web/frontend/extracted-translation-keys.json
index 6a6dff3194..07d4e21c64 100644
--- a/services/web/frontend/extracted-translation-keys.json
+++ b/services/web/frontend/extracted-translation-keys.json
@@ -112,5 +112,12 @@
"duplicate_file",
"error",
"invalid_file_name",
- "ok"
+ "ok",
+ "refresh",
+ "word_count",
+ "total_words",
+ "headers",
+ "math_inline",
+ "math_display",
+ "done"
]
diff --git a/services/web/frontend/js/features/word-count-modal/components/word-count-modal-content.js b/services/web/frontend/js/features/word-count-modal/components/word-count-modal-content.js
new file mode 100644
index 0000000000..7f7c36dd31
--- /dev/null
+++ b/services/web/frontend/js/features/word-count-modal/components/word-count-modal-content.js
@@ -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 (
+ <>
+
+ {t('word_count')}
+
+
+
+ {loading && !error && (
+
+ {t('loading')}…
+
+ )}
+
+ {error && (
+ {t('generic_something_went_wrong')}
+ )}
+
+ {data && (
+
+ {data.messages && (
+
+
+
+ {data.messages}
+
+
+
+ )}
+
+
+
+ {t('total_words')}:
+
+ {data.textWords}
+
+
+
+
+ {t('headers')}:
+
+ {data.headers}
+
+
+
+
+ {t('math_inline')}:
+
+ {data.mathInline}
+
+
+
+
+ {t('math_display')}:
+
+ {data.mathDisplay}
+
+
+ )}
+
+
+
+
+
+ >
+ )
+}
+
+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
+}
+
+export default WordCountModalContent
diff --git a/services/web/frontend/js/features/word-count-modal/components/word-count-modal.js b/services/web/frontend/js/features/word-count-modal/components/word-count-modal.js
new file mode 100644
index 0000000000..20b0079516
--- /dev/null
+++ b/services/web/frontend/js/features/word-count-modal/components/word-count-modal.js
@@ -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 (
+
+
+
+ )
+}
+
+WordCountModal.propTypes = {
+ handleHide: PropTypes.func.isRequired,
+ projectId: PropTypes.string.isRequired,
+ show: PropTypes.bool.isRequired
+}
+
+export default WordCountModal
diff --git a/services/web/frontend/js/features/word-count-modal/controllers/word-count-modal-controller.js b/services/web/frontend/js/features/word-count-modal/controllers/word-count-modal-controller.js
new file mode 100644
index 0000000000..a98a58f996
--- /dev/null
+++ b/services/web/frontend/js/features/word-count-modal/controllers/word-count-modal-controller.js
@@ -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
+ })
+ }
+})
diff --git a/services/web/frontend/js/ide/wordcount/controllers/WordCountController.js b/services/web/frontend/js/ide/wordcount/controllers/WordCountController.js
deleted file mode 100644
index 563e80884f..0000000000
--- a/services/web/frontend/js/ide/wordcount/controllers/WordCountController.js
+++ /dev/null
@@ -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'
- }))
-)
diff --git a/services/web/frontend/js/ide/wordcount/controllers/WordCountModalController.js b/services/web/frontend/js/ide/wordcount/controllers/WordCountModalController.js
deleted file mode 100644
index 72603054a3..0000000000
--- a/services/web/frontend/js/ide/wordcount/controllers/WordCountModalController.js
+++ /dev/null
@@ -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'))
-})
diff --git a/services/web/frontend/js/ide/wordcount/index.js b/services/web/frontend/js/ide/wordcount/index.js
index 7d8f27a843..0a6113a914 100644
--- a/services/web/frontend/js/ide/wordcount/index.js
+++ b/services/web/frontend/js/ide/wordcount/index.js
@@ -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'
diff --git a/services/web/frontend/stories/word-count-modal.stories.js b/services/web/frontend/stories/word-count-modal.stories.js
new file mode 100644
index 0000000000..20b6781cdc
--- /dev/null
+++ b/services/web/frontend/stories/word-count-modal.stories.js
@@ -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 => (
+
+)
+Loading.args = {
+ loading: true,
+ error: false
+}
+
+export const LoadingError = args => (
+
+)
+LoadingError.args = {
+ loading: false,
+ error: true
+}
+
+export const Loaded = args => (
+
+)
+Loaded.args = {
+ loading: false,
+ error: false,
+ data: {
+ headers: 4,
+ mathDisplay: 40,
+ mathInline: 400,
+ textWords: 4000
+ }
+}
+
+export const Messages = args => (
+
+)
+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' }
+ }
+}
diff --git a/services/web/test/frontend/features/word-count-modal/components/word-count-modal.test.js b/services/web/test/frontend/features/word-count-modal/components/word-count-modal.test.js
new file mode 100644
index 0000000000..e0b8095b1e
--- /dev/null
+++ b/services/web/test/frontend/features/word-count-modal/components/word-count-modal.test.js
@@ -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('', function() {
+ it('renders the translated modal title', async function() {
+ render()
+
+ await screen.findByText('Word Count')
+
+ expect(screen.queryByText(/Loading/)).to.not.exist
+ })
+
+ it('renders a loading message when loading', async function() {
+ render()
+
+ await screen.findByText('Loading')
+ })
+
+ it('renders an error message and hides loading message on error', async function() {
+ render()
+
+ await screen.findByText('Sorry, something went wrong')
+
+ expect(screen.queryByText(/Loading/)).to.not.exist
+ })
+
+ it('displays messages', async function() {
+ render(
+
+ )
+
+ await screen.findByText('This is a test')
+ })
+
+ it('displays counts data', async function() {
+ render(
+
+ )
+
+ 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$/)
+ )
+ })
+})