diff --git a/services/web/frontend/js/features/project-list/components/table/cells/inline-tags.tsx b/services/web/frontend/js/features/project-list/components/table/cells/inline-tags.tsx
index 3753877683..c8cbea0128 100644
--- a/services/web/frontend/js/features/project-list/components/table/cells/inline-tags.tsx
+++ b/services/web/frontend/js/features/project-list/components/table/cells/inline-tags.tsx
@@ -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) => (
-
+
))}
)
}
-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(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)}
>
handleRemoveTag(tag._id, projectId)}
onMouseOver={handleCloseMouseOver}
onMouseOut={handleCloseMouseOut}
>
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 ea3e4af251..2b0c242fe7 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
@@ -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>
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,
]
)
diff --git a/services/web/frontend/js/features/project-list/util/api.ts b/services/web/frontend/js/features/project-list/util/api.ts
index f7816cbd20..fedcd86af5 100644
--- a/services/web/frontend/js/features/project-list/util/api.ts
+++ b/services/web/frontend/js/features/project-list/util/api.ts
@@ -11,64 +11,44 @@ export function getProjects(sortBy: Sort): Promise {
export function createTag(tagName: string): Promise {
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`)
}
diff --git a/services/web/test/frontend/features/project-list/components/table/cells/inline-tags.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/inline-tags.test.tsx
new file mode 100644
index 0000000000..1ad7f55db0
--- /dev/null
+++ b/services/web/test/frontend/features/project-list/components/table/cells/inline-tags.test.tsx
@@ -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('', 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()
+ 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()
+ 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')
+ })
+})