mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-09-24 16:30:27 +08:00
Remove legacy caching model
This commit is contained in:
@@ -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,
|
||||
|
||||
-39
@@ -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);
|
||||
},
|
||||
},
|
||||
};
|
||||
-160
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
-77
@@ -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,
|
||||
}),
|
||||
);
|
||||
};
|
||||
-125
@@ -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;
|
||||
-34
@@ -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);
|
||||
},
|
||||
},
|
||||
};
|
||||
-161
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
-34
@@ -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);
|
||||
};
|
||||
-154
@@ -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
|
||||
|
||||
-37
@@ -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
|
||||
}),
|
||||
);
|
||||
};
|
||||
-211
@@ -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>?????<<<B<BBBBB<>CFFFFHEHHH?-CEFFB?ECC??EFFDDGDEHFFDD?A?C??;5AEAEGCFFDFBEDC-AFHHFFFHHD*<DDDCGHHHHHFHFFF@@+77D@=DEFHFFBBA=8.?CEECC????8EEEEECC?.8??AA;A?'8??E;;'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;
|
||||
});
|
||||
};
|
||||
@@ -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))
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user