mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-09-24 16:30:27 +08:00
Merge branch 'dev' of github.com:anuprulez/galaxy into ml-jupyter-notebook
This commit is contained in:
@@ -1 +0,0 @@
|
||||
api
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
./run_tests.sh --dockerize --db postgres --clean_pyc --skip_flakey_fails -api "$@"
|
||||
@@ -1 +0,0 @@
|
||||
framework
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
./run_tests.sh --dockerize --db postgres --clean_pyc --framework "$@"
|
||||
@@ -1 +0,0 @@
|
||||
integration
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
DOCKER_RUN_EXTRA_ARGS="--privileged" ./run_tests.sh --dockerize --db postgres --clean_pyc --integration "$@"
|
||||
@@ -1 +0,0 @@
|
||||
main-tools
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
./run_tests.sh --dockerize --db postgres --clean_pyc -main "$@"
|
||||
@@ -1,24 +0,0 @@
|
||||
FROM toolshed/requirements
|
||||
LABEL maintainer="John Chilton <jmchilton@gmail.com>"
|
||||
|
||||
RUN apt-get -qq update && \
|
||||
apt-get install --no-install-recommends -y postgresql-client python-pip libffi-dev python-cffi && \
|
||||
apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
ENV GALAXY_ROOT=/galaxy \
|
||||
GALAXY_CONFIG_OVERRIDE_ADMIN_USERS=admin@galaxy.org,test@bx.psu.edu \
|
||||
GALAXY_CONFIG_OVERRIDE_ALLOW_USER_DATASET_PURGE=true \
|
||||
GALAXY_CONFIG_OVERRIDE_ALLOW_LIBRARY_PATH_PASTE=true \
|
||||
GALAXY_CONFIG_OVERRIDE_ENABLE_BETA_WORKFLOW_MODULES=true \
|
||||
GALAXY_CONFIG_OVERRIDE_ALLOW_USER_DELETION=true \
|
||||
GALAXY_CONFIG_OVERRIDE_DATABASE_CONNECTION=postgresql://postgres@postgres/galaxy \
|
||||
GALAXY_CONFIG_OVERRIDE_MASTER_API_KEY=94a548bea347a35e457a804bf75bec53 \
|
||||
GALAXY_CONFIG_OVERRIDE_JOB_CONFIG_FILE=/galaxy/config/job_conf.xml.sample_basic \
|
||||
GALAXY_CONFIG_OVERRIDE_DATATYPES_CONFIG_FILE=/galaxy/test/functional/tools/sample_datatypes_conf.xml \
|
||||
GALAXY_CONFIG_OVERRIDE_TOOL_CONFIG_FILE=/galaxy/test/functional/tools/samples_tool_conf.xml \
|
||||
GALAXY_CONFIG_FILE=/galaxy/.ci/jenkins/selenium/galaxy.ini \
|
||||
GALAXY_VIRTUAL_ENV=/galaxy_env
|
||||
|
||||
ADD run_galaxy.bash /run_galaxy.bash
|
||||
EXPOSE 8080
|
||||
CMD /run_galaxy.bash
|
||||
@@ -1,21 +0,0 @@
|
||||
version: '2'
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
galaxy:
|
||||
build: .
|
||||
image: ubuntu
|
||||
volumes:
|
||||
- "${TARGET_ROOT}:${TARGET_PATH}"
|
||||
links:
|
||||
- postgres
|
||||
ports:
|
||||
- "${GALAXY_PORT}:8080"
|
||||
environment:
|
||||
GALAXY_TEST_UID: "${MY_UID}"
|
||||
selenium:
|
||||
image: selenium/standalone-chrome:3.5.2
|
||||
ports:
|
||||
- "${SELENIUM_PORT}:4444"
|
||||
links:
|
||||
- galaxy
|
||||
@@ -1,17 +0,0 @@
|
||||
[server:main]
|
||||
|
||||
use = egg:Paste#http
|
||||
port = 8080
|
||||
host = 0.0.0.0
|
||||
use_threadpool = True
|
||||
threadpool_kill_thread_limit = 10800
|
||||
|
||||
[filter:proxy-prefix]
|
||||
use = egg:PasteDeploy#prefix
|
||||
prefix = /galaxypf
|
||||
|
||||
[app:main]
|
||||
|
||||
paste.app_factory = galaxy.web.buildapp:app_factory
|
||||
|
||||
filter-with = proxy-prefix
|
||||
@@ -1,39 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
# Same hacks to setup a Galaxy user as used in test/docker/base/run_test_wrapper.sh
|
||||
# We want to make sure Galaxy runs as the same user as the external user so the files have the correct permission.
|
||||
echo "Deleting galaxy user - it may not exist and this is fine."
|
||||
deluser galaxy | true
|
||||
|
||||
: ${GALAXY_TEST_UID:-"1"}
|
||||
|
||||
echo "Creating galaxy group with gid $GALAXY_TEST_UID - it may already exist and this is fine."
|
||||
groupadd -r galaxy -g "$GALAXY_TEST_UID" | true
|
||||
echo "Creating galaxy user with uid $GALAXY_TEST_UID - it may already exist and this is fine."
|
||||
useradd -u $GALAXY_TEST_UID -r -g galaxy -d /home/galaxy -c "Galaxy User" galaxy -s /bin/bash | true
|
||||
echo "Setting galaxy user password - the operation may fail."
|
||||
echo "galaxy:galaxy" | chpasswd | true
|
||||
|
||||
virtualenv "$GALAXY_VIRTUAL_ENV"
|
||||
chown -R "$GALAXY_TEST_UID:$GALAXY_TEST_UID" "$GALAXY_VIRTUAL_ENV"
|
||||
|
||||
cd /galaxy
|
||||
sudo -E -H -u "#${GALAXY_TEST_UID}" ./scripts/common_startup.sh || { echo "common_startup.sh failed"; exit 1; }
|
||||
|
||||
echo "Waiting for postgres to become available"
|
||||
while ! nc -z postgres 5432;
|
||||
do
|
||||
sleep 1
|
||||
printf "."
|
||||
done
|
||||
|
||||
echo "Creating postgres database for Galaxy"
|
||||
createdb -w -U postgres -h postgres galaxy
|
||||
|
||||
echo "Starting and waiting for Galaxy daemon(s)"
|
||||
sudo -E -H -u "#${GALAXY_TEST_UID}" GALAXY_RUN_ALL=1 bash "$GALAXY_ROOT/run.sh" --daemon --wait
|
||||
|
||||
echo "Galaxy daemon ready, monitoring Galaxy logs"
|
||||
tail -f "$GALAXY_ROOT/main.log"
|
||||
@@ -1,124 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
TEST_DIRECTORY=`dirname $0`
|
||||
|
||||
DEFAULT_COMPOSE_PROJECT_NAME=`basename $TEST_DIRECTORY`
|
||||
COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME:-$DEFAULT_COMPOSE_PROJECT_NAME}
|
||||
# If in Jenkins environment, append ${BUILD_NUMBER} to project so builds don't interfer.
|
||||
if [ ! -z "$BUILD_NUMBER" ];
|
||||
then
|
||||
COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME}${BUILD_NUMBER}"
|
||||
fi
|
||||
export COMPOSE_PROJECT_NAME
|
||||
|
||||
echo "Running Jenkins test from $TEST_DIRECTORY with compose project name $COMPOSE_PROJECT_NAME"
|
||||
|
||||
find lib -iname '*pyc' -exec rm -rf {} \;
|
||||
find test -iname '*pyc' -exec rm -rf {} \;
|
||||
|
||||
./scripts/common_startup.sh --dev-wheels
|
||||
|
||||
. .venv/bin/activate
|
||||
|
||||
pip install docker-compose
|
||||
|
||||
# TODO: Let docker-compose pick these at random.
|
||||
export GALAXY_PORT=`python -c 'from __future__ import print_function; import socket; s=socket.socket(); s.bind(("", 0)); print(s.getsockname()[1]); s.close()'`
|
||||
export SELENIUM_PORT=`python -c 'from __future__ import print_function; import socket; s=socket.socket(); s.bind(("", 0)); print(s.getsockname()[1]); s.close()'`
|
||||
|
||||
export TARGET_ROOT=`pwd`
|
||||
export TARGET_PATH=/galaxy
|
||||
export MY_UID=$(id -u)
|
||||
|
||||
cd $TEST_DIRECTORY
|
||||
|
||||
echo "Cleaning up previous executions if needed."
|
||||
docker-compose down | true
|
||||
docker-compose build galaxy
|
||||
docker-compose up -d
|
||||
|
||||
function tear_down {
|
||||
docker-compose down
|
||||
}
|
||||
|
||||
trap tear_down EXIT
|
||||
|
||||
for service_name in postgres galaxy selenium
|
||||
do
|
||||
echo "Waiting on service ${service_name}"
|
||||
while true
|
||||
do
|
||||
if docker ps | grep -q "${COMPOSE_PROJECT_NAME}_${service_name}"
|
||||
then
|
||||
container_id=`docker ps | grep "${COMPOSE_PROJECT_NAME}_${service_name}" | cut -d " " -f 1`
|
||||
eval "${service_name}_container_id=${container_id}"
|
||||
echo "Service ${service_name} ready - with container ID ${container_id}"
|
||||
break
|
||||
fi
|
||||
printf "."
|
||||
sleep 1;
|
||||
done
|
||||
done
|
||||
|
||||
|
||||
if [ "$1" = "--debug-running-containers" ];
|
||||
then
|
||||
env
|
||||
exit 0
|
||||
fi
|
||||
|
||||
export GALAXY_TEST_EXTERNAL="http://localhost:$GALAXY_PORT/"
|
||||
|
||||
echo "Waiting on docker-compose managed Galaxy server - $GALAXY_TEST_EXTERNAL."
|
||||
while ! curl -s "$GALAXY_TEST_EXTERNAL";
|
||||
do
|
||||
for service_name in postgres galaxy selenium
|
||||
do
|
||||
if ! docker ps | grep -q "${COMPOSE_PROJECT_NAME}_${service_name}"
|
||||
then
|
||||
echo "Service ${service_name} stopped before Galaxy came up, exiting and halting containers."
|
||||
for service_name in postgres galaxy selenium
|
||||
do
|
||||
container_id_var="${service_name}_container_id"
|
||||
container_id="${!container_id_var}"
|
||||
echo "Dumping logs for $service_name container (${container_id})..."
|
||||
echo "---"
|
||||
docker logs "${container_id}"
|
||||
echo "---"
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
printf "."
|
||||
sleep 4;
|
||||
done;
|
||||
|
||||
# Access Selenium on localhost via port $SELENIUM_PORT
|
||||
export GALAXY_TEST_SELENIUM_REMOTE=1
|
||||
export GALAXY_TEST_SELENIUM_REMOTE_PORT="${SELENIUM_PORT}"
|
||||
|
||||
# Retry all failed Selenium tests a second time to deal
|
||||
# with transiently failing tests. Failure information for
|
||||
# first tests is still populated in database/test_errors
|
||||
# and available at the top of the Jenkins test report.
|
||||
export GALAXY_TEST_SELENIUM_RETRIES=1
|
||||
|
||||
# Access Galaxy on localhost via port $GALAXY_PORT
|
||||
export GALAXY_TEST_PORT="${GALAXY_PORT}"
|
||||
|
||||
# Have Selenium access Galaxy at this URL
|
||||
export GALAXY_TEST_EXTERNAL_FROM_SELENIUM="http://galaxy:8080/galaxypf"
|
||||
export GALAXY_TEST_EXTERNAL="http://localhost:${GALAXY_TEST_PORT}/galaxypf"
|
||||
|
||||
# Point tests at the Master API Key configured in the Dockerfile.
|
||||
export GALAXY_CONFIG_MASTER_API_KEY=94a548bea347a35e457a804bf75bec53
|
||||
|
||||
cd ../../..
|
||||
|
||||
./run_tests.sh --selenium "$@"
|
||||
exit_code=$?
|
||||
|
||||
cd $TEST_DIRECTORY
|
||||
|
||||
exit $exit_code
|
||||
@@ -1 +0,0 @@
|
||||
selenium
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Enable retries on tests to reduce chances of transient failures.
|
||||
: ${GALAXY_TEST_SELENIUM_RETRIES:=1}
|
||||
|
||||
# If in Jenkins environment, use it for artifacts.
|
||||
if [ -n "$BUILD_NUMBER" ];
|
||||
then
|
||||
: ${GALAXY_TEST_ERRORS_DIRECTORY:=${BUILD_NUMBER}-test-errors}
|
||||
: ${GALAXY_TEST_SCREENSHOTS_DIRECTORY:=${BUILD_NUMBER}-test-screenshots}
|
||||
else
|
||||
: ${GALAXY_TEST_ERRORS_DIRECTORY:=database/test-errors}
|
||||
: ${GALAXY_TEST_SCREENSHOTS_DIRECTORY:=database/test-screenshots}
|
||||
fi
|
||||
|
||||
mkdir -p "$GALAXY_TEST_ERRORS_DIRECTORY"
|
||||
mkdir -p "$GALAXY_TEST_SCREENSHOTS_DIRECTORY"
|
||||
|
||||
mkdir -p ~/.jenkins-yarn-cache
|
||||
YARN_CACHE_FOLDER=~/.jenkins-yarn-cache
|
||||
|
||||
# Start Selenium server in the test Docker container.
|
||||
DOCKER_RUN_EXTRA_ARGS="${DOCKER_RUN_EXTRA_ARGS} --shm-size=2g -v $YARN_CACHE_FOLDER:$YARN_CACHE_FOLDER -e YARN_CACHE_FOLDER=$YARN_CACHE_FOLDER -e USE_SELENIUM=1 -e GALAXY_TEST_SELENIUM_RETRIES=${GALAXY_TEST_SELENIUM_RETRIES} -e GALAXY_TEST_ERRORS_DIRECTORY=${GALAXY_TEST_ERRORS_DIRECTORY} -e GALAXY_TEST_SCREENSHOTS_DIRECTORY=${GALAXY_TEST_SCREENSHOTS_DIRECTORY}"
|
||||
export DOCKER_RUN_EXTRA_ARGS
|
||||
|
||||
./run_tests.sh --dockerize --db postgres --clean_pyc --skip_flakey_fails --selenium "$@"
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
./run_tests.sh --dockerize --db postgres --clean_pyc --toolshed "$@"
|
||||
@@ -0,0 +1,32 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: testing
|
||||
name: testing
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: test
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: test
|
||||
spec:
|
||||
containers:
|
||||
- image: postgres:12
|
||||
name: postgres
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
env:
|
||||
- name: POSTGRES_DB
|
||||
value: postgres
|
||||
- name: POSTGRES_USER
|
||||
value: postgres
|
||||
- name: POSTGRES_PASSWORD
|
||||
value: postgres
|
||||
- image: rabbitmq
|
||||
name: rabbitmq
|
||||
ports:
|
||||
- containerPort: 5672
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
set -ex
|
||||
|
||||
SCRIPTDIR=$(dirname "${BASH_SOURCE[0]}")
|
||||
kubectl apply -f "$SCRIPTDIR/deployment.yaml"
|
||||
kubectl expose deployment testing --type=LoadBalancer --name=testing-service
|
||||
|
||||
CLUSTER_IP=$(kubectl get service testing-service -o jsonpath='{.spec.clusterIP}')
|
||||
GALAXY_TEST_DBURI="postgresql://postgres:postgres@${CLUSTER_IP}:5432/galaxy?client_encoding=utf-8"
|
||||
GALAXY_TEST_AMQP_URL="amqp://${CLUSTER_IP}:5672)//"
|
||||
export GALAXY_TEST_DBURI
|
||||
export GALAXY_TEST_AMQP_URL
|
||||
@@ -83,14 +83,6 @@ jobs:
|
||||
key: v1-repo-{{ .Environment.CIRCLE_SHA1 }}
|
||||
paths:
|
||||
- ~/repo
|
||||
py36_docstring:
|
||||
docker:
|
||||
- image: circleci/python:3.6
|
||||
<<: *set_workdir
|
||||
steps:
|
||||
- *restore_repo_cache
|
||||
- *install_tox
|
||||
- run: tox -e py36-lint_docstring_include_list
|
||||
py36_unit:
|
||||
docker:
|
||||
- image: circleci/python:3.6
|
||||
@@ -100,17 +92,6 @@ jobs:
|
||||
- *install_tox
|
||||
- *install_ffprobe
|
||||
- run: tox -e py36-unit
|
||||
py36_first_startup:
|
||||
docker:
|
||||
- image: circleci/python:3.6
|
||||
<<: *set_workdir
|
||||
steps:
|
||||
- *restore_repo_cache
|
||||
# Use this job to test the latest migrations
|
||||
- run: wget -q https://github.com/jmchilton/galaxy-downloads/raw/master/db_gx_rev_0141.sqlite
|
||||
- run: mv db_gx_rev_0141.sqlite database/universe.sqlite
|
||||
- *install_tox
|
||||
- run: tox -e py36-first_startup
|
||||
validate_test_tools:
|
||||
docker:
|
||||
- image: circleci/python:3.6
|
||||
@@ -159,12 +140,8 @@ workflows:
|
||||
get_code_and_test:
|
||||
jobs:
|
||||
- get_code
|
||||
- py36_docstring:
|
||||
<<: *requires_get_code
|
||||
- py36_unit:
|
||||
<<: *requires_get_code
|
||||
- py36_first_startup:
|
||||
<<: *requires_get_code
|
||||
- test_galaxy_packages:
|
||||
<<: *requires_get_code
|
||||
- validate_test_tools:
|
||||
|
||||
@@ -7,7 +7,7 @@ jobs:
|
||||
runs-on: ubuntu-18.04
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.7]
|
||||
python-version: ['3.7']
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.7]
|
||||
python-version: ['3.7']
|
||||
db: ['postgresql', 'sqlite']
|
||||
services:
|
||||
postgres:
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
name: first startup
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
|
||||
test:
|
||||
name: Startup test
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.6']
|
||||
defaults:
|
||||
run:
|
||||
shell: bash -l {0}
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
path: 'galaxy root'
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
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 tox
|
||||
run: pip install tox
|
||||
# Use this job to test the latest migrations
|
||||
- run: wget -q https://github.com/jmchilton/galaxy-downloads/raw/master/db_gx_rev_0141.sqlite
|
||||
- run: mv db_gx_rev_0141.sqlite 'galaxy root'/database/universe.sqlite
|
||||
- name: run tests
|
||||
run: tox -e first_startup
|
||||
working-directory: 'galaxy root'
|
||||
@@ -6,14 +6,15 @@ env:
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-18.04
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: [3.7]
|
||||
python-version: ['3.7']
|
||||
subset: ['upload_datatype', 'extended_metadata', 'kubernetes', 'not (upload_datatype or extended_metadata or kubernetes)']
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:11
|
||||
image: postgres:13
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
@@ -59,8 +60,15 @@ jobs:
|
||||
run: sudo apt-get update && sudo apt-get install ffmpeg -y
|
||||
if: matrix.subset == 'upload_datatype'
|
||||
- name: Run tests
|
||||
if: matrix.subset != 'kubernetes'
|
||||
run: './run_tests.sh -integration test/integration -- -k "${{ matrix.subset }}"'
|
||||
working-directory: 'galaxy root'
|
||||
- name: Run tests
|
||||
if: matrix.subset == 'kubernetes'
|
||||
run: |
|
||||
. .ci/minikube-test-setup/start_services.sh
|
||||
./run_tests.sh -integration test/integration -- -k "${{ matrix.subset }}"
|
||||
working-directory: 'galaxy root'
|
||||
- uses: actions/upload-artifact@v2
|
||||
if: failure()
|
||||
with:
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-18.04
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.7]
|
||||
python-version: ['3.7']
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:11
|
||||
|
||||
@@ -6,7 +6,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.6, 3.9]
|
||||
python-version: ['3.6', '3.9']
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-python@v2
|
||||
@@ -16,10 +16,12 @@ jobs:
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: pip-cache-${{ matrix.python-version }}-${{ hashFiles('requirements.txt') }}
|
||||
key: pip-cache-${{ matrix.python-version }}-${{ hashFiles('lib/galaxy/dependencies/pinned-lint-requirements.txt') }}
|
||||
- name: Install tox
|
||||
run: pip install tox
|
||||
- name: run tests
|
||||
- name: Run linting
|
||||
run: tox -e lint
|
||||
- name: run mypy checks
|
||||
- name: Run docstring linting
|
||||
run: tox -e py36-lint_docstring_include_list
|
||||
- name: Run mypy checks
|
||||
run: tox -e mypy
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: "Maintenance Bot"
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
types: [opened, reopened, edited, ready_for_review, unlabeled]
|
||||
|
||||
jobs:
|
||||
labeler:
|
||||
@@ -10,8 +10,21 @@ jobs:
|
||||
env:
|
||||
MILESTONE_NUMBER: 19
|
||||
steps:
|
||||
- name: Get latest pull request labels
|
||||
id: get_pr_labels
|
||||
uses: actions/github-script@v3
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const response = await github.issues.listLabelsOnIssue({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
console.log(response);
|
||||
return response.data;
|
||||
- name: Add area labels
|
||||
if: ${{ ! contains(join(github.event.pull_request.labels.*.name, ', '), 'area/') }}
|
||||
if: ${{ ! contains(join(fromJSON(steps.get_pr_labels.outputs.result).*.name, ', '), 'area/') }}
|
||||
uses: actions/labeler@main
|
||||
with:
|
||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
||||
@@ -7,7 +7,7 @@ jobs:
|
||||
runs-on: ubuntu-18.04
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.7]
|
||||
python-version: ['3.7']
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Performance 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 --ci_test_metrics --structured_data_html --structured_data_report_file "test.json" --skip_flakey_fails -api lib/galaxy_test/performance
|
||||
working-directory: 'galaxy root'
|
||||
- uses: actions/upload-artifact@v2
|
||||
if: failure()
|
||||
with:
|
||||
name: API test results
|
||||
path: 'galaxy root/run_api_tests.html'
|
||||
- uses: actions/upload-artifact@v2.0.1
|
||||
with:
|
||||
name: 'Performance Metrics'
|
||||
path: 'galaxy root/test.html'
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
runs-on: ubuntu-18.04
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.7]
|
||||
python-version: ['3.7']
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:11
|
||||
|
||||
+3
-2
@@ -74,15 +74,16 @@
|
||||
"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",
|
||||
"vue-rx": "^6.2.0",
|
||||
"vuedraggable": "2.24.1",
|
||||
"vueisotope": "^3.1.2",
|
||||
"vuex": "^3.4.0",
|
||||
"vuex-cache": "^3.2.0",
|
||||
"vuex-persist": "^3.1.3"
|
||||
"vuex-persist": "^3.1.3",
|
||||
"vue-observe-visibility": "^1.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"watch": "gulp && yarn run save-build-hash && yarn run webpack-watch",
|
||||
|
||||
@@ -18,6 +18,7 @@ export default {
|
||||
props: {
|
||||
slug: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
|
||||
@@ -49,7 +49,7 @@ import { UrlTracker } from "./utilities";
|
||||
import { Model } from "./model";
|
||||
import { Services } from "./services";
|
||||
import { getAppRoot } from "onload/loadConfig";
|
||||
import { mountUploadModal } from "components/Upload";
|
||||
import { openUploadModal } from "components/Upload";
|
||||
|
||||
Vue.use(BootstrapVue);
|
||||
|
||||
@@ -135,7 +135,7 @@ export default {
|
||||
callback: this.callback,
|
||||
modalShow: true,
|
||||
};
|
||||
mountUploadModal(propsData);
|
||||
openUploadModal(propsData);
|
||||
this.modalShow = false;
|
||||
},
|
||||
/** Called when selection is complete, values are formatted and parsed to external callback **/
|
||||
|
||||
@@ -46,10 +46,7 @@ export default {
|
||||
pathDestination.filepath
|
||||
);
|
||||
} else if (this.path === undefined || this.path === "undefined") {
|
||||
this.directoryContent = this.removeParentDirectory(
|
||||
pathDestination.datasetContent,
|
||||
pathDestination.datasetRootDir
|
||||
);
|
||||
this.directoryContent = pathDestination.datasetContent;
|
||||
} else {
|
||||
this.errorMessage = `is not found!`;
|
||||
}
|
||||
|
||||
@@ -6,15 +6,11 @@ export const getPathDestination = async (history_dataset_id, path) => {
|
||||
const services = new Services({ root: getAppRoot() });
|
||||
|
||||
const computePathDestination = (pathDestination) => {
|
||||
if (pathDestination.datasetContent[0].class === "Directory")
|
||||
pathDestination.datasetRootDir = datasetContent[0].path;
|
||||
else return;
|
||||
|
||||
if (path === undefined || path === "undefined") {
|
||||
return pathDestination;
|
||||
}
|
||||
|
||||
const filepath = `${pathDestination.datasetRootDir}/${path}`;
|
||||
const filepath = path;
|
||||
|
||||
const datasetEntry = datasetContent.find((datasetEntry) => {
|
||||
return filepath === datasetEntry.path;
|
||||
|
||||
@@ -2,15 +2,9 @@
|
||||
* Renderless component, used to debounce various types of form inputs
|
||||
*/
|
||||
|
||||
import Vue from "vue";
|
||||
import VueRx from "vue-rx";
|
||||
import { filter, debounceTime, distinctUntilChanged, finalize } from "rxjs/operators";
|
||||
import { vueRxShortcuts } from "./plugins";
|
||||
|
||||
Vue.use(VueRx);
|
||||
|
||||
export default {
|
||||
mixins: [vueRxShortcuts],
|
||||
props: {
|
||||
value: { required: true },
|
||||
delay: { type: Number, required: false, default: 500 },
|
||||
|
||||
@@ -5,12 +5,6 @@ export default {
|
||||
|
||||
computed: {
|
||||
contentItemComponent() {
|
||||
if (this.item === null) {
|
||||
return "Loading";
|
||||
}
|
||||
if (this.scrolling) {
|
||||
return "Placeholder";
|
||||
}
|
||||
const { history_content_type } = this.item;
|
||||
switch (history_content_type) {
|
||||
case "dataset":
|
||||
|
||||
@@ -11,22 +11,17 @@
|
||||
import Placeholder from "./Placeholder";
|
||||
import Dataset from "./Dataset";
|
||||
import DatasetCollection from "./DatasetCollection";
|
||||
// import SubDataset from "./SubDataset";
|
||||
import Subcollection from "./Subcollection";
|
||||
|
||||
export default {
|
||||
inject: ["listState", "isSelected", "isExpanded", "setSelected", "setExpanded"],
|
||||
|
||||
template: `
|
||||
<component :is="contentItemComponent"
|
||||
class="content-item p-1"
|
||||
:class="{ loading }"
|
||||
:index="index"
|
||||
:tabindex="index"
|
||||
:item="item"
|
||||
:selected.sync="selected"
|
||||
:expanded.sync="expanded"
|
||||
:scrolling="scrolling"
|
||||
v-on='$listeners'
|
||||
v-on="$listeners"
|
||||
v-bind="bindProps"
|
||||
@mouseover.native.self.stop="setFocus(index)"
|
||||
@keydown.native.arrow-up.self.stop="setFocus(index - 1)"
|
||||
@keydown.native.arrow-down.self.stop="setFocus(index + 1)"
|
||||
@@ -37,6 +32,7 @@ export default {
|
||||
Placeholder,
|
||||
Dataset,
|
||||
DatasetCollection,
|
||||
// SubDataset,
|
||||
Subcollection,
|
||||
},
|
||||
|
||||
@@ -66,24 +62,8 @@ export default {
|
||||
// override me
|
||||
return "Placeholder";
|
||||
},
|
||||
selected: {
|
||||
get() {
|
||||
return this.isSelected(this.item);
|
||||
},
|
||||
set(val) {
|
||||
this.setSelected(this.item, val);
|
||||
},
|
||||
},
|
||||
expanded: {
|
||||
get() {
|
||||
return this.isExpanded(this.item);
|
||||
},
|
||||
set(val) {
|
||||
this.setExpanded(this.item, val);
|
||||
},
|
||||
},
|
||||
scrolling() {
|
||||
return this.listState.scrolling;
|
||||
bindProps() {
|
||||
return { ...this.$props, ...this.$attrs };
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<DatasetUI
|
||||
v-if="dataset"
|
||||
v-bind="$attrs"
|
||||
v-on="$listeners"
|
||||
:dataset="dataset"
|
||||
@update:dataset="onUpdate"
|
||||
@delete="onDelete"
|
||||
@undelete="onUndelete"
|
||||
@unhide="onUnhide"
|
||||
@copy-link="onCopyLink"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DatasetUI from "./DatasetUI";
|
||||
import { Dataset } from "../../model";
|
||||
import { deleteContent, updateContentFields } from "../../model/queries";
|
||||
import { cacheContent } from "../../caching";
|
||||
import { copy as sendToClipboard } from "utils/clipboard";
|
||||
import { absPath } from "utils/redirect";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
DatasetUI,
|
||||
},
|
||||
props: {
|
||||
item: { type: Object, required: true },
|
||||
},
|
||||
computed: {
|
||||
dataset() {
|
||||
return new Dataset(this.item);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async onDelete(opts = {}) {
|
||||
const ajaxResult = await deleteContent(this.item, opts);
|
||||
await cacheContent(ajaxResult);
|
||||
},
|
||||
async onUnhide() {
|
||||
await this.onUpdate({ visible: true });
|
||||
},
|
||||
async onUndelete() {
|
||||
await this.onUpdate({ deleted: false });
|
||||
},
|
||||
async onUpdate(changes) {
|
||||
const newContent = await updateContentFields(this.item, changes);
|
||||
await cacheContent(newContent);
|
||||
},
|
||||
onCopyLink() {
|
||||
const relPath = this.dataset.getUrl("download");
|
||||
const msg = this.localize("Link is copied to your clipboard");
|
||||
sendToClipboard(absPath(relPath), msg);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -72,6 +72,14 @@
|
||||
icon="fas fa-file-download"
|
||||
/>
|
||||
|
||||
<PriorityMenuItem
|
||||
v-if="!dataset.purged && dataset.getUrl('download')"
|
||||
key="copy-link"
|
||||
title="Copy Link"
|
||||
icon="fa fa-chain"
|
||||
@click.stop="$emit('copy-link')"
|
||||
/>
|
||||
|
||||
<div v-if="showDownloads && dataset.hasMetaData">
|
||||
<PriorityMenuItem
|
||||
v-for="mf in dataset.meta_files"
|
||||
@@ -130,12 +138,11 @@
|
||||
|
||||
<script>
|
||||
import { mapGetters } from "vuex";
|
||||
import { Dataset } from "../../model";
|
||||
import { Dataset, STATES } from "../../model";
|
||||
import { PriorityMenu, PriorityMenuItem } from "components/PriorityMenu";
|
||||
import { legacyNavigationMixin } from "components/plugins";
|
||||
import { legacyNavigationMixin } from "components/plugins/legacyNavigation";
|
||||
|
||||
export default {
|
||||
inject: ["STATES"],
|
||||
mixins: [legacyNavigationMixin],
|
||||
|
||||
components: {
|
||||
@@ -156,6 +163,11 @@ export default {
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
// make available to template
|
||||
this.STATES = STATES;
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters("user", ["currentUser"]),
|
||||
...mapGetters("config", ["config"]),
|
||||
@@ -168,10 +180,10 @@ export default {
|
||||
if (this.dataset.purged) {
|
||||
return "Cannot display datasets removed from disk";
|
||||
}
|
||||
if (this.dataset.state == this.STATES.UPLOAD) {
|
||||
if (this.dataset.state == STATES.UPLOAD) {
|
||||
return "This dataset must finish uploading before it can be viewed";
|
||||
}
|
||||
if (this.dataset.state == this.STATES.NEW) {
|
||||
if (this.dataset.state == STATES.NEW) {
|
||||
return "This dataset is not yet viewable";
|
||||
}
|
||||
return "View data";
|
||||
@@ -184,7 +196,7 @@ export default {
|
||||
if (this.dataset.purged) {
|
||||
return "Cannot edit attributes of datasets removed from disk";
|
||||
}
|
||||
const unreadyStates = new Set([this.STATES.UPLOAD, this.STATES.NEW]);
|
||||
const unreadyStates = new Set([STATES.UPLOAD, STATES.NEW]);
|
||||
if (unreadyStates.has(this.dataset.state)) {
|
||||
return "This dataset is not yet editable";
|
||||
}
|
||||
@@ -206,7 +218,7 @@ export default {
|
||||
if (!this.dataset.hasData) {
|
||||
return false;
|
||||
}
|
||||
const okStates = new Set([this.STATES.OK, this.STATES.FAILED_METADATA, this.STATES.ERROR]);
|
||||
const okStates = new Set([STATES.OK, STATES.FAILED_METADATA, STATES.ERROR]);
|
||||
return okStates.has(this.dataset.state);
|
||||
},
|
||||
|
||||
@@ -261,9 +273,8 @@ export default {
|
||||
},
|
||||
|
||||
onDeleteClick() {
|
||||
const eventName = this.dataset.deleted ? "undeleteDataset" : "deleteDataset";
|
||||
// console.log("emitting", eventName, this.dataset);
|
||||
this.$emit(eventName, this.dataset);
|
||||
const eventName = this.dataset.deleted ? "undelete" : "delete";
|
||||
this.$emit(eventName);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ either through the props, and make updates through the events -->
|
||||
@keydown.arrow-right.self.stop="$emit('update:expanded', true)"
|
||||
@keydown.space.self.stop.prevent="$emit('update:selected', !selected)"
|
||||
>
|
||||
<!-- name, state buttons, menus -->
|
||||
<nav
|
||||
class="content-top-menu d-flex align-items-center justify-content-between"
|
||||
@click.stop="$emit('update:expanded', !expanded)"
|
||||
@@ -29,16 +30,15 @@ either through the props, and make updates through the events -->
|
||||
state="hidden"
|
||||
title="Unhide"
|
||||
icon="fa fa-eye-slash"
|
||||
@click.stop="$emit('unhideDataset')"
|
||||
@click.stop="$emit('unhide')"
|
||||
/>
|
||||
|
||||
<StateBtn
|
||||
v-if="dataset.isDeleted && !dataset.purged"
|
||||
class="px-1"
|
||||
state="deleted"
|
||||
title="Undelete"
|
||||
icon="fas fa-trash-restore"
|
||||
@click.stop="$emit('undeleteDataset')"
|
||||
@click.stop="$emit('undelete')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -46,25 +46,30 @@ either through the props, and make updates through the events -->
|
||||
<span class="hid">{{ dataset.hid }}</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" />
|
||||
</div> -->
|
||||
|
||||
<DatasetMenu
|
||||
class="content-item-menu"
|
||||
:dataset="dataset"
|
||||
:expanded="expanded"
|
||||
v-on="$listeners"
|
||||
:show-tags.sync="showTags"
|
||||
/>
|
||||
<slot name="menu">
|
||||
<DatasetMenu
|
||||
class="content-item-menu"
|
||||
:dataset="dataset"
|
||||
:expanded="expanded"
|
||||
v-on="$listeners"
|
||||
:show-tags.sync="showTags"
|
||||
/>
|
||||
</slot>
|
||||
</nav>
|
||||
|
||||
<!--- read-only tags with name: prefix -->
|
||||
<div v-if="collapsed && dataset.nameTags.length" class="nametags p-1">
|
||||
<Nametag v-for="tag in dataset.nameTags" :key="tag" :tag="tag" />
|
||||
</div>
|
||||
|
||||
<!-- expanded view with editors -->
|
||||
<header v-if="expanded" class="p-2">
|
||||
<ClickToEdit
|
||||
v-if="dataset.canEditName"
|
||||
tag-name="h4"
|
||||
:value="dataset.name"
|
||||
@input="$emit('updateDataset', { name: $event })"
|
||||
@input="$emit('update:dataset', { name: $event })"
|
||||
:display-label="dataset.title"
|
||||
:tooltip-title="'Edit dataset name...' | localize"
|
||||
tooltip-placement="left"
|
||||
@@ -73,12 +78,13 @@ either through the props, and make updates through the events -->
|
||||
<Annotation
|
||||
class="mt-1"
|
||||
:value="dataset.annotation"
|
||||
@input="$emit('updateDataset', { annotation: $event })"
|
||||
@input="$emit('update:dataset', { annotation: $event })"
|
||||
/>
|
||||
|
||||
<transition name="shutterfade">
|
||||
<ContentTags v-if="showTags" class="mt-2" :content="dataset" />
|
||||
</transition>
|
||||
<ContentTags v-if="showTags" class="mt-2" :content="dataset" />
|
||||
<div v-else-if="dataset.nameTags.length" class="nametags mt-2">
|
||||
<Nametag v-for="tag in dataset.nameTags" :key="tag" :tag="tag" />
|
||||
</div>
|
||||
|
||||
<div class="details">
|
||||
<DatasetSummary :dataset="dataset" />
|
||||
@@ -102,20 +108,16 @@ either through the props, and make updates through the events -->
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/* eslint-disable no-undef */
|
||||
// STATES is injected, eslint doesn't know that
|
||||
|
||||
import { Dataset } from "../../model";
|
||||
import { Dataset, STATES } from "../../model";
|
||||
import ClickToEdit from "components/ClickToEdit";
|
||||
import Annotation from "components/Annotation";
|
||||
import { Nametag } from "components/Nametags";
|
||||
import { StatusIcon, StateBtn } from "../../StatusIcon";
|
||||
import DatasetMenu from "./DatasetMenu";
|
||||
import DatasetSummary from "./Summary";
|
||||
import ContentTags from "../../ContentTags";
|
||||
import { legacyNavigationMixin } from "components/plugins";
|
||||
|
||||
import { legacyNavigationMixin } from "components/plugins/legacyNavigation";
|
||||
export default {
|
||||
inject: ["listState", "STATES"],
|
||||
mixins: [legacyNavigationMixin],
|
||||
components: {
|
||||
ClickToEdit,
|
||||
@@ -125,12 +127,13 @@ export default {
|
||||
DatasetMenu,
|
||||
DatasetSummary,
|
||||
ContentTags,
|
||||
Nametag,
|
||||
},
|
||||
props: {
|
||||
dataset: { type: Dataset, required: true },
|
||||
expanded: { type: Boolean, required: true },
|
||||
selected: { type: Boolean, required: false, default: false },
|
||||
showHid: { type: Boolean, required: false, default: true },
|
||||
showSelection: { type: Boolean, required: false, default: false },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -147,12 +150,6 @@ export default {
|
||||
ok() {
|
||||
return this.dataset.state == "ok";
|
||||
},
|
||||
counter() {
|
||||
return this.showHid ? this.dataset.hid : "";
|
||||
},
|
||||
showSelection() {
|
||||
return this.listState.showSelection;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onStatusClick(evt) {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import Dataset from "./Dataset";
|
||||
|
||||
export { default as DatasetUI } from "./DatasetUI";
|
||||
export { default as DatasetMenu } from "./DatasetMenu";
|
||||
export default Dataset;
|
||||
@@ -1,49 +0,0 @@
|
||||
<template>
|
||||
<DatasetUI
|
||||
v-if="dataset"
|
||||
v-bind="$attrs"
|
||||
v-on="$listeners"
|
||||
:dataset="dataset"
|
||||
@deleteDataset="onDelete(dataset, $event)"
|
||||
@undeleteDataset="onUndelete(dataset, $event)"
|
||||
@unhideDataset="onUnhide(dataset, $event)"
|
||||
@updateDataset="onUpdate(dataset, $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DatasetUI from "./DatasetUI";
|
||||
import { Dataset } from "../../model";
|
||||
import { deleteContent, updateContentFields } from "../../model/queries";
|
||||
import { cacheContent } from "../../caching";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
DatasetUI,
|
||||
},
|
||||
props: {
|
||||
item: { type: Object, required: true },
|
||||
},
|
||||
computed: {
|
||||
dataset() {
|
||||
return new Dataset(this.item);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async onDelete(dataset, opts = {}) {
|
||||
const ajaxResult = await deleteContent(dataset, opts);
|
||||
await cacheContent(ajaxResult);
|
||||
},
|
||||
async onUnhide(dataset) {
|
||||
await this.onUpdate(dataset, { visible: true });
|
||||
},
|
||||
async onUndelete(dataset) {
|
||||
await this.onUpdate(dataset, { deleted: false });
|
||||
},
|
||||
async onUpdate(dataset, changes) {
|
||||
const newContent = await updateContentFields(dataset, changes);
|
||||
await cacheContent(newContent);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<DscUI
|
||||
v-if="dsc"
|
||||
v-bind="$attrs"
|
||||
v-on="$listeners"
|
||||
class="dataset-collection"
|
||||
:dsc="dsc"
|
||||
@update:dsc="onUpdate"
|
||||
@delete="onDelete"
|
||||
@undelete="onUndelete"
|
||||
@unhide="onUnhide"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DscUI from "./DscUI";
|
||||
import { DatasetCollection } from "../../model";
|
||||
import { deleteDatasetCollection, updateContentFields } from "../../model/queries";
|
||||
import { cacheContent } from "../../caching";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
DscUI,
|
||||
},
|
||||
props: {
|
||||
item: { type: Object, required: true },
|
||||
},
|
||||
computed: {
|
||||
dsc() {
|
||||
return new DatasetCollection(this.item);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async onDelete(flags = {}) {
|
||||
const { recursive = false, purge = false } = flags;
|
||||
const collection = this.item;
|
||||
const result = await deleteDatasetCollection(collection, recursive, purge);
|
||||
if (result.deleted) {
|
||||
const newFields = Object.assign(collection, {
|
||||
isDeleted: result.deleted,
|
||||
});
|
||||
await cacheContent(newFields);
|
||||
}
|
||||
},
|
||||
async onUnhide() {
|
||||
await this.onUpdate({ visible: true });
|
||||
},
|
||||
async onUndelete() {
|
||||
await this.onUpdate({ deleted: false });
|
||||
},
|
||||
async onUpdate(changes) {
|
||||
const newContent = await updateContentFields(this.item, changes);
|
||||
await cacheContent(newContent);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -5,13 +5,13 @@
|
||||
<span class="sr-only">Delete Collection</span>
|
||||
</template>
|
||||
|
||||
<b-dropdown-item @click.stop="$emit('deleteCollection')"> Delete Collection Only </b-dropdown-item>
|
||||
<b-dropdown-item @click.stop="$emit('delete')"> Delete Collection Only </b-dropdown-item>
|
||||
|
||||
<b-dropdown-item @click.stop="$emit('deleteCollection', { recursive: true })">
|
||||
<b-dropdown-item @click.stop="$emit('delete', { recursive: true })">
|
||||
Delete Contained Datasets
|
||||
</b-dropdown-item>
|
||||
|
||||
<b-dropdown-item @click.stop="$emit('deleteCollection', { recursive: true, purge: true })">
|
||||
<b-dropdown-item @click.stop="$emit('delete', { recursive: true, purge: true })">
|
||||
Purge Contained Datasets
|
||||
</b-dropdown-item>
|
||||
</b-dropdown>
|
||||
|
||||
@@ -6,12 +6,9 @@
|
||||
class="dataset dataset-collection collapsed"
|
||||
:class="{ selected }"
|
||||
:data-state="dsc.state"
|
||||
@keydown.arrow-right.self.stop="$emit('select-collection', dsc)"
|
||||
@keydown.arrow-right.self.stop="$emit('viewCollection')"
|
||||
@keydown.space.self.stop.prevent="$emit('update:selected', !selected)"
|
||||
@click.stop="
|
||||
$emit('select-collection', dsc);
|
||||
$emit('update:expanded', dsc);
|
||||
"
|
||||
@click.stop="$emit('viewCollection')"
|
||||
>
|
||||
<nav class="d-flex content-top-menu align-items-center justify-content-between">
|
||||
<div class="d-flex mr-1 align-items-center" @click.stop>
|
||||
@@ -30,7 +27,7 @@
|
||||
state="hidden"
|
||||
title="Unhide"
|
||||
icon="fa fa-eye-slash"
|
||||
@click.stop="$emit('unhideCollection')"
|
||||
@click.stop="$emit('unhide')"
|
||||
/>
|
||||
|
||||
<StateBtn
|
||||
@@ -38,7 +35,7 @@
|
||||
state="ok"
|
||||
title="Collection"
|
||||
icon="fas fa-folder"
|
||||
@click.stop="$emit('select-collection', dsc)"
|
||||
@click.stop="$emit('viewCollection')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -50,19 +47,26 @@
|
||||
</span>
|
||||
</h5>
|
||||
|
||||
<DscMenu v-if="!dsc.deleted" class="content-item-menu" v-on="$listeners" />
|
||||
<slot name="menu">
|
||||
<DscMenu v-if="!dsc.deleted" class="content-item-menu" v-on="$listeners" />
|
||||
</slot>
|
||||
|
||||
<StateBtn
|
||||
v-if="dsc.deleted"
|
||||
class="px-1"
|
||||
state="deleted"
|
||||
title="Undelete"
|
||||
icon="fas fa-trash-restore"
|
||||
@click.stop="$emit('undeleteCollection')"
|
||||
@click.stop="$emit('undelete')"
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<!--- read-only tags with name: prefix -->
|
||||
<div v-if="dsc.nameTags.length" class="nametags p-1">
|
||||
<Nametag v-for="tag in dsc.nameTags" :key="tag" :tag="tag" />
|
||||
</div>
|
||||
|
||||
<JobStateProgress class="m-2" v-if="dsc.jobSummary" :summary="dsc.jobSummary" />
|
||||
<div v-else>No summary</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -71,27 +75,20 @@ import { DatasetCollection } from "../../model/DatasetCollection";
|
||||
import { StatusIcon, StateBtn } from "../../StatusIcon";
|
||||
import JobStateProgress from "./JobStateProgress";
|
||||
import DscMenu from "./DscMenu";
|
||||
import { Nametag } from "components/Nametags";
|
||||
|
||||
export default {
|
||||
inject: ["listState", "STATES"],
|
||||
components: {
|
||||
StatusIcon,
|
||||
StateBtn,
|
||||
JobStateProgress,
|
||||
DscMenu,
|
||||
Nametag,
|
||||
},
|
||||
props: {
|
||||
dsc: { type: DatasetCollection, required: true },
|
||||
selected: { type: Boolean, required: false, default: false },
|
||||
showHid: { type: Boolean, required: false, default: true },
|
||||
},
|
||||
computed: {
|
||||
counter() {
|
||||
return this.showHid ? this.dsc.hid : "";
|
||||
},
|
||||
showSelection() {
|
||||
return this.listState.showSelection;
|
||||
},
|
||||
showSelection: { type: Boolean, required: false, default: false },
|
||||
},
|
||||
methods: {
|
||||
onStatusClick() {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import DatasetCollection from "./DatasetCollection";
|
||||
|
||||
export { default as DscUI } from "./DscUI";
|
||||
export { default as DscMenu } from "./DscMenu";
|
||||
export { default as JobStateProgress } from "./JobStateProgress";
|
||||
export default DatasetCollection;
|
||||
@@ -1,67 +0,0 @@
|
||||
<template>
|
||||
<DscUI
|
||||
v-if="dsc"
|
||||
v-bind="$attrs"
|
||||
v-on="$listeners"
|
||||
class="dataset-collection"
|
||||
:dsc="dsc"
|
||||
@hideCollection="onHide(dsc)"
|
||||
@unhideCollection="onUnhide(dsc, $event)"
|
||||
@deleteCollection="onDelete(dsc, $event)"
|
||||
@undeleteCollection="onUndelete(dsc)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DscUI from "./DscUI";
|
||||
import { DatasetCollection } from "../../model";
|
||||
import { deleteDatasetCollection } from "../../model/queries";
|
||||
import { cacheContent } from "../../caching";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
DscUI,
|
||||
},
|
||||
props: {
|
||||
item: { type: Object, required: true },
|
||||
},
|
||||
computed: {
|
||||
dsc() {
|
||||
return new DatasetCollection(this.item);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async onDelete(collection, flags = {}) {
|
||||
const { recursive = false, purge = false } = flags;
|
||||
const result = await deleteDatasetCollection(collection, recursive, purge);
|
||||
if (result.deleted) {
|
||||
const newFields = Object.assign(collection, {
|
||||
isDeleted: result.deleted,
|
||||
});
|
||||
await cacheContent(newFields);
|
||||
}
|
||||
},
|
||||
|
||||
async onUndelete(collection) {
|
||||
console.log("onUndelete", ...arguments);
|
||||
// await updateDatasetCollection(collection, {
|
||||
// deleted: false
|
||||
// });
|
||||
},
|
||||
// This a valid thing to do?
|
||||
async onHide(collection) {
|
||||
console.log("onHide", ...arguments);
|
||||
// await updateDatasetCollection(collection, {
|
||||
// visible: false
|
||||
// });
|
||||
},
|
||||
async onUnhide(collection) {
|
||||
console.log("onUnhide", ...arguments);
|
||||
// console.log("hideCollection, can we do this?", collection);
|
||||
// await updateDatasetCollection(collection, {
|
||||
// visible: true
|
||||
// });
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -7,10 +7,6 @@ export default {
|
||||
contentItemComponent() {
|
||||
if (this.item.id === undefined) {
|
||||
return "Placeholder";
|
||||
// return "Loading";
|
||||
}
|
||||
if (this.scrolling) {
|
||||
return "Placeholder";
|
||||
}
|
||||
const { history_content_type } = this.item;
|
||||
switch (history_content_type) {
|
||||
|
||||
@@ -7,12 +7,10 @@
|
||||
v-on="$listeners"
|
||||
class="collapsed"
|
||||
:data-state="state"
|
||||
@keydown.arrow-right.self.stop.prevent="$emit('select-collection', dsc)"
|
||||
@keydown.arrow-right.self.stop.prevent="$emit('viewCollection')"
|
||||
@click.stop="$emit('viewCollection')"
|
||||
>
|
||||
<nav
|
||||
class="content-top-menu d-flex align-items-center justify-content-between p-1"
|
||||
@click.stop="$emit('select-collection', dsc)"
|
||||
>
|
||||
<nav class="content-top-menu d-flex align-items-center justify-content-between p-1">
|
||||
<h5 class="flex-grow-1 overflow-hidden mr-auto text-nowrap text-truncate">
|
||||
<span class="name">{{ dsc.name }}</span>
|
||||
<span class="description">
|
||||
@@ -25,9 +23,9 @@
|
||||
|
||||
<script>
|
||||
import { DatasetCollection } from "../model/DatasetCollection";
|
||||
import { STATES } from "../model";
|
||||
|
||||
export default {
|
||||
inject: ["STATES"],
|
||||
props: {
|
||||
item: { type: Object, required: true },
|
||||
index: { type: Number, required: true },
|
||||
@@ -40,7 +38,7 @@ export default {
|
||||
// TODO: see if there are situations where this rule won't work
|
||||
// subcollection doesn't have as much information as the collection
|
||||
// did, but it's probably ok
|
||||
return this.dsc.state || this.STATES.OK;
|
||||
return this.dsc.state || STATES.OK;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<section>
|
||||
<header class="d-flex align-items-center justify-content-between">
|
||||
<h6 class="text-nowrap mr-3">
|
||||
<span>Matches</span>
|
||||
<span v-localize>Matches</span>
|
||||
<a href="#" @click="showFilter = !showFilter">
|
||||
<span>{{ totalMatches }}</span>
|
||||
</a>
|
||||
@@ -21,7 +21,7 @@
|
||||
key="selection"
|
||||
title="Operations on multiple datasets"
|
||||
icon="fas fa-check-square"
|
||||
@click="$emit('update:showSelection', !showSelection)"
|
||||
@click="$emit('update:show-selection', !showSelection)"
|
||||
:pressed="showSelection"
|
||||
/>
|
||||
|
||||
@@ -143,11 +143,13 @@ import {
|
||||
import { createDatasetCollection } from "./model/queries";
|
||||
import { cacheContent } from "./caching";
|
||||
|
||||
import { legacyNavigationMixin } from "components/plugins/legacyNavigation";
|
||||
import ContentFilters from "./ContentFilters";
|
||||
import { PriorityMenu, PriorityMenuItem } from "components/PriorityMenu";
|
||||
import { buildCollectionModal } from "./adapters/buildCollectionModal";
|
||||
|
||||
export default {
|
||||
mixins: [legacyNavigationMixin],
|
||||
components: {
|
||||
ContentFilters,
|
||||
PriorityMenu,
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
<div slot="modal-footer" slot-scope="{ ok, cancel }">
|
||||
<div>
|
||||
<b-button @click="cancel()" class="mr-3"> Cancel </b-button>
|
||||
<b-button @click="copyHistory(ok)" :variant="saveVariant" :disabled="!formValid">
|
||||
{{ saveTitle }}
|
||||
<b-button @click="copy(ok)" :variant="saveVariant" :disabled="!formValid">
|
||||
{{ saveTitle | localize }}
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -46,7 +46,6 @@
|
||||
|
||||
<script>
|
||||
import { History } from "./model/History";
|
||||
import { cloneHistory } from "./model/queries";
|
||||
import { mapActions, mapGetters } from "vuex";
|
||||
|
||||
export default {
|
||||
@@ -94,14 +93,12 @@ export default {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions("betaHistory", ["storeCurrentHistoryId", "storeHistory"]),
|
||||
...mapActions("betaHistory", ["copyHistory"]),
|
||||
|
||||
async copyHistory(close) {
|
||||
async copy(close) {
|
||||
this.loading = true;
|
||||
const { history, name, copyAll } = this;
|
||||
const newHistory = await cloneHistory(history, name, copyAll);
|
||||
await this.storeHistory(newHistory);
|
||||
await this.storeCurrentHistoryId(newHistory.id);
|
||||
await this.copyHistory({ history, name, copyAll });
|
||||
this.loading = false;
|
||||
close();
|
||||
},
|
||||
|
||||
+6
-4
@@ -17,27 +17,29 @@
|
||||
</p>
|
||||
|
||||
<transition name="shutterfade">
|
||||
<ContentTags v-if="writable && showTags" :content="dsc" />
|
||||
<ContentTags v-if="writable" :content="dsc" />
|
||||
<div v-else-if="dsc.tags && dsc.tags.length" class="nametags p-1">
|
||||
<Nametag v-for="tag in dsc.tags" :key="tag" :tag="tag" />
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- <pre style="height:100px;overflow: scroll;">{{ dsc }}</pre> -->
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { DatasetCollection } from "../model";
|
||||
import ClickToEdit from "components/ClickToEdit";
|
||||
import { Nametag } from "components/Nametags";
|
||||
import ContentTags from "../ContentTags";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ClickToEdit,
|
||||
ContentTags,
|
||||
Nametag,
|
||||
},
|
||||
props: {
|
||||
dsc: { type: DatasetCollection, required: true },
|
||||
writable: { type: Boolean, required: true },
|
||||
showTags: { type: Boolean, required: true },
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,109 @@
|
||||
<!-- When a dataset collection is being viewed, this panel shows the contents of that collection -->
|
||||
|
||||
<template>
|
||||
<DscProvider :is-root="isRoot" :collection="selectedCollection" v-slot="{ dsc }">
|
||||
<CollectionContentProvider
|
||||
v-if="dsc"
|
||||
:parent="dsc"
|
||||
v-slot="{
|
||||
payload: { contents = [], startKey = null, topRows = 0, bottomRows = 0 },
|
||||
handlers: { setScrollPos },
|
||||
}"
|
||||
>
|
||||
<ExpandedItems
|
||||
:scope-key="selectedCollection.id"
|
||||
:get-item-key="(item) => item.type_id"
|
||||
v-slot="{ isExpanded, setExpanded }"
|
||||
>
|
||||
<Layout>
|
||||
<template v-slot:nav>
|
||||
<TopNav :history="history" :selected-collections="selectedCollections" v-on="$listeners" />
|
||||
</template>
|
||||
|
||||
<template v-slot:details>
|
||||
<Details :dsc="dsc" :writable="writable" @update:dsc="updateDsc(dsc, $event)" />
|
||||
</template>
|
||||
|
||||
<template v-slot:listing>
|
||||
<Scroller
|
||||
key-field="element_index"
|
||||
:item-height="36"
|
||||
:items="contents"
|
||||
:scroll-to="startKey"
|
||||
:top-placeholders="topRows"
|
||||
:bottom-placeholders="bottomRows"
|
||||
@scroll="setScrollPos"
|
||||
>
|
||||
<template v-slot="{ item, index }">
|
||||
<CollectionContentItem
|
||||
:item="item"
|
||||
:index="index"
|
||||
:expanded="isExpanded(item)"
|
||||
:writable="writable"
|
||||
@update:expanded="setExpanded(item, $event)"
|
||||
@viewCollection="$emit('viewCollection', item)"
|
||||
/>
|
||||
</template>
|
||||
</Scroller>
|
||||
</template>
|
||||
</Layout>
|
||||
</ExpandedItems>
|
||||
</CollectionContentProvider>
|
||||
</DscProvider>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { History } from "../model";
|
||||
import { updateContentFields } from "../model/queries";
|
||||
import { cacheContent } from "../caching";
|
||||
|
||||
import { DscProvider, CollectionContentProvider, ExpandedItems } from "../providers";
|
||||
import Layout from "../Layout";
|
||||
import TopNav from "./TopNav";
|
||||
import Details from "./Details";
|
||||
import Scroller from "../Scroller";
|
||||
import { CollectionContentItem } from "../ContentItem";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
DscProvider,
|
||||
CollectionContentProvider,
|
||||
Layout,
|
||||
TopNav,
|
||||
Details,
|
||||
Scroller,
|
||||
CollectionContentItem,
|
||||
ExpandedItems,
|
||||
},
|
||||
props: {
|
||||
history: { type: History, required: true },
|
||||
selectedCollections: { type: Array, required: true },
|
||||
},
|
||||
computed: {
|
||||
selectedCollection() {
|
||||
const arr = this.selectedCollections;
|
||||
const selected = arr[arr.length - 1];
|
||||
return selected;
|
||||
},
|
||||
isRoot() {
|
||||
return this.selectedCollection == this.selectedCollections[0];
|
||||
},
|
||||
writable() {
|
||||
return this.isRoot;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
// change the data of the root collection, anything past the root
|
||||
// collection is part of the dataset collection, which i believe is supposed to
|
||||
// be immutable, so only edit name, tags, blah of top-level selected collection,
|
||||
|
||||
async updateDsc(collection, fields) {
|
||||
// console.log("updateDsc", this.writable, collection, fields);
|
||||
if (this.writable) {
|
||||
const ajaxResult = await updateContentFields(collection, fields);
|
||||
await cacheContent({ ...collection, ...ajaxResult });
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
-16
@@ -10,13 +10,6 @@
|
||||
<b-button v-else size="sm" class="mr-auto" @click="close"> Back to: {{ history.name }} </b-button>
|
||||
|
||||
<PriorityMenu :starting-height="27">
|
||||
<PriorityMenuItem
|
||||
key="filter"
|
||||
title="Filter History Content"
|
||||
icon="fa fa-filter"
|
||||
@click="$emit('update:showFilter', !showFilter)"
|
||||
:pressed="showFilter"
|
||||
/>
|
||||
<PriorityMenuItem
|
||||
key="download-collection"
|
||||
title="Download Collection"
|
||||
@@ -25,13 +18,6 @@
|
||||
download
|
||||
:href="downloadCollectionUrl"
|
||||
/>
|
||||
<PriorityMenuItem
|
||||
key="edit-tags"
|
||||
title="Edit Collection Tags"
|
||||
icon="fas fa-tags"
|
||||
:pressed="showTags"
|
||||
@click="$emit('update:showTags', !showTags)"
|
||||
/>
|
||||
<PriorityMenuItem key="back-up" title="Back One" icon="fas fa-level-up-alt" @click="back" />
|
||||
</PriorityMenu>
|
||||
</div>
|
||||
@@ -49,8 +35,6 @@ export default {
|
||||
props: {
|
||||
history: { type: History, required: true },
|
||||
selectedCollections: { type: Array, required: true, validate: (val) => val.length > 0 },
|
||||
showFilter: { type: Boolean, required: true },
|
||||
showTags: { type: Boolean, required: true },
|
||||
},
|
||||
computed: {
|
||||
rootCollection() {
|
||||
@@ -1,38 +1,40 @@
|
||||
<template>
|
||||
<HistoryPanel v-if="historyId" :history-id="historyId">
|
||||
<template v-slot:nav>
|
||||
<HistoryTopNav :selected-history-id.sync="historyId" />
|
||||
</template>
|
||||
</HistoryPanel>
|
||||
<div v-else class="flex-grow-1 loadingBackground h-100">
|
||||
<span class="sr-only">Loading History...</span>
|
||||
</div>
|
||||
<CurrentUser v-slot="{ user }">
|
||||
<UserHistories v-if="user" :user="user" v-slot="{ currentHistory, histories, handlers }">
|
||||
<HistoryPanel v-if="currentHistory" :history="currentHistory" v-on="handlers">
|
||||
<template v-slot:nav>
|
||||
<div>
|
||||
<HistorySelector
|
||||
:histories="histories"
|
||||
:current-history="currentHistory"
|
||||
@update:currentHistory="handlers.setCurrentHistory"
|
||||
/>
|
||||
<HistoriesMenu v-on="handlers" />
|
||||
</div>
|
||||
</template>
|
||||
</HistoryPanel>
|
||||
|
||||
<div v-else class="flex-grow-1 loadingBackground h-100">
|
||||
<span class="sr-only" v-localize>Loading History...</span>
|
||||
</div>
|
||||
</UserHistories>
|
||||
</CurrentUser>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions, mapGetters } from "vuex";
|
||||
import HistoryTopNav from "./HistoryTopNav";
|
||||
import CurrentUser from "components/providers/CurrentUser";
|
||||
import UserHistories from "./providers/UserHistories";
|
||||
import HistoryPanel from "./HistoryPanel";
|
||||
import HistorySelector from "./HistorySelector";
|
||||
import HistoriesMenu from "./HistoriesMenu";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
CurrentUser,
|
||||
UserHistories,
|
||||
HistoryPanel,
|
||||
HistoryTopNav,
|
||||
},
|
||||
computed: {
|
||||
...mapGetters("betaHistory", ["currentHistoryId"]),
|
||||
|
||||
historyId: {
|
||||
get() {
|
||||
return this.currentHistoryId;
|
||||
},
|
||||
set(newId) {
|
||||
this.storeCurrentHistoryId(newId);
|
||||
},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions("betaHistory", ["storeCurrentHistoryId"]),
|
||||
HistorySelector,
|
||||
HistoriesMenu,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<PriorityMenu :starting-height="27">
|
||||
<PriorityMenuItem
|
||||
key="create-new-history"
|
||||
title="Create New History"
|
||||
icon="fa fa-plus"
|
||||
@click="$emit('createNewHistory')"
|
||||
/>
|
||||
<PriorityMenuItem
|
||||
key="view-all-histories"
|
||||
title="View All Histories"
|
||||
icon="fa fa-columns"
|
||||
@click="redirect('/history/view_multiple')"
|
||||
/>
|
||||
<PriorityMenuItem
|
||||
key="saved-histories"
|
||||
title="Saved Histories"
|
||||
icon="fas fa-save"
|
||||
@click="backboneRoute('/histories/list')"
|
||||
/>
|
||||
<PriorityMenuItem key="clear-history-cache" title="Clear Cache" icon="fa fa-refresh" @click.stop="clearCache" />
|
||||
<PriorityMenuItem
|
||||
key="use-legacy-history"
|
||||
title="Return to legacy history panel"
|
||||
icon="fas fa-exchange-alt"
|
||||
@click="switchToLegacyHistoryPanel"
|
||||
/>
|
||||
</PriorityMenu>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { PriorityMenuItem, PriorityMenu } from "components/PriorityMenu";
|
||||
import { wipeDatabase, clearHistoryDateStore } from "./caching";
|
||||
import { legacyNavigationMixin } from "components/plugins/legacyNavigation";
|
||||
import { switchToLegacyHistoryPanel } from "./adapters/betaToggle";
|
||||
import { reloadPage } from "utils/redirect";
|
||||
|
||||
export default {
|
||||
mixins: [legacyNavigationMixin],
|
||||
components: {
|
||||
PriorityMenu,
|
||||
PriorityMenuItem,
|
||||
},
|
||||
methods: {
|
||||
async clearCache() {
|
||||
await wipeDatabase();
|
||||
await clearHistoryDateStore();
|
||||
reloadPage();
|
||||
},
|
||||
switchToLegacyHistoryPanel,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -3,90 +3,111 @@
|
||||
:parent="history"
|
||||
v-slot="{
|
||||
loading,
|
||||
scrolling,
|
||||
params,
|
||||
pageSize,
|
||||
payload: {
|
||||
contents = [],
|
||||
startKey = null,
|
||||
topRows = 0,
|
||||
bottomRows = 0,
|
||||
totalMatches = 0,
|
||||
},
|
||||
updateParams,
|
||||
setScrollPos,
|
||||
manualReload,
|
||||
payload: { contents = [], startKey = null, topRows = 0, bottomRows = 0, totalMatches = 0 },
|
||||
handlers: { updateParams, manualReload, setScrollPos },
|
||||
}"
|
||||
>
|
||||
<Layout>
|
||||
<!-- optional top-nav slot, for right-side history panel -->
|
||||
<template v-slot:nav>
|
||||
<slot name="nav"></slot>
|
||||
</template>
|
||||
|
||||
<template v-slot:details>
|
||||
<HistoryDetails class="history-details" :history="history" />
|
||||
</template>
|
||||
|
||||
<template v-slot:messages>
|
||||
<HistoryMessages class="history-messages m-2" :history="history" />
|
||||
<HistoryEmpty v-if="history.empty" class="m-2" />
|
||||
</template>
|
||||
|
||||
<template v-slot:listcontrols>
|
||||
<ContentOperations
|
||||
:history="history"
|
||||
:params="params"
|
||||
@update:params="updateParams"
|
||||
:total-matches="totalMatches"
|
||||
:loading="loading"
|
||||
:content-selection.sync="listState.selected"
|
||||
:show-selection.sync="listState.showSelection"
|
||||
@resetSelection="resetSelection"
|
||||
@selectAllContent="selectItems(contents)"
|
||||
@manualReload="manualReload"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-slot:listing>
|
||||
<VirtualScroller
|
||||
:class="{ loadingBackground: loading }"
|
||||
key-field="hid"
|
||||
:item-height="36"
|
||||
:items="contents"
|
||||
:scroll-to="startKey"
|
||||
:top-placeholders="topRows"
|
||||
:bottom-placeholders="bottomRows"
|
||||
@scroll="setScrollPos"
|
||||
>
|
||||
<template v-slot="{ item, index }">
|
||||
<HistoryContentItem :item="item" :index="index" v-on="$listeners" />
|
||||
<ExpandedItems
|
||||
:scope-key="history.id"
|
||||
:get-item-key="(item) => item.type_id"
|
||||
v-slot="{ isExpanded, setExpanded }"
|
||||
>
|
||||
<SelectedItems
|
||||
:scope-key="history.id"
|
||||
:get-item-key="(item) => item.type_id"
|
||||
v-slot="{
|
||||
selectedItems,
|
||||
showSelection,
|
||||
setShowSelection,
|
||||
selectItems,
|
||||
isSelected,
|
||||
setSelected,
|
||||
resetSelection,
|
||||
}"
|
||||
>
|
||||
<Layout>
|
||||
<template v-slot:nav>
|
||||
<slot name="nav"></slot>
|
||||
</template>
|
||||
</VirtualScroller>
|
||||
</template>
|
||||
|
||||
<template v-slot:modals>
|
||||
<ToolHelpModal />
|
||||
</template>
|
||||
</Layout>
|
||||
<template v-slot:details>
|
||||
<HistoryDetails class="history-details" :history="history" v-on="$listeners" />
|
||||
</template>
|
||||
|
||||
<template v-slot:messages>
|
||||
<HistoryMessages class="history-messages m-2" :history="history" />
|
||||
</template>
|
||||
|
||||
<template v-slot:listcontrols>
|
||||
<ContentOperations
|
||||
v-if="!history.empty"
|
||||
:history="history"
|
||||
:total-matches="totalMatches"
|
||||
:loading="loading"
|
||||
:params="params"
|
||||
@update:params="updateParams"
|
||||
:content-selection="selectedItems"
|
||||
@update:content-selection="selectItems"
|
||||
:show-selection="showSelection"
|
||||
@update:show-selection="setShowSelection"
|
||||
@resetSelection="resetSelection"
|
||||
@selectAllContent="selectItems(contents)"
|
||||
@manualReload="manualReload"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-slot:listing>
|
||||
<HistoryEmpty v-if="history.empty" class="m-2" />
|
||||
|
||||
<Scroller
|
||||
v-else
|
||||
:class="{ loadingBackground: loading }"
|
||||
key-field="hid"
|
||||
:item-height="36"
|
||||
:items="contents"
|
||||
:scroll-to="startKey"
|
||||
:top-placeholders="topRows"
|
||||
:bottom-placeholders="bottomRows"
|
||||
@scroll="setScrollPos"
|
||||
>
|
||||
<template v-slot="{ item, index }">
|
||||
<HistoryContentItem
|
||||
:item="item"
|
||||
:index="index"
|
||||
:show-selection="showSelection"
|
||||
:expanded="isExpanded(item)"
|
||||
@update:expanded="setExpanded(item, $event)"
|
||||
:selected="isSelected(item)"
|
||||
@update:selected="setSelected(item, $event)"
|
||||
@viewCollection="$emit('viewCollection', item)"
|
||||
/>
|
||||
</template>
|
||||
</Scroller>
|
||||
</template>
|
||||
|
||||
<template v-slot:modals>
|
||||
<ToolHelpModal />
|
||||
</template>
|
||||
</Layout>
|
||||
</SelectedItems>
|
||||
</ExpandedItems>
|
||||
</HistoryContentProvider>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { History } from "./model";
|
||||
import { HistoryContentProvider } from "./providers";
|
||||
import { HistoryContentProvider, ExpandedItems, SelectedItems } from "./providers";
|
||||
import Layout from "./Layout";
|
||||
import HistoryMessages from "./HistoryMessages";
|
||||
import HistoryDetails from "./HistoryDetails";
|
||||
import HistoryEmpty from "./HistoryEmpty";
|
||||
import ContentOperations from "./ContentOperations";
|
||||
import ToolHelpModal from "./ToolHelpModal";
|
||||
import ListMixin from "./ListMixin";
|
||||
import VirtualScroller from "../VirtualScroller";
|
||||
import Scroller from "./Scroller";
|
||||
import { HistoryContentItem } from "./ContentItem";
|
||||
|
||||
export default {
|
||||
mixins: [ListMixin],
|
||||
components: {
|
||||
HistoryContentProvider,
|
||||
Layout,
|
||||
@@ -95,8 +116,10 @@ export default {
|
||||
HistoryEmpty,
|
||||
ContentOperations,
|
||||
ToolHelpModal,
|
||||
VirtualScroller,
|
||||
Scroller,
|
||||
HistoryContentItem,
|
||||
ExpandedItems,
|
||||
SelectedItems,
|
||||
},
|
||||
props: {
|
||||
history: { type: History, required: true },
|
||||
@@ -106,13 +129,5 @@ export default {
|
||||
return this.history.id;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
historyId(newId, oldId) {
|
||||
if (newId && newId !== oldId) {
|
||||
this.resetSelection();
|
||||
this.resetExpanded();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<section>
|
||||
<header class="d-flex justify-content-between align-items-center">
|
||||
<header>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h6 class="text-nowrap text-truncate mr-auto">
|
||||
<span class="size">{{ history.niceSize | localize }}</span>
|
||||
</h6>
|
||||
|
||||
<PriorityMenu style="max-width: 50%;" :starting-height="27">
|
||||
<PriorityMenu style="max-width: 50%;" :starting-height="28">
|
||||
<PriorityMenuItem
|
||||
key="edit-history-tags"
|
||||
title="Edit History Tags"
|
||||
@@ -13,7 +13,6 @@
|
||||
:pressed="showTags"
|
||||
@click="showTags = !showTags"
|
||||
/>
|
||||
|
||||
<PriorityMenuItem
|
||||
key="edit-history-annotation"
|
||||
title="Edit Annotation"
|
||||
@@ -21,109 +20,76 @@
|
||||
:pressed="showAnnotation"
|
||||
@click="showAnnotation = !showAnnotation"
|
||||
/>
|
||||
|
||||
<PriorityMenuItem
|
||||
key="copy-history"
|
||||
title="Copy History"
|
||||
icon="fas fa-copy"
|
||||
@click="showCopyModal = true"
|
||||
@click="showCopyModal = !showCopyModal"
|
||||
/>
|
||||
|
||||
<PriorityMenuItem
|
||||
key="share"
|
||||
title="Share or Publish"
|
||||
icon="fas fa-handshake"
|
||||
@click="backboneRoute('/histories/sharing', { id: history.id })"
|
||||
/>
|
||||
|
||||
<PriorityMenuItem
|
||||
key="delete-this-history"
|
||||
title="Delete this History"
|
||||
icon="fas fa-trash"
|
||||
@click="$bvModal.show('delete-history-modal')"
|
||||
@click="showDeleteModal = !showDeleteModal"
|
||||
/>
|
||||
|
||||
<PriorityMenuItem
|
||||
key="purge-this-history"
|
||||
title="Purge this History"
|
||||
icon="fas fa-burn"
|
||||
@click="$bvModal.show('purge-history-modal')"
|
||||
@click="showPurgeModal = !showPurgeModal"
|
||||
/>
|
||||
|
||||
<PriorityMenuItem
|
||||
key="structure"
|
||||
title="Show Structure"
|
||||
icon="fas fa-code-branch"
|
||||
@click="backboneRoute('/histories/show_structure')"
|
||||
/>
|
||||
|
||||
<PriorityMenuItem
|
||||
key="extract-workflow"
|
||||
title="Extract Workflow"
|
||||
icon="fas fa-file-export"
|
||||
@click="iframeRedirect('/workflow/build_from_current_history')"
|
||||
/>
|
||||
|
||||
<PriorityMenuItem
|
||||
key="set-permissions"
|
||||
title="Set Permissions"
|
||||
icon="fas fa-user-lock"
|
||||
@click="backboneRoute('/histories/permissions', { id: history.id })"
|
||||
/>
|
||||
|
||||
<PriorityMenuItem
|
||||
key="make-private"
|
||||
title="Make Private"
|
||||
icon="fas fa-lock"
|
||||
@click="$bvModal.show('make-private-modal')"
|
||||
@click="showPrivacyModal = !showPrivacyModal"
|
||||
/>
|
||||
|
||||
<PriorityMenuItem
|
||||
key="resume-paused-jobs"
|
||||
title="Resume Paused Jobs"
|
||||
icon="fas fa-play"
|
||||
@click="iframeRedirect('/history/resume_paused_jobs?current=True')"
|
||||
/>
|
||||
|
||||
<PriorityMenuItem
|
||||
key="export-citations"
|
||||
title="Export Tool Citations"
|
||||
icon="fas fa-stream"
|
||||
@click="backboneRoute('/histories/citations', { id: history.id })"
|
||||
/>
|
||||
|
||||
<PriorityMenuItem
|
||||
key="export-history-to-file"
|
||||
title="Export History to File"
|
||||
icon="fas fa-file-archive"
|
||||
@click="backboneRoute(`/histories/${history.id}/export`)"
|
||||
@click="iframeRedirect('/history/export_archive?preview=True')"
|
||||
/>
|
||||
</PriorityMenu>
|
||||
</div>
|
||||
|
||||
<CopyModal v-model="showCopyModal" :history="history" />
|
||||
|
||||
<b-modal id="make-private-modal" title="Make History Private" title-tag="h2" @ok="makePrivate">
|
||||
<p v-localize>
|
||||
This will make all the data in this history private (excluding library datasets), and will set
|
||||
permissions such that all new data is created as private. Any datasets within that are currently
|
||||
shared will need to be re-shared or published. Are you sure you want to do this?
|
||||
</p>
|
||||
</b-modal>
|
||||
|
||||
<b-modal id="delete-history-modal" title="Delete History?" title-tag="h2" @ok="deleteHistoryClick">
|
||||
<p v-localize>Really delete the current history?</p>
|
||||
</b-modal>
|
||||
|
||||
<b-modal
|
||||
id="purge-history-modal"
|
||||
title="Permanently Delete History?"
|
||||
title-tag="h2"
|
||||
@ok="purgeHistoryClick"
|
||||
>
|
||||
<p v-localize>Really delete the current history permanently? This cannot be undone.</p>
|
||||
</b-modal>
|
||||
</header>
|
||||
|
||||
<!-- click to edit the name of the history -->
|
||||
<ClickToEdit
|
||||
class="history-title mt-2"
|
||||
tag-name="h3"
|
||||
@@ -139,31 +105,49 @@
|
||||
<transition name="shutterfade">
|
||||
<HistoryTags v-if="showTags" class="mt-2" :history="history" />
|
||||
</transition>
|
||||
</section>
|
||||
|
||||
<!-- #region modals -->
|
||||
|
||||
<CopyHistoryModal v-model="showCopyModal" :history="history" />
|
||||
|
||||
<b-modal v-model="showPrivacyModal" title="Make History Private" title-tag="h2" @ok="makePrivate">
|
||||
<p v-localize>
|
||||
This will make all the data in this history private (excluding library datasets), and will set
|
||||
permissions such that all new data is created as private. Any datasets within that are currently shared
|
||||
will need to be re-shared or published. Are you sure you want to do this?
|
||||
</p>
|
||||
</b-modal>
|
||||
|
||||
<b-modal v-model="showDeleteModal" title="Delete History?" title-tag="h2" @ok="deleteHistory">
|
||||
<p v-localize>Really delete the current history?</p>
|
||||
</b-modal>
|
||||
|
||||
<b-modal v-model="showPurgeModal" title="Permanently Delete History?" title-tag="h2" @ok="purgeHistory">
|
||||
<p v-localize>Really delete the current history permanently? This cannot be undone.</p>
|
||||
</b-modal>
|
||||
|
||||
<!-- #endregion -->
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions } from "vuex";
|
||||
|
||||
import { History } from "./model";
|
||||
import { deleteHistoryById, updateHistoryFields, secureHistory } from "./model/queries";
|
||||
|
||||
import { PriorityMenuItem, PriorityMenu } from "components/PriorityMenu";
|
||||
import { legacyNavigationMixin } from "components/plugins/legacyNavigation";
|
||||
import ClickToEdit from "components/ClickToEdit";
|
||||
import Annotation from "components/Annotation";
|
||||
import { PriorityMenuItem, PriorityMenu } from "components/PriorityMenu";
|
||||
import HistoryTags from "./HistoryTags";
|
||||
import CopyModal from "./CopyModal";
|
||||
import { legacyNavigationMixin } from "components/plugins";
|
||||
import CopyHistoryModal from "./CopyModal";
|
||||
|
||||
export default {
|
||||
mixins: [legacyNavigationMixin],
|
||||
components: {
|
||||
HistoryTags,
|
||||
CopyModal,
|
||||
Annotation,
|
||||
ClickToEdit,
|
||||
PriorityMenu,
|
||||
PriorityMenuItem,
|
||||
HistoryTags,
|
||||
Annotation,
|
||||
ClickToEdit,
|
||||
CopyHistoryModal,
|
||||
},
|
||||
props: {
|
||||
history: { type: History, required: true },
|
||||
@@ -173,7 +157,9 @@ export default {
|
||||
showTags: false,
|
||||
showAnnotation: false,
|
||||
showCopyModal: false,
|
||||
editAnnotation: false,
|
||||
showPrivacyModal: false,
|
||||
showDeleteModal: false,
|
||||
showPurgeModal: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -190,7 +176,7 @@ export default {
|
||||
|
||||
historyName: {
|
||||
get() {
|
||||
return this.history.name;
|
||||
return this.history?.name || "";
|
||||
},
|
||||
set(name) {
|
||||
if (name.length && name !== this.history.name) {
|
||||
@@ -200,30 +186,19 @@ export default {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions("betaHistory", ["storeHistory"]),
|
||||
|
||||
async updateFields(fields = {}) {
|
||||
const updatedHistory = await updateHistoryFields(this.history, fields);
|
||||
await this.storeHistory(updatedHistory);
|
||||
this.$emit("updateHistory", { id: this.history.id, ...fields });
|
||||
},
|
||||
|
||||
async deleteHistoryClick(evt) {
|
||||
const updatedHistory = await deleteHistoryById(this.history.id);
|
||||
// console.log("deleteHistoryClick", updatedHistory);
|
||||
await this.storeHistory(updatedHistory);
|
||||
async deleteHistory(evt) {
|
||||
this.$emit("deleteHistory", this.history);
|
||||
evt.vueTarget.hide();
|
||||
},
|
||||
|
||||
async purgeHistoryClick(evt) {
|
||||
const updatedHistory = await deleteHistoryById(this.history.id, true);
|
||||
// console.log("purgeHistoryClick", updatedHistory);
|
||||
await this.storeHistory(updatedHistory);
|
||||
async purgeHistory(evt) {
|
||||
this.$emit("purgeHistory", this.history);
|
||||
evt.vueTarget.hide();
|
||||
},
|
||||
|
||||
async makePrivate(evt) {
|
||||
const updatedHistory = await secureHistory(this.history.id);
|
||||
await this.storeHistory(updatedHistory);
|
||||
this.$emit("secureHistory", this.history);
|
||||
evt.vueTarget.hide();
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
<b-alert show>
|
||||
<h4>
|
||||
<i class="fa fa-info-circle"></i>
|
||||
This history is empty.
|
||||
<span v-localize>This history is empty.</span>
|
||||
</h4>
|
||||
<p>
|
||||
You can <a href="#" @click.prevent="openUploader" v-localize>load your own data</a> or
|
||||
<a href="#" @click.prevent="openUploader" v-localize>You can load your own data</a> or
|
||||
<a href="#" @click.prevent="clickDataLink" v-localize>get data from an external source</a>.
|
||||
</p>
|
||||
</b-alert>
|
||||
@@ -15,10 +15,7 @@
|
||||
export default {
|
||||
methods: {
|
||||
openUploader() {
|
||||
this.eventHub.$emit("openToolSection", "getext");
|
||||
this.$nextTick(() => {
|
||||
this.eventHub.$emit("clickTool", "upload1");
|
||||
});
|
||||
this.eventHub.$emit("upload:open");
|
||||
},
|
||||
clickDataLink() {
|
||||
this.eventHub.$emit("openToolSection", "getext");
|
||||
|
||||
@@ -2,20 +2,22 @@
|
||||
selected datset collections -->
|
||||
|
||||
<template>
|
||||
<History
|
||||
v-on:select-collection="selectCollection($event)"
|
||||
v-if="history && !selectedCollections.length"
|
||||
<HistoryComponent
|
||||
v-if="history && !breadcrumbs.length"
|
||||
:history="history"
|
||||
v-on="$listeners"
|
||||
@viewCollection="drillDown"
|
||||
>
|
||||
<template v-for="(_, name) in $scopedSlots" :slot="name" slot-scope="slotData">
|
||||
<slot :name="name" v-bind="slotData" :history="history" />
|
||||
</template>
|
||||
</History>
|
||||
</HistoryComponent>
|
||||
|
||||
<SelectedCollection
|
||||
v-else-if="selectedCollections.length"
|
||||
<CurrentCollection
|
||||
v-else-if="breadcrumbs.length"
|
||||
:history="history"
|
||||
:selected-collections.sync="selectedCollections"
|
||||
:selected-collections.sync="breadcrumbs"
|
||||
@viewCollection="drillDown"
|
||||
/>
|
||||
|
||||
<div v-else>
|
||||
@@ -24,34 +26,30 @@ selected datset collections -->
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from "vuex";
|
||||
import History from "./History";
|
||||
import SelectedCollection from "./SelectedCollection/Panel";
|
||||
import { STATES } from "./model";
|
||||
import HistoryComponent from "./History";
|
||||
import CurrentCollection from "./CurrentCollection/Panel";
|
||||
import { History, STATES } from "./model";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
History,
|
||||
SelectedCollection,
|
||||
HistoryComponent,
|
||||
CurrentCollection,
|
||||
},
|
||||
props: {
|
||||
historyId: { type: String, required: true },
|
||||
history: { type: History, required: true },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
// list of collections we have drilled down into
|
||||
breadcrumbs: [],
|
||||
};
|
||||
},
|
||||
data: () => ({
|
||||
selectedCollections: [],
|
||||
}),
|
||||
provide: {
|
||||
STATES,
|
||||
},
|
||||
computed: {
|
||||
...mapGetters("betaHistory", ["getHistoryById"]),
|
||||
history() {
|
||||
return this.getHistoryById(this.historyId);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
selectCollection(coll) {
|
||||
this.selectedCollections = [...this.selectedCollections, coll];
|
||||
drillDown(coll) {
|
||||
this.breadcrumbs = [...this.breadcrumbs, coll];
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<b-dropdown text="Select history..." size="sm">
|
||||
<b-dropdown-item
|
||||
v-for="h in availableHistories"
|
||||
v-for="h in histories"
|
||||
:key="h.id"
|
||||
:active="value == h.id"
|
||||
@click="$emit('input', h.id)"
|
||||
:active="currentHistory.id == h.id"
|
||||
@click="$emit('update:currentHistory', h)"
|
||||
>
|
||||
{{ h.name }}
|
||||
</b-dropdown-item>
|
||||
@@ -12,22 +12,10 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from "vuex";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
value: { type: String, required: true },
|
||||
},
|
||||
computed: {
|
||||
...mapGetters("betaHistory", ["activeHistories"]),
|
||||
|
||||
availableHistories() {
|
||||
return Array.from(this.activeHistories).sort((a, b) => {
|
||||
const aa = a.name.toLowerCase();
|
||||
const bb = b.name.toLowerCase();
|
||||
return bb - aa;
|
||||
});
|
||||
},
|
||||
currentHistory: { type: Object, required: true },
|
||||
histories: { type: Array, default: () => [] },
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -40,7 +40,7 @@ class HistoryTagService extends TagService {
|
||||
async saveHistoryTags(rawTags) {
|
||||
// console.log("saveHistoryTags", rawTags);
|
||||
const tags = Array.from(rawTags);
|
||||
return await updateHistoryFields(this.history, { tags });
|
||||
return await updateHistoryFields(this.history.id, { tags });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
<template>
|
||||
<div v-if="historyId">
|
||||
<HistorySelector v-model="historyId" />
|
||||
|
||||
<PriorityMenu style="max-width: 50%;" :starting-height="27">
|
||||
<PriorityMenuItem
|
||||
key="create-new-history"
|
||||
title="Create New History"
|
||||
icon="fa fa-plus"
|
||||
@click="createHistory"
|
||||
/>
|
||||
|
||||
<PriorityMenuItem
|
||||
key="view-all-histories"
|
||||
title="View All Histories"
|
||||
icon="fa fa-columns"
|
||||
@click="redirect('/history/view_multiple')"
|
||||
/>
|
||||
|
||||
<PriorityMenuItem
|
||||
key="saved-histories"
|
||||
title="Saved Histories"
|
||||
icon="fas fa-save"
|
||||
@click="backboneRoute('/histories/list')"
|
||||
/>
|
||||
|
||||
<PriorityMenuItem
|
||||
key="clear-history-cache"
|
||||
title="Clear cache"
|
||||
icon="fa fa-refresh"
|
||||
@click.stop="clearCache"
|
||||
/>
|
||||
|
||||
<PriorityMenuItem
|
||||
key="use-legacy-history"
|
||||
title="Return to legacy history panel"
|
||||
icon="fas fa-exchange-alt"
|
||||
@click="switchToLegacyHistoryPanel"
|
||||
/>
|
||||
</PriorityMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions } from "vuex";
|
||||
import { PriorityMenuItem, PriorityMenu } from "components/PriorityMenu";
|
||||
import HistorySelector from "./HistorySelector";
|
||||
import { createNewHistory } from "./model/queries";
|
||||
import { wipeDatabase, clearHistoryDateStore } from "./caching";
|
||||
import { legacyNavigationMixin } from "components/plugins";
|
||||
import { switchToLegacyHistoryPanel } from "./adapters/betaToggle";
|
||||
|
||||
export default {
|
||||
mixins: [legacyNavigationMixin],
|
||||
components: {
|
||||
PriorityMenu,
|
||||
PriorityMenuItem,
|
||||
HistorySelector,
|
||||
},
|
||||
props: {
|
||||
selectedHistoryId: { type: String, required: false, default: null },
|
||||
},
|
||||
computed: {
|
||||
historyId: {
|
||||
get() {
|
||||
return this.selectedHistoryId;
|
||||
},
|
||||
set(id) {
|
||||
this.$emit("update:selectedHistoryId", id);
|
||||
},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions("betaHistory", ["storeHistory"]),
|
||||
|
||||
switchToLegacyHistoryPanel,
|
||||
|
||||
// create new server, store in vuex, switch to it
|
||||
async createHistory() {
|
||||
const newHistory = await createNewHistory();
|
||||
await this.storeHistory(newHistory);
|
||||
this.historyId = newHistory.id;
|
||||
},
|
||||
|
||||
async clearCache() {
|
||||
await wipeDatabase();
|
||||
await clearHistoryDateStore();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,73 +0,0 @@
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
listState: {
|
||||
showSelection: false,
|
||||
selected: new Map(),
|
||||
expanded: new Set(),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
isSelected(item) {
|
||||
const { type_id } = item;
|
||||
return this.listState.selected.has(type_id);
|
||||
},
|
||||
setSelected(item, bSelected) {
|
||||
const { type_id } = item;
|
||||
const newSelected = new Map(this.listState.selected);
|
||||
bSelected ? newSelected.set(type_id, item) : newSelected.delete(type_id);
|
||||
this.listState.selected = newSelected;
|
||||
},
|
||||
selectItems(items = []) {
|
||||
const newItems = [...this.listState.selected.values(), ...items];
|
||||
const newEntries = newItems.map((item) => [item.type_id, item]);
|
||||
this.listState.selected = new Map(newEntries);
|
||||
},
|
||||
unselectItems(items = []) {
|
||||
const doomedKeys = items.map((item) => item.type_id);
|
||||
const existing = this.listState.selected.entries();
|
||||
const newEntries = Array.from(existing).filter(([key]) => {
|
||||
return !doomedKeys.includes(key);
|
||||
});
|
||||
this.listState.selected = new Map(newEntries);
|
||||
},
|
||||
isExpanded({ type_id }) {
|
||||
return this.listState.expanded.has(type_id);
|
||||
},
|
||||
setExpanded({ type_id }, val) {
|
||||
const newSet = new Set(this.listState.expanded);
|
||||
val ? newSet.add(type_id) : newSet.delete(type_id);
|
||||
this.listState.expanded = newSet;
|
||||
},
|
||||
resetSelection() {
|
||||
this.listState.selected = new Map();
|
||||
},
|
||||
resetExpanded() {
|
||||
this.listState.expanded = new Set();
|
||||
},
|
||||
},
|
||||
|
||||
provide() {
|
||||
return {
|
||||
listState: this.listState,
|
||||
isSelected: this.isSelected,
|
||||
setSelected: this.setSelected,
|
||||
select: this.select,
|
||||
unselect: this.unselect,
|
||||
isExpanded: this.isExpanded,
|
||||
setExpanded: this.setExpanded,
|
||||
resetSelection: this.resetSelection,
|
||||
resetExpanded: this.resetExpanded,
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
"listState.selected": function (newSet) {
|
||||
if (newSet.size > 0) {
|
||||
this.showSelection = true;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
+1
-1
@@ -222,7 +222,7 @@ export default {
|
||||
"div",
|
||||
{
|
||||
ref: "container",
|
||||
staticClass: "virtualScroller",
|
||||
staticClass: "historyScroller",
|
||||
directives: [
|
||||
{
|
||||
name: "scroll",
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Had to build a custom virtual scroller to suit our needs.
|
||||
*/
|
||||
|
||||
import Scroller from "./Scroller";
|
||||
|
||||
export { Scroller };
|
||||
export default Scroller;
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
.virtualScroller {
|
||||
.historyScroller {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
.virtualScroller {
|
||||
.historyScroller {
|
||||
ul {
|
||||
list-style: none;
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
<!-- When a collection is selected, the panel shows the contents of that selection -->
|
||||
|
||||
<template>
|
||||
<DscProvider :is-root="isRoot" :collection="selectedCollection" v-slot="{ dsc }">
|
||||
<CollectionContentProvider
|
||||
v-if="dsc"
|
||||
:parent="dsc"
|
||||
v-slot="{
|
||||
payload: {
|
||||
contents = [],
|
||||
startKey = null,
|
||||
topRows = 0,
|
||||
bottomRows = 0,
|
||||
totalMatches = 0
|
||||
},
|
||||
busy: loading,
|
||||
params,
|
||||
pageSize,
|
||||
updateParams,
|
||||
setScrollPos,
|
||||
}"
|
||||
>
|
||||
<Layout>
|
||||
<template v-slot:nav>
|
||||
<TopNav
|
||||
:history="history"
|
||||
:selected-collections="selectedCollections"
|
||||
:show-tags.sync="showTags"
|
||||
:show-filter.sync="showFilter"
|
||||
v-on="$listeners"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-slot:details>
|
||||
<Details
|
||||
:dsc="dsc"
|
||||
:writable="writable"
|
||||
:show-tags.sync="showTags"
|
||||
:show-filter.sync="showFilter"
|
||||
@update:dsc="updateDsc(dsc, $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-slot:listing>
|
||||
<VirtualScroller
|
||||
key-field="element_index"
|
||||
:item-height="36"
|
||||
:items="contents"
|
||||
:scroll-to="startKey"
|
||||
:top-placeholders="topRows"
|
||||
:bottom-placeholders="bottomRows"
|
||||
@scroll="setScrollPos"
|
||||
>
|
||||
<template v-slot="{ item, index }">
|
||||
<CollectionContentItem :item="item" :index="index" />
|
||||
</template>
|
||||
</VirtualScroller>
|
||||
</template>
|
||||
</Layout>
|
||||
</CollectionContentProvider>
|
||||
</DscProvider>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { History } from "../model";
|
||||
import { updateContentFields } from "../model/queries";
|
||||
import { cacheContent } from "../caching";
|
||||
|
||||
import { DscProvider, CollectionContentProvider } from "../providers";
|
||||
import Layout from "../Layout";
|
||||
import TopNav from "./TopNav";
|
||||
import Details from "./Details";
|
||||
import ListMixin from "../ListMixin";
|
||||
import VirtualScroller from "../../VirtualScroller";
|
||||
import { CollectionContentItem } from "../ContentItem";
|
||||
|
||||
export default {
|
||||
mixins: [ListMixin],
|
||||
components: {
|
||||
DscProvider,
|
||||
CollectionContentProvider,
|
||||
Layout,
|
||||
TopNav,
|
||||
Details,
|
||||
VirtualScroller,
|
||||
CollectionContentItem,
|
||||
},
|
||||
props: {
|
||||
history: { type: History, required: true },
|
||||
selectedCollections: { type: Array, required: true },
|
||||
},
|
||||
data: () => ({
|
||||
showTags: false,
|
||||
showFilter: false,
|
||||
}),
|
||||
computed: {
|
||||
selectedCollection() {
|
||||
const arr = this.selectedCollections;
|
||||
const selected = arr[arr.length - 1];
|
||||
return selected;
|
||||
},
|
||||
isRoot() {
|
||||
return this.selectedCollection == this.selectedCollections[0];
|
||||
},
|
||||
writable() {
|
||||
return this.isRoot;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
// change the data of the root collection, anything past the root
|
||||
// collection is part of the dataset collection, which i believe is supposed to
|
||||
// be immutable, so only edit name, tags, blah of top-level selected collection,
|
||||
|
||||
async updateDsc(collection, fields) {
|
||||
// console.log("updateDsc", this.writable, collection, fields);
|
||||
if (this.writable) {
|
||||
const ajaxResult = await updateContentFields(collection, fields);
|
||||
await cacheContent({ ...collection, ...ajaxResult });
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -65,7 +65,6 @@ export const HistoryPanelProxy = Backbone.View.extend({
|
||||
// Watch the store, change the fake history model when it changs
|
||||
store.subscribe(({ type, payload: newId }) => {
|
||||
if (type == "betaHistory/setCurrentHistoryId") {
|
||||
console.log("setCurrentHistoryId", newId);
|
||||
Galaxy.currHistoryPanel.setModel(new History({ id: newId }));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ export const bulkCacheContent = toPromise("bulkCacheContent");
|
||||
export const cacheCollectionContent = toPromise("cacheCollectionContent");
|
||||
export const getCachedCollectionContent = toPromise("getCachedCollectionContent");
|
||||
export const bulkCacheDscContent = toPromise("bulkCacheDscContent");
|
||||
export const getContentByTypeId = toPromise("getContentByTypeId");
|
||||
|
||||
// Debugging
|
||||
export const wipeDatabase = toPromise("wipeDatabase");
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
import { map, withLatestFrom, pluck } from "rxjs/operators";
|
||||
import { throttleDistinct } from "utils/observable/throttleDistinct";
|
||||
import { nth } from "utils/observable/nth";
|
||||
|
||||
import { bulkCacheDscContent } from "./db";
|
||||
import { prependPath } from "./workerConfig";
|
||||
@@ -33,9 +34,7 @@ export const loadDscContent = (cfg = {}) => (src$) => {
|
||||
);
|
||||
|
||||
// add contents_url to cache data, will use it as a key
|
||||
const url$ = inputs$.pipe(
|
||||
map((inputs) => inputs[0])
|
||||
);
|
||||
const url$ = inputs$.pipe(nth(0));
|
||||
|
||||
// request, throttle frequently repeated requests
|
||||
const ajaxResponse$ = inputs$.pipe(
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* Lossless backpressure queue for processing observables one at
|
||||
* a time without spamming the server or the cache.
|
||||
*/
|
||||
import { Subject } from "rxjs";
|
||||
import { concatMap, publish, finalize, delay } from "rxjs/operators";
|
||||
|
||||
// put stuff on this Subject to process
|
||||
// format { task: observable to run, label: some identifier }
|
||||
const queue$ = new Subject();
|
||||
|
||||
// process each item sequentially
|
||||
// prettier-ignore
|
||||
const process$ = queue$.pipe(
|
||||
concatMap(({ task, label }) => {
|
||||
task.connect();
|
||||
return task.pipe(
|
||||
delay(100),
|
||||
finalize(() => console.log("[queue] task done", label))
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
// just a counter to help debugging
|
||||
let taskCounter = 0;
|
||||
|
||||
// publish passed observable. This won't emit until later
|
||||
// when connect() is called, throw the new task on the queue$;
|
||||
export function enqueue(obs$) {
|
||||
const label = taskCounter++;
|
||||
const task = obs$.pipe(publish());
|
||||
queue$.next({ task, label });
|
||||
return task;
|
||||
}
|
||||
|
||||
// subscribe to processor
|
||||
export const queueSubscription = process$.subscribe(
|
||||
(result) => console.log("[queue] result", result),
|
||||
(err) => console.warn("[queue] error", err),
|
||||
() => console.warn("[queue] complete, why is queue complete?")
|
||||
);
|
||||
@@ -55,4 +55,10 @@ export class Content extends dateMixin(ModelBase) {
|
||||
get hdca_id() {
|
||||
return this.isCollection ? this.id : null;
|
||||
}
|
||||
|
||||
// subset of tags that start with name:
|
||||
get nameTags() {
|
||||
const allTags = this.tags || [];
|
||||
return allTags.filter((t) => t.includes("name:"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,114 +1,147 @@
|
||||
import Vue from "vue";
|
||||
import VuexPersistence from "vuex-persist";
|
||||
import { History } from "./History";
|
||||
import { getHistoriesForCurrentUser, getCurrentHistoryFromServer, setCurrentHistoryOnServer } from "./queries";
|
||||
// import { changeGalaxyHistory } from "../adapters/changeGalaxyHistory";
|
||||
import { sortByObjectProp } from "utils/sorting";
|
||||
|
||||
const persist = new VuexPersistence();
|
||||
Vue.use(persist);
|
||||
import {
|
||||
createNewHistory,
|
||||
deleteHistoryById,
|
||||
getHistoryList,
|
||||
getHistoryById,
|
||||
getCurrentHistoryFromServer,
|
||||
setCurrentHistoryOnServer,
|
||||
updateHistoryFields,
|
||||
cloneHistory,
|
||||
secureHistory,
|
||||
} from "./queries";
|
||||
|
||||
// default state
|
||||
export const state = {
|
||||
// owner of the histories in this store
|
||||
// need this to track whether or not the right histories have been loaded
|
||||
loadedForUser: null,
|
||||
// selected history
|
||||
currentHistoryId: null,
|
||||
histories: [],
|
||||
// histories for current user
|
||||
histories: {},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
setCurrentHistoryId(state, id) {
|
||||
state.currentHistoryId = id;
|
||||
},
|
||||
setHistory(state, rawHistory) {
|
||||
const history = new History(rawHistory);
|
||||
const newHistories = new Map(state.histories);
|
||||
state.histories = newHistories.set(history.id, new History(history));
|
||||
setHistory(state, newHistory) {
|
||||
Vue.set(state.histories, newHistory.id, newHistory);
|
||||
},
|
||||
setHistories(state, list) {
|
||||
const newHistories = list.reduce((histories, rawHistory) => {
|
||||
const h = new History(rawHistory);
|
||||
return histories.set(h.id, h);
|
||||
}, new Map());
|
||||
state.histories = newHistories;
|
||||
deleteHistory(state, doomed) {
|
||||
Vue.delete(state.histories, doomed.id);
|
||||
},
|
||||
setHistories(state, newHistories = []) {
|
||||
const newMap = newHistories.reduce((acc, h) => ({ ...acc, [h.id]: h }), {});
|
||||
Vue.set(state, "histories", newMap);
|
||||
},
|
||||
setLoadedForUser(state, userId) {
|
||||
state.loadedForUser = userId;
|
||||
},
|
||||
};
|
||||
|
||||
export const getters = {
|
||||
histories: (state) => {
|
||||
return Array.from(state.histories.values());
|
||||
},
|
||||
|
||||
activeHistories: (state, getters) => {
|
||||
const list = Array.from(getters.histories);
|
||||
const okHistory = (h) => !h.deleted && !h.purged;
|
||||
const activeHistories = list.filter(okHistory);
|
||||
return activeHistories;
|
||||
},
|
||||
|
||||
currentHistoryId: (state, getters) => {
|
||||
const activeHistories = getters.activeHistories;
|
||||
const validIds = activeHistories.map((h) => h.id);
|
||||
|
||||
if (state.currentHistoryId && validIds.includes(state.currentHistoryId)) {
|
||||
return state.currentHistoryId;
|
||||
}
|
||||
if (validIds.length) {
|
||||
return validIds[0];
|
||||
}
|
||||
return null;
|
||||
const { histories, currentHistoryId: id } = state;
|
||||
return id in histories ? id : getters.firstHistoryId;
|
||||
},
|
||||
firstHistoryId: (state, getters) => {
|
||||
const { histories } = getters;
|
||||
return histories.length ? histories[0].id : null;
|
||||
},
|
||||
|
||||
currentHistory: (state, getters) => {
|
||||
const currentId = getters.currentHistoryId;
|
||||
return getters.getHistoryById(currentId);
|
||||
const { currentHistoryId: id } = getters;
|
||||
return id ? getters.getHistoryById(id) : null;
|
||||
},
|
||||
|
||||
getHistoryById: (state) => (historyId) => {
|
||||
const raw = state.histories.get(historyId);
|
||||
const history = raw ? new History(raw) : null;
|
||||
return history;
|
||||
histories: (state) => {
|
||||
return Object.values(state.histories).sort(sortByObjectProp("name"));
|
||||
},
|
||||
getHistoryById: (state) => (id) => {
|
||||
return id in state.histories ? state.histories[id] : null;
|
||||
},
|
||||
};
|
||||
|
||||
// Holds promises for in-flight loads
|
||||
const promises = {
|
||||
load: null,
|
||||
byId: new Map(),
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
async $init({ dispatch }, { store }) {
|
||||
store.watch(
|
||||
(state) => state.user.currentUser,
|
||||
async () => {
|
||||
const myHistories = await getHistoriesForCurrentUser();
|
||||
dispatch("storeHistories", myHistories);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
dispatch("loadCurrentHistoryId");
|
||||
},
|
||||
|
||||
async loadCurrentHistoryId({ dispatch }) {
|
||||
const serverSaysWhat = await getCurrentHistoryFromServer();
|
||||
if (serverSaysWhat && serverSaysWhat.id) {
|
||||
dispatch("storeCurrentHistoryId", serverSaysWhat.id);
|
||||
loadUserHistories({ dispatch, commit }) {
|
||||
if (!promises.load) {
|
||||
promises.load = Promise.all([getHistoryList(), getCurrentHistoryFromServer()])
|
||||
.then(([list, history]) => {
|
||||
commit("setHistories", list);
|
||||
dispatch("selectHistory", history);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("loadUserHistories error", err);
|
||||
})
|
||||
.finally(() => {
|
||||
promises.load = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async storeCurrentHistoryId({ commit, getters }, id) {
|
||||
if (id == getters.currentHistoryId) return;
|
||||
const rawHistory = await setCurrentHistoryOnServer(id);
|
||||
if (rawHistory && rawHistory.id) {
|
||||
// tell legacy code that we changed the history,
|
||||
// remove when the backbone history management is not required
|
||||
// changeGalaxyHistory(rawHistory.id);
|
||||
commit("setCurrentHistoryId", rawHistory.id);
|
||||
loadHistoryById({ commit, getters }, id) {
|
||||
if (!promises.byId.has(id)) {
|
||||
const existing = getters.getHistoryById(id);
|
||||
const lastUpdated = existing?.update_time || null;
|
||||
const p = getHistoryById(id, lastUpdated)
|
||||
.then((history) => {
|
||||
commit("setHistory", history);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("loadHistoryById error", id, err);
|
||||
})
|
||||
.finally(() => {
|
||||
promises.byId.delete(id);
|
||||
});
|
||||
promises.byId.set(id, p);
|
||||
}
|
||||
},
|
||||
|
||||
// boilerplate (tm): let's think of 3 names for functions
|
||||
// that all do the same thing
|
||||
|
||||
async storeHistories({ commit }, list) {
|
||||
commit("setHistories", list);
|
||||
async setCurrentHistoryId({ dispatch }, id) {
|
||||
const nextHistory = await setCurrentHistoryOnServer(id);
|
||||
dispatch("selectHistory", nextHistory);
|
||||
},
|
||||
|
||||
async storeHistory({ commit }, history) {
|
||||
async createNewHistory({ dispatch }) {
|
||||
const newHistory = await createNewHistory();
|
||||
dispatch("selectHistory", newHistory);
|
||||
},
|
||||
async updateHistory({ commit }, { id, ...updateFields }) {
|
||||
// save new history params should be an object with an id property and any additional
|
||||
// properties that are to be updated on the server. A full history object is not required
|
||||
const saveResult = await updateHistoryFields(id, updateFields);
|
||||
commit("setHistory", saveResult);
|
||||
},
|
||||
async deleteHistory({ dispatch, commit, getters }, { history, purge = false }) {
|
||||
const deletedHistory = await deleteHistoryById(history.id, purge);
|
||||
commit("deleteHistory", deletedHistory);
|
||||
if (getters.histories.length == 0) {
|
||||
await dispatch("createNewHistory");
|
||||
}
|
||||
},
|
||||
async copyHistory({ dispatch }, { history, name, copyAll }) {
|
||||
const newHistory = await cloneHistory(history, name, copyAll);
|
||||
dispatch("selectHistory", newHistory);
|
||||
},
|
||||
async secureHistory({ commit }, history) {
|
||||
const updatedHistory = await secureHistory(history);
|
||||
commit("setHistory", updatedHistory);
|
||||
},
|
||||
setHistory({ commit }, history) {
|
||||
commit("setHistory", history);
|
||||
},
|
||||
selectHistory({ commit }, history) {
|
||||
commit("setHistory", history);
|
||||
commit("setCurrentHistoryId", history.id);
|
||||
},
|
||||
reset({ commit }) {
|
||||
commit("setHistories", []);
|
||||
commit("setCurrentHistoryId", null);
|
||||
},
|
||||
};
|
||||
|
||||
export const historyStore = {
|
||||
@@ -118,23 +151,3 @@ export const historyStore = {
|
||||
mutations,
|
||||
actions,
|
||||
};
|
||||
|
||||
/**
|
||||
* Localstorage plugin
|
||||
*
|
||||
* Caches simple user settings like "currentHistory" in localStorage instead
|
||||
* of having the server do that. Hopefully doing this in the client
|
||||
* will mean we can move closer towards a completely stateless REST api.
|
||||
*/
|
||||
|
||||
export const historyPersist = new VuexPersistence({
|
||||
key: "vuex-state-history",
|
||||
modules: ["betaHistory"],
|
||||
reducer: (state) => {
|
||||
return {
|
||||
history: {
|
||||
currentHistoryId: state.betaHistory.currentHistoryId,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
import axios from "axios";
|
||||
import moment from "moment";
|
||||
import { prependPath } from "utils/redirect";
|
||||
// import { historyFields } from "./fields";
|
||||
|
||||
// #region setup & utils
|
||||
|
||||
@@ -82,22 +81,8 @@ const stdHistoryParams = {
|
||||
/**
|
||||
* Return list of available histories
|
||||
*/
|
||||
export async function getHistoriesForCurrentUser(lastUpdate = null) {
|
||||
const url = `/histories`;
|
||||
|
||||
// TODO: remove q,qv syntax
|
||||
const legacyParams = {
|
||||
deleted: "None",
|
||||
};
|
||||
if (lastUpdate) {
|
||||
legacyParams["update_time-gt"] = moment.utc(lastUpdate);
|
||||
}
|
||||
const incomptentQueryString = buildLegacyQueryString(legacyParams);
|
||||
|
||||
const response = await api.get(`${url}?${incomptentQueryString}`, {
|
||||
params: stdHistoryParams,
|
||||
});
|
||||
|
||||
export async function getHistoryList() {
|
||||
const response = await api.get("/histories?view=summary");
|
||||
return doResponse(response);
|
||||
}
|
||||
|
||||
@@ -105,8 +90,10 @@ export async function getHistoriesForCurrentUser(lastUpdate = null) {
|
||||
* Load one history by id
|
||||
* @param {String} id
|
||||
*/
|
||||
export async function getHistoryById(id) {
|
||||
const url = `/histories/${id}`;
|
||||
export async function getHistoryById(id, since) {
|
||||
const path = `/histories/${id}`;
|
||||
const sinceParam = since !== undefined ? moment.utc(since).toISOString() : null;
|
||||
const url = sinceParam ? path : `${path}?q=update_time-gt&qv=${sinceParam}`;
|
||||
const response = await api.get(url, { params: stdHistoryParams });
|
||||
return doResponse(response);
|
||||
}
|
||||
@@ -166,8 +153,8 @@ export async function undeleteHistoryById(id) {
|
||||
* @param {Object} history
|
||||
* @param {Object} payload fields to update
|
||||
*/
|
||||
export async function updateHistoryFields(history, payload) {
|
||||
const url = `/histories/${history.id}`;
|
||||
export async function updateHistoryFields(id, payload) {
|
||||
const url = `/histories/${id}`;
|
||||
const response = await api.put(url, payload, { params: stdHistoryParams });
|
||||
return doResponse(response);
|
||||
}
|
||||
@@ -177,14 +164,18 @@ export async function updateHistoryFields(history, payload) {
|
||||
* TODO: rewrite API endpoint for this
|
||||
* @param {String} history_id
|
||||
*/
|
||||
export async function secureHistory(history_id) {
|
||||
export async function secureHistory(history) {
|
||||
// NOTE: does not hit normal api/ endpoint
|
||||
const { id } = history;
|
||||
const url = prependPath("/history/make_private");
|
||||
const response = await axios.post(url, formData({ history_id }));
|
||||
const response = await axios.post(url, formData({ history_id: id }));
|
||||
if (response.status != 200) {
|
||||
throw new Error(response);
|
||||
}
|
||||
return await getHistoryById(history_id);
|
||||
// instead of a full lookup we could alternately figure out if
|
||||
// just a couple fields are changed and return the model with those
|
||||
// fields updated. it would avoid an extraneous ajax call
|
||||
return await getHistoryById(id);
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
@@ -30,7 +30,7 @@ export function syncCurrentHistoryToGalaxy(galaxy$, store, cfg = {}) {
|
||||
);
|
||||
|
||||
return historyId$.subscribe(
|
||||
(id) => store.commit("betaHistory/setCurrentHistoryId", id),
|
||||
(id) => store.dispatch("betaHistory/setCurrentHistoryId", id),
|
||||
(err) => console.warn("syncCurrentHistoryToGalaxy error", err),
|
||||
() => console.log("syncCurrentHistoryToGalaxy complete")
|
||||
);
|
||||
|
||||
+16
-156
@@ -13,17 +13,9 @@
|
||||
* page through the results.
|
||||
*/
|
||||
|
||||
import { NEVER, merge, combineLatest } from "rxjs";
|
||||
import { map, pluck, switchMap, distinctUntilChanged, share, shareReplay, mapTo, debounceTime } from "rxjs/operators";
|
||||
import { tag } from "rxjs-spy/operators/tag";
|
||||
import { whenAny } from "utils/observable/whenAny";
|
||||
import { activity } from "utils/observable/activity";
|
||||
import { chunk } from "../../caching/operators/chunk";
|
||||
|
||||
import { DatasetCollection, SearchParams } from "../../model";
|
||||
import { watchCollectionCache } from "./watchCollectionCache";
|
||||
import { loadCollectionContents } from "./loadCollectionContents";
|
||||
import { ContentProvider, scrollPosEquals, inputsSame } from "../ContentProvider";
|
||||
import { DatasetCollection } from "../../model";
|
||||
import { ContentProvider } from "../ContentProvider";
|
||||
import { processCollectionStreams } from "./processCollectionStreams";
|
||||
|
||||
export default {
|
||||
mixins: [ContentProvider],
|
||||
@@ -37,153 +29,21 @@ export default {
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
dsc(newDsc, oldDsc) {
|
||||
if (!(newDsc.id == oldDsc.id)) {
|
||||
this.resetScrollPos();
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
// prettier-ignore
|
||||
initStreams() {
|
||||
const {
|
||||
debouncePeriod,
|
||||
pageSize,
|
||||
params$,
|
||||
scrollPos$: rawScrollPos$
|
||||
} = this;
|
||||
|
||||
//#region Raw Inputs
|
||||
|
||||
const dsc$ = this.watch$("dsc", true).pipe(
|
||||
distinctUntilChanged(DatasetCollection.equals),
|
||||
tag("CollectionContentProvider-dsc"),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
const distinctParams$ = params$.pipe(
|
||||
distinctUntilChanged(SearchParams.equals),
|
||||
);
|
||||
|
||||
const inputs$ = whenAny(dsc$, distinctParams$).pipe(
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
const totalMatches$ = dsc$.pipe(
|
||||
pluck('totalElements'),
|
||||
tag("CollectionContentProvider-totalMatches"),
|
||||
);
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Scrolling
|
||||
|
||||
const scrolling$ = rawScrollPos$.pipe(
|
||||
activity({ period: debouncePeriod }),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
const scrollStart$ = inputs$.pipe(
|
||||
mapTo({ cursor: 0.0, key: null }),
|
||||
);
|
||||
|
||||
const scrollPos$ = merge(scrollStart$, rawScrollPos$).pipe(
|
||||
distinctUntilChanged(scrollPosEquals),
|
||||
tag("CollectionContentProvider-scrollPos"),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Estimate element index from cursor + dsc
|
||||
|
||||
const calculatedIndex$ = combineLatest([scrollPos$, totalMatches$]).pipe(
|
||||
debounceTime(0),
|
||||
map(([pos, totalMatches]) => this.getIndexFromPos(pos, totalMatches)),
|
||||
tag("CollectionContentProvider-calculatedIndex"),
|
||||
);
|
||||
|
||||
const cursor$ = scrolling$.pipe(
|
||||
switchMap(isScrolling => isScrolling ? NEVER : calculatedIndex$),
|
||||
tag("CollectionContentProvider-cursor"),
|
||||
);
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Loading
|
||||
|
||||
const loadCursor$ = cursor$.pipe(
|
||||
chunk(pageSize),
|
||||
tag('loadCursor'),
|
||||
);
|
||||
|
||||
const loadInputs$ = whenAny(inputs$, loadCursor$).pipe(
|
||||
tag('loadInputs'),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
const loadInputsThrottled$ = scrolling$.pipe(
|
||||
switchMap(isScrolling => isScrolling ? NEVER : loadInputs$),
|
||||
distinctUntilChanged(([inputsA, idxA], [inputsB, idxB]) => {
|
||||
return idxA == idxB && inputsSame(inputsA, inputsB);
|
||||
}),
|
||||
map(([inputs, idx]) => [...inputs, idx]),
|
||||
tag('loadInputsThrottled'),
|
||||
share(),
|
||||
);
|
||||
|
||||
const loader$ = loadInputsThrottled$.pipe(
|
||||
loadCollectionContents({ windowSize: 2 * pageSize }),
|
||||
tag('loader'),
|
||||
share(),
|
||||
);
|
||||
|
||||
const loading$ = merge(loadInputsThrottled$, loader$).pipe(
|
||||
activity({ period: debouncePeriod }),
|
||||
tag('loading'),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Cache watcher
|
||||
|
||||
const cacheCursor$ = cursor$.pipe(
|
||||
tag('cacheCursor'),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
const cacheFromMonitor$ = inputs$.pipe(
|
||||
watchCollectionCache({
|
||||
cursor$: cacheCursor$,
|
||||
pageSize,
|
||||
debouncePeriod
|
||||
}),
|
||||
tag("cacheFromMonitor"),
|
||||
);
|
||||
|
||||
const cache$ = combineLatest([cacheFromMonitor$, totalMatches$]).pipe(
|
||||
debounceTime(0),
|
||||
map((inputs) => this.buildPayload(...inputs)),
|
||||
tag("cache"),
|
||||
);
|
||||
|
||||
//#endregion
|
||||
|
||||
return { scrollPos$, loader$, cache$, scrolling$, loading$ };
|
||||
},
|
||||
|
||||
getIndexFromPos(pos, totalMatches) {
|
||||
const { cursor = null, key = null } = pos;
|
||||
if (key !== null) {
|
||||
return key;
|
||||
}
|
||||
if (cursor !== null) {
|
||||
return Math.round(Number(cursor) * totalMatches);
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
|
||||
buildPayload(result, totalMatches) {
|
||||
// console.log("buildPayload", result, totalMatches);
|
||||
const { contents } = result;
|
||||
const topRows = contents.length ? contents[0].element_index : 0;
|
||||
const bottomRows = Math.max(0, totalMatches - contents.length - topRows);
|
||||
return { ...result, topRows, bottomRows, totalMatches };
|
||||
const { debouncePeriod, pageSize, params$, scrollPos$ } = this;
|
||||
const dsc$ = this.watch$("dsc", true);
|
||||
const sources = { params$, dsc$, scrollPos$ };
|
||||
const settings = { debouncePeriod, pageSize };
|
||||
return processCollectionStreams(sources, settings);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
+2
-5
@@ -1,10 +1,8 @@
|
||||
import { NEVER } from "rxjs";
|
||||
import { createLocalVue } from "@vue/test-utils";
|
||||
import { cacheContent, cacheCollectionContent, bulkCacheDscContent, wipeDatabase } from "../../caching";
|
||||
import { SearchParams } from "../../model/SearchParams";
|
||||
import CollectionContentProvider from "./CollectionContentProvider";
|
||||
import { watchForChange, mountRenderless } from "jest/helpers";
|
||||
import { vueRxShortcutPlugin } from "components/plugins";
|
||||
import { watchForChange, mountRenderless, getLocalVue } from "jest/helpers";
|
||||
|
||||
// sample data
|
||||
import rawRootCollection from "../../test/json/DatasetCollection.json";
|
||||
@@ -28,8 +26,7 @@ loadCollectionContents.mockImplementation((config) => (src$) => {
|
||||
//#endregion
|
||||
|
||||
const mountProvider = async (Component, propsData) => {
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(vueRxShortcutPlugin);
|
||||
const localVue = getLocalVue();
|
||||
const wrapper = mountRenderless(Component, { localVue, propsData });
|
||||
await wrapper.vm.$nextTick();
|
||||
return wrapper;
|
||||
|
||||
+11
-19
@@ -1,25 +1,17 @@
|
||||
import { of, pipe } from "rxjs";
|
||||
import { switchMap, catchError } from "rxjs/operators";
|
||||
import { of } from "rxjs";
|
||||
import { switchMap } from "rxjs/operators";
|
||||
import { tag } from "rxjs-spy/operators/tag";
|
||||
import { loadDscContent } from "../../caching";
|
||||
|
||||
// prettier-ignore
|
||||
export const loadCollectionContents = (cfg = {}) => {
|
||||
const {
|
||||
windowSize = 100
|
||||
} = cfg;
|
||||
const { windowSize = 100 } = cfg;
|
||||
|
||||
return pipe(
|
||||
switchMap(([{contents_url}, params, idx]) => {
|
||||
const singleLoad$ = of([contents_url, params, idx]).pipe(
|
||||
tag('ajaxLoadInputs'),
|
||||
loadDscContent({ windowSize }),
|
||||
);
|
||||
return singleLoad$;
|
||||
}),
|
||||
catchError(err => {
|
||||
console.warn("Error in loadCollectionContents", err);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
// prettier-ignore
|
||||
return switchMap(([dsc, params, idx]) => {
|
||||
const { contents_url } = dsc;
|
||||
return of([contents_url, params, idx]).pipe(
|
||||
tag("ajaxLoadInputs"),
|
||||
loadDscContent({ windowSize })
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import { merge, combineLatest } from "rxjs";
|
||||
import { map, pluck, distinctUntilChanged, share, debounceTime } from "rxjs/operators";
|
||||
import { tag } from "rxjs-spy/operators/tag";
|
||||
import { whenAny } from "utils/observable/whenAny";
|
||||
import { activity } from "utils/observable/activity";
|
||||
import { shareButDie } from "utils/observable/shareButDie";
|
||||
import { chunk } from "../../caching/operators/chunk";
|
||||
|
||||
import { DatasetCollection, SearchParams } from "../../model";
|
||||
import { watchCollectionCache } from "./watchCollectionCache";
|
||||
import { loadCollectionContents } from "./loadCollectionContents";
|
||||
import { loadInputsSame } from "../HistoryContentProvider/processHistoryStreams";
|
||||
|
||||
// prettier-ignore
|
||||
export function processCollectionStreams(sources, settings) {
|
||||
const { debouncePeriod, pageSize } = settings;
|
||||
const { dsc$: rawDsc$, params$: rawParams$, scrollPos$ } = sources;
|
||||
|
||||
//#region Clean up, filter, debounce raw inputs
|
||||
|
||||
const dsc$ = rawDsc$.pipe(distinctUntilChanged(DatasetCollection.equals));
|
||||
const params$ = rawParams$.pipe(distinctUntilChanged(SearchParams.equals));
|
||||
const inputs$ = whenAny(dsc$, params$).pipe(tag('inputs'), share());
|
||||
const scrolling$ = scrollPos$.pipe(activity());
|
||||
const totalMatches$ = dsc$.pipe(pluck("totalElements"));
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Estimate element index from cursor + dsc
|
||||
|
||||
const cursor$ = whenAny(scrollPos$, totalMatches$).pipe(
|
||||
map(([pos, totalMatches]) => getIndexFromPos(pos, totalMatches)),
|
||||
distinctUntilChanged(),
|
||||
tag('cursor'),
|
||||
shareButDie(1),
|
||||
);
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Loading
|
||||
|
||||
const chunkedCursor$ = cursor$.pipe(
|
||||
chunk(pageSize),
|
||||
);
|
||||
|
||||
const loadInputs$ = whenAny(inputs$, chunkedCursor$).pipe(
|
||||
distinctUntilChanged(loadInputsSame),
|
||||
map(([inputs, idx]) => [...inputs, idx]),
|
||||
tag('loadInputs'),
|
||||
shareButDie(1)
|
||||
);
|
||||
|
||||
const loadResult$ = loadInputs$.pipe(
|
||||
loadCollectionContents({ windowSize: 2 * pageSize }),
|
||||
tag('loadResult'),
|
||||
);
|
||||
|
||||
const loading$ = merge(loadInputs$, loadResult$).pipe(
|
||||
activity(),
|
||||
);
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Cache watcher
|
||||
|
||||
const cacheFromMonitor$ = inputs$.pipe(
|
||||
watchCollectionCache({
|
||||
cursor$,
|
||||
pageSize,
|
||||
debouncePeriod,
|
||||
}),
|
||||
tag('cacheFromMonitor'),
|
||||
);
|
||||
|
||||
const payload$ = combineLatest(cacheFromMonitor$, totalMatches$).pipe(
|
||||
debounceTime(debouncePeriod),
|
||||
map((inputs) => buildPayload(...inputs)),
|
||||
tag('payload'),
|
||||
);
|
||||
|
||||
//#endregion
|
||||
|
||||
return { payload$, scrolling$, loading$ };
|
||||
}
|
||||
|
||||
function getIndexFromPos(pos, totalMatches) {
|
||||
const { cursor = null, key = null } = pos;
|
||||
if (key !== null) {
|
||||
return key;
|
||||
}
|
||||
if (cursor !== null) {
|
||||
return Math.round(Number(cursor) * totalMatches);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function buildPayload(result, totalMatches) {
|
||||
const { contents } = result;
|
||||
const topRows = contents.length ? contents[0].element_index : 0;
|
||||
const bottomRows = Math.max(0, totalMatches - contents.length - topRows);
|
||||
return { ...result, topRows, bottomRows, totalMatches };
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
import Vue from "vue";
|
||||
import { vueRxShortcutPlugin } from "components/plugins";
|
||||
import { NEVER, BehaviorSubject } from "rxjs";
|
||||
import { SearchParams } from "../model/SearchParams";
|
||||
|
||||
Vue.use(vueRxShortcutPlugin);
|
||||
|
||||
// dumb math util
|
||||
export const clamp = (val, [bottom, top]) => Math.max(bottom, Math.min(top, val));
|
||||
|
||||
@@ -55,32 +51,31 @@ export const ContentProvider = {
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
params(newParams, oldParams) {
|
||||
if (!SearchParams.equals(newParams, oldParams)) {
|
||||
this.resetScrollPos();
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
this.initParams();
|
||||
this.initScrollPos();
|
||||
|
||||
const { cache$, loader$, loading$, scrolling$ } = this.initStreams();
|
||||
const { payload$, loading$, scrolling$ } = this.initStreams();
|
||||
|
||||
this.listenTo(scrolling$, (val) => (this.scrolling = val));
|
||||
this.listenTo(loading$, (val) => (this.loading = val));
|
||||
|
||||
// render output
|
||||
if (!this.disableWatch) {
|
||||
this.listenTo(cache$, {
|
||||
this.listenTo(payload$, {
|
||||
next: (payload) => this.setPayload(payload),
|
||||
error: (err) => console.warn("error in cache$", err),
|
||||
complete: () => console.warn("why did cache$ complete?"),
|
||||
});
|
||||
}
|
||||
|
||||
// keep sub to loader which popualates the cache
|
||||
if (!this.disableLoad) {
|
||||
this.listenTo(loader$, {
|
||||
// next: (result) => console.log("loader$ result", result),
|
||||
error: (err) => console.warn("error in loader$", err),
|
||||
complete: () => console.warn("why did loader$ complete?"),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
@@ -93,6 +88,10 @@ export const ContentProvider = {
|
||||
this.scrollPos$ = new BehaviorSubject({ cursor: 0.0, key: null });
|
||||
},
|
||||
|
||||
resetScrollPos() {
|
||||
this.scrollPos$.next({ cursor: 0.0, key: null });
|
||||
},
|
||||
|
||||
initStreams() {
|
||||
console.warn("Override initStreams in ContentProvider");
|
||||
return { cache$: NEVER, loader$: NEVER, loading$: NEVER, scrolling$: NEVER };
|
||||
@@ -137,6 +136,7 @@ export const ContentProvider = {
|
||||
|
||||
render() {
|
||||
return this.$scopedSlots.default({
|
||||
// actual content delivery
|
||||
payload: this.payload,
|
||||
|
||||
// local vars/settings/props passthrough
|
||||
@@ -147,9 +147,11 @@ export const ContentProvider = {
|
||||
pageSize: this.pageSize,
|
||||
|
||||
// update methods
|
||||
updateParams: this.updateParams,
|
||||
setScrollPos: this.setScrollPos,
|
||||
manualReload: this.manualReload,
|
||||
handlers: {
|
||||
updateParams: this.updateParams,
|
||||
setScrollPos: this.setScrollPos,
|
||||
manualReload: this.manualReload,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
/* eslint-disable no-unused-vars */
|
||||
import { createLocalVue } from "@vue/test-utils";
|
||||
import { wait, mountRenderless } from "jest/helpers";
|
||||
import { wait, mountRenderless, getLocalVue } from "jest/helpers";
|
||||
import { wipeDatabase } from "../../caching";
|
||||
import { cacheContent, getCachedContent, cacheCollectionContent, getCachedCollectionContent } from "../../caching";
|
||||
import { DatasetCollection } from "../../model/DatasetCollection";
|
||||
import DscProvider from "./DscProvider";
|
||||
import { vueRxShortcutPlugin } from "components/plugins";
|
||||
|
||||
// test data as root-level collections
|
||||
import rootContents from "../../test/json/historyContentWithCollection.json";
|
||||
@@ -18,8 +16,7 @@ jest.mock("app");
|
||||
jest.mock("../../caching");
|
||||
|
||||
const mountProvider = async (Component, propsData) => {
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(vueRxShortcutPlugin);
|
||||
const localVue = getLocalVue();
|
||||
const wrapper = mountRenderless(Component, { localVue, propsData });
|
||||
await wrapper.vm.$nextTick();
|
||||
return wrapper;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
export default {
|
||||
props: {
|
||||
scopeKey: { type: String, required: true },
|
||||
getItemKey: { type: Function, required: true },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
items: new Set(),
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
isExpanded(item) {
|
||||
const key = this.getItemKey(item);
|
||||
return this.items.has(key);
|
||||
},
|
||||
setExpanded(item, val) {
|
||||
const key = this.getItemKey(item);
|
||||
const newSet = new Set(this.items);
|
||||
val ? newSet.add(key) : newSet.delete(key);
|
||||
this.items = newSet;
|
||||
},
|
||||
reset() {
|
||||
this.items = new Set();
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
scopeKey(newKey, oldKey) {
|
||||
if (newKey !== oldKey) {
|
||||
this.reset();
|
||||
}
|
||||
},
|
||||
},
|
||||
render() {
|
||||
return this.$scopedSlots.default({
|
||||
isExpanded: this.isExpanded,
|
||||
setExpanded: this.setExpanded,
|
||||
});
|
||||
},
|
||||
};
|
||||
+15
-327
@@ -1,25 +1,5 @@
|
||||
import { combineLatest, merge, NEVER, Subject } from "rxjs";
|
||||
import {
|
||||
tap,
|
||||
pluck,
|
||||
map,
|
||||
mapTo,
|
||||
switchMap,
|
||||
distinctUntilChanged,
|
||||
shareReplay,
|
||||
withLatestFrom,
|
||||
debounceTime,
|
||||
share,
|
||||
} from "rxjs/operators";
|
||||
import { tag } from "rxjs-spy/operators/tag";
|
||||
import { activity } from "utils/observable/activity";
|
||||
import { whenAny } from "utils/observable/whenAny";
|
||||
import { chunk } from "../../caching/operators/chunk";
|
||||
|
||||
import { History, SearchParams, CurveFit } from "../../model";
|
||||
import { watchHistoryContents } from "./watchHistoryContents";
|
||||
import { loadContents } from "./loadContents";
|
||||
import { ContentProvider, isValidNumber, scrollPosEquals, inputsSame } from "../ContentProvider";
|
||||
import { ContentProvider } from "../ContentProvider";
|
||||
import { processHistoryStreams } from "./processHistoryStreams";
|
||||
|
||||
export default {
|
||||
mixins: [ContentProvider],
|
||||
@@ -30,317 +10,25 @@ export default {
|
||||
|
||||
computed: {
|
||||
history() {
|
||||
if (this.parent instanceof History) {
|
||||
return this.parent;
|
||||
return this.parent;
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
history(newHistory, oldHistory) {
|
||||
if (!(newHistory.id == oldHistory.id)) {
|
||||
this.resetScrollPos();
|
||||
}
|
||||
return new History(this.parent);
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
// prettier-ignore
|
||||
initStreams() {
|
||||
const {
|
||||
disablePoll,
|
||||
debouncePeriod,
|
||||
pageSize,
|
||||
params$,
|
||||
scrollPos$: rawScrollPos$
|
||||
} = this;
|
||||
|
||||
//#region Raw Inputs
|
||||
|
||||
const history$ = this.watch$("history", true).pipe(
|
||||
distinctUntilChanged(History.equals),
|
||||
);
|
||||
|
||||
const distinctParams$ = params$.pipe(
|
||||
distinctUntilChanged(SearchParams.equals),
|
||||
);
|
||||
|
||||
const inputs$ = whenAny(history$, distinctParams$).pipe(
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Total Matches, from history and returned ajax stats
|
||||
|
||||
const serverTotalMatches$ = new Subject();
|
||||
|
||||
const historyMatches$ = history$.pipe(
|
||||
pluck('hidItems')
|
||||
);
|
||||
|
||||
const totalMatches$ = merge(historyMatches$, serverTotalMatches$).pipe(
|
||||
shareReplay(1)
|
||||
);
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Scrolling
|
||||
|
||||
const scrolling$ = rawScrollPos$.pipe(
|
||||
activity({ period: debouncePeriod }),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
const scrollStart$ = inputs$.pipe(
|
||||
mapTo({ cursor: 0.0, key: null }),
|
||||
);
|
||||
|
||||
const scrollPos$ = merge(scrollStart$, rawScrollPos$).pipe(
|
||||
distinctUntilChanged(scrollPosEquals),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Curve-fit objects, used to estimate hid & topRows
|
||||
|
||||
const updateCursorToHid$ = new Subject();
|
||||
|
||||
const freshCursorToHid$ = inputs$.pipe(
|
||||
map(([history]) => {
|
||||
const fit = new CurveFit();
|
||||
fit.domain = [0.0, 1.0];
|
||||
fit.xPrecision = 3;
|
||||
fit.yPrecision = 2;
|
||||
fit.set(0.0, history.hidItems);
|
||||
fit.set(1.0, 1);
|
||||
return fit;
|
||||
}),
|
||||
);
|
||||
|
||||
const cursorToHid$ = merge(freshCursorToHid$, updateCursorToHid$).pipe(
|
||||
shareReplay(1)
|
||||
);
|
||||
|
||||
const updateHidToTopRows$ = new Subject();
|
||||
|
||||
const freshHidToTopRows$ = inputs$.pipe(
|
||||
map(([history]) => {
|
||||
const fit = new CurveFit();
|
||||
fit.domain = [0.0, +history.hidItems];
|
||||
fit.xPrecision = 3;
|
||||
fit.yPrecision = 3;
|
||||
fit.set(history.hidItems, 0.0);
|
||||
fit.set(0.0, history.hidItems);
|
||||
return fit;
|
||||
}),
|
||||
);
|
||||
|
||||
const hidToTopRows$ = merge(freshHidToTopRows$, updateHidToTopRows$).pipe(
|
||||
shareReplay(1)
|
||||
);
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Estimate HID from cursor + history + curveFit
|
||||
|
||||
const hid$ = whenAny(scrollPos$, cursorToHid$).pipe(
|
||||
map(([pos, fit]) => {
|
||||
const hid = pos.key ? pos.key : this.estimateHid(pos, fit);
|
||||
return parseInt(hid);
|
||||
}),
|
||||
// tag('hid'),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Loading
|
||||
|
||||
const loadCursor$ = hid$.pipe(
|
||||
chunk(pageSize, true),
|
||||
);
|
||||
|
||||
const loadInputs$ = whenAny(inputs$, loadCursor$);
|
||||
|
||||
const loadInputsThrottled$ = scrolling$.pipe(
|
||||
switchMap(isScrolling => isScrolling ? NEVER : loadInputs$),
|
||||
distinctUntilChanged(([inputsA, hidA], [inputsB, hidB]) => {
|
||||
return hidA == hidB && inputsSame(inputsA, inputsB);
|
||||
}),
|
||||
map(([inputs, hid]) => [...inputs, hid]),
|
||||
debounceTime(debouncePeriod),
|
||||
tag('loadInputsThrottled'),
|
||||
share(),
|
||||
);
|
||||
|
||||
const loadContent$ = loadInputsThrottled$.pipe(
|
||||
loadContents({ disablePoll, windowSize: 2 * pageSize }),
|
||||
tag('loadContent'),
|
||||
);
|
||||
|
||||
const loader$ = loadContent$.pipe(
|
||||
withLatestFrom(cursorToHid$, hidToTopRows$),
|
||||
tap(([result, cursorToHid, hidToTopRows]) => {
|
||||
this.adjustCursorToHid(result, cursorToHid);
|
||||
const newHidToTopRows = this.adjustHidToTopRows(result, hidToTopRows);
|
||||
const newFit = newHidToTopRows.clone();
|
||||
updateHidToTopRows$.next(newFit);
|
||||
const { totalMatches } = result;
|
||||
serverTotalMatches$.next(totalMatches);
|
||||
}),
|
||||
share(),
|
||||
);
|
||||
|
||||
const loading$ = merge(loadInputsThrottled$, loader$).pipe(
|
||||
activity({ period: debouncePeriod }),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Caching
|
||||
|
||||
const cacheHid$ = hid$.pipe(
|
||||
debounceTime(debouncePeriod),
|
||||
tag('cacheHid'),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
const cacheFromMonitor$ = inputs$.pipe(
|
||||
watchHistoryContents({
|
||||
hid$: cacheHid$,
|
||||
pageSize,
|
||||
debouncePeriod
|
||||
}),
|
||||
tag('cacheFromMonitor'),
|
||||
);
|
||||
|
||||
const cacheBuildInputs$ = combineLatest([
|
||||
cacheFromMonitor$,
|
||||
hidToTopRows$,
|
||||
totalMatches$,
|
||||
]).pipe(
|
||||
debounceTime(2 * debouncePeriod),
|
||||
tag('cacheBuildInputs'),
|
||||
);
|
||||
|
||||
const cache$ = cacheBuildInputs$.pipe(
|
||||
map((inputs) => this.buildPayload(...inputs)),
|
||||
tag('cache'),
|
||||
share(),
|
||||
);
|
||||
|
||||
//#endregion
|
||||
|
||||
return { scrollPos$, loader$, cache$, scrolling$, loading$ };
|
||||
},
|
||||
|
||||
/**
|
||||
* Calculate or estimate hid from cursor position.
|
||||
* @param {Object} scrollPos { cursor, key }
|
||||
* @param {CurveFit} fit
|
||||
*/
|
||||
estimateHid(scrollPos, cursorToHid) {
|
||||
const { cursor, key } = scrollPos;
|
||||
const fit = cursorToHid;
|
||||
|
||||
// do an estimate/retrieval
|
||||
const result = fit.get(cursor, { interpolate: true });
|
||||
if (isValidNumber(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// give them the top of the data
|
||||
if (fit.hasData) {
|
||||
console.log("invalid curve fit", result, cursor, key, fit.domain);
|
||||
return fit.get(0);
|
||||
}
|
||||
|
||||
// we're screwed
|
||||
return undefined;
|
||||
},
|
||||
|
||||
/**
|
||||
* Assemble payload variables from cache result, need to calculate
|
||||
* topRows and bottomRows from the curveFit
|
||||
* @param {*} result Cache obsevable emission
|
||||
* @param {*} hidToTopRows topRows(hid) curveFit
|
||||
* @param {*} totalMatches Total number of matches in current history/params
|
||||
*/
|
||||
buildPayload(result, hidToTopRows, totalMatches) {
|
||||
const { pageSize } = this;
|
||||
const { contents = [], startKey = null, targetKey } = result;
|
||||
const fit = hidToTopRows;
|
||||
|
||||
let topRows = 0;
|
||||
let bottomRows = Math.max(0, totalMatches - contents.length);
|
||||
|
||||
// missing rows above & below content
|
||||
if (fit.size && contents.length && totalMatches > pageSize) {
|
||||
const maxHid = contents[0].hid;
|
||||
const rowsAboveTop = fit.get(maxHid, { interpolate: true });
|
||||
topRows = Math.max(0, Math.round(rowsAboveTop));
|
||||
bottomRows = Math.max(0, totalMatches - topRows - contents.length);
|
||||
}
|
||||
|
||||
return { contents, startKey, targetKey, topRows, bottomRows, totalMatches };
|
||||
},
|
||||
|
||||
/**
|
||||
* Update curve fit after an ajax load.
|
||||
* @param {Object} result Ajax load result
|
||||
* @param {CurveFit} fit hidToTopRows
|
||||
*/
|
||||
adjustHidToTopRows(result, fit) {
|
||||
const {
|
||||
// min and max of returned data
|
||||
minContentHid,
|
||||
maxContentHid,
|
||||
|
||||
// endpoints for all of history, that match filters
|
||||
minHid,
|
||||
maxHid,
|
||||
|
||||
// num matches up/down from request hid
|
||||
matchesUp,
|
||||
matchesDown,
|
||||
matches,
|
||||
|
||||
// total matches up/down from request hid
|
||||
totalMatchesUp,
|
||||
totalMatches,
|
||||
} = result;
|
||||
|
||||
if (matches > 0) {
|
||||
fit.set(maxContentHid, totalMatchesUp - matchesUp);
|
||||
fit.set(minContentHid, Math.max(0, totalMatchesUp + matchesDown - 1));
|
||||
}
|
||||
|
||||
fit.set(maxHid, 0);
|
||||
if (totalMatches > 0) {
|
||||
fit.set(minHid, totalMatches - 1);
|
||||
}
|
||||
|
||||
fit.domain = [minHid, maxHid];
|
||||
|
||||
return fit;
|
||||
},
|
||||
|
||||
/**
|
||||
* Calculate cursor values for edges of returned values and insert into
|
||||
* the datapoints of the curveFit object
|
||||
* @param {Object} result Ajax load result
|
||||
* @param {CurveFit} cursorToHid
|
||||
*/
|
||||
adjustCursorToHid(result, fit) {
|
||||
const { maxHid, minHid, maxContentHid, minContentHid, matches, totalMatchesUp, totalMatches } = result;
|
||||
|
||||
// set 0, 1 for min/max hids
|
||||
fit.set(0, maxHid);
|
||||
fit.set(1, minHid);
|
||||
|
||||
// find cursor for top & bottom of returned content
|
||||
if (totalMatches > 0) {
|
||||
fit.set(totalMatchesUp / totalMatches, maxContentHid);
|
||||
fit.set((matches + totalMatchesUp) / totalMatches, minContentHid);
|
||||
}
|
||||
|
||||
return fit;
|
||||
const { disablePoll, debouncePeriod, pageSize, params$, scrollPos$ } = this;
|
||||
const history$ = this.watch$("history");
|
||||
const sources = { params$, history$, scrollPos$ };
|
||||
const settings = { disablePoll, debouncePeriod, pageSize };
|
||||
return processHistoryStreams(sources, settings);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
+2
-4
@@ -1,13 +1,12 @@
|
||||
import { pipe } from "rxjs";
|
||||
import { map } from "rxjs/operators";
|
||||
import { createLocalVue } from "@vue/test-utils";
|
||||
import { History } from "../../model/History";
|
||||
import { SearchParams } from "../../model/SearchParams";
|
||||
import HistoryContentProvider from "./HistoryContentProvider";
|
||||
import { bulkCacheContent, wipeDatabase } from "../../caching";
|
||||
import { getPropRange } from "../../caching/loadHistoryContents";
|
||||
import { watchForChange, mountRenderless } from "jest/helpers";
|
||||
import { vueRxShortcutPlugin } from "components/plugins";
|
||||
import { getLocalVue } from "jest/helpers";
|
||||
|
||||
//#region Test Data
|
||||
|
||||
@@ -65,8 +64,7 @@ const payloadChange = async ({ vm, label = "payload change" }) => {
|
||||
};
|
||||
|
||||
const mountProvider = async (Component, propsData) => {
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(vueRxShortcutPlugin);
|
||||
const localVue = getLocalVue();
|
||||
const wrapper = mountRenderless(Component, { localVue, propsData });
|
||||
await wrapper.vm.$nextTick();
|
||||
return wrapper;
|
||||
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
import { combineLatest, merge, Subject } from "rxjs";
|
||||
import { tap, pluck, map, distinctUntilChanged, withLatestFrom, debounceTime, share } from "rxjs/operators";
|
||||
import { tag } from "rxjs-spy/operators/tag";
|
||||
import { whenAny } from "utils/observable/whenAny";
|
||||
import { activity } from "utils/observable/activity";
|
||||
import { shareButDie } from "utils/observable/shareButDie";
|
||||
import { chunk } from "../../caching/operators/chunk";
|
||||
import { nth } from "utils/observable/nth";
|
||||
|
||||
import { SearchParams, CurveFit } from "../../model";
|
||||
import { watchHistoryContents } from "./watchHistoryContents";
|
||||
import { loadContents } from "./loadContents";
|
||||
import { isValidNumber, inputsSame } from "../ContentProvider";
|
||||
|
||||
// inputs + hid/cursor comparison
|
||||
export const loadInputsSame = ([inputsA, cursorA], [inputsB, cursorB]) => {
|
||||
return cursorA == cursorB && inputsSame(inputsA, inputsB);
|
||||
};
|
||||
|
||||
// prettier-ignore
|
||||
export function processHistoryStreams(sources, settings) {
|
||||
const { disablePoll, debouncePeriod, pageSize } = settings;
|
||||
const { params$: rawParams$, history$: rawHistory$, scrollPos$ } = sources;
|
||||
|
||||
//#region Clean up, filter, debounce raw inputs
|
||||
|
||||
const history$ = rawHistory$.pipe(distinctUntilChanged((a, b) => a.id == b.id));
|
||||
const params$ = rawParams$.pipe(distinctUntilChanged(SearchParams.equals));
|
||||
const inputs$ = whenAny(history$, params$).pipe(share());
|
||||
const scrolling$ = scrollPos$.pipe(activity());
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Total Matches, from history and returned ajax stats
|
||||
|
||||
const serverTotalMatches$ = new Subject();
|
||||
const historyMatches$ = history$.pipe(pluck("hidItems"));
|
||||
const totalMatches$ = merge(historyMatches$, serverTotalMatches$).pipe(distinctUntilChanged());
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Curve-fit objects, used to estimate hid & topRows
|
||||
|
||||
const updateCursorToHid$ = new Subject();
|
||||
const updateHidToTopRows$ = new Subject();
|
||||
|
||||
// reset curve-fit data whenever inputs change
|
||||
// same as history but emits each time inputs emits
|
||||
const h$ = inputs$.pipe(nth(0));
|
||||
const freshCursorToHid$ = h$.pipe(map(createCursorToHid));
|
||||
const freshHidToTopRows$ = h$.pipe(map(createHidToTopRows));
|
||||
|
||||
// merge create + manual updates
|
||||
const cursorToHid$ = merge(freshCursorToHid$, updateCursorToHid$).pipe(shareButDie(1));
|
||||
const hidToTopRows$ = merge(freshHidToTopRows$, updateHidToTopRows$).pipe(shareButDie(1));
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Estimate HID from cursor + history + curveFit
|
||||
|
||||
const hid$ = whenAny(scrollPos$, cursorToHid$).pipe(
|
||||
map(([pos, fit]) => (pos.key ? pos.key : estimateHid(pos, fit))),
|
||||
map((hid) => parseInt(hid)),
|
||||
distinctUntilChanged(),
|
||||
tag('hid'),
|
||||
shareButDie(1)
|
||||
);
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Loading
|
||||
|
||||
const chunkedHid$ = hid$.pipe(chunk(pageSize, true));
|
||||
|
||||
const loadInput$ = whenAny(inputs$, chunkedHid$).pipe(
|
||||
distinctUntilChanged(loadInputsSame),
|
||||
map(([inputs, hid]) => [...inputs, hid]),
|
||||
shareButDie(1)
|
||||
);
|
||||
|
||||
const loadResult$ = loadInput$.pipe(
|
||||
loadContents({ disablePoll, windowSize: 2 * pageSize })
|
||||
);
|
||||
|
||||
// side-effects of loading
|
||||
const loadEffects$ = loadResult$.pipe(
|
||||
withLatestFrom(cursorToHid$, hidToTopRows$),
|
||||
tap(([result, cursorToHid, hidToTopRows]) => {
|
||||
// can result in infinite loops, adjust curve-data by reference
|
||||
adjustCursorToHid(result, cursorToHid);
|
||||
adjustHidToTopRows(result, hidToTopRows);
|
||||
// updateHidToTopRows$.next();
|
||||
serverTotalMatches$.next(result.totalMatches);
|
||||
})
|
||||
);
|
||||
|
||||
// loading flag
|
||||
const loading$ = merge(loadInput$, loadEffects$).pipe(activity());
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Use inputs to read from cache
|
||||
|
||||
const cacheFromMonitor$ = inputs$.pipe(
|
||||
watchHistoryContents({
|
||||
hid$,
|
||||
pageSize,
|
||||
debouncePeriod,
|
||||
})
|
||||
);
|
||||
|
||||
const payload$ = combineLatest(cacheFromMonitor$, hidToTopRows$, totalMatches$).pipe(
|
||||
debounceTime(debouncePeriod),
|
||||
map((inputs) => buildPayload(...inputs, pageSize))
|
||||
);
|
||||
|
||||
//#endregion
|
||||
|
||||
return { payload$, scrolling$, loading$ };
|
||||
}
|
||||
|
||||
function estimateHid(scrollPos, cursorToHid) {
|
||||
const { cursor, key } = scrollPos;
|
||||
const fit = cursorToHid;
|
||||
|
||||
// do an estimate/retrieval
|
||||
const result = fit.get(cursor, { interpolate: true });
|
||||
if (isValidNumber(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// give them the top of the data
|
||||
if (fit.hasData) {
|
||||
console.log("invalid curve fit", result, cursor, key, fit.domain);
|
||||
return fit.get(0);
|
||||
}
|
||||
|
||||
// we're screwed
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function adjustCursorToHid(result, fit) {
|
||||
const { maxHid, minHid, maxContentHid, minContentHid, matches, totalMatchesUp, totalMatches } = result;
|
||||
|
||||
// set 0, 1 for min/max hids
|
||||
fit.set(0, maxHid);
|
||||
fit.set(1, minHid);
|
||||
|
||||
// find cursor for top & bottom of returned content
|
||||
if (totalMatches > 0) {
|
||||
fit.set(totalMatchesUp / totalMatches, maxContentHid);
|
||||
fit.set((matches + totalMatchesUp) / totalMatches, minContentHid);
|
||||
}
|
||||
}
|
||||
|
||||
function adjustHidToTopRows(result, fit) {
|
||||
const {
|
||||
// min and max of returned data
|
||||
minContentHid,
|
||||
maxContentHid,
|
||||
|
||||
// endpoints for all of history, that match filters
|
||||
minHid,
|
||||
maxHid,
|
||||
|
||||
// num matches up/down from request hid
|
||||
matchesUp,
|
||||
matchesDown,
|
||||
matches,
|
||||
|
||||
// total matches up/down from request hid
|
||||
totalMatchesUp,
|
||||
totalMatches,
|
||||
} = result;
|
||||
|
||||
if (matches > 0) {
|
||||
fit.set(maxContentHid, totalMatchesUp - matchesUp);
|
||||
fit.set(minContentHid, Math.max(0, totalMatchesUp + matchesDown - 1));
|
||||
}
|
||||
|
||||
fit.set(maxHid, 0);
|
||||
if (totalMatches > 0) {
|
||||
fit.set(minHid, totalMatches - 1);
|
||||
}
|
||||
|
||||
fit.domain = [minHid, maxHid];
|
||||
}
|
||||
|
||||
function buildPayload(result, hidToTopRows, totalMatches, pageSize) {
|
||||
const { contents = [], startKey = null, targetKey } = result;
|
||||
const fit = hidToTopRows;
|
||||
|
||||
let topRows = 0;
|
||||
let bottomRows = Math.max(0, totalMatches - contents.length);
|
||||
// missing rows above & below content
|
||||
if (fit.size && contents.length && totalMatches > pageSize) {
|
||||
const maxHid = contents[0].hid;
|
||||
const rowsAboveTop = fit.get(maxHid, { interpolate: true });
|
||||
topRows = Math.max(0, Math.round(rowsAboveTop));
|
||||
bottomRows = Math.max(0, totalMatches - topRows - contents.length);
|
||||
}
|
||||
|
||||
return { contents, startKey, targetKey, topRows, bottomRows, totalMatches };
|
||||
}
|
||||
|
||||
function createHidToTopRows(history) {
|
||||
const fit = new CurveFit();
|
||||
fit.domain = [0.0, +history.hidItems];
|
||||
fit.xPrecision = 3;
|
||||
fit.yPrecision = 3;
|
||||
fit.set(history.hidItems, 0.0);
|
||||
fit.set(0.0, history.hidItems);
|
||||
return fit;
|
||||
}
|
||||
|
||||
function createCursorToHid(history) {
|
||||
const fit = new CurveFit();
|
||||
fit.domain = [0.0, 1.0];
|
||||
fit.xPrecision = 3;
|
||||
fit.yPrecision = 2;
|
||||
fit.set(0.0, history.hidItems);
|
||||
fit.set(1.0, 1);
|
||||
return fit;
|
||||
}
|
||||
+5
-8
@@ -1,11 +1,13 @@
|
||||
import { of, combineLatest } from "rxjs";
|
||||
import { map, switchMap, debounceTime, scan, catchError, distinctUntilChanged } from "rxjs/operators";
|
||||
import { tag } from "rxjs-spy/operators/tag";
|
||||
import { map, switchMap, debounceTime, scan, distinctUntilChanged } from "rxjs/operators";
|
||||
import { chunk } from "../../caching/operators/chunk";
|
||||
import { monitorHistoryContent } from "../../caching";
|
||||
import { SearchParams } from "../../model/SearchParams";
|
||||
import { processContentUpdate, newUpdateMap, buildContentResult, getKeyForUpdateMap } from "../aggregation";
|
||||
|
||||
// disable debugging
|
||||
const tag = () => (src$) => src$;
|
||||
|
||||
/**
|
||||
* Monitor history in the region of the cursor for the provided inputs
|
||||
*
|
||||
@@ -71,10 +73,5 @@ export const watchHistoryContents = (cfg = {}) => input$ => {
|
||||
tag('watchHistoryContents-contentWindow'),
|
||||
);
|
||||
|
||||
return contentWindow$.pipe(
|
||||
catchError(err => {
|
||||
console.warn("Error in watchHistoryContents", err);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
return contentWindow$;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* This is simple list management for the monent, but if I remember right we have plans for a more
|
||||
* abstracted selection scheme in the future for purposes of handling large bulk operations on the
|
||||
* server, this is a good place to implement that logic.
|
||||
*/
|
||||
|
||||
export default {
|
||||
props: {
|
||||
scopeKey: { type: String, required: true },
|
||||
getItemKey: { type: Function, required: true },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
items: new Map(),
|
||||
showSelection: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
selectionSize() {
|
||||
return this.items.size;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
setShowSelection(val) {
|
||||
this.showSelection = val;
|
||||
},
|
||||
isSelected(item) {
|
||||
const key = this.getItemKey(item);
|
||||
return this.items.has(key);
|
||||
},
|
||||
setSelected(item, bSelected) {
|
||||
const key = this.getItemKey(item);
|
||||
const newSelected = new Map(this.items);
|
||||
bSelected ? newSelected.set(key, item) : newSelected.delete(key);
|
||||
this.items = newSelected;
|
||||
},
|
||||
selectItems(items = []) {
|
||||
const newItems = [...this.items.values(), ...items];
|
||||
const newEntries = newItems.map((item) => {
|
||||
const key = this.getItemKey(item);
|
||||
return [key, item];
|
||||
});
|
||||
this.items = new Map(newEntries);
|
||||
},
|
||||
// unselectItems(items = []) {
|
||||
// const doomedKeys = items.map((item) => this.getItemKey(item));
|
||||
// const existing = this.items.entries();
|
||||
// const newEntries = Array.from(existing).filter(([key]) => {
|
||||
// return !doomedKeys.includes(key);
|
||||
// });
|
||||
// this.items = new Map(newEntries);
|
||||
// },
|
||||
reset() {
|
||||
this.items = new Map();
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
scopeKey(newKey, oldKey) {
|
||||
if (newKey !== oldKey) {
|
||||
this.reset();
|
||||
}
|
||||
},
|
||||
selectionSize(newSize) {
|
||||
if (newSize > 0) {
|
||||
this.showSelection = true;
|
||||
}
|
||||
},
|
||||
showSelection(newVal) {
|
||||
if (!newVal) {
|
||||
this.reset();
|
||||
}
|
||||
},
|
||||
},
|
||||
render() {
|
||||
return this.$scopedSlots.default({
|
||||
selectedItems: this.items,
|
||||
showSelection: this.showSelection,
|
||||
setShowSelection: this.setShowSelection,
|
||||
selectItems: this.selectItems,
|
||||
// unselectItems: this.unselectItems,
|
||||
isSelected: this.isSelected,
|
||||
setSelected: this.setSelected,
|
||||
resetSelection: this.reset,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* Replacement for skiplist, aggregates history contents as updates come in,
|
||||
* then sorts the keys of the map on demand. Also can find the closest map key
|
||||
* to a given target value (cursor).
|
||||
*/
|
||||
|
||||
export class UpdateMap extends Map {
|
||||
sortKeys(direction) {
|
||||
const entries = Array.from(this.entries());
|
||||
if (entries.length == 0) return;
|
||||
const intDirection = direction == "asc" ? 1 : -1;
|
||||
const sortFn = (a, b) => intDirection * (a[0] - b[0]);
|
||||
const newContents = entries.sort(sortFn);
|
||||
this.replaceEntries(newContents);
|
||||
}
|
||||
|
||||
replaceEntries(entries) {
|
||||
this.clear();
|
||||
for (const [key, val] of entries) {
|
||||
this.set(key, val);
|
||||
}
|
||||
}
|
||||
|
||||
findClosestKey(target) {
|
||||
let minDist = Infinity;
|
||||
let closestKey = null;
|
||||
let closestIndex = null;
|
||||
let index = 0;
|
||||
|
||||
for (const [key] of this) {
|
||||
// This is an exact match. We're done here.
|
||||
if (key == target) {
|
||||
closestKey = key;
|
||||
closestIndex = index;
|
||||
break;
|
||||
}
|
||||
|
||||
// How far is this key from our target cursor?
|
||||
const dist = Math.abs(key - target);
|
||||
|
||||
// because the incoming sourceMap is pre-sorted by key we can stop looping
|
||||
// when the distance is getting larger, this means we're moving further
|
||||
// away from the right answer which we already found
|
||||
if (dist > minDist) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (dist < minDist) {
|
||||
minDist = dist;
|
||||
closestKey = key;
|
||||
closestIndex = index;
|
||||
}
|
||||
|
||||
index++;
|
||||
}
|
||||
|
||||
return { key: closestKey, index: closestIndex };
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
// Replacement for Skiplist, hoping native implementation will be faster
|
||||
import { UpdateMap } from "./UpdateMap";
|
||||
|
||||
describe("UpdateMap", () => {
|
||||
let content;
|
||||
beforeEach(() => {
|
||||
content = new UpdateMap();
|
||||
content.set(100, "a");
|
||||
content.set(5, "a");
|
||||
content.set(4, "b");
|
||||
content.set(1, "e");
|
||||
content.set(3, "c");
|
||||
content.set(2, "d");
|
||||
});
|
||||
|
||||
describe("sortMap", () => {
|
||||
test("it should sort a map in ascending key order", () => {
|
||||
content.sortKeys("asc");
|
||||
expect(Array.from(content.keys())).toEqual([1, 2, 3, 4, 5, 100]);
|
||||
});
|
||||
|
||||
test("it should sort a map in descending key order", () => {
|
||||
content.sortKeys("desc");
|
||||
expect(Array.from(content.keys())).toEqual([100, 5, 4, 3, 2, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findClosestKey", () => {
|
||||
beforeEach(() => content.sortKeys("desc"));
|
||||
|
||||
test("it should find an exact match", () => {
|
||||
const { key, index } = content.findClosestKey(3);
|
||||
expect(key).toEqual(3);
|
||||
expect(index).toEqual(3);
|
||||
});
|
||||
|
||||
test("it should find the closest key if not an exact match (closest less than target)", () => {
|
||||
const { key, index } = content.findClosestKey(8);
|
||||
expect(key).toEqual(5);
|
||||
expect(index).toEqual(1);
|
||||
});
|
||||
|
||||
test("it should find the closest key if not exact match (closest greater than target)", () => {
|
||||
const { key, index } = content.findClosestKey(80);
|
||||
expect(key).toEqual(100);
|
||||
expect(index).toEqual(0);
|
||||
});
|
||||
|
||||
test("should return nulls for an empty map", () => {
|
||||
const empty = new UpdateMap();
|
||||
const { key, index } = empty.findClosestKey(100);
|
||||
expect(key).toBe(null);
|
||||
expect(index).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
const MockCurrentHistory = (currentHistory = { id: "xyz" }) => ({
|
||||
render() {
|
||||
return this.$scopedSlots.default({
|
||||
currentHistory: currentHistory,
|
||||
currentHistoryId: currentHistory.id,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default MockCurrentHistory;
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Given that almost everything in here simply passes through to Vuex, an argument can be made for
|
||||
* not even having this provider, but I'm making anyway because:
|
||||
*
|
||||
* (1) Vuex requires a component to kick off initializations through a lifecycle event handler. Vuex
|
||||
* lacks the ability to lazy-load data because you can't do an async watch internally, thus that
|
||||
* kind of behavior needs to be managed by a component (which has lifecyle)
|
||||
* (2) We should separate history list management from the rendering UI elements, just as a matter of
|
||||
* good code design.
|
||||
* (2) Having this space gives us a place to house additional update logic if we start caching
|
||||
* history objects in IndexDB or reading updates from a websocket. Even if we continue to store
|
||||
* data in Vuex, we will need a place to subscribe and unsubscribe from sockets/observables
|
||||
*/
|
||||
|
||||
import { mapActions, mapGetters } from "vuex";
|
||||
import { History } from "../../model/History";
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
...mapGetters("betaHistory", ["currentHistoryId", "currentHistory", "histories"]),
|
||||
|
||||
currentHistoryModel() {
|
||||
if (this.currentHistory !== null) {
|
||||
return new History(this.currentHistory);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
historyModels() {
|
||||
return this.histories.map((h) => new History(h));
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions("betaHistory", [
|
||||
"loadHistoryById",
|
||||
"createNewHistory",
|
||||
"updateHistory",
|
||||
"deleteHistory",
|
||||
"setCurrentHistoryId",
|
||||
"setHistory",
|
||||
"loadUserHistories",
|
||||
"secureHistory",
|
||||
]),
|
||||
},
|
||||
watch: {
|
||||
// load history when the current id changes
|
||||
currentHistoryId: {
|
||||
immediate: true,
|
||||
handler(newId, oldId) {
|
||||
if (newId && newId !== oldId) {
|
||||
this.loadHistoryById(newId);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.loadUserHistories();
|
||||
},
|
||||
render() {
|
||||
return this.$scopedSlots.default({
|
||||
// list of available histories
|
||||
histories: this.historyModels,
|
||||
|
||||
// currently selected history object, should be a full object not just a summary
|
||||
currentHistory: this.currentHistoryModel,
|
||||
currentHistoryId: this.currentHistoryModel?.id || null,
|
||||
|
||||
handlers: {
|
||||
// Updates the history in the store without a trip to the server, in the event that a
|
||||
// downstream component does the ajax update itself.
|
||||
setHistory: this.setHistory,
|
||||
|
||||
// select new history, basically just needs the id
|
||||
setCurrentHistory: (h) => this.setCurrentHistoryId(h.id),
|
||||
|
||||
// create new history then select it
|
||||
createNewHistory: this.createNewHistory,
|
||||
|
||||
// save new history params should be an object with an id property and any additional
|
||||
// properties that are to be updated on the server. A full history object is not required
|
||||
updateHistory: this.updateHistory,
|
||||
updateCurrentHistory: () => this.updateHistory(this.currentHistory),
|
||||
|
||||
// delete history then clear currentHistoryId
|
||||
deleteHistory: (history) => this.deleteHistory({ history }),
|
||||
deleteCurrentHistory: () => this.deleteHistory({ history: this.currentHistory }),
|
||||
|
||||
// purge history then clearn currentHistoryId
|
||||
purgeHistory: (history) => this.deleteHistory({ history, purge: true }),
|
||||
purgeCurrentHistory: () => this.deleteHistory({ history: this.currentHistory, purge: true }),
|
||||
|
||||
secureHistory: this.secureHistory,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,201 @@
|
||||
import Vuex from "vuex";
|
||||
import { shallowMount } from "@vue/test-utils";
|
||||
import { getLocalVue, wait, waitForLifecyleEvent } from "jest/helpers";
|
||||
import { historyStore } from "../../model/historyStore";
|
||||
import { History } from "../../model";
|
||||
import UserHistories from "./UserHistories";
|
||||
|
||||
// #region Test Data
|
||||
|
||||
let historySummaries;
|
||||
let fullHistories;
|
||||
let currentHistoryId;
|
||||
|
||||
const resetTestData = () => {
|
||||
const ids = [1, 2, 3, 4];
|
||||
currentHistoryId = ids[0];
|
||||
historySummaries = ids.map((i) => ({ id: i, name: `History #${i}` }));
|
||||
fullHistories = historySummaries.map((h) => ({ ...h, detailView: true }));
|
||||
};
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Mock server responses
|
||||
|
||||
import {
|
||||
getHistoryList,
|
||||
getHistoryById,
|
||||
getCurrentHistoryFromServer,
|
||||
setCurrentHistoryOnServer,
|
||||
createNewHistory,
|
||||
updateHistoryFields,
|
||||
deleteHistoryById,
|
||||
} from "../../model/queries";
|
||||
|
||||
jest.mock("app");
|
||||
jest.mock("../../caching");
|
||||
jest.mock("../../model/queries");
|
||||
|
||||
const maxServerDelay = 60;
|
||||
const serverDelay = async (min = 0, max = maxServerDelay) => {
|
||||
const delay = min + Math.random() * (max - min);
|
||||
await wait(delay);
|
||||
};
|
||||
|
||||
getHistoryList.mockImplementation(async () => {
|
||||
await serverDelay();
|
||||
return [...historySummaries];
|
||||
});
|
||||
|
||||
getCurrentHistoryFromServer.mockImplementation(async () => {
|
||||
await serverDelay();
|
||||
return fullHistories.find((o) => o.id == currentHistoryId);
|
||||
});
|
||||
|
||||
getHistoryById.mockImplementation(async (id) => {
|
||||
await serverDelay();
|
||||
return fullHistories.find((o) => o.id == id);
|
||||
});
|
||||
|
||||
const createdHistory = { id: 666, name: "I am new" };
|
||||
createNewHistory.mockImplementation(async () => {
|
||||
await serverDelay();
|
||||
historySummaries.push(createdHistory);
|
||||
const newFullHistory = { ...createdHistory, detailView: true };
|
||||
fullHistories.push(newFullHistory);
|
||||
return Object.assign({}, newFullHistory);
|
||||
});
|
||||
|
||||
setCurrentHistoryOnServer.mockImplementation(async (id) => {
|
||||
await serverDelay();
|
||||
currentHistoryId = id;
|
||||
const result = fullHistories.find((h) => h.id == id);
|
||||
return Object.assign({}, result);
|
||||
});
|
||||
|
||||
updateHistoryFields.mockImplementation(async (id, payload = {}) => {
|
||||
await serverDelay();
|
||||
const existingHistory = fullHistories.find((h) => h.id == id);
|
||||
return { ...existingHistory, ...payload };
|
||||
});
|
||||
|
||||
deleteHistoryById.mockImplementation(async (id) => {
|
||||
await serverDelay();
|
||||
return fullHistories.find((h) => h.id == id);
|
||||
});
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Mounting
|
||||
|
||||
const localVue = getLocalVue();
|
||||
|
||||
// Generate store for tesint, just need the one module we talk to
|
||||
const historiesStore = new Vuex.Store({
|
||||
modules: {
|
||||
betaHistory: historyStore,
|
||||
},
|
||||
});
|
||||
|
||||
// #endregion
|
||||
|
||||
describe("UserHistories", () => {
|
||||
let wrapper;
|
||||
let slotProps;
|
||||
|
||||
beforeEach(async () => {
|
||||
resetTestData();
|
||||
wrapper = shallowMount(UserHistories, {
|
||||
localVue,
|
||||
store: historiesStore,
|
||||
scopedSlots: {
|
||||
default(props) {
|
||||
slotProps = props;
|
||||
},
|
||||
},
|
||||
});
|
||||
await waitForLifecyleEvent(wrapper.vm, "updated");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
historiesStore.dispatch("betaHistory/reset");
|
||||
if (wrapper) await wrapper.destroy();
|
||||
});
|
||||
|
||||
describe("slotProps: values", () => {
|
||||
test("histories", async () => {
|
||||
const { histories } = slotProps;
|
||||
expect(histories).toBeInstanceOf(Array);
|
||||
expect(histories.length).toEqual(historySummaries.length);
|
||||
histories.forEach((h) => {
|
||||
expect(h).toBeInstanceOf(History);
|
||||
});
|
||||
});
|
||||
|
||||
test("currentHistory", async () => {
|
||||
const { histories, currentHistory } = slotProps;
|
||||
expect(currentHistory).toBeInstanceOf(History);
|
||||
expect(histories.find((h) => h.id == currentHistory.id)).toEqual(currentHistory);
|
||||
});
|
||||
});
|
||||
|
||||
describe("slotProp handlers", () => {
|
||||
test("setCurrentHistory", async () => {
|
||||
const { setCurrentHistory } = slotProps.handlers;
|
||||
expect(setCurrentHistory).toBeInstanceOf(Function);
|
||||
|
||||
// set another history as current
|
||||
const nextHistory = historySummaries[1];
|
||||
setCurrentHistory(nextHistory);
|
||||
await waitForLifecyleEvent(wrapper.vm, "updated");
|
||||
|
||||
// wait for it to register
|
||||
const { currentHistory: changedHistory, histories: newHistories } = slotProps;
|
||||
expect(changedHistory.id).toEqual(nextHistory.id);
|
||||
expect(changedHistory).toBeInstanceOf(History);
|
||||
expect(newHistories.find((h) => h.id == changedHistory.id)).toEqual(changedHistory);
|
||||
});
|
||||
|
||||
test("createNewHistory", async () => {
|
||||
const { createNewHistory } = slotProps.handlers;
|
||||
expect(createNewHistory).toBeInstanceOf(Function);
|
||||
|
||||
// create new history
|
||||
await createNewHistory();
|
||||
await waitForLifecyleEvent(wrapper.vm, "updated");
|
||||
|
||||
// wait for it to come out of the slot
|
||||
expect(slotProps.currentHistory.id).toEqual(createdHistory.id);
|
||||
});
|
||||
|
||||
test("updateHistory", async () => {
|
||||
const {
|
||||
currentHistory,
|
||||
handlers: { updateHistory },
|
||||
} = slotProps;
|
||||
expect(updateHistory).toBeInstanceOf(Function);
|
||||
expect(currentHistory).toBeInstanceOf(History);
|
||||
const modifiedHistory = { ...currentHistory, foo: "bar" };
|
||||
|
||||
expect(modifiedHistory.id).toBeDefined();
|
||||
await updateHistory(modifiedHistory);
|
||||
|
||||
await waitForLifecyleEvent(wrapper.vm, "updated");
|
||||
expect(slotProps.histories.find((h) => h.foo == "bar")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("deleteHistory", async () => {
|
||||
const {
|
||||
currentHistory,
|
||||
handlers: { deleteHistory },
|
||||
} = slotProps;
|
||||
|
||||
expect(deleteHistory).toBeInstanceOf(Function);
|
||||
|
||||
await deleteHistory(currentHistory);
|
||||
await waitForLifecyleEvent(wrapper.vm, "updated");
|
||||
expect(slotProps.histories.find((h) => h.id == currentHistory.id)).toBeFalsy();
|
||||
expect(slotProps.currentHistory.id).not.toEqual(currentHistory.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
import UserHistories from "./UserHistories";
|
||||
export default UserHistories;
|
||||
@@ -15,3 +15,10 @@ export { default as CollectionContentProvider } from "./CollectionContentProvide
|
||||
// dataset collection is stored as history content. But a collection can nest
|
||||
// other collections, and sub-collections are stored as collection-content
|
||||
export { default as DscProvider } from "./DscProvider";
|
||||
|
||||
// Management for current user's histories. Largely a passthrough of store methods
|
||||
export { default as UserHistories } from "./UserHistories";
|
||||
|
||||
// list management functionality
|
||||
export { default as ExpandedItems } from "./ExpandedItems";
|
||||
export { default as SelectedItems } from "./SelectedItems";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user