-
+
@@ -46,7 +46,7 @@
{{ id }}:
- {{ name }}
+ {{ name }}
diff --git a/client/src/components/History/CurrentCollection/CollectionPanel.vue b/client/src/components/History/CurrentCollection/CollectionPanel.vue
index 8e3b18008aca..9a48db2afdd6 100644
--- a/client/src/components/History/CurrentCollection/CollectionPanel.vue
+++ b/client/src/components/History/CurrentCollection/CollectionPanel.vue
@@ -1,135 +1,116 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/client/src/components/Workflow/InvocationsList.vue b/client/src/components/Workflow/InvocationsList.vue
index ac6061a34458..3bdc529f3e4a 100644
--- a/client/src/components/Workflow/InvocationsList.vue
+++ b/client/src/components/Workflow/InvocationsList.vue
@@ -50,7 +50,7 @@
:title="getStoredWorkflowNameByInstanceId(data.item.workflow_id)"
class="truncate">
- {{ getStoredWorkflowNameByInstanceId(data.item.workflow_id) }}
+ {{ getStoredWorkflowNameByInstanceId(data.item.workflow_id) }}
@@ -60,7 +60,7 @@
:title="`
Switch to${getHistoryNameById(data.item.history_id)}`"
class="truncate">
- {{ getHistoryNameById(data.item.history_id) }}
+ {{ getHistoryNameById(data.item.history_id) }}
diff --git a/client/src/schema/schema.ts b/client/src/schema/schema.ts
index b0d0fa4571df..c6063ed8f6f8 100644
--- a/client/src/schema/schema.ts
+++ b/client/src/schema/schema.ts
@@ -4807,10 +4807,11 @@ export interface components {
*/
hid: number;
/**
- * Content Type
- * @description The type of this item.
+ * History Content Type
+ * @description This is always `dataset` for datasets.
+ * @enum {string}
*/
- history_content_type: components["schemas"]["HistoryContentType"];
+ history_content_type: "dataset";
/**
* History ID
* @description The encoded ID of the history associated with this item.
@@ -5021,10 +5022,11 @@ export interface components {
*/
hid: number;
/**
- * Content Type
- * @description The type of this item.
+ * History Content Type
+ * @description This is always `dataset` for datasets.
+ * @enum {string}
*/
- history_content_type: components["schemas"]["HistoryContentType"];
+ history_content_type: "dataset";
/**
* History ID
* @description The encoded ID of the history associated with this item.
@@ -5136,10 +5138,11 @@ export interface components {
*/
hid: number;
/**
- * Content Type
- * @description The type of this item.
+ * History Content Type
+ * @description This is always `dataset_collection` for dataset collections.
+ * @enum {string}
*/
- history_content_type: components["schemas"]["HistoryContentType"];
+ history_content_type: "dataset_collection";
/**
* History ID
* @description The encoded ID of the history associated with this item.
@@ -5270,10 +5273,11 @@ export interface components {
*/
hid: number;
/**
- * Content Type
- * @description The type of this item.
+ * History Content Type
+ * @description This is always `dataset_collection` for dataset collections.
+ * @enum {string}
*/
- history_content_type: components["schemas"]["HistoryContentType"];
+ history_content_type: "dataset_collection";
/**
* History ID
* @description The encoded ID of the history associated with this item.
@@ -9959,7 +9963,7 @@ export interface operations {
/** @description Successful Response */
200: {
content: {
- "application/json": components["schemas"]["HDCADetailed"] | components["schemas"]["HDCASummary"];
+ "application/json": components["schemas"]["HDCADetailed"];
};
};
/** @description Validation Error */
diff --git a/client/src/store/historyStore/collectionElementsStore.js b/client/src/store/historyStore/collectionElementsStore.js
deleted file mode 100644
index 264df2f55447..000000000000
--- a/client/src/store/historyStore/collectionElementsStore.js
+++ /dev/null
@@ -1,73 +0,0 @@
-/**
- * Requests collection elements by reacting to changes of props passed to the collection elements
- * provider used in the collection panel e.g. changes of the offset prop when scrolling. This store
- * attached to the changed history items store, but could also use the getter of the dataset store
- * instead, particularly after a collection store has been added if required.
- */
-
-import { LastQueue } from "utils/promise-queue";
-import { urlData } from "utils/url";
-import Vue from "vue";
-
-import { mergeArray } from "./model/utilities";
-
-const limit = 100;
-const queue = new LastQueue();
-
-const getObjectId = (elementObject) => {
- return `${elementObject.model_class}-${elementObject.id}`;
-};
-
-const state = {
- items: {},
- itemKey: "element_index",
- objectIndex: {},
-};
-
-const getters = {
- getCollectionElements:
- (state) =>
- ({ id }) => {
- const itemArray = state.items[id] || [];
- const filtered = itemArray.filter((item) => !!item);
- return filtered.map((item) => {
- const objectId = getObjectId(item.object);
- const objectData = state.objectIndex[objectId];
- const objectResult = { ...item.object };
- if (objectData) {
- Object.keys(objectResult).forEach((key) => {
- objectResult[key] = objectData[key];
- });
- }
- return { ...item, object: objectResult };
- });
- },
-};
-
-const actions = {
- fetchCollectionElements: async ({ commit }, { id, contentsUrl, offset }) => {
- const url = `/${contentsUrl}?offset=${offset}&limit=${limit}`;
- await queue.enqueue(urlData, { url }).then((payload) => {
- commit("saveCollectionElements", { id, payload });
- });
- },
-};
-
-const mutations = {
- saveCollectionElements: (state, { id, payload }) => {
- mergeArray(id, payload, state.items, state.itemKey);
- },
- saveCollectionObjects: (state, { payload }) => {
- payload.forEach((item) => {
- const objectId = getObjectId(item);
- Vue.set(state.objectIndex, objectId, item);
- });
- },
-};
-
-export const collectionElementsStore = {
- state,
- getters,
- actions,
- mutations,
-};
diff --git a/client/src/store/historyStore/index.js b/client/src/store/historyStore/index.js
deleted file mode 100644
index e51800782011..000000000000
--- a/client/src/store/historyStore/index.js
+++ /dev/null
@@ -1 +0,0 @@
-export { collectionElementsStore } from "./collectionElementsStore";
diff --git a/client/src/store/historyStore/model/watchHistory.js b/client/src/store/historyStore/model/watchHistory.js
index a311e5355c63..97c87c7c1919 100644
--- a/client/src/store/historyStore/model/watchHistory.js
+++ b/client/src/store/historyStore/model/watchHistory.js
@@ -13,6 +13,7 @@ import { getCurrentHistoryFromServer } from "stores/services/history.services";
import { loadSet } from "utils/setCache";
import { urlData } from "utils/url";
+import { useCollectionElementsStore } from "@/stores/collectionElementsStore";
import { useDatasetStore } from "@/stores/datasetStore";
const limit = 1000;
@@ -44,6 +45,7 @@ export async function watchHistoryOnce(store) {
const historyStore = useHistoryStore();
const historyItemsStore = useHistoryItemsStore();
const datasetStore = useDatasetStore();
+ const collectionElementsStore = useCollectionElementsStore();
// "Reset" watchTimeout so we don't queue up watchHistory calls in rewatchHistory.
watchTimeout = null;
// get current history
@@ -82,7 +84,7 @@ export async function watchHistoryOnce(store) {
historyStore.setHistory(history);
datasetStore.saveDatasets(payload);
historyItemsStore.saveHistoryItems(historyId, payload);
- store.commit("saveCollectionObjects", { payload });
+ collectionElementsStore.saveCollections(payload);
// trigger changes in legacy handler
const Galaxy = getGalaxyInstance();
if (Galaxy) {
diff --git a/client/src/store/historyStore/model/watchHistory.test.js b/client/src/store/historyStore/model/watchHistory.test.js
index da23bc755562..d986a7060f5a 100644
--- a/client/src/store/historyStore/model/watchHistory.test.js
+++ b/client/src/store/historyStore/model/watchHistory.test.js
@@ -2,10 +2,8 @@ import { createLocalVue, mount } from "@vue/test-utils";
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { createPinia, mapState } from "pinia";
-import { collectionElementsStore } from "store/historyStore/collectionElementsStore";
import { useHistoryItemsStore } from "stores/history/historyItemsStore";
import { useHistoryStore } from "stores/historyStore";
-import Vuex from "vuex";
import { watchHistoryOnce } from "./watchHistory";
@@ -50,15 +48,9 @@ describe("watchHistory", () => {
beforeEach(() => {
axiosMock = new MockAdapter(axios);
const localVue = createLocalVue();
- localVue.use(Vuex);
useHistoryItemsStore(pinia);
wrapper = mount(testApp, {
- store: new Vuex.Store({
- modules: {
- collectionElements: collectionElementsStore,
- },
- }),
localVue,
pinia,
});
diff --git a/client/src/store/index.js b/client/src/store/index.js
index aed4dd9c169d..4c25b54052e7 100644
--- a/client/src/store/index.js
+++ b/client/src/store/index.js
@@ -13,7 +13,6 @@ import { collectionAttributesStore } from "./collectionAttributesStore";
import { datasetExtFilesStore } from "./datasetExtFilesStore";
import { datasetPathDestinationStore } from "./datasetPathDestinationStore";
import { gridSearchStore } from "./gridSearchStore";
-import { collectionElementsStore } from "./historyStore";
import { invocationStore } from "./invocationStore";
import { jobDestinationParametersStore } from "./jobDestinationParametersStore";
import { panelStore } from "./panelStore";
@@ -46,7 +45,6 @@ export function createStore() {
plugins: [createCache(), panelsPersistence.plugin],
modules: {
collectionAttributesStore: collectionAttributesStore,
- collectionElements: collectionElementsStore,
destinationParameters: jobDestinationParametersStore,
datasetExtFiles: datasetExtFilesStore,
datasetPathDestination: datasetPathDestinationStore,
diff --git a/client/src/stores/collectionElementsStore.test.ts b/client/src/stores/collectionElementsStore.test.ts
new file mode 100644
index 000000000000..9e8bf1d6e557
--- /dev/null
+++ b/client/src/stores/collectionElementsStore.test.ts
@@ -0,0 +1,154 @@
+import flushPromises from "flush-promises";
+import { createPinia, setActivePinia } from "pinia";
+
+import { mockFetcher } from "@/schema/__mocks__";
+import { useCollectionElementsStore } from "@/stores/collectionElementsStore";
+import { DCESummary, HDCASummary } from "@/stores/services";
+
+jest.mock("@/schema");
+
+const collection1: HDCASummary = mockCollection("1");
+const collection2: HDCASummary = mockCollection("2");
+const collections: HDCASummary[] = [collection1, collection2];
+
+describe("useCollectionElementsStore", () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ mockFetcher
+ .path("/api/dataset_collections/{hdca_id}/contents/{parent_id}")
+ .method("get")
+ .mock(fetchCollectionElements);
+ });
+
+ it("should save collections", async () => {
+ const store = useCollectionElementsStore();
+ expect(store.storedCollections).toEqual({});
+
+ store.saveCollections(collections);
+
+ expect(store.storedCollections).toEqual({
+ "1": collection1,
+ "2": collection2,
+ });
+ });
+
+ it("should fetch collection elements if they are not yet in the store", async () => {
+ const store = useCollectionElementsStore();
+ expect(store.storedCollectionElements).toEqual({});
+ expect(store.isLoadingCollectionElements(collection1)).toEqual(false);
+
+ const offset = 0;
+ const limit = 5;
+ // Getting collection elements should trigger a fetch
+ store.getCollectionElements(collection1, offset, limit);
+ expect(store.isLoadingCollectionElements(collection1)).toEqual(true);
+ await flushPromises();
+ expect(store.isLoadingCollectionElements(collection1)).toEqual(false);
+ expect(fetchCollectionElements).toHaveBeenCalled();
+
+ const elements = store.storedCollectionElements[collection1.id];
+ expect(elements).toBeDefined();
+ expect(elements).toHaveLength(limit);
+ });
+
+ it("should not fetch collection elements if they are already in the store", async () => {
+ const store = useCollectionElementsStore();
+ const storedCount = 5;
+ const expectedStoredElements = Array.from({ length: storedCount }, (_, i) => mockElement(collection1.id, i));
+ store.storedCollectionElements[collection1.id] = expectedStoredElements;
+ expect(store.storedCollectionElements[collection1.id]).toHaveLength(storedCount);
+
+ const offset = 0;
+ const limit = 5;
+ // Getting collection elements should not trigger a fetch in this case
+ store.getCollectionElements(collection1, offset, limit);
+ expect(store.isLoadingCollectionElements(collection1)).toEqual(false);
+ expect(fetchCollectionElements).not.toHaveBeenCalled();
+ });
+
+ it("should fetch only missing elements if the requested range is not already stored", async () => {
+ const store = useCollectionElementsStore();
+ const storedCount = 3;
+ const expectedStoredElements = Array.from({ length: storedCount }, (_, i) => mockElement(collection1.id, i));
+ store.storedCollectionElements[collection1.id] = expectedStoredElements;
+ expect(store.storedCollectionElements[collection1.id]).toHaveLength(storedCount);
+
+ const offset = 2;
+ const limit = 5;
+ // Getting collection elements should trigger a fetch in this case
+ store.getCollectionElements(collection1, offset, limit);
+ expect(store.isLoadingCollectionElements(collection1)).toEqual(true);
+ await flushPromises();
+ expect(store.isLoadingCollectionElements(collection1)).toEqual(false);
+ expect(fetchCollectionElements).toHaveBeenCalled();
+
+ const elements = store.storedCollectionElements[collection1.id];
+ expect(elements).toBeDefined();
+ // The offset was overlapping with the stored elements, so it was increased by the number of stored elements
+ // so it fetches the next "limit" number of elements
+ expect(elements).toHaveLength(storedCount + limit);
+ });
+});
+
+function mockCollection(id: string, numElements = 10): HDCASummary {
+ return {
+ id: id,
+ element_count: numElements,
+ collection_type: "list",
+ populated_state: "ok",
+ populated_state_message: "",
+ collection_id: id,
+ name: `collection ${id}`,
+ deleted: false,
+ contents_url: "",
+ hid: 1,
+ history_content_type: "dataset_collection",
+ history_id: "1",
+ model_class: "HistoryDatasetCollectionAssociation",
+ tags: [],
+ visible: true,
+ create_time: "2021-05-25T14:00:00.000Z",
+ update_time: "2021-05-25T14:00:00.000Z",
+ type_id: "dataset_collection",
+ url: "",
+ };
+}
+
+function mockElement(collectionId: string, i: number): DCESummary {
+ const fakeID = `${collectionId}-${i}`;
+ return {
+ id: fakeID,
+ element_index: i,
+ element_identifier: `element ${i}`,
+ element_type: "hda",
+ model_class: "DatasetCollectionElement",
+ object: {
+ id: fakeID,
+ model_class: "HistoryDatasetAssociation",
+ state: "ok",
+ hda_ldda: "hda",
+ history_id: "1",
+ tags: [],
+ },
+ };
+}
+
+interface ApiRequest {
+ hdca_id: string;
+ offset: number;
+ limit: number;
+}
+
+const fetchCollectionElements = jest.fn(fakeCollectionElementsApiResponse);
+
+function fakeCollectionElementsApiResponse(params: ApiRequest) {
+ const elements: DCESummary[] = [];
+ const startIndex = params.offset ?? 0;
+ const endIndex = startIndex + (params.limit ?? 10);
+ for (let i = startIndex; i < endIndex; i++) {
+ elements.push(mockElement(params.hdca_id, i));
+ }
+ return {
+ data: elements,
+ };
+}
diff --git a/client/src/stores/collectionElementsStore.ts b/client/src/stores/collectionElementsStore.ts
new file mode 100644
index 000000000000..b73366ccdabc
--- /dev/null
+++ b/client/src/stores/collectionElementsStore.ts
@@ -0,0 +1,97 @@
+import { defineStore } from "pinia";
+import Vue, { computed, ref } from "vue";
+
+import { DCESummary, HDCASummary, HistoryContentItemBase } from "./services";
+import * as Service from "./services/datasetCollection.service";
+
+export const useCollectionElementsStore = defineStore("collectionElementsStore", () => {
+ const storedCollections = ref<{ [key: string]: HDCASummary }>({});
+ const loadingCollectionElements = ref<{ [key: string]: boolean }>({});
+ const storedCollectionElements = ref<{ [key: string]: DCESummary[] }>({});
+
+ const getCollectionElements = computed(() => {
+ return (collection: HDCASummary, offset = 0, limit = 50) => {
+ const elements = storedCollectionElements.value[collection.id] ?? [];
+ fetchMissingElements({ collection, offset, limit });
+ return elements ?? null;
+ };
+ });
+
+ const isLoadingCollectionElements = computed(() => {
+ return (collection: HDCASummary) => {
+ return loadingCollectionElements.value[collection.id] ?? false;
+ };
+ });
+
+ async function fetchMissingElements(params: { collection: HDCASummary; offset: number; limit: number }) {
+ try {
+ const maxElementCountInCollection = params.collection.element_count ?? 0;
+ const storedElements = storedCollectionElements.value[params.collection.id] ?? [];
+ // Collections are immutable, so there is no need to fetch elements if the range we want is already stored
+ if (params.offset + params.limit <= storedElements.length) {
+ return;
+ }
+ // If we already have items at the offset, we can start fetching from the next offset
+ params.offset = storedElements.length;
+
+ if (params.offset >= maxElementCountInCollection) {
+ return;
+ }
+
+ Vue.set(loadingCollectionElements.value, params.collection.id, true);
+ const fetchedElements = await Service.fetchElementsFromHDCA({
+ hdca: params.collection,
+ offset: params.offset,
+ limit: params.limit,
+ });
+ const updatedElements = [...storedElements, ...fetchedElements];
+ Vue.set(storedCollectionElements.value, params.collection.id, updatedElements);
+ } finally {
+ Vue.delete(loadingCollectionElements.value, params.collection.id);
+ }
+ }
+
+ async function loadCollectionElements(collection: HDCASummary) {
+ const elements = await Service.fetchElementsFromHDCA({ hdca: collection });
+ Vue.set(storedCollectionElements.value, collection.id, elements);
+ }
+
+ function saveCollections(historyContentsPayload: HistoryContentItemBase[]) {
+ const collectionsInHistory = historyContentsPayload.filter(
+ (entry) => entry.history_content_type === "dataset_collection"
+ ) as HDCASummary[];
+ for (const collection of collectionsInHistory) {
+ Vue.set