mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-07 20:31:06 -05:00
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:
parent
30fd0bfc9d
commit
8b19b6107a
9 changed files with 365 additions and 6 deletions
|
@ -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": "",
|
||||
|
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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`)
|
||||
}
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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.')
|
||||
})
|
||||
})
|
|
@ -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}`,
|
||||
})
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue