Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions app/_home/query-flow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
// cBioPortal's homepage flow: choose studies, then explore them (the lab's Explore, scoped to them)
// or query them by gene (the gene query dialog, whose "Submit query" opens the Results page). The
// homepage holds it full width and keeps the selection in its URL; the Results page's "Modify
// query" opens it with what the page queried, the dialog starting from the page's genes.
// query" opens it in a dialog with what the page queried, the gene dialog starting from the page's
// genes, and without the way into Explore.
import { useState } from "react";
import { Dna, LayoutDashboard } from "lucide-react";
import { Button } from "../_ui/button";
Expand All @@ -25,8 +26,10 @@ const analyzeHref = (id: string): string => "/lab?" + new URLSearchParams({ tab:
/** Explore on one study with Custom data → Add data open (the lab reads `custom=add`) */
const addDataHref = (id: string): string => "/lab?" + new URLSearchParams({ tab: "explore", study: id, custom: "add" }).toString();

export function QueryFlow({ initial, onStudies, queryTitle, className }: {
export function QueryFlow({ initial, onStudies, queryTitle, explore = true, className }: {
initial: ResultsTarget;
/** offer "Explore selected studies" beside "Query by gene" */
explore?: boolean;
/** the gene dialog's title when "Query by gene" opens it (the homepage's own wording) */
queryTitle?: string;
/** the selection as it changes, for a page that keeps it in its URL */
Expand All @@ -47,7 +50,7 @@ export function QueryFlow({ initial, onStudies, queryTitle, className }: {
<section data-tw className={cn("rounded-lg border bg-card px-4.5 py-4", className)} aria-label="choose studies">
<QueryGenesDialog target={asking} names={names} onClose={() => setAsking(null)} />
<div className="mb-2.5 flex flex-wrap items-baseline justify-between gap-2.5">
<h2 className="m-0 text-lg font-semibold">Select studies to explore or query</h2>
<h2 className="m-0 text-lg font-semibold">{explore ? "Select studies to explore or query" : "Select studies to query"}</h2>
<SelectionStats studies={studies} selected={chosen} loading={loading} onClear={() => choose([])} />
</div>
{filters.length > 0 && (
Expand All @@ -69,10 +72,10 @@ export function QueryFlow({ initial, onStudies, queryTitle, className }: {
<div className="flex flex-wrap justify-center gap-2.5">
<Button className="querybygene" disabled={!!queryWhy} title={queryWhy || "enter genes to query in the selected studies"}
onClick={() => setAsking({ studies: chosen, filters, query: initial.query, title: queryTitle })}><Dna />Query by gene</Button>
<Button variant="outline" className="exploreselected" disabled={!chosen.length} asChild={!!chosen.length}
{explore && <Button variant="outline" className="exploreselected" disabled={!chosen.length} asChild={!!chosen.length}
title={chosen.length ? "open the selected studies' summary in Explore" + (filters.length ? " (the filters do not carry over)" : "") : "select at least one study"}>
{chosen.length ? <a href={exploreHref(chosen)}><LayoutDashboard />Explore selected studies</a> : <><LayoutDashboard />Explore selected studies</>}
</Button>
</Button>}
</div>
<span aria-hidden="true" />
</div>
Expand Down
11 changes: 8 additions & 3 deletions app/portal/portal-chrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
// tabs (the study, the query, the groups and their sizes).
import { SeriesSwatch } from "../lab/view-chrome.tsx";
import { Badge } from "../_ui/badge";
import { Button } from "../_ui/button";
import { Pencil } from "lucide-react";
import { fmtInt, fmtPct } from "../../lib/chart/format.ts";

/** one group's swatch, name and size; the swatch is the colour the figures draw it in */
Expand All @@ -21,16 +23,18 @@ function GroupChip({ name, i, n }: { name: string; i: number; n?: number }) {
* genes), the headline answer beside it, and the groups every tab compares — with what the page
* left out for this study, so a missing tab is never silent.
*/
export function PortalSummary({ eyebrow, title, study, studyTitle, detail, genes, groups, counts, left, stat, action, children }: {
export function PortalSummary({ eyebrow, title, study, studyTitle, detail, genes, onEditGenes, groups, counts, left, stat, action, children }: {
eyebrow: string; title: string;
/** the study id, or the ids summarised; `studyTitle` lists them all */
study: string; studyTitle?: string; detail?: React.ReactNode; genes?: string[];
/** opens the gene query on the page's own genes (the Results page) */
onEditGenes?: () => void;
groups: string[]; counts: Record<string, number> | null; left: string[];
/** the headline number, right of the title */
stat?: { value: number; of: number; label: string } | null;
/** a control over the whole page (the Results page's "Modify query"), at the end of the groups row */
action?: React.ReactNode;
/** below the groups: the query form when it is open */
/** below the groups */
children?: React.ReactNode;
}) {
return (
Expand All @@ -44,7 +48,8 @@ export function PortalSummary({ eyebrow, title, study, studyTitle, detail, genes
{detail && <><span aria-hidden="true">·</span>{detail}</>}
{genes && genes.length > 0 && <>
<span aria-hidden="true">·</span>
<span className="inline-flex flex-wrap gap-1">{genes.map((g) => <Badge key={g} variant="outline" className="font-mono">{g}</Badge>)}</span>
<span className="inline-flex flex-wrap items-center gap-1">{genes.map((g) => <Badge key={g} variant="outline" className="font-mono">{g}</Badge>)}
{onEditGenes && <Button variant="ghost" size="icon-xs" className="editgenes" aria-label="edit genes" title="edit the query's genes" onClick={onEditGenes}><Pencil /></Button>}</span>
</>}
</div>
</div>
Expand Down
19 changes: 15 additions & 4 deletions app/portal/results/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ import { usePublicEnv } from "../../_env/public-env-context.tsx";
import { fmtInt } from "../../../lib/chart/format.ts";
import { PortalSummary, PortalLoading } from "../portal-chrome.tsx";
import { SiteHeader } from "../../_ui/site-header.tsx";
import { RESULTS_MAX_GENES, readResultsTarget, studiesProblem } from "../query-editor.tsx";
import { QueryGenesDialog, RESULTS_MAX_GENES, readResultsTarget, studiesProblem } from "../query-editor.tsx";
import { QueryFlow } from "../../_home/query-flow.tsx";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../../_ui/dialog";
import { checkGeneQuery } from "../../../lib/gene-query.ts";
import { resultsMaxStudies, crossStudyExpression, expressionAcross, isExpressionProfile, isExpressionType } from "../../../lib/results-settings.ts";

Expand Down Expand Up @@ -49,7 +50,8 @@ export default function ResultsPage() {
: null, [known, studies, filters, check, genes, queryError, across]);
const page = usePageDoc(fresh, linked);
const [counts, setCounts] = useState<Record<string, number> | null>(null);
const [editing, setEditing] = useState(false);
const [modifying, setModifying] = useState(false);
const [editingGenes, setEditingGenes] = useState(false);
const shell = (body: React.ReactNode) => <><SiteHeader page="query results" /><main className="analysis-main">{body}</main></>;
// a query that is not one yet, or cannot be read, opens the homepage's flow at the step it needs
const flow = (note?: string | null) => shell(<>
Expand All @@ -73,9 +75,10 @@ export default function ResultsPage() {
<PortalSummary eyebrow="Query results" title={known ? known.name : studies.length === 1 ? studies[0] : studies.length + " studies"} study={ids}
studyTitle={studies.map((s) => s + (known?.names[s] ? " — " + known.names[s] : "")).join("\n")} genes={check.entries.map((e) => e.text)}
detail={known && <span className="tabular-nums">{filters.length ? fmtInt(known.patients) + " patients after filters" : fmtInt(known.samples) + " samples · " + fmtInt(known.patients) + " patients"}</span>}
onEditGenes={() => setEditingGenes(true)}
groups={Object.keys(fresh?.doc.cohorts || {})} counts={counts} left={fresh?.left || []}
stat={altered != null && profiled ? { value: altered, of: profiled, label: "of " + fmtInt(profiled) + " profiled patients altered in " + (check.entries.length === 1 ? check.entries[0].text : "any of the " + check.entries.length + " query lines") } : null}
action={<Button variant="outline" size="sm" className={TB} aria-expanded={editing} onClick={() => setEditing((e) => !e)}><Pencil />Modify query</Button>}>
action={<Button variant="outline" size="sm" className={TB} aria-haspopup="dialog" onClick={() => setModifying(true)}><Pencil />Modify query</Button>}>
{filters.length > 0 && (
<div className="mt-2.5 flex flex-wrap items-center gap-2 text-sm">
<span className="font-semibold tracking-[0.12em] text-muted-foreground uppercase">Filters</span>
Expand All @@ -89,7 +92,15 @@ export default function ResultsPage() {
</p>
) : null}
</PortalSummary>
{editing && <QueryFlow initial={target} className="mb-4" />}
<QueryGenesDialog target={editingGenes ? { studies, filters, query: target.query, title: "Edit genes" } : null}
names={new Map(Object.entries(known?.names || {}))} onClose={() => setEditingGenes(false)} />
<Dialog open={modifying} onOpenChange={setModifying}>
<DialogContent aria-describedby={undefined}
className="modifyquerydialog flex h-[90vh] w-[94vw] max-w-[94vw] flex-col gap-3 overflow-auto p-6 sm:max-w-[94vw]">
<DialogHeader><DialogTitle className="text-base">Modify query</DialogTitle></DialogHeader>
{modifying && <QueryFlow initial={target} explore={false} />}
</DialogContent>
</Dialog>
{page.note && <p className="note">{page.note}</p>}
{!ready
? <PortalLoading />
Expand Down
18 changes: 14 additions & 4 deletions e2e.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1625,7 +1625,8 @@ async function main() {

// The homepage, as cBioPortal's: choose studies (cancer types, filters, select all), then explore
// them or query them by gene in a modal whose Submit opens the Results page; a row's icons act on
// that study alone, and the Results page's "Modify query" reopens the modal on its own genes.
// that study alone. On the Results page, "Modify query" opens the flow in a dialog, without Explore,
// whose gene query starts from the page's genes, and the pencil by the genes opens that query directly.
{
const ctx = await browser.newContext({ viewport: { width: 1400, height: 1000 } });
const page = await ctx.newPage();
Expand Down Expand Up @@ -1672,13 +1673,22 @@ async function main() {
const q = new URL(page.url()).searchParams;
await page.waitForFunction(() => /profiled patients altered/.test(document.body.innerText), null, { timeout: 240000 });
await page.getByRole("button", { name: "Modify query" }).click();
await page.locator("button.querybygene").click();
const modify = page.locator("[role=dialog].modifyquerydialog");
await modify.locator("button.querybygene").click();
const again = await dlg.locator("textarea").inputValue();
const modifyExplore = await modify.locator(".exploreselected").count();
await page.keyboard.press("Escape");
await dlg.waitFor({ state: "detached" });
await page.keyboard.press("Escape");
await modify.waitFor({ state: "detached" });
await page.locator("button.editgenes").click();
const pencilGenes = await dlg.locator("textarea").inputValue(), pencilTitle = await dlg.getByRole("heading").first().innerText();
ok(name, res.status() === 200 && landed === "/" &&
empty.disabled && /select at least one study/.test(empty.why || "") && byType < all && byData < all && picked >= 1 &&
/^\/lab\?tab=explore&study=/.test(explore || "") && /^Query genes in /.test(rowTitle) && rowTicked === 0 &&
title === "Select genes to query:" && q.get("study") === "coadread_tcga_pub" && q.get("genes") === "KRAS: MUT\nBRAF" && again === "KRAS: MUT\nBRAF",
JSON.stringify({ status: res.status(), landed, empty, rows: [all, byType, byData], picked, explore, rowTitle, rowTicked, title, results: [q.get("study"), q.get("genes")], again }));
title === "Select genes to query:" && q.get("study") === "coadread_tcga_pub" && q.get("genes") === "KRAS: MUT\nBRAF" && again === "KRAS: MUT\nBRAF" &&
modifyExplore === 0 && pencilGenes === "KRAS: MUT\nBRAF" && pencilTitle === "Edit genes",
JSON.stringify({ status: res.status(), landed, empty, rows: [all, byType, byData], picked, explore, rowTitle, rowTicked, title, results: [q.get("study"), q.get("genes")], again, modifyExplore, pencilGenes, pencilTitle }));
} catch (e) {
ok(name, false, String(e.message).split("\n")[0].slice(0, 200));
}
Expand Down
Loading