diff --git a/services/web/app/views/project/editor/left-menu.pug b/services/web/app/views/project/editor/left-menu.pug
index 020bd33091..8abffea540 100644
--- a/services/web/app/views/project/editor/left-menu.pug
+++ b/services/web/app/views/project/editor/left-menu.pug
@@ -34,7 +34,7 @@ aside#left-menu.full-size(
span(ng-show="!anonymous")
h4 #{translate("actions")}
ul.list-unstyled.nav
- li(ng-controller="CloneProjectController")
+ li(ng-controller="LeftMenuCloneProjectModalController")
a(
href,
ng-click="openCloneProjectModal()"
@@ -42,6 +42,13 @@ aside#left-menu.full-size(
i.fa.fa-fw.fa-copy
| #{translate("copy_project")}
+ clone-project-modal(
+ handle-hide="handleHide"
+ project-id="projectId"
+ project-name="projectName"
+ show="show"
+ )
+
!= moduleIncludes("editorLeftMenu:actions", locals)
li(ng-controller="WordCountController")
a(href, ng-if="pdf.url" ,ng-click="openWordCountModal()")
@@ -227,35 +234,6 @@ 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")}
diff --git a/services/web/frontend/extracted-translation-keys.json b/services/web/frontend/extracted-translation-keys.json
index 3d453847e2..6a6dff3194 100644
--- a/services/web/frontend/extracted-translation-keys.json
+++ b/services/web/frontend/extracted-translation-keys.json
@@ -88,6 +88,10 @@
"we_cant_find_any_sections_or_subsections_in_this_file",
"your_message",
"your_project_has_errors",
+ "copy_project",
+ "copying",
+ "copy",
+ "new_name",
"recompile_from_scratch",
"run_syntax_check_now",
"toggle_compile_options_menu",
diff --git a/services/web/frontend/js/features/clone-project-modal/components/clone-project-modal-content.js b/services/web/frontend/js/features/clone-project-modal/components/clone-project-modal-content.js
new file mode 100644
index 0000000000..59aca9bd57
--- /dev/null
+++ b/services/web/frontend/js/features/clone-project-modal/components/clone-project-modal-content.js
@@ -0,0 +1,97 @@
+import React, { useMemo, useState } from 'react'
+import {
+ Modal,
+ Alert,
+ Button,
+ FormGroup,
+ ControlLabel,
+ FormControl
+} from 'react-bootstrap'
+import PropTypes from 'prop-types'
+import { useTranslation } from 'react-i18next'
+
+function CloneProjectModalContent({
+ cloneProject,
+ projectName = '',
+ error,
+ cancel,
+ inFlight
+}) {
+ const { t } = useTranslation()
+
+ const [clonedProjectName, setClonedProjectName] = useState(
+ `${projectName} (Copy)`
+ )
+
+ const valid = useMemo(() => !!clonedProjectName, [clonedProjectName])
+
+ function handleSubmit(event) {
+ event.preventDefault()
+ if (valid) {
+ cloneProject(clonedProjectName)
+ }
+ }
+
+ return (
+ <>
+
+ {t('copy_project')}
+
+
+
+
+
+ {error && (
+
+ {error.message || t('generic_something_went_wrong')}
+
+ )}
+
+
+
+
+
+
+
+ >
+ )
+}
+
+CloneProjectModalContent.propTypes = {
+ cloneProject: PropTypes.func.isRequired,
+ error: PropTypes.oneOfType([
+ PropTypes.bool,
+ PropTypes.shape({
+ message: PropTypes.string
+ })
+ ]),
+ cancel: PropTypes.func.isRequired,
+ inFlight: PropTypes.bool.isRequired,
+ projectName: PropTypes.string
+}
+
+export default CloneProjectModalContent
diff --git a/services/web/frontend/js/features/clone-project-modal/components/clone-project-modal.js b/services/web/frontend/js/features/clone-project-modal/components/clone-project-modal.js
new file mode 100644
index 0000000000..639a823e25
--- /dev/null
+++ b/services/web/frontend/js/features/clone-project-modal/components/clone-project-modal.js
@@ -0,0 +1,80 @@
+import React, { useCallback, useEffect, useState } from 'react'
+import { Modal } from 'react-bootstrap'
+import PropTypes from 'prop-types'
+import CloneProjectModalContent from './clone-project-modal-content'
+
+function CloneProjectModal({ handleHide, show, projectId, projectName }) {
+ const [inFlight, setInFlight] = useState(false)
+ const [error, setError] = useState()
+
+ // reset error when the modal is opened
+ useEffect(() => {
+ if (show) {
+ setError(undefined)
+ }
+ }, [show])
+
+ // clone the project when the form is submitted
+ const cloneProject = useCallback(
+ cloneName => {
+ setInFlight(true)
+
+ fetch(`/project/${projectId}/clone`, {
+ method: 'POST',
+ body: JSON.stringify({
+ _csrf: window.csrfToken,
+ projectName: cloneName
+ }),
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ })
+ .then(async response => {
+ if (response.ok) {
+ const { project_id: clonedProjectId } = await response.json()
+ window.location.assign(`/project/${clonedProjectId}`)
+ } else {
+ if (response.status === 400) {
+ setError({ message: await response.text() })
+ } else {
+ setError(true)
+ }
+ setInFlight(false)
+ }
+ })
+ .catch(() => {
+ setError(true)
+ setInFlight(false)
+ })
+ },
+ [projectId]
+ )
+
+ // close the modal if not in flight
+ const cancel = useCallback(() => {
+ if (!inFlight) {
+ handleHide()
+ }
+ }, [handleHide, inFlight])
+
+ return (
+
+
+
+ )
+}
+
+CloneProjectModal.propTypes = {
+ handleHide: PropTypes.func.isRequired,
+ projectId: PropTypes.string.isRequired,
+ projectName: PropTypes.string,
+ show: PropTypes.bool.isRequired
+}
+
+export default CloneProjectModal
diff --git a/services/web/frontend/js/features/clone-project-modal/controllers/left-menu-clone-project-modal-controller.js b/services/web/frontend/js/features/clone-project-modal/controllers/left-menu-clone-project-modal-controller.js
new file mode 100644
index 0000000000..cc9eb9f23d
--- /dev/null
+++ b/services/web/frontend/js/features/clone-project-modal/controllers/left-menu-clone-project-modal-controller.js
@@ -0,0 +1,38 @@
+import App from '../../../base'
+import { react2angular } from 'react2angular'
+
+import CloneProjectModal from '../components/clone-project-modal'
+
+App.component('cloneProjectModal', react2angular(CloneProjectModal))
+
+export default App.controller('LeftMenuCloneProjectModalController', function(
+ $scope,
+ ide
+) {
+ $scope.show = false
+ $scope.projectId = ide.$scope.project_id
+
+ $scope.handleHide = () => {
+ $scope.$applyAsync(() => {
+ $scope.show = false
+ })
+ }
+
+ $scope.openCloneProjectModal = () => {
+ $scope.$applyAsync(() => {
+ const { project } = ide.$scope
+
+ if (project) {
+ $scope.projectId = project._id
+ $scope.projectName = project.name
+
+ $scope.show = true
+
+ // TODO: is this needed
+ window.setTimeout(() => {
+ $scope.$broadcast('open')
+ }, 200)
+ }
+ })
+ }
+})
diff --git a/services/web/frontend/js/ide/clone/index.js b/services/web/frontend/js/ide/clone/index.js
index 27dd78a967..8e23276be2 100644
--- a/services/web/frontend/js/ide/clone/index.js
+++ b/services/web/frontend/js/ide/clone/index.js
@@ -1,4 +1,6 @@
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
+// Angular
import './controllers/CloneProjectController'
import './controllers/CloneProjectModalController'
+
+// React
+import '../../features/clone-project-modal/controllers/left-menu-clone-project-modal-controller'
diff --git a/services/web/frontend/stories/clone-project-modal.stories.js b/services/web/frontend/stories/clone-project-modal.stories.js
new file mode 100644
index 0000000000..d4486bf95e
--- /dev/null
+++ b/services/web/frontend/stories/clone-project-modal.stories.js
@@ -0,0 +1,67 @@
+import React from 'react'
+
+import CloneProjectModalContent from '../js/features/clone-project-modal/components/clone-project-modal-content'
+
+// NOTE: CloneProjectModalContent is wrapped in modal classes, without modal behaviours
+
+export const Form = args => (
+
+)
+Form.args = {
+ inFlight: false,
+ error: false
+}
+
+export const Loading = args => (
+
+)
+Loading.args = {
+ inFlight: true,
+ error: false
+}
+
+export const LoadingError = args => (
+
+)
+LoadingError.args = {
+ inFlight: false,
+ error: true
+}
+
+export const LoadingErrorMessage = args => (
+
+)
+LoadingErrorMessage.args = {
+ inFlight: false,
+ error: {
+ message: 'The chosen project name is already in use'
+ }
+}
+
+export default {
+ title: 'Clone Project Modal',
+ component: CloneProjectModalContent,
+ args: {
+ projectName: 'Project Title'
+ },
+ argTypes: {
+ cloneProject: { action: 'cloneProject' },
+ cancel: { action: 'cancel' }
+ }
+}
diff --git a/services/web/test/frontend/features/clone-project-modal/components/clone-project-modal.test.js b/services/web/test/frontend/features/clone-project-modal/components/clone-project-modal.test.js
new file mode 100644
index 0000000000..ba6b876836
--- /dev/null
+++ b/services/web/test/frontend/features/clone-project-modal/components/clone-project-modal.test.js
@@ -0,0 +1,237 @@
+import React from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { expect } from 'chai'
+import CloneProjectModalContent from '../../../../../frontend/js/features/clone-project-modal/components/clone-project-modal-content'
+import CloneProjectModal from '../../../../../frontend/js/features/clone-project-modal/components/clone-project-modal'
+import sinon from 'sinon'
+import fetchMock from 'fetch-mock'
+
+const cancel = sinon.stub()
+const cloneProject = sinon.stub()
+const handleHide = sinon.stub()
+
+describe('', function() {
+ afterEach(function() {
+ fetchMock.reset()
+ })
+
+ it('posts the generated project name', async function() {
+ const matcher = 'express:/project/:projectId/clone'
+
+ fetchMock.postOnce(
+ matcher,
+ () => {
+ return {
+ project_id: 'test'
+ }
+ },
+ {
+ body: {
+ projectName: 'A Project (Copy)'
+ }
+ }
+ )
+
+ render(
+
+ )
+
+ const button = await screen.findByRole('button', {
+ name: 'Copy',
+ hidden: true // TODO: this shouldn't be needed
+ })
+
+ const cancelButton = await screen.findByRole('button', {
+ name: 'Cancel',
+ hidden: true // TODO: this shouldn't be needed
+ })
+
+ expect(button.disabled).to.be.false
+ expect(cancelButton.disabled).to.be.false
+
+ fireEvent.click(button)
+
+ expect(fetchMock.done(matcher)).to.be.true
+ // TODO: window.location?
+
+ const errorMessage = screen.queryByText('Sorry, something went wrong')
+ expect(errorMessage).to.be.null
+
+ expect(button.disabled).to.be.true
+ expect(cancelButton.disabled).to.be.true
+ })
+
+ it('handles a generic error response', async function() {
+ const matcher = 'express:/project/:projectId/clone'
+
+ fetchMock.postOnce(matcher, {
+ status: 500,
+ body: 'There was an error!'
+ })
+
+ render(
+
+ )
+
+ const button = await screen.findByRole('button', {
+ name: 'Copy',
+ hidden: true // TODO: this shouldn't be needed
+ })
+
+ const cancelButton = await screen.findByRole('button', {
+ name: 'Cancel',
+ hidden: true // TODO: this shouldn't be needed
+ })
+
+ expect(button.disabled).to.be.false
+ expect(cancelButton.disabled).to.be.false
+
+ fireEvent.click(button)
+
+ expect(fetchMock.done(matcher)).to.be.true
+
+ await screen.findByText('Sorry, something went wrong')
+
+ expect(button.disabled).to.be.false
+ expect(cancelButton.disabled).to.be.false
+ })
+
+ it('handles a specific error response', async function() {
+ const matcher = 'express:/project/:projectId/clone'
+
+ fetchMock.postOnce(matcher, {
+ status: 400,
+ body: 'There was an error!'
+ })
+
+ render(
+
+ )
+
+ const button = await screen.findByRole('button', {
+ name: 'Copy',
+ hidden: true // TODO: this shouldn't be needed
+ })
+
+ const cancelButton = await screen.findByRole('button', {
+ name: 'Cancel',
+ hidden: true // TODO: this shouldn't be needed
+ })
+
+ expect(button.disabled).to.be.false
+ expect(cancelButton.disabled).to.be.false
+
+ fireEvent.click(button)
+
+ expect(fetchMock.done(matcher)).to.be.true
+
+ await screen.findByText('There was an error!')
+
+ expect(button.disabled).to.be.false
+ expect(cancelButton.disabled).to.be.false
+ })
+})
+
+describe('', function() {
+ it('renders the translated modal title', async function() {
+ render(
+
+ )
+
+ await screen.findByText('Copy Project')
+ })
+
+ it('shows the copy button', async function() {
+ render(
+
+ )
+
+ const button = await screen.findByRole('button', { name: 'Copy' })
+
+ expect(button.disabled).to.be.false
+ })
+
+ it('disables the copy button when loading', async function() {
+ render(
+
+ )
+
+ const button = await screen.findByText(
+ (content, element) =>
+ element.nodeName === 'BUTTON' &&
+ element.textContent.trim().match(/^Copying…$/)
+ )
+
+ expect(button.disabled).to.be.true
+ })
+
+ it('renders a generic error message', async function() {
+ render(
+
+ )
+
+ await screen.findByText('Sorry, something went wrong')
+ })
+
+ it('renders a specific error message', async function() {
+ render(
+
+ )
+
+ await screen.findByText('Uh oh!')
+ })
+
+ it('displays a project name', async function() {
+ render(
+
+ )
+
+ const input = await screen.getByLabelText('New Name')
+
+ expect(input.value).to.equal('A copy of a project (Copy)')
+ })
+})