Unverified Commit 4eb17a3f authored by Andrey Azov's avatar Andrey Azov Committed by GitHub
Browse files

Show unviewed blast submissions (#745)

- Submission of BLAST form opens the screen with unviewed submissions
- While the submission is in flight, the screen will show a spinner
- Unviewed submissions page will contain a list of unviewed submissions, shown in reverse chronologic order
- A submission can be deleted
- When the submission is ready, the user will see a button for viewing the results in detail
- Added date and date-time formatters
parent 100d5a25
Pipeline #276631 passed with stages
in 10 minutes and 6 seconds
......@@ -28,6 +28,9 @@ const BlastUnviewedSubmissions = loadable(
const BlastJobs = loadable(
() => import('./views/blast-submissions/BlastSubmissions')
);
const BlastSubmissionResults = loadable(
() => import('./views/blast-submission-results/BlastSubmissionResults')
);
const pageDescription = `
BLAST stands for Basic Local Alignment Search Tool.
......@@ -50,6 +53,10 @@ const BrowserPage = () => {
path="unviewed-submissions"
element={<BlastUnviewedSubmissions />}
/>
<Route
path="submissions/:submissionId"
element={<BlastSubmissionResults />}
/>
<Route path="submissions" element={<BlastJobs />} />
</Routes>
)}
......
......@@ -53,7 +53,13 @@ export type PayloadParams = {
const BlastJobSubmit = () => {
const { sequences } = useBlastInputSequences();
const selectedSpeciesIds = useAppSelector(getSelectedSpeciesIds);
const [submitBlast] = useSubmitBlastMutation();
const [submitBlast] = useSubmitBlastMutation({
// Using a fixed cache key means that any subsequent request
// will overwrite the current request if it hasn't yet completed;
// but in order for this to be a problem, the api endpoint must be fantastically slow,
// and the user must be fantastically fast; so it is most likely a non-issue
fixedCacheKey: 'submit-blast-form'
});
const navigate = useNavigate();
const isDisabled = !isBlastFormValid(selectedSpeciesIds, sequences);
......
......@@ -24,8 +24,7 @@
}
.previousJobs {
height: 32px;
width: 120px;
align-self: start;
}
.select label {
......
......@@ -17,13 +17,14 @@
import React, { FormEvent, useEffect, useState } from 'react';
import classNames from 'classnames';
import { useSelector, useDispatch } from 'react-redux';
import noop from 'lodash/noop';
import * as urlFor from 'src/shared/helpers/urlHelper';
import ShowHide from 'src/shared/components/show-hide/ShowHide';
import Checkbox from 'src/shared/components/checkbox/Checkbox';
import SimpleSelect from 'src/shared/components/simple-select/SimpleSelect';
import ShadedInput from 'src/shared/components/input/ShadedInput';
import { SecondaryButton } from 'src/shared/components/button/Button';
import ButtonLink from 'src/shared/components/button-link/ButtonLink';
import BlastJobSubmit from 'src/content/app/tools/blast/components/blast-job-submit/BlastJobSubmit';
import {
......@@ -192,9 +193,12 @@ const BlastSettings = ({ config }: Props) => {
<BlastJobSubmit />
</div>
</div>
<SecondaryButton className={styles.previousJobs} onClick={noop}>
<ButtonLink
className={styles.previousJobs}
to={urlFor.blastSubmissionsList()}
>
Jobs list
</SecondaryButton>
</ButtonLink>
</div>
{parametersExpanded && (
<div className={styles.bottomLevel}>
......
.grid {
display: grid;
grid-template-columns: 1fr auto auto;
column-gap: 172px;
align-items: center;
padding-left: 26px; // TODO: seems to be a constant shared with sequence boxes (see ListedBlastSubmission.scss)
margin-bottom: 14px;
white-space: nowrap;
}
/**
* See the NOTICE file distributed with this work for additional information
* regarding copyright ownership.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { type ReactNode } from 'react';
import styles from './BlastSubmissionHeaderGrid.scss';
/**
* This is a dumb component whose sole purpose is to arrange the children into a grid
*/
// TODO: consider which interface is better: a single "children" prop,
// or individual "first", "second", "third" props. With individual props,
// the consumer of the component will know that this component accepts only three children
type Props = {
children: ReactNode;
};
const BlastSubmissionHeaderGrid = ({ children }: Props) => {
return <div className={styles.grid}>{children}</div>;
};
export default BlastSubmissionHeaderGrid;
.blastViewsNavigation {
display: grid;
grid-template-columns: [left] auto [right] 1fr;
height: 74px; // this isn't ideal; but 100% won't work because the parent container only has a min-height
align-items: center;
max-width: 1810px; // TODO: this is max width of the two-column blast form; set it to a constant
padding-right: 20px; // same as in the app bar above
max-width: 1800px; // TODO: this is max width of the two-column blast form; set it to a constant
}
.title {
......
@import 'src/styles/common';
.listedBlastSubmission {
/*
The max-width of 1770px is a result of the following calculation:
take the max width of the top bar content (1800px),
and subtract from it the extra left padding that the main container has over the top bar (60px - 30px)
Thus, 1800 - 60 + 30 = 1770px
*/
max-width: 1770px;
}
.listedBlastSubmission:not(:last-child) {
margin-bottom: 48px;
}
/*
NOTE: Regarding the grid for .sequenceBox — remember that job status may either be shown or not,
and also that it can be of variable width
*/
.sequenceBox {
display: grid;
grid-template-columns: 68% auto 1fr;
padding: 24px 26px; // TODO: the 26px padding looks like a constant asking to be extracted
border: 1px solid $grey;
}
.sequenceBox + .sequenceBox {
border-top: none;
}
.submissionIdLabel {
font-weight: $light;
margin-right: 1ch;
}
.editSubmission {
color: $blue;
cursor: pointer;
margin: 0 32px;
}
.timeStamp {
white-space: nowrap;
}
.timeZone {
font-weight: $light;
margin-left: 1ch;
}
.controlButtons {
display: flex;
align-items: center;
justify-content: flex-end;
column-gap: 40px;
width: 268px;
}
.inactiveButton {
--download-button-background: #{$grey};
}
.jobStatus {
justify-self: end;
}
.jobStatusProminent {
color: $red;
}
/**
* See the NOTICE file distributed with this work for additional information
* regarding copyright ownership.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router';
import { configureStore, combineReducers } from '@reduxjs/toolkit';
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as blastStorageService from 'src/content/app/tools/blast/services/blastStorageService';
import ListedBlastSubmission, {
type Props as ListedBlastSubmissionProps
} from 'src/content/app/tools/blast/components/listed-blast-submission/ListedBlastSubmission';
import blastFormReducer from 'src/content/app/tools/blast/state/blast-form/blastFormSlice';
import blastResultsReducer, {
type BlastResultsState
} from 'src/content/app/tools/blast/state/blast-results/blastResultsSlice';
import { createBlastSubmission } from 'tests/fixtures/blast/blastSubmission';
jest.mock('src/content/app/tools/blast/services/blastStorageService');
const defaultProps = {
submission: createBlastSubmission()
};
const renderComponent = ({
props,
state
}: {
props?: Partial<ListedBlastSubmissionProps>;
state?: Partial<BlastResultsState>;
}) => {
const initialState = {
blast: { blastResults: state ?? {} }
};
const mergedProps = {
...defaultProps,
...props
};
const rootReducer = combineReducers({
blast: combineReducers({
blastForm: blastFormReducer,
blastResults: blastResultsReducer
})
});
const store = configureStore({
reducer: rootReducer,
preloadedState: initialState
});
const renderResult = render(
<MemoryRouter>
<Provider store={store}>
<ListedBlastSubmission {...mergedProps} />
</Provider>
</MemoryRouter>
);
return {
...renderResult,
store
};
};
describe('BlastSubmissionHeader', () => {
describe('rendering', () => {
it('shows one sequence box if the submission contained a single sequence', () => {
const submission = createBlastSubmission({
options: { sequencesCount: 1 }
});
const { container } = renderComponent({
props: { submission }
});
expect(container.querySelectorAll('.sequenceBox').length).toBe(1);
});
it('shows multiple sequence boxes if the submission contained multiple sequences', () => {
const submission = createBlastSubmission({
options: { sequencesCount: 5 }
});
const { container } = renderComponent({
props: { submission }
});
expect(container.querySelectorAll('.sequenceBox').length).toBe(5);
});
it.todo('shows submission date');
describe('while at least one job is running', () => {
const submission = createBlastSubmission({
options: { sequencesCount: 5 }
});
// make sure there is only one one running job
submission.results.forEach((job, index) => {
if (index === 0) {
job.status = 'RUNNING';
} else {
job.status = 'FINISHED';
}
});
it('does not show control buttons', () => {
const { container } = renderComponent({
props: { submission }
});
expect(container.querySelector('.deleteButton')).toBeFalsy();
});
it('has a disabled link to submission results page', () => {
const { container } = renderComponent({
props: { submission }
});
const buttonLink = container.querySelector(
'.buttonLink'
) as HTMLElement;
expect(buttonLink).toBeTruthy();
expect(buttonLink.classList.contains('buttonLinkDisabled')).toBe(true);
expect(buttonLink.tagName.toLowerCase()).toBe('span');
});
});
describe('when all jobs are finished', () => {
const submission = createBlastSubmission({
options: { sequencesCount: 5 }
});
submission.results.forEach((job) => {
job.status = 'FINISHED';
});
it('activates link to submission results', () => {
const { container } = renderComponent({
props: { submission }
});
const buttonLink = container.querySelector(
'.buttonLink'
) as HTMLElement;
expect(buttonLink).toBeTruthy();
expect(buttonLink.tagName.toLowerCase()).toBe('a');
expect(buttonLink.getAttribute('href')).toBe(
`/blast/submissions/${submission.id}`
);
});
});
});
describe('behaviour', () => {
// define this behaviour better
it.todo('can fold jobs of a submission into a single box');
// TODO: make sure that deleting a sequence stops polling for this sequence
it('can delete a submission', async () => {
const submission = createBlastSubmission();
submission.results.forEach((job) => (job.status = 'FINISHED'));
const { id: submissionId } = submission;
const submissionInRedux = {
[submissionId]: submission
};
const { container, store } = renderComponent({
props: { submission },
state: submissionInRedux
});
expect(store.getState().blast.blastResults[submissionId]).toBeTruthy();
const deleteButton = container.querySelector(
'.deleteButton'
) as HTMLButtonElement;
await userEvent.click(deleteButton);
expect(blastStorageService.deleteBlastSubmission).toHaveBeenCalledWith(
submissionId
);
expect(
store.getState().blast.blastResults[submissionId]
).not.toBeTruthy();
});
it('can populate BLAST form with submitted data', async () => {
const submission = createBlastSubmission();
const { container, store } = renderComponent({
props: { submission }
});
const editButton = container.querySelector(
'.editSubmission'
) as HTMLSpanElement;
await userEvent.click(editButton);
const blastFormReduxState = store.getState().blast.blastForm;
expect(blastFormReduxState.sequences.length).toBeTruthy();
expect(blastFormReduxState.selectedSpecies).toEqual(
submission.submittedData.species
);
expect(
Object.keys(blastFormReduxState.settings.parameters).length
).toBeGreaterThan(0);
});
});
});
/**
* See the NOTICE file distributed with this work for additional information
* regarding copyright ownership.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { useNavigate } from 'react-router';
import classNames from 'classnames';
import { useAppDispatch } from 'src/store';
import * as urlFor from 'src/shared/helpers/urlHelper';
import { getFormattedDateTime } from 'src/shared/helpers/formatters/dateFormatter';
import { parseBlastInput } from 'src/content/app/tools/blast/utils/blastInputParser';
import { fillBlastForm } from 'src/content/app/tools/blast/state/blast-form/blastFormSlice';
import {
deleteBlastSubmission,
type BlastSubmission,
type BlastJob
} from 'src/content/app/tools/blast/state/blast-results/blastResultsSlice';
import BlastSubmissionHeaderGrid from 'src/content/app/tools/blast/components/blast-submission-header-container/BlastSubmissionHeaderGrid';
import ButtonLink from 'src/shared/components/button-link/ButtonLink';
import DeleteButton from 'src/shared/components/delete-button/DeleteButton';
import DownloadButton from 'src/shared/components/download-button/DownloadButton';
import type { BlastProgram } from 'src/content/app/tools/blast/types/blastSettings';
import styles from './ListedBlastSubmission.scss';
export type Props = {
submission: BlastSubmission;
};
const ListedBlastSubmission = (props: Props) => {
const { submission } = props;
const sequences = submission.submittedData.sequences;
const allJobs = submission.results;
const isAnyJobRunning = allJobs.some((job) => job.status === 'RUNNING');
const jobsGroupedBySequence = sequences.map((sequence) => {
const jobs = allJobs.filter((job) => job.sequenceId === sequence.id);
return {
sequence,
jobs
};
});
const sequenceBoxes = jobsGroupedBySequence.map(({ sequence, jobs }) => (
<SequenceBox key={sequence.id} sequence={sequence} jobs={jobs} />
));
return (
<div className={styles.listedBlastSubmission}>
<Header {...props} isAnyJobRunning={isAnyJobRunning} />
{sequenceBoxes}
</div>
);
};
const Header = (
props: Props & {
isAnyJobRunning: boolean;
}
) => {
const { submission } = props;
const dispatch = useAppDispatch();
const navigate = useNavigate();
const blastProgram =
submission.submittedData.parameters.program.toUpperCase();
const submissionId = submission.id;
const submissionTime = getFormattedDateTime(new Date(submission.submittedAt));
const editSubmission = () => {
const { sequences, species, parameters } = submission.submittedData;
const parsedSequences = sequences.flatMap((sequence) =>
parseBlastInput(sequence.value)
);
const { title, program, stype, ...otherParameters } = parameters;
const payload = {
sequences: parsedSequences,
selectedSpecies: species,
settings: {
jobName: title,
sequenceType: stype,
program: program as BlastProgram,
parameters: otherParameters
}
};
dispatch(fillBlastForm(payload));
navigate(urlFor.blastForm());
};
const handleDeletion = () => {
dispatch(deleteBlastSubmission(submissionId));
};
return (
<BlastSubmissionHeaderGrid>
<div>{blastProgram}</div>
<div>