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

Add automatic switching for database selector (#748)

- Move all code that is responsible for blast state update into the blast slice.
- Add tests for blastSlice
- Add useBlastForm hook (and remove the useBlastInputSequences hook)
- Add the BlastSequenceButton component
parent 7717956c
Pipeline #282139 passed with stages
in 9 minutes and 25 seconds
......@@ -27,6 +27,10 @@
grid-area: aside-top;
}
.blastSequenceButton {
margin: 0 0 0 25px;
}
.sequence {
grid-area: main-bottom;
margin-top: 10px;
......@@ -44,11 +48,13 @@
.sequenceTypeSelection {
margin-top: 50px;
--radio-label-font-weight: #{$light};
}
.reverseComplement {
margin-top: 43px;
white-space: nowrap;
--checkbox-label-font-weight: #{$light};
}
.showHide {
......
......@@ -17,8 +17,15 @@
import React, { useState, useMemo, useEffect } from 'react';
import classNames from 'classnames';
import { useAppSelector } from 'src/store';
import { getReverseComplement } from 'src/shared/helpers/sequenceHelpers';
import { Environment, isEnvironment } from 'src/shared/helpers/environment';
import { getCommittedSpeciesById } from 'src/content/app/species-selector/state/speciesSelectorSelectors';
import BlastSequenceButton from 'src/shared/components/blast-sequence-button/BlastSequenceButton';
import RadioGroup from 'src/shared/components/radio-group/RadioGroup';
import Checkbox from 'src/shared/components/checkbox/Checkbox';
import ShowHide from 'src/shared/components/show-hide/ShowHide';
......@@ -26,6 +33,7 @@ import { PrimaryButton } from 'src/shared/components/button/Button';
import { CircleLoader } from 'src/shared/components/loader';
import type { SequenceType } from 'src/content/app/genome-browser/state/drawer/drawer-sequence/drawerSequenceSlice';
import type { CommittedItem } from 'src/content/app/species-selector/types/species-search';
import styles from './DrawerSequenceView.scss';
......@@ -38,6 +46,7 @@ const sequenceLabelsMap: Record<SequenceType, string> = {
// TODO: we probably also want to pass a sequence header in order to be able to blast it
type Props = {
genomeId: string;
isExpanded: boolean;
toggleSequenceVisibility: () => void;
sequence?: string;
......@@ -53,6 +62,7 @@ type Props = {
const DrawerSequenceView = (props: Props) => {
const {
genomeId,
isExpanded,
isError,
isLoading,
......@@ -66,6 +76,14 @@ const DrawerSequenceView = (props: Props) => {
onReverseComplementChange
} = props;
const species = useAppSelector((state) =>
getCommittedSpeciesById(state, genomeId)
) as CommittedItem;
// BLAST has a different labelling for sequence types than what is passed with props
const sequenceTypeForBlast =
selectedSequenceType === 'protein' ? 'protein' : 'dna';
const sequenceTypeOptions = sequenceTypes.map((sequenceType) => ({
value: sequenceType,
label: sequenceLabelsMap[sequenceType]
......@@ -98,12 +116,16 @@ const DrawerSequenceView = (props: Props) => {
)}
{isLoading && <Loading />}
{isError && <LoadFailure refetch={refetch} />}
{/* The BLAST button will go here when ready
<div className={styles.asideTop}>
BLAST BUTTON HERE!
</div>
*/}
{!isEnvironment([Environment.PRODUCTION]) && (
<div className={styles.asideTop}>
<BlastSequenceButton
className={styles.blastSequenceButton}
sequence={sequence}
species={species}
sequenceType={sequenceTypeForBlast}
/>
</div>
)}
<div className={styles.asideBottom}>
<div className={styles.sequenceTypeSelection}>
<RadioGroup
......
......@@ -74,6 +74,7 @@ export const GeneSequenceView = (props: Props) => {
return (
<DrawerSequenceView
genomeId={genomeId}
isExpanded={isExpanded}
isError={isError}
isLoading={isFetching}
......
......@@ -37,6 +37,12 @@ import TranscriptSequenceView, { type Props } from './TranscriptSequenceView';
jest.mock('config', () => ({
refgetBaseUrl: 'http://refget-api' // need to provide absolute urls to the fetch running in Node
}));
jest.mock(
'src/shared/components/blast-sequence-button/BlastSequenceButton',
() => () => {
return <button className="blast-sequence-button" />;
}
);
const renderTranscriptSequenceView = (props: Props) => {
const genomeId = 'human';
......
......@@ -81,6 +81,7 @@ const TranscriptSequenceView = (props: Props) => {
return (
<DrawerSequenceView
genomeId={genomeId}
isExpanded={isExpanded}
isError={isError}
isLoading={isFetching}
......
......@@ -19,7 +19,7 @@ import { useSelector } from 'react-redux';
import { getEmptyInputVisibility } from 'src/content/app/tools/blast/state/blast-form/blastFormSelectors';
import useBlastInputSequences from './useBlastInputSequences';
import useBlastForm from 'src/content/app/tools/blast/hooks/useBlastForm';
import { parseBlastInput } from 'src/content/app/tools/blast/utils/blastInputParser';
......@@ -34,7 +34,7 @@ const BlastInputSequences = () => {
updateSequences,
appendEmptyInputBox,
setUncommittedSequencePresence
} = useBlastInputSequences();
} = useBlastForm();
const shouldAppendEmptyInput = useSelector(getEmptyInputVisibility);
const onSequenceAdded = (input: string, index: number | null) => {
......
......@@ -25,7 +25,7 @@ import {
import { updateEmptyInputDisplay } from 'src/content/app/tools/blast/state/blast-form/blastFormSlice';
import useBlastInputSequences from './useBlastInputSequences';
import useBlastForm from 'src/content/app/tools/blast/hooks/useBlastForm';
import PlusButton from 'src/shared/components/plus-button/PlusButton';
import RadioGroup from 'src/shared/components/radio-group/RadioGroup';
......@@ -44,7 +44,7 @@ export type Props = {
const BlastInputSequencesHeader = (props: Props) => {
const { compact } = props;
const { sequences, sequenceType, updateSequenceType, clearAllSequences } =
useBlastInputSequences();
useBlastForm();
const isEmptyInputAppended = useSelector(getEmptyInputVisibility);
const isUserTypingInEmptyInput = useSelector(getUncommittedSequencePresence);
......@@ -58,6 +58,10 @@ const BlastInputSequencesHeader = (props: Props) => {
setTimeout(() => scrollToLastInputBox(), 0);
};
const onSequenceTypeChange = (sequenceType: SequenceType) => {
updateSequenceType({ sequenceType });
};
const scrollToLastInputBox = () => {
const lastInputBox = document.querySelector(
`.${sequenceBoxStyles.inputSequenceBox}:last-child`
......@@ -93,7 +97,7 @@ const BlastInputSequencesHeader = (props: Props) => {
</div>
<SequenceSwitcher
sequenceType={sequenceType}
onChange={updateSequenceType}
onChange={onSequenceTypeChange}
/>
<div className={styles.headerGroup}>
<span className={styles.clearAll} onClick={clearAllSequences}>
......
/**
* 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 useBlastInputSequences from './useBlastInputSequences';
import { Provider } from 'react-redux';
import { configureStore, combineReducers } from '@reduxjs/toolkit';
import { renderHook, act } from '@testing-library/react';
import mockBlastSettingsConfig from 'tests/fixtures/blast/blastSettingsConfig.json';
import {
getSequences,
getSelectedSequenceType,
getSequenceSelectionMode
} from 'src/content/app/tools/blast/state/blast-form/blastFormSelectors';
import blastFormReducer, {
initialState as initialBlastFormState,
type BlastFormState
} from 'src/content/app/tools/blast/state/blast-form/blastFormSlice';
jest.mock('src/content/app/tools/blast/state/blast-api/blastApiSlice', () => {
return {
useBlastConfigQuery: () => ({ data: mockBlastSettingsConfig })
};
});
const rootReducer = combineReducers({
blast: combineReducers({
blastForm: blastFormReducer
})
});
const getWrapper = (
{ state }: { state?: Partial<BlastFormState> } = { state: {} }
) => {
const blastFormState = Object.assign({}, initialBlastFormState, state);
const initialState = {
blast: { blastForm: blastFormState }
};
const store = configureStore({
reducer: rootReducer,
preloadedState: initialState
});
const wrapper = ({ children }: { children: ReactNode }) => (
<Provider store={store}>{children}</Provider>
);
return {
wrapper,
store
};
};
describe('useBlastInputSequences', () => {
it('returns sequences from redux slice', () => {
const sequences = [{ header: 'foo', value: 'AAA' }];
const { wrapper } = getWrapper({
state: {
sequences
}
});
const { result } = renderHook(() => useBlastInputSequences(), { wrapper });
expect(result.current.sequences).toEqual(sequences);
});
it('returns sequence type from redux slice', () => {
const { wrapper } = getWrapper();
const { result } = renderHook(() => useBlastInputSequences(), { wrapper });
expect(result.current.sequenceType).toEqual('dna');
});
describe('updateSequences', () => {
it('replaces sequences in the redux store', () => {
const sequences = [{ header: 'foo', value: 'AAA' }];
const { wrapper, store } = getWrapper({
state: {
sequences
}
});
const { result } = renderHook(() => useBlastInputSequences(), {
wrapper
});
const { updateSequences } = result.current;
const newSequences = [
{ header: 'bar', value: 'ACTG' },
{ header: 'baz', value: 'GUAC' }
];
act(() => {
updateSequences(newSequences);
});
expect(getSequences(store.getState() as any)).toEqual(newSequences);
});
it('updates sequence type while updating the sequences', () => {
const proteinSequence =
'MENLNMDLLYMAAAVMMGLAAIGAAIGIGILGGKFLEGAARQPDLIPLLRTQFFIVMGLVDAIPMIAVGL';
const nucleotideSequence =
'CGGACCAGACGGACACAGGGAGAAGCTAGTTTCTTTCATGTGATTGANATNATGACTCTACTCCTAAAAG';
const { wrapper, store } = getWrapper();
const { result } = renderHook(() => useBlastInputSequences(), {
wrapper
});
const { updateSequences } = result.current;
let newSequences = [
{ value: proteinSequence },
{ value: nucleotideSequence }
];
act(() => {
updateSequences(newSequences);
});
expect(getSelectedSequenceType(store.getState() as any)).toEqual(
'protein'
);
newSequences = [
{ value: nucleotideSequence },
{ value: proteinSequence }
];
act(() => {
updateSequences(newSequences);
});
expect(getSelectedSequenceType(store.getState() as any)).toEqual('dna');
});
it('resets sequence type to dna if sequences are deleted', () => {
const proteinSequence =
'MENLNMDLLYMAAAVMMGLAAIGAAIGIGILGGKFLEGAARQPDLIPLLRTQFFIVMGLVDAIPMIAVGL';
// PART 1. With automatically guessed sequence type
const { wrapper: wrapper1, store: store1 } = getWrapper();
const { result: result1 } = renderHook(() => useBlastInputSequences(), {
wrapper: wrapper1
});
let updateSequences = result1.current.updateSequences;
act(() => {
updateSequences([{ value: proteinSequence }]);
});
expect(getSelectedSequenceType(store1.getState() as any)).toEqual(
'protein'
);
act(() => {
updateSequences([]);
});
expect(getSelectedSequenceType(store1.getState() as any)).toEqual('dna');
// PART 2. With manually set sequence type
const { wrapper: wrapper2, store: store2 } = getWrapper({
state: {
sequences: [{ header: 'foo', value: 'MENLNMDL' }],
settings: {
...initialBlastFormState.settings,
sequenceSelectionMode: 'manual',
sequenceType: 'protein'
}
}
});
const { result: result2 } = renderHook(() => useBlastInputSequences(), {
wrapper: wrapper2
});
updateSequences = result2.current.updateSequences;
act(() => {
updateSequences([]);
});
expect(getSelectedSequenceType(store2.getState() as any)).toEqual('dna'); // sequence type reset to initial
});
it('does not change sequence type if user has changed sequence type manually', () => {
const proteinSequence =
'MENLNMDLLYMAAAVMMGLAAIGAAIGIGILGGKFLEGAARQPDLIPLLRTQFFIVMGLVDAIPMIAVGL';
const { wrapper, store } = getWrapper({
state: {
settings: {
...initialBlastFormState.settings,
sequenceSelectionMode: 'manual'
}
}
});
const { result } = renderHook(() => useBlastInputSequences(), {
wrapper
});
const { updateSequences } = result.current;
expect(getSelectedSequenceType(store.getState() as any)).toEqual('dna'); // initial value
act(() => {
updateSequences([{ value: proteinSequence }]);
});
expect(getSelectedSequenceType(store.getState() as any)).toEqual('dna'); // sequence type should not have changed
});
});
describe('updateSequenceType', () => {
it('changes sequence type and sets the change type to manual', () => {
const { wrapper, store } = getWrapper();
const { result } = renderHook(() => useBlastInputSequences(), {
wrapper
});
// initial values
expect(getSelectedSequenceType(store.getState() as any)).toEqual('dna');
expect(getSequenceSelectionMode(store.getState() as any)).toEqual(
'automatic'
);
const { updateSequenceType } = result.current;
act(() => {
updateSequenceType('protein');
});
expect(getSelectedSequenceType(store.getState() as any)).toEqual(
'protein'
);
expect(getSequenceSelectionMode(store.getState() as any)).toEqual(
'manual'
);
});
});
describe('clearAllSequences', () => {
it('clears sequences and sets the change type to automatic', () => {
const { wrapper, store } = getWrapper({
state: {
sequences: [{ header: 'foo', value: 'MENLNMDL' }],
settings: {
...initialBlastFormState.settings,
sequenceSelectionMode: 'manual',
sequenceType: 'protein'
}
}
});
const { result } = renderHook(() => useBlastInputSequences(), {
wrapper
});
const { clearAllSequences } = result.current;
act(() => {
clearAllSequences();
});
expect(getSequences(store.getState() as any)).toEqual([]);
expect(getSelectedSequenceType(store.getState() as any)).toEqual('dna');
expect(getSequenceSelectionMode(store.getState() as any)).toEqual(
'automatic'
);
});
});
});
......@@ -22,7 +22,7 @@ import * as urlFor from 'src/shared/helpers/urlHelper';
import { useAppSelector } from 'src/store';
import { useSubmitBlastMutation } from 'src/content/app/tools/blast/state/blast-api/blastApiSlice';
import useBlastInputSequences from 'src/content/app/tools/blast/components/blast-input-sequences/useBlastInputSequences';
import useBlastForm from 'src/content/app/tools/blast/hooks/useBlastForm';
import { isBlastFormValid } from 'src/content/app/tools/blast/utils/blastFormValidator';
import { getBlastFormData } from 'src/content/app/tools/blast/state/blast-form/blastFormSelectors';
......@@ -51,7 +51,7 @@ export type PayloadParams = {
};
const BlastJobSubmit = () => {
const { sequences } = useBlastInputSequences();
const { sequences } = useBlastForm();
const selectedSpeciesIds = useAppSelector(getSelectedSpeciesIds);
const [submitBlast] = useSubmitBlastMutation({
// Using a fixed cache key means that any subsequent request
......
......@@ -16,10 +16,13 @@
import React, { FormEvent, useEffect, useState } from 'react';
import classNames from 'classnames';
import { useSelector, useDispatch } from 'react-redux';
import { useAppSelector } from 'src/store';
import * as urlFor from 'src/shared/helpers/urlHelper';
import useBlastForm from 'src/content/app/tools/blast/hooks/useBlastForm';
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';
......@@ -34,13 +37,6 @@ import {
getBlastSearchParameters,
getBlastJobName
} from 'src/content/app/tools/blast/state/blast-form/blastFormSelectors';
import {
setBlastDatabase,
setBlastProgram,
changeSensitivityPresets,
setBlastParameter,
setBlastJobName
} from 'src/content/app/tools/blast/state/blast-form/blastFormSlice';
import type {
BlastProgram,
......@@ -84,56 +80,50 @@ type Props = {
const BlastSettings = ({ config }: Props) => {
const [parametersExpanded, setParametersExpanded] = useState(false);
const sequenceType = useSelector(getSelectedSequenceType);
const blastProgram = useSelector(getSelectedBlastProgram);
const searchSensitivity = useSelector(getSelectedSearchSensitivity);
const blastParameters = useSelector(getBlastSearchParameters);
const dispatch = useDispatch();
const sequenceType = useAppSelector(getSelectedSequenceType);
const blastProgram = useAppSelector(getSelectedBlastProgram);
const searchSensitivity = useAppSelector(getSelectedSearchSensitivity);
const blastParameters = useAppSelector(getBlastSearchParameters);
const {
updateBlastDatabase,
updateBlastProgram,
updateSensitivityPresets,
setBlastParameter
} = useBlastForm();
useEffect(() => {
if (!blastParameters.database) {
const defaultDatabase = config.defaults.database;
onDatabaseChange(defaultDatabase);
onDatabaseChange(defaultDatabase, { isAutomatic: true });
}
}, []);
const onDatabaseChange = (database: string) => {
dispatch(
setBlastDatabase({
database,
config
})
);
const onDatabaseChange = (
database: string,
options: { isAutomatic?: boolean } = {}
) => {
updateBlastDatabase({
database,
isAutomatic: options.isAutomatic
});
};
const onBlastProgramChange = (program: string) => {
dispatch(
setBlastProgram({
program: program as BlastProgram,
config
})