diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 3b3a4a4efa..8c258af5af 100644 --- a/services/web/frontend/extracted-translations.json +++ b/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": "", diff --git a/services/web/frontend/js/features/project-list/components/modals/rename-project-modal.tsx b/services/web/frontend/js/features/project-list/components/modals/rename-project-modal.tsx new file mode 100644 index 0000000000..5cd1056099 --- /dev/null +++ b/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 + ) => { + setNewProjectName(event.target.value) + } + + return ( + + + {t('rename_project')} + + +
+ + + {t('new_name')} + + + + +
+
+ + {isError && ( + + {getUserFacingMessage(error)} + + )} + + + +
+ ) +} + +export default memo(RenameProjectModal) diff --git a/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/project-tools-more-dropdown-button.tsx b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/project-tools-more-dropdown-button.tsx new file mode 100644 index 0000000000..af0270baf0 --- /dev/null +++ b/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 ( + + {t('more')} + + + + + ) +} + +export default memo(ProjectToolsMoreDropdownButton) diff --git a/services/web/frontend/js/features/project-list/components/table/project-tools/menu-items/rename-project-menu-item.tsx b/services/web/frontend/js/features/project-list/components/table/project-tools/menu-items/rename-project-menu-item.tsx new file mode 100644 index 0000000000..fb4dc97217 --- /dev/null +++ b/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 ( + <> + {t('rename')} + + + ) +} + +export default memo(RenameProjectMenuItem) diff --git a/services/web/frontend/js/features/project-list/components/table/project-tools/project-tools.tsx b/services/web/frontend/js/features/project-list/components/table/project-tools/project-tools.tsx index 5dcbaef57a..7798abd052 100644 --- a/services/web/frontend/js/features/project-list/components/table/project-tools/project-tools.tsx +++ b/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 ( @@ -21,6 +22,10 @@ function ProjectTools() { {filter === 'trashed' && } {filter === 'archived' && } + + {selectedProjects.length === 1 && + filter !== 'archived' && + filter !== 'trashed' && } ) } diff --git a/services/web/frontend/js/features/project-list/util/api.ts b/services/web/frontend/js/features/project-list/util/api.ts index fedcd86af5..129664ec0d 100644 --- a/services/web/frontend/js/features/project-list/util/api.ts +++ b/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`) } diff --git a/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx b/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx index 208d5a0cef..e9432793fe 100644 --- a/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx +++ b/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('', 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('', function () { }) afterEach(function () { + sendSpy.restore() window.user_id = undefined fetchMock.reset() Object.defineProperty(window, 'location', { @@ -316,15 +321,86 @@ describe('', 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('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('More') + }) + fireEvent.click(allCheckboxes[0]) + expect(within(actionsToolbar).queryByText('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('More') + fireEvent.click(moreDropdown) + }) + + const renameButton = screen.getByText('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('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('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('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('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] diff --git a/services/web/test/frontend/features/project-list/components/table/project-tools/rename-project-modal.test.tsx b/services/web/test/frontend/features/project-list/components/table/project-tools/rename-project-modal.test.tsx new file mode 100644 index 0000000000..8ff9e9c6b5 --- /dev/null +++ b/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('', function () { + afterEach(function () { + resetProjectListContextFetch() + }) + + it('renders the modal and validates new name', async function () { + fetchMock.post('express:/project/:projectId/rename', { + status: 200, + }) + renderWithProjectListContext( + {}} + 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( + {}} + 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.') + }) +}) diff --git a/services/web/test/frontend/features/project-list/fixtures/projects-data.ts b/services/web/test/frontend/features/project-list/fixtures/projects-data.ts index 9f603d6c54..5e7087415a 100644 --- a/services/web/test/frontend/features/project-list/fixtures/projects-data.ts +++ b/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}`, }) )