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_email_to_claim_features": "",
|
||||||
"add_files": "",
|
"add_files": "",
|
||||||
"add_new_email": "",
|
"add_new_email": "",
|
||||||
|
"add_or_remove_project_from_tag": "",
|
||||||
"add_role_and_department": "",
|
"add_role_and_department": "",
|
||||||
|
"add_to_folder": "",
|
||||||
"all_projects": "",
|
"all_projects": "",
|
||||||
"also": "",
|
"also": "",
|
||||||
"anyone_with_link_can_edit": "",
|
"anyone_with_link_can_edit": "",
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import _ from 'lodash'
|
import { sortBy } from 'lodash'
|
||||||
import { Button } from 'react-bootstrap'
|
import { Button } from 'react-bootstrap'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import ColorManager from '../../../../ide/colors/ColorManager'
|
import ColorManager from '../../../../ide/colors/ColorManager'
|
||||||
|
@ -34,7 +34,7 @@ export default function TagsList() {
|
||||||
<span className="name">{t('new_folder')}</span>
|
<span className="name">{t('new_folder')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
{_.sortBy(tags, ['name']).map((tag, index) => {
|
{sortBy(tags, tag => tag.name.toLowerCase()).map((tag, index) => {
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
className={`tag ${selectedTagId === tag._id ? 'active' : ''}`}
|
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 ArchiveProjectsButton from './buttons/archive-projects-button'
|
||||||
import DownloadProjectsButton from './buttons/download-projects-button'
|
import DownloadProjectsButton from './buttons/download-projects-button'
|
||||||
import ProjectToolsMoreDropdownButton from './buttons/project-tools-more-dropdown-button'
|
import ProjectToolsMoreDropdownButton from './buttons/project-tools-more-dropdown-button'
|
||||||
|
import TagsDropdown from './buttons/tags-dropdown'
|
||||||
import TrashProjectsButton from './buttons/trash-projects-button'
|
import TrashProjectsButton from './buttons/trash-projects-button'
|
||||||
import UnarchiveProjectsButton from './buttons/unarchive-projects-button'
|
import UnarchiveProjectsButton from './buttons/unarchive-projects-button'
|
||||||
import UntrashProjectsButton from './buttons/untrash-projects-button'
|
import UntrashProjectsButton from './buttons/untrash-projects-button'
|
||||||
|
@ -23,6 +24,8 @@ function ProjectTools() {
|
||||||
{filter === 'archived' && <UnarchiveProjectsButton />}
|
{filter === 'archived' && <UnarchiveProjectsButton />}
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
|
|
||||||
|
{!['archived', 'trashed'].includes(filter) && <TagsDropdown />}
|
||||||
|
|
||||||
{selectedProjects.length === 1 &&
|
{selectedProjects.length === 1 &&
|
||||||
filter !== 'archived' &&
|
filter !== 'archived' &&
|
||||||
filter !== 'trashed' && <ProjectToolsMoreDropdownButton />}
|
filter !== 'trashed' && <ProjectToolsMoreDropdownButton />}
|
||||||
|
|
|
@ -83,6 +83,7 @@ type ProjectListContextValue = {
|
||||||
deleteTag: (tagId: string) => void
|
deleteTag: (tagId: string) => void
|
||||||
updateProjectViewData: (newProjectData: Project) => void
|
updateProjectViewData: (newProjectData: Project) => void
|
||||||
removeProjectFromView: (project: Project) => void
|
removeProjectFromView: (project: Project) => void
|
||||||
|
addProjectToTagInView: (tagId: string, projectId: string) => void
|
||||||
removeProjectFromTagInView: (tagId: string, projectId: string) => void
|
removeProjectFromTagInView: (tagId: string, projectId: string) => void
|
||||||
searchText: string
|
searchText: string
|
||||||
setSearchText: React.Dispatch<React.SetStateAction<string>>
|
setSearchText: React.Dispatch<React.SetStateAction<string>>
|
||||||
|
@ -302,6 +303,21 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
|
||||||
[setTags]
|
[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(
|
const removeProjectFromTagInView = useCallback(
|
||||||
(tagId: string, projectId: string) => {
|
(tagId: string, projectId: string) => {
|
||||||
setTags(tags => {
|
setTags(tags => {
|
||||||
|
@ -368,7 +384,7 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
|
||||||
() => ({
|
() => ({
|
||||||
addTag,
|
addTag,
|
||||||
addClonedProjectToViewData,
|
addClonedProjectToViewData,
|
||||||
selectOrUnselectAllProjects,
|
addProjectToTagInView,
|
||||||
deleteTag,
|
deleteTag,
|
||||||
error,
|
error,
|
||||||
filter,
|
filter,
|
||||||
|
@ -383,6 +399,7 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
|
||||||
selectedTagId,
|
selectedTagId,
|
||||||
selectFilter,
|
selectFilter,
|
||||||
selectedProjects,
|
selectedProjects,
|
||||||
|
selectOrUnselectAllProjects,
|
||||||
selectTag,
|
selectTag,
|
||||||
searchText,
|
searchText,
|
||||||
setSearchText,
|
setSearchText,
|
||||||
|
@ -398,7 +415,7 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
|
||||||
[
|
[
|
||||||
addTag,
|
addTag,
|
||||||
addClonedProjectToViewData,
|
addClonedProjectToViewData,
|
||||||
selectOrUnselectAllProjects,
|
addProjectToTagInView,
|
||||||
deleteTag,
|
deleteTag,
|
||||||
error,
|
error,
|
||||||
filter,
|
filter,
|
||||||
|
@ -413,6 +430,7 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
|
||||||
selectedTagId,
|
selectedTagId,
|
||||||
selectFilter,
|
selectFilter,
|
||||||
selectedProjects,
|
selectedProjects,
|
||||||
|
selectOrUnselectAllProjects,
|
||||||
selectTag,
|
selectTag,
|
||||||
searchText,
|
searchText,
|
||||||
setSearchText,
|
setSearchText,
|
||||||
|
|
|
@ -6,10 +6,18 @@ import RenameTagModal from '../components/modals/rename-tag-modal'
|
||||||
import DeleteTagModal from '../components/modals/delete-tag-modal'
|
import DeleteTagModal from '../components/modals/delete-tag-modal'
|
||||||
import EditTagModal from '../components/modals/edit-tag-modal'
|
import EditTagModal from '../components/modals/edit-tag-modal'
|
||||||
import { find } from 'lodash'
|
import { find } from 'lodash'
|
||||||
|
import { addProjectToTag } from '../util/api'
|
||||||
|
|
||||||
function useTag() {
|
function useTag() {
|
||||||
const { tags, selectTag, addTag, renameTag, deleteTag } =
|
const {
|
||||||
useProjectListContext()
|
tags,
|
||||||
|
selectTag,
|
||||||
|
addTag,
|
||||||
|
renameTag,
|
||||||
|
deleteTag,
|
||||||
|
selectedProjects,
|
||||||
|
addProjectToTagInView,
|
||||||
|
} = useProjectListContext()
|
||||||
const [creatingTag, setCreatingTag] = useState<boolean>(false)
|
const [creatingTag, setCreatingTag] = useState<boolean>(false)
|
||||||
const [renamingTag, setRenamingTag] = useState<Tag>()
|
const [renamingTag, setRenamingTag] = useState<Tag>()
|
||||||
const [deletingTag, setDeletingTag] = useState<Tag>()
|
const [deletingTag, setDeletingTag] = useState<Tag>()
|
||||||
|
@ -31,8 +39,12 @@ function useTag() {
|
||||||
(tag: Tag) => {
|
(tag: Tag) => {
|
||||||
setCreatingTag(false)
|
setCreatingTag(false)
|
||||||
addTag(tag)
|
addTag(tag)
|
||||||
|
for (const selectedProject of selectedProjects) {
|
||||||
|
addProjectToTagInView(tag._id, selectedProject.id)
|
||||||
|
addProjectToTag(tag._id, selectedProject.id)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[addTag]
|
[addTag, selectedProjects, addProjectToTagInView]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleRenameTag = useCallback(
|
const handleRenameTag = useCallback(
|
||||||
|
|
|
@ -25,6 +25,10 @@ export function deleteTag(tagId: string) {
|
||||||
return deleteJSON(`/tag/${tagId}`)
|
return deleteJSON(`/tag/${tagId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function addProjectToTag(tagId: string, projectId: string) {
|
||||||
|
return postJSON(`/tag/${tagId}/project/${projectId}`)
|
||||||
|
}
|
||||||
|
|
||||||
export function removeProjectFromTag(tagId: string, projectId: string) {
|
export function removeProjectFromTag(tagId: string, projectId: string) {
|
||||||
return deleteJSON(`/tag/${tagId}/project/${projectId}`)
|
return deleteJSON(`/tag/${tagId}/project/${projectId}`)
|
||||||
}
|
}
|
||||||
|
|
|
@ -643,3 +643,30 @@
|
||||||
.project-list-load-more-button {
|
.project-list-load-more-button {
|
||||||
margin-bottom: @margin-sm;
|
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",
|
"you_dont_have_any_repositories": "You don’t have any repositories",
|
||||||
"tag_name_cannot_exceed_characters": "Tag name cannot exceed __maxLength__ characters",
|
"tag_name_cannot_exceed_characters": "Tag name cannot exceed __maxLength__ characters",
|
||||||
"tag_name_is_already_used": "Tag \"__tagName__\" already exists",
|
"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",
|
"save_changes": "Save changes",
|
||||||
"labs_program_already_participating": "You are enrolled in Labs",
|
"labs_program_already_participating": "You are enrolled in Labs",
|
||||||
"labs_program_not_participating": "You are not 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 () {
|
it('renders the project tools for the all projects filter', function () {
|
||||||
renderWithProjectListContext(<ProjectTools />)
|
renderWithProjectListContext(<ProjectTools />)
|
||||||
expect(screen.getAllByRole('button').length).to.equal(3)
|
expect(screen.getAllByRole('button').length).to.equal(5)
|
||||||
screen.getByLabelText('Download')
|
screen.getByLabelText('Download')
|
||||||
screen.getByLabelText('Archive')
|
screen.getByLabelText('Archive')
|
||||||
screen.getByLabelText('Trash')
|
screen.getByLabelText('Trash')
|
||||||
|
screen.getByTitle('Tags')
|
||||||
|
screen.getByRole('button', { name: 'Create New Folder' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
Loading…
Reference in a new issue