Merge remote-tracking branch 'upstream/dev' into dnd-upload

This commit is contained in:
Laila Los
2022-12-19 12:10:43 +01:00
248 changed files with 8892 additions and 4606 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
+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",
"@popperjs/core": "^2.11.5",
@@ -54,7 +54,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",
@@ -97,7 +97,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",
@@ -180,6 +180,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();
+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);
+2 -2
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>;
@@ -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();
@@ -0,0 +1,89 @@
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.emitted()["indeterminate"][0][0]).toBeTruthy();
});
});
@@ -0,0 +1,72 @@
<script setup>
import { computed, ref, watch } from "vue";
const emit = defineEmits(["input", "indeterminate"]);
const props = defineProps({
value: {
default: null,
},
options: {
type: Array,
required: true,
},
});
const selectAll = ref(false);
const indeterminate = ref(false);
const currentValue = computed({
get: () => {
const val = props.value ?? [];
return Array.isArray(val) ? val : [val];
},
set: (val) => {
emit("input", val);
},
});
const hasOptions = computed(() => {
return props.options.length > 0;
});
function onSelectAll() {
if (selectAll.value) {
const allValues = props.options.map((option) => option[1]);
emit("input", allValues);
} else {
emit("input", []);
}
}
watch(currentValue, () => {
const valueLength = currentValue.value.length;
if (valueLength === 0) {
selectAll.value = false;
indeterminate.value = false;
} else if (valueLength === props.options.length) {
selectAll.value = true;
indeterminate.value = false;
} else {
indeterminate.value = true;
}
});
</script>
<template>
<div v-if="hasOptions">
<b-form-checkbox
v-model="selectAll"
v-localize
class="mb-1"
:indeterminate="indeterminate"
@input="onSelectAll"
@change="emit('indeterminate', selectAll)">
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,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({
+73 -105
View File
@@ -1,94 +1,67 @@
<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.
@@ -96,15 +69,15 @@ const emit = defineEmits(["input", "change"]);
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 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";
@@ -77,7 +77,7 @@
:value="tags"
:disabled="tagsDisabled"
:clickable="filterable"
:useToggleLink="false"
:use-toggle-link="false"
@input="onTags"
@tag-click="onTagClick" />
<!-- collections are not expandable, so we only need the DatasetDetails component here -->
@@ -1,153 +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"
: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>
@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";
@@ -179,7 +172,6 @@ export default {
HistoryDropZone,
HistoryEmpty,
HistoryFilters,
HistoryItemsProvider,
HistoryOperations,
HistorySelectionOperations,
HistorySelectionStatus,
@@ -199,10 +191,12 @@ export default {
},
data() {
return {
error: null,
filterText: "",
highlights: {},
highlightsKey: null,
invisible: {},
loading: false,
offset: 0,
showAdvanced: false,
showDropZone: false,
@@ -212,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}`;
@@ -232,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: {
@@ -245,6 +256,7 @@ export default {
this.invisible = {};
this.offset = 0;
this.resetHighlights();
this.loadHistoryItems();
},
historyId(newVal, oldVal) {
if (newVal !== oldVal) {
@@ -255,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)];
},
@@ -270,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,
});
}
@@ -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);
}
+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;
@@ -1,13 +1,13 @@
<script setup lang="ts">
import Multiselect from "vue-multiselect";
import Tag from "./Tag.vue";
import { ref, computed } from "vue";
import Multiselect from "vue-multiselect";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { useUserTags } from "composables/user";
import { useToast } from "composables/toast";
import { useUid } from "composables/utils/uid";
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";
@@ -119,12 +119,12 @@ function onTagClicked(tag: string) {
v-if="!disabled"
ref="multiselectElement"
placeholder="Add Tags"
openDirection="bottom"
open-direction="bottom"
:value="tags"
:options="userTags"
:multiple="true"
:taggable="true"
:closeOnSelect="false"
:close-on-select="false"
@tag="onAddTag"
@input="onInput"
@open="onOpen"
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { keyedColorScheme } from "utils/color";
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;
@@ -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 { 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: {
@@ -86,32 +53,6 @@ export default {
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: {
@@ -185,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>
+14 -1
View File
@@ -1,7 +1,7 @@
<script setup>
import UploadModalContent from "./UploadModalContent";
import { ref } from "vue";
import { ref, watch } from "vue";
import { getAppRoot } from "onload";
import { useCurrentUser } from "composables/user";
@@ -74,6 +74,19 @@ async function open(overrideOptions) {
}
}
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,
});
@@ -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) {
@@ -189,14 +189,12 @@ export default {
},
postChanges(newVersion) {
const payload = Object.assign({}, this.mainValues);
console.debug("FormTool - Posting changes.", payload);
const options = this.configForm;
let toolId = options.id;
let toolVersion = options.version;
if (newVersion) {
toolId = toolId.replace(toolVersion, newVersion);
toolVersion = newVersion;
console.debug("FormTool - Tool version changed.", toolId, toolVersion);
}
this.$emit("onSetData", this.nodeId, {
tool_id: toolId,
@@ -721,7 +721,6 @@ export default {
this.lastQueue
.enqueue(loadWorkflow, { id, version, workflow: this })
.then((data) => {
console.debug("Editor - Loading workflow:", id);
this._loadEditorData(data);
})
.catch((response) => {
@@ -1,5 +1,6 @@
<script setup>
import TrsTool from "./TrsTool";
import TrsUrlImport from "./TrsUrlImport";
import { Toast } from "composables/toast";
import { Services } from "../services";
import { getGalaxyInstance } from "app";
@@ -23,6 +24,10 @@ const props = defineProps({
type: String,
default: null,
},
queryTrsUrl: {
type: String,
default: null,
},
isRun: {
type: Boolean,
default: false,
@@ -115,6 +120,22 @@ const importVersion = (trsId, toolIdToImport, version = null, isRunFormRedirect
importing.value = false;
});
};
const importVersionFromUrl = (url, isRunFormRedirect = false) => {
importing.value = true;
errorMessage.value = null;
services
.importTrsToolFromUrl(url)
.then((response) => {
redirectOnImport(safePath("/"), response, isRunFormRedirect);
})
.catch((e) => {
errorMessage.value = e || "Import failed for an unknown reason.";
})
.finally(() => {
importing.value = false;
});
};
</script>
<template>
@@ -153,6 +174,10 @@ const importVersion = (trsId, toolIdToImport, version = null, isRunFormRedirect
:trs-tool="trsTool"
@onImport="importVersion(trsSelection.id, trsTool.id, $event)" />
</div>
<hr />
<div>
<TrsUrlImport :query-trs-url="props.queryTrsUrl" @onImport="importVersionFromUrl($event)" />
</div>
</b-card>
<b-alert v-else class="text-center my-2" show variant="danger">
Anonymous user cannot import workflows, please register or log in
@@ -0,0 +1,50 @@
<script setup>
import { computed, ref } from "vue";
const props = defineProps({
queryTrsUrl: {
type: String,
default: null,
},
});
const trsUrl = ref(props.queryTrsUrl);
const isImportDisabled = computed(() => {
return !trsUrl.value;
});
const importTooltip = computed(() => {
return isImportDisabled.value ? "You must provide a TRS URL." : "Import workflow from TRS URL";
});
const emit = defineEmits(["onImport"]);
const submit = (ev) => {
ev.preventDefault();
emit("onImport", trsUrl.value);
};
// Automatically trigger the import if the TRS URL was provided as a query param
if (trsUrl.value) {
emit("onImport", trsUrl.value);
}
</script>
<template>
<b-form class="mt-4" @submit="submit">
<h2 class="h-sm">alternatively, provide a TRS URL directly</h2>
<b-form-group label="TRS URL:" label-class="font-weight-bold">
<b-form-input id="trs-import-url-input" v-model="trsUrl" aria-label="TRS URL" type="url" />
If the workflow is accessible via a TRS URL, enter the URL above and click Import.
</b-form-group>
<b-button
id="trs-url-import-button"
type="submit"
:disabled="isImportDisabled"
:title="importTooltip"
variant="primary">
Import workflow
</b-button>
</b-form>
</template>
@@ -110,4 +110,18 @@ export class Services {
rethrowSimple(e);
}
}
async importTrsToolFromUrl(trsUrl) {
const data = {
archive_source: "trs_tool",
trs_url: trsUrl,
};
const url = safePath("/api/workflows");
try {
const response = await axios.post(url, data);
return response.data;
} catch (e) {
rethrowSimple(e);
}
}
}
+27 -39
View File
@@ -1,45 +1,33 @@
<script setup lang="ts">
import { ref, watch, onMounted } from "vue";
import { fetcher } from "@/schema";
const jobLock = ref(false);
const jobLockUpdating = ref(true);
const jobLockStatus = fetcher.path("/api/job_lock").method("get").create();
const jobLockUpdate = fetcher.path("/api/job_lock").method("put").create();
watch(jobLock, async (newVal) => {
jobLockUpdating.value = true;
const { data } = await jobLockUpdate({ active: jobLock.value });
jobLock.value = data.active;
jobLockUpdating.value = false;
});
onMounted(async () => {
// TODO: see if we can upstream an optional arg when no params are required?
// i.e. jobLockStatus() instead of jobLockStatus({})
const response = await jobLockStatus({});
jobLock.value = response.data.active;
jobLockUpdating.value = false;
});
</script>
<template>
<b-form-group>
<b-form-checkbox id="prevent-job-dispatching" v-model="jobLock" switch size="lg">
<b-form-checkbox id="prevent-job-dispatching" v-model="jobLock" :disabled="jobLockUpdating" switch size="lg">
Job dispatching is currently
<strong>{{ jobLockDisplay ? "locked" : "unlocked" }}</strong>
<strong>{{ jobLock ? "locked" : "unlocked" }}</strong>
</b-form-checkbox>
</b-form-group>
</template>
<script>
import { getAppRoot } from "onload/loadConfig";
import axios from "axios";
export default {
data() {
return {
jobLock: false,
jobLockDisplay: false,
};
},
watch: {
jobLock(newVal) {
this.handleJobLock(axios.put(`${getAppRoot()}api/job_lock`, { active: this.jobLock }));
},
},
created() {
this.initJobLock();
},
methods: {
initJobLock() {
this.handleJobLock(axios.get(`${getAppRoot()}api/job_lock`));
},
handleJobLock(axiosPromise) {
axiosPromise
.then((response) => {
this.jobLock = response.data.active;
this.jobLockDisplay = response.data.active;
})
.catch((error) => {
console.error(error);
});
},
},
};
</script>
@@ -192,5 +192,4 @@ export const StoreProvider = (storeAction, storeGetter, storeCountGetter = undef
export const DatasetProvider = StoreProvider("fetchDataset", "getDataset");
export const CollectionElementsProvider = StoreProvider("fetchCollectionElements", "getCollectionElements");
export const HistoryItemsProvider = StoreProvider("fetchHistoryItems", "getHistoryItems", "getTotalMatchesCount");
export const ToolsProvider = StoreProvider("fetchAllTools", "getTools");
-51
View File
@@ -1,51 +0,0 @@
import { ref } from "vue";
import axios from "axios";
import { getAppRoot } from "onload/loadConfig";
/**
* Fetches a detailed array of datatypes available on this galaxy instance.
* Does not cache the result or use a store.
*/
export function useDetailedDatatypes() {
const datatypesLoading = ref(true);
const datatypes = ref([]);
async function getDatatypes() {
try {
const datatypesPromise = axios.get(`${getAppRoot()}api/datatypes?extension_only=false`);
const datatypeEDAMFormatsPromise = axios.get(`${getAppRoot()}api/datatypes/edam_formats/detailed`);
const datatypeEDAMDataPromise = axios.get(`${getAppRoot()}api/datatypes/edam_data/detailed`);
const [baseTypes, datatypeEDAMFormats, datatypeEDAMData] = await Promise.all([
datatypesPromise,
datatypeEDAMFormatsPromise,
datatypeEDAMDataPromise,
]);
datatypes.value = baseTypes.data.map((type, i) => {
const typeEDAMFormat = datatypeEDAMFormats.data[type.extension] ?? null;
const typeEDAMData = datatypeEDAMData.data[type.extension] ?? null;
return {
extension: type.extension,
description: type.description,
descriptionUrl: type.description_url,
edamFormat: typeEDAMFormat.prefix_IRI,
edamFormatLabel: typeEDAMFormat.label,
edamFormatDefinition: typeEDAMFormat.definition,
edamData: typeEDAMData.prefix_IRI,
edamDataLabel: typeEDAMData.label,
edamDataDefinition: typeEDAMData.definition,
};
});
} catch (e) {
console.error("unable to fetch available datatypes\n", e);
} finally {
datatypesLoading.value = false;
}
}
getDatatypes();
return { datatypes, datatypesLoading };
}
+70
View File
@@ -0,0 +1,70 @@
import { ref } from "vue";
import { fetcher } from "@/schema";
import type { Ref } from "vue";
const datatypesFetcher = fetcher.path("/api/datatypes").method("get").create();
const edamFormatsFetcher = fetcher.path("/api/datatypes/edam_formats/detailed").method("get").create();
const edamDataFetcher = fetcher.path("/api/datatypes/edam_data/detailed").method("get").create();
export interface DetailedDatatypes {
extension: string;
description?: string;
descriptionUrl?: string;
edamFormat?: string;
edamFormatLabel?: string;
edamFormatDefinition?: string;
edamData?: string;
edamDataLabel?: string;
edamDataDefinition?: string;
}
/**
* Fetches a detailed array of datatypes available on this galaxy instance.
* Does not cache the result or use a store.
*/
export function useDetailedDatatypes() {
const datatypesLoading = ref(true);
const datatypes: Ref<DetailedDatatypes[]> = ref([]);
async function getDatatypes() {
try {
const datatypesPromise = datatypesFetcher({ extension_only: true });
const datatypeEDAMFormatsPromise = edamFormatsFetcher({});
const datatypeEDAMDataPromise = edamDataFetcher({});
const [baseTypes, datatypeEDAMFormats, datatypeEDAMData] = await Promise.all([
datatypesPromise,
datatypeEDAMFormatsPromise,
datatypeEDAMDataPromise,
]);
type BaseTypes = Exclude<typeof baseTypes.data, string[]>;
datatypes.value = (baseTypes.data as BaseTypes).map((type) => {
const typeEDAMFormat = datatypeEDAMFormats.data[type.extension] ?? null;
const typeEDAMData = datatypeEDAMData.data[type.extension] ?? null;
return {
extension: type.extension,
description: type.description,
descriptionUrl: type.description_url,
edamFormat: typeEDAMFormat?.prefix_IRI,
edamFormatLabel: typeEDAMFormat?.label,
edamFormatDefinition: typeEDAMFormat?.definition,
edamData: typeEDAMData?.prefix_IRI,
edamDataLabel: typeEDAMData?.label,
edamDataDefinition: typeEDAMData?.definition,
};
});
} catch (e) {
console.error("unable to fetch available datatypes\n", e);
} finally {
datatypesLoading.value = false;
}
}
getDatatypes();
return { datatypes, datatypesLoading };
}
+34
View File
@@ -0,0 +1,34 @@
import { ref, readonly, onMounted } from "vue";
import { Services } from "components/FilesDialog/services";
const fileSourcesService = new Services();
/**
* Composable for accessing and working with file sources.
*/
export function useFileSources() {
const isLoading = ref(true);
const hasWritable = ref(false);
const fileSources = ref([]);
onMounted(async () => {
fileSources.value = await fileSourcesService.getFileSources();
hasWritable.value = fileSources.value.some((fs) => fs.writable);
isLoading.value = false;
});
return {
/**
* The list of available file sources from the server.
*/
fileSources: readonly(fileSources),
/**
* Whether the file sources are being loaded from the server.
*/
isLoading: readonly(isLoading),
/**
* Whether the user can write files to any of the available file sources.
*/
hasWritable: readonly(hasWritable),
};
}
@@ -0,0 +1,64 @@
import { computed, unref } from "vue";
import { getAppRoot } from "onload/loadConfig";
/**
* Increase the heading levels of all child nodes of a node
* @param {HTMLElement} node
* @param {number} increaseBy
*/
function increaseHeadingLevels(node, increaseBy) {
[5, 4, 3, 2, 1].forEach((level) => {
increaseHeadingLevel(node, level, increaseBy);
});
}
/**
* Increase a single heading level
*/
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();
});
}
export function useFormattedToolHelp(helpContent, headingLevelIncrease = 2) {
const formattedContent = computed(() => {
const node = document.createElement("div");
node.innerHTML = unref(helpContent);
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;
}
});
increaseHeadingLevels(node, unref(headingLevelIncrease));
return node.innerHTML;
});
return { formattedContent };
}
+123
View File
@@ -0,0 +1,123 @@
import { ref, readonly } from "vue";
import axios from "axios";
import { safePath } from "utils/redirect";
import { rethrowSimple } from "utils/simple-error";
export const DEFAULT_EXPORT_PARAMS = {
modelStoreFormat: "rocrate.zip",
includeFiles: true,
includeDeleted: false,
includeHidden: false,
};
const DEFAULT_POLL_DELAY = 1000;
const DEFAULT_OPTIONS = { exportParams: DEFAULT_EXPORT_PARAMS, pollDelayInMs: DEFAULT_POLL_DELAY };
/**
* Composable to simplify and reuse the logic for downloading objects using Galaxy's Short Term Storage system.
*/
export function useShortTermStorage() {
let timeout = null;
let pollDelay = DEFAULT_POLL_DELAY;
const isPreparing = ref(false);
async function downloadHistory(historyId, options = DEFAULT_OPTIONS) {
return prepareObjectDownload(historyId, "histories", options);
}
async function downloadWorkflowInvocation(invocationId, options = DEFAULT_OPTIONS) {
return prepareObjectDownload(invocationId, "invocations", options);
}
function downloadObjectByRequestId(storageRequestId) {
const url = safePath(`/api/short_term_storage/${storageRequestId}`);
window.location.assign(url);
}
async function prepareObjectDownload(object_id, object_api, options = DEFAULT_OPTIONS) {
const finalOptions = Object.assign(DEFAULT_OPTIONS, options);
resetTimeout();
isPreparing.value = true;
pollDelay = finalOptions.pollDelayInMs;
const url = safePath(`/api/${object_api}/${object_id}/prepare_store_download`);
const exportParams = {
model_store_format: finalOptions.exportParams.modelStoreFormat,
include_files: finalOptions.exportParams.includeFiles,
include_deleted: finalOptions.exportParams.includeDeleted,
include_hidden: finalOptions.exportParams.includeHidden,
};
const response = await axios.post(url, exportParams).catch(handleError);
handleInitialize(response);
}
function handleInitialize(response) {
const storageRequestId = response.data.storage_request_id;
pollStorageRequestId(storageRequestId);
}
function pollStorageRequestId(storageRequestId) {
const url = safePath(`/api/short_term_storage/${storageRequestId}/ready`);
axios
.get(url)
.then((r) => {
handlePollResponse(r, storageRequestId);
})
.catch(handleError);
}
function handlePollResponse(response, storageRequestId) {
const ready = response.data;
if (ready) {
isPreparing.value = false;
downloadObjectByRequestId(storageRequestId);
} else {
pollAfterDelay(storageRequestId);
}
}
function handleError(err) {
rethrowSimple(err);
isPreparing.value = false;
}
function pollAfterDelay(storageRequestId) {
resetTimeout();
timeout = setTimeout(() => {
pollStorageRequestId(storageRequestId);
}, pollDelay);
}
function resetTimeout() {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
}
return {
/**
* Starts preparing a history download file. When `isPreparing` is false the download will start automatically.
* @param {String} historyId The ID of the history to be downloaded
* @param {Object} options Options for the download preparation
*/
downloadHistory,
/**
* Starts preparing a workflow invocation download file. When `isPreparing` is false the download will start automatically.
* @param {String} invocationId The ID of the workflow invocation to be downloaded
* @param {Object} options Options for the download preparation
*/
downloadWorkflowInvocation,
/**
* Starts a direct download of the object associated with the given `storageRequestId`.
* For the download to succeed, the associated `storageRequestId` must be `ready` and not in failure state.
* @param {String} storageRequestId The storage request ID associated to the object to be downloaded
*/
downloadObjectByRequestId,
/**
* Whether the download is still being prepared.
*/
isPreparing: readonly(isPreparing),
};
}
+7 -4
View File
@@ -4,13 +4,14 @@ import axios from "axios";
const SUCCESS_STATE = "SUCCESS";
const FAILURE_STATE = "FAILURE";
const TASK_READY_STATES = [SUCCESS_STATE, FAILURE_STATE];
const POLL_DELAY = 1000;
const DEFAULT_POLL_DELAY = 1000;
/**
* Composable for waiting on Galaxy background tasks.
*/
export function useTaskMonitor() {
let timeout = null;
let pollDelay = DEFAULT_POLL_DELAY;
const isRunning = ref(false);
const status = ref(null);
@@ -21,7 +22,8 @@ export function useTaskMonitor() {
const hasFailed = computed(() => status.value === FAILURE_STATE);
const queryStateUrl = computed(() => `/api/tasks/${currentTaskId.value}/state`);
function waitForTask(taskId) {
function waitForTask(taskId, pollDelayInMs = DEFAULT_POLL_DELAY) {
pollDelay = pollDelayInMs;
resetState();
currentTaskId.value = taskId;
isRunning.value = true;
@@ -46,7 +48,7 @@ export function useTaskMonitor() {
resetTimeout();
timeout = setTimeout(() => {
fetchTaskStatus(taskId);
}, POLL_DELAY);
}, pollDelay);
}
function handleError(err) {
@@ -69,8 +71,9 @@ export function useTaskMonitor() {
return {
/**
* Waits for a particular task ID to be completed.
* While the task is pending, the state will be updated every second by polling the server.
* While the task is pending, the state will be updated every `pollDelayInMs` by polling the server.
* @param {string} taskId The task ID
* @param {Number} pollDelayInMs The time (milliseconds) between poll requests to update the task state.
*/
waitForTask,
/**
-34
View File
@@ -1,34 +0,0 @@
import { ref } from "vue";
let toastRef = ref(null);
export const setToastComponentRef = (newRef) => {
// eslint-disable-next-line vue/no-ref-as-operand
toastRef = newRef;
};
/**
* Direct export to simplify usage in Options Api component.
* Use 'useToast' for the Composition Api instead.
*/
export const Toast = {
success(message, title = "Success") {
toastRef.value.showToast(message, title, "success");
},
info(message, title = "Info") {
toastRef.value.showToast(message, title, "info");
},
warning(message, title = "Warning") {
toastRef.value.showToast(message, title, "warning");
},
error(message, title = "Error") {
toastRef.value.showToast(message, title, "error");
},
};
export function useToast() {
return { ...Toast };
}
+39
View File
@@ -0,0 +1,39 @@
import { ref, type Ref } from "vue";
import type Vue from "vue";
import type ToastComponentFile from "@/components/Toast";
export type ToastComponent = Vue & typeof ToastComponentFile.methods;
let toastRef: Ref<ToastComponent | null> = ref(null);
export const setToastComponentRef = (newRef: typeof toastRef) => {
// eslint-disable-next-line vue/no-ref-as-operand
toastRef = newRef;
};
/**
* Direct export to simplify usage in Options Api component.
* Use 'useToast' for the Composition Api instead.
*/
export const Toast = {
success(message: string, title = "Success") {
toastRef.value?.showToast(message, title, "success");
},
info(message: string, title = "Info") {
toastRef.value?.showToast(message, title, "info");
},
warning(message: string, title = "Warning") {
toastRef.value?.showToast(message, title, "warning");
},
error(message: string, title = "Error") {
toastRef.value?.showToast(message, title, "error");
},
};
export function useToast() {
return { ...Toast };
}
+5 -6
View File
@@ -1,11 +1,10 @@
import { standardInit, addInitialization } from "onload";
import Vue, { provide } from "vue";
import App from "./App.vue";
import store from "store";
import { getRouter } from "./router";
import { createPinia, PiniaVuePlugin } from "pinia";
import { PiniaVuePlugin, createPinia } from "pinia";
import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
import App from "./App.vue";
import { getRouter } from "./router";
import { addInitialization, standardInit } from "@/onload";
import store from "@/store";
Vue.use(PiniaVuePlugin);
const pinia = createPinia();
-2
View File
@@ -17,8 +17,6 @@ export function patchRouterPush(VueRouter) {
if (force) {
location = addSearchParams(location, { __vkey__: Date.now() });
}
// log upcoming location
console.debug("VueRouter - push: ", location);
// verify if confirmation is required
if (this.confirmation) {
if (confirm("There are unsaved changes which will be lost.")) {
+5 -1
View File
@@ -62,6 +62,7 @@ import { APIKey } from "components/User/APIKey";
import { CloudAuth } from "components/User/CloudAuth";
import { ExternalIdentities } from "components/User/ExternalIdentities";
import { HistoryExport } from "components/HistoryExport/index";
import HistoryExportTasks from "components/History/Export/HistoryExport";
import { StorageDashboardRouter } from "components/User/DiskUsage";
Vue.use(VueRouter);
@@ -235,7 +236,9 @@ export function getRouter(Galaxy) {
},
{
path: "histories/:historyId/export",
component: HistoryExport,
get component() {
return Galaxy.config.enable_celery_tasks ? HistoryExportTasks : HistoryExport;
},
props: true,
},
{
@@ -469,6 +472,7 @@ export function getRouter(Galaxy) {
queryTrsServer: route.query.trs_server,
queryTrsId: route.query.trs_id,
queryTrsVersionId: route.query.trs_version,
queryTrsUrl: route.query.trs_url,
isRun: route.query.run_form == "true",
}),
},
-141
View File
@@ -1,141 +0,0 @@
import Backbone from "backbone";
import { getAppRoot } from "onload/loadConfig";
import axios from "axios";
import _l from "utils/localization";
import Ui from "mvc/ui/ui-misc";
import Vue from "vue";
import RuleBasedCollectionCreatorModal from "components/Collections/RuleBasedCollectionCreatorModal";
import { RulesDisplay } from "components/RulesDisplay";
/**
* Bridge rule based builder and the tool form.
*/
var View = Backbone.View.extend({
initialize: function (options) {
this.model = new Backbone.Model();
this.target = options.target;
const view = this;
// create insert edit button
this.browse_button = new Ui.Button({
title: _l("Edit"),
icon: "fa fa-edit",
tooltip: _l("Edit Rules"),
onclick: () => {
if (view.target) {
view._fetchCollectionAndEdit();
} else {
view._showRuleEditor(null);
}
},
});
// add change event. fires on trigger
this.on("change", () => {
if (options.onchange) {
options.onchange(this.value());
}
});
// create elements
this.setElement(this._template(options));
this.$(".ui-rules-edit-button").append(this.browse_button.$el);
var rulesDisplayInstance = Vue.extend(RulesDisplay);
var vm = document.createElement("div");
this.$(".ui-rules-preview").append(vm);
this.instance = new rulesDisplayInstance({
propsData: {
initialRules: {
rules: [],
mapping: [],
},
},
});
this.instance.$mount(vm);
},
_fetchCollectionAndEdit: function () {
const view = this;
const url = `${getAppRoot()}api/dataset_collections/${view.target.id}?instance_type=history`;
axios
.get(url)
.then((response) => this._showCollection(response))
.catch(view._renderFetchError);
},
_showCollection: function (response) {
const elements = response.data;
this._showRuleEditor(elements);
},
_showRuleEditor: function (elements) {
const elementsType = "collection_contents";
const importType = "collections";
const value = this._value;
const options = {
saveRulesFn: (rules) => this._handleRulesSave(rules),
initialRules: value,
};
RuleBasedCollectionCreatorModal.ruleBasedCollectionCreatorModal(
elements,
elementsType,
importType,
options
).catch(() => {});
},
_handleRulesSave: function (rules) {
this._setValue(rules);
},
_renderFetchError: function (e) {
console.log(e);
console.log("problem fetching collection");
},
/** Main Template */
_template: function (options) {
return `
<div class="ui-rules-edit clearfix">
<span class="ui-rules-preview" />
<span class="ui-rules-edit-button float-left" />
</div>
`;
},
/** Return/Set current value */
value: function (new_value) {
if (new_value !== undefined) {
this._setValue(new_value);
} else {
return this._getValue();
}
},
/** Update input element options */
update: function (input_def) {
this.target = input_def.target;
},
/** Returns current value */
_getValue: function () {
return this._value;
},
/** Sets current value */
_setValue: function (new_value) {
if (new_value) {
if (typeof new_value == "string") {
new_value = JSON.parse(new_value);
}
this._value = new_value;
this.model.trigger("error", null);
this.trigger("change");
this.instance.inputRules = new_value;
}
},
});
export default {
View: View,
};
@@ -31,8 +31,12 @@ export default Backbone.View.extend({
this.$buttons = this.$(".charts-buttons");
this.chart = new Chart({}, options);
this.chart.plugin = options.visualization_plugin;
this.chart.requiresConfirmation = asBoolean(this.chart.plugin.specs.confirm);
this.chart.plugin.specs = this.chart.plugin.specs || {};
if (Object.keys(this.chart.plugin.specs).length === 0) {
this.chart.requiresConfirmation = false;
} else {
this.chart.requiresConfirmation = asBoolean(this.chart.plugin.specs.confirm);
}
this.chart_load = options.chart_load;
this.message = new Ui.Message();
this.deferred = new Deferred();
+9 -5
View File
@@ -1,9 +1,13 @@
// Finds <link rel="index"> in head element and pulls root url fragment from
// there That should probably be a <base> tag instead since that's how
// they're using <link rel="index" />
import { serverPath } from "utils/serverPath";
import { serverPath } from "@/utils/serverPath";
/**
* Finds <link rel="index"> in head element and pulls root url fragment from
* there That should probably be a <base> tag instead since that's how
* they're using <link rel="index" />
*
* @param {string} [defaultRoot="/"]
* @returns {string}
*/
export function getRootFromIndexLink(defaultRoot = "/"): string {
const links = document.getElementsByTagName("link");
const indexLink = Array.from(links).find((link) => link.rel == "index");
+2 -2
View File
@@ -1,6 +1,6 @@
import { Fetcher } from "openapi-typescript-fetch";
import { getAppRoot } from "onload/loadConfig";
import { rethrowSimple } from "utils/simple-error";
import { getAppRoot } from "@/onload/loadConfig";
import { rethrowSimple } from "@/utils/simple-error";
import type { paths } from "./schema";
import type { Middleware } from "openapi-typescript-fetch";
+1485 -909
View File
File diff suppressed because it is too large Load Diff
@@ -1,93 +0,0 @@
/**
* Requests history items by reacting to changes of filter props passed
* to the history items provider used in the history panel.
*/
import { reverse } from "lodash";
import { LastQueue } from "utils/promise-queue";
import { urlData } from "utils/url";
import { mergeArray } from "./model/utilities";
import { getFilters, getQueryString, testFilters } from "utils/filterConversion";
const limit = 100;
const queue = new LastQueue();
const state = {
items: {},
itemKey: "hid",
latestCreateTime: new Date(),
totalMatchesCount: undefined,
lastCheckedTime: new Date(),
isWatching: false,
};
const getters = {
getHistoryItems:
(state) =>
({ historyId, filterText }) => {
const itemArray = state.items[historyId] || [];
const filters = getFilters(filterText);
const filtered = itemArray.filter((item) => {
if (!item) {
return false;
}
if (!testFilters(filters, item)) {
return false;
}
return true;
});
return reverse(filtered);
},
getLatestCreateTime: (state) => () => state.latestCreateTime,
getTotalMatchesCount: (state) => () => state.totalMatchesCount,
getLastCheckedTime: (state) => () => state.lastCheckedTime,
getWatchingVisibility: (state) => () => state.isWatching,
};
const actions = {
fetchHistoryItems: async ({ commit }, { historyId, filterText, offset }) => {
const queryString = getQueryString(filterText);
const params = `v=dev&order=hid&offset=${offset}&limit=${limit}`;
const url = `/api/histories/${historyId}/contents?${params}&${queryString}`;
const headers = { accept: "application/vnd.galaxy.history.contents.stats+json" };
await queue.enqueue(urlData, { url, headers }, historyId).then((data) => {
const stats = data.stats;
commit("saveQueryStats", { stats });
const payload = data.contents;
commit("saveHistoryItems", { historyId, payload });
});
},
};
const mutations = {
saveHistoryItems: (state, { historyId, payload }) => {
// merges incoming payload into existing state
mergeArray(historyId, payload, state.items, state.itemKey);
// keep track of latest create time for items
payload.forEach((item) => {
if (item.state == "ok") {
const itemCreateTime = new Date(item.create_time);
if (itemCreateTime > state.latestCreateTime) {
state.latestCreateTime = itemCreateTime;
}
}
});
},
setLastCheckedTime: (state, { checkForUpdate }) => {
state.lastCheckedTime = checkForUpdate;
},
setWatchingVisibility: (state, { watchingVisibility }) => {
state.isWatching = watchingVisibility;
},
saveQueryStats: (state, { stats }) => {
state.totalMatchesCount = stats.total_matches;
},
};
export const historyItemsStore = {
state,
getters,
actions,
mutations,
};
-1
View File
@@ -1,4 +1,3 @@
export { collectionElementsStore } from "./collectionElementsStore";
export { datasetStore } from "./datasetStore";
export { historyItemsStore } from "./historyItemsStore";
export { historyStore } from "./historyStore";
@@ -6,6 +6,7 @@
*/
import defaultStore from "store/index";
import { useHistoryItemsStore } from "stores/history/historyItemsStore";
import { urlData } from "utils/url";
import { loadSet } from "utils/setCache";
import { getCurrentHistoryFromServer } from "./queries";
@@ -37,12 +38,13 @@ function setVisibilityThrottle() {
}
export async function watchHistoryOnce(store) {
const historyItemsStore = useHistoryItemsStore();
// "Reset" watchTimeout so we don't queue up watchHistory calls in rewatchHistory.
watchTimeout = null;
// get current history
const checkForUpdate = new Date();
const history = await getCurrentHistoryFromServer(lastUpdateTime);
store.commit("setLastCheckedTime", { checkForUpdate });
historyItemsStore.setLastCheckedTime(checkForUpdate);
if (!history || !history.id) {
return;
}
@@ -73,7 +75,7 @@ export async function watchHistoryOnce(store) {
// pass changed items to attached stores
store.commit("history/setHistory", history);
store.commit("saveDatasets", { payload });
store.commit("saveHistoryItems", { historyId, payload });
historyItemsStore.saveHistoryItems(historyId, payload);
store.commit("saveCollectionObjects", { payload });
// trigger changes in legacy handler
const Galaxy = getGalaxyInstance();
@@ -86,10 +88,11 @@ export async function watchHistoryOnce(store) {
}
export async function watchHistory(store = defaultStore) {
const historyItemsStore = useHistoryItemsStore();
// Only set up visibility listeners once, whenever a watch is first started
if (watchingVisibility === false) {
watchingVisibility = true;
store.commit("setWatchingVisibility", { watchingVisibility });
historyItemsStore.setWatchingVisibility(watchingVisibility);
document.addEventListener("visibilitychange", setVisibilityThrottle);
}
try {
@@ -98,7 +101,7 @@ export async function watchHistory(store = defaultStore) {
// error alerting the user that watch history failed
console.warn(error);
watchingVisibility = false;
store.commit("setWatchingVisibility", { watchingVisibility });
historyItemsStore.setWatchingVisibility(watchingVisibility);
} finally {
watchTimeout = setTimeout(() => {
watchHistory(store);
@@ -4,22 +4,21 @@ import { watchHistoryOnce } from "./watchHistory";
import { collectionElementsStore } from "store/historyStore/collectionElementsStore";
import { datasetStore } from "store/historyStore/datasetStore";
import { historyStore } from "store/historyStore/historyStore";
import { historyItemsStore } from "store/historyStore/historyItemsStore";
import { useHistoryItemsStore } from "stores/history/historyItemsStore";
import { createPinia, mapState } from "pinia";
import { mount, createLocalVue } from "@vue/test-utils";
import Vuex from "vuex";
const pinia = createPinia();
const testApp = {
template: `<div/>`,
computed: {
...mapState(useHistoryItemsStore, ["getHistoryItems"]),
currentHistoryId() {
return this.$store.getters["history/currentHistoryId"];
},
},
methods: {
getHistoryItems(filterObj) {
return this.$store.getters["getHistoryItems"](filterObj);
},
},
};
describe("watchHistory", () => {
@@ -54,6 +53,7 @@ describe("watchHistory", () => {
axiosMock = new MockAdapter(axios);
const localVue = createLocalVue();
localVue.use(Vuex);
useHistoryItemsStore(pinia);
wrapper = mount(testApp, {
store: new Vuex.Store({
@@ -61,10 +61,10 @@ describe("watchHistory", () => {
collectionElements: collectionElementsStore,
dataset: datasetStore,
history: historyStore,
historyItems: historyItemsStore,
},
}),
localVue,
pinia,
});
});
@@ -79,9 +79,9 @@ describe("watchHistory", () => {
.onGet(/api\/histories\/history-id\/contents?.*/)
.replyOnce(200, historyItems);
await watchHistoryOnce(wrapper.vm.$store);
expect(wrapper.vm.getHistoryItems({ historyId: "history-id", filterText: "" }).length).toBe(2);
expect(wrapper.vm.getHistoryItems({ historyId: "history-id", filterText: "second" })[0].hid).toBe(2);
expect(wrapper.vm.getHistoryItems({ historyId: "history-id", filterText: "state:ok" })[0].hid).toBe(1);
expect(wrapper.vm.getHistoryItems("history-id", "").length).toBe(2);
expect(wrapper.vm.getHistoryItems("history-id", "second")[0].hid).toBe(2);
expect(wrapper.vm.getHistoryItems("history-id", "state:ok")[0].hid).toBe(1);
});
it("survives a failing request", async () => {
@@ -96,7 +96,7 @@ describe("watchHistory", () => {
await watchHistoryOnce(wrapper.vm.$store);
expect(wrapper.vm.currentHistoryId).toBe("history-id");
expect(wrapper.vm.getHistoryItems({ historyId: "history-id", filterText: "" }).length).toBe(2);
expect(wrapper.vm.getHistoryItems("history-id", "").length).toBe(2);
try {
await watchHistoryOnce(wrapper.vm.$store);
} catch (error) {
@@ -123,6 +123,6 @@ describe("watchHistory", () => {
]);
await watchHistoryOnce(wrapper.vm.$store);
// We should have received the update and have 3 items in the history
expect(wrapper.vm.getHistoryItems({ historyId: "history-id", filterText: "" }).length).toBe(3);
expect(wrapper.vm.getHistoryItems("history-id", "").length).toBe(3);
});
});
+1 -2
View File
@@ -15,7 +15,7 @@ import { tagStore } from "./tagStore";
import { jobMetricsStore } from "./jobMetricsStore";
import { jobDestinationParametersStore } from "./jobDestinationParametersStore";
import { invocationStore } from "./invocationStore";
import { collectionElementsStore, datasetStore, historyItemsStore, historyStore } from "./historyStore";
import { collectionElementsStore, datasetStore, historyStore } from "./historyStore";
import { userStore, userFlagsStore } from "./userStore";
import { configStore } from "./configStore";
import { toolStore } from "./toolStore";
@@ -66,7 +66,6 @@ export function createStore() {
jobMetrics: jobMetricsStore,
gridSearch: gridSearchStore,
history: historyStore,
historyItems: historyItemsStore,
panels: panelStore,
tags: tagStore,
tools: toolStore,
@@ -0,0 +1,92 @@
/**
* Requests history items by reacting to changes of filter props passed
* to the history items provider used in the history panel.
*/
import { defineStore } from "pinia";
import { reverse } from "lodash";
import { LastQueue } from "utils/promise-queue";
import { urlData } from "utils/url";
import { mergeArray } from "store/historyStore/model/utilities";
import { getFilters, getQueryString, testFilters } from "utils/filterConversion";
const limit = 100;
const queue = new LastQueue();
export const useHistoryItemsStore = defineStore("historyItemsStore", {
state: () => ({
items: {},
itemKey: "hid",
latestCreateTime: new Date(),
totalMatchesCount: undefined,
lastCheckedTime: new Date(),
isWatching: false,
}),
getters: {
getHistoryItems: (state) => {
return (historyId, filterText) => {
const itemArray = state.items[historyId] || [];
const filters = getFilters(filterText);
const filtered = itemArray.filter((item) => {
if (!item) {
return false;
}
if (!testFilters(filters, item)) {
return false;
}
return true;
});
return reverse(filtered);
};
},
getLatestCreateTime: (state) => {
return state.latestCreateTime;
},
getTotalMatchesCount: (state) => {
return state.totalMatchesCount;
},
getLastCheckedTime: (state) => {
return state.lastCheckedTime;
},
getWatchingVisibility: (state) => {
return state.isWatching;
},
},
actions: {
async fetchHistoryItems(historyId, filterText, offset) {
const queryString = getQueryString(filterText);
const params = `v=dev&order=hid&offset=${offset}&limit=${limit}`;
const url = `/api/histories/${historyId}/contents?${params}&${queryString}`;
const headers = { accept: "application/vnd.galaxy.history.contents.stats+json" };
await queue.enqueue(urlData, { url, headers }, historyId).then((data) => {
const stats = data.stats;
this.totalMatchesCount = stats.total_matches;
const payload = data.contents;
this.saveHistoryItems(historyId, payload);
});
},
// Setters
saveHistoryItems(historyId, payload) {
this.$patch((state) => {
// merges incoming payload into existing state
mergeArray(historyId, payload, state.items, state.itemKey);
// keep track of latest create time for items
payload.forEach((item) => {
if (item.state == "ok") {
const itemCreateTime = new Date(item.create_time);
if (itemCreateTime > state.latestCreateTime) {
state.latestCreateTime = itemCreateTime;
}
}
});
});
},
setLastCheckedTime(checkForUpdate) {
this.lastCheckedTime = checkForUpdate;
},
setWatchingVisibility(watchingVisibility) {
this.isWatching = watchingVisibility;
},
},
});
+15 -9
View File
@@ -102,15 +102,6 @@ $ui-margin-horizontal-large: $margin-v * 2;
cursor: pointer;
}
.ui-btn-back-to-top {
bottom: 2em;
position: fixed;
opacity: 0.5;
}
.ui-btn-back-to-top:hover {
opacity: 1;
}
// thumbnails
.ui-thumbnails {
.ui-thumbnails-item {
@@ -442,3 +433,18 @@ $ui-margin-horizontal-large: $margin-v * 2;
.h-text {
font-size: $font-size-base;
}
// makes a button element look like a link
.ui-link {
border: none !important;
background: none !important;
padding: 0 !important;
color: $brand-primary;
display: inline;
line-height: unset;
vertical-align: unset;
&:hover {
text-decoration: underline;
}
}
+49 -49
View File
@@ -1,54 +1,54 @@
// eslint-disable-next-line no-undef
define(["i18n!nls/locale"], function (localeStrings) {
/**
* @param {String} strToLocalize
* @returns
*/
var localize = function (strToLocalize) {
return localeStrings[strToLocalize] || strToLocalize;
};
import localeStrings from "i18n!nls/locale";
localize.cacheNonLocalized = false;
/**
* @param {String} strToLocalize
* @returns
*/
localize._setUserLocale = function _setUserLocale(user, config) {
const global_locale =
config.default_locale && config.default_locale != "auto" ? config.default_locale.toLowerCase() : false;
let localeStringsSubset = [];
let extra_user_preferences = {};
if (user && user.attributes.preferences && "extra_user_preferences" in user.attributes.preferences) {
extra_user_preferences = JSON.parse(user.attributes.preferences.extra_user_preferences);
export function localize(strToLocalize) {
return localeStringsSubset[strToLocalize] || strToLocalize;
}
export function _setUserLocale(user, config) {
const global_locale =
config.default_locale && config.default_locale != "auto" ? config.default_locale.toLowerCase() : false;
let extra_user_preferences = {};
if (user && user.attributes.preferences && "extra_user_preferences" in user.attributes.preferences) {
extra_user_preferences = JSON.parse(user.attributes.preferences.extra_user_preferences);
}
let user_locale =
"localization|locale" in extra_user_preferences
? extra_user_preferences["localization|locale"].toLowerCase()
: false;
if (user_locale == "auto") {
user_locale = false;
}
const nav_locale =
typeof navigator === "undefined"
? "__root"
: (navigator.language || navigator.userLanguage || "__root").toLowerCase();
const locale = user_locale || nav_locale || global_locale;
sessionStorage.setItem("currentLocale", locale);
}
export function _getUserLocale(user, config) {
let locale = null;
if (Object.prototype.hasOwnProperty.call(localeStrings, "__root")) {
locale = sessionStorage.getItem("currentLocale");
if (locale) {
localeStringsSubset =
localeStrings["__" + locale] || localeStrings["__" + locale.split("-")[0]] || localeStrings.__root;
}
}
return locale;
}
let user_locale =
"localization|locale" in extra_user_preferences
? extra_user_preferences["localization|locale"].toLowerCase()
: false;
if (user_locale == "auto") {
user_locale = false;
}
const nav_locale =
typeof navigator === "undefined"
? "__root"
: (navigator.language || navigator.userLanguage || "__root").toLowerCase();
const locale = user_locale || nav_locale || global_locale;
sessionStorage.setItem("currentLocale", locale);
};
localize._getUserLocale = function _getUserLocale(user, config) {
let locale = null;
if (Object.prototype.hasOwnProperty.call(localeStrings, "__root")) {
locale = sessionStorage.getItem("currentLocale");
if (locale) {
localeStrings =
localeStrings["__" + locale] || localeStrings["__" + locale.split("-")[0]] || localeStrings.__root;
}
}
return locale;
};
return localize;
});
export default localize;
+24 -1
View File
@@ -420,6 +420,28 @@ history_export:
export_button: '.export-button'
success_message: '.history-export-component .alert-success'
history_export_tasks:
selectors:
direct_download: '.direct-download-btn'
file_source_tab: '.tab-export-to-file'
remote_file_name_input: '#file-source-tab #name'
toggle_options_link: '#toggle-options-link'
export_format_selector: '#format-selector'
select_format:
type: xpath
selector: '//select[@id="format-selector"]/option[@value="${format}"]'
last_export_record:
selectors:
details: '.export-record-details'
preparing_export: '.export-record-details .loading-icon'
export_format: '.record-archive-format'
up_to_date_icon: '.record-up-to-date-icon'
outdated_icon: '.record-outdated-icon'
expiration_warning_icon: '.record-expiration-warning-icon'
download_btn: '.record-download-btn'
reimport_btn: '.record-reimport-btn'
history_import:
selectors:
radio_button_remote_files: '.history-import-component .fa-folder-open'
@@ -525,6 +547,8 @@ trs_import:
select_server:
type: xpath
selector: "//a[contains(@class, 'dropdown-item') and text() = '${server}']"
url_input: "#trs-import-url-input"
url_import_button: "#trs-url-import-button"
workflow_run:
selectors:
@@ -918,4 +942,3 @@ job_details:
type: xpath
selector: '//td[@id="galaxy-tool-id"][normalize-space(text()) = "${tool_id}"]'
tool_exit_code: '#exit-code'
+7
View File
@@ -1,3 +1,10 @@
/**
* Returns the path of the current or given URL.
*
* @param {string} [rawUrl=window.location.href]
* @returns {string}
*/
export function serverPath(rawUrl: string = window.location.href): string {
const url = new URL(rawUrl);
return url.pathname;
-1
View File
@@ -4,7 +4,6 @@ import { rethrowSimple } from "utils/simple-error";
export async function urlData({ url, headers, params }) {
try {
console.debug("Requesting data from: ", url);
headers = headers || {};
params = params || {};
const { data } = await axios.get(safePath(url), { headers, params });
+8 -165
View File
@@ -1,5 +1,4 @@
const path = require("path");
const { defaults: tsjPreset } = require("ts-jest/presets");
// For a detailed explanation regarding each configuration property, visit:
@@ -16,191 +15,35 @@ const modulesToTransform = [
].join("|");
module.exports = {
// All imported modules in your tests should be mocked automatically
// automock: false,
// Stop running tests after `n` failures
// bail: 0,
// The directory where Jest should store its cached dependency information
// cacheDirectory: "/private/var/folders/gm/53ksmsz51njf80yl3snw6g500000gn/T/jest_dx",
// Automatically clear mock calls and instances between every test
preset: "ts-jest",
clearMocks: true,
// Indicates whether the coverage information should be collected while executing the test
// collectCoverage: false,
// An array of glob patterns indicating a set of files for which coverage information should be collected
// collectCoverageFrom: undefined,
// The directory where Jest should output its coverage files
coverageDirectory: "coverage",
// An array of regexp pattern strings used to skip coverage collection
// coveragePathIgnorePatterns: [
// "/node_modules/"
// ],
// A list of reporter names that Jest uses when writing coverage reports
// coverageReporters: [
// "json",
// "text",
// "lcov",
// "clover"
// ],
// An object that configures minimum threshold enforcement for coverage results
// coverageThreshold: undefined,
// A path to a custom dependency extractor
// dependencyExtractor: undefined,
// Make calling deprecated APIs throw helpful error messages
// errorOnDeprecated: false,
// Force coverage collection from ignored files using an array of glob patterns
// forceCoverageMatch: [],
// A path to a module which exports an async function that is triggered once before all test suites
// globalSetup: undefined,
// A path to a module which exports an async function that is triggered once after all test suites
// globalTeardown: undefined,
// A set of global variables that need to be available in all test environments
globals: { __webpack_public_path__: "" },
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
// maxWorkers: "50%",
// An array of directory names to be searched recursively up from the requiring module's location
// moduleDirectories: [
// "node_modules"
// ],
// An array of file extensions your modules use
moduleFileExtensions: ["js", "json", "vue", "yml", "txt", "ts"],
modulePaths: ["<rootDir>/src/", "<rootDir>/tests/","<rootDir>/node_modules/", "./"],
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
// Some of these should turn into true mocks, instead of this module name mapping hack.
moduleFileExtensions: ["js", "ts", "json", "vue", "yml", "txt"],
modulePaths: ["<rootDir>/src/", "<rootDir>/tests/", "<rootDir>/node_modules/", "./"],
moduleNameMapper: {
"\\.(css|scss)$": "<rootDir>/tests/jest/__mocks__/style.js",
"^@fontsource/.*": "<rootDir>/tests/jest/__mocks__/font.js",
"^config$": "<rootDir>/tests/jest/__mocks__/config.js",
handsontable: "node_modules/handsontable/dist/handsontable.js",
"utils/localization$": "<rootDir>/tests/jest/__mocks__/localization.js",
"viz/trackster$": "<rootDir>/tests/jest/__mocks__/trackster.js",
"rxjs/internal/scheduler/AsyncScheduler":
"<rootDir>/node_modules/rxjs/dist/esm/internal/scheduler/AsyncScheduler.js",
"^@/(.*)$": "<rootDir>/src/$1",
"^@tests/(.*)$": "<rootDir>/tests/$1",
},
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
modulePathIgnorePatterns: ["<rootDir>/src/.*/__mocks__"],
// Activates notifications for test results
// notify: false,
// An enum that specifies notification mode. Requires { notify: true }
// notifyMode: "failure-change",
// A preset that is used as a base for Jest's configuration
preset: "ts-jest",
// Run tests from one or more projects
// projects: undefined,
// Use this configuration option to add custom reporters to Jest
// reporters: undefined,
// Automatically reset mock state between every test
// resetMocks: false,
// Reset the module registry before running each individual test
// resetModules: false,
// A path to a custom resolver
// resolver: undefined,
// Automatically restore mock state between every test
// restoreMocks: false,
// The root directory that Jest should scan for tests and modules within
// This should be the galaxy `client` directory.
rootDir: path.join(__dirname, "../../"),
// A list of paths to directories that Jest should use to search for files in
roots: ["<rootDir>", "<rootDir>/src/", "<rootDir>/tests/jest/standalone/", "<rootDir>/docs/"],
// Allows you to use a custom runner instead of Jest's default test runner
// runner: "jest-runner",
// The paths to modules that run some code to configure or set up the testing environment before each test
// setupFiles: [],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
roots: ["<rootDir>/src/", "<rootDir>/tests/jest/standalone/"],
setupFilesAfterEnv: ["<rootDir>/tests/jest/jest.setup.js"],
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
// snapshotSerializers: [],
// The test environment that will be used for testing
testEnvironment: "jsdom",
// Options that will be passed to the testEnvironment
// testEnvironmentOptions: {},
// Adds a location field to test results
// testLocationInResults: false,
// The glob patterns Jest uses to detect test files
// testMatch: [
// "**/__tests__/**/*.[jt]s?(x)",
// "**/?(*.)+(spec|test).[tj]s?(x)"
// ],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
// testPathIgnorePatterns: [
// "/node_modules/"
// ],
// The regexp pattern or array of patterns that Jest uses to detect test files
// testRegex: [],
// This option allows the use of a custom results processor
// testResultsProcessor: undefined,
// This option allows use of a custom test runner
// testRunner: "jasmine2",
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
// testURL: "http://localhost",
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
// timers: "real",
// A map from regular expressions to paths to transformers
transform: {
...tsjPreset.transform,
"^.+\\.js$": "babel-jest",
"^.*\\.(vue)$": "@vue/vue2-jest",
"^.+\\.ya?ml$": "<rootDir>/tests/jest/yaml-jest.js",
"^.+\\.txt$": "<rootDir>/tests/jest/jest-raw-loader.js",
...tsjPreset.transform,
},
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
transformIgnorePatterns: [`/node_modules/(?!${modulesToTransform})`],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,
// Indicates whether each individual test should be reported during the run
// verbose: undefined,
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
// watchPathIgnorePatterns: [],
// Whether to use watchman for file crawling
// watchman: true,
};
+5
View File
@@ -1,4 +1,9 @@
import "@testing-library/jest-dom";
import Vue from 'vue';
// Set Vue to suppress production / devtools / etc. warnings
Vue.config.productionTip = false;
Vue.config.devtools = false;
/* still don't understand what was invoking the following, but nothing should,
and this makes the tag tests work correctly */
@@ -1,4 +1,4 @@
import RuleDefs from "components/RuleBuilder/rule-definitions";
import RuleDefs from "@/components/RuleBuilder/rule-definitions";
import SPEC_TEST_CASES from "./rules_dsl_spec.yml";
function applyRules(rules: Array<any>, data: Array<Array<string>>, sources: Array<number>) {
+8 -4
View File
@@ -3,16 +3,20 @@
"compilerOptions": {
"target": "es2016",
"module": "es6",
"baseUrl": "./src",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@tests/*": ["tests/*"]
},
"outDir": "./dist/build",
"allowJs": true,
"checkJs": false,
"outDir": "./dist/build",
"allowSyntheticDefaultImports": true
},
"vueCompilerOptions": {
"target": 2.7
},
"include": ["./types/*.d.ts", "./src/**/*.ts", "./src/**/*.tsx", "./src/**/*.vue", "./tests/**/*.ts"],
"include": ["./types/*.d.ts", "./src/**/*.ts", "./src/**/*.tsx", "./src/**/*.vue", "./tests/**/*.ts"]
}
+2
View File
@@ -6,6 +6,7 @@ const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
const DuplicatePackageCheckerPlugin = require("@cerner/duplicate-package-checker-webpack-plugin");
const { DumpMetaPlugin } = require("dumpmeta-webpack-plugin");
const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin");
const scriptsBase = path.join(__dirname, "src");
const testsBase = path.join(__dirname, "tests");
@@ -43,6 +44,7 @@ module.exports = (env = {}, argv = {}) => {
filename: "[name].bundled.js",
},
resolve: {
plugins: [new TsconfigPathsPlugin({ extensions: [".ts", ".js", ".json", ".vue", ".scss"] })],
extensions: [".ts", ".js", ".json", ".vue", ".scss"],
modules: [scriptsBase, "node_modules", styleBase, testsBase],
fallback: {
+47 -14
View File
@@ -1405,7 +1405,7 @@
resolved "https://registry.yarnpkg.com/@fortawesome/vue-fontawesome/-/vue-fontawesome-2.0.9.tgz#7df35818135be54fd87568f16ec7b998a3d0cce8"
integrity sha512-tUmO92PFHbLOplitjHNBVGMJm6S57vp16tBXJVPKSI/6CfjrgLycqKxEpC6f7qsOqUdoXs5nIv4HLUfrOMHzuw==
"@handsontable/vue@^2.0.0-beta1":
"@handsontable/vue@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@handsontable/vue/-/vue-2.0.0.tgz#28ae7d247a89738088abc32584562925dde18db0"
integrity sha512-QVqJywrQWwJzoDCXibZkXrg+T91hNCTCN9qznmum4FSewL0MUtaHotv7Zc/D4scvEwpwWnKx5vpw4CY14V4OYw==
@@ -3067,6 +3067,11 @@ big.js@^5.2.2:
resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328"
integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==
bignumber.js@^8.1.1:
version "8.1.1"
resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-8.1.1.tgz#4b072ae5aea9c20f6730e4e5d529df1271c4d885"
integrity sha512-QD46ppGintwPGuL1KqmwhR0O+N2cZUg8JG/VzwI2e28sM9TqHjQB10lI4QAaMHVbLzwVLLAwEglpKPViWX+5NQ==
binary-extensions@^2.0.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
@@ -4427,6 +4432,14 @@ enhanced-resolve@^5.10.0:
graceful-fs "^4.2.4"
tapable "^2.2.0"
enhanced-resolve@^5.7.0:
version "5.12.0"
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.12.0.tgz#300e1c90228f5b570c4d35babf263f6da7155634"
integrity sha512-QHTXI/sZQmko1cbDoNAa3mJ5qhWUUNAq3vR0/YiD379fWQrcfuoX1+HW2S0MTt7XmoPLapdaDKUtelUSPic7hQ==
dependencies:
graceful-fs "^4.2.4"
tapable "^2.2.0"
ent@~2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/ent/-/ent-2.2.0.tgz#e964219325a21d05f44466a2f686ed6ce5f5dd1d"
@@ -5523,13 +5536,13 @@ handle-thing@^2.0.0:
resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.1.tgz#857f79ce359580c340d43081cc648970d0bb234e"
integrity sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==
handsontable@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/handsontable/-/handsontable-2.0.0.tgz#b22fbd61d3193eb63b9c798e73843f55856ef022"
integrity sha512-o8yCOgyV0LkBrEyMrGtfex4UDJ+Pw3+Eeoxk9rRg2LmlziJsgwGlz5e4W1YP3/Ew7bPW/iyGlL4q1bp+6pcD/g==
handsontable@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/handsontable/-/handsontable-4.0.0.tgz#1a9591a3f6c2fe13564955d945ea91d20b6c4cf1"
integrity sha512-+i9jkz5D3+Da7bIL4+YjF4hnY/nWjR8ZfcCo9BHuHaYY/ri2CYH75OobVxpMyPTspQlXjyQoWE6v0qhHuesoUw==
dependencies:
moment "2.20.1"
numbro "1.11.0"
numbro "^2.0.6"
pikaday "1.5.1"
has-bigints@^1.0.1, has-bigints@^1.0.2:
@@ -7742,10 +7755,12 @@ number-is-nan@^1.0.0:
resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d"
integrity sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==
numbro@1.11.0:
version "1.11.0"
resolved "https://registry.yarnpkg.com/numbro/-/numbro-1.11.0.tgz#39aa17b358b4682aec8ca0d5755f35c5d9ce8f9e"
integrity sha512-HLgprbMBJf/h3zQ91KpvjzTWyxd9pS2xOFoEBTdkUusBi9eSqYq+RlhFn8i+WSHy/ghGRtbN5brnABOR21z4kA==
numbro@^2.0.6:
version "2.3.6"
resolved "https://registry.yarnpkg.com/numbro/-/numbro-2.3.6.tgz#4bd622ebe59ccbc49dad365c5b9eed200781fa21"
integrity sha512-pxpoTT3hVxQGaOA2RTzXR/muonQNd1K1HPJbWo7QOmxPwiPmoFCFfsG9XXgW3uqjyzezJ0P9IvCPDXUtJexjwg==
dependencies:
bignumber.js "^8.1.1"
nwsapi@^2.2.2:
version "2.2.2"
@@ -10061,6 +10076,24 @@ ts-loader@^9.4.1:
micromatch "^4.0.0"
semver "^7.3.4"
tsconfig-paths-webpack-plugin@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/tsconfig-paths-webpack-plugin/-/tsconfig-paths-webpack-plugin-4.0.0.tgz#84008fc3e3e0658fdb0262758b07b4da6265ff1a"
integrity sha512-fw/7265mIWukrSHd0i+wSwx64kYUSAKPfxRDksjKIYTxSAp9W9/xcZVBF4Kl0eqQd5eBpAQ/oQrc5RyM/0c1GQ==
dependencies:
chalk "^4.1.0"
enhanced-resolve "^5.7.0"
tsconfig-paths "^4.0.0"
tsconfig-paths@^4.0.0:
version "4.1.1"
resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-4.1.1.tgz#7f23094ce897fcf4a93f67c4776e813003e48b75"
integrity sha512-VgPrtLKpRgEAJsMj5Q/I/mXouC6A/7eJ/X4Nuk6o0cRPwBtznYxTCU4FodbexbzH9somBPEXYi0ZkUViUpJ21Q==
dependencies:
json5 "^2.2.1"
minimist "^1.2.6"
strip-bom "^3.0.0"
tsconfig@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/tsconfig/-/tsconfig-7.0.0.tgz#84538875a4dc216e5c4a5432b3a4dec3d54e91b7"
@@ -10544,10 +10577,10 @@ vue-template-es2015-compiler@^1.9.0:
resolved "https://registry.yarnpkg.com/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz#1ee3bc9a16ecbf5118be334bb15f9c46f82f5825"
integrity sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==
vue-virtual-scroll-list@^2.3.3:
version "2.3.3"
resolved "https://registry.yarnpkg.com/vue-virtual-scroll-list/-/vue-virtual-scroll-list-2.3.3.tgz#290f0d04be814b93585f2ef232097b671141e5b1"
integrity sha512-heuwlZ+lEdcVSp66CpVXnyNdsiHl/XU1cvqJb0yUHGlLv6DGK8OX16FH8I3w/wKNXtHSPF9itdBBGJFrXkyHeg==
vue-virtual-scroll-list@^2.3.4:
version "2.3.4"
resolved "https://registry.yarnpkg.com/vue-virtual-scroll-list/-/vue-virtual-scroll-list-2.3.4.tgz#0d08c2d26299d8609018b8c04f5e3ca608fbe2d6"
integrity sha512-a9bmmIhAdnKcp8NG0C9ZQ+pW9nAOrDv5SHqJfbbEpXxVVERCtbIgGeBnkwrGgmoILBoFz/WANIZgA22G44F0Yw==
vue@2.7.8, vue@^2.6.10, vue@^2.7.8:
version "2.7.8"
@@ -437,11 +437,12 @@ window.bundleEntries.load = function (opt) {
});
};
const safe_download_url = `${options.root}${dataset.download_url}`;
$.ajax({
url: dataset.download_url,
url: safe_download_url,
success: function(content) {
const $chartViewer = $("#" + opt.target);
$chartViewer.html("<img id='image-annotate' src='" + dataset.download_url + "' />");
$chartViewer.html("<img id='image-annotate' src='" + safe_download_url + "' />");
$chartViewer.css("overflow", "auto");
$chartViewer.css("position", "relative");
const $image = $chartViewer.find("img");
@@ -87,7 +87,7 @@ function run_search_algorithm( cytoscape, root_id, type, self ) {
// Add css class for the selected edge(s)
algorithm.path[i].addClass( 'searchpath' );
i++;
// Animate the edges and nodes coloring
// Animate the edges and nodes coloring
// of the path with a delay of 500ms
setTimeout( selectNextElement, 500 );
}
@@ -166,8 +166,9 @@ window.bundleEntries.load = function (options) {
cytoscape = null,
sif_file_ext = "sif",
highlighted_color = settings.get( 'color_picker_highlighted' );
const safe_download_url = `${options.root}${dataset.download_url}`;
$.ajax({
url : dataset.download_url,
url : safe_download_url,
success : function( content ) {
// Select data for the graph
if( dataset.file_ext === sif_file_ext ) {
@@ -246,7 +247,7 @@ window.bundleEntries.load = function (options) {
// On tapping any node, BFS or DFS start from that node
cytoscape.$( 'node' ).on('tap', function( e ) {
var ele = e.cyTarget,
search_algorithm = settings.get( 'search_algorithm' ),
search_algorithm = settings.get( 'search_algorithm' ),
traversal_type = settings.get( 'graph_traversal' );
// If search algorithm and traversal both are chosen,
// search algorithm will take preference
@@ -3,8 +3,9 @@ window.bundleEntries = window.bundleEntries || {};
window.bundleEntries.load = function (options) {
var chart = options.chart;
var dataset = options.dataset;
const safe_download_url = `${options.root}${dataset.download_url}`;
$.ajax({
url: dataset.download_url,
url: safe_download_url,
success: function(response) {
var input = response.split('\n');
var app = new DrawRNA({
@@ -14,7 +14,8 @@ Object.assign(window.bundleEntries || {}, {
menu: "small",
bootstrapMenu: "true" == settings.get("menu"),
});
msaViz.u.file.importURL(dataset.download_url, () => {
const safe_download_url = `${options.root}${dataset.download_url}`;
msaViz.u.file.importURL(safe_download_url, () => {
msaViz.render();
chart.state("ok", "Chart drawn.");
options.process.resolve();
+2 -1
View File
@@ -19,8 +19,9 @@ window.bundleEntries.load = function (options) {
opacity: settings.get("opacity"),
};
stage_parameters = { ext: dataset.extension, defaultRepresentation: true };
const safe_download_url = `${options.root}${dataset.download_url}`;
try {
stage.loadFile(dataset.download_url, stage_parameters).then(function (component) {
stage.loadFile(safe_download_url, stage_parameters).then(function (component) {
component.addRepresentation(settings.get("mode"), representation_parameters);
options.chart.state("ok", "Chart drawn.");
options.process.resolve();
@@ -240,10 +240,11 @@ window.bundleEntries = window.bundleEntries || {};
window.bundleEntries.load = function (options) {
const chart = options.chart;
const dataset = options.dataset;
const safe_download_url = `${options.root}${dataset.download_url}`;
$.ajax({
url: dataset.download_url,
url: safe_download_url,
success: () => {
MapViewer.loadFile(dataset.download_url, dataset.extension, options, chart);
MapViewer.loadFile(safe_download_url, dataset.extension, options, chart);
},
error: () => {
chart.state("failed", "Failed to access dataset.");
@@ -4,8 +4,9 @@ _.extend(window.bundleEntries || {}, {
var chart = options.chart;
var dataset = options.dataset;
var settings = options.chart.settings;
const safe_download_url = `${options.root}${dataset.download_url}`;
$.ajax( {
url : dataset.download_url,
url : safe_download_url,
success : function( content ) {
try {
var tree = Phylocanvas.default.createTree( options.target ),
+2 -1
View File
@@ -10,8 +10,9 @@ window.bundleEntries.load = function (options) {
antialias: true,
outline: true,
});
const safe_download_url = `${options.root}${dataset.download_url}`;
var xhr = new XMLHttpRequest();
xhr.open("GET", options.dataset.download_url);
xhr.open("GET", safe_download_url);
xhr.onload = function () {
if (xhr.status === 200) {
var structure = pv.io.pdb(xhr.response);
+67
View File
@@ -3832,6 +3832,73 @@
:Type: str
~~~~~~~~~~~~~~~~~~~~
``ga4gh_service_id``
~~~~~~~~~~~~~~~~~~~~
:Description:
Service ID for GA4GH services (exposed via the service-info
endpoint for the Galaxy DRS API). If unset, one will be generated
using the URL the target API requests are made against.
For more information on GA4GH service definitions - check out
https://github.com/ga4gh-discovery/ga4gh-service-registry and
https://editor.swagger.io/?url=https://raw.githubusercontent.com/ga4gh-discovery/ga4gh-service-registry/develop/service-registry.yaml
This value should likely reflect your service's URL. For instance
for usegalaxy.org this value should be org.usegalaxy. Particular
Galaxy implementations will treat this value as a prefix and
append the service type to this ID. For instance for the DRS
service "id" (available via the DRS API) for the above
configuration value would be org.usegalaxy.drs.
:Default: ``None``
:Type: str
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
``ga4gh_service_organization_name``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
:Description:
Service name for host organization (exposed via the service-info
endpoint for the Galaxy DRS API). If unset, one will be generated
using ga4gh_service_id.
For more information on GA4GH service definitions - check out
https://github.com/ga4gh-discovery/ga4gh-service-registry and
https://editor.swagger.io/?url=https://raw.githubusercontent.com/ga4gh-discovery/ga4gh-service-registry/develop/service-registry.yaml
:Default: ``None``
:Type: str
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
``ga4gh_service_organization_url``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
:Description:
Organization URL for host organization (exposed via the
service-info endpoint for the Galaxy DRS API). If unset, one will
be generated using the URL the target API requests are made
against.
For more information on GA4GH service definitions - check out
https://github.com/ga4gh-discovery/ga4gh-service-registry and
https://editor.swagger.io/?url=https://raw.githubusercontent.com/ga4gh-discovery/ga4gh-service-registry/develop/service-registry.yaml
:Default: ``None``
:Type: str
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
``ga4gh_service_environment``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
:Description:
Service environment (exposed via the service-info endpoint for the
Galaxy DRS API) for implemented GA4GH services.
Suggested values are prod, test, dev, staging.
For more information on GA4GH service definitions - check out
https://github.com/ga4gh-discovery/ga4gh-service-registry and
https://editor.swagger.io/?url=https://raw.githubusercontent.com/ga4gh-discovery/ga4gh-service-registry/develop/service-registry.yaml
:Default: ``None``
:Type: str
~~~~~~~~~~~~~~~~~~~~
``enable_tool_tags``
~~~~~~~~~~~~~~~~~~~~

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