Remove legacy caching model

This commit is contained in:
guerler
2022-02-10 11:58:38 -05:00
parent 205af02fb8
commit 0293b83cf5
44 changed files with 1 additions and 7254 deletions
@@ -29,7 +29,6 @@ function buildWrapper(has_duplicate_inputs = true, has_empty_inputs = true, user
result: { has_duplicate_inputs: has_duplicate_inputs, has_empty_inputs: has_empty_inputs },
}),
DatasetProvider: MockProvider({
resultLabel: "item",
result: { id: "dataset_id", creating_job: "creating_job" },
}),
FontAwesomeIcon: false,
@@ -13,7 +13,7 @@ const mockDatasetProvider = {
render() {
return this.$scopedSlots.default({
loading: false,
item: datasetResponse,
result: datasetResponse,
});
},
};
@@ -58,13 +58,9 @@ import CollectionOperations from "./CollectionOperations.vue";
import Details from "./Details";
import { CollectionContentItem } from "../ContentItem";
import HistoryListing from "components/History/HistoryListing";
import { reportPayload } from "components/providers/History/ContentProvider/helpers";
import { UrlDataProvider } from "components/providers/UrlDataProvider";
export default {
filters: {
reportPayload,
},
components: {
UrlDataProvider,
Layout,
@@ -96,15 +96,11 @@ import HistoryDetails from "./HistoryDetails";
import HistoryEmpty from "./HistoryEmpty";
import ContentOperations from "./ContentOperations";
import ToolHelpModal from "./ToolHelpModal";
import { reportPayload } from "components/providers/History/ContentProvider/helpers";
import HistoryMenu from "./HistoryMenu";
import HistoryListing from "./HistoryListing";
import { HistoryContentItem } from "./ContentItem";
export default {
filters: {
reportPayload,
},
components: {
LoadingSpan,
UrlDataProvider,
@@ -44,7 +44,6 @@ describe("DisplayApplications", () => {
},
],
},
resultLabel: "item",
}),
},
localVue,
@@ -1,39 +0,0 @@
import { DatasetCollection } from "components/History/model/DatasetCollection";
import { SearchParams } from "components/providers/History/SearchParams";
import { ContentProvider, processContentStreams } from "../ContentProvider";
import { collectionPayload } from "./collectionPayload";
export default {
mixins: [ContentProvider],
props: {
params: { type: SearchParams, default: () => new SearchParams({ showHidden: false }) },
},
computed: {
dsc() {
if (this.parent instanceof DatasetCollection) {
return this.parent;
}
return new DatasetCollection(this.parent);
},
},
watch: {
dsc(newDsc, oldDsc) {
if (!(newDsc.id == oldDsc.id)) {
this.resetScrollPos();
}
},
},
methods: {
initStreams() {
const { debouncePeriod, pageSize, params$, scrollPos$, debug } = this;
const parent$ = this.watch$("dsc", true);
const sources = { params$, parent$, scrollPos$ };
const settings = { debouncePeriod, pageSize, debug };
return processContentStreams(collectionPayload, sources, settings);
},
},
};
@@ -1,160 +0,0 @@
import { map } from "rxjs/operators";
import { watchUntil, getLocalVue } from "jest/helpers";
import { shallowMount } from "@vue/test-utils";
import {
cacheContent,
cacheCollectionContent,
wipeDatabase,
loadDscContent,
} from "components/providers/History/caching";
import { bulkCacheDscContent } from "components/providers/History/caching/db/observables";
import { summarizeCacheOperation } from "components/providers/History/caching/loadHistoryContents";
import { SearchParams } from "components/providers/History/SearchParams";
import CollectionContentProvider from "./CollectionContentProvider";
import { sameVueObj, payloadChange } from "components/providers/History/test/providerTestHelpers";
import { testCollection, testCollectionContent } from "components/providers/History/test/testCollection";
import { defaultPayload } from "../ContentProvider";
// mocking
jest.mock("app");
jest.mock("components/providers/History/caching");
// fake server call
// prettier-ignore
loadDscContent.mockImplementation((cfg) => (inputs$) => {
return inputs$.pipe(
map((inputs) => {
const [url, , pagination] = inputs;
const { offset, limit } = pagination;
// console.log("mock loader", url, pagination);
const result = testCollectionContent
.filter((row) => url == row.parent_url)
.slice(offset, offset + limit);
return result;
}),
bulkCacheDscContent(),
summarizeCacheOperation()
);
});
let dsc;
const localVue = getLocalVue();
beforeEach(async () => {
dsc = await cacheContent(testCollection, true);
});
afterEach(async () => {
dsc = null;
await wipeDatabase();
});
describe("CollectionContentProvider", () => {
const pageSize = 5;
const debouncePeriod = 250;
let wrapper;
afterEach(async () => {
if (wrapper) {
wrapper.destroy();
}
wrapper = undefined;
});
describe("blank initialization state", () => {
// current payload
beforeEach(async () => {
wrapper = await shallowMount(CollectionContentProvider, {
localVue,
propsData: {
parent: dsc,
pageSize,
debug: false,
debouncePeriod,
},
scopedSlots: {
default() {},
},
});
await wrapper.vm.$nextTick();
});
test("should expose update methods", () => {
// allows child scroller component to update the position in the history
// via an event handler
expect(wrapper.vm.setScrollPos).toBeInstanceOf(Function);
});
test("should show an empty payload", () => {
expect(SearchParams.equals(new SearchParams(), wrapper.vm.params)).toBe(true);
expect(sameVueObj(wrapper.vm.payload, defaultPayload));
});
});
describe("waiting for simulated server loads", () => {
beforeEach(async () => {
wrapper = await shallowMount(CollectionContentProvider, {
localVue,
propsData: {
parent: dsc,
pageSize,
debug: false,
debouncePeriod,
},
scopedSlots: {
default: function (props) {
// reportPayload(props.payload, { indexKey: "element_index" });
// payload = props.payload;
},
},
});
// eslint-disable-next-line no-unused-vars
const spinUp = await watchUntil(wrapper.vm, function () {
const allDone = wrapper.vm.payload.contents.length > 0;
// console.log("done?", allDone);
return allDone;
});
// console.log(spinUp);
});
test("initial load, no scrolling", async () => {
const payload = wrapper.vm.payload;
expect(payload).toBeDefined();
// reportPayload(payload, { indexKey: "element_index" });
// reportPayload(initialPayload, { indexKey: "element_index" });
const { contents, topRows, bottomRows, startKey, totalMatches } = payload;
expect(topRows).toEqual(0);
expect(contents.length).toBeGreaterThanOrEqual(2 * wrapper.vm.pageSize);
expect(totalMatches).toEqual(testCollectionContent.length);
expect(bottomRows).toBeGreaterThan(0);
expect(bottomRows).toEqual(testCollectionContent.length - contents.length);
const testStartIndex = testCollectionContent[0].element_index;
expect(startKey).toEqual(testStartIndex);
});
test("should update to reflect independent cache changes", async () => {
let hasFoobar;
const payload = wrapper.vm.payload;
// check initial emit, for foobars
expect(payload).toBeDefined();
hasFoobar = payload.contents.some((o) => o.foobar == 123);
expect(hasFoobar).toBeFalsy();
// add a foobar
const testChild = payload.contents[0];
testChild.foobar = 123;
const updateResult = await cacheCollectionContent(testChild, true);
expect(updateResult.foobar).toEqual(123);
// check subsequent payload for a foobar
const updatedPayload = await payloadChange({ vm: wrapper.vm });
// reportPayload(updatedPayload, { label: "updated", indexKey: "element_index" });
hasFoobar = updatedPayload.contents.some((o) => o.foobar == 123);
expect(hasFoobar).toBeTruthy();
});
});
});
@@ -1,77 +0,0 @@
import { of, Observable, Subject, partition, merge } from "rxjs";
import { tap, map, pluck, switchMap, publish, distinctUntilChanged, share, throttleTime } from "rxjs/operators";
import { chunk } from "utils/observable";
import { SearchParams } from "components/providers/History/SearchParams";
import { loadDscContent } from "components/providers/History/caching";
import { watchCollection } from "./watchCollection";
// prettier-ignore
export const collectionPayload = (cfg = {}) => {
const {
parent: dsc,
filters = new SearchParams(),
pageSize = SearchParams.pageSize,
debug = false,
loadingEvents$ = new Subject(),
debouncePeriod = 100,
chunkSize = pageSize
} = cfg;
const { totalElements: totalMatches, contents_url } = dsc;
return publish((pos$) => {
// split scroll position into objects where we exactly know which key to focus on, and those
// where we have to figure out where to start
const [ noKey$, hasKey$ ] = partition(pos$, (pos) => pos.key === null);
// if we don't know the exact elementIndex we need to calculate it from the cursor (portion
// of the total scroller height) and the total matches which we should have from the dsc props
const calculatedIndex$ = noKey$.pipe(
pluck('cursor'),
map((cursor) => Math.round(cursor * totalMatches)),
);
const knownIndex$ = hasKey$.pipe(pluck('key'));
const elementIndex$ = merge(knownIndex$, calculatedIndex$).pipe(share());
// chunk the input to the server request so we don't request for every single mouse move
const chunked$ = elementIndex$.pipe(
chunk(chunkSize),
distinctUntilChanged(),
);
// overlap one page before and one page after, 3 total pages queried
const pagination$ = chunked$.pipe(
map(targetRow => {
const offset = Math.max(0, targetRow - pageSize);
const limit = 3 * pageSize;
return { offset, limit, targetRow };
}),
);
const serverLoad$ = pagination$.pipe(
throttleTime(debouncePeriod, undefined, { trailing: true }),
switchMap(pagination => of([contents_url, filters, pagination]).pipe(
tap(() => loadingEvents$.next(true)),
loadDscContent({ debug }),
tap(() => loadingEvents$.next(false)),
)),
);
const payload$ = elementIndex$.pipe(
watchCollection({ dsc, filters, ...cfg}),
map((result) => {
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 };
}),
);
return new Observable((obs) => {
const watchSub = payload$.subscribe(obs);
watchSub.add(serverLoad$.subscribe());
return watchSub;
});
})
};
@@ -1,18 +0,0 @@
/**
* Same basic format as historyContentProvider, but it should be simpler than
* the history because:
*
* 1. Collections are immutable after creation so they don't change over time,
* meaning there is no polling and no need to do "random access" as we do for
* histories since we can just limit/offset to reach everything since the
* total matches count will always be the same. A "total matches" exists in
* the basic content record in the form of "element_count", and we already
* have that value in the DSC which is passed in.
*
* 2. There is no filtering for collections on the server so all we can do is
* page through the results
*/
import CollectionContentProvider from "./CollectionContentProvider";
export default CollectionContentProvider;
@@ -1,38 +0,0 @@
import { of } from "rxjs";
import { map } from "rxjs/operators";
import { aggregateCacheUpdates } from "../ContentProvider";
import { monitorCollectionContent } from "components/providers/History/caching";
import { SEEK } from "components/providers/History/caching/enums";
import { SearchParams } from "components/providers/History/SearchParams";
// import { show } from "utils/observable";
// prettier-ignore
export const watchCollection = (cfg = {}) => elementIndex$ => {
const {
dsc,
filters = new SearchParams(),
pageSize = SearchParams.pageSize,
debug = false,
keyField = "element_index",
keyDirection = SEEK.ASC,
...otherConfig
} = cfg;
// builds a monitor observable based on a element_index
const monitor = (idx) => of([dsc.contents_url, filters, idx]).pipe(
monitorCollectionContent({ pageSize, debug: false, keyField }),
);
// handles the plumbing for observing the query (monitor) and collecting the output into an
// aggregate ordered list focused around the current target key (element_index in this case)
return elementIndex$.pipe(
map(val => parseInt(val)),
aggregateCacheUpdates(monitor, {
keyField,
keyDirection,
pageSize,
debug,
...otherConfig,
}),
);
};
@@ -1,125 +0,0 @@
import { of, timer } from "rxjs";
import { takeUntil, share } from "rxjs/operators";
import { ObserverSpy } from "@hirez_io/observer-spy";
import { watchCollection } from "./watchCollection";
import { cacheCollectionContent, bulkCacheDscContent, wipeDatabase } from "components/providers/History/caching";
import { testCollectionContent, testCollection } from "components/providers/History/test/testCollection";
import { untilNthEmission } from "jest/helpers";
// need to mock monitorHistoryContent which is instantiated inside the worker,
// but we can't actually fire up threads.js in a unit test because you can't
// call expose() on the main thread, so we mock the adapter functions which
// generate observables from the worker.
jest.mock("app");
jest.mock("components/providers/History/caching");
const debouncePeriod = 250;
const safetyTimeout = 1000;
const pageSize = 5;
const pluckAll = (arr, prop) => arr.map((o) => o[prop]);
let cachedCollectionContent;
beforeEach(async () => {
await wipeDatabase();
cachedCollectionContent = await bulkCacheDscContent(testCollectionContent, true);
// const cachedIds = pluckAll(cachedCollectionContent, "_id");
// console.log(cachedIds);
});
afterEach(async () => {
await wipeDatabase();
});
describe("watchCollection", () => {
test("should observe pre-inserted content on the initial emission", async () => {
// the srource stream for the operator is an element_index
const watcher$ = of(0).pipe(
watchCollection({
dsc: testCollection,
pageSize,
debouncePeriod,
debug: false,
}),
takeUntil(timer(safetyTimeout))
);
// spy on output of observable, wait for it to end
const spy = new ObserverSpy();
watcher$.subscribe(spy);
await spy.onComplete();
expect(spy.receivedNext()).toBe(true);
expect(spy.receivedComplete()).toBe(true);
expect(spy.getValuesLength()).toBe(1);
const payload = spy.getFirstValue();
{
const { startKey, startKeyIndex, targetKey, contents } = payload;
// at the top of the list, should get the match (element_index = 0) + 2 pages
expect(startKey).toEqual(0);
expect(startKeyIndex).toEqual(0);
expect(targetKey).toEqual(0);
expect(Array.isArray(contents)).toBe(true);
// should get back the exact match (element_index = 0, the following page, + 1
// additional page of buffer-room after the current page
const returnedIndexes = pluckAll(contents, "element_index");
// console.log(returnedIndexes);
expect(contents.length).toBeGreaterThanOrEqual(2 * pageSize);
expect(returnedIndexes).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
}
});
test("should observe subsequent updates", async () => {
// the srource stream for the operator is an element_index
const watcher$ = of(0).pipe(
watchCollection({
dsc: testCollection,
debouncePeriod,
pageSize,
}),
takeUntil(timer(safetyTimeout)),
share()
);
const spy = new ObserverSpy();
watcher$.subscribe(spy);
// After the first event, change some stuff on the first row
// need to delay a little to wait for first emissions to run through
// otherwise the debouncing will think the 2nd update is the same as the
await untilNthEmission(watcher$, 1);
const testChild = cachedCollectionContent[0];
testChild.foobar = 123;
const updateResult = await cacheCollectionContent(testChild, true);
// console.log("after update", updateResult);
expect(updateResult.foobar).toEqual(123);
expect(updateResult._rev).not.toEqual(testCollection._rev);
await spy.onComplete();
expect(spy.receivedNext()).toBe(true);
expect(spy.receivedComplete()).toBe(true);
expect(spy.getValuesLength()).toEqual(2);
const firstPayload = spy.getFirstValue();
{
const { contents } = firstPayload;
expect(Array.isArray(contents)).toBe(true);
// no foobar because this happened before the update
const foobars = pluckAll(contents, "foobar");
expect(foobars.every((val) => val === undefined)).toBe(true);
}
const lastPayload = spy.getLastValue();
{
const { contents } = lastPayload;
expect(Array.isArray(contents)).toBe(true);
// should have a foobar
const foobars = pluckAll(contents, "foobar");
expect(foobars.every((val) => val === undefined)).toBe(false);
}
});
});
@@ -1,119 +0,0 @@
import { NEVER } from "rxjs";
import { ScrollPos } from "components/History/model/ScrollPos";
import { SearchParams } from "components/providers/History/SearchParams";
import { isValidNumber } from "utils/validation";
// first emission, emitted when parent (history) or filters changes to reset the view
export const defaultPayload = {
contents: [],
targetKey: null,
startKey: null,
startKeyIndex: 0,
topRows: 0,
bottomRows: 0,
totalMatches: 0,
};
export const ContentProvider = {
props: {
parent: { type: Object, required: true },
params: { type: SearchParams, default: () => new SearchParams() },
pageSize: { type: Number, default: SearchParams.pageSize },
debouncePeriod: { type: Number, default: 1000 },
debug: { type: Boolean, default: false },
},
computed: {
busy() {
return this.loading || this.scrolling;
},
slotProps() {
return {
// actual content delivery
payload: this.payload,
// local vars/settings/props passthrough
scrollPos: this.scrollPos,
loading: this.loading,
scrolling: this.scrolling,
busy: this.busy,
params: this.params,
pageSize: this.pageSize,
// update methods
setScrollPos: this.setScrollPos,
};
},
},
data() {
return {
payload: defaultPayload,
scrollPos: new ScrollPos(),
loading: false,
scrolling: false,
};
},
watch: {
params(newParams, oldParams) {
if (!SearchParams.equals(newParams, oldParams)) {
this.resetScrollPos();
}
},
},
created() {
this.params$ = this.watch$("params");
this.scrollPos$ = this.watch$("scrollPos");
const { payload$, loading$, scrolling$ } = this.initStreams();
this.listenTo(scrolling$, (val) => (this.scrolling = val));
this.listenTo(loading$, (val) => (this.loading = val));
// render output
this.listenTo(payload$, {
next: (payload) => this.setPayload(payload),
error: (err) => console.warn("error in cache$", err),
complete: () => console.warn("why did cache$ complete?"),
});
},
methods: {
resetScrollPos() {
this.setScrollPos();
},
initStreams() {
console.warn("Override initStreams in ContentProvider");
return { payload$: NEVER, loading$: NEVER, scrolling$: NEVER, resetPos$: NEVER };
},
/**
* Handler for the scroller to update its position, either by key or
* cursor (0-1 value representing how far down it is)
* @param {object} Object consisting of key and cursor props
*/
setScrollPos({ cursor = null, key = null } = {}) {
if (isValidNumber(cursor) || key !== null) {
this.scrollPos = ScrollPos.create({ cursor, key });
}
},
/**
* Render cache observable results to the payload property. It's best to
* set everything at once to avoid multiple render passes.
* @param {object} result Cache observable response
*/
setPayload(props = {}) {
const payload = Object.assign({}, defaultPayload, props);
this.$set(this, "payload", payload);
},
},
render() {
return this.$scopedSlots.default(this.slotProps);
},
};
export default ContentProvider;
@@ -1,109 +0,0 @@
// Generalized bi-directional watch and aggregator.
// Looks at cache from starting index, then looks up and down
import { throwError, from, EMPTY } from "rxjs";
import {
map,
debounceTime,
distinctUntilChanged,
groupBy,
reduce,
mergeMap,
switchMap,
withLatestFrom,
timeoutWith,
ignoreElements,
} from "rxjs/operators";
import { show, debounceBurst, shareButDie } from "utils/observable";
import { SearchParams } from "components/providers/History/SearchParams";
import { processContentUpdate, newUpdateMap, buildContentResult, getKeyForUpdateMap } from "./aggregation";
import { SEEK } from "components/providers/History/caching/enums";
import { reportPayload } from "./helpers";
// prettier-ignore
export const aggregateCacheUpdates = (monitor, cfg = {}) => (src$) => {
// monitor is a function that generates the update observable
if (!(monitor instanceof Function)) {
return throwError("monitor should be a function in aggregateCacheUpdates");
}
const {
// number of rows we roughly expect to be in the window
pageSize = SearchParams.pageSize,
// input and output throttling
debouncePeriod = 250,
// field and key order for the contents
keyField = "hid",
keyDirection = SEEK.DESC,
// reduces duplicate updates to the skiplist
// which can be expensive
updateGroupLifetime = 2 * debouncePeriod,
debug = false,
} = cfg;
// function that extracts the key from a document
const getKey = getKeyForUpdateMap(keyField);
// incoming target key
const targetKey$ = src$.pipe(shareButDie(1));
// avoids creating a new monitor for every single key emission
// chunk to ceiling if descending, floor if ascending
const monitorInputKey$ = targetKey$.pipe(
distinctUntilChanged(),
show(debug, (key) => console.log("monitorInputKey", key)),
);
// incoming stream of cache updates
// Monitor generation function is passed in via config so we can use it against different types of queries
const cacheUpdates$ = monitorInputKey$.pipe(
switchMap(monitor),
show(debug, (change) => console.log("monitor output", change)),
);
// groups updates by skiplist key and debounces those
// streams individually so we're not pointlessly updating
// when we're just going to change it again shortly
const groupedUpdates$ = cacheUpdates$.pipe(
groupBy(
update => update.key,
update => update,
updateByKey$ => updateByKey$.pipe(
timeoutWith(updateGroupLifetime, EMPTY),
ignoreElements()
)
),
// eliminates rapid double updates to same item
mergeMap(updateByKey$ => updateByKey$.pipe(
debounceTime(0.25 * debouncePeriod),
)),
debounceBurst(debouncePeriod),
show(debug, changeBurst => console.log('burst', changeBurst.map(o => o.key))),
);
// aggregates changes into a skiplist.
const storage = newUpdateMap();
const currentMap$ = groupedUpdates$.pipe(
mergeMap(updateList => from(updateList).pipe(
reduce(processContentUpdate, storage)
)),
show(debug, () => console.log('grouped update burst done')),
);
const contentListInputs$ = currentMap$.pipe(
withLatestFrom(targetKey$),
);
// query skiplist with scroll position (transformed into a key from the skiplist)
// to produce list of nearby content
const contentList$ = contentListInputs$.pipe(
map(buildContentResult({ pageSize, keyDirection, getKey })),
show(debug, payload => reportPayload(payload, { indexKey: keyField, label: "aggregateCacheUpdates payload" })),
);
return contentList$;
};
@@ -1,158 +0,0 @@
/**
* Functions for aggregating incoming cache updates for presentation to the
* scroller. As cache changes roll in, the updates are summarized and sorted in
* a SkipList for fast, ordered retrieval.
*
* TODO: PouchDB has similar aggregation capabilities but when last I looked they
* were too difficult to get working properly with dynamically changing queries.
* I feel like this is the duty of the caching layer, however, and we should
* investigate again.
*/
import SkipList from "proper-skip-list";
import { pipe, take, filter } from "iter-tools";
import { SEEK } from "components/providers/History/caching/enums";
import { SearchParams } from "components/providers/History/SearchParams";
// utils for buildContentResult
const distance = (a, b) => Math.abs(+a - +b);
export const buildContentResult = (cfg = {}) => {
const { keyDirection = SEEK.ASC, pageSize = SearchParams.pageSize } = cfg;
if (!SEEK.isValid(keyDirection)) {
throw new Error(`buildContentResult: invalid sort direction: ${keyDirection}`);
}
return ([updateMap, targetKey]) => {
// console.log("buildContentResult", updateMap, rawTargetKey, typeof targetKey);
if (updateMap === undefined) {
throw new Error("missing skiplist");
}
if (targetKey === undefined) {
throw new Error("missing target key");
}
// make sure key is an int
if (!Number.isFinite(targetKey)) {
console.warn("Target key should be numeric", updateMap, targetKey);
throw new Error("buildContentResult: targetKey must be a numeric value", targetKey);
}
const { matchingValue, desc, asc } = updateMap.findEntries(targetKey);
// We want 3 whole pages, 1 behind us, the one we're on, and 1 ahead of us, this means
// we have to figure out what "behind" and "in front" mean based on the key direction
// if the keys are ascending, "down" the list is the asc iterator
// if the keys are ascending, "up" the list is the desc iterator
const isAscending = keyDirection == SEEK.ASC;
const topIterator = isAscending ? desc : asc;
const bottomIterator = isAscending ? asc : desc;
const topFilter = pipe(
filter(([k]) => (isAscending ? k < targetKey : k > targetKey)),
take(pageSize)
);
const bottomFilter = pipe(
filter(([k]) => (isAscending ? k > targetKey : k < targetKey)),
take(2 * pageSize)
);
const top = Array.from(topFilter(topIterator));
const bottom = Array.from(bottomFilter(bottomIterator));
// assemble contents array by combining the results
const match = matchingValue ? [[targetKey, matchingValue]] : [];
const contentEntries = [...top.reverse(), ...match, ...bottom];
// startKey is the closest key in the result set to the requested targetKey
const { startKey, startKeyIndex } = findClosestEntry(targetKey, contentEntries);
const contents = contentEntries.map(([k, v]) => v);
return {
// list of contents from the skiplist around the targetKey
contents,
// original value used to query the skiplist, should be in
// the middle of the contents list
targetKey,
// closest key to the targetKey that actually exists in the slice
startKey,
// index of the closest row matching the targetKey, also should
// be in the middle of the contents
startKeyIndex,
};
};
};
/**
* Finds closest of keys by measuring numeric distance from targetKey
*
* @param {String|Number} targetKey Key to match
* @param {String|Number} entries content entries from the map i.e. list of [key,val]
*
* @return {Object} startKey, startKeyIndex
*/
const findClosestEntry = (targetKey, entries = []) => {
const startingVal = { startKey: null, startKeyIndex: -1, distance: Infinity };
return entries
.filter((entry) => Array.isArray(entry))
.reduce((result, entry, idx) => {
const [key] = entry;
const myDistance = distance(targetKey, key);
return myDistance < result.distance ? { startKey: key, startKeyIndex: idx, distance: myDistance } : result;
}, startingVal);
};
/**
* Fresh update map, gets built when inputs change
*/
export const newUpdateMap = () => {
return new SkipList();
};
/**
* Generates a scan function for consuming updates from the pouchdb-live-find.
* Adds or removes incoming docs from the skiplist
*/
export const processContentUpdate = (resultMap, update) => {
const { doc, key, match } = update;
// make sure key is an int
const storageKey = parseInt(key);
if (match) {
resultMap.upsert(storageKey, doc);
} else if (resultMap.has(storageKey)) {
resultMap.delete(storageKey);
}
return resultMap;
};
// Configurable function returns a key for the updateMap from a doc during upate operations
// aggregate results in a map for each set of id + params
export const getKeyForUpdateMap = (keyField) => (doc) => {
let rawKey;
if (keyField in doc) {
rawKey = doc[keyField];
} else {
// won't have a full doc on a delete, but it's embeedded in the id
const parts = doc._id.split("-");
rawKey = parseInt(parts[1]);
}
const result = parseInt(rawKey);
if (isNaN(result)) {
console.warn("Non integer key for document", doc);
throw new Error("Key for update map should be an integer");
}
return result;
};
@@ -1,283 +0,0 @@
import SkipList from "proper-skip-list";
import { buildContentResult } from "./aggregation";
import { SEEK } from "components/providers/History/caching/enums";
jest.mock("app");
jest.mock("components/providers/History/caching");
describe("buildContentResult", () => {
describe("query history content from aggregate storage", () => {
const updateList = new SkipList();
// history content is keyed by hid, descending order
const keyDirection = SEEK.DESC;
const getKey = (item) => item.hid;
const pageSize = 2;
// sample content, indexed by hid
const hids = [100, 95, 61, 60, 50, 44, 41, 17, 1].sort();
hids.forEach((hid) => updateList.upsert(hid, { hid }));
test("query key exists, query key in middle", () => {
const queryKey = 60;
const summarize = buildContentResult({ keyDirection, pageSize, getKey });
// choose a hid we know is in the results
const { contents, targetKey, startKey, startKeyIndex } = summarize([updateList, queryKey]);
// returns the key we used to query the skiplist
expect(targetKey).toEqual(queryKey);
// the closest key to targetKey in the event that targetKey does not exist
expect(startKey).toEqual(queryKey);
// the array index of startKey
expect(startKeyIndex).toEqual(2);
// pageSize causes aggregation to back up one page from start of list, then delivers
// the previous page, the exact match, then 2 pages after the match
const resultKeys = contents.map(getKey);
expect(resultKeys.length).toBeLessThanOrEqual(3 * pageSize + 1);
expect(resultKeys).toEqual([95, 61, 60, 50, 44, 41, 17]);
});
test("query key exists, query key at top", () => {
const queryKey = 100;
const summarize = buildContentResult({ keyDirection, pageSize, getKey });
// choose a hid we know is in the results
const { contents, targetKey, startKey, startKeyIndex } = summarize([updateList, queryKey]);
expect(startKey).toEqual(queryKey);
expect(startKeyIndex).toEqual(0);
expect(targetKey).toEqual(queryKey);
const resultKeys = contents.map(getKey);
// match (top) + 2 pages after
expect(resultKeys).toEqual([100, 95, 61, 60, 50]);
});
test("query key exists, query key at bottom", () => {
const queryKey = 1;
const summarize = buildContentResult({ keyDirection, pageSize, getKey });
// choose a hid we know is in the results
const { contents, targetKey, startKey, startKeyIndex } = summarize([updateList, queryKey]);
expect(startKey).toEqual(queryKey);
expect(startKeyIndex).toEqual(2);
expect(targetKey).toEqual(queryKey);
const resultKeys = contents.map(getKey);
expect(resultKeys).toEqual([41, 17, 1]);
});
test("query key doesn't exist, query key in middle", () => {
const queryKey = 51;
const summarize = buildContentResult({ keyDirection, pageSize, getKey });
// choose a hid we know is in the results
const { contents, targetKey, startKey, startKeyIndex } = summarize([updateList, queryKey]);
expect(startKey).toEqual(50);
expect(startKeyIndex).toEqual(2);
expect(targetKey).toEqual(queryKey);
const resultKeys = contents.map(getKey);
expect(resultKeys).toEqual([61, 60, 50, 44, 41, 17]);
});
test("query key doesn't exist, query key near top", () => {
const queryKey = 99;
const summarize = buildContentResult({ keyDirection, pageSize, getKey });
// choose a hid we know is in the results
const { contents, targetKey, startKey, startKeyIndex } = summarize([updateList, queryKey]);
expect(startKey).toEqual(100);
expect(startKeyIndex).toEqual(0);
expect(targetKey).toEqual(queryKey);
const resultKeys = contents.map(getKey);
expect(resultKeys).toEqual([100, 95, 61, 60, 50]);
});
test("query key doesn't exist, query key near bottom", () => {
const queryKey = 16;
const summarize = buildContentResult({ keyDirection, pageSize, getKey });
// choose a hid we know is in the results
const { contents, targetKey, startKey, startKeyIndex } = summarize([updateList, queryKey]);
expect(startKey).toEqual(17);
expect(startKeyIndex).toEqual(1);
expect(targetKey).toEqual(queryKey);
const resultKeys = contents.map(getKey);
expect(resultKeys).toEqual([41, 17, 1]);
});
test("query key beyond upper limit", () => {
const queryKey = 200;
const summarize = buildContentResult({ keyDirection, pageSize, getKey });
// choose a hid we know is in the results
const { contents, targetKey, startKey, startKeyIndex } = summarize([updateList, queryKey]);
expect(startKey).toEqual(100);
expect(startKeyIndex).toEqual(0);
expect(targetKey).toEqual(queryKey);
const resultKeys = contents.map(getKey);
expect(resultKeys).toEqual([100, 95, 61, 60]);
});
test("query key below lower limit", () => {
const queryKey = -300;
const summarize = buildContentResult({ keyDirection, pageSize, getKey });
// choose a hid we know is in the results
const { contents, targetKey, startKey, startKeyIndex } = summarize([updateList, queryKey]);
expect(startKey).toEqual(1);
expect(startKeyIndex).toEqual(1);
expect(targetKey).toEqual(queryKey);
const resultKeys = contents.map(getKey);
expect(resultKeys).toEqual([17, 1]);
});
});
describe("query collection content from aggregate storage", () => {
const updateList = new SkipList();
// collection content is keyed by element_index, ascending order
const keyDirection = SEEK.ASC;
const getKey = (item) => item.element_index;
const pageSize = 2;
// sample content, indexed by hid
const element_indexes = [1, 2, 3, 4, 5, 6, 7, 8, 9];
element_indexes.forEach((i) => updateList.upsert(i, { element_index: i }));
test("query key exists, query key in middle", () => {
const queryKey = 5;
const summarize = buildContentResult({ keyDirection, pageSize, getKey });
// choose a hid we know is in the results
const { contents, targetKey, startKey, startKeyIndex } = summarize([updateList, queryKey]);
expect(startKey).toEqual(queryKey);
expect(startKeyIndex).toEqual(2);
expect(targetKey).toEqual(queryKey);
const resultKeys = contents.map(getKey);
expect(resultKeys).toEqual([3, 4, 5, 6, 7, 8, 9]);
});
test("query key exists, query key at top", () => {
const queryKey = 1;
const summarize = buildContentResult({ keyDirection, pageSize, getKey });
// choose a hid we know is in the results
const { contents, targetKey, startKey, startKeyIndex } = summarize([updateList, queryKey]);
expect(startKey).toEqual(queryKey);
expect(startKeyIndex).toEqual(0);
expect(targetKey).toEqual(queryKey);
const resultKeys = contents.map(getKey);
expect(resultKeys).toEqual([1, 2, 3, 4, 5]);
});
test("query key exists, query key at bottom", () => {
const queryKey = 9;
const summarize = buildContentResult({ keyDirection, pageSize, getKey });
// choose a hid we know is in the results
const { contents, targetKey, startKey, startKeyIndex } = summarize([updateList, queryKey]);
expect(startKey).toEqual(queryKey);
expect(startKeyIndex).toEqual(2);
expect(targetKey).toEqual(queryKey);
const resultKeys = contents.map(getKey);
expect(resultKeys).toEqual([7, 8, 9]);
});
test("query key doesn't exist, query key in middle", () => {
const queryKey = 4.2;
const summarize = buildContentResult({ keyDirection, pageSize, getKey });
// choose a hid we know is in the results
const { contents, targetKey, startKey, startKeyIndex } = summarize([updateList, queryKey]);
expect(startKey).toEqual(4);
expect(startKeyIndex).toEqual(1);
expect(targetKey).toEqual(queryKey);
const resultKeys = contents.map(getKey);
expect(resultKeys).toEqual([3, 4, 5, 6, 7, 8]);
});
test("query key doesn't exist, query key near top", () => {
const queryKey = 0.8;
const summarize = buildContentResult({ keyDirection, pageSize, getKey });
// choose a hid we know is in the results
const { contents, targetKey, startKey, startKeyIndex } = summarize([updateList, queryKey]);
expect(startKey).toEqual(1);
expect(startKeyIndex).toEqual(0);
expect(targetKey).toEqual(queryKey);
const resultKeys = contents.map(getKey);
expect(resultKeys).toEqual([1, 2, 3, 4]);
});
test("query key doesn't exist, query key near bottom", () => {
const queryKey = 8.1;
const summarize = buildContentResult({ keyDirection, pageSize, getKey });
// choose a hid we know is in the results
const { contents, targetKey, startKey, startKeyIndex } = summarize([updateList, queryKey]);
expect(startKey).toEqual(8);
expect(startKeyIndex).toEqual(1);
expect(targetKey).toEqual(queryKey);
const resultKeys = contents.map(getKey);
expect(resultKeys).toEqual([7, 8, 9]);
});
test("query key beyond upper limit", () => {
const queryKey = -1999;
const summarize = buildContentResult({ keyDirection, pageSize, getKey });
// choose a hid we know is in the results
const { contents, targetKey, startKey, startKeyIndex } = summarize([updateList, queryKey]);
expect(startKey).toEqual(1);
expect(startKeyIndex).toEqual(0);
expect(targetKey).toEqual(queryKey);
const resultKeys = contents.map(getKey);
expect(resultKeys).toEqual([1, 2, 3, 4]);
});
test("query key below lower limit", () => {
const queryKey = 300;
const summarize = buildContentResult({ keyDirection, pageSize, getKey });
// choose a hid we know is in the results
const { contents, targetKey, startKey, startKeyIndex } = summarize([updateList, queryKey]);
expect(startKey).toEqual(9);
expect(startKeyIndex).toEqual(1);
expect(targetKey).toEqual(queryKey);
const resultKeys = contents.map(getKey);
expect(resultKeys).toEqual([8, 9]);
});
});
});
@@ -1,27 +0,0 @@
import { SearchParams } from "components/providers/History/SearchParams";
// match object props
export const propMatch = (name) => (a, b) => {
return a[name] === b[name];
};
// comparator for distinct() style operators inputs are an array of [id, SearchParams]
export const inputsSame = ([a0, a1], [b0, b1]) => {
return a0 == b0 && SearchParams.equals(a1, b1);
};
// inputs + hid/cursor comparison
export const loadInputsSame = ([inputsA, cursorA], [inputsB, cursorB]) => {
return cursorA == cursorB && inputsSame(inputsA, inputsB);
};
export const paginationEqual = (a, b) => {
return a.offset == b.offset && a.limit == b.limit;
};
// debugging func for looking at abbreviated payload output
export const reportPayload = (p, keyField = "hid") => {
const { contents = [], ...theRest } = p;
const keys = contents.map((o) => o[keyField]);
return { keys, ...theRest };
};
@@ -1,18 +0,0 @@
/**
* A component mixin and a set of observable utilities used in common by HicstoryContentProvider and
* CollectionContentProvider
*/
// base component definition for History & Collection content providers
export { ContentProvider, defaultPayload } from "./ContentProvider";
// aggreagtion functions collect an initial query and merge in subsequent updates which appear int
// he cache over time to present a unified list of results ordered by a relevant key
export { buildContentResult, newUpdateMap, processContentUpdate, getKeyForUpdateMap } from "./aggregation";
export { aggregateCacheUpdates } from "./aggregateCacheUpdates";
// configurable operators encapsulating shared behavior for content and collections
export { processContentStreams } from "./processContentStreams";
// utils
export { paginationEqual } from "./helpers";
@@ -1,70 +0,0 @@
/**
* General plumbing for the 2 types of content providers. This function returns 3 observables:
* loading, scrolling, and payload. Payload's the most important and the operator that genrates the
* payload from the inputs is passed in as a parameter to this factory.
*/
import { distinctUntilChanged, share, startWith, switchMap } from "rxjs/operators";
import { activity, whenAny, show } from "utils/observable";
import { propMatch } from "./helpers";
import { ScrollPos } from "components/History/model/ScrollPos";
import { SearchParams } from "components/providers/History/SearchParams";
import { defaultPayload } from "../ContentProvider";
import { Subject } from "rxjs";
/**
* Takes incoming history, filters and scroll position and generates loading, scrolling and payload
* observable for rendering the content. payload$ is an observable that emits the data that should
* be displaye
*
* @param {function} payloadOperator observable operator that delivers the payload for this list
* @param {object} sources object containing params$, history$, scrollPos$ source observables
* @param {object} settings configuration parameter object
*
* @return {object} payload$, loading$, scrolling$ observables
*/
// prettier-ignore
export function processContentStreams(payloadOperator, sources = {}, settings = {}) {
const { debouncePeriod, debug = false } = settings;
// clean incoming source streams
const parent$ = sources.parent$.pipe(
distinctUntilChanged(propMatch("id")),
show(debug, (parent) => console.log('processContentStreams: parent changed', parent)),
);
const params$ = sources.params$.pipe(
distinctUntilChanged(SearchParams.equals),
show(debug, (params) => console.log('processContentStreams: params changed', params)),
);
const pos$ = sources.scrollPos$.pipe(
show(debug, (pos) => console.log('processContentStreams: pos changed', pos)),
distinctUntilChanged(ScrollPos.equals),
);
const scrolling$ = sources.scrollPos$.pipe(
activity(200)
);
const loadingEvents$ = new Subject();
const loading$ = loadingEvents$.pipe(
activity(debouncePeriod),
startWith(true)
);
const resetPos$ = new Subject();
// The actual loader, when history or params change we poll against the api and
// set up a cachewatcher, both of which are controlled by the scroll position
const loaderReset$ = whenAny(parent$, params$);
const payload$ = loaderReset$.pipe(
switchMap(([parent, filters]) => pos$.pipe(
show(debug, pos => {
console.clear();
console.log("processContentStreams: pos", JSON.stringify(pos));
}),
payloadOperator({ parent, filters, loadingEvents$, resetPos$, ...settings }),
startWith({ ...defaultPayload, placeholder: true }),
)),
share()
);
return { payload$, scrolling$, loading$, resetPos$ };
}
@@ -1,95 +0,0 @@
/**
* Given a collection, tracks cache changes to the object.
*
* Dataset collections are stored in two places depending on how deeply nested
* it is in the tree. At the top level are root dataset collections which are
* elements of a history's contents. These source their data from the content
* cache. Then inside a dataset collection, it's possible to nest other
* collections arbitrarily, these collections come from the collections cache
* and the api does not necessarily deliver the same fields for both scenarios,
* but we run both raw objects into a DatasetCollection model class.
*/
import { of } from "rxjs";
import { map, pluck, switchMap, distinctUntilChanged } from "rxjs/operators";
import { whenAny } from "utils/observable/whenAny";
import { monitorContentQuery, monitorDscQuery } from "components/providers/History/caching";
import { DatasetCollection } from "components/History/model/DatasetCollection";
import deepEqual from "deep-equal";
// passed object should have a pouch _id for monitoring
const hasPouchId = (o) => "_id" in o;
export default {
props: {
collection: { type: Object, required: true, validator: hasPouchId },
isRoot: { type: Boolean, required: true },
},
data() {
return {
raw: this.collection,
};
},
computed: {
dsc() {
const cleanObj = JSON.parse(JSON.stringify(this.raw));
return new DatasetCollection(cleanObj);
},
},
watch: {
collection(newVal, oldVal) {
if (newVal && !deepEqual(newVal, oldVal)) {
this.raw = newVal;
}
},
dsc(newVal, oldVal) {
if (newVal && !deepEqual(newVal, oldVal)) {
this.$emit("update:collection", newVal);
}
},
},
// prettier-ignore
created() {
const root$ = this.watch$("isRoot", true).pipe(
distinctUntilChanged()
);
const id$ = this.watch$("collection", true).pipe(
pluck("_id"),
distinctUntilChanged()
);
const monitor$ = whenAny(id$, root$).pipe(
switchMap((inputs) => {
const [_id, isRoot] = inputs;
const monitorOperator = isRoot ? monitorContentQuery : monitorDscQuery;
const selector = { _id };
const request = { selector, offset: 0, limit: 1 };
return of(request).pipe(
monitorOperator(),
map((update) => {
const { initialMatches = [], doc = null, deleted } = update;
if (deleted) {
return null;
}
let updatedDoc = doc;
if (initialMatches.length == 1) {
updatedDoc = initialMatches[0];
}
return updatedDoc;
}),
);
})
);
this.$subscribeTo(monitor$, (doc) => {
this.raw = doc;
});
},
render() {
return this.$scopedSlots.default({
dsc: this.dsc,
});
},
};
@@ -1,131 +0,0 @@
/* eslint-disable no-unused-vars */
import { mountRenderless, watchForChange } from "jest/helpers";
import { wipeDatabase } from "components/providers/History/caching";
import { cacheContent, cacheCollectionContent } from "components/providers/History/caching";
import { DatasetCollection } from "components/History/model/DatasetCollection";
import DscProvider from "./DscProvider";
import { testCollection, testNestedCollection } from "components/providers/History/test/testCollection";
// Imports dependencies without firing up worker because that blows with Jest
jest.mock("app");
jest.mock("components/providers/History/caching");
beforeEach(wipeDatabase);
afterEach(wipeDatabase);
describe("DscProvider", () => {
let wrapper;
afterEach(async () => {
if (wrapper) {
wrapper.destroy();
}
wrapper = undefined;
});
describe("monitors a root collection (direct child of a history)", () => {
let rootCollection;
beforeEach(async () => {
rootCollection = await cacheContent(testCollection, true);
expect(rootCollection).toBeDefined();
wrapper = await mountRenderless(DscProvider, {
propsData: {
collection: rootCollection,
isRoot: true,
},
});
expect(wrapper).toBeDefined();
const { newVal: dsc } = await watchForChange({ vm: wrapper.vm, propName: "dsc" });
expect(dsc._id).toEqual(rootCollection._id);
});
afterEach(() => {
rootCollection = undefined;
});
test("should successfully mount and emit the pre-cached data", () => {
// compare output to input prop, should be the same because we didn't change anything
const root = new DatasetCollection(rootCollection);
const emitted = new DatasetCollection(wrapper.vm.dsc);
const { _rev, cached_at, ...originalProps } = root;
const { _rev: new_rev, cached_at: new_cached_at, ...newProps } = emitted;
expect(originalProps).toEqual(newProps);
});
test("should emit updates as they occur", async () => {
const { cached_at: old_cached_at, _rev: old_rev, ...oldProps } = wrapper.vm.dsc;
// write a new field to the collection
const fooVal = "abc123";
rootCollection.foo = fooVal;
const updatedRoot = await cacheContent(rootCollection, true);
expect(updatedRoot.foo).toEqual(fooVal);
// wait for next update then check the foo val
await wrapper.vm.$nextTick();
// all same props except _rev and cached_at and foo
const { cached_at, _rev, foo, ...newProps } = wrapper.vm.dsc;
expect(foo).toEqual(fooVal);
expect(old_cached_at).toBeLessThan(cached_at);
expect(old_rev).not.toEqual(_rev);
expect(newProps).toEqual(oldProps);
});
});
describe("monitors nested collection", () => {
let nestedCollection;
const parent_url = "/foo";
beforeEach(async () => {
// cache a collection root
nestedCollection = await cacheCollectionContent(testNestedCollection, true);
expect(nestedCollection).toBeDefined();
// mount and monitor
wrapper = await mountRenderless(DscProvider, {
propsData: {
collection: nestedCollection,
isRoot: false,
},
});
expect(wrapper).toBeDefined();
const { newVal: dsc } = await watchForChange({ vm: wrapper.vm, propName: "dsc" });
expect(dsc._id).toEqual(nestedCollection._id);
});
afterEach(() => {
nestedCollection = undefined;
});
test("should successfully mount and emit the pre-cached data", () => {
const orig = new DatasetCollection(nestedCollection);
const emitted = new DatasetCollection(wrapper.vm.dsc);
const { _rev, cached_at, ...originalProps } = orig;
const { _rev: new_rev, cached_at: new_cached_at, ...newProps } = emitted;
expect(originalProps).toEqual(newProps);
});
test("should emit updates as they occur", async () => {
const { cached_at: old_cached_at, _rev: old_rev, ...oldProps } = wrapper.vm.dsc;
// write a new field to the collection
const fooVal = "abc123";
nestedCollection.foo = fooVal;
const updateResult = await cacheCollectionContent(nestedCollection, true);
expect(updateResult.foo).toEqual(fooVal);
// wait for next update then check the foo val
// await wait(500);
await wrapper.vm.$nextTick();
// all same props except _rev and cached_at and foo
expect(wrapper.vm.dsc.foo).toEqual(fooVal);
});
});
});
@@ -1,3 +0,0 @@
import DscProvider from "./DscProvider";
export default DscProvider;
@@ -1,34 +0,0 @@
import { ContentProvider, processContentStreams } from "../ContentProvider";
import { contentPayload } from "./contentPayload";
export default {
mixins: [ContentProvider],
props: {
disablePoll: { type: Boolean, default: false },
},
computed: {
history() {
return this.parent;
},
},
watch: {
history(newHistory, oldHistory) {
if (!(newHistory.id == oldHistory.id)) {
this.resetScrollPos();
}
},
},
methods: {
initStreams() {
const { disablePoll, debouncePeriod, pageSize, params$, scrollPos$, debug } = this;
const parent$ = this.watch$("history");
const sources = { params$, parent$, scrollPos$ };
const settings = { disablePoll, debouncePeriod, pageSize, debug };
return processContentStreams(contentPayload, sources, settings);
},
},
};
@@ -1,161 +0,0 @@
import { shallowMount } from "@vue/test-utils";
import { watchUntil, getLocalVue } from "jest/helpers";
import { sameVueObj, payloadChange } from "components/providers/History/test/providerTestHelpers";
import HistoryContentProvider from "./HistoryContentProvider";
import { SearchParams } from "components/providers/History/SearchParams";
import { bulkCacheContent, wipeDatabase } from "components/providers/History/caching";
import { defaultPayload } from "../ContentProvider";
import { serverContent, testHistory, testHistoryContent } from "components/providers/History/test/testHistory";
// reads hids
const payloadHids = (payload) => payload.contents?.map((o) => o.hid) || [];
const localVue = getLocalVue();
// mocking
jest.mock("app");
jest.mock("components/providers/History/caching");
jest.mock("./loadContents");
afterEach(() => wipeDatabase());
describe("HistoryContentProvider", () => {
let wrapper;
beforeEach(async () => {});
afterEach(async () => {
if (wrapper) {
await wrapper.destroy();
}
});
describe("blank initialization state", () => {
beforeEach(async () => {
wrapper = await shallowMount(HistoryContentProvider, {
localVue,
propsData: {
parent: testHistory,
pageSize: 10,
debug: false,
debouncePeriod: 200,
},
scopedSlots: {
default() {},
},
});
// wait for default emit
await payloadChange({ vm: wrapper.vm });
});
test("should expose update methods", async () => {
expect(wrapper.vm.setScrollPos).toBeInstanceOf(Function);
});
test("should show an empty payload", () => {
expect(SearchParams.equals(new SearchParams(), wrapper.vm.params)).toBe(true);
expect(sameVueObj(wrapper.vm.payload, defaultPayload));
});
});
describe("with pre-inserted content", () => {
beforeEach(async () => {
// eslint-disable-next-line no-unused-vars
const cachedContent = await bulkCacheContent(testHistoryContent, true);
wrapper = await shallowMount(HistoryContentProvider, {
localVue,
propsData: {
parent: testHistory,
pageSize: 10,
debug: false,
debouncePeriod: 200,
},
scopedSlots: {
default() {},
},
});
// eslint-disable-next-line no-unused-vars
const spinUp = await watchUntil(wrapper.vm, function () {
const allDone = wrapper.vm.payload.contents.length > 0;
// console.log("done?", allDone);
return allDone;
});
});
// If the cache adds some content, should still be at the top, but the
// bottomRows should change to reflect the difference between the
// returned content rows and the total matches
test("initial load, no scrolling", async () => {
const payload = wrapper.vm.payload;
// reportPayload(payload);
// test data on server
const allContent = serverContent();
// maximum hid in the fake server content
const maxServerHid = allContent[0].hid;
expect(payload).toBeDefined();
const { contents, topRows, bottomRows, startKey, totalMatches } = payload;
expect(topRows).toEqual(0);
expect(contents.length).toBeGreaterThanOrEqual(2 * wrapper.vm.pageSize);
expect(bottomRows).toBeGreaterThan(0);
expect(bottomRows).toEqual(allContent.length - contents.length);
expect(startKey).toEqual(maxServerHid);
expect(totalMatches).toEqual(allContent.length);
});
// When the scroller moves, we may end up over a known content row, in
// which case the scroller handler will get an exact HID match, if so,
// we should have new content values from the hidMap and some topRows
// bottomRows
test("scroll to known key", async () => {
const payload = wrapper.vm.payload;
// shift the scroller down to half way through that content list
// make sure it's visible and not hidden
const targetElement = testHistoryContent[Math.floor(testHistoryContent.length / 2)];
expect(targetElement.visible).toBe(true);
expect(targetElement.deleted).toBe(false);
const targetKey = targetElement.hid;
expect(targetKey).toBeDefined();
expect(targetKey).toBeGreaterThan(0);
// console.log("setting scroll pos to targetKey", targetKey);
wrapper.vm.setScrollPos({ key: targetKey });
// need to wait for it to settle down
await await payloadChange({ vm: wrapper.vm });
// check to see if what we want is there
const scrollPayload = wrapper.vm.payload;
expect(payload).toBeDefined();
const { startKey } = scrollPayload;
const hids = payloadHids(scrollPayload);
// console.log("hids", hids);
expect(hids).toContain(targetKey);
expect(startKey).toEqual(targetKey);
});
// This simulates dragging the scrollbar to a place in the history where
// there is no exact match of HID. The provider should still render
// contents around the targetHID, if they exist
test("should show results when we pick a HID by scroller height", async () => {
// set scroller to non-existent hid
wrapper.vm.setScrollPos({ cursor: 0.52323 });
const scrollPayload = await payloadChange({ vm: wrapper.vm });
const { contents, startKey, topRows, bottomRows, totalMatches } = scrollPayload;
const hids = payloadHids(scrollPayload);
expect(hids).toContain(startKey);
expect(startKey).toBeGreaterThan(0);
expect(topRows).toBeGreaterThan(0);
expect(bottomRows).toEqual(totalMatches - contents.length - topRows);
});
});
});
@@ -1,34 +0,0 @@
// common mock implementation
// having a hard time making jest manually mock this properly
import { map } from "rxjs/operators";
import { getPropRange } from "components/providers/History/caching/loadHistoryContents";
import { serverContent } from "components/providers/History/test/testHistory";
export const loadContents = jest.fn().mockImplementation((config) => {
const { filters } = config;
return map((pagination) => {
const { offset, limit } = pagination;
// server side result set
const searchResults = serverContent(filters);
const { min: minHid, max: maxHid } = getPropRange(searchResults, "hid");
// window slice returned
const result = searchResults.slice(offset, offset + limit);
const { min: minContentHid, max: maxContentHid } = getPropRange(result, "hid");
return {
summary: {},
matches: result.length,
totalMatches: searchResults.length,
minHid,
maxHid,
minContentHid,
maxContentHid,
limit,
offset,
};
});
});
@@ -1,321 +0,0 @@
import { concat, race, timer, partition, Observable, BehaviorSubject, Subject } from "rxjs";
import {
debounceTime,
distinctUntilChanged,
filter,
map,
mapTo,
mergeAll,
pairwise,
pluck,
publish,
share,
shareReplay,
take,
tap,
window,
withLatestFrom,
} from "rxjs/operators";
import { show } from "utils/observable";
import { CurveFit } from "components/History/model/CurveFit";
import { ScrollPos } from "components/History/model/ScrollPos";
import { SearchParams } from "components/providers/History/SearchParams";
import { loadContents } from "./loadContents";
import { watchHistoryContents } from "./watchHistoryContents";
import { default as store } from "store/index";
import { defaultPayload } from "../ContentProvider";
/**
* Observable operator accepts history, search filters as config values, then uses an observable
* scroll position to periodically poll the server for new data and watch the cache for
* corresponding updates for a window of content defined by the cfg props.
*
* @param {object} cfg history, filters, other operation parameters
* @return {Observable} Observable that emits payloads suitable for use in the scroller
*/
// prettier-ignore
export const contentPayload = (cfg = {}) => {
const {
parent: history,
filters = new SearchParams(),
pageSize = SearchParams.pageSize,
debouncePeriod = 250,
loadingTimeout = 2000,
disablePoll = false,
debug = false,
// optional feedback indicators
loadingEvents$ = new Subject(),
resetPos$ = new Subject(),
chunkSize = pageSize
} = cfg;
// These running totals are shared between instances of content payload because a lot of the
// stats do not get returned on every single pol. That means the most recent values need to be
// preserved when the user switches the filters back and forth
const totalMatches$ = getSubject("totalMatches", history, filters, () => new BehaviorSubject(history.hidItems));
const cursorToHid$ = getSubject("cursorToHid", history, filters, () => new BehaviorSubject(createCursorToHid(history)));
const hidToTopRows$ = getSubject("hidToTopRows", history, filters, () => new BehaviorSubject(createHidToTopRows(history)));
return publish((pos$) => {
// #region establish HID by either having exact value or estimating from scroll position
const [ hasKey$ ] = partition(pos$, (pos) => pos.key === null);
const knownHid$ = hasKey$.pipe(
pluck('key')
);
const hid$ = knownHid$.pipe(
distinctUntilChanged(),
filter(hid => !isNaN(hid)),
share(),
);
// #endregion
// #region server loading
const serverHid$ = hid$.pipe(
chunk({ chunkSize, debug, label: "serverHid" }),
distinctUntilChanged(),
debounceTime(debouncePeriod),
show(debug, hid => console.log("serverHid", hid)),
share(),
);
const serverLoad$ = serverHid$.pipe(
tap(() => loadingEvents$.next(true)),
loadContents({ history, filters, disablePoll, debug }),
tap(() => loadingEvents$.next(false)),
tap((response) => updateVuexHistory(history.id, response)),
share(),
);
//#endregion
// #region Cache monitor
// after a fresh server request has been made, wait for first response before allowing
// an input to the cache, avoids bounciness
const cacheHid$ = hid$.pipe(
window(serverHid$),
mergeAll(),
debounceTime(100),
);
const cacheMonitor$ = cacheHid$.pipe(
watchHistoryContents({ history, filters, pageSize, debouncePeriod, debug }),
show(debug, (result) => console.log("cacheMonitor.contents (hid)", result.contents.map(o => o.hid))),
shareReplay(1)
);
const cachePayload$ = cacheMonitor$.pipe(
withLatestFrom(totalMatches$, hidToTopRows$),
map(buildPayload(pageSize)),
show(debug, (payload) => console.log("payload", payload)),
);
// An empty cache response, wait a while then emit an empty payload
const noResults$ = timer(loadingTimeout).pipe(
mapTo({ ...defaultPayload, noResults: true }),
take(1)
);
const noInitialResults$ = concat(noResults$, cachePayload$);
const payload$ = race(noInitialResults$, cachePayload$);
// #endregion
// #region Reposition View to accomodate recent updates
// When new updates to the history come in, we should shift the scrollPos so that we can see
// them. Without some kind of explicit move, the system will think they're just random
// updates that are happening outside the region of interest, and there would be no
// particular reason to shift the view. But that's not ideal when you're already looking at
// the top of the history and expect to see the most recent updates as they come in.
const adjustedScrollPos$ = serverLoad$.pipe(
pairwise(),
filter(([a,b]) => !isNaN(a.maxHid) && !isNaN(b.maxHid)),
withLatestFrom(pos$, hid$),
map(([[lastResponse, response], pos, hid]) => {
const updatesAtTop = response.maxHid >= lastResponse.maxHid;
const scrollerExactlyAtTop = pos.cursor === 0 || pos.key === lastResponse.maxHid;
const fudge = 2;
const scrollNearLastTop = pos.key !== null ? Math.abs(pos.key - lastResponse.maxContentHid) < fudge : false;
const scrollerAtTop = scrollerExactlyAtTop || scrollNearLastTop;
if (updatesAtTop && scrollerAtTop) {
return ScrollPos.create({ cursor: 0, key: response.maxContentHid });
}
return null;
}),
filter(Boolean),
);
// #endregion
// #region serverLoad side-effects, keeps track of important stats via feedback loops
const newCursorToHid$ = serverLoad$.pipe(
withLatestFrom(cursorToHid$),
map(([result, fit]) => adjustCursorToHid(result, fit))
);
const newHidToTopRows$ = serverLoad$.pipe(
withLatestFrom(hidToTopRows$),
map(([result, fit]) => adjustHidToTopRows(result, fit))
);
const newMatches$ = serverLoad$.pipe(
pluck('totalMatches'),
map(val => parseInt(val)),
);
//#endregion
return new Observable((obs) => {
const sub = payload$.subscribe(obs);
// Tie stat feedback subscriptions to main payload sub
// Make sure not to combineLatest these subject accumulators or we'll
// end up with an infinite loop
sub.add(newCursorToHid$.subscribe(cursorToHid$));
sub.add(newHidToTopRows$.subscribe(hidToTopRows$));
sub.add(newMatches$.subscribe(totalMatches$));
sub.add(adjustedScrollPos$.subscribe(resetPos$));
return sub;
})
})
};
const 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 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;
};
const buildPayload = (pageSize) => (inputs) => {
// cacheWatchResult is the value from the cache watcher
// hidToTopRows is a dataset for estimating toprows from HID
// since we can't possibly have that information at the time
// of the cache-read
const [cacheWatchResult, totalMatches, hidToTopRows] = inputs;
// contents is the list from the aggregator
// targetKey was the request value for the aggregation
const { contents = [] } = cacheWatchResult;
let topRows = 0;
let bottomRows = Math.max(0, totalMatches - contents.length - 1);
// missing rows above & below content
if (hidToTopRows.size && contents.length && totalMatches > pageSize) {
const maxHid = contents[0].hid;
const rowsAboveTop = hidToTopRows.get(maxHid, { interpolate: true });
topRows = Math.max(0, Math.round(rowsAboveTop));
bottomRows = Math.max(0, totalMatches - topRows - contents.length);
}
return { topRows, bottomRows, totalMatches, ...cacheWatchResult };
};
const 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;
};
const 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;
};
// Need to stash these subjects in memory since we don't get the counts
// back on every single poll request
const subjects = new Map();
const getSubject = (label, history, filters, subjectFactory) => {
const key = makeSubjectKey(label, history, filters);
if (!subjects.has(key)) {
subjects.set(key, subjectFactory());
}
return subjects.get(key);
};
const makeSubjectKey = (label, history, filters) => {
return JSON.stringify({ label, historyId: history.id, filters: filters.export() });
};
/**
* Updates the vuex history when a polling result comes back.
* TODO: consider storing histories in cache instead of Vuex, since some users have very large lists
* of histories.
*
* @param {string} historyId History id from poll result
* @param {object} pollResponse Poll summary response
*/
const updateVuexHistory = (historyId, pollResponse) => {
const getter = store.getters["betaHistory/getHistoryById"];
const existingHistory = getter(historyId);
const { historySize: size, historyEmpty: empty } = pollResponse;
const history = { ...existingHistory, size, empty };
store.dispatch("betaHistory/setHistory", history);
};
@@ -1,154 +0,0 @@
import { BehaviorSubject, timer } from "rxjs";
import { takeUntil, share } from "rxjs/operators";
import { ObserverSpy } from "@hirez_io/observer-spy";
import { ScrollPos } from "components/History/model/ScrollPos";
import { bulkCacheContent, wipeDatabase } from "components/providers/History/caching";
import { contentPayload } from "./contentPayload";
import { loadContents } from "./loadContents";
import { serverContent, testHistory, testHistoryContent } from "components/providers/History/test/testHistory";
import { untilNthEmission } from "jest/helpers";
jest.mock("app");
jest.mock("components/providers/History/caching");
jest.mock("./loadContents");
// Create a history and a set of filters then wire up a scrollPos to
// the contentPayload operator, check the payloads that come out
describe("contentPayload operator", () => {
let sub;
const safetyTimeout = 2000;
const disablePoll = true;
const debouncePeriod = 250;
beforeEach(async () => {
await wipeDatabase();
await bulkCacheContent(testHistoryContent, true);
});
afterEach(async () => {
if (sub) {
sub.unsubscribe();
}
await wipeDatabase();
});
// The first emission any time we subscribe to the payload should
// be the top of the history, this is because we do not yet have any
// way to gauge how big the history is, so our first emission is always
// from the top of the list, which is where we reset the scroller when
// the inputs change anyway
test("single emission, no scrolling, top of list", async () => {
const spy = new ObserverSpy();
const start = new ScrollPos();
const input$ = new BehaviorSubject(start);
const pageSize = 10;
// prettier-ignore
sub = input$.pipe(
contentPayload({
parent: testHistory,
pageSize,
disablePoll,
debouncePeriod
}),
takeUntil(timer(safetyTimeout))
).subscribe(spy);
await spy.onComplete();
// should finish
expect(spy.receivedComplete()).toBe(true);
// should call the loader once
expect(loadContents.mock.calls.length).toEqual(1);
// should only emit one result
expect(spy.receivedNext()).toBe(true);
expect(spy.getValues().length).toEqual(1);
// expect a payload with results from the top of the list (cursor = 0)
const { contents } = spy.getFirstValue();
expect(contents).toBeDefined();
expect(contents).toBeInstanceOf(Array);
expect(contents.length).toBeGreaterThanOrEqual(2 * pageSize);
const payloadHids = contents.map((c) => c.hid);
expect(payloadHids[0]).toEqual(testHistoryContent[0].hid);
});
// The first emission must be from the top of the list, but then
// the user moves the scroller down half-way. Should get results
// from the middle of the sample contents
test("move half way down the list", async () => {
const pageSize = 5;
// scroll position (cursor/key)
const pos$ = new BehaviorSubject(ScrollPos.create());
// subscribe to payload emitter
// first emission needs to be at top of list
const payload$ = pos$.pipe(
contentPayload({
parent: testHistory,
pageSize,
disablePoll,
}),
takeUntil(timer(safetyTimeout)),
share()
);
const spy = new ObserverSpy();
sub = payload$.subscribe(spy);
// wait until first emission, then move scroller
await untilNthEmission(payload$, 1);
pos$.next(ScrollPos.create({ cursor: 0.5 }));
await spy.onComplete();
expect(spy.receivedComplete()).toBe(true);
expect(spy.receivedNext()).toBe(true);
// spy.getValues().forEach(reportPayload);
// should call the loader once
expect(loadContents.mock.calls.length).toBeGreaterThan(0);
const serverResults = serverContent();
const serverResultHids = serverResults.map((o) => o.hid);
const initialPayload = spy.getFirstValue();
{
const { contents, startKey, startKeyIndex, topRows, bottomRows, totalMatches } = initialPayload;
const listLength = 2 * pageSize;
const payloadHids = contents.map((o) => o.hid);
const historyHids = serverResultHids.slice(startKeyIndex, contents.length);
expect(contents).toBeDefined();
expect(contents).toBeInstanceOf(Array);
expect(contents.length).toBeGreaterThanOrEqual(listLength);
expect(startKey).toEqual(serverResultHids[0]);
expect(startKeyIndex).toEqual(0);
expect(topRows).toEqual(0);
expect(totalMatches).toEqual(serverResults.length);
expect(payloadHids).toEqual(historyHids);
expect(topRows + bottomRows + contents.length).toEqual(totalMatches);
}
const secondPayload = spy.getLastValue();
{
const { contents, startKey, startKeyIndex, topRows, bottomRows, totalMatches } = secondPayload;
const payloadHids = contents.map((o) => o.hid);
expect(contents).toBeDefined();
expect(contents).toBeInstanceOf(Array);
expect(topRows).toBeGreaterThan(0);
expect(totalMatches).toEqual(serverResults.length);
expect(startKeyIndex).toBeGreaterThan(0);
expect(payloadHids).toContain(startKey);
expect(topRows + bottomRows + contents.length).toEqual(totalMatches);
}
});
});
@@ -1,3 +0,0 @@
import HistoryContentProvider from "./HistoryContentProvider";
export default HistoryContentProvider;
@@ -1,57 +0,0 @@
import { defer, of, throwError } from "rxjs";
import { switchMap, debounceTime, startWith, repeatWhen } from "rxjs/operators";
import { decay } from "utils/observable";
import { monitorXHR } from "utils/observable/monitorXHR";
import { loadHistoryContents } from "components/providers/History/caching";
import { SearchParams } from "components/providers/History/SearchParams";
// prettier-ignore
export const loadContents = (cfg = {}) => {
const {
history,
filters,
initialInterval = 2 * 1000,
maxInterval = 10 * initialInterval,
disablePoll = false,
debug = false,
windowSize = 2 * SearchParams.pageSize,
} = cfg;
if (history === undefined) {
return throwError(new Error("Missing history in loadContents"));
}
if (filters === undefined) {
return throwError(new Error("Missing filters in loadContents"));
}
const { id } = history;
const singleLoad = (hid) => defer(() => of([id, filters, hid]).pipe(
loadHistoryContents({ windowSize }),
));
const poll = (request$) => resetPoll(100).pipe(
switchMap(() => request$.pipe(
repeatWhen(completed$ => completed$.pipe(
decay({ initialInterval, maxInterval, debug }),
))
))
);
return switchMap((hid) => {
const request$ = singleLoad(hid);
return disablePoll ? request$ : poll(request$);
})
};
// history, tools routes all refresh
// prettier-ignore
const resetPoll = (debouncePeriod) => {
const routes = [/api\/(history|tools|histories)/];
const methods = ["POST", "PUT", "DELETE"];
return monitorXHR({ methods, routes }).pipe(
startWith(true),
debounceTime(debouncePeriod)
);
};
@@ -1,73 +0,0 @@
The history content provider is where the grunt-work of loading, polling and caching
happens. The output of the provider is a list of current data suitable for rendering in the companion Scroller component.
### Design
The API of the provider is a renderless Vue component. Its inputs are the history and the
currently selected filters. Internally, it tracks the user's current scroll position on the scrolling list then combines those 3 values to ultimately expose an object containing all the
currently-visible content for the history along with some other values that are useful
for the scroller to render the visible window of data.
#### Composition
```html static
<HistoryContentProvider
:parent="history"
:params="params"
v-slot="{ setScrollPos, loading, scrolling, payload }">
<!-- other UI elements that care about the current content,
filters editors, etc -->
<Scroller
:items="payload.contents"
:top-placeholders="payload.topRows"
:bottom-placeholders="payload.bottomRows"
:scroll-to="payload.startKey"
@scroll="setScrollPos">
<template v-slot="{ item, index }">
<!-- single item rendering -->
</template>
</Scroller>
</HistoryContentProvider>
```
#### History Content Provider exposed slot props (outputs)
```js
{
// a function to update the internal scroll position
setScrollPos: fn,
// current content window
payload: {
contents: []
topRows: 10
botttomRows: 100,
startKey: "123",
},
// flags
loading: true,
scrolling: false,
}
```
### Implementation
Because actually delivering that data is kind of a complex process (see dependency graph below) and has multiple goals, Vue's rather
simplistic observability system is not by itself be an ideal means to actually deliver the
constantly shifting payload value. Instead, we used RxJS and its more sophisticated, but admittedly
more complex API.
The goal of this section is to explain how the RxJS works inside the history content provider to
deliver the payload from the 3 changing inputs.
I believe it's easiest to follow RxJS streams by simply drawing a map of them. An rxjs operator is a
functional transformation of a source observable emitter into a new observable emitter and therefore
a combined composition of them (often called a stream) is essentially its own dependency tree. All
you have to do is connect the inputs to the outputs.
### RxJS Flowchart
@@ -1,37 +0,0 @@
import { of } from "rxjs";
import { map } from "rxjs/operators";
import { aggregateCacheUpdates } from "../ContentProvider";
import { monitorHistoryContent } from "components/providers/History/caching";
import { SEEK } from "components/providers/History/caching/enums";
import { SearchParams } from "components/providers/History/SearchParams";
// prettier-ignore
export const watchHistoryContents = (cfg = {}) => hid$ => {
const {
history,
filters = new SearchParams(),
pageSize = SearchParams.pageSize,
debug = false,
keyField = "hid",
keyDirection = SEEK.DESC,
...otherConfig
} = cfg;
// builds a monitor observable based on a hid
const monitor = (hid) => of([history.id, filters, hid]).pipe(
monitorHistoryContent({ pageSize, debug, keyField })
);
// handles the plumbing for observing the query (monitor) and collecting the output into an
// aggregate ordered list focused around the current target key (hid in this case)
return hid$.pipe(
map(val => parseInt(val)),
aggregateCacheUpdates(monitor, {
keyField,
keyDirection,
pageSize,
debug,
...otherConfig
}),
);
};
@@ -1,211 +0,0 @@
import { of, isObservable, timer } from "rxjs";
import { take, takeUntil } from "rxjs/operators";
import { ObserverSpy } from "@hirez_io/observer-spy";
import { untilNthEmission } from "jest/helpers";
import { SearchParams } from "components/providers/History/SearchParams";
import { buildContentId } from "components/providers/History/caching/db/observables";
import { watchHistoryContents } from "./watchHistoryContents";
import { cacheContent, getCachedContent, bulkCacheContent, wipeDatabase } from "components/providers/History/caching";
import historyContent from "components/providers/History/test/json/historyContent.json";
// need to mock monitorHistoryContent which is instantiated inside the worker,
// but we can't actually fire up threads.js in a unit test because you can't
// call expose() on the main thread, so we mock the adapter functions which
// generate observables from the worker.
jest.mock("app");
jest.mock("components/providers/History/caching");
beforeEach(async () => {
await wipeDatabase();
// eslint-disable-next-line no-unused-vars
const cachedContent = await bulkCacheContent(historyContent, true);
// console.log(cachedContent.map((o) => o.hid));
});
afterEach(async () => {
await wipeDatabase();
});
// We're testing observables and we're assuming they complete,
// but we add a takeUntil() to each one in the event that something
// is wrong to avoid making the tests take forever
const safetyTimeout = 1000;
describe("watchHistoryContents", () => {
const firstDoc = historyContent[0];
describe("simple loading scenario", () => {
const hid$ = of(firstDoc.hid);
const history = { id: firstDoc.history_id };
const filters = new SearchParams();
const pageSize = 5;
test("should observe pre-inserted content on the initial emission", async () => {
const watcher$ = hid$.pipe(
watchHistoryContents({
history,
filters,
pageSize,
debug: false,
}),
takeUntil(timer(safetyTimeout))
);
expect(isObservable(watcher$)).toBe(true);
// spy on output of observable, wait for it to end
const spy = new ObserverSpy();
watcher$.subscribe(spy);
await spy.onComplete();
expect(spy.receivedNext()).toBe(true);
expect(spy.receivedComplete()).toBe(true);
expect(spy.getValuesLength()).toBe(1);
const { startKey, contents } = spy.getFirstValue();
// we know there should be an exactd match
expect(startKey).toEqual(firstDoc.hid);
expect(Array.isArray(contents)).toEqual(true);
// Top of list, match + 2 * pageSize
expect(contents.length).toBeGreaterThanOrEqual(2 * pageSize);
});
test("should observe subsequently updated data", async () => {
const watcher$ = hid$.pipe(
watchHistoryContents({
history,
filters,
debouncePeriod: 100,
pageSize: 5,
}),
takeUntil(timer(safetyTimeout))
);
// spy on output of observable, wait for it to end
const spy = new ObserverSpy();
watcher$.subscribe(spy);
// wait for the first event (take(n) as a promise for easier testing)
await untilNthEmission(watcher$, 1, safetyTimeout);
// then update the first doc, need to delay longer than
// the debounce on the watch operator or the aggregation will
// think it's all part of the same update
const docId = buildContentId(firstDoc);
const lookup = await getCachedContent(docId);
expect(lookup.hid).toEqual(firstDoc.hid);
expect(lookup._id).toEqual(docId);
lookup.foo = "abc";
const update = await cacheContent(lookup);
expect(update.updated).toEqual(true);
expect(update.id).toEqual(lookup._id);
expect(update.id).toEqual(docId);
// Should see 2 sets of content, the last of which should have the
// foo field in one of its entries
await spy.onComplete();
expect(spy.receivedNext()).toBe(true);
expect(spy.receivedComplete()).toBe(true);
expect(spy.getValuesLength()).toBe(2);
const { contents: lastContents } = spy.getLastValue();
expect(lastContents.some((doc) => doc.foo == "abc")).toBe(true);
});
});
describe("should respect filter inputs", () => {
const hid$ = of(firstDoc.hid);
const history = { id: firstDoc.history_id };
let filters;
beforeEach(() => {
filters = new SearchParams();
});
test("deleted flag", async () => {
filters.showDeleted = true;
const watcher$ = hid$.pipe(
watchHistoryContents({
history,
filters,
pageSize: 5,
}),
take(1),
takeUntil(timer(safetyTimeout))
);
expect(isObservable(watcher$)).toBe(true);
// spy on output of observable, wait for it to end
const spy = new ObserverSpy();
watcher$.subscribe(spy);
await spy.onComplete();
expect(spy.receivedNext()).toBe(true);
expect(spy.receivedComplete()).toBe(true);
const { contents } = spy.getFirstValue();
expect(Array.isArray(contents)).toEqual(true);
expect(contents.every((doc) => doc.isDeleted == true)).toBe(true);
});
test("hidden flag", async () => {
filters.showHidden = true;
const watcher$ = hid$.pipe(
watchHistoryContents({
history,
filters,
pageSize: 5,
}),
take(1),
takeUntil(timer(safetyTimeout))
);
expect(isObservable(watcher$)).toBe(true);
// spy on output of observable, wait for it to end
const spy = new ObserverSpy();
watcher$.subscribe(spy);
await spy.onComplete();
expect(spy.receivedNext()).toBe(true);
expect(spy.receivedComplete()).toBe(true);
const { contents } = spy.getFirstValue();
expect(Array.isArray(contents)).toEqual(true);
expect(contents.every((doc) => doc.visible == false)).toBe(true);
});
test("deleted and hidden flag", async () => {
filters.showDeleted = true;
filters.showHidden = true;
const watcher$ = hid$.pipe(
watchHistoryContents({
history,
filters,
pageSize: 5,
}),
take(1),
takeUntil(timer(safetyTimeout))
);
expect(isObservable(watcher$)).toBe(true);
// spy on output of observable, wait for it to end
const spy = new ObserverSpy();
watcher$.subscribe(spy);
await spy.onComplete();
expect(spy.receivedNext()).toBe(true);
expect(spy.receivedComplete()).toBe(true);
const { contents } = spy.getFirstValue();
expect(Array.isArray(contents)).toEqual(true);
expect(contents.every((doc) => doc.visible == false)).toBe(true);
expect(contents.every((doc) => doc.isDeleted == true)).toBe(true);
});
});
});
@@ -1,228 +0,0 @@
import deepEqual from "deep-equal";
import { isString } from "underscore";
const pairSplitRE = /(\w+=\w+)|(\w+="(\w|\s)+")/g;
const scrubFieldRE = /[^\w]/g;
const scrubQuotesRE = /'|"/g;
// Fields that can be used for text searches
const validTextFields = new Set([
"name",
"history_content_type",
"type",
"format",
"extension",
"misc_info",
"state",
"hid",
"database",
"annotation",
"description",
"tag",
"tags",
]);
// alias search field to internal field (requestd name: pouch field name)
const pouchFieldAlias = {
format: "file_ext",
database: "genome_build",
description: "annotation",
tag: "tags",
deleted: "isDeleted",
type: "history_content_type",
};
// maps user-field -> server querystring field
// if maps to null, that filter not available on server
const serverFieldAlias = {
tags: "tag",
file_ext: null,
genome_build: null,
};
// Convert actualy boolean into objectively incorrect python value our server accepts
const dumbBool = (val) => (val ? "True" : "False");
export class SearchParams {
constructor(props = {}) {
this.filterText = "";
this.showDeleted = false;
this.showHidden = false;
Object.assign(this, props);
}
get pageSize() {
return SearchParams.pageSize;
}
clone() {
return new SearchParams(this);
}
// need this because of what Vue does to objects to make them reactive
export() {
const { filterText, showDeleted, showHidden } = this;
return { filterText, showDeleted, showHidden };
}
// Filtering, parses single text input into a map of field->value
// in the case of multiples, maps to field -> [value, value]
get textCriteria() {
const raw = this.filterText;
const result = new Map();
if (!raw.length) {
return result;
}
let matches = raw.match(pairSplitRE);
if (matches === null && raw.length) {
matches = [`name=${raw}`];
}
const criteria = matches.reduce((result, pair) => {
const [field, val] = pair.split("=");
if (validTextFields.has(field)) {
let cleanVal = val.replace(scrubQuotesRE, "");
// set an array of criteria if we have multiples of the same field name
if (result.has(field)) {
cleanVal = [result.get(field), cleanVal].flat();
}
result.set(field, cleanVal);
}
return result;
}, result);
return criteria;
}
// all criteria, map of field->value, includes our non-standard boolean filters
get criteria() {
const criteria = new Map(this.textCriteria);
criteria.set("visible", !this.showHidden);
criteria.set("deleted", this.showDeleted);
return criteria;
}
// Generates an array of pouchDB selector objects
// { field: { $operator: value }}
get pouchFilters() {
const filters = Array.from(this.criteria)
// generate multiple objects for duplicated field=val entries
// these will be AND-ed together in the final pouch selector
// map userfield to pouchfield, userfield = what the user typed in the box
// pouchfield = the actual field in the cache
.map(([userField, val]) => [this.getPouchFieldName(userField), val])
.map(([pouchField, val]) => {
const vals = Array.isArray(val) ? val : [val];
return vals.map((v) => [pouchField, v]);
})
.flat()
.map(([pouchField, val]) => {
const comparator = isString(val) ? { $regex: new RegExp(val) } : { $eq: val };
return { [pouchField]: comparator };
});
return filters;
}
// Creates an object of query criteria for use with the api
get historyContentQueryFields() {
return Array.from(this.criteria)
.map(([userField, val]) => {
let serverField = this.getServerFieldName(userField);
let serverVal = val;
switch (serverField) {
// some client-side filters do not correspond to filters on the server
// they can be used to filter local results but will not affect the polling
// TODO: consider adding them as available filter options on the api?
case null:
return [];
// This is advertised to work, but is broken on the current api
case "annotation":
case "description":
return [];
// non-standard REST bools
// deleted serverField was reserved by pouchDB, needed to rename it to "isDeleted"
case "deleted":
case "visible":
serverVal = dumbBool(val);
break;
// no text searching in some fields
case "hid":
case "state":
case "history_content_type":
case "type":
break;
// assume text-contains search
default:
serverField = serverField + "-contains";
serverVal = encodeURIComponent(val);
break;
}
return [serverField, serverVal];
})
.filter((pair) => pair.length == 2)
.reduce((fields, [k, v]) => {
fields[k] = v;
return fields;
}, {});
}
// legacy q/qv syntax for content api
// TODO: Delete when we no longer use q/qv
get legacyContentQueryString() {
return Object.entries(this.historyContentQueryFields)
.map(([f, v]) => `q=${f}&qv=${v}`)
.join("&");
}
// Standard query string (field=val&field=val...)
get historyContentQueryString() {
return Object.entries(this.historyContentQueryFields)
.map(([f, v]) => `${f}=${v}`)
.join("&");
}
// maps friendly user field name to internal data field if necessary
getPouchFieldName(field) {
const cleanName = field.replace(scrubFieldRE, "");
return cleanName in pouchFieldAlias ? pouchFieldAlias[cleanName] : cleanName;
}
getServerFieldName(field) {
const cleanName = field.replace(scrubFieldRE, "");
return cleanName in serverFieldAlias ? serverFieldAlias[cleanName] : cleanName;
}
// output current state to log
report(label = "params", collapsed = true) {
const { showDeleted, showHidden, filterText } = this;
const consoleOpen = collapsed ? console.groupCollapsed : console.group;
consoleOpen.call(console, label);
console.log("showDeleted", showDeleted);
console.log("showHidden", showHidden);
console.log("filterText", filterText);
console.groupEnd();
}
}
// Statics
SearchParams.pageSize = 30;
SearchParams.equals = function (a, b) {
if (a !== undefined && b !== undefined) {
return deepEqual(a.export(), b.export());
}
return false;
};
@@ -1,376 +0,0 @@
// I'm using this as the reference for the requirement:
// https://training.galaxyproject.org/training-material/topics/galaxy-interface/tutorials/history/tutorial.html#advanced-searching
import { SearchParams } from "./SearchParams";
import { matchesSelector } from "pouchdb-selector-core";
import { STATES } from "components/History/model/states";
// #region Test generators
const testPouchSelector = (userField, searchVal, goodDoc, badDoc) => {
let pouchFieldName;
let params;
let selector;
beforeEach(() => {
params = new SearchParams();
params.filterText = `${userField}="${searchVal}"`;
selector = { $and: params.pouchFilters };
pouchFieldName = params.getPouchFieldName(userField);
});
describe("pouchdb selector", () => {
it("should have a row in the selector", () => {
const hasRow = selector.$and.some((row) => row[pouchFieldName] !== undefined);
expect(hasRow).toBeTruthy();
});
it("should return results with matching name field", () => {
expect(matchesSelector(goodDoc, selector)).toBeTruthy();
});
it("should not return results where the name string appears in other fields", () => {
expect(matchesSelector(badDoc, selector)).toBeFalsy();
});
});
};
const testUrlGeneration = (userField, searchVal) => {
describe("request querystring", () => {
it("should generate a query string with the expected server-side parameter", () => {
const params = new SearchParams();
params.filterText = `${userField}="${searchVal}"`;
const serverField = params.getServerFieldName(userField);
if (serverField) {
const qs = params.historyContentQueryString;
const queryParam = `${serverField}-contains=${encodeURIComponent(searchVal)}`;
expect(qs.includes(queryParam)).toBeTruthy();
}
});
});
};
// TODO: fix api to actually process the fields that it says it will
const testNotInUrl = (userField, searchVal) => {
describe("request querystring", () => {
it("should not send this filter, because it does not work on the api as described", () => {
const params = new SearchParams();
params.filterText = `${userField}="${searchVal}"`;
const serverField = params.getServerFieldName(userField);
const qs = params.historyContentQueryString;
expect(qs.includes(serverField)).toBeFalsy();
});
});
};
//#endregion
describe("history contents search field text parsing", () => {
describe("single field criteria", () => {
// name="FASTQC on"
// Any datasets with “FASTQC on” in the title, but avoids items which
// have “FASTQC on” in other fields like the description or annotation.
describe("name", () => {
const searchField = "name";
const searchTerm = "FASTQC on";
const goodDoc = {
_id: "anything",
name: "asfasdfasdfasdf FASTQC onsdfasdfasdf",
visible: true,
isDeleted: false,
};
const badDoc = {
_id: "anything",
otherField: "asfasdfasdfasdf FASTQC onsdfasdfasdf",
visible: true,
isDeleted: false,
};
testPouchSelector(searchField, searchTerm, goodDoc, badDoc);
testUrlGeneration(searchField, searchTerm);
});
// format=vcf
// Datasets with a specific format. Some formats are hierarchical, e.g.
// searching for fastq will find fastq files but also fastqsanger and
// fastqillumina files. You can see more formats in the upload dialogue
describe("format", () => {
const searchField = "format";
const searchTerm = "vcf";
const goodDoc = {
_id: "anything",
file_ext: "vcf",
visible: true,
isDeleted: false,
};
const badDoc = {
_id: "anything",
file_ext: "notgonnamatch",
visible: true,
isDeleted: false,
};
testPouchSelector(searchField, searchTerm, goodDoc, badDoc);
testUrlGeneration(searchField, searchTerm);
});
// database=hg19 Datasets with a specific reference genome
describe("database", () => {
const searchField = "database";
const searchTerm = "hg19";
const goodDoc = {
_id: "anything",
genome_build: "hg19",
visible: true,
isDeleted: false,
};
const badDoc = {
_id: "anything",
genome_build: "somethingelse",
visible: true,
isDeleted: false,
};
testPouchSelector(searchField, searchTerm, goodDoc, badDoc);
testUrlGeneration(searchField, searchTerm);
});
// annotation="first of five"
describe("annotation", () => {
const searchField = "annotation";
const searchTerm = "first of fiv";
const goodDoc = {
_id: "anything",
annotation: "this is the first of five",
visible: true,
isDeleted: false,
};
const badDoc = {
_id: "anything",
annotation: "somethingelse",
visible: true,
isDeleted: false,
};
testPouchSelector(searchField, searchTerm, goodDoc, badDoc);
testNotInUrl(searchField, searchTerm);
});
// NOTE: is annotation different from description?
// description="This is data of a Borneo Orangutan" for dataset summary
describe("description (same as annotation?)", () => {
const searchField = "description";
const searchTerm = "This is data of a Borneo Orangutan";
const goodDoc = {
_id: "anything",
annotation: "asdfasdfThis is data of a Borneo Orangutansdfasdf",
visible: true,
isDeleted: false,
};
const badDoc = {
_id: "anything",
annotation: "somethingelse",
visible: true,
isDeleted: false,
};
testPouchSelector(searchField, searchTerm, goodDoc, badDoc);
testNotInUrl(searchField, searchTerm);
});
// info="started mapping"
// for searching on job’s info field.
// describe("job info", () => {});
// tag=experiment1 tag=to_publish
// for searching on (a partial) dataset tag.
describe("tags", () => {
const searchField = "tags";
const searchTerm = "experiment1";
const goodDoc = {
_id: "anything",
tags: ["experiment1", "experiment2"],
visible: true,
isDeleted: false,
};
const badDoc = {
_id: "anything",
tags: ["nodice"],
visible: true,
isDeleted: false,
};
testPouchSelector(searchField, searchTerm, goodDoc, badDoc);
testUrlGeneration(searchField, searchTerm);
});
// hid=25
// A specific history item ID (based on the ordering in the history)
describe("hid", () => {
const searchField = "hid";
const searchTerm = 25;
const goodDoc = {
_id: "anything",
hid: 25,
visible: true,
isDeleted: false,
};
const badDoc = {
_id: "anything",
hid: 200,
visible: true,
isDeleted: false,
};
testPouchSelector(searchField, searchTerm, goodDoc, badDoc);
describe("request querystring", () => {
it("hid should show up with an =, no text-search", () => {
const params = new SearchParams();
params.filterText = `${searchField}="${searchTerm}"`;
const qs = params.historyContentQueryString;
const serverField = params.getServerFieldName(searchField);
const fragment = `${serverField}=${searchTerm}`;
expect(qs.includes(fragment)).toBeTruthy();
});
});
});
// state=error
// To show only datasets in a given state. Other options include ok,
// running, paused, and new.
describe("state", () => {
const searchField = "state";
const searchTerm = STATES.OK;
const goodDoc = {
_id: "anything",
hid: 25,
visible: true,
state: STATES.OK,
isDeleted: false,
};
const badDoc = {
_id: "anything",
hid: 200,
visible: true,
isDeleted: false,
state: STATES.ERROR,
};
testPouchSelector(searchField, searchTerm, goodDoc, badDoc);
describe("request querystring", () => {
it("state should show up with an =, no text-search", () => {
const params = new SearchParams();
params.filterText = `${searchField}="${searchTerm}"`;
const qs = params.historyContentQueryString;
const serverField = params.getServerFieldName(searchField);
const fragment = `${serverField}=${searchTerm}`;
expect(qs.includes(fragment)).toBeTruthy();
});
});
});
});
describe("multiple field criteria", () => {
it("should allow a boolean AND combination of 2 filters", () => {
const goodDoc = {
_id: "anything",
name: "sadfasdffooasdfasdf",
tags: ["abc", "def", "blah"],
visible: true,
isDeleted: false,
};
const onlyOneMatch = {
_id: "anything",
name: "sadfasdffooasdfasdf",
visible: true,
isDeleted: false,
};
const theOtherMatch = {
_id: "anything",
tags: ["abc", "def", "blah"],
visible: true,
isDeleted: false,
};
const params = new SearchParams();
params.filterText = "name=foo tag=blah";
const selector = { $and: params.pouchFilters };
expect(matchesSelector(goodDoc, selector)).toBeTruthy();
expect(matchesSelector(onlyOneMatch, selector)).toBeFalsy();
expect(matchesSelector(theOtherMatch, selector)).toBeFalsy();
});
});
// TODO: There is a bug in the mongo query syntax implementation that stops us from having
// two simultaneous criteria for the same field with the same type of operator, example
// can't have { $and: [ { tag: { $regex: /abc/ }, { tag: { $regex: /def/ }}]}
// https://github.com/pouchdb/pouchdb/issues/8265
// I'll have to come up with a better solution for that AND intersection feature.
xdescribe("deferring this functionality for now", () => {
it("should allow a boolean AND combination of 2 of the same filter", () => {
const doc = {
_id: "anything",
name: "abc def",
visible: true,
isDeleted: false,
};
// this should match
const params = new SearchParams();
params.filterText = "name=abc name=def";
const selector = { $and: params.pouchFilters };
expect(matchesSelector(doc, selector)).toBeTruthy();
// this should not, it fails because of the bug which overwrites the regex on the field,
// making it impossible to run 2 regexes against "name"
const params2 = new SearchParams();
params2.filterText = "name=sfsfssf name=def";
const selector2 = params2.buildHistoryContentSelector();
expect(matchesSelector(doc, selector2)).toBeFalsy();
});
it("should allow a boolean AND combination of 2 filters of for the same field, partial matches", () => {
const doc = {
_id: "anything",
tags: ["abcasdfasdf", "def"],
visible: true,
isDeleted: false,
};
// this should match
const params = new SearchParams();
params.filterText = "tags=abc tag=def";
const selector = { $and: params.pouchFilters };
expect(matchesSelector(doc, selector)).toBeTruthy();
// this should not
const params2 = new SearchParams();
params2.filterText = "tags=foo tag=def";
const selector2 = { $and: params2.pouchFilters };
expect(matchesSelector(doc, selector2)).toBeFalsy();
});
});
});
@@ -35,7 +35,6 @@ import {
} from "components/History/model/queries";
jest.mock("app");
jest.mock("components/providers/History/caching");
jest.mock("components/History/model/queries");
const maxServerDelay = 60;
@@ -3,18 +3,5 @@
* cache or other observables to emit end results as slot props.
*/
// These are pretty complex. Input parameters from "the top" are the History or
// selected collection, but they also expose slot-prop methods to update
// internal data such as user-provided filter parameters or the physical
// position of the scroll cursor in the list UI
export { default as HistoryContentProvider } from "./HistoryContentProvider";
export { default as CollectionContentProvider } from "./CollectionContentProvider";
// The DscProvider is only complex because we store dataset collection data in
// two places depending on its place in the data hierarchy. At the root level, a
// 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";
@@ -1,23 +0,0 @@
{
"importable": false,
"username_and_slug": null,
"hid_counter": 101,
"contents_url": "/api/histories/2f94e8ae9edff68a/contents",
"annotation": "this is the annotation",
"genome_build": null,
"create_time": "2020-06-22T20:24:57.630416",
"empty": false,
"purged": false,
"update_time": "2020-07-07T21:03:42.514499",
"user_id": "f2db41e1fa331b3e",
"name": "160-ish items",
"slug": null,
"tags": [],
"published": false,
"nice_size": "708 bytes",
"size": 708,
"url": "/api/histories/2f94e8ae9edff68a",
"deleted": false,
"state": "ok",
"id": "2f94e8ae9edff68a"
}
@@ -1,842 +0,0 @@
[
{
"model_class": "DatasetCollectionElement",
"id": "068bb135123e39c3",
"element_type": "hda",
"element_index": 0,
"element_identifier": "0",
"object": {
"id": "e746d19c9c8cc71c",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "cc0e2ba29cc1d8af",
"element_type": "hda",
"element_index": 1,
"element_identifier": "1",
"object": {
"id": "bd2618511bd6d954",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "088766ba409c97ae",
"element_type": "hda",
"element_index": 2,
"element_identifier": "10",
"object": {
"id": "b1aecce06b45adc8",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "e48823c5da673276",
"element_type": "hda",
"element_index": 3,
"element_identifier": "100",
"object": {
"id": "01cda2950d5e63c3",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "cf17043882bf0911",
"element_type": "hda",
"element_index": 4,
"element_identifier": "1000",
"object": {
"id": "5f2de0d744e8c295",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "a088eb6d1a3dbcab",
"element_type": "hda",
"element_index": 5,
"element_identifier": "10000",
"object": {
"id": "43bce5ef002caad3",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "35cddac7ff46d42d",
"element_type": "hda",
"element_index": 6,
"element_identifier": "100000",
"object": {
"id": "5fb23b4e4319a7c5",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "eac6cd9007b1908f",
"element_type": "hda",
"element_index": 7,
"element_identifier": "100001",
"object": {
"id": "80233f5f28a6c72f",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "aa4f9dc4708b54b6",
"element_type": "hda",
"element_index": 8,
"element_identifier": "100002",
"object": {
"id": "1c5156c6a0363f35",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "cbd3da2c4f117e14",
"element_type": "hda",
"element_index": 9,
"element_identifier": "100003",
"object": {
"id": "af091cf96ab275f3",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "68a588a2a9524bc5",
"element_type": "hda",
"element_index": 10,
"element_identifier": "100004",
"object": {
"id": "97b2c68eeabafb3d",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "a58d0afa3c515242",
"element_type": "hda",
"element_index": 11,
"element_identifier": "100005",
"object": {
"id": "33867d668bd83fde",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "f0c8870154edf2d6",
"element_type": "hda",
"element_index": 12,
"element_identifier": "100006",
"object": {
"id": "1914213279532687",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "018c8c5d62a8df97",
"element_type": "hda",
"element_index": 13,
"element_identifier": "100007",
"object": {
"id": "713b988311601359",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "d0c151b99af9360b",
"element_type": "hda",
"element_index": 14,
"element_identifier": "100008",
"object": {
"id": "f38a73020009daba",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "29eac6dcbd14f883",
"element_type": "hda",
"element_index": 15,
"element_identifier": "100009",
"object": {
"id": "622229cf50f04387",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "a158574d58234118",
"element_type": "hda",
"element_index": 16,
"element_identifier": "10001",
"object": {
"id": "b68d17846c7fbc83",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "2b98540a14173828",
"element_type": "hda",
"element_index": 17,
"element_identifier": "100010",
"object": {
"id": "6eeb1155086b617b",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "8e114d54b2d1f419",
"element_type": "hda",
"element_index": 18,
"element_identifier": "100011",
"object": {
"id": "e7746068f68fc864",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "749fa1ed60051f98",
"element_type": "hda",
"element_index": 19,
"element_identifier": "100012",
"object": {
"id": "8051d6590bfd64e9",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "911f2df796ad747c",
"element_type": "hda",
"element_index": 20,
"element_identifier": "100013",
"object": {
"id": "5cba928dd1a05f48",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "faa6c009defd91d0",
"element_type": "hda",
"element_index": 21,
"element_identifier": "100014",
"object": {
"id": "eac6ce67595195ec",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "6129b0ae6f8527f0",
"element_type": "hda",
"element_index": 22,
"element_identifier": "100015",
"object": {
"id": "98d427a4631d635a",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "789438fb3dff9d11",
"element_type": "hda",
"element_index": 23,
"element_identifier": "100016",
"object": {
"id": "ddf8d1e1eb7b0fb7",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "a7d7479d06608eca",
"element_type": "hda",
"element_index": 24,
"element_identifier": "100017",
"object": {
"id": "7083648ea486ebf6",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "440b7cc14c8c4d06",
"element_type": "hda",
"element_index": 25,
"element_identifier": "100018",
"object": {
"id": "aaf0f2ebe11ae45c",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "fb4f28349f5baf2d",
"element_type": "hda",
"element_index": 26,
"element_identifier": "100019",
"object": {
"id": "c78ca882684b7434",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "87fd7db76c544224",
"element_type": "hda",
"element_index": 27,
"element_identifier": "10002",
"object": {
"id": "c9c548cca771bdbc",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "a9431fe40e66ea34",
"element_type": "hda",
"element_index": 28,
"element_identifier": "100020",
"object": {
"id": "2fbde361d6a31e7e",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "43acf2f33287edcb",
"element_type": "hda",
"element_index": 29,
"element_identifier": "100021",
"object": {
"id": "b73abcd044761674",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "9a74eaab42e02827",
"element_type": "hda",
"element_index": 30,
"element_identifier": "100022",
"object": {
"id": "7326e2004b6d8758",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "6d8e4d965b771ea5",
"element_type": "hda",
"element_index": 31,
"element_identifier": "100023",
"object": {
"id": "e16a1758d8c32a91",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "c69237419b0ccecb",
"element_type": "hda",
"element_index": 32,
"element_identifier": "100024",
"object": {
"id": "16d9d7a5407b90bb",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "2d09ebff4dc98b05",
"element_type": "hda",
"element_index": 33,
"element_identifier": "100025",
"object": {
"id": "be6aaa8b33c20354",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "359f5b7c99fbb2a6",
"element_type": "hda",
"element_index": 34,
"element_identifier": "100026",
"object": {
"id": "f0d2dd7ce1a0d1d7",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "890b48f1534ba81c",
"element_type": "hda",
"element_index": 35,
"element_identifier": "100027",
"object": {
"id": "a4243929ecd1e137",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "4220951a437ba60e",
"element_type": "hda",
"element_index": 36,
"element_identifier": "100028",
"object": {
"id": "efdbb5c431ed86c4",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "f306434746a46cb6",
"element_type": "hda",
"element_index": 37,
"element_identifier": "100029",
"object": {
"id": "0c7525f276af0cb2",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "abc747938ed8a600",
"element_type": "hda",
"element_index": 38,
"element_identifier": "10003",
"object": {
"id": "0032a856b26c744e",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "63636a455c5b21fa",
"element_type": "hda",
"element_index": 39,
"element_identifier": "100030",
"object": {
"id": "aa1ea414cbd09e79",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "e8c3a4e75936ee34",
"element_type": "hda",
"element_index": 40,
"element_identifier": "100031",
"object": {
"id": "9a0d644f6e3ebb6f",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "25dce1f99c32a06f",
"element_type": "hda",
"element_index": 41,
"element_identifier": "100032",
"object": {
"id": "c6d7ec9dd85960fd",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "0f308c8bc2dba01e",
"element_type": "hda",
"element_index": 42,
"element_identifier": "100033",
"object": {
"id": "c1e28fb74593e606",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "134707f5254d3467",
"element_type": "hda",
"element_index": 43,
"element_identifier": "100034",
"object": {
"id": "edb9e58b575cfb18",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "af2a47bf25ac31c6",
"element_type": "hda",
"element_index": 44,
"element_identifier": "100035",
"object": {
"id": "595375e544772627",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "9b4142aa13cacd8d",
"element_type": "hda",
"element_index": 45,
"element_identifier": "100036",
"object": {
"id": "d917f9554bc7b276",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "8732705714d1b967",
"element_type": "hda",
"element_index": 46,
"element_identifier": "100037",
"object": {
"id": "17ac36cd25e09d75",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "9faac61c404df8d0",
"element_type": "hda",
"element_index": 47,
"element_identifier": "100038",
"object": {
"id": "fc587326c65e935c",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "eec6329884514299",
"element_type": "hda",
"element_index": 48,
"element_identifier": "100039",
"object": {
"id": "0f1c2717b9554064",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "0a7c50c76808cb45",
"element_type": "hda",
"element_index": 49,
"element_identifier": "10004",
"object": {
"id": "175262dbcbd9987f",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "1ff31d34983529f8",
"element_type": "hda",
"element_index": 50,
"element_identifier": "100040",
"object": {
"id": "7c3fb968df7e50f1",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "0af255ba4961216c",
"element_type": "hda",
"element_index": 51,
"element_identifier": "100041",
"object": {
"id": "cf83f2dcc9f7362b",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "5be07bbe21af2e4a",
"element_type": "hda",
"element_index": 52,
"element_identifier": "100042",
"object": {
"id": "4efb7c25e2bb7fa8",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "8ab1435b8102e81e",
"element_type": "hda",
"element_index": 53,
"element_identifier": "100043",
"object": {
"id": "defeea39a9509c16",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "842e3a8e9b827812",
"element_type": "hda",
"element_index": 54,
"element_identifier": "100044",
"object": {
"id": "5f5b885264688585",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "a5824a57e59503dc",
"element_type": "hda",
"element_index": 55,
"element_identifier": "100045",
"object": {
"id": "041689872363985f",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "616698141b35a61f",
"element_type": "hda",
"element_index": 56,
"element_identifier": "100046",
"object": {
"id": "2839cb63aea76bb4",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "d36010717df75f3d",
"element_type": "hda",
"element_index": 57,
"element_identifier": "100047",
"object": {
"id": "4236bdc00992172f",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "f5d1282ba4e61ea9",
"element_type": "hda",
"element_index": 58,
"element_identifier": "100048",
"object": {
"id": "bf8ded787d0c5b53",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "8dc4bd112d29b27d",
"element_type": "hda",
"element_index": 59,
"element_identifier": "100049",
"object": {
"id": "023453c935463dba",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
}
]
@@ -1,30 +0,0 @@
[
{
"element_identifier": "forward",
"object": {
"hda_ldda": "hda",
"history_id": "4ff6f47412c3e65e",
"state": "ok",
"model_class": "HistoryDatasetAssociation",
"id": "d5836ddca35af8fe"
},
"id": "40876639881ca029",
"element_index": 0,
"element_type": "hda",
"model_class": "DatasetCollectionElement"
},
{
"element_identifier": "reverse",
"object": {
"hda_ldda": "hda",
"history_id": "4ff6f47412c3e65e",
"state": "ok",
"model_class": "HistoryDatasetAssociation",
"id": "c1209dcf6a15c53b"
},
"id": "d071e794759ab192",
"element_index": 1,
"element_type": "hda",
"model_class": "DatasetCollectionElement"
}
]
File diff suppressed because it is too large Load Diff
@@ -1,256 +0,0 @@
[
{
"element_count": 2,
"tags": [],
"id": "a799d38679e985db",
"name": "asdaaa",
"type": "collection",
"type_id": "dataset_collection-a799d38679e985db",
"contents_url": "/api/dataset_collections/a799d38679e985db/contents/03501d7626bd192f",
"create_time": "2020-06-24T15:34:06.848662",
"populated": true,
"populated_state": "ok",
"collection_id": 10,
"collection_type": "list",
"job_state_summary": {
"running": 0,
"resubmitted": 0,
"upload": 0,
"deleted": 0,
"error": 0,
"all_jobs": 0,
"waiting": 0,
"queued": 0,
"ok": 0,
"new": 0,
"failed": 0,
"paused": 0,
"deleted_new": 0
},
"populated_state_message": null,
"job_source_id": null,
"deleted": false,
"visible": true,
"hid": 22,
"update_time": "2020-06-24T15:34:06.848669",
"history_id": "63cd3858d057a6d1",
"history_content_type": "dataset_collection",
"job_source_type": null,
"url": "/api/histories/63cd3858d057a6d1/contents/dataset_collections/a799d38679e985db"
},
{
"hda_ldda": "hda",
"tags": [],
"type": "file",
"resubmitted": false,
"validated_state_message": null,
"misc_info": "uploaded fastqsanger file",
"deleted": false,
"id": "ab50e8da6b53023e",
"type_id": "dataset-ab50e8da6b53023e",
"rerunnable": false,
"display_types": [],
"create_time": "2020-06-24T11:31:35.680953",
"uuid": "92efa8a4-92ab-4941-977a-7d653e95c250",
"data_type": "galaxy.datatypes.sequence.FastqSanger",
"state": "ok",
"download_url": "/api/histories/63cd3858d057a6d1/contents/ab50e8da6b53023e/display",
"model_class": "HistoryDatasetAssociation",
"dataset_id": "ee712460b9af5737",
"file_ext": "fastqsanger",
"meta_files": [],
"file_name": "/Users/mason/Projects/ohsu/galaxy-data-files/001/dataset_1030.dat",
"annotation": null,
"creating_job": "ee712460b9af5737",
"accessible": true,
"name": "abcdeff",
"created_from_basename": null,
"visible": true,
"hid": 8,
"update_time": "2020-09-23T00:23:52.834927",
"history_id": "63cd3858d057a6d1",
"misc_blurb": "207.5 MB",
"peek": "<table cellspacing=\"0\" cellpadding=\"3\"><tr><td>@M01368:20:000000000-A46F5:1:1101:10294:8369/2<\/td><\/tr><tr><td>CTCCAGGCCCATAACACTTGGGGGTAGCTAAAGTGAACTGTATCCGACATCTGGTTCCTACTTCAGGGCCATAAAGCCTAAATAGCCCACACGTTCCCCTTAAATAAGACATCACGATGGATCACAGGTCTATCACCCTATTAACCACTCACGGGAGCTCTCCATGCATTTGGTATTTTCGTCTGGGGGGTATGCACGCGATAGCATTGCGAGACGCTGGAGCCGGAGCACCCTATGTCGCAGTAT<\/td><\/tr><tr><td>+<\/td><\/tr><tr><td>BBCCCFFCCCCFGGGGGGGGGGGGGGGHHHHHGHHHHHHHHHHHHGGGGGHHHHHHHHHFHGHHHHGGGGCHGHHHHEHFHFHHGHHHHGGGGHHHHHHHHHHFHHFHHGHHHHHGGHGGHHHHHHGHGGGHHHHHHHGFHHGFHHHGHHHHHGGFGGHGHHHHHHHHHHHGHHEHHHHHGHGHGHGGGFFFFFFFFFFFFFDFFFFFFFF;D=DDFFFA.AFFFAA;A.;.AAFFFBBEECDFEF<\/td><\/tr><tr><td>@M01368:20:000000000-A46F5:1:1101:10302:11041/2<\/td><\/tr><\/table>",
"history_content_type": "dataset",
"purged": false,
"display_apps": [],
"file_size": 217570404,
"genome_build": "?",
"validated_state": "unknown",
"extension": "fastqsanger",
"url": "/api/histories/63cd3858d057a6d1/contents/ab50e8da6b53023e",
"api_type": "file"
},
{
"hda_ldda": "hda",
"tags": [],
"type": "file",
"resubmitted": false,
"validated_state_message": null,
"misc_info": "uploaded fastqsanger file",
"deleted": false,
"id": "ee712460b9af5737",
"type_id": "dataset-ee712460b9af5737",
"rerunnable": false,
"display_types": [],
"create_time": "2020-06-24T11:31:25.377836",
"uuid": "a8a88acf-7cd4-4acc-9bf0-23a917e62d94",
"data_type": "galaxy.datatypes.sequence.FastqSanger",
"state": "ok",
"download_url": "/api/histories/63cd3858d057a6d1/contents/ee712460b9af5737/display",
"model_class": "HistoryDatasetAssociation",
"dataset_id": "9d04922baebb355f",
"file_ext": "fastqsanger",
"meta_files": [],
"file_name": "/Users/mason/Projects/ohsu/galaxy-data-files/001/dataset_1029.dat",
"annotation": null,
"creating_job": "9d04922baebb355f",
"accessible": true,
"name": "M117C1-ch_1.fq.fastqsan",
"created_from_basename": null,
"visible": true,
"hid": 7,
"update_time": "2020-09-10T18:21:32.734118",
"history_id": "63cd3858d057a6d1",
"misc_blurb": "207.5 MB",
"peek": "<table cellspacing=\"0\" cellpadding=\"3\"><tr><td>@M01368:20:000000000-A46F5:1:1101:10294:8369/1<\/td><\/tr><tr><td>TATTGAACGTAGGTGCGATAAATAATGGGATGAGGCAGGAATCAAAGACAGATACTGCGACATAGGGTGCTCCGGCTCCAGCGTCTCGCAATGCTATCGCGTGCATACCCCCCAGACGAAAATACCAAATGCATGGAGAGCTCCCGTGAGTGGTTAATAGGGTGATAGACCTGTGATCCATCGTGATGTCTTATTTAAGGGGAACGTGTGGGCTATTTAGGCTTTATGGCCCTGAAGTAGGAACCAGATGT<\/td><\/tr><tr><td>+<\/td><\/tr><tr><td>BBBBBFFFFBFFGGGGGGGGGGHGFGHHHGHGHHHGGGGGHHHHHHHGGFHHHGHHGHGGGGGHHHHHGGHHHGGGGGGGHHGGGGHHGGGGHHHGHHGGGGGGHHHHHHHGGGGHGGGGGGHGHGHFGHGHHHHHGHFDHHHHGGAEEEDGFGGHFGFHHHGGHHHFGGHHHHHHHHHHHGGAFBFGGGGGFGGGGGGGGGGGGGGGGGFDFFFFFFFFFFFFFFFFFFFFFFF/9:BFFFFF?AF.BF/<\/td><\/tr><tr><td>@M01368:20:000000000-A46F5:1:1101:10302:11041/1<\/td><\/tr><\/table>",
"history_content_type": "dataset",
"purged": false,
"display_apps": [],
"file_size": 217620050,
"genome_build": "?",
"validated_state": "unknown",
"extension": "fastqsanger",
"url": "/api/histories/63cd3858d057a6d1/contents/ee712460b9af5737",
"api_type": "file"
},
{
"hda_ldda": "hda",
"tags": [],
"type": "file",
"resubmitted": false,
"validated_state_message": null,
"misc_info": "uploaded fastqsanger file",
"deleted": false,
"id": "85f94a3b50966dc9",
"type_id": "dataset-85f94a3b50966dc9",
"rerunnable": false,
"display_types": [],
"create_time": "2020-06-24T11:30:35.766010",
"uuid": "fad0ccfa-f164-45d6-b779-1b433178da13",
"data_type": "galaxy.datatypes.sequence.FastqSanger",
"state": "ok",
"download_url": "/api/histories/63cd3858d057a6d1/contents/85f94a3b50966dc9/display",
"model_class": "HistoryDatasetAssociation",
"dataset_id": "5733d8d6dc37a408",
"file_ext": "fastqsanger",
"meta_files": [],
"file_name": "/Users/mason/Projects/ohsu/galaxy-data-files/001/dataset_1026.dat",
"annotation": null,
"creating_job": "5733d8d6dc37a408",
"accessible": true,
"name": "M117-ch_2.fq.fastqsanger",
"created_from_basename": null,
"visible": true,
"hid": 4,
"update_time": "2020-06-24T11:30:47.520492",
"history_id": "63cd3858d057a6d1",
"misc_blurb": "326.1 MB",
"peek": "<table cellspacing=\"0\" cellpadding=\"3\"><tr><td>@M01368:13:000000000-A41AR:1:1101:10488:10383/2<\/td><\/tr><tr><td>CTTCAGGGCCATAAAGCCTAAATAGCCCACACGTTCCCCTTAAATAAGACATCACGATGGATCACAGGTCTATCACCCTATTAACCACTCACGGGAGCTCTCCATGCATTTGGTATTTTCGTCTGGGGGGTATGCACGCGATAGCATTGCGAGACGCTGGAGCCGGAGCACCCTATGTCGCAGTATCTGTCTTTGATTCCTGCCTCATCCCATTATTTATCGCACCTACGTTCAATATTACAGGCGAAC<\/td><\/tr><tr><td>+<\/td><\/tr><tr><td>CCCCCFFFCCCFGGGGGGGGGGHHHHHHHGGGGHHHHHHHHHHGHGHHHHHHHHHGGHGGHHHHHHHHHHHGHHHHHHHHHHGHHHHGHHHHHGGGGGHHHFHHHHHEHHHHHHFHHHHHGHGHGHFEGGCFGGGGGGGGGGFGGEFFGGGGFFFFFFFFFFFFFF@DAEFEFFFFFFFFFAFFFFFFFFFFFFFEFFFFFFFFFF0BBFFEFFFFBFFF09..:;FFFDFFFBBBBFFF0BFBEFF@B<\/td><\/tr><tr><td>@M01368:13:000000000-A41AR:1:1101:10763:9363/2<\/td><\/tr><\/table>",
"history_content_type": "dataset",
"purged": false,
"display_apps": [],
"file_size": 341989137,
"genome_build": "?",
"validated_state": "unknown",
"extension": "fastqsanger",
"url": "/api/histories/63cd3858d057a6d1/contents/85f94a3b50966dc9",
"api_type": "file"
},
{
"hda_ldda": "hda",
"tags": [],
"type": "file",
"resubmitted": false,
"validated_state_message": null,
"misc_info": "uploaded fastqsanger file",
"deleted": false,
"id": "5733d8d6dc37a408",
"type_id": "dataset-5733d8d6dc37a408",
"rerunnable": false,
"display_types": [],
"create_time": "2020-06-24T11:30:22.204488",
"uuid": "1fbccf35-ef7c-4e67-9e9e-b7878b1db264",
"data_type": "galaxy.datatypes.sequence.FastqSanger",
"state": "ok",
"download_url": "/api/histories/63cd3858d057a6d1/contents/5733d8d6dc37a408/display",
"model_class": "HistoryDatasetAssociation",
"dataset_id": "e41dd4fdd9a586ef",
"file_ext": "fastqsanger",
"meta_files": [],
"file_name": "/Users/mason/Projects/ohsu/galaxy-data-files/001/dataset_1025.dat",
"annotation": null,
"creating_job": "e41dd4fdd9a586ef",
"accessible": true,
"name": "M117-ch_1.fq.fastqsanger",
"created_from_basename": null,
"visible": true,
"hid": 3,
"update_time": "2020-06-24T11:30:32.534455",
"history_id": "63cd3858d057a6d1",
"misc_blurb": "325.9 MB",
"peek": "<table cellspacing=\"0\" cellpadding=\"3\"><tr><td>@M01368:13:000000000-A41AR:1:1101:10488:10383/1<\/td><\/tr><tr><td>CACTTTAGTAAGTATGTTCGCCTGTAATATTGAACGTAGGTGCGATAAATAATGGGATGAGGCAGGAATCAAAGACAGATACTGCGACATAGGGTGCTCCGGCTCCAGCGTCTCGCAATGCTATCGCGTGCATACCCCCCAGACGAAAATACCAAATGCATGGAGAGCTCCCGTGAGTGGTTAATAGGGTGATAGACCTGTGATCCATCGTGATGTCTTATTTAAGGGGAACGTGTGGGCTATTTAGGCTT<\/td><\/tr><tr><td>+<\/td><\/tr><tr><td>CCCCCFFFFFFFGGGGGGGGGGGHHHHHHHHHGHHHHHHGHHHGGHGGHHHHHHHHHHGHHHGGGGGHHHHHHGHHHHHHHHHHHGGGGGHHHHHGGHHHGGGGGGHHHGFGGHHGGGGHHHHHHGGGGGGHHHHHHHGGGGHGGGGGGHHHHHHHHHHHHHHHHHGHHGHGGGGAEGGGGGGGGEGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGFFFFFFFFBFFFFFFFFFFFFFFFFFFFFFFFFFF<\/td><\/tr><tr><td>@M01368:13:000000000-A41AR:1:1101:10763:9363/1<\/td><\/tr><\/table>",
"history_content_type": "dataset",
"purged": false,
"display_apps": [],
"file_size": 341692613,
"genome_build": "?",
"validated_state": "unknown",
"extension": "fastqsanger",
"url": "/api/histories/63cd3858d057a6d1/contents/5733d8d6dc37a408",
"api_type": "file"
},
{
"hda_ldda": "hda",
"tags": [],
"type": "file",
"resubmitted": false,
"validated_state_message": null,
"misc_info": "uploaded fastqsanger file",
"deleted": false,
"id": "e41dd4fdd9a586ef",
"type_id": "dataset-e41dd4fdd9a586ef",
"rerunnable": false,
"display_types": [],
"create_time": "2020-06-24T11:30:08.266197",
"uuid": "37c3aaf0-c47d-4398-9f82-7f4218fb4c2f",
"data_type": "galaxy.datatypes.sequence.FastqSanger",
"state": "ok",
"download_url": "/api/histories/63cd3858d057a6d1/contents/e41dd4fdd9a586ef/display",
"model_class": "HistoryDatasetAssociation",
"dataset_id": "03feb7df0a14654f",
"file_ext": "fastqsanger",
"meta_files": [],
"file_name": "/Users/mason/Projects/ohsu/galaxy-data-files/001/dataset_1024.dat",
"annotation": null,
"creating_job": "03feb7df0a14654f",
"accessible": true,
"name": "M117-bl_2.fq.fastqsanger",
"created_from_basename": null,
"visible": true,
"hid": 2,
"update_time": "2020-06-24T11:30:20.546660",
"history_id": "63cd3858d057a6d1",
"misc_blurb": "497.4 MB",
"peek": "<table cellspacing=\"0\" cellpadding=\"3\"><tr><td>@M01368:28:000000000-A5KYY:1:1101:10045:12104/2<\/td><\/tr><tr><td>CCATAAAGCCTAAATAGCCCACACGTTCCCCTTAAATAAGACATCACGATGGATCACAGGTCTATCACCCTATTAACCACTCACGGGAGCTCTCCATGCATTTGGTATTTTCGTCTGGGGGGTATGCACGCGATAGCATTGCGAGACGCTGGAGCCGGAGCACCCTATGTCGCAGTATCTGTCTTTGATTCCTGCCTCATCCCATTATTTATCGCACCTACGTTCAATAT<\/td><\/tr><tr><td>+<\/td><\/tr><tr><td>?????&lt;&lt;&lt;B&lt;BBBBB&lt;&gt;CFFFFHEHHH?-CEFFB?ECC??EFFDDGDEHFFDD?A?C??;5AEAEGCFFDFBEDC-AFHHFFFHHD*&lt;DDDCGHHHHHFHFFF@@+77D@=DEFHFFBBA=8.?CEECC????8EEEEECC?.8??AA;A?&#39;8??E;;&#39;82AC*8AECCEEEE2AEEE?EE:?AC::AE?AEE::*0:C?A?EC?C*:?E:/?A?848:?CECEEEEECE<\/td><\/tr><tr><td>@M01368:28:000000000-A5KYY:1:1101:10126:23848/2<\/td><\/tr><\/table>",
"history_content_type": "dataset",
"purged": false,
"display_apps": [],
"file_size": 521548589,
"genome_build": "?",
"validated_state": "unknown",
"extension": "fastqsanger",
"url": "/api/histories/63cd3858d057a6d1/contents/e41dd4fdd9a586ef",
"api_type": "file"
}
]
@@ -1,43 +0,0 @@
import { watchForChange } from "jest/helpers";
import { defaultPayload } from "components/providers/History/ContentProvider";
/**
* Vue adds a bunch of setters and getters so we need to create our own comparison fn
*/
export const sameVueObj = (a, b) => {
return JSON.stringify(a) == JSON.stringify(b);
};
/**
* Detects a new emission of a "payload" a standardized object delivered by HistoryContentProvider
* and CollectionContentProvider, skips the default value that's emitted when it initialized
*
* @param {Object} config vm, propName, label, other watchForChange configs
* @return {Promise} promise that resolves with the changed payload
*/
export const payloadChange = async (config = {}) => {
const { vm, propName = "payload", label = "payload change", skipDefault = false, ...cfg } = config;
const result = await watchForChange({ vm, propName, label, ...cfg });
// can see how fast the response comes back by monitoring this result
// console.log("payloadChange result", JSON.stringify(result.newVal));
// chopout the placeholder flag for the default
// eslint-disable-next-line no-unused-vars
const { placeholder, ...payload } = result.newVal;
if (skipDefault && sameVueObj(payload, defaultPayload)) {
return await payloadChange(config);
}
return payload;
};
export const reportPayload = (payload, { indexKey = "hid", label = "payload" } = {}) => {
const { contents = [], startKeyIndex, ...resultFields } = payload;
const keys = contents.map((o, idx) => {
const thisKey = o[indexKey];
const keyString = idx == startKeyIndex ? `${thisKey}*` : thisKey;
return keyString;
});
const report = { keys, startKeyIndex, ...resultFields };
console.log(label, report);
};
@@ -1,25 +0,0 @@
// Doctored collection & children for unit tests
import rawCollection from "./json/DatasetCollection";
import rawNestedCollection from "./json/DatasetCollection.nested.json";
import rawCollectionContent from "./json/collectionContent.json";
import rawNestedCollectionContent from "./json/collectionContent.nested.json";
// Sample collection for CCP tests
const parent_url = "/foo";
export const testCollectionContent = rawCollectionContent.map((props) => ({ ...props, parent_url }));
export const testCollection = Object.assign({}, rawCollection, {
contents_url: parent_url,
element_count: testCollectionContent.length,
});
export const testNestedCollection = Object.assign({}, rawNestedCollection, {
parent_url: parent_url,
contents_url: "/foo/bar",
element_count: rawNestedCollectionContent.length,
});
// a list of element_indexes handy for testing
export const indices = (list) => list.map(({ element_index, _id, parent_url }) => ({ element_index, _id, parent_url }));
@@ -1,29 +0,0 @@
import { History } from "components/History/model";
import { SearchParams } from "components/providers/History/SearchParams";
import rawHistory from "./json/History.json";
import rawHistoryContent from "./json/historyContent.json";
// doctor the sample content
export const testHistoryContent = rawHistoryContent.map((item) => {
item.history_id = rawHistory.id;
return item;
});
// doctor the sample history
export const testHistory = new History({
...rawHistory,
hid_counter: testHistoryContent[0].hid + 1,
});
// simplified filter simlates server-side complete set
export const serverContent = (filters = new SearchParams()) => {
return testHistoryContent.filter((o) => {
if (!filters.showDeleted && o.deleted) {
return false;
}
if (!filters.showHidden && !o.visible) {
return false;
}
return true;
});
};
-25
View File
@@ -1,25 +0,0 @@
import { pipe } from "rxjs";
import { map, mergeMap } from "rxjs/operators";
import { ajax } from "rxjs/ajax";
import { prependPath } from "utils/redirect";
export const fetchDatasetById = () => {
return pipe(
map((id) => prependPath(`/api/datasets/${id}`)),
mergeMap((url) => ajax.getJSON(url))
);
};
export const fetchDatasetCollectionById = () => {
return pipe(
map((id) => prependPath(`/api/dataset_collections/${id}?instance_type=history`)),
mergeMap((url) => ajax.getJSON(url))
);
};
export const fetchInvocationStepById = () => {
return pipe(
map((id) => prependPath(`/api/invocations/any/steps/${id}`)),
mergeMap((url) => ajax.getJSON(url))
);
};