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/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 70b66a9af7f..37e1ec36dac 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", "@popperjs/core": "^2.11.5", @@ -54,7 +54,7 @@ "file-saver": "^2.0.5", "flush-promises": "^1.0.2", "glob": "^7.2.0", - "handsontable": "^2.0.0", + "handsontable": "^4.0.0", "hsluv": "^0.1.0", "imask": "^6.4.0", "jquery": "2", @@ -97,7 +97,7 @@ "vue-prismjs": "^1.2.0", "vue-router": "^3.6.5", "vue-rx": "^6.2.0", - "vue-virtual-scroll-list": "^2.3.3", + "vue-virtual-scroll-list": "^2.3.4", "vuedraggable": "2.24.3", "vuex": "^3.6.2", "vuex-cache": "^3.4.0", @@ -180,6 +180,7 @@ "style-loader": "^3.3.1", "ts-jest": "^29.0.3", "ts-loader": "^9.4.1", + "tsconfig-paths-webpack-plugin": "^4.0.0", "typescript": "^4.9.3", "typescript-eslint": "^0.0.1-alpha.0", "vue-loader": "^15.10.0", 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/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/Dataset/services.ts b/client/src/components/Dataset/services.ts index 33d976e3318..227fdbc4792 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; 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/FormCheck.test.js b/client/src/components/Form/Elements/FormCheck.test.js new file mode 100644 index 00000000000..969652104ad --- /dev/null +++ b/client/src/components/Form/Elements/FormCheck.test.js @@ -0,0 +1,89 @@ +import { mount } from "@vue/test-utils"; +import { getLocalVue } from "tests/jest/helpers"; +import MountTarget from "./FormCheck"; + +const localVue = getLocalVue(true); + +describe("FormCheck", () => { + let wrapper; + + beforeEach(() => { + wrapper = mount(MountTarget, { + propsData: { + value: null, + options: [], + }, + localVue, + }); + }); + + it("Confirm 'n + 1' checkboxes created (eg. includes the Select-All). Confirm labels and values match. Confirm correct values emitted.", async () => { + const noInput = wrapper.find("[type='checkbox']"); + expect(noInput.exists()).toBe(false); + const n = 3; + const options = []; + for (let i = 0; i < n; i++) { + options.push([`label_${i}`, `value_${i}`]); + } + await wrapper.setProps({ options }); + const inputs = wrapper.findAll("[type='checkbox']"); + const labels = wrapper.findAll(".custom-control-label"); + expect(inputs.length).toBe(n + 1); + const expectedValues = []; + for (let i = 0; i < n; i++) { + await inputs.at(i + 1).setChecked(); + expect(labels.at(i + 1).text()).toBe(`label_${i}`); + expect(inputs.at(i + 1).attributes("value")).toBe(`value_${i}`); + expectedValues.push(`value_${i}`); + expect(wrapper.emitted()["input"][i][0]).toEqual(expectedValues); + } + }); + it("Confirm checkboxes are created when various 'empty values' are passed.", async () => { + const emptyValues = [0, null, false, true, undefined]; + const options = []; + for (let i = 0; i < emptyValues.length; i++) { + options.push([`label_${i}`, emptyValues[i]]); + } + await wrapper.setProps({ options }); + const inputs = wrapper.findAll("[type='checkbox']"); + expect(inputs.length).toBe(emptyValues.length + 1); + const expectedValues = []; + for (let i = 0; i < emptyValues; i++) { + await inputs.at(i + 1).setChecked(); + expect(inputs.at(i + 1).attributes("value")).toBe(emptyValues[i]); + expectedValues.push(expectedValues[i]); + expect(wrapper.emitted()["input"][i][0]).toEqual(expectedValues); + } + }); + it("Confirm Select-All checkbox works in various states: select-all, unselect-all, indeterminate/partial-list-selection.", async () => { + const n = 3; + const options = []; + for (let i = 0; i < n; i++) { + options.push([`label_${i}`, `value_${i}`]); + } + await wrapper.setProps({ options }); + const inputs = wrapper.findAll("[type='checkbox']"); + /* confirm number of checkboxes requested matches number checkboxes created */ + expect(inputs.length).toBe(n + 1); + /* confirm component loads unchecked */ + for (let i = 0; i < n + 1; i++) { + expect(inputs.at(i).element.checked).toBeFalsy(); + } + /* 1 - confirm select-all option checked */ + await inputs.at(0).setChecked(); + expect(inputs.at(0).element.checked).toBeTruthy(); + /* ...confirm corresponding options checked */ + const values = options.map((option) => option[1]); + expect(wrapper.emitted()["input"][0][0]).toStrictEqual(values); + /* 2 - confirm select-all option UNchecked */ + await inputs.at(0).setChecked(false); + expect(inputs.at(0).element.checked).toBeFalsy(); + /* ...confirm corresponding options UNchecked */ + for (let i = 0; i < n; i++) { + expect(inputs.at(i + 1).element.checked).toBeFalsy(); + } + /* 3 - confirm corresponding options indeterminate-state */ + await inputs.at(1).setChecked(true); + expect(wrapper.emitted()["indeterminate"][0][0]).toBeTruthy(); + }); +}); diff --git a/client/src/components/Form/Elements/FormCheck.vue b/client/src/components/Form/Elements/FormCheck.vue new file mode 100644 index 00000000000..d0e9fa91dbe --- /dev/null +++ b/client/src/components/Form/Elements/FormCheck.vue @@ -0,0 +1,72 @@ + + + diff --git a/client/src/components/Form/Elements/FormRadio.vue b/client/src/components/Form/Elements/FormRadio.vue index 751961e9b4b..ec133dfe3f1 100644 --- a/client/src/components/Form/Elements/FormRadio.vue +++ b/client/src/components/Form/Elements/FormRadio.vue @@ -1,7 +1,7 @@ diff --git a/client/src/components/Form/Elements/FormRulesEdit.vue b/client/src/components/Form/Elements/FormRulesEdit.vue new file mode 100644 index 00000000000..4a0308d6b17 --- /dev/null +++ b/client/src/components/Form/Elements/FormRulesEdit.vue @@ -0,0 +1,96 @@ + + + + + + + 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..e1784907709 100644 --- a/client/src/components/Form/FormElement.vue +++ b/client/src/components/Form/FormElement.vue @@ -1,94 +1,67 @@ - - -