diff --git a/CLAUDE.md b/CLAUDE.md index 5b076572..d22c7a54 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -200,6 +200,22 @@ ClickHouse SQL; clients never send SQL. Live at https://cbiolab.vercel.app (UI a enabled only where the deployment signs people in, each dataset its saver's. A document pins what it uses as `uploads: [{as, dataset, version}]` (added by the page on run); requests send `customRefs` and the server loads the pinned, immutable version. +- **Stored id sets** — a cohort given as a long list of ids (`sampleList`/`patientList` with + `"set": ""` instead of `"ids"`). **`lib/id-set-store.ts`** is the only module touching its + tables (`db/migrations/0006_id_sets.sql`): owned and shared by `lib/access.ts`, members + immutable (trigger), soft delete, every read logged. `lib/id-set-import.ts` matches a pasted list + to the studies' cases before saving (`POST /api/id-sets`; UI: "+ Add cohort" → "From a list of + ids"). Every engine route passes its body as `uses` to `withRequestCustomData`, which reads the + sets it names (`lib/id-sets-server.ts`) into **`lib/id-sets.ts`**'s request context; the compiler + then names one ClickHouse external table per set and study, and `lib/ch.ts` uploads it only to + the patient-level connection holding that study. A set is not read without a signed-in request + (MCP, scripts), and an answer that reads one bypasses the result cache (`hasIdSets()`). Inline + lists are refused by name past `INLINE_ID_SQL_LIMIT` (`lib/cohort2.ts`). In the web app every + list becomes a set where the reader is signed in (`app/lab/id-sets-client.ts`): oncoprint + selections and Cell Explorer imports as they enter the document, and any list still inline when a + cohort or analysis is saved (the page's `persist`/`persistSpaces` wrappers, which give the open + document the same sets so a re-save stores nothing new). Inline lists stay valid DSL for MCP, + scripts and signed-out readers. - **Signing in** (`lib/auth.ts`, the header's Sign in) guards custom data, saved cohorts/analyses (`/api/artifacts`) and campaign changes; exploring, analyses, `?doc=` links and MCP need none. A deployment has ONE way in: an OpenID Connect provider (`lib/oidc.ts`; @@ -311,7 +327,8 @@ ClickHouse SQL; clients never send SQL. Live at https://cbiolab.vercel.app (UI a Read before claiming this project is "fast": on a cached single chart the portal is ~20x quicker (those measurements predate the result cache of invariant 11, which answers only exact repeats), and our advantage is data movement (36x less to the - browser), not speed. Also records the ~20,000-id ceiling on explicit patient lists. + browser), not speed. Also records the ceiling on inline id lists (~12,000 sample ids) and the + stored id sets that remove it. ## Invariants — do not break these diff --git a/app/api/analysis/method/route.ts b/app/api/analysis/method/route.ts index 0446c064..32430597 100644 --- a/app/api/analysis/method/route.ts +++ b/app/api/analysis/method/route.ts @@ -12,7 +12,7 @@ export const maxDuration = 60; export async function POST(req: Request) { try { const { customData, customRefs, ...doc } = parseBody(AnalysisRequest, await req.json()); - const result = await withRequestCustomData(customData, () => cachedAnalysis(doc as unknown as AnalysisDoc, { oncoprint: "spec" }), { refs: customRefs, req }); + const result = await withRequestCustomData(customData, () => cachedAnalysis(doc as unknown as AnalysisDoc, { oncoprint: "spec" }), { refs: customRefs, req, uses: doc }); const view = result.views[0]; if (!view || view.error) return Response.json({ error: view ? view.error : "the document has no view" }, { status: 422 }); return Response.json({ python: view.method?.python ?? null }); diff --git a/app/api/analysis/notebook/route.ts b/app/api/analysis/notebook/route.ts index ee1cc2a2..04c7f026 100644 --- a/app/api/analysis/notebook/route.ts +++ b/app/api/analysis/notebook/route.ts @@ -31,7 +31,7 @@ export async function POST(req: Request) { view, method: view.method, files, missing, doc, viewIndex: notebookView.index, origin: process.env.APP_BASE_URL || new URL(req.url).origin, }); - }, { refs: customRefs, req }); + }, { refs: customRefs, req, uses: doc }); const name = slug(notebookView.kind + "-" + String((doc as { study?: string }).study || "")) + ".ipynb"; return new Response(JSON.stringify(nb), { headers: { "Content-Type": "application/x-ipynb+json; charset=utf-8", "Content-Disposition": `attachment; filename="${name}"` }, diff --git a/app/api/analysis/route.ts b/app/api/analysis/route.ts index 4217656a..05b28321 100644 --- a/app/api/analysis/route.ts +++ b/app/api/analysis/route.ts @@ -14,7 +14,7 @@ export async function POST(req: Request) { const url = new URL(req.url); // the web app draws its own oncoprints; ?oncoprint=spec skips rendering them here const mode = url.searchParams.get("oncoprint") === "spec" ? "spec" as const : "svg" as const; - const result = await withRequestCustomData(customData, () => cachedAnalysis(doc as unknown as AnalysisDoc, { oncoprint: mode }), { refs: customRefs, req }); + const result = await withRequestCustomData(customData, () => cachedAnalysis(doc as unknown as AnalysisDoc, { oncoprint: mode }), { refs: customRefs, req, uses: doc }); // ?figures=svg: every view drawn by the shared server renderer, for a client that cannot draw if (url.searchParams.get("figures") === "svg") return Response.json({ ...result, views: result.views.map((v) => v.svg || v.error ? v : { ...v, svg: viewToSvg(v) || undefined }) }); diff --git a/app/api/cell-explorer/route.ts b/app/api/cell-explorer/route.ts index 9db91e1e..99a029c9 100644 --- a/app/api/cell-explorer/route.ts +++ b/app/api/cell-explorer/route.ts @@ -11,7 +11,7 @@ export async function POST(req: Request) { const body = parseBody(CellExplorerRequest, await req.json()); if (body.mode === "import") return Response.json(await cohortFromCells(body)); const { customData, customRefs, doc, cohort } = body; - return Response.json(await withRequestCustomData(customData, () => cohortCellsLink({ doc, cohort }), { refs: customRefs, req })); + return Response.json(await withRequestCustomData(customData, () => cohortCellsLink({ doc, cohort }), { refs: customRefs, req, uses: doc })); } catch (e) { return Response.json({ error: String((e as Error)?.message || e) }, { status: 400 }); } diff --git a/app/api/custom-data/members/route.ts b/app/api/custom-data/members/route.ts index 39ee090e..8482eea6 100644 --- a/app/api/custom-data/members/route.ts +++ b/app/api/custom-data/members/route.ts @@ -10,7 +10,7 @@ export const maxDuration = 60; export async function POST(req: Request) { try { const { customData, customRefs, def, studies } = parseBody(MembersRequest, await req.json()); - return Response.json({ studies: await withRequestCustomData(customData, () => cohortMembership(def, studies.map(String)), { refs: customRefs, req }) }); + return Response.json({ studies: await withRequestCustomData(customData, () => cohortMembership(def, studies.map(String)), { refs: customRefs, req, uses: def }) }); } catch (e) { return Response.json({ error: String((e as Error)?.message || e) }, { status: 400 }); } diff --git a/app/api/explore/route.ts b/app/api/explore/route.ts index 09a7782b..bb48ff0b 100644 --- a/app/api/explore/route.ts +++ b/app/api/explore/route.ts @@ -10,6 +10,7 @@ import { studyProfiles, resolveStudy } from "../../../lib/analysis.ts"; import { chQuery } from "../../../lib/ch.ts"; import { withRequestCustomData } from "../../../lib/custom-data-server.ts"; import { assertCustomDataPresent } from "../../../lib/custom-data.ts"; +import { idSetRefsIn } from "../../../lib/id-sets.ts"; import type { DerivedRequest } from "../../../lib/types.ts"; export const maxDuration = 60; @@ -32,9 +33,9 @@ export async function POST(req: Request) { async function handle(req: Request) { try { const { customData, customRefs, ...body } = parseBody(ExploreRequest, await req.json()); - // facets over uploaded data are the uploader's alone: answered directly, never cached - if ((Array.isArray(customData) && customData.length) || (Array.isArray(customRefs) && customRefs.length)) - return Response.json(await withRequestCustomData(customData, () => ensureChContext(() => exploreFor(body)), { refs: customRefs, req })); + // facets over uploaded data or a stored id set are their reader's alone: answered directly, never cached + if ((Array.isArray(customData) && customData.length) || (Array.isArray(customRefs) && customRefs.length) || idSetRefsIn(body.draft).length) + return Response.json(await withRequestCustomData(customData, () => ensureChContext(() => exploreFor(body)), { refs: customRefs, req, uses: body.draft })); return Response.json(await throughCache(async () => exploreEntry(body, await dataVersion()), () => ensureChContext(() => exploreFor(body)))); } catch (e) { // The reader superseded this request: not a failure, and there is nobody left to tell. diff --git a/app/api/id-sets/[id]/route.ts b/app/api/id-sets/[id]/route.ts new file mode 100644 index 00000000..5f107971 --- /dev/null +++ b/app/api/id-sets/[id]/route.ts @@ -0,0 +1,25 @@ +import { requirePrincipal, AccessError } from "../../../../lib/auth.ts"; +import { getIdSet, readIdSet, deleteIdSet } from "../../../../lib/id-set-store.ts"; + +const fail = (e: unknown) => Response.json({ error: String((e as Error)?.message || e) }, { status: e instanceof AccessError ? e.status : 400 }); + +/** One set's description; with `?ids=1`, its ids too, one ":" per line (that read is logged). */ +export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const principal = requirePrincipal(req, "use stored id sets"); + if (new URL(req.url).searchParams.get("ids") !== "1") return Response.json({ set: await getIdSet(principal, id) }); + const { perStudy } = await readIdSet(principal, id); + const text = Object.entries(perStudy).flatMap(([study, ids]) => ids.map((x) => study + ":" + x)).join("\n") + "\n"; + return new Response(text, { headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "private, no-store" } }); + } catch (e) { return fail(e); } +} + +/** Delete (its owner only): documents naming it are refused from then on, saying when it went. */ +export async function DELETE(req: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + await deleteIdSet(requirePrincipal(req, "use stored id sets"), id); + return Response.json({ deleted: true }); + } catch (e) { return fail(e); } +} diff --git a/app/api/id-sets/[id]/sharing/route.ts b/app/api/id-sets/[id]/sharing/route.ts new file mode 100644 index 00000000..11b1b199 --- /dev/null +++ b/app/api/id-sets/[id]/sharing/route.ts @@ -0,0 +1,13 @@ +import { requirePrincipal, AccessError } from "../../../../../lib/auth.ts"; +import { shareIdSet } from "../../../../../lib/id-set-store.ts"; + +// Who a stored id set is shared with (its owner only; lib/access.ts). Body as for +// /api/artifacts/[id]/sharing. Its ids are patient-level: nothing shares them but this. +export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + return Response.json({ set: await shareIdSet(requirePrincipal(req, "use stored id sets"), id, await req.json().catch(() => null)) }); + } catch (e) { + return Response.json({ error: String((e as Error)?.message || e) }, { status: e instanceof AccessError ? e.status : 400 }); + } +} diff --git a/app/api/id-sets/route.ts b/app/api/id-sets/route.ts new file mode 100644 index 00000000..d01cf9bf --- /dev/null +++ b/app/api/id-sets/route.ts @@ -0,0 +1,27 @@ +import { connection } from "next/server"; +import { requirePrincipal, AccessError } from "../../../lib/auth.ts"; +import { listIdSets } from "../../../lib/id-set-store.ts"; +import { createIdSet } from "../../../lib/id-set-import.ts"; + +export const maxDuration = 60; + +const fail = (e: unknown) => Response.json({ error: String((e as Error)?.message || e) }, { status: e instanceof AccessError ? e.status : 400 }); + +/** The stored id sets this reader may use, without their ids. */ +export async function GET(req: Request) { + // rendered per request: without it the build prerenders whatever the login check answers then + await connection(); + try { return Response.json({ sets: await listIdSets(requirePrincipal(req, "use stored id sets")) }); } + catch (e) { return fail(e); } +} + +/** + * Save a list of ids as a stored set: `{ name, level, studies, ids }`. The ids are matched to the + * studies' own samples or patients first; the answer is the set and what did not match. + */ +export async function POST(req: Request) { + try { + const principal = requirePrincipal(req, "save stored id sets"); + return Response.json(await createIdSet(principal, await req.json()), { status: 201 }); + } catch (e) { return fail(e); } +} diff --git a/app/api/lock/route.ts b/app/api/lock/route.ts new file mode 100644 index 00000000..f1c02867 --- /dev/null +++ b/app/api/lock/route.ts @@ -0,0 +1,22 @@ +import { parseBody, LockRequest } from "../../../lib/api-schemas.ts"; +import { requirePrincipal, AccessError } from "../../../lib/auth.ts"; +import { withRequestCustomData } from "../../../lib/custom-data-server.ts"; +import { lockCohorts } from "../../../lib/cohort-lock-server.ts"; +import { dataVersion } from "../../../lib/next-cache.ts"; + +export const maxDuration = 60; + +/** + * Lock cohorts of a document (lib/cohort-lock.ts): `{ doc, cohorts? }`, every cohort when none are + * named. Answers the moment, the data version, the OncoKB release and each cohort's lock, for the + * page to write into the document. The members are saved as the reader's stored id sets. + */ +export async function POST(req: Request) { + try { + const principal = requirePrincipal(req, "lock cohorts"); + const { customData, customRefs, doc, cohorts } = parseBody(LockRequest, await req.json()); + return Response.json(await withRequestCustomData(customData, () => lockCohorts(principal, { doc, cohorts }, dataVersion), { refs: customRefs, req, uses: doc })); + } catch (e) { + return Response.json({ error: String((e as Error)?.message || e) }, { status: e instanceof AccessError ? e.status : 400 }); + } +} diff --git a/app/api/oncoprint/route.ts b/app/api/oncoprint/route.ts index 81011ec6..5fcb35cb 100644 --- a/app/api/oncoprint/route.ts +++ b/app/api/oncoprint/route.ts @@ -19,7 +19,7 @@ export async function POST(req: Request) { const sp = new URL(req.url).searchParams; // a provenance request is answered with the queries that built the (cached) contract const provenance = sp.get("provenance") === "1"; - const contract = await withRequestCustomData(customData, () => cachedContract(spec, { provenance }), { refs: customRefs, req }); + const contract = await withRequestCustomData(customData, () => cachedContract(spec, { provenance }), { refs: customRefs, req, uses: spec }); const engineMs = Date.now() - t0; const fmt = sp.get("format"); if (fmt === "svg" || fmt === "tsv") { diff --git a/app/api/partition/route.ts b/app/api/partition/route.ts index 18ec0837..4e58cd56 100644 --- a/app/api/partition/route.ts +++ b/app/api/partition/route.ts @@ -9,7 +9,7 @@ export const maxDuration = 60; export async function POST(req: Request) { try { const { customData, customRefs, ...body } = parseBody(PartitionRequest, await req.json()); - return Response.json(await withRequestCustomData(customData, () => proposePartition(body as unknown as Parameters[0]), { refs: customRefs, req })); + return Response.json(await withRequestCustomData(customData, () => proposePartition(body as unknown as Parameters[0]), { refs: customRefs, req, uses: body })); } catch (e) { return Response.json({ error: String((e as Error)?.message || e) }, { status: 400 }); } diff --git a/app/api/table/route.ts b/app/api/table/route.ts index e5d4ed2d..7f688082 100644 --- a/app/api/table/route.ts +++ b/app/api/table/route.ts @@ -33,7 +33,7 @@ export async function POST(req: Request) { } catch (e) { return new Response("cannot download: " + String((e as Error)?.message || e) + "\n", { status: 400, headers: { "content-type": "text/plain; charset=utf-8" } }); } - const run = (fn: () => Promise) => withRequestCustomData(body.customData, fn, { refs: body.customRefs, req }); + const run = (fn: () => Promise) => withRequestCustomData(body.customData, fn, { refs: body.customRefs, req, uses: body }); const table = typeof body.table === "string" ? body.table : undefined; const first = await run(() => runAnalysis(doc, { table })).catch((e) => ({ views: [{ kind: "", cohorts: [], error: String((e as Error)?.message || e) } as ViewResult] })); const v = first.views[0]; diff --git a/app/api/view-page/route.ts b/app/api/view-page/route.ts index 67372b9b..1a76f893 100644 --- a/app/api/view-page/route.ts +++ b/app/api/view-page/route.ts @@ -23,7 +23,7 @@ export async function POST(req: Request) { const view = Array.isArray(doc.views) ? doc.views[vi] : null; if (!view || !PAGED_KINDS.includes(view.kind)) throw new Error("view " + body.view + " is not a table view (" + PAGED_KINDS.join(", ") + ")"); const one = { ...doc, views: [view] }; - const run = (opts: Parameters[1]) => withRequestCustomData(customData, () => runAnalysis(one, opts), { refs: customRefs, req }); + const run = (opts: Parameters[1]) => withRequestCustomData(customData, () => runAnalysis(one, opts), { refs: customRefs, req, uses: one }); const pick = (r: Awaited>) => { const v = r.views[0]; const t = pagedTable(v, body.table); diff --git a/app/lab/add-cohort-dialog.tsx b/app/lab/add-cohort-dialog.tsx index 9934a526..d4952cab 100644 --- a/app/lab/add-cohort-dialog.tsx +++ b/app/lab/add-cohort-dialog.tsx @@ -1,7 +1,7 @@ "use client"; -// "+ Add cohort" on a view: one dialog with the three places a cohort comes from — the analysis's -// own cohorts, the library (the login's saved cohorts and the built-in examples), and Explore, -// where a new one is built and comes back to this view. +// "+ Add cohort" on a view: one dialog with the places a cohort comes from — the analysis's own +// cohorts, the library (the login's saved cohorts and those shared with them), Explore, where a new +// one is built and comes back to this view, and a pasted list of ids, saved as a stored id set. import { useEffect, useState } from "react"; import { Plus } from "lucide-react"; import type { CohortDef } from "../../lib/types.ts"; @@ -15,6 +15,7 @@ import { Tabs, TabsList, TabsTrigger } from "../_ui/tabs"; import { TB } from "../_ui/toolbar.ts"; import { Textarea } from "../_ui/textarea"; import { api } from "./api.ts"; +import { ListSource, addLabel, type SignInState } from "./list-cohort.tsx"; import type { CellExplorerDataset } from "../../lib/cell-explorer.ts"; export interface AddCohortTarget { @@ -29,20 +30,23 @@ export interface AddCohortTarget { export interface LibraryState { /** saved cohorts are the signed-in reader's: "locked" when signed out, "off" where there is no sign-in */ - saved: "off" | "locked" | "loading" | "ready" | "error"; + saved: SignInState; savedError?: string | null; /** the login's saved cohorts: `id` is the artifact's, which survives a rename */ cohorts: { id: string; name: string; def: CohortDef; savedAt: string }[]; - examples: { name: string; desc: string; def: CohortDef }[]; } -type Source = "analysis" | "library" | "explore" | "cells"; +type Source = "analysis" | "library" | "explore" | "list" | "cells"; -export function AddCohortDialog({ target, analysis, library, onAdd, onExplore, onLogin, onClose, cells }: { +export function AddCohortDialog({ target, analysis, library, studies, storeLists, onAdd, onExplore, onLogin, onClose, cells }: { target: AddCohortTarget | null; /** the analysis's cohorts in its order (the colour each has in its summary), with their sizes once known */ analysis: { name: string; def: CohortDef; count: number | null; views: string[] }[]; library: LibraryState; + /** the studies the analysis uses: a pasted bare id is looked up in each */ + studies: string[]; + /** a cohort's inline id lists stored as id sets, where the reader is signed in (id-sets-client.ts) */ + storeLists: (def: CohortDef, name: string) => Promise; /** add a cohort to the view: an analysis cohort by name, or a library one with its definition */ onAdd: (name: string, def?: CohortDef) => void; onExplore: () => void; @@ -92,7 +96,6 @@ export function AddCohortDialog({ target, analysis, library, onAdd, onExplore, o ); }; const saved = library.cohorts.filter((c) => match(c.name)); - const examples = library.examples.filter((c) => match(c.name) || match(c.desc)); return ( { if (!open) onClose(); }}> @@ -110,6 +113,7 @@ export function AddCohortDialog({ target, analysis, library, onAdd, onExplore, o In this analysis {available.length} Library Build in Explore + From a list of ids {cells && From Cell Explorer} @@ -138,7 +142,6 @@ export function AddCohortDialog({ target, analysis, library, onAdd, onExplore, o {source === "library" && <> setQ(e.target.value)} autoFocus /> -

Your saved cohorts

{library.saved === "off" ?

This deployment has no sign-in, so it keeps no saved cohorts.

: library.saved === "locked" ?

to see your saved cohorts.

: library.saved === "loading" ?

loading your saved cohorts…

@@ -146,9 +149,6 @@ export function AddCohortDialog({ target, analysis, library, onAdd, onExplore, o : !library.cohorts.length ?

You have no saved cohorts yet — save one from Explore (“Save as a cohort”).

: !saved.length ?

No saved cohort matches “{q}”.

:
    {saved.map((c) => libraryRow(c.name, c.def, "saved " + c.savedAt, { id: c.id, savedAt: c.savedAt }))}
} -

Examples

- {examples.length ?
    {examples.map((c) => libraryRow(c.name, c.def, c.desc))}
- :

No example matches “{q}”.

} } {source === "explore" && ( @@ -160,8 +160,10 @@ export function AddCohortDialog({ target, analysis, library, onAdd, onExplore, o )} + {source === "list" && addTitle("“" + as + "”")} + doneLabel={addLabel(replaces)} nameFor={libraryName} onDone={(as, def) => onAdd(as, def)} onLogin={onLogin} />} {source === "cells" && cells && } + nameFor={libraryName} storeLists={storeLists} onAdd={onAdd} />} }
); @@ -172,9 +174,10 @@ export function AddCohortDialog({ target, analysis, library, onAdd, onExplore, o * matched to the study's patients on the server (lib/cell-explorer-server.ts). The text stays in * this component while it is typed. */ -function CellsSource({ cells, full, replaces, addTitle, nameFor, onAdd }: { +function CellsSource({ cells, full, replaces, addTitle, nameFor, storeLists, onAdd }: { cells: CellExplorerDataset; full: boolean; replaces: boolean; addTitle: (what: string) => string; - nameFor: (name: string, def: CohortDef) => string; onAdd: (name: string, def?: CohortDef) => void; + nameFor: (name: string, def: CohortDef) => string; storeLists: (def: CohortDef, name: string) => Promise; + onAdd: (name: string, def?: CohortDef) => void; }) { const [text, setText] = useState(""); const [name, setName] = useState("from Cell Explorer"); @@ -191,6 +194,14 @@ function CellsSource({ cells, full, replaces, addTitle, nameFor, onAdd }: { finally { setBusy(false); } }; const as = found ? nameFor(name.trim() || "from Cell Explorer", found.def) : name.trim(); + // the donors' patients are a list: stored as an id set before they enter the analysis + const add = async () => { + if (!found) return; + setBusy(true); setErr(null); + try { onAdd(as, await storeLists(found.def, as)); } + catch (e) { setErr(String((e as Error)?.message || e)); } + finally { setBusy(false); } + }; return (

@@ -216,8 +227,8 @@ function CellsSource({ cells, full, replaces, addTitle, nameFor, onAdd }: {

- +
{as !== (name.trim() || "from Cell Explorer") &&

added as “{as}”, since this analysis already has a different “{name.trim()}”

} diff --git a/app/lab/analysis-sections.tsx b/app/lab/analysis-sections.tsx index 5e7a3971..19129d44 100644 --- a/app/lab/analysis-sections.tsx +++ b/app/lab/analysis-sections.tsx @@ -4,10 +4,15 @@ // document names custom data that is not here. The page holds the state; each section reads it // and the page's actions from one AnalysisCtx. import { useState } from "react"; -import { Braces, LoaderCircle, Play, Plus, Redo2, Save, Undo2, X } from "lucide-react"; +import { Braces, LoaderCircle, Lock, Play, Plus, Redo2, Save, Undo2, X } from "lucide-react"; +import { lockDate } from "../../lib/cohort-lock.ts"; +import type { CohortLock, LockDrift } from "../../lib/types.ts"; import { Button } from "../_ui/button"; import { CHIP } from "../_ui/toolbar.ts"; import { cn } from "../../lib/cn"; +import { TB } from "../_ui/toolbar.ts"; +import { SaveToLibraryDialog } from "./dialogs.tsx"; +import { ListCohortDialog, type SignInState } from "./list-cohort.tsx"; import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "../_ui/dialog"; import { Textarea } from "../_ui/textarea"; import CohortPills from "./pills"; @@ -60,7 +65,17 @@ export interface AnalysisCtx { updateFromSaved: (name: string) => void; /** whether a cohort has anything to save to the library, and the action that saves it */ cohortSaveState: (name: string) => CohortSaveState | null; - saveCohortToLibrary: (name: string) => Promise; + saveCohortToLibrary: (name: string, as?: string) => Promise; + /** lock the cohorts named, or every one (the analysis); unlock one, or all (lib/cohort-lock.ts) */ + lockCohorts: (names?: string[]) => Promise; + unlockCohort: (name: string) => void; + unlockAll: () => void; + /** whether the reader may store id sets (signed in); asks them to sign in when not */ + canSave: () => boolean; + signIn: SignInState; + requestLogin: () => void; + /** the studies the analysis uses: a pasted bare id is looked up in each */ + analysisStudies: () => string[]; /** the single-cell dataset a cohort's patients can be opened in (lib/cell-explorer.ts), and opening it */ cohortCells: (name: string) => import("../../lib/cell-explorer.ts").CellExplorerDataset | null; openCohortCells: (name: string) => Promise; @@ -158,7 +173,18 @@ function EmptyViewCard({ ctx, vi }: { ctx: AnalysisCtx; vi: number }) { } export function CohortColumn({ ctx }: { ctx: AnalysisCtx }) { - const { busy, catalogAttrs, cohortCells, cohortSaveState, cohortSource, doc, openCohortCells, loggedIn, menuOpenRef, openCohortEdit, removeCohort, removePartitionNamed, renamePartitionRow, result, saveCohortToLibrary, setPartitionDef, setUpgradeNote, showClauseData, spaces, splitCohortBy, splitsFlat, studyNames, updateFromSaved, upgradeNote, viewTitle } = ctx; + const { busy, canSave, saved, signIn, requestLogin, analysisStudies, docs, catalogAttrs, cohortCells, cohortSaveState, cohortSource, doc, lockCohorts, openCohortCells, loggedIn, menuOpenRef, openCohortEdit, removeCohort, removePartitionNamed, renamePartitionRow, result, saveCohortToLibrary, setPartitionDef, setUpgradeNote, showClauseData, spaces, splitCohortBy, splitsFlat, studyNames, unlockAll, unlockCohort, updateFromSaved, upgradeNote, viewTitle } = ctx; + const [lockingAll, setLockingAll] = useState(false); + /** the cohort whose "save to library…" dialog is open */ + const [savingToLibrary, setSavingToLibrary] = useState(null); + const [fromList, setFromList] = useState(false); + /** a pasted list's cohort joins the analysis under its name, or the next free one */ + const freeName = (n: string) => { const have = docs.current.doc.cohorts || {}; if (!have[n]) return n; for (let i = 2; ; i++) if (!have[n + " (" + i + ")"]) return n + " (" + i + ")"; }; + const lockAll = async () => { + if (!canSave()) return; + setLockingAll(true); + try { await lockCohorts(); } finally { setLockingAll(false); } + }; const [cohortOpen, setCohortOpen] = useState>(() => new Set()); const names = Object.keys(doc.cohorts || {}); const flat = splitsFlat(); @@ -177,11 +203,29 @@ export function CohortColumn({ ctx }: { ctx: AnalysisCtx }) { {/* The analysis's cohorts are not its top level: each view names the ones it draws, and these cards summarise them — each with the views that draw it and the saved cohort it was copied from. Cohorts come in through a view's "+ Add cohort". */} - {names.length > 0 && ( + {(names.length > 0 || doc.study) && (

Cohorts in this analysis — add one from a view{busy && } + + + {names.length > 0 && + {doc.locked + ? <> + + locked {lockDate(doc.locked.at)} + + + : } + } +

+ {!names.length &&

No cohorts yet: add one to a view with its “+ Add cohort”, or make one from a list of ids.

}
{names.map((n, i) => { const c = result?.cohorts[n]; @@ -198,9 +242,10 @@ export function CohortColumn({ ctx }: { ctx: AnalysisCtx }) { onEdit={() => openCohortEdit(n)} onRemove={() => removeCohort(n)} split={(done: () => void) => } splitCount={splits.length} - save={toSave && { onSave: () => saveCohortToLibrary(n), label: toSave.action === "create" ? "save to library" : "save changes to library", + save={toSave && { onSave: async () => setSavingToLibrary(n), label: toSave.action === "create" ? "save to library…" : "save changes to library…", title: toSave.action === "create" ? "save “" + toSave.as + "” to your library, to add it to other analyses" : "write this cohort's definition and splits over “" + toSave.as + "” in your library" }} + lock={{ locked: !!doc.cohorts?.[n]?.lock, onLock: async () => { if (canSave()) await lockCohorts([n]); }, onUnlock: () => unlockCohort(n) }} cells={cohortCells(n)} onCells={() => openCohortCells(n)} menuId={"cohort:" + n} menuStore={menuOpenRef.current} />
@@ -208,6 +253,7 @@ export function CohortColumn({ ctx }: { ctx: AnalysisCtx }) { {c ? <>{c.count.toLocaleString()} patients : busy ? : null}
+ {doc.cohorts?.[n]?.lock && } {doc.cohorts?.[n] && } @@ -217,25 +263,25 @@ export function CohortColumn({ ctx }: { ctx: AnalysisCtx }) { {views.length ? "in " + views.join(", ") : "in no view"} {src && } + title={src.state === "changed" ? "your saved cohort “" + src.srcName + "” has changed since this one was linked to it — open to compare and update" + : src.state === "deleted" ? "linked to a saved cohort “" + src.from.name + "” that has since been deleted" + : "linked to your saved cohort “" + (src.srcName || src.from.name) + "” (#" + shortHash(src.from.hash) + ") — open for details"}> + {src.state === "changed" ? "saved version changed" : src.state === "deleted" ? "source deleted" : "in library"}}
{open && src && (
- Copied from saved cohort “{src.from.name}”{src.from.savedAt ? " (saved " + src.from.savedAt + ")" : ""} + Linked to your saved cohort “{src.from.name}”{src.from.savedAt ? " (saved " + src.from.savedAt + ")" : ""} {" "}· definition #{shortHash(src.from.hash)}. {src.state === "unknown" && {loggedIn ? "Checking the saved cohort…" : "Sign in to compare it with the saved cohort."}} - {src.state === "deleted" && That saved cohort has since been deleted; this copy is unaffected.} + {src.state === "deleted" && That saved cohort has since been deleted; this one is unaffected.} {(src.state === "current" || src.state === "changed") && src.srcName !== src.from.name && It is now named “{src.srcName}”.} {src.state === "current" && !src.edited && The saved cohort is unchanged.} - {src.edited && This copy has been edited here since (now #{shortHash(cohortHash(doc.cohorts![n]))}).} + {src.edited && This one has been edited here since (now #{shortHash(cohortHash(doc.cohorts![n]))}).} {src.state === "changed" && The saved cohort has changed since (now #{shortHash(src.now!)}, saved {src.src!.savedAt}).} {(src.state === "changed" || (src.state === "current" && src.edited)) && <> {src.state === "changed" &&
The saved version:
} }
@@ -255,10 +301,45 @@ export function CohortColumn({ ctx }: { ctx: AnalysisCtx }) {
)} + {fromList && Add to the analysis} doneTitle={(as) => "add “" + as + "” to this analysis; it is in no view until you add it to one"} + nameFor={freeName} onLogin={requestLogin} onClose={() => setFromList(false)} + onDone={async (as, def) => { const now = docs.current.doc; docs.set({ ...now, cohorts: { ...now.cohorts, [as]: def } }); }} />} + {savingToLibrary && (() => { + const st = cohortSaveState(savingToLibrary); + return st && saveCohortToLibrary(savingToLibrary, as)} onClose={() => setSavingToLibrary(null)} />; + })()}
; } +/** + * A locked cohort's lock, and how far the data has moved since: what its definition would select + * now, who that would add or drop, frozen patients no longer in the data, and their new samples. + */ +function LockLine({ lock, drift }: { lock: CohortLock; drift?: LockDrift }) { + const nf = (x: number) => x.toLocaleString(); + const parts: string[] = []; + if (drift && !drift.error) { + if (drift.entered || drift.left) + parts.push("its definition now selects " + nf(drift.now) + (drift.entered ? " (+" + nf(drift.entered) + " new" : " (") + + (drift.entered && drift.left ? ", " : "") + (drift.left ? "−" + nf(drift.left) + " no longer" : "") + ")"); + if (drift.present < drift.n) parts.push(nf(drift.n - drift.present) + " of the locked patients are no longer in the data"); + if (drift.newSamples) parts.push(nf(drift.newSamples) + " new sample" + (drift.newSamples === 1 ? "" : "s") + " of these patients, which views include"); + } + return ( +

+ locked {lockDate(lock.at)} + {!drift ? null + : drift.error ? {drift.error} + : parts.length ? since then: {parts.join("; ")} + : unchanged since: its definition still selects exactly these patients} +

+ ); +} + export function DocDialog({ ctx }: { ctx: AnalysisCtx }) { const { applyText, busy, clearAnalysis, doc, docDialog, docText, docs, result } = ctx; return <> diff --git a/app/lab/api.ts b/app/lab/api.ts index 72077144..81f3e84b 100644 --- a/app/lab/api.ts +++ b/app/lab/api.ts @@ -6,7 +6,7 @@ // They return the Response, as fetch does: a caller still decides how to read it (JSON, the // binary contract, a file) and what an error status means for it. import type { - AnalysisBody, NotebookBody, ExploreBody, OncoprintBody, OncoprintDetailBody, PatientsOfBody, PartitionBody, ViewPageBody, TableBody, CellExplorerBody, + AnalysisBody, NotebookBody, ExploreBody, OncoprintBody, OncoprintDetailBody, PatientsOfBody, PartitionBody, ViewPageBody, TableBody, CellExplorerBody, LockBody, } from "../../lib/api-schemas.ts"; import { withCustomData, type UploadBinding } from "./custom-data-client.ts"; import type { ArtifactKind } from "../../lib/artifacts.ts"; @@ -60,6 +60,10 @@ export const api = { partition: async (body: PartitionBody, opts: Opts = {}) => post("/api/partition", await withCustomData(body), opts.signal), + /** Lock cohorts of the document: those named, or every one. Answers each cohort's lock (lib/cohort-lock-server.ts). */ + lock: async (body: LockBody, opts: Opts = {}) => + post("/api/lock", await withCustomData(body), opts.signal), + /** One page of a table view, or the values one of its columns takes. */ viewPage: async (body: ViewPageBody, opts: Opts = {}) => post("/api/view-page", await withCustomData(body), opts.signal), @@ -84,6 +88,16 @@ export const api = { share: (id: string, sharing: Sharing) => send("PUT", "/api/artifacts/" + id + "/sharing", sharing), }, + /** Stored id sets (lib/id-set-store.ts): cohorts given as long lists of ids, kept on the server and named by id. */ + idSets: { + list: (opts: Opts = {}) => fetch("/api/id-sets", { signal: opts.signal }), + /** match `ids` (":" or bare, looked up in every study given) and save the matches; answers what did not match */ + create: (body: { name: string; level: "sample" | "patient"; studies: string[]; ids: string[] }) => post("/api/id-sets", body), + remove: (id: string) => fetch("/api/id-sets/" + id, { method: "DELETE" }), + /** who it is shared with (its owner only) */ + share: (id: string, sharing: Sharing) => send("PUT", "/api/id-sets/" + id + "/sharing", sharing), + }, + /** Groups (lib/groups.ts): the reader's, and managing one. */ groups: { list: (opts: Opts = {}) => fetch("/api/groups", { signal: opts.signal }), diff --git a/app/lab/dialogs.tsx b/app/lab/dialogs.tsx index 38b03074..a2c32c6e 100644 --- a/app/lab/dialogs.tsx +++ b/app/lab/dialogs.tsx @@ -64,6 +64,40 @@ export function SaveCohortDialog({ initial, meta, onSave, onClose }: { ); } +/** + * A cohort card's "save to library…": the name it is saved under, prefilled with the library name it + * would take. Says what saving does — a new saved cohort, the one it came from updated, or another + * saved cohort of that name replaced — before it is done. + */ +export function SaveToLibraryDialog({ cohort, initial, updates, taken, onSave, onClose }: { + /** the cohort's name in this analysis */ + cohort: string; + initial: string; + /** the saved cohort this one would update when saved under its name, if any */ + updates: string | null; + /** the names of the reader's saved cohorts */ + taken: string[]; + onSave: (name: string) => Promise; + onClose: () => void; +}) { + const [name, setName] = useState(initial); + const [busy, setBusy] = useState(false); + const n = name.trim(); + const replaces = !!n && n !== updates && taken.includes(n); + const what = !n ? "" : n === updates ? "Updates your saved cohort “" + n + "” with this definition." + : replaces ? "You already have a saved cohort named “" + n + "”: saving replaces it with this one." + : "Saves a new cohort “" + n + "” to your library."; + return ( + { if (!n || busy) return; setBusy(true); try { await onSave(n); onClose(); } finally { setBusy(false); } }} + footer={}> + + setName(e.target.value)} autoFocus placeholder="cohort name…" /> +

{what}

+
+ ); +} + /** Explore's "Save as feature": the name of the yes/no attribute the current filters become. */ export function SaveFeatureDialog({ initial, meta, onSave, onClose }: { initial: string; meta: string; onSave: (name: string) => void; onClose: () => void; diff --git a/app/lab/examples-tab.tsx b/app/lab/examples-tab.tsx index 013d7f43..80e99fea 100644 --- a/app/lab/examples-tab.tsx +++ b/app/lab/examples-tab.tsx @@ -15,6 +15,9 @@ import { cn } from "../../lib/cn"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../_ui/dialog"; import { usePublicEnv } from "../_env/public-env-context.tsx"; import { parseExamplesUrl } from "../../lib/site.ts"; +import type { CohortDef } from "../../lib/types.ts"; +import { Tabs, TabsList, TabsTrigger } from "../_ui/tabs"; +import CohortPills from "./pills"; /** a tag filter: an outline chip, filled while it is the one shown */ const TAG = "group rounded-pill px-2.5 text-sm font-normal aria-pressed:border-link aria-pressed:bg-link aria-pressed:text-card aria-pressed:hover:bg-link/90 aria-pressed:hover:text-card"; @@ -35,7 +38,16 @@ export function useExamplesUrl(): { examplesUrl: string | null; examplesUrlError catch (e) { return { examplesUrl: null, examplesUrlError: String((e as Error)?.message || e) }; } } -export function ExamplesTab({ tag, onTag, query, onQuery, busy, onRun, link, elsewhere, elsewhereError, onError }: { +/** An example cohort: a definition to preview, analyse or save, one per kind of clause. */ +export type ExampleCohort = { name: string; desc: string; def: CohortDef }; +export type ExampleEval = { count?: number; error?: string; busy?: boolean } | null; + +export function ExamplesTab({ tag, onTag, query, onQuery, busy, onRun, link, elsewhere, elsewhereError, onError, cohorts }: { + /** the example cohorts' tab: each previewed, analysed or saved on its own */ + cohorts: { + examples: ExampleCohort[]; evals: Record; study: string; canSave: boolean; + onPreview: (ex: ExampleCohort) => void; onAnalyze: (ex: ExampleCohort) => void; onSave: (ex: ExampleCohort) => void; + }; tag: string; onTag: (t: string) => void; /** the search over titles, descriptions and pills */ @@ -52,6 +64,7 @@ export function ExamplesTab({ tag, onTag, query, onQuery, busy, onRun, link, els onError: (msg: string) => void; }) { const [copied, setCopied] = useState(null); + const [sub, setSub] = useState<"analyses" | "cohorts">("analyses"); // the example whose Run is waiting on the reader's go-ahead to leave this site const [leaving, setLeaving] = useState(null); const run = (ex: DslExample) => { if (elsewhere) setLeaving(ex); else onRun(ex); }; @@ -83,6 +96,14 @@ export function ExamplesTab({ tag, onTag, query, onQuery, busy, onRun, link, els return (
+ setSub(v as typeof sub)} className="mb-3"> + + Analyses + Cohorts + + + {sub === "cohorts" && } + {sub === "analyses" && <>

Examples — every one of these is executed by the test gate

@@ -164,6 +185,7 @@ export function ExamplesTab({ tag, onTag, query, onQuery, busy, onRun, link, els ); })}

+ } {leaving && elsewhere && ( { if (!open) setLeaving(null); }}> @@ -187,3 +209,40 @@ export function ExamplesTab({ tag, onTag, query, onQuery, busy, onRun, link, els
); } + +/** + * The example cohorts, one per kind of clause: each previewed (its size), analysed on its own, or + * saved to the library. One with no study runs in the study the page is on. + */ +function ExampleCohorts({ examples, evals, study, canSave, onPreview, onAnalyze, onSave }: { + examples: ExampleCohort[]; evals: Record; study: string; canSave: boolean; + onPreview: (ex: ExampleCohort) => void; onAnalyze: (ex: ExampleCohort) => void; onSave: (ex: ExampleCohort) => void; +}) { + return ( +
+

Example cohorts — one for each kind of clause; those that name no study run in {study}

+ {examples.map((ex) => { + const ev = evals[ex.name]; + const where = ex.def.studies?.length ? ex.def.studies.join(" + ") : ex.def.study || study; + return ( +
+
+ {ex.name} + {ex.desc} + {where} + {ev && ev.count != null && {ev.count.toLocaleString()} pts} + {ev && ev.busy && } + + + + +
+ {ev && ev.error &&
{ev.error}
} +
+
+ ); + })} +
+ ); +} diff --git a/app/lab/id-sets-client.ts b/app/lab/id-sets-client.ts new file mode 100644 index 00000000..e4f752d8 --- /dev/null +++ b/app/lab/id-sets-client.ts @@ -0,0 +1,61 @@ +// Inline id lists in the page's documents become stored id sets (lib/id-set-store.ts) wherever the +// reader is signed in: a cohort picked on an oncoprint, donors from Cell Explorer, and every list +// still inline when a cohort or an analysis is saved. The document then names the set +// (`{"kind":"sampleList","set":""}`), so it, its ?doc= link and every request stay small +// however long the list is. Inline lists remain valid DSL for MCP clients, scripts and signed-out +// readers, who cannot read a set. +import type { AnalysisDoc, CohortDef } from "../../lib/types.ts"; +import { api } from "./api.ts"; + +type ListKind = "sampleList" | "patientList"; +interface InlineList { kind: ListKind; ids: string[] } + +const isInline = (x: unknown): x is InlineList => { + const o = x as { kind?: unknown; ids?: unknown; set?: unknown } | null; + return !!o && (o.kind === "sampleList" || o.kind === "patientList") && Array.isArray(o.ids) && o.ids.length > 0 && o.set === undefined; +}; + +/** Save one inline list as a set; the clause naming it, or the list unchanged when none of its ids names a case. */ +async function store(leaf: InlineList, name: string): Promise { + // only ":" ids select anyone inline (lib/cohort2.ts), so only those are stored + const ids = leaf.ids.map(String).filter((s) => { const i = s.indexOf(":"); return i > 0 && i < s.length - 1; }); + if (!ids.length) return leaf; + const studies = [...new Set(ids.map((s) => s.slice(0, s.indexOf(":"))))]; + const level = leaf.kind === "sampleList" ? "sample" : "patient"; + const res = await api.idSets.create({ name, level, studies, ids }); + const j = await res.json().catch(() => ({ error: res.statusText })); + if (!res.ok || j.error) { + // a list naming no case here selects nobody here either way (none matched, or its studies are + // held where patient ids may not go): nothing to store, and nothing lost by keeping it + if (/none of the ids matched|cannot be matched against/.test(j.error || "")) return leaf; + throw new Error("could not store the list of " + leaf.ids.length.toLocaleString() + " ids in “" + name + "”: " + (j.error || res.statusText)); + } + const { kind: _k, ids: _ids, ...rest } = leaf as InlineList & Record; + return { ...rest, kind: leaf.kind, set: j.set.id, label: j.set.name, n: j.set.count }; +} + +/** `x` with every inline list under it stored, each named `name`; the same object when it has none. */ +async function storeUnder(x: T, name: string): Promise { + if (!x || typeof x !== "object") return x; + if (isInline(x)) return (await store(x, name)) as T; + if (Array.isArray(x)) { + const out = await Promise.all(x.map((v) => storeUnder(v, name))); + return out.every((v, i) => v === x[i]) ? x : (out as T); + } + const entries = Object.entries(x as Record); + const out = await Promise.all(entries.map(async ([k, v]) => [k, await storeUnder(v, name)] as const)); + return out.every(([, v], i) => v === entries[i][1]) ? x : (Object.fromEntries(out) as T); +} + +/** A cohort with its inline lists stored as sets named after it; the same object when it has none. */ +export function storeCohortLists(def: CohortDef, name: string): Promise { + return storeUnder(def, name); +} + +/** A document with each cohort's inline lists stored as sets named after the cohort; the same object when it has none. */ +export async function storeDocLists(doc: AnalysisDoc): Promise { + const cohorts = doc.cohorts || {}; + const names = Object.keys(cohorts); + const out = await Promise.all(names.map((n) => storeCohortLists(cohorts[n], n))); + return out.every((d, i) => d === cohorts[names[i]]) ? doc : { ...doc, cohorts: Object.fromEntries(names.map((n, i) => [n, out[i]])) }; +} diff --git a/app/lab/list-cohort.tsx b/app/lab/list-cohort.tsx new file mode 100644 index 00000000..704cb5d6 --- /dev/null +++ b/app/lab/list-cohort.tsx @@ -0,0 +1,174 @@ +"use client"; +// A cohort from a pasted list of sample or patient ids, wherever one can be made: a view's +// "+ Add cohort", the analysis's cohort panel, and the library. The list is matched to the +// studies' own cases and kept on the server (a stored id set, lib/id-set-store.ts); the cohort +// names it by its id, so the analysis and its link stay small however long the list is. Needs a +// sign-in, since the list is its saver's. The text stays in this component while it is typed. +import { useState } from "react"; +import { Plus } from "lucide-react"; +import type { CohortDef } from "../../lib/types.ts"; +import { cn } from "../../lib/cn"; +import { Button } from "../_ui/button"; +import { Choice } from "../_ui/choice"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../_ui/dialog"; +import { Input } from "../_ui/input"; +import { Tabs, TabsList, TabsTrigger } from "../_ui/tabs"; +import { Textarea } from "../_ui/textarea"; +import { TB } from "../_ui/toolbar.ts"; +import { api } from "./api.ts"; + +/** The reader's sign-in, as the places that save cohorts know it. */ +export type SignInState = "off" | "locked" | "loading" | "ready" | "error"; + +/** One id per line, or separated by commas, semicolons, tabs or spaces. */ +const splitIds = (text: string): string[] => text.split(/[\s,;]+/).map((x) => x.trim()).filter(Boolean); + +interface SavedList { + set: { id: string; name: string; level: "sample" | "patient"; studies: string[]; count: number }; + unmatched: number; unmatchedExamples: string[]; duplicates: number; multiStudy: number; + refused: { study: string; reason: string }[]; +} + +/** + * The paste, match and confirm steps. `studies` are those the ids are matched in (a bare id is + * looked up in each); `studyPicker`, when given, sits in front of the form to choose them. + */ +export function ListSource({ studies, studyPicker, saved, disabled, doneTitle, doneLabel, nameFor = (n) => n, onDone, onLogin }: { + studies: string[]; + studyPicker?: React.ReactNode; + saved: SignInState; + /** the final action is not possible now; `doneTitle` says why */ + disabled?: boolean; + doneTitle: (as: string) => string; + doneLabel: React.ReactNode; + /** the name the cohort takes where it goes, when that place already has a different one of this name */ + nameFor?: (name: string, def: CohortDef) => string; + onDone: (name: string, def: CohortDef) => void | Promise; + onLogin: () => void; +}) { + const [text, setText] = useState(""); + const [level, setLevel] = useState<"sample" | "patient">("sample"); + // no default: a list's name is how it is found again, in the analysis and the library + const [name, setName] = useState(""); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + const [done, setDone] = useState(null); + if (saved === "off") return

A cohort from a list of ids is kept on the server, which needs a sign-in, and this deployment has none.

; + if (saved === "locked") return

to make a cohort from a list of ids: the list is kept on the server, as yours.

; + const ids = splitIds(text); + const save = async () => { + setBusy(true); setErr(null); setDone(null); + try { + const res = await api.idSets.create({ name: name.trim(), level, studies, ids }); + const j = await res.json(); + if (!res.ok || j.error) throw new Error(j.error || res.statusText); + setDone(j); + } catch (e) { setErr(String((e as Error)?.message || e)); } + finally { setBusy(false); } + }; + const finish = async (as: string, d: CohortDef) => { + setBusy(true); setErr(null); + try { await onDone(as, d); } + catch (e) { setErr(String((e as Error)?.message || e)); } + finally { setBusy(false); } + }; + const def: CohortDef | null = done ? { + all: [{ kind: done.set.level === "sample" ? "sampleList" : "patientList", set: done.set.id, label: done.set.name, n: done.set.count }], + // a cohort names the studies it was matched in, so it means the same wherever it is used + ...(done.set.studies.length > 1 ? { studies: done.set.studies } : { study: done.set.studies[0] }), + } : null; + const as = def ? nameFor(done!.set.name, def) : name.trim(); + const noun = level === "sample" ? "sample" : "patient"; + const reset = () => setDone(null); + return ( +
+

+ Paste {noun} ids, one per line or separated by commas or spaces, as study:id or bare ids, which are looked + up in {studies.length ? studies.join(", ") : "the study you choose"}. They are matched to the {noun}s there, and the + cohort is those {noun}s{noun === "sample" ? "' patients" : ""}. +

+
+ {studyPicker &&
{studyPicker}
} +
+ ids are + { setLevel(v as "sample" | "patient"); reset(); }}> + + samples + patients + + +
+
+