mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-09-24 16:30:27 +08:00
Merge branch 'dev' of ssh://github.com/galaxyproject/galaxy into stc_sections
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
.venv
|
||||
database
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -21,3 +21,5 @@ jobs:
|
||||
run: pip install tox
|
||||
- name: run tests
|
||||
run: tox -e lint
|
||||
- name: run mypy checks
|
||||
run: tox -e mypy
|
||||
|
||||
@@ -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/') }}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
+1
-3
@@ -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
|
||||
|
||||
@@ -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=<image0 name>...
|
||||
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
|
||||
@@ -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
|
||||
|
||||
|
||||
+3
-1
@@ -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",
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
<template>
|
||||
<div class="list-collection-creator">
|
||||
<div v-if="(state == 'error')">
|
||||
<b-alert show variant="danger">
|
||||
{{ errorText }}
|
||||
</b-alert>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="noElementsSelected">
|
||||
<b-alert show variant="warning" dismissible>
|
||||
{{ noElementsHeader }}
|
||||
{{ allInvalidElementsPartOne }}
|
||||
<a class="cancel-text" href="javascript:void(0)" role="button" @click="oncancel">
|
||||
{{ cancelText }}
|
||||
</a>
|
||||
{{ allInvalidElementsPartTwo }}
|
||||
</b-alert>
|
||||
<div class="float-left">
|
||||
<button class="cancel-create btn" tabindex="-1" @click="oncancel">
|
||||
{{ l("Cancel") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="allElementsAreInvalid">
|
||||
<b-alert show variant="warning" dismissible>
|
||||
{{ invalidHeader }}
|
||||
<ul>
|
||||
<li v-for="problem in returnInvalidElements" :key="problem">
|
||||
{{ problem }}
|
||||
</li>
|
||||
</ul>
|
||||
{{ allInvalidElementsPartOne }}
|
||||
<a class="cancel-text" href="javascript:void(0)" role="button" @click="oncancel">
|
||||
{{ cancelText }}
|
||||
</a>
|
||||
{{ allInvalidElementsPartTwo }}
|
||||
</b-alert>
|
||||
<div class="float-left">
|
||||
<button class="cancel-create btn" tabindex="-1" @click="oncancel">
|
||||
{{ l("Cancel") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="returnInvalidElementsLength">
|
||||
<b-alert show variant="warning" dismissible>
|
||||
{{ invalidHeader }}
|
||||
<ul>
|
||||
<li v-for="problem in returnInvalidElements" :key="problem">
|
||||
{{ problem }}
|
||||
</li>
|
||||
</ul>
|
||||
</b-alert>
|
||||
</div>
|
||||
<div v-if="showDuplicateError">
|
||||
<b-alert show variant="danger">
|
||||
{{ l("Collections cannot have duplicated names. The following list names are duplicated: ") }}
|
||||
<ol>
|
||||
<li v-for="name in duplicateNames" :key="name">{{ name }}</li>
|
||||
</ol>
|
||||
{{ l("Please fix these duplicates and try again.") }}
|
||||
</b-alert>
|
||||
</div>
|
||||
<collection-creator
|
||||
:oncancel="oncancel"
|
||||
:hideSourceItems="hideSourceItems"
|
||||
@onUpdateHideSourceItems="onUpdateHideSourceItems"
|
||||
@clicked-create="clickedCreate"
|
||||
:creationFn="creationFn"
|
||||
>
|
||||
<template v-slot:help-content>
|
||||
<p>
|
||||
{{
|
||||
l(
|
||||
[
|
||||
"Collections of datasets are permanent, ordered lists of datasets that can be passed to tools ",
|
||||
"and workflows in order to have analyses done on each member of the entire group. This interface allows ",
|
||||
"you to create a collection and re-order the final collection.",
|
||||
].join("")
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
{{ l("Rename elements in the list by clicking on") }}
|
||||
<i data-target=".collection-element .name">
|
||||
{{ l("the existing name") }}
|
||||
</i>
|
||||
{{ l(".") }}
|
||||
</li>
|
||||
<li>
|
||||
{{ l("Discard elements from the final created list by clicking on the ") }}
|
||||
<i data-target=".collection-element .discard">
|
||||
{{ l("Discard") }}
|
||||
</i>
|
||||
{{ l("button.") }}
|
||||
</li>
|
||||
<li>
|
||||
{{
|
||||
l(
|
||||
"Reorder the list by clicking and dragging elements. Select multiple elements by clicking on"
|
||||
)
|
||||
}}
|
||||
<i data-target=".collection-element">
|
||||
{{ l("them") }}
|
||||
</i>
|
||||
{{
|
||||
l(
|
||||
"and you can then move those selected by dragging the entire group. Deselect them by clicking them again or by clicking the"
|
||||
)
|
||||
}}
|
||||
<i data-target=".clear-selected">
|
||||
{{ l("Clear selected") }}
|
||||
</i>
|
||||
{{ l("link.") }}
|
||||
</li>
|
||||
<li>
|
||||
{{ l("Click the") }}
|
||||
<i data-target=".reset">
|
||||
{{ l("Start over") }}
|
||||
</i>
|
||||
{{ l("link to begin again as if you had just opened the interface.") }}
|
||||
</li>
|
||||
<li>
|
||||
{{ l("Click the") }}
|
||||
<i data-target=".cancel-create">
|
||||
{{ l("Cancel") }}
|
||||
</i>
|
||||
{{ l("button to exit the interface.") }}
|
||||
</li>
|
||||
</ul>
|
||||
<br />
|
||||
<p>
|
||||
{{ l("Once your collection is complete, enter a ") }}
|
||||
<i data-target=".collection-name">
|
||||
{{ l("name") }}
|
||||
</i>
|
||||
{{ l("and click") }}
|
||||
<i data-target=".create-collection">
|
||||
{{ l("Create list") }}
|
||||
</i>
|
||||
{{ l(".") }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-slot:middle-content>
|
||||
<div class="collection-elements-controls">
|
||||
<a
|
||||
class="reset"
|
||||
href="javascript:void(0);"
|
||||
role="button"
|
||||
:title="titleUndoButton"
|
||||
@click="reset"
|
||||
>
|
||||
{{ l("Start over") }}
|
||||
</a>
|
||||
<a
|
||||
class="clear-selected"
|
||||
v-if="atLeastOneDatasetIsSelected"
|
||||
href="javascript:void(0);"
|
||||
role="button"
|
||||
:title="titleDeselectButton"
|
||||
@click="clickClearAll"
|
||||
>
|
||||
{{ l("Clear selected") }}
|
||||
</a>
|
||||
</div>
|
||||
<draggable
|
||||
v-model="workingElements"
|
||||
class="collection-elements scroll-container flex-row drop-zone"
|
||||
@start="drag = true"
|
||||
@end="drag = false"
|
||||
>
|
||||
<div v-if="noMoreValidDatasets">
|
||||
<b-alert show variant="warning" dismissible>
|
||||
{{ discardedElementsHeader }}
|
||||
<a class="reset-text" href="javascript:void(0)" role="button" @click="reset">
|
||||
{{ startOverText }}
|
||||
</a>
|
||||
?
|
||||
</b-alert>
|
||||
</div>
|
||||
<dataset-collection-element-view
|
||||
v-else
|
||||
v-for="element in returnWorkingElements"
|
||||
:key="element.id"
|
||||
@element-is-selected="elementSelected"
|
||||
@element-is-discarded="elementDiscarded"
|
||||
:class="{ selected: getSelectedDatasetElems.includes(element.id) }"
|
||||
:element="element"
|
||||
/>
|
||||
</draggable>
|
||||
</template>
|
||||
</collection-creator>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import mixin from "./common/mixin";
|
||||
import DatasetCollectionElementView from "./ListDatasetCollectionElementView";
|
||||
import _l from "utils/localization";
|
||||
import STATES from "mvc/dataset/states";
|
||||
import "ui/hoverhighlight";
|
||||
import Vue from "vue";
|
||||
import BootstrapVue from "bootstrap-vue";
|
||||
import draggable from "vuedraggable";
|
||||
|
||||
Vue.use(BootstrapVue);
|
||||
export default {
|
||||
mixins: [mixin],
|
||||
created() {
|
||||
this._instanceSetUp();
|
||||
this._elementsSetUp();
|
||||
this.saveOriginalNames();
|
||||
},
|
||||
components: { DatasetCollectionElementView, draggable },
|
||||
data: function () {
|
||||
return {
|
||||
state: "build", //error
|
||||
titleUndoButton: _l("Undo all reordering and discards"),
|
||||
titleDeselectButton: _l("De-select all selected datasets"),
|
||||
noElementsHeader: _l("No datasets were selected"),
|
||||
discardedElementsHeader: _l("No elements left. Would you like to"),
|
||||
invalidHeader: _l("The following selections could not be included due to problems:"),
|
||||
allInvalidElementsPartOne: _l("At least one element is needed for the collection. You may need to"),
|
||||
cancelText: _l("cancel"),
|
||||
startOverText: _l("start over"),
|
||||
allInvalidElementsPartTwo: _l("and reselect new elements."),
|
||||
errorText: _l("Galaxy could not be reached and may be updating. Try again in a few minutes."),
|
||||
workingElements: [],
|
||||
originalNamedElements: [],
|
||||
invalidElements: [],
|
||||
selectedDatasetElems: [],
|
||||
removeExtensions: true,
|
||||
duplicateNames: [],
|
||||
};
|
||||
},
|
||||
props: {
|
||||
/** distance from list edge to begin autoscrolling list */
|
||||
autoscrollDist: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 24,
|
||||
},
|
||||
/** Color passed to hoverhighlight */
|
||||
highlightClr: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "rgba( 64, 255, 255, 1.0 )",
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
atLeastOneDatasetIsSelected() {
|
||||
return this.selectedDatasetElems.length > 0;
|
||||
},
|
||||
getSelectedDatasetElems() {
|
||||
return this.selectedDatasetElems;
|
||||
},
|
||||
returnWorkingElements: function () {
|
||||
return this.workingElements;
|
||||
},
|
||||
noMoreValidDatasets() {
|
||||
return this.workingElements.length == 0;
|
||||
},
|
||||
returnInvalidElementsLength: function () {
|
||||
return this.invalidElements.length > 0;
|
||||
},
|
||||
returnInvalidElements: function () {
|
||||
return this.invalidElements;
|
||||
},
|
||||
allElementsAreInvalid: function () {
|
||||
return this.initialElements.length == this.invalidElements.length;
|
||||
},
|
||||
noElementsSelected: function () {
|
||||
return this.initialElements.length == 0;
|
||||
},
|
||||
noElementsLeft: function () {
|
||||
return this.workingElements.length == 0;
|
||||
},
|
||||
showDuplicateError() {
|
||||
return this.duplicateNames.length > 0;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
l(str) {
|
||||
// _l conflicts private methods of Vue internals, expose as l instead
|
||||
return _l(str);
|
||||
},
|
||||
/** set up instance vars */
|
||||
_instanceSetUp: function () {
|
||||
/** Ids of elements that have been selected by the user - to preserve over renders */
|
||||
this.selectedDatasetElems = [];
|
||||
},
|
||||
// ------------------------------------------------------------------------ process raw list
|
||||
/** set up main data */
|
||||
_elementsSetUp: function () {
|
||||
/** a list of invalid elements and the reasons they aren't valid */
|
||||
this.invalidElements = [];
|
||||
//TODO: handle fundamental problem of syncing DOM, views, and list here
|
||||
/** data for list in progress */
|
||||
this.workingElements = [];
|
||||
// copy initial list, sort, add ids if needed
|
||||
this.workingElements = this.initialElements.slice(0);
|
||||
this._ensureElementIds();
|
||||
this._validateElements();
|
||||
this._mangleDuplicateNames();
|
||||
},
|
||||
/** add ids to dataset objs in initial list if none */
|
||||
_ensureElementIds: function () {
|
||||
this.workingElements.forEach((element) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(element, "id")) {
|
||||
element.id = element._uid;
|
||||
}
|
||||
});
|
||||
return this.workingElements;
|
||||
},
|
||||
// /** separate working list into valid and invalid elements for this collection */
|
||||
_validateElements: function () {
|
||||
this.workingElements = this.workingElements.filter((element) => {
|
||||
var problem = this._isElementInvalid(element);
|
||||
if (problem) {
|
||||
this.invalidElements.push(element.name + " " + problem);
|
||||
}
|
||||
return !problem;
|
||||
});
|
||||
return this.workingElements;
|
||||
},
|
||||
/** describe what is wrong with a particular element if anything */
|
||||
_isElementInvalid: function (element) {
|
||||
if (element.history_content_type === "dataset_collection") {
|
||||
return _l("is a collection, this is not allowed");
|
||||
}
|
||||
var validState = element.state === STATES.OK || STATES.NOT_READY_STATES.includes(element.state);
|
||||
if (!validState) {
|
||||
return _l("has errored, is paused, or is not accessible");
|
||||
}
|
||||
if (element.deleted || element.purged) {
|
||||
return _l("has been deleted or purged");
|
||||
}
|
||||
return null;
|
||||
},
|
||||
// /** mangle duplicate names using a mac-like '(counter)' addition to any duplicates */
|
||||
_mangleDuplicateNames: function () {
|
||||
var counter = 1;
|
||||
var existingNames = {};
|
||||
this.workingElements.forEach((element) => {
|
||||
var currName = element.name;
|
||||
while (Object.prototype.hasOwnProperty.call(existingNames, currName)) {
|
||||
currName = `${element.name} (${counter})`;
|
||||
counter += 1;
|
||||
}
|
||||
element.name = currName;
|
||||
existingNames[element.name] = true;
|
||||
});
|
||||
},
|
||||
saveOriginalNames: function () {
|
||||
// Deep copy elements
|
||||
this.originalNamedElements = JSON.parse(JSON.stringify(this.workingElements));
|
||||
},
|
||||
getOriginalNames: function () {
|
||||
// Deep copy elements
|
||||
this.workingElements = JSON.parse(JSON.stringify(this.originalNamedElements));
|
||||
},
|
||||
elementSelected: function (e) {
|
||||
if (!this.selectedDatasetElems.includes(e.id)) {
|
||||
this.selectedDatasetElems.push(e.id);
|
||||
} else {
|
||||
this.selectedDatasetElems.splice(this.selectedDatasetElems.indexOf(e.id), 1);
|
||||
}
|
||||
},
|
||||
elementDiscarded: function (e) {
|
||||
this.$delete(this.workingElements, this.workingElements.indexOf(e));
|
||||
return this.workingElements;
|
||||
},
|
||||
clickClearAll: function () {
|
||||
this.selectedDatasetElems = [];
|
||||
},
|
||||
clickedCreate: function (collectionName) {
|
||||
this.checkForDuplicates();
|
||||
if (this.state !== "error") {
|
||||
this.$emit("clicked-create", this.workingElements, this.collectionName, this.hideSourceItems);
|
||||
return this.creationFn(this.workingElements, collectionName, this.hideSourceItems)
|
||||
.done(this.oncreate)
|
||||
.fail(() => {
|
||||
this.state = "error";
|
||||
});
|
||||
}
|
||||
},
|
||||
checkForDuplicates: function () {
|
||||
var existingNames = {};
|
||||
this.duplicateNames = [];
|
||||
var valid = true;
|
||||
this.workingElements.forEach((element) => {
|
||||
if (Object.prototype.hasOwnProperty.call(existingNames, element.name)) {
|
||||
valid = false;
|
||||
this.duplicateNames.push(element.name);
|
||||
}
|
||||
existingNames[element.name] = true;
|
||||
});
|
||||
this.state = valid ? "build" : "error";
|
||||
},
|
||||
//TODO: issue #9497
|
||||
// removeExtensionsToggle: function () {
|
||||
// this.removeExtensions = !this.removeExtensions;
|
||||
// if (this.removeExtensions == true) {
|
||||
// this.removeExtensionsFn();
|
||||
// }
|
||||
// },
|
||||
// removeExtensionsFn: function () {
|
||||
// this.workingElements.forEach((e) => {
|
||||
// var lastDotIndex = e.lastIndexOf(".");
|
||||
// if (lastDotIndex > 0) {
|
||||
// var extension = e.slice(lastDotIndex, e.length);
|
||||
// e = e.replace(extension, "");
|
||||
// }
|
||||
// });
|
||||
// },
|
||||
/** reset all data to the initial state */
|
||||
reset: function () {
|
||||
this._instanceSetUp();
|
||||
this.getOriginalNames();
|
||||
},
|
||||
/** string rep */
|
||||
toString: function () {
|
||||
return "ListCollectionCreator";
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.list-collection-creator {
|
||||
.footer {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.cancel-create {
|
||||
border-color: lightgrey;
|
||||
}
|
||||
.collection-elements-controls {
|
||||
margin-bottom: 8px;
|
||||
.clear-selected {
|
||||
float: right !important;
|
||||
}
|
||||
}
|
||||
.collection-elements {
|
||||
max-height: 400px;
|
||||
border: 0px solid lightgrey;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
// TODO: taken from .dataset above - swap these out
|
||||
.collection-element {
|
||||
height: 32px;
|
||||
margin: 2px 4px 0px 4px;
|
||||
opacity: 1;
|
||||
border: 1px solid lightgrey;
|
||||
border-radius: 3px;
|
||||
padding: 0 8px 0 8px;
|
||||
line-height: 28px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
&:last-of-type {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
&:hover {
|
||||
border-color: black;
|
||||
}
|
||||
&.selected {
|
||||
border-color: black;
|
||||
background: rgb(118, 119, 131);
|
||||
color: white;
|
||||
a {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
.name {
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
button {
|
||||
margin-top: 3px;
|
||||
}
|
||||
.discard {
|
||||
@extend .float-right !optional;
|
||||
}
|
||||
}
|
||||
.empty-message {
|
||||
margin: 8px;
|
||||
color: grey;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div class="collection-element" @click="$emit('element-is-selected', element)">
|
||||
<click-to-edit v-model="element.name" :title="titleElementName" />
|
||||
<button class="discard-btn btn-sm" :title="titleDiscardButton" @click="clickDiscard">
|
||||
{{ l("Discard") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import _l from "utils/localization";
|
||||
import ClickToEdit from "./common/ClickToEdit";
|
||||
export default {
|
||||
props: {
|
||||
element: {
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
titleDiscardButton: _l("Remove this dataset from the list"),
|
||||
titleElementName: _l("Click to rename"),
|
||||
isSelected: false,
|
||||
};
|
||||
},
|
||||
components: { ClickToEdit },
|
||||
methods: {
|
||||
l(str) {
|
||||
// _l conflicts private methods of Vue internals, expose as l instead
|
||||
return _l(str);
|
||||
},
|
||||
clickDiscard: function () {
|
||||
this.$emit("element-is-discarded", this.element);
|
||||
},
|
||||
/** string rep */
|
||||
toString() {
|
||||
return "ListDatasetCollectionElementView()";
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.discard-btn {
|
||||
float: right;
|
||||
}
|
||||
.collection-element {
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,332 @@
|
||||
<template>
|
||||
<div class="pair-collection-creator">
|
||||
<div v-if="(state == 'error')">
|
||||
<b-alert show variant="danger">
|
||||
{{ errorText }}
|
||||
</b-alert>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="noElementsSelected">
|
||||
<b-alert show variant="warning" dismissible>
|
||||
{{ noElementsHeader }}
|
||||
{{ allInvalidElementsPartOne }}
|
||||
<a class="cancel-text" href="javascript:void(0)" role="button" @click="oncancel">
|
||||
{{ cancelText }}
|
||||
</a>
|
||||
{{ allInvalidElementsPartTwo }}
|
||||
</b-alert>
|
||||
<div class="float-left">
|
||||
<button class="cancel-create btn" tabindex="-1" @click="oncancel">
|
||||
{{ l("Cancel") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="allElementsAreInvalid">
|
||||
<b-alert show variant="warning" dismissible>
|
||||
{{ invalidHeader }}
|
||||
<ul>
|
||||
<li v-for="problem in returnInvalidElements" :key="problem">
|
||||
{{ problem }}
|
||||
</li>
|
||||
</ul>
|
||||
{{ allInvalidElementsPartOne }}
|
||||
<a class="cancel-text" href="javascript:void(0)" role="button" @click="oncancel">
|
||||
{{ cancelText }}
|
||||
</a>
|
||||
{{ allInvalidElementsPartTwo }}
|
||||
</b-alert>
|
||||
<div class="float-left">
|
||||
<button class="cancel-create btn" tabindex="-1" @click="oncancel">
|
||||
{{ l("Cancel") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!exactlyTwoValidElements">
|
||||
<div v-if="returnInvalidElementsLength">
|
||||
<b-alert show variant="warning" dismissible>
|
||||
{{ invalidHeader }}
|
||||
<ul>
|
||||
<li v-for="problem in returnInvalidElements" :key="problem">
|
||||
{{ problem }}
|
||||
</li>
|
||||
</ul>
|
||||
</b-alert>
|
||||
</div>
|
||||
<b-alert show variant="warning" dismissible>
|
||||
{{ exactlyTwoValidElementsPartOne }}
|
||||
<a class="cancel-text" href="javascript:void(0)" role="button" @click="oncancel">
|
||||
{{ cancelText }}
|
||||
</a>
|
||||
{{ exactlyTwoValidElementsPartTwo }}
|
||||
</b-alert>
|
||||
<div class="float-left">
|
||||
<button class="cancel-create btn" tabindex="-1" @click="oncancel">
|
||||
{{ l("Cancel") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="returnInvalidElementsLength">
|
||||
<b-alert show variant="warning" dismissible>
|
||||
{{ invalidHeader }}
|
||||
<ul>
|
||||
<li v-for="problem in returnInvalidElements" :key="problem">
|
||||
{{ problem }}
|
||||
</li>
|
||||
</ul>
|
||||
</b-alert>
|
||||
</div>
|
||||
<collection-creator
|
||||
:oncancel="oncancel"
|
||||
:hideSourceItems="hideSourceItems"
|
||||
@onUpdateHideSourceItems="onUpdateHideSourceItems"
|
||||
@clicked-create="clickedCreate"
|
||||
@remove-extensions-toggle="removeExtensionsToggle"
|
||||
:creationFn="creationFn"
|
||||
>
|
||||
<template v-slot:help-content>
|
||||
<p>
|
||||
{{
|
||||
l(
|
||||
[
|
||||
"Pair collections are permanent collections containing two datasets: one forward and one reverse. ",
|
||||
"Often these are forward and reverse reads. The pair collections can be passed to tools and workflows in ",
|
||||
"order to have analyses done on both datasets. This interface allows you to create a pair, name it, and ",
|
||||
"swap which is forward and which reverse.",
|
||||
].join("")
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
{{ l("Click the ") }}
|
||||
<i data-target=".swap">
|
||||
{{ l("Swap") }}
|
||||
</i>
|
||||
{{ l("link to make your forward dataset the reverse and the reverse dataset forward") }}
|
||||
</li>
|
||||
<li>
|
||||
{{ l("Click the ") }}
|
||||
<i data-target=".cancel-create">
|
||||
{{ l("Cancel") }}
|
||||
</i>
|
||||
{{ l("button to exit the interface.") }}
|
||||
</li>
|
||||
</ul>
|
||||
<br />
|
||||
<p>
|
||||
{{ l("Once your collection is complete, enter a ") }}
|
||||
<i data-target=".collection-name"> {{ l("name") }}</i>
|
||||
{{ l("and click ") }}
|
||||
<i data-target=".create-collection">
|
||||
{{ l("Create list") }}
|
||||
</i>
|
||||
{{ l(".") }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-slot:middle-content>
|
||||
<div class="collection-elements-controls">
|
||||
<a
|
||||
class="swap"
|
||||
href="javascript:void(0);"
|
||||
title="l('Swap forward and reverse datasets')"
|
||||
@click="swapButton"
|
||||
>
|
||||
{{ l("Swap") }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="collection-elements scroll-container flex-row">
|
||||
<div
|
||||
v-for="(element, index) in workingElements"
|
||||
:key="element.id"
|
||||
class="collection-element"
|
||||
>
|
||||
{{ index == 0 ? l("forward") : l("reverse") }}: {{ element.name }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</collection-creator>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import mixin from "./common/mixin";
|
||||
import STATES from "mvc/dataset/states";
|
||||
import _l from "utils/localization";
|
||||
import Vue from "vue";
|
||||
import BootstrapVue from "bootstrap-vue";
|
||||
|
||||
Vue.use(BootstrapVue);
|
||||
export default {
|
||||
mixins: [mixin],
|
||||
created() {
|
||||
this._elementsSetUp();
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
state: "build", //error
|
||||
errorText: _l("Galaxy could not be reached and may be updating. Try again in a few minutes."),
|
||||
noElementsHeader: _l("No datasets were selected."),
|
||||
allInvalidElementsPartOne: _l("Exactly two elements needed for the collection. You may need to"),
|
||||
cancelText: _l("cancel"),
|
||||
allInvalidElementsPartTwo: _l("and reselect new elements."),
|
||||
exactlyTwoValidElementsPartOne: _l("Two (and only two) elements are needed for the pair. You may need to "),
|
||||
exactlyTwoValidElementsPartTwo: _l("and reselect new elements."),
|
||||
invalidHeader: _l("The following selections could not be included due to problems:"),
|
||||
minElements: 2,
|
||||
workingElements: [],
|
||||
invalidElements: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
returnInvalidElementsLength: function () {
|
||||
return this.invalidElements.length > 0;
|
||||
},
|
||||
returnInvalidElements: function () {
|
||||
return this.invalidElements;
|
||||
},
|
||||
allElementsAreInvalid: function () {
|
||||
return this.initialElements.length == this.invalidElements.length;
|
||||
},
|
||||
noElementsSelected: function () {
|
||||
return this.initialElements.length == 0;
|
||||
},
|
||||
exactlyTwoValidElements: function () {
|
||||
return this.workingElements.length == 2;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
l(str) {
|
||||
// _l conflicts private methods of Vue internals, expose as l instead
|
||||
return _l(str);
|
||||
},
|
||||
_elementsSetUp: function () {
|
||||
/** a list of invalid elements and the reasons they aren't valid */
|
||||
this.invalidElements = [];
|
||||
//TODO: handle fundamental problem of syncing DOM, views, and list here
|
||||
/** data for list in progress */
|
||||
this.workingElements = [];
|
||||
// copy initial list, sort, add ids if needed
|
||||
this.workingElements = this.initialElements.slice(0);
|
||||
this._ensureElementIds();
|
||||
this._validateElements();
|
||||
},
|
||||
/** add ids to dataset objs in initial list if none */
|
||||
_ensureElementIds: function () {
|
||||
this.workingElements.forEach((element) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(element, "id")) {
|
||||
element.id = element._uid;
|
||||
}
|
||||
});
|
||||
return this.workingElements;
|
||||
},
|
||||
// /** separate working list into valid and invalid elements for this collection */
|
||||
_validateElements: function () {
|
||||
this.workingElements = this.workingElements.filter((element) => {
|
||||
var problem = this._isElementInvalid(element);
|
||||
if (problem) {
|
||||
this.invalidElements.push(element.name + " " + problem);
|
||||
}
|
||||
return !problem;
|
||||
});
|
||||
return this.workingElements;
|
||||
},
|
||||
/** describe what is wrong with a particular element if anything */
|
||||
_isElementInvalid: function (element) {
|
||||
if (element.history_content_type === "dataset_collection") {
|
||||
return _l("is a collection, this is not allowed");
|
||||
}
|
||||
var validState = element.state === STATES.OK || STATES.NOT_READY_STATES.includes(element.state);
|
||||
if (!validState) {
|
||||
return _l("has errored, is paused, or is not accessible");
|
||||
}
|
||||
if (element.deleted || element.purged) {
|
||||
return _l("has been deleted or purged");
|
||||
}
|
||||
return null;
|
||||
},
|
||||
swapButton: function () {
|
||||
this.workingElements = [this.workingElements[1], this.workingElements[0]];
|
||||
},
|
||||
clickedCreate: function (collectionName) {
|
||||
if (this.state !== "error") {
|
||||
this.$emit("clicked-create", this.workingElements, this.collectionName, this.hideSourceItems);
|
||||
return this.creationFn(this.workingElements, collectionName, this.hideSourceItems)
|
||||
.done(this.oncreate)
|
||||
.fail(() => {
|
||||
this.state = "error";
|
||||
});
|
||||
}
|
||||
},
|
||||
/** string rep */
|
||||
toString: function () {
|
||||
return "PairCollectionCreator";
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.pair-collection-creator {
|
||||
.footer {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.main-help {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.collection-elements-controls {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.collection-elements {
|
||||
max-height: 400px;
|
||||
border: 0px solid lightgrey;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
// TODO: taken from .dataset above - swap these out
|
||||
.collection-element {
|
||||
height: 32px;
|
||||
margin: 2px 4px 0px 4px;
|
||||
opacity: 1;
|
||||
border: 1px solid lightgrey;
|
||||
border-radius: 3px;
|
||||
padding: 0 8px 0 8px;
|
||||
line-height: 28px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
|
||||
&:last-of-type {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
&:hover {
|
||||
border-color: black;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 3px;
|
||||
}
|
||||
.identifier {
|
||||
&:after {
|
||||
content: ":";
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
.name {
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
.empty-message {
|
||||
margin: 8px;
|
||||
color: grey;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div>
|
||||
<li class="dataset paired">
|
||||
<span class="forward-dataset-name flex-column">{{ pair.forward.name }}</span>
|
||||
<span class="pair-name-column flex-column">
|
||||
<span class="pair-name">
|
||||
<click-to-edit v-model="name" :title="titlePairName" />
|
||||
</span>
|
||||
</span>
|
||||
<span class="reverse-dataset-name flex-column">{{ pair.reverse.name }}</span>
|
||||
</li>
|
||||
<button class="unpair-btn" @click="unlinkFn">
|
||||
<span class="fa fa-unlink" :title="unpairButtonTitle"></span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import ClickToEdit from "./common/ClickToEdit.vue";
|
||||
import _l from "utils/localization";
|
||||
export default {
|
||||
components: { ClickToEdit },
|
||||
data: function () {
|
||||
return {
|
||||
unpairButtonTitle: _l("Unpair"),
|
||||
titlePairName: _l("Click to rename"),
|
||||
};
|
||||
},
|
||||
props: {
|
||||
pair: {
|
||||
required: true,
|
||||
},
|
||||
unlinkFn: {
|
||||
required: true,
|
||||
type: Function,
|
||||
},
|
||||
name: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
l(str) {
|
||||
// _l conflicts private methods of Vue internals, expose as l instead
|
||||
return _l(str);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
pair() {
|
||||
this.name = this.pair.name;
|
||||
},
|
||||
name() {
|
||||
this.$emit("onPairRename", this.name);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<li class="dataset" @click="$emit('element-is-selected', element)">
|
||||
<label>
|
||||
{{ element.name }}
|
||||
</label>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import _l from "utils/localization";
|
||||
export default {
|
||||
props: {
|
||||
element: {
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
titleElementName: _l("Click to rename"),
|
||||
isSelected: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
l(str) {
|
||||
// _l conflicts private methods of Vue internals, expose as l instead
|
||||
return _l(str);
|
||||
},
|
||||
/** string rep */
|
||||
toString() {
|
||||
return "PairedListDatasetCollectionElementView()";
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.discard-btn {
|
||||
float: right;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<input
|
||||
class="click-to-edit-input"
|
||||
v-if="editable"
|
||||
contenteditable
|
||||
v-model="localValue"
|
||||
@blur="editable = false"
|
||||
@keyup.enter="editable = false"
|
||||
/>
|
||||
<label v-else @click="editable = true">
|
||||
{{ localValue }}
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
title: {
|
||||
required: false,
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
editable: false,
|
||||
localValue: this.value,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
localValue(newValue) {
|
||||
this.$emit("input", newValue);
|
||||
},
|
||||
value(newValue) {
|
||||
this.localValue = newValue;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.click-to-edit-input {
|
||||
width: 600px;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,420 @@
|
||||
<template>
|
||||
<div class="collection-creator">
|
||||
<div class="header flex-row no-flex">
|
||||
<div class="main-help well clear" v-bind:class="{ expanded: isExpanded }">
|
||||
<a
|
||||
class="more-help"
|
||||
href="javascript:void(0);"
|
||||
role="button"
|
||||
@click="_clickForHelp"
|
||||
:title="titleForHelp"
|
||||
>
|
||||
<div v-if="!isExpanded">
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</div>
|
||||
<div v-else>
|
||||
<i class="fas fa-chevron-up"></i>
|
||||
</div>
|
||||
</a>
|
||||
<div class="help-content">
|
||||
<!-- each collection that extends this will add their own help content -->
|
||||
<slot name="help-content"></slot>
|
||||
<a
|
||||
class="more-help"
|
||||
href="javascript:void(0);"
|
||||
role="button"
|
||||
@click="_clickForHelp"
|
||||
:title="titleForHelp"
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="middle flex-row flex-row-container">
|
||||
<slot name="middle-content"></slot>
|
||||
</div>
|
||||
<div class="footer flex-row no-flex">
|
||||
<div class="attributes clear">
|
||||
<div class="clear">
|
||||
<label class="setting-prompt float-right" v-if="renderExtensionsToggle">
|
||||
{{ removeFileExtensionsText }}
|
||||
<input
|
||||
class="remove-extensions float-right"
|
||||
type="checkbox"
|
||||
@click="$emit('remove-extensions-toggle')"
|
||||
checked
|
||||
/>
|
||||
</label>
|
||||
<label class="setting-prompt float-right">
|
||||
{{ hideOriginalsText }}
|
||||
<input class="hide-originals float-right" type="checkbox" v-model="localHideSourceItems" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="clear">
|
||||
<input
|
||||
class="collection-name form-control float-right"
|
||||
:placeholder="placeholderEnterName"
|
||||
v-model="collectionName"
|
||||
/>
|
||||
<div class="collection-name-prompt float-right">
|
||||
{{ l("Name:") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions clear vertically-spaced">
|
||||
<div class="float-left">
|
||||
<button class="cancel-create btn" tabindex="-1" @click="_cancelCreate">
|
||||
{{ l("Cancel") }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="main-options float-right">
|
||||
<button
|
||||
class="create-collection btn btn-primary"
|
||||
@click="$emit('clicked-create', collectionName)"
|
||||
:disabled="!validInput"
|
||||
>
|
||||
{{ l("Create list") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import _l from "utils/localization";
|
||||
export default {
|
||||
props: {
|
||||
oncancel: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
creationFn: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
renderExtensionsToggle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hideSourceItems: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
validInput: function () {
|
||||
return this.collectionName.length > 0;
|
||||
},
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
titleForHelp: _l("Expand or Close Help"),
|
||||
hideOriginalsText: _l("Hide original elements?"),
|
||||
titleMoreHelp: _l("Close and show more help"),
|
||||
placeholderEnterName: _l("Enter a name for your new collection"),
|
||||
dropdownText: _l("Create a <i>single</> pair"),
|
||||
isExpanded: false,
|
||||
collectionName: "",
|
||||
removeFileExtensionsText: "Remove file extensions?",
|
||||
localHideSourceItems: this.hideSourceItems,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
l(str) {
|
||||
// _l conflicts private methods of Vue internals, expose as l instead
|
||||
return _l(str);
|
||||
},
|
||||
_clickForHelp: function () {
|
||||
this.isExpanded = !this.isExpanded;
|
||||
return this.isExpanded;
|
||||
},
|
||||
_cancelCreate: function () {
|
||||
this.oncancel();
|
||||
},
|
||||
_getName: function () {
|
||||
return this.collectionName;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
localHideSourceItems() {
|
||||
this.$emit("onUpdateHideSourceItems", this.localHideSourceItems);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
$fa-font-path: "../../../../node_modules/@fortawesome/fontawesome-free/webfonts/";
|
||||
@import "~@fortawesome/fontawesome-free/scss/_variables";
|
||||
@import "../../../../node_modules/@fortawesome/fontawesome-free/scss/solid";
|
||||
@import "../../../../node_modules/@fortawesome/fontawesome-free/scss/fontawesome";
|
||||
@import "../../../node_modules/@fortawesome/fontawesome-free/scss/brands";
|
||||
.collection-creator {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
// ------------------------------------------------------------------------ general
|
||||
ol,
|
||||
li {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
> *:not(.popover) {
|
||||
padding: 0px 8px 0px 8px;
|
||||
}
|
||||
.btn {
|
||||
border-color: #bfbfbf;
|
||||
}
|
||||
.vertically-spaced {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.scroll-container {
|
||||
overflow: auto;
|
||||
//overflow-y: scroll;
|
||||
}
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.empty-message {
|
||||
color: grey;
|
||||
font-style: italic;
|
||||
}
|
||||
// ------------------------------------------------------------------------ flex
|
||||
&.flex-row-container,
|
||||
.flex-row-container,
|
||||
.flex-column-container {
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-align-items: stretch;
|
||||
-ms-align-items: stretch;
|
||||
align-items: stretch;
|
||||
-webkit-align-content: stretch;
|
||||
-ms-align-content: stretch;
|
||||
align-content: stretch;
|
||||
}
|
||||
// a series of vertical elements that will expand to fill available space
|
||||
// Tried to drop the important here but we have divs with classes of the form:
|
||||
// flex-column-container scroll-container flex-row
|
||||
// and flex-row specifies a row direction with important. I was unable to separate
|
||||
// this into two separate divs and get the styling right but that'd probably be a
|
||||
// better way to go longer term.
|
||||
&.flex-row-container,
|
||||
.flex-row-container {
|
||||
-webkit-flex-direction: column !important;
|
||||
-ms-flex-direction: column !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
.flex-row {
|
||||
-webkit-flex: 1 auto;
|
||||
-ms-flex: 1 auto;
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
.flex-row.no-flex {
|
||||
-webkit-flex: 0 auto;
|
||||
-ms-flex: 0 auto;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
// a series of horizontal elements that will expand to fill available space
|
||||
.flex-column-container {
|
||||
-webkit-flex-direction: row;
|
||||
-ms-flex-direction: row;
|
||||
flex-direction: row;
|
||||
}
|
||||
.flex-column {
|
||||
-webkit-flex: 1 auto;
|
||||
-ms-flex: 1 auto;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.flex-column.no-flex {
|
||||
-webkit-flex: 0 auto;
|
||||
-ms-flex: 0 auto;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
// ------------------------------------------------------------------------ sub-components
|
||||
.choose-filters {
|
||||
.help {
|
||||
margin-bottom: 2px;
|
||||
font-size: 90%;
|
||||
color: grey;
|
||||
}
|
||||
button {
|
||||
width: 100%;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
.header .alert {
|
||||
display: none;
|
||||
li {
|
||||
list-style: circle;
|
||||
margin-left: 32px;
|
||||
}
|
||||
}
|
||||
// ------------------------------------------------------------------------ columns
|
||||
.column {
|
||||
width: 30%;
|
||||
}
|
||||
.column-title {
|
||||
height: 22px;
|
||||
line-height: 22px;
|
||||
overflow: hidden;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
.title {
|
||||
font-weight: bold;
|
||||
}
|
||||
.title-info {
|
||||
color: grey;
|
||||
&:before {
|
||||
content: " - ";
|
||||
}
|
||||
}
|
||||
}
|
||||
// ------------------------------------------------------------------------ header
|
||||
.header {
|
||||
.main-help {
|
||||
margin-bottom: 17px;
|
||||
overflow: hidden;
|
||||
padding: 15px;
|
||||
&:not(.expanded) {
|
||||
// chosen to match alert - dependent on line height and .alert padding
|
||||
max-height: 49px;
|
||||
.help-content {
|
||||
p:first-child {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
> *:not(:first-child) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.expanded {
|
||||
max-height: none;
|
||||
}
|
||||
.help-content {
|
||||
i {
|
||||
cursor: help;
|
||||
border-bottom: 1px dotted grey;
|
||||
font-style: normal;
|
||||
//font-weight: bold;
|
||||
//text-decoration: underline;
|
||||
//text-decoration-style: dashed;
|
||||
}
|
||||
ul,
|
||||
li {
|
||||
list-style: circle;
|
||||
margin-left: 16px;
|
||||
}
|
||||
.scss-help {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
.more-help {
|
||||
//display: inline-block;
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
.column-headers {
|
||||
.column-header {
|
||||
//min-height: 45px;
|
||||
.unpaired-filter {
|
||||
width: 100%;
|
||||
.search-query {
|
||||
width: 100%;
|
||||
height: 22px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.paired-column a:not(:last-child) {
|
||||
margin-right: 8px;
|
||||
}
|
||||
.reverse-column .column-title {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
// ------------------------------------------------------------------------ middle
|
||||
// ---- all
|
||||
// macro
|
||||
.flex-bordered-vertically {
|
||||
// huh! - giving these any static height will pull them in
|
||||
height: 0;
|
||||
// NOT setting the above will give a full-height page
|
||||
border: 1px solid lightgrey;
|
||||
border-width: 1px 0 1px 0;
|
||||
}
|
||||
.element-drop-placeholder {
|
||||
width: 60px;
|
||||
height: 3px;
|
||||
margin: 2px 0px 0px 14px;
|
||||
background: black;
|
||||
&:before {
|
||||
@extend .fas;
|
||||
float: left;
|
||||
font-size: 120%;
|
||||
margin: -9px 0px 0px -8px;
|
||||
content: fa-content($fa-var-caret-right);
|
||||
}
|
||||
&:last-child {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
// ------------------------------------------------------------------------ footer
|
||||
.footer {
|
||||
.attributes {
|
||||
.setting-prompt {
|
||||
//margin-right: 32px;
|
||||
line-height: 32px;
|
||||
padding-left: 10px;
|
||||
.remove-extensions {
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
.hide-originals {
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
// actually appears/floats to the left of the input
|
||||
.collection-name-prompt {
|
||||
margin: 5px 4px 0 0;
|
||||
}
|
||||
.collection-name-prompt.validation-warning:before {
|
||||
//TODO: localize (somehow)
|
||||
content: "(required)";
|
||||
margin-right: 4px;
|
||||
color: red;
|
||||
}
|
||||
.collection-name {
|
||||
width: 50%;
|
||||
&.validation-warning {
|
||||
border-color: red;
|
||||
}
|
||||
}
|
||||
}
|
||||
.actions {
|
||||
.other-options > * {
|
||||
// do not display the links to create other collections yet
|
||||
display: none;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 = "<p>My cool <strong>markdown</strong>\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();
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,18 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3 v-if="includeTitle">Dataset Storage</h3>
|
||||
<loading-span v-if="storageInfo == null"> </loading-span>
|
||||
<div v-if="errorMessage" class="error">{{ errorMessage }}</div>
|
||||
<loading-span v-else-if="storageInfo == null"> </loading-span>
|
||||
<div v-else>
|
||||
<p v-if="storageInfo.name">
|
||||
This data is stored in a Galaxy ObjectStore named <b>{{ storageInfo.name }}</b
|
||||
>.
|
||||
</p>
|
||||
<p v-else-if="storageInfo.object_store_id">
|
||||
This data is stored in a Galaxy ObjectStore with id <b>{{ storageInfo.object_store_id }}</b
|
||||
>.
|
||||
</p>
|
||||
<p v-else>
|
||||
This data is stored in the default configured Galaxy ObjectStore.
|
||||
<p>
|
||||
This dataset is stored in
|
||||
<span class="display-os-by-name" v-if="storageInfo.name">
|
||||
a Galaxy object store named <b>{{ storageInfo.name }}</b>
|
||||
</span>
|
||||
<span class="display-os-by-id" v-else-if="storageInfo.object_store_id">
|
||||
a Galaxy object store with id <b>{{ storageInfo.object_store_id }}</b>
|
||||
</span>
|
||||
<span class="display-os-default" v-else> the default configured Galaxy object store </span>.
|
||||
</p>
|
||||
<div v-html="descriptionRendered"></div>
|
||||
</div>
|
||||
@@ -24,8 +24,7 @@ import axios from "axios";
|
||||
import { getAppRoot } from "onload/loadConfig";
|
||||
import LoadingSpan from "components/LoadingSpan";
|
||||
import MarkdownIt from "markdown-it";
|
||||
|
||||
const md = MarkdownIt();
|
||||
import { errorMessageAsString } from "utils/simple-error";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -48,24 +47,29 @@ export default {
|
||||
return {
|
||||
storageInfo: null,
|
||||
descriptionRendered: null,
|
||||
errorMessage: null,
|
||||
};
|
||||
},
|
||||
created() {
|
||||
const datasetId = this.datasetId;
|
||||
const datasetType = this.datasetType;
|
||||
axios
|
||||
.get(`${getAppRoot()}api/datasets/${datasetId}/storage?hda_ldda=${datasetType}`)
|
||||
.get(`${getAppRoot()}api/datasets/${datasetId}/storage`, { hda_ldda: datasetType })
|
||||
.then(this.handleResponse)
|
||||
.catch(this.handleError);
|
||||
.catch((errorMessage) => {
|
||||
this.errorMessage = errorMessageAsString(errorMessage);
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
handleResponse(response) {
|
||||
console.log(response);
|
||||
this.storageInfo = response.data;
|
||||
this.descriptionRendered = md.render(this.storageInfo.description);
|
||||
},
|
||||
handleError(err) {
|
||||
console.log(err);
|
||||
const storageInfo = response.data;
|
||||
const description = storageInfo.description;
|
||||
this.storageInfo = storageInfo;
|
||||
if (description) {
|
||||
this.descriptionRendered = MarkdownIt().render(storageInfo.description);
|
||||
} else {
|
||||
this.descriptionRendered = null;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,35 +1,36 @@
|
||||
import Vuex from "vuex";
|
||||
import { mount, createLocalVue } from "@vue/test-utils";
|
||||
import axios from "axios";
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import DatasetInformation from "./DatasetInformation";
|
||||
import datasetResponse from "./testData/datasetResponse";
|
||||
import flushPromises from "flush-promises";
|
||||
import createCache from "vuex-cache";
|
||||
import moment from "moment";
|
||||
|
||||
jest.mock("../History/caching");
|
||||
const HDA_ID = "FOO_HDA_ID";
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(Vuex);
|
||||
|
||||
const testStore = new Vuex.Store({
|
||||
plugins: [createCache()],
|
||||
modules: {
|
||||
datasetsStore: {
|
||||
actions: {
|
||||
fetchDataset: jest.fn(),
|
||||
},
|
||||
getters: {
|
||||
dataset: (state) => (hda_id) => {
|
||||
return datasetResponse;
|
||||
},
|
||||
},
|
||||
},
|
||||
const mockDatasetProvider = {
|
||||
render() {
|
||||
return this.$scopedSlots.default({
|
||||
loading: false,
|
||||
item: datasetResponse,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
describe("DatasetInformation/DatasetInformation.vue", () => {
|
||||
let wrapper;
|
||||
let datasetInfoTable;
|
||||
let axiosMock;
|
||||
|
||||
beforeEach(() => {
|
||||
axiosMock = new MockAdapter(axios);
|
||||
axiosMock.onGet(new RegExp(`api/configuration/decode/*`)).reply(200, { decoded_id: 123 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
axiosMock.restore();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const propsData = {
|
||||
@@ -37,9 +38,10 @@ describe("DatasetInformation/DatasetInformation.vue", () => {
|
||||
};
|
||||
|
||||
wrapper = mount(DatasetInformation, {
|
||||
store: testStore,
|
||||
propsData,
|
||||
localVue,
|
||||
stubs: {
|
||||
DatasetProvider: mockDatasetProvider,
|
||||
},
|
||||
});
|
||||
datasetInfoTable = wrapper.find("#dataset-details");
|
||||
await flushPromises();
|
||||
@@ -49,8 +51,8 @@ describe("DatasetInformation/DatasetInformation.vue", () => {
|
||||
// table should exist
|
||||
expect(datasetInfoTable).toBeTruthy();
|
||||
const rows = datasetInfoTable.findAll("tbody > tr");
|
||||
// should contain 7 rows
|
||||
expect(rows.length).toBe(7);
|
||||
// should contain 11 rows
|
||||
expect(rows.length).toBe(11);
|
||||
});
|
||||
|
||||
it("filesize should be formatted", async () => {
|
||||
|
||||
@@ -1,47 +1,70 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3>Dataset Information</h3>
|
||||
<table id="dataset-details" class="tabletip info_data_table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Number</td>
|
||||
<td id="number">{{ dataset.hid }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Name</td>
|
||||
<td id="name">{{ dataset.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Created</td>
|
||||
<td id="created" v-if="dataset.create_time">
|
||||
<UtcDate :date="dataset.create_time" mode="pretty" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Filesize</td>
|
||||
<td id="filesize" v-html="bytesToString(dataset.file_size)"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Dbkey</td>
|
||||
<td id="dbkey">{{ dataset.metadata_dbkey }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Format</td>
|
||||
<td id="format">{{ dataset.file_ext }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>File contents</td>
|
||||
<td id="file-contents"><a :href="dataset.download_url">contents</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DatasetProvider :id="hda_id" v-slot="{ item: dataset, loading }">
|
||||
<div v-if="!loading">
|
||||
<h3>Dataset Information</h3>
|
||||
<table id="dataset-details" class="tabletip info_data_table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Number</td>
|
||||
<td id="number">{{ dataset.hid }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Name</td>
|
||||
<td id="name">{{ dataset.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Created</td>
|
||||
<td id="created" v-if="dataset.create_time">
|
||||
<UtcDate :date="dataset.create_time" mode="pretty" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Filesize</td>
|
||||
<td id="filesize" v-html="bytesToString(dataset.file_size)"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Dbkey</td>
|
||||
<td id="dbkey">{{ dataset.metadata_dbkey }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Format</td>
|
||||
<td id="format">{{ dataset.file_ext }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>File contents</td>
|
||||
<td id="file-contents"><a :href="dataset.download_url">contents</a></td>
|
||||
</tr>
|
||||
<tr v-if="dataset.id">
|
||||
<td>History Content API ID</td>
|
||||
<td>
|
||||
<div id="dataset-id">{{ dataset.id }} <decoded-id :id="dataset.id" /></div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="dataset.history_id">
|
||||
<td>History API ID</td>
|
||||
<td>
|
||||
<div id="history_id">{{ dataset.history_id }} <decoded-id :id="dataset.history_id" /></div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="dataset.uuid">
|
||||
<td>UUID</td>
|
||||
<td id="dataset-uuid">{{ dataset.uuid }}</td>
|
||||
</tr>
|
||||
<tr v-if="dataset.file_name">
|
||||
<td>Full Path</td>
|
||||
<td id="file_name">{{ dataset.file_name }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</DatasetProvider>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapCacheActions } from "vuex-cache";
|
||||
import Utils from "utils/utils";
|
||||
import UtcDate from "components/UtcDate";
|
||||
import DecodedId from "../DecodedId";
|
||||
import { DatasetProvider } from "../WorkflowInvocationState/providers";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
@@ -51,21 +74,14 @@ export default {
|
||||
},
|
||||
},
|
||||
components: {
|
||||
DatasetProvider,
|
||||
DecodedId,
|
||||
UtcDate,
|
||||
},
|
||||
created: function () {
|
||||
this.fetchDataset(this.hda_id);
|
||||
},
|
||||
computed: {
|
||||
dataset: function () {
|
||||
return this.$store.getters.dataset(this.hda_id);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
bytesToString(raw_size) {
|
||||
return Utils.bytesToString(raw_size);
|
||||
},
|
||||
...mapCacheActions(["fetchDataset"]),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<span v-if="decoded_id">({{ decoded_id }})</span>
|
||||
</template>
|
||||
<script>
|
||||
import axios from "axios";
|
||||
import { rethrowSimple } from "utils/simple-error";
|
||||
import { getAppRoot } from "onload/loadConfig";
|
||||
import { getGalaxyInstance } from "app";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return { decoded_id: null };
|
||||
},
|
||||
created: function () {
|
||||
this.decodeId(this.id);
|
||||
},
|
||||
computed: {
|
||||
isAdmin() {
|
||||
// window.parent.Galaxy is needed when instance is mounted in mako
|
||||
const Galaxy = getGalaxyInstance() || window.parent.Galaxy;
|
||||
return Galaxy?.user?.isAdmin() || false;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
decodeId: async function (id) {
|
||||
if (this.isAdmin) {
|
||||
const url = `${getAppRoot()}api/configuration/decode/${id}`;
|
||||
try {
|
||||
const response = await axios.get(url);
|
||||
this.decoded_id = response.data.decoded_id;
|
||||
} catch (e) {
|
||||
rethrowSimple(e);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<b-form-input v-model="localValue" :placeholder="placeholder" @click="selectFile"> </b-form-input>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { BFormInput } from "bootstrap-vue";
|
||||
import { filesDialog } from "utils/data";
|
||||
|
||||
export default {
|
||||
components: { BFormInput },
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: "file",
|
||||
},
|
||||
requireWritable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
localValue: this.value,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
placeholder() {
|
||||
return `Click to select ${this.mode}`;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
selectFile() {
|
||||
const props = {
|
||||
mode: this.mode,
|
||||
requireWritable: this.requireWritable,
|
||||
};
|
||||
filesDialog((selected) => {
|
||||
this.localValue = selected?.url;
|
||||
}, props);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
localValue(newValue) {
|
||||
this.$emit("input", newValue);
|
||||
},
|
||||
value(newValue) {
|
||||
this.localValue = newValue;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -26,6 +26,7 @@ export default {
|
||||
:selected.sync="selected"
|
||||
:expanded.sync="expanded"
|
||||
:scrolling="scrolling"
|
||||
v-on='$listeners'
|
||||
@mouseover.native.self.stop="setFocus(index)"
|
||||
@keydown.native.arrow-up.self.stop="setFocus(index - 1)"
|
||||
@keydown.native.arrow-down.self.stop="setFocus(index + 1)"
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState, mapGetters } from "vuex";
|
||||
import { mapGetters } from "vuex";
|
||||
import { Dataset } from "../../model";
|
||||
import { PriorityMenu, PriorityMenuItem } from "components/PriorityMenu";
|
||||
import { legacyNavigationMixin } from "components/plugins";
|
||||
@@ -158,10 +158,11 @@ export default {
|
||||
|
||||
computed: {
|
||||
...mapGetters("user", ["currentUser"]),
|
||||
...mapGetters("config", ["config"]),
|
||||
|
||||
...mapState("config", {
|
||||
showViz: (state) => state.config.visualizations_visible,
|
||||
}),
|
||||
showViz() {
|
||||
return this.config.visualizations_visible;
|
||||
},
|
||||
|
||||
displayButtonTitle() {
|
||||
if (this.dataset.purged) {
|
||||
|
||||
@@ -44,7 +44,7 @@ either through the props, and make updates through the events -->
|
||||
|
||||
<h5 class="flex-grow-1 overflow-hidden mr-auto text-nowrap text-truncate">
|
||||
<span class="hid">{{ dataset.hid }}</span>
|
||||
<span v-if="collapsed" class="name">{{ dataset.title }}</span>
|
||||
<span v-if="collapsed || !dataset.canEditName" class="name">{{ dataset.title }}</span>
|
||||
</h5>
|
||||
<!-- <div v-if="collapsed && dataset.tags.length" class="nametags mt-1">
|
||||
<nametag v-for="tag in dataset.tags" :key="tag" :tag="tag" />
|
||||
@@ -61,6 +61,7 @@ either through the props, and make updates through the events -->
|
||||
|
||||
<header v-if="expanded" class="p-2">
|
||||
<ClickToEdit
|
||||
v-if="dataset.canEditName"
|
||||
tag-name="h4"
|
||||
:value="dataset.name"
|
||||
@input="$emit('updateDataset', { name: $event })"
|
||||
@@ -94,7 +95,6 @@ either through the props, and make updates through the events -->
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pre v-if="dataset.peek" class="dataset-peek p-1" v-html="dataset.peek"></pre>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -6,9 +6,12 @@
|
||||
class="dataset dataset-collection collapsed"
|
||||
:class="{ selected }"
|
||||
:data-state="dsc.state"
|
||||
@keydown.arrow-right.self.stop="eventHub.$emit('selectCollection', dsc)"
|
||||
@keydown.arrow-right.self.stop="$emit('select-collection', dsc)"
|
||||
@keydown.space.self.stop.prevent="$emit('update:selected', !selected)"
|
||||
@click.stop="eventHub.$emit('selectCollection', dsc)"
|
||||
@click.stop="
|
||||
$emit('select-collection', dsc);
|
||||
$emit('update:expanded', dsc);
|
||||
"
|
||||
>
|
||||
<nav class="d-flex content-top-menu align-items-center justify-content-between">
|
||||
<div class="d-flex mr-1 align-items-center" @click.stop>
|
||||
@@ -35,7 +38,7 @@
|
||||
state="ok"
|
||||
title="Collection"
|
||||
icon="fas fa-folder"
|
||||
@click.stop="$emit('selectCollection', dsc)"
|
||||
@click.stop="$emit('select-collection', dsc)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -47,7 +50,15 @@
|
||||
</span>
|
||||
</h5>
|
||||
|
||||
<DscMenu class="content-item-menu" v-on="$listeners" />
|
||||
<DscMenu v-if="!dsc.deleted" class="content-item-menu" v-on="$listeners" />
|
||||
<StateBtn
|
||||
v-if="dsc.deleted"
|
||||
class="px-1"
|
||||
state="deleted"
|
||||
title="Undelete"
|
||||
icon="fas fa-trash-restore"
|
||||
@click.stop="$emit('undeleteCollection')"
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<JobStateProgress class="m-2" v-if="dsc.jobSummary" :summary="dsc.jobSummary" />
|
||||
|
||||
@@ -5,7 +5,7 @@ export default {
|
||||
|
||||
computed: {
|
||||
contentItemComponent() {
|
||||
if (this.item._id === undefined) {
|
||||
if (this.item.id === undefined) {
|
||||
return "Placeholder";
|
||||
// return "Loading";
|
||||
}
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
v-on="$listeners"
|
||||
class="collapsed"
|
||||
:data-state="state"
|
||||
@keydown.arrow-right.self.stop.prevent="eventHub.$emit('selectCollection', dsc)"
|
||||
@keydown.arrow-right.self.stop.prevent="$emit('select-collection', dsc)"
|
||||
>
|
||||
<nav
|
||||
class="content-top-menu d-flex align-items-center justify-content-between p-1"
|
||||
@click.stop="eventHub.$emit('selectCollection', dsc)"
|
||||
@click.stop="$emit('select-collection', dsc)"
|
||||
>
|
||||
<h5 class="flex-grow-1 overflow-hidden mr-auto text-nowrap text-truncate">
|
||||
<span class="name">{{ dsc.name }}</span>
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
@scroll="setScrollPos"
|
||||
>
|
||||
<template v-slot="{ item, index }">
|
||||
<HistoryContentItem :item="item" :index="index" />
|
||||
<HistoryContentItem :item="item" :index="index" v-on="$listeners" />
|
||||
</template>
|
||||
</VirtualScroller>
|
||||
</template>
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
key="export-history-to-file"
|
||||
title="Export History to File"
|
||||
icon="fas fa-file-archive"
|
||||
@click="iframeRedirect('/history/export_archive?preview=True')"
|
||||
@click="backboneRoute(`/histories/${history.id}/export`)"
|
||||
/>
|
||||
</PriorityMenu>
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
selected datset collections -->
|
||||
|
||||
<template>
|
||||
<History v-if="history && !selectedCollections.length" :history="history">
|
||||
<History
|
||||
v-on:select-collection="selectCollection($event)"
|
||||
v-if="history && !selectedCollections.length"
|
||||
:history="history"
|
||||
>
|
||||
<template v-for="(_, name) in $scopedSlots" :slot="name" slot-scope="slotData">
|
||||
<slot :name="name" v-bind="slotData" :history="history" />
|
||||
</template>
|
||||
@@ -50,8 +54,5 @@ export default {
|
||||
this.selectedCollections = [...this.selectedCollections, coll];
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.eventHub.$on("selectCollection", this.selectCollection);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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 = {}) => {
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -13,4 +13,4 @@ export { changes } from "./changes";
|
||||
export { monitorQuery } from "./monitorQuery";
|
||||
|
||||
// utility function
|
||||
export { wipeDatabase } from "./pouch";
|
||||
export { wipeDatabase } from "./wipeDatabase";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
+1
-1
@@ -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;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<span>
|
||||
<b
|
||||
><a :href="link">{{ link }}</a></b
|
||||
>
|
||||
<font-awesome-icon
|
||||
v-b-tooltip.hover
|
||||
title="Copy export URL to your clipboard"
|
||||
icon="link"
|
||||
style="cursor: pointer;"
|
||||
@click="copyUrl"
|
||||
/>
|
||||
<i
|
||||
title="Information about when the history export was generated is included in the job details. Additionally, if there are issues with export, the job details may help figure out the underlying problem or communicate issues to your Galaxy administrator."
|
||||
>
|
||||
(<a href="#" @click="showDetails">view job details</a>)
|
||||
</i>
|
||||
<b-modal v-model="details" scrollable ok-only>
|
||||
<job-information :job_id="historyExport.job_id" :include-times="true" />
|
||||
</b-modal>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { library } from "@fortawesome/fontawesome-svg-core";
|
||||
import { faLink } from "@fortawesome/free-solid-svg-icons";
|
||||
import { BModal } from "bootstrap-vue";
|
||||
import { copy } from "utils/clipboard";
|
||||
import JobInformation from "components/JobInformation/JobInformation.vue";
|
||||
|
||||
library.add(faLink);
|
||||
|
||||
export default {
|
||||
components: { BModal, JobInformation, FontAwesomeIcon },
|
||||
props: {
|
||||
historyExport: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
details: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
link() {
|
||||
return this.historyExport.external_download_permanent_url;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
showDetails() {
|
||||
this.details = true;
|
||||
},
|
||||
copyUrl() {
|
||||
copy(this.latestExportUrl, "Export URL copied to your clipboard");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<span>
|
||||
<h2>Export history archive</h2>
|
||||
<span v-if="initializing">
|
||||
<loading-span message="Loading server configuration." />
|
||||
</span>
|
||||
<span v-else-if="hasWritableFileSources">
|
||||
<b-card no-body>
|
||||
<b-tabs pills card vertical>
|
||||
<b-tab title="to a link" active>
|
||||
<b-card-text>
|
||||
<ToLink :history-id="historyId" />
|
||||
</b-card-text>
|
||||
</b-tab>
|
||||
<b-tab title="to a remote file">
|
||||
<ToRemoteFile :history-id="historyId" />
|
||||
</b-tab>
|
||||
</b-tabs>
|
||||
</b-card>
|
||||
</span>
|
||||
<span v-else>
|
||||
<ToLink :history-id="historyId" />
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from "vue";
|
||||
import BootstrapVue from "bootstrap-vue";
|
||||
import ToLink from "./ToLink.vue";
|
||||
import ToRemoteFile from "./ToRemoteFile.vue";
|
||||
import { Services } from "components/FilesDialog/services";
|
||||
import LoadingSpan from "components/LoadingSpan";
|
||||
|
||||
Vue.use(BootstrapVue);
|
||||
|
||||
export default {
|
||||
components: {
|
||||
LoadingSpan,
|
||||
ToLink,
|
||||
ToRemoteFile,
|
||||
},
|
||||
props: {
|
||||
historyId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
initializing: true,
|
||||
hasWritableFileSources: false,
|
||||
};
|
||||
},
|
||||
async mounted() {
|
||||
await this.initialize();
|
||||
},
|
||||
methods: {
|
||||
async initialize() {
|
||||
const fileSources = await new Services().getFileSources();
|
||||
this.hasWritableFileSources = fileSources.some((fs) => fs.writable);
|
||||
console.log(fileSources);
|
||||
this.initializing = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div>
|
||||
<b-alert show variant="danger" v-if="errorMessage" dismissible @dismissed="errorMessage = null">
|
||||
{{ errorMessage }}
|
||||
<JobError
|
||||
v-if="jobError"
|
||||
style="margin-top: 15px;"
|
||||
header="History export job ended in error"
|
||||
:job="jobError"
|
||||
/>
|
||||
</b-alert>
|
||||
<div v-if="loadingExports">
|
||||
<loading-span message="Loading history export information from Galaxy server." />
|
||||
</div>
|
||||
<div v-else-if="waitingOnJob">
|
||||
<loading-span message="Galaxy server is preparing history for download, this will likely take a while." />
|
||||
</div>
|
||||
<div v-else-if="latestExportReady">
|
||||
Link for download ready
|
||||
<export-link :historyExport="latestExport" />
|
||||
. Use this link to download the archive or import it on another Galaxy server.
|
||||
</div>
|
||||
<div v-else-if="hasReadyExport">
|
||||
<p>An out of date export is ready <export-link :historyExport="latestReadyExport" />.</p>
|
||||
|
||||
<p>
|
||||
The history has changed since this export was generated,
|
||||
<a class="export-link" href="#" @click.prevent="regenerateExport"
|
||||
>click here to generate a new archive for the current history state</a
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p>No link for history export ready, {{ whyNoLink }}.</p>
|
||||
<p>
|
||||
<b
|
||||
><a class="export-link" href="#" @click.prevent="regenerateExport"
|
||||
>Click here to to generate a new archive for this history.</a
|
||||
></b
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getAppRoot } from "onload/loadConfig";
|
||||
import axios from "axios";
|
||||
import Vue from "vue";
|
||||
import BootstrapVue from "bootstrap-vue";
|
||||
import { errorMessageAsString } from "utils/simple-error";
|
||||
import LoadingSpan from "components/LoadingSpan";
|
||||
import ExportLink from "./ExportLink.vue";
|
||||
import { waitOnJob } from "components/JobStates/wait";
|
||||
import JobError from "components/JobInformation/JobError";
|
||||
|
||||
Vue.use(BootstrapVue);
|
||||
|
||||
export default {
|
||||
components: { LoadingSpan, ExportLink, JobError },
|
||||
props: {
|
||||
historyId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
errorMessage: null,
|
||||
exportsInitialized: false,
|
||||
exports: null,
|
||||
loadingExports: false,
|
||||
waitingOnJob: false,
|
||||
jobError: null,
|
||||
};
|
||||
},
|
||||
created() {
|
||||
if (!this.exportsInitialized) {
|
||||
this.exportsInitialized = true;
|
||||
this.loadExports();
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
hasExports() {
|
||||
return this.exports !== null && this.exports.length > 0;
|
||||
},
|
||||
latestExport() {
|
||||
return this.exports && this.exports[0];
|
||||
},
|
||||
readyExports() {
|
||||
return (this.exports || []).filter((e) => e.ready);
|
||||
},
|
||||
hasReadyExport() {
|
||||
return this.latestReadyExport !== null;
|
||||
},
|
||||
latestReadyExport() {
|
||||
return this.readyExports.length > 0 ? this.readyExports[0] : null;
|
||||
},
|
||||
latestExportReady() {
|
||||
return this.latestExport?.ready && this.latestExport?.up_to_date;
|
||||
},
|
||||
latestExportPreparing() {
|
||||
return this.latestExport?.preparing;
|
||||
},
|
||||
whyNoLink() {
|
||||
if (!this.hasExports) {
|
||||
return `no history export ever initiated for this history`;
|
||||
} else {
|
||||
return `previous history export archival likely failed`;
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
loadExports() {
|
||||
this.loadingExports = true;
|
||||
const url = `${getAppRoot()}api/histories/${this.historyId}/exports`;
|
||||
axios
|
||||
.get(url)
|
||||
.then((response) => {
|
||||
this.loadingExports = false;
|
||||
this.exports = response.data;
|
||||
if (this.latestExportPreparing) {
|
||||
this.waitOnExportJob(this.latestExport.job_id);
|
||||
} else {
|
||||
this.waitingOnJob = false;
|
||||
}
|
||||
})
|
||||
.catch(this.handleError);
|
||||
},
|
||||
regenerateExport() {
|
||||
this.waitingOnJob = true;
|
||||
const url = `${getAppRoot()}api/histories/${this.historyId}/exports`;
|
||||
axios
|
||||
.put(url)
|
||||
.then((response) => {
|
||||
const status = response.status;
|
||||
if (status == 200) {
|
||||
this.loadExports();
|
||||
} else if (status == 202) {
|
||||
this.waitOnExportJob(response.data.job_id);
|
||||
} else {
|
||||
// error ....
|
||||
this.errorMessage = `Unexpected error while polling history export ${errorMessageAsString(
|
||||
response
|
||||
)}`;
|
||||
}
|
||||
})
|
||||
.catch(this.handleError);
|
||||
},
|
||||
waitOnExportJob(jobId) {
|
||||
this.waitingOnJob = true;
|
||||
this.jobError = false;
|
||||
waitOnJob(jobId)
|
||||
.then((jobResponse) => {
|
||||
this.waitingOnJob = false;
|
||||
this.loadingExports = true;
|
||||
// Race condition, for some reasons the job API returns
|
||||
// ok before the JEHA...
|
||||
setTimeout(this.loadExports, 2000);
|
||||
})
|
||||
.catch(this.handleError);
|
||||
},
|
||||
handleError(err) {
|
||||
this.waitingOnJob = false;
|
||||
this.loadingExports = false;
|
||||
this.errorMessage = errorMessageAsString(err, "Error generating a history export link");
|
||||
if (err?.data?.stderr) {
|
||||
this.jobError = err.data;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<div>
|
||||
<b-alert v-if="errorMessage" show variant="danger" dismissible @dismissed="errorMessage = null">
|
||||
{{ errorMessage }}
|
||||
<JobError
|
||||
v-if="jobError"
|
||||
style="margin-top: 15px;"
|
||||
header="History export job ended in error"
|
||||
:job="jobError"
|
||||
/>
|
||||
</b-alert>
|
||||
<div v-if="waitingOnJob">
|
||||
<loading-span message="Executing history export job, this will likely take a while." />
|
||||
</div>
|
||||
<div v-else-if="jobComplete">
|
||||
<b-alert show variant="success" dismissible @dismissed="reset">
|
||||
<h4>Done!</h4>
|
||||
<p>History successfully exported.</p>
|
||||
</b-alert>
|
||||
</div>
|
||||
<div v-else>
|
||||
<b-form-group
|
||||
id="directory"
|
||||
label-for="directory"
|
||||
description="Select a 'remote files' directory to export history archive to."
|
||||
class="mt-3"
|
||||
>
|
||||
<files-input v-model="directory" mode="directory" :requireWritable="true" />
|
||||
</b-form-group>
|
||||
<b-form-group id="name" label-for="name" description="Give the exported file a name." class="mt-3">
|
||||
<b-form-input id="name" v-model="name" placeholder="Name" required></b-form-input>
|
||||
</b-form-group>
|
||||
<b-row align-h="end">
|
||||
<b-col
|
||||
><b-button class="export-button" variant="primary" @click="doExport" :disabled="!canExport"
|
||||
>Export</b-button
|
||||
></b-col
|
||||
>
|
||||
</b-row>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getAppRoot } from "onload/loadConfig";
|
||||
import axios from "axios";
|
||||
import { waitOnJob } from "components/JobStates/wait";
|
||||
import { errorMessageAsString } from "utils/simple-error";
|
||||
import LoadingSpan from "components/LoadingSpan";
|
||||
import FilesInput from "components/FilesDialog/FilesInput.vue";
|
||||
import JobError from "components/JobInformation/JobError";
|
||||
import Vue from "vue";
|
||||
import BootstrapVue from "bootstrap-vue";
|
||||
Vue.use(BootstrapVue);
|
||||
|
||||
export default {
|
||||
components: {
|
||||
FilesInput,
|
||||
LoadingSpan,
|
||||
JobError,
|
||||
},
|
||||
props: {
|
||||
historyId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
canExport() {
|
||||
return !!this.name && !!this.directory;
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
errorMessage: null,
|
||||
name: null,
|
||||
directory: null,
|
||||
waitingOnJob: false,
|
||||
jobComplete: false,
|
||||
jobError: null,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
doExport() {
|
||||
const data = {
|
||||
directory_uri: this.directory,
|
||||
file_name: this.name,
|
||||
};
|
||||
this.waitingOnJob = true;
|
||||
const url = `${getAppRoot()}api/histories/${this.historyId}/exports`;
|
||||
axios
|
||||
.put(url, data)
|
||||
.then((response) => {
|
||||
waitOnJob(response.data.job_id)
|
||||
.then((data) => {
|
||||
this.waitingOnJob = false;
|
||||
this.jobComplete = true;
|
||||
})
|
||||
.catch(this.handleError);
|
||||
})
|
||||
.catch(this.handleError);
|
||||
},
|
||||
reset() {
|
||||
this.jobComplete = false;
|
||||
this.errorMessage = null;
|
||||
this.name = "";
|
||||
this.directory = "";
|
||||
this.jobError = null;
|
||||
},
|
||||
handleError(err) {
|
||||
this.waitingOnJob = false;
|
||||
this.errorMessage = errorMessageAsString(err, "History export failed.");
|
||||
if (err?.data?.stderr) {
|
||||
this.jobError = err.data;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as HistoryExport } from "./Index.vue";
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,57 +1,172 @@
|
||||
<template>
|
||||
<b-form @submit="submit">
|
||||
<b-card title="Import a history from an archive">
|
||||
<b-alert :show="hasErrorMessage" variant="danger">{{ errorMessage }}</b-alert>
|
||||
<p>Please provide a Galaxy history export URL or a history file.</p>
|
||||
<b-form-group label="Archived History URL"> <b-form-input type="url" v-model="sourceURL" /> </b-form-group>
|
||||
<b-form-group label="Archived History File"> <b-form-file v-model="sourceFile" /> </b-form-group>
|
||||
<b-button type="submit">Import history</b-button>
|
||||
</b-card>
|
||||
</b-form>
|
||||
<b-card title="Import a history from an archive">
|
||||
<b-alert v-if="errorMessage" variant="danger" dismissible @dismissed="errorMessage = null" show>
|
||||
{{ errorMessage }}
|
||||
<JobError
|
||||
v-if="jobError"
|
||||
style="margin-top: 15px;"
|
||||
header="History import job ended in error"
|
||||
:job="jobError"
|
||||
/>
|
||||
</b-alert>
|
||||
<div v-if="initializing">
|
||||
<loading-span message="Loading server configuration." />
|
||||
</div>
|
||||
<div v-else-if="waitingOnJob">
|
||||
<LoadingSpan message="Waiting on history import job, this may take a while." />
|
||||
</div>
|
||||
<div v-else-if="complete">
|
||||
<b-alert :show="complete" variant="success" dismissible @dismissed="complete = false">
|
||||
<h4>Done!</h4>
|
||||
<p>History imported, check out <a :href="historyLink">your histories</a>.</p>
|
||||
</b-alert>
|
||||
</div>
|
||||
<div v-else>
|
||||
<b-form @submit.prevent="submit">
|
||||
<b-form-group label="How would you like to specify the history archive?" v-slot="{ ariaDescribedby }">
|
||||
<b-form-radio-group
|
||||
v-model="importType"
|
||||
:aria-describedby="ariaDescribedby"
|
||||
name="import-type"
|
||||
stacked
|
||||
>
|
||||
<b-form-radio value="externalUrl">
|
||||
Export URL from another Galaxy instance
|
||||
<font-awesome-icon icon="external-link-alt" />
|
||||
</b-form-radio>
|
||||
<b-form-radio value="upload">
|
||||
Upload local file from your computer
|
||||
<font-awesome-icon icon="upload" />
|
||||
</b-form-radio>
|
||||
<b-form-radio value="remoteFilesUri" v-if="hasFileSources">
|
||||
Select a remote file (e.g. Galaxy's FTP)
|
||||
<font-awesome-icon icon="folder-open" />
|
||||
</b-form-radio>
|
||||
</b-form-radio-group>
|
||||
</b-form-group>
|
||||
<b-form-group v-if="importType == 'externalUrl'" label="Archived History URL">
|
||||
<b-form-input type="url" v-model="sourceURL" />
|
||||
</b-form-group>
|
||||
<b-form-group v-if="importType == 'upload'" label="Archived History File">
|
||||
<b-form-file v-model="sourceFile" />
|
||||
</b-form-group>
|
||||
<b-form-group v-show="importType == 'remoteFilesUri'" label="Remote File">
|
||||
<!-- using v-show so we can have a persistent ref and launch dialog on select -->
|
||||
<files-input v-model="sourceRemoteFilesUri" ref="filesInput" />
|
||||
</b-form-group>
|
||||
<b-button class="import-button" variant="primary" type="submit" :disabled="!importReady"
|
||||
>Import history</b-button
|
||||
>
|
||||
</b-form>
|
||||
</div>
|
||||
</b-card>
|
||||
</template>
|
||||
<script>
|
||||
import { getAppRoot } from "onload/loadConfig";
|
||||
import axios from "axios";
|
||||
import Vue from "vue";
|
||||
import BootstrapVue from "bootstrap-vue";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { library } from "@fortawesome/fontawesome-svg-core";
|
||||
import { faFolderOpen, faUpload, faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons";
|
||||
import FilesInput from "components/FilesDialog/FilesInput.vue";
|
||||
import { waitOnJob } from "components/JobStates/wait";
|
||||
import { errorMessageAsString } from "utils/simple-error";
|
||||
import LoadingSpan from "components/LoadingSpan";
|
||||
import JobError from "components/JobInformation/JobError";
|
||||
import { Services } from "components/FilesDialog/services";
|
||||
|
||||
library.add(faFolderOpen);
|
||||
library.add(faUpload);
|
||||
library.add(faExternalLinkAlt);
|
||||
Vue.use(BootstrapVue);
|
||||
|
||||
export default {
|
||||
components: { FilesInput, FontAwesomeIcon, JobError, LoadingSpan },
|
||||
data() {
|
||||
return {
|
||||
initializing: true,
|
||||
importType: "externalUrl",
|
||||
sourceFile: null,
|
||||
sourceURL: null,
|
||||
sourceRemoteFilesUri: null,
|
||||
errorMessage: null,
|
||||
waitingOnJob: false,
|
||||
complete: false,
|
||||
jobError: null,
|
||||
hasFileSources: false,
|
||||
};
|
||||
},
|
||||
async mounted() {
|
||||
await this.initialize();
|
||||
},
|
||||
computed: {
|
||||
hasErrorMessage() {
|
||||
return this.errorMessage != null;
|
||||
importReady() {
|
||||
const importType = this.importType;
|
||||
if (importType == "externalUrl") {
|
||||
return !!this.sourceURL;
|
||||
} else if (importType == "upload") {
|
||||
return !!this.sourceFile;
|
||||
} else if (importType == "remoteFilesUri") {
|
||||
return !!this.sourceRemoteFilesUri;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
historyLink() {
|
||||
return `${getAppRoot()}histories/list`;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async initialize() {
|
||||
const fileSources = await new Services().getFileSources();
|
||||
this.hasFileSources = fileSources.length > 0;
|
||||
this.initializing = false;
|
||||
},
|
||||
submit: function (ev) {
|
||||
ev.preventDefault();
|
||||
if (!this.sourceFile && !this.sourceURL) {
|
||||
this.errorMessage = "You must provide a history archive URL or file.";
|
||||
} else {
|
||||
const formData = new FormData();
|
||||
formData.append("archive_file", this.sourceFile);
|
||||
const formData = new FormData();
|
||||
const importType = this.importType;
|
||||
if (importType == "externalUrl") {
|
||||
formData.append("archive_source", this.sourceURL);
|
||||
axios
|
||||
.post(`${getAppRoot()}api/histories`, formData)
|
||||
.then((response) => {
|
||||
window.location = `${getAppRoot()}histories/list?message=${
|
||||
response.data.message
|
||||
}&status=success`;
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error.response.data && error.response.data.err_msg;
|
||||
this.errorMessage = message || "Import failed for an unknown reason.";
|
||||
});
|
||||
} else if (importType == "upload") {
|
||||
formData.append("archive_file", this.sourceFile);
|
||||
formData.append("archive_source", "");
|
||||
} else if (importType == "remoteFilesUri") {
|
||||
formData.append("archive_source", this.sourceRemoteFilesUri);
|
||||
}
|
||||
axios
|
||||
.post(`${getAppRoot()}api/histories`, formData)
|
||||
.then((response) => {
|
||||
this.waitingOnJob = true;
|
||||
waitOnJob(response.data.id)
|
||||
.then((jobResponse) => {
|
||||
this.waitingOnJob = false;
|
||||
this.complete = true;
|
||||
})
|
||||
.catch(this.handleError);
|
||||
})
|
||||
.catch(this.handleError);
|
||||
},
|
||||
handleError: function (err) {
|
||||
this.waitingOnJob = false;
|
||||
this.errorMessage = errorMessageAsString(err, "History import failed.");
|
||||
if (err?.data?.stderr) {
|
||||
this.jobError = err.data;
|
||||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
importType() {
|
||||
if (this.importType == "remoteFilesUri" && !this.sourceRemoteFilesUri) {
|
||||
this.$refs.filesInput.selectFile();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.error-wrapper {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<tr @click="toggleExpanded">
|
||||
<td>
|
||||
{{ codeLabel }}
|
||||
</td>
|
||||
<td v-if="expanded">
|
||||
<pre class="code">{{ codeItem }}</pre>
|
||||
</td>
|
||||
<td v-else-if="codeItem">
|
||||
<pre class="code preview">{{ codeItem }}</pre>
|
||||
<b>(Click to expand)</b>
|
||||
</td>
|
||||
<td v-else><i>empty</i></td>
|
||||
</tr>
|
||||
</template>
|
||||
<script>
|
||||
import Vue from "vue";
|
||||
import BootstrapVue from "bootstrap-vue";
|
||||
|
||||
Vue.use(BootstrapVue);
|
||||
|
||||
export default {
|
||||
props: {
|
||||
codeLabel: String,
|
||||
codeItem: String,
|
||||
},
|
||||
data() {
|
||||
return { expanded: false };
|
||||
},
|
||||
methods: {
|
||||
toggleExpanded() {
|
||||
if (this.codeItem) {
|
||||
this.expanded = !this.expanded;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<b-card border-variant="danger" :header="header">
|
||||
<b-card-text>
|
||||
<div @click="showInfo = true">
|
||||
<a href="#">See full job details <font-awesome-icon icon="info-circle" /></a>
|
||||
</div>
|
||||
<div class="error-wrapper" v-if="job.stderr" @click="toggleExpanded">
|
||||
Job Standard Error
|
||||
<pre :class="errorClasses">{{ job.stderr }}</pre>
|
||||
<b v-if="!expanded">(Click to expand job standard error)</b>
|
||||
</div>
|
||||
<!-- TODO: modal for reporting error. -->
|
||||
</b-card-text>
|
||||
<b-modal v-model="showInfo" scrollable ok-only>
|
||||
<job-information :job_id="job.id" :include-times="true" />
|
||||
</b-modal>
|
||||
</b-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import JobInformation from "./JobInformation.vue";
|
||||
import Vue from "vue";
|
||||
import BootstrapVue from "bootstrap-vue";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { library } from "@fortawesome/fontawesome-svg-core";
|
||||
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
library.add(faInfoCircle);
|
||||
Vue.use(BootstrapVue);
|
||||
|
||||
export default {
|
||||
components: { JobInformation, FontAwesomeIcon },
|
||||
props: {
|
||||
job: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
header: {
|
||||
type: String,
|
||||
default: "Job ended in error",
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
expanded: false,
|
||||
showInfo: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
errorClasses() {
|
||||
const classes = ["code"];
|
||||
if (!this.expanded) {
|
||||
classes.push("preview");
|
||||
}
|
||||
return classes;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
toggleExpanded() {
|
||||
this.expanded = !this.expanded;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.error-wrapper {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3>Job Information</h3>
|
||||
<table id="job-information" class="tabletip">
|
||||
<table id="job-information" class="tabletip info_data_table">
|
||||
<tbody>
|
||||
<tr v-if="job && job.tool_id">
|
||||
<td>Galaxy Tool ID:</td>
|
||||
@@ -11,24 +11,25 @@
|
||||
<td>Galaxy Tool Version:</td>
|
||||
<td id="galaxy-tool-version">{{ job.tool_version }}</td>
|
||||
</tr>
|
||||
<tr v-if="dataset.tool_version">
|
||||
<td>Tool Version:</td>
|
||||
<td id="tool-version">{{ dataset.tool_version }}</td>
|
||||
<tr v-if="job && includeTimes">
|
||||
<td>Created</td>
|
||||
<td id="created" v-if="job.create_time">
|
||||
<UtcDate :date="job.create_time" mode="pretty" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Tool Standard Output:</td>
|
||||
<td id="stdout"><a :href="`${getAppRoot()}datasets/${this.dataset.id}/stdout`">stdout</a></td>
|
||||
<tr v-if="job && includeTimes">
|
||||
<td>Updated</td>
|
||||
<td id="created" v-if="job.update_time">
|
||||
<UtcDate :date="job.update_time" mode="pretty" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Tool Standard Error:</td>
|
||||
<td id="stderr"><a :href="`${getAppRoot()}datasets/${this.dataset.id}/stderr`">stderr</a></td>
|
||||
</tr>
|
||||
|
||||
<code-row id="command-line" v-if="job" :codeLabel="'Command Line'" :codeItem="job.command_line" />
|
||||
<code-row id="stdout" v-if="job" :codeLabel="'Tool Standard Output'" :codeItem="job.tool_stdout" />
|
||||
<code-row id="stderr" v-if="job" :codeLabel="'Tool Standard Error'" :codeItem="job.tool_stderr" />
|
||||
<tr v-if="job">
|
||||
<td>Tool Exit Code:</td>
|
||||
<td id="exist-code">{{ job.exit_code }}</td>
|
||||
</tr>
|
||||
|
||||
<tr id="job-messages" v-if="job && job.job_messages && job.job_messages.length > 0">
|
||||
<td>Job Messages</td>
|
||||
<td>
|
||||
@@ -37,49 +38,16 @@
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>History Content API ID:</td>
|
||||
<td>
|
||||
<div id="dataset-id">{{ dataset.id }}</div>
|
||||
<div id="history-dataset-id" v-if="dataset.dataset_id">({{ dataset.dataset_id }})</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr v-if="job">
|
||||
<tr v-if="job && job.id">
|
||||
<td>Job API ID:</td>
|
||||
<td>
|
||||
<div id="encoded-job-id">{{ job.id }}</div>
|
||||
<div id="job-id" v-if="isEligibleForDecode && decoded_job_id">({{ decoded_job_id }})</div>
|
||||
</td>
|
||||
<td id="encoded-job-id">{{ job.id }} <decoded-id :id="job.id" /></td>
|
||||
</tr>
|
||||
<tr v-if="isCopiedFromJobId">
|
||||
<tr v-if="job && job.copied_from_job_id">
|
||||
<td>Copied from Job API ID:</td>
|
||||
<td>
|
||||
<div id="encoded-copied-from-job-id">{{ job.copied_from_job_id }}</div>
|
||||
<div id="copied-from-job-id" v-if="decoded_copied_from_job_id">
|
||||
({{ decoded_copied_from_job_id }})
|
||||
</div>
|
||||
<td id="encoded-copied-from-job-id">
|
||||
{{ job.copied_from_job_id }} <decoded-id :id="job.copied_from_job_id" />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr v-if="dataset.history_id">
|
||||
<td>History API ID:</td>
|
||||
<td>
|
||||
<div id="history_id">{{ dataset.history_id }}</div>
|
||||
<div v-if="isEligibleForDecode && decoded_history_id">({{ decoded_history_id }})</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr v-if="dataset.uuid">
|
||||
<td>UUID:</td>
|
||||
<td id="dataset-uuid">{{ dataset.uuid }}</td>
|
||||
</tr>
|
||||
|
||||
<tr v-if="dataset.file_name">
|
||||
<td>Full Path:</td>
|
||||
<td id="file_name">{{ dataset.file_name }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -88,79 +56,40 @@
|
||||
<script>
|
||||
import { mapCacheActions } from "vuex-cache";
|
||||
import { getAppRoot } from "onload/loadConfig";
|
||||
import { getGalaxyInstance } from "app";
|
||||
import { Services } from "./services";
|
||||
import DecodedId from "../DecodedId.vue";
|
||||
import CodeRow from "./CodeRow.vue";
|
||||
import UtcDate from "components/UtcDate";
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
decoded_history_id: undefined,
|
||||
decoded_job_id: undefined,
|
||||
decoded_copied_from_job_id: undefined,
|
||||
};
|
||||
components: {
|
||||
CodeRow,
|
||||
DecodedId,
|
||||
UtcDate,
|
||||
},
|
||||
props: {
|
||||
hda_id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
job_id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
includeTimes: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
created: function () {
|
||||
this.fetchDataset(this.hda_id);
|
||||
this.fetchJob(this.job_id);
|
||||
this.services = new Services({ root: this.root });
|
||||
},
|
||||
computed: {
|
||||
isAdmin: function () {
|
||||
const Galaxy = getGalaxyInstance();
|
||||
return Galaxy?.user?.isAdmin() || false;
|
||||
},
|
||||
dataset: function () {
|
||||
return this.$store.getters.dataset(this.hda_id);
|
||||
},
|
||||
job: function () {
|
||||
const job = this.$store.getters.job(this.job_id);
|
||||
return job;
|
||||
},
|
||||
isEligibleForDecode: function () {
|
||||
if (this.job.id && this.dataset.history_id && this.isAdmin) {
|
||||
this.decode_ids();
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
isCopiedFromJobId() {
|
||||
let value = false;
|
||||
if (this.job.copied_from_job_id) {
|
||||
value = true;
|
||||
if (this.isAdmin) {
|
||||
this.decode_copied_from_job_id();
|
||||
}
|
||||
}
|
||||
return value;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
decode_ids: async function () {
|
||||
this.decoded_history_id = await this.decode(this.dataset.history_id);
|
||||
this.decoded_job_id = await this.decode(this.job.id);
|
||||
},
|
||||
decode_copied_from_job_id: async function () {
|
||||
this.decoded_copied_from_job_id = await this.decode(this.job.copied_from_job_id);
|
||||
},
|
||||
...mapCacheActions(["fetchJob"]),
|
||||
getAppRoot() {
|
||||
return getAppRoot();
|
||||
},
|
||||
decode: async function (id) {
|
||||
const response = await this.services.decode(id);
|
||||
return response.data.decoded_id;
|
||||
},
|
||||
...mapCacheActions(["fetchJob", "fetchDataset"]),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { mountVueComponent } from "utils/mountVueComponent";
|
||||
|
||||
export const mountJobInformation = (propsData = {}) => {
|
||||
document.querySelectorAll(".job-information").forEach((element) => {
|
||||
propsData.hda_id = element.getAttribute("hda_id");
|
||||
propsData.job_id = element.getAttribute("job_id");
|
||||
mountVueComponent(JobInformation)(propsData, element);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
"tool_id": "upload1",
|
||||
"tool_version": "1.1.7",
|
||||
"exit_code": 0,
|
||||
"tool_stdout": "stdout",
|
||||
"tool_stderr": "stderr",
|
||||
"command_line": "commandline",
|
||||
"job_messages": ["test-msg1", "test-msg2", "test-msg3", "test-msg4"],
|
||||
"id": "test_id",
|
||||
"copied_from_job_id": "test_copied_from_job_id"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div>
|
||||
<div v-if="!isSingleParam" class="tool-parameters">
|
||||
<h3 v-if="includeTitle">Tool Parameters</h3>
|
||||
<table class="tabletip" id="tool-parameters">
|
||||
<table class="tabletip info_data_table" id="tool-parameters">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Input Parameter</th>
|
||||
@@ -130,12 +130,3 @@ export default {
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
table.info_data_table {
|
||||
table-layout: fixed;
|
||||
word-break: break-word;
|
||||
}
|
||||
table.info_data_table td:nth-child(1) {
|
||||
width: 25%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { getAppRoot } from "onload/loadConfig";
|
||||
import JOB_STATES_MODEL from "mvc/history/job-states-model";
|
||||
import axios from "axios";
|
||||
|
||||
export function waitOnJob(jobId, onStateUpdate = null, interval = 1000) {
|
||||
// full=true to capture standard error on last iteration for building
|
||||
// error messages.
|
||||
const jobUrl = `${getAppRoot()}api/jobs/${jobId}?full=true`;
|
||||
const checkCondition = function (resolve, reject) {
|
||||
axios
|
||||
.get(jobUrl)
|
||||
.then((jobResponse) => {
|
||||
const state = jobResponse.data.state;
|
||||
if (onStateUpdate !== null) {
|
||||
onStateUpdate(state);
|
||||
}
|
||||
if (JOB_STATES_MODEL.NON_TERMINAL_STATES.indexOf(state) !== -1) {
|
||||
setTimeout(checkCondition, interval, resolve, reject);
|
||||
} else if (JOB_STATES_MODEL.ERROR_STATES.indexOf(state) !== -1) {
|
||||
reject(jobResponse);
|
||||
} else {
|
||||
resolve(jobResponse);
|
||||
}
|
||||
})
|
||||
.catch(reject);
|
||||
};
|
||||
|
||||
return new Promise(checkCondition);
|
||||
}
|
||||
@@ -153,6 +153,10 @@
|
||||
<font-awesome-icon v-if="row.item.type === 'folder'" :icon="['far', 'folder']" title="Folder" />
|
||||
<font-awesome-icon v-else-if="row.item.type === 'file'" title="Dataset" :icon="['far', 'file']" />
|
||||
</template>
|
||||
<template v-slot:cell(type)="row">
|
||||
<div v-if="row.item.type === 'folder'">{{ row.item.type }}</div>
|
||||
<div v-else-if="row.item.type === 'file'">{{ row.item.file_ext }}</div>
|
||||
</template>
|
||||
<template v-slot:cell(raw_size)="row">
|
||||
<div v-if="row.item.type === 'file'" v-html="bytesToString(row.item.raw_size)"></div>
|
||||
</template>
|
||||
@@ -606,6 +610,11 @@ export default {
|
||||
this.fetchFolderContents(this.include_deleted);
|
||||
},
|
||||
},
|
||||
perPage: {
|
||||
handler: function (value) {
|
||||
this.fetchFolderContents(this.include_deleted);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
+11
-8
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -5,20 +5,16 @@
|
||||
<img alt="logo" class="navbar-brand-image" :src="brandImageSecondary" v-if="brandImageSecondary" />
|
||||
<span class="navbar-brand-title">{{ brandTitle }}</span>
|
||||
</b-navbar-brand>
|
||||
|
||||
<b-navbar-nav>
|
||||
<masthead-item
|
||||
v-for="(tab, idx) in tabs"
|
||||
:tab="tab"
|
||||
:active-tab="activeTab"
|
||||
:key="`tab-${idx}`"
|
||||
:app-root="appRoot"
|
||||
:galaxy="galaxy"
|
||||
v-show="!(tab.hidden === undefined ? false : tab.hidden)"
|
||||
>
|
||||
</masthead-item>
|
||||
</b-navbar-nav>
|
||||
|
||||
<div ref="quota-meter-container" class="quota-meter-container" />
|
||||
</b-navbar>
|
||||
</template>
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 @@
|
||||
>
|
||||
<template v-if="tab.icon">
|
||||
<span :class="iconClasses" />
|
||||
<span v-if="tab.show_note" :class="['nav-note-port', tab.note_cls]">{{ tab.note }}</span>
|
||||
<span v-if="tab.show_note" :class="['nav-note', tab.note_cls]">{{ tab.note }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ tab.title }}
|
||||
@@ -54,6 +53,8 @@
|
||||
import Vue from "vue";
|
||||
import { VBPopoverPlugin, VBTooltipPlugin } from "bootstrap-vue";
|
||||
import { BNavItem, BNavItemDropdown, BDropdownItem } from "bootstrap-vue";
|
||||
import { getAppRoot } from "onload/loadConfig";
|
||||
import { getGalaxyInstance } from "app";
|
||||
|
||||
Vue.use(VBPopoverPlugin);
|
||||
Vue.use(VBTooltipPlugin);
|
||||
@@ -72,39 +73,30 @@ export default {
|
||||
activeTab: {
|
||||
type: String,
|
||||
},
|
||||
appRoot: {
|
||||
type: String,
|
||||
},
|
||||
galaxy: {
|
||||
type: Object,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
menu() {
|
||||
return this.tab.menu;
|
||||
},
|
||||
active() {
|
||||
return this.tab.id == this.activeTab;
|
||||
},
|
||||
popoverNote() {
|
||||
return `Please <a href="${this.appRoot}login">login or register</a> to use this feature.`;
|
||||
return `Please <a href="${getAppRoot()}login">login or register</a> to use this feature.`;
|
||||
},
|
||||
classes() {
|
||||
let classesString = this.tab.cls || "";
|
||||
if (this.active) {
|
||||
classesString = `${classesString} active`;
|
||||
}
|
||||
return classesString;
|
||||
const isActiveTab = this.tab.id == this.activeTab;
|
||||
return Object.fromEntries([
|
||||
["active", isActiveTab],
|
||||
[this.tab.cls, true],
|
||||
]);
|
||||
},
|
||||
linkClasses() {
|
||||
return {
|
||||
"nav-icon": this.tab.icon,
|
||||
toggle: this.tab.toggle,
|
||||
};
|
||||
},
|
||||
iconClasses() {
|
||||
return Object.fromEntries([
|
||||
["fa", true],
|
||||
["toggle", this.tab.toggle],
|
||||
["fa fa-fw", true],
|
||||
[this.tab.icon, this.tab.icon],
|
||||
]);
|
||||
},
|
||||
@@ -138,23 +130,21 @@ export default {
|
||||
if (tab.onclick) {
|
||||
return this.propogateClick(tab, event);
|
||||
}
|
||||
|
||||
if (tab.disabled) {
|
||||
event.preventDefault();
|
||||
|
||||
this.$root.$emit("bv::hide::tooltip");
|
||||
this.$root.$emit("bv::show::popover", tab.id);
|
||||
|
||||
setTimeout(() => {
|
||||
this.$root.$emit("bv::hide::popover", tab.id);
|
||||
}, 3000);
|
||||
} else if (!tab.menu) {
|
||||
event.preventDefault();
|
||||
if (tab.target === "__use_router__" && typeof this.galaxy.page !== "undefined") {
|
||||
this.galaxy.page.router.executeUseRouter(this.formatUrl(tab.url));
|
||||
const galaxy = getGalaxyInstance();
|
||||
if (tab.target === "__use_router__" && typeof galaxy.page !== "undefined") {
|
||||
galaxy.page.router.executeUseRouter(this.formatUrl(tab.url));
|
||||
} else {
|
||||
try {
|
||||
this.galaxy.frame.add({ ...tab, url: this.formatUrl(tab.url) });
|
||||
galaxy.frame.add({ ...tab, url: this.formatUrl(tab.url) });
|
||||
} catch (err) {
|
||||
console.warn("Missing frame element on galaxy instance", err);
|
||||
}
|
||||
@@ -164,31 +154,17 @@ export default {
|
||||
propogateClick(tab, event) {
|
||||
event.preventDefault();
|
||||
tab.onclick();
|
||||
|
||||
if (tab.id === "enable-scratchbook") {
|
||||
this.$emit("updateScratchbookTab", tab);
|
||||
}
|
||||
},
|
||||
formatUrl(url) {
|
||||
return typeof url === "string" && url.indexOf("//") === -1 && url.charAt(0) != "/"
|
||||
? this.appRoot + url
|
||||
: url;
|
||||
if (typeof url === "string" && url.indexOf("//") === -1 && url.charAt(0) != "/") {
|
||||
return getAppRoot() + url;
|
||||
} else {
|
||||
return url;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nav-note-port {
|
||||
position: absolute;
|
||||
font-weight: 700;
|
||||
font-size: 0.7rem;
|
||||
color: gold;
|
||||
line-height: 3.5rem;
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
li .nav-link > span.toggle {
|
||||
color: gold;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
<template>
|
||||
<div id="masthead"></div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getGalaxyInstance } from "app";
|
||||
import Masthead from "layout/masthead";
|
||||
|
||||
export default {
|
||||
mounted() {
|
||||
this.backboneRender();
|
||||
},
|
||||
|
||||
updated() {
|
||||
this.backboneRender();
|
||||
},
|
||||
|
||||
methods: {
|
||||
backboneRender() {
|
||||
// dont bother unless we're in top window
|
||||
if (window.top !== window) {
|
||||
return;
|
||||
}
|
||||
|
||||
const galaxy = getGalaxyInstance();
|
||||
|
||||
// old backbone view
|
||||
if (!galaxy.masthead) {
|
||||
galaxy.masthead = new Masthead.View(this.$props);
|
||||
}
|
||||
|
||||
// bolt the rendered DOM onto the vue component's element
|
||||
const backboneDom = galaxy.masthead.render();
|
||||
this.$el.appendChild(backboneDom.$el[0]);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
#masthead:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.framed {
|
||||
#messagebox,
|
||||
#masthead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#center,
|
||||
#right,
|
||||
#left {
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -96,8 +96,8 @@ export default {
|
||||
// merge weird vnode attributes to get enough to generate a dropdown
|
||||
getMenuDataFromNode(vnode) {
|
||||
const componentOptions = vnode.componentOptions || {};
|
||||
const attrs = Object.assign({}, vnode.data.attrs || {}, componentOptions.propsData || {});
|
||||
const on = Object.assign({}, vnode.data.on || {}, componentOptions.listeners || {});
|
||||
const attrs = Object.assign({}, (vnode.data && vnode.data.attrs) || {}, componentOptions.propsData || {});
|
||||
const on = Object.assign({}, (vnode.data && vnode.data.on) || {}, componentOptions.listeners || {});
|
||||
return { attrs, on, key: vnode.key };
|
||||
},
|
||||
},
|
||||
@@ -105,13 +105,15 @@ export default {
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
/* standard list reset, which we should already
|
||||
.priority-menu {
|
||||
/* standard list reset, which we should already
|
||||
have but mysteriously dont */
|
||||
ul,
|
||||
li {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
ul,
|
||||
li {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.priority-menu {
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
class="ui-button-default"
|
||||
id="btn-build"
|
||||
@click="_eventSelect"
|
||||
v-if="selectable"
|
||||
:disabled="!enableBuild"
|
||||
:variant="enableBuild ? 'primary' : ''"
|
||||
>
|
||||
|
||||
@@ -27,6 +27,10 @@ export default {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
selectable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
btnFilesTitle() {
|
||||
@@ -37,7 +41,8 @@ export default {
|
||||
}
|
||||
},
|
||||
remoteFiles() {
|
||||
return this.fileSourcesConfigured || this.ftpUploadSite;
|
||||
// this needs to be true for the tests to pass
|
||||
return !!this.fileSourcesConfigured || !!this.ftpUploadSite;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { shallowMount, createLocalVue } from "@vue/test-utils";
|
||||
import BootstrapVue from "bootstrap-vue";
|
||||
|
||||
jest.mock("app");
|
||||
jest.mock("../History/caching");
|
||||
|
||||
const propsData = {
|
||||
chunkUploadSize: 1024,
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
</template>
|
||||
<b-tabs v-if="ready">
|
||||
<b-tab title="Regular" id="regular" button-id="tab-title-link-regular" v-if="showRegular">
|
||||
<default :app="this" :lazy-load-max="50" :multiple="multiple" />
|
||||
<default :app="this" :lazy-load-max="50" :multiple="multiple" :selectable="callback != null" />
|
||||
</b-tab>
|
||||
<b-tab title="Composite" id="composite" button-id="tab-title-link-composite" v-if="showComposite">
|
||||
<composite :app="this" />
|
||||
@@ -37,7 +37,6 @@
|
||||
<script>
|
||||
import _l from "utils/localization";
|
||||
import Backbone from "backbone";
|
||||
import { getGalaxyInstance } from "app";
|
||||
import UploadUtils from "mvc/upload/upload-utils";
|
||||
import { getDatatypesMapper } from "components/Datatypes";
|
||||
import Composite from "./Composite";
|
||||
@@ -173,11 +172,11 @@ const UploadModal = {
|
||||
currentUserId: (state) => state.currentUser.id,
|
||||
}),
|
||||
|
||||
currentHistoryId() {
|
||||
const Galaxy = getGalaxyInstance();
|
||||
const legacyId = Galaxy.currHistoryPanel?.model.get("id");
|
||||
return legacyId;
|
||||
},
|
||||
// go straight to "state" instead of getter because the getter would filter out a current id
|
||||
// that wasn't in the list
|
||||
...mapState("betaHistory", {
|
||||
currentHistoryId: (state) => state.currentHistoryId,
|
||||
}),
|
||||
|
||||
historyAvailable() {
|
||||
return Boolean(this.currentHistoryId);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import Invocations from "../Workflow/Invocations";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import flushPromises from "flush-promises";
|
||||
|
||||
import RecentInvocations from "./RecentInvocations.vue";
|
||||
|
||||
jest.mock("../History/caching");
|
||||
jest.mock("./UserServices");
|
||||
|
||||
describe("RecentInvocations.vue", () => {
|
||||
let wrapper;
|
||||
let fetchInvocationsSpy;
|
||||
|
||||
beforeEach(async () => {
|
||||
fetchInvocationsSpy = jest.spyOn(RecentInvocations.methods, "fetchRecentInvocations");
|
||||
wrapper = mount(RecentInvocations);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it("loading should be false", async () => {
|
||||
expect(wrapper.vm.loading).toBeFalsy();
|
||||
expect(fetchInvocationsSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("invocationItems should be empty", async () => {
|
||||
expect(wrapper.vm.invocationItems).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should show no invocation message", async () => {
|
||||
// message should exist
|
||||
expect(wrapper.text()).toContain("There are no invocations to be shown.");
|
||||
});
|
||||
|
||||
it("should call fetchInvocationItems on event", async () => {
|
||||
wrapper.findComponent(Invocations).vm.$emit("reload-invocations");
|
||||
expect(fetchInvocationsSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
<invocations
|
||||
:invocation-items="invocationItems"
|
||||
:loading="loading"
|
||||
@reload-invocations="fetchRecentInvocations"
|
||||
no-invocations-message="There are no invocations to be shown."
|
||||
>
|
||||
</invocations>
|
||||
@@ -22,14 +23,17 @@ export default {
|
||||
};
|
||||
},
|
||||
created() {
|
||||
getRecentInvocations()
|
||||
.then((response) => {
|
||||
this.invocationItems = response.data;
|
||||
this.loading = false;
|
||||
})
|
||||
.catch(this.handleError);
|
||||
this.fetchRecentInvocations();
|
||||
},
|
||||
methods: {
|
||||
fetchRecentInvocations() {
|
||||
getRecentInvocations()
|
||||
.then((response) => {
|
||||
this.invocationItems = response.data;
|
||||
this.loading = false;
|
||||
})
|
||||
.catch(this.handleError);
|
||||
},
|
||||
handleError(error) {
|
||||
console.error(error);
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getGalaxyInstance } from "app";
|
||||
|
||||
export function getRecentInvocations() {
|
||||
const Galaxy = getGalaxyInstance();
|
||||
const params = { user_id: Galaxy.user.id, limit: 150 };
|
||||
const params = { user_id: Galaxy.user.id, limit: 150, view: "collection" };
|
||||
const url = `${getAppRoot()}api/invocations`;
|
||||
return axios.get(url, { params: params });
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user