Merge branch 'dev' of https://github.com/neoformit/galaxy into dev-isee

This commit is contained in:
Cameron Hyde
2021-10-06 12:59:09 +10:00
421 changed files with 12270 additions and 6380 deletions
+31
View File
@@ -0,0 +1,31 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**Galaxy Version and/or server at which you observed the bug**
Galaxy Version: (check <galaxy_url>/api/version if you don't know)
Commit: (run `git rev-parse HEAD` if you run this Galaxy server)
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Additional context**
Add any other context about the problem here.
+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'
+51
View File
@@ -0,0 +1,51 @@
name: Selenium tests (beta history panel)
on: [push, pull_request]
env:
GALAXY_TEST_DBURI: 'postgresql://postgres:postgres@localhost:5432/galaxy?client_encoding=utf8'
GALAXY_TEST_SKIP_FLAKEY_TESTS_ON_ERROR: 1
GALAXY_TEST_SELENIUM_RETRIES: 1
GALAXY_TEST_SELENIUM_BETA_HISTORY: 1
jobs:
test:
name: Test
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ['3.7']
chunk: [0, 1, 2]
continue-on-error: true
services:
postgres:
image: postgres:13
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- 5432:5432
steps:
- uses: actions/checkout@v2
with:
path: 'galaxy root'
- uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Cache pip dir
uses: actions/cache@v2
with:
path: ~/.cache/pip
key: pip-cache-${{ matrix.python-version }}-${{ hashFiles('galaxy root/requirements.txt') }}
- uses: nanasess/setup-chromedriver@master
- name: Run tests
run: ./run_tests.sh -selenium lib/galaxy_test/selenium -- --num-shards=3 --shard-id=${{ matrix.chunk }} || true
working-directory: 'galaxy root'
- uses: actions/upload-artifact@v2
with:
name: Selenium beta history panel test results (${{ matrix.python-version }}, ${{ matrix.chunk }})
path: 'galaxy root/database/test_errors'
- uses: actions/upload-artifact@v2
with:
name: Selenium beta history panel test results (${{ matrix.python-version }})
path: 'galaxy root/run_selenium_tests.html'
+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.0-alpha.5",
"d3": "3",
"date-fns": "^2.21.1",
@@ -55,6 +55,7 @@
"markdown-it": "^11.0.0",
"markdown-it-regexp": "^0.4.0",
"moment": "2.26.0",
"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",
@@ -85,7 +87,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)
@@ -0,0 +1,36 @@
<template>
<b-button v-if="wait" variant="info" disabled>
<font-awesome-icon icon="spinner" class="mr-2" spin />Please wait...
</b-button>
<b-button v-else variant="primary" v-b-tooltip.hover.bottom :title="tooltip" @click="$emit('onClick')">
<font-awesome-icon icon="check" class="mr-2" />{{ title }}
</b-button>
</template>
<script>
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { library } from "@fortawesome/fontawesome-svg-core";
import { faCheck, faSpinner } from "@fortawesome/free-solid-svg-icons";
library.add(faCheck);
library.add(faSpinner);
export default {
components: {
FontAwesomeIcon,
},
props: {
title: {
type: String,
required: true,
},
wait: {
type: Boolean,
default: false,
},
tooltip: {
type: String,
default: null,
},
},
};
</script>
+27
View File
@@ -0,0 +1,27 @@
<template>
<div ref="webhook" id="webhook-view" />
</template>
<script>
import Webhooks from "mvc/webhooks";
export default {
props: {
type: {
type: String,
required: true,
},
toolId: {
type: String,
default: null,
},
},
mounted() {
const el = this.$refs["webhook"];
new Webhooks.WebhookView({
el,
type: this.type,
toolId: this.toolId,
});
},
};
</script>
@@ -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,140 @@
<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>
<DatasetErrorDetails
:tool-stderr="jobDetails.tool_stderr"
:job-stderr="jobDetails.job_stderr"
:job-messages="jobDetails.job_messages"
/>
<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 DatasetErrorDetails from "./DatasetErrorDetails";
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,
DatasetErrorDetails,
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,46 @@
<template>
<div v-if="hasDetails">
<h3>Details</h3>
<div v-if="hasMessages" id="dataset-error-job-messages">
<p>Execution resulted in the following messages:</p>
<div v-for="(jobMessage, index) in jobMessages" :key="index">
<pre class="rounded code">{{ jobMessage["desc"] }}</pre>
</div>
</div>
<div v-if="toolStderr">
<p>Tool generated the following standard error:</p>
<pre id="dataset-error-tool-stderr" class="rounded code">{{ toolStderr }}</pre>
</div>
<div v-if="jobStderr">
<p>Galaxy job runner generated the following standard error:</p>
<pre id="dataset-error-job-stderr" class="rounded code">{{ jobStderr }}</pre>
</div>
</div>
</template>
<script>
export default {
props: {
toolStderr: {
type: String,
default: null,
},
jobStderr: {
type: String,
default: null,
},
jobMessages: {
type: Array,
default: null,
},
},
computed: {
hasDetails() {
return this.toolStderr || this.jobStderr || this.hasMessages;
},
hasMessages() {
return this.jobMessages && this.jobMessages.length > 0;
},
},
};
</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);
}
}
@@ -0,0 +1,32 @@
import { mount } from "@vue/test-utils";
import { getLocalVue } from "jest/helpers";
import FormBoolean from "./FormBoolean";
import Vue from "vue";
const localVue = getLocalVue();
describe("FormBoolean", () => {
let wrapper;
beforeEach(() => {
wrapper = mount(FormBoolean, {
propsData: {
value: false,
},
localVue,
});
});
it("check initial value and value change", async () => {
const input = wrapper.find("input");
expect(wrapper.vm.currentValue).toBe(false);
wrapper.setProps({ value: true });
await Vue.nextTick();
expect(wrapper.vm.currentValue).toBe(true);
input.trigger("click");
expect(input.element.checked).toBe(false);
input.trigger("click");
expect(input.element.checked).toBe(true);
expect(wrapper.emitted().input[0][0]).toBe(true);
});
});
@@ -0,0 +1,24 @@
<template>
<b-form-checkbox v-model="currentValue" size="lg" switch />
</template>
<script>
export default {
props: {
value: {
type: Boolean,
required: true,
},
},
computed: {
currentValue: {
get() {
return this.value;
},
set(val) {
this.$emit("input", val);
},
},
},
};
</script>
@@ -24,7 +24,7 @@ describe("FormInput", () => {
expect(input.element.value).toBe("initial_value");
await input.setValue("new_value");
expect(input.element.value).toBe("new_value");
expect(wrapper.emitted().onChange[0][0]).toBe("new_value");
expect(wrapper.emitted().input[0][0]).toBe("new_value");
});
it("check switching to text area", async () => {
@@ -33,6 +33,6 @@ describe("FormInput", () => {
expect(input.element.value).toBe("initial_value");
await input.setValue("new_value");
expect(input.element.value).toBe("new_value");
expect(wrapper.emitted().onChange[0][0]).toBe("new_value");
expect(wrapper.emitted().input[0][0]).toBe("new_value");
});
});
@@ -0,0 +1,32 @@
<template>
<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 {
props: {
id: {
type: String,
required: true,
},
value: {
type: String,
default: "",
},
area: {
type: Boolean,
default: false,
},
},
computed: {
currentValue: {
get() {
return this.value;
},
set(val) {
this.$emit("input", val);
},
},
},
};
</script>
-68
View File
@@ -1,68 +0,0 @@
<template>
<div ref="form" />
</template>
<script>
import Form from "mvc/form/form-view";
export default {
props: {
id: {
type: String,
default: null,
},
inputs: {
type: Array,
required: true,
},
textEnable: {
type: String,
default: null,
},
textDisable: {
type: String,
default: null,
},
initialErrors: {
type: Boolean,
default: false,
},
},
watch: {
id() {
this.onRender();
},
},
mounted() {
this.onRender();
},
methods: {
onChange() {
this.$emit("onChange", this.form.data.create());
},
onRender() {
this.$nextTick(() => {
const el = this.$refs["form"];
this.form = new Form({
el,
inputs: this.inputs,
initial_errors: this.initialErrors,
text_enable: this.textEnable,
text_disable: this.textDisable,
}).on("change", this.onChange);
this.onChange();
});
},
parseUpdate(data) {
this.$nextTick(() => {
this.form.update(data, false);
});
},
parseErrors(data) {
this.$nextTick(() => {
this.form.errors(data);
});
},
},
};
</script>
@@ -0,0 +1,29 @@
import { mount } from "@vue/test-utils";
import { getLocalVue } from "jest/helpers";
import FormCard from "./FormCard";
const localVue = getLocalVue();
describe("FormCard", () => {
let wrapper;
beforeEach(() => {
wrapper = mount(FormCard, {
propsData: {
title: "title",
description: "description",
icon: "icon-class",
},
localVue,
});
});
it("check props", async () => {
const title = wrapper.find(".portlet-title-text > b");
expect(title.text()).toBe("title");
const description = wrapper.find(".portlet-title-text > span");
expect(description.text()).toBe("description");
const icon = wrapper.find(".portlet-title-icon");
expect(icon.classes()).toContain("icon-class");
});
});
+51 -10
View File
@@ -3,22 +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>
{{ version }}
</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,
@@ -26,15 +48,34 @@ export default {
},
description: {
type: String,
required: false,
},
version: {
type: String,
required: false,
default: null,
},
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);
}
},
},
};
+190
View File
@@ -0,0 +1,190 @@
<template>
<div ref="form" />
</template>
<script>
import _ from "underscore";
import Form from "mvc/form/form-view";
import { visitInputs } from "components/Form/utilities";
export default {
props: {
id: {
type: String,
default: null,
},
inputs: {
type: Array,
required: true,
},
errors: {
type: Object,
default: null,
},
sustainRepeats: {
type: Boolean,
default: false,
},
sustainConditionals: {
type: Boolean,
default: false,
},
textEnable: {
type: String,
default: null,
},
textDisable: {
type: String,
default: null,
},
validationScrollTo: {
type: Array,
default: null,
},
replaceParams: {
type: Object,
default: null,
},
},
data() {
return {
formData: {},
};
},
watch: {
id() {
this.onRender();
},
validationScrollTo() {
this.onHighlight(this.validationScrollTo);
},
validation() {
this.onHighlight(this.validation, true);
this.$emit("onValidation", this.validation);
},
inputs() {
this.$nextTick(() => {
this.form.update(this.inputs);
});
},
errors() {
this.$nextTick(() => {
this.form.errors(this.errors);
});
},
replaceParams() {
this.onReplaceParams();
},
},
mounted() {
this.onRender();
},
beforeDestroy() {
this.form.off();
},
computed: {
validation() {
let batch_n = -1;
let batch_src = null;
for (const job_input_id in this.formData) {
const input_value = this.formData[job_input_id];
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 || input_def.step_linked) {
continue;
}
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 [job_input_id, message];
}
}
if (input_value && input_value.batch) {
const n = input_value.values.length;
const src = n > 0 && input_value.values[0] && input_value.values[0].src;
if (src) {
if (batch_src === null) {
batch_src = src;
} else if (batch_src !== src) {
return [
job_input_id,
"Please select either dataset or dataset list fields for all batch mode fields.",
];
}
}
if (batch_n === -1) {
batch_n = n;
} else if (batch_n !== n) {
return [
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>.`,
];
}
}
}
return null;
},
},
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(refreshRequest) {
this.formData = this.form.data.create();
this.$emit("onChange", this.formData, refreshRequest);
},
onRender() {
this.$nextTick(() => {
const el = this.$refs["form"];
this.form = new Form({
el,
inputs: this.inputs,
text_enable: this.textEnable,
text_disable: this.textDisable,
sustain_repeats: this.sustainRepeats,
sustain_conditionals: this.sustainConditionals,
onchange: (refreshRequest) => {
this.onChange(refreshRequest);
},
});
this.onChange();
});
},
onHighlight(validation, silent = false) {
this.form.trigger("reset");
if (validation && validation.length == 2) {
const input_id = this.form.data.match(validation[0]);
this.form.highlight(input_id, validation[1], silent);
}
},
},
};
</script>
@@ -22,7 +22,7 @@ describe("FormElement", () => {
});
});
it("check help text", async () => {
it("check props", async () => {
const help = wrapper.find(".ui-form-info");
expect(help.text()).toBe("help_text");
const error = wrapper.find(".ui-form-error-text");
+21 -11
View File
@@ -6,16 +6,19 @@
</div>
<div class="ui-form-title">{{ title }}</div>
<div class="ui-form-field">
<FormInput :id="id" :value="value" :area="area" @onChange="onChange" />
<FormInput v-if="type == 'text'" :id="id" :area="area" v-model="currentValue" />
<FormBoolean v-else-if="type == 'boolean'" :id="id" v-model="currentValue" />
<span class="ui-form-info form-text text-muted mt-2">{{ help }}</span>
</div>
</div>
</template>
<script>
import FormInput from "./FormInput";
import FormBoolean from "./Elements/FormBoolean";
import FormInput from "./Elements/FormInput";
export default {
components: {
FormBoolean,
FormInput,
},
props: {
@@ -24,16 +27,16 @@ export default {
required: true,
},
value: {
type: String,
default: "",
type: [String, Boolean],
default: null,
},
title: {
type: String,
default: "",
default: null,
},
help: {
type: String,
default: "",
default: null,
},
area: {
type: Boolean,
@@ -43,8 +46,20 @@ export default {
type: String,
default: null,
},
type: {
type: String,
default: "text",
},
},
computed: {
currentValue: {
get() {
return this.value;
},
set(val) {
this.$emit("input", val);
},
},
hasError() {
return !!this.error;
},
@@ -56,10 +71,5 @@ export default {
}
},
},
methods: {
onChange(value) {
this.$emit("onChange", value);
},
},
};
</script>
+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>
-37
View File
@@ -1,37 +0,0 @@
<template>
<textarea v-if="area" class="ui-input" v-model="currentValue" @input="onChange" @change="onChange" />
<input v-else class="ui-input" v-model="currentValue" @input="onChange" @change="onChange" />
</template>
<script>
export default {
props: {
id: {
type: String,
required: true,
},
value: {
type: String,
default: "",
},
area: {
type: Boolean,
default: false,
},
},
watch: {
value(newValue) {
this.currentValue = newValue;
},
},
data() {
return {
currentValue: this.value,
};
},
methods: {
onChange() {
this.$emit("onChange", this.currentValue);
},
},
};
</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);
}
}
+62 -12
View File
@@ -1,14 +1,64 @@
import { getAppRoot } from "onload/loadConfig";
import { copy } from "utils/clipboard";
import _ from "underscore";
export function copyLink(toolId, message) {
copy(`${window.location.origin + getAppRoot()}root?tool_id=${toolId}`, message);
}
/** 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);
}
}
};
export function downloadTool(toolId) {
window.location.href = `${getAppRoot()}api/tools/${toolId}/download`;
}
export function openLink(url) {
window.open(url);
}
/** 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;
};
@@ -1,7 +1,13 @@
<template>
<b-input-group v-if="params">
<DebouncedInput v-model.trim="filterText" v-slot="{ value, input }">
<b-form-input size="sm" :value="value" @input="input" :placeholder="'Search Filter' | localize" />
<b-form-input
size="sm"
:value="value"
@input="input"
:placeholder="'Search Filter' | localize"
data-description="filter text input"
/>
</DebouncedInput>
<b-input-group-append>
@@ -10,6 +16,7 @@
:pressed="showDeleted"
:variant="showDeleted ? 'info' : 'secondary'"
@click="showDeleted = !showDeleted"
data-description="show deleted filter toggle"
>
{{ "Deleted" | localize }}
</b-button>
@@ -18,6 +25,7 @@
:pressed="showHidden"
:variant="showHidden ? 'info' : 'secondary'"
@click="showHidden = !showHidden"
data-description="show hidden filter toggle"
>
{{ "Hidden" | localize }}
</b-button>
@@ -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,171 @@
<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="writable && 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="writable && 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 },
writable: { type: Boolean, default: true },
},
data() {
@@ -163,11 +175,6 @@ export default {
};
},
created() {
// make available to template
this.STATES = STATES;
},
computed: {
...mapGetters("user", ["currentUser"]),
...mapGetters("config", ["config"]),
@@ -180,10 +187,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 +203,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 +225,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 +285,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>
@@ -7,7 +7,8 @@ either through the props, and make updates through the events -->
<template>
<div
class="dataset"
class="dataset history-content"
:id="typeId"
:class="{ expanded, collapsed, selected }"
:data-state="dataset.state"
@keydown.arrow-left.self.stop="$emit('update:expanded', false)"
@@ -15,72 +16,105 @@ 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 v-if="showSelection" :checked="selected" @change="$emit('update:selected', $event)" />
<StatusIcon v-if="!ok" class="status-icon px-1" :state="dataset.state" @click.stop="onStatusClick" />
<StateBtn
v-if="!dataset.visible"
<nav class="content-top-menu p-1 d-flex cursor-pointer" @click.stop="$emit('update:expanded', !expanded)">
<div class="d-flex 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"
/>
<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 title p-1 overflow-hidden">
<h5 class="text-truncate" v-if="collapsed">
<span class="hid" data-description="dataset hid">{{ dataset.hid }}</span>
<span class="name" data-description="dataset 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" :writable="writable" v-on="$listeners" />
</slot>
</div>
</nav>
<!--- read-only tags with name: prefix -->
<div v-if="collapsed && dataset.nameTags.length" class="nametags p-1">
<div v-if="collapsed && dataset.nameTags.length" class="nametags px-2 pb-2">
<Nametag v-for="tag in dataset.nameTags" :key="tag" :tag="tag" />
</div>
<!-- expanded view with editors -->
<header v-if="expanded" class="p-2">
<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 data-description="dataset name">{{ 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>
<StatelessTags class="mt-2" v-model="tags" />
</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">
<DatasetSummary :dataset="dataset" />
<DatasetSummary :dataset="dataset" class="summary" />
<div class="display-applications" v-if="dataset.displayLinks.length">
<div class="display-application" v-for="app in dataset.displayLinks" :key="app.label">
@@ -96,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";
import { StatelessTags } from "components/Tags";
export default {
mixins: [legacyNavigationMixin],
components: {
ClickToEdit,
Annotation,
StatusIcon,
StateBtn,
DatasetMenu,
DatasetSummary,
ContentTags,
Nametag,
IconButton,
EditorMenu,
StatelessTags,
},
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: {
@@ -140,8 +177,43 @@ export default {
typeId() {
return this.dataset.type_id;
},
ok() {
return this.dataset.state == "ok";
isEditing() {
return this.writable && this.editing;
},
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 });
},
},
tags: {
get() {
return this.tempContent?.tags || [];
},
set(newTags) {
if (Array.isArray(newTags)) {
this.patchTempContent({ tags: newTags });
}
},
},
showSelectionBox() {
return this.selectable && this.showSelection;
},
},
methods: {
@@ -154,6 +226,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,5 +1,5 @@
<template>
<component :is="summaryComponent" :dataset="dataset" class="summary" />
<component :is="summaryComponent" :dataset="dataset" />
</template>
<script>
@@ -3,7 +3,7 @@
v-if="dsc"
v-bind="$attrs"
v-on="$listeners"
class="dataset-collection"
class="dataset-collection history-content"
:dsc="dsc"
@update:dsc="onUpdate"
@delete="onDelete"
@@ -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>
@@ -5,14 +5,21 @@
<div
class="dataset dataset-collection collapsed"
:class="{ selected }"
:id="typedId"
: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 v-if="showSelection" :checked="selected" @change="$emit('update:selected', $event)" />
<nav class="content-top-menu p-1 d-flex cursor-pointer">
<div class="d-flex 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'"
@@ -21,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 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>
@@ -66,7 +74,7 @@
</nav>
<!--- read-only tags with name: prefix -->
<div v-if="dsc.nameTags.length" class="nametags p-1">
<div v-if="collapsed && dsc.nameTags.length" class="nametags px-2 pb-2">
<Nametag v-for="tag in dsc.nameTags" :key="tag" :tag="tag" />
</div>
@@ -76,29 +84,39 @@
<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() {
console.log("onStatusClick", ...arguments);
},
},
computed: {
typedId() {
return this.dsc.type_id;
},
collapsed() {
return !this.expanded;
},
},
};
</script>
@@ -2,43 +2,27 @@
inside of other collections -->
<template>
<div
v-bind="$attrs"
v-on="$listeners"
class="collapsed"
:data-state="state"
@keydown.arrow-right.self.stop.prevent="$emit('viewCollection')"
@click.stop="$emit('viewCollection')"
>
<nav class="content-top-menu d-flex align-items-center justify-content-between p-1">
<h5 class="flex-grow-1 overflow-hidden mr-auto text-nowrap text-truncate">
<span class="name">{{ dsc.name }}</span>
<span class="description">
({{ dsc.collectionType | localize }} {{ dsc.collectionCountDescription | localize }})
</span>
</h5>
</nav>
</div>
<DscUI v-if="dsc" v-bind="$attrs" v-on="$listeners" class="dataset-collection history-content" :dsc="dsc" />
</template>
<script>
import { DatasetCollection } from "../model/DatasetCollection";
import { STATES } from "../model";
import DscUI from "./DatasetCollection/DscUI";
export default {
components: {
DscUI,
},
props: {
item: { type: Object, required: true },
index: { type: Number, required: true },
},
computed: {
dsc() {
return new DatasetCollection(this.item);
},
state() {
// TODO: see if there are situations where this rule won't work
// subcollection doesn't have as much information as the collection
// did, but it's probably ok
return this.dsc.state || STATES.OK;
// TODO: I'm sure this is not the right way to set the state, but the collection api
// doesn't return any kind of state value at because the api is inconsistent
// /api/dataset_collections/d071e794759ab192/contents/e144222fed44799a
return new DatasetCollection({ ...this.item, populated_state: STATES.OK });
},
},
};
@@ -0,0 +1,61 @@
<!-- 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"
@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,126 +1,187 @@
<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"
data-description="content filter toggle"
/>
<PriorityMenuItem
key="selection"
title="Operations on multiple datasets"
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"
title="Copy Datasets"
icon="fas fa-copy"
@click="iframeRedirect('/dataset/copy_datasets')"
/>
<b-button-group>
<b-dropdown
class="history-contents-list-action-menu-btn"
size="sm"
text="Selection"
:disabled="!hasSelection"
>
<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>
<PriorityMenuItem
key="collapse-expanded"
title="Collapse Expanded Datasets"
icon="fas fa-compress-alt"
@click="emit('collapseAllContent')"
/>
<b-dropdown-item aria-describedby="history-op-selected-content" v-b-modal:hide-selected-content>
<span v-localize>Hide</span>
</b-dropdown-item>
<PriorityMenuItem
key="show-hidden-datasets"
title="Unhide All Hidden Datasets"
icon="fas fa-eye"
@click="$bvModal.show('show-hidden-content')"
/>
<b-dropdown-item aria-describedby="history-op-selected-content" v-b-modal:show-selected-content>
<span v-localize>Unhide</span>
</b-dropdown-item>
<PriorityMenuItem
key="delete-hidden"
title="Delete Hidden Datasets"
icon="fas fa-trash"
@click="$bvModal.show('delete-hidden-content')"
/>
<b-dropdown-item aria-describedby="history-op-selected-content" v-b-modal:delete-selected-content>
<span v-localize>Delete</span>
</b-dropdown-item>
<PriorityMenuItem
key="purge-hidden"
title="Purge Hidden Datasets"
icon="fas fa-burn"
@click="$bvModal.show('purge-deleted-content')"
/>
</PriorityMenu>
</header>
<b-dropdown-item aria-describedby="history-op-selected-content" v-b-modal:restore-selected-content>
<span v-localize>Undelete</span>
</b-dropdown-item>
<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">
<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-dropdown class="ml-auto" size="sm" text="With Selected" :disabled="!hasSelection">
<b-dropdown-item @click="hideSelected">
{{ "Hide Datasets" | localize }}
</b-dropdown-item>
<b-dropdown-item @click="unhideSelected">
{{ "Unhide Datasets" | localize }}
</b-dropdown-item>
<b-dropdown-item @click="deleteSelected">
{{ "Delete Datasets" | localize }}
</b-dropdown-item>
<b-dropdown-item @click="undeleteSelected">
{{ "Undelete Datasets" | localize }}
</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"
data-description="new content menu"
>
<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')"
data-description="copy datasets"
>
<span v-localize>Copy Datasets</span>
</b-dropdown-item>
<b-dropdown-item
aria-describedby="history-op-new-content"
@click="buildDatasetList"
data-description="build list"
>
<span v-localize>Build Dataset List</span>
</b-dropdown-item>
<b-dropdown-item
aria-describedby="history-op-new-content"
@click="buildDatasetPair"
data-description="build pair"
>
<span v-localize>Build Dataset Pair</span>
</b-dropdown-item>
<b-dropdown-item
aria-describedby="history-op-new-content"
@click="buildListOfPairs"
data-description="build list of pairs"
>
<span v-localize>Build List of Dataset Pairs</span>
</b-dropdown-item>
<b-dropdown-item
aria-describedby="history-op-new-content"
@click="buildCollectionFromRules"
data-description="build collection from rules"
>
<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 -->
@@ -142,18 +203,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 },
@@ -161,6 +220,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 {
@@ -168,13 +229,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;
@@ -184,8 +243,12 @@ export default {
},
},
numSelected() {
return this.contentSelection.size || 0;
},
hasSelection() {
return this.contentSelection.size > 0;
return this.numSelected > 0;
},
countHidden() {
@@ -193,22 +256,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
@@ -233,7 +303,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");
},
@@ -257,8 +330,19 @@ export default {
async buildNewCollection(collectionTypeCode) {
const modalResult = await buildCollectionModal(collectionTypeCode, this.history.id, this.contentSelection);
const newCollection = await createDatasetCollection(this.history, modalResult);
// cache the collection
await cacheContent(newCollection);
this.$emit("manualReload");
// have to hide the source items if that was requested
if (modalResult.hide_source_items) {
this.contentSelection.forEach(async (dataset) => {
await cacheContent({ ...dataset, visible: false }, true);
});
this.$emit("resetSelection");
}
// this.$emit("manualReload");
},
// #endregion
@@ -272,3 +356,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,54 +0,0 @@
<template>
<StatelessTags :value="tags" @before-adding-tag="saveTag" @before-deleting-tag="deleteTag" />
</template>
<script>
// TODO: rewrite tags with provider component instead of dependency injection
import { Content } from "./model";
import { updateContentFields } from "./model/queries";
import { cacheContent } from "./caching";
import { StatelessTags } from "components/Tags";
import { createTag } from "components/Tags/model";
export default {
components: {
StatelessTags,
},
props: {
content: { type: Content, required: true },
},
computed: {
tags() {
return this.content.tags;
},
},
methods: {
async saveTag({ tag, addTag }) {
const newTag = createTag(tag);
if (!newTag.valid) {
return;
}
const tagSet = new Set(this.tags);
tagSet.add(newTag.text);
const tags = Array.from(tagSet);
const ajaxResult = await updateContentFields(this.content, { tags });
await cacheContent(ajaxResult);
addTag(newTag);
},
async deleteTag({ tag, deleteTag }) {
const doomedTag = createTag(tag);
if (!doomedTag.valid) {
return;
}
const tagSet = new Set([...this.tags]);
tagSet.delete(doomedTag.text);
const tags = Array.from(tagSet);
const ajaxResult = await updateContentFields(this.content, { tags });
await cacheContent(ajaxResult);
deleteTag(tag);
},
},
};
</script>
+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,130 @@
<!-- Edits a collection, if that collection is editable. -->
<template>
<section class="history-details">
<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" data-description="edit details">
<div class="flex-grow-1">
<h3 class="history-title" data-description="collection name display">
{{ 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" @keydown.esc="revertAndCancel" data-description="edit form">
<b-textarea
v-model="dscName"
placeholder="Collection Name"
trim
max-rows="4"
data-description="name input"
></b-textarea>
<StatelessTags v-model="tags" class="mt-3 tags" />
</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";
import { StatelessTags } from "components/Tags";
export default {
components: {
ClickToEdit,
ContentTags,
Nametag,
EditorMenu,
StatelessTags,
},
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 });
},
},
tags: {
get() {
return this.tempContent?.tags || [];
},
set(newTags) {
if (Array.isArray(newTags)) {
this.patchTempDsc({ tags: newTags });
}
},
},
},
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();
}
},
revertAndCancel() {
this.revert();
this.editing = false;
},
},
};
</script>
@@ -8,11 +8,20 @@
:get-item-key="(item) => item.type_id"
v-slot="{ isExpanded, setExpanded }"
>
<Layout>
<template v-slot:nav>
<Layout class="dataset-collection-panel">
<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,15 +32,20 @@
: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)"
:data-index="index"
:data-row-key="rowKey"
/>
</template>
</Scroller>
@@ -55,6 +69,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 +84,7 @@ export default {
Scroller,
CollectionContentItem,
ExpandedItems,
IconButton,
},
props: {
history: { type: History, required: true },
@@ -86,14 +102,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,48 @@
<template>
<div>
<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"
data-description="collection breadcrumbs menu"
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" @click="close"> Back to: {{ history.name }} </b-button>
<b-dropdown-item @click="close" data-description="back to history">
<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
class="back"
size="sm"
title="`Return to: ${history.name}`"
@click="close"
data-description="back to history"
>
<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 +56,6 @@ export default {
});
return options;
},
downloadCollectionUrl() {
let url = "";
if (this.rootCollection) {
url = `${this.rootCollection.url}/download`;
}
return url;
},
},
methods: {
reselect(newList) {
@@ -1,18 +1,14 @@
<template>
<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">
<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>
<HistoryPanel v-if="currentHistory" :history="currentHistory" v-on="handlers" id="current-history-panel">
<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,77 @@
<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="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="editor save button"
/>
<!-- revert -->
<IconButton
:title="label('Revert') | l"
class="revert-changes-button mb-1"
:class="{ disabled: !dirty }"
icon="undo"
@click="$emit('revert')"
data-description="editor revert button"
/>
<!-- form status -->
<IconButton
:title="statusLabel | l"
class="status-button mb-1"
:class="{ valid: valid, disabled: !valid }"
:icon="valid ? 'check' : 'exclamation-triangle'"
variant="link"
data-description="editor status"
/>
</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 -47
View File
@@ -1,63 +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 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"
icon="fa fa-plus"
@click="$emit('createNewHistory')"
/>
<PriorityMenuItem
key="view-all-histories"
title="View All Histories"
icon="fa fa-columns"
@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-sync"
@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,
},
};
+18 -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,20 @@
: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"
:data-index="index"
:data-row-key="rowKey"
/>
</template>
</Scroller>
@@ -101,6 +111,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 +129,7 @@ export default {
HistoryContentItem,
ExpandedItems,
SelectedItems,
HistoryMenu,
},
props: {
history: { type: History, required: true },
+113 -169
View File
@@ -1,210 +1,154 @@
<template>
<header>
<div class="d-flex justify-content-between align-items-center">
<h6 class="text-nowrap text-truncate mr-auto">
<span class="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"
icon="fas fa-tags"
:pressed="showTags"
@click="showTags = !showTags"
/>
<PriorityMenuItem
key="edit-history-annotation"
title="Edit Annotation"
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="iframeRedirect('/history/export_archive?preview=True')"
/>
</PriorityMenu>
<template>
<section class="history-details d-flex" data-description="edit details">
<div class="flex-grow-1 overflow-hidden">
<h3 data-description="history name display">{{ historyName || "(History Name)" }}</h3>
<h5 class="history-size">
<span v-if="history.size">{{ history.size | niceFileSize }}</span>
<span v-else>(empty)</span>
</h5>
<!-- display title, annotation, tags -->
<div v-if="!editing">
<p data-description="history annotation" v-if="annotation" class="mt-1">{{ annotation }}</p>
<StatelessTags class="mt-2 tags" :value="tags" :disabled="true" />
</div>
<!-- edit form, change title, annotation, or tags -->
<div v-else class="mt-3" @keydown.esc="revertAndCancel" data-description="edit form">
<b-textarea
class="mb-2"
v-model="historyName"
placeholder="History Name"
trim
max-rows="4"
data-description="name input"
></b-textarea>
<b-textarea
class="mb-2"
v-model="annotation"
placeholder="Annotation (optional)"
trim
max-rows="4"
data-description="annotation input"
></b-textarea>
<StatelessTags class="mt-3 tags" v-model="tags" />
</div>
</div>
<!-- click to edit the name of the history -->
<ClickToEdit
class="history-title mt-2"
tag-name="h3"
v-model="historyName"
:tooltip-title="'Rename history...' | localize"
tooltip-placement="top"
<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";
import { StatelessTags } from "components/Tags";
export default {
mixins: [legacyNavigationMixin],
components: {
PriorityMenu,
PriorityMenuItem,
HistoryTags,
Annotation,
ClickToEdit,
CopyHistoryModal,
EditorMenu,
StatelessTags,
},
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 });
},
},
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();
}
},
revertAndCancel() {
this.revert();
this.editing = false;
},
},
};
</script>
<style lang="scss">
@import "scss/transitions.scss";
</style>
@@ -1,7 +1,7 @@
<template>
<b-alert show>
<h4 class="mb-1">
<i class="fa fa-info-circle"></i>
<i class="fa fa-info-circle empty-message"></i>
<span>{{ message | l }}</span>
</h4>
<p>
@@ -0,0 +1,125 @@
<template>
<div>
<b-dropdown
size="sm"
variant="link"
text="Histories"
toggle-class="text-decoration-none"
data-description="history menu"
>
<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)" data-description="export to file">
<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, this.STATES.RUNNING)) {
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>
@@ -15,7 +15,7 @@ import LIST_OF_PAIRS_COLLECTION_CREATOR from "../../Collections/PairedListCollec
import RULE_BASED_COLLECTION_CREATOR from "../../Collections/RuleBasedCollectionCreatorModal";
// stand-in for buildCollection from history-view-edit.js
export async function buildCollectionModal(collectionType, history_id, selectedContent, hideSourceItems = false) {
export async function buildCollectionModal(collectionType, history_id, selectedContent, hideSourceItems = true) {
// select legacy function
let createFunc;
if (collectionType == "list") {
@@ -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$),
@@ -22,7 +22,7 @@ export const clearHistoryDateStore = async () => {
*/
// prettier-ignore
export const loadHistoryContents = (cfg = {}) => (rawInputs$) => {
const {
const {
noInitial = false,
windowSize = SearchParams.pageSize
} = cfg;
@@ -39,7 +39,7 @@ export const loadHistoryContents = (cfg = {}) => (rawInputs$) => {
return `${baseUrl}?${params.historyContentQueryString}`;
}),
map(prependPath),
requestWithUpdateTime({ dateStore, noInitial, responseQualifier }),
requestWithUpdateTime({ dateStore, noInitial, responseQualifier, dateFieldName: "since" }),
);
const validResponses$ = ajaxResponse$.pipe(
@@ -81,7 +81,7 @@ export const loadHistoryContents = (cfg = {}) => (rawInputs$) => {
const matches = areDefined(matchesUp, matchesDown) ? matchesUp + matchesDown : undefined;
const totalMatches = areDefined(totalMatchesUp, totalMatchesDown) ? totalMatchesUp + totalMatchesDown : undefined;
return {
summary,
matches,
@@ -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;
})
);
@@ -25,7 +25,7 @@ export const requestWithUpdateTime = (config = {}) => {
requestTime = moment.utc(),
// indicates we don't want initial results
noInitial = false,
responseQualifier = () => true
responseQualifier = (response) => response.status == 200
} = config;
// mark and flag this update-time, append to next request with same base
@@ -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,29 +7,23 @@
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);
}
// number of contained contents
// Need to do some manual handling, because once again, the API is
// inconsistent, element_count not returned in the case of a pair. It
// should be two, but we need to add that value in.
get totalElements() {
if ("element_count" in this) {
return this.element_count;
}
if (this.collection_type == "paired") {
return 2;
}
return undefined;
}
@@ -58,14 +52,34 @@ export class DatasetCollection extends Content {
// amalgam state value
get state() {
return this.jobSummary.state || this.populated_state;
return this.jobSummary.state;
}
get jobSummary() {
return new JobStateSummary(this);
return Object.freeze(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();
});
});
});
@@ -1,20 +1,13 @@
/**
* Read-only class takes raw data and applies some rules
* to determine an amalgam state for a collection.
*
* Logic stolen from job-state-model.js and hdca-li.js
*/
import { STATES } from "./states";
// logic largely stolen from the butchered, degenerate mess at:
// import nonsense from "mvc/history/job-states-model.js"
// Job-state-summary lists
const NON_TERMINAL_STATES = [STATES.NEW, STATES.WAITING, STATES.QUEUED, STATES.RUNNING];
const ERROR_STATES = [
STATES.ERROR,
STATES.DELETED, // does this exist?
];
const ERROR_STATES = [STATES.ERROR, STATES.DELETED];
export class JobStateSummary extends Map {
constructor(dsc = {}) {
@@ -23,7 +16,6 @@ export class JobStateSummary extends Map {
this.populated_state = populated_state;
}
// state element has value > 0
hasJobs(key) {
return this.has(key) ? this.get(key) > 0 : false;
}
@@ -33,48 +25,23 @@ export class JobStateSummary extends Map {
return states.some((s) => this.hasJobs(s));
}
// amalgam state, basically just for display purposes
get state() {
// FROM mvc/history/hdca-li.js
//TODO: model currently has no state
// var state;
// var jobStatesSummary = this.model.jobStatesSummary;
// if (jobStatesSummary) {
// if (jobStatesSummary.new()) {
// state = "loading";
// } else if (jobStatesSummary.errored()) {
// state = "error";
// } else if (jobStatesSummary.terminal()) {
// state = "ok";
// } else if (jobStatesSummary.running()) {
// state = "running";
// } else {
// state = "queued";
// }
// } else if (this.model.get("job_source_id")) {
// // Initial rendering - polling will fill in more details in a bit.
// state = "loading";
// } else {
// state = this.model.get("populated_state") ? STATES.OK : STATES.RUNNING;
// }
if (this.jobCount) {
if (this.isErrored) {
return STATES.ERROR;
}
if (this.isNew) {
return STATES.NEW;
}
if (this.isRunning) {
return STATES.RUNNING;
}
if (this.isNew) {
return STATES.LOADING;
}
if (this.isTerminal) {
return STATES.OK;
}
return STATES.QUEUED;
}
return null;
return this.populated_state;
}
// Flags
@@ -84,11 +51,7 @@ export class JobStateSummary extends Map {
}
get isErrored() {
return this.anyHasJobs(...ERROR_STATES);
}
get isRunning() {
return this.hasJobs(STATES.RUNNING);
return this.populated_state == STATES.ERROR || this.anyHasJobs(...ERROR_STATES);
}
get isTerminal() {
@@ -98,6 +61,10 @@ export class JobStateSummary extends Map {
return !this.anyHasJobs(...NON_TERMINAL_STATES);
}
get isRunning() {
return this.hasJobs(STATES.RUNNING);
}
// Counts
get jobCount() {
@@ -109,6 +76,6 @@ export class JobStateSummary extends Map {
}
get runningCount() {
return this.get(STATES.ERRUNNINGROR);
return this.get(STATES.RUNNING);
}
}
@@ -1,7 +1,7 @@
export class ScrollPos {
constructor(props = {}) {
const { cursor = 0.0, key = null } = props;
this.cursor = cursor;
const { cursor = null, key = null } = props;
this.cursor = key === null && cursor === null ? 0 : cursor;
this.key = key;
}
}
@@ -12,5 +12,5 @@ ScrollPos.create = function (props) {
};
ScrollPos.equals = function (a, b) {
return a.cursor == b.cursor && a.key == b.key;
return a.cursor === b.cursor && a.key === b.key;
};
+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({
+15 -8
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) {
@@ -352,7 +359,7 @@ export async function createDatasetCollection(history, inputs = {}) {
copy_elements: true,
name: "list",
element_identifiers: [],
hide_source_items: "True",
hide_source_items: true,
type: "dataset_collection",
};
@@ -33,6 +33,8 @@ export const STATES = {
// found in job-state summary model?
// this an actual state value or something derived from deleted prop?
DELETED: "deleted",
LOADING: "loading",
};
export const NOT_READY_STATES = [STATES.UPLOAD, STATES.QUEUED, STATES.RUNNING, STATES.SETTING_METADATA, STATES.NEW];
@@ -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 }),
@@ -83,7 +83,7 @@ export const ContentProvider = {
methods: {
resetScrollPos() {
this.setScrollPos(ScrollPos.create());
this.setScrollPos();
},
initStreams() {
@@ -96,8 +96,8 @@ export const ContentProvider = {
* cursor (0-1 value representing how far down it is)
* @param {object} Object consisting of key and cursor props
*/
setScrollPos({ cursor = 0.0, key = null } = {}) {
if (isValidNumber(cursor)) {
setScrollPos({ cursor = null, key = null } = {}) {
if (isValidNumber(cursor) || key !== null) {
this.scrollPos = ScrollPos.create({ cursor, key });
}
},
@@ -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,
});
},
};
@@ -148,7 +148,7 @@ describe("HistoryContentProvider", () => {
test("should show results when we pick a HID by scroller height", async () => {
// set scroller to non-existent hid
wrapper.vm.setScrollPos({ cursor: 0.52323, key: null });
wrapper.vm.setScrollPos({ cursor: 0.52323 });
const scrollPayload = await payloadChange({ vm: wrapper.vm });
const { contents, startKey, topRows, bottomRows, totalMatches } = scrollPayload;
const hids = payloadHids(scrollPayload);
@@ -1,4 +1,4 @@
import { concat, race, timer, merge, partition, Observable, BehaviorSubject, Subject } from "rxjs";
import { combineLatest, concat, race, timer, merge, partition, Observable, BehaviorSubject, Subject } from "rxjs";
import {
debounceTime,
distinctUntilChanged,
@@ -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
@@ -64,12 +65,15 @@ export const contentPayload = (cfg = {}) => {
pluck('key')
);
// only have cursor height, need to make a guess
const estimatedHid$ = noKey$.pipe(
const cursor$ = noKey$.pipe(
pluck('cursor'),
withLatestFrom(cursorToHid$),
);
// only have cursor height, need to make a guess
const estimatedHid$ = combineLatest(cursor$, cursorToHid$).pipe(
map((inputs) => estimateHid(...inputs)),
map(hid => Math.round(hid)),
distinctUntilChanged(),
show(debug, hid => console.log("estimatedHid", hid, history)),
);
@@ -78,14 +82,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 +114,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 +138,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
@@ -147,18 +152,13 @@ export const contentPayload = (cfg = {}) => {
map(([[lastResponse, response], pos, hid]) => {
const updatesAtTop = response.maxHid > lastResponse.maxHid;
const scrollerInDefault = (pos.cursor == 0 && pos.key == null);
const scrollerExactlyAtTop = pos.cursor === 0 || pos.key === lastResponse.maxHid;
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);
// console.groupEnd();
const scrollNearLastTop = pos.key !== null ? Math.abs(pos.key - lastResponse.maxContentHid) < fudge : false;
const scrollerAtTop = scrollerExactlyAtTop || scrollNearLastTop;
if (updatesAtTop && scrollerAtTop) {
return new ScrollPos({ cursor: 0, key: response.maxHid })
return ScrollPos.create({ cursor: 0, key: response.maxContentHid });
}
return null;
}),
@@ -190,7 +190,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 new Set();
}
}
+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>
@@ -220,10 +220,10 @@
</b-button>
<b-button
v-if="row.item.can_manage && !row.item.deleted"
@click="navigateToPermission(row.item)"
size="sm"
class="lib-btn permission_lib_btn"
:title="`Permissions of ${row.item.name}`"
:to="{ path: `${navigateToPermission(row.item)}` }"
>
<font-awesome-icon icon="users" />
Manage
@@ -241,17 +241,26 @@
</div>
</template>
</b-table>
<!-- hide pagination if the table is loading-->
<b-container>
<b-row class="justify-content-md-center">
<b-row align-v="center" class="justify-content-md-center">
<b-col md="auto">
<div v-if="isBusy">
<b-spinner small type="grow"></b-spinner>
<b-spinner small type="grow"></b-spinner>
<b-spinner small type="grow"></b-spinner>
</div>
<b-pagination
v-model="currentPage"
v-else
:value="currentPage"
:total-rows="total_rows"
:per-page="perPage"
@input="changePage"
aria-controls="folder_list_body"
>
</b-pagination>
</b-col>
<b-col cols="1.5">
<table>
<tr>
@@ -304,7 +313,6 @@ function initialFolderState() {
include_deleted: false,
search_text: "",
isAllSelectedMode: false,
currentPage: 1,
};
}
export default {
@@ -313,6 +321,11 @@ export default {
type: String,
required: true,
},
page: {
type: Number,
default: 1,
required: false,
},
},
components: {
FolderTopBar,
@@ -323,6 +336,7 @@ export default {
return {
...initialFolderState(),
...{
currentPage: null,
current_folder_id: null,
error: null,
isBusy: false,
@@ -338,11 +352,12 @@ export default {
},
created() {
this.services = new Services({ root: this.root });
this.initFolder(this.folder_id);
this.getFolder(this.folder_id, this.page);
},
methods: {
initFolder(folder_id) {
getFolder(folder_id, page) {
this.current_folder_id = folder_id;
this.currentPage = page;
this.resetData();
this.fetchFolderContents(this.include_deleted);
},
@@ -486,9 +501,9 @@ export default {
},
navigateToPermission(element) {
if (element.type === "file") {
this.$router.push({ path: `${this.folder_id}/dataset/${element.id}/permissions` });
return `/folders/${this.folder_id}/dataset/${element.id}/permissions`;
} else if (element.type === "folder") {
this.$router.push({ path: `${element.id}/permissions` });
return `/folders/${element.id}/permissions`;
}
},
getMessage(element) {
@@ -581,6 +596,9 @@ export default {
);
}
},
changePage(page) {
this.$router.push({ name: `LibraryFolder`, params: { folder_id: this.folder_id, page: page } });
},
/*
Former Backbone code, adopted to work with Vue
@@ -624,11 +642,6 @@ export default {
},
},
watch: {
currentPage: {
handler: function (value) {
this.fetchFolderContents(this.include_deleted);
},
},
perPage: {
handler: function (value) {
this.fetchFolderContents(this.include_deleted);
@@ -636,7 +649,7 @@ export default {
},
},
beforeRouteUpdate(to, from, next) {
this.initFolder(to.params.folder_id);
this.getFolder(to.params.folder_id, to.params.page);
next();
},
};
@@ -1,6 +1,6 @@
<template>
<div>
<PermissionsHeader v-if="folder" :name="folder.name" :path="`/folders/${this.folder.parent_id}`" />
<PermissionsHeader v-if="folder" :name="folder.name" />
<b-container fluid>
<div class="dataset_table">
<h2 class="text-center">Folder permissions</h2>
@@ -24,11 +24,19 @@ export default new VueRouter({
component: LibraryPermissions,
props: true,
},
// redirect to the 1st page
{ path: "/folders/:folder_id", redirect: "/folders/:folder_id/page/1" },
{
path: "/folders/:folder_id",
path: "/folders/:folder_id/page/:page",
name: "LibraryFolder",
component: LibraryFolder,
props: true,
props(route) {
const props = { ...route.params };
if (props.page) {
props.page = +props.page;
}
return props;
},
},
{
path: "/folders/:folder_id/permissions",
@@ -1,6 +1,6 @@
<template>
<div>
<PermissionsHeader v-if="library" :name="library.name" path="/" />
<PermissionsHeader v-if="library" :name="library.name" />
<h2 class="text-center">Library permissions</h2>
<PermissionsInputField
v-if="access_library_role_list"
@@ -1,9 +1,6 @@
<template>
<div>
<div v-if="name">
<b-button variant="primary" title="back to parent folder" :to="{ path: `${this.path}` }">
<font-awesome-icon icon="angle-double-left" />
</b-button>
<div>
<div class="header text-center">{{ name }}</div>
</div>
@@ -15,8 +12,7 @@
<script>
import { getGalaxyInstance } from "app";
import LibraryPermissionsWarning from "components/Libraries/LibraryFolder/LibraryFolderPermissions/LibraryPermissionsWarning.vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import LibraryPermissionsWarning from "components/Libraries/LibraryFolder/LibraryFolderPermissions/LibraryPermissionsWarning";
export default {
props: {
@@ -24,14 +20,9 @@ export default {
type: String,
required: true,
},
path: {
type: String,
required: true,
},
},
components: {
LibraryPermissionsWarning,
FontAwesomeIcon,
},
data() {
return {
@@ -1,45 +1,69 @@
<template>
<div class="w-100 p-2">
<b-card body-class="p-0">
<b-card-header>
<span class="float-right">
<b-button
:href="downloadUrl"
variant="link"
size="sm"
role="button"
title="Download Collection"
type="button"
class="py-0 px-1"
v-b-tooltip.hover
>
<span class="fa fa-download" />
</b-button>
</span>
<span>
<span>Dataset Collection:</span>
<span class="font-weight-light">{{ collectionName }}</span>
</span>
</b-card-header>
<b-card-body>
<LoadingSpan v-if="loading" message="Loading Collection" />
<div v-else class="content-height">
<CollectionTree :node="itemContent" :skip-head="true" />
</div>
</b-card-body>
</b-card>
</div>
<b-card body-class="p-0">
<b-card-header>
<span class="float-right">
<b-button
:href="downloadUrl"
variant="link"
size="sm"
role="button"
title="Download Collection"
type="button"
class="py-0 px-1"
v-b-tooltip.hover
>
<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>
<span class="font-weight-light">{{ collectionName }}</span>
</span>
</b-card-header>
<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>
</b-card>
</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 +79,8 @@ export default {
return {
itemContent: null,
loading: true,
messageText: null,
messageVariant: null,
};
},
created() {
@@ -78,6 +104,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);
@@ -1,58 +1,91 @@
<template>
<div class="w-100 p-2">
<b-card body-class="p-0">
<b-card-header v-if="!embedded">
<span class="float-right">
<b-button
:href="downloadUrl"
variant="link"
size="sm"
role="button"
title="Download Dataset"
type="button"
class="py-0 px-1"
v-b-tooltip.hover
>
<span class="fa fa-download" />
</b-button>
<b-button
:href="importUrl"
role="button"
variant="link"
title="Import Dataset"
type="button"
class="py-0 px-1"
v-b-tooltip.hover
>
<span class="fa fa-file-import" />
</b-button>
</span>
<span>
<span>Dataset:</span>
<span class="font-weight-light">{{ datasetName }}</span>
</span>
</b-card-header>
<b-card-body>
<b-card body-class="p-0">
<b-card-header v-if="!embedded">
<span class="float-right">
<b-button
:href="downloadUrl"
variant="link"
size="sm"
role="button"
title="Download Dataset"
type="button"
class="py-0 px-1"
v-b-tooltip.hover
>
<span class="fa fa-download" />
</b-button>
<b-button
:href="importUrl"
role="button"
variant="link"
title="Import Dataset"
type="button"
class="py-0 px-1"
v-b-tooltip.hover
>
<span class="fa fa-file-import" />
</b-button>
</span>
<span>
<span>Dataset:</span>
<span class="font-weight-light">{{ datasetName }}</span>
</span>
</b-card-header>
<b-card-body>
<UrlDataProvider :url="itemUrl" v-slot="{ result: itemContent, loading, error }">
<LoadingSpan v-if="loading" message="Loading Dataset" />
<div v-else-if="error">{{ error }}</div>
<div v-else class="embedded-dataset content-height">
<pre v-if="itemContent.item_data">
<code class="text-normalwrap">{{ itemContent.item_data }}</code>
</pre>
<div v-else>No content found.</div>
<b-link v-if="itemContent.truncated" :href="itemContent.item_url"> Show More... </b-link>
<UrlDataProvider :url="datatypesUrl" v-slot="{ result: datatypesModel, loading: datatypesLoading }">
<LoadingSpan v-if="datatypesLoading" message="Loading Datatypes" />
<div v-else>
<b-embed
v-if="isSubTypeOfAny(datasetType, ['pdf', 'html'], datatypesModel)"
type="iframe"
aspect="16by9"
:src="displayUrl"
/>
<div v-else-if="itemContent.item_data">
<div v-if="isSubTypeOfAny(datasetType, ['tabular'], datatypesModel)">
<UrlDataProvider
:url="metaUrl"
v-slot="{ result: metaData, loading: metaLoading, error: metaError }"
>
<LoadingSpan v-if="metaLoading" message="Loading Metadata" />
<div v-else-if="metaError">{{ metaError }}</div>
<b-table
v-else
striped
hover
:fields="getFields(metaData)"
:items="getItems(itemContent.item_data, metaData)"
/>
</UrlDataProvider>
</div>
<pre v-else>
<code class="text-normalwrap">{{ itemContent.item_data }}</code>
</pre>
</div>
<div v-else>No content found.</div>
<b-link v-if="itemContent.truncated" :href="itemContent.item_url"> Show More... </b-link>
</div>
</UrlDataProvider>
</div>
</b-card-body>
</b-card>
</div>
</UrlDataProvider>
</b-card-body>
</b-card>
</template>
<script>
import { getAppRoot } from "onload/loadConfig";
import LoadingSpan from "components/LoadingSpan";
import axios from "axios";
import { UrlDataProvider } from "components/providers/UrlDataProvider";
import { DatatypesMapperModel } from "components/Datatypes/model";
export default {
components: {
LoadingSpan,
UrlDataProvider,
},
props: {
args: {
@@ -68,47 +101,81 @@ export default {
default: false,
},
},
data() {
return {
itemContent: null,
loading: true,
};
},
created() {
this.getContent().then((data) => {
this.itemContent = data;
this.loading = false;
});
},
computed: {
datasetType() {
const dataset = this.datasets[this.args.history_dataset_id];
return dataset.ext;
},
datasetName() {
const dataset = this.datasets[this.args.history_dataset_id];
return dataset && dataset.name;
},
datatypesUrl() {
return "api/datatypes/types_and_mapping";
},
downloadUrl() {
return `${getAppRoot()}dataset/display?dataset_id=${this.args.history_dataset_id}`;
},
displayUrl() {
return `${getAppRoot()}datasets/${this.args.history_dataset_id}/display`;
},
importUrl() {
return `${getAppRoot()}dataset/imp?dataset_id=${this.args.history_dataset_id}`;
},
itemUrl() {
return `${getAppRoot()}api/datasets/${this.args.history_dataset_id}/get_content_as_text`;
return `api/datasets/${this.args.history_dataset_id}/get_content_as_text`;
},
metaUrl() {
return `api/datasets/${this.args.history_dataset_id}`;
},
},
methods: {
async getContent() {
try {
const response = await axios.get(this.itemUrl);
return response.data;
} catch (e) {
return `Failed to retrieve content. ${e}`;
isSubTypeOfAny(child, parents, datatypesModel) {
const datatypesMapper = new DatatypesMapperModel(datatypesModel);
return datatypesMapper.isSubTypeOfAny(child, parents);
},
getFields(metaData) {
const fields = [];
const columnNames = metaData.metadata_column_names || [];
const columnCount = metaData.metadata_columns;
for (let i = 0; i < columnCount; i++) {
fields.push({
key: `${i}`,
label: columnNames[i] || i,
sortable: true,
});
}
return fields;
},
getItems(textData, metaData) {
const tableData = [];
const delimiter = metaData.metadata_delimiter || "\t";
const comments = metaData.metadata_comment_lines || 0;
const lines = textData.split("\n");
lines.forEach((line, i) => {
if (i >= comments) {
const tabs = line.split(delimiter);
const rowData = {};
let hasData = false;
tabs.forEach((cellData, j) => {
const cellDataTrimmed = cellData.trim();
if (cellDataTrimmed) {
hasData = true;
}
rowData[j] = cellDataTrimmed;
});
if (hasData) {
tableData.push(rowData);
}
}
});
return tableData;
},
},
};
</script>
<style scoped>
.content-height {
max-height: 15rem;
max-height: 20rem;
}
</style>
@@ -1,9 +1,7 @@
<template>
<div class="w-100 p-2">
<b-card nobody>
<JobMetrics class="job-metrics" :job-id="args.job_id" />
</b-card>
</div>
<b-card nobody>
<JobMetrics class="job-metrics" :job-id="args.job_id" />
</b-card>
</template>
<script>
@@ -1,9 +1,7 @@
<template>
<div class="w-100 p-2">
<b-card nobody>
<JobParameters class="job-parameters" :job-id="args.job_id" :param="args.param" :include-title="false" />
</b-card>
</div>
<b-card nobody>
<JobParameters class="job-parameters" :job-id="args.job_id" :param="args.param" :include-title="false" />
</b-card>
</template>
<script>
@@ -1,11 +1,9 @@
<template>
<div class="w-100 p-2">
<b-card nobody class="content-height">
<div :class="name" :job_id="args.job_id">
<pre><code class="text-normalwrap">{{ jobContent }}</code></pre>
</div>
</b-card>
</div>
<b-card nobody class="content-height">
<div :class="name" :job_id="args.job_id">
<pre><code class="text-normalwrap">{{ jobContent }}</code></pre>
</div>
</b-card>
</template>
<script>
@@ -1,11 +1,9 @@
<template>
<div class="w-100 p-2">
<b-card body-class="embed-responsive embed-responsive-4by3">
<LoadingSpan v-if="loading" class="m-2" message="Loading Visualization" />
<div v-else-if="error" class="m-2">{{ error }}</div>
<iframe v-else class="embed-responsive-item" :src="visualizationUrl" />
</b-card>
</div>
<b-card body-class="embed-responsive embed-responsive-4by3">
<LoadingSpan v-if="loading" class="m-2" message="Loading Visualization" />
<div v-else-if="error" class="m-2">{{ error }}</div>
<iframe v-else class="embed-responsive-item" :src="visualizationUrl" />
</b-card>
</template>
<script>
@@ -1,48 +1,46 @@
<template>
<div class="w-100 p-2">
<b-card body-class="p-0">
<b-card-header v-if="!embedded">
<span class="float-right">
<b-button
:href="downloadUrl"
variant="link"
size="sm"
role="button"
title="Download Workflow"
type="button"
class="py-0 px-1"
v-b-tooltip.hover
>
<span class="fa fa-download" />
</b-button>
<b-button
:href="importUrl"
role="button"
variant="link"
title="Import Workflow"
type="button"
class="py-0 px-1"
v-b-tooltip.hover
>
<span class="fa fa-file-import" />
</b-button>
</span>
<span>
<span>Workflow:</span>
<span class="font-weight-light">{{ workflowName }}</span>
</span>
</b-card-header>
<b-card-body>
<LoadingSpan v-if="loading" message="Loading Workflow" />
<div v-else class="content-height">
<div v-for="step in itemContent.steps" :key="step.order_index" class="mb-2">
<div>Step {{ step.order_index + 1 }}: {{ step.label }}</div>
<WorkflowTree :input="step" :skip-head="true" />
</div>
<b-card body-class="p-0">
<b-card-header v-if="!embedded">
<span class="float-right">
<b-button
:href="downloadUrl"
variant="link"
size="sm"
role="button"
title="Download Workflow"
type="button"
class="py-0 px-1"
v-b-tooltip.hover
>
<span class="fa fa-download" />
</b-button>
<b-button
:href="importUrl"
role="button"
variant="link"
title="Import Workflow"
type="button"
class="py-0 px-1"
v-b-tooltip.hover
>
<span class="fa fa-file-import" />
</b-button>
</span>
<span>
<span>Workflow:</span>
<span class="font-weight-light">{{ workflowName }}</span>
</span>
</b-card-header>
<b-card-body>
<LoadingSpan v-if="loading" message="Loading Workflow" />
<div v-else class="content-height">
<div v-for="step in itemContent.steps" :key="step.order_index" class="mb-2">
<div>Step {{ step.order_index + 1 }}: {{ step.label }}</div>
<WorkflowTree :input="step" :skip-head="true" />
</div>
</b-card-body>
</b-card>
</div>
</div>
</b-card-body>
</b-card>
</template>
<script>
+17 -6
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"
@@ -36,7 +44,7 @@
</div>
</b-alert>
</div>
<div v-for="(obj, index) in markdownObjects" :key="index">
<div v-for="(obj, index) in markdownObjects" :key="index" class="markdown-components">
<p v-if="obj.name == 'default'" v-html="obj.content" class="text-justify m-2" />
<div v-else-if="obj.name == 'generate_galaxy_version'" class="galaxy-version">
<pre><code>{{ getVersion }}</code></pre>
@@ -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>
@@ -19,7 +19,7 @@
@ok="onOk"
@cancel="onCancel"
>
<Form :inputs="formInputs" @onChange="onChange" />
<FormDisplay :inputs="formInputs" @onChange="onChange" />
</b-modal>
</span>
</template>
@@ -29,7 +29,7 @@ import Vue from "vue";
import BootstrapVue from "bootstrap-vue";
import MarkdownSelector from "./MarkdownSelector";
import DataDialog from "components/DataDialog/DataDialog";
import Form from "components/Form/Form";
import FormDisplay from "components/Form/FormDisplay";
Vue.use(BootstrapVue);
@@ -37,7 +37,7 @@ export default {
components: {
MarkdownSelector,
DataDialog,
Form,
FormDisplay,
},
props: {
argumentName: {

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