Merge remote-tracking branch 'upstream/dev' into webpack5

This commit is contained in:
Dannon Baker
2021-08-20 09:03:51 -04:00
264 changed files with 6909 additions and 3477 deletions
+1 -6
View File
@@ -5,8 +5,6 @@ concurrency:
cancel-in-progress: true
env:
GALAXY_TEST_DBURI: 'postgresql://postgres:postgres@localhost:5432/galaxy?client_encoding=utf8'
GALAXY_TEST_SELENIUM_REMOTE: '1'
GALAXY_TEST_SELENIUM_REMOTE_PORT: "4444"
GALAXY_SKIP_CLIENT_BUILD: '0'
GALAXY_TEST_SELENIUM_RETRIES: 1
YARN_INSTALL_OPTS: --frozen-lockfile
@@ -26,10 +24,6 @@ jobs:
POSTGRES_DB: postgres
ports:
- 5432:5432
selenium:
image: selenium/standalone-chrome:3.141.59
ports:
- 4444:4444
steps:
- name: Prune unused docker image, volumes and containers
run: docker system prune -a -f
@@ -48,6 +42,7 @@ jobs:
- uses: mvdbeek/gha-yarn-cache@master
with:
yarn-lock-file: 'galaxy root/client/yarn.lock'
- uses: nanasess/setup-chromedriver@master
- name: Run tests
run: './run_tests.sh -integration test/integration_selenium'
working-directory: 'galaxy root'
+4
View File
@@ -83,6 +83,10 @@ environment variable:
GALAXY_URL="http://localhost:8000" make client-dev-server
Sometimes you want to run your local UI against a remote Galaxy server. This is also possible, if you enable `CHANGE_ORIGIN` flag
CHANGE_ORIGIN=true GALAXY_URL="https://usegalaxy.org/" make client-dev-server
## Changing Styles/CSS
Galaxy uses Sass for its styling, which is a superset of CSS that compiles down
+5 -2
View File
@@ -29,7 +29,7 @@
"axios": "^0.21.1",
"backbone": "1.4.0",
"bootstrap": "4.5.0",
"bootstrap-vue": "^2.15.0",
"bootstrap-vue": "^2.21.2",
"citation-js": "^0.5.1",
"d3": "3",
"date-fns": "^2.23.0",
@@ -55,6 +55,7 @@
"markdown-it": "^11.0.0",
"markdown-it-regexp": "^0.4.0",
"moment": "2.29.1",
"object-hash": "^2.2.0",
"popper.js": "^1.16.1",
"pouchdb": "^7.2.2",
"pouchdb-adapter-memory": "^7.2.2",
@@ -62,6 +63,7 @@
"pouchdb-erase": "^1.0.2",
"pouchdb-find": "^7.2.2",
"pouchdb-upsert": "^2.2.0",
"pretty-bytes": "^5.6.0",
"proper-skip-list": "^4.0.2",
"pyre-to-regexp": "^0.0.5",
"regression": "^2.0.1",
@@ -88,7 +90,8 @@
"vueisotope": "^3.1.2",
"vuex": "^3.4.0",
"vuex-cache": "^3.2.0",
"vuex-persist": "^3.1.3"
"vuex-persist": "^3.1.3",
"vuex-persistedstate": "^4.0.0"
},
"scripts": {
"watch": "gulp && yarn run save-build-hash && yarn run webpack-watch",
+1
View File
@@ -32,6 +32,7 @@ export { default as LegacyGridView } from "legacy/grid/grid-view";
export { create_chart, create_histogram } from "reports/run_stats";
export { default as ToolshedGroups } from "toolshed/toolshed.groups";
export { default as IES } from "galaxy.interactive_environments";
export { openGlobalUploadModal } from "components/Upload";
export { Toast } from "ui/toast"; // TODO: remove when external consumers are updated/gone (IES right now)
@@ -66,7 +66,7 @@ export default {
const description = storageInfo.description;
this.storageInfo = storageInfo;
if (description) {
this.descriptionRendered = MarkdownIt().render(storageInfo.description);
this.descriptionRendered = MarkdownIt({ html: true }).render(storageInfo.description);
} else {
this.descriptionRendered = null;
}
@@ -0,0 +1,62 @@
import Vue from "vue";
import axios from "axios";
import { mount } from "@vue/test-utils";
import { getLocalVue } from "jest/helpers";
import DatasetAttributes from "./DatasetAttributes";
import MockProvider from "../providers/MockProvider";
import MockAdapter from "axios-mock-adapter";
import flushPromises from "flush-promises";
const localVue = getLocalVue();
function buildWrapper(conversion_disable = false) {
return mount(DatasetAttributes, {
propsData: {
datasetId: "dataset_id",
},
localVue,
stubs: {
DatasetAttributesProvider: MockProvider({
result: {
attribute_inputs: [{ name: "attribute_text", type: "text" }],
conversion_inputs: [{ name: "conversion_text", type: "text" }],
conversion_disable: conversion_disable,
datatype_inputs: [{ name: "datatype_text", type: "text" }],
permission_inputs: [{ name: "permission_text", type: "text" }],
},
}),
FontAwesomeIcon: false,
FormElement: false,
},
});
}
describe("DatasetAttributes", () => {
it("check rendering", async () => {
const axiosMock = new MockAdapter(axios);
axiosMock.onPut(`/dataset/set_edit`).reply(200, { message: "success", status: "success" });
const wrapper = buildWrapper();
await Vue.nextTick();
expect(wrapper.findAll("button").length).toBe(6);
expect(wrapper.findAll("[tour_id=attribute_text]").length).toBe(1);
expect(wrapper.findAll("[tour_id=conversion_text]").length).toBe(1);
expect(wrapper.findAll("[tour_id=datatype_text]").length).toBe(1);
expect(wrapper.findAll("[tour_id=permission_text]").length).toBe(1);
expect(wrapper.findAll(".tab-pane").length).toBe(4);
const $button = wrapper.find("#dataset-attributes-default-save");
await $button.trigger("click");
await flushPromises();
expect(wrapper.find("[role=alert]").text()).toBe("success");
});
it("check rendering without conversion option", async () => {
const wrapper = buildWrapper(true);
await Vue.nextTick();
expect(wrapper.findAll("button").length).toBe(5);
expect(wrapper.findAll("[tour_id=attribute_text]").length).toBe(1);
expect(wrapper.findAll("[tour_id=conversion_text]").length).toBe(0);
expect(wrapper.findAll("[tour_id=datatype_text]").length).toBe(1);
expect(wrapper.findAll("[tour_id=permission_text]").length).toBe(1);
expect(wrapper.findAll(".tab-pane").length).toBe(3);
});
});
@@ -0,0 +1,134 @@
<template>
<div>
<h4>Edit Dataset Attributes</h4>
<b-alert v-if="messageText" :variant="messageVariant" show>
{{ messageText | l }}
</b-alert>
<DatasetAttributesProvider :id="datasetId" :use-cache="false" v-slot="{ result, loading }" @error="onError">
<div v-if="!loading" class="mt-3">
<b-tabs>
<b-tab v-if="!result['attribute_disable']">
<template v-slot:title>
<font-awesome-icon icon="bars" class="mr-1" />{{ "Attributes" | l }}
</template>
<FormDisplay :inputs="result['attribute_inputs']" @onChange="onAttribute" />
<div class="mt-2">
<b-button
id="dataset-attributes-default-save"
@click="submit('attribute', 'attributes')"
variant="primary"
class="mr-1"
>
<font-awesome-icon icon="save" class="mr-1" />{{ "Save" | l }}
</b-button>
<b-button @click="submit('attribute', 'autodetect')">
<font-awesome-icon icon="redo" class="mr-1" />{{ "Auto-detect" | l }}
</b-button>
</div>
</b-tab>
<b-tab v-if="!result['conversion_disable']">
<template v-slot:title>
<font-awesome-icon icon="cog" class="mr-1" />{{ "Convert" | l }}
</template>
<FormDisplay :inputs="result['conversion_inputs']" @onChange="onConversion" />
<div class="mt-2">
<b-button @click="submit('conversion', 'conversion')" variant="primary">
<font-awesome-icon icon="exchange-alt" class="mr-1" />{{ "Create Dataset" | l }}
</b-button>
</div>
</b-tab>
<b-tab v-if="!result['datatype_disable']">
<template v-slot:title>
<font-awesome-icon icon="database" class="mr-1" />{{ "Datatypes" | l }}
</template>
<FormDisplay :inputs="result['datatype_inputs']" @onChange="onDatatype" />
<div class="mt-2">
<b-button @click="submit('datatype', 'datatype')" variant="primary" class="mr-1">
<font-awesome-icon icon="save" class="mr-1" />{{ "Save" | l }}
</b-button>
<b-button @click="submit('datatype', 'datatype_detect')">
<font-awesome-icon icon="redo" class="mr-1" />{{ "Auto-detect" | l }}
</b-button>
</div>
</b-tab>
<b-tab v-if="!result['permission_disable']">
<template v-slot:title>
<font-awesome-icon icon="user" class="mr-1" />{{ "Permissions" | l }}
</template>
<FormDisplay :inputs="result['permission_inputs']" @onChange="onPermission" />
<div class="mt-2">
<b-button @click="submit('permission', 'permission')" variant="primary">
<font-awesome-icon icon="save" class="mr-1" />{{ "Save" | l }}
</b-button>
</div>
</b-tab>
</b-tabs>
</div>
</DatasetAttributesProvider>
</div>
</template>
<script>
import { getGalaxyInstance } from "app";
import FormDisplay from "components/Form/FormDisplay";
import { DatasetAttributesProvider } from "components/providers/DatasetProvider";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { library } from "@fortawesome/fontawesome-svg-core";
import { faBars, faCog, faDatabase, faExchangeAlt, faRedo, faSave, faUser } from "@fortawesome/free-solid-svg-icons";
import { setAttributes } from "./services";
library.add(faBars, faCog, faDatabase, faExchangeAlt, faRedo, faSave, faUser);
export default {
components: {
DatasetAttributesProvider,
FontAwesomeIcon,
FormDisplay,
},
props: {
datasetId: {
type: String,
required: true,
},
},
data() {
return {
messageText: null,
messageVariant: null,
formData: {},
};
},
methods: {
onAttribute(data) {
this.formData["attribute"] = data;
},
onConversion(data) {
this.formData["conversion"] = data;
},
onDatatype(data) {
this.formData["datatype"] = data;
},
onPermission(data) {
this.formData["permission"] = data;
},
onError(messageText) {
this.messageText = messageText;
this.messageVariant = "danger";
},
submit(key, operation) {
setAttributes(this.datasetId, this.formData[key], operation).then((response) => {
this.messageText = response.message;
this.messageVariant = response.status;
this._reloadHistory();
}, this.onError);
},
/** reload Galaxy's history after updating dataset's attributes */
_reloadHistory: function () {
const Galaxy = getGalaxyInstance();
if (Galaxy) {
Galaxy.currHistoryPanel.loadCurrentHistory();
}
},
},
};
</script>
@@ -0,0 +1,64 @@
import { mount } from "@vue/test-utils";
import { getLocalVue } from "jest/helpers";
import DatasetError from "./DatasetError";
import MockProvider from "../providers/MockProvider";
jest.mock("components/WorkflowInvocationState/providers", () => {
return {}; // stubbed below
});
const localVue = getLocalVue();
function buildWrapper(has_duplicate_inputs = true, has_empty_inputs = true, user_email = "") {
return mount(DatasetError, {
propsData: {
datasetId: "dataset_id",
},
localVue,
stubs: {
JobDetailsProvider: MockProvider({
result: {
tool_id: "tool_id",
tool_stderr: "tool_stderr",
job_stderr: "job_stderr",
job_messages: [{ desc: "message_1" }, { desc: "message_2" }],
user_email: user_email,
},
}),
JobProblemProvider: MockProvider({
result: { has_duplicate_inputs: has_duplicate_inputs, has_empty_inputs: has_empty_inputs },
}),
DatasetProvider: MockProvider({
resultLabel: "item",
result: { id: "dataset_id", creating_job: "creating_job" },
}),
FontAwesomeIcon: false,
FormElement: false,
},
});
}
describe("DatasetError", () => {
it("check props with common problems", async () => {
const wrapper = buildWrapper();
expect(wrapper.find("#dataset-error-tool-id").text()).toBe("tool_id");
expect(wrapper.find("#dataset-error-tool-stderr").text()).toBe("tool_stderr");
expect(wrapper.find("#dataset-error-job-stderr").text()).toBe("job_stderr");
const messages = wrapper.findAll("#dataset-error-job-messages .code");
expect(messages.at(0).text()).toBe("message_1");
expect(messages.at(1).text()).toBe("message_2");
expect(wrapper.find("#dataset-error-has-empty-inputs")).toBeDefined();
expect(wrapper.find("#dataset-error-has-duplicate-inputs")).toBeDefined();
expect(wrapper.findAll("#dataset-error-email").length).toBe(1);
});
it("check props without common problems", async () => {
const wrapper = buildWrapper(false, false, "user_email");
expect(wrapper.find("#dataset-error-tool-id").text()).toBe("tool_id");
expect(wrapper.find("#dataset-error-tool-stderr").text()).toBe("tool_stderr");
expect(wrapper.find("#dataset-error-job-stderr").text()).toBe("job_stderr");
expect(wrapper.findAll("#dataset-error-has-empty-inputs").length).toBe(0);
expect(wrapper.findAll("#dataset-error-has-duplicate-inputs").length).toBe(0);
expect(wrapper.findAll("#dataset-error-email").length).toBe(0);
});
});
@@ -0,0 +1,150 @@
<template>
<div>
<b-alert v-if="errorMessage" variant="danger" show>
<h4 class="alert-heading">Failed to access Dataset details.</h4>
{{ errorMessage }}
</b-alert>
<DatasetProvider :id="datasetId" v-slot="{ item: dataset, loading: datasetLoading }">
<JobDetailsProvider
v-if="!datasetLoading"
:jobid="dataset.creating_job"
v-slot="{ result: jobDetails, loading }"
@error="onError"
>
<div v-if="!loading">
<div class="page-container edit-attr">
<div class="response-message"></div>
</div>
<h2>Dataset Error Report</h2>
<p>
An error occurred while running the tool
<b id="dataset-error-tool-id" class="text-break">{{ jobDetails.tool_id }}</b
>.
</p>
<div v-if="jobDetails.tool_stderr || jobDetails.job_stderr || jobDetails.job_messages">
<h3>Details</h3>
</div>
<div v-if="jobDetails.job_messages" id="dataset-error-job-messages">
<p>Execution resulted in the following messages:</p>
<div v-for="(job_message, index) in jobDetails.job_messages" :key="index">
<pre class="rounded code">{{ job_message["desc"] }}</pre>
</div>
</div>
<div v-if="jobDetails.tool_stderr">
<p>Tool generated the following standard error:</p>
<pre id="dataset-error-tool-stderr" class="rounded code">{{ jobDetails.tool_stderr }}</pre>
</div>
<div v-if="jobDetails.job_stderr">
<p>Galaxy job runner generated the following standard error:</p>
<pre id="dataset-error-job-stderr" class="rounded code">{{ jobDetails.job_stderr }}</pre>
</div>
<JobProblemProvider :jobid="dataset.creating_job" v-slot="{ result: jobProblems }" @error="onError">
<div v-if="jobProblems && (jobProblems.has_duplicate_inputs || jobProblems.has_empty_inputs)">
<h3 class="common_problems mt-3">Detected Common Potential Problems</h3>
<p v-if="jobProblems.has_empty_inputs" id="dataset-error-has-empty-inputs">
The tool was executed with one or more empty input datasets. This frequently results in
tool errors due to problematic input choices.
</p>
<p v-if="jobProblems.has_duplicate_inputs" id="dataset-error-has-duplicate-inputs">
The tool was executed with one or more duplicate input datasets. This frequently results
in tool errors due to problematic input choices.
</p>
</div>
</JobProblemProvider>
<h3 class="mt-3">Troubleshooting</h3>
<p>
There are a number of helpful resources to self diagnose and correct problems.
<br />
Start here:
<b>
<a href="https://galaxyproject.org/support/tool-error/" target="_blank">
My job ended with an error. What can I do?
</a>
</b>
</p>
<h3 class="mb-3">Issue Report</h3>
<b-alert
v-for="(resultMessage, index) in resultMessages"
:key="index"
:variant="resultMessage[1]"
show
>{{ resultMessage[0] }}</b-alert
>
<FormElement
v-if="!jobDetails.user_email"
id="dataset-error-email"
v-model="email"
title="Please provide your email:"
/>
<FormElement
id="dataset-error-message"
v-model="message"
:area="true"
title="Please provide detailed information on the activities leading to this issue:"
/>
<b-button
id="dataset-error-submit"
variant="primary"
@click="submit(dataset, jobDetails.user_email)"
class="mt-3"
>
<font-awesome-icon icon="bug" class="mr-1" />Report
</b-button>
</div>
</JobDetailsProvider>
</DatasetProvider>
</div>
</template>
<script>
import FormElement from "components/Form/FormElement";
import { DatasetProvider } from "components/WorkflowInvocationState/providers";
import { JobDetailsProvider, JobProblemProvider } from "components/providers/JobProvider";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { library } from "@fortawesome/fontawesome-svg-core";
import { faBug } from "@fortawesome/free-solid-svg-icons";
import { sendErrorReport } from "./services";
library.add(faBug);
export default {
components: {
DatasetProvider,
FontAwesomeIcon,
FormElement,
JobDetailsProvider,
JobProblemProvider,
},
props: {
datasetId: {
type: String,
required: true,
},
},
data() {
return {
message: null,
email: null,
errorMessage: null,
resultMessages: [],
};
},
methods: {
onError(err) {
this.errorMessage = err;
},
submit(dataset, userEmail) {
const email = this.email || userEmail;
const message = this.message;
sendErrorReport(dataset, message, email).then(
(resultMessages) => {
this.resultMessages = resultMessages;
},
(errorMessage) => {
this.errorMessage = errorMessage;
}
);
},
},
};
</script>
@@ -0,0 +1,33 @@
import axios from "axios";
import { rethrowSimple } from "utils/simple-error";
import { getAppRoot } from "onload/loadConfig";
export async function sendErrorReport(dataset, message, email) {
const payload = {
dataset_id: dataset.id,
message,
email,
};
const url = `${getAppRoot()}api/jobs/${dataset.creating_job}/error`;
try {
const { data } = await axios.post(url, payload);
return data.messages;
} catch (e) {
rethrowSimple(e);
}
}
export async function setAttributes(datasetId, settings, operation) {
const payload = {
dataset_id: datasetId,
operation: operation,
...settings,
};
const url = `${getAppRoot()}dataset/set_edit`;
try {
const { data } = await axios.put(url, payload);
return data;
} catch (e) {
rethrowSimple(e);
}
}
@@ -1,6 +1,6 @@
<template>
<textarea v-if="area" class="ui-input" v-model="currentValue" />
<input v-else class="ui-input" v-model="currentValue" />
<textarea v-if="area" :id="id" class="ui-textarea" v-model="currentValue" />
<input v-else :id="id" class="ui-input" v-model="currentValue" />
</template>
<script>
export default {
+50 -4
View File
@@ -3,21 +3,44 @@
<div class="portlet-header">
<div class="portlet-operations">
<slot name="operations" />
<b-button
v-if="collapsible"
role="button"
title="Collapse/Expand"
variant="link"
size="sm"
class="float-right"
v-b-tooltip.hover.bottom
@click="onCollapse"
>
<font-awesome-icon v-if="collapsed" icon="eye-slash" class="fa-fw" />
<font-awesome-icon v-else icon="eye" class="fa-fw" />
</b-button>
</div>
<div class="portlet-title">
<a class="portlet-title" @click="onCollapse" :href="href">
<i :class="['portlet-title-icon fa mr-1', icon]" style="display: inline"></i>
<span class="portlet-title-text">
<b itemprop="name">{{ title }}</b> <span itemprop="description">{{ description }}</span>
</span>
</div>
</a>
</div>
<div class="portlet-content">
<div v-show="!collapsed" class="portlet-content">
<slot name="body" />
</div>
</div>
</template>
<script>
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { library } from "@fortawesome/fontawesome-svg-core";
import { faEye, faEyeSlash } from "@fortawesome/free-solid-svg-icons";
library.add(faEye);
library.add(faEyeSlash);
export default {
components: {
FontAwesomeIcon,
},
props: {
title: {
type: String,
@@ -29,7 +52,30 @@ export default {
},
icon: {
type: String,
default: "fa-wrench",
default: "",
},
collapsible: {
type: Boolean,
default: false,
},
collapsed: {
type: Boolean,
default: false,
},
},
data() {
return {};
},
computed: {
href() {
return this.collapsible ? "#" : null;
},
},
methods: {
onCollapse() {
if (this.collapsible) {
this.$emit("update:collapsed", !this.collapsed);
}
},
},
};
+56 -15
View File
@@ -3,7 +3,9 @@
</template>
<script>
import _ from "underscore";
import Form from "mvc/form/form-view";
import { visitInputs } from "components/Form/utilities";
export default {
props: {
@@ -15,6 +17,18 @@ export default {
type: Array,
required: true,
},
sustainRepeats: {
type: Boolean,
default: false,
},
sustainConditionals: {
type: Boolean,
default: false,
},
hideOperations: {
type: Boolean,
default: false,
},
textEnable: {
type: String,
default: null,
@@ -31,11 +45,11 @@ export default {
type: Array,
default: null,
},
validationErrors: {
formConfig: {
type: Object,
default: null,
},
formConfig: {
replaceParams: {
type: Object,
default: null,
},
@@ -56,12 +70,6 @@ export default {
this.onHighlight(this.validation, true);
this.$emit("onValidation", this.validation);
},
validationErrors() {
const matchedErrors = Object.entries(this.form.data.matchResponse(this.validationErrors));
if (matchedErrors.length > 0) {
this.onHighlight(matchedErrors[0]);
}
},
formConfig() {
this.$nextTick(() => {
this.form.update(this.formConfig);
@@ -70,6 +78,9 @@ export default {
}
});
},
replaceParams() {
this.onReplaceParams();
},
},
mounted() {
this.onRender();
@@ -86,16 +97,27 @@ export default {
const input_id = this.form.data.match(job_input_id);
const input_field = this.form.field_list[input_id];
const input_def = this.form.input_list[input_id];
if (!input_id || !input_def || !input_field) {
if (!input_id || !input_def || !input_field || input_def.step_linked) {
continue;
}
if (!input_def.optional && input_value == null && input_def.type != "hidden") {
return [input_id, "Please provide a value for this option."];
if (
input_value &&
Array.isArray(input_value.values) &&
input_value.values.length == 0 &&
!input_def.optional
) {
return [job_input_id, "Please provide data for this input."];
}
if (input_value == null && !input_def.optional && input_def.type != "hidden") {
return [job_input_id, "Please provide a value for this option."];
}
if (input_def.wp_linked && input_def.text_value == input_value) {
return [job_input_id, "Please provide a value for this workflow parameter."];
}
if (input_field.validate) {
const message = input_field.validate();
if (message) {
return [input_id, message];
return [job_input_id, message];
}
}
if (input_value && input_value.batch) {
@@ -106,7 +128,7 @@ export default {
batch_src = src;
} else if (batch_src !== src) {
return [
input_id,
job_input_id,
"Please select either dataset or dataset list fields for all batch mode fields.",
];
}
@@ -115,7 +137,7 @@ export default {
batch_n = n;
} else if (batch_n !== n) {
return [
input_id,
job_input_id,
`Please make sure that you select the same number of inputs for all batch mode fields. This field contains <b>${n}</b> selection(s) while a previous field contains <b>${batch_n}</b>.`,
];
}
@@ -125,6 +147,22 @@ export default {
},
},
methods: {
onReplaceParams() {
if (this.replaceParams) {
this.params = {};
visitInputs(this.inputs, (input, name) => {
this.params[name] = input;
});
_.each(this.params, (input, name) => {
const newValue = this.replaceParams[name];
if (newValue) {
const field = this.form.field_list[this.form.data.match(name)];
field.value(newValue);
}
});
this.form.trigger("change");
}
},
onChange() {
this.formData = this.form.data.create();
this.$emit("onChange", this.formData);
@@ -138,6 +176,8 @@ export default {
initial_errors: this.initialErrors,
text_enable: this.textEnable,
text_disable: this.textDisable,
sustain_repeats: this.sustainRepeats,
sustain_conditionals: this.sustainConditionals,
onchange: () => {
this.onChange();
},
@@ -148,7 +188,8 @@ export default {
onHighlight(validation, silent = false) {
this.form.trigger("reset");
if (validation && validation.length == 2) {
this.form.highlight(validation[0], validation[1], silent);
const input_id = this.form.data.match(validation[0]);
this.form.highlight(input_id, validation[1], silent);
}
},
},
+139
View File
@@ -0,0 +1,139 @@
<template>
<UrlDataProvider :url="url" v-slot="{ result: config, loading }" @error="onError">
<div :id="id" v-if="!loading">
<b-alert v-if="config.message" :variant="configMessageVariant(config)" show>
{{ config.message }}
</b-alert>
<b-alert v-if="messageText" :variant="messageVariant" show>
{{ messageText }}
</b-alert>
<FormCard :title="configTitle(config)" :icon="configIcon(config)">
<template v-slot:body>
<FormDisplay :inputs="config.inputs" :replace-params="replaceParams" @onChange="onChange" />
</template>
</FormCard>
<div class="mt-3">
<b-button id="submit" variant="primary" class="mr-1" @click="onSubmit()">
<span :class="submitIconClass" />{{ submitTitle | l }}
</b-button>
<b-button v-if="cancelRedirect" @click="onCancel()">
<span class="mr-1 fa fa-times" />{{ "Cancel" | l }}
</b-button>
</div>
</div>
</UrlDataProvider>
</template>
<script>
import { getAppRoot } from "onload/loadConfig";
import { submitData } from "./services";
import { UrlDataProvider } from "components/providers/UrlDataProvider";
import { visitInputs } from "components/Form/utilities";
import FormCard from "components/Form/FormCard";
import FormDisplay from "components/Form/FormDisplay";
export default {
props: {
id: {
type: String,
required: false,
},
title: {
type: String,
required: false,
},
icon: {
type: String,
required: false,
},
submitIcon: {
type: String,
default: "fa-save",
},
submitTitle: {
type: String,
default: "Save",
},
cancelRedirect: {
type: String,
required: false,
},
url: {
type: String,
required: true,
},
redirect: {
type: String,
required: true,
},
},
components: {
FormCard,
FormDisplay,
UrlDataProvider,
},
data() {
return {
messageText: null,
messageVariant: null,
formData: {},
replaceParams: null,
};
},
computed: {
submitIconClass() {
return `mr-1 fa ${this.submitIcon}`;
},
},
methods: {
configTitle(options) {
return this.title || options.title;
},
configIcon(options) {
return this.icon || options.icon;
},
configMessageVariant(options) {
return options.status || "warning";
},
onChange(formData) {
this.formData = formData;
},
onCancel() {
window.location = `${getAppRoot()}${this.cancelRedirect}`;
},
onSubmit() {
submitData(this.url, this.formData).then((response) => {
let params = {};
if (response.id) {
params.id = response.id;
} else {
params = {
message: response.message,
status: "success",
persistent: false,
};
}
if (this.redirect) {
const urlParams = new URLSearchParams(params);
window.location = `${getAppRoot()}${this.redirect}?${urlParams.toString()}`;
} else {
const replaceParams = {};
visitInputs(response.inputs, (input, name) => {
replaceParams[name] = input.value;
});
this.replaceParams = replaceParams;
this.showMessage(response.message);
}
}, this.onError);
},
onError(error) {
this.showMessage(error || `Failed to load resource ${this.url}.`, "danger");
},
showMessage(message, variant = "success") {
this.messageText = message;
this.messageVariant = variant;
document.querySelector(".center-panel").scrollTop = 0;
},
},
};
</script>
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<b-alert :variant="variant" :show="showAlert">
<b-alert class="mt-2" :variant="variant" :show="showAlert">
{{ message | l }}
</b-alert>
</template>
+12
View File
@@ -0,0 +1,12 @@
import axios from "axios";
import { rethrowSimple } from "utils/simple-error";
import { getAppRoot } from "onload/loadConfig";
export async function submitData(url, payload) {
try {
const { data } = await axios.put(`${getAppRoot()}${url}`, payload);
return data;
} catch (e) {
rethrowSimple(e);
}
}
+64
View File
@@ -0,0 +1,64 @@
import _ from "underscore";
/** Visits tool inputs
* @param{dict} inputs - Nested dictionary of input elements
* @param{dict} callback - Called with the mapped dictionary object and corresponding model node
*/
export var visitInputs = (inputs, callback, prefix, context) => {
context = Object.assign({}, context);
_.each(inputs, (input) => {
if (input && input.type && input.name) {
context[input.name] = input;
}
});
for (var key in inputs) {
var node = inputs[key];
node.name = node.name || key;
var name = prefix ? `${prefix}|${node.name}` : node.name;
switch (node.type) {
case "repeat":
_.each(node.cache, (cache, j) => {
visitInputs(cache, callback, `${name}_${j}`, context);
});
break;
case "conditional":
if (node.test_param) {
callback(node.test_param, `${name}|${node.test_param.name}`, context);
var selectedCase = matchCase(node, node.test_param.value);
if (selectedCase != -1) {
visitInputs(node.cases[selectedCase].inputs, callback, name, context);
} else {
console.debug(`Form.utilities::visitInputs() - Invalid case for ${name}.`);
}
} else {
console.debug(`Form.utilities::visitInputs() - Conditional test parameter missing for ${name}.`);
}
break;
case "section":
visitInputs(node.inputs, callback, name, context);
break;
default:
callback(node, name, context);
}
}
};
/** Match conditional values to selected cases
* @param{dict} input - Definition of conditional input parameter
* @param{dict} value - Current value
*/
export var matchCase = (input, value) => {
if (input.test_param.type == "boolean") {
if (value == "true") {
value = input.test_param.truevalue || "true";
} else {
value = input.test_param.falsevalue || "false";
}
}
for (var i in input.cases) {
if (input.cases[i].value == value) {
return i;
}
}
return -1;
};
@@ -8,7 +8,7 @@ export default {
const { history_content_type } = this.item;
switch (history_content_type) {
case "dataset":
return "Dataset";
return "Subdataset";
case "dataset_collection":
return "Subcollection";
default:
@@ -11,18 +11,24 @@
import Placeholder from "./Placeholder";
import Dataset from "./Dataset";
import DatasetCollection from "./DatasetCollection";
// import SubDataset from "./SubDataset";
import Subdataset from "./Subdataset";
import Subcollection from "./Subcollection";
export default {
template: `
<component :is="contentItemComponent"
class="content-item p-1"
:data-ci-type="contentItemComponent"
class="content-item"
:class="{ loading }"
:tabindex="index"
:writable="writable"
v-on="$listeners"
v-bind="bindProps"
@mouseover.native.self.stop="setFocus(index)"
v-bind="$attrs"
:item="item"
:index="index"
:row-key="rowKey"
:writable="writable"
@mouseover.native.stop="setFocus(index)"
@keydown.native.arrow-up.self.stop="setFocus(index - 1)"
@keydown.native.arrow-down.self.stop="setFocus(index + 1)"
/>
@@ -32,13 +38,15 @@ export default {
Placeholder,
Dataset,
DatasetCollection,
// SubDataset,
Subdataset,
Subcollection,
},
props: {
item: { type: Object, required: true },
index: { type: Number, required: true },
index: { type: Number, required: false, default: null },
rowKey: { type: [Number, String], required: false, default: "" },
writable: { type: Boolean, required: false, default: true },
},
data: () => ({
@@ -66,9 +74,6 @@ export default {
// override me
return "Placeholder";
},
bindProps() {
return { ...this.$props, ...this.$attrs };
},
},
created() {
@@ -1,159 +1,170 @@
<template>
<PriorityMenu :starting-height="27">
<PriorityMenuItem
v-if="notIn(STATES.NOT_VIEWABLE, STATES.DISCARDED)"
key="view-dataset"
:title="displayButtonTitle"
:disabled="dataset.purged || isIn(STATES.UPLOAD, STATES.NEW)"
@click.stop="viewData"
icon="fas fa-eye"
/>
<PriorityMenuItem
v-if="notIn(STATES.DISCARDED)"
key="edit-dataset"
:title="editButtonTitle"
:disabled="dataset.deleted || isIn(STATES.UPLOAD, STATES.NEW)"
@click.stop="
backboneRoute('datasets/edit', {
dataset_id: dataset.id,
})
"
icon="fa fa-pencil"
/>
<PriorityMenuItem
v-if="dataset.accessible"
key="delete-dataset"
:title="deleteButtonTitle"
:disabled="dataset.purged"
@click.stop="onDeleteClick"
:icon="dataset.deleted ? 'fas fa-trash-restore' : 'fas fa-trash'"
/>
<PriorityMenuItem
v-if="expanded"
key="edit-tags"
title="Edit Dataset Tags"
:pressed="showTags"
@click.stop="$emit('update:showTags', !showTags)"
icon="fas fa-tags"
/>
<PriorityMenuItem
v-if="isIn(STATES.ERROR)"
key="show-error"
title="Error"
@click.stop="
backboneRoute('datasets/error', {
dataset_id: dataset.id,
})
"
icon="fa fa-bug"
/>
<PriorityMenuItem
v-if="!(showDownloads && dataset.hasMetaData)"
key="download"
title="Download"
:href="prependPath(dataset.getUrl('download'))"
target="_blank"
download
icon="fas fa-file-download"
/>
<PriorityMenuItem
v-if="showDownloads && dataset.hasMetaData"
key="download-metadata"
title="Download"
:href="prependPath(dataset.getUrl('download'))"
target="_blank"
download
icon="fas fa-file-download"
/>
<PriorityMenuItem
v-if="!dataset.purged && dataset.getUrl('download')"
key="copy-link"
title="Copy Link"
icon="fa fa-chain"
@click.stop="$emit('copy-link')"
/>
<div v-if="showDownloads && dataset.hasMetaData">
<PriorityMenuItem
v-for="mf in dataset.meta_files"
:key="'download-' + mf.download_url"
:title="'Download ' + mf.file_type"
:href="prependPath(dataset.getUrl('meta_download') + mf.file_type)"
target="_blank"
download
icon="fas fa-file-download"
<div>
<b-button-group>
<IconButton
v-if="notIn(STATES.NOT_VIEWABLE, STATES.DISCARDED)"
icon="eye"
:title="displayButtonTitle"
:disabled="dataset.purged || isIn(STATES.UPLOAD, STATES.NEW)"
@click.stop="viewData"
variant="link"
class="px-1"
/>
<IconButton
v-if="notIn(STATES.DISCARDED)"
icon="pen"
:title="editButtonTitle"
:disabled="dataset.deleted || isIn(STATES.UPLOAD, STATES.NEW)"
@click.stop="backboneRoute('datasets/edit', { dataset_id: dataset.id })"
variant="link"
class="px-1"
/>
<IconButton
v-if="dataset.accessible"
:icon="dataset.deleted ? 'trash-restore' : 'trash'"
:title="deleteButtonTitle"
:disabled="dataset.purged"
v-b-modal="bsId('delete-modal')"
variant="link"
class="px-1"
@click.stop
/>
</div>
<PriorityMenuItem
v-if="dataset.rerunnable && dataset.creating_job && notIn(STATES.UPLOAD, STATES.NOT_VIEWABLE)"
key="run-job"
title="Run job again"
:href="prependPath(dataset.getUrl('rerun'))"
@click.stop="
backboneRoute('/', {
job_id: dataset.creating_job,
})
"
icon="fa fa-redo"
/>
<b-dropdown
size="sm"
variant="link"
:text="'Dataset Operations' | l"
toggle-class="p-1 pl-2"
class="flex-grow-0"
v-if="expanded"
boundary="window"
>
<template v-slot:button-content>
<Icon icon="ellipsis-v" variant="link" />
<span class="sr-only">Dataset Operations</span>
</template>
<PriorityMenuItem
v-if="showViz && hasViz && isIn(STATES.OK, STATES.FAILED_METADATA)"
key="visualize"
title="Visualize Data"
@click.stop.prevent="visualize"
icon="fa-bar-chart"
/>
<b-dropdown-item
v-if="isIn(STATES.ERROR)"
key="show-error"
title="Error"
@click.stop="
backboneRoute('datasets/error', {
dataset_id: dataset.id,
})
"
>
<Icon icon="exclamation-triangle" class="mr-1" />
<span v-localize>Show Error</span>
</b-dropdown-item>
<PriorityMenuItem
v-if="currentUser && currentUser.id && dataset.creating_job"
key="tool-tip"
title="Tool Help"
@click.stop="showToolHelp(dataset.creating_job)"
icon="fa fa-question"
/>
<b-dropdown-item
v-if="!dataset.purged && dataset.getUrl('download')"
title="Copy Link"
@click.stop="$emit('copy-link')"
>
<Icon icon="link" class="mr-1" />
<span v-localize>Copy Link</span>
</b-dropdown-item>
<PriorityMenuItem
v-if="notIn(STATES.NOT_VIEWABLE)"
key="dataset-details"
title="View Details"
@click.stop.prevent="
iframeAdd({
path: dataset.getUrl('show_params'),
title: 'Dataset details',
})
"
icon="fa fa-info-circle"
/>
</PriorityMenu>
<b-dropdown-item
v-if="!(showDownloads && dataset.hasMetaData)"
title="Download"
:href="prependPath(dataset.getUrl('download'))"
target="_blank"
download
>
<Icon icon="download" class="mr-1" />
<span v-localize>Download</span>
</b-dropdown-item>
<b-dropdown-group v-if="showDownloads && dataset.hasMetaData">
<b-dropdown-item
v-for="mf in dataset.meta_files"
:key="'download-' + mf.download_url"
:title="'Download ' + mf.file_type"
:href="prependPath(dataset.getUrl('meta_download') + mf.file_type)"
target="_blank"
download
>
<Icon icon="download" class="mr-1" />
<span v-localize>{{ "Download " + mf.file_type }}</span>
</b-dropdown-item>
</b-dropdown-group>
<b-dropdown-item
v-if="dataset.rerunnable && dataset.creating_job && notIn(STATES.UPLOAD, STATES.NOT_VIEWABLE)"
title="Run job again"
:href="prependPath(dataset.getUrl('rerun'))"
@click.stop="
backboneRoute('/', {
job_id: dataset.creating_job,
})
"
>
<Icon icon="play" class="mr-1" />
<span v-localize>Run job again</span>
</b-dropdown-item>
<b-dropdown-item
v-if="showViz && hasViz && isIn(STATES.OK, STATES.FAILED_METADATA)"
title="Visualize Data"
@click.stop.prevent="visualize"
>
<Icon icon="chart-area" class="mr-1" />
<span v-localize>Visualize Data</span>
</b-dropdown-item>
<b-dropdown-item
v-if="currentUser && currentUser.id && dataset.creating_job"
title="Tool Help"
@click.stop="showToolHelp(dataset.creating_job)"
>
<Icon icon="question" class="mr-1" />
<span v-localize>Tool Help</span>
</b-dropdown-item>
<b-dropdown-item
v-if="notIn(STATES.NOT_VIEWABLE)"
key="dataset-details"
title="View Dataset Details"
@click.stop.prevent="
iframeAdd({
path: dataset.getUrl('show_params'),
title: 'View Dataset Details',
})
"
>
<Icon icon="info-circle" class="mr-1" />
<span v-localize>View Dataset Details</span>
</b-dropdown-item>
</b-dropdown>
</b-button-group>
<!-- modals -->
<b-modal :id="bsId('delete-modal')" title="Delete Dataset?" title-tag="h2" @ok="onDeleteClick">
<p v-localize>Really delete this dataset?</p>
</b-modal>
</div>
</template>
<script>
import { mapGetters } from "vuex";
import { Dataset, STATES } from "../../model";
import { PriorityMenu, PriorityMenuItem } from "components/PriorityMenu";
import { Dataset } from "../../model";
import { legacyNavigationMixin } from "components/plugins/legacyNavigation";
import IconButton from "components/IconButton";
export default {
mixins: [legacyNavigationMixin],
inject: ["STATES"],
components: {
PriorityMenu,
PriorityMenuItem,
IconButton,
},
props: {
dataset: { type: Dataset, required: true },
expanded: { type: Boolean, required: true },
showTags: { type: Boolean, required: false, default: false },
},
data() {
@@ -163,11 +174,6 @@ export default {
};
},
created() {
// make available to template
this.STATES = STATES;
},
computed: {
...mapGetters("user", ["currentUser"]),
...mapGetters("config", ["config"]),
@@ -180,10 +186,10 @@ export default {
if (this.dataset.purged) {
return "Cannot display datasets removed from disk";
}
if (this.dataset.state == STATES.UPLOAD) {
if (this.dataset.state == this.STATES.UPLOAD) {
return "This dataset must finish uploading before it can be viewed";
}
if (this.dataset.state == STATES.NEW) {
if (this.dataset.state == this.STATES.NEW) {
return "This dataset is not yet viewable";
}
return "View data";
@@ -196,7 +202,7 @@ export default {
if (this.dataset.purged) {
return "Cannot edit attributes of datasets removed from disk";
}
const unreadyStates = new Set([STATES.UPLOAD, STATES.NEW]);
const unreadyStates = new Set([this.STATES.UPLOAD, this.STATES.NEW]);
if (unreadyStates.has(this.dataset.state)) {
return "This dataset is not yet editable";
}
@@ -218,7 +224,7 @@ export default {
if (!this.dataset.hasData) {
return false;
}
const okStates = new Set([STATES.OK, STATES.FAILED_METADATA, STATES.ERROR]);
const okStates = new Set([this.STATES.OK, this.STATES.FAILED_METADATA, this.STATES.ERROR]);
return okStates.has(this.dataset.state);
},
@@ -278,6 +284,11 @@ export default {
const eventName = this.dataset.deleted ? "undelete" : "delete";
this.$emit(eventName);
},
// bootstrap demands ids instead of simply using a ref so I guess we're doing this
bsId(suffix) {
return `dataset-${this.dataset.id}-${suffix}`;
},
},
};
</script>
@@ -8,7 +8,7 @@ either through the props, and make updates through the events -->
<template>
<div
class="dataset history-content"
:id="typedId"
:id="typeId"
:class="{ expanded, collapsed, selected }"
:data-state="dataset.state"
@keydown.arrow-left.self.stop="$emit('update:expanded', false)"
@@ -16,73 +16,101 @@ either through the props, and make updates through the events -->
@keydown.space.self.stop.prevent="$emit('update:selected', !selected)"
>
<!-- name, state buttons, menus -->
<nav class="content-top-menu" @click.stop="$emit('update:expanded', !expanded)">
<div class="content-status-indicators" @click.stop>
<b-check
class="selector"
v-if="showSelection"
:checked="selected"
@change="$emit('update:selected', $event)"
<nav class="p-1 d-flex align-items-center cursor-pointer" @click.stop="$emit('update:expanded', !expanded)">
<div class="d-flex align-items-center flex-grow-1 overflow-hidden">
<div class="pl-1" v-if="showSelectionBox">
<b-check class="selector" :checked="selected" @change="$emit('update:selected', $event)"></b-check>
</div>
<StatusIcon
v-if="!collapsed"
class="status-icon px-1"
:state="dataset.state"
@click.stop="onStatusClick"
/>
<StatusIcon v-if="!ok" class="status-icon px-1" :state="dataset.state" @click.stop="onStatusClick" />
<StateBtn
v-if="!dataset.visible"
<IconButton
v-if="!collapsed && !dataset.visible"
class="px-1"
state="hidden"
title="Unhide"
icon="fa fa-eye-slash"
icon="eye-slash"
@click.stop="$emit('unhide')"
/>
<StateBtn
v-if="dataset.isDeleted && !dataset.purged"
<IconButton
v-if="!collapsed && dataset.isDeleted && !dataset.purged"
class="px-1"
state="deleted"
title="Undelete"
icon="fas fa-trash-restore"
icon="trash-restore"
@click.stop="$emit('undelete')"
/>
<div class="content-title p-1 overflow-hidden">
<h5 class="text-truncate" v-if="collapsed">
<!-- <span class="hid">{{ dataset.hid }}</span> -->
<span class="name">{{ dataset.title }}</span>
</h5>
</div>
</div>
<div class="content-title">
<h5 class="text-truncate">
<span class="hid">{{ dataset.hid }}</span>
<span v-if="collapsed || !dataset.canEditName" class="name">{{ dataset.title }}</span>
</h5>
</div>
<div class="content-item-menu">
<div class="d-flex">
<slot name="menu">
<DatasetMenu :dataset="dataset" :expanded="expanded" v-on="$listeners" :show-tags.sync="showTags" />
<DatasetMenu :dataset="dataset" :expanded="expanded" v-on="$listeners" />
</slot>
</div>
</nav>
<!--- read-only tags with name: prefix -->
<div v-if="collapsed && dataset.nameTags.length" class="nametags p-1">
<!--- read-only tags with name: prefix
<div v-if="collapsed && dataset.nameTags.length" class="nametags p-2">
<Nametag v-for="tag in dataset.nameTags" :key="tag" :tag="tag" />
</div>
</div> -->
<!-- expanded view with editors -->
<header v-if="expanded" class="p-2 details">
<ClickToEdit
v-if="dataset.canEditName"
tag-name="h4"
:value="dataset.name"
@input="$emit('update:dataset', { name: $event })"
:display-label="dataset.title"
:tooltip-title="'Edit dataset name...' | localize"
tooltip-placement="left"
/>
<div v-if="expanded" class="p-3 details">
<div class="d-flex">
<div class="flex-grow-1">
<h4>{{ name || "(Dataset Name)" }}</h4>
<Annotation
class="mt-1"
:value="dataset.annotation"
@input="$emit('update:dataset', { annotation: $event })"
/>
<template v-if="!editing">
<p v-if="annotation">{{ annotation }}</p>
<div v-if="dataset.nameTags.length" class="nametags mt-2">
<Nametag v-for="tag in dataset.nameTags" :key="tag" :tag="tag" />
</div>
</template>
<ContentTags v-if="showTags" class="mt-2" :content="dataset" />
<div v-else-if="dataset.nameTags.length" class="nametags mt-2">
<Nametag v-for="tag in dataset.nameTags" :key="tag" :tag="tag" />
<div v-else class="my-3">
<b-textarea
class="mb-3"
v-model="name"
placeholder="Dataset Name"
trim
max-rows="4"
></b-textarea>
<b-textarea
class="mb-3"
v-model="annotation"
placeholder="Annotation (optional)"
trim
max-rows="4"
></b-textarea>
<ContentTags class="mt-2" :content="dataset" />
</div>
</div>
<EditorMenu
class="ml-3 flex-grow-0 d-flex flex-column"
v-if="writable"
model-name="Collection"
:editing.sync="editing"
:writable="writable"
:valid="valid"
:dirty="dirty"
@save="save"
@revert="revert"
/>
</div>
<div class="details">
@@ -102,41 +130,44 @@ either through the props, and make updates through the events -->
</div>
<pre v-if="dataset.peek" class="dataset-peek p-1" v-html="dataset.peek"></pre>
</div>
</header>
</div>
</div>
</template>
<script>
import { Dataset, STATES } from "../../model";
import ClickToEdit from "components/ClickToEdit";
import Annotation from "components/Annotation";
import { Nametag } from "components/Nametags";
import { StatusIcon, StateBtn } from "../../StatusIcon";
import StatusIcon from "../../StatusIcon";
import DatasetMenu from "./DatasetMenu";
import DatasetSummary from "./Summary";
import ContentTags from "../../ContentTags";
import { legacyNavigationMixin } from "components/plugins/legacyNavigation";
import IconButton from "components/IconButton";
import EditorMenu from "../../EditorMenu";
export default {
mixins: [legacyNavigationMixin],
components: {
ClickToEdit,
Annotation,
StatusIcon,
StateBtn,
DatasetMenu,
DatasetSummary,
ContentTags,
Nametag,
IconButton,
EditorMenu,
},
props: {
dataset: { type: Dataset, required: true },
expanded: { type: Boolean, required: true },
selected: { type: Boolean, required: false, default: false },
showSelection: { type: Boolean, required: false, default: false },
writable: { type: Boolean, required: false, default: true },
selectable: { type: Boolean, required: false, default: true },
},
data() {
return {
showTags: false,
editing: false,
tempContent: this.dataset.clone(),
};
},
computed: {
@@ -146,11 +177,33 @@ export default {
typeId() {
return this.dataset.type_id;
},
ok() {
return this.dataset.state == "ok";
isEditing() {
return this.writable && this.editing;
},
typedId() {
return `dataset-${this.dataset.id}`;
valid() {
return true;
},
dirty() {
return !this.tempContent.equals(this.dataset);
},
name: {
get() {
return this.tempContent?.name || "";
},
set(newVal) {
this.patchTempContent({ name: newVal });
},
},
annotation: {
get() {
return this.tempContent?.annotation || "";
},
set(newVal) {
this.patchTempContent({ annotation: newVal });
},
},
showSelectionBox() {
return this.selectable && this.showSelection;
},
},
methods: {
@@ -163,6 +216,23 @@ export default {
console.log("unhandled status icon click", this.dataset.state);
}
},
patchTempContent(props = {}) {
this.tempContent = this.tempContent.patch(props);
},
save() {
if (this.valid) {
this.$emit("update:dataset", this.tempContent);
this.editing = false;
}
},
reset() {
this.tempContent = this.dataset.clone();
},
revert() {
if (this.dirty) {
this.reset();
}
},
},
};
</script>
@@ -1,18 +1,20 @@
<template>
<b-dropdown no-caret right variant="link" size="sm" boundary="window" toggle-class="p-1">
<b-dropdown no-caret right variant="link" size="sm" boundary="window" toggle-class="p-1 pl-3">
<template v-slot:button-content>
<i class="fas fa-trash" />
<Icon icon="trash" variant="link" />
<span class="sr-only">Delete Collection</span>
</template>
<b-dropdown-item @click.stop="$emit('delete')"> Delete Collection Only </b-dropdown-item>
<b-dropdown-item @click.stop="$emit('delete')">
<span v-localize>Delete Collection Only </span>
</b-dropdown-item>
<b-dropdown-item @click.stop="$emit('delete', { recursive: true })">
Delete Contained Datasets
<span v-localize>Delete Contained Datasets</span>
</b-dropdown-item>
<b-dropdown-item @click.stop="$emit('delete', { recursive: true, purge: true })">
Purge Contained Datasets
<span v-localize>Purge Contained Datasets</span>
</b-dropdown-item>
</b-dropdown>
</template>
@@ -9,16 +9,17 @@
:data-state="dsc.state"
@keydown.arrow-right.self.stop="$emit('viewCollection')"
@keydown.space.self.stop.prevent="$emit('update:selected', !selected)"
@click.stop="$emit('viewCollection')"
>
<nav class="content-top-menu">
<div class="content-status-indicators mr-1" @click.stop>
<b-check
class="selector"
v-if="showSelection"
:checked="selected"
@change="$emit('update:selected', $event)"
/>
<nav class="p-1 d-flex align-items-center cursor-pointer">
<div class="d-flex align-items-center flex-grow-1 overflow-hidden">
<div class="pl-1" v-if="showSelection">
<b-check
class="selector"
:checked="selected"
@click.stop
@change="$emit('update:selected', $event)"
/>
</div>
<StatusIcon
v-if="dsc.state != 'ok'"
@@ -27,44 +28,45 @@
@click.stop="onStatusClick"
/>
<StateBtn
<IconButton
v-if="!dsc.visible"
class="px-1"
state="hidden"
title="Unhide"
icon="fa fa-eye-slash"
icon="eye-slash"
@click.stop="$emit('unhide')"
/>
<StateBtn
<IconButton
v-if="dsc.deleted"
class="px-1"
state="deleted"
title="Undelete"
icon="fas fa-trash-restore"
icon="trash-restore"
@click.stop="$emit('undelete')"
/>
<StateBtn
<IconButton
class="px-1"
state="ok"
title="Collection"
icon="fas fa-folder"
icon="folder"
@click.stop="$emit('viewCollection')"
variant="link"
/>
<div class="content-title flex-grow-1 overflow-hidden" @click.stop="$emit('viewCollection')">
<h5 class="text-truncate">
<!-- <span class="hid">{{ dsc.hid }}</span> -->
<span class="name">{{ dsc.name }}</span>
<span class="description">
({{ dsc.collectionType | localize }} {{ dsc.collectionCountDescription | localize }})
</span>
</h5>
</div>
</div>
<div class="content-title mr-1">
<h5 class="text-truncate">
<span class="hid">{{ dsc.hid }}</span>
<span class="name">{{ dsc.name }}</span>
<span class="description">
({{ dsc.collectionType | localize }} {{ dsc.collectionCountDescription | localize }})
</span>
</h5>
</div>
<div class="content-item-menu">
<div class="d-flex" v-if="writable">
<slot name="menu">
<DscMenu v-if="!dsc.deleted" v-on="$listeners" />
</slot>
@@ -72,7 +74,7 @@
</nav>
<!--- read-only tags with name: prefix -->
<div v-if="dsc.nameTags.length" class="nametags p-1">
<div v-if="dsc.nameTags.length" class="nametags p-2">
<Nametag v-for="tag in dsc.nameTags" :key="tag" :tag="tag" />
</div>
@@ -82,23 +84,25 @@
<script>
import { DatasetCollection } from "../../model/DatasetCollection";
import { StatusIcon, StateBtn } from "../../StatusIcon";
import StatusIcon from "../../StatusIcon";
import JobStateProgress from "./JobStateProgress";
import DscMenu from "./DscMenu";
import { Nametag } from "components/Nametags";
import IconButton from "components/IconButton";
export default {
components: {
StatusIcon,
StateBtn,
JobStateProgress,
DscMenu,
Nametag,
IconButton,
},
props: {
dsc: { type: DatasetCollection, required: true },
selected: { type: Boolean, required: false, default: false },
showSelection: { type: Boolean, required: false, default: false },
writable: { type: Boolean, required: false, default: true },
},
methods: {
onStatusClick() {
@@ -28,7 +28,6 @@ import { STATES } from "../model";
export default {
props: {
item: { type: Object, required: true },
index: { type: Number, required: true },
},
computed: {
dsc() {
@@ -0,0 +1,62 @@
<!-- a read-only dataset inside a collection. The collection query does not return enough
information to properly render the dataset, so what we do is look it up on expand, checking the
cache first to see if we already have the data -->
<template>
<DatasetUI
v-if="dataset"
v-bind="$attrs"
v-on="$listeners"
:dataset="dataset"
:writable="false"
:selectable="false"
:row-key="rowKey"
@update:expanded="onExpand"
/>
</template>
<script>
import { Dataset } from "../model";
import { getContentByTypeId, cacheContent } from "../caching";
import { getContentDetails } from "../model/queries";
import DatasetUI from "./Dataset/DatasetUI";
export default {
components: {
DatasetUI,
},
props: {
item: { type: Object, required: true },
},
data() {
return {
localItem: { ...this.item },
loading: false,
};
},
computed: {
dataset() {
return new Dataset(this.localItem);
},
},
methods: {
onExpand(val) {
if (val) {
this.loadDetails();
}
this.$emit("update:expand", val);
},
async loadDetails() {
const { type_id, history_id } = this.item;
const existingDataset = await getContentByTypeId(history_id, type_id);
if (existingDataset) {
this.localItem = existingDataset;
} else {
const loadedDataset = await getContentDetails(this.item);
await cacheContent(loadedDataset);
this.localItem = loadedDataset;
}
},
},
};
</script>
@@ -3,7 +3,7 @@
.content-item {
outline: none;
border-bottom: 1px solid white;
border-bottom: 2px solid white;
&:focus-within {
box-shadow: inset 0 0 1em 0.25em rgba(255, 255, 255, 0.25);
}
@@ -11,36 +11,9 @@
// Nav across the top of the content item
.content-item .content-top-menu {
/* a fixed-height is important for proper
functioning of the PriorityMenuItem dropdown since
it relies on flexbox and overflow */
min-height: 2em;
cursor: pointer;
// flex parent
display: flex;
align-items: center;
justify-content: space-between;
// error, clock, etc.
.content-status-indicators {
// d-flex mr-1 align-items-center
display: flex;
align-items: center;
&:blank {
display: none;
}
}
// name & labelling
.content-title {
// flex child
flex-grow: 1;
overflow: hidden;
.hid {
color: adjust-color($text-color, $alpha: -0.6);
font-size: 80%;
@@ -56,24 +29,4 @@
font-weight: 700;
}
}
// priority menu on right side
.content-item-menu {
display: flex;
justify-content: flex-end;
}
}
// Setting a max-width here limits the number of icons that
// will show. The priority menu is css awayre
.content-item.collapsed .content-item-menu {
max-width: 90px;
}
// .content-menu button:hover,
// .content-menu .show > button.dropdown-toggle {
// background-color: transparent;
// border-color: transparent;
// }
}
@@ -1,136 +1,168 @@
<template>
<section>
<header class="d-flex align-items-center justify-content-between">
<h6 class="text-nowrap mr-3">
<span v-localize>Matches</span>
<a href="#" @click="showFilter = !showFilter">
<span>{{ totalMatches }}</span>
</a>
</h6>
<PriorityMenu style="max-width: 50%" :starting-height="27">
<PriorityMenuItem
key="filter"
<nav class="content-operations-menu d-flex justify-content-between bg-secondary">
<b-button-group>
<IconButton
:title="hasSelection ? 'Select All Contents' : 'Unselect All Contents'"
icon="check-square"
:disabled="!totalMatches"
:pressed="hasSelection"
@click="toggleSelectAll"
/>
<IconButton
title="Filter History Content"
icon="fa fa-filter"
@click="showFilter = !showFilter"
icon="filter"
:disabled="!totalMatches"
:pressed="showFilter"
@click="showFilter = !showFilter"
/>
<PriorityMenuItem
key="selection"
title="Operations on multiple datasets"
class="show-history-content-selectors-btn"
icon="fas fa-check-square"
@click="$emit('update:show-selection', !showSelection)"
:pressed="showSelection"
<IconButton
title="Collapse Expanded Items"
icon="compress"
:disabled="!expandedCount"
@click="$emit('collapseAllContent')"
/>
</b-button-group>
<PriorityMenuItem
key="copy-datasets"
class="copy-datasets-menu-item"
title="Copy Datasets"
icon="fas fa-copy"
@click="iframeRedirect('/dataset/copy_datasets')"
/>
<PriorityMenuItem
key="collapse-expanded"
title="Collapse Expanded Datasets"
icon="fas fa-compress-alt"
@click="emit('collapseAllContent')"
/>
<PriorityMenuItem
key="show-hidden-datasets"
title="Unhide All Hidden Datasets"
icon="fas fa-eye"
@click="$bvModal.show('show-hidden-content')"
/>
<PriorityMenuItem
key="delete-hidden"
title="Delete Hidden Datasets"
icon="fas fa-trash"
@click="$bvModal.show('delete-hidden-content')"
/>
<PriorityMenuItem
key="purge-hidden"
title="Purge Hidden Datasets"
icon="fas fa-burn"
@click="$bvModal.show('purge-deleted-content')"
/>
</PriorityMenu>
</header>
<transition name="shutterfade">
<content-filters v-if="showContentFilters" class="content-filters mt-1" :params.sync="localParams" />
</transition>
<transition name="shutterfade">
<b-button-toolbar
v-if="showSelection"
class="content-selection justify-content-between mt-1 list-action-menu"
>
<b-button-group>
<b-button size="sm" @click="$emit('selectAllContent')">
{{ "Select All" | localize }}
</b-button>
<b-button size="sm" @click="$emit('resetSelection')">
{{ "Unselect All" | localize }}
</b-button>
</b-button-group>
<b-button-group>
<b-dropdown
class="ml-auto history-contents-list-action-menu-btn"
class="history-contents-list-action-menu-btn"
size="sm"
text="With Selected"
text="Selection"
:disabled="!hasSelection"
>
<b-dropdown-item @click="hideSelected">
{{ "Hide Datasets" | localize }}
<b-dropdown-text id="history-op-selected-content">
<span v-localize v-if="hasSelection">With {{ numSelected }} selected items...</span>
<span v-localize v-else>You don't have any selected content.</span>
</b-dropdown-text>
<b-dropdown-item aria-describedby="history-op-selected-content" v-b-modal:hide-selected-content>
<span v-localize>Hide</span>
</b-dropdown-item>
<b-dropdown-item @click="unhideSelected">
{{ "Unhide Datasets" | localize }}
<b-dropdown-item aria-describedby="history-op-selected-content" v-b-modal:show-selected-content>
<span v-localize>Unhide</span>
</b-dropdown-item>
<b-dropdown-item @click="deleteSelected">
{{ "Delete Datasets" | localize }}
<b-dropdown-item aria-describedby="history-op-selected-content" v-b-modal:delete-selected-content>
<span v-localize>Delete</span>
</b-dropdown-item>
<b-dropdown-item @click="undeleteSelected">
{{ "Undelete Datasets" | localize }}
<b-dropdown-item aria-describedby="history-op-selected-content" v-b-modal:restore-selected-content>
<span v-localize>Undelete</span>
</b-dropdown-item>
<b-dropdown-item @click="purgeSelected">
{{ "Permanently Delete Datasets" | localize }}
</b-dropdown-item>
<b-dropdown-item @click="buildDatasetList">
{{ "Build Dataset List" | localize }}
</b-dropdown-item>
<b-dropdown-item @click="buildDatasetPair">
{{ "Build Dataset Pair" | localize }}
</b-dropdown-item>
<b-dropdown-item @click="buildListOfPairs">
{{ "Build List of Dataset Pairs" | localize }}
</b-dropdown-item>
<b-dropdown-item @click="buildCollectionFromRules">
{{ "Build Collection from Rules" | localize }}
<b-dropdown-item
aria-describedby="history-op-selected-content"
v-b-modal:purge-selected-content
:disabled="!hasSelection"
>
<span v-localize>Permanently Delete</span>
<span v-if="numSelected">{{ numSelected }} items</span>
<span v-else>(Disabled)</span>
</b-dropdown-item>
</b-dropdown>
</b-button-toolbar>
<b-dropdown
class="history-contents-list-action-menu-btn"
size="sm"
text="History"
:disabled="!totalMatches"
>
<b-dropdown-text id="history-op-all-content">
<span v-localize>With entire history...</span>
</b-dropdown-text>
<b-dropdown-item v-b-modal:show-all-hidden-content aria-describedby="history-op-all-content">
<span v-localize>Unhide All Hidden Content</span>
</b-dropdown-item>
<b-dropdown-item v-b-modal:delete-all-hidden-content aria-describedby="history-op-all-content">
<span v-localize>Delete All Hidden Content</span>
</b-dropdown-item>
<b-dropdown-item v-b-modal:purge-all-deleted-content aria-describedby="history-op-all-content">
<span v-localize>Purge All Hidden Content</span>
</b-dropdown-item>
</b-dropdown>
<b-dropdown
class="history-contents-list-action-menu-btn"
size="sm"
text="Create New Content"
:disabled="!totalMatches"
>
<template v-slot:button-content>
<Icon icon="plus" />
<span class="sr-only" v-localize>Create New Content</span>
</template>
<b-dropdown-text id="history-op-new-content">
<span>Create new content</span>
</b-dropdown-text>
<b-dropdown-item
aria-describedby="history-op-new-content"
@click="iframeRedirect('/dataset/copy_datasets')"
>
<span v-localize>Copy Datasets</span>
</b-dropdown-item>
<b-dropdown-item aria-describedby="history-op-new-content" @click="buildDatasetList">
<span v-localize>Build Dataset List</span>
</b-dropdown-item>
<b-dropdown-item aria-describedby="history-op-new-content" @click="buildDatasetPair">
<span v-localize>Build Dataset Pair</span>
</b-dropdown-item>
<b-dropdown-item aria-describedby="history-op-new-content" @click="buildListOfPairs">
<span v-localize>Build List of Dataset Pairs</span>
</b-dropdown-item>
<b-dropdown-item aria-describedby="history-op-new-content" @click="buildCollectionFromRules">
<span v-localize>Build Collection from Rules</span>
</b-dropdown-item>
</b-dropdown>
</b-button-group>
</nav>
<transition name="shutterfade">
<content-filters v-if="showContentFilters" class="content-filters p-2" :params.sync="localParams" />
</transition>
<!-- #region Menus and popovers -->
<b-modal id="show-hidden-content" title="Show Hidden Datasets" title-tag="h2" @ok="unhideAll">
<p>{{ "Really unhide all hidden datasets?" | localize }}</p>
<b-modal id="hide-selected-content" title="Hide Selected Content?" title-tag="h2" @ok="hideSelected">
<p v-localize>Really hide {{ numSelected }} content items?</p>
</b-modal>
<b-modal id="delete-hidden-content" title="Delete Hidden Datasets" title-tag="h2" @ok="deleteAllHidden">
<p>{{ "Really delete all hidden datasets?" | localize }}</p>
<b-modal id="show-selected-content" title="Show Selected Content?" title-tag="h2" @ok="unhideSelected">
<p v-localize>Really show {{ numSelected }} content items?</p>
</b-modal>
<b-modal id="purge-deleted-content" title="Purge Deleted Datasets" title-tag="h2" @ok="purgeAllDeleted">
<p>{{ "Really delete all deleted datasets permanently? This cannot be undone." | localize }}</p>
<b-modal id="delete-selected-content" title="Delete Selected Content?" title-tag="h2" @ok="deleteSelected">
<p v-localize>Really delete {{ numSelected }} content items?</p>
</b-modal>
<b-modal id="restore-selected-content" title="Restore Selected Content?" title-tag="h2" @ok="undeleteSelected">
<p v-localize>Really restore {{ numSelected }} content items?</p>
</b-modal>
<b-modal id="purge-selected-content" title="Purge Selected Content?" title-tag="h2" @ok="purgeSelected">
<p v-localize>Permanently delete {{ numSelected }} content items? This cannot be undone.</p>
</b-modal>
<b-modal id="show-all-hidden-content" title="Show Hidden Datasets" title-tag="h2" @ok="unhideAll">
<p v-localize>Really unhide all hidden datasets?</p>
</b-modal>
<b-modal id="delete-all-hidden-content" title="Delete Hidden Datasets" title-tag="h2" @ok="deleteAllHidden">
<p v-localize>Really delete all hidden datasets?</p>
</b-modal>
<b-modal id="purge-all-deleted-content" title="Purge Deleted Datasets" title-tag="h2" @ok="purgeAllDeleted">
<p v-localize>"Really delete all deleted datasets permanently? This cannot be undone.</p>
</b-modal>
<!-- #endregion -->
@@ -152,18 +184,16 @@ import {
} from "./model";
import { createDatasetCollection } from "./model/queries";
import { cacheContent } from "./caching";
import { legacyNavigationMixin } from "components/plugins/legacyNavigation";
import ContentFilters from "./ContentFilters";
import { PriorityMenu, PriorityMenuItem } from "components/PriorityMenu";
import { buildCollectionModal } from "./adapters/buildCollectionModal";
import ContentFilters from "./ContentFilters";
import IconButton from "components/IconButton";
export default {
mixins: [legacyNavigationMixin],
components: {
ContentFilters,
PriorityMenu,
PriorityMenuItem,
IconButton,
},
props: {
history: { type: History, required: true },
@@ -171,6 +201,8 @@ export default {
contentSelection: { type: Map, required: true },
showSelection: { type: Boolean, required: true },
totalMatches: { type: Number, required: true },
expandedCount: { type: Number, required: false, default: 0 },
debug: { type: Boolean, default: false },
},
data() {
return {
@@ -178,13 +210,11 @@ export default {
};
},
computed: {
// pop it open if anything is selected
showContentFilters() {
const { showHidden, showDeleted, filterText } = this.params;
return this.showFilter || showHidden || showDeleted || filterText;
},
// pass-through
localParams: {
get() {
return this.params;
@@ -194,8 +224,12 @@ export default {
},
},
numSelected() {
return this.contentSelection.size || 0;
},
hasSelection() {
return this.contentSelection.size > 0;
return this.numSelected > 0;
},
countHidden() {
@@ -203,22 +237,29 @@ export default {
},
},
methods: {
toggleSelectAll() {
if (this.hasSelection) {
this.$emit("resetSelection");
this.$emit("update:show-selection", false);
} else {
this.$emit("selectAllContent");
this.$emit("update:show-selection", true);
}
},
// #region history-wide bulk updates, does server query first to determine "selection"
async unhideAll(evt) {
await unhideAllHiddenContent(this.history);
this.$emit("manualReload");
evt.vueTarget.hide();
},
async deleteAllHidden(evt) {
await deleteAllHiddenContent(this.history);
this.$emit("manualReload");
evt.vueTarget.hide();
},
async purgeAllDeleted(evt) {
await purgeAllDeletedContent(this.history);
this.$emit("manualReload");
evt.vueTarget.hide();
},
// #endregion
@@ -243,7 +284,10 @@ export default {
async runOnSelection(fn) {
const items = Array.from(this.contentSelection.values());
const type_ids = items.map((o) => o.type_id);
await fn(this.history, type_ids);
const results = await fn(this.history, type_ids);
if (this.debug) {
console.log("operation results", results);
}
this.$emit("resetSelection");
this.$emit("manualReload");
},
@@ -282,3 +326,10 @@ export default {
},
};
</script>
<style lang="scss">
// remove borders around buttons in menu
.content-operations-menu .btn-group .btn {
border-color: transparent !important;
}
</style>
+1 -12
View File
@@ -1,5 +1,5 @@
<template>
<b-modal ref="copyModal" v-model="showModal" :title="title" title-tag="h2">
<b-modal v-bind="$attrs" v-on="$listeners" :title="title" title-tag="h2">
<transition name="fade">
<b-alert :show="currentUser.isAnonymous" variant="warning" v-localize>
As an anonymous user, unless you login or register, you will lose your current history after copying
@@ -51,7 +51,6 @@ import { mapActions, mapGetters } from "vuex";
export default {
props: {
history: { type: History, required: true },
value: { type: Boolean, required: false, default: false },
},
data() {
return {
@@ -81,16 +80,6 @@ export default {
formValid() {
return this.newNameValid;
},
showModal: {
get() {
return this.value;
},
set(newVal, oldVal) {
if (newVal !== oldVal) {
this.$emit("input", newVal);
}
},
},
},
methods: {
...mapActions("betaHistory", ["copyHistory"]),
@@ -1,45 +1,108 @@
<!-- Edits a collection, if that collection is editable. -->
<template>
<section class="history-details controls">
<ClickToEdit
:value="dsc.name"
v-if="writable"
@input="(name) => $emit('update:dsc', { name })"
tag-name="h3"
class="history-title mt-3"
:tooltip-title="'Rename collection...' | localize"
/>
<h3 v-else class="history-title mt-3">{{ dsc.name }}</h3>
<section class="history-details d-flex">
<div class="flex-grow-1">
<h3 class="history-title">{{ dscName || "(Collection Name)" }}</h3>
<p class="mt-1">
<i class="fas fa-folder"></i>
a {{ dsc.collectionType | localize }}
{{ dsc.collectionCount | localize }}
</p>
<p class="mt-1">
<i class="fas fa-folder"></i>
a {{ dsc.collectionType | localize }}
{{ dsc.collectionCount | localize }}
</p>
<div v-if="isEditing" class="mt-3">
<b-textarea v-model="dscName" placeholder="Collection Name" trim max-rows="4"></b-textarea>
<ContentTags :content="dsc" class="mt-3" />
</div>
<transition name="shutterfade">
<ContentTags v-if="writable" :content="dsc" />
<div v-else-if="dsc.tags && dsc.tags.length" class="nametags p-1">
<div v-else-if="dsc.tags && dsc.tags.length">
<Nametag v-for="tag in dsc.tags" :key="tag" :tag="tag" />
</div>
</transition>
</div>
<EditorMenu
class="ml-3 flex-grow-0 d-flex flex-column"
v-if="writable"
model-name="Collection"
:editing.sync="editing"
:writable="writable"
:valid="valid"
:dirty="dirty"
@save="save"
@revert="revert"
/>
</section>
</template>
<script>
import { DatasetCollection } from "../model";
import ClickToEdit from "components/ClickToEdit";
import { Nametag } from "components/Nametags";
import ContentTags from "../ContentTags";
import EditorMenu from "../EditorMenu";
export default {
components: {
ClickToEdit,
ContentTags,
Nametag,
EditorMenu,
},
props: {
dsc: { type: DatasetCollection, required: true },
writable: { type: Boolean, required: true },
},
data() {
return {
editing: false,
tempContent: this.dsc.clone(),
};
},
computed: {
dscId() {
return this.dsc.id;
},
isEditing() {
return this.writable && this.editing;
},
valid() {
return this.tempContent.name.length > 0;
},
dirty() {
return !this.tempContent.equals(this.dsc);
},
dscName: {
get() {
return this.tempContent.name;
},
set(newName) {
this.patchTempDsc({ name: newName });
},
},
},
watch: {
dscId(newId, oldId) {
if (newId != oldId) {
this.reset();
}
},
},
methods: {
reset() {
this.tempContent = this.dsc.clone();
},
patchTempDsc(props = {}) {
this.tempContent = this.tempContent.patch(props);
},
save() {
if (this.valid) {
this.$emit("update:dsc", this.tempContent);
this.editing = false;
}
},
revert() {
if (this.dirty) {
this.reset();
}
},
},
};
</script>
@@ -9,10 +9,19 @@
v-slot="{ isExpanded, setExpanded }"
>
<Layout class="dataset-collection-panel">
<template v-slot:nav>
<template v-slot:globalNav>
<TopNav :history="history" :selected-collections="selectedCollections" v-on="$listeners" />
</template>
<template v-slot:localNav>
<IconButton
icon="download"
title="Download Collection"
:href="downloadCollectionUrl"
download
/>
</template>
<template v-slot:details>
<Details :dsc="dsc" :writable="writable" @update:dsc="updateDsc(dsc, $event)" />
</template>
@@ -23,13 +32,16 @@
:class="{ loadingBackground: loading }"
v-bind="payload"
@scroll="setScrollPos"
:debug="false"
>
<template v-slot="{ item, index }">
<template v-slot="{ item, index, rowKey }">
<CollectionContentItem
:item="item"
:index="index"
:row-key="rowKey"
:expanded="isExpanded(item)"
:writable="writable"
:writable="false"
:selectable="false"
@update:expanded="setExpanded(item, $event)"
@viewCollection="$emit('viewCollection', item)"
/>
@@ -55,6 +67,7 @@ import Scroller from "../Scroller";
import { CollectionContentItem } from "../ContentItem";
import { reportPayload } from "../providers/ContentProvider/helpers";
import IconButton from "components/IconButton";
export default {
filters: {
@@ -69,6 +82,7 @@ export default {
Scroller,
CollectionContentItem,
ExpandedItems,
IconButton,
},
props: {
history: { type: History, required: true },
@@ -86,14 +100,22 @@ export default {
writable() {
return this.isRoot;
},
rootCollection() {
return this.selectedCollections[0];
},
downloadCollectionUrl() {
let url = "";
if (this.rootCollection) {
url = `${this.rootCollection.url}/download`;
}
return url;
},
},
methods: {
// change the data of the root collection, anything past the root
// collection is part of the dataset collection, which i believe is supposed to
// be immutable, so only edit name, tags, blah of top-level selected collection,
async updateDsc(collection, fields) {
// console.log("updateDsc", this.writable, collection, fields);
if (this.writable) {
const ajaxResult = await updateContentFields(collection, fields);
await cacheContent({ ...collection, ...ajaxResult });
@@ -1,46 +1,34 @@
<template>
<div class="navigation">
<b-dropdown v-if="breadCrumbOptions.length" class="mr-auto" size="sm" text="Go to..." boundary="viewport">
<b-dropdown-item @click="close"> History: {{ history.name }} </b-dropdown-item>
<b-dropdown-item v-for="option in breadCrumbOptions" :key="option.key" @click="reselect(option.value)">
{{ option.text }}
</b-dropdown-item>
</b-dropdown>
<b-dropdown v-if="breadCrumbOptions.length" size="sm" text="Return to..." boundary="viewport" no-caret>
<template v-slot:button-content>
<Icon icon="arrow-up" class="mr-1" />
<b class="text-nowrap" v-localize>Return to...</b>
</template>
<b-button v-else size="sm" class="mr-auto back" @click="close"> Back to: {{ history.name }} </b-button>
<b-dropdown-item @click="close">
<span>History: {{ history.name }}</span>
</b-dropdown-item>
<PriorityMenu :starting-height="27">
<PriorityMenuItem
key="download-collection"
title="Download Collection"
icon="fas fa-file-download"
tag="a"
download
:href="downloadCollectionUrl"
/>
<PriorityMenuItem key="back-up" title="Back One" icon="fas fa-level-up-alt" @click="back" />
</PriorityMenu>
</div>
<b-dropdown-item v-for="option in breadCrumbOptions" :key="option.key" @click="reselect(option.value)">
<span>{{ option.text }}</span>
</b-dropdown-item>
</b-dropdown>
<b-button v-else size="sm" title="`Return to: ${history.name}`" @click="close">
<Icon icon="arrow-up" class="mr-1" />
<b class="text-nowrap">Return to: {{ history.name }}</b>
</b-button>
</template>
<script>
import { History } from "../model";
import { PriorityMenuItem, PriorityMenu } from "components/PriorityMenu";
export default {
components: {
PriorityMenuItem,
PriorityMenu,
},
props: {
history: { type: History, required: true },
selectedCollections: { type: Array, required: true, validate: (val) => val.length > 0 },
},
computed: {
rootCollection() {
return this.selectedCollections[0];
},
// List for the dropdown. Should be the history and every parent
// collection excepting the current one.
breadCrumbOptions() {
@@ -54,14 +42,6 @@ export default {
});
return options;
},
downloadCollectionUrl() {
let url = "";
if (this.rootCollection) {
url = `${this.rootCollection.url}/download`;
}
return url;
},
},
methods: {
reselect(newList) {
@@ -2,17 +2,13 @@
<CurrentUser class="d-flex flex-column" v-slot="{ user }">
<UserHistories v-if="user" :user="user" v-slot="{ currentHistory, histories, handlers }">
<HistoryPanel v-if="currentHistory" :history="currentHistory" v-on="handlers" id="current-history-panel">
<template v-slot:nav>
<div>
<HistorySelector
:histories="histories"
:current-history="currentHistory"
:show-modal="showModal"
@update:currentHistory="handlers.setCurrentHistory"
@hideModal="showModalHandler"
/>
<HistoriesMenu v-on="handlers" @selectHistoryModal="showModalHandler" />
</div>
<template v-slot:globalNav>
<HistoriesMenu
v-on="handlers"
:histories="histories"
:current-history="currentHistory"
title="Current History"
/>
</template>
</HistoryPanel>
@@ -27,7 +23,6 @@
import CurrentUser from "components/providers/CurrentUser";
import UserHistories from "./providers/UserHistories";
import HistoryPanel from "./HistoryPanel";
import HistorySelector from "./HistorySelector";
import HistoriesMenu from "./HistoriesMenu";
export default {
@@ -35,18 +30,12 @@ export default {
CurrentUser,
UserHistories,
HistoryPanel,
HistorySelector,
HistoriesMenu,
},
data() {
return {
showModal: false,
};
},
methods: {
showModalHandler(event) {
this.showModal = !this.showModal;
},
},
// data() {
// return {
// showModal: false,
// };
// },
};
</script>
@@ -0,0 +1,75 @@
<template>
<nav class="edit-controls">
<IconButton
:title="label('Edit') | l"
class="edit-button mb-1"
:pressed="editing"
icon="pen"
@click="$emit('update:editing', !editing)"
data-description="history editor toggle"
/>
<template v-if="editing">
<!-- save -->
<IconButton
:title="label('Save') | l"
class="save-button mb-1"
:class="{ disabled: !valid }"
icon="save"
@click="$emit('save')"
data-description="history editor save button"
/>
<!-- revert -->
<IconButton
:title="label('Revert') | l"
class="revert-changes-button mb-1"
:class="{ disabled: !dirty }"
icon="undo"
@click="$emit('revert')"
/>
<!-- form status -->
<IconButton
:title="statusLabel | l"
class="status-button mb-1"
:class="{ valid: valid, disabled: !valid }"
:icon="valid ? 'check' : 'exclamation-triangle'"
variant="link"
/>
</template>
</nav>
</template>
<script>
import IconButton from "components/IconButton";
export default {
components: {
IconButton,
},
props: {
modelName: { type: String, required: true },
editing: { type: Boolean, required: true },
dirty: { type: Boolean, required: false, default: false },
valid: { type: Boolean, required: false, default: false },
},
computed: {
statusLabel() {
const status = this.valid ? "Valid" : "Invalid";
return `${status} ${this.modelName}`;
},
},
methods: {
label(verb) {
return `${verb} ${this.modelName}`;
},
},
};
</script>
<style lang="scss">
.edit-controls .disabled {
opacity: 0.25;
}
</style>
+67 -50
View File
@@ -1,66 +1,83 @@
<!-- menu allowing user to change the current history, make a new one, basically anything that's
"above" editing the current history -->
<template>
<PriorityMenu :starting-height="27">
<PriorityMenuItem
key="select-new-history"
title="Select History"
icon="fa fa-folder-open"
@click="$emit('selectHistoryModal')"
<div>
<b-dropdown
size="sm"
variant="link"
:text="title | l"
toggle-class="text-decoration-none"
no-caret
class="histories-operation-menu"
data-description="histories operation menu"
>
<template v-slot:button-content>
<Icon class="mr-1" icon="folder" />
<span id="click-me-fool" class="text-nowrap">{{ title | l }}</span>
</template>
<b-dropdown-text>
<span>You have {{ histories.length }} histories.</span>
</b-dropdown-text>
<b-dropdown-divider></b-dropdown-divider>
<b-dropdown-item v-b-modal.history-selector-modal>
<Icon class="mr-1" icon="exchange-alt" />
<span v-localize>Change the Current History</span>
</b-dropdown-item>
<b-dropdown-item data-description="create new history" @click="$emit('createNewHistory')">
<Icon class="mr-1" icon="plus" />
<span v-localize>Create a New History</span>
</b-dropdown-item>
<b-dropdown-item @click="backboneRoute('/histories/list')">
<Icon class="mr-1" icon="list" />
<span v-localize>View Saved Histories</span>
</b-dropdown-item>
<b-dropdown-item @click="redirect('/history/view_multiple')">
<Icon class="mr-1" icon="columns" />
<span v-localize>Show Histories Side-by-Side</span>
</b-dropdown-item>
<b-dropdown-divider></b-dropdown-divider>
<b-dropdown-item @click="switchToLegacyHistoryPanel">
<Icon class="mr-1" icon="arrow-up" />
<span v-localize>Return to legacy panel</span>
</b-dropdown-item>
</b-dropdown>
<!-- modals -->
<HistorySelectorModal
id="history-selector-modal"
:histories="histories"
:current-history="currentHistory"
@selectHistory="$emit('setCurrentHistory', $event)"
/>
<PriorityMenuItem
key="create-new-history"
title="Create New History"
class="history-new-button"
icon="fa fa-plus"
@click="$emit('createNewHistory')"
/>
<PriorityMenuItem
key="view-all-histories"
title="View All Histories"
icon="fa fa-columns"
class="history-view-multi-button"
@click="redirect('/history/view_multiple')"
/>
<PriorityMenuItem
key="saved-histories"
title="Saved Histories"
icon="fas fa-save"
@click="backboneRoute('/histories/list')"
/>
<PriorityMenuItem
key="clear-history-cache"
title="Refresh History"
icon="fa fa-refresh"
class="history-refresh-button"
@click.stop="clearCache"
/>
<PriorityMenuItem
key="use-legacy-history"
title="Return to legacy history panel"
icon="fas fa-exchange-alt"
@click="switchToLegacyHistoryPanel"
/>
</PriorityMenu>
</div>
</template>
<script>
import { PriorityMenuItem, PriorityMenu } from "components/PriorityMenu";
import { wipeDatabase, clearHistoryDateStore } from "./caching";
import { History } from "./model";
import { legacyNavigationMixin } from "components/plugins/legacyNavigation";
import { switchToLegacyHistoryPanel } from "./adapters/betaToggle";
import { reloadPage } from "utils/redirect";
import HistorySelectorModal from "./HistorySelectorModal";
export default {
mixins: [legacyNavigationMixin],
components: {
PriorityMenu,
PriorityMenuItem,
HistorySelectorModal,
},
props: {
histories: { type: Array, required: true },
currentHistory: { type: History, required: true },
title: { type: String, required: false, default: "Histories" },
},
methods: {
async clearCache() {
await wipeDatabase();
await clearHistoryDateStore();
reloadPage();
},
switchToLegacyHistoryPanel,
},
};
+16 -6
View File
@@ -10,7 +10,7 @@
<ExpandedItems
:scope-key="history.id"
:get-item-key="(item) => item.type_id"
v-slot="{ isExpanded, setExpanded }"
v-slot="{ expandedCount, isExpanded, setExpanded, collapseAll }"
>
<SelectedItems
:scope-key="history.id"
@@ -26,16 +26,20 @@
}"
>
<Layout>
<template v-slot:nav>
<slot name="nav"></slot>
<template v-slot:globalNav>
<slot name="globalNav" :history="history"></slot>
</template>
<template v-slot:localNav>
<HistoryMenu :history="history" v-on="$listeners" />
</template>
<template v-slot:details>
<HistoryDetails class="history-details" :history="history" v-on="$listeners" />
<HistoryDetails :history="history" v-on="$listeners" />
</template>
<template v-slot:messages>
<HistoryMessages class="history-messages m-2" :history="history" />
<HistoryMessages class="m-2" :history="history" />
</template>
<template v-slot:listcontrols>
@@ -51,6 +55,8 @@
@resetSelection="resetSelection"
@selectAllContent="selectItems(payload.contents)"
@manualReload="manualReload"
:expanded-count="expandedCount"
@collapseAllContent="collapseAll"
/>
</template>
@@ -65,16 +71,18 @@
:debug="false"
@scroll="setScrollPos"
>
<template v-slot="{ item, index }">
<template v-slot="{ item, index, rowKey }">
<HistoryContentItem
:item="item"
:index="index"
:row-key="rowKey"
:show-selection="showSelection"
:expanded="isExpanded(item)"
@update:expanded="setExpanded(item, $event)"
:selected="isSelected(item)"
@update:selected="setSelected(item, $event)"
@viewCollection="$emit('viewCollection', item)"
:data-hid="item.hid"
/>
</template>
</Scroller>
@@ -101,6 +109,7 @@ import ToolHelpModal from "./ToolHelpModal";
import Scroller from "./Scroller";
import { HistoryContentItem } from "./ContentItem";
import { reportPayload } from "./providers/ContentProvider/helpers";
import HistoryMenu from "./HistoryMenu";
export default {
filters: {
@@ -118,6 +127,7 @@ export default {
HistoryContentItem,
ExpandedItems,
SelectedItems,
HistoryMenu,
},
props: {
history: { type: History, required: true },
+103 -171
View File
@@ -1,215 +1,147 @@
<template>
<header>
<div class="d-flex justify-content-between align-items-center actions">
<h6 class="text-nowrap text-truncate mr-auto">
<span class="size history-size">{{ history.niceSize | localize }}</span>
</h6>
<!--
History object editor. Change name, edit tags, description.
Emit new History model object when done via .sync syntax
-->
<PriorityMenu style="max-width: 50%" :starting-height="28">
<PriorityMenuItem
key="edit-history-tags"
title="Edit History Tags"
class="history-tag-btn"
icon="fas fa-tags"
:pressed="showTags"
@click="showTags = !showTags"
/>
<PriorityMenuItem
key="edit-history-annotation"
title="Edit Annotation"
class="history-annotate-btn"
icon="fas fa-comment"
:pressed="showAnnotation"
@click="showAnnotation = !showAnnotation"
/>
<PriorityMenuItem
key="copy-history"
title="Copy History"
icon="fas fa-copy"
@click="showCopyModal = !showCopyModal"
/>
<PriorityMenuItem
key="share"
title="Share or Publish"
icon="fas fa-share-alt"
@click="backboneRoute('/histories/sharing', { id: history.id })"
/>
<PriorityMenuItem
key="delete-this-history"
title="Delete this History"
icon="fas fa-trash"
@click="showDeleteModal = !showDeleteModal"
/>
<PriorityMenuItem
key="purge-this-history"
title="Purge this History"
icon="fas fa-burn"
@click="showPurgeModal = !showPurgeModal"
/>
<PriorityMenuItem
key="structure"
title="Show Structure"
icon="fas fa-code-branch"
@click="backboneRoute('/histories/show_structure')"
/>
<PriorityMenuItem
key="extract-workflow"
title="Extract Workflow"
icon="fas fa-file-export"
@click="iframeRedirect('/workflow/build_from_current_history')"
/>
<PriorityMenuItem
key="set-permissions"
title="Set Permissions"
icon="fas fa-user-lock"
@click="backboneRoute('/histories/permissions', { id: history.id })"
/>
<PriorityMenuItem
key="make-private"
title="Make Private"
icon="fas fa-lock"
@click="showPrivacyModal = !showPrivacyModal"
/>
<PriorityMenuItem
key="resume-paused-jobs"
title="Resume Paused Jobs"
icon="fas fa-play"
@click="iframeRedirect('/history/resume_paused_jobs?current=True')"
/>
<PriorityMenuItem
key="export-citations"
title="Export Tool Citations"
icon="fas fa-stream"
@click="backboneRoute('/histories/citations', { id: history.id })"
/>
<PriorityMenuItem
key="export-history-to-file"
title="Export History to File"
icon="fas fa-file-archive"
@click="backboneRoute(exportLink)"
/>
</PriorityMenu>
<template>
<section class="history-details d-flex">
<div class="flex-grow-1 overflow-hidden">
<h3 data-description="history name display">{{ historyName || "(History Name)" }}</h3>
<h5 class="history-size">{{ history.size | niceFileSize }}</h5>
<!-- display title, annotation, tags -->
<div v-if="!editing">
<p v-if="annotation" class="mt-1">{{ annotation }}</p>
<HistoryTags class="mt-2" :history="history" :disabled="true" />
</div>
<!-- edit form, change title, annotation, or tags -->
<div v-else class="mt-3">
<b-textarea
class="mb-2"
v-model="historyName"
placeholder="History Name"
trim
max-rows="4"
data-description="history name input"
></b-textarea>
<b-textarea
class="mb-2"
v-model="annotation"
placeholder="Annotation (optional)"
trim
max-rows="4"
data-description="history annotation input"
></b-textarea>
<HistoryTags class="mt-3" :history="history" />
</div>
</div>
<!-- click to edit the name of the history -->
<ClickToEdit
class="history-title mt-2"
tag-name="h3"
v-model="historyName"
:title="'Rename history...' | localize"
v-b-tooltip.hover="{ boundary: 'viewport', placement: 'left' }"
<EditorMenu
class="ml-3 flex-grow-0 d-flex flex-column"
v-if="writable"
model-name="History"
:editing.sync="editing"
:writable="writable"
:valid="valid"
:dirty="dirty"
@save="save"
@revert="revert"
/>
<transition name="shutterfade">
<Annotation v-if="showAnnotation" class="history-annotation mt-2" v-model="annotation" />
</transition>
<transition name="shutterfade">
<HistoryTags v-if="showTags" class="mt-2" :history="history" />
</transition>
<!-- #region modals -->
<CopyHistoryModal v-model="showCopyModal" :history="history" />
<b-modal v-model="showPrivacyModal" title="Make History Private" title-tag="h2" @ok="makePrivate">
<p v-localize>
This will make all the data in this history private (excluding library datasets), and will set
permissions such that all new data is created as private. Any datasets within that are currently shared
will need to be re-shared or published. Are you sure you want to do this?
</p>
</b-modal>
<b-modal v-model="showDeleteModal" title="Delete History?" title-tag="h2" @ok="deleteHistory">
<p v-localize>Really delete the current history?</p>
</b-modal>
<b-modal v-model="showPurgeModal" title="Permanently Delete History?" title-tag="h2" @ok="purgeHistory">
<p v-localize>Really delete the current history permanently? This cannot be undone.</p>
</b-modal>
<!-- #endregion -->
</header>
</section>
</template>
<script>
import prettyBytes from "pretty-bytes";
import { History } from "./model";
import { PriorityMenuItem, PriorityMenu } from "components/PriorityMenu";
import { legacyNavigationMixin } from "components/plugins/legacyNavigation";
import ClickToEdit from "components/ClickToEdit";
import Annotation from "components/Annotation";
import HistoryTags from "./HistoryTags";
import CopyHistoryModal from "./CopyModal";
import EditorMenu from "./EditorMenu";
export default {
mixins: [legacyNavigationMixin],
components: {
PriorityMenu,
PriorityMenuItem,
HistoryTags,
Annotation,
ClickToEdit,
CopyHistoryModal,
EditorMenu,
},
filters: {
niceFileSize(rawSize = 0) {
return prettyBytes(rawSize);
},
},
props: {
history: { type: History, required: true },
writable: { type: Boolean, required: false, default: true },
},
data() {
return {
showTags: false,
showAnnotation: false,
showCopyModal: false,
showPrivacyModal: false,
showDeleteModal: false,
showPurgeModal: false,
editing: false,
tempHistory: this.history.clone(),
};
},
computed: {
historyId() {
return this.history.id;
},
isEditing() {
return this.writable && this.editing;
},
valid() {
return this.tempHistory.name.length > 0;
},
dirty() {
return !this.tempHistory.equals(this.history);
},
annotation: {
get() {
return this.history.annotation || "";
return this.tempHistory.annotation;
},
set(annotation) {
if (annotation.length && annotation !== this.history.annotation) {
this.updateFields({ annotation });
}
this.patchTempHistory({ annotation });
},
},
historyName: {
get() {
return this.history?.name || "";
return this.tempHistory.name;
},
set(name) {
if (name.length && name !== this.history.name) {
this.updateFields({ name });
}
this.patchTempHistory({ name });
},
},
exportLink() {
return `histories/${this.history.id}/export`;
tags: {
get() {
return this.tempHistory.tags || [];
},
set(newTags) {
this.patchTempHistory({ tags: newTags });
},
},
},
watch: {
historyId(newId, oldId) {
if (newId != oldId) {
this.reset();
}
},
},
methods: {
async updateFields(fields = {}) {
this.$emit("updateHistory", { id: this.history.id, ...fields });
reset() {
this.tempHistory = this.history.clone();
},
async deleteHistory(evt) {
this.$emit("deleteHistory", this.history);
evt.vueTarget.hide();
patchTempHistory(props = {}) {
this.tempHistory = this.tempHistory.patch(props);
},
async purgeHistory(evt) {
this.$emit("purgeHistory", this.history);
evt.vueTarget.hide();
save() {
if (this.valid) {
this.$emit("update:history", this.tempHistory);
this.editing = false;
}
},
async makePrivate(evt) {
this.$emit("secureHistory", this.history);
evt.vueTarget.hide();
revert() {
if (this.dirty) {
this.reset();
}
},
},
};
</script>
<style lang="scss">
@import "scss/transitions.scss";
</style>
@@ -0,0 +1,119 @@
<template>
<div>
<b-dropdown size="sm" variant="link" text="Histories" toggle-class="text-decoration-none">
<template v-slot:button-content>
<Icon icon="wrench" />
<span class="sr-only">Operations on the current history</span>
</template>
<b-dropdown-item v-b-modal:copy-history-modal>
<Icon icon="copy" class="mr-1" />
<span v-localize>Copy this History</span>
</b-dropdown-item>
<b-dropdown-item v-b-modal:delete-history-modal>
<Icon icon="trash" class="mr-1" />
<span v-localize>Delete this History</span>
</b-dropdown-item>
<b-dropdown-item v-b-modal:purge-history-modal>
<Icon icon="burn" class="mr-1" />
<span v-localize>Purge this History</span>
</b-dropdown-item>
<b-dropdown-divider></b-dropdown-divider>
<b-dropdown-item @click="iframeRedirect('/history/resume_paused_jobs?current=True')">
<Icon icon="play" class="mr-1" />
<span v-localize>Resume Paused Jobs</span>
</b-dropdown-item>
<b-dropdown-item @click="iframeRedirect('/workflow/build_from_current_history')">
<Icon icon="file-export" class="mr-1" />
<span v-localize>Extract Workflow</span>
</b-dropdown-item>
<b-dropdown-item @click="backboneRoute('/histories/show_structure')">
<Icon icon="code-branch" class="mr-1" />
<span v-localize>Show Structure</span>
</b-dropdown-item>
<b-dropdown-divider></b-dropdown-divider>
<b-dropdown-item @click="backboneRoute('/histories/sharing', { id: history.id })">
<Icon icon="share-alt" class="mr-1" />
<span v-localize>Share or Publish</span>
</b-dropdown-item>
<b-dropdown-item @click="backboneRoute('/histories/permissions', { id: history.id })">
<Icon icon="user-lock" class="mr-1" />
<span v-localize>Set Permissions</span>
</b-dropdown-item>
<b-dropdown-item v-b-modal:history-privacy-modal>
<Icon icon="lock" class="mr-1" />
<span v-localize>Make Private</span>
</b-dropdown-item>
<b-dropdown-divider></b-dropdown-divider>
<b-dropdown-item @click="backboneRoute('/histories/citations', { id: history.id })">
<Icon icon="stream" class="mr-1" />
<span v-localize>Export Tool Citations</span>
</b-dropdown-item>
<b-dropdown-item @click="backboneRoute(history.exportLink)">
<Icon icon="file-archive" class="mr-1" />
<span v-localize>Export History to File</span>
</b-dropdown-item>
</b-dropdown>
<!-- #region modals -->
<CopyHistoryModal id="copy-history-modal" :history="history" />
<b-modal
id="history-privacy-modal"
title="Make History Private"
title-tag="h2"
@ok="$emit('secureHistory', history)"
>
<p v-localize>
This will make all the data in this history private (excluding library datasets), and will set
permissions such that all new data is created as private. Any datasets within that are currently shared
will need to be re-shared or published. Are you sure you want to do this?
</p>
</b-modal>
<b-modal id="delete-history-modal" title="Delete History?" title-tag="h2" @ok="$emit('deleteHistory', history)">
<p v-localize>Really delete the current history?</p>
</b-modal>
<b-modal
id="purge-history-modal"
title="Permanently Delete History?"
title-tag="h2"
@ok="$emit('purgeHistory', history)"
>
<p v-localize>Really delete the current history permanently? This cannot be undone.</p>
</b-modal>
<!-- #endregion -->
</div>
</template>
<script>
import { History } from "./model";
import CopyHistoryModal from "./CopyModal";
import { legacyNavigationMixin } from "components/plugins/legacyNavigation";
export default {
mixins: [legacyNavigationMixin],
components: {
CopyHistoryModal,
},
props: {
history: { type: History, required: true },
},
};
</script>
@@ -0,0 +1,106 @@
<template>
<b-modal ref="modal" v-on="$listeners" v-bind="$attrs" :title="'Switch to History' | l">
<b-form-group :description="'Filter histories' | l">
<b-input v-model="filter" :placeholder="'Search Filter' | l" />
</b-form-group>
<b-table
ref="history-list"
striped
hover
sticky-header="50vh"
v-model="currentRows"
primary-key="id"
:fields="fields"
:filter="filter"
:items="histories"
:per-page="perPage"
:current-page="currentPage"
:selectable="true"
select-mode="single"
selected-variant="success"
@row-selected="switchToHistory"
>
<template v-slot:cell(tags)="row">
<stateless-tags :value="row.item.tags" :disabled="true" />
</template>
<template v-slot:cell(update_time)="data">
<UtcDate :date="data.value" mode="elapsed" />
</template>
</b-table>
<template v-slot:modal-footer>
<b-pagination v-model="currentPage" :total-rows="filteredRowCount" :per-page="perPage"></b-pagination>
</template>
</b-modal>
</template>
<script>
import { StatelessTags } from "components/Tags";
import UtcDate from "components/UtcDate";
import { debounce } from "underscore";
export default {
components: {
StatelessTags,
UtcDate,
},
props: {
currentHistory: { type: Object, required: true },
histories: { type: Array, default: () => [] },
perPage: { type: Number, required: false, default: 50 },
},
data() {
return {
filter: null,
currentPage: 1,
currentRows: [],
};
},
computed: {
filteredRowCount() {
return this.currentRows.length;
},
selectedIndex() {
return this.currentRows.findIndex((h) => h.id == this.currentHistory.id);
},
},
created() {
this.fields = [
{ key: "name", sortable: true },
{ key: "tags", sortable: true },
{ key: "update_time", label: "Updated", sortable: true },
];
this.debounceSelectCurrentRow = debounce(this.selectCurrentRow, 100);
},
watch: {
currentRows() {
this.selectCurrentRow();
},
filteredRowCount(newVal, oldVal) {
if (newVal != oldVal) {
this.currentPage = 1;
}
},
selectedIndex(idx, oldIdx) {
if (idx != oldIdx) {
this.debounceSelectCurrentRow();
}
},
},
methods: {
switchToHistory(selected) {
if (selected.length == 1) {
this.$emit("selectHistory", selected[0]);
}
},
selectCurrentRow() {
const idx = this.selectedIndex;
const list = this.$refs["history-list"];
if (list && idx > -1) {
list.selectRow(idx);
}
},
},
};
</script>
@@ -1,5 +1,5 @@
<template>
<Tags :tags="history.tags" :store-key="storeKey" :tag-service="historyTagService" />
<Tags :tags="history.tags" :store-key="storeKey" :tag-service="historyTagService" :disabled="disabled" />
</template>
<script>
@@ -48,6 +48,7 @@ export default {
components: { Tags },
props: {
history: { type: History, required: true },
disabled: { type: Boolean, required: false, default: false },
},
computed: {
storeKey() {
+20 -39
View File
@@ -4,14 +4,17 @@ amateur-hour baby-tool like Bootstrap instead of taking the afternoon
to learn how CSS is supposed to work. -->
<template>
<section class="history d-flex flex-column">
<header class="p-2">
<!-- optional slot for injecting global navigation -->
<nav v-if="$slots.nav" class="top-nav pb-2">
<slot name="nav"></slot>
<section class="history beta d-flex flex-column">
<header>
<nav class="d-flex justify-content-between bg-secondary">
<slot name="globalNav">Global Nav Default</slot>
<slot name="localNav">Local nav Default</slot>
</nav>
<!-- details about the thing you're looking at -->
<slot name="details"></slot>
<div class="px-3 py-4 details">
<slot name="details"></slot>
</div>
</header>
<!-- warnings, messages, dirty limericks -->
@@ -20,12 +23,12 @@ to learn how CSS is supposed to work. -->
</section>
<!-- controls atop the list -->
<section v-if="$slots.listcontrols" class="p-2">
<section v-if="$slots.listcontrols" class="list-controls">
<slot name="listcontrols"></slot>
</section>
<!-- scrolling list -->
<section class="scroller flex-grow-1">
<section class="position-relative flex-grow-1 scroller">
<slot name="listing"></slot>
</section>
@@ -37,13 +40,8 @@ to learn how CSS is supposed to work. -->
</template>
<style lang="scss">
@import "scss/mixins.scss";
@import "scss/transitions.scss";
@import "scss/loadingBackground.scss";
/* TODO: css reset? */
.history {
.history.beta {
/* TODO: css reset? */
h1,
h2,
h3,
@@ -52,31 +50,14 @@ to learn how CSS is supposed to work. -->
h6 {
margin: 0;
}
}
/*
container css for first child of top-nav,
flex horizontally
.list-controls {
margin-bottom: 2px;
}
Vue can't put in multi-root slot contents so we have to do some kung-fu to get
the layout right on the top-nav. There will have to be an extra div container
around the contents of the nav slot, so we'll style that right here so the user
doesn't need to do it manually every time. */
.history > header > .top-nav > * {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.history .messages:empty {
display: none;
}
/* make sure scroller actually scrolls */
.history .scroller {
position: relative;
.list-controls:empty,
.messages:empty {
display: none;
}
}
</style>
+50 -28
View File
@@ -3,9 +3,14 @@
<!-- mousewheel moves up and down linearly, allows for fine-tuning of view by moving just a
few rows at a time -->
<div ref="listing" class="listing">
<ul>
<li v-for="(item, index) in itemWindow" :key="getItemKey(item, index)">
<slot :item="item" :index="index" :row-key="getItemKey(item, index)">
<ul class="list-unstyled m-0">
<li
v-for="(item, index) in itemWindow"
:key="getItemKey(item)"
:data-row-index="index"
:data-row-key="getItemKey(item)"
>
<slot :item="item" :index="index" :row-key="getItemKey(item)">
<pre>{{ item }}</pre>
</slot>
</li>
@@ -15,6 +20,7 @@
<!-- big blank scroll region uses scroll bar to drag view to entirely new region, calculated
by visual height of the scroller knob --->
<div
v-if="overlayActive"
v-show="showScroller"
ref="scrollSliderContainer"
class="scrollSliderContainer"
@@ -23,6 +29,12 @@
>
<div ref="scrollSlider"></div>
</div>
<!-- debugging overlay -->
<div v-if="debug" class="debugOverlay">
<pre>{{ $attrs }}</pre>
<pre>{{ debugProps }}</pre>
</div>
</div>
</template>
@@ -75,10 +87,26 @@ export default {
// width of scroll bar container
sbWidth: 30,
// whether to activate the 2nd scrollbar which does a fast-seek on large histories
overlayActive: true,
};
},
computed: {
debugProps() {
return {
manualStartIndex: this.manualStartIndex,
keyField: this.keyField,
contentsLength: this.contents.length,
startKeyIndex: this.startKeyIndex,
totalMatches: this.totalMatches,
topRows: this.topRows,
bottomRows: this.bottomRows,
pageSize: this.pageSize,
};
},
// The index to start rendering content at, can be manually adjusted with
// the mouse wheel, and must be updated when new contents come in
itemStartIndex() {
@@ -144,7 +172,7 @@ export default {
// non-reactive data
this.suppressEvents = false;
this.suppressionTimeout = null;
this.debouncedUpdateCursor = debounce(this.updateCursor, 100);
// this.debouncedUpdateCursor = debounce(this.updateCursor, 100);
this.debouncedAdjustScrollTop = debounce(this.adjustScrollTop, 100);
},
@@ -193,7 +221,8 @@ export default {
// value which we send out on scrollPos
onScroll() {
this.debouncedUpdateCursor();
// this.debouncedUpdateCursor();
this.updateCursor();
},
// determines cursor from scrollTop, need to wait until after render to calculate
@@ -290,22 +319,15 @@ export default {
</script>
<style lang="scss">
@import "scss/mixins.scss";
.scrollContainer,
.listing,
.scrollSliderContainer {
@include absfill();
}
.scrollContainer {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
.listing,
.scrollSliderContainer {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.listing {
overflow-y: scroll;
z-index: 0;
@@ -326,14 +348,14 @@ export default {
}
}
ul {
list-style: none;
}
ul,
li {
padding: 0;
margin: 0;
.debugOverlay {
background: rgba(255, 255, 255, 0.75);
z-index: 2;
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: auto;
}
}
</style>
+32 -59
View File
@@ -1,82 +1,55 @@
<template>
<StateBtn
v-if="inState(STATES.RUNNING)"
:state="state"
icon="upload-symbol upload-icon fa fa-spinner fa-spin"
<IconButton
v-if="stateIcon"
v-b-tooltip
:title="theTitle"
:icon="stateIcon"
:variant="variant"
v-on="$listeners"
v-bind="$attrs"
/>
<StateBtn v-else-if="inState(STATES.QUEUED, STATES.NEW)" :state="state" icon="fas fa-clock" v-on="$listeners" />
<StateBtn v-else-if="inState(STATES.PAUSED)" :state="state" icon="fas fa-pause" v-on="$listeners" />
<StateBtn v-else-if="inState(STATES.ERROR)" :state="state" icon="fas fa-exclamation-triangle" v-on="$listeners" />
<StateBtn v-else-if="inState(STATES.MIXED)" :state="state" icon="fas fa-exclamation-circle" v-on="$listeners" />
<span v-else class="sr-only">
{{ state | localize }}
</span>
</template>
<script>
import capitalize from "underscore.string/capitalize";
import IconButton from "components/IconButton";
export const StateBtn = {
template: `
<b-button class="state-button"
v-b-tooltip
:title="theTitle | localize"
size="sm"
variant="link"
v-on="$listeners"
v-bind="$attrs">
<i :class="icon" />
<span class="sr-only">{{ theTitle | localize }}</span>
</b-button>
`,
export default {
inject: ["STATES"],
components: {
IconButton,
},
props: {
state: { type: String, required: true },
icon: { type: String, required: true },
title: { type: String, required: false, default: "" },
variant: { type: String, required: false, default: "link" },
},
inject: ["STATES"],
computed: {
stateIcon() {
if (this.inState(this.STATES.QUEUED, this.STATES.NEW)) {
return "clock";
}
if (this.inState(this.STATES.PAUSED)) {
return "pause";
}
if (this.inState(this.STATES.ERROR)) {
return "exclamation-triangle";
}
if (this.inState(this.STATES.MIXED)) {
return "exclamation-circle";
}
if (this.inState(this.STATES.OK)) {
return "check";
}
return null;
},
theTitle() {
return this.title || capitalize(this.state);
},
},
};
export const StatusIcon = {
components: {
StateBtn,
},
props: {
state: { type: String, required: true },
},
inject: ["STATES"],
methods: {
inState(...states) {
return states.includes(this.state);
},
},
};
export default StatusIcon;
</script>
<style lang="css">
/* Override ham-fisted base css rules */
button.state-button,
button.state-button.disabled,
button.state-button:disabled,
button.state-button:focus,
button.state-button:hover,
button.state-button:not(:disabled):active {
background-color: transparent !important;
border-style: none !important;
box-shadow: none !important;
}
</style>
@@ -10,13 +10,14 @@ import { bulkCacheDscContent } from "./db";
import { SearchParams } from "../model/SearchParams";
import { prependPath } from "./workerConfig";
import { summarizeCacheOperation, dateStore } from "./loadHistoryContents";
import { show } from "utils/observable";
/**
* Load collection content (drill down)
* Params: contents_url + search params + element_index
*/
export const loadDscContent = (cfg = {}) => {
// const { onceEvery = 10 * 1000 } = cfg;
const { debug = false } = cfg;
return publish((inputs$) => {
const url$ = inputs$.pipe(nth(0));
@@ -24,6 +25,7 @@ export const loadDscContent = (cfg = {}) => {
return inputs$.pipe(
map(buildDscContentUrl),
map(prependPath),
show(debug, (url) => console.log("Sending collection request:", url)),
requestWithUpdateTime({ dateStore }),
pluck("response"),
withLatestFrom(url$),
@@ -1,42 +0,0 @@
import { pipe } from "rxjs";
import { map } from "rxjs/operators";
/**
* Bust incoming numeric source values into blocks of designated size.
*
* @param {*} chunkSize
* @param {*} ceil
*/
// prettier-ignore
export const chunk = (chunkSize, ceil = false) => pipe(
map((chunkMe) => {
const rawVal = chunkMe / chunkSize;
return chunkSize * (ceil ? Math.ceil(rawVal) : Math.floor(rawVal));
})
);
/**
* Change one parameter to be an multiple of indicated block size. Used to
* regulate the URLs we send to the server so that some will be cached. Pos is
* the position in the combined inputs array, and the chunkSize is the size of
* the block to break the value into.
*
* Ex: Source Inputs [ x, y, z, 750 ],
* chunkParam(3, 200)
* Results in [ x, y, z, 600]
*
* @param {integer} pos Input array parameter number to chunk
* @param {integer} chunkSize Size of chunks
* @param {Boolean} ceil Math.ceil or Math.floor chunk value
*/
// prettier-ignore
export const chunkParam = (pos, chunkSize, ceil = false) => pipe(
map((inputs) => {
const chunkMe = inputs[pos];
const rawVal = 1.0 * chunkMe / chunkSize;
const chunkedVal = chunkSize * (ceil ? Math.ceil(rawVal) : Math.floor(rawVal));
const newInputs = inputs.slice();
newInputs[pos] = chunkedVal;
return newInputs;
})
);
@@ -5,6 +5,7 @@
*/
import { Content } from "./Content";
import { scrubModelProps } from "utils/safeAssign";
export class Dataset extends Content {
getUrl(urlType) {
@@ -40,4 +41,28 @@ export class Dataset extends Content {
const { display_apps = [], display_types = [] } = this;
return [...display_apps, ...display_types];
}
clone() {
const newProps = cleanDatasetProps(this);
return new Dataset(newProps);
}
patch(newProps) {
const cleanProps = cleanDatasetProps({ ...this, ...newProps });
return new Dataset(cleanProps);
}
equals(other) {
return Dataset.equals(this, other);
}
}
Dataset.equals = function (a, b) {
return JSON.stringify(a) == JSON.stringify(b);
};
const scrubber = scrubModelProps(Dataset);
export const cleanDatasetProps = (props = {}) => {
const cleanProps = JSON.parse(JSON.stringify(props));
return scrubber(cleanProps);
};
@@ -7,11 +7,11 @@
import { Content } from "./Content";
import { JobStateSummary } from "./JobStateSummary";
import { scrubModelProps } from "utils/safeAssign";
export class DatasetCollection extends Content {
loadProps(raw = {}) {
if (!raw.contents_url) {
// console.log("ouch", raw);
throw new Error("missing contents_url", raw);
}
super.loadProps(raw);
@@ -64,8 +64,28 @@ export class DatasetCollection extends Content {
get jobSummary() {
return new JobStateSummary(this);
}
clone() {
const newProps = cleanDscProps(this);
return new DatasetCollection(newProps);
}
patch(newProps) {
const cleanProps = cleanDscProps({ ...this, ...newProps });
return new DatasetCollection(cleanProps);
}
equals(other) {
return DatasetCollection.equals(this, other);
}
}
DatasetCollection.equals = function (a, b) {
return JSON.stringify(a) == JSON.stringify(b);
};
const scrubber = scrubModelProps(DatasetCollection);
export const cleanDscProps = (props = {}) => {
const cleanProps = JSON.parse(JSON.stringify(props));
return scrubber(cleanProps);
};
@@ -1,4 +1,5 @@
import { dateMixin, ModelBase } from "./ModelBase";
import { scrubModelProps } from "utils/safeAssign";
export class History extends dateMixin(ModelBase) {
// not deleted
@@ -36,8 +37,32 @@ export class History extends dateMixin(ModelBase) {
}
return status.join(", ");
}
get exportLink() {
return `histories/${this.id}/export`;
}
clone() {
const newProps = cleanHistoryProps(this);
return new History(newProps);
}
patch(newProps) {
const cleanProps = cleanHistoryProps({ ...this, ...newProps });
return new History(cleanProps);
}
equals(other) {
return History.equals(this, other);
}
}
History.equals = function (a, b) {
return JSON.stringify(a) == JSON.stringify(b);
};
const scrubber = scrubModelProps(History);
export const cleanHistoryProps = (props = {}) => {
const cleanProps = JSON.parse(JSON.stringify(props));
return scrubber(cleanProps);
};
@@ -0,0 +1,101 @@
import { History, cleanHistoryProps } from "./History";
describe("History model", () => {
const initialProps = {
id: "abc123",
name: "Foo",
tags: ["a", "b", "c"],
annotation: "I am a test history",
};
const h = new History(initialProps);
describe("clone", () => {
const c = h.clone();
it("must be able to clone itself for use in editors", () => {
// should be == but not ===
expect(h).not.toBe(c);
expect(c).toBeInstanceOf(History);
expect(History.equals(h, c)).toBeTruthy();
});
it("should have the same props before and after cloning", () => {
expect(h.id).toEqual(c.id);
expect(h.name).toEqual(c.name);
expect(h.tags).toEqual(c.tags);
expect(h.annotation).toEqual(c.annotation);
});
});
describe("cleanHistoryProps", () => {
it("should ignore computed prop names during cloning", () => {
const sloppyProps = {
...initialProps,
// these are all computed values in the history class which are defined by getters,
// if we feed thsm into the constructor they should be ignored
statusDescription: "123",
totalItems: Number.POSITIVE_INFINITY,
hidItems: "four",
active: "The Fonz",
};
const cleanProps = cleanHistoryProps(sloppyProps);
expect(cleanProps).toEqual(initialProps);
expect(cleanProps).not.toBe(initialProps);
expect(cleanProps).not.toHaveProperty("statusDescription");
expect(cleanProps).not.toHaveProperty("totalItems");
expect(cleanProps).not.toHaveProperty("hidItems");
expect(cleanProps).not.toHaveProperty("active");
const h = new History(cleanProps);
expect(h.statusDescription).not.toEqual(sloppyProps.statusDescription);
});
});
describe("patch", () => {
const patchVals = {
name: "i was patched",
annotation: "patched annotation",
tags: ["foo", "bar", "blech"],
};
const clone = h.patch(patchVals);
it("should return a new instance of History", () => {
expect(h).not.toBe(clone);
expect(clone).toBeInstanceOf(History);
expect(History.equals(h, clone)).toBeFalsy();
});
it("should patch in passed properties", () => {
expect(clone.name).toEqual(patchVals.name);
expect(clone.annotation).toEqual(patchVals.annotation);
expect(clone.tags).toEqual(patchVals.tags);
});
});
describe("equivalence comparison", () => {
it("should recognize different props", () => {
const hDifferent = new History({ id: "def345" });
expect(History.equals(h, hDifferent)).toBeFalsy();
});
it("equivalence should work fine with cloned histories", () => {
const clone = h.clone();
// different object
expect(h).not.toBe(clone);
// but equivalent because props the same
expect(History.equals(h, clone)).toBeTruthy();
});
it("should work with patch", () => {
const clone = h.patch({ id: "floobar" });
// different object
expect(h).not.toBe(clone);
// not equivalent because different val
expect(History.equals(h, clone)).toBeFalsy();
});
});
});
+3 -1
View File
@@ -8,7 +8,9 @@ import { bulkContentUpdate, getAllContentByFilter } from "./queries";
export const updateSelectedContent = (updates) => async (history, type_ids) => {
// single ajax call with wierd syntax because.... reasons
const changes = await bulkContentUpdate(history, type_ids, updates);
await bulkCacheContent(changes);
const cacheResult = await bulkCacheContent(changes, true);
console.log("cacheResult", changes, cacheResult);
return cacheResult;
};
export const hideSelectedContent = updateSelectedContent({
+14 -7
View File
@@ -8,6 +8,7 @@
import axios from "axios";
import moment from "moment";
import { prependPath } from "utils/redirect";
import { History } from "./History";
// #region setup & utils
@@ -87,9 +88,10 @@ const stdHistoryParams = {
* Return list of available histories
*/
export async function getHistoryList() {
const params = { view: "summary" };
const params = { view: "summary", keys: "size" };
const response = await api.get("/histories", { params });
return doResponse(response);
const rawList = doResponse(response);
return rawList.map((props) => new History(props));
}
/**
@@ -101,7 +103,8 @@ export async function getHistoryById(id, since) {
const sinceParam = since !== undefined ? moment.utc(since).toISOString() : null;
const url = sinceParam ? `${path}?q=update_time-gt&qv=${sinceParam}` : path;
const response = await api.get(url, { params: stdHistoryParams });
return doResponse(response);
const props = doResponse(response);
return new History(props);
}
/**
@@ -121,7 +124,8 @@ export async function createNewHistory(props = {}) {
if (!id) {
throw new Error("failed to create and select new history");
}
return doResponse(createResponse);
const newHistoryProps = doResponse(createResponse);
return new History(newHistoryProps);
}
/**
@@ -139,7 +143,8 @@ export async function cloneHistory(history, name, copyAll) {
current: true,
};
const response = await api.post(url, payload, { params: stdHistoryParams });
return doResponse(response);
const clonedProps = doResponse(response);
return new History(clonedProps);
}
/**
@@ -171,7 +176,8 @@ export async function undeleteHistoryById(id) {
export async function updateHistoryFields(id, payload) {
const url = `/histories/${id}`;
const response = await api.put(url, payload, { params: stdHistoryParams });
return doResponse(response);
const props = doResponse(response);
return new History(props);
}
/**
@@ -202,7 +208,8 @@ export async function getCurrentHistoryFromServer() {
const response = await api.get(url, {
baseURL: prependPath("/"), // old api doesn't use api path
});
return doResponse(response);
const props = doResponse(response);
return new History(props);
}
export async function setCurrentHistoryOnServer(history_id) {
@@ -1,10 +1,14 @@
import { DatasetCollection } from "../../model";
import { SearchParams, DatasetCollection } from "../../model";
import { ContentProvider, processContentStreams } from "../ContentProvider";
import { collectionPayload } from "./collectionPayload";
export default {
mixins: [ContentProvider],
props: {
params: { type: SearchParams, default: () => new SearchParams({ showHidden: false }) },
},
computed: {
dsc() {
if (this.parent instanceof DatasetCollection) {
@@ -1,5 +1,5 @@
import { of, Observable, Subject, partition, merge } from "rxjs";
import { tap, map, pluck, switchMap, publish, distinctUntilChanged, share } from "rxjs/operators";
import { tap, map, pluck, switchMap, publish, distinctUntilChanged, share, throttleTime } from "rxjs/operators";
import { chunk } from "utils/observable";
import { SearchParams } from "../../model/SearchParams";
import { loadDscContent } from "../../caching";
@@ -13,6 +13,8 @@ export const collectionPayload = (cfg = {}) => {
pageSize = SearchParams.pageSize,
debug = false,
loadingEvents$ = new Subject(),
debouncePeriod = 100,
chunkSize = pageSize
} = cfg;
const { totalElements: totalMatches, contents_url } = dsc;
@@ -34,7 +36,7 @@ export const collectionPayload = (cfg = {}) => {
// chunk the input to the server request so we don't request for every single mouse move
const chunked$ = elementIndex$.pipe(
chunk(pageSize, false),
chunk(chunkSize),
distinctUntilChanged(),
);
@@ -48,6 +50,7 @@ export const collectionPayload = (cfg = {}) => {
);
const serverLoad$ = pagination$.pipe(
throttleTime(debouncePeriod, undefined, { trailing: true }),
switchMap(pagination => of([contents_url, filters, pagination]).pipe(
tap(() => loadingEvents$.next(true)),
loadDscContent({ debug }),
@@ -14,7 +14,7 @@ import {
timeoutWith,
ignoreElements,
} from "rxjs/operators";
import { chunk, show, debounceBurst, shareButDie } from "utils/observable";
import { show, debounceBurst, shareButDie } from "utils/observable";
import { SearchParams } from "../../model/SearchParams";
import { processContentUpdate, newUpdateMap, buildContentResult, getKeyForUpdateMap } from "./aggregation";
import { SEEK } from "../../caching/enums";
@@ -38,11 +38,6 @@ export const aggregateCacheUpdates = (monitor, cfg = {}) => (src$) => {
keyField = "hid",
keyDirection = SEEK.DESC,
// interval in which we create new monitors
// if we chunk the inputs then we won't make as many
// might be easier on memory
inputChunk = Math.floor(pageSize / 2),
// reduces duplicate updates to the skiplist
// which can be expensive
updateGroupLifetime = 2 * debouncePeriod,
@@ -59,7 +54,6 @@ export const aggregateCacheUpdates = (monitor, cfg = {}) => (src$) => {
// avoids creating a new monitor for every single key emission
// chunk to ceiling if descending, floor if ascending
const monitorInputKey$ = targetKey$.pipe(
chunk(inputChunk, keyDirection == SEEK.DESC),
distinctUntilChanged(),
show(debug, (key) => console.log("monitorInputKey", key)),
);
@@ -76,10 +70,10 @@ export const aggregateCacheUpdates = (monitor, cfg = {}) => (src$) => {
// when we're just going to change it again shortly
const groupedUpdates$ = cacheUpdates$.pipe(
groupBy(
update => update.key,
update => update,
update => update.key,
update => update,
updateByKey$ => updateByKey$.pipe(
timeoutWith(updateGroupLifetime, EMPTY),
timeoutWith(updateGroupLifetime, EMPTY),
ignoreElements()
)
),
@@ -104,7 +98,7 @@ export const aggregateCacheUpdates = (monitor, cfg = {}) => (src$) => {
withLatestFrom(targetKey$),
);
// query skiplist with scroll position (transformed into a key from the skiplist)
// query skiplist with scroll position (transformed into a key from the skiplist)
// to produce list of nearby content
const contentList$ = contentListInputs$.pipe(
map(buildContentResult({ pageSize, keyDirection, getKey })),
@@ -1,9 +1,9 @@
/**
* General plumbing for the 2 types of content providers. This funcction emits 3 observables:
* General plumbing for the 2 types of content providers. This function returns 3 observables:
* loading, scrolling, and payload. Payload's the most important and the operator that genrates the
* payload from the inputs is passed in as a parameter to this factory.
*/
import { debounceTime, distinctUntilChanged, share, startWith, switchMap } from "rxjs/operators";
import { distinctUntilChanged, share, startWith, switchMap } from "rxjs/operators";
import { activity, whenAny, show } from "utils/observable";
import { propMatch } from "./helpers";
import { SearchParams, ScrollPos } from "../../model";
@@ -35,7 +35,6 @@ export function processContentStreams(payloadOperator, sources = {}, settings =
show(debug, (params) => console.log('processContentStreams: params changed', params)),
);
const pos$ = sources.scrollPos$.pipe(
debounceTime(debouncePeriod),
show(debug, (pos) => console.log('processContentStreams: pos changed', pos)),
distinctUntilChanged(ScrollPos.equals),
);
@@ -48,7 +47,7 @@ export function processContentStreams(payloadOperator, sources = {}, settings =
activity(debouncePeriod),
startWith(true)
);
const resetPos$ = new Subject();
// The actual loader, when history or params change we poll against the api and
@@ -1,3 +1,5 @@
import { saveSet, loadSet } from "./setCache";
export default {
props: {
scopeKey: { type: String, required: true },
@@ -8,6 +10,11 @@ export default {
items: new Set(),
};
},
computed: {
expandedCount() {
return this.items.size;
},
},
methods: {
isExpanded(item) {
const key = this.getItemKey(item);
@@ -29,11 +36,23 @@ export default {
this.reset();
}
},
items(newSet) {
saveSet(this.key, newSet);
},
},
created() {
this.key = "expanded-history-items";
const cachedSet = loadSet(this.key);
if (cachedSet) {
this.items = cachedSet;
}
},
render() {
return this.$scopedSlots.default({
isExpanded: this.isExpanded,
setExpanded: this.setExpanded,
collapseAll: this.reset,
expandedCount: this.expandedCount,
});
},
};
@@ -34,10 +34,10 @@ import { defaultPayload } from "../ContentProvider";
*/
// prettier-ignore
export const contentPayload = (cfg = {}) => {
const {
parent: history,
const {
parent: history,
filters = new SearchParams(),
pageSize = SearchParams.pageSize,
pageSize = SearchParams.pageSize,
debouncePeriod = 250,
loadingTimeout = 2000,
disablePoll = false,
@@ -45,6 +45,7 @@ export const contentPayload = (cfg = {}) => {
// optional feedback indicators
loadingEvents$ = new Subject(),
resetPos$ = new Subject(),
chunkSize = pageSize
} = cfg;
// These running totals are shared between instances of content payload because a lot of the
@@ -78,14 +79,15 @@ export const contentPayload = (cfg = {}) => {
filter(hid => !isNaN(hid)),
share(),
);
// #endregion
// #region server loading
const serverHid$ = hid$.pipe(
chunk(pageSize, true),
chunk({ chunkSize, debug, label: "serverHid" }),
distinctUntilChanged(),
debounceTime(debouncePeriod),
show(debug, hid => console.log("serverHid", hid)),
share(),
);
@@ -109,7 +111,7 @@ export const contentPayload = (cfg = {}) => {
mergeAll(),
debounceTime(100),
);
const cacheMonitor$ = cacheHid$.pipe(
watchHistoryContents({ history, filters, pageSize, debouncePeriod, debug }),
show(debug, (result) => console.log("cacheMonitor.contents (hid)", result.contents.map(o => o.hid))),
@@ -133,7 +135,7 @@ export const contentPayload = (cfg = {}) => {
// #endregion
// #region Reposition View to accomodate recent updates
// When new updates to the history come in, we should shift the scrollPos so that we can see
// them. Without some kind of explicit move, the system will think they're just random
// updates that are happening outside the region of interest, and there would be no
@@ -151,7 +153,7 @@ export const contentPayload = (cfg = {}) => {
const fudge = 2;
const scrollNearLastTop = Math.abs(hid - lastResponse.maxContentHid) < fudge;
const scrollerAtTop = scrollerInDefault || scrollNearLastTop;
// console.group("should i reposition?");
// console.log("updatesAtTop", updatesAtTop);
// console.log("scrollerAtTop", scrollerAtTop);
@@ -190,7 +192,7 @@ export const contentPayload = (cfg = {}) => {
const sub = payload$.subscribe(obs);
// Tie stat feedback subscriptions to main payload sub
// Make sure not to combineLatest these subject accumulators or we'll
// Make sure not to combineLatest these subject accumulators or we'll
// end up with an infinite loop
sub.add(newCursorToHid$.subscribe(cursorToHid$));
sub.add(newHidToTopRows$.subscribe(hidToTopRows$));
@@ -43,6 +43,10 @@ export default {
"loadUserHistories",
"secureHistory",
]),
updateCurrentHistory(updates) {
this.updateHistory({ ...updates, id: this.currentHistory.id });
},
},
watch: {
// when user changes reload histories
@@ -65,7 +69,7 @@ export default {
// currently selected history object, should be a full object not just a summary
currentHistory: this.currentHistoryModel,
currentHistoryId: this.currentHistoryModel?.id || null,
currentHistoryId: this.currentHistoryId,
handlers: {
// Updates the history in the store without a trip to the server, in the event that a
@@ -81,7 +85,11 @@ export default {
// save new history params should be an object with an id property and any additional
// properties that are to be updated on the server. A full history object is not required
updateHistory: this.updateHistory,
updateCurrentHistory: () => this.updateHistory(this.currentHistory),
updateCurrentHistory: this.updateCurrentHistory,
// also conform to .sync event name format
"update:history": this.updateHistory,
"update:currentHistory": this.updateCurrentHistory,
// delete history then clear currentHistoryId
deleteHistory: (history) => this.deleteHistory({ history }),
@@ -0,0 +1,22 @@
export function saveSet(key, setObject) {
try {
const setToArray = Array.from(setObject);
const json = JSON.stringify(setToArray);
sessionStorage.setItem(key, json);
} catch (err) {
console.warn("Error saving set to cache", key, err);
sessionStorage.removeItem(key);
}
}
export function loadSet(key) {
try {
const arrJson = sessionStorage.getItem(key) || [];
const cachedArray = JSON.parse(arrJson);
return Array.isArray(cachedArray) ? new Set(cachedArray) : new Set();
} catch (err) {
console.warn("Error loading cached list", key, err);
sessionStorage.removeItem(key);
return null;
}
}
+51
View File
@@ -0,0 +1,51 @@
<template>
<b-button
class="galaxy-icon-button"
v-bind="$attrs"
v-on="$listeners"
:title="title"
:size="size"
:pressed="pressed"
:disabled="disabled"
:variant="localVariant"
>
<Icon :icon="icon" />
<span class="sr-only">{{ title | l }}</span>
</b-button>
</template>
<script>
export default {
props: {
icon: { type: [String, Array, Object], required: true },
title: { type: String, required: true },
size: { type: String, required: false, default: "sm" },
pressed: { type: Boolean, required: false, default: false },
disabled: { type: Boolean, required: false, default: false },
// overrides variant set by pressed/disabled
variant: { type: String, required: false, default: null },
},
computed: {
localVariant() {
if (this.variant) {
// override with prop
return this.variant;
}
if (this.disabled) {
return "secondary";
}
return this.pressed ? "info" : "secondary";
},
},
};
</script>
<style lang="scss">
// removes irritating bootstrap outline
.galaxy-icon-button.btn {
box-shadow: none !important;
&.disabled > svg {
opacity: 0.5;
}
}
</style>
@@ -15,6 +15,23 @@
>
<span class="fa fa-download" />
</b-button>
<CurrentUser v-slot="{ user }">
<UserHistories v-if="user" :user="user" v-slot="{ currentHistoryId }">
<b-button
v-if="currentHistoryId"
@click="onCopyCollection(currentHistoryId)"
href="#"
role="button"
variant="link"
title="Import Collection"
type="button"
class="py-0 px-1"
v-b-tooltip.hover
>
<span class="fa fa-file-import" />
</b-button>
</UserHistories>
</CurrentUser>
</span>
<span>
<span>Dataset Collection:</span>
@@ -24,6 +41,9 @@
<b-card-body>
<LoadingSpan v-if="loading" message="Loading Collection" />
<div v-else class="content-height">
<b-alert v-if="!!messageText" :variant="messageVariant" show>
{{ messageText }}
</b-alert>
<CollectionTree :node="itemContent" :skip-head="true" />
</div>
</b-card-body>
@@ -32,14 +52,20 @@
</template>
<script>
import { getAppRoot } from "onload/loadConfig";
import axios from "axios";
import { getAppRoot } from "onload/loadConfig";
import CollectionTree from "./CollectionTree";
import LoadingSpan from "components/LoadingSpan";
import CurrentUser from "components/providers/CurrentUser";
import UserHistories from "components/History/providers/UserHistories";
import { copyCollection } from "components/Markdown/services";
export default {
components: {
CollectionTree,
CurrentUser,
LoadingSpan,
UserHistories,
},
props: {
args: {
@@ -55,6 +81,8 @@ export default {
return {
itemContent: null,
loading: true,
messageText: null,
messageVariant: null,
};
},
created() {
@@ -78,6 +106,19 @@ export default {
},
},
methods: {
onCopyCollection(currentHistoryId) {
const hdcaId = this.args.history_dataset_collection_id;
copyCollection(hdcaId, currentHistoryId).then(
(response) => {
this.messageVariant = "success";
this.messageText = "Successfully copied to current history.";
},
(error) => {
this.messageVariant = "danger";
this.messageText = error;
}
);
},
async getContent() {
try {
const response = await axios.get(this.itemUrl);
+16 -5
View File
@@ -2,10 +2,18 @@
<div class="markdown-wrapper">
<LoadingSpan v-if="loading" />
<div v-else>
<a v-if="effectiveExportLink" :href="exportLink" class="markdown-export position-absolute p-3">
<i class="fa fa-3x fa-download" />
</a>
<div>
<b-button
v-if="effectiveExportLink"
class="float-right"
title="Download PDF"
variant="link"
role="button"
v-b-tooltip.hover.bottom
@click="onDownload"
>
<font-awesome-icon icon="download" />
</b-button>
<b-button
v-if="!readOnly"
class="float-right"
@@ -97,7 +105,7 @@ import MarkdownIt from "markdown-it";
import markdownItRegexp from "markdown-it-regexp";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { library } from "@fortawesome/fontawesome-svg-core";
import { faEdit } from "@fortawesome/free-solid-svg-icons";
import { faDownload, faEdit } from "@fortawesome/free-solid-svg-icons";
import LoadingSpan from "components/LoadingSpan";
import HistoryDatasetAsImage from "./Elements/HistoryDatasetAsImage";
@@ -128,7 +136,7 @@ md.use(mdNewline);
Vue.use(BootstrapVue);
library.add(faEdit);
library.add(faDownload, faEdit);
export default {
store: store,
@@ -279,6 +287,9 @@ export default {
content: content,
};
},
onDownload() {
window.location.href = this.exportLink;
},
},
};
</script>
@@ -0,0 +1,19 @@
import axios from "axios";
import { rethrowSimple } from "utils/simple-error";
import { getAppRoot } from "onload/loadConfig";
export async function copyCollection(hdcaId, historyId) {
const url = `${getAppRoot()}api/histories/${historyId}/contents/dataset_collections`;
const payload = {
source: "hdca",
type: "dataset_collection",
content: hdcaId,
copy_elements: true,
};
try {
const { data } = await axios.post(url, payload);
return data;
} catch (e) {
rethrowSimple(e);
}
}
@@ -0,0 +1,102 @@
<template>
<b-dropdown
no-caret
right
role="button"
title="Panel Views"
variant="link"
aria-label="View all tool panel configurations"
class="tool-panel-dropdown float-right"
size="sm"
v-b-tooltip.hover
>
<template v-slot:button-content>
<span class="fa fa-list" />
<!-- Maybe cog? -->
</template>
<PanelViewMenuItem
:current-panel-view="currentPanelView"
:panel-view="defaultPanelView"
@onSelect="updatePanelView"
/>
<b-dropdown-divider></b-dropdown-divider>
<b-dropdown-group v-for="group in groupedPanelViews" :key="group.type" :id="group.type" :header="group.title">
<PanelViewMenuItem
v-for="(panelView, key) in group.panelViews"
:key="key"
:current-panel-view="currentPanelView"
:panel-view="panelView"
@onSelect="updatePanelView"
/>
</b-dropdown-group>
<b-dropdown-divider v-if="groupedPanelViews.length > 0"></b-dropdown-divider>
<PanelViewMenuItem
v-for="(panelView, key) in ungroupedPanelViews"
:key="key"
:current-panel-view="currentPanelView"
:panel-view="panelView"
@onSelect="updatePanelView"
/>
</b-dropdown>
</template>
<script>
import PanelViewMenuItem from "./PanelViewMenuItem";
const groupsDefinitions = [
{ type: "ontology", title: "... by Ontology" },
{ type: "activity", title: "... for Activity" },
{ type: "publication", title: "... from Publication" },
{ type: "training", title: "... for Training" },
];
export default {
components: { PanelViewMenuItem },
props: {
panelViews: {
type: Object,
},
currentPanelView: {
type: String,
},
},
computed: {
defaultPanelView() {
return this.panelViewsOfType("default")[0];
},
groupedPanelViews() {
const groups = [];
for (const group of groupsDefinitions) {
const viewType = group.type;
const panelViews = this.panelViewsOfType(viewType);
if (panelViews.length > 0) {
groups.push({
type: viewType,
title: group.title,
panelViews: panelViews,
});
}
}
return groups;
},
ungroupedPanelViews() {
return this.panelViewsOfType("generic");
},
},
methods: {
updatePanelView(panelView) {
this.$emit("updatePanelView", panelView.id);
},
panelViewsOfType(panelViewType) {
const panelViews = [];
for (const panelViewId in this.panelViews) {
const panelView = this.panelViews[panelViewId];
if (panelView.view_type == panelViewType) {
panelViews.push(panelView);
}
}
return panelViews;
},
},
};
</script>
@@ -0,0 +1,52 @@
<template>
<b-dropdown-item
:active="currentPanelView == panelView.id"
:disabled="currentPanelView == panelView.id"
@click="onClick"
>
<span :class="icon" />
<span :title="title" v-localize>{{ name }}</span>
</b-dropdown-item>
</template>
<script>
const types_to_icons = {
default: "fa-backspace",
generic: "fa-filter",
ontology: "fa-sitemap",
activity: "fa-project-diagram",
publication: "fa-newspaper",
training: "fa-graduation-cap",
};
export default {
props: {
currentPanelView: {
type: String,
required: true,
},
panelView: {
type: Object,
required: true,
},
},
computed: {
title() {
return this.panelView.description;
},
icon() {
const viewType = this.panelView.view_type;
const faIcon = types_to_icons[viewType];
return ["fa", faIcon];
},
name() {
return this.panelView.name;
},
},
methods: {
onClick() {
this.$emit("onSelect", this.panelView);
},
},
};
</script>
@@ -14,6 +14,10 @@ export default {
DelayedInput,
},
props: {
currentPanelView: {
type: String,
required: true,
},
query: {
type: String,
default: "",
@@ -46,7 +50,7 @@ export default {
this.loading = true;
axios
.get(`${getAppRoot()}api/tools`, {
params: { q },
params: { q, view: this.currentPanelView },
})
.then((response) => {
this.loading = false;
@@ -0,0 +1,52 @@
<template>
<ConfigProvider class="d-flex flex-column" v-slot="{ config }">
<ToolPanelViewProvider
v-slot="{ currentPanel, currentPanelView }"
:site-default-panel-view="config.default_panel_view"
v-if="config.default_panel_view"
>
<ToolBox
:toolbox="currentPanel"
:panel-views="config.panel_views"
:current-panel-view="currentPanelView"
:stored-workflow-menu-entries="storedWorkflowMenuEntries"
:workflow-title="workflowTitle"
@updatePanelView="updatePanelView"
v-if="currentPanelView"
>
</ToolBox>
</ToolPanelViewProvider>
</ConfigProvider>
</template>
<script>
import ToolBox from "./ToolBox";
import ConfigProvider from "components/providers/ConfigProvider";
import ToolPanelViewProvider from "components/providers/ToolPanelViewProvider";
import _l from "utils/localization";
import { mapActions } from "vuex";
export default {
components: {
ConfigProvider,
ToolBox,
ToolPanelViewProvider,
},
props: {
storedWorkflowMenuEntries: {
type: Array,
required: true,
},
workflowTitle: {
type: String,
default: _l("Workflows"),
},
},
methods: {
updatePanelView(panelView) {
this.setCurrentPanelView(panelView);
},
...mapActions("panels", ["setCurrentPanelView"]),
},
};
</script>
@@ -0,0 +1,72 @@
<template>
<ConfigProvider class="d-flex flex-column" v-slot="{ config }">
<ToolPanelViewProvider
v-slot="{ currentPanel, currentPanelView }"
:site-default-panel-view="config.default_panel_view"
v-if="config.default_panel_view"
>
<ToolBoxWorkflow
:toolbox="currentPanel"
:panel-views="config.panel_views"
:current-panel-view="currentPanelView"
:workflows="workflows"
:data-managers="dataManagers"
:module-sections="moduleSections"
@updatePanelView="updatePanelView"
@onInsertTool="onInsertTool"
@onInsertModule="onInsertModule"
@onInsertWorkflow="onInsertWorkflow"
@onInsertWorkflowSteps="onInsertWorkflowSteps"
v-if="currentPanelView"
>
</ToolBoxWorkflow>
</ToolPanelViewProvider>
</ConfigProvider>
</template>
<script>
import ToolBoxWorkflow from "./ToolBoxWorkflow";
import ConfigProvider from "components/providers/ConfigProvider";
import ToolPanelViewProvider from "components/providers/ToolPanelViewProvider";
import { mapActions } from "vuex";
export default {
components: {
ConfigProvider,
ToolBoxWorkflow,
ToolPanelViewProvider,
},
props: {
workflows: {
type: Array,
required: true,
},
dataManagers: {
type: Array,
required: true,
},
moduleSections: {
type: Array,
required: true,
},
},
methods: {
updatePanelView(panelView) {
this.setCurrentPanelView(panelView);
},
...mapActions("panels", ["setCurrentPanelView"]),
onInsertTool(toolId, toolName) {
this.$emit("onInsertTool", toolId, toolName);
},
onInsertModule(moduleName, moduleTitle) {
this.$emit("onInsertModule", moduleName, moduleTitle);
},
onInsertWorkflow(workflowId, workflowName) {
this.$emit("onInsertWorkflow", workflowId, workflowName);
},
onInsertWorkflowSteps(workflowId, workflowStepCount) {
this.$emit("onInsertWorkflowSteps", workflowId, workflowStepCount);
},
},
};
</script>
+24 -2
View File
@@ -4,12 +4,24 @@
<div class="unified-panel-header-inner">
<div class="panel-header-buttons">
<favorites-button :query="query" @onFavorites="onFavorites" v-if="isUser" />
<panel-view-button
:panel-views="panelViews"
:current-panel-view="currentPanelView"
@updatePanelView="updatePanelView"
v-if="panelViews && Object.keys(panelViews).length > 1"
/>
</div>
<div class="panel-header-text">Tools</div>
</div>
</div>
<div class="unified-panel-controls">
<tool-search :query="query" placeholder="search tools" @onQuery="onQuery" @onResults="onResults" />
<tool-search
:current-panel-view="currentPanelView"
:query="query"
placeholder="search tools"
@onQuery="onQuery"
@onResults="onResults"
/>
<upload-button />
<div class="py-2" v-if="hasResults">
<b-button @click="onToggle" size="sm" class="w-100">
@@ -52,16 +64,17 @@ import ToolSection from "./Common/ToolSection";
import ToolSearch from "./Common/ToolSearch";
import { UploadButton, openGlobalUploadModal } from "components/Upload";
import FavoritesButton from "./Buttons/FavoritesButton";
import PanelViewButton from "./Buttons/PanelViewButton";
import { filterToolSections, filterTools } from "./utilities";
import { getGalaxyInstance } from "app";
import { getAppRoot } from "onload";
import _l from "utils/localization";
export default {
name: "ToolBox",
components: {
UploadButton,
FavoritesButton,
PanelViewButton,
ToolSection,
ToolSearch,
},
@@ -81,6 +94,12 @@ export default {
type: Array,
required: true,
},
panelViews: {
type: Object,
},
currentPanelView: {
type: String,
},
storedWorkflowMenuEntries: {
type: Array,
required: true,
@@ -163,6 +182,9 @@ export default {
this.buttonText = this.showSections ? "Hide Sections" : "Show Sections";
this.buttonIcon = this.showSections ? "fa fa-eye-slash" : "fa fa-eye";
},
updatePanelView(panelView) {
this.$emit("updatePanelView", panelView);
},
},
};
</script>
@@ -2,11 +2,24 @@
<div class="unified-panel">
<div class="unified-panel-header" unselectable="on">
<div class="unified-panel-header-inner">
<div class="panel-header-buttons">
<panel-view-button
:panel-views="panelViews"
:current-panel-view="currentPanelView"
@updatePanelView="updatePanelView"
v-if="panelViews && Object.keys(panelViews).length > 1"
/>
</div>
<div class="panel-header-text">Tools</div>
</div>
</div>
<div class="unified-panel-controls">
<tool-search placeholder="search tools" @onQuery="onQuery" @onResults="onResults" />
<tool-search
:current-panel-view="currentPanelView"
placeholder="search tools"
@onQuery="onQuery"
@onResults="onResults"
/>
</div>
<div class="unified-panel-body">
<div class="toolMenuContainer">
@@ -58,12 +71,14 @@ import _l from "utils/localization";
import ToolSection from "./Common/ToolSection";
import ToolSearch from "./Common/ToolSearch";
import { filterToolSections } from "./utilities";
import PanelViewButton from "./Buttons/PanelViewButton";
export default {
name: "ToolBox",
components: {
ToolSection,
ToolSearch,
PanelViewButton,
},
data() {
return {
@@ -76,6 +91,12 @@ export default {
type: Array,
required: true,
},
panelViews: {
type: Object,
},
currentPanelView: {
type: String,
},
workflows: {
type: Array,
required: true,
@@ -147,6 +168,9 @@ export default {
onInsertWorkflowSteps(workflow) {
this.$emit("onInsertWorkflowSteps", workflow.id, workflow.step_count);
},
updatePanelView(panelView) {
this.$emit("updatePanelView", panelView);
},
},
};
</script>
+3 -7
View File
@@ -88,8 +88,8 @@
</div>
</div>
<div class="portlet-content">
<FormMessage class="mt-2" :message="errorText" variant="danger" :persistent="true" />
<FormMessage class="mt-2" :message="messageText" :variant="messageVariant" />
<FormMessage :message="errorText" variant="danger" :persistent="true" />
<FormMessage :message="messageText" :variant="messageVariant" />
<slot name="body" />
</div>
</div>
@@ -143,10 +143,6 @@ export default {
type: String,
required: false,
},
sustainVersion: {
type: Boolean,
default: false,
},
options: {
type: Object,
required: true,
@@ -179,7 +175,7 @@ export default {
return !!this.user.email && !this.isFavorite;
},
showVersions() {
return !this.sustainVersion && this.versions && this.versions.length > 1;
return this.versions && this.versions.length > 1;
},
availableVersions() {
const versions = this.versions.slice();
+30 -33
View File
@@ -1,8 +1,8 @@
<template>
<ConfigProvider v-slot="{ config }">
<CurrentUser v-slot="{ user }">
<UserHistories v-if="user" :user="user" v-slot="{ currentHistory }">
<div>
<UserHistories v-if="user" :user="user" v-slot="{ currentHistoryId }">
<div v-if="currentHistoryId">
<LoadingSpan v-if="showLoading" message="Loading Tool" />
<div v-if="showEntryPoints">
<ToolEntryPoints v-for="job in entryPoints" :job-id="job.id" :key="job.id" />
@@ -42,7 +42,6 @@
<FormDisplay
:id="formConfig.id"
:inputs="inputs"
:validation-errors="validationErrors"
:validation-scroll-to="validationScrollTo"
:form-config="formConfig"
@onChange="onChange"
@@ -54,7 +53,7 @@
title="Email notification"
help="Send an email notification when the job completes."
type="boolean"
v-model="email"
v-model="useEmail"
/>
<FormElement
v-if="remapAllowed"
@@ -62,7 +61,7 @@
:title="remapTitle"
:help="remapHelp"
type="boolean"
v-model="remap"
v-model="useJobRemapping"
/>
<FormElement
v-if="reuseAllowed(user)"
@@ -70,14 +69,14 @@
title="Attempt to re-use jobs with identical parameters?"
help="This may skip executing jobs that you have already run."
type="boolean"
v-model="reuse"
v-model="useCachedJobs"
/>
</template>
<template v-slot:buttons>
<ButtonSpinner
id="execute"
title="Execute"
@onClick="onExecute(config, currentHistory.id)"
@onClick="onExecute(config, currentHistoryId)"
:wait="showExecuting"
:tooltip="tooltip"
/>
@@ -90,9 +89,9 @@
</template>
<script>
import Scroller from "vue-scrollto";
import { getGalaxyInstance } from "app";
import { getToolFormData, updateToolFormData, submitJob } from "./services";
import { allowCachedJobs } from "./utilities";
import ToolCard from "./ToolCard";
import ButtonSpinner from "components/Common/ButtonSpinner";
import CurrentUser from "components/providers/CurrentUser";
@@ -147,13 +146,14 @@ export default {
error: null,
formConfig: {},
formData: {},
remapAllowed: false,
errorTitle: null,
errorContent: null,
messageVariant: "",
messageText: "",
reuse: false,
email: false,
remap: false,
useCachedJobs: false,
useEmail: false,
useJobRemapping: false,
entryPoints: [],
jobDef: {},
jobResponse: {},
@@ -185,18 +185,15 @@ export default {
inputs() {
return this.formConfig.inputs;
},
remapAllowed() {
return this.job_id && this.formConfig.job_remap;
},
remapTitle() {
if (this.formConfig.job_remap === "job_produced_collection_elements") {
return "Replace elements in collection ?";
if (this.remapAllowed === "job_produced_collection_elements") {
return "Replace elements in collection?";
} else {
return "Resume dependencies from this job ?";
return "Resume dependencies from this job?";
}
},
remapHelp() {
if (this.formConfig.job_remap === "job_produced_collection_elements") {
if (this.remapAllowed === "job_produced_collection_elements") {
return "The previous run of this tool failed. Use this option to replace the failed element(s) in the dataset collection that were produced during the previous tool run.";
} else {
return "The previous run of this tool failed and other tools were waiting for it to finish successfully. Use this option to resume those tools using the new output(s) of this tool run.";
@@ -208,14 +205,7 @@ export default {
return config.server_mail_configured && !user.isAnonymous;
},
reuseAllowed(user) {
if (user.preferences && "extra_user_preferences" in user.preferences) {
const extra_user_preferences = JSON.parse(user.preferences.extra_user_preferences);
const keyCached = "use_cached_job|use_cached_job_checkbox";
const hasCachedJobs = keyCached in extra_user_preferences;
return hasCachedJobs ? extra_user_preferences[keyCached] : false;
} else {
return false;
}
return allowCachedJobs(user.preferences);
},
onValidation(validationInternal) {
this.validationInternal = validationInternal;
@@ -225,7 +215,7 @@ export default {
this.onUpdate();
},
onUpdate() {
updateToolFormData(this.formConfig.id, this.currentVersion, this.formData).then((data) => {
updateToolFormData(this.formConfig.id, this.currentVersion, this.history_id, this.formData).then((data) => {
this.formConfig = data;
});
},
@@ -239,6 +229,7 @@ export default {
this.currentVersion = newVersion || this.currentVersion;
getToolFormData(this.id, this.currentVersion, this.job_id, this.history_id).then((data) => {
this.formConfig = data;
this.remapAllowed = this.job_id && data.job_remap;
this.showLoading = false;
this.showForm = true;
if (newVersion) {
@@ -262,13 +253,13 @@ export default {
...this.formData,
},
};
if (this.email) {
if (this.useEmail) {
jobDef.inputs["send_email_notification"] = true;
}
if (this.remap) {
if (this.useJobRemapping) {
jobDef.inputs["rerun_remap_job_id"] = this.job_id;
}
if (this.reuse) {
if (this.useCachedJobs) {
jobDef.inputs["use_cached_job"] = true;
}
console.debug("toolForm::onExecute()", jobDef);
@@ -295,14 +286,20 @@ export default {
if ([true, "true"].includes(config.enable_tool_recommendations)) {
this.showRecommendation = true;
}
Scroller.scrollTo("body");
document.querySelector(".center-panel").scrollTop = 0;
},
(e) => {
this.showExecuting = false;
let genericError = true;
const errorData = e && e.response && e.response.data && e.response.data.err_data;
if (errorData) {
this.validationErrors = errorData;
} else {
const errorEntries = Object.entries(errorData);
if (errorEntries.length > 0) {
this.validationScrollTo = errorEntries[0];
genericError = false;
}
}
if (genericError) {
this.showError = true;
this.showForm = false;
this.errorTitle = "Job submission failed";
+2 -1
View File
@@ -2,11 +2,12 @@ import axios from "axios";
import { rethrowSimple } from "utils/simple-error";
import { getAppRoot } from "onload/loadConfig";
export async function updateToolFormData(tool_id, tool_version, inputs) {
export async function updateToolFormData(tool_id, tool_version, history_id, inputs) {
const current_state = {
tool_id: tool_id,
tool_version: tool_version,
inputs: inputs,
history_id: history_id,
};
const url = `${getAppRoot()}api/tools/${tool_id}/build`;
try {
+11
View File
@@ -12,3 +12,14 @@ export function downloadTool(toolId) {
export function openLink(url) {
window.open(url);
}
export function allowCachedJobs(userPreferences) {
if (userPreferences && "extra_user_preferences" in userPreferences) {
const extra_user_preferences = JSON.parse(userPreferences.extra_user_preferences);
const keyCached = "use_cached_job|use_cached_job_checkbox";
const hasCachedJobs = keyCached in extra_user_preferences;
return hasCachedJobs ? ["true", true].includes(extra_user_preferences[keyCached]) : false;
} else {
return false;
}
}
@@ -20,7 +20,12 @@ import RepositoryDetails from "../RepositoryDetails/Index.vue";
import LoadingSpan from "components/LoadingSpan";
export default {
props: ["repo"],
props: {
repo: {
type: Object,
required: true,
},
},
components: {
LoadingSpan,
RepositoryDetails,
@@ -34,7 +39,7 @@ export default {
},
created() {
this.root = getAppRoot();
this.services = new Services({ root: this.root });
this.services = new Services();
this.load();
},
methods: {
@@ -20,6 +20,7 @@ Services.mockImplementation(() => {
{
name: "name_0",
description: "description_0",
tool_shed: "toolshed_1",
tool_shed_status: {
latest_installable_revision: false,
},
@@ -27,6 +28,7 @@ Services.mockImplementation(() => {
{
name: "name_1",
description: "description_1",
tool_shed: "toolshed_2",
tool_shed_status: {
latest_installable_revision: true,
},
@@ -58,5 +60,6 @@ describe("InstalledList", () => {
expect(links.length).toBe(3);
const badge = links.at(1).find(".badge");
expect(badge.text()).toBe("Newer version available!");
expect(wrapper.find('th[role="columnheader"][aria-colindex="3"] > div').text()).toBe("Tool Shed");
});
});
@@ -69,23 +69,15 @@ export default {
Monitor,
RepositoryDetails,
},
props: ["filter"],
props: {
filter: {
type: String,
required: true,
},
},
data() {
return {
error: null,
fields: [
{
key: "name",
sortable: true,
sortByFormatted: (value, key, item) => {
return `${this.isLatest(item)}_${value}`;
},
},
{
key: "owner",
sortable: true,
},
],
loading: true,
message: null,
messageVariant: null,
@@ -105,10 +97,39 @@ export default {
showMessage() {
return !!this.message;
},
numToolsheds() {
const toolsheds = new Set();
this.repositories.forEach((x) => {
toolsheds.add(x.tool_shed);
});
return toolsheds.size;
},
fields() {
const fields = [
{
key: "name",
sortable: true,
sortByFormatted: (value, key, item) => {
return `${this.isLatest(item)}_${value}`;
},
},
{
key: "owner",
sortable: true,
},
];
if (this.numToolsheds > 1) {
fields.push({
key: "tool_shed",
sortable: true,
});
}
return fields;
},
},
created() {
this.root = getAppRoot();
this.services = new Services({ root: this.root });
this.services = new Services();
this.load();
},
methods: {
@@ -30,7 +30,6 @@
<script>
import Vue from "vue";
import BootstrapVue from "bootstrap-vue";
import { getAppRoot } from "onload/loadConfig";
import { Services } from "../services";
import InstallationButton from "../RepositoryDetails/InstallationButton";
@@ -58,8 +57,7 @@ export default {
},
},
created() {
this.root = getAppRoot();
this.services = new Services({ root: this.root });
this.services = new Services();
this.load();
},
destroyed() {
@@ -30,7 +30,6 @@
</template>
<template v-slot:cell(actions)="row">
<InstallationButton
:installed="row.item.installed"
:status="row.item.status"
@onInstall="setupRepository(row.item)"
@onUninstall="uninstallRepository(row.item)"
@@ -67,7 +66,16 @@ export default {
InstallationButton,
RepositoryTools,
},
props: ["repo", "toolshedUrl"],
props: {
repo: {
type: Object,
required: true,
},
toolshedUrl: {
type: String,
required: true,
},
},
data() {
return {
repoChecked: "fa fa-check text-success",
@@ -19,7 +19,12 @@ import BootstrapVue from "bootstrap-vue";
Vue.use(BootstrapVue);
export default {
props: ["installed", "status"],
props: {
status: {
type: String,
required: true,
},
},
data() {
return {
buttonClass: "btn-sm text-nowrap",
@@ -55,7 +55,28 @@ import { getGalaxyInstance } from "app";
Vue.use(BootstrapVue);
export default {
props: ["repo", "changesetRevision", "requiresPanel", "toolshedUrl", "modalStatic"],
props: {
repo: {
type: Object,
required: true,
},
changesetRevision: {
type: String,
required: true,
},
requiresPanel: {
type: Boolean,
required: true,
},
toolshedUrl: {
type: String,
required: true,
},
modalStatic: {
type: Boolean,
required: true,
},
},
data() {
return {
modalShow: true,
@@ -33,7 +33,12 @@
</template>
<script>
export default {
props: ["tools"],
props: {
tools: {
type: Array,
required: true,
},
},
data() {
return {
clsExpanded: "bg-transparent border-bottom",
@@ -24,7 +24,16 @@ import LoadingSpan from "components/LoadingSpan";
Vue.use(BootstrapVue);
export default {
props: ["toolshedUrl", "loading"],
props: {
toolshedUrl: {
type: String,
required: true,
},
loading: {
type: Boolean,
required: true,
},
},
components: { LoadingSpan },
data() {
return {
@@ -34,7 +34,16 @@ import Categories from "./Categories.vue";
import Repositories from "./Repositories.vue";
import ServerSelection from "./ServerSelection.vue";
export default {
props: ["query", "scrolled"],
props: {
query: {
type: String,
required: true,
},
scrolled: {
type: Boolean,
required: true,
},
},
components: {
Categories,
Repositories,
@@ -33,7 +33,7 @@ describe("Repositories", () => {
it("test repository details loading", async () => {
const wrapper = mount(Repositories, {
propsData: {
query: true,
query: "toolname",
scrolled: false,
toolshedUrl: "toolshedUrl",
},
@@ -32,7 +32,20 @@ export default {
LoadingSpan,
RepositoryDetails,
},
props: ["query", "scrolled", "toolshedUrl"],
props: {
query: {
type: String,
required: true,
},
scrolled: {
type: Boolean,
required: true,
},
toolshedUrl: {
type: String,
required: true,
},
},
data() {
return {
repositories: [],
@@ -11,11 +11,11 @@ describe("ServerSelection", () => {
toolshedUrl: "url_0",
toolshedUrls: ["url_0", "url_1"],
loading: false,
total: "total",
total: 10,
},
localVue,
});
expect(wrapper.find(".description").text()).toBe("total repositories available at");
expect(wrapper.find(".description").text()).toBe("10 repositories available at");
const $options = wrapper.findAll("a");
expect($options.at(0).text()).toBe("url_0");
expect($options.at(1).text()).toBe("url_0");
@@ -38,7 +38,24 @@ import BootstrapVue from "bootstrap-vue";
Vue.use(BootstrapVue);
export default {
props: ["toolshedUrl", "toolshedUrls", "loading", "total"],
props: {
toolshedUrl: {
type: String,
required: true,
},
toolshedUrls: {
type: Array,
required: true,
},
loading: {
type: Boolean,
required: true,
},
total: {
type: Number,
required: true,
},
},
computed: {
showDropdown() {
return this.toolshedUrls.length > 1;
+5 -4
View File
@@ -78,7 +78,7 @@ export class Services {
const url = `${getAppRoot()}api/tool_shed_repositories/?uninstalled=False`;
try {
const response = await axios.get(url);
const repositories = this._groupByNameOwner(response.data, options.filter, options.selectLatest);
const repositories = this._groupByNameOwnerToolshed(response.data, options.filter, options.selectLatest);
this._fixToolshedUrls(repositories, Galaxy.config.tool_shed_urls);
return repositories;
} catch (e) {
@@ -125,15 +125,16 @@ export class Services {
rethrowSimple(e);
}
}
_groupByNameOwner(incoming, filter, selectLatest) {
_groupByNameOwnerToolshed(incoming, filter, selectLatest) {
if (selectLatest) {
const getSortValue = (x, y) => {
return x == y ? 0 : x < y ? -1 : 1;
return x === y ? 0 : x < y ? -1 : 1;
};
incoming = incoming.sort((a, b) => {
return (
getSortValue(a.name, b.name) ||
getSortValue(a.owner, b.owner) ||
getSortValue(a.tool_shed, b.tool_shed) ||
getSortValue(parseInt(b.ctx_rev), parseInt(a.ctx_rev))
);
});
@@ -141,7 +142,7 @@ export class Services {
const hash = {};
const repositories = [];
incoming.forEach((x) => {
const hashCode = `${x.name}_${x.owner}`;
const hashCode = `${x.name}_${x.owner}_${x.tool_shed}`;
if (!filter || filter(x)) {
if (!hash[hashCode]) {
hash[hashCode] = true;
@@ -40,7 +40,7 @@ describe("Toolshed service helpers", () => {
it("test fix toolshed helper", () => {
const services = new Services();
const filter = (x) => x.status !== "Installed";
const grouped = services._groupByNameOwner(incoming, filter, true);
const grouped = services._groupByNameOwnerToolshed(incoming, filter, true);
services._fixToolshedUrls(grouped, urls);
expect(grouped.length).toBe(2);
expect(grouped[0].name).toBe("name_0");
@@ -1,15 +1,16 @@
import { getGalaxyInstance } from "app";
import _l from "utils/localization";
export const getUserPreferencesModel = () => {
export const getUserPreferencesModel = (user_id) => {
const Galaxy = getGalaxyInstance();
const config = Galaxy.config;
user_id = user_id || Galaxy.user.id;
return {
information: {
title: _l("Manage Information"),
id: "edit-preferences-information",
description: "Edit your email, addresses and custom parameters or change your public name.",
url: `api/users/${Galaxy.user.id}/information/inputs`,
url: `api/users/${user_id}/information/inputs`,
icon: "fa-user",
redirect: "user",
shouldRender: !config.use_remote_user && config.enable_account_interface,
@@ -19,7 +20,7 @@ export const getUserPreferencesModel = () => {
id: "edit-preferences-password",
description: _l("Allows you to change your login credentials."),
icon: "fa-unlock-alt",
url: `api/users/${Galaxy.user.id}/password/inputs`,
url: `api/users/${user_id}/password/inputs`,
submit_title: "Save Password",
redirect: "user",
shouldRender: !config.use_remote_user && config.enable_account_interface,
@@ -29,8 +30,8 @@ export const getUserPreferencesModel = () => {
id: "manage-third-party-identities",
description: _l("Connect or disconnect access to your third-party identities."),
icon: "fa-id-card-o",
submit_title: "Disconnect identity",
submit_icon: "fa-trash",
submitTitle: "Disconnect identity",
submitIcon: "fa-trash",
shouldRender: config.enable_oidc,
},
permissions: {
@@ -38,9 +39,9 @@ export const getUserPreferencesModel = () => {
id: "edit-preferences-permissions",
description:
"Grant others default access to newly created histories. Changes made here will only affect histories created after these settings have been stored.",
url: `api/users/${Galaxy.user.id}/permissions/inputs`,
url: `api/users/${user_id}/permissions/inputs`,
icon: "fa-users",
submit_title: "Save Permissions",
submitTitle: "Save Permissions",
redirect: "user",
shouldRender: !config.single_user,
},
@@ -55,27 +56,27 @@ export const getUserPreferencesModel = () => {
title: _l("Manage API Key"),
id: "edit-preferences-api-key",
description: _l("Access your current API key or create a new one."),
url: `api/users/${Galaxy.user.id}/api_key/inputs`,
url: `api/users/${user_id}/api_key/inputs`,
icon: "fa-key",
submit_title: "Create a new Key",
submit_icon: "fa-check",
submitTitle: "Create a new Key",
submitIcon: "fa-check",
},
cloud_auth: {
id: "edit-preferences-cloud-auth",
title: _l("Manage Cloud Authorization"),
description: _l("Add or modify the configuration that grants Galaxy to access your cloud-based resources."),
icon: "fa-cloud",
submit_title: "Create a new Key",
submit_icon: "fa-check",
submitTitle: "Create a new Key",
submitIcon: "fa-check",
shouldRender: config.enable_account_interface,
},
toolbox_filters: {
title: _l("Manage Toolbox Filters"),
id: "edit-preferences-toolbox-filters",
description: _l("Customize your Toolbox by displaying or omitting sets of Tools."),
url: `api/users/${Galaxy.user.id}/toolbox_filters/inputs`,
url: `api/users/${user_id}/toolbox_filters/inputs`,
icon: "fa-filter",
submit_title: "Save Filters",
submitTitle: "Save Filters",
redirect: "user",
shouldRender: !!config.has_user_tool_filters,
},
-67
View File
@@ -1,67 +0,0 @@
/** Button variant with waiting and progress tracking. */
<template>
<button class="ui-button-default" :class="buttonClass" type="button" @click="$emit('click')">
<i class="icon fa" :class="iconClass"> </i>
<span class="title"> {{ currentText }} </span>
<div class="progress" v-if="percentage != -1">
<div class="progress-bar" :style="{ width: progressWidth }"></div>
</div>
</button>
</template>
<script>
export default {
props: {
title: {
type: String,
default: "",
},
icon: {
type: String,
default: "fa-play",
},
percentage: {
type: Number,
default: -1,
},
waiting: {
type: Boolean,
default: false,
},
waitText: {
type: String,
default: "Sending...",
},
variant: {
type: String,
default: "info",
},
},
computed: {
progressWidth() {
return `${this.percentage}%`;
},
buttonClass() {
if (this.waiting) {
return "disabled";
} else {
return `btn btn-${this.variant}`;
}
},
iconClass() {
if (this.waiting) {
return "fa-spinner fa-spin mr-1";
} else {
return this.icon + " mr-1";
}
},
currentText() {
if (this.waiting) {
return this.waitText;
} else {
return this.title;
}
},
},
};
</script>
@@ -27,7 +27,7 @@
</b-button>
</template>
<template v-slot:body>
<FormMessage class="mt-2" :message="errorText" variant="danger" :persistent="true" />
<FormMessage :message="errorText" variant="danger" :persistent="true" />
<FormElement
id="__label"
:value="node.label"
@@ -39,8 +39,7 @@
<div v-show="isCanvas">
<SidePanel id="left" side="left">
<template v-slot:panel>
<ToolBoxWorkflow
:toolbox="toolbox"
<ProviderAwareToolBoxWorkflow
:module-sections="moduleSections"
:data-managers="dataManagers"
:workflows="workflows"
@@ -113,7 +112,7 @@
/>
</div>
</div>
<div class="unified-panel-body workflow-right">
<div class="unified-panel-body workflow-right" ref="right-panel">
<div class="m-1">
<FormTool
v-if="hasActiveNodeTool"
@@ -186,7 +185,7 @@ import WorkflowOptions from "./Options";
import FormDefault from "./Forms/FormDefault";
import FormTool from "./Forms/FormTool";
import MarkdownEditor from "components/Markdown/MarkdownEditor";
import ToolBoxWorkflow from "components/Panels/ToolBoxWorkflow";
import ProviderAwareToolBoxWorkflow from "components/Panels/ProviderAwareToolBoxWorkflow";
import SidePanel from "components/Panels/SidePanel";
import { getAppRoot } from "onload/loadConfig";
import reportDefault from "./reportDefault";
@@ -205,7 +204,7 @@ export default {
MarkdownEditor,
SidePanel,
StateUpgradeModal,
ToolBoxWorkflow,
ProviderAwareToolBoxWorkflow,
FormDefault,
FormTool,
WorkflowOptions,
@@ -241,10 +240,6 @@ export default {
type: Array,
required: true,
},
toolbox: {
type: Array,
required: true,
},
},
data() {
return {
@@ -335,6 +330,7 @@ export default {
node.makeActive();
this.activeNode = node;
this.canvasManager.drawOverview();
this.$refs["right-panel"].scrollTop = 0;
}
},
onDeactivate() {
@@ -7,11 +7,7 @@
<b-alert v-if="loading" variant="info" show>
<loading-span message="Loading workflow run data" />
</b-alert>
<workflow-run-success
v-else-if="invocations != null"
:invocations="invocations"
:workflow-name="workflowName"
/>
<workflow-run-success v-else-if="!!invocations" :invocations="invocations" :workflow-name="workflowName" />
<div v-else class="ui-form-composite">
<div class="ui-form-composite-messages mb-4">
<b-alert v-if="hasUpgradeMessages" variant="warning" show>
@@ -29,41 +25,21 @@
Workflow submission failed: {{ submissionError }}
</b-alert>
</div>
<!-- h4 as a class here looks odd but it was in the Backbone -->
<div class="ui-form-composite-header h4">
<b>Workflow: {{ workflowName }}</b>
<wait-button
title="Run Workflow"
id="run-workflow"
variant="primary"
:disabled="!runButtonEnabled"
:waiting="!runButtonEnabled"
:wait-text="runButtonWaitText"
:percentage="runButtonPercentage"
@click="execute"
>
</wait-button>
</div>
<workflow-run-form
ref="runform"
<workflow-run-form-simple
v-if="simpleForm"
:model="model"
v-if="!simpleForm"
:set-run-button-status="setRunButtonStatus"
:target-history="simpleFormTargetHistory"
:use-job-cache="simpleFormUseJobCache"
@submissionSuccess="handleInvocations"
@submissionError="handleSubmissionError"
@showAdvanced="showAdvanced"
/>
<workflow-run-form
v-else
:model="model"
@submissionSuccess="handleInvocations"
@submissionError="handleSubmissionError"
/>
<div v-else>
<workflow-run-form-simple
ref="runform"
:model="model"
:set-run-button-status="setRunButtonStatus"
:target-history="simpleFormTargetHistory"
:use-job-cache="simpleFormUseJobCache"
@submissionSuccess="handleInvocations"
@submissionError="handleSubmissionError"
/>
<!-- Options to default one way or the other, disable if admins want, etc.. -->
<a href="#" @click="showAdvanced">Expand to full workflow form.</a>
</div>
</div>
</span>
</span>
@@ -71,7 +47,6 @@
<script>
import { getRunData } from "./services";
import WaitButton from "components/WaitButton";
import LoadingSpan from "components/LoadingSpan";
import WorkflowRunSuccess from "./WorkflowRunSuccess";
import WorkflowRunForm from "./WorkflowRunForm";
@@ -82,7 +57,6 @@ import { errorMessageAsString } from "utils/simple-error";
export default {
components: {
LoadingSpan,
WaitButton,
WorkflowRunSuccess,
WorkflowRunForm,
WorkflowRunFormSimple,
@@ -109,10 +83,6 @@ export default {
hasUpgradeMessages: false,
hasStepVersionChanges: false,
workflowName: "",
runForm: null,
runButtonEnabled: true,
runButtonWaitText: "",
runButtonPercentage: -1,
invocations: null,
simpleForm: null,
submissionError: null,
@@ -122,6 +92,7 @@ export default {
created() {
getRunData(this.workflowId)
.then((runData) => {
this.loading = false;
const model = new WorkflowRunModel(runData);
let simpleForm = this.preferSimpleForm;
if (simpleForm) {
@@ -155,23 +126,12 @@ export default {
this.hasUpgradeMessages = model.hasUpgradeMessages;
this.hasStepVersionChanges = model.hasStepVersionChanges;
this.workflowName = this.model.name;
this.loading = false;
})
.catch((response) => {
console.log(response);
this.error = errorMessageAsString(response);
});
},
methods: {
execute() {
this.$refs.runform.execute();
this.submissionError = null;
},
setRunButtonStatus(enabled, waitText, percentage) {
this.runButtonEnabled = enabled;
this.runButtonWaitText = waitText;
this.runButtonPercentage = percentage;
},
handleInvocations(invocations) {
this.invocations = invocations;
},
@@ -0,0 +1,85 @@
<template>
<div :step-label="model.step_label">
<FormCard :title="model.fixed_title" :icon="icon" :collapsible="true" :collapsed.sync="collapsed">
<template v-slot:body>
<FormDisplay
:inputs="inputs"
:validation-scroll-to="validationScrollTo"
@onChange="onChange"
@onValidation="onValidation"
/>
</template>
</FormCard>
</div>
</template>
<script>
import WorkflowIcons from "components/Workflow/icons";
import FormDisplay from "components/Form/FormDisplay";
import FormCard from "components/Form/FormCard";
export default {
components: {
FormDisplay,
FormCard,
},
props: {
model: {
type: Object,
required: true,
},
validationScrollTo: {
type: Array,
required: true,
},
},
data() {
return {
collapsed: this.model.collapsed,
};
},
watch: {
validationScrollTo() {
if (this.validationScrollTo.length > 0) {
this.collapsed = false;
}
},
},
computed: {
icon() {
return WorkflowIcons[this.model.step_type];
},
isSimpleInput() {
return (
this.model.step_type.startsWith("data_input") ||
this.model.step_type.startsWith("data_collection_input")
);
},
inputs() {
this.model.inputs.forEach((input) => {
input.flavor = "module";
input.hide_label = this.isSimpleInput;
});
if (this.model.inputs && this.model.inputs.length > 0) {
return this.model.inputs;
} else {
return [
{
type: "hidden",
name: "No options available.",
ignore: null,
},
];
}
},
},
methods: {
onChange(data) {
this.$emit("onChange", this.model.index, data);
},
onValidation(validation) {
this.$emit("onValidation", this.model.index, validation);
},
},
};
</script>
@@ -1,44 +1,248 @@
<template>
<div ref="form" />
<CurrentUser v-slot="{ user }">
<UserHistories v-if="user" :user="user" v-slot="{ currentHistoryId }">
<div v-if="currentHistoryId">
<div class="h4 clearfix mb-3">
<b>Workflow: {{ model.name }}</b>
<ButtonSpinner
id="run-workflow"
class="float-right"
title="Run Workflow"
:wait="showExecuting"
@onClick="onExecute"
/>
</div>
<FormCard v-if="wpInputsAvailable" title="Workflow Parameters">
<template v-slot:body>
<FormDisplay :inputs="wpInputs" @onChange="onWpInputs" />
</template>
</FormCard>
<FormCard title="History Options">
<template v-slot:body>
<FormDisplay :inputs="historyInputs" @onChange="onHistoryInputs" />
</template>
</FormCard>
<FormCard v-if="reuseAllowed(user)" title="Job re-use Options">
<template v-slot:body>
<FormElement
title="Attempt to re-use jobs with identical parameters?"
help="This may skip executing jobs that you have already run."
type="boolean"
v-model="useCachedJobs"
/>
</template>
</FormCard>
<FormCard v-if="resourceInputsAvailable" title="Workflow Resource Options">
<template v-slot:body>
<FormDisplay :inputs="resourceInputs" @onChange="onResourceInputs" />
</template>
</FormCard>
<div v-for="step in model.steps" :key="step.index">
<WorkflowRunToolStep
v-if="step.step_type == 'tool'"
:model="step"
:step-data="stepData"
:validation-scroll-to="getValidationScrollTo(step.index)"
:wp-data="wpData"
:history-id="currentHistoryId"
@onChange="onToolStepInputs"
@onValidation="onValidation"
/>
<WorkflowRunDefaultStep
v-else
:model="step"
:validation-scroll-to="getValidationScrollTo(step.index)"
@onChange="onDefaultStepInputs"
@onValidation="onValidation"
/>
</div>
</div>
</UserHistories>
</CurrentUser>
</template>
<script>
import ToolFormComposite from "mvc/tool/tool-form-composite";
import ButtonSpinner from "components/Common/ButtonSpinner";
import CurrentUser from "components/providers/CurrentUser";
import FormDisplay from "components/Form/FormDisplay";
import FormCard from "components/Form/FormCard";
import FormElement from "components/Form/FormElement";
import UserHistories from "components/History/providers/UserHistories";
import WorkflowRunDefaultStep from "./WorkflowRunDefaultStep";
import WorkflowRunToolStep from "./WorkflowRunToolStep";
import { invokeWorkflow } from "./services";
import { allowCachedJobs } from "components/Tool/utilities";
export default {
components: {
ButtonSpinner,
CurrentUser,
FormDisplay,
FormCard,
FormElement,
UserHistories,
WorkflowRunDefaultStep,
WorkflowRunToolStep,
},
props: {
model: {
type: Object,
required: true,
},
setRunButtonStatus: {
type: Function,
required: true,
},
},
data() {
return {
runForm: null,
showExecuting: false,
stepData: {},
stepValidations: {},
stepScrollTo: {},
wpData: {},
historyData: {},
useCachedJobs: false,
historyInputs: [
{
type: "conditional",
name: "new_history",
test_param: {
name: "check",
label: "Send results to a new history",
type: "boolean",
value: "false",
help: "",
},
cases: [
{
value: "true",
inputs: [
{
name: "name",
label: "History name",
type: "text",
value: this.model.name,
},
],
},
],
},
],
};
},
mounted() {
this.$nextTick(() => {
const el = this.$refs["form"];
const formProps = {
el,
model: this.model,
setRunButtonStatus: this.setRunButtonStatus,
handleInvocations: this.handleInvocations,
};
this.runForm = new ToolFormComposite.View(formProps);
});
computed: {
resourceInputsAvailable() {
return this.resourceInputs.length > 0;
},
resourceInputs() {
return this.toArray(this.model.workflowResourceParameters);
},
wpInputsAvailable() {
return this.wpInputs.length > 0;
},
wpInputs() {
return this.toArray(this.model.wpInputs);
},
},
methods: {
execute() {
this.runForm.execute();
reuseAllowed(user) {
return allowCachedJobs(user.preferences);
},
handleInvocations(invocations) {
this.$emit("submissionSuccess", invocations);
getValidationScrollTo(stepId) {
if (this.stepScrollTo.stepId == stepId) {
return this.stepScrollTo.stepError;
}
return [];
},
onDefaultStepInputs(stepId, data) {
this.stepData[stepId] = data;
this.stepData = Object.assign({}, this.stepData);
},
onToolStepInputs(stepId, data) {
this.stepData[stepId] = data;
},
onHistoryInputs(data) {
this.historyData = data;
},
onResourceInputs(data) {
this.resourceData = data;
},
onWpInputs(data) {
this.wpData = data;
},
onValidation(stepId, validation) {
this.stepValidations[stepId] = validation;
},
onExecute() {
for (const [stepId, stepValidation] of Object.entries(this.stepValidations)) {
if (stepValidation) {
this.stepScrollTo = {
stepId: stepId,
stepError: stepValidation.slice(),
};
return;
}
}
const parameters = {};
Object.entries(this.stepData).forEach(([stepId, stepData]) => {
const stepDataFiltered = {};
Object.entries(stepData).forEach(([inputName, inputValue]) => {
if (!inputValue || typeof inputValue !== "object" || inputValue.__class__ != "ConnectedValue") {
stepDataFiltered[inputName] = inputValue;
}
});
parameters[stepId] = stepDataFiltered;
});
const jobDef = {
new_history_name: this.historyData["new_history|name"] ? this.historyData["new_history|name"] : null,
history_id: !this.historyData["new_history|name"] ? this.model.historyId : null,
resource_params: this.resourceData,
replacement_params: this.wpData,
use_cached_job: this.useCachedJobs,
parameters: parameters,
// Tool form will submit flat maps for each parameter
// (e.g. "repeat_0|cond|param": "foo" instead of nested
// data structures).
parameters_normalized: true,
// Tool form always wants a list of invocations back
// so that inputs can be batched.
batch: true,
};
console.debug("WorkflowRunForm::onExecute()", "Ready for submission.", jobDef);
this.showExecuting = true;
invokeWorkflow(this.model.workflowId, jobDef)
.then((invocations) => {
console.debug("WorkflowRunForm::onExecute()", "Submission successful.", invocations);
this.showExecuting = false;
this.$emit("submissionSuccess", invocations);
})
.catch((e) => {
console.debug("WorkflowRunForm::onExecute()", "Submission failed.", e);
this.showExecuting = false;
const errorData = e && e.response && e.response.data && e.response.data.err_data;
if (errorData) {
try {
const errorEntries = Object.entries(errorData);
this.stepScrollTo = {
stepId: errorEntries[0][0],
stepError: Object.entries(errorEntries[0][1])[0],
};
} catch (errorFormatting) {
console.debug(
errorFormatting,
"WorkflowRunForm::onExecute()",
"Invalid server error response format.",
errorData
);
this.$emit("submissionError", e);
}
} else {
this.$emit("submissionError", e);
}
});
},
toArray(obj) {
return obj ? Object.keys(obj).map((k) => obj[k]) : [];
},
},
};
@@ -1,22 +1,32 @@
<template>
<div ref="form"></div>
<div>
<div class="h4 clearfix mb-3">
<b>Workflow: {{ model.name }}</b>
<ButtonSpinner class="float-right" title="Run Workflow" id="run-workflow" @onClick="onExecute" />
</div>
<FormDisplay :inputs="formInputs" @onChange="onChange" />
<!-- Options to default one way or the other, disable if admins want, etc.. -->
<a href="#" @click="$emit('showAdvanced')">Expand to full workflow form.</a>
</div>
</template>
<script>
import Form from "mvc/form/form-view";
import FormDisplay from "components/Form/FormDisplay";
import ButtonSpinner from "components/Common/ButtonSpinner";
import { invokeWorkflow } from "./services";
import { isWorkflowInput } from "components/Workflow/constants";
import { errorMessageAsString } from "utils/simple-error";
export default {
components: {
ButtonSpinner,
FormDisplay,
},
props: {
model: {
type: Object,
required: true,
},
setRunButtonStatus: {
type: Function,
required: true,
},
targetHistory: {
type: String,
default: "current",
@@ -28,22 +38,13 @@ export default {
},
data() {
return {
form: null,
formData: {},
inputTypes: {},
};
},
computed: {},
created() {
this.form = new Form(this.formDefinition());
this.$nextTick(() => {
const el = this.$refs["form"];
el.appendChild(this.form.$el[0]);
});
},
methods: {
formDefinition() {
computed: {
formInputs() {
const inputs = [];
// Add workflow parameters.
Object.values(this.model.wpInputs).forEach((input) => {
const inputCopy = Object.assign({}, input);
@@ -52,7 +53,6 @@ export default {
inputs.push(inputCopy);
this.inputTypes[inputCopy.name] = "replacement_parameter";
});
// Add actual input modules.
this.model.steps.forEach((step, i) => {
if (!isWorkflowInput(step.step_type)) {
@@ -68,18 +68,18 @@ export default {
inputs.push(stepAsInput);
this.inputTypes[stepName] = step.step_type;
});
const def = {
inputs: inputs,
};
return def;
return inputs;
},
execute() {
},
methods: {
onChange(data) {
this.formData = data;
},
onExecute() {
const replacementParams = {};
const inputs = {};
const formData = this.form.data.create();
for (const inputName in formData) {
const value = formData[inputName];
for (const inputName in this.formData) {
const value = this.formData[inputName];
const inputType = this.inputTypes[inputName];
if (inputType == "replacement_parameter") {
replacementParams[inputName] = value;
@@ -104,8 +104,7 @@ export default {
this.$emit("submissionSuccess", invocations);
})
.catch((error) => {
console.log(error);
this.$emit("submissionError", error);
this.$emit("submissionError", errorMessageAsString(error));
});
},
},
@@ -0,0 +1,143 @@
<template>
<div :step-label="model.step_label">
<FormCard :title="model.fixed_title" icon="fa-wrench" :collapsible="true" :collapsed.sync="collapsed">
<template v-slot:body>
<FormMessage :message="errorText" variant="danger" :persistent="true" />
<FormDisplay
:inputs="model.inputs"
:form-config="formConfig"
:sustain-repeats="true"
:sustain-conditionals="true"
:replace-params="replaceParams"
:validation-scroll-to="validationScrollTo"
@onChange="onChange"
@onValidation="onValidation"
/>
</template>
</FormCard>
</div>
</template>
<script>
import _ from "underscore";
import FormDisplay from "components/Form/FormDisplay";
import FormMessage from "components/Form/FormMessage";
import FormCard from "components/Form/FormCard";
import { visitInputs } from "components/Form/utilities";
import { isDataStep } from "components/Workflow/Run/model";
import { getTool } from "./services";
export default {
components: {
FormDisplay,
FormCard,
FormMessage,
},
props: {
model: {
type: Object,
required: true,
},
wpData: {
type: Object,
default: null,
},
stepData: {
type: Object,
default: null,
},
validationScrollTo: {
type: Array,
required: true,
},
historyId: {
type: String,
default: null,
},
},
data() {
return {
collapsed: this.model.collapsed,
formConfig: {},
formData: {},
replaceParams: {},
errorText: null,
};
},
watch: {
stepData() {
this.onReplaceParams();
},
wpData() {
this.onReplaceParams();
},
validationScrollTo() {
if (this.validationScrollTo.length > 0) {
this.collapsed = false;
}
},
},
methods: {
onReplaceParams() {
const params = {};
visitInputs(this.model.inputs, (input, name) => {
params[name] = input;
});
this.replaceParams = {};
_.each(params, (input, name) => {
if (input.wp_linked || input.step_linked) {
let newValue;
if (input.step_linked) {
newValue = { values: [] };
_.each(input.step_linked, (sourceStep) => {
if (isDataStep(sourceStep)) {
const sourceData = this.stepData[sourceStep.index];
const value = sourceData && sourceData.input;
if (value) {
_.each(value.values, (v) => {
newValue.values.push(v);
});
}
}
});
if (!input.multiple && newValue.values.length > 0) {
newValue = {
values: [newValue.values[0]],
};
}
}
if (input.wp_linked) {
newValue = input.value;
const re = /\$\{(.+?)\}/g;
let match;
while ((match = re.exec(input.value))) {
const wpValue = this.wpData[match[1]];
if (wpValue) {
newValue = newValue.split(match[0]).join(wpValue);
}
}
}
if (newValue !== undefined) {
this.replaceParams[name] = newValue;
}
}
});
},
onChange(data) {
getTool(this.model.id, this.model.version, data, this.historyId).then(
(formConfig) => {
this.formConfig = formConfig;
},
(errorText) => {
this.errorText = errorText;
}
);
this.formData = data;
this.$emit("onChange", this.model.index, data);
},
onValidation(validation) {
this.$emit("onValidation", this.model.index, validation);
},
},
};
</script>
+5 -13
View File
@@ -1,6 +1,6 @@
import _ from "underscore";
import FormData from "mvc/form/form-data";
import { visitInputs } from "components/Form/utilities";
import WorkflowIcons from "components/Workflow/icons";
import Utils from "utils/utils";
@@ -44,20 +44,11 @@ export class WorkflowRunModel {
citations: null,
collapsible: true,
collapsed: i > 0 && !isDataStep(step),
sustain_version: true,
sustain_repeats: true,
sustain_conditionals: true,
narrow: true,
text_enable: "Edit",
text_disable: "Undo",
cls_enable: "fa fa-edit",
cls_disable: "fa fa-undo",
errors: step.messages,
initial_errors: true,
cls: "ui-portlet-section",
hide_operations: true,
needs_refresh: false,
always_refresh: step.step_type != "tool",
},
step
);
@@ -73,7 +64,7 @@ export class WorkflowRunModel {
// build linear index of step input pairs
_.each(this.steps, (step, i) => {
FormData.visitInputs(step.inputs, (input, name) => {
visitInputs(step.inputs, (input, name) => {
this.parms[i][name] = input;
});
});
@@ -104,7 +95,7 @@ export class WorkflowRunModel {
if (connection) {
input.type = "hidden";
input.help = input.step_linked ? `${input.help}, ` : "";
input.help += `Output dataset '${connection.output_name}' from step ${parseInt(i) + 1}`;
input.help += `Connected to '${connection.output_name}' from Step ${parseInt(i) + 1}`;
input.step_linked = input.step_linked || [];
input.step_linked.push({ index: step.index, step_type: step.step_type });
}
@@ -123,6 +114,7 @@ export class WorkflowRunModel {
color: `hsl( ${++wp_count * 100}, 70%, 30% )`,
style: "ui-form-wp-source",
links: [],
optional: true,
});
};
@@ -158,7 +150,7 @@ export class WorkflowRunModel {
_.each(this.steps, (step, i) => {
if (step.step_type == "tool") {
var data_resolved = true;
FormData.visitInputs(step.inputs, (input, name, context) => {
visitInputs(step.inputs, (input, name, context) => {
var is_runtime_value = input.value && input.value.__class__ == "RuntimeValue";
var is_data_input = ["data", "data_collection"].indexOf(input.type) != -1;
var data_ref = context[input.data_ref];
@@ -31,3 +31,26 @@ export async function invokeWorkflow(workflowId, invocationData) {
const response = await axios.post(url, invocationData);
return response.data;
}
/**
* Request tool step data.
*
* @param {String} toolId - Tool ID to fetch data for.
* @param {String} toolVersion - Corresponding tool version.
* @param {Object} toolInputs - Current tool state.
* @param {Object} historyId - History ID to populate data selection fields.
*/
export async function getTool(toolId, toolVersion, toolInputs, historyId) {
const requestData = {
tool_id: toolId,
tool_version: toolVersion,
inputs: JSON.parse(JSON.stringify(toolInputs)),
history_id: historyId,
};
try {
const { data } = await axios.post(`${getAppRoot()}api/tools/${toolId}/build`, requestData);
return data;
} catch (e) {
rethrowSimple(e);
}
}

Some files were not shown because too many files have changed in this diff Show More