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')} + + +
- - {t('new_name')} + + - {error.message || t('generic_something_went_wrong')} + {error.length ? ( + error + ) : ( + + )} )} - + ) } - 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)') - }) -})