diff --git a/packages_rs/nextclade-web/package.json b/packages_rs/nextclade-web/package.json index 7da09b99f..e6fca04fb 100644 --- a/packages_rs/nextclade-web/package.json +++ b/packages_rs/nextclade-web/package.json @@ -143,6 +143,7 @@ "react-resize-detector": "7.0.0", "react-select": "5.3.0", "react-toggle": "4.1.2", + "react-use-wizard": "2.2.3", "react-virtualized-auto-sizer": "1.0.6", "react-virtualized-select": "3.1.3", "react-window": "1.8.7", diff --git a/packages_rs/nextclade-web/src/components/About/About.tsx b/packages_rs/nextclade-web/src/components/About/About.tsx new file mode 100644 index 000000000..7f15d6fde --- /dev/null +++ b/packages_rs/nextclade-web/src/components/About/About.tsx @@ -0,0 +1,7 @@ +import React from 'react' + +import AboutContent from './AboutContent.mdx' + +export function About() { + return +} diff --git a/packages_rs/nextclade-web/src/components/About/AboutContent.mdx b/packages_rs/nextclade-web/src/components/About/AboutContent.mdx new file mode 100644 index 000000000..9f97e267a --- /dev/null +++ b/packages_rs/nextclade-web/src/components/About/AboutContent.mdx @@ -0,0 +1,43 @@ +import { CladeSchema } from 'src/components/Main/CladeSchema.tsx' + +## What is Nextclade? + +Nextclade is a tool that performs genetic sequence alignment, clade assignment, mutation calling, phylogenetic placement, and quality checks for SARS-CoV-2, Influenza (Flu), Mpox (Monkeypox), Respiratory Syncytial Virus (RSV) and other pathogens. + +Nextclade identifies differences between your sequences and a reference sequence, uses these differences to assign your sequences to clades, reports potential sequence quality issues in your data, and shows how the sequences are related to each other by placing them into an existing phylogenetic tree (we call it "phylogenetic placement"). You can use the tool to analyze sequences before you upload them to a database, or if you want to assign Nextstrain clades to a set of sequences. + +To analyze your data, drag a fasta file onto the upload box or paste sequences into the text box. These sequences will then be analyzed in your browser - data never leave your computer. Since your computer is doing the work rather than a server, it is advisable to analyze at most a few hundred sequences at a time. + +The Nextclade app and algorithms are opensource. The code is available on [GitHub](https://github.com/nextstrain/nextclade). The user manual is available at [docs.nextstrain.org/projects/nextclade](https://docs.nextstrain.org/projects/nextclade). + + +### What are the SARS-CoV-2 clades? + +Nextclade was originally developed during COVID-19 pandemic, primarily focused on SARS-CoV-2. This section describes clades with application to SARS-CoV-2, but Nextclade can analyse other pathogens too. + + + +Since its emergence in late 2019, SARS-CoV-2 has diversified into several different co-circulating variants. To facilitate discussion of these variants, we have grouped them into __clades__ which are defined by specific signature mutations. + +We currently define more than 30 clades (see [this blog post](https://nextstrain.org/blog/2021-01-06-updated-SARS-CoV-2-clade-naming) for details): + +- 19A and 19B emerged in Wuhan and have dominated the early outbreak +- 20A emerged from 19A out of dominated the European outbreak in March and has since spread globally +- 20B and 20C are large genetically distinct subclades 20A emerged in early 2020 +- 20D to 20J have emerged over the summer of 2020 and include three "Variants of Concern" (VoC). +- 21A to 21F include the VoC __delta__ and several Variants of Interest (VoI). +- 21K onwards are different clades within the diverse VoC __omicron__. + +Within Nextstrain, we define each clade by its combination of signature mutations. You can find the exact clade definition in [github.com/nextstrain/ncov/defaults/clades.tsv](https://github.com/nextstrain/ncov/blob/master/defaults/clades.tsv). When available, we will include [WHO labels for VoCs and VoIs](https://www.who.int/en/activities/tracking-SARS-CoV-2-variants/). + +Learn more about how Nextclade assigns clades in the [documentation](https://docs.nextstrain.org/projects/nextclade/en/stable/user/algorithm/). + +### Other pathogens + +Besides SARS-CoV-2, we provide Nextclade datasets to analyze the following other pathogens: + + * Seasonal Influenza viruses (HA and NA for A/H3N2, A/H1N1pdm, B/Vic, and B/Yam) + * Mpox virus (the overall clade structure, as well as fine-grained lineages within the recent sustained human-to-human transmission) + * Respiratory Syncytial Virus (RSV) (subtypes A and B) + +You can also put together your own dataset to analyse other pathogens. diff --git a/packages_rs/nextclade-web/src/components/Common/Dropdown.tsx b/packages_rs/nextclade-web/src/components/Common/Dropdown.tsx index 51fb7ce2e..f903bcbb3 100644 --- a/packages_rs/nextclade-web/src/components/Common/Dropdown.tsx +++ b/packages_rs/nextclade-web/src/components/Common/Dropdown.tsx @@ -45,6 +45,7 @@ export function Dropdown({ value={value} isMulti={false} onChange={handleChange} + menuPortalTarget={document.body} {...restProps} /> ) diff --git a/packages_rs/nextclade-web/src/components/Common/Markdown.tsx b/packages_rs/nextclade-web/src/components/Common/Markdown.tsx index e9c0e22d1..8c5766e8e 100644 --- a/packages_rs/nextclade-web/src/components/Common/Markdown.tsx +++ b/packages_rs/nextclade-web/src/components/Common/Markdown.tsx @@ -3,25 +3,21 @@ import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import rehypeRaw from 'rehype-raw' import rehypeSanitize from 'rehype-sanitize' -import { LinkExternal } from 'src/components/Link/LinkExternal' import { useAxiosQuery } from 'src/helpers/useAxiosQuery' import { LOADING } from 'src/components/Loading/Loading' +import { mdxComponents } from 'src/mdx-components' const REMARK_PLUGINS = [remarkGfm] const REHYPE_PLUGINS = [rehypeRaw, rehypeSanitize] -const MD_COMPONENTS = { - a: LinkExternal, -} - export interface MarkdownProps { content: string } export function Markdown({ content }: MarkdownProps) { return ( - + {content} ) diff --git a/packages_rs/nextclade-web/src/components/Common/SearchBox.tsx b/packages_rs/nextclade-web/src/components/Common/SearchBox.tsx index c5b634d7a..17104a884 100644 --- a/packages_rs/nextclade-web/src/components/Common/SearchBox.tsx +++ b/packages_rs/nextclade-web/src/components/Common/SearchBox.tsx @@ -79,6 +79,7 @@ export function SearchBox({ searchTitle, searchTerm, onSearchTermChange, ...rest - {title} + {title} + {FileUploadOrFileInfo} diff --git a/packages_rs/nextclade-web/src/components/FilePicker/FilePickerAdvanced.tsx b/packages_rs/nextclade-web/src/components/FilePicker/FilePickerAdvanced.tsx index 2de0544e0..67c623f3a 100644 --- a/packages_rs/nextclade-web/src/components/FilePicker/FilePickerAdvanced.tsx +++ b/packages_rs/nextclade-web/src/components/FilePicker/FilePickerAdvanced.tsx @@ -37,7 +37,7 @@ export function FilePickerAdvanced() { { return [ { url: '/', content: t('Start'), title: t('Show start page') }, + { url: '/dataset', content: t('Dataset'), title: t('Show current dataset details') }, { url: hasRan ? '/results' : undefined, content: t('Results'), diff --git a/packages_rs/nextclade-web/src/components/Main/ButtonChangeDataset.tsx b/packages_rs/nextclade-web/src/components/Main/ButtonChangeDataset.tsx new file mode 100644 index 000000000..c8b599db2 --- /dev/null +++ b/packages_rs/nextclade-web/src/components/Main/ButtonChangeDataset.tsx @@ -0,0 +1,57 @@ +import { isNil } from 'lodash' +import React, { useMemo } from 'react' +import { Button, ButtonProps } from 'reactstrap' +import styled from 'styled-components' +import { useRecoilValue } from 'recoil' +import { useTranslationSafe } from 'src/helpers/useTranslationSafe' +import { datasetCurrentAtom } from 'src/state/dataset.state' + +export interface DatasetNoneSectionProps { + toDatasetSelection(): void +} + +export function DatasetNoneSection({ toDatasetSelection }: DatasetNoneSectionProps) { + return ( + + + + ) +} + +const Container = styled.div` + display: flex; + flex: 1; + flex-direction: column; + overflow: hidden; + + padding: 12px; + border: 1px #ccc9 solid; + border-radius: 5px; + + min-height: 110px; +` + +export interface ChangeDatasetButtonProps extends ButtonProps { + onClick(): void +} + +export function ButtonChangeDataset({ onClick, ...restProps }: ChangeDatasetButtonProps) { + const { t } = useTranslationSafe() + const dataset = useRecoilValue(datasetCurrentAtom) + + const { color, text, tooltip } = useMemo(() => { + const hasDataset = !isNil(dataset) + const text = hasDataset ? t('Change dataset') : t('Select dataset') + return { + color: hasDataset ? 'secondary' : 'primary', + text, + tooltip: text, + } + }, [dataset, t]) + + return ( + + ) +} diff --git a/packages_rs/nextclade-web/src/components/Main/ButtonLoadExample.tsx b/packages_rs/nextclade-web/src/components/Main/ButtonLoadExample.tsx new file mode 100644 index 000000000..fe2a64160 --- /dev/null +++ b/packages_rs/nextclade-web/src/components/Main/ButtonLoadExample.tsx @@ -0,0 +1,38 @@ +import React, { useCallback } from 'react' +import { Button } from 'reactstrap' +import { useRecoilValue } from 'recoil' +import { useTranslationSafe } from 'src/helpers/useTranslationSafe' +import { AlgorithmInputDefault } from 'src/io/AlgorithmInput' +import { datasetCurrentAtom } from 'src/state/dataset.state' +import { hasInputErrorsAtom } from 'src/state/error.state' +import { useQuerySeqInputs } from 'src/state/inputs.state' + +export function ButtonLoadExample({ ...rest }) { + const { t } = useTranslationSafe() + + const datasetCurrent = useRecoilValue(datasetCurrentAtom) + const { addQryInputs } = useQuerySeqInputs() + const hasInputErrors = useRecoilValue(hasInputErrorsAtom) + // const shouldRunAutomatically = useRecoilValue(shouldRunAutomaticallyAtom) + // const shouldSuggestDatasets = useRecoilValue(shouldSuggestDatasetsAtom) + // const runAnalysis = useRunAnalysis() + // const runAutodetect = useRunSeqAutodetect() + + const setExampleSequences = useCallback(() => { + if (datasetCurrent) { + addQryInputs([new AlgorithmInputDefault(datasetCurrent)]) + // if (shouldSuggestDatasets) { + // runAutodetect() + // } + // if (shouldRunAutomatically) { + // runAnalysis() + // } + } + }, [addQryInputs, datasetCurrent]) + + return ( + + ) +} diff --git a/packages_rs/nextclade-web/src/components/Main/ButtonRun.tsx b/packages_rs/nextclade-web/src/components/Main/ButtonRun.tsx new file mode 100644 index 000000000..1a9cb1225 --- /dev/null +++ b/packages_rs/nextclade-web/src/components/Main/ButtonRun.tsx @@ -0,0 +1,44 @@ +import { isNil } from 'lodash' +import React, { useMemo } from 'react' +import { Button, ButtonProps } from 'reactstrap' +import { useRecoilValue } from 'recoil' +import styled from 'styled-components' +import { useTranslationSafe } from 'src/helpers/useTranslationSafe' +import { isAutodetectRunningAtom } from 'src/state/autodetect.state' +import { datasetCurrentAtom } from 'src/state/dataset.state' +import { hasInputErrorsAtom } from 'src/state/error.state' +import { hasRequiredInputsAtom } from 'src/state/inputs.state' +import { canRunAtom } from 'src/state/results.state' + +export interface ButtonRunProps extends ButtonProps { + onClick(): void +} + +export function ButtonRun({ onClick, ...restProps }: ButtonRunProps) { + const canRun = useRecoilValue(canRunAtom) + const hasRequiredInputs = useRecoilValue(hasRequiredInputsAtom) + const hasInputErrors = useRecoilValue(hasInputErrorsAtom) + const isAutodetectRunning = useRecoilValue(isAutodetectRunningAtom) + const dataset = useRecoilValue(datasetCurrentAtom) + + const { t } = useTranslationSafe() + const { isDisabled, color, tooltip } = useMemo(() => { + const isDisabled = !(canRun && hasRequiredInputs && !isAutodetectRunning) || hasInputErrors || isNil(dataset) + return { + isDisabled, + color: isDisabled ? 'secondary' : 'success', + tooltip: isDisabled ? t('Please provide sequence data first') : t('Launch the algorithm!'), + } + }, [canRun, dataset, hasInputErrors, hasRequiredInputs, isAutodetectRunning, t]) + + return ( + + {t('Run')} + + ) +} + +const ButtonStyled = styled(Button)` + min-width: 150px; + min-height: 40px; +` diff --git a/packages_rs/nextclade-web/src/components/Main/DatasetContentSection.tsx b/packages_rs/nextclade-web/src/components/Main/DatasetContentSection.tsx index 2c1d98107..e13ca509f 100644 --- a/packages_rs/nextclade-web/src/components/Main/DatasetContentSection.tsx +++ b/packages_rs/nextclade-web/src/components/Main/DatasetContentSection.tsx @@ -1,18 +1,18 @@ import classnames from 'classnames' -import React, { PropsWithChildren, useCallback, useMemo, useRef, useState } from 'react' +import React, { PropsWithChildren, useCallback, useState } from 'react' import styled from 'styled-components' import { Nav as NavBase, NavItem as NavItemBase, NavLink as NavLinkBase, - TabPane, + TabPane as TabPaneBase, TabContent as TabContentBase, NavItemProps, - TabPaneProps, } from 'reactstrap' import { useRecoilValue } from 'recoil' import { MarkdownRemote } from 'src/components/Common/Markdown' import { datasetCurrentAtom } from 'src/state/dataset.state' +import { DatasetContentTabAdvanced } from 'src/components/Main/DatasetContentTabAdvanced' export function DatasetContentSection() { const [activeTabId, setActiveTabId] = useState(0) @@ -20,21 +20,30 @@ export function DatasetContentSection() { return ( - - + {currentDataset?.files.readme && } - - + + {currentDataset?.files.changelog && } - + + {currentDataset && } ) @@ -58,40 +67,40 @@ export function TabLabel({ tabId, activeTabId, setActiveTabId, children, ...rest ) } -export interface TabContentPaneProps extends PropsWithChildren { - tabId: number - activeTabId: number -} +const ContentSection = styled.div` + display: flex; + flex: 1; + flex-direction: column; + overflow: hidden; +` -export function TabContentPane({ tabId, activeTabId, children, ...rest }: TabContentPaneProps) { - const active = activeTabId === tabId - return ( - - - {children} - - - ) -} +const TabContent = styled(TabContentBase)` + display: flex; + flex: 1; + flex-direction: column; + overflow: hidden; -export interface LazyProps { - visible: boolean -} + margin-top: -1px; +` -export function LazyRender({ visible, children }: PropsWithChildren) { - const rendered = useRef(visible) - const style = useMemo(() => ({ display: visible ? 'block' : 'none' }), [visible]) - if (visible && !rendered.current) { - rendered.current = true - } - if (!rendered.current) return null - return
{children}
-} +const TabPane = styled(TabPaneBase)` + display: flex; + flex: 1; + flex-direction: column; + overflow: auto; -const ContentSection = styled.div` - max-width: 100%; + border: 1px #ccc9 solid; + border-bottom-left-radius: 5px; + border-bottom-right-radius: 5px; + border-top-right-radius: 5px; + + padding: 1rem; ` +export interface LazyProps { + visible: boolean +} + const Nav = styled(NavBase)` border-bottom: 0 !important; ` @@ -108,6 +117,8 @@ const NavItem = styled(NavItemBase)` border-bottom: 0 !important; + z-index: 2; + .active { font-weight: bold; background-color: ${(props) => props.theme.white} !important; @@ -135,8 +146,3 @@ const NavItem = styled(NavItemBase)` const NavLink = styled(NavLinkBase)` color: ${(props) => props.theme.bodyColor}; ` - -const TabContent = styled(TabContentBase)` - border: #ddd 1px solid; - margin-top: -1px; -` diff --git a/packages_rs/nextclade-web/src/components/Main/DatasetContentTabAdvanced.tsx b/packages_rs/nextclade-web/src/components/Main/DatasetContentTabAdvanced.tsx new file mode 100644 index 000000000..dd177a0a8 --- /dev/null +++ b/packages_rs/nextclade-web/src/components/Main/DatasetContentTabAdvanced.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import styled from 'styled-components' +import { FilePickerAdvanced } from 'src/components/FilePicker/FilePickerAdvanced' +import AdvancedModeExplanationContent from 'src/components/Main/AdvancedModeExplanation.mdx' + +export function DatasetContentTabAdvanced() { + return ( + +
+ + + +
+ +
+ +
+
+ ) +} + +export const AdvancedModeExplanationWrapper = styled.div` + > p { + margin: 0; + } +` + +const Container = styled.div` + display: flex; + flex: 1; + flex-direction: column; + overflow: hidden; +` + +const Header = styled.div` + flex: 0; +` + +const Main = styled.div` + display: flex; + flex: 1; + flex-direction: column; + overflow: auto; + width: 100%; +` diff --git a/packages_rs/nextclade-web/src/components/Main/DatasetCurrent.tsx b/packages_rs/nextclade-web/src/components/Main/DatasetCurrent.tsx index 0c4d5167e..a57b8a357 100644 --- a/packages_rs/nextclade-web/src/components/Main/DatasetCurrent.tsx +++ b/packages_rs/nextclade-web/src/components/Main/DatasetCurrent.tsx @@ -1,16 +1,15 @@ -import { isNil } from 'lodash' -import React, { useCallback, useMemo, useState } from 'react' -import { Button, Col, Collapse, Row, UncontrolledAlert } from 'reactstrap' -import { useRecoilState, useRecoilValue, useResetRecoilState, useSetRecoilState } from 'recoil' +import React from 'react' +import { Col, Row } from 'reactstrap' +import { useRecoilValue } from 'recoil' +import { ButtonRun } from 'src/components/Main/ButtonRun' +import { useRunAnalysis } from 'src/hooks/useRunAnalysis' import styled from 'styled-components' import { useUpdatedDataset } from 'src/io/fetchDatasets' -import { datasetCurrentAtom, datasetUpdatedAtom } from 'src/state/dataset.state' +import { datasetCurrentAtom } from 'src/state/dataset.state' import { useTranslationSafe } from 'src/helpers/useTranslationSafe' -import { ButtonCustomize } from 'src/components/Main/ButtonCustomize' -import { FilePickerAdvanced } from 'src/components/FilePicker/FilePickerAdvanced' -import { LinkExternal } from 'src/components/Link/LinkExternal' -import { DatasetInfo } from './DatasetInfo' -import AdvancedModeExplanationContent from './AdvancedModeExplanation.mdx' +import { DatasetInfo } from 'src/components/Main/DatasetInfo' +import { DatasetContentSection } from 'src/components/Main/DatasetContentSection' +import { DatasetCurrentUpdateNotification } from 'src/components/Main/DatasetCurrentUpdateNotification' export const CurrentDatasetInfoContainer = styled.div` display: flex; @@ -22,89 +21,59 @@ export const CurrentDatasetInfoContainer = styled.div` export const CurrentDatasetInfoHeader = styled.section` display: flex; margin-bottom: 0.5rem; + margin-top: 7px; ` const DatasetInfoH4 = styled.h4` flex: 1; margin: auto 0; + margin-top: 12px; ` export const CurrentDatasetInfoBody = styled.section` display: flex; flex-direction: column; - margin: 0; padding: 12px; border: 1px #ccc9 solid; border-radius: 5px; - height: 100%; ` -export const Left = styled.section` - flex: 1 1 auto; +const Container = styled.div` display: flex; + flex: 1; + flex-direction: column; + overflow: hidden; +` + +const Header = styled.div` + flex: 0; ` -export const Right = styled.section` - flex: 0 0 250px; +const Main = styled.div` display: flex; + flex: 1; flex-direction: column; - height: 100%; -` + overflow: auto; + width: 100%; -export const ChangeButton = styled(Button)` - flex: 0 0 auto; - height: 2.1rem; - min-width: 100px; - margin-left: auto; + margin: 0.5rem 0; ` -export const AdvancedModeExplanationWrapper = styled.div` - max-width: 550px; - margin-top: 0.5rem; - - > p { - margin: 0; - } +export const FlexLeft = styled.div` + flex: 1; ` +export const FlexRight = styled.div`` + export function DatasetCurrent() { // Periodically checks if there's local update for the current dataset useUpdatedDataset() const { t } = useTranslationSafe() - const [advancedOpen, setAdvancedOpen] = useState(false) - const datasetCurrent = useRecoilValue(datasetCurrentAtom) - const resetDatasetCurrent = useResetRecoilState(datasetCurrentAtom) - - const onChangeClicked = useCallback(() => { - resetDatasetCurrent() - }, [resetDatasetCurrent]) - - const onCustomizeClicked = useCallback(() => setAdvancedOpen((advancedOpen) => !advancedOpen), []) - - const customize = useMemo(() => { - if (datasetCurrent?.path === 'autodetect') { - return null - } - - return ( - - - - - - - - - - - - - - ) - }, [advancedOpen, datasetCurrent?.path, onCustomizeClicked]) - - if (!datasetCurrent) { + const dataset = useRecoilValue(datasetCurrentAtom) + const run = useRunAnalysis() + + if (!dataset) { return null } @@ -112,78 +81,25 @@ export function DatasetCurrent() { {t('Selected pathogen')} + - - - - - - - - - - - - - {t('Change')} - - - - - - {customize} - + +
+ + + + + + + + + +
+ +
+ +
+
) } - -function DatasetCurrentUpdateNotification() { - const { t } = useTranslationSafe() - const [datasetUpdated, setDatasetUpdated] = useRecoilState(datasetUpdatedAtom) - const setDatasetCurrent = useSetRecoilState(datasetCurrentAtom) - - const onDatasetUpdateClicked = useCallback(() => { - setDatasetCurrent(datasetUpdated) - setDatasetUpdated(undefined) - }, [datasetUpdated, setDatasetCurrent, setDatasetUpdated]) - - if (isNil(datasetUpdated)) { - return null - } - - return ( - - - - -

{t('A new version of this dataset is available.')}

-

- - {"What's new?"} - -

-
- - - - {t('Update')} - - -
- -
- ) -} - -const AlertTextWrapper = styled.div` - flex: 1; -` - -const AlertButtonWrapper = styled.div` - flex: 0; -` diff --git a/packages_rs/nextclade-web/src/components/Main/DatasetCurrentSummary.tsx b/packages_rs/nextclade-web/src/components/Main/DatasetCurrentSummary.tsx new file mode 100644 index 000000000..e396f2f11 --- /dev/null +++ b/packages_rs/nextclade-web/src/components/Main/DatasetCurrentSummary.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import { Col, Row } from 'reactstrap' +import { useRecoilValue } from 'recoil' +import { ButtonLoadExample } from 'src/components/Main/ButtonLoadExample' +import styled from 'styled-components' +import { useUpdatedDataset } from 'src/io/fetchDatasets' +import { datasetCurrentAtom } from 'src/state/dataset.state' +import { DatasetCurrentUpdateNotification } from 'src/components/Main/DatasetCurrentUpdateNotification' +import { DatasetInfo } from 'src/components/Main/DatasetInfo' + +export function DatasetCurrentSummary() { + // Periodically checks if there's local update for the current dataset + useUpdatedDataset() + + const dataset = useRecoilValue(datasetCurrentAtom) + + if (!dataset) { + return null + } + + return ( + + + + + + + + + + + + + + + ) +} + +const Container = styled.div` + display: flex; + flex-direction: column; + padding: 12px; + border: 1px #ccc9 solid; + border-radius: 5px; +` diff --git a/packages_rs/nextclade-web/src/components/Main/DatasetCurrentUpdateNotification.tsx b/packages_rs/nextclade-web/src/components/Main/DatasetCurrentUpdateNotification.tsx new file mode 100644 index 000000000..75f30bc79 --- /dev/null +++ b/packages_rs/nextclade-web/src/components/Main/DatasetCurrentUpdateNotification.tsx @@ -0,0 +1,66 @@ +import { isNil } from 'lodash' +import React, { useCallback } from 'react' +import { Button, Col, Row, UncontrolledAlert } from 'reactstrap' +import { useRecoilState, useSetRecoilState } from 'recoil' +import { LinkExternal } from 'src/components/Link/LinkExternal' +import { useTranslationSafe } from 'src/helpers/useTranslationSafe' +import { datasetCurrentAtom, datasetUpdatedAtom } from 'src/state/dataset.state' +import styled from 'styled-components' + +export function DatasetCurrentUpdateNotification() { + const { t } = useTranslationSafe() + const [datasetUpdated, setDatasetUpdated] = useRecoilState(datasetUpdatedAtom) + const setDatasetCurrent = useSetRecoilState(datasetCurrentAtom) + + const onDatasetUpdateClicked = useCallback(() => { + setDatasetCurrent(datasetUpdated) + setDatasetUpdated(undefined) + }, [datasetUpdated, setDatasetCurrent, setDatasetUpdated]) + + if (isNil(datasetUpdated)) { + return null + } + + return ( + + + + +

{t('A new version of this dataset is available.')}

+

+ + {"What's new?"} + +

+
+ + + + {t('Update')} + + +
+ +
+ ) +} + +const AlertTextWrapper = styled.div` + flex: 1; +` + +const AlertButtonWrapper = styled.div` + flex: 0; +` + +const ChangeButton = styled(Button)` + flex: 0 0 auto; + height: 2.1rem; + min-width: 100px; + margin-left: auto; +` diff --git a/packages_rs/nextclade-web/src/components/Main/DatasetInfo.tsx b/packages_rs/nextclade-web/src/components/Main/DatasetInfo.tsx index b6c34748e..0636bd4f8 100644 --- a/packages_rs/nextclade-web/src/components/Main/DatasetInfo.tsx +++ b/packages_rs/nextclade-web/src/components/Main/DatasetInfo.tsx @@ -7,30 +7,14 @@ import { colorHash } from 'src/helpers/colorHash' import { formatDateIsoUtcSimple } from 'src/helpers/formatDate' import { firstLetter } from 'src/helpers/string' import { useTranslationSafe } from 'src/helpers/useTranslationSafe' -import { - autodetectResultsByDatasetAtom, - DATASET_ID_UNDETECTED, - numberAutodetectResultsAtom, -} from 'src/state/autodetect.state' +import { autodetectResultsByDatasetAtom, numberAutodetectResultsAtom } from 'src/state/autodetect.state' import { AnyType, attrBoolMaybe, attrStrMaybe } from 'src/types' import type { Dataset } from 'src/types' import styled from 'styled-components' export const Container = styled.div` display: flex; - //border: 1px #ccc9 solid; - //border-radius: 5px; - - //margin-top: 3px !important; - //margin-bottom: 3px !important; - //margin-left: 5px; - //padding: 15px; - margin: 0; - padding: 15px; - box-shadow: 0 0 12px 0 #0002; - border: 1px #ccc9 solid; - border-radius: 5px; ` export const FlexLeft = styled.div` @@ -67,16 +51,16 @@ export const DatasetInfoLine = styled.p` ` const DatasetInfoBadge = styled(Badge)` - font-size: 0.8rem; - margin-top: 2px !important; - padding: 0.25rem 0.5rem; + font-size: 0.7rem; + padding: 0.17rem 0.33rem; ` export interface DatasetInfoProps { dataset: Dataset + showSuggestions?: boolean } -export function DatasetInfo({ dataset }: DatasetInfoProps) { +export function DatasetInfo({ dataset, showSuggestions }: DatasetInfoProps) { const { t } = useTranslationSafe() const { attributes, path, version } = dataset @@ -88,24 +72,22 @@ export function DatasetInfo({ dataset }: DatasetInfoProps) { return updatedAt }, [t, version?.tag, version?.updatedAt]) - if (path === DATASET_ID_UNDETECTED) { - return - } - return ( - + {attrStrMaybe(attributes, 'name') ?? path} + +
{path.startsWith('nextstrain') ? ( @@ -113,7 +95,7 @@ export function DatasetInfo({ dataset }: DatasetInfoProps) { ) : ( )} - +
{t('Reference: {{ ref }}', { ref: formatReference(attributes) })} {t('Updated at: {{updated}}', { updated: updatedAt })} @@ -167,26 +149,54 @@ function formatReference(attributes: Record | undefined) { return name } +export function DatasetAutodetectInfo({ dataset }: { dataset: Dataset }) { + const { t } = useTranslationSafe() + + return ( + + + + + + + + {t('Autodetect')} + + {t('Detect pathogen automatically from sequences')} + {'\u00A0'} + {'\u00A0'} + {'\u00A0'} + + + ) +} + export function DatasetUndetectedInfo() { const { t } = useTranslationSafe() return ( - + - {t('Autodetect')} + {t('Not detected')} - {t('Detect pathogen automatically from sequences')} - - - + {t('Unable to deduce dataset')} + {'\u00A0'} + {'\u00A0'} + {'\u00A0'} + ) } +const ContainerFixed = styled(Container)` + height: 127px; +` + export interface DatasetInfoCircleProps { dataset: Dataset + showSuggestions?: boolean } -function DatasetInfoAutodetectProgressCircle({ dataset }: DatasetInfoCircleProps) { +function DatasetInfoAutodetectProgressCircle({ dataset, showSuggestions }: DatasetInfoCircleProps) { const { attributes, path } = dataset const name = attrStrMaybe(attributes, 'name') ?? last(path.split('/')) ?? '?' @@ -195,7 +205,7 @@ function DatasetInfoAutodetectProgressCircle({ dataset }: DatasetInfoCircleProps const numberAutodetectResults = useRecoilValue(numberAutodetectResultsAtom) const { circleText, countText, percentage } = useMemo(() => { - if (isNil(records)) { + if (!showSuggestions || isNil(records)) { return { circleText: (firstLetter(name) ?? ' ').toUpperCase(), percentage: 0, @@ -210,7 +220,7 @@ function DatasetInfoAutodetectProgressCircle({ dataset }: DatasetInfoCircleProps return { circleText, percentage, countText } } return { circleText: `0%`, percentage: 0, countText: `0 / ${numberAutodetectResults}` } - }, [records, numberAutodetectResults, name]) + }, [showSuggestions, records, numberAutodetectResults, name]) return ( <> diff --git a/packages_rs/nextclade-web/src/components/Main/DatasetListEntry.tsx b/packages_rs/nextclade-web/src/components/Main/DatasetListEntry.tsx new file mode 100644 index 000000000..1a03dd408 --- /dev/null +++ b/packages_rs/nextclade-web/src/components/Main/DatasetListEntry.tsx @@ -0,0 +1,25 @@ +import React from 'react' +import styled from 'styled-components' +import type { Dataset } from 'src/types' +import { DatasetInfo } from 'src/components/Main/DatasetInfo' + +export const Container = styled.div` + display: flex; + padding: 15px; + box-shadow: 0 0 12px 0 #0002; + border: 1px #ccc9 solid; + border-radius: 5px; +` + +export interface DatasetListEntryProps { + dataset: Dataset + showSuggestions?: boolean +} + +export function DatasetListEntry({ dataset, showSuggestions }: DatasetListEntryProps) { + return ( + + + + ) +} diff --git a/packages_rs/nextclade-web/src/components/Main/DatasetPage.tsx b/packages_rs/nextclade-web/src/components/Main/DatasetPage.tsx new file mode 100644 index 000000000..f9d152d97 --- /dev/null +++ b/packages_rs/nextclade-web/src/components/Main/DatasetPage.tsx @@ -0,0 +1,25 @@ +import React from 'react' +import styled from 'styled-components' +import { Layout } from 'src/components/Layout/Layout' +import { StepDatasetSelection } from 'src/components/Main/StepDatasetSelection' + +const Main = styled.div` + display: flex; + flex: 1 1 100%; + overflow: hidden; + padding: 0; + margin: 0 auto; + + width: 100%; + max-width: 1400px; +` + +export function DatasetPage() { + return ( + +
+ +
+
+ ) +} diff --git a/packages_rs/nextclade-web/src/components/Main/DatasetSelector.tsx b/packages_rs/nextclade-web/src/components/Main/DatasetSelector.tsx index ca7f290b4..adce980f5 100644 --- a/packages_rs/nextclade-web/src/components/Main/DatasetSelector.tsx +++ b/packages_rs/nextclade-web/src/components/Main/DatasetSelector.tsx @@ -1,21 +1,99 @@ -import React, { HTMLProps, useState } from 'react' +import { get, isNil, sortBy } from 'lodash' +import React, { useEffect, useMemo, useState } from 'react' import { useRecoilState, useRecoilValue } from 'recoil' -import styled from 'styled-components' -import { ThreeDots } from 'react-loader-spinner' +import { Container as ContainerBase } from 'reactstrap' +import { DatasetSelectorList } from 'src/components/Main/DatasetSelectorList' import { SuggestionPanel } from 'src/components/Main/SuggestionPanel' +import { + autodetectResultsAtom, + AutodetectRunState, + autodetectRunStateAtom, + groupByDatasets, +} from 'src/state/autodetect.state' +import styled from 'styled-components' +import type { Dataset } from 'src/types' import { useTranslationSafe } from 'src/helpers/useTranslationSafe' -import { datasetCurrentAtom, datasetsAtom } from 'src/state/dataset.state' +import { datasetsAtom } from 'src/state/dataset.state' import { SearchBox } from 'src/components/Common/SearchBox' -import { DatasetSelectorList } from 'src/components/Main/DatasetSelectorList' -export function DatasetSelector() { - const { t } = useTranslationSafe() - const [searchTerm, setSearchTerm] = useState('') +export interface DatasetSelectorProps { + datasetHighlighted?: Dataset + onDatasetHighlighted?(dataset?: Dataset): void +} + +export function DatasetAutosuggestionResultsList({ datasetHighlighted, onDatasetHighlighted }: DatasetSelectorProps) { const { datasets } = useRecoilValue(datasetsAtom) - const [datasetCurrent, setDatasetCurrent] = useRecoilState(datasetCurrentAtom) - const isBusy = datasets.length === 0 + const autodetectResults = useRecoilValue(autodetectResultsAtom) + const [autodetectRunState, setAutodetectRunState] = useRecoilState(autodetectRunStateAtom) + + const result = useMemo(() => { + if (isNil(autodetectResults) || autodetectResults.length === 0) { + return { itemsStartWith: [], itemsInclude: datasets, itemsNotInclude: [] } + } + + const recordsByDataset = groupByDatasets(autodetectResults) + + let itemsInclude = datasets.filter((candidate) => + Object.entries(recordsByDataset).some(([dataset, _]) => dataset === candidate.path), + ) + + itemsInclude = sortBy(itemsInclude, (dataset) => -get(recordsByDataset, dataset.path, []).length) + + const itemsNotInclude = datasets.filter((candidate) => !itemsInclude.map((it) => it.path).includes(candidate.path)) + + return { itemsStartWith: [], itemsInclude, itemsNotInclude } + }, [autodetectResults, datasets]) + + const datasetsActive = useMemo(() => { + const { itemsStartWith, itemsInclude } = result + return [...itemsStartWith, ...itemsInclude] + }, [result]) + + const datasetsInactive = useMemo(() => { + const { itemsNotInclude } = result + return itemsNotInclude + }, [result]) + + const showSuggestions = useMemo(() => !isNil(autodetectResults) && autodetectResults.length > 0, [autodetectResults]) + + useEffect(() => { + const topSuggestion = result.itemsInclude[0] + + if (autodetectRunState === AutodetectRunState.Done) { + onDatasetHighlighted?.(topSuggestion) + setAutodetectRunState(AutodetectRunState.Idle) + } + }, [autodetectRunState, result.itemsInclude, onDatasetHighlighted, setAutodetectRunState]) + return ( + + ) +} + +export interface DatasetSelectorImplProps { + datasetsActive: Dataset[] + datasetsInactive?: Dataset[] + datasetHighlighted?: Dataset + onDatasetHighlighted?(dataset?: Dataset): void + showSuggestions?: boolean +} + +export function DatasetSelectorImpl({ + datasetsActive, + datasetsInactive, + datasetHighlighted, + onDatasetHighlighted, + showSuggestions, +}: DatasetSelectorImplProps) { + const { t } = useTranslationSafe() + const [searchTerm, setSearchTerm] = useState('') return (
@@ -24,39 +102,31 @@ export function DatasetSelector() {
+
+ +
+
- {!isBusy && ( - - )} - - {isBusy && ( - - - - - - )} +
- -
- -
) } -const Container = styled.div` +const Container = styled(ContainerBase)` display: flex; flex: 1; flex-direction: column; - height: 100%; overflow: hidden; - margin-right: 10px; + margin: 0 auto; + max-width: 800px; ` const Header = styled.div` @@ -74,28 +144,7 @@ const Main = styled.div` overflow: hidden; ` -const Footer = styled.div` - display: flex; - flex: 0; -` - const Title = styled.h4` flex: 1; margin: auto 0; ` - -const SpinnerWrapper = styled.div>` - width: 100%; - height: 100%; - display: flex; -` - -const SpinnerWrapperInternal = styled.div` - margin: auto; -` - -const Spinner = styled(ThreeDots)` - flex: 1; - margin: auto; - height: 100%; -` diff --git a/packages_rs/nextclade-web/src/components/Main/DatasetSelectorList.tsx b/packages_rs/nextclade-web/src/components/Main/DatasetSelectorList.tsx index 9b53d5bb1..fa7737a7f 100644 --- a/packages_rs/nextclade-web/src/components/Main/DatasetSelectorList.tsx +++ b/packages_rs/nextclade-web/src/components/Main/DatasetSelectorList.tsx @@ -1,146 +1,110 @@ -import { get, isNil, sortBy } from 'lodash' +import React, { forwardRef, useCallback, useMemo, useRef } from 'react' import { lighten } from 'polished' -import React, { forwardRef, useCallback, useEffect, useMemo, useRef } from 'react' -import { useRecoilState, useRecoilValue } from 'recoil' +import { ListGroup } from 'reactstrap' import styled from 'styled-components' import { areDatasetsEqual, attrStrMaybe, Dataset } from 'src/types' import { ListGenericCss } from 'src/components/Common/List' -import { DatasetInfo } from 'src/components/Main/DatasetInfo' import { search } from 'src/helpers/search' -import { - autodetectResultsAtom, - AutodetectRunState, - autodetectRunStateAtom, - groupByDatasets, -} from 'src/state/autodetect.state' +import { DatasetListEntry } from 'src/components/Main/DatasetListEntry' export interface DatasetSelectorListProps { - datasets: Dataset[] - searchTerm: string + datasetsActive: Dataset[] + datasetsInactive?: Dataset[] datasetHighlighted?: Dataset - - onDatasetHighlighted(dataset?: Dataset): void + onDatasetHighlighted?(dataset?: Dataset): void + searchTerm: string + showSuggestions?: boolean } export function DatasetSelectorList({ - datasets, - searchTerm, + datasetsActive, + datasetsInactive = [], datasetHighlighted, onDatasetHighlighted, + searchTerm, + showSuggestions, }: DatasetSelectorListProps) { - const onItemClick = useCallback((dataset: Dataset) => () => onDatasetHighlighted(dataset), [onDatasetHighlighted]) - - const autodetectResults = useRecoilValue(autodetectResultsAtom) - const [autodetectRunState, setAutodetectRunState] = useRecoilState(autodetectRunStateAtom) - - const autodetectResult = useMemo(() => { - if (isNil(autodetectResults) || autodetectResults.length === 0) { - return { itemsStartWith: [], itemsInclude: datasets, itemsNotInclude: [] } - } - - const recordsByDataset = groupByDatasets(autodetectResults) - - let itemsInclude = datasets.filter((candidate) => - Object.entries(recordsByDataset).some(([dataset, _]) => dataset === candidate.path), - ) + const onItemClick = useCallback((dataset: Dataset) => () => onDatasetHighlighted?.(dataset), [onDatasetHighlighted]) - itemsInclude = sortBy(itemsInclude, (dataset) => -get(recordsByDataset, dataset.path, []).length) - - const itemsNotInclude = datasets.filter((candidate) => !itemsInclude.map((it) => it.path).includes(candidate.path)) - - return { itemsStartWith: [], itemsInclude, itemsNotInclude } - }, [autodetectResults, datasets]) + const listItemsRef = useScrollListToDataset(datasetHighlighted) const searchResult = useMemo(() => { if (searchTerm.trim().length === 0) { - return autodetectResult + return { itemsStartWith: [], itemsInclude: datasetsActive, itemsNotInclude: datasetsInactive } } - return search( - [...autodetectResult.itemsStartWith, ...autodetectResult.itemsInclude, ...autodetectResult.itemsNotInclude], - searchTerm, - (dataset) => [ - dataset.path, - attrStrMaybe(dataset.attributes, 'name') ?? '', - attrStrMaybe(dataset.attributes, 'reference name') ?? '', - attrStrMaybe(dataset.attributes, 'reference accession') ?? '', - ], - ) - }, [autodetectResult, searchTerm]) + return search([...datasetsActive, ...datasetsInactive], searchTerm, (dataset) => [ + dataset.path, + attrStrMaybe(dataset.attributes, 'name') ?? '', + attrStrMaybe(dataset.attributes, 'reference name') ?? '', + attrStrMaybe(dataset.attributes, 'reference accession') ?? '', + dataset.path, + ]) + }, [datasetsActive, datasetsInactive, searchTerm]) const { itemsStartWith, itemsInclude, itemsNotInclude } = searchResult + return useMemo( + () => ( +
    + {[...itemsStartWith, ...itemsInclude].map((dataset) => ( + + ))} + + {itemsNotInclude.map((dataset) => ( + + ))} +
+ ), + [datasetHighlighted, itemsInclude, itemsNotInclude, itemsStartWith, listItemsRef, onItemClick, showSuggestions], + ) +} + +function nodeRefSetOrDelete(map: Map, key: string) { + return function nodeRefSetOrDeleteImpl(node: T) { + if (node) { + map.set(key, node) + } else { + map.delete(key) + } + } +} + +function useScrollListToDataset(datasetHighlighted?: Dataset) { const itemsRef = useRef>(new Map()) - function scrollToId(itemId: string) { + const scrollToId = useCallback((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]) - - const ulRef = useRef(null) - useEffect(() => ulRef.current?.scrollTo({ top: 0, left: 0, behavior: 'smooth' }), [searchTerm]) - - const listItems = useMemo(() => { - return ( - <> - {[itemsStartWith, itemsInclude].map((datasets) => - datasets.map((dataset) => ( - - )), - )} - - {[itemsNotInclude].map((datasets) => - datasets.map((dataset) => ( - - )), - )} - - ) - }, [datasetHighlighted, itemsInclude, itemsNotInclude, itemsStartWith, onItemClick]) - - return
    {listItems}
-} - -function nodeRefSetOrDelete(map: Map, key: string) { - return function nodeRefSetOrDeleteImpl(node: T) { - if (node) { - map.set(key, node) - } else { - map.delete(key) - } - } + return itemsRef } -export const Ul = styled.ul` +export const Ul = styled(ListGroup)` ${ListGenericCss}; flex: 1; overflow: auto; @@ -162,6 +126,7 @@ export const Li = styled.li<{ $active?: boolean; $isDimmed?: boolean }>` ` color: ${props.theme.white}; background-color: ${lighten(0.033)(props.theme.primary)}; + color: ${props.theme.gray100}; box-shadow: -3px 3px 12px 3px #0005; opacity: ${props.$isDimmed && 0.66}; `}; @@ -171,14 +136,15 @@ interface DatasetSelectorListItemProps { dataset: Dataset isCurrent?: boolean isDimmed?: boolean + showSuggestions?: boolean onClick?: () => void } const DatasetSelectorListItem = forwardRef( - function DatasetSelectorListItemWithRef({ dataset, isCurrent, isDimmed, onClick }, ref) { + function DatasetSelectorListItemWithRef({ dataset, isCurrent, isDimmed, onClick, showSuggestions }, ref) { return (
  • - +
  • ) }, diff --git a/packages_rs/nextclade-web/src/components/Main/Downloads.tsx b/packages_rs/nextclade-web/src/components/Main/Downloads.tsx new file mode 100644 index 000000000..d0c7a1597 --- /dev/null +++ b/packages_rs/nextclade-web/src/components/Main/Downloads.tsx @@ -0,0 +1,168 @@ +import React, { HTMLProps, ReactNode } from 'react' + +import { useTranslationSafe as useTranslation } from 'src/helpers/useTranslationSafe' +import { FaBook, FaDocker, FaDownload, FaGithub, FaGlobeAmericas } from 'react-icons/fa' +import { + Card as ReactstrapCard, + CardBody as ReactstrapCardBody, + CardHeader as ReactstrapCardHeader, + Col, + Row, +} from 'reactstrap' +import styled from 'styled-components' + +import { LinkExternal as LinkExternalBase } from 'src/components/Link/LinkExternal' + +const DownloadLinkList = styled.ul` + display: flex; + flex-direction: column; + list-style: none; + padding: 0; +` + +const DownloadLinkListItem = styled.li` + display: flex; + flex: 1; + margin: auto; +` + +const LinkExternal = styled(LinkExternalBase)` + width: 200px; + height: 55px; + margin: 0.25rem; + padding: 1rem; +` + +const Card = styled(ReactstrapCard)` + margin: 5px; + height: 100%; +` + +const CardBody = styled(ReactstrapCardBody)` + padding: 0.5rem; +` + +const CardHeader = styled(ReactstrapCardHeader)` + padding: 1rem; +` + +const iconDownload = +const iconGithub = +const iconDocker = +const iconBook = +const iconGlobe = + +export interface DownloadLinkProps extends HTMLProps { + Icon: ReactNode + text: string + url: string +} + +export function DownloadLink({ Icon, text, url }: DownloadLinkProps) { + return ( + + + {Icon} + {text} + + + ) +} + +export function Downloads() { + const { t } = useTranslation() + + return ( + + + + +

    {t('For more advanced use-cases:')}

    + +
    + + + + + +

    {'Nextclade CLI'}

    +

    {t('faster, more configurable command-line version of this application')}

    +
    + + + + + + + + +
    + + + + + +

    {'Nextalign CLI'}

    +

    + {t('pairwise reference alignment and translation tool used by Nextclade')} +

    +
    + + + + + + + + +
    + + + + + +

    {'Nextstrain'}

    +

    + {t('our parent project, an open-source initiative to harness the potential of pathogen genome data')} +

    +
    + + + + + + + + + +
    + +
    + +
    + ) +} diff --git a/packages_rs/nextclade-web/src/components/Main/ExampleSequencePicker.tsx b/packages_rs/nextclade-web/src/components/Main/ExampleSequencePicker.tsx new file mode 100644 index 000000000..9f26a2ce7 --- /dev/null +++ b/packages_rs/nextclade-web/src/components/Main/ExampleSequencePicker.tsx @@ -0,0 +1,112 @@ +import { Dataset } from '_SchemaRoot' +import React, { useCallback, useMemo, useState } from 'react' +import { + Dropdown as DropdownBase, + DropdownToggle as DropdownToggleBase, + DropdownMenu as DropdownMenuBase, + DropdownItem as DropdownItemBase, + DropdownProps, +} from 'reactstrap' +import { attrStrMaybe } from 'src/types' +import styled from 'styled-components' +import { useRecoilValue } from 'recoil' +import { SearchBox } from 'src/components/Common/SearchBox' +import { search } from 'src/helpers/search' +import { useTranslationSafe } from 'src/helpers/useTranslationSafe' +import { datasetsAtom } from 'src/state/dataset.state' +import { useQuerySeqInputs } from 'src/state/inputs.state' +import { AlgorithmInputDefault } from 'src/io/AlgorithmInput' + +export type LanguageSwitcherProps = DropdownProps + +export function ExampleSequencePicker({ ...restProps }: LanguageSwitcherProps) { + const { t } = useTranslationSafe() + const [searchTerm, setSearchTerm] = useState('') + const [dropdownOpen, setDropdownOpen] = useState(false) + const toggle = useCallback(() => { + setDropdownOpen((prevState) => !prevState) + }, []) + const { datasets } = useRecoilValue(datasetsAtom) + const { addQryInputs } = useQuerySeqInputs() + + const filtered = useMemo(() => { + if (searchTerm.trim().length === 0) { + return datasets + } + const { itemsStartWith, itemsInclude } = search(datasets, searchTerm, (dataset) => [ + dataset.path, + attrStrMaybe(dataset.attributes, 'name') ?? '', + attrStrMaybe(dataset.attributes, 'reference name') ?? '', + attrStrMaybe(dataset.attributes, 'reference accession') ?? '', + dataset.path, + ]) + return [...itemsStartWith, ...itemsInclude] + }, [datasets, searchTerm]) + + const onClick = useCallback( + (dataset: Dataset) => () => { + addQryInputs([new AlgorithmInputDefault(dataset)]) + }, + [addQryInputs], + ) + + return ( + + + {t('Example')} + + + + + + + {filtered.map((dataset) => { + return ( + + {dataset.path} + + ) + })} + + + + ) +} + +const Dropdown = styled(DropdownBase)` + padding: 8px 16px !important; + border: 0; + box-shadow: inset 0 -1px 0 #ddd; + margin-bottom: 1px; +` + +const DropdownToggle = styled(DropdownToggleBase)` + color: ${(props) => props.theme.bodyColor}; + padding: 0; + margin: 0; +` + +const DropdownMenu = styled(DropdownMenuBase)` + position: absolute !important; + background-color: ${(props) => props.theme.bodyBg}; + box-shadow: 1px 1px 20px 0 #0005; + transition: opacity ease-out 0.25s; + padding: 1rem; + min-width: 275px; + z-index: 10000; +` + +const DropdownItem = styled(DropdownItemBase)` + width: 100% !important; + padding: 0 !important; +` + +const SearchBoxWrapper = styled.div` + margin-bottom: 0.5rem; + margin-right: 1rem; +` + +const DropdownMenuListWrapper = styled.div` + max-height: 50vh; + overflow-y: 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..fab431f2b 100644 --- a/packages_rs/nextclade-web/src/components/Main/MainInputForm.tsx +++ b/packages_rs/nextclade-web/src/components/Main/MainInputForm.tsx @@ -1,41 +1,161 @@ -import React from 'react' -import { QuerySequenceFilePicker } from 'src/components/Main/QuerySequenceFilePicker' +import { useRouter } from 'next/router' +import React, { useCallback, useMemo } from 'react' +import { isNil } from 'lodash' +import { useRecoilValue } from 'recoil' +import { ButtonRun } from 'src/components/Main/ButtonRun' +import { useRunAnalysis } from 'src/hooks/useRunAnalysis' +import { shouldSuggestDatasetsAtom } from 'src/state/settings.state' import styled from 'styled-components' -import { Col as ColBase, Row as RowBase } from 'reactstrap' +import { hasRequiredInputsAtom } from 'src/state/inputs.state' +import { datasetCurrentAtom } from 'src/state/dataset.state' +import { DatasetCurrentSummary } from 'src/components/Main/DatasetCurrentSummary' +import { MainSectionTitle } from 'src/components/Main/MainSectionTitle' +import { QuerySequenceFilePicker } from 'src/components/Main/QuerySequenceFilePicker' import { useUpdatedDatasetIndex } from 'src/io/fetchDatasets' -import { DatasetSelector } from 'src/components/Main/DatasetSelector' +import { ButtonChangeDataset, DatasetNoneSection } from 'src/components/Main/ButtonChangeDataset' +import { useTranslationSafe } from 'src/helpers/useTranslationSafe' +import { useRunSeqAutodetect } from 'src/hooks/useRunSeqAutodetect' +import { QuerySequenceList } from './QuerySequenceList' -const Container = styled.div` +const ContainerFixed = styled.div` + display: flex; + flex: 1; + flex-direction: column; + overflow: hidden; + width: 100%; margin: 0 auto; - height: 100%; + max-width: 1000px; +` + +const Container = styled.div` + display: flex; + flex: 1; + flex-direction: column; overflow: hidden; +` + +const ContainerColumns = styled.div` + display: flex; + flex-direction: row; + overflow: hidden; +` + +const Header = styled.div` + display: flex; + flex: 0; + padding-left: 10px; margin-top: 10px; + margin-bottom: 3px; ` -const Row = styled(RowBase)` +const Main = styled.div` + display: flex; + flex-direction: column; overflow: hidden; - height: 100%; ` -const Col = styled(ColBase)` +const Footer = styled.div` + display: flex; + flex: 1; overflow: hidden; - height: 100%; ` export function MainInputForm() { // This periodically fetches dataset index and updates the list of datasets. useUpdatedDatasetIndex() + return +} + +function MainWizard() { return ( - - - - - +
    + +
    +
    + ) +} + +function StepLanding() { + const { push } = useRouter() + const runAutodetect = useRunSeqAutodetect() + const hasRequiredInputs = useRecoilValue(hasRequiredInputsAtom) + const shouldSuggestDatasets = useRecoilValue(shouldSuggestDatasetsAtom) + + const toDatasetSelection = useCallback(() => { + void push('/dataset') // eslint-disable-line no-void + if (shouldSuggestDatasets && hasRequiredInputs) { + runAutodetect() + } + }, [hasRequiredInputs, push, runAutodetect, shouldSuggestDatasets]) + + return ( + +
    + +
    +
    + - - + + +
    + +
    + +
    + +
    +
    +
    +
    + ) +} + +export interface DatasetCurrentOrSelectProps { + toDatasetSelection(): void +} + +function DatasetCurrentOrSelectButton({ toDatasetSelection }: DatasetCurrentOrSelectProps) { + const { t } = useTranslationSafe() + const dataset = useRecoilValue(datasetCurrentAtom) + const run = useRunAnalysis() + + const text = useMemo(() => { + if (isNil(dataset)) { + return t('Select dataset') + } + return t('Selected dataset') + }, [dataset, t]) + if (!dataset) { + return ( + +
    +

    {text}

    +
    + +
    + +
    +
    + ) + } + + return ( + +
    +

    {text}

    +
    + +
    + +
    + +
    + + +
    ) } diff --git a/packages_rs/nextclade-web/src/components/Main/MainPage.tsx b/packages_rs/nextclade-web/src/components/Main/MainPage.tsx index be7f8592c..a88d79f57 100644 --- a/packages_rs/nextclade-web/src/components/Main/MainPage.tsx +++ b/packages_rs/nextclade-web/src/components/Main/MainPage.tsx @@ -2,11 +2,25 @@ import React from 'react' import { Layout } from 'src/components/Layout/Layout' import { MainInputForm } from 'src/components/Main/MainInputForm' +import styled from 'styled-components' + +const Main = styled.div` + display: flex; + flex: 1 1 100%; + overflow: hidden; + padding: 0; + margin: 0 auto; + + width: 100%; + max-width: 1400px; +` export function MainPage() { return ( - +
    + +
    ) } diff --git a/packages_rs/nextclade-web/src/components/Main/MainSectionInfo.tsx b/packages_rs/nextclade-web/src/components/Main/MainSectionInfo.tsx new file mode 100644 index 000000000..47669bece --- /dev/null +++ b/packages_rs/nextclade-web/src/components/Main/MainSectionInfo.tsx @@ -0,0 +1,26 @@ +import React from 'react' + +import { Col, Row } from 'reactstrap' + +import { About } from 'src/components/About/About' +import { Downloads } from 'src/components/Main/Downloads' + +export function MainSectionInfo() { + return ( + + + + + + + + + + + + + + + + ) +} diff --git a/packages_rs/nextclade-web/src/components/Main/MainSectionTitle.tsx b/packages_rs/nextclade-web/src/components/Main/MainSectionTitle.tsx new file mode 100644 index 000000000..798444f96 --- /dev/null +++ b/packages_rs/nextclade-web/src/components/Main/MainSectionTitle.tsx @@ -0,0 +1,19 @@ +import React from 'react' + +import { useTranslationSafe as useTranslation } from 'src/helpers/useTranslationSafe' +import { Col, Row } from 'reactstrap' + +import { Subtitle, Title } from 'src/components/Main/Title' + +export function MainSectionTitle() { + const { t } = useTranslation() + + return ( + + + + <Subtitle>{t('Clade assignment, mutation calling, and sequence quality checks')}</Subtitle> + </Col> + </Row> + ) +} diff --git a/packages_rs/nextclade-web/src/components/Main/QuerySequenceFilePicker.tsx b/packages_rs/nextclade-web/src/components/Main/QuerySequenceFilePicker.tsx index 0c69f6f48..b83c7c43a 100644 --- a/packages_rs/nextclade-web/src/components/Main/QuerySequenceFilePicker.tsx +++ b/packages_rs/nextclade-web/src/components/Main/QuerySequenceFilePicker.tsx @@ -2,8 +2,6 @@ import React, { useCallback, useMemo } from 'react' 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' @@ -50,27 +48,17 @@ export function QuerySequenceFilePicker() { return ( <Container> - <Header> - <FilePicker - title={headerText} - icon={icon} - exampleUrl="https://example.com/sequences.fasta" - pasteInstructions={t('Enter sequence data in FASTA format')} - input={undefined} - error={qrySeqError} - isInProgress={false} - onInputs={setSequences} - multiple - /> - </Header> - - <Main> - <QuerySequenceList /> - </Main> - - <Footer> - <RunPanel /> - </Footer> + <FilePicker + title={headerText} + icon={icon} + exampleUrl="https://example.com/sequences.fasta" + pasteInstructions={t('Enter sequence data in FASTA format')} + input={undefined} + error={qrySeqError} + isInProgress={false} + onInputs={setSequences} + multiple + /> </Container> ) } @@ -84,21 +72,3 @@ const Container = styled.div` margin-left: 10px; margin-right: 12px; ` - -const Header = styled.div` - display: flex; - flex: 0; - margin-bottom: 15px; -` - -const Main = styled.div` - display: flex; - flex: 1; - flex-direction: column; - overflow: hidden; -` - -const Footer = styled.div` - display: flex; - flex: 0; -` diff --git a/packages_rs/nextclade-web/src/components/Main/QuerySequenceList.tsx b/packages_rs/nextclade-web/src/components/Main/QuerySequenceList.tsx index c110194dc..86b47de8a 100644 --- a/packages_rs/nextclade-web/src/components/Main/QuerySequenceList.tsx +++ b/packages_rs/nextclade-web/src/components/Main/QuerySequenceList.tsx @@ -8,7 +8,7 @@ import { useTranslationSafe } from 'src/helpers/useTranslationSafe' import { ButtonTransparent } from 'src/components/Common/ButtonTransparent' import { UlGeneric } from 'src/components/Common/List' -export function QuerySequenceList() { +export function QuerySequenceList({ toMainPage }: { toMainPage?: () => void }) { const { t } = useTranslationSafe() const { qryInputs, clearQryInputs } = useQuerySeqInputs() @@ -27,12 +27,26 @@ export function QuerySequenceList() { return ( <div className="d-flex"> <h4>{t("Sequence data you've added")}</h4> - <Button className="ml-auto" color="link" onClick={clearQryInputs} title={t('Remove all input files')}> - {t('Remove all')} - </Button> + + <div className="d-flex ml-auto"> + <Button className="" color="link" onClick={clearQryInputs} title={t('Remove all input files')}> + {t('Remove all')} + </Button> + + {toMainPage && ( + <Button + className="" + color="secondary" + onClick={toMainPage} + title={t('Go to main page to add more input files')} + > + {t('Add more')} + </Button> + )} + </div> </div> ) - }, [clearQryInputs, qryInputs.length, t]) + }, [clearQryInputs, qryInputs.length, t, toMainPage]) if (qryInputs.length === 0) { return null diff --git a/packages_rs/nextclade-web/src/components/Main/RunPanel.tsx b/packages_rs/nextclade-web/src/components/Main/RunPanel.tsx index 9e573dc0d..30a26283e 100644 --- a/packages_rs/nextclade-web/src/components/Main/RunPanel.tsx +++ b/packages_rs/nextclade-web/src/components/Main/RunPanel.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useMemo } from 'react' import styled from 'styled-components' -import { Button, Form as FormBase, FormGroup } from 'reactstrap' +import { Button, Form as FormBase, FormGroup as FormGroupBase, FormGroupProps } from 'reactstrap' import { useRecoilValue } from 'recoil' import { useRunAnalysis } from 'src/hooks/useRunAnalysis' import { useRunSeqAutodetect } from 'src/hooks/useRunSeqAutodetect' @@ -15,6 +15,27 @@ import { useTranslationSafe } from 'src/helpers/useTranslationSafe' import { AlgorithmInputDefault } from 'src/io/AlgorithmInput' import { hasRequiredInputsAtom, useQuerySeqInputs } from 'src/state/inputs.state' +export function ToggleRunAutomatically({ ...restProps }: FormGroupProps) { + const { t } = useTranslationSafe() + const { state: shouldRunAutomatically, toggle: toggleRunAutomatically } = useRecoilToggle(shouldRunAutomaticallyAtom) + return ( + <FormGroup inline {...restProps}> + <Toggle + identifier="toggle-run-automatically" + checked={shouldRunAutomatically} + onCheckedChanged={toggleRunAutomatically} + > + <span title={t('Run Nextclade automatically after sequence data is provided')}>{t('Run automatically')}</span> + </Toggle> + </FormGroup> + ) +} + +const FormGroup = styled(FormGroupBase)` + display: flex; + margin: auto 0; +` + export function RunPanel() { const { t } = useTranslationSafe() @@ -22,7 +43,7 @@ export function RunPanel() { const { addQryInputs } = useQuerySeqInputs() const canRun = useRecoilValue(canRunAtom) - const { state: shouldRunAutomatically, toggle: toggleRunAutomatically } = useRecoilToggle(shouldRunAutomaticallyAtom) + const shouldRunAutomatically = useRecoilValue(shouldRunAutomaticallyAtom) const shouldSuggestDatasets = useRecoilValue(shouldSuggestDatasetsAtom) const hasRequiredInputs = useRecoilValue(hasRequiredInputsAtom) @@ -58,17 +79,7 @@ export function RunPanel() { <Container> <Form> <FlexLeft> - <FormGroup> - <Toggle - identifier="toggle-run-automatically" - checked={shouldRunAutomatically} - onCheckedChanged={toggleRunAutomatically} - > - <span title={t('Run Nextclade automatically after sequence data is provided')}> - {t('Run automatically')} - </span> - </Toggle> - </FormGroup> + <ToggleRunAutomatically /> </FlexLeft> <FlexRight> diff --git a/packages_rs/nextclade-web/src/components/Main/StepDatasetSelection.tsx b/packages_rs/nextclade-web/src/components/Main/StepDatasetSelection.tsx new file mode 100644 index 000000000..029d4dddc --- /dev/null +++ b/packages_rs/nextclade-web/src/components/Main/StepDatasetSelection.tsx @@ -0,0 +1,109 @@ +import { useRouter } from 'next/router' +import React, { useCallback } from 'react' +import { QuerySequenceList } from 'src/components/Main/QuerySequenceList' +import styled from 'styled-components' +import { useRecoilValue, useSetRecoilState } from 'recoil' +import { Row as RowBase, Col as ColBase } from 'reactstrap' +import { datasetCurrentAtom } from 'src/state/dataset.state' +import { DatasetAutosuggestionResultsList } from 'src/components/Main/DatasetSelector' +import { DatasetCurrent } from 'src/components/Main/DatasetCurrent' + +export interface StepDatasetSelectionProps { + toLanding(): void +} + +export function StepDatasetSelection() { + return ( + <Container> + <Main> + <DatasetSelection /> + </Main> + </Container> + ) +} + +const Container = styled.div` + display: flex; + flex: 1; + flex-direction: column; + overflow: hidden; +` + +const Main = styled.div` + display: flex; + flex: 1; + flex-direction: column; + overflow: hidden; +` + +const MainFixed = styled.div` + display: flex; + flex-direction: column; + overflow: hidden; + max-height: 20%; + margin-top: 12px; + padding: 0 15px; +` + +function DatasetSelection() { + const dataset = useRecoilValue(datasetCurrentAtom) + const setDataset = useSetRecoilState(datasetCurrentAtom) + + const { push } = useRouter() + const toMainPage = useCallback(() => { + void push('/') // eslint-disable-line no-void + }, [push]) + + return ( + <Wrapper> + <Row noGutters className="flex-column flex-lg-row h-100"> + <Col lg={6}> + <Container> + <MainFixed> + <QuerySequenceList toMainPage={toMainPage} /> + </MainFixed> + <Main> + <DatasetAutosuggestionResultsList datasetHighlighted={dataset} onDatasetHighlighted={setDataset} /> + </Main> + </Container> + </Col> + <Col lg={6}> + <DatasetCurrent /> + </Col> + </Row> + </Wrapper> + ) +} + +const Wrapper = styled.div` + display: flex; + flex: 1; + width: 100% !important; + + @media (min-width: 991.98px) { + overflow: hidden; + height: 100%; + } +` + +const Row = styled(RowBase)` + display: flex; + flex: 1; + width: 100% !important; + + @media (min-width: 991.98px) { + overflow: hidden; + height: 100%; + } +` + +const Col = styled(ColBase)` + display: flex; + flex: 1; + width: 100% !important; + + @media (min-width: 991.98px) { + overflow: hidden; + height: 100%; + } +` 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 <SuggestionPanelIdle /> + case AutodetectRunState.Started: + return <SuggestionPanelStarted /> + case AutodetectRunState.Done: + return <SuggestionPanelDone /> + case AutodetectRunState.Failed: + return <SuggestionPanelFailed /> + 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 ( + <ButtonRunStyled onClick={runSuggest} disabled={!canRun} color={color} title={title}> + {text} + </ButtonRunStyled> + ) +} + +export function ButtonSuggestionsReset() { + const { t } = useTranslationSafe() + const resetAutodetectResults = useResetSuggestions() + const hasAutodetectResults = useRecoilValue(hasAutodetectResultsAtom) + return ( + <ButtonResetStyled color="link" onClick={resetAutodetectResults} disabled={!hasAutodetectResults}> + {t('Reset')} + </ButtonResetStyled> + ) +} + +export function SuggestionPanelIdle() { return ( <Container> <Form> <FlexLeft> - <AutosuggestionToggle /> + <Alert color="none" fade={false} className="d-flex" closeClassName="d-none"> + <div> + <p className="m-0"> + <AutosuggestionToggle /> + </p> + <p className="m-0">{'\u00A0'}</p> + </div> + </Alert> </FlexLeft> + <FlexRight> + <ButtonSuggest /> + </FlexRight> + </Form> + </Container> + ) +} +export function SuggestionPanelStarted() { + const { t } = useTranslationSafe() + const numberAutodetectResults = useRecoilValue(numberAutodetectResultsAtom) + + return ( + <Container> + <Form> + <Alert color="none" fade={false} className="d-flex" closeClassName="d-none"> + <Spinner className="my-auto mr-3" /> + <div> + <p className="m-0">{t('Searching matching datasets')}</p> + <p className="m-0">{t(`${numberAutodetectResults} sequences`)}</p> + </div> + </Alert> + </Form> + </Container> + ) +} + +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 ( + <Alert color="warning" fade={false} closeClassName="d-none"> + <p className="my-0">{t('No matching datasets found.')}</p> + <p className="my-0">{t('Consider contributing a new dataset.')}</p> + </Alert> + ) + } + return ( + <Alert color="none" fade={false} closeClassName="d-none"> + <p className="my-0">{t(`${numSuggestedDatasets} dataset(s) appear to match your data.`)}</p> + <p className="my-0">{t('Select the one to use.')}</p> + </Alert> + ) + }, [numSuggestedDatasets, t]) + + return ( + <Container> + <Form> + <FlexLeft>{text}</FlexLeft> <FlexRight> - <Button color="link" onClick={resetAutodetectResults} disabled={!hasAutodetectResults}> - {t('Reset suggestions')} - </Button> + <ButtonSuggestionsReset /> + <ButtonSuggest /> + </FlexRight> + </Form> + </Container> + ) +} - <ButtonRunStyled onClick={runSuggest} disabled={!canRun} color={runButtonColor} title={runButtonTooltip}> - {t('Suggest')} - </ButtonRunStyled> +export function SuggestionPanelFailed() { + const { t } = useTranslationSafe() + return ( + <Container> + <Form> + <FlexLeft> + <Alert color="danger" fade={false} closeClassName="d-none"> + <p className="m-0">{t('Suggestion engine failed.')}</p> + <p className="m-0">{t('Please report this issue.')}</p> + </Alert> + </FlexLeft> + <FlexRight> + <ButtonSuggestionsReset /> + <ButtonSuggest /> </FlexRight> </Form> </Container> @@ -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 ( - <FormGroup> + <FormGroup {...restProps}> <Toggle identifier="toggle-suggest-datasets" checked={shouldSuggestDatasets} @@ -99,3 +257,7 @@ function AutosuggestionToggle() { </FormGroup> ) } + +const FormGroup = styled(FormGroupBase)` + margin: auto 0; +` diff --git a/packages_rs/nextclade-web/src/components/Main/Title.tsx b/packages_rs/nextclade-web/src/components/Main/Title.tsx new file mode 100644 index 000000000..d1fd27a33 --- /dev/null +++ b/packages_rs/nextclade-web/src/components/Main/Title.tsx @@ -0,0 +1,82 @@ +import React from 'react' + +import styled from 'styled-components' + +import { TITLE_COLORS } from 'src/constants' + +// eslint-disable-next-line prefer-destructuring +const PACKAGE_VERSION = process.env.PACKAGE_VERSION + +// Borrowed with modifications from Nextstrain.org +// https://github.com/nextstrain/nextstrain.org/blob/master/static-site/src/components/splash/title.jsx + +const TitleH1 = styled.h1` + display: inline; + margin-top: 0; + margin-bottom: 0; + font-weight: 300; + letter-spacing: -1px; + font-size: 6rem; + + @media (max-width: 767.98px) { + font-size: 5rem; + } + + @media (max-width: 576px) { + font-size: 3.5rem; + } +` + +const VersionNumberBadge = styled.p` + display: inline; + font-size: 0.85rem; + color: #7b838a; + + @media (max-width: 767.98px) { + left: -35px; + font-size: 0.8rem; + } + + @media (max-width: 576px) { + left: -30px; + font-size: 0.75rem; + } +` + +const LetterSpan = styled.span<{ pos: number }>` + color: ${(props) => TITLE_COLORS[props.pos]}; +` + +export function Title() { + return ( + <span> + <TitleH1> + {'Nextclade'.split('').map((letter, i) => ( + // eslint-disable-next-line react/no-array-index-key + <LetterSpan key={`${i}_${letter}`} pos={i}> + {letter} + </LetterSpan> + ))} + </TitleH1> + {PACKAGE_VERSION && <VersionNumberBadge color="secondary">{`v${PACKAGE_VERSION}`}</VersionNumberBadge>} + </span> + ) +} + +export const Subtitle = styled.h2` + text-align: center; + font-size: 2rem; + font-weight: 300; + + @media (max-width: 991.98px) { + font-size: 1.5rem; + } + + @media (max-width: 767.98px) { + font-size: 1.2rem; + } + + @media (max-width: 576px) { + font-size: 1rem; + } +` diff --git a/packages_rs/nextclade-web/src/helpers/search.ts b/packages_rs/nextclade-web/src/helpers/search.ts index cc5647d23..7e8b4c66b 100644 --- a/packages_rs/nextclade-web/src/helpers/search.ts +++ b/packages_rs/nextclade-web/src/helpers/search.ts @@ -8,7 +8,7 @@ export function startsWithLowerCase(candidate: string, searchTerm: string): bool return candidate.toLowerCase().startsWith(searchTerm.toLowerCase()) } -/** Parition array in 3 parts: items starting with term, items including term and items not including term */ +/** Partition array in 3 parts: items starting with term, items including term and items not including term */ export function search<T>(items: T[], term: string, getter: (item: T) => string[]) { const [itemsStartWith, itemsNotStartWith] = partition(items, (item) => getter(item).some((candidate) => startsWithLowerCase(candidate, term)), 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/io/fetchDatasets.ts b/packages_rs/nextclade-web/src/io/fetchDatasets.ts index be86b86cd..de8da1bb3 100644 --- a/packages_rs/nextclade-web/src/io/fetchDatasets.ts +++ b/packages_rs/nextclade-web/src/io/fetchDatasets.ts @@ -126,6 +126,7 @@ export function useUpdatedDatasetIndex() { const datasetServerUrl = useRecoilValue(datasetServerUrlAtom) const setDatasetsState = useSetRecoilState(datasetsAtom) const setMinimizerIndexVersion = useSetRecoilState(minimizerIndexVersionAtom) + useQuery( 'refetchDatasetIndex', async () => { diff --git a/packages_rs/nextclade-web/src/mdx-components.tsx b/packages_rs/nextclade-web/src/mdx-components.tsx new file mode 100644 index 000000000..a08990d95 --- /dev/null +++ b/packages_rs/nextclade-web/src/mdx-components.tsx @@ -0,0 +1,136 @@ +import React from 'react' +import styled from 'styled-components' +import { TableSlim } from 'src/components/Common/TableSlim' +import { LinkExternal } from 'src/components/Link/LinkExternal' + +export const H1 = styled.h1` + font-weight: bold; + font-size: 1.55rem; +` + +export const H2 = styled.h2` + font-size: 1.33rem; + font-weight: bold; + + margin-top: 1.25rem; + margin-bottom: 0.75rem; + + :first-child { + margin-top: 0; + } + + border-radius: 5px; +` + +export const H3 = styled.h3` + font-size: 1.25rem; + font-weight: bold; + + margin: 0 !important; + margin-top: 1rem !important; + + code { + font-size: 1.2rem; + background-color: #eaeaea; + border-radius: 5px; + overflow-wrap: break-word; + white-space: pre-wrap; + + @media (max-width: 992px) { + font-size: 1.1rem; + } + } +` + +export const H4 = styled.h4` + font-size: 1.2rem; + font-weight: bold; + + @media (max-width: 992px) { + font-size: 1.2rem; + margin-top: 1.2rem; + } +` + +export const H5 = styled.h5` + font-size: 1.1rem; + font-weight: bold; + margin-top: 1.1rem; + + @media (max-width: 992px) { + font-size: 1rem; + margin-top: 1.1rem; + } +` + +export const H6 = styled.h6` + font-size: 1rem; + font-weight: bold; +` + +export const Blockquote = styled.blockquote` + border-radius: 3px; + background-color: #f4ebbd; +` + +export const P = styled.p` + margin: 3px 7px; + + code { + font-size: 0.8rem; + padding: 1px 5px; + background-color: #eaeaea; + border-radius: 5px; + overflow-wrap: break-word; + white-space: pre-wrap; + } +` + +export const Pre = styled.pre` + margin: 0.3rem 0.8rem; + padding: 0.7rem 0.5rem; + + background-color: #eaeaea; + border-radius: 5px; + overflow: hidden; + overflow-wrap: break-word; + white-space: pre-wrap; + line-height: 1.33rem; + font-size: 0.8rem; +` + +export const Code = styled.code` + font-size: 0.8rem; + padding: 1px 0; + background-color: #eaeaea; + border-radius: 5px; + overflow-wrap: break-word; + white-space: pre-wrap; +` + +export const Li = styled.li` + & > p { + margin: 0; + padding: 0; + } +` + +function Table({ ...restProps }) { + return <TableSlim striped {...restProps} /> +} + +export const mdxComponents = { + h1: H1, + h2: H2, + h3: H3, + h4: H4, + h5: H5, + h6: H6, + p: P, + a: LinkExternal, + blockquote: Blockquote, + li: Li, + pre: Pre, + code: Code, + table: Table, +} diff --git a/packages_rs/nextclade-web/src/pages/_app.tsx b/packages_rs/nextclade-web/src/pages/_app.tsx index 68eb1b18f..42690a671 100644 --- a/packages_rs/nextclade-web/src/pages/_app.tsx +++ b/packages_rs/nextclade-web/src/pages/_app.tsx @@ -12,6 +12,7 @@ import { sanitizeError } from 'src/helpers/sanitizeError' import { useRunAnalysis } from 'src/hooks/useRunAnalysis' import i18nAuspice, { changeAuspiceLocale } from 'src/i18n/i18n.auspice' import { createInputFastasFromUrlParam, createInputFromUrlParamMaybe } from 'src/io/createInputFromUrlParamMaybe' +import { mdxComponents } from 'src/mdx-components' import LoadingPage from 'src/pages/loading' import { globalErrorAtom } from 'src/state/error.state' import { @@ -42,7 +43,6 @@ import { parseUrl } from 'src/helpers/parseUrl' import { getDatasetServerUrl, initializeDatasets } from 'src/io/fetchDatasets' import { fetchSingleDataset } from 'src/io/fetchSingleDataset' import { ErrorPopup } from 'src/components/Error/ErrorPopup' -import { LinkExternal } from 'src/components/Link/LinkExternal' import { SEO } from 'src/components/Common/SEO' import { Plausible } from 'src/components/Common/Plausible' import i18n, { changeLocale, getLocaleWithKey } from 'src/i18n/i18n' @@ -172,8 +172,6 @@ export function RecoilStateInitializer() { return null } -const mdxComponents = { a: LinkExternal } - const REACT_QUERY_OPTIONS: QueryClientConfig = { defaultOptions: { queries: { suspense: true, retry: 1 } }, } diff --git a/packages_rs/nextclade-web/src/pages/_error.tsx b/packages_rs/nextclade-web/src/pages/_error.tsx index b1920ea3f..00f7357a7 100644 --- a/packages_rs/nextclade-web/src/pages/_error.tsx +++ b/packages_rs/nextclade-web/src/pages/_error.tsx @@ -7,6 +7,7 @@ import { ErrorContent } from 'src/components/Error/ErrorContent' import { RestartButton } from 'src/components/Error/ErrorStyles' import { Layout } from 'src/components/Layout/Layout' import { useTranslationSafe } from 'src/helpers/useTranslationSafe' +import { MainSectionTitle } from 'src/components/Main/MainSectionTitle' import styled from 'styled-components' export const Container = styled(ContainerBase)` @@ -72,6 +73,8 @@ function ErrorPage({ statusCode, title, error }: ErrorPageProps) { return ( <Layout> <MainContent> + <MainSectionTitle /> + <Row noGutters> <Col className="text-center text-danger"> <h2>{titleText}</h2> diff --git a/packages_rs/nextclade-web/src/pages/dataset.tsx b/packages_rs/nextclade-web/src/pages/dataset.tsx new file mode 100644 index 000000000..539c02fbf --- /dev/null +++ b/packages_rs/nextclade-web/src/pages/dataset.tsx @@ -0,0 +1 @@ +export { DatasetPage as default } from 'src/components/Main/DatasetPage' diff --git a/packages_rs/nextclade-web/src/state/autodetect.state.ts b/packages_rs/nextclade-web/src/state/autodetect.state.ts index 5697f4875..127d0932a 100644 --- a/packages_rs/nextclade-web/src/state/autodetect.state.ts +++ b/packages_rs/nextclade-web/src/state/autodetect.state.ts @@ -1,9 +1,8 @@ /* eslint-disable no-loops/no-loops */ -import unique from 'fork-ts-checker-webpack-plugin/lib/utils/array/unique' -import { isEmpty, isNil } from 'lodash' +import { isEmpty, isNil, uniq } from 'lodash' import { atom, atomFamily, DefaultValue, selector, selectorFamily } from 'recoil' -import type { MinimizerIndexJson, MinimizerSearchRecord } from 'src/types' import { isDefaultValue } from 'src/state/utils/isDefaultValue' +import type { MinimizerIndexJson, MinimizerSearchRecord } from 'src/types' export const minimizerIndexAtom = atom<MinimizerIndexJson>({ key: 'minimizerIndexAtom', @@ -51,7 +50,7 @@ export const autodetectResultByIndexAtom = selectorFamily<MinimizerSearchRecord, export const DATASET_ID_UNDETECTED = 'undetected' export function groupByDatasets(records: MinimizerSearchRecord[]): Record<string, MinimizerSearchRecord[]> { - const names = unique(records.flatMap((record) => record.result.datasets.map((dataset) => dataset.name))) + const names = uniq(records.flatMap((record) => record.result.datasets.map((dataset) => dataset.name))) let byDataset = {} for (const name of names) { const selectedRecords = records.filter((record) => record.result.datasets.some((dataset) => dataset.name === name)) @@ -131,3 +130,8 @@ export const autodetectRunStateAtom = atom<AutodetectRunState>({ key: 'autodetectRunStateAtom', default: AutodetectRunState.Idle, }) + +export const isAutodetectRunningAtom = selector({ + key: 'isAutodetectRunningAtom', + get: ({ get }) => get(autodetectRunStateAtom) === AutodetectRunState.Started, +}) diff --git a/packages_rs/nextclade-web/src/state/dataset.state.ts b/packages_rs/nextclade-web/src/state/dataset.state.ts index edc7f8d81..744202c5d 100644 --- a/packages_rs/nextclade-web/src/state/dataset.state.ts +++ b/packages_rs/nextclade-web/src/state/dataset.state.ts @@ -14,7 +14,6 @@ export interface Datasets { export const datasetServerUrlAtom = atom<string>({ key: 'datasetServerUrlAtom', - default: '/', }) export const datasetsAtom = atom<Datasets>({ 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<AlgorithmInput[]>({ key: 'qrySeqInputsStorage', @@ -13,7 +13,7 @@ export const qrySeqInputsStorageAtom = atom<AlgorithmInput[]>({ 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/yarn.lock b/packages_rs/nextclade-web/yarn.lock index 82b1949ba..5c689d653 100644 --- a/packages_rs/nextclade-web/yarn.lock +++ b/packages_rs/nextclade-web/yarn.lock @@ -13539,6 +13539,11 @@ react-transition-group@^4.3.0, react-transition-group@^4.4.2: loose-envify "^1.4.0" prop-types "^15.6.2" +react-use-wizard@2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/react-use-wizard/-/react-use-wizard-2.2.3.tgz#d2e57b299b6c35fcca90f3a6deb2d0a81318b466" + integrity sha512-Oh3EfpmWwF7vW1YZ2EgSLoU9VKJ13+TyLNmT56rBhLoXr3FXZoDEmtyxZ46GVkcicl/8qiHX0C42M6n2oKDkEQ== + react-virtualized-auto-sizer@1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.6.tgz#66c5b1c9278064c5ef1699ed40a29c11518f97ca"