Merge remote-tracking branch 'upstream/dev' into it-store

This commit is contained in:
Dannon Baker
2023-01-03 10:55:00 -05:00
392 changed files with 12846 additions and 6160 deletions
+1
View File
@@ -0,0 +1 @@
hda
+60
View File
@@ -0,0 +1,60 @@
name: OpenAPI linting
on:
push:
paths-ignore:
- 'client/**'
- 'doc/**'
- 'lib/galaxy_test/selenium/**'
pull_request:
paths-ignore:
- 'client/**'
- 'doc/**'
- 'lib/galaxy_test/selenium/**'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
validate-schema:
name: Validate OpenAPI schema
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ['3.7']
steps:
- uses: actions/checkout@v3
with:
path: 'galaxy root'
- uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Get full Python version
id: full-python-version
shell: bash
run: echo "version=$(python -c 'import sys; print("-".join(str(v) for v in sys.version_info))')" >> $GITHUB_OUTPUT
- name: Cache pip dir
uses: actions/cache@v3
with:
path: ~/.cache/pip
key: pip-cache-${{ matrix.python-version }}-${{ hashFiles('galaxy root/requirements.txt') }}
- name: Cache galaxy venv
uses: actions/cache@v3
with:
path: 'galaxy root/.venv'
key: gxy-venv-${{ runner.os }}-${{ steps.full-python-version.outputs.version }}-${{ hashFiles('galaxy root/requirements.txt') }}-api
- name: Install dependencies
run: ./scripts/common_startup.sh --skip-client-build
working-directory: 'galaxy root'
- name: Lint schema
run: make lint-api-schema
working-directory: 'galaxy root'
- name: Build typescript schema
run: make update-client-api-schema
working-directory: 'galaxy root'
- name: Check for changes
run: |
if [[ `git status --porcelain` ]]; then
echo "Rebuilding client/src/schema/schema.ts resulted in changes, run 'make update-client-api-schema' and commit results"
exit 1
fi
working-directory: 'galaxy root'
+16
View File
@@ -0,0 +1,16 @@
# This file instructs Redocly's linter to ignore the rules contained for specific parts of your API.
# See https://redoc.ly/docs/cli/ for more information.
_schema.yaml:
no-empty-servers:
- '#/openapi'
no-ambiguous-paths:
- >-
#/paths/~1api~1histories~1{history_id}~1contents~1{dataset_id}~1permissions
- >-
#/paths/~1api~1histories~1{history_id}~1contents~1{history_content_id}~1display
- >-
#/paths/~1api~1histories~1{history_id}~1contents~1{history_content_id}~1extra_files
- >-
#/paths/~1api~1histories~1{history_id}~1contents~1{history_content_id}~1metadata_file
- '#/paths/~1api~1histories~1{history_id}~1contents~1{id}~1validate'
- '#/paths/~1api~1histories~1{history_id}~1contents~1{type}s~1{id}'
+5
View File
@@ -0,0 +1,5 @@
organization: galaxyproject.org
extends:
- recommended
rules:
operation-4xx-response: off
+1 -1
View File
@@ -11,7 +11,7 @@ The following individuals have contributed code to Galaxy:
* Patrick Austin <patrick.austin@stfc.ac.uk>
* Raj Ayyampalayam <raj76@uga.edu>
* Abdulrahman Azab <eng.azab@gmail.com>
* Finn Bacall <finn.bacall@cs.man.ac.uk>
* Finn Bacall <finn.bacall@manchester.ac.uk>
* Dannon Baker <dannon.baker@gmail.com>
* balto <balto_59@hotmail.fr>
* Christopher Bare <christopherbare@gmail.com>
+14 -2
View File
@@ -170,8 +170,20 @@ else
endif
update-client-api-schema: node-deps
$(IN_VENV) cd client && python ../scripts/dump_openapi_schema.py | yarn run openapi-typescript --output src/schema/schema.ts && npx prettier --write src/schema/schema.ts
build-api-schema:
$(IN_VENV) python scripts/dump_openapi_schema.py _schema.yaml
remove-api-schema:
rm _schema.yaml
update-client-api-schema: node-deps build-api-schema
$(IN_VENV) cd client && node openapi_to_schema.mjs ../_schema.yaml > src/schema/schema.ts && npx prettier --write src/schema/schema.ts
$(MAKE) remove-api-schema
lint-api-schema: build-api-schema
$(IN_VENV) npx --yes @redocly/cli lint _schema.yaml
$(IN_VENV) codespell -I .ci/ignore-spelling.txt _schema.yaml
$(MAKE) remove-api-schema
client: node-deps ## Rebuild client-side artifacts for local development.
cd client && yarn run build
+1 -1
View File
@@ -87,7 +87,7 @@ While simpler in this example, you may need to manually mock more return values
## Using Composables for more than Stores
Composables can be of great use to extract any reactive code from your components. For an example of this, take a look at [userFilterObjectArray](https://github.com/galaxyproject/galaxy/blob/dev/client/src/composables/utils/filter.js).
Composables can be of great use to extract any reactive code from your components. For an example of this, take a look at [useFilterObjectArray](https://github.com/galaxyproject/galaxy/blob/dev/client/src/composables/utils/filter.js).
Usage:
+433
View File
@@ -0,0 +1,433 @@
# Styleguide
Most of the client's code style is handled by prettier. Prettier does a good job of keeping an overall consistent code style, however there are some cases it can not account for.
This document serves as a guide on how to style your code in such cases, with explanations as to why.
Treat it more like a set of recommendations, than hard rules.
## Naming
Do not abbreviate. This includes naming `variables`, `functions` and `modules`.
> **Do**
>
> ```js
> function errorMessageTemplate(workflowfName, errorMessage) {
> return `Failed to run ${workflowfName}. ${errorMessage}`;
> }
> ```
>
> **Don't**
>
> ```js
> function eMsgTmpl(wfName, msg) {
> return `Failed to run ${wfName}. ${msg}`;
> }
> ```
> **Reason**
>
> While abbreviation may save a few keystrokes now, it will make the code harder to understand, and therefore maintain. Even when you think the abbreviations are obvious within this context, consider people looking at your code in the future might not have the same context you do when writing the code.
## Functions
There are several ways to define functions in JavaScript.
```js
// named function
function myFunction(param) {
// do stuff
}
// arrow functions
const myFunction = (param) => {
// do stuff
};
// anonymous functions
const myFunction = function(param) {
//do stuff
};
```
Only use the first two.
`anonymous functions` have mostly been superseded by `arrow functions`
### When to use the named functions
Use named functions in the top-level module scope and to declare class methods.
> **Reason**
>
> `function` is easy to process and understand at a glance. In module scope, arrow functions offer no benefit over regular functions, and they are not allowed as class methods.
### When to use arrow functions
Use arrow functions when declaring temporary functions within other scopes. This can be within another function, or inside a method that expects a callback function (eg. `array.forEach()`).
> **Reason**
>
> Arrow functions offer benefits about the ambiguity of the `this` keyword within other scopes, as they do not provide their own `this` context.
>
> Binding them to a variable, also makes it clear that this function only exists within said scope, just like any other scoped variable.
### When to use anonymous functions
When possible, use arrow functions instead.
### Examples
> **Do**
>
> ```js
> // in myModules.js
>
> export function myFunction(parameter) {
> const addOne = (value) => {
> return value + 1;
> }
> // do more stuff...
> }
>
> ```
>
> **Don't**
>
> ```js
> // in myModules.js
>
> export const myFunction = (parameter) => {
> const addOne = function(value) {
> return value + 1;
> }
> // do more stuff...
> }
>
> ```
## HTML Multi-Line Layout
Prettier tires to respect whitespace when formatting your HTML templates, even when it doesn't need to. So for example this code:
```vue
<b-button class="danger-button mb-4" variant="danger" @click="onDangerButtonClick">A very Long Button Text</b-button>
```
Might get turned into:
```vue
<b-button class="danger-button mb-4" variant="danger" @click="onDangerButtonClick"
>A very Long Button Text</b-button
>
```
Notice the strange positioning of the `>` brackets.
In the case of the button, this formatting is equivalent to the much more readable:
```vue
<b-button class="danger-button mb-4" variant="danger" @click="onDangerButtonClick">
A very Long Button Text
</b-button>
```
Prettier does not know if our element has significant whitespace, or not. Check if your element has significant whitespace, and if it does not, reformat the HTML to avoid disjointed brackets.
[Further reading about significant whitespace](https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model/Whitespace)
## Spacing
Prettier adds no empty newlines into your code, but they can help in making it more readable.
### Javascript
Add space between blocks of variable definitions and other code.
> **Do**
>
> ```js
> let a = 5;
> let b = 6;
>
> console.log(a + b);
> ```
>
> **Don't**
>
> ```js
> let a = 5;
> let b = 6;
> console.log(a + b);
> ```
Add space between scopes.
> **Do**
>
> ```js
> function myFunction(parameter) {
> if (condition) {
> // do stuff...
> }
>
> if (otherCondition) {
> // do more stuff...
> }
> }
>
> function otherFunction() {
> // do other stuff...
> }
> ```
>
> **Don't**
>
> ```js
> function myFunction(parameter) {
> if (condition) {
> // do stuff...
> }
> if (otherCondition) {
> // do more stuff...
> }
> }
> function otherFunction() {
> // do other stuff...
> }
> ```
Add space between scopes and other code.
> **Do**
>
> ```js
> const myConstant = 5;
>
> if (myConstant === 5) {
> // do stuff...
> }
>
> console.log("log stuff");
> ```
>
> **Don't**
>
> ```js
> const myConstant = 5;
> if (myConstant === 5) {
> // do stuff...
> }
> console.log("log stuff");
> ```
### Vue Components
Add spaces between the `script`, `template` and `style` blocks.
> **Do**
>
> ```vue
> <script setup>
> // do stuff...
> </script>
>
> <template>
> <!--template stuff-->
> </template>
>
> <style lang="scss" scoped>
> // stlye stuff...
> </style>
> ```
>
> **Don't**
>
> ```vue
> <script setup>
> // do stuff...
> </script>
> <template>
> <!--template stuff-->
> </template>
> <style lang="scss" scoped>
> // stlye stuff...
> </style>
> ```
### Vue Templates
Do not add space between elements connected by conditionals.
> **Do**
>
> ```vue
> <div>
> <span v-if="conditional">
> condition met
> </span>
> <span v-else>
> condition not met
> </span>
> </div>
> ```
>
> **Don't**
>
> ```vue
> <div>
> <span v-if="conditional">
> condition met
> </span>
>
> <span v-else>
> condition not met
> </span>
> </div>
> ```
Add space between non-connected elements.
> **Do**
>
> ```vue
> <div>
> <span>
> First span.
> </span>
>
> <span>
> Second span.
> </span>
> </div>
> ```
>
> **Don't**
>
> ```vue
> <div>
> <span>
> First span.
> </span>
> <span>
> Second span.
> </span>
> </div>
> ```
Add space between logical blocks of elements.
> **Do**
>
> ```vue
> <div>
> <span v-if="conditional">
> condition 1 met
> </span>
> <span v-else>
> condition 1 not met
> </span>
>
> <span v-if="otherConditional">
> condition 2 met
> </span>
> <span v-else>
> condition 2 not met
> </span>
> </div>
> ```
>
> **Don't**
>
> ```vue
> <div>
> <span v-if="conditional">
> condition 1 met
> </span>
> <span v-else>
> condition 1 not met
> </span>
> <span v-if="otherConditional">
> condition 2 met
> </span>
> <span v-else>
> condition 2 not met
> </span>
> </div>
> ```
## Casting
Use explicit boolean casting.
> **Do**
>
> ```js
> const condition = Boolean(value);
> ```
>
> **Don't**
>
> ```js
> const condition = !!value;
> ```
> **Reason**
>
> `!!` is not an intentional casting operator. The casting is a side-effect of the `!` operator. Using explicit casting is easier to understand at a glance.
## Equality
When possible, use strict equality.
> **Do**
>
> ```js
> if (value === 5) {
> // do stuff...
> }
>
> if (otherValue !== 6) {
> // do more stuff...
> }
> ```
>
> **Don't**
>
> ```js
> if (value == 5) {
> // do stuff...
> }
>
> if (otherValue != 6) {
> // do more stuff...
> }
> ```
> **Reason**
>
> Strict equality simply checks if two values are equal. Loose equality does type conversion under the hood and is a lot more complicated. While in most cases these do the same, due to the added complexity of loose equality, there are some edge cases where loose equality (`==`) can lead to unexpected problems.
>
> For consistency, treat `===` as the default for equality checking.
>
> [Read more on MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Strict_equality)
## Default value assignment
Use `??` to assign default values.
> **Do**
>
> ```js
> const value = parameter ?? 0;
> ```
>
> **Don't**
>
> ```js
> const value = parameter || 0;
> ```
> **Reason**
>
> The `??` operator uses the right hand value, when the left hand one is unassigned (`undefined` or `null`), while `||` does this on all falsely values (eg. `false` or `0`). This can lead to unexpected bugs in edge cases.
>
> [Read more on MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Nullish_coalescing#assigning_a_default_value_to_a_variable)
+14
View File
@@ -0,0 +1,14 @@
// this is a helper script that fixes const values
// upstream fix in https://github.com/drwpow/openapi-typescript/pull/1014
import openapiTS from "openapi-typescript";
const inputFilePath = process.argv[2];
const localPath = new URL(inputFilePath, import.meta.url);
openapiTS(localPath, {
transform(schemaObject, metadata) {
if ("const" in schemaObject && schemaObject.type === "string") {
return `"${schemaObject.const}"`;
}
},
}).then((output) => console.log(output));
+4 -3
View File
@@ -29,7 +29,7 @@
"@fortawesome/free-regular-svg-icons": "^5.15.4",
"@fortawesome/free-solid-svg-icons": "^5.15.4",
"@fortawesome/vue-fontawesome": "^2.0.9",
"@handsontable/vue": "^2.0.0-beta1",
"@handsontable/vue": "^2.0.0",
"@hirez_io/observer-spy": "^2.1.2",
"@johmun/vue-tags-input": "^2.1.0",
"@pinia/testing": "^0.0.14",
@@ -55,7 +55,7 @@
"file-saver": "^2.0.5",
"flush-promises": "^1.0.2",
"glob": "^7.2.0",
"handsontable": "^2.0.0",
"handsontable": "^4.0.0",
"hsluv": "^0.1.0",
"imask": "^6.4.0",
"jquery": "2",
@@ -99,7 +99,7 @@
"vue-prismjs": "^1.2.0",
"vue-router": "^3.6.5",
"vue-rx": "^6.2.0",
"vue-virtual-scroll-list": "^2.3.3",
"vue-virtual-scroll-list": "^2.3.4",
"vuedraggable": "2.24.3",
"vuex": "^3.6.2",
"vuex-cache": "^3.4.0",
@@ -182,6 +182,7 @@
"style-loader": "^3.3.1",
"ts-jest": "^29.0.3",
"ts-loader": "^9.4.1",
"tsconfig-paths-webpack-plugin": "^4.0.0",
"typescript": "^4.9.3",
"typescript-eslint": "^0.0.1-alpha.0",
"vue-loader": "^15.10.0",
+3 -3
View File
@@ -5,7 +5,7 @@ import BASE_MVC from "./base-mvc";
import userModel from "./user-model";
import metricsLogger from "utils/metrics-logger";
import addLogging from "utils/add-logging";
import localize from "utils/localization";
import { localize, _setUserLocale, _getUserLocale } from "utils/localization";
import { getGalaxyInstance } from "app";
import { create, dialog } from "utils/data";
@@ -77,8 +77,8 @@ GalaxyApp.prototype._init = function (options, bootstrapped) {
this._initUser(options.user || {});
this.debug("GalaxyApp.user: ", this.user);
this.localize._setUserLocale(this.user, this.config);
this.localize._getUserLocale();
_setUserLocale(this.user, this.config);
_getUserLocale();
this.debug("currentLocale: ", sessionStorage.getItem("currentLocale"));
this._setUpListeners();
-1
View File
@@ -20,7 +20,6 @@ export { SweepsterVisualization, SweepsterVisualizationView } from "viz/sweepste
export { createTabularDatasetChunkedView } from "mvc/dataset/data";
export { default as LegacyGridView } from "legacy/grid/grid-view";
export { create_chart, create_histogram } from "reports/run_stats";
export { openGlobalUploadModal } from "components/Upload";
export { runTour } from "components/Tour/runTour";
export { Toast } from "ui/toast"; // TODO: remove when external consumers are updated/gone (IES right now)
+5 -5
View File
@@ -4,11 +4,11 @@
import { computed } from "vue";
import { getAppRoot } from "onload/loadConfig";
import { useConfig } from "composables/config";
import UtcDate from "components/UtcDate.vue";
import License from "components/License/License.vue";
import ExternalLink from "components/ExternalLink.vue";
import { getAppRoot } from "@/onload/loadConfig";
import { useConfig } from "@/composables/config";
import UtcDate from "@/components/UtcDate.vue";
import License from "@/components/License/License.vue";
import ExternalLink from "@/components/ExternalLink.vue";
const { config, isLoaded } = useConfig();
@@ -1,11 +1,11 @@
<script setup lang="ts">
import { ref } from "vue";
import { useDetailedDatatypes } from "composables/datatypes";
import { useFilterObjectArray } from "composables/utils/filter";
import DelayedInput from "components/Common/DelayedInput.vue";
import { useDetailedDatatypes, type DetailedDatatypes } from "@/composables/datatypes";
import { useFilterObjectArray } from "@/composables/utils/filter";
import DelayedInput from "@/components/Common/DelayedInput.vue";
const filter = ref("");
const filterFields = ["extension"];
const filterFields: Array<keyof DetailedDatatypes> = ["extension"];
const { datatypes } = useDetailedDatatypes();
const filteredDatatypes = useFilterObjectArray(datatypes, filter, filterFields);
@@ -35,4 +35,35 @@ describe("ExportForm.vue", () => {
it("should localize button text", async () => {
expect(wrapper.find(".export-button").text()).toBeLocalizationOf("Export");
});
it("should emit 'export' event with correct inputs on export button click", async () => {
await wrapper.setData({
name: "export.tar.gz",
directory: "gxfiles://",
});
expect(wrapper.emitted()).not.toHaveProperty("export");
await wrapper.find(".export-button").trigger("click");
expect(wrapper.emitted()).toHaveProperty("export");
expect(wrapper.emitted()["export"][0][0]).toBe("gxfiles://");
expect(wrapper.emitted()["export"][0][1]).toBe("export.tar.gz");
});
it("should clear the inputs after export when clearInputAfterExport is enabled", async () => {
await wrapper.setProps({
clearInputAfterExport: true,
});
await wrapper.setData({
name: "export.tar.gz",
directory: "gxfiles://",
});
expect(wrapper.vm.directory).toEqual("gxfiles://");
expect(wrapper.vm.name).toEqual("export.tar.gz");
await wrapper.find(".export-button").trigger("click");
expect(wrapper.vm.directory).toBe(null);
expect(wrapper.vm.name).toBe(null);
});
});
+9 -1
View File
@@ -1,5 +1,5 @@
<template>
<div>
<div class="export-to-remote-file">
<b-form-group
id="fieldset-directory"
label-for="directory"
@@ -32,6 +32,10 @@ export default {
type: String,
default: "archive",
},
clearInputAfterExport: {
type: Boolean,
default: false,
},
},
data() {
return {
@@ -59,6 +63,10 @@ export default {
methods: {
doExport() {
this.$emit("export", this.directory, this.name);
if (this.clearInputAfterExport) {
this.directory = null;
this.name = null;
}
},
},
};
@@ -0,0 +1,127 @@
<script setup>
import { computed } from "vue";
import { BAlert, BCard, BCardTitle } from "bootstrap-vue";
import LoadingSpan from "components/LoadingSpan";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { library } from "@fortawesome/fontawesome-svg-core";
import { faExclamationCircle, faExclamationTriangle, faCheckCircle, faClock } from "@fortawesome/free-solid-svg-icons";
import { ExportRecordModel } from "./models/exportRecordModel";
library.add(faExclamationCircle, faExclamationTriangle, faCheckCircle, faClock);
const props = defineProps({
record: {
type: ExportRecordModel,
required: true,
},
objectType: {
type: String,
required: true,
},
actionMessage: {
type: String,
default: null,
},
actionMessageVariant: {
type: String,
default: "info",
},
});
const emit = defineEmits(["onReimport", "onDownload", "onActionMessageDismissed"]);
const title = computed(() => (props.record.isReady ? `Exported` : `Export started`));
const preparingMessage = computed(
() => `Preparing export. This may take some time depending on the size of your ${props.objectType}`
);
async function reimportObject() {
emit("onReimport", props.record);
}
function downloadObject() {
emit("onDownload", props.record);
}
function onMessageDismissed() {
emit("onActionMessageDismissed");
}
</script>
<template>
<b-card class="export-record-details">
<b-card-title>
<b>{{ title }}</b> {{ props.record.elapsedTime }}
</b-card-title>
<p v-if="!props.record.isPreparing">
Format: <b class="record-archive-format">{{ props.record.modelStoreFormat }}</b>
</p>
<span v-if="props.record.isPreparing">
<loading-span :message="preparingMessage" />
</span>
<div v-else>
<div v-if="props.record.hasFailed">
<font-awesome-icon
icon="exclamation-circle"
class="text-danger record-failed-icon"
title="Export failed" />
<span>
Something failed during this export. Please try again and if the problem persist contact your
administrator.
</span>
<b-alert show variant="danger">{{ props.record.errorMessage }}</b-alert>
</div>
<div v-else-if="props.record.isUpToDate" title="Up to date">
<font-awesome-icon icon="check-circle" class="text-success record-up-to-date-icon" />
<span> This export record contains the latest changes of the {{ props.objectType }}. </span>
</div>
<div v-else>
<font-awesome-icon icon="exclamation-triangle" class="text-warning record-outdated-icon" />
<span>
This export is outdated and contains the changes of this {{ props.objectType }} from
{{ props.record.elapsedTime }}.
</span>
</div>
<p v-if="props.record.canExpire" class="mt-3">
<span v-if="props.record.hasExpired">
<font-awesome-icon icon="clock" class="text-danger record-expired-icon" /> This download link has
expired.
</span>
<span v-else>
<font-awesome-icon icon="clock" class="text-warning record-expiration-warning-icon" /> This download
link expires {{ props.record.expirationElapsedTime }}.
</span>
</p>
<div v-if="props.record.isReady">
<p class="mt-3">You can do the following actions with this {{ props.objectType }} export:</p>
<b-alert
v-if="props.actionMessage !== null"
:variant="props.actionMessageVariant"
show
fade
dismissible
@dismissed="onMessageDismissed">
{{ props.actionMessage }}
</b-alert>
<div v-else class="actions">
<b-button
v-if="props.record.canDownload"
class="record-download-btn"
variant="primary"
@click="downloadObject">
Download
</b-button>
<b-button
v-if="props.record.canReimport"
class="record-reimport-btn"
variant="primary"
@click="reimportObject">
Reimport
</b-button>
</div>
</div>
</div>
</b-card>
</template>
@@ -0,0 +1,130 @@
<script setup>
import { computed, ref } from "vue";
import { BCard, BButton, BButtonGroup, BButtonToolbar, BCollapse, BTable, BLink } from "bootstrap-vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { library } from "@fortawesome/fontawesome-svg-core";
import {
faExclamationCircle,
faCheckCircle,
faDownload,
faFileImport,
faSpinner,
} from "@fortawesome/free-solid-svg-icons";
library.add(faExclamationCircle, faCheckCircle, faDownload, faFileImport, faSpinner);
const props = defineProps({
records: {
type: Array,
required: true,
},
});
const emit = defineEmits(["onReimport", "onDownload"]);
const fields = [
{ key: "elapsedTime", label: "Exported" },
{ key: "format", label: "Format" },
{ key: "expires", label: "Expires" },
{ key: "isUpToDate", label: "Up to date", class: "text-center" },
{ key: "isReady", label: "Ready", class: "text-center" },
{ key: "actions", label: "Actions" },
];
const isExpanded = ref(false);
const title = computed(() => (isExpanded.value ? `Hide export records` : `Show export records`));
async function reimportObject(record) {
emit("onReimport", record);
}
function downloadObject(record) {
emit("onDownload", record);
}
</script>
<template>
<div>
<b-link
:class="isExpanded ? null : 'collapsed'"
:aria-expanded="isExpanded ? 'true' : 'false'"
aria-controls="collapse-previous"
@click="isExpanded = !isExpanded">
{{ title }}
</b-link>
<b-collapse id="collapse-previous" v-model="isExpanded">
<b-card>
<b-table :items="props.records" :fields="fields">
<template v-slot:cell(elapsedTime)="row">
<span :title="row.item.date">{{ row.value }}</span>
</template>
<template v-slot:cell(format)="row">
<span>{{ row.item.modelStoreFormat }}</span>
</template>
<template v-slot:cell(expires)="row">
<span v-if="row.item.hasExpired">Expired</span>
<span v-else-if="row.item.expirationDate" :title="row.item.expirationDate">{{
row.item.expirationElapsedTime
}}</span>
<span v-else>No</span>
</template>
<template v-slot:cell(isUpToDate)="row">
<font-awesome-icon
v-if="row.item.isUpToDate"
icon="check-circle"
class="text-success"
title="This export record contains the latest changes." />
<font-awesome-icon
v-else
icon="exclamation-circle"
class="text-danger"
title="This export record is outdated. Please consider generating a new export if you need the latest changes." />
</template>
<template v-slot:cell(isReady)="row">
<font-awesome-icon
v-if="row.item.isReady"
icon="check-circle"
class="text-success"
title="Ready to download or import." />
<font-awesome-icon
v-else-if="row.item.isPreparing"
icon="spinner"
spin
class="text-info"
title="Exporting in progress..." />
<font-awesome-icon
v-else-if="row.item.hasExpired"
icon="exclamation-circle"
class="text-danger"
title="The export has expired." />
<font-awesome-icon
v-else
icon="exclamation-circle"
class="text-danger"
title="The export failed." />
</template>
<template v-slot:cell(actions)="row">
<b-button-toolbar aria-label="Actions">
<b-button-group>
<b-button
v-b-tooltip.hover.bottom
:disabled="!row.item.canDownload"
title="Download"
@click="downloadObject(row.item)">
<font-awesome-icon icon="download" />
</b-button>
<b-button
v-b-tooltip.hover.bottom
:disabled="!row.item.canReimport"
title="Reimport"
@click="reimportObject(row.item)">
<font-awesome-icon icon="file-import" />
</b-button>
</b-button-group>
</b-button-toolbar>
</template>
</b-table>
</b-card>
</b-collapse>
</div>
</template>
@@ -0,0 +1,61 @@
import { ExportRecordModel } from "./exportRecordModel";
import {
EXPECTED_EXPIRATION_DATE,
EXPIRED_STS_DOWNLOAD_RESPONSE,
FAILED_DOWNLOAD_RESPONSE,
FILE_SOURCE_STORE_RESPONSE,
RECENT_STS_DOWNLOAD_RESPONSE,
} from "./testData/exportData";
describe("ExportRecordModel", () => {
describe("STS Download Record", () => {
const stsDownloadRecord = new ExportRecordModel(RECENT_STS_DOWNLOAD_RESPONSE);
it("should be considered temporal (STS) when it has a short term storage ID defined", () => {
expect(stsDownloadRecord.isStsDownload).toBe(true);
expect(stsDownloadRecord.stsDownloadId).toBeTruthy();
});
it("should allow download when ready and not yet expired", () => {
expect(stsDownloadRecord.isReady).toBe(true);
expect(stsDownloadRecord.hasExpired).toBe(false);
expect(stsDownloadRecord.canDownload).toBe(true);
});
});
describe("Expired STS Download Record", () => {
const expiredDownloadRecord = new ExportRecordModel(EXPIRED_STS_DOWNLOAD_RESPONSE);
it("should calculate the correct expiration date", () => {
expect(expiredDownloadRecord.canExpire).toBe(true);
expect(expiredDownloadRecord.expirationDate).toStrictEqual(EXPECTED_EXPIRATION_DATE());
});
it("should not allow download when expired", () => {
expect(expiredDownloadRecord.hasExpired).toBe(true);
expect(expiredDownloadRecord.canDownload).toBe(false);
expect(expiredDownloadRecord.isReady).toBe(false);
});
});
describe("Failed STS Download Record", () => {
const failedDownloadRecord = new ExportRecordModel(FAILED_DOWNLOAD_RESPONSE);
it("should not be downloadable", () => {
expect(failedDownloadRecord.isReady).toBe(false);
expect(failedDownloadRecord.isPreparing).toBe(false);
expect(failedDownloadRecord.hasExpired).toBe(false);
expect(failedDownloadRecord.canDownload).toBe(false);
});
});
describe("File Source Storage Record", () => {
const failedDownloadRecord = new ExportRecordModel(FILE_SOURCE_STORE_RESPONSE);
it("should be importable", () => {
expect(failedDownloadRecord.isReady).toBe(true);
expect(failedDownloadRecord.canReimport).toBe(true);
expect(failedDownloadRecord.importUri).toBeTruthy();
});
});
});
@@ -0,0 +1,148 @@
import { formatDistanceToNow, parseISO } from "date-fns";
import type { components } from "@/schema";
type ExportObjectRequestMetadata = components["schemas"]["ExportObjectRequestMetadata"];
export type StoreExportPayload = components["schemas"]["StoreExportPayload"];
export type ObjectExportTaskResponse = components["schemas"]["ObjectExportTaskResponse"];
export class ExportParamsModel {
private _params: StoreExportPayload;
constructor(data: StoreExportPayload = {}) {
this._params = data;
}
get modelStoreFormat() {
return this._params?.model_store_format;
}
get includeFiles() {
return this._params?.include_files;
}
get includeDeleted() {
return this._params?.include_deleted;
}
get includeHidden() {
return this._params?.include_hidden;
}
public equals(otherExportParams?: ExportParamsModel) {
if (!otherExportParams) {
return false;
}
return (
this.modelStoreFormat === otherExportParams.modelStoreFormat &&
this.includeFiles === otherExportParams.includeFiles &&
this.includeDeleted === otherExportParams.includeDeleted &&
this.includeHidden === otherExportParams.includeHidden
);
}
}
export class ExportRecordModel {
private _data: ObjectExportTaskResponse;
private _expirationDate?: Date | null;
private _requestMetadata?: ExportObjectRequestMetadata;
private _exportParameters?: ExportParamsModel;
constructor(data: ObjectExportTaskResponse) {
this._data = data;
this._expirationDate = undefined;
this._requestMetadata = data.export_metadata?.request_data;
this._exportParameters = this._requestMetadata?.payload
? new ExportParamsModel(this._requestMetadata?.payload)
: undefined;
}
get isReady() {
return (this._data.ready && !this.hasExpired) ?? false;
}
get isPreparing() {
return this._data.preparing ?? false;
}
get isUpToDate() {
return this._data.up_to_date ?? false;
}
get hasFailed() {
return !this.isReady && !this.isPreparing && !this.hasExpired;
}
get date() {
return parseISO(`${this._data.create_time}Z`);
}
get elapsedTime() {
return formatDistanceToNow(this.date, { addSuffix: true });
}
get taskUUID() {
return this._data.task_uuid;
}
get importUri() {
const payload = this._requestMetadata?.payload;
return payload && "target_uri" in payload ? payload.target_uri : undefined;
}
get canReimport() {
return this.isReady && Boolean(this.importUri);
}
get stsDownloadId() {
const payload = this._requestMetadata?.payload;
return payload && "short_term_storage_request_id" in payload
? payload.short_term_storage_request_id
: undefined;
}
get isStsDownload() {
return Boolean(this.stsDownloadId);
}
get canDownload() {
return this.isReady && this.isStsDownload && !this.hasExpired;
}
get modelStoreFormat() {
return this.exportParams?.modelStoreFormat;
}
get exportParams() {
return this._exportParameters;
}
get duration() {
const payload = this._requestMetadata?.payload;
return payload && "duration" in payload ? payload.duration : undefined;
}
get canExpire() {
return this.isStsDownload && Boolean(this.duration);
}
get expirationDate() {
if (this._expirationDate === undefined) {
this._expirationDate = this.duration ? new Date(this.date.getTime() + this.duration * 1000) : null;
}
return this._expirationDate;
}
get expirationElapsedTime() {
return this.canExpire && this.expirationDate
? formatDistanceToNow(this.expirationDate, { addSuffix: true })
: null;
}
get hasExpired() {
return this.canExpire && this.expirationDate && Date.now() > this.expirationDate.getTime();
}
get errorMessage() {
return this._data?.export_metadata?.result_data?.error;
}
}
@@ -0,0 +1,108 @@
import { ExportRecordModel } from "@/components/Common/models/exportRecordModel";
import type { components } from "@/schema";
type ObjectExportTaskResponse = components["schemas"]["ObjectExportTaskResponse"];
type ExportObjectRequestMetadata = components["schemas"]["ExportObjectRequestMetadata"];
type ExportObjectResultMetadata = components["schemas"]["ExportObjectResultMetadata"];
const PAST_EXPORT_DATE = new Date("11 November 2022 14:48 UTC").toISOString();
const RECENT_EXPORT_DATE = new Date().toISOString();
const STS_EXPORT_DURATION_IN_SECONDS = 86400;
export const EXPECTED_EXPIRATION_DATE = () => {
const expectedDate = new Date(PAST_EXPORT_DATE);
expectedDate.setSeconds(expectedDate.getSeconds() + STS_EXPORT_DURATION_IN_SECONDS);
return expectedDate;
};
const FAKE_STS_DOWNLOAD_REQUEST_DATA: ExportObjectRequestMetadata = {
object_id: "3cc0effd29705aa3",
object_type: "history",
user_id: "f597429621d6eb2b",
payload: {
model_store_format: "rocrate.zip",
include_files: true,
include_deleted: false,
include_hidden: false,
short_term_storage_request_id: "08bf4cc3-758e-4a9d-9fe4-a89a0d0604c7",
duration: STS_EXPORT_DURATION_IN_SECONDS,
},
};
const FAKE_FILE_SOURCE_REQUEST_DATA: ExportObjectRequestMetadata = {
object_id: "3cc0effd29705aa3",
object_type: "history",
user_id: "f597429621d6eb2b",
payload: {
model_store_format: "tar.gz",
include_files: true,
include_deleted: false,
include_hidden: false,
target_uri: "gxfiles://fake-target-uri/test.tar.gz",
},
};
const SUCCESS_EXPORT_RESULT_DATA: ExportObjectResultMetadata = {
success: true,
error: undefined,
};
const FAILED_EXPORT_RESULT_DATA: ExportObjectResultMetadata = {
success: false,
error: "Fake Error Message",
};
export const RECENT_STS_DOWNLOAD_RESPONSE: ObjectExportTaskResponse = {
id: "FAKE_RECENT_DOWNLOAD_ID",
ready: true,
preparing: false,
up_to_date: true,
task_uuid: "35563335-e275-4520-80e8-885793279095",
create_time: RECENT_EXPORT_DATE,
export_metadata: {
request_data: FAKE_STS_DOWNLOAD_REQUEST_DATA,
result_data: SUCCESS_EXPORT_RESULT_DATA,
},
};
export const EXPIRED_STS_DOWNLOAD_RESPONSE: ObjectExportTaskResponse = {
id: "FAKE_EXPIRED_DOWNLOAD_ID",
ready: true,
preparing: false,
up_to_date: true,
task_uuid: "35563335-e275-4520-80e8-885793279095",
create_time: PAST_EXPORT_DATE,
export_metadata: {
request_data: FAKE_STS_DOWNLOAD_REQUEST_DATA,
result_data: SUCCESS_EXPORT_RESULT_DATA,
},
};
export const FAILED_DOWNLOAD_RESPONSE: ObjectExportTaskResponse = {
id: "FAKE_FAILED_DOWNLOAD_ID",
ready: false,
preparing: false,
up_to_date: true,
task_uuid: "35563335-e275-4520-80e8-885793279095",
create_time: RECENT_EXPORT_DATE,
export_metadata: {
request_data: FAKE_STS_DOWNLOAD_REQUEST_DATA,
result_data: FAILED_EXPORT_RESULT_DATA,
},
};
export const FILE_SOURCE_STORE_RESPONSE: ObjectExportTaskResponse = {
id: "FAKE_RECENT_DOWNLOAD_ID",
ready: true,
preparing: false,
up_to_date: true,
task_uuid: "35563335-e275-4520-80e8-885793279095",
create_time: RECENT_EXPORT_DATE,
export_metadata: {
request_data: FAKE_FILE_SOURCE_REQUEST_DATA,
result_data: SUCCESS_EXPORT_RESULT_DATA,
},
};
export const EXPIRED_STS_DOWNLOAD_RECORD = new ExportRecordModel(EXPIRED_STS_DOWNLOAD_RESPONSE);
export const FILE_SOURCE_STORE_RECORD = new ExportRecordModel(FILE_SOURCE_STORE_RESPONSE);
export const RECENT_STS_DOWNLOAD_RECORD = new ExportRecordModel(RECENT_STS_DOWNLOAD_RESPONSE);
@@ -46,7 +46,7 @@ import { UrlTracker } from "./utilities";
import { Model } from "./model";
import { Services } from "./services";
import { getAppRoot } from "onload/loadConfig";
import { mountUploadModal } from "components/Upload";
import { useGlobalUploadModal } from "composables/globalUploadModal";
Vue.use(BootstrapVue);
@@ -78,6 +78,10 @@ export default {
default: true,
},
},
setup() {
const { openGlobalUploadModal } = useGlobalUploadModal();
return { openGlobalUploadModal };
},
data() {
return {
errorMessage: null,
@@ -133,7 +137,7 @@ export default {
modalShow: true,
selectable: true,
};
mountUploadModal(propsData);
this.openGlobalUploadModal(propsData);
this.modalShow = false;
},
/** Called when selection is complete, values are formatted and parsed to external callback **/
+3 -3
View File
@@ -1,6 +1,6 @@
import { fetcher } from "schema";
import { safePath } from "utils/redirect";
import type { FetchArgType } from "openapi-typescript-fetch";
import { fetcher } from "@/schema";
import { safePath } from "@/utils/redirect";
const _getDatasets = fetcher.path("/api/datasets").method("get").create();
type GetDatasetsApiOptions = FetchArgType<typeof _getDatasets>;
@@ -17,7 +17,7 @@ export async function getDatasets(options: GetDatasetsOptions = {}) {
const params: GetDatasetsApiOptions = {};
if (options.sortBy) {
const sortPrefix = options.sortDesc ? "-dsc" : "-asc";
params.order = `${options.sortBy}${sortPrefix}&`;
params.order = `${options.sortBy}${sortPrefix}`;
}
if (options.limit) {
params.limit = options.limit;
@@ -4,7 +4,7 @@
:id="datasetId"
v-slot="{ result: dataset, loading: isDatasetLoading, error: datasetLoadingError }">
<div aria-labelledby="dataset-details-heading">
<h1 id="dataset-details-heading" class="hide-element">Dataset Details</h1>
<h1 id="dataset-details-heading" class="sr-only">Dataset Details</h1>
<LoadingSpan v-if="isDatasetLoading" />
<Alert v-else-if="datasetLoadingError" :message="datasetLoadingError" variant="error" />
<CurrentUser v-else v-slot="{ user }">
@@ -2,6 +2,7 @@ import { mount } from "@vue/test-utils";
import { getLocalVue } from "tests/jest/helpers";
import DatasetError from "./DatasetError";
import MockProvider from "../providers/MockProvider";
import MockCurrentUser from "../providers/MockCurrentUser";
jest.mock("components/providers", () => {
return {}; // stubbed below
@@ -33,6 +34,7 @@ function buildWrapper(has_duplicate_inputs = true, has_empty_inputs = true, user
}),
FontAwesomeIcon: false,
FormElement: false,
CurrentUser: MockCurrentUser({ email: "email" }),
},
});
}
@@ -48,7 +50,6 @@ describe("DatasetError", () => {
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 () => {
@@ -61,25 +61,25 @@
show
>{{ resultMessage[0] }}</b-alert
>
<div v-if="showForm" id="fieldsAndButton">
<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"
class="mt-3"
@click="submit(dataset, jobDetails.user_email)">
<font-awesome-icon icon="bug" class="mr-1" />Report
</b-button>
</div>
<CurrentUser v-slot="{ user }">
<div v-if="showForm" id="fieldsAndButton">
<span class="mr-2 font-weight-bold">{{ emailTitle }}</span>
<span v-if="!!user.email">{{ user.email }}</span>
<span v-else>{{ "You must be logged in to receive emails" | l }}</span>
<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"
class="mt-3"
@click="submit(dataset, jobDetails.user_email)">
<font-awesome-icon icon="bug" class="mr-1" />Report
</b-button>
</div>
</CurrentUser>
</div>
</JobDetailsProvider>
</DatasetProvider>
@@ -95,6 +95,7 @@ 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";
import CurrentUser from "components/providers/CurrentUser";
library.add(faBug);
@@ -106,6 +107,7 @@ export default {
FormElement,
JobDetailsProvider,
JobProblemProvider,
CurrentUser,
},
props: {
datasetId: {
@@ -116,9 +118,9 @@ export default {
data() {
return {
message: null,
email: null,
errorMessage: null,
resultMessages: [],
emailTitle: this.l("Your email address"),
};
},
computed: {
@@ -132,8 +134,8 @@ export default {
onError(err) {
this.errorMessage = err;
},
submit(dataset, userEmail) {
const email = this.email || userEmail;
submit(dataset, userEmailJob) {
const email = userEmailJob || this.currentUserEmail;
const message = this.message;
sendErrorReport(dataset, message, email).then(
(resultMessages) => {
+1 -1
View File
@@ -1,4 +1,4 @@
import type { components } from "schema";
import type { components } from "@/schema";
export type DatatypesCombinedMap = components["schemas"]["DatatypesCombinedMap"];
+1 -1
View File
@@ -1,4 +1,4 @@
import { fetcher } from "schema/fetcher";
import { fetcher } from "@/schema/fetcher";
const getTypesAndMappings = fetcher.path("/api/datatypes/types_and_mapping").method("get").create();
@@ -18,11 +18,16 @@ describe("FormBoolean", () => {
it("check initial value and value change", async () => {
const input = wrapper.find("input");
expect(wrapper.vm.currentValue).toBe(false);
const switchComponent = wrapper.findComponent(".custom-switch");
expect(switchComponent.props().value).toBe(false);
await wrapper.setProps({ value: true });
expect(wrapper.vm.currentValue).toBe(true);
expect(switchComponent.props().value).toBe(true);
await input.trigger("click");
expect(input.element.checked).toBe(false);
await input.trigger("click");
expect(input.element.checked).toBe(true);
expect(wrapper.emitted().input[0][0]).toBe(true);
@@ -1,28 +1,30 @@
<script setup lang="ts">
import { computed } from "vue";
export interface FormBooleanProps {
value: boolean | string;
}
const props = defineProps<FormBooleanProps>();
const emit = defineEmits<{
(e: "input", value: boolean): void;
}>();
const currentValue = computed({
get() {
return Boolean(props.value);
},
set(newValue) {
emit("input", newValue);
},
});
const label = computed(() => (currentValue.value ? "Yes" : "No"));
</script>
<template>
<b-form-checkbox v-model="currentValue" class="no-highlight" switch>
{{ label }}
</b-form-checkbox>
</template>
<script>
export default {
props: {
value: {
required: true,
},
},
computed: {
currentValue: {
get() {
return ["true", true].includes(this.value);
},
set(val) {
this.$emit("input", val);
},
},
label() {
return this.currentValue ? "Yes" : "No";
},
},
};
</script>
@@ -0,0 +1,120 @@
import { mount } from "@vue/test-utils";
import { getLocalVue } from "tests/jest/helpers";
import MountTarget from "./FormCheck";
const localVue = getLocalVue(true);
describe("FormCheck", () => {
let wrapper;
beforeEach(() => {
wrapper = mount(MountTarget, {
propsData: {
value: null,
options: [],
},
localVue,
});
});
it("Confirm 'n + 1' checkboxes created (eg. includes the Select-All). Confirm labels and values match. Confirm correct values emitted.", async () => {
const noInput = wrapper.find("[type='checkbox']");
expect(noInput.exists()).toBe(false);
const n = 3;
const options = [];
for (let i = 0; i < n; i++) {
options.push([`label_${i}`, `value_${i}`]);
}
await wrapper.setProps({ options });
const inputs = wrapper.findAll("[type='checkbox']");
const labels = wrapper.findAll(".custom-control-label");
expect(inputs.length).toBe(n + 1);
const expectedValues = [];
for (let i = 0; i < n; i++) {
await inputs.at(i + 1).setChecked();
expect(labels.at(i + 1).text()).toBe(`label_${i}`);
expect(inputs.at(i + 1).attributes("value")).toBe(`value_${i}`);
expectedValues.push(`value_${i}`);
expect(wrapper.emitted()["input"][i][0]).toEqual(expectedValues);
}
});
it("Confirm checkboxes are created when various 'empty values' are passed.", async () => {
const emptyValues = [0, null, false, true, undefined];
const options = [];
for (let i = 0; i < emptyValues.length; i++) {
options.push([`label_${i}`, emptyValues[i]]);
}
await wrapper.setProps({ options });
const inputs = wrapper.findAll("[type='checkbox']");
expect(inputs.length).toBe(emptyValues.length + 1);
const expectedValues = [];
for (let i = 0; i < emptyValues; i++) {
await inputs.at(i + 1).setChecked();
expect(inputs.at(i + 1).attributes("value")).toBe(emptyValues[i]);
expectedValues.push(expectedValues[i]);
expect(wrapper.emitted()["input"][i][0]).toEqual(expectedValues);
}
});
it("Confirm Select-All checkbox works in various states: select-all, unselect-all, indeterminate/partial-list-selection.", async () => {
const n = 3;
const options = [];
for (let i = 0; i < n; i++) {
options.push([`label_${i}`, `value_${i}`]);
}
await wrapper.setProps({ options });
const inputs = wrapper.findAll("[type='checkbox']");
/* confirm number of checkboxes requested matches number checkboxes created */
expect(inputs.length).toBe(n + 1);
/* confirm component loads unchecked */
for (let i = 0; i < n + 1; i++) {
expect(inputs.at(i).element.checked).toBeFalsy();
}
/* 1 - confirm select-all option checked */
await inputs.at(0).setChecked();
expect(inputs.at(0).element.checked).toBeTruthy();
/* ...confirm corresponding options checked */
const values = options.map((option) => option[1]);
expect(wrapper.emitted()["input"][0][0]).toStrictEqual(values);
/* 2 - confirm select-all option UNchecked */
await inputs.at(0).setChecked(false);
expect(inputs.at(0).element.checked).toBeFalsy();
/* ...confirm corresponding options UNchecked */
for (let i = 0; i < n; i++) {
expect(inputs.at(i + 1).element.checked).toBeFalsy();
}
/* 3 - confirm corresponding options indeterminate-state */
await inputs.at(1).setChecked(true);
expect(wrapper.find("input:indeterminate").exists()).toBe(true);
});
});
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { computed, ref } from "vue";
export interface FormCheckProps {
value?: string | string[];
options: string[];
}
const props = defineProps<FormCheckProps>();
const emit = defineEmits<{
(e: "input", value: string[]): void;
}>();
const indeterminate = ref(false);
const currentValue = computed({
get: () => {
const val = props.value ?? [];
return Array.isArray(val) ? val : [val];
},
set: (newValue) => {
emit("input", newValue);
if (newValue.length === 0) {
selectAll.value = false;
indeterminate.value = false;
} else if (newValue.length === props.options.length) {
selectAll.value = true;
indeterminate.value = false;
} else {
indeterminate.value = true;
}
},
});
const hasOptions = computed(() => {
return props.options.length > 0;
});
const selectAll = ref(false);
function onSelectAll() {
if (selectAll.value) {
const allValues = props.options.map((option) => option[1]);
emit("input", allValues);
} else {
emit("input", []);
}
}
</script>
<template>
<div v-if="hasOptions">
<b-form-checkbox
v-model="selectAll"
v-localize
class="mb-1"
:indeterminate="indeterminate"
@input="onSelectAll">
Select / Deselect all
</b-form-checkbox>
<b-form-checkbox-group v-model="currentValue" stacked class="pl-3">
<b-form-checkbox v-for="(option, index) in options" :key="index" :value="option[1]">
{{ option[0] }}
</b-form-checkbox>
</b-form-checkbox-group>
</div>
<b-alert v-else v-localize variant="warning" show> No options available. </b-alert>
</template>
@@ -1,3 +1,29 @@
<script setup lang="ts">
import { computed } from "vue";
export interface FormColorProps {
value?: string;
id: string;
}
const props = withDefaults(defineProps<FormColorProps>(), {
value: "",
});
const emit = defineEmits<{
(e: "input", value: string): void;
}>();
const currentValue = computed({
get() {
return props.value;
},
set(newValue) {
emit("input", newValue);
},
});
</script>
<template>
<b-row>
<b-col class="form-color-input">
@@ -9,30 +35,6 @@
</b-row>
</template>
<script>
export default {
props: {
value: {
type: String,
default: "",
},
id: {
type: String,
required: true,
},
},
computed: {
currentValue: {
get() {
return this.value;
},
set(val) {
this.$emit("input", val);
},
},
},
};
</script>
<style scoped>
.form-color-input {
max-width: 3.6rem;
@@ -1,7 +1,7 @@
<script setup>
import { computed } from "vue";
const $emit = defineEmits(["input"]);
const emit = defineEmits(["input"]);
const props = defineProps({
value: {
default: null,
@@ -17,15 +17,20 @@ const currentValue = computed({
return props.value;
},
set: (val) => {
$emit("input", val);
emit("input", val);
},
});
const hasOptions = computed(() => {
return props.options.length > 0;
});
</script>
<template>
<b-form-radio-group v-model="currentValue" stacked>
<b-form-radio-group v-if="hasOptions" v-model="currentValue" stacked>
<b-form-radio v-for="(option, index) in options" :key="index" :value="option[1]">
{{ option[0] }}
</b-form-radio>
</b-form-radio-group>
<b-alert v-else v-localize variant="warning" show> No options available. </b-alert>
</template>
@@ -0,0 +1,96 @@
<script setup>
import RuleCollectionBuilder from "components/RuleCollectionBuilder";
import RulesDisplay from "components/RulesDisplay/RulesDisplay";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { ref, computed } from "vue";
import { getAppRoot } from "onload/loadConfig";
import axios from "axios";
const props = defineProps({
value: {
type: Object,
},
target: {
type: Object,
default: null,
},
});
const modal = ref(null);
const elements = ref(null);
const initialRules = {
rules: [],
mapping: [],
};
const displayRules = computed(() => props.value ?? initialRules);
async function onEdit() {
if (props.target) {
const url = `${getAppRoot()}api/dataset_collections/${props.target.id}?instance_type=history`;
try {
const response = await axios.get(url);
elements.value = response.data;
modal.value.show();
} catch (e) {
console.error(e);
console.log("problem fetching collection");
}
} else {
modal.value.show();
}
}
const emit = defineEmits(["input"]);
function onSaveRules(rules) {
modal.value.hide();
emit("input", rules);
}
function onCancel() {
modal.value.hide();
}
</script>
<script>
import { library } from "@fortawesome/fontawesome-svg-core";
import { faEdit } from "@fortawesome/free-solid-svg-icons";
library.add(faEdit);
</script>
<template>
<div class="form-rules-edit">
<RulesDisplay :input-rules="displayRules" />
<b-button title="Edit Rules" @click="onEdit">
<FontAwesomeIcon icon="fa-edit" />
<span>Edit</span>
</b-button>
<b-modal ref="modal" modal-class="ui-form-rules-edit-modal" hide-footer>
<template v-slot:modal-title>
<h2 class="mb-0">Build Rules for Applying to Existing Collection</h2>
</template>
<RuleCollectionBuilder
elements-type="collection_contents"
import-type="collections"
:initial-elements="elements"
:initial-rules="props.value"
:save-rules-fn="onSaveRules"
:oncancel="onCancel"
:oncreate="() => {}" />
</b-modal>
</div>
</template>
<style lang="scss">
.ui-form-rules-edit-modal {
.modal-dialog {
width: 100%;
max-width: 85%;
}
}
</style>
@@ -1,5 +1,6 @@
<script setup>
import { computed } from "vue";
import FormCheck from "./FormCheck";
import FormRadio from "./FormRadio";
const $emit = defineEmits(["input"]);
@@ -50,5 +51,6 @@ const currentOptions = computed(() => {
</script>
<template>
<form-radio v-if="display == 'radio'" v-model="currentValue" :options="currentOptions" />
<form-check v-if="display === 'checkboxes'" v-model="currentValue" :options="currentOptions" />
<form-radio v-else-if="display === 'radio'" v-model="currentValue" :options="currentOptions" />
</template>
@@ -8,7 +8,6 @@ import Ui from "mvc/ui/ui-misc";
import SelectContent from "mvc/ui/ui-select-content";
import SelectLibrary from "mvc/ui/ui-select-library";
import SelectFtp from "mvc/ui/ui-select-ftp";
import RulesEdit from "mvc/ui/ui-rules-edit";
import DataPicker from "mvc/ui/ui-data-picker";
// create form view
@@ -197,14 +196,6 @@ export default Backbone.View.extend({
});
},
_fieldRulesEdit: function (input_def) {
return new RulesEdit.View({
id: input_def.id,
onchange: input_def.onchange,
target: input_def.target,
});
},
/** Upload file field */
_fieldUpload: function (input_def) {
return new Ui.Upload({
+74 -106
View File
@@ -1,110 +1,83 @@
<script setup>
import FormBoolean from "./Elements/FormBoolean";
import FormHidden from "./Elements/FormHidden";
import FormInput from "./Elements/FormInput";
import FormParameter from "./Elements/FormParameter";
import FormSelection from "./Elements/FormSelection";
import FormColor from "./Elements/FormColor";
import FormDirectory from "./Elements/FormDirectory";
import FormNumber from "./Elements/FormNumber";
<script setup lang="ts">
import FormBoolean from "./Elements/FormBoolean.vue";
import FormHidden from "./Elements/FormHidden.vue";
import FormInput from "./Elements/FormInput.vue";
import FormParameter from "./Elements/FormParameter.vue";
import FormSelection from "./Elements/FormSelection.vue";
import FormColor from "./Elements/FormColor.vue";
import FormDirectory from "./Elements/FormDirectory.vue";
import FormNumber from "./Elements/FormNumber.vue";
import FormRulesEdit from "./Elements/FormRulesEdit.vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { ref, computed, useAttrs } from "vue";
import { library } from "@fortawesome/fontawesome-svg-core";
import { faExclamation, faTimes, faArrowsAltH } from "@fortawesome/free-solid-svg-icons";
import { faCaretSquareDown, faCaretSquareUp } from "@fortawesome/free-regular-svg-icons";
const props = defineProps({
id: {
type: String,
default: "identifier",
},
type: {
type: String,
default: null,
},
value: {
default: null,
},
title: {
type: String,
default: null,
},
refreshOnChange: {
type: Boolean,
default: false,
},
help: {
type: String,
default: null,
},
error: {
type: String,
default: null,
},
backbonejs: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
attributes: {
type: Object,
default: null,
},
collapsedEnableText: {
type: String,
default: "Enable",
},
collapsedDisableText: {
type: String,
default: "Disable",
},
collapsedEnableIcon: {
type: String,
default: "far fa-caret-square-down",
},
collapsedDisableIcon: {
type: String,
default: "far fa-caret-square-up",
},
connectedEnableText: {
type: String,
default: "Remove connection from module.",
},
connectedDisableText: {
type: String,
default: "Add connection to module.",
},
connectedEnableIcon: {
type: String,
default: "fa fa-times",
},
connectedDisableIcon: {
type: String,
default: "fa fa-arrows-alt-h",
},
workflowBuildingMode: {
type: Boolean,
default: false,
},
import type { ComputedRef } from "vue";
import type { FormParameterTypes, FormParameterAttributes, FormParameterValue } from "./parameterTypes";
export interface FormElementProps {
id?: string;
type?: FormParameterTypes;
value?: FormParameterValue;
title?: string;
refreshOnChange?: boolean;
help?: string;
error?: string;
backbonejs?: boolean;
disabled?: boolean;
attributes?: FormParameterAttributes;
collapsedEnableText?: string;
collapsedDisableText?: string;
collapsedEnableIcon?: string;
collapsedDisableIcon?: string;
connectedEnableText?: string;
connectedDisableText?: string;
connectedEnableIcon?: string;
connectedDisableIcon?: string;
workflowBuildingMode?: boolean;
}
const props = withDefaults(defineProps<FormElementProps>(), {
id: "identifier",
refreshOnChange: false,
backbonejs: false,
disabled: false,
collapsedEnableText: "Enable",
collapsedDisableText: "Disable",
collapsedEnableIcon: "far fa-caret-square-down",
collapsedDisableIcon: "far fa-caret-square-up",
connectedEnableText: "Remove connection from module.",
connectedDisableText: "Add connection to module.",
connectedEnableIcon: "fa fa-times",
connectedDisableIcon: "fa fa-arrows-alt-h",
workflowBuildingMode: false,
});
const emit = defineEmits(["input", "change"]);
const emit = defineEmits<{
(e: "input", value: FormParameterValue, id: string): void;
(e: "change", shouldRefresh: boolean): void;
}>();
//@ts-ignore bad library types
library.add(faExclamation, faTimes, faArrowsAltH, faCaretSquareDown, faCaretSquareUp);
/** TODO: remove attrs computed.
useAttrs is *not* reactive, and does not play nice with type safety.
It is present for compatibility with the legacy "FormParameter" component,
but should be removed as soon as that component is removed.
*/
const attrs = computed(() => props.attributes || useAttrs());
const collapsibleValue = computed(() => attrs.value["collapsible_value"]);
const defaultValue = computed(() => attrs.value["default_value"]);
const connectedValue = { __class__: "ConnectedValue" };
const attrs: ComputedRef<FormParameterAttributes> = computed(() => props.attributes || useAttrs());
const collapsibleValue: ComputedRef<FormParameterValue> = computed(() => attrs.value["collapsible_value"]);
const defaultValue: ComputedRef<FormParameterValue> = computed(() => attrs.value["default_value"]);
const connectedValue: FormParameterValue = { __class__: "ConnectedValue" };
const connected = ref(false);
const collapsed = ref(false);
const collapsible = computed(() => !props.disabled && collapsibleValue.value !== undefined);
const connectable = computed(() => collapsible.value && attrs.value["connectable"]);
const connectable = computed(() => collapsible.value && Boolean(attrs.value["connectable"]));
// Determines to wether expand or collapse the input
{
@@ -117,7 +90,7 @@ const connectable = computed(() => collapsible.value && attrs.value["connectable
}
/** Submits a changed value. */
function setValue(value) {
function setValue(value: FormParameterValue) {
emit("input", value, props.id);
emit("change", props.refreshOnChange);
}
@@ -153,7 +126,7 @@ const showField = computed(() => !collapsed.value && !props.disabled);
const previewText = computed(() => attrs.value["text_value"]);
const helpText = computed(() => {
const helpArgument = attrs.value["argument"];
if (helpArgument && !props.help.includes(`(${helpArgument})`)) {
if (helpArgument && !props.help?.includes(`(${helpArgument})`)) {
return `${props.help} (${helpArgument})`;
} else {
return props.help;
@@ -170,7 +143,9 @@ const currentValue = computed({
});
const isHiddenType = computed(
() => ["hidden", "hidden_data", "baseurl"].includes(props.type) || (props.attributes && props.attributes.titleonly)
() =>
["hidden", "hidden_data", "baseurl"].includes(props.type ?? "") ||
(props.attributes && props.attributes.titleonly)
);
const collapseText = computed(() => (collapsed.value ? props.collapsedEnableText : props.collapsedDisableText));
@@ -181,7 +156,7 @@ const isEmpty = computed(() => {
return true;
}
if (["text", "integer", "float", "password"].includes(props.type) && currentValue.value === "") {
if (["text", "integer", "float", "password"].includes(props.type ?? "") && currentValue.value === "") {
return true;
}
@@ -193,14 +168,6 @@ const isRequiredType = computed(() => props.type !== "boolean");
const isOptional = computed(() => !isRequired.value && attrs.value["optional"] !== undefined);
</script>
<script>
import { library } from "@fortawesome/fontawesome-svg-core";
import { faExclamation, faTimes, faArrowsAltH } from "@fortawesome/free-solid-svg-icons";
import { faCaretSquareDown, faCaretSquareUp } from "@fortawesome/free-regular-svg-icons";
library.add(faExclamation, faTimes, faArrowsAltH, faCaretSquareDown, faCaretSquareUp);
</script>
<template>
<div
v-show="!isHidden"
@@ -257,10 +224,10 @@ library.add(faExclamation, faTimes, faArrowsAltH, faCaretSquareDown, faCaretSqua
v-model="currentValue"
:max="attrs.max"
:min="attrs.min"
:type="type"
:type="props.type ?? 'float'"
:workflow-building-mode="workflowBuildingMode" />
<FormSelection
v-else-if="props.type == 'select' && attrs.display == 'radio'"
v-else-if="props.type === 'select' && ['radio', 'checkboxes'].includes(attrs.display)"
:id="id"
v-model="currentValue"
:data="attrs.data"
@@ -270,12 +237,13 @@ library.add(faExclamation, faTimes, faArrowsAltH, faCaretSquareDown, faCaretSqua
:multiple="attrs.multiple" />
<FormColor v-else-if="props.type === 'color'" :id="props.id" v-model="currentValue" />
<FormDirectory v-else-if="props.type === 'directory_uri'" v-model="currentValue" />
<FormRulesEdit v-else-if="type == 'rules'" v-model="currentValue" :target="attrs.target" />
<FormParameter
v-else-if="backbonejs"
:id="props.id"
v-model="currentValue"
:data-label="props.title"
:type="props.type"
:type="props.type ?? 'text'"
:attributes="attrs" />
<FormInput v-else :id="props.id" v-model="currentValue" :area="attrs['area']" />
</div>
+30
View File
@@ -0,0 +1,30 @@
// TODO: stricter types
export type FormParameterValue = any;
export type FormParameterAttributes = {
[attribute: string]: any;
};
export type FormParameterTypes =
| "boolean"
| "hidden"
| "hidden_data"
| "baseurl"
| "integer"
| "float"
| "radio"
| "color"
| "directory_uri"
| "text"
| "password"
| "select"
| "data_column"
| "genomebuild"
| "data"
| "data_collection"
| "drill_down"
| "group_tag"
| "library_data"
| "ftpfile"
| "upload"
| "rules"
| "data_dialog";
@@ -30,50 +30,70 @@ describe("ContentItem", () => {
name: "name",
selected: false,
selectable: false,
filterable: true,
},
localVue,
stubs: {
DatasetDetails: true,
vueTagsInput: false,
},
provide: {
store: {
dispatch: jest.fn,
getters: {},
},
},
});
});
it("check basics", async () => {
expect(wrapper.attributes("data-hid")).toBe("1");
expect(wrapper.find(".content-title").text()).toBe("name");
const tags = wrapper.find(".alltags").findAll(".ti-tag");
const tags = wrapper.find(".stateless-tags").findAll(".tag");
// verify tags
expect(tags.length).toBe(3);
for (let i = 0; i < 3; i++) {
expect(tags.at(i).text()).toBe(`tag${i + 1}`);
await tags.at(i).find(".tag-name").trigger("click");
await tags.at(i).trigger("click");
expect(wrapper.emitted()["tag-click"][i][0]).toBe(`tag${i + 1}`);
}
// close all tags
for (let i = 0; i < 3; i++) {
const tagRemover = wrapper.find(".ti-icon-close");
const tagRemover = wrapper.find(`.tag[data-option=tag${i + 1}] button`);
await tagRemover.trigger("click");
expect(wrapper.emitted()["tag-change"][i][1]).not.toContain(`tag${i + 1}`);
}
await wrapper.setProps({ isHistoryItem: false, item: { tags: [] } });
expect(wrapper.find(".alltags").exists()).toBe(false);
expect(wrapper.find(".stateless-tags").exists()).toBe(false);
// expansion button
const $el = wrapper.find(".cursor-pointer");
$el.trigger("click");
expect(wrapper.emitted()["update:expand-dataset"]).toBeDefined();
// select and unselect
const noSelector = wrapper.find(".selector > svg");
expect(noSelector.exists()).toBe(false);
await wrapper.setProps({ selectable: true });
expect(wrapper.classes()).toEqual(expect.arrayContaining(["alert-success"]));
const selector = wrapper.find(".selector > svg");
expect(selector.attributes("data-icon")).toBe("square");
selector.trigger("click");
await localVue.nextTick();
expect(wrapper.emitted()["update:selected"][0][0]).toBe(true);
await wrapper.setProps({ selected: true });
selector.trigger("click");
await localVue.nextTick();
expect(wrapper.emitted()["update:selected"][1][0]).toBe(false);
expect(wrapper.classes()).toEqual(expect.arrayContaining(["alert-info"]));
@@ -3,37 +3,18 @@
:id="contentId"
:class="['content-item m-1 p-0 rounded btn-transparent-background', contentCls]"
:data-hid="id"
:data-state="state">
<div
class="p-1 cursor-pointer"
draggable
tabindex="0"
@dragstart="onDragStart"
@click.stop="onClick"
@keypress="onClick">
:data-state="state"
tabindex="0"
role="button"
@keydown="onKeyDown">
<div class="p-1 cursor-pointer" draggable @dragstart="onDragStart" @click.stop="onClick">
<div class="d-flex justify-content-between">
<span class="p-1 font-weight-bold">
<span v-if="selectable" class="selector">
<icon
v-if="selected"
fixed-width
size="lg"
:icon="['far', 'check-square']"
@click.stop="$emit('update:selected', false)" />
<icon
v-else
fixed-width
size="lg"
:icon="['far', 'square']"
@click.stop="$emit('update:selected', true)" />
</span>
<span
v-if="highlight == 'input'"
v-b-tooltip.hover
title="Input"
tabindex="0"
@click.stop="toggleHighlights"
@keypress="toggleHighlights">
<b-button v-if="selectable" class="selector p-0" @click.stop="$emit('update:selected', !selected)">
<icon v-if="selected" fixed-width size="lg" :icon="['far', 'check-square']" />
<icon v-else fixed-width size="lg" :icon="['far', 'square']" />
</b-button>
<span v-if="highlight == 'input'" v-b-tooltip.hover title="Input" @click.stop="toggleHighlights">
<font-awesome-icon class="text-info" icon="arrow-circle-up" />
</span>
<span
@@ -75,6 +56,7 @@
:is-visible="item.visible"
:state="state"
:item-urls="itemUrls"
:keyboard-selectable="expandDataset"
@delete="$emit('delete')"
@display="onDisplay"
@showCollectionInfo="onShowCollectionInfo"
@@ -92,12 +74,12 @@
:elements-datatypes="item.elements_datatypes" />
<StatelessTags
v-if="!tagsDisabled || hasTags"
class="alltags p-1"
:value="tags"
:use-toggle-link="false"
:disabled="tagsDisabled"
@tag-click="onTagClick"
@input="onTags" />
:clickable="filterable"
:use-toggle-link="false"
@input="onTags"
@tag-click="onTagClick" />
<!-- collections are not expandable, so we only need the DatasetDetails component here -->
<b-collapse :visible="expandDataset">
<DatasetDetails
@@ -113,7 +95,7 @@
</template>
<script>
import { StatelessTags } from "components/Tags";
import StatelessTags from "components/TagsMultiselect/StatelessTags";
import { STATES, HIERARCHICAL_COLLECTION_JOB_STATES } from "./model/states";
import CollectionDescription from "./Collection/CollectionDescription";
import ContentOptions from "./ContentOptions";
@@ -146,6 +128,7 @@ export default {
name: { type: String, required: true },
selected: { type: Boolean, default: false },
selectable: { type: Boolean, default: false },
filterable: { type: Boolean, default: false },
},
computed: {
jobState() {
@@ -217,6 +200,15 @@ export default {
},
},
methods: {
onKeyDown(event) {
if (!event.target.classList.contains("content-item")) {
return;
}
if (event.key === "Enter" || event.key === " ") {
this.onClick();
}
},
onClick() {
if (this.isDataset) {
this.$emit("update:expand-dataset", !this.expandDataset);
@@ -251,7 +243,9 @@ export default {
updateContentFields(this.item, { tags: newTags });
},
onTagClick(tag) {
this.$emit("tag-click", tag.label);
if (this.filterable) {
this.$emit("tag-click", tag);
}
},
toggleHighlights() {
this.$emit("toggleHighlights", this.item);
@@ -259,10 +253,21 @@ export default {
},
};
</script>
<style lang="scss">
<style lang="scss" scoped>
@import "~bootstrap/scss/_functions.scss";
@import "theme/blue.scss";
.content-item {
cursor: default;
.name {
word-break: break-all;
}
// improve focus visibility
&:deep(.btn:focus) {
box-shadow: 0 0 0 0.2rem transparentize($brand-primary, 0.75);
}
}
</style>
@@ -17,6 +17,7 @@
v-if="isDataset"
:disabled="displayDisabled"
:title="displayButtonTitle"
:tabindex="tabindex"
class="display-btn px-1"
size="sm"
variant="link"
@@ -28,6 +29,7 @@
v-if="writable && isHistoryItem"
:disabled="editDisabled"
:title="editButtonTitle"
:tabindex="tabindex"
class="edit-btn px-1"
size="sm"
variant="link"
@@ -37,6 +39,7 @@
</b-button>
<b-button
v-if="writable && isHistoryItem && !isDeleted"
:tabindex="tabindex"
class="delete-btn px-1"
title="Delete"
size="sm"
@@ -46,6 +49,7 @@
</b-button>
<b-button
v-if="writable && isHistoryItem && isDeleted"
:tabindex="tabindex"
class="undelete-btn px-1"
title="Undelete"
size="sm"
@@ -55,6 +59,7 @@
</b-button>
<b-button
v-if="writable && isHistoryItem && !isVisible"
:tabindex="tabindex"
class="unhide-btn px-1"
title="Unhide"
size="sm"
@@ -76,6 +81,7 @@ export default {
isVisible: { type: Boolean, default: true },
state: { type: String, default: "" },
itemUrls: { type: Object, required: true },
keyboardSelectable: { type: Boolean, default: true },
},
computed: {
displayButtonTitle() {
@@ -111,6 +117,9 @@ export default {
showCollectionDetailsUrl() {
return prependPath(this.itemUrls.showDetails);
},
tabindex() {
return this.keyboardSelectable ? "0" : "-1";
},
},
};
</script>
@@ -3,7 +3,7 @@
* It allows to select individual items or perform a query selection.
*/
import { getFilters, testFilters } from "utils/filterConversion";
import { HistoryFilters } from "../HistoryFilters";
export default {
props: {
@@ -27,7 +27,7 @@ export default {
return this.allSelected && this.totalItemsInQuery !== this.items.size;
},
currentFilters() {
return getFilters(this.filterText);
return HistoryFilters.getFilters(this.filterText);
},
},
methods: {
@@ -40,7 +40,7 @@ export default {
},
isSelected(item) {
if (this.isQuerySelection) {
return testFilters(this.currentFilters, item);
return HistoryFilters.testFilters(this.currentFilters, item);
}
const key = this.getItemKey(item);
return this.items.has(key);
@@ -28,6 +28,7 @@
:name="item.element_identifier"
:expand-dataset="isExpanded(item)"
:is-dataset="item.element_type == 'hda'"
:filterable="filterable"
@update:expand-dataset="setExpanded(item, $event)"
@view-collection="onViewSubCollection" />
</template>
@@ -63,6 +64,7 @@ export default {
history: { type: Object, required: true },
selectedCollections: { type: Array, required: true },
showControls: { type: Boolean, default: true },
filterable: { type: Boolean, default: false },
},
data() {
return {
@@ -13,14 +13,17 @@
</template>
<script>
import { openGlobalUploadModal } from "components/Upload";
import { useGlobalUploadModal } from "composables/globalUploadModal";
export default {
props: {
message: { type: String, default: "This history is empty." },
},
setup() {
const { openGlobalUploadModal } = useGlobalUploadModal();
return { openGlobalUploadModal };
},
methods: {
openGlobalUploadModal,
clickDataLink() {
this.eventHub.$emit("openToolSection", "getext");
},
@@ -1,7 +1,7 @@
import { mount } from "@vue/test-utils";
import { getLocalVue } from "tests/jest/helpers";
import HistoryFilters from "./HistoryFilters";
import { getQueryDict } from "utils/filterConversion";
import { getLocalVue } from "tests/jest/helpers";
import { HistoryFilters as HistoryFiltering } from "components/History/HistoryFilters";
const localVue = getLocalVue();
@@ -13,8 +13,8 @@ describe("HistoryFilters", () => {
expect(wrapper.emitted()["update:show-advanced"][toggleEmit][0]).toEqual(showAdvanced);
await wrapper.setProps({ showAdvanced: wrapper.emitted()["update:show-advanced"][toggleEmit][0] });
const receivedText = wrapper.emitted()["update:filter-text"][filterEmit][0];
const receivedDict = getQueryDict(receivedText);
const parsedDict = getQueryDict(filterText);
const receivedDict = HistoryFiltering.getQueryDict(receivedText);
const parsedDict = HistoryFiltering.getQueryDict(filterText);
expect(receivedDict).toEqual(parsedDict);
}
@@ -32,6 +32,7 @@ describe("HistoryFilters", () => {
expect(wrapper.find("[description='advanced filters']").exists()).toBe(false);
await wrapper.setProps({ showAdvanced: true });
expect(wrapper.find("[description='advanced filters']").exists()).toBe(true);
expect(wrapper.find("[description='advanced filters']").exists()).toBe(true);
const filterInputs = {
"[placeholder='any name']": "name-filter",
"[placeholder='any extension']": "ext-filter",
@@ -85,7 +85,7 @@
import DebouncedInput from "components/DebouncedInput";
import HistoryFiltersDefault from "./HistoryFiltersDefault";
import { STATES } from "components/History/Content/model/states";
import { getFilters, getFilterText, toAlias } from "utils/filterConversion";
import { HistoryFilters } from "components/History/HistoryFilters";
export default {
components: {
@@ -104,7 +104,7 @@ export default {
},
computed: {
filterSettings() {
return toAlias(getFilters(this.filterText));
return HistoryFilters.toAlias(HistoryFilters.getFilters(this.filterText));
},
localFilter: {
get() {
@@ -134,7 +134,7 @@ export default {
this.onToggle();
this.filterSettings["create_time>"] = this.create_time_gt;
this.filterSettings["create_time<"] = this.create_time_lt;
this.updateFilter(getFilterText(this.filterSettings));
this.updateFilter(HistoryFilters.getFilterText(this.filterSettings));
},
onToggle() {
this.$emit("update:show-advanced", !this.showAdvanced);
@@ -151,13 +151,13 @@ import {
addTagsToSelectedContent,
removeTagsFromSelectedContent,
} from "components/History/model/crud";
import { checkFilter, getQueryDict } from "utils/filterConversion";
import { createDatasetCollection } from "components/History/model/queries";
import { buildCollectionModal } from "components/History/adapters/buildCollectionModal";
import { DbKeyProvider, DatatypesProvider } from "components/providers";
import SingleItemSelector from "components/SingleItemSelector";
import { StatelessTags } from "components/Tags";
import ConfigProvider from "components/providers/ConfigProvider";
import { HistoryFilters } from "components/History/HistoryFilters";
export default {
components: {
@@ -185,11 +185,11 @@ export default {
computed: {
/** @returns {Boolean} */
showHidden() {
return checkFilter(this.filterText, "visible", false);
return HistoryFilters.checkFilter(this.filterText, "visible", false);
},
/** @returns {Boolean} */
showDeleted() {
return checkFilter(this.filterText, "deleted", true);
return HistoryFilters.checkFilter(this.filterText, "deleted", true);
},
/** @returns {Boolean} */
showBuildOptions() {
@@ -265,7 +265,7 @@ export default {
async runOnSelection(operation, extraParams = null) {
this.$emit("update:operation-running", this.history.update_time);
const items = this.getExplicitlySelectedItems();
const filters = getQueryDict(this.filterText);
const filters = HistoryFilters.getQueryDict(this.filterText);
this.$emit("update:show-selection", false);
let expectHistoryUpdate = false;
try {
@@ -1,152 +1,146 @@
<template>
<HistoryItemsProvider
:key="historyId"
v-slot="{ loading, result: itemsLoaded, count: totalItemsInQuery }"
:history-id="historyId"
:offset="offset"
:update-time="history.update_time"
:filter-text="filterText">
<ExpandedItems
v-slot="{ expandedCount, isExpanded, setExpanded, collapseAll }"
:scope-key="historyId"
:get-item-key="(item) => item.type_id">
<SelectedItems
v-slot="{
selectedItems,
showSelection,
isQuerySelection,
selectionSize,
setShowSelection,
selectAllInCurrentQuery,
isSelected,
setSelected,
resetSelection,
}"
:scope-key="queryKey"
:get-item-key="(item) => item.type_id"
:filter-text="filterText"
:total-items-in-query="totalItemsInQuery"
@query-selection-break="querySelectionBreak = true">
<section
class="history-layout d-flex flex-column w-100"
@drop.prevent="onDrop"
@dragenter.prevent="onDragEnter"
@dragover.prevent
@dragleave.prevent="onDragLeave">
<slot name="navigation" :history="history" />
<HistoryFilters
<ExpandedItems
v-slot="{ expandedCount, isExpanded, setExpanded, collapseAll }"
:scope-key="historyId"
:get-item-key="(item) => item.type_id">
<SelectedItems
v-slot="{
selectedItems,
showSelection,
isQuerySelection,
selectionSize,
setShowSelection,
selectAllInCurrentQuery,
isSelected,
setSelected,
resetSelection,
}"
:scope-key="queryKey"
:get-item-key="(item) => item.type_id"
:filter-text="filterText"
:total-items-in-query="totalItemsInQuery"
@query-selection-break="querySelectionBreak = true">
<section
class="history-layout d-flex flex-column w-100"
@drop.prevent="onDrop"
@dragenter.prevent="onDragEnter"
@dragover.prevent
@dragleave.prevent="onDragLeave">
<slot name="navigation" :history="history" />
<HistoryFilters
v-if="showControls"
class="content-operations-filters mx-3"
:filter-text.sync="filterText"
:show-advanced.sync="showAdvanced" />
<section v-if="!showAdvanced">
<HistoryDetails
:history="history"
:writeable="writable"
@update:history="$emit('updateHistory', $event)" />
<HistoryMessages :history="history" />
<HistoryCounter
v-if="showControls"
class="content-operations-filters mx-3"
:history="history"
:is-watching="isWatching"
:last-checked="lastChecked"
:filter-text.sync="filterText"
:show-advanced.sync="showAdvanced" />
<section v-if="!showAdvanced">
<HistoryDetails
:history="history"
:writeable="writable"
@update:history="$emit('updateHistory', $event)" />
<HistoryMessages :history="history" />
<HistoryCounter
v-if="showControls"
:history="history"
:is-watching="isWatching"
:last-checked="lastChecked"
:filter-text.sync="filterText"
@reloadContents="reloadContents" />
<HistoryOperations
v-if="showControls"
:history="history"
:show-selection="showSelection"
:expanded-count="expandedCount"
:has-matches="hasMatches(itemsLoaded)"
:operation-running.sync="operationRunning"
@update:show-selection="setShowSelection"
@collapse-all="collapseAll">
<template v-slot:selection-operations>
<HistorySelectionOperations
:history="history"
:filter-text="filterText"
:content-selection="selectedItems"
:selection-size="selectionSize"
:is-query-selection="isQuerySelection"
:total-items-in-query="totalItemsInQuery"
:operation-running.sync="operationRunning"
@update:show-selection="setShowSelection"
@operation-error="onOperationError"
@hide-selection="onHideSelection"
@reset-selection="resetSelection" />
<HistorySelectionStatus
v-if="showSelection"
:selection-size="selectionSize"
@select-all="selectAllInCurrentQuery(itemsLoaded)"
@reset-selection="resetSelection" />
</template>
</HistoryOperations>
<SelectionChangeWarning :query-selection-break="querySelectionBreak" />
<OperationErrorDialog
v-if="operationError"
:operation-error="operationError"
@hide="operationError = null" />
</section>
<section v-if="!showAdvanced" class="position-relative flex-grow-1 scroller">
<history-drop-zone v-if="showDropZone" />
<div>
<div v-if="loading && itemsLoaded && itemsLoaded.length === 0">
<b-alert class="m-2" variant="info" show>
<LoadingSpan message="Loading History" />
</b-alert>
</div>
<b-alert v-else-if="isProcessing" class="m-2" variant="info" show>
<LoadingSpan message="Processing operation" />
</b-alert>
<div v-else-if="itemsLoaded.length === 0">
<HistoryEmpty v-if="queryDefault" class="m-2" />
<b-alert v-else class="m-2" variant="info" show>
No data found for selected filter.
</b-alert>
</div>
<ListingLayout
v-else
:offset="listOffset"
:items="itemsLoaded"
:query-key="queryKey"
@scroll="onScroll">
<template v-slot:item="{ item, currentOffset }">
<ContentItem
v-if="!invisible[item.hid]"
:id="item.hid"
is-history-item
:item="item"
:name="item.name"
:writable="writable"
:expand-dataset="isExpanded(item)"
:is-dataset="isDataset(item)"
:highlight="getHighlight(item)"
:selected="isSelected(item)"
:selectable="showSelection"
@tag-click="onTagClick"
@tag-change="onTagChange"
@toggleHighlights="toggleHighlights"
@update:expand-dataset="setExpanded(item, $event)"
@update:selected="setSelected(item, $event)"
@view-collection="$emit('view-collection', item, currentOffset)"
@delete="onDelete(item)"
@undelete="onUndelete(item)"
@unhide="onUnhide(item)" />
</template>
</ListingLayout>
</div>
</section>
@reloadContents="reloadContents" />
<HistoryOperations
v-if="showControls"
:history="history"
:show-selection="showSelection"
:expanded-count="expandedCount"
:has-matches="hasMatches(itemsLoaded)"
:operation-running.sync="operationRunning"
@update:show-selection="setShowSelection"
@collapse-all="collapseAll">
<template v-slot:selection-operations>
<HistorySelectionOperations
:history="history"
:filter-text="filterText"
:content-selection="selectedItems"
:selection-size="selectionSize"
:is-query-selection="isQuerySelection"
:total-items-in-query="totalItemsInQuery"
:operation-running.sync="operationRunning"
@update:show-selection="setShowSelection"
@operation-error="onOperationError"
@hide-selection="onHideSelection"
@reset-selection="resetSelection" />
<HistorySelectionStatus
v-if="showSelection"
:selection-size="selectionSize"
@select-all="selectAllInCurrentQuery(itemsLoaded)"
@reset-selection="resetSelection" />
</template>
</HistoryOperations>
<SelectionChangeWarning :query-selection-break="querySelectionBreak" />
<OperationErrorDialog
v-if="operationError"
:operation-error="operationError"
@hide="operationError = null" />
</section>
</SelectedItems>
</ExpandedItems>
</HistoryItemsProvider>
<section v-if="!showAdvanced" class="position-relative flex-grow-1 scroller">
<history-drop-zone v-if="showDropZone" />
<div>
<div v-if="loading && itemsLoaded && itemsLoaded.length === 0">
<b-alert class="m-2" variant="info" show>
<LoadingSpan message="Loading History" />
</b-alert>
</div>
<b-alert v-else-if="isProcessing" class="m-2" variant="info" show>
<LoadingSpan message="Processing operation" />
</b-alert>
<div v-else-if="itemsLoaded.length === 0">
<HistoryEmpty v-if="queryDefault" class="m-2" />
<b-alert v-else class="m-2" variant="info" show>
No data found for selected filter.
</b-alert>
</div>
<ListingLayout
v-else
:offset="listOffset"
:items="itemsLoaded"
:query-key="queryKey"
@scroll="onScroll">
<template v-slot:item="{ item, currentOffset }">
<ContentItem
v-if="!invisible[item.hid]"
:id="item.hid"
is-history-item
:item="item"
:name="item.name"
:writable="writable"
:expand-dataset="isExpanded(item)"
:is-dataset="isDataset(item)"
:highlight="getHighlight(item)"
:selected="isSelected(item)"
:selectable="showSelection"
:filterable="filterable"
@tag-click="onTagClick"
@tag-change="onTagChange"
@toggleHighlights="toggleHighlights"
@update:expand-dataset="setExpanded(item, $event)"
@update:selected="setSelected(item, $event)"
@view-collection="$emit('view-collection', item, currentOffset)"
@delete="onDelete(item)"
@undelete="onUndelete(item)"
@unhide="onUnhide(item)" />
</template>
</ListingLayout>
</div>
</section>
</section>
</SelectedItems>
</ExpandedItems>
</template>
<script>
import Vue from "vue";
import { Toast } from "composables/toast";
import { mapActions } from "vuex";
import { HistoryItemsProvider } from "components/providers/storeProviders";
import { mapActions as vuexMapActions } from "vuex";
import { mapActions, mapState, storeToRefs } from "pinia";
import { useHistoryItemsStore } from "stores/history/historyItemsStore";
import LoadingSpan from "components/LoadingSpan";
import ContentItem from "components/History/Content/ContentItem";
import { deleteContent, updateContentFields } from "components/History/model/queries";
@@ -178,7 +172,6 @@ export default {
HistoryDropZone,
HistoryEmpty,
HistoryFilters,
HistoryItemsProvider,
HistoryOperations,
HistorySelectionOperations,
HistorySelectionStatus,
@@ -194,13 +187,16 @@ export default {
filter: { type: String, default: "" },
writable: { type: Boolean, default: true },
showControls: { type: Boolean, default: true },
filterable: { type: Boolean, default: false },
},
data() {
return {
error: null,
filterText: "",
highlights: {},
highlightsKey: null,
invisible: {},
loading: false,
offset: 0,
showAdvanced: false,
showDropZone: false,
@@ -210,10 +206,15 @@ export default {
};
},
computed: {
...mapState(useHistoryItemsStore, ["getHistoryItems"]),
/** @returns {String} */
historyId() {
return this.history.id;
},
/** @returns {Date} */
historyUpdateTime() {
return this.history.update_time;
},
/** @returns {String} */
queryKey() {
return `${this.historyId}-${this.filterText}`;
@@ -230,12 +231,24 @@ export default {
isProcessing() {
return this.operationRunning >= this.history.update_time;
},
/** @returns {Array} */
itemsLoaded() {
return this.getHistoryItems(this.historyId, this.filterText);
},
/** @returns {Date} */
lastChecked() {
return this.$store.getters.getLastCheckedTime();
const { getLastCheckedTime } = storeToRefs(useHistoryItemsStore());
return getLastCheckedTime.value;
},
/** @returns {Number} */
totalItemsInQuery() {
const { getTotalMatchesCount } = storeToRefs(useHistoryItemsStore());
return getTotalMatchesCount.value;
},
/** @returns {Boolean} */
isWatching() {
return this.$store.getters.getWatchingVisibility();
const { getWatchingVisibility } = storeToRefs(useHistoryItemsStore());
return getWatchingVisibility.value;
},
},
watch: {
@@ -243,6 +256,7 @@ export default {
this.invisible = {};
this.offset = 0;
this.resetHighlights();
this.loadHistoryItems();
},
historyId(newVal, oldVal) {
if (newVal !== oldVal) {
@@ -253,9 +267,19 @@ export default {
filter(newVal) {
this.filterText = newVal;
},
offset() {
this.loadHistoryItems();
},
historyUpdateTime() {
this.loadHistoryItems();
},
},
async mounted() {
await this.loadHistoryItems();
},
methods: {
...mapActions("history", ["loadHistoryById"]),
...vuexMapActions("history", ["loadHistoryById"]),
...mapActions(useHistoryItemsStore, ["fetchHistoryItems"]),
getHighlight(item) {
return this.highlights[this.getItemKey(item)];
},
@@ -268,6 +292,18 @@ export default {
isDataset(item) {
return item.history_content_type == "dataset";
},
async loadHistoryItems() {
this.loading = true;
try {
await this.fetchHistoryItems(this.historyId, this.filterText, this.offset);
this.error = null;
this.loading = false;
} catch (error) {
console.debug("HistoryPanel - Load error.", error);
this.error = error;
this.loading = false;
}
},
onDelete(item) {
this.setInvisible(item);
deleteContent(item);
@@ -0,0 +1,80 @@
<script setup>
import { computed, reactive, ref } from "vue";
import { BCard, BFormSelect, BFormCheckbox, BFormGroup, BCollapse, BLink } from "bootstrap-vue";
import { ExportParamsModel } from "components/Common/models/exportRecordModel";
import { AVAILABLE_EXPORT_FORMATS } from "./services";
const props = defineProps({
exportParams: {
type: ExportParamsModel,
required: true,
},
});
const emit = defineEmits(["onValueChanged"]);
const isExpanded = ref(false);
const title = computed(() => (isExpanded.value ? `Hide advanced export options` : `Show advanced export options`));
const localOptions = reactive({
modelStoreFormat: props.exportParams.modelStoreFormat,
includeFiles: props.exportParams.includeFiles,
includeDeleted: props.exportParams.includeDeleted,
includeHidden: props.exportParams.includeHidden,
});
function onValueChanged() {
emit("onValueChanged", localOptions);
}
</script>
<template>
<div>
<b-link
id="toggle-options-link"
:class="isExpanded ? null : 'collapsed'"
:aria-expanded="isExpanded ? 'true' : 'false'"
aria-controls="collapse-options"
@click="isExpanded = !isExpanded">
{{ title }}
</b-link>
<b-collapse id="collapse-options" v-model="isExpanded">
<b-card>
<b-form-group label="Export Format:" label-for="format">
<b-form-select
id="format-selector"
v-model="localOptions.modelStoreFormat"
:options="AVAILABLE_EXPORT_FORMATS"
value-field="id"
text-field="name"
@change="onValueChanged" />
</b-form-group>
<b-form-group label="Dataset files included in the package:">
<b-form-checkbox
id="include-files-check"
v-model="localOptions.includeFiles"
switch
@change="onValueChanged">
Include Active
</b-form-checkbox>
<b-form-checkbox
id="include-deleted-check"
v-model="localOptions.includeDeleted"
switch
@change="onValueChanged">
Include Deleted (not purged)
</b-form-checkbox>
<b-form-checkbox
id="include-hidden-check"
v-model="localOptions.includeHidden"
switch
@change="onValueChanged">
Include Hidden
</b-form-checkbox>
</b-form-group>
</b-card>
</b-collapse>
</div>
</template>
@@ -0,0 +1,103 @@
import { shallowMount } from "@vue/test-utils";
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import {
EXPIRED_STS_DOWNLOAD_RECORD,
FILE_SOURCE_STORE_RECORD,
RECENT_STS_DOWNLOAD_RECORD,
} from "@/components/Common/models/testData/exportData";
import flushPromises from "flush-promises";
import type { components } from "@/schema";
import { getLocalVue } from "../../../../tests/jest/helpers";
import HistoryExport from "./HistoryExport.vue";
import { getExportRecords } from "./services";
const localVue = getLocalVue(true);
jest.mock("./services");
const mockGetExportRecords = getExportRecords as jest.MockedFunction<typeof getExportRecords>;
mockGetExportRecords.mockResolvedValue([]);
const FAKE_HISTORY_ID = "fake-history-id";
const REMOTE_FILES_API_ENDPOINT = new RegExp("/api/remote_files/plugins");
type FilesSourcePluginList = components["schemas"]["FilesSourcePlugin"][];
const REMOTE_FILES_API_RESPONSE: FilesSourcePluginList = [
{
id: "test-posix-source",
type: "posix",
uri_root: "gxfiles://test-posix-source",
label: "TestSource",
doc: "For testing",
writable: true,
requires_roles: undefined,
requires_groups: undefined,
},
];
async function mountHistoryExport() {
const wrapper = shallowMount(HistoryExport, {
propsData: { historyId: FAKE_HISTORY_ID },
localVue,
});
await flushPromises();
return wrapper;
}
describe("HistoryExport.vue", () => {
let axiosMock: MockAdapter;
beforeEach(async () => {
axiosMock = new MockAdapter(axios);
axiosMock.onGet(REMOTE_FILES_API_ENDPOINT).reply(200, []);
});
afterEach(() => {
axiosMock.restore();
});
it("should render export options", async () => {
const wrapper = await mountHistoryExport();
expect(wrapper.find("#history-export-options").exists()).toBe(true);
});
it("should display a message indicating there are no exports where there are none", async () => {
const wrapper = await mountHistoryExport();
expect(wrapper.find("#no-export-records-alert").exists()).toBe(true);
});
it("should render previous records when there is more than one record", async () => {
mockGetExportRecords.mockResolvedValue([
RECENT_STS_DOWNLOAD_RECORD,
FILE_SOURCE_STORE_RECORD,
EXPIRED_STS_DOWNLOAD_RECORD,
]);
const wrapper = await mountHistoryExport();
expect(wrapper.find("#previous-export-records").exists()).toBe(true);
});
it("should not render previous records when there is one or less records", async () => {
mockGetExportRecords.mockResolvedValue([RECENT_STS_DOWNLOAD_RECORD]);
const wrapper = await mountHistoryExport();
expect(wrapper.find("#previous-export-records").exists()).toBe(false);
});
it("should display file sources tab if there are available", async () => {
axiosMock.onGet(REMOTE_FILES_API_ENDPOINT).reply(200, REMOTE_FILES_API_RESPONSE);
const wrapper = await mountHistoryExport();
expect(wrapper.find("#direct-download-tab").exists()).toBe(true);
expect(wrapper.find("#file-source-tab").exists()).toBe(true);
});
it("should not display file sources tab if there are no file sources available", async () => {
const wrapper = await mountHistoryExport();
expect(wrapper.find("#direct-download-tab").exists()).toBe(true);
expect(wrapper.find("#file-source-tab").exists()).toBe(false);
});
});
@@ -0,0 +1,202 @@
<script setup>
import { computed, ref, reactive, onMounted, watch } from "vue";
import { BAlert, BCard, BButton, BTab, BTabs } from "bootstrap-vue";
import LoadingSpan from "components/LoadingSpan";
import ExportRecordDetails from "components/Common/ExportRecordDetails.vue";
import ExportRecordTable from "components/Common/ExportRecordTable.vue";
import ExportOptions from "./ExportOptions.vue";
import ExportToFileSourceForm from "components/Common/ExportForm.vue";
import { getExportRecords, exportToFileSource, reimportHistoryFromRecord } from "./services";
import { useTaskMonitor } from "composables/taskMonitor";
import { useFileSources } from "composables/fileSources";
import { useShortTermStorage, DEFAULT_EXPORT_PARAMS } from "composables/shortTermStorage";
import { useConfirmDialog } from "composables/confirmDialog";
const { isRunning: isExportTaskRunning, waitForTask } = useTaskMonitor();
const { hasWritable: hasWritableFileSources } = useFileSources();
const { isPreparing: isPreparingDownload, downloadHistory, downloadObjectByRequestId } = useShortTermStorage();
const { confirm } = useConfirmDialog();
const props = defineProps({
historyId: {
type: String,
required: true,
},
});
const exportParams = reactive(DEFAULT_EXPORT_PARAMS);
const isLoadingRecords = ref(true);
const exportRecords = ref(null);
const latestExportRecord = computed(() => (exportRecords.value?.length ? exportRecords.value.at(0) : null));
const previousExportRecords = computed(() => (exportRecords.value ? exportRecords.value.slice(1) : null));
const hasPreviousExports = computed(() => previousExportRecords.value?.length > 0);
const availableRecordsMessage = computed(() =>
isLoadingRecords.value
? "Loading export records..."
: "This history has no export records yet. You can choose one of the export options above."
);
const errorMessage = ref(null);
const actionMessage = ref(null);
const actionMessageVariant = ref(null);
onMounted(async () => {
updateExports();
});
watch(isExportTaskRunning, (newValue, oldValue) => {
const hasFinished = oldValue && !newValue;
if (hasFinished) {
updateExports();
}
});
async function updateExports() {
isLoadingRecords.value = true;
try {
errorMessage.value = null;
exportRecords.value = await getExportRecords(props.historyId);
const shouldWaitForTask = latestExportRecord.value?.isPreparing && !isExportTaskRunning.value;
if (shouldWaitForTask) {
waitForTask(latestExportRecord.value.taskUUID, 3000);
}
} catch (error) {
errorMessage.value = error;
}
isLoadingRecords.value = false;
}
async function doExportToFileSource(exportDirectory, fileName) {
await exportToFileSource(props.historyId, exportDirectory, fileName, exportParams);
updateExports();
}
async function prepareDownload() {
const upToDateDownloadRecord = findValidUpToDateDownloadRecord();
if (upToDateDownloadRecord) {
downloadObjectByRequestId(upToDateDownloadRecord.stsDownloadId);
return;
}
await downloadHistory(props.historyId, { pollDelayInMs: 3000, exportParams: exportParams });
updateExports();
}
function downloadFromRecord(record) {
if (record.canDownload) {
downloadObjectByRequestId(record.stsDownloadId);
}
}
function findValidUpToDateDownloadRecord() {
return exportRecords.value
? exportRecords.value.find(
(record) => record.canDownload && record.isUpToDate && record.exportParams?.equals(exportParams)
)
: null;
}
async function reimportFromRecord(record) {
const confirmed = await confirm(
`Do you really want to import a new copy of this history exported ${record.elapsedTime}?`
);
if (confirmed) {
reimportHistoryFromRecord(record)
.then(() => {
actionMessageVariant.value = "info";
actionMessage.value =
"The history is being imported in the background. Check your histories after a while to find it.";
})
.catch((reason) => {
actionMessageVariant.value = "danger";
actionMessage.value = reason;
});
}
}
function onActionMessageDismissedFromRecord() {
actionMessage.value = null;
actionMessageVariant.value = null;
}
function updateExportParams(newParams) {
exportParams.modelStoreFormat = newParams.modelStoreFormat;
exportParams.includeFiles = newParams.includeFiles;
exportParams.includeDeleted = newParams.includeDeleted;
exportParams.includeHidden = newParams.includeHidden;
}
</script>
<template>
<span class="history-export-component">
<h1 class="h-lg">Export history {{ props.historyId }}</h1>
<export-options
id="history-export-options"
:export-params="exportParams"
@onValueChanged="updateExportParams" />
<b-card no-body class="mt-3">
<b-tabs pills card>
<b-tab id="direct-download-tab" title="to direct download" title-link-class="tab-export-to-link" active>
<p>
Here you can generate a temporal download for your history. When your download link expires or
your history changes you can re-generate it again.
</p>
<b-alert show variant="warning">
History archive downloads can expire and are removed at regular intervals. For permanent
storage, export to a <b>remote file</b> or download and then import the archive on another
Galaxy server.
</b-alert>
<b-button
class="direct-download-btn"
:disabled="isPreparingDownload"
variant="primary"
@click="prepareDownload">
Download
</b-button>
<span v-if="isPreparingDownload">
<loading-span message="Galaxy is preparing your download, this will likely take a while" />
</span>
</b-tab>
<b-tab
v-if="hasWritableFileSources"
id="file-source-tab"
title="to remote file"
title-link-class="tab-export-to-file">
<p>
If you need a "more permanent" way of storing your history archive you can export it directly to
one of the available remote file sources here. You will be able to re-import it later as long as
it remains available on the remote server.
</p>
<export-to-file-source-form
what="history"
:clear-input-after-export="true"
@export="doExportToFileSource" />
</b-tab>
</b-tabs>
</b-card>
<export-record-details
v-if="latestExportRecord"
:record="latestExportRecord"
object-type="history"
class="mt-3"
:action-message="actionMessage"
:action-message-variant="actionMessageVariant"
@onDownload="downloadFromRecord"
@onReimport="reimportFromRecord"
@onActionMessageDismissed="onActionMessageDismissedFromRecord" />
<b-alert v-else-if="errorMessage" id="last-export-record-error-alert" variant="danger" class="mt-3" show>
{{ errorMessage }}
</b-alert>
<b-alert v-else id="no-export-records-alert" variant="info" class="mt-3" show>
{{ availableRecordsMessage }}
</b-alert>
<export-record-table
v-if="hasPreviousExports"
id="previous-export-records"
:records="previousExportRecords"
class="mt-3"
@onDownload="downloadFromRecord"
@onReimport="reimportFromRecord" />
</span>
</template>
@@ -0,0 +1,77 @@
import { ExportRecordModel } from "@/components/Common/models/exportRecordModel";
import type { ObjectExportTaskResponse } from "@/components/Common/models/exportRecordModel";
import { DEFAULT_EXPORT_PARAMS } from "@/composables/shortTermStorage";
import type { components } from "@/schema";
import { fetcher } from "@/schema";
type ModelStoreFormat = components["schemas"]["ModelStoreFormat"];
const _getExportRecords = fetcher.path("/api/histories/{history_id}/exports").method("get").create();
const _exportToFileSource = fetcher.path("/api/histories/{history_id}/write_store").method("post").create();
const _importFromStoreAsync = fetcher.path("/api/histories/from_store_async").method("post").create();
/**
* A list of objects with the available export formats IDs and display names.
*/
export const AVAILABLE_EXPORT_FORMATS: { id: ModelStoreFormat; name: string }[] = [
{ id: "rocrate.zip", name: "RO-Crate" },
{ id: "tar.gz", name: "Compressed TGZ" },
];
/**
* Gets a list of export records for the given history.
* @param historyId the encoded ID of the history
* @param params query and pagination params
* @returns a promise with a list of export records associated with the given history.
*/
export async function getExportRecords(historyId: string) {
const response = await _getExportRecords(
{
history_id: historyId,
},
{
headers: {
Accept: "application/vnd.galaxy.task.export+json",
},
}
);
return response.data.map((item: unknown) => new ExportRecordModel(item as ObjectExportTaskResponse));
}
/**
*
* @param historyId the encoded ID of the history
* @param exportDirectory the output directory in the file source
* @param fileName the name of the output archive
* @param exportParams additional parameters to configure the export
* @returns A promise with the request response
*/
export async function exportToFileSource(
historyId: string,
exportDirectory: string,
fileName: string,
exportParams = DEFAULT_EXPORT_PARAMS
) {
const exportDirectoryUri = `${exportDirectory}/${fileName}.${exportParams.modelStoreFormat}`;
return _exportToFileSource({
history_id: historyId,
target_uri: exportDirectoryUri,
model_store_format: exportParams.modelStoreFormat as ModelStoreFormat,
include_files: exportParams.includeFiles,
include_deleted: exportParams.includeDeleted,
include_hidden: exportParams.includeHidden,
});
}
/**
* Imports a new history using the information stored in the given export record.
* @param record The export record to be imported
* @returns A promise with the request response
*/
export async function reimportHistoryFromRecord(record: ExportRecordModel) {
return _importFromStoreAsync({
store_content_uri: record.importUri,
model_store_format: record.modelStoreFormat,
});
}
@@ -0,0 +1,27 @@
import Filtering, { compare, contains, equals, expandNameTag, toBool, toDate } from "utils/filtering";
export const validFilters = {
hid: equals("hid"),
state: equals("state"),
name: contains("name"),
extension: equals("extension"),
hid_ge: compare("hid", "ge"),
hid_gt: compare("hid", "gt"),
hid_le: compare("hid", "le"),
hid_lt: compare("hid", "lt"),
tag: contains("tags", "tag", expandNameTag),
visible: equals("visible", "visible", toBool),
deleted: equals("deleted", "deleted", toBool),
create_time: compare("create_time", "le", toDate),
create_time_ge: compare("create_time", "ge", toDate),
create_time_gt: compare("create_time", "gt", toDate),
create_time_le: compare("create_time", "le", toDate),
create_time_lt: compare("create_time", "lt", toDate),
update_time: compare("update_time", "le", toDate),
update_time_ge: compare("update_time", "ge", toDate),
update_time_gt: compare("update_time", "gt", toDate),
update_time_le: compare("update_time", "le", toDate),
update_time_lt: compare("update_time", "lt", toDate),
};
export const HistoryFilters = new Filtering(validFilters, true);
@@ -7,7 +7,15 @@ import Heading from "components/Common/Heading";
import LoadingSpan from "components/LoadingSpan";
import DebouncedInput from "components/DebouncedInput";
import { getPublishedHistories, updateTags } from "./services";
import { getFilters, getFilterText, toAlias } from "utils/filterConversion";
import Filtering, { contains, expandNameTag } from "utils/filtering";
const validFilters = {
name: contains("name"),
annotation: contains("annotation"),
tag: contains("tags", "tag", expandNameTag),
};
const filters = new Filtering(validFilters, false);
const limit = ref(50);
const offset = ref(0);
@@ -43,7 +51,7 @@ const localFilter = computed({
},
});
const filterSettings = computed(() => toAlias(getFilters(filterText.value, false)));
const filterSettings = computed(() => filters.toAlias(filters.getFilters(filterText.value)));
const updateFilter = (newVal, append = false) => {
let oldValue = filterText.value;
@@ -69,13 +77,17 @@ const onTagClick = (tag) => {
const load = async () => {
loading.value = true;
getPublishedHistories({
limit: limit.value,
offset: offset.value,
sortBy: sortBy.value,
sortDesc: sortDesc.value,
filterText: filterText.value,
})
getPublishedHistories(
{
limit: limit.value,
offset: offset.value,
sortBy: sortBy.value,
sortDesc: sortDesc.value,
filterText: filterText.value,
},
filters
)
.then((data) => {
items.value = data;
})
@@ -98,7 +110,7 @@ const onToggle = () => {
const onSearch = () => {
onToggle();
updateFilter(getFilterText(filterSettings.value));
updateFilter(filters.getFilterText(filterSettings.value));
};
load();
+1
View File
@@ -6,6 +6,7 @@
v-if="!breadcrumbs.length"
:list-offset="listOffset"
:history="currentHistory"
:filterable="true"
v-on="handlers"
@view-collection="onViewCollection">
<template v-slot:navigation>
@@ -67,7 +67,7 @@
<script>
import { mapGetters } from "vuex";
import short from "components/directives/v-short";
import { StatelessTags } from "components/Tags";
import StatelessTags from "components/TagsMultiselect/StatelessTags";
export default {
components: {
@@ -4,6 +4,7 @@
*/
import Backbone from "backbone";
import store from "store";
import { useHistoryItemsStore } from "stores/history/historyItemsStore";
import { buildCollectionModal } from "./buildCollectionModal";
import { createDatasetCollection } from "components/History/model/queries";
import { watchHistory } from "store/historyStore/model/watchHistory";
@@ -12,30 +13,14 @@ import { watchHistory } from "store/historyStore/model/watchHistory";
export class HistoryPanelProxy {
constructor() {
const model = (this.model = new Backbone.Model({}));
const historyItemsStore = useHistoryItemsStore();
this.collection = {
each(callback, filterText = "") {
const historyItems = store.getters.getHistoryItems({ historyId: model.id, filterText: filterText });
const historyItems = historyItemsStore.getHistoryItems(model.id, filterText);
historyItems.forEach((model) => {
callback(new Backbone.Model(model));
});
},
on(name, callback) {
this.off();
this.unwatch = store.watch(
(state, getters) => getters.getLatestCreateTime(),
() => {
callback();
console.debug("History change watcher detected a change.", name);
}
);
console.debug("History change watcher enabled.", name);
},
off(name) {
if (this.unwatch) {
this.unwatch();
console.debug("History change watcher disabled.", name);
}
},
};
// watch the store, update history id
@@ -87,7 +87,6 @@ export async function bulkUpdate(history, operation, filters, items = [], params
params,
};
const response = await axios.put(prependPath(url), payload);
console.debug(`Submitted request to ${operation} selected content in bulk. Parameters: ${params}`, response);
return doResponse(response);
}
+2 -3
View File
@@ -1,10 +1,9 @@
import axios from "axios";
import { safePath } from "utils/redirect";
import { rethrowSimple } from "utils/simple-error";
import { getQueryString } from "utils/filterConversion";
export async function getPublishedHistories({ limit, offset, sortBy, sortDesc, filterText }) {
const queryString = getQueryString(filterText, false);
export async function getPublishedHistories({ limit, offset, sortBy, sortDesc, filterText }, filters) {
const queryString = filters.getQueryString(filterText);
let params = `view=summary&keys=username,username_and_slug&offset=${offset}&limit=${limit}`;
if (sortBy) {
const sortPrefix = sortDesc ? "-dsc" : "-asc";
+20 -3
View File
@@ -1,5 +1,5 @@
import { shallowMount } from "@vue/test-utils";
import { getLocalVue } from "tests/jest/helpers";
import { mount } from "@vue/test-utils";
import { getLocalVue, wait } from "tests/jest/helpers";
import HistoryImport from "./HistoryImport.vue";
import MockAdapter from "axios-mock-adapter";
import axios from "axios";
@@ -21,7 +21,7 @@ describe("HistoryImport.vue", () => {
beforeEach(async () => {
axiosMock = new MockAdapter(axios);
axiosMock.onGet(TEST_PLUGINS_URL).reply(200, [{ id: "foo", writable: false }]);
wrapper = shallowMount(HistoryImport, {
wrapper = mount(HistoryImport, {
propsData: {},
localVue,
});
@@ -76,6 +76,23 @@ describe("HistoryImport.vue", () => {
expect(wrapper.vm.complete).toBeTruthy();
});
it("warns about shared history imports", async () => {
const input = wrapper.find("input[type=url]");
await input.setValue("https://usegalaxy.org/u/some_user/h/exported_history");
await wait(210);
const alert = wrapper.find(".alert");
expect(alert.classes()).toContain("alert-warning");
expect(alert.text()).toContain(
"It looks like you are trying to import a published history from another galaxy instance"
);
// Link to the GTN
const link = alert.find("a");
expect(link.text()).toContain("GTN");
});
afterEach(() => {
axiosMock.restore();
});
+47 -13
View File
@@ -1,8 +1,7 @@
<template>
<b-card body-class="history-import-component" aria-labelledby="history-import-heading">
<template slot="header">
<h1 id="history-import-heading" class="mb-0 h-sm">Import a history from an archive</h1>
</template>
<div class="history-import-component" aria-labelledby="history-import-heading">
<h1 id="history-import-heading" class="h-lg">Import a history from an archive</h1>
<b-alert v-if="errorMessage" variant="danger" dismissible show @dismissed="errorMessage = null">
{{ errorMessage }}
<JobError
@@ -11,6 +10,7 @@
header="History import job ended in error"
:job="jobError" />
</b-alert>
<div v-if="initializing">
<loading-span message="Loading server configuration." />
</div>
@@ -45,23 +45,35 @@
</b-form-radio>
</b-form-radio-group>
</b-form-group>
<b-form-group v-if="importType == 'externalUrl'" label="Archived History URL">
<b-form-group v-if="importType === 'externalUrl'" label="Archived History URL">
<b-alert v-if="showImportUrlWarning" variant="warning" show>
It looks like you are trying to import a published history from another galaxy instance. You can
only import histories via an archive URL.
<ExternalLink
href="https://training.galaxyproject.org/training-material/faqs/galaxy/histories_transfer_entire_histories_from_one_galaxy_server_to_another.html">
Read more on the GTN
</ExternalLink>
</b-alert>
<b-form-input v-model="sourceURL" type="url" />
</b-form-group>
<b-form-group v-if="importType == 'upload'" label="Archived History File">
<b-form-group v-else-if="importType === 'upload'" label="Archived History File">
<b-form-file v-model="sourceFile" />
</b-form-group>
<b-form-group v-show="importType == 'remoteFilesUri'" label="Remote File">
<b-form-group v-show="importType === 'remoteFilesUri'" label="Remote File">
<!-- using v-show so we can have a persistent ref and launch dialog on select -->
<files-input ref="filesInput" v-model="sourceRemoteFilesUri" />
</b-form-group>
<b-button class="import-button" variant="primary" type="submit" :disabled="!importReady"
>Import history</b-button
>
<b-button class="import-button" variant="primary" type="submit" :disabled="!importReady">
Import history
</b-button>
</b-form>
</div>
</b-card>
</div>
</template>
<script>
import { getAppRoot } from "onload/loadConfig";
import axios from "axios";
@@ -76,6 +88,9 @@ import { errorMessageAsString } from "utils/simple-error";
import LoadingSpan from "components/LoadingSpan";
import JobError from "components/JobInformation/JobError";
import { Services } from "components/FilesDialog/services";
import { ref, watch } from "vue";
import { refDebounced } from "@vueuse/core";
import ExternalLink from "./ExternalLink";
library.add(faFolderOpen);
library.add(faUpload);
@@ -83,13 +98,32 @@ library.add(faExternalLinkAlt);
Vue.use(BootstrapVue);
export default {
components: { FilesInput, FontAwesomeIcon, JobError, LoadingSpan },
components: { FilesInput, FontAwesomeIcon, JobError, LoadingSpan, ExternalLink },
setup() {
const sourceURL = ref("");
const debouncedURL = refDebounced(sourceURL, 200);
const mayBeHistoryUrlRegEx = /\/u(ser)?\/.+\/h(istory)?\/.+/;
const showImportUrlWarning = ref(false);
watch(
() => debouncedURL.value,
(val) => {
const url = val ?? "";
showImportUrlWarning.value = Boolean(url.match(mayBeHistoryUrlRegEx));
}
);
return {
sourceURL,
showImportUrlWarning,
};
},
data() {
return {
initializing: true,
importType: "externalUrl",
sourceFile: null,
sourceURL: null,
sourceRemoteFilesUri: null,
errorMessage: null,
waitingOnJob: false,
@@ -1,10 +1,10 @@
import Vuex from "vuex";
import { default as Masthead } from "./Masthead.vue";
import { mount } from "@vue/test-utils";
import { getLocalVue } from "tests/jest/helpers";
import { getLocalVue, mockModule } from "tests/jest/helpers";
import { WindowManager } from "layout/window-manager";
import { loadWebhookMenuItems } from "./_webhooks";
import { userStore } from "store/userStore";
import { userStore, userFlagsStore } from "store/userStore";
import { configStore } from "store/configStore";
import { getActiveTab } from "./utilities";
import { createTestingPinia } from "@pinia/testing";
@@ -44,20 +44,9 @@ describe("Masthead.vue", () => {
testPinia = createTestingPinia();
store = new Vuex.Store({
modules: {
user: {
state,
actions: {
loadUser: jest.fn(),
},
getters: userStore.getters,
namespaced: true,
},
config: {
state,
actions,
getters: configStore.getters,
namespaced: true,
},
user: mockModule(userStore),
config: mockModule(configStore),
userFlags: mockModule(userFlagsStore),
},
});
@@ -94,6 +83,7 @@ describe("Masthead.vue", () => {
initialActiveTab,
},
store,
provide: { store },
localVue,
pinia: testPinia,
});
+97 -13
View File
@@ -9,11 +9,11 @@ import { watch, computed, ref, reactive } from "vue";
import { onMounted, onBeforeMount } from "vue";
import { useRoute } from "vue-router/composables";
import { useEntryPointStore } from "stores/entryPointStore";
// basics
import ThemeSelector from "./ThemeSelector.vue";
const route = useRoute();
const emit = defineEmits(["open-url"]);
/* props */
const props = defineProps({
tabs: {
type: Array,
@@ -45,7 +45,6 @@ const props = defineProps({
},
});
/* refs */
const activeTab = ref(props.initialActiveTab);
const extensionTabs = ref([]);
const windowToggle = ref(false);
@@ -64,11 +63,11 @@ const allTabs = computed(() => {
return [].concat(props.tabs, itsMenu, extensionTabs.value);
});
/* methods */
function setActiveTab() {
const currentRoute = route.path;
activeTab.value = getActiveTab(currentRoute, props.tabs) || activeTab.value;
}
function onWindowToggle() {
windowToggle.value = !windowToggle.value;
}
@@ -76,7 +75,6 @@ function updateVisibility(isActive) {
itsMenu.hidden = !isActive;
}
/* watchers */
watch(
() => route.path,
() => {
@@ -101,26 +99,112 @@ onMounted(() => {
<template>
<b-navbar id="masthead" type="dark" role="navigation" aria-label="Main" class="justify-content-between">
<b-navbar-nav>
<b-navbar-brand id="analysis" :href="safePath(logoUrl)" aria-label="homepage">
<b-button v-b-tooltip.hover variant="link" size="sm" title="Home">
<img alt="logo" :src="safePath(logoSrc)" />
<img v-if="logoSrcSecondary" alt="logo" :src="safePath(logoSrcSecondary)" />
</b-button>
<b-navbar-brand
v-b-tooltip.hover
class="ml-2 mr-1"
title="Home"
aria-label="homepage"
:href="safePath(logoUrl)">
<img alt="logo" :src="safePath(logoSrc)" />
<img v-if="logoSrcSecondary" alt="logo" :src="safePath(logoSrcSecondary)" />
</b-navbar-brand>
<b-nav-item v-if="brand" class="navbar-brand-title" disabled>
<span v-if="brand" class="navbar-text">
{{ brand }}
</b-nav-item>
</span>
</b-navbar-nav>
<b-navbar-nav>
<masthead-item
v-for="(tab, idx) in allTabs"
v-for="(tab, idx) in props.tabs"
v-show="tab.hidden !== true"
:key="`tab-${idx}`"
:tab="tab"
:active-tab="activeTab"
@open-url="emit('open-url', $event)" />
<masthead-item
v-show="itsMenu.hidden !== true"
:key="`its-tab`"
:tab="itsMenu"
:active-tab="activeTab"
@open-url="emit('open-url', $event)" />
<ThemeSelector />
<masthead-item
v-for="(tab, idx) in extensionTabs"
v-show="tab.hidden !== true"
:key="`extension-tab-${idx}`"
:tab="tab"
:active-tab="activeTab"
@open-url="emit('open-url', $event)" />
<masthead-item v-if="windowTab" :tab="windowTab" :toggle="windowToggle" @click="onWindowToggle" />
</b-navbar-nav>
<quota-meter />
</b-navbar>
</template>
<style scoped lang="scss">
@import "theme/blue.scss";
#masthead {
padding: 0;
margin-bottom: 0;
background: var(--masthead-color);
height: $masthead-height;
&:deep(.navbar-nav) {
height: $masthead-height;
& > li {
// This allows the background color to fill the full height of the
// masthead, while still keeping the contents centered (using flex)
min-height: 100%;
display: flex;
align-items: center;
background: var(--masthead-link-color);
&:hover {
background: var(--masthead-link-hover);
}
&.show,
&.active {
background: var(--masthead-link-active);
.nav-link {
color: var(--masthead-text-active);
}
}
.nav-link {
position: relative;
cursor: pointer;
text-decoration: none;
color: var(--masthead-text-color);
&:hover {
color: var(--masthead-text-hover);
}
&.nav-icon {
font-size: 1.3em;
.nav-note {
position: absolute;
left: 1.9rem;
top: 1.9rem;
font-size: 0.6rem;
font-weight: bold;
}
}
&.toggle {
color: var(--masthead-text-hover);
}
}
}
}
.navbar-brand {
cursor: pointer;
img {
display: inline;
border: none;
height: 2.3rem;
}
}
.navbar-text {
font-weight: bold;
font-family: Verdana, sans-serif;
font-size: 1rem;
line-height: 2rem;
color: var(--masthead-text-color);
}
}
</style>
@@ -77,8 +77,6 @@ export default {
</script>
<style lang="scss" scoped>
@import "theme/blue.scss";
.quota-meter {
position: relative;
right: 0.8rem;
@@ -104,8 +102,14 @@ export default {
}
.quota-text {
color: $brand-light;
color: var(--masthead-text-color);
text-decoration: none;
}
:deep(a) {
&:focus-visible {
outline: 2px solid var(--masthead-text-hover);
}
}
}
</style>
@@ -0,0 +1,42 @@
<script setup>
import { useCurrentTheme } from "@/composables/userFlags";
import { useConfig } from "@/composables/config";
import { watch, ref } from "vue";
import { library } from "@fortawesome/fontawesome-svg-core";
import { faPalette } from "@fortawesome/free-solid-svg-icons";
const { currentTheme, setCurrentTheme } = useCurrentTheme();
const { config, isLoaded } = useConfig();
const show = ref(false);
watch(
() => isLoaded.value,
() => {
const themes = Object.keys(config.value.themes);
show.value = themes?.length > 1 ?? false;
if (!themes.includes(currentTheme.value)) {
setCurrentTheme(themes[0]);
}
}
);
library.add(faPalette);
</script>
<template>
<b-nav-item-dropdown v-show="show" text="Theme">
<b-dropdown-item
v-for="(theme, name) in config.themes"
:key="name"
:active="name === currentTheme"
@click="() => setCurrentTheme(name)">
<icon v-if="name === currentTheme" icon="fas fa-check" />
<icon v-else icon="fas fa-palette" />
<span>{{ name }}</span>
</b-dropdown-item>
</b-nav-item-dropdown>
</template>
@@ -52,7 +52,7 @@ export default {
return `${this.dataUrl}.pdf`;
},
editUrl() {
return `/page/edit_content?id=${this.pageId}`;
return `/pages/editor?id=${this.pageId}`;
},
},
created() {
+7 -2
View File
@@ -70,7 +70,8 @@
<script>
import ToolSection from "./Common/ToolSection";
import ToolSearch from "./Common/ToolSearch";
import { UploadButton, openGlobalUploadModal } from "components/Upload";
import { UploadButton } from "components/Upload";
import { useGlobalUploadModal } from "composables/globalUploadModal";
import FavoritesButton from "./Buttons/FavoritesButton";
import PanelViewButton from "./Buttons/PanelViewButton";
import { filterToolSections, filterTools, hasResults } from "./utilities";
@@ -106,6 +107,10 @@ export default {
default: _l("Workflows"),
},
},
setup() {
const { openGlobalUploadModal } = useGlobalUploadModal();
return { openGlobalUploadModal };
},
data() {
return {
query: null,
@@ -171,7 +176,7 @@ export default {
onOpen(tool, evt) {
if (tool.id === "upload1") {
evt.preventDefault();
openGlobalUploadModal();
this.openGlobalUploadModal();
} else if (tool.form_style === "regular") {
evt.preventDefault();
// encode spaces in tool.id
+6 -4
View File
@@ -76,12 +76,14 @@ export default {
},
onDownload(config) {
if (!config.enable_celery_tasks) {
console.log("celery tasks not enabled - setting href to fallback URL.");
window.location.assign(safePath(this.fallbackUrl));
return;
} else {
this.waiting = true;
axios
.post(this.downloadEndpoint, this.postParameters)
.then(this.handleInitialize)
.catch(this.handleError);
}
this.waiting = true;
axios.post(this.downloadEndpoint, this.postParameters).then(this.handleInitialize).catch(this.handleError);
},
handleInitialize(response) {
const storageRequestId = response.data.storage_request_id;
+40 -61
View File
@@ -9,76 +9,55 @@ import { keyedColorScheme } from "utils/color";
// separated by a period, and then an optional value after a colon.
export const VALID_TAG_RE = /^([^\s.:])+(.[^\s.:]+)*(:[^\s.:]+)?$/;
function TagModel(props = {}) {
this.text = "";
export class TagModel {
/**
* @param {string || object} data
*/
constructor(data) {
let props = {};
// special handling for name:thing tags
if (props.text && props.text.startsWith("#")) {
props.text = props.text.replace("#", "name:");
switch (typeof data) {
case "string":
props = { text: data };
break;
case "object":
props = data;
break;
}
Object.assign(this, props);
this.text = props.text ?? "";
this.label = this.text.replace(/^name:/, "#");
this.text = this.text.replace(/^#/, "name:");
this.style = "";
if (this.text.startsWith("name:")) {
this.style += "font-weight: bold;";
}
const { primary, darker } = keyedColorScheme(this.text);
this.style += `background-color: ${primary};`;
this.style += "color: black;";
this.style += `border-color: ${darker};`;
this.valid = VALID_TAG_RE.test(this.text);
}
Object.assign(this, props);
equals(otherTag) {
return this.text === otherTag.text;
}
// Need to do Object.defineProperty instead of a class getter to make
// style enumerable for vue-tags-input
Object.defineProperty(this, "style", {
enumerable: true,
get: function () {
const { primary, darker } = keyedColorScheme(this.text);
const styles = {
"background-color": primary,
color: "black",
"border-color": darker,
};
if (this.text.startsWith("name:")) {
styles["font-weight"] = "bold";
}
return Object.keys(styles)
.map((prop) => `${prop}: ${styles[prop]}`)
.join(";");
},
});
// Changes name:foo to #foo
Object.defineProperty(this, "label", {
enumerable: true,
get: function () {
return this.text.startsWith("name:") ? this.text.replace("name:", "#") : this.text;
},
});
// valid flag
Object.defineProperty(this, "valid", {
enumerable: false,
get: function () {
return VALID_TAG_RE.test(this.text);
},
});
toString() {
return this.text;
}
}
TagModel.prototype.equals = function (otherTag) {
return this.text == otherTag.text;
};
TagModel.prototype.toString = function () {
return this.text;
};
// Public factory
export function createTag(data) {
let props = {};
switch (typeof data) {
case "string":
props = { text: data };
break;
case "object":
props = Object.assign({}, data);
break;
}
return new TagModel(props);
return new TagModel(data);
}
// Returns tags in "newTags" that aren't present in "existingTags"
@@ -0,0 +1,136 @@
import { getLocalVue } from "tests/jest/helpers";
import { mount } from "@vue/test-utils";
import { useUserTags } from "composables/user";
import { useToast } from "composables/toast";
import { computed } from "vue";
import StatelessTags from "./StatelessTags";
const autocompleteTags = ["#named_user_tag", "abc", "my_tag"];
const localVue = getLocalVue();
const mountWithProps = (props) => {
return mount(StatelessTags, {
propsData: props,
localVue,
});
};
jest.mock("composables/user");
const addLocalTagMock = jest.fn((tag) => tag);
useUserTags.mockReturnValue({
userTags: computed(() => autocompleteTags),
addLocalTag: addLocalTagMock,
});
jest.mock("composables/toast");
const warningMock = jest.fn((message, title) => {
return { message, title };
});
useToast.mockReturnValue({
warning: warningMock,
});
describe("StatelessTags", () => {
it("shows tags", () => {
const wrapper = mountWithProps({
value: ["tag_1", "tag_2", "tags:tag_3"],
disabled: true,
});
expect(wrapper.find(".tag").exists()).toBe(true);
const tags = wrapper.findAll(".tag");
expect(tags.length).toBe(3);
expect(tags.at(0).text()).toBe("tag_1");
expect(tags.at(1).text()).toBe("tag_2");
expect(tags.at(2).text()).toBe("tags:tag_3");
});
it("formats named tags", () => {
const wrapper = mountWithProps({
value: ["name:tag_1", "tag_2", "name:tag_3"],
disabled: true,
});
const tags = wrapper.findAll(".tag");
expect(tags.at(0).text()).toBe("#tag_1");
expect(tags.at(1).text()).toBe("tag_2");
expect(tags.at(2).text()).toBe("#tag_3");
});
it("shows autocomplete options", async () => {
const wrapper = mountWithProps({
disabled: false,
});
const multiselect = wrapper.find(".multiselect");
multiselect.find("button").trigger("click");
await wrapper.vm.$nextTick();
const options = multiselect.findAll(".multiselect-option");
const visibleOptions = options.filter((option) => option.isVisible());
expect(visibleOptions.length).toBe(autocompleteTags.length);
visibleOptions.wrappers.forEach((option, i) => {
expect(option.text()).toContain(autocompleteTags[i]);
});
});
it("adds new tags", async () => {
const wrapper = mountWithProps({
disabled: false,
});
const multiselect = wrapper.find(".multiselect");
multiselect.find("button").trigger("click");
await wrapper.vm.$nextTick();
await multiselect.find("input").setValue("new_tag");
await wrapper.vm.$nextTick();
multiselect.find(".multiselect-option").trigger("click");
await wrapper.vm.$nextTick();
expect(addLocalTagMock.mock.calls.length).toBe(1);
expect(addLocalTagMock.mock.results[0].value).toBe("new_tag");
});
it("warns about not allowed tags", async () => {
const wrapper = mountWithProps({
disabled: false,
});
const multiselect = wrapper.find(".multiselect");
multiselect.find("button").trigger("click");
await wrapper.vm.$nextTick();
await multiselect.find("input").setValue(":illegal_tag");
await wrapper.vm.$nextTick();
const option = multiselect.find(".multiselect-option");
expect(option.classes()).toContain("invalid");
option.trigger("click");
await wrapper.vm.$nextTick();
expect(warningMock.mock.calls.length).toBe(1);
expect(warningMock.mock.results[0].value.title).toBe("Invalid Tag");
});
it("hides too many tags", async () => {
const wrapper = mountWithProps({
value: ["tag_1", "tag_2", "tag_3", "tag_4", "tag_5", "tag_6"],
disabled: true,
useToggleLing: true,
maxVisibleTags: 4,
});
const tags = wrapper.findAll(".tag");
expect(tags.length).toBe(4);
const showMoreLink = wrapper.find(".toggle-link");
expect(showMoreLink.text()).toContain("2");
});
});
@@ -0,0 +1,357 @@
<script setup lang="ts">
import { ref, computed } from "vue";
import Multiselect from "vue-multiselect";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { library } from "@fortawesome/fontawesome-svg-core";
import { faTags, faCheck, faTimes, faPlus } from "@fortawesome/free-solid-svg-icons";
import Tag from "./Tag.vue";
import { useUserTags } from "@/composables/user";
import { useToast } from "@/composables/toast";
import { useUid } from "@/composables/utils/uid";
import type { Ref } from "vue";
export interface StatelessTagsProps {
value?: string[];
disabled?: boolean;
clickable?: boolean;
useToggleLink?: boolean;
maxVisibleTags?: number;
}
const props = withDefaults(defineProps<StatelessTagsProps>(), {
value: () => [],
disabled: false,
clickable: false,
useToggleLink: true,
maxVisibleTags: 5,
});
const emit = defineEmits<{
(e: "input", tags: string[]): void;
(e: "tag-click", tag: string): void;
}>();
//@ts-ignore bad library types
library.add(faTags, faCheck, faTimes, faPlus);
const { userTags, addLocalTag } = useUserTags();
const { warning } = useToast();
function onAddTag(tag: string) {
const newTag = tag.trim();
if (isValid(newTag)) {
addLocalTag(newTag);
emit("input", [...props.value, newTag]);
} else {
warning(`"${newTag}" is not a valid tag.`, "Invalid Tag");
}
}
function onInput(val: string[]) {
emit("input", val);
}
function onDelete(tag: string) {
const val = [...tags.value];
const index = tags.value.indexOf(tag);
val.splice(index, 1);
emit("input", val);
}
const editing = ref(false);
function onOpen() {
editing.value = true;
}
function onClose() {
editing.value = false;
}
const multiselectElement: Ref<Multiselect | null> = ref(null);
function openMultiselect() {
//@ts-ignore bad library types
multiselectElement.value?.activate();
}
const tags = computed(() => props.value.map((tag) => tag.replace(/^name:/, "#")));
const toggledOpen = ref(false);
const toggleButtonId = useUid("toggle-link-");
const trimmedTags = computed(() => {
if (!props.useToggleLink || toggledOpen.value) {
return tags.value;
} else {
return tags.value.slice(0, props.maxVisibleTags);
}
});
const slicedTags = computed(() => {
if (!props.useToggleLink) {
return [];
} else {
return tags.value.slice(props.maxVisibleTags);
}
});
const invalidTagRegex = /([.:\s][.:\s])|(^[.:])|([.:]$)|(^[\s]*$)/;
function isValid(tag: string | { label: string }) {
if (typeof tag === "string") {
return !tag.match(invalidTagRegex);
} else {
return !tag.label.match(invalidTagRegex);
}
}
function onTagClicked(tag: string) {
emit("tag-click", tag);
}
</script>
<template>
<div class="stateless-tags px-1">
<Multiselect
v-if="!disabled"
ref="multiselectElement"
placeholder="Add Tags"
open-direction="bottom"
:value="tags"
:options="userTags"
:multiple="true"
:taggable="true"
:close-on-select="false"
@tag="onAddTag"
@input="onInput"
@open="onOpen"
@close="onClose">
<template v-slot:tag="{ option, search }">
<Tag
:option="option"
:search="search"
:editable="true"
:clickable="props.clickable"
@deleted="onDelete"
@click="onTagClicked"></Tag>
</template>
<template v-slot:noOptions>
<span class="multiselect-option">Type to add new tag</span>
</template>
<template v-slot:caret>
<b-button v-if="!editing" class="toggle-button" variant="link" tabindex="-1" @click="openMultiselect">
Add Tags
<FontAwesomeIcon icon="fa-tags" />
</b-button>
</template>
<template v-slot:option="{ option }">
<span class="multiselect-option" :class="{ invalid: !isValid(option) }">
<span>{{ option.label ?? option }}</span>
<span v-if="tags.includes(option)" class="float-right">
<span class="info">
<FontAwesomeIcon class="check-icon" icon="fa-check" fixed-width />
</span>
<span class="info highlighted">
<FontAwesomeIcon class="times-icon" icon="fa-times" fixed-width />
<span class="sr-only">remove tag</span>
</span>
</span>
<span v-else class="float-right">
<span class="info highlighted">
<FontAwesomeIcon class="plus-icon" icon="fa-plus" fixed-width />
<span class="sr-only">add tag</span>
</span>
</span>
</span>
</template>
</Multiselect>
<div v-else class="pl-1 pb-2">
<div class="d-inline">
<Tag
v-for="tag in trimmedTags"
:key="tag"
:option="tag"
:editable="false"
:clickable="props.clickable"
@click="onTagClicked"></Tag>
<b-button
v-if="slicedTags.length > 0 && !toggledOpen"
:id="toggleButtonId"
variant="link"
class="toggle-link"
@click="() => (toggledOpen = true)">
{{ slicedTags.length }} more...
</b-button>
<b-tooltip
v-if="slicedTags.length > 0 && !toggledOpen"
:target="toggleButtonId"
custom-class="stateless-tags--tag-preview-tooltip"
placement="bottom">
<Tag
v-for="tag in slicedTags"
:key="tag"
:option="tag"
:editable="false"
:clickable="props.clickable"
@click="onTagClicked"></Tag>
</b-tooltip>
</div>
</div>
</div>
</template>
<style lang="scss">
.stateless-tags--tag-preview-tooltip {
opacity: 1 !important;
}
</style>
<style lang="scss" scoped>
@import "scss/theme/blue.scss";
.stateless-tags {
.toggle-link {
padding: 0;
border: none;
&:hover {
background-color: transparent;
border: none;
}
}
&:deep(.multiselect) {
min-height: unset;
display: flex;
flex-direction: column-reverse;
.multiselect__select {
top: unset;
bottom: 0;
padding: 0 0.25rem;
z-index: 1;
height: $font-size-base * 2;
&::before {
border-color: $text-color transparent transparent;
}
}
.multiselect__placeholder {
display: none;
}
.multiselect__tags-wrap {
margin-bottom: 0.5rem;
}
.multiselect__tags {
padding: 0 0.25rem;
background: none;
font-size: $font-size-base;
border: none;
min-height: 0;
}
.multiselect__content-wrapper {
top: 100%;
z-index: 800;
width: calc(100% - 4px);
left: 2px;
box-shadow: 0 0 6px 0 rgba(3, 0, 34, 0.048), 0 0 4px 0 rgba(3, 0, 34, 0.185);
}
&.multiselect--above .multiselect__content-wrapper {
top: unset;
}
.multiselect__input,
.toggle-button {
font-size: $font-size-base;
color: $text-color;
text-decoration: none;
padding: 0;
background: none;
cursor: text;
text-align: left;
margin: 0;
border: none;
}
.multiselect__input {
padding-left: 0.25rem;
}
.toggle-button {
padding-left: 0.5rem;
}
// built in option class
.multiselect__option {
min-height: unset;
padding: 0;
&::after {
display: none;
}
// custom option wrapper
.multiselect-option {
font-size: $font-size-base;
padding: 0.5rem;
display: inline-block;
width: 100%;
height: 100%;
.info {
display: none;
}
&.invalid {
color: $brand-light;
background-color: $brand-warning;
}
}
}
.multiselect__option--selected {
.multiselect-option {
color: $brand-primary;
.info:not(.highlighted) {
display: inline-block;
}
}
}
.multiselect__option--highlight {
&::after {
display: none;
}
.multiselect-option {
background: $brand-primary;
color: $brand-light;
.info.highlighted {
display: inline-block;
}
.info:not(.highlighted) {
display: none;
}
}
}
}
}
</style>
@@ -0,0 +1,109 @@
import { getLocalVue } from "tests/jest/helpers";
import { mount } from "@vue/test-utils";
import Tag from "./Tag";
const localVue = getLocalVue();
const mountWithProps = (props) => {
return mount(Tag, {
propsData: props,
localVue,
});
};
describe("Tag", () => {
it("displays it's option", () => {
{
const tag = mountWithProps({ option: "my_tag" });
expect(tag.text()).toBe("my_tag");
}
{
const tag = mountWithProps({ option: "a_longer_tag_name" });
expect(tag.text()).toBe("a_longer_tag_name");
}
});
it("shows it's clickable", async () => {
const tag = mountWithProps({ option: "my_tag" });
expect(tag.classes()).not.toContain("clickable");
tag.setProps({ clickable: true });
await tag.vm.$nextTick();
expect(tag.classes()).toContain("clickable");
});
it("can be clicked", async () => {
const tag = mountWithProps({ option: "my_tag", clickable: true });
expect(tag.classes()).toContain("clickable");
tag.trigger("click");
await tag.vm.$nextTick();
expect(tag.emitted().click).toBeTruthy();
expect(tag.emitted().click.length).toBe(1);
expect(tag.emitted().click[0]).toEqual(["my_tag"]);
tag.trigger("click");
await tag.vm.$nextTick();
expect(tag.emitted().click.length).toBe(2);
expect(tag.emitted().click).toStrictEqual([["my_tag"], ["my_tag"]]);
});
it("changes appearance when editable", async () => {
const tag = mountWithProps({ option: "my_tag" });
expect(tag.classes()).not.toContain("editable");
expect(tag.find(".tag-delete-button").exists()).not.toBe(true);
tag.setProps({ editable: true });
await tag.vm.$nextTick();
expect(tag.classes()).toContain("editable");
expect(tag.find(".tag-delete-button").exists()).toBe(true);
});
it("can be deleted", async () => {
const tag = mountWithProps({ option: "my_tag", editable: true });
expect(tag.find(".tag-delete-button").exists()).toBe(true);
tag.find(".tag-delete-button").trigger("click");
await tag.vm.$nextTick();
expect(tag.emitted().deleted).toBeTruthy();
expect(tag.emitted().deleted.length).toBe(1);
expect(tag.emitted().deleted[0]).toEqual(["my_tag"]);
expect(tag.emitted().click).toBeFalsy();
});
it("displays named tags bold", () => {
{
const wrapper = mountWithProps({ option: "my_tag" });
const span = wrapper.find(".tag span");
expect(span.classes()).not.toContain("font-weight-bold");
}
{
const wrapper = mountWithProps({ option: "#named_tag" });
const span = wrapper.find(".tag span");
expect(span.classes()).toContain("font-weight-bold");
}
});
it("highlights when searched", async () => {
const tag = mountWithProps({ option: "my_tag" });
expect(tag.classes()).not.toContain("searched");
tag.setProps({ search: "my_tag" });
await tag.vm.$nextTick();
expect(tag.classes()).toContain("searched");
});
});
@@ -0,0 +1,112 @@
<script setup lang="ts">
import { computed } from "vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { library } from "@fortawesome/fontawesome-svg-core";
import { faTimes } from "@fortawesome/free-solid-svg-icons";
import { keyedColorScheme } from "@/utils/color";
export interface TagProps {
option: string;
search?: string;
editable?: boolean;
clickable?: boolean;
}
const props = defineProps<TagProps>();
const emit = defineEmits<{
(e: "click", tag: string): void;
(e: "deleted", tag: string): void;
}>();
//@ts-ignore bad types
library.add(faTimes);
const color = computed(() => keyedColorScheme(props.option));
function onClick() {
emit("click", props.option);
}
function onDelete() {
emit("deleted", props.option);
}
const named = computed(() => props.option?.startsWith("#"));
const searched = computed(() => props.option?.toLowerCase() === props.search?.toLowerCase());
</script>
<template>
<div
class="tag btn-transparent-background"
:data-option="props.option"
:class="{ editable, clickable, searched }"
:style="`--color-primary: ${color.primary}; --color-darker: ${color.darker}; --color-dimmed: ${color.dimmed}`"
@click.prevent.stop="onClick">
<span :class="{ 'font-weight-bold': named }">
{{ props.option }}
</span>
<b-button
v-if="editable"
size="sm"
variant="link"
class="px-1 py-0 tag-delete-button"
tabindex="-1"
@click.prevent.stop="onDelete">
<FontAwesomeIcon icon="fa-times"></FontAwesomeIcon>
</b-button>
</div>
</template>
<style lang="scss" scoped>
@import "scss/theme/blue.scss";
.tag {
display: inline-flex;
align-items: baseline;
margin-right: 0.25rem;
margin-bottom: 0.1rem;
font-size: $font-size-base * 0.95;
color: black;
border-radius: 4px;
background-color: var(--color-primary);
transition: background-color 0.1s;
padding: 0 0.5rem;
&.editable {
padding: 0 0.25rem;
}
position: relative;
&:before {
content: "";
width: 100%;
height: 100%;
top: 0;
left: 0;
position: absolute;
border-color: var(--color-darker);
border-radius: inherit;
pointer-events: none;
border-style: solid;
border-width: 0 2px 1px 0;
}
&.clickable {
cursor: pointer;
&:hover {
background-color: var(--color-dimmed);
}
}
&.searched {
outline: 2px solid $brand-danger;
}
}
</style>
@@ -8,10 +8,12 @@ import MockConfigProvider from "../providers/MockConfigProvider";
import MockCurrentHistory from "components/providers/MockCurrentHistory";
import Vue from "vue";
import Vuex from "vuex";
import { createPinia } from "pinia";
import { userStore } from "store/userStore";
import { configStore } from "store/configStore";
const localVue = getLocalVue();
const pinia = createPinia();
describe("ToolForm", () => {
let wrapper;
@@ -52,6 +54,7 @@ describe("ToolForm", () => {
FormDisplay: true,
},
provide: { store },
pinia,
});
});
+12 -11
View File
@@ -107,6 +107,8 @@
<script>
import { getGalaxyInstance } from "app";
import { useHistoryItemsStore } from "stores/history/historyItemsStore";
import { mapState } from "pinia";
import { getToolFormData, updateToolFormData, submitJob } from "./services";
import { allowCachedJobs } from "./utilities";
import { refreshContentsWrapper } from "utils/data";
@@ -189,6 +191,7 @@ export default {
};
},
computed: {
...mapState(useHistoryItemsStore, ["getLatestCreateTime"]),
toolName() {
return this.formConfig.name;
},
@@ -219,21 +222,19 @@ export default {
}
},
},
created() {
this.requestTool().then(() => {
watch: {
getLatestCreateTime() {
const Galaxy = getGalaxyInstance();
if (Galaxy && Galaxy.currHistoryPanel) {
console.debug(`ToolForm::created - Started listening to history changes. [${this.id}]`);
Galaxy.currHistoryPanel.collection.on("change", this.onHistoryChange, this);
console.debug("History change watcher detected a change.");
this.onHistoryChange();
}
});
},
},
beforeDestroy() {
const Galaxy = getGalaxyInstance();
if (Galaxy && Galaxy.currHistoryPanel) {
Galaxy.currHistoryPanel.collection.off("change", this.onHistoryChange, this);
console.debug(`ToolForm::beforeDestroy - Stopped listening to history changes. [${this.id}]`);
}
created() {
this.requestTool().then(() => {
console.debug(`ToolForm::created - Started listening to history changes. [${this.id}]`);
});
},
methods: {
emailAllowed(config, user) {
+2 -53
View File
@@ -1,6 +1,5 @@
<script setup>
import { computed } from "vue";
import { getAppRoot } from "onload/loadConfig";
import { useFormattedToolHelp } from "composables/formattedToolHelp";
const props = defineProps({
content: {
@@ -9,57 +8,7 @@ const props = defineProps({
},
});
const formattedContent = computed(() => {
const node = document.createElement("div");
node.innerHTML = props.content;
const links = node.getElementsByTagName("a");
Array.from(links).forEach((link) => {
link.target = "_blank";
});
const images = node.getElementsByTagName("img");
Array.from(images).forEach((image) => {
if (image.src.includes("admin_toolshed")) {
image.src = getAppRoot() + image.src;
}
});
// loop these levels backwards to avoid increasing heading twice
[5, 4, 3, 2, 1].forEach((level) => {
increaseHeadingLevel(node, level, 2);
});
return node.innerHTML;
});
/**
* @param {HTMLElement} node
* @param {number} level
* @param {number} increaseBy
*/
function increaseHeadingLevel(node, level, increaseBy) {
// cap target level at 6 (highest heading level)
let targetLevel = level + increaseBy;
if (targetLevel > 6) {
targetLevel = 6;
}
const headings = node.getElementsByTagName(`h${level}`);
// create new headings with target level and copy contents + attributes
Array.from(headings).forEach((heading) => {
const newTag = document.createElement(`h${targetLevel}`);
newTag.innerHTML = heading.innerHTML;
Array.from(heading.attributes).forEach((attribute) => {
newTag.setAttribute(attribute.name, attribute.value);
});
heading.insertAdjacentElement("beforebegin", newTag);
heading.remove();
});
}
const { formattedContent } = useFormattedToolHelp(props.content);
</script>
<template>
@@ -1,17 +1,24 @@
<template>
<b-button
v-if="offset > 200"
v-b-tooltip.noninteractive.hover
class="ui-btn-back-to-top btn-circle"
class="back-to-top"
:class="{ show: offset > 100 }"
title="Scroll To Top"
variant="info"
@click="$emit('click')">
<i class="fa fa-arrow-up" />
<FontAwesomeIcon icon="fa-chevron-up" />
</b-button>
</template>
<script>
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { library } from "@fortawesome/fontawesome-svg-core";
import { faChevronUp } from "@fortawesome/free-solid-svg-icons";
library.add(faChevronUp);
export default {
components: { FontAwesomeIcon },
props: {
offset: {
type: Number,
@@ -20,3 +27,17 @@ export default {
},
};
</script>
<style lang="scss" scoped>
.back-to-top {
bottom: 0.5rem;
left: 0.5rem;
position: absolute;
opacity: 0;
transition: opacity 0.4s;
&.show {
opacity: 1;
}
}
</style>
+40 -24
View File
@@ -4,36 +4,34 @@
:filter-settings="filterSettings"
:toolbox="toolbox"
:panel-view="panelView">
<section class="overflow-auto h-100" @scroll="onScroll">
<section class="tools-list">
<div class="mb-2">
<span class="row mb-2">
<span class="col">
<h1 v-if="hasFilters" class="d-inline-block h-lg">Advanced Tool Search Results</h1>
<h1 v-else class="d-inline-block h-lg">
Consolidated view of {{ itemsLoaded.length }} available tools.
</h1>
</span>
</span>
<h1 class="h-lg">Search Results</h1>
<span v-if="itemsLoaded.length !== 0" class="row">
<span v-if="hasFilters" class="col d-inline-block">
<span v-if="filterCount" class="col d-inline-block d-flex align-items-baseline flex-gapx-1">
Found {{ itemsLoaded.length }} tools for
<a id="popover-filters" href="javascript:void(0)">filters</a>.
<b-popover target="popover-filters" triggers="hover" placement="top">
<b-button id="popover-filters" class="ui-link">
{{ filterCount }}
{{ filterCount === 1 ? "filter" : "filters" }}.
</b-button>
<b-popover target="popover-filters" triggers="hover focus" placement="bottom">
<template v-slot:title>Filters</template>
<div v-for="(value, filter) in filterSettings" :key="filter">
<b>{{ filter }}</b
>: {{ value }}
</div>
</b-popover>
Click <a href="javascript:void(0)" @click.stop="showAllTools">here</a> for a consolidated view
of all tools in this Galaxy instance.
<b-button variant="link" size="sm" @click.stop="showAllTools">
<FontAwesomeIcon icon="fa-times" />
Clear filters
</b-button>
</span>
<span v-else class="col d-inline-block">
No filters applied. Please add filters to the menu in the Tool Panel.
No filters applied. Please add filters to the Advanced Tool Search in the Tool Panel.
</span>
</span>
</div>
<div>
<div ref="scrollContainer" class="overflow-auto">
<b-alert v-if="loading" class="m-2" variant="info" show>
<LoadingSpan message="Loading Advanced Search Results" />
</b-alert>
@@ -44,7 +42,7 @@
<ToolsListTable :tools="itemsLoaded" />
</div>
</div>
<ScrollToTopButton :offset="offset" @click="scrollToTop" />
<ScrollToTopButton :offset="scrollTop" @click="scrollToTop" />
</section>
</ToolsProvider>
</template>
@@ -54,6 +52,13 @@ import LoadingSpan from "components/LoadingSpan";
import { ToolsProvider } from "components/providers/storeProviders";
import ToolsListTable from "./ToolsListTable";
import ScrollToTopButton from "./ScrollToTopButton";
import { useAnimationFrameScroll } from "composables/sensors/animationFrameScroll";
import { ref } from "vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { library } from "@fortawesome/fontawesome-svg-core";
import { faTimes } from "@fortawesome/free-solid-svg-icons";
library.add(faTimes);
export default {
components: {
@@ -61,6 +66,7 @@ export default {
ToolsListTable,
ToolsProvider,
ScrollToTopButton,
FontAwesomeIcon,
},
props: {
name: {
@@ -80,9 +86,13 @@ export default {
default: "",
},
},
data() {
setup() {
const scrollContainer = ref(null);
const { scrollTop } = useAnimationFrameScroll(scrollContainer);
return {
offset: 0,
scrollContainer,
scrollTop,
};
},
computed: {
@@ -101,16 +111,13 @@ export default {
});
return newFilterSettings;
},
hasFilters() {
filterCount() {
return Object.keys(this.filterSettings).length;
},
},
methods: {
onScroll(e) {
this.offset = e.target.scrollTop;
},
scrollToTop() {
this.$el.scrollTop = 0;
this.$refs.scrollContainer.scrollTo({ top: 0, behavior: "smooth" });
},
showAllTools() {
this.$router.push({ path: "/tools/list" });
@@ -118,3 +125,12 @@ export default {
},
};
</script>
<style lang="scss" scoped>
.tools-list {
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
}
</style>
@@ -0,0 +1,136 @@
<script setup>
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { ref, computed } from "vue";
import { useFormattedToolHelp } from "composables/formattedToolHelp";
import ToolFavoriteButton from "components/Tool/Buttons/ToolFavoriteButton";
const props = defineProps({
id: { type: String, required: true },
name: { type: String, required: true },
section: { type: String, required: true },
description: { type: String, default: null },
summary: { type: String, default: null },
help: { type: String, default: null },
version: { type: String, default: null },
link: { type: String, default: null },
workflowCompatible: { type: Boolean, default: false },
local: { type: Boolean, default: false },
});
const emit = defineEmits(["open"]);
const showHelp = ref(false);
const formattedToolHelp = computed(() => {
if (showHelp.value) {
const { formattedContent } = useFormattedToolHelp(props.help);
return formattedContent.value;
} else {
return "";
}
});
</script>
<script>
import { library } from "@fortawesome/fontawesome-svg-core";
import {
faWrench,
faGlobe,
faCheck,
faTimes,
faAngleDown,
faAngleUp,
faExclamationTriangle,
} from "@fortawesome/free-solid-svg-icons";
library.add(faWrench, faGlobe, faCheck, faTimes, faAngleDown, faAngleUp, faExclamationTriangle);
</script>
<template>
<div class="tool-list-item ui-portlet-section">
<div class="top-bar bg-secondary px-2 py-1 rounded-right">
<div class="py-1 d-flex flex-wrap flex-gapx-1">
<span>
<FontAwesomeIcon v-if="props.local" icon="fa-wrench" fixed-width />
<FontAwesomeIcon v-else icon="fa-globe" fixed-width />
<b-button v-if="props.local" class="ui-link text-dark" @click="() => emit('open')">
<b>{{ props.name }}</b>
</b-button>
<b-button v-else class="ui-link text-dark" :href="props.link">
<b>{{ props.name }}</b>
</b-button>
</span>
<span itemprop="description">{{ props.description }}</span>
<span>(Galaxy Version {{ props.version }})</span>
</div>
<div>
<ToolFavoriteButton :id="props.id" />
<b-button v-if="props.local" variant="primary" size="sm" @click="() => emit('open')">
<FontAwesomeIcon icon="fa-wrench" fixed-width />
Open
</b-button>
<b-button v-else variant="primary" size="sm" :href="props.link">
<FontAwesomeIcon icon="fa-globe" fixed-width />
Open
</b-button>
</div>
</div>
<div class="portlet-content">
<div class="d-flex flex-gapx-1 py-2">
<span v-if="props.section" class="info px-1 rounded">
<b>Section:</b> <b-link :to="`/tools/list?section=${props.section}`">{{ section }}</b-link>
</span>
<span v-if="!props.local" class="info px-1 rounded">
<FontAwesomeIcon icon="fa-globe" fixed-width />
External
</span>
<span v-if="!props.workflowCompatible" class="warn px-1 rounded">
<FontAwesomeIcon icon="fa-exclamation-triangle" />
Not Workflow compatible
</span>
</div>
<div v-if="props.summary" v-html="props.summary"></div>
<div v-if="props.help" class="mt-2">
<b-button v-if="!showHelp" class="ui-link" @click="() => (showHelp = true)">
<FontAwesomeIcon icon="fa-angle-down" />
Show tool help
</b-button>
<b-button v-else class="ui-link" @click="() => (showHelp = false)">
<FontAwesomeIcon icon="fa-angle-up" />
Hide tool help
</b-button>
<div v-if="showHelp" class="mt-2" v-html="formattedToolHelp"></div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
@import "theme/blue.scss";
.tool-list-item {
.info {
background-color: scale-color($brand-info, $lightness: +75%);
}
.success {
background-color: scale-color($brand-success, $lightness: +75%);
}
.warn {
background-color: scale-color($brand-warning, $lightness: +75%);
}
.top-bar {
display: flex;
justify-content: space-between;
}
}
</style>
@@ -1,59 +1,25 @@
<template>
<div v-infinite-scroll="loadTools" infinite-scroll-disabled="busy">
<b-table striped bordered :fields="fields" :items="buffer">
<template v-slot:cell(name)="row">
<span v-if="!row.item.help">
<b>{{ row.item.name }}</b> {{ row.item.description }}
</span>
<span v-else>
<b-link href="javascript:void(0)" role="button" @click.stop="row.toggleDetails()">
<b>{{ row.item.name }}</b> {{ row.item.description }}
</b-link>
<p v-if="!row.item._showDetails && row.item.summary" v-html="row.item.summary" />
</span>
</template>
<template v-slot:row-details="row">
<b-card v-if="row.item.help">
<p class="mb-1" v-html="row.item.help" />
<a
:href="row.item.target === 'galaxy_main' ? 'javascript:void(0)' : row.item.link"
@click.stop="onOpen(row.item)">
Click here to open the tool
</a>
</b-card>
</template>
<template v-slot:cell(section)="row">
{{ row.item.panel_section_name }}
</template>
<template v-slot:cell(workflow)="row">
<span
v-if="row.item.is_workflow_compatible"
v-b-tooltip.hover
class="fa fa-check text-success"
title="Is Workflow Compatible" />
<span v-else v-b-tooltip.hover class="fa fa-times text-danger" title="Not Workflow Compatible" />
</template>
<template v-slot:cell(target)="row">
<span
v-if="row.item.target === 'galaxy_main'"
v-b-tooltip.hover
class="fa fa-check text-success"
title="Is Local" />
<span v-else v-b-tooltip.hover class="fa fa-times text-danger" title="Not Local" />
</template>
<template v-slot:cell(open)="row">
<b-button
v-b-tooltip.hover.top
:title="'Open Tool' | localize"
class="fa fa-play"
size="sm"
variant="primary"
:href="row.item.target === 'galaxy_main' ? 'javascript:void(0)' : row.item.link"
@click.stop="onOpen(row.item)" />
</template>
</b-table>
<div
v-infinite-scroll="loadTools"
class="tools-list-table"
infinite-scroll-distance="200"
infinite-scroll-disabled="busy">
<ToolsListItem
v-for="item of buffer"
:id="item.id"
:key="item.id"
:name="item.name"
:section="item.panel_section_name"
:description="item.description"
:summary="item.summary"
:help="item.help"
:local="item.target === 'galaxy_main'"
:link="item.link"
:workflow-compatible="item.is_workflow_compatible"
:version="item.version"
@open="() => onOpen(item)" />
<div>
<i v-if="allLoaded">All {{ tools.length > 1 ? tools.length : "" }} results loaded</i>
<div v-if="allLoaded" class="list-end my-2">- End of search results -</div>
<b-overlay :show="busy" opacity="0.5" />
</div>
</div>
@@ -61,15 +27,16 @@
<script>
import Vue from "vue";
import _l from "utils/localization";
import infiniteScroll from "vue-infinite-scroll";
import { openGlobalUploadModal } from "components/Upload";
import { useGlobalUploadModal } from "composables/globalUploadModal";
import { fetchData } from "./services";
import ToolsListItem from "./ToolsListItem";
const defaultBufferLen = 4;
const loadTimeout = 100;
export default {
components: { ToolsListItem },
directives: { infiniteScroll },
props: {
tools: {
@@ -77,37 +44,15 @@ export default {
default: null,
},
},
setup() {
const { openGlobalUploadModal } = useGlobalUploadModal();
return { openGlobalUploadModal };
},
data() {
return {
allLoaded: false,
bufferLen: 0,
busy: false,
fields: [
{
key: "name",
label: _l("Name"),
sortable: true,
},
{
key: "section",
label: _l("Section"),
sortable: true,
},
{
key: "workflow",
label: _l("Workflow Compatible"),
sortable: false,
},
{
key: "target",
label: _l("Local Tool"),
sortable: false,
},
{
key: "open",
label: "",
},
],
};
},
computed: {
@@ -121,7 +66,7 @@ export default {
methods: {
onOpen(tool) {
if (tool.id === "upload1") {
openGlobalUploadModal();
this.openGlobalUploadModal();
} else if (tool.form_style === "regular") {
// encode spaces in tool.id
const toolId = tool.id;
@@ -181,3 +126,19 @@ export default {
},
};
</script>
<style lang="scss" scoped>
@import "theme/blue.scss";
.tools-list-table {
display: flex;
flex-direction: column;
gap: 0.5rem;
.list-end {
width: 100%;
text-align: center;
color: $text-light;
}
}
</style>
@@ -1,9 +1,9 @@
import Collection from "./Collection.vue";
import { mountWithApp } from "./testHelpers";
import { mountWithDetails } from "./testHelpers";
describe("Collection.vue", () => {
it("loads with correct initial state", async () => {
const { wrapper } = mountWithApp(Collection);
const { wrapper } = mountWithDetails(Collection);
expect(wrapper.vm.counterAnnounce).toBe(0);
expect(wrapper.vm.showHelper).toBe(true);
expect(wrapper.vm.extensions[0].id).toBe("ab1");
@@ -14,21 +14,19 @@ describe("Collection.vue", () => {
});
it("does render FTP is site set", async () => {
const { wrapper } = mountWithApp(Collection);
const { wrapper } = mountWithDetails(Collection);
expect(wrapper.find("#btn-ftp").element).toBeVisible();
});
it("doesn't render FTP is no site set", async () => {
const { wrapper } = mountWithApp(Collection, {
currentFtp: () => {
return null;
},
const { wrapper } = mountWithDetails(Collection, {
currentFtp: null,
});
expect(wrapper.findAll("#btn-ftp").length).toBe(0);
});
it("resets properly", async () => {
const { wrapper, localVue } = mountWithApp(Collection);
const { wrapper, localVue } = mountWithDetails(Collection);
expect(wrapper.vm.showHelper).toBe(true);
await localVue.nextTick();
await wrapper.find("#btn-new").trigger("click");
@@ -43,7 +41,7 @@ describe("Collection.vue", () => {
});
it("respects lazyLoadMax limit", async () => {
const { wrapper, localVue } = mountWithApp(Collection, {}, { lazyLoadMax: 2 });
const { wrapper, localVue } = mountWithDetails(Collection, {}, { lazyLoadMax: 2 });
expect(wrapper.findAll(".ui-limitloader").length).toBe(1);
await localVue.nextTick();
await wrapper.find("#btn-new").trigger("click");
+6 -6
View File
@@ -134,8 +134,8 @@ export default {
uploadUrl: null,
topInfo: "",
showHelper: true,
extension: this.app.defaultExtension,
genome: this.app.defaultDbKey,
extension: this.details.defaultExtension,
genome: this.details.defaultDbKey,
collectionType: "list",
listExtensions: [],
listGenomes: [],
@@ -168,7 +168,7 @@ export default {
return result;
},
appModel() {
return this.app.model;
return this.details.model;
},
},
watch: {
@@ -221,7 +221,7 @@ export default {
ondragleave: () => {
this.highlightBox = false;
},
chunkSize: this.app.chunkUploadSize,
chunkSize: this.details.chunkUploadSize,
});
this.collection.on("remove", (model) => {
this._eventRemove(model);
@@ -289,8 +289,8 @@ export default {
this.counterError = 0;
this.counterRunning = 0;
this.uploadbox.reset();
this.extension = this.app.defaultExtension;
this.genome = this.app.defaultDbKey;
this.extension = this.details.defaultExtension;
this.genome = this.details.defaultDbKey;
this.appModel.set("percentage", 0);
this._updateStateForCounters();
}
@@ -1,9 +1,9 @@
import Composite from "./Composite.vue";
import { mountWithApp } from "./testHelpers";
import { mountWithDetails } from "./testHelpers";
describe("Composite.vue", () => {
it("loads with correct initial state", async () => {
const { wrapper } = mountWithApp(Composite);
const { wrapper } = mountWithDetails(Composite);
expect(wrapper.find("#btn-start").classes()).toEqual(expect.arrayContaining(["disabled"]));
expect(wrapper.vm.showHelper).toBe(true);
expect(wrapper.vm.readyStart).toBe(false);
+4 -4
View File
@@ -72,7 +72,7 @@ export default {
data() {
return {
extension: "_select_",
genome: this.app.defaultDbKey,
genome: this.details.defaultDbKey,
listExtensions: [],
listGenomes: [],
running: false,
@@ -160,7 +160,7 @@ export default {
});
});
submitUpload({
url: this.app.uploadPath,
url: this.details.uploadPath,
data: uploadModelsToPayload(this.collection.filter(), this.history_id, true),
success: (message) => {
this._eventSuccess(message);
@@ -178,8 +178,8 @@ export default {
_eventReset: function () {
if (this.collection.where({ status: "running" }).length == 0) {
this.collection.reset();
this.extension = this.app.defaultExtension;
this.genome = this.app.defaultDbKey;
this.extension = this.details.defaultExtension;
this.genome = this.details.defaultDbKey;
this.renderNonReactiveComponents();
}
},
+7 -9
View File
@@ -1,5 +1,5 @@
import Default from "./Default.vue";
import { mountWithApp } from "./testHelpers";
import { mountWithDetails } from "./testHelpers";
import Backbone from "backbone";
jest.mock("app");
@@ -10,7 +10,7 @@ UploadRow.mockImplementation(Backbone.View);
describe("Default.vue", () => {
it("loads with correct initial state", async () => {
const { wrapper } = mountWithApp(Default);
const { wrapper } = mountWithDetails(Default);
expect(wrapper.vm.counterAnnounce).toBe(0);
expect(wrapper.vm.showHelper).toBe(true);
expect(wrapper.vm.extensions[0].id).toBe("ab1");
@@ -20,23 +20,21 @@ describe("Default.vue", () => {
});
it("does render FTP is site set", async () => {
const { wrapper } = mountWithApp(Default);
const { wrapper } = mountWithDetails(Default);
expect(wrapper.find("#btn-ftp").element).toBeVisible();
await wrapper.find("#btn-ftp").trigger("click");
// TODO: test popover appears... not sure best way to do this...
});
it("doesn't render FTP is no site set", async () => {
const { wrapper } = mountWithApp(Default, {
currentFtp: () => {
return null;
},
const { wrapper } = mountWithDetails(Default, {
currentFtp: null,
});
expect(wrapper.findAll("#btn-ftp").length).toBe(0);
});
it("resets properly", async () => {
const { wrapper, localVue } = mountWithApp(Default);
const { wrapper, localVue } = mountWithDetails(Default);
expect(wrapper.vm.showHelper).toBe(true);
await localVue.nextTick();
await wrapper.find("#btn-new").trigger("click");
@@ -47,7 +45,7 @@ describe("Default.vue", () => {
});
it("renders a limitloader element if lazyLoadMax set", async () => {
const { wrapper } = mountWithApp(Default, {}, { lazyLoadMax: 2 });
const { wrapper } = mountWithDetails(Default, {}, { lazyLoadMax: 2 });
expect(wrapper.findAll(".ui-limitloader").length).toBe(1);
// hard to actually test the functionality like in Collection.test.js
// because we're stubbing out all of UploadRow.
+6 -6
View File
@@ -123,8 +123,8 @@ export default {
topInfo: "",
highlightBox: false,
showHelper: true,
extension: this.app.defaultExtension,
genome: this.app.defaultDbKey,
extension: this.details.defaultExtension,
genome: this.details.defaultDbKey,
listExtensions: [],
listGenomes: [],
running: false,
@@ -151,7 +151,7 @@ export default {
return result;
},
appModel() {
return this.app.model;
return this.details.model;
},
},
watch: {
@@ -205,7 +205,7 @@ export default {
ondragleave: () => {
this.highlightBox = false;
},
chunkSize: this.app.chunkUploadSize,
chunkSize: this.details.chunkUploadSize,
});
this.collection.on("remove", (model) => {
this._eventRemove(model);
@@ -241,8 +241,8 @@ export default {
this.counterError = 0;
this.counterRunning = 0;
this.uploadbox.reset();
this.extension = this.app.defaultExtension;
this.genome = this.app.defaultDbKey;
this.extension = this.details.defaultExtension;
this.genome = this.details.defaultDbKey;
this.appModel.set("percentage", 0);
this._updateStateForCounters();
}
@@ -0,0 +1,71 @@
<script setup>
import { ref, computed } from "vue";
import { useFileDrop } from "composables/fileDrop";
import { useGlobalUploadModal } from "composables/globalUploadModal";
const modalContentElement = ref(null);
const { isFileOverDocument, isFileOverDropZone } = useFileDrop(modalContentElement, onDrop, true);
const modalClass = computed(() => {
if (isFileOverDropZone.value) {
return "ui-drag-and-drop-modal drag-over";
} else {
return "ui-drag-and-drop-modal";
}
});
const { openGlobalUploadModal } = useGlobalUploadModal();
function onDrop(event) {
console.debug(event.dataTransfer);
if (event.dataTransfer?.files?.length > 0) {
openGlobalUploadModal({
immediateUpload: true,
immediateFiles: event.dataTransfer.files,
});
}
}
</script>
<template>
<b-modal v-model="isFileOverDocument" :modal-class="modalClass" hide-header hide-footer centered>
<div ref="modalContentElement" class="inner-content h-xl">Drop Files here to Upload</div>
</b-modal>
</template>
<style lang="scss">
@import "theme/blue.scss";
.ui-drag-and-drop-modal {
.modal-content {
background-color: transparent;
border-radius: 16px;
border: 6px dashed;
border-color: $brand-secondary;
min-height: 40vh;
.modal-body {
display: flex;
}
.inner-content {
flex: 1 1 auto;
display: grid;
place-items: center;
color: $brand-secondary;
font-weight: bold;
}
}
&.drag-over {
.modal-content {
border-color: lighten($brand-info, 30%);
.inner-content {
color: lighten($brand-info, 30%);
}
}
}
}
</style>
@@ -1,14 +1,14 @@
import RulesInput from "./RulesInput.vue";
import { mountWithApp } from "./testHelpers";
import { mountWithDetails } from "./testHelpers";
describe("RulesInput.vue", () => {
it("loads with correct initial state", async () => {
const { wrapper } = mountWithApp(RulesInput);
const { wrapper } = mountWithDetails(RulesInput);
expect(wrapper.find("#btn-reset").classes()).toEqual(expect.arrayContaining(["disabled"]));
});
it("enables reset when sourceContent is populated", async () => {
const { wrapper } = mountWithApp(RulesInput);
const { wrapper } = mountWithDetails(RulesInput);
const textInput = wrapper.find(".upload-rule-source-content");
expect(textInput.element.value).toBe("");
await textInput.setValue("a b c d");
+19 -18
View File
@@ -22,10 +22,6 @@ export default {
Select2,
},
props: {
app: {
type: Object,
required: true,
},
lazyLoadMax: {
type: Number,
default: null,
@@ -38,6 +34,10 @@ export default {
type: Boolean,
default: false,
},
details: {
type: Object,
required: true,
},
},
computed: {
btnFilesTitle() {
@@ -55,12 +55,7 @@ export default {
return this.hasCallback ? "Cancel" : "Close";
},
history_id() {
const storeId = this.$store?.getters["history/currentHistoryId"];
if (storeId) {
return storeId;
}
const legacyId = this.app.currentHistory();
return legacyId;
return this.details.history_id;
},
},
methods: {
@@ -119,7 +114,7 @@ export default {
}
});
if (list.length > 0) {
const data = uploadModelsToPayload(list, this.history_id);
const data = uploadModelsToPayload(list, this.details.history_id);
axios
.post(`${getAppRoot()}api/tools/fetch`, data)
.then((message) => {
@@ -289,6 +284,9 @@ export default {
_eventCreate: function () {
this.uploadbox.add([{ name: defaultNewFileName, size: 0, mode: "new" }]);
},
addFiles: function (files) {
this.uploadbox.add(files);
},
/** Pause upload process */
_eventStop: function () {
if (this.counterRunning > 0) {
@@ -305,7 +303,7 @@ export default {
return $(this.$refs.uploadTable);
},
extensionDetails(extension) {
return findExtension(this.app.effectiveExtensions, extension);
return findExtension(this.details.effectiveExtensions, extension);
},
initExtensionInfo() {
$(this.$refs.footerExtensionInfo)
@@ -329,10 +327,10 @@ export default {
this.collection = new UploadModel.Collection();
},
initAppProperties() {
this.listExtensions = this.app.effectiveExtensions;
this.listGenomes = this.app.listGenomes;
this.ftpUploadSite = this.app.currentFtp();
this.fileSourcesConfigured = this.app.fileSourcesConfigured;
this.listExtensions = this.details.effectiveExtensions;
this.listGenomes = this.details.listGenomes;
this.ftpUploadSite = this.details.currentFtp;
this.fileSourcesConfigured = this.details.fileSourcesConfigured;
},
initFtpPopover() {
// add ftp file viewer
@@ -348,7 +346,7 @@ export default {
this.collection.each((model) => {
if (
model.get("status") == "init" &&
(model.get("extension") == this.app.defaultExtension || !defaults_only)
(model.get("extension") == this.details.defaultExtension || !defaults_only)
) {
model.set("extension", extension);
}
@@ -356,7 +354,10 @@ export default {
},
updateGenome: function (genome, defaults_only) {
this.collection.each((model) => {
if (model.get("status") == "init" && (model.get("genome") == this.app.defaultDbKey || !defaults_only)) {
if (
model.get("status") == "init" &&
(model.get("genome") == this.details.defaultDbKey || !defaults_only)
) {
model.set("genome", genome);
}
});
@@ -28,7 +28,8 @@ import { VBTooltip } from "bootstrap-vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { library } from "@fortawesome/fontawesome-svg-core";
import { faUpload } from "@fortawesome/free-solid-svg-icons";
import { openGlobalUploadModal } from "./mount";
import { useGlobalUploadModal } from "composables/globalUploadModal";
library.add(faUpload);
export default {
@@ -39,6 +40,10 @@ export default {
props: {
title: { type: String, default: "Download from URL or upload files from disk" },
},
setup() {
const { openGlobalUploadModal } = useGlobalUploadModal();
return { openGlobalUploadModal };
},
data() {
return {
status: "",
@@ -58,7 +63,7 @@ export default {
},
methods: {
showUploadDialog() {
openGlobalUploadModal();
this.openGlobalUploadModal();
},
setStatus(val) {
this.status = val;
@@ -2,12 +2,12 @@ import MockAdapter from "axios-mock-adapter";
import axios from "axios";
import UploadModal from "./UploadModal";
import UploadModalContent from "./UploadModalContent";
import store from "../../store";
import { mount } from "@vue/test-utils";
import { getLocalVue } from "tests/jest/helpers";
import MockCurrentUser from "../providers/MockCurrentUser";
import MockCurrentHistory from "../providers/MockCurrentHistory";
import { getLocalVue, mockModule } from "tests/jest/helpers";
import { userStore } from "store/userStore";
import { historyStore } from "store/historyStore";
import { configStore } from "store/configStore";
import Vuex from "vuex";
jest.mock("app");
@@ -17,6 +17,16 @@ const propsData = {
fileSourcesConfigured: true,
};
const createStore = () => {
return new Vuex.Store({
modules: {
user: mockModule(userStore, { currentUser: { id: "fakeuser" } }),
history: mockModule(historyStore, { currentHistoryId: "fakehistory", histories: { fakehistory: {} } }),
config: mockModule(configStore, { config: {} }),
},
});
};
describe("UploadModal.vue", () => {
let wrapper;
let axiosMock;
@@ -47,17 +57,14 @@ describe("UploadModal.vue", () => {
axiosMock.onGet(`/api/datatypes?extension_only=False`).reply(200, datatypesResponse);
const localVue = getLocalVue();
const store = createStore();
wrapper = await mount(UploadModal, {
store,
provide: { store },
propsData,
localVue,
stubs: {
// Need to stub all this horrible-ness because of the last 2 tests
// which need to dig into the first layer of the mount tree, will remove
// all of this shortly with a PR that completely replaces Upload
CurrentUser: MockCurrentUser({ id: "fakeuser" }),
UserHistories: MockCurrentHistory({ id: "fakehistory" }),
BTabs: true,
BTab: true,
Collection: true,
@@ -66,6 +73,8 @@ describe("UploadModal.vue", () => {
RulesInput: true,
},
});
await wrapper.vm.open();
});
afterEach(() => {
@@ -74,8 +83,9 @@ describe("UploadModal.vue", () => {
});
it("should load with correct defaults", async () => {
expect(wrapper.vm.auto.id).toBe("auto");
expect(wrapper.vm.datatypesDisableAuto).toBe(false);
const contentWrapper = wrapper.findComponent(UploadModalContent);
expect(contentWrapper.vm.auto.id).toBe("auto");
expect(contentWrapper.vm.datatypesDisableAuto).toBe(false);
});
it("should fetch datatypes and parse them", async () => {
+114 -85
View File
@@ -1,89 +1,118 @@
<!--
Temporary modal wrapper until I replace the entire UploadModal which desperately needs a rewrite.
Provides user and current history to modal because it currently has initialization sequence issues
-->
<script setup>
import UploadModalContent from "./UploadModalContent";
import { ref, watch } from "vue";
import { getAppRoot } from "onload";
import { useCurrentUser } from "composables/user";
import { useUserHistories } from "composables/userHistories";
import { useConfig } from "composables/config";
const { currentUser } = useCurrentUser();
const { currentHistoryId } = useUserHistories(currentUser);
const { config, isLoaded } = useConfig();
function getDefaultOptions() {
const baseOptions = {
title: "Upload from Disk or Web",
modalStatic: true,
callback: null,
multiple: true,
selectable: false,
uploadPath: "",
immediateUpload: false,
immediateFiles: null,
};
const configOptions = isLoaded.value
? {
uploadPath: config.value.nginx_upload_path ?? `${getAppRoot()}api/tools`,
chunkUploadSize: config.value.chunk_upload_size,
fileSourcesConfigured: config.value.file_sources_configured,
ftpUploadSite: config.value.ftp_upload_site,
defaultDbKey: config.value.default_genome,
defaultExtension: config.value.default_extension,
}
: {};
return { ...baseOptions, ...configOptions };
}
const options = ref(getDefaultOptions());
const showModal = ref(false);
const content = ref(null);
function dismiss(result) {
if (result && options.value.callback) {
options.value.callback(result);
}
showModal.value = false;
}
function wait(milliseconds) {
return new Promise((resolve) => {
setTimeout(() => resolve(), milliseconds);
});
}
async function open(overrideOptions) {
const newOptions = overrideOptions ?? {};
options.value = { ...getDefaultOptions(), ...newOptions };
if (options.value.callback) {
options.value.hasCallback = true;
}
showModal.value = true;
await wait(100);
if (options.value.immediateUpload) {
content.value.immediateUpload(options.value.immediateFiles);
}
}
watch(
() => showModal.value,
(modalShown) => setIframeEvents(modalShown)
);
function setIframeEvents(disableEvents) {
const element = document.getElementById("galaxy_main");
if (element) {
element.style["pointer-events"] = disableEvents ? "none" : "auto";
}
}
defineExpose({
open,
});
</script>
<template>
<CurrentUser v-slot="{ user }">
<UserHistories v-if="user" v-slot="{ currentHistoryId }" :user="user">
<b-modal
v-model="modalShow"
:static="modalStatic"
header-class="no-separator"
modal-class="ui-modal"
dialog-class="upload-dialog"
body-class="upload-dialog-body"
no-enforce-focus
hide-footer>
<template v-slot:modal-header>
<h2 class="title h-sm" tabindex="0">{{ title | localize }}</h2>
</template>
<b-modal
v-model="showModal"
:static="options.modalStatic"
header-class="no-separator"
modal-class="ui-modal"
dialog-class="upload-dialog"
body-class="upload-dialog-body"
no-enforce-focus
hide-footer>
<template v-slot:modal-header>
<h2 class="title h-sm" tabindex="0">{{ options.title }}</h2>
</template>
<UploadModalContent
v-if="currentHistoryId"
:current-user-id="user.id"
:current-history-id="currentHistoryId"
v-bind="{ ...$props, ...$attrs }"
@dismiss="dismiss" />
</b-modal>
</UserHistories>
</CurrentUser>
<UploadModalContent
v-if="currentHistoryId"
ref="content"
:key="showModal"
:currentUserId="currentUser.id"
:currentHistoryId="currentHistoryId"
v-bind="options"
@dismiss="dismiss" />
</b-modal>
</template>
<script>
import CurrentUser from "../providers/CurrentUser";
import UserHistories from "components/providers/UserHistories";
import UploadModalContent from "./UploadModalContent";
import { commonProps } from "./helpers";
export default {
components: {
CurrentUser,
UserHistories,
UploadModalContent,
},
props: {
title: { type: String, default: "Download from web or upload from disk" },
modalStatic: { type: Boolean, default: true },
...commonProps,
},
data() {
return {
modalShow: false,
};
},
watch: {
modalShow() {
this.setIframeEvents(this.modalShow);
},
},
mounted() {
this.show();
// handles subsequent external requests to re-open a re-used modal
this.$root.$on("openUpload", this.show);
},
methods: {
show() {
this.modalShow = true;
},
hide() {
this.modalShow = false;
},
dismiss(result) {
if (undefined !== result) {
this.$root.$emit("uploadResult", result);
}
this.hide();
},
/** Disable mouse events in iframe to prevent interference with uploader drop box */
setIframeEvents(disableEvents) {
const element = document.getElementById("galaxy_main");
if (element) {
element.style["pointer-events"] = disableEvents ? "none" : "auto";
} else {
console.warn("UploadModal::setIframeEvents - `galaxy_main` not found.");
}
},
},
};
</script>
@@ -2,7 +2,8 @@
<b-tabs v-if="ready">
<b-tab v-if="showRegular" id="regular" title="Regular" button-id="tab-title-link-regular">
<default
:app="this"
ref="regular"
:details="details"
:lazy-load-max="50"
:multiple="multiple"
:has-callback="hasCallback"
@@ -10,13 +11,13 @@
v-on="$listeners" />
</b-tab>
<b-tab v-if="showComposite" id="composite" title="Composite" button-id="tab-title-link-composite">
<composite :app="this" :has-callback="hasCallback" :selectable="selectable" v-on="$listeners" />
<composite :details="details" :has-callback="hasCallback" :selectable="selectable" v-on="$listeners" />
</b-tab>
<b-tab v-if="showCollection" id="collection" title="Collection" button-id="tab-title-link-collection">
<collection :app="this" :has-callback="hasCallback" :selectable="selectable" v-on="$listeners" />
<collection :details="details" :has-callback="hasCallback" :selectable="selectable" v-on="$listeners" />
</b-tab>
<b-tab v-if="showRules" id="rule-based" title="Rule-based" button-id="tab-title-link-rule-based">
<rules-input :app="this" :has-callback="hasCallback" :selectable="selectable" v-on="$listeners" />
<rules-input :details="details" :has-callback="hasCallback" :selectable="selectable" v-on="$listeners" />
</b-tab>
</b-tabs>
<div v-else>
@@ -35,7 +36,6 @@ import RulesInput from "./RulesInput";
import LoadingSpan from "components/LoadingSpan";
import { uploadModelsToPayload } from "./helpers";
import { BTabs, BTab } from "bootstrap-vue";
import { commonProps } from "./helpers";
export default {
components: {
@@ -50,7 +50,29 @@ export default {
props: {
currentHistoryId: { type: String, required: true },
currentUserId: { type: String, default: "" },
...commonProps,
uploadPath: { type: String, required: true },
chunkUploadSize: { type: Number, default: 1024 },
fileSourcesConfigured: { type: Boolean, default: false },
ftpUploadSite: { type: String, default: "" },
defaultDbKey: { type: String, default: UploadUtils.DEFAULT_DBKEY },
defaultExtension: { type: String, default: UploadUtils.DEFAULT_EXTENSION },
datatypesDisableAuto: { type: Boolean, default: false },
formats: { type: Array, default: null },
multiple: {
// Restrict the forms to a single dataset upload if false
type: Boolean,
default: true,
},
hasCallback: {
// Return uploads when done if supplied.
type: Boolean,
default: false,
},
selectable: { type: Boolean, default: false },
auto: {
type: Object,
default: () => UploadUtils.AUTO_EXTENSION,
},
},
data: function () {
return {
@@ -113,6 +135,23 @@ export default {
}
return this.multiple;
},
currentFtp() {
return this.currentUserId && this.ftpUploadSite;
},
details() {
return {
effectiveExtensions: this.effectiveExtensions,
listGenomes: this.listGenomes,
currentFtp: this.currentFtp,
fileSourcesConfigured: this.fileSourcesConfigured,
defaultExtension: this.defaultExtension,
defaultDbKey: this.defaultDbKey,
uploadPath: this.uploadPath,
model: this.model,
chunkUploadSize: this.chunkUploadSize,
history_id: this.currentHistoryId,
};
},
},
created() {
this.model = new Backbone.Model({
@@ -170,9 +209,6 @@ export default {
this.id = String(this._uid);
},
methods: {
currentFtp: function () {
return this.currentUserId && this.ftpUploadSite;
},
/**
* Package API data from array of backbone models
* @param{Array} items - Upload items/rows filtered from a collection
@@ -180,6 +216,10 @@ export default {
toData: function (items, history_id, composite = false) {
return uploadModelsToPayload(items, history_id, composite);
},
immediateUpload: function (files) {
this.$refs.regular?.addFiles(files);
this.$refs.regular?._eventStart();
},
},
};
</script>
-18
View File
@@ -1,18 +0,0 @@
import { getGalaxyInstance } from "app";
import { getAppRoot } from "onload";
export function initializeUploadDefaults(propsData = {}) {
const Galaxy = getGalaxyInstance();
const appRoot = getAppRoot();
const defaults = {
multiple: true,
uploadPath: Galaxy.config.nginx_upload_path || `${appRoot}api/tools`,
chunkUploadSize: Galaxy.config.chunk_upload_size,
fileSourcesConfigured: Galaxy.config.file_sources_configured,
ftpUploadSite: Galaxy.config.ftp_upload_site,
defaultDbKey: Galaxy.config.default_genome,
defaultExtension: Galaxy.config.default_extension,
selectable: false,
};
return Object.assign({}, defaults, propsData);
}
-56
View File
@@ -1,4 +1,3 @@
import UploadUtils from "mvc/upload/upload-utils";
export const defaultNewFileName = "New File";
const URI_PREFIXES = ["http", "https", "ftp", "file", "gxfiles", "gximport", "gxuserimport", "gxftp"];
@@ -109,58 +108,3 @@ export function uploadModelsToPayload(items, history_id, composite = false) {
files: files,
};
}
export const commonProps = {
uploadPath: {
type: String,
required: true,
},
chunkUploadSize: {
type: Number,
default: 1024,
},
fileSourcesConfigured: {
type: Boolean,
default: false,
},
ftpUploadSite: {
type: String,
default: "",
},
defaultDbKey: {
type: String,
default: UploadUtils.DEFAULT_DBKEY,
},
defaultExtension: {
type: String,
default: UploadUtils.DEFAULT_EXTENSION,
},
datatypesDisableAuto: {
type: Boolean,
default: false,
},
formats: {
type: Array,
default: null,
},
multiple: {
// Restrict the forms to a single dataset upload if false
type: Boolean,
default: true,
},
hasCallback: {
// Return uploads when done if supplied.
type: Boolean,
default: false,
},
selectable: {
type: Boolean,
default: false,
},
auto: {
type: Object,
default: () => {
return UploadUtils.AUTO_EXTENSION;
},
},
};
-2
View File
@@ -2,6 +2,4 @@
* External entry point for Upload components (currently only the modal).
*/
export { default as UploadModal } from "./UploadModal";
export { initializeUploadDefaults } from "./config";
export { openGlobalUploadModal, mountUploadModal } from "./mount";
export { default as UploadButton } from "./UploadButton";
-37
View File
@@ -1,37 +0,0 @@
import UploadModal from "./UploadModal";
import { initializeUploadDefaults } from "./config";
import { mountVueComponent } from "utils/mountVueComponent";
export function mountUploadModal(options = {}) {
const props = initializeUploadDefaults(options);
// should use events insted of passing in functions
const { callback, ...propsData } = props;
if (callback) {
// internal display characteristic
propsData.hasCallback = true;
}
const mounter = mountVueComponent(UploadModal);
const container = document.createElement("div");
document.body.appendChild(container);
const uploadVm = mounter(propsData, container);
if (callback) {
uploadVm.$once("uploadResult", callback);
}
return uploadVm;
}
// Global upload dialog instance
let uploadVm = null;
export function openGlobalUploadModal(options) {
if (!uploadVm) {
uploadVm = mountUploadModal(options);
}
// re-open
uploadVm.$emit("openUpload", uploadVm);
return uploadVm;
}
+5 -7
View File
@@ -4,12 +4,10 @@ import "utils/uploadbox";
import { mount, createLocalVue } from "@vue/test-utils";
import BootstrapVue from "bootstrap-vue";
export const createMockApp = (options = {}) => {
export const createMockDetails = (options = {}) => {
return _.defaults(options, {
defaultExtension: "auto",
currentFtp: () => {
return "ftp://localhost";
},
currentFtp: "ftp://localhost",
model: new Backbone.Model(),
effectiveExtensions: [
{ id: "ab1", text: "ab1", description: "A binary sequence file in 'ab1' format with a '.ab1'" },
@@ -34,9 +32,9 @@ export const createMockApp = (options = {}) => {
});
};
export function mountWithApp(component, options = {}, propsData_ = {}) {
const app = createMockApp(options);
const propsData = _.defaults(propsData_, { app });
export function mountWithDetails(component, options = {}, propsData_ = {}) {
const details = createMockDetails(options);
const propsData = _.defaults(propsData_, { details });
const localVue = createLocalVue();
localVue.use(BootstrapVue);
@@ -138,7 +138,6 @@ export default {
if (pjas[this.emailPayloadKey]) {
pjas[this.emailActionKey] = true;
}
console.debug("FormSection - Setting new data.", this.postJobActions, pjas);
this.formData = pjas;
},
setEmailAction(pjas) {

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