diff --git a/apps/web/src/analyses/components/Create/CreateAnalysisForm.tsx b/apps/web/src/analyses/components/Create/CreateAnalysisForm.tsx index 51f277cf0..669968086 100644 --- a/apps/web/src/analyses/components/Create/CreateAnalysisForm.tsx +++ b/apps/web/src/analyses/components/Create/CreateAnalysisForm.tsx @@ -134,11 +134,11 @@ export default function CreateAnalysisForm({ rules={{ required: true }} /> - + {errors.indexId && "A reference must be selected"} - + +
Reference {indexes.length ? ( ) : ( - + diff --git a/apps/web/src/analyses/components/Create/QuickAnalyze.tsx b/apps/web/src/analyses/components/Create/QuickAnalyze.tsx index 1d70f7b36..c55f041d5 100644 --- a/apps/web/src/analyses/components/Create/QuickAnalyze.tsx +++ b/apps/web/src/analyses/components/Create/QuickAnalyze.tsx @@ -2,7 +2,6 @@ import { Dialog, DialogTitle } from "@base/Dialog"; import QueryError from "@base/QueryError"; import { useListHmms } from "@hmm/queries"; import type { SampleMinimal } from "@samples/types"; -import { useEffect } from "react"; import HMMAlert from "../HMMAlert"; import CreateAnalysisDialogContent from "./CreateAnalysisDialogContent"; import CreateAnalysisForm from "./CreateAnalysisForm"; @@ -10,32 +9,27 @@ import { SelectedSamples } from "./SelectedSamples"; import { getCompatibleWorkflows } from "./workflows"; type QuickAnalyzeProps = { + /** Whether the samples came from the list selection rather than a single sample */ + fromSelection: boolean; + open: boolean; - /** A callback function to clear selected samples */ - onClear: () => void; setOpen: (open: boolean) => void; - /** The selected samples */ + /** The samples to analyze */ samples: SampleMinimal[]; }; /** - * A form for triggering quick analyses on selected samples + * A form for triggering quick analyses on the passed samples */ export default function QuickAnalyze({ + fromSelection, open, samples, setOpen, }: QuickAnalyzeProps) { const { data: hmms, isPending, isError } = useListHmms(1, 1, ""); - // The dialog should close when all selected samples have been analyzed and deselected. - useEffect(() => { - if (open && samples.length === 0) { - setOpen(false); - } - }, [open, samples, setOpen]); - if (isError && !hmms) { return ( @@ -61,7 +55,7 @@ export default function QuickAnalyze({ Quick Analyze - + +
- Selected Samples {samples.length} + {fromSelection ? ( + <> + Selected Samples {samples.length} + + ) : ( + "Selected Sample" + )}
{samples.map(({ id, name }) => ( @@ -35,6 +46,6 @@ export function SelectedSamples({ samples }: SelectedSamplesProps) { ))}
- +
); } diff --git a/apps/web/src/analyses/components/Create/SubtractionSelector.tsx b/apps/web/src/analyses/components/Create/SubtractionSelector.tsx index 82ec6f0d4..f08f2379e 100644 --- a/apps/web/src/analyses/components/Create/SubtractionSelector.tsx +++ b/apps/web/src/analyses/components/Create/SubtractionSelector.tsx @@ -30,7 +30,7 @@ export default function SubtractionSelector({ } return ( -
+
label="Subtractions" items={results} diff --git a/apps/web/src/analyses/components/Create/WorkflowSelector.tsx b/apps/web/src/analyses/components/Create/WorkflowSelector.tsx index 4780e1734..46440d4aa 100644 --- a/apps/web/src/analyses/components/Create/WorkflowSelector.tsx +++ b/apps/web/src/analyses/components/Create/WorkflowSelector.tsx @@ -1,7 +1,4 @@ -import BoxGroup from "@base/BoxGroup"; -import BoxGroupSection from "@base/BoxGroupSection"; -import { RadioGroup, RadioGroupItem } from "@base/RadioGroup"; -import CreateAnalysisFieldTitle from "./CreateAnalysisFieldTitle"; +import { SelectBox, SelectBoxItem } from "@base/SelectBox"; import type { workflow } from "./workflows"; type WorkflowSelectorProps = { @@ -16,7 +13,7 @@ type WorkflowSelectorProps = { }; /** - * A radio group for choosing which analysis workflow to run. + * A boxed picker for choosing which analysis workflow to run. */ export default function WorkflowSelector({ workflows, @@ -24,33 +21,20 @@ export default function WorkflowSelector({ onChange, }: WorkflowSelectorProps) { return ( -
- Workflow - + - - {workflows.map((workflow) => ( - - - - - ))} - - + {workflows.map(({ description, id, name }) => ( + +
{name}
+ {description} +
+ ))} +
); } diff --git a/apps/web/src/analyses/components/Create/__tests__/WorkflowSelector.test.tsx b/apps/web/src/analyses/components/Create/__tests__/WorkflowSelector.test.tsx index caeda75a7..329cd10ce 100644 --- a/apps/web/src/analyses/components/Create/__tests__/WorkflowSelector.test.tsx +++ b/apps/web/src/analyses/components/Create/__tests__/WorkflowSelector.test.tsx @@ -6,6 +6,9 @@ import { describe, expect, it } from "vitest"; import WorkflowSelector from "../WorkflowSelector"; import { nuvsWorkflow, pathoscopeWorkflow } from "../workflows"; +const pathoscopeName = `${pathoscopeWorkflow.name} ${pathoscopeWorkflow.description}`; +const nuvsName = `${nuvsWorkflow.name} ${nuvsWorkflow.description}`; + function Harness() { const [selected, setSelected] = useState(pathoscopeWorkflow.id); @@ -23,24 +26,33 @@ describe("", () => { renderWithProviders(); expect(screen.getByRole("radiogroup", { name: "Workflow" })).toBeVisible(); - expect(screen.getByRole("radio", { name: "Pathoscope" })).toBeChecked(); - expect(screen.getByRole("radio", { name: "NuVs" })).not.toBeChecked(); + expect(screen.getByRole("radio", { name: pathoscopeName })).toBeChecked(); + expect(screen.getByRole("radio", { name: nuvsName })).not.toBeChecked(); + }); + + it("describes what each workflow finds", () => { + renderWithProviders(); + + expect(screen.getByText("Find known viruses.")).toBeVisible(); + expect(screen.getByText("Find novel viruses.")).toBeVisible(); }); it("selects a workflow when its option is chosen", async () => { renderWithProviders(); - await userEvent.click(screen.getByRole("radio", { name: "NuVs" })); + await userEvent.click(screen.getByRole("radio", { name: nuvsName })); - expect(screen.getByRole("radio", { name: "NuVs" })).toBeChecked(); - expect(screen.getByRole("radio", { name: "Pathoscope" })).not.toBeChecked(); + expect(screen.getByRole("radio", { name: nuvsName })).toBeChecked(); + expect( + screen.getByRole("radio", { name: pathoscopeName }), + ).not.toBeChecked(); }); - it("selects a workflow when its label is clicked", async () => { + it("selects a workflow when its name is clicked", async () => { renderWithProviders(); - await userEvent.click(screen.getByText("NuVs")); + await userEvent.click(screen.getByText(nuvsWorkflow.name)); - expect(screen.getByRole("radio", { name: "NuVs" })).toBeChecked(); + expect(screen.getByRole("radio", { name: nuvsName })).toBeChecked(); }); }); diff --git a/apps/web/src/analyses/components/Create/workflows.ts b/apps/web/src/analyses/components/Create/workflows.ts index 570c1f34f..4dbb9ba1a 100644 --- a/apps/web/src/analyses/components/Create/workflows.ts +++ b/apps/web/src/analyses/components/Create/workflows.ts @@ -1,14 +1,17 @@ export type workflow = { + description: string; id: string; name: string; }; export const pathoscopeWorkflow = { + description: "Find known viruses.", id: "pathoscope", name: "Pathoscope", }; export const nuvsWorkflow = { + description: "Find novel viruses.", id: "nuvs", name: "NuVs", }; diff --git a/apps/web/src/base/IconButton.tsx b/apps/web/src/base/IconButton.tsx index 61192830b..fc772fa72 100644 --- a/apps/web/src/base/IconButton.tsx +++ b/apps/web/src/base/IconButton.tsx @@ -4,6 +4,8 @@ import Tooltip from "./Tooltip"; import type { IconColor } from "./types"; export type IconButtonProps = { + /** Accessible name for the button. Defaults to ``tip``. */ + ariaLabel?: string; className?: string; color?: IconColor; IconComponent: LucideIcon; @@ -17,6 +19,7 @@ export type IconButtonProps = { * A styled clickable icon with tooltip describing its action */ export default function IconButton({ + ariaLabel, className, color = "black", IconComponent, @@ -55,7 +58,7 @@ export default function IconButton({ }, className, )} - aria-label={tip} + aria-label={ariaLabel ?? tip} type="button" onClick={onClick} > diff --git a/apps/web/src/base/SelectBox.tsx b/apps/web/src/base/SelectBox.tsx index db8943821..0fbdcf687 100644 --- a/apps/web/src/base/SelectBox.tsx +++ b/apps/web/src/base/SelectBox.tsx @@ -21,7 +21,7 @@ export function SelectBox({ const labelId = useId(); return ( -
+
{label} Create Reference - setMode(value as "empty" | "import")} - value={mode} - > - -
Empty
- Start from a blank reference. -
- -
Import
- - Create a reference from a file previously exported from another - Virtool reference. - -
-
+
+ setMode(value as "empty" | "import")} + value={mode} + > + +
Empty
+ Start from a blank reference. +
+ +
Import
+ + Create a reference from a file previously exported from another + Virtool reference. + +
+
+
diff --git a/apps/web/src/samples/components/Item/EndIcon.tsx b/apps/web/src/samples/components/Item/EndIcon.tsx index fbd9933bc..aecc05a22 100644 --- a/apps/web/src/samples/components/Item/EndIcon.tsx +++ b/apps/web/src/samples/components/Item/EndIcon.tsx @@ -5,6 +5,9 @@ import { ChartArea } from "lucide-react"; import { cn } from "@/app/utils"; type SampleItemEndIconProps = { + /** Accessible name for the quick analyze button */ + ariaLabel: string; + /** Progress of the job responsible for creating the sample */ progress: number; @@ -25,6 +28,7 @@ type SampleItemEndIconProps = { * Icon indicating the status of sample */ export default function SampleItemEndIcon({ + ariaLabel, onClick, ready, progress, @@ -40,6 +44,7 @@ export default function SampleItemEndIcon({ return (
void; - /** Callback to handle sample selection on end icon quick analysis */ - selectOnQuickAnalyze: () => void; - setOpenQuickAnalyze: (open: boolean) => void; + /** Callback to open a quick analysis scoped to this sample */ + onQuickAnalyze: () => void; }; /** @@ -31,20 +30,15 @@ export default function SampleItem({ sample, checked, handleSelect, - selectOnQuickAnalyze, - setOpenQuickAnalyze, + onQuickAnalyze, }: SampleItemProps) { - function onQuickAnalyze() { - selectOnQuickAnalyze(); - setOpenQuickAnalyze(true); - } - const { data: job } = useFetchJob(sample.job?.id ?? Number.NaN, sample.job); return (
)} void; + + /** A callback to open a quick analysis scoped to the selected samples */ + onQuickAnalyze: () => void; + /** A list of selected samples */ selected: string[]; - setOpenQuickAnalyze: (open: boolean) => void; }; /** @@ -15,15 +18,15 @@ type SampleSelectionToolbarProps = { */ export default function SampleSelectionToolbar({ onClear, + onQuickAnalyze, selected, - setOpenQuickAnalyze, }: SampleSelectionToolbarProps) { return (
-
diff --git a/apps/web/src/samples/components/SamplesList.tsx b/apps/web/src/samples/components/SamplesList.tsx index 6b790927d..193a34777 100644 --- a/apps/web/src/samples/components/SamplesList.tsx +++ b/apps/web/src/samples/components/SamplesList.tsx @@ -11,7 +11,7 @@ import { useListIndexes } from "@indexes/queries"; import type { Label } from "@labels/types"; import { useListSamples } from "@samples/queries"; import type { SampleMinimal } from "@samples/types"; -import { intersectionWith, union, xor } from "es-toolkit/array"; +import { intersectionWith, xor } from "es-toolkit/array"; import { FlaskConical, SearchX } from "lucide-react"; import { useState } from "react"; import SampleFilters from "./Filter/SampleFilters"; @@ -19,6 +19,13 @@ import SampleItem from "./Item/SampleItem"; import SampleToolbar from "./SamplesToolbar"; import SampleLabels from "./Sidebar/ManageLabels"; +type QuickAnalyzeTarget = { + /** Whether the samples came from the list selection rather than a single sample */ + fromSelection: boolean; + + samples: SampleMinimal[]; +}; + type SamplesListProps = { labels: Label[]; filterLabels?: number[]; @@ -54,6 +61,15 @@ export default function SamplesList({ const [selected, setSelected] = useState([]); const [openQuickAnalyze, setOpenQuickAnalyze] = useState(false); + const [quickAnalyzeTarget, setQuickAnalyzeTarget] = + useState({ fromSelection: false, samples: [] }); + + // Held separately from ``selected`` so a row's quick analyze can ignore the + // checkbox selection, and so the samples outlive the dialog's exit animation. + function openQuickAnalyzeFor(target: QuickAnalyzeTarget) { + setQuickAnalyzeTarget(target); + setOpenQuickAnalyze(true); + } if ((isErrorSamples || isErrorIndexes) && !samples) { return ; @@ -65,25 +81,26 @@ export default function SamplesList({ const { items, page, page_count, total_count } = samples; + const selectedSamples = intersectionWith( + items, + selected, + (item, id) => item.id === id, + ); + function renderRow(item: SampleMinimal) { function handleSelect() { setSelected(xor(selected, [item.id])); } - function selectOnQuickAnalyze() { - if (!selected.includes(item.id)) { - setSelected(union(selected, [item.id])); - } - } - return ( + openQuickAnalyzeFor({ fromSelection: false, samples: [item] }) + } /> ); } @@ -91,14 +108,10 @@ export default function SamplesList({ return ( <> setSelected([])} setOpen={setOpenQuickAnalyze} - samples={intersectionWith( - items, - selected, - (item, id) => item.id === id, - )} + samples={quickAnalyzeTarget.samples} />
setSelected([])} - setOpenQuickAnalyze={setOpenQuickAnalyze} + onQuickAnalyze={() => + openQuickAnalyzeFor({ + fromSelection: true, + samples: selectedSamples, + }) + } term={term} onChange={(e) => setSearch({ term: e.target.value })} /> @@ -151,14 +169,7 @@ export default function SamplesList({ )}
{selected.length ? ( - item.id === id, - )} - /> + ) : ( ) => void; + term: string; +}; + +function SampleSearchToolbar({ onChange, term }: SampleSearchToolbarProps) { const { hasPermission: canCreate } = useCheckAdminRoleOrPermission("create_sample"); @@ -26,6 +32,17 @@ function SampleSearchToolbar({ onChange, term }) { ); } +type SampleToolbarProps = SampleSearchToolbarProps & { + /** A callback function to clear selected samples */ + onClear: () => void; + + /** A callback to open a quick analysis scoped to the selected samples */ + onQuickAnalyze: () => void; + + /** A list of selected samples */ + selected: string[]; +}; + /** * A toolbar allowing samples to be filtered by name and to create an analysis for selected samples */ @@ -33,14 +50,14 @@ export default function SampleToolbar({ selected, onClear, onChange, - setOpenQuickAnalyze, + onQuickAnalyze, term, -}) { +}: SampleToolbarProps) { return selected.length ? ( ) : ( diff --git a/apps/web/src/samples/components/__tests__/SamplesList.test.tsx b/apps/web/src/samples/components/__tests__/SamplesList.test.tsx index b2cbe0889..7e3d6b187 100644 --- a/apps/web/src/samples/components/__tests__/SamplesList.test.tsx +++ b/apps/web/src/samples/components/__tests__/SamplesList.test.tsx @@ -1,4 +1,4 @@ -import { screen } from "@testing-library/react"; +import { screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { createFakeAccount, mockApiGetAccount } from "@tests/fake/account"; import { createFakeHmmSearchResults, mockApiGetHmms } from "@tests/fake/hmm"; @@ -100,4 +100,118 @@ describe("", () => { screen.queryByRole("link", { name: "Create" }), ).not.toBeInTheDocument(); }); + + describe("quick analyze", () => { + it("should scope to the clicked sample, ignoring the selection", async () => { + await renderWithRouter(, path); + expect(await screen.findByText("Samples")).toBeInTheDocument(); + + const selectedSample = at(samples, 0); + const clickedSample = at(samples, 1); + + await userEvent.click( + screen.getByRole("checkbox", { name: `Select ${selectedSample.name}` }), + ); + await userEvent.click( + screen.getByRole("button", { + name: `Quick analyze ${clickedSample.name}`, + }), + ); + + const dialog = await screen.findByRole("dialog"); + + expect(within(dialog).getByText(clickedSample.name)).toBeInTheDocument(); + expect( + within(dialog).queryByText(selectedSample.name), + ).not.toBeInTheDocument(); + }); + + it("should title a single clicked sample without a count", async () => { + await renderWithRouter(, path); + expect(await screen.findByText("Samples")).toBeInTheDocument(); + + await userEvent.click( + screen.getByRole("button", { + name: `Quick analyze ${at(samples, 0).name}`, + }), + ); + + const dialog = await screen.findByRole("dialog"); + + expect(within(dialog).getByText("Selected Sample")).toBeInTheDocument(); + expect( + within(dialog).queryByText("Selected Samples"), + ).not.toBeInTheDocument(); + }); + + it("should count a single-sample selection from the toolbar", async () => { + await renderWithRouter(, path); + expect(await screen.findByText("Samples")).toBeInTheDocument(); + + await userEvent.click( + screen.getByRole("checkbox", { name: `Select ${at(samples, 0).name}` }), + ); + await userEvent.click( + screen.getByRole("button", { name: "Quick Analyze" }), + ); + + const dialog = await screen.findByRole("dialog"); + + const title = within(dialog).getByText("Selected Samples"); + expect(within(title).getByText("1")).toBeInTheDocument(); + }); + + it("should leave the existing selection intact after a row is analyzed", async () => { + await renderWithRouter(, path); + expect(await screen.findByText("Samples")).toBeInTheDocument(); + + const selectedSample = at(samples, 0); + const clickedSample = at(samples, 1); + + await userEvent.click( + screen.getByRole("checkbox", { name: `Select ${selectedSample.name}` }), + ); + await userEvent.click( + screen.getByRole("button", { + name: `Quick analyze ${clickedSample.name}`, + }), + ); + + expect(await screen.findByRole("dialog")).toBeInTheDocument(); + + await userEvent.keyboard("{Escape}"); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + + expect( + screen.getByRole("checkbox", { name: `Select ${selectedSample.name}` }), + ).toBeChecked(); + expect( + screen.getByRole("checkbox", { name: `Select ${clickedSample.name}` }), + ).not.toBeChecked(); + }); + + it("should include every selected sample when triggered from the toolbar", async () => { + await renderWithRouter(, path); + expect(await screen.findByText("Samples")).toBeInTheDocument(); + + for (const sample of samples) { + await userEvent.click( + screen.getByRole("checkbox", { name: `Select ${sample.name}` }), + ); + } + + await userEvent.click( + screen.getByRole("button", { name: "Quick Analyze" }), + ); + + const dialog = await screen.findByRole("dialog"); + + const title = within(dialog).getByText("Selected Samples"); + expect(within(title).getByText("2")).toBeInTheDocument(); + + for (const sample of samples) { + expect(within(dialog).getByText(sample.name)).toBeInTheDocument(); + } + }); + }); });