From 7650e0607408fecdbd3a12b5d8f91ff49921cbec Mon Sep 17 00:00:00 2001 From: ilkin-overleaf <100852799+ilkin-overleaf@users.noreply.github.com> Date: Thu, 8 Dec 2022 12:46:34 +0200 Subject: [PATCH] Merge pull request #10802 from overleaf/ii-dashboard-leave-button [web] Project dashboard leave button GitOrigin-RevId: 6c472ffe9b3d07f103f32e07fec9996a6d45caef --- .../web/frontend/extracted-translations.json | 2 + .../modals/archive-project-modal.tsx | 43 ++-- .../modals/delete-leave-project-modal.tsx | 81 ++++++ .../modals/delete-project-modal.tsx | 31 ++- .../components/modals/leave-project-modal.tsx | 31 ++- .../modals/projects-action-modal.tsx | 47 +--- .../components/modals/projects-list.tsx | 28 +++ .../components/modals/trash-project-modal.tsx | 43 ++-- .../buttons/archive-projects-button.tsx | 24 +- .../buttons/delete-leave-projects-button.tsx | 58 +++++ .../buttons/delete-projects-button.tsx | 54 ++++ .../buttons/leave-projects-button.tsx | 54 ++++ .../buttons/trash-projects-button.tsx | 24 +- .../table/project-tools/project-tools.tsx | 13 + .../context/project-list-context.tsx | 33 ++- .../js/features/project-list/util/project.ts | 8 + .../components/project-list-root.test.tsx | 232 ++++++++++++++++-- .../delete-leave-projects-button.test.tsx | 29 +++ .../buttons/delete-projects-button.test.tsx | 29 +++ .../buttons/leave-projects-button.test.tsx | 28 +++ .../table/projects-action-modal.test.tsx | 2 +- .../project-list/fixtures/projects-data.ts | 7 + 22 files changed, 762 insertions(+), 139 deletions(-) create mode 100644 services/web/frontend/js/features/project-list/components/modals/delete-leave-project-modal.tsx create mode 100644 services/web/frontend/js/features/project-list/components/modals/projects-list.tsx create mode 100644 services/web/frontend/js/features/project-list/components/table/project-tools/buttons/delete-leave-projects-button.tsx create mode 100644 services/web/frontend/js/features/project-list/components/table/project-tools/buttons/delete-projects-button.tsx create mode 100644 services/web/frontend/js/features/project-list/components/table/project-tools/buttons/leave-projects-button.tsx create mode 100644 services/web/test/frontend/features/project-list/components/table/project-tools/buttons/delete-leave-projects-button.test.tsx create mode 100644 services/web/test/frontend/features/project-list/components/table/project-tools/buttons/delete-projects-button.test.tsx create mode 100644 services/web/test/frontend/features/project-list/components/table/project-tools/buttons/leave-projects-button.test.tsx diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index cc30096efd..499b51298e 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -128,6 +128,8 @@ "delete_account_confirmation_label": "", "delete_account_warning_message_3": "", "delete_acct_no_existing_pw": "", + "delete_and_leave": "", + "delete_and_leave_projects": "", "delete_folder": "", "delete_projects": "", "delete_your_account": "", diff --git a/services/web/frontend/js/features/project-list/components/modals/archive-project-modal.tsx b/services/web/frontend/js/features/project-list/components/modals/archive-project-modal.tsx index da03aafdcb..8ad54b8781 100644 --- a/services/web/frontend/js/features/project-list/components/modals/archive-project-modal.tsx +++ b/services/web/frontend/js/features/project-list/components/modals/archive-project-modal.tsx @@ -1,5 +1,7 @@ +import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import ProjectsActionModal from './projects-action-modal' +import ProjectsList from './projects-list' type ArchiveProjectModalProps = Pick< React.ComponentProps, @@ -13,29 +15,42 @@ function ArchiveProjectModal({ handleCloseModal, }: ArchiveProjectModalProps) { const { t } = useTranslation() + const [projectsToDisplay, setProjectsToDisplay] = useState( + [] + ) + + useEffect(() => { + if (showModal) { + setProjectsToDisplay(displayProjects => { + return displayProjects.length ? displayProjects : projects + }) + } else { + setProjectsToDisplay([]) + } + }, [showModal, projects]) return ( {t('about_to_archive_projects')}

} - bodyBottom={ -

- {t('archiving_projects_wont_affect_collaborators')}{' '} - - {t('find_out_more_nt')} - -

- } showModal={showModal} handleCloseModal={handleCloseModal} projects={projects} - /> + > +

{t('about_to_archive_projects')}

+ +

+ {t('archiving_projects_wont_affect_collaborators')}{' '} + + {t('find_out_more_nt')} + +

+
) } diff --git a/services/web/frontend/js/features/project-list/components/modals/delete-leave-project-modal.tsx b/services/web/frontend/js/features/project-list/components/modals/delete-leave-project-modal.tsx new file mode 100644 index 0000000000..4d870055f7 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/modals/delete-leave-project-modal.tsx @@ -0,0 +1,81 @@ +import { useEffect, useState, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import ProjectsActionModal from './projects-action-modal' +import Icon from '../../../../shared/components/icon' +import ProjectsList from './projects-list' +import { isLeavableProject, isDeletableProject } from '../../util/project' + +type DeleteLeaveProjectModalProps = Pick< + React.ComponentProps, + 'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal' +> + +function DeleteLeaveProjectModal({ + projects, + actionHandler, + showModal, + handleCloseModal, +}: DeleteLeaveProjectModalProps) { + const { t } = useTranslation() + const [projectsToDeleteDisplay, setProjectsToDeleteDisplay] = useState< + typeof projects + >([]) + const [projectsToLeaveDisplay, setProjectsToLeaveDisplay] = useState< + typeof projects + >([]) + + const projectsToDelete = useMemo(() => { + return projects.filter(isDeletableProject) + }, [projects]) + const projectsToLeave = useMemo(() => { + return projects.filter(isLeavableProject) + }, [projects]) + + useEffect(() => { + if (showModal) { + setProjectsToDeleteDisplay(displayProjects => { + return displayProjects.length ? displayProjects : projectsToDelete + }) + } else { + setProjectsToDeleteDisplay([]) + } + }, [showModal, projectsToDelete]) + + useEffect(() => { + if (showModal) { + setProjectsToLeaveDisplay(displayProjects => { + return displayProjects.length ? displayProjects : projectsToLeave + }) + } else { + setProjectsToLeaveDisplay([]) + } + }, [showModal, projectsToLeave]) + + return ( + +

{t('about_to_delete_projects')}

+ +

{t('about_to_leave_projects')}

+ +
+ {' '} + {t('this_action_cannot_be_undone')} +
+
+ ) +} + +export default DeleteLeaveProjectModal diff --git a/services/web/frontend/js/features/project-list/components/modals/delete-project-modal.tsx b/services/web/frontend/js/features/project-list/components/modals/delete-project-modal.tsx index 4fb6260f4c..c2fa587b81 100644 --- a/services/web/frontend/js/features/project-list/components/modals/delete-project-modal.tsx +++ b/services/web/frontend/js/features/project-list/components/modals/delete-project-modal.tsx @@ -1,6 +1,8 @@ +import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import ProjectsActionModal from './projects-action-modal' import Icon from '../../../../shared/components/icon' +import ProjectsList from './projects-list' type DeleteProjectModalProps = Pick< React.ComponentProps, @@ -14,23 +16,36 @@ function DeleteProjectModal({ handleCloseModal, }: DeleteProjectModalProps) { const { t } = useTranslation() + const [projectsToDisplay, setProjectsToDisplay] = useState( + [] + ) + + useEffect(() => { + if (showModal) { + setProjectsToDisplay(displayProjects => { + return displayProjects.length ? displayProjects : projects + }) + } else { + setProjectsToDisplay([]) + } + }, [showModal, projects]) return ( {t('about_to_delete_projects')}

} - bodyBottom={ -
- {' '} - {t('this_action_cannot_be_undone')} -
- } showModal={showModal} handleCloseModal={handleCloseModal} projects={projects} - /> + > +

{t('about_to_delete_projects')}

+ +
+ {' '} + {t('this_action_cannot_be_undone')} +
+
) } diff --git a/services/web/frontend/js/features/project-list/components/modals/leave-project-modal.tsx b/services/web/frontend/js/features/project-list/components/modals/leave-project-modal.tsx index f708caa01d..6a26bd32cd 100644 --- a/services/web/frontend/js/features/project-list/components/modals/leave-project-modal.tsx +++ b/services/web/frontend/js/features/project-list/components/modals/leave-project-modal.tsx @@ -1,6 +1,8 @@ +import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import ProjectsActionModal from './projects-action-modal' import Icon from '../../../../shared/components/icon' +import ProjectsList from './projects-list' type LeaveProjectModalProps = Pick< React.ComponentProps, @@ -14,23 +16,36 @@ function LeaveProjectModal({ handleCloseModal, }: LeaveProjectModalProps) { const { t } = useTranslation() + const [projectsToDisplay, setProjectsToDisplay] = useState( + [] + ) + + useEffect(() => { + if (showModal) { + setProjectsToDisplay(displayProjects => { + return displayProjects.length ? displayProjects : projects + }) + } else { + setProjectsToDisplay([]) + } + }, [showModal, projects]) return ( {t('about_to_leave_projects')}

} - bodyBottom={ -
- {' '} - {t('this_action_cannot_be_undone')} -
- } showModal={showModal} handleCloseModal={handleCloseModal} projects={projects} - /> + > +

{t('about_to_leave_projects')}

+ +
+ {' '} + {t('this_action_cannot_be_undone')} +
+
) } 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 60aeb1198c..03f3289281 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, useRef, useState } from 'react' +import { memo, useEffect, useState } from 'react' import { Alert, Modal } from 'react-bootstrap' import { useTranslation } from 'react-i18next' import { Project } from '../../../../../../types/project/dashboard/api' @@ -9,13 +9,12 @@ import * as eventTracking from '../../../../infrastructure/event-tracking' type ProjectsActionModalProps = { title?: string - action: 'archive' | 'trash' | 'delete' | 'leave' + action: 'archive' | 'trash' | 'delete' | 'leave' | 'leaveOrDelete' actionHandler: (project: Project) => Promise handleCloseModal: () => void - bodyTop?: React.ReactNode - bodyBottom?: React.ReactNode projects: Array showModal: boolean + children?: React.ReactNode } function ProjectsActionModal({ @@ -23,16 +22,13 @@ function ProjectsActionModal({ action, actionHandler, handleCloseModal, - bodyTop, - bodyBottom, showModal, projects, + children, }: ProjectsActionModalProps) { 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) { @@ -66,21 +62,8 @@ 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, projects, projectsRef, showModal]) - - const projectIdsRemainingToProcess = projects.map(p => p.id) + }, [action, showModal]) return ( {title} - - {bodyTop} -
    - {projectsToDisplay.map(project => ( -
  • - {project.name} -
  • - ))} -
- - {bodyBottom} -
+ {children} {!isProcessing && errors.length > 0 && diff --git a/services/web/frontend/js/features/project-list/components/modals/projects-list.tsx b/services/web/frontend/js/features/project-list/components/modals/projects-list.tsx new file mode 100644 index 0000000000..77592d7057 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/modals/projects-list.tsx @@ -0,0 +1,28 @@ +import classnames from 'classnames' +import { Project } from '../../../../../../types/project/dashboard/api' + +type ProjectsToDisplayProps = { + projects: Project[] + projectsToDisplay: Project[] +} + +function ProjectsList({ projects, projectsToDisplay }: ProjectsToDisplayProps) { + return ( +
    + {projectsToDisplay.map(project => ( +
  • id === project.id + ), + })} + > + {project.name} +
  • + ))} +
+ ) +} + +export default ProjectsList diff --git a/services/web/frontend/js/features/project-list/components/modals/trash-project-modal.tsx b/services/web/frontend/js/features/project-list/components/modals/trash-project-modal.tsx index 7a3962e937..5c882343dc 100644 --- a/services/web/frontend/js/features/project-list/components/modals/trash-project-modal.tsx +++ b/services/web/frontend/js/features/project-list/components/modals/trash-project-modal.tsx @@ -1,5 +1,7 @@ +import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import ProjectsActionModal from './projects-action-modal' +import ProjectsList from './projects-list' type TrashProjectPropsModalProps = Pick< React.ComponentProps, @@ -13,29 +15,42 @@ function TrashProjectModal({ handleCloseModal, }: TrashProjectPropsModalProps) { const { t } = useTranslation() + const [projectsToDisplay, setProjectsToDisplay] = useState( + [] + ) + + useEffect(() => { + if (showModal) { + setProjectsToDisplay(displayProjects => { + return displayProjects.length ? displayProjects : projects + }) + } else { + setProjectsToDisplay([]) + } + }, [showModal, projects]) return ( {t('about_to_trash_projects')}

} - bodyBottom={ -

- {t('trashing_projects_wont_affect_collaborators')}{' '} - - {t('find_out_more_nt')} - -

- } showModal={showModal} handleCloseModal={handleCloseModal} projects={projects} - /> + > +

{t('about_to_trash_projects')}

+ +

+ {t('trashing_projects_wont_affect_collaborators')}{' '} + + {t('find_out_more_nt')} + +

+
) } diff --git a/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/archive-projects-button.tsx b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/archive-projects-button.tsx index bd6e714131..14897b2a8b 100644 --- a/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/archive-projects-button.tsx +++ b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/archive-projects-button.tsx @@ -6,6 +6,7 @@ import ArchiveProjectModal from '../../../modals/archive-project-modal' import useIsMounted from '../../../../../../shared/hooks/use-is-mounted' import { useProjectListContext } from '../../../../context/project-list-context' import { archiveProject } from '../../../../util/api' +import { Project } from '../../../../../../../../types/project/dashboard/api' function ArchiveProjectsButton() { const { selectedProjects, updateProjectViewData } = useProjectListContext() @@ -25,17 +26,16 @@ function ArchiveProjectsButton() { } }, [isMounted]) - const handleArchiveProjects = useCallback(async () => { - for (const project of selectedProjects) { - await archiveProject(project.id) - updateProjectViewData({ - ...project, - archived: true, - selected: false, - trashed: false, - }) - } - }, [selectedProjects, updateProjectViewData]) + const handleArchiveProject = async (project: Project) => { + await archiveProject(project.id) + + updateProjectViewData({ + ...project, + archived: true, + selected: false, + trashed: false, + }) + } return ( <> @@ -54,7 +54,7 @@ function ArchiveProjectsButton() { diff --git a/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/delete-leave-projects-button.tsx b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/delete-leave-projects-button.tsx new file mode 100644 index 0000000000..705579a123 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/delete-leave-projects-button.tsx @@ -0,0 +1,58 @@ +import { useState } from 'react' +import { Button } from 'react-bootstrap' +import { useTranslation } from 'react-i18next' +import DeleteLeaveProjectModal from '../../../modals/delete-leave-project-modal' +import useIsMounted from '../../../../../../shared/hooks/use-is-mounted' +import { useProjectListContext } from '../../../../context/project-list-context' +import { deleteProject, leaveProject } from '../../../../util/api' +import { Project } from '../../../../../../../../types/project/dashboard/api' + +function DeleteLeaveProjectsButton() { + const { t } = useTranslation() + const { + selectedProjects, + removeProjectFromView, + hasLeavableProjectsSelected, + hasDeletableProjectsSelected, + } = useProjectListContext() + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = () => { + setShowModal(true) + } + + const handleCloseModal = () => { + if (isMounted.current) { + setShowModal(false) + } + } + + const handleDeleteAndLeaveProject = async (project: Project) => { + if (project.accessLevel === 'owner') { + await deleteProject(project.id) + } else { + await leaveProject(project.id) + } + + removeProjectFromView(project) + } + + return ( + <> + {hasDeletableProjectsSelected && hasLeavableProjectsSelected && ( + + )} + + + ) +} + +export default DeleteLeaveProjectsButton diff --git a/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/delete-projects-button.tsx b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/delete-projects-button.tsx new file mode 100644 index 0000000000..3ff3fb1da5 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/delete-projects-button.tsx @@ -0,0 +1,54 @@ +import { useState } from 'react' +import { Button } from 'react-bootstrap' +import { useTranslation } from 'react-i18next' +import DeleteProjectModal from '../../../modals/delete-project-modal' +import useIsMounted from '../../../../../../shared/hooks/use-is-mounted' +import { useProjectListContext } from '../../../../context/project-list-context' +import { deleteProject } from '../../../../util/api' +import { Project } from '../../../../../../../../types/project/dashboard/api' + +function DeleteProjectsButton() { + const { t } = useTranslation() + const { + selectedProjects, + removeProjectFromView, + hasLeavableProjectsSelected, + hasDeletableProjectsSelected, + } = useProjectListContext() + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = () => { + setShowModal(true) + } + + const handleCloseModal = () => { + if (isMounted.current) { + setShowModal(false) + } + } + + const handleDeleteProject = async (project: Project) => { + await deleteProject(project.id) + + removeProjectFromView(project) + } + + return ( + <> + {hasDeletableProjectsSelected && !hasLeavableProjectsSelected && ( + + )} + + + ) +} + +export default DeleteProjectsButton diff --git a/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/leave-projects-button.tsx b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/leave-projects-button.tsx new file mode 100644 index 0000000000..a8e65186ff --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/leave-projects-button.tsx @@ -0,0 +1,54 @@ +import { useState } from 'react' +import { Button } from 'react-bootstrap' +import { useTranslation } from 'react-i18next' +import LeaveProjectModal from '../../../modals/leave-project-modal' +import useIsMounted from '../../../../../../shared/hooks/use-is-mounted' +import { useProjectListContext } from '../../../../context/project-list-context' +import { leaveProject } from '../../../../util/api' +import { Project } from '../../../../../../../../types/project/dashboard/api' + +function LeaveProjectsButton() { + const { t } = useTranslation() + const { + selectedProjects, + removeProjectFromView, + hasLeavableProjectsSelected, + hasDeletableProjectsSelected, + } = useProjectListContext() + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = () => { + setShowModal(true) + } + + const handleCloseModal = () => { + if (isMounted.current) { + setShowModal(false) + } + } + + const handleLeaveProject = async (project: Project) => { + await leaveProject(project.id) + + removeProjectFromView(project) + } + + return ( + <> + {!hasDeletableProjectsSelected && hasLeavableProjectsSelected && ( + + )} + + + ) +} + +export default LeaveProjectsButton diff --git a/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/trash-projects-button.tsx b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/trash-projects-button.tsx index c190f86521..b57d5cb120 100644 --- a/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/trash-projects-button.tsx +++ b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/trash-projects-button.tsx @@ -6,6 +6,7 @@ import TrashProjectModal from '../../../modals/trash-project-modal' import useIsMounted from '../../../../../../shared/hooks/use-is-mounted' import { useProjectListContext } from '../../../../context/project-list-context' import { trashProject } from '../../../../util/api' +import { Project } from '../../../../../../../../types/project/dashboard/api' function TrashProjectsButton() { const { selectedProjects, updateProjectViewData } = useProjectListContext() @@ -25,17 +26,16 @@ function TrashProjectsButton() { } }, [isMounted]) - const handleTrashProjects = useCallback(async () => { - for (const project of selectedProjects) { - await trashProject(project.id) - updateProjectViewData({ - ...project, - trashed: true, - archived: false, - selected: false, - }) - } - }, [selectedProjects, updateProjectViewData]) + const handleTrashProject = async (project: Project) => { + await trashProject(project.id) + + updateProjectViewData({ + ...project, + trashed: true, + archived: false, + selected: false, + }) + } return ( <> @@ -54,7 +54,7 @@ function TrashProjectsButton() { 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 68dee26ac8..db7d459f81 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 @@ -8,6 +8,9 @@ import TagsDropdown from './buttons/tags-dropdown' import TrashProjectsButton from './buttons/trash-projects-button' import UnarchiveProjectsButton from './buttons/unarchive-projects-button' import UntrashProjectsButton from './buttons/untrash-projects-button' +import DeleteLeaveProjectsButton from './buttons/delete-leave-projects-button' +import LeaveProjectsButton from './buttons/leave-projects-button' +import DeleteProjectsButton from './buttons/delete-projects-button' function ProjectTools() { const { filter, selectedProjects } = useProjectListContext() @@ -24,6 +27,16 @@ function ProjectTools() { {filter === 'archived' && } + + {filter === 'trashed' && ( + <> + + + + + )} + + {!['archived', 'trashed'].includes(filter) && } {selectedProjects.length === 1 && diff --git a/services/web/frontend/js/features/project-list/context/project-list-context.tsx b/services/web/frontend/js/features/project-list/context/project-list-context.tsx index e43afeffc1..9079c71579 100644 --- a/services/web/frontend/js/features/project-list/context/project-list-context.tsx +++ b/services/web/frontend/js/features/project-list/context/project-list-context.tsx @@ -29,6 +29,7 @@ import getMeta from '../../../utils/meta' import useAsync from '../../../shared/hooks/use-async' import { getProjects } from '../util/api' import sortProjects from '../util/sort-projects' +import { isDeletableProject, isLeavableProject } from '../util/project' const MAX_PROJECT_PER_PAGE = 20 @@ -62,7 +63,7 @@ const filters: FilterMap = { export const UNCATEGORIZED_KEY = 'uncategorized' -type ProjectListContextValue = { +export type ProjectListContextValue = { addClonedProjectToViewData: (project: Project) => void selectOrUnselectAllProjects: React.Dispatch> visibleProjects: Project[] @@ -92,6 +93,8 @@ type ProjectListContextValue = { loadMoreCount: number showAllProjects: () => void loadMoreProjects: () => void + hasLeavableProjectsSelected: boolean + hasDeletableProjectsSelected: boolean } export const ProjectListContext = createContext< @@ -375,20 +378,26 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) { const updateProjectViewData = useCallback((newProjectData: Project) => { setLoadedProjects(loadedProjects => { - return loadedProjects.map((p: Project) => + return loadedProjects.map(p => p.id === newProjectData.id ? { ...newProjectData } : p ) }) }, []) - const removeProjectFromView = useCallback( - (project: Project) => { - const projects = loadedProjects.filter( - (p: Project) => p.id !== project.id - ) - setLoadedProjects(projects) - }, - [loadedProjects] + const removeProjectFromView = useCallback((project: Project) => { + setLoadedProjects(loadedProjects => { + return loadedProjects.filter(p => p.id !== project.id) + }) + }, []) + + const hasLeavableProjectsSelected = useMemo( + () => selectedProjects.some(isLeavableProject), + [selectedProjects] + ) + + const hasDeletableProjectsSelected = useMemo( + () => selectedProjects.some(isDeletableProject), + [selectedProjects] ) const value = useMemo( @@ -399,6 +408,8 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) { deleteTag, error, filter, + hasLeavableProjectsSelected, + hasDeletableProjectsSelected, hiddenProjectsCount, isLoading, loadMoreCount, @@ -430,6 +441,8 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) { deleteTag, error, filter, + hasLeavableProjectsSelected, + hasDeletableProjectsSelected, hiddenProjectsCount, isLoading, loadMoreCount, diff --git a/services/web/frontend/js/features/project-list/util/project.ts b/services/web/frontend/js/features/project-list/util/project.ts index f2ecd967b0..d005ff4be3 100644 --- a/services/web/frontend/js/features/project-list/util/project.ts +++ b/services/web/frontend/js/features/project-list/util/project.ts @@ -12,3 +12,11 @@ export function getOwnerName(project: Project) { return '' } + +export function isDeletableProject(project: Project) { + return project.accessLevel === 'owner' && project.trashed +} + +export function isLeavableProject(project: Project) { + return project.accessLevel !== 'owner' && project.trashed +} 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 f6533a84fc..78b0305c7b 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 @@ -11,7 +11,9 @@ import { archivedProjects, makeLongProjectList, } from '../fixtures/projects-data' -const { fullList, currentList, trashedList } = makeLongProjectList(40) + +const { fullList, currentList, trashedList, leavableList, deletableList } = + makeLongProjectList(40) const userId = owner.id @@ -78,9 +80,10 @@ describe('', function () { describe('project table', function () { beforeEach(async function () { - renderWithProjectListContext(, { + const { unmount } = renderWithProjectListContext(, { projects: fullList, }) + this.unmount = unmount await fetchMock.flush(true) await screen.findByRole('table') }) @@ -240,7 +243,7 @@ describe('', function () { }) const unarchiveButton = - within(actionsToolbar).getByText('Restore') + within(actionsToolbar).getByText('Restore') fireEvent.click(unarchiveButton) await fetchMock.flush(true) @@ -284,7 +287,7 @@ describe('', function () { actionsToolbar = screen.getAllByRole('toolbar')[0] }) - it('only shows the download, archive, and restore buttons in top toolbar', function () { + it('shows the download, archive, and restore buttons in top toolbar', function () { expect(screen.queryByLabelText('Trash')).to.be.null within(actionsToolbar).queryByLabelText('Download') within(actionsToolbar).queryByLabelText('Archive') @@ -307,7 +310,7 @@ describe('', function () { }) const untrashButton = - within(actionsToolbar).getByText('Restore') + within(actionsToolbar).getByText('Restore') fireEvent.click(untrashButton) await fetchMock.flush(true) @@ -330,22 +333,213 @@ describe('', function () { }) it('removes project from view when archiving', async function () { - fetchMock.post(`express:/project/:id/archive`, { - status: 200, - }) + fetchMock.post( + `express:/project/:id/archive`, + { + status: 200, + }, + { repeat: trashedList.length } + ) - const untrashButton = - within(actionsToolbar).getByLabelText('Archive') - fireEvent.click(untrashButton) + const archiveButton = + within(actionsToolbar).getByLabelText('Archive') + fireEvent.click(archiveButton) - const confirmButton = screen.getByText('Confirm') + const confirmButton = screen.getByText('Confirm') fireEvent.click(confirmButton) expect(confirmButton.disabled).to.be.true await fetchMock.flush(true) expect(fetchMock.done()).to.be.true - screen.getByText('No projects') + const calls = fetchMock.calls().map(([url]) => url) + + trashedList.forEach(project => { + expect(calls).to.contain(`/project/${project.id}/archive`) + }) + }) + + it('removes only selected projects from view when leaving', async function () { + // rerender content with different projects + this.unmount() + fetchMock.restore() + + renderWithProjectListContext(, { + projects: leavableList, + }) + + await fetchMock.flush(true) + await screen.findByRole('table') + + expect(leavableList.length).to.be.greaterThan(0) + + fetchMock.post( + `express:/project/:id/leave`, + { + status: 200, + }, + { repeat: leavableList.length } + ) + + allCheckboxes = screen.getAllByRole('checkbox') + // + 1 because of select all + expect(allCheckboxes.length).to.equal(leavableList.length + 1) + + // first one is the select all checkbox + fireEvent.click(allCheckboxes[0]) + + actionsToolbar = screen.getAllByRole('toolbar')[0] + + const toolbar = within(actionsToolbar) + expect(toolbar.queryByRole('button', { name: /delete/i })).to.be.null + expect( + toolbar.queryByRole('button', { + name: /delete \/ leave/i, + }) + ).to.be.null + + const leaveButton = toolbar.getByRole('button', { + name: /leave/i, + }) + fireEvent.click(leaveButton) + + const confirmButton = screen.getByText('Confirm') + fireEvent.click(confirmButton) + expect(confirmButton.disabled).to.be.true + + await fetchMock.flush(true) + expect(fetchMock.done()).to.be.true + + const calls = fetchMock.calls().map(([url]) => url) + leavableList.forEach(project => { + expect(calls).to.contain(`/project/${project.id}/leave`) + }) + }) + + it('removes only selected projects from view when deleting', async function () { + // rerender content with different projects + this.unmount() + fetchMock.restore() + + renderWithProjectListContext(, { + projects: deletableList, + }) + + await fetchMock.flush(true) + await screen.findByRole('table') + + expect(deletableList.length).to.be.greaterThan(0) + + fetchMock.delete( + `express:/project/:id`, + { + status: 200, + }, + { repeat: deletableList.length } + ) + + allCheckboxes = screen.getAllByRole('checkbox') + // + 1 because of select all + expect(allCheckboxes.length).to.equal(deletableList.length + 1) + + // first one is the select all checkbox + fireEvent.click(allCheckboxes[0]) + + actionsToolbar = screen.getAllByRole('toolbar')[0] + + const toolbar = within(actionsToolbar) + expect(toolbar.queryByRole('button', { name: /leave/i })).to.be.null + expect( + toolbar.queryByRole('button', { + name: /delete \/ leave/i, + }) + ).to.be.null + + const deleteButton = toolbar.getByRole('button', { + name: /delete/i, + }) + fireEvent.click(deleteButton) + + const confirmButton = screen.getByText('Confirm') + fireEvent.click(confirmButton) + expect(confirmButton.disabled).to.be.true + + await fetchMock.flush(true) + expect(fetchMock.done()).to.be.true + + const calls = fetchMock.calls().map(([url]) => url) + deletableList.forEach(project => { + expect(calls).to.contain(`/project/${project.id}`) + }) + }) + + it('removes only selected projects from view when deleting and leaving', async function () { + // rerender content with different projects + this.unmount() + fetchMock.restore() + + const deletableAndLeavableList = [...deletableList, ...leavableList] + + renderWithProjectListContext(, { + projects: deletableAndLeavableList, + }) + + await fetchMock.flush(true) + await screen.findByRole('table') + + expect(deletableList.length).to.be.greaterThan(0) + expect(leavableList.length).to.be.greaterThan(0) + + fetchMock + .delete( + `express:/project/:id`, + { + status: 200, + }, + { repeat: deletableList.length } + ) + .post( + `express:/project/:id/leave`, + { + status: 200, + }, + { repeat: leavableList.length } + ) + + allCheckboxes = screen.getAllByRole('checkbox') + // + 1 because of select all + expect(allCheckboxes.length).to.equal( + deletableAndLeavableList.length + 1 + ) + + // first one is the select all checkbox + fireEvent.click(allCheckboxes[0]) + + actionsToolbar = screen.getAllByRole('toolbar')[0] + + const toolbar = within(actionsToolbar) + expect(toolbar.queryByRole('button', { name: 'Leave' })).to.be.null + expect(toolbar.queryByRole('button', { name: 'Delete' })).to.be.null + + const deleteLeaveButton = toolbar.getByRole('button', { + name: /delete \/ leave/i, + }) + fireEvent.click(deleteLeaveButton) + + const confirmButton = screen.getByText('Confirm') + fireEvent.click(confirmButton) + expect(confirmButton.disabled).to.be.true + + await fetchMock.flush(true) + expect(fetchMock.done()).to.be.true + + const calls = fetchMock.calls().map(([url]) => url) + deletableAndLeavableList.forEach(project => { + expect(calls).to.contain.oneOf([ + `/project/${project.id}`, + `/project/${project.id}/leave`, + ]) + }) }) }) @@ -497,7 +691,7 @@ describe('', function () { fireEvent.click(moreDropdown) const renameButton = - screen.getAllByText('Rename')[1] // first one is for the tag in the sidebar + screen.getAllByText('Rename')[1] // first one is for the tag in the sidebar fireEvent.click(renameButton) const modals = await screen.findAllByRole('dialog') @@ -508,7 +702,7 @@ describe('', function () { // same name let confirmButton = - within(modal).getByText('Rename') + within(modal).getByText('Rename') expect(confirmButton.disabled).to.be.true let input = screen.getByLabelText('New Name') as HTMLButtonElement @@ -517,7 +711,7 @@ describe('', function () { fireEvent.change(input, { target: { value: '' }, }) - confirmButton = within(modal).getByText('Rename') + confirmButton = within(modal).getByText('Rename') expect(confirmButton.disabled).to.be.true }) @@ -534,7 +728,7 @@ describe('', function () { fireEvent.click(moreDropdown) const renameButton = - within(actionsToolbar).getByText('Rename') // first one is for the tag in the sidebar + within(actionsToolbar).getByText('Rename') // first one is for the tag in the sidebar fireEvent.click(renameButton) const modals = await screen.findAllByRole('dialog') @@ -551,7 +745,7 @@ describe('', function () { }) const confirmButton = - within(modal).getByText('Rename') + within(modal).getByText('Rename') expect(confirmButton.disabled).to.be.false fireEvent.click(confirmButton) @@ -605,7 +799,7 @@ describe('', function () { }) const copyButton = - within(actionsToolbar).getByText('Make a copy') + within(actionsToolbar).getByText('Make a copy') fireEvent.click(copyButton) // confirm in modal diff --git a/services/web/test/frontend/features/project-list/components/table/project-tools/buttons/delete-leave-projects-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/project-tools/buttons/delete-leave-projects-button.test.tsx new file mode 100644 index 0000000000..928265cbad --- /dev/null +++ b/services/web/test/frontend/features/project-list/components/table/project-tools/buttons/delete-leave-projects-button.test.tsx @@ -0,0 +1,29 @@ +import { fireEvent, screen, render } from '@testing-library/react' +import DeleteLeaveProjectsButton from '../../../../../../../../frontend/js/features/project-list/components/table/project-tools/buttons/delete-leave-projects-button' +import { makeLongProjectList } from '../../../../fixtures/projects-data' +import { + ProjectListContext, + ProjectListContextValue, +} from '../../../../../../../../frontend/js/features/project-list/context/project-list-context' + +const { deletableList, leavableList } = makeLongProjectList(40) + +describe('', function () { + it('opens the modal when clicked', function () { + const value = { + selectedProjects: [...deletableList, ...leavableList], + hasDeletableProjectsSelected: true, + hasLeavableProjectsSelected: true, + } as ProjectListContextValue + + render( + + + + ) + + const btn = screen.getByRole('button', { name: /delete \/ leave/i }) + fireEvent.click(btn) + screen.getByRole('heading', { name: /delete and leave projects/i }) + }) +}) diff --git a/services/web/test/frontend/features/project-list/components/table/project-tools/buttons/delete-projects-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/project-tools/buttons/delete-projects-button.test.tsx new file mode 100644 index 0000000000..ffcaa51217 --- /dev/null +++ b/services/web/test/frontend/features/project-list/components/table/project-tools/buttons/delete-projects-button.test.tsx @@ -0,0 +1,29 @@ +import { fireEvent, screen, render } from '@testing-library/react' +import DeleteProjectsButton from '../../../../../../../../frontend/js/features/project-list/components/table/project-tools/buttons/delete-projects-button' +import { makeLongProjectList } from '../../../../fixtures/projects-data' +import { + ProjectListContext, + ProjectListContextValue, +} from '../../../../../../../../frontend/js/features/project-list/context/project-list-context' + +const { deletableList } = makeLongProjectList(40) + +describe('', function () { + it('opens the modal when clicked', function () { + const value = { + selectedProjects: deletableList, + hasDeletableProjectsSelected: true, + hasLeavableProjectsSelected: false, + } as ProjectListContextValue + + render( + + + + ) + + const btn = screen.getByRole('button', { name: /delete/i }) + fireEvent.click(btn) + screen.getByRole('heading', { name: /delete projects/i }) + }) +}) diff --git a/services/web/test/frontend/features/project-list/components/table/project-tools/buttons/leave-projects-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/project-tools/buttons/leave-projects-button.test.tsx new file mode 100644 index 0000000000..71e11c91d2 --- /dev/null +++ b/services/web/test/frontend/features/project-list/components/table/project-tools/buttons/leave-projects-button.test.tsx @@ -0,0 +1,28 @@ +import { fireEvent, screen, render } from '@testing-library/react' +import LeaveProjectsButton from '../../../../../../../../frontend/js/features/project-list/components/table/project-tools/buttons/leave-projects-button' +import { makeLongProjectList } from '../../../../fixtures/projects-data' +import { + ProjectListContext, + ProjectListContextValue, +} from '../../../../../../../../frontend/js/features/project-list/context/project-list-context' + +const { leavableList } = makeLongProjectList(40) + +describe('', function () { + it('opens the modal when clicked', function () { + const value = { + selectedProjects: leavableList, + hasDeletableProjectsSelected: false, + hasLeavableProjectsSelected: true, + } as ProjectListContextValue + + render( + + + + ) + const btn = screen.getByRole('button', { name: /leave/i }) + fireEvent.click(btn) + screen.getByRole('heading', { name: /leave projects/i }) + }) +}) diff --git a/services/web/test/frontend/features/project-list/components/table/projects-action-modal.test.tsx b/services/web/test/frontend/features/project-list/components/table/projects-action-modal.test.tsx index 7f9d32c50f..19dce5f7d5 100644 --- a/services/web/test/frontend/features/project-list/components/table/projects-action-modal.test.tsx +++ b/services/web/test/frontend/features/project-list/components/table/projects-action-modal.test.tsx @@ -76,7 +76,7 @@ describe('', function () { }) }) - it('should send an analytics even when opened', function () { + it('should send an analytics event when opened', function () { renderWithProjectListContext( { ({ archived, trashed }) => !archived && !trashed ), trashedList: longList.filter(({ trashed }) => trashed), + leavableList: longList.filter(isLeavableProject), + deletableList: longList.filter(isDeletableProject), } }