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

This commit is contained in:
Laila Los
2022-11-28 12:25:46 +01:00
406 changed files with 7172 additions and 5017 deletions
-2
View File
@@ -1,2 +0,0 @@
exclude=$(sed -e 's|^|./|' -e 's|/$||' .ci/flake8_ignorelist.txt | paste -s -d ',' - )
autopep8 -i -r --exclude $exclude --select E11,E101,E127,E201,E202,E22,E301,E302,E303,E304,E306,E711,W291,W292,W293,W391 ./lib/ ./test/
-35
View File
@@ -1,35 +0,0 @@
#!/bin/sh
if command -v ack-grep >/dev/null; then
ACK=ack-grep
else
ACK=ack
fi
PYTHON2_ONLY_MODULES="__builtin__ _winreg BaseHTTPServer CGIHTTPServer \
ConfigParser Cookie cookielib copy_reg cPickle cStringIO Dialog dummy_thread \
FileDialog gdbm htmlentitydefs HTMLParser httplib Queue robotparser \
ScrolledText SimpleDialog SimpleHTTPServer SimpleXMLRPCServer SocketServer \
StringIO thread Tix tkColorChooser tkCommonDialog Tkconstants Tkdnd tkFont \
Tkinter tkFileDialog tkMessageBox tkSimpleDialog ttk urllib urllib2 urlparse \
xmlrpclib"
ret=0
for mod in $PYTHON2_ONLY_MODULES; do
$ACK --type python \
--ignore-dir=.git \
--ignore-dir=.tox \
--ignore-dir=.venv \
--ignore-dir=.venv3 \
--ignore-dir=client/node_modules \
--ignore-dir=database \
--ignore-dir=doc/build \
--ignore-dir=eggs \
--ignore-dir=static/maps \
--ignore-dir=static/scripts \
"^import $mod(\n|\.)|^from $mod import "
if [ $? -eq 0 ]; then ret=1; fi
done
exit $ret
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
n_tests=$(pytest --collect-only --ignore=test/functional lib/galaxy_test/ test/ | grep 'tests collected' | sed -e 's/[^0-9]*\([0-9]*\) tests collected.*/\1/')
n_tests_extra_classes=$(pytest -o python_classes='Test* *Test *TestCase' --collect-only --ignore=test/functional lib/galaxy_test/ test/ | grep 'tests collected' | sed -e 's/[^0-9]*\([0-9]*\) tests collected.*/\1/')
if [ "$n_tests_extra_classes" -gt "$n_tests" ]; then
echo "New test class with name not starting with Test introduced, change it to have tests collected by pytest"
exit 1
fi
@@ -0,0 +1,28 @@
name: Check test class names
on:
pull_request:
paths:
- '.ci/check_test_class_names.sh'
- 'lib/galaxy_test/**'
- 'test/**'
jobs:
test:
name: Test
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.7']
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Cache pip dir
uses: actions/cache@v3
with:
path: ~/.cache/pip
key: pip-cache-${{ matrix.python-version }}-${{ hashFiles('requirements.txt') }}
- name: Install Python dependencies
run: pip install -r requirements.txt -r lib/galaxy/dependencies/dev-requirements.txt
- name: Run tests
run: .ci/check_test_class_names.sh
+2 -15
View File
@@ -59,24 +59,11 @@ jobs:
- name: Lint converters
run: |
mapfile -t TOOL_ARRAY < tool_list.txt
for CONVERTER in "${TOOL_ARRAY[@]}"; do
planemo lint --skip citations,stdio,help --report_level warn "$CONVERTER"
done
planemo lint --skip citations,stdio,help --report_level warn "${TOOL_ARRAY[@]}"
- name: Run tests
run: |
mkdir -p json_output
EXIT_CODE=0
mapfile -t TOOL_ARRAY < tool_list.txt
for CONVERTER in "${TOOL_ARRAY[@]}"; do
json=$(mktemp -u -p json_output --suff .json)
planemo test --test_output_json "$json" --galaxy_python_version ${{ matrix.python-version }} --galaxy_root 'galaxy root' "$CONVERTER" || EXIT_CODE=$?
done
planemo merge_test_reports json_output/*.json tool_test_output.json
planemo test_reports tool_test_output.json --test_output tool_test_output.html
if [ "$EXIT_CODE" -ne 0 ]; then
echo "Unsuccessful tests found, inspect the 'Converter test results' artifact for details."
exit $EXIT_CODE
fi
planemo test --galaxy_python_version ${{ matrix.python-version }} --galaxy_root 'galaxy root' "${TOOL_ARRAY[@]}"
- uses: actions/upload-artifact@v3
if: failure()
with:
+4 -2
View File
@@ -18,6 +18,7 @@ jobs:
name: Test
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ['3.7']
steps:
@@ -33,7 +34,6 @@ jobs:
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
id: pip-cache
with:
path: ~/.cache/pip
key: pip-cache-${{ matrix.python-version }}-${{ hashFiles('galaxy root/requirements.txt') }}
@@ -42,9 +42,11 @@ jobs:
with:
path: .tox
key: tox-cache-${{ runner.os }}-${{ steps.full-python-version.outputs.version }}-${{ hashFiles('galaxy root/requirements.txt') }}-mulled
- name: Install Apptainer's singularity
uses: eWaterCycle/setup-apptainer@v2
- name: Install tox
run: pip install tox
- name: run tests
- name: Run tests
run: tox -e mulled
working-directory: 'galaxy root'
- uses: actions/upload-artifact@v3
+6 -4
View File
@@ -37,15 +37,17 @@ jobs:
with:
path: ~/.cache/pip
key: pip-cache-${{ matrix.python-version }}-${{ hashFiles('galaxy root/requirements.txt') }}
- name: Cache galaxy venv
- name: Cache tox env
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') }}-unit
path: .tox
key: tox-cache-${{ runner.os }}-${{ steps.full-python-version.outputs.version }}-${{ hashFiles('galaxy root/requirements.txt') }}-unit
- name: Install ffmpeg
run: sudo apt-get update && sudo apt-get -y install ffmpeg
- name: Install tox
run: pip install tox
- name: Run tests
run: ./run_tests.sh --coverage -u
run: tox -e unit-coverage
working-directory: 'galaxy root'
- uses: codecov/codecov-action@v3
with:
+7 -7
View File
@@ -1,11 +1,10 @@
repos:
- repo: https://github.com/psf/black
rev: 22.1.0
rev: 22.10.0
hooks:
- id: black
language_version: python3.7
- repo: https://gitlab.com/pycqa/flake8
rev: 4.0.1
- repo: https://github.com/pycqa/flake8
rev: 5.0.4
hooks:
- id: flake8
- repo: https://github.com/pre-commit/mirrors-prettier
@@ -15,20 +14,21 @@ repos:
types: [file]
types_or: [javascript, jsx, ts, tsx, vue]
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.1.0 # Use the ref you want to point at
rev: v4.3.0 # Use the ref you want to point at
hooks:
- id: trailing-whitespace
- id: check-merge-conflict
- id: check-symlinks
- id: destroyed-symlinks
- id: end-of-file-fixer
- id: name-tests-test
- repo: https://github.com/detailyang/pre-commit-shell
rev: v1.0.6
rev: 1.0.5
hooks:
- id: shell-lint
args: [--format=json]
- repo: https://github.com/python-jsonschema/check-jsonschema
rev: 0.14.0
rev: 0.19.2
hooks:
- id: check-github-workflows
- repo: local
+1 -4
View File
@@ -2,7 +2,7 @@
VENV?=.venv
# Source virtualenv to execute command (flake8, sphinx, twine, etc...)
IN_VENV=if [ -f "$(VENV)/bin/activate" ]; then . "$(VENV)/bin/activate"; fi;
RELEASE_CURR:=22.09
RELEASE_CURR:=23.0
RELEASE_UPSTREAM:=upstream
CONFIG_MANAGE=$(IN_VENV) python lib/galaxy/config/config_manage.py
PROJECT_URL?=https://github.com/galaxyproject/galaxy
@@ -47,9 +47,6 @@ format: ## Format Python code base
remove-unused-imports: ## Remove unused imports in Python code base
$(IN_VENV) autoflake --in-place --remove-all-unused-imports --recursive --verbose lib/ test/
list-dependency-updates: setup-venv
$(IN_VENV) pip list --outdated --format=columns
docs-slides-ready:
test -f plantuml.jar || wget http://jaist.dl.sourceforge.net/project/plantuml/plantuml.jar
java -jar plantuml.jar -c $(DOC_SOURCE_DIR)/slideshow/architecture/images/plantuml_options.txt -tsvg $(SLIDESHOW_DIR)/architecture/images/ *.plantuml.txt
+32 -5
View File
@@ -24,16 +24,20 @@
"vue/valid-v-slot": "error",
"vue/v-slot-style": ["error", { "atComponent": "v-slot", "default": "v-slot", "named": "longform" }],
// vue/multi-word-component names is considered an error, that we
// downgrade to a warning here. It should be prioritized to get fixed
// and compliant.
// TODO: We want to fix or add inline exceptions documenting these.
// Downgrade the severity of some rules to warnings as a transition measure.
// For example, vue/multi-word-component names is considered an error,
// but that kind of refactoring is best done slowly, one bit at a time
// as those components are touched.
"vue/multi-word-component-names": "warn",
"vue/prop-name-casing": "warn",
"vue/require-prop-types": "warn",
"vue/require-default-prop": "warn",
"vue/no-v-html": "warn",
// Increase the severity of some rules to errors
"vue/attributes-order": "error",
"vue/order-in-components": "error",
// Prettier compromises/workarounds -- mostly #wontfix?
"vue/html-indent": "off",
"vue/max-attributes-per-line": "off",
@@ -54,6 +58,29 @@
"vuejs-accessibility/mouse-events-have-key-events": "warn",
"vuejs-accessibility/no-autofocus": "error",
"vuejs-accessibility/tabindex-no-positive": "error"
},
"ignorePatterns": ["src/qunit", "src/mocha", "src/libs", "src/nls", "src/legacy"]
"ignorePatterns": ["src/qunit", "src/mocha", "src/libs", "src/nls", "src/legacy"],
"overrides": [{
"files": ["**/*.ts", "**/*.tsx"],
"extends": [
"eslint:recommended",
"plugin:vue/recommended",
"plugin:compat/recommended",
"plugin:vuejs-accessibility/recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": { "jsx": true },
"ecmaVersion": 2018,
"sourceType": "module",
"project": "./tsconfig.json"
},
"rules": {
"@typescript-eslint/no-explicit-any": 0
},
"plugins": ["@typescript-eslint"]
}]
}
+1 -1
View File
@@ -1 +1 @@
16.15.1
18.12.1
+1 -1
View File
@@ -1,5 +1,5 @@
{
"presets": ["@babel/preset-env"],
"presets": ["@babel/preset-env", "@babel/preset-typescript"],
"env": {
"test": {
"plugins": ["@babel/plugin-transform-runtime"]
+107
View File
@@ -0,0 +1,107 @@
# Composables
Composables are way of splitting up your code into distinct, reusable chunks.
They can replace providers, mixins and more. Any code you can put into a component, can also be written as a composable.
Using them effectively can make your code more reusable, decoupled, and easier to follow.
**More about Composables:**
* [Composables Overview](https://vuejs.org/guide/reusability/composables.html)
* [Composition API](https://vuejs.org/api/composition-api-setup.html)
* [\<script setup\>](https://vuejs.org/api/sfc-script-setup.html)
## Using Composables in the Composition API
Example: accessing the current user from the store
```vue
<script setup>
import { useCurrentUser } from "composables/user";
const { currentUser } = useCurrentUser();
</script>
```
You can now access the current user with `currentUser.value`.
## Using Composables in the Options API
Composables are not limited to the composition api. This is the same example from above, using the options api.
```vue
<script>
import { useCurrentUser } from "composables/user";
export default {
setup() {
const { currentUser } = useCurrentUser();
return { currentUser };
}
}
</script>
```
You can now access the current user with `this.currentUser` from anywhere within the component.
## Testing Components with Composable Stores
When writing a test which includes a component that has a composable store (like useCurrentUser),
there are two ways to test it.
### Mocking the store
You can provide the store in the mount function as follows:
```js
const wrapper = shallowMount(TestedComponent,
localVue,
provide: { store },
});
```
`store` must be a Vuex store.
The `mockModule` helper can help creating a store for the required modules:
```js
const store = new Vuex.Store({
modules: {
user: mockModule(userStore),
},
});
```
### Mocking the composable
The second option is to mock the composable:
```js
import { useCurrentUser } from "composables/user";
jest.mock("composables/user");
useCurrentUser.mockReturnValue({
currentUser: {}
});
```
While simpler in this example, you may need to manually mock more return values and composables than the other method, depending on the composables the component is using.
## Using Composables for more than Stores
Composables can be of great use to extract any reactive code from your components. For an example of this, take a look at [userFilterObjectArray](https://github.com/galaxyproject/galaxy/blob/dev/client/src/composables/utils/filter.js).
Usage:
```vue
<script setup>
import { useFilterObjectArray } from "composables/utils/filter";
const filteredArray = useFilterObjectArray(
someReactiveArray,
searchValue,
["name", "description"]
);
</script>
```
It's a simple filtering function, but fully reactive.
Whenever any of the inputs changes, the return value is re-computed, without having to call the function again.
+10 -6
View File
@@ -1,3 +1,7 @@
**Notice** Consider using [Composables](composables.md) instead of Providers. They offer more functionality and need less boilerplate.
---
We are using components in two very distinct ways. The first, "normal", kind of component will
probably look familiar to anybody whis is already passingly familiar with Vue. Here the relevant
information comes in as properties, any internal variables get defined in "data", changes go out as
@@ -27,7 +31,7 @@ restriction that Vue needs a single root element in which to render.
<template>
<AutoComplete
:options="options"
:value="doodad.category"
:value="doodad.category"
@select="saveCategory"
/>
</template>
@@ -35,7 +39,7 @@ restriction that Vue needs a single root element in which to render.
<script>
export default {
props: {
props: {
doodad: { type: Object, required: true },
options: { type: Array, required: true },
},
@@ -51,7 +55,7 @@ export default {
This component accepts a mandatory input object (doodad), lets the user play with a category prop,
then emits a fresh object after it's done. So what, what's the big deal? The important part to walk
away from this dumb example is the things that are NOT in this sample component.
away from this dumb example is the things that are NOT in this sample component.
This component doesn't save the data. This component doesn't make ajax calls, and this component
doesn't mutate its props. What it does do is to allow the user to edit some object named "doodad"
@@ -149,8 +153,8 @@ describe("A renderless component", () => {
},
});
// waits for "updated" Vue lifecycle hook to fire on the renderless
// component. This is often good enough for waiting for
// waits for "updated" Vue lifecycle hook to fire on the renderless
// component. This is often good enough for waiting for
// an initial ajax load to finish, for example
await waitForLifecyleEvent(wrapper.vm, "updated");
})
@@ -161,4 +165,4 @@ describe("A renderless component", () => {
// ...more tests
})
})
```
```
+8 -4
View File
@@ -32,7 +32,7 @@ const STATIC_PLUGIN_BUILD_IDS = [
"pv",
"nora",
"venn",
"ts_visjs"
"ts_visjs",
];
const DIST_PLUGIN_BUILD_IDS = ["new_user"];
const PLUGIN_BUILD_IDS = Array.prototype.concat(DIST_PLUGIN_BUILD_IDS, STATIC_PLUGIN_BUILD_IDS);
@@ -128,7 +128,7 @@ function buildPlugins(callback, forceRebuild) {
// Stage current hash to .orig for debugging and to
// force a plugin rebuild in the event of a failure
// (i.e. -- we're committed to a new build of this plugin).
fs.renameSync(hashFilePath, `${hashFilePath}.orig`)
fs.renameSync(hashFilePath, `${hashFilePath}.orig`);
}
} else {
console.log(`No build hashfile detected for ${pluginName}, generating now.`);
@@ -150,8 +150,12 @@ function buildPlugins(callback, forceRebuild) {
);
console.log(`Building ${pluginName}`);
if (
child_process.spawnSync("yarn", ["build"], { cwd: pluginDir, stdio: "inherit", shell: true })
.status === 0
child_process.spawnSync("yarn", ["build"], {
cwd: pluginDir,
stdio: "inherit",
shell: true,
env: { ...process.env, NODE_OPTIONS: "--openssl-legacy-provider" },
}).status === 0
) {
console.log(`Successfully built, saving build state to ${hashFilePath}`);
child_process.exec(`(git rev-parse HEAD 2>/dev/null || echo \`\`) > ${hashFilePath}`);
+17 -9
View File
@@ -67,6 +67,7 @@
"markdown-it": "^12.3.2",
"markdown-it-regexp": "^0.4.0",
"object-hash": "^3.0.0",
"pinia": "^2.0.23",
"popper.js": "^1.16.1",
"pretty-bytes": "^6.0.0",
"pyre-to-regexp": "^0.0.5",
@@ -90,7 +91,7 @@
"vue-multiselect": "^2.1.6",
"vue-observe-visibility": "^1.0.0",
"vue-prismjs": "^1.2.0",
"vue-router": "^3.5.3",
"vue-router": "^3.6.5",
"vue-rx": "^6.2.0",
"vue-virtual-scroll-list": "^2.3.3",
"vuedraggable": "2.24.3",
@@ -113,15 +114,15 @@
"webpack-production-maps": "GXY_BUILD_SOURCEMAPS=1 NODE_ENV=production webpack",
"gulp": "gulp",
"save-build-hash": "(git rev-parse HEAD 2>/dev/null || echo '') >../static/client_build_hash.txt",
"format": "prettier --write 'src/style/scss/**/*.scss' 'src/**/{*.js,*.vue}' '!src/libs/**'",
"format-check": "prettier --check 'src/style/scss/**/*.scss' 'src/**/{*.js,*.vue}' '!src/libs/**'",
"format": "prettier --write 'src/style/scss/**/*.scss' 'src/**/{*.ts,*.js,*.vue}' 'tests/jest/standalone/{*.ts,*.js}' '!src/libs/**'",
"format-check": "prettier --check 'src/style/scss/**/*.scss' 'src/**/{*.js,*.vue}' 'tests/jest/standalone/{*.ts,*.js}' '!src/libs/**'",
"prettier": "yarn run format",
"test": "yarn run qunit && yarn run jest",
"jest": "jest --config tests/jest/jest.config.js",
"jest-watch": "jest --config tests/jest/jest.config.js --watch",
"qunit": "karma start tests/karma/karma.config.qunit.js",
"eslint-precommit": "eslint -c .eslintrc.json --quiet --ext .js,.vue src",
"eslint": "eslint -c .eslintrc.json --ext .js,.vue src"
"eslint-precommit": "eslint -c .eslintrc.json --quiet --ext .js,.vue,.ts src tests/jest/standalone",
"eslint": "eslint -c .eslintrc.json --ext .js,.vue,.ts src tests/jest/standalone"
},
"devDependencies": {
"@babel/core": "^7.18.9",
@@ -130,8 +131,11 @@
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-transform-runtime": "^7.18.9",
"@babel/preset-env": "^7.18.9",
"@babel/preset-typescript": "^7.18.6",
"@cerner/duplicate-package-checker-webpack-plugin": "^2.3.0",
"@testing-library/jest-dom": "^5.16.4",
"@typescript-eslint/eslint-plugin": "^5.43.0",
"@typescript-eslint/parser": "^5.43.0",
"@vue/test-utils": "^1.3.0",
"@vue/vue2-jest": "^29.1.0",
"amdi18n-loader": "^0.9.3",
@@ -146,10 +150,10 @@
"css-loader": "^6.7.1",
"css-minimizer-webpack-plugin": "^4.0.0",
"del": "^6.0.0",
"eslint": "^8.20.0",
"eslint": "^8.27.0",
"eslint-plugin-compat": "^4.0.2",
"eslint-plugin-vue": "^9.3.0",
"eslint-plugin-vuejs-accessibility": "^1.2.0",
"eslint-plugin-vue": "^9.7.0",
"eslint-plugin-vuejs-accessibility": "^2.0.0",
"expose-loader": "^4.0.0",
"gulp": "^4.0.2",
"ignore-loader": "^0.1.2",
@@ -157,7 +161,6 @@
"jest": "^27.4.7",
"jest-location-mock": "^1.0.9",
"jest-raw-loader": "^1.0.1",
"jest-transform-yaml": "1.0.0",
"json-loader": "^0.5.7",
"karma": "^6.3.16",
"karma-chrome-launcher": "^3.1.0",
@@ -175,12 +178,17 @@
"sinon": "^13.0.1",
"store": "^2.0.12",
"style-loader": "^3.3.1",
"ts-jest": "^29.0.3",
"ts-loader": "^9.4.1",
"typescript": "^4.9.3",
"typescript-eslint": "^0.0.1-alpha.0",
"vue-loader": "^15.10.0",
"vue-template-compiler": "^2.7.8",
"webpack": "^5.73.0",
"webpack-cli": "^4.10.0",
"webpack-dev-server": "^4.9.3",
"webpack-merge": "^5.8.0",
"yaml-jest": "^1.2.0",
"yaml-loader": "^0.8.0"
},
"peerDependencies": {
+1 -1
View File
@@ -27,7 +27,7 @@ if (!window.Galaxy) {
},
});
} else {
console.error("Detected redefinition of window.Galaxy -- skipping, but this should be investigated.", serverPath());
console.debug("Skipping, window.Galaxy already exists.", serverPath());
}
export default window.Galaxy;
+1 -1
View File
@@ -5,7 +5,7 @@
import { computed } from "vue";
import { getAppRoot } from "onload/loadConfig";
import { useConfig } from "composables/useConfig";
import { useConfig } from "composables/config";
import UtcDate from "components/UtcDate";
import License from "components/License/License";
import ExternalLink from "components/ExternalLink";
@@ -13,8 +13,8 @@
</div>
<b>{{ l("New Type") }}: </b>
<multiselect
class="datatype-dropdown"
v-model="selectedDatatype"
class="datatype-dropdown"
deselect-label="Can't remove this value"
track-by="id"
label="text"
@@ -13,8 +13,8 @@
</div>
<b>{{ l("Database/Build") }}: </b>
<multiselect
class="database-dropdown"
v-model="selectedGenome"
class="database-dropdown"
deselect-label="Can't remove this value"
track-by="id"
label="text"
@@ -6,14 +6,13 @@
class="search-query"
size="sm"
autocomplete="off"
:disabled="includeAdvBtn && showAdvanced"
:placeholder="placeholder"
@input="delayQuery"
@change="setQuery"
@keydown.esc="setQuery('')" />
<b-input-group-append>
<b-button
v-if="includeAdvBtn"
v-if="enableAdvanced"
size="sm"
:pressed="showAdvanced"
:variant="showAdvanced ? 'info' : 'secondary'"
@@ -27,7 +26,6 @@
class="search-clear"
size="sm"
:title="titleClear | l"
:disabled="includeAdvBtn && showAdvanced"
data-description="reset query"
@click="clearBox">
<icon v-if="loading" icon="spinner" spin />
@@ -55,7 +53,7 @@ export default {
type: Number,
default: 1000,
},
includeAdvBtn: {
enableAdvanced: {
type: Boolean,
default: false,
},
+1 -1
View File
@@ -42,8 +42,8 @@ if (props.size) {
<div class="stripe"></div>
</div>
<component
v-else
:is="element"
v-else
class="heading"
:class="[sizeClass, props.bold ? 'font-weight-bold' : '', props.inline ? 'inline' : '']">
<FontAwesomeIcon v-if="props.icon" :icon="props.icon" />
+11 -7
View File
@@ -11,8 +11,9 @@
<div class="m-3">
<div v-if="modelTitle">
<h1 class="h-sm">About this {{ modelTitle }}</h1>
<h2 class="h-md">{{ details.title || details.name }}</h2>
<StatelessTags v-if="details.tags" class="tags mt-2" :value="details.tags" :disabled="true" />
<h2 class="h-md">{{ item.title || item.name }}</h2>
<img class="float-right" :src="gravatarSource" alt="user avatar" />
<StatelessTags v-if="item.tags" class="tags mt-2" :value="item.tags" :disabled="true" />
<br />
<h2 class="h-sm">Author</h2>
<div>{{ owner }}</div>
@@ -25,7 +26,7 @@
<router-link :to="publishedByUser"> Published {{ plural }} by {{ owner }}. </router-link>
</div>
</div>
<LoadingSpan v-else message="Loading details" />
<LoadingSpan v-else message="Loading item details" />
</div>
</div>
</div>
@@ -40,21 +41,24 @@ export default {
StatelessTags,
},
props: {
details: {
item: {
type: Object,
required: true,
},
},
computed: {
gravatarSource() {
return `https://secure.gravatar.com/avatar/${this.item.email_hash}?d=identicon`;
},
modelTitle() {
const modelClass = this.details ? this.details.model_class : "Item";
const modelClass = this.item ? this.item.model_class : "Item";
if (modelClass == "StoredWorkflow") {
return "Workflow";
}
return modelClass;
},
owner() {
return this.details.owner || this.details.username || "Unavailable";
return this.item.owner || this.item.username || "Unavailable";
},
plural() {
if (this.modelTitle == "History") {
@@ -66,7 +70,7 @@ export default {
return this.plural.toLowerCase();
},
publishedByUser() {
return `/${this.pluralPath}/list_published?f-username=${this.details.username}`;
return `/${this.pluralPath}/list_published?f-username=${this.item.username}`;
},
urlAll() {
return `/${this.pluralPath}/list_published`;
+15
View File
@@ -0,0 +1,15 @@
export default {
methods: {
async confirm(message, options = {}) {
return this.$bvModal.msgBoxConfirm(message, {
title: "Please Confirm",
titleClass: "h-md",
hideHeaderClose: false,
...options,
});
},
},
render() {
return {};
},
};
@@ -1,6 +1,6 @@
<template>
<div aria-labelledby="dataset-attributes-heading">
<h1 id="dataset-attributes-heading" class="h-lg" v-localize>Edit Dataset Attributes</h1>
<h1 id="dataset-attributes-heading" v-localize class="h-lg">Edit Dataset Attributes</h1>
<b-alert v-if="messageText" :variant="messageVariant" show>
{{ messageText | l }}
</b-alert>
@@ -0,0 +1,39 @@
import { mount } from "@vue/test-utils";
import { getLocalVue } from "jest/helpers";
import MountTarget from "./FormRadio";
const localVue = getLocalVue(true);
describe("FormRadio", () => {
let wrapper;
beforeEach(() => {
wrapper = mount(MountTarget, {
propsData: {
value: false,
options: [],
},
localVue,
});
});
it("basics", async () => {
const noInput = wrapper.find("[type='radio']");
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='radio']");
const labels = wrapper.findAll(".custom-control-label");
expect(inputs.length).toBe(n);
for (let i = 0; i < n; i++) {
await inputs.at(i).setChecked();
expect(labels.at(i).text()).toBe(`label_${i}`);
expect(inputs.at(i).attributes("value")).toBe(`value_${i}`);
expect(wrapper.emitted()["input"][i][0]).toBe(`value_${i}`);
}
});
});
@@ -0,0 +1,31 @@
<script setup>
import { computed } from "vue";
const $emit = defineEmits(["input"]);
const props = defineProps({
value: {
default: null,
},
options: {
type: Array,
required: true,
},
});
const currentValue = computed({
get: () => {
return props.value;
},
set: (val) => {
$emit("input", val);
},
});
</script>
<template>
<b-form-radio-group 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>
</template>
@@ -0,0 +1,54 @@
<script setup>
import { computed } from "vue";
import FormRadio from "./FormRadio";
const $emit = defineEmits(["input"]);
const props = defineProps({
value: {
default: null,
},
data: {
type: Array,
default: null,
},
display: {
type: String,
default: null,
},
options: {
type: Array,
default: null,
},
multiple: {
type: Boolean,
default: false,
},
});
const currentValue = computed({
get: () => {
return props.value;
},
set: (val) => {
$emit("input", val);
},
});
/** Provides formatted select options. */
const currentOptions = computed(() => {
const data = props.data;
const options = props.options;
if (options && options.length > 0) {
return options;
} else if (data && data.length > 0) {
return data.map((option) => {
return [option.label, option.value];
});
}
return [];
});
</script>
<template>
<form-radio v-if="display == 'radio'" v-model="currentValue" :options="currentOptions" />
</template>
+2 -2
View File
@@ -57,11 +57,11 @@ export default {
},
collapsedEnableIcon: {
type: String,
default: "fa fa-caret-square-o-down",
default: "far fa-caret-square-down",
},
collapsedDisableIcon: {
type: String,
default: "fa fa-caret-square-o-up",
default: "far fa-caret-square-up",
},
validationScrollTo: {
type: Array,
+42 -13
View File
@@ -1,6 +1,8 @@
import { mount } from "@vue/test-utils";
import { getLocalVue } from "jest/helpers";
import FormElement from "./FormElement";
import FormHidden from "./Elements/FormHidden";
import FormInput from "./Elements/FormInput";
const localVue = getLocalVue();
@@ -17,53 +19,80 @@ describe("FormElement", () => {
title: "title_text",
},
localVue,
stubs: {
FormInput: { template: "<div>form-input</div>" },
FormHidden: { template: "<div>form-hidden</div>" },
},
});
});
it("check props", async () => {
const help = wrapper.find(".ui-form-info");
expect(help.text()).toBe("help_text");
const error = wrapper.find(".ui-form-error-text");
expect(error.text()).toBe("error_text");
await wrapper.setProps({ error: "" });
const no_error = wrapper.findAll(".ui-form-error");
expect(no_error.length).toBe(0);
const title = wrapper.find(".ui-form-title");
expect(title.text()).toBe("title_text");
expect(title.text()).toContain("title_text");
});
it("check collapsibles and other features", async () => {
await wrapper.setProps({ disabled: true });
expect(wrapper.findAll(".ui-form-field").length).toEqual(0);
await wrapper.setProps({ disabled: false });
expect(wrapper.findAll(".ui-form-field").length).toEqual(1);
await wrapper.setProps({ default_value: "default_value", collapsible_value: "collapsible_value" });
await wrapper.setProps({
attributes: { default_value: "default_value", collapsible_value: "collapsible_value" },
});
expect(wrapper.find(".ui-form-title-text").text()).toEqual("title_text");
expect(wrapper.findAll("span[title='Disable']").length).toEqual(1);
expect(wrapper.findAll("button[title='Disable']").length).toEqual(1);
expect(wrapper.emitted().input[0][0]).toEqual("initial_value");
await wrapper.find(".ui-form-collapsible-icon").trigger("click");
expect(wrapper.emitted().input[1][0]).toEqual("collapsible_value");
expect(wrapper.emitted().input[1][1]).toEqual("input");
await wrapper.setProps({
collapsedEnableText: "Enable Collapsible",
collapsedDisableText: "Disable Collapsible",
});
expect(wrapper.findAll("span[title='Enable Collapsible']").length).toEqual(1);
expect(wrapper.findAll("span[title='Disable Collapsible']").length).toEqual(0);
expect(wrapper.findAll("button[title='Enable Collapsible']").length).toEqual(1);
expect(wrapper.findAll("button[title='Disable Collapsible']").length).toEqual(0);
await wrapper.find(".ui-form-collapsible-icon").trigger("click");
expect(wrapper.emitted().input[2][0]).toEqual("default_value");
expect(wrapper.findAll("span[title='Disable Collapsible']").length).toEqual(1);
expect(wrapper.findAll("span[title='Enable Collapsible']").length).toEqual(0);
expect(wrapper.findAll("button[title='Disable Collapsible']").length).toEqual(1);
expect(wrapper.findAll("button[title='Enable Collapsible']").length).toEqual(0);
});
it("check type matching", async () => {
await wrapper.setProps({ type: "text" });
expect(wrapper.find("div[id='input'").text()).toEqual("form-input");
expect(wrapper.findComponent(FormInput).exists()).toBe(true);
expect(wrapper.findComponent(FormHidden).exists()).toBe(false);
await wrapper.setProps({ attributes: { titleonly: true } });
expect(wrapper.find("div[id='input'").text()).toEqual("form-hidden");
expect(wrapper.findComponent(FormHidden).exists()).toBe(true);
expect(wrapper.findComponent(FormInput).exists()).toBe(false);
});
it("marks required values", async () => {
await wrapper.setProps({ type: "text", attributes: { optional: false } });
expect(wrapper.find(".ui-form-title-star").exists()).toBe(true);
expect(wrapper.find(".ui-form-title-message").exists()).toBe(false);
});
it("marks optional values", async () => {
await wrapper.setProps({ type: "text", attributes: { optional: true } });
expect(wrapper.find(".ui-form-title-star").exists()).toBe(false);
expect(wrapper.find(".ui-form-title-message").text()).toContain("optional");
});
it("warns about empty required values", async () => {
await wrapper.setProps({ type: "text", value: "", attributes: { optional: false } });
expect(wrapper.find(".ui-form-title-star").exists()).toBe(true);
expect(wrapper.find(".ui-form-title-message").text()).toContain("required");
});
});
+323 -249
View File
@@ -1,270 +1,344 @@
<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";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { ref, computed, useAttrs } from "vue";
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,
},
});
const emit = defineEmits(["input", "change"]);
/** TODO: remove attrs computed.
useAttrs is *not* reactive, and does not play nice with type safety.
It is present for compatibility with the legacy "FormParameter" component,
but should be removed as soon as that component is removed.
*/
const attrs = computed(() => props.attributes || useAttrs());
const collapsibleValue = computed(() => attrs.value["collapsible_value"]);
const defaultValue = computed(() => attrs.value["default_value"]);
const connectedValue = { __class__: "ConnectedValue" };
const connected = ref(false);
const collapsed = ref(false);
const collapsible = computed(() => !props.disabled && collapsibleValue.value !== undefined);
const connectable = computed(() => collapsible.value && attrs.value["connectable"]);
// Determines to wether expand or collapse the input
{
setValue(props.value);
const valueJson = JSON.stringify(props.value);
connected.value = valueJson === JSON.stringify(connectedValue);
collapsed.value =
connected.value ||
(collapsibleValue.value !== undefined && valueJson === JSON.stringify(collapsibleValue.value));
}
/** Submits a changed value. */
function setValue(value) {
emit("input", value, props.id);
emit("change", props.refreshOnChange);
}
/** Handles collapsible toggle. */
function onCollapse() {
collapsed.value = !collapsed.value;
connected.value = false;
if (collapsed.value) {
setValue(collapsibleValue.value);
} else {
setValue(defaultValue.value);
}
}
/** Handles connected state. */
function onConnect() {
connected.value = !connected.value;
collapsed.value = connected.value;
if (connected.value) {
setValue(connectedValue);
} else {
setValue(defaultValue.value);
}
}
const isHidden = computed(() => attrs.value["hidden"]);
const elementId = computed(() => `form-element-${props.id}`);
const hasError = computed(() => Boolean(props.error));
const showPreview = computed(() => (collapsed.value && attrs.value["collapsible_preview"]) || props.disabled);
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})`)) {
return `${props.help} (${helpArgument})`;
} else {
return props.help;
}
});
const currentValue = computed({
get() {
return props.value;
},
set(val) {
setValue(val);
},
});
const isHiddenType = computed(
() => ["hidden", "hidden_data", "baseurl"].includes(props.type) || (props.attributes && props.attributes.titleonly)
);
const collapseText = computed(() => (collapsed.value ? props.collapsedEnableText : props.collapsedDisableText));
const connectText = computed(() => (connected.value ? props.connectedEnableText : props.connectedDisableText));
const isEmpty = computed(() => {
if (currentValue.value === null || currentValue.value === undefined) {
return true;
}
if (["text", "integer", "float", "password"].includes(props.type) && currentValue.value === "") {
return true;
}
return false;
});
const isRequired = computed(() => attrs.value["optional"] === false);
const isRequiredType = computed(() => props.type !== "boolean");
</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" :id="elementId" :class="['ui-form-element section-row', cls]">
<div
v-show="!isHidden"
:id="elementId"
class="ui-form-element section-row"
:class="{ alert: hasError, 'alert-info': hasError }">
<div v-if="hasError" class="ui-form-error">
<span class="fa fa-exclamation mr-1" />
<span class="ui-form-error-text" v-html="error" />
<FontAwesomeIcon class="mr-1" icon="fa-exclamation" />
<span class="ui-form-error-text" v-html="props.error" />
</div>
<div class="ui-form-title">
<div v-if="collapsible || connectable">
<span v-if="collapsible && !connected" class="ui-form-collapsible-icon icon" @click="onCollapse">
<span v-if="collapsed" :class="collapsedEnableIcon" :title="collapsedEnableText" />
<span v-else :class="collapsedDisableIcon" :title="collapsedDisableText" />
<span v-if="collapsible || connectable">
<b-button
v-if="collapsible && !connected"
class="ui-form-collapsible-icon"
:title="collapseText"
@click="onCollapse">
<FontAwesomeIcon v-if="collapsed" :icon="props.collapsedEnableIcon" />
<FontAwesomeIcon v-else :icon="props.collapsedDisableIcon" />
</b-button>
<b-button v-if="connectable" class="ui-form-connected-icon" :title="connectText" @click="onConnect">
<FontAwesomeIcon v-if="connected" :icon="props.connectedEnableIcon" />
<FontAwesomeIcon v-else :icon="props.connectedDisableIcon" />
</b-button>
<span v-if="props.title" class="ui-form-title-text ml-1">
{{ props.title }}
</span>
<span v-if="connectable" class="ui-form-connected-icon icon" @click="onConnect">
<span v-if="connected" :class="connectedEnableIcon" :title="connectedEnableText" />
<span v-else :class="connectedDisableIcon" :title="connectedDisableText" />
</span>
<span class="ui-form-title-text ml-1">
{{ title }}
</span>
</div>
<span v-else class="ui-form-title-text">{{ title }}</span>
</span>
<span v-else-if="props.title" class="ui-form-title-text">{{ props.title }}</span>
<span
v-if="isRequired && isRequiredType && props.title"
v-b-tooltip.hover
class="ui-form-title-star"
title="required"
:class="{ warning: isEmpty }">
*
<span v-if="isEmpty" class="ui-form-title-message warning"> required </span>
</span>
<span v-else-if="isRequiredType && props.title" class="ui-form-title-message"> - optional </span>
</div>
<div v-if="showField" class="ui-form-field" :data-label="title">
<FormBoolean v-if="type == 'boolean'" :id="id" v-model="currentValue" />
<FormHidden v-else-if="isHiddenType" :id="id" v-model="currentValue" :info="attrs['info']" />
<div v-if="showField" class="ui-form-field" :data-label="props.title">
<FormBoolean v-if="props.type === 'boolean'" :id="props.id" v-model="currentValue" />
<FormHidden v-else-if="isHiddenType" :id="props.id" v-model="currentValue" :info="attrs['info']" />
<FormNumber
v-else-if="type == 'integer' || type == 'float'"
:id="id"
v-else-if="props.type === 'integer' || props.type === 'float'"
:id="props.id"
v-model="currentValue"
:max="attrs.max"
:min="attrs.min"
:type="type"
:workflow-building-mode="workflowBuildingMode" />
<FormColor v-else-if="type == 'color'" :id="id" v-model="currentValue" />
<FormDirectory v-else-if="type == 'directory_uri'" v-model="currentValue" />
<FormSelection
v-else-if="props.type == 'select' && attrs.display == 'radio'"
:id="id"
v-model="currentValue"
:data="attrs.data"
:display="attrs.display"
:options="attrs.options"
:optional="attrs.optional"
: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" />
<FormParameter
v-else-if="backbonejs"
:id="id"
ref="params"
:id="props.id"
v-model="currentValue"
:data-label="title"
:type="type"
:data-label="props.title"
:type="props.type"
:attributes="attrs" />
<FormInput v-else :id="id" v-model="currentValue" :area="attrs['area']" />
<FormInput v-else :id="props.id" v-model="currentValue" :area="attrs['area']" />
</div>
<div v-if="showPreview" class="ui-form-preview" v-html="previewText" />
<span v-if="!!helpText" class="ui-form-info form-text text-muted" v-html="helpText" />
<div v-if="showPreview" class="ui-form-preview pt-1 pl-2 mt-1">{{ previewText }}</div>
<span v-if="Boolean(helpText)" class="ui-form-info form-text text-muted" v-html="helpText" />
</div>
</template>
<script>
import _ from "underscore";
import FormBoolean from "./Elements/FormBoolean";
import FormHidden from "./Elements/FormHidden";
import FormInput from "./Elements/FormInput";
import FormParameter from "./Elements/FormParameter";
import FormColor from "./Elements/FormColor";
import FormDirectory from "./Elements/FormDirectory";
import FormNumber from "./Elements/FormNumber";
<style lang="scss" scoped>
@import "theme/blue.scss";
@import "~@fortawesome/fontawesome-free/scss/_variables";
export default {
components: {
FormBoolean,
FormHidden,
FormInput,
FormNumber,
FormColor,
FormParameter,
FormDirectory,
},
props: {
id: {
type: String,
default: "identifer",
},
type: {
type: String,
default: "",
},
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: "fa fa-caret-square-o-down",
},
collapsedDisableIcon: {
type: String,
default: "fa fa-caret-square-o-up",
},
connectedEnableText: {
type: String,
default: "Remove connection from module.",
},
connectedDisableText: {
type: String,
default: "Add connection to module.",
},
connectedEnableIcon: {
type: String,
default: "fa fa fa-times",
},
connectedDisableIcon: {
type: String,
default: "fa fa-arrows-h",
},
workflowBuildingMode: {
type: Boolean,
default: false,
},
},
data() {
return {
collapsed: false,
connected: false,
connectedValue: { __class__: "ConnectedValue" },
};
},
computed: {
elementId() {
return `form-element-${this.id}`;
},
argument() {
return this.attrs["argument"];
},
attrs() {
return this.attributes || this.$attrs;
},
cls() {
return this.hasError && "alert alert-info";
},
collapsible() {
return !this.disabled && this.collapsibleValue !== undefined;
},
collapsibleValue() {
return this.attrs["collapsible_value"];
},
collapsiblePreview() {
return this.attrs["collapsible_preview"];
},
connectable() {
return this.collapsible && this.attrs["connectable"];
},
currentValue: {
get() {
return this.value;
},
set(val) {
this.setValue(val);
},
},
defaultValue() {
return this.attrs["default_value"];
},
hasError() {
return !!this.error;
},
helpText() {
const help = this.help;
const helpArgument = this.argument;
if (helpArgument && help.indexOf(`(${helpArgument})`) == -1) {
return `${help} (${helpArgument})`;
}
return help;
},
isHidden() {
return this.attrs["hidden"];
},
isHiddenType() {
return (
["hidden", "hidden_data", "baseurl"].includes(this.type) ||
(this.attributes && this.attributes.titleonly)
);
},
previewText() {
return _.escape(this.textValue).replace(/\n/g, "<br>");
},
showField() {
return !this.collapsed && !this.disabled;
},
showPreview() {
return (this.collapsed && this.collapsiblePreview) || this.disabled;
},
textValue() {
return this.attrs["text_value"];
},
},
created() {
this.initialState();
},
methods: {
/** Submits a changed value. */
setValue(value) {
this.$emit("input", value, this.id);
this.$emit("change", this.refreshOnChange);
},
/**
* Determines to wether expand or collapse the input.
*/
initialState() {
this.setValue(this.value);
const collapsibleValue = this.collapsibleValue;
const value = JSON.stringify(this.value);
this.connected = value == JSON.stringify(this.connectedValue);
this.collapsed =
this.connected || (collapsibleValue !== undefined && value == JSON.stringify(collapsibleValue));
},
/**
* Handles collapsible toggle.
*/
onCollapse() {
this.collapsed = !this.collapsed;
this.connected = false;
if (this.collapsed) {
this.setValue(this.collapsibleValue);
} else {
this.setValue(this.defaultValue);
}
},
/**
* Handles connected state.
*/
onConnect() {
this.connected = !this.connected;
this.collapsed = this.connected;
if (this.connected) {
this.setValue(this.connectedValue);
} else {
this.setValue(this.defaultValue);
}
},
},
};
</script>
.ui-form-element {
margin-top: $margin-v * 0.25;
margin-bottom: $margin-v * 0.25;
overflow: visible;
clear: both;
.ui-form-title {
word-wrap: break-word;
font-weight: bold;
.ui-form-title-message {
font-size: $font-size-base * 0.7;
font-weight: 300;
vertical-align: text-top;
color: $text-light;
cursor: default;
}
.ui-form-title-star {
color: $text-light;
font-weight: 300;
cursor: default;
}
.warning {
color: $brand-danger;
}
}
.ui-form-field {
position: relative;
margin-top: $margin-v * 0.25;
}
&:deep(.ui-form-collapsible-icon),
&:deep(.ui-form-connected-icon) {
border: none;
background: none;
padding: 0;
line-height: 1;
font-size: 1.2em;
&:hover {
color: $brand-info;
}
&:focus {
color: $brand-primary;
}
&:active {
background: none;
}
}
}
</style>
+22 -18
View File
@@ -1,24 +1,28 @@
<template>
<div>
<div v-for="(input, index) in inputs" :key="index">
<div v-if="input.type == 'conditional'">
<FormElement
:id="conditionalPrefix(input, input.test_param.name)"
v-model="input.test_param.value"
:title="input.test_param.label"
:type="input.test_param.type"
:help="input.test_param.help"
:refresh-on-change="false"
:disabled="sustainConditionals"
:attributes="input.test_param"
:backbonejs="true"
@change="onChange" />
<div v-for="(caseDetails, caseId) in input.cases" :key="caseId">
<FormNode
v-if="conditionalMatch(input, caseId)"
v-bind="$props"
:inputs="caseDetails.inputs"
:prefix="getPrefix(input.name)" />
<div v-if="input.type == 'conditional'" class="ui-portlet-section mt-3">
<div class="portlet-header">
<b>{{ input.test_param.label }}</b>
</div>
<div class="portlet-content">
<FormElement
:id="conditionalPrefix(input, input.test_param.name)"
v-model="input.test_param.value"
:type="input.test_param.type"
:help="input.test_param.help"
:refresh-on-change="false"
:disabled="sustainConditionals"
:attributes="input.test_param"
:backbonejs="true"
@change="onChange" />
<div v-for="(caseDetails, caseId) in input.cases" :key="caseId">
<FormNode
v-if="conditionalMatch(input, caseId)"
v-bind="$props"
:inputs="caseDetails.inputs"
:prefix="getPrefix(input.name)" />
</div>
</div>
</div>
<div v-else-if="input.type == 'repeat'">
@@ -7,7 +7,7 @@
@save="$emit('update:dsc', $event)">
<template v-slot:name>
<!-- eslint-disable-next-line vuejs-accessibility/heading-has-content -->
<h2 class="h-md" v-short="dsc.name || 'Collection'" data-description="collection name display" />
<h2 v-short="dsc.name || 'Collection'" class="h-md" data-description="collection name display" />
<CollectionDescription
:job-state-summary="jobState"
:collection-type="dsc.collection_type"
@@ -7,6 +7,7 @@
class="rounded-0 text-decoration-none"
size="sm"
variant="link"
:href="downloadUrl"
@click="onDownload">
<Icon class="mr-1" icon="download" />
<span>Download</span>
@@ -144,7 +144,7 @@
<script>
import Vue from "vue";
import { Toast } from "ui/toast";
import { Toast } from "composables/toast";
import { mapActions } from "vuex";
import { HistoryItemsProvider } from "components/providers/storeProviders";
import LoadingSpan from "components/LoadingSpan";
@@ -1,5 +1,5 @@
<template>
<Published :details="history">
<Published :item="history">
<template v-slot>
<HistoryView :id="id" />
</template>
@@ -67,15 +67,15 @@ export default {
selectedCollections: [],
};
},
created() {
this.$store.dispatch("history/loadHistoryById", this.id);
},
computed: {
...mapGetters({ getHistoryById: "history/getHistoryById" }),
history() {
return this.getHistoryById(this.id);
},
},
created() {
this.$store.dispatch("history/loadHistoryById", this.id);
},
methods: {
onViewCollection(collection) {
this.selectedCollections = [...this.selectedCollections, collection];
@@ -1,7 +1,7 @@
<template>
<section class="m-3 details" data-description="edit details">
<b-button
v-if="writeable && !currentUser.isAnonymous"
:disabled="currentUser.isAnonymous || !writeable"
class="edit-button ml-1 float-right"
data-description="editor toggle"
size="sm"
@@ -189,7 +189,7 @@ import { getAppRoot } from "onload/loadConfig";
import BootstrapVue from "bootstrap-vue";
import { Services } from "./services";
import { fields } from "./table-fields";
import { Toast } from "ui/toast";
import { Toast } from "composables/toast";
import { initLibrariesIcons } from "components/Libraries/icons";
import { MAX_DESCRIPTION_LENGTH, DEFAULT_PER_PAGE, onError } from "components/Libraries/library-utils";
import LibraryEditField from "components/Libraries/LibraryEditField";
@@ -276,7 +276,7 @@ import { Services } from "./services";
import Utils from "utils/utils";
import linkifyHtml from "linkify-html";
import { fields } from "./table-fields";
import { Toast } from "ui/toast";
import { Toast } from "composables/toast";
import FolderTopBar from "./TopToolbar/FolderTopBar";
import { initFolderTableIcons } from "components/Libraries/icons";
import { MAX_DESCRIPTION_LENGTH, DEFAULT_PER_PAGE } from "components/Libraries/library-utils";
@@ -139,7 +139,7 @@ import { Services } from "components/Libraries/LibraryFolder/services";
import LibraryBreadcrumb from "components/Libraries/LibraryFolder/LibraryBreadcrumb";
import download from "components/Libraries/LibraryFolder/TopToolbar/download";
import CopyToClipboard from "components/CopyToClipboard";
import { Toast } from "ui/toast";
import { Toast } from "composables/toast";
import { fieldTitles } from "components/Libraries/LibraryFolder/LibraryFolderDataset/constants";
import { DbKeyProvider, DatatypesProvider } from "components/providers";
import SingleItemSelector from "components/SingleItemSelector";
@@ -78,7 +78,7 @@ import Vue from "vue";
import { getAppRoot } from "onload/loadConfig";
import BootstrapVue from "bootstrap-vue";
import { Services } from "components/Libraries/LibraryPermissions/services";
import { Toast } from "ui/toast";
import { Toast } from "composables/toast";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { getGalaxyInstance } from "app";
import PermissionsHeader from "components/Libraries/LibraryPermissions/PermissionsHeader";
@@ -44,7 +44,7 @@ import Vue from "vue";
import { getAppRoot } from "onload/loadConfig";
import BootstrapVue from "bootstrap-vue";
import { Services } from "components/Libraries/LibraryPermissions/services";
import { Toast } from "ui/toast";
import { Toast } from "composables/toast";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import PermissionsInputField from "components/Libraries/LibraryPermissions/PermissionsInputField";
import PermissionsHeader from "components/Libraries/LibraryPermissions/PermissionsHeader";
@@ -125,7 +125,7 @@ import { initTopBarIcons } from "components/Libraries/icons";
import mod_import_dataset from "./import-to-history/import-dataset";
import mod_import_collection from "./import-to-history/import-collection";
import mod_add_datasets from "./add-datasets";
import { Toast } from "ui/toast";
import { Toast } from "composables/toast";
import download from "./download";
import mod_utils from "utils/utils";
import { getAppRoot } from "onload/loadConfig";
@@ -1,5 +1,5 @@
import { getGalaxyInstance } from "app";
import { Toast } from "ui/toast";
import { Toast } from "composables/toast";
import _l from "utils/localization";
import mod_library_model from "./library-model";
import _ from "underscore";
@@ -1,5 +1,5 @@
import { getGalaxyInstance } from "app";
import { Toast } from "ui/toast";
import { Toast } from "composables/toast";
import _l from "utils/localization";
import mod_library_model from "./library-model";
import _ from "underscore";
@@ -1,5 +1,5 @@
import $ from "jquery";
import { Toast } from "ui/toast";
import { Toast } from "composables/toast";
import { getAppRoot } from "onload";
function processDownload(url, data, method) {
@@ -1,6 +1,6 @@
import { getGalaxyInstance } from "app";
import { getAppRoot } from "onload/loadConfig";
import { Toast } from "ui/toast";
import { Toast } from "composables/toast";
import mod_library_model from "../library-model";
import _ from "underscore";
import Backbone from "backbone";
@@ -1,5 +1,5 @@
import { getGalaxyInstance } from "app";
import { Toast } from "ui/toast";
import { Toast } from "composables/toast";
import _l from "utils/localization";
import mod_library_model from "../library-model";
import _ from "underscore";
@@ -43,7 +43,7 @@ import Vue from "vue";
import { getAppRoot } from "onload/loadConfig";
import BootstrapVue from "bootstrap-vue";
import { Services } from "components/Libraries/LibraryPermissions/services";
import { Toast } from "ui/toast";
import { Toast } from "composables/toast";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import PermissionsHeader from "components/Libraries/LibraryPermissions/PermissionsHeader";
import { extractRoles } from "components/Libraries/library-utils";
@@ -1,4 +1,4 @@
import { Toast } from "ui/toast";
import { Toast } from "composables/toast";
export const MAX_DESCRIPTION_LENGTH = 40;
export const DEFAULT_PER_PAGE = 10;
@@ -0,0 +1,69 @@
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { mount } from "@vue/test-utils";
import { getLocalVue } from "jest/helpers";
import { safePath } from "utils/redirect";
import MountTarget from "./ChangePassword";
// mock routes
jest.mock("utils/redirect");
const mockSafePath = jest.fn();
safePath.mockImplementation(() => mockSafePath);
const localVue = getLocalVue(true);
describe("ChangePassword", () => {
let wrapper;
let axiosMock;
beforeEach(() => {
axiosMock = new MockAdapter(axios);
wrapper = mount(MountTarget, {
propsData: {
messageText: "message_text",
messageVariant: "message_variant",
},
localVue,
});
});
afterEach(() => {
axiosMock.reset();
});
it("basics", async () => {
const cardHeader = wrapper.find(".card-header");
expect(cardHeader.text()).toBe("Change your password");
const inputs = wrapper.findAll("input");
expect(inputs.length).toBe(2);
const firstPwdField = inputs.at(0);
expect(firstPwdField.attributes("type")).toBe("password");
await firstPwdField.setValue("test_first_pwd");
const secondPwdField = inputs.at(1);
expect(secondPwdField.attributes("type")).toBe("password");
await secondPwdField.setValue("test_second_pwd");
const submitButton = wrapper.find("button[type='submit']");
await submitButton.trigger("submit");
const postedData = JSON.parse(axiosMock.history.post[0].data);
expect(postedData.password).toBe("test_first_pwd");
expect(postedData.confirm).toBe("test_second_pwd");
});
it("props", async () => {
await wrapper.setProps({
token: "test_token",
expiredUser: "expired_user",
});
const input = wrapper.find("input");
expect(input.attributes("type")).toBe("password");
await input.setValue("current_password");
const submitButton = wrapper.find("button[type='submit']");
await submitButton.trigger("submit");
const postedData = JSON.parse(axiosMock.history.post[0].data);
expect(postedData.token).toBe("test_token");
expect(postedData.id).toBe("expired_user");
expect(postedData.current).toBe("current_password");
const alert = wrapper.find(".alert");
expect(alert.text()).toBe("message_text");
});
});
@@ -1,10 +1,10 @@
<template>
<b-form @submit.prevent="submit">
<b-alert :show="messageShow" :variant="messageVariant">
{{ messageText }}
<b-alert v-if="!!message" :variant="variant" show>
{{ message }}
</b-alert>
<b-card header="Change your password">
<b-form-group v-if="user" label="Current Password">
<b-form-group v-if="expiredUser" label="Current Password">
<b-form-input v-model="current" type="password" />
</b-form-group>
<b-form-group label="New Password"> <b-form-input v-model="password" type="password" /> </b-form-group>
@@ -17,48 +17,55 @@
import axios from "axios";
import Vue from "vue";
import BootstrapVue from "bootstrap-vue";
import { getGalaxyInstance } from "app";
import { getAppRoot } from "onload";
import { safePath } from "utils/redirect";
Vue.use(BootstrapVue);
export default {
props: {
token: {
type: String,
default: null,
},
expiredUser: {
type: String,
default: null,
},
messageText: {
type: String,
default: null,
},
messageVariant: {
type: String,
default: null,
},
},
data() {
const Galaxy = getGalaxyInstance();
return {
token: Galaxy.params.token,
user: Galaxy.params.expired_user,
password: null,
confirm: null,
current: null,
messageText: Galaxy.params.message,
messageVariant: Galaxy.params.status,
message: this.messageText,
variant: this.messageVariant,
};
},
computed: {
messageShow() {
return this.messageText != null;
},
},
methods: {
submit: function (ev) {
const urlRoot = getAppRoot();
ev.preventDefault();
submit() {
axios
.post(`${urlRoot}user/change_password`, {
.post(safePath("/user/change_password"), {
token: this.token,
id: this.user,
id: this.expiredUser,
current: this.current,
password: this.password,
confirm: this.confirm,
})
.then((response) => {
window.location = `${urlRoot}`;
window.location = safePath(`/`);
})
.catch((error) => {
this.messageVariant = "danger";
const message = error.response.data && error.response.data.err_msg;
this.messageText = message || "Password change failed for an unknown reason.";
this.variant = "danger";
const message = error.response && error.response.data && error.response.data.err_msg;
this.message = message || "Password change failed for an unknown reason.";
});
},
},
@@ -0,0 +1,62 @@
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { mount } from "@vue/test-utils";
import { getLocalVue } from "jest/helpers";
import MountTarget from "./LoginForm";
const localVue = getLocalVue(true);
describe("LoginForm", () => {
let wrapper;
let axiosMock;
beforeEach(() => {
axiosMock = new MockAdapter(axios);
wrapper = mount(MountTarget, {
propsData: {
sessionCsrfToken: "sessionCsrfToken",
},
localVue,
stubs: {
ExternalLogin: true,
},
});
});
afterEach(() => {
axiosMock.reset();
});
it("basics", async () => {
const cardHeader = wrapper.find(".card-header");
expect(cardHeader.text()).toBe("Welcome to Galaxy, please log in");
const inputs = wrapper.findAll("input");
expect(inputs.length).toBe(2);
const usernameField = inputs.at(0);
expect(usernameField.attributes("type")).toBe("text");
await usernameField.setValue("test_user");
const pwdField = inputs.at(1);
expect(pwdField.attributes("type")).toBe("password");
await pwdField.setValue("test_pwd");
const submitButton = wrapper.find("button[type='submit']");
await submitButton.trigger("submit");
const postedData = JSON.parse(axiosMock.history.post[0].data);
expect(postedData.login).toBe("test_user");
expect(postedData.password).toBe("test_pwd");
});
it("props", async () => {
const $register = "[id='register-toggle']";
expect(wrapper.findAll($register).length).toBe(0);
await wrapper.setProps({
allowUserCreation: true,
enableOidc: true,
showWelcomeWithLogin: true,
welcomeUrl: "welcome_url",
});
const register = wrapper.find($register);
expect(register.text()).toBe("Register here.");
const welcomePage = wrapper.find("iframe");
expect(welcomePage.attributes("src")).toBe("welcome_url");
});
});
@@ -3,10 +3,10 @@
<div class="row justify-content-md-center">
<template v-if="!confirmURL">
<div class="col col-lg-6">
<b-alert :show="messageShow" :variant="messageVariant">
<b-alert :show="!!messageText" :variant="messageVariant">
{{ messageText }}
</b-alert>
<b-form id="login" @submit.prevent="submitGalaxyLogin()">
<b-form id="login" @submit.prevent="submitLogin()">
<b-card no-body header="Welcome to Galaxy, please log in">
<b-card-body>
<div>
@@ -18,14 +18,14 @@
<b-form-input v-model="password" name="password" type="password" />
<b-form-text>
Forgot password?
<a href="javascript:void(0)" role="button" @click="reset">
<a href="javascript:void(0)" role="button" @click.prevent="resetLogin">
Click here to reset your password.
</a>
</b-form-text>
</b-form-group>
<b-button name="login" type="submit">Login</b-button>
</div>
<div v-if="enable_oidc">
<div v-if="enableOidc">
<!-- OIDC login-->
<external-login :login_page="true" />
</div>
@@ -62,9 +62,12 @@
</div>
</template>
<template v-else>
<new-user-confirmation :redirect="redirect" @setRedirect="setRedirect" />
<new-user-confirmation
:registration-warning-message="registrationWarningMessage"
:terms-url="termsUrl"
@setRedirect="setRedirect" />
</template>
<div v-if="show_welcome_with_login" class="col">
<div v-if="showWelcomeWithLogin" class="col">
<b-embed type="iframe" :src="welcomeUrlWithRoot" aspect="1by1" />
</div>
</div>
@@ -76,8 +79,7 @@ import axios from "axios";
import Vue from "vue";
import BootstrapVue from "bootstrap-vue";
import { safePath } from "utils/redirect";
import { getGalaxyInstance } from "app";
import NewUserConfirmation from "components/login/NewUserConfirmation";
import NewUserConfirmation from "./NewUserConfirmation";
import ExternalLogin from "components/User/ExternalIdentities/ExternalLogin";
Vue.use(BootstrapVue);
@@ -88,51 +90,73 @@ export default {
NewUserConfirmation,
},
props: {
show_welcome_with_login: {
allowUserCreation: {
type: Boolean,
required: false,
default: false,
},
welcome_url: {
enableOidc: {
type: Boolean,
default: false,
},
redirect: {
type: String,
required: false,
default: null,
},
registrationWarningMessage: {
type: String,
default: null,
},
sessionCsrfToken: {
type: String,
required: true,
},
showWelcomeWithLogin: {
type: Boolean,
default: false,
},
termsUrl: {
type: String,
default: null,
},
welcomeUrl: {
type: String,
default: null,
},
},
data() {
const galaxy = getGalaxyInstance();
return {
login: null,
password: null,
url: null,
messageText: null,
messageVariant: null,
allowUserCreation: galaxy.config.allow_user_creation,
redirect: galaxy.params.redirect,
session_csrf_token: galaxy.session_csrf_token,
enable_oidc: galaxy.config.enable_oidc,
};
},
computed: {
messageShow() {
return this.messageText != null;
},
confirmURL() {
var urlParams = new URLSearchParams(window.location.search);
return urlParams.has("confirm") && urlParams.get("confirm") == "true";
},
welcomeUrlWithRoot() {
return safePath(this.welcome_url);
return safePath(this.welcomeUrl);
},
},
methods: {
toggleLogin() {
this.$emit("toggle-login");
},
submitGalaxyLogin(method) {
submitLogin() {
let redirect = this.redirect;
if (localStorage.getItem("redirect_url")) {
this.redirect = localStorage.getItem("redirect_url");
redirect = localStorage.getItem("redirect_url");
}
axios
.post(safePath(`/user/login`), this.$data)
.post(safePath("/user/login"), {
login: this.login,
password: this.password,
redirect: redirect,
session_csrf_token: this.sessionCsrfToken,
})
.then(({ data }) => {
if (data.message && data.status) {
alert(data.message);
@@ -154,10 +178,9 @@ export default {
setRedirect(url) {
localStorage.setItem("redirect_url", url);
},
reset(ev) {
ev.preventDefault();
resetLogin() {
axios
.post(safePath(`/user/reset_password`), { email: this.login })
.post(safePath("/user/reset_password"), { email: this.login })
.then((response) => {
this.messageVariant = "info";
this.messageText = response.data.message;
@@ -0,0 +1,39 @@
import { mount } from "@vue/test-utils";
import { getLocalVue } from "jest/helpers";
import MountTarget from "./LoginIndex";
const localVue = getLocalVue(true);
describe("LoginIndex", () => {
let wrapper;
beforeEach(() => {
wrapper = mount(MountTarget, {
propsData: {
allowUserCreation: false,
sessionCsrfToken: "sessionCsrfToken",
},
localVue,
});
});
it("switching between register and login", async () => {
const cardHeader = wrapper.find(".card-header");
expect(cardHeader.text()).toBe("Welcome to Galaxy, please log in");
const $registerToggle = "[id=register-toggle]";
const missingToggle = wrapper.find($registerToggle);
expect(missingToggle.exists()).toBeFalsy();
await wrapper.setProps({ allowUserCreation: true });
const registerToggle = wrapper.find($registerToggle);
expect(registerToggle.exists()).toBeTruthy();
await registerToggle.trigger("click");
const newCardHeader = wrapper.find(".card-header");
expect(newCardHeader.text()).toBe("Create a Galaxy account");
const $loginToggle = "[id=login-toggle]";
const loginToggle = wrapper.find($loginToggle);
await loginToggle.trigger("click");
const oldCardHeader = wrapper.find(".card-header");
expect(oldCardHeader.text()).toBe("Welcome to Galaxy, please log in");
expect(wrapper.vm.showLoginLink).toBe(true);
});
});
+101
View File
@@ -0,0 +1,101 @@
<template>
<div>
<login-form
v-if="showLogin"
:allow-user-creation="allowUserCreation"
:enable-oidc="enableOidc"
:redirect="redirect"
:registration-warning-message="registrationWarningMessage"
:session-csrf-token="sessionCsrfToken"
:show-welcome-with-login="showWelcomeWithLogin"
:terms-url="termsUrl"
:welcome-url="welcomeUrl"
@toggle-login="toggleLogin" />
<register-form
v-else
:enable-oidc="enableOidc"
:mailing-join-addr="mailingJoinAddr"
:prefer-custos-login="preferCustosLogin"
:registration-warning-message="registrationWarningMessage"
:show-login-link="showLoginLink"
:server-mail-configured="serverMailConfigured"
:session-csrf-token="sessionCsrfToken"
:terms-url="termsUrl"
@toggle-login="toggleLogin" />
</div>
</template>
<script>
import Vue from "vue";
import BootstrapVue from "bootstrap-vue";
import LoginForm from "./LoginForm";
import RegisterForm from "./RegisterForm";
Vue.use(BootstrapVue);
export default {
components: {
LoginForm,
RegisterForm,
},
props: {
allowUserCreation: {
type: Boolean,
required: true,
},
enableOidc: {
type: Boolean,
default: false,
},
mailingJoinAddr: {
type: String,
default: null,
},
preferCustosLogin: {
type: Boolean,
default: false,
},
redirect: {
type: String,
default: null,
},
registrationWarningMessage: {
type: String,
default: null,
},
showLoginLink: {
type: Boolean,
default: true,
},
sessionCsrfToken: {
type: String,
required: true,
},
serverMailConfigured: {
type: Boolean,
default: false,
},
showWelcomeWithLogin: {
type: Boolean,
default: false,
},
termsUrl: {
type: String,
default: null,
},
welcomeUrl: {
type: String,
default: null,
},
},
data() {
return {
showLogin: true,
};
},
methods: {
toggleLogin() {
this.showLogin = !this.showLogin;
},
},
};
</script>
@@ -0,0 +1,49 @@
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { mount } from "@vue/test-utils";
import { getLocalVue } from "jest/helpers";
import MountTarget from "./NewUserConfirmation";
const localVue = getLocalVue(true);
describe("NewUserConfirmation", () => {
let wrapper;
let axiosMock;
beforeEach(() => {
axiosMock = new MockAdapter(axios);
wrapper = mount(MountTarget, {
propsData: {},
localVue,
});
});
afterEach(() => {
axiosMock.reset();
});
it("basics", async () => {
const cardHeader = wrapper.find(".card-header");
expect(cardHeader.text()).toBe("Confirm new account creation");
const inputs = wrapper.findAll("input");
expect(inputs.length).toBe(1);
const checkField = inputs.at(0);
expect(checkField.attributes("type")).toBe("checkbox");
const submitButton = wrapper.find("button[name='confirm']");
await submitButton.trigger("click");
expect(axiosMock.history.post.length).toBe(0);
await checkField.setChecked();
await submitButton.trigger("click");
const postedData = axiosMock.history.post[0];
expect(postedData.url).toBe("/authnz/custos/create_user?token=null");
await wrapper.setProps({ registrationWarningMessage: "registration warning message" });
const alert = wrapper.find(".alert");
expect(alert.text()).toBe("registration warning message");
await wrapper.setProps({ termsUrl: "terms_url" });
const termsFrame = wrapper.find("iframe");
expect(termsFrame.attributes("src")).toBe("terms_url");
const $toggle = "a[id=login-toggle]";
const loginToggle = wrapper.find($toggle);
expect(loginToggle.text()).toBe("Log in here.");
});
});
@@ -1,11 +1,11 @@
<template>
<div class="container">
<div class="row justify-content-md-center">
<div class="col" :class="{ 'col-lg-6': !isAdmin }">
<b-alert :show="showRegistrationWarning" variant="info">
{{ registration_warning_message }}
<div class="col col-lg-6">
<b-alert :show="!!registrationWarningMessage" variant="info">
{{ registrationWarningMessage }}
</b-alert>
<b-alert :show="messageShow" :variant="messageVariant">
<b-alert :show="!!messageText" :variant="messageVariant">
{{ messageText }}
</b-alert>
<b-form id="confirmation" @submit.prevent="submit()">
@@ -18,9 +18,9 @@
connect this account via <strong>User Preferences</strong>.
<a
href="https://galaxyproject.org/authnz/use/oidc/idps/custos/#link-an-existing-galaxy-account"
target="_blank"
>More details here.</a
>
target="_blank">
More details here.
</a>
</p>
<p>
If you wish to continue and create a new account, select
@@ -31,27 +31,26 @@
subject to termination and data deletion on public Galaxy servers. Connect existing
account now to continue to use your existing data and avoid possible loss of data.
</p>
<b-form-group>
<b-form-checkbox v-model="termsRead">
I have read and accept these terms to create a new Galaxy account.
</b-form-checkbox>
</b-form-group>
<b-button name="confirm" type="submit" :disabled="!termsRead" @click.prevent="submit"
>Yes, create new account</b-button
>
<b-button name="confirm" type="submit" :disabled="!termsRead" @click.prevent="submit">
Yes, create new account
</b-button>
<b-button name="cancel" type="submit" @click.prevent="login">No, go back to login</b-button>
</b-card-body>
<b-card-footer v-if="!isAdmin">
<b-card-footer>
Already have an account?
<a id="login-toggle" href="javascript:void(0)" role="button" @click.prevent="login"
>Log in here.</a
>
<a id="login-toggle" href="javascript:void(0)" role="button" @click.prevent="login">
Log in here.
</a>
</b-card-footer>
</b-card>
</b-form>
</div>
<div v-if="terms_url" class="col">
<div v-if="termsUrl" class="col">
<b-embed type="iframe" :src="termsUrlwithRoot" aspect="1by1" />
</div>
</div>
@@ -61,53 +60,37 @@
import axios from "axios";
import Vue from "vue";
import BootstrapVue from "bootstrap-vue";
import { getGalaxyInstance } from "app";
import { safePath } from "utils/redirect";
Vue.use(BootstrapVue);
export default {
props: {
registration_warning_message: {
registrationWarningMessage: {
type: String,
required: false,
default: null,
},
redirect: {
termsUrl: {
type: String,
required: false,
},
terms_url: {
type: String,
required: false,
default: null,
},
},
data() {
const galaxy = getGalaxyInstance();
return {
username: null,
subscribe: null,
messageText: null,
messageVariant: null,
session_csrf_token: galaxy.session_csrf_token,
isAdmin: galaxy.user.isAdmin(),
termsRead: false,
};
},
computed: {
messageShow() {
return this.messageText != null;
},
showRegistrationWarning() {
return this.registration_warning_message != null;
},
termsUrlwithRoot() {
return safePath(this.terms_url);
return safePath(this.termsUrl);
},
},
methods: {
login() {
// set url to redirect user to 3rd party management after login
this.$emit("setRedirect", "user/external_ids");
this.$emit("setRedirect", "/user/external_ids");
window.location = safePath("/login");
},
submit() {
@@ -0,0 +1,44 @@
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { mount } from "@vue/test-utils";
import { getLocalVue } from "jest/helpers";
import MountTarget from "./RegisterForm";
const localVue = getLocalVue(true);
describe("RegisterForm", () => {
let wrapper;
let axiosMock;
beforeEach(() => {
axiosMock = new MockAdapter(axios);
wrapper = mount(MountTarget, {
propsData: {
sessionCsrfToken: "sessionCsrfToken",
},
localVue,
});
});
afterEach(() => {
axiosMock.reset();
});
it("basics", async () => {
const cardHeader = wrapper.find(".card-header");
expect(cardHeader.text()).toBe("Create a Galaxy account");
const inputs = wrapper.findAll("input");
expect(inputs.length).toBe(4);
const usernameField = inputs.at(0);
expect(usernameField.attributes("type")).toBe("text");
await usernameField.setValue("test_user");
const pwdField = inputs.at(1);
expect(pwdField.attributes("type")).toBe("password");
await pwdField.setValue("test_pwd");
const submitButton = wrapper.find("button[type='submit']");
await submitButton.trigger("submit");
const postedData = JSON.parse(axiosMock.history.post[0].data);
expect(postedData.email).toBe("test_user");
expect(postedData.password).toBe("test_pwd");
});
});
@@ -1,11 +1,11 @@
<template>
<div class="container">
<div class="row justify-content-md-center">
<div class="col" :class="{ 'col-lg-6': !isAdmin }">
<b-alert :show="showRegistrationWarning" variant="info">
{{ registration_warning_message }}
<div class="col col-lg-6">
<b-alert :show="!!registrationWarningMessage" variant="info">
{{ registrationWarningMessage }}
</b-alert>
<b-alert :show="messageShow" :variant="messageVariant">
<b-alert :show="!!messageText" :variant="messageVariant">
{{ messageText }}
</b-alert>
<b-form id="registration" @submit.prevent="submit()">
@@ -45,32 +45,32 @@
</b-form-group>
<b-form-group label="Public name">
<b-form-input v-model="username" name="username" type="text" />
<b-form-text
>Your public name is an identifier that will be used to generate addresses for
<b-form-text>
Your public name is an identifier that will be used to generate addresses for
information you share publicly. Public names must be at least three characters
in length and contain only lower-case letters, numbers, dots, underscores, and
dashes ('.', '_', '-').</b-form-text
>
dashes ('.', '_', '-').
</b-form-text>
</b-form-group>
<b-form-group
v-if="mailing_join_addr && server_mail_configured"
v-if="mailingJoinAddr && serverMailConfigured"
label="Subscribe to mailing list">
<input v-model="subscribe" name="subscribe" type="checkbox" />
</b-form-group>
<b-button name="create" type="submit" :disabled="disableCreate">Create</b-button>
</b-card-body>
</b-collapse>
<b-card-footer v-if="!isAdmin">
<b-card-footer v-if="showLoginLink">
Already have an account?
<a id="login-toggle" href="javascript:void(0)" role="button" @click.prevent="toggleLogin"
>Log in here.</a
>
<a id="login-toggle" href="javascript:void(0)" role="button" @click.prevent="toggleLogin">
Log in here.
</a>
</b-card-footer>
</b-card>
</b-form>
</div>
<div v-if="terms_url" class="col">
<b-embed type="iframe" :src="terms_url" aspect="1by1" />
<div v-if="termsUrl" class="col">
<b-embed type="iframe" :src="termsUrl" aspect="1by1" />
</div>
</div>
</div>
@@ -79,9 +79,8 @@
import axios from "axios";
import Vue from "vue";
import BootstrapVue from "bootstrap-vue";
import { getGalaxyInstance } from "app";
import { getAppRoot } from "onload";
import ExternalLogin from "components/User/ExternalIdentities/ExternalLogin.vue";
import { safePath } from "utils/redirect";
import ExternalLogin from "components/User/ExternalIdentities/ExternalLogin";
Vue.use(BootstrapVue);
@@ -90,29 +89,44 @@ export default {
ExternalLogin,
},
props: {
registration_warning_message: {
type: String,
required: false,
},
server_mail_configured: {
enableOidc: {
type: Boolean,
required: false,
default: false,
},
mailing_join_addr: {
showLoginLink: {
type: Boolean,
default: false,
},
mailingJoinAddr: {
type: String,
required: false,
default: null,
},
preferCustosLogin: {
type: Boolean,
default: false,
},
redirect: {
type: String,
required: false,
default: null,
},
terms_url: {
registrationWarningMessage: {
type: String,
required: false,
default: null,
},
serverMailConfigured: {
type: Boolean,
default: false,
},
sessionCsrfToken: {
type: String,
required: true,
},
termsUrl: {
type: String,
default: null,
},
},
data() {
const galaxy = getGalaxyInstance();
return {
disableCreate: false,
email: null,
@@ -122,37 +136,33 @@ export default {
subscribe: null,
messageText: null,
messageVariant: null,
session_csrf_token: galaxy.session_csrf_token,
isAdmin: galaxy.user.isAdmin(),
enable_oidc: galaxy.config.enable_oidc,
prefer_custos_login: galaxy.config.prefer_custos_login,
};
},
computed: {
messageShow() {
return this.messageText != null;
},
showRegistrationWarning() {
return this.registration_warning_message != null;
},
custosPreferred() {
return this.enable_oidc && this.prefer_custos_login;
return this.enableOidc && this.preferCustosLogin;
},
},
methods: {
toggleLogin() {
this.$emit("toggle-login");
},
submit(method) {
submit() {
this.disableCreate = true;
const rootUrl = getAppRoot();
axios
.post(`${rootUrl}user/create`, this.$data)
.post(safePath("/user/create"), {
email: this.email,
username: this.username,
password: this.password,
confirm: this.confirm,
subscribe: this.subscribe,
session_csrf_token: this.sessionCsrfToken,
})
.then((response) => {
if (response.data.message && response.data.status) {
alert(response.data.message);
}
window.location = this.redirect || `${rootUrl}welcome/new`;
window.location = this.redirect || safePath("/welcome/new");
})
.catch((error) => {
this.disableCreate = false;
@@ -0,0 +1,43 @@
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { mount } from "@vue/test-utils";
import { getLocalVue } from "jest/helpers";
import MountTarget from "./WorkflowDisplay";
const localVue = getLocalVue(true);
describe("WorkflowDisplay", () => {
let wrapper;
let axiosMock;
beforeEach(() => {
axiosMock = new MockAdapter(axios);
const data = {};
axiosMock.onGet(`/api/workflows/workflow_id/download?style=preview`).reply(200, data);
wrapper = mount(MountTarget, {
propsData: {
args: {
workflow_id: "workflow_id",
},
embedded: false,
expanded: false,
},
localVue,
});
});
afterEach(() => {
axiosMock.reset();
});
it("basics", async () => {
const cardHeader = wrapper.find(".card-header");
expect(cardHeader.text()).toBe("Workflow:");
const downloadUrl = wrapper.find("[data-description='workflow download']");
expect(downloadUrl.attributes("href")).toBe("/api/workflows/workflow_id/download?format=json-download");
const importUrl = wrapper.find("[data-description='workflow import']");
expect(importUrl.attributes("href")).toBe("/workflow/imp?id=workflow_id");
const dataRequest = axiosMock.history.get[0].url;
expect(dataRequest).toBe("/api/workflows/workflow_id/download?style=preview");
});
});
@@ -78,10 +78,10 @@ export default {
return this.itemContent ? this.itemContent.name : "...";
},
downloadUrl() {
return safePath("/api/workflows/${this.args.workflow_id}/download?format=json-download");
return safePath(`/api/workflows/${this.args.workflow_id}/download?format=json-download`);
},
importUrl() {
return safePath("/workflow/imp?id=${this.args.workflow_id}");
return safePath(`/workflow/imp?id=${this.args.workflow_id}`);
},
itemUrl() {
return `/api/workflows/${this.args.workflow_id}/download?style=preview`;
@@ -1,6 +1,6 @@
<template>
<config-provider v-slot="{ config, loading }">
<Published :details="page">
<Published :item="page">
<template v-slot>
<div v-if="!loading">
<markdown
@@ -57,7 +57,7 @@ export default {
},
created() {
urlData({ url: this.dataUrl }).then((data) => {
this.page = { ...data };
this.page = data;
});
},
methods: {
@@ -11,7 +11,7 @@
<script>
import axios from "axios";
import { Toast } from "ui/toast";
import { Toast } from "composables/toast";
import { getAppRoot } from "onload/loadConfig";
import { rethrowSimple } from "utils/simple-error";
import LoadingSpan from "components/LoadingSpan";
@@ -36,7 +36,7 @@ import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { library } from "@fortawesome/fontawesome-svg-core";
import { faEye, faSave } from "@fortawesome/free-solid-svg-icons";
import MarkdownEditor from "components/Markdown/MarkdownEditor";
import { Toast } from "ui/toast";
import { Toast } from "composables/toast";
import { save } from "./util";
Vue.use(BootstrapVue);
@@ -1,189 +0,0 @@
<template>
<div v-infinite-scroll="loadMore" infinite-scroll-disabled="busy">
<b-overlay :show="busy" opacity="0.5">
<b-table ref="toolTable" striped bordered :fields="fields" :items="buffer">
<template v-slot:cell(name)="row">
<span v-if="!row.item.help_text"
><b>{{ row.item.name }}</b> {{ row.item.description }}</span
>
<span v-else>
<b-link href="javascript:void(0)" role="button" @click="toggleHelp(row)">
<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_text">
<p class="mb-1" v-html="row.item.help_text" />
<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>
</b-overlay>
</div>
</template>
<script>
const loadTimeout = 100;
const defaultBufferLen = 4;
import _l from "utils/localization";
import infiniteScroll from "vue-infinite-scroll";
import { useGlobalUploadModal } from "composables/useGlobalUploadModal";
import { fetchData } from "./services";
export default {
directives: { infiniteScroll },
props: {
tools: {
type: Array,
default: null,
},
},
data() {
return {
buffer: {},
bufferLen: defaultBufferLen,
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: "",
},
],
};
},
setup() {
const { openGlobalUploadModal } = useGlobalUploadModal();
return { openGlobalUploadModal };
},
created() {
const initData = { summary: "", help_text: "", _showDetails: false, is_fetched: false };
this.buffer = this.tools.slice(0, this.bufferLen);
this.buffer.forEach((tool) => Object.assign(tool, { ...initData }));
this.buffer.forEach(async (tool, index) => {
await this.fetchHelp(index);
});
},
methods: {
onOpen(tool) {
if (tool.id === "upload1") {
this.openGlobalUploadModal();
} else if (tool.form_style === "regular") {
// encode spaces in tool.id
const toolId = tool.id;
const toolVersion = tool.version;
this.$router.push({ path: `/?tool_id=${encodeURIComponent(toolId)}&version=${toolVersion}` });
}
},
async loadMore() {
if (this.buffer.length < this.tools.length) {
this.busy = true;
setTimeout(() => {
this.bufferLen += defaultBufferLen;
this.buffer = this.tools.slice(0, this.bufferLen);
this.buffer.forEach(async (tool, index) => {
await this.fetchHelp(index);
});
this.busy = false;
}, loadTimeout);
}
},
toggleHelp(row) {
row.toggleDetails();
this.$refs.toolTable.refresh();
},
async fetchHelp(index) {
const id = this.buffer[index]["id"];
if (!this.buffer[index]["is_fetched"]) {
this.buffer[index]["is_fetched"] = true; // tags the tool's help as being fetched
await fetchData(`api/tools/${id}/build`).then((response) => {
if (response.help && response.help != "\n") {
this.buffer[index]["help_text"] = response.help;
this.buffer[index]["summary"] = this.parseHelp(response.help);
} else {
this.buffer[index]["help_text"] = ""; // for cases where helpText == '\n'
}
});
this.$refs.toolTable.refresh();
}
},
parseHelp(help) {
let summary = "";
const parser = new DOMParser();
const helpDoc = parser.parseFromString(help, "text/html");
const xpaths = [
"//strong[text()='What it does']/../following-sibling::*",
"//strong[text()='What It Does']/../following-sibling::*",
"//h1[text()='Synopsis']/following-sibling::*",
"//strong[text()='Syntax']/../following-sibling::*",
];
const matches = [];
xpaths.forEach((xpath) => {
matches.push(
helpDoc.evaluate(xpath, helpDoc, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue
);
});
matches.forEach((match) => {
if (match) {
summary += match.innerHTML + "\n";
}
});
return summary;
},
},
};
</script>
@@ -1,65 +0,0 @@
import MockAdapter from "axios-mock-adapter";
import axios from "axios";
import { filterToolSections, filterTools } from "../utilities";
describe("ToolBox", () => {
const toolsMock = [
{
model_class: "ToolSection",
id: "group_1",
name: "Group 1",
elems: [
{
model_class: "Tool1",
id: "tool1",
name: "Tool 1",
link: "/tool_runner?tool_id=tool1",
panel_section_id: "group_1",
panel_section_name: "Group 1",
},
{
model_class: "Tool2",
id: "tool2",
name: "Tool 2",
link: "/tool_runner?tool_id=tool2",
panel_section_id: "group_1",
panel_section_name: "Group 1",
},
],
},
{
model_class: "ToolSection",
id: "group_2",
name: "Group 2",
elems: [
{
model_class: "Tool3",
id: "tool3",
name: "Tool 3",
link: "/tool_runner?tool_id=tool3",
panel_section_id: "group_2",
panel_section_name: "Group 2",
},
],
},
];
const resultsMock = ["tool1", "tool3"];
let axiosMock;
beforeEach(async () => {
axiosMock = new MockAdapter(axios);
});
it("test filter functions correctly matching: (1) Tools store array-of-objects with (2) Results array", async () => {
axiosMock
.onGet(`/api/tools`)
.replyOnce(200, toolsMock)
.onGet(/api\/tools?.*/)
.replyOnce(200, resultsMock);
const toolsResults = filterTools(toolsMock, resultsMock);
const toolsResultsSection = filterToolSections(toolsMock, resultsMock);
expect(toolsResults.length).toBe(2);
expect(toolsResultsSection.length).toBe(2);
});
});
@@ -1,5 +1,6 @@
import { mount } from "@vue/test-utils";
import { getLocalVue } from "jest/helpers";
import DelayedInput from "components/Common/DelayedInput";
import ToolSearch from "./ToolSearch";
const localVue = getLocalVue();
@@ -8,7 +9,9 @@ describe("ToolSearch", () => {
const wrapper = mount(ToolSearch, {
propsData: {
currentPanelView: "default",
enableAdvanced: false,
showAdvanced: false,
toolbox: [],
},
localVue,
stubs: {
@@ -16,17 +19,23 @@ describe("ToolSearch", () => {
},
});
expect(wrapper.find("[data-description='toggle advanced search']").exists()).toBe(false);
expect(wrapper.find("[description='advanced tool filters']").exists()).toBe(false);
await wrapper.setProps({ showAdvanced: true });
await wrapper.setProps({ enableAdvanced: true, showAdvanced: true });
expect(wrapper.find("[data-description='toggle advanced search']").exists()).toBe(true);
expect(wrapper.find("[description='advanced tool filters']").exists()).toBe(true);
const filterInputs = {
"[placeholder='any tool name']": "name-filter",
name: "name-filter",
"[placeholder='any section']": "section-filter",
"[placeholder='any id']": "id-filter",
"[placeholder='any description']": "desc-filter",
"[placeholder='any help text']": "help-filter",
};
// Now add filters in all input fields in the advanced menu
// Add name filter (comes from DelayedInput emitting the query prop)
wrapper.findComponent(DelayedInput).vm.$emit("change", filterInputs["name"]);
// Now add remaining filters (other than name) in the advanced menu
Object.entries(filterInputs).forEach(([selector, value]) => {
const filterInput = wrapper.find(selector);
if (filterInput.vm && filterInput.props().type == "text") {
@@ -37,16 +46,17 @@ describe("ToolSearch", () => {
// Test: values are stored in the filterSettings object
expect(Object.values(wrapper.vm.filterSettings)).toEqual(Object.values(filterInputs));
// Test: clearing the filters
const clearButton = wrapper.find("[description='clear filters']");
expect(Object.values(wrapper.vm.filterSettings).length).toBe(4);
await clearButton.trigger("click");
expect(Object.values(wrapper.vm.filterSettings).length).toBe(0);
// Test: changing panel view should change search by section field to search by ontology
expect(wrapper.find("[placeholder='any section']").exists()).toBe(true);
await wrapper.setProps({ currentPanelView: "ontology:edam_operations" });
expect(wrapper.find("[placeholder='any section']").exists()).toBe(false);
expect(wrapper.find("[placeholder='any ontology']").exists()).toBe(true);
await wrapper.setProps({ currentPanelView: "default" });
// Test: keyup.esc (should toggle the view out)
const nameField = wrapper.find("[placeholder='any tool name']");
// Test: keyup.esc (should toggle the view out) --- doesn't work from name (DelayedInput) field
const sectionField = wrapper.find("[placeholder='any section']");
expect(wrapper.emitted()["update:show-advanced"]).toBeUndefined();
await nameField.trigger("keyup.esc");
await sectionField.trigger("keyup.esc");
expect(wrapper.emitted()["update:show-advanced"].length - 1).toBeFalsy();
});
});
@@ -1,36 +1,39 @@
<template>
<div>
<small v-if="showAdvanced">Filter by name:</small>
<DelayedInput
:class="!showAdvanced && 'mb-3'"
:query="query"
:loading="loading"
:show-advanced="showAdvanced"
include-adv-btn
:placeholder="placeholder"
:enable-advanced="enableAdvanced"
:placeholder="showAdvanced ? 'any name' : placeholder"
@change="checkQuery"
@onToggle="onToggle" />
<div
v-if="showAdvanced"
class="mt-2"
description="advanced tool filters"
@keyup.enter="onSearch"
@keyup.esc="onToggle(false)">
<small>Filter by tool name:</small>
<b-form-input v-model="filterSettings['name']" size="sm" placeholder="any tool name" />
<small class="mt-1">Filter by section:</small>
<b-form-input v-model="filterSettings['panelSectionName']" size="sm" placeholder="any section" />
<small class="mt-1">Filter by {{ sectionLabel }}:</small>
<b-form-input
v-model="filterSettings['section']"
autocomplete="off"
size="sm"
:placeholder="`any ${sectionLabel}`"
list="sectionSelect" />
<b-form-datalist id="sectionSelect" :options="sectionNames"></b-form-datalist>
<small class="mt-1">Filter by id:</small>
<b-form-input v-model="filterSettings['id']" size="sm" placeholder="any id" />
<small class="mt-1">Filter by description:</small>
<b-form-input v-model="filterSettings['description']" size="sm" placeholder="any description" />
<small class="mt-1">Filter by help text:</small>
<b-form-input v-model="filterSettings['help']" size="sm" placeholder="any help text" />
<div class="mt-3">
<b-button class="mr-1" size="sm" variant="primary" @click="onSearch">
<icon icon="search" />
<span>{{ "Search" | localize }}</span>
</b-button>
<b-button size="sm" description="clear filters" @click="filterSettings = {}">
<b-button size="sm" @click="onToggle(false)">
<icon icon="redo" />
<span>{{ "Clear" | localize }}</span>
<span>{{ "Cancel" | localize }}</span>
</b-button>
</div>
</div>
@@ -38,10 +41,9 @@
</template>
<script>
import axios from "axios";
import { getAppRoot } from "onload/loadConfig";
import { getGalaxyInstance } from "app";
import DelayedInput from "components/Common/DelayedInput";
import { normalizeTools, searchToolsByKeys } from "../utilities.js";
export default {
name: "ToolSearch",
@@ -53,6 +55,10 @@ export default {
type: String,
required: true,
},
enableAdvanced: {
type: Boolean,
default: false,
},
placeholder: {
type: String,
default: "search tools",
@@ -65,12 +71,15 @@ export default {
type: Boolean,
default: false,
},
toolbox: {
type: Array,
required: true,
},
},
data() {
return {
favorites: ["#favs", "#favorites", "#favourites"],
minQueryLength: 3,
loading: false,
filterSettings: {},
};
},
@@ -79,27 +88,29 @@ export default {
const Galaxy = getGalaxyInstance();
return Galaxy.user.getFavorites().tools;
},
sectionNames() {
return this.toolbox.map((section) =>
section.name !== undefined && section.name !== "Uncategorized" ? section.name : ""
);
},
sectionLabel() {
return this.currentPanelView === "default" ? "section" : "ontology";
},
toolsList() {
return normalizeTools(this.toolbox);
},
},
methods: {
checkQuery(q) {
this.filterSettings["name"] = q;
this.$emit("onQuery", q);
if (q && q.length >= this.minQueryLength) {
if (this.favorites.includes(q)) {
this.$emit("onResults", this.favoritesResults);
} else {
this.loading = true;
axios
.get(`${getAppRoot()}api/tools`, {
params: { q, view: this.currentPanelView },
})
.then((response) => {
this.loading = false;
this.$emit("onResults", response.data);
})
.catch((err) => {
this.loading = false;
this.$emit("onError", err);
});
// keys with sorting order
const keys = { exact: 2, name: 1, description: 0 };
this.$emit("onResults", searchToolsByKeys(this.toolsList, keys, q));
}
} else {
this.$emit("onResults", null);
@@ -111,7 +122,7 @@ export default {
delete this.filterSettings[filter];
}
}
this.$router.push({ path: "/tools/advanced_search", query: this.filterSettings });
this.$router.push({ path: "/tools/list", query: this.filterSettings });
},
onToggle(toggleAdvanced) {
this.$emit("update:show-advanced", toggleAdvanced);
@@ -2,9 +2,9 @@ import { mount } from "@vue/test-utils";
import { getLocalVue } from "jest/helpers";
import ToolSection from "./ToolSection";
import { useConfig } from "composables/useConfig";
import { useConfig } from "composables/config";
jest.mock("composables/useConfig");
jest.mock("composables/config");
useConfig.mockReturnValue({
config: {
toolbox_auto_sort: true,
@@ -53,7 +53,7 @@ import ToolPanelLabel from "./ToolPanelLabel";
import ariaAlert from "utils/ariaAlert";
import ToolPanelLinks from "./ToolPanelLinks";
import { useConfig } from "composables/useConfig";
import { useConfig } from "composables/config";
export default {
name: "ToolSection",
@@ -0,0 +1,86 @@
import Vuex from "vuex";
import { mount } from "@vue/test-utils";
import { getLocalVue } from "jest/helpers";
import MockAdapter from "axios-mock-adapter";
import axios from "axios";
import { filterToolSections, filterTools } from "./utilities";
import ToolBox from "./ToolBox";
import toolsList from "./testToolsList";
import { userStore } from "store/userStore";
import { useConfig } from "composables/config";
jest.mock("composables/config");
useConfig.mockReturnValue({
config: {
toolbox_auto_sort: true,
},
isLoaded: true,
});
const localVue = getLocalVue();
describe("ToolBox", () => {
const toolsMock = toolsList;
const resultsMock = ["join1", "join_collections", "find1"];
let axiosMock;
const searches = {
join: ["join1", "join_collections", "find1"],
find: ["find1"],
remove: ["remove1", "remove_duplicate"],
fi: null,
};
let wrapper;
let store;
let state;
beforeEach(async () => {
axiosMock = new MockAdapter(axios);
store = new Vuex.Store({
modules: {
user: {
state,
actions: {
loadUser: jest.fn(),
},
getters: userStore.getters,
namespaced: true,
},
},
});
wrapper = mount(ToolBox, {
propsData: {
toolbox: toolsList,
currentPanelView: "default",
storedWorkflowMenuEntries: [],
},
store,
localVue,
stubs: {
icon: { template: "<div></div>" },
},
});
});
it("test filter functions correctly matching: (1) Tools store array-of-objects with (2) Results array", async () => {
axiosMock
.onGet(`/api/tools`)
.replyOnce(200, toolsMock)
.onGet(/api\/tools?.*/)
.replyOnce(200, resultsMock);
const toolsResults = filterTools(toolsMock, resultsMock);
const toolsResultsSection = filterToolSections(toolsMock, resultsMock);
expect(toolsResults.length).toBe(3);
expect(toolsResultsSection.length).toBe(2);
});
it("test toolbox client search", async () => {
for (const [query, results] of Object.entries(searches)) {
await wrapper.setData({ query: query });
expect(wrapper.vm.results).toEqual(results);
}
});
});
+8 -6
View File
@@ -3,12 +3,12 @@
<div unselectable="on">
<div class="unified-panel-header-inner">
<nav class="d-flex justify-content-between mx-3 my-2">
<h2 v-if="!showAdvanced" v-localize id="toolbox-heading" class="m-1 h-sm">Tools</h2>
<h2 v-else v-localize id="toolbox-heading" class="m-1 h-sm">Advanced Tool Search</h2>
<h2 v-if="!showAdvanced" id="toolbox-heading" v-localize class="m-1 h-sm">Tools</h2>
<h2 v-else id="toolbox-heading" v-localize class="m-1 h-sm">Advanced Tool Search</h2>
<div v-if="!showAdvanced" class="panel-header-buttons">
<div class="panel-header-buttons">
<b-button-group>
<favorites-button :query="query" @onFavorites="onQuery" />
<favorites-button v-if="!showAdvanced" :query="query" @onFavorites="onQuery" />
<panel-view-button
v-if="panelViews && Object.keys(panelViews).length > 1"
:panel-views="panelViews"
@@ -21,9 +21,11 @@
</div>
<div class="unified-panel-controls">
<tool-search
enable-advanced
:current-panel-view="currentPanelView"
:placeholder="titleSearchTools"
:show-advanced.sync="showAdvanced"
:toolbox="toolbox"
:query="query"
@onQuery="onQuery"
@onResults="onResults" />
@@ -72,7 +74,7 @@ import { UploadButton } from "components/Upload";
import { useGlobalUploadModal } from "composables/useGlobalUploadModal";
import FavoritesButton from "./Buttons/FavoritesButton";
import PanelViewButton from "./Buttons/PanelViewButton";
import { filterToolSections, filterTools } from "./utilities";
import { filterToolSections, filterTools, hasResults } from "./utilities";
import { getGalaxyInstance } from "app";
import { getAppRoot } from "onload";
import _l from "utils/localization";
@@ -133,7 +135,7 @@ export default {
if (this.showSections) {
return filterToolSections(this.toolbox, this.results);
} else {
return filterTools(this.toolbox, this.results);
return hasResults(this.results) ? filterTools(this.toolbox, this.results) : this.toolbox;
}
},
isUser() {
@@ -18,9 +18,16 @@
<tool-search
:current-panel-view="currentPanelView"
placeholder="search tools"
:toolbox="toolbox"
:query="query"
@onQuery="onQuery"
@onResults="onResults" />
<div v-if="queryTooShort" class="pb-2">
<b-badge class="alert-danger w-100">Search string too short!</b-badge>
</div>
<div v-else-if="noResults" class="pb-2">
<b-badge class="alert-danger w-100">No results found!</b-badge>
</div>
</div>
<div class="unified-panel-body">
<div class="toolMenuContainer">
@@ -108,6 +115,12 @@ export default {
};
},
computed: {
queryTooShort() {
return this.query && this.query.length < 3;
},
noResults() {
return this.query && this.results.length === 0;
},
hasWorkflowSection() {
return this.workflows.length > 0;
},
@@ -0,0 +1,72 @@
[
{
"model_class": "ToolSection",
"id": "group_1",
"name": "Group 1",
"elems": [
{
"model_class": "Tool",
"name": "Join",
"id": "join1",
"description": "two files",
"panel_section_id": "group_1",
"panel_section_name": "Group 1"
},
{
"model_class": "Tool",
"name": "Remove",
"id": "remove1",
"description": "lines",
"panel_section_id": "group_1",
"panel_section_name": "Group 1"
},
{
"model_class": "Tool",
"name": "Cut",
"id": "cut1",
"description": "lines",
"panel_section_id": "group_1",
"panel_section_name": "Group 1"
},
{
"model_class": "Tool",
"name": "Find",
"id": "find1",
"description": "joined dataset",
"panel_section_id": "group_1",
"panel_section_name": "Group 1"
}
]
},
{
"model_class": "ToolSection",
"id": "group_2",
"name": "Group 2",
"elems": [
{
"model_class": "Tool",
"name": "Join Collections",
"id": "join_collections",
"description": "",
"panel_section_id": "group_2",
"panel_section_name": "Group 2"
},
{
"model_class": "Tool",
"name": "Create Collections",
"id": "create_collections",
"description": "from datasets",
"panel_section_id": "group_2",
"panel_section_name": "Group 2"
}
]
},
{
"model_class": "Tool",
"name": "Remove duplicate",
"id": "remove_duplicate",
"description": "lines from dataset",
"panel_section_id": "remove_duplicate",
"panel_section_name": "Remove duplicate"
}
]
+88 -13
View File
@@ -1,20 +1,64 @@
/**
* Utilities file for Tool Search (panel/client search + advanced/backend search)
*/
import { orderBy } from "lodash";
const TOOLS_RESULTS_SORT_LABEL = "apiSort";
const TOOLS_RESULTS_SECTIONS_HIDE = ["Expression Tools"];
// - Takes filterSettings = {"name": "Tool Name", "section": "Collection", ...}
// - Takes panelView (if not 'default', does ontology search at backend)
// - Takes toolbox (to find ontology id if given ontology name)
// - Returns parsed Whoosh query
// e.g. fn call: createWhooshQuery(filterSettings, 'ontology:edam_topics', toolbox)
// can return:
// query = "(name:(skew) name_exact:(skew) description:(skew)) AND (edam_topics:(topic_0797) AND )"
export function createWhooshQuery(filterSettings, panelView, toolbox) {
let query = "(";
// add description+name_exact fields = name, to do a combined OrGroup at backend
const name = filterSettings["name"];
if (name) {
query += "name:(" + name + ") ";
query += "name_exact:(" + name + ") ";
query += "description:(" + name + ")";
}
query += ") AND (";
for (const [key, filterValue] of Object.entries(filterSettings)) {
// get ontology keys if view is not default
if (key === "section" && panelView !== "default") {
const ontology = toolbox.find(({ name }) => name && name.toLowerCase().match(filterValue.toLowerCase()));
if (ontology) {
let ontologyKey = "";
if (panelView === "ontology:edam_operations") {
ontologyKey = "edam_operations";
} else if (panelView === "ontology:edam_topics") {
ontologyKey = "edam_topics";
}
query += ontologyKey + ":(" + ontology.id + ") AND ";
} else {
query += key + ":(" + filterValue + ") AND ";
}
} else if (key == "id") {
query += "id_exact:(" + filterValue + ") AND ";
} else if (key != "name") {
query += key + ":(" + filterValue + ") AND ";
}
}
query += ")";
return query;
}
// Given toolbox and search results, returns filtered tool results
export function filterTools(tools, results) {
let toolsResults = [];
if (hasResults(results)) {
tools = normalizeTools(tools);
toolsResults = mapToolsResults(tools, results);
toolsResults = sortToolsResults(toolsResults);
} else {
toolsResults = tools;
}
tools = normalizeTools(tools);
toolsResults = mapToolsResults(tools, results);
toolsResults = sortToolsResults(toolsResults);
toolsResults = removeDuplicateResults(toolsResults);
return toolsResults;
}
// Given toolbox and search results, returns filtered tool results by sections
export function filterToolSections(tools, results) {
let toolsResults = [];
let toolsResultsSection = [];
@@ -23,7 +67,6 @@ export function filterToolSections(tools, results) {
tools = flattenToolsSection(section);
toolsResultsSection = mapToolsResults(tools, results);
toolsResultsSection = sortToolsResults(toolsResultsSection);
return {
...section,
elems: toolsResultsSection,
@@ -36,7 +79,31 @@ export function filterToolSections(tools, results) {
return toolsResults;
}
function normalizeTools(tools) {
export function hasResults(results) {
return Array.isArray(results) && results.length > 0;
}
// Given toolbox, keys to sort/search results by and a search query,
// Returns tool ids sorted by order of keys that are being searched
export function searchToolsByKeys(tools, keys, query) {
const returnedTools = [];
for (const tool of tools) {
for (const key of Object.keys(keys)) {
const actualValue = tool[key] ? tool[key].toLowerCase() : "";
const queryLowerCase = query.toLowerCase();
if (actualValue.match(queryLowerCase)) {
// do we care for exact matches && is it an exact match ?
const order = keys.exact && actualValue === queryLowerCase ? keys.exact : keys[key];
returnedTools.push({ id: tool.id, order });
break;
}
}
}
// sorting results by indexed order of keys
return orderBy(returnedTools, ["order"], ["desc"]).map((tool) => tool.id);
}
export function normalizeTools(tools) {
tools = hideToolsSection(tools);
tools = flattenTools(tools);
return tools;
@@ -66,6 +133,18 @@ function mapToolsResults(tools, results) {
return toolsResults;
}
function removeDuplicateResults(results) {
const uniqueTools = [];
return results.filter((tool) => {
if (!uniqueTools.includes(tool.id)) {
uniqueTools.push(tool.id);
return true;
} else {
return false;
}
});
}
function setSort(tool, results) {
return { [TOOLS_RESULTS_SORT_LABEL]: results.indexOf(tool.id) };
}
@@ -104,7 +183,3 @@ function flattenTools(tools) {
});
return normalizedTools;
}
function hasResults(results) {
return Array.isArray(results) && results.length > 0;
}
@@ -0,0 +1,53 @@
import toolsList from "components/ToolsView/testData/toolsList";
import { createWhooshQuery, filterTools, filterToolSections, normalizeTools, searchToolsByKeys } from "./utilities";
describe("test helpers in tool searching utilities", () => {
it("test parsing helper that converts settings to whoosh query", async () => {
const settings = {
name: "Filter",
id: "__FILTER_FAILED_DATASETS__",
help: "downstream",
};
const q = createWhooshQuery(settings, "default", []);
// OrGroup (at backend) on name, name_exact, description
expect(q).toContain("name:(Filter) name_exact:(Filter) description:(Filter)");
// AndGroup (explicit at frontend) on all other settings
expect(q).toContain("id_exact:(__FILTER_FAILED_DATASETS__) AND help:(downstream)");
});
it("test tool search helper that searches for tools given keys", async () => {
const q = "collection";
let expectedResults = [
"__FILTER_FAILED_DATASETS__",
"__FILTER_EMPTY_DATASETS__",
"__UNZIP_COLLECTION__",
"__ZIP_COLLECTION__",
];
let keys = { description: 1, name: 0 };
let results = searchToolsByKeys(normalizeTools(toolsList), keys, q);
expect(results).toEqual(expectedResults);
expectedResults = [
"__UNZIP_COLLECTION__",
"__ZIP_COLLECTION__",
"__FILTER_FAILED_DATASETS__",
"__FILTER_EMPTY_DATASETS__",
];
keys = { description: 0, name: 1 };
results = searchToolsByKeys(normalizeTools(toolsList), keys, q);
expect(results).toEqual(expectedResults);
});
it("test tool filtering helpers on toolsList given list of ids", async () => {
const ids = ["__FILTER_FAILED_DATASETS__", "liftOver1"];
// check length of first section from imported const toolsList
expect(toolsList.find((section) => section.id == "collection_operations").elems).toHaveLength(4);
// check length of same section from filtered toolsList
expect(
filterToolSections(toolsList, ids).find((section) => section.id == "collection_operations").elems
).toHaveLength(1);
// check length of filtered tools without sections
expect(filterTools(toolsList, ids)).toHaveLength(2);
});
});
+1 -1
View File
@@ -25,7 +25,7 @@ import { library } from "@fortawesome/fontawesome-svg-core";
import { faDownload, faSpinner } from "@fortawesome/free-solid-svg-icons";
library.add(faDownload, faSpinner);
import ConfigProvider from "components/providers/ConfigProvider";
import { Toast } from "ui/toast";
import { Toast } from "composables/toast";
import axios from "axios";
import { safePath } from "utils/redirect";
export default {
+16
View File
@@ -0,0 +1,16 @@
export default {
methods: {
showToast(message, title, variant) {
this.$bvToast.toast(message, {
variant,
title,
toaster: "b-toaster-bottom-right",
append: true,
solid: true,
});
},
},
render() {
return {};
},
};
@@ -1,6 +1,6 @@
<script setup>
import ToolSource from "./ToolSource.vue";
import { useConfig } from "composables/useConfig";
import { useConfig } from "composables/config";
import { useCurrentUser } from "composables/user";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
@@ -1,10 +1,21 @@
<template>
<ToolsProvider v-slot="{ loading, result: itemsLoaded }" :filter-settings="filterSettings">
<ToolsProvider
v-slot="{ loading, result: itemsLoaded }"
:filter-settings="filterSettings"
:toolbox="toolbox"
:panel-view="panelView">
<section class="overflow-auto h-100" @scroll="onScroll">
<div class="mb-2">
<h1 class="h-lg mb-2">Advanced Tool Search Results</h1>
<span v-if="itemsLoaded.length !== 0 && hasFilters" class="row">
<span class="col d-inline-block">
<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>
<span v-if="itemsLoaded.length !== 0" class="row">
<span v-if="hasFilters" class="col d-inline-block">
Found {{ itemsLoaded.length }} tools for
<a id="popover-filters" href="javascript:void(0)">filters</a>.
<b-popover target="popover-filters" triggers="hover" placement="top">
@@ -14,6 +25,11 @@
>: {{ 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.
</span>
<span v-else class="col d-inline-block">
No filters applied. Please add filters to the menu in the Tool Panel.
</span>
</span>
</div>
@@ -21,14 +37,11 @@
<b-alert v-if="loading" class="m-2" variant="info" show>
<LoadingSpan message="Loading Advanced Search Results" />
</b-alert>
<b-alert v-else-if="!hasFilters" class="m-2" variant="secondary" show>
Please add filters to the menu in the Tool Panel.
</b-alert>
<b-alert v-else-if="!itemsLoaded || itemsLoaded.length == 0" class="m-2" variant="info" show>
No tools found for the entered filters.
</b-alert>
<div v-else>
<ToolAdvancedSearchResults :tools="itemsLoaded" />
<ToolsListTable :tools="itemsLoaded" />
</div>
</div>
<ScrollToTopButton :offset="offset" @click="scrollToTop" />
@@ -39,13 +52,13 @@
<script>
import LoadingSpan from "components/LoadingSpan";
import { ToolsProvider } from "components/providers/storeProviders";
import ToolAdvancedSearchResults from "./ToolAdvancedSearchResults";
import ToolsListTable from "./ToolsListTable";
import ScrollToTopButton from "./ScrollToTopButton";
export default {
components: {
LoadingSpan,
ToolAdvancedSearchResults,
ToolsListTable,
ToolsProvider,
ScrollToTopButton,
},
@@ -54,7 +67,7 @@ export default {
type: String,
default: "",
},
panelSectionName: {
section: {
type: String,
default: "",
},
@@ -62,7 +75,7 @@ export default {
type: String,
default: "",
},
description: {
help: {
type: String,
default: "",
},
@@ -73,15 +86,17 @@ export default {
};
},
computed: {
toolbox() {
return this.$store.getters["panels/currentPanel"];
},
panelView() {
return this.$store.getters["panels/currentPanelView"];
},
filterSettings() {
const newFilterSettings = {};
Object.entries(this.$props).forEach(([filter, value]) => {
if (value && value !== "") {
if (filter === "panelSectionName") {
newFilterSettings["panel_section_name"] = value;
} else {
newFilterSettings[filter] = value;
}
newFilterSettings[filter] = value;
}
});
return newFilterSettings;
@@ -97,6 +112,9 @@ export default {
scrollToTop() {
this.$el.scrollTop = 0;
},
showAllTools() {
this.$router.push({ path: "/tools/list" });
},
},
};
</script>
@@ -0,0 +1,183 @@
<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>
<i v-if="allLoaded">All {{ tools.length > 1 ? tools.length : "" }} results loaded</i>
<b-overlay :show="busy" opacity="0.5" />
</div>
</div>
</template>
<script>
import Vue from "vue";
import _l from "utils/localization";
import infiniteScroll from "vue-infinite-scroll";
import { openGlobalUploadModal } from "components/Upload";
import { fetchData } from "./services";
const defaultBufferLen = 4;
const loadTimeout = 100;
export default {
directives: { infiniteScroll },
props: {
tools: {
type: Array,
default: null,
},
},
data() {
return {
allLoaded: false,
bufferLen: 0,
busy: false,
fields: [
{
key: "name",
label: _l("Name"),
sortable: true,
},
{
key: "section",
label: _l("Section"),
sortable: true,
},
{
key: "workflow",
label: _l("Workflow Compatible"),
sortable: false,
},
{
key: "target",
label: _l("Local Tool"),
sortable: false,
},
{
key: "open",
label: "",
},
],
};
},
computed: {
buffer() {
return this.tools.slice(0, this.bufferLen);
},
},
created() {
this.loadTools();
},
methods: {
onOpen(tool) {
if (tool.id === "upload1") {
openGlobalUploadModal();
} else if (tool.form_style === "regular") {
// encode spaces in tool.id
const toolId = tool.id;
const toolVersion = tool.version;
this.$router.push({ path: `/?tool_id=${encodeURIComponent(toolId)}&version=${toolVersion}` });
}
},
loadTools() {
if (this.tools && !this.busy && this.bufferLen < this.tools.length) {
this.busy = true;
setTimeout(() => {
this.bufferLen += defaultBufferLen;
this.tools.slice(this.bufferLen - defaultBufferLen, this.bufferLen).forEach(async (tool) => {
await this.fetchHelp(tool);
});
this.busy = false;
}, loadTimeout);
} else if (this.bufferLen >= this.tools.length) {
this.allLoaded = true;
}
},
async fetchHelp(tool) {
await fetchData(`api/tools/${tool.id}/build`).then((response) => {
const help = response.help;
Vue.set(tool, "_showDetails", false); // maybe not needed
if (help && help != "\n") {
Vue.set(tool, "help", help);
Vue.set(tool, "summary", this.parseHelp(help));
} else {
Vue.set(tool, "help", ""); // for cases where helpText == '\n'
}
});
},
parseHelp(help) {
let summary = "";
const parser = new DOMParser();
const helpDoc = parser.parseFromString(help, "text/html");
const xpaths = [
"//strong[text()='What it does']/../following-sibling::*",
"//strong[text()='What It Does']/../following-sibling::*",
"//h1[text()='Synopsis']/following-sibling::*",
"//strong[text()='Syntax']/../following-sibling::*",
];
const matches = [];
xpaths.forEach((xpath) => {
matches.push(
helpDoc.evaluate(xpath, helpDoc, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue
);
});
matches.forEach((match) => {
if (match) {
summary += match.innerHTML + "\n";
}
});
return summary;
},
},
};
</script>
+1 -1
View File
@@ -32,7 +32,7 @@ getAPIKey();
<template>
<section class="api-key d-flex flex-column">
<h1 class="h-lg" v-localize>Manage API Key</h1>
<h1 v-localize class="h-lg">Manage API Key</h1>
<span v-localize class="mb-2">
An API key will allow you to access via web API. Please note that this key acts as an alternate means to
@@ -6,7 +6,7 @@
</b-alert>
<hgroup class="cloud-auth-title">
<h1 class="h-lg" v-localize>Manage Cloud Authorization</h1>
<h1 v-localize class="h-lg">Manage Cloud Authorization</h1>
<nav class="operations">
<ul>
<li class="cloudKeyHelp">
@@ -3,13 +3,13 @@
<div class="text-center my-5">
<div v-if="allSourcesUnlimited">
<h2>You've got <b>unlimited</b> disk quota</h2>
<h3 class="h-sm" v-localize>All your storage sources have unlimited disk space. Enjoy!</h3>
<h3 v-localize class="h-sm">All your storage sources have unlimited disk space. Enjoy!</h3>
</div>
<div v-else>
<h2>
You've got <b> {{ niceTotalQuota }} </b> of total disk quota
</h2>
<h3 class="h-sm" v-localize>
<h3 v-localize class="h-sm">
This is the maximum disk space that you can use across all your storage sources. Unlimited storage
sources are not taken into account
</h3>
@@ -1,6 +1,6 @@
<template>
<b-container fluid class="p-0">
<h1 class="h-lg" v-localize>User preferences</h1>
<h1 v-localize class="h-lg">User preferences</h1>
<b-alert :variant="messageVariant" :show="!!message">
{{ message }}
</b-alert>
@@ -1,5 +1,5 @@
<template>
<Published :details="visualization">
<Published :item="visualization">
<template v-slot>
<CenterFrame :src="getUrl" />
</template>
@@ -12,7 +12,7 @@
<FormElement
:id="actionNames.ChangeDatatypeAction__newtype"
:value="formData[actionNames.ChangeDatatypeAction__newtype]"
:options="datatypeExtensions"
:attributes="{ options: datatypeExtensions }"
title="Change datatype"
type="select"
backbonejs
@@ -229,6 +229,7 @@ import WorkflowAttributes from "./Attributes";
import ZoomControl from "./ZoomControl";
import WorkflowNode from "./Node";
import Vue from "vue";
import { ConfirmDialog } from "composables/confirmDialog";
export default {
components: {
@@ -362,6 +363,11 @@ export default {
},
},
watch: {
id(newId, oldId) {
if (oldId) {
this._loadCurrent(newId);
}
},
annotation(newAnnotation, oldAnnotation) {
if (newAnnotation != oldAnnotation) {
this.hasChanges = true;
@@ -490,7 +496,7 @@ export default {
this.showInPanel = "attributes";
},
onEditSubworkflow(contentId) {
const editUrl = `/workflows/edit?id=${contentId}`;
const editUrl = `/workflows/edit?workflow_id=${contentId}`;
this.onNavigate(editUrl);
},
async onClone(node) {
@@ -536,7 +542,7 @@ export default {
if (stepCount < 10) {
this.copyIntoWorkflow(workflowId);
} else {
const confirmed = await this.$bvModal.msgBoxConfirm(
const confirmed = await ConfirmDialog.confirm(
`Warning this will add ${stepCount} new steps into your current workflow. You may want to consider using a subworkflow instead.`
);
if (confirmed) {
@@ -563,7 +569,7 @@ export default {
.then((response) => {
this.onWorkflowMessage("Workflow saved as", "success");
this.hideModal();
this.onNavigate(`${getAppRoot()}workflow/editor?id=${response.data}`, true);
this.onNavigate(`${getAppRoot()}workflows/edit?id=${response.data}`, true);
})
.catch((response) => {
this.onWorkflowError("Saving workflow failed, please contact an administrator.");
@@ -1,5 +1,5 @@
import * as d3 from "d3";
import { Toast } from "ui/toast";
import { Toast } from "composables/toast";
// Rendering parameters
const zIndex = 1000;
@@ -1,6 +1,6 @@
<script setup>
import TrsTool from "./TrsTool";
import { Toast } from "ui/toast";
import { Toast } from "composables/toast";
import { Services } from "../services";
import { getGalaxyInstance } from "app";
import { safePath } from "utils/redirect";
@@ -1,7 +1,7 @@
<script setup>
import { watch } from "vue";
import { BButton } from "bootstrap-vue";
import { useTaskMonitor } from "composables/useTaskMonitor";
import { useTaskMonitor } from "composables/taskMonitor";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
const { isRunning, isCompleted, hasFailed, requestHasFailed, waitForTask } = useTaskMonitor();
@@ -6,8 +6,8 @@ import ActionButton from "./ActionButton.vue";
import StsDownloadButton from "components/StsDownloadButton.vue";
import ExportToRemoteButton from "components/Workflow/Invocation/Export/ExportToRemoteButton.vue";
import ExportToRemoteModal from "components/Workflow/Invocation/Export/ExportToRemoteModal.vue";
import { useMarkdown } from "composables/useMarkdown";
import { Toast } from "ui/toast";
import { useMarkdown } from "composables/markdown";
import { Toast } from "composables/toast";
import axios from "axios";
const { renderMarkdown } = useMarkdown({ openLinksInNewPage: true });
@@ -2,7 +2,7 @@
import { ref, reactive, inject } from "vue";
import { BModal } from "bootstrap-vue";
import axios from "axios";
import { Toast } from "ui/toast";
import { Toast } from "composables/toast";
import { safePath } from "utils/redirect";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import ExternalLink from "components/ExternalLink";
@@ -12,7 +12,7 @@
<script>
import { safePath } from "utils/redirect";
import { urlData } from "utils/url";
import { Toast } from "ui/toast";
import { Toast } from "composables/toast";
import ConfigProvider from "components/providers/ConfigProvider";
import Markdown from "components/Markdown/Markdown";
import Vue from "vue";
@@ -1,4 +1,4 @@
import Invocations from "./Invocations";
import InvocationsList from "./InvocationsList";
import { mount } from "@vue/test-utils";
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
@@ -6,11 +6,14 @@ import { getLocalVue } from "jest/helpers";
import mockInvocationData from "./test/json/invocation.json";
import { parseISO, formatDistanceToNow } from "date-fns";
import { createPinia } from "pinia";
import "jest-location-mock";
const localVue = getLocalVue();
describe("Invocations.vue", () => {
const pinia = createPinia();
describe("InvocationsList.vue", () => {
let axiosMock;
let wrapper;
@@ -28,7 +31,7 @@ describe("Invocations.vue", () => {
const propsData = {
ownerGrid: false,
};
wrapper = mount(Invocations, {
wrapper = mount(InvocationsList, {
propsData,
localVue,
});
@@ -49,9 +52,10 @@ describe("Invocations.vue", () => {
const propsData = {
ownerGrid: false,
};
wrapper = mount(Invocations, {
wrapper = mount(InvocationsList, {
propsData,
localVue,
pinia,
});
});
@@ -68,9 +72,10 @@ describe("Invocations.vue", () => {
storedWorkflowName: "My Workflow",
storedWorkflowId: "abcde145678",
};
wrapper = mount(Invocations, {
wrapper = mount(InvocationsList, {
propsData,
localVue,
pinia,
});
});
@@ -92,7 +97,7 @@ describe("Invocations.vue", () => {
ownerGrid: false,
loading: false,
};
wrapper = mount(Invocations, {
wrapper = mount(InvocationsList, {
propsData,
computed: {
getWorkflowNameByInstanceId: (state) => (id) => "workflow name",
@@ -113,6 +118,7 @@ describe("Invocations.vue", () => {
},
},
localVue,
pinia,
});
});
@@ -80,14 +80,17 @@
</template>
<script>
import { mapCacheActions } from "vuex-cache";
import { mapGetters } from "vuex";
import { mapActions, mapState } from "pinia";
import { getAppRoot } from "onload/loadConfig";
import { getGalaxyInstance } from "app";
import { invocationsProvider } from "components/providers/InvocationsProvider";
import WorkflowInvocationState from "components/WorkflowInvocationState/WorkflowInvocationState";
import WorkflowRunButton from "./WorkflowRunButton.vue";
import UtcDate from "components/UtcDate";
import { mapCacheActions } from "vuex-cache";
import { mapGetters } from "vuex";
import { useWorkflowStore } from "stores/workflowStore";
import paginationMixin from "./paginationMixin";
export default {
@@ -125,7 +128,11 @@ export default {
};
},
computed: {
...mapGetters(["getWorkflowNameByInstanceId", "getWorkflowByInstanceId", "getStoredWorkflowIdByInstanceId"]),
...mapState(useWorkflowStore, [
"getWorkflowNameByInstanceId",
"getWorkflowByInstanceId",
"getStoredWorkflowIdByInstanceId",
]),
...mapGetters("history", ["getHistoryById", "getHistoryNameById"]),
title() {
let title = `Workflow Invocations`;
@@ -160,7 +167,7 @@ export default {
},
},
methods: {
...mapCacheActions(["fetchWorkflowForInstanceId"]),
...mapActions(useWorkflowStore, ["fetchWorkflowForInstanceId"]),
...mapCacheActions("history", ["loadHistoryById"]),
async provider(ctx) {
ctx.root = this.root;

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