mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-09-24 16:30:27 +08:00
Merge pull request #9261 from OlegZharkov/tools-view-port
implementing a global tools-view component + schema.org support
This commit is contained in:
@@ -1,33 +1,52 @@
|
||||
<template>
|
||||
<b-card>
|
||||
<template v-slot:header>
|
||||
<h4 class="mb-0">
|
||||
Citations
|
||||
<b-button
|
||||
v-if="viewRender"
|
||||
@click="toggleViewRender"
|
||||
title="Show all in BibTeX format."
|
||||
class="citations-to-bibtex"
|
||||
>
|
||||
<i class="fa fa-pencil-square-o"></i> Show BibTeX
|
||||
</b-button>
|
||||
<b-button v-else @click="toggleViewRender" title="Return to formatted citation list.">
|
||||
<i class="fa fa-times"></i> Hide BibTeX
|
||||
</b-button>
|
||||
</h4>
|
||||
</template>
|
||||
<div v-if="source === 'histories'" class="infomessage">
|
||||
When writing up your analysis, remember to include all references that should be cited in order to
|
||||
completely describe your work. Also, please remember to
|
||||
<a href="https://galaxyproject.org/citing-galaxy">cite Galaxy</a>.
|
||||
</div>
|
||||
<span v-if="viewRender" class="citations-formatted"> <p v-html="formattedReferences"></p> </span>
|
||||
<pre v-else>
|
||||
<div>
|
||||
<b-card v-if="!simple">
|
||||
<template v-slot:header>
|
||||
<h4 class="mb-0">
|
||||
Citations
|
||||
<b-button
|
||||
v-if="viewRender"
|
||||
title="Show all in BibTeX format."
|
||||
class="citations-to-bibtex"
|
||||
@click="toggleViewRender"
|
||||
>
|
||||
<i class="fa fa-pencil-square-o"></i> Show BibTeX
|
||||
</b-button>
|
||||
<b-button v-else title="Return to formatted citation list." @click="toggleViewRender">
|
||||
<i class="fa fa-times"></i> Hide BibTeX
|
||||
</b-button>
|
||||
</h4>
|
||||
</template>
|
||||
<div v-if="source === 'histories'" class="infomessage">
|
||||
When writing up your analysis, remember to include all references that should be cited in order to
|
||||
completely describe your work. Also, please remember to
|
||||
<a href="https://galaxyproject.org/citing-galaxy">cite Galaxy</a>.
|
||||
</div>
|
||||
<span v-if="viewRender" class="citations-formatted">
|
||||
<p v-html="formattedReferences"></p>
|
||||
</span>
|
||||
<pre v-else>
|
||||
<code class="citations-bibtex">
|
||||
{{ content }}
|
||||
</code>
|
||||
</pre>
|
||||
</b-card>
|
||||
</b-card>
|
||||
<div v-else-if="citations.length">
|
||||
<b-btn v-b-toggle="id" variant="primary">Citations</b-btn>
|
||||
<b-collapse
|
||||
:id="id"
|
||||
class="mt-2"
|
||||
@show="$emit('show')"
|
||||
@shown="$emit('shown')"
|
||||
@hide="$emit('hide')"
|
||||
@hidden="$emit('hidden')"
|
||||
>
|
||||
<b-card>
|
||||
<p v-html="formattedReferences"></p>
|
||||
</b-card>
|
||||
</b-collapse>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import _ from "underscore";
|
||||
@@ -55,29 +74,40 @@ export default {
|
||||
type: Boolean,
|
||||
requried: false,
|
||||
default: true
|
||||
},
|
||||
simple: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
citations: [],
|
||||
content: "",
|
||||
errors: []
|
||||
errors: [],
|
||||
showCollapse: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
formattedReferences: function() {
|
||||
formattedReferences() {
|
||||
return this.citations.reduce(
|
||||
(a, b) => a.concat(`<p class="formatted-reference">${this.formattedReference(b)}</p>`),
|
||||
""
|
||||
);
|
||||
}
|
||||
},
|
||||
created: function() {
|
||||
updated() {
|
||||
this.$nextTick(() => {
|
||||
this.$emit("rendered");
|
||||
});
|
||||
},
|
||||
created() {
|
||||
axios
|
||||
.get(`${getAppRoot()}api/${this.source}/${this.id}/citations`)
|
||||
.then(response => {
|
||||
this.content = "";
|
||||
for (const rawCitation of response.data) {
|
||||
response.data.forEach(rawCitation => {
|
||||
try {
|
||||
const citation = {
|
||||
fields: {},
|
||||
@@ -87,68 +117,67 @@ export default {
|
||||
if (parsed) {
|
||||
parsed = _.first(parsed);
|
||||
citation.entryType = parsed.entryType || undefined;
|
||||
for (const key in parsed.entryTags) {
|
||||
Object.keys(parsed.entryTags).forEach(key => {
|
||||
citation.fields[key.toLowerCase()] = parsed.entryTags[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
this.citations.push(citation);
|
||||
this.content += rawCitation.content;
|
||||
} catch (err) {
|
||||
console.warn("Error parsing bibtex: " + err);
|
||||
console.warn(`Error parsing bibtex: ${err}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(e);
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
formattedReference: function(citation) {
|
||||
const entryType = citation.entryType;
|
||||
const fields = citation.fields;
|
||||
formattedReference(citation) {
|
||||
const { entryType, fields } = citation;
|
||||
|
||||
let ref = "";
|
||||
const authorsAndYear = `${this._asSentence(
|
||||
const authorsAndYear = `${this.asSentence(
|
||||
(fields.author ? fields.author : "") + (fields.year ? ` (${fields.year})` : "")
|
||||
)} `;
|
||||
const title = fields.title || "";
|
||||
const pages = fields.pages ? `pp. ${fields.pages}` : "";
|
||||
const address = fields.address;
|
||||
if (entryType == "article") {
|
||||
const { address } = fields;
|
||||
if (entryType === "article") {
|
||||
const volume =
|
||||
(fields.volume ? fields.volume : "") +
|
||||
(fields.number ? ` (${fields.number})` : "") +
|
||||
(pages ? `, ${pages}` : "");
|
||||
ref = `${authorsAndYear +
|
||||
this._asSentence(title) +
|
||||
this.asSentence(title) +
|
||||
(fields.journal ? `In <em>${fields.journal}, ` : "") +
|
||||
this._asSentence(volume) +
|
||||
this._asSentence(fields.address)}</em>`;
|
||||
} else if (entryType == "inproceedings" || entryType == "proceedings") {
|
||||
this.asSentence(volume) +
|
||||
this.asSentence(fields.address)}</em>`;
|
||||
} else if (entryType === "inproceedings" || entryType === "proceedings") {
|
||||
ref = `${authorsAndYear +
|
||||
this._asSentence(title) +
|
||||
this.asSentence(title) +
|
||||
(fields.booktitle ? `In <em>${fields.booktitle}, ` : "") +
|
||||
(pages ? pages : "") +
|
||||
(pages || "") +
|
||||
(address ? `, ${address}` : "")}.</em>`;
|
||||
} else if (entryType == "mastersthesis" || entryType == "phdthesis") {
|
||||
} else if (entryType === "mastersthesis" || entryType === "phdthesis") {
|
||||
ref =
|
||||
authorsAndYear +
|
||||
this._asSentence(title) +
|
||||
this.asSentence(title) +
|
||||
(fields.howpublished ? `${fields.howpublished}. ` : "") +
|
||||
(fields.note ? `${fields.note}.` : "");
|
||||
} else if (entryType == "techreport") {
|
||||
} else if (entryType === "techreport") {
|
||||
ref =
|
||||
authorsAndYear +
|
||||
this._asSentence(title) +
|
||||
this._asSentence(fields.institution) +
|
||||
this._asSentence(fields.number) +
|
||||
this._asSentence(fields.type);
|
||||
} else if (entryType == "book" || entryType == "inbook" || entryType == "incollection") {
|
||||
ref = `${authorsAndYear} ${this._formatBookInfo(fields)}`;
|
||||
this.asSentence(title) +
|
||||
this.asSentence(fields.institution) +
|
||||
this.asSentence(fields.number) +
|
||||
this.asSentence(fields.type);
|
||||
} else if (entryType === "book" || entryType === "inbook" || entryType === "incollection") {
|
||||
ref = `${authorsAndYear} ${this.formatBookInfo(fields)}`;
|
||||
} else {
|
||||
ref = `${authorsAndYear} ${this._asSentence(title)}${this._asSentence(
|
||||
ref = `${authorsAndYear} ${this.asSentence(title)}${this.asSentence(
|
||||
fields.howpublished
|
||||
)}${this._asSentence(fields.note)}`;
|
||||
)}${this.asSentence(fields.note)}`;
|
||||
}
|
||||
if (fields.doi) {
|
||||
ref += `[<a href="https://doi.org/${fields.doi}" target="_blank">doi:${fields.doi}</a>]`;
|
||||
@@ -158,7 +187,7 @@ export default {
|
||||
}
|
||||
return convertLaTeX({ onError: (error, latex) => `{${stringifyLaTeX(latex)}}` }, ref);
|
||||
},
|
||||
_formatBookInfo: function(fields) {
|
||||
formatBookInfo(fields) {
|
||||
let info = "";
|
||||
if (fields.chapter) {
|
||||
info += `${fields.chapter} in `;
|
||||
@@ -186,10 +215,10 @@ export default {
|
||||
}
|
||||
return `${info}.`;
|
||||
},
|
||||
_asSentence: function(str) {
|
||||
asSentence(str) {
|
||||
return str && str.trim() ? `${str}. ` : "";
|
||||
},
|
||||
toggleViewRender: function() {
|
||||
toggleViewRender() {
|
||||
this.viewRender = !this.viewRender;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,14 @@ export default {
|
||||
document.getElementById("center").style.transition = "";
|
||||
}, 250);
|
||||
}
|
||||
},
|
||||
hide() {
|
||||
this.show = false;
|
||||
document.getElementById("center").style.transition = `${this.side} 200ms linear`;
|
||||
document.getElementById("center").style[this.side] = "0";
|
||||
setTimeout(() => {
|
||||
document.getElementById("center").style.transition = "";
|
||||
}, 250);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import ToolsJson from "./ToolsJson";
|
||||
// test response
|
||||
import testToolsListResponse from "../testData/toolsList";
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
import axios from "axios";
|
||||
import Vue from "vue";
|
||||
import { mount } from "@vue/test-utils";
|
||||
|
||||
describe("ToolsView/ToolsView.vue", () => {
|
||||
let wrapper, axiosMock;
|
||||
const defaultSchemaElementTag = "application/ld+json";
|
||||
|
||||
beforeEach(async () => {
|
||||
axiosMock = new MockAdapter(axios);
|
||||
wrapper = mount(ToolsJson);
|
||||
axiosMock.onGet("/api/tools?tool_help=True").reply(200, testToolsListResponse);
|
||||
await Vue.nextTick();
|
||||
});
|
||||
|
||||
it("schema.org script element is created", async () => {
|
||||
await Vue.nextTick();
|
||||
const tools = wrapper.vm.createToolsJson(testToolsListResponse);
|
||||
const schemaElement = document.getElementById("schema-json");
|
||||
const schemaText = JSON.parse(schemaElement.text);
|
||||
assert(tools["@graph"].length === 84, "tools are not parsed correctly!");
|
||||
assert(schemaText["@graph"].length === 84, "tools are not correctly presented in schema script tag!");
|
||||
assert(schemaElement.type === defaultSchemaElementTag, "element type is not correct!");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getAppRoot } from "onload/loadConfig";
|
||||
import axios from "axios";
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return { schemaTagObj: {} };
|
||||
},
|
||||
methods: {
|
||||
createToolsJson(tools) {
|
||||
function extractSections(acc, section) {
|
||||
function extractTools(_acc, tool) {
|
||||
return tool.name
|
||||
? [
|
||||
..._acc,
|
||||
{
|
||||
"@type": "SoftwareApplication",
|
||||
operatingSystem: "Any",
|
||||
applicationCategory: "Web application",
|
||||
name: tool.name,
|
||||
description: tool.help || tool.description,
|
||||
softwareVersion: tool.version,
|
||||
url: getAppRoot() + String(tool.link).substring(1)
|
||||
}
|
||||
]
|
||||
: _acc;
|
||||
}
|
||||
if ("elems" in section) {
|
||||
return acc.concat(section.elems.reduce(extractTools, []));
|
||||
} else {
|
||||
return acc;
|
||||
}
|
||||
}
|
||||
return {
|
||||
"@context": "http://schema.org",
|
||||
"@graph": tools.reduce(extractSections, [])
|
||||
};
|
||||
}
|
||||
},
|
||||
created() {
|
||||
axios
|
||||
.get(`${getAppRoot()}api/tools?tool_help=True`)
|
||||
.then(response => {
|
||||
this.schemaTagObj = this.createToolsJson(response.data);
|
||||
const el = document.createElement("script");
|
||||
el.id = "schema-json";
|
||||
el.type = "application/ld+json";
|
||||
el.text = JSON.stringify(this.schemaTagObj);
|
||||
document.head.appendChild(el);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,82 @@
|
||||
/* global expect */
|
||||
import ToolsView from "./ToolsView";
|
||||
import { shallowMount, mount, createLocalVue } from "@vue/test-utils";
|
||||
import _l from "utils/localization";
|
||||
import Vue from "vue";
|
||||
|
||||
// test response
|
||||
import testToolsListResponse from "./testData/toolsList";
|
||||
import testCitation from "./testData/citation";
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
import axios from "axios";
|
||||
|
||||
describe("ToolsView/ToolsView.vue", () => {
|
||||
const localVue = createLocalVue();
|
||||
localVue.filter("localize", value => _l(value));
|
||||
let wrapper, emitted, axiosMock;
|
||||
|
||||
beforeEach(async () => {
|
||||
axiosMock = new MockAdapter(axios);
|
||||
wrapper = mount(ToolsView);
|
||||
emitted = wrapper.emitted();
|
||||
axiosMock.onGet("/api/tools?tool_help=True").reply(200, testToolsListResponse);
|
||||
axiosMock.onGet(new RegExp(`./*/citations`)).reply(200, testCitation);
|
||||
await Vue.nextTick();
|
||||
await Vue.nextTick();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
axiosMock.restore();
|
||||
});
|
||||
|
||||
it("should render infinite scroll div", async () => {
|
||||
expect(wrapper.html()).contain('<div infinite-scroll-disabled="busy">');
|
||||
});
|
||||
|
||||
it("should return defined number of tools", async () => {
|
||||
assert(wrapper.vm.getToolsNumber() === 84, "Tools Get Response is not parsed correctly!");
|
||||
});
|
||||
|
||||
it("should render only specific number of tools, equal to current buffer", async () => {
|
||||
let buttons = wrapper.findAll('[type="button"]').filter(button => button.text() === "Info");
|
||||
// one 'info' button per tool
|
||||
assert(wrapper.vm.buffer.length === buttons.length, "Number of 'info' buttons do not equal the buffer size!");
|
||||
});
|
||||
|
||||
it("should open modal on button click", async () => {
|
||||
// findAll() returns WrapperArray, thus regular array.find() won't work
|
||||
let infoButton = wrapper
|
||||
.findAll('[type="button"]')
|
||||
.filter(button => button.text() === "Info")
|
||||
.at(0);
|
||||
const modalId = "modal--" + infoButton.attributes().index;
|
||||
const modal = wrapper.find("#" + modalId);
|
||||
assert(modal.isVisible() === false, "modal is visible before the click!");
|
||||
|
||||
infoButton.trigger("click");
|
||||
await Vue.nextTick();
|
||||
|
||||
assert(modal.isVisible(), "'Info' button didn't open a modal!");
|
||||
});
|
||||
|
||||
it("citation should open on click", async () => {
|
||||
await Vue.nextTick();
|
||||
await Vue.nextTick();
|
||||
await Vue.nextTick();
|
||||
|
||||
let infoButton = wrapper
|
||||
.findAll('[type="button"]')
|
||||
.filter(button => button.text() === "Citations")
|
||||
.at(0);
|
||||
const citation = wrapper.find("#" + infoButton.attributes("aria-controls"));
|
||||
|
||||
assert(citation.isVisible() === false, "citation is visible before being triggered!");
|
||||
assert(infoButton.attributes("aria-expanded") === "false", "citation is expanded before being triggered!");
|
||||
|
||||
infoButton.trigger("click");
|
||||
await Vue.nextTick();
|
||||
|
||||
assert(infoButton.attributes("aria-expanded") === "true", "citation field did not expand!");
|
||||
assert(citation.isVisible(), "citation is not visible, after being triggered!");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<div v-infinite-scroll="loadMore" infinite-scroll-disabled="busy">
|
||||
<h2 class="mb-3" style="text-align: center">
|
||||
<span id="tools-view">Consolidated view of {{ tools.length }} available tools.</span>
|
||||
</h2>
|
||||
<div v-if="!loading">
|
||||
<b-container fluid class="mb-4">
|
||||
<b-row class="justify-content-center">
|
||||
<b-col md="6">
|
||||
<b-form-group description="Search for strings or regular expressions">
|
||||
<b-input-group>
|
||||
<b-form-input
|
||||
v-model="filterText"
|
||||
placeholder="Type to Search"
|
||||
@keyup.native="filter('filterByText')"
|
||||
@keyup.esc.native="filterText = ''"
|
||||
/>
|
||||
<b-input-group-append>
|
||||
<b-btn :disabled="!filter" @click="filter = ''">Clear (esc)</b-btn>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
</b-form-group>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-container>
|
||||
<isotope
|
||||
ref="iso"
|
||||
:options="isoOptions"
|
||||
:list="buffer"
|
||||
style="margin: 0 auto;"
|
||||
@filter="filterOption = arguments[2]"
|
||||
>
|
||||
<b-card v-for="(info, index) in buffer" :key="index" ref="cards" class="m-2" style="width: 23rem;">
|
||||
<template v-slot:header>
|
||||
<div>
|
||||
<b-link :href="info.url" target="_blank">
|
||||
<h4 class="tools-view-name">{{ info.name }}</h4>
|
||||
</b-link>
|
||||
<b-badge class="tools-view-section">{{ info.section }}</b-badge>
|
||||
</div>
|
||||
</template>
|
||||
<p class="card-text" v-html="helpSummary(info.help) || info.description" />
|
||||
<p class="card-text">
|
||||
<b-btn :index="index" v-b-modal="'modal-' + '-' + index">Info</b-btn>
|
||||
<b-modal :id="'modal-' + '-' + index" centered ok-only :static="true" :title="info.name">
|
||||
<b>{{ info.version + " / " + info.id }}</b>
|
||||
<p>{{ info.description }}</p>
|
||||
<p v-html="info.help"></p>
|
||||
</b-modal>
|
||||
</p>
|
||||
<Citations
|
||||
:id="info.id"
|
||||
simple
|
||||
source="tools"
|
||||
@rendered="layout"
|
||||
@show="show(index)"
|
||||
@shown="shown(index)"
|
||||
@hidden="hidden(index)"
|
||||
/>
|
||||
</b-card>
|
||||
</isotope>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getAppRoot } from "onload/loadConfig";
|
||||
import infiniteScroll from "vue-infinite-scroll";
|
||||
import isotope from "vueisotope";
|
||||
import axios from "axios";
|
||||
import Citations from "components/Citations.vue";
|
||||
import { setTimeout } from "timers";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Citations,
|
||||
isotope
|
||||
},
|
||||
directives: { infiniteScroll },
|
||||
props: {
|
||||
transitionDuration: {
|
||||
type: Number,
|
||||
default: 200
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tools: [],
|
||||
buffer: [],
|
||||
filterOption: null,
|
||||
filterText: "",
|
||||
busy: false,
|
||||
loading: true
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isoOptions() {
|
||||
return {
|
||||
transitionDuration: this.transitionDuration,
|
||||
masonry: {
|
||||
fitWidth: true
|
||||
},
|
||||
getFilterData: {
|
||||
filterByText: el => {
|
||||
const re = new RegExp(this.filterText, "i");
|
||||
return el.name.match(re) || el.description.match(re) || el.help.match(re);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
created() {
|
||||
axios
|
||||
.get(`${getAppRoot()}api/tools?tool_help=True`)
|
||||
.then(response => {
|
||||
this.initialize(response.data);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
show(index) {
|
||||
this.$refs.cards[index].style.zIndex = "1";
|
||||
},
|
||||
shown(index) {
|
||||
this.layout();
|
||||
setTimeout(() => {
|
||||
this.$refs.cards[index].style.zIndex = "auto";
|
||||
}, 200);
|
||||
},
|
||||
hidden(index) {
|
||||
this.layout();
|
||||
},
|
||||
layout() {
|
||||
this.$refs.iso.layout();
|
||||
},
|
||||
filter(key) {
|
||||
this.$refs.iso.filter(key);
|
||||
},
|
||||
toolsExtracted(tools) {
|
||||
function extractSections(acc, section) {
|
||||
function extractTools(_acc, tool) {
|
||||
return tool.name
|
||||
? [
|
||||
..._acc,
|
||||
{
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
section: section.name,
|
||||
description: tool.description,
|
||||
url: getAppRoot() + String(tool.link).substring(1),
|
||||
version: tool.version,
|
||||
help: tool.help
|
||||
}
|
||||
]
|
||||
: _acc;
|
||||
}
|
||||
if ("elems" in section) {
|
||||
return acc.concat(section.elems.reduce(extractTools, []));
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
return tools
|
||||
.reduce(extractSections, [])
|
||||
.map(a => [Math.random(), a])
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(a => a[1]);
|
||||
},
|
||||
loadMore() {
|
||||
if (this.buffer.length < this.tools.length) {
|
||||
this.busy = true;
|
||||
|
||||
setTimeout(() => {
|
||||
const start = this.buffer.length;
|
||||
const end = start + 10;
|
||||
const newItems = this.tools.slice(start, end);
|
||||
this.buffer = this.buffer.concat(newItems);
|
||||
|
||||
this.busy = false;
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
helpSummary(help) {
|
||||
const parser = new DOMParser();
|
||||
const helpDoc = parser.parseFromString(help, "text/html");
|
||||
const xpath = "//strong[text()='What it does']/../following-sibling::*";
|
||||
const match = helpDoc.evaluate(xpath, helpDoc, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null)
|
||||
.singleNodeValue;
|
||||
if (match) {
|
||||
return match.innerHTML;
|
||||
}
|
||||
const helpText = helpDoc.documentElement.textContent;
|
||||
if (helpText) {
|
||||
return helpText.substring(0, helpText.indexOf("\n\n"));
|
||||
}
|
||||
return null;
|
||||
},
|
||||
getToolsNumber() {
|
||||
return this.tools.length;
|
||||
},
|
||||
initialize(tools) {
|
||||
this.tools = this.toolsExtracted(tools);
|
||||
this.buffer = this.tools.slice(0, 20);
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1 @@
|
||||
[{"content": " @article{Blankenberg_2011, title={Making whole genome multiple alignments usable for biologists}, volume={27}, ISSN={1367-4803}, url={http://dx.doi.org/10.1093/bioinformatics/btr398}, DOI={10.1093/bioinformatics/btr398}, number={17}, journal={Bioinformatics}, publisher={Oxford University Press (OUP)}, author={Blankenberg, Daniel and Taylor, James and Nekrutenko, Anton}, year={2011}, month={Jul}, pages={2426\u00e2\u0080\u00932428}}\n", "format": "bibtex"}]
|
||||
File diff suppressed because one or more lines are too long
@@ -33,6 +33,8 @@ import HistoryView from "components/HistoryView.vue";
|
||||
import WorkflowInvocationReport from "components/WorkflowInvocationReport.vue";
|
||||
import WorkflowRun from "components/Workflow/Run/WorkflowRun.vue";
|
||||
import RecentInvocations from "components/User/RecentInvocations.vue";
|
||||
import ToolsView from "components/ToolsView/ToolsView.vue";
|
||||
import ToolsJson from "components/ToolsView/ToolsSchemaJson/ToolsJson.vue";
|
||||
import HistoryList from "mvc/history/history-list";
|
||||
import PluginList from "components/PluginList.vue";
|
||||
import QueryStringParsing from "utils/query-string-parsing";
|
||||
@@ -51,6 +53,8 @@ export const getAnalysisRouter = Galaxy =>
|
||||
routes: {
|
||||
"(/)(#)(_=_)": "home",
|
||||
"(/)root*": "home",
|
||||
"(/)tools/view": "show_tools_view",
|
||||
"(/)tools/json": "show_tools_json",
|
||||
"(/)tours(/)(:tour_id)": "show_tours",
|
||||
"(/)user(/)": "show_user",
|
||||
"(/)user(/)cloud_auth": "show_cloud_auth",
|
||||
@@ -242,6 +246,15 @@ export const getAnalysisRouter = Galaxy =>
|
||||
this._display_vue_helper(HistoryImport);
|
||||
},
|
||||
|
||||
show_tools_view: function() {
|
||||
this.page.toolPanel.getVueComponent().hide();
|
||||
this.page.panels.right.hide();
|
||||
this._display_vue_helper(ToolsView);
|
||||
},
|
||||
|
||||
show_tools_json: function() {
|
||||
this._display_vue_helper(ToolsJson);
|
||||
},
|
||||
show_histories_permissions: function() {
|
||||
this.page.display(
|
||||
new FormWrapper.View({
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"glob": "^7.1.4",
|
||||
"handsontable": "^2.0.0",
|
||||
"imask": "^4.1.5",
|
||||
"isotope-layout": "^3.0.6",
|
||||
"jquery": "2",
|
||||
"jquery-migrate": "~1.4",
|
||||
"jquery-mousewheel": "^3.1.13",
|
||||
@@ -50,9 +51,11 @@
|
||||
"toastr": "^2.1.4",
|
||||
"underscore": "^1.9.2",
|
||||
"vue": "^2.6.11",
|
||||
"vue-infinite-scroll": "^2.0.2",
|
||||
"vue-multiselect": "^2.1.0",
|
||||
"vue-router": "^3.1.5",
|
||||
"vue-rx": "^6.2.0",
|
||||
"vueisotope": "^3.1.2",
|
||||
"vuex": "^3.1.2",
|
||||
"vuex-cache": "^3.1.0"
|
||||
},
|
||||
|
||||
+67
-4
@@ -4748,6 +4748,11 @@ des.js@^1.0.0:
|
||||
inherits "^2.0.1"
|
||||
minimalistic-assert "^1.0.0"
|
||||
|
||||
desandro-matches-selector@^2.0.0:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/desandro-matches-selector/-/desandro-matches-selector-2.0.2.tgz#717beed4dc13e7d8f3762f707a6d58a6774218e1"
|
||||
integrity sha1-cXvu1NwT59jzdi9wem1YpndCGOE=
|
||||
|
||||
destroy@~1.0.4:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80"
|
||||
@@ -5397,6 +5402,11 @@ etag@~1.8.1:
|
||||
resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
|
||||
integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=
|
||||
|
||||
ev-emitter@^1.0.0:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/ev-emitter/-/ev-emitter-1.1.1.tgz#8f18b0ce5c76a5d18017f71c0a795c65b9138f2a"
|
||||
integrity sha512-ipiDYhdQSCZ4hSbX4rMW+XzNKMD1prg/sTvoVmSLkuQ1MVlwjJQQA+sW8tMYR3BLUr9KjodFV4pvzunvRhd33Q==
|
||||
|
||||
eventemitter3@^3.0.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.0.tgz#090b4d6cdbd645ed10bf750d4b5407942d7ba163"
|
||||
@@ -5949,6 +5959,13 @@ fined@^1.0.1:
|
||||
object.pick "^1.2.0"
|
||||
parse-filepath "^1.0.1"
|
||||
|
||||
fizzy-ui-utils@^2.0.0, fizzy-ui-utils@^2.0.4:
|
||||
version "2.0.7"
|
||||
resolved "https://registry.yarnpkg.com/fizzy-ui-utils/-/fizzy-ui-utils-2.0.7.tgz#7df45dcc4eb374a08b65d39bb9a4beedf7330505"
|
||||
integrity sha512-CZXDVXQ1If3/r8s0T+v+qVeMshhfcuq0rqIFgJnrtd+Bu8GmDmqMjntjUePypVtjHXKJ6V4sw9zeyox34n9aCg==
|
||||
dependencies:
|
||||
desandro-matches-selector "^2.0.0"
|
||||
|
||||
flagged-respawn@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/flagged-respawn/-/flagged-respawn-1.0.0.tgz#4e79ae9b2eb38bf86b3bb56bf3e0a56aa5fcabd7"
|
||||
@@ -6213,6 +6230,11 @@ get-own-enumerable-property-symbols@^3.0.0:
|
||||
resolved "https://registry.yarnpkg.com/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.0.tgz#b877b49a5c16aefac3655f2ed2ea5b684df8d203"
|
||||
integrity sha512-CIJYJC4GGF06TakLg8z4GQKvDsx9EMspVxOYih7LerEL/WosUnFIww45CGfxfeKHqlg3twgUrYRT1O3WQqjGCg==
|
||||
|
||||
get-size@^2.0.0, get-size@^2.0.2:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/get-size/-/get-size-2.0.3.tgz#54a1d0256b20ea7ac646516756202769941ad2ef"
|
||||
integrity sha512-lXNzT/h/dTjTxRbm9BXb+SGxxzkm97h/PCIKtlN/CBCxxmkkIVV21udumMS93MuVTDX583gqc94v3RjuHmI+2Q==
|
||||
|
||||
get-stdin@^4.0.1:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-4.0.1.tgz#b968c6b0a04384324902e8bf1a5df32579a450fe"
|
||||
@@ -7770,6 +7792,17 @@ isobject@^3.0.0, isobject@^3.0.1:
|
||||
resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df"
|
||||
integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8=
|
||||
|
||||
isotope-layout@^3.0.1, isotope-layout@^3.0.6:
|
||||
version "3.0.6"
|
||||
resolved "https://registry.yarnpkg.com/isotope-layout/-/isotope-layout-3.0.6.tgz#d5636ec74d68e7e88fa5166fe6396858db00f432"
|
||||
integrity sha512-z2ZKablhocXhoNyWwzJPFd7u7FWbYbVJA51Nvsqsod8jH2ExGc1SwDsSWKE54e3PhXzqf2yZPhFSq/c2MR1arw==
|
||||
dependencies:
|
||||
desandro-matches-selector "^2.0.0"
|
||||
fizzy-ui-utils "^2.0.4"
|
||||
get-size "^2.0.0"
|
||||
masonry-layout "^4.1.0"
|
||||
outlayer "^2.1.0"
|
||||
|
||||
isstream@~0.1.2:
|
||||
version "0.1.2"
|
||||
resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
|
||||
@@ -8388,7 +8421,7 @@ lodash.uniq@^4.5.0:
|
||||
resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
|
||||
integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=
|
||||
|
||||
lodash@^4.0.0, lodash@^4.0.1, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.4, lodash@^4.17.5, lodash@~4.17.10:
|
||||
lodash@^4.0.0, lodash@^4.0.1, lodash@^4.13.1, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.4, lodash@^4.17.5, lodash@~4.17.10:
|
||||
version "4.17.11"
|
||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d"
|
||||
integrity sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==
|
||||
@@ -8588,6 +8621,14 @@ markdown-to-jsx@^6.9.1:
|
||||
prop-types "^15.6.2"
|
||||
unquote "^1.1.0"
|
||||
|
||||
masonry-layout@^4.1.0:
|
||||
version "4.2.2"
|
||||
resolved "https://registry.yarnpkg.com/masonry-layout/-/masonry-layout-4.2.2.tgz#d57b44af13e601bfcdc423f1dd8348b5524de348"
|
||||
integrity sha512-iGtAlrpHNyxaR19CvKC3npnEcAwszXoyJiI8ARV2ePi7fmYhIud25MHK8Zx4P0LCC4d3TNO9+rFa1KoK1OEOaA==
|
||||
dependencies:
|
||||
get-size "^2.0.2"
|
||||
outlayer "^2.1.0"
|
||||
|
||||
matchdep@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/matchdep/-/matchdep-2.0.0.tgz#c6f34834a0d8dbc3b37c27ee8bbcb27c7775582e"
|
||||
@@ -9759,6 +9800,15 @@ osenv@0, osenv@^0.1.0, osenv@^0.1.4:
|
||||
os-homedir "^1.0.0"
|
||||
os-tmpdir "^1.0.0"
|
||||
|
||||
outlayer@^2.1.0:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/outlayer/-/outlayer-2.1.1.tgz#29863b6de10ea5dadfffcadfa0d728907387e9a2"
|
||||
integrity sha1-KYY7beEOpdrf/8rfoNcokHOH6aI=
|
||||
dependencies:
|
||||
ev-emitter "^1.0.0"
|
||||
fizzy-ui-utils "^2.0.0"
|
||||
get-size "^2.0.2"
|
||||
|
||||
p-defer@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/p-defer/-/p-defer-1.0.0.tgz#9f6eb182f6c9aa8cd743004a7d4f96b196b0fb0c"
|
||||
@@ -14116,6 +14166,11 @@ 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-infinite-scroll@^2.0.2:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/vue-infinite-scroll/-/vue-infinite-scroll-2.0.2.tgz#ca37a91fe92ee0ad3b74acf8682c00917144b711"
|
||||
integrity sha512-n+YghR059YmciANGJh9SsNWRi1YZEBVlODtmnb/12zI+4R72QZSWd+EuZ5mW6auEo/yaJXgxzwsuhvALVnm73A==
|
||||
|
||||
vue-loader@^15.8.3:
|
||||
version "15.8.3"
|
||||
resolved "https://registry.yarnpkg.com/vue-loader/-/vue-loader-15.8.3.tgz#857cb9e30eb5fc25e66db48dce7e4f768602a23c"
|
||||
@@ -14258,6 +14313,14 @@ vue@^2.6.9:
|
||||
resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.10.tgz#a72b1a42a4d82a721ea438d1b6bf55e66195c637"
|
||||
integrity sha512-ImThpeNU9HbdZL3utgMCq0oiMzAkt1mcgy3/E6zWC/G6AaQoeuFdsl9nDhTDU3X1R6FK7nsIUuRACVcjI+A2GQ==
|
||||
|
||||
vueisotope@^3.1.2:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.yarnpkg.com/vueisotope/-/vueisotope-3.1.2.tgz#df9d3e32d9770ff97a409d6baca68ec830f03ae7"
|
||||
integrity sha512-mPlHr+YT1kaXXPLzAEwLxgE8Q3gauqj7hfs1U0ww2oeO9cYCdI8sWbJL1iNRDZTSd6p7Lks+6el+FwWcnXLHvQ==
|
||||
dependencies:
|
||||
isotope-layout "^3.0.1"
|
||||
lodash "^4.13.1"
|
||||
|
||||
vuex-cache@^3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/vuex-cache/-/vuex-cache-3.1.0.tgz#aad576cfb39b325cc7a43ea9a42414e356374c4b"
|
||||
@@ -14378,9 +14441,9 @@ webpack-dev-server@^3.1.14:
|
||||
yargs "12.0.2"
|
||||
|
||||
webpack-dev-server@^3.10.1:
|
||||
version "3.10.1"
|
||||
resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-3.10.1.tgz#1ff3e5cccf8e0897aa3f5909c654e623f69b1c0e"
|
||||
integrity sha512-AGG4+XrrXn4rbZUueyNrQgO4KGnol+0wm3MPdqGLmmA+NofZl3blZQKxZ9BND6RDNuvAK9OMYClhjOSnxpWRoA==
|
||||
version "3.10.3"
|
||||
resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-3.10.3.tgz#f35945036813e57ef582c2420ef7b470e14d3af0"
|
||||
integrity sha512-e4nWev8YzEVNdOMcNzNeCN947sWJNd43E5XvsJzbAL08kGc2frm1tQ32hTJslRS+H65LCb/AaUCYU7fjHCpDeQ==
|
||||
dependencies:
|
||||
ansi-html "0.0.7"
|
||||
bonjour "^3.5.0"
|
||||
|
||||
@@ -1919,7 +1919,7 @@ class Tool(Dictifiable):
|
||||
os.remove(temp_file)
|
||||
return tarball_archive
|
||||
|
||||
def to_dict(self, trans, link_details=False, io_details=False):
|
||||
def to_dict(self, trans, link_details=False, io_details=False, tool_help=False):
|
||||
""" Returns dict of tool. """
|
||||
|
||||
# Basic information
|
||||
@@ -1969,6 +1969,13 @@ class Tool(Dictifiable):
|
||||
# FIXME: the Tool class should declare directly, instead of ad hoc inspection
|
||||
regular_form = tool_class == Tool or isinstance(self, (DatabaseOperationTool, InteractiveTool))
|
||||
tool_dict["form_style"] = "regular" if regular_form else "special"
|
||||
if tool_help:
|
||||
# create tool help
|
||||
help_txt = ''
|
||||
if self.help:
|
||||
help_txt = self.help.render(static_path=self.app.url_for('/static'), host_url=self.app.url_for('/', qualified=True))
|
||||
help_txt = unicodify(help_txt)
|
||||
tool_dict['help'] = help_txt
|
||||
|
||||
return tool_dict
|
||||
|
||||
|
||||
@@ -1050,24 +1050,29 @@ class AbstractToolBox(Dictifiable, ManagesIntegratedToolPanelMixin):
|
||||
if elt:
|
||||
yield elt
|
||||
|
||||
def get_tool_to_dict(self, trans, tool):
|
||||
def get_tool_to_dict(self, trans, tool, tool_help=False):
|
||||
"""Return tool's to_dict.
|
||||
Use cache if present, store to cache otherwise.
|
||||
Note: The cached tool's to_dict is specific to the calls from toolbox.
|
||||
"""
|
||||
to_dict = None
|
||||
if not trans.user_is_admin:
|
||||
to_dict = self._tool_to_dict_cache.get(tool.id, None)
|
||||
if not tool_help:
|
||||
to_dict = self._tool_to_dict_cache.get(tool.id, None)
|
||||
if not to_dict:
|
||||
to_dict = tool.to_dict(trans, link_details=True)
|
||||
self._tool_to_dict_cache[tool.id] = to_dict
|
||||
to_dict = tool.to_dict(trans, link_details=True, tool_help=tool_help)
|
||||
if not tool_help:
|
||||
self._tool_to_dict_cache[tool.id] = to_dict
|
||||
else:
|
||||
to_dict = self._tool_to_dict_cache_admin.get(tool.id, None)
|
||||
if not tool_help:
|
||||
to_dict = self._tool_to_dict_cache_admin.get(tool.id, None)
|
||||
if not to_dict:
|
||||
to_dict = tool.to_dict(trans, link_details=True)
|
||||
self._tool_to_dict_cache_admin[tool.id] = to_dict
|
||||
to_dict = tool.to_dict(trans, link_details=True, tool_help=tool_help)
|
||||
if not tool_help:
|
||||
self._tool_to_dict_cache_admin[tool.id] = to_dict
|
||||
return to_dict
|
||||
|
||||
def to_dict(self, trans, in_panel=True, **kwds):
|
||||
def to_dict(self, trans, in_panel=True, tool_help=False, **kwds):
|
||||
"""
|
||||
Create a dictionary representation of the toolbox.
|
||||
Uses primitive cache for toolbox-specific tool 'to_dict's.
|
||||
@@ -1078,9 +1083,9 @@ class AbstractToolBox(Dictifiable, ManagesIntegratedToolPanelMixin):
|
||||
for elt in panel_elts:
|
||||
# Only use cache for objects that are Tools.
|
||||
if hasattr(elt, "tool_type"):
|
||||
rval.append(self.get_tool_to_dict(trans, elt))
|
||||
rval.append(self.get_tool_to_dict(trans, elt, tool_help=tool_help))
|
||||
else:
|
||||
kwargs = dict(trans=trans, link_details=True, toolbox=self)
|
||||
kwargs = dict(trans=trans, link_details=True, tool_help=tool_help, toolbox=self)
|
||||
rval.append(elt.to_dict(**kwargs))
|
||||
else:
|
||||
filter_method = self._build_filter_method(trans)
|
||||
@@ -1088,7 +1093,7 @@ class AbstractToolBox(Dictifiable, ManagesIntegratedToolPanelMixin):
|
||||
tool = filter_method(tool, panel_item_types.TOOL)
|
||||
if not tool:
|
||||
continue
|
||||
rval.append(self.get_tool_to_dict(trans, tool))
|
||||
rval.append(self.get_tool_to_dict(trans, tool, tool_help=tool_help))
|
||||
return rval
|
||||
|
||||
def _lineage_in_panel(self, panel_dict, tool=None, tool_lineage=None):
|
||||
|
||||
@@ -69,18 +69,19 @@ class ToolSection(Dictifiable, HasPanelItems):
|
||||
copy.elems = self.elems.copy()
|
||||
return copy
|
||||
|
||||
def to_dict(self, trans, link_details=False, toolbox=None):
|
||||
def to_dict(self, trans, link_details=False, tool_help=False, toolbox=None):
|
||||
""" Return a dict that includes section's attributes. """
|
||||
|
||||
section_dict = super(ToolSection, self).to_dict()
|
||||
section_elts = []
|
||||
kwargs = dict(
|
||||
trans=trans,
|
||||
link_details=link_details
|
||||
link_details=link_details,
|
||||
tool_help=tool_help
|
||||
)
|
||||
for elt in self.elems.values():
|
||||
if hasattr(elt, "tool_type") and toolbox:
|
||||
section_elts.append(toolbox.get_tool_to_dict(trans, elt))
|
||||
section_elts.append(toolbox.get_tool_to_dict(trans, elt, tool_help=tool_help))
|
||||
else:
|
||||
section_elts.append(elt.to_dict(**kwargs))
|
||||
section_dict['elems'] = section_elts
|
||||
|
||||
@@ -57,6 +57,7 @@ class ToolsController(BaseAPIController, UsesVisualizationMixin):
|
||||
trackster = util.string_as_bool(kwds.get('trackster', 'False'))
|
||||
q = kwds.get('q', '')
|
||||
tool_id = kwds.get('tool_id', '')
|
||||
tool_help = util.string_as_bool(kwds.get('tool_help', 'False'))
|
||||
|
||||
# Find whether to search.
|
||||
if q:
|
||||
@@ -88,7 +89,7 @@ class ToolsController(BaseAPIController, UsesVisualizationMixin):
|
||||
|
||||
# Return everything.
|
||||
try:
|
||||
return self.app.toolbox.to_dict(trans, in_panel=in_panel, trackster=trackster)
|
||||
return self.app.toolbox.to_dict(trans, in_panel=in_panel, trackster=trackster, tool_help=tool_help)
|
||||
except Exception:
|
||||
raise exceptions.InternalServerError("Error: Could not convert toolbox to dictionary")
|
||||
|
||||
|
||||
@@ -123,6 +123,8 @@ def app_factory(global_conf, load_app_kwds={}, **kwargs):
|
||||
webapp.add_client_route('/admin/quotas', 'admin')
|
||||
webapp.add_client_route('/admin/form/{form_id}', 'admin')
|
||||
webapp.add_client_route('/admin/api_keys', 'admin')
|
||||
webapp.add_client_route('/tools/view')
|
||||
webapp.add_client_route('/tools/json')
|
||||
webapp.add_client_route('/tours')
|
||||
webapp.add_client_route('/tours/{tour_id}')
|
||||
webapp.add_client_route('/user')
|
||||
|
||||
Reference in New Issue
Block a user