Skip to content

Commit 99d4954

Browse files
committed
[TSPS-512] Implement actions column w/ downloads outputs and view error modals (#5339)
1 parent 1f2c820 commit 99d4954

File tree

10 files changed

+792
-34
lines changed

10 files changed

+792
-34
lines changed

src/libs/ajax/teaspoons/Teaspoons.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { fetchTeaspoons } from 'src/libs/ajax/ajax-common';
55
import {
66
GetPipelineRunsResponse,
77
PipelineList,
8+
PipelineRunResponse,
89
PipelineWithDetails,
910
PreparePipelineRunResponse,
1011
StartPipelineResponse,
@@ -77,6 +78,11 @@ export const Teaspoons = (signal?: AbortSignal) => ({
7778
);
7879
return res.json();
7980
},
81+
82+
getPipelineRunResult: async (jobId: string): Promise<PipelineRunResponse> => {
83+
const res = await fetchTeaspoons(`pipelineruns/v1/result/${jobId}`, _.merge(authOpts(), { signal }));
84+
return res.json();
85+
},
8086
});
8187

8288
export type TeaspoonsContract = ReturnType<typeof Teaspoons>;

src/libs/ajax/teaspoons/teaspoons-models.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,34 @@ export interface StartPipelineResponse {
8181
};
8282
}
8383

84+
export interface PipelineRunResponse {
85+
jobReport: PipelineJobReport;
86+
errorReport?: PipelineRunErrorReport;
87+
pipelineRunReport: PipelineRunReport;
88+
}
89+
90+
export interface PipelineJobReport {
91+
id: string;
92+
description?: string;
93+
status: PipelineRunStatus;
94+
statusCode?: number;
95+
submitted: string;
96+
completed?: string;
97+
resultURL?: string;
98+
}
99+
100+
export interface PipelineRunErrorReport {
101+
message: string;
102+
errorCode: number;
103+
causes: string[];
104+
}
105+
106+
export interface PipelineRunReport {
107+
pipelineName: string;
108+
pipelineVersion: number;
109+
toolVersion: string;
110+
outputs?: Record<string, string>;
111+
outputExpirationDate?: string;
112+
}
113+
84114
export type PipelineRunStatus = 'PREPARING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED';

src/pages/scientificServices/landingPage/ScientificServicesDescription.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,14 @@ export const ScientificServicesDescription = () => {
1515
</div>
1616
<div style={{ fontWeight: 'bold', marginTop: '2rem' }}>Our current offerings:</div>
1717
<div>
18-
<span style={{ fontStyle: 'italic' }}>All of Us</span> + AnVIL Imputation Service
18+
<a
19+
href='https://allofus-anvil-imputation.terra.bio/'
20+
target='_blank'
21+
rel='noopener noreferrer'
22+
style={{ textDecoration: 'underline' }}
23+
>
24+
<span style={{ fontStyle: 'italic' }}>All of Us</span> + AnVIL Imputation Service
25+
</a>
1926
</div>
2027
<div style={{ fontWeight: 'bold', marginTop: '2rem', marginBottom: '1rem' }}>First time using this service?</div>
2128
{buttonVisible && (

src/pages/scientificServices/pipelines/utils/mock-utils.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import {
22
Pipeline,
33
PipelineInput,
4+
PipelineRun,
5+
PipelineRunStatus,
46
PipelineWithDetails,
57
UserPipelineQuotaDetails,
68
} from 'src/libs/ajax/teaspoons/teaspoons-models';
@@ -43,3 +45,15 @@ export function mockUserPipelineQuotaDetails(name: string): UserPipelineQuotaDet
4345
quotaUnits: 'things',
4446
};
4547
}
48+
49+
export function mockPipelineRun(status: PipelineRunStatus): PipelineRun {
50+
return {
51+
jobId: 'run-id-123',
52+
pipelineName: 'array_imputation',
53+
status,
54+
description: 'Test pipeline run',
55+
timeSubmitted: '2023-10-01T00:00:00Z',
56+
timeCompleted: status === 'SUCCEEDED' || status === 'FAILED' ? '2023-10-01T01:00:00Z' : undefined,
57+
quotaConsumed: status === 'SUCCEEDED' ? 500 : undefined,
58+
};
59+
}

src/pages/scientificServices/pipelines/views/JobHistory.test.tsx

Lines changed: 162 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
import { screen } from '@testing-library/react';
1+
import { screen, waitFor } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
23
import React from 'react';
34
import { Teaspoons, TeaspoonsContract } from 'src/libs/ajax/teaspoons/Teaspoons';
5+
import { mockPipelineRun } from 'src/pages/scientificServices/pipelines/utils/mock-utils';
46
import { asMockedFn, partial, renderWithAppContexts as render } from 'src/testing/test-utils';
57

68
import { JobHistory } from './JobHistory';
@@ -35,17 +37,8 @@ jest.mock('src/libs/nav', () => ({
3537

3638
describe('job history table', () => {
3739
it('renders the job history table', async () => {
38-
const pipelineRuns = [
39-
{
40-
id: '123',
41-
description: 'Test Job',
42-
status: 'RUNNING',
43-
createdAt: '2023-10-01T00:00:00Z',
44-
updatedAt: '2023-10-01T00:00:00Z',
45-
pipelineVersion: 'v1.0.0',
46-
pipelineName: 'array_imputation',
47-
},
48-
];
40+
const pipelineRun = mockPipelineRun('RUNNING');
41+
const pipelineRuns = [pipelineRun];
4942

5043
const mockPipelineRunResponse = {
5144
pageToken: 'nextPageToken',
@@ -62,7 +55,163 @@ describe('job history table', () => {
6255
render(<JobHistory />);
6356

6457
expect(await screen.findByText('Job History')).toBeInTheDocument();
65-
expect(screen.queryAllByText('Test Job')).toHaveLength(2);
58+
expect(screen.queryAllByText(pipelineRun.description!)).toHaveLength(2);
6659
expect(screen.getByText('In Progress')).toBeInTheDocument();
6760
});
61+
62+
it('shows View Outputs button for SUCCEEDED jobs', async () => {
63+
const pipelineRun = mockPipelineRun('SUCCEEDED');
64+
const pipelineRuns = [pipelineRun];
65+
66+
const mockPipelineRunResponse = {
67+
pageToken: null,
68+
results: pipelineRuns,
69+
totalResults: 1,
70+
};
71+
72+
asMockedFn(Teaspoons).mockReturnValue(
73+
partial<TeaspoonsContract>({
74+
getAllPipelineRuns: jest.fn().mockReturnValue(mockPipelineRunResponse),
75+
})
76+
);
77+
78+
render(<JobHistory />);
79+
80+
await waitFor(() => {
81+
expect(screen.getAllByText(pipelineRun.jobId)).toHaveLength(2);
82+
});
83+
84+
const viewOutputsButton = screen.getByText('View Outputs');
85+
expect(viewOutputsButton).toBeInTheDocument();
86+
87+
expect(screen.queryByText('View Error')).not.toBeInTheDocument();
88+
});
89+
90+
it('shows View Error button for FAILED jobs', async () => {
91+
const pipelineRun = mockPipelineRun('FAILED');
92+
const pipelineRuns = [pipelineRun];
93+
94+
const mockPipelineRunResponse = {
95+
pageToken: null,
96+
results: pipelineRuns,
97+
totalResults: 1,
98+
};
99+
100+
asMockedFn(Teaspoons).mockReturnValue(
101+
partial<TeaspoonsContract>({
102+
getAllPipelineRuns: jest.fn().mockReturnValue(mockPipelineRunResponse),
103+
})
104+
);
105+
106+
render(<JobHistory />);
107+
108+
await waitFor(() => {
109+
expect(screen.getAllByText(pipelineRun.jobId)).toHaveLength(2);
110+
});
111+
112+
const viewErrorButton = screen.getByText('View Error');
113+
expect(viewErrorButton).toBeInTheDocument();
114+
115+
expect(screen.queryByText('View Outputs')).not.toBeInTheDocument();
116+
});
117+
118+
it('shows neither button for RUNNING jobs', async () => {
119+
const pipelineRun = mockPipelineRun('RUNNING');
120+
const pipelineRuns = [pipelineRun];
121+
122+
const mockPipelineRunResponse = {
123+
pageToken: null,
124+
results: pipelineRuns,
125+
totalResults: 1,
126+
};
127+
128+
asMockedFn(Teaspoons).mockReturnValue(
129+
partial<TeaspoonsContract>({
130+
getAllPipelineRuns: jest.fn().mockReturnValue(mockPipelineRunResponse),
131+
})
132+
);
133+
134+
render(<JobHistory />);
135+
136+
await waitFor(() => {
137+
expect(screen.getAllByText(pipelineRun.jobId)).toHaveLength(2);
138+
});
139+
140+
expect(screen.queryByText('View Outputs')).not.toBeInTheDocument();
141+
expect(screen.queryByText('View Error')).not.toBeInTheDocument();
142+
expect(screen.getByText('In Progress')).toBeInTheDocument();
143+
});
144+
145+
it('opens the outputs modal when View Outputs button is clicked', async () => {
146+
const pipelineRun = mockPipelineRun('SUCCEEDED');
147+
const pipelineRuns = [pipelineRun];
148+
149+
const mockPipelineRunResponse = {
150+
pageToken: null,
151+
results: pipelineRuns,
152+
totalResults: 1,
153+
};
154+
155+
const mockPipelineRunResult = {
156+
pipelineRunReport: {
157+
outputs: {
158+
'output1.txt': 'https://example.com/output1.txt',
159+
},
160+
},
161+
};
162+
163+
asMockedFn(Teaspoons).mockReturnValue(
164+
partial<TeaspoonsContract>({
165+
getAllPipelineRuns: jest.fn().mockReturnValue(mockPipelineRunResponse),
166+
getPipelineRunResult: jest.fn().mockResolvedValue(mockPipelineRunResult),
167+
})
168+
);
169+
170+
render(<JobHistory />);
171+
172+
await waitFor(() => {
173+
expect(screen.getAllByText(pipelineRun.jobId)).toHaveLength(2);
174+
});
175+
176+
const user = userEvent.setup();
177+
await user.click(screen.getByText('View Outputs'));
178+
179+
expect(await screen.findByText('Pipeline Outputs', { exact: false })).toBeInTheDocument();
180+
});
181+
182+
it('opens the error modal when View Error button is clicked', async () => {
183+
const pipelineRun = mockPipelineRun('FAILED');
184+
const pipelineRuns = [pipelineRun];
185+
186+
const mockPipelineRunResponse = {
187+
pageToken: null,
188+
results: pipelineRuns,
189+
totalResults: 1,
190+
};
191+
192+
const mockPipelineRunResult = {
193+
errorReport: {
194+
message: 'Test error message',
195+
causes: ['Test error cause'],
196+
},
197+
};
198+
199+
asMockedFn(Teaspoons).mockReturnValue(
200+
partial<TeaspoonsContract>({
201+
getAllPipelineRuns: jest.fn().mockReturnValue(mockPipelineRunResponse),
202+
getPipelineRunResult: jest.fn().mockResolvedValue(mockPipelineRunResult),
203+
})
204+
);
205+
206+
render(<JobHistory />);
207+
208+
await waitFor(() => {
209+
expect(screen.getAllByText(pipelineRun.jobId)).toHaveLength(2);
210+
});
211+
212+
const user = userEvent.setup();
213+
await user.click(screen.getByText('View Error'));
214+
215+
expect(await screen.findByText('Pipeline Error', { exact: false })).toBeInTheDocument();
216+
});
68217
});

0 commit comments

Comments
 (0)