From bf048cc570d61bcb3e07874c3ed4140b52e86509 Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Mon, 16 Feb 2026 16:41:27 -0800 Subject: [PATCH 1/3] feat: implement entity caching mechanism to optimize API calls --- src/components/common/EntityLink.vue | 50 ++++++++++++++-- src/composables/entity-cache.ts | 88 ++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 src/composables/entity-cache.ts diff --git a/src/components/common/EntityLink.vue b/src/components/common/EntityLink.vue index 8d47a463..37224f41 100644 --- a/src/components/common/EntityLink.vue +++ b/src/components/common/EntityLink.vue @@ -7,6 +7,7 @@ import _ from 'lodash' import {defineComponent} from 'vue' import useItem from '@/composition/item' +import {useEntityCache} from '@/composables/entity-cache' /** * A link to an experiment set, experiment, or score set. @@ -40,26 +41,44 @@ export default defineComponent({ urn: { type: String, required: true + }, + /** Use cached entity data to avoid redundant API calls. Useful in lists. */ + useCache: { + type: Boolean, + default: false } }, - setup: (props) => useItem({itemTypeName: props.entityType}), + setup: (props) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const itemComposition = useItem({itemTypeName: props.entityType as any}) + const {getEntity} = useEntityCache() + + return { + ...itemComposition, + getEntity + } + }, data: function () { return { - selectedOptionValues: this.value + selectedOptionValues: this.value, + cachedEntity: null as Record | null, + loadingCache: false } }, computed: { linkText: function () { - if (!this.item) { + const entity = this.useCache ? this.cachedEntity : this.item + + if (!entity) { return this.urn } else { if (_.isString(this.display)) { - return _.get(this.item, this.display) + return _.get(entity, this.display) } else if (_.isFunction(this.display)) { - return this.display(this.item) + return this.display(entity) } else { return this.urn } @@ -81,10 +100,29 @@ export default defineComponent({ watch: { urn: { handler: function () { - this.setItemId(this.urn) + if (this.useCache) { + this.loadCachedEntity() + } else { + this.setItemId(this.urn) + } }, immediate: true } + }, + + methods: { + async loadCachedEntity() { + if (!this.urn) return + + this.loadingCache = true + try { + this.cachedEntity = (await this.getEntity(this.entityType, this.urn)) as Record + } catch (error) { + console.error('Failed to load cached entity:', this.urn, error) + } finally { + this.loadingCache = false + } + } } }) diff --git a/src/composables/entity-cache.ts b/src/composables/entity-cache.ts new file mode 100644 index 00000000..7d7ba639 --- /dev/null +++ b/src/composables/entity-cache.ts @@ -0,0 +1,88 @@ +import {ref, watch} from 'vue' +import axios from 'axios' +import config from '@/config' + +interface CacheEntry { + data: Record | null + loading: boolean + error: Error | null + timestamp: number +} + +interface EntityCache { + [urn: string]: CacheEntry +} + +const cache = ref({}) +const CACHE_TTL = 5 * 60 * 1000 // 5 minutes + +export function useEntityCache() { + const isCacheValid = (entry: CacheEntry): boolean => { + return Date.now() - entry.timestamp < CACHE_TTL + } + + const getEntity = async (entityType: string, urn: string, forceRefresh = false) => { + // Return cached value if available and valid + if (!forceRefresh && cache.value[urn]?.data && isCacheValid(cache.value[urn])) { + return cache.value[urn].data + } + + // Return if already loading + if (cache.value[urn]?.loading) { + // Wait for the existing request to complete + return new Promise((resolve) => { + const unwatch = watch( + () => cache.value[urn], + (value) => { + if (!value.loading && value.data) { + unwatch() + resolve(value.data) + } + } + ) + }) + } + + // Initialize cache entry + cache.value[urn] = {data: null, loading: true, error: null, timestamp: Date.now()} + + try { + const endpoint = { + scoreSet: 'score-sets', + experiment: 'experiments', + experimentSet: 'experiment-sets' + }[entityType] + + if (!endpoint) { + throw new Error(`Unknown entity type: ${entityType}`) + } + + const response = await axios.get(`${config.apiBaseUrl}/${endpoint}/${urn}`) + cache.value[urn] = {data: response.data, loading: false, error: null, timestamp: Date.now()} + return response.data + } catch (error) { + const err = error instanceof Error ? error : new Error('Unknown error') + cache.value[urn] = {data: null, loading: false, error: err, timestamp: Date.now()} + throw error + } + } + + const invalidateCache = (urn?: string) => { + if (urn) { + delete cache.value[urn] + } else { + cache.value = {} + } + } + + const refreshEntity = async (entityType: string, urn: string) => { + return getEntity(entityType, urn, true) + } + + return { + getEntity, + invalidateCache, + refreshEntity, + cache + } +} From 6fddc9e437512667f8a498fc041159db5f8daf30 Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Mon, 16 Feb 2026 18:50:19 -0800 Subject: [PATCH 2/3] feat: unify inline collection item management with reusable table + add flow replace duplicated score set/experiment list UI with a reusable collection items table add inline row reordering and removal with shared handler logic and toast feedback integrate add actions into the table (footer button / empty-state link) wire table add actions to the collection data set editor dialog improve add-URN UX: Enter key triggers add pending typed URN is captured when clicking Add invalid URNs show warning toast details already-in-collection / already-queued URNs show skipped info toast simplify collection editor internals and keep save/reload behavior consistent after mutations --- src/components/CollectionDataSetEditor.vue | 198 ++++++++++----------- src/components/CollectionItemsTable.vue | 114 ++++++++++++ src/components/screens/CollectionView.vue | 166 +++++++++++++---- 3 files changed, 342 insertions(+), 136 deletions(-) create mode 100644 src/components/CollectionItemsTable.vue diff --git a/src/components/CollectionDataSetEditor.vue b/src/components/CollectionDataSetEditor.vue index 570ff1bd..2eea7de2 100644 --- a/src/components/CollectionDataSetEditor.vue +++ b/src/components/CollectionDataSetEditor.vue @@ -3,43 +3,26 @@
-
@@ -85,6 +77,8 @@ import useItem from '@/composition/item' import config from '@/config' import EmailPrompt from '@/components/common/EmailPrompt.vue' +const INVALID_URN_MESSAGES_DISPLAY_LIMIT = 3 + export default { name: 'CollectionDataSetEditor', @@ -98,6 +92,10 @@ export default { dataSetType: { type: String, required: true + }, + showTrigger: { + type: Boolean, + default: true } }, @@ -110,13 +108,9 @@ export default { unvalidatedUrnsToAdd: [], dataSetsToAdd: [], - urnsToRemove: [], - - selectedDataSets: [], validationErrors: [], additionErrors: [], - removalErrors: [], dataSetTypeDisplay: { scoreSet: 'score set', @@ -126,14 +120,8 @@ export default { }), computed: { - allDataSets() { - const savedDataSets = this.savedDataSets.map((dataSet) => ({...dataSet, saved: true})) - const newDataSets = this.dataSetsToAdd.map((dataSet) => ({...dataSet, saved: false})) - return savedDataSets.concat(newDataSets) - }, - errors: function () { - return [...this.additionErrors, ...this.removalErrors] + return this.additionErrors }, restCollectionParent: function () { @@ -142,10 +130,6 @@ export default { } else { return 'score-sets' } - }, - - savedDataSetUrns: function () { - return this.item?.[`${this.dataSetType}Urns`] || [] } }, @@ -157,19 +141,14 @@ export default { } }, immediate: true - }, - - savedDataSetUrns: { - handler: async function (newValue, oldValue) { - if (!_.isEqual(newValue, oldValue)) { - await this.fetchSavedDataSets() - } - }, - immediate: true } }, methods: { + openEditor: function () { + this.visible = true + }, + clearAutoCompleteInput: function (event) { if (event.target) { event.target.value = '' @@ -185,21 +164,15 @@ export default { } event.target.value = '' }, - rowStyle: function (data) { - if (this.urnsToRemove.includes(data.urn)) { - return {backgroundColor: '#ffcccb'} // Light red - } else if (data.saved === false) { - return {backgroundColor: '#d1ffbd'} // Light green - } - }, - markDataSetsToRemove: function () { - for (const dataSetToRemove of this.selectedDataSets) { - if (dataSetToRemove.saved) { - this.urnsToRemove.push(dataSetToRemove.urn) - } else { - _.remove(this.dataSetsToAdd, (dataSet) => dataSet.urn == dataSetToRemove.urn) - } + flushPendingUrnInput: function () { + const input = this.$refs.urnAutoComplete?.$el?.querySelector('input') + const pendingUrn = (input?.value || '').replace(',', '').trim() + if (pendingUrn !== '' && !this.unvalidatedUrnsToAdd.includes(pendingUrn)) { + this.unvalidatedUrnsToAdd.push(pendingUrn) + } + if (input) { + input.value = '' } }, @@ -210,7 +183,6 @@ export default { } this.additionErrors = [] - this.removalErrors = [] const additionErrorUrns = [] for (const dataSetToAdd of this.dataSetsToAdd) { @@ -231,38 +203,47 @@ export default { } _.remove(this.dataSetsToAdd, (dataSet) => !additionErrorUrns.includes(dataSet.urn)) - const removalErrorUrns = [] - for (const urn of this.urnsToRemove) { - try { - await axios.delete( - `${config.apiBaseUrl}/collections/${this.collectionUrn}/${this.restCollectionParent}/${urn}` - ) - } catch (error) { - removalErrorUrns.push(urn) - this.removalErrors.push(`${urn}: ${error.message || 'Could not be removed from the collection'}`) - } - } - _.remove(this.urnsToRemove, (dataSet) => !removalErrorUrns.includes(dataSet.urn)) - if (_.isEmpty(this.errors)) { this.visible = false - this.$toast.add({severity: 'success', summary: "Successfully updated collection's experiments.", life: 3000}) + this.$toast.add({ + severity: 'success', + summary: `Successfully added ${this.dataSetTypeDisplay[this.dataSetType]}s.`, + life: 3000 + }) } // Always emit 'saved', because if any API calls succeed (even if others fail), we need to reload collection's data sets. this.$emit('saved') }, + removeDataSetToAdd: function (urn) { + _.remove(this.dataSetsToAdd, (dataSet) => dataSet.urn === urn) + }, + fetchDataSetsToAdd: async function () { this.validationErrors = [] + this.flushPendingUrnInput() + + if (!this.item) { + this.validationErrors.push('Collection is still loading. Please try again in a moment.') + return + } + + if (this.unvalidatedUrnsToAdd.length === 0) { + this.validationErrors.push('Please enter at least one URN.') + return + } + const invalidUrns = [] + const invalidUrnMessages = [] + const alreadyInCollectionUrns = [] + const alreadyQueuedUrns = [] for (let urn of this.unvalidatedUrnsToAdd) { urn = urn.trim() - if ( - this.item[`${this.dataSetType}Urns`].includes(urn) || - this.dataSetsToAdd.some((dataSet) => dataSet.urn == urn) - ) { - // Silently ignore the data sets in the collection or already prepared for adding in this session. + if (this.item[`${this.dataSetType}Urns`].includes(urn)) { + alreadyInCollectionUrns.push(urn) + } else if (this.dataSetsToAdd.some((dataSet) => dataSet.urn == urn)) { + alreadyQueuedUrns.push(urn) } else { // Fetch the data set. let response = null @@ -270,8 +251,12 @@ export default { response = await axios.get(`${config.apiBaseUrl}/${this.restCollectionParent}/${urn}`) } catch (e) { response = e.response || {status: 500} - this.validationErrors.push(`${urn}: ${e.message}`) + const errorDetail = e.response?.data?.detail || e.message || 'Invalid URN' + const errorMessage = `${urn}: ${errorDetail}` + this.validationErrors.push(errorMessage) + invalidUrnMessages.push(errorMessage) } + console.log(response) if (response.status == 200) { this.dataSetsToAdd.push(response.data) } else { @@ -279,34 +264,49 @@ export default { } } } - this.unvalidatedUrnsToAdd = invalidUrns - }, + this.unvalidatedUrnsToAdd = [] - fetchSavedDataSets: async function () { - const savedDataSets = [] - for (const urn of this.savedDataSetUrns) { - console.log(urn) - let response = null - try { - response = await axios.get(`${config.apiBaseUrl}/${this.restCollectionParent}/${urn}`) - } catch (e) { - response = e.response || {status: 500} + if (invalidUrnMessages.length > 0) { + const detail = + invalidUrnMessages.length > INVALID_URN_MESSAGES_DISPLAY_LIMIT + ? `${invalidUrnMessages.slice(0, INVALID_URN_MESSAGES_DISPLAY_LIMIT).join(' • ')} • +${invalidUrnMessages.length - INVALID_URN_MESSAGES_DISPLAY_LIMIT} more` + : invalidUrnMessages.join(' • ') + this.$toast.add({ + severity: 'warn', + summary: 'Some URNs could not be added', + detail, + life: 6000 + }) + } + + if (alreadyInCollectionUrns.length > 0 || alreadyQueuedUrns.length > 0) { + const details = [] + if (alreadyInCollectionUrns.length > 0) { + details.push( + `Already in collection: ${alreadyInCollectionUrns.slice(0, INVALID_URN_MESSAGES_DISPLAY_LIMIT).join(', ')}${alreadyInCollectionUrns.length > INVALID_URN_MESSAGES_DISPLAY_LIMIT ? ` (+${alreadyInCollectionUrns.length - INVALID_URN_MESSAGES_DISPLAY_LIMIT} more)` : ''}` + ) } - if (response.status == 200) { - savedDataSets.push(response.data) + if (alreadyQueuedUrns.length > 0) { + details.push( + `Already queued: ${alreadyQueuedUrns.slice(0, INVALID_URN_MESSAGES_DISPLAY_LIMIT).join(', ')}${alreadyQueuedUrns.length > INVALID_URN_MESSAGES_DISPLAY_LIMIT ? ` (+${alreadyQueuedUrns.length - INVALID_URN_MESSAGES_DISPLAY_LIMIT} more)` : ''}` + ) } + + this.$toast.add({ + severity: 'info', + summary: 'Some URNs were skipped', + detail: details.join(' • '), + life: 5000 + }) } - this.savedDataSets = savedDataSets }, resetDataSetEditor: function () { this.unvalidatedUrnsToAdd = [] this.dataSetsToAdd = [] - this.urnsToRemove = [] this.validationErrors = [] this.additionErrors = [] - this.removalErrors = [] } } } @@ -320,10 +320,6 @@ export default { width: fit-content; } -.mavedb-collection-remove-data-set-button { - width: fit-content; -} - .mavedb-collection-editor-action-buttons { display: flex; justify-content: flex-end; diff --git a/src/components/CollectionItemsTable.vue b/src/components/CollectionItemsTable.vue new file mode 100644 index 00000000..7acba5d5 --- /dev/null +++ b/src/components/CollectionItemsTable.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/src/components/screens/CollectionView.vue b/src/components/screens/CollectionView.vue index 1f292c44..ba15bd1c 100644 --- a/src/components/screens/CollectionView.vue +++ b/src/components/screens/CollectionView.vue @@ -119,39 +119,45 @@ -
- Score Sets -
- -
+
Score Sets
+
+
-
    -
  • - -
  • -
-
No associated score sets yet
-
- Experiments - -
- -
+ +
Experiments
+ +
+
-
    -
  • - -
  • -
-
No associated experiments yet
+
User Permissions
@@ -247,9 +253,9 @@ import InputText from 'primevue/inputtext' import Textarea from 'primevue/textarea' import {useHead} from '@unhead/vue' -import EntityLink from '@/components/common/EntityLink' import CollectionBadge from '@/components/CollectionBadge' import CollectionDataSetEditor from '@/components/CollectionDataSetEditor' +import CollectionItemsTable from '@/components/CollectionItemsTable' import CollectionPermissionsEditor from '@/components/CollectionPermissionsEditor' import DefaultLayout from '@/components/layout/DefaultLayout' import ItemNotFound from '@/components/common/ItemNotFound' @@ -266,10 +272,10 @@ export default { Button, CollectionBadge, CollectionDataSetEditor, + CollectionItemsTable, CollectionPermissionsEditor, DefaultLayout, Dialog, - EntityLink, Inplace, InputText, ItemNotFound, @@ -312,6 +318,15 @@ export default { privacyDialogVisible: false }), + computed: { + scoreSetsList() { + return (this.item?.scoreSetUrns || []).map((urn) => ({urn})) + }, + experimentsList() { + return (this.item?.experimentUrns || []).map((urn) => ({urn})) + } + }, + watch: { item: { handler: function (newValue) { @@ -353,6 +368,87 @@ export default { this.reloadItem(this.itemId) }, + openEditor(editorRef) { + const editor = this.$refs[editorRef] + if (editor && typeof editor.openEditor === 'function') { + editor.openEditor() + } + }, + + openScoreSetEditor() { + this.openEditor('scoreSetEditor') + }, + + openExperimentEditor() { + this.openEditor('experimentEditor') + }, + async removeCollectionEntity(entityType, urn, {successSummary, failureSummary}) { + try { + await axios.delete(`${config.apiBaseUrl}/collections/${this.item.urn}/${entityType}/${urn}`) + this.$toast.add({severity: 'success', summary: successSummary, life: 3000}) + this.reloadItem(this.itemId) + } catch (error) { + this.$toast.add({ + severity: 'error', + summary: failureSummary, + detail: error.response?.data?.detail || error.message, + life: 5000 + }) + } + }, + + async removeScoreSet(urn) { + return this.removeCollectionEntity('score-sets', urn, { + successSummary: 'Score set removed', + failureSummary: 'Failed to remove score set' + }) + }, + + async removeExperiment(urn) { + return this.removeCollectionEntity('experiments', urn, { + successSummary: 'Experiment removed', + failureSummary: 'Failed to remove experiment' + }) + }, + + async reorderCollectionItems(event, urnFieldName, successSummary, failureSummary) { + const newOrder = event.value.map((row) => row.urn) + try { + const response = await axios.patch(`${config.apiBaseUrl}/collections/${this.item.urn}`, { + [urnFieldName]: newOrder + }) + if (response.status === 200) { + // TODO#XXX: Consider adding an 'updateItem' method to the item store to avoid this extra API round trip. + // Currently using reloadItem() for safety to ensure state consistency, but we already have the updated + // item in response.data. A carefully designed updateItem() could reduce API calls while maintaining + // data integrity through proper validation and state management. + this.reloadItem(this.itemId) + this.$toast.add({severity: 'success', summary: successSummary, life: 3000}) + } + } catch (error) { + this.$toast.add({ + severity: 'error', + summary: failureSummary, + detail: error.response?.data?.detail || error.message, + life: 5000 + }) + this.reloadItem(this.itemId) // Rollback on error + } + }, + + async onScoreSetReorder(event) { + await this.reorderCollectionItems(event, 'score_set_urns', 'Score sets reordered', 'Failed to reorder score sets') + }, + + async onExperimentReorder(event) { + await this.reorderCollectionItems( + event, + 'experiment_urns', + 'Experiments reordered', + 'Failed to reorder experiments' + ) + }, + deleteCollectionWithConfirmation: function () { const numOtherUsers = (this.item.admins || []).length + (this.item.editors || []).length + (this.item.viewers || []).length - 1 @@ -431,10 +527,10 @@ export default { editedDescription = editedDescription == '' ? null : editedDescription if (editedDescription == this.item.description) { // Do nothing if the description has not changed. - this.displayCollectionNameEdit = false + this.displayCollectionDescriptionEdit = false } else { const collectionPatch = { - description: editedDescription == '' ? null : editedDescription + description: editedDescription } let response = null try { From c98f703fd70b5bc596d2c8761f3a8649fef6126c Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Mon, 16 Feb 2026 18:53:16 -0800 Subject: [PATCH 3/3] chore: refresh types for collections --- src/schema/openapi.d.ts | 48 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/src/schema/openapi.d.ts b/src/schema/openapi.d.ts index 5bd2934b..c4719749 100644 --- a/src/schema/openapi.d.ts +++ b/src/schema/openapi.d.ts @@ -59,7 +59,12 @@ export interface paths { delete: operations["delete_collection_api_v1_collections__urn__delete"]; /** * Update a collection - * @description Modify a collection's metadata. + * @description Modify a collection's metadata. Also supports reordering and modifying collection membership + * via score_set_urns and experiment_urns fields (replace-all with implicit add/remove). + * + * When score_set_urns or experiment_urns are provided, the order of URNs in the array determines + * the display order in the collection. The provided list replaces the entire set of associations: + * URNs not in the list are removed, new URNs are added, and the order is updated to match. */ patch: operations["update_collection_api_v1_collections__urn__patch"]; }; @@ -67,6 +72,10 @@ export interface paths { /** * Create a collection * @description Create a new collection owned by the current user. + * + * The order of URNs in score_set_urns and experiment_urns determines the display + * order in the collection. This order is preserved and can be modified later using + * the PATCH endpoint. */ post: operations["create_collection_api_v1_collections__post"]; }; @@ -74,6 +83,9 @@ export interface paths { /** * Add a score set to a collection * @description Add an existing score set to an existing collection. + * + * The score set will be appended to the end of the collection's score set list. + * To specify a different position, use the PATCH endpoint with the full ordered list. */ post: operations["add_score_set_to_collection_api_v1_collections__collection_urn__score_sets_post"]; }; @@ -89,6 +101,9 @@ export interface paths { /** * Add an experiment to a collection * @description Add an existing experiment to an existing collection. + * + * The experiment will be appended to the end of the collection's experiment list. + * To specify a different position, use the PATCH endpoint with the full ordered list. */ post: operations["add_experiment_to_collection_api_v1_collections__collection_urn__experiments_post"]; }; @@ -1976,6 +1991,16 @@ export interface components { * @description Badge name. Input ignored unless requesting user has MaveDB admin privileges. */ badgeName?: string | null; + /** + * Scoreseturns + * @description Ordered list of score set URNs. When provided, replaces the full set of score sets and their ordering. URNs not currently in the collection will be added; URNs currently in the collection but absent from this list will be removed. The list order determines the persisted display order. + */ + scoreSetUrns?: string[] | null; + /** + * Experimenturns + * @description Ordered list of experiment URNs. When provided, replaces the full set of experiments and their ordering. URNs not currently in the collection will be added; URNs currently in the collection but absent from this list will be removed. The list order determines the persisted display order. + */ + experimentUrns?: string[] | null; }; /** * ConceptMapping @@ -3550,6 +3575,10 @@ export interface components { name: string; /** Urn */ urn: string; + /** Scoreseturns */ + scoreSetUrns: string[]; + /** Experimenturns */ + experimentUrns: string[]; }; /** OrcidUser */ OrcidUser: { @@ -6114,7 +6143,12 @@ export interface operations { }; /** * Update a collection - * @description Modify a collection's metadata. + * @description Modify a collection's metadata. Also supports reordering and modifying collection membership + * via score_set_urns and experiment_urns fields (replace-all with implicit add/remove). + * + * When score_set_urns or experiment_urns are provided, the order of URNs in the array determines + * the display order in the collection. The provided list replaces the entire set of associations: + * URNs not in the list are removed, new URNs are added, and the order is updated to match. */ update_collection_api_v1_collections__urn__patch: { parameters: { @@ -6168,6 +6202,10 @@ export interface operations { /** * Create a collection * @description Create a new collection owned by the current user. + * + * The order of URNs in score_set_urns and experiment_urns determines the display + * order in the collection. This order is preserved and can be modified later using + * the PATCH endpoint. */ create_collection_api_v1_collections__post: { parameters: { @@ -6218,6 +6256,9 @@ export interface operations { /** * Add a score set to a collection * @description Add an existing score set to an existing collection. + * + * The score set will be appended to the end of the collection's score set list. + * To specify a different position, use the PATCH endpoint with the full ordered list. */ add_score_set_to_collection_api_v1_collections__collection_urn__score_sets_post: { parameters: { @@ -6317,6 +6358,9 @@ export interface operations { /** * Add an experiment to a collection * @description Add an existing experiment to an existing collection. + * + * The experiment will be appended to the end of the collection's experiment list. + * To specify a different position, use the PATCH endpoint with the full ordered list. */ add_experiment_to_collection_api_v1_collections__collection_urn__experiments_post: { parameters: {