diff --git a/package.json b/package.json index b060b85..d872e09 100644 --- a/package.json +++ b/package.json @@ -13,11 +13,13 @@ }, "dependencies": { "@hey-api/openapi-ts": "^0.80.10", + "@monaco-editor/react": "^4.7.0", "@tailwindcss/typography": "^0.5.16", "@tailwindcss/vite": "^4.0.9", "axios": "^1.11.0", "classnames": "^2.5.1", "flowbite-react": "^0.12.7", + "monaco-editor": "^0.55.1", "react": "^19.0.0", "react-dom": "^19.0.0", "react-icons": "^5.5.0", diff --git a/src/App.tsx b/src/App.tsx index a15168c..81bc457 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -39,6 +39,7 @@ function App() { } /> } /> } /> + } /> } diff --git a/src/components/catalog/CatalogSqlPanel.tsx b/src/components/catalog/CatalogSqlPanel.tsx new file mode 100644 index 0000000..5813891 --- /dev/null +++ b/src/components/catalog/CatalogSqlPanel.tsx @@ -0,0 +1,130 @@ +import { FormEvent, ReactElement, useState } from "react"; +import { Link as RouterLink } from "react-router-dom"; +import type { + TapSchemaEntry, + TapSyncResponse, +} from "../../clients/backend/types.gen"; +import { executeSqlQuery, syncPayloadToTable } from "../../lib/tap"; +import { Button } from "../core/Button"; +import { Text } from "../core/Text"; +import { Loading } from "../core/Loading"; +import { CommonTable } from "../ui/CommonTable"; +import { SqlEditor } from "./SqlEditor"; + +function runQueryShortcutLabel(): string { + if (typeof navigator === "undefined") { + return "Ctrl+Enter"; + } + return /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? "⌘↵" : "Ctrl+Enter"; +} + +interface CatalogSqlPanelProps { + sql: string; + onSqlChange: (sql: string) => void; + schemas?: TapSchemaEntry[]; + loggedIn: boolean; +} + +export function CatalogSqlPanel({ + sql, + onSqlChange, + schemas, + loggedIn, +}: CatalogSqlPanelProps): ReactElement { + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const runShortcut = runQueryShortcutLabel(); + + async function runQuery(): Promise { + if (!loggedIn || loading) { + return; + } + const trimmed = sql.trim(); + if (!trimmed) { + setError("Enter a SQL query to run."); + setResult(null); + return; + } + + setLoading(true); + setError(null); + setResult(null); + + try { + const payload = await executeSqlQuery(trimmed); + setResult(payload); + } catch (runError) { + setError(`${runError}`); + } finally { + setLoading(false); + } + } + + async function handleSubmit( + event: FormEvent, + ): Promise { + event.preventDefault(); + await runQuery(); + } + + const tableData = result ? syncPayloadToTable(result) : null; + const rowCount = tableData?.rows.length ?? 0; + + if (!loggedIn) { + return ( +
+ + Log in to run SQL queries + + + + Sign in + {" "} + to execute queries via TAP /sync. + +
+ ); + } + + return ( +
+
+ +
+ +
+ {error ? ( +
+ + Query failed + + + {error} + +
+ ) : null} + {loading ? : null} + + + {tableData ? ( + + + {rowCount === 1 ? "1 row" : `${rowCount} rows`} + + + ) : null} +
+ ); +} diff --git a/src/components/catalog/CatalogViewTabs.tsx b/src/components/catalog/CatalogViewTabs.tsx new file mode 100644 index 0000000..c4075a7 --- /dev/null +++ b/src/components/catalog/CatalogViewTabs.tsx @@ -0,0 +1,25 @@ +import { ReactElement } from "react"; +import { NavLink } from "react-router-dom"; +import classNames from "classnames"; + +function catalogTabClassName({ isActive }: { isActive: boolean }): string { + return classNames( + "px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors", + isActive + ? "border-accent text-primary" + : "border-transparent text-muted hover:text-primary hover:border-border", + ); +} + +export function CatalogViewTabs(): ReactElement { + return ( + + ); +} diff --git a/src/components/catalog/SqlEditor.tsx b/src/components/catalog/SqlEditor.tsx new file mode 100644 index 0000000..9734adb --- /dev/null +++ b/src/components/catalog/SqlEditor.tsx @@ -0,0 +1,141 @@ +import { ReactElement, useEffect, useMemo, useRef, useState } from "react"; +import Editor from "@monaco-editor/react"; +import type * as Monaco from "monaco-editor"; +import type { TapSchemaEntry } from "../../clients/backend/types.gen"; +import { useTheme } from "../../hooks/useTheme"; + +type CatalogCompletionTemplate = Omit; + +function buildCompletionItems( + monaco: typeof Monaco, + schemas: TapSchemaEntry[] | undefined, +): CatalogCompletionTemplate[] { + const items: CatalogCompletionTemplate[] = []; + for (const schema of schemas ?? []) { + for (const table of schema.tables) { + items.push({ + label: table.name, + kind: monaco.languages.CompletionItemKind.Class, + insertText: table.name, + detail: schema.schema_name, + documentation: table.description ?? undefined, + }); + for (const column of table.columns ?? []) { + items.push({ + label: column.name, + kind: monaco.languages.CompletionItemKind.Field, + insertText: column.name, + detail: `${table.name}.${column.name}`, + documentation: column.description ?? column.datatype, + }); + } + } + } + return items; +} + +interface SqlEditorProps { + value: string; + onChange: (value: string) => void; + schemas?: TapSchemaEntry[]; + disabled?: boolean; + height?: string; + onRunQuery?: () => void; +} + +export function SqlEditor({ + value, + onChange, + schemas, + disabled = false, + height = "280px", + onRunQuery, +}: SqlEditorProps): ReactElement { + const { effectiveTheme } = useTheme(); + const monacoRef = useRef(null); + const providerRef = useRef(null); + const onRunQueryRef = useRef(onRunQuery); + const [editorReady, setEditorReady] = useState(false); + + onRunQueryRef.current = onRunQuery; + + useEffect(() => { + const monaco = monacoRef.current; + if (monaco && editorReady) { + providerRef.current?.dispose(); + const catalogItems = buildCompletionItems(monaco, schemas); + + providerRef.current = monaco.languages.registerCompletionItemProvider( + "sql", + { + triggerCharacters: [" ", ".", ",", "("], + provideCompletionItems: ( + model: Monaco.editor.ITextModel, + position: Monaco.Position, + ) => { + const word = model.getWordUntilPosition(position); + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; + const prefix = word.word.toLowerCase(); + const suggestions = catalogItems + .filter( + (item) => + !prefix || + String(item.label).toLowerCase().startsWith(prefix), + ) + .map((item) => ({ ...item, range })); + + return { suggestions }; + }, + }, + ); + } + + return () => { + providerRef.current?.dispose(); + providerRef.current = null; + }; + }, [schemas, editorReady]); + + const editorOptions = useMemo( + () => ({ + readOnly: disabled, + minimap: { enabled: false }, + fontSize: 14, + scrollBeyondLastLine: false, + wordWrap: "on" as const, + automaticLayout: true, + tabSize: 2, + suggestOnTriggerCharacters: true, + quickSuggestions: true, + }), + [disabled], + ); + + return ( +
+ onChange(next ?? "")} + onMount={(editor, monaco) => { + monacoRef.current = monaco; + setEditorReady(true); + editor.addCommand( + monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, + () => { + onRunQueryRef.current?.(); + }, + ); + }} + options={editorOptions} + /> +
+ ); +} diff --git a/src/lib/tap.ts b/src/lib/tap.ts new file mode 100644 index 0000000..0c35340 --- /dev/null +++ b/src/lib/tap.ts @@ -0,0 +1,63 @@ +import { tapSync } from "../clients/backend/sdk.gen"; +import type { + TapSyncResponse, + ValidationError, +} from "../clients/backend/types.gen"; +import { backendClient } from "../clients/config"; +import type { CellPrimitive, Column } from "../components/ui/CommonTable"; + +export const DEFAULT_SQL_EXAMPLE = + "SELECT * FROM layer2.designations WHERE pgc = 67872"; + +export function formatApiError(error: unknown): string { + const detail = (error as { detail?: ValidationError[] }).detail; + if (detail?.length) { + return detail.map((e) => e.msg).join(", "); + } + return JSON.stringify(error); +} + +export async function executeSqlQuery(sql: string): Promise { + const response = await tapSync({ + client: backendClient, + query: { query: sql }, + }); + if (response.error) { + throw new Error(formatApiError(response.error)); + } + if (!response.data?.data) { + throw new Error("No data received from server"); + } + return response.data.data; +} + +export function cellValue(value: unknown): CellPrimitive { + if (value === null || value === undefined) { + return "—"; + } + if (typeof value === "number") { + return value; + } + return String(value); +} + +export function syncPayloadToTable(payload: TapSyncResponse): { + columns: Column[]; + rows: Record[]; +} { + const syncTable = payload.resource.table; + const syncColumns = syncTable.columns; + const columns: Column[] = syncColumns.map((c) => ({ name: c.name })); + const rows = (syncTable.data ?? []).map((row) => { + const out: Record = {}; + for (let i = 0; i < syncColumns.length; i++) { + out[syncColumns[i].name] = cellValue(row[i]); + } + return out; + }); + return { columns, rows }; +} + +export function defaultSelectForTable(tableName: string, limit = 25): string { + return `SELECT * FROM ${tableName} LIMIT ${limit}`; +} diff --git a/src/pages/DataCatalog.tsx b/src/pages/DataCatalog.tsx index 4ae87d4..6170163 100644 --- a/src/pages/DataCatalog.tsx +++ b/src/pages/DataCatalog.tsx @@ -1,5 +1,5 @@ import { ReactElement, useEffect, useMemo, useState } from "react"; -import { useNavigate, useParams } from "react-router-dom"; +import { useMatch, useNavigate, useParams } from "react-router-dom"; import { tapSync, tapTables } from "../clients/backend/sdk.gen"; import type { ListTapTablesResponse, @@ -7,7 +7,6 @@ import type { TapSchemaEntry, TapSyncResponse, TapTableInfo, - ValidationError, } from "../clients/backend/types.gen"; import { backendClient } from "../clients/config"; import { isLoggedIn } from "../auth/token"; @@ -22,15 +21,16 @@ import { import { TextFilter } from "../components/core/TextFilter"; import { Accordion } from "../components/core/Accordion"; import { Text } from "../components/core/Text"; +import { Button } from "../components/core/Button"; import classNames from "classnames"; - -function formatApiError(error: unknown): string { - const detail = (error as { detail?: ValidationError[] }).detail; - if (detail?.length) { - return detail.map((e) => e.msg).join(", "); - } - return JSON.stringify(error); -} +import { CatalogViewTabs } from "../components/catalog/CatalogViewTabs"; +import { CatalogSqlPanel } from "../components/catalog/CatalogSqlPanel"; +import { + cellValue, + DEFAULT_SQL_EXAMPLE, + defaultSelectForTable, + formatApiError, +} from "../lib/tap"; async function fetchTablesList(): Promise { const response = await tapTables({ @@ -94,16 +94,6 @@ function filterSchemas( .filter((s) => s.tables.length > 0); } -function cellValue(value: unknown): CellPrimitive { - if (value === null || value === undefined) { - return "—"; - } - if (typeof value === "number") { - return value; - } - return String(value); -} - interface SchemaSidebarProps { schemas: TapSchemaEntry[]; selectedSchema: string | null; @@ -196,15 +186,23 @@ function columnMetadataHint(column: TapColumnInfo): ReactElement { const catalogPanelClassName = "rounded-lg border border-dashed border-border p-8 text-center"; -function CatalogBrowsePrompt(): ReactElement { +function CatalogBrowsePrompt({ + onOpenSql, +}: { + onOpenSql: () => void; +}): ReactElement { return (
Browse the data - Choose a table on the left to see column definitions and sample rows + Choose a table on the left to see column definitions and sample rows, or + run a custom query in the SQL editor. +
); } @@ -228,6 +226,7 @@ interface TableDetailProps { syncLoading: boolean; syncError: string | null; loggedIn: boolean; + onOpenSql: () => void; } function TableDetail({ @@ -236,6 +235,7 @@ function TableDetail({ syncLoading, syncError, loggedIn, + onOpenSql, }: TableDetailProps): ReactElement { const metadataColumns = tableInfo.columns ?? []; const syncTable = syncPayload?.resource.table; @@ -266,19 +266,24 @@ function TableDetail({ return (
-
- - {tableInfo.description ?? ( - +
+
+ + {tableInfo.description ?? ( + + {tableInfo.name} + + )} + + {tableInfo.description ? ( + {tableInfo.name} - )} - - {tableInfo.description ? ( - - {tableInfo.name} - - ) : null} + ) : null} +
+
{!loggedIn ? ( @@ -304,15 +309,28 @@ export function DataCatalogPage(): ReactElement { tableName?: string; }>(); const navigate = useNavigate(); + const isQueryMode = Boolean(useMatch("/data-catalog/query")); const [filter, setFilter] = useState(""); + const [sqlDraft, setSqlDraft] = useState(DEFAULT_SQL_EXAMPLE); const loggedIn = isLoggedIn(); - const selectedSchema = schemaName ?? null; - const selectedTable = tableName ?? null; + const [sqlSidebarSelection, setSqlSidebarSelection] = useState<{ + schema: string; + table: string; + } | null>(null); + + const selectedSchema = isQueryMode + ? (sqlSidebarSelection?.schema ?? null) + : (schemaName ?? null); + const selectedTable = isQueryMode + ? (sqlSidebarSelection?.table ?? null) + : (tableName ?? null); useEffect(() => { - document.title = "Data catalog | HyperLEDA"; - }, []); + document.title = isQueryMode + ? "SQL query | HyperLEDA" + : "Data catalog | HyperLEDA"; + }, [isQueryMode]); const { data: tablesPayload, @@ -343,13 +361,25 @@ export function DataCatalogPage(): ReactElement { return findTableInfo(tablesPayload?.schemas, selectedSchema, selectedTable); }, [tablesPayload?.schemas, selectedSchema, selectedTable]); + function openSqlEditor(sql?: string): void { + if (sql) { + setSqlDraft(sql); + } + navigate("/data-catalog/query"); + } + function handleSelect(nextSchema: string, nextTable: string): void { + if (isQueryMode) { + setSqlSidebarSelection({ schema: nextSchema, table: nextTable }); + setSqlDraft(defaultSelectForTable(nextTable)); + return; + } navigate( `/data-catalog/${encodeURIComponent(nextSchema)}/${encodeURIComponent(nextTable)}`, ); } - function SidebarContent(): ReactElement { + function renderSidebarContent(): ReactElement { if (tablesError && !tablesPayload) { return ; } @@ -375,9 +405,24 @@ export function DataCatalogPage(): ReactElement { ); } - function DetailContent(): ReactElement { + function renderDetailContent(): ReactElement { + if (isQueryMode) { + return ( + + ); + } + if (!selectedSchema || !selectedTable) { - return loggedIn ? : ; + return loggedIn ? ( + openSqlEditor()} /> + ) : ( + + ); } if (tablesError && !tablesPayload) { @@ -404,6 +449,7 @@ export function DataCatalogPage(): ReactElement { syncLoading={loggedIn && syncLoading} syncError={loggedIn ? syncError : null} loggedIn={loggedIn} + onOpenSql={() => openSqlEditor(defaultSelectForTable(selectedTable))} /> ); } @@ -421,11 +467,12 @@ export function DataCatalogPage(): ReactElement { />
- + {renderSidebarContent()}
- + + {renderDetailContent()}