Merge pull request #7270 from Nerdinacan/tagging_component_3

Tagging component
This commit is contained in:
Dannon
2019-03-04 13:11:23 -05:00
committed by GitHub
39 changed files with 1339 additions and 727 deletions
-12
View File
@@ -1,12 +0,0 @@
{
"presets": [
[
"@babel/preset-env",
{
"modules": false
}
]
],
"plugins": ["transform-vue-template", "@babel/plugin-syntax-dynamic-import"],
"ignore": ["i18n.js", "utils/localization.js", "nls/*"]
}
+2 -2
View File
@@ -96,7 +96,7 @@ export let chartUtilities = {
export { initMasthead } from "components/Masthead/initMasthead";
export { panelManagement } from "onload/globalInits/panelManagement";
export { init_tag_click_function } from "ui/autocom_tagging";
export { mountTaggingComponent } from "components/Tags";
// Used in common.mako
export { default as store } from "store";
export { default as store } from "storemodern";
@@ -0,0 +1,51 @@
import { mount, createLocalVue } from "@vue/test-utils";
import GalaxyTags from "./GalaxyTags";
import _l from "utils/localization";
let localVue = createLocalVue();
localVue.filter("localize", value => _l(value));
describe("Tags/GalaxyTags.vue", () => {
const testTags = ["abc", "def", "ghi"];
let wrapper, emitted;
beforeEach(function() {
wrapper = mount(GalaxyTags, { localVue });
wrapper.setProps({
value: testTags
});
emitted = wrapper.emitted();
});
it("should render a div for each tag", () => {
let tags = wrapper.findAll(".ti-tag-center");
assert(tags.length == testTags.length, "Wrong number of tags");
for (let i = 0; i < testTags.length; i++) {
assert(tags.at(i).is("div"), "button not a div");
assert(tags.at(i).text() == testTags[i], "rendered tag label doesn't match test data");
}
});
it("should emit a click event when the tag is clicked", () => {
let tags = wrapper.findAll(".ti-tag-center > div");
tags.at(0).trigger("click");
assert(emitted["tag-click"], "click event not detected");
assert(emitted["tag-click"].length == 1, "wrong event count");
});
it("should emit a tag model payload when tag is clicked", () => {
let tags = wrapper.findAll(".ti-tag-center > div");
tags.at(0).trigger("click");
let firstEvent = emitted["tag-click"][0];
let firstArg = firstEvent[0];
assert((firstArg.text = testTags[0]), "returned tag model doesn't match test data");
});
it("should change intermal model representation when new tag list assigned", async () => {
assert(wrapper.vm.tagModels.length == 3);
let newTags = ["floob", "clown", "hoohah", "doodoo"];
wrapper.setProps({ value: newTags });
assert(wrapper.vm.tagModels.length == newTags.length);
});
});
@@ -0,0 +1,70 @@
GalaxyTags.vue
Configurable tag component. Needed to do some minor event/property remapping
to make the 3rd party component suit the interface I wanted to expose. This
component is used like a glorified form input with v-model. Set v-model
to the array of selected tags.
Props:
"value":
Standard vue property that is assigned when v-model="tags" is set.
No need to set it directly, just use v-model="tags"
autoCompleteItems:
An array of autocomplete items for the current tag text. You can capture
changes to the tag being entered in the tag-input-changed event and reset
this property as required
Events:
"input" (standard v-model update event, Not used directly):
Emits when list of tags change
before-adding-tag({ tag, addTag })
Hook allowing user the chance to do an operation before
commiting a tag to the list
before-deleting-tag({ tag, deleteTag })
Hook allowing consumer to do somehting before removing
a tag from the displayed list.
tag-click:
When the central name display of the tag is clicked, can
assign a handler from the consuming environment
tag-input-changed:
As user is typing a new tag, the text gets emitted here.
Usage:
<galaxy-tags
v-model="tags"
:autocomplete-items="autocompleteItems"
@tag-click="tagClick"
@tag-input-changed="tagTextChanged"
@before-adding-tag="beforeAddingTag"
@before-deleting-tag="beforeDeletingTag"
/>
...
methods: {
// tag is only added when addTag(tag) is called
beforeAddingTag({ tag, addTag }) {
saveTagToServer().then(() => addTag(tag));
},
// tag is deleted when deleteTag(tag) is called
beforeDeletingTag({ tag, deleteTag }) {
deleteTagFromServer().then(() => deleteTag(tag));
},
// do a database lookup to generate viable options and
// set on the property that's passed to the component to
// do a little autocomplete dropdown
tagTextChanged(txt) {
generateAutocompleteOptions(txt).then((newOptions) => {
this.autocompleteItems = newOptions;
})
},
// any appropriate click handler (update search, etc.)
tagClick(tag) {
// do something with the tag data
}
}
@@ -0,0 +1,193 @@
<template>
<div :class="tagContainerClasses">
<a href="#" class="toggle-link" v-if="linkVisible" @click.prevent="toggleTagDisplay">
{{ linkText | localize }}
</a>
<vue-tags-input
class="tag-area"
v-if="tagsVisible"
v-model="tagText"
:tags="tagModels"
:autocomplete-items="autocompleteTags"
:disabled="disabled"
:placeholder="'Add Tags' | localize"
:add-on-key="[13, ' ']"
@before-adding-tag="beforeAddingTag"
@before-deleting-tag="beforeDeletingTag"
@tags-changed="tagsChanged"
>
<div class="tag-name" slot="tag-center" slot-scope="tagProps" @click="$emit('tag-click', tagProps.tag)">
{{ tagProps.tag.text }}
</div>
</vue-tags-input>
</div>
</template>
<script>
import VueTagsInput from "@johmun/vue-tags-input";
import { createTag } from "./model";
export default {
components: {
VueTagsInput
},
props: {
value: { type: Array, required: false, default: () => [] },
autocompleteItems: { type: Array, required: false, default: () => [] },
maxVisibleTags: { type: Number, required: false, default: 5 },
useToggleLink: { type: Boolean, required: false, default: true },
disabled: { type: Boolean, required: false, default: false }
},
data() {
// initialize toggle value
let isClosed = this.useToggleLink && this.value.length > this.maxVisibleTags;
return {
// text of the new tag, when editable
tagText: "",
// if list is too long and we're using the toggle, then hide the tags
tagToggle: !isClosed
};
},
computed: {
tagContainerClasses() {
return {
"galaxy-tags": true,
disabled: this.disabled
};
},
tagModels() {
return this.value.map(createTag);
},
autocompleteTags() {
return this.autocompleteItems.map(createTag);
},
linkText() {
return `${this.tagModels.length} Tags`;
},
linkVisible() {
return this.useToggleLink && this.tagModels.length > this.maxVisibleTags;
},
tagsVisible() {
return this.useToggleLink ? this.tagToggle : true;
}
},
watch: {
tagText(newValue) {
this.$emit("tag-input-changed", newValue);
}
},
methods: {
tagsChanged(newTags) {
this.$emit("input", this.pluckLabels(newTags));
},
pluckLabels(newTags) {
return newTags.map(t => createTag(t).toString());
},
toggleTagDisplay() {
this.tagToggle = !this.tagToggle;
this.$emit("show", this.tagToggle);
},
beforeAddingTag($event) {
if (!this.emitHookEvent("before-adding-tag", $event)) {
let { tag, addTag } = $event;
addTag(tag);
}
},
beforeDeletingTag($event) {
if (!this.emitHookEvent("before-deleting-tag", $event)) {
let { tag, deleteTag } = $event;
deleteTag(tag);
}
},
emitHookEvent(eventName, $event) {
if (this.hasHandler(eventName)) {
this.$emit(eventName, $event);
return true;
}
return false;
},
hasHandler(eventName) {
return Object.keys(this.$listeners).includes(eventName);
}
}
};
</script>
<style lang="scss">
@import "theme/blue";
@import "scss/mixins";
// Puts a little graphic in place of the text-input
// when the input is not in focus
@mixin newTagHoverButton() {
.vue-tags-input .ti-tags .ti-new-tag-input-wrapper {
input {
background-color: transparent;
}
input:not(:focus) {
background: url("/static/images/fugue/tag--plus.png");
background-repeat: no-repeat;
color: transparent;
&::placeholder {
color: transparent;
}
}
}
}
// hides tag container edges
@mixin hideEditorBorders() {
.vue-tags-input {
// need to add yet another class to beat the scoping
&.tag-area {
background-color: transparent;
}
.ti-input {
border: none;
}
}
}
// general style butchering
@mixin matchBootstrapStyling() {
.vue-tags-input {
@include fill();
.ti-input {
padding: 0;
}
.ti-tag {
border-radius: 4px;
font-size: 0.8rem;
font-weight: 400;
}
}
}
// Version of the tags that only allow clicking
// existing tags instead of the full editing UI
@mixin forDisplayOnly() {
.vue-tags-input {
.ti-actions,
.ti-new-tag-input-wrapper {
display: none;
}
}
}
.galaxy-tags {
// adds in a graphic in place of the text input
@include newTagHoverButton();
// match bootstrap tag styles/colors
@include matchBootstrapStyling();
// removes input borders (not sure if this happens everywhere)
@include hideEditorBorders();
// display-only tags
&.disabled {
@include forDisplayOnly();
}
}
</style>
@@ -0,0 +1,111 @@
import sinon from "sinon";
import { mount, createLocalVue } from "@vue/test-utils";
import StandardTags from "./StandardTags";
import store from "../../store";
import _l from "utils/localization";
let mockRedirect = sinon.stub();
StandardTags.__Rewire__("redirectToUrl", mockRedirect);
describe("Tags/StandardTags.vue", () => {
let localVue = createLocalVue();
localVue.filter("localize", value => _l(value));
let wrapper;
let testTags = ["abc", "def", "ghi"];
function clickFirstTag() {
let firstTag = wrapper.find(".ti-tag-center > div");
firstTag.trigger("click");
}
// TODO: put this in a store-specific test file
it("the searchTags in the store should be a Set object", () => {
let searchTags = store.state.gridSearch.searchTags; // this is a Set()
assert(searchTags instanceof Set, "searchTags wrong variable type, should be Set()");
});
describe("grid tags (tagClickFn: add_tag_to_grid_filter)", () => {
let propsData = {
tags: Array.from(testTags),
tagClickFn: "add_tag_to_grid_filter",
id: "fakeID",
itemClass: "fakeItemClass"
};
beforeEach(function() {
wrapper = mount(StandardTags, {
store,
propsData,
localVue
});
});
afterEach(function() {
mockRedirect.reset();
store.dispatch("clearSearchTags");
});
it("clicking on a search tag should put that tag in the global store", () => {
clickFirstTag();
let searchTags = store.state.gridSearch.searchTags; // this is a Set()
assert(searchTags.has(testTags[0]), "clicked tag not in store");
assert(searchTags.size == 1, `wrong number of searchTags in store: ${searchTags.size}`);
});
it("clicking the same tag twice should add and remove it from the global store", () => {
clickFirstTag();
clickFirstTag();
let searchTags = store.state.gridSearch.searchTags;
assert(!searchTags.has(testTags[0]), "clicked tag shouldn't be in store");
});
it("clicking any odd number of times should put the tag in the store", () => {
let oddNumber = 2 * Math.floor(Math.random() * 10) + 1;
for (let i = 0; i < oddNumber; i++) {
clickFirstTag();
}
let searchTags = store.state.gridSearch.searchTags;
assert(searchTags.has(testTags[0]), "clicked tag not in store");
});
it("clicking any even number of times should remove the tag from the store", () => {
let evenNumber = 2 * Math.floor(Math.random() * 10);
for (let i = 0; i < evenNumber; i++) {
clickFirstTag();
}
let searchTags = store.state.gridSearch.searchTags;
assert(!searchTags.has(testTags[0]), "clicked tag shouldn't be in store");
});
});
describe("community tag (tagClickFn: community_tag_click)", () => {
let propsData = {
tags: ["abc", "def"],
tagClickFn: "community_tag_click",
clickUrl: "foo/bar",
id: "fakeID",
itemClass: "fakeItemClass"
};
beforeEach(function() {
wrapper = mount(StandardTags, {
store,
propsData,
localVue
});
});
afterEach(function() {
mockRedirect.reset();
store.dispatch("clearSearchTags");
});
// tagClickFn=community_tag_click, clickUrl=something
it("should try to redirect when you click a 'community tag'", () => {
clickFirstTag();
let expectedUrl = `${propsData.clickUrl}?f-tags=${testTags[0]}`;
assert(mockRedirect.calledWith(expectedUrl), "requested wrong url");
});
});
});
@@ -0,0 +1,116 @@
<!--
Wraps up existing functionality for community/indvidual tags. The component
wraps the more general "GalaxyTags".
I expect that "StandardTags" will go away one day as we move more of the site
into nested components/views which will implement "GalaxyTags" with appropriate
context-specific parameters. Until we can do that, we have to wrangle the python
parameters here.
-->
<template>
<galaxy-tags
v-model="tags"
:disabled="disabled"
:autocomplete-items="autocompleteItems"
@tag-click="clickHandler"
@tag-input-changed="updateTagSearch"
@before-adding-tag="beforeAddingTag"
@before-deleting-tag="beforeDeletingTag"
/>
</template>
<script>
import Vue from "vue";
import VueRx from "vue-rx";
import { map } from "rxjs/operators";
import GalaxyTags from "./GalaxyTags";
import { redirectToUrl } from "utils/redirect";
import { buildTagService } from "./tagService";
import { diffTags } from "./model";
Vue.use(VueRx);
export default {
components: {
GalaxyTags
},
props: {
tags: { type: Array, required: false, default: () => [] },
// currently we're passing in the click handler name as a property, and
// that property is still defined by python, so this will have to stay
// for a little bit.
tagClickFn: { type: String, required: false, default: "" },
// used with community_tag_click option
clickUrl: { type: String, required: false, default: "" },
// tag service params (TODO: should inject the service in as a property
// instead of constructing it in here from the props)
id: { type: String, required: true },
itemClass: { type: String, required: true },
context: { type: String, required: false, default: "" },
debounceInterval: { type: Number, required: false, default: 150 },
// allows user to add tags
disabled: { type: Boolean, required: false, default: false }
},
computed: {
tagService() {
return buildTagService(this.$props);
}
},
subscriptions() {
return {
// return search result tags without the ones we've already selected
autocompleteItems: this.tagService.autocompleteOptions.pipe(
map(resultTags => diffTags(resultTags, this.tags))
)
};
},
methods: {
clickHandler(tag) {
if (undefined !== this[this.tagClickFn]) {
this[this.tagClickFn](tag);
}
},
add_tag_to_grid_filter(tag) {
this.$store.dispatch("toggleSearchTag", tag);
},
community_tag_click(tag) {
// I made this match the existing behavior, but I am not clear on
// the reason why this link redirects to a raw json page
let suffix = tag.value ? `:${tag.value}` : "";
let href = `${this.clickUrl}?f-tags=${tag.text}${suffix}`;
redirectToUrl(href);
},
// Hooks to save/delete tag to server before updating UI
beforeAddingTag({ tag, addTag }) {
this.tagService
.save(tag)
.then(() => addTag(tag))
.catch(err => console.warn("unable to save tag", err));
},
beforeDeletingTag({ tag, deleteTag }) {
this.tagService
.delete(tag)
.then(result => deleteTag(tag))
.catch(err => console.warn("Unable to delete tag", err));
},
// Set search value on tag service input proprety and eventually search
// results will appear on the tagService.autocompleteOptions observable
// object which is subscribed to above
updateTagSearch(searchTxt) {
this.tagService.autocompleteSearchText = searchTxt;
}
}
};
</script>
@@ -0,0 +1,3 @@
export { default as GalaxyTags } from "./GalaxyTags";
export { default as StandardTags } from "./StandardTags";
export { mountTaggingComponent } from "./inits";
@@ -0,0 +1,12 @@
/**
* Initialization functions for tagging component. This is a bridge between the
* python-rendered page and an eventual component-based architecture. These
* functions pass in a set of python-rendered configuration variables and
* instantiate a component. In the near future, we'll just pass props to the
* component from the parent components and do away with this hybrid approach.
*/
import StandardTags from "./StandardTags";
import { mountVueComponent } from "utils/mountVueComponent";
export const mountTaggingComponent = mountVueComponent(StandardTags);
@@ -0,0 +1,42 @@
/**
* The tag model is pretty simple, so this extra file might be overkill, but
* it's good practice to separate data modeling from data retrieval
*/
// model prototype
function TagModel(props = {}) {
this.text = "";
Object.assign(this, props);
}
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);
}
// Returns tags in "newTags" that aren't present in "existingTags"
export const diffTags = (newTags, existingTags) => {
let newModels = newTags.map(createTag);
let existingModels = existingTags.map(createTag);
return newModels.filter(tag => !existingModels.some(st => st.equals(tag)));
};
@@ -0,0 +1,52 @@
import { createTag, diffTags } from "./model";
describe("Tags/model.js", () => {
// Basic props
describe("tag model", () => {
it("should have a string representation equal to text prop", () => {
let testLabel = "abc";
let model = createTag(testLabel);
assert.equal(model, testLabel);
assert.equal(model.text, testLabel);
assert.equal(model.toString(), testLabel);
});
});
// Factory Function
describe("createTag", () => {
it("should build a model from a string", () => {
let label = "floob";
let model = createTag(label);
expect(model.text).to.equal(label);
});
it("should build a model from an object", () => {
let data = { text: "floob" };
let model = createTag(data);
expect(model.text).to.equal(data.text);
});
});
// Filtered select function, currently used in component to remove
// selected items from a list of returned autocomplete options
describe("diffTags", () => {
let source, selected;
beforeEach(() => {
source = ["a", "b", "c", "d"].map(createTag);
selected = ["a", "d", "f"].map(createTag);
});
it("should remove duplicates from a passed array", () => {
let result = diffTags(source, selected);
expect(result.length).to.equal(2);
assert(result[0].equals(source[1]), true);
assert(result[0].equals(createTag("b")), true);
assert(result[1].equals(source[2]), true);
assert(result[1].equals(createTag("c")), true);
});
});
});
@@ -0,0 +1,107 @@
/**
* Generates a service object used by the tagging component to save, delete and
* lookup potential tag options for the autocomplete feature. Standard typeahead
* debouncing and ajax cancelling functionality is also provided here, though an
* argument can be made that it more properly belongs in the component that is
* handling the inputs.
*
* TODO: convert this python endpoint to a legit json REST service
*/
import axios from "axios";
import { createTag } from "./model";
import { Subject } from "rxjs";
import { filter, debounceTime, switchMap, distinctUntilChanged } from "rxjs/operators";
export function buildTagService({ id, itemClass, context, debounceInterval = 150 }) {
/**
* Save tag, input can be text string or tag model
* @param {string|Tag} tag
* @returns Promise yielding new tag model
*/
async function saveTag(rawTag) {
let tag = createTag(rawTag);
let url = `/tag/add_tag_async?item_id=${id}&item_class=${itemClass}&context=${context}&new_tag=${tag.text}`;
let response = await axios.get(url);
if (response.status !== 200) {
throw new Error(`Unable to save tag: ${tag}`);
}
return createTag(tag);
}
/**
* Delete tag, input can be text string or tag model
* @param {string|Tag} tag
* @returns Promise yielding deleted tag model
*/
async function deleteTag(rawTag) {
let tag = createTag(rawTag);
let url = `/tag/remove_tag_async?item_id=${id}&item_class=${itemClass}&context=${context}&tag_name=${tag.text}`;
let response = await axios.get(url);
if (response.status !== 200) {
throw new Error(`Unable to delete tag: ${tag}`);
}
return tag;
}
/**
* Looks up autocomplete options based on search text
* @param {string} searchText
* @returns Promise yielding an array of tag models
*/
async function autocomplete(searchText) {
let url = `/tag/tag_autocomplete_data?item_id=${id}&item_class=${itemClass}&q=${searchText}`;
let response = await axios.get(url);
if (response.status !== 200) {
throw new Error(`Unable to retrieve autocomplete tags for search string: ${searchText}`);
}
return parseAutocompleteResults(response.data).map(createTag);
}
/**
* Incoming autocomplete search text buffer
*/
const _searchText = new Subject();
return {
// saves a single tag
save: saveTag,
// deletes a single tag
delete: deleteTag,
// returns options matching a search string for autocomplete this is
// exposed for testing purposes only, in practice a consuming component
// will set the autocompleteSearchText property and observe results by
// subscribing to the autocompleteOptions observable property
autocomplete,
// input point for autocomplete text search
set autocompleteSearchText(txt) {
_searchText.next(txt);
},
// output of autocomplete search results
// subscribe to this observable to get results
autocompleteOptions: _searchText.pipe(
filter(txt => txt.length),
debounceTime(debounceInterval),
distinctUntilChanged(),
switchMap(autocomplete)
)
};
}
/**
* Parser for the archaic result format in the current API.
* See testData/autocompleteResponse.txt for a sample
* @param {string} rawResponse
*/
export function parseAutocompleteResults(rawResponse) {
return rawResponse
.split("\n")
.filter(line => line.includes("|"))
.map(line => line.split("|")[0])
.filter(label => label.length)
.filter(label => label !== "#Header");
}
@@ -0,0 +1,138 @@
import sinon from "sinon";
import { buildTagService, __RewireAPI__ as rewire } from "./tagService";
import { createTag } from "./model";
import { interval } from "rxjs";
import { take, takeUntil } from "rxjs/operators";
// test response
import autocompleteResponse from "./testData/autocompleteResponse.txt";
describe("Tags/tagService.js", () => {
let svcParams = {
id: 123,
itemClass: "fooClass",
context: "",
debounceInterval: 50 // shorter value than default for unit tests
};
let svc = buildTagService(svcParams);
let mockAxios = {
get: () => null
};
let stub;
beforeEach(() => {
rewire.__Rewire__("axios", mockAxios);
});
afterEach(() => {
if (stub) stub.restore();
});
describe("save", () => {
let testLabel = "fo0bar123";
let testTag = createTag(testLabel);
let { id, itemClass, context } = svcParams;
let expectedSaveUrl = `/tag/add_tag_async?item_id=${id}&item_class=${itemClass}&context=${context}&new_tag=${testLabel}`;
it("should save a string tag", async () => {
stub = sinon.stub(mockAxios, "get").resolves({ status: 200 });
let savedTag = await svc.save(testLabel);
expect(savedTag.text).to.equal(testLabel);
assert(stub.calledWith(expectedSaveUrl));
});
it("should save an object tag", async () => {
stub = sinon.stub(mockAxios, "get").resolves({ status: 200 });
let savedTag = await svc.save(testTag);
expect(savedTag.text).to.equal(testLabel);
assert(stub.calledWith(expectedSaveUrl));
});
// TODO: test error conditions
});
describe("delete", () => {
let testLabel = "fo0bar123";
let testTag = createTag(testLabel);
let { id, itemClass, context } = svcParams;
let expectedDeleteUrl = `/tag/remove_tag_async?item_id=${id}&item_class=${itemClass}&context=${context}&tag_name=${testLabel}`;
it("should delete a text tag", async () => {
stub = sinon.stub(mockAxios, "get").resolves({ status: 200 });
let result = await svc.delete(testTag);
assert(result);
assert(stub.calledWith(expectedDeleteUrl));
});
it("should delete an object tag", async () => {
stub = sinon.stub(mockAxios, "get").resolves({ status: 200 });
let result = await svc.delete(testTag);
assert(result);
assert(stub.calledWith(expectedDeleteUrl));
});
// TODO: test error conditions
});
describe("autocomplete", () => {
let searchString = "foo";
let { id, itemClass } = svcParams;
let expectedSearchUrl = `/tag/tag_autocomplete_data?item_id=${id}&item_class=${itemClass}&q=${searchString}`;
let successResponse = {
status: 200,
data: autocompleteResponse
};
let checkAutocompleteResult = result => {
assert(result);
assert(result instanceof Array);
assert(result.length == 2);
assert(result[0] instanceof Object);
assert((result[0].text = "abc"));
};
// straight ajax request, unused in practice, but it's easier to
// test this call if we just expose it
it("ajax call should return tag objects", async () => {
stub = sinon.stub(mockAxios, "get").resolves(successResponse);
let result = await svc.autocomplete(searchString);
assert(stub.calledWith(expectedSearchUrl), "Called with wrong search url");
checkAutocompleteResult(result);
});
// hit the search input with multiple entries, only one ajax call
// should result because of debouncing
it("should debounce autocomplete search inputs", done => {
let searchResult;
// ends subscription to observable so test doesn't go on forever
let spamCount = Math.floor(Math.random() * 10);
let timer = interval(1500).pipe(take(1));
// stub ajax request to return the success response if the
// searchString is the expected input
stub = sinon.stub(mockAxios, "get").resolves(successResponse);
svc.autocompleteOptions.pipe(takeUntil(timer)).subscribe(
result => (searchResult = result),
err => console.log("error", err),
() => {
assert(stub.called, "Ajax call not made");
assert(stub.callCount == 1, `Wrong number of ajax calls: ${stub.callCount}`);
checkAutocompleteResult(searchResult);
done();
}
);
// spam a bunch of key inputs
for (var i = 0; i < spamCount; i++) svc.autocompleteSearchText = new String(Math.random());
svc.autocompleteSearchText = searchString;
});
});
});
@@ -0,0 +1,3 @@
#Header|Your Tags
abc|abc
def|def
@@ -172,7 +172,8 @@ export const getAnalysisRouter = Galaxy =>
},
show_histories: function(action_id) {
this.page.display(new HistoryList.View({ action_id: action_id }));
let view = new HistoryList.View({ action_id: action_id });
this.page.display(view);
},
show_history_citations: function() {
+48 -14
View File
@@ -8,6 +8,8 @@ import Templates from "mvc/grid/grid-template";
import PopupMenu from "mvc/ui/popup-menu";
import LoadingIndicator from "ui/loading-indicator";
import { init_refresh_on_change } from "onload/globalInits/init_refresh_on_change";
import store from "../../store";
import slug from "slug";
// This is necessary so that, when nested arrays are used in ajax/post/get methods, square brackets ('[]') are
// not appended to the identifier of a nested array.
@@ -20,22 +22,27 @@ export default Backbone.View.extend({
// Initialize
initialize: function(grid_config) {
console.log("Grid initialize", grid_config, this);
this.grid = new GridModel();
this.title = grid_config.title;
this.active_tab = grid_config.active_tab;
var self = this;
// Why is this a global?
window.add_tag_to_grid_filter = (tag_name, tag_value) => {
// Put tag name and value together.
var tag = tag_name + (tag_value !== undefined && tag_value !== "" ? `:${tag_value}` : "");
var advanced_search = $("#advanced-search").is(":visible");
if (!advanced_search) {
$("#standard-search").slideToggle("fast");
$("#advanced-search").slideToggle("fast");
// Subscribe to changes in the store, currently just storing
// tag changes from the tagging components, but that will change
// when we rework the grid. This subscription ties this older grid
// code to the new vue components
store.watch(
state => state.gridSearch.searchTags,
newTags => {
let tagArray = Array.from(newTags);
self.grid.add_filter("tags", tagArray, false);
self.openAdvancedSearch();
self.render_filter_button("tags", tagArray);
self.go_page_one();
self.execute();
}
self.add_filter_condition("tags", tag);
};
);
if (grid_config.url_base && !grid_config.items) {
LoadingIndicator.markViewAsLoading(this);
@@ -66,6 +73,14 @@ export default Backbone.View.extend({
}
},
openAdvancedSearch: function() {
var isOpen = $("#advanced-search").is(":visible");
if (!isOpen) {
$("#standard-search").slideToggle("fast");
$("#advanced-search").slideToggle("fast");
}
},
// refresh frames
handle_refresh: function(refresh_frames) {
if (refresh_frames) {
@@ -99,6 +114,9 @@ export default Backbone.View.extend({
// append main template
this.$el.html(Templates.grid(options));
// add a class identifier for styling purposes
this.$el.addClass(this.getRootClassName(grid_config));
// update div contents
this.$el.find("#grid-table-header").html(Templates.header(options));
this.$el.find("#grid-table-body").html(Templates.body(options));
@@ -310,6 +328,14 @@ export default Backbone.View.extend({
// Add condition to grid.
this.grid.add_filter(name, value, true);
this.render_filter_button(name, value);
// execute
this.go_page_one();
this.execute();
},
render_filter_button: function(name, value) {
// Add button that displays filter and provides a button to delete it.
var t = $(Templates.filter_element(name, value));
var self = this;
@@ -324,10 +350,6 @@ export default Backbone.View.extend({
// append to container
var container = this.$el.find(`#${name}-filtering-criteria`);
container.append(t);
// execute
this.go_page_one();
this.execute();
},
// Remove a condition to the grid filter; this adds the condition and refreshes the grid.
@@ -335,6 +357,11 @@ export default Backbone.View.extend({
// Remove filter condition.
this.grid.remove_filter(name, value);
// update vuex if the one criteria we're currently tracking changes
if (name == "tags") {
store.dispatch("removeSearchTag", { text: value });
}
// Execute
this.go_page_one();
this.execute();
@@ -665,5 +692,12 @@ export default Backbone.View.extend({
});
}
});
},
// Generates a class name at the root of the view that we can
// use for conditional styling in the various kinds of grids
// instead of acres of if/then statements in javascript
getRootClassName({ title = "grid" }) {
return slug(title).toLowerCase();
}
});
-12
View File
@@ -1,12 +0,0 @@
describe("Sample Test", () => {
it("hey look, tests run", () => {
assert.equal(1, 1);
});
it("prove I can use a Proxy", () => {
let target = {};
let thing = new Proxy(target, {});
thing.foo = 232;
assert.equal(target.foo, thing.foo);
});
});
@@ -0,0 +1,33 @@
/**
* Vuex store module used for managing search parameters in the grid. Currently
* only manages the tags that access this store via the new tagging components,
* but presumably the entire grid search filter criteria will live here one day.
*/
export const gridSearchStore = {
state: {
searchTags: new Set()
},
mutations: {
// TODO: we could write an equivalence comparator here for searchTag
// Sets and not register a change if the new set is equivalent
setSearchTags(state, tags) {
state.searchTags = new Set(tags);
}
},
actions: {
toggleSearchTag({ state, commit }, { text }) {
let tags = new Set(state.searchTags);
tags.has(text) ? tags.delete(text) : tags.add(text);
commit("setSearchTags", tags);
},
removeSearchTag({ state, commit }, { text }) {
let tags = new Set(state.searchTags);
tags.delete(text);
commit("setSearchTags", tags);
},
clearSearchTags({ state, commit }) {
commit("setSearchTags", new Set());
}
}
};
+13
View File
@@ -0,0 +1,13 @@
/**
* Central Vuex store
*/
import Vue from "vue";
import Vuex from "vuex";
import { gridSearchStore as gridSearch } from "./gridSearchStore";
Vue.use(Vuex);
export default new Vuex.Store({
modules: { gridSearch }
});
-369
View File
@@ -1,369 +0,0 @@
import $ from "jquery";
import _ from "underscore";
// ============================================================================
/**
* JQuery extension for tagging with autocomplete.
* @author: Jeremy Goecks
* @require: jquery.autocomplete plugin
*/
//
// Initialize "tag click functions" for tags.
//
export function init_tag_click_function(tag_elt, click_func) {
$(tag_elt)
.find(".tag-name")
.each(function() {
$(this).click(function() {
var tag_str = $(this).text();
var tag_name_and_value = tag_str.split(":");
click_func(tag_name_and_value[0], tag_name_and_value[1]);
return true;
});
});
}
$.fn.autocomplete_tagging = function(options) {
var defaults = {
get_toggle_link_text_fn: function(tags) {
var text = "";
var num_tags = _.size(tags);
if (num_tags > 0) {
text = num_tags + (num_tags > 1 ? " Tags" : " Tag");
} else {
text = "Add tags";
}
return text;
},
tag_click_fn: function(name, value) {},
editable: true,
input_size: 20,
in_form: false,
tags: {},
use_toggle_link: true,
item_id: "",
add_tag_img: "",
add_tag_img_rollover: "",
delete_tag_img: "",
ajax_autocomplete_tag_url: "",
ajax_retag_url: "",
ajax_delete_tag_url: "",
ajax_add_tag_url: ""
};
var settings = $.extend(defaults, options);
//
// Initalize object's elements.
//
// Get elements for this object. For this_obj, assume the last element with the id is the "this"; this is somewhat of a hack to address the problem
// that there may be two tagging elements for a single item if there are both community and individual tags for an element.
var this_obj = $(this);
var tag_area = this_obj.find(".tag-area");
var toggle_link = this_obj.find(".toggle-link");
var tag_input_field = this_obj.find(".tag-input");
var add_tag_button = this_obj.find(".add-tag-button");
// Initialize toggle link.
toggle_link.click(function() {
// Take special actions depending on whether toggle is showing or hiding link.
var after_toggle_fn;
if (tag_area.is(":hidden")) {
after_toggle_fn = function() {
// If there are no tags, go right to editing mode by generating a click on the area.
var num_tags = $(this).find(".tag-button").length;
if (num_tags === 0) {
tag_area.click();
}
};
} else {
after_toggle_fn = () => {
tag_area.blur();
};
}
tag_area.slideToggle("fast", after_toggle_fn);
return $(this);
});
// Initialize tag input field.
if (settings.editable) {
tag_input_field.hide();
}
tag_input_field.keyup(function(e) {
if (e.keyCode === 27) {
// Escape key
$(this).trigger("blur");
} else if (
e.keyCode === 13 || // Return Key
e.keyCode === 188 || // Comma
e.keyCode === 32 // Space
) {
//
// Check input.
//
var new_value = this.value;
// Suppress space after a ":"
if (new_value.indexOf(": ", new_value.length - 2) !== -1) {
this.value = new_value.substring(0, new_value.length - 1);
return false;
}
// Remove trigger keys from input.
if (e.keyCode === 188 || e.keyCode === 32) {
new_value = new_value.substring(0, new_value.length - 1);
}
// Trim whitespace.
new_value = $.trim(new_value);
// Too short?
if (new_value.length < 2) {
return false;
}
//
// New tag OK - apply it.
//
this.value = ""; // Reset text field now that tag is being added
// Add button for tag after all other tag buttons.
var new_tag_button = build_tag_button(new_value);
var tag_buttons = tag_area.children(".tag-button");
if (tag_buttons.length !== 0) {
var last_tag_button = tag_buttons.slice(tag_buttons.length - 1);
last_tag_button.after(new_tag_button);
} else {
tag_area.prepend(new_tag_button);
}
// Add tag to internal list.
var tag_name_and_value = new_value.split(":");
settings.tags[tag_name_and_value[0]] = tag_name_and_value[1];
// Update toggle link text.
var new_text = settings.get_toggle_link_text_fn(settings.tags);
toggle_link.text(new_text);
// Commit tag to server.
var zz = $(this);
$.ajax({
url: settings.ajax_add_tag_url,
data: { new_tag: new_value },
error: function() {
// Failed. Roll back changes and show alert.
new_tag_button.remove();
delete settings.tags[tag_name_and_value[0]];
var new_text = settings.get_toggle_link_text_fn(settings.tags);
toggle_link.text(new_text);
alert("Add tag failed");
},
success: function() {
// Flush autocomplete cache because it's not out of date.
// TODO: in the future, we could remove the particular item
// that was chosen from the cache rather than flush it.
zz.data("autocompleter").cacheFlush();
}
});
return false;
}
});
// Add autocomplete to input.
var format_item_func = (key, row_position, num_rows, value, search_term) => {
var tag_name_and_value = value.split(":");
return tag_name_and_value.length === 1 ? tag_name_and_value[0] : tag_name_and_value[1];
};
var autocomplete_options = {
selectFirst: false,
formatItem: format_item_func,
autoFill: false,
highlight: false
};
tag_input_field.autocomplete_verheul(settings.ajax_autocomplete_tag_url, autocomplete_options);
// Initialize delete tag images for current tags.
this_obj.find(".delete-tag-img").each(function() {
init_delete_tag_image($(this));
});
// Initialize tag click function.
init_tag_click_function($(this), settings.tag_click_fn);
// Initialize "add tag" button.
add_tag_button.click(function() {
$(this).hide();
// Clicking on button is the same as clicking on the tag area.
tag_area.click();
return false;
});
//
// Set up tag area interactions; these are needed only if tags are editable.
//
if (settings.editable) {
// When the tag area blurs, go to "view tag" mode.
tag_area.bind("blur", e => {
if (_.size(settings.tags) > 0) {
add_tag_button.show();
tag_input_field.hide();
tag_area.removeClass("active-tag-area");
// tag_area.addClass("tooltip");
} else {
// No tags, so do nothing to ensure that input is still visible.
}
});
// On click, enable user to add tags.
tag_area.click(function(e) {
var is_active = $(this).hasClass("active-tag-area");
// If a "delete image" object was pressed and area is inactive, do nothing.
if ($(e.target).hasClass("delete-tag-img") && !is_active) {
return false;
}
// If a "tag name" object was pressed and area is inactive, do nothing.
if ($(e.target).hasClass("tag-name") && !is_active) {
return false;
}
// Remove tooltip.
// $(this).removeClass("tooltip");
// Hide add tag button, show tag_input field. Change background to show
// area is active.
$(this).addClass("active-tag-area");
add_tag_button.hide();
tag_input_field.show();
tag_input_field.focus();
// Add handler to document that will call blur when the tag area is blurred;
// a tag area is blurred when a user clicks on an element outside the area.
var handle_document_click = e => {
var check_click = function(tag_area, target) {
// Blur the tag area if the element clicked on is not in the tag area.
if (target !== tag_area) {
tag_area.blur();
$(window).unbind("click.tagging_blur");
$(this).addClass("tooltip");
}
};
check_click(tag_area, $(e.target));
};
// TODO: we should attach the click handler to all frames in order to capture
// clicks outside the frame that this element is in.
//window.parent.document.onclick = handle_document_click;
//var temp = $(window.parent.document.body).contents().find("iframe").html();
//alert(temp);
//$(document).parent().click(handle_document_click);
$(window).bind("click.tagging_blur", handle_document_click);
return false;
});
}
// If using toggle link, hide the tag area. Otherwise, show the tag area.
if (settings.use_toggle_link) {
tag_area.hide();
}
//
// Helper functions.
//
// Initialize a "delete tag image": when click, delete tag from UI and send delete request to server.
function init_delete_tag_image(delete_img) {
$(delete_img).mouseenter(function() {
$(this).attr("src", settings.delete_tag_img_rollover);
});
$(delete_img).mouseleave(function() {
$(this).attr("src", settings.delete_tag_img);
});
$(delete_img).click(function() {
// Tag button is image's parent.
var tag_button = $(this).parent();
// Get tag name, value.
var tag_name_elt = tag_button.find(".tag-name").eq(0);
var tag_str = tag_name_elt.text();
var tag_name_and_value = tag_str.split(":");
var tag_name = tag_name_and_value[0];
var tag_value = tag_name_and_value[1];
var prev_button = tag_button.prev();
tag_button.remove();
// Remove tag from local list for consistency.
delete settings.tags[tag_name];
// Update toggle link text.
var new_text = settings.get_toggle_link_text_fn(settings.tags);
toggle_link.text(new_text);
// Delete tag.
$.ajax({
url: settings.ajax_delete_tag_url,
data: { tag_name: tag_name },
error: function() {
// Failed. Roll back changes and show alert.
settings.tags[tag_name] = tag_value;
if (prev_button.hasClass("tag-button")) {
prev_button.after(tag_button);
} else {
tag_area.prepend(tag_button);
}
alert("Remove tag failed");
toggle_link.text(settings.get_toggle_link_text_fn(settings.tags));
// TODO: no idea why it's necessary to set this up again.
delete_img.mouseenter(function() {
$(this).attr("src", settings.delete_tag_img_rollover);
});
delete_img.mouseleave(function() {
$(this).attr("src", settings.delete_tag_img);
});
},
success: function() {}
});
return true;
});
}
//
// Function that builds a tag button.
//
function build_tag_button(tag_str) {
// Build "delete tag" image.
var delete_img = $("<img/>")
.attr("src", settings.delete_tag_img)
.addClass("delete-tag-img");
init_delete_tag_image(delete_img);
// Build tag button.
var tag_name_elt = $("<span>")
.text(tag_str)
.addClass("tag-name");
tag_name_elt.click(() => {
var tag_name_and_value = tag_str.split(":");
settings.tag_click_fn(tag_name_and_value[0], tag_name_and_value[1]);
return true;
});
var tag_button = $("<span></span>").addClass("tag-button");
tag_button.append(tag_name_elt);
// Allow delete only if element is editable.
if (settings.editable) {
tag_button.append(delete_img);
}
return tag_button;
}
};
-13
View File
@@ -1,13 +0,0 @@
/**
* A combination of all the mocha test files so that we can run them as a unit,
* which is much faster during deployment.
*
* Note, this is non-intuitive, but the parameters of require.context must be
* literals, can't even be stored in local variables or webpack can't do static
* dependency analysis.
*
* https://webpack.js.org/guides/dependency-management/
*/
// eslint-disable-next-line no-undef
let testContext = require.context("./", true, /\.test\.js$/);
testContext.keys().forEach(testContext);
+1 -1
View File
@@ -1,4 +1,4 @@
// console.log api but does nothing
// Applies noop to all methods for mock creation
const doNothing = () => null;
@@ -0,0 +1,15 @@
// Generic Vue component mount for use in transitional
// mount functions
import Vue from "vue";
import store from "../store";
import _l from "utils/localization";
// make localization filter available to all components
Vue.filter("localize", value => _l(value));
export const mountVueComponent = ComponentDefinition => (propsData, el) => {
// console.log("mount function", propsData);
let component = Vue.extend(ComponentDefinition);
return new component({ store, propsData, el });
};
+5
View File
@@ -0,0 +1,5 @@
// This file exists purely to make unit testing easier
export function redirectToUrl(url) {
window.location = url;
}
-1
View File
@@ -30,7 +30,6 @@ $fa-font-path: "../../../node_modules/font-awesome/fonts/";
@import "ui.scss";
@import "library.scss";
@import "trackster.scss";
@import "autocomplete_tagging.scss";
@import "toastr.scss";
@import "jstree.scss";
@import "tour.scss";
+8
View File
@@ -0,0 +1,8 @@
// Utility mixin expands to container edges
@mixin fill() {
position: relative;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
+9 -27
View File
@@ -4,38 +4,20 @@
const baseKarmaConfig = require("./karma.config.base");
const single_pack = (process.env.GALAXY_TEST_AS_SINGLE_PACK == "true");
const testBundles = [
"**/unitTestBundle.js",
"**/mocha/test.js"
];
const separateTests = [
"**/*.test.js",
"**/mocha/tests/*_tests.js"
];
module.exports = function (config) {
console.log("single_pack?", single_pack);
// pick all separate tests or the dynamic test-bundles
let files = single_pack
? testBundles
: separateTests;
let preprocessors = files.reduce((result, path) => {
result[path] = ["webpack"];
return result;
}, {});
let settings = Object.assign({}, baseKarmaConfig, {
files: files,
files: [
"../../node_modules/@babel/polyfill/dist/polyfill.js",
{ pattern: "**/*.test.js", watched: true },
{ pattern: "**/mocha/tests/*_tests.js" }
],
preprocessors: {
"**/*.js": ["webpack"]
},
exclude: ["**/qunit/*"],
preprocessors: preprocessors,
reporters: ["mocha"],
frameworks: ["polyfill", "mocha", "chai"]
frameworks: ["mocha", "chai"]
});
config.set(settings);
+2 -1
View File
@@ -29,7 +29,8 @@ module.exports = function (config) {
let settings = Object.assign({}, baseKarmaConfig, {
files: testFiles.concat(assets),
preprocessors: preprocessors,
frameworks: ["polyfill", "qunit"]
frameworks: ["polyfill", "qunit"],
singleRun: true
});
config.set(settings);
+36 -19
View File
@@ -3,28 +3,45 @@
* the ignore-loader for speedier testing.
*/
let merge = require("webpack-merge");
let wpConfig = require("../webpack.config");
const merge = require("webpack-merge");
const wpConfig = require("../webpack.config");
wpConfig.mode = "development";
wpConfig.entry = () => ({});
// Don't need assets for unit testing, override those rules
module.exports = merge.smart(wpConfig, {
module: {
rules: [
{
test: /\.(png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot)(\?.*$|$)/,
loader: "ignore-loader"
},
{
test: /\.css$/,
loader: "ignore-loader"
},
{
test: /\.scss$/,
loader: "ignore-loader"
}
]
// Don't need any assets for unit testing
let ignoreAssetLoaders = {
rules:[
{
test: /\.(png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot)(\?.*$|$)/,
loader: "ignore-loader"
},
{
test: /\.css$/,
loader: "ignore-loader"
},
{
test: /\.scss$/,
loader: "ignore-loader"
}
]
};
wpConfig.module = merge.smart(wpConfig.module, ignoreAssetLoaders);
// Using babel-plugin-rewire to handle dependency mocking since webpack 4
// exports immutable bindings for ES modules but we still need a way to
// overwrite dependencies during unit-testing.
wpConfig.module.rules = wpConfig.module.rules.map(rule => {
if (rule.loader == "babel-loader") {
rule.options.plugins.push("rewire");
}
return rule;
});
module.exports = wpConfig;
+13 -7
View File
@@ -16,6 +16,8 @@
"@babel/polyfill": "^7.0.0",
"@babel/preset-env": "^7.1.0",
"@handsontable/vue": "^2.0.0-beta1",
"@johmun/vue-tags-input": "^2.0.0",
"@vue/test-utils": "^1.0.0-beta.28",
"amdi18n-loader": "^0.8.0",
"axios": "^0.18.0",
"backbone": "1.3",
@@ -44,9 +46,12 @@
"raven-js": "^3.27.0",
"requirejs": "2.3.6",
"rxjs": "^6.3.3",
"slug": "^0.9.3",
"underscore": "^1.9.1",
"vue": "2.5.17",
"vue-router": "3.0.1"
"vue": "^2.5.22",
"vue-router": "^3.0.2",
"vue-rx": "^6.1.0",
"vuex": "^3.1.0"
},
"scripts": {
"watch": "gulp staging && gulp clean && gulp && yarn run save-build-hash && yarn run webpack-watch",
@@ -69,13 +74,14 @@
"styleguide:build": "vue-styleguidist build",
"test": "npm run test-mocha && npm run test-qunit",
"test-watch": "npm run test-mocha -- --no-single-run",
"test-qunit": "GALAXY_TEST_AS_SINGLE_PACK=true karma start karma/karma.config.qunit.js",
"test-mocha": "GALAXY_TEST_AS_SINGLE_PACK=true karma start karma/karma.config.mocha.js",
"test-qunit": "karma start karma/karma.config.qunit.js",
"test-mocha": "karma start karma/karma.config.mocha.js",
"jshint": "jshint --exclude='galaxy/scripts/libs/**' galaxy/scripts/**/*.js",
"eslint": "eslint -c .eslintrc.js galaxy/scripts --ext .js,.vue"
},
"devDependencies": {
"babel-loader": "^8.0.4",
"babel-plugin-rewire": "^1.2.0",
"babel-plugin-transform-inline-environment-variables": "^0.4.3",
"babel-plugin-transform-vue-template": "^0.4.2",
"chai": "^4.2.0",
@@ -112,14 +118,14 @@
"phantomjs-prebuilt": "^2.1.7",
"prettier": "^1.15.3",
"qunitjs": "^2.4.1",
"raw-loader": "^1.0.0",
"sass-loader": "^7.1.0",
"sinon": "^4.1.2",
"store": "^2.0.12",
"style-loader": "^0.23.1",
"vue-loader": "^15.4.2",
"vue-style-loader": "^4.1.2",
"vue-loader": "^15.6.2",
"vue-styleguidist": "^1.8.9",
"vue-template-compiler": "2.5.17",
"vue-template-compiler": "^2.5.22",
"webpack": "^4.23.0",
"webpack-cli": "^3.1.2",
"webpack-merge": "^4.1.4",
+22 -4
View File
@@ -29,7 +29,7 @@ let buildconfig = {
alias: {
jquery$: `${libsBase}/jquery.custom.js`,
jqueryVendor$: `${libsBase}/jquery/jquery.js`,
store$: "store/dist/store.modern.js"
storemodern$: "store/dist/store.modern.js"
}
},
optimization: {
@@ -64,7 +64,22 @@ let buildconfig = {
libsBase
],
loader: "babel-loader",
options: { babelrc: true }
options: {
cacheDirectory: true,
cacheCompression: false,
presets: [
["@babel/preset-env", { modules: false }]
],
plugins: [
"transform-vue-template",
"@babel/plugin-syntax-dynamic-import"
],
ignore: [
"i18n.js",
"utils/localization.js",
"nls/*"
]
}
},
{
test: `${libsBase}/jquery.custom.js`,
@@ -144,12 +159,15 @@ let buildconfig = {
},
{
loader: "sass-loader",
options: { sourceMap: true }
options: {
sourceMap: true,
includePaths: ["galaxy/style/scss"]
}
}
]
},
{
test: /\.tmpl$/,
test: /\.(txt|tmpl)$/,
loader: "raw-loader"
}
]
+151 -8
View File
@@ -754,6 +754,13 @@
resolved "https://registry.yarnpkg.com/@handsontable/vue/-/vue-2.0.0.tgz#28ae7d247a89738088abc32584562925dde18db0"
integrity sha512-QVqJywrQWwJzoDCXibZkXrg+T91hNCTCN9qznmum4FSewL0MUtaHotv7Zc/D4scvEwpwWnKx5vpw4CY14V4OYw==
"@johmun/vue-tags-input@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@johmun/vue-tags-input/-/vue-tags-input-2.0.0.tgz#95870006ddd1320eb4979a58cec8ed24ee966c9b"
integrity sha512-SBNZm9ZtgwfnjEYUOkJ8Al7ZzUluZ3F49mO8EOTjTdYfB+ZJIQbf0R71PGYfKWkU7qdAwzW2imzlTPnJC3Wh7g==
dependencies:
vue "^2.5.16"
"@sinonjs/formatio@3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@sinonjs/formatio/-/formatio-3.0.0.tgz#9d282d81030a03a03fa0c5ce31fd8786a4da311a"
@@ -802,6 +809,29 @@
source-map "^0.5.6"
vue-template-es2015-compiler "^1.6.0"
"@vue/component-compiler-utils@^2.5.1":
version "2.5.2"
resolved "https://registry.yarnpkg.com/@vue/component-compiler-utils/-/component-compiler-utils-2.5.2.tgz#a8d57e773354ab10e4742c7d6a8dd86184d4d7be"
integrity sha512-3exq9O89GXo9E+CGKzgURCbasG15FtFMs8QRrCUVWGaKue4Egpw41MHb3Avtikv1VykKfBq3FvAnf9Nx3sdVJg==
dependencies:
consolidate "^0.15.1"
hash-sum "^1.0.2"
lru-cache "^4.1.2"
merge-source-map "^1.1.0"
postcss "^7.0.14"
postcss-selector-parser "^5.0.0"
prettier "1.16.3"
source-map "~0.6.1"
vue-template-es2015-compiler "^1.8.2"
"@vue/test-utils@^1.0.0-beta.28":
version "1.0.0-beta.28"
resolved "https://registry.yarnpkg.com/@vue/test-utils/-/test-utils-1.0.0-beta.28.tgz#767c43413df8cde86128735e58923803e444b9a5"
integrity sha512-uVbFJG0g/H9hf2pgWUdhvQYItRGzQ44cMFf00wp0YEo85pxuvM9e3mx8QLQfx6R2CogxbK4CvV7qvkLblehXeQ==
dependencies:
dom-event-types "^1.0.0"
lodash "^4.17.4"
"@vxna/mini-html-webpack-template@^0.1.7":
version "0.1.7"
resolved "https://registry.yarnpkg.com/@vxna/mini-html-webpack-template/-/mini-html-webpack-template-0.1.7.tgz#2a8270e513ee14f395cc17c2ce22ced383c45d22"
@@ -1839,6 +1869,11 @@ babel-plugin-espower@^2.1.0:
espurify "^1.6.0"
estraverse "^4.1.1"
babel-plugin-rewire@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/babel-plugin-rewire/-/babel-plugin-rewire-1.2.0.tgz#822562d72ed2c84e47c0f95ee232c920853e9d89"
integrity sha512-JBZxczHw3tScS+djy6JPLMjblchGhLI89ep15H3SyjujIzlxo5nr6Yjo7AXotdeVczeBmWs0tF8PgJWDdgzAkQ==
babel-plugin-syntax-async-functions@^6.8.0:
version "6.13.0"
resolved "https://registry.yarnpkg.com/babel-plugin-syntax-async-functions/-/babel-plugin-syntax-async-functions-6.13.0.tgz#cad9cad1191b5ad634bf30ae0872391e0647be95"
@@ -2968,6 +3003,15 @@ chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.3.0, chalk@^2.3.1, chalk@^2.4
escape-string-regexp "^1.0.5"
supports-color "^5.3.0"
chalk@^2.4.2:
version "2.4.2"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==
dependencies:
ansi-styles "^3.2.1"
escape-string-regexp "^1.0.5"
supports-color "^5.3.0"
character-entities-html4@^1.0.0:
version "1.1.2"
resolved "https://registry.yarnpkg.com/character-entities-html4/-/character-entities-html4-1.1.2.tgz#c44fdde3ce66b52e8d321d6c1bf46101f0150610"
@@ -3927,6 +3971,11 @@ cssesc@^1.0.1:
resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-1.0.1.tgz#ef7bd8d0229ed6a3a7051ff7771265fe7330e0a8"
integrity sha512-S2hzrpWvE6G/rW7i7IxJfWBYn27QWfOIncUW++8Rbo1VB5zsJDSVPcnI+Q8z7rhxT6/yZeLOCja4cZnghJrNGA==
cssesc@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-2.0.0.tgz#3b13bd1bb1cb36e1bcb5a4dcd27f54c5dcb35703"
integrity sha512-MsCAG1z9lPdoO/IUMLSBWBSVxVtJ1395VGIQ+Fc2gNdkQ1hNDnQdw3YhA71WJCBW1vdwA0cAnk/DnW6bqoEUYg==
cssnano-preset-default@^4.0.2:
version "4.0.3"
resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-4.0.3.tgz#9bfd1b06d4aa3991ed958ad9b9ec25a179261705"
@@ -4411,6 +4460,11 @@ dom-converter@~0.2:
dependencies:
utila "~0.4"
dom-event-types@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/dom-event-types/-/dom-event-types-1.0.0.tgz#5830a0a29e1bf837fe50a70cd80a597232813cae"
integrity sha512-2G2Vwi2zXTHBGqXHsJ4+ak/iP0N8Ar+G8a7LiD2oup5o4sQWytwqqrZu/O6hIMV0KMID2PL69OhpshLO0n7UJQ==
dom-serialize@^2.2.0:
version "2.2.1"
resolved "https://registry.yarnpkg.com/dom-serialize/-/dom-serialize-2.2.1.tgz#562ae8999f44be5ea3076f5419dcd59eb43ac95b"
@@ -10243,6 +10297,15 @@ postcss-selector-parser@^3.0.0, postcss-selector-parser@^3.1.1:
indexes-of "^1.0.1"
uniq "^1.0.1"
postcss-selector-parser@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-5.0.0.tgz#249044356697b33b64f1a8f7c80922dddee7195c"
integrity sha512-w+zLE5Jhg6Liz8+rQOWEAwtwkyqpfnmsinXjXg6cY7YIONZZtgvE0v2O0uhQBs0peNomOJwWRKt6JBfTdTd3OQ==
dependencies:
cssesc "^2.0.0"
indexes-of "^1.0.1"
uniq "^1.0.1"
postcss-selector-parser@^5.0.0-rc.3:
version "5.0.0-rc.3"
resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-5.0.0-rc.3.tgz#c4525dcc8eb90166c53dcbf0cb9317ceff5a15b5"
@@ -10333,6 +10396,15 @@ postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.2:
source-map "^0.6.1"
supports-color "^5.5.0"
postcss@^7.0.14:
version "7.0.14"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.14.tgz#4527ed6b1ca0d82c53ce5ec1a2041c2346bbd6e5"
integrity sha512-NsbD6XUUMZvBxtQAJuWDJeeC4QFsmWsfozWxCJPWf3M55K9iu2iMDaKqyoOdTJ1R4usBXuxlVFAIo8rZPQD4Bg==
dependencies:
chalk "^2.4.2"
source-map "^0.6.1"
supports-color "^6.1.0"
power-assert-context-formatter@^1.0.7:
version "1.2.0"
resolved "https://registry.yarnpkg.com/power-assert-context-formatter/-/power-assert-context-formatter-1.2.0.tgz#8fbe72692288ec5a7203cdf215c8b838a6061d2a"
@@ -10467,6 +10539,11 @@ prettier@1.13.7:
resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.13.7.tgz#850f3b8af784a49a6ea2d2eaa7ed1428a34b7281"
integrity sha512-KIU72UmYPGk4MujZGYMFwinB7lOf2LsDNGSOC8ufevsrPLISrZbNJlWstRi3m0AMuszbH+EFSQ/r6w56RSPK6w==
prettier@1.16.3:
version "1.16.3"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.16.3.tgz#8c62168453badef702f34b45b6ee899574a6a65d"
integrity sha512-kn/GU6SMRYPxUakNXhpP0EedT/KmaPzr0H5lIsDogrykbaxOpOfAFfk5XA7DZrJyMAv1wlMV3CPcZruGXVVUZw==
prettier@^1.15.3:
version "1.15.3"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.15.3.tgz#1feaac5bdd181237b54dbe65d874e02a1472786a"
@@ -10765,6 +10842,14 @@ raw-body@2.3.3:
iconv-lite "0.4.23"
unpipe "1.0.0"
raw-loader@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/raw-loader/-/raw-loader-1.0.0.tgz#3f9889e73dadbda9a424bce79809b4133ad46405"
integrity sha512-Uqy5AqELpytJTRxYT4fhltcKPj0TyaEpzJDcGz7DFJi+pQOOi3GjR/DOdxTkTsF+NzhnldIoG6TORaBlInUuqA==
dependencies:
loader-utils "^1.1.0"
schema-utils "^1.0.0"
rc@^1.0.1, rc@^1.1.6, rc@^1.2.7:
version "1.2.8"
resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
@@ -11889,6 +11974,13 @@ slide@^1.1.5:
resolved "https://registry.yarnpkg.com/slide/-/slide-1.1.6.tgz#56eb027d65b4d2dce6cb2e2d32c4d4afc9e1d707"
integrity sha1-VusCfWW00tzmyy4tMsTUr8nh1wc=
slug@^0.9.3:
version "0.9.3"
resolved "https://registry.yarnpkg.com/slug/-/slug-0.9.3.tgz#8c9c773d79367c0188733316cf49fd2b8db40f6a"
integrity sha512-DddSQQnUdAofjFOKRT+zsMNrdzdte04G5DUA+NeaUJlPAqR1bWQ22qVfayRxHFiRGR3bUV0wt5VSj4849pGKSw==
dependencies:
unicode ">= 0.3.1"
snapdragon-node@^2.0.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b"
@@ -12466,6 +12558,13 @@ supports-color@^5.1.0, supports-color@^5.3.0, supports-color@^5.4.0, supports-co
dependencies:
has-flag "^3.0.0"
supports-color@^6.1.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3"
integrity sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==
dependencies:
has-flag "^3.0.0"
svgo@^0.7.0:
version "0.7.2"
resolved "https://registry.yarnpkg.com/svgo/-/svgo-0.7.2.tgz#9f5772413952135c6fefbf40afe6a4faa88b4bb5"
@@ -12960,6 +13059,11 @@ unicode-property-aliases-ecmascript@^1.0.4:
resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.0.4.tgz#5a533f31b4317ea76f17d807fa0d116546111dd0"
integrity sha512-2WSLa6OdYd2ng8oqiGIWnJqyFArvhn+5vgx5GTxMbUYjCYKUcuKS62YLFF0R/BDGlB1yzXjQOLtPAfHsgirEpg==
"unicode@>= 0.3.1":
version "11.0.1"
resolved "https://registry.yarnpkg.com/unicode/-/unicode-11.0.1.tgz#735bd422ec75cf28d396eb224d535d168d5f1db6"
integrity sha512-+cHtykLb+eF1yrSLWTwcYBrqJkTfX7Quoyg7Juhe6uylF43ZbMdxMuSHNYlnyLT8T7POAvavgBthzUF9AIaQvQ==
unified@^6.0.0:
version "6.2.0"
resolved "https://registry.yarnpkg.com/unified/-/unified-6.2.0.tgz#7fbd630f719126d67d40c644b7e3f617035f6dba"
@@ -13377,7 +13481,7 @@ vue-hot-reload-api@^2.3.0:
resolved "https://registry.yarnpkg.com/vue-hot-reload-api/-/vue-hot-reload-api-2.3.1.tgz#b2d3d95402a811602380783ea4f566eb875569a2"
integrity sha512-AA86yKZ5uOKz87/q1UpngEXhbRkaYg1b7HMMVRobNV1IVKqZe8oLIzo6iMocVwZXnYitlGwf2k4ZRLOZlS8oPQ==
vue-loader@^15.2.4, vue-loader@^15.4.2:
vue-loader@^15.2.4:
version "15.4.2"
resolved "https://registry.yarnpkg.com/vue-loader/-/vue-loader-15.4.2.tgz#812bb26e447dd3b84c485eb634190d914ce125e2"
integrity sha512-nVV27GNIA9MeoD8yQ3dkUzwlAaAsWeYSWZHsu/K04KCD339lW0Jv2sJWsjj3721SP7sl2lYdPmjcHgkWQSp5bg==
@@ -13388,10 +13492,26 @@ vue-loader@^15.2.4, vue-loader@^15.4.2:
vue-hot-reload-api "^2.3.0"
vue-style-loader "^4.1.0"
vue-router@3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-3.0.1.tgz#d9b05ad9c7420ba0f626d6500d693e60092cc1e9"
integrity sha512-vLLoY452L+JBpALMP5UHum9+7nzR9PeIBCghU9ZtJ1eWm6ieUI8Zb/DI3MYxH32bxkjzYV1LRjNv4qr8d+uX/w==
vue-loader@^15.6.2:
version "15.6.2"
resolved "https://registry.yarnpkg.com/vue-loader/-/vue-loader-15.6.2.tgz#892741d96260936ff69e892f72ec361ba4d100d2"
integrity sha512-T6fONodj861M3PqZ1jlbUFjeezbUnPRY2bd+3eZuDvYADgkN3VFU2H5feqySNg9XBt8rcbyBGmFWTZtrOX+v5w==
dependencies:
"@vue/component-compiler-utils" "^2.5.1"
hash-sum "^1.0.2"
loader-utils "^1.1.0"
vue-hot-reload-api "^2.3.0"
vue-style-loader "^4.1.0"
vue-router@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-3.0.2.tgz#dedc67afe6c4e2bc25682c8b1c2a8c0d7c7e56be"
integrity sha512-opKtsxjp9eOcFWdp6xLQPLmRGgfM932Tl56U9chYTnoWqKxQ8M20N7AkdEbM5beUh6wICoFGYugAX9vQjyJLFg==
vue-rx@^6.1.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/vue-rx/-/vue-rx-6.1.0.tgz#75c378ef7d58195344b9bf95379d61497c9863ae"
integrity sha512-2nfkmmoZ1C4hBZ6eC1c2vvLzc3DextyNpXIDLQcPfcSHyarQCClyEO5y5Pr/P7MDzT5mrRzfunlLQaH3/lQoXA==
vue-style-loader@^3.0.1:
version "3.1.2"
@@ -13401,7 +13521,7 @@ vue-style-loader@^3.0.1:
hash-sum "^1.0.2"
loader-utils "^1.0.2"
vue-style-loader@^4.1.0, vue-style-loader@^4.1.2:
vue-style-loader@^4.1.0:
version "4.1.2"
resolved "https://registry.yarnpkg.com/vue-style-loader/-/vue-style-loader-4.1.2.tgz#dedf349806f25ceb4e64f3ad7c0a44fba735fcf8"
integrity sha512-0ip8ge6Gzz/Bk0iHovU9XAUQaFt/G2B61bnWa2tCcqqdgfHs1lF9xXorFbE55Gmy92okFT+8bfmySuUOu13vxQ==
@@ -13478,7 +13598,7 @@ vue-styleguidist@^1.8.9:
webpack-dev-server "^2.11.2"
webpack-merge "^4.1.3"
vue-template-compiler@2.5.17, vue-template-compiler@^2.4.2, vue-template-compiler@^2.5.16:
vue-template-compiler@^2.4.2, vue-template-compiler@^2.5.16:
version "2.5.17"
resolved "https://registry.yarnpkg.com/vue-template-compiler/-/vue-template-compiler-2.5.17.tgz#52a4a078c327deb937482a509ae85c06f346c3cb"
integrity sha512-63uI4syCwtGR5IJvZM0LN5tVsahrelomHtCxvRkZPJ/Tf3ADm1U1wG6KWycK3qCfqR+ygM5vewUvmJ0REAYksg==
@@ -13486,11 +13606,24 @@ vue-template-compiler@2.5.17, vue-template-compiler@^2.4.2, vue-template-compile
de-indent "^1.0.2"
he "^1.1.0"
vue-template-compiler@^2.5.22:
version "2.5.22"
resolved "https://registry.yarnpkg.com/vue-template-compiler/-/vue-template-compiler-2.5.22.tgz#c3d3c02c65f1908205c4fbd3b0ef579e51239955"
integrity sha512-1VTw/NPTUeHNiwhkq6NkFzO7gYLjFCueBN0FX8NEiQIemd5EUMQ5hxrF7O0zCPo5tae+U9S/scETPea+hIz8Eg==
dependencies:
de-indent "^1.0.2"
he "^1.1.0"
vue-template-es2015-compiler@^1.5.3, vue-template-es2015-compiler@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.6.0.tgz#dc42697133302ce3017524356a6c61b7b69b4a18"
integrity sha512-x3LV3wdmmERhVCYy3quqA57NJW7F3i6faas++pJQWtknWT+n7k30F4TVdHvCLn48peTJFRvCpxs3UuFPqgeELg==
vue-template-es2015-compiler@^1.8.2:
version "1.8.2"
resolved "https://registry.yarnpkg.com/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.8.2.tgz#dd73e80ba58bb65dd7a8aa2aeef6089cf6116f2a"
integrity sha512-cliV19VHLJqFUYbz/XeWXe5CO6guzwd0yrrqqp0bmjlMP3ZZULY7fu8RTC4+3lmHwo6ESVDHFDsvjB15hcR5IA==
vue-webpack-loaders@^1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/vue-webpack-loaders/-/vue-webpack-loaders-1.0.8.tgz#1476bfa1feb40401663b5eb72de83ab611f1f047"
@@ -13513,11 +13646,21 @@ vue-webpack-loaders@^1.0.8:
vue-style-loader "^3.0.1"
vue-template-compiler "^2.5.16"
vue@2.5.17, vue@^2.4.2, vue@^2.5.16:
vue@^2.4.2, vue@^2.5.16:
version "2.5.17"
resolved "https://registry.yarnpkg.com/vue/-/vue-2.5.17.tgz#0f8789ad718be68ca1872629832ed533589c6ada"
integrity sha512-mFbcWoDIJi0w0Za4emyLiW72Jae0yjANHbCVquMKijcavBGypqlF7zHRgMa5k4sesdv7hv2rB4JPdZfR+TPfhQ==
vue@^2.5.22:
version "2.5.22"
resolved "https://registry.yarnpkg.com/vue/-/vue-2.5.22.tgz#3bf88041af08b8539c37b268b70ca79245e9cc30"
integrity sha512-pxY3ZHlXNJMFQbkjEgGVMaMMkSV1ONpz+4qB55kZuJzyJOhn6MSy/YZdzhdnumegNzVTL/Dn3Pp4UrVBYt1j/g==
vuex@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/vuex/-/vuex-3.1.0.tgz#634b81515cf0cfe976bd1ffe9601755e51f843b9"
integrity sha512-mdHeHT/7u4BncpUZMlxNaIdcN/HIt1GsGG5LKByArvYG/v6DvHcOxvDCts+7SRdCoIRGllK8IMZvQtQXLppDYg==
walk-back@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/walk-back/-/walk-back-3.0.0.tgz#2358787a35da91032dad5e92f80b12370d8795c5"
+1 -3
View File
@@ -644,7 +644,7 @@ class CommunityTagsColumn(TextColumn):
def get_value(self, trans, grid, item):
return trans.fill_template("/tagging_common.mako", tag_type="community", trans=trans, user=trans.get_user(), tagged_item=item, elt_context=self.grid_name,
in_form=True, input_size="20", tag_click_fn="add_tag_to_grid_filter", use_toggle_link=True)
tag_click_fn="add_tag_to_grid_filter", use_toggle_link=True)
def filter(self, trans, user, query, column_filter):
""" Modify query to filter model_class by tag. Multiple filters are ANDed. """
@@ -680,8 +680,6 @@ class IndividualTagsColumn(CommunityTagsColumn):
user=trans.user,
tagged_item=item,
elt_context=self.grid_name,
in_form=True,
input_size="20",
tag_click_fn="add_tag_to_grid_filter",
use_toggle_link=True)
@@ -30,8 +30,6 @@ class TagsController(BaseUIController, UsesTagsMixin):
user=trans.user,
tagged_item=item,
elt_context=elt_context,
in_form=False,
input_size="22",
tag_click_fn="default_tag_click_fn",
use_toggle_link=False)
@@ -563,7 +563,7 @@ class CommunityTagsColumn(TextColumn):
def get_value(self, trans, grid, item):
return trans.fill_template("/tagging_common.mako", tag_type="community", trans=trans, user=trans.get_user(), tagged_item=item, elt_context=self.grid_name,
in_form=True, input_size="20", tag_click_fn="add_tag_to_grid_filter", use_toggle_link=True)
tag_click_fn="add_tag_to_grid_filter", use_toggle_link=True)
def filter(self, trans, user, query, column_filter):
""" Modify query to filter model_class by tag. Multiple filters are ANDed. """
@@ -599,8 +599,6 @@ class IndividualTagsColumn(CommunityTagsColumn):
user=trans.user,
tagged_item=item,
elt_context=self.grid_name,
in_form=True,
input_size="20",
tag_click_fn="add_tag_to_grid_filter",
use_toggle_link=True)
+74 -219
View File
@@ -1,259 +1,114 @@
<%namespace file="/display_common.mako" import="get_controller_name" />
<%!
from cgi import escape
from random import random
from math import floor
import six
from galaxy.model import Tag, ItemTagAssociation
from galaxy.web.framework.helpers import iff
from galaxy.util import unicodify
from cgi import escape
%>
## Render a tagging element if there is a tagged_item.
%if tagged_item is not None:
%if tag_type == "individual":
${render_individual_tagging_element( user=user, tagged_item=tagged_item, elt_context=elt_context, in_form=in_form, input_size=input_size, tag_click_fn=tag_click_fn, use_toggle_link=use_toggle_link )}
${render_individual_tagging_element(
user=user,
tagged_item=tagged_item,
elt_context=elt_context,
tag_click_fn=tag_click_fn,
use_toggle_link=use_toggle_link
)}
%elif tag_type == "community":
${render_community_tagging_element(tagged_item=tagged_item, elt_context=elt_context, tag_click_fn=tag_click_fn)}
${render_community_tagging_element(
tagged_item=tagged_item,
elt_context=elt_context,
tag_click_fn=tag_click_fn
)}
%endif
%endif
## Render HTML for a list of tags.
<%def name="render_tagging_element_html(elt_id=None, tags=None, editable=True, use_toggle_link=True, input_size='15', in_form=False, tag_type='individual', render_add_tag_button=True)">
## Useful attributes.
<%def name="render_community_tagging_element(
tagged_item=None,
elt_context=None,
use_toggle_link=False,
tag_click_fn='default_tag_click_fn')">
<%
num_tags = len( tags )
%>
<div class="tag-element"
%if elt_id:
id="${elt_id}"
%endif
## Do not display element if there are no tags and it is not editable.
%if num_tags == 0 and not editable:
style="display: none"
%endif
>
%if use_toggle_link:
<a class="toggle-link" href="#">${num_tags} Tag${iff( num_tags == 1, "", "s")}</a>
%endif
<div class="tag-area
%if tag_type == 'individual':
individual-tag-area
%endif
">
## Build buttons for current tags.
%for tag in tags:
<%
## Handle both Tag and ItemTagAssociation objects.
if isinstance( tag, Tag ):
tag_name = tag.name
tag_value = None
elif isinstance( tag, ItemTagAssociation ):
tag_name = tag.user_tname
tag_value = tag.user_value
## Convert tag name, value to unicode.
if isinstance( tag_name, six.binary_type ):
tag_name = unicodify( escape( tag_name ) )
if tag_value:
tag_value = unicodify( escape( tag_value ) )
if tag_value:
tag_str = tag_name + ":" + tag_value
else:
tag_str = tag_name
%>
<span class="tag-button">
<span class="tag-name">${tag_str | h}</span>
%if editable:
<img class="delete-tag-img" src="${h.url_for('/static/images/delete_tag_icon_gray.png')}"/>
%endif
</span>
%endfor
## Add tag input field. If element is in form, tag input is a textarea; otherwise element is a input type=text.
%if editable:
%if in_form:
<textarea class="tag-input" rows='1' cols='${input_size}'></textarea>
%else:
<input class="tag-input" type='text' size='${input_size}'/>
%endif
## Add "add tag" button.
%if render_add_tag_button:
<img src='${h.url_for('/static/images/fugue/tag--plus.png')}' class="add-tag-button" title="Add tags"/>
%endif
%endif
</div>
</div>
</%def>
## Render tool tagging elements
<%def name="render_tool_tagging_elements()">
<%
elt_id = int ( floor ( random() * six.MAXSIZE ) )
tags = trans.app.tag_handler.get_tool_tags()
%>
${self.render_tagging_element_html(elt_id=elt_id, \
tags=tags, \
editable=False, \
use_toggle_link=False )}
<script type="text/javascript">
window.bundleEntries.init_tag_click_function($('#${elt_id}'), tool_tag_click);
</script>
</%def>
## Render community tagging element.
<%def name="render_community_tagging_element(tagged_item=None, elt_context=None, use_toggle_link=False, tag_click_fn='default_tag_click_fn')">
## Build HTML.
<%
elt_id = int ( floor ( random() * six.MAXSIZE ) )
controller_name = get_controller_name(tagged_item)
click_url = h.url_for( controller='/' + controller_name , action='list_published')
community_tags = trans.app.tag_handler.get_community_tags( item=tagged_item, limit=5 )
## Having trouble converting list of tags into a plain array, this just
## just plucks out the name
community_tag_names = []
for tag in community_tags:
community_tag_names.append(escape(tag.name))
%>
${self.render_tagging_element_html(elt_id=elt_id, \
tags=community_tags, \
use_toggle_link=use_toggle_link, \
editable=False, tag_type="community")}
<div id="tags-community-${controller_name}-${tagged_item.id}"></div>
## Set up tag click function.
<script type="text/javascript">
window.bundleEntries.init_tag_click_function($('#${elt_id}'), ${tag_click_fn});
config.addInitialization(function(galaxy, config) {
var container = document.getElementById("tags-community-${controller_name}-${tagged_item.id}");
var options = ${h.dumps(tagged_item.to_dict())};
options.tags = ${h.dumps(community_tag_names)};
options.id = String(options.id);
options.itemClass = options.model_class;
options.context = "${elt_context}";
options.tagClickFn = "${tag_click_fn}";
options.clickUrl = "${click_url}";
options.disabled = true;
bundleEntries.mountTaggingComponent(options, container);
});
</script>
</%def>
## Render individual tagging element.
<%def name="render_individual_tagging_element(user=None, tagged_item=None, elt_context=None, use_toggle_link=True, in_form=False, input_size='15', tag_click_fn='default_tag_click_fn', get_toggle_link_text_fn='default_get_toggle_link_text_fn', editable=True, render_add_tag_button=True)">
## Useful attributes.
<%def name="render_individual_tagging_element(
user=None,
tagged_item=None,
elt_context=None,
use_toggle_link=True,
tag_click_fn='default_tag_click_fn',
get_toggle_link_text_fn='default_get_toggle_link_text_fn',
editable=True)">
<%
# Useful ids.
tagged_item_id = str( trans.security.encode_id ( tagged_item.id ) )
elt_id = int ( floor ( random() * six.MAXSIZE ) )
# Get list of user's item tags. TODO: implement owner_tags for all taggable objects and use here.
item_tags = [ tag for tag in tagged_item.tags if ( tag.user == user ) ]
item_tag_names = []
for ta in item_tags:
item_tag_names.append(escape(ta.tag.name))
%>
## Build HTML.
<%
if len(item_tags) > 3:
# If item has more than 3 tags show a link to see tags instead of displaying them all
use_toggle_link = True
else:
use_toggle_link = False
%>
${self.render_tagging_element_html(elt_id=elt_id, tags=item_tags, editable=editable, use_toggle_link=use_toggle_link, input_size=input_size, in_form=in_form, render_add_tag_button=render_add_tag_button)}
<div id="tags-${tagged_item_id}"></div>
## Build script that augments tags using progressive javascript.
<script type="text/javascript">
//
// Set up autocomplete tagger.
//
config.addInitialization(function(galaxy, config) {
var container = document.getElementById("tags-${tagged_item_id}");
//
// Default function get text to display on the toggle link.
//
var default_get_toggle_link_text_fn = function(tags)
{
var text = "";
var num_tags = _.size(tags);
if (num_tags != 0)
{
text = num_tags + (num_tags != 1 ? " Tags" : " Tag");
/*
// Show first N tags; hide the rest.
var max_to_show = 1;
var options = ${h.dumps(tagged_item.to_dict())};
options.tags = ${h.dumps(item_tag_names)};
options.id = "${tagged_item_id}";
options.itemClass = "${tagged_item.__class__.__name__}";
options.context = "${elt_context}";
options.tagClickFn = "${tag_click_fn}";
options.disabled = !${h.to_js_bool(editable)};
// Build tag string.
var tag_strs = new Array();
var count = 0;
for (tag_name in tags)
{
tag_value = tags[tag_name];
tag_strs[tag_strs.length] = build_tag_str(tag_name, tag_value);
if (++count == max_to_show)
break;
}
tag_str = tag_strs.join(", ");
// Finalize text.
var num_tags_hiding = num_tags - max_to_show;
text = "Tags: " + tag_str +
(num_tags_hiding != 0 ? " and " + num_tags_hiding + " more" : "");
*/
}
else
{
// No tags.
text = "Add tags";
}
return text;
};
// Default function to handle a tag click.
var default_tag_click_fn = function(tag_name, tag_value) { };
<%
## Build dict of tag name, values.
tag_names_and_values = dict()
for tag in item_tags:
tag_name = escape( tag.user_tname )
tag_value = ""
if tag.value is not None:
tag_value = escape( tag.user_value )
## Tag names and values may be string or unicode object.
if isinstance( tag_name, six.binary_type ):
tag_names_and_values[unicodify(tag_name, 'utf-8')] = unicodify(tag_value, 'utf-8')
else:
tag_names_and_values[tag_name] = tag_value
%>
var options =
{
tags : ${h.dumps(tag_names_and_values)},
editable : ${iff( editable, 'true', 'false' )},
get_toggle_link_text_fn: ${get_toggle_link_text_fn},
tag_click_fn: ${tag_click_fn},
## Use forward slash in controller to suppress route memory.
ajax_autocomplete_tag_url: "${h.url_for( controller='/tag', action='tag_autocomplete_data', item_id=tagged_item_id, item_class=tagged_item.__class__.__name__ )}",
ajax_add_tag_url: "${h.url_for( controller='/tag', action='add_tag_async', item_id=tagged_item_id, item_class=tagged_item.__class__.__name__, context=elt_context )}",
ajax_delete_tag_url: "${h.url_for( controller='/tag', action='remove_tag_async', item_id=tagged_item_id, item_class=tagged_item.__class__.__name__, context=elt_context )}",
delete_tag_img: "${h.url_for('/static/images/delete_tag_icon_gray.png')}",
delete_tag_img_rollover: "${h.url_for('/static/images/delete_tag_icon_white.png')}",
use_toggle_link: ${iff( use_toggle_link, 'true', 'false' )}
};
$('#${elt_id}').autocomplete_tagging(options);
bundleEntries.mountTaggingComponent(options, container);
});
</script>
## Use style to hide/display the tag area.
<style>
.tag-area {
display: ${iff( use_toggle_link, "none", "block" )};
}
</style>
<noscript>
<style>
.tag-area {
display: block;
}
</style>
</noscript>
</%def>
<%def name="community_tag_js( controller_name )">
## TODO: Note that this function no longer has anything to do with community
## tags. the ratings code and tagging initialization were previously co-mingled
## in here. Will remove this script when we write the ratings components
## set up comminity tag and rating handling - used for page start up / set up
## controller_name: the model controller for the item being tagged - generally gotten with get_controller_name( item )
<script type="text/javascript">
// Handle click on community tag.
function community_tag_click(tag_name, tag_value) {
var href = '${h.url_for ( controller='/' + controller_name , action='list_published')}';
href = href + "?f-tags=" + tag_name;
if (tag_value != undefined && tag_value != "") {
href = href + ":" + tag_value;
}
self.location = href;
}
// Map item rating to number of stars to show.
function map_rating_to_num_stars(rating) {
@@ -282,7 +282,7 @@
Tags:
</label>
<div style="float: left; width: 225px; margin-right: 10px; border-style: inset; border-width: 1px; margin-left: 2px">
${render_individual_tagging_element(user=trans.get_user(), tagged_item=stored, elt_context="edit_attributes.mako", use_toggle_link=False, input_size="20")}
${render_individual_tagging_element(user=trans.get_user(), tagged_item=stored, elt_context="edit_attributes.mako", use_toggle_link=False)}
</div>
<div class="toolParamHelp">Apply tags to make it easy to search for and find items with the same tag.</div>
</div>
@@ -95,7 +95,7 @@ class HistoryGridTestCase(SharedStateSeleniumTestCase):
if tags is None:
raise AssertionError('Failed to find history with name [%s]' % self.history1_name)
tag_button_selector = '.tag-area > .tag-button > .tag-name'
tag_button_selector = 'div.tag-name'
tag_buttons = tags.find_elements_by_css_selector(tag_button_selector)
tag_button_text = None
target_tag_button_text = self.history1_tags[0]
+3 -7
View File
@@ -211,20 +211,16 @@ class SavedHistoriesTestCase(SharedStateSeleniumTestCase):
self._login()
self.navigate_to_saved_histories_page()
# Click the add tag button
tags_cell = self.get_history_tags_cell(self.history2_name)
add_tag_button = tags_cell.find_element_by_css_selector('.add-tag-button')
add_tag_button.click()
# Insert a tag
tags_cell = self.get_history_tags_cell(self.history2_name)
tag_area = tags_cell.find_element_by_tag_name('textarea')
tag_area = tags_cell.find_element_by_css_selector('.ti-new-tag-input-wrapper input')
tag_area.click()
tag_area.send_keys(self.history2_tags[0])
self.send_enter(tag_area)
# Search by tag
tags_cell = self.get_history_tags_cell(self.history2_name)
tag = tags_cell.find_element_by_css_selector('span.tag-name')
tag = tags_cell.find_element_by_css_selector('.ti-tag-center')
tag.click()
self.assert_grid_histories_are([self.history2_name], False)