diff --git a/packages_rs/nextclade-web/src/components/Main/DatasetSelector.tsx b/packages_rs/nextclade-web/src/components/Main/DatasetSelector.tsx index ca7f290b4..950900815 100644 --- a/packages_rs/nextclade-web/src/components/Main/DatasetSelector.tsx +++ b/packages_rs/nextclade-web/src/components/Main/DatasetSelector.tsx @@ -20,9 +20,11 @@ export function DatasetSelector() { {t('Select dataset')} - + + + {!isBusy && ( @@ -42,10 +44,6 @@ export function DatasetSelector() { )} - - ) } @@ -62,7 +60,6 @@ const Container = styled.div` const Header = styled.div` display: flex; flex: 0; - padding-left: 10px; margin-top: 10px; margin-bottom: 3px; ` @@ -74,11 +71,6 @@ const Main = styled.div` overflow: hidden; ` -const Footer = styled.div` - display: flex; - flex: 0; -` - const Title = styled.h4` flex: 1; margin: auto 0; diff --git a/packages_rs/nextclade-web/src/components/Main/DatasetSelectorList.tsx b/packages_rs/nextclade-web/src/components/Main/DatasetSelectorList.tsx index ec5fd62d5..aab4ab43e 100644 --- a/packages_rs/nextclade-web/src/components/Main/DatasetSelectorList.tsx +++ b/packages_rs/nextclade-web/src/components/Main/DatasetSelectorList.tsx @@ -1,16 +1,26 @@ import { get, isNil, sortBy } from 'lodash' import { lighten } from 'polished' import React, { forwardRef, useCallback, useEffect, useMemo, useRef } from 'react' -import { useRecoilState, useRecoilValue } from 'recoil' +import { Button } from 'reactstrap' +import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil' import { ListGenericCss } from 'src/components/Common/List' import { DatasetInfo } from 'src/components/Main/DatasetInfo' import { search } from 'src/helpers/search' +import { useTranslationSafe } from 'src/helpers/useTranslationSafe' +import { useRunAnalysis } from 'src/hooks/useRunAnalysis' +import { useRunSeqAutodetect } from 'src/hooks/useRunSeqAutodetect' +import { AlgorithmInputDefault } from 'src/io/AlgorithmInput' import { autodetectResultsAtom, AutodetectRunState, autodetectRunStateAtom, groupByDatasets, } from 'src/state/autodetect.state' +import { datasetCurrentAtom } from 'src/state/dataset.state' +import { hasInputErrorsAtom } from 'src/state/error.state' +import { hasRequiredInputsAtom, useQuerySeqInputs } from 'src/state/inputs.state' +import { canRunAtom } from 'src/state/results.state' +import { shouldRunAutomaticallyAtom, shouldSuggestDatasetsAtom } from 'src/state/settings.state' import type { Dataset } from 'src/types' import { areDatasetsEqual } from 'src/types' import styled from 'styled-components' @@ -29,7 +39,12 @@ export function DatasetSelectorList({ datasetHighlighted, onDatasetHighlighted, }: DatasetSelectorListProps) { - const onItemClick = useCallback((dataset: Dataset) => () => onDatasetHighlighted(dataset), [onDatasetHighlighted]) + const onItemClick = useCallback( + (_dataset: Dataset) => () => { + /* onDatasetHighlighted(dataset) */ + }, + [], + ) const autodetectResults = useRecoilValue(autodetectResultsAtom) const [autodetectRunState, setAutodetectRunState] = useRecoilState(autodetectRunStateAtom) @@ -73,24 +88,10 @@ export function DatasetSelectorList({ const itemsRef = useRef>(new Map()) - function scrollToId(itemId: string) { - const node = itemsRef.current.get(itemId) - node?.scrollIntoView({ - behavior: 'smooth', - block: 'nearest', - inline: 'center', - }) - } - - if (datasetHighlighted) { - scrollToId(datasetHighlighted.path) - } - useEffect(() => { const topSuggestion = autodetectResult.itemsInclude[0] if (autodetectRunState === AutodetectRunState.Done) { onDatasetHighlighted(topSuggestion) - setAutodetectRunState(AutodetectRunState.Idle) } }, [autodetectRunState, autodetectResult.itemsInclude, onDatasetHighlighted, setAutodetectRunState]) @@ -145,12 +146,12 @@ export const Ul = styled.ul` ${ListGenericCss}; flex: 1; overflow: auto; - padding: 5px 5px; border-radius: 0 !important; ` export const Li = styled.li<{ $active?: boolean; $isDimmed?: boolean }>` - cursor: pointer; + position: relative; + opacity: ${(props) => props.$isDimmed && 0.4}; background-color: transparent; @@ -176,11 +177,81 @@ interface DatasetSelectorListItemProps { } const DatasetSelectorListItem = forwardRef( - function DatasetSelectorListItemWithRef({ dataset, isCurrent, isDimmed, onClick }, ref) { + function DatasetSelectorListItemWithRef({ dataset, isDimmed, onClick }, ref) { + const { t } = useTranslationSafe() + + const setDatasetCurrent = useSetRecoilState(datasetCurrentAtom) + const shouldRunAutomatically = useRecoilValue(shouldRunAutomaticallyAtom) + const shouldSuggestDatasets = useRecoilValue(shouldSuggestDatasetsAtom) + const { addQryInputs } = useQuerySeqInputs() + const canRun = useRecoilValue(canRunAtom) + const hasRequiredInputs = useRecoilValue(hasRequiredInputsAtom) + const hasInputErrors = useRecoilValue(hasInputErrorsAtom) + + const runAnalysis = useRunAnalysis() + const runSuggestions = useRunSeqAutodetect() + const run = useCallback(() => { + setDatasetCurrent(dataset) + runAnalysis() + }, [dataset, runAnalysis, setDatasetCurrent]) + + const { isRunButtonDisabled, runButtonColor, runButtonTooltip } = useMemo(() => { + const isRunButtonDisabled = !(canRun && hasRequiredInputs) || hasInputErrors + return { + isRunButtonDisabled, + runButtonColor: isRunButtonDisabled ? 'secondary' : 'success', + runButtonTooltip: isRunButtonDisabled + ? t('Please provide sequence data for the algorithm') + : t('Launch the algorithm!'), + } + }, [canRun, hasInputErrors, hasRequiredInputs, t]) + + const setExampleSequences = useCallback(() => { + addQryInputs([new AlgorithmInputDefault(dataset)]) + if (shouldSuggestDatasets) { + runSuggestions() + } + if (shouldRunAutomatically) { + runAnalysis() + } + }, [addQryInputs, dataset, runAnalysis, runSuggestions, shouldRunAutomatically, shouldSuggestDatasets]) + return ( - + + + + {t('Load example')} + + + + {t('Run')} + ) }, ) + +const ButtonRunStyled = styled(Button)` + position: absolute; + bottom: 10px; + right: 10px; + min-width: 120px; + min-height: 30px; +` + +const ButtonLoadExample = styled(Button)` + position: absolute; + bottom: 45px; + right: 2px; + min-width: 120px; + min-height: 30px; +` + +export const FlexRight = styled.div` + position: absolute; +` + +export const FlexLeft = styled.div` + margin-right: auto; +` diff --git a/packages_rs/nextclade-web/src/components/Main/MainInputForm.tsx b/packages_rs/nextclade-web/src/components/Main/MainInputForm.tsx index e8e9993b4..fc62eb146 100644 --- a/packages_rs/nextclade-web/src/components/Main/MainInputForm.tsx +++ b/packages_rs/nextclade-web/src/components/Main/MainInputForm.tsx @@ -6,6 +6,7 @@ import { useUpdatedDatasetIndex } from 'src/io/fetchDatasets' import { DatasetSelector } from 'src/components/Main/DatasetSelector' const Container = styled.div` + max-width: ${(props) => props.theme.containerMaxWidths.twoxl}; margin: 0 auto; height: 100%; overflow: hidden; @@ -28,12 +29,12 @@ export function MainInputForm() { return ( - + - + - + diff --git a/packages_rs/nextclade-web/src/components/Main/QuerySequenceFilePicker.tsx b/packages_rs/nextclade-web/src/components/Main/QuerySequenceFilePicker.tsx index 0c69f6f48..c2e9c28e4 100644 --- a/packages_rs/nextclade-web/src/components/Main/QuerySequenceFilePicker.tsx +++ b/packages_rs/nextclade-web/src/components/Main/QuerySequenceFilePicker.tsx @@ -3,7 +3,6 @@ import { useRecoilValue } from 'recoil' import styled from 'styled-components' import type { AlgorithmInput } from 'src/types' import { QuerySequenceList } from 'src/components/Main/QuerySequenceList' -import { RunPanel } from 'src/components/Main/RunPanel' import { useRunAnalysis } from 'src/hooks/useRunAnalysis' import { useRunSeqAutodetect } from 'src/hooks/useRunSeqAutodetect' import { useRecoilToggle } from 'src/hooks/useToggle' @@ -67,10 +66,6 @@ export function QuerySequenceFilePicker() { - - ) } @@ -97,8 +92,3 @@ const Main = styled.div` flex-direction: column; overflow: hidden; ` - -const Footer = styled.div` - display: flex; - flex: 0; -` diff --git a/packages_rs/nextclade-web/src/components/Main/SuggestionPanel.tsx b/packages_rs/nextclade-web/src/components/Main/SuggestionPanel.tsx index 8d9800c17..b45fa4c41 100644 --- a/packages_rs/nextclade-web/src/components/Main/SuggestionPanel.tsx +++ b/packages_rs/nextclade-web/src/components/Main/SuggestionPanel.tsx @@ -1,54 +1,187 @@ import { isNil } from 'lodash' import React, { useMemo } from 'react' -import { useRunSeqAutodetect } from 'src/hooks/useRunSeqAutodetect' -import { hasRequiredInputsAtom } from 'src/state/inputs.state' +import { Button, Form as FormBase, FormGroup as FormGroupBase, Spinner, UncontrolledAlert } from 'reactstrap' +import { useRecoilValue } from 'recoil' import styled from 'styled-components' -import { Button, Form as FormBase, FormGroup } from 'reactstrap' -import { useRecoilValue, useResetRecoilState } from 'recoil' import { Toggle } from 'src/components/Common/Toggle' -import { FlexLeft, FlexRight } from 'src/components/FilePicker/FilePickerStyles' +import { unreachable } from 'src/helpers/unreachable' import { useTranslationSafe } from 'src/helpers/useTranslationSafe' +import { useRunSeqAutodetect } from 'src/hooks/useRunSeqAutodetect' +import { useResetSuggestions } from 'src/hooks/useResetSuggestions' import { useRecoilToggle } from 'src/hooks/useToggle' -import { autodetectResultsAtom, hasAutodetectResultsAtom } from 'src/state/autodetect.state' -import { minimizerIndexVersionAtom } from 'src/state/dataset.state' +import { + autodetectResultsAtom, + AutodetectRunState, + autodetectRunStateAtom, + groupByDatasets, + hasAutodetectResultsAtom, + numberAutodetectResultsAtom, +} from 'src/state/autodetect.state' +import { datasetsAtom, minimizerIndexVersionAtom } from 'src/state/dataset.state' +import { hasRequiredInputsAtom } from 'src/state/inputs.state' import { shouldSuggestDatasetsAtom } from 'src/state/settings.state' export function SuggestionPanel() { - const { t } = useTranslationSafe() const minimizerIndexVersion = useRecoilValue(minimizerIndexVersionAtom) - const resetAutodetectResults = useResetRecoilState(autodetectResultsAtom) - const hasAutodetectResults = useRecoilValue(hasAutodetectResultsAtom) + const autodetectRunState = useRecoilValue(autodetectRunStateAtom) + + if (isNil(minimizerIndexVersion)) { + return null + } + + switch (autodetectRunState) { + case AutodetectRunState.Idle: + return + case AutodetectRunState.Started: + return + case AutodetectRunState.Done: + return + case AutodetectRunState.Failed: + return + default: { + return unreachable(autodetectRunState) + } + } +} + +export function ButtonSuggest() { + const { t } = useTranslationSafe() const hasRequiredInputs = useRecoilValue(hasRequiredInputsAtom) const runSuggest = useRunSeqAutodetect() + const hasAutodetectResults = useRecoilValue(hasAutodetectResultsAtom) - const { canRun, runButtonColor, runButtonTooltip } = useMemo(() => { + const { text, canRun, color, title } = useMemo(() => { const canRun = hasRequiredInputs return { + text: hasAutodetectResults ? t('Re-suggest') : t('Suggest'), canRun, - runButtonColor: !canRun ? 'secondary' : 'success', - runButtonTooltip: !canRun ? t('Please provide sequence data for the algorithm') : t('Launch suggestions engine!'), + color: !canRun ? 'secondary' : 'primary', + title: !canRun + ? t('Please provide sequence data for the algorithm') + : hasAutodetectResults + ? t('Re-launch suggestions engine!') + : t('Launch suggestions engine!'), } - }, [hasRequiredInputs, t]) + }, [hasAutodetectResults, hasRequiredInputs, t]) - if (isNil(minimizerIndexVersion)) { - return null - } + return ( + + {text} + + ) +} + +export function ButtonSuggestionsReset() { + const { t } = useTranslationSafe() + const resetAutodetectResults = useResetSuggestions() + const hasAutodetectResults = useRecoilValue(hasAutodetectResultsAtom) + return ( + + {t('Reset')} + + ) +} + +export function SuggestionPanelIdle() { return ( - + + + + + + {'\u00A0'} + + + + + + + + ) +} +export function SuggestionPanelStarted() { + const { t } = useTranslationSafe() + const numberAutodetectResults = useRecoilValue(numberAutodetectResultsAtom) + + return ( + + + + + + {t('Searching matching datasets')} + {t(`${numberAutodetectResults} sequences`)} + + + + + ) +} + +export function SuggestionPanelDone() { + const { t } = useTranslationSafe() + const { datasets } = useRecoilValue(datasetsAtom) + const autodetectResults = useRecoilValue(autodetectResultsAtom) + const numSuggestedDatasets = useMemo(() => { + if (!autodetectResults) { + return 0 + } + const recordsByDataset = groupByDatasets(autodetectResults) + return datasets.filter((candidate) => + Object.entries(recordsByDataset).some(([dataset, _]) => dataset === candidate.path), + ).length + }, [autodetectResults, datasets]) + + const text = useMemo(() => { + if (numSuggestedDatasets === 0) { + return ( + + {t('No matching datasets found.')} + {t('Consider contributing a new dataset.')} + + ) + } + return ( + + {t(`${numSuggestedDatasets} dataset(s) appear to match your data.`)} + {t('Select the one to use.')} + + ) + }, [numSuggestedDatasets, t]) + + return ( + + + {text} - - {t('Reset suggestions')} - + + + + + + ) +} - - {t('Suggest')} - +export function SuggestionPanelFailed() { + const { t } = useTranslationSafe() + return ( + + + + + {t('Suggestion engine failed.')} + {t('Please report this issue.')} + + + + + @@ -57,32 +190,57 @@ export function SuggestionPanel() { const Container = styled.div` flex: 1; - margin-top: auto; - margin-bottom: 7px; - padding: 7px 0; - padding-left: 5px; ` const Form = styled(FormBase)` display: flex; width: 100%; height: 100%; - margin-top: auto; + min-height: 45px; padding: 10px; border: 1px #ccc9 solid; border-radius: 5px; ` +export const FlexLeft = styled.div` + display: flex; + flex: 1; + margin-right: auto; + vertical-align: middle; +` + +export const FlexRight = styled.div` + margin-left: auto; +` + +const Alert = styled(UncontrolledAlert)` + margin: 0; + width: 100%; + padding: 0.5rem 1rem; +` + const ButtonRunStyled = styled(Button)` - min-width: 150px; - min-height: 45px; + width: 120px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: center; +` + +const ButtonResetStyled = styled(Button)` + margin: 0 1rem; + max-width: 100px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: center; ` -function AutosuggestionToggle() { +function AutosuggestionToggle({ ...restProps }) { const { t } = useTranslationSafe() const { state: shouldSuggestDatasets, toggle: toggleSuggestDatasets } = useRecoilToggle(shouldSuggestDatasetsAtom) return ( - + ) } + +const FormGroup = styled(FormGroupBase)` + margin: auto 0; +` diff --git a/packages_rs/nextclade-web/src/helpers/unreachable.ts b/packages_rs/nextclade-web/src/helpers/unreachable.ts new file mode 100644 index 000000000..61ab958f2 --- /dev/null +++ b/packages_rs/nextclade-web/src/helpers/unreachable.ts @@ -0,0 +1,5 @@ +import { ErrorInternal } from 'src/helpers/ErrorInternal' + +export function unreachable(impossible: never): never { + throw new ErrorInternal(`Reached impossible state: '${impossible}'`) +} diff --git a/packages_rs/nextclade-web/src/hooks/useResetSuggestions.ts b/packages_rs/nextclade-web/src/hooks/useResetSuggestions.ts new file mode 100644 index 000000000..8124dd0ff --- /dev/null +++ b/packages_rs/nextclade-web/src/hooks/useResetSuggestions.ts @@ -0,0 +1,12 @@ +import { useCallback } from 'react' +import { useResetRecoilState } from 'recoil' +import { autodetectResultsAtom, autodetectRunStateAtom } from 'src/state/autodetect.state' + +export function useResetSuggestions() { + const resetAutodetectResultsAtom = useResetRecoilState(autodetectResultsAtom) + const resetAutodetectRunStateAtom = useResetRecoilState(autodetectRunStateAtom) + return useCallback(() => { + resetAutodetectResultsAtom() + resetAutodetectRunStateAtom() + }, [resetAutodetectResultsAtom, resetAutodetectRunStateAtom]) +} diff --git a/packages_rs/nextclade-web/src/hooks/useRunSeqAutodetect.ts b/packages_rs/nextclade-web/src/hooks/useRunSeqAutodetect.ts index fb1ce5837..0b80d38b2 100644 --- a/packages_rs/nextclade-web/src/hooks/useRunSeqAutodetect.ts +++ b/packages_rs/nextclade-web/src/hooks/useRunSeqAutodetect.ts @@ -23,8 +23,6 @@ export function useRunSeqAutodetect() { () => { const { getPromise } = snapshot - set(autodetectRunStateAtom, AutodetectRunState.Started) - reset(minimizerIndexAtom) reset(autodetectResultsAtom) reset(autodetectRunStateAtom) @@ -44,6 +42,8 @@ export function useRunSeqAutodetect() { set(autodetectRunStateAtom, AutodetectRunState.Done) } + set(autodetectRunStateAtom, AutodetectRunState.Started) + Promise.all([getPromise(qrySeqInputsStorageAtom), getPromise(minimizerIndexVersionAtom)]) .then(async ([qrySeqInputs, minimizerIndexVersion]) => { if (!minimizerIndexVersion) { diff --git a/packages_rs/nextclade-web/src/state/autodetect.state.ts b/packages_rs/nextclade-web/src/state/autodetect.state.ts index 5697f4875..5018211a3 100644 --- a/packages_rs/nextclade-web/src/state/autodetect.state.ts +++ b/packages_rs/nextclade-web/src/state/autodetect.state.ts @@ -113,13 +113,6 @@ export const numberAutodetectResultsAtom = selector({ }, }) -export const hasAutodetectResultsAtom = selector({ - key: 'hasAutodetectResultsAtom', - get({ get }) { - return get(numberAutodetectResultsAtom) > 0 - }, -}) - export enum AutodetectRunState { Idle = 'Idle', Started = 'Started', @@ -131,3 +124,10 @@ export const autodetectRunStateAtom = atom({ key: 'autodetectRunStateAtom', default: AutodetectRunState.Idle, }) + +export const hasAutodetectResultsAtom = selector({ + key: 'hasAutodetectResultsAtom', + get({ get }) { + return get(autodetectRunStateAtom) === AutodetectRunState.Done && get(numberAutodetectResultsAtom) > 0 + }, +}) diff --git a/packages_rs/nextclade-web/src/state/inputs.state.ts b/packages_rs/nextclade-web/src/state/inputs.state.ts index 2ddff2b13..74ec627aa 100644 --- a/packages_rs/nextclade-web/src/state/inputs.state.ts +++ b/packages_rs/nextclade-web/src/state/inputs.state.ts @@ -1,9 +1,9 @@ import { isEmpty } from 'lodash' -import { useCallback } from 'react' +import { useCallback, useEffect } from 'react' import { atom, selector, useRecoilState, useResetRecoilState } from 'recoil' -import { autodetectResultsAtom } from 'src/state/autodetect.state' import { AlgorithmInput } from 'src/types' import { notUndefinedOrNull } from 'src/helpers/notUndefined' +import { useResetSuggestions } from 'src/hooks/useResetSuggestions' export const qrySeqInputsStorageAtom = atom({ key: 'qrySeqInputsStorage', @@ -13,7 +13,7 @@ export const qrySeqInputsStorageAtom = atom({ export function useQuerySeqInputs() { const [qryInputs, setQryInputs] = useRecoilState(qrySeqInputsStorageAtom) const resetSeqInputsStorage = useResetRecoilState(qrySeqInputsStorageAtom) - const resetAutodetectResults = useResetRecoilState(autodetectResultsAtom) + const resetSuggestions = useResetSuggestions() const addQryInputs = useCallback( (newInputs: AlgorithmInput[]) => { @@ -30,9 +30,15 @@ export function useQuerySeqInputs() { ) const clearQryInputs = useCallback(() => { - resetAutodetectResults() + resetSuggestions() resetSeqInputsStorage() - }, [resetAutodetectResults, resetSeqInputsStorage]) + }, [resetSeqInputsStorage, resetSuggestions]) + + useEffect(() => { + if (qryInputs.length === 0) { + resetSuggestions() + } + }, [qryInputs, resetSuggestions]) return { qryInputs, addQryInputs, removeQryInput, clearQryInputs } } diff --git a/packages_rs/nextclade-web/src/theme.ts b/packages_rs/nextclade-web/src/theme.ts index 4d13bc8a0..e97b90704 100644 --- a/packages_rs/nextclade-web/src/theme.ts +++ b/packages_rs/nextclade-web/src/theme.ts @@ -16,6 +16,7 @@ const containerMaxWidths = { md: '720px', lg: '960px', xl: '1140px', + twoxl: '1500px', xxl: '1950px', }
+ +
{'\u00A0'}
{t('Searching matching datasets')}
{t(`${numberAutodetectResults} sequences`)}
{t('No matching datasets found.')}
{t('Consider contributing a new dataset.')}
{t(`${numSuggestedDatasets} dataset(s) appear to match your data.`)}
{t('Select the one to use.')}
{t('Suggestion engine failed.')}
{t('Please report this issue.')}