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

[web] Add rename option to project tools

GitOrigin-RevId: 5bf622609e612e27c77c4e5e11d64fdad1bb47b4
This commit is contained in:
Jessica Lawshe 2022-10-03 08:20:23 -05:00 committed by Copybot
parent 30fd0bfc9d
commit 8b19b6107a
9 changed files with 365 additions and 6 deletions

View file

@ -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": "",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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]

View file

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

View file

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