diff --git a/services/web/app/views/project/editor/left-menu.pug b/services/web/app/views/project/editor/left-menu.pug
index 628b26b279..7a332fc4a9 100644
--- a/services/web/app/views/project/editor/left-menu.pug
+++ b/services/web/app/views/project/editor/left-menu.pug
@@ -46,6 +46,7 @@ aside#left-menu.full-size(
handle-hide="handleHide"
project-id="projectId"
project-name="projectName"
+ open-project="openProject"
show="show"
)
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
index 59aca9bd57..a34d27f3e0 100644
--- 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
@@ -1,52 +1,49 @@
-import React, { useMemo, useState } from 'react'
+import React from 'react'
+import PropTypes from 'prop-types'
+import { Trans } from 'react-i18next'
import {
Modal,
Alert,
Button,
- FormGroup,
ControlLabel,
- FormControl
+ FormControl,
+ FormGroup
} from 'react-bootstrap'
-import PropTypes from 'prop-types'
-import { useTranslation } from 'react-i18next'
+import AccessibleModal from '../../../shared/components/accessible-modal'
-function CloneProjectModalContent({
- cloneProject,
- projectName = '',
- error,
+export default function CloneProjectModalContent({
+ animation = true,
+ show,
cancel,
- inFlight
+ handleSubmit,
+ clonedProjectName,
+ setClonedProjectName,
+ error,
+ inFlight,
+ valid
}) {
- 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')}
+
+
+
- >
+
)
}
-
CloneProjectModalContent.propTypes = {
- cloneProject: PropTypes.func.isRequired,
- error: PropTypes.oneOfType([
- PropTypes.bool,
- PropTypes.shape({
- message: PropTypes.string
- })
- ]),
+ animation: PropTypes.bool,
+ show: PropTypes.bool.isRequired,
cancel: PropTypes.func.isRequired,
+ handleSubmit: PropTypes.func.isRequired,
+ clonedProjectName: PropTypes.string,
+ setClonedProjectName: PropTypes.func.isRequired,
+ error: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]),
inFlight: PropTypes.bool.isRequired,
- projectName: PropTypes.string
+ valid: PropTypes.bool.isRequired
}
-
-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
index 639a823e25..f1dbb81458 100644
--- 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
@@ -1,54 +1,30 @@
-import React, { useCallback, useEffect, useState } from 'react'
-import { Modal } from 'react-bootstrap'
+import React, { useCallback, useEffect, useMemo, useState } from 'react'
import PropTypes from 'prop-types'
+import { cloneProject } from '../utils/api'
import CloneProjectModalContent from './clone-project-modal-content'
-function CloneProjectModal({ handleHide, show, projectId, projectName }) {
+function CloneProjectModal({
+ show,
+ handleHide,
+ projectId,
+ projectName = '',
+ openProject
+}) {
const [inFlight, setInFlight] = useState(false)
const [error, setError] = useState()
+ const [clonedProjectName, setClonedProjectName] = useState('')
+
+ // set the cloned project name when the modal opens
+ useEffect(() => {
+ if (show) {
+ setClonedProjectName(`${projectName} (Copy)`)
+ }
+ }, [show, projectName])
// 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]
- )
+ setError(undefined)
+ }, [])
// close the modal if not in flight
const cancel = useCallback(() => {
@@ -57,16 +33,49 @@ function CloneProjectModal({ handleHide, show, projectId, projectName }) {
}
}, [handleHide, inFlight])
+ // valid if the cloned project has a name
+ const valid = useMemo(() => !!clonedProjectName, [clonedProjectName])
+
+ // form submission: clone the project if the name is valid
+ const handleSubmit = event => {
+ event.preventDefault()
+
+ if (!valid) {
+ return
+ }
+
+ setError(false)
+ setInFlight(true)
+
+ // clone the project
+ cloneProject(projectId, clonedProjectName)
+ .then(data => {
+ // open the cloned project
+ openProject(data.project_id)
+ })
+ .catch(({ response, data }) => {
+ if (response.status === 400) {
+ setError(data.message)
+ } else {
+ setError(true)
+ }
+ })
+ .finally(() => {
+ setInFlight(false)
+ })
+ }
+
return (
-
-
-
+
)
}
@@ -74,6 +83,7 @@ CloneProjectModal.propTypes = {
handleHide: PropTypes.func.isRequired,
projectId: PropTypes.string.isRequired,
projectName: PropTypes.string,
+ openProject: PropTypes.func.isRequired,
show: PropTypes.bool.isRequired
}
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
index cc9eb9f23d..0957cab2b5 100644
--- 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
@@ -18,6 +18,10 @@ export default App.controller('LeftMenuCloneProjectModalController', function(
})
}
+ $scope.openProject = projectId => {
+ window.location.assign(`/project/${projectId}`)
+ }
+
$scope.openCloneProjectModal = () => {
$scope.$applyAsync(() => {
const { project } = ide.$scope
diff --git a/services/web/frontend/js/features/clone-project-modal/utils/api.js b/services/web/frontend/js/features/clone-project-modal/utils/api.js
new file mode 100644
index 0000000000..5e31d852f1
--- /dev/null
+++ b/services/web/frontend/js/features/clone-project-modal/utils/api.js
@@ -0,0 +1,9 @@
+import { postJSON } from '../../../infrastructure/fetch-json'
+
+export function cloneProject(projectId, cloneName) {
+ return postJSON(`/project/${projectId}/clone`, {
+ body: {
+ projectName: cloneName
+ }
+ })
+}
diff --git a/services/web/frontend/stories/clone-project-modal-content.stories.js b/services/web/frontend/stories/clone-project-modal-content.stories.js
new file mode 100644
index 0000000000..8ec3f60687
--- /dev/null
+++ b/services/web/frontend/stories/clone-project-modal-content.stories.js
@@ -0,0 +1,46 @@
+import React from 'react'
+
+import CloneProjectModalContent from '../js/features/clone-project-modal/components/clone-project-modal-content'
+
+export const Basic = args => {
+ return
+}
+
+export const Invalid = args => {
+ return (
+
+ )
+}
+
+export const Inflight = args => {
+ return
+}
+
+export const GenericError = args => {
+ return
+}
+
+export const SpecificError = args => {
+ return (
+
+ )
+}
+
+export default {
+ title: 'Clone Project Modal / Content',
+ component: CloneProjectModalContent,
+ args: {
+ animation: false,
+ projectId: 'original-project',
+ clonedProjectName: 'Project Title',
+ show: true,
+ error: false,
+ inFlight: false,
+ valid: true
+ },
+ argTypes: {
+ cancel: { action: 'cancel' },
+ handleSubmit: { action: 'submit' },
+ setClonedProjectName: { action: 'set project name' }
+ }
+}
diff --git a/services/web/frontend/stories/clone-project-modal.stories.js b/services/web/frontend/stories/clone-project-modal.stories.js
index d4486bf95e..32f1fe2089 100644
--- a/services/web/frontend/stories/clone-project-modal.stories.js
+++ b/services/web/frontend/stories/clone-project-modal.stories.js
@@ -1,67 +1,67 @@
import React from 'react'
+import fetchMock from 'fetch-mock'
+import PropTypes from 'prop-types'
-import CloneProjectModalContent from '../js/features/clone-project-modal/components/clone-project-modal-content'
+import CloneProjectModal from '../js/features/clone-project-modal/components/clone-project-modal'
-// NOTE: CloneProjectModalContent is wrapped in modal classes, without modal behaviours
+export const Interactive = ({
+ mockResponse = 200,
+ mockResponseDelay = 500,
+ ...args
+}) => {
+ fetchMock.restore().post(
+ 'express:/project/:projectId/clone',
+ () => {
+ switch (mockResponse) {
+ case 400:
+ return { status: 400, body: 'The project name is not valid' }
-export const Form = args => (
-
-)
-Form.args = {
- inFlight: false,
- error: false
+ default:
+ return mockResponse
+ }
+ },
+ { delay: mockResponseDelay }
+ )
+
+ return
}
-
-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'
- }
+Interactive.propTypes = {
+ mockResponse: PropTypes.number,
+ mockResponseDelay: PropTypes.number
}
export default {
title: 'Clone Project Modal',
- component: CloneProjectModalContent,
+ component: CloneProjectModal,
args: {
- projectName: 'Project Title'
+ projectId: 'original-project',
+ projectName: 'Project Title',
+ show: true
},
argTypes: {
- cloneProject: { action: 'cloneProject' },
- cancel: { action: 'cancel' }
+ handleHide: { action: 'close modal' },
+ openProject: { action: 'open project' },
+ mockResponse: {
+ name: 'Mock Response Status',
+ type: { name: 'number', required: false },
+ description: 'The status code that should be returned by the mock server',
+ defaultValue: 200,
+ control: {
+ type: 'radio',
+ options: [200, 500, 400]
+ }
+ },
+ mockResponseDelay: {
+ name: 'Mock Response Delay',
+ type: { name: 'number', required: false },
+ description: 'The delay before returning a response from the mock server',
+ defaultValue: 500,
+ control: {
+ type: 'range',
+ min: 0,
+ max: 2500,
+ step: 250
+ }
+ }
}
}
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
index ba6b876836..7c774879f6 100644
--- 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
@@ -1,69 +1,84 @@
import React from 'react'
-import { fireEvent, render, screen } from '@testing-library/react'
+import { fireEvent, render, screen, waitFor } 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()
})
+ const modalProps = {
+ handleHide: sinon.stub(),
+ projectId: 'project-1',
+ projectName: 'Test Project',
+ openProject: sinon.stub(),
+ show: true
+ }
+
+ it('renders the translated modal title', async function() {
+ render()
+
+ await screen.findByText('Copy Project')
+ })
+
it('posts the generated project name', async function() {
- const matcher = 'express:/project/:projectId/clone'
-
- fetchMock.postOnce(
- matcher,
- () => {
- return {
- project_id: 'test'
- }
- },
+ fetchMock.post(
+ 'express:/project/:projectId/clone',
{
- body: {
- projectName: 'A Project (Copy)'
- }
- }
+ status: 200,
+ body: { project_id: modalProps.projectId }
+ },
+ { delay: 10 }
)
- render(
-
- )
+ const openProject = sinon.stub()
- const button = await screen.findByRole('button', {
- name: 'Copy',
- hidden: true // TODO: this shouldn't be needed
- })
+ render()
- const cancelButton = await screen.findByRole('button', {
- name: 'Cancel',
- hidden: true // TODO: this shouldn't be needed
- })
-
- expect(button.disabled).to.be.false
+ const cancelButton = await screen.findByRole('button', { name: 'Cancel' })
expect(cancelButton.disabled).to.be.false
- fireEvent.click(button)
+ const submitButton = await screen.findByRole('button', { name: 'Copy' })
+ expect(submitButton.disabled).to.be.false
- expect(fetchMock.done(matcher)).to.be.true
- // TODO: window.location?
+ const input = await screen.getByLabelText('New Name')
+
+ fireEvent.change(input, {
+ target: { value: '' }
+ })
+ expect(submitButton.disabled).to.be.true
+
+ fireEvent.change(input, {
+ target: { value: 'A Cloned Project' }
+ })
+ expect(submitButton.disabled).to.be.false
+
+ fireEvent.click(submitButton)
+ expect(submitButton.disabled).to.be.true
+
+ await fetchMock.flush(true)
+ expect(fetchMock.done()).to.be.true
+ const [url, options] = fetchMock.lastCall(
+ 'express:/project/:projectId/clone'
+ )
+ expect(url).to.equal('/project/project-1/clone')
+
+ expect(JSON.parse(options.body)).to.deep.equal({
+ projectName: 'A Cloned Project'
+ })
+
+ expect(openProject).to.be.calledOnce
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
+ await waitFor(() => {
+ expect(submitButton.disabled).to.be.false
+ expect(cancelButton.disabled).to.be.false
+ })
})
it('handles a generic error response', async function() {
@@ -74,31 +89,20 @@ describe('', function() {
body: 'There was an error!'
})
- render(
-
- )
+ const openProject = sinon.stub()
- 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
- })
+ render()
+ const button = await screen.findByRole('button', { name: 'Copy' })
expect(button.disabled).to.be.false
+
+ const cancelButton = await screen.findByRole('button', { name: 'Cancel' })
expect(cancelButton.disabled).to.be.false
fireEvent.click(button)
expect(fetchMock.done(matcher)).to.be.true
+ expect(openProject).not.to.be.called
await screen.findByText('Sorry, something went wrong')
@@ -114,31 +118,21 @@ describe('', function() {
body: 'There was an error!'
})
- render(
-
- )
+ const openProject = sinon.stub()
- 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
- })
+ render()
+ const button = await screen.findByRole('button', { name: 'Copy' })
expect(button.disabled).to.be.false
+
+ const cancelButton = await screen.findByRole('button', { name: 'Cancel' })
expect(cancelButton.disabled).to.be.false
fireEvent.click(button)
+ await fetchMock.flush(true)
expect(fetchMock.done(matcher)).to.be.true
+ expect(openProject).not.to.be.called
await screen.findByText('There was an error!')
@@ -146,92 +140,3 @@ describe('', function() {
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)')
- })
-})