diff --git a/client/src/components/DatasetInformation/DatasetDetails.vue b/client/src/components/DatasetInformation/DatasetDetails.vue
index 9c202627343..b8a7dfdb10f 100644
--- a/client/src/components/DatasetInformation/DatasetDetails.vue
+++ b/client/src/components/DatasetInformation/DatasetDetails.vue
@@ -4,7 +4,7 @@
:id="datasetId"
v-slot="{ result: dataset, loading: isDatasetLoading, error: datasetLoadingError }">
-
Dataset Details
+
Dataset Details
diff --git a/client/src/components/History/Content/ContentItem.test.js b/client/src/components/History/Content/ContentItem.test.js
index e731b526132..ce8e4673233 100644
--- a/client/src/components/History/Content/ContentItem.test.js
+++ b/client/src/components/History/Content/ContentItem.test.js
@@ -30,50 +30,70 @@ describe("ContentItem", () => {
name: "name",
selected: false,
selectable: false,
+ filterable: true,
},
localVue,
stubs: {
DatasetDetails: true,
vueTagsInput: false,
},
+ provide: {
+ store: {
+ dispatch: jest.fn,
+ getters: {},
+ },
+ },
});
});
it("check basics", async () => {
expect(wrapper.attributes("data-hid")).toBe("1");
expect(wrapper.find(".content-title").text()).toBe("name");
- const tags = wrapper.find(".alltags").findAll(".ti-tag");
+ const tags = wrapper.find(".stateless-tags").findAll(".tag");
+
// verify tags
expect(tags.length).toBe(3);
+
for (let i = 0; i < 3; i++) {
expect(tags.at(i).text()).toBe(`tag${i + 1}`);
- await tags.at(i).find(".tag-name").trigger("click");
+
+ await tags.at(i).trigger("click");
expect(wrapper.emitted()["tag-click"][i][0]).toBe(`tag${i + 1}`);
}
+
// close all tags
for (let i = 0; i < 3; i++) {
- const tagRemover = wrapper.find(".ti-icon-close");
+ const tagRemover = wrapper.find(`.tag[data-option=tag${i + 1}] button`);
+
await tagRemover.trigger("click");
expect(wrapper.emitted()["tag-change"][i][1]).not.toContain(`tag${i + 1}`);
}
+
await wrapper.setProps({ isHistoryItem: false, item: { tags: [] } });
- expect(wrapper.find(".alltags").exists()).toBe(false);
+ expect(wrapper.find(".stateless-tags").exists()).toBe(false);
+
// expansion button
const $el = wrapper.find(".cursor-pointer");
$el.trigger("click");
expect(wrapper.emitted()["update:expand-dataset"]).toBeDefined();
+
// select and unselect
const noSelector = wrapper.find(".selector > svg");
expect(noSelector.exists()).toBe(false);
+
await wrapper.setProps({ selectable: true });
expect(wrapper.classes()).toEqual(expect.arrayContaining(["alert-success"]));
+
const selector = wrapper.find(".selector > svg");
expect(selector.attributes("data-icon")).toBe("square");
selector.trigger("click");
+
await localVue.nextTick();
expect(wrapper.emitted()["update:selected"][0][0]).toBe(true);
+
await wrapper.setProps({ selected: true });
selector.trigger("click");
+
await localVue.nextTick();
expect(wrapper.emitted()["update:selected"][1][0]).toBe(false);
expect(wrapper.classes()).toEqual(expect.arrayContaining(["alert-info"]));
diff --git a/client/src/components/History/Content/ContentItem.vue b/client/src/components/History/Content/ContentItem.vue
index 0ff44351494..a99b3053ea0 100644
--- a/client/src/components/History/Content/ContentItem.vue
+++ b/client/src/components/History/Content/ContentItem.vue
@@ -3,37 +3,18 @@
:id="contentId"
:class="['content-item m-1 p-0 rounded btn-transparent-background', contentCls]"
:data-hid="id"
- :data-state="state">
-
+ :data-state="state"
+ tabindex="0"
+ role="button"
+ @keydown="onKeyDown">
+
-
-
-
-
-
+
+
+
+
+
+ :clickable="filterable"
+ :useToggleLink="false"
+ @input="onTags"
+ @tag-click="onTagClick" />
-
diff --git a/client/src/components/History/Content/ContentOptions.vue b/client/src/components/History/Content/ContentOptions.vue
index c2a9d8e4c49..d1b442cc73d 100644
--- a/client/src/components/History/Content/ContentOptions.vue
+++ b/client/src/components/History/Content/ContentOptions.vue
@@ -17,6 +17,7 @@
v-if="isDataset"
:disabled="displayDisabled"
:title="displayButtonTitle"
+ :tabindex="tabindex"
class="display-btn px-1"
size="sm"
variant="link"
@@ -28,6 +29,7 @@
v-if="writable && isHistoryItem"
:disabled="editDisabled"
:title="editButtonTitle"
+ :tabindex="tabindex"
class="edit-btn px-1"
size="sm"
variant="link"
@@ -37,6 +39,7 @@
diff --git a/client/src/components/History/CurrentCollection/CollectionPanel.vue b/client/src/components/History/CurrentCollection/CollectionPanel.vue
index 68af5a016f4..eb69d0ee598 100644
--- a/client/src/components/History/CurrentCollection/CollectionPanel.vue
+++ b/client/src/components/History/CurrentCollection/CollectionPanel.vue
@@ -28,6 +28,7 @@
:name="item.element_identifier"
:expand-dataset="isExpanded(item)"
:is-dataset="item.element_type == 'hda'"
+ :filterable="filterable"
@update:expand-dataset="setExpanded(item, $event)"
@view-collection="onViewSubCollection" />
@@ -63,6 +64,7 @@ export default {
history: { type: Object, required: true },
selectedCollections: { type: Array, required: true },
showControls: { type: Boolean, default: true },
+ filterable: { type: Boolean, default: false },
},
data() {
return {
diff --git a/client/src/components/History/CurrentHistory/HistoryPanel.vue b/client/src/components/History/CurrentHistory/HistoryPanel.vue
index 45b4cafeb58..af66154bcd7 100644
--- a/client/src/components/History/CurrentHistory/HistoryPanel.vue
+++ b/client/src/components/History/CurrentHistory/HistoryPanel.vue
@@ -123,6 +123,7 @@
:highlight="getHighlight(item)"
:selected="isSelected(item)"
:selectable="showSelection"
+ :filterable="filterable"
@tag-click="onTagClick"
@tag-change="onTagChange"
@toggleHighlights="toggleHighlights"
@@ -194,6 +195,7 @@ export default {
filter: { type: String, default: "" },
writable: { type: Boolean, default: true },
showControls: { type: Boolean, default: true },
+ filterable: { type: Boolean, default: false },
},
data() {
return {
diff --git a/client/src/components/History/Index.vue b/client/src/components/History/Index.vue
index da6677092f2..91aff574db1 100644
--- a/client/src/components/History/Index.vue
+++ b/client/src/components/History/Index.vue
@@ -6,6 +6,7 @@
v-if="!breadcrumbs.length"
:list-offset="listOffset"
:history="currentHistory"
+ :filterable="true"
v-on="handlers"
@view-collection="onViewCollection">
diff --git a/client/src/components/History/Layout/DetailsLayout.vue b/client/src/components/History/Layout/DetailsLayout.vue
index 629388419b3..df5219098f2 100644
--- a/client/src/components/History/Layout/DetailsLayout.vue
+++ b/client/src/components/History/Layout/DetailsLayout.vue
@@ -67,7 +67,7 @@
+
+
+
+
+
+
+
+
diff --git a/client/src/components/TagsMultiselect/Tag.test.js b/client/src/components/TagsMultiselect/Tag.test.js
new file mode 100644
index 00000000000..bc507d45b20
--- /dev/null
+++ b/client/src/components/TagsMultiselect/Tag.test.js
@@ -0,0 +1,109 @@
+import { getLocalVue } from "tests/jest/helpers";
+import { mount } from "@vue/test-utils";
+import Tag from "./Tag";
+
+const localVue = getLocalVue();
+
+const mountWithProps = (props) => {
+ return mount(Tag, {
+ propsData: props,
+ localVue,
+ });
+};
+
+describe("Tag", () => {
+ it("displays it's option", () => {
+ {
+ const tag = mountWithProps({ option: "my_tag" });
+ expect(tag.text()).toBe("my_tag");
+ }
+
+ {
+ const tag = mountWithProps({ option: "a_longer_tag_name" });
+ expect(tag.text()).toBe("a_longer_tag_name");
+ }
+ });
+
+ it("shows it's clickable", async () => {
+ const tag = mountWithProps({ option: "my_tag" });
+
+ expect(tag.classes()).not.toContain("clickable");
+
+ tag.setProps({ clickable: true });
+ await tag.vm.$nextTick();
+
+ expect(tag.classes()).toContain("clickable");
+ });
+
+ it("can be clicked", async () => {
+ const tag = mountWithProps({ option: "my_tag", clickable: true });
+
+ expect(tag.classes()).toContain("clickable");
+
+ tag.trigger("click");
+ await tag.vm.$nextTick();
+
+ expect(tag.emitted().click).toBeTruthy();
+ expect(tag.emitted().click.length).toBe(1);
+ expect(tag.emitted().click[0]).toEqual(["my_tag"]);
+
+ tag.trigger("click");
+ await tag.vm.$nextTick();
+
+ expect(tag.emitted().click.length).toBe(2);
+ expect(tag.emitted().click).toStrictEqual([["my_tag"], ["my_tag"]]);
+ });
+
+ it("changes appearance when editable", async () => {
+ const tag = mountWithProps({ option: "my_tag" });
+
+ expect(tag.classes()).not.toContain("editable");
+ expect(tag.find(".tag-delete-button").exists()).not.toBe(true);
+
+ tag.setProps({ editable: true });
+ await tag.vm.$nextTick();
+
+ expect(tag.classes()).toContain("editable");
+ expect(tag.find(".tag-delete-button").exists()).toBe(true);
+ });
+
+ it("can be deleted", async () => {
+ const tag = mountWithProps({ option: "my_tag", editable: true });
+
+ expect(tag.find(".tag-delete-button").exists()).toBe(true);
+
+ tag.find(".tag-delete-button").trigger("click");
+ await tag.vm.$nextTick();
+
+ expect(tag.emitted().deleted).toBeTruthy();
+ expect(tag.emitted().deleted.length).toBe(1);
+ expect(tag.emitted().deleted[0]).toEqual(["my_tag"]);
+
+ expect(tag.emitted().click).toBeFalsy();
+ });
+
+ it("displays named tags bold", () => {
+ {
+ const wrapper = mountWithProps({ option: "my_tag" });
+ const span = wrapper.find(".tag span");
+ expect(span.classes()).not.toContain("font-weight-bold");
+ }
+
+ {
+ const wrapper = mountWithProps({ option: "#named_tag" });
+ const span = wrapper.find(".tag span");
+ expect(span.classes()).toContain("font-weight-bold");
+ }
+ });
+
+ it("highlights when searched", async () => {
+ const tag = mountWithProps({ option: "my_tag" });
+
+ expect(tag.classes()).not.toContain("searched");
+
+ tag.setProps({ search: "my_tag" });
+ await tag.vm.$nextTick();
+
+ expect(tag.classes()).toContain("searched");
+ });
+});
diff --git a/client/src/components/TagsMultiselect/Tag.vue b/client/src/components/TagsMultiselect/Tag.vue
new file mode 100644
index 00000000000..87dd4396e1b
--- /dev/null
+++ b/client/src/components/TagsMultiselect/Tag.vue
@@ -0,0 +1,112 @@
+
+
+
+
+
+ {{ props.option }}
+
+
+
+
+
+
+
+
diff --git a/client/src/composables/user.js b/client/src/composables/user.js
deleted file mode 100644
index 0910ec0ff7c..00000000000
--- a/client/src/composables/user.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import { computed, onMounted, inject } from "vue";
-
-/**
- * composable user store wrapper
- * @returns currentUser computed
- */
-export function useCurrentUser() {
- const store = inject("store");
-
- const currentUser = computed(() => store.getters["user/currentUser"]);
-
- onMounted(() => {
- store.dispatch("user/loadUser");
- });
-
- const addFavoriteTool = async (toolId) => await store.dispatch("user/addFavoriteTool", toolId);
- const removeFavoriteTool = async (toolId) => await store.dispatch("user/removeFavoriteTool", toolId);
-
- return { currentUser, addFavoriteTool, removeFavoriteTool };
-}
diff --git a/client/src/composables/user.ts b/client/src/composables/user.ts
new file mode 100644
index 00000000000..0f777bb36bc
--- /dev/null
+++ b/client/src/composables/user.ts
@@ -0,0 +1,62 @@
+import { computed, onMounted, inject, ref, unref } from "vue";
+import type { Ref } from "vue";
+import type { Store } from "vuex";
+
+// TODO: support computed for "noFetch"
+/**
+ * composable user store wrapper
+ * @param noFetch when true, the user will not be fetched from the server
+ * @returns currentUser computed
+ */
+export function useCurrentUser(noFetch: boolean | Ref = false) {
+ // TODO: add store typing
+ const store = inject("store") as Store;
+
+ const currentUser = computed(() => store.getters["user/currentUser"]);
+
+ onMounted(() => {
+ if (!unref(noFetch)) {
+ store.dispatch("user/loadUser");
+ }
+ });
+
+ const addFavoriteTool = async (toolId: string) => {
+ await store.dispatch("user/addFavoriteTool", toolId);
+ };
+
+ const removeFavoriteTool = async (toolId: string) => {
+ await store.dispatch("user/removeFavoriteTool", toolId);
+ };
+
+ return { currentUser, addFavoriteTool, removeFavoriteTool };
+}
+
+// temporarily stores tags which have not yet been fetched from the backend
+const localTags = ref([]);
+
+/**
+ * Keeps tracks of the tags the current user has used.
+ */
+export function useUserTags() {
+ const { currentUser } = useCurrentUser(true);
+
+ const userTags = computed(() => {
+ let tags: string[];
+
+ if (currentUser.value) {
+ tags = [...currentUser.value.tags_used, ...localTags.value];
+ } else {
+ tags = localTags.value;
+ }
+
+ const tagSet = new Set(tags);
+
+ return Array.from(tagSet).map((tag) => tag.replace(/^name:/, "#"));
+ });
+
+ const addLocalTag = (tag: string) => {
+ localTags.value.push(tag);
+ };
+
+ return { userTags, addLocalTag };
+}
diff --git a/client/src/composables/utils/uid.js b/client/src/composables/utils/uid.js
new file mode 100644
index 00000000000..812bc1edf7e
--- /dev/null
+++ b/client/src/composables/utils/uid.js
@@ -0,0 +1,14 @@
+import { computed, unref } from "vue";
+
+var idCounter = 0;
+
+/**
+ * Returns a page-unique id with and optional reactive prefix
+ */
+export function useUid(prefix = "") {
+ const id = idCounter;
+ idCounter += 1;
+
+ const uid = computed(() => `${unref(prefix)}${id}`);
+ return uid;
+}
diff --git a/client/src/style/scss/ui.scss b/client/src/style/scss/ui.scss
index 14859cd17e1..5f6f0d4cbfb 100644
--- a/client/src/style/scss/ui.scss
+++ b/client/src/style/scss/ui.scss
@@ -442,15 +442,3 @@ $ui-margin-horizontal-large: $margin-v * 2;
.h-text {
font-size: $font-size-base;
}
-
-/* hides element in a way where it can still be read by screen-readers */
-.hide-element {
- border: 0;
- clip: rect(1px, 1px, 1px, 1px);
- height: 1px;
- margin: -1px;
- overflow: hidden;
- padding: 0;
- position: absolute;
- width: 1px;
-}
diff --git a/client/src/utils/color.js b/client/src/utils/color.js
index 054f0429a37..f75ff5ed6bd 100644
--- a/client/src/utils/color.js
+++ b/client/src/utils/color.js
@@ -18,7 +18,8 @@ export function keyedColorScheme(strKey) {
const [r, g, b] = hsluvToRgb([hue, 100, lightness]);
const primary = `rgb(${r * 255},${g * 255},${b * 255})`;
- const darker = hsluvToHex([hue, 100, lightness - 20]);
+ const darker = hsluvToHex([hue, 100, lightness * 0.9]);
+ const dimmed = hsluvToHex([hue, 100, lightness * 0.95]);
- return { primary, darker };
+ return { primary, darker, dimmed };
}
diff --git a/client/src/utils/navigation/navigation.yml b/client/src/utils/navigation/navigation.yml
index 9ec0762b59a..81ff0f34969 100644
--- a/client/src/utils/navigation/navigation.yml
+++ b/client/src/utils/navigation/navigation.yml
@@ -158,7 +158,7 @@ history_panel:
info_button: '${_} .params-btn'
tool_help_button: '${_} .fa.fa-question'
rerun_button: '${_} .rerun-btn'
- alltags: '${_} .alltags .ti-tags'
+ alltags: '${_} .stateless-tags .tag'
# history content item
content_item:
@@ -188,7 +188,7 @@ history_panel:
# Action buttons...
download_button: '${_} .download-btn'
info_button: '${_} .params-btn'
- alltags: '${_} .alltags .ti-tags'
+ alltags: '${_} .stateless-tags .tag'
metadata_file_download: '${_} [data-description="download ${metadata_name}"]'
dataset_operations_dropdown: '${_} .dataset-actions'
@@ -202,18 +202,18 @@ history_panel:
form: '${_} [data-description="edit form"]'
name_input: '${_} [data-description="name input"]'
annotation_input: '${_} [data-description="annotation input"]'
- tags_input: '${_} .tags input'
+ tags_input: '${_} .stateless-tags button'
save_button: '${_} [data-description="editor save button"]'
# history tags
tag_editor:
selectors:
- _: '${scope} .details .tags'
+ _: '${scope} .details .stateless-tags'
toggle: '${_} .toggle-link'
- display: '${_} .tag-area .tag-name'
+ display: '${_} .tag span'
input: '${_} input'
- tag_area: '${_} .tag-area'
- tag_close_btn: '${_} .ti-icon-close'
+ tag_area: '${_} .multiselect__tags'
+ tag_close_btn: '${_} .tag-delete-button'
multi_operations:
selectors:
@@ -237,7 +237,8 @@ history_panel:
title_input: '.dataset-collection-panel .controls .title input'
subtitle: '.dataset-collection-panel .controls .title .subtitle'
elements_warning: '.dataset-collection-panel .controls .elements-warning'
- tag_area_input: '.controls .tags-display .tags-input input'
+ tag_area_button: '.details .stateless-tags .multiselect button'
+ tag_area_input: '.details .stateless-tags .multiselect input'
list_items: '.dataset-collection-panel .listing .content-item'
selectors:
@@ -251,11 +252,11 @@ history_panel:
contents: '.history-index .content-item'
empty_message: '.empty-message'
size: '.history-size'
- tag_icon: '.actions .history-tag-btn'
- tag_area: '.details .tags-display'
- tag_area_input: '.details .tags-display .tags-input input'
- tag_close_btn: '.tags-display .ti-icon-close'
- tags: 'li.ti-tag.ti-valid .tag-name'
+ tag_area: '.details .stateless-tags'
+ tag_area_button: '.details .stateless-tags .multiselect button'
+ tag_area_input: '.details .stateless-tags .multiselect input'
+ tag_close_btn: '.tags-display .tag-delete-button'
+ tags: '.tag span'
annotation_icon: '.actions .history-annotate-btn'
annotation_area: '.details .history-annotation'
diff --git a/lib/galaxy/managers/taggable.py b/lib/galaxy/managers/taggable.py
index 196f1df7ac9..31b44574710 100644
--- a/lib/galaxy/managers/taggable.py
+++ b/lib/galaxy/managers/taggable.py
@@ -5,6 +5,7 @@ Mixins for Taggable model managers and serializers.
# from galaxy import exceptions as galaxy_exceptions
import logging
+import re
from typing import Type
from sqlalchemy import (
@@ -36,7 +37,9 @@ def _tag_str_gen(item):
def _tags_to_strings(item):
if not hasattr(item, "tags"):
return None
- return sorted(list(_tag_str_gen(item)))
+ tag_list = list(_tag_str_gen(item))
+ # consider named tags while sorting
+ return sorted(tag_list, key=lambda str: re.sub("^name:", "#", str))
def _tags_from_strings(item, tag_handler, new_tags_list, user=None):
diff --git a/lib/galaxy/managers/users.py b/lib/galaxy/managers/users.py
index f29aac5f002..a3b31ec8843 100644
--- a/lib/galaxy/managers/users.py
+++ b/lib/galaxy/managers/users.py
@@ -4,6 +4,7 @@ Manager and Serializer for Users.
import hashlib
import logging
import random
+import re
import socket
import time
from datetime import datetime
@@ -414,7 +415,8 @@ class UserManager(base.ModelManager, deletable.PurgableManagerMixin):
# boil the tag tuples down into a sorted list of DISTINCT name:val strings
tags = all_tags_query.distinct().all()
tags = [(f"{name}:{val}" if val else name) for name, val in tags]
- return sorted(tags)
+ # consider named tags while sorting
+ return sorted(tags, key=lambda str: re.sub("^name:", "#", str))
def change_password(self, trans, password=None, confirm=None, token=None, id=None, current=None):
"""
diff --git a/lib/galaxy/selenium/navigates_galaxy.py b/lib/galaxy/selenium/navigates_galaxy.py
index ebb40283497..255acecda96 100644
--- a/lib/galaxy/selenium/navigates_galaxy.py
+++ b/lib/galaxy/selenium/navigates_galaxy.py
@@ -1515,22 +1515,18 @@ class NavigatesGalaxy(HasDriver):
@edit_details
def history_panel_add_tags(self, tags):
- tag_icon = self.components.history_panel.tag_icon
- tag_area = self.components.history_panel.tag_area
- tag_area_input = self.components.history_panel.tag_area_input
+ tag_area_button = self.components.history_panel.tag_area_button
- # if the tag editor is not present but the tag_icon is, then click it
- if not tag_icon.is_absent and (tag_area.is_absent or not tag_area.is_displayed):
- tag_icon.wait_for_and_click()
-
- input_element = tag_area_input.wait_for_and_click()
- self.sleep_for(self.wait_types.UX_RENDER)
+ tag_area_button.wait_for_and_click()
+ input_element = self.components.history_panel.tag_area_input.wait_for_visible()
for tag in tags:
input_element.send_keys(tag)
self.send_enter(input_element)
self.sleep_for(self.wait_types.UX_RENDER)
+ self.send_escape(input_element)
+
@edit_details
def history_panel_rename(self, new_name):
editable_text_input_element = self.history_panel_name_input()
diff --git a/lib/galaxy/tools/tag_collection_from_file.xml b/lib/galaxy/tools/tag_collection_from_file.xml
index 72bf1780b73..615684fed03 100644
--- a/lib/galaxy/tools/tag_collection_from_file.xml
+++ b/lib/galaxy/tools/tag_collection_from_file.xml
@@ -37,13 +37,13 @@
-
+
-
+
@@ -66,13 +66,13 @@
-
+
-
+
@@ -92,7 +92,7 @@ Description
The relationship between element names and tags is specified in a two column tab-delimited file. This file may contain less entries than elements in the collection. In that case only matching list identifiers will be tagged.
-To create name: or group: tags prepend them with ``#`` (you can also use ``name:``) or ``group:``, respectively.
+To create name: or group: tags prepend them with ``#`` (you can also use ``name:``) or ``group:``, respectively.
===============
More about tags
@@ -100,7 +100,7 @@ More about tags
Galaxy allows tagging datasets to facilitate analyses. There are several types of tags including simple tags, name tags, and group tags. **Simple** tags allow you to attach an alternative label to a dataset, which will make it easier to find it later. **Name** tags allow you to track propagation of a dataset through the analyses: all datasets derived from the initial dataset labeled with a name tag will inherit it. Finally, **group** tags allow you to label group of datasets. This is useful. for example, for differential expression analysis where you can have two groups of datasets labeled as "treatment" and "control".
-To learn mote about tags go to `our training site`_.
+To learn mote about tags go to `our training site`_.
.. _our training site: https://training.galaxyproject.org/training-material/search?query=tags
diff --git a/lib/galaxy_test/selenium/test_history_panel.py b/lib/galaxy_test/selenium/test_history_panel.py
index 883022b028d..3e367ca2ac1 100644
--- a/lib/galaxy_test/selenium/test_history_panel.py
+++ b/lib/galaxy_test/selenium/test_history_panel.py
@@ -106,7 +106,7 @@ class TestHistoryPanel(SeleniumTestCase):
assert_no_tags()
# add new tags to empty tags area
- tags_size = 5
+ tags_size = 6
tags = add_tags(tags_size)
assert_current_tags(tags)