From 88e2715f679f1501433a17e494d1c7e38e03c518 Mon Sep 17 00:00:00 2001 From: Arash Date: Wed, 25 Mar 2026 16:07:12 +0100 Subject: [PATCH 01/70] Implement tool request form backend and admin notifications --- lib/galaxy/config/schemas/config_schema.yml | 13 ++ lib/galaxy/managers/configuration.py | 5 +- lib/galaxy/schema/notifications.py | 33 +++- .../webapps/galaxy/api/tool_request_form.py | 40 +++++ .../galaxy/services/tool_request_form.py | 148 ++++++++++++++++++ test/integration/test_tool_request_form.py | 111 +++++++++++++ 6 files changed, 346 insertions(+), 4 deletions(-) create mode 100644 lib/galaxy/webapps/galaxy/api/tool_request_form.py create mode 100644 lib/galaxy/webapps/galaxy/services/tool_request_form.py create mode 100644 test/integration/test_tool_request_form.py diff --git a/lib/galaxy/config/schemas/config_schema.yml b/lib/galaxy/config/schemas/config_schema.yml index fe8addfbae6f..2a4957f5d9a0 100644 --- a/lib/galaxy/config/schemas/config_schema.yml +++ b/lib/galaxy/config/schemas/config_schema.yml @@ -4649,6 +4649,19 @@ mapping: desc: | Enable the integration of the Galaxy Help Forum in the tool panel. This requires the help_forum_api_url to be set. + enable_tool_request_form: + type: bool + required: false + default: false + per_host: true + desc: | + Enable the Tool Request Form in the toolbox, allowing users to request new tools to be installed on this Galaxy instance. + + When enabled, a "Request a Tool" button will appear in the tool panel. Submitted requests are sent as + notifications to all admin users via Galaxy's notification system, which must also be enabled. + + This requires ``enable_notification_system`` to be set to ``true``. + file_source_temp_dir: type: str required: false diff --git a/lib/galaxy/managers/configuration.py b/lib/galaxy/managers/configuration.py index ad085d3668e0..349c9026797e 100644 --- a/lib/galaxy/managers/configuration.py +++ b/lib/galaxy/managers/configuration.py @@ -7,9 +7,7 @@ import logging import sys -from typing import ( - Any, -) +from typing import Any from galaxy.managers import base from galaxy.managers.context import ProvidesUserContext @@ -245,6 +243,7 @@ def _config_is_truthy(item, key, **context): "fixed_delegated_auth": _defaults_to(False), "help_forum_api_url": _use_config, "enable_help_forum_tool_panel_integration": _use_config, + "enable_tool_request_form": _use_config, "llm_api_configured": lambda item, key, **context: bool( item.ai_api_key or item.ai_api_base_url or getattr(item, "inference_services", None) ), diff --git a/lib/galaxy/schema/notifications.py b/lib/galaxy/schema/notifications.py index f8e1b0a558ea..f497fa6217f5 100644 --- a/lib/galaxy/schema/notifications.py +++ b/lib/galaxy/schema/notifications.py @@ -62,6 +62,7 @@ class PersonalNotificationCategory(str, Enum): message = "message" new_shared_item = "new_shared_item" storage_operation = "storage_operation" + tool_request = "tool_request" # TODO: enable this and create content model when we have a hook for completed workflows # workflow_execution_completed = "workflow_execution_completed" @@ -137,6 +138,36 @@ class StorageOperationNotificationContent(MessageNotificationContentBase): skipped_count: int = Field(default=0, title="Skipped Count", description="Skipped datasets count.") +class ToolRequestNotificationContent(Model): + category: Literal[PersonalNotificationCategory.tool_request] = PersonalNotificationCategory.tool_request + tool_name: str = Field(..., title="Tool name", description="The name of the requested tool.") + tool_url: Optional[str] = Field( + None, title="Tool URL", description="Homepage or repository URL for the requested tool." + ) + description: str = Field( + ..., title="Description", description="Short description of the tool and its scientific use case." + ) + scientific_domain: Optional[str] = Field( + None, title="Scientific domain", description="The scientific domain for the requested tool." + ) + requested_version: Optional[str] = Field( + None, title="Requested version", description="The version of the tool being requested." + ) + conda_available: Optional[bool] = Field( + None, title="Conda available", description="Whether a Conda package for this tool is available." + ) + test_data_available: Optional[bool] = Field( + None, title="Test data available", description="Whether test data for this tool is available." + ) + requester_name: str = Field(..., title="Requester name", description="The name of the person requesting the tool.") + requester_email: Optional[str] = Field( + None, title="Requester email", description="The email address of the requester for follow-up." + ) + requester_affiliation: Optional[str] = Field( + None, title="Requester affiliation", description="The affiliation/lab of the requester." + ) + + NotificationContentField = Field( default=..., discriminator="category", @@ -145,7 +176,7 @@ class StorageOperationNotificationContent(MessageNotificationContentBase): ) AnyUserNotificationContent = Annotated[ - MessageNotificationContent | NewSharedItemNotificationContent | StorageOperationNotificationContent, + MessageNotificationContent | NewSharedItemNotificationContent | StorageOperationNotificationContent | ToolRequestNotificationContent, NotificationContentField, ] diff --git a/lib/galaxy/webapps/galaxy/api/tool_request_form.py b/lib/galaxy/webapps/galaxy/api/tool_request_form.py new file mode 100644 index 000000000000..376b8b810bbb --- /dev/null +++ b/lib/galaxy/webapps/galaxy/api/tool_request_form.py @@ -0,0 +1,40 @@ +import logging + +from fastapi import Body + +from galaxy.managers.context import ProvidesUserContext +from galaxy.webapps.galaxy.services.tool_request_form import ( + ToolRequestFormData, + ToolRequestFormService, +) +from . import ( + depends, + DependsOnTrans, + Router, +) + +log = logging.getLogger(__name__) + +router = Router(tags=["tool request form"]) + + +@router.cbv +class ToolRequestFormAPI: + service: ToolRequestFormService = depends(ToolRequestFormService) + + @router.post( + "/api/tool_request_form", + summary="Submit a tool installation request to the instance admins.", + status_code=204, + ) + def submit_tool_request( + self, + trans: ProvidesUserContext = DependsOnTrans, + payload: ToolRequestFormData = Body(), + ) -> None: + """Submit a request for a new tool to be installed on this Galaxy instance. + + Sends a notification to all admin users with the submitted request details. + Requires the notification system and tool request form to be enabled in the configuration. + """ + self.service.submit_tool_request(trans, payload) diff --git a/lib/galaxy/webapps/galaxy/services/tool_request_form.py b/lib/galaxy/webapps/galaxy/services/tool_request_form.py new file mode 100644 index 000000000000..c8edfb548fc5 --- /dev/null +++ b/lib/galaxy/webapps/galaxy/services/tool_request_form.py @@ -0,0 +1,148 @@ +import logging +from datetime import ( + timedelta, + timezone, +) +from typing import Optional + +from pydantic import Field + +from galaxy.config import GalaxyAppConfiguration +from galaxy.exceptions import ( + AuthenticationRequired, + ServerNotConfiguredForRequest, +) +from galaxy.managers.context import ProvidesUserContext +from galaxy.managers.notification import NotificationManager +from galaxy.managers.users import UserManager +from galaxy.schema.notifications import ( + NotificationCreateData, + NotificationCreateRequest, + NotificationRecipients, + NotificationVariant, + PersonalNotificationCategory, + ToolRequestNotificationContent, +) +from galaxy.schema.schema import Model +from galaxy.security.idencoding import IdEncodingHelper +from galaxy.webapps.galaxy.services.base import ServiceBase + +log = logging.getLogger(__name__) + + +class ToolRequestFormData(Model): + """The data submitted with the Tool Request Form.""" + + tool_name: str = Field(..., title="Tool name", description="The name of the requested tool.") + tool_url: Optional[str] = Field( + None, title="Tool URL", description="Homepage or repository URL for the requested tool." + ) + description: str = Field( + ..., title="Description", description="Short description of the tool and its scientific use case." + ) + scientific_domain: Optional[str] = Field( + None, title="Scientific domain", description="The scientific domain for the requested tool." + ) + requested_version: Optional[str] = Field( + None, title="Requested version", description="The version of the tool being requested." + ) + conda_available: Optional[bool] = Field( + None, title="Conda available", description="Whether a Conda package for this tool is available." + ) + test_data_available: Optional[bool] = Field( + None, title="Test data available", description="Whether test data for this tool is available." + ) + requester_name: str = Field(..., title="Requester name", description="The name of the person requesting the tool.") + requester_email: Optional[str] = Field( + None, title="Requester email", description="The email address of the requester for follow-up." + ) + requester_affiliation: Optional[str] = Field( + None, title="Requester affiliation", description="The affiliation/lab of the requester." + ) + + +class ToolRequestFormService(ServiceBase): + """Service for handling Tool Request Form submissions. + + When a user submits a tool request, a notification is sent to all admin users + via Galaxy's notification system so that admins can review and act on the request. + """ + + def __init__( + self, + security: IdEncodingHelper, + config: GalaxyAppConfiguration, + notification_manager: NotificationManager, + user_manager: UserManager, + ): + super().__init__(security) + self.config = config + self.notification_manager = notification_manager + self.user_manager = user_manager + + def submit_tool_request(self, trans: ProvidesUserContext, payload: ToolRequestFormData) -> None: + """Submit a tool installation request to the instance admins. + + Sends a notification to all admin users with the submitted tool request details. + + :raises ServerNotConfiguredForRequest: if the tool request form is not enabled. + :raises AuthenticationRequired: if the user is not authenticated. + """ + if not self.config.enable_tool_request_form: + raise ServerNotConfiguredForRequest("The tool request form is not enabled in the configuration.") + + if trans.anonymous: + raise AuthenticationRequired("You must be logged in to submit a tool request.") + + if not self.config.enable_notification_system: + raise ServerNotConfiguredForRequest("The notification system must be enabled to use the tool request form.") + + admin_users = self.user_manager.admins() + if not admin_users: + raise ServerNotConfiguredForRequest("No admin users are configured on this Galaxy instance.") + + content = ToolRequestNotificationContent( + tool_name=payload.tool_name, + tool_url=payload.tool_url, + description=payload.description, + scientific_domain=payload.scientific_domain, + requested_version=payload.requested_version, + conda_available=payload.conda_available, + test_data_available=payload.test_data_available, + requester_name=payload.requester_name, + requester_email=payload.requester_email, + requester_affiliation=payload.requester_affiliation, + ) + + import datetime + + now = datetime.datetime.now(tz=timezone.utc).replace(tzinfo=None) + expiration_time = now + timedelta(days=180) + + notification_data = NotificationCreateData( + source="tool_request_form", + category=PersonalNotificationCategory.tool_request, + variant=NotificationVariant.info, + content=content, + expiration_time=expiration_time, + ) + + recipients = NotificationRecipients( + user_ids=[user.id for user in admin_users], + ) + + galaxy_url = str(trans.url_builder("/", qualified=True)).rstrip("/") if trans.url_builder else None + + request = NotificationCreateRequest( + notification=notification_data, + recipients=recipients, + galaxy_url=galaxy_url, + ) + + self.notification_manager.send_notification_to_recipients(request) + log.info( + "Tool request '%s' submitted by user %s, notified %d admin(s).", + payload.tool_name, + trans.user.username if trans.user else "unknown", + len(admin_users), + ) diff --git a/test/integration/test_tool_request_form.py b/test/integration/test_tool_request_form.py new file mode 100644 index 000000000000..06ba941684b4 --- /dev/null +++ b/test/integration/test_tool_request_form.py @@ -0,0 +1,111 @@ +"""Integration tests for the Tool Request Form API endpoint.""" + +from galaxy_test.base.api_util import ADMIN_TEST_USER +from galaxy_test.driver.integration_util import IntegrationTestCase + +TOOL_REQUEST_PAYLOAD = { + "tool_name": "FastQC", + "tool_url": "https://github.com/s-andrews/FastQC", + "description": "Quality control tool for high-throughput sequencing data.", + "scientific_domain": "Genomics", + "requested_version": "0.12.1", + "conda_available": True, + "test_data_available": True, + "requester_name": "Dr. Smith", + "requester_email": "smith@example.com", + "requester_affiliation": "Example University", +} + + +class ToolRequestFormIntegrationBase(IntegrationTestCase): + """Base class with configuration for tool request form tests.""" + + @classmethod + def handle_galaxy_config_kwds(cls, config): + super().handle_galaxy_config_kwds(config) + config["enable_notification_system"] = True + config["enable_tool_request_form"] = True + config["enable_celery_tasks"] = False + + def setUp(self): + super().setUp() + # Ensure the admin user exists in the database so notifications can be sent to them. + self._setup_user(ADMIN_TEST_USER) + + +class TestToolRequestFormIntegration(ToolRequestFormIntegrationBase): + def test_anonymous_user_cannot_submit(self): + """Anonymous users should receive 403 (AuthenticationRequired).""" + with self._different_user(anon=True): + response = self._post("tool_request_form", data=TOOL_REQUEST_PAYLOAD, json=True) + self._assert_status_code_is(response, 403) + + def test_registered_user_can_submit(self): + """A registered user can successfully submit a tool request.""" + user = self._setup_user("tool_request_submitter@galaxy.test") + with self._different_user(user["email"]): + response = self._post("tool_request_form", data=TOOL_REQUEST_PAYLOAD, json=True) + # 204 No Content on success + self._assert_status_code_is(response, 204) + + def test_admin_receives_notification_after_submission(self): + """After a user submits a tool request the admin should have a new notification.""" + user = self._setup_user("tool_request_sender@galaxy.test") + with self._different_user(user["email"]): + response = self._post("tool_request_form", data=TOOL_REQUEST_PAYLOAD, json=True) + self._assert_status_code_is(response, 204) + + # Admin should now have a tool_request notification + with self._different_user(ADMIN_TEST_USER): + notifications = self._get("notifications").json() + tool_request_notifications = [n for n in notifications if n.get("category") == "tool_request"] + assert ( + len(tool_request_notifications) >= 1 + ), f"Expected at least one tool_request notification for admin, got: {notifications}" + + notification = tool_request_notifications[0] + assert notification["content"]["tool_name"] == TOOL_REQUEST_PAYLOAD["tool_name"] + assert notification["content"]["requester_name"] == TOOL_REQUEST_PAYLOAD["requester_name"] + assert notification["content"]["description"] == TOOL_REQUEST_PAYLOAD["description"] + + def test_missing_required_fields_returns_422(self): + """Missing required fields should return 422 Unprocessable Entity.""" + user = self._setup_user("tool_request_invalid@galaxy.test") + with self._different_user(user["email"]): + # Missing tool_name and description (both required) + incomplete_payload = { + "requester_name": "Dr. Smith", + } + response = self._post("tool_request_form", data=incomplete_payload, json=True) + self._assert_status_code_is(response, 400) + + def test_minimal_payload_succeeds(self): + """Only required fields should be enough to submit.""" + user = self._setup_user("tool_request_minimal@galaxy.test") + with self._different_user(user["email"]): + minimal_payload = { + "tool_name": "Samtools", + "description": "Tools for manipulating alignments in SAM format.", + "requester_name": "Dr. Jones", + } + response = self._post("tool_request_form", data=minimal_payload, json=True) + self._assert_status_code_is(response, 204) + + +class TestToolRequestFormDisabledIntegration(IntegrationTestCase): + """Tests for when the tool request form feature is disabled.""" + + @classmethod + def handle_galaxy_config_kwds(cls, config): + super().handle_galaxy_config_kwds(config) + config["enable_notification_system"] = True + config["enable_tool_request_form"] = False + config["enable_celery_tasks"] = False + + def test_disabled_config_returns_error(self): + """When tool_request_form is disabled, requests should return 501.""" + user = self._setup_user("tool_request_disabled@galaxy.test") + with self._different_user(user["email"]): + response = self._post("tool_request_form", data=TOOL_REQUEST_PAYLOAD, json=True) + # ServerNotConfiguredForRequest → 501 Not Implemented + self._assert_status_code_is(response, 501) From 30131e6c7fd7eb3449a751bc9f0b2d69b668fbce Mon Sep 17 00:00:00 2001 From: Arash Date: Wed, 25 Mar 2026 16:07:13 +0100 Subject: [PATCH 02/70] Update frontend API and types for tool requests --- .../packages/api-client/src/schema/schema.ts | 201 +++++++++++++++++- client/src/api/notifications.ts | 25 ++- client/src/api/toolRequestForm.ts | 12 ++ 3 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 client/src/api/toolRequestForm.ts diff --git a/client/packages/api-client/src/schema/schema.ts b/client/packages/api-client/src/schema/schema.ts index c3867494199d..e98f436980e8 100644 --- a/client/packages/api-client/src/schema/schema.ts +++ b/client/packages/api-client/src/schema/schema.ts @@ -5468,6 +5468,29 @@ export interface paths { patch?: never; trace?: never; }; + "/api/tool_request_form": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Submit a tool installation request to the instance admins. + * @description Submit a request for a new tool to be installed on this Galaxy instance. + * + * Sends a notification to all admin users with the submitted request details. + * Requires the notification system and tool request form to be enabled in the configuration. + */ + post: operations["submit_tool_request_api_tool_request_form_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/tool_requests/{id}": { parameters: { query?: never; @@ -19777,6 +19800,7 @@ export interface components { | components["schemas"]["MessageNotificationContent"] | components["schemas"]["NewSharedItemNotificationContent"] | components["schemas"]["StorageOperationNotificationContent"] + | components["schemas"]["ToolRequestNotificationContent"] | components["schemas"]["BroadcastNotificationContent"]; /** * Expiration time @@ -19866,6 +19890,7 @@ export interface components { | components["schemas"]["MessageNotificationContent"] | components["schemas"]["NewSharedItemNotificationContent"] | components["schemas"]["StorageOperationNotificationContent"] + | components["schemas"]["ToolRequestNotificationContent"] | components["schemas"]["BroadcastNotificationContent"]; /** * Create time @@ -21065,7 +21090,7 @@ export interface components { * displayed in the notification preferences. * @enum {string} */ - PersonalNotificationCategory: "message" | "new_shared_item" | "storage_operation"; + PersonalNotificationCategory: "message" | "new_shared_item" | "storage_operation" | "tool_request"; /** PluginAspectStatus */ PluginAspectStatus: { /** Message */ @@ -24966,6 +24991,62 @@ export interface components { state?: components["schemas"]["ToolRequestState"] | null; state_message?: components["schemas"]["ToolRequestStateMessage"] | null; }; + /** + * ToolRequestFormData + * @description The data submitted with the Tool Request Form. + */ + ToolRequestFormData: { + /** + * Conda available + * @description Whether a Conda package for this tool is available. + */ + conda_available?: boolean | null; + /** + * Description + * @description Short description of the tool and its scientific use case. + */ + description: string; + /** + * Requested version + * @description The version of the tool being requested. + */ + requested_version?: string | null; + /** + * Requester affiliation + * @description The affiliation/lab of the requester. + */ + requester_affiliation?: string | null; + /** + * Requester email + * @description The email address of the requester for follow-up. + */ + requester_email?: string | null; + /** + * Requester name + * @description The name of the person requesting the tool. + */ + requester_name: string; + /** + * Scientific domain + * @description The scientific domain for the requested tool. + */ + scientific_domain?: string | null; + /** + * Test data available + * @description Whether test data for this tool is available. + */ + test_data_available?: boolean | null; + /** + * Tool name + * @description The name of the requested tool. + */ + tool_name: string; + /** + * Tool URL + * @description Homepage or repository URL for the requested tool. + */ + tool_url?: string | null; + }; /** ToolRequestImplicitCollectionReference */ ToolRequestImplicitCollectionReference: { /** @@ -25009,6 +25090,64 @@ export interface components { state?: components["schemas"]["ToolRequestState"] | null; state_message?: components["schemas"]["ToolRequestStateMessage"] | null; }; + /** ToolRequestNotificationContent */ + ToolRequestNotificationContent: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + category: "tool_request"; + /** + * Conda available + * @description Whether a Conda package for this tool is available. + */ + conda_available?: boolean | null; + /** + * Description + * @description Short description of the tool and its scientific use case. + */ + description: string; + /** + * Requested version + * @description The version of the tool being requested. + */ + requested_version?: string | null; + /** + * Requester affiliation + * @description The affiliation/lab of the requester. + */ + requester_affiliation?: string | null; + /** + * Requester email + * @description The email address of the requester for follow-up. + */ + requester_email?: string | null; + /** + * Requester name + * @description The name of the person requesting the tool. + */ + requester_name: string; + /** + * Scientific domain + * @description The scientific domain for the requested tool. + */ + scientific_domain?: string | null; + /** + * Test data available + * @description Whether test data for this tool is available. + */ + test_data_available?: boolean | null; + /** + * Tool name + * @description The name of the requested tool. + */ + tool_name: string; + /** + * Tool URL + * @description Homepage or repository URL for the requested tool. + */ + tool_url?: string | null; + }; /** * ToolRequestState * @enum {string} @@ -25665,6 +25804,13 @@ export interface components { * "push": true * }, * "enabled": true + * }, + * "tool_request": { + * "channels": { + * "email": true, + * "push": true + * }, + * "enabled": true * } * } * } @@ -26081,6 +26227,13 @@ export interface components { * "push": true * }, * "enabled": true + * }, + * "tool_request": { + * "channels": { + * "email": true, + * "push": true + * }, + * "enabled": true * } * } * } @@ -26111,7 +26264,8 @@ export interface components { content: | components["schemas"]["MessageNotificationContent"] | components["schemas"]["NewSharedItemNotificationContent"] - | components["schemas"]["StorageOperationNotificationContent"]; + | components["schemas"]["StorageOperationNotificationContent"] + | components["schemas"]["ToolRequestNotificationContent"]; /** * Create time * Format: date-time @@ -48848,6 +49002,49 @@ export interface operations { }; }; }; + submit_tool_request_api_tool_request_form_post: { + parameters: { + query?: never; + header?: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + "run-as"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ToolRequestFormData"]; + }; + }; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Request Error */ + "4XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + /** @description Server Error */ + "5XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + }; + }; get_tool_request_api_tool_requests__id__get: { parameters: { query?: never; diff --git a/client/src/api/notifications.ts b/client/src/api/notifications.ts index 243e6a6841d1..b15f3c29f91d 100644 --- a/client/src/api/notifications.ts +++ b/client/src/api/notifications.ts @@ -33,7 +33,30 @@ export interface MessageNotificationCreateRequest extends NotificationCreateRequ notification: MessageNotificationCreateData; } -export type UserNotification = MessageNotification | SharedItemNotification | StorageOperationNotification; +export interface ToolRequestNotificationContent { + category: "tool_request"; + tool_name: string; + tool_url?: string; + description: string; + scientific_domain?: string; + requested_version?: string; + conda_available?: boolean; + test_data_available?: boolean; + requester_name: string; + requester_email?: string; + requester_affiliation?: string; +} + +export interface ToolRequestNotification extends BaseUserNotification { + category: "tool_request"; + content: ToolRequestNotificationContent; +} + +export type UserNotification = + | MessageNotification + | SharedItemNotification + | StorageOperationNotification + | ToolRequestNotification; export type NotificationChanges = components["schemas"]["UserNotificationUpdateRequest"]; diff --git a/client/src/api/toolRequestForm.ts b/client/src/api/toolRequestForm.ts new file mode 100644 index 000000000000..59f559945327 --- /dev/null +++ b/client/src/api/toolRequestForm.ts @@ -0,0 +1,12 @@ +import { GalaxyApi } from "@/api"; +import type { components } from "@/api/schema"; +import { rethrowSimple } from "@/utils/simple-error"; + +export type ToolRequestFormData = components["schemas"]["ToolRequestFormData"]; + +export async function submitToolRequest(payload: ToolRequestFormData): Promise { + const { error } = await GalaxyApi().POST("/api/tool_request_form", { body: payload }); + if (error) { + rethrowSimple(error); + } +} From 352197d8f4196047925174cb51a7337efb87a43c Mon Sep 17 00:00:00 2001 From: Arash Date: Wed, 25 Mar 2026 16:07:14 +0100 Subject: [PATCH 03/70] Add tool request form and integrate into toolbox --- client/src/components/Panels/ToolBox.vue | 22 ++ .../components/Tool/ToolRequestForm.test.ts | 137 +++++++++++ .../src/components/Tool/ToolRequestForm.vue | 217 ++++++++++++++++++ 3 files changed, 376 insertions(+) create mode 100644 client/src/components/Tool/ToolRequestForm.test.ts create mode 100644 client/src/components/Tool/ToolRequestForm.vue diff --git a/client/src/components/Panels/ToolBox.vue b/client/src/components/Panels/ToolBox.vue index 58037b1ac58a..0b7c52170e35 100644 --- a/client/src/components/Panels/ToolBox.vue +++ b/client/src/components/Panels/ToolBox.vue @@ -1,10 +1,12 @@ + + From dacb75d4a3c719b260df7f17d8211ab91dd38569 Mon Sep 17 00:00:00 2001 From: Arash Date: Wed, 25 Mar 2026 16:07:15 +0100 Subject: [PATCH 04/70] Enhance notification system to display tool requests --- .../Notifications/NotificationCard.test.ts | 25 ++++++++- .../Notifications/NotificationCard.vue | 55 +++++++++++++++++-- .../components/Notifications/test-utils.ts | 29 ++++++++++ 3 files changed, 102 insertions(+), 7 deletions(-) diff --git a/client/src/components/Notifications/NotificationCard.test.ts b/client/src/components/Notifications/NotificationCard.test.ts index ee1c850df7a4..5161815eaed8 100644 --- a/client/src/components/Notifications/NotificationCard.test.ts +++ b/client/src/components/Notifications/NotificationCard.test.ts @@ -6,7 +6,11 @@ import { setActivePinia } from "pinia"; import { describe, expect, it, vi } from "vitest"; import { nextTick } from "vue"; -import { generateMessageNotification, generateNewSharedItemNotification } from "@/components/Notifications/test-utils"; +import { + generateMessageNotification, + generateNewSharedItemNotification, + generateToolRequestNotification, +} from "@/components/Notifications/test-utils"; import { useNotificationsStore } from "@/stores/notificationsStore"; import NotificationCard from "@/components/Notifications/NotificationCard.vue"; @@ -137,4 +141,23 @@ describe("Notifications categories", () => { expect(spyOnUpdateNotification).toHaveBeenCalledTimes(1); }); + + it("tool_request notification shows tool name in title and details in description", async () => { + const notification = generateToolRequestNotification(); + + const wrapper = await mountComponent(NotificationCard, { + notification, + }); + + // Title should include the tool name + expect(wrapper.text()).toContain(notification.content.tool_name); + + // Description area should show tool request details + const descriptionArea = wrapper.find(`#g-card-description-${notification.id}`); + expect(descriptionArea.text()).toContain(notification.content.description); + expect(descriptionArea.text()).toContain(notification.content.scientific_domain); + expect(descriptionArea.text()).toContain(notification.content.requested_version); + expect(descriptionArea.text()).toContain(notification.content.requester_name); + expect(descriptionArea.text()).toContain(notification.content.requester_affiliation); + }); }); diff --git a/client/src/components/Notifications/NotificationCard.vue b/client/src/components/Notifications/NotificationCard.vue index 9dc710004923..d4ef2740325e 100644 --- a/client/src/components/Notifications/NotificationCard.vue +++ b/client/src/components/Notifications/NotificationCard.vue @@ -7,6 +7,7 @@ import { faInbox, faRetweet, faTrash, + faWrench, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { BLink } from "bootstrap-vue"; @@ -63,19 +64,21 @@ const title = computed(() => { return `${sharedItemType.value} shared with you by ${props.notification.content.owner_name}`; } else if (props.notification.category === "storage_operation") { return props.notification.content.subject; + } else if (props.notification.category === "tool_request") { + return `Tool Request: ${props.notification.content.tool_name}`; } else { return props.notification.content.subject; } }); const titleIcon = computed(() => { + const iconMap: Record = { + new_shared_item: faRetweet, + storage_operation: faHourglassHalf, + tool_request: faWrench, + }; return { - icon: - props.notification.category === "new_shared_item" - ? faRetweet - : props.notification.category === "storage_operation" - ? faHourglassHalf - : faInbox, + icon: iconMap[props.notification.category] ?? faInbox, class: `text-${notificationVariant.value}`, }; }); @@ -208,6 +211,46 @@ function markNotificationAsSeen() { Open storage operation run status +