From d607d84e4b8dbdee10f67b16b6081ad8cdbd0932 Mon Sep 17 00:00:00 2001 From: Panthevm Date: Thu, 21 May 2026 19:20:17 +0300 Subject: [PATCH] Notebooks: ViewDefinition + SQLQuery cell types - New cell type "view-definition": pick a ViewDefinition via ResourcePicker (kinds=["ViewDefinition"]), open it as a link (Title/Name/Id + description gray), Run hits POST /fhir/ViewDefinition//$run, response is decoded from Binary(base64) and shown as a table with dynamic columns. - New cell type "sql-query": same picker (kinds=["SQLQuery"]), Library opens as a link, Run hits POST /fhir/Library//$sqlquery-run with FHIR Parameters. Library.parameter[use=in] is rendered as uppercase-name + text input, values are persisted into cell.value (JSON with backward-compatible fallback to plain URL). - ResourcePicker accepts optional `kinds` and `placeholder`, so it can fetch only ViewDefinitions or only SQLQueries. - Editor: + dropdown gets ViewDefinition / SQLQuery entries, CellWrapper routes new types to the dedicated views, default description on a new notebook is "Description". --- .../SQLQueryBuilder/resource-picker.tsx | 48 +- src/components/notebook-editor.tsx | 29 +- src/routes/notebooks.$id.tsx | 612 ++++++++++++++++++ 3 files changed, 669 insertions(+), 20 deletions(-) diff --git a/src/components/SQLQueryBuilder/resource-picker.tsx b/src/components/SQLQueryBuilder/resource-picker.tsx index af335c9..6e6f41c 100644 --- a/src/components/SQLQueryBuilder/resource-picker.tsx +++ b/src/components/SQLQueryBuilder/resource-picker.tsx @@ -70,11 +70,16 @@ function useDebouncedValue(value: T, delay: number): T { return v; } -function useCandidates(search: string, enabled = true) { +function useCandidates( + search: string, + enabled = true, + kinds: CandidateKind[] = ["ViewDefinition", "SQLQuery"], +) { const client = useAidboxClient(); const debouncedSearch = useDebouncedValue(search, 200); + const kindsKey = kinds.slice().sort().join(","); return useQuery({ - queryKey: ["sqlquery-depends-on-candidates", debouncedSearch], + queryKey: ["sqlquery-depends-on-candidates", debouncedSearch, kindsKey], enabled, queryFn: async () => { const baseParams: Array<[string, string]> = [ @@ -83,20 +88,25 @@ function useCandidates(search: string, enabled = true) { ["_sort", "-_createdAt"], ]; if (debouncedSearch) baseParams.push(["_ilike", debouncedSearch]); + const want = (k: CandidateKind) => kinds.includes(k); const [vd, lib] = await Promise.all([ - client.request({ - method: "GET", - url: "/fhir/ViewDefinition", - params: baseParams, - }), - client.request({ - method: "GET", - url: "/fhir/Library", - params: [...baseParams, ["type", SQL_QUERY_TYPE_TOKEN]], - }), + want("ViewDefinition") + ? client.request({ + method: "GET", + url: "/fhir/ViewDefinition", + params: baseParams, + }) + : null, + want("SQLQuery") + ? client.request({ + method: "GET", + url: "/fhir/Library", + params: [...baseParams, ["type", SQL_QUERY_TYPE_TOKEN]], + }) + : null, ]); const out: CandidateOption[] = []; - if (vd.isOk()) { + if (vd?.isOk()) { for (const entry of vd.value.resource.entry ?? []) { const r = entry.resource as unknown as RawCandidate; out.push({ @@ -111,7 +121,7 @@ function useCandidates(search: string, enabled = true) { }); } } - if (lib.isOk()) { + if (lib?.isOk()) { for (const entry of lib.value.resource.entry ?? []) { const r = entry.resource as unknown as RawCandidate; const relatedArtifacts = (r.relatedArtifact ?? []).flatMap((ra) => @@ -144,15 +154,19 @@ export function ResourcePicker({ value, onChange, className, + kinds, + placeholder, }: { value: string | undefined; onChange: (reference: string) => void; className?: string; + kinds?: CandidateKind[]; + placeholder?: string; }) { const [open, setOpen] = React.useState(false); const [search, setSearch] = React.useState(""); - const { data: candidates = [] } = useCandidates(search, open); - const { data: allCandidates = [] } = useCandidates("", open); + const { data: candidates = [] } = useCandidates(search, open, kinds); + const { data: allCandidates = [] } = useCandidates("", open, kinds); const commandRef = React.useRef(null); const lookupByUrl = React.useMemo(() => { const map = new Map(); @@ -182,7 +196,7 @@ export function ResourcePicker({ onClick={(e) => e.stopPropagation()} > - {value || "Select view or query…"} + {value || placeholder || "Select view or query…"} diff --git a/src/components/notebook-editor.tsx b/src/components/notebook-editor.tsx index c77901f..e60afb1 100644 --- a/src/components/notebook-editor.tsx +++ b/src/components/notebook-editor.tsx @@ -12,10 +12,18 @@ import { normalizeMarkdown, RestCellView, SqlCellView, + SqlQueryCellView, + ViewDefinitionCellView, } from "../routes/notebooks.$id"; import { ConfirmDialog } from "./confirm-dialog"; -export type CellType = "rest" | "sql" | "markdown" | "rpc"; +export type CellType = + | "rest" + | "sql" + | "markdown" + | "rpc" + | "view-definition" + | "sql-query"; export type EditableCell = { id: string; @@ -36,9 +44,10 @@ export type EditableNotebook = { const CELL_TYPES: { value: CellType; label: string }[] = [ { value: "rest", label: "REST" }, - { value: "rpc", label: "RPC" }, { value: "sql", label: "SQL" }, { value: "markdown", label: "Markdown" }, + { value: "view-definition", label: "ViewDefinition" }, + { value: "sql-query", label: "SQLQuery" }, ]; const DEFAULT_CELL_VALUE: Record = { @@ -46,6 +55,8 @@ const DEFAULT_CELL_VALUE: Record = { rpc: "POST /rpc\ncontent-type: application/json\n\n{}", sql: "SELECT 1;", markdown: "", + "view-definition": "", + "sql-query": "", }; function genCellId(): string { @@ -57,7 +68,7 @@ function genCellId(): string { export function emptyNotebook(): EditableNotebook { return { name: "", - description: "", + description: "Description", cells: [], }; } @@ -170,6 +181,18 @@ function CellWrapper({ onValueChange={handleValue} onResultChange={handleResult} /> + ) : cell.type === "view-definition" ? ( + + ) : cell.type === "sql-query" ? ( + ) : cell.type === "markdown" ? ( ) : ( diff --git a/src/routes/notebooks.$id.tsx b/src/routes/notebooks.$id.tsx index 06b707d..0629d06 100644 --- a/src/routes/notebooks.$id.tsx +++ b/src/routes/notebooks.$id.tsx @@ -25,6 +25,7 @@ import { useAidboxClient } from "../AidboxClient"; import { ConfirmDialog } from "../components/confirm-dialog"; import { ResultContent } from "../components/db-console/result-content"; import { transformToQueryResultItems } from "../components/db-console/tables-view"; +import { ResourcePicker } from "../components/SQLQueryBuilder/resource-picker"; import { HTTP_STATUS_CODES } from "../shared/const"; import { prettyEdn } from "../utils/edn"; import type { QueryResultItem } from "../webmcp/db-console-context"; @@ -766,6 +767,611 @@ export function SqlCellView({ ); } +type ViewDefinitionResource = { + id: string; + url?: string; + name?: string; + title?: string; + description?: string; + resource?: string; +}; + +type ViewRunResult = + | { ok: true; rows: Record[] } + | { ok: false; error: string }; + +function useViewDefinitionByUrl(url: string | null) { + const client = useAidboxClient(); + return useQuery({ + queryKey: ["view-definition-by-url", url], + enabled: !!url, + staleTime: 60_000, + queryFn: async () => { + const resp = await client.rawRequest({ + method: "GET", + url: `/fhir/ViewDefinition?url=${encodeURIComponent(url ?? "")}&_count=1`, + }); + const json = (await resp.response.json()) as { + entry?: { resource: ViewDefinitionResource }[]; + }; + return json.entry?.[0]?.resource ?? null; + }, + }); +} + +export function ViewDefinitionCellView({ + cell, + onValueChange, + onResultChange, +}: { + cell: Cell; + onValueChange?: (value: string) => void; + onResultChange?: (result: unknown) => void; +}) { + const client = useAidboxClient(); + const editable = !!onValueChange; + const url = cell.value ?? ""; + const { data: vd, isLoading: vdLoading } = useViewDefinitionByUrl( + url ? url : null, + ); + const initial = (cell.result as ViewRunResult | null) ?? null; + const [result, setResult] = useState(initial); + const [loading, setLoading] = useState(false); + + const run = async () => { + if (!vd?.id) return; + setLoading(true); + try { + const resp = await client.rawRequest({ + method: "POST", + url: `/fhir/ViewDefinition/${vd.id}/$run`, + headers: { + "Content-Type": "application/json", + Accept: "application/fhir+json", + }, + body: JSON.stringify({ + resourceType: "Parameters", + parameter: [ + { name: "_format", valueCode: "json" }, + { name: "_limit", valueInteger: 1000 }, + { name: "_page", valueInteger: 1 }, + ], + }), + }); + const text = await resp.response.text(); + let next: ViewRunResult; + try { + const body = JSON.parse(text) as { + data?: string; + issue?: { diagnostics?: string }[]; + }; + if (body?.data) { + const decoded = atob(body.data); + const rows = JSON.parse(decoded) as Record[]; + next = { ok: true, rows }; + } else { + next = { + ok: false, + error: + body.issue?.map((i) => i.diagnostics).join("\n") ?? + "Empty response", + }; + } + } catch { + next = { ok: false, error: text }; + } + setResult(next); + onResultChange?.(next); + } finally { + setLoading(false); + } + }; + + const clear = () => { + setResult(null); + onResultChange?.(null); + }; + + const label = vd?.title ?? vd?.name ?? vd?.id ?? "(unknown view)"; + const columns = + result?.ok && result.rows.length > 0 + ? Array.from(new Set(result.rows.flatMap((r) => Object.keys(r)))) + : []; + + return ( +
+
+
+ + ViewDefinition + + {editable && ( + onValueChange?.(v)} + kinds={["ViewDefinition"]} + placeholder={url ? "Change…" : "Select view…"} + className={url ? "max-w-[200px]" : "max-w-[500px]"} + /> + )} +
+ +
+ {url && ( +
+ {vdLoading ? ( + + ) : vd?.id ? ( + + {label} + + ) : ( + + Not found: {url} + + )} + {vd?.description && ( + + {vd.description} + + )} +
+ )} + {result && ( + <> +
+ + Result + {result.ok ? ` (${result.rows.length})` : " — error"} + + {onResultChange && ( + + )} +
+ {result.ok ? ( + result.rows.length === 0 ? ( +
+ No rows. +
+ ) : ( +
+ + + + {columns.map((c) => ( + {c} + ))} + + + + {result.rows.map((row, i) => ( + + {columns.map((c) => ( + + {formatCellValue(row[c])} + + ))} + + ))} + + +
+ ) + ) : ( +
+							{result.error}
+						
+ )} + + )} +
+ ); +} + +function formatCellValue(v: unknown): string { + if (v === null || v === undefined) return ""; + if (typeof v === "object") return JSON.stringify(v); + return String(v); +} + +type LibraryParameter = { + name?: string; + type?: string; + use?: string; + min?: number; + max?: string; +}; + +type LibraryResource = { + id: string; + url?: string; + name?: string; + title?: string; + description?: string; + parameter?: LibraryParameter[]; +}; + +type SqlQueryRunResult = + | { ok: true; columns: string[]; rows: unknown[][] } + | { ok: false; error: string }; + +function useLibraryByUrl(url: string | null) { + const client = useAidboxClient(); + return useQuery({ + queryKey: ["sqlquery-library-by-url", url], + enabled: !!url, + staleTime: 60_000, + queryFn: async () => { + const resp = await client.rawRequest({ + method: "GET", + url: `/fhir/Library?url=${encodeURIComponent(url ?? "")}&_count=1`, + }); + const json = (await resp.response.json()) as { + entry?: { resource: LibraryResource }[]; + }; + return json.entry?.[0]?.resource ?? null; + }, + }); +} + +function buildParamValue( + value: string, + type: string | undefined, +): Record { + const t = (type ?? "string").toLowerCase(); + if (t === "integer" || t === "positiveint" || t === "unsignedint") { + const n = Number.parseInt(value, 10); + return { valueInteger: Number.isFinite(n) ? n : 0 }; + } + if (t === "decimal") { + const n = Number.parseFloat(value); + return { valueDecimal: Number.isFinite(n) ? n : 0 }; + } + if (t === "boolean") return { valueBoolean: value === "true" }; + if (t === "code") return { valueCode: value }; + return { valueString: value }; +} + +type ParamPart = { name?: string; [k: string]: unknown }; +type ParamsResp = { + parameter?: { name?: string; part?: ParamPart[] }[]; + issue?: { diagnostics?: string }[]; +}; + +function getPartValue(part: ParamPart): unknown { + for (const key of Object.keys(part)) { + if (key.startsWith("value")) return part[key]; + } + return null; +} + +function parseSqlQueryResponse(body: ParamsResp): SqlQueryRunResult { + if (body.issue?.length) + return { + ok: false, + error: body.issue.map((i) => i.diagnostics).join("\n"), + }; + const rowParams = (body.parameter ?? []).filter((p) => p.name === "row"); + const columns: string[] = []; + const seen = new Set(); + for (const rp of rowParams) { + for (const part of rp.part ?? []) { + if (part.name && !seen.has(part.name)) { + seen.add(part.name); + columns.push(part.name); + } + } + } + const rows = rowParams.map((rp) => { + const byName = new Map(); + for (const part of rp.part ?? []) { + if (part.name) byName.set(part.name, getPartValue(part)); + } + return columns.map((c) => byName.get(c) ?? null); + }); + return { ok: true, columns, rows }; +} + +function parseSqlQueryCellValue(raw: string): { + url: string; + params: Record; +} { + const trimmed = (raw ?? "").trim(); + if (!trimmed) return { url: "", params: {} }; + if (trimmed.startsWith("{")) { + try { + const obj = JSON.parse(trimmed) as { + url?: string; + params?: Record; + }; + return { url: obj.url ?? "", params: obj.params ?? {} }; + } catch { + return { url: trimmed, params: {} }; + } + } + return { url: trimmed, params: {} }; +} + +function stringifySqlQueryCellValue( + url: string, + params: Record, +): string { + const nonEmpty: Record = {}; + for (const [k, v] of Object.entries(params)) { + if (v !== "") nonEmpty[k] = v; + } + if (Object.keys(nonEmpty).length === 0) return url; + return JSON.stringify({ url, params: nonEmpty }); +} + +export function SqlQueryCellView({ + cell, + onValueChange, + onResultChange, +}: { + cell: Cell; + onValueChange?: (value: string) => void; + onResultChange?: (result: unknown) => void; +}) { + const client = useAidboxClient(); + const editable = !!onValueChange; + const parsed = parseSqlQueryCellValue(cell.value ?? ""); + const url = parsed.url; + const { data: lib, isLoading: libLoading } = useLibraryByUrl( + url ? url : null, + ); + const inputParams = (lib?.parameter ?? []).filter( + (p) => (p.use ?? "in") === "in" && p.name, + ); + const [paramValues, setParamValues] = useState>( + parsed.params, + ); + const initial = (cell.result as SqlQueryRunResult | null) ?? null; + const [result, setResult] = useState(initial); + const [loading, setLoading] = useState(false); + + const run = async () => { + if (!lib?.id) return; + setLoading(true); + try { + const parameterEntries: Record[] = [ + { name: "_format", valueCode: "fhir" }, + ]; + const filled = inputParams + .filter((p) => p.name && paramValues[p.name]) + .map((p) => ({ + name: p.name, + ...buildParamValue(paramValues[p.name ?? ""] ?? "", p.type), + })); + if (filled.length > 0) { + parameterEntries.push({ + name: "parameters", + resource: { resourceType: "Parameters", parameter: filled }, + }); + } + const resp = await client.rawRequest({ + method: "POST", + url: `/fhir/Library/${lib.id}/$sqlquery-run`, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + resourceType: "Parameters", + parameter: parameterEntries, + }), + }); + const text = await resp.response.text(); + let next: SqlQueryRunResult; + try { + const body = JSON.parse(text) as ParamsResp; + next = parseSqlQueryResponse(body); + } catch { + next = { ok: false, error: text }; + } + setResult(next); + onResultChange?.(next); + } finally { + setLoading(false); + } + }; + + const clear = () => { + setResult(null); + onResultChange?.(null); + }; + + const label = lib?.title ?? lib?.name ?? lib?.id ?? "(unknown query)"; + + return ( +
+
+
+ + SQLQuery + + {editable && ( + { + setParamValues({}); + onValueChange?.(v); + }} + kinds={["SQLQuery"]} + placeholder={url ? "Change…" : "Select query…"} + className={url ? "max-w-[200px]" : "max-w-[500px]"} + /> + )} +
+ +
+ {url && ( +
+ {libLoading ? ( + + ) : lib?.id ? ( + + {label} + + ) : ( + + Not found: {url} + + )} + {lib?.description && ( + + {lib.description} + + )} +
+ )} + {inputParams.length > 0 && ( +
+ + Parameters + + {inputParams.map((p) => { + const key = p.name ?? ""; + return ( +
+ + { + const next = { + ...paramValues, + [key]: e.target.value, + }; + setParamValues(next); + onValueChange?.(stringifySqlQueryCellValue(url, next)); + }} + placeholder={p.type ?? "string"} + className="w-full px-2 py-1 typo-code border border-border-default rounded text-text-primary bg-bg-primary outline-none focus:border-border-info-primary" + /> +
+ ); + })} +
+ )} + {result && ( + <> +
+ + Result + {result.ok ? ` (${result.rows.length})` : " — error"} + + {onResultChange && ( + + )} +
+ {result.ok ? ( + result.rows.length === 0 ? ( +
+ No rows. +
+ ) : ( +
+ + + + {result.columns.map((c) => ( + {c} + ))} + + + + {result.rows.map((row, i) => ( + + {row.map((cellVal, j) => ( + + {formatCellValue(cellVal)} + + ))} + + ))} + + +
+ ) + ) : ( +
+							{result.error}
+						
+ )} + + )} +
+ ); +} + function CellView({ cell }: { cell: Cell }) { const type = cell.type ?? "rest"; if (type === "rest" || type === "rpc") { @@ -774,6 +1380,12 @@ function CellView({ cell }: { cell: Cell }) { if (type === "sql") { return ; } + if (type === "view-definition") { + return ; + } + if (type === "sql-query") { + return ; + } if (type === "markdown") { return (