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/.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/.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/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..a243e170359 100644 --- a/Makefile +++ b/Makefile @@ -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/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/styleguide.md b/client/docs/styleguide.md new file mode 100644 index 00000000000..da460321e2d --- /dev/null +++ b/client/docs/styleguide.md @@ -0,0 +1,433 @@ +# Styleguide + +Most of the client's code style is handled by prettier. Prettier does a good job of keeping an overall consistent code style, however there are some cases it can not account for. +This document serves as a guide on how to style your code in such cases, with explanations as to why. +Treat it more like a set of recommendations, than hard rules. + +## Naming + +Do not abbreviate. This includes naming `variables`, `functions` and `modules`. + +> **Do** +> +> ```js +> function errorMessageTemplate(workflowfName, errorMessage) { +> return `Failed to run ${workflowfName}. ${errorMessage}`; +> } +> ``` +> +> **Don't** +> +> ```js +> function eMsgTmpl(wfName, msg) { +> return `Failed to run ${wfName}. ${msg}`; +> } +> ``` + +> **Reason** +> +> While abbreviation may save a few keystrokes now, it will make the code harder to understand, and therefore maintain. Even when you think the abbreviations are obvious within this context, consider people looking at your code in the future might not have the same context you do when writing the code. + +## Functions + +There are several ways to define functions in JavaScript. + +```js +// named function +function myFunction(param) { + // do stuff +} + +// arrow functions +const myFunction = (param) => { + // do stuff +}; + +// anonymous functions +const myFunction = function(param) { + //do stuff +}; +``` + +Only use the first two. +`anonymous functions` have mostly been superseded by `arrow functions` + +### When to use the named functions + +Use named functions in the top-level module scope and to declare class methods. + +> **Reason** +> +> `function` is easy to process and understand at a glance. In module scope, arrow functions offer no benefit over regular functions, and they are not allowed as class methods. + +### When to use arrow functions + +Use arrow functions when declaring temporary functions within other scopes. This can be within another function, or inside a method that expects a callback function (eg. `array.forEach()`). + +> **Reason** +> +> Arrow functions offer benefits about the ambiguity of the `this` keyword within other scopes, as they do not provide their own `this` context. +> +> Binding them to a variable, also makes it clear that this function only exists within said scope, just like any other scoped variable. + +### When to use anonymous functions + +When possible, use arrow functions instead. + +### Examples + +> **Do** +> +> ```js +> // in myModules.js +> +> export function myFunction(parameter) { +> const addOne = (value) => { +> return value + 1; +> } +> // do more stuff... +> } +> +> ``` +> +> **Don't** +> +> ```js +> // in myModules.js +> +> export const myFunction = (parameter) => { +> const addOne = function(value) { +> return value + 1; +> } +> // do more stuff... +> } +> +> ``` + +## HTML Multi-Line Layout + +Prettier tires to respect whitespace when formatting your HTML templates, even when it doesn't need to. So for example this code: + +```vue +A very Long Button Text +``` + +Might get turned into: + +```vue +A very Long Button Text +``` + +Notice the strange positioning of the `>` brackets. + +In the case of the button, this 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 adds no empty newlines into your code, but they can help in making it more readable. + +### Javascript + +Add space between blocks of variable definitions and other code. + +> **Do** +> +> ```js +> let a = 5; +> let b = 6; +> +> console.log(a + b); +> ``` +> +> **Don't** +> +> ```js +> let a = 5; +> let b = 6; +> console.log(a + b); +> ``` + +Add space between scopes. + +> **Do** +> +> ```js +> function myFunction(parameter) { +> if (condition) { +> // do stuff... +> } +> +> if (otherCondition) { +> // do more stuff... +> } +> } +> +> function otherFunction() { +> // do other stuff... +> } +> ``` +> +> **Don't** +> +> ```js +> function myFunction(parameter) { +> if (condition) { +> // do stuff... +> } +> if (otherCondition) { +> // do more stuff... +> } +> } +> function otherFunction() { +> // do other stuff... +> } +> ``` + +Add space between scopes and other code. + +> **Do** +> +> ```js +> const myConstant = 5; +> +> if (myConstant === 5) { +> // do stuff... +> } +> +> console.log("log stuff"); +> ``` +> +> **Don't** +> +> ```js +> const myConstant = 5; +> if (myConstant === 5) { +> // do stuff... +> } +> console.log("log stuff"); +> ``` + +### Vue Components + +Add spaces between the `script`, `template` and `style` blocks. + +> **Do** +> +> ```vue +> +> +> +> +> +> ``` +> +> **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) 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 91d849a47d6..1c631ac9ce4 100644 --- a/client/package.json +++ b/client/package.json @@ -29,7 +29,7 @@ "@fortawesome/free-regular-svg-icons": "^5.15.4", "@fortawesome/free-solid-svg-icons": "^5.15.4", "@fortawesome/vue-fontawesome": "^2.0.9", - "@handsontable/vue": "^2.0.0-beta1", + "@handsontable/vue": "^2.0.0", "@hirez_io/observer-spy": "^2.1.2", "@johmun/vue-tags-input": "^2.1.0", "@pinia/testing": "^0.0.14", @@ -55,7 +55,7 @@ "file-saver": "^2.0.5", "flush-promises": "^1.0.2", "glob": "^7.2.0", - "handsontable": "^2.0.0", + "handsontable": "^4.0.0", "hsluv": "^0.1.0", "imask": "^6.4.0", "jquery": "2", @@ -99,7 +99,7 @@ "vue-prismjs": "^1.2.0", "vue-router": "^3.6.5", "vue-rx": "^6.2.0", - "vue-virtual-scroll-list": "^2.3.3", + "vue-virtual-scroll-list": "^2.3.4", "vuedraggable": "2.24.3", "vuex": "^3.6.2", "vuex-cache": "^3.4.0", @@ -182,6 +182,7 @@ "style-loader": "^3.3.1", "ts-jest": "^29.0.3", "ts-loader": "^9.4.1", + "tsconfig-paths-webpack-plugin": "^4.0.0", "typescript": "^4.9.3", "typescript-eslint": "^0.0.1-alpha.0", "vue-loader": "^15.10.0", 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/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/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/services.ts b/client/src/components/Dataset/services.ts index 33d976e3318..cbc452b8eef 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 { safePath } 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; 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 ebc70ad0d14..2bda611fec3 100644 --- a/client/src/components/DatasetInformation/DatasetError.test.js +++ b/client/src/components/DatasetInformation/DatasetError.test.js @@ -2,6 +2,7 @@ import { mount } from "@vue/test-utils"; import { getLocalVue } from "tests/jest/helpers"; import DatasetError from "./DatasetError"; import MockProvider from "../providers/MockProvider"; +import MockCurrentUser from "../providers/MockCurrentUser"; jest.mock("components/providers", () => { return {}; // stubbed below @@ -33,6 +34,7 @@ function buildWrapper(has_duplicate_inputs = true, has_empty_inputs = true, user }), FontAwesomeIcon: false, FormElement: false, + CurrentUser: MockCurrentUser({ email: "email" }), }, }); } @@ -48,7 +50,6 @@ describe("DatasetError", () => { expect(messages.at(1).text()).toBe("message_2"); expect(wrapper.find("#dataset-error-has-empty-inputs")).toBeDefined(); expect(wrapper.find("#dataset-error-has-duplicate-inputs")).toBeDefined(); - expect(wrapper.findAll("#dataset-error-email").length).toBe(1); }); it("check props without common problems", async () => { 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/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 cbd4727dca1..29feb654063 100644 --- a/client/src/components/Form/Elements/FormBoolean.test.js +++ b/client/src/components/Form/Elements/FormBoolean.test.js @@ -18,11 +18,16 @@ describe("FormBoolean", () => { it("check initial value and value change", async () => { const input = wrapper.find("input"); - expect(wrapper.vm.currentValue).toBe(false); + const switchComponent = wrapper.findComponent(".custom-switch"); + + expect(switchComponent.props().value).toBe(false); + await wrapper.setProps({ value: true }); - expect(wrapper.vm.currentValue).toBe(true); + expect(switchComponent.props().value).toBe(true); + await input.trigger("click"); expect(input.element.checked).toBe(false); + await input.trigger("click"); expect(input.element.checked).toBe(true); expect(wrapper.emitted().input[0][0]).toBe(true); diff --git a/client/src/components/Form/Elements/FormBoolean.vue b/client/src/components/Form/Elements/FormBoolean.vue index 8784035c5c5..e18035db21e 100644 --- a/client/src/components/Form/Elements/FormBoolean.vue +++ b/client/src/components/Form/Elements/FormBoolean.vue @@ -1,28 +1,30 @@ + + - - 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..1279ab1344f --- /dev/null +++ b/client/src/components/Form/Elements/FormCheck.test.js @@ -0,0 +1,120 @@ +import { mount } from "@vue/test-utils"; +import { getLocalVue } from "tests/jest/helpers"; +import MountTarget from "./FormCheck"; + +const localVue = getLocalVue(true); + +describe("FormCheck", () => { + let wrapper; + + beforeEach(() => { + wrapper = mount(MountTarget, { + propsData: { + value: null, + options: [], + }, + localVue, + }); + }); + + it("Confirm 'n + 1' checkboxes created (eg. includes the Select-All). Confirm labels and values match. Confirm correct values emitted.", async () => { + const noInput = wrapper.find("[type='checkbox']"); + + expect(noInput.exists()).toBe(false); + + const n = 3; + const options = []; + + for (let i = 0; i < n; i++) { + options.push([`label_${i}`, `value_${i}`]); + } + + await wrapper.setProps({ options }); + + const inputs = wrapper.findAll("[type='checkbox']"); + const labels = wrapper.findAll(".custom-control-label"); + + expect(inputs.length).toBe(n + 1); + + const expectedValues = []; + + for (let i = 0; i < n; i++) { + await inputs.at(i + 1).setChecked(); + expect(labels.at(i + 1).text()).toBe(`label_${i}`); + expect(inputs.at(i + 1).attributes("value")).toBe(`value_${i}`); + + expectedValues.push(`value_${i}`); + + expect(wrapper.emitted()["input"][i][0]).toEqual(expectedValues); + } + }); + + it("Confirm checkboxes are created when various 'empty values' are passed.", async () => { + const emptyValues = [0, null, false, true, undefined]; + const options = []; + + for (let i = 0; i < emptyValues.length; i++) { + options.push([`label_${i}`, emptyValues[i]]); + } + + await wrapper.setProps({ options }); + + const inputs = wrapper.findAll("[type='checkbox']"); + + expect(inputs.length).toBe(emptyValues.length + 1); + + const expectedValues = []; + + for (let i = 0; i < emptyValues; i++) { + await inputs.at(i + 1).setChecked(); + expect(inputs.at(i + 1).attributes("value")).toBe(emptyValues[i]); + + expectedValues.push(expectedValues[i]); + + expect(wrapper.emitted()["input"][i][0]).toEqual(expectedValues); + } + }); + + it("Confirm Select-All checkbox works in various states: select-all, unselect-all, indeterminate/partial-list-selection.", async () => { + const n = 3; + const options = []; + + for (let i = 0; i < n; i++) { + options.push([`label_${i}`, `value_${i}`]); + } + + await wrapper.setProps({ options }); + + const inputs = wrapper.findAll("[type='checkbox']"); + + /* confirm number of checkboxes requested matches number checkboxes created */ + expect(inputs.length).toBe(n + 1); + + /* confirm component loads unchecked */ + for (let i = 0; i < n + 1; i++) { + expect(inputs.at(i).element.checked).toBeFalsy(); + } + + /* 1 - confirm select-all option checked */ + await inputs.at(0).setChecked(); + expect(inputs.at(0).element.checked).toBeTruthy(); + + /* ...confirm corresponding options checked */ + const values = options.map((option) => option[1]); + + expect(wrapper.emitted()["input"][0][0]).toStrictEqual(values); + + /* 2 - confirm select-all option UNchecked */ + await inputs.at(0).setChecked(false); + expect(inputs.at(0).element.checked).toBeFalsy(); + + /* ...confirm corresponding options UNchecked */ + for (let i = 0; i < n; i++) { + expect(inputs.at(i + 1).element.checked).toBeFalsy(); + } + + /* 3 - confirm corresponding options indeterminate-state */ + await inputs.at(1).setChecked(true); + expect(wrapper.find("input:indeterminate").exists()).toBe(true); + }); +}); 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/parameters.js b/client/src/components/Form/Elements/parameters.js index 40ad903c675..b20f219786f 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 @@ -197,14 +196,6 @@ export default Backbone.View.extend({ }); }, - _fieldRulesEdit: function (input_def) { - return new RulesEdit.View({ - id: input_def.id, - onchange: input_def.onchange, - target: input_def.target, - }); - }, - /** Upload file field */ _fieldUpload: function (input_def) { return new Ui.Upload({ diff --git a/client/src/components/Form/FormElement.vue b/client/src/components/Form/FormElement.vue index 3779ef7bab5..3906594e94a 100644 --- a/client/src/components/Form/FormElement.vue +++ b/client/src/components/Form/FormElement.vue @@ -1,110 +1,83 @@ - - - @@ -63,6 +64,7 @@ export default { history: { type: Object, required: true }, selectedCollections: { type: Array, required: true }, showControls: { type: Boolean, default: true }, + filterable: { type: Boolean, default: false }, }, data() { return { diff --git a/client/src/components/History/CurrentHistory/HistoryEmpty.vue b/client/src/components/History/CurrentHistory/HistoryEmpty.vue index bfd16dc946a..dbd74b8ef7a 100644 --- a/client/src/components/History/CurrentHistory/HistoryEmpty.vue +++ b/client/src/components/History/CurrentHistory/HistoryEmpty.vue @@ -13,14 +13,17 @@ + + 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..f61d53f5883 --- /dev/null +++ b/client/src/components/History/HistoryFilters.js @@ -0,0 +1,27 @@ +import Filtering, { compare, contains, equals, expandNameTag, toBool, toDate } from "utils/filtering"; + +export const validFilters = { + hid: equals("hid"), + state: equals("state"), + name: contains("name"), + extension: equals("extension"), + hid_ge: compare("hid", "ge"), + hid_gt: compare("hid", "gt"), + hid_le: compare("hid", "le"), + hid_lt: compare("hid", "lt"), + tag: contains("tags", "tag", expandNameTag), + visible: equals("visible", "visible", toBool), + deleted: equals("deleted", "deleted", toBool), + create_time: compare("create_time", "le", toDate), + create_time_ge: compare("create_time", "ge", toDate), + create_time_gt: compare("create_time", "gt", toDate), + create_time_le: compare("create_time", "le", toDate), + create_time_lt: compare("create_time", "lt", toDate), + update_time: compare("update_time", "le", toDate), + update_time_ge: compare("update_time", "ge", toDate), + update_time_gt: compare("update_time", "gt", toDate), + update_time_le: compare("update_time", "le", toDate), + update_time_lt: compare("update_time", "lt", toDate), +}; + +export const HistoryFilters = new Filtering(validFilters, true); diff --git a/client/src/components/History/HistoryPublishedList.vue b/client/src/components/History/HistoryPublishedList.vue index d7f3a2dc1bd..f318a8baf53 100644 --- a/client/src/components/History/HistoryPublishedList.vue +++ b/client/src/components/History/HistoryPublishedList.vue @@ -7,7 +7,15 @@ import Heading from "components/Common/Heading"; import LoadingSpan from "components/LoadingSpan"; import DebouncedInput from "components/DebouncedInput"; import { getPublishedHistories, updateTags } from "./services"; -import { getFilters, getFilterText, toAlias } from "utils/filterConversion"; +import Filtering, { contains, expandNameTag } from "utils/filtering"; + +const validFilters = { + name: contains("name"), + annotation: contains("annotation"), + tag: contains("tags", "tag", expandNameTag), +}; + +const filters = new Filtering(validFilters, false); const limit = ref(50); const offset = ref(0); @@ -43,7 +51,7 @@ const localFilter = computed({ }, }); -const filterSettings = computed(() => toAlias(getFilters(filterText.value, false))); +const filterSettings = computed(() => filters.toAlias(filters.getFilters(filterText.value))); const updateFilter = (newVal, append = false) => { let oldValue = filterText.value; @@ -69,13 +77,17 @@ const onTagClick = (tag) => { const load = async () => { loading.value = true; - getPublishedHistories({ - limit: limit.value, - offset: offset.value, - sortBy: sortBy.value, - sortDesc: sortDesc.value, - filterText: filterText.value, - }) + + getPublishedHistories( + { + limit: limit.value, + offset: offset.value, + sortBy: sortBy.value, + sortDesc: sortDesc.value, + filterText: filterText.value, + }, + filters + ) .then((data) => { items.value = data; }) @@ -98,7 +110,7 @@ const onToggle = () => { const onSearch = () => { onToggle(); - updateFilter(getFilterText(filterSettings.value)); + updateFilter(filters.getFilterText(filterSettings.value)); }; load(); diff --git a/client/src/components/History/Index.vue b/client/src/components/History/Index.vue index da6677092f2..91aff574db1 100644 --- a/client/src/components/History/Index.vue +++ b/client/src/components/History/Index.vue @@ -6,6 +6,7 @@ v-if="!breadcrumbs.length" :list-offset="listOffset" :history="currentHistory" + :filterable="true" v-on="handlers" @view-collection="onViewCollection">