| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { useCallback, useEffect, useState } from "react"; |
| import { VIEW_OPEN_EVENT, signal } from "../apiContract"; |
| import { databaseEntries } from "../shell/nav"; |
| import type { NavEntry } from "../shell/nav"; |
| import { buildQuery, deleteQuery, fetchQueries, saveQuery } from "./queryApi"; |
| import type { BuildResult, SavedQuery } from "./queryApi"; |
| import "./query.css"; |
|
|
| export interface QueryPageProps { |
| |
| |
| |
| |
| |
| |
| |
| granted: NavEntry[]; |
| } |
|
|
| |
| const KIND_LABEL: Record<string, string> = { |
| grid: "Table", |
| list: "List", |
| chart: "Chart", |
| kanban: "Board", |
| calendar: "Calendar", |
| timeseries: "Time series", |
| map: "Map", |
| }; |
|
|
| |
| |
| |
| |
| |
| export const OPEN_RETRIES = [0, 250, 700, 1500, 2600] as const; |
|
|
| export function openSavedView(scope: string, viewId: string): void { |
| if (typeof window === "undefined") return; |
| if (window.location.hash.replace(/^#\/?/, "") !== scope) { |
| window.location.hash = `#/${scope}`; |
| } |
| for (const delay of OPEN_RETRIES) { |
| window.setTimeout(() => signal(VIEW_OPEN_EVENT, { topic: scope, viewId }), delay); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function QueryAnswer({ result, saving, onKeep, onDiscard }: { |
| result: BuildResult; |
| saving?: boolean; |
| onKeep: () => void; |
| onDiscard: () => void; |
| }) { |
| if (result.refused) { |
| return ( |
| <section className="qy-answer is-refused" role="status"> |
| <p className="qy-answer-head">That is not a view this database can draw</p> |
| <p className="qy-answer-body">{result.refused}</p> |
| </section> |
| ); |
| } |
| if (!result.spec) return null; |
| return ( |
| <section className="qy-answer"> |
| <p className="qy-answer-head">Here is what that view would do</p> |
| {/* ⛔ THE READ-BACK. Derived by the SERVER from the spec it accepted, so it |
| describes what will be built rather than what the model meant. */} |
| {result.explain ? <p className="qy-answer-body">{result.explain}</p> : null} |
| <div className="qy-answer-acts"> |
| <button type="button" className="qy-btn qy-btn--primary" disabled={saving} |
| onClick={onKeep}> |
| {saving ? "Saving…" : "Keep this view"} |
| </button> |
| <button type="button" className="qy-btn" onClick={onDiscard}> |
| Discard |
| </button> |
| </div> |
| </section> |
| ); |
| } |
|
|
| |
| export function SavedQueryRow({ view, dbLabel, onOpen, onDelete }: { |
| view: SavedQuery; |
| dbLabel: string; |
| onOpen: () => void; |
| onDelete: () => void; |
| }) { |
| return ( |
| <li className="qy-row"> |
| <div className="qy-row-main"> |
| <button type="button" className="qy-row-open" onClick={onOpen}> |
| {view.name} |
| </button> |
| <span className="qy-row-meta"> |
| {KIND_LABEL[view.kind] ?? view.kind} · {dbLabel} |
| </span> |
| </div> |
| {view.question ? <p className="qy-row-q">“{view.question}”</p> : null} |
| {view.explain ? <p className="qy-row-explain">{view.explain}</p> : null} |
| <div className="qy-row-acts"> |
| <button type="button" className="qy-btn" onClick={onOpen}> |
| Open |
| </button> |
| <button type="button" className="qy-btn qy-btn--quiet" onClick={onDelete}> |
| Delete |
| </button> |
| </div> |
| </li> |
| ); |
| } |
|
|
| export default function QueryPage({ granted }: QueryPageProps) { |
| const [scope, setScope] = useState<string>(""); |
| const [question, setQuestion] = useState(""); |
| const [busy, setBusy] = useState(false); |
| const [result, setResult] = useState<BuildResult | null>(null); |
| const [problem, setProblem] = useState(""); |
| const [saved, setSaved] = useState<SavedQuery[]>([]); |
| const [builtins, setBuiltins] = useState<string[]>([]); |
| const [saving, setSaving] = useState(false); |
| const [loaded, setLoaded] = useState(false); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const databases = databaseEntries(granted).filter( |
| (e) => e.kind !== "group" && (e.key.startsWith("ut_") || builtins.includes(e.key)) |
| ); |
|
|
| |
| |
| |
| useEffect(() => { |
| if (databases.length && !databases.some((d) => d.key === scope)) { |
| setScope(databases[0].key); |
| } |
| }, [databases, scope]); |
|
|
| const reload = useCallback(async () => { |
| const res = await fetchQueries(); |
| setLoaded(true); |
| if (res.ok) { |
| setSaved(res.value.views); |
| setBuiltins(res.value.builtins); |
| } |
| }, []); |
|
|
| useEffect(() => { |
| void reload(); |
| }, [reload]); |
|
|
| const ask = useCallback(async () => { |
| const q = question.trim(); |
| if (!q || !scope || busy) return; |
| setBusy(true); |
| setProblem(""); |
| setResult(null); |
| const res = await buildQuery(q, scope); |
| setBusy(false); |
| if (!res.ok) { |
| setProblem(res.message); |
| return; |
| } |
| setResult(res.value); |
| }, [busy, question, scope]); |
|
|
| const keep = useCallback(async () => { |
| if (!result?.spec || saving) return; |
| setSaving(true); |
| const res = await saveQuery(question.trim(), scope, result.spec); |
| setSaving(false); |
| if (!res.ok) { |
| setProblem(res.message); |
| return; |
| } |
| setResult(null); |
| setQuestion(""); |
| await reload(); |
| }, [question, reload, result, saving, scope]); |
|
|
| const forget = useCallback(async (id: string) => { |
| const res = await deleteQuery(id); |
| if (!res.ok) { |
| setProblem(res.message); |
| return; |
| } |
| await reload(); |
| }, [reload]); |
|
|
| const labelOf = (key: string) => |
| databases.find((d) => d.key === key)?.label ?? key; |
|
|
| return ( |
| <div className="qy-page"> |
| <h1 className="qy-title">Query</h1> |
| <p className="qy-lede"> |
| Ask about one of your databases. You get a view you can read before you keep it — |
| and a plain answer when the question is not one this product can draw. |
| </p> |
| |
| {databases.length === 0 ? ( |
| // ⚠ TWO DIFFERENT FACTS, TWO DIFFERENT SENTENCES. Before the index arrives the offerable |
| // set is deliberately narrow (see `databases`), so "you have no database" would be a claim |
| // this page cannot yet make. |
| <p className="qy-note"> |
| {loaded |
| ? "You do not have a database to ask about yet. Create one from the Database menu, " |
| + "then come back and ask about it." |
| : "Loading your databases…"} |
| </p> |
| ) : ( |
| <section className="qy-ask"> |
| <label className="qy-field"> |
| <span className="qy-label">Database</span> |
| <select |
| className="qy-select" |
| value={scope} |
| onChange={(e) => setScope(e.currentTarget.value)} |
| > |
| {databases.map((d) => ( |
| <option key={d.key} value={d.key}> |
| {d.label} |
| </option> |
| ))} |
| </select> |
| </label> |
| <label className="qy-field qy-field--grow"> |
| <span className="qy-label">Question</span> |
| <input |
| className="qy-input" |
| value={question} |
| placeholder="Everyone with more than 50,000 followers, biggest first" |
| onChange={(e) => setQuestion(e.currentTarget.value)} |
| onKeyDown={(e) => { |
| if (e.key === "Enter") void ask(); |
| }} |
| /> |
| </label> |
| <button |
| type="button" |
| className="qy-btn qy-btn--primary" |
| disabled={busy || !question.trim()} |
| onClick={() => void ask()} |
| > |
| {busy ? "Working…" : "Build a view"} |
| </button> |
| </section> |
| )} |
| |
| {problem ? ( |
| <p className="qy-note is-warn" role="status"> |
| {problem} |
| </p> |
| ) : null} |
| |
| {/* A REFUSAL IS AN ANSWER, and it gets the same weight as a success — a sentence, |
| never an error page (R1's own words). Both states live in `QueryAnswer`. */} |
| {result ? ( |
| <QueryAnswer |
| result={result} |
| saving={saving} |
| onKeep={() => void keep()} |
| onDiscard={() => setResult(null)} |
| /> |
| ) : null} |
| |
| <h2 className="qy-sub">Your views</h2> |
| {!loaded ? ( |
| <p className="qy-note"> |
| <span className="lp-spin" role="status" aria-label="Loading" /> |
| </p> |
| ) : saved.length === 0 ? ( |
| <p className="qy-note"> |
| Nothing yet. A view you keep lands here, and in the database it was built from. |
| </p> |
| ) : ( |
| <ul className="qy-list"> |
| {saved.map((v) => ( |
| <SavedQueryRow |
| key={v.id} |
| view={v} |
| dbLabel={labelOf(v.scope)} |
| onOpen={() => openSavedView(v.scope, v.viewId)} |
| onDelete={() => void forget(v.id)} |
| /> |
| ))} |
| </ul> |
| )} |
| </div> |
| ); |
| } |
|
|