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

[web] Add tags dropdown to the React dashboard toolbar

GitOrigin-RevId: 8f949d925e1ba0ef68dde508c0dbbaac5828625e
This commit is contained in:
Jessica Lawshe 2022-10-03 08:36:18 -05:00 committed by Copybot
parent fd9c66404a
commit 07a68a5a57
10 changed files with 208 additions and 8 deletions

View file

@ -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": "",

View file

@ -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' : ''}`}

View file

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

View file

@ -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 />}

View file

@ -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,

View file

@ -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(

View file

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

View file

@ -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;
}
}

View file

@ -1887,6 +1887,7 @@
"you_dont_have_any_repositories": "You dont 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",

View file

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