Merge branch 'dev' of github.com:anuprulez/galaxy into ml-jupyter-notebook

This commit is contained in:
Anup Kumar
2021-02-19 10:06:49 +01:00
394 changed files with 6612 additions and 5364 deletions
-1
View File
@@ -1 +0,0 @@
api
-3
View File
@@ -1,3 +0,0 @@
#!/bin/bash
./run_tests.sh --dockerize --db postgres --clean_pyc --skip_flakey_fails -api "$@"
-1
View File
@@ -1 +0,0 @@
framework
-3
View File
@@ -1,3 +0,0 @@
#!/bin/bash
./run_tests.sh --dockerize --db postgres --clean_pyc --framework "$@"
-1
View File
@@ -1 +0,0 @@
integration
-3
View File
@@ -1,3 +0,0 @@
#!/bin/bash
DOCKER_RUN_EXTRA_ARGS="--privileged" ./run_tests.sh --dockerize --db postgres --clean_pyc --integration "$@"
-1
View File
@@ -1 +0,0 @@
main-tools
-3
View File
@@ -1,3 +0,0 @@
#!/bin/bash
./run_tests.sh --dockerize --db postgres --clean_pyc -main "$@"
-24
View File
@@ -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
-17
View File
@@ -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"
-124
View File
@@ -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
View File
@@ -1 +0,0 @@
selenium
-26
View File
@@ -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 "$@"
-3
View File
@@ -1,3 +0,0 @@
#!/bin/bash
./run_tests.sh --dockerize --db postgres --clean_pyc --toolshed "$@"
+32
View File
@@ -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
+12
View File
@@ -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
-23
View File
@@ -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:
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.7]
python-version: ['3.7']
db: ['postgresql', 'sqlite']
services:
postgres:
+35
View File
@@ -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'
+11 -3
View File
@@ -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:
+1 -1
View File
@@ -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 -4
View File
@@ -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
+15 -2
View File
@@ -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 }}"
+1 -1
View File
@@ -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:
+44
View File
@@ -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'
+1 -1
View File
@@ -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
View File
@@ -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;
-6
View File
@@ -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,
+5 -8
View File
@@ -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();
},
@@ -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>
@@ -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>
+89 -74
View File
@@ -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");
+23 -25
View File
@@ -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;
}
},
},
};
@@ -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;
@@ -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,
},
};
},
});
+15 -24
View File
@@ -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")
);
@@ -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);
},
},
};
@@ -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;
@@ -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 })
);
});
};
@@ -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,
});
},
};
@@ -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);
},
},
};
@@ -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;
@@ -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;
}
@@ -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