diff --git a/services/web/frontend/js/features/project-list/components/modals/projects-action-modal.tsx b/services/web/frontend/js/features/project-list/components/modals/projects-action-modal.tsx index 7c9df72b77..b542cad40a 100644 --- a/services/web/frontend/js/features/project-list/components/modals/projects-action-modal.tsx +++ b/services/web/frontend/js/features/project-list/components/modals/projects-action-modal.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useState } from 'react' +import { memo, useEffect, useRef, useState } from 'react' import { Alert, Modal } from 'react-bootstrap' import { useTranslation } from 'react-i18next' import { Project } from '../../../../../../types/project/dashboard/api' @@ -31,6 +31,8 @@ function ProjectsActionModal({ const { t } = useTranslation() const [errors, setErrors] = useState>([]) const [isProcessing, setIsProcessing] = useState(false) + const [projectsToDisplay, setProjectsToDisplay] = useState([]) + const projectsRef = useRef([]) const isMounted = useIsMounted() async function handleActionForProjects(projects: Array) { @@ -64,8 +66,21 @@ function ProjectsActionModal({ 'project action', action ) + + if (projectsRef.current.length > 0) { + // maintain the original list in the display of the modal, + // even after some project actions have completed + setProjectsToDisplay(projectsRef.current) + } else { + projectsRef.current = projects + setProjectsToDisplay(projects) + } + } else { + projectsRef.current = [] } - }, [action, showModal]) + }, [action, projects, projectsRef, showModal]) + + const projectIdsRemainingToProcess = projects.map(p => p.id) return ( {bodyTop} -
    - {projects.map(project => ( -
  • +
      + {projectsToDisplay.map(project => ( +
    • {project.name}
    • ))}
    + {bodyBottom} diff --git a/services/web/frontend/stylesheets/app/project-list-react.less b/services/web/frontend/stylesheets/app/project-list-react.less index b340aa7ef5..47705ea6df 100644 --- a/services/web/frontend/stylesheets/app/project-list-react.less +++ b/services/web/frontend/stylesheets/app/project-list-react.less @@ -707,3 +707,23 @@ flex: @project-list-sidebar-wrapper-flex; } } + +.projects-action-list { + list-style: none; + + li:not(.completed-project-action) { + list-style-type: disc; + } + + li.completed-project-action { + &:before { + display: inline-block; + content: '\f00c'; + font-family: FontAwesome; + color: @ol-green; + width: 1.2em; + margin-left: -1.2em; + height: 0; // prevent slight vertical layout shift + } + } +} 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 38c6d8de34..ecc46483e5 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 @@ -640,6 +640,91 @@ describe('', function () { screen.getByText(copiedProjectName) }) }) + + describe('projects list in actions modal', function () { + let modal: HTMLElement + let projectsToProcess: any[] + + function selectedProjectNames() { + projectsToProcess = [] + // needs to be done ahead of opening modal + + allCheckboxes = screen.getAllByRole('checkbox') + // update list so we know which are checked + + const tableRows = screen.getAllByRole('row') + + for (const [index, checkbox] of allCheckboxes.entries()) { + if (checkbox.checked) { + const linkForProjectToCopy = within(tableRows[index]).getByRole( + 'link' + ) + const projectNameToCopy = linkForProjectToCopy.textContent + projectsToProcess.push(projectNameToCopy) + } + } + + expect(projectsToProcess.length > 0).to.be.true + } + + function selectedMatchesDisplayed(expectedLength: number) { + selectedProjectNames() + // any action will work for check since they all use the same modal + const archiveButton = within(actionsToolbar).getByLabelText('Archive') + fireEvent.click(archiveButton) + modal = screen.getAllByRole('dialog')[0] + + const listitems = within(modal).getAllByRole('listitem') + expect(listitems.length).to.equal(projectsToProcess.length) + expect(listitems.length).to.equal(expectedLength) + + for (const projectName of projectsToProcess) { + within(modal).getByText(projectName) + } + } + + beforeEach(function () { + allCheckboxes = screen.getAllByRole('checkbox') + // first one is the select all checkbox, just check 2 at first + fireEvent.click(allCheckboxes[1]) + fireEvent.click(allCheckboxes[2]) + + actionsToolbar = screen.getAllByRole('toolbar')[0] + }) + + it('opens the modal with the 2 originally selected projects', function () { + selectedMatchesDisplayed(2) + }) + + it('shows correct list after closing modal, changing selecting, and reopening modal', async function () { + selectedMatchesDisplayed(2) + + const cancelButton = screen.getByRole('button', { name: 'Cancel' }) + fireEvent.click(cancelButton) + expect(screen.queryByRole('dialog', { hidden: false })).to.be.null + await screen.findAllByRole('checkbox') + fireEvent.click(allCheckboxes[3]) + selectedMatchesDisplayed(3) + }) + + it('maintains original list even after some have been processed', async function () { + const totalProjectsToProcess = 2 + selectedMatchesDisplayed(totalProjectsToProcess) + const button = screen.getByRole('button', { name: 'Confirm' }) + fireEvent.click(button) + project1Id = allCheckboxes[1].getAttribute('data-project-id') + fetchMock.post('express:/project/:id/archive', { + status: 200, + }) + fetchMock.post(`express:/${project2Id}/:id/archive`, { + status: 500, + }) + + await screen.findByRole('alert') // ensure that error was thrown for the 2nd project + const listitems = within(modal).getAllByRole('listitem') + expect(listitems.length).to.equal(totalProjectsToProcess) + }) + }) }) describe('search', function () {