diff --git a/client/packages/api-client/src/schema/schema.ts b/client/packages/api-client/src/schema/schema.ts index c3867494199d..8d33de101ccd 100644 --- a/client/packages/api-client/src/schema/schema.ts +++ b/client/packages/api-client/src/schema/schema.ts @@ -19777,6 +19777,7 @@ export interface components { | components["schemas"]["MessageNotificationContent"] | components["schemas"]["NewSharedItemNotificationContent"] | components["schemas"]["StorageOperationNotificationContent"] + | components["schemas"]["ToolInstallationRequestCreateContent"] | components["schemas"]["BroadcastNotificationContent"]; /** * Expiration time @@ -19866,6 +19867,7 @@ export interface components { | components["schemas"]["MessageNotificationContent"] | components["schemas"]["NewSharedItemNotificationContent"] | components["schemas"]["StorageOperationNotificationContent"] + | components["schemas"]["ToolInstallationRequestNotificationContent"] | components["schemas"]["BroadcastNotificationContent"]; /** * Create time @@ -21065,7 +21067,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_installation_request"; /** PluginAspectStatus */ PluginAspectStatus: { /** Message */ @@ -21719,6 +21721,48 @@ export interface components { | "track_config" | "genome_data" | "in_use_state"; + /** + * RequestedTool + * @description A single requested tool in a tool installation request. + * + * This is the per-item model: each entry describes one tool. An installation + * request submits an array of these, wrapped by + * :class:`ToolInstallationRequestNotificationContent` which carries the + * request-level metadata. All fields are sanitized on validation: control + * characters are collapsed and whitespace-only values become ``None``. + */ + RequestedTool: { + /** + * Description + * @description Short description of the tool and its scientific use case. + */ + description?: string | null; + /** + * Tool name + * @description The human-readable name of the tool, if known. + */ + name?: string | null; + /** + * Requested version + * @description The version of the tool being requested, if any. + */ + requested_version?: string | null; + /** + * Scientific domain + * @description The scientific domain for the requested tool. + */ + scientific_domain?: string | null; + /** + * Tool shed ID + * @description The fully qualified tool shed repository ID (e.g. ``toolshed.g2.bx.psu.edu/repos/devteam/bwa``), if known. + */ + tool_shed_id?: string | null; + /** + * Tool URL + * @description Homepage or repository URL for the requested tool. Must be an http(s) URL. + */ + tool_url?: string | null; + }; /** * Requirement * @description Available types of job sources (model classes) that produce dataset collections. @@ -24683,6 +24727,81 @@ export interface components { */ values: string; }; + /** + * ToolInstallationRequestCreateContent + * @description The client-submittable (request) shape of a tool installation request. + * + * Carries only the fields a user supplies: the requested ``tools`` and + * request-level metadata (workflow context, remarks). The two server-stamped + * fields -- ``requester_email`` and ``is_confirmation`` -- are deliberately + * absent so they cannot be set by clients and do not appear in the POST + * request schema. The service stamps them, promoting the content to a + * :class:`ToolInstallationRequestNotificationContent` for persistence. + */ + ToolInstallationRequestCreateContent: { + /** + * Additional remarks + * @description Any additional information or context for the request. + */ + additional_remarks?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + category: "tool_installation_request"; + /** + * Requested tools + * @description The tools being requested. Each entry describes a single tool. + */ + tools: components["schemas"]["RequestedTool"][]; + /** + * Workflow ID + * @description Encoded ID of the workflow requiring these tools, if applicable. + */ + workflow_id?: string | null; + }; + /** + * ToolInstallationRequestNotificationContent + * @description The persisted/response shape of a tool installation request. + * + * Extends the create model with the two server-stamped fields. ``requester_email`` + * is derived from the authenticated submitter; ``is_confirmation`` selects the + * confirmation vs. admin-facing email template. Both are written by the service + * and never trusted from the client. + */ + ToolInstallationRequestNotificationContent: { + /** + * Additional remarks + * @description Any additional information or context for the request. + */ + additional_remarks?: string | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + category: "tool_installation_request"; + /** + * Is confirmation + * @description True on the copy sent to the user who made the request; False on the request sent to admins. + * @default false + */ + is_confirmation: boolean; + /** + * Requester email + * @description Email address of the user who made the request. + */ + requester_email?: string | null; + /** + * Requested tools + * @description The tools being requested. Each entry describes a single tool. + */ + tools: components["schemas"]["RequestedTool"][]; + /** + * Workflow ID + * @description Encoded ID of the workflow requiring these tools, if applicable. + */ + workflow_id?: string | null; + }; /** ToolLandingRequest */ ToolLandingRequest: { /** Origin */ @@ -25665,6 +25784,13 @@ export interface components { * "push": true * }, * "enabled": true + * }, + * "tool_installation_request": { + * "channels": { + * "email": true, + * "push": true + * }, + * "enabled": true * } * } * } @@ -26081,6 +26207,13 @@ export interface components { * "push": true * }, * "enabled": true + * }, + * "tool_installation_request": { + * "channels": { + * "email": true, + * "push": true + * }, + * "enabled": true * } * } * } @@ -26111,7 +26244,8 @@ export interface components { content: | components["schemas"]["MessageNotificationContent"] | components["schemas"]["NewSharedItemNotificationContent"] - | components["schemas"]["StorageOperationNotificationContent"]; + | components["schemas"]["StorageOperationNotificationContent"] + | components["schemas"]["ToolInstallationRequestNotificationContent"]; /** * Create time * Format: date-time diff --git a/client/packages/ui/src/components/GModal.vue b/client/packages/ui/src/components/GModal.vue index 6cb7a96bebca..933f58a5c0de 100644 --- a/client/packages/ui/src/components/GModal.vue +++ b/client/packages/ui/src/components/GModal.vue @@ -6,7 +6,7 @@ import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { watchImmediate } from "@vueuse/core"; import { faXmark } from "font-awesome-6"; -import { computed, onBeforeUnmount, onMounted, ref } from "vue"; +import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue"; import { useUid } from "../composables/uid"; import { type ComponentColor, type ComponentSize, type ComponentSizeClassList, prefix } from "./componentVariants"; @@ -43,6 +43,8 @@ const props = withDefaults( okDisabled?: boolean; /** Title to show when the Ok button is disabled */ okDisabledTitle?: string; + /** Disables all cancel/dismiss actions (Cancel button, backdrop click, Escape) */ + cancelDisabled?: boolean; /** When false, keeps the modal open on "ok" */ closeOnOk?: boolean; /** Allows content to overflow the modal body (e.g. for dropdowns/selectors inside the modal) */ @@ -62,6 +64,7 @@ const props = withDefaults( okColor: "blue", okDisabled: false, okDisabledTitle: undefined, + cancelDisabled: false, closeOnOk: true, overflowVisible: false, }, @@ -90,6 +93,7 @@ const dialog = ref(null); onMounted(() => { if (dialog.value) { dialog.value.addEventListener("close", onClose); + dialog.value.addEventListener("cancel", onCancel); } if (props.show) { showModal(); @@ -99,11 +103,16 @@ onMounted(() => { onBeforeUnmount(() => { if (dialog.value) { dialog.value.removeEventListener("close", onClose); + dialog.value.removeEventListener("cancel", onCancel); } }); function showModal() { - dialog.value?.showModal(); + if (!dialog.value || dialog.value.open) { + return; + } + + dialog.value.showModal(); onOpen(); } @@ -114,22 +123,30 @@ function hideModal(ok = false) { emit("ok"); } else { isOk = ok; - dialog.value?.close(); + if (dialog.value?.open) { + dialog.value.close(); + } } } watchImmediate( () => props.show, - () => { - if (props.show) { + async (show) => { + await nextTick(); + + if (show) { showModal(); } else { hideModal(); } }, + { flush: "post" }, ); function onClickDialog(event: MouseEvent) { + if (props.cancelDisabled) { + return; + } if ((event.target as HTMLElement | null)?.tagName === "DIALOG") { const rect = dialog.value?.getBoundingClientRect(); const insideDialogX = rect && event.clientX >= rect.left && event.clientX <= rect.right; @@ -142,6 +159,14 @@ function onClickDialog(event: MouseEvent) { } } +function onCancel(event: Event) { + // The native fires "cancel" on Escape; prevent dismissal while + // cancel actions are disabled (e.g. during an in-flight submit). + if (props.cancelDisabled) { + event.preventDefault(); + } +} + function onOpen() { emit("update:show", true); emit("open"); @@ -191,7 +216,13 @@ defineExpose({ showModal, hideModal }); - + @@ -206,7 +237,9 @@ defineExpose({ showModal, hideModal });
- {{ props.cancelText ?? "Cancel" }} + + {{ props.cancelText ?? "Cancel" }} + ; + +export type UserNotification = + | MessageNotification + | SharedItemNotification + | StorageOperationNotification + | ToolInstallationRequestNotification; export type NotificationChanges = components["schemas"]["UserNotificationUpdateRequest"]; @@ -43,3 +71,24 @@ export type NotificationVariants = components["schemas"]["NotificationVariant"]; export type NewSharedItemNotificationContentItemType = components["schemas"]["NewSharedItemNotificationContent"]["item_type"]; + +/** Submit a tool installation request as the authenticated user. */ +export async function submitToolInstallationRequest(content: ToolInstallationRequestInput) { + const { error } = await GalaxyApi().POST("/api/notifications", { + body: { + recipients: { user_ids: [], group_ids: [], role_ids: [] }, + notification: { + source: "tool_installation_request_form", + category: "tool_installation_request", + variant: "info", + content: { + ...content, + category: "tool_installation_request", + }, + }, + }, + }); + if (error) { + rethrowSimple(error); + } +} diff --git a/client/src/components/Notifications/NotificationCard.test.ts b/client/src/components/Notifications/NotificationCard.test.ts index ee1c850df7a4..bbcaf645035e 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, + generateToolInstallationRequestNotification, +} from "@/components/Notifications/test-utils"; import { useNotificationsStore } from "@/stores/notificationsStore"; import NotificationCard from "@/components/Notifications/NotificationCard.vue"; @@ -137,4 +141,96 @@ describe("Notifications categories", () => { expect(spyOnUpdateNotification).toHaveBeenCalledTimes(1); }); + + it("tool_installation_request notification shows tool name in title and details in description", async () => { + const notification = generateToolInstallationRequestNotification(); + + const wrapper = await mountComponent(NotificationCard, { + notification, + }); + + // Title should include the first tool's label + const firstTool = notification.content.tools[0]!; + expect(wrapper.text()).toContain(firstTool.name); + + // Description area should show tool installation request details + const descriptionArea = wrapper.find(`#g-card-description-${notification.id}`); + expect(descriptionArea.text()).toContain(firstTool.description); + expect(descriptionArea.text()).toContain(firstTool.scientific_domain); + expect(descriptionArea.text()).toContain(firstTool.requested_version); + expect(descriptionArea.text()).toContain(notification.content.requester_email); + }); + + it("tool_installation_request notification shows the tool shed id alongside the tool name", async () => { + const notification = generateToolInstallationRequestNotification(); + notification.content.tools = [ + { + name: "bwa", + tool_shed_id: "toolshed.g2.bx.psu.edu/repos/devteam/bwa", + tool_url: null, + description: null, + scientific_domain: null, + requested_version: "0.7.17", + }, + ]; + + const wrapper = await mountComponent(NotificationCard, { + notification, + }); + + const descriptionArea = wrapper.find(`#g-card-description-${notification.id}`); + expect(descriptionArea.text()).toContain("Tool shed ID"); + expect(descriptionArea.text()).toContain("toolshed.g2.bx.psu.edu/repos/devteam/bwa"); + }); + + it("tool_installation_request notification associates details with each tool in multi-tool requests", async () => { + const notification = generateToolInstallationRequestNotification(); + notification.content.tools = [ + { + name: "bwa", + tool_shed_id: null, + tool_url: null, + description: "Aligner for short reads", + scientific_domain: null, + requested_version: null, + }, + { + name: "samtools", + tool_shed_id: null, + tool_url: null, + description: "SAM/BAM utilities", + scientific_domain: null, + requested_version: "1.13", + }, + ]; + + const wrapper = await mountComponent(NotificationCard, { + notification, + }); + + expect(wrapper.text()).toContain("Tool Installation Request: 2 tools"); + + // Each tool's list item must contain its own details and not the other tool's. + const toolItems = wrapper.findAll("ul:not(.list-unstyled) > li"); + expect(toolItems).toHaveLength(2); + expect(toolItems.at(0).text()).toContain("bwa"); + expect(toolItems.at(0).text()).toContain("Aligner for short reads"); + expect(toolItems.at(0).text()).not.toContain("SAM/BAM utilities"); + expect(toolItems.at(1).text()).toContain("samtools"); + expect(toolItems.at(1).text()).toContain("SAM/BAM utilities"); + expect(toolItems.at(1).text()).toContain("1.13"); + expect(toolItems.at(1).text()).not.toContain("Aligner for short reads"); + }); + + it("tool_installation_request notification links workflow id and exposes anchor for deep-linking", async () => { + const notification = generateToolInstallationRequestNotification(); + notification.content.workflow_id = "encoded-workflow-id-abc"; + + const wrapper = await mountComponent(NotificationCard, { + notification, + }); + + expect(wrapper.html()).toContain(`/workflows/run?id=${notification.content.workflow_id}`); + expect(wrapper.find(`#notification-card-${notification.id}`).exists()).toBe(true); + }); }); diff --git a/client/src/components/Notifications/NotificationCard.vue b/client/src/components/Notifications/NotificationCard.vue index 9dc710004923..b4ed5105771a 100644 --- a/client/src/components/Notifications/NotificationCard.vue +++ b/client/src/components/Notifications/NotificationCard.vue @@ -1,4 +1,5 @@