Unverified Commit 1d5b071d authored by Andrey Azov's avatar Andrey Azov Committed by GitHub
Browse files

Store blast form submission and poll for job statuses (#718)

Upon a BLAST form submission:
- the results are saved in the browser storage (IndexedDB);
- the browser starts polling for the statuses of the submitted jobs.

The polling is implemented using a redux-observable epic.

An alternative implementation, using redux-listener-middleware, was proposed in:
https://github.com/Ensembl/ensembl-client/pull/722, but rejected in favour of this implementation.
parent 99655e4f
Pipeline #267556 passed with stages
in 5 minutes and 24 seconds
This diff is collapsed.
......@@ -133,6 +133,7 @@
"licence-manager": "git+https://github.com/Ensembl/ensembl-licence-manager.git#36b534d28ff26d345b4ba4da0cbc18e1e0f7a6b7",
"lint-staged": "12.3.4",
"mini-css-extract-plugin": "2.5.3",
"msw": "0.39.2",
"nodemon": "2.0.15",
"postcss": "8.4.7",
"postcss-loader": "6.2.1",
......
......@@ -32,7 +32,21 @@ const sequences = [
}
];
const selectedSpecies = ['human', 'wheat'];
const selectedHuman = {
genome_id: 'human-genome-id',
common_name: 'Human',
scientific_name: 'Homo sapiens',
assembly_name: 'GRCh38'
};
const selectedMouse = {
genome_id: 'mouse-genome-id',
common_name: 'Mouse',
scientific_name: 'Mus musculus',
assembly_name: 'GRCm39'
};
const selectedSpecies = [selectedHuman, selectedMouse];
const jobName = faker.lorem.words();
const database = faker.lorem.word();
const blastParameters = {
......@@ -65,7 +79,7 @@ const mockState = merge({}, initialState, {
});
const expectedPayload = {
genomeIds: selectedSpecies,
species: selectedSpecies,
querySequences: sequences.map((seq) => toFasta(seq)),
parameters: {
title: jobName,
......
......@@ -15,27 +15,33 @@
*/
import React from 'react';
import { useSelector } from 'react-redux';
import config from 'config';
import { useAppDispatch, useAppSelector } from 'src/store';
import { submitBlast } from 'src/content/app/tools/blast/state/blast-api/blastApiSlice';
import LoadingButton from 'src/shared/components/loading-button/LoadingButton';
import useBlastInputSequences from 'src/content/app/tools/blast/components/blast-input-sequences/useBlastInputSequences';
import { getSelectedSpeciesIds } from 'src/content/app/tools/blast/state/blast-form/blastFormSelectors';
import { isBlastFormValid } from 'src/content/app/tools/blast/utils/blastFormValidator';
import { getBlastFormData } from '../../state/blast-form/blastFormSelectors';
import { getBlastFormData } from 'src/content/app/tools/blast/state/blast-form/blastFormSelectors';
import { getSelectedSpeciesIds } from 'src/content/app/tools/blast/state/blast-form/blastFormSelectors';
import { toFasta } from 'src/shared/helpers/formatters/fastaFormatter';
import { BlastFormState } from 'src/content/app/tools/blast/state/blast-form/blastFormSlice';
import {
import type {
Species,
BlastFormState
} from 'src/content/app/tools/blast/state/blast-form/blastFormSlice';
import type {
BlastParameterName,
SequenceType
} from 'src/content/app/tools/blast/types/blastSettings';
import type { BlastSubmission } from 'src/content/app/tools/blast/state/blast-results/blastResultsSlice';
export type PayloadParams = {
genomeIds: string[];
species: Species[];
querySequences: string[];
parameters: Partial<Record<BlastParameterName, string>> & {
title: string;
......@@ -45,28 +51,33 @@ export type PayloadParams = {
type BlastSubmissionResponse = {
submissionId: string;
jobs: Array<{
jobId?: string;
error?: string;
}>;
submission: BlastSubmission;
};
const BlastJobSubmit = () => {
const { sequences } = useBlastInputSequences();
const selectedSpecies = useSelector(getSelectedSpeciesIds);
const selectedSpeciesIds = useAppSelector(getSelectedSpeciesIds);
const dispatch = useAppDispatch();
const isDisabled = !isBlastFormValid(selectedSpecies, sequences);
const isDisabled = !isBlastFormValid(selectedSpeciesIds, sequences);
const blastFormData = useSelector(getBlastFormData);
const blastFormData = useAppSelector(getBlastFormData);
const onBlastSubmit = () => {
const onBlastSubmit = async () => {
const payload = createBlastSubmissionData(blastFormData);
return submitBlastForm(payload);
const submission = dispatch(submitBlast.initiate(payload));
submission.then((response) => {
submission.reset(); // prevent indefinite caching of subscription result
if ('data' in response) {
onSubmitSuccess(response.data);
}
});
};
const onSubmitSuccess = (response: BlastSubmissionResponse) => {
// TODO: change the temporary implementation of this function with a more permanent one
const firstJobId = response.jobs[0].jobId;
const firstJobId = response.submission.results[0].jobId;
if (!firstJobId) {
return;
}
......@@ -80,11 +91,7 @@ const BlastJobSubmit = () => {
};
return (
<LoadingButton
onClick={onBlastSubmit}
onSuccess={onSubmitSuccess as any}
isDisabled={isDisabled}
>
<LoadingButton onClick={onBlastSubmit} isDisabled={isDisabled}>
Run
</LoadingButton>
);
......@@ -98,7 +105,7 @@ export const createBlastSubmissionData = (
);
return {
genomeIds: blastFormData.selectedSpecies,
species: blastFormData.selectedSpecies,
querySequences: sequences,
parameters: {
title: blastFormData.settings.jobName,
......@@ -110,24 +117,4 @@ export const createBlastSubmissionData = (
};
};
const submitBlastForm = async (
payload: ReturnType<typeof createBlastSubmissionData>
) => {
const endpointURL = `${config.toolsApiBaseUrl}/blast/job`;
const response = await fetch(endpointURL, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (response.ok) {
return response.json();
} else {
throw new Error();
}
};
export default BlastJobSubmit;
......@@ -25,7 +25,9 @@ import blastFormReducer, {
type BlastFormState
} from 'src/content/app/tools/blast/state/blast-form/blastFormSlice';
import SpeciesSelector from './BlastSpeciesSelector';
import BlastSpeciesSelector from './BlastSpeciesSelector';
import speciesList from './speciesList';
const renderComponent = (
{
......@@ -51,7 +53,7 @@ const renderComponent = (
const renderResult = render(
<Provider store={store}>
<SpeciesSelector />
<BlastSpeciesSelector />
</Provider>
);
......@@ -62,22 +64,20 @@ const renderComponent = (
};
describe('SpeciesSelector', () => {
describe('species selection', () => {
it('updates the selectedSpecies state', () => {
const { container, store } = renderComponent();
it('updates the selectedSpecies state', () => {
const { container, store } = renderComponent();
const speciesCheckbox = container.querySelector(
'tbody tr [data-test-id="checkbox"]'
) as HTMLElement;
const speciesCheckbox = container.querySelector(
'tbody tr [data-test-id="checkbox"]'
) as HTMLElement;
userEvent.click(speciesCheckbox);
userEvent.click(speciesCheckbox);
const updatedState = store.getState();
const updatedState = store.getState();
expect(updatedState.blast.blastForm.selectedSpecies.length).toBe(1);
expect(updatedState.blast.blastForm.selectedSpecies[0]).toEqual(
'homo_sapiens_GCA_000001405_14'
);
});
expect(updatedState.blast.blastForm.selectedSpecies.length).toBe(1);
expect(updatedState.blast.blastForm.selectedSpecies[0]).toEqual(
speciesList[0]
);
});
});
......@@ -21,23 +21,28 @@ import {
addSelectedSpecies,
removeSelectedSpecies
} from 'src/content/app/tools/blast/state/blast-form/blastFormSlice';
import { getSelectedSpeciesIds } from 'src/content/app/tools/blast/state/blast-form/blastFormSelectors';
import { getSelectedSpeciesList } from 'src/content/app/tools/blast/state/blast-form/blastFormSelectors';
import Checkbox from 'src/shared/components/checkbox/Checkbox';
import SpeciesList from './SpeciesList';
import speciesList from './speciesList';
import type { Species } from 'src/content/app/tools/blast/state/blast-form/blastFormSlice';
import styles from './BlastSpeciesSelector.scss';
const BlastSpeciesSelector = () => {
const dispatch = useDispatch();
const selectedSpecies = useSelector(getSelectedSpeciesIds);
const selectedSpeciesList = useSelector(getSelectedSpeciesList);
const selectedGenomeIds = selectedSpeciesList.map(
({ genome_id }) => genome_id
);
const onSpeciesSelection = (isChecked: boolean, genomeId: string) => {
const onSpeciesSelection = (isChecked: boolean, species: Species) => {
if (isChecked) {
dispatch(addSelectedSpecies({ genomeId }));
dispatch(addSelectedSpecies(species));
} else {
dispatch(removeSelectedSpecies({ genomeId }));
dispatch(removeSelectedSpecies(species.genome_id));
}
};
......@@ -53,19 +58,19 @@ const BlastSpeciesSelector = () => {
</tr>
</thead>
<tbody>
{SpeciesList.map((item, index) => {
{speciesList.map((species, index) => {
return (
<tr key={index}>
<td>{item.common_name ? item.common_name : '-'}</td>
<td>{species.common_name ?? '-'}</td>
<td className={styles.scientificNameCol}>
{item.scientific_name}
{species.scientific_name}
</td>
<td className={styles.assemblyCol}>{item.assembly_name}</td>
<td className={styles.assemblyCol}>{species.assembly_name}</td>
<td className={styles.selectCol}>
<Checkbox
checked={selectedSpecies.includes(item.genome_id)}
checked={selectedGenomeIds.includes(species.genome_id)}
onChange={(isChecked) =>
onSpeciesSelection(isChecked, item.genome_id)
onSpeciesSelection(isChecked, species)
}
/>
</td>
......
......@@ -70,6 +70,20 @@ const renderComponent = (
};
};
const selectedHuman = {
genome_id: 'human-genome-id',
common_name: 'Human',
scientific_name: 'Homo sapiens',
assembly_name: 'GRCh38'
};
const selectedMouse = {
genome_id: 'mouse-genome-id',
common_name: 'Mouse',
scientific_name: 'Mus musculus',
assembly_name: 'GRCm39'
};
describe('SpeciesSelectorHeader', () => {
describe('species counter', () => {
it('starts with 0', () => {
......@@ -79,7 +93,7 @@ describe('SpeciesSelectorHeader', () => {
});
it('displays the number of added species', () => {
const selectedSpecies = ['human', 'mouse'];
const selectedSpecies = [selectedHuman, selectedMouse];
const { container } = renderComponent({ state: { selectedSpecies } });
const speciesCounter = container.querySelector('.header .speciesCounter');
expect(getNodeText(speciesCounter as HTMLElement)).toBe(
......@@ -90,7 +104,7 @@ describe('SpeciesSelectorHeader', () => {
describe('clear all control', () => {
it('clears all selected species', () => {
const selectedSpecies = ['human', 'mouse'];
const selectedSpecies = [selectedHuman, selectedMouse];
const { container, store } = renderComponent({
state: { selectedSpecies }
});
......
......@@ -21,7 +21,7 @@ import {
switchToSequencesStep,
clearSelectedSpecies
} from 'src/content/app/tools/blast/state/blast-form/blastFormSlice';
import { getSelectedSpeciesIds } from 'src/content/app/tools/blast//state/blast-form/blastFormSelectors';
import { getSelectedSpeciesList } from 'src/content/app/tools/blast/state/blast-form/blastFormSelectors';
import { SecondaryButton } from 'src/shared/components/button/Button';
......@@ -35,7 +35,7 @@ const BlastSpeciesSelectorHeader = (props: Props) => {
const { compact } = props;
const dispatch = useDispatch();
const selectedSpecies = useSelector(getSelectedSpeciesIds);
const selectedSpeciesList = useSelector(getSelectedSpeciesList);
const onSwitchToSequence = () => {
dispatch(switchToSequencesStep());
......@@ -49,7 +49,9 @@ const BlastSpeciesSelectorHeader = (props: Props) => {
<div className={styles.header}>
<div className={styles.headerGroup}>
<span className={styles.headerTitle}>Blast against</span>
<span className={styles.speciesCounter}>{selectedSpecies.length}</span>
<span className={styles.speciesCounter}>
{selectedSpeciesList.length}
</span>
<span className={styles.maxSpecies}>of 7 species</span>
{compact && (
<span className={styles.clearAll} onClick={onClearAll}>
......
......@@ -16,7 +16,7 @@
//TODO: hardcoding species list in this file, Once we get it from the API (when we implement search or add more species), this file can be deleted
// Check if we need the sorting and which field we need to submit a job
const SpeciesList = [
const speciesList = [
{
assembly_name: 'GRCh38.p13',
common_name: 'Human',
......@@ -63,7 +63,7 @@ const SpeciesList = [
];
// species with common name first and sort alphabetically by common name
// If no common name, then sort by scientific name alphabetically
SpeciesList.sort((a, b) => {
speciesList.sort((a, b) => {
if (a.common_name) {
if (a.common_name && b.common_name) {
return a.common_name > b.common_name ? 1 : -1;
......@@ -77,4 +77,4 @@ SpeciesList.sort((a, b) => {
}
});
export default SpeciesList;
export default speciesList;
/**
* 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 IndexedDB from 'src/services/indexeddb-service';
import type {
BlastSubmission,
BlastJob
} from '../state/blast-results/blastResultsSlice';
const STORE_NAME = 'blast-submissions';
export const saveBlastSubmission = async (
submissionId: string,
submission: BlastSubmission
) => {
await IndexedDB.set(STORE_NAME, submissionId, submission);
};
export const getAllBlastSubmissions = async (): Promise<
Record<string, BlastSubmission>
> => {
const submissionIds = (await IndexedDB.keys(STORE_NAME)) as string[];
const submissions = await Promise.all(
submissionIds.map((id) => getBlastSubmission(id))
);
return submissionIds.reduce((obj, id, index) => {
return {
...obj,
[id]: submissions[index]
};
}, {} as Record<string, BlastSubmission>);
};
export const getBlastSubmission = async (
submissionId: string
): Promise<BlastSubmission> => {
return await IndexedDB.get(STORE_NAME, submissionId);
};
export const updateSavedBlastJob = async (params: {
submissionId: string;
jobId: string;
fragment: Partial<BlastJob>;
}) => {
const { submissionId, jobId, fragment } = params;
const submission = await getBlastSubmission(submissionId);
const job = submission.results.find((job) => job.jobId === jobId);
Object.assign(job, fragment); // this will mutate the object
await saveBlastSubmission(submissionId, submission);
};
export const deleteBlastSubmission = async (submissionId: string) => {
await IndexedDB.delete(STORE_NAME, submissionId);
};
......@@ -19,6 +19,22 @@ import config from 'config';
import restApiSlice from 'src/shared/state/api-slices/restSlice';
import type { BlastSettingsConfig } from 'src/content/app/tools/blast/types/blastSettings';
import type { Species } from 'src/content/app/tools/blast/state/blast-form/blastFormSlice';
import type { BlastSubmission } from '../blast-results/blastResultsSlice';
export type BlastSubmissionPayload = {
species: Species[];
querySequences: string[];
parameters: Record<string, string>;
};
export type BlastSubmissionResponse = {
submissionId: string;
jobs: Array<{
jobId?: string;
error?: string;
}>;
};
const blastApiSlice = restApiSlice.injectEndpoints({
endpoints: (builder) => ({
......@@ -27,8 +43,48 @@ const blastApiSlice = restApiSlice.injectEndpoints({
url: `${config.toolsApiBaseUrl}/blast/config`
}),
keepUnusedDataFor: 60 * 60 // one hour
}),
submitBlast: builder.mutation<
{ submissionId: string; submission: BlastSubmission },
BlastSubmissionPayload
>({
query(payload) {
const body = {
genomeIds: payload.species.map(({ genome_id }) => genome_id),
querySequences: payload.querySequences,
parameters: payload.parameters
};
return {
url: `${config.toolsApiBaseUrl}/blast/job`,
method: 'POST',
body
};
},
transformResponse(response: BlastSubmissionResponse, _, payload) {
const { submissionId, jobs } = response;
// TODO: decide what to do when a submission returns error jobs
const results = jobs.map((job) => ({
jobId: job.jobId as string,
status: 'RUNNING',
seen: false,
data: null
}));
return {
submissionId,
submission: {
submittedData: {
species: payload.species,
sequences: payload.querySequences,
parameters: payload.parameters
},
results,
submittedAt: Date.now()
} as BlastSubmission
};
}
})
})
});
export const { useBlastConfigQuery } = blastApiSlice;
export const { submitBlast } = blastApiSlice.endpoints;
......@@ -14,6 +14,8 @@
* limitations under the License.
*/
import { createSelector } from 'reselect';
import { RootState } from 'src/store';
export const getSequences = (state: RootState) =>
......@@ -45,7 +47,12 @@ export const getBlastJobName = (state: RootState) =>
export const getStep = (state: RootState) => state.blast.blastForm.step;
export const getSelectedSpeciesIds = (state: RootState) =>
export const getSelectedSpeciesList = (state: RootState) =>
state.blast.blastForm.selectedSpecies;
export const getSelectedSpeciesIds = createSelector(
getSelectedSpeciesList,
(speciesList) => speciesList.map(({ genome_id }) => genome_id)
);
export const getBlastFormData = (state: RootState) => state.blast.blastForm;
......@@ -34,12 +34,19 @@ type BlastFormSettings = {
parameters: Partial<Record<BlastParameterName, string>>;
};
export type Species = {
genome_id: string;
common_name: string | null;
scientific_name: string;
assembly_name: string;
};
export type BlastFormState = {
step: 'sequences' | 'species'; // will only be relevant on smaller screens
sequences: ParsedInputSequence[];
shouldAppendEmptyInput: boolean;
hasUncommittedSequence: boolean;
selectedSpecies: string[];
selectedSpecies: Species[];
settings: BlastFormSettings;
};
......@@ -101,14 +108,14 @@ const blastFormSlice = createSlice({
const { sequences } = action.payload;