Merge pull request #14936 from ElectronicBlueberry/tags

New Tag component based on VueMultiselect
This commit is contained in:
Dannon
2022-12-08 11:52:17 -05:00
committed by GitHub
24 changed files with 947 additions and 168 deletions
@@ -4,7 +4,7 @@
:id="datasetId"
v-slot="{ result: dataset, loading: isDatasetLoading, error: datasetLoadingError }">
<div aria-labelledby="dataset-details-heading">
<h1 id="dataset-details-heading" class="hide-element">Dataset Details</h1>
<h1 id="dataset-details-heading" class="sr-only">Dataset Details</h1>
<LoadingSpan v-if="isDatasetLoading" />
<Alert v-else-if="datasetLoadingError" :message="datasetLoadingError" variant="error" />
<CurrentUser v-else v-slot="{ user }">
@@ -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"]));
@@ -3,37 +3,18 @@
:id="contentId"
:class="['content-item m-1 p-0 rounded btn-transparent-background', contentCls]"
:data-hid="id"
:data-state="state">
<div
class="p-1 cursor-pointer"
draggable
tabindex="0"
@dragstart="onDragStart"
@click.stop="onClick"
@keypress="onClick">
:data-state="state"
tabindex="0"
role="button"
@keydown="onKeyDown">
<div class="p-1 cursor-pointer" draggable @dragstart="onDragStart" @click.stop="onClick">
<div class="d-flex justify-content-between">
<span class="p-1 font-weight-bold">
<span v-if="selectable" class="selector">
<icon
v-if="selected"
fixed-width
size="lg"
:icon="['far', 'check-square']"
@click.stop="$emit('update:selected', false)" />
<icon
v-else
fixed-width
size="lg"
:icon="['far', 'square']"
@click.stop="$emit('update:selected', true)" />
</span>
<span
v-if="highlight == 'input'"
v-b-tooltip.hover
title="Input"
tabindex="0"
@click.stop="toggleHighlights"
@keypress="toggleHighlights">
<b-button v-if="selectable" class="selector p-0" @click.stop="$emit('update:selected', !selected)">
<icon v-if="selected" fixed-width size="lg" :icon="['far', 'check-square']" />
<icon v-else fixed-width size="lg" :icon="['far', 'square']" />
</b-button>
<span v-if="highlight == 'input'" v-b-tooltip.hover title="Input" @click.stop="toggleHighlights">
<font-awesome-icon class="text-info" icon="arrow-circle-up" />
</span>
<span
@@ -75,6 +56,7 @@
:is-visible="item.visible"
:state="state"
:item-urls="itemUrls"
:keyboard-selectable="expandDataset"
@delete="$emit('delete')"
@display="onDisplay"
@showCollectionInfo="onShowCollectionInfo"
@@ -92,12 +74,12 @@
:elements-datatypes="item.elements_datatypes" />
<StatelessTags
v-if="!tagsDisabled || hasTags"
class="alltags p-1"
:value="tags"
:use-toggle-link="false"
:disabled="tagsDisabled"
@tag-click="onTagClick"
@input="onTags" />
:clickable="filterable"
:useToggleLink="false"
@input="onTags"
@tag-click="onTagClick" />
<!-- collections are not expandable, so we only need the DatasetDetails component here -->
<b-collapse :visible="expandDataset">
<DatasetDetails
@@ -113,7 +95,7 @@
</template>
<script>
import { StatelessTags } from "components/Tags";
import StatelessTags from "components/TagsMultiselect/StatelessTags";
import { STATES, HIERARCHICAL_COLLECTION_JOB_STATES } from "./model/states";
import CollectionDescription from "./Collection/CollectionDescription";
import ContentOptions from "./ContentOptions";
@@ -145,6 +127,7 @@ export default {
name: { type: String, required: true },
selected: { type: Boolean, default: false },
selectable: { type: Boolean, default: false },
filterable: { type: Boolean, default: false },
},
computed: {
jobState() {
@@ -216,6 +199,15 @@ export default {
},
},
methods: {
onKeyDown(event) {
if (!event.target.classList.contains("content-item")) {
return;
}
if (event.key === "Enter" || event.key === " ") {
this.onClick();
}
},
onClick() {
if (this.isDataset) {
this.$emit("update:expand-dataset", !this.expandDataset);
@@ -242,7 +234,9 @@ export default {
updateContentFields(this.item, { tags: newTags });
},
onTagClick(tag) {
this.$emit("tag-click", tag.label);
if (this.filterable) {
this.$emit("tag-click", tag);
}
},
toggleHighlights() {
this.$emit("toggleHighlights", this.item);
@@ -250,10 +244,21 @@ export default {
},
};
</script>
<style lang="scss">
<style lang="scss" scoped>
@import "~bootstrap/scss/_functions.scss";
@import "theme/blue.scss";
.content-item {
cursor: default;
.name {
word-break: break-all;
}
// improve focus visibility
&:deep(.btn:focus) {
box-shadow: 0 0 0 0.2rem transparentize($brand-primary, 0.75);
}
}
</style>
@@ -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 @@
</b-button>
<b-button
v-if="writable && isHistoryItem && !isDeleted"
:tabindex="tabindex"
class="delete-btn px-1"
title="Delete"
size="sm"
@@ -46,6 +49,7 @@
</b-button>
<b-button
v-if="writable && isHistoryItem && isDeleted"
:tabindex="tabindex"
class="undelete-btn px-1"
title="Undelete"
size="sm"
@@ -55,6 +59,7 @@
</b-button>
<b-button
v-if="writable && isHistoryItem && !isVisible"
:tabindex="tabindex"
class="unhide-btn px-1"
title="Unhide"
size="sm"
@@ -76,6 +81,7 @@ export default {
isVisible: { type: Boolean, default: true },
state: { type: String, default: "" },
itemUrls: { type: Object, required: true },
keyboardSelectable: { type: Boolean, default: true },
},
computed: {
displayButtonTitle() {
@@ -111,6 +117,9 @@ export default {
showCollectionDetailsUrl() {
return prependPath(this.itemUrls.showDetails);
},
tabindex() {
return this.keyboardSelectable ? "0" : "-1";
},
},
};
</script>
@@ -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" />
</template>
@@ -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 {
@@ -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 {
+1
View File
@@ -6,6 +6,7 @@
v-if="!breadcrumbs.length"
:list-offset="listOffset"
:history="currentHistory"
:filterable="true"
v-on="handlers"
@view-collection="onViewCollection">
<template v-slot:navigation>
@@ -67,7 +67,7 @@
<script>
import { mapGetters } from "vuex";
import short from "components/directives/v-short";
import { StatelessTags } from "components/Tags";
import StatelessTags from "components/TagsMultiselect/StatelessTags";
export default {
components: {
+40 -61
View File
@@ -9,76 +9,55 @@ import { keyedColorScheme } from "utils/color";
// separated by a period, and then an optional value after a colon.
export const VALID_TAG_RE = /^([^\s.:])+(.[^\s.:]+)*(:[^\s.:]+)?$/;
function TagModel(props = {}) {
this.text = "";
export class TagModel {
/**
* @param {string || object} data
*/
constructor(data) {
let props = {};
// special handling for name:thing tags
if (props.text && props.text.startsWith("#")) {
props.text = props.text.replace("#", "name:");
switch (typeof data) {
case "string":
props = { text: data };
break;
case "object":
props = data;
break;
}
Object.assign(this, props);
this.text = props.text ?? "";
this.label = this.text.replace(/^name:/, "#");
this.text = this.text.replace(/^#/, "name:");
this.style = "";
if (this.text.startsWith("name:")) {
this.style += "font-weight: bold;";
}
const { primary, darker } = keyedColorScheme(this.text);
this.style += `background-color: ${primary};`;
this.style += "color: black;";
this.style += `border-color: ${darker};`;
this.valid = VALID_TAG_RE.test(this.text);
}
Object.assign(this, props);
equals(otherTag) {
return this.text === otherTag.text;
}
// Need to do Object.defineProperty instead of a class getter to make
// style enumerable for vue-tags-input
Object.defineProperty(this, "style", {
enumerable: true,
get: function () {
const { primary, darker } = keyedColorScheme(this.text);
const styles = {
"background-color": primary,
color: "black",
"border-color": darker,
};
if (this.text.startsWith("name:")) {
styles["font-weight"] = "bold";
}
return Object.keys(styles)
.map((prop) => `${prop}: ${styles[prop]}`)
.join(";");
},
});
// Changes name:foo to #foo
Object.defineProperty(this, "label", {
enumerable: true,
get: function () {
return this.text.startsWith("name:") ? this.text.replace("name:", "#") : this.text;
},
});
// valid flag
Object.defineProperty(this, "valid", {
enumerable: false,
get: function () {
return VALID_TAG_RE.test(this.text);
},
});
toString() {
return this.text;
}
}
TagModel.prototype.equals = function (otherTag) {
return this.text == otherTag.text;
};
TagModel.prototype.toString = function () {
return this.text;
};
// Public factory
export function createTag(data) {
let props = {};
switch (typeof data) {
case "string":
props = { text: data };
break;
case "object":
props = Object.assign({}, data);
break;
}
return new TagModel(props);
return new TagModel(data);
}
// Returns tags in "newTags" that aren't present in "existingTags"
@@ -0,0 +1,136 @@
import { getLocalVue } from "tests/jest/helpers";
import { mount } from "@vue/test-utils";
import { useUserTags } from "composables/user";
import { useToast } from "composables/toast";
import { computed } from "vue";
import StatelessTags from "./StatelessTags";
const autocompleteTags = ["#named_user_tag", "abc", "my_tag"];
const localVue = getLocalVue();
const mountWithProps = (props) => {
return mount(StatelessTags, {
propsData: props,
localVue,
});
};
jest.mock("composables/user");
const addLocalTagMock = jest.fn((tag) => tag);
useUserTags.mockReturnValue({
userTags: computed(() => autocompleteTags),
addLocalTag: addLocalTagMock,
});
jest.mock("composables/toast");
const warningMock = jest.fn((message, title) => {
return { message, title };
});
useToast.mockReturnValue({
warning: warningMock,
});
describe("StatelessTags", () => {
it("shows tags", () => {
const wrapper = mountWithProps({
value: ["tag_1", "tag_2", "tags:tag_3"],
disabled: true,
});
expect(wrapper.find(".tag").exists()).toBe(true);
const tags = wrapper.findAll(".tag");
expect(tags.length).toBe(3);
expect(tags.at(0).text()).toBe("tag_1");
expect(tags.at(1).text()).toBe("tag_2");
expect(tags.at(2).text()).toBe("tags:tag_3");
});
it("formats named tags", () => {
const wrapper = mountWithProps({
value: ["name:tag_1", "tag_2", "name:tag_3"],
disabled: true,
});
const tags = wrapper.findAll(".tag");
expect(tags.at(0).text()).toBe("#tag_1");
expect(tags.at(1).text()).toBe("tag_2");
expect(tags.at(2).text()).toBe("#tag_3");
});
it("shows autocomplete options", async () => {
const wrapper = mountWithProps({
disabled: false,
});
const multiselect = wrapper.find(".multiselect");
multiselect.find("button").trigger("click");
await wrapper.vm.$nextTick();
const options = multiselect.findAll(".multiselect-option");
const visibleOptions = options.filter((option) => option.isVisible());
expect(visibleOptions.length).toBe(autocompleteTags.length);
visibleOptions.wrappers.forEach((option, i) => {
expect(option.text()).toContain(autocompleteTags[i]);
});
});
it("adds new tags", async () => {
const wrapper = mountWithProps({
disabled: false,
});
const multiselect = wrapper.find(".multiselect");
multiselect.find("button").trigger("click");
await wrapper.vm.$nextTick();
await multiselect.find("input").setValue("new_tag");
await wrapper.vm.$nextTick();
multiselect.find(".multiselect-option").trigger("click");
await wrapper.vm.$nextTick();
expect(addLocalTagMock.mock.calls.length).toBe(1);
expect(addLocalTagMock.mock.results[0].value).toBe("new_tag");
});
it("warns about not allowed tags", async () => {
const wrapper = mountWithProps({
disabled: false,
});
const multiselect = wrapper.find(".multiselect");
multiselect.find("button").trigger("click");
await wrapper.vm.$nextTick();
await multiselect.find("input").setValue(":illegal_tag");
await wrapper.vm.$nextTick();
const option = multiselect.find(".multiselect-option");
expect(option.classes()).toContain("invalid");
option.trigger("click");
await wrapper.vm.$nextTick();
expect(warningMock.mock.calls.length).toBe(1);
expect(warningMock.mock.results[0].value.title).toBe("Invalid Tag");
});
it("hides too many tags", async () => {
const wrapper = mountWithProps({
value: ["tag_1", "tag_2", "tag_3", "tag_4", "tag_5", "tag_6"],
disabled: true,
useToggleLing: true,
maxVisibleTags: 4,
});
const tags = wrapper.findAll(".tag");
expect(tags.length).toBe(4);
const showMoreLink = wrapper.find(".toggle-link");
expect(showMoreLink.text()).toContain("2");
});
});
@@ -0,0 +1,357 @@
<script setup lang="ts">
import Multiselect from "vue-multiselect";
import Tag from "./Tag.vue";
import { ref, computed } from "vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { useUserTags } from "composables/user";
import { useToast } from "composables/toast";
import { useUid } from "composables/utils/uid";
import { library } from "@fortawesome/fontawesome-svg-core";
import { faTags, faCheck, faTimes, faPlus } from "@fortawesome/free-solid-svg-icons";
import type { Ref } from "vue";
export interface StatelessTagsProps {
value?: string[];
disabled?: boolean;
clickable?: boolean;
useToggleLink?: boolean;
maxVisibleTags?: number;
}
const props = withDefaults(defineProps<StatelessTagsProps>(), {
value: () => [],
disabled: false,
clickable: false,
useToggleLink: true,
maxVisibleTags: 5,
});
const emit = defineEmits<{
(e: "input", tags: string[]): void;
(e: "tag-click", tag: string): void;
}>();
//@ts-ignore bad library types
library.add(faTags, faCheck, faTimes, faPlus);
const { userTags, addLocalTag } = useUserTags();
const { warning } = useToast();
function onAddTag(tag: string) {
const newTag = tag.trim();
if (isValid(newTag)) {
addLocalTag(newTag);
emit("input", [...props.value, newTag]);
} else {
warning(`"${newTag}" is not a valid tag.`, "Invalid Tag");
}
}
function onInput(val: string[]) {
emit("input", val);
}
function onDelete(tag: string) {
const val = [...tags.value];
const index = tags.value.indexOf(tag);
val.splice(index, 1);
emit("input", val);
}
const editing = ref(false);
function onOpen() {
editing.value = true;
}
function onClose() {
editing.value = false;
}
const multiselectElement: Ref<Multiselect | null> = ref(null);
function openMultiselect() {
//@ts-ignore bad library types
multiselectElement.value?.activate();
}
const tags = computed(() => props.value.map((tag) => tag.replace(/^name:/, "#")));
const toggledOpen = ref(false);
const toggleButtonId = useUid("toggle-link-");
const trimmedTags = computed(() => {
if (!props.useToggleLink || toggledOpen.value) {
return tags.value;
} else {
return tags.value.slice(0, props.maxVisibleTags);
}
});
const slicedTags = computed(() => {
if (!props.useToggleLink) {
return [];
} else {
return tags.value.slice(props.maxVisibleTags);
}
});
const invalidTagRegex = /([.:\s][.:\s])|(^[.:])|([.:]$)|(^[\s]*$)/;
function isValid(tag: string | { label: string }) {
if (typeof tag === "string") {
return !tag.match(invalidTagRegex);
} else {
return !tag.label.match(invalidTagRegex);
}
}
function onTagClicked(tag: string) {
emit("tag-click", tag);
}
</script>
<template>
<div class="stateless-tags px-1">
<Multiselect
v-if="!disabled"
ref="multiselectElement"
placeholder="Add Tags"
openDirection="bottom"
:value="tags"
:options="userTags"
:multiple="true"
:taggable="true"
:closeOnSelect="false"
@tag="onAddTag"
@input="onInput"
@open="onOpen"
@close="onClose">
<template v-slot:tag="{ option, search }">
<Tag
:option="option"
:search="search"
:editable="true"
:clickable="props.clickable"
@deleted="onDelete"
@click="onTagClicked"></Tag>
</template>
<template v-slot:noOptions>
<span class="multiselect-option">Type to add new tag</span>
</template>
<template v-slot:caret>
<b-button v-if="!editing" class="toggle-button" variant="link" tabindex="-1" @click="openMultiselect">
Add Tags
<FontAwesomeIcon icon="fa-tags" />
</b-button>
</template>
<template v-slot:option="{ option }">
<span class="multiselect-option" :class="{ invalid: !isValid(option) }">
<span>{{ option.label ?? option }}</span>
<span v-if="tags.includes(option)" class="float-right">
<span class="info">
<FontAwesomeIcon class="check-icon" icon="fa-check" fixed-width />
</span>
<span class="info highlighted">
<FontAwesomeIcon class="times-icon" icon="fa-times" fixed-width />
<span class="sr-only">remove tag</span>
</span>
</span>
<span v-else class="float-right">
<span class="info highlighted">
<FontAwesomeIcon class="plus-icon" icon="fa-plus" fixed-width />
<span class="sr-only">add tag</span>
</span>
</span>
</span>
</template>
</Multiselect>
<div v-else class="pl-1 pb-2">
<div class="d-inline">
<Tag
v-for="tag in trimmedTags"
:key="tag"
:option="tag"
:editable="false"
:clickable="props.clickable"
@click="onTagClicked"></Tag>
<b-button
v-if="slicedTags.length > 0 && !toggledOpen"
:id="toggleButtonId"
variant="link"
class="toggle-link"
@click="() => (toggledOpen = true)">
{{ slicedTags.length }} more...
</b-button>
<b-tooltip
v-if="slicedTags.length > 0 && !toggledOpen"
:target="toggleButtonId"
custom-class="stateless-tags--tag-preview-tooltip"
placement="bottom">
<Tag
v-for="tag in slicedTags"
:key="tag"
:option="tag"
:editable="false"
:clickable="props.clickable"
@click="onTagClicked"></Tag>
</b-tooltip>
</div>
</div>
</div>
</template>
<style lang="scss">
.stateless-tags--tag-preview-tooltip {
opacity: 1 !important;
}
</style>
<style lang="scss" scoped>
@import "scss/theme/blue.scss";
.stateless-tags {
.toggle-link {
padding: 0;
border: none;
&:hover {
background-color: transparent;
border: none;
}
}
&:deep(.multiselect) {
min-height: unset;
display: flex;
flex-direction: column-reverse;
.multiselect__select {
top: unset;
bottom: 0;
padding: 0 0.25rem;
z-index: 1;
height: $font-size-base * 2;
&::before {
border-color: $text-color transparent transparent;
}
}
.multiselect__placeholder {
display: none;
}
.multiselect__tags-wrap {
margin-bottom: 0.5rem;
}
.multiselect__tags {
padding: 0 0.25rem;
background: none;
font-size: $font-size-base;
border: none;
min-height: 0;
}
.multiselect__content-wrapper {
top: 100%;
z-index: 800;
width: calc(100% - 4px);
left: 2px;
box-shadow: 0 0 6px 0 rgba(3, 0, 34, 0.048), 0 0 4px 0 rgba(3, 0, 34, 0.185);
}
&.multiselect--above .multiselect__content-wrapper {
top: unset;
}
.multiselect__input,
.toggle-button {
font-size: $font-size-base;
color: $text-color;
text-decoration: none;
padding: 0;
background: none;
cursor: text;
text-align: left;
margin: 0;
border: none;
}
.multiselect__input {
padding-left: 0.25rem;
}
.toggle-button {
padding-left: 0.5rem;
}
// built in option class
.multiselect__option {
min-height: unset;
padding: 0;
&::after {
display: none;
}
// custom option wrapper
.multiselect-option {
font-size: $font-size-base;
padding: 0.5rem;
display: inline-block;
width: 100%;
height: 100%;
.info {
display: none;
}
&.invalid {
color: $brand-light;
background-color: $brand-warning;
}
}
}
.multiselect__option--selected {
.multiselect-option {
color: $brand-primary;
.info:not(.highlighted) {
display: inline-block;
}
}
}
.multiselect__option--highlight {
&::after {
display: none;
}
.multiselect-option {
background: $brand-primary;
color: $brand-light;
.info.highlighted {
display: inline-block;
}
.info:not(.highlighted) {
display: none;
}
}
}
}
}
</style>
@@ -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");
});
});
@@ -0,0 +1,112 @@
<script setup lang="ts">
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { keyedColorScheme } from "utils/color";
import { computed } from "vue";
import { library } from "@fortawesome/fontawesome-svg-core";
import { faTimes } from "@fortawesome/free-solid-svg-icons";
export interface TagProps {
option: string;
search?: string;
editable?: boolean;
clickable?: boolean;
}
const props = defineProps<TagProps>();
const emit = defineEmits<{
(e: "click", tag: string): void;
(e: "deleted", tag: string): void;
}>();
//@ts-ignore bad types
library.add(faTimes);
const color = computed(() => keyedColorScheme(props.option));
function onClick() {
emit("click", props.option);
}
function onDelete() {
emit("deleted", props.option);
}
const named = computed(() => props.option?.startsWith("#"));
const searched = computed(() => props.option?.toLowerCase() === props.search?.toLowerCase());
</script>
<template>
<div
class="tag btn-transparent-background"
:data-option="props.option"
:class="{ editable, clickable, searched }"
:style="`--color-primary: ${color.primary}; --color-darker: ${color.darker}; --color-dimmed: ${color.dimmed}`"
@click.prevent.stop="onClick">
<span :class="{ 'font-weight-bold': named }">
{{ props.option }}
</span>
<b-button
v-if="editable"
size="sm"
variant="link"
class="px-1 py-0 tag-delete-button"
tabindex="-1"
@click.prevent.stop="onDelete">
<FontAwesomeIcon icon="fa-times"></FontAwesomeIcon>
</b-button>
</div>
</template>
<style lang="scss" scoped>
@import "scss/theme/blue.scss";
.tag {
display: inline-flex;
align-items: baseline;
margin-right: 0.25rem;
margin-bottom: 0.1rem;
font-size: $font-size-base * 0.95;
color: black;
border-radius: 4px;
background-color: var(--color-primary);
transition: background-color 0.1s;
padding: 0 0.5rem;
&.editable {
padding: 0 0.25rem;
}
position: relative;
&:before {
content: "";
width: 100%;
height: 100%;
top: 0;
left: 0;
position: absolute;
border-color: var(--color-darker);
border-radius: inherit;
pointer-events: none;
border-style: solid;
border-width: 0 2px 1px 0;
}
&.clickable {
cursor: pointer;
&:hover {
background-color: var(--color-dimmed);
}
}
&.searched {
outline: 2px solid $brand-danger;
}
}
</style>
-20
View File
@@ -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 };
}
+62
View File
@@ -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<boolean> = false) {
// TODO: add store typing
const store = inject("store") as Store<unknown>;
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<string[]>([]);
/**
* 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 };
}
+14
View File
@@ -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;
}
-12
View File
@@ -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;
}
+3 -2
View File
@@ -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 };
}
+14 -13
View File
@@ -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'
+4 -1
View File
@@ -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):
+3 -1
View File
@@ -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):
"""
+5 -9
View File
@@ -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()
@@ -37,13 +37,13 @@
<assert_contents>
<has_text_matching expression="^This is a line of text.\n$" />
</assert_contents>
<metadata name="tags" value="alias:f,alias:r1,group:sample1,name:forward,orientation:forward,zoo" />
<metadata name="tags" value="name:forward,alias:f,alias:r1,group:sample1,orientation:forward,zoo" />
</element>
<element name="reverse">
<assert_contents>
<has_text_matching expression="^This is a different line of text.\n$" />
</assert_contents>
<metadata name="tags" value="alias:r,alias:r2,group:sample2,name:reverse,orientation:reverse" />
<metadata name="tags" value="name:reverse,alias:r,alias:r2,group:sample2,orientation:reverse" />
</element>
</output_collection>
</test>
@@ -66,13 +66,13 @@
<assert_contents>
<has_text_matching expression="^This is a line of text.\n$" />
</assert_contents>
<metadata name="tags" value="alias:f,alias:r1,group:sample1,name:forward,orientation:forward" />
<metadata name="tags" value="name:forward,alias:f,alias:r1,group:sample1,orientation:forward" />
</element>
<element name="reverse">
<assert_contents>
<has_text_matching expression="^This is a different line of text.\n$" />
</assert_contents>
<metadata name="tags" value="alias:r,alias:r2,group:sample2,name:reverse,orientation:reverse" />
<metadata name="tags" value="name:reverse,alias:r,alias:r2,group:sample2,orientation:reverse" />
</element>
</element>
</output_collection>
@@ -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
@@ -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)