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') + }) +})