diff --git a/.ci/flake8_ignorelist.txt b/.ci/flake8_ignorelist.txt index 298dace888f..f1cffc4f595 100644 --- a/.ci/flake8_ignorelist.txt +++ b/.ci/flake8_ignorelist.txt @@ -9,9 +9,7 @@ packages/venv node_modules database doc/build -doc/source/conf.py eggs -lib/galaxy/util/jstree.py lib/galaxy/web/proxy/js/node_modules static/maps static/scripts diff --git a/.ci/ignore-spelling.txt b/.ci/ignore-spelling.txt new file mode 100644 index 00000000000..62397276eda --- /dev/null +++ b/.ci/ignore-spelling.txt @@ -0,0 +1 @@ +hda diff --git a/.flake8 b/.flake8 index 4655c52d927..fe463fb975e 100644 --- a/.flake8 +++ b/.flake8 @@ -3,8 +3,7 @@ # B008 Do not perform function calls in argument defaults (for FastAPI Depends and Body) # E203 is whitespace before ':'; we follow black's formatting here. See https://black.readthedocs.io/en/stable/faq.html#why-are-flake8-s-e203-and-w503-violated # E402 module level import not at top of file # TODO, we would like to improve this. -# E501 is line length +# E501 is line length (delegated to black) # W503 is line breaks before binary operators, which has been reversed in PEP 8. # D** are docstring linting - which we mostly ignore except D302. (Hopefully we will solve more over time). ignore = B008,E203,E402,E501,W503,D100,D101,D102,D103,D104,D105,D106,D107,D200,D201,D202,D204,D205,D206,D207,D208,D209,D210,D211,D300,D301,D400,D401,D402,D403,D412,D413 -exclude = lib/galaxy/util/jstree.py diff --git a/.github/workflows/lint_openapi_schema.yml b/.github/workflows/lint_openapi_schema.yml new file mode 100644 index 00000000000..00c279d0d5a --- /dev/null +++ b/.github/workflows/lint_openapi_schema.yml @@ -0,0 +1,60 @@ +name: OpenAPI linting +on: + push: + paths-ignore: + - 'client/**' + - 'doc/**' + - 'lib/galaxy_test/selenium/**' + pull_request: + paths-ignore: + - 'client/**' + - 'doc/**' + - 'lib/galaxy_test/selenium/**' +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + validate-schema: + name: Validate OpenAPI schema + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ['3.7'] + steps: + - uses: actions/checkout@v3 + with: + path: 'galaxy root' + - uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Get full Python version + id: full-python-version + shell: bash + run: echo "version=$(python -c 'import sys; print("-".join(str(v) for v in sys.version_info))')" >> $GITHUB_OUTPUT + - name: Cache pip dir + uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: pip-cache-${{ matrix.python-version }}-${{ hashFiles('galaxy root/requirements.txt') }} + - name: Cache galaxy venv + uses: actions/cache@v3 + with: + path: 'galaxy root/.venv' + key: gxy-venv-${{ runner.os }}-${{ steps.full-python-version.outputs.version }}-${{ hashFiles('galaxy root/requirements.txt') }}-api + - name: Install dependencies + run: ./scripts/common_startup.sh --skip-client-build + working-directory: 'galaxy root' + - name: Lint schema + run: make lint-api-schema + working-directory: 'galaxy root' + - name: Build typescript schema + run: make update-client-api-schema + working-directory: 'galaxy root' + - name: Check for changes + run: | + if [[ `git status --porcelain` ]]; then + echo "Rebuilding client/src/schema/schema.ts resulted in changes, run 'make update-client-api-schema' and commit results" + exit 1 + fi + working-directory: 'galaxy root' diff --git a/.isort.cfg b/.isort.cfg index 2a7408c3311..c99585f7f27 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -1,6 +1,5 @@ [settings] combine_as_imports=true -extend_skip=doc/source/conf.py,lib/galaxy/util/jstree.py force_alphabetical_sort_within_sections=true # Override force_grid_wrap value from profile=black, but black is still happy force_grid_wrap=2 diff --git a/.redocly.lint-ignore.yaml b/.redocly.lint-ignore.yaml new file mode 100644 index 00000000000..01997597ceb --- /dev/null +++ b/.redocly.lint-ignore.yaml @@ -0,0 +1,16 @@ +# This file instructs Redocly's linter to ignore the rules contained for specific parts of your API. +# See https://redoc.ly/docs/cli/ for more information. +_schema.yaml: + no-empty-servers: + - '#/openapi' + no-ambiguous-paths: + - >- + #/paths/~1api~1histories~1{history_id}~1contents~1{dataset_id}~1permissions + - >- + #/paths/~1api~1histories~1{history_id}~1contents~1{history_content_id}~1display + - >- + #/paths/~1api~1histories~1{history_id}~1contents~1{history_content_id}~1extra_files + - >- + #/paths/~1api~1histories~1{history_id}~1contents~1{history_content_id}~1metadata_file + - '#/paths/~1api~1histories~1{history_id}~1contents~1{id}~1validate' + - '#/paths/~1api~1histories~1{history_id}~1contents~1{type}s~1{id}' diff --git a/.redocly.yaml b/.redocly.yaml new file mode 100644 index 00000000000..15897fe5d31 --- /dev/null +++ b/.redocly.yaml @@ -0,0 +1,5 @@ +organization: galaxyproject.org +extends: + - recommended +rules: + operation-4xx-response: off diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7de433d88aa..e876012fa8d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -157,8 +157,8 @@ https://help.github.com/en/github/getting-started-with-github/git-and-github-lea - Galaxy follows [PEP-8](https://www.python.org/dev/peps/pep-0008/), with particular emphasis on readability being the ultimate goal: - 4 spaces (not tabs!) per indentation level - - divergences from PEP-8 are listed in the `[flake8]` section of the - `.flake8` file + - divergences from PEP-8 are listed in the `[flake8]` section of the `.flake8` + file and in the `[tool.ruff]` section of the `pyproject.toml` file. - The Python code base is automatically formatted using [isort](https://pycqa.github.io/isort/) (for imports) and [black](https://black.readthedocs.io). To easily format your Python code diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index e38b9ea764f..87e587cac53 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -11,7 +11,7 @@ The following individuals have contributed code to Galaxy: * Patrick Austin * Raj Ayyampalayam * Abdulrahman Azab -* Finn Bacall +* Finn Bacall * Dannon Baker * balto * Christopher Bare diff --git a/Makefile b/Makefile index 62972721db6..d2748e0ff5d 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Location of virtualenv used for development. VENV?=.venv -# Source virtualenv to execute command (flake8, sphinx, twine, etc...) +# Source virtualenv to execute command (darker, sphinx, twine, etc...) IN_VENV=if [ -f "$(VENV)/bin/activate" ]; then . "$(VENV)/bin/activate"; fi; RELEASE_CURR:=23.0 RELEASE_UPSTREAM:=upstream @@ -170,8 +170,20 @@ else endif -update-client-api-schema: node-deps - $(IN_VENV) cd client && python ../scripts/dump_openapi_schema.py | yarn run openapi-typescript --output src/schema/schema.ts && npx prettier --write src/schema/schema.ts +build-api-schema: + $(IN_VENV) python scripts/dump_openapi_schema.py _schema.yaml + +remove-api-schema: + rm _schema.yaml + +update-client-api-schema: node-deps build-api-schema + $(IN_VENV) cd client && node openapi_to_schema.mjs ../_schema.yaml > src/schema/schema.ts && npx prettier --write src/schema/schema.ts + $(MAKE) remove-api-schema + +lint-api-schema: build-api-schema + $(IN_VENV) npx --yes @redocly/cli lint _schema.yaml + $(IN_VENV) codespell -I .ci/ignore-spelling.txt _schema.yaml + $(MAKE) remove-api-schema client: node-deps ## Rebuild client-side artifacts for local development. cd client && yarn run build diff --git a/SECURITY.md b/SECURITY.md index 77928a15c5b..2b30ab9b110 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -54,10 +54,11 @@ embargo, we will: - Patch the oldest release within the 12 month support window, and merge that fix forward. - Updates will be available on the `release_XX.YY` branches. - Update each release branch -- Post a notice to the [galaxy-announce mailing list](https://lists.galaxyproject.org/listinfo/galaxy-announce) with: +- Publish a repository security advisory on GitHub containing: - A description of the issue - List of supported versions that are affected - Steps to update or patch your Galaxy + - A CVE identifier (if applicable) If an issue is deemed to be time-sensitive – e.g. due to active and ongoing exploits in the wild – the embargo may be shortened considerably. diff --git a/client/.eslintrc.json b/client/.eslintrc.json index 9dc0251f439..f13b7808484 100644 --- a/client/.eslintrc.json +++ b/client/.eslintrc.json @@ -16,7 +16,7 @@ "rules": { // Standard rules "no-console": "off", - "no-unused-vars": ["error", { "args": "none" }], + "no-unused-vars": ["error", { "args": "none", "varsIgnorePattern": "[iI]gnoreUnused.*"}], "prefer-const": "error", "one-var": ["error", "never"], "curly": "error", diff --git a/client/docs/composables.md b/client/docs/composables.md index 2ba75d898fc..a7a22b5c6d3 100644 --- a/client/docs/composables.md +++ b/client/docs/composables.md @@ -87,7 +87,7 @@ While simpler in this example, you may need to manually mock more return values ## Using Composables for more than Stores -Composables can be of great use to extract any reactive code from your components. For an example of this, take a look at [userFilterObjectArray](https://github.com/galaxyproject/galaxy/blob/dev/client/src/composables/utils/filter.js). +Composables can be of great use to extract any reactive code from your components. For an example of this, take a look at [useFilterObjectArray](https://github.com/galaxyproject/galaxy/blob/dev/client/src/composables/utils/filter.js). Usage: diff --git a/client/docs/providers-and-renderers.md b/client/docs/providers-and-renderers.md index 480bef218ef..1eca93b3e13 100644 --- a/client/docs/providers-and-renderers.md +++ b/client/docs/providers-and-renderers.md @@ -134,7 +134,7 @@ mandatory markup, just one big empty slot. // Testing a renderless component import { shallowMount } from "@vue/test-utils"; -import { getLocalVue, waitForLifecyleEvent } from "jest/helpers"; +import { getLocalVue, waitForLifecyleEvent } from "tests/jest/helpers" import DoodadProvider from "./DoodadProvider"; describe("A renderless component", () => { diff --git a/client/docs/styleguide.md b/client/docs/styleguide.md new file mode 100644 index 00000000000..938ed22fc23 --- /dev/null +++ b/client/docs/styleguide.md @@ -0,0 +1,441 @@ +# Styleguide + +Most of the client's code style is handled by Prettier. Prettier does a good job of keeping an overall consistent code style, however there are some cases it cannot account for. +This document serves as a guide on how to style your code in such cases, with explanations as to why. +Treat it more like a set of recommendations than hard rules. + +## Naming + +Do not abbreviate. This includes naming variables, functions and modules. + +> **Do** +> +> ```js +> function errorMessageTemplate(workflowfName, errorMessage) { +> return `Failed to run ${workflowfName}. ${errorMessage}`; +> } +> ``` +> +> **Don't** +> +> ```js +> function eMsgTmpl(wfName, msg) { +> return `Failed to run ${wfName}. ${msg}`; +> } +> ``` + +> **Reason** +> +> While abbreviation may save a few keystrokes now, it will make the code harder to understand, and therefore maintain. Even when you think the abbreviations are obvious within this context, consider people looking at your code in the future might not have the same context you do when writing the code. + +## Functions + +There are several ways to define functions in JavaScript. + +```js +// named function +function myFunction(param) { + // do stuff +} + +// arrow functions +const myFunction = (param) => { + // do stuff +}; + +// anonymous functions +const myFunction = function(param) { + //do stuff +}; +``` + +Only use the first two. +`anonymous functions` have mostly been superseded by `arrow functions` + +### When to use the named functions + +Use named functions in the top-level module scope and to declare class methods. + +> **Reason** +> +> `function` is easy to process and understand at a glance. In module scope, arrow functions offer no benefit over regular functions, and they are not allowed as class methods. + +### When to use arrow functions + +Use arrow functions when declaring temporary functions within other scopes. This can be within another function, or inside a method that expects a callback function (eg. `array.forEach()`). + +> **Reason** +> +> Arrow functions offer benefits about the ambiguity of the `this` keyword within other scopes, as they do not provide their own `this` context. +> +> Binding them to a variable, also makes it clear that this function only exists within said scope, just like any other scoped variable. + +### When to use anonymous functions + +When possible, use arrow functions instead. + +### Examples + +> **Do** +> +> ```js +> // in myModules.js +> +> export function myFunction(parameter) { +> const addOne = (value) => { +> return value + 1; +> } +> // do more stuff... +> } +> +> ``` +> +> **Don't** +> +> ```js +> // in myModules.js +> +> export const myFunction = (parameter) => { +> const addOne = function(value) { +> return value + 1; +> } +> // do more stuff... +> } +> +> ``` + +## HTML Multi-Line Layout + +Prettier tries to respect whitespace when formatting your HTML templates, even when it doesn't need to. So for example this code: + +```vue +A very Long Button Text +``` + +Might get turned into: + +```vue +A very Long Button Text +``` + +Notice the strange positioning of the `>` brackets. + +In this case the formatting is equivalent to the much more readable: + +```vue + + A very Long Button Text + +``` + +Prettier does not know if our element has significant whitespace, or not. Check if your element has significant whitespace, and if it does not, reformat the HTML to avoid disjointed brackets. + +[Further reading about significant whitespace](https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model/Whitespace) + +## Spacing + +Prettier doesn't add empty lines to your code, but they can help in making it more readable. + +### Javascript + +Add an empty line between a block of variable definitions and other code. + +> **Do** +> +> ```js +> let a = 5; +> let b = 6; +> +> console.log(a + b); +> ``` +> +> **Don't** +> +> ```js +> let a = 5; +> let b = 6; +> console.log(a + b); +> ``` + +Add space between scopes. + +> **Do** +> +> ```js +> function myFunction(parameter) { +> if (condition) { +> // do stuff... +> } +> +> if (otherCondition) { +> // do more stuff... +> } +> } +> +> function otherFunction() { +> // do other stuff... +> } +> ``` +> +> **Don't** +> +> ```js +> function myFunction(parameter) { +> if (condition) { +> // do stuff... +> } +> if (otherCondition) { +> // do more stuff... +> } +> } +> function otherFunction() { +> // do other stuff... +> } +> ``` + +Add space between scopes and other code. + +> **Do** +> +> ```js +> const myConstant = 5; +> +> if (myConstant === 5) { +> // do stuff... +> } +> +> console.log("log stuff"); +> ``` +> +> **Don't** +> +> ```js +> const myConstant = 5; +> if (myConstant === 5) { +> // do stuff... +> } +> console.log("log stuff"); +> ``` + +### Vue Components + +Add spaces between the `script`, `template` and `style` blocks. + +> **Do** +> +> ```vue +> +> +> +> +> +> ``` +> +> **Don't** +> +> ```vue +> +> +> +> ``` + +### Vue Templates + +Do not add space between elements connected by conditionals. + +> **Do** +> +> ```vue +>
+> +> condition met +> +> +> condition not met +> +>
+> ``` +> +> **Don't** +> +> ```vue +>
+> +> condition met +> +> +> +> condition not met +> +>
+> ``` + +Add space between non-connected elements. + +> **Do** +> +> ```vue +>
+> +> First span. +> +> +> +> Second span. +> +>
+> ``` +> +> **Don't** +> +> ```vue +>
+> +> First span. +> +> +> Second span. +> +>
+> ``` + +Add space between logical blocks of elements. + +> **Do** +> +> ```vue +>
+> +> condition 1 met +> +> +> condition 1 not met +> +> +> +> condition 2 met +> +> +> condition 2 not met +> +>
+> ``` +> +> **Don't** +> +> ```vue +>
+> +> condition 1 met +> +> +> condition 1 not met +> +> +> condition 2 met +> +> +> condition 2 not met +> +>
+> ``` + +## Casting + +Use explicit boolean casting. + +> **Do** +> +> ```js +> const condition = Boolean(value); +> ``` +> +> **Don't** +> +> ```js +> const condition = !!value; +> ``` + +> **Reason** +> +> `!!` is not an intentional casting operator. The casting is a side-effect of the `!` operator. Using explicit casting is easier to understand at a glance. + +## Equality + +When possible, use strict equality. + +> **Do** +> +> ```js +> if (value === 5) { +> // do stuff... +> } +> +> if (otherValue !== 6) { +> // do more stuff... +> } +> ``` +> +> **Don't** +> +> ```js +> if (value == 5) { +> // do stuff... +> } +> +> if (otherValue != 6) { +> // do more stuff... +> } +> ``` + +> **Reason** +> +> Strict equality simply checks if two values are equal. Loose equality does type conversion under the hood and is a lot more complicated. While in most cases these do the same, due to the added complexity of loose equality, there are some edge cases where loose equality (`==`) can lead to unexpected problems. +> +> For consistency, treat `===` as the default for equality checking. +> +> [Read more on MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Strict_equality) + +## Default value assignment + +Use `??` to assign default values. + +> **Do** +> +> ```js +> const value = parameter ?? 0; +> ``` +> +> **Don't** +> +> ```js +> const value = parameter || 0; +> ``` + +> **Reason** +> +> The `??` operator uses the right hand value, when the left hand one is unassigned (`undefined` or `null`), while `||` does this on all falsely values (eg. `false` or `0`). This can lead to unexpected bugs in edge cases. +> +> [Read more on MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Nullish_coalescing#assigning_a_default_value_to_a_variable) + +### Working with unused variables + +Unused variables are marked as errors in eslint, however when destructuring objects to remove +certain keys you may be left with a variable you will not use further. You can +name the unused variable `ignoredUnused`. The `no-unused-vars` config setting in `client/.eslintrc` +contains `"varsIgnorePattern": "[iI]gnoreUnused.*"}` to ignore unused variables if they +start with `ignoredUnused` or `IgnoredUnused`. diff --git a/client/openapi_to_schema.mjs b/client/openapi_to_schema.mjs new file mode 100644 index 00000000000..7c158e74413 --- /dev/null +++ b/client/openapi_to_schema.mjs @@ -0,0 +1,14 @@ +// this is a helper script that fixes const values +// upstream fix in https://github.com/drwpow/openapi-typescript/pull/1014 +import openapiTS from "openapi-typescript"; + +const inputFilePath = process.argv[2]; + +const localPath = new URL(inputFilePath, import.meta.url); +openapiTS(localPath, { + transform(schemaObject, metadata) { + if ("const" in schemaObject && schemaObject.type === "string") { + return `"${schemaObject.const}"`; + } + }, +}).then((output) => console.log(output)); diff --git a/client/package.json b/client/package.json index 9f493a09502..777badca0eb 100644 --- a/client/package.json +++ b/client/package.json @@ -24,16 +24,19 @@ "dependencies": { "@fontsource/atkinson-hyperlegible": "^4.5.10", "@fortawesome/fontawesome-free": "^5.15.4", - "@fortawesome/fontawesome-svg-core": "^1.2.36", + "@fortawesome/fontawesome-svg-core": "^6.2.1", "@fortawesome/free-brands-svg-icons": "^5.15.4", "@fortawesome/free-regular-svg-icons": "^5.15.4", "@fortawesome/free-solid-svg-icons": "^5.15.4", - "@fortawesome/vue-fontawesome": "^2.0.6", - "@handsontable/vue": "^2.0.0-beta1", + "@fortawesome/vue-fontawesome": "^2.0.9", + "@handsontable/vue": "^2.0.0", "@hirez_io/observer-spy": "^2.1.2", "@johmun/vue-tags-input": "^2.1.0", + "@pinia/testing": "^0.0.14", "@popperjs/core": "^2.11.5", "@sentry/browser": "^6.17.4", + "@types/d3": "^7.4.0", + "@vueuse/core": "^9.10.0", "assert": "^2.0.0", "axios": "^0.25.0", "babel-runtime": "^6.26.0", @@ -43,7 +46,8 @@ "citation-js": "^0.6.4", "core-js": "^3.21.0", "csv-parse": "^5.3.0", - "d3": "3", + "d3": "^7.8.0", + "d3-zoom": "^3.0.0", "date-fns": "^2.28.0", "date-fns-tz": "^1.3.3", "decode-uri-component": "^0.2.1", @@ -53,7 +57,7 @@ "file-saver": "^2.0.5", "flush-promises": "^1.0.2", "glob": "^7.2.0", - "handsontable": "^2.0.0", + "handsontable": "^4.0.0", "hsluv": "^0.1.0", "imask": "^6.4.0", "jquery": "2", @@ -64,6 +68,7 @@ "linkify-html": "^3.0.5", "linkifyjs": "^3.0.5", "localforage": "^1.10.0", + "lodash.isequal": "^4.5.0", "markdown-it": "^12.3.2", "markdown-it-regexp": "^0.4.0", "object-hash": "^3.0.0", @@ -96,7 +101,7 @@ "vue-prismjs": "^1.2.0", "vue-router": "^3.6.5", "vue-rx": "^6.2.0", - "vue-virtual-scroll-list": "^2.3.3", + "vue-virtual-scroll-list": "^2.3.4", "vuedraggable": "2.24.3", "vuex": "^3.6.2", "vuex-cache": "^3.4.0", @@ -128,28 +133,23 @@ "eslint": "eslint -c .eslintrc.json --ext .js,.vue,.ts src tests/jest/standalone" }, "devDependencies": { - "@babel/core": "^7.18.9", - "@babel/eslint-parser": "^7.18.9", - "@babel/helper-validator-identifier": "^7.18.6", + "@babel/core": "^7.20.5", + "@babel/helper-validator-identifier": "^7.19.1", "@babel/plugin-syntax-dynamic-import": "^7.8.3", - "@babel/plugin-transform-runtime": "^7.18.9", - "@babel/preset-env": "^7.18.9", + "@babel/plugin-transform-runtime": "^7.19.6", + "@babel/preset-env": "^7.20.2", "@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", + "@typescript-eslint/eslint-plugin": "^5.45.1", + "@typescript-eslint/parser": "^5.45.1", + "@vue/test-utils": "^1.3.3", "@vue/tsconfig": "^0.1.3", - "@vue/vue2-jest": "^29.1.0", + "@vue/vue2-jest": "^29.2.1", "amdi18n-loader": "^0.9.3", "autoprefixer": "10.4.5", "axios-mock-adapter": "^1.20.0", - "babel-core": "^7.0.0-bridge.0", - "babel-jest": "^28.1.3", - "babel-loader": "^8.2.5", - "babel-plugin-transform-inline-environment-variables": "^0.4.4", - "babel-plugin-transform-vue-template": "^0.4.2", + "babel-jest": "^29.3.1", "buffer": "^6.0.3", "css-loader": "^6.7.1", "css-minimizer-webpack-plugin": "^4.0.0", @@ -162,9 +162,9 @@ "gulp": "^4.0.2", "ignore-loader": "^0.1.2", "imports-loader": "^4.0.0", - "jest": "^27.4.7", + "jest": "^29.3.1", + "jest-environment-jsdom": "^29.3.1", "jest-location-mock": "^1.0.9", - "jest-raw-loader": "^1.0.1", "json-loader": "^0.5.7", "karma": "^6.3.16", "karma-chrome-launcher": "^3.1.0", @@ -184,6 +184,7 @@ "style-loader": "^3.3.1", "ts-jest": "^29.0.3", "ts-loader": "^9.4.1", + "tsconfig-paths-webpack-plugin": "^4.0.0", "typescript": "^4.9.3", "typescript-eslint": "^0.0.1-alpha.0", "vue-loader": "^15.10.0", diff --git a/client/src/app/galaxy.js b/client/src/app/galaxy.js index 983503ed6ef..0b506a86abf 100644 --- a/client/src/app/galaxy.js +++ b/client/src/app/galaxy.js @@ -5,7 +5,7 @@ import BASE_MVC from "./base-mvc"; import userModel from "./user-model"; import metricsLogger from "utils/metrics-logger"; import addLogging from "utils/add-logging"; -import localize from "utils/localization"; +import { localize, _setUserLocale, _getUserLocale } from "utils/localization"; import { getGalaxyInstance } from "app"; import { create, dialog } from "utils/data"; @@ -77,8 +77,8 @@ GalaxyApp.prototype._init = function (options, bootstrapped) { this._initUser(options.user || {}); this.debug("GalaxyApp.user: ", this.user); - this.localize._setUserLocale(this.user, this.config); - this.localize._getUserLocale(); + _setUserLocale(this.user, this.config); + _getUserLocale(); this.debug("currentLocale: ", sessionStorage.getItem("currentLocale")); this._setUpListeners(); diff --git a/client/src/assets/images/largespinner.gif b/client/src/assets/images/largespinner.gif deleted file mode 100644 index 3288d1035d7..00000000000 Binary files a/client/src/assets/images/largespinner.gif and /dev/null differ diff --git a/client/src/bundleEntries.js b/client/src/bundleEntries.js index fd41c060c12..a6a83d2a68c 100644 --- a/client/src/bundleEntries.js +++ b/client/src/bundleEntries.js @@ -20,7 +20,6 @@ export { SweepsterVisualization, SweepsterVisualizationView } from "viz/sweepste export { createTabularDatasetChunkedView } from "mvc/dataset/data"; export { default as LegacyGridView } from "legacy/grid/grid-view"; export { create_chart, create_histogram } from "reports/run_stats"; -export { openGlobalUploadModal } from "components/Upload"; export { runTour } from "components/Tour/runTour"; export { Toast } from "ui/toast"; // TODO: remove when external consumers are updated/gone (IES right now) diff --git a/client/src/components/AboutGalaxy.vue b/client/src/components/AboutGalaxy.vue index 774db0f8a35..5b5de240c3c 100644 --- a/client/src/components/AboutGalaxy.vue +++ b/client/src/components/AboutGalaxy.vue @@ -4,11 +4,11 @@ import { computed } from "vue"; -import { getAppRoot } from "onload/loadConfig"; -import { useConfig } from "composables/config"; -import UtcDate from "components/UtcDate.vue"; -import License from "components/License/License.vue"; -import ExternalLink from "components/ExternalLink.vue"; +import { getAppRoot } from "@/onload/loadConfig"; +import { useConfig } from "@/composables/config"; +import UtcDate from "@/components/UtcDate.vue"; +import License from "@/components/License/License.vue"; +import ExternalLink from "@/components/ExternalLink.vue"; const { config, isLoaded } = useConfig(); diff --git a/client/src/components/AvailableDatatypes/AvailableDatatypes.vue b/client/src/components/AvailableDatatypes/AvailableDatatypes.vue index 079f9795680..ef876d343cf 100644 --- a/client/src/components/AvailableDatatypes/AvailableDatatypes.vue +++ b/client/src/components/AvailableDatatypes/AvailableDatatypes.vue @@ -1,11 +1,11 @@ + + diff --git a/client/src/components/Common/ExportRecordTable.vue b/client/src/components/Common/ExportRecordTable.vue new file mode 100644 index 00000000000..9eb72a3e533 --- /dev/null +++ b/client/src/components/Common/ExportRecordTable.vue @@ -0,0 +1,130 @@ + + + diff --git a/client/src/components/Common/exportsMixin.test.js b/client/src/components/Common/exportsMixin.test.js index 76ab1f86d9f..645cf6e37e3 100644 --- a/client/src/components/Common/exportsMixin.test.js +++ b/client/src/components/Common/exportsMixin.test.js @@ -1,5 +1,5 @@ import { shallowMount } from "@vue/test-utils"; -import { getLocalVue } from "jest/helpers"; +import { getLocalVue } from "tests/jest/helpers"; import exportsMixin from "./exportsMixin"; import axios from "axios"; import MockAdapter from "axios-mock-adapter"; diff --git a/client/src/components/Common/models/exportRecordModel.test.ts b/client/src/components/Common/models/exportRecordModel.test.ts new file mode 100644 index 00000000000..f6119019ee3 --- /dev/null +++ b/client/src/components/Common/models/exportRecordModel.test.ts @@ -0,0 +1,61 @@ +import { ExportRecordModel } from "./exportRecordModel"; +import { + EXPECTED_EXPIRATION_DATE, + EXPIRED_STS_DOWNLOAD_RESPONSE, + FAILED_DOWNLOAD_RESPONSE, + FILE_SOURCE_STORE_RESPONSE, + RECENT_STS_DOWNLOAD_RESPONSE, +} from "./testData/exportData"; + +describe("ExportRecordModel", () => { + describe("STS Download Record", () => { + const stsDownloadRecord = new ExportRecordModel(RECENT_STS_DOWNLOAD_RESPONSE); + + it("should be considered temporal (STS) when it has a short term storage ID defined", () => { + expect(stsDownloadRecord.isStsDownload).toBe(true); + expect(stsDownloadRecord.stsDownloadId).toBeTruthy(); + }); + + it("should allow download when ready and not yet expired", () => { + expect(stsDownloadRecord.isReady).toBe(true); + expect(stsDownloadRecord.hasExpired).toBe(false); + expect(stsDownloadRecord.canDownload).toBe(true); + }); + }); + + describe("Expired STS Download Record", () => { + const expiredDownloadRecord = new ExportRecordModel(EXPIRED_STS_DOWNLOAD_RESPONSE); + + it("should calculate the correct expiration date", () => { + expect(expiredDownloadRecord.canExpire).toBe(true); + expect(expiredDownloadRecord.expirationDate).toStrictEqual(EXPECTED_EXPIRATION_DATE()); + }); + + it("should not allow download when expired", () => { + expect(expiredDownloadRecord.hasExpired).toBe(true); + expect(expiredDownloadRecord.canDownload).toBe(false); + expect(expiredDownloadRecord.isReady).toBe(false); + }); + }); + + describe("Failed STS Download Record", () => { + const failedDownloadRecord = new ExportRecordModel(FAILED_DOWNLOAD_RESPONSE); + + it("should not be downloadable", () => { + expect(failedDownloadRecord.isReady).toBe(false); + expect(failedDownloadRecord.isPreparing).toBe(false); + expect(failedDownloadRecord.hasExpired).toBe(false); + expect(failedDownloadRecord.canDownload).toBe(false); + }); + }); + + describe("File Source Storage Record", () => { + const failedDownloadRecord = new ExportRecordModel(FILE_SOURCE_STORE_RESPONSE); + + it("should be importable", () => { + expect(failedDownloadRecord.isReady).toBe(true); + expect(failedDownloadRecord.canReimport).toBe(true); + expect(failedDownloadRecord.importUri).toBeTruthy(); + }); + }); +}); diff --git a/client/src/components/Common/models/exportRecordModel.ts b/client/src/components/Common/models/exportRecordModel.ts new file mode 100644 index 00000000000..6c9f6c3f0e3 --- /dev/null +++ b/client/src/components/Common/models/exportRecordModel.ts @@ -0,0 +1,148 @@ +import { formatDistanceToNow, parseISO } from "date-fns"; +import type { components } from "@/schema"; + +type ExportObjectRequestMetadata = components["schemas"]["ExportObjectRequestMetadata"]; + +export type StoreExportPayload = components["schemas"]["StoreExportPayload"]; +export type ObjectExportTaskResponse = components["schemas"]["ObjectExportTaskResponse"]; + +export class ExportParamsModel { + private _params: StoreExportPayload; + constructor(data: StoreExportPayload = {}) { + this._params = data; + } + + get modelStoreFormat() { + return this._params?.model_store_format; + } + + get includeFiles() { + return this._params?.include_files; + } + + get includeDeleted() { + return this._params?.include_deleted; + } + + get includeHidden() { + return this._params?.include_hidden; + } + + public equals(otherExportParams?: ExportParamsModel) { + if (!otherExportParams) { + return false; + } + return ( + this.modelStoreFormat === otherExportParams.modelStoreFormat && + this.includeFiles === otherExportParams.includeFiles && + this.includeDeleted === otherExportParams.includeDeleted && + this.includeHidden === otherExportParams.includeHidden + ); + } +} + +export class ExportRecordModel { + private _data: ObjectExportTaskResponse; + private _expirationDate?: Date | null; + private _requestMetadata?: ExportObjectRequestMetadata; + private _exportParameters?: ExportParamsModel; + + constructor(data: ObjectExportTaskResponse) { + this._data = data; + this._expirationDate = undefined; + this._requestMetadata = data.export_metadata?.request_data; + this._exportParameters = this._requestMetadata?.payload + ? new ExportParamsModel(this._requestMetadata?.payload) + : undefined; + } + + get isReady() { + return (this._data.ready && !this.hasExpired) ?? false; + } + + get isPreparing() { + return this._data.preparing ?? false; + } + + get isUpToDate() { + return this._data.up_to_date ?? false; + } + + get hasFailed() { + return !this.isReady && !this.isPreparing && !this.hasExpired; + } + + get date() { + return parseISO(`${this._data.create_time}Z`); + } + + get elapsedTime() { + return formatDistanceToNow(this.date, { addSuffix: true }); + } + + get taskUUID() { + return this._data.task_uuid; + } + + get importUri() { + const payload = this._requestMetadata?.payload; + return payload && "target_uri" in payload ? payload.target_uri : undefined; + } + + get canReimport() { + return this.isReady && Boolean(this.importUri); + } + + get stsDownloadId() { + const payload = this._requestMetadata?.payload; + return payload && "short_term_storage_request_id" in payload + ? payload.short_term_storage_request_id + : undefined; + } + + get isStsDownload() { + return Boolean(this.stsDownloadId); + } + + get canDownload() { + return this.isReady && this.isStsDownload && !this.hasExpired; + } + + get modelStoreFormat() { + return this.exportParams?.modelStoreFormat; + } + + get exportParams() { + return this._exportParameters; + } + + get duration() { + const payload = this._requestMetadata?.payload; + return payload && "duration" in payload ? payload.duration : undefined; + } + + get canExpire() { + return this.isStsDownload && Boolean(this.duration); + } + + get expirationDate() { + if (this._expirationDate === undefined) { + this._expirationDate = this.duration ? new Date(this.date.getTime() + this.duration * 1000) : null; + } + return this._expirationDate; + } + + get expirationElapsedTime() { + return this.canExpire && this.expirationDate + ? formatDistanceToNow(this.expirationDate, { addSuffix: true }) + : null; + } + + get hasExpired() { + return this.canExpire && this.expirationDate && Date.now() > this.expirationDate.getTime(); + } + + get errorMessage() { + return this._data?.export_metadata?.result_data?.error; + } +} diff --git a/client/src/components/Common/models/testData/exportData.ts b/client/src/components/Common/models/testData/exportData.ts new file mode 100644 index 00000000000..419af7290d3 --- /dev/null +++ b/client/src/components/Common/models/testData/exportData.ts @@ -0,0 +1,108 @@ +import { ExportRecordModel } from "@/components/Common/models/exportRecordModel"; +import type { components } from "@/schema"; + +type ObjectExportTaskResponse = components["schemas"]["ObjectExportTaskResponse"]; +type ExportObjectRequestMetadata = components["schemas"]["ExportObjectRequestMetadata"]; +type ExportObjectResultMetadata = components["schemas"]["ExportObjectResultMetadata"]; + +const PAST_EXPORT_DATE = new Date("11 November 2022 14:48 UTC").toISOString(); +const RECENT_EXPORT_DATE = new Date().toISOString(); +const STS_EXPORT_DURATION_IN_SECONDS = 86400; +export const EXPECTED_EXPIRATION_DATE = () => { + const expectedDate = new Date(PAST_EXPORT_DATE); + expectedDate.setSeconds(expectedDate.getSeconds() + STS_EXPORT_DURATION_IN_SECONDS); + return expectedDate; +}; + +const FAKE_STS_DOWNLOAD_REQUEST_DATA: ExportObjectRequestMetadata = { + object_id: "3cc0effd29705aa3", + object_type: "history", + user_id: "f597429621d6eb2b", + payload: { + model_store_format: "rocrate.zip", + include_files: true, + include_deleted: false, + include_hidden: false, + short_term_storage_request_id: "08bf4cc3-758e-4a9d-9fe4-a89a0d0604c7", + duration: STS_EXPORT_DURATION_IN_SECONDS, + }, +}; + +const FAKE_FILE_SOURCE_REQUEST_DATA: ExportObjectRequestMetadata = { + object_id: "3cc0effd29705aa3", + object_type: "history", + user_id: "f597429621d6eb2b", + payload: { + model_store_format: "tar.gz", + include_files: true, + include_deleted: false, + include_hidden: false, + target_uri: "gxfiles://fake-target-uri/test.tar.gz", + }, +}; + +const SUCCESS_EXPORT_RESULT_DATA: ExportObjectResultMetadata = { + success: true, + error: undefined, +}; + +const FAILED_EXPORT_RESULT_DATA: ExportObjectResultMetadata = { + success: false, + error: "Fake Error Message", +}; + +export const RECENT_STS_DOWNLOAD_RESPONSE: ObjectExportTaskResponse = { + id: "FAKE_RECENT_DOWNLOAD_ID", + ready: true, + preparing: false, + up_to_date: true, + task_uuid: "35563335-e275-4520-80e8-885793279095", + create_time: RECENT_EXPORT_DATE, + export_metadata: { + request_data: FAKE_STS_DOWNLOAD_REQUEST_DATA, + result_data: SUCCESS_EXPORT_RESULT_DATA, + }, +}; + +export const EXPIRED_STS_DOWNLOAD_RESPONSE: ObjectExportTaskResponse = { + id: "FAKE_EXPIRED_DOWNLOAD_ID", + ready: true, + preparing: false, + up_to_date: true, + task_uuid: "35563335-e275-4520-80e8-885793279095", + create_time: PAST_EXPORT_DATE, + export_metadata: { + request_data: FAKE_STS_DOWNLOAD_REQUEST_DATA, + result_data: SUCCESS_EXPORT_RESULT_DATA, + }, +}; + +export const FAILED_DOWNLOAD_RESPONSE: ObjectExportTaskResponse = { + id: "FAKE_FAILED_DOWNLOAD_ID", + ready: false, + preparing: false, + up_to_date: true, + task_uuid: "35563335-e275-4520-80e8-885793279095", + create_time: RECENT_EXPORT_DATE, + export_metadata: { + request_data: FAKE_STS_DOWNLOAD_REQUEST_DATA, + result_data: FAILED_EXPORT_RESULT_DATA, + }, +}; + +export const FILE_SOURCE_STORE_RESPONSE: ObjectExportTaskResponse = { + id: "FAKE_RECENT_DOWNLOAD_ID", + ready: true, + preparing: false, + up_to_date: true, + task_uuid: "35563335-e275-4520-80e8-885793279095", + create_time: RECENT_EXPORT_DATE, + export_metadata: { + request_data: FAKE_FILE_SOURCE_REQUEST_DATA, + result_data: SUCCESS_EXPORT_RESULT_DATA, + }, +}; + +export const EXPIRED_STS_DOWNLOAD_RECORD = new ExportRecordModel(EXPIRED_STS_DOWNLOAD_RESPONSE); +export const FILE_SOURCE_STORE_RECORD = new ExportRecordModel(FILE_SOURCE_STORE_RESPONSE); +export const RECENT_STS_DOWNLOAD_RECORD = new ExportRecordModel(RECENT_STS_DOWNLOAD_RESPONSE); diff --git a/client/src/components/DataDialog/DataDialog.vue b/client/src/components/DataDialog/DataDialog.vue index e2e73ec52a7..fcb6fe3654c 100644 --- a/client/src/components/DataDialog/DataDialog.vue +++ b/client/src/components/DataDialog/DataDialog.vue @@ -46,7 +46,7 @@ import { UrlTracker } from "./utilities"; import { Model } from "./model"; import { Services } from "./services"; import { getAppRoot } from "onload/loadConfig"; -import { mountUploadModal } from "components/Upload"; +import { useGlobalUploadModal } from "composables/globalUploadModal"; Vue.use(BootstrapVue); @@ -78,6 +78,10 @@ export default { default: true, }, }, + setup() { + const { openGlobalUploadModal } = useGlobalUploadModal(); + return { openGlobalUploadModal }; + }, data() { return { errorMessage: null, @@ -133,7 +137,7 @@ export default { modalShow: true, selectable: true, }; - mountUploadModal(propsData); + this.openGlobalUploadModal(propsData); this.modalShow = false; }, /** Called when selection is complete, values are formatted and parsed to external callback **/ diff --git a/client/src/components/Dataset/DatasetName.test.js b/client/src/components/Dataset/DatasetName.test.js index 0c6cc02968e..2a2e9062081 100644 --- a/client/src/components/Dataset/DatasetName.test.js +++ b/client/src/components/Dataset/DatasetName.test.js @@ -1,6 +1,6 @@ import { shallowMount } from "@vue/test-utils"; import DatasetName from "./DatasetName"; -import { getLocalVue } from "jest/helpers"; +import { getLocalVue } from "tests/jest/helpers"; const localVue = getLocalVue(); diff --git a/client/src/components/Dataset/DatasetStorage/DatasetStorage.test.js b/client/src/components/Dataset/DatasetStorage/DatasetStorage.test.js index e497c401d75..82112654392 100644 --- a/client/src/components/Dataset/DatasetStorage/DatasetStorage.test.js +++ b/client/src/components/Dataset/DatasetStorage/DatasetStorage.test.js @@ -1,6 +1,6 @@ import { shallowMount } from "@vue/test-utils"; import DatasetStorage from "./DatasetStorage"; -import { getLocalVue } from "jest/helpers"; +import { getLocalVue } from "tests/jest/helpers"; import flushPromises from "flush-promises"; import MockAdapter from "axios-mock-adapter"; import axios from "axios"; diff --git a/client/src/components/Dataset/services.ts b/client/src/components/Dataset/services.ts index 33d976e3318..d47b20d578b 100644 --- a/client/src/components/Dataset/services.ts +++ b/client/src/components/Dataset/services.ts @@ -1,6 +1,6 @@ -import { fetcher } from "schema"; -import { safePath } from "utils/redirect"; import type { FetchArgType } from "openapi-typescript-fetch"; +import { fetcher } from "@/schema"; +import { withPrefix } from "@/utils/redirect"; const _getDatasets = fetcher.path("/api/datasets").method("get").create(); type GetDatasetsApiOptions = FetchArgType; @@ -17,7 +17,7 @@ export async function getDatasets(options: GetDatasetsOptions = {}) { const params: GetDatasetsApiOptions = {}; if (options.sortBy) { const sortPrefix = options.sortDesc ? "-dsc" : "-asc"; - params.order = `${options.sortBy}${sortPrefix}&`; + params.order = `${options.sortBy}${sortPrefix}`; } if (options.limit) { params.limit = options.limit; @@ -67,7 +67,7 @@ export async function updateTags( export function getCompositeDatasetLink(historyDatasetId: string, path: string) { // TODO: historyDatasetId is wrong here, we should expose the route without forcing to provide a history id - return safePath(`/api/histories/${historyDatasetId}/contents/${historyDatasetId}/display?filename=${path}`); + return withPrefix(`/api/histories/${historyDatasetId}/contents/${historyDatasetId}/display?filename=${path}`); } const getDataset = fetcher.path("/api/histories/{history_id}/contents/{id}").method("get").create(); diff --git a/client/src/components/DatasetInformation/DatasetAttributes.test.js b/client/src/components/DatasetInformation/DatasetAttributes.test.js index 59b85428df9..97bd9d9fed2 100644 --- a/client/src/components/DatasetInformation/DatasetAttributes.test.js +++ b/client/src/components/DatasetInformation/DatasetAttributes.test.js @@ -1,6 +1,6 @@ import axios from "axios"; import { mount } from "@vue/test-utils"; -import { getLocalVue } from "jest/helpers"; +import { getLocalVue } from "tests/jest/helpers"; import DatasetAttributes from "./DatasetAttributes"; import MockProvider from "../providers/MockProvider"; import MockAdapter from "axios-mock-adapter"; diff --git a/client/src/components/DatasetInformation/DatasetDetails.vue b/client/src/components/DatasetInformation/DatasetDetails.vue index 9c202627343..b8a7dfdb10f 100644 --- a/client/src/components/DatasetInformation/DatasetDetails.vue +++ b/client/src/components/DatasetInformation/DatasetDetails.vue @@ -4,7 +4,7 @@ :id="datasetId" v-slot="{ result: dataset, loading: isDatasetLoading, error: datasetLoadingError }">
-

Dataset Details

+

Dataset Details

diff --git a/client/src/components/DatasetInformation/DatasetError.test.js b/client/src/components/DatasetInformation/DatasetError.test.js index 39c8b513a29..2bda611fec3 100644 --- a/client/src/components/DatasetInformation/DatasetError.test.js +++ b/client/src/components/DatasetInformation/DatasetError.test.js @@ -1,7 +1,8 @@ import { mount } from "@vue/test-utils"; -import { getLocalVue } from "jest/helpers"; +import { getLocalVue } from "tests/jest/helpers"; import DatasetError from "./DatasetError"; import MockProvider from "../providers/MockProvider"; +import MockCurrentUser from "../providers/MockCurrentUser"; jest.mock("components/providers", () => { return {}; // stubbed below @@ -33,6 +34,7 @@ function buildWrapper(has_duplicate_inputs = true, has_empty_inputs = true, user }), FontAwesomeIcon: false, FormElement: false, + CurrentUser: MockCurrentUser({ email: "email" }), }, }); } @@ -48,7 +50,6 @@ describe("DatasetError", () => { expect(messages.at(1).text()).toBe("message_2"); expect(wrapper.find("#dataset-error-has-empty-inputs")).toBeDefined(); expect(wrapper.find("#dataset-error-has-duplicate-inputs")).toBeDefined(); - expect(wrapper.findAll("#dataset-error-email").length).toBe(1); }); it("check props without common problems", async () => { diff --git a/client/src/components/DatasetInformation/DatasetError.vue b/client/src/components/DatasetInformation/DatasetError.vue index 29b1888a052..001bb403e0a 100644 --- a/client/src/components/DatasetInformation/DatasetError.vue +++ b/client/src/components/DatasetInformation/DatasetError.vue @@ -61,25 +61,25 @@ show >{{ resultMessage[0] }} -
- - - - Report - -
+ +
+ {{ emailTitle }} + {{ user.email }} + {{ "You must be logged in to receive emails" | l }} + + + Report + +
+
@@ -95,6 +95,7 @@ import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { library } from "@fortawesome/fontawesome-svg-core"; import { faBug } from "@fortawesome/free-solid-svg-icons"; import { sendErrorReport } from "./services"; +import CurrentUser from "components/providers/CurrentUser"; library.add(faBug); @@ -106,6 +107,7 @@ export default { FormElement, JobDetailsProvider, JobProblemProvider, + CurrentUser, }, props: { datasetId: { @@ -116,9 +118,9 @@ export default { data() { return { message: null, - email: null, errorMessage: null, resultMessages: [], + emailTitle: this.l("Your email address"), }; }, computed: { @@ -132,8 +134,8 @@ export default { onError(err) { this.errorMessage = err; }, - submit(dataset, userEmail) { - const email = this.email || userEmail; + submit(dataset, userEmailJob) { + const email = userEmailJob || this.currentUserEmail; const message = this.message; sendErrorReport(dataset, message, email).then( (resultMessages) => { diff --git a/client/src/components/DatasetInformation/DatasetInformation.test.js b/client/src/components/DatasetInformation/DatasetInformation.test.js index 5a8ade80420..84ef036a478 100644 --- a/client/src/components/DatasetInformation/DatasetInformation.test.js +++ b/client/src/components/DatasetInformation/DatasetInformation.test.js @@ -1,7 +1,7 @@ import axios from "axios"; import MockAdapter from "axios-mock-adapter"; import { mount } from "@vue/test-utils"; -import { getLocalVue } from "jest/helpers"; +import { getLocalVue } from "tests/jest/helpers"; import DatasetInformation from "./DatasetInformation"; import datasetResponse from "./testData/datasetResponse"; import flushPromises from "flush-promises"; diff --git a/client/src/components/Datatypes/model.ts b/client/src/components/Datatypes/model.ts index 6b1551b2d93..8d1e47f82bc 100644 --- a/client/src/components/Datatypes/model.ts +++ b/client/src/components/Datatypes/model.ts @@ -1,4 +1,4 @@ -import type { components } from "schema"; +import type { components } from "@/schema"; export type DatatypesCombinedMap = components["schemas"]["DatatypesCombinedMap"]; diff --git a/client/src/components/Datatypes/services.ts b/client/src/components/Datatypes/services.ts index 9ae24e8525e..59f98475768 100644 --- a/client/src/components/Datatypes/services.ts +++ b/client/src/components/Datatypes/services.ts @@ -1,4 +1,4 @@ -import { fetcher } from "schema/fetcher"; +import { fetcher } from "@/schema/fetcher"; const getTypesAndMappings = fetcher.path("/api/datatypes/types_and_mapping").method("get").create(); diff --git a/client/src/components/Form/Elements/FormBoolean.test.js b/client/src/components/Form/Elements/FormBoolean.test.js index 5e7855ee44f..16629137fbc 100644 --- a/client/src/components/Form/Elements/FormBoolean.test.js +++ b/client/src/components/Form/Elements/FormBoolean.test.js @@ -1,5 +1,5 @@ import { mount } from "@vue/test-utils"; -import { getLocalVue } from "jest/helpers"; +import { getLocalVue } from "tests/jest/helpers"; import FormBoolean from "./FormBoolean"; const localVue = getLocalVue(); @@ -18,13 +18,17 @@ describe("FormBoolean", () => { it("check initial value and value change", async () => { const input = wrapper.find("input"); - expect(wrapper.vm.currentValue).toBe(false); - await wrapper.setProps({ value: true }); - expect(wrapper.vm.currentValue).toBe(true); - await input.trigger("click"); - expect(input.element.checked).toBe(false); - await input.trigger("click"); - expect(input.element.checked).toBe(true); + const switchComponent = wrapper.findComponent(".custom-switch"); + expect(switchComponent.props().value).toBe(false); + await wrapper.setProps({ value: "true" }); expect(wrapper.emitted().input[0][0]).toBe(true); + await wrapper.setProps({ value: "false" }); + expect(wrapper.emitted().input[1][0]).toBe(false); + await wrapper.setProps({ value: true }); + expect(wrapper.emitted().input[2][0]).toBe(true); + await input.setChecked(false); + expect(wrapper.emitted().input[3][0]).toBe(false); + await input.setChecked(true); + expect(wrapper.emitted().input[4][0]).toBe(true); }); }); diff --git a/client/src/components/Form/Elements/FormBoolean.vue b/client/src/components/Form/Elements/FormBoolean.vue index 8784035c5c5..c2a30124d2f 100644 --- a/client/src/components/Form/Elements/FormBoolean.vue +++ b/client/src/components/Form/Elements/FormBoolean.vue @@ -1,28 +1,29 @@ + + - - diff --git a/client/src/components/Form/Elements/FormCheck.test.js b/client/src/components/Form/Elements/FormCheck.test.js new file mode 100644 index 00000000000..87c2829c6ce --- /dev/null +++ b/client/src/components/Form/Elements/FormCheck.test.js @@ -0,0 +1,91 @@ +import { mount } from "@vue/test-utils"; +import { getLocalVue } from "tests/jest/helpers"; +import MountTarget from "./FormCheck"; + +const localVue = getLocalVue(true); + +describe("FormCheck", () => { + let wrapper; + + beforeEach(() => { + wrapper = mount(MountTarget, { + propsData: { + value: null, + options: [], + }, + localVue, + }); + }); + + it("Confirm 'n + 1' checkboxes created (eg. includes the Select-All). Confirm labels and values match. Confirm correct values emitted.", async () => { + const noInput = wrapper.find("[type='checkbox']"); + expect(noInput.exists()).toBe(false); + const n = 3; + const options = []; + for (let i = 0; i < n; i++) { + options.push([`label_${i}`, `value_${i}`]); + } + await wrapper.setProps({ options }); + const inputs = wrapper.findAll("[type='checkbox']"); + const labels = wrapper.findAll(".custom-control-label"); + expect(inputs.length).toBe(n + 1); + const expectedValues = []; + for (let i = 0; i < n; i++) { + await inputs.at(i + 1).setChecked(); + expect(labels.at(i + 1).text()).toBe(`label_${i}`); + expect(inputs.at(i + 1).attributes("value")).toBe(`value_${i}`); + expectedValues.push(`value_${i}`); + expect(wrapper.emitted()["input"][i][0]).toEqual(expectedValues); + } + }); + + it("Confirm checkboxes are created when various 'empty values' are passed.", async () => { + const emptyValues = [0, null, false, true, undefined]; + const options = []; + for (let i = 0; i < emptyValues.length; i++) { + options.push([`label_${i}`, emptyValues[i]]); + } + await wrapper.setProps({ options }); + const inputs = wrapper.findAll("[type='checkbox']"); + expect(inputs.length).toBe(emptyValues.length + 1); + const expectedValues = []; + for (let i = 0; i < emptyValues; i++) { + await inputs.at(i + 1).setChecked(); + expect(inputs.at(i + 1).attributes("value")).toBe(emptyValues[i]); + expectedValues.push(expectedValues[i]); + expect(wrapper.emitted()["input"][i][0]).toEqual(expectedValues); + } + }); + + it("Confirm Select-All checkbox works in various states: select-all, unselect-all, indeterminate/partial-list-selection.", async () => { + const n = 3; + const options = []; + for (let i = 0; i < n; i++) { + options.push([`label_${i}`, `value_${i}`]); + } + await wrapper.setProps({ options }); + const inputs = wrapper.findAll("[type='checkbox']"); + /* confirm number of checkboxes requested matches number checkboxes created */ + expect(inputs.length).toBe(n + 1); + /* confirm component loads unchecked */ + for (let i = 0; i < n + 1; i++) { + expect(inputs.at(i).element.checked).toBeFalsy(); + } + /* 1 - confirm select-all option checked */ + await inputs.at(0).setChecked(); + expect(inputs.at(0).element.checked).toBeTruthy(); + /* ...confirm corresponding options checked */ + const values = options.map((option) => option[1]); + expect(wrapper.emitted()["input"][0][0]).toStrictEqual(values); + /* 2 - confirm select-all option UNchecked */ + await inputs.at(0).setChecked(false); + expect(inputs.at(0).element.checked).toBeFalsy(); + /* ...confirm corresponding options UNchecked */ + for (let i = 0; i < n; i++) { + expect(inputs.at(i + 1).element.checked).toBeFalsy(); + } + /* 3 - confirm corresponding options indeterminate-state */ + await inputs.at(1).setChecked(true); + expect(wrapper.find("input:indeterminate").exists()).toBe(true); + }); +}); diff --git a/client/src/components/Form/Elements/FormCheck.vue b/client/src/components/Form/Elements/FormCheck.vue new file mode 100644 index 00000000000..2450e623170 --- /dev/null +++ b/client/src/components/Form/Elements/FormCheck.vue @@ -0,0 +1,71 @@ + + + diff --git a/client/src/components/Form/Elements/FormColor.vue b/client/src/components/Form/Elements/FormColor.vue index 31e3156aa37..6bb30b94245 100644 --- a/client/src/components/Form/Elements/FormColor.vue +++ b/client/src/components/Form/Elements/FormColor.vue @@ -1,3 +1,29 @@ + + - diff --git a/client/src/components/Form/Elements/FormSelection.vue b/client/src/components/Form/Elements/FormSelection.vue index 4ba5e7dd90f..7698ad4b119 100644 --- a/client/src/components/Form/Elements/FormSelection.vue +++ b/client/src/components/Form/Elements/FormSelection.vue @@ -1,5 +1,6 @@ diff --git a/client/src/components/Form/Elements/FormText.test.js b/client/src/components/Form/Elements/FormText.test.js new file mode 100644 index 00000000000..0a4c2953b06 --- /dev/null +++ b/client/src/components/Form/Elements/FormText.test.js @@ -0,0 +1,78 @@ +import { mount } from "@vue/test-utils"; +import { getLocalVue } from "tests/jest/helpers"; +import FormText from "./FormText"; + +const localVue = getLocalVue(); + +describe("FormText", () => { + const mountFormText = async (props) => + await mount(FormText, { + propsData: props, + localVue, + }); + + it("should render the appropriate input type", async () => { + const wrapper = await mountFormText({}); + const el = wrapper.find("input[type='text']"); + expect(el.exists()).toBe(true); + await wrapper.setProps({ type: "password" }); + const elPassword = wrapper.find("input[type='password']"); + expect(elPassword.exists()).toBe(true); + await wrapper.setProps({ type: "anyothertype" }); + const elOtherType = wrapper.find("input[type='text']"); + expect(elOtherType.exists()).toBe(true); + }); + + it("should render the appropriate component", async () => { + const wrapper = await mountFormText({ area: true }); + const el = wrapper.find("textarea"); + expect(el.exists()).toBe(true); + await wrapper.setProps({ area: false, multiple: true }); + const elMultiple = wrapper.find("textarea"); + expect(elMultiple.exists()).toBe(true); + }); + + it("should be able to render a datalist", async () => { + const wrapper = await mountFormText({ id: "text-input", datalist: ["one", "two", "three"] }); + let el = wrapper.find("datalist"); + expect(el.exists()).toBe(true); + el = wrapper.find("option"); + expect(el.exists()).toBe(true); + el = wrapper.find("[list='text-input-datalist']"); + expect(el.exists()).toBe(true); + }); + + it("should be able to render border and text color from props", async () => { + const wrapper = await mountFormText({}); + const el = wrapper.find("input"); + expect(el.element.style).toMatchObject({}); + await wrapper.setProps({ color: "green" }); + expect(el.element.style).toMatchObject({ color: "green", "border-color": "green" }); + await wrapper.setProps({ cls: "my-custom-class" }); + expect(el.element.classList).toContain("my-custom-class"); + }); + + it("should be able to accept a default value", async () => { + const v = "something"; + const wrapper = await mountFormText({ value: v }); + const el = wrapper.find("input"); + expect(el.props("value")).toEqual(v); + }); + + it("should be able to accept an array as value", async () => { + const v = ["field_1", "field_2", "field_3"]; + const wrapper = await mountFormText({ value: v }); + const el = wrapper.find("input"); + expect(el.props("value")).toEqual("field_1"); + await wrapper.setProps({ multiple: true }); + const elMultiple = wrapper.find("textarea"); + expect(elMultiple.props("value")).toEqual("field_1\nfield_2\nfield_3\n"); + }); + + it("should be able to accept an empty array as value", async () => { + const v = []; + const wrapper = await mountFormText({ value: v }); + const el = wrapper.find("input"); + expect(el.props("value")).toEqual(""); + }); +}); diff --git a/client/src/components/Form/Elements/FormText.vue b/client/src/components/Form/Elements/FormText.vue new file mode 100644 index 00000000000..8a07155ae4f --- /dev/null +++ b/client/src/components/Form/Elements/FormText.vue @@ -0,0 +1,118 @@ + + + + diff --git a/client/src/components/Form/Elements/parameters.js b/client/src/components/Form/Elements/parameters.js index 40ad903c675..c9fbc13a745 100644 --- a/client/src/components/Form/Elements/parameters.js +++ b/client/src/components/Form/Elements/parameters.js @@ -8,7 +8,6 @@ import Ui from "mvc/ui/ui-misc"; import SelectContent from "mvc/ui/ui-select-content"; import SelectLibrary from "mvc/ui/ui-select-library"; import SelectFtp from "mvc/ui/ui-select-ftp"; -import RulesEdit from "mvc/ui/ui-rules-edit"; import DataPicker from "mvc/ui/ui-data-picker"; // create form view @@ -31,6 +30,11 @@ export default Backbone.View.extend({ data_dialog: "_fieldDialog", }, + remove: function () { + this.field.remove(); + Backbone.View.prototype.remove.call(this); + }, + /** Returns an input field for a given field type */ create: function (input_def) { const Galaxy = getGalaxyInstance(); @@ -197,14 +201,6 @@ export default Backbone.View.extend({ }); }, - _fieldRulesEdit: function (input_def) { - return new RulesEdit.View({ - id: input_def.id, - onchange: input_def.onchange, - target: input_def.target, - }); - }, - /** Upload file field */ _fieldUpload: function (input_def) { return new Ui.Upload({ diff --git a/client/src/components/Form/FormCard.test.js b/client/src/components/Form/FormCard.test.js index 5588befaa20..227cec8a4d8 100644 --- a/client/src/components/Form/FormCard.test.js +++ b/client/src/components/Form/FormCard.test.js @@ -1,5 +1,5 @@ import { mount } from "@vue/test-utils"; -import { getLocalVue } from "jest/helpers"; +import { getLocalVue } from "tests/jest/helpers"; import FormCard from "./FormCard"; const localVue = getLocalVue(); diff --git a/client/src/components/Form/FormDisplay.test.js b/client/src/components/Form/FormDisplay.test.js index 3443c2bffcb..ca802103b45 100644 --- a/client/src/components/Form/FormDisplay.test.js +++ b/client/src/components/Form/FormDisplay.test.js @@ -1,5 +1,5 @@ import { mount } from "@vue/test-utils"; -import { getLocalVue } from "jest/helpers"; +import { getLocalVue } from "tests/jest/helpers"; import FormDisplay from "./FormDisplay"; const localVue = getLocalVue(); diff --git a/client/src/components/Form/FormElement.test.js b/client/src/components/Form/FormElement.test.js index c361c6b616a..79a7770442f 100644 --- a/client/src/components/Form/FormElement.test.js +++ b/client/src/components/Form/FormElement.test.js @@ -1,8 +1,8 @@ import { mount } from "@vue/test-utils"; -import { getLocalVue } from "jest/helpers"; +import { getLocalVue } from "tests/jest/helpers"; import FormElement from "./FormElement"; import FormHidden from "./Elements/FormHidden"; -import FormInput from "./Elements/FormInput"; +import FormText from "./Elements/FormText"; const localVue = getLocalVue(); @@ -70,12 +70,17 @@ describe("FormElement", () => { it("check type matching", async () => { await wrapper.setProps({ type: "text" }); - expect(wrapper.findComponent(FormInput).exists()).toBe(true); + expect(wrapper.findComponent(FormText).exists()).toBe(true); expect(wrapper.findComponent(FormHidden).exists()).toBe(false); await wrapper.setProps({ attributes: { titleonly: true } }); expect(wrapper.findComponent(FormHidden).exists()).toBe(true); - expect(wrapper.findComponent(FormInput).exists()).toBe(false); + expect(wrapper.findComponent(FormText).exists()).toBe(false); + }); + + it("displays as the correct type if is_workflow is true", async () => { + await wrapper.setProps({ type: "data_column", attributes: { is_workflow: true } }); + expect(wrapper.findComponent(FormText).exists()).toBe(true); }); it("marks required values", async () => { diff --git a/client/src/components/Form/FormElement.vue b/client/src/components/Form/FormElement.vue index 3779ef7bab5..9e74614f742 100644 --- a/client/src/components/Form/FormElement.vue +++ b/client/src/components/Form/FormElement.vue @@ -1,110 +1,85 @@ - - - - diff --git a/client/src/components/History/Content/ContentOptions.vue b/client/src/components/History/Content/ContentOptions.vue index c2a9d8e4c49..330d7dd533b 100644 --- a/client/src/components/History/Content/ContentOptions.vue +++ b/client/src/components/History/Content/ContentOptions.vue @@ -17,6 +17,7 @@ v-if="isDataset" :disabled="displayDisabled" :title="displayButtonTitle" + :tabindex="tabindex" class="display-btn px-1" size="sm" variant="link" @@ -28,6 +29,7 @@ v-if="writable && isHistoryItem" :disabled="editDisabled" :title="editButtonTitle" + :tabindex="tabindex" class="edit-btn px-1" size="sm" variant="link" @@ -37,6 +39,7 @@ diff --git a/client/src/components/History/Content/Dataset/DatasetActions.vue b/client/src/components/History/Content/Dataset/DatasetActions.vue index f0a3697ae24..46fce47e359 100644 --- a/client/src/components/History/Content/Dataset/DatasetActions.vue +++ b/client/src/components/History/Content/Dataset/DatasetActions.vue @@ -71,7 +71,7 @@ + + diff --git a/client/src/components/History/Export/HistoryExport.test.ts b/client/src/components/History/Export/HistoryExport.test.ts new file mode 100644 index 00000000000..e2ec1de4f38 --- /dev/null +++ b/client/src/components/History/Export/HistoryExport.test.ts @@ -0,0 +1,103 @@ +import { shallowMount } from "@vue/test-utils"; +import axios from "axios"; +import MockAdapter from "axios-mock-adapter"; +import { + EXPIRED_STS_DOWNLOAD_RECORD, + FILE_SOURCE_STORE_RECORD, + RECENT_STS_DOWNLOAD_RECORD, +} from "@/components/Common/models/testData/exportData"; +import flushPromises from "flush-promises"; +import type { components } from "@/schema"; +import { getLocalVue } from "../../../../tests/jest/helpers"; +import HistoryExport from "./HistoryExport.vue"; +import { getExportRecords } from "./services"; + +const localVue = getLocalVue(true); + +jest.mock("./services"); +const mockGetExportRecords = getExportRecords as jest.MockedFunction; +mockGetExportRecords.mockResolvedValue([]); + +const FAKE_HISTORY_ID = "fake-history-id"; +const REMOTE_FILES_API_ENDPOINT = new RegExp("/api/remote_files/plugins"); + +type FilesSourcePluginList = components["schemas"]["FilesSourcePlugin"][]; +const REMOTE_FILES_API_RESPONSE: FilesSourcePluginList = [ + { + id: "test-posix-source", + type: "posix", + uri_root: "gxfiles://test-posix-source", + label: "TestSource", + doc: "For testing", + writable: true, + requires_roles: undefined, + requires_groups: undefined, + }, +]; + +async function mountHistoryExport() { + const wrapper = shallowMount(HistoryExport, { + propsData: { historyId: FAKE_HISTORY_ID }, + localVue, + }); + await flushPromises(); + return wrapper; +} + +describe("HistoryExport.vue", () => { + let axiosMock: MockAdapter; + + beforeEach(async () => { + axiosMock = new MockAdapter(axios); + axiosMock.onGet(REMOTE_FILES_API_ENDPOINT).reply(200, []); + }); + + afterEach(() => { + axiosMock.restore(); + }); + + it("should render export options", async () => { + const wrapper = await mountHistoryExport(); + + expect(wrapper.find("#history-export-options").exists()).toBe(true); + }); + + it("should display a message indicating there are no exports where there are none", async () => { + const wrapper = await mountHistoryExport(); + + expect(wrapper.find("#no-export-records-alert").exists()).toBe(true); + }); + + it("should render previous records when there is more than one record", async () => { + mockGetExportRecords.mockResolvedValue([ + RECENT_STS_DOWNLOAD_RECORD, + FILE_SOURCE_STORE_RECORD, + EXPIRED_STS_DOWNLOAD_RECORD, + ]); + const wrapper = await mountHistoryExport(); + + expect(wrapper.find("#previous-export-records").exists()).toBe(true); + }); + + it("should not render previous records when there is one or less records", async () => { + mockGetExportRecords.mockResolvedValue([RECENT_STS_DOWNLOAD_RECORD]); + const wrapper = await mountHistoryExport(); + + expect(wrapper.find("#previous-export-records").exists()).toBe(false); + }); + + it("should display file sources tab if there are available", async () => { + axiosMock.onGet(REMOTE_FILES_API_ENDPOINT).reply(200, REMOTE_FILES_API_RESPONSE); + const wrapper = await mountHistoryExport(); + + expect(wrapper.find("#direct-download-tab").exists()).toBe(true); + expect(wrapper.find("#file-source-tab").exists()).toBe(true); + }); + + it("should not display file sources tab if there are no file sources available", async () => { + const wrapper = await mountHistoryExport(); + + expect(wrapper.find("#direct-download-tab").exists()).toBe(true); + expect(wrapper.find("#file-source-tab").exists()).toBe(false); + }); +}); diff --git a/client/src/components/History/Export/HistoryExport.vue b/client/src/components/History/Export/HistoryExport.vue new file mode 100644 index 00000000000..36d908af4d3 --- /dev/null +++ b/client/src/components/History/Export/HistoryExport.vue @@ -0,0 +1,202 @@ + + diff --git a/client/src/components/History/Export/services.ts b/client/src/components/History/Export/services.ts new file mode 100644 index 00000000000..8798ae05e23 --- /dev/null +++ b/client/src/components/History/Export/services.ts @@ -0,0 +1,77 @@ +import { ExportRecordModel } from "@/components/Common/models/exportRecordModel"; +import type { ObjectExportTaskResponse } from "@/components/Common/models/exportRecordModel"; +import { DEFAULT_EXPORT_PARAMS } from "@/composables/shortTermStorage"; +import type { components } from "@/schema"; +import { fetcher } from "@/schema"; + +type ModelStoreFormat = components["schemas"]["ModelStoreFormat"]; + +const _getExportRecords = fetcher.path("/api/histories/{history_id}/exports").method("get").create(); +const _exportToFileSource = fetcher.path("/api/histories/{history_id}/write_store").method("post").create(); +const _importFromStoreAsync = fetcher.path("/api/histories/from_store_async").method("post").create(); + +/** + * A list of objects with the available export formats IDs and display names. + */ +export const AVAILABLE_EXPORT_FORMATS: { id: ModelStoreFormat; name: string }[] = [ + { id: "rocrate.zip", name: "RO-Crate" }, + { id: "tar.gz", name: "Compressed TGZ" }, +]; + +/** + * Gets a list of export records for the given history. + * @param historyId the encoded ID of the history + * @param params query and pagination params + * @returns a promise with a list of export records associated with the given history. + */ +export async function getExportRecords(historyId: string) { + const response = await _getExportRecords( + { + history_id: historyId, + }, + { + headers: { + Accept: "application/vnd.galaxy.task.export+json", + }, + } + ); + return response.data.map((item: unknown) => new ExportRecordModel(item as ObjectExportTaskResponse)); +} + +/** + * + * @param historyId the encoded ID of the history + * @param exportDirectory the output directory in the file source + * @param fileName the name of the output archive + * @param exportParams additional parameters to configure the export + * @returns A promise with the request response + */ +export async function exportToFileSource( + historyId: string, + exportDirectory: string, + fileName: string, + exportParams = DEFAULT_EXPORT_PARAMS +) { + const exportDirectoryUri = `${exportDirectory}/${fileName}.${exportParams.modelStoreFormat}`; + + return _exportToFileSource({ + history_id: historyId, + target_uri: exportDirectoryUri, + model_store_format: exportParams.modelStoreFormat as ModelStoreFormat, + include_files: exportParams.includeFiles, + include_deleted: exportParams.includeDeleted, + include_hidden: exportParams.includeHidden, + }); +} + +/** + * Imports a new history using the information stored in the given export record. + * @param record The export record to be imported + * @returns A promise with the request response + */ +export async function reimportHistoryFromRecord(record: ExportRecordModel) { + return _importFromStoreAsync({ + store_content_uri: record.importUri, + model_store_format: record.modelStoreFormat, + }); +} diff --git a/client/src/components/History/HistoryFilters.js b/client/src/components/History/HistoryFilters.js new file mode 100644 index 00000000000..e8d9dd9bb8e --- /dev/null +++ b/client/src/components/History/HistoryFilters.js @@ -0,0 +1,28 @@ +import Filtering, { compare, contains, equals, expandNameTag, toBool, toDate } from "utils/filtering"; + +export const validFilters = { + hid: equals("hid"), + state: equals("state"), + name: contains("name"), + extension: equals("extension"), + hid_ge: compare("hid", "ge"), + hid_gt: compare("hid", "gt"), + hid_le: compare("hid", "le"), + hid_lt: compare("hid", "lt"), + related: equals("related"), + tag: contains("tags", "tag", expandNameTag), + visible: equals("visible", "visible", toBool), + deleted: equals("deleted", "deleted", toBool), + create_time: compare("create_time", "le", toDate), + create_time_ge: compare("create_time", "ge", toDate), + create_time_gt: compare("create_time", "gt", toDate), + create_time_le: compare("create_time", "le", toDate), + create_time_lt: compare("create_time", "lt", toDate), + update_time: compare("update_time", "le", toDate), + update_time_ge: compare("update_time", "ge", toDate), + update_time_gt: compare("update_time", "gt", toDate), + update_time_le: compare("update_time", "le", toDate), + update_time_lt: compare("update_time", "lt", toDate), +}; + +export const HistoryFilters = new Filtering(validFilters, true); diff --git a/client/src/components/History/HistoryPublishedList.vue b/client/src/components/History/HistoryPublishedList.vue index d7f3a2dc1bd..5eb78a5dbae 100644 --- a/client/src/components/History/HistoryPublishedList.vue +++ b/client/src/components/History/HistoryPublishedList.vue @@ -1,13 +1,20 @@ + - - diff --git a/client/src/components/History/Multiple/MultipleViewList.vue b/client/src/components/History/Multiple/MultipleViewList.vue index 3f02a4d5dd4..481d2f3addc 100644 --- a/client/src/components/History/Multiple/MultipleViewList.vue +++ b/client/src/components/History/Multiple/MultipleViewList.vue @@ -1,3 +1,63 @@ + + - - diff --git a/client/src/components/Masthead/MastheadItem.test.js b/client/src/components/Masthead/MastheadItem.test.js index 3fcad3f2f8a..40a299f7aac 100644 --- a/client/src/components/Masthead/MastheadItem.test.js +++ b/client/src/components/Masthead/MastheadItem.test.js @@ -1,5 +1,5 @@ import { mount } from "@vue/test-utils"; -import { getLocalVue } from "jest/helpers"; +import { getLocalVue } from "tests/jest/helpers"; import MastheadItem from "./MastheadItem.vue"; describe("MastheadItem.vue", () => { diff --git a/client/src/components/Masthead/MastheadItem.vue b/client/src/components/Masthead/MastheadItem.vue index 00dcefeb971..b28a9dca7a1 100644 --- a/client/src/components/Masthead/MastheadItem.vue +++ b/client/src/components/Masthead/MastheadItem.vue @@ -2,7 +2,7 @@ import Vue from "vue"; import { VBPopoverPlugin, VBTooltipPlugin } from "bootstrap-vue"; import { BNavItem, BNavItemDropdown, BDropdownItem } from "bootstrap-vue"; -import { safePath } from "utils/redirect"; +import { withPrefix } from "utils/redirect"; import { ref, computed } from "vue"; import { getCurrentInstance } from "vue"; @@ -32,7 +32,7 @@ const props = defineProps({ /* computed */ const menu = computed(() => props.tab.menu); const popoverNote = computed( - () => `Please log in or register to use this feature.` + () => `Please log in or register to use this feature.` ); const classes = computed(() => { const isActiveTab = props.tab.id == props.activeTab; @@ -79,7 +79,7 @@ function open(tab, event) { v-b-tooltip.hover.bottom v-b-popover.manual.bottom="{ id: tab.id, content: popoverNote, html: true }" :class="classes" - :href="safePath(tab.url)" + :href="withPrefix(tab.url)" :target="tab.target || '_parent'" :link-classes="linkClasses" :title="tab.tooltip" @@ -114,7 +114,7 @@ function open(tab, event) { diff --git a/client/src/components/NewUserWelcome/NewUserWelcome.test.js b/client/src/components/NewUserWelcome/NewUserWelcome.test.js index 7de30edfb80..1f8738c19ee 100644 --- a/client/src/components/NewUserWelcome/NewUserWelcome.test.js +++ b/client/src/components/NewUserWelcome/NewUserWelcome.test.js @@ -1,5 +1,5 @@ import { shallowMount, mount } from "@vue/test-utils"; -import { getLocalVue } from "jest/helpers"; +import { getLocalVue } from "tests/jest/helpers"; import testData from "./testData.json"; import NewUserWelcome from "./NewUserWelcome.vue"; import { getResource } from "./getResource"; diff --git a/client/src/components/PageDisplay/PageDisplay.vue b/client/src/components/PageDisplay/PageDisplay.vue index 60b9e8134ea..9994a67a027 100644 --- a/client/src/components/PageDisplay/PageDisplay.vue +++ b/client/src/components/PageDisplay/PageDisplay.vue @@ -20,7 +20,7 @@ + + + + + + diff --git a/client/src/components/TagsMultiselect/Tag.test.js b/client/src/components/TagsMultiselect/Tag.test.js new file mode 100644 index 00000000000..bc507d45b20 --- /dev/null +++ b/client/src/components/TagsMultiselect/Tag.test.js @@ -0,0 +1,109 @@ +import { getLocalVue } from "tests/jest/helpers"; +import { mount } from "@vue/test-utils"; +import Tag from "./Tag"; + +const localVue = getLocalVue(); + +const mountWithProps = (props) => { + return mount(Tag, { + propsData: props, + localVue, + }); +}; + +describe("Tag", () => { + it("displays it's option", () => { + { + const tag = mountWithProps({ option: "my_tag" }); + expect(tag.text()).toBe("my_tag"); + } + + { + const tag = mountWithProps({ option: "a_longer_tag_name" }); + expect(tag.text()).toBe("a_longer_tag_name"); + } + }); + + it("shows it's clickable", async () => { + const tag = mountWithProps({ option: "my_tag" }); + + expect(tag.classes()).not.toContain("clickable"); + + tag.setProps({ clickable: true }); + await tag.vm.$nextTick(); + + expect(tag.classes()).toContain("clickable"); + }); + + it("can be clicked", async () => { + const tag = mountWithProps({ option: "my_tag", clickable: true }); + + expect(tag.classes()).toContain("clickable"); + + tag.trigger("click"); + await tag.vm.$nextTick(); + + expect(tag.emitted().click).toBeTruthy(); + expect(tag.emitted().click.length).toBe(1); + expect(tag.emitted().click[0]).toEqual(["my_tag"]); + + tag.trigger("click"); + await tag.vm.$nextTick(); + + expect(tag.emitted().click.length).toBe(2); + expect(tag.emitted().click).toStrictEqual([["my_tag"], ["my_tag"]]); + }); + + it("changes appearance when editable", async () => { + const tag = mountWithProps({ option: "my_tag" }); + + expect(tag.classes()).not.toContain("editable"); + expect(tag.find(".tag-delete-button").exists()).not.toBe(true); + + tag.setProps({ editable: true }); + await tag.vm.$nextTick(); + + expect(tag.classes()).toContain("editable"); + expect(tag.find(".tag-delete-button").exists()).toBe(true); + }); + + it("can be deleted", async () => { + const tag = mountWithProps({ option: "my_tag", editable: true }); + + expect(tag.find(".tag-delete-button").exists()).toBe(true); + + tag.find(".tag-delete-button").trigger("click"); + await tag.vm.$nextTick(); + + expect(tag.emitted().deleted).toBeTruthy(); + expect(tag.emitted().deleted.length).toBe(1); + expect(tag.emitted().deleted[0]).toEqual(["my_tag"]); + + expect(tag.emitted().click).toBeFalsy(); + }); + + it("displays named tags bold", () => { + { + const wrapper = mountWithProps({ option: "my_tag" }); + const span = wrapper.find(".tag span"); + expect(span.classes()).not.toContain("font-weight-bold"); + } + + { + const wrapper = mountWithProps({ option: "#named_tag" }); + const span = wrapper.find(".tag span"); + expect(span.classes()).toContain("font-weight-bold"); + } + }); + + it("highlights when searched", async () => { + const tag = mountWithProps({ option: "my_tag" }); + + expect(tag.classes()).not.toContain("searched"); + + tag.setProps({ search: "my_tag" }); + await tag.vm.$nextTick(); + + expect(tag.classes()).toContain("searched"); + }); +}); diff --git a/client/src/components/TagsMultiselect/Tag.vue b/client/src/components/TagsMultiselect/Tag.vue new file mode 100644 index 00000000000..4a1d90f9020 --- /dev/null +++ b/client/src/components/TagsMultiselect/Tag.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/client/src/components/Tool/Buttons/ToolFavoriteButton.vue b/client/src/components/Tool/Buttons/ToolFavoriteButton.vue index e2f88cad847..c5f7ecfcb83 100644 --- a/client/src/components/Tool/Buttons/ToolFavoriteButton.vue +++ b/client/src/components/Tool/Buttons/ToolFavoriteButton.vue @@ -12,10 +12,10 @@ const props = defineProps({ const emit = defineEmits(["onSetError", "onUpdateFavorites"]); -const { currentUser: user, addFavoriteTool, removeFavoriteTool } = useCurrentUser(); +const { currentUser: user, currentFavorites, addFavoriteTool, removeFavoriteTool } = useCurrentUser(); const hasUser = computed(() => !user.value.isAnonymous); -const isFavorite = computed(() => getFavorites().tools.includes(props.id)); +const isFavorite = computed(() => currentFavorites.value.tools.includes(props.id)); const showAddFavorite = computed(() => hasUser.value && !isFavorite.value); const showRemoveFavorite = computed(() => hasUser.value && isFavorite.value); @@ -58,16 +58,6 @@ async function onRemoveFavorite() { ariaAlert("failed to remove from favorites"); } } - -function getFavorites() { - const preferences = user.value.preferences; - - if (preferences?.favorites) { - return JSON.parse(preferences.favorites); - } else { - return { tools: [] }; - } -} @@ -54,6 +52,13 @@ import LoadingSpan from "components/LoadingSpan"; import { ToolsProvider } from "components/providers/storeProviders"; import ToolsListTable from "./ToolsListTable"; import ScrollToTopButton from "./ScrollToTopButton"; +import { useAnimationFrameScroll } from "composables/sensors/animationFrameScroll"; +import { ref } from "vue"; +import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; +import { library } from "@fortawesome/fontawesome-svg-core"; +import { faTimes } from "@fortawesome/free-solid-svg-icons"; + +library.add(faTimes); export default { components: { @@ -61,6 +66,7 @@ export default { ToolsListTable, ToolsProvider, ScrollToTopButton, + FontAwesomeIcon, }, props: { name: { @@ -80,9 +86,13 @@ export default { default: "", }, }, - data() { + setup() { + const scrollContainer = ref(null); + const { scrollTop } = useAnimationFrameScroll(scrollContainer); + return { - offset: 0, + scrollContainer, + scrollTop, }; }, computed: { @@ -101,16 +111,13 @@ export default { }); return newFilterSettings; }, - hasFilters() { + filterCount() { return Object.keys(this.filterSettings).length; }, }, methods: { - onScroll(e) { - this.offset = e.target.scrollTop; - }, scrollToTop() { - this.$el.scrollTop = 0; + this.$refs.scrollContainer.scrollTo({ top: 0, behavior: "smooth" }); }, showAllTools() { this.$router.push({ path: "/tools/list" }); @@ -118,3 +125,12 @@ export default { }, }; + + diff --git a/client/src/components/ToolsList/ToolsListItem.vue b/client/src/components/ToolsList/ToolsListItem.vue new file mode 100644 index 00000000000..874ab63bc33 --- /dev/null +++ b/client/src/components/ToolsList/ToolsListItem.vue @@ -0,0 +1,136 @@ + + + + + + + diff --git a/client/src/components/ToolsList/ToolsListTable.vue b/client/src/components/ToolsList/ToolsListTable.vue index f92c94184cd..80ed264f827 100644 --- a/client/src/components/ToolsList/ToolsListTable.vue +++ b/client/src/components/ToolsList/ToolsListTable.vue @@ -1,59 +1,25 @@ - - diff --git a/client/src/components/User/UserDeletion.test.js b/client/src/components/User/UserDeletion.test.js index 909fa4de06f..86e54534be6 100644 --- a/client/src/components/User/UserDeletion.test.js +++ b/client/src/components/User/UserDeletion.test.js @@ -1,5 +1,5 @@ import { mount } from "@vue/test-utils"; -import { getLocalVue } from "jest/helpers"; +import { getLocalVue } from "tests/jest/helpers"; import UserDeletion from "./UserDeletion"; const localVue = getLocalVue(true); diff --git a/client/src/components/User/UserDeletion.vue b/client/src/components/User/UserDeletion.vue index de8776d338d..1aa65f5bfb2 100644 --- a/client/src/components/User/UserDeletion.vue +++ b/client/src/components/User/UserDeletion.vue @@ -41,15 +41,12 @@ import axios from "axios"; import Vue from "vue"; import BootstrapVue from "bootstrap-vue"; import { userLogoutClient } from "utils/logout"; +import { withPrefix } from "utils/redirect"; Vue.use(BootstrapVue); export default { props: { - root: { - type: String, - required: true, - }, userId: { type: String, required: true, @@ -94,7 +91,7 @@ export default { if (this.email === this.name) { this.nameState = true; try { - await axios.delete(`${this.root}api/users/${this.userId}`); + await axios.delete(withPrefix(`/api/users/${this.userId}`)); } catch (e) { if (e.response.status === 403) { this.deleteError = diff --git a/client/src/components/User/UserPreferences.vue b/client/src/components/User/UserPreferences.vue index 58a43dbcda0..fd88bb519e1 100644 --- a/client/src/components/User/UserPreferences.vue +++ b/client/src/components/User/UserPreferences.vue @@ -5,68 +5,113 @@ {{ message }}

- {{ titleLoggedInAs }} {{ email }}. + You are logged in as + {{ email }} + and you are using + {{ diskUsage }} + of disk space. + If this is more than expected, please visit the + + Storage Dashboard + + to free up disk space. + + Your disk quota is: + {{ diskQuota }}. +

- - -
- {{ link.title }} - {{ link.title }} -
- {{ link.description }} -
-
-
- - + + + + + + + + + + + + + + + + + > - - - -
- Enable notifications -
- Allow push and tab notifcations on job completion. To disable, revoke the site notification - privilege in your browser. -
-
-
-

- You are using {{ diskUsage }} of disk space in this Galaxy instance. - - Your disk quota is: {{ diskQuota }}. - - Is your usage more than expected? Review your - Storage Dashboard. -

+ - diff --git a/client/src/components/User/UserPreferencesElement.vue b/client/src/components/User/UserPreferencesElement.vue new file mode 100644 index 00000000000..bdd6941448c --- /dev/null +++ b/client/src/components/User/UserPreferencesElement.vue @@ -0,0 +1,57 @@ + + + diff --git a/client/src/components/User/UserPreferencesModel.js b/client/src/components/User/UserPreferencesModel.js index bf96454d856..c4ece7087cd 100644 --- a/client/src/components/User/UserPreferencesModel.js +++ b/client/src/components/User/UserPreferencesModel.js @@ -15,7 +15,7 @@ export const getUserPreferencesModel = (user_id) => { url: `/api/users/${user_id}/information/inputs`, icon: "fa-user", redirect: "/user", - shouldRender: !config.use_remote_user, + disabled: config.use_remote_user, }, password: { title: _l("Change Password"), @@ -25,16 +25,7 @@ export const getUserPreferencesModel = (user_id) => { url: `/api/users/${user_id}/password/inputs`, submit_title: "Save Password", redirect: "/user", - shouldRender: !config.use_remote_user && config.enable_account_interface, - }, - external_ids: { - title: _l("Manage Third-Party Identities"), - id: "manage-third-party-identities", - description: _l("Connect or disconnect access to your third-party identities."), - icon: "fa-id-card-o", - submitTitle: "Disconnect identity", - submitIcon: "fa-trash", - shouldRender: config.enable_oidc, + disabled: config.use_remote_user || !config.enable_account_interface, }, permissions: { title: _l("Set Dataset Permissions for New Histories"), @@ -46,29 +37,7 @@ export const getUserPreferencesModel = (user_id) => { icon: "fa-users", submitTitle: "Save Permissions", redirect: "/user", - shouldRender: !config.single_user, - }, - make_data_private: { - title: _l("Make All Data Private"), - id: "edit-preferences-make-data-private", - description: _l("Click here to make all data private."), - icon: "fa-lock", - shouldRender: !config.single_user, - }, - api_key: { - id: "edit-preferences-api-key", - title: _l("Manage API Key"), - description: _l("Access your current API key or create a new one."), - icon: "fa-key", - }, - cloud_auth: { - id: "edit-preferences-cloud-auth", - title: _l("Manage Cloud Authorization"), - description: _l("Add or modify the configuration that grants Galaxy to access your cloud-based resources."), - icon: "fa-cloud", - submitTitle: "Create a new Key", - submitIcon: "fa-check", - shouldRender: true, + disabled: config.single_user, }, toolbox_filters: { title: _l("Manage Toolbox Filters"), @@ -78,19 +47,7 @@ export const getUserPreferencesModel = (user_id) => { icon: "fa-filter", submitTitle: "Save Filters", redirect: "/user", - shouldRender: !!config.has_user_tool_filters, - }, - custom_builds: { - title: _l("Manage Custom Builds"), - description: _l("Add or remove custom builds using history datasets."), - icon: "fa-cubes", - }, - logout: { - title: _l("Sign out"), - id: "edit-preferences-sign-out", - description: _l("Click here to sign out of all sessions."), - icon: "fa-sign-out", - shouldRender: !!Galaxy.session_csrf_token && !config.single_user, + disabled: !config.has_user_tool_filters, }, }; }; diff --git a/client/src/components/UtcDate.test.js b/client/src/components/UtcDate.test.js index e4dfc151fd1..c59d9c8ff31 100644 --- a/client/src/components/UtcDate.test.js +++ b/client/src/components/UtcDate.test.js @@ -1,5 +1,5 @@ import { shallowMount } from "@vue/test-utils"; -import { getLocalVue } from "jest/helpers"; +import { getLocalVue } from "tests/jest/helpers"; import UtcDate from "./UtcDate.vue"; describe("UTCDate component", () => { diff --git a/client/src/components/Visualizations/DisplayApplications.test.js b/client/src/components/Visualizations/DisplayApplications.test.js index b2899806ab2..a1c539ed09a 100644 --- a/client/src/components/Visualizations/DisplayApplications.test.js +++ b/client/src/components/Visualizations/DisplayApplications.test.js @@ -1,5 +1,5 @@ import { mount } from "@vue/test-utils"; -import { getLocalVue } from "jest/helpers"; +import { getLocalVue } from "tests/jest/helpers"; import DisplayApplications from "./DisplayApplications"; import MockProvider from "components/providers/MockProvider"; diff --git a/client/src/components/Visualizations/Tabular/TabularChunkedView.vue b/client/src/components/Visualizations/Tabular/TabularChunkedView.vue new file mode 100644 index 00000000000..c5c1cfddcdb --- /dev/null +++ b/client/src/components/Visualizations/Tabular/TabularChunkedView.vue @@ -0,0 +1,199 @@ + + + + + diff --git a/client/src/components/Workflow/Editor/Attributes.vue b/client/src/components/Workflow/Editor/Attributes.vue index b094bd4c794..c825f32e2e4 100644 --- a/client/src/components/Workflow/Editor/Attributes.vue +++ b/client/src/components/Workflow/Editor/Attributes.vue @@ -85,12 +85,14 @@ export default { }, annotation: { type: String, + default: null, }, license: { type: String, default: "", }, creator: { + type: Object, default: null, }, version: { diff --git a/client/src/components/Workflow/Editor/ConnectionMenu.vue b/client/src/components/Workflow/Editor/ConnectionMenu.vue new file mode 100644 index 00000000000..ec214414ab9 --- /dev/null +++ b/client/src/components/Workflow/Editor/ConnectionMenu.vue @@ -0,0 +1,123 @@ + + diff --git a/client/src/components/Workflow/Editor/Connector.vue b/client/src/components/Workflow/Editor/Connector.vue new file mode 100644 index 00000000000..e78e13a3028 --- /dev/null +++ b/client/src/components/Workflow/Editor/Connector.vue @@ -0,0 +1,73 @@ + + diff --git a/client/src/components/Workflow/Editor/Draggable.vue b/client/src/components/Workflow/Editor/Draggable.vue new file mode 100644 index 00000000000..58bed655d35 --- /dev/null +++ b/client/src/components/Workflow/Editor/Draggable.vue @@ -0,0 +1,78 @@ + + diff --git a/client/src/components/Workflow/Editor/DraggablePan.vue b/client/src/components/Workflow/Editor/DraggablePan.vue new file mode 100644 index 00000000000..7f13013526f --- /dev/null +++ b/client/src/components/Workflow/Editor/DraggablePan.vue @@ -0,0 +1,105 @@ + + + diff --git a/client/src/components/Workflow/Editor/Forms/FormDefault.test.js b/client/src/components/Workflow/Editor/Forms/FormDefault.test.js index 12b0a9bb637..7ab61f364dd 100644 --- a/client/src/components/Workflow/Editor/Forms/FormDefault.test.js +++ b/client/src/components/Workflow/Editor/Forms/FormDefault.test.js @@ -1,46 +1,44 @@ import { mount } from "@vue/test-utils"; -import { getLocalVue } from "jest/helpers"; +import { getLocalVue } from "tests/jest/helpers"; import FormDefault from "./FormDefault"; -import { ActiveOutputs } from "components/Workflow/Editor/modules/outputs"; +import { createTestingPinia } from "@pinia/testing"; +import { PiniaVuePlugin } from "pinia"; const localVue = getLocalVue(); +localVue.use(PiniaVuePlugin); describe("FormDefault", () => { let wrapper; - const activeOutputs = new ActiveOutputs(); const outputs = [ { name: "output-name", label: "output-label" }, { name: "other-name", label: "other-label" }, ]; beforeEach(() => { - activeOutputs.initialize(outputs); wrapper = mount(FormDefault, { propsData: { datatypes: [], - getManager: () => { - return { - nodes: [], - }; - }, - nodeId: "id", - nodeContentId: "id", - nodeLabel: "label", - nodeName: "node-title", - nodeType: "subworkflow", - nodeOutputs: outputs, - nodeActiveOutputs: activeOutputs, - configForm: { + step: { + id: 0, + contentId: "id", + annotation: "annotation", + label: "label", + type: "subworkflow", + configForm: { + inputs: [], + }, inputs: [], + outputs, }, }, localVue, + pinia: createTestingPinia(), }); }); it("check initial value and value change", async () => { const title = wrapper.find(".portlet-title-text").text(); - expect(title).toBe("node-title"); + expect(title).toBe("label"); const inputCount = wrapper.findAll("input").length; expect(inputCount).toBe(3); const outputLabelCount = wrapper.findAll("#__label__output-name").length; diff --git a/client/src/components/Workflow/Editor/Forms/FormDefault.vue b/client/src/components/Workflow/Editor/Forms/FormDefault.vue index 2dba36a8b7b..9f3f77f78c4 100644 --- a/client/src/components/Workflow/Editor/Forms/FormDefault.vue +++ b/client/src/components/Workflow/Editor/Forms/FormDefault.vue @@ -1,5 +1,5 @@ - diff --git a/client/src/components/Workflow/Editor/Forms/FormOutput.vue b/client/src/components/Workflow/Editor/Forms/FormOutput.vue index 7d3d87a7944..12e858b61d2 100644 --- a/client/src/components/Workflow/Editor/Forms/FormOutput.vue +++ b/client/src/components/Workflow/Editor/Forms/FormOutput.vue @@ -1,7 +1,7 @@ - diff --git a/client/src/entry/analysis/index.ts b/client/src/entry/analysis/index.ts index 44c179dac9f..1fec7cfe61c 100644 --- a/client/src/entry/analysis/index.ts +++ b/client/src/entry/analysis/index.ts @@ -1,11 +1,10 @@ -import { standardInit, addInitialization } from "onload"; - import Vue, { provide } from "vue"; -import App from "./App.vue"; -import store from "store"; -import { getRouter } from "./router"; -import { createPinia, PiniaVuePlugin } from "pinia"; +import { PiniaVuePlugin, createPinia } from "pinia"; import piniaPluginPersistedstate from "pinia-plugin-persistedstate"; +import App from "./App.vue"; +import { getRouter } from "./router"; +import { addInitialization, standardInit } from "@/onload"; +import store from "@/store"; Vue.use(PiniaVuePlugin); const pinia = createPinia(); diff --git a/client/src/entry/analysis/modules/Analysis.vue b/client/src/entry/analysis/modules/Analysis.vue index 36b1a8af328..4df9c96048d 100644 --- a/client/src/entry/analysis/modules/Analysis.vue +++ b/client/src/entry/analysis/modules/Analysis.vue @@ -14,6 +14,7 @@ +