Просмотр исходного кода

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
Alexandre Bourdin 4 лет назад
Родитель
Сommit
4013288971

+ 19 - 3
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) => (
-          <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}
       >

+ 45 - 20
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<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,
     ]
   )
 

+ 13 - 33
services/web/frontend/js/features/project-list/util/api.ts

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

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