diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index f70ab047de..974ef7fc6b 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/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": "",
diff --git a/services/web/frontend/js/features/project-list/components/sidebar/tags-list.tsx b/services/web/frontend/js/features/project-list/components/sidebar/tags-list.tsx
index c915b561cb..3eb379b5ff 100644
--- a/services/web/frontend/js/features/project-list/components/sidebar/tags-list.tsx
+++ b/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() {
{t('new_folder')}
- {_.sortBy(tags, ['name']).map((tag, index) => {
+ {sortBy(tags, tag => tag.name.toLowerCase()).map((tag, index) => {
return (
{
+ 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 (
+ <>
+
+
+
+
+
+
+ {t('add_to_folder')}
+
+ {sortBy(tags, tag => tag.name.toLowerCase()).map(tag => {
+ return (
+
+
+
+ )
+ })}
+
+
+
+
+
+
+
+ >
+ )
+}
+
+export default memo(TagsDropdown)
diff --git a/services/web/frontend/js/features/project-list/components/table/project-tools/project-tools.tsx b/services/web/frontend/js/features/project-list/components/table/project-tools/project-tools.tsx
index 7798abd052..68dee26ac8 100644
--- a/services/web/frontend/js/features/project-list/components/table/project-tools/project-tools.tsx
+++ b/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' && }
+ {!['archived', 'trashed'].includes(filter) && }
+
{selectedProjects.length === 1 &&
filter !== 'archived' &&
filter !== 'trashed' && }
diff --git a/services/web/frontend/js/features/project-list/context/project-list-context.tsx b/services/web/frontend/js/features/project-list/context/project-list-context.tsx
index bfc5b51c88..9ca6264962 100644
--- a/services/web/frontend/js/features/project-list/context/project-list-context.tsx
+++ b/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>
@@ -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,
diff --git a/services/web/frontend/js/features/project-list/hooks/use-tag.tsx b/services/web/frontend/js/features/project-list/hooks/use-tag.tsx
index 15546e18b8..adc4189f67 100644
--- a/services/web/frontend/js/features/project-list/hooks/use-tag.tsx
+++ b/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(false)
const [renamingTag, setRenamingTag] = useState()
const [deletingTag, setDeletingTag] = useState()
@@ -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(
diff --git a/services/web/frontend/js/features/project-list/util/api.ts b/services/web/frontend/js/features/project-list/util/api.ts
index 129664ec0d..1405b7ccdb 100644
--- a/services/web/frontend/js/features/project-list/util/api.ts
+++ b/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}`)
}
diff --git a/services/web/frontend/stylesheets/app/project-list-react.less b/services/web/frontend/stylesheets/app/project-list-react.less
index b203e0a5bb..6af532186e 100644
--- a/services/web/frontend/stylesheets/app/project-list-react.less
+++ b/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;
+ }
+}
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index b27f5e5ea7..87f074a82c 100644
--- a/services/web/locales/en.json
+++ b/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",
diff --git a/services/web/test/frontend/features/project-list/components/table/project-tools/project-tools.test.tsx b/services/web/test/frontend/features/project-list/components/table/project-tools/project-tools.test.tsx
index b2ce94fff3..f321f094c6 100644
--- a/services/web/test/frontend/features/project-list/components/table/project-tools/project-tools.test.tsx
+++ b/services/web/test/frontend/features/project-list/components/table/project-tools/project-tools.test.tsx
@@ -13,9 +13,11 @@ describe('', function () {
it('renders the project tools for the all projects filter', function () {
renderWithProjectListContext()
- 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' })
})
})