diff --git a/src/libs/ajax/teaspoons/Teaspoons.ts b/src/libs/ajax/teaspoons/Teaspoons.ts index 2f8f20b8e7..77a3920a18 100644 --- a/src/libs/ajax/teaspoons/Teaspoons.ts +++ b/src/libs/ajax/teaspoons/Teaspoons.ts @@ -5,6 +5,7 @@ import { fetchTeaspoons } from 'src/libs/ajax/ajax-common'; import { GetPipelineRunsResponse, PipelineList, + PipelineRunResponse, PipelineWithDetails, PreparePipelineRunResponse, StartPipelineResponse, @@ -77,6 +78,11 @@ export const Teaspoons = (signal?: AbortSignal) => ({ ); return res.json(); }, + + getPipelineRunResult: async (jobId: string): Promise => { + const res = await fetchTeaspoons(`pipelineruns/v1/result/${jobId}`, _.merge(authOpts(), { signal })); + return res.json(); + }, }); export type TeaspoonsContract = ReturnType; diff --git a/src/libs/ajax/teaspoons/teaspoons-models.ts b/src/libs/ajax/teaspoons/teaspoons-models.ts index 94fadaade3..02bb4789dd 100644 --- a/src/libs/ajax/teaspoons/teaspoons-models.ts +++ b/src/libs/ajax/teaspoons/teaspoons-models.ts @@ -81,4 +81,34 @@ export interface StartPipelineResponse { }; } +export interface PipelineRunResponse { + jobReport: PipelineJobReport; + errorReport?: PipelineRunErrorReport; + pipelineRunReport: PipelineRunReport; +} + +export interface PipelineJobReport { + id: string; + description?: string; + status: PipelineRunStatus; + statusCode?: number; + submitted: string; + completed?: string; + resultURL?: string; +} + +export interface PipelineRunErrorReport { + message: string; + errorCode: number; + causes: string[]; +} + +export interface PipelineRunReport { + pipelineName: string; + pipelineVersion: number; + toolVersion: string; + outputs?: Record; + outputExpirationDate?: string; +} + export type PipelineRunStatus = 'PREPARING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED'; diff --git a/src/pages/scientificServices/landingPage/ScientificServicesDescription.tsx b/src/pages/scientificServices/landingPage/ScientificServicesDescription.tsx index 20536ed36d..e5849403cf 100644 --- a/src/pages/scientificServices/landingPage/ScientificServicesDescription.tsx +++ b/src/pages/scientificServices/landingPage/ScientificServicesDescription.tsx @@ -15,7 +15,14 @@ export const ScientificServicesDescription = () => {
Our current offerings:
- All of Us + AnVIL Imputation Service + + All of Us + AnVIL Imputation Service +
First time using this service?
{buttonVisible && ( diff --git a/src/pages/scientificServices/pipelines/utils/mock-utils.ts b/src/pages/scientificServices/pipelines/utils/mock-utils.ts index c7c38f639a..3decd96491 100644 --- a/src/pages/scientificServices/pipelines/utils/mock-utils.ts +++ b/src/pages/scientificServices/pipelines/utils/mock-utils.ts @@ -1,6 +1,8 @@ import { Pipeline, PipelineInput, + PipelineRun, + PipelineRunStatus, PipelineWithDetails, UserPipelineQuotaDetails, } from 'src/libs/ajax/teaspoons/teaspoons-models'; @@ -43,3 +45,15 @@ export function mockUserPipelineQuotaDetails(name: string): UserPipelineQuotaDet quotaUnits: 'things', }; } + +export function mockPipelineRun(status: PipelineRunStatus): PipelineRun { + return { + jobId: 'run-id-123', + pipelineName: 'array_imputation', + status, + description: 'Test pipeline run', + timeSubmitted: '2023-10-01T00:00:00Z', + timeCompleted: status === 'SUCCEEDED' || status === 'FAILED' ? '2023-10-01T01:00:00Z' : undefined, + quotaConsumed: status === 'SUCCEEDED' ? 500 : undefined, + }; +} diff --git a/src/pages/scientificServices/pipelines/views/JobHistory.test.tsx b/src/pages/scientificServices/pipelines/views/JobHistory.test.tsx index e1219ed5c9..2c534b3cf0 100644 --- a/src/pages/scientificServices/pipelines/views/JobHistory.test.tsx +++ b/src/pages/scientificServices/pipelines/views/JobHistory.test.tsx @@ -1,6 +1,8 @@ -import { screen } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React from 'react'; import { Teaspoons, TeaspoonsContract } from 'src/libs/ajax/teaspoons/Teaspoons'; +import { mockPipelineRun } from 'src/pages/scientificServices/pipelines/utils/mock-utils'; import { asMockedFn, partial, renderWithAppContexts as render } from 'src/testing/test-utils'; import { JobHistory } from './JobHistory'; @@ -35,17 +37,8 @@ jest.mock('src/libs/nav', () => ({ describe('job history table', () => { it('renders the job history table', async () => { - const pipelineRuns = [ - { - id: '123', - description: 'Test Job', - status: 'RUNNING', - createdAt: '2023-10-01T00:00:00Z', - updatedAt: '2023-10-01T00:00:00Z', - pipelineVersion: 'v1.0.0', - pipelineName: 'array_imputation', - }, - ]; + const pipelineRun = mockPipelineRun('RUNNING'); + const pipelineRuns = [pipelineRun]; const mockPipelineRunResponse = { pageToken: 'nextPageToken', @@ -62,7 +55,163 @@ describe('job history table', () => { render(); expect(await screen.findByText('Job History')).toBeInTheDocument(); - expect(screen.queryAllByText('Test Job')).toHaveLength(2); + expect(screen.queryAllByText(pipelineRun.description!)).toHaveLength(2); expect(screen.getByText('In Progress')).toBeInTheDocument(); }); + + it('shows View Outputs button for SUCCEEDED jobs', async () => { + const pipelineRun = mockPipelineRun('SUCCEEDED'); + const pipelineRuns = [pipelineRun]; + + const mockPipelineRunResponse = { + pageToken: null, + results: pipelineRuns, + totalResults: 1, + }; + + asMockedFn(Teaspoons).mockReturnValue( + partial({ + getAllPipelineRuns: jest.fn().mockReturnValue(mockPipelineRunResponse), + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getAllByText(pipelineRun.jobId)).toHaveLength(2); + }); + + const viewOutputsButton = screen.getByText('View Outputs'); + expect(viewOutputsButton).toBeInTheDocument(); + + expect(screen.queryByText('View Error')).not.toBeInTheDocument(); + }); + + it('shows View Error button for FAILED jobs', async () => { + const pipelineRun = mockPipelineRun('FAILED'); + const pipelineRuns = [pipelineRun]; + + const mockPipelineRunResponse = { + pageToken: null, + results: pipelineRuns, + totalResults: 1, + }; + + asMockedFn(Teaspoons).mockReturnValue( + partial({ + getAllPipelineRuns: jest.fn().mockReturnValue(mockPipelineRunResponse), + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getAllByText(pipelineRun.jobId)).toHaveLength(2); + }); + + const viewErrorButton = screen.getByText('View Error'); + expect(viewErrorButton).toBeInTheDocument(); + + expect(screen.queryByText('View Outputs')).not.toBeInTheDocument(); + }); + + it('shows neither button for RUNNING jobs', async () => { + const pipelineRun = mockPipelineRun('RUNNING'); + const pipelineRuns = [pipelineRun]; + + const mockPipelineRunResponse = { + pageToken: null, + results: pipelineRuns, + totalResults: 1, + }; + + asMockedFn(Teaspoons).mockReturnValue( + partial({ + getAllPipelineRuns: jest.fn().mockReturnValue(mockPipelineRunResponse), + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getAllByText(pipelineRun.jobId)).toHaveLength(2); + }); + + expect(screen.queryByText('View Outputs')).not.toBeInTheDocument(); + expect(screen.queryByText('View Error')).not.toBeInTheDocument(); + expect(screen.getByText('In Progress')).toBeInTheDocument(); + }); + + it('opens the outputs modal when View Outputs button is clicked', async () => { + const pipelineRun = mockPipelineRun('SUCCEEDED'); + const pipelineRuns = [pipelineRun]; + + const mockPipelineRunResponse = { + pageToken: null, + results: pipelineRuns, + totalResults: 1, + }; + + const mockPipelineRunResult = { + pipelineRunReport: { + outputs: { + 'output1.txt': 'https://example.com/output1.txt', + }, + }, + }; + + asMockedFn(Teaspoons).mockReturnValue( + partial({ + getAllPipelineRuns: jest.fn().mockReturnValue(mockPipelineRunResponse), + getPipelineRunResult: jest.fn().mockResolvedValue(mockPipelineRunResult), + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getAllByText(pipelineRun.jobId)).toHaveLength(2); + }); + + const user = userEvent.setup(); + await user.click(screen.getByText('View Outputs')); + + expect(await screen.findByText('Pipeline Outputs', { exact: false })).toBeInTheDocument(); + }); + + it('opens the error modal when View Error button is clicked', async () => { + const pipelineRun = mockPipelineRun('FAILED'); + const pipelineRuns = [pipelineRun]; + + const mockPipelineRunResponse = { + pageToken: null, + results: pipelineRuns, + totalResults: 1, + }; + + const mockPipelineRunResult = { + errorReport: { + message: 'Test error message', + causes: ['Test error cause'], + }, + }; + + asMockedFn(Teaspoons).mockReturnValue( + partial({ + getAllPipelineRuns: jest.fn().mockReturnValue(mockPipelineRunResponse), + getPipelineRunResult: jest.fn().mockResolvedValue(mockPipelineRunResult), + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getAllByText(pipelineRun.jobId)).toHaveLength(2); + }); + + const user = userEvent.setup(); + await user.click(screen.getByText('View Error')); + + expect(await screen.findByText('Pipeline Error', { exact: false })).toBeInTheDocument(); + }); }); diff --git a/src/pages/scientificServices/pipelines/views/JobHistory.tsx b/src/pages/scientificServices/pipelines/views/JobHistory.tsx index a7f00e0086..6ee4626aee 100644 --- a/src/pages/scientificServices/pipelines/views/JobHistory.tsx +++ b/src/pages/scientificServices/pipelines/views/JobHistory.tsx @@ -1,4 +1,5 @@ -import { Icon, Spinner } from '@terra-ui-packages/components'; +import { Icon, Spinner, useModalHandler } from '@terra-ui-packages/components'; +import { formatDate, formatDatetime } from '@terra-ui-packages/core-utils'; import _, { capitalize } from 'lodash'; import pluralize from 'pluralize'; import React, { ReactNode, useEffect, useRef, useState } from 'react'; @@ -9,6 +10,8 @@ import { Teaspoons } from 'src/libs/ajax/teaspoons/Teaspoons'; import { GetPipelineRunsResponse, PipelineRun, PipelineRunStatus } from 'src/libs/ajax/teaspoons/teaspoons-models'; import { useCancellation } from 'src/libs/react-utils'; import { pipelinesTopBar } from 'src/pages/scientificServices/pipelines/common/scientific-services-common'; +import { ViewErrorModal } from 'src/pages/scientificServices/pipelines/views/modals/ViewErrorModal'; +import { ViewOutputsModal } from 'src/pages/scientificServices/pipelines/views/modals/ViewOutputsModal'; /* Right now, this will show all pipeline runs. Once we support more than one pipeline, @@ -47,7 +50,9 @@ export const JobHistory = () => { >

Job History

-
All files associated with jobs will be automatically deleted after 2 weeks from completed.
+
+ All files associated with jobs will be automatically deleted after 2 weeks from completion. +
For support, email{' '} { cellRenderer: ({ rowIndex }) => { return ; }, - size: { basis: 90 }, + size: { basis: 80 }, }, { field: 'completed', @@ -146,7 +151,15 @@ const getColumns = (paginatedRuns: PipelineRun[]) => { cellRenderer: ({ rowIndex }) => { return ; }, - size: { basis: 90 }, + size: { basis: 80 }, + }, + { + field: 'dataDeletionDate', + headerRenderer: () => Deletion Date, + cellRenderer: ({ rowIndex }) => { + return ; + }, + size: { basis: 80 }, }, { field: 'quotaUsed', @@ -216,13 +229,7 @@ const StatusCell = ({ pipelineRun }: CellProps): ReactNode => { /** Format date like "Feb 15, 2025", and enable tooltip with precise time */ const MediumDateWithTooltip = ({ date }: { date: string | Date }): React.JSX.Element => { - const mediumDate = new Date(date).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }); - - return {mediumDate}; + return {formatDate(date)}; }; const SubmittedCell = ({ pipelineRun }: CellProps): ReactNode => { @@ -233,6 +240,18 @@ const CompletedCell = ({ pipelineRun }: CellProps): ReactNode => { return
{pipelineRun.timeCompleted ? : ''}
; }; +const DataDeletionDateCell = ({ pipelineRun }: CellProps): ReactNode => { + if (!pipelineRun.timeCompleted || !(pipelineRun.status === 'SUCCEEDED')) { + return
N/A
; + } + + const completionDate = new Date(pipelineRun?.timeCompleted); + const deletionDate = new Date(completionDate); + deletionDate.setDate(deletionDate.getDate() + 14); + + return ; +}; + const QuotaUsedCell = (props: CellProps): ReactNode => { return (
@@ -244,20 +263,57 @@ const QuotaUsedCell = (props: CellProps): ReactNode => { }; const ActionCell = ({ pipelineRun }: CellProps): ReactNode => { - // TODO these actions will be implemented in a later ticket + const outputsModal = useModalHandler(() => { + return ; + }); + + const errorModal = useModalHandler(() => { + return ; + }); + return (
{pipelineRun.status === 'SUCCEEDED' && ( - // eslint-disable-next-line jsx-a11y/anchor-is-valid - - Download Output - + <> + + {outputsModal.maybeRender()} + )} {pipelineRun.status === 'FAILED' && ( - // eslint-disable-next-line jsx-a11y/anchor-is-valid - - See Details - + <> + + {errorModal.maybeRender()} + )}
); diff --git a/src/pages/scientificServices/pipelines/views/modals/ViewErrorModal.test.tsx b/src/pages/scientificServices/pipelines/views/modals/ViewErrorModal.test.tsx new file mode 100644 index 0000000000..6fd6d00b00 --- /dev/null +++ b/src/pages/scientificServices/pipelines/views/modals/ViewErrorModal.test.tsx @@ -0,0 +1,139 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { Teaspoons, TeaspoonsContract } from 'src/libs/ajax/teaspoons/Teaspoons'; +import { PipelineRunResponse } from 'src/libs/ajax/teaspoons/teaspoons-models'; +import { asMockedFn, partial, renderWithAppContexts } from 'src/testing/test-utils'; + +import { ViewErrorModal } from './ViewErrorModal'; + +jest.mock('src/libs/ajax/teaspoons/Teaspoons'); + +describe('ViewErrorModal', () => { + const jobId = 'test-job-id'; + const onDismissMock = jest.fn(); + + it('displays loading state initially', () => { + asMockedFn(Teaspoons).mockReturnValue( + partial({ + getPipelineRunResult: jest.fn().mockReturnValue(new Promise(() => {})), + }) + ); + + renderWithAppContexts(); + + expect(screen.getByText('Loading error details...')).toBeInTheDocument(); + expect(screen.getByText(`Pipeline Error - ${jobId}`)).toBeInTheDocument(); + }); + + it('fetches and displays error information', async () => { + const mockErrorReport = { + message: 'Test error message', + errorCode: 504, + causes: ['Error cause 1', 'Error cause 2'], + }; + + const mockPipelineRunResponse: Partial = { + errorReport: mockErrorReport, + }; + + asMockedFn(Teaspoons).mockReturnValue( + partial({ + getPipelineRunResult: jest.fn().mockResolvedValue(mockPipelineRunResponse), + }) + ); + + renderWithAppContexts(); + + await waitFor(() => { + expect(screen.queryByText('Loading error details...')).not.toBeInTheDocument(); + }); + + // Verify error details are displayed + expect(screen.getByText('Error Message:')).toBeInTheDocument(); + expect(screen.getByText('Test error message')).toBeInTheDocument(); + + // Verify error causes are displayed + expect(screen.getByText('Error Causes:')).toBeInTheDocument(); + expect(screen.getByText('Error cause 1')).toBeInTheDocument(); + expect(screen.getByText('Error cause 2')).toBeInTheDocument(); + }); + + it('displays a message when no error details are available', async () => { + const mockPipelineRunResponse: Partial = {}; + + asMockedFn(Teaspoons).mockReturnValue( + partial({ + getPipelineRunResult: jest.fn().mockResolvedValue(mockPipelineRunResponse), + }) + ); + + renderWithAppContexts(); + + // Wait for loading to complete + await waitFor(() => { + expect(screen.queryByText('Loading error details...')).not.toBeInTheDocument(); + }); + + // Verify "no error details" message is displayed + expect(screen.getByText('No detailed error information available for this job.')).toBeInTheDocument(); + }); + + it('calls onDismiss when Close button is clicked', async () => { + const mockPipelineRunResponse: Partial = { + errorReport: { + errorCode: 500, + causes: [], + message: 'Test error', + }, + }; + + asMockedFn(Teaspoons).mockReturnValue( + partial({ + getPipelineRunResult: jest.fn().mockResolvedValue(mockPipelineRunResponse), + }) + ); + + renderWithAppContexts(); + + await waitFor(() => { + expect(screen.queryByText('Loading error details...')).not.toBeInTheDocument(); + }); + + const user = userEvent.setup(); + await user.click(screen.getByText('Close')); + + // Verify onDismiss was called + expect(onDismissMock).toHaveBeenCalledTimes(1); + }); + + it('handles errors empty causes array', async () => { + const mockErrorReport = { + message: 'Simple error message with no causes', + errorCode: 500, + causes: [], + }; + + const mockPipelineRunResponse: Partial = { + errorReport: mockErrorReport, + }; + + asMockedFn(Teaspoons).mockReturnValue( + partial({ + getPipelineRunResult: jest.fn().mockResolvedValue(mockPipelineRunResponse), + }) + ); + + renderWithAppContexts(); + + await waitFor(() => { + expect(screen.queryByText('Loading error details...')).not.toBeInTheDocument(); + }); + + // Verify error message is displayed + expect(screen.getByText('Simple error message with no causes')).toBeInTheDocument(); + + // Verify causes section is not displayed + expect(screen.queryByText('Error Causes:')).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/scientificServices/pipelines/views/modals/ViewErrorModal.tsx b/src/pages/scientificServices/pipelines/views/modals/ViewErrorModal.tsx new file mode 100644 index 0000000000..9220e44b5a --- /dev/null +++ b/src/pages/scientificServices/pipelines/views/modals/ViewErrorModal.tsx @@ -0,0 +1,103 @@ +import { ButtonPrimary, Modal, Spinner } from '@terra-ui-packages/components'; +import React, { ReactNode, useEffect, useState } from 'react'; +import { Teaspoons } from 'src/libs/ajax/teaspoons/Teaspoons'; +import { PipelineRunResponse } from 'src/libs/ajax/teaspoons/teaspoons-models'; +import { useCancellation } from 'src/libs/react-utils'; + +/** + * Modal component for displaying pipeline errors + */ +interface ErrorModalProps { + jobId: string; + onDismiss: () => void; +} + +export const ViewErrorModal = ({ jobId, onDismiss }: ErrorModalProps): ReactNode => { + const [result, setResult] = useState(); + const [loading, setLoading] = useState(true); + const signal = useCancellation(); + + useEffect(() => { + const fetchPipelineRunResults = async () => { + try { + setLoading(true); + const results = await Teaspoons(signal).getPipelineRunResult(jobId); + setResult(results); + } finally { + setLoading(false); + } + }; + + fetchPipelineRunResults(); + }, [jobId, signal]); + + return ( + +
+

Error Details

+ + {loading ? ( +
+ +
Loading error details...
+
+ ) : ( +
+ {result?.errorReport ? ( +
+
+
Error Message:
+
{result.errorReport.message}
+
+ + {result.errorReport.causes && result.errorReport.causes.length > 0 && ( +
+
Error Causes:
+
+ {result.errorReport.causes.map((cause, index) => ( + // eslint-disable-next-line react/no-array-index-key +
+ {cause} +
+ ))} +
+
+ )} +
+ ) : ( +
+ No detailed error information available for this job. +
+ )} +
+ )} + +
+ Close +
+
+
+ ); +}; diff --git a/src/pages/scientificServices/pipelines/views/modals/ViewOutputsModal.test.tsx b/src/pages/scientificServices/pipelines/views/modals/ViewOutputsModal.test.tsx new file mode 100644 index 0000000000..fabc31a650 --- /dev/null +++ b/src/pages/scientificServices/pipelines/views/modals/ViewOutputsModal.test.tsx @@ -0,0 +1,135 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { Teaspoons, TeaspoonsContract } from 'src/libs/ajax/teaspoons/Teaspoons'; +import { PipelineRunResponse } from 'src/libs/ajax/teaspoons/teaspoons-models'; +import { asMockedFn, partial, renderWithAppContexts } from 'src/testing/test-utils'; + +import { ViewOutputsModal } from './ViewOutputsModal'; + +jest.mock('src/libs/ajax/teaspoons/Teaspoons'); + +describe('ViewOutputsModal', () => { + const jobId = 'test-job-id'; + const onDismissMock = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('displays loading state initially', () => { + // Mock Teaspoons to return a promise that never resolves to keep the loading state + asMockedFn(Teaspoons).mockReturnValue( + partial({ + getPipelineRunResult: jest.fn().mockReturnValue(new Promise(() => {})), + }) + ); + + renderWithAppContexts(); + + expect(screen.getByText('Loading outputs...')).toBeInTheDocument(); + expect(screen.getByText(`Pipeline Outputs - ${jobId}`)).toBeInTheDocument(); + }); + + it('fetches and displays pipeline outputs', async () => { + const mockOutputs = { + output1: 'https://example.com/output1.vcf', + output2: 'https://example.com/output2.bam', + }; + + const mockPipelineRunResponse: Partial = { + pipelineRunReport: { + pipelineName: 'test-pipeline', + pipelineVersion: 1, + toolVersion: '1.0.0', + outputExpirationDate: '2025-07-09T00:00:00Z', + outputs: mockOutputs, + }, + }; + + asMockedFn(Teaspoons).mockReturnValue( + partial({ + getPipelineRunResult: jest.fn().mockResolvedValue(mockPipelineRunResponse), + }) + ); + + renderWithAppContexts(); + + // Wait for loading to complete + await waitFor(() => { + expect(screen.queryByText('Loading outputs...')).not.toBeInTheDocument(); + }); + + // Verify outputs are displayed + expect(screen.getByText('output1')).toBeInTheDocument(); + expect(screen.getByText('output2')).toBeInTheDocument(); + + // Verify download buttons are present + const downloadButtons = screen.getAllByText('Download'); + expect(downloadButtons).toHaveLength(2); + + // Verify expiration notice is displayed + expect(screen.getByText(/All output files for this job will be automatically deleted on/)).toBeInTheDocument(); + expect(screen.getByText('Jul 9, 2025')).toBeInTheDocument(); + }); + + it('displays a message when no outputs are available', async () => { + const mockPipelineRunResponse: Partial = { + pipelineRunReport: { + pipelineName: 'test-pipeline', + pipelineVersion: 1, + toolVersion: '1.0.0', + outputExpirationDate: '2025-07-09T00:00:00Z', + outputs: {}, + }, + }; + + asMockedFn(Teaspoons).mockReturnValue( + partial({ + getPipelineRunResult: jest.fn().mockResolvedValue(mockPipelineRunResponse), + }) + ); + + renderWithAppContexts(); + + // Wait for loading to complete + await waitFor(() => { + expect(screen.queryByText('Loading outputs...')).not.toBeInTheDocument(); + }); + + // Verify "no outputs" message is displayed + expect(screen.getByText('No output files found for this job.')).toBeInTheDocument(); + }); + + it('calls onDismiss when Close button is clicked', async () => { + const mockPipelineRunResponse: Partial = { + pipelineRunReport: { + pipelineName: 'test-pipeline', + pipelineVersion: 1, + toolVersion: '1.0.0', + outputExpirationDate: '2025-07-09T00:00:00Z', + outputs: {}, + }, + }; + + asMockedFn(Teaspoons).mockReturnValue( + partial({ + getPipelineRunResult: jest.fn().mockResolvedValue(mockPipelineRunResponse), + }) + ); + + renderWithAppContexts(); + + // Wait for loading to complete + await waitFor(() => { + expect(screen.queryByText('Loading outputs...')).not.toBeInTheDocument(); + }); + + // Click the Close button + const user = userEvent.setup(); + await user.click(screen.getByText('Close')); + + // Verify onDismiss was called + expect(onDismissMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/pages/scientificServices/pipelines/views/modals/ViewOutputsModal.tsx b/src/pages/scientificServices/pipelines/views/modals/ViewOutputsModal.tsx new file mode 100644 index 0000000000..e0bbd789d3 --- /dev/null +++ b/src/pages/scientificServices/pipelines/views/modals/ViewOutputsModal.tsx @@ -0,0 +1,119 @@ +import { ButtonPrimary, Icon, Modal, Spinner } from '@terra-ui-packages/components'; +import { formatDate } from '@terra-ui-packages/core-utils'; +import React, { ReactNode, useEffect, useState } from 'react'; +import { Teaspoons } from 'src/libs/ajax/teaspoons/Teaspoons'; +import { PipelineRunResponse } from 'src/libs/ajax/teaspoons/teaspoons-models'; +import { useCancellation } from 'src/libs/react-utils'; + +/** + * Modal component for displaying pipeline outputs + */ +interface OutputsModalProps { + jobId: string; + onDismiss: () => void; +} + +export const ViewOutputsModal = ({ jobId, onDismiss }: OutputsModalProps): ReactNode => { + const [result, setResult] = useState(); + const [loading, setLoading] = useState(true); + const signal = useCancellation(); + + useEffect(() => { + const fetchPipelineRunResults = async () => { + try { + setLoading(true); + const results = await Teaspoons(signal).getPipelineRunResult(jobId); + setResult(results); + } finally { + setLoading(false); + } + }; + + fetchPipelineRunResults(); + }, [jobId, signal]); + + return ( + +
+

Available Output Files

+ + {loading ? ( +
+ +
Loading outputs...
+
+ ) : ( +
+ {result?.pipelineRunReport.outputs && Object.entries(result.pipelineRunReport.outputs).length > 0 ? ( +
+ {Object.entries(result.pipelineRunReport.outputs).map(([key, url]) => ( +
+
{key}
+ { + window.open(url, '_blank'); + }} + style={{ marginLeft: '1rem' }} + > +
+ + Download +
+
+
+ ))} + {result.pipelineRunReport.outputExpirationDate && ( +
+ All output files for this job will be automatically deleted on{' '} + + {formatDate(result.pipelineRunReport.outputExpirationDate)} + + . Please download them before this date. +
+ )} +
+ ) : ( +
No output files found for this job.
+ )} +
+ )} + +
+ Close +
+
+
+ ); +};