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 {
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
+ }
+}
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: {