mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-09-24 16:30:27 +08:00
Merge pull request #14936 from ElectronicBlueberry/tags
New Tag component based on VueMultiselect
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user