mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
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:
parent
fd9c66404a
commit
07a68a5a57
10 changed files with 208 additions and 8 deletions
|
@ -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": "",
|
||||
|
|
|
@ -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' : ''}`}
|
||||
|
|
|
@ -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)
|
|
@ -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 />}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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}`)
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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' })
|
||||
})
|
||||
})
|
||||
|
|
Loading…
Reference in a new issue