Merge pull request #3446 from overleaf/ae-clone-project-modal

Migrate left menu Clone Project modal to React

GitOrigin-RevId: ad113e2b40de4007def513d40551d55bb0c913db
This commit is contained in:
Alf Eaton 2021-01-06 10:30:08 +00:00 committed by Copybot
parent c0e13db7ac
commit 0135236df8
8 changed files with 535 additions and 32 deletions

View file

@ -34,7 +34,7 @@ aside#left-menu.full-size(
span(ng-show="!anonymous") span(ng-show="!anonymous")
h4 #{translate("actions")} h4 #{translate("actions")}
ul.list-unstyled.nav ul.list-unstyled.nav
li(ng-controller="CloneProjectController") li(ng-controller="LeftMenuCloneProjectModalController")
a( a(
href, href,
ng-click="openCloneProjectModal()" ng-click="openCloneProjectModal()"
@ -42,6 +42,13 @@ aside#left-menu.full-size(
i.fa.fa-fw.fa-copy i.fa.fa-fw.fa-copy
|    #{translate("copy_project")} |    #{translate("copy_project")}
clone-project-modal(
handle-hide="handleHide"
project-id="projectId"
project-name="projectName"
show="show"
)
!= moduleIncludes("editorLeftMenu:actions", locals) != moduleIncludes("editorLeftMenu:actions", locals)
li(ng-controller="WordCountController") li(ng-controller="WordCountController")
a(href, ng-if="pdf.url" ,ng-click="openWordCountModal()") a(href, ng-if="pdf.url" ,ng-click="openWordCountModal()")
@ -227,35 +234,6 @@ aside#left-menu.full-size(
ng-cloak 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') script(type='text/ng-template', id='wordCountModalTemplate')
.modal-header .modal-header
h3 #{translate("word_count")} h3 #{translate("word_count")}

View file

@ -88,6 +88,10 @@
"we_cant_find_any_sections_or_subsections_in_this_file", "we_cant_find_any_sections_or_subsections_in_this_file",
"your_message", "your_message",
"your_project_has_errors", "your_project_has_errors",
"copy_project",
"copying",
"copy",
"new_name",
"recompile_from_scratch", "recompile_from_scratch",
"run_syntax_check_now", "run_syntax_check_now",
"toggle_compile_options_menu", "toggle_compile_options_menu",

View file

@ -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 (
<>
<Modal.Header closeButton>
<Modal.Title>{t('copy_project')}</Modal.Title>
</Modal.Header>
<Modal.Body>
<form id="clone-project-form" onSubmit={handleSubmit}>
<FormGroup>
<ControlLabel htmlFor="cloned-project-name">
{t('new_name')}
</ControlLabel>
<FormControl
id="cloned-project-name"
type="text"
placeholder="New Project Name"
required
value={clonedProjectName}
onChange={event => setClonedProjectName(event.target.value)}
/>
</FormGroup>
</form>
{error && (
<Alert bsStyle="danger">
{error.message || t('generic_something_went_wrong')}
</Alert>
)}
</Modal.Body>
<Modal.Footer>
<Button type="button" disabled={inFlight} onClick={cancel}>
{t('cancel')}
</Button>
<Button
form="clone-project-form"
type="submit"
bsStyle="primary"
disabled={inFlight || !valid}
>
{inFlight ? <span>{t('copying')}</span> : <span>{t('copy')}</span>}
</Button>
</Modal.Footer>
</>
)
}
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

View file

@ -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 (
<Modal show={show} onHide={cancel}>
<CloneProjectModalContent
cloneProject={cloneProject}
error={error}
cancel={cancel}
inFlight={inFlight}
projectName={projectName}
/>
</Modal>
)
}
CloneProjectModal.propTypes = {
handleHide: PropTypes.func.isRequired,
projectId: PropTypes.string.isRequired,
projectName: PropTypes.string,
show: PropTypes.bool.isRequired
}
export default CloneProjectModal

View file

@ -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)
}
})
}
})

View file

@ -1,4 +1,6 @@
// TODO: This file was created by bulk-decaffeinate. // Angular
// Fix any style issues and re-enable lint.
import './controllers/CloneProjectController' import './controllers/CloneProjectController'
import './controllers/CloneProjectModalController' import './controllers/CloneProjectModalController'
// React
import '../../features/clone-project-modal/controllers/left-menu-clone-project-modal-controller'

View file

@ -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 => (
<div className="modal-dialog">
<div className="modal-content">
<CloneProjectModalContent {...args} />
</div>
</div>
)
Form.args = {
inFlight: false,
error: false
}
export const Loading = args => (
<div className="modal-dialog">
<div className="modal-content">
<CloneProjectModalContent {...args} />
</div>
</div>
)
Loading.args = {
inFlight: true,
error: false
}
export const LoadingError = args => (
<div className="modal-dialog">
<div className="modal-content">
<CloneProjectModalContent {...args} />
</div>
</div>
)
LoadingError.args = {
inFlight: false,
error: true
}
export const LoadingErrorMessage = args => (
<div className="modal-dialog">
<div className="modal-content">
<CloneProjectModalContent {...args} />
</div>
</div>
)
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' }
}
}

View file

@ -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('<CloneProjectModal />', 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(
<CloneProjectModal
handleHide={handleHide}
projectId="project-1"
projectName="A Project"
show
/>
)
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(
<CloneProjectModal
handleHide={handleHide}
projectId="project-2"
projectName="A Project"
show
/>
)
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(
<CloneProjectModal
handleHide={handleHide}
projectId="project-3"
projectName="A Project"
show
/>
)
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('<CloneProjectModalContent />', function() {
it('renders the translated modal title', async function() {
render(
<CloneProjectModalContent
cloneProject={cloneProject}
cancel={cancel}
inFlight={false}
/>
)
await screen.findByText('Copy Project')
})
it('shows the copy button', async function() {
render(
<CloneProjectModalContent
cloneProject={cloneProject}
cancel={cancel}
inFlight={false}
/>
)
const button = await screen.findByRole('button', { name: 'Copy' })
expect(button.disabled).to.be.false
})
it('disables the copy button when loading', async function() {
render(
<CloneProjectModalContent
cloneProject={cloneProject}
cancel={cancel}
inFlight
/>
)
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(
<CloneProjectModalContent
cloneProject={cloneProject}
cancel={cancel}
inFlight={false}
error
/>
)
await screen.findByText('Sorry, something went wrong')
})
it('renders a specific error message', async function() {
render(
<CloneProjectModalContent
cloneProject={cloneProject}
cancel={cancel}
inFlight={false}
error={{
message: 'Uh oh!'
}}
/>
)
await screen.findByText('Uh oh!')
})
it('displays a project name', async function() {
render(
<CloneProjectModalContent
cloneProject={cloneProject}
cancel={cancel}
inFlight={false}
projectName="A copy of a project"
/>
)
const input = await screen.getByLabelText('New Name')
expect(input.value).to.equal('A copy of a project (Copy)')
})
})