Просмотр исходного кода

Merge pull request #9764 from overleaf/jel-project-tools-rename

[web] Add rename option to project tools

GitOrigin-RevId: 5bf622609e612e27c77c4e5e11d64fdad1bb47b4
Jessica Lawshe 4 лет назад
Родитель
Сommit
8b19b6107a

+ 2 - 0
services/web/frontend/extracted-translations.json

@@ -374,6 +374,7 @@
   "mendeley_reference_loading_error_forbidden": "",
   "mendeley_sync_description": "",
   "menu": "",
+  "more": "",
   "n_items": "",
   "n_items_plural": "",
   "navigate_log_source": "",
@@ -507,6 +508,7 @@
   "remove_tag": "",
   "rename": "",
   "rename_folder": "",
+  "rename_project": "",
   "renaming": "",
   "repository_name": "",
   "resend": "",

+ 132 - 0
services/web/frontend/js/features/project-list/components/modals/rename-project-modal.tsx

@@ -0,0 +1,132 @@
+import { memo, useCallback, useEffect, useMemo, useState } from 'react'
+import {
+  Alert,
+  Button,
+  ControlLabel,
+  FormControl,
+  FormGroup,
+  Modal,
+} from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import AccessibleModal from '../../../../shared/components/accessible-modal'
+import * as eventTracking from '../../../../infrastructure/event-tracking'
+import { Project } from '../../../../../../types/project/dashboard/api'
+import { renameProject } from '../../util/api'
+import useAsync from '../../../../shared/hooks/use-async'
+import { useProjectListContext } from '../../context/project-list-context'
+import { getUserFacingMessage } from '../../../../infrastructure/fetch-json'
+
+type RenameProjectModalProps = {
+  handleCloseModal: () => void
+  project: Project
+  showModal: boolean
+}
+
+function RenameProjectModal({
+  handleCloseModal,
+  showModal,
+  project,
+}: RenameProjectModalProps) {
+  const { t } = useTranslation()
+  const [newProjectName, setNewProjectName] = useState(project.name)
+  const { error, isError, isLoading, runAsync } = useAsync()
+  const { updateProjectViewData } = useProjectListContext()
+
+  useEffect(() => {
+    if (showModal) {
+      eventTracking.send(
+        'project-list-page-interaction',
+        'project action',
+        'Rename'
+      )
+    }
+  }, [showModal])
+
+  const isValid = useMemo(
+    () => newProjectName !== project.name && newProjectName.trim().length > 0,
+    [newProjectName, project]
+  )
+
+  const handleSubmit = useCallback(
+    event => {
+      event.preventDefault()
+
+      if (!isValid) return
+
+      runAsync(renameProject(project.id, newProjectName))
+        .then(() => {
+          updateProjectViewData({
+            ...project,
+            name: newProjectName,
+            selected: false,
+          })
+          handleCloseModal()
+        })
+        .catch(console.error)
+    },
+    [
+      handleCloseModal,
+      isValid,
+      newProjectName,
+      project,
+      runAsync,
+      updateProjectViewData,
+    ]
+  )
+
+  const handleOnChange = (
+    event: React.ChangeEvent<HTMLFormElement & FormControl>
+  ) => {
+    setNewProjectName(event.target.value)
+  }
+
+  return (
+    <AccessibleModal
+      animation
+      show={showModal}
+      onHide={handleCloseModal}
+      id="rename-project-modal"
+      backdrop="static"
+    >
+      <Modal.Header closeButton>
+        <Modal.Title>{t('rename_project')}</Modal.Title>
+      </Modal.Header>
+      <Modal.Body>
+        <form id="rename-project-form" onSubmit={handleSubmit}>
+          <FormGroup>
+            <ControlLabel htmlFor="rename-project-form-name">
+              {t('new_name')}
+            </ControlLabel>
+
+            <FormControl
+              id="rename-project-form-name"
+              type="text"
+              placeholder={t('project_name')}
+              required
+              value={newProjectName}
+              onChange={handleOnChange}
+            />
+          </FormGroup>
+        </form>
+      </Modal.Body>
+      <Modal.Footer>
+        {isError && (
+          <Alert bsStyle="danger" className="text-center" aria-live="polite">
+            {getUserFacingMessage(error)}
+          </Alert>
+        )}
+        <Button onClick={handleCloseModal}>{t('cancel')}</Button>
+        <Button
+          form="rename-project-form"
+          bsStyle="primary"
+          disabled={isLoading || !isValid}
+          type="submit"
+        >
+          {t('rename')}
+        </Button>
+      </Modal.Footer>
+    </AccessibleModal>
+  )
+}
+
+export default memo(RenameProjectModal)

+ 19 - 0
services/web/frontend/js/features/project-list/components/table/project-tools/buttons/project-tools-more-dropdown-button.tsx

@@ -0,0 +1,19 @@
+import { memo } from 'react'
+import { Dropdown } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import ControlledDropdown from '../../../../../../shared/components/controlled-dropdown'
+import RenameProjectMenuItem from '../menu-items/rename-project-menu-item'
+
+function ProjectToolsMoreDropdownButton() {
+  const { t } = useTranslation()
+  return (
+    <ControlledDropdown id="project-tools-more-dropdown">
+      <Dropdown.Toggle>{t('more')}</Dropdown.Toggle>
+      <Dropdown.Menu className="dropdown-menu-right">
+        <RenameProjectMenuItem />
+      </Dropdown.Menu>
+    </ControlledDropdown>
+  )
+}
+
+export default memo(ProjectToolsMoreDropdownButton)

+ 38 - 0
services/web/frontend/js/features/project-list/components/table/project-tools/menu-items/rename-project-menu-item.tsx

@@ -0,0 +1,38 @@
+import { memo, useCallback, useState } from 'react'
+import { MenuItem } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import { useProjectListContext } from '../../../../context/project-list-context'
+import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
+import RenameProjectModal from '../../../modals/rename-project-modal'
+
+function RenameProjectMenuItem() {
+  const { selectedProjects } = useProjectListContext()
+  const [showModal, setShowModal] = useState(false)
+  const isMounted = useIsMounted()
+  const { t } = useTranslation()
+
+  const handleOpenModal = useCallback(() => {
+    setShowModal(true)
+  }, [])
+
+  const handleCloseModal = useCallback(() => {
+    if (isMounted.current) {
+      setShowModal(false)
+    }
+  }, [isMounted])
+
+  if (selectedProjects.length !== 1) return null
+
+  return (
+    <>
+      <MenuItem onClick={handleOpenModal}>{t('rename')}</MenuItem>
+      <RenameProjectModal
+        handleCloseModal={handleCloseModal}
+        showModal={showModal}
+        project={selectedProjects[0]}
+      />
+    </>
+  )
+}
+
+export default memo(RenameProjectMenuItem)

+ 6 - 1
services/web/frontend/js/features/project-list/components/table/project-tools/project-tools.tsx

@@ -3,12 +3,13 @@ import { ButtonGroup, ButtonToolbar } from 'react-bootstrap'
 import { useProjectListContext } from '../../../context/project-list-context'
 import ArchiveProjectsButton from './buttons/archive-projects-button'
 import DownloadProjectsButton from './buttons/download-projects-button'
+import ProjectToolsMoreDropdownButton from './buttons/project-tools-more-dropdown-button'
 import TrashProjectsButton from './buttons/trash-projects-button'
 import UnarchiveProjectsButton from './buttons/unarchive-projects-button'
 import UntrashProjectsButton from './buttons/untrash-projects-button'
 
 function ProjectTools() {
-  const { filter } = useProjectListContext()
+  const { filter, selectedProjects } = useProjectListContext()
   return (
     <ButtonToolbar>
       <ButtonGroup>
@@ -21,6 +22,10 @@ function ProjectTools() {
         {filter === 'trashed' && <UntrashProjectsButton />}
         {filter === 'archived' && <UnarchiveProjectsButton />}
       </ButtonGroup>
+
+      {selectedProjects.length === 1 &&
+        filter !== 'archived' &&
+        filter !== 'trashed' && <ProjectToolsMoreDropdownButton />}
     </ButtonToolbar>
   )
 }

+ 8 - 0
services/web/frontend/js/features/project-list/util/api.ts

@@ -41,6 +41,14 @@ export function leaveProject(projectId: string) {
   return postJSON(`/project/${projectId}/leave`)
 }
 
+export function renameProject(projectId: string, newName: string) {
+  return postJSON(`/project/${projectId}/rename`, {
+    body: {
+      newProjectName: newName,
+    },
+  })
+}
+
 export function trashProject(projectId: string) {
   return postJSON(`/project/${projectId}/trash`)
 }

+ 81 - 5
services/web/test/frontend/features/project-list/components/project-list-root.test.tsx

@@ -4,6 +4,7 @@ import fetchMock from 'fetch-mock'
 import sinon from 'sinon'
 import ProjectListRoot from '../../../../../frontend/js/features/project-list/components/project-list-root'
 import { renderWithProjectListContext } from '../helpers/render-with-context'
+import * as eventTracking from '../../../../../frontend/js/infrastructure/event-tracking'
 import {
   owner,
   archivedProjects,
@@ -16,8 +17,11 @@ const userId = owner.id
 describe('<ProjectListRoot />', function () {
   const originalLocation = window.location
   const locationStub = sinon.stub()
+  let sendSpy: sinon.SinonSpy
 
   beforeEach(async function () {
+    global.localStorage.clear()
+    sendSpy = sinon.spy(eventTracking, 'send')
     window.metaAttributesCache = new Map()
     window.metaAttributesCache.set('ol-tags', [])
     window.metaAttributesCache.set('ol-ExposedSettings', { templateLinks: [] })
@@ -37,6 +41,7 @@ describe('<ProjectListRoot />', function () {
   })
 
   afterEach(function () {
+    sendSpy.restore()
     window.user_id = undefined
     fetchMock.reset()
     Object.defineProperty(window, 'location', {
@@ -316,15 +321,86 @@ describe('<ProjectListRoot />', function () {
         screen.getByText('No projects')
       })
     })
+
+    describe('project tools "More" dropdown', function () {
+      beforeEach(async function () {
+        const filterButton = screen.getAllByText('All Projects')[0]
+        fireEvent.click(filterButton)
+        allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
+        // first one is the select all checkbox
+        fireEvent.click(allCheckboxes[2])
+        actionsToolbar = screen.getAllByRole('toolbar')[0]
+      })
+
+      it('does not show the dropdown when more than 1 project is selected', async function () {
+        await waitFor(() => {
+          within(actionsToolbar).getByText<HTMLElement>('More')
+        })
+        fireEvent.click(allCheckboxes[0])
+        expect(within(actionsToolbar).queryByText<HTMLElement>('More')).to.be
+          .null
+      })
+
+      it('opens the rename modal, validates name, and can rename the project', async function () {
+        fetchMock.post(`express:/project/:id/rename`, {
+          status: 200,
+        })
+
+        await waitFor(() => {
+          const moreDropdown =
+            within(actionsToolbar).getByText<HTMLElement>('More')
+          fireEvent.click(moreDropdown)
+        })
+
+        const renameButton = screen.getByText<HTMLInputElement>('Rename')
+        fireEvent.click(renameButton)
+
+        const modal = screen.getAllByRole('dialog')[0]
+
+        expect(sendSpy).to.be.calledOnce
+        expect(sendSpy).calledWith('project-list-page-interaction')
+
+        // same name
+        let confirmButton = within(modal).getByText<HTMLInputElement>('Rename')
+        expect(confirmButton.disabled).to.be.true
+        let input = screen.getByLabelText('New Name') as HTMLButtonElement
+        const oldName = input.value
+
+        // no name
+        let newProjectName = ''
+        input = screen.getByLabelText('New Name') as HTMLButtonElement
+        fireEvent.change(input, {
+          target: { value: newProjectName },
+        })
+        confirmButton = within(modal).getByText<HTMLInputElement>('Rename')
+        expect(confirmButton.disabled).to.be.true
+
+        // a valid name
+        newProjectName = 'A new project name'
+        input = screen.getByLabelText('New Name') as HTMLButtonElement
+        fireEvent.change(input, {
+          target: { value: newProjectName },
+        })
+
+        confirmButton = within(modal).getByText<HTMLInputElement>('Rename')
+        expect(confirmButton.disabled).to.be.false
+        fireEvent.click(confirmButton)
+
+        await fetchMock.flush(true)
+        expect(fetchMock.done()).to.be.true
+
+        screen.findByText(newProjectName)
+        expect(screen.queryByText(oldName)).to.be.null
+
+        const allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
+        const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
+        expect(allCheckboxesChecked.length).to.equal(0)
+      })
+    })
   })
 
   describe('search', function () {
     it('shows only projects based on the input', async function () {
-      await fetchMock.flush(true)
-      await waitFor(() => {
-        screen.findByRole('table')
-      })
-
       const input = screen.getAllByRole('textbox', {
         name: /search projects/i,
       })[0]

+ 78 - 0
services/web/test/frontend/features/project-list/components/table/project-tools/rename-project-modal.test.tsx

@@ -0,0 +1,78 @@
+import { fireEvent, screen, within } from '@testing-library/react'
+import { expect } from 'chai'
+import RenameProjectModal from '../../../../../../../frontend/js/features/project-list/components/modals/rename-project-modal'
+import {
+  renderWithProjectListContext,
+  resetProjectListContextFetch,
+} from '../../../helpers/render-with-context'
+import { currentProjects } from '../../../fixtures/projects-data'
+import fetchMock from 'fetch-mock'
+
+describe('<RenameProjectModal />', function () {
+  afterEach(function () {
+    resetProjectListContextFetch()
+  })
+
+  it('renders the modal and validates new name', async function () {
+    fetchMock.post('express:/project/:projectId/rename', {
+      status: 200,
+    })
+    renderWithProjectListContext(
+      <RenameProjectModal
+        handleCloseModal={() => {}}
+        showModal
+        project={currentProjects[0]}
+      />
+    )
+    screen.getByText('Rename Project')
+    const input = screen.getByLabelText('New Name') as HTMLButtonElement
+    expect(input.value).to.equal(currentProjects[0].name)
+
+    const submitButton = screen.getByText('Rename') as HTMLButtonElement
+    expect(submitButton.disabled).to.be.true
+
+    fireEvent.change(input, {
+      target: { value: '' },
+    })
+    expect(submitButton.disabled).to.be.true
+
+    fireEvent.change(input, {
+      target: { value: 'A new name' },
+    })
+    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
+  })
+
+  it('shows error message from API', async function () {
+    fetchMock.post('express:/project/:projectId/rename', {
+      status: 500,
+    })
+    renderWithProjectListContext(
+      <RenameProjectModal
+        handleCloseModal={() => {}}
+        showModal
+        project={currentProjects[0]}
+      />
+    )
+    screen.getByText('Rename Project')
+    const input = screen.getByLabelText('New Name') as HTMLButtonElement
+    expect(input.value).to.equal(currentProjects[0].name)
+
+    fireEvent.change(input, {
+      target: { value: 'A new name' },
+    })
+    const modal = screen.getAllByRole('dialog')[0]
+    const submitButton = within(modal).getByText('Rename') as HTMLButtonElement
+    fireEvent.click(submitButton)
+
+    await fetchMock.flush(true)
+    expect(fetchMock.done()).to.be.true
+
+    screen.getByText('Something went wrong. Please try again.')
+  })
+})

+ 1 - 0
services/web/test/frontend/features/project-list/fixtures/projects-data.ts

@@ -145,6 +145,7 @@ export const makeLongProjectList = (listLength: number) => {
   while (longList.length < listLength) {
     longList.push(
       Object.assign({}, copyableProject, {
+        name: `Report (${longList.length})`,
         id: `newProjectId${longList.length}`,
       })
     )