mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-10-13 08:51:38 +08:00
Merge pull request #8512 from jmchilton/page_format
Markdown-based Galaxy Pages.
This commit is contained in:
@@ -94,6 +94,7 @@ export { mountMakoTags } from "components/Tags";
|
||||
export { mountJobMetrics } from "components/JobMetrics";
|
||||
export { mountJobParameters } from "components/JobParameters";
|
||||
export { mountWorkflowEditor } from "components/Workflow/Editor/mount";
|
||||
export { mountPageDisplay } from "components/PageDisplay";
|
||||
|
||||
// Used in common.mako
|
||||
export { default as store } from "storemodern";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import DataDialog from "./DataDialog.vue";
|
||||
import SelectionDialog from "components/SelectionDialog/SelectionDialog.vue";
|
||||
import { __RewireAPI__ as rewire } from "./DataDialog";
|
||||
import { Model } from "./model.js";
|
||||
import { UrlTracker } from "./utilities.js";
|
||||
import { Services } from "./services";
|
||||
import Vue from "vue";
|
||||
import { mount, createLocalVue } from "@vue/test-utils";
|
||||
|
||||
const mockOptions = {
|
||||
callback: () => {},
|
||||
@@ -80,9 +80,9 @@ describe("services/Services:isDataset", () => {
|
||||
expect(services.isDataset({ history_content_type: "dataset" })).to.equals(true);
|
||||
expect(services.isDataset({ history_content_type: "xyz" })).to.equals(false);
|
||||
expect(services.isDataset({ type: "file" })).to.equals(true);
|
||||
expect(services.getRecord({ hid: 1, history_content_type: "dataset" }).isDataset).to.equals(true);
|
||||
expect(services.getRecord({ hid: 2, history_content_type: "xyz" }).isDataset).to.equals(false);
|
||||
expect(services.getRecord({ type: "file" }).isDataset).to.equals(true);
|
||||
expect(services.getRecord({ hid: 1, history_content_type: "dataset" }).isLeaf).to.equals(true);
|
||||
expect(services.getRecord({ hid: 2, history_content_type: "xyz" }).isLeaf).to.equals(false);
|
||||
expect(services.getRecord({ type: "file" }).isLeaf).to.equals(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,18 +137,32 @@ describe("DataDialog.vue", () => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
rewire.__Rewire__("Services", mockServices);
|
||||
rewire.__Rewire__("getGalaxyInstance", () => {
|
||||
root: "root";
|
||||
});
|
||||
const localVue = createLocalVue();
|
||||
wrapper = mount(DataDialog, {
|
||||
propsData: mockOptions
|
||||
propsData: mockOptions,
|
||||
attachToDocument: true,
|
||||
localVue
|
||||
});
|
||||
});
|
||||
|
||||
it("loads correctly, shows datasets and folders", async () => {
|
||||
expect(wrapper.find(":first-child div").classes()).contain("data-dialog-modal");
|
||||
expect(wrapper.find(".fa-spinner").text()).to.equals("");
|
||||
expect(wrapper.contains(".fa-spinner")).to.equals(true);
|
||||
await Vue.nextTick();
|
||||
expect(wrapper.findAll(".fa-folder").length).to.equals(2);
|
||||
expect(wrapper.findAll(".fa-file-o").length).to.equals(2);
|
||||
it("loads correctly, embeds a SelectionDialog", async () => {
|
||||
expect(wrapper.find(SelectionDialog).is(SelectionDialog)).to.equals(true);
|
||||
// Cannot get nested slot templates to render into the wrapper
|
||||
/// Lots of open issues around this
|
||||
/// ... Maybe because named slots have many issues
|
||||
// - https://github.com/vuejs/vue-test-utils/issues?utf8=%E2%9C%93&q=is%3Aissue+is%3Aopen+slot
|
||||
// ... Maybe because it is a modal and no longer inside the template DOM element?
|
||||
// - https://github.com/vuejs/vue-test-utils/issues/1333
|
||||
|
||||
// expect(wrapper.find(".fa-spinner").text()).to.equals("");
|
||||
// expect(wrapper.contains(".fa-spinner")).to.equals(true);
|
||||
// await Vue.nextTick();
|
||||
// expect(wrapper.findAll(".fa-folder").length).to.equals(2);
|
||||
// expect(wrapper.findAll(".fa-file-o").length).to.equals(2);
|
||||
|
||||
// SelectionDialog DOM properties are tested now in SelectionDialog.test.js however.
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<template>
|
||||
<b-modal modal-class="data-dialog-modal" v-if="modalShow" visible ok-only ok-title="Close" :static="modalStatic">
|
||||
<template v-slot:modal-header>
|
||||
<selection-dialog
|
||||
:errorMessage="errorMessage"
|
||||
:optionsShow="optionsShow"
|
||||
:modalShow="modalShow"
|
||||
:hideModal="() => (modalShow = false)"
|
||||
>
|
||||
<template v-slot:search>
|
||||
<data-dialog-search v-model="filter" />
|
||||
</template>
|
||||
<b-alert v-if="errorMessage" variant="danger" show v-html="errorMessage" />
|
||||
<div v-else>
|
||||
<template v-slot:options>
|
||||
<data-dialog-table
|
||||
v-if="optionsShow"
|
||||
:items="items"
|
||||
@@ -13,35 +17,30 @@
|
||||
@clicked="clicked"
|
||||
@load="load"
|
||||
/>
|
||||
<div v-else><span class="fa fa-spinner fa-spin" /> <span>Please wait...</span></div>
|
||||
</div>
|
||||
<template v-if="!errorMessage" v-slot:modal-footer>
|
||||
<div class="w-100">
|
||||
<b-btn size="sm" class="float-left" v-if="undoShow" @click="load()">
|
||||
<div class="fa fa-caret-left mr-1" />
|
||||
Back
|
||||
</b-btn>
|
||||
<b-btn
|
||||
v-if="multiple"
|
||||
size="sm"
|
||||
class="float-right ml-1"
|
||||
variant="primary"
|
||||
@click="finalize"
|
||||
:disabled="!hasValue"
|
||||
>
|
||||
Ok
|
||||
</b-btn>
|
||||
<b-btn size="sm" class="float-right" @click="modalShow = false"> Cancel </b-btn>
|
||||
</div>
|
||||
</template>
|
||||
</b-modal>
|
||||
<template v-slot:buttons>
|
||||
<b-btn size="sm" class="float-left" v-if="undoShow" @click="load()">
|
||||
<div class="fa fa-caret-left mr-1" />
|
||||
Back
|
||||
</b-btn>
|
||||
<b-btn
|
||||
v-if="multiple"
|
||||
size="sm"
|
||||
class="float-right ml-1"
|
||||
variant="primary"
|
||||
@click="finalize"
|
||||
:disabled="!hasValue"
|
||||
>
|
||||
Ok
|
||||
</b-btn>
|
||||
</template>
|
||||
</selection-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from "vue";
|
||||
import BootstrapVue from "bootstrap-vue";
|
||||
import DataDialogSearch from "./DataDialogSearch.vue";
|
||||
import DataDialogTable from "./DataDialogTable.vue";
|
||||
import SelectionDialogMixin from "components/SelectionDialog/SelectionDialogMixin";
|
||||
import { UrlTracker } from "./utilities.js";
|
||||
import { Model } from "./model.js";
|
||||
import { Services } from "./services.js";
|
||||
@@ -49,15 +48,8 @@ import { Services } from "./services.js";
|
||||
Vue.use(BootstrapVue);
|
||||
|
||||
export default {
|
||||
components: {
|
||||
"data-dialog-search": DataDialogSearch,
|
||||
"data-dialog-table": DataDialogTable
|
||||
},
|
||||
mixins: [SelectionDialogMixin],
|
||||
props: {
|
||||
callback: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
@@ -95,8 +87,7 @@ export default {
|
||||
modalShow: true,
|
||||
optionsShow: false,
|
||||
undoShow: false,
|
||||
hasValue: false,
|
||||
oldnewItems: false
|
||||
hasValue: false
|
||||
};
|
||||
},
|
||||
created: function() {
|
||||
@@ -114,7 +105,7 @@ export default {
|
||||
formatRows() {
|
||||
for (const item of this.items) {
|
||||
let _rowVariant = "active";
|
||||
if (item.isDataset) {
|
||||
if (item.isLeaf) {
|
||||
_rowVariant = this.model.exists(item.id) ? "success" : "default";
|
||||
}
|
||||
Vue.set(item, "_rowVariant", _rowVariant);
|
||||
@@ -122,7 +113,7 @@ export default {
|
||||
},
|
||||
/** Collects selected datasets in value array **/
|
||||
clicked: function(record) {
|
||||
if (record.isDataset) {
|
||||
if (record.isLeaf) {
|
||||
this.model.add(record);
|
||||
this.hasValue = this.model.count() > 0;
|
||||
if (this.multiple) {
|
||||
@@ -166,10 +157,3 @@ export default {
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<style>
|
||||
.data-dialog-modal .modal-body {
|
||||
max-height: 50vh;
|
||||
height: 50vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -53,7 +53,7 @@ export class Services {
|
||||
getRecord(record) {
|
||||
record.details = record.extension || record.description;
|
||||
record.time = record.update_time || record.create_time;
|
||||
record.isDataset = this.isDataset(record);
|
||||
record.isLeaf = this.isDataset(record);
|
||||
if (record.time) {
|
||||
record.time = record.time.substring(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import MarkdownIt from "markdown-it";
|
||||
import JOB_STATES_MODEL from "mvc/history/job-states-model";
|
||||
import HDCAModel from "mvc/history/hdca-model";
|
||||
import HDCAListItemEdit from "mvc/history/hdca-li-edit";
|
||||
import HDCAListItem from "mvc/history/hdca-li";
|
||||
|
||||
const FUNCTION_CALL_LINE_TEMPLATE = /\s*(\w+)\s*\((\s*\w+\s*=\s*\w+\s*)\)\s*/m;
|
||||
|
||||
@@ -140,6 +141,10 @@ export default {
|
||||
props: {
|
||||
markdownConfig: {
|
||||
type: Object
|
||||
},
|
||||
readOnly: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -190,8 +195,8 @@ export default {
|
||||
jobStateSummariesCollection.historyId = hdca["history_id"];
|
||||
jobStateSummariesCollection.monitor();
|
||||
jobStateSummariesCollection.trackModel(hdcaModel);
|
||||
|
||||
return new HDCAListItemEdit.HDCAListItemEdit({
|
||||
const viewClass = this.readOnly ? HDCAListItem.HDCAListItemView : HDCAListItemEdit.HDCAListItemEdit;
|
||||
return new viewClass({
|
||||
model: hdcaModel,
|
||||
el: $(el),
|
||||
linkTarget: "galaxy_main",
|
||||
|
||||
@@ -1,9 +1,87 @@
|
||||
<template>
|
||||
<textarea class="markdown-textarea" id="workflow-report-editor" v-model="input" @input="update"> </textarea>
|
||||
<div style="display: flex; flex: 1; flex-direction: column">
|
||||
<ul class="galaxymark-toolbar" ref="menu" v-if="toolbar">
|
||||
<li>
|
||||
<a
|
||||
href="#"
|
||||
class="fa-2x fa fa-file"
|
||||
@click="selectDataset"
|
||||
v-b-tooltip.hover
|
||||
title="Insert Dataset"
|
||||
></a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#"
|
||||
class="fa-2x fa fa-folder"
|
||||
@click="selectDatasetCollection"
|
||||
v-b-tooltip.hover
|
||||
title="Insert Dataset Collection"
|
||||
></a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#"
|
||||
class="fa-2x fa fa-sitemap fa-rotate-270"
|
||||
@click="selectWorkflow"
|
||||
v-b-tooltip.hover
|
||||
title="Inseert Workflow Preview"
|
||||
></a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#"
|
||||
class="fa-2x fa fa-image"
|
||||
@click="selectDatasetForImage"
|
||||
v-b-tooltip.hover
|
||||
title="Insert Dataset as an Image"
|
||||
></a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#"
|
||||
class="fa-2x fa fa-question"
|
||||
@click="showMarkdownHelp"
|
||||
v-if="showMarkdownHelp != null"
|
||||
v-b-tooltip.hover
|
||||
title="Show Galaxy Markdown Help"
|
||||
></a>
|
||||
</li>
|
||||
</ul>
|
||||
<textarea class="markdown-textarea" id="workflow-report-editor" v-model="input" @input="update" ref="editor">
|
||||
</textarea>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import _ from "underscore";
|
||||
import Vue from "vue";
|
||||
import BootstrapVue from "bootstrap-vue";
|
||||
|
||||
Vue.use(BootstrapVue);
|
||||
|
||||
import { dialog, datasetCollectionDialog, workflowDialog } from "utils/data";
|
||||
|
||||
const FENCE = "```";
|
||||
|
||||
// https://stackoverflow.com/questions/11076975/insert-text-into-textarea-at-cursor-position-javascript
|
||||
function _insertAtCursor(myField, myValue) {
|
||||
//IE support
|
||||
if (document.selection) {
|
||||
myField.focus();
|
||||
var sel = document.selection.createRange();
|
||||
sel.text = myValue;
|
||||
}
|
||||
//MOZILLA and others
|
||||
else if (myField.selectionStart || myField.selectionStart == "0") {
|
||||
var startPos = myField.selectionStart;
|
||||
var endPos = myField.selectionEnd;
|
||||
myField.value =
|
||||
myField.value.substring(0, startPos) + myValue + myField.value.substring(endPos, myField.value.length);
|
||||
} else {
|
||||
myField.value += myValue;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
props: {
|
||||
@@ -11,6 +89,15 @@ export default {
|
||||
required: true,
|
||||
type: String
|
||||
},
|
||||
toolbar: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showMarkdownHelp: {
|
||||
// only used if toolbar is True - waiting on a general toolbar refactor
|
||||
type: Function,
|
||||
required: false
|
||||
},
|
||||
onupdate: {
|
||||
type: Function
|
||||
}
|
||||
@@ -22,16 +109,95 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
update: _.debounce(function(e) {
|
||||
this.input = e.target.value;
|
||||
if (this.onupdate) {
|
||||
this.onupdate(this.input);
|
||||
}
|
||||
}, 300)
|
||||
}, 300),
|
||||
insertMarkdown(markdown) {
|
||||
const editorTextarea = this.$refs["editor"];
|
||||
_insertAtCursor(editorTextarea, markdown);
|
||||
Vue.nextTick(() => {
|
||||
const event = new Event("input");
|
||||
editorTextarea.dispatchEvent(event);
|
||||
});
|
||||
},
|
||||
insertGalaxyMarkdownBlock(block) {
|
||||
this.insertMarkdown(`${FENCE}galaxy\n${block}\n${FENCE}\n`);
|
||||
},
|
||||
_selectDataset(galaxyCall) {
|
||||
dialog(
|
||||
response => {
|
||||
const datasetId = response.id;
|
||||
this.insertGalaxyMarkdownBlock(`${galaxyCall}(history_dataset_id=${datasetId})`);
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
format: null,
|
||||
library: false // TODO: support?
|
||||
}
|
||||
);
|
||||
},
|
||||
selectDataset() {
|
||||
this._selectDataset("history_dataset_display");
|
||||
},
|
||||
selectDatasetForImage() {
|
||||
this._selectDataset("history_dataset_as_image");
|
||||
},
|
||||
selectDatasetCollection() {
|
||||
datasetCollectionDialog(response => {
|
||||
this.insertGalaxyMarkdownBlock(
|
||||
`history_dataset_collection_display(history_dataset_collection_id=${response.id})`
|
||||
);
|
||||
}, {});
|
||||
},
|
||||
selectWorkflow() {
|
||||
workflowDialog(response => {
|
||||
this.insertGalaxyMarkdownBlock(`workflow_display(workflow_id=${response.id})`);
|
||||
}, {});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style lang="scss" scoped>
|
||||
.galaxymark-toolbar {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.galaxymark-toolbar li {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.galaxymark-toolbar li a {
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
text-align: center;
|
||||
transition: color 0.2s linear;
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.galaxymark-toolbar li a:hover {
|
||||
color: #000;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdown-text {
|
||||
font: 16px/1.7 Menlo, Consolas, Monaco, "Andale Mono", monospace;
|
||||
}
|
||||
|
||||
.markdown-textarea {
|
||||
border: none;
|
||||
border-right: 1px solid #ccc;
|
||||
@@ -39,9 +205,9 @@ export default {
|
||||
resize: none;
|
||||
outline: none;
|
||||
background-color: #f6f6f6;
|
||||
font-size: 14px;
|
||||
font-family: "Monaco", courier, monospace;
|
||||
@extend .markdown-text;
|
||||
padding: 20px;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
export const markdownGeneralHelpHtml = `
|
||||
<p>
|
||||
For an overview of standard Markdown visit the
|
||||
<a href="https://commonmark.org/help/tutorial/">commonmark.org tutorial</a>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The Galaxy extensions to Markdown are represented as code blocks, these blocks start
|
||||
with the line <tt>\`\`\`galaxy</tt> and end with the line <tt>\`\`\`</tt> and have a
|
||||
command with arguments that reference parts of the workflow in the middle.
|
||||
</p>`;
|
||||
|
||||
export const datasetCommandsHtml = `
|
||||
<dt><tt>history_dataset_display</tt></dt>
|
||||
<dd>Embed a dataset description in the resulting document.</dd>
|
||||
<dt><tt>history_dataset_collection_display</tt></dt>
|
||||
<dd>Embed a dataset collection description in the resulting document.</dd>
|
||||
<dt><tt>history_dataset_as_image</tt></dt>
|
||||
<dd>Embed a dataset as an image in the resulting document - the dataset should be an image datatype.</dd>
|
||||
<dt><tt>history_dataset_peek</tt></dt>
|
||||
<dd>Embed Galaxy's metadata attribute 'peek' into the resulting document - this is datatype dependent metadata but usually this is a few lines from the start of a file.</dd>
|
||||
<dt><tt>history_dataset_info</tt></dt>
|
||||
<dd>Embed Galaxy's metadata attribute 'info' into the resulting document - this is datatype dependent metadata but usually this is the program output that generated the dataset.</dd>
|
||||
</dl>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<markdown :markdown-config="markdownConfig"> </markdown>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getAppRoot } from "onload/loadConfig";
|
||||
import axios from "axios";
|
||||
import Markdown from "components/Markdown/Markdown.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Markdown
|
||||
},
|
||||
props: {
|
||||
pageId: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
markdownConfig: {}
|
||||
};
|
||||
},
|
||||
created: function() {
|
||||
const pageId = this.pageId;
|
||||
const url = getAppRoot() + `api/pages/${pageId}`;
|
||||
this.ajaxCall(url);
|
||||
},
|
||||
methods: {
|
||||
ajaxCall: function(url) {
|
||||
axios
|
||||
.get(url)
|
||||
.then(response => {
|
||||
this.markdownConfig = { ...response.data, markdown: response.data.content };
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(e);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as PageDisplay } from "./PageDisplay";
|
||||
|
||||
// function for mounting page display in non-Vue environments
|
||||
export { mountPageDisplay } from "./mount";
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Endpoint for mounting PageDisplay from non-Vue environment (annotated DOMs).
|
||||
*/
|
||||
import $ from "jquery";
|
||||
import Vue from "vue";
|
||||
import PageDisplay from "./PageDisplay.vue";
|
||||
|
||||
export const mountPageDisplay = (propsData = {}) => {
|
||||
$("#page-display-content").each((index, el) => {
|
||||
const pageId = $(el).attr("page_id");
|
||||
const component = Vue.extend(PageDisplay);
|
||||
propsData.pageId = pageId;
|
||||
return new component({ propsData: propsData }).$mount(el);
|
||||
});
|
||||
};
|
||||
@@ -3,15 +3,34 @@
|
||||
<div class="unified-panel-header" unselectable="on">
|
||||
<div class="unified-panel-header-inner">
|
||||
Page Editor: {{ title }}
|
||||
<a id="save-button" class="btn btn-secondary fa fa-save float-right" @click="saveContent"></a>
|
||||
<a
|
||||
id="save-button"
|
||||
class="btn btn-secondary fa fa-save float-right"
|
||||
href="javascript:void(0)"
|
||||
@click="saveContent"
|
||||
></a>
|
||||
<a
|
||||
id="view-button"
|
||||
class="btn btn-secondary fa fa-eye float-right"
|
||||
href="javascript:void(0)"
|
||||
@click="viewContent"
|
||||
></a>
|
||||
</div>
|
||||
</div>
|
||||
<page-editor-html :page-id="pageId" :content="content" ref="contentEditor" />
|
||||
<page-editor-html :page-id="pageId" :content="content" v-if="contentFormat == 'html'" ref="contentEditor" />
|
||||
<page-editor-markdown
|
||||
:page-id="pageId"
|
||||
:initial-content="content"
|
||||
v-if="contentFormat == 'markdown'"
|
||||
ref="contentEditor"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getAppRoot } from "onload/loadConfig";
|
||||
import PageEditorHtml from "./PageEditorHtml";
|
||||
import PageEditorMarkdown from "./PageEditorMarkdown";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
@@ -19,20 +38,40 @@ export default {
|
||||
required: true,
|
||||
type: String
|
||||
},
|
||||
publicUrl: {
|
||||
required: true,
|
||||
type: String
|
||||
},
|
||||
content: {
|
||||
type: String
|
||||
},
|
||||
contentFormat: {
|
||||
type: String,
|
||||
default: "html"
|
||||
},
|
||||
title: {
|
||||
type: String
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
saveContent: function() {
|
||||
this.$refs.contentEditor.saveContent();
|
||||
},
|
||||
viewContent: function() {
|
||||
// TODO: Future iteration, contentEditor should have an entry point that can decide
|
||||
// if it has changed content.
|
||||
const r = window.confirm(
|
||||
"This will leave the current page editor, if you have unsaved changes they will be lost. Do you wish to continue?"
|
||||
);
|
||||
if (r === true) {
|
||||
window.location = `${getAppRoot()}${this.publicUrl}`;
|
||||
}
|
||||
}
|
||||
},
|
||||
components: {
|
||||
PageEditorHtml
|
||||
PageEditorHtml,
|
||||
PageEditorMarkdown
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div class="unified-panel-body editor-body">
|
||||
<markdown-editor
|
||||
:initial-markdown="initialContent"
|
||||
:onupdate="onUpdate"
|
||||
ref="markdownEditor"
|
||||
:toolbar="true"
|
||||
:showMarkdownHelp="showMarkdownHelp"
|
||||
>
|
||||
</markdown-editor>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MarkdownEditor from "components/Markdown/MarkdownEditor";
|
||||
import { showMarkdownHelp } from "./markdownHelp";
|
||||
import { save } from "./util";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
MarkdownEditor
|
||||
},
|
||||
data: function() {
|
||||
return {
|
||||
content: "",
|
||||
showMarkdownHelp: showMarkdownHelp
|
||||
};
|
||||
},
|
||||
props: {
|
||||
pageId: {
|
||||
required: true,
|
||||
type: String
|
||||
},
|
||||
initialContent: {
|
||||
type: String
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onUpdate: function(content) {
|
||||
this.content = content;
|
||||
},
|
||||
saveContent: function() {
|
||||
save(this.pageId, this.content);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.editor-body {
|
||||
display: flex;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,87 @@
|
||||
import { datasetCommandsHtml, markdownGeneralHelpHtml } from "components/Markdown/help";
|
||||
|
||||
const markdownHelp = `
|
||||
<div>
|
||||
<h3>Overview</h3>
|
||||
|
||||
<p>
|
||||
This document Markdown document will be used to generate your Galaxy Page.
|
||||
This document should be Markdown with embedded command for extracting and
|
||||
displaying Galaxy objects and their metadata.
|
||||
</p>
|
||||
|
||||
${markdownGeneralHelpHtml}
|
||||
|
||||
<h3>History Contents Commands</h3>
|
||||
|
||||
<p>
|
||||
These commands reference a dataset. For instance, the following examples would display
|
||||
the dataset collection corresponding to a Galaxy dataset collection ID and display a
|
||||
single dataset as an image corresponding to a Galaxy dataset ID.
|
||||
</p>
|
||||
|
||||
<pre>
|
||||
\`\`\`galaxy
|
||||
history_dataset_collection_display(history_dataset_collection_id=33b43b4e7093c91f)
|
||||
\`\`\`
|
||||
</pre>
|
||||
|
||||
<pre>
|
||||
\`\`\`galaxy
|
||||
history_dataset_as_image(history_dataset_id=33b43b4e7093c91f)
|
||||
\`\`\`
|
||||
</pre>
|
||||
|
||||
|
||||
${datasetCommandsHtml}
|
||||
|
||||
<h3>Workflow Commands</h3>
|
||||
|
||||
<p>
|
||||
These commands reference a workflow (currently only one). The following example would
|
||||
display a representation of the workflow in the resulting Galaxy Page:
|
||||
</p>
|
||||
|
||||
<pre>
|
||||
\`\`\`galaxy
|
||||
workflow_display(workflow_id=33b43b4e7093c91f>)
|
||||
\`\`\`
|
||||
</pre>
|
||||
|
||||
<dl>
|
||||
<dt><tt>workflow_display</tt></dt>
|
||||
<dd>Embed a description of the workflow itself in the resulting document.</dd>
|
||||
</dl>
|
||||
|
||||
<h3>Job Commands</h3>
|
||||
|
||||
<p>
|
||||
These commands reference a Galaxy job.For instance, the
|
||||
following example would show the job parameters the job ID 33b43b4e7093c91f:
|
||||
</p>
|
||||
|
||||
<pre>
|
||||
\`\`\`galaxy
|
||||
job_parameters(job_id=33b43b4e7093c91f)
|
||||
\`\`\`
|
||||
</pre>
|
||||
|
||||
<dt><tt>tool_stderr</tt></dt>
|
||||
<dd>Embed the tool standard error stream for this job in the resulting document.</dd>
|
||||
<dt><tt>tool_stdout</tt></dt>
|
||||
<dd>Embed the tool standard output stream for this job in the resulting document.</dd>
|
||||
<dt><tt>job_metrics</tt></dt>
|
||||
<dd>Embed the job metrics for this job in the resulting document (if Galaxy is configured and you have permission).</dd>
|
||||
<dt><tt>job_parameters</tt></dt>
|
||||
<dd>Embed the tool parameters for this job in the resulting document.</dd>
|
||||
`;
|
||||
|
||||
import $ from "jquery";
|
||||
import { hide_modal, show_modal } from "layout/modal";
|
||||
|
||||
export function showMarkdownHelp() {
|
||||
const markdownHelpBody = $(markdownHelp);
|
||||
show_modal("Pages Markdown Help", markdownHelpBody, {
|
||||
Ok: hide_modal
|
||||
});
|
||||
}
|
||||
@@ -1,24 +1,15 @@
|
||||
import $ from "jquery";
|
||||
import axios from "axios";
|
||||
import _l from "utils/localization";
|
||||
import { getAppRoot } from "onload/loadConfig";
|
||||
import { show_modal, hide_modal } from "layout/modal";
|
||||
import { Toast } from "ui/toast";
|
||||
import { errorMessageAsString } from "utils/simple-error";
|
||||
|
||||
export const save = (pageId, content) => {
|
||||
console.log(`content is ${content}`);
|
||||
|
||||
show_modal(_l("Saving page"), _l("progress"));
|
||||
|
||||
// Do save.
|
||||
$.ajax({
|
||||
url: `${getAppRoot()}page/save`,
|
||||
type: "POST",
|
||||
data: {
|
||||
id: pageId,
|
||||
content: content,
|
||||
_: "true"
|
||||
},
|
||||
success: function() {
|
||||
hide_modal();
|
||||
}
|
||||
});
|
||||
axios
|
||||
.post(`${getAppRoot()}api/pages/${pageId}/revisions`, { content: content })
|
||||
.finally(hide_modal)
|
||||
.catch(error_message => Toast.error("Failed to save page: " + errorMessageAsString(error_message)));
|
||||
};
|
||||
|
||||
+5
-1
@@ -12,7 +12,7 @@
|
||||
@filtered="filtered"
|
||||
>
|
||||
<template v-slot:cell(label)="data">
|
||||
<i v-if="data.item.isDataset" class="fa fa-file-o" /> <i v-else class="fa fa-folder" />
|
||||
<i v-if="data.item.isLeaf" :class="leafIcon" /> <i v-else class="fa fa-folder" />
|
||||
{{ data.value ? data.value : "-" }}
|
||||
</template>
|
||||
<template v-slot:cell(details)="data">
|
||||
@@ -52,6 +52,10 @@ export default {
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
leafIcon: {
|
||||
type: String,
|
||||
default: "fa fa-file-o"
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -0,0 +1,68 @@
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
import axios from "axios";
|
||||
import DatasetCollectionDialog from "./DatasetCollectionDialog.vue";
|
||||
import { __RewireAPI__ as rewire } from "./DatasetCollectionDialog";
|
||||
import SelectionDialog from "./SelectionDialog.vue";
|
||||
|
||||
import { shallowMount, createLocalVue } from "@vue/test-utils";
|
||||
|
||||
const mockOptions = {
|
||||
callback: () => {},
|
||||
modalStatic: true,
|
||||
history: "f2db41e1fa331b3e"
|
||||
};
|
||||
|
||||
const galaxyInstance = {
|
||||
root: "/"
|
||||
};
|
||||
|
||||
describe("DatasetCollectionDialog.vue", () => {
|
||||
let wrapper;
|
||||
let localVue;
|
||||
let axiosMock;
|
||||
|
||||
beforeEach(() => {
|
||||
axiosMock = new MockAdapter(axios);
|
||||
rewire.__Rewire__("getGalaxyInstance", () => galaxyInstance);
|
||||
localVue = createLocalVue();
|
||||
wrapper = shallowMount(DatasetCollectionDialog, {
|
||||
propsData: mockOptions,
|
||||
localVue: localVue
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
axiosMock.restore();
|
||||
});
|
||||
|
||||
it("loads correctly in loading state, shows options when optionsShow becomes true", async () => {
|
||||
// Initially in loading state.
|
||||
const collectionsResponse = [{ id: "f2db41e1fa331b3e", name: "Awesome Collection" }];
|
||||
axiosMock
|
||||
.onGet(`/api/histories/${mockOptions.history}/contents?type=dataset_collection`)
|
||||
.reply(200, collectionsResponse);
|
||||
|
||||
expect(wrapper.find(SelectionDialog).is(SelectionDialog)).to.equals(true);
|
||||
expect(wrapper.vm.optionsShow).to.equals(false);
|
||||
|
||||
await localVue.nextTick();
|
||||
await localVue.nextTick();
|
||||
await localVue.nextTick();
|
||||
|
||||
// why not shown?
|
||||
expect(wrapper.vm.errorMessage).to.equals(null);
|
||||
expect(wrapper.vm.optionsShow).to.equals(true);
|
||||
});
|
||||
|
||||
it("error message set on dataset collection fetch problems", async () => {
|
||||
expect(wrapper.vm.errorMessage).to.equals(null);
|
||||
axiosMock
|
||||
.onGet(`/api/histories/${mockOptions.history}/contents?type=dataset_collection`)
|
||||
.reply(403, { err_msg: "Bad error" });
|
||||
await localVue.nextTick();
|
||||
await localVue.nextTick();
|
||||
await localVue.nextTick();
|
||||
await localVue.nextTick();
|
||||
expect(wrapper.vm.errorMessage).to.equals("Bad error");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<selection-dialog
|
||||
:errorMessage="errorMessage"
|
||||
:optionsShow="optionsShow"
|
||||
:modalShow="modalShow"
|
||||
:hideModal="() => (modalShow = false)"
|
||||
>
|
||||
<template v-slot:search>
|
||||
<data-dialog-search v-model="filter" />
|
||||
</template>
|
||||
<template v-slot:options>
|
||||
<data-dialog-table
|
||||
v-if="optionsShow"
|
||||
:items="items"
|
||||
:multiple="false"
|
||||
:filter="filter"
|
||||
leafIcon="fa fa-folder"
|
||||
@clicked="clicked"
|
||||
@load="load"
|
||||
/>
|
||||
</template>
|
||||
</selection-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from "axios";
|
||||
import SelectionDialogMixin from "./SelectionDialogMixin";
|
||||
import { getGalaxyInstance } from "app";
|
||||
import { errorMessageAsString } from "utils/simple-error";
|
||||
|
||||
export default {
|
||||
mixins: [SelectionDialogMixin],
|
||||
props: {
|
||||
history: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
errorMessage: null,
|
||||
filter: null,
|
||||
items: [],
|
||||
modalShow: true,
|
||||
optionsShow: false,
|
||||
hasValue: false
|
||||
};
|
||||
},
|
||||
created: function() {
|
||||
this.load();
|
||||
},
|
||||
methods: {
|
||||
formatRows() {},
|
||||
clicked: function(record) {
|
||||
this.modalShow = false;
|
||||
this.callback(record);
|
||||
},
|
||||
/** Performs server request to retrieve data records **/
|
||||
load: function() {
|
||||
this.filter = null;
|
||||
this.optionsShow = false;
|
||||
const Galaxy = getGalaxyInstance();
|
||||
const url = `${Galaxy.root}api/histories/${this.history}/contents?type=dataset_collection`;
|
||||
axios
|
||||
.get(url)
|
||||
.then(response => {
|
||||
this.items = response.data.map(item => {
|
||||
return {
|
||||
id: item.id,
|
||||
label: item.name,
|
||||
time: item.created_time,
|
||||
isLeaf: true
|
||||
};
|
||||
});
|
||||
this.formatRows();
|
||||
this.optionsShow = true;
|
||||
})
|
||||
.catch(errorMessage => {
|
||||
this.errorMessage = errorMessageAsString(errorMessage);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
import SelectionDialog from "./SelectionDialog.vue";
|
||||
import { mount, createLocalVue } from "@vue/test-utils";
|
||||
|
||||
const mockOptions = {
|
||||
callback: () => {},
|
||||
modalStatic: true
|
||||
};
|
||||
|
||||
describe("SelectionDialog.vue", () => {
|
||||
let wrapper;
|
||||
let localVue;
|
||||
let calledHide = false;
|
||||
|
||||
beforeEach(() => {
|
||||
localVue = createLocalVue();
|
||||
mockOptions.hideModal = () => {
|
||||
calledHide = true;
|
||||
};
|
||||
wrapper = mount(SelectionDialog, {
|
||||
slots: {
|
||||
options: "<tree-options />",
|
||||
search: "<cool-search />"
|
||||
},
|
||||
propsData: mockOptions,
|
||||
localVue
|
||||
});
|
||||
});
|
||||
|
||||
it("loads correctly in loading state, shows options when optionsShow becomes true", async () => {
|
||||
expect(wrapper.find(".fa-spinner").text()).to.equals("");
|
||||
expect(wrapper.contains(".fa-spinner")).to.equals(true);
|
||||
expect(wrapper.contains("tree-options")).to.equals(false);
|
||||
wrapper.setProps({ optionsShow: true });
|
||||
await localVue.nextTick();
|
||||
expect(wrapper.contains(".fa-spinner")).to.equals(false);
|
||||
expect(wrapper.contains("tree-options")).to.equals(true);
|
||||
});
|
||||
|
||||
it("loads search correctly", async () => {
|
||||
await localVue.nextTick();
|
||||
expect(wrapper.contains("cool-search")).to.equals(true);
|
||||
});
|
||||
|
||||
it("hideModal called on click cancel", async () => {
|
||||
expect(calledHide).to.equals(false);
|
||||
expect(wrapper.contains(".selection-dialog-modal-cancel")).to.equals(true);
|
||||
wrapper.setProps({ optionsShow: true });
|
||||
await localVue.nextTick();
|
||||
wrapper.find(".selection-dialog-modal-cancel").trigger("click");
|
||||
await localVue.nextTick();
|
||||
expect(calledHide).to.equals(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<b-modal
|
||||
modal-class="selection-dialog-modal"
|
||||
v-if="modalShow"
|
||||
visible
|
||||
ok-only
|
||||
ok-title="Close"
|
||||
:static="modalStatic"
|
||||
>
|
||||
<template v-slot:modal-header>
|
||||
<slot name="search"> </slot>
|
||||
</template>
|
||||
<b-alert v-if="errorMessage" variant="danger" show v-html="errorMessage" />
|
||||
<div v-else>
|
||||
<slot name="options" v-if="optionsShow"> </slot>
|
||||
<div v-else><span class="fa fa-spinner fa-spin" /> <span>Please wait...</span></div>
|
||||
</div>
|
||||
<template v-if="!errorMessage" v-slot:modal-footer>
|
||||
<div class="w-100">
|
||||
<slot name="buttons"> </slot>
|
||||
<b-btn size="sm" class="float-right selection-dialog-modal-cancel" @click="hideModal"> Cancel </b-btn>
|
||||
</div>
|
||||
</template>
|
||||
</b-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from "vue";
|
||||
import BootstrapVue from "bootstrap-vue";
|
||||
|
||||
Vue.use(BootstrapVue);
|
||||
|
||||
export default {
|
||||
props: {
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
modalStatic: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
errorMessage: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
optionsShow: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
modalShow: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
hideModal: {
|
||||
type: Function,
|
||||
required: true
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.selection-dialog-modal .modal-body {
|
||||
max-height: 50vh;
|
||||
height: 50vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
import DataDialogSearch from "./DataDialogSearch.vue";
|
||||
import DataDialogTable from "./DataDialogTable.vue";
|
||||
import SelectionDialog from "./SelectionDialog.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
"selection-dialog": SelectionDialog,
|
||||
"data-dialog-table": DataDialogTable,
|
||||
"data-dialog-search": DataDialogSearch
|
||||
},
|
||||
props: {
|
||||
callback: {
|
||||
type: Function,
|
||||
required: true
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
import axios from "axios";
|
||||
import WorkflowDialog from "./WorkflowDialog.vue";
|
||||
import { __RewireAPI__ as rewire } from "components/Workflow/services";
|
||||
import SelectionDialog from "./SelectionDialog.vue";
|
||||
|
||||
import { shallowMount, createLocalVue } from "@vue/test-utils";
|
||||
|
||||
const mockOptions = {
|
||||
callback: () => {},
|
||||
modalStatic: true
|
||||
};
|
||||
|
||||
const userModel = {
|
||||
get: () => "username"
|
||||
};
|
||||
|
||||
const galaxyInstance = {
|
||||
root: "/",
|
||||
user: userModel
|
||||
};
|
||||
|
||||
describe("WorkflowDialog.vue", () => {
|
||||
let wrapper;
|
||||
let localVue;
|
||||
let axiosMock;
|
||||
|
||||
beforeEach(() => {
|
||||
axiosMock = new MockAdapter(axios);
|
||||
rewire.__Rewire__("getGalaxyInstance", () => galaxyInstance);
|
||||
rewire.__Rewire__("_addAttributes", workflow => workflow);
|
||||
localVue = createLocalVue();
|
||||
wrapper = shallowMount(WorkflowDialog, {
|
||||
propsData: mockOptions,
|
||||
localVue: localVue
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
axiosMock.restore();
|
||||
});
|
||||
|
||||
it("loads correctly in loading state, shows options when optionsShow becomes true", async () => {
|
||||
// Initially in loading state.
|
||||
const workflowsResponse = [{ id: "f2db41e1fa331b3e", name: "Awesome Workflow" }];
|
||||
axiosMock.onGet("/api/workflows").reply(200, workflowsResponse);
|
||||
|
||||
expect(wrapper.find(SelectionDialog).is(SelectionDialog)).to.equals(true);
|
||||
expect(wrapper.vm.optionsShow).to.equals(false);
|
||||
|
||||
await localVue.nextTick();
|
||||
await localVue.nextTick();
|
||||
await localVue.nextTick();
|
||||
|
||||
// why not shown?
|
||||
expect(wrapper.vm.errorMessage).to.equals(null);
|
||||
expect(wrapper.vm.optionsShow).to.equals(true);
|
||||
});
|
||||
|
||||
it("error message set on workflow fetch problems", async () => {
|
||||
expect(wrapper.vm.errorMessage).to.equals(null);
|
||||
axiosMock.onGet("/api/workflows").reply(403, { err_msg: "Bad error" });
|
||||
await localVue.nextTick();
|
||||
await localVue.nextTick();
|
||||
await localVue.nextTick();
|
||||
await localVue.nextTick();
|
||||
expect(wrapper.vm.errorMessage).to.equals("Bad error");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<selection-dialog
|
||||
:errorMessage="errorMessage"
|
||||
:optionsShow="optionsShow"
|
||||
:modalShow="modalShow"
|
||||
:hideModal="() => (modalShow = false)"
|
||||
>
|
||||
<template v-slot:search>
|
||||
<data-dialog-search v-model="filter" />
|
||||
</template>
|
||||
<template v-slot:options>
|
||||
<data-dialog-table
|
||||
v-if="optionsShow"
|
||||
:items="items"
|
||||
:multiple="false"
|
||||
:filter="filter"
|
||||
leafIcon="fa fa-sitemap fa-rotate-270"
|
||||
@clicked="clicked"
|
||||
@load="load"
|
||||
/>
|
||||
</template>
|
||||
</selection-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import SelectionDialogMixin from "./SelectionDialogMixin";
|
||||
import { Services } from "components/Workflow/services";
|
||||
|
||||
export default {
|
||||
mixins: [SelectionDialogMixin],
|
||||
props: {},
|
||||
data() {
|
||||
return {
|
||||
errorMessage: null,
|
||||
filter: null,
|
||||
items: [],
|
||||
modalShow: true,
|
||||
optionsShow: false,
|
||||
hasValue: false
|
||||
};
|
||||
},
|
||||
created: function() {
|
||||
this.services = new Services();
|
||||
this.load();
|
||||
},
|
||||
methods: {
|
||||
formatRows() {},
|
||||
clicked: function(record) {
|
||||
this.modalShow = false;
|
||||
this.callback(record);
|
||||
},
|
||||
/** Performs server request to retrieve data records **/
|
||||
load: function() {
|
||||
this.filter = null;
|
||||
this.optionsShow = false;
|
||||
this.services
|
||||
.getWorkflows()
|
||||
.then(items => {
|
||||
this.items = items.map(item => {
|
||||
return {
|
||||
id: item.id,
|
||||
label: item.name,
|
||||
time: item.create_time,
|
||||
isLeaf: true
|
||||
};
|
||||
});
|
||||
this.formatRows();
|
||||
this.optionsShow = true;
|
||||
})
|
||||
.catch(errorMessage => {
|
||||
this.errorMessage = errorMessage.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -100,7 +100,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="unified-panel-body workflow-report-body" v-show="mode == 'report'">
|
||||
<markdown-editor ref="report-editor" initial-markdown="" :onupdate="onReportUpdate" />
|
||||
<markdown-editor ref="report-editor" initial-markdown="" :onupdate="onReportUpdate" :toolbar="false" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { datasetCommandsHtml, markdownGeneralHelpHtml } from "components/Markdown/help";
|
||||
|
||||
const reportHelp = `
|
||||
<div>
|
||||
<h3>Overview</h3>
|
||||
@@ -6,16 +8,10 @@ const reportHelp = `
|
||||
This document Markdown document will be used to generate a report
|
||||
for invocations of this workflow. This document should be Markdown
|
||||
with embedded command for extracting and displaying parts of the workflow,
|
||||
its invocation metadata, its inputs and outputs, etc.. For an overview
|
||||
of standard Markdown visit the <a href="https://commonmark.org/help/tutorial/">commonmark.org
|
||||
tutorial</a>.
|
||||
its invocation metadata, its inputs and outputs, etc..
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The Galaxy extensions to Markdown are represented as code blocks, these blocks start
|
||||
with the line <tt>\`\`\`galaxy</tt> and end with the line <tt>\`\`\`</tt> and have a
|
||||
command with arguments that reference parts of the workflow in the middle.
|
||||
</p>
|
||||
${markdownGeneralHelpHtml}
|
||||
|
||||
<h3>Workflow Commands</h3>
|
||||
|
||||
@@ -78,18 +74,7 @@ history_dataset_collection_display(output="Merged Bam")
|
||||
\`\`\`
|
||||
</pre>
|
||||
|
||||
<dt><tt>history_dataset_display</tt></dt>
|
||||
<dd>Embed a dataset description in the resulting report.</dd>
|
||||
<dt><tt>history_dataset_collection_display</tt></dt>
|
||||
<dd>Embed a dataset collection description in the resulting report.</dd>
|
||||
<dt><tt>history_dataset_as_image</tt></dt>
|
||||
<dd>Embed a dataset as an image in the resulting report - the dataset should be an image datatype.</dd>
|
||||
<dt><tt>history_dataset_peek</tt></dt>
|
||||
<dd>Embed Galaxy's metadata attribute 'peek' into the resulting report - this is datatype dependent metadata but usually this is a few lines from the start of a file.</dd>
|
||||
<dt><tt>history_dataset_info</tt></dt>
|
||||
<dd>Embed Galaxy's metadata attribute 'info' into the resulting report - this is datatype dependent metadata but usually this is the program output that generated the dataset.</dd>
|
||||
</dl>
|
||||
</div>
|
||||
${datasetCommandsHtml}
|
||||
`;
|
||||
|
||||
import $ from "jquery";
|
||||
|
||||
@@ -5,7 +5,7 @@ import { rethrowSimple } from "utils/simple-error";
|
||||
/** Workflow data request helper **/
|
||||
export class Services {
|
||||
constructor(options = {}) {
|
||||
this.root = options.root;
|
||||
this.root = options.root || getGalaxyInstance().root;
|
||||
}
|
||||
|
||||
async copyWorkflow(workflow) {
|
||||
|
||||
@@ -10,12 +10,15 @@ export default function pagesEditorOnload() {
|
||||
axios
|
||||
.get(`${getAppRoot()}api/pages/${pageId}`)
|
||||
.then(response => {
|
||||
const data = response.data;
|
||||
const pageEditorInstance = Vue.extend(PageEditor);
|
||||
new pageEditorInstance({
|
||||
propsData: {
|
||||
publicUrl: `u/${data.username}/p/${data.slug}`,
|
||||
pageId: pageId,
|
||||
content: response.data.content,
|
||||
title: response.data.title
|
||||
content: data.content,
|
||||
contentFormat: data.content_format,
|
||||
title: data.title
|
||||
},
|
||||
el: "#page-editor-content"
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import jQuery from "jquery";
|
||||
import _ from "underscore";
|
||||
import Backbone from "backbone";
|
||||
import { getGalaxyInstance } from "app";
|
||||
import BASE_MVC from "mvc/base-mvc";
|
||||
|
||||
//=============================================================================
|
||||
@@ -28,15 +27,11 @@ var ControlledFetchCollection = Backbone.Collection.extend({
|
||||
*/
|
||||
fetch: function(options) {
|
||||
options = this._buildFetchOptions(options);
|
||||
const Galaxy = getGalaxyInstance();
|
||||
Galaxy.debug("fetch options:", options);
|
||||
return Backbone.Collection.prototype.fetch.call(this, options);
|
||||
},
|
||||
|
||||
/** build ajax data/parameters from options */
|
||||
_buildFetchOptions: function(options) {
|
||||
const Galaxy = getGalaxyInstance();
|
||||
|
||||
// note: we normally want options passed in to override the defaults built here
|
||||
// so most of these fns will generate defaults
|
||||
options = _.clone(options) || {};
|
||||
@@ -52,15 +47,12 @@ var ControlledFetchCollection = Backbone.Collection.extend({
|
||||
// (i.e. this.on( 'sync', function( options ){ if( options.limit ){ ... } }))
|
||||
// however, when we send to xhr/jquery we copy them to data also so that they become API query params
|
||||
options.data = options.data || this._buildFetchData(options);
|
||||
Galaxy.debug("data:", options.data);
|
||||
|
||||
// options.data.filters --> options.data.q, options.data.qv
|
||||
var filters = this._buildFetchFilters(options);
|
||||
Galaxy.debug("filters:", filters);
|
||||
if (!_.isEmpty(filters)) {
|
||||
_.extend(options.data, this._fetchFiltersToAjaxData(filters));
|
||||
}
|
||||
Galaxy.debug("data:", options.data);
|
||||
return options;
|
||||
},
|
||||
|
||||
@@ -271,8 +263,6 @@ var InfinitelyScrollingCollection = ControlledFetchCollection.extend({
|
||||
|
||||
/** fetch the first 'page' of data */
|
||||
fetchFirst: function(options) {
|
||||
const Galaxy = getGalaxyInstance();
|
||||
Galaxy.debug("ControlledFetchCollection.fetchFirst:", options);
|
||||
options = options ? _.clone(options) : {};
|
||||
this.allFetched = false;
|
||||
this.lastFetched = 0;
|
||||
@@ -286,13 +276,9 @@ var InfinitelyScrollingCollection = ControlledFetchCollection.extend({
|
||||
|
||||
/** fetch the next page of data */
|
||||
fetchMore: function(options) {
|
||||
const Galaxy = getGalaxyInstance();
|
||||
|
||||
Galaxy.debug("ControlledFetchCollection.fetchMore:", options);
|
||||
options = _.clone(options || {});
|
||||
var collection = this;
|
||||
|
||||
Galaxy.debug("fetchMore, options.reset:", options.reset);
|
||||
if (!options.reset && collection.allFetched) {
|
||||
return jQuery.when();
|
||||
}
|
||||
@@ -305,7 +291,6 @@ var InfinitelyScrollingCollection = ControlledFetchCollection.extend({
|
||||
options.offset = collection.lastFetched;
|
||||
}
|
||||
var limit = (options.limit = options.limit || collection.limitPerFetch || null);
|
||||
Galaxy.debug("fetchMore, limit:", limit, "offset:", options.offset);
|
||||
|
||||
collection.trigger("fetching-more");
|
||||
return (
|
||||
@@ -318,7 +303,6 @@ var InfinitelyScrollingCollection = ControlledFetchCollection.extend({
|
||||
.done(function _postFetchMore(fetchedData) {
|
||||
var numFetched = _.isArray(fetchedData) ? fetchedData.length : 0;
|
||||
collection.lastFetched += numFetched;
|
||||
Galaxy.debug("fetchMore, lastFetched:", collection.lastFetched);
|
||||
// anything less than a full page means we got all there is to get
|
||||
if (!limit || numFetched < limit) {
|
||||
collection.allFetched = true;
|
||||
|
||||
@@ -2,6 +2,8 @@ import $ from "jquery";
|
||||
import axios from "axios";
|
||||
import Vue from "vue";
|
||||
import DataDialog from "components/DataDialog/DataDialog.vue";
|
||||
import WorkflowDialog from "components/SelectionDialog/WorkflowDialog.vue";
|
||||
import DatasetCollectionDialog from "components/SelectionDialog/DatasetCollectionDialog.vue";
|
||||
import { getGalaxyInstance } from "app";
|
||||
import { getAppRoot } from "onload/loadConfig";
|
||||
|
||||
@@ -41,15 +43,44 @@ export function dialog(callback, options = {}) {
|
||||
root: galaxy.root,
|
||||
host: host
|
||||
});
|
||||
const instance = Vue.extend(DataDialog);
|
||||
const vm = document.createElement("div");
|
||||
$("body").append(vm);
|
||||
new instance({
|
||||
propsData: options
|
||||
}).$mount(vm);
|
||||
_mountSelectionDialog(DataDialog, options);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a modal dialog for workflow selection
|
||||
* @param {function} callback - Result function called with selection
|
||||
*/
|
||||
export function workflowDialog(callback, options = {}) {
|
||||
Object.assign(options, {
|
||||
callback: callback
|
||||
});
|
||||
_mountSelectionDialog(WorkflowDialog, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a modal dialog for dataset collection selection
|
||||
* @param {function} callback - Result function called with selection
|
||||
*/
|
||||
export function datasetCollectionDialog(callback, options = {}) {
|
||||
getCurrentGalaxyHistory().then(history_id => {
|
||||
Object.assign(options, {
|
||||
callback: callback,
|
||||
history: history_id
|
||||
});
|
||||
_mountSelectionDialog(DatasetCollectionDialog, options);
|
||||
});
|
||||
}
|
||||
|
||||
function _mountSelectionDialog(clazz, options) {
|
||||
const instance = Vue.extend(clazz);
|
||||
const vm = document.createElement("div");
|
||||
$("body").append(vm);
|
||||
new instance({
|
||||
propsData: options
|
||||
}).$mount(vm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a history dataset by submitting an upload request
|
||||
* TODO: This should live somewhere else.
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
export function rethrowSimple(e) {
|
||||
export function errorMessageAsString(e) {
|
||||
let message = "Request failed.";
|
||||
if (e.response) {
|
||||
message = e.response.data.err_msg || `${e.response.statusText} (${e.response.status})`;
|
||||
} else if (typeof e == "string") {
|
||||
message = e;
|
||||
}
|
||||
throw message;
|
||||
return message;
|
||||
}
|
||||
|
||||
export function rethrowSimple(e) {
|
||||
throw errorMessageAsString(e);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ context specific processing.
|
||||
import logging
|
||||
import re
|
||||
|
||||
from galaxy.exceptions import MalformedContents, MalformedId
|
||||
from galaxy.managers.collections import DatasetCollectionManager
|
||||
from galaxy.managers.hdas import HDAManager
|
||||
from galaxy.managers.hdcas import HDCASerializer
|
||||
@@ -53,7 +54,8 @@ OUTPUT_LABEL_PATTERN = re.compile(r'output=\s*%s\s*' % ARG_VAL_CAPTURED_REGEX)
|
||||
INPUT_LABEL_PATTERN = re.compile(r'input=\s*%s\s*' % ARG_VAL_CAPTURED_REGEX)
|
||||
STEP_LABEL_PATTERN = re.compile(r'step=\s*%s\s*' % ARG_VAL_CAPTURED_REGEX)
|
||||
# STEP_OUTPUT_LABEL_PATTERN = re.compile(r'step_output=([\w_\-]+)/([\w_\-]+)')
|
||||
ID_PATTERN = re.compile(r'(workflow_id|history_dataset_id|history_dataset_collection_id|job_id)=([\d]+)')
|
||||
UNENCODED_ID_PATTERN = re.compile(r'(workflow_id|history_dataset_id|history_dataset_collection_id|job_id)=([\d]+)')
|
||||
ENCODED_ID_PATTERN = re.compile(r'(workflow_id|history_dataset_id|history_dataset_collection_id|job_id)=([a-z0-9]+)')
|
||||
GALAXY_FLAVORED_MARKDOWN_CONTAINER_LINE_PATTERN = re.compile(
|
||||
r"```\s*galaxy\s*"
|
||||
)
|
||||
@@ -66,6 +68,27 @@ VALID_CONTAINER_END_PATTERN = re.compile(r"^```\s*$")
|
||||
WHITE_SPACE_ONLY_PATTERN = re.compile(r"^[\s]+$")
|
||||
|
||||
|
||||
def ready_galaxy_markdown_for_import(trans, external_galaxy_markdown):
|
||||
"""Convert from encoded IDs to decoded numeric IDs for storing in the DB."""
|
||||
|
||||
validate_galaxy_markdown(external_galaxy_markdown, internal=False)
|
||||
|
||||
def _remap(container, line):
|
||||
id_match = re.search(ENCODED_ID_PATTERN, line)
|
||||
object_id = None
|
||||
if id_match:
|
||||
object_id = id_match.group(2)
|
||||
try:
|
||||
decoded_id = trans.security.decode_id(object_id)
|
||||
except Exception:
|
||||
raise MalformedId("Invalid encoded ID %s" % object_id)
|
||||
line = line.replace(id_match.group(), "%s=%d" % (id_match.group(1), decoded_id))
|
||||
return (line, False)
|
||||
|
||||
internal_markdown = _remap_galaxy_markdown_calls(_remap, external_galaxy_markdown)
|
||||
return internal_markdown
|
||||
|
||||
|
||||
def ready_galaxy_markdown_for_export(trans, internal_galaxy_markdown):
|
||||
"""Fill in details needed to render Galaxy flavored markdown.
|
||||
|
||||
@@ -76,10 +99,13 @@ def ready_galaxy_markdown_for_export(trans, internal_galaxy_markdown):
|
||||
"""
|
||||
hdas_manager = HDAManager(trans.app)
|
||||
workflows_manager = WorkflowsManager(trans.app)
|
||||
job_manager = JobManager(trans.app)
|
||||
collection_manager = DatasetCollectionManager(trans.app)
|
||||
|
||||
extra_rendering_data = {}
|
||||
|
||||
def _remap(container, line):
|
||||
id_match = re.search(ID_PATTERN, line)
|
||||
id_match = re.search(UNENCODED_ID_PATTERN, line)
|
||||
object_id = None
|
||||
encoded_id = None
|
||||
if id_match:
|
||||
@@ -112,13 +138,12 @@ def ready_galaxy_markdown_for_export(trans, internal_galaxy_markdown):
|
||||
elif container == "history_dataset_info":
|
||||
hda = hdas_manager.get_accessible(object_id, trans.user)
|
||||
info = hda.info
|
||||
extend_history_dataset_rendering_data("info", info, "*No Dataset Peek Available*")
|
||||
extend_history_dataset_rendering_data("info", info, "*No Dataset Info Available*")
|
||||
elif container == "workflow_display":
|
||||
# TODO: should be workflow id...
|
||||
stored_workflow = workflows_manager.get_stored_accessible_workflow(trans, encoded_id)
|
||||
ensure_rendering_data_for("workflows", encoded_id)["name"] = stored_workflow.name
|
||||
elif container == "history_dataset_collection_display":
|
||||
collection_manager = DatasetCollectionManager(trans.app)
|
||||
hdca = collection_manager.get_dataset_collection_instance(trans, "history", encoded_id)
|
||||
hdca_serializer = HDCASerializer(trans.app)
|
||||
hdca_view = hdca_serializer.serialize_to_view(
|
||||
@@ -128,11 +153,9 @@ def ready_galaxy_markdown_for_export(trans, internal_galaxy_markdown):
|
||||
extra_rendering_data["history_dataset_collections"] = {}
|
||||
ensure_rendering_data_for("history_dataset_collections", encoded_id).update(hdca_view)
|
||||
elif container == "tool_stdout":
|
||||
job_manager = JobManager(trans.app)
|
||||
job = job_manager.get_accessible_job(trans, object_id)
|
||||
ensure_rendering_data_for("jobs", encoded_id)["tool_stdout"] = job.tool_stdout or "*No Standard Output Available*"
|
||||
elif container == "tool_stderr":
|
||||
job_manager = JobManager(trans.app)
|
||||
job = job_manager.get_accessible_job(trans, object_id)
|
||||
ensure_rendering_data_for("jobs", encoded_id)["tool_stderr"] = job.tool_stderr or "*No Standard Error Available*"
|
||||
return (line, False)
|
||||
@@ -238,13 +261,11 @@ history_dataset_collection_display(input=%s)
|
||||
line = line.replace(target_match.group(), "%s_id=%s" % (ref_object_type, ref_object.id))
|
||||
return (line, False)
|
||||
|
||||
log.debug("workflow markdown is \n%s" % workflow_markdown)
|
||||
workflow_markdown = _remap_galaxy_markdown_calls(
|
||||
_section_remap,
|
||||
workflow_markdown,
|
||||
)
|
||||
galaxy_markdown = _remap_galaxy_markdown_calls(_remap, workflow_markdown)
|
||||
log.debug("galaxy markdown is \n%s" % galaxy_markdown)
|
||||
return galaxy_markdown
|
||||
|
||||
|
||||
@@ -258,7 +279,7 @@ def validate_galaxy_markdown(galaxy_markdown, internal=True):
|
||||
def invalid_line(template, **kwd):
|
||||
if "line" in kwd:
|
||||
kwd["line"] = line.rstrip("\r\n")
|
||||
raise Exception("Invalid line %d: %s" % (line_no + 1, template.format(**kwd)))
|
||||
raise MalformedContents("Invalid line %d: %s" % (line_no + 1, template.format(**kwd)))
|
||||
|
||||
expecting_container_close = expecting_container_close_for is not None
|
||||
if not fenced and expecting_container_close:
|
||||
@@ -267,13 +288,16 @@ def validate_galaxy_markdown(galaxy_markdown, internal=True):
|
||||
elif not fenced:
|
||||
continue
|
||||
elif fenced and expecting_container_close and BLOCK_FENCE_END.match(line):
|
||||
# reset
|
||||
expecting_container_close_for = None
|
||||
function_calls = 0
|
||||
elif open_fence and GALAXY_FLAVORED_MARKDOWN_CONTAINER_LINE_PATTERN.match(line):
|
||||
if expecting_container_close:
|
||||
if not VALID_CONTAINER_END_PATTERN.match(line):
|
||||
invalid_line("Invalid command close line [{line}] for [{expected_for}]", line=line, expected_for=expecting_container_close_for)
|
||||
# else closing container and we're done
|
||||
expecting_container_close_for = None
|
||||
function_calls = 0
|
||||
continue
|
||||
|
||||
expecting_container_close_for = line
|
||||
@@ -291,7 +315,7 @@ def validate_galaxy_markdown(galaxy_markdown, internal=True):
|
||||
continue
|
||||
|
||||
if expecting_container_close_for:
|
||||
raise Exception("Invalid line %d: %s" % (last_line_no, "close of block for [{expected_for}] expected".format(expected_for=expecting_container_close_for)))
|
||||
raise MalformedContents("Invalid line %d: %s" % (last_line_no, "close of block for [{expected_for}] expected".format(expected_for=expecting_container_close_for)))
|
||||
|
||||
|
||||
def _remap_galaxy_markdown_containers(func, markdown):
|
||||
@@ -360,5 +384,6 @@ def _split_markdown_lines(markdown):
|
||||
|
||||
__all__ = (
|
||||
'ready_galaxy_markdown_for_export',
|
||||
'ready_galaxy_markdown_for_import',
|
||||
'resolve_invocation_markdown',
|
||||
)
|
||||
|
||||
@@ -14,6 +14,10 @@ from six.moves.html_parser import HTMLParser
|
||||
from galaxy import exceptions, model
|
||||
from galaxy.managers import base, sharable
|
||||
from galaxy.managers.hdas import HDAManager
|
||||
from galaxy.managers.markdown_util import (
|
||||
ready_galaxy_markdown_for_export,
|
||||
ready_galaxy_markdown_for_import,
|
||||
)
|
||||
from galaxy.model.item_attrs import UsesAnnotations
|
||||
from galaxy.util import unicodify
|
||||
from galaxy.util.sanitize_html import sanitize_html
|
||||
@@ -87,7 +91,8 @@ class PageManager(sharable.SharableModelManager, UsesAnnotations):
|
||||
raise exceptions.DuplicatedSlugException("Page identifier must be unique")
|
||||
|
||||
content = payload.get("content", "")
|
||||
content = self.rewrite_content_for_import(trans, content)
|
||||
content_format = payload.get("content_format", "html")
|
||||
content = self.rewrite_content_for_import(trans, content, content_format)
|
||||
|
||||
# Create the new stored page
|
||||
page = trans.app.model.Page()
|
||||
@@ -105,6 +110,7 @@ class PageManager(sharable.SharableModelManager, UsesAnnotations):
|
||||
page_revision.page = page
|
||||
page.latest_revision = page_revision
|
||||
page_revision.content = content
|
||||
page_revision.content_format = content_format
|
||||
# Persist
|
||||
session = trans.sa_session
|
||||
session.add(page)
|
||||
@@ -114,46 +120,66 @@ class PageManager(sharable.SharableModelManager, UsesAnnotations):
|
||||
def save_new_revision(self, trans, page, payload):
|
||||
# Assumes security has already been checked by caller.
|
||||
content = payload.get("content", None)
|
||||
content_format = payload.get("content_format", None)
|
||||
if not content:
|
||||
raise exceptions.ObjectAttributeMissingException("content undefined or empty")
|
||||
if content_format not in [None, "html", "markdown"]:
|
||||
raise exceptions.RequestParameterInvalidException("content_format [%s], if specified, must be either html or markdown" % content_format)
|
||||
|
||||
if 'title' in payload:
|
||||
title = payload['title']
|
||||
else:
|
||||
title = page.title
|
||||
|
||||
content = self.rewrite_content_for_import(trans, content)
|
||||
if content_format is None:
|
||||
content_format = page.latest_revision.content_format
|
||||
content = self.rewrite_content_for_import(trans, content, content_format=content_format)
|
||||
|
||||
page_revision = trans.app.model.PageRevision()
|
||||
page_revision.title = title
|
||||
page_revision.page = page
|
||||
page.latest_revision = page_revision
|
||||
page_revision.content = content
|
||||
page_revision.content_format = content_format
|
||||
|
||||
# Persist
|
||||
session = trans.sa_session
|
||||
session.flush()
|
||||
return page_revision
|
||||
|
||||
def rewrite_content_for_import(self, trans, content):
|
||||
try:
|
||||
content = sanitize_html(content)
|
||||
processor = PageContentProcessor(trans, placeholderRenderForSave)
|
||||
processor.feed(content)
|
||||
# Output is string, so convert to unicode for saving.
|
||||
content = unicodify(processor.output(), 'utf-8')
|
||||
except exceptions.MessageException:
|
||||
raise
|
||||
except Exception:
|
||||
raise exceptions.RequestParameterInvalidException("problem with embedded HTML content [%s]" % content)
|
||||
def rewrite_content_for_import(self, trans, content, content_format):
|
||||
if content_format == "html":
|
||||
try:
|
||||
content = sanitize_html(content)
|
||||
processor = PageContentProcessor(trans, placeholderRenderForSave)
|
||||
processor.feed(content)
|
||||
# Output is string, so convert to unicode for saving.
|
||||
content = unicodify(processor.output(), 'utf-8')
|
||||
except exceptions.MessageException:
|
||||
raise
|
||||
except Exception:
|
||||
raise exceptions.RequestParameterInvalidException("problem with embedded HTML content [%s]" % content)
|
||||
elif content_format == "markdown":
|
||||
content = ready_galaxy_markdown_for_import(trans, content)
|
||||
else:
|
||||
raise exceptions.RequestParameterInvalidException("content_format [%s] must be either html or markdown" % content_format)
|
||||
|
||||
return content
|
||||
|
||||
def rewrite_content_for_export(self, trans, as_dict):
|
||||
content = as_dict["content"]
|
||||
processor = PageContentProcessor(trans, placeholderRenderForEdit)
|
||||
processor.feed(content)
|
||||
content = unicodify(processor.output(), 'utf-8')
|
||||
as_dict["content"] = content
|
||||
content_format = as_dict.get("content_format", "html")
|
||||
if content_format == "html":
|
||||
processor = PageContentProcessor(trans, placeholderRenderForEdit)
|
||||
processor.feed(content)
|
||||
content = unicodify(processor.output(), 'utf-8')
|
||||
as_dict["content"] = content
|
||||
elif content_format == "markdown":
|
||||
content, extra_attributes = ready_galaxy_markdown_for_export(trans, content)
|
||||
as_dict["content"] = content
|
||||
as_dict.update(extra_attributes)
|
||||
else:
|
||||
raise exceptions.RequestParameterInvalidException("content_format [%s] must be either html or markdown" % content_format)
|
||||
return as_dict
|
||||
|
||||
|
||||
|
||||
@@ -5767,7 +5767,8 @@ class CloudAuthz(RepresentById):
|
||||
|
||||
|
||||
class Page(Dictifiable, RepresentById):
|
||||
dict_element_visible_keys = ['id', 'title', 'latest_revision_id', 'slug', 'published', 'importable', 'deleted']
|
||||
# username needed for slug generation
|
||||
dict_element_visible_keys = ['id', 'title', 'latest_revision_id', 'slug', 'published', 'importable', 'deleted', 'username']
|
||||
|
||||
def __init__(self):
|
||||
self.id = None
|
||||
@@ -5787,14 +5788,20 @@ class Page(Dictifiable, RepresentById):
|
||||
rval['revision_ids'] = rev
|
||||
return rval
|
||||
|
||||
@property
|
||||
def username(self):
|
||||
return self.user.username
|
||||
|
||||
|
||||
class PageRevision(Dictifiable, RepresentById):
|
||||
dict_element_visible_keys = ['id', 'page_id', 'title', 'content']
|
||||
DEFAULT_CONTENT_FORMAT = 'html'
|
||||
dict_element_visible_keys = ['id', 'page_id', 'title', 'content', 'content_format']
|
||||
|
||||
def __init__(self):
|
||||
self.user = None
|
||||
self.title = None
|
||||
self.content = None
|
||||
self.content_format = PageRevision.DEFAULT_CONTENT_FORMAT
|
||||
|
||||
def to_dict(self, view='element'):
|
||||
rval = super(PageRevision, self).to_dict(view=view)
|
||||
|
||||
@@ -1246,7 +1246,8 @@ model.PageRevision.table = Table(
|
||||
Column("update_time", DateTime, default=now, onupdate=now),
|
||||
Column("page_id", Integer, ForeignKey("page.id"), index=True, nullable=False),
|
||||
Column("title", TEXT),
|
||||
Column("content", TEXT))
|
||||
Column("content", TEXT),
|
||||
Column("content_format", TrimmedString(32)))
|
||||
|
||||
model.PageUserShareAssociation.table = Table(
|
||||
"page_user_share_association", metadata,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
Adds page content format.
|
||||
"""
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from sqlalchemy import Column, MetaData
|
||||
|
||||
from galaxy.model.custom_types import TrimmedString
|
||||
from galaxy.model.migrate.versions.util import add_column, drop_column
|
||||
|
||||
now = datetime.datetime.utcnow
|
||||
log = logging.getLogger(__name__)
|
||||
metadata = MetaData()
|
||||
|
||||
|
||||
def upgrade(migrate_engine):
|
||||
metadata.bind = migrate_engine
|
||||
print(__doc__)
|
||||
metadata.reflect()
|
||||
|
||||
content_format_column = Column('content_format', TrimmedString(32), default='html', server_default="html", nullable=False)
|
||||
add_column(content_format_column, 'page_revision', metadata)
|
||||
|
||||
|
||||
def downgrade(migrate_engine):
|
||||
metadata.bind = migrate_engine
|
||||
metadata.reflect()
|
||||
|
||||
drop_column('content_format', 'page_revision', metadata)
|
||||
@@ -52,8 +52,7 @@ class PageRevisionsController(BaseAPIController, SharableItemSecurityMixin, Uses
|
||||
|
||||
:param page_id: Add revision to Page with ID=page_id
|
||||
:param payload: A dictionary containing::
|
||||
'title' = New title of the page
|
||||
'content' = New content of the page
|
||||
'content' = New content of new page revision
|
||||
|
||||
:rtype: dictionary
|
||||
:returns: Dictionary with 'success' or 'error' element to indicate the result of the request
|
||||
|
||||
@@ -72,10 +72,11 @@ class PagesController(BaseAPIController, SharableItemSecurityMixin, UsesAnnotati
|
||||
Create a page and return dictionary containing Page summary
|
||||
|
||||
:param payload: dictionary structure containing::
|
||||
'slug' = The title slug for the page URL, must be unique
|
||||
'title' = Title of the page
|
||||
'content' = HTML contents of the page
|
||||
'annotation' = Annotation that will be attached to the page
|
||||
'slug' = The title slug for the page URL, must be unique
|
||||
'title' = Title of the page
|
||||
'content' = contents of the first page revision (type dependent on content_format)
|
||||
'content_format' = 'html' or 'markdown'
|
||||
'annotation' = Annotation that will be attached to the page
|
||||
|
||||
:rtype: dict
|
||||
:returns: Dictionary return of the Page.to_dict call
|
||||
@@ -120,5 +121,6 @@ class PagesController(BaseAPIController, SharableItemSecurityMixin, UsesAnnotati
|
||||
page = get_object(trans, id, 'Page', check_ownership=False, check_accessible=True)
|
||||
rval = self.encode_all_ids(trans, page.to_dict(), True)
|
||||
rval['content'] = page.latest_revision.content
|
||||
rval['content_format'] = page.latest_revision.content_format
|
||||
self.manager.rewrite_content_for_export(trans, rval)
|
||||
return rval
|
||||
|
||||
@@ -333,6 +333,12 @@ class PageController(BaseUIController, SharableMixin,
|
||||
'name' : 'annotation',
|
||||
'label' : 'Annotation',
|
||||
'help' : 'A description of the page. The annotation is shown alongside published pages.'
|
||||
}, {
|
||||
'name' : 'content_format',
|
||||
'label' : 'Content Format',
|
||||
'type' : 'select',
|
||||
'options' : [('HTML', 'html'), ('Markdown', 'markdown')],
|
||||
'help' : 'Use the traditional rich HTML editor or the newer experimental Markdown editor to create the page content. The HTML editor has several known bugs, is unmaintained and pages created with it will be read-only in future releases of Galaxy.'
|
||||
}]
|
||||
}
|
||||
else:
|
||||
@@ -448,14 +454,6 @@ class PageController(BaseUIController, SharableMixin,
|
||||
email=email,
|
||||
use_panels=use_panels)
|
||||
|
||||
@web.expose
|
||||
@web.require_login()
|
||||
def save(self, trans, id, content):
|
||||
id = self.decode_id(id)
|
||||
page = trans.sa_session.query(model.Page).get(id)
|
||||
assert page.user == trans.user
|
||||
self.page_manager.save_new_revision(trans, page, {"content": content})
|
||||
|
||||
@web.expose
|
||||
@web.require_login()
|
||||
def display(self, trans, id):
|
||||
@@ -478,11 +476,17 @@ class PageController(BaseUIController, SharableMixin,
|
||||
# Security check raises error if user cannot access page.
|
||||
self.security_check(trans, page, False, True)
|
||||
|
||||
# Process page content.
|
||||
processor = PageContentProcessor(trans, self._get_embed_html)
|
||||
processor.feed(page.latest_revision.content)
|
||||
# Output is string, so convert to unicode for display.
|
||||
page_content = unicodify(processor.output(), 'utf-8')
|
||||
latest_revision = page.latest_revision
|
||||
if latest_revision.content_format == "html":
|
||||
# Process page content.
|
||||
processor = PageContentProcessor(trans, self._get_embed_html)
|
||||
processor.feed(page.latest_revision.content)
|
||||
# Output is string, so convert to unicode for display.
|
||||
page_content = unicodify(processor.output(), 'utf-8')
|
||||
template = "page/display.mako"
|
||||
else:
|
||||
page_content = trans.security.encode_id(page.id)
|
||||
template = "page/display_markdown.mako"
|
||||
|
||||
# Get rating data.
|
||||
user_item_rating = 0
|
||||
@@ -494,7 +498,7 @@ class PageController(BaseUIController, SharableMixin,
|
||||
user_item_rating = 0
|
||||
ave_item_rating, num_ratings = self.get_ave_item_rating_data(trans.sa_session, page)
|
||||
|
||||
return trans.fill_template_mako("page/display.mako", item=page,
|
||||
return trans.fill_template_mako(template, item=page,
|
||||
item_data=page_content,
|
||||
user_item_rating=user_item_rating,
|
||||
ave_item_rating=ave_item_rating,
|
||||
|
||||
@@ -22,10 +22,16 @@ class BasePageApiTestCase(ApiTestCase):
|
||||
return page_response.json()
|
||||
|
||||
def _test_page_payload(self, **kwds):
|
||||
content_format = kwds.get("content_format", "html")
|
||||
if content_format == "html":
|
||||
content = "<p>Page!</p>"
|
||||
else:
|
||||
content = "*Page*\n\n"
|
||||
request = dict(
|
||||
slug="mypage",
|
||||
title="MY PAGE",
|
||||
content="<p>Page!</p>",
|
||||
content=content,
|
||||
content_format=content_format,
|
||||
)
|
||||
request.update(**kwds)
|
||||
return request
|
||||
@@ -41,7 +47,7 @@ class PageApiTestCase(BasePageApiTestCase):
|
||||
create_response_json = self._create_valid_page_with_slug("indexpage")
|
||||
assert self._users_index_has_page_with_id(create_response_json["id"])
|
||||
|
||||
def test_index_doesnt_show_unavailable_pages(self):
|
||||
def test_index_does_not_show_unavailable_pages(self):
|
||||
create_response_json = self._create_valid_page_as("others_page_index@bx.psu.edu", "otherspageindex")
|
||||
assert not self._users_index_has_page_with_id(create_response_json["id"])
|
||||
|
||||
@@ -53,6 +59,13 @@ class PageApiTestCase(BasePageApiTestCase):
|
||||
self._assert_status_code_is(page_response_2, 400)
|
||||
self._assert_error_code_is(page_response_2, error_codes.USER_SLUG_DUPLICATE)
|
||||
|
||||
def test_cannot_create_page_with_invalid_content_format(self):
|
||||
page_request = self._test_page_payload(slug="mypageinvalidformat")
|
||||
page_request["content_format"] = "xml"
|
||||
page_response_1 = self._post("pages", page_request)
|
||||
self._assert_status_code_is(page_response_1, 400)
|
||||
self._assert_error_code_is(page_response_1, error_codes.USER_REQUEST_INVALID_PARAMETER)
|
||||
|
||||
def test_page_requires_name(self):
|
||||
page_request = self._test_page_payload(slug="requires-name")
|
||||
del page_request['title']
|
||||
@@ -89,16 +102,30 @@ class PageApiTestCase(BasePageApiTestCase):
|
||||
self._assert_status_code_is(page_response, 400)
|
||||
self._assert_error_code_is(page_response, error_codes.MALFORMED_ID)
|
||||
|
||||
def test_400_on_invalid_id_encoding_markdown(self):
|
||||
page_request = self._test_page_payload(slug="invalid-id-encding-markdown", content_format="markdown")
|
||||
page_request["content"] = '''```galaxy\nhistory_dataset_display(history_dataset_id=badencoding)\n```\n'''
|
||||
page_response = self._post("pages", page_request)
|
||||
self._assert_status_code_is(page_response, 400)
|
||||
self._assert_error_code_is(page_response, error_codes.MALFORMED_ID)
|
||||
|
||||
def test_400_on_invalid_embedded_content(self):
|
||||
dataset_populator = DatasetPopulator(self.galaxy_interactor)
|
||||
valid_id = dataset_populator.new_history()
|
||||
page_request = self._test_page_payload(slug="invalid-id-encding")
|
||||
page_request = self._test_page_payload(slug="invalid-embed-content")
|
||||
page_request["content"] = '''<p>Page!<div class="embedded-item" id="CoolObject-%s"></div></p>''' % valid_id
|
||||
page_response = self._post("pages", page_request)
|
||||
self._assert_status_code_is(page_response, 400)
|
||||
self._assert_error_code_is(page_response, error_codes.USER_REQUEST_INVALID_PARAMETER)
|
||||
assert "embedded HTML content" in page_response.text
|
||||
|
||||
def test_400_on_invalid_markdown_call(self):
|
||||
page_request = self._test_page_payload(slug="invalid-markdown-call", content_format="markdown")
|
||||
page_request["content"] = '''```galaxy\njob_metrics(job_id)\n```\n'''
|
||||
page_response = self._post("pages", page_request)
|
||||
self._assert_status_code_is(page_response, 400)
|
||||
self._assert_error_code_is(page_response, error_codes.MALFORMED_CONTENTS)
|
||||
|
||||
def test_show(self):
|
||||
response_json = self._create_valid_page_with_slug("pagetoshow")
|
||||
show_response = self._get("pages/%s" % response_json['id'])
|
||||
@@ -108,6 +135,7 @@ class PageApiTestCase(BasePageApiTestCase):
|
||||
self.assertEqual(show_json["slug"], "pagetoshow")
|
||||
self.assertEqual(show_json["title"], "MY PAGE")
|
||||
self.assertEqual(show_json["content"], "<p>Page!</p>")
|
||||
self.assertEqual(show_json["content_format"], "html")
|
||||
|
||||
def test_403_on_unowner_show(self):
|
||||
response_json = self._create_valid_page_as("others_page_show@bx.psu.edu", "otherspageshow")
|
||||
|
||||
@@ -71,8 +71,10 @@
|
||||
%endif
|
||||
## TODO: why is the default font size so small?
|
||||
<pre style="font-size: 135%">${ data_to_render | h }</pre>
|
||||
%elif data.get_size() == 0:
|
||||
<i>Dataset is empty.</i>
|
||||
%else:
|
||||
<p align='center'>Cannot show dataset content</p>
|
||||
<i>Problem displaying dataset content.</i>
|
||||
%endif
|
||||
</%def>
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<%inherit file="/display_base.mako"/>
|
||||
|
||||
<%def name="javascript_app()">
|
||||
|
||||
${parent.javascript_app()}
|
||||
|
||||
<script type="text/javascript">
|
||||
config.addInitialization(function() {
|
||||
console.log("page/display_markdown.mako, javascript_app", "Setup page display markdown");
|
||||
window.bundleEntries.mountPageDisplay();
|
||||
});
|
||||
</script>
|
||||
</%def>
|
||||
|
||||
<%def name="stylesheets()">
|
||||
${parent.stylesheets()}
|
||||
${h.css( "base" )}
|
||||
</%def>
|
||||
|
||||
<%def name="render_item_header( item )">
|
||||
## No header for pages.
|
||||
</%def>
|
||||
|
||||
<%def name="render_item_links( page )">
|
||||
</%def>
|
||||
|
||||
<%def name="render_item( page, page_data=None )">
|
||||
<div id="page-display-content" page_id="${page_data}">
|
||||
</div>
|
||||
</%def>
|
||||
@@ -18,23 +18,53 @@ class PageJsonEncodingIntegrationTestCase(integration_util.IntegrationTestCase):
|
||||
def setUp(self):
|
||||
super(PageJsonEncodingIntegrationTestCase, self).setUp()
|
||||
self.dataset_populator = DatasetPopulator(self.galaxy_interactor)
|
||||
self.history_id = self.dataset_populator.new_history()
|
||||
|
||||
def test_page_encoding(self):
|
||||
history_id = self.dataset_populator.new_history()
|
||||
request = dict(
|
||||
slug="mypage",
|
||||
title="MY PAGE",
|
||||
content='''<p>Page!<div class="embedded-item" id="History-%s"></div></p>''' % history_id,
|
||||
content='''<p>Page!<div class="embedded-item" id="History-%s"></div></p>''' % self.history_id,
|
||||
)
|
||||
page_response = self._post("pages", request)
|
||||
api_asserts.assert_status_code_is_ok(page_response)
|
||||
sa_session = self._app.model.context
|
||||
page_revision = sa_session.query(model.PageRevision).all()[0]
|
||||
page_revision = sa_session.query(model.PageRevision).filter_by(content_format="html").all()[0]
|
||||
assert '''id="History-1"''' in page_revision.content, page_revision.content
|
||||
assert '''id="History-%s"''' % history_id not in page_revision.content, page_revision.content
|
||||
assert '''id="History-%s"''' % self.history_id not in page_revision.content, page_revision.content
|
||||
|
||||
show_page_response = self._get("pages/%s" % page_response.json()["id"])
|
||||
api_asserts.assert_status_code_is_ok(show_page_response)
|
||||
content = show_page_response.json()["content"]
|
||||
assert '''id="History-1"''' not in content, content
|
||||
assert '''id="History-%s"''' % history_id in content, content
|
||||
assert '''id="History-%s"''' % self.history_id in content, content
|
||||
|
||||
def test_page_encoding_markdown(self):
|
||||
dataset = self.dataset_populator.new_dataset(self.history_id)
|
||||
dataset_id = dataset["id"]
|
||||
request = dict(
|
||||
slug="mypage-markdown",
|
||||
title="MY PAGE",
|
||||
content='''```galaxy
|
||||
history_dataset_display(history_dataset_id=%s)
|
||||
```''' % dataset["id"],
|
||||
content_format="markdown",
|
||||
)
|
||||
page_response = self._post("pages", request)
|
||||
api_asserts.assert_status_code_is_ok(page_response)
|
||||
sa_session = self._app.model.context
|
||||
page_revision = sa_session.query(model.PageRevision).filter_by(content_format="markdown").all()[0]
|
||||
assert '''```galaxy
|
||||
history_dataset_display(history_dataset_id=1)
|
||||
```''' in page_revision.content, page_revision.content
|
||||
assert '''::: history_dataset_display history_dataset_id=%s''' % dataset_id not in page_revision.content, page_revision.content
|
||||
|
||||
show_page_response = self._get("pages/%s" % page_response.json()["id"])
|
||||
api_asserts.assert_status_code_is_ok(show_page_response)
|
||||
content = show_page_response.json()["content"]
|
||||
assert '''```galaxy
|
||||
history_dataset_display(history_dataset_id=1)
|
||||
```''' not in content, content
|
||||
assert '''```galaxy
|
||||
history_dataset_display(history_dataset_id=%s)
|
||||
```''' % dataset_id in content, content
|
||||
|
||||
@@ -36,6 +36,20 @@ printf('hello')
|
||||
job_metrics(job_id=THISFAKEID)
|
||||
```
|
||||
""")
|
||||
# assert multiple valid container is fine.
|
||||
assert_markdown_valid("""
|
||||
```galaxy
|
||||
job_metrics(job_id=THISFAKEID)
|
||||
```
|
||||
|
||||
Markdown between directives.
|
||||
|
||||
```galaxy
|
||||
job_metrics(job_id=THISFAKEID)
|
||||
```
|
||||
|
||||
""")
|
||||
|
||||
# assert valid container is fine at end of document.
|
||||
assert_markdown_valid("""
|
||||
```galaxy
|
||||
|
||||
Reference in New Issue
Block a user