Merge pull request #9619 from overleaf/ab-react-dash-remove-tag

[web] Handle selecting/removing a tag in the inline tag list of the project table

GitOrigin-RevId: c3f39006c690beebb8ca7c1f3595bd9e016cd60c
This commit is contained in:
Alexandre Bourdin 2022-09-26 11:13:11 +02:00 committed by Copybot
parent 9f4df9c0f4
commit 4013288971
4 changed files with 150 additions and 56 deletions

View file

@ -1,9 +1,10 @@
import { useState, useRef } from 'react'
import { useCallback, useState, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { Tag } from '../../../../../../../app/src/Features/Tags/types'
import ColorManager from '../../../../../ide/colors/ColorManager'
import Icon from '../../../../../shared/components/icon'
import { useProjectListContext } from '../../../context/project-list-context'
import { removeProjectFromTag } from '../../../util/api'
import classnames from 'classnames'
type InlineTagsProps = {
@ -19,14 +20,20 @@ function InlineTags({ projectId, ...props }: InlineTagsProps) {
{tags
.filter(tag => tag.project_ids?.includes(projectId))
.map((tag, index) => (
<InlineTag tag={tag} key={index} />
<InlineTag tag={tag} projectId={projectId} key={index} />
))}
</span>
)
}
function InlineTag({ tag }: { tag: Tag }) {
type InlineTagProps = {
tag: Tag
projectId: string
}
function InlineTag({ tag, projectId }: InlineTagProps) {
const { t } = useTranslation()
const { selectTag, removeProjectFromTagInView } = useProjectListContext()
const [classNames, setClassNames] = useState('')
const tagLabelRef = useRef(null)
const tagBtnRef = useRef<HTMLButtonElement>(null)
@ -39,6 +46,13 @@ function InlineTag({ tag }: { tag: Tag }) {
}
}
const handleRemoveTag = useCallback(
async (tagId: string, projectId: string) => {
removeProjectFromTagInView(tagId, projectId)
await removeProjectFromTag(tagId, projectId)
},
[removeProjectFromTagInView]
)
const handleCloseMouseOver = () => setClassNames('tag-label-close-hover')
const handleCloseMouseOut = () => setClassNames('')
@ -53,6 +67,7 @@ function InlineTag({ tag }: { tag: Tag }) {
className="label label-default tag-label-name"
aria-label={t('select_tag', { tagName: tag.name })}
ref={tagBtnRef}
onClick={() => selectTag(tag._id)}
>
<span
style={{
@ -67,6 +82,7 @@ function InlineTag({ tag }: { tag: Tag }) {
<button
className="label label-default tag-label-remove"
aria-label={t('remove_tag', { tagName: tag.name })}
onClick={() => handleRemoveTag(tag._id, projectId)}
onMouseOver={handleCloseMouseOver}
onMouseOut={handleCloseMouseOut}
>

View file

@ -1,4 +1,13 @@
import _ from 'lodash'
import {
cloneDeep,
concat,
filter as arrayFilter,
find,
flatten,
uniq,
uniqBy,
without,
} from 'lodash'
import {
createContext,
ReactNode,
@ -73,6 +82,7 @@ type ProjectListContextValue = {
deleteTag: (tagId: string) => void
updateProjectViewData: (project: Project) => void
removeProjectFromView: (project: Project) => void
removeProjectFromTagInView: (tagId: string, projectId: string) => void
searchText: string
setSearchText: React.Dispatch<React.SetStateAction<string>>
selectedProjects: Project[]
@ -146,9 +156,7 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
if (selectedTagId !== undefined) {
if (selectedTagId === UNCATEGORIZED_KEY) {
const taggedProjectIds = _.uniq(
_.flatten(tags.map(tag => tag.project_ids))
)
const taggedProjectIds = uniq(flatten(tags.map(tag => tag.project_ids)))
filteredProjects = filteredProjects.filter(
project =>
!project.archived &&
@ -156,7 +164,7 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
!taggedProjectIds.includes(project.id)
)
} else {
const tag = _.find(tags, tag => tag._id === selectedTagId)
const tag = tags.find(tag => tag._id === selectedTagId)
if (tag) {
filteredProjects = filteredProjects.filter(project =>
tag?.project_ids?.includes(project.id)
@ -167,7 +175,7 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
}
}
} else {
filteredProjects = _.filter(filteredProjects, filters[filter])
filteredProjects = arrayFilter(filteredProjects, filters[filter])
}
if (prevSortRef.current !== sort) {
@ -233,7 +241,7 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
}, [visibleProjects, hiddenProjects, loadMoreCount])
const untaggedProjectsCount = useMemo(() => {
const taggedProjectIds = _.uniq(_.flatten(tags.map(tag => tag.project_ids)))
const taggedProjectIds = uniq(flatten(tags.map(tag => tag.project_ids)))
return loadedProjects.filter(
project =>
!project.archived &&
@ -259,13 +267,13 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
)
const addTag = useCallback((tag: Tag) => {
setTags(tags => _.uniqBy(_.concat(tags, [tag]), '_id'))
setTags(tags => uniqBy(concat(tags, [tag]), '_id'))
}, [])
const renameTag = useCallback((tagId: string, newTagName: string) => {
setTags(tags => {
const newTags = _.cloneDeep(tags)
const tag = _.find(newTags, ['_id', tagId])
const newTags = cloneDeep(tags)
const tag = find(newTags, ['_id', tagId])
if (tag) {
tag.name = newTagName
}
@ -280,6 +288,21 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
[setTags]
)
const removeProjectFromTagInView = useCallback(
(tagId: string, projectId: string) => {
setTags(tags => {
const updatedTags = [...tags]
for (const tag of updatedTags) {
if (tag._id === tagId) {
tag.project_ids = without(tag.project_ids || [], projectId)
}
}
return updatedTags
})
},
[setTags]
)
const addClonedProjectToViewData = useCallback(
project => {
// clone API not using camelCase and does not return all data
@ -334,8 +357,13 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
deleteTag,
error,
filter,
hiddenProjects,
isLoading,
loadMoreCount,
loadMoreProjects,
loadProgress,
removeProjectFromTagInView,
removeProjectFromView,
renameTag,
selectedTagId,
selectFilter,
@ -345,17 +373,13 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
setSearchText,
setSelectedProjects,
setSort,
showAllProjects,
sort,
tags,
totalProjectsCount,
untaggedProjectsCount,
updateProjectViewData,
visibleProjects,
removeProjectFromView,
hiddenProjects,
loadMoreCount,
showAllProjects,
loadMoreProjects,
}),
[
addTag,
@ -363,8 +387,13 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
deleteTag,
error,
filter,
hiddenProjects,
isLoading,
loadMoreCount,
loadMoreProjects,
loadProgress,
removeProjectFromTagInView,
removeProjectFromView,
renameTag,
selectedTagId,
selectFilter,
@ -374,17 +403,13 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
setSearchText,
setSelectedProjects,
setSort,
showAllProjects,
sort,
tags,
totalProjectsCount,
untaggedProjectsCount,
updateProjectViewData,
visibleProjects,
removeProjectFromView,
hiddenProjects,
loadMoreCount,
showAllProjects,
loadMoreProjects,
]
)

View file

@ -11,64 +11,44 @@ export function getProjects(sortBy: Sort): Promise<GetProjectsResponseBody> {
export function createTag(tagName: string): Promise<Tag> {
return postJSON(`/tag`, {
body: { name: tagName, _csrf: window.csrfToken },
body: { name: tagName },
})
}
export function renameTag(tagId: string, newTagName: string) {
return postJSON(`/tag/${tagId}/rename`, {
body: { name: newTagName, _csrf: window.csrfToken },
body: { name: newTagName },
})
}
export function deleteTag(tagId: string) {
return deleteJSON(`/tag/${tagId}`, { body: { _csrf: window.csrfToken } })
return deleteJSON(`/tag/${tagId}`)
}
export function removeProjectFromTag(tagId: string, projectId: string) {
return deleteJSON(`/tag/${tagId}/project/${projectId}`)
}
export function archiveProject(projectId: string) {
return postJSON(`/project/${projectId}/archive`, {
body: {
_csrf: window.csrfToken,
},
})
return postJSON(`/project/${projectId}/archive`)
}
export function deleteProject(projectId: string) {
return deleteJSON(`/project/${projectId}`, {
body: {
_csrf: window.csrfToken,
},
})
return deleteJSON(`/project/${projectId}`)
}
export function leaveProject(projectId: string) {
return postJSON(`/project/${projectId}/leave`, {
body: {
_csrf: window.csrfToken,
},
})
return postJSON(`/project/${projectId}/leave`)
}
export function trashProject(projectId: string) {
return postJSON(`/project/${projectId}/trash`, {
body: {
_csrf: window.csrfToken,
},
})
return postJSON(`/project/${projectId}/trash`)
}
export function unarchiveProject(projectId: string) {
return deleteJSON(`/project/${projectId}/archive`, {
body: {
_csrf: window.csrfToken,
},
})
return deleteJSON(`/project/${projectId}/archive`)
}
export function untrashProject(projectId: string) {
return deleteJSON(`/project/${projectId}/trash`, {
body: {
_csrf: window.csrfToken,
},
})
return deleteJSON(`/project/${projectId}/trash`)
}

View file

@ -0,0 +1,73 @@
import { expect } from 'chai'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import fetchMock from 'fetch-mock'
import {
resetProjectListContextFetch,
renderWithProjectListContext,
} from '../../../helpers/render-with-context'
import InlineTags from '../../../../../../../frontend/js/features/project-list/components/table/cells/inline-tags'
import {
archivedProject,
copyableProject,
} from '../../../fixtures/projects-data'
describe('<InlineTags />', function () {
beforeEach(function () {
window.metaAttributesCache = new Map()
window.metaAttributesCache.set('ol-tags', [
{
_id: '789fff789fff',
name: 'My Test Tag',
project_ids: [copyableProject.id, archivedProject.id],
},
{
_id: '555eee555eee',
name: 'Tag 2',
project_ids: [copyableProject.id],
},
{
_id: '444ddd444ddd',
name: 'Tag 3',
project_ids: [archivedProject.id],
},
])
this.projectId = copyableProject.id
})
afterEach(function () {
resetProjectListContextFetch()
window.metaAttributesCache.clear()
})
it('renders tags list for a project', function () {
renderWithProjectListContext(<InlineTags projectId={this.projectId} />)
screen.getByText('My Test Tag')
screen.getByText('Tag 2')
expect(screen.queryByText('Tag 3')).to.not.exist
})
it('handles removing a project from a tag', async function () {
fetchMock.delete(
`express:/tag/789fff789fff/project/${copyableProject.id}`,
{
status: 204,
},
{ delay: 0 }
)
renderWithProjectListContext(<InlineTags projectId={this.projectId} />)
const removeButton = screen.getByRole('button', {
name: 'Remove tag My Test Tag',
})
await fireEvent.click(removeButton)
await waitFor(() =>
expect(
fetchMock.called(`/tag/789fff789fff/project/${copyableProject.id}`, {
method: 'DELETE',
})
)
)
expect(screen.queryByText('My Test Tag')).to.not.exist
screen.getByText('Tag 2')
})
})