Merge pull request #10802 from overleaf/ii-dashboard-leave-button

[web] Project dashboard leave button

GitOrigin-RevId: 6c472ffe9b3d07f103f32e07fec9996a6d45caef
This commit is contained in:
ilkin-overleaf 2022-12-08 12:46:34 +02:00 committed by Copybot
parent 12af54069c
commit 7650e06074
22 changed files with 762 additions and 139 deletions

View file

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

View file

@ -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<typeof ProjectsActionModal>,
@ -13,29 +15,42 @@ function ArchiveProjectModal({
handleCloseModal,
}: ArchiveProjectModalProps) {
const { t } = useTranslation()
const [projectsToDisplay, setProjectsToDisplay] = useState<typeof projects>(
[]
)
useEffect(() => {
if (showModal) {
setProjectsToDisplay(displayProjects => {
return displayProjects.length ? displayProjects : projects
})
} else {
setProjectsToDisplay([])
}
}, [showModal, projects])
return (
<ProjectsActionModal
action="archive"
actionHandler={actionHandler}
title={t('archive_projects')}
bodyTop={<p>{t('about_to_archive_projects')}</p>}
bodyBottom={
<p>
{t('archiving_projects_wont_affect_collaborators')}{' '}
<a
href="https://www.overleaf.com/blog/new-feature-using-archive-and-trash-to-keep-your-projects-organized"
target="_blank"
rel="noreferrer"
>
{t('find_out_more_nt')}
</a>
</p>
}
showModal={showModal}
handleCloseModal={handleCloseModal}
projects={projects}
/>
>
<p>{t('about_to_archive_projects')}</p>
<ProjectsList projects={projects} projectsToDisplay={projectsToDisplay} />
<p>
{t('archiving_projects_wont_affect_collaborators')}{' '}
<a
href="https://www.overleaf.com/blog/new-feature-using-archive-and-trash-to-keep-your-projects-organized"
target="_blank"
rel="noreferrer"
>
{t('find_out_more_nt')}
</a>
</p>
</ProjectsActionModal>
)
}

View file

@ -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<typeof ProjectsActionModal>,
'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 (
<ProjectsActionModal
action="leaveOrDelete"
actionHandler={actionHandler}
title={t('delete_and_leave_projects')}
showModal={showModal}
handleCloseModal={handleCloseModal}
projects={[...projectsToDelete, ...projectsToLeave]}
>
<p>{t('about_to_delete_projects')}</p>
<ProjectsList
projects={projectsToDelete}
projectsToDisplay={projectsToDeleteDisplay}
/>
<p>{t('about_to_leave_projects')}</p>
<ProjectsList
projects={projectsToLeave}
projectsToDisplay={projectsToLeaveDisplay}
/>
<div className="project-action-alert alert alert-warning">
<Icon type="exclamation-triangle" fw />{' '}
{t('this_action_cannot_be_undone')}
</div>
</ProjectsActionModal>
)
}
export default DeleteLeaveProjectModal

View file

@ -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<typeof ProjectsActionModal>,
@ -14,23 +16,36 @@ function DeleteProjectModal({
handleCloseModal,
}: DeleteProjectModalProps) {
const { t } = useTranslation()
const [projectsToDisplay, setProjectsToDisplay] = useState<typeof projects>(
[]
)
useEffect(() => {
if (showModal) {
setProjectsToDisplay(displayProjects => {
return displayProjects.length ? displayProjects : projects
})
} else {
setProjectsToDisplay([])
}
}, [showModal, projects])
return (
<ProjectsActionModal
action="delete"
actionHandler={actionHandler}
title={t('delete_projects')}
bodyTop={<p>{t('about_to_delete_projects')}</p>}
bodyBottom={
<div className="project-action-alert alert alert-warning">
<Icon type="exclamation-triangle" fw />{' '}
{t('this_action_cannot_be_undone')}
</div>
}
showModal={showModal}
handleCloseModal={handleCloseModal}
projects={projects}
/>
>
<p>{t('about_to_delete_projects')}</p>
<ProjectsList projects={projects} projectsToDisplay={projectsToDisplay} />
<div className="project-action-alert alert alert-warning">
<Icon type="exclamation-triangle" fw />{' '}
{t('this_action_cannot_be_undone')}
</div>
</ProjectsActionModal>
)
}

View file

@ -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<typeof ProjectsActionModal>,
@ -14,23 +16,36 @@ function LeaveProjectModal({
handleCloseModal,
}: LeaveProjectModalProps) {
const { t } = useTranslation()
const [projectsToDisplay, setProjectsToDisplay] = useState<typeof projects>(
[]
)
useEffect(() => {
if (showModal) {
setProjectsToDisplay(displayProjects => {
return displayProjects.length ? displayProjects : projects
})
} else {
setProjectsToDisplay([])
}
}, [showModal, projects])
return (
<ProjectsActionModal
action="leave"
actionHandler={actionHandler}
title={t('leave_projects')}
bodyTop={<p>{t('about_to_leave_projects')}</p>}
bodyBottom={
<div className="project-action-alert alert alert-warning">
<Icon type="exclamation-triangle" fw />{' '}
{t('this_action_cannot_be_undone')}
</div>
}
showModal={showModal}
handleCloseModal={handleCloseModal}
projects={projects}
/>
>
<p>{t('about_to_leave_projects')}</p>
<ProjectsList projects={projects} projectsToDisplay={projectsToDisplay} />
<div className="project-action-alert alert alert-warning">
<Icon type="exclamation-triangle" fw />{' '}
{t('this_action_cannot_be_undone')}
</div>
</ProjectsActionModal>
)
}

View file

@ -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<void>
handleCloseModal: () => void
bodyTop?: React.ReactNode
bodyBottom?: React.ReactNode
projects: Array<Project>
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<Array<any>>([])
const [isProcessing, setIsProcessing] = useState(false)
const [projectsToDisplay, setProjectsToDisplay] = useState<Project[]>([])
const projectsRef = useRef<Project[]>([])
const isMounted = useIsMounted()
async function handleActionForProjects(projects: Array<Project>) {
@ -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 (
<AccessibleModal
@ -93,25 +76,7 @@ function ProjectsActionModal({
<Modal.Header closeButton>
<Modal.Title>{title}</Modal.Title>
</Modal.Header>
<Modal.Body>
{bodyTop}
<ul>
{projectsToDisplay.map(project => (
<li
key={`projects-action-list-${project.id}`}
className={
projectIdsRemainingToProcess.includes(project.id)
? ''
: 'list-style-check-green'
}
>
<b>{project.name}</b>
</li>
))}
</ul>
{bodyBottom}
</Modal.Body>
<Modal.Body>{children}</Modal.Body>
<Modal.Footer>
{!isProcessing &&
errors.length > 0 &&

View file

@ -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 (
<ul className="projects-action-list">
{projectsToDisplay.map(project => (
<li
key={`projects-action-list-${project.id}`}
className={classnames({
'list-style-check-green': !projects.some(
({ id }) => id === project.id
),
})}
>
<b>{project.name}</b>
</li>
))}
</ul>
)
}
export default ProjectsList

View file

@ -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<typeof ProjectsActionModal>,
@ -13,29 +15,42 @@ function TrashProjectModal({
handleCloseModal,
}: TrashProjectPropsModalProps) {
const { t } = useTranslation()
const [projectsToDisplay, setProjectsToDisplay] = useState<typeof projects>(
[]
)
useEffect(() => {
if (showModal) {
setProjectsToDisplay(displayProjects => {
return displayProjects.length ? displayProjects : projects
})
} else {
setProjectsToDisplay([])
}
}, [showModal, projects])
return (
<ProjectsActionModal
action="trash"
actionHandler={actionHandler}
title={t('trash_projects')}
bodyTop={<p>{t('about_to_trash_projects')}</p>}
bodyBottom={
<p>
{t('trashing_projects_wont_affect_collaborators')}{' '}
<a
href="https://www.overleaf.com/blog/new-feature-using-archive-and-trash-to-keep-your-projects-organized"
target="_blank"
rel="noreferrer"
>
{t('find_out_more_nt')}
</a>
</p>
}
showModal={showModal}
handleCloseModal={handleCloseModal}
projects={projects}
/>
>
<p>{t('about_to_trash_projects')}</p>
<ProjectsList projects={projects} projectsToDisplay={projectsToDisplay} />
<p>
{t('trashing_projects_wont_affect_collaborators')}{' '}
<a
href="https://www.overleaf.com/blog/new-feature-using-archive-and-trash-to-keep-your-projects-organized"
target="_blank"
rel="noreferrer"
>
{t('find_out_more_nt')}
</a>
</p>
</ProjectsActionModal>
)
}

View file

@ -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() {
</Tooltip>
<ArchiveProjectModal
projects={selectedProjects}
actionHandler={handleArchiveProjects}
actionHandler={handleArchiveProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>

View file

@ -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 && (
<Button bsStyle={null} className="btn-danger" onClick={handleOpenModal}>
{t('delete_and_leave')}
</Button>
)}
<DeleteLeaveProjectModal
projects={selectedProjects}
actionHandler={handleDeleteAndLeaveProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
</>
)
}
export default DeleteLeaveProjectsButton

View file

@ -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 && (
<Button bsStyle={null} className="btn-danger" onClick={handleOpenModal}>
{t('delete')}
</Button>
)}
<DeleteProjectModal
projects={selectedProjects}
actionHandler={handleDeleteProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
</>
)
}
export default DeleteProjectsButton

View file

@ -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 && (
<Button bsStyle={null} className="btn-danger" onClick={handleOpenModal}>
{t('leave')}
</Button>
)}
<LeaveProjectModal
projects={selectedProjects}
actionHandler={handleLeaveProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
</>
)
}
export default LeaveProjectsButton

View file

@ -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() {
</Tooltip>
<TrashProjectModal
projects={selectedProjects}
actionHandler={handleTrashProjects}
actionHandler={handleTrashProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>

View file

@ -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' && <UnarchiveProjectsButton />}
</ButtonGroup>
<ButtonGroup>
{filter === 'trashed' && (
<>
<LeaveProjectsButton />
<DeleteProjectsButton />
<DeleteLeaveProjectsButton />
</>
)}
</ButtonGroup>
{!['archived', 'trashed'].includes(filter) && <TagsDropdown />}
{selectedProjects.length === 1 &&

View file

@ -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<React.SetStateAction<boolean>>
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<ProjectListContextValue>(
@ -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,

View file

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

View file

@ -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('<ProjectListRoot />', function () {
describe('project table', function () {
beforeEach(async function () {
renderWithProjectListContext(<ProjectListRoot />, {
const { unmount } = renderWithProjectListContext(<ProjectListRoot />, {
projects: fullList,
})
this.unmount = unmount
await fetchMock.flush(true)
await screen.findByRole('table')
})
@ -240,7 +243,7 @@ describe('<ProjectListRoot />', function () {
})
const unarchiveButton =
within(actionsToolbar).getByText<HTMLInputElement>('Restore')
within(actionsToolbar).getByText<HTMLButtonElement>('Restore')
fireEvent.click(unarchiveButton)
await fetchMock.flush(true)
@ -284,7 +287,7 @@ describe('<ProjectListRoot />', 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('<ProjectListRoot />', function () {
})
const untrashButton =
within(actionsToolbar).getByText<HTMLInputElement>('Restore')
within(actionsToolbar).getByText<HTMLButtonElement>('Restore')
fireEvent.click(untrashButton)
await fetchMock.flush(true)
@ -330,22 +333,213 @@ describe('<ProjectListRoot />', 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<HTMLInputElement>('Archive')
fireEvent.click(untrashButton)
const archiveButton =
within(actionsToolbar).getByLabelText<HTMLButtonElement>('Archive')
fireEvent.click(archiveButton)
const confirmButton = screen.getByText<HTMLInputElement>('Confirm')
const confirmButton = screen.getByText<HTMLButtonElement>('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(<ProjectListRoot />, {
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<HTMLInputElement>('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<HTMLButtonElement>('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(<ProjectListRoot />, {
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<HTMLInputElement>('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<HTMLButtonElement>('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(<ProjectListRoot />, {
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<HTMLInputElement>('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<HTMLButtonElement>('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('<ProjectListRoot />', function () {
fireEvent.click(moreDropdown)
const renameButton =
screen.getAllByText<HTMLInputElement>('Rename')[1] // first one is for the tag in the sidebar
screen.getAllByText<HTMLButtonElement>('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('<ProjectListRoot />', function () {
// same name
let confirmButton =
within(modal).getByText<HTMLInputElement>('Rename')
within(modal).getByText<HTMLButtonElement>('Rename')
expect(confirmButton.disabled).to.be.true
let input = screen.getByLabelText('New Name') as HTMLButtonElement
@ -517,7 +711,7 @@ describe('<ProjectListRoot />', function () {
fireEvent.change(input, {
target: { value: '' },
})
confirmButton = within(modal).getByText<HTMLInputElement>('Rename')
confirmButton = within(modal).getByText<HTMLButtonElement>('Rename')
expect(confirmButton.disabled).to.be.true
})
@ -534,7 +728,7 @@ describe('<ProjectListRoot />', function () {
fireEvent.click(moreDropdown)
const renameButton =
within(actionsToolbar).getByText<HTMLInputElement>('Rename') // first one is for the tag in the sidebar
within(actionsToolbar).getByText<HTMLButtonElement>('Rename') // first one is for the tag in the sidebar
fireEvent.click(renameButton)
const modals = await screen.findAllByRole('dialog')
@ -551,7 +745,7 @@ describe('<ProjectListRoot />', function () {
})
const confirmButton =
within(modal).getByText<HTMLInputElement>('Rename')
within(modal).getByText<HTMLButtonElement>('Rename')
expect(confirmButton.disabled).to.be.false
fireEvent.click(confirmButton)
@ -605,7 +799,7 @@ describe('<ProjectListRoot />', function () {
})
const copyButton =
within(actionsToolbar).getByText<HTMLInputElement>('Make a copy')
within(actionsToolbar).getByText<HTMLButtonElement>('Make a copy')
fireEvent.click(copyButton)
// confirm in modal

View file

@ -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('<DeleteLeaveProjectsButton />', function () {
it('opens the modal when clicked', function () {
const value = {
selectedProjects: [...deletableList, ...leavableList],
hasDeletableProjectsSelected: true,
hasLeavableProjectsSelected: true,
} as ProjectListContextValue
render(
<ProjectListContext.Provider value={value}>
<DeleteLeaveProjectsButton />
</ProjectListContext.Provider>
)
const btn = screen.getByRole('button', { name: /delete \/ leave/i })
fireEvent.click(btn)
screen.getByRole('heading', { name: /delete and leave projects/i })
})
})

View file

@ -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('<DeleteProjectsButton />', function () {
it('opens the modal when clicked', function () {
const value = {
selectedProjects: deletableList,
hasDeletableProjectsSelected: true,
hasLeavableProjectsSelected: false,
} as ProjectListContextValue
render(
<ProjectListContext.Provider value={value}>
<DeleteProjectsButton />
</ProjectListContext.Provider>
)
const btn = screen.getByRole('button', { name: /delete/i })
fireEvent.click(btn)
screen.getByRole('heading', { name: /delete projects/i })
})
})

View file

@ -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('<LeaveProjectsButton />', function () {
it('opens the modal when clicked', function () {
const value = {
selectedProjects: leavableList,
hasDeletableProjectsSelected: false,
hasLeavableProjectsSelected: true,
} as ProjectListContextValue
render(
<ProjectListContext.Provider value={value}>
<LeaveProjectsButton />
</ProjectListContext.Provider>
)
const btn = screen.getByRole('button', { name: /leave/i })
fireEvent.click(btn)
screen.getByRole('heading', { name: /leave projects/i })
})
})

View file

@ -76,7 +76,7 @@ describe('<ProjectsActionModal />', function () {
})
})
it('should send an analytics even when opened', function () {
it('should send an analytics event when opened', function () {
renderWithProjectListContext(
<ProjectsActionModal
action="archive"

View file

@ -1,4 +1,9 @@
import { Project } from '../../../../../types/project/dashboard/api'
import {
isDeletableProject,
isLeavableProject,
} from '../../../../../frontend/js/features/project-list/util/project'
const moment = require('moment')
export const owner = {
@ -157,5 +162,7 @@ export const makeLongProjectList = (listLength: number) => {
({ archived, trashed }) => !archived && !trashed
),
trashedList: longList.filter(({ trashed }) => trashed),
leavableList: longList.filter(isLeavableProject),
deletableList: longList.filter(isDeletableProject),
}
}