Repository: ITISFoundation/osparc-simcore
Pull request #7696: 🎨 [Frontend] Card Menu: Template menu on template, tutorial and hypertool cards
Author: login: odeimaiz
Labels: name: t:enhancement
description: Improvement or request on an existing feature
name: a:frontend
description: issue affecting the front-end  (area group)
name: 🤖-automerge
description: marks PR as ready to be merged for Mergify
Created: 2025-05-19T07:25:17Z | Merged: 2025-05-19T08:36:14Z

## What do these changes do?

Before bringing the Hypertools and Public Projects sections, the Study browser was only listing studies, the Template browser templates and the Service browser services...

We now have templates in all three browser, but the menu in their cards would still show the browser specific menu. This PR fixes that issue.

Wrong menus:
![WrongMenu](https://github.com/user-attachments/assets/56d3ceec-c4a8-49a3-899a-7fb38a4d3d3b)

Correct menus:
![CorrectMenu](https://github.com/user-attachments/assets/b4736419-cf28-4d01-95ce-eeff1a439928)


## Related issue/s
- related to https://github.com/ITISFoundation/private-issues/issues/53
- related to https://github.com/ITISFoundation/private-issues/issues/7


## How to test

<!-- Give REVIEWERS some hits or code snippets on how could this be tested -->

## Dev-ops

<!--
- No changes /updated ENV. SEE https://git.speag.com/oSparc/osparc-ops-deployment-configuration/-/blob/configs/README.md?ref_type=heads#how-to-update-env-variables)
- SEE docs/devops-checklist.md
-->



Diff:
diff --git a/services/static-webserver/client/source/class/osparc/dashboard/AppBrowser.js b/services/static-webserver/client/source/class/osparc/dashboard/AppBrowser.js
index ba1d56642638..de4f2614836f 100644
--- a/services/static-webserver/client/source/class/osparc/dashboard/AppBrowser.js
+++ b/services/static-webserver/client/source/class/osparc/dashboard/AppBrowser.js
@@ -165,6 +165,16 @@ qx.Class.define("osparc.dashboard.AppBrowser", {
     },
 
     _populateCardMenu: function(card) {
+      const studyData = card.getResourceData();
+      if (studyData["resourceType"] === "hypertool") {
+        // The App Browser can also list templates (hypertools)
+        this._populateTemplateCardMenu(card);
+      } else {
+        this._populateServiceCardMenu(card);
+      }
+    },
+
+    _populateServiceCardMenu: function(card) {
       const menu = card.getMenu();
       const appData = card.getResourceData();
 
diff --git a/services/static-webserver/client/source/class/osparc/dashboard/CardBase.js b/services/static-webserver/client/source/class/osparc/dashboard/CardBase.js
index f915b0013fbe..0235ea5408d2 100644
--- a/services/static-webserver/client/source/class/osparc/dashboard/CardBase.js
+++ b/services/static-webserver/client/source/class/osparc/dashboard/CardBase.js
@@ -40,6 +40,7 @@ qx.Class.define("osparc.dashboard.CardBase", {
   events: {
     "updateStudy": "qx.event.type.Data",
     "updateTemplate": "qx.event.type.Data",
+    "updateTutorial": "qx.event.type.Data",
     "updateService": "qx.event.type.Data",
     "updateHypertool": "qx.event.type.Data",
     "publishTemplate": "qx.event.type.Data",
@@ -325,8 +326,9 @@ qx.Class.define("osparc.dashboard.CardBase", {
       check: [
         "study",
         "template",
-        "service",
+        "tutorial",
         "hypertool",
+        "service",
       ],
       init: true,
       nullable: true,
@@ -519,6 +521,7 @@ qx.Class.define("osparc.dashboard.CardBase", {
       switch (resourceData["resourceType"]) {
         case "study":
         case "template":
+        case "tutorial":
         case "hypertool":
           uuid = resourceData.uuid ? resourceData.uuid : null;
           owner = resourceData.prjOwner ? resourceData.prjOwner : "";
@@ -552,7 +555,12 @@ qx.Class.define("osparc.dashboard.CardBase", {
         workbench
       });
 
-      if (["study", "template", "hypertool"].includes(resourceData["resourceType"])) {
+      if ([
+        "study",
+        "template",
+        "tutorial",
+        "hypertool"
+      ].includes(resourceData["resourceType"])) {
         osparc.store.Services.getStudyServices(resourceData.uuid)
           .then(resp => {
             const services = resp["services"];
@@ -1002,6 +1010,7 @@ qx.Class.define("osparc.dashboard.CardBase", {
       [
         "updateStudy",
         "updateTemplate",
+        "updateTutorial",
         "updateService",
         "updateHypertool",
       ].forEach(ev => {
@@ -1055,6 +1064,8 @@ qx.Class.define("osparc.dashboard.CardBase", {
         toolTipText += osparc.product.Utils.getStudyAlias();
       } else if (this.isResourceType("template")) {
         toolTipText += osparc.product.Utils.getTemplateAlias();
+      } else if (this.isResourceType("tutorial")) {
+        toolTipText += osparc.product.Utils.getTutorialAlias();
       } else if (this.isResourceType("hypertool")) {
         toolTipText += osparc.product.Utils.getAppAlias();
       }
diff --git a/services/static-webserver/client/source/class/osparc/dashboard/GridButtonItem.js b/services/static-webserver/client/source/class/osparc/dashboard/GridButtonItem.js
index 10d755424cd8..858328268c08 100644
--- a/services/static-webserver/client/source/class/osparc/dashboard/GridButtonItem.js
+++ b/services/static-webserver/client/source/class/osparc/dashboard/GridButtonItem.js
@@ -185,7 +185,12 @@ qx.Class.define("osparc.dashboard.GridButtonItem", {
     // overridden
     _applyLastChangeDate: function(value, old) {
       if (value) {
-        if (["study", "template", "hypertool"].includes(this.getResourceType())) {
+        if ([
+          "study",
+          "template",
+          "tutorial",
+          "hypertool",
+        ].includes(this.getResourceType())) {
           const dateBy = this.getChildControl("date-by");
           dateBy.set({
             date: value,
diff --git a/services/static-webserver/client/source/class/osparc/dashboard/ListButtonItem.js b/services/static-webserver/client/source/class/osparc/dashboard/ListButtonItem.js
index d79717bb8f72..23cc75e57145 100644
--- a/services/static-webserver/client/source/class/osparc/dashboard/ListButtonItem.js
+++ b/services/static-webserver/client/source/class/osparc/dashboard/ListButtonItem.js
@@ -186,7 +186,12 @@ qx.Class.define("osparc.dashboard.ListButtonItem", {
     // overridden
     _applyLastChangeDate: function(value, old) {
       if (value) {
-        if (["study", "template", "hypertool"].includes(this.getResourceType())) {
+        if ([
+          "study",
+          "template",
+          "tutorial",
+          "hypertool",
+        ].includes(this.getResourceType())) {
           const dateBy = this.getChildControl("date-by");
           dateBy.set({
             date: value,
diff --git a/services/static-webserver/client/source/class/osparc/dashboard/ResourceBrowserBase.js b/services/static-webserver/client/source/class/osparc/dashboard/ResourceBrowserBase.js
index c46f96ec0242..b82fb9ddf8b7 100644
--- a/services/static-webserver/client/source/class/osparc/dashboard/ResourceBrowserBase.js
+++ b/services/static-webserver/client/source/class/osparc/dashboard/ResourceBrowserBase.js
@@ -287,6 +287,7 @@ qx.Class.define("osparc.dashboard.ResourceBrowserBase", {
       }
       resourcesContainer.addListener("updateStudy", e => this._updateStudyData(e.getData()));
       resourcesContainer.addListener("updateTemplate", e => this._updateTemplateData(e.getData()));
+      resourcesContainer.addListener("updateTutorial", e => this._updateTutorialData(e.getData()));
       resourcesContainer.addListener("updateService", e => this._updateServiceData(e.getData()));
       resourcesContainer.addListener("updateHypertool", e => this._updateHypertoolData(e.getData()));
       resourcesContainer.addListener("publishTemplate", e => this.fireDataEvent("publishTemplate", e.getData()));
@@ -579,6 +580,111 @@ qx.Class.define("osparc.dashboard.ResourceBrowserBase", {
       throw new Error("Abstract method called!");
     },
 
+    _populateTemplateCardMenu: function(card) {
+      const menu = card.getMenu();
+      const templateData = card.getResourceData();
+
+      const editButton = this.__getEditTemplateMenuButton(templateData);
+      if (editButton) {
+        menu.add(editButton);
+        menu.addSeparator();
+      }
+
+      const openButton = this._getOpenMenuButton(templateData);
+      if (openButton) {
+        menu.add(openButton);
+      }
+
+      const shareButton = this._getShareMenuButton(card);
+      if (shareButton) {
+        menu.add(shareButton);
+      }
+
+      const tagsButton = this._getTagsMenuButton(card);
+      if (tagsButton) {
+        menu.add(tagsButton);
+      }
+
+      const deleteButton = this.__getDeleteTemplateMenuButton(templateData);
+      if (deleteButton && editButton) {
+        menu.addSeparator();
+        menu.add(deleteButton);
+      }
+    },
+
+    __getEditTemplateMenuButton: function(templateData) {
+      const isCurrentUserOwner = osparc.data.model.Study.canIWrite(templateData["accessRights"]);
+      if (!isCurrentUserOwner) {
+        return null;
+      }
+
+      const editButton = new qx.ui.menu.Button(this.tr("Open"));
+      editButton.addListener("execute", () => {
+        const isStudyCreation = false;
+        this._startStudyById(templateData["uuid"], null, null, isStudyCreation);
+      }, this);
+      return editButton;
+    },
+
+    __getDeleteTemplateMenuButton: function(templateData) {
+      const isCurrentUserOwner = osparc.data.model.Study.canIDelete(templateData["accessRights"]);
+      if (!isCurrentUserOwner) {
+        return null;
+      }
+
+      const deleteButton = new qx.ui.menu.Button(this.tr("Delete"), "@FontAwesome5Solid/trash/12");
+      deleteButton.set({
+        appearance: "menu-button"
+      });
+      deleteButton.addListener("execute", () => this._deleteTemplateRequested(templateData), this);
+      return deleteButton;
+    },
+
+    _deleteTemplateRequested: function(templateData) {
+      const rUSure = this.tr("Are you sure you want to delete ");
+      const msg = rUSure + "<b>" + templateData.name + "</b>?";
+      const win = new osparc.ui.window.Confirmation(msg).set({
+        caption: this.tr("Delete"),
+        confirmText: this.tr("Delete"),
+        confirmAction: "delete"
+      });
+      win.center();
+      win.open();
+      win.addListener("close", () => {
+        if (win.getConfirmed()) {
+          this.__doDeleteTemplate(templateData);
+        }
+      }, this);
+    },
+
+    __doDeleteTemplate: function(templateData) {
+      const myGid = osparc.auth.Data.getInstance().getGroupId();
+      const collabGids = Object.keys(templateData["accessRights"]);
+      const amICollaborator = collabGids.indexOf(myGid) > -1;
+
+      let operationPromise = null;
+      if (collabGids.length > 1 && amICollaborator) {
+        const arCopy = osparc.utils.Utils.deepCloneObject(templateData["accessRights"]);
+        // remove collaborator
+        delete arCopy[myGid];
+        operationPromise = osparc.store.Study.patchStudyData(templateData, "accessRights", arCopy);
+      } else {
+        // delete study
+        operationPromise = osparc.store.Store.getInstance().deleteStudy(templateData.uuid);
+      }
+      operationPromise
+        .then(() => this.__removeFromTemplateList(templateData.uuid))
+        .catch(err => osparc.FlashMessenger.logError(err));
+    },
+
+    __removeFromTemplateList: function(templateId) {
+      const idx = this._resourcesList.findIndex(study => study["uuid"] === templateId);
+      if (idx > -1) {
+        this._resourcesList.splice(idx, 1);
+      }
+      this._resourcesContainer.removeCard(templateId);
+    },
+
     _updateTemplateData: function(templateData) {
       const templatesList = this._resourcesList;
       const index = templatesList.findIndex(template => template["uuid"] === templateData["uuid"]);
@@ -588,6 +694,10 @@ qx.Class.define("osparc.dashboard.ResourceBrowserBase", {
       }
     },
 
+    updateTutorialData: function(tutorialData) {
+      throw new Error("Abstract method called!");
+    },
+
     _updateServiceData: function(serviceData) {
       throw new Error("Abstract method called!");
     },
@@ -811,6 +921,7 @@ qx.Class.define("osparc.dashboard.ResourceBrowserBase", {
             break;
           }
           case "template":
+          case "tutorial":
           case "hypertool":
             this._createStudyFromTemplate(resourceData);
             break;
@@ -827,6 +938,7 @@ qx.Class.define("osparc.dashboard.ResourceBrowserBase", {
       const win = osparc.dashboard.ResourceDetails.popUpInWindow(resourceDetails);
       resourceDetails.addListener("updateStudy", e => this._updateStudyData(e.getData()));
       resourceDetails.addListener("updateTemplate", e => this._updateTemplateData(e.getData()));
+      resourceDetails.addListener("updateTutorial", e => this._updateTutorialData(e.getData()));
       resourceDetails.addListener("updateService", e => this._updateServiceData(e.getData()));
       resourceDetails.addListener("updateHypertool", e => this._updateHypertoolData(e.getData()));
       resourceDetails.addListener("publishTemplate", e => {
@@ -839,15 +951,16 @@ qx.Class.define("osparc.dashboard.ResourceBrowserBase", {
         const isStudyCreation = false;
         this._startStudyById(studyId, openCB, null, isStudyCreation);
       });
-      resourceDetails.addListener("openTemplate", e => {
-        win.close();
-        const templateData = e.getData();
-        this._createStudyFromTemplate(templateData);
-      });
-      resourceDetails.addListener("openHypertool", e => {
-        win.close();
-        const templateData = e.getData();
-        this._createStudyFromTemplate(templateData);
+      [
+        "openTemplate",
+        "openTutorial",
+        "openHypertool",
+      ].forEach(eventName => {
+        resourceDetails.addListener(eventName, e => {
+          win.close();
+          const templateData = e.getData();
+          this._createStudyFromTemplate(templateData);
+        });
       });
       resourceDetails.addListener("openService", e => {
         win.close();
diff --git a/services/static-webserver/client/source/class/osparc/dashboard/ResourceContainerManager.js b/services/static-webserver/client/source/class/osparc/dashboard/ResourceContainerManager.js
index 93f30346768c..9398d8eba77f 100644
--- a/services/static-webserver/client/source/class/osparc/dashboard/ResourceContainerManager.js
+++ b/services/static-webserver/client/source/class/osparc/dashboard/ResourceContainerManager.js
@@ -67,6 +67,7 @@ qx.Class.define("osparc.dashboard.ResourceContainerManager", {
   events: {
     "updateStudy": "qx.event.type.Data",
     "updateTemplate": "qx.event.type.Data",
+    "updateTutorial": "qx.event.type.Data",
     "updateService": "qx.event.type.Data",
     "updateHypertool": "qx.event.type.Data",
     "publishTemplate": "qx.event.type.Data",
@@ -240,6 +241,7 @@ qx.Class.define("osparc.dashboard.ResourceContainerManager", {
       [
         "updateStudy",
         "updateTemplate",
+        "updateTutorial",
         "updateService",
         "updateHypertool",
         "publishTemplate",
diff --git a/services/static-webserver/client/source/class/osparc/dashboard/ResourceDetails.js b/services/static-webserver/client/source/class/osparc/dashboard/ResourceDetails.js
index bd6580564902..5942be9cb458 100644
--- a/services/static-webserver/client/source/class/osparc/dashboard/ResourceDetails.js
+++ b/services/static-webserver/client/source/class/osparc/dashboard/ResourceDetails.js
@@ -27,6 +27,7 @@ qx.Class.define("osparc.dashboard.ResourceDetails", {
     switch (resourceData["resourceType"]) {
       case "study":
       case "template":
+      case "tutorial":
       case "hypertool": {
         const params = {
           url: {
@@ -54,6 +55,7 @@ qx.Class.define("osparc.dashboard.ResourceDetails", {
         switch (resourceData["resourceType"]) {
           case "study":
           case "template":
+          case "tutorial":
           case "hypertool":
             osparc.store.Services.getStudyServicesMetadata(latestResourceData)
               .finally(() => {
@@ -76,11 +78,14 @@ qx.Class.define("osparc.dashboard.ResourceDetails", {
 
   events: {
     "pagesAdded": "qx.event.type.Event",
+    "openStudy": "qx.event.type.Data",
     "openTemplate": "qx.event.type.Data",
+    "openTutorial": "qx.event.type.Data",
     "openHypertool": "qx.event.type.Data",
     "openService": "qx.event.type.Data",
     "updateStudy": "qx.event.type.Data",
     "updateTemplate": "qx.event.type.Data",
+    "updateTutorial": "qx.event.type.Data",
     "updateService": "qx.event.type.Data",
     "updateHypertool": "qx.event.type.Data",
     "publishTemplate": "qx.event.type.Data",
@@ -248,6 +253,9 @@ qx.Class.define("osparc.dashboard.ResourceDetails", {
         case "template":
           this.fireDataEvent("openTemplate", this.__resourceData);
           break;
+        case "tutorial":
+          this.fireDataEvent("openTutorial", this.__resourceData);
+          break;
         case "hypertool":
           this.fireDataEvent("openHypertool", this.__resourceData);
           break;
@@ -384,6 +392,9 @@ qx.Class.define("osparc.dashboard.ResourceDetails", {
         case "template":
           this.fireDataEvent("updateTemplate", updatedData);
           break;
+        case "tutorial":
+          this.fireDataEvent("updateTutorial", updatedData);
+          break;
         case "hypertool":
           this.fireDataEvent("updateHypertool", updatedData);
           break;
@@ -558,6 +569,8 @@ qx.Class.define("osparc.dashboard.ResourceDetails", {
             collaboratorsView.getChildControl("study-link").show();
           } else if (osparc.utils.Resources.isTemplate(resourceData)) {
             collaboratorsView.getChildControl("template-link").show();
+          } else if (osparc.utils.Resources.isTutorial(resourceData)) {
+            collaboratorsView.getChildControl("template-link").show();
           }
           collaboratorsView.addListener("updateAccessRights", e => {
             const updatedData = e.getData();
@@ -726,7 +739,12 @@ qx.Class.define("osparc.dashboard.ResourceDetails", {
         });
         page.addToContent(servicesBootOpts);
 
-        if (osparc.utils.Resources.isStudy(resourceData) || osparc.utils.Resources.isTemplate(resourceData)) {
+        if (
+          osparc.utils.Resources.isStudy(resourceData) ||
+          osparc.utils.Resources.isTemplate(resourceData) ||
+          osparc.utils.Resources.isTutorial(resourceData) ||
+          osparc.utils.Resources.isHypertool(resourceData)
+        ) {
           if (osparc.product.Utils.showDisableServiceAutoStart()) {
             const study = new osparc.data.model.Study(resourceData);
             const autoStartButton = osparc.info.StudyUtils.createDisableServiceAutoStart(study).set({
@@ -846,10 +864,10 @@ qx.Class.define("osparc.dashboard.ResourceDetails", {
     __getProjectFilesPopUp: function() {
       const resourceData = this.__resourceData;
       if (!osparc.utils.Resources.isService(resourceData)) {
-        const title = osparc.product.Utils.getStudyAlias({firstUpperCase: true}) + this.tr(" Files...");
+        const title = osparc.product.Utils.resourceTypeToAlias(resourceData["resourceType"], {firstUpperCase: true}) + this.tr(" Files");
         const iconSrc = "@FontAwesome5Solid/file/22";
         const dataAccess = new qx.ui.basic.Atom().set({
-          label: title,
+          label: title + "...",
           icon: iconSrc,
           font: "text-14",
           padding: 8,
@@ -857,7 +875,7 @@ qx.Class.define("osparc.dashboard.ResourceDetails", {
           gap: 14,
           cursor: "pointer",
         });
-        dataAccess.addListener("tap", () => osparc.widget.StudyDataManager.popUpInWindow(resourceData["uuid"]));
+        dataAccess.addListener("tap", () => osparc.widget.StudyDataManager.popUpInWindow(resourceData["uuid"], null, title));
         this.addWidgetToTabs(dataAccess);
 
         if (resourceData["resourceType"] === "study") {
diff --git a/services/static-webserver/client/source/class/osparc/dashboard/StudyBrowser.js b/services/static-webserver/client/source/class/osparc/dashboard/StudyBrowser.js
index 4f58eaaa43c2..6ca86566385f 100644
--- a/services/static-webserver/client/source/class/osparc/dashboard/StudyBrowser.js
+++ b/services/static-webserver/client/source/class/osparc/dashboard/StudyBrowser.js
@@ -1543,14 +1543,24 @@ qx.Class.define("osparc.dashboard.StudyBrowser", {
     },
 
     _populateCardMenu: function(card) {
+      const studyData = card.getResourceData();
+      if (studyData["resourceType"] === "template") {
+        // The Study Browser can also list templates
+        this._populateTemplateCardMenu(card);
+      } else {
+        this.__populateStudyCardMenu(card);
+      }
+    },
+
+    __populateStudyCardMenu: function(card) {
       const menu = card.getMenu();
       const studyData = card.getResourceData();
 
-      const trashed = Boolean(studyData["trashedAt"]);
       const writeAccess = osparc.data.model.Study.canIWrite(studyData["accessRights"]);
       const deleteAccess = osparc.data.model.Study.canIDelete(studyData["accessRights"]);
 
       if (this.getCurrentContext() === "trash") {
+        const trashed = Boolean(studyData["trashedAt"]);
         if (trashed) {
           if (writeAccess) {
             const untrashButton = this.__getUntrashStudyMenuButton(studyData);
diff --git a/services/static-webserver/client/source/class/osparc/dashboard/TutorialBrowser.js b/services/static-webserver/client/source/class/osparc/dashboard/TutorialBrowser.js
index aa9f396542f4..78ae0e458a01 100644
--- a/services/static-webserver/client/source/class/osparc/dashboard/TutorialBrowser.js
+++ b/services/static-webserver/client/source/class/osparc/dashboard/TutorialBrowser.js
@@ -88,13 +88,14 @@ qx.Class.define("osparc.dashboard.TutorialBrowser", {
           .catch(() => this.__setResourcesToList([]));
     },
 
-    _updateTemplateData: function(templateData) {
+    _updateTutorialData: function(templateData) {
       templateData["resourceType"] = "tutorial";
-      this.base(arguments, templateData);
+
+      this._updateTemplateData(templateData);
     },
 
     __setResourcesToList: function(tutorialsList) {
-      tutorialsList.forEach(tutorial => tutorial["resourceType"] = "template");
+      tutorialsList.forEach(tutorial => tutorial["resourceType"] = "tutorial");
       this._resourcesList = tutorialsList;
       this._reloadCards();
     },
@@ -221,7 +222,7 @@ qx.Class.define("osparc.dashboard.TutorialBrowser", {
           }
         }
         Promise.all(templatePromises)
-          .then(() => this._updateTemplateData(uniqueTemplateData))
+          .then(() => this._updateTutorialData(uniqueTemplateData))
           .catch(err => {
             osparc.FlashMessenger.logError(err);
           });
@@ -231,46 +232,7 @@ qx.Class.define("osparc.dashboard.TutorialBrowser", {
 
     // MENU //
     _populateCardMenu: function(card) {
-      const menu = card.getMenu();
-      const templateData = card.getResourceData();
-
-      const editButton = this.__getEditTemplateMenuButton(templateData);
-      if (editButton) {
-        menu.add(editButton);
-        menu.addSeparator();
-      }
-
-      const openButton = this._getOpenMenuButton(templateData);
-      if (openButton) {
-        menu.add(openButton);
-      }
-
-      const shareButton = this._getShareMenuButton(card);
-      if (shareButton) {
-        menu.add(shareButton);
-      }
-
-      const tagsButton = this._getTagsMenuButton(card);
-      if (tagsButton) {
-        menu.add(tagsButton);
-      }
-
-      const deleteButton = this.__getDeleteTemplateMenuButton(templateData);
-      if (deleteButton && editButton) {
-        menu.addSeparator();
-        menu.add(deleteButton);
-      }
-    },
-
-    __getEditTemplateMenuButton: function(templateData) {
-      const isCurrentUserOwner = osparc.data.model.Study.canIWrite(templateData["accessRights"]);
-      if (!isCurrentUserOwner) {
-        return null;
-      }
-
-      const editButton = new qx.ui.menu.Button(this.tr("Open"));
-      editButton.addListener("execute", () => this.__editTemplate(templateData), this);
-      return editButton;
+      this._populateTemplateCardMenu(card);
     },
 
     __getTemplateData: function(id) {
@@ -278,72 +240,7 @@ qx.Class.define("osparc.dashboard.TutorialBrowser", {
     },
 
     _deleteResourceRequested: function(templateId) {
-      this.__deleteTemplateRequested(this.__getTemplateData(templateId));
-    },
-
-    __deleteTemplateRequested: function(templateData) {
-      const rUSure = this.tr("Are you sure you want to delete ");
-      const msg = rUSure + "<b>" + templateData.name + "</b>?";
-      const win = new osparc.ui.window.Confirmation(msg).set({
-        caption: this.tr("Delete"),
-        confirmText: this.tr("Delete"),
-        confirmAction: "delete"
-      });
-      win.center();
-      win.open();
-      win.addListener("close", () => {
-        if (win.getConfirmed()) {
-          this.__doDeleteTemplate(templateData);
-        }
-      }, this);
-    },
-
-    __getDeleteTemplateMenuButton: function(templateData) {
-      const isCurrentUserOwner = osparc.data.model.Study.canIDelete(templateData["accessRights"]);
-      if (!isCurrentUserOwner) {
-        return null;
-      }
-
-      const deleteButton = new qx.ui.menu.Button(this.tr("Delete"), "@FontAwesome5Solid/trash/12");
-      deleteButton.set({
-        appearance: "menu-button"
-      });
-      osparc.utils.Utils.setIdToWidget(deleteButton, "studyItemMenuDelete");
-      deleteButton.addListener("execute", () => this.__deleteTemplateRequested(templateData), this);
-      return deleteButton;
-    },
-
-    __editTemplate: function(studyData) {
-      const isStudyCreation = false;
-      this._startStudyById(studyData.uuid, null, null, isStudyCreation);
-    },
-
-    __doDeleteTemplate: function(studyData) {
-      const myGid = osparc.auth.Data.getInstance().getGroupId();
-      const collabGids = Object.keys(studyData["accessRights"]);
-      const amICollaborator = collabGids.indexOf(myGid) > -1;
-
-      let operationPromise = null;
-      if (collabGids.length > 1 && amICollaborator) {
-        const arCopy = osparc.utils.Utils.deepCloneObject(studyData["accessRights"]);
-        // remove collaborator
-        delete arCopy[myGid];
-        operationPromise = osparc.store.Study.patchStudyData(studyData, "accessRights", arCopy);
-      } else {
-        // delete study
-        operationPromise = osparc.store.Store.getInstance().deleteStudy(studyData.uuid);
-      }
-      operationPromise
-        .then(() => this.__removeFromTemplateList(studyData.uuid))
-        .catch(err => osparc.FlashMessenger.logError(err));
-    },
-
-    __removeFromTemplateList: function(templateId) {
-      const idx = this._resourcesList.findIndex(study => study["uuid"] === templateId);
-      if (idx > -1) {
-        this._resourcesList.splice(idx, 1);
-      }
-      this._resourcesContainer.removeCard(templateId);
+      this._deleteTemplateRequested(this.__getTemplateData(templateId));
     },
     // MENU //
 
diff --git a/services/static-webserver/client/source/class/osparc/desktop/organizations/TutorialsList.js b/services/static-webserver/client/source/class/osparc/desktop/organizations/TutorialsList.js
index b27b6cf2d4de..6636b4bd918e 100644
--- a/services/static-webserver/client/source/class/osparc/desktop/organizations/TutorialsList.js
+++ b/services/static-webserver/client/source/class/osparc/desktop/organizations/TutorialsList.js
@@ -89,7 +89,7 @@ qx.Class.define("osparc.desktop.organizations.TutorialsList", {
             osparc.store.Templates.getTutorial(templateId)
               .then(templateData => {
                 if (templateData) {
-                  templateData["resourceType"] = "template";
+                  templateData["resourceType"] = "tutorial";
                   const resourceDetails = new osparc.dashboard.ResourceDetails(templateData).set({
                     showOpenButton: false
                   });
diff --git a/services/static-webserver/client/source/class/osparc/product/Utils.js b/services/static-webserver/client/source/class/osparc/product/Utils.js
index 605a9db61dd2..651ca4d505e0 100644
--- a/services/static-webserver/client/source/class/osparc/product/Utils.js
+++ b/services/static-webserver/client/source/class/osparc/product/Utils.js
@@ -81,10 +81,10 @@ qx.Class.define("osparc.product.Utils", {
       return alias;
     },
 
-    getServiceAlias: function(options = {}) {
-      let alias = qx.locale.Manager.tr("service");
+    getTutorialAlias: function(options = {}) {
+      let alias = qx.locale.Manager.tr("tutorial");
       if (options.plural) {
-        alias = qx.locale.Manager.tr("services");
+        alias = qx.locale.Manager.tr("tutorials");
       }
 
       if (options.firstUpperCase) {
@@ -111,6 +111,21 @@ qx.Class.define("osparc.product.Utils", {
       return alias;
     },
 
+    getServiceAlias: function(options = {}) {
+      let alias = qx.locale.Manager.tr("service");
+      if (options.plural) {
+        alias = qx.locale.Manager.tr("services");
+      }
+
+      if (options.firstUpperCase) {
+        alias = osparc.utils.Utils.capitalize(alias);
+      } else if (options.allUpperCase) {
+        alias = alias.toUpperCase();
+      }
+
+      return alias;
+    },
+
     getAppAlias: function(options = {}) {
       let alias = qx.locale.Manager.tr("app");
       if (options.plural) {
@@ -132,6 +147,8 @@ qx.Class.define("osparc.product.Utils", {
           return this.getStudyAlias(options);
         case "template":
           return this.getTemplateAlias(options);
+        case "tutorial":
+          return this.getTutorialAlias(options);
         case "service":
           // return this.getServiceAlias(options);
           // Do not use this alias anymore, use "app" instead
diff --git a/services/static-webserver/client/source/class/osparc/share/Collaborators.js b/services/static-webserver/client/source/class/osparc/share/Collaborators.js
index ca26799687ad..63509d88871b 100644
--- a/services/static-webserver/client/source/class/osparc/share/Collaborators.js
+++ b/services/static-webserver/client/source/class/osparc/share/Collaborators.js
@@ -194,6 +194,7 @@ qx.Class.define("osparc.share.Collaborators", {
       switch (this._resourceType) {
         case "study":
         case "template":
+        case "tutorial":
         case "hypertool":
           canIShare = osparc.data.model.Study.canIWrite(this._serializedDataCopy["accessRights"]);
           break;
@@ -219,6 +220,7 @@ qx.Class.define("osparc.share.Collaborators", {
       switch (this._resourceType) {
         case "study":
         case "template":
+        case "tutorial":
         case "hypertool":
           fullOptions = osparc.data.model.Study.canIDelete(this._serializedDataCopy["accessRights"]);
           break;
@@ -240,6 +242,7 @@ qx.Class.define("osparc.share.Collaborators", {
       switch (this._resourceType) {
         case "study":
         case "template":
+        case "tutorial":
         case "hypertool":
           rolesLayout = osparc.data.Roles.createRolesStudyInfo();
           break;
@@ -345,7 +348,7 @@ qx.Class.define("osparc.share.Collaborators", {
           item.addListener("removeMember", e => {
             const orgMember = e.getData();
             if (
-              ["study", "template", "hypertool"].includes(this._resourceType) &&
+              ["study", "template", "tutorial", "hypertool"].includes(this._resourceType) &&
               !osparc.share.CollaboratorsStudy.canCollaboratorBeRemoved(this._serializedDataCopy, orgMember["gid"])
             ) {
               let msg = this.tr("Collaborator can't be removed:");
@@ -373,7 +376,7 @@ qx.Class.define("osparc.share.Collaborators", {
     __getLeaveStudyButton: function() {
       const myGid = osparc.auth.Data.getInstance().getGroupId();
       if (
-        ["study", "template", "hypertool"].includes(this._resourceType) &&
+        ["study", "template", "tutorial", "hypertool"].includes(this._resourceType) &&
         osparc.share.CollaboratorsStudy.canCollaboratorBeRemoved(this._serializedDataCopy, myGid)
       ) {
         const leaveText = this.tr("Leave") + " " + osparc.product.Utils.getStudyAlias({
diff --git a/services/static-webserver/client/source/class/osparc/share/CollaboratorsStudy.js b/services/static-webserver/client/source/class/osparc/share/CollaboratorsStudy.js
index 5254d35ca852..a595b47320df 100644
--- a/services/static-webserver/client/source/class/osparc/share/CollaboratorsStudy.js
+++ b/services/static-webserver/client/source/class/osparc/share/CollaboratorsStudy.js
@@ -88,9 +88,7 @@ qx.Class.define("osparc.share.CollaboratorsStudy", {
       if (!newAccessRights) {
         newAccessRights = this._resourceType === "study" ? writeAccessRole.accessRights : readAccessRole.accessRights;
       }
-      const resourceAlias = this._resourceType === "template" ?
-        osparc.product.Utils.getTemplateAlias({firstUpperCase: true}) :
-        osparc.product.Utils.getStudyAlias({firstUpperCase: true});
+      const resourceAlias = osparc.product.Utils.resourceTypeToAlias(this._resourceType, {firstUpperCase: true});
       const newCollaborators = {};
       gids.forEach(gid => {
         newCollaborators[gid] = newAccessRights;
@@ -217,13 +215,17 @@ qx.Class.define("osparc.share.CollaboratorsStudy", {
         if (gid in potentialCollaborators && "getUserId" in potentialCollaborators[gid]) {
           // it's a user, not an organization
           const uid = potentialCollaborators[gid].getUserId();
-          if (this._resourceType === "study") {
-            osparc.notification.Notifications.postNewStudy(uid, this._serializedDataCopy["uuid"]);
-          } else if (this._resourceType === "template") {
-            // do not push TEMPLATE_SHARED notification if users are not supposed to see the templates
-            if (osparc.data.Permissions.getInstance().canRoleDo("user", "dashboard.templates.read")) {
-              osparc.notification.Notifications.postNewTemplate(uid, this._serializedDataCopy["uuid"]);
-            }
+          switch (this._resourceType) {
+            case "study":
+              osparc.notification.Notifications.postNewStudy(uid, this._serializedDataCopy["uuid"]);
+              break;
+            case "template":
+            case "tutorial":
+              // do not push TEMPLATE_SHARED notification if users are not supposed to see the templates
+              if (osparc.data.Permissions.getInstance().canRoleDo("user", "dashboard.templates.read")) {
+                osparc.notification.Notifications.postNewTemplate(uid, this._serializedDataCopy["uuid"]);
+              }
+              break;
           }
         }
       });
diff --git a/services/static-webserver/client/source/class/osparc/share/NewCollaboratorsManager.js b/services/static-webserver/client/source/class/osparc/share/NewCollaboratorsManager.js
index 71655ea452f5..c8c21429d733 100644
--- a/services/static-webserver/client/source/class/osparc/share/NewCollaboratorsManager.js
+++ b/services/static-webserver/client/source/class/osparc/share/NewCollaboratorsManager.js
@@ -244,18 +244,26 @@ qx.Class.define("osparc.share.NewCollaboratorsManager", {
       let showProductEveryone = false;
       if (this.__showOrganizations === false) {
         showProductEveryone = false;
-      } else if (this.__resourceData && this.__resourceData["resourceType"] === "study") {
-        // studies can't be shared with ProductEveryone
-        showProductEveryone = false;
-      } else if (this.__resourceData && this.__resourceData["resourceType"] === "template") {
-        // only users with permissions can share templates with ProductEveryone
-        showProductEveryone = osparc.data.Permissions.getInstance().canDo("study.everyone.share");
-      } else if (this.__resourceData && this.__resourceData["resourceType"] === "service") {
-        // all users can share services with ProductEveryone
-        showProductEveryone = true;
-      } else if (this.__resourceData && this.__resourceData["resourceType"] === "hypertool") {
-        // all users can share hypertool with ProductEveryone
-        showProductEveryone = true;
+      } else if (this.__resourceData) {
+        switch (this.__resourceData["resourceType"]) {
+          case "study":
+            // studies can't be shared with ProductEveryone
+            showProductEveryone = false;
+            break;
+          case "template":
+          case "tutorial":
+            // only users with permissions can share templates with ProductEveryone
+            showProductEveryone = osparc.data.Permissions.getInstance().canDo("study.everyone.share");
+            break;
+          case "service":
+            // all users can share services with ProductEveryone
+            showProductEveryone = true;
+            break;
+          case "hypertool":
+            // all users can share hypertool with ProductEveryone
+            showProductEveryone = true;
+            break;
+        }
       }
       return showProductEveryone;
     },
diff --git a/services/static-webserver/client/source/class/osparc/ui/list/CollaboratorListItem.js b/services/static-webserver/client/source/class/osparc/ui/list/CollaboratorListItem.js
index 8962431206a9..9f06c0c5c896 100644
--- a/services/static-webserver/client/source/class/osparc/ui/list/CollaboratorListItem.js
+++ b/services/static-webserver/client/source/class/osparc/ui/list/CollaboratorListItem.js
@@ -76,7 +76,7 @@ qx.Class.define("osparc.ui.list.CollaboratorListItem", {
   members: {
     __getRoleInfo: function(id) {
       const resource = this.getResourceType();
-      if (["study", "template", "hypertool"].includes(resource)) {
+      if (["study", "template", "tutorial", "hypertool"].includes(resource)) {
         return osparc.data.Roles.STUDY[id];
       } else if (resource === "service") {
         return osparc.data.Roles.SERVICES[id];
diff --git a/services/static-webserver/client/source/class/osparc/utils/Resources.js b/services/static-webserver/client/source/class/osparc/utils/Resources.js
index b5045092a918..9d5f3c331f91 100644
--- a/services/static-webserver/client/source/class/osparc/utils/Resources.js
+++ b/services/static-webserver/client/source/class/osparc/utils/Resources.js
@@ -31,6 +31,10 @@ qx.Class.define("osparc.utils.Resources", {
       return ((templateData["resourceType"] === "template") && ("uuid" in templateData));
     },
 
+    isTutorial: function(tutorialData) {
+      return ((tutorialData["resourceType"] === "tutorial") && ("uuid" in tutorialData));
+    },
+
     isHypertool: function(hypertoolData) {
       return ((hypertoolData["resourceType"] === "hypertool") && ("uuid" in hypertoolData));
     },


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR introduces support for a new resource type “tutorial” across various browser components, ensuring that cards for templates, tutorials, and hypertools now display a consistent “template” menu instead of the browser‐specific menus. Key changes include adding a new isTutorial helper in Resources.js, updating menu button conditions in several UI components (e.g. CollaboratorListItem.js, Collaborators.js, CardBase.js), and extending utility functions to support the new tutorial resource type.

### Reviewed Changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 2 comments.

<details>
<summary>Show a summary per file</summary>

| File | Description |
| ---- | ----------- |
| osparc/utils/Resources.js | Adds a new isTutorial method to identify tutorial resources. |
| CollaboratorListItem.js, Collaborators.js, CollaboratorsStudy.js | Updates resource type checks to include “tutorial” in appropriate arrays. |
| NewCollaboratorsManager.js | Refactors sharing logic for tutorials using a switch statement. |
| Utils.js | Adds getTutorialAlias and reorders alias functions to include tutorials. |
| TutorialsList.js, TutorialBrowser.js, and related dashboard components | Update resource type attributes from “template” to “tutorial” and adjust event handling accordingly. |
| StudyBrowser.js, CardBase.js, ListButtonItem.js, GridButtonItem.js, AppBrowser.js | Modify card menu population logic and resource type checks to accommodate the tutorial resource type. |
</details>





state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T08:27:09Z
user: login: matusdrobuliak66
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-19T08:28:13Z
user: login: giancarloromeo
body: Thanks
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T08:29:25Z

Review comments:
diff_hunk: @@ -1543,14 +1543,24 @@ qx.Class.define("osparc.dashboard.StudyBrowser", {
     },
 
     _populateCardMenu: function(card) {
+      const studyData = card.getResourceData();
+      if (studyData["resourceType"] === "template") {
path: services/static-webserver/client/source/class/osparc/dashboard/StudyBrowser.js
user: login: Copilot
body: If the design requires tutorial cards to show the template menu as well, consider updating the condition to check for both "template" and "tutorial" resource types.
created_at: 2025-05-19T08:27:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -88,13 +88,14 @@ qx.Class.define("osparc.dashboard.TutorialBrowser", {
           .catch(() => this.__setResourcesToList([]));
     },
 
-    _updateTemplateData: function(templateData) {
+    _updateTutorialData: function(templateData) {
       templateData["resourceType"] = "tutorial";
-      this.base(arguments, templateData);
+
+      this._updateTemplateData(templateData);
path: services/static-webserver/client/source/class/osparc/dashboard/TutorialBrowser.js
user: login: Copilot
body: [nitpick] Within _updateTutorialData, consider renaming or refactoring the call to _updateTemplateData for clarity since the intent is to update tutorial data rather than template data.
```suggestion
      this._updateTutorialDataInternal(templateData);
```
created_at: 2025-05-19T08:27:09Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-05-19T08:20:03Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=ITISFoundation_osparc-simcore&pullRequest=7696) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=ITISFoundation_osparc-simcore&pullRequest=7696&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=ITISFoundation_osparc-simcore&pullRequest=7696&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=ITISFoundation_osparc-simcore&pullRequest=7696&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=ITISFoundation_osparc-simcore&pullRequest=7696&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=ITISFoundation_osparc-simcore&pullRequest=7696&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=ITISFoundation_osparc-simcore&pullRequest=7696)


author_association: NONE
user: login: odeimaiz
created_at: 2025-05-19T08:28:45Z
body: @MergifyIO queue
author_association: MEMBER
user: login: mergify[bot]
created_at: 2025-05-19T08:29:27Z
body: > queue

#### ✅ The pull request has been merged automatically

<details>

The pull request has been merged automatically at *97fafc48ad4967ed4af43316a1c1dbfc881ddcb0*

</details>


<!---
DO NOT EDIT
-*- Mergify Payload -*-
{"command": "queue", "conclusion": "success", "action_is_running": true}
-*- Mergify Payload End -*-
-->
author_association: CONTRIBUTOR