diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000000..2e05d2c9b7d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +.venv +database diff --git a/.github/workflows/api.yaml b/.github/workflows/api.yaml index 32da03aa47a..9b4489e4302 100644 --- a/.github/workflows/api.yaml +++ b/.github/workflows/api.yaml @@ -36,5 +36,5 @@ jobs: - uses: actions/upload-artifact@v2 if: failure() with: - name: API test results + name: API test results (${{ matrix.python-version }}) path: 'galaxy root/run_api_tests.html' diff --git a/.github/workflows/api_paste.yaml b/.github/workflows/api_paste.yaml index 11dc17a8b30..1f34a6b6840 100644 --- a/.github/workflows/api_paste.yaml +++ b/.github/workflows/api_paste.yaml @@ -1,4 +1,4 @@ -name: API tests (legacy paste) +name: API (legacy paste) tests on: [push, pull_request] env: GALAXY_TEST_DBURI: 'postgres://postgres:postgres@localhost:5432/galaxy?client_encoding=utf8' @@ -37,5 +37,5 @@ jobs: - uses: actions/upload-artifact@v2 if: failure() with: - name: API test results + name: API (legacy paste) test results (${{ matrix.python-version }}) path: 'galaxy root/run_api_tests.html' diff --git a/.github/workflows/framework.yaml b/.github/workflows/framework.yaml new file mode 100644 index 00000000000..55aa99f6219 --- /dev/null +++ b/.github/workflows/framework.yaml @@ -0,0 +1,40 @@ +name: Framework tests +on: [push, pull_request] +env: + GALAXY_TEST_DBURI: 'postgres://postgres:postgres@localhost:5432/galaxy?client_encoding=utf8' +jobs: + test: + name: Test + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.7'] + services: + postgres: + image: postgres:11 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - 5432:5432 + steps: + - uses: actions/checkout@v2 + with: + path: 'galaxy root' + - uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Cache pip dir + uses: actions/cache@v2 + with: + path: ~/.cache/pip + key: pip-cache-${{ matrix.python-version }}-${{ hashFiles('galaxy root/requirements.txt') }} + - name: Run tests + run: ./run_tests.sh --framework + working-directory: 'galaxy root' + - uses: actions/upload-artifact@v2 + if: failure() + with: + name: Framework test results (${{ matrix.python-version }}) + path: 'galaxy root/run_framework_tests.html' diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index 22d6d3a7e06..30c3e1f8174 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -25,39 +25,44 @@ jobs: ports: - 5672:5672 steps: - - name: Prune unused docker image, volumes and containers - run: docker system prune -a -f - - name: Clean dotnet folder for space - if: matrix.subset == 'kubernetes' - run: rm -Rf /usr/share/dotnet - - name: Setup Minikube - if: matrix.subset == 'kubernetes' - id: minikube - uses: CodingNagger/minikube-setup-action@v1.0.3 - with: - minikube-version: "1.9.0-0_amd64" - - name: Launch Minikube - if: matrix.subset == 'kubernetes' - run: eval ${{ steps.minikube.outputs.launcher }} - - name: Check pods - if: matrix.subset == 'kubernetes' - run: | - kubectl get pods - - uses: actions/checkout@v2 - with: - path: 'galaxy root' - - uses: actions/setup-python@v1 - with: - python-version: ${{ matrix.python-version }} - - name: Cache pip dir - uses: actions/cache@v1 - id: pip-cache - with: - path: ~/.cache/pip - key: pip-cache-${{ matrix.python-version }}-${{ hashFiles('galaxy root/requirements.txt') }} - - name: Install ffmpeg - run: sudo apt-get update && sudo apt-get install ffmpeg -y - if: matrix.subset == 'upload_datatype' - - name: Run tests - run: './run_tests.sh -integration test/integration -- -k "${{ matrix.subset }}"' - working-directory: 'galaxy root' + - name: Prune unused docker image, volumes and containers + run: docker system prune -a -f + - name: Clean dotnet folder for space + if: matrix.subset == 'kubernetes' + run: rm -Rf /usr/share/dotnet + - name: Setup Minikube + if: matrix.subset == 'kubernetes' + id: minikube + uses: CodingNagger/minikube-setup-action@v1.0.3 + with: + minikube-version: "1.9.0-0_amd64" + - name: Launch Minikube + if: matrix.subset == 'kubernetes' + run: eval ${{ steps.minikube.outputs.launcher }} + - name: Check pods + if: matrix.subset == 'kubernetes' + run: | + kubectl get pods + - uses: actions/checkout@v2 + with: + path: 'galaxy root' + - uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python-version }} + - name: Cache pip dir + uses: actions/cache@v1 + id: pip-cache + with: + path: ~/.cache/pip + key: pip-cache-${{ matrix.python-version }}-${{ hashFiles('galaxy root/requirements.txt') }} + - name: Install ffmpeg + run: sudo apt-get update && sudo apt-get install ffmpeg -y + if: matrix.subset == 'upload_datatype' + - name: Run tests + run: './run_tests.sh -integration test/integration -- -k "${{ matrix.subset }}"' + working-directory: 'galaxy root' + - uses: actions/upload-artifact@v2 + if: failure() + with: + name: Integration test results (${{ matrix.python-version }}, ${{ matrix.subset }}) + path: 'galaxy root/run_integration_tests.html' diff --git a/.github/workflows/integration_selenium.yaml b/.github/workflows/integration_selenium.yaml index b57342c4912..962bb79ad94 100644 --- a/.github/workflows/integration_selenium.yaml +++ b/.github/workflows/integration_selenium.yaml @@ -26,20 +26,25 @@ jobs: ports: - 4444:4444 steps: - - name: Prune unused docker image, volumes and containers - run: docker system prune -a -f - - uses: actions/checkout@v2 - with: - path: 'galaxy root' - - uses: actions/setup-python@v1 - with: - python-version: ${{ matrix.python-version }} - - name: Cache pip dir - uses: actions/cache@v1 - id: pip-cache - with: - path: ~/.cache/pip - key: pip-cache-${{ matrix.python-version }}-${{ hashFiles('galaxy root/requirements.txt') }} - - name: Run tests - run: './run_tests.sh -integration test/integration_selenium' - working-directory: 'galaxy root' + - name: Prune unused docker image, volumes and containers + run: docker system prune -a -f + - uses: actions/checkout@v2 + with: + path: 'galaxy root' + - uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python-version }} + - name: Cache pip dir + uses: actions/cache@v1 + id: pip-cache + with: + path: ~/.cache/pip + key: pip-cache-${{ matrix.python-version }}-${{ hashFiles('galaxy root/requirements.txt') }} + - name: Run tests + run: './run_tests.sh -integration test/integration_selenium' + working-directory: 'galaxy root' + - uses: actions/upload-artifact@v2 + if: failure() + with: + name: Integration Selenium test results (${{ matrix.python-version }}) + path: 'galaxy root/run_integration_tests.html' diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 8ca6378a151..211ad869473 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -21,3 +21,5 @@ jobs: run: pip install tox - name: run tests run: tox -e lint + - name: run mypy checks + run: tox -e mypy diff --git a/.github/workflows/maintenance_bot.yaml b/.github/workflows/maintenance_bot.yaml index a34a2be4f07..0083d243545 100644 --- a/.github/workflows/maintenance_bot.yaml +++ b/.github/workflows/maintenance_bot.yaml @@ -8,7 +8,7 @@ jobs: name: Assign labels and milestone runs-on: ubuntu-latest env: - MILESTONE_NUMBER: 18 + MILESTONE_NUMBER: 19 steps: - name: Add area labels if: ${{ ! contains(join(github.event.pull_request.labels.*.name, ', '), 'area/') }} diff --git a/.github/workflows/selenium.yaml b/.github/workflows/selenium.yaml index e7252df2efb..a6bb3ed340f 100644 --- a/.github/workflows/selenium.yaml +++ b/.github/workflows/selenium.yaml @@ -41,5 +41,5 @@ jobs: - uses: actions/upload-artifact@v2 if: failure() with: - name: Selenium test results + name: Selenium test results (${{ matrix.python-version }}, ${{ matrix.chunk }}) path: 'galaxy root/database/test_errors' diff --git a/.github/workflows/toolshed.yaml b/.github/workflows/toolshed.yaml index 38628962cea..305a236bd88 100644 --- a/.github/workflows/toolshed.yaml +++ b/.github/workflows/toolshed.yaml @@ -1,4 +1,4 @@ -name: Toolshed +name: Toolshed tests on: [push, pull_request] env: GALAXY_TEST_DBURI: 'postgres://postgres:postgres@localhost:5432/galaxy?client_encoding=utf8' @@ -20,18 +20,23 @@ jobs: ports: - 5432:5432 steps: - - uses: actions/checkout@v2 - with: - path: 'galaxy root' - - uses: actions/setup-python@v1 - with: - python-version: ${{ matrix.python-version }} - - name: Cache pip dir - uses: actions/cache@v1 - id: pip-cache - with: - path: ~/.cache/pip - key: pip-cache-${{ matrix.python-version }}-${{ hashFiles('galaxy root/requirements.txt') }} - - name: Run tests - run: './run_tests.sh -toolshed' - working-directory: 'galaxy root' + - uses: actions/checkout@v2 + with: + path: 'galaxy root' + - uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python-version }} + - name: Cache pip dir + uses: actions/cache@v1 + id: pip-cache + with: + path: ~/.cache/pip + key: pip-cache-${{ matrix.python-version }}-${{ hashFiles('galaxy root/requirements.txt') }} + - name: Run tests + run: './run_tests.sh -toolshed' + working-directory: 'galaxy root' + - uses: actions/upload-artifact@v2 + if: failure() + with: + name: Toolshed test results (${{ matrix.python-version }}) + path: 'galaxy root/run_toolshed_tests.html' diff --git a/.gitignore b/.gitignore index 0a6ceade288..5f286aff736 100644 --- a/.gitignore +++ b/.gitignore @@ -140,6 +140,7 @@ test/unit/**.log .idea .vscode client/**/jsconfig.json +vetur.config.js # Chrom len files *.len @@ -161,10 +162,7 @@ client/galaxy doc/build doc/schema.md doc/source/admin/config_logging_default_yaml.rst -doc/source/api/api.rst -doc/source/api/ts_api.rst doc/source/dev/schema.md -doc/source/dev/schema.rst client/styleguide # webpack stats diff --git a/.k8s_ci.Dockerfile b/.k8s_ci.Dockerfile new file mode 100644 index 00000000000..c2580717e7d --- /dev/null +++ b/.k8s_ci.Dockerfile @@ -0,0 +1,127 @@ +# Stage 1: +# - base: ubuntu (default) OR prebuilt image0 +# - install build tools +# - run playbook (image0 avoids rerunning lengthy tasks) +# - remove build artifacts + files not needed in container +# Stage 2: +# - install python-virtualenv +# - create galaxy user + group + directory +# - copy galaxy files from stage 1 +# - finalize container (set path, user...) + +# Init ARGs +ARG ROOT_DIR=/galaxy +ARG SERVER_DIR=$ROOT_DIR/server + +# For much faster build time override this with image0 (Dockerfile.0 build): +# docker build --build-arg BASE=... +ARG STAGE1_BASE=ubuntu:20.04 +ARG STAGE2_BASE=$STAGE1_BASE +# NOTE: the value of GALAXY_USER must be also hardcoded in COPY in final stage +ARG GALAXY_USER=galaxy +ARG GALAXY_PLAYBOOK_REPO=https://github.com/galaxyproject/galaxy-docker-k8s + +# Stage-1 +FROM $STAGE1_BASE AS stage1 +ARG DEBIAN_FRONTEND=noninteractive +ARG SERVER_DIR +ARG GALAXY_PLAYBOOK_REPO + +# Init Env +ENV LC_ALL=en_US.UTF-8 +ENV LANG=en_US.UTF-8 + +# Install build dependencies + ansible +RUN set -xe; \ + echo "force-unsafe-io" > /etc/dpkg/dpkg.cfg.d/02apt-speedup \ + && echo "Acquire::http {No-Cache=True;};" > /etc/apt/apt.conf.d/no-cache \ + && apt-get -qq update && apt-get install -y --no-install-recommends \ + locales locales-all \ + apt-transport-https \ + git \ + make \ + libpython3.6 \ + python3-dev \ + python3-virtualenv \ + software-properties-common \ + ssh \ + gcc \ + && apt-get -qq update && apt-get install -y --no-install-recommends \ + ansible \ + && apt-get autoremove -y && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* + +# Remove context from previous build; copy current context; run playbook +WORKDIR /tmp/ansible +RUN rm -rf * +ENV LC_ALL en_US.UTF-8 +RUN git clone --depth 1 $GALAXY_PLAYBOOK_REPO galaxy-docker +WORKDIR /tmp/ansible/galaxy-docker +RUN ansible-galaxy install -r requirements.yml -p roles --force-with-deps + +# Add Galaxy source code +COPY . $SERVER_DIR/ +RUN ansible-playbook -i localhost, playbook.yml -v + +RUN cat /galaxy/server/lib/galaxy/dependencies/conditional-requirements.txt | grep psycopg2-binary | xargs /galaxy/server/.venv/bin/pip install + +# Remove build artifacts + files not needed in container +WORKDIR $SERVER_DIR +# Save commit hash of HEAD before zapping git folder +RUN git rev-parse HEAD > GITREVISION +RUN rm -rf \ + .ci \ + .git \ + .venv/bin/node \ + .venv/include/node \ + .venv/src/node* \ + doc \ + test \ + test-data +# Clean up *all* node_modules, including plugins. Everything is already built+staged. +RUN find . -name "node_modules" -type d -prune -exec rm -rf '{}' + + +# Stage-2 +FROM $STAGE2_BASE +ARG DEBIAN_FRONTEND=noninteractive +ARG ROOT_DIR +ARG SERVER_DIR +ARG GALAXY_USER + +# Init Env +ENV LC_ALL=en_US.UTF-8 +ENV LANG=en_US.UTF-8 + +# Install python-virtualenv +RUN set -xe; \ + echo "force-unsafe-io" > /etc/dpkg/dpkg.cfg.d/02apt-speedup \ + && echo "Acquire::http {No-Cache=True;};" > /etc/apt/apt.conf.d/no-cache \ + && apt-get -qq update && apt-get install -y --no-install-recommends \ + locales \ + libpython3.6 \ + python3-virtualenv \ + vim \ + curl \ + && locale-gen $LANG && update-locale LANG=$LANG \ + && apt-get autoremove -y && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* + +# Create Galaxy user, group, directory; chown +RUN set -xe; \ + adduser --system --group $GALAXY_USER \ + && mkdir -p $SERVER_DIR \ + && chown $GALAXY_USER:$GALAXY_USER $ROOT_DIR -R + +WORKDIR $ROOT_DIR +# Copy galaxy files to final image +# The chown value MUST be hardcoded (see #35018 at github.com/moby/moby) +COPY --chown=galaxy:galaxy --from=stage1 $ROOT_DIR . + +WORKDIR $SERVER_DIR +EXPOSE 8080 +USER $GALAXY_USER + +ENV PATH="$SERVER_DIR/.venv/bin:${PATH}" + +# [optional] to run: +CMD uwsgi --yaml config/galaxy.yml diff --git a/Makefile b/Makefile index 0815ade564c..938f7bf63fd 100644 --- a/Makefile +++ b/Makefile @@ -30,6 +30,9 @@ docs: ## Generate HTML documentation. $(IN_VENV) $(MAKE) -C doc clean $(IN_VENV) $(MAKE) -C doc html +docs-develop: ## Fast doc generation and more warnings (for development) + $(IN_VENV) GALAXY_DOCS_SKIP_VIEW_CODE=1 SPHINXOPTS='-j 4' $(MAKE) -C doc html + setup-venv: if [ ! -f $(VENV)/bin/activate ]; then bash scripts/common_startup.sh --dev-wheels; fi diff --git a/client/package.json b/client/package.json index c3c94aa8e15..7f3ac4610ed 100644 --- a/client/package.json +++ b/client/package.json @@ -25,7 +25,7 @@ "@hirez_io/observer-spy": "^2.0.0", "@johmun/vue-tags-input": "^2.1.0", "@sentry/browser": "^5.20.1", - "axios": "^0.19.2", + "axios": "^0.21.1", "backbone": "1.4.0", "bootstrap": "4.5.0", "bootstrap-tour": "https://github.com/sorich87/bootstrap-tour.git#65a49742e131d19f41e3f5bf63588995f7b8a9e0", @@ -68,11 +68,13 @@ "rxjs-spy": "^7.5.3", "rxjs-spy-devtools-plugin": "^0.0.4", "slugify": "^1.4.0", + "splitpanes": "2.3.6", "threads": "^1.6.3", "toastr": "^2.1.4", "underscore": "^1.10.2", "underscore.string": "^3.3.5", "vue": "^2.6.11", + "vuedraggable": "2.24.1", "vue-infinite-scroll": "^2.0.2", "vue-multiselect": "^2.1.0", "vue-router": "^3.3.1", diff --git a/client/src/components/Collections/ListCollectionCreator.vue b/client/src/components/Collections/ListCollectionCreator.vue new file mode 100644 index 00000000000..9687035e75b --- /dev/null +++ b/client/src/components/Collections/ListCollectionCreator.vue @@ -0,0 +1,496 @@ + + + + + diff --git a/client/src/components/Collections/ListCollectionCreatorModal.js b/client/src/components/Collections/ListCollectionCreatorModal.js new file mode 100644 index 00000000000..1477f736f6a --- /dev/null +++ b/client/src/components/Collections/ListCollectionCreatorModal.js @@ -0,0 +1,50 @@ +import _l from "utils/localization"; +import Vue from "vue"; +import { collectionCreatorModalSetup } from "./common/modal"; + +function listCollectionCreatorModal(elements, options) { + options = options || {}; + options.title = _l("Create a collection from a list of datasets"); + const { promise, showEl } = collectionCreatorModalSetup(options); + return import(/* webpackChunkName: "ListCollectionCreator" */ "./ListCollectionCreator.vue").then((module) => { + var listCollectionCreatorInstance = Vue.extend(module.default); + var vm = document.createElement("div"); + showEl(vm); + new listCollectionCreatorInstance({ + propsData: { + initialElements: elements, + creationFn: options.creationFn, + oncancel: options.oncancel, + oncreate: options.oncreate, + defaultHideSourceItems: options.defaultHideSourceItems, + }, + }).$mount(vm); + return promise; + }); +} + +/** Use a modal to create a list collection, then add it to the given history contents. + * @returns {Promise} resolved when the collection is added to the history. + */ +function createListCollection(contents, defaultHideSourceItems) { + const elements = contents.toJSON(); + var copyElements; + const promise = listCollectionCreatorModal(elements, { + defaultHideSourceItems: defaultHideSourceItems, + creationFn: function (elements, name, hideSourceItems) { + elements = elements.map((element) => ({ + id: element.id, + name: element.name, + //TODO: this allows for list:list even if the filter above does not - reconcile + src: element.history_content_type === "dataset" ? "hda" : "hdca", + })); + copyElements = !hideSourceItems; + return contents.createHDCA(elements, "list", name, hideSourceItems, copyElements); + }, + }); + return promise; +} +export default { + listCollectionCreatorModal: listCollectionCreatorModal, + createListCollection: createListCollection, +}; diff --git a/client/src/components/Collections/ListDatasetCollectionElementView.vue b/client/src/components/Collections/ListDatasetCollectionElementView.vue new file mode 100644 index 00000000000..9e6895d56b7 --- /dev/null +++ b/client/src/components/Collections/ListDatasetCollectionElementView.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/client/src/components/Collections/PairCollectionCreator.vue b/client/src/components/Collections/PairCollectionCreator.vue new file mode 100644 index 00000000000..10b6d81130f --- /dev/null +++ b/client/src/components/Collections/PairCollectionCreator.vue @@ -0,0 +1,332 @@ + + + + + diff --git a/client/src/components/Collections/PairCollectionCreatorModal.js b/client/src/components/Collections/PairCollectionCreatorModal.js new file mode 100644 index 00000000000..a95e6dec65a --- /dev/null +++ b/client/src/components/Collections/PairCollectionCreatorModal.js @@ -0,0 +1,44 @@ +import _l from "utils/localization"; +import Vue from "vue"; +import { collectionCreatorModalSetup } from "./common/modal"; + +function pairCollectionCreatorModal(elements, options) { + options = options || {}; + options.title = _l("Create a collection from a pair of datasets"); + const { promise, showEl } = collectionCreatorModalSetup(options); + return import(/* webpackChunkName: "PairCollectionCreator" */ "./PairCollectionCreator.vue").then((module) => { + var pairCollectionCreatorInstance = Vue.extend(module.default); + var vm = document.createElement("div"); + showEl(vm); + new pairCollectionCreatorInstance({ + propsData: { + initialElements: elements, + creationFn: options.creationFn, + oncancel: options.oncancel, + oncreate: options.oncreate, + defaultHideSourceItems: options.defaultHideSourceItems, + }, + }).$mount(vm); + return promise; + }); +} +function createPairCollection(contents, defaultHideSourceItems) { + var elements = contents.toJSON(); + var copyElements; + var promise = pairCollectionCreatorModal(elements, { + defaultHideSourceItems: defaultHideSourceItems, + creationFn: function (elements, name, hideSourceItems) { + elements = [ + { name: "forward", src: "hda", id: elements[0].id }, + { name: "reverse", src: "hda", id: elements[1].id }, + ]; + copyElements = !hideSourceItems; + return contents.createHDCA(elements, "paired", name, hideSourceItems, copyElements); + }, + }); + return promise; +} +export default { + pairCollectionCreatorModal: pairCollectionCreatorModal, + createPairCollection: createPairCollection, +}; diff --git a/client/src/components/Collections/PairedElementView.vue b/client/src/components/Collections/PairedElementView.vue new file mode 100644 index 00000000000..9be35e6a35b --- /dev/null +++ b/client/src/components/Collections/PairedElementView.vue @@ -0,0 +1,56 @@ + + diff --git a/client/src/components/Collections/PairedListCollectionCreator.test.js b/client/src/components/Collections/PairedListCollectionCreator.test.js new file mode 100644 index 00000000000..596787fa493 --- /dev/null +++ b/client/src/components/Collections/PairedListCollectionCreator.test.js @@ -0,0 +1,34 @@ +import { shallowMount } from "@vue/test-utils"; +import PairedListCollectionCreator from "components/Collections/PairedListCollectionCreator"; +import DATA from "../../../tests/qunit/test-data/paired-collection-creator.data.js"; + +describe("PairedListCollectionCreator", () => { + let wrapper; + + beforeEach(async () => { + wrapper = shallowMount(PairedListCollectionCreator, { + propsData: { + initialElements: DATA._1, + creationFn: () => { + return; + }, + oncreate: () => { + return; + }, + oncancel: () => { + return; + }, + }, + }); + await wrapper.vm.$nextTick(); + }); + + afterEach(async () => { + await wrapper.vm.$nextTick(); + }); + + it("autopairs the dataset", async () => { + // Autopair is called on startup + expect(wrapper.findAll("li.dataset").length == 0).toBeTruthy(); + }); +}); diff --git a/client/src/components/Collections/PairedListCollectionCreator.vue b/client/src/components/Collections/PairedListCollectionCreator.vue new file mode 100644 index 00000000000..cd867e44cd9 --- /dev/null +++ b/client/src/components/Collections/PairedListCollectionCreator.vue @@ -0,0 +1,1230 @@ + + + diff --git a/client/src/components/Collections/PairedListCollectionCreatorModal.js b/client/src/components/Collections/PairedListCollectionCreatorModal.js new file mode 100644 index 00000000000..fdae0e1db3e --- /dev/null +++ b/client/src/components/Collections/PairedListCollectionCreatorModal.js @@ -0,0 +1,63 @@ +import _l from "utils/localization"; +import Vue from "vue"; +import { collectionCreatorModalSetup } from "./common/modal"; + +function pairedListCollectionCreatorModal(elements, options) { + options = options || {}; + options.title = _l("Create a collection of paired datasets"); + const { promise, showEl } = collectionCreatorModalSetup(options); + return import(/* webpackChunkName: "PairedListCollectionCreator" */ "./PairedListCollectionCreator.vue").then( + (module) => { + var pairedListCollectionCreatorInstance = Vue.extend(module.default); + var vm = document.createElement("div"); + showEl(vm); + new pairedListCollectionCreatorInstance({ + propsData: { + initialElements: elements, + creationFn: options.creationFn, + oncancel: options.oncancel, + oncreate: options.oncreate, + defaultHideSourceItems: options.defaultHideSourceItems, + }, + }).$mount(vm); + return promise; + } + ); +} + +/** Use a modal to create a list collection, then add it to the given history contents. + * @returns {Promise} resolved when the collection is added to the history. + */ +function createPairedListCollection(contents, defaultHideSourceItems) { + const elements = contents.toJSON(); + var copyElements; + const promise = pairedListCollectionCreatorModal(elements, { + defaultHideSourceItems: defaultHideSourceItems, + creationFn: function (elements, name, hideSourceItems) { + elements = elements.map((pair) => ({ + collection_type: "paired", + src: "new_collection", + name: pair.name, + element_identifiers: [ + { + name: "forward", + id: pair.forward.id, + src: pair.forward.src || "hda", + }, + { + name: "reverse", + id: pair.reverse.id, + src: pair.reverse.src || "hda", + }, + ], + })); + copyElements = !hideSourceItems; + return contents.createHDCA(elements, "list:paired", name, hideSourceItems, copyElements); + }, + }); + return promise; +} +export default { + pairedListCollectionCreatorModal: pairedListCollectionCreatorModal, + createPairedListCollection: createPairedListCollection, +}; diff --git a/client/src/components/Collections/RuleBasedCollectionCreatorModal.js b/client/src/components/Collections/RuleBasedCollectionCreatorModal.js new file mode 100644 index 00000000000..40bed880c02 --- /dev/null +++ b/client/src/components/Collections/RuleBasedCollectionCreatorModal.js @@ -0,0 +1,91 @@ +import _l from "utils/localization"; +import Vue from "vue"; +import { collectionCreatorModalSetup } from "./common/modal"; + +function ruleBasedCollectionCreatorModal(elements, elementsType, importType, options) { + // importType in [datasets, collection] + // elementsType in [raw, ftp, datasets] + let title; + if (importType == "datasets") { + title = _l("Build Rules for Uploading Datasets"); + } else if (elementsType == "collection_contents") { + title = _l("Build Rules for Applying to Existing Collection"); + } else if (elementsType == "datasets" || elementsType == "library_datasets") { + title = _l("Build Rules for Creating Collection(s)"); + } else { + title = _l("Build Rules for Uploading Collections"); + } + options.title = title; + const { promise, showEl } = collectionCreatorModalSetup(options); + return import(/* webpackChunkName: "ruleCollectionBuilder" */ "components/RuleCollectionBuilder.vue").then( + (module) => { + var ruleCollectionBuilderInstance = Vue.extend(module.default); + var vm = document.createElement("div"); + showEl(vm); + new ruleCollectionBuilderInstance({ + propsData: { + initialElements: elements, + elementsType: elementsType, + importType: importType, + ftpUploadSite: options.ftpUploadSite, + creationFn: options.creationFn, + oncancel: options.oncancel, + oncreate: options.oncreate, + defaultHideSourceItems: options.defaultHideSourceItems, + saveRulesFn: options.saveRulesFn, + initialRules: options.initialRules, + }, + }).$mount(vm); + return promise; + } + ); +} +function createCollectionViaRules(selection, defaultHideSourceItems) { + let elements; + let elementsType; + let importType; + const selectionType = selection.selectionType; + const copyElements = !defaultHideSourceItems; + if (!selectionType) { + // Have HDAs from the history panel. + elements = selection.toJSON(); + elementsType = "datasets"; + importType = "collections"; + } else if (selection.elements) { + elementsType = selection.selectionType; + importType = selection.dataType || "collections"; + elements = selection.elements; + } else { + const hasNonWhitespaceChars = RegExp(/[^\s]/); + // Have pasted data, data from a history dataset, or FTP list. + const lines = selection.content + .split(/[\n\r]/) + .filter((line) => line.length > 0 && hasNonWhitespaceChars.exec(line)); + // Really poor tabular parser - we should get a library for this or expose options? I'm not + // sure. + let hasTabs = false; + if (lines.length > 0) { + const firstLine = lines[0]; + if (firstLine.indexOf("\t") >= 0) { + hasTabs = true; + } + } + const regex = hasTabs ? /\t/ : /\s+/; + elements = lines.map((line) => line.split(regex)); + elementsType = selection.selectionType; + importType = selection.dataType || "collections"; + } + const promise = ruleBasedCollectionCreatorModal(elements, elementsType, importType, { + ftpUploadSite: selection.ftpUploadSite, + defaultHideSourceItems: defaultHideSourceItems, + creationFn: function (elements, collectionType, name, hideSourceItems) { + return selection.createHDCA(elements, collectionType, name, hideSourceItems, copyElements); + }, + }); + return promise; +} + +export default { + ruleBasedCollectionCreatorModal: ruleBasedCollectionCreatorModal, + createCollectionViaRules: createCollectionViaRules, +}; diff --git a/client/src/components/Collections/UnpairedDatasetElementView.vue b/client/src/components/Collections/UnpairedDatasetElementView.vue new file mode 100644 index 00000000000..958bc8f6472 --- /dev/null +++ b/client/src/components/Collections/UnpairedDatasetElementView.vue @@ -0,0 +1,40 @@ + + + + + diff --git a/client/src/components/Collections/common/ClickToEdit.vue b/client/src/components/Collections/common/ClickToEdit.vue new file mode 100644 index 00000000000..b232c28910e --- /dev/null +++ b/client/src/components/Collections/common/ClickToEdit.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/client/src/components/Collections/common/CollectionCreator.vue b/client/src/components/Collections/common/CollectionCreator.vue new file mode 100644 index 00000000000..0dd0fe71267 --- /dev/null +++ b/client/src/components/Collections/common/CollectionCreator.vue @@ -0,0 +1,420 @@ + + + + + diff --git a/client/src/components/Collections/common/mixin.js b/client/src/components/Collections/common/mixin.js new file mode 100644 index 00000000000..22685ef924d --- /dev/null +++ b/client/src/components/Collections/common/mixin.js @@ -0,0 +1,41 @@ +import CollectionCreator from "./CollectionCreator"; + +export default { + components: { + CollectionCreator, + }, + props: { + initialElements: { + required: true, + type: Array, + }, + creationFn: { + type: Function, + required: true, + }, + /** fn to call when the cancel button is clicked (scoped to this) - if falsy, no btn is displayed */ + oncancel: { + type: Function, + required: true, + }, + oncreate: { + type: Function, + required: true, + }, + defaultHideSourceItems: { + type: Boolean, + required: false, + default: true, + }, + }, + data() { + return { + hideSourceItems: this.defaultHideSourceItems, + }; + }, + methods: { + onUpdateHideSourceItems(hideSourceItems) { + this.hideSourceItems = hideSourceItems; + }, + }, +}; diff --git a/client/src/components/Collections/common/modal.js b/client/src/components/Collections/common/modal.js new file mode 100644 index 00000000000..b51c7ee5def --- /dev/null +++ b/client/src/components/Collections/common/modal.js @@ -0,0 +1,29 @@ +import { getGalaxyInstance } from "app"; +import UI_MODAL from "mvc/ui/ui-modal"; +import _l from "utils/localization"; + +export function collectionCreatorModalSetup(options, Galaxy = null) { + Galaxy = Galaxy || getGalaxyInstance(); + const modal = Galaxy.modal || new UI_MODAL.View(); + const promise = new Promise((then, reject) => { + options.oncancel = function () { + modal.hide(); + reject("cancelled"); + }; + options.oncreate = function (creator, response) { + modal.hide(); + then(response); + }; + }); + const showEl = function (el) { + modal.show({ + title: options.title || _l("Create a collection"), + body: el, + width: "85%", + height: "100%", + xlarge: true, + closing_events: true, + }); + }; + return { promise, options, showEl }; +} diff --git a/client/src/components/Collections/common/modal.test.js b/client/src/components/Collections/common/modal.test.js new file mode 100644 index 00000000000..091fe33159a --- /dev/null +++ b/client/src/components/Collections/common/modal.test.js @@ -0,0 +1,60 @@ +jest.mock("app"); + +import { collectionCreatorModalSetup } from "./modal"; +import flushPromises from "flush-promises"; + +describe("modal.js", () => { + let showOptions = null; + let hidden = false; + const mockApp = { + modal: { + show(showOptions_) { + showOptions = showOptions_; + }, + hide() { + hidden = true; + }, + }, + }; + let options; + let showEl; + let resolution; + let rejected; + + describe("collectionCreatorModalSetup", () => { + beforeEach(() => { + hidden = false; + rejected = false; + const object = collectionCreatorModalSetup({}, mockApp); + options = object.options; + showEl = object.showEl; + object.promise + .catch((rejection_) => { + rejected = true; + }) + .then((resolution_) => { + resolution = resolution_; + }); + }); + + it("should create showEl and resolve oncreate", async () => { + expect(showOptions).toBe(null); + showEl(); + expect(showOptions.title).toEqual("Create a collection"); + + expect(hidden).toBeFalsy(); + options.oncreate(null, "testres"); + await flushPromises(); + expect(resolution).toEqual("testres"); + expect(hidden).toBeTruthy(); + }); + + it("should hide oncancel", async () => { + expect(hidden).toBeFalsy(); + options.oncancel(); + await flushPromises(); + expect(hidden).toBeTruthy(); + expect(rejected).toBeTruthy(); + }); + }); +}); diff --git a/client/src/components/Dataset/DatasetStorage/DatasetStorage.test.js b/client/src/components/Dataset/DatasetStorage/DatasetStorage.test.js new file mode 100644 index 00000000000..6b34492d590 --- /dev/null +++ b/client/src/components/Dataset/DatasetStorage/DatasetStorage.test.js @@ -0,0 +1,124 @@ +import { shallowMount } from "@vue/test-utils"; +import DatasetStorage from "./DatasetStorage"; +import { getLocalVue } from "jest/helpers"; +import flushPromises from "flush-promises"; +import MockAdapter from "axios-mock-adapter"; +import axios from "axios"; +import MarkdownIt from "markdown-it"; + +const localVue = getLocalVue(); + +const TEST_STORAGE_API_RESPONSE_WITHOUT_ID = { + object_store_id: null, +}; +const TEST_STORAGE_API_RESPONSE_WITH_ID = { + object_store_id: "foobar", +}; +const TEST_STORAGE_API_RESPONSE_WITH_NAME = { + object_store_id: "foobar", + name: "my cool storage", + description: "My cool **markdown**", +}; +const TEST_DATASET_ID = "1"; +const TEST_STORAGE_URL = `/api/datasets/${TEST_DATASET_ID}/storage`; +const TEST_RENDERED_MARKDOWN_AS_HTML = "

My cool markdown\n"; +const TEST_ERROR_MESSAGE = "Opps all errors."; + +// works fine without mocking but I guess it is more JS unit-y with the mock? +jest.mock("markdown-it"); +MarkdownIt.mockImplementation(() => { + return { + render(markdown) { + return TEST_RENDERED_MARKDOWN_AS_HTML; + }, + }; +}); + +describe("Dataset Storage", () => { + let axiosMock; + let wrapper; + + beforeEach(async () => { + axiosMock = new MockAdapter(axios); + }); + + function mount() { + wrapper = shallowMount(DatasetStorage, { + propsData: { datasetId: TEST_DATASET_ID }, + localVue, + stubs: { + "loading-span": true, + }, + }); + } + + async function mountWithResponse(response) { + axiosMock.onGet(TEST_STORAGE_URL).reply(200, response); + mount(); + await flushPromises(); + } + + it("test loading...", async () => { + mount(); + await wrapper.vm.$nextTick(); + expect(wrapper.findAll("loading-span-stub").length).toBe(1); + }); + + it("test error rendering...", async () => { + axiosMock.onGet(TEST_STORAGE_URL).reply(400, { + err_msg: TEST_ERROR_MESSAGE, + }); + mount(); + await flushPromises(); + expect(wrapper.findAll(".error").length).toBe(1); + expect(wrapper.findAll(".error").at(0).text()).toBe(TEST_ERROR_MESSAGE); + expect(wrapper.findAll("loading-span-stub").length).toBe(0); + }); + + it("test dataset storage with object store without id", async () => { + await mountWithResponse(TEST_STORAGE_API_RESPONSE_WITHOUT_ID); + expect(wrapper.findAll("loading-span-stub").length).toBe(0); + expect(wrapper.vm.descriptionRendered).toBeNull(); + const header = wrapper.findAll("h3"); + expect(header.length).toBe(1); + expect(header.at(0).text()).toBe("Dataset Storage"); + const byIdSpan = wrapper.findAll(".display-os-by-id"); + expect(byIdSpan.length).toBe(0); + const byNameSpan = wrapper.findAll(".display-os-by-name"); + expect(byNameSpan.length).toBe(0); + const byDefaultSpan = wrapper.findAll(".display-os-default"); + expect(byDefaultSpan.length).toBe(1); + }); + + it("test dataset storage with object store id", async () => { + await mountWithResponse(TEST_STORAGE_API_RESPONSE_WITH_ID); + expect(wrapper.findAll("loading-span-stub").length).toBe(0); + expect(wrapper.vm.storageInfo.object_store_id).toBe("foobar"); + expect(wrapper.vm.descriptionRendered).toBeNull(); + const header = wrapper.findAll("h3"); + expect(header.length).toBe(1); + expect(header.at(0).text()).toBe("Dataset Storage"); + const byIdSpan = wrapper.findAll(".display-os-by-id"); + expect(byIdSpan.length).toBe(1); + const byNameSpan = wrapper.findAll(".display-os-by-name"); + expect(byNameSpan.length).toBe(0); + }); + + it("test dataset storage with object store name", async () => { + await mountWithResponse(TEST_STORAGE_API_RESPONSE_WITH_NAME); + expect(wrapper.findAll("loading-span-stub").length).toBe(0); + expect(wrapper.vm.storageInfo.object_store_id).toBe("foobar"); + expect(wrapper.vm.descriptionRendered).toBe(TEST_RENDERED_MARKDOWN_AS_HTML); + const header = wrapper.findAll("h3"); + expect(header.length).toBe(1); + expect(header.at(0).text()).toBe("Dataset Storage"); + const byIdSpan = wrapper.findAll(".display-os-by-id"); + expect(byIdSpan.length).toBe(0); + const byNameSpan = wrapper.findAll(".display-os-by-name"); + expect(byNameSpan.length).toBe(1); + }); + + afterEach(() => { + axiosMock.restore(); + }); +}); diff --git a/client/src/components/Dataset/DatasetStorage/DatasetStorage.vue b/client/src/components/Dataset/DatasetStorage/DatasetStorage.vue index ace08f8f272..7232d97e508 100644 --- a/client/src/components/Dataset/DatasetStorage/DatasetStorage.vue +++ b/client/src/components/Dataset/DatasetStorage/DatasetStorage.vue @@ -1,18 +1,18 @@ diff --git a/client/src/components/History/adapters/buildCollectionModal.js b/client/src/components/History/adapters/buildCollectionModal.js index 4612c7f7521..3bf5599a6f2 100644 --- a/client/src/components/History/adapters/buildCollectionModal.js +++ b/client/src/components/History/adapters/buildCollectionModal.js @@ -3,15 +3,16 @@ * the collection assembly modals. i.e. With selected..... create dataset collection, * create paired collection, etc. * - * The goal is to use the existing "crateListCollection", etc. functions but doctor + * The goal is to use the existing "createListCollection", etc. functions but doctor * the content parameter to have the API of a backbone model which requires a * deprecated jquery Deferred object. */ import jQuery from "jquery"; -import LIST_COLLECTION_CREATOR from "mvc/collection/list-collection-creator"; -import PAIR_COLLECTION_CREATOR from "mvc/collection/pair-collection-creator"; -import LIST_OF_PAIRS_COLLECTION_CREATOR from "mvc/collection/list-of-pairs-collection-creator"; +import LIST_COLLECTION_CREATOR from "../../Collections/ListCollectionCreatorModal"; +import PAIR_COLLECTION_CREATOR from "../../Collections/PairCollectionCreatorModal"; +import LIST_OF_PAIRS_COLLECTION_CREATOR from "../../Collections/PairedListCollectionCreatorModal"; +import RULE_BASED_COLLECTION_CREATOR from "../../Collections/RuleBasedCollectionCreatorModal"; // stand-in for buildCollection from history-view-edit.js export async function buildCollectionModal(collectionType, history_id, selectedContent, hideSourceItems = false) { @@ -22,9 +23,9 @@ export async function buildCollectionModal(collectionType, history_id, selectedC } else if (collectionType == "paired") { createFunc = PAIR_COLLECTION_CREATOR.createPairCollection; } else if (collectionType == "list:paired") { - createFunc = LIST_OF_PAIRS_COLLECTION_CREATOR.createListOfPairsCollection; + createFunc = LIST_OF_PAIRS_COLLECTION_CREATOR.createPairedListCollection; } else if (collectionType.startsWith("rules")) { - createFunc = LIST_COLLECTION_CREATOR.createCollectionViaRules; + createFunc = RULE_BASED_COLLECTION_CREATOR.createCollectionViaRules; } else { throw new Error(`Unknown collectionType encountered ${collectionType}`); } diff --git a/client/src/components/History/caching/CacheApi.js b/client/src/components/History/caching/CacheApi.js index 9f62300ee5b..8f6cc927aca 100644 --- a/client/src/components/History/caching/CacheApi.js +++ b/client/src/components/History/caching/CacheApi.js @@ -17,7 +17,7 @@ export * from "./db/promises"; export { loadDscContent } from "./loadDscContent"; export { loadHistoryContents, clearHistoryDateStore } from "./loadHistoryContents"; export { monitorHistoryContent } from "./monitorHistoryContent"; -export { wipeDatabase } from "./db/pouch"; +export { wipeDatabase } from "./db/wipeDatabase"; // generic content query monitor export const monitorContentQuery = (cfg = {}) => { diff --git a/client/src/components/History/caching/db/changes.js b/client/src/components/History/caching/db/changes.js index 0cdd9474a0c..535856cd074 100644 --- a/client/src/components/History/caching/db/changes.js +++ b/client/src/components/History/caching/db/changes.js @@ -2,7 +2,7 @@ import { pipe, Observable } from "rxjs"; import { switchMap, filter, share } from "rxjs/operators"; // feed observables, keyed by underlying database instance -const feeds = new Map(); +export const feeds = new Map(); /** * Returns an observable with all the change events from the indicated database @@ -19,7 +19,6 @@ export const changes = (cfg = {}) => { }), // filter out index creation which can appear as a change filter(({ id }) => !id.includes("_design")), - share() ); }; @@ -31,11 +30,16 @@ export const changes = (cfg = {}) => { const buildFeed = (db, cfg = {}) => { const { live = true, returnDocs = true, include_docs = true, since = "now", timeout = false } = cfg; - return new Observable((obs) => { + const feed$ = new Observable((obs) => { const changeOpts = { live, include_docs, returnDocs, since, timeout }; const feed = db.changes(changeOpts); feed.on("change", (update) => obs.next(update)); feed.on("error", (err) => obs.error(err)); - return () => feed.cancel(); + return () => { + feed.cancel(); + feeds.delete(db); + }; }); + + return feed$.pipe(share()); }; diff --git a/client/src/components/History/caching/db/changes.test.js b/client/src/components/History/caching/db/changes.test.js index 6620eccc044..424faf6b9d5 100644 --- a/client/src/components/History/caching/db/changes.test.js +++ b/client/src/components/History/caching/db/changes.test.js @@ -1,6 +1,7 @@ -import { takeWhile, share } from "rxjs/operators"; +import { timer } from "rxjs"; +import { takeWhile, share, takeUntil } from "rxjs/operators"; import { content$, dscContent$ } from "./observables"; -import { wipeDatabase } from "./pouch"; +import { wipeDatabase } from "./wipeDatabase"; import { bulkCacheContent, cacheContent, @@ -9,7 +10,8 @@ import { getCachedCollectionContent, cacheCollectionContent, } from "./promises"; -import { changes } from "./changes"; +import { changes, feeds } from "./changes"; +import { wait } from "jest/helpers"; // test data import historyContent from "../../test/json/historyContent.json"; @@ -120,4 +122,51 @@ describe("changes operator", () => { expect(floobars.length).toEqual(1); }); }); + + describe("change feed should be shared", () => { + const lifeTime = 300; + const spinUpTime = 100; + + test("subscriging should show 1 feed, unsubscribing should show 0", async () => { + // subscribe to changes once + const feed$ = content$.pipe(changes(), takeUntil(timer(lifeTime))); + const spy = new ObserverSpy(); + feed$.subscribe(spy); + + // give it a little time to put itself together + await wait(spinUpTime); + expect(feeds.size).toEqual(1); + + // complete, share() should now remove instance + await spy.onComplete(); + expect(feeds.size).toEqual(0); + }); + + test("subscribing 2 times should result in one feed if it's the same DB", async () => { + // subscribe to changes onece + const feed$ = content$.pipe(changes(), takeUntil(timer(2 * lifeTime))); + const spy = new ObserverSpy(); + feed$.subscribe(spy); + + // give it a little time to put itself together + await wait(spinUpTime); + expect(feeds.size).toEqual(1); + + // subscribe again + const feed2$ = content$.pipe(changes(), takeUntil(timer(lifeTime))); + const spy2 = new ObserverSpy(); + feed2$.subscribe(spy2); + + await wait(spinUpTime); + expect(feeds.size).toEqual(1); + + // unsub from 2nd feed + await spy2.onComplete(); + expect(feeds.size).toEqual(1); + + // unsub from 1st feed + await spy.onComplete(); + expect(feeds.size).toEqual(0); + }); + }); }); diff --git a/client/src/components/History/caching/db/db.test.js b/client/src/components/History/caching/db/db.test.js index c25599cadba..95a63e7fb57 100644 --- a/client/src/components/History/caching/db/db.test.js +++ b/client/src/components/History/caching/db/db.test.js @@ -1,6 +1,6 @@ import isPromise from "is-promise"; import { isObservable } from "rxjs"; -import { wipeDatabase } from "./pouch"; +import { wipeDatabase } from "./wipeDatabase"; import { content$, dscContent$, buildContentId, buildCollectionId, prepContent, prepDscContent } from "./observables"; import { firstValueFrom } from "utils/observable/firstValueFrom"; diff --git a/client/src/components/History/caching/db/find.test.js b/client/src/components/History/caching/db/find.test.js index 6acd268420e..5cded53c7a9 100644 --- a/client/src/components/History/caching/db/find.test.js +++ b/client/src/components/History/caching/db/find.test.js @@ -2,7 +2,7 @@ import { Subject } from "rxjs"; import { take } from "rxjs/operators"; import { ObserverSpy } from "@hirez_io/observer-spy"; -import { wipeDatabase } from "./pouch"; +import { wipeDatabase } from "./wipeDatabase"; import { bulkCacheContent, bulkCacheDscContent } from "./promises"; import { content$, dscContent$, buildContentId } from "./observables"; import { find } from "./find"; diff --git a/client/src/components/History/caching/db/index.js b/client/src/components/History/caching/db/index.js index 0f8d7b9562e..0e77b861485 100644 --- a/client/src/components/History/caching/db/index.js +++ b/client/src/components/History/caching/db/index.js @@ -13,4 +13,4 @@ export { changes } from "./changes"; export { monitorQuery } from "./monitorQuery"; // utility function -export { wipeDatabase } from "./pouch"; +export { wipeDatabase } from "./wipeDatabase"; diff --git a/client/src/components/History/caching/db/monitorQuery.js b/client/src/components/History/caching/db/monitorQuery.js index efb9c7380b9..b91c2aecc2c 100644 --- a/client/src/components/History/caching/db/monitorQuery.js +++ b/client/src/components/History/caching/db/monitorQuery.js @@ -1,5 +1,5 @@ import deepEqual from "deep-equal"; -import { isObservable, partition, merge, concat, of } from "rxjs"; +import { isObservable, partition, merge, concat, of, pipe } from "rxjs"; import { tap, map, switchMap, debounceTime, distinctUntilChanged, pluck, filter } from "rxjs/operators"; import { matchesSelector } from "pouchdb-selector-core"; import { find } from "./find"; @@ -70,6 +70,37 @@ export const monitorQuery = (cfg = {}) => (request$) => { ); }; +// prettier-ignore +export const singleUpdateResult = () => { + return pipe( + map(update => { + const { action, initialMatches = [], doc = null } = update; + + // if no match, result is undefined + let result = null; + + switch (action) { + case ACTIONS.INITIAL: + if (initialMatches.length) { + result = initialMatches[0]; + } + break; + case ACTIONS.REMOVE: + result = null; + break; + case ACTIONS.UPDATE: + case ACTIONS.ADD: + if (doc) { + result = doc; + } + break; + } + + return result; + }) + ) +}; + export const ACTIONS = { INITIAL: "INITIAL", ADD: "ADD", diff --git a/client/src/components/History/caching/db/monitorQuery.test.js b/client/src/components/History/caching/db/monitorQuery.test.js index 14b551be462..a319901e04c 100644 --- a/client/src/components/History/caching/db/monitorQuery.test.js +++ b/client/src/components/History/caching/db/monitorQuery.test.js @@ -2,7 +2,7 @@ import { of, timer } from "rxjs"; import { take, takeUntil } from "rxjs/operators"; import { ObserverSpy } from "@hirez_io/observer-spy"; import { wait } from "jest/helpers"; -import { wipeDatabase } from "./pouch"; +import { wipeDatabase } from "./wipeDatabase"; import { monitorQuery, ACTIONS } from "./monitorQuery"; import { content$, dscContent$ } from "./observables"; diff --git a/client/src/components/History/caching/db/pouch.js b/client/src/components/History/caching/db/pouch.js index e6d60fb5daa..d8648e20498 100644 --- a/client/src/components/History/caching/db/pouch.js +++ b/client/src/components/History/caching/db/pouch.js @@ -8,6 +8,7 @@ import deepEqual from "deep-equal"; import { defer, pipe, from } from "rxjs"; import { tap, filter, mergeMap, reduce, shareReplay } from "rxjs/operators"; import { needs } from "../operators/needs"; +import { dasherize } from "underscore.string"; import PouchDB from "pouchdb"; import PouchAdapterMemory from "pouchdb-adapter-memory"; @@ -27,7 +28,7 @@ PouchDB.plugin(PouchErase); // const show = (obj) => console.log(JSON.stringify(obj, null, 4)); // Instance storage map, keyed by database name -const dbs = new Map(); +export const dbs = new Map(); /** * Generate an observable that initializes and shares a pouchdb instance. @@ -71,7 +72,7 @@ async function buildCollection(opts, appConfig) { function collectionName(opts, appConfig) { const { name: dbName } = opts; const { name: envName } = appConfig; - return `${dbName}-${envName}`; + return dasherize(`${dbName} ${envName}`); } async function installCollectionIndexes(db, indexes = []) { @@ -230,12 +231,3 @@ export async function deleteIndexes(db) { const promises = doomedIndexes.map((idx) => db.deleteIndex(idx)); return await Promise.all(promises); } - -/** - * Erases all stored database instances - */ -export async function wipeDatabase() { - for (const db of dbs.values()) { - await db.erase(); - } -} diff --git a/client/src/components/History/caching/db/wipeDatabase.js b/client/src/components/History/caching/db/wipeDatabase.js new file mode 100644 index 00000000000..0e5c5b6b2dc --- /dev/null +++ b/client/src/components/History/caching/db/wipeDatabase.js @@ -0,0 +1,10 @@ +import { dbs } from "./pouch"; + +/** + * Erases all stored database instances + */ +export async function wipeDatabase() { + for (const db of dbs.values()) { + await db.erase(); + } +} diff --git a/client/src/components/History/caching/loadHistoryContents.js b/client/src/components/History/caching/loadHistoryContents.js index c5a301f0eec..47de04d480e 100644 --- a/client/src/components/History/caching/loadHistoryContents.js +++ b/client/src/components/History/caching/loadHistoryContents.js @@ -4,7 +4,7 @@ import { throttleDistinct } from "utils/observable/throttleDistinct"; import { tag } from "rxjs-spy/operators/tag"; import { hydrate } from "./operators/hydrate"; -import { requestWithUpdateTime } from "./operators/requestWithUpdateTime"; +import { requestWithUpdateTime, requestWithUpdateTimeNoInitial } from "./operators/requestWithUpdateTime"; import { prependPath } from "./workerConfig"; import { bulkCacheContent } from "./db"; import { SearchParams } from "../model/SearchParams"; @@ -26,9 +26,13 @@ export const clearHistoryDateStore = async () => { export const loadHistoryContents = (cfg = {}) => (rawInputs$) => { const { onceEvery = 30 * 1000, - windowSize = SearchParams.pageSize + windowSize = SearchParams.pageSize, + noInitial = false } = cfg; + // if noInitial, only returns updates + const dateAppender = noInitial ? requestWithUpdateTimeNoInitial : requestWithUpdateTime + const inputs$ = rawInputs$.pipe( shareReplay(1) ); @@ -38,7 +42,7 @@ export const loadHistoryContents = (cfg = {}) => (rawInputs$) => { map(buildHistoryContentsUrl(windowSize)), throttleDistinct({ timeout: onceEvery }), map(prependPath), - requestWithUpdateTime({ dateStore }), + dateAppender({ dateStore }), shareReplay(1), ); @@ -93,10 +97,9 @@ export const loadHistoryContents = (cfg = {}) => (rawInputs$) => { // TODO: method in history model maybe? Or maybe Searchparams? export const buildHistoryContentsUrl = (windowSize) => (inputs) => { const [historyId, filters, hid] = inputs; - console.log("buildHistoryContentsUrl", windowSize, hid); // Filtering - const { showDeleted, showHidden } = filters; + const { showDeleted, showHidden, showVisible } = filters; let deletedClause = "deleted=False"; let visibleClause = "visible=True"; if (showDeleted) { @@ -105,7 +108,11 @@ export const buildHistoryContentsUrl = (windowSize) => (inputs) => { } if (showHidden) { deletedClause = ""; - visibleClause = "visible=False"; + if (showVisible) { + visibleClause = ""; + } else { + visibleClause = "visible=False"; + } } if (showDeleted && showHidden) { deletedClause = "deleted=True"; diff --git a/client/src/components/History/caching/monitorHistoryContent.test.js b/client/src/components/History/caching/monitorHistoryContent.test.js index a151e0f6c14..ad386d1bbf2 100644 --- a/client/src/components/History/caching/monitorHistoryContent.test.js +++ b/client/src/components/History/caching/monitorHistoryContent.test.js @@ -1,7 +1,7 @@ import { timer, of } from "rxjs"; import { take, pluck, takeUntil } from "rxjs/operators"; import { firstValueFrom } from "utils/observable/firstValueFrom"; -import { wipeDatabase } from "../caching/db/pouch"; +import { wipeDatabase } from "./db/wipeDatabase"; import { wait } from "jest/helpers"; import { ObserverSpy } from "@hirez_io/observer-spy"; diff --git a/client/src/components/History/caching/operators/requestWithUpdateTime.js b/client/src/components/History/caching/operators/requestWithUpdateTime.js index dce32e63b2d..7d3909aeb04 100644 --- a/client/src/components/History/caching/operators/requestWithUpdateTime.js +++ b/client/src/components/History/caching/operators/requestWithUpdateTime.js @@ -35,6 +35,22 @@ export const requestWithUpdateTime = (config = {}) => { ); }; +// prettier-ignore +// Like request withUpdateTime but omits initial results +export const requestWithUpdateTimeNoInitial = (config = {}) => { + const { + dateStore = requestDateStore, + } = config; + return pipe( + tap((url) => { + if (!dateStore.has(url)) { + dateStore.set(url, moment.utc()); + } + }), + requestWithUpdateTime(config) + ); +} + /** * Takes a base URL appends an update_time-gt restriction based on the lst * time this URL was requestd as indicated by the dateStore. diff --git a/client/src/components/History/index.js b/client/src/components/History/index.js index 869f7b38a61..55b124ab919 100644 --- a/client/src/components/History/index.js +++ b/client/src/components/History/index.js @@ -6,5 +6,5 @@ export { default as CurrentHistoryPanel } from "./CurrentHistoryPanel"; export { default as HistoryPanelProxy } from "./adapters/HistoryPanelProxy"; // rxjs debugging panel -import { initSpy } from "utils/observable/rxjsDebugging"; -initSpy(); +// import { initSpy } from "utils/observable/rxjsDebugging"; +// initSpy(); diff --git a/client/src/components/History/model/Content.js b/client/src/components/History/model/Content.js index 64900761280..8b5327fd0c6 100644 --- a/client/src/components/History/model/Content.js +++ b/client/src/components/History/model/Content.js @@ -17,13 +17,10 @@ export class Content extends dateMixin(ModelBase) { } get title() { - const { name, isDeleted, visible, purged } = this; - let result = name; + const { name, element_identifier, visible, purged } = this; + let result = element_identifier || name; const itemStates = []; - if (isDeleted) { - itemStates.push("Deleted"); - } - if (visible == false) { + if (visible == false && !element_identifier) { itemStates.push("Hidden"); } if (purged) { diff --git a/client/src/components/History/model/Dataset.js b/client/src/components/History/model/Dataset.js index 373d5abe924..68ca88bdcea 100644 --- a/client/src/components/History/model/Dataset.js +++ b/client/src/components/History/model/Dataset.js @@ -23,6 +23,10 @@ export class Dataset extends Content { return urlType in urls ? urls[urlType] : null; } + get canEditName() { + return this.element_identifier ? false : true; + } + get hasData() { return this.file_size && this.file_size > 0; } diff --git a/client/src/components/History/model/Dataset.test.js b/client/src/components/History/model/Dataset.test.js new file mode 100644 index 00000000000..05d18c9a459 --- /dev/null +++ b/client/src/components/History/model/Dataset.test.js @@ -0,0 +1,32 @@ +import { Content } from "./Content"; +import { Dataset } from "./Dataset"; + +import raw from "../test/json/Dataset.json"; + +const elementIdentifier = "collection_element1"; + +describe("Dataset", () => { + const historyDataset = new Dataset(raw); + const collectionElement = new Dataset({ ...raw, element_identifier: elementIdentifier }); + + test("it should be the right type", () => { + expect(historyDataset).toBeInstanceOf(Dataset); + expect(historyDataset).toBeInstanceOf(Content); + + expect(collectionElement).toBeInstanceOf(Dataset); + expect(collectionElement).toBeInstanceOf(Content); + }); + test("dataset title should include name", () => { + expect(historyDataset.name).toBe("M117C1-ch_2.fq.fastqsanger"); + expect(historyDataset.title).toBe("M117C1-ch_2.fq.fastqsanger"); + }); + test("collection element title should include element identifier for collection element", () => { + expect(collectionElement.title).toBe(elementIdentifier); + }); + test("dataset name can be edited", () => { + expect(historyDataset.canEditName).toBeTruthy(); + }); + test("collection element name cannot be edited", () => { + expect(collectionElement.canEditName).toBeFalsy(); + }); +}); diff --git a/client/src/components/History/model/syncCurrentHistoryToGalaxy.js b/client/src/components/History/model/syncCurrentHistoryToGalaxy.js index 49868a0c29f..be7ce846c14 100644 --- a/client/src/components/History/model/syncCurrentHistoryToGalaxy.js +++ b/client/src/components/History/model/syncCurrentHistoryToGalaxy.js @@ -1,28 +1,28 @@ // Sync Galaxy store to legacy galaxy current history -import { getGalaxyInstance } from "app"; -import { switchMap, pluck } from "rxjs/operators"; -import { monitorChange } from "utils/observable/monitorChange"; -import { monitorBackboneModel } from "utils/observable/monitorBackboneModel"; +import { fromEvent } from "rxjs"; +import { map, filter, switchMap, pluck, distinctUntilChanged } from "rxjs/operators"; -// prettier-ignore -export function syncCurrentHistoryToGalaxy(handler) { - - // wait for the current history panel to appear - const currentHistoryPanel$ = monitorChange(() => { - return getGalaxyInstance()?.currHistoryPanel?.model; - }); - - // then emit the id each time it changes - const result$ = currentHistoryPanel$.pipe( - switchMap(model => monitorBackboneModel(model, "id").pipe( - pluck("id") - )) +export function syncCurrentHistoryToGalaxy(galaxy$, store) { + // prettier-ignore + const historyId$ = galaxy$.pipe( + pluck("currHistoryPanel"), + switchMap((panel) => { + // relationship between currHistoryPanel and its model is murky, but I don't care, I'm + // just going to grab the id fresh after every possible event + return fromEvent(panel, "all").pipe( + map(() => panel.model.id) + ); + }), + filter(Boolean), + distinctUntilChanged() ); - return result$.subscribe( - val => handler(val), - err => console.log("syncCurrentHistoryToGalaxy error", err), + return historyId$.subscribe( + (id) => { + store.commit("betaHistory/setCurrentHistoryId", id); + }, + (err) => console.log("syncCurrentHistoryToGalaxy error", err), () => console.log("syncCurrentHistoryToGalaxy complete") ); } diff --git a/client/src/components/History/providers/CollectionContentProvider/CollectionContentProvider.test.js b/client/src/components/History/providers/CollectionContentProvider/CollectionContentProvider.test.js index dd0527e9838..f36a84e8c93 100644 --- a/client/src/components/History/providers/CollectionContentProvider/CollectionContentProvider.test.js +++ b/client/src/components/History/providers/CollectionContentProvider/CollectionContentProvider.test.js @@ -30,7 +30,7 @@ loadCollectionContents.mockImplementation((config) => (src$) => { const mountProvider = async (Component, propsData) => { const localVue = createLocalVue(); localVue.use(vueRxShortcutPlugin); - const wrapper = mountRenderless(Component, localVue, propsData); + const wrapper = mountRenderless(Component, { localVue, propsData }); await wrapper.vm.$nextTick(); return wrapper; }; diff --git a/client/src/components/History/providers/DscProvider/DscProvider.test.js b/client/src/components/History/providers/DscProvider/DscProvider.test.js index 894e380bed7..56b6a19bc2c 100644 --- a/client/src/components/History/providers/DscProvider/DscProvider.test.js +++ b/client/src/components/History/providers/DscProvider/DscProvider.test.js @@ -1,7 +1,7 @@ /* eslint-disable no-unused-vars */ import { createLocalVue } from "@vue/test-utils"; import { wait, mountRenderless } from "jest/helpers"; -import { wipeDatabase } from "../../caching/db/pouch"; +import { wipeDatabase } from "../../caching"; import { cacheContent, getCachedContent, cacheCollectionContent, getCachedCollectionContent } from "../../caching"; import { DatasetCollection } from "../../model/DatasetCollection"; import DscProvider from "./DscProvider"; @@ -20,7 +20,7 @@ jest.mock("../../caching"); const mountProvider = async (Component, propsData) => { const localVue = createLocalVue(); localVue.use(vueRxShortcutPlugin); - const wrapper = mountRenderless(Component, localVue, propsData); + const wrapper = mountRenderless(Component, { localVue, propsData }); await wrapper.vm.$nextTick(); return wrapper; }; diff --git a/client/src/components/History/providers/HistoryContentProvider/HistoryContentProvider.test.js b/client/src/components/History/providers/HistoryContentProvider/HistoryContentProvider.test.js index 789c63ca01e..14f479eca0c 100644 --- a/client/src/components/History/providers/HistoryContentProvider/HistoryContentProvider.test.js +++ b/client/src/components/History/providers/HistoryContentProvider/HistoryContentProvider.test.js @@ -67,7 +67,7 @@ const payloadChange = async ({ vm, label = "payload change" }) => { const mountProvider = async (Component, propsData) => { const localVue = createLocalVue(); localVue.use(vueRxShortcutPlugin); - const wrapper = mountRenderless(Component, localVue, propsData); + const wrapper = mountRenderless(Component, { localVue, propsData }); await wrapper.vm.$nextTick(); return wrapper; }; diff --git a/client/src/components/HistoryExport/ExportLink.vue b/client/src/components/HistoryExport/ExportLink.vue new file mode 100644 index 00000000000..f42627f2745 --- /dev/null +++ b/client/src/components/HistoryExport/ExportLink.vue @@ -0,0 +1,61 @@ + + + diff --git a/client/src/components/HistoryExport/Index.test.js b/client/src/components/HistoryExport/Index.test.js new file mode 100644 index 00000000000..830104b2491 --- /dev/null +++ b/client/src/components/HistoryExport/Index.test.js @@ -0,0 +1,33 @@ +import { shallowMount } from "@vue/test-utils"; +import Index from "./Index.vue"; +import { getLocalVue } from "jest/helpers"; +import axios from "axios"; +import MockAdapter from "axios-mock-adapter"; + +const TEST_PLUGINS_URL = "/api/remote_files/plugins"; + +const localVue = getLocalVue(); + +describe("Index.vue", () => { + let axiosMock; + + beforeEach(() => { + axiosMock = new MockAdapter(axios); + axiosMock.onGet(TEST_PLUGINS_URL).reply(200, [{ id: "foo", writable: false }]); + }); + + it("should render tabs", () => { + // just make sure the component renders to catch obvious big errors + const wrapper = shallowMount(Index, { + propsData: { + historyId: "test_id", + }, + localVue, + }); + expect(wrapper.exists("b-tabs-stub")).toBeTruthy(); + }); + + afterEach(() => { + axiosMock.restore(); + }); +}); diff --git a/client/src/components/HistoryExport/Index.vue b/client/src/components/HistoryExport/Index.vue new file mode 100644 index 00000000000..51db0e2fccd --- /dev/null +++ b/client/src/components/HistoryExport/Index.vue @@ -0,0 +1,67 @@ + + + diff --git a/client/src/components/HistoryExport/ToLink.test.js b/client/src/components/HistoryExport/ToLink.test.js new file mode 100644 index 00000000000..39b3defb70f --- /dev/null +++ b/client/src/components/HistoryExport/ToLink.test.js @@ -0,0 +1,64 @@ +import { shallowMount } from "@vue/test-utils"; +import { getLocalVue } from "jest/helpers"; +import ToLink from "./ToLink.vue"; +import flushPromises from "flush-promises"; +import MockAdapter from "axios-mock-adapter"; +import axios from "axios"; +import { waitOnJob } from "components/JobStates/wait"; + +const localVue = getLocalVue(); +const TEST_HISTORY_ID = "hist1235"; +const TEST_EXPORTS_URL = `/api/histories/${TEST_HISTORY_ID}/exports`; +const TEST_JOB_ID = "test1234job"; + +jest.mock("components/JobStates/wait"); + +describe("ToLink.vue", () => { + let axiosMock; + let wrapper; + + async function mountWithInitialExports(exports) { + axiosMock.onGet(TEST_EXPORTS_URL).reply(200, exports); + wrapper = shallowMount(ToLink, { + propsData: { + historyId: TEST_HISTORY_ID, + }, + localVue, + }); + await wrapper.vm.$nextTick(); + expect(wrapper.find("loading-span-stub").exists()).toBeTruthy(); + await flushPromises(); + } + + beforeEach(async () => { + axiosMock = new MockAdapter(axios); + }); + + it("should display a link if no exports ever generated", async () => { + await mountWithInitialExports([]); + expect(wrapper.find(".export-link")).toBeTruthy(); + expect(wrapper.find("loading-span-stub").exists()).toBeFalsy(); // loading span gone + }); + + it("should start polling if latest export is preparing", async () => { + let then = null; + waitOnJob.mockReturnValue( + new Promise((then_) => { + then = then_; + }) + ); + await mountWithInitialExports([ + { + preparing: true, + job_id: TEST_JOB_ID, + }, + ]); + expect(then).toBeTruthy(); + expect(wrapper.vm.waitingOnJob).toBeTruthy(); + expect(wrapper.find("loading-span-stub").exists()).toBeTruthy(); + }); + + afterEach(() => { + axiosMock.restore(); + }); +}); diff --git a/client/src/components/HistoryExport/ToLink.vue b/client/src/components/HistoryExport/ToLink.vue new file mode 100644 index 00000000000..b45587bac26 --- /dev/null +++ b/client/src/components/HistoryExport/ToLink.vue @@ -0,0 +1,173 @@ + + + diff --git a/client/src/components/HistoryExport/ToRemoteFile.test.js b/client/src/components/HistoryExport/ToRemoteFile.test.js new file mode 100644 index 00000000000..275d2076bab --- /dev/null +++ b/client/src/components/HistoryExport/ToRemoteFile.test.js @@ -0,0 +1,72 @@ +import { shallowMount } from "@vue/test-utils"; +import { getLocalVue } from "jest/helpers"; +import ToRemoteFile from "./ToRemoteFile.vue"; +import MockAdapter from "axios-mock-adapter"; +import axios from "axios"; +import flushPromises from "flush-promises"; +import { waitOnJob } from "components/JobStates/wait"; + +const localVue = getLocalVue(); +const TEST_HISTORY_ID = "hist1235"; +const TEST_JOB_ID = "job123789"; +const TEST_EXPORTS_URL = `/api/histories/${TEST_HISTORY_ID}/exports`; + +jest.mock("components/JobStates/wait"); + +describe("ToRemoteFile.vue", () => { + let axiosMock; + let wrapper; + + beforeEach(async () => { + axiosMock = new MockAdapter(axios); + wrapper = shallowMount(ToRemoteFile, { + propsData: { + historyId: TEST_HISTORY_ID, + }, + localVue, + }); + }); + + it("should render a form with export disable because inputs empty", async () => { + expect(wrapper.find(".export-button").exists()).toBeTruthy(); + expect(wrapper.find(".export-button").attributes("disabled")).toBeTruthy(); + expect(wrapper.vm.canExport).toBeFalsy(); + }); + + it("should allow export when name and directory available", async () => { + await wrapper.setData({ + name: "export.tar.gz", + directory: "gxfiles://", + }); + expect(wrapper.vm.directory).toEqual("gxfiles://"); + expect(wrapper.vm.name).toEqual("export.tar.gz"); + expect(wrapper.vm.canExport).toBeTruthy(); + }); + + it("should issue export PUT request on export", async () => { + await wrapper.setData({ + name: "export.tar.gz", + directory: "gxfiles://", + }); + let request; + axiosMock.onPut(TEST_EXPORTS_URL).reply((request_) => { + request = request_; + return [200, { job_id: TEST_JOB_ID }]; + }); + waitOnJob.mockReturnValue( + new Promise((then) => { + then({ state: "ok" }); + }) + ); + wrapper.vm.doExport(); + await flushPromises(); + const putData = JSON.parse(request.data); + expect(putData.directory_uri).toEqual("gxfiles://"); + expect(putData.file_name).toEqual("export.tar.gz"); + expect(wrapper.find("b-alert-stub").attributes("variant")).toEqual("success"); + }); + + afterEach(() => { + axiosMock.restore(); + }); +}); diff --git a/client/src/components/HistoryExport/ToRemoteFile.vue b/client/src/components/HistoryExport/ToRemoteFile.vue new file mode 100644 index 00000000000..85ea86ad414 --- /dev/null +++ b/client/src/components/HistoryExport/ToRemoteFile.vue @@ -0,0 +1,119 @@ + + + diff --git a/client/src/components/HistoryExport/index.js b/client/src/components/HistoryExport/index.js new file mode 100644 index 00000000000..d1e4f95783f --- /dev/null +++ b/client/src/components/HistoryExport/index.js @@ -0,0 +1 @@ +export { default as HistoryExport } from "./Index.vue"; diff --git a/client/src/components/HistoryImport.test.js b/client/src/components/HistoryImport.test.js new file mode 100644 index 00000000000..2e304a50588 --- /dev/null +++ b/client/src/components/HistoryImport.test.js @@ -0,0 +1,82 @@ +import { shallowMount } from "@vue/test-utils"; +import { getLocalVue } from "jest/helpers"; +import HistoryImport from "./HistoryImport.vue"; +import MockAdapter from "axios-mock-adapter"; +import axios from "axios"; +import flushPromises from "flush-promises"; +import { waitOnJob } from "components/JobStates/wait"; + +const localVue = getLocalVue(); +const TEST_JOB_ID = "job123789"; +const TEST_HISTORY_URI = "/api/histories"; +const TEST_SOURCE_URL = "http://galaxy.example/import"; +const TEST_PLUGINS_URL = "/api/remote_files/plugins"; + +jest.mock("components/JobStates/wait"); + +describe("HistoryImport.vue", () => { + let axiosMock; + let wrapper; + + beforeEach(async () => { + axiosMock = new MockAdapter(axios); + axiosMock.onGet(TEST_PLUGINS_URL).reply(200, [{ id: "foo", writable: false }]); + wrapper = shallowMount(HistoryImport, { + propsData: {}, + localVue, + }); + await flushPromises(); + }); + + it("should render a form with submit disabled because inputs empty", async () => { + expect(wrapper.find(".import-button").exists()).toBeTruthy(); + expect(wrapper.find(".import-button").attributes("disabled")).toBeTruthy(); + expect(wrapper.vm.importReady).toBeFalsy(); + }); + + it("should allow import when URL available", async () => { + await wrapper.setData({ + sourceURL: TEST_SOURCE_URL, + }); + expect(wrapper.vm.importReady).toBeTruthy(); + }); + + it("should require an URI if that is the import type", async () => { + await wrapper.setData({ + sourceURL: TEST_SOURCE_URL, + importType: "sourceRemoteFilesUri", + }); + expect(wrapper.vm.importReady).toBeFalsy(); + }); + + it("should post to create a new history and wait on job when submitted", async () => { + await wrapper.setData({ + sourceURL: TEST_SOURCE_URL, + }); + let formData; + axiosMock.onPost(TEST_HISTORY_URI).reply((request) => { + formData = request.data; + return [200, { job_id: TEST_JOB_ID }]; + }); + let then; + waitOnJob.mockReturnValue( + new Promise((then_) => { + then = then_; + }) + ); + wrapper.vm.submit(); + await flushPromises(); + expect(formData.get("archive_source")).toBe(TEST_SOURCE_URL); + expect(wrapper.vm.waitingOnJob).toBeTruthy(); + + // complete job and make sure waitingOnJob is false and complete is true + then({ state: "ok" }); + await flushPromises(); + expect(wrapper.vm.waitingOnJob).toBeFalsy(); + expect(wrapper.vm.complete).toBeTruthy(); + }); + + afterEach(() => { + axiosMock.restore(); + }); +}); diff --git a/client/src/components/HistoryImport.vue b/client/src/components/HistoryImport.vue index 5a3314bd112..305000896a0 100644 --- a/client/src/components/HistoryImport.vue +++ b/client/src/components/HistoryImport.vue @@ -1,57 +1,172 @@ + + diff --git a/client/src/components/JobInformation/CodeRow.vue b/client/src/components/JobInformation/CodeRow.vue new file mode 100644 index 00000000000..2a837eef465 --- /dev/null +++ b/client/src/components/JobInformation/CodeRow.vue @@ -0,0 +1,38 @@ + + diff --git a/client/src/components/JobInformation/JobError.vue b/client/src/components/JobInformation/JobError.vue new file mode 100644 index 00000000000..bab388d1f52 --- /dev/null +++ b/client/src/components/JobInformation/JobError.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/client/src/components/JobInformation/JobInformation.test.js b/client/src/components/JobInformation/JobInformation.test.js index 245718c3f3a..1c5c9a374f3 100644 --- a/client/src/components/JobInformation/JobInformation.test.js +++ b/client/src/components/JobInformation/JobInformation.test.js @@ -1,4 +1,6 @@ import Vuex from "vuex"; +import axios from "axios"; +import MockAdapter from "axios-mock-adapter"; import { mount, createLocalVue } from "@vue/test-utils"; import JobInformation from "./JobInformation"; import datasetResponse from "components/DatasetInformation/testData/datasetResponse"; @@ -9,7 +11,6 @@ import createCache from "vuex-cache"; jest.mock("app"); -const HDA_ID = "FOO_HDA_ID"; const JOB_ID = "test_id"; const localVue = createLocalVue(); @@ -38,6 +39,16 @@ const jobStore = new Vuex.Store({ describe("JobInformation/JobInformation.vue", () => { let wrapper; let jobInfoTable; + let axiosMock; + + beforeEach(() => { + axiosMock = new MockAdapter(axios); + axiosMock.onGet(new RegExp(`api/configuration/decode/*`)).reply(200, { decoded_id: 123 }); + }); + + afterEach(() => { + axiosMock.restore(); + }); const verifyValues = (rendered_entries, infoTable, backendResponse) => { rendered_entries.forEach((entry) => { @@ -45,13 +56,13 @@ describe("JobInformation/JobInformation.vue", () => { const backendText = entry.wrapped_in_brackets ? `(${backendResponse[entry.backend_key]})` : backendResponse[entry.backend_key]; - expect(renderedText).toBe(backendText.toString()); + // use includes because we append the decoded id + expect(renderedText.includes(backendText.toString())).toBeTruthy(); }); }; beforeEach(async () => { const propsData = { - hda_id: HDA_ID, job_id: JOB_ID, }; @@ -69,14 +80,14 @@ describe("JobInformation/JobInformation.vue", () => { expect(jobInfoTable).toBeTruthy(); const rows = jobInfoTable.findAll("tr"); // should contain 9 rows - expect(rows.length).toBe(12); + expect(rows.length).toBe(9); }); - it("stdout, stderr links", async () => { - ["stderr", "stdout"].forEach((std) => { - const rendered_link = jobInfoTable.find(`#${std} > a`); - expect(rendered_link.text()).toBe(std); - expect(rendered_link.attributes("href")).toBe(`/datasets/${HDA_ID}/${std}`); + it("stdout and stderr should be rendered", async () => { + ["stdout", "stderr"].forEach((std) => { + const label = jobInfoTable.find("#" + std); + const value = label.find(".code"); + expect(value.text()).toBe(std); }); }); @@ -98,15 +109,4 @@ describe("JobInformation/JobInformation.vue", () => { ]; verifyValues(rendered_entries, jobInfoTable, jobResponse); }); - - it("dataset API content", async () => { - const rendered_entries = [ - { id: "file_name", backend_key: "file_name" }, - { id: "dataset-uuid", backend_key: "uuid" }, - { id: "history_id", backend_key: "history_id" }, - { id: "history-dataset-id", backend_key: "dataset_id", wrapped_in_brackets: true }, - { id: "dataset-id", backend_key: "id" }, - ]; - verifyValues(rendered_entries, jobInfoTable, datasetResponse); - }); }); diff --git a/client/src/components/JobInformation/JobInformation.vue b/client/src/components/JobInformation/JobInformation.vue index 2e61b27ca63..161621a3b5c 100644 --- a/client/src/components/JobInformation/JobInformation.vue +++ b/client/src/components/JobInformation/JobInformation.vue @@ -1,7 +1,7 @@ + @@ -606,6 +610,11 @@ export default { this.fetchFolderContents(this.include_deleted); }, }, + perPage: { + handler: function (value) { + this.fetchFolderContents(this.include_deleted); + }, + }, }, }; diff --git a/client/src/components/LibraryFolder/TopToolbar/import-to-history/import-collection.js b/client/src/components/LibraryFolder/TopToolbar/import-to-history/import-collection.js index 62386460384..ffc5ae0e3d1 100644 --- a/client/src/components/LibraryFolder/TopToolbar/import-to-history/import-collection.js +++ b/client/src/components/LibraryFolder/TopToolbar/import-to-history/import-collection.js @@ -5,9 +5,12 @@ import mod_library_model from "mvc/library/library-model"; import _ from "underscore"; import Backbone from "backbone"; import axios from "axios"; -import LIST_CREATOR from "mvc/collection/list-collection-creator"; -import PAIR_CREATOR from "mvc/collection/pair-collection-creator"; -import PAIRED_CREATOR from "mvc/collection/list-of-pairs-collection-creator"; +import LIST_CREATOR from "components/Collections/ListCollectionCreator"; +import LIST_CREATOR_MODAL from "components/Collections/ListCollectionCreatorModal"; +import PAIR_CREATOR from "components/Collections/PairCollectionCreator"; +import PAIR_CREATOR_MODAL from "components/Collections/PairCollectionCreatorModal"; +import PAIRED_CREATOR from "components/Collections/PairedListCollectionCreatorModal"; +import RULE_CREATOR_MODAL from "components/Collections/RuleBasedCollectionCreatorModal"; import HDCA_MODEL from "mvc/history/hdca-model"; var ImportCollectionModal = Backbone.View.extend({ @@ -111,7 +114,7 @@ var ImportCollectionModal = Backbone.View.extend({ let creationFn; this.collectionType = this.modal.$el.find("#library-collection-type-select").val(); if (this.collectionType === "list") { - creator_class = LIST_CREATOR.ListCollectionCreator; + creator_class = LIST_CREATOR; creationFn = (elements, name, hideSourceItems) => { elements = elements.map((element) => ({ id: element.id, @@ -120,13 +123,13 @@ var ImportCollectionModal = Backbone.View.extend({ })); return this.createHDCA(elements, this.collectionType, name, hideSourceItems, history_id); }; - LIST_CREATOR.collectionCreatorModal( + LIST_CREATOR_MODAL.collectionCreatorModal( collection_elements, { creationFn: creationFn, title: modal_title, defaultHideSourceItems: true }, creator_class ); } else if (this.collectionType === "paired") { - creator_class = PAIR_CREATOR.PairCollectionCreator; + creator_class = PAIR_CREATOR; creationFn = (elements, name, hideSourceItems) => { elements = [ { name: "forward", src: "ldda", id: elements[0].id }, @@ -134,7 +137,7 @@ var ImportCollectionModal = Backbone.View.extend({ ]; return this.createHDCA(elements, this.collectionType, name, hideSourceItems, history_id); }; - LIST_CREATOR.collectionCreatorModal( + PAIR_CREATOR_MODAL.collectionCreatorModal( collection_elements, { creationFn: creationFn, title: modal_title, defaultHideSourceItems: true }, creator_class @@ -154,7 +157,7 @@ var ImportCollectionModal = Backbone.View.extend({ const creationFn = (elements, collectionType, name, hideSourceItems) => { return this.createHDCA(elements, collectionType, name, hideSourceItems, history_id); }; - LIST_CREATOR.ruleBasedCollectionCreatorModal(collection_elements, "library_datasets", "collections", { + RULE_CREATOR_MODAL.ruleBasedCollectionCreatorModal(collection_elements, "library_datasets", "collections", { creationFn: creationFn, defaultHideSourceItems: true, }); diff --git a/client/src/components/Masthead/Masthead.test.js b/client/src/components/Masthead/Masthead.test.js index 1e92be2e7df..f7e1b6c277a 100644 --- a/client/src/components/Masthead/Masthead.test.js +++ b/client/src/components/Masthead/Masthead.test.js @@ -8,6 +8,7 @@ import { loadWebhookMenuItems } from "./_webhooks"; jest.mock("app"); jest.mock("layout/menu"); jest.mock("./_webhooks"); +jest.mock("../History/caching"); describe("Masthead.vue", () => { let wrapper; @@ -86,7 +87,6 @@ describe("Masthead.vue", () => { propsData: { mastheadState, activeTab, - appRoot: "prefix/", }, localVue, }); @@ -111,7 +111,7 @@ describe("Masthead.vue", () => { expect(wrapper.findAll("li.nav-item").length).toBe(6); // Ensure specified link title respected. expect(wrapper.find("#analysis a").text()).toBe("Analyze"); - expect(wrapper.find("#analysis a").attributes("href")).toBe("prefix/root"); + expect(wrapper.find("#analysis a").attributes("href")).toBe("/root"); }); it("should render tab items with menus", () => { @@ -120,7 +120,7 @@ describe("Masthead.vue", () => { expect(wrapper.find("#shared").classes("dropdown")).toBe(true); expect(wrapper.findAll("#shared .dropdown-menu li").length).toBe(1); - expect(wrapper.find("#shared .dropdown-menu li a").attributes().href).toBe("prefix/_menu_url"); + expect(wrapper.find("#shared .dropdown-menu li a").attributes().href).toBe("/_menu_url"); expect(wrapper.find("#shared .dropdown-menu li a").attributes().target).toBe("_menu_target"); expect(wrapper.find("#shared .dropdown-menu li a").text()).toBe("_menu_title"); }); diff --git a/client/src/components/Masthead/Masthead.vue b/client/src/components/Masthead/Masthead.vue index d271fb0da16..17672ff84ab 100644 --- a/client/src/components/Masthead/Masthead.vue +++ b/client/src/components/Masthead/Masthead.vue @@ -5,20 +5,16 @@ logo {{ brandTitle }} - -

@@ -60,14 +56,6 @@ export default { type: Object, default: null, }, - appRoot: { - type: String, - default: null, - }, - galaxy: { - type: Object, - default: null, - }, menuOptions: { type: Object, default: null, diff --git a/client/src/components/Masthead/MastheadItem.test.js b/client/src/components/Masthead/MastheadItem.test.js index 6331bd64988..9baf36e1cff 100644 --- a/client/src/components/Masthead/MastheadItem.test.js +++ b/client/src/components/Masthead/MastheadItem.test.js @@ -31,7 +31,7 @@ describe("MastheadItem.vue", () => { active = "mytab"; menu = true; wrapper = m(); - expect(wrapper.vm.active).toBe(true); + expect(wrapper.vm.classes.active).toBe(true); expect(wrapper.vm.menu).toBe(true); }); @@ -39,7 +39,7 @@ describe("MastheadItem.vue", () => { active = "othertab"; menu = false; wrapper = m(); - expect(wrapper.vm.active).toBe(false); + expect(wrapper.vm.classes.active).toBe(false); expect(wrapper.vm.menu).toBe(false); }); }); diff --git a/client/src/components/Masthead/MastheadItem.vue b/client/src/components/Masthead/MastheadItem.vue index d3c5119a7ec..561db183262 100644 --- a/client/src/components/Masthead/MastheadItem.vue +++ b/client/src/components/Masthead/MastheadItem.vue @@ -3,7 +3,6 @@ v-if="!menu" :class="classes" :style="styles" - :active="!tab.disabled" :id="tab.id" :href="formatUrl(tab.url)" :target="tab.target || '_parent'" @@ -16,7 +15,7 @@ >