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

Merge pull request #9760 from overleaf/ab-dash-toolbar-tags-dropdown

[web] Add tags dropdown to the React dashboard toolbar

GitOrigin-RevId: 8f949d925e1ba0ef68dde508c0dbbaac5828625e
Jessica Lawshe 3 лет назад
Родитель
Сommit
07a68a5a57

+ 2 - 0
services/web/frontend/extracted-translations.json

@@ -17,7 +17,9 @@
   "add_email_to_claim_features": "",
   "add_files": "",
   "add_new_email": "",
+  "add_or_remove_project_from_tag": "",
   "add_role_and_department": "",
+  "add_to_folder": "",
   "all_projects": "",
   "also": "",
   "anyone_with_link_can_edit": "",

+ 2 - 2
services/web/frontend/js/features/project-list/components/sidebar/tags-list.tsx

@@ -1,4 +1,4 @@
-import _ from 'lodash'
+import { sortBy } from 'lodash'
 import { Button } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import ColorManager from '../../../../ide/colors/ColorManager'
@@ -34,7 +34,7 @@ export default function TagsList() {
           <span className="name">{t('new_folder')}</span>
         </Button>
       </li>
-      {_.sortBy(tags, ['name']).map((tag, index) => {
+      {sortBy(tags, tag => tag.name.toLowerCase()).map((tag, index) => {
         return (
           <li
             className={`tag ${selectedTagId === tag._id ? 'active' : ''}`}

+ 131 - 0
services/web/frontend/js/features/project-list/components/table/project-tools/buttons/tags-dropdown.tsx

@@ -0,0 +1,131 @@
+import { sortBy } from 'lodash'
+import { memo, useCallback } from 'react'
+import { Button, Dropdown } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import ControlledDropdown from '../../../../../../shared/components/controlled-dropdown'
+import Icon from '../../../../../../shared/components/icon'
+import { useProjectListContext } from '../../../../context/project-list-context'
+import useTag from '../../../../hooks/use-tag'
+import { addProjectToTag, removeProjectFromTag } from '../../../../util/api'
+
+function TagsDropdown() {
+  const {
+    tags,
+    selectedProjects,
+    addProjectToTagInView,
+    removeProjectFromTagInView,
+  } = useProjectListContext()
+  const { t } = useTranslation()
+  const { openCreateTagModal, CreateTagModal } = useTag()
+
+  const handleOpenCreateTagModal = useCallback(
+    e => {
+      e.preventDefault()
+      openCreateTagModal()
+    },
+    [openCreateTagModal]
+  )
+
+  const handleAddTagToSelectedProjects = useCallback(
+    (e, tagId) => {
+      e.preventDefault()
+      const tag = tags.find(tag => tag._id === tagId)
+      for (const selectedProject of selectedProjects) {
+        if (!tag?.project_ids?.includes(selectedProject.id)) {
+          addProjectToTagInView(tagId, selectedProject.id)
+          addProjectToTag(tagId, selectedProject.id)
+        }
+      }
+    },
+    [tags, selectedProjects, addProjectToTagInView]
+  )
+
+  const handleRemoveTagFromSelectedProjects = useCallback(
+    (e, tagId) => {
+      e.preventDefault()
+      for (const selectedProject of selectedProjects) {
+        removeProjectFromTagInView(tagId, selectedProject.id)
+        removeProjectFromTag(tagId, selectedProject.id)
+      }
+    },
+    [selectedProjects, removeProjectFromTagInView]
+  )
+
+  const containsAllSelectedProjects = useCallback(
+    tag => {
+      for (const project of selectedProjects) {
+        if (!(tag.project_ids || []).includes(project.id)) {
+          return false
+        }
+      }
+      return true
+    },
+    [selectedProjects]
+  )
+
+  const containsSomeSelectedProjects = useCallback(
+    tag => {
+      for (const project of selectedProjects) {
+        if (tag.project_ids?.includes(project.id)) {
+          return true
+        }
+      }
+      return false
+    },
+    [selectedProjects]
+  )
+
+  return (
+    <>
+      <ControlledDropdown id="tags">
+        <Dropdown.Toggle bsStyle="default" title={t('tags')}>
+          <Icon type="folder-open" />
+        </Dropdown.Toggle>
+        <Dropdown.Menu className="dropdown-menu-right">
+          <li className="dropdown-header" role="heading" aria-level={3}>
+            {t('add_to_folder')}
+          </li>
+          {sortBy(tags, tag => tag.name.toLowerCase()).map(tag => {
+            return (
+              <li key={tag._id}>
+                <Button
+                  className="tag-dropdown-button"
+                  onClick={e =>
+                    containsAllSelectedProjects(tag)
+                      ? handleRemoveTagFromSelectedProjects(e, tag._id)
+                      : handleAddTagToSelectedProjects(e, tag._id)
+                  }
+                >
+                  <Icon
+                    type={
+                      containsAllSelectedProjects(tag)
+                        ? 'check-square-o'
+                        : containsSomeSelectedProjects(tag)
+                        ? 'minus-square-o'
+                        : 'square-o'
+                    }
+                  />
+                  <span className="sr-only">
+                    {t('add_or_remove_project_from_tag', { tagName: tag.name })}
+                  </span>
+                </Button>
+              </li>
+            )
+          })}
+          <li className="divider" />
+          <li>
+            <Button
+              className="tag-dropdown-button"
+              onClick={handleOpenCreateTagModal}
+            >
+              {t('create_new_folder')}
+            </Button>
+          </li>
+        </Dropdown.Menu>
+      </ControlledDropdown>
+      <CreateTagModal id="toolbar-create-tag-modal" />
+    </>
+  )
+}
+
+export default memo(TagsDropdown)

+ 3 - 0
services/web/frontend/js/features/project-list/components/table/project-tools/project-tools.tsx

@@ -4,6 +4,7 @@ import { useProjectListContext } from '../../../context/project-list-context'
 import ArchiveProjectsButton from './buttons/archive-projects-button'
 import DownloadProjectsButton from './buttons/download-projects-button'
 import ProjectToolsMoreDropdownButton from './buttons/project-tools-more-dropdown-button'
+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'
@@ -23,6 +24,8 @@ function ProjectTools() {
         {filter === 'archived' && <UnarchiveProjectsButton />}
       </ButtonGroup>
 
+      {!['archived', 'trashed'].includes(filter) && <TagsDropdown />}
+
       {selectedProjects.length === 1 &&
         filter !== 'archived' &&
         filter !== 'trashed' && <ProjectToolsMoreDropdownButton />}

+ 20 - 2
services/web/frontend/js/features/project-list/context/project-list-context.tsx

@@ -83,6 +83,7 @@ type ProjectListContextValue = {
   deleteTag: (tagId: string) => void
   updateProjectViewData: (newProjectData: Project) => void
   removeProjectFromView: (project: Project) => void
+  addProjectToTagInView: (tagId: string, projectId: string) => void
   removeProjectFromTagInView: (tagId: string, projectId: string) => void
   searchText: string
   setSearchText: React.Dispatch<React.SetStateAction<string>>
@@ -302,6 +303,21 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
     [setTags]
   )
 
+  const addProjectToTagInView = useCallback(
+    (tagId: string, projectId: string) => {
+      setTags(tags => {
+        const updatedTags = [...tags]
+        for (const tag of updatedTags) {
+          if (tag._id === tagId) {
+            tag.project_ids = uniq([...(tag.project_ids || []), projectId])
+          }
+        }
+        return updatedTags
+      })
+    },
+    [setTags]
+  )
+
   const removeProjectFromTagInView = useCallback(
     (tagId: string, projectId: string) => {
       setTags(tags => {
@@ -368,7 +384,7 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
     () => ({
       addTag,
       addClonedProjectToViewData,
-      selectOrUnselectAllProjects,
+      addProjectToTagInView,
       deleteTag,
       error,
       filter,
@@ -383,6 +399,7 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
       selectedTagId,
       selectFilter,
       selectedProjects,
+      selectOrUnselectAllProjects,
       selectTag,
       searchText,
       setSearchText,
@@ -398,7 +415,7 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
     [
       addTag,
       addClonedProjectToViewData,
-      selectOrUnselectAllProjects,
+      addProjectToTagInView,
       deleteTag,
       error,
       filter,
@@ -413,6 +430,7 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
       selectedTagId,
       selectFilter,
       selectedProjects,
+      selectOrUnselectAllProjects,
       selectTag,
       searchText,
       setSearchText,

+ 15 - 3
services/web/frontend/js/features/project-list/hooks/use-tag.tsx

@@ -6,10 +6,18 @@ import RenameTagModal from '../components/modals/rename-tag-modal'
 import DeleteTagModal from '../components/modals/delete-tag-modal'
 import EditTagModal from '../components/modals/edit-tag-modal'
 import { find } from 'lodash'
+import { addProjectToTag } from '../util/api'
 
 function useTag() {
-  const { tags, selectTag, addTag, renameTag, deleteTag } =
-    useProjectListContext()
+  const {
+    tags,
+    selectTag,
+    addTag,
+    renameTag,
+    deleteTag,
+    selectedProjects,
+    addProjectToTagInView,
+  } = useProjectListContext()
   const [creatingTag, setCreatingTag] = useState<boolean>(false)
   const [renamingTag, setRenamingTag] = useState<Tag>()
   const [deletingTag, setDeletingTag] = useState<Tag>()
@@ -31,8 +39,12 @@ function useTag() {
     (tag: Tag) => {
       setCreatingTag(false)
       addTag(tag)
+      for (const selectedProject of selectedProjects) {
+        addProjectToTagInView(tag._id, selectedProject.id)
+        addProjectToTag(tag._id, selectedProject.id)
+      }
     },
-    [addTag]
+    [addTag, selectedProjects, addProjectToTagInView]
   )
 
   const handleRenameTag = useCallback(

+ 4 - 0
services/web/frontend/js/features/project-list/util/api.ts

@@ -25,6 +25,10 @@ export function deleteTag(tagId: string) {
   return deleteJSON(`/tag/${tagId}`)
 }
 
+export function addProjectToTag(tagId: string, projectId: string) {
+  return postJSON(`/tag/${tagId}/project/${projectId}`)
+}
+
 export function removeProjectFromTag(tagId: string, projectId: string) {
   return deleteJSON(`/tag/${tagId}/project/${projectId}`)
 }

+ 27 - 0
services/web/frontend/stylesheets/app/project-list-react.less

@@ -643,3 +643,30 @@
 .project-list-load-more-button {
   margin-bottom: @margin-sm;
 }
+
+.tag-dropdown-button {
+  display: block;
+  width: 100%;
+  font-weight: normal;
+  font-size: @font-size-base;
+  text-align: left;
+  color: @gray-dark;
+  background-color: transparent;
+  border-radius: unset;
+  border: none;
+  border-bottom: solid 1px transparent;
+  padding: 3px 20px;
+
+  &:active,
+  &:focus {
+    color: @gray-dark;
+    background-color: transparent;
+    text-decoration: none;
+    outline: none;
+  }
+
+  &:hover {
+    color: @white;
+    background-color: @ol-green;
+  }
+}

+ 1 - 0
services/web/locales/en.json

@@ -1887,6 +1887,7 @@
   "you_dont_have_any_repositories": "You don’t have any repositories",
   "tag_name_cannot_exceed_characters": "Tag name cannot exceed __maxLength__ characters",
   "tag_name_is_already_used": "Tag \"__tagName__\" already exists",
+  "add_or_remove_project_from_tag": "Add or remove project from tag __tagName__",
   "save_changes": "Save changes",
   "labs_program_already_participating": "You are enrolled in Labs",
   "labs_program_not_participating": "You are not enrolled in Labs",

+ 3 - 1
services/web/test/frontend/features/project-list/components/table/project-tools/project-tools.test.tsx

@@ -13,9 +13,11 @@ describe('<ProjectListTable />', function () {
 
   it('renders the project tools for the all projects filter', function () {
     renderWithProjectListContext(<ProjectTools />)
-    expect(screen.getAllByRole('button').length).to.equal(3)
+    expect(screen.getAllByRole('button').length).to.equal(5)
     screen.getByLabelText('Download')
     screen.getByLabelText('Archive')
     screen.getByLabelText('Trash')
+    screen.getByTitle('Tags')
+    screen.getByRole('button', { name: 'Create New Folder' })
   })
 })