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
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ function App() {
<Route path="/table/:tableName" element={<TableDetailsPage />} />
<Route path="/tables" element={<TablesPage />} />
<Route path="/data-catalog" element={<DataCatalogPage />} />
<Route path="/data-catalog/query" element={<DataCatalogPage />} />
<Route
path="/data-catalog/:schemaName/:tableName"
element={<DataCatalogPage />}
Expand Down
130 changes: 130 additions & 0 deletions src/components/catalog/CatalogSqlPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<TapSyncResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const runShortcut = runQueryShortcutLabel();

async function runQuery(): Promise<void> {
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<HTMLFormElement>,
): Promise<void> {
event.preventDefault();
await runQuery();
}

const tableData = result ? syncPayloadToTable(result) : null;
const rowCount = tableData?.rows.length ?? 0;

if (!loggedIn) {
return (
<div className="rounded-lg border border-dashed border-border p-8 text-center">
<Text as="p" size="large">
Log in to run SQL queries
</Text>
<Text as="p" className="mt-2">
<RouterLink to="/login" className="text-accent hover:underline">
Sign in
</RouterLink>{" "}
to execute queries via TAP /sync.
</Text>
</div>
);
}

return (
<div className="flex flex-col gap-4">
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
<SqlEditor
value={sql}
onChange={onSqlChange}
schemas={schemas}
disabled={loading}
onRunQuery={runQuery}
/>
<div className="flex flex-wrap items-center gap-3">
<Button type="submit" disabled={loading}>
{loading ? "Running…" : `Run query (${runShortcut})`}
</Button>
</div>
{error ? (
<div
role="alert"
className="rounded-lg border border-danger/40 bg-danger/10 px-4 py-3 text-left"
>
<Text as="p" style="header" size="small">
Query failed
</Text>
<Text as="p" className="mt-1 text-danger">
{error}
</Text>
</div>
) : null}
{loading ? <Loading /> : null}
</form>

{tableData ? (
<CommonTable columns={tableData.columns} data={tableData.rows}>
<Text style="header" size="small">
{rowCount === 1 ? "1 row" : `${rowCount} rows`}
</Text>
</CommonTable>
) : null}
</div>
);
}
25 changes: 25 additions & 0 deletions src/components/catalog/CatalogViewTabs.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<nav className="flex gap-1 border-b border-border mb-4">
<NavLink to="/data-catalog" end className={catalogTabClassName}>
Browse tables
</NavLink>
<NavLink to="/data-catalog/query" className={catalogTabClassName}>
SQL query
</NavLink>
</nav>
);
}
141 changes: 141 additions & 0 deletions src/components/catalog/SqlEditor.tsx
Original file line number Diff line number Diff line change
@@ -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<Monaco.languages.CompletionItem, "range">;

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<typeof Monaco | null>(null);
const providerRef = useRef<Monaco.IDisposable | null>(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 (
<div className="rounded-lg border border-border overflow-hidden">
<Editor
height={height}
language="sql"
theme={effectiveTheme === "dark" ? "vs-dark" : "vs"}
value={value}
onChange={(next) => onChange(next ?? "")}
onMount={(editor, monaco) => {
monacoRef.current = monaco;
setEditorReady(true);
editor.addCommand(
monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter,
() => {
onRunQueryRef.current?.();
},
);
}}
options={editorOptions}
/>
</div>
);
}
63 changes: 63 additions & 0 deletions src/lib/tap.ts
Original file line number Diff line number Diff line change
@@ -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<TapSyncResponse> {
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<string, CellPrimitive>[];
} {
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<string, CellPrimitive> = {};
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}`;
}
Loading
Loading